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:
The OC Guy committed 2026-10-23 21:41:25 +00:00
1 parent a46e513f50
commit 77d3f00072
8 files changed
+1088 -348

No files matched your search

+3 -1
View File
@@ -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
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
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -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"
}
]
}
+28 -82
View File
@@ -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 &amp; Tricks</em></h2> <h2 class="section-title" id="tips-title"><span class="kicker">The latest in</span> <em>Tips &amp; 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 class="feature-body">
<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> </div>
<div class="cards" id="blogGrid" data-feed="blog"></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> </div>
<p>Return Merchandise Authorization for the whole flow — the customer sends it back, you decide repair
or credit, the stock move happens once.</p> <ul class="news" id="newsList" data-feed="news"></ul>
</div>
<span class="news-source">github.com/OCA/rma</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/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
View File
@@ -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');
});
});
})();
+422
View File
@@ -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())