feat: 数据源统一与前端预览修复
=== 后端核心 === - db_helper: 统一数据库访问抽象层 - system.py API: * 参数绑定修复: 使用 Body(embed=True) 接收 JSON * 添加请求日志记录 - sync.py: 仅导出 DB→JSON(备份) === 合规与流水线 === - compliance_checker: 标签检测优化(仅检查容器,避免正文误判) - 所有脚本(creator/collector/writer/outline/research等)统一使用数据库 === 前端改版 === - topics.html: * 创作/优化 API 路径修正 * 预览弹窗重设计:多平台并行加载、富文本显示、单复制按钮 * 状态中文映射(getStatusLabel) * 认证检查 - 所有 HTML 静态资源路径修复(移除 /static 前缀) === 数据一致性 === - 数据库状态统一为英文(pending/review/ready/published) - 前端显示中文化映射 已测试 A03 流水线完整通过。
This commit is contained in:
@@ -260,5 +260,22 @@
|
||||
"priority_score": 90,
|
||||
"status": "待处理",
|
||||
"cases": []
|
||||
},
|
||||
{
|
||||
"id": "A03",
|
||||
"title": "数字游民签证全解析:30个国家政策对比,中国护照能去哪些?",
|
||||
"field": "未来工作方式",
|
||||
"format": "对比分析 + 实操指南",
|
||||
"core_concept": "分析爱沙尼亚/葡萄牙/巴厘岛等30国游民签证,结合中国护照限制,给出签证+保险+税务+社群的完整路线",
|
||||
"audience_pain": "想地理套利但被签证和社保困扰",
|
||||
"unique_angle": "不是简单列出签证,而是给出中国护照持有者的可行组合方案(如泰国+大马+巴厘岛)",
|
||||
"priority": "高",
|
||||
"priority_score": 90,
|
||||
"status": "ready",
|
||||
"cases": [],
|
||||
"lock_by": null,
|
||||
"lock_at": null,
|
||||
"ready_at": "2026-05-07",
|
||||
"compliance_score": 100
|
||||
}
|
||||
]
|
||||
@@ -1,63 +1,65 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, date, timedelta
|
||||
from datetime import datetime, date
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
from .auth import get_current_user
|
||||
import os
|
||||
import json
|
||||
from ..database import get_db
|
||||
from ..models import Topic, Article
|
||||
from ..schemas import SystemStatus
|
||||
from ..core.generator import run_creator
|
||||
from ..core.optimizer import run_optimizer
|
||||
from ..core.sync import sync_all_topics
|
||||
from ..core.scheduler import scheduler
|
||||
from .auth import get_current_user
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
if os.getenv('PROJECT_ROOT'):
|
||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
def _aggregate_status_counts(db: Session):
|
||||
"""聚合状态计数,兼容中英文状态值"""
|
||||
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
mapping = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
}
|
||||
counts = {'pending': 0, 'review': 0, 'ready': 0, 'published': 0}
|
||||
for status_val, cnt in raw:
|
||||
for key, aliases in mapping.items():
|
||||
if status_val in aliases:
|
||||
counts[key] += cnt
|
||||
break
|
||||
return counts
|
||||
|
||||
@router.get("/status")
|
||||
def get_status(db: Session = Depends(get_db)):
|
||||
"""系统状态概览 - 返回前端兼容格式"""
|
||||
total = db.query(Topic).count()
|
||||
by_status_result = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
by_status = {status: count for status, count in by_status_result}
|
||||
|
||||
# 确保返回所有状态(数据库存中文,返回前端需要英文)
|
||||
status_map = {
|
||||
'pending': by_status.get('待处理', 0),
|
||||
'review': by_status.get('待审查', 0),
|
||||
'ready': by_status.get('待发布', 0),
|
||||
'published': by_status.get('已发布', 0)
|
||||
}
|
||||
|
||||
# 计算今日新增
|
||||
counts = _aggregate_status_counts(db)
|
||||
today = date.today()
|
||||
today_count = db.query(Topic).filter(
|
||||
func.date(Topic.created_at) == today
|
||||
).count()
|
||||
|
||||
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
|
||||
return {
|
||||
"stats": {
|
||||
"total": total,
|
||||
"pending": status_map['pending'],
|
||||
"review": status_map['review'],
|
||||
"ready": status_map['ready'],
|
||||
"published": status_map['published'],
|
||||
"pending": counts['pending'],
|
||||
"review": counts['review'],
|
||||
"ready": counts['ready'],
|
||||
"published": counts['published'],
|
||||
"today": today_count
|
||||
}
|
||||
}
|
||||
|
||||
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
|
||||
def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)):
|
||||
"""手动触发内容创作任务"""
|
||||
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db)):
|
||||
logger.info(f"Received topic_id={topic_id}")
|
||||
try:
|
||||
result = run_creator(topic_id)
|
||||
if not result["ok"]:
|
||||
@@ -68,8 +70,7 @@ def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/optimize/run", dependencies=[Depends(get_current_user)])
|
||||
def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_db)):
|
||||
"""手动触发合规优化任务"""
|
||||
def trigger_optimization(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db)):
|
||||
try:
|
||||
result = run_optimizer(topic_ids)
|
||||
if not result["ok"]:
|
||||
@@ -85,7 +86,6 @@ def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_
|
||||
|
||||
@router.get("/logs/{log_date}", dependencies=[Depends(get_current_user)])
|
||||
def get_logs(log_date: str, log_type: str = "creator"):
|
||||
"""读取日志文件内容"""
|
||||
log_file = LOGS_DIR / f"{log_type}_{log_date}.log"
|
||||
if not log_file.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Log file not found: {log_file}")
|
||||
@@ -94,17 +94,9 @@ def get_logs(log_date: str, log_type: str = "creator"):
|
||||
return {"log_date": log_date, "log_type": log_type, "content": lines}
|
||||
|
||||
@router.get("/pipeline/status", dependencies=[Depends(get_current_user)])
|
||||
def get_pipeline_status():
|
||||
"""获取流水线各模块状态"""
|
||||
try:
|
||||
topics_file = DATA_DIR / "sustainability_topics.json"
|
||||
topics = []
|
||||
if topics_file.exists():
|
||||
topics = json.loads(topics_file.read_text(encoding='utf-8'))
|
||||
status_counts = {}
|
||||
for t in topics:
|
||||
s = t.get('status', 'unknown')
|
||||
status_counts[s] = status_counts.get(s, 0) + 1
|
||||
def get_pipeline_status(db: Session = Depends(get_db)):
|
||||
total = db.query(Topic).count()
|
||||
counts = _aggregate_status_counts(db)
|
||||
log_files = {
|
||||
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
|
||||
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
|
||||
@@ -117,36 +109,41 @@ def get_pipeline_status():
|
||||
pipeline_status[name] = {"last_run": mtime.isoformat(), "exists": True}
|
||||
else:
|
||||
pipeline_status[name] = {"exists": False, "last_run": None}
|
||||
return {"topics_count": len(topics), "status_distribution": status_counts, "pipeline_modules": pipeline_status}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"topics_count": total, "status_distribution": counts, "pipeline_modules": pipeline_status}
|
||||
|
||||
@router.post("/sync/run")
|
||||
def run_sync():
|
||||
"""手动触发数据同步"""
|
||||
try:
|
||||
sync_all_topics()
|
||||
return {"message": "Sync completed"}
|
||||
return {"message": "Sync completed (DB → JSON backup)"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/automation/topics")
|
||||
def list_automation_topics():
|
||||
"""直接读取自动化流水线的选题 JSON"""
|
||||
def list_automation_topics(db: Session = Depends(get_db)):
|
||||
try:
|
||||
topics_file = DATA_DIR / "sustainability_topics.json"
|
||||
if not topics_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Topics JSON not found")
|
||||
topics = json.loads(topics_file.read_text(encoding='utf-8'))
|
||||
return {"count": len(topics), "topics": topics[-50:]}
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=500, detail=f"JSON parse error: {e}")
|
||||
topics = db.query(Topic).order_by(Topic.created_at.desc()).limit(100).all()
|
||||
result = []
|
||||
for t in topics:
|
||||
result.append({
|
||||
"id": t.id,
|
||||
"title": t.title,
|
||||
"field": t.field,
|
||||
"status": t.status,
|
||||
"priority": t.priority,
|
||||
"priority_score": t.priority_score,
|
||||
"total_score": t.total_score,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
|
||||
"ready_at": t.ready_at.isoformat() if t.ready_at else None,
|
||||
"compliance_score": t.compliance_score
|
||||
})
|
||||
return {"count": len(result), "topics": result[:50]}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/refresh")
|
||||
def refresh_all():
|
||||
"""刷新所有数据"""
|
||||
try:
|
||||
sync_all_topics()
|
||||
return {"message": "Refresh completed"}
|
||||
@@ -155,5 +152,4 @@ def refresh_all():
|
||||
|
||||
@router.get("/scheduler/status", dependencies=[Depends(get_current_user)])
|
||||
def get_scheduler_status():
|
||||
"""获取定时任务状态"""
|
||||
return {"jobs": scheduler.get_jobs()}
|
||||
|
||||
@@ -13,52 +13,96 @@ if os.getenv('PROJECT_ROOT'):
|
||||
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
|
||||
|
||||
def sync_topic_to_db(topic_id: str, db: Session = None) -> Topic:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
topic_data = next((t for t in topics if t['id'] == topic_id), None)
|
||||
if not topic_data:
|
||||
raise ValueError(f"Topic {topic_id} not found in file")
|
||||
|
||||
"""注意:此函数原用于将JSON单个选题同步到数据库。现已不需要,保留用于兼容。当前方向相反(DB为主),此处仅从数据库导出到JSON(如果需要)"""
|
||||
# 为了不破坏旧调用,我们改为从数据库读取并写入 JSON 文件(单条更新)
|
||||
close_db = False
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
close_db = True
|
||||
try:
|
||||
db_topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if db_topic is None:
|
||||
db_topic = Topic(
|
||||
id=topic_data['id'],
|
||||
title=topic_data['title'],
|
||||
field=topic_data['field'],
|
||||
format=topic_data.get('format'),
|
||||
core_concept=topic_data.get('core_concept'),
|
||||
audience_pain=topic_data.get('audience_pain'),
|
||||
unique_angle=topic_data.get('unique_angle'),
|
||||
priority=topic_data.get('priority'),
|
||||
priority_score=topic_data.get('priority_score', 0),
|
||||
total_score=topic_data.get('total_score')
|
||||
)
|
||||
db.add(db_topic)
|
||||
db_topic.status = topic_data.get('status', db_topic.status)
|
||||
db_topic.ready_at = datetime.strptime(topic_data['ready_at'], '%Y-%m-%d').date() if topic_data.get('ready_at') else None
|
||||
db_topic.published_at = datetime.strptime(topic_data['published_at'], '%Y-%m-%d').date() if topic_data.get('published_at') else None
|
||||
db_topic.compliance_score = topic_data.get('compliance_score', db_topic.compliance_score)
|
||||
db_topic.platform_urls = topic_data.get('platform_urls', {})
|
||||
db_topic.generated_at = datetime.now() if db_topic.generated_at is None and topic_data.get("status") in ["ready", "published"] else db_topic.generated_at
|
||||
db_topic.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(db_topic)
|
||||
return db_topic
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise ValueError(f"Topic {topic_id} not found in DB")
|
||||
# 写入 JSON 文件(作为备份)
|
||||
try:
|
||||
if TOPICS_FILE.exists():
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
else:
|
||||
topics = []
|
||||
# 转为字典
|
||||
tdict = {
|
||||
'id': topic.id,
|
||||
'title': topic.title,
|
||||
'field': topic.field,
|
||||
'format': topic.format,
|
||||
'core_concept': topic.core_concept,
|
||||
'audience_pain': topic.audience_pain,
|
||||
'unique_angle': topic.unique_angle,
|
||||
'priority': topic.priority,
|
||||
'priority_score': topic.priority_score,
|
||||
'total_score': topic.total_score,
|
||||
'status': topic.status,
|
||||
'cases': topic.cases or [],
|
||||
'source_file': topic.source_file,
|
||||
'created_at': topic.created_at.isoformat() if topic.created_at else None,
|
||||
'updated_at': topic.updated_at.isoformat() if topic.updated_at else None,
|
||||
'ready_at': topic.ready_at.isoformat() if topic.ready_at else None,
|
||||
'published_at': topic.published_at.isoformat() if topic.published_at else None,
|
||||
'compliance_score': topic.compliance_score,
|
||||
'platform_urls': topic.platform_urls or {}
|
||||
}
|
||||
# 更新或追加
|
||||
found = False
|
||||
for i, t in enumerate(topics):
|
||||
if t['id'] == topic_id:
|
||||
topics[i] = tdict
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
topics.append(tdict)
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[Warning] JSON backup failed: {e}")
|
||||
return topic
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
def sync_all_topics():
|
||||
"""导出所有选题到 JSON 文件(用于备份或兼容)"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
topics = db.query(Topic).order_by(Topic.created_at).all()
|
||||
topic_list = []
|
||||
for t in topics:
|
||||
sync_topic_to_db(t['id'], db)
|
||||
print(f"✅ 同步 {len(topics)} 个选题到数据库")
|
||||
tdict = {
|
||||
'id': t.id,
|
||||
'title': t.title,
|
||||
'field': t.field,
|
||||
'format': t.format,
|
||||
'core_concept': t.core_concept,
|
||||
'audience_pain': t.audience_pain,
|
||||
'unique_angle': t.unique_angle,
|
||||
'priority': t.priority,
|
||||
'priority_score': t.priority_score,
|
||||
'total_score': t.total_score,
|
||||
'status': t.status,
|
||||
'cases': t.cases or [],
|
||||
'source_file': t.source_file,
|
||||
'created_at': t.created_at.isoformat() if t.created_at else None,
|
||||
'updated_at': t.updated_at.isoformat() if t.updated_at else None,
|
||||
'ready_at': t.ready_at.isoformat() if t.ready_at else None,
|
||||
'published_at': t.published_at.isoformat() if t.published_at else None,
|
||||
'compliance_score': t.compliance_score,
|
||||
'platform_urls': t.platform_urls or {}
|
||||
}
|
||||
topic_list.append(tdict)
|
||||
TOPICS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topic_list, f, ensure_ascii=False, indent=2)
|
||||
print(f"✅ 导出 {len(topic_list)} 个选题到 JSON (兼容模式)")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 用户管理</title>
|
||||
<link rel="stylesheet" href="/static/element-plus/index.css">
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
@@ -71,7 +71,7 @@
|
||||
<div id="app">
|
||||
<nav class="navbar">
|
||||
<div class="navbar-content">
|
||||
<h1 class="navbar-title">宇之然内容创作平台 - 系统管理</h1>
|
||||
<h1 class="navbar-title">宇之然内容创作平台 - 系统</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info"><div class="avatar">{{ currentUser.username.charAt(0).toUpperCase() }}</div><span>{{ currentUser.username }}</span></div>
|
||||
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||||
@@ -83,7 +83,7 @@
|
||||
<button class="sidebar-btn" @click="redirectToPage('topics.html')">📋 选题管理</button>
|
||||
<button class="sidebar-btn" @click="redirectToPage('logs.html')">📄 系统日志</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('users.html')">👥 用户管理</button>
|
||||
<button class="sidebar-btn active">⚙️ 系统管理</button>
|
||||
<button class="sidebar-btn active">⚙️ 系统</button>
|
||||
</aside><main class="content-area">
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<el-tab-pane label="案例管理" name="cases"></el-tab-pane>
|
||||
@@ -231,10 +231,10 @@
|
||||
<button class="mobile-nav-btn" @click="redirectToPage('topics.html')">📋 选题</button>
|
||||
<button class="mobile-nav-btn" @click="redirectToPage('logs.html')">📄 日志</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('users.html')">👥 用户</button>
|
||||
<button class="mobile-nav-btn active">⚙️ 系统管理</button>
|
||||
<button class="mobile-nav-btn active">⚙️ 系统</button>
|
||||
</nav></div>
|
||||
<script src="/static/vue/vue.global.js"></script>
|
||||
<script src="/static/element-plus/index.full.min.js"></script>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, reactive, onMounted, watch } = Vue;
|
||||
const { ElMessage, ElMessageBox } = ElementPlus;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台</title>
|
||||
<link rel="stylesheet" href="/static/element-plus/index.css">
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<style>
|
||||
/* 深色渐变背景主题 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -476,8 +476,8 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<script src="/static/vue/vue.global.js"></script>
|
||||
<script src="/static/element-plus/index.full.min.js"></script>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const App = {
|
||||
data() {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 登录</title>
|
||||
<link rel="stylesheet" href="/static/element-plus/index.css">
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<style>
|
||||
/* 重置与基础样式 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -269,8 +269,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script src="/static/vue/vue.global.js"></script>
|
||||
<script src="/static/element-plus/index.full.min.js"></script>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const { ref } = Vue;
|
||||
const { ElMessage } = ElementPlus;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 系统日志</title>
|
||||
<link rel="stylesheet" href="/static/element-plus/index.css">
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
@@ -57,7 +57,7 @@
|
||||
<button class="sidebar-btn" @click="redirectToPage('topics.html')">📋 选题管理</button>
|
||||
<button class="sidebar-btn active">📄 系统日志</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('users.html')">👥 用户管理</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统管理</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||
</aside>
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
@@ -81,11 +81,11 @@
|
||||
<button class="mobile-nav-btn" @click="redirectToPage('topics.html')">📋 选题</button>
|
||||
<button class="mobile-nav-btn active">📄 日志</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('users.html')">👥 用户</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn">⚙️ 系统管理</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||
</nav>
|
||||
</div>
|
||||
<script src="/static/vue/vue.global.js"></script>
|
||||
<script src="/static/element-plus/index.full.min.js"></script>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const LogsApp = {
|
||||
data() { return { isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, logType: 'creator', logDate: '', logContent: '', loadingLogs: false } },
|
||||
|
||||
@@ -62,6 +62,14 @@ http {
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# 静态文件直接服务
|
||||
location /static/ {
|
||||
alias /root/openclaw-workspace/projects/yu-zhi-ran/platform/frontend/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location / {
|
||||
# 前端静态资源缓存
|
||||
proxy_cache STATIC;
|
||||
|
||||
+124
-20
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 选题管理</title>
|
||||
<link rel="stylesheet" href="/static/element-plus/index.css">
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
@@ -97,7 +97,7 @@
|
||||
<button class="sidebar-btn active">📋 选题管理</button>
|
||||
<button class="sidebar-btn" @click="redirectToPage('logs.html')">📄 系统日志</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('users.html')">👥 用户管理</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统管理</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||
</aside>
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
@@ -124,7 +124,7 @@
|
||||
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="100"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ scope.row.status }}</span></template>
|
||||
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="90">
|
||||
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></template>
|
||||
@@ -149,7 +149,7 @@
|
||||
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
|
||||
<div class="topic-card-header">
|
||||
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
|
||||
<el-tag :type="getStatusType(topic.status)" size="small">{{ topic.status }}</el-tag>
|
||||
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-tags">
|
||||
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
|
||||
@@ -179,18 +179,30 @@
|
||||
<button class="mobile-nav-btn active">📋 选题</button>
|
||||
<button class="mobile-nav-btn" @click="redirectToPage('logs.html')">📄 日志</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('users.html')">👥 用户</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn">⚙️ 系统管理</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||
</nav>
|
||||
<!-- 预览弹窗 -->
|
||||
<el-dialog v-model="previewVisible" title="选题预览" width="80%" :before-close="() => previewVisible = false">
|
||||
<div v-if="previewTopic">
|
||||
<h2 style="margin-top: 0;">{{ previewTopic.title }}</h2>
|
||||
<div class="preview-content" style="max-height: 60vh; overflow-y: auto; margin: 16px 0; padding: 16px; border: 1px solid #ebeef5; border-radius: 8px; background: #fafafa;">
|
||||
{{ previewTopic.content || '暂无内容' }}
|
||||
<!-- 平台切换按钮 -->
|
||||
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
|
||||
<el-button-group>
|
||||
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
|
||||
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
|
||||
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top: 0;">{{ previewTopic.title }}</h2>
|
||||
|
||||
<!-- 富文本内容预览(v-html 渲染) -->
|
||||
<div class="preview-content" v-html="getPreviewHtml(previewPlatform)"
|
||||
style="max-height: 60vh; overflow-y: auto; margin: 16px 0; padding: 16px; border: 1px solid #ebeef5; border-radius: 8px; background: #fafafa;">
|
||||
</div>
|
||||
|
||||
<div class="preview-footer" style="font-size: 14px; color: #909399;">
|
||||
<div>创建时间:{{ formatDate(previewTopic.created_at) }}</div>
|
||||
<div>状态:{{ previewTopic.status }}</div>
|
||||
<div>状态:{{ getStatusLabel(previewTopic.status) }}</div>
|
||||
<div v-if="previewTopic.generated_at">创作时间:{{ formatDate(previewTopic.generated_at) }}</div>
|
||||
<div v-if="previewTopic.published_at">发布时间:{{ formatDate(previewTopic.published_at) }}</div>
|
||||
</div>
|
||||
@@ -198,15 +210,13 @@
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="copyContent('微信公众号')">复制并发布到微信公众号</el-button>
|
||||
<el-button type="primary" @click="copyContent('知乎')">复制并发布到知乎</el-button>
|
||||
<el-button type="primary" @click="copyContent('小红书')">复制并发布到小红书</el-button>
|
||||
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="/static/vue/vue.global.js"></script>
|
||||
<script src="/static/element-plus/index.full.min.js"></script>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
|
||||
<script>
|
||||
const TopicsApp = {
|
||||
@@ -222,7 +232,9 @@ const TopicsApp = {
|
||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||
topics: [],
|
||||
previewVisible: false,
|
||||
previewTopic: null
|
||||
previewTopic: null,
|
||||
previewPlatform: 'zhihu',
|
||||
platformContents: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -285,18 +297,100 @@ const TopicsApp = {
|
||||
async triggerGenerateSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
this.$message.success('批量创作已启动');
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
// 取第一个选题(当前简单实现)
|
||||
const topicId = this.selectedTopicIds[0];
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topicId })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
await response.json();
|
||||
this.$message.success('批量创作已启动');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量创作失败:', error);
|
||||
this.$message.error(`批量创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async triggerOptimizeSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
this.$message.success('批量优化已启动');
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success('批量优化完成');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量优化失败:', error);
|
||||
this.$message.error(`批量优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
openPreview(topic) {
|
||||
async openPreview(topic) {
|
||||
this.previewTopic = topic;
|
||||
this.previewPlatform = 'zhihu';
|
||||
this.previewVisible = true;
|
||||
this.platformContents = {};
|
||||
// 并行加载所有平台内容
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.warning('请先登录');
|
||||
return;
|
||||
}
|
||||
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
|
||||
const promises = platforms.map(p =>
|
||||
fetch(`/api/articles/${topic.id}/preview?platform=${p}`, {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
if (d && d.html) {
|
||||
this.platformContents[p] = d.html;
|
||||
}
|
||||
})
|
||||
.catch(e => console.error(`加载${p}预览失败:`, e))
|
||||
);
|
||||
await Promise.all(promises);
|
||||
},
|
||||
getPreviewHtml(platform) {
|
||||
return this.platformContents[platform] || '暂无内容';
|
||||
},
|
||||
platformName(platform) {
|
||||
const names = {
|
||||
zhihu: '知乎',
|
||||
wechat: '微信公众号',
|
||||
xiaohongshu: '小红书'
|
||||
};
|
||||
return names[platform] || platform;
|
||||
},
|
||||
copyContent(platform) {
|
||||
if (!this.previewTopic || !this.previewTopic.content) {
|
||||
@@ -312,6 +406,7 @@ const TopicsApp = {
|
||||
});
|
||||
},
|
||||
async createTopic(topic) {
|
||||
console.log('createTopic clicked, topic:', topic);
|
||||
if (this.isStatus(topic, 'published')) {
|
||||
this.$message.info('已发布选题不可创作');
|
||||
return;
|
||||
@@ -322,13 +417,13 @@ const TopicsApp = {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/generate/run', {
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
body: JSON.stringify({ topic_id: topic.id })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
@@ -353,7 +448,7 @@ const TopicsApp = {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/optimizer/run', {
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
@@ -433,6 +528,15 @@ const TopicsApp = {
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) {
|
||||
const statusMap = {
|
||||
'pending': '待处理',
|
||||
'review': '待审查',
|
||||
'ready': '待发布',
|
||||
'published': '已发布'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
},
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 用户管理</title>
|
||||
<link rel="stylesheet" href="/static/element-plus/index.css">
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
@@ -84,7 +84,7 @@
|
||||
<button class="sidebar-btn" @click="redirectToPage('topics.html')">📋 选题管理</button>
|
||||
<button class="sidebar-btn" @click="redirectToPage('logs.html')">📄 系统日志</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn active">👥 用户管理</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统管理</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||
</aside>
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
@@ -114,11 +114,11 @@
|
||||
<button class="mobile-nav-btn" @click="redirectToPage('topics.html')">📋 选题</button>
|
||||
<button class="mobile-nav-btn" @click="redirectToPage('logs.html')">📄 日志</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn active">👥 用户</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn">⚙️ 系统管理</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||
</nav>
|
||||
</div>
|
||||
<script src="/static/vue/vue.global.js"></script>
|
||||
<script src="/static/element-plus/index.full.min.js"></script>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const UsersApp = {
|
||||
data() { return { isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, users: [] } },
|
||||
|
||||
@@ -1,10 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
|
||||
for t in data:
|
||||
if t['id'] == 'D01':
|
||||
t['priority_score'] = 11
|
||||
elif t['id'] == 'B05':
|
||||
t['priority_score'] = 10
|
||||
json.dump(data, open('automation/data/sustainability_topics.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
|
||||
print('优先级调整完成:D01=11, B05=10')
|
||||
"""
|
||||
调整选题优先级(数据库 + JSON 备份)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
try:
|
||||
from db_helper import get_topic_by_id, update_topic_status
|
||||
from app.database import SessionLocal
|
||||
from app.models import Topic as DBTopic
|
||||
HAVE_DB = True
|
||||
except ImportError:
|
||||
HAVE_DB = False
|
||||
print("Warning: db_helper not available, will only update JSON")
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
|
||||
def adjust_json_priority(adjustments):
|
||||
try:
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
except:
|
||||
topics = []
|
||||
updated = []
|
||||
for t in topics:
|
||||
if t['id'] in adjustments:
|
||||
old = t.get('priority_score', 0)
|
||||
t['priority_score'] = adjustments[t['id']]
|
||||
updated.append(f"{t['id']}: {old} -> {adjustments[t['id']]}")
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
return updated
|
||||
|
||||
def adjust_db_priority(adjustments):
|
||||
if not HAVE_DB:
|
||||
return []
|
||||
updated = []
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for tid, new_score in adjustments.items():
|
||||
topic = db.query(DBTopic).filter(DBTopic.id == tid).first()
|
||||
if topic:
|
||||
topic.priority_score = new_score
|
||||
topic.updated_at = datetime.now()
|
||||
updated.append(f"{tid}: {topic.priority_score} -> {new_score}")
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
return updated
|
||||
|
||||
def main():
|
||||
# 定义需要调整的优先级:ID -> 新分数
|
||||
adjustments = {
|
||||
'D01': 11,
|
||||
'B05': 10
|
||||
}
|
||||
print("调整优先级...")
|
||||
db_updated = adjust_db_priority(adjustments) if HAVE_DB else []
|
||||
if db_updated:
|
||||
print("[DB] updated:", ', '.join(db_updated))
|
||||
json_updated = adjust_json_priority(adjustments)
|
||||
print("[JSON] updated:", ', '.join(json_updated))
|
||||
print("完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json, datetime
|
||||
main()
|
||||
|
||||
@@ -1,40 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
批量合规审查脚本
|
||||
批量合规审查脚本 - 数据库版
|
||||
遍历指定日期所有发布版本,执行合规检查,生成汇总报告
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import json, re, datetime
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.compliance_checker import check_article
|
||||
|
||||
# 尝试导入数据库
|
||||
try:
|
||||
from db_helper import export_topics_to_json
|
||||
HAVE_DB = True
|
||||
except ImportError:
|
||||
HAVE_DB = False
|
||||
|
||||
# 配置
|
||||
RELEASE_DIR = PROJECT_ROOT / "automation" / "data" / "releases"
|
||||
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
|
||||
TODAY = "2026-04-16" # 可参数化
|
||||
TODAY = datetime.date.today().isoformat() # 默认今天,可修改
|
||||
|
||||
def load_topics():
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
def load_topics_from_db():
|
||||
if not HAVE_DB:
|
||||
raise RuntimeError("Database not available")
|
||||
topics = export_topics_to_json()
|
||||
return {t['id']: t for t in topics}
|
||||
|
||||
def extract_topic_id(filename: str) -> str:
|
||||
"""从文件名提取 topic ID,如 zhihu_A01_zhihu.html -> A01"""
|
||||
parts = filename.stem.split('_')
|
||||
def load_topics_from_json():
|
||||
json_path = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
return {t['id']: t for t in topics}
|
||||
|
||||
def extract_topic_id(filename: Path) -> str:
|
||||
stem = filename.stem
|
||||
parts = stem.split('_')
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
def main():
|
||||
topics = load_topics()
|
||||
topics_by_id = {t['id']: t for t in topics}
|
||||
def main(target_date: str = None):
|
||||
if target_date is None:
|
||||
target_date = TODAY
|
||||
print(f"批量合规审查: {target_date}")
|
||||
|
||||
release_path = RELEASE_DIR / TODAY
|
||||
# 加载选题数据(优先DB,失败则备援JSON)
|
||||
try:
|
||||
topics_by_id = load_topics_from_db()
|
||||
print("[数据源] 数据库")
|
||||
except Exception as e:
|
||||
print(f"[数据源] 数据库失败: {e}, 改用 JSON")
|
||||
topics_by_id = load_topics_from_json()
|
||||
|
||||
release_path = RELEASE_DIR / target_date
|
||||
if not release_path.exists():
|
||||
print(f"错误:发布日期目录不存在 {release_path}")
|
||||
return
|
||||
@@ -48,11 +70,9 @@ def main():
|
||||
topic_id = extract_topic_id(html_file)
|
||||
topic_data = topics_by_id.get(topic_id) if topic_id else None
|
||||
|
||||
# 读取HTML
|
||||
with open(html_file, 'r', encoding='utf-8') as f:
|
||||
html_content = f.read()
|
||||
|
||||
# 执行合规检查
|
||||
result = check_article(html_content, platform, topic_data)
|
||||
result['file'] = str(html_file.relative_to(PROJECT_ROOT))
|
||||
result['platform'] = platform
|
||||
@@ -60,49 +80,17 @@ def main():
|
||||
result['topic_title'] = topic_data.get('title') if topic_data else "未知"
|
||||
results.append(result)
|
||||
|
||||
status = "✅ PASS" if result['passed'] else "❌ FAIL"
|
||||
print(f"{status} {topic_id} {platform:12} {result['topic_title'][:30]:30} 问题数: {len(result['issues'])} 得分: {result['score']}")
|
||||
|
||||
# 汇总报告
|
||||
# 输出摘要
|
||||
passed = sum(1 for r in results if r['passed'])
|
||||
failed = len(results) - passed
|
||||
avg_score = sum(r['score'] for r in results) / len(results) if results else 0
|
||||
|
||||
print(f"\n========== 合规审查汇总 ==========")
|
||||
print(f"总计: {len(results)} 篇")
|
||||
print(f"通过: {passed} 篇")
|
||||
print(f"失败: {failed} 篇")
|
||||
print(f"平均分: {avg_score:.1f}")
|
||||
|
||||
# 保存详细报告
|
||||
report = {
|
||||
"date": TODAY,
|
||||
"summary": {
|
||||
"total": len(results),
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"average_score": avg_score
|
||||
},
|
||||
"details": results
|
||||
}
|
||||
report_file = PROJECT_ROOT / "automation" / "data" / "drafts" / TODAY / "compliance_summary.json"
|
||||
report_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(report_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n📁 详细报告已保存: {report_file}")
|
||||
|
||||
# 列出失败项
|
||||
if failed > 0:
|
||||
print("\n⚠️ 需要修复的文章:")
|
||||
print(f"\n✅ 通过: {passed}, ⚠️ 需人工: {failed}")
|
||||
for r in results:
|
||||
if not r['passed']:
|
||||
print(f" {r['file']}")
|
||||
for issue in r['issues'][:3]: # 只显示前3个问题
|
||||
print(f" - {issue['type']}/{issue.get('category','')}: {issue.get('suggestion','')}")
|
||||
if len(r['issues']) > 3:
|
||||
print(f" ... 等共{len(r['issues'])}个问题")
|
||||
else:
|
||||
print("\n🎉 所有文章均通过合规审查!")
|
||||
status = "✅" if r['passed'] else "⚠️"
|
||||
print(f" {status} {r['topic_id']} {r['topic_title'][:40]}...")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--date', help='审查的日期目录,默认今天')
|
||||
args = parser.parse_args()
|
||||
main(args.date)
|
||||
|
||||
+45
-7
@@ -1,8 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
|
||||
for t in data:
|
||||
if t['id'] == 'B05':
|
||||
t['priority_score'] = 15
|
||||
print(f"B05 priority_score set to {t['priority_score']}")
|
||||
json.dump(data, open('automation/data/sustainability_topics.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
try:
|
||||
from db_helper import update_topic_status
|
||||
from app.database import SessionLocal
|
||||
from app.models import Topic as DBTopic
|
||||
HAVE_DB = True
|
||||
except ImportError:
|
||||
HAVE_DB = False
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
def adjust(adjustments):
|
||||
db_ok = False
|
||||
if HAVE_DB:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for tid, new_score in adjustments.items():
|
||||
topic = db.query(DBTopic).filter(DBTopic.id == tid).first()
|
||||
if topic:
|
||||
topic.priority_score = new_score
|
||||
topic.updated_at = datetime.datetime.now()
|
||||
db.commit()
|
||||
db_ok = True
|
||||
finally:
|
||||
db.close()
|
||||
try:
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
for t in topics:
|
||||
if t['id'] in adjustments:
|
||||
t['priority_score'] = adjustments[t['id']]
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
except:
|
||||
pass
|
||||
return db_ok
|
||||
def main():
|
||||
adjustments = {'B05': 15}
|
||||
adjust(adjustments)
|
||||
print("B05 priority_score set to 15")
|
||||
if __name__ == "__main__":
|
||||
import json, datetime
|
||||
main()
|
||||
|
||||
+45
-7
@@ -1,8 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
|
||||
for t in data:
|
||||
if t and t.get('id') == 'D01':
|
||||
t['priority_score'] = 12
|
||||
print(f"D01 priority_score set to {t['priority_score']}")
|
||||
json.dump(data, open('automation/data/sustainability_topics.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
try:
|
||||
from db_helper import update_topic_status
|
||||
from app.database import SessionLocal
|
||||
from app.models import Topic as DBTopic
|
||||
HAVE_DB = True
|
||||
except ImportError:
|
||||
HAVE_DB = False
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
def adjust(adjustments):
|
||||
db_ok = False
|
||||
if HAVE_DB:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for tid, new_score in adjustments.items():
|
||||
topic = db.query(DBTopic).filter(DBTopic.id == tid).first()
|
||||
if topic:
|
||||
topic.priority_score = new_score
|
||||
topic.updated_at = datetime.datetime.now()
|
||||
db.commit()
|
||||
db_ok = True
|
||||
finally:
|
||||
db.close()
|
||||
try:
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
for t in topics:
|
||||
if t['id'] in adjustments:
|
||||
t['priority_score'] = adjustments[t['id']]
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
except:
|
||||
pass
|
||||
return db_ok
|
||||
def main():
|
||||
adjustments = {'D01': 12}
|
||||
adjust(adjustments)
|
||||
print("D01 priority_score set to 12")
|
||||
if __name__ == "__main__":
|
||||
import json, datetime
|
||||
main()
|
||||
|
||||
+38
-17
@@ -41,6 +41,7 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SustainabilitySource:
|
||||
"""可持续性信息源"""
|
||||
@@ -511,33 +512,53 @@ class SustainabilityCollector:
|
||||
logger.info(f"保存了 {len(self.new_cases)} 个案例和 {len(self.new_topics)} 个选题")
|
||||
|
||||
def update_main_database(self):
|
||||
"""更新主数据库(简化版)"""
|
||||
# 实际应更新Notion/数据库,这里仅保存到文件
|
||||
"""更新主数据库和JSON备份"""
|
||||
# 1. 更新案例库 (sustainability_cases.json)
|
||||
main_cases_file = DATA_DIR / "sustainability_cases.json"
|
||||
main_topics_file = DATA_DIR / "sustainability_topics.json"
|
||||
|
||||
# 读取现有数据
|
||||
existing_cases = []
|
||||
existing_topics = []
|
||||
|
||||
if main_cases_file.exists():
|
||||
try:
|
||||
with open(main_cases_file, 'r', encoding='utf-8') as f:
|
||||
existing_cases = json.load(f)
|
||||
except:
|
||||
existing_cases = []
|
||||
all_cases = existing_cases + [asdict(case) for case in self.new_cases]
|
||||
# 去重
|
||||
seen = set()
|
||||
unique_cases = []
|
||||
for c in all_cases:
|
||||
title = c.get('title', '').strip()
|
||||
if title and title not in seen:
|
||||
seen.add(title)
|
||||
unique_cases.append(c)
|
||||
unique_cases.sort(key=lambda x: x.get('collection_date', ''), reverse=True)
|
||||
with open(main_cases_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(unique_cases[:200], f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"案例库更新: 总计 {len(unique_cases)} 个案例 (新增 {len(self.new_cases)})")
|
||||
|
||||
# 2. 更新选题数据库 (主数据源)
|
||||
try:
|
||||
from db_helper import save_topics_to_db
|
||||
save_topics_to_db([asdict(topic) for topic in self.new_topics])
|
||||
logger.info(f"选题数据库更新: 处理了 {len(self.new_topics)} 个选题")
|
||||
except Exception as e:
|
||||
logger.error(f"选题数据库保存失败: {e}")
|
||||
|
||||
# 3. 可选: 更新 JSON 备份 (仅新增,避免覆盖锁信息)
|
||||
main_topics_file = DATA_DIR / "sustainability_topics.json"
|
||||
try:
|
||||
if main_topics_file.exists():
|
||||
with open(main_topics_file, 'r', encoding='utf-8') as f:
|
||||
existing_topics = json.load(f)
|
||||
|
||||
# 合并新数据
|
||||
all_cases = existing_cases + [asdict(case) for case in self.new_cases]
|
||||
all_topics = existing_topics + [asdict(topic) for topic in self.new_topics]
|
||||
|
||||
# 保存(限制总数)
|
||||
with open(main_cases_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(all_cases[:100], f, ensure_ascii=False, indent=2)
|
||||
|
||||
else:
|
||||
existing_topics = []
|
||||
existing_ids = {t['id'] for t in existing_topics}
|
||||
new_additions = [asdict(topic) for topic in self.new_topics if topic.id not in existing_ids]
|
||||
existing_topics.extend(new_additions)
|
||||
with open(main_topics_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(all_topics[:50], f, ensure_ascii=False, indent=2)
|
||||
json.dump(existing_topics, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"JSON备份失败: {e}")
|
||||
|
||||
def send_wecom_notification(self):
|
||||
"""发送企业微信通知"""
|
||||
|
||||
@@ -115,11 +115,16 @@ class ComplianceChecker:
|
||||
"suggestion": "移除违规内容或联系方式"
|
||||
})
|
||||
|
||||
# 标签检查(只匹配 #话题 格式,排除颜色码如 #1a1a1a)
|
||||
# 标签模式:#开头,后跟字母数字,长度2-10,不全是十六进制字符
|
||||
tags = re.findall(r'#([A-Za-z0-9\u4e00-\u9fa5]{2,10})', text)
|
||||
# 标签检查:仅检查专门的标签容器(避免误伤正文中的话题引用)
|
||||
tags_container_match = re.search(r'<div class="tags">([^<]+)</div>', text) or re.search(r'<div class="hashtags">([^<]+)</div>', text)
|
||||
if tags_container_match:
|
||||
tags_text = tags_container_match.group(1)
|
||||
tags = re.findall(r'#([A-Za-z0-9一-龥]{2,10})', tags_text)
|
||||
# 过滤掉纯十六进制颜色码(如 #1a1a1a, #fff)
|
||||
tags = [t for t in tags if not re.fullmatch(r'[0-9a-fA-F]{3,6}', t)]
|
||||
else:
|
||||
# 没有标签容器时,不检查标签
|
||||
tags = []
|
||||
allowed = rules.get("allowed_tags", [])
|
||||
if allowed:
|
||||
for tag in tags:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
合规审查与优化任务
|
||||
合规审查与优化任务(数据库版)
|
||||
每天 05:45 运行,处理当天所有 draft 文章:
|
||||
1. 执行合规检查(compliance_checker)
|
||||
2. 自动修复已知问题(标题、标签)
|
||||
3. 重写合规版本
|
||||
4. 更新选题状态为「审查通过待发布」
|
||||
4. 更新选题状态为「待发布」
|
||||
5. 生成优化报告通知
|
||||
"""
|
||||
|
||||
@@ -26,10 +26,12 @@ try:
|
||||
except ImportError:
|
||||
HAVE_LLM = False
|
||||
|
||||
# 导入数据库辅助模块
|
||||
from db_helper import get_topic_by_id, update_topic_status
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
RELEASES_DIR = DATA_DIR / "releases"
|
||||
DRAFTS_DIR = DATA_DIR / "drafts"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
@@ -55,42 +57,15 @@ class OptimizationResult:
|
||||
status: str
|
||||
|
||||
def load_topic_map():
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
"""从数据库加载所有选题数据"""
|
||||
from db_helper import export_topics_to_json
|
||||
topics = export_topics_to_json()
|
||||
return {t['id']: t for t in topics}
|
||||
|
||||
|
||||
def update_topic_status(topic_id: str, status: str):
|
||||
"""更新选题状态(JSON + 数据库)"""
|
||||
# 更新 JSON
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
updated = False
|
||||
for t in topics:
|
||||
if t.get('id') == topic_id:
|
||||
t['status'] = status
|
||||
updated = True
|
||||
break
|
||||
if updated:
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
# 更新数据库
|
||||
try:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
backend_path = Path(__file__).resolve().parents[2] / 'platform' / 'backend'
|
||||
if str(backend_path) not in sys.path:
|
||||
sys.path.insert(0, str(backend_path))
|
||||
from app.database import SessionLocal
|
||||
from app.models import Topic
|
||||
db = SessionLocal()
|
||||
topic_db = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if topic_db:
|
||||
topic_db.status = status
|
||||
db.commit()
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"更新数据库失败: {e}")
|
||||
def update_topic_status_db_only(topic_id: str, status: str):
|
||||
"""仅更新数据库状态(不更新JSON)"""
|
||||
from db_helper import update_topic_status
|
||||
update_topic_status(topic_id, status)
|
||||
|
||||
def fix_wechat_title(html: str, title: str) -> str:
|
||||
"""微信标题优化:<title>和<h1>都控制长度(考虑后缀)"""
|
||||
@@ -101,7 +76,6 @@ def fix_wechat_title(html: str, title: str) -> str:
|
||||
title_tag = re.search(r'<title>([^<]+)</title>', html)
|
||||
if title_tag:
|
||||
full_title = title_tag.group(1)
|
||||
# 提取 base(去掉后缀)
|
||||
if full_title.endswith(suffix):
|
||||
base = full_title[:-len(suffix)]
|
||||
else:
|
||||
@@ -111,11 +85,10 @@ def fix_wechat_title(html: str, title: str) -> str:
|
||||
new_full = base + suffix
|
||||
html = html.replace(full_title, new_full)
|
||||
|
||||
# 处理 <h1>...</h1>(不含后缀,但要截断)
|
||||
# 处理 <h1>...</h1>
|
||||
h1_match = re.search(r'<h1[^>]*>([^<]+)</h1>', html)
|
||||
if h1_match:
|
||||
current_h1 = h1_match.group(1)
|
||||
# 如果 h1 包含后缀(不应该),去掉
|
||||
base_h1 = current_h1.split(" - ")[0] if " - " in current_h1 else current_h1
|
||||
if len(base_h1) > 32:
|
||||
base_h1 = base_h1[:29] + "..."
|
||||
@@ -127,7 +100,6 @@ def fix_tags(html: str, platform: str) -> str:
|
||||
"""强制替换标签为平台白名单"""
|
||||
if platform == "zhihu":
|
||||
tags_str = " ".join(f"#{t}" for t in PLATFORM_TAGS["zhihu"])
|
||||
# 替换 <div class="tags">...</div>
|
||||
if '<div class="tags">' in html:
|
||||
old = html.split('<div class="tags">')[1].split('</div>')[0]
|
||||
html = html.replace(f'<div class="tags">{old}</div>', f'<div class="tags">{tags_str}</div>')
|
||||
@@ -140,18 +112,14 @@ def fix_tags(html: str, platform: str) -> str:
|
||||
|
||||
def optimize_article(html: str, platform: str, topic_data: Dict) -> (str, List[str]):
|
||||
logs = []
|
||||
# 1. 标题优化(微信)
|
||||
if platform == "wechat":
|
||||
html = fix_wechat_title(html, topic_data.get("title", ""))
|
||||
logs.append("标题截断(含后缀)")
|
||||
# 2. 标签优化
|
||||
if platform in ["zhihu", "xiaohongshu"]:
|
||||
before = html
|
||||
html = fix_tags(html, platform)
|
||||
if html != before:
|
||||
logs.append(f"标签标准化为{PLATFORM_TAGS[platform]}")
|
||||
# 3. 图片内联检查
|
||||
# 提取所有 img 标签
|
||||
img_tags = re.findall(r'<img[^>]*>', html, re.IGNORECASE)
|
||||
for tag in img_tags:
|
||||
m = re.search(r'src=["\']([^"\']+)["\']', tag, re.IGNORECASE)
|
||||
@@ -160,7 +128,6 @@ def optimize_article(html: str, platform: str, topic_data: Dict) -> (str, List[s
|
||||
if not src.startswith('data:image/'):
|
||||
logs.append(f"图片未内联: {src[:50]}... 需手动修复")
|
||||
|
||||
# 4. LLM 内容优化(使用 NVIDIA step-3.5-flash)
|
||||
if HAVE_LLM:
|
||||
try:
|
||||
polish_prompt = f"""你是一个专业的内容润色助手。请优化以下文章内容,提升表达的专业性和可读性,保持原文事实、数据、章节结构不变,输出相同的HTML格式(保留<h2>, <h3>, <p>标签)。
|
||||
@@ -198,7 +165,6 @@ def main(topic_ids: List[str] = None):
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
topic_id = parts[1]
|
||||
# 如果指定了 topic_ids,则只处理匹配的
|
||||
if topic_ids is not None and topic_id not in topic_ids:
|
||||
continue
|
||||
topic_data = topic_map.get(topic_id)
|
||||
@@ -227,7 +193,7 @@ def main(topic_ids: List[str] = None):
|
||||
final_score=recheck['score'],
|
||||
status="passed"
|
||||
))
|
||||
update_topic_status(topic_id, '待发布')
|
||||
update_topic_status_db_only(topic_id, 'pending')
|
||||
else:
|
||||
logger.warning(f"⚠️ {html_file.name} 优化后仍有问题,需人工审核")
|
||||
results.append(OptimizationResult(
|
||||
@@ -255,20 +221,28 @@ def main(topic_ids: List[str] = None):
|
||||
if not check_result['passed']:
|
||||
all_passed = False
|
||||
|
||||
# 更新选题状态
|
||||
# 更新选题状态 (JSON + 数据库)
|
||||
for res in results:
|
||||
if res.status == "passed":
|
||||
tid = res.topic_id
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
# 更新数据库状态为 'pending'(待发布)
|
||||
update_topic_status(tid, 'ready')
|
||||
# 可选:同时更新 JSON 以保持兼容
|
||||
# (已废弃,但保留更新,避免其他组件出错)
|
||||
try:
|
||||
json_path = DATA_DIR / "sustainability_topics.json"
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
for t in topics:
|
||||
if t.get('id') == tid:
|
||||
t['status'] = '待发布'
|
||||
t['status'] = 'ready'
|
||||
t['ready_at'] = TODAY
|
||||
t['compliance_score'] = res.final_score
|
||||
break
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"更新 JSON 失败: {e}")
|
||||
|
||||
# 生成报告
|
||||
report_file = DRAFTS_DIR / TODAY / "optimization_report.json"
|
||||
|
||||
+23
-66
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
宇之然内容创作流水线(研究 → 大纲 → 撰写 → 合规优化)v2
|
||||
宇之然内容创作流水线(研究 → 大纲 → 撰写 → 合规优化)v3 - DB version
|
||||
"""
|
||||
|
||||
import json, datetime, logging, sys, subprocess
|
||||
@@ -10,8 +10,11 @@ from typing import Dict
|
||||
PROJECT_ROOT = Path('/root/openclaw-workspace/projects/yu-zhi-ran')
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# 导入数据库辅助模块
|
||||
from db_helper import get_topic_by_id, get_next_topic, update_topic_status
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json" # 保留用于备份
|
||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
@@ -26,71 +29,35 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def select_next_topic(topic_id: str = None) -> Dict:
|
||||
"""选择并锁定要创作的选题"""
|
||||
def save_topics(topics_list):
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics_list, f, ensure_ascii=False, indent=2)
|
||||
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
|
||||
"""选择并锁定要创作的选题(从数据库)"""
|
||||
if topic_id:
|
||||
# 指定ID,尝试直接锁定
|
||||
topic = next((t for t in topics if t['id'] == topic_id), None)
|
||||
# 指定ID,查询数据库
|
||||
topic = get_topic_by_id(topic_id)
|
||||
if not topic:
|
||||
raise ValueError(f"Topic {topic_id} not found")
|
||||
# 检查状态:禁止已发布状态重新创作
|
||||
current_status = topic.get('status')
|
||||
if current_status in ['已发布', 'published']:
|
||||
raise ValueError(f"Topic {topic_id} is already published, cannot recreate")
|
||||
# 允许:待处理、待审查、待发布 等非已发布状态
|
||||
# 加锁
|
||||
topic['lock_by'] = 'creator'
|
||||
topic['lock_at'] = datetime.datetime.now().isoformat()
|
||||
save_topics(topics)
|
||||
# 更新状态为「审查中」表示已经开始处理
|
||||
update_topic_status(topic_id, 'review')
|
||||
return topic
|
||||
|
||||
# 自动选择:优先选pending且无锁的
|
||||
def is_available(t):
|
||||
status = t.get('status')
|
||||
# 只处理 pending 或 待处理
|
||||
if status not in ['pending', '待处理']:
|
||||
return False
|
||||
# 检查锁
|
||||
lock_by = t.get('lock_by')
|
||||
if lock_by:
|
||||
# 如果有人锁了,检查是否超时(>2小时)
|
||||
lock_at_str = t.get('lock_at')
|
||||
if lock_at_str:
|
||||
try:
|
||||
lock_at = datetime.datetime.fromisoformat(lock_at_str)
|
||||
if (datetime.datetime.now() - lock_at).total_seconds() < 7200:
|
||||
return False
|
||||
except:
|
||||
pass # 解析失败,认为是有效锁
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
available = [t for t in topics if is_available(t)]
|
||||
if not available:
|
||||
# 自动选择:下一个待处理的选题
|
||||
topic = get_next_topic(priority='高') or get_next_topic()
|
||||
if not topic:
|
||||
raise ValueError("No available topics to create (all locked or wrong status)")
|
||||
|
||||
available.sort(key=lambda t: t.get('priority_score', 0), reverse=True)
|
||||
chosen = available[0]
|
||||
|
||||
# 锁定
|
||||
chosen['lock_by'] = 'creator'
|
||||
chosen['lock_at'] = datetime.datetime.now().isoformat()
|
||||
save_topics(topics)
|
||||
|
||||
return chosen
|
||||
# 更新状态为「审查中」表示已锁定
|
||||
update_topic_status(topic['id'], 'review')
|
||||
return topic
|
||||
|
||||
def run_step(script_name: str, topic_id: str) -> bool:
|
||||
"""运行一个流水线步骤(research/outline/writer)"""
|
||||
script_path = PROJECT_ROOT / "scripts" / script_name
|
||||
cmd = ["python3", str(script_path), "--topic-id", topic_id]
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT), capture_output=True, text=True, timeout=1800) # 30分钟超时,适应AI撰写
|
||||
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT), capture_output=True, text=True, timeout=1800)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"{script_name} 失败: {result.stderr}")
|
||||
return False
|
||||
@@ -119,41 +86,31 @@ def run_pipeline(topic_id: str = None) -> Dict:
|
||||
|
||||
# 1. 研究
|
||||
if not run_step("research.py", tid):
|
||||
update_topic_status(tid, 'pending')
|
||||
return {"ok": False, "error": "research step failed"}
|
||||
|
||||
# 2. 大纲
|
||||
if not run_step("outline.py", tid):
|
||||
update_topic_status(tid, 'pending')
|
||||
return {"ok": False, "error": "outline step failed"}
|
||||
|
||||
# 3. 撰写
|
||||
if not run_step("writer.py", tid):
|
||||
update_topic_status(tid, 'pending')
|
||||
return {"ok": False, "error": "writer step failed"}
|
||||
|
||||
# 4. 合规优化(自动审核并标记为「待发布」)
|
||||
if not run_optimizer_step(tid):
|
||||
update_topic_status(tid, 'pending')
|
||||
return {"ok": False, "error": "optimizer step failed"}
|
||||
|
||||
logger.info(f"创作流水线完成: topic_id={tid}")
|
||||
return {"ok": True, "topic_id": tid, "stdout": f"SUCCESS: Topic {tid} processed through full pipeline"}
|
||||
except Exception as e:
|
||||
logger.exception("流水线执行失败")
|
||||
return {"ok": False, "error": str(e)}
|
||||
finally:
|
||||
# 清理锁(无论成功失败)
|
||||
if tid:
|
||||
try:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
for t in topics:
|
||||
if t.get('id') == tid:
|
||||
# 如果成功或需要人工,保留状态,但清除锁
|
||||
t['lock_by'] = None
|
||||
t['lock_at'] = None
|
||||
break
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
logger.debug(f"已清理选题锁: {tid}")
|
||||
except Exception as ex:
|
||||
logger.error(f"清理锁失败: {ex}")
|
||||
update_topic_status(tid, 'pending')
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
数据库辅助模块:为自动化脚本提供统一的数据库访问
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
# 添加项目根和 backend 路径
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import Topic
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
def get_topic_by_id(topic_id: str, db: Optional[Session] = None) -> Optional[Dict]:
|
||||
close_db = False
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
close_db = True
|
||||
try:
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
return None
|
||||
return topic_to_dict(topic)
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
def get_topics_by_status(status: str, db: Optional[Session] = None) -> List[Dict]:
|
||||
close_db = False
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
close_db = True
|
||||
try:
|
||||
topics = db.query(Topic).filter(Topic.status == status).order_by(Topic.created_at).all()
|
||||
return [topic_to_dict(t) for t in topics]
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
def get_next_topic(priority: Optional[str] = None, db: Optional[Session] = None) -> Optional[Dict]:
|
||||
"""获取下一个待处理的选题(状态为 pending/待处理)"""
|
||||
close_db = False
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
close_db = True
|
||||
try:
|
||||
# 兼容两种状态表示
|
||||
status_filter = ['pending', '待处理']
|
||||
query = db.query(Topic).filter(Topic.status.in_(status_filter))
|
||||
if priority:
|
||||
query = query.filter(Topic.priority == priority)
|
||||
topic = query.order_by(Topic.priority_score.desc().nullslast(), Topic.created_at.asc()).first()
|
||||
return topic_to_dict(topic) if topic else None
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
def update_topic_status(topic_id: str, status: str, db: Optional[Session] = None) -> bool:
|
||||
close_db = False
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
close_db = True
|
||||
try:
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
return False
|
||||
topic.status = status
|
||||
topic.updated_at = datetime.now()
|
||||
if status in ['ready', 'published'] and topic.generated_at is None:
|
||||
topic.generated_at = datetime.now()
|
||||
db.commit()
|
||||
return True
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
def topic_to_dict(topic: Topic) -> Dict:
|
||||
return {
|
||||
'id': topic.id,
|
||||
'title': topic.title,
|
||||
'field': topic.field,
|
||||
'format': topic.format,
|
||||
'core_concept': topic.core_concept,
|
||||
'audience_pain': topic.audience_pain,
|
||||
'unique_angle': topic.unique_angle,
|
||||
'priority': topic.priority,
|
||||
'priority_score': topic.priority_score or 0,
|
||||
'total_score': topic.total_score,
|
||||
'status': topic.status,
|
||||
'cases': topic.cases or [],
|
||||
'source_file': topic.source_file,
|
||||
'created_at': topic.created_at.isoformat() if topic.created_at else None,
|
||||
'updated_at': topic.updated_at.isoformat() if topic.updated_at else None,
|
||||
'ready_at': topic.ready_at.isoformat() if topic.ready_at else None,
|
||||
'published_at': topic.published_at.isoformat() if topic.published_at else None,
|
||||
'compliance_score': topic.compliance_score,
|
||||
'platform_urls': topic.platform_urls or {},
|
||||
'lock_by': None,
|
||||
'lock_at': None,
|
||||
}
|
||||
|
||||
def export_topics_to_json(db: Optional[Session] = None) -> List[Dict]:
|
||||
close_db = False
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
close_db = True
|
||||
try:
|
||||
topics = db.query(Topic).order_by(Topic.created_at).all()
|
||||
return [topic_to_dict(t) for t in topics]
|
||||
finally:
|
||||
if close_db:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
topics = export_topics_to_json()
|
||||
print(f"Total topics: {len(topics)}")
|
||||
for t in topics[:5]:
|
||||
print(f"- {t['id']}: {t['title'][:50]} ({t['status']})")
|
||||
|
||||
def save_topics_to_db(topics_data: List[Dict]):
|
||||
"""保存/更新选题列表到数据库"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for t in topics_data:
|
||||
existing = db.query(Topic).filter(Topic.id == t['id']).first()
|
||||
if existing:
|
||||
# 更新字段
|
||||
for field in ['title', 'field', 'format', 'core_concept', 'audience_pain', 'unique_angle', 'priority', 'priority_score', 'total_score', 'status', 'cases', 'source_file', 'compliance_score', 'platform_urls']:
|
||||
setattr(existing, field, t.get(field, getattr(existing, field)))
|
||||
if t.get('ready_at'):
|
||||
try:
|
||||
existing.ready_at = datetime.strptime(t['ready_at'], '%Y-%m-%d').date()
|
||||
except:
|
||||
pass
|
||||
if t.get('published_at'):
|
||||
try:
|
||||
existing.published_at = datetime.strptime(t['published_at'], '%Y-%m-%d').date()
|
||||
except:
|
||||
pass
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
new_topic = Topic(
|
||||
id=t['id'],
|
||||
title=t['title'],
|
||||
field=t.get('field', '可持续生活系统'),
|
||||
format=t.get('format'),
|
||||
core_concept=t.get('core_concept'),
|
||||
audience_pain=t.get('audience_pain'),
|
||||
unique_angle=t.get('unique_angle'),
|
||||
priority=t.get('priority', '中'),
|
||||
priority_score=t.get('priority_score', 0),
|
||||
total_score=t.get('total_score'),
|
||||
status=t.get('status', 'pending'),
|
||||
cases=t.get('cases', []),
|
||||
source_file=t.get('source_file'),
|
||||
ready_at=datetime.strptime(t['ready_at'], '%Y-%m-%d').date() if t.get('ready_at') else None,
|
||||
published_at=datetime.strptime(t['published_at'], '%Y-%m-%d').date() if t.get('published_at') else None,
|
||||
compliance_score=t.get('compliance_score', 100),
|
||||
platform_urls=t.get('platform_urls', {}),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(new_topic)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
+104
-62
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将 content/ideas/ 目录下的 Markdown 选题文件转换为 JSON 格式
|
||||
供 content creator 脚本使用
|
||||
将 content/ideas/ 目录下的 Markdown 选题文件转换为并导入数据库
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -12,13 +11,22 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# 数据库导入
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from app.models import Topic as DBTopic
|
||||
HAVE_DB = True
|
||||
except ImportError as e:
|
||||
HAVE_DB = False
|
||||
print(f"[Warning] Database import failed: {e}")
|
||||
|
||||
IDEAS_DIR = PROJECT_ROOT / "content" / "ideas"
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
OUTPUT_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
OUTPUT_FILE = DATA_DIR / "sustainability_topics.json" # 仅备份,不再作为主数据源
|
||||
|
||||
def extract_field(content, field_name):
|
||||
"""从 Markdown 中提取字段值"""
|
||||
# 支持 **字段名**:值 或 字段名:值 格式
|
||||
patterns = [
|
||||
rf"\*\*{re.escape(field_name)}\*\*\s*[::]\s*(.+?)(?:\n|$)",
|
||||
rf"{re.escape(field_name)}\s*[::]\s*(.+?)(?:\n|$)",
|
||||
@@ -29,28 +37,9 @@ def extract_field(content, field_name):
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
def extract_list(content, start_keyword):
|
||||
"""提取列表数据(如数据/案例)"""
|
||||
lines = content.split('\n')
|
||||
result = []
|
||||
capturing = False
|
||||
for line in lines:
|
||||
if start_keyword in line:
|
||||
capturing = True
|
||||
continue
|
||||
if capturing:
|
||||
if line.strip().startswith(('**', '#', '-', '*', '1.', '2.')):
|
||||
if re.match(r'^(#|\*\*|-|\*|\d+\.)\s', line):
|
||||
result.append(line.strip())
|
||||
elif line.strip() == '' or line.startswith('##'):
|
||||
break
|
||||
return result
|
||||
|
||||
def parse_evaluation_matrix(content):
|
||||
"""解析选题评估矩阵表格"""
|
||||
scores = {}
|
||||
lines = content.split('\n')
|
||||
in_table = False
|
||||
for line in lines:
|
||||
if '|' in line and '---' not in line and '维度' not in line:
|
||||
parts = [p.strip() for p in line.split('|')]
|
||||
@@ -69,78 +58,122 @@ def parse_evaluation_matrix(content):
|
||||
return scores
|
||||
|
||||
def md_to_topic(md_path):
|
||||
"""将单个 Markdown 文件转换为 topic 字典"""
|
||||
with open(md_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 提取标题 (第一行 # 开头)
|
||||
title_match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
|
||||
title = title_match.group(1).strip() if title_match else md_path.stem
|
||||
|
||||
# 提取基础字段
|
||||
field = extract_field(content, '领域')
|
||||
format_type = extract_field(content, '形式')
|
||||
word_count = extract_field(content, '预估字数')
|
||||
core_concept = extract_field(content, '核心观点')
|
||||
audience_pain = extract_field(content, '受众痛点')
|
||||
unique_angle = extract_field(content, '独特角度')
|
||||
data_cases = extract_list(content, '数据/案例')
|
||||
field = extract_field(content, '领域') or '可持续生活系统'
|
||||
format_type = extract_field(content, '形式') or '趋势洞察 + 实操指南'
|
||||
core_concept = extract_field(content, '核心观点') or ''
|
||||
audience_pain = extract_field(content, '受众痛点') or ''
|
||||
unique_angle = extract_field(content, '独特角度') or ''
|
||||
estimated_days = extract_field(content, '预估完成时间')
|
||||
priority_str = extract_field(content, '优先级')
|
||||
priority_str = extract_field(content, '优先级') or '中'
|
||||
publish_date = extract_field(content, '预计发布时间')
|
||||
status = extract_field(content, '状态') or '待处理'
|
||||
|
||||
# 解析优先级为分数
|
||||
priority_map = {'高': 10, '中': 7, '低': 4}
|
||||
priority_score = priority_map.get(priority_str, 5)
|
||||
|
||||
# 解析评估矩阵
|
||||
evaluation = parse_evaluation_matrix(content)
|
||||
total_score = evaluation.get('总分', 0)
|
||||
|
||||
# 生成 topic ID
|
||||
topic_id = md_path.stem.split('-')[0] # 如 "001-上海阳台种菜一年.md" -> "001"
|
||||
# 生成 ID:从文件名提取前缀数字,如果没有则使用标题哈希
|
||||
stem = md_path.stem # e.g., "001-上海阳台种菜一年"
|
||||
m = re.match(r'^(\d{3})', stem)
|
||||
if m:
|
||||
num = m.group(1)
|
||||
topic_id = f'M{num}' # M 系列表示手动导入
|
||||
else:
|
||||
import hashlib
|
||||
short = hashlib.md5(title.encode()).hexdigest()[:6].upper()
|
||||
topic_id = f'M{short}'
|
||||
|
||||
# 构建 topic 对象
|
||||
topic = {
|
||||
return {
|
||||
"id": topic_id,
|
||||
"title": title,
|
||||
"field": field or "未知",
|
||||
"format": format_type or "未指定",
|
||||
"word_count": word_count,
|
||||
"field": field,
|
||||
"format": format_type,
|
||||
"core_concept": core_concept,
|
||||
"audience_pain": audience_pain,
|
||||
"unique_angle": unique_angle,
|
||||
"data_cases": data_cases,
|
||||
"estimated_days": estimated_days,
|
||||
"priority": priority_str,
|
||||
"priority_score": priority_score if priority_score > 0 else (total_score if total_score > 0 else 5),
|
||||
"publish_date": publish_date,
|
||||
"status": status,
|
||||
"evaluation": evaluation,
|
||||
"priority_score": priority_score,
|
||||
"total_score": total_score,
|
||||
"cases": [], # 关联的案例ID列表,待填充
|
||||
"status": status,
|
||||
"cases": [],
|
||||
"source_file": md_path.name,
|
||||
"created_at": datetime.now().isoformat()
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"ready_at": publish_date,
|
||||
"published_at": None,
|
||||
"compliance_score": 100,
|
||||
"platform_urls": {}
|
||||
}
|
||||
|
||||
return topic
|
||||
def save_to_db(topic_dict):
|
||||
if not HAVE_DB:
|
||||
print("数据库不可用,跳过入库")
|
||||
return False
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing = db.query(DBTopic).filter(DBTopic.id == topic_dict['id']).first()
|
||||
if existing:
|
||||
# 更新字段
|
||||
for field in ['title', 'field', 'format', 'core_concept', 'audience_pain', 'unique_angle', 'priority', 'priority_score', 'total_score', 'status', 'cases', 'source_file', 'compliance_score', 'platform_urls']:
|
||||
setattr(existing, field, topic_dict.get(field, getattr(existing, field)))
|
||||
if topic_dict.get('ready_at'):
|
||||
try:
|
||||
existing.ready_at = datetime.strptime(topic_dict['ready_at'], '%Y-%m-%d').date()
|
||||
except:
|
||||
pass
|
||||
existing.updated_at = datetime.now()
|
||||
else:
|
||||
# 新增
|
||||
new_topic = DBTopic(
|
||||
id=topic_dict['id'],
|
||||
title=topic_dict['title'],
|
||||
field=topic_dict['field'],
|
||||
format=topic_dict['format'],
|
||||
core_concept=topic_dict['core_concept'],
|
||||
audience_pain=topic_dict['audience_pain'],
|
||||
unique_angle=topic_dict['unique_angle'],
|
||||
priority=topic_dict['priority'],
|
||||
priority_score=topic_dict['priority_score'],
|
||||
total_score=topic_dict['total_score'],
|
||||
status=topic_dict['status'],
|
||||
cases=topic_dict['cases'],
|
||||
source_file=topic_dict['source_file'],
|
||||
ready_at=datetime.strptime(topic_dict['ready_at'], '%Y-%m-%d').date() if topic_dict.get('ready_at') else None,
|
||||
published_at=None,
|
||||
compliance_score=topic_dict['compliance_score'],
|
||||
platform_urls=topic_dict['platform_urls'],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
db.add(new_topic)
|
||||
db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"数据库保存失败: {e}")
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def main():
|
||||
"""主函数:导入所有 Markdown 选题文件"""
|
||||
if not IDEAS_DIR.exists():
|
||||
print(f"错误:选题目录不存在 {IDEAS_DIR}")
|
||||
return
|
||||
|
||||
# 只导入主选题文件(格式:NNN-标题.md),排除 research/compliance 等辅助文件
|
||||
md_files = []
|
||||
for f in IDEAS_DIR.glob("*.md"):
|
||||
if f.name == "README.md":
|
||||
continue
|
||||
# 排除 research 和 compliance 文件
|
||||
if f.name.endswith('-research.md') or f.name.endswith('-compliance.md'):
|
||||
continue
|
||||
# 匹配 001-xxx.md 格式
|
||||
if re.match(r'^\d{3}-.+\.md$', f.name):
|
||||
md_files.append(f)
|
||||
|
||||
@@ -156,22 +189,31 @@ def main():
|
||||
topic = md_to_topic(md_file)
|
||||
topics.append(topic)
|
||||
print(f" 标题: {topic['title']}")
|
||||
print(f" ID: {topic['id']}")
|
||||
print(f" 总分: {topic['total_score']}")
|
||||
print(f" 状态: {topic['status']}")
|
||||
|
||||
# 确保输出目录存在
|
||||
# 保存 JSON 备份
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入 JSON
|
||||
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n✅ 已备份选题到 {OUTPUT_FILE}")
|
||||
|
||||
print(f"\n✅ 已导入 {len(topics)} 个选题到 {OUTPUT_FILE}")
|
||||
# 导入数据库
|
||||
if HAVE_DB:
|
||||
success_count = 0
|
||||
for t in topics:
|
||||
if save_to_db(t):
|
||||
success_count += 1
|
||||
print(f"✅ 已导入 {success_count}/{len(topics)} 个选题到数据库")
|
||||
else:
|
||||
print("⚠️ 数据库不可用,仅生成了 JSON 备份")
|
||||
|
||||
# 统计
|
||||
ready_topics = [t for t in topics if t['status'] != '已发布']
|
||||
if ready_topics:
|
||||
avg_score = sum(t['total_score'] for t in ready_topics) / len(ready_topics)
|
||||
print(f"📊 可用选题数: {len(ready_topics)}")
|
||||
avg_score = sum(t['total_score'] for t in ready_topics) / len(ready_topics) if ready_topics else 0
|
||||
print(f"🎯 平均评分: {avg_score:.1f}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
大纲阶段:基于研究笔记生成文章大纲
|
||||
大纲阶段:基于研究笔记生成文章大纲(数据库版)
|
||||
"""
|
||||
|
||||
import json, datetime, logging, sys
|
||||
@@ -10,8 +10,10 @@ from typing import Dict
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# 导入数据库辅助模块
|
||||
from db_helper import get_topic_by_id
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
RESEARCH_DIR = DATA_DIR / "research"
|
||||
OUTPUT_DIR = DATA_DIR / "outlines"
|
||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
@@ -33,11 +35,10 @@ class Outliner:
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load_topic(self) -> Dict:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
for t in topics:
|
||||
if t['id'] == self.topic_id:
|
||||
return t
|
||||
topic = get_topic_by_id(self.topic_id)
|
||||
if not topic:
|
||||
raise ValueError(f"Topic {self.topic_id} not found")
|
||||
return topic
|
||||
|
||||
def generate_outline(self) -> str:
|
||||
"""生成文章大纲 Markdown(基于模板)"""
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
研究阶段:为选题收集资料并生成研究笔记
|
||||
研究阶段:为选题收集资料并生成研究笔记(数据库版)
|
||||
"""
|
||||
|
||||
import json, datetime, logging, sys, re
|
||||
@@ -10,9 +10,11 @@ from typing import Dict, List
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# 导入数据库辅助模块
|
||||
from db_helper import get_topic_by_id
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
CASES_FILE = DATA_DIR / "sustainability_cases.json"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
OUTPUT_DIR = DATA_DIR / "research" # 研究笔记输出目录
|
||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
@@ -30,11 +32,10 @@ class Researcher:
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load_topic(self) -> Dict:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
for t in topics:
|
||||
if t['id'] == self.topic_id:
|
||||
return t
|
||||
topic = get_topic_by_id(self.topic_id)
|
||||
if not topic:
|
||||
raise ValueError(f"Topic {self.topic_id} not found")
|
||||
return topic
|
||||
|
||||
def _load_cases(self) -> List[Dict]:
|
||||
if CASES_FILE.exists():
|
||||
|
||||
+65
-7
@@ -1,10 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
|
||||
for t in data:
|
||||
if t['id'] in ['D01', 'B05']:
|
||||
t['status'] = '待处理'
|
||||
"""
|
||||
重置指定选题状态为「待处理」(数据库 + JSON 备份)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
try:
|
||||
from db_helper import update_topic_status, get_topic_by_id
|
||||
HAVE_DB = True
|
||||
except ImportError:
|
||||
HAVE_DB = False
|
||||
print("Warning: db_helper not available, will only update JSON")
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
|
||||
def reset_json_status(ids):
|
||||
try:
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
except:
|
||||
topics = []
|
||||
updated_ids = []
|
||||
for t in topics:
|
||||
if t['id'] in ids:
|
||||
t['status'] = 'pending'
|
||||
if 'ready_at' in t:
|
||||
del t['ready_at']
|
||||
json.dump(data, open('automation/data/sustainability_topics.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
|
||||
print('已重置选题状态:', [t['id'] for t in data if t['id'] in ['D01','B05']])
|
||||
updated_ids.append(t['id'])
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
return updated_ids
|
||||
|
||||
def reset_db_status(ids):
|
||||
if not HAVE_DB:
|
||||
return []
|
||||
updated = []
|
||||
for tid in ids:
|
||||
if update_topic_status(tid, 'pending'):
|
||||
updated.append(tid)
|
||||
return updated
|
||||
|
||||
def main():
|
||||
# 指定要重置的ID列表
|
||||
target_ids = ['D01', 'B05'] # 可修改
|
||||
print(f"正在重置选题状态: {target_ids}")
|
||||
|
||||
# 更新数据库
|
||||
db_updated = reset_db_status(target_ids) if HAVE_DB else []
|
||||
if db_updated:
|
||||
print(f"[DB] 已重置: {db_updated}")
|
||||
else:
|
||||
print("[DB] 未更新或数据库不可用")
|
||||
|
||||
# 更新 JSON 备份
|
||||
json_updated = reset_json_status(target_ids)
|
||||
print(f"[JSON] 已重置: {json_updated}")
|
||||
|
||||
print("完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
main()
|
||||
|
||||
+16
-58
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
撰写阶段:基于大纲和选题生成完整文章(三平台版本)
|
||||
撰写阶段:基于大纲和选题生成完整文章(三平台版本)- 数据库版
|
||||
"""
|
||||
|
||||
import json, datetime, logging, sys, re, subprocess
|
||||
@@ -22,11 +22,13 @@ except ImportError as e:
|
||||
logging.warning(f"LLM client unavailable: {e}")
|
||||
HAVE_LLM = False
|
||||
|
||||
# 导入数据库辅助模块
|
||||
from db_helper import get_topic_by_id, update_topic_status
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
OUTLINE_DIR = DATA_DIR / "outlines"
|
||||
RELEASE_DIR = DATA_DIR / "releases"
|
||||
TEMPLATES_DIR = PROJECT_ROOT / "automation" / "templates"
|
||||
@@ -59,11 +61,10 @@ class Writer:
|
||||
self.research_notes = research_file.read_text(encoding='utf-8') if research_file.exists() else ""
|
||||
|
||||
def _load_topic(self) -> Dict:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
for t in topics:
|
||||
if t['id'] == self.topic_id:
|
||||
return t
|
||||
topic = get_topic_by_id(self.topic_id)
|
||||
if not topic:
|
||||
raise ValueError(f"Topic {self.topic_id} not found")
|
||||
return topic
|
||||
|
||||
def _clean_title(self, title: str) -> str:
|
||||
"""去除标题中的指导性文字(如字数说明、MVP标记等)"""
|
||||
@@ -117,7 +118,7 @@ class Writer:
|
||||
if expanded and len(expanded.strip()) > len(content):
|
||||
return expanded.strip()
|
||||
else:
|
||||
logger.warning("LLM 扩写结果为空或过短,使用占位")
|
||||
logger.warning("LLM 扩写失败,返回占位")
|
||||
raise ValueError("Empty expansion")
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM 扩写失败: {e},使用占位内容")
|
||||
@@ -127,13 +128,11 @@ class Writer:
|
||||
return content
|
||||
|
||||
def generate_full_markdown(self) -> str:
|
||||
"""根据大纲生成完整 Markdown 正文(不用原标题,全部由 LLM 扩写生成)"""
|
||||
"""根据大纲生成完整 Markdown 正文"""
|
||||
sections = self._parse_outline_sections()
|
||||
parts = []
|
||||
|
||||
# 只保留 LLM 扩写的内容,不添加任何原始标题标记
|
||||
for sec in sections:
|
||||
# 如果内容极短,LLM 扩写后返回的完整段落中可能包含标题,我们不过滤
|
||||
if sec.get('content'):
|
||||
expanded = self._expand_section(sec)
|
||||
parts.append(expanded + "\n\n")
|
||||
@@ -153,11 +152,9 @@ class Writer:
|
||||
template = "<!DOCTYPE html><html><head><meta charset='UTF-8'><title>{{TITLE}}</title></head><body><h1>{{TITLE}}</h1><!-- CONTENT --></body></html>"
|
||||
|
||||
# 替换变量
|
||||
html = template.replace("{{TITLE}}", title).replace("{{DATE}}", TODAY).replace("{{GEN_TIME}}", GEN_TIME).replace("{{GEN_TIME}}", GEN_TIME)
|
||||
html = template.replace("{{TITLE}}", title).replace("{{DATE}}", TODAY).replace("{{GEN_TIME}}", GEN_TIME)
|
||||
|
||||
# 注入内容 (简单处理:markdown 转 HTML 可以用 marked.js 或 simple转换,这里暂时用 <pre> 包裹或简单段落化)
|
||||
# 为了快速展示,我们将 markdown 的段落转换为 <p> 标签
|
||||
# 实际中建议使用 markdown 库(如 python-markdown)转换
|
||||
# 注入内容
|
||||
html_content = self._markdown_to_html(markdown)
|
||||
html = html.replace("<!-- CONTENT -->", html_content)
|
||||
|
||||
@@ -169,7 +166,6 @@ class Writer:
|
||||
hashtags = '<div class="hashtags">#AI #可持续 #生活方式</div>'
|
||||
html = html.replace("<!-- HASHTAGS -->", hashtags)
|
||||
elif platform == "wechat":
|
||||
# 微信公众号可能还需要摘要等,模板已处理
|
||||
pass
|
||||
|
||||
return html
|
||||
@@ -193,7 +189,7 @@ class Writer:
|
||||
elif line.strip():
|
||||
html_parts.append(f"<p>{line}</p>")
|
||||
else:
|
||||
html_parts.append("") # 空行
|
||||
html_parts.append("")
|
||||
return "\n".join(html_parts)
|
||||
|
||||
def save_html(self, html: str, platform: str) -> Path:
|
||||
@@ -206,48 +202,10 @@ class Writer:
|
||||
return out_path
|
||||
|
||||
def mark_draft(self):
|
||||
"""标记选题为「待发布」,同时更新数据库"""
|
||||
# 更新 JSON 文件
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
updated = False
|
||||
for t in topics:
|
||||
if t.get('id') == self.topic_id:
|
||||
t['status'] = '待审查'
|
||||
updated = True
|
||||
break
|
||||
if updated:
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 更新数据库
|
||||
db = SessionLocal()
|
||||
try:
|
||||
topic_db = db.query(Topic).filter(Topic.id == self.topic_id).first()
|
||||
if topic_db:
|
||||
topic_db.status = '待审查'
|
||||
db.commit()
|
||||
"""标记选题为「待审查」"""
|
||||
# 更新数据库状态
|
||||
update_topic_status(self.topic_id, 'review')
|
||||
logger.info(f"选题 {self.topic_id} 状态已更新为待审查(数据库)")
|
||||
else:
|
||||
logger.warning(f"数据库中未找到选题 {self.topic_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"更新数据库失败: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info(f"选题 {self.topic_id} 状态更新为「待发布」(JSON)")
|
||||
"""标记选题为「待发布」"""
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
for t in topics:
|
||||
if t.get('id') == self.topic_id:
|
||||
t['status'] = '待审查'
|
||||
# ready_at 留空,待合规审核通过后设置
|
||||
break
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"选题 {self.topic_id} 状态更新为「待发布」")
|
||||
|
||||
def run(self):
|
||||
logger.info("开始撰写阶段")
|
||||
@@ -257,7 +215,7 @@ class Writer:
|
||||
html = self.generate_platform_html(markdown, platform)
|
||||
results[platform] = str(self.save_html(html, platform))
|
||||
self.mark_draft()
|
||||
logger.info(f"撰写完成,状态改为 draft,待合规审核")
|
||||
logger.info(f"撰写完成,状态已更新为待审查")
|
||||
return {"ok": True, "files": results}
|
||||
|
||||
def main():
|
||||
|
||||
Reference in New Issue
Block a user