Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933976e310 | ||
|
|
6d39875fcf | ||
|
|
7a995da88f | ||
|
|
89aa8d6030 | ||
|
|
b6dc5192b8 |
@@ -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
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Core
|
# Core
|
||||||
fastapi==0.138.0
|
fastapi==0.138.1
|
||||||
uvicorn==0.30.0
|
uvicorn==0.30.0
|
||||||
python-telegram-bot==21.5
|
python-telegram-bot==21.5
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
|||||||
+19
-2
@@ -278,7 +278,11 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
|
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
output_arg = ctx.args[0].lower() if ctx.args else ""
|
output_arg = ctx.args[0].lower() if ctx.args else ""
|
||||||
lang = "en" if len(ctx.args) > 1 and ctx.args[1].lower() == "en" else "es"
|
rest = [a.lower() for a in ctx.args[1:]]
|
||||||
|
lang = "en" if "en" in rest else "es"
|
||||||
|
# `/generate blog en dry` forces SEO dry-run for this one call (proposes SEO to
|
||||||
|
# Telegram, writes a bare draft). Global default still comes from SEO_AUTOFILL.
|
||||||
|
seo_override = "dryrun" if ("dry" in rest or "dryrun" in rest) else None
|
||||||
|
|
||||||
type_map = {
|
type_map = {
|
||||||
"podcast": OutputType.PODCAST,
|
"podcast": OutputType.PODCAST,
|
||||||
@@ -347,7 +351,8 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
processor = ContentProcessor(db, ollama)
|
processor = ContentProcessor(db, ollama)
|
||||||
generator = OutputGenerator(db, ollama, processor)
|
generator = OutputGenerator(db, ollama, processor)
|
||||||
|
|
||||||
output = await generator.generate(session_id, output_type, gen_progress, lang=lang)
|
output = await generator.generate(session_id, output_type, gen_progress,
|
||||||
|
lang=lang, seo_override=seo_override)
|
||||||
|
|
||||||
# Send as file if very long
|
# Send as file if very long
|
||||||
if len(output) > 8000:
|
if len(output) > 8000:
|
||||||
@@ -384,6 +389,18 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
else:
|
else:
|
||||||
await send_chunked(update.message, output)
|
await send_chunked(update.message, output)
|
||||||
|
|
||||||
|
# SEO autofill / dry-run summary — a SEPARATE short message so it is never
|
||||||
|
# buried inside the long .md document. None on the flag-off path.
|
||||||
|
if getattr(generator, "last_publish_notice", None):
|
||||||
|
try:
|
||||||
|
await update.message.reply_text(
|
||||||
|
generator.last_publish_notice,
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to send SEO summary message", error=str(e))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stats = await db.get_usage_stats(session_id)
|
stats = await db.get_usage_stats(session_id)
|
||||||
total_cost = sum(s.get("total_cost", 0) for s in stats)
|
total_cost = sum(s.get("total_cost", 0) for s in stats)
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ class Settings(BaseSettings):
|
|||||||
ghost_url_en: str = Field("", env="GHOST_URL_EN")
|
ghost_url_en: str = Field("", env="GHOST_URL_EN")
|
||||||
ghost_api_key_en: str = Field("", env="GHOST_API_KEY_EN")
|
ghost_api_key_en: str = Field("", env="GHOST_API_KEY_EN")
|
||||||
|
|
||||||
|
# SEO autofill — "off" | "on" | "dryrun" (default off).
|
||||||
|
# off = today's exact behavior (bare draft, no second LLM call).
|
||||||
|
# on = adds best-effort meta/OG/Twitter/tags/internal-links to the DRAFT
|
||||||
|
# (status stays "draft" — NEVER auto-publishes; see src/seo/autofill.py).
|
||||||
|
# dryrun = runs the full pipeline + reports the proposed SEO to Telegram, but
|
||||||
|
# writes a BARE draft (no seo fields), for inspection before trusting
|
||||||
|
# the write path. A `/generate blog en dry` arg forces dryrun per-call.
|
||||||
|
seo_autofill: str = Field("off", env="SEO_AUTOFILL")
|
||||||
|
|
||||||
# Alerts
|
# Alerts
|
||||||
cost_alert_threshold: float = Field(0.15, env="COST_ALERT_THRESHOLD")
|
cost_alert_threshold: float = Field(0.15, env="COST_ALERT_THRESHOLD")
|
||||||
|
|
||||||
@@ -61,6 +70,15 @@ class Settings(BaseSettings):
|
|||||||
return []
|
return []
|
||||||
return [int(uid.strip()) for uid in self.telegram_allowed_users.split(",") if uid.strip()]
|
return [int(uid.strip()) for uid in self.telegram_allowed_users.split(",") if uid.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def seo_autofill_enabled(self) -> bool:
|
||||||
|
"""True when autofill should run (either live 'on' or 'dryrun')."""
|
||||||
|
return (self.seo_autofill or "").strip().lower() in ("on", "true", "1", "yes", "dryrun")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def seo_autofill_dryrun(self) -> bool:
|
||||||
|
return (self.seo_autofill or "").strip().lower() == "dryrun"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
|
|||||||
+230
-46
@@ -278,8 +278,116 @@ def _strip_researchowl_header(content: str) -> str:
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def _seo_checklist(slug: str) -> str:
|
||||||
|
"""Static pre-publish SEO reminder appended to the draft-published notice.
|
||||||
|
NOT a validator call: the fresh draft has no meta yet, so checking now would
|
||||||
|
be all-fail noise. Jose runs `seo-check <slug>` after filling these in."""
|
||||||
|
return (
|
||||||
|
"\n\n⚠️ Antes de publicar, añade: meta title, meta description (≤145), "
|
||||||
|
"custom excerpt, OG/Twitter, feature image + alt, 2-3 internal links "
|
||||||
|
"(donde sea natural)\n"
|
||||||
|
f"Luego valida: seo-check {slug}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Language-aware default tag when no seo/tags are supplied. Fixes the historical
|
||||||
|
# hardcoded "investigacion" that mis-tagged EN posts; EN canonical is "investigation".
|
||||||
|
_DEFAULT_TAG = {"en": "investigation", "es": "investigacion"}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_seo_mode(override: str | None = None) -> str:
|
||||||
|
"""Effective SEO autofill mode: 'off' | 'on' | 'dryrun'.
|
||||||
|
A per-call override ('dryrun'/'on', e.g. from `/generate blog en dry`) wins;
|
||||||
|
otherwise it derives from settings.seo_autofill."""
|
||||||
|
if override in ("on", "dryrun"):
|
||||||
|
return override
|
||||||
|
if settings.seo_autofill_dryrun:
|
||||||
|
return "dryrun"
|
||||||
|
if settings.seo_autofill_enabled:
|
||||||
|
return "on"
|
||||||
|
return "off"
|
||||||
|
|
||||||
|
|
||||||
|
def _seo_checklist_slim(slug: str) -> str:
|
||||||
|
"""Slimmed checklist for the autofill path: meta/OG/excerpt/tags/links are
|
||||||
|
already on the draft, so only the human-only bits remain."""
|
||||||
|
return (
|
||||||
|
"⚠️ Antes de publicar: añade *feature image + alt*, luego:\n"
|
||||||
|
f"`seo-check {slug}`"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bare_ghost_notice(ghost: "GhostPublisher", post: dict) -> str:
|
||||||
|
"""Today's exact draft-published notice (editor link + full checklist).
|
||||||
|
Used for the flag-off path and as the autofill failure fallback."""
|
||||||
|
return (
|
||||||
|
f"\n\n---\n"
|
||||||
|
f"📤 *Borrador publicado en Ghost*\n"
|
||||||
|
f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}"
|
||||||
|
f"{_seo_checklist(post.get('slug', ''))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seo_link_summary(inserted_pairs: list[dict], n_suggestions: int) -> str:
|
||||||
|
"""Report ACTUALLY-INSERTED links only ("phrase → /slug/"), with a separate
|
||||||
|
count of well-formed suggestions that were skipped (no verbatim body match).
|
||||||
|
Never surfaces raw rejected suggestions."""
|
||||||
|
n = len(inserted_pairs)
|
||||||
|
skipped = max(0, n_suggestions - n)
|
||||||
|
head = f"{n} insertados" + (f", {skipped} omitidos" if skipped else "")
|
||||||
|
if not inserted_pairs:
|
||||||
|
return head
|
||||||
|
body = "; ".join(f"{p['phrase']} → /{p['slug']}/" for p in inserted_pairs)
|
||||||
|
return f"{head} — {body}"
|
||||||
|
|
||||||
|
|
||||||
|
def _seo_live_message(ghost: "GhostPublisher", post: dict, seo: dict, inserted_pairs: list[dict]) -> str:
|
||||||
|
"""Separate Telegram message for the live autofill path (SEO written to draft)."""
|
||||||
|
slug = post.get("slug", "")
|
||||||
|
warns = seo.get("seo_warnings") or []
|
||||||
|
warn_line = ("\n⚠️ revisar: " + "; ".join(warns)) if warns else ""
|
||||||
|
n_sug = len(seo.get("internal_links", []))
|
||||||
|
return (
|
||||||
|
f"📤 *Borrador en Ghost — SEO autorrelleno*\n"
|
||||||
|
f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}\n\n"
|
||||||
|
f"🔖 *SEO escrito en el draft:*\n"
|
||||||
|
f"• meta title ({len(seo['meta_title'])}): {seo['meta_title']}\n"
|
||||||
|
f"• meta desc ({len(seo['meta_description'])}): {seo['meta_description']}\n"
|
||||||
|
f"• excerpt: {len(seo['custom_excerpt'])} car.\n"
|
||||||
|
f"• tags: {', '.join(seo['tags'])}\n"
|
||||||
|
f"• internal links: {_seo_link_summary(inserted_pairs, n_sug)}"
|
||||||
|
f"{warn_line}\n\n"
|
||||||
|
f"🖼️ *Imagen sugerida:*\n"
|
||||||
|
f"`imagefinder --query \"{seo['image_query']}\" --context \"{seo['image_context']}\" --lang {ghost.lang}`\n\n"
|
||||||
|
f"{_seo_checklist_slim(slug)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seo_dryrun_message(ghost: "GhostPublisher", post: dict, seo: dict, inserted_pairs: list[dict]) -> str:
|
||||||
|
"""Separate Telegram message for DRY-RUN: bare draft created, SEO only proposed."""
|
||||||
|
slug = post.get("slug", "")
|
||||||
|
warns = seo.get("seo_warnings") or []
|
||||||
|
warn_line = ("\n⚠️ revisar: " + "; ".join(warns)) if warns else ""
|
||||||
|
n_sug = len(seo.get("internal_links", []))
|
||||||
|
return (
|
||||||
|
f"🧪 *DRY-RUN SEO* — draft creado SIN escribir SEO (solo revisión)\n"
|
||||||
|
f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}\n\n"
|
||||||
|
f"🔖 *SEO propuesto (NO escrito en Ghost):*\n"
|
||||||
|
f"• meta title ({len(seo['meta_title'])}): {seo['meta_title']}\n"
|
||||||
|
f"• meta desc ({len(seo['meta_description'])}): {seo['meta_description']}\n"
|
||||||
|
f"• excerpt ({len(seo['custom_excerpt'])} car.): {seo['custom_excerpt']}\n"
|
||||||
|
f"• tags: {', '.join(seo['tags'])}\n"
|
||||||
|
f"• internal links que se insertarían: {_seo_link_summary(inserted_pairs, n_sug)}"
|
||||||
|
f"{warn_line}\n\n"
|
||||||
|
f"🖼️ *Imagen sugerida:*\n"
|
||||||
|
f"`imagefinder --query \"{seo['image_query']}\" --context \"{seo['image_context']}\" --lang {ghost.lang}`\n\n"
|
||||||
|
f"{_seo_checklist_slim(slug)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GhostPublisher:
|
class GhostPublisher:
|
||||||
def __init__(self, lang: str = "es"):
|
def __init__(self, lang: str = "es"):
|
||||||
|
self.lang = lang
|
||||||
if lang == "en":
|
if lang == "en":
|
||||||
self.url = (settings.ghost_url_en or "").rstrip("/")
|
self.url = (settings.ghost_url_en or "").rstrip("/")
|
||||||
self.api_key = settings.ghost_api_key_en or ""
|
self.api_key = settings.ghost_api_key_en or ""
|
||||||
@@ -290,6 +398,16 @@ class GhostPublisher:
|
|||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(self.url and self.api_key)
|
return bool(self.url and self.api_key)
|
||||||
|
|
||||||
|
def _build_html(self, markdown_content: str) -> str:
|
||||||
|
"""Canonical markdown → Ghost body HTML conversion: strip the ResearchOwl
|
||||||
|
header, render markdown, drop the first <h1> (Ghost renders the title).
|
||||||
|
Shared so callers that need the linked HTML produce the SAME html that gets
|
||||||
|
posted (no drift between link-insertion and the mobiledoc body)."""
|
||||||
|
import markdown as _md
|
||||||
|
clean = _strip_researchowl_header(markdown_content)
|
||||||
|
html = _md.markdown(clean, extensions=["extra"])
|
||||||
|
return re.sub(r"<h1[^>]*>.*?</h1>", "", html, count=1, flags=re.DOTALL).lstrip()
|
||||||
|
|
||||||
def _make_token(self) -> str:
|
def _make_token(self) -> str:
|
||||||
key_id, secret = self.api_key.split(":", 1)
|
key_id, secret = self.api_key.split(":", 1)
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
@@ -302,18 +420,25 @@ class GhostPublisher:
|
|||||||
return f"{signing}.{sig}"
|
return f"{signing}.{sig}"
|
||||||
|
|
||||||
async def publish_draft(self, title: str, markdown_content: str,
|
async def publish_draft(self, title: str, markdown_content: str,
|
||||||
tags: list[str] | None = None) -> dict:
|
tags: list[str] | None = None,
|
||||||
|
seo: dict | None = None,
|
||||||
|
body_html: str | None = None) -> dict:
|
||||||
|
"""Create a Ghost DRAFT. status is ALWAYS "draft" — never published.
|
||||||
|
|
||||||
|
Purely-additive SEO hooks (both default None → byte-identical to the
|
||||||
|
original behavior):
|
||||||
|
* body_html — pre-converted + internal-linked HTML from the caller; used
|
||||||
|
verbatim when given, else markdown_content is converted as before.
|
||||||
|
* seo — a generate_seo_fields dict; when given, its meta/OG/Twitter fields
|
||||||
|
are added to the post and its (allow-list) tags are used.
|
||||||
|
"""
|
||||||
import aiohttp as _aio
|
import aiohttp as _aio
|
||||||
import markdown as _md
|
|
||||||
|
|
||||||
clean = _strip_researchowl_header(markdown_content)
|
# Caller-supplied linked HTML wins; otherwise convert markdown as before.
|
||||||
html = _md.markdown(clean, extensions=["extra"])
|
html = body_html if body_html is not None else self._build_html(markdown_content)
|
||||||
|
|
||||||
# Ghost añade el título automáticamente — eliminar el primer <h1> para evitar duplicado
|
|
||||||
html = re.sub(r"<h1[^>]*>.*?</h1>", "", html, count=1, flags=re.DOTALL).lstrip()
|
|
||||||
|
|
||||||
logger.info("Ghost publish_draft", html_length=len(html),
|
logger.info("Ghost publish_draft", html_length=len(html),
|
||||||
html_preview=html[:200])
|
html_preview=html[:200], seo=bool(seo))
|
||||||
|
|
||||||
if not html.strip():
|
if not html.strip():
|
||||||
raise ValueError("Ghost: HTML vacío tras conversión markdown — contenido no enviado")
|
raise ValueError("Ghost: HTML vacío tras conversión markdown — contenido no enviado")
|
||||||
@@ -330,14 +455,25 @@ class GhostPublisher:
|
|||||||
})
|
})
|
||||||
|
|
||||||
token = self._make_token()
|
token = self._make_token()
|
||||||
body = {
|
# Language-aware default tag (fixes the hardcoded "investigacion" for EN).
|
||||||
"posts": [{
|
tag_names = tags or [_DEFAULT_TAG.get(self.lang, "investigation")]
|
||||||
|
post_obj = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"mobiledoc": mobiledoc,
|
"mobiledoc": mobiledoc,
|
||||||
"status": "draft",
|
"status": "draft", # NEVER "published" — draft only, always.
|
||||||
"tags": [{"name": t} for t in (tags or ["investigacion"])],
|
"tags": [{"name": t} for t in tag_names],
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
if seo:
|
||||||
|
post_obj.update({
|
||||||
|
"meta_title": seo["meta_title"],
|
||||||
|
"meta_description": seo["meta_description"],
|
||||||
|
"custom_excerpt": seo["custom_excerpt"],
|
||||||
|
"og_title": seo["og_title"],
|
||||||
|
"og_description": seo["og_description"],
|
||||||
|
"twitter_title": seo["twitter_title"],
|
||||||
|
"twitter_description": seo["twitter_description"],
|
||||||
|
})
|
||||||
|
body = {"posts": [post_obj]}
|
||||||
async with _aio.ClientSession() as sess:
|
async with _aio.ClientSession() as sess:
|
||||||
async with sess.post(
|
async with sess.post(
|
||||||
f"{self.url}/ghost/api/admin/posts/",
|
f"{self.url}/ghost/api/admin/posts/",
|
||||||
@@ -360,15 +496,86 @@ class OutputGenerator:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.ollama = ollama
|
self.ollama = ollama
|
||||||
self.processor = processor
|
self.processor = processor
|
||||||
|
# Set during a blog generation when the autofill/dry-run path runs: a short
|
||||||
|
# markdown SEO summary the bot sends as a SEPARATE Telegram message (so it is
|
||||||
|
# never buried inside the long .md document). None on the flag-off path.
|
||||||
|
self.last_publish_notice: str | None = None
|
||||||
|
|
||||||
|
async def _publish_blog_to_ghost(self, lang: str, full_output: str, topic: str,
|
||||||
|
session_id: int, seo_override: str | None) -> str:
|
||||||
|
"""Publish a blog DRAFT to Ghost, gated by the SEO autofill mode.
|
||||||
|
|
||||||
|
Returns the ghost_notice to APPEND to the returned document (flag-off /
|
||||||
|
fallback bare-draft path only). For the autofill 'on'/'dryrun' paths it sets
|
||||||
|
self.last_publish_notice (a separate Telegram message) and returns "".
|
||||||
|
|
||||||
|
NEVER raises and NEVER blocks publishing: any autofill failure degrades to
|
||||||
|
today's exact bare-draft publish. status is always "draft".
|
||||||
|
"""
|
||||||
|
ghost = GhostPublisher(lang=lang)
|
||||||
|
if not ghost.is_configured():
|
||||||
|
return ""
|
||||||
|
title = _extract_title(full_output) or topic
|
||||||
|
mode = _resolve_seo_mode(seo_override)
|
||||||
|
|
||||||
|
if mode in ("on", "dryrun"):
|
||||||
|
try:
|
||||||
|
from src.seo.autofill import (
|
||||||
|
fetch_published_menu, generate_seo_fields, insert_internal_links,
|
||||||
|
)
|
||||||
|
article_md = _strip_researchowl_header(full_output)
|
||||||
|
menu = await fetch_published_menu(lang)
|
||||||
|
seo = await generate_seo_fields(
|
||||||
|
article_md, menu, lang, title=title,
|
||||||
|
db=self.db, session_id=session_id,
|
||||||
|
)
|
||||||
|
if seo is None:
|
||||||
|
raise RuntimeError("generate_seo_fields returned None")
|
||||||
|
# Build the SAME html that will be posted, then link it deterministically.
|
||||||
|
html = ghost._build_html(article_md)
|
||||||
|
linked_html, inserted_pairs = insert_internal_links(
|
||||||
|
html, seo["internal_links"], menu, lang)
|
||||||
|
|
||||||
|
if mode == "dryrun":
|
||||||
|
# Bare draft (today's write path) — do NOT write seo fields; only
|
||||||
|
# surface the proposal so Jose can inspect before trusting writes.
|
||||||
|
result = await ghost.publish_draft(title, full_output)
|
||||||
|
post = result["posts"][0]
|
||||||
|
self.last_publish_notice = _seo_dryrun_message(ghost, post, seo, inserted_pairs)
|
||||||
|
else:
|
||||||
|
result = await ghost.publish_draft(
|
||||||
|
title, full_output, tags=seo["tags"], seo=seo,
|
||||||
|
body_html=linked_html)
|
||||||
|
post = result["posts"][0]
|
||||||
|
self.last_publish_notice = _seo_live_message(ghost, post, seo, inserted_pairs)
|
||||||
|
logger.info("Auto-published blog to Ghost",
|
||||||
|
mode=mode, post_id=post["id"], links=len(inserted_pairs))
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("SEO autofill failed; falling back to bare draft",
|
||||||
|
error=str(e))
|
||||||
|
# fall through to the bare-draft publish below
|
||||||
|
|
||||||
|
# OFF mode, or autofill failed → today's exact bare-draft publish.
|
||||||
|
try:
|
||||||
|
result = await ghost.publish_draft(title, full_output)
|
||||||
|
post = result["posts"][0]
|
||||||
|
logger.info("Auto-published blog to Ghost (bare)", post_id=post["id"])
|
||||||
|
return _bare_ghost_notice(ghost, post)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Auto-publish to Ghost failed", error=str(e))
|
||||||
|
return ""
|
||||||
|
|
||||||
async def generate(self, session_id: int, output_type: OutputType,
|
async def generate(self, session_id: int, output_type: OutputType,
|
||||||
progress_callback=None, lang: str = "es") -> str:
|
progress_callback=None, lang: str = "es",
|
||||||
|
seo_override: str | None = None) -> str:
|
||||||
"""Generate an output for a research session"""
|
"""Generate an output for a research session"""
|
||||||
|
self.last_publish_notice = None
|
||||||
if output_type in (OutputType.REPORT_EXTENDED,
|
if output_type in (OutputType.REPORT_EXTENDED,
|
||||||
OutputType.BLOG_EXTENDED,
|
OutputType.BLOG_EXTENDED,
|
||||||
OutputType.PODCAST_EXTENDED):
|
OutputType.PODCAST_EXTENDED):
|
||||||
return await self.generate_extended(session_id, output_type, progress_callback,
|
return await self.generate_extended(session_id, output_type, progress_callback,
|
||||||
lang=lang)
|
lang=lang, seo_override=seo_override)
|
||||||
|
|
||||||
session = await self.db.get_session(session_id)
|
session = await self.db.get_session(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
@@ -414,23 +621,11 @@ class OutputGenerator:
|
|||||||
# Save to DB
|
# Save to DB
|
||||||
await self.db.save_output(session_id, output_type, full_output)
|
await self.db.save_output(session_id, output_type, full_output)
|
||||||
|
|
||||||
# Auto-publish to Ghost for blog outputs
|
# Auto-publish to Ghost for blog outputs (autofill mode gated inside helper).
|
||||||
ghost_notice = ""
|
ghost_notice = ""
|
||||||
if output_type in (OutputType.BLOG, OutputType.BLOG_EXTENDED):
|
if output_type in (OutputType.BLOG, OutputType.BLOG_EXTENDED):
|
||||||
ghost = GhostPublisher(lang=lang)
|
ghost_notice = await self._publish_blog_to_ghost(
|
||||||
if ghost.is_configured():
|
lang, full_output, topic, session_id, seo_override)
|
||||||
try:
|
|
||||||
title = _extract_title(full_output) or topic
|
|
||||||
result = await ghost.publish_draft(title, full_output)
|
|
||||||
post = result["posts"][0]
|
|
||||||
ghost_notice = (
|
|
||||||
f"\n\n---\n"
|
|
||||||
f"📤 *Borrador publicado en Ghost*\n"
|
|
||||||
f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}"
|
|
||||||
)
|
|
||||||
logger.info("Auto-published blog to Ghost", post_id=post["id"])
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Auto-publish to Ghost failed", error=str(e))
|
|
||||||
|
|
||||||
logger.info("Output generated", type=output_type, length=len(full_output))
|
logger.info("Output generated", type=output_type, length=len(full_output))
|
||||||
return full_output + ghost_notice
|
return full_output + ghost_notice
|
||||||
@@ -487,7 +682,8 @@ class OutputGenerator:
|
|||||||
return systems.get(output_type, "You are a helpful research assistant.")
|
return systems.get(output_type, "You are a helpful research assistant.")
|
||||||
|
|
||||||
async def generate_extended(self, session_id: int, output_type: OutputType,
|
async def generate_extended(self, session_id: int, output_type: OutputType,
|
||||||
progress_callback=None, lang: str = "es") -> str:
|
progress_callback=None, lang: str = "es",
|
||||||
|
seo_override: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Generación por secciones para outputs exhaustivos.
|
Generación por secciones para outputs exhaustivos.
|
||||||
1. Recupera muestra de contexto para el outline
|
1. Recupera muestra de contexto para el outline
|
||||||
@@ -588,23 +784,11 @@ class OutputGenerator:
|
|||||||
|
|
||||||
await self.db.save_output(session_id, output_type, full_output)
|
await self.db.save_output(session_id, output_type, full_output)
|
||||||
|
|
||||||
# Auto-publish to Ghost for extended blog outputs
|
# Auto-publish to Ghost for extended blog outputs (autofill mode gated inside).
|
||||||
ghost_notice = ""
|
ghost_notice = ""
|
||||||
if output_type == OutputType.BLOG_EXTENDED:
|
if output_type == OutputType.BLOG_EXTENDED:
|
||||||
ghost = GhostPublisher(lang=lang)
|
ghost_notice = await self._publish_blog_to_ghost(
|
||||||
if ghost.is_configured():
|
lang, full_output, topic, session_id, seo_override)
|
||||||
try:
|
|
||||||
title = _extract_title(full_output) or topic
|
|
||||||
result = await ghost.publish_draft(title, full_output)
|
|
||||||
post = result["posts"][0]
|
|
||||||
ghost_notice = (
|
|
||||||
f"\n\n---\n"
|
|
||||||
f"📤 *Borrador publicado en Ghost*\n"
|
|
||||||
f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}"
|
|
||||||
)
|
|
||||||
logger.info("Auto-published extended blog to Ghost", post_id=post["id"])
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Auto-publish to Ghost failed (extended)", error=str(e))
|
|
||||||
|
|
||||||
logger.info("Extended output generated", type=output_type,
|
logger.info("Extended output generated", type=output_type,
|
||||||
sections=len(sections), length=len(full_output))
|
sections=len(sections), length=len(full_output))
|
||||||
|
|||||||
@@ -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,656 @@
|
|||||||
|
"""SEO autofill — best-effort meta/OG/Twitter/tags/internal-links for a draft.
|
||||||
|
|
||||||
|
Step 2 (this file): pure, testable units. NOTHING here is wired into the live
|
||||||
|
publish path yet (that is Step 3, behind `settings.seo_autofill`). Every public
|
||||||
|
function is isolation-safe: on ANY failure it degrades (menu → [], fields → None,
|
||||||
|
link insertion → unchanged html), so it can never block or break article
|
||||||
|
publishing, and it never changes a post's `status` away from `draft`.
|
||||||
|
|
||||||
|
Three units:
|
||||||
|
* fetch_published_menu(lang) — live "menu" of existing posts to link to.
|
||||||
|
* generate_seo_fields(...) — one Haiku JSON call → validated SEO dict.
|
||||||
|
* insert_internal_links(...) — deterministic, LLM never touches HTML.
|
||||||
|
|
||||||
|
The SEO rules engine (length limits, link counting) is the SAME vendored module
|
||||||
|
the auditor (Tool A) and validator (Tool C) use: src/seo/rules.py.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.llm import get_anthropic_client
|
||||||
|
from src.seo import rules as R
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
# Canonical site host per language. The wrapped <a> hrefs use the www host (the
|
||||||
|
# site's canonical form); rules.internal_links still counts them because its
|
||||||
|
# SITE_HOST ("theexclusionzone.com") is a substring of "www.theexclusionzone.com".
|
||||||
|
SITE_BY_LANG = {
|
||||||
|
"en": "www.theexclusionzone.com",
|
||||||
|
"es": "www.zonadeexclusion.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tag allow-list — the model may ONLY pick from these; invented tags are dropped
|
||||||
|
# deterministically after the call (never trust the LLM to self-limit). EN is the
|
||||||
|
# live vocabulary Jose confirmed; ES is not yet constrained, so we don't enforce
|
||||||
|
# it (model picks freely there until an ES vocab is pinned down).
|
||||||
|
ALLOWED_TAGS = {
|
||||||
|
"en": [
|
||||||
|
"uap", "declassified", "military-cases", "classic-cases",
|
||||||
|
"investigation", "spain-cases", "latin-america",
|
||||||
|
],
|
||||||
|
# "es": [...] # TODO: pin the live ES tag vocabulary before constraining.
|
||||||
|
}
|
||||||
|
|
||||||
|
# Language-aware default tag when nothing from the allow-list fits / the model
|
||||||
|
# returns none. EN canonical is "investigation" (NOT the legacy "investigacion").
|
||||||
|
DEFAULT_TAG = {"en": "investigation", "es": "investigacion"}
|
||||||
|
|
||||||
|
MAX_INTERNAL_LINKS = 3
|
||||||
|
|
||||||
|
# Blocking violations at generation time = only the length/missing rules on the
|
||||||
|
# fields WE generate. feature_image*, internal_links.too_few, social *.empty and
|
||||||
|
# the INFO rules are expected / non-blocking at draft time.
|
||||||
|
_BLOCKING_PREFIXES = ("meta_title.", "meta_description.", "custom_excerpt.")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 1. Published menu ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def fetch_published_menu(lang: str) -> list[dict]:
|
||||||
|
"""Live list of published posts on the same site/lang: [{slug, title}, ...].
|
||||||
|
|
||||||
|
Reuses GhostPublisher's per-lang URL + JWT minting. On ANY failure
|
||||||
|
(unconfigured, timeout, non-200, bad JSON) → return [] and log; never raise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Lazy import to avoid a heavy/circular import at module load.
|
||||||
|
from src.generator.generator import GhostPublisher
|
||||||
|
import aiohttp as _aio
|
||||||
|
|
||||||
|
pub = GhostPublisher(lang=lang)
|
||||||
|
if not pub.is_configured():
|
||||||
|
logger.warning("seo.menu: Ghost not configured for lang", lang=lang)
|
||||||
|
return []
|
||||||
|
|
||||||
|
token = pub._make_token()
|
||||||
|
url = (
|
||||||
|
f"{pub.url}/ghost/api/admin/posts/"
|
||||||
|
"?filter=status:published&fields=id,slug,title&limit=all"
|
||||||
|
)
|
||||||
|
timeout = _aio.ClientTimeout(total=30)
|
||||||
|
async with _aio.ClientSession(timeout=timeout) as sess:
|
||||||
|
async with sess.get(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Ghost {token}",
|
||||||
|
"Accept-Version": "v5.0",
|
||||||
|
},
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
body = await resp.text()
|
||||||
|
logger.warning("seo.menu: non-200 from Ghost",
|
||||||
|
status=resp.status, body=body[:200])
|
||||||
|
return []
|
||||||
|
data = await resp.json()
|
||||||
|
|
||||||
|
menu = [
|
||||||
|
{"slug": p["slug"], "title": p.get("title", "")}
|
||||||
|
for p in data.get("posts", [])
|
||||||
|
if p.get("slug")
|
||||||
|
]
|
||||||
|
logger.info("seo.menu: fetched", lang=lang, count=len(menu))
|
||||||
|
return menu
|
||||||
|
except Exception as e: # noqa: BLE001 — isolation guarantee
|
||||||
|
logger.warning("seo.menu: fetch failed, using empty menu",
|
||||||
|
lang=lang, error=str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 2. SEO field generation (one Haiku JSON call) ──────────────────────────
|
||||||
|
|
||||||
|
def _system_prompt(lang: str) -> str:
|
||||||
|
allow = ", ".join(ALLOWED_TAGS.get(lang, []))
|
||||||
|
out_lang = "SPANISH" if lang == "es" else "ENGLISH"
|
||||||
|
tag_clause = (
|
||||||
|
f"TAGS: choose 2-4 tags ONLY from this exact list (never invent a tag, "
|
||||||
|
f"never translate it, never use \"investigacion\"): {allow}.\n"
|
||||||
|
if allow else
|
||||||
|
"TAGS: 2-4 lowercase-hyphenated topical tags appropriate to the article.\n"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"You are an SEO editor for an investigative blog about UAP/UFO history.\n"
|
||||||
|
"You are given a FINISHED article and a MENU of existing published posts on the site.\n"
|
||||||
|
"Return ONLY a single JSON object — no prose, no markdown fences — with these fields.\n\n"
|
||||||
|
"HARD LIMITS (count characters; never exceed — and aim BELOW the cap for safety):\n"
|
||||||
|
f"- meta_title: <= {R.META_TITLE_MAX} characters (aim ~50). Compelling, specific, "
|
||||||
|
"front-load the key entity.\n"
|
||||||
|
f"- meta_description: <= {R.META_DESC_MAX} characters (aim ~125 — one SHORT sentence). "
|
||||||
|
"Earn the click; describe what the article answers, not clickbait.\n"
|
||||||
|
f"- custom_excerpt: <= {R.CUSTOM_EXCERPT_MAX} characters (aim ~260). 1-2 sentences "
|
||||||
|
"summarizing the article's substance.\n\n"
|
||||||
|
+ tag_clause +
|
||||||
|
"\nINTERNAL LINKS — quality over count, mirror the site's manual policy:\n"
|
||||||
|
"- The \"phrase\" MUST be a SHORT entity name (typically 1-4 words: a person, program, "
|
||||||
|
"office, or named incident) copied VERBATIM from the ARTICLE body. NEVER use a menu "
|
||||||
|
"title or slug as the phrase — the phrase has to literally appear in the article text.\n"
|
||||||
|
"- Link that phrase to a MENU post ONLY if that post's PRIMARY SUBJECT — what it is "
|
||||||
|
"actually ABOUT (judge from its slug + title) — IS that same entity. Not merely a post "
|
||||||
|
"that mentions it. If unsure the target is really ABOUT the entity, DO NOT link it.\n"
|
||||||
|
" GOOD: phrase \"AARO\" → aaro-... post (short phrase from the body; that post is ABOUT AARO).\n"
|
||||||
|
" GOOD: phrase \"GIMBAL\" → gimbal-gofast-... post (it is ABOUT the GIMBAL video).\n"
|
||||||
|
" BAD: phrase \"AATIP\" → gimbal-gofast-... post (that post is NOT about AATIP). Skip it.\n"
|
||||||
|
" BAD: using the full menu title \"AARO's UFO Investigation: What Eight Decades...\" as the "
|
||||||
|
"phrase (that string is not in the article body — it will fail to match). Use \"AARO\".\n"
|
||||||
|
"- 0 to 3 links. NEVER force a link to hit a number. ONE strong, on-subject link beats two "
|
||||||
|
"loose ones. Skip if no target is genuinely about the phrase.\n"
|
||||||
|
"- Use only slugs from the MENU. Never link the article to itself.\n"
|
||||||
|
"- Give the phrase EXACTLY as it appears in the article (case-sensitive), so it can be matched.\n\n"
|
||||||
|
"IMAGE: suggest a concrete image search query (subjects/objects a stock site would have — "
|
||||||
|
"NOT the headline) and a one-sentence context describing what the article is about.\n\n"
|
||||||
|
f"Write meta_title, meta_description, custom_excerpt, image_query and image_context in {out_lang}.\n\n"
|
||||||
|
"JSON schema (all fields required):\n"
|
||||||
|
'{"meta_title": "...", "meta_description": "...", "custom_excerpt": "...", '
|
||||||
|
'"tags": ["..."], "internal_links": [{"phrase": "...", "slug": "..."}], '
|
||||||
|
'"image_query": "...", "image_context": "..."}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_message(article_text: str, link_menu: list[dict]) -> str:
|
||||||
|
menu_lines = "\n".join(
|
||||||
|
f"- {m['slug']} — {m.get('title','')}" for m in link_menu
|
||||||
|
) or "(no existing posts)"
|
||||||
|
return (
|
||||||
|
"MENU of existing published posts (slug — title):\n"
|
||||||
|
f"{menu_lines}\n\n"
|
||||||
|
"ARTICLE:\n"
|
||||||
|
f"{article_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_object(text: str) -> dict:
|
||||||
|
"""Strip optional ``` fences and parse the first JSON object. Raises on failure."""
|
||||||
|
t = text.strip()
|
||||||
|
t = re.sub(r"^```(?:json)?\s*", "", t)
|
||||||
|
t = re.sub(r"\s*```$", "", t).strip()
|
||||||
|
# If extra prose snuck in, grab the outermost {...}.
|
||||||
|
if not t.startswith("{"):
|
||||||
|
m = re.search(r"\{.*\}", t, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
t = m.group(0)
|
||||||
|
obj = json.loads(t)
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("model did not return a JSON object")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
_REQUIRED_STR = ("meta_title", "meta_description", "custom_excerpt",
|
||||||
|
"image_query", "image_context")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(obj: dict, lang: str) -> dict:
|
||||||
|
"""Validate shape + constrain tags to the allow-list. Raises on missing required strings."""
|
||||||
|
for k in _REQUIRED_STR:
|
||||||
|
if not isinstance(obj.get(k), str) or not obj[k].strip():
|
||||||
|
raise ValueError(f"missing/empty required field: {k}")
|
||||||
|
|
||||||
|
raw_tags = obj.get("tags") or []
|
||||||
|
if not isinstance(raw_tags, list):
|
||||||
|
raw_tags = []
|
||||||
|
allow = ALLOWED_TAGS.get(lang)
|
||||||
|
if allow:
|
||||||
|
seen, tags = set(), []
|
||||||
|
allow_set = set(allow)
|
||||||
|
for t in raw_tags:
|
||||||
|
if isinstance(t, str):
|
||||||
|
s = t.strip().lower()
|
||||||
|
if s in allow_set and s not in seen:
|
||||||
|
seen.add(s)
|
||||||
|
tags.append(s)
|
||||||
|
if not tags:
|
||||||
|
tags = [DEFAULT_TAG.get(lang, "investigation")]
|
||||||
|
else:
|
||||||
|
tags = [t.strip().lower() for t in raw_tags
|
||||||
|
if isinstance(t, str) and t.strip()] or [DEFAULT_TAG.get(lang, "investigacion")]
|
||||||
|
|
||||||
|
links = []
|
||||||
|
for item in (obj.get("internal_links") or []):
|
||||||
|
if isinstance(item, dict) and item.get("phrase") and item.get("slug"):
|
||||||
|
links.append({"phrase": str(item["phrase"]), "slug": str(item["slug"])})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"meta_title": obj["meta_title"].strip(),
|
||||||
|
"meta_description": obj["meta_description"].strip(),
|
||||||
|
"custom_excerpt": obj["custom_excerpt"].strip(),
|
||||||
|
"tags": tags,
|
||||||
|
"internal_links": links,
|
||||||
|
"image_query": obj["image_query"].strip(),
|
||||||
|
"image_context": obj["image_context"].strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _markdown_to_html(article_text: str) -> str:
|
||||||
|
"""Same conversion publish_draft uses, so validation sees the real body."""
|
||||||
|
import markdown as _md
|
||||||
|
html = _md.markdown(article_text, extensions=["extra"])
|
||||||
|
return re.sub(r"<h1[^>]*>.*?</h1>", "", html, count=1, flags=re.DOTALL).lstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_post(fields: dict, html: str, title: str, slug: str) -> dict:
|
||||||
|
mt, md = fields["meta_title"], fields["meta_description"]
|
||||||
|
return {
|
||||||
|
"html": html,
|
||||||
|
"title": title,
|
||||||
|
"slug": slug or "",
|
||||||
|
"meta_title": mt,
|
||||||
|
"meta_description": md,
|
||||||
|
"custom_excerpt": fields["custom_excerpt"],
|
||||||
|
"og_title": mt, "og_description": md,
|
||||||
|
"twitter_title": mt, "twitter_description": md,
|
||||||
|
"feature_image": "", # human adds later
|
||||||
|
"feature_image_alt": "", # human adds later
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _blocking(violations) -> list:
|
||||||
|
return [v for v in violations if v.rule.startswith(_BLOCKING_PREFIXES)]
|
||||||
|
|
||||||
|
|
||||||
|
# Length-limited fields we generate. The retry aims at (limit - margin), well
|
||||||
|
# UNDER the hard limit: Haiku cannot count to an exact char count and reliably
|
||||||
|
# overshoots its target by 20-50 chars, so the margin must absorb that overshoot.
|
||||||
|
# Margins are per-field (bigger for the long free-text fields that overshoot most).
|
||||||
|
_LEN_LIMITS = {
|
||||||
|
"meta_title": R.META_TITLE_MAX,
|
||||||
|
"meta_description": R.META_DESC_MAX,
|
||||||
|
"custom_excerpt": R.CUSTOM_EXCERPT_MAX,
|
||||||
|
}
|
||||||
|
_LEN_MARGIN = {
|
||||||
|
"meta_title": 10, # target 50
|
||||||
|
"meta_description": 35, # target 110
|
||||||
|
"custom_excerpt": 45, # target 255
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Minimum useful length per field. If a CLEAN boundary-trim would drop a field
|
||||||
|
# below this, we DON'T trim it — better a slightly-over field a human nudges than
|
||||||
|
# a butchered stub. Falls back to path-1 (keep + flag) for that field only.
|
||||||
|
_MIN_USEFUL = {
|
||||||
|
"meta_title": 25,
|
||||||
|
"meta_description": 80,
|
||||||
|
"custom_excerpt": 120,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Connective / function words (EN + ES) we must not leave dangling at the end of
|
||||||
|
# a word-boundary trim — they all "expect more" after them, so ending on one reads
|
||||||
|
# as cut-off. Compared lowercased, after stripping trailing punctuation. Includes
|
||||||
|
# correlatives (neither/nor), interrogatives (why/what), and contracted auxiliaries
|
||||||
|
# (won't/can't) that surfaced as bad trims in testing.
|
||||||
|
_DANGLING_WORDS = {
|
||||||
|
# English — articles / prepositions / basic connectives
|
||||||
|
"and", "or", "but", "the", "a", "an", "of", "to", "in", "on", "for", "with",
|
||||||
|
"at", "by", "from", "as", "that", "which", "is", "was", "were", "this",
|
||||||
|
"its", "their", "his", "her", "into", "over", "after", "about", "between",
|
||||||
|
"during", "against", "among", "without", "within", "upon", "toward", "towards",
|
||||||
|
"via", "per", "amid", "despite", "near", "off", "out", "up", "down",
|
||||||
|
# English — correlatives / subordinators / interrogatives / negation
|
||||||
|
"nor", "neither", "either", "both", "whether", "while", "since", "though",
|
||||||
|
"although", "unless", "until", "because", "if", "than", "then", "yet", "so",
|
||||||
|
"not", "no", "why", "how", "when", "where", "what", "who", "whom", "whose",
|
||||||
|
# English — contracted auxiliaries (read as mid-clause)
|
||||||
|
"won't", "can't", "cannot", "don't", "doesn't", "didn't", "isn't", "aren't",
|
||||||
|
"wasn't", "weren't", "hasn't", "haven't", "still", "just", "ever",
|
||||||
|
# Spanish
|
||||||
|
"y", "o", "u", "pero", "el", "la", "los", "las", "un", "una", "de", "del",
|
||||||
|
"en", "con", "por", "para", "que", "su", "sus", "al", "como", "se", "lo",
|
||||||
|
"ni", "sino", "porque", "aunque", "mientras", "cuando", "donde", "segun",
|
||||||
|
"según", "sin", "entre", "sobre", "tras", "ante", "hacia", "hasta", "desde",
|
||||||
|
"ya", "muy", "mas", "más", "menos", "tan", "no",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Characters safe to strip from the end of a trimmed phrase (whitespace, commas,
|
||||||
|
# semicolons, colons, dashes/em-dashes). Sentence-final . ! ? are intentionally
|
||||||
|
# NOT here — if a trim happens to land on one we keep it.
|
||||||
|
_TRAIL_PUNCT = " \t,;:—–- "
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_sentences_to_fit(text: str, limit: int) -> str:
|
||||||
|
"""Drop WHOLE trailing sentences until the text fits within `limit`.
|
||||||
|
|
||||||
|
Splits on sentence terminators (. ! ?) keeping the delimiter, then returns the
|
||||||
|
longest run of complete leading sentences that is <= limit. Never a mid-
|
||||||
|
sentence cut. Returns "" if even the first sentence is over limit (caller's
|
||||||
|
min-useful guardrail then keeps the original)."""
|
||||||
|
parts = [p for p in re.findall(r"[^.!?]*[.!?]+|\S[^.!?]*$", text.strip()) if p.strip()]
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
out = ""
|
||||||
|
for p in parts:
|
||||||
|
candidate = out + p
|
||||||
|
if len(candidate.strip()) <= limit:
|
||||||
|
out = candidate
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return out.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_to_word_boundary(text: str, limit: int) -> str:
|
||||||
|
"""Trim to the last WORD boundary that fits, then strip trailing punctuation
|
||||||
|
and any dangling connective word. No mid-word cut, no ellipsis. The result
|
||||||
|
reads as a complete-ish phrase. Returns "" if nothing survives the cleanup."""
|
||||||
|
text = text.strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
out = ""
|
||||||
|
for w in text.split():
|
||||||
|
candidate = w if not out else f"{out} {w}"
|
||||||
|
if len(candidate) <= limit:
|
||||||
|
out = candidate
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
# Clean the tail: alternately strip trailing punctuation and dangling words
|
||||||
|
# until the phrase ends on a content word.
|
||||||
|
while out:
|
||||||
|
stripped = out.rstrip(_TRAIL_PUNCT)
|
||||||
|
if stripped != out:
|
||||||
|
out = stripped
|
||||||
|
continue
|
||||||
|
# Capture the trailing word INCLUDING internal apostrophes, so contractions
|
||||||
|
# ("won't", "doesn't") match the dangling list instead of just their tail.
|
||||||
|
m = re.search(r"([^\W\d_]+(?:['’][^\W\d_]+)*)$", out, re.UNICODE)
|
||||||
|
if m and m.group(1).lower() in _DANGLING_WORDS:
|
||||||
|
out = out[:m.start()].rstrip(_TRAIL_PUNCT)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
return out.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten_over_limit(fields: dict) -> tuple[dict, list[dict]]:
|
||||||
|
"""FINAL, deterministic, boundary-aware shortener — runs only after the LLM +
|
||||||
|
retry have tried, only on fields STILL over their hard limit. This is NOT the
|
||||||
|
forbidden ugly truncation: custom_excerpt drops whole trailing sentences; the
|
||||||
|
single-line metas trim to a word boundary and clean dangling punctuation.
|
||||||
|
|
||||||
|
Quality guardrail: if a clean shorten would fall below the field's minimum
|
||||||
|
useful length, we keep the LLM's over-limit text and flag it (path-1 fallback
|
||||||
|
for that field only). Mutates `fields` in place; also returns it.
|
||||||
|
|
||||||
|
Returns (fields, log) where log entries are
|
||||||
|
{field, before, after, applied, reason} for reporting/auditing."""
|
||||||
|
log: list[dict] = []
|
||||||
|
for field, limit in _LEN_LIMITS.items():
|
||||||
|
before = fields.get(field, "")
|
||||||
|
if len(before) <= limit:
|
||||||
|
continue
|
||||||
|
if field == "custom_excerpt":
|
||||||
|
after = _drop_sentences_to_fit(before, limit)
|
||||||
|
else:
|
||||||
|
# Single-line metas: prefer a clean WHOLE-sentence prefix (many metas
|
||||||
|
# are 2 sentences — keeping just the first reads as a complete thought).
|
||||||
|
# Only fall back to a word-boundary trim if that prefix is too short.
|
||||||
|
after = _drop_sentences_to_fit(before, limit)
|
||||||
|
if not after or len(after) < _MIN_USEFUL[field]:
|
||||||
|
after = _trim_to_word_boundary(before, limit)
|
||||||
|
|
||||||
|
if after and len(after) <= limit and len(after) >= _MIN_USEFUL[field]:
|
||||||
|
fields[field] = after
|
||||||
|
log.append({"field": field, "before": before, "after": after,
|
||||||
|
"applied": True, "reason": "shortened"})
|
||||||
|
logger.info("seo.shorten: field shortened",
|
||||||
|
field=field, before_len=len(before), after_len=len(after))
|
||||||
|
else:
|
||||||
|
reason = "would-be-too-short" if after else "no-clean-boundary"
|
||||||
|
log.append({"field": field, "before": before, "after": before,
|
||||||
|
"applied": False, "reason": reason})
|
||||||
|
logger.info("seo.shorten: kept over-limit (clean trim unsafe)",
|
||||||
|
field=field, before_len=len(before),
|
||||||
|
trimmed_len=len(after), reason=reason)
|
||||||
|
return fields, log
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_instruction(fields: dict) -> str:
|
||||||
|
"""Forceful, field-specific shrink instruction listing actual length, hard
|
||||||
|
limit, the exact overage, and a sub-limit target. Empty string if nothing over."""
|
||||||
|
lines = []
|
||||||
|
for field, limit in _LEN_LIMITS.items():
|
||||||
|
cur = len(fields.get(field, ""))
|
||||||
|
if cur > limit:
|
||||||
|
target = limit - _LEN_MARGIN[field]
|
||||||
|
cut = cur - target
|
||||||
|
lines.append(
|
||||||
|
f"- {field} is currently {cur} characters. The HARD limit is {limit}. "
|
||||||
|
f"Rewrite it to AT MOST {target} characters (aim for {target}, NOT {limit}; "
|
||||||
|
f"shorter is better than longer) — cut at least {cut} characters by removing a "
|
||||||
|
f"clause, adjective, or example. Keep the same meaning and language."
|
||||||
|
)
|
||||||
|
if not lines:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
"\n\nSTOP. YOUR PREVIOUS OUTPUT EXCEEDED A HARD CHARACTER LIMIT. "
|
||||||
|
"Fix ONLY the field(s) below; leave every other field exactly as you had it:\n"
|
||||||
|
+ "\n".join(lines)
|
||||||
|
+ "\nReturn the FULL JSON object again with ALL fields present, only these shortened. "
|
||||||
|
"When in doubt, cut MORE — a shorter field is always acceptable, an over-limit one is not."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_seo_fields(
|
||||||
|
article_text: str,
|
||||||
|
link_menu: list[dict],
|
||||||
|
lang: str,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
slug: str = "",
|
||||||
|
db=None,
|
||||||
|
session_id: int | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Second, dedicated Haiku call → validated SEO dict, or None on hard failure.
|
||||||
|
|
||||||
|
Returns: meta_title, meta_description, custom_excerpt, tags[],
|
||||||
|
internal_links[{phrase,slug}], image_query, image_context, og_/twitter_*
|
||||||
|
(derived deterministically), and seo_warnings[] for any non-fatal issues.
|
||||||
|
On ANY exception → None (caller falls back to a bare draft).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_anthropic_client()
|
||||||
|
system = _system_prompt(lang)
|
||||||
|
user = _user_message(article_text, link_menu)
|
||||||
|
|
||||||
|
async def _raw(messages: list, max_tokens: int = 1024,
|
||||||
|
temperature: float | None = None) -> str:
|
||||||
|
kwargs = {
|
||||||
|
"model": settings.claude_model,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"system": system,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
if temperature is not None:
|
||||||
|
kwargs["temperature"] = temperature
|
||||||
|
msg = await client.messages.create(**kwargs)
|
||||||
|
if db is not None and session_id is not None:
|
||||||
|
try:
|
||||||
|
await db.log_api_call(
|
||||||
|
session_id, "seo_fields", settings.claude_model,
|
||||||
|
msg.usage.input_tokens, msg.usage.output_tokens,
|
||||||
|
)
|
||||||
|
except Exception as log_err: # noqa: BLE001
|
||||||
|
logger.warning("seo.fields: usage log failed", error=str(log_err))
|
||||||
|
return msg.content[0].text
|
||||||
|
|
||||||
|
base_msgs = [{"role": "user", "content": user}]
|
||||||
|
text1 = await _raw(base_msgs)
|
||||||
|
fields = _coerce(_parse_json_object(text1), lang)
|
||||||
|
fields["internal_links"] = _sanitize_links(fields["internal_links"], link_menu)
|
||||||
|
|
||||||
|
# Validate against the shared engine using the real body (md→html + links).
|
||||||
|
body_html = _markdown_to_html(article_text)
|
||||||
|
linked_html, _ = insert_internal_links(body_html, fields["internal_links"], link_menu, lang)
|
||||||
|
violations = R.check_post(_synthetic_post(fields, linked_html, title, slug))
|
||||||
|
blocking = _blocking(violations)
|
||||||
|
|
||||||
|
if blocking:
|
||||||
|
detail = "; ".join(
|
||||||
|
f"{v.rule}: {v.message}" for v in blocking
|
||||||
|
)
|
||||||
|
instr = _retry_instruction(fields)
|
||||||
|
logger.info("seo.fields: blocking violation, one stricter retry", detail=detail)
|
||||||
|
try:
|
||||||
|
# Real edit turn: hand the model its OWN previous JSON to shorten,
|
||||||
|
# rather than re-rolling from scratch (which kept overshooting). Lower
|
||||||
|
# max_tokens to physically discourage rambling.
|
||||||
|
retry_msgs = base_msgs + [
|
||||||
|
{"role": "assistant", "content": text1},
|
||||||
|
{"role": "user", "content": instr},
|
||||||
|
]
|
||||||
|
# temperature=0 so the shorten instruction binds deterministically.
|
||||||
|
retry = _coerce(_parse_json_object(
|
||||||
|
await _raw(retry_msgs, max_tokens=768, temperature=0.0)), lang)
|
||||||
|
retry["internal_links"] = _sanitize_links(retry["internal_links"], link_menu)
|
||||||
|
rlinked, _ = insert_internal_links(
|
||||||
|
_markdown_to_html(article_text), retry["internal_links"], link_menu, lang)
|
||||||
|
rviol = R.check_post(_synthetic_post(retry, rlinked, title, slug))
|
||||||
|
if not _blocking(rviol):
|
||||||
|
fields, violations, blocking = retry, rviol, []
|
||||||
|
else:
|
||||||
|
# Keep the retry's text but record it stayed over (never truncate).
|
||||||
|
fields, violations, blocking = retry, rviol, _blocking(rviol)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("seo.fields: retry failed, keeping first output", error=str(e))
|
||||||
|
|
||||||
|
# Final boundary-aware shortener — only for fields the LLM + retry left
|
||||||
|
# over limit. Clean (sentence-drop / word-boundary), never mid-word, and
|
||||||
|
# skipped (kept + flagged) if it would butcher the field below min-useful.
|
||||||
|
shorten_log: list[dict] = []
|
||||||
|
if blocking:
|
||||||
|
fields, shorten_log = _shorten_over_limit(fields)
|
||||||
|
slinked, _ = insert_internal_links(
|
||||||
|
_markdown_to_html(article_text), fields["internal_links"], link_menu, lang)
|
||||||
|
violations = R.check_post(_synthetic_post(fields, slinked, title, slug))
|
||||||
|
blocking = _blocking(violations)
|
||||||
|
|
||||||
|
mt, md = fields["meta_title"], fields["meta_description"]
|
||||||
|
fields["og_title"] = fields["twitter_title"] = mt
|
||||||
|
fields["og_description"] = fields["twitter_description"] = md
|
||||||
|
fields["seo_warnings"] = [
|
||||||
|
f"{v.rule}: {v.message}" for v in blocking
|
||||||
|
]
|
||||||
|
if shorten_log:
|
||||||
|
fields["seo_shortened"] = [
|
||||||
|
{"field": e["field"], "applied": e["applied"], "reason": e["reason"]}
|
||||||
|
for e in shorten_log
|
||||||
|
]
|
||||||
|
return fields
|
||||||
|
except Exception as e: # noqa: BLE001 — isolation guarantee
|
||||||
|
logger.warning("seo.fields: generation failed, falling back to bare draft",
|
||||||
|
error=str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 3. Deterministic internal-link insertion ──────────────────────────────
|
||||||
|
|
||||||
|
def _sanitize_links(suggestions: list[dict], menu: list[dict]) -> list[dict]:
|
||||||
|
"""Defense-in-depth: drop malformed link suggestions before insertion/reporting.
|
||||||
|
|
||||||
|
The "phrase" must be SHORT verbatim ARTICLE text — never a slug or a menu
|
||||||
|
title. A suggestion is dropped when its phrase equals its own slug, any menu
|
||||||
|
slug, or any menu title (the slug-as-phrase / title-as-phrase traps). Even if
|
||||||
|
the model ignores the prompt rule, the output stays clean. Drops are logged at
|
||||||
|
debug and never raise; survivors still go through the verbatim-in-body guard.
|
||||||
|
"""
|
||||||
|
menu_slugs = {(m.get("slug") or "").strip().casefold() for m in menu}
|
||||||
|
menu_titles = {(m.get("title") or "").strip().casefold() for m in menu}
|
||||||
|
menu_slugs.discard("")
|
||||||
|
menu_titles.discard("")
|
||||||
|
clean: list[dict] = []
|
||||||
|
for s in suggestions:
|
||||||
|
phrase = (s.get("phrase") or "").strip()
|
||||||
|
slug = (s.get("slug") or "").strip()
|
||||||
|
if not phrase or not slug:
|
||||||
|
continue
|
||||||
|
pf = phrase.casefold()
|
||||||
|
if pf == slug.casefold() or pf in menu_slugs or pf in menu_titles:
|
||||||
|
logger.debug("seo.links: dropped malformed suggestion (slug/title as phrase)",
|
||||||
|
phrase=phrase, slug=slug)
|
||||||
|
continue
|
||||||
|
clean.append({"phrase": phrase, "slug": slug})
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def insert_internal_links(
|
||||||
|
html: str,
|
||||||
|
suggestions: list[dict],
|
||||||
|
menu: list[dict],
|
||||||
|
lang: str = "en",
|
||||||
|
) -> tuple[str, int]:
|
||||||
|
"""Wrap the FIRST verbatim, word-boundary occurrence of each suggested phrase
|
||||||
|
in an <a> to its menu slug. Deterministic — the LLM never edits HTML.
|
||||||
|
|
||||||
|
Rules: slug must be in the menu; phrase must appear verbatim in a TEXT node
|
||||||
|
(never inside a tag, never inside an existing <a>); word-boundary match (no
|
||||||
|
partial words); each phrase/slug used at most once; cap at MAX_INTERNAL_LINKS.
|
||||||
|
A phrase that is missing or already linked is silently skipped (never forced,
|
||||||
|
never an error). Returns (modified_html, inserted_pairs) where inserted_pairs
|
||||||
|
is the list of {phrase, slug} dicts actually wrapped (len == links inserted).
|
||||||
|
"""
|
||||||
|
if not html or not suggestions:
|
||||||
|
return html, []
|
||||||
|
|
||||||
|
site = SITE_BY_LANG.get(lang, SITE_BY_LANG["en"])
|
||||||
|
menu_slugs = {m["slug"] for m in menu if m.get("slug")}
|
||||||
|
|
||||||
|
# Split into tag tokens and text tokens; we only ever edit text tokens, and
|
||||||
|
# only when not inside an <a>…</a> (anchor depth 0). This guarantees no nested
|
||||||
|
# links and no edits inside tag attributes.
|
||||||
|
tokens = re.split(r"(<[^>]+>)", html)
|
||||||
|
inserted = 0
|
||||||
|
inserted_pairs: list[dict] = []
|
||||||
|
used_phrases: set[str] = set()
|
||||||
|
used_slugs: set[str] = set()
|
||||||
|
|
||||||
|
for sug in suggestions:
|
||||||
|
if inserted >= MAX_INTERNAL_LINKS:
|
||||||
|
break
|
||||||
|
phrase = (sug.get("phrase") or "").strip()
|
||||||
|
slug = (sug.get("slug") or "").strip()
|
||||||
|
if not phrase or not slug:
|
||||||
|
continue
|
||||||
|
if slug not in menu_slugs:
|
||||||
|
continue
|
||||||
|
if phrase in used_phrases or slug in used_slugs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pat = re.compile(r"(?<!\w)" + re.escape(phrase) + r"(?!\w)")
|
||||||
|
anchor_depth = 0
|
||||||
|
done = False
|
||||||
|
for i, tok in enumerate(tokens):
|
||||||
|
if tok.startswith("<") and tok.endswith(">"):
|
||||||
|
low = tok.lower()
|
||||||
|
if low.startswith("<a") and not low.startswith("</a"):
|
||||||
|
anchor_depth += 1
|
||||||
|
elif low.startswith("</a"):
|
||||||
|
anchor_depth = max(0, anchor_depth - 1)
|
||||||
|
continue
|
||||||
|
if anchor_depth > 0 or not tok:
|
||||||
|
continue
|
||||||
|
m = pat.search(tok)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
replacement = (
|
||||||
|
f'<a href="https://{site}/{slug}/">{m.group(0)}</a>'
|
||||||
|
)
|
||||||
|
tokens[i] = tok[:m.start()] + replacement + tok[m.end():]
|
||||||
|
inserted += 1
|
||||||
|
inserted_pairs.append({"phrase": phrase, "slug": slug})
|
||||||
|
used_phrases.add(phrase)
|
||||||
|
used_slugs.add(slug)
|
||||||
|
done = True
|
||||||
|
break
|
||||||
|
if not done:
|
||||||
|
logger.debug("seo.links: phrase not found / already linked, skipped",
|
||||||
|
phrase=phrase, slug=slug)
|
||||||
|
|
||||||
|
return "".join(tokens), inserted_pairs
|
||||||
@@ -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