feat: GEO data model and API endpoints

- SearchRanking: +5 fields (ai_search_engine, citation_snippet, citation_url, geo_score, content_type)
- New GeoReadinessScore model (6-dimension scoring: schema/faq/howto/citations/word_count/readability/headings)
- GET /api/seo/geo/overview, GET /api/seo/geo/readiness, POST /api/seo/geo/track

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:25:18 +08:00
parent 63a6fabc00
commit 773080cf4f
2 changed files with 132 additions and 3 deletions
+82 -1
View File
@@ -5,7 +5,7 @@ from typing import Optional
from datetime import datetime, timedelta, timezone
from ..database import get_db
from ..models import SearchRanking, Article, Topic
from ..models import SearchRanking, Article, Topic, GeoReadinessScore
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/seo", tags=["seo"])
@@ -75,3 +75,84 @@ def get_rankings_overview(
"best_keyword": best.keyword if best else None,
}
}
@router.get("/geo/overview")
def get_geo_overview(
days: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""GEO 概览 — AI 搜索引用 + 就绪度评分"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
base = db.query(SearchRanking).filter(SearchRanking.checked_at >= cutoff)
total_geo = base.filter(SearchRanking.search_engine == "geo").count()
total_cited = base.filter(
SearchRanking.search_engine == "geo",
SearchRanking.ai_cited == True
).count()
engine_breakdown = {}
engines = base.filter(
SearchRanking.search_engine == "geo",
SearchRanking.ai_source.isnot(None)
).with_entities(
SearchRanking.ai_source,
func.count(SearchRanking.id).label("cnt"),
func.sum(func.cast(SearchRanking.ai_cited, func.Integer)).label("cited"),
).group_by(SearchRanking.ai_source).all()
for row in engines:
engine_breakdown[row.ai_source] = {
"total": row.cnt,
"cited": row.cited or 0,
}
latest_scores = db.query(GeoReadinessScore).order_by(
desc(GeoReadinessScore.checked_at)
).limit(50).all()
avg_score = db.query(func.avg(GeoReadinessScore.total_score)).filter(
GeoReadinessScore.checked_at >= cutoff
).scalar() or 0
return {
"ok": True,
"data": {
"total_geo_checks": total_geo,
"total_cited": total_cited,
"citation_rate": round(total_cited / total_geo * 100, 1) if total_geo else 0,
"engine_breakdown": engine_breakdown,
"avg_geo_readiness": round(float(avg_score), 1),
"latest_scores": [s.to_dict() for s in latest_scores[:10]],
}
}
@router.get("/geo/readiness")
def get_geo_readiness_scores(
days: int = Query(30, ge=1, le=365),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""获取 GEO 就绪度评分列表"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
scores = db.query(GeoReadinessScore).filter(
GeoReadinessScore.checked_at >= cutoff
).order_by(desc(GeoReadinessScore.checked_at)).limit(limit).all()
return {"ok": True, "data": [s.to_dict() for s in scores]}
@router.post("/geo/track")
def trigger_geo_tracking(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""手动触发 GEO 追踪"""
try:
from scripts.geo_tracker import run_all
result = run_all()
return {"ok": True, "message": "GEO 追踪已启动", "result": result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+50 -2
View File
@@ -860,7 +860,7 @@ class CollectorSource(Base):
class SearchRanking(Base):
"""搜索排名追踪"""
"""搜索排名追踪 + AI 搜索引用追踪"""
__tablename__ = "search_rankings"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
@@ -872,7 +872,12 @@ class SearchRanking(Base):
position = Column(Integer, nullable=True) # 搜索排名位置(null = 未上榜)
url_found = Column(String, nullable=True) # 被找到的具体 URL
ai_cited = Column(Boolean, default=False) # 是否被 AI 搜索引用
ai_source = Column(String, nullable=True) # AI 搜索来源名称
ai_source = Column(String, nullable=True) # AI 搜索来源chatgpt/perplexity/deepseek
ai_search_engine = Column(String, nullable=True) # AI 搜索具体引擎模型名
citation_snippet = Column(String, nullable=True) # 被引用的文本片段
citation_url = Column(String, nullable=True) # 引用来源链接
geo_score = Column(Integer, nullable=True) # GEO 就绪度评分 0-100
content_type = Column(String, nullable=True) # 内容类型: article/listicle/howto/faq/review
checked_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
@@ -887,5 +892,48 @@ class SearchRanking(Base):
"url_found": self.url_found,
"ai_cited": self.ai_cited,
"ai_source": self.ai_source,
"ai_search_engine": self.ai_search_engine,
"citation_snippet": self.citation_snippet,
"citation_url": self.citation_url,
"geo_score": self.geo_score,
"content_type": self.content_type,
"checked_at": self.checked_at.isoformat() if self.checked_at else None,
}
class GeoReadinessScore(Base):
"""GEO 就绪度评分 — 按文章评估被 AI 搜索引用的概率"""
__tablename__ = "geo_readiness_scores"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
article_id = Column(String, ForeignKey("articles.id"), nullable=True, index=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=True)
platform = Column(String, nullable=True)
total_score = Column(Integer, default=0) # 总分 0-100
has_schema = Column(Boolean, default=False) # 是否有结构化数据
schema_types = Column(String, nullable=True) # 含有的 schema 类型列表
has_faq_format = Column(Boolean, default=False) # 是否含 FAQ 格式
has_howto_format = Column(Boolean, default=False) # 是否含 HowTo 格式
has_citations = Column(Boolean, default=False) # 是否引用数据源
word_count = Column(Integer, default=0)
readability_score = Column(Integer, default=0) # 可读性评分 0-100
heading_structure_score = Column(Integer, default=0) # 标题结构评分 0-100
checked_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
return {
"id": self.id,
"article_id": self.article_id,
"topic_id": self.topic_id,
"platform": self.platform,
"total_score": self.total_score,
"has_schema": self.has_schema,
"schema_types": self.schema_types,
"has_faq_format": self.has_faq_format,
"has_howto_format": self.has_howto_format,
"has_citations": self.has_citations,
"word_count": self.word_count,
"readability_score": self.readability_score,
"heading_structure_score": self.heading_structure_score,
"checked_at": self.checked_at.isoformat() if self.checked_at else None,
}