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