Initial commit: yu-zhi-ran platform with automation integration
This commit is contained in:
@@ -0,0 +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':
|
||||
t['priority_score'] = 11
|
||||
elif t['id'] == 'B05':
|
||||
t['priority_score'] = 10
|
||||
json.dump(data, open('automation/data/sustainability_topics.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
|
||||
print('优先级调整完成:D01=11, B05=10')
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
批量合规审查脚本
|
||||
遍历指定日期所有发布版本,执行合规检查,生成汇总报告
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
from scripts.compliance_checker import check_article
|
||||
|
||||
# 配置
|
||||
RELEASE_DIR = PROJECT_ROOT / "automation" / "data" / "releases"
|
||||
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
|
||||
TODAY = "2026-04-16" # 可参数化
|
||||
|
||||
def load_topics():
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def extract_topic_id(filename: str) -> str:
|
||||
"""从文件名提取 topic ID,如 zhihu_A01_zhihu.html -> A01"""
|
||||
parts = filename.stem.split('_')
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
def main():
|
||||
topics = load_topics()
|
||||
topics_by_id = {t['id']: t for t in topics}
|
||||
|
||||
release_path = RELEASE_DIR / TODAY
|
||||
if not release_path.exists():
|
||||
print(f"错误:发布日期目录不存在 {release_path}")
|
||||
return
|
||||
|
||||
html_files = list(release_path.rglob("*.html"))
|
||||
print(f"找到 {len(html_files)} 个HTML文件,开始合规审查...\n")
|
||||
|
||||
results = []
|
||||
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
|
||||
|
||||
# 读取HTML
|
||||
with open(html_file, 'r', encoding='utf-8') as f:
|
||||
html_content = f.read()
|
||||
|
||||
# 执行合规检查
|
||||
result = check_article(html_content, platform, topic_data)
|
||||
result['file'] = str(html_file.relative_to(PROJECT_ROOT))
|
||||
result['platform'] = platform
|
||||
result['topic_id'] = topic_id
|
||||
result['topic_title'] = topic_data.get('title') if topic_data else "未知"
|
||||
results.append(result)
|
||||
|
||||
status = "✅ PASS" if result['passed'] else "❌ FAIL"
|
||||
print(f"{status} {topic_id} {platform:12} {result['topic_title'][:30]:30} 问题数: {len(result['issues'])} 得分: {result['score']}")
|
||||
|
||||
# 汇总报告
|
||||
passed = sum(1 for r in results if r['passed'])
|
||||
failed = len(results) - passed
|
||||
avg_score = sum(r['score'] for r in results) / len(results) if results else 0
|
||||
|
||||
print(f"\n========== 合规审查汇总 ==========")
|
||||
print(f"总计: {len(results)} 篇")
|
||||
print(f"通过: {passed} 篇")
|
||||
print(f"失败: {failed} 篇")
|
||||
print(f"平均分: {avg_score:.1f}")
|
||||
|
||||
# 保存详细报告
|
||||
report = {
|
||||
"date": TODAY,
|
||||
"summary": {
|
||||
"total": len(results),
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"average_score": avg_score
|
||||
},
|
||||
"details": results
|
||||
}
|
||||
report_file = PROJECT_ROOT / "automation" / "data" / "drafts" / TODAY / "compliance_summary.json"
|
||||
report_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(report_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n📁 详细报告已保存: {report_file}")
|
||||
|
||||
# 列出失败项
|
||||
if failed > 0:
|
||||
print("\n⚠️ 需要修复的文章:")
|
||||
for r in results:
|
||||
if not r['passed']:
|
||||
print(f" {r['file']}")
|
||||
for issue in r['issues'][:3]: # 只显示前3个问题
|
||||
print(f" - {issue['type']}/{issue.get('category','')}: {issue.get('suggestion','')}")
|
||||
if len(r['issues']) > 3:
|
||||
print(f" ... 等共{len(r['issues'])}个问题")
|
||||
else:
|
||||
print("\n🎉 所有文章均通过合规审查!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
|
||||
for t in data:
|
||||
if t['id'] == 'B05':
|
||||
t['priority_score'] = 15
|
||||
print(f"B05 priority_score set to {t['priority_score']}")
|
||||
json.dump(data, open('automation/data/sustainability_topics.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
|
||||
for t in data:
|
||||
if t and t.get('id') == 'D01':
|
||||
t['priority_score'] = 12
|
||||
print(f"D01 priority_score set to {t['priority_score']}")
|
||||
json.dump(data, open('automation/data/sustainability_topics.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/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'))
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/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')}")
|
||||
@@ -0,0 +1,580 @@
|
||||
#!/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, local
|
||||
url: Optional[str] = None # 可为空(如本地源)
|
||||
update_frequency: str = "daily"
|
||||
credibility: str = "medium"
|
||||
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 = "待处理" # 待处理/待审查/待发布/已发布
|
||||
lock_by: Optional[str] = None # 被哪个任务锁定
|
||||
lock_at: Optional[str] = None # 锁定时间
|
||||
created_at: Optional[str] = None # 创建时间
|
||||
|
||||
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 load_local_cases_from_db(self) -> List[SustainabilityCase]:
|
||||
"""从本地案例库加载历史案例,用于降级生成选题"""
|
||||
local_cases = []
|
||||
db_file = DATA_DIR / "sustainability_cases.json"
|
||||
if db_file.exists():
|
||||
try:
|
||||
with open(db_file, 'r', encoding='utf-8') as f:
|
||||
cases_data = json.load(f)
|
||||
# 取最近50个案例(按日期倒序)
|
||||
recent_cases = cases_data[-50:] if len(cases_data) > 50 else cases_data
|
||||
for case_dict in recent_cases:
|
||||
# 转换为 dataclass
|
||||
case = SustainabilityCase(**case_dict)
|
||||
local_cases.append(case)
|
||||
logger.info(f"从本地数据库加载了 {len(local_cases)} 个历史案例")
|
||||
except Exception as e:
|
||||
logger.error(f"读取本地案例库失败: {e}")
|
||||
return local_cases
|
||||
|
||||
def load_local_cases_from_markdown(self) -> List[SustainabilityCase]:
|
||||
"""从 Markdown 案例文件解析案例(备用)"""
|
||||
local_cases = []
|
||||
md_file = PROJECT_ROOT / "strategy" / "全球案例数据库-v1.md"
|
||||
if not md_file.exists():
|
||||
return local_cases
|
||||
|
||||
try:
|
||||
content = md_file.read_text(encoding='utf-8')
|
||||
# 简单解析:按 "#### ID:" 分割案例
|
||||
import re
|
||||
blocks = re.split(r'#### ID:', content)
|
||||
for block in blocks[1:]: # 第一个是引言
|
||||
case_data = {
|
||||
'id': 'LOCAL-UNKNOWN',
|
||||
'country': 'Global',
|
||||
'category': '未分类',
|
||||
'title': '',
|
||||
'core_idea': '',
|
||||
'data_facts': '',
|
||||
'global_advantage': '',
|
||||
'china_pain_point': '',
|
||||
'localization_suggestion': '',
|
||||
'mvp_action': '',
|
||||
'source_url': '',
|
||||
'credibility_rating': '⭐⭐',
|
||||
'china_applicability': '⭐⭐',
|
||||
'collection_date': TODAY
|
||||
}
|
||||
|
||||
# 提取字段
|
||||
title_match = re.search(r'标题[::]\s*(.+)\n', block)
|
||||
if title_match:
|
||||
case_data['title'] = title_match.group(1).strip()
|
||||
case_data['id'] = f"LOCAL-{hashlib.md5(title_match.group(1).encode()).hexdigest()[:6].upper()}"
|
||||
|
||||
country_match = re.search(r'国家[::]\s*(.+)\n', block)
|
||||
if country_match:
|
||||
case_data['country'] = country_match.group(1).strip()
|
||||
|
||||
field_match = re.search(r'领域[::]\s*(.+)\n', block)
|
||||
if field_match:
|
||||
field = field_match.group(1).strip()
|
||||
# 映射到子领域
|
||||
category_map = {
|
||||
'远程工作方式': '城市农业',
|
||||
'数字游民政策': '低碳出行',
|
||||
'AI副业服务': '环保科技产品',
|
||||
'一人公司模式': '循环消费',
|
||||
'未来技能趋势': '可持续饮食'
|
||||
}
|
||||
case_data['category'] = category_map.get(field, field[:4] if len(field) > 4 else field)
|
||||
|
||||
core_match = re.search(r'核心观点[::]([\s\S]*?)(?=数据/事实|$)', block)
|
||||
if core_match:
|
||||
case_data['core_idea'] = core_match.group(1).strip()[:500]
|
||||
|
||||
data_match = re.search(r'数据/事实[::]([\s\S]*?)(?=全球优势|$)', block)
|
||||
if data_match:
|
||||
case_data['data_facts'] = data_match.group(1).strip()[:200]
|
||||
|
||||
global_match = re.search(r'全球优势[::]([\s\S]*?)(?=中国痛点|$)', block)
|
||||
if global_match:
|
||||
case_data['global_advantage'] = global_match.group(1).strip()[:200]
|
||||
|
||||
pain_match = re.search(r'中国痛点[::]([\s\S]*?)(?=本土化建议|$)', block)
|
||||
if pain_match:
|
||||
case_data['china_pain_point'] = pain_match.group(1).strip()[:200]
|
||||
|
||||
local_match = re.search(r'本土化建议[::]([\s\S]*?)(?=MVP行动|$)', block)
|
||||
if local_match:
|
||||
case_data['localization_suggestion'] = local_match.group(1).strip()[:200]
|
||||
|
||||
mvp_match = re.search(r'MVP行动[::]([\s\S]*?)(?=来源URL|$)', block)
|
||||
if mvp_match:
|
||||
case_data['mvp_action'] = mvp_match.group(1).strip()[:200]
|
||||
|
||||
url_match = re.search(r'来源URL[::]\s*(.+)\n', block)
|
||||
if url_match:
|
||||
case_data['source_url'] = url_match.group(1).strip()
|
||||
|
||||
case = SustainabilityCase(**case_data)
|
||||
local_cases.append(case)
|
||||
|
||||
logger.info(f"从 Markdown 案例库解析了 {len(local_cases)} 个案例")
|
||||
except Exception as e:
|
||||
logger.error(f"解析 Markdown 案例库失败: {e}")
|
||||
return local_cases
|
||||
|
||||
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)
|
||||
elif source.type == 'api':
|
||||
# TODO: 实现API抓取
|
||||
pass
|
||||
elif source.type == 'local':
|
||||
# 本地源不产生新文章,后续降级处理
|
||||
pass
|
||||
|
||||
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 len(self.new_cases) < 2:
|
||||
logger.warning(f"外部源案例不足 ({len(self.new_cases)} < 2),启动降级策略")
|
||||
|
||||
# 优先:从本地JSON数据库加载最近案例
|
||||
local_cases = self.load_local_cases_from_db()
|
||||
if len(local_cases) < 2:
|
||||
# 备用:从Markdown案例库解析
|
||||
local_cases = self.load_local_cases_from_markdown()
|
||||
|
||||
if local_cases:
|
||||
# 随机选取2-3个本地案例作为本次选题的案例基础
|
||||
import random
|
||||
selected = random.sample(local_cases, min(3, len(local_cases)))
|
||||
self.new_cases.extend(selected)
|
||||
logger.info(f"降级:使用了 {len(selected)} 个本地案例")
|
||||
else:
|
||||
logger.error("降级失败:本地案例库为空")
|
||||
|
||||
# 4. 生成选题
|
||||
if self.new_cases:
|
||||
topic = self.generate_topic_from_cases(self.new_cases)
|
||||
if topic:
|
||||
# 标记为今日创建,并添加锁字段(表示未被占用)
|
||||
topic.created_at = datetime.datetime.now().isoformat()
|
||||
topic.lock_by = None
|
||||
topic.lock_at = None
|
||||
# 确保状态为「待处理」
|
||||
topic.status = "待处理"
|
||||
self.new_topics.append(topic)
|
||||
|
||||
# 5. 保存结果
|
||||
self.save_results()
|
||||
|
||||
# 6. 发送通知
|
||||
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()
|
||||
@@ -0,0 +1,438 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/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)
|
||||
self._check_inline_images(text)
|
||||
self._check_timeliness(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_inline_images(self, html: str):
|
||||
"""检查图片是否以内联方式嵌入(data:image)"""
|
||||
# 提取所有 img 标签的 src 属性值
|
||||
srcs = re.findall(r'<img\b[^>]*src=[\'"]([^\'"]+)[\'"]', html, re.IGNORECASE)
|
||||
for src in srcs:
|
||||
if not src.startswith('data:image/'):
|
||||
self.issues.append({
|
||||
"type": "资源合规",
|
||||
"category": "图片内联",
|
||||
"detail": f"图片未内联: {src[:50]}... 需手动修复"
|
||||
})
|
||||
|
||||
def _check_timeliness(self, text: str):
|
||||
years = re.findall(r'(19\d{2}|20[0-4]\d)', text)
|
||||
outdated = {y for y in years if int(y) < 2025}
|
||||
if outdated:
|
||||
self.issues.append({
|
||||
"type": "平台规则",
|
||||
"category": "时效性",
|
||||
"detail": f"使用过时年份: {', '.join(sorted(outdated))},需更新为2025年及以后的数据",
|
||||
"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))
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/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))
|
||||
@@ -0,0 +1,295 @@
|
||||
#!/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 dataclasses import dataclass, asdict
|
||||
|
||||
PROJECT_ROOT = Path('/root/.openclaw/workspaces/yzr-yxl/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"))
|
||||
try:
|
||||
from app.core.nvidia_client import call_llm
|
||||
HAVE_LLM = True
|
||||
except ImportError:
|
||||
HAVE_LLM = False
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
RELEASES_DIR = DATA_DIR / "releases"
|
||||
DRAFTS_DIR = DATA_DIR / "drafts"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.FileHandler(LOGS_DIR / f"optimizer_{TODAY}.log"), logging.StreamHandler()])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 平台白名单标签
|
||||
PLATFORM_TAGS = {
|
||||
"zhihu": ["科技", "职场"],
|
||||
"xiaohongshu": ["AI", "可持续", "生活方式"]
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class OptimizationResult:
|
||||
file: str
|
||||
platform: str
|
||||
topic_id: str
|
||||
title: str
|
||||
original_issues: int
|
||||
fixed_issues: int
|
||||
final_score: int
|
||||
status: str
|
||||
|
||||
def load_topic_map():
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
return {t['id']: t for t in topics}
|
||||
|
||||
|
||||
def update_topic_status(topic_id: str, status: str):
|
||||
"""更新选题状态(JSON + 数据库)"""
|
||||
# 更新 JSON
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
updated = False
|
||||
for t in topics:
|
||||
if t.get('id') == topic_id:
|
||||
t['status'] = status
|
||||
updated = True
|
||||
break
|
||||
if updated:
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
# 更新数据库
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from app.models import Topic
|
||||
db = SessionLocal()
|
||||
topic_db = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if topic_db:
|
||||
topic_db.status = status
|
||||
db.commit()
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"更新数据库失败: {e}")
|
||||
|
||||
def fix_wechat_title(html: str, title: str) -> str:
|
||||
"""微信标题优化:<title>和<h1>都控制长度(考虑后缀)"""
|
||||
suffix = f" - {TODAY} - 微信公众号"
|
||||
max_base_len = 32 - len(suffix) # <title> 中 base 部分允许的最大长度
|
||||
|
||||
# 处理 <title>...</title>
|
||||
title_tag = re.search(r'<title>([^<]+)</title>', html)
|
||||
if title_tag:
|
||||
full_title = title_tag.group(1)
|
||||
# 提取 base(去掉后缀)
|
||||
if full_title.endswith(suffix):
|
||||
base = full_title[:-len(suffix)]
|
||||
else:
|
||||
base = full_title.split(" - ")[0]
|
||||
if len(base) > max_base_len:
|
||||
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)
|
||||
# 如果 h1 包含后缀(不应该),去掉
|
||||
base_h1 = current_h1.split(" - ")[0] if " - " in current_h1 else current_h1
|
||||
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"])
|
||||
# 替换 <div class="tags">...</div>
|
||||
if '<div class="tags">' in html:
|
||||
old = html.split('<div class="tags">')[1].split('</div>')[0]
|
||||
html = html.replace(f'<div class="tags">{old}</div>', f'<div class="tags">{tags_str}</div>')
|
||||
elif platform == "xiaohongshu":
|
||||
tags_str = " ".join(f"#{t}" for t in PLATFORM_TAGS["xiaohongshu"])
|
||||
if '<div class="hashtags">' in html:
|
||||
old = html.split('<div class="hashtags">')[1].split('</div>')[0]
|
||||
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]):
|
||||
logs = []
|
||||
# 1. 标题优化(微信)
|
||||
if platform == "wechat":
|
||||
html = fix_wechat_title(html, topic_data.get("title", ""))
|
||||
logs.append("标题截断(含后缀)")
|
||||
# 2. 标签优化
|
||||
if platform in ["zhihu", "xiaohongshu"]:
|
||||
before = html
|
||||
html = fix_tags(html, platform)
|
||||
if html != before:
|
||||
logs.append(f"标签标准化为{PLATFORM_TAGS[platform]}")
|
||||
# 3. 图片内联检查
|
||||
# 提取所有 img 标签
|
||||
img_tags = re.findall(r'<img[^>]*>', html, re.IGNORECASE)
|
||||
for tag in img_tags:
|
||||
m = re.search(r'src=["\']([^"\']+)["\']', tag, re.IGNORECASE)
|
||||
if m:
|
||||
src = m.group(1)
|
||||
if not src.startswith('data:image/'):
|
||||
logs.append(f"图片未内联: {src[:50]}... 需手动修复")
|
||||
|
||||
# 4. LLM 内容优化(使用 NVIDIA step-3.5-flash)
|
||||
if HAVE_LLM:
|
||||
try:
|
||||
polish_prompt = f"""你是一个专业的内容润色助手。请优化以下文章内容,提升表达的专业性和可读性,保持原文事实、数据、章节结构不变,输出相同的HTML格式(保留<h2>, <h3>, <p>标签)。
|
||||
|
||||
原文:
|
||||
{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}")
|
||||
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
|
||||
|
||||
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():
|
||||
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]
|
||||
# 如果指定了 topic_ids,则只处理匹配的
|
||||
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']
|
||||
|
||||
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(topic_id, '待发布')
|
||||
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:
|
||||
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=0,
|
||||
fixed_issues=0,
|
||||
final_score=score,
|
||||
status="passed" if check_result['passed'] else "manual_review"
|
||||
))
|
||||
if not check_result['passed']:
|
||||
all_passed = False
|
||||
|
||||
# 更新选题状态
|
||||
for res in results:
|
||||
if res.status == "passed":
|
||||
tid = res.topic_id
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
topics = json.load(f)
|
||||
for t in topics:
|
||||
if t.get('id') == tid:
|
||||
t['status'] = '待发布'
|
||||
t['ready_at'] = TODAY
|
||||
t['compliance_score'] = res.final_score
|
||||
break
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 生成报告
|
||||
report_file = DRAFTS_DIR / TODAY / "optimization_report.json"
|
||||
report_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
report = {
|
||||
"date": TODAY,
|
||||
"summary": {
|
||||
"total_articles": len(results),
|
||||
"passed_auto": sum(1 for r in results if r.status == "passed"),
|
||||
"need_manual": sum(1 for r in results if r.status == "manual_review"),
|
||||
"average_score": sum(r.final_score for r in results) / len(results) if results else 0
|
||||
},
|
||||
"details": [asdict(r) for r in results],
|
||||
"all_passed": all_passed
|
||||
}
|
||||
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')} 篇自动通过")
|
||||
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)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='合规审查与优化任务')
|
||||
parser.add_argument('--topic-ids', help='逗号分隔的选题ID列表,例如: A01,B02')
|
||||
args = parser.parse_args()
|
||||
topic_ids = args.topic_ids.split(',') if args.topic_ids else None
|
||||
main(topic_ids)
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
宇之然内容创作流水线(研究 → 大纲 → 撰写 → 合规优化)v2
|
||||
"""
|
||||
|
||||
import json, datetime, logging, sys, subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
PROJECT_ROOT = Path('/root/.openclaw/workspaces/yzr-yxl/projects/yu-zhi-ran')
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
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")
|
||||
|
||||
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__)
|
||||
|
||||
def select_next_topic(topic_id: str = None) -> Dict:
|
||||
"""选择并锁定要创作的选题"""
|
||||
def save_topics(topics_list):
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics_list, f, ensure_ascii=False, indent=2)
|
||||
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
|
||||
if topic_id:
|
||||
# 指定ID,尝试直接锁定
|
||||
topic = next((t for t in topics if t['id'] == topic_id), None)
|
||||
if not topic:
|
||||
raise ValueError(f"Topic {topic_id} not found")
|
||||
# 检查状态
|
||||
if topic.get('status') != 'pending' and topic.get('status') != '待处理':
|
||||
raise ValueError(f"Topic {topic_id} status is {topic.get('status')}, cannot create")
|
||||
# 加锁
|
||||
topic['lock_by'] = 'creator'
|
||||
topic['lock_at'] = datetime.datetime.now().isoformat()
|
||||
save_topics(topics)
|
||||
return topic
|
||||
|
||||
# 自动选择:优先选pending且无锁的
|
||||
def is_available(t):
|
||||
status = t.get('status')
|
||||
# 只处理 pending 或 待处理
|
||||
if status not in ['pending', '待处理']:
|
||||
return False
|
||||
# 检查锁
|
||||
lock_by = t.get('lock_by')
|
||||
if lock_by:
|
||||
# 如果有人锁了,检查是否超时(>2小时)
|
||||
lock_at_str = t.get('lock_at')
|
||||
if lock_at_str:
|
||||
try:
|
||||
lock_at = datetime.datetime.fromisoformat(lock_at_str)
|
||||
if (datetime.datetime.now() - lock_at).total_seconds() < 7200:
|
||||
return False
|
||||
except:
|
||||
pass # 解析失败,认为是有效锁
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
available = [t for t in topics if is_available(t)]
|
||||
if not available:
|
||||
raise ValueError("No available topics to create (all locked or wrong status)")
|
||||
|
||||
available.sort(key=lambda t: t.get('priority_score', 0), reverse=True)
|
||||
chosen = available[0]
|
||||
|
||||
# 锁定
|
||||
chosen['lock_by'] = 'creator'
|
||||
chosen['lock_at'] = datetime.datetime.now().isoformat()
|
||||
save_topics(topics)
|
||||
|
||||
return chosen
|
||||
|
||||
def run_step(script_name: str, topic_id: str) -> bool:
|
||||
"""运行一个流水线步骤(research/outline/writer)"""
|
||||
script_path = PROJECT_ROOT / "scripts" / script_name
|
||||
cmd = ["python3", str(script_path), "--topic-id", topic_id]
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT), capture_output=True, text=True, timeout=300)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"{script_name} 失败: {result.stderr}")
|
||||
return False
|
||||
logger.info(f"{script_name} 完成: {result.stdout.strip()}")
|
||||
return True
|
||||
|
||||
def run_optimizer_step(topic_id: str) -> bool:
|
||||
"""运行合规优化步骤(只针对单个选题)"""
|
||||
script_path = PROJECT_ROOT / "scripts" / "compliance_optimizer.py"
|
||||
cmd = ["python3", str(script_path), "--topic-ids", topic_id]
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd=str(PROJECT_ROOT), capture_output=True, text=True, timeout=600)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"compliance_optimizer 失败: {result.stderr}")
|
||||
return False
|
||||
logger.info(f"compliance_optimizer 完成: {result.stdout.strip()}")
|
||||
return True
|
||||
|
||||
def run_pipeline(topic_id: str = None) -> Dict:
|
||||
"""运行完整流水线:研究 → 大纲 → 撰写 → 合规优化"""
|
||||
tid = None
|
||||
try:
|
||||
topic = select_next_topic(topic_id)
|
||||
tid = topic['id']
|
||||
logger.info(f"开始创作流水线: topic_id={tid}, title={topic.get('title')}")
|
||||
|
||||
# 1. 研究
|
||||
if not run_step("research.py", tid):
|
||||
return {"ok": False, "error": "research step failed"}
|
||||
|
||||
# 2. 大纲
|
||||
if not run_step("outline.py", tid):
|
||||
return {"ok": False, "error": "outline step failed"}
|
||||
|
||||
# 3. 撰写
|
||||
if not run_step("writer.py", tid):
|
||||
return {"ok": False, "error": "writer step failed"}
|
||||
|
||||
# 4. 合规优化(自动审核并标记为「待发布」)
|
||||
if not run_optimizer_step(tid):
|
||||
return {"ok": False, "error": "optimizer step failed"}
|
||||
|
||||
logger.info(f"创作流水线完成: topic_id={tid}")
|
||||
return {"ok": True, "topic_id": tid, "stdout": f"SUCCESS: Topic {tid} processed through full pipeline"}
|
||||
except Exception as e:
|
||||
logger.exception("流水线执行失败")
|
||||
return {"ok": False, "error": str(e)}
|
||||
finally:
|
||||
# 清理锁(无论成功失败)
|
||||
if tid:
|
||||
try:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
for t in topics:
|
||||
if t.get('id') == tid:
|
||||
# 如果成功或需要人工,保留状态,但清除锁
|
||||
t['lock_by'] = None
|
||||
t['lock_at'] = None
|
||||
break
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
logger.debug(f"已清理选题锁: {tid}")
|
||||
except Exception as ex:
|
||||
logger.error(f"清理锁失败: {ex}")
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='内容创作流水线(研究→大纲→撰写→合规优化)')
|
||||
parser.add_argument('--topic-id', help='指定选题ID,不指定则自动选择待处理选题')
|
||||
args = parser.parse_args()
|
||||
|
||||
result = run_pipeline(args.topic_id)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
sys.exit(0 if result['ok'] else 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/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()
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/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()
|
||||
Executable
+208
@@ -0,0 +1,208 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,651 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
独立图片生成脚本 - 供定时任务调用
|
||||
用法: python3 generate_images.py <article_title> [platform]
|
||||
示例: python3 generate_images.py \"上海阳台种菜一年\" zhihu
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.image_generator import ImageGenerator
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python3 generate_images.py <article_title> [platform=zhihu]")
|
||||
sys.exit(1)
|
||||
|
||||
title = sys.argv[1]
|
||||
platform = sys.argv[2] if len(sys.argv) > 2 else "zhihu"
|
||||
|
||||
generator = ImageGenerator()
|
||||
|
||||
print(f"开始生成图片...")
|
||||
print(f"文章标题: {title}")
|
||||
print(f"目标平台: {platform}")
|
||||
print(f"输出目录: {generator.output_dir}")
|
||||
|
||||
try:
|
||||
files = generator.generate_all_placeholders(title, platform)
|
||||
|
||||
print(f"\n✅ 成功生成 {len(files)} 张图片:")
|
||||
for name, path in files.items():
|
||||
size_kb = path.stat().st_size // 1024
|
||||
print(f" - {name}: {path.name} ({size_kb}KB)")
|
||||
|
||||
print(f"\n📁 图片保存在: {generator.output_dir}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"\n❌ 图片生成失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,454 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
文章配图自动生成器
|
||||
基于PIL,根据文章标题、内容自动生成适合各平台的配图
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
import yaml
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import random
|
||||
|
||||
# 确保项目根目录在路径中
|
||||
PROJECT_ROOT = Path('/root/.openclaw/workspaces/yzr-yxl/projects/yu-zhi-ran')
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# 加载配置
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
with open(CONFIG_DIR / "wecom_config.yaml", 'r', encoding='utf-8') as f:
|
||||
wecom_config = yaml.safe_load(f)
|
||||
|
||||
@dataclass
|
||||
class ImageSpec:
|
||||
"""图片规格"""
|
||||
platform: str
|
||||
width: int
|
||||
height: int
|
||||
format: str = "PNG"
|
||||
quality: int = 85
|
||||
bg_color: Tuple[int, int, int] = (255, 255, 255) # 白色背景
|
||||
accent_color: Tuple[int, int, int] = (76, 175, 80) # 品牌绿色 #4CAF50
|
||||
text_color: Tuple[int, int, int] = (51, 51, 51) # 深灰色
|
||||
|
||||
class ImageGenerator:
|
||||
"""图片生成器"""
|
||||
|
||||
def __init__(self, output_base: Path = None):
|
||||
self.output_base = output_base or (PROJECT_ROOT / "automation" / "images" / "generated")
|
||||
self.today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
self.output_dir = self.output_base / self.today
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 加载平台规格
|
||||
self.platform_specs = {}
|
||||
for platform, specs in wecom_config["image_specs"].items():
|
||||
self.platform_specs[platform] = ImageSpec(
|
||||
platform=platform,
|
||||
width=specs["width"],
|
||||
height=specs["height"],
|
||||
format=specs["format"],
|
||||
quality=specs["quality"]
|
||||
)
|
||||
|
||||
# 字体路径
|
||||
self.font_paths = self._find_chinese_fonts()
|
||||
|
||||
def _find_chinese_fonts(self) -> List[str]:
|
||||
"""查找系统中可用的中文字体"""
|
||||
font_paths = [
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", # 文泉驿微米黑
|
||||
"/usr/share/fonts/truetype/arphic/uming.ttc", # 文鼎PL中等
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
"/System/Library/Fonts/PingFang.ttc", # macOS
|
||||
"/System/Library/Fonts/STHeiti Medium.ttc", # macOS
|
||||
"C:\\Windows\\Fonts\\msyh.ttc", # Windows
|
||||
"C:\\Windows\\Fonts\\simsun.ttc"
|
||||
]
|
||||
available = [p for p in font_paths if os.path.exists(p)]
|
||||
return available if available else [None] # 回退到默认字体
|
||||
|
||||
def _get_font(self, size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
||||
"""获取合适的中文字体"""
|
||||
for font_path in self.font_paths:
|
||||
if font_path:
|
||||
try:
|
||||
return ImageFont.truetype(font_path, size)
|
||||
except:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
def generate_cover_image(self, title: str, subtitle: str = "", platform: str = "zhihu") -> Path:
|
||||
"""生成封面图"""
|
||||
spec = self.platform_specs.get(platform, self.platform_specs["zhihu"])
|
||||
|
||||
# 创建图片
|
||||
img = Image.new('RGB', (spec.width, spec.height), color=spec.bg_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 添加渐变背景
|
||||
for y in range(spec.height):
|
||||
# 从顶部到中间的渐变
|
||||
ratio = y / (spec.height * 0.6)
|
||||
r = int(255 * (1 - ratio) + 230 * ratio)
|
||||
g = int(255 * (1 - ratio) + 240 * ratio)
|
||||
b = int(255 * (1 - ratio) + 250 * ratio)
|
||||
draw.line([(0, y), (spec.width, y)], fill=(r, g, b))
|
||||
|
||||
# 绘制品牌标识区域(底部条纹)
|
||||
stripe_height = 20
|
||||
stripe_y = spec.height - stripe_height - 30
|
||||
draw.rectangle([0, stripe_y, spec.width, stripe_y + stripe_height], fill=spec.accent_color)
|
||||
draw.text((20, stripe_y + 5), "宇之然", fill=(255, 255, 255), font=self._get_font(14))
|
||||
|
||||
# 标题排版
|
||||
title_font = self._get_font(int(spec.height * 0.12), bold=True)
|
||||
subtitle_font = self._get_font(int(spec.height * 0.06))
|
||||
|
||||
# 自动换行处理
|
||||
max_width = spec.width * 0.9
|
||||
title_lines = self._wrap_text(title, title_font, max_width)
|
||||
subtitle_lines = self._wrap_text(subtitle, subtitle_font, max_width) if subtitle else []
|
||||
|
||||
# 计算总高度
|
||||
line_spacing = 1.2
|
||||
title_height = len(title_lines) * title_font.size * line_spacing
|
||||
subtitle_height = len(subtitle_lines) * subtitle_font.size * line_spacing
|
||||
total_text_height = title_height + subtitle_height + 20 # 间距
|
||||
|
||||
# 居中绘制
|
||||
start_y = (spec.height - total_text_height) // 2
|
||||
|
||||
# 绘制标题
|
||||
for i, line in enumerate(title_lines):
|
||||
y = start_y + i * (title_font.size * line_spacing)
|
||||
self._draw_centered_text(draw, line, y, spec.width, title_font, spec.text_color)
|
||||
|
||||
# 绘制副标题
|
||||
if subtitle_lines:
|
||||
subtitle_start_y = start_y + title_height + 10
|
||||
for i, line in enumerate(subtitle_lines):
|
||||
y = subtitle_start_y + i * (subtitle_font.size * line_spacing)
|
||||
self._draw_centered_text(draw, line, y, spec.width, subtitle_font, (102, 102, 102))
|
||||
|
||||
# 保存图片
|
||||
filename = f"cover_{platform}.{spec.format.lower()}"
|
||||
output_path = self.output_dir / filename
|
||||
img.save(output_path, quality=spec.quality)
|
||||
|
||||
return output_path
|
||||
|
||||
def generate_chart_image(self, chart_type: str, data: Dict, title: str, platform: str = "zhihu") -> Path:
|
||||
"""生成数据图表"""
|
||||
spec = self.platform_specs.get(platform, self.platform_specs["zhihu"])
|
||||
|
||||
img = Image.new('RGB', (spec.width, spec.height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 绘制标题
|
||||
title_font = self._get_font(36, bold=True)
|
||||
draw.text((50, 30), title, fill=spec.text_color, font=title_font)
|
||||
|
||||
# 根据图表类型绘制
|
||||
if chart_type == "bar":
|
||||
self._draw_bar_chart(draw, data, spec)
|
||||
elif chart_type == "pie":
|
||||
self._draw_pie_chart(draw, data, spec)
|
||||
elif chart_type == "line":
|
||||
self._draw_line_chart(draw, data, spec)
|
||||
else:
|
||||
# 默认显示文本
|
||||
text_font = self._get_font(24)
|
||||
draw.text((50, 150), f"图表类型: {chart_type}", fill=spec.text_color, font=text_font)
|
||||
draw.text((50, 200), f"数据: {json.dumps(data, ensure_ascii=False)}", fill=spec.text_color, font=text_font)
|
||||
|
||||
# 水印
|
||||
watermark_font = self._get_font(14)
|
||||
draw.text((spec.width - 150, spec.height - 30), "数据来源: 宇之然", fill=(150, 150, 150), font=watermark_font)
|
||||
|
||||
filename = f"data_chart_{platform}.png"
|
||||
output_path = self.output_dir / filename
|
||||
img.save(output_path, quality=spec.quality)
|
||||
|
||||
return output_path
|
||||
|
||||
def _draw_bar_chart(self, draw: ImageDraw.Draw, data: Dict, spec: ImageSpec):
|
||||
"""绘制柱状图"""
|
||||
# 数据格式: {"label1": value1, "label2": value2, ...}
|
||||
labels = list(data.keys())
|
||||
values = list(data.values())
|
||||
max_value = max(values) if values else 1
|
||||
|
||||
chart_area = {
|
||||
"left": 100,
|
||||
"top": 120,
|
||||
"right": spec.width - 50,
|
||||
"bottom": spec.height - 100
|
||||
}
|
||||
|
||||
chart_width = chart_area["right"] - chart_area["left"]
|
||||
chart_height = chart_area["bottom"] - chart_area["top"]
|
||||
|
||||
bar_width = chart_width // (len(values) * 2)
|
||||
gap = bar_width
|
||||
|
||||
# 绘制坐标轴
|
||||
draw.line([
|
||||
(chart_area["left"], chart_area["top"]),
|
||||
(chart_area["left"], chart_area["bottom"])
|
||||
], fill=(0, 0, 0), width=2)
|
||||
draw.line([
|
||||
(chart_area["left"], chart_area["bottom"]),
|
||||
(chart_area["right"], chart_area["bottom"])
|
||||
], fill=(0, 0, 0), width=2)
|
||||
|
||||
# 绘制柱子
|
||||
for i, (label, value) in enumerate(zip(labels, values)):
|
||||
x = chart_area["left"] + i * (bar_width + gap) + gap // 2
|
||||
bar_height = (value / max_value) * chart_height
|
||||
y_bottom = chart_area["bottom"]
|
||||
y_top = chart_area["bottom"] - bar_height
|
||||
|
||||
# 柱子(渐变色)
|
||||
for y in range(int(y_top), int(y_bottom)):
|
||||
ratio = (y - y_top) / bar_height if bar_height > 0 else 0
|
||||
r = int(76 + (100-76) * ratio)
|
||||
g = int(175 + (150-175) * ratio)
|
||||
b = int(80 + (120-80) * ratio)
|
||||
draw.line([(x, y), (x + bar_width, y)], fill=(r, g, b))
|
||||
|
||||
# 标签
|
||||
label_font = self._get_font(18)
|
||||
self._draw_centered_text(draw, label, y_bottom + 10, x + bar_width // 2, label_font, (80, 80, 80))
|
||||
|
||||
# 数值
|
||||
value_font = self._get_font(20, bold=True)
|
||||
self._draw_centered_text(draw, f"{value}", y_top - 10, x + bar_width // 2, value_font, spec.accent_color)
|
||||
|
||||
def _draw_pie_chart(self, draw: ImageDraw.Draw, data: Dict, spec: ImageSpec):
|
||||
"""绘制饼图"""
|
||||
# 简单实现:绘制圆形扇形
|
||||
center_x, center_y = spec.width // 2, spec.height // 2
|
||||
radius = min(spec.width, spec.height) // 3
|
||||
|
||||
total = sum(data.values()) if data else 1
|
||||
angle_start = 0
|
||||
|
||||
# 颜色调色板
|
||||
colors = [
|
||||
(76, 175, 80), (33, 150, 83), (139, 195, 74),
|
||||
(255, 193, 7), (255, 152, 0), (244, 67, 54)
|
||||
]
|
||||
|
||||
for i, (label, value) in enumerate(data.items()):
|
||||
angle_extent = (value / total) * 360
|
||||
color = colors[i % len(colors)]
|
||||
|
||||
# 绘制扇形
|
||||
draw.arc(
|
||||
[center_x - radius, center_y - radius, center_x + radius, center_y + radius],
|
||||
angle_start, angle_start + angle_extent,
|
||||
fill=color, width=radius * 2
|
||||
)
|
||||
angle_start += angle_extent
|
||||
|
||||
# 画中心白圆形成饼图效果
|
||||
inner_radius = radius * 0.5
|
||||
draw.ellipse(
|
||||
[center_x - inner_radius, center_y - inner_radius, center_x + inner_radius, center_y + inner_radius],
|
||||
fill=(255, 255, 255)
|
||||
)
|
||||
|
||||
# 绘制图例
|
||||
legend_y = spec.height - 80
|
||||
legend_x = 100
|
||||
for i, (label, value) in enumerate(data.items()):
|
||||
color = colors[i % len(colors)]
|
||||
# 色块
|
||||
draw.rectangle([legend_x, legend_y + i*25, legend_x+20, legend_y+20+i*25], fill=color)
|
||||
# 标签
|
||||
label_font = self._get_font(16)
|
||||
draw.text((legend_x+30, legend_y+i*25), f"{label}: {value}", fill=(60, 60, 60), font=label_font)
|
||||
|
||||
def _draw_line_chart(self, draw: ImageDraw.Draw, data: Dict, spec: ImageSpec):
|
||||
"""绘制折线图"""
|
||||
# 简化版:显示文本描述
|
||||
title_font = self._get_font(24)
|
||||
draw.text((50, 100), "折线图 (数据趋势)", fill=spec.text_color, font=title_font)
|
||||
|
||||
items = list(data.items())
|
||||
if not items:
|
||||
draw.text((50, 150), "无可用数据", fill=(100, 100, 100), font=self._get_font(18))
|
||||
return
|
||||
|
||||
# 列出数据
|
||||
data_font = self._get_font(16)
|
||||
y = 200
|
||||
for label, value in items[:10]: # 限制显示数量
|
||||
draw.text((50, y), f"{label}: {value}", fill=(80, 80, 80), font=data_font)
|
||||
y += 25
|
||||
|
||||
def generate_concept_image(self, title: str, items: List[str], platform: str = "zhihu") -> Path:
|
||||
"""生成概念示意图(用于行动清单等)"""
|
||||
spec = self.platform_specs.get(platform, self.platform_specs["zhihu"])
|
||||
|
||||
img = Image.new('RGB', (spec.width, spec.height), color=(245, 245, 245))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 标题
|
||||
title_font = self._get_font(42, bold=True)
|
||||
self._draw_centered_text(draw, title, 60, spec.width, title_font, spec.text_color)
|
||||
|
||||
# 绘制项目列表(带复选框样式)
|
||||
item_font = self._get_font(28)
|
||||
start_y = 150
|
||||
for i, item in enumerate(items[:8]): # 限制8个
|
||||
y = start_y + i * 50
|
||||
# 复选框
|
||||
box_size = 30
|
||||
box_x = (spec.width - 400) // 2
|
||||
draw.rectangle([box_x, y, box_x + box_size, y + box_size], outline=spec.accent_color, width=3)
|
||||
# 勾
|
||||
check_font = self._get_font(24)
|
||||
draw.text((box_x + 7, y + 2), "✓", fill=spec.accent_color, font=check_font)
|
||||
# 文字
|
||||
draw.text((box_x + box_size + 20, y + 5), item[:30], fill=(60, 60, 60), font=item_font)
|
||||
|
||||
filename = f"action_checklist_{platform}.png"
|
||||
output_path = self.output_dir / filename
|
||||
img.save(output_path, quality=spec.quality)
|
||||
|
||||
return output_path
|
||||
|
||||
def generate_equipment_list_image(self, items: List[Dict[str, str]], platform: str = "zhihu") -> Path:
|
||||
"""生成装备清单图"""
|
||||
spec = self.platform_specs.get(platform, self.platform_specs["zhihu"])
|
||||
|
||||
img = Image.new('RGB', (spec.width, spec.height), color=(255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 标题
|
||||
title = "装备清单"
|
||||
title_font = self._get_font(38, bold=True)
|
||||
draw.text((50, 40), title, fill=spec.text_color, font=title_font)
|
||||
|
||||
# 列头
|
||||
headers = ["名称", "用途", "预算"]
|
||||
header_font = self._get_font(24, bold=True)
|
||||
col_width = spec.width // len(headers)
|
||||
for i, header in enumerate(headers):
|
||||
x = i * col_width + 20
|
||||
draw.text((x, 100), header, fill=(100, 100, 100), font=header_font)
|
||||
|
||||
# 分隔线
|
||||
draw.line([(50, 130), (spec.width-50, 130)], fill=(200, 200, 200), width=2)
|
||||
|
||||
# 绘制条目
|
||||
item_font = self._get_font(20)
|
||||
row_height = 40
|
||||
y = 150
|
||||
for item in items[:10]: # 最多10行
|
||||
name = item.get("name", "")[:12]
|
||||
purpose = item.get("purpose", "")[:10]
|
||||
budget = item.get("budget", "")
|
||||
|
||||
draw.text((70, y), name, fill=(50, 50, 50), font=item_font)
|
||||
draw.text((col_width + 70, y), purpose, fill=(50, 50, 50), font=item_font)
|
||||
draw.text((2*col_width + 70, y), budget, fill=(50, 50, 50), font=item_font)
|
||||
|
||||
y += row_height
|
||||
|
||||
# 底部总预算
|
||||
total_budget = sum([int(item.get("budget", "0").replace("元", "")) for item in items if item.get("budget", "").replace("元", "").isdigit()])
|
||||
total_font = self._get_font(22, bold=True)
|
||||
draw.text((50, spec.height - 50), f"总预算: {total_budget}元", fill=spec.accent_color, font=total_font)
|
||||
|
||||
filename = f"equipment_{platform}.png"
|
||||
output_path = self.output_dir / filename
|
||||
img.save(output_path, quality=spec.quality)
|
||||
|
||||
return output_path
|
||||
|
||||
def _wrap_text(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]:
|
||||
"""文本自动换行"""
|
||||
words = list(text)
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for char in words:
|
||||
test_line = current_line + char
|
||||
bbox = font.getbbox(test_line)
|
||||
width = bbox[2] - bbox[0]
|
||||
|
||||
if width <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = char
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
return lines if lines else [text]
|
||||
|
||||
def _draw_centered_text(self, draw: ImageDraw.Draw, text: str, y: int, center_x: int, font: ImageFont.FreeTypeFont, color: Tuple[int, int, int]):
|
||||
"""绘制居中文本"""
|
||||
bbox = font.getbbox(text)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
x = center_x - text_width // 2
|
||||
draw.text((x, y), text, fill=color, font=font)
|
||||
|
||||
def generate_all_placeholders(self, article_title: str, platform: str = "zhihu") -> Dict[str, Path]:
|
||||
"""生成所有占位图片"""
|
||||
files = {}
|
||||
|
||||
# 1. 封面图
|
||||
files["cover"] = self.generate_cover_image(article_title, "宇之然 · 可持续生活指南", platform)
|
||||
|
||||
# 2. 数据图表示例
|
||||
files["data_chart"] = self.generate_chart_image("bar", {"选项A": 45, "选项B": 32, "选项C": 23}, "数据对比", platform)
|
||||
|
||||
# 3. 概念图(行动清单)
|
||||
files["action_checklist"] = self.generate_concept_image("立即行动清单", [
|
||||
"第一步:记录现状,识别改进空间",
|
||||
"第二步:尝试最小可行改变",
|
||||
"第三步:评估效果,决定是否继续",
|
||||
"第四步:建立习惯,持续改进"
|
||||
], platform)
|
||||
|
||||
# 4. 装备清单图
|
||||
files["equipment"] = self.generate_equipment_list_image([
|
||||
{"name": "智能插座", "purpose": "定时控制", "budget": "50元"},
|
||||
{"name": "土壤传感器", "purpose": "湿度监测", "budget": "80元"},
|
||||
{"name": "自动灌溉", "purpose": "浇水", "budget": "120元"},
|
||||
{"name": "LED补光灯", "purpose": "光照", "budget": "200元"}
|
||||
], platform)
|
||||
|
||||
return files
|
||||
|
||||
def main():
|
||||
"""测试主函数"""
|
||||
generator = ImageGenerator()
|
||||
|
||||
# 测试生成图片
|
||||
print(f"开始生成图片到: {generator.output_dir}")
|
||||
|
||||
# 生成所有类型的占位图
|
||||
files = generator.generate_all_placeholders("上海阳台种菜一年:我收获的不仅是蔬菜", "zhihu")
|
||||
|
||||
print("\n生成的文件:")
|
||||
for name, path in files.items():
|
||||
print(f" - {name}: {path.name} ({path.stat().st_size // 1024}KB)")
|
||||
|
||||
print(f"\n✅ 图片生成完成,共 {len(files)} 张")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将 content/ideas/ 目录下的 Markdown 选题文件转换为 JSON 格式
|
||||
供 content creator 脚本使用
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
IDEAS_DIR = PROJECT_ROOT / "content" / "ideas"
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
OUTPUT_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
|
||||
def extract_field(content, field_name):
|
||||
"""从 Markdown 中提取字段值"""
|
||||
# 支持 **字段名**:值 或 字段名:值 格式
|
||||
patterns = [
|
||||
rf"\*\*{re.escape(field_name)}\*\*\s*[::]\s*(.+?)(?:\n|$)",
|
||||
rf"{re.escape(field_name)}\s*[::]\s*(.+?)(?:\n|$)",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
def extract_list(content, start_keyword):
|
||||
"""提取列表数据(如数据/案例)"""
|
||||
lines = content.split('\n')
|
||||
result = []
|
||||
capturing = False
|
||||
for line in lines:
|
||||
if start_keyword in line:
|
||||
capturing = True
|
||||
continue
|
||||
if capturing:
|
||||
if line.strip().startswith(('**', '#', '-', '*', '1.', '2.')):
|
||||
if re.match(r'^(#|\*\*|-|\*|\d+\.)\s', line):
|
||||
result.append(line.strip())
|
||||
elif line.strip() == '' or line.startswith('##'):
|
||||
break
|
||||
return result
|
||||
|
||||
def parse_evaluation_matrix(content):
|
||||
"""解析选题评估矩阵表格"""
|
||||
scores = {}
|
||||
lines = content.split('\n')
|
||||
in_table = False
|
||||
for line in lines:
|
||||
if '|' in line and '---' not in line and '维度' not in line:
|
||||
parts = [p.strip() for p in line.split('|')]
|
||||
if len(parts) >= 3:
|
||||
dimension = parts[1]
|
||||
score_str = parts[2]
|
||||
try:
|
||||
score = int(score_str)
|
||||
scores[dimension] = score
|
||||
except:
|
||||
pass
|
||||
if '**总分**' in line:
|
||||
total_match = re.search(r'\*\*总分\*\*\s*\|\s*\*\*(\d+)\*\*', line)
|
||||
if total_match:
|
||||
scores['总分'] = int(total_match.group(1))
|
||||
return scores
|
||||
|
||||
def md_to_topic(md_path):
|
||||
"""将单个 Markdown 文件转换为 topic 字典"""
|
||||
with open(md_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 提取标题 (第一行 # 开头)
|
||||
title_match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
|
||||
title = title_match.group(1).strip() if title_match else md_path.stem
|
||||
|
||||
# 提取基础字段
|
||||
field = extract_field(content, '领域')
|
||||
format_type = extract_field(content, '形式')
|
||||
word_count = extract_field(content, '预估字数')
|
||||
core_concept = extract_field(content, '核心观点')
|
||||
audience_pain = extract_field(content, '受众痛点')
|
||||
unique_angle = extract_field(content, '独特角度')
|
||||
data_cases = extract_list(content, '数据/案例')
|
||||
estimated_days = extract_field(content, '预估完成时间')
|
||||
priority_str = extract_field(content, '优先级')
|
||||
publish_date = extract_field(content, '预计发布时间')
|
||||
status = extract_field(content, '状态') or '待处理'
|
||||
|
||||
# 解析优先级为分数
|
||||
priority_map = {'高': 10, '中': 7, '低': 4}
|
||||
priority_score = priority_map.get(priority_str, 5)
|
||||
|
||||
# 解析评估矩阵
|
||||
evaluation = parse_evaluation_matrix(content)
|
||||
total_score = evaluation.get('总分', 0)
|
||||
|
||||
# 生成 topic ID
|
||||
topic_id = md_path.stem.split('-')[0] # 如 "001-上海阳台种菜一年.md" -> "001"
|
||||
|
||||
# 构建 topic 对象
|
||||
topic = {
|
||||
"id": topic_id,
|
||||
"title": title,
|
||||
"field": field or "未知",
|
||||
"format": format_type or "未指定",
|
||||
"word_count": word_count,
|
||||
"core_concept": core_concept,
|
||||
"audience_pain": audience_pain,
|
||||
"unique_angle": unique_angle,
|
||||
"data_cases": data_cases,
|
||||
"estimated_days": estimated_days,
|
||||
"priority": priority_str,
|
||||
"priority_score": priority_score if priority_score > 0 else (total_score if total_score > 0 else 5),
|
||||
"publish_date": publish_date,
|
||||
"status": status,
|
||||
"evaluation": evaluation,
|
||||
"total_score": total_score,
|
||||
"cases": [], # 关联的案例ID列表,待填充
|
||||
"source_file": md_path.name,
|
||||
"created_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return topic
|
||||
|
||||
def main():
|
||||
"""主函数:导入所有 Markdown 选题文件"""
|
||||
if not IDEAS_DIR.exists():
|
||||
print(f"错误:选题目录不存在 {IDEAS_DIR}")
|
||||
return
|
||||
|
||||
# 只导入主选题文件(格式:NNN-标题.md),排除 research/compliance 等辅助文件
|
||||
md_files = []
|
||||
for f in IDEAS_DIR.glob("*.md"):
|
||||
if f.name == "README.md":
|
||||
continue
|
||||
# 排除 research 和 compliance 文件
|
||||
if f.name.endswith('-research.md') or f.name.endswith('-compliance.md'):
|
||||
continue
|
||||
# 匹配 001-xxx.md 格式
|
||||
if re.match(r'^\d{3}-.+\.md$', f.name):
|
||||
md_files.append(f)
|
||||
|
||||
if not md_files:
|
||||
print("未找到选题文件")
|
||||
return
|
||||
|
||||
print(f"找到 {len(md_files)} 个选题文件,开始导入...")
|
||||
|
||||
topics = []
|
||||
for md_file in sorted(md_files):
|
||||
print(f" 处理: {md_file.name}")
|
||||
topic = md_to_topic(md_file)
|
||||
topics.append(topic)
|
||||
print(f" 标题: {topic['title']}")
|
||||
print(f" 总分: {topic['total_score']}")
|
||||
print(f" 状态: {topic['status']}")
|
||||
|
||||
# 确保输出目录存在
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入 JSON
|
||||
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✅ 已导入 {len(topics)} 个选题到 {OUTPUT_FILE}")
|
||||
|
||||
# 统计
|
||||
ready_topics = [t for t in topics if t['status'] != '已发布']
|
||||
print(f"📊 可用选题数: {len(ready_topics)}")
|
||||
avg_score = sum(t['total_score'] for t in ready_topics) / len(ready_topics) if ready_topics else 0
|
||||
print(f"🎯 平均评分: {avg_score:.1f}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
data = json.load(open('automation/data/sustainability_topics.json', 'r', encoding='utf-8'))
|
||||
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('\n按领域分组:')
|
||||
fields = {}
|
||||
for t in sorted(data, 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 '⏳'
|
||||
print(f' {status_icon} {t["id"]} {t["title"]}')
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
import 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
|
||||
print(f'待处理选题数: {len(pending)}')
|
||||
print(f'待处理平均分: {avg:.1f}')
|
||||
print('\n待处理选题详情:')
|
||||
print('ID 标题 优先级 总分')
|
||||
print('-' * 80)
|
||||
for t in sorted(pending, key=lambda x: (-x['priority_score'], x['id'])):
|
||||
print(f"{t['id']:3} {t['title'][:35]:35} {t['priority']} ({t['priority_score']:2}) {t['total_score']:2}")
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
大纲阶段:基于研究笔记生成文章大纲
|
||||
"""
|
||||
|
||||
import json, datetime, logging, sys
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
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"
|
||||
RESEARCH_DIR = DATA_DIR / "research"
|
||||
OUTPUT_DIR = DATA_DIR / "outlines"
|
||||
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"outline_{TODAY}.log"), logging.StreamHandler()])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Outliner:
|
||||
def __init__(self, topic_id: str):
|
||||
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.output_dir = OUTPUT_DIR / TODAY
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load_topic(self) -> Dict:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
for t in topics:
|
||||
if t['id'] == self.topic_id:
|
||||
return t
|
||||
raise ValueError(f"Topic {self.topic_id} not found")
|
||||
|
||||
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', '')
|
||||
|
||||
# 解析研究笔记中的案例数量
|
||||
case_count = self.research_notes.count('### 案例')
|
||||
|
||||
outline = f"""# 文章大纲:{title}
|
||||
|
||||
## 一、引言(约200字)
|
||||
- 开场场景/痛点引入
|
||||
- 提出核心问题:{title}
|
||||
- 点明文章价值
|
||||
|
||||
## 二、核心观点(约300字)
|
||||
{core}
|
||||
|
||||
## 三、受众痛点分析(约300字)
|
||||
{pain}
|
||||
|
||||
## 四、全球/行业趋势与案例(约500字)
|
||||
- 引用研究笔记中的 {case_count} 个案例,精选 2-3 个详述
|
||||
- 数据支撑:提取研究笔记中的关键数据
|
||||
- 趋势分析
|
||||
|
||||
## 五、本土落地建议(约400字)
|
||||
- 结合{field}领域特点
|
||||
- 提供可执行的步骤
|
||||
- 注意事项
|
||||
|
||||
## 六、独特视角:{angle}(约300字)
|
||||
|
||||
## 七、行动指南(MVP,约200字)
|
||||
1. 理解现状
|
||||
2. 小范围试验
|
||||
3. 评估效果
|
||||
4. 形成习惯
|
||||
|
||||
## 八、总结与鼓励(约200字)
|
||||
- 回顾要点
|
||||
- 呼吁行动
|
||||
|
||||
## 九、参考文献
|
||||
- 从研究笔记中提取来源链接
|
||||
|
||||
---
|
||||
*大纲生成时间:{TODAY}*
|
||||
"""
|
||||
return outline
|
||||
|
||||
def save(self):
|
||||
outline_text = self.generate_outline()
|
||||
out_path = self.output_dir / f"{self.topic_id}_outline.md"
|
||||
out_path.write_text(outline_text, encoding='utf-8')
|
||||
logger.info(f"大纲已保存: {out_path}")
|
||||
return out_path
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--topic-id', required=True, help='选题ID')
|
||||
args = parser.parse_args()
|
||||
|
||||
o = Outliner(args.topic_id)
|
||||
o.save()
|
||||
print(f"SUCCESS: Outline created for {args.topic_id}")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
多平台内容发布脚本
|
||||
将「待发布」的文章发布到各平台(知乎/公众号/小红书/B站/头条号)
|
||||
支持单个 topic 生成发布包模式(--topic-id)
|
||||
"""
|
||||
|
||||
import json, datetime, logging, sys, subprocess, time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
import argparse
|
||||
|
||||
PROJECT_ROOT = Path('/root/.openclaw/workspaces/yzr-yxl/projects/yu-zhi-ran')
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
RELEASES_DIR = DATA_DIR / "releases"
|
||||
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"publisher_{TODAY}.log"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 命令行参数
|
||||
parser = argparse.ArgumentParser(description='发布管理脚本')
|
||||
parser.add_argument('--topic-id', help='仅处理指定 topic ID')
|
||||
args = parser.parse_args()
|
||||
|
||||
# 平台配置
|
||||
PLATFORMS = {
|
||||
"zhihu": {"name": "知乎", "enabled": True, "template": "zhihu.html"},
|
||||
"wechat": {"name": "微信公众号", "enabled": False, "template": "wechat.html"}, # 需手动授权
|
||||
"xiaohongshu": {"name": "小红书", "enabled": True, "template": "xiaohongshu.html"},
|
||||
"bilibili": {"name": "B站", "enabled": False, "template": "bilibili.html"}, # 规划中
|
||||
"toutiao": {"name": "头条号", "enabled": False, "template": "toutiao.html"} # 规划中
|
||||
}
|
||||
|
||||
def load_topics() -> List[Dict]:
|
||||
with open(TOPICS_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_topics(topics: List[Dict]):
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_ready_topics() -> List[Dict]:
|
||||
topics = load_topics()
|
||||
ready = [t for t in topics if t.get('status') == '待发布']
|
||||
ready.sort(key=lambda t: t.get('ready_at', ''), reverse=True) # 优先最新
|
||||
return ready, topics
|
||||
|
||||
def publish_to_xiaohongshu(html_path: Path, topic: Dict) -> bool:
|
||||
"""小红书:复制HTML到发布目录(供手动发布)"""
|
||||
logger.info(f"准备小红书发布: {topic['id']}")
|
||||
try:
|
||||
publish_base = PROJECT_ROOT / "content" / "published"
|
||||
dest_dir = publish_base / topic['id'] / "手动发布" / "小红书"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest_html = dest_dir / "文章.html"
|
||||
import shutil
|
||||
shutil.copy2(html_path, dest_html)
|
||||
|
||||
logger.info(f"✅ 小红书发布包就绪: {dest_dir}")
|
||||
return True, str(dest_dir)
|
||||
except Exception as e:
|
||||
logger.error(f"小红书发布准备失败: {e}")
|
||||
return False, None
|
||||
|
||||
def publish_to_zhihu(html_path: Path, topic: Dict) -> bool:
|
||||
"""知乎:复制HTML到发布目录"""
|
||||
logger.info(f"准备知乎发布: {topic['id']}")
|
||||
try:
|
||||
publish_base = PROJECT_ROOT / "content" / "published"
|
||||
dest_dir = publish_base / topic['id'] / "手动发布" / "知乎"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest_html = dest_dir / "文章.html"
|
||||
import shutil
|
||||
shutil.copy2(html_path, dest_html)
|
||||
|
||||
logger.info(f"✅ 知乎发布包就绪: {dest_dir}")
|
||||
return True, str(dest_dir)
|
||||
except Exception as e:
|
||||
logger.error(f"知乎发布准备失败: {e}")
|
||||
return False, None
|
||||
|
||||
def publish_to_wechat(html_path: Path, topic: Dict) -> bool:
|
||||
"""微信公众号:复制HTML到发布目录"""
|
||||
logger.info(f"准备微信公众号发布: {topic['id']}")
|
||||
try:
|
||||
publish_base = PROJECT_ROOT / "content" / "published"
|
||||
dest_dir = publish_base / topic['id'] / "手动发布" / "微信公众号"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest_html = dest_dir / "文章.html"
|
||||
import shutil
|
||||
shutil.copy2(html_path, dest_html)
|
||||
|
||||
logger.info(f"✅ 微信公众号发布包就绪: {dest_dir}")
|
||||
return True, str(dest_dir)
|
||||
except Exception as e:
|
||||
logger.error(f"微信公众号发布准备失败: {e}")
|
||||
return False, None
|
||||
|
||||
def publish_to_platform(platform: str, html_path: Path, topic: Dict) -> (bool, str):
|
||||
"""生成平台发布包(人工发布)"""
|
||||
if platform == "xiaohongshu":
|
||||
return publish_to_xiaohongshu(html_path, topic)
|
||||
elif platform == "zhihu":
|
||||
return publish_to_zhihu(html_path, topic)
|
||||
elif platform == "wechat":
|
||||
return publish_to_wechat(html_path, topic)
|
||||
else:
|
||||
logger.warning(f"平台 {platform} 暂未支持")
|
||||
return False, None
|
||||
|
||||
def main():
|
||||
logger.info("=== 多平台内容发布包生成开始 ===")
|
||||
ready, all_topics = get_ready_topics()
|
||||
if not ready:
|
||||
logger.info("没有待发布内容")
|
||||
sys.exit(0)
|
||||
|
||||
# 如果指定了 topic-id,只处理该选题
|
||||
if args.topic_id:
|
||||
ready = [t for t in ready if t['id'] == args.topic_id]
|
||||
if not ready:
|
||||
logger.info(f"未找到指定 topic ID: {args.topic_id}")
|
||||
sys.exit(0)
|
||||
|
||||
results = []
|
||||
for topic in ready:
|
||||
tid = topic['id']
|
||||
title = topic.get('title', '')[:50]
|
||||
release_date = topic.get('ready_at', TODAY)
|
||||
release_dir = RELEASES_DIR / release_date
|
||||
|
||||
platform_urls = topic.get('platform_urls', {})
|
||||
|
||||
for platform, config in PLATFORMS.items():
|
||||
if not config['enabled']:
|
||||
continue
|
||||
# 检查是否已发布过
|
||||
if platform in platform_urls and platform_urls[platform]:
|
||||
logger.info(f"跳过已发布: {tid} - {platform}")
|
||||
continue
|
||||
|
||||
html_file = release_dir / platform / f"{platform}_{tid}_{platform}.html"
|
||||
if not html_file.exists():
|
||||
logger.warning(f"HTML文件不存在: {html_file}")
|
||||
continue
|
||||
|
||||
# 生成发布包(不自动发布)
|
||||
success, info = publish_to_platform(platform, html_file, topic)
|
||||
if success:
|
||||
results.append((tid, platform, info))
|
||||
logger.info(f"✅ {tid} 发布包已准备: {platform}")
|
||||
else:
|
||||
logger.error(f"❌ {tid} 发布包准备失败: {platform}")
|
||||
|
||||
# 汇总报告
|
||||
summary_file = LOGS_DIR / f"publisher_summary_{TODAY}.json"
|
||||
summary = {
|
||||
"date": TODAY,
|
||||
"total_ready": len(ready),
|
||||
"packages_generated": len(results),
|
||||
"details": [{"topic_id": r[0], "platform": r[1], "path": r[2]} for r in results]
|
||||
}
|
||||
with open(summary_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(summary, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"📦 发布包生成完成: {len(results)} 个平台发布包已就绪")
|
||||
print(f"PUBLISH_PACKAGES_READY: {len(results)} packages generated")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/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))
|
||||
|
||||
DATA_DIR = PROJECT_ROOT / "automation" / "data"
|
||||
CASES_FILE = DATA_DIR / "sustainability_cases.json"
|
||||
TOPICS_FILE = DATA_DIR / "sustainability_topics.json"
|
||||
OUTPUT_DIR = DATA_DIR / "research" # 研究笔记输出目录
|
||||
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
|
||||
TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.FileHandler(LOGS_DIR / f"research_{TODAY}.log"), logging.StreamHandler()])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Researcher:
|
||||
def __init__(self, topic_id: str):
|
||||
self.topic_id = topic_id
|
||||
self.topic = self._load_topic()
|
||||
self.cases = self._load_cases()
|
||||
self.output_dir = OUTPUT_DIR / TODAY
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load_topic(self) -> Dict:
|
||||
topics = json.loads(TOPICS_FILE.read_text(encoding='utf-8'))
|
||||
for t in topics:
|
||||
if t['id'] == self.topic_id:
|
||||
return t
|
||||
raise ValueError(f"Topic {self.topic_id} not found")
|
||||
|
||||
def _load_cases(self) -> List[Dict]:
|
||||
if CASES_FILE.exists():
|
||||
return json.loads(CASES_FILE.read_text(encoding='utf-8'))
|
||||
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))
|
||||
if m and int(m.group(1)) < 2025:
|
||||
continue
|
||||
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:
|
||||
score += 1
|
||||
if score > 0:
|
||||
scored.append((score, case))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [c for _, c in scored[:top_k]]
|
||||
|
||||
def generate_notes(self) -> str:
|
||||
"""生成研究笔记 Markdown"""
|
||||
cases = self.find_relevant_cases()
|
||||
lines = [
|
||||
f"# 研究笔记:{self.topic['title']}",
|
||||
f"\n## 选题信息",
|
||||
f"- **ID**: {self.topic['id']}",
|
||||
f"- **领域**: {self.topic.get('field')}",
|
||||
f"- **核心观点**: {self.topic.get('core_concept', '待补充')}",
|
||||
f"- **受众痛点**: {self.topic.get('audience_pain', '待补充')}",
|
||||
f"- **独特视角**: {self.topic.get('unique_angle', '待补充')}",
|
||||
f"\n## 相关案例({len(cases)}个)\n"
|
||||
]
|
||||
for i, case in enumerate(cases, 1):
|
||||
lines.extend([
|
||||
f"### 案例 {i}: {case.get('title')}",
|
||||
f"- **来源**: {case.get('source', '未知')}",
|
||||
f"- **日期**: {case.get('date', '未知')}",
|
||||
f"- **摘要**: {case.get('summary', case.get('description', '无'))}",
|
||||
f"- **关键数据**: {case.get('key_metrics', '无')}",
|
||||
""
|
||||
])
|
||||
lines.extend([
|
||||
"## 研究发现摘要",
|
||||
"- 待补充:从案例中提炼的趋势和洞察",
|
||||
"- 待补充:数据支撑",
|
||||
"",
|
||||
"## 待深入研究的问题",
|
||||
"- [ ] 需要更多本土数据",
|
||||
"- [ ] 需要验证某些结论的适用性",
|
||||
"",
|
||||
f"*生成时间:{TODAY}*"
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
def save(self):
|
||||
notes = self.generate_notes()
|
||||
out_path = self.output_dir / f"{self.topic_id}_research.md"
|
||||
out_path.write_text(notes, encoding='utf-8')
|
||||
logger.info(f"研究笔记已保存: {out_path}")
|
||||
return out_path
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--topic-id', required=True, help='选题ID')
|
||||
args = parser.parse_args()
|
||||
|
||||
r = Researcher(args.topic_id)
|
||||
r.save()
|
||||
print(f"SUCCESS: Research notes created for {args.topic_id}")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +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 ['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)
|
||||
print('已重置 B05, D01 为待处理')
|
||||
@@ -0,0 +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']:
|
||||
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)
|
||||
print('已重置选题状态:', [t['id'] for t in data if t['id'] in ['D01','B05']])
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, datetime
|
||||
from pathlib import Path
|
||||
|
||||
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"
|
||||
|
||||
# 1. 选题状态
|
||||
topics = json.load(open(data_dir / "sustainability_topics.json", encoding='utf-8'))
|
||||
by_status = {}
|
||||
for t in topics:
|
||||
s = t.get('status','待处理')
|
||||
by_status.setdefault(s, []).append(t)
|
||||
|
||||
print(f"📊 宇之然内容生成系统状态报告 ({datetime.date.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) 已生成内容 ===")
|
||||
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'))
|
||||
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))}")
|
||||
else:
|
||||
print(" 今日无发布内容")
|
||||
|
||||
# 3. 合规与优化结果
|
||||
report_file = drafts_dir / "optimization_report.json"
|
||||
if report_file.exists():
|
||||
report = json.load(open(report_file, encoding='utf-8'))
|
||||
print(f"\n=== 3. 合规优化结果 ===")
|
||||
sm = report['summary']
|
||||
print(f" 总文章数: {sm['total_articles']}")
|
||||
print(f" 自动通过: {sm['passed_auto']} 篇")
|
||||
print(f" 需人工审核: {sm['need_manual']} 篇")
|
||||
print(f" 平均合规分: {sm['average_score']:.1f}")
|
||||
if sm['need_manual'] == 0:
|
||||
print(" ✅ 所有文章均已自动合规")
|
||||
else:
|
||||
print("\n=== 3. 合规优化结果 ===")
|
||||
print(" 未找到优化报告")
|
||||
|
||||
# 4. 待发布选题(可发布)
|
||||
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):
|
||||
print(f" {t['id']}: {t['title'][:50]}")
|
||||
else:
|
||||
print(" 暂无待发布选题")
|
||||
|
||||
print(f"\n=== 5. 操作提示 ===")
|
||||
print("1. 人工发布:将「待发布」选题的HTML发布到对应平台")
|
||||
print("2. 发布后运行: python3 scripts/mark_published.py <选题ID>")
|
||||
print("3. 或批量发布: python3 scripts/mark_published.py --all-ready")
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
from collections import Counter
|
||||
|
||||
with open('automation/data/sustainability_topics.json', 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
print('=== 选题库状态 ===')
|
||||
print(f'总选题数: {len(data)}')
|
||||
print(f'字段: {list(data[0].keys())}')
|
||||
|
||||
print('\n状态分布:')
|
||||
status_counts = Counter(t.get('status', '<无>') for t in data)
|
||||
for s, c in sorted(status_counts.items()):
|
||||
print(f' {s}: {c} 个')
|
||||
|
||||
print('\n各状态详情:')
|
||||
for t in data:
|
||||
print(f"{t['id']}: {t['title'][:40]:40} | 状态: {t.get('status', '?'):6} | 优先级: {t.get('priority_score', '-')}")
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, datetime
|
||||
|
||||
topics = [
|
||||
{"id":"A01","title":"远程工作2026中国指南:从'不可能'到'可行'的路径图","field":"未来工作方式","format":"趋势洞察 + 实操指南","core_concept":"通过法律实操(合同、社保、个税)和心理建设(孤独应对),在中国环境下实现远程工作","audience_pain":"想远程但不知如何合法操作,担心被边缘化","unique_angle":"对比GitLab/Zapier海外实践,本土化落地策略","priority":"高","priority_score":10,"total_score":53,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"A02","title":"AI副业入门:用DeepSeek实现第一笔收入的100天","field":"未来工作方式","format":"实操指南 + 案例研究","core_concept":"从代写文案/数据分析起步,通过Fiverr国内外平台对比,制定定价策略和违规红线规避","audience_pain":"想用AI赚钱但不知从何开始,怕踩坑","unique_angle":"对比Fiverr海外繁荣 vs 国内空白,提供本土化接单路径","priority":"高","priority_score":10,"total_score":52,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"A03","title":"数字游民签证全解析:30个国家政策对比,中国护照能去哪些?","field":"未来工作方式","format":"对比分析 + 实操指南","core_concept":"分析爱沙尼亚/葡萄牙/巴厘岛等30国游民签证,结合中国护照限制,给出签证+保险+税务+社群的完整路线","audience_pain":"想地理套利但被签证和社保困扰","unique_angle":"不是简单列出签证,而是给出中国护照持有者的可行组合方案(如泰国+大马+巴厘岛)","priority":"高","priority_score":10,"total_score":51,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"A04","title":"一人公司实验:从创意到营收的365天日志","field":"未来工作方式","format":"实践日志 + 方法论","core_concept":"基于Indie Hackers案例,结合中国孤独创业现状,提供MVP设计、现金流管理、法律合规的一站式指南","audience_pain":"想单干但怕失败、缺启动资金、不懂营销","unique_angle":"真实日志形式,展示完整从0到营收的过程,不美化","priority":"高","priority_score":10,"total_score":50,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"A05","title":"AI时代的技能组合:什么技能值得投入10年?","field":"未来工作方式","format":"趋势分析 + 个人规划","core_concept":"基于WEF未来技能报告,划分4个技能维度(AI强化型、AI无法替代、复合型、过时型),帮中国职场人识别护城河技能","audience_pain":"学什么都不放心,怕投入时间后AI又取代","unique_angle":"将全球宏观报告转化为个人技能地图,提供可视化工具","priority":"中","priority_score":7,"total_score":50,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"B01","title":"城市农业ROI报告:20㎡阳台种菜一年,省了多少钱?","field":"可持续生活系统","format":"数据分析 + 实操指南","core_concept":"对比东京垂直农场与国内空间限制,精选高ROI蔬菜品种,智能设备自动灌溉,给出详细成本核算和品种推荐","audience_pain":"想种但怕麻烦、怕亏本、不知道种什么","unique_angle":"用财务思维算账(投入/产出/时间成本),打破'种菜必须有地'的思维","priority":"高","priority_score":10,"total_score":52,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"B02","title":"零浪费家庭实验:一年只产100L垃圾,可能吗?","field":"可持续生活系统","format":"实践实验 + 方法论","core_concept":"对比瑞典零浪费城市,针对中国垃圾分类困境,提供垃圾追踪表、替代方案数据库、社区互助网络","audience_pain":"想环保但觉得做不到、不知道从哪减","unique_angle":"极限实验(100L/年)+ 可执行步骤(从塑料减量开始),不理想化","priority":"高","priority_score":10,"total_score":51,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"B03","title":"低碳生活账单:用3年省了8万,碳足迹降了60%","field":"可持续生活系统","format":"数据分析 + 案例研究","core_concept":"对比欧洲碳税政策,从交通(电动车+共享)、饮食(植物为主)、消费(二手优先)三个维度,展示真实账单变化","audience_pain":"觉得低碳=更贵,不敢尝试","unique_angle":"用财务数据说话(省8万),打破'环保=烧钱'误解","priority":"高","priority_score":10,"total_score":50,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"B04","title":"循环消费实战:10件物品,用3年省了2万","field":"可持续生活系统","format":"实操指南 + 案例清单","core_concept":"对比法国二手强制法与中国闲鱼文化,提供购买决策树(买新/二手/租)、延长寿命技巧、转卖策略","audience_pain":"想买二手但怕质量差、怕麻烦","unique_angle":"10件物品的具体交易记录和对比(手机、相机、家具等),可复制","priority":"中","priority_score":7,"total_score":50,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"B05","title":"社区菜园指南:如何推动小区5户邻居共建共享","field":"可持续生活系统","format":"方法论 + 实操步骤","core_concept":"对比纽约社区花园政策与中国物业协调难题,提供法律风险(物权)、利益分配机制、技术方案(分区+智能)","audience_pain":"想组织但怕纠纷、不懂法律、协调不了邻居","unique_angle":"从1个友好小区试点开始,成功后复制,降低风险","priority":"高","priority_score":10,"total_score":49,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"C01","title":"第二大脑2.0:用DeepSeek+本地向量库建立私有知识系统","field":"个人知识工厂","format":"技术指南 + 实操案例","core_concept":"对比Obsidian+RAG海外实践,针对国内云服务担忧,提供数据主权、隐私保护、无缝检索、AI问答的本地化方案","audience_pain":"想系统化知识但担心云存储安全,怕复杂","unique_angle":"强调数据主权,从API调用到本地部署的渐进路线","priority":"中","priority_score":7,"total_score":52,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"C02","title":"PKM极简实践:PARA系统在Notion上的落地模板","field":"个人知识工厂","format":"模板分享 + 方法论","core_concept":"将Tiago Forte的PARA体系简化为3个核心文件夹,每周10分钟维护,AI辅助整理,让中国人真正用起来","audience_pain":"学了方法坚持不了,工具复杂难上手","unique_angle":"极简版(4个区)+ 每日5分钟习惯养成,降低门槛","priority":"中","priority_score":7,"total_score":51,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"C03","title":"费曼学习法AI增强:如何让AI帮你'教'懂一个概念","field":"个人知识工厂","format":"方法论 + 实践工具","core_concept":"结合经典费曼技巧与AI工具,三步法(AI简化→自我复述→Gap识别)+ 输出倒逼输入","audience_pain":"学东西记不住,自以为懂了但其实不会","unique_angle":"用AI当'测试官',验证你的理解深度,非被动接受知识","priority":"中","priority_score":7,"total_score":50,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"C04","title":"AI个人助理搭建:从ChatGPT到私有化部署的完整路线","field":"个人知识工厂","format":"技术路线图 + 成本分析","core_concept":"基于海外个人AI助手普及现状,针对国内数据安全顾虑,提供从API调用到本地部署的渐进式方案(成本可控)","audience_pain":"想用AI助手但又怕数据泄露,不知如何起步","unique_angle":"不是直接推本地部署(成本高),而是API优先,敏感时再本地策略","priority":"中","priority_score":7,"total_score":50,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"C05","title":"技能树可视化:用思维导图规划5年职业路径","field":"个人知识工厂","format":"方法论 + 工具模板","core_concept":"借鉴化工业界能力模型,构建硬技能×软技能矩阵,行业对标和学习资源聚合,让职业成长可规划","audience_pain":"不知道学什么,学了不知道用在哪,职业迷茫","unique_angle":"技能树而非技能列表,展示技能间关联和成长路径","priority":"中","priority_score":7,"total_score":49,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"D01","title":"AI伦理实践指南:开发者在中国的合规清单","field":"科技人文交叉","format":"合规指南 + 案例分析","core_concept":"对比EU AI Act与中国算法推荐管理规定,提供数据隐私、歧视检测、透明度义务、备案流程的自查清单","audience_pain":"开发者不了解国内AI伦理法规,怕踩雷","unique_angle":"不是泛泛而谈伦理,而是具体到'备案流程'和'自查表',即拿即用","priority":"高","priority_score":10,"total_score":51,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"D02","title":"数字排毒月:戒掉微信/抖音后,生活发生了什么","field":"科技人文交叉","format":"实践实验 + 效果分析","core_concept":"对比硅谷禅修热与中国'失联恐惧',采用渐进式戒断(无屏时段)+ 替代活动 + 社交边界管理","audience_pain":"想减少屏幕时间但又怕错过重要信息,自律困难","unique_angle":"真实实验记录(not理论),展示戒断前后的生活变化数据","priority":"中","priority_score":7,"total_score":50,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"D03","title":"银发科技报告:给爸妈装智能设备,学到的5个设计原则","field":"科技人文交叉","format":"设计原则 + 案例","core_concept":"对比日本适老化设计与国产'适老模式'鸡肋,提炼简化选项、物理反馈、容错设计、情感连接的具体方案","audience_pain":"给父母买智能设备但他们不用,功能复杂","unique_angle":"不是推荐产品,而是总结5个设计原则,让读者自己改造设备","priority":"中","priority_score":7,"total_score":49,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"D04","title":"儿童数字素养课:10岁儿子的AI启蒙12周","field":"科技人文交叉","format":"教育日志 + 方法论","core_concept":"对比芬兰AI教育与国内家长'禁止接触'心态,通过每周1次'AI家庭时间',培养批判性思维和创造力","audience_pain":"不知如何让孩子正确认识AI,怕沉迷又怕脱节","unique_angle":"真实父子12周项目记录,提供可复制的课程大纲","priority":"中","priority_score":7,"total_score":48,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()},
|
||||
{"id":"D05","title":"科技与自然共生:如何用AI让阳台农场更'自然'","field":"科技人文交叉","format":"理念 + 实操方案","core_concept":"对比荷兰智能温室与中国人'回归原始'误区,实现技术隐形化(传感器+提醒)+ 自然反馈闭环 + 人工仪式感","audience_pain":"想用科技但又怕失去'自然感',追求矛盾","unique_angle":"技术与情感连接的平衡方案,AI只做幕后,人工保留仪式","priority":"高","priority_score":10,"total_score":48,"status":"待处理","cases":[],"source_file":"strategy/全球-本土比较研究与全新内容战略规划-2026-04-15.md","created_at":datetime.datetime.now().isoformat()}
|
||||
]
|
||||
|
||||
with open(OUTPUT_FILE:= '/root/.openclaw/workspaces/yzr-yxl/projects/yu-zhi-ran/automation/data/sustainability_topics.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✅ 已创建全新选题库:{len(topics)} 个选题")
|
||||
pillars = {"未来工作方式":0,"可持续生活系统":0,"个人知识工厂":0,"科技人文交叉":0}
|
||||
for t in topics: pillars[t['field']] += 1
|
||||
for p,c in pillars.items(): print(f" {p}: {c} 个选题")
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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']}")
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试图片生成器"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.image_generator import ImageGenerator
|
||||
|
||||
def main():
|
||||
print("开始测试图片生成...")
|
||||
generator = ImageGenerator()
|
||||
print(f"输出目录: {generator.output_dir}")
|
||||
|
||||
try:
|
||||
files = generator.generate_all_placeholders("测试文章标题:上海阳台种菜一年", "zhihu")
|
||||
print(f"✅ 成功生成 {len(files)} 张图片:")
|
||||
for name, path in files.items():
|
||||
size_kb = path.stat().st_size // 1024
|
||||
print(f" - {name}: {path.name} ({size_kb}KB)")
|
||||
print(f"图片保存在: {generator.output_dir}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"❌ 生成失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/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]:
|
||||
print(f"- {t.get('id')}: {t.get('title','')[:40]}")
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
企业微信通知脚本
|
||||
根据收集器或创作器的结果,发送企业微信通知给用户 WangLiuTong
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import datetime
|
||||
import yaml
|
||||
|
||||
# 项目根目录
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# 配置路径
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
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"notifier_{TODAY}.log"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WeComNotifier:
|
||||
"""企业微信通知器"""
|
||||
|
||||
def __init__(self):
|
||||
self.load_config()
|
||||
|
||||
def load_config(self):
|
||||
"""加载配置文件"""
|
||||
try:
|
||||
with open(CONFIG_DIR / "wecom_config.yaml", "r", encoding='utf-8') as f:
|
||||
self.config = yaml.safe_load(f)
|
||||
except:
|
||||
# 如果没有yaml,使用默认配置
|
||||
self.config = {
|
||||
"wecom": {
|
||||
"target_user": "WangLiuTong",
|
||||
"message_template": {
|
||||
"header": "【宇之然自动推送】",
|
||||
"footer": "详情请查看项目目录",
|
||||
"max_length": 2000
|
||||
}
|
||||
},
|
||||
"notification_templates": {
|
||||
"sustainability_task_complete": """【可持续性内容收集完成】
|
||||
时间: {{TIME}}
|
||||
新增选题数: {{TOPIC_COUNT}}
|
||||
新增案例数: {{CASE_COUNT}}
|
||||
信息源: {{SOURCE_COUNT}}个
|
||||
详情: {{DETAILS_LINK}}""",
|
||||
"content_creation_complete": """【内容创作完成】
|
||||
时间: {{TIME}}
|
||||
选题: {{TOPIC_TITLE}}
|
||||
平台版本: 知乎、公众号、小红书
|
||||
图片数: {{IMAGE_COUNT}}
|
||||
文件位置: {{OUTPUT_DIR}}
|
||||
状态: {{STATUS}}""",
|
||||
"system_error": """【定时任务异常】
|
||||
任务: {{TASK_NAME}}
|
||||
错误: {{ERROR}}
|
||||
时间: {{TIME}}
|
||||
请检查日志: {{LOG_PATH}}"""
|
||||
}
|
||||
}
|
||||
|
||||
def format_message(self, template_name: str, data: dict) -> str:
|
||||
"""格式化消息"""
|
||||
templates = self.config["notification_templates"]
|
||||
template = templates.get(template_name, "")
|
||||
|
||||
for key, value in data.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
template = template.replace(placeholder, str(value))
|
||||
|
||||
# 添加头部和尾部
|
||||
header = self.config["wecom"]["message_template"]["header"]
|
||||
footer = self.config["wecom"]["message_template"]["footer"]
|
||||
|
||||
message = f"{header}\n{template}\n{footer}"
|
||||
|
||||
# 截断到最大长度
|
||||
max_len = self.config["wecom"]["message_template"]["max_length"]
|
||||
if len(message) > max_len:
|
||||
message = message[:max_len-3] + "..."
|
||||
|
||||
return message
|
||||
|
||||
def send_via_openclaw(self, message: str) -> bool:
|
||||
"""通过OpenClaw发送消息"""
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
# 尝试使用OpenClaw CLI发送消息
|
||||
# 假设有企业微信通道配置
|
||||
target_user = self.config["wecom"]["target_user"]
|
||||
|
||||
# 构建命令:使用openclaw message send
|
||||
cmd = [
|
||||
"openclaw", "message", "send",
|
||||
"--channel", "wecom",
|
||||
"--account", "default",
|
||||
"--target", target_user,
|
||||
"--message", message
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"通过OpenClaw发送成功")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"OpenClaw发送失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("OpenClaw CLI未找到,使用备用方法")
|
||||
return self.send_via_stdout(message)
|
||||
except Exception as e:
|
||||
logger.error(f"发送失败: {e}")
|
||||
return self.send_via_stdout(message)
|
||||
|
||||
def send_via_stdout(self, message: str) -> bool:
|
||||
"""备用方法:输出到stdout"""
|
||||
print(f"企业微信通知(待发送给{self.config['wecom']['target_user']}):")
|
||||
print("-" * 50)
|
||||
print(message)
|
||||
print("-" * 50)
|
||||
print("(实际发送需要配置企业微信通道)")
|
||||
return True
|
||||
|
||||
def process_notification_file(self, data_file: Path):
|
||||
"""处理通知数据文件"""
|
||||
if not data_file.exists():
|
||||
logger.error(f"通知数据文件不存在: {data_file}")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(data_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
task_type = data.get("task", "")
|
||||
time_str = data.get("time", datetime.datetime.now().strftime("%Y-%m-%d %H:%M"))
|
||||
|
||||
if task_type == "sustainability_collection":
|
||||
message_data = {
|
||||
"TIME": time_str,
|
||||
"TOPIC_COUNT": data.get("topic_count", 0),
|
||||
"CASE_COUNT": data.get("case_count", 0),
|
||||
"SOURCE_COUNT": data.get("source_count", 0),
|
||||
"DETAILS_LINK": data.get("details_link", "")
|
||||
}
|
||||
message = self.format_message("sustainability_task_complete", message_data)
|
||||
|
||||
elif task_type == "content_creation":
|
||||
message_data = {
|
||||
"TIME": time_str,
|
||||
"TOPIC_TITLE": data.get("topic_title", ""),
|
||||
"IMAGE_COUNT": data.get("image_count", 0),
|
||||
"OUTPUT_DIR": data.get("output_dir", ""),
|
||||
"STATUS": data.get("status", "")
|
||||
}
|
||||
message = self.format_message("content_creation_complete", message_data)
|
||||
|
||||
else:
|
||||
message_data = {
|
||||
"TASK_NAME": task_type,
|
||||
"ERROR": data.get("error", "未知错误"),
|
||||
"TIME": time_str,
|
||||
"LOG_PATH": data.get("log_path", "")
|
||||
}
|
||||
message = self.format_message("system_error", message_data)
|
||||
|
||||
# 发送消息
|
||||
success = self.send_via_openclaw(message)
|
||||
|
||||
if success:
|
||||
logger.info(f"通知发送成功: {task_type}")
|
||||
else:
|
||||
logger.warning(f"通知发送失败,已输出到stdout")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理通知文件失败: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python wecom_notifier.py <notification_data_file>")
|
||||
sys.exit(1)
|
||||
|
||||
data_file = Path(sys.argv[1])
|
||||
if not data_file.exists():
|
||||
print(f"Error: Data file not found: {data_file}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
notifier = WeComNotifier()
|
||||
success = notifier.process_notification_file(data_file)
|
||||
|
||||
if success:
|
||||
print("SUCCESS: Notification processed")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("WARNING: Notification failed")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"通知任务失败: {e}")
|
||||
print(f"ERROR: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
撰写阶段:基于大纲和选题生成完整文章(三平台版本)
|
||||
"""
|
||||
|
||||
import json, datetime, logging, sys, re, subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
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.qnaigc_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))
|
||||
# 导入数据库模型
|
||||
from app.database import SessionLocal
|
||||
from app.models import Topic
|
||||
|
||||
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
|
||||
|
||||
if platform == "xiaohongshu":
|
||||
html = self._fill_image_placeholders(html, platform, title)
|
||||
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'] = '待审查'
|
||||
updated = True
|
||||
break
|
||||
if updated:
|
||||
with open(TOPICS_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(topics, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 更新数据库
|
||||
db = SessionLocal()
|
||||
try:
|
||||
topic_db = db.query(Topic).filter(Topic.id == self.topic_id).first()
|
||||
if topic_db:
|
||||
topic_db.status = '待审查'
|
||||
db.commit()
|
||||
logger.info(f"选题 {self.topic_id} 状态已更新为 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'] = '待审查'
|
||||
# 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 _image_to_data_url(self, img_path: Path, fmt: str = None) -> str:
|
||||
data = img_path.read_bytes()
|
||||
if fmt is None:
|
||||
fmt = img_path.suffix.lstrip('.').lower()
|
||||
b64 = base64.b64encode(data).decode('ascii')
|
||||
return f"data:image/{fmt};base64,{b64}"
|
||||
|
||||
def _generate_and_inline_images(self, platform: str, title: str) -> dict:
|
||||
from scripts.image_generator import ImageGenerator
|
||||
gen = ImageGenerator()
|
||||
files = gen.generate_all_placeholders(title, platform)
|
||||
mapping = {}
|
||||
cover = files.get('cover')
|
||||
if cover and cover.exists():
|
||||
mapping['main-image-src'] = self._image_to_data_url(cover)
|
||||
thumbs = []
|
||||
for k, p in files.items():
|
||||
if k != 'cover' and p.exists():
|
||||
thumbs.append(self._image_to_data_url(p))
|
||||
mapping['thumbnail-srcs'] = thumbs
|
||||
return mapping
|
||||
|
||||
def _fill_image_placeholders(self, html: str, platform: str, title: str) -> str:
|
||||
if platform != 'xiaohongshu':
|
||||
return html
|
||||
mapping = self._generate_and_inline_images(platform, title)
|
||||
# Replace main image placeholder
|
||||
main_ph = '<img src="" alt="封面图" class="main-image">'
|
||||
if 'main-image-src' in mapping:
|
||||
new_main = f'<img src="{mapping["main-image-src"]}" alt="封面图" class="main-image">'
|
||||
html = html.replace(main_ph, new_main)
|
||||
# Replace thumbnail placeholders (6)
|
||||
thumbs = mapping.get('thumbnail-srcs', [])
|
||||
for idx, src in enumerate(thumbs[:6], start=1):
|
||||
ph = f'<img src="" alt="图{idx}" class="thumbnail">'
|
||||
new_thumb = f'<img src="{src}" alt="图{idx}" class="thumbnail">'
|
||||
html = html.replace(ph, new_thumb)
|
||||
return html
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user