From 7c9a8b88b41977d15a215b892277e4c45c46f6ef Mon Sep 17 00:00:00 2001 From: Yuzhiran Dev Date: Tue, 16 Jun 2026 08:26:44 +0800 Subject: [PATCH] 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 --- platform/backend/app/core/prompt_loader.py | 5 +- scripts/outline.py | 2 +- scripts/writer.py | 84 +++++++++++++++++++++- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/platform/backend/app/core/prompt_loader.py b/platform/backend/app/core/prompt_loader.py index 34e93e8..18496e9 100644 --- a/platform/backend/app/core/prompt_loader.py +++ b/platform/backend/app/core/prompt_loader.py @@ -30,11 +30,10 @@ _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine) def _get_session(): - session = _SessionLocal() try: + session = _SessionLocal() return session - except: - session.close() + except Exception: raise diff --git a/scripts/outline.py b/scripts/outline.py index 5b02723..0e9c51a 100644 --- a/scripts/outline.py +++ b/scripts/outline.py @@ -53,7 +53,7 @@ class Outliner: if m: 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: return "、".join(keywords[:5]) return "暂无" diff --git a/scripts/writer.py b/scripts/writer.py index dca45a6..ab75eba 100644 --- a/scripts/writer.py +++ b/scripts/writer.py @@ -92,14 +92,68 @@ def _extract_tags_list(tags_html: str) -> list: return re.findall(r'([^<]+)', 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: """向 HTML 注入 SEO/GEO 结构化元数据""" description = _extract_description(content) tags_list = _extract_tags_list(tags_html) platform_name = PLATFORM_NAMES.get(platform, platform) today = datetime.datetime.now().strftime("%Y-%m-%d") + content_type = _detect_content_type(content) - # JSON-LD Article schema json_ld = { "@context": "https://schema.org", "@type": "Article", @@ -125,6 +179,34 @@ def inject_geo_metadata(html: str, title: str, content: str, platform: str, tags if tags_list: 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) meta_tags = f"""