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
@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, func
from typing import Optional
from datetime import datetime, timedelta, timezone
from ..database import get_db
from ..models import SearchRanking, Article, Topic
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/seo", tags=["seo"])
ArticleModel = Article
def _apply_org_filter(query, current_user, db):
of = org_filter(current_user, ArticleModel)
if of is not True:
query = query.join(ArticleModel, SearchRanking.article_id == ArticleModel.id, isouter=True)
query = query.filter(of)
return query
@router.get("/rankings")
def get_rankings(
article_id: Optional[str] = None,
keyword: Optional[str] = None,
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),
):
"""获取搜索排名数据"""
query = db.query(SearchRanking)
query = _apply_org_filter(query, current_user, db)
if article_id:
query = query.filter(SearchRanking.article_id == article_id)
if keyword:
query = query.filter(SearchRanking.keyword.ilike(f"%{keyword}%"))
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
query = query.filter(SearchRanking.checked_at >= cutoff)
rankings = query.order_by(desc(SearchRanking.checked_at)).limit(limit).all()
return {"ok": True, "data": [r.to_dict() for r in rankings]}
@router.get("/rankings/overview")
def get_rankings_overview(
days: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""搜索排名概览统计"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
base = db.query(SearchRanking)
base = _apply_org_filter(base, current_user, db)
base = base.filter(SearchRanking.checked_at >= cutoff)
total_checks = base.count()
on_page = base.filter(SearchRanking.position.isnot(None)).count()
ai_cited = base.filter(SearchRanking.ai_cited == True).count()
best = base.filter(SearchRanking.position.isnot(None)).order_by(SearchRanking.position).first()
return {
"ok": True,
"data": {
"total_checks": total_checks,
"on_page": on_page,
"ai_cited": ai_cited,
"best_position": best.position if best else None,
"best_keyword": best.keyword if best else None,
}
}