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
+55 -3
View File
@@ -3,7 +3,7 @@
> 本文件为项目进度唯一真理源,所有进度信息以此为准。 > 本文件为项目进度唯一真理源,所有进度信息以此为准。
> 其他文档中的进度描述一律以本文为准。 > 其他文档中的进度描述一律以本文为准。
**最后更新**2026-06-05 (v19) **最后更新**2026-06-09 (v20)
--- ---
@@ -158,14 +158,23 @@
| v-cloak 修复 Vue 模板闪烁 | 2026-06-04 | 9 页 `<div id="app">` + `theme-modern.css``[v-cloak]` 规则,消除原始模板代码闪烁 | | v-cloak 修复 Vue 模板闪烁 | 2026-06-04 | 9 页 `<div id="app">` + `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` | | **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` APIadmin.html 新增"搜索排名"tab |
### ⏳ 待办 ### ⏳ 待办
| 任务 | 优先级 | 备注 | | 任务 | 优先级 | 备注 |
|------|--------|------| |------|--------|------|
| 生成式 AI 引用检测(Perplexity/Bing AI/百度 AI 搜索) | 中 | rank_tracker 的 `ai_cited` 检测需具体实现 |
| 百度搜索排名接入(需百度站长 API) | 中 | 当前仅 Bing,国内百度覆盖更广 |
| admin.html 任务管理 tabTaskConfig 参数编辑+TaskLog 历史时间轴) | 高 | 刚完成后端 DB 化,需完善前端 UI | | admin.html 任务管理 tabTaskConfig 参数编辑+TaskLog 历史时间轴) | 高 | 刚完成后端 DB 化,需完善前端 UI |
| M4 第 4 篇文章发布 (首月目标) | 中 | 可用银发科技或 F01 补齐 | | M4 第 4 篇文章发布 (首月目标) | 中 | 可用银发科技或 F01 补齐 |
| 现有文章重新创作(清除 AI 思考内容) | 中 | writer.py 已修复,新文章不会再有;旧文章需重跑 creator.py | | 现有文章重新创作(清除 AI 思考内容) | 中 | writer.py 已修复,新文章不会再有;旧文章需重跑 creator.py |
| 数据追踪接入 (阅读量/互动) | 中 | 需要对接平台 API |
| 归档 IMPLEMENTATION_PLAN.md | 低 | 内容已过时,与实际架构不符 | | 归档 IMPLEMENTATION_PLAN.md | 低 | 内容已过时,与实际架构不符 |
--- ---
@@ -204,7 +213,50 @@
**验证**33/33 测试通过 **验证**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** — `<meta name="description">`, `<meta name="keywords">`
- **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 排名列表) |
## 九、规范说明
### 规划文档 ### 规划文档
+4
View File
@@ -78,6 +78,8 @@ def update_user(
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") 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 = {} changes = {}
if user_update.username is not None: if user_update.username is not None:
@@ -122,6 +124,8 @@ def delete_user(
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") 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": if user.role == "admin":
raise HTTPException(status_code=400, detail="不能删除管理员用户") raise HTTPException(status_code=400, detail="不能删除管理员用户")
+2
View File
@@ -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, "topic_title": topic.title if topic else None,
"platform": article.platform, "platform": article.platform,
"file_path": article.file_path, "file_path": article.file_path,
"title": article.title,
"content": article.content,
"status": article.status, "status": article.status,
"compliance_score": article.compliance_score, "compliance_score": article.compliance_score,
"word_count": article.word_count, "word_count": article.word_count,
+23 -5
View File
@@ -10,7 +10,7 @@ from typing import List, Optional
from ..database import get_db from ..database import get_db
from ..models import MediaAsset from ..models import MediaAsset
from ..schemas import MediaAssetCreate, MediaAssetUpdate, MediaAssetResponse 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"]) router = APIRouter(prefix="/api/assets", tags=["assets"])
@@ -33,6 +33,9 @@ def list_assets(
current_user=Depends(get_current_user) current_user=Depends(get_current_user)
): ):
query = db.query(MediaAsset) query = db.query(MediaAsset)
of = org_filter(current_user, MediaAsset)
if of is not True:
query = query.filter(of)
if file_type: if file_type:
query = query.filter(MediaAsset.file_type == file_type) query = query.filter(MediaAsset.file_type == file_type)
@@ -54,7 +57,11 @@ def list_tags(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(get_current_user) 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() all_tags = set()
for a in assets: for a in assets:
if a[0]: if a[0]:
@@ -67,9 +74,13 @@ def get_counts(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(get_current_user) 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 = {} 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: for ftype, cnt in rows:
by_type[ftype] = cnt by_type[ftype] = cnt
return {"total": total, "by_type": by_type} return {"total": total, "by_type": by_type}
@@ -120,7 +131,8 @@ async def upload_asset(
alt_text=alt_text, alt_text=alt_text,
tags=parsed_tags, tags=parsed_tags,
topic_ids=parsed_topic_ids, 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.add(asset)
db.commit() db.commit()
@@ -138,6 +150,8 @@ def update_asset(
asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first() asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first()
if not asset: if not asset:
raise HTTPException(status_code=404, detail="素材不存在") 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(): for k, v in data.model_dump(exclude_unset=True).items():
setattr(asset, k, v) setattr(asset, k, v)
@@ -155,6 +169,8 @@ def delete_asset(
asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first() asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first()
if not asset: if not asset:
raise HTTPException(status_code=404, detail="素材不存在") 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): if os.path.exists(asset.file_path):
try: try:
@@ -176,6 +192,8 @@ def increment_usage(
asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first() asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first()
if not asset: if not asset:
raise HTTPException(status_code=404, detail="素材不存在") 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 asset.usage_count = (asset.usage_count or 0) + 1
db.commit() db.commit()
return {"ok": True, "usage_count": asset.usage_count} return {"ok": True, "usage_count": asset.usage_count}
+14 -6
View File
@@ -15,12 +15,15 @@ def list_topics(
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
status: Optional[str] = None, status: Optional[str] = None,
field: Optional[str] = None field: Optional[str] = None,
org_id: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""获取选题列表,支持分页和筛选""" """获取选题列表,支持分页和筛选"""
db = SessionLocal() db = SessionLocal()
try: try:
query = db.query(Topic) query = db.query(Topic)
if org_id:
query = query.filter(Topic.org_id == org_id)
if status: if status:
query = query.filter(Topic.status == status) query = query.filter(Topic.status == status)
if field: if field:
@@ -51,11 +54,14 @@ def list_topics(
db.close() 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() db = SessionLocal()
try: 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: if not t:
return {"error": f"选题 {topic_id} 不存在"} return {"error": f"选题 {topic_id} 不存在"}
return { return {
@@ -191,19 +197,21 @@ def list_system_configs() -> List[Dict[str, Any]]:
ACTION_REGISTRY = { ACTION_REGISTRY = {
"list_topics": { "list_topics": {
"func": list_topics, "func": list_topics,
"description": "获取选题列表,支持分页(page, page_size)和筛选(status, field)", "description": "获取选题列表,支持分页(page, page_size)和筛选(status, field, org_id)",
"params_schema": { "params_schema": {
"page": {"type": "integer", "default": 1, "desc": "页码"}, "page": {"type": "integer", "default": 1, "desc": "页码"},
"page_size": {"type": "integer", "default": 20, "desc": "每页条数"}, "page_size": {"type": "integer", "default": 20, "desc": "每页条数"},
"status": {"type": "string", "enum": ["pending", "review", "draft", "ready", "published"], "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": { "get_topic": {
"func": get_topic, "func": get_topic,
"description": "获取单个选题详情", "description": "获取单个选题详情",
"params_schema": { "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": { "get_recent_task_logs": {
+9
View File
@@ -120,6 +120,11 @@ def update_entry(
if not entry: if not entry:
raise HTTPException(status_code=404, detail="日历条目不存在") 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(): for k, v in data.model_dump(exclude_unset=True).items():
setattr(entry, k, v) setattr(entry, k, v)
db.commit() db.commit()
@@ -147,6 +152,10 @@ def delete_entry(
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first() entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
if not entry: if not entry:
raise HTTPException(status_code=404, detail="日历条目不存在") 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.delete(entry)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
+11
View File
@@ -186,6 +186,13 @@ def create_metric(
return 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) @router.put("/entries/{metric_id}", response_model=ContentMetricsResponse)
def update_metric( def update_metric(
metric_id: int, metric_id: int,
@@ -196,6 +203,7 @@ def update_metric(
metric = db.query(ContentMetrics).filter(ContentMetrics.id == metric_id).first() metric = db.query(ContentMetrics).filter(ContentMetrics.id == metric_id).first()
if not metric: if not metric:
raise HTTPException(status_code=404, detail="数据记录不存在") raise HTTPException(status_code=404, detail="数据记录不存在")
_check_metric_org(metric, current_user, db)
for k, v in data.model_dump(exclude_unset=True).items(): for k, v in data.model_dump(exclude_unset=True).items():
setattr(metric, k, v) setattr(metric, k, v)
@@ -214,6 +222,7 @@ def delete_metric(
metric = db.query(ContentMetrics).filter(ContentMetrics.id == metric_id).first() metric = db.query(ContentMetrics).filter(ContentMetrics.id == metric_id).first()
if not metric: if not metric:
raise HTTPException(status_code=404, detail="数据记录不存在") raise HTTPException(status_code=404, detail="数据记录不存在")
_check_metric_org(metric, current_user, db)
db.delete(metric) db.delete(metric)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -357,6 +366,8 @@ def fetch_zhihu_metrics(
topic = db.query(Topic).filter(Topic.id == data.topic_id).first() topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
if not topic: if not topic:
raise HTTPException(status_code=404, detail="选题不存在") 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) post_id = _extract_zhihu_post_id(data.zhihu_url)
@@ -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,
}
}
+7 -5
View File
@@ -114,7 +114,7 @@ def _aggregate_status_counts(q):
return counts return counts
@router.get("/status") @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() total = db.query(Topic).count()
counts = _aggregate_status_counts(db.query(Topic)) counts = _aggregate_status_counts(db.query(Topic))
today = date.today() 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} return {"topics_count": total, "status_distribution": counts, "pipeline_modules": pipeline_status}
@router.post("/sync/run") @router.post("/sync/run")
def run_sync(): def run_sync(current_user=Depends(get_current_user)):
try: try:
sync_all_topics() sync_all_topics()
return {"message": "Sync completed (DB → JSON backup)"} return {"message": "Sync completed (DB → JSON backup)"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/optimize-sources/run") @router.post("/optimize-sources/run")
def trigger_optimize_sources(): def trigger_optimize_sources(current_user=Depends(get_current_user)):
try: try:
from ..core.scheduler import scheduler from ..core.scheduler import scheduler
def _bg(): def _bg():
@@ -265,8 +266,9 @@ def trigger_optimize_sources():
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/metrics-sync/run") @router.post("/metrics-sync/run")
def trigger_metrics_sync(): def trigger_metrics_sync(current_user=Depends(get_current_user)):
try: try:
from ..core.scheduler import scheduler from ..core.scheduler import scheduler
def _bg(): 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)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/refresh") @router.post("/refresh")
def refresh_all(): def refresh_all(current_user=Depends(get_current_user)):
try: try:
sync_all_topics() sync_all_topics()
return {"message": "Refresh completed"} return {"message": "Refresh completed"}
+23
View File
@@ -14,6 +14,16 @@ _creator_semaphore = threading.Semaphore(3)
router = APIRouter(prefix="/api/tasks", tags=["tasks"]) 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]) @router.get("", response_model=List[ContentTaskResponse])
def list_tasks( def list_tasks(
status: Optional[str] = None, status: Optional[str] = None,
@@ -94,6 +104,7 @@ def get_task(
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
_check_task_org(task, current_user, db)
return task return task
@@ -106,6 +117,7 @@ def start_task(
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
_check_task_org(task, current_user, db)
from datetime import datetime, timezone from datetime import datetime, timezone
task.status = "running" task.status = "running"
@@ -128,6 +140,7 @@ def update_progress(
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
_check_task_org(task, current_user, db)
task.progress = progress task.progress = progress
if message: if message:
@@ -148,6 +161,7 @@ def complete_task(
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
_check_task_org(task, current_user, db)
from datetime import datetime, timezone from datetime import datetime, timezone
finished = datetime.now(timezone.utc) finished = datetime.now(timezone.utc)
@@ -175,6 +189,7 @@ def fail_task(
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
_check_task_org(task, current_user, db)
from datetime import datetime, timezone from datetime import datetime, timezone
finished = datetime.now(timezone.utc) finished = datetime.now(timezone.utc)
@@ -197,6 +212,7 @@ def cancel_task(
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first() task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
_check_task_org(task, current_user, db)
task.status = "cancelled" task.status = "cancelled"
db.commit() db.commit()
@@ -406,6 +422,13 @@ def run_creator_task(
): ):
from datetime import datetime, timezone 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) now = datetime.now(timezone.utc)
task_id = f"task_{uuid.uuid4().hex[:16]}" task_id = f"task_{uuid.uuid4().hex[:16]}"
+63 -2
View File
@@ -9,7 +9,9 @@ from ..database import get_db
from ..models import Topic, TopicField, TopicConfigField, Article, ContentMetrics from ..models import Topic, TopicField, TopicConfigField, Article, ContentMetrics
from ..schemas import ( from ..schemas import (
TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest, TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest,
TopicAnalyzeRequest, TopicAnalyzeResponse,
) )
from ..core.nvidia_client import call_llm
from .auth import get_current_user, org_filter from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)]) 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) @router.post("", response_model=TopicResponse)
def create_topic( def create_topic(
data: TopicCreate, data: TopicCreate,
@@ -111,7 +172,7 @@ def create_topic(
else: else:
topic_id = f"T{datetime.now().strftime('%m%d%H%M')}" topic_id = f"T{datetime.now().strftime('%m%d%H%M')}"
field_name = None field_name = "未分类"
if data.field_id: if data.field_id:
field = db.query(TopicField).filter(TopicField.id == data.field_id).first() field = db.query(TopicField).filter(TopicField.id == data.field_id).first()
if field: if field:
@@ -164,7 +225,7 @@ def update_topic(
topic.field_id = data.field_id topic.field_id = data.field_id
if data.field_id: if data.field_id:
field = db.query(TopicField).filter(TopicField.id == data.field_id).first() 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(): 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": if k == "tags" or k == "custom_data" or k == "scoring_data":
+37
View File
@@ -56,6 +56,7 @@ MODULES = {
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00"}, "scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00"},
"scheduled_reset_search_usage": {"name": "🔁 搜索用量重置", "cron": "00:05"}, "scheduled_reset_search_usage": {"name": "🔁 搜索用量重置", "cron": "00:05"},
"scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"}, "scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"},
"scheduled_rank_tracker": {"name": "🔍 搜索排名追踪", "cron": "07:00"},
} }
LOG_FILE_MAP = { LOG_FILE_MAP = {
@@ -67,6 +68,7 @@ LOG_FILE_MAP = {
"scheduled_metrics_sync": "metrics_sync", "scheduled_metrics_sync": "metrics_sync",
"scheduled_reset_search_usage": "reset_search_usage", "scheduled_reset_search_usage": "reset_search_usage",
"scheduled_task_monitor": "task_monitor", "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): 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_optimize_sources", self._run_optimize_sources, "信息源优化"),
("scheduled_metrics_sync", self._run_metrics_sync, "指标同步"), ("scheduled_metrics_sync", self._run_metrics_sync, "指标同步"),
("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"), ("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"),
("scheduled_rank_tracker", self._run_rank_tracker, "搜索排名追踪"),
] ]
for module_id, fn, name in MODULE_JOBS: for module_id, fn, name in MODULE_JOBS:
@@ -585,6 +588,40 @@ class TaskScheduler:
started_at=started, finished_at=datetime.now(timezone.utc)) started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[TaskMonitor] 监控检查失败: %s", e) 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): def get_jobs(self):
"""返回当前所有定时任务的状态""" """返回当前所有定时任务的状态"""
jobs = [] jobs = []
+3
View File
@@ -56,7 +56,10 @@ def init_db():
conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'")) conn.execute(text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS images TEXT DEFAULT '{}'"))
for table, col, typ in [ for table, col, typ in [
("users", "org_id", "VARCHAR DEFAULT 'default'"), ("users", "org_id", "VARCHAR DEFAULT 'default'"),
("articles", "title", "VARCHAR"),
("articles", "content", "TEXT"),
("articles", "updated_at", "TIMESTAMP"), ("articles", "updated_at", "TIMESTAMP"),
("media_assets", "org_id", "VARCHAR DEFAULT 'default'"),
("topics", "org_id", "VARCHAR DEFAULT 'default'"), ("topics", "org_id", "VARCHAR DEFAULT 'default'"),
("topics", "reviewed_at", "TIMESTAMP"), ("topics", "reviewed_at", "TIMESTAMP"),
("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"), ("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"),
+2 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from .database import engine, get_db, init_db from .database import engine, get_db, init_db
from .models import Base 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 .initial_data import import_initial_data
from .core.scheduler import scheduler 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.router)
app.include_router(menu_configs.public_router) app.include_router(menu_configs.public_router)
app.include_router(search_providers.router) app.include_router(search_providers.router)
app.include_router(search_rankings.router)
# 挂载自动生成的图片(必须先于前端根挂载) # 挂载自动生成的图片(必须先于前端根挂载)
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
+35
View File
@@ -282,6 +282,8 @@ class Article(Base):
topic_id = Column(String, ForeignKey("topics.id"), nullable=False) topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
platform = Column(String, nullable=False) platform = Column(String, nullable=False)
file_path = 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") status = Column(String, default="draft")
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
compliance_score = Column(Integer) compliance_score = Column(Integer)
@@ -410,6 +412,7 @@ class MediaAsset(Base):
topic_ids = Column(JSON, default=list) topic_ids = Column(JSON, default=list)
usage_count = Column(Integer, default=0) usage_count = Column(Integer, default=0)
uploaded_by = Column(String, nullable=True) uploaded_by = Column(String, nullable=True)
org_id = Column(String, default="default", nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -854,3 +857,35 @@ class CollectorSource(Base):
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_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,
}
+19
View File
@@ -92,6 +92,23 @@ class TopicCreate(BaseModel):
scoring_data: Dict[str, Any] = {} 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): class TopicUpdate(BaseModel):
field_id: Optional[int] = None field_id: Optional[int] = None
title: Optional[str] = None title: Optional[str] = None
@@ -131,6 +148,8 @@ class ArticleBase(BaseModel):
topic_id: str topic_id: str
platform: str platform: str
file_path: str file_path: str
title: Optional[str] = None
content: Optional[str] = None
status: str = "draft" status: str = "draft"
compliance_score: Optional[int] = None compliance_score: Optional[int] = None
word_count: Optional[int] = None word_count: Optional[int] = None
+66 -2
View File
@@ -32,6 +32,7 @@
<el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button> <el-button size="default" :type="activeTab === 'logs' ? 'primary' : ''" @click="switchTab('logs')">运行日志</el-button>
<el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button> <el-button size="default" :type="activeTab === 'assistant' ? 'primary' : ''" @click="switchTab('assistant')">AI 助手</el-button>
<el-button size="default" :type="activeTab === 'searchproviders' ? 'primary' : ''" @click="switchTab('searchproviders')">搜索API</el-button> <el-button size="default" :type="activeTab === 'searchproviders' ? 'primary' : ''" @click="switchTab('searchproviders')">搜索API</el-button>
<el-button size="default" :type="activeTab === 'searchrankings' ? 'primary' : ''" @click="switchTab('searchrankings')">搜索排名</el-button>
<el-button size="default" :type="activeTab === 'configitems' ? 'primary' : ''" @click="switchTab('configitems')">配置管理</el-button> <el-button size="default" :type="activeTab === 'configitems' ? 'primary' : ''" @click="switchTab('configitems')">配置管理</el-button>
</div> </div>
</div> </div>
@@ -607,6 +608,53 @@
<template #footer><el-button @click="searchProviderTestVisible = false">关闭</el-button></template> <template #footer><el-button @click="searchProviderTestVisible = false">关闭</el-button></template>
</el-dialog> </el-dialog>
<div v-if="activeTab === 'searchrankings'">
<div class="toolbar">
<span style="font-size:13px;color:#909399;">GEO/SEO 搜索排名追踪 — 每日 07:00 自动检查</span>
</div>
<div v-if="rankingsLoading" class="card-loading">加载中...</div>
<template v-else>
<div style="display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;">
<div class="stat-card" style="flex:1;min-width:140px;padding:16px;background:#f0f9ff;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#409eff;">{{ rankingStats.total_checks }}</div>
<div style="font-size:12px;color:#909399;margin-top:4px;">累计检查关键词</div>
</div>
<div class="stat-card" style="flex:1;min-width:140px;padding:16px;background:#f0fdf4;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#67c23a;">{{ rankingStats.on_page }}</div>
<div style="font-size:12px;color:#909399;margin-top:4px;">有排名关键词</div>
</div>
<div class="stat-card" style="flex:1;min-width:140px;padding:16px;background:#fef3f2;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#f56c6c;">{{ rankingStats.ai_cited }}</div>
<div style="font-size:12px;color:#909399;margin-top:4px;">AI 搜索引用</div>
</div>
<div class="stat-card" style="flex:1;min-width:140px;padding:16px;background:#fff7e6;border-radius:8px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#e6a23c;">{{ rankingStats.best_position ? '#' + rankingStats.best_position : '-' }}</div>
<div style="font-size:12px;color:#909399;margin-top:4px;">最佳排名</div>
</div>
</div>
<el-table :data="rankings" style="width:100%" size="small" max-height="500">
<el-table-column prop="keyword" label="关键词" min-width="140" />
<el-table-column prop="platform" label="平台" width="80" />
<el-table-column prop="search_engine" label="引擎" width="70" />
<el-table-column prop="position" label="排名" width="70">
<template #default="{ row }">
<el-tag :type="row.position && row.position <= 3 ? 'success' : row.position ? 'warning' : 'info'" size="small">
{{ row.position ? '#' + row.position : '未上榜' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ai_cited" label="AI引用" width="80">
<template #default="{ row }">
<el-tag :type="row.ai_cited ? 'success' : 'info'" size="small">{{ row.ai_cited ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="url_found" label="匹配 URL" min-width="200" show-overflow-tooltip />
<el-table-column prop="checked_at" label="检查时间" width="160" />
</el-table>
</template>
</div>
<div v-if="activeTab === 'configitems'"> <div v-if="activeTab === 'configitems'">
<div class="filter-bar"> <div class="filter-bar">
<el-button size="default" :type="configSubTab === 'sensitive' ? 'primary' : ''" @click="switchConfigSubTab('sensitive')">🔒 敏感词</el-button> <el-button size="default" :type="configSubTab === 'sensitive' ? 'primary' : ''" @click="switchConfigSubTab('sensitive')">🔒 敏感词</el-button>
@@ -634,7 +682,7 @@
<el-tag size="small">{{ row.category || '未分类' }}</el-tag> <el-tag size="small">{{ row.category || '未分类' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="150">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="editSensitiveWord(row)">编辑</el-button> <el-button size="small" @click="editSensitiveWord(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSensitiveWord(row)">删除</el-button> <el-button size="small" type="danger" @click="deleteSensitiveWord(row)">删除</el-button>
@@ -682,7 +730,7 @@
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '启用' : '停用' }}</el-tag> <el-tag :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '启用' : '停用' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="150">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="editCleanRule(row)">编辑</el-button> <el-button size="small" @click="editCleanRule(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCleanRule(row)">删除</el-button> <el-button size="small" type="danger" @click="deleteCleanRule(row)">删除</el-button>
@@ -1270,6 +1318,20 @@ const llmConfigs = ref([]);
return Object.entries(map).sort((a, b) => a[0].localeCompare(b[0])); 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 contentCleanRules = ref([]);
const ccrLoading = ref(false); const ccrLoading = ref(false);
const ccrDialogVisible = ref(false); const ccrDialogVisible = ref(false);
@@ -1319,6 +1381,7 @@ const llmConfigs = ref([]);
users: fetchUsers, users: fetchUsers,
logs: loadLogTypes, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig, logs: loadLogTypes, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig,
searchproviders: loadSearchProviders, searchproviders: loadSearchProviders,
searchrankings: loadRankings,
configitems: () => { loadSensitiveWords(); loadContentCleanRules(); }, configitems: () => { loadSensitiveWords(); loadContentCleanRules(); },
}; };
const loadedTabs = new Set([]); const loadedTabs = new Set([]);
@@ -1378,6 +1441,7 @@ const llmConfigs = ref([]);
sensitiveWords, swLoading, swDialogVisible, swSaving, swForm, editingSwId, loadSensitiveWords, showAddSensitiveWord, editSensitiveWord, saveSensitiveWord, deleteSensitiveWord, configSubTab, switchConfigSubTab, sensitiveWords, swLoading, swDialogVisible, swSaving, swForm, editingSwId, loadSensitiveWords, showAddSensitiveWord, editSensitiveWord, saveSensitiveWord, deleteSensitiveWord, configSubTab, switchConfigSubTab,
contentCleanRules, ccrLoading, ccrDialogVisible, ccrDialogTitle, ccrSaving, ccrForm, editingCcrId, contentCleanRules, ccrLoading, ccrDialogVisible, ccrDialogTitle, ccrSaving, ccrForm, editingCcrId,
loadContentCleanRules, showAddCleanRule, editCleanRule, saveCleanRule, deleteCleanRule, loadContentCleanRules, showAddCleanRule, editCleanRule, saveCleanRule, deleteCleanRule,
rankings, rankingStats, rankingsLoading, loadRankings,
}; };
} }
}); });
+2 -2
View File
@@ -124,8 +124,8 @@
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button> <el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
</div> </div>
</div> </div>
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;"> <div style="max-width: 1000px; margin: 0 auto; width: 100%; flex: 1; min-height: 0; display: flex; flex-direction: column;">
<iframe v-if="previewArticleData.html_content" :srcdoc="previewArticleData.html_content" class="preview-iframe" style="flex:1; min-height:500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding:0; width:100%;" sandbox></iframe> <iframe v-if="previewArticleData.html_content" :srcdoc="previewArticleData.html_content" class="preview-iframe" style="flex:1; border:1px solid #ebeef5; border-radius:8px; background:#fff; padding:0;" sandbox></iframe>
<div v-else style="padding:60px 20px; text-align:center; color:#909399; font-size:14px;">该文章暂无 HTML 内容</div> <div v-else style="padding:60px 20px; text-align:center; color:#909399; font-size:14px;">该文章暂无 HTML 内容</div>
</div> </div>
</div> </div>
+4 -4
View File
@@ -363,13 +363,13 @@ body {
.login-footer a:hover { color: var(--color-accent); } .login-footer a:hover { color: var(--color-accent); }
/* ========== Preview Dialog ========== */ /* ========== Preview Dialog ========== */
.preview-iframe { box-sizing: border-box; } .preview-iframe { box-sizing: border-box; height: 100%; width: 100%; }
.preview-dialog-custom.el-dialog { .preview-dialog-custom.el-dialog {
max-height: calc(100vh - 90px); overflow: hidden; display: flex; max-height: calc(100vh - 90px); overflow: hidden; display: flex;
flex-direction: column; margin-top: 0 !important; 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__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.el-dialog .el-dialog__footer { flex-shrink: 0; padding: var(--spacing-sm) var(--spacing-md); }
.preview-dialog-custom.is-fullscreen { z-index: 100001 !important; } .preview-dialog-custom.is-fullscreen { z-index: 100001 !important; }
body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index: 100000 !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-button--default { padding: 10px 16px !important; }
.el-input__inner { min-height: 38px; } .el-input__inner { min-height: 38px; }
.preview-iframe { max-height: calc(100vh - 250px) !important; } .preview-iframe { min-height: 300px; }
.preview-dialog-custom { position: relative; } .preview-dialog-custom { position: relative; }
} }
@media (min-width: 769px) { @media (min-width: 769px) {
.preview-iframe { max-height: calc(100vh - 100px) !important; } .preview-iframe { min-height: 400px; }
.preview-dialog-custom { position: relative; left: 90px; } .preview-dialog-custom { position: relative; left: 90px; }
} }
+160 -9
View File
@@ -8,8 +8,8 @@
<link rel="stylesheet" href="theme-modern.css"> <link rel="stylesheet" href="theme-modern.css">
<style> <style>
.selected-count { color: var(--color-text-secondary); font-size: var(--font-size-body); margin-left: auto; } .selected-count { color: var(--color-text-secondary); font-size: var(--font-size-body); margin-left: auto; }
.preview-dialog-custom .el-dialog__body { overflow-y: auto; max-height: calc(90vh - 120px); min-height: 400px; padding: 16px 20px; } .preview-dialog-custom.el-dialog .el-dialog__body { overflow-y: auto; min-height: 400px; padding: 16px 20px; }
.preview-dialog-custom .preview-body { max-height: calc(90vh - 180px); min-height: 300px; overflow: auto; } .preview-dialog-custom .preview-body { min-height: 300px; overflow: auto; }
.preview-dialog-custom .preview-body img { max-width: 100%; } .preview-dialog-custom .preview-body img { max-width: 100%; }
.topic-card-list { display: none; } .topic-card-list { display: none; }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -41,10 +41,11 @@
<div class="page-header"> <div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 选题管理</h2> <h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 选题管理</h2>
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" size="small" @click="refreshAll"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 批量刷</el-button> <el-button type="primary" size="small" @click="openCreateDialog"><el-icon style="vertical-align:-2px;"><IconPlus /></el-icon>增选题</el-button>
<el-button size="small" @click="refreshAll"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 批量刷新</el-button>
<el-button size="small" @click="toggleSelectAll">{{ selectAllLabel }}</el-button> <el-button size="small" @click="toggleSelectAll">{{ selectAllLabel }}</el-button>
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconPlus /></el-icon> 批量创作</el-button> <el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0"> 批量创作</el-button>
<el-button type="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> 批量审查</el-button> <el-button type="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0"> 批量审查</el-button>
<span v-if="selectedTopicIds.length > 0" class="selected-count">已选 {{ selectedTopicIds.length }} 项</span> <span v-if="selectedTopicIds.length > 0" class="selected-count">已选 {{ selectedTopicIds.length }} 项</span>
</div> </div>
</div> </div>
@@ -162,6 +163,82 @@
</div> </div>
</main> </main>
</div> </div>
<el-dialog v-model="createDialogVisible" :title="createStep === 1 ? '新增选题' : 'AI 分析结果'" width="680px" :close-on-click-modal="false" @close="resetCreateDialog">
<template v-if="createStep === 1">
<el-form label-position="top">
<el-form-item label="选题思路 / 内容描述">
<el-input type="textarea" :rows="5" v-model="createForm.raw_input" placeholder="描述你想写的选题方向,可以是大致想法、关键词、或者一句话需求&#10;例如:想写一篇关于旧物换补贴的实操指南,结合最近的碳普惠政策" />
</el-form-item>
<el-form-item label="参考链接(可选,每行一个)">
<el-input type="textarea" :rows="3" v-model="createForm.reference_links" placeholder="https://...&#10;https://..." />
</el-form-item>
<el-form-item label="所属领域(可选)">
<el-select v-model="createForm.field_id" placeholder="选择领域" clearable style="width:100%;">
<el-option v-for="f in fieldOptions" :key="f.id" :label="f.name" :value="f.id" />
</el-select>
</el-form-item>
</el-form>
</template>
<template v-else>
<el-form label-position="top">
<el-form-item label="选题标题">
<el-input v-model="createResult.title" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="内容形式">
<el-select v-model="createResult.format" style="width:100%;">
<el-option label="趋势洞察" value="趋势洞察" />
<el-option label="实操指南" value="实操指南" />
<el-option label="对比分析" value="对比分析" />
<el-option label="案例解读" value="案例解读" />
<el-option label="观点讨论" value="观点讨论" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="优先级">
<el-select v-model="createResult.priority" style="width:100%;">
<el-option label="高" value="高" />
<el-option label="中" value="中" />
<el-option label="低" value="低" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="核心概念">
<el-input v-model="createResult.core_concept" />
</el-form-item>
<el-form-item label="受众痛点">
<el-input v-model="createResult.audience_pain" />
</el-form-item>
<el-form-item label="独特角度">
<el-input v-model="createResult.unique_angle" />
</el-form-item>
<el-form-item label="标签(回车添加)">
<el-select v-model="createResult.tags" multiple filterable allow-create default-first-option style="width:100%;" placeholder="输入标签后回车添加">
<el-option v-for="t in createResult.tags" :key="t" :label="t" :value="t" />
</el-select>
</el-form-item>
</el-form>
</template>
<template #footer>
<div style="display:flex; justify-content:space-between; width:100%;">
<div>
<el-button v-if="createStep === 2" size="small" @click="createStep = 1">← 返回编辑输入</el-button>
<el-button v-else @click="createDialogVisible = false">取消</el-button>
</div>
<div v-if="createStep === 1">
<el-button type="primary" @click="runAiAnalysis" :loading="analyzing">AI 智能分析</el-button>
</div>
<div v-else>
<el-button @click="saveCreatedTopic('list')">仅保存到选题列表</el-button>
<el-button type="success" @click="saveCreatedTopic('create')" :loading="saving">保存并创作</el-button>
<el-button type="warning" @click="saveCreatedTopic('review')" :loading="saving">保存并审查</el-button>
</div>
</div>
</template>
</el-dialog>
<el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false"> <el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false">
<div v-if="publishTopic"> <div v-if="publishTopic">
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">
@@ -253,10 +330,14 @@ const TopicsApp = {
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true }, publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
publishing: false, publishing: false,
savingContent: false, savingContent: false,
currentPage: 1, pageSize: 10, currentPage: 1, pageSize: 10,
sortField: 'created_at', sortOrder: 'ascending', sortField: 'created_at', sortOrder: 'ascending',
showSearch: false, showSearch: false,
searchForm: { id: '', title: '', field: '', createdStart: null, createdEnd: null, generatedStart: null, generatedEnd: null, reviewedStart: null, reviewedEnd: null, publishedStart: null, publishedEnd: null } searchForm: { id: '', title: '', field: '', createdStart: null, createdEnd: null, generatedStart: null, generatedEnd: null, reviewedStart: null, reviewedEnd: null, publishedStart: null, publishedEnd: null },
createDialogVisible: false, createStep: 1, analyzing: false, saving: false,
fieldOptions: [],
createForm: { raw_input: '', reference_links: '', field_id: null },
createResult: { title: '', format: '趋势洞察', core_concept: '', audience_pain: '', unique_angle: '', tags: [], priority: '中' }
} }
}, },
computed: { computed: {
@@ -558,6 +639,76 @@ const TopicsApp = {
this.$message.error('删除失败: ' + msg); this.$message.error('删除失败: ' + msg);
} }
}, },
async openCreateDialog() {
this.createDialogVisible = true;
this.createStep = 1;
this.createForm = { raw_input: '', reference_links: '', field_id: null };
this.createResult = { title: '', format: '趋势洞察', core_concept: '', audience_pain: '', unique_angle: '', tags: [], priority: '中' };
try {
this.fieldOptions = await this.api('/api/topic-config/fields') || [];
} catch (e) { this.fieldOptions = []; }
},
resetCreateDialog() {
this.createStep = 1; this.analyzing = false; this.saving = false;
},
async runAiAnalysis() {
if (!this.createForm.raw_input.trim()) { this.$message.warning('请输入选题思路'); return; }
this.analyzing = true;
try {
const refs = this.createForm.reference_links.split('\n').map(s => s.trim()).filter(Boolean);
const data = await this.api('/api/topics/analyze', {
method: 'POST',
body: JSON.stringify({
raw_input: this.createForm.raw_input,
reference_links: refs,
field_id: this.createForm.field_id || null
})
});
this.createResult.title = data.title || '';
this.createResult.format = data.format || '趋势洞察';
this.createResult.core_concept = data.core_concept || '';
this.createResult.audience_pain = data.audience_pain || '';
this.createResult.unique_angle = data.unique_angle || '';
this.createResult.tags = data.tags || [];
this.createResult.priority = data.priority || '中';
this.createStep = 2;
this.$message.success('AI 分析完成,请确认选题信息');
} catch (e) { this.$message.error('AI 分析失败: ' + (e.message || '未知错误')); }
finally { this.analyzing = false; }
},
async saveCreatedTopic(action) {
this.saving = true;
try {
const body = {
title: this.createResult.title,
field_id: this.createForm.field_id || null,
format: this.createResult.format,
core_concept: this.createResult.core_concept,
audience_pain: this.createResult.audience_pain,
unique_angle: this.createResult.unique_angle,
tags: this.createResult.tags,
priority: this.createResult.priority,
};
const topic = await this.api('/api/topics', { method: 'POST', body: JSON.stringify(body) });
const topicId = topic.id;
this.createDialogVisible = false;
this.$message.success(`选题 ${topicId} 创建成功`);
await this.fetchTopics();
if (action === 'create') {
try {
await this.api('/api/tasks/run-creator?topic_id=' + topicId, { method: 'POST' });
this.$message.success(`创作任务已启动: ${topic.title}`);
} catch (e) { this.$message.error(`创作启动失败: ${e.message}`); }
} else if (action === 'review') {
try {
await this.api('/api/system/review/run', { method: 'POST', body: JSON.stringify({ topic_ids: [topicId] }) });
this.$message.success(`审查已启动: ${topic.title}`);
} catch (e) { this.$message.error(`审查启动失败: ${e.message}`); }
}
await this.fetchTodayCount();
} catch (e) { this.$message.error('保存选题失败: ' + (e.message || '未知错误')); }
finally { this.saving = false; }
},
switchPlatform(platform) { switchPlatform(platform) {
if (this.editing) this.cancelEdit(); if (this.editing) this.cancelEdit();
this.previewPlatform = platform; this.previewPlatform = platform;
+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: if not HAVE_LLM:
return html, None return html, None
prompt_key = "compliance_fix" if remaining_issues else "compliance_polish"
for attempt in range(2): for attempt in range(2):
try: try:
temperature = 0.5 temperature = 0.5
max_tokens = 4000 params = get_prompt_params(prompt_key)
max_tokens = params.get("max_tokens", 8000)
system_prompt = "你是一个专业的内容合规与优化助手,擅长在保持文章质量和可读性的前提下修复合规问题。" system_prompt = "你是一个专业的内容合规与优化助手,擅长在保持文章质量和可读性的前提下修复合规问题。"
if remaining_issues: if remaining_issues:
issues_desc = "\n".join( issues_desc = "\n".join(
f"- [{i['type']}] {i.get('category','')}: {i.get('detail','')}" f"- [{i['type']}] {i.get('category','')}: {i.get('detail','')}"
for i in remaining_issues 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: 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 = call_llm(prompt, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt)
polished = clean_html_content(polished) polished = clean_html_content(polished)
polished = strip_ai_preface(polished) polished = strip_ai_preface(polished)
polished = strip_thinking_html(polished) polished = strip_thinking_html(polished)
if '<h2' in polished or '<p>' in 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 "常规润色" tag = "针对性修复" if remaining_issues else "常规润色"
return polished, f"LLM {tag}" return polished, f"LLM {tag}"
logger.warning(f"LLM 优化输出过短,保留原文 (len={len(polished)})") 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: finally:
db.close() 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 表""" """保存/更新文章到 articles 表"""
close_db = False close_db = False
if db is None: 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() now = datetime.now()
if existing: if existing:
existing.html_content = html_content existing.html_content = html_content
if title:
existing.title = title
if content:
existing.content = content
else: else:
article = Article( article = Article(
id=article_id, id=article_id,
topic_id=topic_id, topic_id=topic_id,
platform=platform, platform=platform,
file_path=f"db:{article_id}", file_path=f"db:{article_id}",
title=title,
content=content,
html_content=html_content, html_content=html_content,
status="draft", status="draft",
compliance_score=None compliance_score=None
+18 -1
View File
@@ -3,7 +3,7 @@
大纲阶段:基于选题和研究笔记,用 LLM 动态生成结构化大纲 大纲阶段:基于选题和研究笔记,用 LLM 动态生成结构化大纲
""" """
import json, datetime, logging, sys import json, datetime, logging, sys, re
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
@@ -43,6 +43,21 @@ class Outliner:
if not topic: raise ValueError(f"Topic {self.topic_id} not found") if not topic: raise ValueError(f"Topic {self.topic_id} not found")
return topic 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: def generate_outline(self) -> str:
title = self.topic['title'] title = self.topic['title']
field = self.topic.get('field', '') field = self.topic.get('field', '')
@@ -50,6 +65,7 @@ class Outliner:
pain = self.topic.get('audience_pain', '') pain = self.topic.get('audience_pain', '')
angle = self.topic.get('unique_angle', '') angle = self.topic.get('unique_angle', '')
cases_summary = self.research_notes[:2000] if self.research_notes else "暂无研究笔记" cases_summary = self.research_notes[:2000] if self.research_notes else "暂无研究笔记"
seo_keywords = self._extract_seo_keywords(self.research_notes)
if HAVE_LLM: if HAVE_LLM:
_now = datetime.datetime.now() _now = datetime.datetime.now()
@@ -62,6 +78,7 @@ class Outliner:
pain=pain, pain=pain,
angle=angle, angle=angle,
cases_summary=cases_summary, cases_summary=cases_summary,
seo_keywords=seo_keywords,
) )
try: try:
params = get_prompt_params("outline_generation") 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"], "variables": ["now", "year", "title", "field", "core", "pain", "angle", "search_section", "n", "cases_text"],
}, },
"outline_generation": { "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, "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": { "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内容,不要其他文字说明。", "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, "temperature": 0.5, "max_tokens": 3000,
"variables": ["n", "cat_names", "n2", "src_summary", "year"], "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": { "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直接输出标签,空格分隔。不要输出思考过程和其他文字。", "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, "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 import json, datetime, logging, sys, re
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List, Optional
PROJECT_ROOT = Path(__file__).parent.parent PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
@@ -69,6 +69,90 @@ def _load_platform_config() -> dict:
PLATFORM_CONFIG = _load_platform_config() 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: class Writer:
def __init__(self, topic_id: str): def __init__(self, topic_id: str):
self.topic_id = topic_id self.topic_id = topic_id
@@ -443,11 +527,12 @@ class Writer:
else: else:
html = html.replace("<!-- TAGS -->", "") html = html.replace("<!-- TAGS -->", "")
html = inject_geo_metadata(html, title, adapted, platform, tags_html)
return 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: 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}") logger.info(f"文章写入数据库: {platform}_{self.topic_id}")
return f"db:{platform}_{self.topic_id}" return f"db:{platform}_{self.topic_id}"
except Exception as e: except Exception as e:
@@ -463,8 +548,9 @@ class Writer:
results = {} results = {}
for platform in ["zhihu", "wechat", "xiaohongshu"]: for platform in ["zhihu", "wechat", "xiaohongshu"]:
markdown = self.generate_platform_markdown(platform) markdown = self.generate_platform_markdown(platform)
title = self._optimize_title(platform)
html = self.generate_platform_html(markdown, 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() self.mark_draft()
logger.info(f"撰写完成,状态已更新为待审查") logger.info(f"撰写完成,状态已更新为待审查")
return {"ok": True, "files": results} return {"ok": True, "files": results}