Initial commit: yu-zhi-ran platform with automation integration

This commit is contained in:
lt
2026-04-19 14:05:09 +08:00
commit 3cb2df51c8
209 changed files with 80379 additions and 0 deletions
+10
View File
@@ -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')
+108
View File
@@ -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()
+8
View File
@@ -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)
+8
View File
@@ -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)
+7
View File
@@ -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'))
+6
View File
@@ -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')}")
+580
View File
@@ -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()
+438
View File
@@ -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()
+288
View File
@@ -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))
+265
View File
@@ -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))
+295
View File
@@ -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)
+167
View File
@@ -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()
+208
View File
@@ -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()
+231
View File
@@ -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()
+208
View File
@@ -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()
+651
View File
@@ -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()
+48
View File
@@ -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())
+454
View File
@@ -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()
+178
View File
@@ -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()
+17
View File
@@ -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"]}')
+16
View File
@@ -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}")
+115
View File
@@ -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()
+186
View File
@@ -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()
+123
View File
@@ -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()
+10
View File
@@ -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 为待处理')
+10
View File
@@ -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']])
+68
View File
@@ -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")
+19
View File
@@ -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', '-')}")
+33
View File
@@ -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} 个选题")
+19
View File
@@ -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']}")
+32
View File
@@ -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())
+6
View File
@@ -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]}")
+233
View File
@@ -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()
+324
View File
@@ -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()
+276
View File
@@ -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()