feat: 创作工作台统一 + AI味检测/白标 + 移动端补全 + 合规发布闭环
- 新增创作工作台 studio.html:合并选题/内容工厂/文章管理为单一 tab 入口(iframe embed 模式) - 新增 AI味检测模块(ai_slop API + 页面,合规软硬问题分级) - 新增白标品牌配置(branding API + 页面 + deploy 私有化交付包) - 发布闭环:publishing 放宽至 editor + records/mark-published 接口 - 移动端响应式补全(admin/calendar/ai-slop 表格卡片兜底) - 修复菜单幂等播种缺陷(按 path 对齐,避免功能页孤立) - 新增短视频脚本 shortvideo.py 与 2026 市场调研简报
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, Topic, Article
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/ai-slop", tags=["ai-slop"])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
SCRIPTS_DIR = PROJECT_ROOT / "scripts"
|
||||
if not SCRIPTS_DIR.exists():
|
||||
SCRIPTS_DIR = PROJECT_ROOT.parent / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
check_article = None
|
||||
polish_with_llm = None
|
||||
clean_html_content = None
|
||||
strip_ai_preface = None
|
||||
strip_thinking_html = None
|
||||
try:
|
||||
from compliance_checker import check_article
|
||||
except Exception:
|
||||
check_article = None
|
||||
try:
|
||||
from compliance_optimizer import polish_with_llm
|
||||
except Exception:
|
||||
polish_with_llm = None
|
||||
try:
|
||||
from content_cleaner import clean_html_content, strip_ai_preface, strip_thinking_html
|
||||
except Exception:
|
||||
clean_html_content = None
|
||||
strip_ai_preface = None
|
||||
strip_thinking_html = None
|
||||
|
||||
HARD_ISSUE_TYPES = ("敏感词", "法律法规", "平台规则", "品牌规范", "资源合规")
|
||||
|
||||
|
||||
class IssueItem(BaseModel):
|
||||
type: str
|
||||
category: str = ""
|
||||
detail: str = ""
|
||||
suggestion: str = ""
|
||||
severity: str = "medium"
|
||||
|
||||
|
||||
class PlatformReport(BaseModel):
|
||||
platform: str
|
||||
score: int
|
||||
passed: bool
|
||||
issues: List[IssueItem]
|
||||
html_preview: str = ""
|
||||
|
||||
|
||||
class ReportResponse(BaseModel):
|
||||
topic_id: str
|
||||
platforms: List[PlatformReport]
|
||||
|
||||
|
||||
class PurifyRequest(BaseModel):
|
||||
topic_id: str
|
||||
platform: str
|
||||
|
||||
|
||||
class PurifyResponse(BaseModel):
|
||||
ok: bool
|
||||
platform: str
|
||||
score_before: Optional[int] = None
|
||||
score_after: Optional[int] = None
|
||||
issues_before: List[IssueItem] = []
|
||||
issues_after: List[IssueItem] = []
|
||||
preview: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
def _normalize_issues(issues: list) -> List[IssueItem]:
|
||||
result = []
|
||||
for i in issues or []:
|
||||
itype = i.get("type", "")
|
||||
severity = "high" if itype in HARD_ISSUE_TYPES else "medium"
|
||||
detail = i.get("detail") or i.get("suggestion") or i.get("word") or i.get("tag") or i.get("pattern") or ""
|
||||
result.append(IssueItem(
|
||||
type=itype,
|
||||
category=i.get("category", ""),
|
||||
detail=detail,
|
||||
suggestion=i.get("suggestion", ""),
|
||||
severity=severity,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _extract_title(html: str) -> str:
|
||||
import re
|
||||
m = re.search(r"<title>\s*([^<]+?)\s*</title>", html, re.IGNORECASE)
|
||||
if not m:
|
||||
m = re.search(r"<h1[^>]*>\s*([^<]+?)\s*</h1>", html, re.IGNORECASE)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def _extract_content(html: str) -> str:
|
||||
import re
|
||||
text = re.sub(r"<style.*?</style>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r"<script.*?</script>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def _load_topic_data(db, topic_id: str) -> dict:
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
return {}
|
||||
return {
|
||||
"topic": {
|
||||
"title": getattr(topic, "title", "") or "",
|
||||
"field": getattr(topic, "field", "") or "",
|
||||
"core_concept": getattr(topic, "core_concept", "") or "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/report", response_model=ReportResponse)
|
||||
def get_report(topic_id: str, current_user: User = Depends(get_current_user), db=Depends(get_db)):
|
||||
if check_article is None:
|
||||
raise HTTPException(status_code=503, detail="合规检测模块不可用")
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
topic_data = _load_topic_data(db, topic_id)
|
||||
from db_helper import get_articles_by_topic
|
||||
articles = get_articles_by_topic(topic_id)
|
||||
platforms = []
|
||||
for art in articles:
|
||||
platform = art.get("platform")
|
||||
html = art.get("html_content") or ""
|
||||
if not html:
|
||||
continue
|
||||
res = check_article(html, platform, topic_data=topic_data)
|
||||
platforms.append(PlatformReport(
|
||||
platform=platform,
|
||||
score=res.get("score", 0),
|
||||
passed=res.get("passed", False),
|
||||
issues=_normalize_issues(res.get("issues", [])),
|
||||
html_preview=html[:600],
|
||||
))
|
||||
return ReportResponse(topic_id=topic_id, platforms=platforms)
|
||||
|
||||
|
||||
@router.post("/purify", response_model=PurifyResponse)
|
||||
def purify(req: PurifyRequest, current_user: User = Depends(get_current_user), db=Depends(get_db)):
|
||||
if check_article is None:
|
||||
raise HTTPException(status_code=503, detail="合规检测模块不可用")
|
||||
topic = db.query(Topic).filter(Topic.id == req.topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True and topic.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
from db_helper import get_articles_by_topic, save_article
|
||||
articles = get_articles_by_topic(req.topic_id)
|
||||
target = next((a for a in articles if a.get("platform") == req.platform), None)
|
||||
if not target or not target.get("html_content"):
|
||||
raise HTTPException(status_code=404, detail=f"未找到 {req.platform} 平台的文章")
|
||||
html = target["html_content"]
|
||||
topic_data = _load_topic_data(db, req.topic_id)
|
||||
|
||||
before = check_article(html, req.platform, topic_data=topic_data)
|
||||
issues_before = _normalize_issues(before.get("issues", []))
|
||||
raw_issues = before.get("issues", [])
|
||||
|
||||
polished_html, log_msg = (html, None)
|
||||
if polish_with_llm is not None:
|
||||
polished_html, log_msg = polish_with_llm(html, req.platform, remaining_issues=raw_issues)
|
||||
|
||||
cleaned = polished_html
|
||||
if clean_html_content is not None:
|
||||
cleaned = clean_html_content(cleaned)
|
||||
if strip_ai_preface is not None:
|
||||
cleaned = strip_ai_preface(cleaned)
|
||||
if strip_thinking_html is not None:
|
||||
cleaned = strip_thinking_html(cleaned)
|
||||
|
||||
title = _extract_title(cleaned)
|
||||
content = _extract_content(cleaned)
|
||||
save_article(req.topic_id, req.platform, cleaned, title=title, content=content, db=db)
|
||||
|
||||
after = check_article(cleaned, req.platform, topic_data=topic_data)
|
||||
issues_after = _normalize_issues(after.get("issues", []))
|
||||
|
||||
message = "净化完成" + (f"({log_msg})" if log_msg else "(仅执行清洗,未调用 LLM)")
|
||||
return PurifyResponse(
|
||||
ok=True,
|
||||
platform=req.platform,
|
||||
score_before=before.get("score"),
|
||||
score_after=after.get("score"),
|
||||
issues_before=issues_before,
|
||||
issues_after=issues_after,
|
||||
preview=cleaned[:600],
|
||||
message=message,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import SystemConfig
|
||||
from .auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/api/branding", tags=["branding"])
|
||||
|
||||
BRAND_KEYS = {
|
||||
"brand_name": "宇之然内容创作平台",
|
||||
"brand_logo": "",
|
||||
"brand_primary_color": "#2563eb",
|
||||
"brand_support_email": "",
|
||||
}
|
||||
|
||||
PUBLIC_RESP_DESC = "白标实例公开信息(供前端运行时应用品牌)"
|
||||
|
||||
|
||||
class BrandingResponse(BaseModel):
|
||||
brand_name: str
|
||||
brand_logo: str
|
||||
brand_primary_color: str
|
||||
brand_support_email: str
|
||||
version: str
|
||||
|
||||
|
||||
class BrandingUpdate(BaseModel):
|
||||
brand_name: Optional[str] = None
|
||||
brand_logo: Optional[str] = None
|
||||
brand_primary_color: Optional[str] = None
|
||||
brand_support_email: Optional[str] = None
|
||||
|
||||
|
||||
def _get(db: Session, key: str) -> str:
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||
if not cfg or cfg.value is None:
|
||||
return BRAND_KEYS.get(key, "")
|
||||
return cfg.value
|
||||
|
||||
|
||||
@router.get("", response_model=BrandingResponse)
|
||||
def get_branding(db: Session = Depends(get_db)):
|
||||
"""公开接口:返回当前实例的白标配置(无需登录),用于前端运行时渲染品牌"""
|
||||
try:
|
||||
from ..main import app
|
||||
version = getattr(app, "version", "0.1.0")
|
||||
except Exception:
|
||||
version = "0.1.0"
|
||||
return BrandingResponse(
|
||||
brand_name=_get(db, "brand_name"),
|
||||
brand_logo=_get(db, "brand_logo"),
|
||||
brand_primary_color=_get(db, "brand_primary_color"),
|
||||
brand_support_email=_get(db, "brand_support_email"),
|
||||
version=version,
|
||||
)
|
||||
|
||||
|
||||
@router.put("", response_model=BrandingResponse)
|
||||
def update_branding(
|
||||
payload: BrandingUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin_user=Depends(get_current_admin),
|
||||
):
|
||||
"""管理员接口:更新白标配置(品牌名/Logo/主色/支持邮箱)"""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for key, value in data.items():
|
||||
if key not in BRAND_KEYS:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||
if cfg:
|
||||
cfg.value = str(value)
|
||||
else:
|
||||
db.add(SystemConfig(key=key, value=str(value), description="白标配置"))
|
||||
db.commit()
|
||||
return get_branding(db)
|
||||
@@ -1,4 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import Optional
|
||||
@@ -6,10 +12,18 @@ from datetime import datetime, timezone
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import ExternalProduct, PromotionCampaign, CampaignKeyword, SEOAudit, KeywordRanking, OptimizationTask, User
|
||||
from .auth import get_current_user, org_filter
|
||||
from .auth import get_current_user, get_current_admin, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/external", tags=["external_promotion"])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent
|
||||
SEO_AUDITOR_PATH = PROJECT_ROOT / "scripts" / "seo_auditor.py"
|
||||
|
||||
|
||||
class AuditRunRequest(BaseModel):
|
||||
product_id: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
|
||||
|
||||
def _org_filtered_query(db, model, current_user):
|
||||
query = db.query(model)
|
||||
@@ -467,3 +481,44 @@ def external_overview(
|
||||
"type_breakdown": type_breakdown,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ===== Run SEO Audit (delegate to scripts/seo_auditor.py) =====
|
||||
|
||||
@router.post("/audit/run")
|
||||
def run_audit(
|
||||
body: AuditRunRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin),
|
||||
):
|
||||
cmd = [sys.executable, str(SEO_AUDITOR_PATH)]
|
||||
if body.product_id:
|
||||
cmd += ["--product-id", str(body.product_id)]
|
||||
elif body.url:
|
||||
cmd += ["--url", body.url]
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="product_id 或 url 至少提供一个")
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(status_code=408, detail="SEO 审计超时")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"审计执行失败: {str(e)}")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"message": "SEO 审计脚本返回错误",
|
||||
"stderr": result.stderr,
|
||||
"stdout": result.stdout,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode,
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"""发布管理 API"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from typing import Optional, List
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Topic, PublishRecord, User
|
||||
from sqlalchemy.orm import Session
|
||||
from .auth import get_current_admin, org_filter
|
||||
from .auth import get_current_user, org_filter
|
||||
from ..core.audit_logger import audit_log
|
||||
|
||||
router = APIRouter(prefix="/api/publishing", tags=["publishing"])
|
||||
@@ -40,9 +40,9 @@ async def create_publish_record(
|
||||
req: MultiPublishRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""多平台发布选题(管理员)"""
|
||||
"""多平台发布选题(运营/管理员,按组织隔离)"""
|
||||
try:
|
||||
q = db.query(Topic).filter(Topic.id == req.topic_id)
|
||||
of = org_filter(current_user, Topic)
|
||||
@@ -66,17 +66,17 @@ async def create_publish_record(
|
||||
record = PublishRecord(
|
||||
topic_id=req.topic_id,
|
||||
platform=platform,
|
||||
action='publish',
|
||||
status='success',
|
||||
action='stage',
|
||||
status='pending_manual',
|
||||
operator=operator,
|
||||
description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}",
|
||||
description=f"选题 {req.topic_id} 已生成 {PLATFORM_LABELS.get(platform, platform)} 发布稿,待人工校验后发布",
|
||||
org_id=topic.org_id
|
||||
)
|
||||
db.add(record)
|
||||
results.append(PublishResult(
|
||||
platform=platform,
|
||||
platform_label=PLATFORM_LABELS.get(platform, platform),
|
||||
status='success'
|
||||
status='pending_manual'
|
||||
))
|
||||
except Exception as e:
|
||||
results.append(PublishResult(
|
||||
@@ -86,16 +86,17 @@ async def create_publish_record(
|
||||
error_msg=str(e)
|
||||
))
|
||||
|
||||
topic.status = '已发布'
|
||||
# 诚实状态流转:本项目无真实自动发布器(且按《AI生成内容标识办法》AI 稿需真人把关),
|
||||
# 故仅将选题置为「待人工发布」,待运营在对应平台校验声明后手动发布。
|
||||
topic.status = 'pending_publish'
|
||||
_now = datetime.now(timezone(timedelta(hours=8)))
|
||||
topic.updated_at = _now
|
||||
topic.published_at = _now.date()
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
db.refresh(topic)
|
||||
|
||||
audit_log(
|
||||
action="publish",
|
||||
action="publish_stage",
|
||||
user=current_user,
|
||||
resource_type="topic",
|
||||
resource_id=req.topic_id,
|
||||
@@ -105,15 +106,104 @@ async def create_publish_record(
|
||||
db=db
|
||||
)
|
||||
|
||||
success_count = sum(1 for r in results if r.status == 'success')
|
||||
staged = sum(1 for r in results if r.status == 'pending_manual')
|
||||
return MultiPublishResponse(
|
||||
ok=success_count > 0,
|
||||
ok=staged == len(results),
|
||||
topic_id=req.topic_id,
|
||||
results=results,
|
||||
message=f"选题 {req.topic_id} 发布完成({success_count}/{len(results)} 平台成功)"
|
||||
message=(
|
||||
f"选题 {req.topic_id} 已生成 {staged}/{len(results)} 个平台的发布稿并置为「待人工发布」。"
|
||||
f"AI 生成内容须由真人校验声明后手动发布,系统不代发。"
|
||||
)
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/records")
|
||||
def list_publish_records(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""列出发布记录(按组织隔离),用于运营查看「待人工发布 / 已发布」状态"""
|
||||
q = db.query(PublishRecord)
|
||||
of = org_filter(current_user, PublishRecord)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
recs = q.order_by(PublishRecord.created_at.desc()).all()
|
||||
result = []
|
||||
for r in recs:
|
||||
topic = db.query(Topic).filter(Topic.id == r.topic_id).first()
|
||||
result.append({
|
||||
"id": r.id,
|
||||
"topic_id": r.topic_id,
|
||||
"topic_title": topic.title if topic else None,
|
||||
"platform": r.platform,
|
||||
"action": r.action,
|
||||
"status": r.status,
|
||||
"operator": r.operator,
|
||||
"description": r.description,
|
||||
"url": r.url,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||
})
|
||||
return {"records": result}
|
||||
|
||||
|
||||
class MarkPublishedResponse(BaseModel):
|
||||
ok: bool
|
||||
topic_id: str
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/{topic_id}/mark-published", response_model=MarkPublishedResponse)
|
||||
def mark_published(
|
||||
topic_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""运营在对应平台手动发布后,回填「已发布」闭环状态"""
|
||||
q = db.query(Topic).filter(Topic.id == topic_id)
|
||||
of = org_filter(current_user, Topic)
|
||||
if of is not True:
|
||||
q = q.filter(of)
|
||||
topic = q.first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail=f"选题 {topic_id} 不存在")
|
||||
if topic.status != 'pending_publish':
|
||||
raise HTTPException(status_code=400, detail=f"选题 {topic_id} 当前状态为「{topic.status}」,无需标记发布")
|
||||
|
||||
_now = datetime.now(timezone(timedelta(hours=8)))
|
||||
topic.status = 'published'
|
||||
topic.published_at = date.today()
|
||||
topic.updated_at = _now
|
||||
|
||||
recs = db.query(PublishRecord).filter(
|
||||
PublishRecord.topic_id == topic_id,
|
||||
PublishRecord.status == 'pending_manual'
|
||||
)
|
||||
for r in recs:
|
||||
r.status = 'published'
|
||||
r.updated_at = _now
|
||||
|
||||
db.commit()
|
||||
audit_log(
|
||||
action="publish_confirm",
|
||||
user=current_user,
|
||||
resource_type="topic",
|
||||
resource_id=topic_id,
|
||||
details={"operator": current_user.username},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
db=db
|
||||
)
|
||||
return MarkPublishedResponse(
|
||||
ok=True,
|
||||
topic_id=topic_id,
|
||||
message=f"选题 {topic_id} 已标记为「已发布」,发布记录同步更新。"
|
||||
)
|
||||
|
||||
@@ -25,8 +25,7 @@ def _get_cmd(topic_ids: List[str] = None):
|
||||
cmd = [str(venv_python), str(script_path)] if venv_python.exists() else ["python3", str(script_path)]
|
||||
if topic_ids:
|
||||
cmd.extend(["--topic-ids", ','.join(topic_ids)])
|
||||
else:
|
||||
cmd.append("--today-only")
|
||||
# 不再使用 --today-only,避免三平台串行撰写时小红书文章错过审查窗口
|
||||
return cmd
|
||||
|
||||
def run_optimizer(topic_ids: List[str] = None):
|
||||
|
||||
@@ -58,6 +58,7 @@ MODULES = {
|
||||
"scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"},
|
||||
"scheduled_rank_tracker": {"name": "🔍 搜索排名追踪", "cron": "07:00"},
|
||||
"scheduled_geo_tracker": {"name": "🌐 AI 搜索引用追踪", "cron": "07:30"},
|
||||
"scheduled_shortvideo": {"name": "🎬 短视频拆条", "cron": "08:00"},
|
||||
}
|
||||
|
||||
LOG_FILE_MAP = {
|
||||
@@ -71,6 +72,7 @@ LOG_FILE_MAP = {
|
||||
"scheduled_task_monitor": "task_monitor",
|
||||
"scheduled_rank_tracker": "rank_tracker",
|
||||
"scheduled_geo_tracker": "geo_tracker",
|
||||
"scheduled_shortvideo": "shortvideo",
|
||||
}
|
||||
|
||||
def _log_to_file(module_id: str, status: str, message: str = None, error_trace: str = None):
|
||||
@@ -188,6 +190,7 @@ class TaskScheduler:
|
||||
("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"),
|
||||
("scheduled_rank_tracker", self._run_rank_tracker, "搜索排名追踪"),
|
||||
("scheduled_geo_tracker", self._run_geo_tracker, "AI 搜索引用追踪"),
|
||||
("scheduled_shortvideo", self._run_shortvideo, "短视频拆条"),
|
||||
]
|
||||
|
||||
for module_id, fn, name in MODULE_JOBS:
|
||||
@@ -418,41 +421,48 @@ class TaskScheduler:
|
||||
count = 0
|
||||
for topic in topics:
|
||||
platform_urls = topic.platform_urls or {}
|
||||
zhihu_url = platform_urls.get("zhihu", "")
|
||||
if not zhihu_url:
|
||||
continue
|
||||
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', zhihu_url)
|
||||
if not m:
|
||||
continue
|
||||
post_id = m.group(1)
|
||||
api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
|
||||
try:
|
||||
resp = http_requests.get(api_url, headers={"User-Agent": ua}, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
for platform, url in platform_urls.items():
|
||||
if not url:
|
||||
continue
|
||||
if platform == "zhihu":
|
||||
m = re.search(r'zhuanlan\.zhihu\.com/p/(\d+)', url)
|
||||
if not m:
|
||||
continue
|
||||
post_id = m.group(1)
|
||||
api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
|
||||
try:
|
||||
resp = http_requests.get(api_url, headers={"User-Agent": ua}, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
raw = resp.json()
|
||||
existing = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == topic.id,
|
||||
ContentMetrics.platform == "zhihu"
|
||||
).first()
|
||||
metric_data = {
|
||||
"views": raw.get("voteup_count", raw.get("views_count", 0)),
|
||||
"likes": raw.get("voteup_count", 0),
|
||||
"favorites": raw.get("favorite_count", 0),
|
||||
"comments": raw.get("comment_count", raw.get("comments_count", 0)),
|
||||
"shares": raw.get("share_count", 0),
|
||||
"last_fetched": datetime.now(),
|
||||
"publish_url": url,
|
||||
"data_snapshot": raw,
|
||||
}
|
||||
if existing:
|
||||
for k, v in metric_data.items():
|
||||
setattr(existing, k, v)
|
||||
else:
|
||||
db.add(ContentMetrics(topic_id=topic.id, platform="zhihu", **metric_data))
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
elif platform in ("wechat_mp", "xiaohongshu"):
|
||||
logger.info("平台 %s 无公开指标 API,跳过", platform)
|
||||
continue
|
||||
raw = resp.json()
|
||||
existing = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.topic_id == topic.id,
|
||||
ContentMetrics.platform == "zhihu"
|
||||
).first()
|
||||
metric_data = {
|
||||
"views": raw.get("voteup_count", raw.get("views_count", 0)),
|
||||
"likes": raw.get("voteup_count", 0),
|
||||
"favorites": raw.get("favorite_count", 0),
|
||||
"comments": raw.get("comment_count", raw.get("comments_count", 0)),
|
||||
"shares": raw.get("share_count", 0),
|
||||
"last_fetched": datetime.now(),
|
||||
"publish_url": zhihu_url,
|
||||
"data_snapshot": raw,
|
||||
}
|
||||
if existing:
|
||||
for k, v in metric_data.items():
|
||||
setattr(existing, k, v)
|
||||
else:
|
||||
db.add(ContentMetrics(topic_id=topic.id, platform="zhihu", **metric_data))
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
logger.info("平台 %s 暂无指标同步支持,跳过", platform)
|
||||
continue
|
||||
if count:
|
||||
db.commit()
|
||||
logger.info("[Scheduled] Metrics sync completed: synced %d zhihu articles", count)
|
||||
@@ -661,6 +671,46 @@ class TaskScheduler:
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[GeoTracker] AI 搜索引用追踪失败: %s", e)
|
||||
|
||||
def _run_shortvideo(self):
|
||||
"""每日将前一日「待发布」选题的长文拆条为短视频脚本"""
|
||||
started = datetime.now(timezone.utc)
|
||||
log_id = _log_task("scheduled_shortvideo", "running", started_at=started)
|
||||
try:
|
||||
from ..database import SessionLocal
|
||||
from ..models import Topic
|
||||
db = SessionLocal()
|
||||
try:
|
||||
topics = db.query(Topic).filter(
|
||||
Topic.status.in_(["ready", "待发布", "pending_publish"])
|
||||
).order_by(Topic.ready_at.desc().nullslast()).limit(3).all()
|
||||
finally:
|
||||
db.close()
|
||||
if not topics:
|
||||
_log_task("scheduled_shortvideo", "success", log_id=log_id,
|
||||
message="无待拆条选题", started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
return
|
||||
import subprocess
|
||||
total = 0
|
||||
for t in topics:
|
||||
res = subprocess.run(
|
||||
[sys.executable, str(PROJECT_ROOT / "scripts" / "shortvideo.py"), "--topic-id", t.id, "--count", "3"],
|
||||
capture_output=True, text=True, timeout=600
|
||||
)
|
||||
if res.returncode == 0:
|
||||
total += 1
|
||||
_log_task("scheduled_shortvideo", "success", log_id=log_id,
|
||||
message=f"拆条完成: {total}/{len(topics)} 选题",
|
||||
result_data={"topics": len(topics), "done": total},
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.info("[ShortVideo] 拆条完成: %d/%d", total, len(topics))
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_log_task("scheduled_shortvideo", "failed", log_id=log_id,
|
||||
message=str(e),
|
||||
error_trace=traceback.format_exc(),
|
||||
started_at=started, finished_at=datetime.now(timezone.utc))
|
||||
logger.exception("[ShortVideo] 拆条失败: %s", e)
|
||||
|
||||
def get_jobs(self):
|
||||
"""返回当前所有定时任务的状态"""
|
||||
jobs = []
|
||||
|
||||
@@ -76,6 +76,17 @@ def import_initial_data():
|
||||
for cfg in default_system_configs:
|
||||
if db.query(SystemConfig).filter(SystemConfig.key == cfg["key"]).first() is None:
|
||||
db.add(SystemConfig(**cfg))
|
||||
|
||||
# 白标默认值(私有化部署/卖给他人时可改)
|
||||
default_branding = [
|
||||
{"key": "brand_name", "value": "宇之然内容创作平台", "description": "白标:平台名称"},
|
||||
{"key": "brand_logo", "value": "", "description": "白标:Logo URL(留空用文字)"},
|
||||
{"key": "brand_primary_color", "value": "#2563eb", "description": "白标:主色(导航/按钮)"},
|
||||
{"key": "brand_support_email", "value": "", "description": "白标:支持邮箱"},
|
||||
]
|
||||
for b in default_branding:
|
||||
if db.query(SystemConfig).filter(SystemConfig.key == b["key"]).first() is None:
|
||||
db.add(SystemConfig(**b))
|
||||
if db.query(SystemConfig).filter(SystemConfig.key == "review_llm_id").first() is None:
|
||||
first_llm = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
|
||||
if first_llm:
|
||||
@@ -294,21 +305,47 @@ def import_initial_data():
|
||||
db.commit()
|
||||
print("✅ 插入默认角色")
|
||||
|
||||
# 初始化默认菜单(与 uni-nav.js 对齐)
|
||||
if db.query(Menu).count() == 0:
|
||||
default_menus = [
|
||||
{"name": "仪表盘", "path": "/", "icon": "IconHome", "sort_order": 0, "roles": ["admin", "editor"]},
|
||||
{"name": "选题", "path": "topics.html", "icon": "IconTopic", "sort_order": 1, "roles": ["admin", "editor"]},
|
||||
{"name": "数据", "path": "metrics.html", "icon": "IconData", "sort_order": 2, "roles": ["admin", "editor"]},
|
||||
{"name": "日历", "path": "calendar.html", "icon": "IconCalendar", "sort_order": 3, "roles": ["admin", "editor"]},
|
||||
{"name": "素材", "path": "assets.html", "icon": "IconFolder", "sort_order": 4, "roles": ["admin", "editor"]},
|
||||
{"name": "任务", "path": "tasks.html", "icon": "IconMenu", "sort_order": 5, "roles": ["admin", "editor"]},
|
||||
{"name": "系统", "path": "admin.html", "icon": "IconSetting", "sort_order": 6, "roles": ["admin"]},
|
||||
]
|
||||
for m in default_menus:
|
||||
db.add(Menu(**m))
|
||||
# 归一化首页菜单路径,避免 index.html 与 /index.html 产生重复入口
|
||||
idx_plain = db.query(Menu).filter(Menu.path == "index.html").first()
|
||||
idx_slash = db.query(Menu).filter(Menu.path == "/index.html").first()
|
||||
if idx_plain and not idx_slash:
|
||||
idx_plain.path = "/index.html"
|
||||
db.commit()
|
||||
print("✅ 插入默认菜单")
|
||||
elif idx_plain and idx_slash:
|
||||
db.delete(idx_plain)
|
||||
db.commit()
|
||||
|
||||
# 初始化默认菜单:幂等按 path 对齐(已有时更新排序/角色,缺失时插入)。
|
||||
# 运行时会用 DB 菜单覆盖 uni-nav.js 的 fallback,因此这里必须保证文章管理/内容工厂/白标设置等入口存在。
|
||||
default_menus = [
|
||||
{"name": "工作台", "path": "/index.html", "icon": "IconHome", "sort_order": 0, "roles": ["admin", "editor"]},
|
||||
{"name": "创作工作台", "path": "studio.html", "icon": "IconEdit", "sort_order": 1, "roles": ["admin", "editor"]},
|
||||
{"name": "数据", "path": "metrics.html", "icon": "IconData", "sort_order": 2, "roles": ["admin", "editor"]},
|
||||
{"name": "日历", "path": "calendar.html", "icon": "IconCalendar", "sort_order": 3, "roles": ["admin", "editor"]},
|
||||
{"name": "素材", "path": "assets.html", "icon": "IconFolder", "sort_order": 4, "roles": ["admin", "editor"]},
|
||||
{"name": "任务", "path": "tasks.html", "icon": "IconMenu", "sort_order": 5, "roles": ["admin", "editor"]},
|
||||
{"name": "外推营销", "path": "campaigns.html", "icon": "IconStar", "sort_order": 6, "roles": ["admin", "editor"]},
|
||||
{"name": "AI味检测", "path": "ai-slop.html", "icon": "IconSearch", "sort_order": 7, "roles": ["admin", "editor"]},
|
||||
{"name": "白标设置", "path": "branding.html", "icon": "IconSetting", "sort_order": 8, "roles": ["admin"]},
|
||||
{"name": "系统", "path": "admin.html", "icon": "IconSetting", "sort_order": 9, "roles": ["admin"]},
|
||||
]
|
||||
for m in default_menus:
|
||||
found = db.query(Menu).filter(Menu.path == m["path"]).first()
|
||||
if found:
|
||||
found.sort_order = m["sort_order"]
|
||||
found.roles = m["roles"]
|
||||
found.icon = m["icon"]
|
||||
found.is_active = True
|
||||
# 保留原名称,避免意外重命名
|
||||
else:
|
||||
db.add(Menu(**m))
|
||||
# 原独立的选题/内容工厂/文章管理已合并进「创作工作台」,从主导航隐藏(页面仍可被 studio 内嵌与直接访问)
|
||||
for legacy in ["topics.html", "factory.html", "articles.html"]:
|
||||
legacy_menu = db.query(Menu).filter(Menu.path == legacy).first()
|
||||
if legacy_menu:
|
||||
legacy_menu.is_active = False
|
||||
db.commit()
|
||||
print("✅ 菜单已对齐(创作工作台合并选题/内容工厂/文章管理)")
|
||||
|
||||
# 同步 PostgreSQL 自增序列
|
||||
if os.getenv('USE_POSTGRES', 'true').lower() == 'true':
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
from .database import engine, get_db, init_db
|
||||
from .models import Base
|
||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers, search_rankings, external_promotion
|
||||
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, external_promotion, ai_slop, branding
|
||||
from .initial_data import import_initial_data
|
||||
from .core.scheduler import scheduler
|
||||
|
||||
@@ -106,6 +106,8 @@ app.include_router(menu_configs.public_router)
|
||||
app.include_router(search_providers.router)
|
||||
app.include_router(search_rankings.router)
|
||||
app.include_router(external_promotion.router)
|
||||
app.include_router(ai_slop.router)
|
||||
app.include_router(branding.router)
|
||||
|
||||
# 挂载自动生成的图片(必须先于前端根挂载)
|
||||
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
|
||||
|
||||
Reference in New Issue
Block a user