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
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:
co-authored by
Claude Opus 4.8
parent
89aa8d6030
commit
7a995da88f
+210
-50
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user