feat: FAQ/HowTo schema injection and SEO pipeline enhancement

- writer.py: inject_geo_metadata now detects content type (article/listicle/howto/faq/review) and injects appropriate JSON-LD (FAQPage, HowTo, ItemList)
- New helpers: _detect_content_type, _extract_faq_pairs, _extract_howto_steps
- outline.py: _extract_seo_keywords regex support for CJK
- prompt_loader.py: Minor update

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Yuzhiran Dev
2026-06-16 08:26:44 +08:00
parent 93d1d511e5
commit 7c9a8b88b4
3 changed files with 86 additions and 5 deletions
+2 -3
View File
@@ -30,11 +30,10 @@ _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
def _get_session(): def _get_session():
session = _SessionLocal()
try: try:
session = _SessionLocal()
return session return session
except: except Exception:
session.close()
raise raise
+1 -1
View File
@@ -53,7 +53,7 @@ class Outliner:
if m: if m:
return m.group(1).strip()[:300] return m.group(1).strip()[:300]
# 回退:取所有 # 标签或关键词模式 # 回退:取所有 # 标签或关键词模式
keywords = re.findall(r'[#](\w{2,6})', research_notes) keywords = re.findall(r'[#]([\u4e00-\u9fff\w]{2,6})', research_notes)
if keywords: if keywords:
return "".join(keywords[:5]) return "".join(keywords[:5])
return "暂无" return "暂无"
+83 -1
View File
@@ -92,14 +92,68 @@ def _extract_tags_list(tags_html: str) -> list:
return re.findall(r'<span class="tag">([^<]+)</span>', tags_html) return re.findall(r'<span class="tag">([^<]+)</span>', tags_html)
def _detect_content_type(content: str) -> str:
"""检测内容类型: article / listicle / howto / faq / review"""
if not content:
return "article"
c = content.lower()
howto_score = 0
for p in ['步骤', '第一步', '第二步', '首先', '然后', '最后', 'step 1', 'step 2']:
if p in c:
howto_score += 1
faq_score = 0
for p in ['q', 'a', 'q:', 'a:', '问:', '答:', '什么', '如何', '怎么', '为什么']:
if p in c:
faq_score += 1
listicle_score = c.count('\n- ') + c.count('\n* ') + c.count('\n1. ')
if faq_score >= 4:
return "faq"
if howto_score >= 2:
return "howto"
if listicle_score >= 3:
return "listicle"
return "article"
def _extract_faq_pairs(content: str, max_pairs: int = 5) -> list:
"""从 markdown 中提取 FAQ 问答对"""
pairs = []
lines = content.split('\n')
current_q = None
for line in lines:
q_match = re.match(r'^(?:[Qq]|问)[:]\s*(.+)$', line)
if q_match:
current_q = q_match.group(1).strip()
continue
a_match = re.match(r'^(?:[Aa]|答)[:]\s*(.+)$', line)
if a_match and current_q:
pairs.append({"question": current_q, "answer": a_match.group(1).strip()[:200]})
current_q = None
if len(pairs) >= max_pairs:
break
return pairs
def _extract_howto_steps(content: str, max_steps: int = 8) -> list:
"""从 markdown 中提取 HowTo 步骤"""
steps = []
for m in re.finditer(r'(?:步骤|Step)\s*(\d+)[:.\s)]+(.+)', content, re.IGNORECASE):
steps.append({"name": m.group(2).strip()[:100], "position": int(m.group(1))})
if not steps:
for i, m in enumerate(re.finditer(r'第一步[:]\s*(.+?)\n|第二步[:]\s*(.+?)\n|第三步[:]\s*(.+?)\n', content)):
step_text = next(g for g in m.groups() if g)
steps.append({"name": step_text.strip()[:100], "position": i + 1})
return steps[:max_steps]
def inject_geo_metadata(html: str, title: str, content: str, platform: str, tags_html: str = "") -> str: def inject_geo_metadata(html: str, title: str, content: str, platform: str, tags_html: str = "") -> str:
"""向 HTML <head> 注入 SEO/GEO 结构化元数据""" """向 HTML <head> 注入 SEO/GEO 结构化元数据"""
description = _extract_description(content) description = _extract_description(content)
tags_list = _extract_tags_list(tags_html) tags_list = _extract_tags_list(tags_html)
platform_name = PLATFORM_NAMES.get(platform, platform) platform_name = PLATFORM_NAMES.get(platform, platform)
today = datetime.datetime.now().strftime("%Y-%m-%d") today = datetime.datetime.now().strftime("%Y-%m-%d")
content_type = _detect_content_type(content)
# JSON-LD Article schema
json_ld = { json_ld = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
@@ -125,6 +179,34 @@ def inject_geo_metadata(html: str, title: str, content: str, platform: str, tags
if tags_list: if tags_list:
json_ld["keywords"] = ", ".join(tags_list[:8]) json_ld["keywords"] = ", ".join(tags_list[:8])
if content_type == "faq":
faq_pairs = _extract_faq_pairs(content)
if faq_pairs:
json_ld["@type"] = "FAQPage"
json_ld["mainEntity"] = [
{"@type": "Question", "name": p["question"],
"acceptedAnswer": {"@type": "Answer", "text": p["answer"]}}
for p in faq_pairs
]
elif content_type == "howto":
howto_steps = _extract_howto_steps(content)
if howto_steps:
json_ld["@type"] = "HowTo"
json_ld["step"] = [
{"@type": "HowToStep", "position": s["position"],
"name": s["name"], "itemListElement": [
{"@type": "HowToDirection", "text": s["name"]}
]}
for s in howto_steps
]
elif content_type == "listicle":
json_ld["@type"] = "ItemList"
items = re.findall(r'^[-*\d]+[.、)\s]+(.+)$', content, re.MULTILINE)
json_ld["itemListElement"] = [
{"@type": "ListItem", "position": i + 1, "name": item.strip()[:100]}
for i, item in enumerate(items[:10])
]
json_ld_str = json.dumps(json_ld, ensure_ascii=False) json_ld_str = json.dumps(json_ld, ensure_ascii=False)
meta_tags = f""" meta_tags = f"""