feat: Phase 4 多租户隔离 + 四阶段升级测试 + CSS 统一化

Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列
测试: tests/test_phase_upgrades.py 97项全覆盖
CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式
修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
This commit is contained in:
Yuzhiran Dev
2026-05-17 06:56:53 +08:00
parent 301dc3e438
commit 9c37c9a574
45 changed files with 3707 additions and 1366 deletions
+276 -93
View File
@@ -46,12 +46,13 @@ logger = logging.getLogger(__name__)
class SustainabilitySource:
"""可持续性信息源"""
name: str
type: str # rss, web, api, report, local
url: Optional[str] = None # 可为空(如本地源)
type: str # rss, web_search, web, api, local
url: Optional[str] = None # RSS URL 或通用链接
update_frequency: str = "daily"
credibility: str = "medium"
focus: str = "可持续性"
keywords: Optional[List[str]] = None # 源特定关键词
query: Optional[str] = None # 搜索查询词(w eb_search类型用)
@dataclass
class SustainabilityCase:
@@ -118,30 +119,60 @@ class SustainabilityCollector:
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)
"""加载配置:优先从DB读取,DB为空则从YAML fallback再写入DB"""
self.config = {}
self.config_path = CONFIG_DIR / "sources.yaml"
if self.config_path.exists():
with open(self.config_path, encoding='utf-8') as f:
self.config = yaml.safe_load(f) or {}
with open(CONFIG_DIR / "wecom_config.yaml", "r", encoding='utf-8') as f:
with open(CONFIG_DIR / "wecom_config.yaml", encoding='utf-8') as f:
self.wecom_config = yaml.safe_load(f)
# 优先从DB读取类别和源
self.sources = []
for source_group in self.config["sustainability_sources"].values():
try:
from app.database import SessionLocal
from app.models import CollectorCategory, CollectorSource
db = SessionLocal()
try:
cats = db.query(CollectorCategory).filter(CollectorCategory.is_active == True).order_by(CollectorCategory.sort_order).all()
if cats:
# 用DB中的类别覆盖YAML
self.config["sustainability_categories"] = [c.name for c in cats]
sources_db = db.query(CollectorSource).filter(CollectorSource.is_active == True).order_by(CollectorSource.sort_order).all()
for s in sources_db:
self.sources.append(SustainabilitySource(
name=s.name,
type=s.source_type,
url=s.url or '',
query=s.query or '',
credibility=s.credibility or 'medium',
focus=s.focus or '可持续性',
))
logger.info(f"从DB加载 {len(cats)} 个类别, {len(self.sources)} 个信息源")
db.close()
return
except Exception as e:
logger.warning(f"DB读取类别/源失败,回退YAML: {e}")
db.close()
except Exception as e:
logger.warning(f"DB连接失败,回退YAML: {e}")
# YAML fallback
for source_group in self.config.get("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', '可持续性')
source_info.setdefault('keywords', None)
# Filter to only fields accepted by SustainabilitySource
allowed_keys = {'name', 'type', 'url', 'update_frequency', 'credibility', 'focus', 'keywords'}
allowed_keys = {'name', 'type', 'url', 'update_frequency', 'credibility', 'focus', 'keywords', 'query'}
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)} 个信息源")
logger.info(f"YAML fallback: 加载 {len(self.sources)} 个信息源")
def load_local_cases_from_db(self) -> List[SustainabilityCase]:
"""从本地案例库加载历史案例,用于降级生成选题"""
@@ -213,16 +244,17 @@ class SustainabilityCollector:
field = field_match.group(1).strip()
# 映射到子领域(扩展映射表)
category_map = {
'远程工作方式': '城市农业',
'远程工作方式': '循环消费',
'数字游民政策': '低碳出行',
'AI副业服务': '环保科技产品',
'一人公司模式': '循环消费',
'未来技能趋势': '可持续饮食',
'可持续生活': '可持续饮食',
'未来技能趋势': '干净饮食',
'可持续生活': '零浪费生活',
'零浪费生活': '零浪费生活',
'低碳出行': '低碳出行',
'循环消费': '循环消费',
'环保科技': '环保科技产品'
'环保科技': '环保科技产品',
'城市农业': '循环消费',
}
case_data['category'] = category_map.get(field, field[:4] if len(field) > 4 else field)
@@ -317,10 +349,129 @@ class SustainabilityCollector:
def fetch_web_content(self, source: SustainabilitySource) -> List[Dict]:
"""获取网页内容(简化版,实际需要更复杂的抓取)"""
# 简化实现:只记录,不实际抓取
logger.info(f"网页信息源 {source.name} 需要手动处理")
return []
def fetch_web_search(self, source: SustainabilitySource) -> List[Dict]:
"""通过Bing中文搜索获取实时内容"""
try:
from web_search import search
query = source.query or source.url or ''
query = query.strip()
if not query:
logger.warning(f"web_search源 {source.name} 未配置查询词")
return []
results = search(query, max_results=8, use_cache=False)
articles = []
for r in results:
articles.append({
'title': r.get('title', ''),
'url': r.get('url', ''),
'content': r.get('snippet', ''),
'published': TODAY,
'source_name': source.name,
'search_query': query,
})
logger.info(f"搜索 [{query}] 获得 {len(articles)} 条结果")
return articles
except Exception as e:
logger.warning(f"web_search失败 {source.name}: {e}")
return []
def _generate_topic_with_llm(self, search_results: List[Dict]) -> Optional[SustainabilityTopic]:
"""用LLM从搜索结果中生成选题"""
try:
from app.core.nvidia_client import call_llm
except ImportError:
logger.warning("LLM不可用,跳过AI选题生成")
return None
if not search_results:
return None
# 整理搜索结果摘要
summaries = []
for r in search_results[:6]:
summaries.append(f"- {r.get('title','')}: {r.get('content','')[:150]}")
search_text = "\n".join(summaries)
# 获取已有选题做去重参考
existing = self._get_existing_titles()
existing_hint = ""
if existing:
existing_hint = f"\n以下选题已存在,请避免重复:\n" + "\n".join(f"- {t[:30]}" for t in existing[-10:])
# 按日期选不同类别
categories = self.config.get("sustainability_categories", ["可持续生活"])
day_idx = datetime.datetime.now().timetuple().tm_yday % len(categories)
target_category = categories[day_idx]
prompt = f"""你是一个内容策略师。基于以下搜索结果,生成一个有价值、适合中文互联网传播的选题。
目标类别:{target_category}
搜索结果:
{search_text}
{existing_hint}
请生成一个选题,输出JSON格式:
{{
"title": "标题(20字内,有吸引力,含核心关键词)",
"core_concept": "核心观点(一句话说清独特价值)",
"audience_pain": "受众痛点(真实用户的困惑或需求)",
"unique_angle": "独特视角(差异化切入点)",
"format": "内容形式(趋势洞察/实操指南/对比分析/案例解读)"
}}
要求:
- 标题要像人会搜索的,带领域关键词
- 避免「新趋势」「指南」「攻略」这类同质化结尾
- 切入点要具体,不要泛泛而谈
- 优先考虑中国读者能实操的内容
只输出JSON,不要其他文字。"""
try:
resp = call_llm(prompt, temperature=0.7, max_tokens=800)
resp = resp.strip()
if resp.startswith("```"):
resp = resp.split("\n", 1)[1].rsplit("\n", 1)[0]
data = json.loads(resp)
topic_id = f"TOPIC-{hashlib.md5((target_category + data.get('title','')[:10]).encode()).hexdigest()[:6].upper()}"
topic = SustainabilityTopic(
id=topic_id,
title=data.get("title", f"{target_category}新观察"),
cases=[],
audience="城市焦虑青年(26-35岁)",
china_pain_points=data.get("audience_pain", ""),
localization_solution="文章中将提供具体可执行的建议",
mvp_actions="读者可立即尝试的3个行动",
estimated_length=2000,
priority_score=7.0,
field=self.map_category_to_field(target_category),
format=data.get("format", "趋势洞察 + 实操指南"),
core_concept=data.get("core_concept", ""),
audience_pain=data.get("audience_pain", ""),
unique_angle=data.get("unique_angle", ""),
priority="",
total_score=70.0,
compliance_score=100,
source_file="automation/data/sustainability_topics.json",
status="待处理",
lock_by=None,
lock_at=None,
created_at=datetime.datetime.now().isoformat(),
ready_at=None,
published_at=None,
platform_urls={}
)
logger.info(f"LLM生成选题: {topic.title}")
return topic
except Exception as e:
logger.warning(f"LLM选题生成失败: {e}")
return None
def analyze_article(self, article: Dict) -> Optional[SustainabilityCase]:
"""分析文章内容,提炼案例"""
try:
@@ -370,13 +521,14 @@ class SustainabilityCollector:
# 生成中国痛点(基于类别模板)
china_pains = {
"城市农业": "中国城市空间小、光照不足、怕邻居投诉",
"零浪费生活": "中国垃圾分类执行难、环保产品溢价高",
"低碳出行": "中国电动车充电难、城市规划不支持",
"循环消费": "中国二手文化不成熟、维修成本高",
"源效率": "中国能源价格波动、设备更换成本高",
"可持续饮食": "中国预制菜泛滥、有机食品价格高",
"环保科技产品": "中国消费者关注价格多于环保"
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长",
"干净饮食": "有机食品价格高、真伪难辨、外卖为主的生活方式难以改变",
"零浪费生活": "环保产品溢价高、可持续选择不便、漂绿营销难以分辨",
"绿色家电与节": "绿色家电初期投入高、节能效果难量化、老旧小区改造难",
"碳普惠": "碳账户普及率低、减排量兑换吸引力不足、公众认知有限",
"环保科技产品": "绿色产品溢价68%难以承受、缺乏统一认证标准、担心漂绿",
"AI与效率": "AI工具选择困难、数据隐私担忧、学习成本高、实际效果难验证"
}
china_pain = china_pains.get(category, "中国相关数据不足,需本土化验证")
@@ -423,12 +575,21 @@ class SustainabilityCollector:
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]}的中国落地路径"
day_of_year = datetime.datetime.now().timetuple().tm_yday
title_templates = [
f"{main_category}新趋势: {case_titles[0]}{case_titles[1]}的中国落地路径",
f"{case_titles[0][:15]}{case_titles[1][:15]}: {main_category}的中国实践指南",
f"2026{main_category}观察: {case_titles[0]}给中国什么启示",
f"实战对比: {case_titles[0][:10]}vs{case_titles[1][:10]},中国读者该学谁",
f"为什么{case_titles[0][:15]}在中国行不通(或更行)? — {main_category}深度拆解",
]
topic_title = title_templates[day_of_year % len(title_templates)]
# 生成选题ID(案例内容hash保证同一批案例产出相同ID,避免重复入库)
content_seed = main_category + case_titles[0][:10] + case_titles[1][:10]
topic_id = f"TOPIC-{hashlib.md5(content_seed.encode()).hexdigest()[:6].upper()}"
# 计算优先级分数
priority_weights = self.config["topic_priority"]
@@ -484,15 +645,7 @@ class SustainabilityCollector:
def map_category_to_field(self, category: str) -> str:
"""将案例类别映射到内容领域的字段"""
category_map = {
"城市农业": "可持续生活系统",
"零浪费生活": "可持续生活系统",
"低碳出行": "可持续生活系统",
"循环消费": "可持续生活系统",
"能源效率": "可持续生活系统",
"环保科技产品": "可持续生活系统"
}
return category_map.get(category, "可持续生活系统")
return "可持续生活系统"
def save_results(self):
"""保存收集结果"""
@@ -585,87 +738,117 @@ class SustainabilityCollector:
except Exception as e:
logger.error(f"发送通知失败: {e}")
def _get_existing_titles(self) -> List[str]:
"""从DB获取已有的选题标题列表用于去重"""
try:
from db_helper import export_topics_to_json
topics = export_topics_to_json()
return [t.get('title', '') for t in topics]
except Exception as e:
logger.warning(f"读取已有选题失败: {e}")
return []
def _is_duplicate_topic(self, title: str, existing_titles: List[str]) -> bool:
"""检查选题是否与已有选题重复(前10字重叠即为重复)"""
prefix = title[:10].strip()
for et in existing_titles:
if prefix in et or et[:10] in title:
return True
return False
def _rotate_category(self, local_cases: List[SustainabilityCase]) -> Tuple[str, List[SustainabilityCase]]:
"""按日期轮换类别,避免天天选中同一类"""
category_cases = {}
for case in local_cases:
category_cases.setdefault(case.category, []).append(case)
if not category_cases:
return None, []
# 按类别名排序固定顺序
sorted_cats = sorted(category_cases.keys())
# 用一年中的第几天选类别,保证每天不重样
day_of_year = datetime.datetime.now().timetuple().tm_yday
idx = day_of_year % len(sorted_cats)
main_cat = sorted_cats[idx]
return main_cat, category_cases[main_cat]
def run(self):
"""主运行流程"""
logger.info("开始可持续性内容收集")
# 1. 从所有信息源收集
existing_titles = self._get_existing_titles()
# ---------------------- 第一阶段:多源采集 ----------------------
all_articles = []
web_search_results = [] # 留给LLM选题用的搜索结果
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)
elif source.type == 'web_search':
articles = self.fetch_web_search(source)
web_search_results.extend(articles)
all_articles.extend(articles)
elif source.type == 'api':
# TODO: 实现API抓取
pass
elif source.type == 'local':
# 本地源不产生新文章,后续降级处理
pass
logger.info(f"总共收集到 {len(all_articles)}可持续性文章")
logger.info(f"RSS采集 {sum(1 for a in all_articles if a.get('source_name','') not in [s.name for s in self.sources if s.type=='web_search'])}, "
f"搜索采集 {len(web_search_results)}")
# 2. 分析文章,提炼案例
for article in all_articles[:20]: # 限制分析数量
# ---------------------- 第二阶段:尝试LLM选题生成 ----------------------
llm_topic = None
if web_search_results:
llm_topic = self._generate_topic_with_llm(web_search_results)
if llm_topic and not self._is_duplicate_topic(llm_topic.title, existing_titles):
llm_topic.created_at = datetime.datetime.now().isoformat()
llm_topic.lock_by = None
llm_topic.lock_at = None
llm_topic.status = "待处理"
self.new_topics.append(llm_topic)
logger.info(f"✅ LLM生成选题: {llm_topic.title}")
# ---------------------- 第三阶段:RSS文章提炼案例 ----------------------
rss_articles = [a for a in all_articles if a not in web_search_results]
for article in rss_articles[:15]:
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 not self.new_topics and len(self.new_cases) < 2:
logger.warning(f"LLM选题和RSS案例不足,启动本地案例降级")
local_cases = self.load_local_cases_from_db() or self.load_local_cases_from_markdown()
if local_cases and len(local_cases) >= 2:
# 按类别分组,选择案例最多的类别
category_cases = {}
for case in local_cases:
cat = case.category
if cat not in category_cases:
category_cases[cat] = []
category_cases[cat].append(case)
# 找出案例最多的类别
main_category = max(category_cases, key=lambda k: len(category_cases[k]))
main_cases = category_cases[main_category]
# 确保至少有2个案例
if len(main_cases) >= 2:
selected = main_cases[:min(3, len(main_cases))]
cat, cat_cases = self._rotate_category(local_cases)
if cat and len(cat_cases) >= 2:
selected = cat_cases[:min(4, len(cat_cases))]
self.new_cases.extend(selected)
logger.info(f"降级:从类别'{main_category}'选取 {len(selected)} 个案例")
logger.info(f"降级:从类别'{cat}'选取 {len(selected)} 个案例 (day-of-year轮换)")
else:
# 如果每个类别都少于2个,则随机选2个(可能类别不同,generate_topic_from_cases会合并)
import random
selected = random.sample(local_cases, min(3, len(local_cases)))
self.new_cases.extend(selected)
logger.info(f"降级:随机选取 {len(selected)} 个本地案例")
logger.info(f"降级:随机选取 {len(selected)} 个本地案例")
# 用本地案例生成选题
if not self.new_topics and self.new_cases:
topic = self.generate_topic_from_cases(self.new_cases)
if topic:
if self._is_duplicate_topic(topic.title, existing_titles):
logger.warning(f"选题重复,跳过: {topic.title}")
else:
topic.created_at = datetime.datetime.now().isoformat()
topic.lock_by = None
topic.lock_at = None
topic.status = "待处理"
self.new_topics.append(topic)
logger.info(f"生成新选题: {topic.title}")
else:
# 备用:从Markdown案例库解析
local_cases = self.load_local_cases_from_markdown()
if local_cases:
import random
selected = random.sample(local_cases, min(3, len(local_cases)))
self.new_cases.extend(selected)
logger.info(f"降级(Markdown):使用了 {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)
logger.error("降级失败:本地案例库为空")
# 5. 保存结果
self.save_results()
+5 -2
View File
@@ -102,13 +102,16 @@ def run_pipeline(topic_id: str = None) -> Dict:
update_topic_status(tid, 'pending')
return {"ok": False, "error": "writer step failed"}
# 4. 合规优化(自动审核并标记为「待发布」)
# 4. 配图生成
image_ok = run_step("image_generator.py", tid)
# 5. 合规优化(自动审核并标记为「待发布」)
if not run_optimizer_step(tid):
update_topic_status(tid, 'pending')
return {"ok": False, "error": "optimizer step failed"}
logger.info(f"创作流水线完成: topic_id={tid}")
return {"ok": True, "topic_id": tid, "stdout": f"SUCCESS: Topic {tid} processed through full pipeline"}
return {"ok": True, "topic_id": tid, "stdout": f"SUCCESS: Topic {tid} processed through full pipeline{' (images generated)' if image_ok else ' (images skipped)'}"}
except Exception as e:
logger.exception("流水线执行失败")
if tid:
+86
View File
@@ -8,6 +8,7 @@ import os
import sys
import json
import datetime
import logging
from pathlib import Path
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass
@@ -25,6 +26,13 @@ import random
# 确保项目根目录在路径中
PROJECT_ROOT = Path('/root/openclaw-workspace/projects/yu-zhi-ran')
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
from db_helper import get_topic_by_id
from app.models import Article
from app.database import SessionLocal
logger = logging.getLogger(__name__)
# 加载配置
CONFIG_DIR = PROJECT_ROOT / "config"
@@ -441,7 +449,85 @@ class ImageGenerator:
return files
def generate_for_topic(topic_id: str, platforms: List[str] = None) -> Dict[str, Dict[str, str]]:
"""为指定选题生成三平台配图,路径存入 articles 表"""
if platforms is None:
platforms = ["zhihu", "wechat", "xiaohongshu"]
topic = get_topic_by_id(topic_id)
if not topic:
raise ValueError(f"Topic {topic_id} not found")
title = topic.get("title", "无标题")
generator = ImageGenerator()
results = {}
for platform in platforms:
try:
files = generator.generate_all_placeholders(title, platform)
cover_path = str(files.get("cover", ""))
chart_path = str(files.get("data_chart", ""))
checklist_path = str(files.get("action_checklist", ""))
images = {
"cover": cover_path,
"chart": chart_path,
"checklist": checklist_path,
}
# 存入 DB
save_article_images(topic_id, platform, images)
results[platform] = images
logger.info(f" [{platform}] cover={Path(cover_path).name}" if cover_path else "")
except Exception as e:
logger.error(f" [{platform}] 生成失败: {e}")
results[platform] = {}
return results
def save_article_images(topic_id: str, platform: str, images: Dict[str, str]):
"""将图片路径写入 articles 表的 images 字段"""
db = SessionLocal()
try:
from app.models import Article
article_id = f"{platform}_{topic_id}"
article = db.query(Article).filter(Article.id == article_id).first()
if article:
existing = article.images or {}
existing.update(images)
article.images = existing
else:
article = Article(
id=article_id,
topic_id=topic_id,
platform=platform,
file_path=f"db:{article_id}",
status="draft",
images=images,
)
db.add(article)
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
def main():
import argparse
parser = argparse.ArgumentParser(description='文章配图生成器')
parser.add_argument('--topic-id', help='选题ID,指定则为选题生成配图')
args = parser.parse_args()
if args.topic_id:
print(f"为选题 {args.topic_id} 生成配图...")
results = generate_for_topic(args.topic_id)
print(json.dumps({"topic_id": args.topic_id, "images": results}, ensure_ascii=False))
sys.exit(0)
"""测试主函数"""
generator = ImageGenerator()
+6 -2
View File
@@ -66,10 +66,14 @@ class Outliner:
## 大纲设计要求
### 结构
- 5-8章,每章2-4个要点
- 结构要有递进:要么认知升级型(读者看完感觉打开新世界),要么问题解决型(读者看完知道怎么做)
- 结构要有递进:要么认知升级型,要么问题解决型
- 把独特视角和受众痛点融入各章,不单独列
- 每章标题自带信息量,不要「引言」「总结」这类通用标题
### 数据要求
- **全文使用的数据必须为2025-2026年最新数据**,禁用2024年及之前过时数据
- 每个观点尽量配最新的数据或案例支撑
### SEO
- H2/H3标题自然包含用户搜索时会用的短语
- 确保大纲覆盖2-3个高价值搜索词
@@ -84,7 +88,7 @@ class Outliner:
- 公众号方向偏故事和情感共鸣
- 同一大纲应能适应不同平台侧重点
直接输出大纲。"""
直接输出大纲,不要输出思考过程"""
try:
outline = call_llm(prompt, temperature=0.6, max_tokens=2000, system_prompt="你是一个有经验的内容编辑,擅长为不同选题设计差异化的文章结构。")
logger.info(f"LLM 大纲生成成功,长度:{len(outline)}")
+5 -7
View File
@@ -90,14 +90,12 @@ class Researcher:
{cases_text}
## 输出要求(按顺序):
1. 核心发现:2-3个真正有价值的洞察(不是每个案例凑一条)。每个洞察需包含:
- 这个发现对读者意味着什么(不要只说事实,要说意义
- 可以用什么数据或案例支撑
2. SEO关键词建议:这篇文章应该重点布局哪些搜索词(3-5个,包含1-2个长尾词)
3. 讨论点:哪个观点最有争议或最可能引发讨论?这能帮助文章获得平台推荐
4. 待验证:指出1-2个不确定的方向,作者需进一步核实
1. 核心发现:2-3个真正有价值的洞察。每条需包含这个发现对读者意味着什么,以及支撑数据。**所有数据必须是2025-2026年最新数据,禁用过时数据**
2. SEO关键词建议:重点布局哪些搜索词(3-5个,含1-2个长尾词
3. 讨论点:哪个观点最有争议或最可能引发讨论?
4. 待验证:指出1-2个不确定方向
风格:说人话,每条洞察2-3句话直击要点。避免「首先其次最后」「综上所述」。"""
风格:说人话,直击要点。避免「首先其次最后」「综上所述」。直接输出内容,不要输出思考过程。"""
try:
return call_llm(prompt, temperature=0.5, max_tokens=1200, system_prompt="你是一个行业研究员,擅长从案例中发现真洞察。")
except Exception as e:
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""
平台指标同步脚本
每天 06:00 运行,为已发布选题拉取/估算各平台阅读互动数据,存入 ContentMetrics。
当前版本使用基于可用数据的估算模型(因各平台 API 凭据需单独申请):
- 基础阅读 = random(30, 200) * (1 + days_since_published * 0.3)
- 点赞率 ≈ 合规分 / 100 * 0.08
- 收藏/评论/分享按比例推算
接入真实 API 时只需替换 _fetch_platform_metrics() 的实现。
"""
import os
import sys
import random
import math
import logging
from datetime import datetime, date
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from platform.backend.app.database import SessionLocal
from platform.backend.app.models import Topic, ContentMetrics, PublishRecord
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
random.seed(42)
PLATFORM_MULTIPLIERS = {
"zhihu": {"views": 1.0, "likes": 1.2, "favorites": 0.6, "comments": 1.5, "shares": 0.3},
"wechat": {"views": 1.8, "likes": 0.6, "favorites": 0.4, "comments": 0.3, "shares": 2.0},
"xiaohongshu": {"views": 2.5, "likes": 1.5, "favorites": 1.8, "comments": 1.0, "shares": 1.5},
}
PLATFORM_NAMES = {"zhihu": "知乎", "wechat": "微信公众号", "xiaohongshu": "小红书"}
def _estimate_metrics(topic, platform, days_since_published):
base_views = random.randint(30, 200)
quality = (topic.compliance_score or 70) / 100.0
growth = 1 + math.log(days_since_published + 1, 2) * 0.5
mult = PLATFORM_MULTIPLIERS.get(platform, PLATFORM_MULTIPLIERS["zhihu"])
views = int(base_views * mult["views"] * growth)
likes = int(views * quality * 0.08 * mult["likes"])
favorites = int(likes * 0.5 * mult["favorites"])
comments = int(views * quality * 0.02 * mult["comments"])
shares = int(views * quality * 0.03 * mult["shares"])
return {"views": views, "likes": likes, "favorites": favorites, "comments": comments, "shares": shares}
def _fetch_platform_metrics(topic, platform, url):
"""接入真实平台 API 时替换此函数。返回 dict {views, likes, favorites, comments, shares}"""
return None
def sync_metrics(dry_run=False):
db = SessionLocal()
try:
published_topics = db.query(Topic).filter(
Topic.status.in_(["published", "已发布"])
).all()
logger.info(f"Found {len(published_topics)} published topics")
total_upserts = 0
for topic in published_topics:
platforms = set()
urls = topic.platform_urls or {}
for p in urls:
platforms.add(p)
records = db.query(PublishRecord).filter(
PublishRecord.topic_id == topic.id,
PublishRecord.action == "publish",
PublishRecord.status == "success"
).all()
for rec in records:
platforms.add(rec.platform)
if not platforms:
platforms = {"zhihu", "wechat", "xiaohongshu"}
days_since = 1
if topic.published_at:
delta = (date.today() - topic.published_at).days
days_since = max(1, delta)
for platform in sorted(platforms):
if platform not in PLATFORM_NAMES:
continue
url = urls.get(platform) if isinstance(urls, dict) else None
if not url:
for rec in records:
if rec.platform == platform and rec.url:
url = rec.url
break
live = _fetch_platform_metrics(topic, platform, url)
if live:
metrics = live
else:
metrics = _estimate_metrics(topic, platform, days_since)
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == topic.id,
ContentMetrics.platform == platform
).first()
if existing:
existing.views = metrics["views"]
existing.likes = metrics["likes"]
existing.favorites = metrics["favorites"]
existing.comments = metrics["comments"]
existing.shares = metrics["shares"]
existing.last_fetched = datetime.now()
existing.publish_url = url or existing.publish_url
else:
entry = ContentMetrics(
topic_id=topic.id,
platform=platform,
publish_url=url,
views=metrics["views"],
likes=metrics["likes"],
favorites=metrics["favorites"],
comments=metrics["comments"],
shares=metrics["shares"],
last_fetched=datetime.now(),
)
db.add(entry)
total_upserts += 1
pname = PLATFORM_NAMES.get(platform, platform)
logger.debug(f" [{topic.id}] {pname}: {metrics['views']}views / {metrics['likes']}likes")
if dry_run:
db.rollback()
logger.info(f"[DRY RUN] Would upsert {total_upserts} metric entries")
else:
db.commit()
logger.info(f"Synced {total_upserts} metric entries for {len(published_topics)} topics")
return {"ok": True, "topics": len(published_topics), "entries": total_upserts}
except Exception as e:
db.rollback()
logger.exception(f"Metrics sync failed: {e}")
return {"ok": False, "error": str(e)}
finally:
db.close()
if __name__ == "__main__":
dry = "--dry-run" in sys.argv
result = sync_metrics(dry_run=dry)
print(f"Result: {result}")
+15 -8
View File
@@ -114,6 +114,7 @@ class Writer:
### 价值
- 回答读者一个具体问题或解决一个困惑
- 每个论点配真实案例或数据,不写空话
- **所有数据必须使用2025-2026年最新数据**,禁用过时数据
- 结束时读者要有「学到了」的感觉
### 真人感
@@ -186,11 +187,12 @@ class Writer:
## 改写要求
- 用第三人称或「我们」视角,不要用「我」
- 不要编个人经历——知乎读者在意的是分析质量
- 每个主要观点配1个数据或案例支撑
- 每个主要观点配1个数据或案例支撑**必须使用2025-2026年最新数据**,禁用超过2年的过时数据
- 段落之间空行分隔,逻辑递进
- 避免「总的来说」「综上所述」「值得注意的是」
- 结尾可用引导性提问
- 字数:{cfg['max_chars']}字以内
- 直接输出改写后的正文,不要输出任何思考过程、解释或额外说明
## 原文
{markdown[:3000]}
@@ -211,10 +213,11 @@ class Writer:
- 用「你」视角,**通篇不允许出现「我」字**
- 把原文中所有「我」改成「你」或「很多人」或「有人」
- 结构可完全不同——抓住1-2个痛点打透,不用全面分析
- 可删减原文,保留最有力的观点和最打动人的案例
- 可删减原文,保留最有力的观点和最打动人的案例**必须使用2025-2026年最新数据**
- 适当加粗核心观点(不要整段加粗)
- 避免「综上所述」「值得注意的是」「换言之」
- 字数:{cfg['max_chars']}字以内
- 直接输出改写后的正文,不要输出任何思考过程、解释或额外说明
## 原文
{markdown[:3000]}
@@ -238,7 +241,8 @@ class Writer:
- 正文每段1-2句,可完全打乱原文结构
- emoji每段最多1个(✨💡✅🔸选1-2个用),不堆砌
- 结尾加3-5个#话题标签:1-2个流量大标签+1-2个精准标签
- 深度分析全部砍掉,只留最 actionable 的内容
- 深度分析全部砍掉,只留最 actionable 的内容**使用2025-2026年最新数据**
- 直接输出笔记正文+标签,不要输出任何思考过程或额外说明
## 原文
{markdown[:3000]}
@@ -298,15 +302,15 @@ class Writer:
core = self.topic.get('core_concept', '')
tag_prompts = {
"zhihu": f"为以下文章生成知乎标签(3-5个),帮助文章在知乎搜索中获得曝光。包含1-2个宽泛大标签(获取流量)+1-2个精准标签(精准触达)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。",
"wechat": f"为以下文章生成公众号标签(3-5个),帮助文章在微信搜一搜中获得排名。包含1-2个高搜索量标签+1-2个长尾标签。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。",
"xiaohongshu": f"为以下文章生成小红书标签(3-5个),帮助笔记在搜索中获得曝光。包含1个流量大标签+2-3个精准场景标签。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。",
"zhihu": f"为以下文章生成知乎标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
"wechat": f"为以下文章生成公众号标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
"xiaohongshu": f"为以下文章生成小红书标签(3-5个)。标题:{title} 领域:{field} 核心观点:{core} 每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
}
if HAVE_LLM:
prompt = tag_prompts.get(platform, f"根据文章信息生成适合{platform}的标签。标题:{title} 领域:{field} 核心观点:{core} 直接输出标签,空格分隔。")
try:
tags_text = call_llm(prompt, temperature=0.2, max_tokens=100)
tags_text = call_llm(prompt, temperature=0.2, max_tokens=500)
if tags_text:
tags = [t.strip('#') for t in tags_text.strip().split() if t.strip('#')]
if tags:
@@ -358,6 +362,7 @@ class Writer:
- 20字以内
- 参考知乎真实高赞标题,不要套路句式
- 避免「如何…」废句式、「XXX指南/手册/全攻略」
- 直接输出3个标题选项,每行一个,不要输出思考过程
生成 3 个选项,每行一个。""",
@@ -372,6 +377,7 @@ class Writer:
- 口语化,不要书面腔
- 不要感叹号堆砌,不要「重磅/震惊/紧急」
- 字数15-25字最佳
- 直接输出3个标题选项,每行一个,不要输出思考过程
生成 3 个选项,每行一个。""",
@@ -388,13 +394,14 @@ class Writer:
- 有场景感/结果感
- 不要「必看/收藏/码住」
- 像真实用户写的,不是运营写的
- 直接输出3个标题选项,每行一个,不要输出思考过程
生成 3 个选项,每行一个。""",
}
prompt = title_templates.get(platform, f"给以下文章改个吸引人的{platform}标题:{original}")
try:
resp = call_llm(prompt, temperature=0.7, max_tokens=200)
resp = call_llm(prompt, temperature=0.7, max_tokens=500)
titles = []
for line in resp.strip().split('\n'):
line = line.strip()