feat: GEO/SEO structured data + search ranking tracker

This commit is contained in:
Yuzhiran Dev
2026-06-09 17:18:09 +08:00
parent 23ff63baa9
commit 8595bbc521
26 changed files with 961 additions and 51 deletions
+7 -4
View File
@@ -169,25 +169,28 @@ def polish_with_llm(html: str, platform: str, remaining_issues: Optional[List[Di
"""
if not HAVE_LLM:
return html, None
prompt_key = "compliance_fix" if remaining_issues else "compliance_polish"
for attempt in range(2):
try:
temperature = 0.5
max_tokens = 4000
params = get_prompt_params(prompt_key)
max_tokens = params.get("max_tokens", 8000)
system_prompt = "你是一个专业的内容合规与优化助手,擅长在保持文章质量和可读性的前提下修复合规问题。"
if remaining_issues:
issues_desc = "\n".join(
f"- [{i['type']}] {i.get('category','')}: {i.get('detail','')}"
for i in remaining_issues
)
prompt = get_prompt("compliance_fix", issues_desc=issues_desc, html=html)
prompt = get_prompt(prompt_key, issues_desc=issues_desc, html=html)
else:
prompt = get_prompt("compliance_polish", html=html)
prompt = get_prompt(prompt_key, html=html)
polished = call_llm(prompt, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt)
polished = clean_html_content(polished)
polished = strip_ai_preface(polished)
polished = strip_thinking_html(polished)
if '<h2' in polished or '<p>' in polished:
if len(polished) > len(html) * 0.3 and len(polished) > 100:
min_acceptable = min(len(html) * 0.3, 12000)
if len(polished) > min_acceptable and len(polished) > 100:
tag = "针对性修复" if remaining_issues else "常规润色"
return polished, f"LLM {tag}"
logger.warning(f"LLM 优化输出过短,保留原文 (len={len(polished)})")
+7 -1
View File
@@ -236,7 +236,7 @@ def save_topics_to_db(topics_data: List[Dict]):
finally:
db.close()
def save_article(topic_id: str, platform: str, html_content: str, db: Optional[Session] = None) -> Dict:
def save_article(topic_id: str, platform: str, html_content: str, *, title: str = "", content: str = "", db: Optional[Session] = None) -> Dict:
"""保存/更新文章到 articles 表"""
close_db = False
if db is None:
@@ -249,12 +249,18 @@ def save_article(topic_id: str, platform: str, html_content: str, db: Optional[S
now = datetime.now()
if existing:
existing.html_content = html_content
if title:
existing.title = title
if content:
existing.content = content
else:
article = Article(
id=article_id,
topic_id=topic_id,
platform=platform,
file_path=f"db:{article_id}",
title=title,
content=content,
html_content=html_content,
status="draft",
compliance_score=None
+18 -1
View File
@@ -3,7 +3,7 @@
大纲阶段:基于选题和研究笔记,用 LLM 动态生成结构化大纲
"""
import json, datetime, logging, sys
import json, datetime, logging, sys, re
from pathlib import Path
from typing import Dict
@@ -43,6 +43,21 @@ class Outliner:
if not topic: raise ValueError(f"Topic {self.topic_id} not found")
return topic
@staticmethod
def _extract_seo_keywords(research_notes: str) -> str:
"""从研究笔记中提取 SEO 关键词"""
if not research_notes:
return "暂无"
# 尝试匹配 "SEO关键词建议" 块
m = re.search(r'(?:SEO关键词建议|SEO关键词|搜索词)[::]\s*(.*?)(?:\n\n|\Z)', research_notes, re.DOTALL)
if m:
return m.group(1).strip()[:300]
# 回退:取所有 # 标签或关键词模式
keywords = re.findall(r'[#](\w{2,6})', research_notes)
if keywords:
return "".join(keywords[:5])
return "暂无"
def generate_outline(self) -> str:
title = self.topic['title']
field = self.topic.get('field', '')
@@ -50,6 +65,7 @@ class Outliner:
pain = self.topic.get('audience_pain', '')
angle = self.topic.get('unique_angle', '')
cases_summary = self.research_notes[:2000] if self.research_notes else "暂无研究笔记"
seo_keywords = self._extract_seo_keywords(self.research_notes)
if HAVE_LLM:
_now = datetime.datetime.now()
@@ -62,6 +78,7 @@ class Outliner:
pain=pain,
angle=angle,
cases_summary=cases_summary,
seo_keywords=seo_keywords,
)
try:
params = get_prompt_params("outline_generation")
+7 -2
View File
@@ -80,9 +80,9 @@ _PROMPT_DEFAULTS = {
"variables": ["now", "year", "title", "field", "core", "pain", "angle", "search_section", "n", "cases_text"],
},
"outline_generation": {
"content": "你是一个资深内容编辑,擅长设计读者爱看+搜索引擎友好+平台愿意推荐的推文结构。\n\n今天是{date}。当前年份:{year}年。\n\n选题信息:\n标题:{title}\n领域:{field}\n核心观点:{core}\n受众痛点:{pain}\n独特视角:{angle}\n\n研究笔记:\n{cases_summary}\n\n大纲要求:\n- 5-8章,每章有完整段落要点(非单句)\n- 结构递进:认知升级型或问题解决型\n- 每章标题自带信息量+好奇心,不要「引言」「总结」这类通用标题\n- 每章的要点必须是2-4句有内容的段落,不是一行关键词\n- 开头从具体场景切入,不要空洞的开场白\n- 把「独特视角」融入各章,而不是单独列\n\n输出格式:每章以「## 标题」开头,下面跟2-4段要点文字。\n不要输出其他说明。",
"content": "你是一个资深内容编辑,擅长设计读者爱看+搜索引擎友好+平台愿意推荐的推文结构。\n\n今天是{date}。当前年份:{year}年。\n\n选题信息:\n标题:{title}\n领域:{field}\n核心观点:{core}\n受众痛点:{pain}\n独特视角:{angle}\n\n研究笔记:\n{cases_summary}\n\n重点布局的SEO关键词(在章节中自然融入):\n{seo_keywords}\n\n大纲要求:\n- 5-8章,每章有完整段落要点(非单句)\n- 结构递进:认知升级型或问题解决型\n- 每章标题自带信息量+好奇心,不要「引言」「总结」这类通用标题\n- 每章至少自然融入1个上述SEO关键词,涉及相关搜索意图\n- 每章的要点必须是2-4句有内容的段落,不是一行关键词\n- 开头从具体场景切入,不要空洞的开场白\n- 把「独特视角」融入各章,而不是单独列\n- GEO(生成式搜索优化):每章包含一个可引用的数据点或来源,增加被AI搜索引用的概率\n\n输出格式:每章以「## 标题」开头,下面跟2-4段要点文字。\n不要输出其他说明。",
"temperature": 0.7, "max_tokens": 4000,
"variables": ["date", "year", "title", "field", "core", "pain", "angle", "cases_summary"],
"variables": ["date", "year", "title", "field", "core", "pain", "angle", "cases_summary", "seo_keywords"],
},
"compliance_fix": {
"content": "你是一个专业的内容合规与质量优化助手。以下文章存在需要修复的问题,请逐一修复并输出完整HTML。\n\n需修复的问题:\n{issues_desc}\n\n原文:\n{html}\n\n修复要求:\n- 只修复上述问题,不改变文章结构和核心内容\n- 保持<h2>, <h3>, <p>等标签结构不变\n- 替换敏感词时选择意思相近的替代词,不删节重要信息\n- AI套话:直接删除或改写「首先其次最后」「总的来说」「值得注意的是」「综上所述」等模式\n- 人称混用:统一为「你」\n- 缺少配图:在关键位置插入 <p></p> 空段落占位,配图由后续流程处理\n- 缺少互动/收藏引导:在文末自然加入(不要生硬)\n- 段落过长:将超过300字的段落拆分为2-3段\n\n输出完整的HTML,只输出HTML内容,不要其他文字说明。",
@@ -99,6 +99,11 @@ _PROMPT_DEFAULTS = {
"temperature": 0.5, "max_tokens": 3000,
"variables": ["n", "cat_names", "n2", "src_summary", "year"],
},
"topic_manual_analyze": {
"content": "你是一个专业的内容策略师。用户提供了一个选题思路,请分析并提炼为结构化的选题信息。\n\n用户输入的原始内容:\n{raw_input}\n\n参考链接(如有):\n{reference_links}\n\n请输出 JSON(只输出 JSON,不要其他文字):\n{\n \"title\": \"优化后的选题标题(20字内,含核心关键词,有吸引力)\",\n \"format\": \"内容形式(趋势洞察/实操指南/对比分析/案例解读/观点讨论)\",\n \"core_concept\": \"核心观点(一句话说清独特价值,20字内)\",\n \"audience_pain\": \"受众痛点(目标读者的真实困惑或需求,20字内)\",\n \"unique_angle\": \"差异化切入点(与常见文章不同的视角,20字内)\",\n \"tags\": [\"标签1\", \"标签2\", \"标签3\", \"标签4\", \"标签5\"]\n}",
"temperature": 0.6, "max_tokens": 1500,
"variables": ["raw_input", "reference_links"],
},
"tags_generation": {
"content": "为以下文章生成{platform}标签(5-8个)。\n\n标题:{title}\n领域:{field}\n核心观点:{core}\n\n要求:\n- 每个标签2-5字\n- 包含1-2个搜索流量词(用户在{platform}会搜的词)\n- 包含1-2个热门话题词\n- 标签要有层次:大领域→小话题→具体场景\n- 不要重复意思相近的标签\n\n直接输出标签,空格分隔。不要输出思考过程和其他文字。",
"temperature": 0.3, "max_tokens": 500,
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""
搜索排名追踪模块
功能:
1. 对已发布的文章关键词,查搜索排名(Bing/Baidu)
2. 检测文章是否被 AI 搜索引用(通过特定查询判断)
3. 结果写入 SearchRanking 表
4. 可作为定时任务每天运行
"""
import json, logging, sys, re, datetime
from pathlib import Path
from typing import List, Dict, Optional
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
from web_search import search_api
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
DOMAIN = "yu-zhi-ran.com"
def get_published_articles() -> List[Dict]:
"""从 DB 获取所有已发布的文章"""
try:
from app.database import SessionLocal
from app.models import Article, Topic
db = SessionLocal()
try:
results = db.query(Article, Topic).join(Topic, Article.topic_id == Topic.id).all()
articles = []
for article, topic in results:
articles.append({
"id": article.id,
"topic_id": article.topic_id,
"platform": article.platform,
"title": article.title or topic.title,
"status": article.status,
"topic_title": topic.title,
"field": topic.field or "",
})
return articles
finally:
db.close()
except Exception as e:
logger.warning(f"无法读取文章列表: {e}")
return []
def _build_search_queries(article: Dict) -> List[str]:
"""为文章生成需要追踪的关键词"""
queries = []
title = article.get("title", "") or article.get("topic_title", "")
field = article.get("field", "")
if title:
queries.append(title[:30])
# 核心关键词:标题短句
parts = re.split(r'[:,。.!?]', title)
for p in parts[:2]:
p = p.strip()
if 4 <= len(p) <= 25:
queries.append(p)
if field:
queries.append(field[:20])
return list(set(q for q in queries if len(q) >= 4))[:5]
def _generate_keywords_for_article(article: Dict) -> List[str]:
"""通过 LLM 生成更多 SEO 关键词(可选)"""
try:
from app.core.nvidia_client import call_llm
from prompt_loader import get_prompt
except ImportError:
return []
title = article.get("title") or article.get("topic_title", "")
field = article.get("field", "")
try:
prompt = get_prompt("tags_generation",
platform="搜索引擎",
title=title,
field=field,
core=article.get("topic_id", ""),
)
resp = call_llm(prompt, temperature=0.3, max_tokens=500)
if resp:
from content_cleaner import strip_thinking
resp = strip_thinking(resp)
keywords = [t.strip("# ") for t in resp.split() if len(t.strip("# ")) >= 3]
return keywords[:5]
except Exception as e:
logger.warning(f"关键词生成失败: {e}")
return []
def check_rankings(article: Dict, keywords: List[str], engine: str = "bing") -> List[Dict]:
"""检查文章关键词在搜索引擎的排名"""
title = article.get("title") or article.get("topic_title", "")
article_id = article.get("id", "")
topic_id = article.get("topic_id", "")
platform = article.get("platform", "")
results = []
for keyword in keywords:
try:
search_results = search_api(keyword, max_results=10)
position = None
url_found = None
for i, sr in enumerate(search_results):
url = sr.get("url", "")
if DOMAIN in url or any(part in url for part in title.split() if len(part) >= 4):
position = i + 1
url_found = url[:200]
break
results.append({
"article_id": article_id,
"topic_id": topic_id,
"keyword": keyword,
"platform": platform,
"search_engine": engine,
"position": position,
"url_found": url_found,
"ai_cited": False,
"ai_source": None,
})
logger.info(f" [{engine}] '{keyword}'{'#' + str(position) if position else '未上榜'}"
f"{' ' + url_found[:60] if url_found else ''}")
except Exception as e:
logger.warning(f"检查关键词 '{keyword}' 失败: {e}")
return results
def save_rankings(rankings: List[Dict]):
"""将排名结果写入数据库"""
if not rankings:
return
try:
from app.database import SessionLocal
from app.models import SearchRanking
db = SessionLocal()
try:
for r in rankings:
record = SearchRanking(
article_id=r.get("article_id"),
topic_id=r.get("topic_id"),
keyword=r.get("keyword"),
platform=r.get("platform"),
search_engine=r.get("search_engine", "bing"),
position=r.get("position"),
url_found=r.get("url_found"),
ai_cited=r.get("ai_cited", False),
ai_source=r.get("ai_source"),
)
db.add(record)
db.commit()
logger.info(f"已保存 {len(rankings)} 条排名记录")
finally:
db.close()
except Exception as e:
logger.warning(f"保存排名记录失败: {e}")
def run_all(engine: str = "bing") -> Dict:
"""对所有已发布文章执行排名追踪"""
articles = get_published_articles()
if not articles:
logger.warning("没有已发布的文章可追踪")
return {"ok": True, "tracked": 0, "articles": 0}
all_rankings = []
for article in articles:
keywords = _build_search_queries(article)
if not keywords:
continue
logger.info(f"追踪 [{article['id']}] {article.get('title','')[:30]} keywords: {keywords}")
rankings = check_rankings(article, keywords, engine)
all_rankings.extend(rankings)
save_rankings(all_rankings)
on_page = sum(1 for r in all_rankings if r.get("position") is not None)
logger.info(f"排名追踪完成: {len(articles)} 篇文章, "
f"{len(all_rankings)} 条关键词检查, "
f"{on_page} 条有排名")
return {
"ok": True,
"articles_checked": len(articles),
"keywords_checked": len(all_rankings),
"on_page": on_page,
}
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--engine', default='bing', help='搜索引擎 (bing/baidu/google)')
args = parser.parse_args()
result = run_all(args.engine)
print(json.dumps(result, ensure_ascii=False))
sys.exit(0 if result['ok'] else 1)
if __name__ == "__main__":
main()
+90 -4
View File
@@ -5,7 +5,7 @@
"""
import json, datetime, logging, sys, re
from pathlib import Path
from typing import Dict, List
from typing import Dict, List, Optional
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
@@ -69,6 +69,90 @@ def _load_platform_config() -> dict:
PLATFORM_CONFIG = _load_platform_config()
PLATFORM_NAMES = {
"zhihu": "知乎专栏",
"wechat": "微信公众号",
"xiaohongshu": "小红书",
}
def _extract_description(content: str, max_len: int = 200) -> str:
"""从 markdown 正文提取第一段有意义的文字作为 description"""
text = re.sub(r'^#\s+.*$', '', content, flags=re.MULTILINE)
text = re.sub(r'[#*>`~\[\]()\n]', ' ', text)
paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
for p in paragraphs:
p = re.sub(r'\s+', ' ', p).strip()
if len(p) >= 15 and not p.startswith('http'):
return p[:max_len]
return content.replace('\n', ' ')[:max_len]
def _extract_tags_list(tags_html: str) -> list:
"""从 HTML tags 块提取纯标签列表"""
return re.findall(r'<span class="tag">([^<]+)</span>', tags_html)
def inject_geo_metadata(html: str, title: str, content: str, platform: str, tags_html: str = "") -> str:
"""向 HTML <head> 注入 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")
# JSON-LD Article schema
json_ld = {
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"datePublished": today,
"dateModified": today,
"author": {
"@type": "Organization",
"name": "宇之然",
"url": "https://yu-zhi-ran.com"
},
"publisher": {
"@type": "Organization",
"name": "宇之然",
"url": "https://yu-zhi-ran.com"
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": f"https://yu-zhi-ran.com/article/{platform}"
},
}
if tags_list:
json_ld["keywords"] = ", ".join(tags_list[:8])
json_ld_str = json.dumps(json_ld, ensure_ascii=False)
meta_tags = f"""
<meta name="description" content="{description}">
<meta name="keywords" content="{', '.join(tags_list[:8]) if tags_list else ''}">
<meta property="og:type" content="article">
<meta property="og:title" content="{title}">
<meta property="og:description" content="{description[:150]}">
<meta property="og:site_name" content="宇之然 | {platform_name}">
<meta property="article:published_time" content="{today}">
<meta property="article:author" content="宇之然">
<script type="application/ld+json">
{json_ld_str}
</script>"""
# 注入到 </head> 之前
html = html.replace("</head>", meta_tags + "\n</head>")
# 为 wechat + xiaohongshu 追加 Weibo/Wechat 兼容 meta
if platform in ("wechat", "xiaohongshu"):
html = html.replace("</head>", """
<meta property="og:image" content="https://yu-zhi-ran.com/og-image.png">
<meta name="weibo:webpage:source" content="宇之然">
</head>""")
return html
class Writer:
def __init__(self, topic_id: str):
self.topic_id = topic_id
@@ -443,11 +527,12 @@ class Writer:
else:
html = html.replace("<!-- TAGS -->", "")
html = inject_geo_metadata(html, title, adapted, platform, tags_html)
return html
def save_html(self, html: str, platform: str) -> str:
def save_html(self, html: str, platform: str, *, title: str = "", content: str = "") -> str:
try:
save_article(self.topic_id, platform, html)
save_article(self.topic_id, platform, html, title=title, content=content)
logger.info(f"文章写入数据库: {platform}_{self.topic_id}")
return f"db:{platform}_{self.topic_id}"
except Exception as e:
@@ -463,8 +548,9 @@ class Writer:
results = {}
for platform in ["zhihu", "wechat", "xiaohongshu"]:
markdown = self.generate_platform_markdown(platform)
title = self._optimize_title(platform)
html = self.generate_platform_html(markdown, platform)
results[platform] = str(self.save_html(html, platform))
results[platform] = str(self.save_html(html, platform, title=title, content=markdown))
self.mark_draft()
logger.info(f"撰写完成,状态已更新为待审查")
return {"ok": True, "files": results}