diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index b38f6bb..edeafe7 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -20,6 +20,21 @@ jobs: with: 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 id: tag run: echo "TAG=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bc5661f --- /dev/null +++ b/Makefile @@ -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 diff --git a/src/seo/.rules.sha256 b/src/seo/.rules.sha256 new file mode 100644 index 0000000..dac6e23 --- /dev/null +++ b/src/seo/.rules.sha256 @@ -0,0 +1 @@ +b8faa93b1f7727d3870e18f69b68283d9a97ed9da819ef0cfa79a60cc2c4ab70 diff --git a/src/seo/__init__.py b/src/seo/__init__.py new file mode 100644 index 0000000..2d6eaac --- /dev/null +++ b/src/seo/__init__.py @@ -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 diff --git a/src/seo/_vendor_header.py b/src/seo/_vendor_header.py new file mode 100644 index 0000000..28569ff --- /dev/null +++ b/src/seo/_vendor_header.py @@ -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) ===== diff --git a/src/seo/rules.py b/src/seo/rules.py new file mode 100644 index 0000000..aaf9ee4 --- /dev/null +++ b/src/seo/rules.py @@ -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"}} ...