feat: GEO/SEO structured data + search ranking tracker
This commit is contained in:
+55
-3
@@ -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` API;admin.html 新增"搜索排名"tab |
|
||||||
|
|
||||||
### ⏳ 待办
|
### ⏳ 待办
|
||||||
|
|
||||||
| 任务 | 优先级 | 备注 |
|
| 任务 | 优先级 | 备注 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
|
| 生成式 AI 引用检测(Perplexity/Bing AI/百度 AI 搜索) | 中 | rank_tracker 的 `ai_cited` 检测需具体实现 |
|
||||||
|
| 百度搜索排名接入(需百度站长 API) | 中 | 当前仅 Bing,国内百度覆盖更广 |
|
||||||
| admin.html 任务管理 tab(TaskConfig 参数编辑+TaskLog 历史时间轴) | 高 | 刚完成后端 DB 化,需完善前端 UI |
|
| admin.html 任务管理 tab(TaskConfig 参数编辑+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 排名列表) |
|
||||||
|
|
||||||
|
## 九、规范说明
|
||||||
|
|
||||||
### 规划文档
|
### 规划文档
|
||||||
|
|
||||||
|
|||||||
@@ -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="不能删除管理员用户")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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]}"
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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="描述你想写的选题方向,可以是大致想法、关键词、或者一句话需求 例如:想写一篇关于旧物换补贴的实操指南,结合最近的碳普惠政策" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="参考链接(可选,每行一个)">
|
||||||
|
<el-input type="textarea" :rows="3" v-model="createForm.reference_links" placeholder="https://... 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;">
|
||||||
@@ -256,7 +333,11 @@ const TopicsApp = {
|
|||||||
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;
|
||||||
|
|||||||
@@ -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)})")
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user