feat: 内容数据迁移至数据库,合规审查全链路打通

- 文章 HTML 存储从文件系统迁移至 articles 表,删除 releases 目录
- 合规审查从 DB 读取 HTML,审查结果写回 DB,通过后自动推进至待发布
- 新增 todayCount 筛选按钮,与系统概览统计数据一致
- 全屏预览修复:提升 z-index 超过侧边栏,添加退出全屏/关闭按钮
- 统一 '优化' → '审查' 命名,消除前后端术语不一致
- 调度器创作完成后自动触发审查(生成 → 审查 → 待发布)
- 清理旧备份/调试文件、过期大纲和研究笔记
This commit is contained in:
Yuzhiran Dev
2026-05-13 17:33:56 +08:00
parent bc6a302e59
commit 233e23016c
234 changed files with 5670 additions and 10651 deletions
+11 -45
View File
@@ -1,73 +1,39 @@
#!/usr/bin/env python3
"""
调整选题优先级(数据库 + JSON 备份)
"""
import sys
from pathlib import Path
from datetime import datetime
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
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
from db_helper import get_topic_by_id
from app.database import SessionLocal
from app.models import Topic as DBTopic
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:
old = topic.priority_score
topic.priority_score = new_score
topic.updated_at = datetime.now()
updated.append(f"{tid}: {topic.priority_score} -> {new_score}")
updated.append(f"{tid}: {old} -> {new_score}")
db.commit()
finally:
db.close()
return updated
def main():
# 定义需要调整的优先级:ID -> 新分数
adjustments = {
'D01': 11,
'B05': 10
}
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))
updated = adjust_db_priority(adjustments)
if updated:
print("[DB] updated:", ', '.join(updated))
print("完成")
if __name__ == "__main__":
import json, datetime
main()
+4 -31
View File
@@ -4,7 +4,7 @@
遍历指定日期所有发布版本,执行合规检查,生成汇总报告
"""
import json, re, datetime
import re, datetime
from pathlib import Path
import sys
@@ -12,29 +12,11 @@ PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.compliance_checker import check_article
# 尝试导入数据库
try:
from db_helper import export_topics_to_json
HAVE_DB = True
except ImportError:
HAVE_DB = False
from db_helper import get_topic_by_id
# 配置
RELEASE_DIR = PROJECT_ROOT / "automation" / "data" / "releases"
TODAY = datetime.date.today().isoformat() # 默认今天,可修改
def load_topics_from_db():
if not HAVE_DB:
raise RuntimeError("Database not available")
topics = export_topics_to_json()
return {t['id']: t for t in topics}
def load_topics_from_json():
json_path = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
with open(json_path, 'r', encoding='utf-8') as f:
topics = json.load(f)
return {t['id']: t for t in topics}
TODAY = datetime.date.today().isoformat()
def extract_topic_id(filename: Path) -> str:
stem = filename.stem
@@ -48,14 +30,6 @@ def main(target_date: str = None):
target_date = TODAY
print(f"批量合规审查: {target_date}")
# 加载选题数据(优先DB,失败则备援JSON)
try:
topics_by_id = load_topics_from_db()
print("[数据源] 数据库")
except Exception as e:
print(f"[数据源] 数据库失败: {e}, 改用 JSON")
topics_by_id = load_topics_from_json()
release_path = RELEASE_DIR / target_date
if not release_path.exists():
print(f"错误:发布日期目录不存在 {release_path}")
@@ -68,7 +42,7 @@ def main(target_date: str = None):
for html_file in html_files:
platform = html_file.parent.name
topic_id = extract_topic_id(html_file)
topic_data = topics_by_id.get(topic_id) if topic_id else None
topic_data = get_topic_by_id(topic_id) if topic_id else None
with open(html_file, 'r', encoding='utf-8') as f:
html_content = f.read()
@@ -80,7 +54,6 @@ def main(target_date: str = None):
result['topic_title'] = topic_data.get('title') if topic_data else "未知"
results.append(result)
# 输出摘要
passed = sum(1 for r in results if r['passed'])
failed = len(results) - passed
print(f"\n✅ 通过: {passed}, ⚠️ 需人工: {failed}")
+19 -35
View File
@@ -1,46 +1,30 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
from datetime import datetime
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
try:
from db_helper import update_topic_status
from app.database import SessionLocal
from app.models import Topic as DBTopic
HAVE_DB = True
except ImportError:
HAVE_DB = False
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
from app.database import SessionLocal
from app.models import Topic as DBTopic
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()
db = SessionLocal()
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
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()
db.commit()
finally:
db.close()
def main():
adjustments = {'B05': 15}
adjust(adjustments)
adjust({'B05': 15})
print("B05 priority_score set to 15")
if __name__ == "__main__":
import json, datetime
main()
+19 -35
View File
@@ -1,46 +1,30 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
from datetime import datetime
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
try:
from db_helper import update_topic_status
from app.database import SessionLocal
from app.models import Topic as DBTopic
HAVE_DB = True
except ImportError:
HAVE_DB = False
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
from app.database import SessionLocal
from app.models import Topic as DBTopic
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()
db = SessionLocal()
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
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()
db.commit()
finally:
db.close()
def main():
adjustments = {'D01': 12}
adjust(adjustments)
adjust({'D01': 12})
print("D01 priority_score set to 12")
if __name__ == "__main__":
import json, datetime
main()
+9 -6
View File
@@ -1,7 +1,10 @@
#!/usr/bin/env python3
import json
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
for t in data:
if t['id'] == 'D01':
print(f"D01 title: {t['title']} (length: {len(t['title'])})")
print("Field:", t.get('field'))
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from db_helper import get_topic_by_id
t = get_topic_by_id('D01')
if t:
print(f"D01 title: {t['title']} (length: {len(t['title'])})")
print("Field:", t.get('field'))
+9 -5
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
import json
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
for t in data:
if t['id'] in ['D01','B05']:
print(f"{t['id']}: priority_score = {t['priority_score']}, status = {t.get('status')}")
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from db_helper import get_topic_by_id
for tid in ['D01', 'B05']:
t = get_topic_by_id(tid)
if t:
print(f"{tid}: priority_score = {t['priority_score']}, status = {t.get('status')}")
+1 -15
View File
@@ -544,21 +544,7 @@ class SustainabilityCollector:
except Exception as e:
logger.error(f"选题数据库保存失败: {e}")
# 3. 可选: 更新 JSON 备份 (仅新增,避免覆盖锁信息)
main_topics_file = DATA_DIR / "sustainability_topics.json"
try:
if main_topics_file.exists():
with open(main_topics_file, 'r', encoding='utf-8') as f:
existing_topics = json.load(f)
else:
existing_topics = []
existing_ids = {t['id'] for t in existing_topics}
new_additions = [asdict(topic) for topic in self.new_topics if topic.id not in existing_ids]
existing_topics.extend(new_additions)
with open(main_topics_file, 'w', encoding='utf-8') as f:
json.dump(existing_topics, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"JSON备份失败: {e}")
# DB 是唯一数据源,不再写 JSON
def send_wecom_notification(self):
"""发送企业微信通知"""
-438
View File
@@ -1,438 +0,0 @@
#!/usr/bin/env python3
"""
可持续性内容收集脚本
每天凌晨5:00运行,收集全球可持续性趋势信息,提炼选题和案例
"""
import os
import sys
import yaml
import json
import datetime
import logging
from pathlib import Path
import feedparser
import requests
import re
from typing import Dict, List, Optional, Tuple
import hashlib
from dataclasses import dataclass, asdict
import subprocess
# 项目根目录
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# 配置路径
CONFIG_DIR = PROJECT_ROOT / "config"
DATA_DIR = PROJECT_ROOT / "automation" / "data"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
# 日志配置
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOGS_DIR / f"collector_{TODAY}.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
@dataclass
class SustainabilitySource:
"""可持续性信息源"""
name: str
type: str # rss, web, api, report
url: str
update_frequency: str
credibility: str # high, medium, low
focus: str
@dataclass
class SustainabilityCase:
"""可持续性案例"""
id: str
country: str
category: str # 子领域:城市农业、零浪费生活等
title: str
core_idea: str
data_facts: str
global_advantage: str
china_pain_point: str
localization_suggestion: str
mvp_action: str
source_url: str
credibility_rating: str # ⭐⭐ ⭐⭐⭐
china_applicability: str # ⭐ ⭐⭐ ⭐⭐⭐
collection_date: str
status: str = "待验证"
@dataclass
class SustainabilityTopic:
"""可持续性选题"""
id: str
title: str
cases: List[str] # 关联的案例ID列表
audience: str # 目标受众
china_pain_points: str
localization_solution: str
mvp_actions: str
estimated_length: int
priority_score: float
status: str = "待创作"
class SustainabilityCollector:
"""可持续性内容收集器"""
def __init__(self):
self.load_config()
self.today_dir = DATA_DIR / "sustainability_raw" / TODAY
self.today_dir.mkdir(parents=True, exist_ok=True)
# 结果存储
self.new_cases: List[SustainabilityCase] = []
self.new_topics: List[SustainabilityTopic] = []
def load_config(self):
"""加载配置文件"""
with open(CONFIG_DIR / "sources.yaml", "r", encoding='utf-8') as f:
self.config = yaml.safe_load(f)
with open(CONFIG_DIR / "wecom_config.yaml", "r", encoding='utf-8') as f:
self.wecom_config = yaml.safe_load(f)
self.sources = []
for source_group in self.config["sustainability_sources"].values():
for source_info in source_group:
# Handle both 'url' and 'base_url' in config
source_info = source_info.copy()
if 'base_url' in source_info and 'url' not in source_info:
source_info['url'] = source_info.pop('base_url')
# Provide defaults for missing optional fields
source_info.setdefault('update_frequency', 'daily')
source_info.setdefault('focus', '可持续性')
# Filter to only fields accepted by SustainabilitySource
allowed_keys = {'name', 'type', 'url', 'update_frequency', 'credibility', 'focus'}
filtered_info = {k: v for k, v in source_info.items() if k in allowed_keys}
self.sources.append(SustainabilitySource(**filtered_info))
logger.info(f"加载了 {len(self.sources)} 个信息源")
def fetch_rss_feed(self, source: SustainabilitySource) -> List[Dict]:
"""获取RSS订阅内容"""
try:
feed = feedparser.parse(source.url)
articles = []
for entry in feed.entries[:10]: # 限制数量
# 检查是否包含可持续性关键词
content = entry.get('summary', entry.get('description', ''))
title = entry.get('title', '')
# 可持续性关键词匹配
sustainability_keywords = [
'sustainable', 'green', 'eco', 'circular', 'climate',
'carbon', 'zero waste', 'renewable', 'recycle',
'环保', '可持续', '碳中和', '循环经济', '零浪费'
]
if any(keyword.lower() in (title + content).lower() for keyword in sustainability_keywords):
articles.append({
'title': title,
'url': entry.get('link', ''),
'content': content,
'published': entry.get('published', ''),
'source_name': source.name
})
logger.info(f"从 {source.name} 获取到 {len(articles)} 篇可持续性文章")
return articles
except Exception as e:
logger.error(f"获取RSS失败 {source.name}: {e}")
return []
def fetch_web_content(self, source: SustainabilitySource) -> List[Dict]:
"""获取网页内容(简化版,实际需要更复杂的抓取)"""
# 简化实现:只记录,不实际抓取
logger.info(f"网页信息源 {source.name} 需要手动处理")
return []
def analyze_article(self, article: Dict) -> Optional[SustainabilityCase]:
"""分析文章内容,提炼案例"""
try:
content = article['content']
title = article['title']
# 提取关键数据(简化版,实际可用NLP)
data_patterns = [
r'(\d+\.?\d*)\s*(?:percent|%|百分比)',
r'(\d+\.?\d*)\s*(?:million|billion|万|亿)',
r'(\d+\.?\d*)\s*(?:tons|tonnes|吨)',
r'(\d+\.?\d*)\s*(?:reduction|increase|减少|增加)'
]
data_points = []
for pattern in data_patterns:
matches = re.findall(pattern, content, re.IGNORECASE)
if matches:
data_points.extend(matches[:3]) # 限制数量
if len(data_points) < 2:
logger.info(f"文章数据不足: {title}")
return None
# 确定国家(简化判断)
countries = ['China', 'Japan', 'Germany', 'US', 'UK', 'Sweden', 'Netherlands']
country = 'Global' # 默认
for c in countries:
if c.lower() in content.lower():
country = c
break
# 确定子领域
categories = self.config["sustainability_categories"]
category = categories[0] # 默认第一个
for cat in categories:
if any(keyword in content.lower() for keyword in [cat.lower(), cat[:4].lower()]):
category = cat
break
# 生成案例ID
case_id = f"SUS-{hashlib.md5(title.encode()).hexdigest()[:8].upper()}"
# 提取核心观点(简化版)
# 实际应用中可用AI提取,这里用前100字符
core_idea = content[:200] if len(content) > 200 else content
# 生成中国痛点(基于类别模板)
china_pains = {
"城市农业": "中国城市空间小、光照不足、怕邻居投诉",
"零浪费生活": "中国垃圾分类执行难、环保产品溢价高",
"低碳出行": "中国电动车充电难、城市规划不支持",
"循环消费": "中国二手文化不成熟、维修成本高",
"能源效率": "中国能源价格波动、设备更换成本高",
"可持续饮食": "中国预制菜泛滥、有机食品价格高",
"环保科技产品": "中国消费者关注价格多于环保"
}
china_pain = china_pains.get(category, "中国相关数据不足,需本土化验证")
# 生成案例
case = SustainabilityCase(
id=case_id,
country=country,
category=category,
title=title,
core_idea=core_idea,
data_facts=f"数据点: {', '.join(data_points[:3])}",
global_advantage="需进一步分析全球优势",
china_pain_point=china_pain,
localization_suggestion="需基于中国现实调整实施",
mvp_action="建议先小规模试点验证",
source_url=article['url'],
credibility_rating="⭐⭐" if article['source_name'] in ['GreenBiz', 'Sustainable Brands'] else "⭐",
china_applicability="⭐⭐",
collection_date=TODAY
)
return case
except Exception as e:
logger.error(f"分析文章失败: {e}")
return None
def generate_topic_from_cases(self, cases: List[SustainabilityCase]) -> Optional[SustainabilityTopic]:
"""从案例组合生成选题"""
if len(cases) < 2:
return None
# 按类别分组
category_cases = {}
for case in cases:
if case.category not in category_cases:
category_cases[case.category] = []
category_cases[case.category].append(case)
# 选择案例数最多的类别
main_category = max(category_cases, key=lambda k: len(category_cases[k]))
main_cases = category_cases[main_category]
if len(main_cases) < 2:
return None
# 生成选题ID
topic_id = f"TOPIC-.{hashlib.md5((main_category + TODAY).encode()).hexdigest()[:6].upper()}"
# 组合标题
case_titles = [case.title[:30] for case in main_cases[:2]]
topic_title = f"{main_category}新趋势: {case_titles[0]}与{case_titles[1]}的中国落地路径"
# 计算优先级分数
priority_weights = self.config["topic_priority"]
priority_score = (
priority_weights["audience_match"] * 0.8 + # 受众匹配度预估
priority_weights["data_availability"] * 0.9 + # 数据可得性
priority_weights["uniqueness"] * 0.7 + # 独特性
priority_weights["executability"] * 0.6 + # 可执行性
priority_weights["brand_fit"] * 0.9 # 品牌契合度
)
topic = SustainabilityTopic(
id=topic_id,
title=topic_title,
cases=[case.id for case in main_cases],
audience="城市焦虑青年(26-35岁)",
china_pain_points=f"{main_category}在中国面临的主要问题",
localization_solution="国际案例中国化适配方案",
mvp_actions="读者可立即尝试的3个行动",
estimated_length=2500,
priority_score=round(priority_score, 2)
)
return topic
def save_results(self):
"""保存收集结果"""
# 保存案例
cases_file = self.today_dir / "new_cases.json"
with open(cases_file, 'w', encoding='utf-8') as f:
json.dump([asdict(case) for case in self.new_cases], f, ensure_ascii=False, indent=2)
# 保存选题
topics_file = self.today_dir / "new_topics.json"
with open(topics_file, 'w', encoding='utf-8') as f:
json.dump([asdict(topic) for topic in self.new_topics], f, ensure_ascii=False, indent=2)
# 更新主数据库
self.update_main_database()
logger.info(f"保存了 {len(self.new_cases)} 个案例和 {len(self.new_topics)} 个选题")
def update_main_database(self):
"""更新主数据库(简化版)"""
# 实际应更新Notion/数据库,这里仅保存到文件
main_cases_file = DATA_DIR / "sustainability_cases.json"
main_topics_file = DATA_DIR / "sustainability_topics.json"
# 读取现有数据
existing_cases = []
existing_topics = []
if main_cases_file.exists():
with open(main_cases_file, 'r', encoding='utf-8') as f:
existing_cases = json.load(f)
if main_topics_file.exists():
with open(main_topics_file, 'r', encoding='utf-8') as f:
existing_topics = json.load(f)
# 合并新数据
all_cases = existing_cases + [asdict(case) for case in self.new_cases]
all_topics = existing_topics + [asdict(topic) for topic in self.new_topics]
# 保存(限制总数)
with open(main_cases_file, 'w', encoding='utf-8') as f:
json.dump(all_cases[:100], f, ensure_ascii=False, indent=2)
with open(main_topics_file, 'w', encoding='utf-8') as f:
json.dump(all_topics[:50], f, ensure_ascii=False, indent=2)
def send_wecom_notification(self):
"""发送企业微信通知"""
try:
# 调用通知脚本
notification_script = PROJECT_ROOT / "scripts" / "wecom_notifier.py"
if not notification_script.exists():
logger.warning("企业微信通知脚本不存在")
return
# 准备通知数据
notification_data = {
"task": "sustainability_collection",
"time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
"topic_count": len(self.new_topics),
"case_count": len(self.new_cases),
"source_count": len(self.sources),
"details_link": str(self.today_dir.relative_to(PROJECT_ROOT))
}
data_file = self.today_dir / "notification_data.json"
with open(data_file, 'w', encoding='utf-8') as f:
json.dump(notification_data, f, ensure_ascii=False)
# 运行通知脚本
result = subprocess.run(
[sys.executable, str(notification_script), str(data_file)],
capture_output=True,
text=True,
cwd=PROJECT_ROOT
)
if result.returncode == 0:
logger.info("企业微信通知发送成功")
else:
logger.error(f"通知发送失败: {result.stderr}")
except Exception as e:
logger.error(f"发送通知失败: {e}")
def run(self):
"""主运行流程"""
logger.info("开始可持续性内容收集")
# 1. 从所有信息源收集
all_articles = []
for source in self.sources:
if source.type == 'rss':
articles = self.fetch_rss_feed(source)
all_articles.extend(articles)
elif source.type == 'web':
articles = self.fetch_web_content(source)
all_articles.extend(articles)
logger.info(f"总共收集到 {len(all_articles)} 篇可持续性文章")
# 2. 分析文章,提炼案例
for article in all_articles[:20]: # 限制分析数量
case = self.analyze_article(article)
if case:
self.new_cases.append(case)
# 3. 生成选题
if self.new_cases:
topic = self.generate_topic_from_cases(self.new_cases)
if topic:
self.new_topics.append(topic)
# 4. 保存结果
self.save_results()
# 5. 发送通知
self.send_wecom_notification()
logger.info(f"收集完成: {len(self.new_cases)} 案例, {len(self.new_topics)} 选题")
return len(self.new_cases), len(self.new_topics)
def main():
"""主函数"""
try:
collector = SustainabilityCollector()
case_count, topic_count = collector.run()
# 返回结果码
if case_count > 0 or topic_count > 0:
print(f"SUCCESS: Collected {case_count} cases and {topic_count} topics")
sys.exit(0)
else:
print("WARNING: No new content found")
sys.exit(1)
except Exception as e:
logger.error(f"收集任务失败: {e}")
print(f"ERROR: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
-265
View File
@@ -1,265 +0,0 @@
#!/usr/bin/env python3
"""
内容合规审查模块
检查文章是否符合法律法规、平台规则、品牌规范
"""
import re
from typing import Dict, List, Tuple
# 敏感词库(示例,需要持续更新)
SENSITIVE_WORDS = {
"政治敏感": ["国家主席", "政治局", "常委", "军委", "统战部", "颠覆国家", "分裂主义", "台独", "疆独", "藏独"],
"违禁内容": ["赌博", "毒品", "迷药", "枪支", "炸药", "色情", "低俗", "反动", "邪教"],
"不实信息": [" guaranteed 赚钱", "一夜暴富", "100%有效", "包治百病", "绝对正确"],
"领导人相关": ["主席", "总理", "总书记", "国家领导人"] # 需上下文判断
}
# 平台规则限制
PLATFORM_RULES = {
"zhihu": {
"max_title_len": 100,
"min_word_count": 1000,
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"],
"forbidden_patterns": [r"加微信", r"私聊", r"付费咨询", r"点击领取"]
},
"wechat": {
"max_title_len": 32,
"min_word_count": 800,
"allowed_tags": ["科技", "生活", "职场", "教育", "可持续", "AI", "远程工作", "个人成长"],
"forbidden_patterns": [r"诱导分享", r"朋友圈", r"转发群"]
},
"xiaohongshu": {
"max_title_len": 50,
"min_word_count": 400,
"allowed_tags": ["生活方式", "可持续", "AI", "个人成长", "极简", "环保"],
"forbidden_patterns": [r"私信", r"加群", r"导流"]
}
}
class ComplianceChecker:
"""合规审查器"""
def __init__(self):
self.issues = []
def check_text(self, text: str, platform: str, topic_data: Dict = None) -> Dict:
"""执行全面合规检查"""
self.issues = []
# 1. 敏感词检查
self._check_sensitive_words(text)
# 2. 平台规则检查
self._check_platform_rules(text, platform)
# 3. 法律法规检查
self._check_legal_compliance(text)
# 4. 品牌调性检查
self._check_brand_guidelines(text)
# 5. 内容事实性检查(如有主题数据)
if topic_data:
self._check_factual_consistency(text, topic_data)
# 6. 最小字数检查
self._check_min_length(text, platform)
# 7. 结构完整性检查(必须包含关键章节)
self._check_required_sections(text)
return {
"passed": len(self.issues) == 0,
"issues": self.issues,
"score": max(0, 100 - len(self.issues) * 10)
}
def _check_sensitive_words(self, text: str):
"""检查敏感词"""
for category, words in SENSITIVE_WORDS.items():
for word in words:
if word in text:
self.issues.append({
"type": "敏感词",
"category": category,
"word": word,
"suggestion": f"删除或替换'{word}'"
})
def _check_platform_rules(self, text: str, platform: str):
"""检查平台特定规则"""
rules = PLATFORM_RULES.get(platform, {})
# 标题长度(从HTML中提取)
title_match = re.search(r'<title>([^<]+)</title>', text) or re.search(r'<h1[^>]*>([^<]+)</h1>', text)
if title_match and rules.get("max_title_len"):
title_len = len(title_match.group(1))
if title_len > rules["max_title_len"]:
self.issues.append({
"type": "平台规则",
"category": "标题长度",
"detail": f"标题{title_len}字,超过{platform}限制{rules['max_title_len']}字",
"suggestion": "缩短标题"
})
# 禁止的模式匹配
for pattern in rules.get("forbidden_patterns", []):
if re.search(pattern, text):
self.issues.append({
"type": "平台规则",
"category": "禁止内容",
"pattern": pattern,
"suggestion": "移除违规内容或联系方式"
})
# 标签检查(只匹配 #话题 格式,排除颜色码如 #1a1a1a)
# 标签模式:#开头,后跟字母数字,长度2-10,不全是十六进制字符
tags = re.findall(r'#([A-Za-z0-9\u4e00-\u9fa5]{2,10})', text)
# 过滤掉纯十六进制颜色码(如 #1a1a1a, #fff
tags = [t for t in tags if not re.fullmatch(r'[0-9a-fA-F]{3,6}', t)]
allowed = rules.get("allowed_tags", [])
if allowed:
for tag in tags:
if tag not in allowed:
self.issues.append({
"type": "平台规则",
"category": "标签合规",
"tag": tag,
"suggestion": f"使用平台允许的标签,如{', '.join(allowed[:3])}"
})
def _check_legal_compliance(self, text: str):
"""检查法律法规合规性"""
# 检查是否涉及国家秘密、国家安全
if re.search(r'国家机密|军事秘密|绝密|机密', text):
self.issues.append({
"type": "法律法规",
"category": "国家秘密",
"suggestion": "立即删除涉密内容"
})
# 检查是否宣传迷信、邪教
if re.search(r'算命|看相|测八字|跳大神|法轮功', text):
self.issues.append({
"type": "法律法规",
"category": "封建迷信",
"suggestion": "删除迷信内容"
})
# 检查是否赌博相关
if re.search(r'赌|博彩|下注|时时彩|六合彩', text):
self.issues.append({
"type": "法律法规",
"category": "赌博违法",
"suggestion": "删除赌博相关内容"
})
# 检查版权问题(是否使用未授权素材)
if re.search(r'版权声明.*?未经授权|转载请联系|盗用', text, re.IGNORECASE):
self.issues.append({
"type": "法律法规",
"category": "版权风险",
"suggestion": "确保所有引用已标注来源或获得授权"
})
def _check_brand_guidelines(self, text: str):
"""检查品牌调性(宇之然)"""
# 检查是否使用第一人称"我"
first_person_count = len(re.findall(r'^(我|本人|笔者)\b', text, re.MULTILINE))
if first_person_count > 2: # 允许少量情感连接
self.issues.append({
"type": "品牌规范",
"category": "人称使用",
"detail": f"发现{first_person_count}处第一人称,建议使用客观叙事",
"suggestion": "改为'实践者'、'本专栏'等客观表述"
})
# 检查是否有商业推广倾向
if re.search(r'强烈推荐|必买|最好的|最赚钱|独家', text):
self.issues.append({
"type": "品牌规范",
"category": "过度推广",
"suggestion": "使用更中立的表达,避免绝对化用语"
})
# 检查是否提及具体品牌(需模糊化)
known_brands = ["米家", "花帮主", "园艺助手", "Aerogarden"]
for brand in known_brands:
if brand in text:
self.issues.append({
"type": "品牌规范",
"category": "品牌露出",
"brand": brand,
"suggestion": f"将'{brand}'改为'一些第三方工具'或'智能设备'"
})
def _check_factual_consistency(self, text: str, topic_data: Dict):
"""检查内容与选题的一致性"""
topic = topic_data.get("topic", {})
expected_title = topic.get("title", "")
expected_field = topic.get("field", "")
# 检查标题是否出现在文章中
if expected_title and expected_title[:5] not in text:
self.issues.append({
"type": "内容质量",
"category": "主题一致性",
"detail": f"文章可能偏离选题'{expected_title}'",
"suggestion": "确认内容围绕选题展开"
})
# 检查是否有核心观点
core_concept = topic.get("core_concept", "")
if core_concept and len(core_concept) > 10:
# 核心概念应出现在前1/3内容
first_third = text[:len(text)//3]
if core_concept[:10] not in first_third:
self.issues.append({
"type": "内容质量",
"category": "核心观点",
"suggestion": "在文章前1/3部分明确阐述核心观点"
})
def _check_min_length(self, text: str, platform: str):
"""检查文章最小字数(去除HTML标签)"""
# 简单去除HTML标签
plain = re.sub(r'<[^>]+>', '', text)
word_count = len(plain.strip())
min_words = PLATFORM_RULES.get(platform, {}).get("min_word_count", 1000)
if word_count < min_words:
self.issues.append({
"type": "内容完整度",
"category": "字数不足",
"detail": f"当前{word_count}字,低于平台要求{min_words}字",
"suggestion": "扩写内容至最低要求"
})
def _check_required_sections(self, text: str):
"""检查是否包含必要章节(如引言、核心观点、总结等)"""
required_headings = [
"引言", "核心观点", "受众痛点", "总结", "行动指南"
]
missing = []
for heading in required_headings:
# 检查 h2 或 h3 中是否出现 heading
if not re.search(r'<h[23][^>]*>.*' + re.escape(heading) + r'.*</h[23]>', text, re.IGNORECASE):
missing.append(heading)
if missing:
self.issues.append({
"type": "结构完整",
"category": "章节缺失",
"detail": f"缺少必要章节:{', '.join(missing)}",
"suggestion": "补充缺失章节"
})
def check_article(html_content: str, platform: str, topic_data: Dict = None) -> Dict:
"""便捷函数:执行完整合规检查"""
checker = ComplianceChecker()
return checker.check_text(html_content, platform, topic_data)
if __name__ == "__main__":
# 测试
test_html = "<html><body><h1>测试</h1>内容涉及赌博网站</body></html>"
result = check_article(test_html, "zhihu")
print(json.dumps(result, ensure_ascii=False, indent=2))
+138 -131
View File
@@ -1,36 +1,23 @@
#!/usr/bin/env python3
"""
合规审查与优化任务(数据库版)
每天 05:45 运行,处理当天所有 draft 文章:
1. 执行合规检查(compliance_checker
2. 自动修复已知问题(标题、标签)
3. 重写合规版本
4. 更新选题状态为「待发布」
5. 生成优化报告通知
"""
import json, datetime, logging, sys, re
from pathlib import Path
from typing import Dict, List
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass, asdict
PROJECT_ROOT = Path('/root/openclaw-workspace/projects/yu-zhi-ran')
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.compliance_checker import check_article
# 导入 LLM 客户端(合规优化使用 NVIDIA)
sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
from scripts.compliance_checker import check_article
try:
from app.core.modelscope_client import call_llm
from app.core.nvidia_client import call_llm
HAVE_LLM = True
except ImportError:
HAVE_LLM = False
# 导入数据库辅助模块
from db_helper import get_topic_by_id, update_topic_status
from db_helper import get_topic_by_id, update_topic_status, get_active_llm_config, get_articles_by_topic, save_article
DATA_DIR = PROJECT_ROOT / "automation" / "data"
RELEASES_DIR = DATA_DIR / "releases"
DRAFTS_DIR = DATA_DIR / "drafts"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
@@ -39,12 +26,19 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(
handlers=[logging.FileHandler(LOGS_DIR / f"optimizer_{TODAY}.log"), logging.StreamHandler()])
logger = logging.getLogger(__name__)
# 平台白名单标签
PLATFORM_TAGS = {
"zhihu": ["科技", "职场"],
"xiaohongshu": ["AI", "可持续", "生活方式"]
}
_llm_config_cache = None
def get_llm_config():
global _llm_config_cache
if _llm_config_cache is None:
_llm_config_cache = get_active_llm_config()
return _llm_config_cache
@dataclass
class OptimizationResult:
file: str
@@ -57,22 +51,40 @@ class OptimizationResult:
status: str
def load_topic_map():
"""从数据库加载所有选题数据"""
from db_helper import export_topics_to_json
topics = export_topics_to_json()
return {t['id']: t for t in topics}
def update_topic_status_db_only(topic_id: str, status: str):
"""仅更新数据库状态(不更新JSON"""
from db_helper import update_topic_status
update_topic_status(topic_id, status)
def get_articles_from_db(topic_ids: Optional[List[str]] = None) -> List[Tuple[str, str, str]]:
"""从 articles 表读取 HTML 内容
Returns: [(html_content, platform, topic_id), ...]
"""
from db_helper import get_articles_by_topic
results = []
seen_topics = set()
if topic_ids:
for tid in topic_ids:
articles = get_articles_by_topic(tid)
for a in articles:
if a.get("html_content"):
results.append((a["html_content"], a["platform"], a["topic_id"]))
seen_topics.add(a["topic_id"])
else:
from app.database import SessionLocal
from app.models import Article
db = SessionLocal()
try:
all_articles = db.query(Article).filter(Article.html_content.isnot(None)).all()
for a in all_articles:
results.append((a.html_content, a.platform, a.topic_id))
finally:
db.close()
return results
def fix_wechat_title(html: str, title: str) -> str:
"""微信标题优化:<title>和<h1>都控制长度(考虑后缀)"""
suffix = f" - {TODAY} - 微信公众号"
max_base_len = 32 - len(suffix) # <title> 中 base 部分允许的最大长度
# 处理 <title>...</title>
max_base_len = 32 - len(suffix)
title_tag = re.search(r'<title>([^<]+)</title>', html)
if title_tag:
full_title = title_tag.group(1)
@@ -84,8 +96,6 @@ def fix_wechat_title(html: str, title: str) -> str:
base = base[:max_base_len-3] + "..."
new_full = base + suffix
html = html.replace(full_title, new_full)
# 处理 <h1>...</h1>
h1_match = re.search(r'<h1[^>]*>([^<]+)</h1>', html)
if h1_match:
current_h1 = h1_match.group(1)
@@ -93,11 +103,9 @@ def fix_wechat_title(html: str, title: str) -> str:
if len(base_h1) > 32:
base_h1 = base_h1[:29] + "..."
html = html.replace(current_h1, base_h1)
return html
def fix_tags(html: str, platform: str) -> str:
"""强制替换标签为平台白名单"""
if platform == "zhihu":
tags_str = " ".join(f"#{t}" for t in PLATFORM_TAGS["zhihu"])
if '<div class="tags">' in html:
@@ -110,7 +118,31 @@ def fix_tags(html: str, platform: str) -> str:
html = html.replace(f'<div class="hashtags">{old}</div>', f'<div class="hashtags">{tags_str}</div>')
return html
def optimize_article(html: str, platform: str, topic_data: Dict) -> (str, List[str]):
def polish_with_llm(html: str, platform: str) -> Tuple[str, Optional[str]]:
"""用 LLM 优化文章内容,返回 (html, log_message_or_None)"""
if not HAVE_LLM:
return html, None
llm_cfg = get_llm_config()
try:
model = llm_cfg.get('model') if llm_cfg else None
temperature = llm_cfg.get('temperature', 0.5) if llm_cfg else 0.5
max_tokens = llm_cfg.get('max_tokens', 4000) if llm_cfg else 4000
system_prompt = llm_cfg.get('system_prompt') if llm_cfg else "你是一个专业的内容创作助手。"
polish_prompt = f"""你是一个专业的内容润色助手。请优化以下文章内容,提升表达的专业性和可读性,保持原文事实、数据、章节结构不变,输出相同的HTML格式(保留<h2>, <h3>, <p>标签)。
原文:
{html}
优化后:"""
polished = call_llm(polish_prompt, model=model, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt)
if '<h2' in polished or '<p>' in polished:
return polished, "LLM 内容优化"
except Exception as e:
logger.warning(f"LLM 优化失败: {e}")
return html, None
def optimize_article(html: str, platform: str, topic_data: Dict) -> Tuple[str, List[str]]:
logs = []
if platform == "wechat":
html = fix_wechat_title(html, topic_data.get("title", ""))
@@ -127,124 +159,99 @@ def optimize_article(html: str, platform: str, topic_data: Dict) -> (str, List[s
src = m.group(1)
if not src.startswith('data:image/'):
logs.append(f"图片未内联: {src[:50]}... 需手动修复")
if HAVE_LLM:
try:
polish_prompt = f"""你是一个专业的内容润色助手。请优化以下文章内容,提升表达的专业性和可读性,保持原文事实、数据、章节结构不变,输出相同的HTML格式(保留<h2>, <h3>, <p>标签)。
原文:
{html}
优化后:"""
polished = call_llm(polish_prompt, temperature=0.5, max_tokens=4000)
if '<h2' in polished or '<p>' in polished:
html = polished
logs.append("LLM 内容优化(NVIDIA")
except Exception as e:
logger.warning(f"LLM 优化失败: {e}")
polished, pol_log = polish_with_llm(html, platform)
if pol_log:
html = polished
logs.append(pol_log)
return html, logs
def main(topic_ids: List[str] = None):
logger.info("=== 合规审查与优化开始 ===")
release_dir = RELEASES_DIR / TODAY
if not release_dir.exists():
logger.warning(f"今日发布目录不存在: {release_dir}")
return
llm_cfg = get_llm_config()
if llm_cfg:
logger.info(f"LLM 配置: {llm_cfg['name']} (model={llm_cfg['model']})")
else:
logger.info("LLM 配置: 使用环境变量默认值")
articles = get_articles_from_db(topic_ids)
if not articles:
logger.warning("未找到任何文章(可能尚未创作或同步到 DB)")
report_file = DRAFTS_DIR / TODAY / "optimization_report.json"
report_file.parent.mkdir(parents=True, exist_ok=True)
with open(report_file, 'w', encoding='utf-8') as f:
json.dump({
"date": TODAY,
"summary": {"total_articles": 0, "passed_auto": 0, "need_manual": 0, "average_score": 0},
"details": [], "all_passed": True
}, f, ensure_ascii=False, indent=2)
print("OPTIMIZATION_COMPLETE: 0 articles found")
sys.exit(0)
topic_map = load_topic_map()
results = []
all_passed = True
for platform_dir in ["zhihu", "wechat", "xiaohongshu"]:
platform_path = release_dir / platform_dir
if not platform_path.exists():
for html, platform_dir, topic_id in articles:
topic_data = topic_map.get(topic_id)
if not topic_data:
logger.warning(f"未找到选题: {topic_id}")
continue
for html_file in platform_path.glob("*.html"):
stem = html_file.stem
parts = stem.split('_')
if len(parts) < 2:
continue
topic_id = parts[1]
if topic_ids is not None and topic_id not in topic_ids:
continue
topic_data = topic_map.get(topic_id)
if not topic_data:
logger.warning(f"未找到选题: {topic_id}")
continue
html = html_file.read_text(encoding='utf-8')
check_result = check_article(html, platform_dir, topic_data)
issues = check_result['issues']
score = check_result['score']
check_result = check_article(html, platform_dir, topic_data)
issues = check_result['issues']
score = check_result['score']
label = f"{platform_dir}/{topic_id}"
if any(issue['type'] == '平台规则' for issue in issues):
optimized_html, opt_logs = optimize_article(html, platform_dir, topic_data)
recheck = check_article(optimized_html, platform_dir, topic_data)
if recheck['passed']:
html_file.write_text(optimized_html, encoding='utf-8')
logger.info(f"{html_file.name}优化并通过合规检查")
results.append(OptimizationResult(
file=str(html_file.relative_to(PROJECT_ROOT)),
platform=platform_dir,
topic_id=topic_id,
title=topic_data.get('title',''),
original_issues=len(issues),
fixed_issues=len(issues) - len(recheck['issues']),
final_score=recheck['score'],
status="passed"
))
update_topic_status_db_only(topic_id, 'pending')
else:
logger.warning(f"⚠️ {html_file.name} 优化后仍有问题,需人工审核")
results.append(OptimizationResult(
file=str(html_file.relative_to(PROJECT_ROOT)),
platform=platform_dir,
topic_id=topic_id,
title=topic_data.get('title',''),
original_issues=len(issues),
fixed_issues=len(issues) - len(recheck['issues']),
final_score=recheck['score'],
status="manual_review"
))
all_passed = False
else:
if issues:
optimized_html, opt_logs = optimize_article(html, platform_dir, topic_data)
recheck = check_article(optimized_html, platform_dir, topic_data)
if recheck['passed']:
save_article(topic_id, platform_dir, optimized_html)
logger.info(f"{label}修复并通过审查 ({len(issues)} issues fixed)")
results.append(OptimizationResult(
file=str(html_file.relative_to(PROJECT_ROOT)),
file=f"db:{platform_dir}_{topic_id}",
platform=platform_dir,
topic_id=topic_id,
title=topic_data.get('title',''),
original_issues=0,
fixed_issues=0,
final_score=score,
status="passed" if check_result['passed'] else "manual_review"
original_issues=len(issues),
fixed_issues=len(issues) - len(recheck['issues']),
final_score=recheck['score'],
status="passed"
))
if not check_result['passed']:
all_passed = False
else:
logger.warning(f"⚠️ {label} 仍有 {len(recheck['issues'])} 个问题需人工处理")
results.append(OptimizationResult(
file=f"db:{platform_dir}_{topic_id}",
platform=platform_dir,
topic_id=topic_id,
title=topic_data.get('title',''),
original_issues=len(issues),
fixed_issues=len(issues) - len(recheck['issues']),
final_score=recheck['score'],
status="manual_review"
))
all_passed = False
else:
results.append(OptimizationResult(
file=f"db:{platform_dir}_{topic_id}",
platform=platform_dir,
topic_id=topic_id,
title=topic_data.get('title',''),
original_issues=0,
fixed_issues=0,
final_score=score,
status="passed"
))
logger.info(f"{label} 合规检查通过 ({score}分)")
# 更新选题状态 (JSON + 数据库)
passed_ids = set()
for res in results:
if res.status == "passed":
tid = res.topic_id
# 更新数据库状态为 'pending'(待发布)
update_topic_status(tid, 'ready')
# 可选:同时更新 JSON 以保持兼容
# (已废弃,但保留更新,避免其他组件出错)
try:
json_path = DATA_DIR / "sustainability_topics.json"
with open(json_path, 'r', encoding='utf-8') as f:
topics = json.load(f)
for t in topics:
if t.get('id') == tid:
t['status'] = 'ready'
t['ready_at'] = TODAY
t['compliance_score'] = res.final_score
break
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(topics, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"更新 JSON 失败: {e}")
passed_ids.add(res.topic_id)
for tid in passed_ids:
update_topic_status(tid, 'ready')
logger.info(f"选题 {tid} 状态 → ready(待发布)")
# 生成报告
report_file = DRAFTS_DIR / TODAY / "optimization_report.json"
report_file.parent.mkdir(parents=True, exist_ok=True)
report = {
@@ -261,7 +268,7 @@ def main(topic_ids: List[str] = None):
with open(report_file, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
logger.info(f"✅ 合规优化完成: {len(results)} 篇文章, {sum(1 for r in results if r.status=='passed')}自动通过")
logger.info(f"✅ 合规审查完成: {len(results)} 篇文章, {sum(1 for r in results if r.status=='passed')} 篇通过")
print(f"OPTIMIZATION_COMPLETE: {len(results)} articles, {sum(1 for r in results if r.status=='passed')} passed, {sum(1 for r in results if r.status=='manual_review')} need manual review")
sys.exit(0)
+12 -9
View File
@@ -14,7 +14,6 @@ sys.path.insert(0, str(PROJECT_ROOT))
from db_helper import get_topic_by_id, get_next_topic, update_topic_status
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TOPICS_FILE = DATA_DIR / "sustainability_topics.json" # 保留用于备份
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
@@ -29,26 +28,30 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
def select_next_topic(topic_id: str = None) -> Dict:
"""选择并锁定要创作的选题(从数据库"""
"""选择并锁定要创作的选题(趋势引擎匹配 → 回退优先级"""
if topic_id:
# 指定ID,查询数据库
topic = get_topic_by_id(topic_id)
if not topic:
raise ValueError(f"Topic {topic_id} not found")
# 检查状态:禁止已发布状态重新创作
current_status = topic.get('status')
if current_status in ['已发布', 'published']:
raise ValueError(f"Topic {topic_id} is already published, cannot recreate")
# 更新状态为「审查中」表示已经开始处理
update_topic_status(topic_id, 'review')
return topic
# 自动选择:下一个待处理的选题
try:
from topic_selector import select_best_topic as engine_select
topic = engine_select()
if topic:
logger.info(f"选题引擎推荐: {topic['id']} {topic['title']}")
update_topic_status(topic['id'], 'review')
return topic
except Exception as e:
logger.warning(f"选题引擎失效,回退简单策略: {e}")
topic = get_next_topic(priority='') or get_next_topic()
if not topic:
raise ValueError("No available topics to create (all locked or wrong status)")
# 更新状态为「审查中」表示已锁定
update_topic_status(topic['id'], 'review')
return topic
-208
View File
@@ -1,208 +0,0 @@
#!/usr/bin/env python3
"""
内容创作脚本(修复版)
支持:标题长度限制、标签合规、状态流程
"""
import os, sys, yaml, json, datetime, logging, random
from pathlib import Path
from typing import Dict, List
import subprocess
from dataclasses import dataclass, asdict
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.image_generator import ImageGenerator
CONFIG_DIR = PROJECT_ROOT / "config"
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TEMPLATES_DIR = PROJECT_ROOT / "automation" / "templates"
IMAGES_DIR = PROJECT_ROOT / "automation" / "images"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler(LOGS_DIR / f"creator_{TODAY}.log"), logging.StreamHandler()])
logger = logging.getLogger(__name__)
@dataclass
class ContentArticle:
id: str
topic_id: str
title: str
platform: str
content: str
image_paths: List[str]
metadata: dict
created_date: str
output_dir: str
status: str = "draft" # draft, pending_review, ready_for_publish, published
class ContentCreator:
def __init__(self):
self.articles = []
self.release_dir = DATA_DIR / "releases" / TODAY
self.today_dir = DATA_DIR / "drafts" / TODAY
def load_config(self):
config_file = CONFIG_DIR / "wecom_config.yaml"
if not config_file.exists():
logger.warning("配置文件不存在,使用默认")
self.wecom_config = {"content_rules": {}}
return
with open(config_file, 'r', encoding='utf-8') as f:
self.wecom_config = yaml.safe_load(f)
logger.info("配置加载完成")
def select_topic_for_today(self):
topics_file = DATA_DIR / "sustainability_topics.json"
if not topics_file.exists():
logger.error("选题库文件不存在")
return None
with open(topics_file, 'r', encoding='utf-8') as f:
all_topics = json.load(f)
available = [t for t in all_topics if t.get("status") != "已发布" and t.get("status") != "待发布"]
if not available:
logger.warning("没有可选选题")
return None
selected = max(available, key=lambda t: t.get("priority_score", 0))
logger.info(f"选择了选题: {selected.get('title')} (优先级: {selected.get('priority_score')})")
return {"topic": selected, "cases": []}
def create_content(self, topic_data: Dict) -> str:
topic = topic_data["topic"]
title = topic.get("title", "")
sections = [
f"<h2>{title}</h2>",
f"<p>今天是{TODAY},我们探讨「{title}」。根据全球案例与本土分析,给出以下建议:</p>",
"<h3>核心观点</h3><p>" + topic.get("core_concept", "待补充") + "</p>",
"<h3>目标受众痛点</h3><p>" + topic.get("audience_pain", "待补充") + "</p>",
"<h3>本土化方案</h3><ul><li>" + topic.get("unique_angle", "待补充") + "</li></ul>",
"<h3>MVP行动</h3><ol><li>记录现状</li><li>小步尝试</li><li>评估效果</li><li>建立习惯</li></ol>",
"<p>(本文由宇之然AI助手生成,数据来源可靠,内容经合规审查)</p>"
]
return "\n".join(sections)
def generate_images(self, title: str) -> Dict[str, str]:
generator = ImageGenerator()
try:
generated = generator.generate_all_placeholders(title, platform="zhihu")
return {k: str(v) for k, v in generated.items()}
except Exception as e:
logger.error(f"图片生成失败: {e}")
return {}
def create_html_for_platform(self, content: str, images: Dict[str, str], platform: str, title: str) -> str:
template_path = TEMPLATES_DIR / f"{platform}.html"
if template_path.exists():
with open(template_path, 'r', encoding='utf-8') as f:
template = f.read()
else:
template = "<!DOCTYPE html><html><body>{{TITLE}}<hr><!-- CONTENT --></body></html>"
# 插入图片标记
for marker, path in images.items():
if path:
img_tag = f'<img src="{path}" alt="{marker}">'
content = content.replace(f"[IMAGE: {marker}]", img_tag)
# 平台特定处理
extra = ""
if platform == "zhihu":
# 使用平台允许的标签(科技、职场、AI都在允许列表)
extra = '<div class="tags">#科技 #职场 #AI</div>'
elif platform == "wechat":
# 截断标题
if len(title) > 32:
title = title[:29] + "..."
abstract = content[:100] + "..."
extra = f'<p class="abstract">{abstract}</p>'
elif platform == "xiaohongshu":
# 小红书允许标签:生活方式、可持续、AI
extra = '<div class="hashtags">#AI #科技 #生活方式</div>'
full_content = content + extra
html = template.replace("<!-- CONTENT -->", full_content)
html = html.replace("{{DATE}}", TODAY)
html = html.replace("{{TITLE}}", title)
return html
def mark_topic_ready(self, topic_id: str):
"""标记选题为「待发布」(审查通过)"""
topics_file = DATA_DIR / "sustainability_topics.json"
with open(topics_file, 'r', encoding='utf-8') as f:
topics = json.load(f)
for t in topics:
if t.get("id") == topic_id:
t["status"] = "待发布"
t["ready_at"] = TODAY
break
with open(topics_file, 'w', encoding='utf-8') as f:
json.dump(topics, f, ensure_ascii=False, indent=2)
logger.info(f"选题 {topic_id} 已标记为「待发布」")
def run(self):
logger.info("开始内容创作")
self.load_config()
topic_data = self.select_topic_for_today()
if not topic_data:
logger.error("未能选择选题,任务结束")
return False
content = self.create_content(topic_data)
images = self.generate_images(topic_data["topic"].get("title", "内容"))
topic_id = topic_data["topic"]["id"]
title = topic_data["topic"]["title"]
for platform in ["zhihu", "wechat", "xiaohongshu"]:
html = self.create_html_for_platform(content, images, platform, title)
article = ContentArticle(
id=f"{topic_id}_{platform}",
topic_id=topic_id,
title=title,
platform=platform,
content=html,
image_paths=list(images.values()),
metadata={"platform": platform, "topic": topic_data["topic"]},
created_date=TODAY,
output_dir=str(self.release_dir / platform),
status="draft"
)
self.save_article(article)
self.articles.append(article)
# 标记为待发布(而不是已发布)
self.mark_topic_ready(topic_id)
# 发送通知(可选)
logger.info(f"创作完成: {len(self.articles)} 篇文章,状态:待发布")
return True
def save_article(self, article: ContentArticle):
output_dir = Path(article.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
html_file = output_dir / f"{article.platform}_{article.id}.html"
with open(html_file, 'w', encoding='utf-8') as f:
f.write(article.content)
meta_file = output_dir / f"{article.platform}_{article.id}.json"
with open(meta_file, 'w', encoding='utf-8') as f:
json.dump(asdict(article), f, ensure_ascii=False, indent=2)
logger.info(f"保存了 {article.platform} 版本: {html_file}")
def main():
try:
creator = ContentCreator()
success = creator.run()
if success:
print(f"SUCCESS: Created {len(creator.articles)} articles for {TODAY} (status: 待发布)")
sys.exit(0)
else:
print("WARNING: Content creation failed")
sys.exit(1)
except Exception as e:
logger.error(f"创作任务失败: {e}")
print(f"ERROR: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
-231
View File
@@ -1,231 +0,0 @@
#!/usr/bin/env python3
"""
内容创作脚本(最终修复版)
- 标题长度:考虑模板后缀,整体限制在32字内(微信)
- 标签按领域动态映射(知乎、小红书)
- 状态:生成后为「待发布」,人工发布后手动改为「已发布」
"""
import os, sys, yaml, json, datetime, logging
from pathlib import Path
from typing import Dict, List
from dataclasses import dataclass, asdict
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.image_generator import ImageGenerator
CONFIG_DIR = PROJECT_ROOT / "config"
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TEMPLATES_DIR = PROJECT_ROOT / "automation" / "templates"
IMAGES_DIR = PROJECT_ROOT / "automation" / "images"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler(LOGS_DIR / f"creator_{TODAY}.log"), logging.StreamHandler()])
logger = logging.getLogger(__name__)
@dataclass
class ContentArticle:
id: str
topic_id: str
title: str
platform: str
content: str
image_paths: List[str]
metadata: dict
created_date: str
output_dir: str
status: str = "draft" # draft → pending_review → ready_for_publish → published
class ContentCreator:
def __init__(self):
self.articles = []
self.release_dir = DATA_DIR / "releases" / TODAY
self.today_dir = DATA_DIR / "drafts" / TODAY
def load_config(self):
config_file = CONFIG_DIR / "wecom_config.yaml"
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
self.wecom_config = yaml.safe_load(f)
else:
self.wecom_config = {"content_rules": {}}
logger.info("配置加载完成")
def select_topic_for_today(self):
topics_file = DATA_DIR / "sustainability_topics.json"
with open(topics_file, 'r', encoding='utf-8') as f:
all_topics = json.load(f)
available = [t for t in all_topics if t.get("status") not in ["已发布", "待发布"]]
if not available:
logger.warning("没有可选选题")
return None
selected = max(available, key=lambda t: t.get("priority_score", 0))
logger.info(f"选择了选题: {selected.get('title')} (优先级: {selected.get('priority_score')})")
return {"topic": selected, "cases": []}
def create_content(self, topic_data: Dict) -> str:
topic = topic_data["topic"]
title = topic.get("title", "")
# 使用真实字段构建内容
sections = [
f"<h2>{title}</h2>",
f"<p>今天是{TODAY},我们探讨「{title}」。基于全球案例与本土实践,提供可执行的建议。</p>",
"<h3>核心观点</h3><p>" + topic.get("core_concept", "待补充") + "</p>",
"<h3>目标受众痛点</h3><p>" + topic.get("audience_pain", "待补充") + "</p>",
"<h3>独特视角</h3><p>" + topic.get("unique_angle", "待补充") + "</p>",
"<h3>MVP行动</h3><ol><li>理解现状</li><li>小范围试验</li><li>评估效果</li><li>形成习惯</li></ol>",
"<p>(本文由宇之然AI助手生成,数据来源可靠,内容经合规审查)</p>"
]
return "\n".join(sections)
def generate_images(self, title: str) -> Dict[str, str]:
generator = ImageGenerator()
try:
return generator.generate_all_placeholders(title, platform="zhihu")
except Exception as e:
logger.error(f"图片生成失败: {e}")
return {}
def create_html_for_platform(self, content: str, images: Dict[str, str], platform: str, topic_data: Dict, title: str) -> str:
template_path = TEMPLATES_DIR / f"{platform}.html"
if template_path.exists():
with open(template_path, 'r', encoding='utf-8') as f:
template = f.read()
else:
template = "<!DOCTYPE html><html><body>{{TITLE}}<hr><!-- CONTENT --></body></html>"
# 插入图片(将 Path 转换为字符串)
for marker, path in images.items():
if path:
img_src = str(path)
content = content.replace(f"[IMAGE: {marker}]", f'<img src="{img_src}" alt="{marker}">')
# 平台特定附加内容与标签(动态)
extra_html = ""
field = topic_data.get("topic", {}).get("field", "未来工作方式")
if platform == "zhihu":
tag_map = {
"未来工作方式": "#科技 #职场 #AI",
"可持续生活系统": "#可持续 #生活 #环保",
"个人知识工厂": "#知识管理 #个人成长 #效率",
"科技人文交叉": "#科技 #人文 #AI伦理"
}
tags = tag_map.get(field, "#科技 #生活 #AI")
extra_html = f'<div class="tags">{tags} #2026年趋势</div>'
elif platform == "wechat":
abstract = content[:100] + "..."
extra_html = f'<p class="abstract">{abstract}</p>'
elif platform == "xiaohongshu":
hashtag_map = {
"未来工作方式": "#远程工作 #数字游民 #AI副业",
"可持续生活系统": "#可持续生活 #零浪费 #环保",
"个人知识工厂": "#第二大脑 #PKM #个人成长",
"科技人文交叉": "#科技 #AI伦理 #数字健康"
}
hashtags = hashtag_map.get(field, "#可持续生活 #全球视野 #宇之然")
extra_html = f'<div class="hashtags">{hashtags}</div>'
full_content = content + extra_html
html = template.replace("<!-- CONTENT -->", full_content)
# 标题与日期处理(微信需整体截断)
date_str = TODAY
if platform == "wechat":
# 模板产生的完整标题:title - date - 微信公众号
suffix = f" - {date_str} - 微信公众号"
max_title_len = 32 - len(suffix)
if len(title) > max_title_len:
title = title[:max_title_len-3] + "..."
full_title = title + suffix
else:
full_title = title
html = html.replace("{{DATE}}", date_str)
html = html.replace("{{TITLE}}", full_title)
return html
def mark_topic_ready(self, topic_id: str):
"""标记选题为「待发布」(审查通过)"""
topics_file = DATA_DIR / "sustainability_topics.json"
with open(topics_file, 'r', encoding='utf-8') as f:
topics = json.load(f)
for t in topics:
if t.get("id") == topic_id:
t["status"] = "待发布"
t["ready_at"] = TODAY
break
with open(topics_file, 'w', encoding='utf-8') as f:
json.dump(topics, f, ensure_ascii=False, indent=2)
logger.info(f"选题 {topic_id} 已标记为「待发布」")
def run(self):
logger.info("开始内容创作")
self.load_config()
topic_data = self.select_topic_for_today()
if not topic_data:
logger.error("未能选择选题,任务结束")
return False
content = self.create_content(topic_data)
images = self.generate_images(topic_data["topic"].get("title", "内容"))
topic_id = topic_data["topic"]["id"]
title = topic_data["topic"]["title"]
for platform in ["zhihu", "wechat", "xiaohongshu"]:
html = self.create_html_for_platform(content, images, platform, topic_data, title)
article = ContentArticle(
id=f"{topic_id}_{platform}",
topic_id=topic_id,
title=title,
platform=platform,
content=html,
image_paths=[str(p) for p in images.values()],
metadata={"platform": platform, "topic": topic_data["topic"]},
created_date=TODAY,
output_dir=str(self.release_dir / platform),
status="pending_review" # 生成后待审查
)
self.save_article(article)
self.articles.append(article)
# 标记选题为待发布(审查通过)
self.mark_topic_ready(topic_id)
logger.info(f"创作完成: {len(self.articles)} 篇文章,状态:待发布")
return True
def save_article(self, article: ContentArticle):
output_dir = Path(article.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
html_file = output_dir / f"{article.platform}_{article.id}.html"
with open(html_file, 'w', encoding='utf-8') as f:
f.write(article.content)
meta_file = output_dir / f"{article.platform}_{article.id}.json"
# 序列化前转换 Path 对象为字符串
article_dict = asdict(article)
article_dict['image_paths'] = [str(p) for p in article.image_paths]
json.dump(article_dict, open(meta_file, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
logger.info(f"保存了 {article.platform} 版本: {html_file}")
def main():
try:
creator = ContentCreator()
success = creator.run()
if success:
print(f"SUCCESS: Created {len(creator.articles)} articles for {TODAY} (status: 待发布)")
sys.exit(0)
else:
print("WARNING: Content creation failed")
sys.exit(1)
except Exception as e:
logger.error(f"创作任务失败: {e}")
print(f"ERROR: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
-208
View File
@@ -1,208 +0,0 @@
#!/usr/bin/env python3
"""
内容创作脚本(最终修复版 v3
- 知乎标签:严格使用 #科技 #职场
- 小红书标签:严格使用 #AI #可持续 #生活方式
- 微信标题截断:整体长度≤32字
"""
import os, sys, yaml, json, datetime, logging
from pathlib import Path
from typing import Dict, List
from dataclasses import dataclass, asdict
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.image_generator import ImageGenerator
CONFIG_DIR = PROJECT_ROOT / "config"
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TEMPLATES_DIR = PROJECT_ROOT / "automation" / "templates"
IMAGES_DIR = PROJECT_ROOT / "automation" / "images"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler(LOGS_DIR / f"creator_{TODAY}.log"), logging.StreamHandler()])
logger = logging.getLogger(__name__)
@dataclass
class ContentArticle:
id: str
topic_id: str
title: str
platform: str
content: str
image_paths: List[str]
metadata: dict
created_date: str
output_dir: str
status: str = "pending_review"
class ContentCreator:
def __init__(self):
self.articles = []
self.release_dir = DATA_DIR / "releases" / TODAY
def load_config(self):
config_file = CONFIG_DIR / "wecom_config.yaml"
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
self.wecom_config = yaml.safe_load(f)
else:
self.wecom_config = {"content_rules": {}}
logger.info("配置加载完成")
def select_topic_for_today(self):
topics_file = DATA_DIR / "sustainability_topics.json"
with open(topics_file, 'r', encoding='utf-8') as f:
all_topics = json.load(f)
available = [t for t in all_topics if t.get("status") not in ["已发布", "待发布"]]
if not available:
logger.warning("没有可选选题")
return None
selected = max(available, key=lambda t: t.get("priority_score", 0))
logger.info(f"选择了选题: {selected.get('title')} (优先级: {selected.get('priority_score')})")
return {"topic": selected, "cases": []}
def create_content(self, topic_data: Dict) -> str:
topic = topic_data["topic"]
title = topic.get("title", "")
sections = [
f"<h2>{title}</h2>",
f"<p>今天是{TODAY},我们探讨「{title}」。基于全球案例与本土实践,提供可执行的建议。</p>",
"<h3>核心观点</h3><p>" + topic.get("core_concept", "待补充") + "</p>",
"<h3>目标受众痛点</h3><p>" + topic.get("audience_pain", "待补充") + "</p>",
"<h3>独特视角</h3><p>" + topic.get("unique_angle", "待补充") + "</p>",
"<h3>MVP行动</h3><ol><li>理解现状</li><li>小范围试验</li><li>评估效果</li><li>形成习惯</li></ol>",
"<p>(本文由宇之然AI助手生成,数据来源可靠,内容经合规审查)</p>"
]
return "\n".join(sections)
def generate_images(self, title: str) -> Dict[str, str]:
generator = ImageGenerator()
try:
return generator.generate_all_placeholders(title, platform="zhihu")
except Exception as e:
logger.error(f"图片生成失败: {e}")
return {}
def create_html_for_platform(self, content: str, images: Dict[str, str], platform: str, topic_data: Dict, title: str) -> str:
template_path = TEMPLATES_DIR / f"{platform}.html"
if template_path.exists():
with open(template_path, 'r', encoding='utf-8') as f:
template = f.read()
else:
template = "<!DOCTYPE html><html><body>{{TITLE}}<hr><!-- CONTENT --></body></html>"
# 插入图片(Path 转字符串)
for marker, path in images.items():
if path:
content = content.replace(f"[IMAGE: {marker}]", f'<img src="{str(path)}" alt="{marker}">')
# 平台特定附加内容
extra_html = ""
if platform == "zhihu":
# 知乎仅允许标签:科技, 生活, 职场
extra_html = '<div class="tags">#科技 #职场</div>'
elif platform == "wechat":
abstract = content[:100] + "..."
extra_html = f'<p class="abstract">{abstract}</p>'
elif platform == "xiaohongshu":
# 小红书允许:生活方式, 可持续, AI
extra_html = '<div class="hashtags">#AI #可持续 #生活方式</div>'
full_content = content + extra_html
html = template.replace("<!-- CONTENT -->", full_content)
# 标题与日期处理(微信需整体截断)
date_str = TODAY
if platform == "wechat":
suffix = f" - {date_str} - 微信公众号"
max_len = 32 - len(suffix)
if len(title) > max_len:
title = title[:max_len-3] + "..."
full_title = title + suffix
else:
full_title = title
html = html.replace("{{DATE}}", date_str)
html = html.replace("{{TITLE}}", full_title)
return html
def mark_topic_ready(self, topic_id: str):
topics_file = DATA_DIR / "sustainability_topics.json"
with open(topics_file, 'r', encoding='utf-8') as f:
topics = json.load(f)
for t in topics:
if t.get("id") == topic_id:
t["status"] = "待发布"
t["ready_at"] = TODAY
break
with open(topics_file, 'w', encoding='utf-8') as f:
json.dump(topics, f, ensure_ascii=False, indent=2)
logger.info(f"选题 {topic_id} 已标记为「待发布」")
def run(self):
logger.info("开始内容创作")
self.load_config()
topic_data = self.select_topic_for_today()
if not topic_data:
logger.error("未能选择选题,任务结束")
return False
content = self.create_content(topic_data)
images = self.generate_images(topic_data["topic"].get("title", "内容"))
topic_id = topic_data["topic"]["id"]
title = topic_data["topic"]["title"]
for platform in ["zhihu", "wechat", "xiaohongshu"]:
html = self.create_html_for_platform(content, images, platform, topic_data, title)
article = ContentArticle(
id=f"{topic_id}_{platform}",
topic_id=topic_id,
title=title,
platform=platform,
content=html,
image_paths=[str(p) for p in images.values()],
metadata={"platform": platform, "topic": topic_data["topic"]},
created_date=TODAY,
output_dir=str(self.release_dir / platform),
status="pending_review"
)
self.save_article(article)
self.articles.append(article)
self.mark_topic_ready(topic_id)
logger.info(f"创作完成: {len(self.articles)} 篇文章,状态:待发布")
return True
def save_article(self, article: ContentArticle):
output_dir = Path(article.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
html_file = output_dir / f"{article.platform}_{article.id}.html"
with open(html_file, 'w', encoding='utf-8') as f:
f.write(article.content)
meta_file = output_dir / f"{article.platform}_{article.id}.json"
article_dict = asdict(article)
article_dict['image_paths'] = [str(p) for p in article.image_paths]
json.dump(article_dict, open(meta_file, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
logger.info(f"保存了 {article.platform} 版本: {html_file}")
def main():
try:
creator = ContentCreator()
success = creator.run()
if success:
print(f"SUCCESS: Created {len(creator.articles)} articles for {TODAY} (status: 待发布)")
sys.exit(0)
else:
print("WARNING: Content creation failed")
sys.exit(1)
except Exception as e:
logger.error(f"创作任务失败: {e}")
print(f"ERROR: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
-651
View File
@@ -1,651 +0,0 @@
#!/usr/bin/env python3
"""
内容创作脚本
每天凌晨5:30运行,从选题库选出最适合当天发布的题目,创作内容,生成图片内联HTML
"""
import os
import sys
import yaml
import json
import datetime
import logging
import random
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import hashlib
from dataclasses import dataclass, asdict
import subprocess
import re
# 项目根目录
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# 导入图片生成器
from scripts.image_generator import ImageGenerator
# 配置路径
CONFIG_DIR = PROJECT_ROOT / "config"
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TEMPLATES_DIR = PROJECT_ROOT / "automation" / "templates"
IMAGES_DIR = PROJECT_ROOT / "automation" / "images"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
# 日志配置
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOGS_DIR / f"creator_{TODAY}.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
@dataclass
class ContentArticle:
"""内容文章"""
id: str
topic_id: str
title: str
platform: str # zhihu, wechat, xiaohongshu
content: str # HTML内容
image_paths: List[str]
metadata: Dict
created_date: str
output_dir: str
class ContentCreator:
"""内容创作器"""
def __init__(self):
self.load_config()
self.today_dir = DATA_DIR / "drafts" / TODAY
self.today_dir.mkdir(parents=True, exist_ok=True)
self.release_dir = DATA_DIR / "releases" / TODAY
self.release_dir.mkdir(parents=True, exist_ok=True)
# 结果存储
self.articles: List[ContentArticle] = []
def load_config(self):
"""加载配置文件"""
with open(CONFIG_DIR / "sources.yaml", "r", encoding='utf-8') as f:
self.config = yaml.safe_load(f)
with open(CONFIG_DIR / "wecom_config.yaml", "r", encoding='utf-8') as f:
self.wecom_config = yaml.safe_load(f)
# 平台规则
self.platform_rules = self.wecom_config["content_rules"]
logger.info("配置加载完成")
def select_topic_for_today(self) -> Optional[Dict]:
"""选择最适合当天发布的选题"""
topics_file = DATA_DIR / "sustainability_topics.json"
if not topics_file.exists():
logger.error("选题库文件不存在")
return None
with open(topics_file, 'r', encoding='utf-8') as f:
all_topics = json.load(f)
if not all_topics:
logger.warning("选题库为空")
return None
# 选择策略:优先选择高优先级、未发布、匹配当天趋势的选题
available_topics = [t for t in all_topics if t.get("status") != "已发布"]
if not available_topics:
logger.warning("没有未发布的选题")
return None
# 简单策略:选择优先级最高的
selected = max(available_topics, key=lambda t: t.get("priority_score", 0))
# 获取相关案例
case_ids = selected.get("cases", [])
cases_file = DATA_DIR / "sustainability_cases.json"
related_cases = []
if cases_file.exists():
with open(cases_file, 'r', encoding='utf-8') as f:
all_cases = json.load(f)
related_cases = [c for c in all_cases if c.get("id") in case_ids]
logger.info(f"选择了选题: {selected.get('title')} (优先级: {selected.get('priority_score')})")
return {"topic": selected, "cases": related_cases}
def create_content(self, topic_data: Dict) -> str:
"""基于选题创作内容"""
topic = topic_data["topic"]
cases = topic_data["cases"]
# 核心内容结构
sections = [
self._create_introduction(topic, cases),
self._create_global_cases_section(cases),
self._create_china_pain_analysis(topic),
self._create_localization_solution(topic),
self._create_mvp_actions(topic),
self._create_conclusion(topic)
]
content = "\n\n".join(sections)
# 插入图片标记
image_markers = [
"[IMAGE: cover]",
"[IMAGE: data_chart]",
"[IMAGE: case_comparison]",
"[IMAGE: action_checklist]"
]
# 在合适位置插入图片标记
content_with_images = self._insert_image_markers(content, image_markers)
return content_with_images
def _create_introduction(self, topic: Dict, cases: List[Dict]) -> str:
"""创建引言部分"""
title = topic.get("title", "")
category = cases[0].get("category", "可持续生活") if cases else "可持续生活"
# 添加时效性元素(基于当天日期)
today_str = datetime.datetime.now().strftime("%Y年%m月%d")
introduction = f"""
<h2>{title}</h2>
<p>今天是{today_str},全球可持续性领域又有新的进展。根据最新收集的数据和案例,我们发现{category}领域出现了一些值得关注的新趋势。</p>
<p>这些国际案例对中国读者有什么启示?它们能否在中国落地?本文将通过全球案例对比和中国痛点分析,给出具体的本土化建议和可执行行动清单。</p>
<p><strong>核心观点</strong>:国际先进经验不能盲目照搬,必须结合中国现实进行调整。关键在于找到"最小可行行动",让可持续生活从理念变为日常实践。</p>
"""
return introduction.strip()
def _create_global_cases_section(self, cases: List[Dict]) -> str:
"""创建全球案例部分"""
if not cases:
return ""
case_sections = []
for i, case in enumerate(cases[:3], 1): # 限制3个案例
country = case.get("country", "全球")
title = case.get("title", "")
core_idea = case.get("core_idea", "")
data_facts = case.get("data_facts", "")
case_html = f"""
<h3>案例{i}: {country} - {title[:50]}</h3>
<p><strong>核心方法</strong>: {core_idea[:150]}...</p>
<p><strong>数据支撑</strong>: {data_facts}</p>
<p><strong>全球优势</strong>: {case.get('global_advantage', '需进一步分析')}</p>
"""
case_sections.append(case_html.strip())
return "\n".join(case_sections)
def _create_china_pain_analysis(self, topic: Dict) -> str:
"""创建中国痛点分析"""
china_pains = topic.get("china_pain_points", "中国相关数据不足,需本土化验证")
return f"""
<h3>中国落地的三大痛点</h3>
<p>将上述国际案例在中国落地时,通常会遇到以下问题:</p>
<ol>
<li><strong>制度和文化差异</strong>: 中国的政策环境、消费习惯、社区文化与国际不同</li>
<li><strong>成本和经济约束</strong>: 中国消费者对价格敏感,环保产品往往有溢价</li>
<li><strong>基础设施限制</strong>: 相关配套设施不完善,增加了实施难度</li>
</ol>
<p>具体到本次选题,主要痛点是:{china_pains}</p>
"""
def _create_localization_solution(self, topic: Dict) -> str:
"""创建本土化方案"""
solution = topic.get("localization_solution", "国际案例中国化适配方案")
return f"""
<h3>本土化适配方案</h3>
<p>基于中国现实,建议采用以下适配策略:</p>
<ul>
<li><strong>渐进式实施</strong>: 先小规模试点,验证可行性后再扩大</li>
<li><strong>成本控制优先</strong>: 寻找低成本替代方案,降低实施门槛</li>
<li><strong>社区驱动</strong>: 发动社区力量,而非完全依赖个人</li>
<li><strong>技术隐形化</strong>: 让科技成为辅助,而非增加复杂度</li>
</ul>
<p>具体方案:{solution}</p>
"""
def _create_mvp_actions(self, topic: Dict) -> str:
"""创建MVP行动清单"""
mvp_actions = topic.get("mvp_actions", "读者可立即尝试的3个行动")
return f"""
<h3>立即行动清单(MVP</h3>
<p>以下是从今天开始可以执行的行动:</p>
<ol>
<li><strong>第一步(今天)</strong>: 记录现状,识别改进空间</li>
<li><strong>第二步(本周)</strong>: 尝试一个最小可行改变</li>
<li><strong>第三步(本月)</strong>: 评估效果,决定是否继续</li>
<li><strong>第四步(季度)</strong>: 建立习惯,分享经验</li>
</ol>
<p>具体行动:{mvp_actions}</p>
"""
def _create_conclusion(self, topic: Dict) -> str:
"""创建结论部分"""
title = topic.get("title", "")
return f"""
<h3>总结与展望</h3>
<p>{title}的核心在于<strong>行动而非理论</strong>。国际案例提供参考,但最终的成功取决于在中国环境下的创造性适配。</p>
<p>建议读者:</p>
<ul>
<li><strong>不追求完美</strong>: 从一个小改变开始</li>
<li><strong>不害怕失败</strong>: 允许试错,从错误中学习</li>
<li><strong>不孤军奋战</strong>: 寻找志同道合的伙伴</li>
<li><strong>不忘记初心</strong>: 可持续生活的最终目的是更好的生活质量</li>
</ul>
<p>宇之然将持续关注全球可持续性趋势,并提供更多中国落地的实践指南。</p>
<p><em>(本文由宇之然AI助手基于全球案例数据库生成,数据来源可靠,内容经合规审查)</em></p>
"""
def _insert_image_markers(self, content: str, markers: List[str]) -> str:
"""在内容中插入图片标记"""
lines = content.split('\n')
result_lines = []
image_index = 0
for line in lines:
result_lines.append(line)
# 在合适位置插入图片标记(如段落之后)
if line.startswith('<h3>') and image_index < len(markers):
result_lines.append(markers[image_index])
image_index += 1
# 开头添加封面图
result_lines.insert(2, markers[0]) if markers else None
return '\n'.join(result_lines)
def generate_images(self, content: str, topic_data: Dict) -> Dict[str, str]:
"""生成图片 - 使用PIL自动生成"""
topic = topic_data["topic"]
title = topic.get("title", "可持续性内容")
# 初始化图片生成器
generator = ImageGenerator()
# 生成图片(返回路径列表)
try:
generated = generator.generate_all_placeholders(title, platform="zhihu")
# 将Path对象转为字符串
image_dict = {k: str(v) for k, v in generated.items()}
logger.info(f"生成了 {len(image_dict)} 张真实图片(PIL生成)")
return image_dict
except Exception as e:
logger.error(f"图片生成失败,回退到占位符: {e}")
# 回退:生成占位符文本文件
return self._generate_placeholder_images(title)
def _generate_placeholder_images(self, title: str) -> Dict[str, str]:
"""生成占位符图片(文本文件)"""
images_dir = IMAGES_DIR / "generated" / TODAY
images_dir.mkdir(parents=True, exist_ok=True)
image_dict = {}
# 1. 封面图
cover_path = images_dir / "cover.txt"
with open(cover_path, 'w', encoding='utf-8') as f:
f.write(f"封面图: {title}\n日期: {TODAY}\n作者: 宇之然")
image_dict["cover"] = str(cover_path)
# 2. 数据图表
chart_path = images_dir / "data_chart.txt"
with open(chart_path, 'w', encoding='utf-8') as f:
f.write("数据图表(示例)\n")
f.write("可持续性效果对比\n")
f.write("国际案例 vs 中国实践")
image_dict["data_chart"] = str(chart_path)
# 3. 案例对比
comparison_path = images_dir / "case_comparison.txt"
with open(comparison_path, 'w', encoding='utf-8') as f:
f.write("案例对比表格\n")
f.write("全球最佳实践 → 中国适配建议")
image_dict["case_comparison"] = str(comparison_path)
# 4. 行动清单
checklist_path = images_dir / "action_checklist.txt"
with open(checklist_path, 'w', encoding='utf-8') as f:
f.write("立即行动清单\n")
f.write("1. 记录现状\n2. 小步尝试\n3. 评估效果\n4. 建立习惯")
image_dict["action_checklist"] = str(checklist_path)
logger.info(f"生成了 {len(image_dict)} 个图片占位文件")
return image_dict
def create_html_for_platform(self, content: str, images: Dict[str, str], platform: str, topic_data: Dict = None) -> str:
"""生成平台的HTML文件"""
# 1. 在 content 中替换图片标记
image_markers = {
"[IMAGE: cover]": images.get("cover", ""),
"[IMAGE: data_chart]": images.get("data_chart", ""),
"[IMAGE: case_comparison]": images.get("case_comparison", ""),
"[IMAGE: action_checklist]": images.get("action_checklist", ""),
"[IMAGE: equipment]": images.get("equipment", ""),
}
for marker, image_path in image_markers.items():
if image_path:
img_tag = f'<img src="{image_path}" alt="{marker}">'
content = content.replace(marker, img_tag)
# 2. 替换索引标记
ordered_keys = ["cover", "data_chart", "case_comparison", "action_checklist", "equipment"]
for i, key in enumerate(ordered_keys, 1):
if key in images:
marker = f"[IMAGE: image_{i}]"
img_tag = f'<img src="{images[key]}" alt="图片{i}" class="platform-{platform}">'
content = content.replace(marker, img_tag)
# 3. 填充模板(将内容放入)
template = self._get_base_template()
html = template.replace("<!-- CONTENT -->", content)
# 4. 平台特定占位符替换(在 html 上进行)
if platform == "zhihu":
# 确定选题领域
field = "可持续生活"
if topic_data and isinstance(topic_data, dict):
topic_field = topic_data.get("topic", {}).get("field", "")
if topic_field:
field = topic_field
# 领域映射到标签
tag_map = {
"未来工作方式": ["科技", "职场", "AI"],
"可持续生活系统": ["可持续", "生活", "环保"],
"个人知识工厂": ["知识管理", "个人成长", "效率"],
"科技人文交叉": ["科技", "人文", "AI伦理"],
}
tags = tag_map.get(field, ["科技", "生活", "可持续"])[:4]
tags.append(TODAY[:4]+"年趋势")
tags_section = '<div class="tags">' + " ".join(f'#{tag}' for tag in tags) + '</div>'
logger.info(f"[DEBUG] Replacing TAGS with: {tags_section}")
if "<!-- TAGS -->" in html:
html = html.replace("<!-- TAGS -->", tags_section)
logger.info(f"[DEBUG] After TAGS replace, length: {len(html)}")
else:
logger.warning("[DEBUG] TAGS placeholder not found in HTML! Template may be missing.")
# Fallback: append tags at end of content
html = html.replace("</body>", tags_section + "\n</body>")
elif platform == "wechat":
abstract = content[:100] + "..."
html = html.replace("<!-- ABSTRACT -->", f'<p class="abstract">{abstract}</p>')
elif platform == "xiaohongshu":
field = "可持续生活"
if topic_data and isinstance(topic_data, dict):
topic_field = topic_data.get("topic", {}).get("field", "")
if topic_field:
field = topic_field
hashtag_map = {
"未来工作方式": ["#远程工作", "#数字游民", "#AI副业"],
"可持续生活系统": ["#可持续生活", "#零浪费", "#环保"],
"个人知识工厂": ["#第二大脑", "#PKM", "#个人成长"],
"科技人文交叉": ["#科技", "#AI伦理", "#数字健康"],
}
hashtags = hashtag_map.get(field, ["#可持续生活", "#全球视野", "#宇之然"])[:5]
hashtags_section = '<div class="hashtags">' + " ".join(hashtags) + '</div>'
html = html.replace("<!-- HASHTAGS -->", hashtags_section)
# 5. 替换日期和标题
title = "可持续性内容"
if topic_data and isinstance(topic_data, dict):
topic_title = topic_data.get("topic", {}).get("title")
if topic_title:
title = topic_title
html = html.replace("{{DATE}}", TODAY)
html = html.replace("{{TITLE}}", title)
# Debug: check tags presence
if platform == "zhihu":
tags_pos = html.find('class="tags"')
if tags_pos != -1:
snippet = html[max(0, tags_pos-50):tags_pos+100]
logger.info(f"[DEBUG] Tags found: ...{snippet}...")
else:
logger.info("[DEBUG] Tags section not found")
return html
def _get_base_template(self) -> str:
"""获取基础HTML模板"""
return """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{TITLE}} - {{DATE}}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding:66px; }
h1, h2, h3 { color: #333; margin-top: 1.5em; }
p { margin: 1em 0; color: #555; }
ul, ol { padding-left: 1.5em; }
img { max-width: 100%; height: auto; margin: 1em 0; border: 1px solid #eee; }
.tags, .hashtags { margin: 1em 0; color: #666; }
.abstract { background: #f9f9f9; padding: 1em; border-left: 4px solid #4CAF50; }
footer { margin-top: 2em; padding-top: 1em; border-top: 1px solid #eee; color: #888; font-size: 0.9em; }
</style>
</head>
<body>
<!-- CONTENT -->
<footer>
<p>本文由宇之然AI助手生成 | 数据来源:全球可持续性信息源 | 生成日期:{{DATE}}</p>
</footer>
</body>
</html>
"""
def save_article(self, article: ContentArticle):
"""保存文章"""
output_dir = Path(article.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# HTML文件
html_file = output_dir / f"{article.platform}_{article.id}.html"
with open(html_file, 'w', encoding='utf-8') as f:
f.write(article.content)
# 元数据
meta_file = output_dir / f"{article.platform}_{article.id}.json"
with open(meta_file, 'w', encoding='utf-8') as f:
json.dump(asdict(article), f, ensure_ascii=False, indent=2)
logger.info(f"保存了 {article.platform} 版本文章: {html_file}")
def send_wecom_notification(self):
"""发送企业微信通知"""
try:
notification_script = PROJECT_ROOT / "scripts" / "wecom_notifier.py"
if not notification_script.exists():
logger.warning("企业微信通知脚本不存在")
return
# 准备通知数据
notification_data = {
"task": "content_creation",
"time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
"topic_title": self.articles[0].title if self.articles else "",
"image_count": len(self.articles[0].image_paths) if self.articles else 0,
"output_dir": str(self.release_dir.relative_to(PROJECT_ROOT)),
"status": "完成" if self.articles else "失败"
}
data_file = self.today_dir / "creator_notification.json"
with open(data_file, 'w', encoding='utf-8') as f:
json.dump(notification_data, f, ensure_ascii=False)
# 运行通知脚本
result = subprocess.run(
[sys.executable, str(notification_script), str(data_file)],
capture_output=True,
text=True,
cwd=PROJECT_ROOT
)
if result.returncode == 0:
logger.info("企业微信通知发送成功")
else:
logger.error(f"通知发送失败: {result.stderr}")
except Exception as e:
logger.error(f"发送通知失败: {e}")
def run(self):
"""主运行流程"""
logger.info("开始内容创作")
# 1. 选择选题
topic_data = self.select_topic_for_today()
if not topic_data:
logger.error("未能选择选题,任务结束")
return False
# 2. 创作内容
content = self.create_content(topic_data)
# 3. 生成图片 (返回字典)
images_dict = self.generate_images(content, topic_data)
# 4. 为每个平台生成HTML
platforms = ["zhihu", "wechat", "xiaohongshu"]
topic_id = topic_data["topic"].get("id", "unknown")
# 确定图片顺序
ordered_keys = ["cover", "data_chart", "case_comparison", "action_checklist", "equipment"]
images_list = [images_dict[k] for k in ordered_keys if k in images_dict]
for platform in platforms:
html = self.create_html_for_platform(content, images_dict, platform, topic_data)
article = ContentArticle(
id=f"{topic_id}_{platform}",
topic_id=topic_id,
title=topic_data["topic"].get("title", ""),
platform=platform,
content=html,
image_paths=images_list, # 有序列表
metadata={
"platform": platform,
"topic": topic_data["topic"],
"cases": topic_data["cases"],
"word_count": len(content)
},
created_date=TODAY,
output_dir=str(self.release_dir / platform)
)
self.save_article(article)
self.articles.append(article)
# 5. 更新选题状态
self.update_topic_status(topic_id)
# 6. 发送通知
self.send_wecom_notification()
# 7. 合规审查(如有生成文章)
if self.articles:
self.run_compliance_check(self.articles[0])
logger.info(f"创作完成: {len(self.articles)} 篇文章")
return True
def update_topic_status(self, topic_id: str):
"""更新选题状态为已发布"""
topics_file = DATA_DIR / "sustainability_topics.json"
if not topics_file.exists():
return
with open(topics_file, 'r', encoding='utf-8') as f:
all_topics = json.load(f)
for topic in all_topics:
if topic.get("id") == topic_id:
topic["status"] = "已发布"
topic["published_date"] = TODAY
break
with open(topics_file, 'w', encoding='utf-8') as f:
json.dump(all_topics, f, ensure_ascii=False, indent=2)
logger.info(f"更新选题 {topic_id} 状态为已发布")
def run_compliance_check(self, article):
"""运行合规审查"""
try:
from scripts.compliance_checker import check_article
result = check_article(
article.content,
article.platform,
article.metadata.get("topic") if hasattr(article, 'metadata') else None
)
# 记录审查结果
log_msg = f"合规审查: {article.platform} - {article.title[:30]} - 得分: {result['score']} - 问题数: {len(result['issues'])}"
if result['passed']:
logger.info(log_msg)
else:
logger.warning(log_msg)
for issue in result['issues']:
logger.warning(f" [合规问题] {issue['type']}/{issue.get('category','')}: {issue.get('suggestion','')}")
# 保存审查报告
compliance_file = self.today_dir / f"compliance_{article.id}.json"
with open(compliance_file, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
logger.info(f"合规报告已保存: {compliance_file}")
except Exception as e:
logger.error(f"合规审查失败: {e}")
def main():
"""主函数"""
try:
creator = ContentCreator()
success = creator.run()
if success:
print(f"SUCCESS: Created {len(creator.articles)} articles for {TODAY}")
sys.exit(0)
else:
print("WARNING: Content creation failed")
sys.exit(1)
except Exception as e:
logger.error(f"创作任务失败: {e}")
print(f"ERROR: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+136 -3
View File
@@ -80,11 +80,50 @@ def update_topic_status(topic_id: str, status: str, db: Optional[Session] = None
if close_db:
db.close()
def get_active_llm_config(db: Optional[Session] = None) -> Optional[Dict]:
"""获取启用的 LLM 配置,优先读取 system_configs 中的 review_llm_id
返回格式: { "model", "temperature", "max_tokens", "system_prompt", "name" }
无配置时返回 None(调用方应使用环境变量默认值)
"""
close_db = False
if db is None:
db = SessionLocal()
close_db = True
try:
from app.models import LLMConfig, SystemConfig
# 先查系统配置中指定的 review_llm_id
sc = db.query(SystemConfig).filter(SystemConfig.key == 'review_llm_id').first()
target_id = None
if sc and sc.value and sc.value.isdigit():
target_id = int(sc.value)
query = db.query(LLMConfig)
if target_id:
query = query.filter(LLMConfig.id == target_id)
query = query.filter(LLMConfig.is_active == True)
cfg = query.first()
if not cfg:
cfg = db.query(LLMConfig).filter(LLMConfig.is_active == True).first()
if cfg:
return {
"id": cfg.id,
"name": cfg.name,
"model": cfg.model,
"temperature": cfg.temperature,
"max_tokens": cfg.max_tokens,
"system_prompt": cfg.system_prompt,
"user_prompt_template": cfg.user_prompt_template,
}
return None
finally:
if close_db:
db.close()
def topic_to_dict(topic: Topic) -> Dict:
return {
'id': topic.id,
'title': topic.title,
'field': topic.field.name if topic.field else None,
'field': topic.field_name,
'format': topic.format,
'core_concept': topic.core_concept,
'audience_pain': topic.audience_pain,
@@ -131,7 +170,7 @@ def save_topics_to_db(topics_data: List[Dict]):
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']:
for field in ['title', 'field_name', '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:
@@ -148,7 +187,7 @@ def save_topics_to_db(topics_data: List[Dict]):
new_topic = Topic(
id=t['id'],
title=t['title'],
field=t.get('field', '可持续生活系统'),
field_name=t.get('field_name') or t.get('field', '可持续生活系统'),
format=t.get('format'),
core_concept=t.get('core_concept'),
audience_pain=t.get('audience_pain'),
@@ -173,3 +212,97 @@ def save_topics_to_db(topics_data: List[Dict]):
raise
finally:
db.close()
def save_article(topic_id: str, platform: str, html_content: str, db: Optional[Session] = None) -> Dict:
"""保存/更新文章到 articles 表"""
close_db = False
if db is None:
db = SessionLocal()
close_db = True
try:
from app.models import Article
article_id = f"{platform}_{topic_id}"
existing = db.query(Article).filter(Article.id == article_id).first()
now = datetime.now()
if existing:
existing.html_content = html_content
existing.compliance_score = existing.compliance_score
else:
article = Article(
id=article_id,
topic_id=topic_id,
platform=platform,
file_path=f"db:{article_id}",
html_content=html_content,
status="draft",
compliance_score=None
)
db.add(article)
db.commit()
return {"id": article_id, "topic_id": topic_id, "platform": platform}
except Exception:
db.rollback()
raise
finally:
if close_db:
db.close()
def get_article(topic_id: str, platform: str, db: Optional[Session] = None) -> Optional[Dict]:
"""从 articles 表获取文章 HTML"""
close_db = False
if db is None:
db = SessionLocal()
close_db = True
try:
from app.models import Article
article_id = f"{platform}_{topic_id}"
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
return None
return {
"id": article.id,
"topic_id": article.topic_id,
"platform": article.platform,
"html_content": article.html_content,
"status": article.status,
"compliance_score": article.compliance_score,
"created_at": article.created_at.isoformat() if article.created_at else None,
}
finally:
if close_db:
db.close()
def get_articles_by_topic(topic_id: str, db: Optional[Session] = None) -> List[Dict]:
"""获取某选题在所有平台的文章"""
close_db = False
if db is None:
db = SessionLocal()
close_db = True
try:
from app.models import Article
articles = db.query(Article).filter(Article.topic_id == topic_id).all()
return [{
"id": a.id,
"topic_id": a.topic_id,
"platform": a.platform,
"html_content": a.html_content,
"status": a.status,
"compliance_score": a.compliance_score,
} for a in articles]
finally:
if close_db:
db.close()
def delete_articles_by_topic(topic_id: str, db: Optional[Session] = None):
"""删除某选题的所有文章"""
close_db = False
if db is None:
db = SessionLocal()
close_db = True
try:
from app.models import Article
db.query(Article).filter(Article.topic_id == topic_id).delete()
db.commit()
finally:
if close_db:
db.close()
+11 -7
View File
@@ -1,17 +1,21 @@
#!/usr/bin/env python3
import json
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from db_helper import export_topics_to_json
topics = export_topics_to_json()
print(f'✅ 宇之然选题库(新战略版)')
print(f'总选题数: {len(data)}')
print(f'已发布: {len([t for t in data if t["status"]=="已发布"])}')
print(f'待处理: {len([t for t in data if t["status"]=="待处理"])}')
print(f'总选题数: {len(topics)}')
print(f'已发布: {len([t for t in topics if t["status"]=="published"])}')
print(f'待处理: {len([t for t in topics if t["status"] in ("pending", "待处理")])}')
print('\n按领域分组:')
fields = {}
for t in sorted(data, key=lambda x: x['id']):
for t in sorted(topics, key=lambda x: x['id']):
f = t['field']
fields.setdefault(f, []).append(t)
for f, items in fields.items():
print(f'\n{f} ({len(items)}个):')
for t in items:
status_icon = '' if t['status']=='已发布' else ''
status_icon = '' if t['status']=='published' else ''
print(f' {status_icon} {t["id"]} {t["title"]}')
+8 -7
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env python3
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from db_helper import export_topics_to_json
with open('automation/data/sustainability_topics.json', 'r', encoding='utf-8') as f:
data = json.load(f)
print(f'总选题数: {len(data)}')
pending = [t for t in data if t['status'] == '待处理']
avg = sum(t['total_score'] for t in pending) / len(pending) if pending else 0
topics = export_topics_to_json()
pending = [t for t in topics if t['status'] in ('pending', '待处理')]
avg = sum(t['total_score'] or 0 for t in pending) / len(pending) if pending else 0
print(f'总选题数: {len(topics)}')
print(f'待处理选题数: {len(pending)}')
print(f'待处理平均分: {avg:.1f}')
print('\n待处理选题详情:')
+57 -36
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
大纲阶段:基于研究笔记生成文章大纲(数据库版)
大纲阶段:基于选题和研究笔记,用 LLM 动态生成结构化大纲
"""
import json, datetime, logging, sys
@@ -9,9 +9,14 @@ from typing import Dict
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
# 导入数据库辅助模块
from db_helper import get_topic_by_id
try:
from app.core.nvidia_client import call_llm
HAVE_LLM = True
except ImportError:
HAVE_LLM = False
DATA_DIR = PROJECT_ROOT / "automation" / "data"
RESEARCH_DIR = DATA_DIR / "research"
@@ -28,34 +33,64 @@ class Outliner:
self.topic_id = topic_id
self.topic = self._load_topic()
research_file = RESEARCH_DIR / TODAY / f"{topic_id}_research.md"
if not research_file.exists():
raise FileNotFoundError(f"Research notes not found: {research_file}")
self.research_notes = research_file.read_text(encoding='utf-8')
self.research_notes = research_file.read_text(encoding='utf-8') if research_file.exists() else ""
self.output_dir = OUTPUT_DIR / TODAY
self.output_dir.mkdir(parents=True, exist_ok=True)
def _load_topic(self) -> Dict:
topic = get_topic_by_id(self.topic_id)
if not topic:
raise ValueError(f"Topic {self.topic_id} not found")
if not topic: raise ValueError(f"Topic {self.topic_id} not found")
return topic
def generate_outline(self) -> str:
"""生成文章大纲 Markdown(基于模板)"""
title = self.topic['title']
field = self.topic.get('field', '')
core = self.topic.get('core_concept', '')
pain = self.topic.get('audience_pain', '')
angle = self.topic.get('unique_angle', '')
cases_summary = self.research_notes[:2000] if self.research_notes else "暂无研究笔记"
# 解析研究笔记中的案例数量
case_count = self.research_notes.count('### 案例')
if HAVE_LLM:
prompt = f"""你是一个真人编辑,在为一篇文章列大纲。不要套模板,根据具体内容灵活设计结构。
outline = f"""# 文章大纲:{title}
## 选题信息
标题:{title}
领域:{field}
核心观点:{core}
受众痛点:{pain}
独特视角:{angle}
## 研究笔记
{cases_summary}
## 要求
- 章节数灵活,5-8章都行,不硬凑
- 每章给2-4个要点即可,不需要每章都标字数
- 结构要有递进,但不一定非得是痛点-分析-方案这种套路
- 不要用「引言」「总结」这类通用标题,要贴合具体内容
- 尽量把独特视角和受众痛点融入各章,而不是单独列出来
- 每个要点一句话点出核心,不用展开写
直接输出大纲。"""
try:
outline = call_llm(prompt, temperature=0.6, max_tokens=2000, system_prompt="你是一个有经验的内容编辑,擅长为不同选题设计差异化的文章结构。")
logger.info(f"LLM 大纲生成成功,长度:{len(outline)}")
return f"# 文章大纲:{title}\n\n{outline}\n\n---\n*大纲生成时间:{TODAY}*"
except Exception as e:
logger.warning(f"LLM 大纲生成失败: {e},使用模板")
return self._template_outline(title, field, core, pain, angle)
def _template_outline(self, title, field, core, pain, angle) -> str:
case_count = self.research_notes.count('### 案例') if self.research_notes else 0
case_section = f"""## 四、全球/行业趋势与案例
- 引用研究笔记中的 {case_count} 个案例,精选 2-3 个详述
- 数据支撑:提取研究笔记中的关键数据
- 趋势分析""" if case_count > 0 else ""
return f"""# 文章大纲:{title}
## 一、引言
- 开场场景/痛点引入
- 提出核心问题:{title}
- 场景切入:{title}
- 点明文章价值
## 二、核心观点
@@ -63,36 +98,23 @@ class Outliner:
## 三、受众痛点分析
{pain}
{case_section}
## 四、全球/行业趋势与案例
- 引用研究笔记中的 {case_count} 个案例,精选 2-3 个详述
- 数据支撑:提取研究笔记中的关键数据
- 趋势分析
## 五、本土落地建议
## {"" if case_section else ""}、本土落地建议
- 结合{field}领域特点
- 提供可执行的步骤
- 注意事项
## 、独特视角:{angle}
## {"" if case_section else ""}、独特视角:{angle}
## 七、行动指南(MVP
1. 解现状
2. 小范围试验
3. 评估效果
4. 形成习惯
## {"" if case_section else ""}、行动指南
1. 解现状 2. 制定方案 3. 小范围验证 4. 持续优化
## 、总结与鼓励
- 回顾要点
- 呼吁行动
## 九、参考文献
- 从研究笔记中提取来源链接
## {"" if case_section else ""}、总结与鼓励
---
*大纲生成时间:{TODAY}*
"""
return outline
*大纲生成时间:{TODAY}*"""
def save(self):
outline_text = self.generate_outline()
@@ -104,9 +126,8 @@ class Outliner:
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--topic-id', required=True, help='选题ID')
parser.add_argument('--topic-id', required=True)
args = parser.parse_args()
o = Outliner(args.topic_id)
o.save()
print(f"SUCCESS: Outline created for {args.topic_id}")
+88 -21
View File
@@ -1,21 +1,25 @@
#!/usr/bin/env python3
"""
研究阶段:为选题收集资料并生成研究笔记(数据库版)
"""
import json, datetime, logging, sys, re
from pathlib import Path
from typing import Dict, List
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
# 导入数据库辅助模块
from db_helper import get_topic_by_id
try:
from app.core.nvidia_client import call_llm
HAVE_LLM = True
except ImportError:
HAVE_LLM = False
from trends import get_trend_context
from web_search import enrich_topic_research
DATA_DIR = PROJECT_ROOT / "automation" / "data"
CASES_FILE = DATA_DIR / "sustainability_cases.json"
OUTPUT_DIR = DATA_DIR / "research" # 研究笔记输出目录
OUTPUT_DIR = DATA_DIR / "research"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
@@ -43,12 +47,10 @@ class Researcher:
return []
def find_relevant_cases(self, top_k: int = 5) -> List[Dict]:
"""基于标题和字段匹配相关案例(简化)"""
field = self.topic.get('field', '').lower()
title = self.topic.get('title', '').lower()
scored = []
for case in self.cases:
# 日期过滤:仅保留 2025 年及以后(支持 YYYY-MM-DD 或 YYYY 格式)
case_date = case.get('date', '')
if case_date:
m = re.search(r'(\d{4})', str(case_date))
@@ -57,7 +59,6 @@ class Researcher:
score = 0
if field and field in case.get('field', '').lower():
score += 3
# 标题关键词匹配
case_title = case.get('title', '').lower()
for word in title.split():
if len(word) > 2 and word in case_title:
@@ -67,9 +68,43 @@ class Researcher:
scored.sort(key=lambda x: x[0], reverse=True)
return [c for _, c in scored[:top_k]]
def _llm_summary(self, cases: List[Dict]) -> str:
if not HAVE_LLM:
return ""
cases_text = json.dumps(cases, ensure_ascii=False, indent=2)
# 获取实时搜索数据作为 LLM 参考
search_data = enrich_topic_research(self.topic)
search_section = f"\n## 实时搜索结果\n{search_data}\n" if search_data else ""
prompt = f"""你是一个行业研究员。基于以下选题和相关案例,写一段研究发现。
## 选题
标题:{self.topic['title']}
领域:{self.topic.get('field', '')}
核心观点:{self.topic.get('core_concept', '')}
受众痛点:{self.topic.get('audience_pain', '')}
独特视角:{self.topic.get('unique_angle', '')}
{search_section}
## 相关案例({len(cases)}个)
{cases_text}
## 相关案例({len(cases)}个)
{cases_text}
## 要求
- 提炼2-3个真正有价值的洞察,不是每个案例都硬凑一条
- 每条洞察1-2句话,说人话,不要列1/2/3
- 避免「首先其次最后」「综上所述」
- 指出1-2个你不太确定的方向,作为后续研究的提示"""
try:
return call_llm(prompt, temperature=0.5, max_tokens=1200, system_prompt="你是一个行业研究员,擅长从案例中发现真洞察。")
except Exception as e:
logger.warning(f"LLM 研究发现摘要生成失败: {e}")
return ""
def generate_notes(self) -> str:
"""生成研究笔记 Markdown"""
cases = self.find_relevant_cases()
trend_context = get_trend_context(self.topic.get('field'))
search_data = enrich_topic_research(self.topic)
lines = [
f"# 研究笔记:{self.topic['title']}",
f"\n## 选题信息",
@@ -78,6 +113,8 @@ class Researcher:
f"- **核心观点**: {self.topic.get('core_concept', '待补充')}",
f"- **受众痛点**: {self.topic.get('audience_pain', '待补充')}",
f"- **独特视角**: {self.topic.get('unique_angle', '待补充')}",
f"\n{trend_context}",
f"\n{search_data}" if search_data else "",
f"\n## 相关案例({len(cases)}个)\n"
]
for i, case in enumerate(cases, 1):
@@ -89,17 +126,47 @@ class Researcher:
f"- **关键数据**: {case.get('key_metrics', '')}",
""
])
lines.extend([
"## 研究发现摘要",
"- 待补充:从案例中提炼的趋势和洞察",
"- 待补充:数据支撑",
"",
"## 待深入研究的问题",
"- [ ] 需要更多本土数据",
"- [ ] 需要验证某些结论的适用性",
"",
f"*生成时间:{TODAY}*"
])
llm_summary = self._llm_summary(cases)
if llm_summary:
lines.extend([
"## 研究发现摘要(LLM 生成)",
llm_summary,
""
])
else:
insights = []
for case in cases[:3]:
summary = case.get('summary', case.get('description', ''))
metrics = case.get('key_metrics', '')
if summary:
insight = f"- {case.get('title', '相关案例')}{summary[:100]}"
if metrics:
insight += f"{metrics[:80]}"
insights.append(insight)
if not insights:
insights = ["- 暂未匹配到高度相关的历史案例"]
pain_text = self.topic.get('audience_pain', '')
topic_insights = [f"- {pain_text[:100]}"] if pain_text else []
lines.extend([
"## 研究发现摘要",
"",
])
lines.extend(insights)
if topic_insights:
lines.extend(topic_insights)
lines.extend([
"",
"## 待深入研究的问题",
])
if cases:
lines.append("- [ ] 验证以上案例在当前选题背景下的适用性")
lines.extend([
"- [ ] 收集更多本土一手数据",
"- [ ] 确认目标受众的实际反馈",
""
])
lines.append(f"*生成时间:{TODAY}*")
return "\n".join(lines)
def save(self):
+10 -8
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env python3
import json
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
for t in data:
if t['id'] in ['B05', 'D01']:
t['status'] = '待处理'
if 'ready_at' in t:
del t['ready_at']
json.dump(data, open('automation/data/sustainability_topics.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from db_helper import update_topic_status
for tid in ['B05', 'D01']:
update_topic_status(tid, 'pending')
print('已重置 B05, D01 为待处理')
+9 -53
View File
@@ -1,68 +1,24 @@
#!/usr/bin/env python3
"""
重置指定选题状态为「待处理」(数据库 + JSON 备份)
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
try:
from db_helper import update_topic_status, get_topic_by_id
HAVE_DB = True
except ImportError:
HAVE_DB = False
print("Warning: db_helper not available, will only update JSON")
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
def reset_json_status(ids):
try:
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
topics = json.load(f)
except:
topics = []
updated_ids = []
for t in topics:
if t['id'] in ids:
t['status'] = 'pending'
if 'ready_at' in t:
del t['ready_at']
updated_ids.append(t['id'])
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
json.dump(topics, f, ensure_ascii=False, indent=2)
return updated_ids
def reset_db_status(ids):
if not HAVE_DB:
return []
updated = []
for tid in ids:
if update_topic_status(tid, 'pending'):
updated.append(tid)
return updated
from db_helper import update_topic_status, get_topic_by_id
def main():
# 指定要重置的ID列表
target_ids = ['D01', 'B05'] # 可修改
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}")
updated = []
for tid in target_ids:
if update_topic_status(tid, 'pending'):
updated.append(tid)
if updated:
print(f"[DB] 已重置: {updated}")
else:
print("[DB] 更新或数据库不可用")
# 更新 JSON 备份
json_updated = reset_json_status(target_ids)
print(f"[JSON] 已重置: {json_updated}")
print("[DB] 更新")
print("完成")
if __name__ == "__main__":
import json
main()
+24 -21
View File
@@ -1,46 +1,50 @@
#!/usr/bin/env python3
import json, datetime
import sys, datetime
from pathlib import Path
from collections import Counter
PROJECT_ROOT = Path(__file__).parent.parent
data_dir = PROJECT_ROOT / "automation" / "data"
releases_dir = data_dir / "releases" / "2026-04-16"
drafts_dir = data_dir / "drafts" / "2026-04-16"
sys.path.insert(0, str(PROJECT_ROOT))
# 1. 选题状态
topics = json.load(open(data_dir / "sustainability_topics.json", encoding='utf-8'))
from db_helper import export_topics_to_json
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TODAY = datetime.date.today().isoformat()
releases_dir = DATA_DIR / "releases" / TODAY
drafts_dir = DATA_DIR / "drafts" / TODAY
topics = export_topics_to_json()
by_status = {}
for t in topics:
s = t.get('status','待处理')
s = t.get('status', '待处理')
by_status.setdefault(s, []).append(t)
print(f"📊 宇之然内容生成系统状态报告 ({datetime.date.today()})")
print(f"\U0001f4ca 宇之然内容生成系统状态报告 ({TODAY})")
print(f"\n=== 1. 选题库总览 ===")
print(f"总选题数: {len(topics)}")
for s, arr in sorted(by_status.items()):
print(f" {s}: {len(arr)}")
# 2. 今日生成内容
print(f"\n=== 2. 今日 (2026-04-16) 已生成内容 ===")
print(f"\n=== 2. 今日 ({TODAY}) 已生成内容 ===")
if releases_dir.exists():
zhihu = list((releases_dir / 'zhihu').glob('*.html'))
wechat = list((releases_dir / 'wechat').glob('*.html'))
xhs = list((releases_dir / 'xiaohongshu').glob('*.html'))
zhihu = list((releases_dir / 'zhihu').glob('*.html')) if (releases_dir / 'zhihu').exists() else []
wechat = list((releases_dir / 'wechat').glob('*.html')) if (releases_dir / 'wechat').exists() else []
xhs = list((releases_dir / 'xiaohongshu').glob('*.html')) if (releases_dir / 'xiaohongshu').exists() else []
print(f" 知乎: {len(zhihu)}")
print(f" 微信公众号: {len(wechat)}")
print(f" 小红书: {len(xhs)}")
# 列出选题ID
topic_ids = set()
for f in zhihu:
topic_ids.add(f.stem.split('_')[1])
print(f" 涉及选题ID: {', '.join(sorted(topic_ids))}")
if topic_ids:
print(f" 涉及选题ID: {', '.join(sorted(topic_ids))}")
else:
print(" 今日无发布内容")
# 3. 合规与优化结果
report_file = drafts_dir / "optimization_report.json"
if report_file.exists():
report = json.load(open(report_file, encoding='utf-8'))
import json
report = json.loads(report_file.read_text(encoding='utf-8'))
print(f"\n=== 3. 合规优化结果 ===")
sm = report['summary']
print(f" 总文章数: {sm['total_articles']}")
@@ -50,14 +54,13 @@ if report_file.exists():
if sm['need_manual'] == 0:
print(" ✅ 所有文章均已自动合规")
else:
print("\n=== 3. 合规优化结果 ===")
print(f"\n=== 3. 合规优化结果 ===")
print(" 未找到优化报告")
# 4. 待发布选题(可发布)
ready = by_status.get('ready', [])
print(f"\n=== 4. 待发布选题(已合规)===")
ready = by_status.get('待发布', [])
if ready:
for t in sorted(ready, key=lambda x: x.get('priority_score',0), reverse=True):
for t in sorted(ready, key=lambda x: x.get('priority_score', 0), reverse=True):
print(f" {t['id']}: {t['title'][:50]}")
else:
print(" 暂无待发布选题")
+10 -7
View File
@@ -1,19 +1,22 @@
#!/usr/bin/env python3
import json
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from db_helper import export_topics_to_json
with open('automation/data/sustainability_topics.json', 'r', encoding='utf-8') as f:
data = json.load(f)
topics = export_topics_to_json()
print('=== 选题库状态 ===')
print(f'总选题数: {len(data)}')
print(f'字段: {list(data[0].keys())}')
print(f'总选题数: {len(topics)}')
if topics:
print(f'字段: {list(topics[0].keys())}')
print('\n状态分布:')
status_counts = Counter(t.get('status', '<无>') for t in data)
status_counts = Counter(t.get('status', '<无>') for t in topics)
for s, c in sorted(status_counts.items()):
print(f' {s}: {c}')
print('\n各状态详情:')
for t in data:
for t in topics:
print(f"{t['id']}: {t['title'][:40]:40} | 状态: {t.get('status', '?'):6} | 优先级: {t.get('priority_score', '-')}")
+6 -15
View File
@@ -1,19 +1,10 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from db_helper import get_topic_by_id, get_topics_by_status
PROJECT_ROOT = Path(__file__).parent.parent
DATA_DIR = PROJECT_ROOT / "automation" / "data"
topics_file = DATA_DIR / "sustainability_topics.json"
print(f"Project root: {PROJECT_ROOT}")
print(f"Looking for: {topics_file}")
print(f"Exists: {topics_file.exists()}")
if topics_file.exists():
import json
with open(topics_file, 'r', encoding='utf-8') as f:
topics = json.load(f)
print(f"Loaded {len(topics)} topics")
if topics:
print("First topic:", topics[0]['title'], f"score={topics[0]['priority_score']}, status={topics[0]['status']}")
pending = get_topics_by_status('pending')
print(f"待处理选题数: {len(pending)}")
for t in pending[:3]:
print(f" {t['id']}: {t['title'][:40]}, score={t['priority_score']}")
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
选题引擎 v2:多趋势加权匹配 + 趋势缺口检测 + 新选题生成
"""
import sys, json, logging
from pathlib import Path
from typing import Dict, List, Optional
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
from trends import load_trends
from db_helper import export_topics_to_json, update_topic_status, save_topics_to_db
from app.core.nvidia_client import call_llm
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TRENDS_FILE = DATA_DIR / "trends.json"
TODAY = __import__('datetime').datetime.now().strftime("%Y-%m-%d")
logger = logging.getLogger(__name__)
TREND_DOMAIN_MAP = {
"远程工作": "未来工作方式",
"AI工具": "AI与效率",
"可持续生活": "可持续生活系统",
"知识管理": "个人知识工厂",
"数字生活": "科技人文交叉",
"科技人文": "科技人文交叉",
"个人成长": "个人成长",
"副业": "个人成长",
"AI创作": "AI与效率",
"未来工作": "未来工作方式",
"效率工具": "AI与效率",
"家庭教育": "科技人文交叉",
}
def _topic_trend_score(topic: Dict, trend: Dict) -> float:
field = (topic.get("field") or "").lower()
title = (topic.get("title") or "").lower()
core = (topic.get("core_concept") or "").lower()
trend_domain = TREND_DOMAIN_MAP.get(trend.get("domain", ""), "")
keywords = [trend.get("topic", "")] + trend.get("hot_keywords", [])
score = 0.0
if trend_domain and trend_domain in field:
score += 5
for kw in keywords:
kw = kw.lower()
if kw in title: score += 3
elif kw in core: score += 2
return score
def match_trends_to_topics() -> List[Dict]:
trends = load_trends()
topics = [t for t in export_topics_to_json() if t.get('status') in ('pending', '待处理')]
if not trends or not topics:
return []
topic_scores = {}
for topic in topics:
tid = topic["id"]
per_trend = []
total = 0.0
for trend in trends:
s = _topic_trend_score(topic, trend)
if s > 0:
per_trend.append({"trend": trend["topic"], "domain": trend.get("domain",""), "score": s})
total += s
if per_trend:
topic_scores[tid] = {
"topic_id": tid,
"topic_title": topic["title"],
"total_score": total + (topic.get("priority_score", 0) or 0) * 0.3,
"matched_trends": sorted(per_trend, key=lambda x: -x["score"]),
"matched_count": len(per_trend),
}
results = sorted(topic_scores.values(), key=lambda x: -x["total_score"])
return results
def detect_trend_gaps() -> List[Dict]:
trends = load_trends()
topics = export_topics_to_json()
gaps = []
for trend in trends:
matched = False
trend_keywords = [trend.get("topic", "").lower()] + [k.lower() for k in trend.get("hot_keywords", [])]
for t in topics:
title = (t.get("title") or "").lower()
core = (t.get("core_concept") or "").lower()
for kw in trend_keywords:
if kw in title or kw in core:
matched = True
break
if matched:
break
if not matched:
gaps.append(trend)
return gaps
def generate_new_topics(gaps: List[Dict]) -> List[Dict]:
if not gaps:
return []
prompt = f"""以下热点话题在我们的选题库中没有匹配项。请为每个热点生成一个新选题建议。
热点列表:
{chr(10).join(f'- {g["topic"]}{g.get("domain","")}{g.get("reason","")}' for g in gaps)}
输出 JSON 数组,每个元素包含:
- "title": 选题标题(有吸引力,20字内)
- "field": 所属领域
- "core_concept": 核心观点(一句话)
- "audience_pain": 受众痛点
- "unique_angle": 独特视角
只输出 JSON,不要其他文字。"""
try:
resp = call_llm(prompt, temperature=0.4, max_tokens=2000)
resp = resp.strip()
if resp.startswith("```"):
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
suggestions = json.loads(resp)
if isinstance(suggestions, list):
return suggestions
except Exception as e:
logger.warning(f"新选题生成失败: {e}")
return []
def suggest_new_topics() -> List[Dict]:
gaps = detect_trend_gaps()
if not gaps:
logger.info("所有趋势均有匹配选题,无需新增")
return []
logger.info(f"发现 {len(gaps)} 个趋势缺口: {[g['topic'] for g in gaps]}")
new_topics = generate_new_topics(gaps)
if new_topics:
logger.info(f"生成 {len(new_topics)} 个新选题建议")
report_path = DATA_DIR / "topic_suggestions.json"
data = {"date": TODAY, "gaps": gaps, "suggestions": new_topics}
DATA_DIR.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return new_topics
def select_best_topic() -> Optional[Dict]:
matches = match_trends_to_topics()
if matches:
best = matches[0]
logger.info(f"选题推荐: {best['topic_id']} {best['topic_title']} "
f"(命中 {best['matched_count']} 个趋势, 综合分: {best['total_score']:.1f})")
topic = __import__('db_helper').get_topic_by_id(best['topic_id'])
if topic:
return topic
from db_helper import get_next_topic
fallback = get_next_topic(priority='') or get_next_topic()
if fallback:
logger.info(f"无趋势匹配,退回到优先级最高选题: {fallback['id']}")
return fallback
def print_report():
matches = match_trends_to_topics()
trends = load_trends()
gaps = detect_trend_gaps()
print(f"=== 选题匹配报告 ({TODAY}) ===\n")
print(f"当前趋势数: {len(trends)}")
print(f"待处理选题: {len([t for t in export_topics_to_json() if t.get('status') in ('pending', '待处理')])}")
print(f"趋势匹配数: {len(matches)}")
print(f"趋势缺口: {len(gaps)}\n")
if matches:
print(f"{'排名':>4} {'选题ID':6} {'综合分':>6} {'命中趋势':>6} {'趋势详情':30} {'选题标题'}")
print("-" * 100)
for i, m in enumerate(matches[:10], 1):
trends_str = ", ".join(t["trend"][:10] for t in m["matched_trends"][:3])
print(f"{i:>4} {m['topic_id']:6} {m['total_score']:>6.1f} {m['matched_count']:>6} {trends_str:30} {m['topic_title'][:40]}")
if gaps:
print(f"\n=== 趋势缺口(无匹配选题)===")
for g in gaps:
print(f" ⚠️ {g['topic']}{g.get('domain','')})— {g.get('reason','')}")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
print_report()
print()
new = suggest_new_topics()
if new:
print(f"\n新选题建议已保存到 automation/data/topic_suggestions.json")
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
热点趋势感知模块
- LLM 生成当前领域热点话题
- 可扩展接入外部热搜 API
"""
import json, datetime, logging, sys
from pathlib import Path
from typing import List, Dict
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
from app.core.nvidia_client import call_llm
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TRENDS_FILE = DATA_DIR / "trends.json"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
logging.basicConfig(
level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler(LOGS_DIR / f"trends_{TODAY}.log"), logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
DOMAINS = ["远程工作", "AI工具", "可持续生活", "知识管理", "数字生活", "科技人文"]
def fetch_llm_trends() -> List[Dict]:
prompt = f"""你是社交媒体趋势分析师。列出今天(2026年5月)中文互联网上最热的10个话题,要求:
1. 覆盖以下领域:{', '.join(DOMAINS)}
2. 每个话题包含:领域、话题名称、热度原因(1句话)、相关热搜词(3个)
3. 优先选择在知乎/微博/小红书上有讨论度的话题
4. 输出 JSON 数组,格式:
[{{"domain": "领域", "topic": "话题名", "reason": "热度原因", "hot_keywords": ["词1","词2","词3"], "platform": "知乎/微博/小红书"}}]
只输出 JSON,不要其他文字。"""
try:
resp = call_llm(prompt, temperature=0.4, max_tokens=2000)
resp = resp.strip()
if resp.startswith("```"):
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
trends = json.loads(resp)
if isinstance(trends, list):
return trends
except Exception as e:
logger.warning(f"LLM 趋势获取失败: {e}")
return []
def save_trends(trends: List[Dict]):
data = {
"date": TODAY,
"updated_at": datetime.datetime.now().isoformat(),
"trends": trends
}
DATA_DIR.mkdir(parents=True, exist_ok=True)
TRENDS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
logger.info(f"趋势数据已保存: {len(trends)}")
def load_trends() -> List[Dict]:
if TRENDS_FILE.exists():
try:
data = json.loads(TRENDS_FILE.read_text(encoding='utf-8'))
if data.get("date") == TODAY:
return data.get("trends", [])
except:
pass
return []
def get_trending_topics(domain: str = None, top_k: int = 5) -> List[Dict]:
trends = load_trends()
if domain:
trends = [t for t in trends if domain in t.get("domain", "") or t.get("domain", "") in domain]
return trends[:top_k]
def get_trend_context(domain: str = None) -> str:
trends = get_trending_topics(domain, top_k=3)
if not trends:
return "暂无趋势数据"
lines = ["## 当前热点趋势", ""]
for t in trends:
keywords = ", ".join(t.get("hot_keywords", []))
lines.append(f"- **{t['topic']}**{t.get('platform','')}):{t.get('reason','')}")
if keywords:
lines.append(f" 热搜词:{keywords}")
return "\n".join(lines)
def main():
logger.info("开始获取热点趋势...")
trends = fetch_llm_trends()
if trends:
save_trends(trends)
for t in trends:
print(f" [{t.get('domain','?')}] {t['topic']}{t.get('platform','')}")
else:
print("未获取到趋势数据")
print(f"完成,共 {len(trends)}")
if __name__ == "__main__":
main()
+8 -4
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
import json
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
print(f"Total topics: {len(data)}")
for t in data[:5]:
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from db_helper import export_topics_to_json
topics = export_topics_to_json()
print(f"Total topics: {len(topics)}")
for t in topics[:5]:
print(f"- {t.get('id')}: {t.get('title','')[:40]}")
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""实时搜索模块:使用 Bing 搜索获取真实结果,为研究提供数据支撑"""
import sys, json, logging, re
from pathlib import Path
from typing import List, Dict
from datetime import datetime
PROJECT_ROOT = Path(__file__).parent.parent
DATA_DIR = PROJECT_ROOT / "automation" / "data"
logger = logging.getLogger(__name__)
CACHE_FILE = DATA_DIR / "search_cache.json"
CACHE_TTL = 3600 * 6
_skip_domains = ['baike.baidu.com', 'zdic.net', 'hanyu.baidu.com', 'dict.cn']
_cache = None
def _load_cache() -> Dict:
global _cache
if _cache is not None:
return _cache
if CACHE_FILE.exists():
try:
_cache = json.loads(CACHE_FILE.read_text(encoding='utf-8'))
return _cache
except:
pass
_cache = {}
return _cache
def _save_cache():
global _cache
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
CACHE_FILE.write_text(json.dumps(_cache, ensure_ascii=False, indent=2), encoding='utf-8')
def _is_relevant(result: Dict, query: str) -> bool:
url = result.get('url', '')
if any(d in url for d in _skip_domains):
return False
text = result.get('title', '') + result.get('snippet', '')
query_words = [w for w in re.split(r'[\s,]+', query) if len(w) >= 2]
if not query_words:
return True
match_count = sum(1 for w in query_words if w in text)
return match_count >= max(1, int(len(query_words) * 0.3))
def search(query: str, max_results: int = 5, use_cache: bool = True) -> List[Dict]:
cache_key = f"{query}_{max_results}"
if use_cache:
cache = _load_cache()
if cache_key in cache:
entry = cache[cache_key]
if datetime.now().timestamp() - entry.get('ts', 0) < CACHE_TTL:
logger.info(f"缓存命中: {query[:30]}")
return entry['results']
try:
from bs4 import BeautifulSoup
import requests
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
url = 'https://cn.bing.com/search?q=' + requests.utils.quote(query) + '&setlang=zh-cn&cc=cn'
r = requests.get(url, headers=headers, timeout=15)
soup = BeautifulSoup(r.text, 'html.parser')
results = []
for li in soup.find_all('li', class_='b_algo'):
if len(results) >= max_results:
break
h2 = li.find('h2')
if not h2:
continue
a = h2.find('a')
if not a or not a.get('href'):
continue
title = a.get_text(strip=True)
href = a['href']
p = li.find('p')
snippet = p.get_text(strip=True) if p else ''
if title and href and not href.startswith('javascript'):
r = {'title': title, 'url': href, 'snippet': snippet[:200]}
if _is_relevant(r, query):
results.append(r)
if use_cache:
_load_cache()
_cache[cache_key] = {'ts': datetime.now().timestamp(), 'results': results}
_save_cache()
logger.info(f"搜索完成: {query[:40]} -> {len(results)}")
return results
except Exception as e:
logger.warning(f"搜索失败: {query[:30]} -> {e}")
return []
def _extract_keywords(text: str, max_words: int = 4) -> str:
sep = r'[:,。!?\s()()""\u201c\u201d#+\-*\d]'
words = [w.strip() for w in re.split(sep, text) if len(w.strip()) >= 2]
seen = set()
result = []
for w in words:
if w not in seen:
seen.add(w)
result.append(w)
return ' '.join(result[:max_words])
def _make_queries(topic: Dict) -> List[str]:
title = topic.get('title', '')
field = topic.get('field', '')
core = topic.get('core_concept', '')
pain = topic.get('audience_pain', '')
queries = []
kw = _extract_keywords(title)
if kw:
queries.append(kw)
core_kw = _extract_keywords(core, 3)
if core_kw and core_kw != kw:
queries.append(core_kw)
if field and kw:
queries.append(field + ' ' + kw.split()[0] if kw.split() else field)
if pain:
pain_kw = _extract_keywords(pain, 3)
if pain_kw and pain_kw not in queries:
queries.append(pain_kw)
if kw:
queries.append(kw + ' 2025 2026')
queries.append(kw + ' 案例')
return queries
def enrich_topic_research(topic: Dict) -> str:
queries = _make_queries(topic)
seen_urls = set()
all_results = []
for q in queries:
for r in search(q, max_results=3):
if r['url'] not in seen_urls:
seen_urls.add(r['url'])
all_results.append(r)
if not all_results:
return ""
lines = ["\n## 实时搜索数据\n"]
for r in all_results[:6]:
lines.append("- **" + r['title'] + "**")
lines.append(" " + r['url'])
if r.get('snippet'):
lines.append(" > " + r['snippet'][:200])
lines.append("")
return '\n'.join(lines)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
results = search("Obsidian AI 第二大脑 搭建")
print(json.dumps(results, ensure_ascii=False, indent=2))
+298 -87
View File
@@ -1,32 +1,20 @@
#!/usr/bin/env python3
"""
撰写阶段:基于大纲和选题生成完整文章(三平台版本)- 数据库版
"""
import json, datetime, logging, sys, re, subprocess
import json, datetime, logging, sys, re
from pathlib import Path
from typing import Dict, List
PROJECT_ROOT = Path(__file__).parent.parent
# 添加项目根和 backend 路径,以导入 app.core.llm_client
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
# 导入 LLM 客户端(NVIDIA
from app.database import SessionLocal
from app.models import Topic
from db_helper import get_topic_by_id, update_topic_status
try:
from app.core.modelscope_client import expand_content_with_llm # type: ignore
HAVE_LLM = True # ModelScope
except ImportError as e:
logging.warning(f"LLM client unavailable: {e}")
from app.core.nvidia_client import call_llm
HAVE_LLM = True
except ImportError:
HAVE_LLM = False
# 导入数据库辅助模块
from db_helper import get_topic_by_id, update_topic_status
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
import mistune
DATA_DIR = PROJECT_ROOT / "automation" / "data"
OUTLINE_DIR = DATA_DIR / "outlines"
@@ -46,6 +34,23 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
_md_parser = mistune.create_markdown()
PLATFORM_CONFIG = {
"zhihu": {
"max_chars": 3000,
"style": "深度长文分析",
},
"wechat": {
"max_chars": 1500,
"style": "亲切口语化",
},
"xiaohongshu": {
"max_chars": 800,
"style": "图文笔记,emoji+标签",
},
}
class Writer:
def __init__(self, topic_id: str):
self.topic_id = topic_id
@@ -56,7 +61,6 @@ class Writer:
self.outline_content = outline_file.read_text(encoding='utf-8')
self.release_dir = RELEASE_DIR / TODAY
self.release_dir.mkdir(parents=True, exist_ok=True)
# 加载研究笔记(作为 LLM 上下文)
research_file = DATA_DIR / "research" / TODAY / f"{topic_id}_research.md"
self.research_notes = research_file.read_text(encoding='utf-8') if research_file.exists() else ""
@@ -67,16 +71,12 @@ class Writer:
return topic
def _clean_title(self, title: str) -> str:
"""去除标题中的指导性文字(如字数说明、MVP标记等)"""
import re
# 去掉括号中的说明:约200字、约300字、MVP、试行等
title = re.sub(r'[(]约\s*\d+字[)]', '', title)
title = re.sub(r'[(]MVP[)]', '', title)
title = re.sub(r'[(][^)]*?[)]', '', title) # 保守移除任意括号内容(可能误伤,但大纲通常不包含重要括号信息)
title = re.sub(r'[(][^)]*?[)]', '', title)
return title.strip()
def _parse_outline_sections(self) -> List[Dict]:
"""将大纲 Markdown 解析为结构化列表,保留层级和内容"""
sections = []
current = None
for line in self.outline_content.splitlines():
@@ -100,110 +100,321 @@ class Writer:
return sections
def _expand_section(self, section: Dict) -> str:
"""将大纲中的简短描述扩展为完整段落"""
content = section.get('content', '').strip()
# 如果有足够内容(>200字),直接返回
if len(content) > 200:
return content
# 如果内容极少,需要 LLM 扩写
if HAVE_LLM and len(content) < 150:
logger.info(f"使用 LLM 扩写章节: {section['title']}")
prompt = f"""你是一个真人写作者,在写一篇关于「{self.topic['title']}」的文章。现在要写「{section['title']}」这一节,你的笔记要点如下:
{content}
要求:
- 200-300字,用自己的话展开
- 用「你」或「我们」视角,不要用「我」
- 读起来像人在自然说话,不是AI在组装文字
- 避免「首先」「其次」「总的来说」「综上所述」这类套路表达
- 如果合适,可以加一句反问
直接输出段落正文。"""
try:
expanded = expand_content_with_llm(
topic=self.topic,
section_title=section['title'],
section_content=content,
context=self.research_notes
)
expanded = call_llm(prompt, temperature=0.6, max_tokens=1500)
if expanded and len(expanded.strip()) > len(content):
return expanded.strip()
else:
logger.warning("LLM 扩写失败,返回占位")
raise ValueError("Empty expansion")
except Exception as e:
logger.warning(f"LLM 扩写失败: {e},使用占位内容")
# 返回占位内容,保持流程继续
return f"{content}\n\n(本段内容需要人工补充:当前模型调用失败或未配置)"
# 否则返回原内容
logger.warning(f"LLM 扩写失败: {e}")
# Fallback: 将 bullet points 展开为段落
lines = [l.strip() for l in content.split('\n') if l.strip()]
if lines:
sentences = []
for line in lines:
text = line.lstrip('- *').strip()
if text:
for prefix in ['- ', '* ', '1. ', '2. ', '3. ', '4. ', '5. ']:
if line.startswith(prefix):
text = line[len(prefix):]
break
if text[-1] not in '。!?;':
text += ''
sentences.append(text)
if sentences:
return ' '.join(sentences)
return content
def generate_full_markdown(self) -> str:
"""根据大纲生成完整 Markdown 正文"""
sections = self._parse_outline_sections()
parts = []
for sec in sections:
if sec['level'] == 1:
continue
heading = f"{'#' * sec['level']} {sec['title']}"
parts.append(heading)
if sec.get('content'):
expanded = self._expand_section(sec)
parts.append(expanded + "\n\n")
parts.append(expanded + "\n")
full_md = "\n".join(parts).strip()
return full_md
def generate_platform_html(self, markdown: str, platform: str) -> str:
"""将 Markdown 转换为平台 HTML(基于模板)"""
title = self.topic['title']
def _adapt_for_platform(self, markdown: str, platform: str) -> str:
cfg = PLATFORM_CONFIG[platform]
# 加载模板
platform_prompts = {
"zhihu": f"""你是一个知乎答主。把以下文章改写成知乎回答。
## 知乎回答的特点
- 知乎用户习惯理性分析、数据支撑、逻辑递进
- 开头直击问题本质,不绕弯子
- 每一段讲一个观点,段与段之间有逻辑推进
## 改写要求
- 用第三人称或「我们」视角,不要用「我」
- 不要编个人经历——知乎读者在意的是分析质量,不是故事
- 开头可以抛数据、抛现象、抛一个矛盾点
- 不用"首先其次最后",用自然过渡
- 避免AI感表达:「总的来说」「综上所述」「值得注意的是」
- 字数:{cfg['max_chars']}字以内
## 原文
{markdown[:3000]}
## 输出
直接输出改写后的完整内容(仅正文),每段后空一行。""",
"wechat": f"""你是一个公众号作者。把以下文章改写成公众号推文。
## 公众号推文的特点
- 开头必须制造共鸣或好奇心,让读者愿意往下读
- 短段落,有节奏感,每段2-3行
- 核心观点加粗突出
## 改写要求
- 用「你」视角,**通篇不允许出现「我」字**
- 把原文中所有的「我」改成「你」或「很多人」或「有人」
- 结构可以和原文完全不同——公众号不需要全面的分析,抓住1-2个痛点打透就行
- 可以删减原文内容,保留最有力的观点
- 避免「综上所述」「值得注意的是」「换言之」
- 字数:{cfg['max_chars']}字以内
## 原文
{markdown[:3000]}
## 输出
直接输出改写后的完整内容(仅正文),每段后空一行。""",
"xiaohongshu": f"""你是一个小红书用户。把以下文章改写成小红书笔记。
## 小红书笔记的特点
- 极短!极短!极短!小红书用户没耐心看长文
- 直接给结论、给清单、给步骤
- 原文通常很深很全,但笔记只取最关键的2-3个点
## 改写要求
- **全文控制在 {cfg['max_chars']} 字以内**,多一个字都不要
- 用「你」视角,不要用「我」
- 开头一句抓住注意力,可以是结论、可以是一个反常识的观点
- 正文每段1-2句,可以完全打乱原文结构
- emoji每人段最多1个点缀,不要堆砌(✨💡✅🔸)
- 结尾加 #话题标签 3-5个
- 原文的深度分析全部砍掉,只留最 actionable 的内容
## 原文
{markdown[:3000]}
## 输出
直接输出改写后的完整内容(仅正文)。""",
}
if HAVE_LLM:
prompt = platform_prompts.get(platform, f"""将以下文章改写为适合{platform}平台版本,{cfg['max_chars']}字以内:
{markdown[:3000]}""")
try:
adapted = call_llm(prompt, temperature=0.7, max_tokens=2000)
if adapted and len(adapted.strip()) > 50:
adapted = adapted.strip()
# 字数强校验
max_c = cfg['max_chars']
if platform == "xiaohongshu" and len(adapted) > max_c * 1.2:
adapted = adapted[:max_c]
last_break = max(adapted.rfind(''), adapted.rfind('\n'), adapted.rfind(''), adapted.rfind(''))
if last_break > max_c // 2:
adapted = adapted[:last_break + 1]
# 微信/知乎清除残留的"我"
if platform in ("wechat", "zhihu"):
adapted = adapted.replace('', '')
logger.info(f"LLM 平台适配完成: {platform} ({len(adapted)}字, \"\"x{adapted.count('')})")
return adapted
except Exception as e:
logger.warning(f"LLM {platform} 适配失败: {e}")
except Exception as e:
logger.warning(f"LLM {platform} 适配失败: {e}")
if platform == "xiaohongshu":
lines = markdown.split('\n')
result = []
char_count = 0
for line in lines:
if char_count >= cfg['max_chars']:
break
if line.startswith('## '):
line = f"## ✨ {line[3:]}"
elif line.startswith('### '):
line = f"### 💡 {line[4:]}"
result.append(line)
char_count += len(line)
adapted = '\n'.join(result)
if adapted.count('#') == 0:
adapted = f"# {self.topic['title']}\n\n{adapted}"
return adapted
return markdown
def _get_platform_tags(self, platform: str) -> str:
field = self.topic.get('field', '')
title = self.topic.get('title', '')
core = self.topic.get('core_concept', '')
tag_prompts = {
"zhihu": f"根据文章信息,生成知乎文章分类标签(3-5个,每个2-4字)。知乎标签偏学术/行业分类,如「经济学」「消费心理学」「科技趋势」。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。",
"wechat": f"根据文章信息,生成微信公众号文章标签(3-5个,每个2-4字)。公众号标签偏话题/兴趣分类,如「省钱攻略」「生活方式」「成长干货」。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。",
"xiaohongshu": f"根据文章信息,生成小红书笔记标签(3-5个,每个2-4字)。小红书标签偏场景/人群分类,如「实用干货」「学生党」「打工人必看」「好物分享」。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。",
}
if HAVE_LLM:
prompt = tag_prompts.get(platform, f"根据文章信息生成适合{platform}的标签。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。")
try:
tags_text = call_llm(prompt, temperature=0.2, max_tokens=100)
if tags_text:
tags = [t.strip('#') for t in tags_text.strip().split() if t.strip('#')]
if tags:
return " ".join(f'<span class="tag">{t}</span>' for t in tags[:5])
except Exception:
pass
tags = []
if field:
import re
parts = re.split(r'[/、与和及]', field)
for p in parts:
p = p.strip()
if len(p) >= 2:
tags.append(p)
if len(parts) == 1 and len(parts[0]) > 4:
for i in range(0, len(parts[0]), 2):
chunk = parts[0][i:i+2]
if len(chunk) == 2:
tags.append(chunk)
tags.pop(0)
platform_extra = {"zhihu": ["职场"], "xiaohongshu": ["生活"]}
for t in platform_extra.get(platform, []):
if t not in tags:
tags.append(t)
if not tags:
tags = ["科技"]
seen = set()
return " ".join(f'<span class="tag">{t}</span>' for t in tags if t not in seen and not seen.add(t))
def _optimize_title(self, platform: str) -> str:
original = self.topic['title']
if not HAVE_LLM:
return original
title_templates = {
"zhihu": f"""你是一个知乎用户,在写一个回答的标题。
原文标题:{original}
领域:{self.topic.get('field', '')}
要求:
- 有信息量、带数字或对比最好
- 不要太长,20字以内
- 风格参考知乎真实高赞标题,不要套路句式
- 避免:「如何……」废句式、「XXX指南/手册/全攻略」
生成 3 个选项,每行一个。""",
"wechat": f"""你是一个公众号作者,在给文章起标题。
原文标题:{original}
领域:{self.topic.get('field', '')}
要求:
- 制造点好奇心,让人想点开看
- 口语化,不要书面腔
- 不要感叹号堆砌,不要「重磅/震惊/紧急」
- 参考真实公众号标题的感觉
生成 3 个选项,每行一个。""",
"xiaohongshu": f"""你是一个小红书用户,在给笔记起标题。
原文标题:{original}
领域:{self.topic.get('field', '')}
要求:
- 短,20字以内
- 带1个emoji点缀就行,不用多
- 有场景感或结果感
- 不要「必看/收藏/码住」
- 像真实用户写的,不是运营写的
生成 3 个选项,每行一个。""",
}
prompt = title_templates.get(platform, f"给以下文章改个吸引人的{platform}标题:{original}")
try:
resp = call_llm(prompt, temperature=0.7, max_tokens=200)
titles = []
for line in resp.strip().split('\n'):
line = line.strip()
if not line:
continue
line = re.sub(r'^\d+[.、)\s]+', '', line)
line = line.strip('*#- \t')
if line:
titles.append(line)
if titles:
logger.info(f"标题优化 [{platform}]: {titles[0][:50]}...")
return titles[0]
except Exception as e:
logger.warning(f"标题优化失败: {e}")
return original
def generate_platform_html(self, markdown: str, platform: str) -> str:
title = self._optimize_title(platform)
adapted = self._adapt_for_platform(markdown, platform)
tpl_path = TEMPLATES_DIR / f"{platform}.html"
if tpl_path.exists():
template = tpl_path.read_text(encoding='utf-8')
else:
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><meta name='viewport' content='width=device-width'><style>body{max-width:800px;margin:0 auto;padding:20px;font-family:-apple-system,sans-serif;line-height:1.8}</style></head><body><h1>{{TITLE}}</h1><!-- CONTENT --></body></html>"
# 替换变量
html = template.replace("{{TITLE}}", title).replace("{{DATE}}", TODAY).replace("{{GEN_TIME}}", GEN_TIME)
# 注入内容
html_content = self._markdown_to_html(markdown)
html_content = _md_parser(adapted)
html = html.replace("<!-- CONTENT -->", html_content)
# 平台特定标签补充
if platform == "zhihu":
tags = '<div class="tags">#科技 #职场</div>'
html = html.replace("<!-- TAGS -->", tags)
elif platform == "xiaohongshu":
hashtags = '<div class="hashtags">#AI #可持续 #生活方式</div>'
html = html.replace("<!-- HASHTAGS -->", hashtags)
elif platform == "wechat":
pass
tags_html = self._get_platform_tags(platform)
if tags_html:
html = html.replace("<!-- TAGS -->", tags_html)
else:
html = html.replace("<!-- TAGS -->", "")
return html
def _markdown_to_html(self, md: str) -> str:
"""极简 markdown 转换(仅本场景使用)"""
lines = md.split('\n')
html_parts = []
for line in lines:
if line.startswith('# '):
html_parts.append(f"<h1>{line[2:]}</h1>")
elif line.startswith('## '):
html_parts.append(f"<h2>{line[3:]}</h2>")
elif line.startswith('### '):
html_parts.append(f"<h3>{line[4:]}</h3>")
elif line.strip().startswith('- '):
html_parts.append(f"<li>{line[2:]}</li>")
elif re.match(r'^\d+\. ', line):
content = re.sub(r'^\d+\. ', '', line)
html_parts.append(f"<li>{content}</li>")
elif line.strip():
html_parts.append(f"<p>{line}</p>")
else:
html_parts.append("")
return "\n".join(html_parts)
def save_html(self, html: str, platform: str) -> Path:
out_dir = self.release_dir / platform
out_dir.mkdir(parents=True, exist_ok=True)
filename = f"{platform}_{self.topic_id}_{platform}.html"
filename = f"{platform}_{self.topic_id}.html"
out_path = out_dir / filename
out_path.write_text(html, encoding='utf-8')
logger.info(f"HTML 生成: {out_path}")
return out_path
def mark_draft(self):
"""标记选题为「待审查」"""
# 更新数据库状态
update_topic_status(self.topic_id, 'review')
logger.info(f"选题 {self.topic_id} 状态已更新为待审查(数据库)")
-276
View File
@@ -1,276 +0,0 @@
#!/usr/bin/env python3
"""
撰写阶段:基于大纲和选题生成完整文章(三平台版本)
"""
import json, datetime, logging, sys, re, subprocess
from pathlib import Path
from typing import Dict, List
PROJECT_ROOT = Path(__file__).parent.parent
# 添加项目根和 backend 路径,以导入 app.core.llm_client
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
# 导入 LLM 客户端(NVIDIA
try:
from app.core.nvidia_client import expand_content_with_llm # type: ignore
HAVE_LLM = True
except ImportError as e:
logging.warning(f"LLM client unavailable: {e}")
HAVE_LLM = False
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
OUTLINE_DIR = DATA_DIR / "outlines"
RELEASE_DIR = DATA_DIR / "releases"
TEMPLATES_DIR = PROJECT_ROOT / "automation" / "templates"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOGS_DIR / f"writer_{TODAY}.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class Writer:
def __init__(self, topic_id: str):
self.topic_id = topic_id
self.topic = self._load_topic()
outline_file = OUTLINE_DIR / TODAY / f"{topic_id}_outline.md"
if not outline_file.exists():
raise FileNotFoundError(f"Outline not found: {outline_file}")
self.outline_content = outline_file.read_text(encoding='utf-8')
self.release_dir = RELEASE_DIR / TODAY
self.release_dir.mkdir(parents=True, exist_ok=True)
# 加载研究笔记(作为 LLM 上下文)
research_file = DATA_DIR / "research" / TODAY / f"{topic_id}_research.md"
self.research_notes = research_file.read_text(encoding='utf-8') if research_file.exists() else ""
def _load_topic(self) -> Dict:
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
for t in topics:
if t['id'] == self.topic_id:
return t
raise ValueError(f"Topic {self.topic_id} not found")
def _clean_title(self, title: str) -> str:
"""去除标题中的指导性文字(如字数说明、MVP标记等)"""
import re
# 去掉括号中的说明:约200字、约300字、MVP、试行等
title = re.sub(r'[(]约\s*\d+字[)]', '', title)
title = re.sub(r'[(]MVP[)]', '', title)
title = re.sub(r'[(][^)]*?[)]', '', title) # 保守移除任意括号内容(可能误伤,但大纲通常不包含重要括号信息)
return title.strip()
def _parse_outline_sections(self) -> List[Dict]:
"""将大纲 Markdown 解析为结构化列表,保留层级和内容"""
sections = []
current = None
for line in self.outline_content.splitlines():
if line.startswith("# "):
if current:
sections.append(current)
current = {"level": 1, "title": line[2:].strip(), "content": ""}
elif line.startswith("## "):
if current:
sections.append(current)
current = {"level": 2, "title": line[3:].strip(), "content": ""}
elif line.startswith("### "):
if current:
sections.append(current)
current = {"level": 3, "title": line[4:].strip(), "content": ""}
else:
if current and line.strip():
current['content'] = current.get('content', '') + line + "\n"
if current:
sections.append(current)
return sections
def _expand_section(self, section: Dict) -> str:
"""将大纲中的简短描述扩展为完整段落"""
content = section.get('content', '').strip()
# 如果有足够内容(>200字),直接返回
if len(content) > 200:
return content
# 如果内容极少,需要 LLM 扩写
if HAVE_LLM and len(content) < 150:
logger.info(f"使用 LLM 扩写章节: {section['title']}")
try:
expanded = expand_content_with_llm(
topic=self.topic,
section_title=section['title'],
section_content=content,
context=self.research_notes
)
if expanded and len(expanded.strip()) > len(content):
return expanded.strip()
else:
logger.warning("LLM 扩写结果为空或过短,使用占位")
raise ValueError("Empty expansion")
except Exception as e:
logger.warning(f"LLM 扩写失败: {e},使用占位内容")
# 返回占位内容,保持流程继续
return f"{content}\n\n(本段内容需要人工补充:当前模型调用失败或未配置)"
# 否则返回原内容
return content
def generate_full_markdown(self) -> str:
"""根据大纲生成完整 Markdown 正文(不用原标题,全部由 LLM 扩写生成)"""
sections = self._parse_outline_sections()
parts = []
# 只保留 LLM 扩写的内容,不添加任何原始标题标记
for sec in sections:
# 如果内容极短,LLM 扩写后返回的完整段落中可能包含标题,我们不过滤
if sec.get('content'):
expanded = self._expand_section(sec)
parts.append(expanded + "\n\n")
full_md = "\n".join(parts).strip()
# 添加文末声明
full_md += f"\n<p>(本文由宇之然AI助手生成,数据来源可靠,内容经合规审查)</p>\n"
full_md += f"<p><em>生成时间:{TODAY}</em></p>\n"
return full_md
def generate_platform_html(self, markdown: str, platform: str) -> str:
"""将 Markdown 转换为平台 HTML(基于模板)"""
title = self.topic['title']
# 加载模板
tpl_path = TEMPLATES_DIR / f"{platform}.html"
if tpl_path.exists():
template = tpl_path.read_text(encoding='utf-8')
else:
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)
# 注入内容 (简单处理:markdown 转 HTML 可以用 marked.js 或 simple转换,这里暂时用 <pre> 包裹或简单段落化)
# 为了快速展示,我们将 markdown 的段落转换为 <p> 标签
# 实际中建议使用 markdown 库(如 python-markdown)转换
html_content = self._markdown_to_html(markdown)
html = html.replace("<!-- CONTENT -->", html_content)
# 平台特定标签补充
if platform == "zhihu":
tags = '<div class="tags">#科技 #职场</div>'
html = html.replace("<!-- TAGS -->", tags)
elif platform == "xiaohongshu":
hashtags = '<div class="hashtags">#AI #可持续 #生活方式</div>'
html = html.replace("<!-- HASHTAGS -->", hashtags)
elif platform == "wechat":
# 微信公众号可能还需要摘要等,模板已处理
pass
return html
def _markdown_to_html(self, md: str) -> str:
"""极简 markdown 转换(仅本场景使用)"""
lines = md.split('\n')
html_parts = []
for line in lines:
if line.startswith('# '):
html_parts.append(f"<h1>{line[2:]}</h1>")
elif line.startswith('## '):
html_parts.append(f"<h2>{line[3:]}</h2>")
elif line.startswith('### '):
html_parts.append(f"<h3>{line[4:]}</h3>")
elif line.strip().startswith('- '):
html_parts.append(f"<li>{line[2:]}</li>")
elif re.match(r'^\d+\. ', line):
content = re.sub(r'^\d+\. ', '', line)
html_parts.append(f"<li>{content}</li>")
elif line.strip():
html_parts.append(f"<p>{line}</p>")
else:
html_parts.append("") # 空行
return "\n".join(html_parts)
def save_html(self, html: str, platform: str) -> Path:
out_dir = self.release_dir / platform
out_dir.mkdir(parents=True, exist_ok=True)
filename = f"{platform}_{self.topic_id}_{platform}.html"
out_path = out_dir / filename
out_path.write_text(html, encoding='utf-8')
logger.info(f"HTML 生成: {out_path}")
return out_path
def mark_draft(self):
"""标记选题为「待发布」,同时更新数据库"""
# 更新 JSON 文件
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
topics = json.load(f)
updated = False
for t in topics:
if t.get('id') == self.topic_id:
t[status'] = 'draft'
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 = 'draft'
db.commit()
logger.info(f"选题 {self.topic_id} 状态已更新为 draft(数据库)")
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'] = 'draft'
# ready_at 留空,待合规审核通过后设置
break
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
json.dump(topics, f, ensure_ascii=False, indent=2)
logger.info(f"选题 {self.topic_id} 状态更新为「待发布」")
def run(self):
logger.info("开始撰写阶段")
markdown = self.generate_full_markdown()
results = {}
for platform in ["zhihu", "wechat", "xiaohongshu"]:
html = self.generate_platform_html(markdown, platform)
results[platform] = str(self.save_html(html, platform))
self.mark_draft()
logger.info(f"撰写完成,状态改为 draft,待合规审核")
return {"ok": True, "files": results}
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--topic-id', required=True, help='选题ID')
args = parser.parse_args()
w = Writer(args.topic_id)
result = w.run()
print(json.dumps(result, ensure_ascii=False))
sys.exit(0 if result['ok'] else 1)
if __name__ == "__main__":
main()
-278
View File
@@ -1,278 +0,0 @@
#!/usr/bin/env python3
"""
撰写阶段:基于大纲和选题生成完整文章(三平台版本)
"""
import json, datetime, logging, sys, re, subprocess
from pathlib import Path
from typing import Dict, List
PROJECT_ROOT = Path(__file__).parent.parent
# 添加项目根和 backend 路径,以导入 app.core.llm_client
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
# 导入 LLM 客户端(NVIDIA
try:
from app.core.nvidia_client import expand_content_with_llm # type: ignore
HAVE_LLM = True
except ImportError as e:
logging.warning(f"LLM client unavailable: {e}")
HAVE_LLM = False
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
DATA_DIR = PROJECT_ROOT / "automation" / "data"
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
OUTLINE_DIR = DATA_DIR / "outlines"
RELEASE_DIR = DATA_DIR / "releases"
TEMPLATES_DIR = PROJECT_ROOT / "automation" / "templates"
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(LOGS_DIR / f"writer_{TODAY}.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class Writer:
def __init__(self, topic_id: str):
self.topic_id = topic_id
self.topic = self._load_topic()
outline_file = OUTLINE_DIR / TODAY / f"{topic_id}_outline.md"
if not outline_file.exists():
raise FileNotFoundError(f"Outline not found: {outline_file}")
self.outline_content = outline_file.read_text(encoding='utf-8')
self.release_dir = RELEASE_DIR / TODAY
self.release_dir.mkdir(parents=True, exist_ok=True)
# 加载研究笔记(作为 LLM 上下文)
research_file = DATA_DIR / "research" / TODAY / f"{topic_id}_research.md"
self.research_notes = research_file.read_text(encoding='utf-8') if research_file.exists() else ""
def _load_topic(self) -> Dict:
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
for t in topics:
if t['id'] == self.topic_id:
return t
raise ValueError(f"Topic {self.topic_id} not found")
def _clean_title(self, title: str) -> str:
"""去除标题中的指导性文字(如字数说明、MVP标记等)"""
import re
# 去掉括号中的说明:约200字、约300字、MVP、试行等
title = re.sub(r'[(]约\s*\d+字[)]', '', title)
title = re.sub(r'[(]MVP[)]', '', title)
title = re.sub(r'[(][^)]*?[)]', '', title) # 保守移除任意括号内容(可能误伤,但大纲通常不包含重要括号信息)
return title.strip()
def _parse_outline_sections(self) -> List[Dict]:
"""将大纲 Markdown 解析为结构化列表,保留层级和内容"""
sections = []
current = None
for line in self.outline_content.splitlines():
if line.startswith("# "):
if current:
sections.append(current)
current = {"level": 1, "title": line[2:].strip(), "content": ""}
elif line.startswith("## "):
if current:
sections.append(current)
current = {"level": 2, "title": line[3:].strip(), "content": ""}
elif line.startswith("### "):
if current:
sections.append(current)
current = {"level": 3, "title": line[4:].strip(), "content": ""}
else:
if current and line.strip():
current['content'] = current.get('content', '') + line + "\n"
if current:
sections.append(current)
return sections
def _expand_section(self, section: Dict) -> str:
"""将大纲中的简短描述扩展为完整段落"""
content = section.get('content', '').strip()
# 如果有足够内容(>200字),直接返回
if len(content) > 200:
return content
# 如果内容极少,需要 LLM 扩写
if HAVE_LLM and len(content) < 150:
logger.info(f"使用 LLM 扩写章节: {section['title']}")
try:
expanded = expand_content_with_llm(
topic=self.topic,
section_title=section['title'],
section_content=content,
context=self.research_notes
)
if expanded and len(expanded.strip()) > len(content):
return expanded.strip()
else:
logger.warning("LLM 扩写结果为空或过短,使用占位")
raise ValueError("Empty expansion")
except Exception as e:
logger.warning(f"LLM 扩写失败: {e},使用占位内容")
# 返回占位内容,保持流程继续
return f"{content}\n\n(本段内容需要人工补充:当前模型调用失败或未配置)"
# 否则返回原内容
return content
def generate_full_markdown(self) -> str:
"""根据大纲生成完整 Markdown 正文(不用原标题,全部由 LLM 扩写生成)"""
sections = self._parse_outline_sections()
parts = []
# 只保留 LLM 扩写的内容,不添加任何原始标题标记
for sec in sections:
# 如果内容极短,LLM 扩写后返回的完整段落中可能包含标题,我们不过滤
if sec.get('content'):
expanded = self._expand_section(sec)
parts.append(expanded + "\n\n")
full_md = "\n".join(parts).strip()
# 添加文末声明
full_md += f"\n<p>(本文由宇之然AI助手生成,数据来源可靠,内容经合规审查)</p>\n"
full_md += f"<p><em>生成时间:{TODAY}</em></p>\n"
return full_md
def generate_platform_html(self, markdown: str, platform: str) -> str:
"""将 Markdown 转换为平台 HTML(基于模板)"""
title = self.topic['title']
# 加载模板
tpl_path = TEMPLATES_DIR / f"{platform}.html"
if tpl_path.exists():
template = tpl_path.read_text(encoding='utf-8')
else:
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)
gen_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
html = html.replace("{{GEN_TIME}}", gen_time)
# 注入内容 (简单处理:markdown 转 HTML 可以用 marked.js 或 simple转换,这里暂时用 <pre> 包裹或简单段落化)
# 为了快速展示,我们将 markdown 的段落转换为 <p> 标签
# 实际中建议使用 markdown 库(如 python-markdown)转换
html_content = self._markdown_to_html(markdown)
html = html.replace("<!-- CONTENT -->", html_content)
# 平台特定标签补充
if platform == "zhihu":
tags = '<div class="tags">#科技 #职场</div>'
html = html.replace("<!-- TAGS -->", tags)
elif platform == "xiaohongshu":
hashtags = '<div class="hashtags">#AI #可持续 #生活方式</div>'
html = html.replace("<!-- HASHTAGS -->", hashtags)
elif platform == "wechat":
# 微信公众号可能还需要摘要等,模板已处理
pass
return html
def _markdown_to_html(self, md: str) -> str:
"""极简 markdown 转换(仅本场景使用)"""
lines = md.split('\n')
html_parts = []
for line in lines:
if line.startswith('# '):
html_parts.append(f"<h1>{line[2:]}</h1>")
elif line.startswith('## '):
html_parts.append(f"<h2>{line[3:]}</h2>")
elif line.startswith('### '):
html_parts.append(f"<h3>{line[4:]}</h3>")
elif line.strip().startswith('- '):
html_parts.append(f"<li>{line[2:]}</li>")
elif re.match(r'^\d+\. ', line):
content = re.sub(r'^\d+\. ', '', line)
html_parts.append(f"<li>{content}</li>")
elif line.strip():
html_parts.append(f"<p>{line}</p>")
else:
html_parts.append("") # 空行
return "\n".join(html_parts)
def save_html(self, html: str, platform: str) -> Path:
out_dir = self.release_dir / platform
out_dir.mkdir(parents=True, exist_ok=True)
filename = f"{platform}_{self.topic_id}_{platform}.html"
out_path = out_dir / filename
out_path.write_text(html, encoding='utf-8')
logger.info(f"HTML 生成: {out_path}")
return out_path
def mark_draft(self):
"""标记选题为「待发布」,同时更新数据库"""
# 更新 JSON 文件
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
topics = json.load(f)
updated = False
for t in topics:
if t.get('id') == self.topic_id:
t['status'] = 'draft'
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 = 'draft'
db.commit()
logger.info(f"选题 {self.topic_id} 状态已更新为 draft(数据库)")
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'] = 'draft'
# ready_at 留空,待合规审核通过后设置
break
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
json.dump(topics, f, ensure_ascii=False, indent=2)
logger.info(f"选题 {self.topic_id} 状态更新为「待发布」")
def run(self):
logger.info("开始撰写阶段")
markdown = self.generate_full_markdown()
results = {}
for platform in ["zhihu", "wechat", "xiaohongshu"]:
html = self.generate_platform_html(markdown, platform)
results[platform] = str(self.save_html(html, platform))
self.mark_draft()
logger.info(f"撰写完成,状态改为 draft,待合规审核")
return {"ok": True, "files": results}
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--topic-id', required=True, help='选题ID')
args = parser.parse_args()
w = Writer(args.topic_id)
result = w.run()
print(json.dumps(result, ensure_ascii=False))
sys.exit(0 if result['ok'] else 1)
if __name__ == "__main__":
main()