Skip to content

Commit f522207

Browse files
r8a5claude
andcommitted
feat: v1.0.5
Mirrors the v1.0.5 release on pages-seo-site. See CHANGELOG.md for the full notes; headlines: ADDED - AI duplicate detection (BGE embeddings, cosine ≥ 0.80) - Pre-publish quality scoring (status=review for thin posts) - Internal-link injection (up to 3 per post, slug-matched) - Slug cleanup + 301 redirects (blog_post_redirects table) - Category-aware topic rotation (7-day category cooldown) - Public RSS feed at /feed.xml, with rel=alternate discovery - Richer /api/version (recent_commits, release_notes, commits_since_tag) - Beefier /api/health (cron_likely_alive, jobs.in_flight_stuck) FIXED - Cron ADMIN_TOKEN sync (was returning 401 silently) - Installer D1 JSON parsing + wrangler.template.toml fallback - Installer masked password input via /dev/tty - Full GFM markdown (tables, fences, blockquotes, nested lists, autolinks, strikethrough, images, underscore bold/italic) - /install page restyle (marketing palette + Browser/Terminal chooser) - npx pages-seo-install docs replaced with working install commands Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 16aaaa6 commit f522207

20 files changed

Lines changed: 1341 additions & 106 deletions

CHANGELOG.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,108 @@ version.
77

88
The format is loosely Keep-a-Changelog, dates in ISO order.
99

