Vendor shared SEO rule engine + CI drift guard (no pipeline wiring yet)
src/seo/rules.py is a byte-for-byte copy of the canonical seo_rules.py (git.chemavx.xyz/chemavx/chemavx-seo-tools). CI step "Verify vendored SEO engine" clones the canonical and fails the build on any divergence; `make sync-seo` / `make check-seo-sync` cover the local-canonical workflow. Nothing imports this in a runtime path yet — generate_seo_fields integration comes next behind SEO_AUTOFILL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b6dc5192b8
commit
89aa8d6030
@@ -20,6 +20,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ssl-verify: false
|
ssl-verify: false
|
||||||
|
|
||||||
|
- name: Verify vendored SEO engine
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
CANON_URL="http://gitea.gitea.svc.cluster.local:3000/chemavx/chemavx-seo-tools/raw/branch/main/seo_rules.py"
|
||||||
|
curl -fsS -u "chemavx:${{ secrets.CI_TOKEN }}" "$CANON_URL" -o /tmp/canonical_seo_rules.py
|
||||||
|
N=$(grep -n "BEGIN VENDORED seo_rules.py" src/seo/rules.py | head -1 | cut -d: -f1)
|
||||||
|
tail -n +$((N+1)) src/seo/rules.py > /tmp/vendored_body.py
|
||||||
|
if cmp -s /tmp/canonical_seo_rules.py /tmp/vendored_body.py; then
|
||||||
|
echo "OK: vendored SEO engine matches canonical chemavx-seo-tools/seo_rules.py"
|
||||||
|
else
|
||||||
|
echo "::error::SEO ENGINE DRIFT — src/seo/rules.py != canonical seo_rules.py. Run 'make sync-seo' and commit."
|
||||||
|
diff -u /tmp/canonical_seo_rules.py /tmp/vendored_body.py | head -40 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Set image tag
|
- name: Set image tag
|
||||||
id: tag
|
id: tag
|
||||||
run: echo "TAG=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
|
run: echo "TAG=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# ResearchOwl — developer tasks.
|
||||||
|
#
|
||||||
|
# SEO engine vendoring: src/seo/rules.py is a byte-for-byte copy of the canonical
|
||||||
|
# seo_rules.py (repo git.chemavx.xyz/chemavx/chemavx-seo-tools; local working copy
|
||||||
|
# ~/seo-tools). These targets keep the vendored copy in sync; CI enforces it too.
|
||||||
|
|
||||||
|
SEO_TOOLS_DIR ?= $(HOME)/seo-tools
|
||||||
|
CANON := $(SEO_TOOLS_DIR)/seo_rules.py
|
||||||
|
VENDORED := src/seo/rules.py
|
||||||
|
HEADER := src/seo/_vendor_header.py
|
||||||
|
MARKER := BEGIN VENDORED seo_rules.py
|
||||||
|
|
||||||
|
.PHONY: sync-seo check-seo-sync
|
||||||
|
|
||||||
|
sync-seo: ## Re-copy canonical seo_rules.py into the vendored file + record hash
|
||||||
|
@test -f "$(CANON)" || { echo "canonical not found at $(CANON)"; exit 1; }
|
||||||
|
@cat "$(HEADER)" "$(CANON)" > "$(VENDORED)"
|
||||||
|
@sha256sum "$(CANON)" | cut -d' ' -f1 > src/seo/.rules.sha256
|
||||||
|
@echo "synced $(VENDORED) from $(CANON) (sha $$(cat src/seo/.rules.sha256))"
|
||||||
|
|
||||||
|
check-seo-sync: ## Fail if the vendored copy diverges from the local canonical
|
||||||
|
@test -f "$(CANON)" || { echo "canonical not found at $(CANON) — skipping (run on a machine with seo-tools)"; exit 0; }
|
||||||
|
@N=$$(grep -n "$(MARKER)" "$(VENDORED)" | head -1 | cut -d: -f1); \
|
||||||
|
tail -n +$$((N+1)) "$(VENDORED)" > /tmp/_vendored_body.py; \
|
||||||
|
if cmp -s /tmp/_vendored_body.py "$(CANON)"; then \
|
||||||
|
echo "seo engine in sync ($(VENDORED) == $(CANON))"; \
|
||||||
|
else \
|
||||||
|
echo "SEO ENGINE DRIFT: $(VENDORED) != $(CANON). Run 'make sync-seo' and commit."; \
|
||||||
|
diff -u "$(CANON)" /tmp/_vendored_body.py | head -40; exit 1; \
|
||||||
|
fi
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
b8faa93b1f7727d3870e18f69b68283d9a97ed9da819ef0cfa79a60cc2c4ab70
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Vendored SEO rule engine (see rules.py). Re-export check_post/score for the bot."""
|
||||||
|
from .rules import check_post, score, internal_links, Violation # noqa: F401
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# VENDORED COPY — DO NOT EDIT THIS FILE BY HAND.
|
||||||
|
#
|
||||||
|
# Canonical source of truth:
|
||||||
|
# git.chemavx.xyz/chemavx/chemavx-seo-tools -> seo_rules.py
|
||||||
|
# (local working copy: ~/seo-tools/seo_rules.py)
|
||||||
|
#
|
||||||
|
# The bot reuses the shared SEO rule engine inside the container, where
|
||||||
|
# seo-tools is not installed. Everything below the BEGIN marker is a
|
||||||
|
# byte-for-byte copy of the canonical file.
|
||||||
|
#
|
||||||
|
# To update: edit the canonical, then run `make sync-seo` (re-copies here).
|
||||||
|
# Drift guard: CI step "Verify vendored SEO engine" clones the canonical and
|
||||||
|
# diffs it against the content below the marker; the build FAILS on
|
||||||
|
# any divergence. Locally, `make check-seo-sync` does the same.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# ===== BEGIN VENDORED seo_rules.py (exact copy of canonical; do not edit below) =====
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# VENDORED COPY — DO NOT EDIT THIS FILE BY HAND.
|
||||||
|
#
|
||||||
|
# Canonical source of truth:
|
||||||
|
# git.chemavx.xyz/chemavx/chemavx-seo-tools -> seo_rules.py
|
||||||
|
# (local working copy: ~/seo-tools/seo_rules.py)
|
||||||
|
#
|
||||||
|
# The bot reuses the shared SEO rule engine inside the container, where
|
||||||
|
# seo-tools is not installed. Everything below the BEGIN marker is a
|
||||||
|
# byte-for-byte copy of the canonical file.
|
||||||
|
#
|
||||||
|
# To update: edit the canonical, then run `make sync-seo` (re-copies here).
|
||||||
|
# Drift guard: CI step "Verify vendored SEO engine" clones the canonical and
|
||||||
|
# diffs it against the content below the marker; the build FAILS on
|
||||||
|
# any divergence. Locally, `make check-seo-sync` does the same.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# ===== BEGIN VENDORED seo_rules.py (exact copy of canonical; do not edit below) =====
|
||||||
|
"""
|
||||||
|
Reusable SEO rule engine for The Exclusion Zone (EN) — theexclusionzone.com (Ghost).
|
||||||
|
|
||||||
|
Pure functions, NO I/O. Feed it a Ghost Admin API post dict (with `html` format
|
||||||
|
included) and it returns a list of Violation(rule, severity, message, fix).
|
||||||
|
|
||||||
|
Shared by:
|
||||||
|
- seo_audit.py — Tool A, site-wide auditor (this engine, run over all posts)
|
||||||
|
- (future) seo_validate.py — Tool C, pre-publish validator (same engine, one draft)
|
||||||
|
|
||||||
|
Design note: every check is an independent function registered in RULES. To add a
|
||||||
|
rule, write a function (post) -> list[Violation] and append it to RULES. The auditor
|
||||||
|
and the validator both just call check_post(); they never re-implement a check.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
SITE_HOST = "theexclusionzone.com"
|
||||||
|
|
||||||
|
# ---- thresholds (single source of truth, reused by validator) -------------
|
||||||
|
META_TITLE_MAX = 60
|
||||||
|
META_DESC_MAX = 145
|
||||||
|
CUSTOM_EXCERPT_MAX = 300
|
||||||
|
MIN_INTERNAL_LINKS = 2
|
||||||
|
|
||||||
|
# ---- severity weights (used to rank "worst first") ------------------------
|
||||||
|
HIGH, MED, LOW, INFO = 3, 2, 1, 0
|
||||||
|
SEV_NAME = {HIGH: "HIGH", MED: "MED", LOW: "LOW", INFO: "INFO"}
|
||||||
|
|
||||||
|
# The Edition-main theme injects BlogPosting JSON-LD globally in default.hbs
|
||||||
|
# ({{#is "post"}} ... <script type="application/ld+json"> @type BlogPosting),
|
||||||
|
# plus Ghost's own {{ghost_head}}. So JSON-LD is handled site-wide and is NOT a
|
||||||
|
# per-post rule. Flip to False only if that theme block is ever removed.
|
||||||
|
THEME_HANDLES_JSONLD = True
|
||||||
|
|
||||||
|
Violation = namedtuple("Violation", "rule severity message fix")
|
||||||
|
|
||||||
|
|
||||||
|
def _s(v):
|
||||||
|
return v if isinstance(v, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _empty(v):
|
||||||
|
return not (isinstance(v, str) and v.strip())
|
||||||
|
|
||||||
|
|
||||||
|
# First path segments that are NOT article posts on a Ghost site (tags, authors,
|
||||||
|
# pagination, static content, etc.). Keeps root-relative link counting from
|
||||||
|
# treating /tag/foo or /content/images/... as an internal article link.
|
||||||
|
NON_POST_PREFIXES = {
|
||||||
|
"tag", "tags", "author", "page", "p", "content", "assets",
|
||||||
|
"rss", "ghost", "members", "404", "sitemap",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _internal_slug(href, self_slug):
|
||||||
|
"""Return the article slug an href points to if it's an internal POST link,
|
||||||
|
else None. Accepts both absolute (contains SITE_HOST) and root-relative
|
||||||
|
("/slug/") forms; rejects protocol-relative ("//host"), anchors, mailto,
|
||||||
|
external links, static assets, and known non-post sections."""
|
||||||
|
if SITE_HOST in href:
|
||||||
|
path = re.sub(r"^https?://[^/]+/", "", href)
|
||||||
|
elif href.startswith("/") and not href.startswith("//"):
|
||||||
|
path = href[1:]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
slug = path.strip("/").split("/")[0]
|
||||||
|
if not slug or slug == self_slug:
|
||||||
|
return None
|
||||||
|
if slug in NON_POST_PREFIXES or "." in slug: # section page or static asset
|
||||||
|
return None
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
def internal_links(post):
|
||||||
|
"""Distinct internal article slugs linked from the body, excluding self-links.
|
||||||
|
|
||||||
|
Counts BOTH absolute internal links (href containing SITE_HOST) and
|
||||||
|
root-relative links ("/slug/"), so a Ghost-relative link isn't miscounted as
|
||||||
|
"too few". Tool A (auditor) and Tool C (validator) share this, staying in sync.
|
||||||
|
"""
|
||||||
|
html = _s(post.get("html"))
|
||||||
|
self_slug = post.get("slug")
|
||||||
|
out = set()
|
||||||
|
for m in re.finditer(r'href="([^"#]+)"', html):
|
||||||
|
slug = _internal_slug(m.group(1), self_slug)
|
||||||
|
if slug:
|
||||||
|
out.add(slug)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- individual rules -------------------------------------------------------
|
||||||
|
|
||||||
|
def r_meta_title(p):
|
||||||
|
mt = _s(p.get("meta_title"))
|
||||||
|
title = _s(p.get("title"))
|
||||||
|
if _empty(mt):
|
||||||
|
sev = MED if len(title) > META_TITLE_MAX else LOW
|
||||||
|
return [Violation("meta_title.missing", sev,
|
||||||
|
f"meta_title missing → falls back to title ({len(title)} chars"
|
||||||
|
+ (f", which is >{META_TITLE_MAX}!" if len(title) > META_TITLE_MAX else "") + ")",
|
||||||
|
"set meta_title")]
|
||||||
|
if len(mt) > META_TITLE_MAX:
|
||||||
|
return [Violation("meta_title.too_long", MED,
|
||||||
|
f"meta_title {len(mt)} chars > {META_TITLE_MAX}", "shorten meta_title")]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def r_meta_description(p):
|
||||||
|
md = _s(p.get("meta_description"))
|
||||||
|
if _empty(md):
|
||||||
|
return [Violation("meta_description.missing", HIGH,
|
||||||
|
"meta_description MISSING (no SERP snippet control)", "write meta_description")]
|
||||||
|
if len(md) > META_DESC_MAX:
|
||||||
|
return [Violation("meta_description.too_long", MED,
|
||||||
|
f"meta_description {len(md)} chars > {META_DESC_MAX} (will be truncated in SERP)",
|
||||||
|
"shorten meta_description")]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def r_custom_excerpt(p):
|
||||||
|
ce = _s(p.get("custom_excerpt"))
|
||||||
|
if ce and len(ce) > CUSTOM_EXCERPT_MAX:
|
||||||
|
return [Violation("custom_excerpt.too_long", LOW,
|
||||||
|
f"custom_excerpt {len(ce)} chars > {CUSTOM_EXCERPT_MAX}", "shorten custom_excerpt")]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def r_social_fields(p):
|
||||||
|
"""OG/Twitter empties — the safest auto-fixable category (mirror meta_*)."""
|
||||||
|
out = []
|
||||||
|
fallbacks = {
|
||||||
|
"og_title": "meta_title",
|
||||||
|
"og_description": "meta_description",
|
||||||
|
"twitter_title": "meta_title",
|
||||||
|
"twitter_description": "meta_description",
|
||||||
|
}
|
||||||
|
for field, src in fallbacks.items():
|
||||||
|
if _empty(p.get(field)):
|
||||||
|
out.append(Violation(f"{field}.empty", LOW,
|
||||||
|
f"{field} empty", f"mirror from {src}"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def r_feature_image(p):
|
||||||
|
out = []
|
||||||
|
if _empty(p.get("feature_image")):
|
||||||
|
out.append(Violation("feature_image.missing", MED,
|
||||||
|
"feature_image missing (no social/share card image)", "add feature image"))
|
||||||
|
else:
|
||||||
|
if _empty(p.get("feature_image_alt")):
|
||||||
|
out.append(Violation("feature_image_alt.missing", LOW,
|
||||||
|
"feature_image_alt missing (a11y + image SEO)", "add feature image alt text"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def r_internal_links(p):
|
||||||
|
n = len(internal_links(p))
|
||||||
|
if n < MIN_INTERNAL_LINKS:
|
||||||
|
return [Violation("internal_links.too_few", MED,
|
||||||
|
f"{n} internal link(s) < {MIN_INTERNAL_LINKS} (weak interlinking)",
|
||||||
|
"add internal links to related articles")]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def r_title_equals_meta(p):
|
||||||
|
mt = _s(p.get("meta_title"))
|
||||||
|
title = _s(p.get("title"))
|
||||||
|
if mt and mt == title:
|
||||||
|
return [Violation("title_eq_meta_title", INFO,
|
||||||
|
"meta_title identical to title (often fine; review)", "")]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def r_jsonld(p):
|
||||||
|
if THEME_HANDLES_JSONLD:
|
||||||
|
return []
|
||||||
|
return [Violation("jsonld.missing", MED, "no BlogPosting JSON-LD", "add JSON-LD")]
|
||||||
|
|
||||||
|
|
||||||
|
RULES = [
|
||||||
|
r_meta_title,
|
||||||
|
r_meta_description,
|
||||||
|
r_custom_excerpt,
|
||||||
|
r_social_fields,
|
||||||
|
r_feature_image,
|
||||||
|
r_internal_links,
|
||||||
|
r_title_equals_meta,
|
||||||
|
r_jsonld,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def check_post(post):
|
||||||
|
"""Run every rule against one post dict → list[Violation]."""
|
||||||
|
out = []
|
||||||
|
for rule in RULES:
|
||||||
|
out.extend(rule(post))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def score(violations):
|
||||||
|
"""Total severity weight (for ranking posts worst-first); INFO counts 0."""
|
||||||
|
return sum(v.severity for v in violations)
|
||||||
Reference in New Issue
Block a user