Warm styleguide homepage, JSON post feed from n8n, daily sync
- css/site.css: oat/espresso palette, Fraunces display, rounded surfaces, warm photo grade - index.html: Module of the Month replaced by the blog; tips, blog and community sections render from data/posts.json - js/feed.js: client-side feed + search over one manifest (blog/index.json technique) - scripts/sync_posts.py: daily webhook fetch, image download, dedupe by id and content hash, non-image attachments skipped - data/posts.json: manifest, one published post (Odoo 20 Community review)
This commit is contained in:
1 parent
a46e513f50
commit
77d3f00072
8 files changed
+1087
-347
No files matched your search
+3
-1
@@ -42,7 +42,9 @@ ODOO Community is the best kept secret in business software — a full-featured,
|
|||||||
- Name: **The OC Guy**
|
- Name: **The OC Guy**
|
||||||
- Tagline: **ODOO Community Advocate**
|
- Tagline: **ODOO Community Advocate**
|
||||||
- Creator: Oliver Arnold / 安雷威
|
- Creator: Oliver Arnold / 安雷威
|
||||||
- Visual identity from existing video assets: dark background (`#0a0a0f`), purple (`#9C27B0`), cyan (`#00E6FF`), glitch/scanline effects, tech-cyberpunk aesthetic
|
- Visual identity: warm editorial. Oat ground `#F2E9D6`, espresso ink `#3B2619`, warm sand `#D9C7A5`, honey oak `#AC8050`, dusty navy `#29444B`, muted olive `#747552`, terracotta accent `#B76D48`. Chunky rounded display type (Fraunces), soft rounded surfaces, warm window light, matte textures. No stark white, no pure black, no neon, no cyberpunk.
|
||||||
|
- Style authority: `/root/workspace/shared/manifesto/STYLEGUIDE.md` — read it before changing any page. Claims and voice authority: `MANIFESTO.md` in the same directory.
|
||||||
|
- The old dark/purple/cyan video identity (`#0a0a0f`, `#9C27B0`, `#00E6FF`) is retired for web. Video title cards still needing it should be re-rendered in the warm palette rather than reintroducing the old one.
|
||||||
- Domain: `oc-guy.com`
|
- Domain: `oc-guy.com`
|
||||||
|
|
||||||
## Evidence on Hand
|
## Evidence on Hand
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Odoo 20 Community Is Here — and It Feels Like a Genuinely Polished Release — The OC Guy</title>
|
||||||
|
<meta name="description" content="Odoo 20.0 Community is available, and after spending time testing it, I can say it is a genuinely polished release. That is not a phrase I throw around lightly when a new version l">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="theme-color" content="#F2E9D6">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="../css/site.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="masthead">
|
||||||
|
<a class="wordmark" href="../index.html">The OC Guy</a>
|
||||||
|
<nav class="nav" aria-label="Sections">
|
||||||
|
<a href="../index.html#tips">Tips</a>
|
||||||
|
<a href="../index.html#blog">Blog</a>
|
||||||
|
<a href="../index.html#community">Community</a>
|
||||||
|
<a href="../index.html#ask">About</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="section post">
|
||||||
|
<p class="post-meta"><span class="card-cat">news</span> <time datetime="2026-10-23">23 Oct 2026</time></p>
|
||||||
|
<h1 class="post-title">Odoo 20 Community Is Here — and It Feels Like a Genuinely Polished Release</h1>
|
||||||
|
<img class="post-image" src="../assets/blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.png" alt="" width="1200" height="800">
|
||||||
|
<div class="post-body"><p>Odoo 20.0 Community is available, and after spending time testing it, I can say it is a genuinely polished release. That is not a phrase I throw around lightly when a new version lands.</p>
|
||||||
|
|
||||||
|
<p>My usual focus is the business side of a release — the changes that quietly affect how you work every day. Design rarely tops my list. But the small design updates teased before launch are a real step forward. They are not flashy for the sake of it; they make the interface feel calmer and more considered.</p>
|
||||||
|
|
||||||
|
<h2>The AI and MCP Side</h2>
|
||||||
|
|
||||||
|
<p>I cannot brag about the AI and MCP-related features the way my friend Andrew from Odoo It Yourself has in his review. It is well worth watching: <a href="https://www.youtube.com/watch?v=drR3HNWcnd4">Odoo It Yourself review</a>.</p>
|
||||||
|
|
||||||
|
<p>For my own work, though, that gap matters less than you might expect. My approach has always been to treat Odoo as a stable foundation and pimp it with tools like n8n and a Hermes Agent. That setup does not depend on AI features shipping inside the core. It also gives me something better than a feature list — a reason to write about how to fill the gap yourself.</p>
|
||||||
|
|
||||||
|
<h2>What Is Coming Next</h2>
|
||||||
|
|
||||||
|
<p>Stay tuned for Odoo Community-related AI posts. There is plenty to explore, and the absence of built-in AI in Community makes the DIY angle more interesting, not less.</p>
|
||||||
|
|
||||||
|
<p>And with Release 20, I am hoping to finally write the blog post I have wanted to write for a long time: how to use Community for accounting.</p>
|
||||||
|
|
||||||
|
<p>Take a look at the release, test it against your own setup, and see what it changes for you. When you are ready to go further, check out our homepage.</p></div>
|
||||||
|
<p class="post-note">Published 23 Oct 2026 · Odoo Community news by The OC Guy.
|
||||||
|
Conditions under which this advice fails are stated in the text; if they are missing, the post is not finished.</p>
|
||||||
|
</main>
|
||||||
|
<footer class="foot">
|
||||||
|
<p class="foot-name" aria-hidden="true">The OC Guy</p>
|
||||||
|
<div class="foot-cols">
|
||||||
|
<p class="foot-note">Odoo Community advocate. Written by Oliver Arnold, 安雷威 — no partner programme, no licences sold.</p>
|
||||||
|
<nav class="foot-links" aria-label="Elsewhere">
|
||||||
|
<a href="../index.html">Home</a>
|
||||||
|
<a href="https://github.com/OCA" target="_blank" rel="noopener">OCA modules</a>
|
||||||
|
<a href="mailto:oliver@oc-guy.com">Email</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+405
-265
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"generated": "2026-10-23T21:40:57+00:00",
|
||||||
|
"source": "https://n8n.odoo4projects.com/webhook/oc-guy/posts",
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"id": "odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release",
|
||||||
|
"source_id": "17",
|
||||||
|
"date": "2026-10-23",
|
||||||
|
"category": "news",
|
||||||
|
"headline": "Odoo 20 Community Is Here — and It Feels Like a Genuinely Polished Release",
|
||||||
|
"teaser": "Odoo 20.0 Community is available, and after spending time testing it, I can say it is a genuinely polished release. That is not a phrase I throw around lightly when a new version lands. My usual…",
|
||||||
|
"image": "assets/blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.png",
|
||||||
|
"link": "blog/odoo-20-community-is-here-and-it-feels-like-a-genuinely-polished-release.html",
|
||||||
|
"hash": "ac409d15d7dd3159",
|
||||||
|
"published": "2026-10-23T21:40:57+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+27
-81
@@ -6,9 +6,10 @@
|
|||||||
<title>The OC Guy — Odoo Community tips, modules and community news</title>
|
<title>The OC Guy — Odoo Community tips, modules and community news</title>
|
||||||
<meta name="description" content="Welcome. Practical tips and tricks for Odoo Community, the open-source ERP: install it, back it up, extend it, and put AI on top. By The OC Guy, Oliver Arnold.">
|
<meta name="description" content="Welcome. Practical tips and tricks for Odoo Community, the open-source ERP: install it, back it up, extend it, and put AI on top. By The OC Guy, Oliver Arnold.">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="theme-color" content="#F2E9D6">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,700;1,400;1,500&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="css/site.css">
|
<link rel="stylesheet" href="css/site.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
<nav class="nav" aria-label="Sections">
|
<nav class="nav" aria-label="Sections">
|
||||||
<a href="#welcome">Welcome</a>
|
<a href="#welcome">Welcome</a>
|
||||||
<a href="#tips">Tips</a>
|
<a href="#tips">Tips</a>
|
||||||
<a href="#module">Modules</a>
|
<a href="#blog">Blog</a>
|
||||||
<a href="#community">Community</a>
|
<a href="#community">Community</a>
|
||||||
<a href="#ask">About</a>
|
<a href="#ask">About</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -60,66 +61,32 @@
|
|||||||
<header class="section-head">
|
<header class="section-head">
|
||||||
<span class="initial" aria-hidden="true">T</span>
|
<span class="initial" aria-hidden="true">T</span>
|
||||||
<h2 class="section-title" id="tips-title"><span class="kicker">The latest in</span> <em>Tips & Tricks</em></h2>
|
<h2 class="section-title" id="tips-title"><span class="kicker">The latest in</span> <em>Tips & Tricks</em></h2>
|
||||||
<a class="more" href="https://www.odoo.com/documentation" target="_blank" rel="noopener">View all tips</a>
|
<a class="more" href="#blog">All posts</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cards">
|
<div class="feed-tools">
|
||||||
<a class="card" href="https://www.odoo.com/documentation" target="_blank" rel="noopener">
|
<label class="sr-only" for="tipsSearch">Search tips and tricks</label>
|
||||||
<img class="card-img" src="assets/tip-install.jpg" alt="Two plain cardboard boxes stacked on a wooden table" width="1000" height="649">
|
<input id="tipsSearch" class="feed-search" type="search" placeholder="Search the tips…" autocomplete="off">
|
||||||
<span class="card-cat">Install</span>
|
<span class="feed-count"><span id="tipsCount">0</span> posts</span>
|
||||||
<h3>Run Odoo from Docker Compose, not from source</h3>
|
|
||||||
<p class="card-desc">Two containers, one file, a volume you can copy. The shortest path to a server you
|
|
||||||
understand and can move next year without a migration project.</p>
|
|
||||||
<span class="card-meta">6 min read</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="card" href="https://www.odoo.com/documentation" target="_blank" rel="noopener">
|
|
||||||
<img class="card-img" src="assets/tip-backup.jpg" alt="Rows of old book spines lined up on a shelf" width="1000" height="666">
|
|
||||||
<span class="card-cat">Backup</span>
|
|
||||||
<h3>Dump the database nightly and restore it monthly</h3>
|
|
||||||
<p class="card-desc">A backup you have never restored is a hope, not a backup. One pg_dump, one cron line,
|
|
||||||
and a restore into a scratch copy that takes four minutes.</p>
|
|
||||||
<span class="card-meta">4 min read</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="card" href="https://github.com/OCA" target="_blank" rel="noopener">
|
|
||||||
<img class="card-img" src="assets/tip-extend.jpg" alt="Hand tools, a clamp and a hammer on a workbench" width="1000" height="666">
|
|
||||||
<span class="card-cat">Extend</span>
|
|
||||||
<h3>Take an OCA module before you write your own</h3>
|
|
||||||
<p class="card-desc">Someone already solved your problem and maintains it for every series. Reading their
|
|
||||||
code teaches you the ORM faster than any tutorial.</p>
|
|
||||||
<span class="card-meta">8 min read</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="cards" id="tipsGrid" data-feed="tips"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section" id="module" aria-labelledby="module-title">
|
<section class="section" id="blog" aria-labelledby="blog-title">
|
||||||
<header class="section-head">
|
<header class="section-head">
|
||||||
<span class="initial" aria-hidden="true">M</span>
|
<span class="initial" aria-hidden="true">B</span>
|
||||||
<h2 class="section-title" id="module-title"><span class="kicker">Featured</span> <em>Module of the month</em></h2>
|
<h2 class="section-title" id="blog-title"><span class="kicker">Everything</span> <em>The OC Guy blog</em></h2>
|
||||||
|
<a class="more" href="#community">Community news</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="feature">
|
<div class="feed-tools">
|
||||||
<figure class="feature-photo">
|
<label class="sr-only" for="blogSearch">Search the blog</label>
|
||||||
<img src="assets/module-inventory.jpg" alt="Steel shelving in a warehouse, stock and pallets stacked to the ceiling" width="1600" height="1067">
|
<input id="blogSearch" class="feed-search" type="search" placeholder="Search every post…" autocomplete="off">
|
||||||
</figure>
|
<span class="feed-count"><span id="blogCount">0</span> posts</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature-body">
|
<div class="cards" id="blogGrid" data-feed="blog"></div>
|
||||||
<p class="plate-kicker">Odoo Community · LGPL-3 · series 18.0</p>
|
|
||||||
<h3 class="feature-name">Inventory, before you buy anything</h3>
|
|
||||||
<p>One module holds what you own, where it sits and what it is worth: stock moves, reordering rules,
|
|
||||||
lots and serial numbers, three warehouse steps if you need them, one if you do not.</p>
|
|
||||||
<ul class="benefits">
|
|
||||||
<li><strong>Real stock, not a spreadsheet</strong>Every receipt, delivery and adjustment is a move with a date and a person behind it.</li>
|
|
||||||
<li><strong>Barcode-ready on day one</strong>The Community module ships the operations your scanner needs; no paid add-on.</li>
|
|
||||||
<li><strong>Multi-warehouse without a second system</strong>Locations, routes and reordering rules live in the same database as your invoices.</li>
|
|
||||||
</ul>
|
|
||||||
<div class="feature-actions">
|
|
||||||
<a class="btn" href="https://www.odoo.com/documentation/18.0/applications/inventory_and_mrp/inventory.html" target="_blank" rel="noopener">Explore module</a>
|
|
||||||
<span class="feature-note">Free in the Community edition · installs from Apps</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section oca" id="oca" aria-labelledby="oca-title">
|
<section class="section oca" id="oca" aria-labelledby="oca-title">
|
||||||
@@ -194,35 +161,13 @@
|
|||||||
<a class="more" href="https://github.com/OCA" target="_blank" rel="noopener">View all community news</a>
|
<a class="more" href="https://github.com/OCA" target="_blank" rel="noopener">View all community news</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul class="news">
|
<div class="feed-tools">
|
||||||
<li>
|
<label class="sr-only" for="newsSearch">Search community news</label>
|
||||||
<span class="news-date"><time datetime="2026-10-23">23 Oct 2026</time></span>
|
<input id="newsSearch" class="feed-search" type="search" placeholder="Search the news…" autocomplete="off">
|
||||||
<div class="news-body">
|
<span class="feed-count"><span id="newsCount">0</span> posts</span>
|
||||||
<h3><a href="https://github.com/OCA/rma" target="_blank" rel="noopener">RMA: returns, repairs and replacements inside Odoo</a></h3>
|
|
||||||
<p>Return Merchandise Authorization for the whole flow — the customer sends it back, you decide repair
|
|
||||||
or credit, the stock move happens once.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="news-source">github.com/OCA/rma</span>
|
|
||||||
</li>
|
<ul class="news" id="newsList" data-feed="news"></ul>
|
||||||
<li>
|
|
||||||
<span class="news-date"><time datetime="2026-10-23">23 Oct 2026</time></span>
|
|
||||||
<div class="news-body">
|
|
||||||
<h3><a href="https://github.com/OCA/helpdesk" target="_blank" rel="noopener">Helpdesk modules that stay out of your way</a></h3>
|
|
||||||
<p>Tickets, teams and SLAs without the enterprise edition: the OCA helpdesk repository keeps a branch
|
|
||||||
per supported series, so an upgrade does not orphan your support desk.</p>
|
|
||||||
</div>
|
|
||||||
<span class="news-source">github.com/OCA/helpdesk</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<span class="news-date"><time datetime="2026-10-23">23 Oct 2026</time></span>
|
|
||||||
<div class="news-body">
|
|
||||||
<h3><a href="https://github.com/OCA/l10n-italy" target="_blank" rel="noopener">Localisation is community work: Italy's charts, kept current</a></h3>
|
|
||||||
<p>Tax reports, e-invoicing and the country chart of accounts, maintained by the people who file with
|
|
||||||
them. The same pattern exists for your country.</p>
|
|
||||||
</div>
|
|
||||||
<span class="news-source">github.com/OCA/l10n-italy</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ask" id="ask" aria-labelledby="ask-title">
|
<section class="ask" id="ask" aria-labelledby="ask-title">
|
||||||
@@ -258,5 +203,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="js/site.js" defer></script>
|
<script src="js/site.js" defer></script>
|
||||||
|
<script src="js/feed.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
/* The OC Guy — post feed.
|
||||||
|
One manifest (data/posts.json) written by scripts/sync_posts.py, three views:
|
||||||
|
Tips & Tricks, Community news and the Blog. Search is client-side over
|
||||||
|
headline + teaser + category, the same technique as blog/index.json on
|
||||||
|
odoo4projects.com. No JS, no feed: the sections say so instead of lying. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var SOURCE = 'data/posts.json';
|
||||||
|
var BADGE = { tips: 'Tips & Tricks', news: 'Community news', blog: 'Blog' };
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var n = document.createElement(tag);
|
||||||
|
if (cls) n.className = cls;
|
||||||
|
if (text != null) n.textContent = text;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
var d = new Date(iso + 'T00:00:00Z');
|
||||||
|
if (isNaN(d.getTime())) return iso || '';
|
||||||
|
return d.getUTCDate() + ' ' + months[d.getUTCMonth()] + ' ' + d.getUTCFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(p, term) {
|
||||||
|
if (!term) return true;
|
||||||
|
var hay = (p.headline + ' ' + p.teaser + ' ' + p.category + ' ' + (p.tags || []).join(' ')).toLowerCase();
|
||||||
|
return hay.indexOf(term) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyState(node, kind) {
|
||||||
|
node.textContent = '';
|
||||||
|
var box = el('p', 'feed-empty');
|
||||||
|
box.textContent = kind === 'news'
|
||||||
|
? 'No community news published yet. Posts from the daily feed land here.'
|
||||||
|
: 'Nothing published in this section yet.';
|
||||||
|
node.appendChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(p) {
|
||||||
|
var a = el('a', 'card');
|
||||||
|
a.href = p.link;
|
||||||
|
if (p.image) {
|
||||||
|
var img = el('img', 'card-img');
|
||||||
|
img.src = p.image;
|
||||||
|
img.alt = '';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.width = 1000;
|
||||||
|
img.height = 666;
|
||||||
|
a.appendChild(img);
|
||||||
|
}
|
||||||
|
a.appendChild(el('span', 'card-cat', BADGE[p.category] || p.category));
|
||||||
|
a.appendChild(el('h3', null, p.headline));
|
||||||
|
a.appendChild(el('p', 'card-desc', p.teaser));
|
||||||
|
a.appendChild(el('span', 'card-meta', fmtDate(p.date)));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newsRow(p) {
|
||||||
|
var li = el('li');
|
||||||
|
var when = el('span', 'news-date');
|
||||||
|
var t = el('time');
|
||||||
|
t.dateTime = p.date;
|
||||||
|
t.textContent = fmtDate(p.date);
|
||||||
|
when.appendChild(t);
|
||||||
|
|
||||||
|
var body = el('div', 'news-body');
|
||||||
|
var h3 = el('h3');
|
||||||
|
var a = el('a', null, p.headline);
|
||||||
|
a.href = p.link;
|
||||||
|
h3.appendChild(a);
|
||||||
|
body.appendChild(h3);
|
||||||
|
body.appendChild(el('p', null, p.teaser));
|
||||||
|
|
||||||
|
li.appendChild(when);
|
||||||
|
li.appendChild(body);
|
||||||
|
li.appendChild(el('span', 'news-source', p.category === 'tips' ? 'Tips & Tricks' : 'Community'));
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(view, posts, term) {
|
||||||
|
var list = posts.filter(function (p) {
|
||||||
|
return (view.all || p.category === view.category) && matches(p, term);
|
||||||
|
});
|
||||||
|
var node = view.node;
|
||||||
|
if (view.countBox) {
|
||||||
|
view.countBox.textContent = list.length + (list.length === 1 ? ' post' : ' posts');
|
||||||
|
}
|
||||||
|
if (!list.length) {
|
||||||
|
emptyState(node, view.kind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.textContent = '';
|
||||||
|
if (view.kind === 'news') {
|
||||||
|
list.slice(0, view.limit || 5).forEach(function (p) { node.appendChild(newsRow(p)); });
|
||||||
|
} else {
|
||||||
|
list.slice(0, view.limit || 50).forEach(function (p) { node.appendChild(card(p)); });
|
||||||
|
}
|
||||||
|
if (view.count) view.count.textContent = String(list.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickCategory(p) {
|
||||||
|
var c = (p.category || 'blog').toLowerCase();
|
||||||
|
return BADGE[c] ? c : 'blog';
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(SOURCE + '?_=' + Date.now())
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var posts = (data && data.posts ? data.posts : []).map(function (p) {
|
||||||
|
return {
|
||||||
|
id: p.id, date: p.date, headline: p.headline, teaser: p.teaser || '',
|
||||||
|
image: p.image || null, link: p.link || '#',
|
||||||
|
category: pickCategory(p), tags: p.tags || []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
var views = [
|
||||||
|
{ node: document.getElementById('tipsGrid'), category: 'tips', kind: 'cards',
|
||||||
|
search: document.getElementById('tipsSearch'), count: document.getElementById('tipsCount'), limit: 3 },
|
||||||
|
{ node: document.getElementById('newsList'), category: 'news', kind: 'news',
|
||||||
|
search: document.getElementById('newsSearch'), count: document.getElementById('newsCount'), limit: 6 },
|
||||||
|
{ node: document.getElementById('blogGrid'), category: 'blog', kind: 'cards', all: true,
|
||||||
|
search: document.getElementById('blogSearch'), count: document.getElementById('blogCount'), limit: 60 }
|
||||||
|
].filter(function (v) { return v.node; });
|
||||||
|
|
||||||
|
views.forEach(function (v) {
|
||||||
|
v.term = '';
|
||||||
|
if (v.count) v.countBox = v.count.parentElement;
|
||||||
|
render(v, posts, '');
|
||||||
|
if (v.search) {
|
||||||
|
v.search.addEventListener('input', function () {
|
||||||
|
v.term = this.value.trim().toLowerCase();
|
||||||
|
render(v, posts, v.term);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* posts tagged both tips and blog are listed twice on purpose:
|
||||||
|
the sections are channels, not storage. */
|
||||||
|
var total = document.getElementById('feedTotal');
|
||||||
|
if (total) total.textContent = String(posts.length);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
['tipsGrid', 'newsList', 'blogGrid'].forEach(function (id) {
|
||||||
|
var n = document.getElementById(id);
|
||||||
|
if (n) emptyState(n, id === 'newsList' ? 'news' : 'cards');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pull the day's posts from the n8n webhook and publish them into the site.
|
||||||
|
|
||||||
|
Runs once a day from cron. Everything it writes is derived from the webhook
|
||||||
|
payload: post pages under blog/<slug>.html, images under assets/blog/, and the
|
||||||
|
manifest at data/posts.json which the homepage reads.
|
||||||
|
|
||||||
|
Rules (from MANIFESTO.md / STYLEGUIDE.md):
|
||||||
|
- only real posts are published; attachments that are not images are ignored
|
||||||
|
- every published item keeps its date and category, no undated entries
|
||||||
|
- duplicates (same id, same link, same content hash) are never published twice
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
sync_posts.py fetch, publish, update manifest
|
||||||
|
sync_posts.py --dry-run fetch and report, write nothing
|
||||||
|
sync_posts.py --fixture f.json read a saved payload instead of the webhook
|
||||||
|
sync_posts.py --check report manifest state and exit
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
MANIFEST = ROOT / "data" / "posts.json"
|
||||||
|
POSTS_DIR = ROOT / "blog"
|
||||||
|
ASSETS_DIR = ROOT / "assets" / "blog"
|
||||||
|
LOCK = ROOT / "data" / ".sync.lock"
|
||||||
|
DEFAULT_URL = "https://n8n.odoo4projects.com/webhook/oc-guy/posts"
|
||||||
|
UA = "oc-guy-sync/1.0 (+https://oc-guy.com)"
|
||||||
|
MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
IMAGE_KEYS = ("image", "images", "cover", "banner", "picture", "photo", "featured_image", "file")
|
||||||
|
FILE_KEYS = ("file", "files", "attachment", "attachments", "document", "documents", "file_url")
|
||||||
|
TITLE_KEYS = ("header", "headline", "title", "name", "subject")
|
||||||
|
BODY_KEYS = ("text", "body", "content", "html", "markdown", "description", "story")
|
||||||
|
CATEGORY_KEYS = ("category", "area", "tags", "tag", "section", "type")
|
||||||
|
DATE_KEYS = ("scheduled", "date", "published", "pubDate", "created", "timestamp")
|
||||||
|
ID_KEYS = ("id", "slug", "uuid", "_id")
|
||||||
|
|
||||||
|
KNOWN_CATEGORIES = {"tips": "tips", "tip": "tips", "tricks": "tips",
|
||||||
|
"news": "news", "community": "news", "oca": "news",
|
||||||
|
"blog": "blog", "article": "blog", "post": "blog"}
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
print(f"[{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%SZ')}] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url: str, timeout: int = 30) -> object:
|
||||||
|
"""GET the webhook, fall back to POST — n8n registers one method, not both."""
|
||||||
|
last = None
|
||||||
|
for method in ("GET", "POST"):
|
||||||
|
req = urllib.request.Request(url, method=method, headers={
|
||||||
|
"User-Agent": UA, "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}, data=b"{}" if method == "POST" else None)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8", "replace"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
last = f"{method} {url} -> HTTP {e.code} {e.reason}"
|
||||||
|
except Exception as e: # noqa: BLE001 - network, DNS, JSON
|
||||||
|
last = f"{method} {url} -> {type(e).__name__}: {e}"
|
||||||
|
raise RuntimeError(last or "webhook unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap(payload: object) -> list[dict]:
|
||||||
|
"""n8n hands back a list, or {data:[...]}, or {posts:[...]} — accept all three."""
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return [p for p in payload if isinstance(p, dict)]
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for key in ("posts", "data", "items", "records", "results"):
|
||||||
|
v = payload.get(key)
|
||||||
|
if isinstance(v, list):
|
||||||
|
return [p for p in v if isinstance(p, dict)]
|
||||||
|
if any(k in payload for k in TITLE_KEYS):
|
||||||
|
return [payload]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def pick(item: dict, keys: tuple[str, ...]) -> object:
|
||||||
|
for k in keys:
|
||||||
|
if k in item and item[k] not in (None, "", []):
|
||||||
|
return item[k]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(text: str, fallback: str) -> str:
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "-", str(text).lower()).strip("-")
|
||||||
|
return s[:80] or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_date(value: object) -> str:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return datetime.fromtimestamp(value, timezone.utc).date().isoformat()
|
||||||
|
if isinstance(value, str):
|
||||||
|
m = re.match(r"(\d{4}-\d{2}-\d{2})", value)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
for fmt in ("%d.%m.%Y", "%d/%m/%Y", "%Y/%m/%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value, fmt).date().isoformat()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_category(value: object) -> str:
|
||||||
|
"""Map whatever tag comes in onto the three site sections."""
|
||||||
|
raw: list[str] = []
|
||||||
|
if isinstance(value, str):
|
||||||
|
raw = [v.strip().lower() for v in re.split(r"[,\s]+", value) if v.strip()]
|
||||||
|
elif isinstance(value, list):
|
||||||
|
raw = [str(v).strip().lower() for v in value if str(v).strip()]
|
||||||
|
for tag in raw:
|
||||||
|
if tag in KNOWN_CATEGORIES:
|
||||||
|
return KNOWN_CATEGORIES[tag]
|
||||||
|
return "blog"
|
||||||
|
|
||||||
|
|
||||||
|
def image_urls(value: object) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
if isinstance(value, str):
|
||||||
|
if re.search(r"\.(jpe?g|png|webp|avif|gif)(\?|$)", value, re.I) or value.startswith("data:image/"):
|
||||||
|
out.append(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for v in value:
|
||||||
|
out.extend(image_urls(v))
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
for key in ("url", "src", "path", "link"):
|
||||||
|
if isinstance(value.get(key), str):
|
||||||
|
out.extend(image_urls(value[key]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def strip_tags(raw: str) -> str:
|
||||||
|
text = re.sub(r"(?is)<(script|style).*?</\1>", " ", raw)
|
||||||
|
text = re.sub(r"(?s)<[^>]+>", " ", text)
|
||||||
|
return re.sub(r"\s+", " ", html.unescape(text)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_html(raw: str) -> str:
|
||||||
|
"""Keep the post's formatting, drop anything executable or remote-loading."""
|
||||||
|
out = re.sub(r"(?is)<(script|style|iframe|object|embed|form)[^>]*>.*?</\1>", "", raw)
|
||||||
|
out = re.sub(r"(?i)\son\w+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)", "", out)
|
||||||
|
out = re.sub(r"(?i)(href|src)\s*=\s*(\"|')javascript:[^\"']*(\"|')", r'\1="#"', out)
|
||||||
|
return out.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def download_image(src: str, dest_stem: Path) -> str | None:
|
||||||
|
"""Save one image next to the post. Anything that is not an image is dropped."""
|
||||||
|
try:
|
||||||
|
if src.startswith("data:image/"):
|
||||||
|
header, _, b64 = src.partition(",")
|
||||||
|
import base64
|
||||||
|
blob = base64.b64decode(b64)
|
||||||
|
ext = (re.match(r"data:image/(\w+)", header) or [None, "png"])[1]
|
||||||
|
ext = "jpg" if ext == "jpeg" else ext
|
||||||
|
data, ctype = blob, f"image/{ext}"
|
||||||
|
else:
|
||||||
|
req = urllib.request.Request(src, headers={"User-Agent": UA})
|
||||||
|
with urllib.request.urlopen(req, timeout=45) as r:
|
||||||
|
ctype = (r.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
||||||
|
data = r.read(MAX_IMAGE_BYTES + 1)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log(f" image failed ({type(e).__name__}): {src[:90]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not ctype.startswith("image/"):
|
||||||
|
log(f" skipped non-image file: {src[:90]} ({ctype or 'unknown type'})")
|
||||||
|
return None
|
||||||
|
if len(data) > MAX_IMAGE_BYTES:
|
||||||
|
log(f" skipped oversized image: {src[:90]}")
|
||||||
|
return None
|
||||||
|
if len(data) < 64:
|
||||||
|
log(f" skipped empty image: {src[:90]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
ext = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp",
|
||||||
|
"image/avif": "avif", "image/gif": "gif"}.get(ctype, "img")
|
||||||
|
dest = dest_stem.with_suffix("." + ext)
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_bytes(data)
|
||||||
|
return str(dest.relative_to(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
POST_PAGE = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{title} — The OC Guy</title>
|
||||||
|
<meta name="description" content="{description}">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="theme-color" content="#F2E9D6">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="../css/site.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="masthead">
|
||||||
|
<a class="wordmark" href="../index.html">The OC Guy</a>
|
||||||
|
<nav class="nav" aria-label="Sections">
|
||||||
|
<a href="../index.html#tips">Tips</a>
|
||||||
|
<a href="../index.html#blog">Blog</a>
|
||||||
|
<a href="../index.html#community">Community</a>
|
||||||
|
<a href="../index.html#ask">About</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="section post">
|
||||||
|
<p class="post-meta"><span class="card-cat">{category}</span> <time datetime="{date}">{date_human}</time></p>
|
||||||
|
<h1 class="post-title">{title}</h1>
|
||||||
|
{image}
|
||||||
|
<div class="post-body">{body}</div>
|
||||||
|
<p class="post-note">Published {date_human} · Odoo Community {category} by The OC Guy.
|
||||||
|
Conditions under which this advice fails are stated in the text; if they are missing, the post is not finished.</p>
|
||||||
|
</main>
|
||||||
|
<footer class="foot">
|
||||||
|
<p class="foot-name" aria-hidden="true">The OC Guy</p>
|
||||||
|
<div class="foot-cols">
|
||||||
|
<p class="foot-note">Odoo Community advocate. Written by Oliver Arnold, 安雷威 — no partner programme, no licences sold.</p>
|
||||||
|
<nav class="foot-links" aria-label="Elsewhere">
|
||||||
|
<a href="../index.html">Home</a>
|
||||||
|
<a href="https://github.com/OCA" target="_blank" rel="noopener">OCA modules</a>
|
||||||
|
<a href="mailto:oliver@oc-guy.com">Email</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||||
|
|
||||||
|
|
||||||
|
def human_date(iso: str) -> str:
|
||||||
|
try:
|
||||||
|
d = datetime.strptime(iso, "%Y-%m-%d")
|
||||||
|
return f"{d.day} {MONTHS[d.month - 1]} {d.year}"
|
||||||
|
except ValueError:
|
||||||
|
return iso
|
||||||
|
|
||||||
|
|
||||||
|
def build_entry(item: dict, seen_ids: set[str], seen_hashes: set[str], dry: bool) -> dict | None:
|
||||||
|
title = str(pick(item, TITLE_KEYS) or "").strip()
|
||||||
|
body_raw = str(pick(item, BODY_KEYS) or "").strip()
|
||||||
|
if not title and not body_raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
iso = normalise_date(pick(item, DATE_KEYS))
|
||||||
|
category = normalise_category(pick(item, CATEGORY_KEYS))
|
||||||
|
raw_id = str(pick(item, ID_KEYS) or "").strip()
|
||||||
|
if not title:
|
||||||
|
title = strip_tags(body_raw)[:80]
|
||||||
|
# a URL wants the headline, not a database id: "17.html" tells a reader nothing
|
||||||
|
base = slugify(title, slugify(raw_id, f"post-{iso}"))
|
||||||
|
sid = raw_id or base
|
||||||
|
|
||||||
|
body_html = safe_html(body_raw)
|
||||||
|
body_text = strip_tags(body_raw)
|
||||||
|
digest = hashlib.sha256((title + "\u0000" + body_text).encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
if sid in seen_ids or base in seen_ids:
|
||||||
|
log(f" duplicate id, skipped: {sid}")
|
||||||
|
return None
|
||||||
|
if digest in seen_hashes:
|
||||||
|
log(f" duplicate content, skipped: {title[:60]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
teaser = body_text[:200].rsplit(" ", 1)[0] + "…" if len(body_text) > 200 else body_text
|
||||||
|
image_rel = None
|
||||||
|
sources = image_urls(pick(item, IMAGE_KEYS))
|
||||||
|
if not sources:
|
||||||
|
# an item carrying only a non-image attachment (pdf, docx, zip) is not a post
|
||||||
|
for key in FILE_KEYS:
|
||||||
|
value = pick(item, (key,))
|
||||||
|
for ref in image_urls(value) or ([value] if isinstance(value, str) else []):
|
||||||
|
if isinstance(ref, str) and re.search(r"\.\w{2,5}(\?|$)", ref) and not re.search(
|
||||||
|
r"\.(jpe?g|png|webp|avif|gif)(\?|$)", ref, re.I):
|
||||||
|
log(f" skipped non-post attachment: {base} ({ref[:80]})")
|
||||||
|
return None
|
||||||
|
if sources:
|
||||||
|
if dry:
|
||||||
|
image_rel = f"assets/blog/{base}.<ext>"
|
||||||
|
log(f" [dry] would download {len(sources)} image(s), first: {sources[0][:80]}")
|
||||||
|
else:
|
||||||
|
image_rel = download_image(sources[0], ASSETS_DIR / base)
|
||||||
|
else:
|
||||||
|
log(f" no image in payload: {base}")
|
||||||
|
|
||||||
|
if not dry:
|
||||||
|
POSTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
page = POST_PAGE.format(
|
||||||
|
title=html.escape(title),
|
||||||
|
description=html.escape(teaser)[:180],
|
||||||
|
category=html.escape(category),
|
||||||
|
date=iso,
|
||||||
|
date_human=html.escape(human_date(iso)),
|
||||||
|
image=f'<img class="post-image" src="../{image_rel}" alt="" width="1200" height="800">' if image_rel else "",
|
||||||
|
body=body_html or f"<p>{html.escape(body_text)}</p>",
|
||||||
|
)
|
||||||
|
(POSTS_DIR / f"{base}.html").write_text(page, encoding="utf-8")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": base,
|
||||||
|
"source_id": sid,
|
||||||
|
"date": iso,
|
||||||
|
"category": category,
|
||||||
|
"headline": title,
|
||||||
|
"teaser": teaser,
|
||||||
|
"image": image_rel,
|
||||||
|
"link": f"blog/{base}.html",
|
||||||
|
"hash": digest,
|
||||||
|
"published": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest() -> dict:
|
||||||
|
if MANIFEST.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
log("manifest unreadable, starting a new one")
|
||||||
|
return {"generated": None, "source": DEFAULT_URL, "posts": []}
|
||||||
|
|
||||||
|
|
||||||
|
def write_manifest(manifest: dict) -> None:
|
||||||
|
MANIFEST.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
manifest["posts"].sort(key=lambda p: (p.get("date", ""), p.get("published", "")), reverse=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(MANIFEST.parent), suffix=".json")
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(manifest, fh, ensure_ascii=False, indent=2)
|
||||||
|
fh.write("\n")
|
||||||
|
os.replace(tmp, MANIFEST)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--url", default=os.environ.get("OC_GUY_POSTS_WEBHOOK", DEFAULT_URL))
|
||||||
|
ap.add_argument("--fixture", help="read a saved JSON payload instead of the webhook")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--check", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
manifest = load_manifest()
|
||||||
|
posts = manifest.get("posts", [])
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
log(f"manifest: {len(posts)} post(s)")
|
||||||
|
for p in posts[:10]:
|
||||||
|
log(f" {p['date']} {p['category']:<5} {p['id']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if LOCK.exists():
|
||||||
|
log("another sync is running, exiting")
|
||||||
|
return 0
|
||||||
|
LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
LOCK.write_text(str(os.getpid()), encoding="utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.fixture:
|
||||||
|
payload = json.loads(Path(args.fixture).read_text(encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
payload = fetch(args.url)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log(f"ERROR webhook unavailable: {e}")
|
||||||
|
log("nothing published, manifest untouched")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
items = unwrap(payload)
|
||||||
|
log(f"payload: {len(items)} item(s)")
|
||||||
|
|
||||||
|
seen_ids = {p.get("id") for p in posts} | {p.get("source_id") for p in posts}
|
||||||
|
seen_hashes = {p.get("hash") for p in posts}
|
||||||
|
new_entries = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
entry = build_entry(item, seen_ids, seen_hashes, args.dry_run)
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
if args.dry_run:
|
||||||
|
log(f" [dry] would publish: {entry['category']:<5} {entry['date']} {entry['headline'][:70]}")
|
||||||
|
continue
|
||||||
|
seen_ids.add(entry["id"])
|
||||||
|
seen_ids.add(entry["source_id"])
|
||||||
|
seen_hashes.add(entry["hash"])
|
||||||
|
new_entries.append(entry)
|
||||||
|
log(f" published: {entry['category']:<5} {entry['date']} {entry['id']}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
log("dry run: nothing written")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if new_entries:
|
||||||
|
manifest["posts"] = new_entries + posts
|
||||||
|
manifest["generated"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
manifest["source"] = args.url
|
||||||
|
write_manifest(manifest)
|
||||||
|
log(f"manifest updated: {len(new_entries)} new, {len(manifest['posts'])} total")
|
||||||
|
else:
|
||||||
|
log("no new posts")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
LOCK.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user