10+
## 1.0.5 — 2026-06-06
11+
12+
### Added
13+
- **AI duplicate detection** (`functions/_lib/dedup.js`). Every
14+
candidate topic is embedded via Workers AI's BGE base model and
15+
cosine-compared against the 50 most recent published posts. The
16+
cron repicks on near-duplicates (similarity ≥ 0.80) up to 5 times,
17+
then falls back to the least-similar option. Calibrated against
18+
real posts: rewrites of an existing topic score ~0.83 (blocked);
19+
family-related but distinct topics score ~0.77 (allowed).
20+
- **Pre-publish quality scoring** (`functions/_lib/quality.js`).
21+
Deterministic 0–100 check on word count, headings, lists,
22+
internal links, title/meta length. Posts scoring 'bad' (< 45)
23+
go to a new `status='review'` state instead of going live.
24+
Public site treats 'review' as 404; admin still sees them. The
25+
operator can `force_publish: true` to override.
26+
- **Internal-link injection** (`functions/_lib/internal_links.js`).
27+
After generation, scan the body for phrases matching titles and
28+
keywords of other published posts, and inject up to 3 markdown
29+
links — skipping code blocks and existing links. Big SEO + reader-
30+
retention win; every new post becomes a link upgrade for older ones.
31+
- **Slug cleanup + 301 redirects.** The AI occasionally prepended
32+
"Blog" to its own title/slug field, producing URLs like
33+
`/blog/blogoptimize-...`. Text generation now strips this via a
34+
title-level check (the AI's tell is a capital letter immediately
35+
after "Blog"), then re-slugifies. A new admin endpoint
36+
`POST /api/admin/blog/rename-slug` migrates broken legacy slugs
37+
with automatic 301 redirects (via a new `blog_post_redirects`
38+
table created on first use).
39+
- **Category-aware topic rotation.** `functions/_lib/topics.js` now
40+
tags each topic with a `category` (on-page, technical, content,
41+
links, off-page, analytics, platform, ai). The cron picker
42+
prefers categories not used in the last 7 days, preventing the
43+
"three Cloudflare Pages posts in five days" clustering pattern.
44+
- **Public RSS feed** at `/feed.xml`. RSS 2.0 with the 30 most
45+
recent posts, advertised via `<link rel="alternate">` in every
46+
blog page's `<head>` and a `Feed:` line in `robots.txt`. No
47+
config required — uses the request's own host.
48+
- **Richer `/api/version`.** External tools and other installs now
49+
get `recent_commits[]` (last 20 on main), `release_notes` (body
50+
of the latest GH release), and `commits_since_tag` (count of
51+
main commits ahead of the latest tag). Backward-compatible: all
52+
v1 fields preserved.
53+
- **Beefier `/api/health`.** Adds `posts.count`,
54+
`posts.last_published`, `posts.hours_since_last`,
55+
`posts.cron_likely_alive`, and `jobs.in_flight_stuck` so an
56+
uptime monitor can detect a silently-dead cron without you having
57+
to look at the admin UI.
58+
- **Cron Worker `/diag`** (`pages-seo-cron`). Unauthenticated
59+
config probe that reports whether each secret is configured,
60+
whether the BLOG_URL/PROG_URL endpoints answer, the current UK
61+
offset, and the next scheduled run times. Doesn't leak any
62+
secret values.
63+
64+
### Fixed
65+
- **Cron's `ADMIN_TOKEN` was stale.** The cron Worker had a
66+
different `ADMIN_TOKEN` from the Pages project, so every cron
67+
hour returned `{"error":"unauthorized"}`. Rotated to a fresh
68+
64-hex token on both sides — diagnosed via the new `/diag`.
69+
- **Installer (`run.py`/`run.js`/`run.sh`) D1 parsing.** Wrangler
70+
changed its `d1 create` output from a TOML snippet to JSON;
71+
installers now accept both, with a bare-UUID fallback for any
72+
future format change.
73+
- **Installer source archive missing `wrangler.toml`.** Releases
74+
ship `wrangler.template.toml` so the demo's real D1/R2 IDs
75+
aren't carried into installs. All three installers now copy
76+
the template into place when the working file is absent.
77+
- **Installer password input was fully hidden.** Both `run.py` and
78+
`run.sh` now read from `/dev/tty` (so curl-piped invocations
79+
don't swallow script bytes as input) and show the last typed
80+
character in plain before masking on the next keystroke —
81+
clearer than blind input without exposing the password.
82+
- **Markdown rendering — full GFM support.** Tables, fenced code
83+
blocks, blockquotes, horizontal rules, nested lists, underscore
84+
bold/italic, strikethrough, images, and bare-URL autolinks all
85+
now render. Replaced the NUL-byte sentinel in the inline-code
86+
protection (which made the source a binary file) with a split-
87+
based approach. Bare-URL autolinks now correctly strip trailing
88+
sentence punctuation.
89+
- **Cover SVGs serve the cream/sage background** for posts without
90+
a stored hero image. Falls back to the live `/cover/<slug>.svg`
91+
template via a `?v=<template_updated_at>` cache-buster, so
92+
template edits invalidate cached covers immediately.
93+
- **`/install` page restyled** to match the marketing palette
94+
(was inheriting admin dashboard's dark/cyan theme). Black primary
95+
button + cream background. The "Stuck? Use AI" pill no longer
96+
overlaps the step list. Re-instated the Browser-vs-Terminal
97+
chooser as the first step.
98+
- **README/CLI docs replaced `npx pages-seo-install`** with the
99+
real working install one-liners. The CLI was never published to
100+
npm.
101+
102+
### Changed
103+
- Migration: `blog_posts` gains `embedding`, `embedding_model`,
104+
`embedding_at` columns (all nullable; backfilled via the new
105+
`POST /api/admin/blog/embed-backfill` endpoint).
106+
- Migration: `blog_post_redirects` table created lazily by the
107+
rename-slug endpoint. No separate migration needed.
108+
- `/api/version` cache headers now include
109+
`stale-while-revalidate=86400` so a GitHub blip never breaks
110+
external consumers.
111+
10112
## 1.0.4 — 2026-05-21
11113

12114
### Added

functions/_lib/dedup.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// AI similarity de-dup for blog posts.
2+
//
3+
// We embed the candidate topic (title + angle) with Cloudflare Workers
4+
// AI's BGE base model, fetch the embeddings of recent published posts,
5+
// and compare cosine similarity. Anything above SIMILARITY_THRESHOLD is
6+
// considered a duplicate.
7+
//
8+
// The model is tiny (~6k neurons / M tokens) so this adds negligible
9+
// cost to a generation. We cache embeddings on each blog_posts row so
10+
// we only embed each post once.
11+
12+
const EMBED_MODEL = '@cf/baai/bge-base-en-v1.5';
13+
// 0.80 in practice — BGE embeddings cluster same-niche content tightly,
14+
// so all "SEO" posts share heavy vocabulary overlap. Empirically:
15+
// - near-verbatim rewrite of an existing post: ~0.83
16+
// - same topic family, different angle: ~0.77
17+
// - clearly distinct topic, same niche: ~0.73
18+
// 0.80 catches the rewrites while letting through family-related but
19+
// distinct angles. Bump higher if too many family-relateds get blocked.
20+
const SIMILARITY_THRESHOLD = 0.80;
21+
const RECENT_POSTS_TO_CHECK = 50; // 50 most recent published posts
22+
const MAX_TEXT_FOR_EMBED = 1500; // chars; well within model's 512 tokens
23+
24+
// Embed a string. Returns { vector, dims, model } or throws.
25+
export async function embed(env, text) {
26+
const trimmed = String(text || '').slice(0, MAX_TEXT_FOR_EMBED);
27+
if (!trimmed) throw new Error('embed: empty text');
28+
const out = await env.AI.run(EMBED_MODEL, { text: [trimmed] });
29+
// The model returns { shape: [1, 768], data: [[...]] } or sometimes
30+
// { data: [...] }. Normalise.
31+
const vec = Array.isArray(out?.data?.[0]) ? out.data[0]
32+
: Array.isArray(out?.data) ? out.data
33+
: null;
34+
if (!vec || !vec.length) throw new Error('embed: bad model response');
35+
return { vector: vec, dims: vec.length, model: EMBED_MODEL };
36+
}
37+
38+
// Standard cosine similarity. Both vectors must be the same length.
39+
export function cosine(a, b) {
40+
if (!a || !b || a.length !== b.length) return 0;
41+
let dot = 0, na = 0, nb = 0;
42+
for (let i = 0; i < a.length; i++) {
43+
dot += a[i] * b[i];
44+
na += a[i] * a[i];
45+
nb += b[i] * b[i];
46+
}
47+
if (!na || !nb) return 0;
48+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
49+
}
50+
51+
// Given a candidate topic (key + angle), check whether anything in the
52+
// recent published-posts pool is too similar. Returns:
53+
// { duplicate: bool, similarity, against: { slug, title, score } | null,
54+
// scored: [{slug,title,score}] }
55+
//
56+
// `scored` lists the top 5 most-similar existing posts for debugging.
57+
export async function checkDuplicate(env, { title, angle }) {
58+
if (!env?.AI || !env?.DB) {
59+
// Best-effort: if AI binding missing, never block.
60+
return { duplicate: false, similarity: 0, against: null, scored: [], skipped: 'no_ai_binding' };
61+
}
62+
const candidate = String(title || '') + ' — ' + String(angle || '');
63+
let candidateVec;
64+
try { candidateVec = (await embed(env, candidate)).vector; }
65+
catch (e) { return { duplicate: false, similarity: 0, against: null, scored: [], error: String(e?.message || e) }; }
66+
67+
const rows = await env.DB.prepare(
68+
`SELECT slug, title, meta_description, embedding
69+
FROM blog_posts
70+
WHERE status = 'published' AND embedding IS NOT NULL
71+
ORDER BY published_at DESC LIMIT ?`
72+
).bind(RECENT_POSTS_TO_CHECK).all().catch(() => ({ results: [] }));
73+
74+
const scored = [];
75+
for (const r of (rows.results || [])) {
76+
let v;
77+
try { v = JSON.parse(r.embedding); } catch { continue; }
78+
if (!Array.isArray(v) || v.length !== candidateVec.length) continue;
79+
const score = cosine(candidateVec, v);
80+
scored.push({ slug: r.slug, title: r.title, score });
81+
}
82+
scored.sort((a, b) => b.score - a.score);
83+
const top = scored[0] || null;
84+
return {
85+
duplicate: !!top && top.score >= SIMILARITY_THRESHOLD,
86+
similarity: top?.score || 0,
87+
against: top,
88+
scored: scored.slice(0, 5),
89+
threshold: SIMILARITY_THRESHOLD,
90+
};
91+
}
92+
93+
// Pick a non-duplicate topic. Calls `pickFn()` up to `maxTries` times,
94+
// each call returning a fresh candidate. Returns:
95+
// { topic, dup, tries, fallback }
96+
// - topic: the chosen topic (the cleanest available)
97+
// - dup: similarity check result for the chosen topic
98+
// - tries: how many candidates we burned through
99+
// - fallback: true if all candidates were duplicates and we picked the
100+
// least-similar one anyway (logged so cron keeps publishing)
101+
export async function pickNonDuplicate(env, pickFn, { maxTries = 5 } = {}) {
102+
const burned = [];
103+
for (let i = 0; i < maxTries; i++) {
104+
const topic = await pickFn();
105+
if (!topic) break;
106+
const dup = await checkDuplicate(env, { title: topic.angle, angle: topic.angle });
107+
if (!dup.duplicate) {
108+
return { topic, dup, tries: i + 1, fallback: false };
109+
}
110+
burned.push({ topic, dup });
111+
}
112+
// Everything was a duplicate. Pick the candidate that was LEAST
113+
// similar so we still publish. The cron stays alive; admin can audit
114+
// the warning in the post metadata.
115+
if (!burned.length) {
116+
// pickFn returned nothing (empty pool) — give up
117+
return { topic: null, dup: null, tries: maxTries, fallback: true };
118+
}
119+
burned.sort((a, b) => a.dup.similarity - b.dup.similarity);
120+
const best = burned[0];
121+
return { topic: best.topic, dup: best.dup, tries: burned.length, fallback: true };
122+
}
123+
124+
// Embed + persist a post's embedding. Called from publish.js so every
125+
// new post immediately participates in future dedup checks.
126+
export async function storeEmbedding(env, slug, { title, body_markdown, meta_description }) {
127+
if (!env?.AI || !env?.DB) return { ok: false, reason: 'no_binding' };
128+
// We embed title + meta + first ~1k of body. Captures the topic well
129+
// without exceeding the model's input limit.
130+
const body = String(body_markdown || '').slice(0, 800);
131+
const text = `${title || ''}\n${meta_description || ''}\n${body}`;
132+
try {
133+
const { vector, model } = await embed(env, text);
134+
await env.DB.prepare(
135+
`UPDATE blog_posts SET embedding = ?, embedding_model = ?, embedding_at = ?
136+
WHERE slug = ?`
137+
).bind(JSON.stringify(vector), model, Math.floor(Date.now() / 1000), slug).run();
138+
return { ok: true, dims: vector.length };
139+
} catch (e) {
140+
return { ok: false, reason: String(e?.message || e).slice(0, 200) };
141+
}
142+
}

functions/_lib/internal_links.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Internal-link injector. After the AI writes a post, scan the body
2+
// for phrases that match the titles or keywords of OTHER published
3+
// posts, and turn the first matching occurrence of each into a
4+
// markdown link to that post.
5+
//
6+
// We're conservative on purpose:
7+
// - Max 3 links injected per post (search engines disregard spammy
8+
// internal linking; UX suffers above ~5)
9+
// - One link per target post (never two links to the same place)
10+
// - Skip phrases inside fenced code blocks or existing markdown links
11+
// - Only match whole-word phrases of 3-6 words, lowercased
12+
//
13+
// Real impact: every post becomes a link upgrade for older posts AND
14+
// gives readers a path to keep reading on the site.
15+
16+
const MAX_LINKS_PER_POST = 3;
17+
const MIN_PHRASE_WORDS = 3;
18+
const MAX_PHRASE_WORDS = 6;
19+
const TARGET_POOL_SIZE = 100;
20+
21+
// Strip code-fences before scanning so we don't link inside code.
22+
function stripFenced(md) {
23+
return md.replace(/```[\s\S]*?```/g, (m) => ' '.repeat(m.length));
24+
}
25+
26+
// Build candidate phrases from a target post: title + first few keywords.
27+
// Returned phrases are lowercased + normalised to plain words.
28+
function buildPhrases(post) {
29+
const out = new Set();
30+
const title = String(post.title || '');
31+
// The title itself, if 3-6 words.
32+
const tWords = title.split(/\s+/).filter(Boolean);
33+
if (tWords.length >= MIN_PHRASE_WORDS && tWords.length <= MAX_PHRASE_WORDS) {
34+
out.add(title.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim());
35+
}
36+
// Each keyword, if it's a multi-word phrase. The keywords field is
37+
// comma-separated.
38+
const kws = String(post.keywords || '').split(',');
39+
for (const kRaw of kws) {
40+
const k = kRaw.trim().toLowerCase().replace(/[^a-z0-9\s]/g, '').trim();
41+
const w = k.split(/\s+/).filter(Boolean);
42+
if (w.length >= MIN_PHRASE_WORDS && w.length <= MAX_PHRASE_WORDS) {
43+
out.add(k);
44+
}
45+
}
46+
return [...out];
47+
}
48+
49+
// Escape a phrase for use inside a RegExp.
50+
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
51+
52+
// For a candidate phrase in a body, find the FIRST occurrence that
53+
// isn't already inside a markdown link (i.e. not after a `[` without a
54+
// closing `](...)`). Returns { start, end } or null.
55+
function findUnlinkedMatch(body, phrase) {
56+
const rx = new RegExp(`\\b${escapeRx(phrase)}\\b`, 'gi');
57+
let m;
58+
while ((m = rx.exec(body)) !== null) {
59+
const start = m.index;
60+
const end = start + m[0].length;
61+
// Already inside a markdown link's [text]? Look backward for an
62+
// unclosed `[` before any `]` between it and start.
63+
const before = body.slice(0, start);
64+
const lastOpen = before.lastIndexOf('[');
65+
const lastClose = before.lastIndexOf(']');
66+
if (lastOpen > lastClose) continue; // we're inside a link text
67+
// Already part of a link's URL? Look for `](` preceding without a
68+
// closing `)` between.
69+
const lastUrlOpen = before.lastIndexOf('](');
70+
const lastUrlClose = before.lastIndexOf(')');
71+
if (lastUrlOpen > lastUrlClose) continue;
72+
return { start, end };
73+
}
74+
return null;
75+
}
76+
77+
// Inject up to MAX_LINKS_PER_POST internal links into `body`.
78+
// `selfSlug` excludes the current post from its own link pool.
79+
// `targets` is an array of { slug, title, keywords }.
80+
export function injectInternalLinks(body, selfSlug, targets) {
81+
if (!body || !Array.isArray(targets) || !targets.length) return { body, injected: [] };
82+
// Strip code blocks for the scan but apply replacements to the real body.
83+
const scan = stripFenced(body).toLowerCase();
84+
const injected = [];
85+
const seenTargets = new Set();
86+
let result = body;
87+
let cursorOffset = 0; // adjusts as we splice in <a> tags
88+
89+
// Build (phrase, target) pairs, then sort by phrase length so longer
90+
// matches win first (avoids "page titles" hijacking "page titles
91+
// that rank" etc).
92+
const pairs = [];
93+
for (const t of targets) {
94+
if (t.slug === selfSlug) continue;
95+
for (const p of buildPhrases(t)) {
96+
if (p.length < 8) continue; // tiny matches are too risky
97+
pairs.push({ phrase: p, target: t });
98+
}
99+
}
100+
pairs.sort((a, b) => b.phrase.length - a.phrase.length);
101+
102+
for (const { phrase, target } of pairs) {
103+
if (injected.length >= MAX_LINKS_PER_POST) break;
104+
if (seenTargets.has(target.slug)) continue;
105+
106+
const hit = findUnlinkedMatch(result, phrase);
107+
if (!hit) continue;
108+
109+
// Use the ORIGINAL casing from the body, not the lowercased phrase.
110+
const orig = result.slice(hit.start, hit.end);
111+
const replacement = `[${orig}](/blog/${target.slug})`;
112+
result = result.slice(0, hit.start) + replacement + result.slice(hit.end);
113+
seenTargets.add(target.slug);
114+
injected.push({ slug: target.slug, phrase, original: orig });
115+
}
116+
return { body: result, injected };
117+
}
118+
119+
// Pull recent published posts for use as the link target pool.
120+
export async function loadLinkTargets(env, selfSlug, { limit = TARGET_POOL_SIZE } = {}) {
121+
const rows = await env.DB.prepare(
122+
`SELECT slug, title, keywords FROM blog_posts
123+
WHERE status='published' AND slug != ?
124+
ORDER BY published_at DESC LIMIT ?`
125+
).bind(selfSlug || '', limit).all().catch(() => ({ results: [] }));
126+
return rows.results || [];
127+
}

functions/_lib/page_render.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export function renderContentPage({ env, request, post, kind, related = [], sett
215215
${post.keywords ? `<meta name="keywords" content="${esc(post.keywords)}" />` : ''}
216216
<link rel="canonical" href="https://${host}${urlPath}" />
217217
<meta name="robots" content="index,follow,max-image-preview:large" />
218+
<link rel="alternate" type="application/rss+xml" title="${esc(env.SITE_NAME || settings.site_name || 'pages-seo')} — RSS feed" href="https://${host}/feed.xml" />
218219
${verifyMetas}
219220
<meta property="og:type" content="${kind === 'blog' ? 'article' : 'website'}" />
220221
<meta property="og:title" content="${esc(post.title)}" />

0 commit comments

Comments
 (0)