feat(seo): wire autofill into publish path behind SEO_AUTOFILL (default off, dry-run support)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-06-25 14:14:39 +00:00
co-authored by Claude Opus 4.8
parent 89aa8d6030
commit 7a995da88f
4 changed files with 870 additions and 52 deletions
+210 -50
View File
@@ -290,8 +290,94 @@ def _seo_checklist(slug: str) -> str:
)
# 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(seo: dict, n_links: int) -> str:
pairs = [(l["phrase"], l["slug"]) for l in seo.get("internal_links", [])]
return f"{n_links} insertados" + (f"{pairs}" if pairs else "")
def _seo_live_message(ghost: "GhostPublisher", post: dict, seo: dict, n_links: int) -> 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 ""
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(seo, n_links)}"
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, n_links: int) -> 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 ""
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(seo, n_links)}"
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:
def __init__(self, lang: str = "es"):
self.lang = lang
if lang == "en":
self.url = (settings.ghost_url_en or "").rstrip("/")
self.api_key = settings.ghost_api_key_en or ""
@@ -302,6 +388,16 @@ class GhostPublisher:
def is_configured(self) -> bool:
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:
key_id, secret = self.api_key.split(":", 1)
now = int(time.time())
@@ -314,18 +410,25 @@ class GhostPublisher:
return f"{signing}.{sig}"
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 markdown as _md
clean = _strip_researchowl_header(markdown_content)
html = _md.markdown(clean, extensions=["extra"])
# 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()
# Caller-supplied linked HTML wins; otherwise convert markdown as before.
html = body_html if body_html is not None else self._build_html(markdown_content)
logger.info("Ghost publish_draft", html_length=len(html),
html_preview=html[:200])
html_preview=html[:200], seo=bool(seo))
if not html.strip():
raise ValueError("Ghost: HTML vacío tras conversión markdown — contenido no enviado")
@@ -342,14 +445,25 @@ class GhostPublisher:
})
token = self._make_token()
body = {
"posts": [{
"title": title,
"mobiledoc": mobiledoc,
"status": "draft",
"tags": [{"name": t} for t in (tags or ["investigacion"])],
}]
# Language-aware default tag (fixes the hardcoded "investigacion" for EN).
tag_names = tags or [_DEFAULT_TAG.get(self.lang, "investigation")]
post_obj = {
"title": title,
"mobiledoc": mobiledoc,
"status": "draft", # NEVER "published" — draft only, always.
"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 sess.post(
f"{self.url}/ghost/api/admin/posts/",
@@ -372,15 +486,86 @@ class OutputGenerator:
self.db = db
self.ollama = ollama
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, n_links = 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, n_links)
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, n_links)
logger.info("Auto-published blog to Ghost",
mode=mode, post_id=post["id"], links=n_links)
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,
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"""
self.last_publish_notice = None
if output_type in (OutputType.REPORT_EXTENDED,
OutputType.BLOG_EXTENDED,
OutputType.PODCAST_EXTENDED):
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)
if not session:
@@ -426,24 +611,11 @@ class OutputGenerator:
# Save to DB
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 = ""
if output_type in (OutputType.BLOG, OutputType.BLOG_EXTENDED):
ghost = GhostPublisher(lang=lang)
if ghost.is_configured():
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']}"
f"{_seo_checklist(post.get('slug', ''))}"
)
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))
ghost_notice = await self._publish_blog_to_ghost(
lang, full_output, topic, session_id, seo_override)
logger.info("Output generated", type=output_type, length=len(full_output))
return full_output + ghost_notice
@@ -500,7 +672,8 @@ class OutputGenerator:
return systems.get(output_type, "You are a helpful research assistant.")
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.
1. Recupera muestra de contexto para el outline
@@ -601,24 +774,11 @@ class OutputGenerator:
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 = ""
if output_type == OutputType.BLOG_EXTENDED:
ghost = GhostPublisher(lang=lang)
if ghost.is_configured():
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']}"
f"{_seo_checklist(post.get('slug', ''))}"
)
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))
ghost_notice = await self._publish_blog_to_ghost(
lang, full_output, topic, session_id, seo_override)
logger.info("Extended output generated", type=output_type,
sections=len(sections), length=len(full_output))