From 8595bbc521454f0f87f05cabc247cf6af3027125 Mon Sep 17 00:00:00 2001 From: Yuzhiran Dev Date: Tue, 9 Jun 2026 17:18:09 +0800 Subject: [PATCH] feat: GEO/SEO structured data + search ranking tracker --- PROGRESS.md | 58 ++++- platform/backend/app/api/admin.py | 4 + platform/backend/app/api/articles.py | 2 + platform/backend/app/api/assets.py | 28 ++- platform/backend/app/api/assistant_actions.py | 20 +- platform/backend/app/api/calendar.py | 9 + platform/backend/app/api/metrics.py | 11 + platform/backend/app/api/search_rankings.py | 77 +++++++ platform/backend/app/api/system.py | 12 +- platform/backend/app/api/tasks.py | 23 ++ platform/backend/app/api/topics.py | 65 +++++- platform/backend/app/core/scheduler.py | 37 +++ platform/backend/app/database.py | 3 + platform/backend/app/main.py | 3 +- platform/backend/app/models.py | 35 +++ platform/backend/app/schemas.py | 19 ++ platform/frontend/admin.html | 68 +++++- platform/frontend/articles.html | 4 +- platform/frontend/theme-modern.css | 8 +- platform/frontend/topics.html | 169 +++++++++++++- scripts/compliance_optimizer.py | 11 +- scripts/db_helper.py | 8 +- scripts/outline.py | 19 +- scripts/prompt_loader.py | 9 +- scripts/rank_tracker.py | 216 ++++++++++++++++++ scripts/writer.py | 94 +++++++- 26 files changed, 961 insertions(+), 51 deletions(-) create mode 100644 platform/backend/app/api/search_rankings.py create mode 100644 scripts/rank_tracker.py diff --git a/PROGRESS.md b/PROGRESS.md index 2cb0d38..d25debb 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -3,7 +3,7 @@ > 本文件为项目进度唯一真理源,所有进度信息以此为准。 > 其他文档中的进度描述一律以本文为准。 -**最后更新**:2026-06-05 (v19) +**最后更新**:2026-06-09 (v20) --- @@ -158,14 +158,23 @@ | v-cloak 修复 Vue 模板闪烁 | 2026-06-04 | 9 页 `
` + `theme-modern.css` 的 `[v-cloak]` 规则,消除原始模板代码闪烁 | | **sensenova 多模型 + 模型级 rate limit** | **2026-06-04** | `LLMConfig` 加 `rate_limit`/`rate_limit_window_minutes`;seed 3 模型:deepseek-v4-flash(500次/5h)、6.7-flash-lite(1500次/5h)、u1-fast(1500次/5h);进程内 rate limiter;`LLM_TASK_MODEL` 环境变量实现任务级模型选择;`ai_image_generator.py` 已固定用 `sensenova-u1-fast` | +### ✅ 已完成 + +| 任务 | 完成日期 | 备注 | +|------|---------|------| +| GEO/SEO 结构化元数据注入 | 2026-06-09 | writer.py 的 `inject_geo_metadata()` 向 HTML 注入 JSON-LD Article schema、meta description/keywords、OG tags | +| 大纲提示词 SEO 关键词增强 | 2026-06-09 | outline.py 新增 `_extract_seo_keywords()` 从研究笔记提取关键词;outline prompt 加入 GEO 数据引用要求 | +| 搜索排名追踪 | 2026-06-09 | `SearchRanking` 模型(文章ID/关键词/排名位置/AI引用);`rank_tracker.py` 定时任务(每日 07:00)查 Bing 排名;`/api/seo/rankings` API;admin.html 新增"搜索排名"tab | + ### ⏳ 待办 | 任务 | 优先级 | 备注 | |------|--------|------| +| 生成式 AI 引用检测(Perplexity/Bing AI/百度 AI 搜索) | 中 | rank_tracker 的 `ai_cited` 检测需具体实现 | +| 百度搜索排名接入(需百度站长 API) | 中 | 当前仅 Bing,国内百度覆盖更广 | | admin.html 任务管理 tab(TaskConfig 参数编辑+TaskLog 历史时间轴) | 高 | 刚完成后端 DB 化,需完善前端 UI | | M4 第 4 篇文章发布 (首月目标) | 中 | 可用银发科技或 F01 补齐 | | 现有文章重新创作(清除 AI 思考内容) | 中 | writer.py 已修复,新文章不会再有;旧文章需重跑 creator.py | -| 数据追踪接入 (阅读量/互动) | 中 | 需要对接平台 API | | 归档 IMPLEMENTATION_PLAN.md | 低 | 内容已过时,与实际架构不符 | --- @@ -204,7 +213,50 @@ **验证**:33/33 测试通过 -## 八、规范说明 +## 八、GEO/SEO 升级(v20 · 2026-06-09) + +### 为 AI 搜索时代而生 + +**背景**:ChatGPT、Perplexity、百度 AI 搜索等生成式引擎正在取代传统搜索。内容被 AI 引用的价值大于单纯的关键词排名。此版本让流水线生产的内容天然符合 AI 搜索偏好。 + +### GEO 结构化数据 + +每篇生成的 HTML 自动注入: + +- **JSON-LD Article schema** — `@context: schema.org`,含 headline/description/datePublished/author/publisher/keywords +- **SEO meta tags** — ``, `` +- **Open Graph tags** — `og:title`, `og:description`, `og:type`, `og:site_name`, `article:published_time` +- **平台专用** — wechat/xiaohongshu 额外注入 `og:image` + +### 大纲 SEO 增强 + +- outline prompt 新增 `seo_keywords` 变量(从研究笔记自动提取) +- 每章要求至少融入 1 个 SEO 关键词 +- 新增 GEO 要求:每章包含可引用数据点,增加被 AI 搜索引用的概率 + +### 搜索排名追踪 + +| 模块 | 说明 | +|------|------| +| `SearchRanking` 模型 | 记录 article_id/keyword/position/url_found/ai_cited/search_engine | +| `scripts/rank_tracker.py` | 对所有已发布文章,自动生成搜索查询 → 调用 Bing API 查排名 → 写入 DB | +| 定时任务 | `scheduled_rank_tracker` 每日 07:00 自动运行 | +| 管理后台 | admin.html 新增「搜索排名」tab,展示概览统计(累计检查/有排名/AI引用/最佳排名)+ 排名列表 | + +### 关键文件 + +| 文件 | 改动 | +|------|------| +| `scripts/writer.py` | 新增 `inject_geo_metadata()`, `_extract_description()`, `_extract_tags_list()` — 在 `generate_platform_html()` 末尾注入 | +| `scripts/outline.py` | 新增 `_extract_seo_keywords()` — 传入 outline prompt | +| `scripts/prompt_loader.py` | outline_generation 提示词加入 `seo_keywords` 变量 + GEO 数据点要求 | +| `scripts/rank_tracker.py` | 新建 — Bing 排名查询 + DB 写入 | +| `platform/backend/app/models.py` | 新增 `SearchRanking` 模型 | +| `platform/backend/app/api/search_rankings.py` | 新建 — `/api/seo/rankings` + `/api/seo/rankings/overview` | +| `platform/backend/app/core/scheduler.py` | 新增 `scheduled_rank_tracker` 07:00 | +| `platform/frontend/admin.html` | 新增「搜索排名」tab(统计卡片 + el-table 排名列表) | + +## 九、规范说明 ### 规划文档 diff --git a/platform/backend/app/api/admin.py b/platform/backend/app/api/admin.py index 79d4ae0..aca1b27 100644 --- a/platform/backend/app/api/admin.py +++ b/platform/backend/app/api/admin.py @@ -78,6 +78,8 @@ def update_user( user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="用户不存在") + if admin_user.role != "admin" and user.org_id != admin_user.org_id: + raise HTTPException(status_code=404, detail="用户不存在") changes = {} if user_update.username is not None: @@ -122,6 +124,8 @@ def delete_user( user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="用户不存在") + if admin_user.role != "admin" and user.org_id != admin_user.org_id: + raise HTTPException(status_code=404, detail="用户不存在") if user.role == "admin": raise HTTPException(status_code=400, detail="不能删除管理员用户") diff --git a/platform/backend/app/api/articles.py b/platform/backend/app/api/articles.py index b362989..7708b57 100644 --- a/platform/backend/app/api/articles.py +++ b/platform/backend/app/api/articles.py @@ -83,6 +83,8 @@ def get_article_detail(article_id: str, current_user: User = Depends(get_current "topic_title": topic.title if topic else None, "platform": article.platform, "file_path": article.file_path, + "title": article.title, + "content": article.content, "status": article.status, "compliance_score": article.compliance_score, "word_count": article.word_count, diff --git a/platform/backend/app/api/assets.py b/platform/backend/app/api/assets.py index efbbd38..1f5b213 100644 --- a/platform/backend/app/api/assets.py +++ b/platform/backend/app/api/assets.py @@ -10,7 +10,7 @@ from typing import List, Optional from ..database import get_db from ..models import MediaAsset from ..schemas import MediaAssetCreate, MediaAssetUpdate, MediaAssetResponse -from .auth import get_current_user +from .auth import get_current_user, org_filter router = APIRouter(prefix="/api/assets", tags=["assets"]) @@ -33,6 +33,9 @@ def list_assets( current_user=Depends(get_current_user) ): query = db.query(MediaAsset) + of = org_filter(current_user, MediaAsset) + if of is not True: + query = query.filter(of) if file_type: query = query.filter(MediaAsset.file_type == file_type) @@ -54,7 +57,11 @@ def list_tags( db: Session = Depends(get_db), current_user=Depends(get_current_user) ): - assets = db.query(MediaAsset.tags).all() + of = org_filter(current_user, MediaAsset) + base = db.query(MediaAsset) + if of is not True: + base = base.filter(of) + assets = base.with_entities(MediaAsset.tags).all() all_tags = set() for a in assets: if a[0]: @@ -67,9 +74,13 @@ def get_counts( db: Session = Depends(get_db), current_user=Depends(get_current_user) ): - total = db.query(MediaAsset).count() + of = org_filter(current_user, MediaAsset) + base = db.query(MediaAsset) + if of is not True: + base = base.filter(of) + total = base.count() by_type = {} - rows = db.query(MediaAsset.file_type, func.count(MediaAsset.id)).group_by(MediaAsset.file_type).all() + rows = base.with_entities(MediaAsset.file_type, func.count(MediaAsset.id)).group_by(MediaAsset.file_type).all() for ftype, cnt in rows: by_type[ftype] = cnt return {"total": total, "by_type": by_type} @@ -120,7 +131,8 @@ async def upload_asset( alt_text=alt_text, tags=parsed_tags, topic_ids=parsed_topic_ids, - uploaded_by=current_user.username + uploaded_by=current_user.username, + org_id=current_user.org_id or "default", ) db.add(asset) db.commit() @@ -138,6 +150,8 @@ def update_asset( asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first() if not asset: raise HTTPException(status_code=404, detail="素材不存在") + if current_user.role != "admin" and asset.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="素材不存在") for k, v in data.model_dump(exclude_unset=True).items(): setattr(asset, k, v) @@ -155,6 +169,8 @@ def delete_asset( asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first() if not asset: raise HTTPException(status_code=404, detail="素材不存在") + if current_user.role != "admin" and asset.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="素材不存在") if os.path.exists(asset.file_path): try: @@ -176,6 +192,8 @@ def increment_usage( asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first() if not asset: raise HTTPException(status_code=404, detail="素材不存在") + if current_user.role != "admin" and asset.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="素材不存在") asset.usage_count = (asset.usage_count or 0) + 1 db.commit() return {"ok": True, "usage_count": asset.usage_count} \ No newline at end of file diff --git a/platform/backend/app/api/assistant_actions.py b/platform/backend/app/api/assistant_actions.py index c02b9f5..93ab62b 100644 --- a/platform/backend/app/api/assistant_actions.py +++ b/platform/backend/app/api/assistant_actions.py @@ -15,12 +15,15 @@ def list_topics( page: int = 1, page_size: int = 20, status: Optional[str] = None, - field: Optional[str] = None + field: Optional[str] = None, + org_id: Optional[str] = None, ) -> Dict[str, Any]: """获取选题列表,支持分页和筛选""" db = SessionLocal() try: query = db.query(Topic) + if org_id: + query = query.filter(Topic.org_id == org_id) if status: query = query.filter(Topic.status == status) if field: @@ -51,11 +54,14 @@ def list_topics( db.close() -def get_topic(topic_id: str) -> Dict[str, Any]: +def get_topic(topic_id: str, org_id: Optional[str] = None) -> Dict[str, Any]: """获取单个选题详情""" db = SessionLocal() try: - t = db.query(Topic).filter(Topic.id == topic_id).first() + query = db.query(Topic).filter(Topic.id == topic_id) + if org_id: + query = query.filter(Topic.org_id == org_id) + t = query.first() if not t: return {"error": f"选题 {topic_id} 不存在"} return { @@ -191,19 +197,21 @@ def list_system_configs() -> List[Dict[str, Any]]: ACTION_REGISTRY = { "list_topics": { "func": list_topics, - "description": "获取选题列表,支持分页(page, page_size)和筛选(status, field)", + "description": "获取选题列表,支持分页(page, page_size)和筛选(status, field, org_id)", "params_schema": { "page": {"type": "integer", "default": 1, "desc": "页码"}, "page_size": {"type": "integer", "default": 20, "desc": "每页条数"}, "status": {"type": "string", "enum": ["pending", "review", "draft", "ready", "published"], "desc": "按状态筛选"}, - "field": {"type": "string", "desc": "按领域筛选"} + "field": {"type": "string", "desc": "按领域筛选"}, + "org_id": {"type": "string", "desc": "按组织筛选"} } }, "get_topic": { "func": get_topic, "description": "获取单个选题详情", "params_schema": { - "topic_id": {"type": "string", "desc": "选题ID,如 A01 或 LIVING-001-26"} + "topic_id": {"type": "string", "desc": "选题ID,如 A01 或 LIVING-001-26"}, + "org_id": {"type": "string", "desc": "按组织筛选(可选)"} } }, "get_recent_task_logs": { diff --git a/platform/backend/app/api/calendar.py b/platform/backend/app/api/calendar.py index 58f5949..ae754ad 100644 --- a/platform/backend/app/api/calendar.py +++ b/platform/backend/app/api/calendar.py @@ -120,6 +120,11 @@ def update_entry( if not entry: raise HTTPException(status_code=404, detail="日历条目不存在") + if entry.topic_id: + topic = db.query(Topic).filter(Topic.id == entry.topic_id).first() + if topic and current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="日历条目不存在") + for k, v in data.model_dump(exclude_unset=True).items(): setattr(entry, k, v) db.commit() @@ -147,6 +152,10 @@ def delete_entry( entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first() if not entry: raise HTTPException(status_code=404, detail="日历条目不存在") + if entry.topic_id: + topic = db.query(Topic).filter(Topic.id == entry.topic_id).first() + if topic and current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="日历条目不存在") db.delete(entry) db.commit() return {"ok": True} diff --git a/platform/backend/app/api/metrics.py b/platform/backend/app/api/metrics.py index 65c560e..199299d 100644 --- a/platform/backend/app/api/metrics.py +++ b/platform/backend/app/api/metrics.py @@ -186,6 +186,13 @@ def create_metric( return metric +def _check_metric_org(metric, current_user, db): + """Check if metric's topic belongs to user's org""" + topic = db.query(Topic).filter(Topic.id == metric.topic_id).first() + if topic and current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="数据记录不存在") + + @router.put("/entries/{metric_id}", response_model=ContentMetricsResponse) def update_metric( metric_id: int, @@ -196,6 +203,7 @@ def update_metric( metric = db.query(ContentMetrics).filter(ContentMetrics.id == metric_id).first() if not metric: raise HTTPException(status_code=404, detail="数据记录不存在") + _check_metric_org(metric, current_user, db) for k, v in data.model_dump(exclude_unset=True).items(): setattr(metric, k, v) @@ -214,6 +222,7 @@ def delete_metric( metric = db.query(ContentMetrics).filter(ContentMetrics.id == metric_id).first() if not metric: raise HTTPException(status_code=404, detail="数据记录不存在") + _check_metric_org(metric, current_user, db) db.delete(metric) db.commit() return {"ok": True} @@ -357,6 +366,8 @@ def fetch_zhihu_metrics( topic = db.query(Topic).filter(Topic.id == data.topic_id).first() if not topic: raise HTTPException(status_code=404, detail="选题不存在") + if current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="选题不存在") post_id = _extract_zhihu_post_id(data.zhihu_url) diff --git a/platform/backend/app/api/search_rankings.py b/platform/backend/app/api/search_rankings.py new file mode 100644 index 0000000..478b8b7 --- /dev/null +++ b/platform/backend/app/api/search_rankings.py @@ -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, + } + } diff --git a/platform/backend/app/api/system.py b/platform/backend/app/api/system.py index ef219f0..38d5762 100644 --- a/platform/backend/app/api/system.py +++ b/platform/backend/app/api/system.py @@ -114,7 +114,7 @@ def _aggregate_status_counts(q): return counts @router.get("/status") -def get_status(db: Session = Depends(get_db)): +def get_status(db: Session = Depends(get_db), current_user=Depends(get_current_user)): total = db.query(Topic).count() counts = _aggregate_status_counts(db.query(Topic)) today = date.today() @@ -243,15 +243,16 @@ def get_pipeline_status(db: Session = Depends(get_db), current_user=Depends(get_ return {"topics_count": total, "status_distribution": counts, "pipeline_modules": pipeline_status} @router.post("/sync/run") -def run_sync(): +def run_sync(current_user=Depends(get_current_user)): try: sync_all_topics() return {"message": "Sync completed (DB → JSON backup)"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @router.post("/optimize-sources/run") -def trigger_optimize_sources(): +def trigger_optimize_sources(current_user=Depends(get_current_user)): try: from ..core.scheduler import scheduler def _bg(): @@ -265,8 +266,9 @@ def trigger_optimize_sources(): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @router.post("/metrics-sync/run") -def trigger_metrics_sync(): +def trigger_metrics_sync(current_user=Depends(get_current_user)): try: from ..core.scheduler import scheduler def _bg(): @@ -330,7 +332,7 @@ def list_automation_topics(db: Session = Depends(get_db), current_user=Depends(g raise HTTPException(status_code=500, detail=str(e)) @router.post("/refresh") -def refresh_all(): +def refresh_all(current_user=Depends(get_current_user)): try: sync_all_topics() return {"message": "Refresh completed"} diff --git a/platform/backend/app/api/tasks.py b/platform/backend/app/api/tasks.py index 6e61f7e..d7ea0c9 100644 --- a/platform/backend/app/api/tasks.py +++ b/platform/backend/app/api/tasks.py @@ -14,6 +14,16 @@ _creator_semaphore = threading.Semaphore(3) router = APIRouter(prefix="/api/tasks", tags=["tasks"]) +def _check_task_org(task, current_user, db): + """Check if task's topic belongs to user's org""" + if current_user.role == "admin" or not task.topic_id: + return True + topic = db.query(Topic).filter(Topic.id == task.topic_id).first() + if topic and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="任务不存在") + return True + + @router.get("", response_model=List[ContentTaskResponse]) def list_tasks( status: Optional[str] = None, @@ -94,6 +104,7 @@ def get_task( task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() if not task: raise HTTPException(status_code=404, detail="任务不存在") + _check_task_org(task, current_user, db) return task @@ -106,6 +117,7 @@ def start_task( task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() if not task: raise HTTPException(status_code=404, detail="任务不存在") + _check_task_org(task, current_user, db) from datetime import datetime, timezone task.status = "running" @@ -128,6 +140,7 @@ def update_progress( task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() if not task: raise HTTPException(status_code=404, detail="任务不存在") + _check_task_org(task, current_user, db) task.progress = progress if message: @@ -148,6 +161,7 @@ def complete_task( task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() if not task: raise HTTPException(status_code=404, detail="任务不存在") + _check_task_org(task, current_user, db) from datetime import datetime, timezone finished = datetime.now(timezone.utc) @@ -175,6 +189,7 @@ def fail_task( task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() if not task: raise HTTPException(status_code=404, detail="任务不存在") + _check_task_org(task, current_user, db) from datetime import datetime, timezone finished = datetime.now(timezone.utc) @@ -197,6 +212,7 @@ def cancel_task( task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() if not task: raise HTTPException(status_code=404, detail="任务不存在") + _check_task_org(task, current_user, db) task.status = "cancelled" db.commit() @@ -406,6 +422,13 @@ def run_creator_task( ): from datetime import datetime, timezone + if topic_id: + topic = db.query(Topic).filter(Topic.id == topic_id).first() + if not topic: + raise HTTPException(status_code=404, detail="选题不存在") + if current_user.role != "admin" and topic.org_id != current_user.org_id: + raise HTTPException(status_code=404, detail="选题不存在") + now = datetime.now(timezone.utc) task_id = f"task_{uuid.uuid4().hex[:16]}" diff --git a/platform/backend/app/api/topics.py b/platform/backend/app/api/topics.py index 42f3ba8..23c7622 100644 --- a/platform/backend/app/api/topics.py +++ b/platform/backend/app/api/topics.py @@ -9,7 +9,9 @@ from ..database import get_db from ..models import Topic, TopicField, TopicConfigField, Article, ContentMetrics from ..schemas import ( TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest, + TopicAnalyzeRequest, TopicAnalyzeResponse, ) +from ..core.nvidia_client import call_llm from .auth import get_current_user, org_filter router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)]) @@ -93,6 +95,65 @@ def topic_stats( } +@router.post("/analyze", response_model=TopicAnalyzeResponse) +def analyze_topic( + data: TopicAnalyzeRequest, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """AI 分析用户输入的选题思路,返回结构化的选题信息""" + import json, re + from sqlalchemy import text as sa_text + + refs = "\n".join(f"- {link}" for link in data.reference_links) if data.reference_links else "无" + prompt_text = f"""你是一个专业的内容策略师。用户提供了一个选题思路,请分析并提炼为结构化的选题信息。 + +用户输入的原始内容: +{data.raw_input} + +参考链接(如有): +{refs} + +请输出 JSON(只输出 JSON,不要其他文字): +{{ + "title": "优化后的选题标题(20字内,含核心关键词,有吸引力)", + "format": "内容形式(趋势洞察/实操指南/对比分析/案例解读/观点讨论)", + "core_concept": "核心观点(一句话说清独特价值,20字内)", + "audience_pain": "受众痛点(目标读者的真实困惑或需求,20字内)", + "unique_angle": "差异化切入点(与常见文章不同的视角,20字内)", + "tags": ["标签1", "标签2", "标签3", "标签4", "标签5"] +}}""" + + try: + resp = call_llm(prompt_text, temperature=0.6, max_tokens=1500) + except Exception as e: + raise HTTPException(status_code=503, detail=f"AI 分析失败: {str(e)}") + + # Parse JSON from response + resp = resp.strip() + # Remove markdown code fences if present + resp = re.sub(r'^```(?:json)?\s*', '', resp) + resp = re.sub(r'\s*```$', '', resp) + parsed = json.loads(resp) + + field_name = None + if data.field_id: + field = db.query(TopicField).filter(TopicField.id == data.field_id).first() + if field: + field_name = field.name + + return TopicAnalyzeResponse( + title=parsed.get("title", ""), + format=parsed.get("format", ""), + core_concept=parsed.get("core_concept", ""), + audience_pain=parsed.get("audience_pain", ""), + unique_angle=parsed.get("unique_angle", ""), + field_name=field_name, + tags=parsed.get("tags", []), + priority="中", + ) + + @router.post("", response_model=TopicResponse) def create_topic( data: TopicCreate, @@ -111,7 +172,7 @@ def create_topic( else: topic_id = f"T{datetime.now().strftime('%m%d%H%M')}" - field_name = None + field_name = "未分类" if data.field_id: field = db.query(TopicField).filter(TopicField.id == data.field_id).first() if field: @@ -164,7 +225,7 @@ def update_topic( topic.field_id = data.field_id if data.field_id: field = db.query(TopicField).filter(TopicField.id == data.field_id).first() - topic.field_name = field.name if field else None + topic.field_name = field.name if field else '未分类' for k, v in data.model_dump(exclude_unset=True, exclude={"field_id"}).items(): if k == "tags" or k == "custom_data" or k == "scoring_data": diff --git a/platform/backend/app/core/scheduler.py b/platform/backend/app/core/scheduler.py index d34ed22..1b9189e 100644 --- a/platform/backend/app/core/scheduler.py +++ b/platform/backend/app/core/scheduler.py @@ -56,6 +56,7 @@ MODULES = { "scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00"}, "scheduled_reset_search_usage": {"name": "🔁 搜索用量重置", "cron": "00:05"}, "scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"}, + "scheduled_rank_tracker": {"name": "🔍 搜索排名追踪", "cron": "07:00"}, } LOG_FILE_MAP = { @@ -67,6 +68,7 @@ LOG_FILE_MAP = { "scheduled_metrics_sync": "metrics_sync", "scheduled_reset_search_usage": "reset_search_usage", "scheduled_task_monitor": "task_monitor", + "scheduled_rank_tracker": "rank_tracker", } def _log_to_file(module_id: str, status: str, message: str = None, error_trace: str = None): @@ -182,6 +184,7 @@ class TaskScheduler: ("scheduled_optimize_sources", self._run_optimize_sources, "信息源优化"), ("scheduled_metrics_sync", self._run_metrics_sync, "指标同步"), ("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"), + ("scheduled_rank_tracker", self._run_rank_tracker, "搜索排名追踪"), ] for module_id, fn, name in MODULE_JOBS: @@ -585,6 +588,40 @@ class TaskScheduler: started_at=started, finished_at=datetime.now(timezone.utc)) logger.exception("[TaskMonitor] 监控检查失败: %s", e) + def _run_rank_tracker(self): + """每日搜索排名追踪(Bing 查询关键词排名)""" + started = datetime.now(timezone.utc) + log_id = _log_task("scheduled_rank_tracker", "running", started_at=started) + try: + import subprocess + result = subprocess.run( + [sys.executable, str(PROJECT_ROOT / "scripts" / "rank_tracker.py"), "--engine", "bing"], + capture_output=True, text=True, timeout=300 + ) + if result.returncode == 0: + try: + data = json.loads(result.stdout.strip()) + except json.JSONDecodeError: + data = {} + _log_task("scheduled_rank_tracker", "success", log_id=log_id, + message=f"追踪完成: {data.get('keywords_checked', 0)} 关键词, " + f"{data.get('on_page', 0)} 条有排名", + result_data=data, + started_at=started, finished_at=datetime.now(timezone.utc)) + logger.info("[RankTracker] 完成: %s", result.stdout.strip()[:200]) + else: + _log_task("scheduled_rank_tracker", "failed", log_id=log_id, + message=f"返回码 {result.returncode}", + error_trace=result.stderr[-500:], + started_at=started, finished_at=datetime.now(timezone.utc)) + except Exception as e: + import traceback + _log_task("scheduled_rank_tracker", "failed", log_id=log_id, + message=str(e), + error_trace=traceback.format_exc(), + started_at=started, finished_at=datetime.now(timezone.utc)) + logger.exception("[RankTracker] 排名追踪失败: %s", e) + def get_jobs(self): """返回当前所有定时任务的状态""" jobs = [] diff --git a/platform/backend/app/database.py b/platform/backend/app/database.py index 5c524b6..7d0e64b 100644 --- a/platform/backend/app/database.py +++ b/platform/backend/app/database.py @@ -56,7 +56,10 @@ def init_db(): conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'")) for table, col, typ in [ ("users", "org_id", "VARCHAR DEFAULT 'default'"), + ("articles", "title", "VARCHAR"), + ("articles", "content", "TEXT"), ("articles", "updated_at", "TIMESTAMP"), + ("media_assets", "org_id", "VARCHAR DEFAULT 'default'"), ("topics", "org_id", "VARCHAR DEFAULT 'default'"), ("topics", "reviewed_at", "TIMESTAMP"), ("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"), diff --git a/platform/backend/app/main.py b/platform/backend/app/main.py index 8015882..dbe230b 100644 --- a/platform/backend/app/main.py +++ b/platform/backend/app/main.py @@ -9,7 +9,7 @@ from pathlib import Path from .database import engine, get_db, init_db from .models import Base -from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers +from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers, search_rankings from .initial_data import import_initial_data from .core.scheduler import scheduler @@ -104,6 +104,7 @@ app.include_router(role_configs.router) app.include_router(menu_configs.router) app.include_router(menu_configs.public_router) app.include_router(search_providers.router) +app.include_router(search_rankings.router) # 挂载自动生成的图片(必须先于前端根挂载) PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent diff --git a/platform/backend/app/models.py b/platform/backend/app/models.py index 7ecba21..ae0c567 100644 --- a/platform/backend/app/models.py +++ b/platform/backend/app/models.py @@ -282,6 +282,8 @@ class Article(Base): topic_id = Column(String, ForeignKey("topics.id"), nullable=False) platform = Column(String, nullable=False) file_path = Column(String, nullable=False) + title = Column(String, nullable=True) + content = Column(Text, nullable=True) status = Column(String, default="draft") created_at = Column(DateTime(timezone=True), server_default=func.now()) compliance_score = Column(Integer) @@ -410,6 +412,7 @@ class MediaAsset(Base): topic_ids = Column(JSON, default=list) usage_count = Column(Integer, default=0) uploaded_by = Column(String, nullable=True) + org_id = Column(String, default="default", nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -853,4 +856,36 @@ class CollectorSource(Base): "sort_order": self.sort_order, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class SearchRanking(Base): + """搜索排名追踪""" + __tablename__ = "search_rankings" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + article_id = Column(String, ForeignKey("articles.id"), nullable=True) + topic_id = Column(String, ForeignKey("topics.id"), nullable=True) + keyword = Column(String, nullable=False, index=True) + platform = Column(String, nullable=True) + search_engine = Column(String, default="bing") # bing / baidu / google + 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 搜索来源名称 + 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, + "keyword": self.keyword, + "platform": self.platform, + "search_engine": self.search_engine, + "position": self.position, + "url_found": self.url_found, + "ai_cited": self.ai_cited, + "ai_source": self.ai_source, + "checked_at": self.checked_at.isoformat() if self.checked_at else None, } \ No newline at end of file diff --git a/platform/backend/app/schemas.py b/platform/backend/app/schemas.py index bf9945f..7d6e322 100644 --- a/platform/backend/app/schemas.py +++ b/platform/backend/app/schemas.py @@ -92,6 +92,23 @@ class TopicCreate(BaseModel): scoring_data: Dict[str, Any] = {} +class TopicAnalyzeRequest(BaseModel): + raw_input: str = Field(..., min_length=1, description="用户输入的选题思路/内容描述") + reference_links: List[str] = Field(default=[], description="参考链接列表(可选)") + field_id: Optional[int] = Field(default=None, description="所属领域ID(可选)") + + +class TopicAnalyzeResponse(BaseModel): + title: str + format: str + core_concept: str + audience_pain: str + unique_angle: str + field_name: Optional[str] = None + tags: List[str] = [] + priority: str = "中" + + class TopicUpdate(BaseModel): field_id: Optional[int] = None title: Optional[str] = None @@ -131,6 +148,8 @@ class ArticleBase(BaseModel): topic_id: str platform: str file_path: str + title: Optional[str] = None + content: Optional[str] = None status: str = "draft" compliance_score: Optional[int] = None word_count: Optional[int] = None diff --git a/platform/frontend/admin.html b/platform/frontend/admin.html index 3110863..33ce0fc 100644 --- a/platform/frontend/admin.html +++ b/platform/frontend/admin.html @@ -32,6 +32,7 @@ 运行日志 AI 助手 搜索API + 搜索排名 配置管理
@@ -607,6 +608,53 @@ +
+
+ GEO/SEO 搜索排名追踪 — 每日 07:00 自动检查 +
+
加载中...
+ +
+
🔒 敏感词 @@ -634,7 +682,7 @@ {{ row.category || '未分类' }} - + - +