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 自动检查
+
+
加载中...
+
+
+
+
{{ rankingStats.total_checks }}
+
累计检查关键词
+
+
+
{{ rankingStats.on_page }}
+
有排名关键词
+
+
+
{{ rankingStats.ai_cited }}
+
AI 搜索引用
+
+
+
{{ rankingStats.best_position ? '#' + rankingStats.best_position : '-' }}
+
最佳排名
+
+
+
+
+
+
+
+
+
+
+ {{ row.position ? '#' + row.position : '未上榜' }}
+
+
+
+
+
+ {{ row.ai_cited ? '是' : '否' }}
+
+
+
+
+
+
+
+
🔒 敏感词
@@ -634,7 +682,7 @@
{{ row.category || '未分类' }}
-
+
编辑
删除
@@ -682,7 +730,7 @@
{{ row.is_active ? '启用' : '停用' }}
-
+
编辑
删除
@@ -1270,6 +1318,20 @@ const llmConfigs = ref([]);
return Object.entries(map).sort((a, b) => a[0].localeCompare(b[0]));
});
+ const rankings = ref([]);
+ const rankingStats = ref({ total_checks: 0, on_page: 0, ai_cited: 0, best_position: null });
+ const rankingsLoading = ref(false);
+ const loadRankings = async () => {
+ rankingsLoading.value = true;
+ try {
+ const resp = await api.get('/api/seo/rankings?limit=100');
+ rankings.value = resp.data || [];
+ const overview = await api.get('/api/seo/rankings/overview');
+ rankingStats.value = overview.data || { total_checks: 0, on_page: 0, ai_cited: 0, best_position: null };
+ } catch (e) { console.error('加载排名数据失败:', e); }
+ finally { rankingsLoading.value = false; }
+ };
+
const contentCleanRules = ref([]);
const ccrLoading = ref(false);
const ccrDialogVisible = ref(false);
@@ -1319,6 +1381,7 @@ const llmConfigs = ref([]);
users: fetchUsers,
logs: loadLogTypes, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig,
searchproviders: loadSearchProviders,
+ searchrankings: loadRankings,
configitems: () => { loadSensitiveWords(); loadContentCleanRules(); },
};
const loadedTabs = new Set([]);
@@ -1378,6 +1441,7 @@ const llmConfigs = ref([]);
sensitiveWords, swLoading, swDialogVisible, swSaving, swForm, editingSwId, loadSensitiveWords, showAddSensitiveWord, editSensitiveWord, saveSensitiveWord, deleteSensitiveWord, configSubTab, switchConfigSubTab,
contentCleanRules, ccrLoading, ccrDialogVisible, ccrDialogTitle, ccrSaving, ccrForm, editingCcrId,
loadContentCleanRules, showAddCleanRule, editCleanRule, saveCleanRule, deleteCleanRule,
+ rankings, rankingStats, rankingsLoading, loadRankings,
};
}
});
diff --git a/platform/frontend/articles.html b/platform/frontend/articles.html
index 6578830..50ca6a3 100644
--- a/platform/frontend/articles.html
+++ b/platform/frontend/articles.html
@@ -124,8 +124,8 @@
关闭
-
diff --git a/platform/frontend/theme-modern.css b/platform/frontend/theme-modern.css
index bc981e2..ea04213 100644
--- a/platform/frontend/theme-modern.css
+++ b/platform/frontend/theme-modern.css
@@ -363,13 +363,13 @@ body {
.login-footer a:hover { color: var(--color-accent); }
/* ========== Preview Dialog ========== */
-.preview-iframe { box-sizing: border-box; }
+.preview-iframe { box-sizing: border-box; height: 100%; width: 100%; }
.preview-dialog-custom.el-dialog {
max-height: calc(100vh - 90px); overflow: hidden; display: flex;
flex-direction: column; margin-top: 0 !important;
}
.preview-dialog-custom.el-dialog .el-dialog__header { padding: var(--spacing-sm) var(--spacing-md); margin: 0; flex-shrink: 0; }
-.preview-dialog-custom.el-dialog .el-dialog__body { padding: var(--spacing-md); overflow: hidden; }
+.preview-dialog-custom.el-dialog .el-dialog__body { padding: var(--spacing-md); overflow: hidden; flex: 1; min-height: 0; }
.preview-dialog-custom.el-dialog .el-dialog__footer { flex-shrink: 0; padding: var(--spacing-sm) var(--spacing-md); }
.preview-dialog-custom.is-fullscreen { z-index: 100001 !important; }
body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index: 100000 !important; }
@@ -400,11 +400,11 @@ body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index
.el-button--default { padding: 10px 16px !important; }
.el-input__inner { min-height: 38px; }
- .preview-iframe { max-height: calc(100vh - 250px) !important; }
+ .preview-iframe { min-height: 300px; }
.preview-dialog-custom { position: relative; }
}
@media (min-width: 769px) {
- .preview-iframe { max-height: calc(100vh - 100px) !important; }
+ .preview-iframe { min-height: 400px; }
.preview-dialog-custom { position: relative; left: 90px; }
}
diff --git a/platform/frontend/topics.html b/platform/frontend/topics.html
index c37a029..673c1b3 100644
--- a/platform/frontend/topics.html
+++ b/platform/frontend/topics.html
@@ -8,8 +8,8 @@