diff --git a/.gitignore b/.gitignore
index b056479..dd3322f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,3 +59,6 @@ automation/data/research/
automation/data/sustainability_raw/
automation/images/generated/
data/*.db
+*.db
+automation/data/mcp_*.json
+automation/data/*_session.txt
diff --git a/AGENTS.md b/AGENTS.md
index cb2ffb0..c1cb98d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -52,6 +52,17 @@ yu-zhi-ran/
- `USE_POSTGRES=false` falls back to SQLite (used in tests)
- Models have timezone-aware `DateTime(timezone=True)` columns
+### Prompts (`prompt_configs` table)
+- **DB 是唯一来源**,修改 prompt 直接 `UPDATE prompt_configs SET content = '...' WHERE key = '...';`
+- 代码 `scripts/prompt_loader.py` 中的 `_PROMPT_DEFAULTS` **仅作种子数据**,第一次写入后就不再生效
+- 新增 prompt:在 `_PROMPT_DEFAULTS` 添加定义 → 重启后自动补入 DB(仅当该 key 不存在时)
+- 修改 prompt:**直接改 DB,不要改代码**(除非要更新种子供新环境用)
+- DB 不可用时回退代码默认值(仅紧急模式)
+
+### Prompt quality checks (`compliance_checker.py`)
+- 软质量问题(AI套话/人称混用/阅读体验)只降分、不挡流程(`passed=true`)
+- 硬合规问题(敏感词/法律/品牌)扣分多且阻塞流程
+
### LLM
- `call_llm()` in `core/nvidia_client.py` — reads active provider from DB `LLMConfig.is_active`, API key from env
- DeepSeek reasoning models return `reasoning_content` (thinking) + `content` (answer). `call_llm` prefers `content`, falls back to tail of `reasoning_content`
diff --git a/automation/data/search_cache.json b/automation/data/search_cache.json
index 9f69c6f..c82c2c3 100644
--- a/automation/data/search_cache.json
+++ b/automation/data/search_cache.json
@@ -1,62 +1,83 @@
{
+ "_metadata": {
+ "updated_at": "2026-05-23T01:39:18.420136"
+ },
"以旧换新 二手交易 闲置 循环 2026": [
{
- "title": "循环消费实战:10件物品,用3年省了2万",
- "url": "https://sspai.com/post/109886",
- "content": "实用、好用的正版软件推荐,二手交易平台对比,闲置物品变现指南",
- "source": "webfetch"
+ "title": "漆云兰:2026年\"以旧换新\"再进阶:从\"规模效应\"到\"效能红利\"",
+ "url": "https://www.drc.gov.cn/DocView.aspx?chnid=379&leafid=1338&docid=2909735",
+ "content": "2026年以旧换新政策再进阶,从追求规模效应转向效能红利,聚焦设备更新和消费品以旧换新,推动循环经济发展。",
+ "source": "opencode_webfetch"
},
{
- "title": "商务部:以旧换新政策成效显著,2026年绿色家电销售增长40%",
- "url": "https://36kr.com/p/3818280989443202",
- "content": "国家持续推行以旧换新政策,带动循环消费市场快速增长",
- "source": "webfetch"
+ "title": "从\"断舍离\"到新商机 \"闲置经济\"开启万亿新蓝海",
+ "url": "https://finance.sina.com.cn/jjxw/2026-01-09/doc-inhftpvn1314064.shtml",
+ "content": "2024年二手商品交易额达1.69万亿元,同比增长28%。Z世代成为主力,闲置经济从\"低价淘货\"转向兴趣表达与品质体验,闲鱼、转转等平台领跑。",
+ "source": "opencode_webfetch"
},
{
- "title": "二手交易平台闲鱼年交易额突破5000亿,循环经济成为新趋势",
- "url": "https://36kr.com/p/3816942423000196",
- "content": "闲鱼用户数超3亿,每天上架商品超200万件,二手交易已成常态",
- "source": "webfetch"
+ "title": "2026年二手电商:万亿市场的变局与机遇",
+ "url": "https://www.sohu.com/a/978503810_122498610",
+ "content": "2026年二手电商继续高速增长,行业从\"野蛮生长\"走向\"规范发展\"的关键转折点,标准化、专业化的平台迎来机遇。",
+ "source": "opencode_webfetch"
+ },
+ {
+ "title": "低碳循环经济成年轻人新风尚 闲置交易与旧物修复催生万亿新赛道",
+ "url": "https://www.xkb.com.cn/articleDetail/494615",
+ "content": "2026年中国闲置交易市场规模预计突破3.74万亿元,旧物修复市场达1285亿元。青年极简践行率超63%,旧物修复师从业人数突破26万人。",
+ "source": "opencode_webfetch"
}
],
"新能源车 骑行 绿色通勤 低碳出行 2026": [
{
- "title": "低碳生活账单:用3年省了8万,碳足迹降了60%",
- "url": "https://sspai.com/post/109889",
- "content": "从交通(电动车+共享)、饮食(植物为主)、消费(二手优先)三个维度,展示真实账单变化",
- "source": "webfetch"
+ "title": "开局十五五 奋楫新征程 | 绿色低碳出行蔚然成风 民生福祉提质增效",
+ "url": "https://news.qq.com/rain/a/20260417A07ZEK00",
+ "content": "从目前运营数据来看,日均骑行量能达到1000人次以上,后续也会持续做好车辆运维等工作,在保障居民便捷骑行的同时,守护好城区市容秩序。",
+ "source": "opencode_webfetch"
},
{
- "title": "2026年中国新能源车产销突破1500万辆,渗透率超55%",
- "url": "https://36kr.com/p/3816942423000196",
- "content": "新能源汽车市场持续高速增长,充电基础设施逐步完善",
- "source": "webfetch"
+ "title": "政策退坡与技术突围并行 2026中国新能源汽车发展五大趋势",
+ "url": "https://k.sina.cn/article_1262737061_4b43d6a500103249k.html",
+ "content": "2026年新能源汽车产业链将围绕'降本增效'与'绿色低碳'两大核心加速升级,动力电池回收体系逐步完善,关键资源循环利用率提升至30%以上。",
+ "source": "opencode_webfetch"
},
{
- "title": "共享骑行从中国走向全球:中国自行车品牌海外营收增长60%",
- "url": "https://www.36kr.com/feed",
- "content": "国产电动自行车、共享单车品牌加速出海,绿色出行成为全球共识",
- "source": "webfetch"
+ "title": "【深度解析】全球视野下,“低碳”出行如何重塑未来?",
+ "url": "https://www.sohu.com/a/814079598_121956424",
+ "content": "随着技术进步和基础设施完善,预计到2030年新能源汽车将占据全球新车销售的半壁江山。倡导步行和骑行的城市规划理念,有助于改善城市空气质量并提升公众健康水平。",
+ "source": "opencode_webfetch"
+ },
+ {
+ "title": "“行进中的低碳”:绿色交通正在成为中国人的自觉",
+ "url": "https://www.news.cn/20240814/4fd2aa039aa14c45b4322d953dc0bee4/c.html",
+ "content": "民众出行更倾向于'绿色座驾',新能源车零售渗透率高达51.10%;共享的单车、电动车穿梭于大街小巷,全国刮起'骑行风';各地加快公共交通电动化、氢能化。",
+ "source": "opencode_webfetch"
}
],
"干净饮食 有机食品 植物基 本地食材 2026": [
{
- "title": "干净饮食新趋势:有机食品市场规模突破2000亿",
- "url": "https://36kr.com/p/3818280989443202",
- "content": "中国人对食品安全和健康饮食的关注度持续提升,有机食品消费快速增长",
- "source": "webfetch"
+ "title": "植物为主的饮食:护健康兼利环保",
+ "url": "https://www.familydoctor.cn/hlthsci/zhiwu-yinshi-hu-jiankang-jianli-huanbao-564572.html",
+ "content": "介绍星球健康饮食框架,以植物性食物为主、动物性食物为辅,强调全谷物、豆类、坚果及本地当季食材,解析植物蛋白与动物蛋白互补效应,阐述降低慢性病风险及减少碳排放的环境效益。",
+ "source": "opencode_webfetch"
},
{
- "title": "植物基食品在中国:从尝鲜到日常",
- "url": "https://sspai.com/post/109869",
- "content": "植物肉、植物奶等替代蛋白产品逐步走进中国消费者的日常生活",
- "source": "webfetch"
+ "title": "植物為主飲食納每日飲食指南 千禧之愛攜7-ELEVEN推蔬果佔一半",
+ "url": "https://focus.586.com.tw/2026/05/14/p387573/",
+ "content": "2026年台湾卫福部首将植物为主饮食纳入每日饮食指南草案,推动蔬果占餐盘一半运动,强调植物性食物为主、搭配适量白肉与蛋,可降低34%糖尿病风险及减少碳排。",
+ "source": "opencode_webfetch"
},
{
- "title": "本地食材运动兴起:社区团购+农场直供模式重塑食品供应链",
- "url": "https://36kr.com/p/3817849322964098",
- "content": "消费者越来越关注食材来源,本地化、短链供应成为新趋势",
- "source": "webfetch"
+ "title": "2026年必囤!TOP10有机食材清单",
+ "url": "https://www.yuejiaxmz.com/news/view/1455481",
+ "content": "有机品牌素养生活深耕十二年,覆盖杂粮、菌菇、海产等四大品类,坚持3年土壤转换期、低温物理加工、一袋一码全链路溯源,从内蒙古小米到庆元香菇定义真有机标准。",
+ "source": "opencode_webfetch"
+ },
+ {
+ "title": "有机菜VS常规菜:该怎么选更科学?",
+ "url": "https://www.familydoctor.cn/hlthsci/youji-cai-changgui-cai-gaizenme-xuan-kexue-436606.html",
+ "content": "多维度对比有机与常规蔬菜,有机蔬菜在抗氧化物质和微量元素上更具优势、碳足迹低40%,但价格贵2-5倍;建议敏感人群优先选有机叶菜,根茎类可选常规,均衡多样化才是核心。",
+ "source": "opencode_webfetch"
}
],
"零浪费 自带杯 极简生活 可持续时尚 2026": [
@@ -81,99 +102,105 @@
],
"绿色家电 一级能效 以旧换新 节能 2026": [
{
- "title": "一级能效家电占比超90%,国补政策推动绿色消费",
- "url": "https://36kr.com/p/3818280989443202",
- "content": "政府加大绿色家电补贴力度,以旧换新政策带动家电市场增长",
- "source": "webfetch"
+ "title": "商务部等5部门办公厅关于做好2026年家电以旧换新、数码和智能产品购新补贴工作的通知",
+ "url": "https://www.gov.cn/zhengce/zhengceku/202512/content_7053369.htm",
+ "content": "2026年1月1日起,对个人消费者购买1级能效冰箱、洗衣机、电视、空调、热水器、电脑6类家电,按最终售价15%补贴,每件不超过1500元。",
+ "source": "opencode_webfetch"
},
{
- "title": "智能家居节能革命:AI管家让家庭能耗降低30%",
- "url": "https://sspai.com/post/109945",
- "content": "AI技术赋能家电,实现智能化能源管理,家庭电费支出显著下降",
- "source": "webfetch"
+ "title": "国家发展改革委 财政部关于2026年实施大规模设备更新和消费品以旧换新政策的通知",
+ "url": "https://www.ndrc.gov.cn/xwdt/tzgg/202512/t20251230_1402852.html",
+ "content": "6类家电中1级能效产品按售价15%补贴,每件不超1500元。支持手机、平板等4类数码产品购新补贴15%,每件不超500元。",
+ "source": "opencode_webfetch"
},
{
- "title": "德国房屋节能改造经验的中国实践",
- "url": "https://sspai.com/post/109889",
- "content": "通过外墙保温、三层玻璃窗、热回收新风系统,实现旧房节能改造",
- "source": "webfetch"
+ "title": "北京市2026年家电以旧换新、数码和智能产品购新补贴实施细则",
+ "url": "https://www.beijing.gov.cn/zhengce/zhengcefagui/202602/t20260206_4497165.html",
+ "content": "在京个人消费者购买1级能效6类家电享15%补贴,每件不超1500元;4类数码产品每件不超500元。每日10时开放领资格,先购先得。",
+ "source": "opencode_webfetch"
+ },
+ {
+ "title": "国家发展改革委 财政部关于2026年实施大规模设备更新和消费品以旧换新政策的通知",
+ "url": "https://www.gov.cn/zhengce/zhengceku/202512/content_7053236.htm",
+ "content": "重点推广绿色低碳智能产品,1级能效家电按售价15%补贴,每件不超1500元。各地可按比例配套资金,优先支持高能效水效产品。",
+ "source": "opencode_webfetch"
}
],
"碳账户 碳普惠 个人碳减排 蚂蚁森林 2026": [
{
- "title": "IIGF观点 | 浅析我国碳账户体系发展现状及未来展望",
- "url": "https://iigf.cufe.edu.cn/info/1012/6225.htm",
- "content": "2022年我国碳账户探索步入活跃期。2016年支付宝上线'蚂蚁森林'个人碳账户,用户通过低碳行为收集虚拟能量,能量积攒到一定数额可通过公益组织在现实世界种树,截至2020年5月参与者已达5.5亿。2022年十余家机构先后推出个人碳账户,将碳账户与普惠金融挂钩——积分越多信用等级越高,可享信贷利率优惠。浙江衢州已建立覆盖工业、农业、能源等七大领域的239.6万个碳账户,发放企业碳账户贷款294亿元、个人碳账户贷款48亿元。碳账户面临三大问题:碳排放数据采集核算缺乏统一标准、各平台数据无互联互通机制、数据安全缺乏法规保障。",
+ "title": "个人碳账户助推绿色新风尚 碳普惠机制激励公众",
+ "url": "https://caijing.chinadaily.com.cn/a/202407/22/WS669db161a3107cd55d26be3f.html",
+ "content": "碳普惠是绿色低碳发展领域针对公众参与减排的创新机制,个人碳账户是碳普惠的重要工具。政府、企业、金融机构三类主体推出的个人碳账户产品已达上百种,但仍面临顶层政策指引不足、价值消纳体系不完善等挑战。",
"source": "opencode_webfetch"
},
{
- "title": "中信碳账户3周年:共创碳普惠行业标准 焕发可持续消费潜能",
- "url": "https://www.citicbank.com/about/companynews/banknew/message/202504/t20250423_3563037.html",
- "content": "2025年4月22日第56个世界地球日,国内首个银行主导的个人碳账户'中信碳账户'上线3周年,用户超2150万,累计碳减排量超19万吨。中信信用卡联合中汇信碳推出全国首个银行业无纸化金融场景碳普惠方法学,对电子借记卡、电子信用卡、电子账单、线上缴费、线上贷款等7类场景碳减排量进行科学量化并计入碳账户。全新'绿信分'体系从四大维度记录用户绿色生活足迹。2024年'绿色消费'主题活动吸引超4000万人次参与。深圳自2021年印发碳普惠体系建设方案以来已发布6份碳普惠方法学,形成完整制度体系。",
+ "title": "什么是个人碳账户?(延伸阅读)",
+ "url": "https://paper.people.com.cn/rmrbwap/html/2024-05/20/nw.D110000renmrb_20240520_2-08.htm",
+ "content": "个人碳账户是碳普惠机制的重要工具,可视化每个人的减排贡献。推出主体包括地方政府、互联网平台企业(如蚂蚁森林)以及金融机构。激励模式分实物奖励、资产累积、碳普惠减排量交易三种。",
"source": "opencode_webfetch"
},
{
- "title": "个人碳账户助推绿色新风尚 — 新华网",
- "url": "https://www.xinhuanet.com/fortune/20240722/c928f68f99bc4416bd0027da30203bce/c.html",
- "content": "生态环境部宣教中心与中华环保联合会发布的《中国碳普惠发展与实践进展报告(2023)》显示,我国碳普惠取得显著进展。垃圾分类、绿色出行、光盘行动等日常行为均可被'个人碳账户'记录并换取收益。四川泸州'绿芽积分'小程序注册用户超35万,日活超4万,累计减碳超320吨。'个人数字碳账本'已服务北京'绿色生活季'、山西'三晋绿色生活'、黑龙江'碳惠冰城'等平台。全国碳普惠平台和碳账户产品已达上百种,推出主体包括地方政府、互联网平台企业(有ESG需求)、金融机构三类。专家建议加快出台碳普惠顶层设计政策,统一标准。",
- "source": "opencode_webfetch"
- },
- {
- "title": "中国个人碳账户前路在何方? — 对话地球",
+ "title": "中国个人碳账户前路在何方?",
"url": "https://dialogue.earth/zh/3/60096048/",
- "content": "过去三年中国经历了全球最大规模的个人碳足迹核算实践。2015年广州启动国内最早的个人碳账户,2016年支付宝'蚂蚁森林'上线,用户通过低碳行为获'绿色能量'折算成现实树木。2022年碳账户产品从不到10个猛增到60多个,至少7家科技企业(腾讯、美团、阿里)、7家银行、16个城市和4个省份参与。2024年中国自愿碳市场将个人碳账户平台排除在外,碳普惠未能被纳入全国碳市场,也未出台相关法规推广。武汉市政府运营的碳账户允许用户用积分抵扣房贷利息(4.5万克碳积分抵扣90元贷款),低碳行为计量标准为:公交每次212.5克、地铁每公里78.4克、骑行每公里93.3克碳减排量。",
+ "content": "2022年中国个人碳账户产品从不到十个激增至超60个,蚂蚁森林是最知名的平台。但2024年自愿碳市场将个人碳账户排除在外,碳普惠未能纳入碳市场,产品数量大幅减少,未来发展面临不确定性。",
+ "source": "opencode_webfetch"
+ },
+ {
+ "title": "中信碳账户3周年:共创碳普惠行业标准",
+ "url": "https://www.citicbank.com/about/companynews/banknew/message/202504/t20250423_3563037.html",
+ "content": "中信碳账户是国内首个银行主导的个人碳账户,上线三周年用户超2150万,累计碳减排量超19万吨。推出全国首个银行业无纸化金融场景碳普惠方法学,覆盖电子卡、线上缴费等7类场景。",
"source": "opencode_webfetch"
}
],
"环保科技 绿色产品 可持续材料 2026": [
{
- "title": "《中国消费市场绿色低碳趋势调查报告(2025—2026)》重磅发布",
- "url": "https://news.qq.com/rain/a/20260422A01E4A00",
- "content": "报告揭示了消费市场从'单点减碳'到'全链共生'、从'概念营销'到'价值共创'的转型趋势。2026年4月22日发布,涵盖绿色消费理念在供应链各环节的渗透与落地实践。",
+ "title": "李长荣Lacyon™鞋大底循环材料首秀2026国际橡塑展,可持续解决方案赋能产业链绿色转型",
+ "url": "https://www.lcycic.com.cn/news/lcy-lacyontm-sole-circular-material-debuts-at-chinaplas-2026-driving-green-transition",
+ "content": "李长荣展示Lacyon™热塑性弹性体硫化物循环材料用于鞋大底,可减少5-10%制程废弃物并降低41%生产碳排放,实现全鞋回收再制。",
"source": "opencode_webfetch"
},
{
- "title": "2026 可持续包装趋势指南:企业必看的环保包装新方向",
- "url": "https://www.dhl.com/discover/zh-cn/logistics-advice/sustainability-and-green-logistics/sustainable-packaging-trends",
- "content": "DHL发布,指出2026年可持续包装六大趋势:新一代生物可降解材料(PLA、菌丝体基包装)爆发增长;循环包装模式(押金返还机制)加速普及;智能包装借助二维码/NFC引导回收;轻量化设计降低运输碳排放;个性化环保轻奢风兴起;全球一次性塑料禁令与回收含量法规持续趋严。",
+ "title": "巴斯夫正式发布全新减少产品碳足迹(ReducedPCF)的高性能Elastollan® TPU产品组合",
+ "url": "https://www.basf.com/cn/zh/media/news-releases/asia-pacific/2026/05/apac-26-49",
+ "content": "巴斯夫推出ReducedPCF热塑性聚氨酯(TPU)产品组合,可降低10-30%产品碳足迹,在上海首发生产,面向鞋材、运动休闲、线缆等行业。",
"source": "opencode_webfetch"
},
{
- "title": "工业产品绿色设计指南(2026年版)",
- "url": "http://www.ecopv.org.cn/upload/gfzwh/file/20260428/1777343910650427.pdf",
- "content": "国家层面发布的官方指南,涵盖长寿命、无害化、轻量化、节能、节水、节材、降噪、节空间、易回收再生、可重复使用、零碳等11大绿色设计重点方向。针对汽车、工程机械、风电、光伏、锂电池、家用电器、纺织等行业提出具体解决方案,推动'人工智能+绿色设计'及标准体系建设。",
+ "title": "科思创循环材料赋能,润米科技于CHINAPLAS面向全球发售新一代90分R-GO系列咖啡箱",
+ "url": "https://www.covestro.com/press/zh-chs/ninetygo-launches-coffee-suitcase-made-with-recycled-materials-from-covestro-cn/",
+ "content": "90分R-GO系列咖啡箱外壳采用含90%消费后回收再生聚碳酸酯制成,箱壳碳足迹较上一代减少逾67%,不含有意添加的PFAS。",
"source": "opencode_webfetch"
},
{
- "title": "2025-2026年中国绿色消费行为白皮书 - 艾媒咨询",
- "url": "https://www.sohu.com/a/997098772_121864818",
- "content": "报告显示绿色消费核心受众为21-40岁中青年群体(占比78.69%),已婚已育人群达66.70%,家庭育儿需求是重要驱动因素。健康意识与环保责任是消费者购买绿色产品两大核心动因,消费场景呈现'生存型>生活型>享受型'递减趋势。",
+ "title": "瑞讯生物材料首家生物基THF和PTMEG商业化工厂完成机械竣工,并发布全新非粮生物基PTMEG品牌Xatryx®",
+ "url": "https://www.cnu.com.cn/hotnews/202604/186577.html",
+ "content": "瑞讯生物材料启东工厂机械竣工,年产5万吨bioTHF和bioPTMEG,发布非粮生物基PTMEG品牌Xatryx®,原料取自玉米芯等二代生物质。",
"source": "opencode_webfetch"
}
],
"AI工具 人工智能 效率提升 2026": [
+ {
+ "title": "2026年必备AI工具清单,效率提升10倍",
+ "url": "https://www.toutiao.com/article/7621431281997447715",
+ "content": "精选2026年最值得掌握的AI工具,覆盖文本写作、图像创作等场景,从入门到进阶,助你打造AI工作流。",
+ "source": "opencode_webfetch"
+ },
+ {
+ "title": "2026年职场效率革命:10款AI工具重塑生产力格局",
+ "url": "https://developer.baidu.com/article/detail.html?id=5602703",
+ "content": "精选2026年职场人必备的10款AI生产力工具,涵盖智能文档处理、视频创作、数据分析、跨语言协作等核心场景。",
+ "source": "opencode_webfetch"
+ },
{
"title": "2026年必备的40个AI工具软件,办公效率提升120%",
"url": "https://zhuanlan.zhihu.com/p/2009728985890828767",
- "content": "知乎专栏文章,系统梳理2026年最值得关注的40款AI工具,涵盖通用大模型(ChatGPT/Claude/Gemini/DeepSeek)、AI思维导图(boardmix/Miro)、AI编程(GitHub Copilot/Cursor)、AI写作(Notion AI/Grammarly/Jasper)、AI绘图(Midjourney/Stable Diffusion/Adobe Firefly)、AI视频(Runway/Synthesia/CapCut)、AI音频(ElevenLabs/Suno)、AI生成PPT(博思AIPPT/Gamma)八大类,每类均分析核心优势与局限性,并提供选型指南。",
+ "content": "进入2026年,AI工具已成为日常工作与生活中不可或缺的伙伴,重塑着各行各业的生产力边界。",
"source": "opencode_webfetch"
},
{
- "title": "2026年真正实用的10款AI神器:让创作、效率与思维整理实现质的飞跃",
- "url": "https://blog.csdn.net/lgf228/article/details/157800617",
- "content": "CSDN技术博客精选10款职场AI工具,涵盖对话助手(DeepSeek免费全能/通义千问阿里生态/豆包内容加速)、办公效率(WPS AI深度集成/ChatExcel自然语言操作表格/飞书AI会议纪要自动化)、内容创作(Midjourney/即梦本土绘图/可灵AI视频)、思维整理(博思白板AI生成思维导图/GetNote知识管理)。包含行业实践案例:锡盟融媒体中心用DeepSeek+即梦将内容生产效率提升30%+,影视制作团队从4-6人缩减至1-2人。",
- "source": "opencode_webfetch"
- },
- {
- "title": "2026年成熟企业提升工作流程效率的顶级人工智能工具",
- "url": "https://www.ranktracker.com/zh/blog/top-ai-tools-business-workflow-efficiency/",
- "content": "RankTracker企业效率专题,分析AI在零售(Yieldigo AI定价优化)、流程自动化(UiPath RPA机器人)、品牌设计(Design.com AI标志生成)、知识管理(Notion AI)四大场景的落地实践。指出到2026年AI已从实验性技术变为企业日常工具,企业正用AI自动化重复任务、加速数据分析、优化客户支持与营销活动、支持财务预测和产品开发。",
- "source": "opencode_webfetch"
- },
- {
- "title": "2026年AI技术演进与职场变革全景展望:探索效率突破与职业转型新路径",
- "url": "https://zhuanlan.zhihu.com/p/1981743817511178354",
- "content": "知乎深度分析文章,指出开源框架(如DeepSeek-MoE)与轻量化模型(Phi-3-mini等38亿参数模型)持续降低AI开发门槛;企业微信AI机器人、微信'元宝'助手、支付宝智能客服等应用将AI无缝嵌入日常生产生活场景,推动'人工智能+千行百业'进入规模化落地新阶段。探讨AI对职场变革的双向影响:效率突破与职业转型路径。",
+ "title": "2026最新AI办公工具汇总:20款国内AI工具推荐",
+ "url": "https://chatexcel.com/blog/latest-ai-tools-summary",
+ "content": "盘点2026年国内AI工具,包括写作、Excel、PPT等场景,覆盖ChatExcel、豆包、腾讯元宝、WPS AI等,提升办公效率。",
"source": "opencode_webfetch"
}
]
diff --git a/automation/templates/wechat.html b/automation/templates/wechat.html
index 6b65577..9bbe2db 100644
--- a/automation/templates/wechat.html
+++ b/automation/templates/wechat.html
@@ -139,10 +139,6 @@
diff --git a/automation/templates/xiaohongshu.html b/automation/templates/xiaohongshu.html
index 7dfbaaa..cfe7413 100644
--- a/automation/templates/xiaohongshu.html
+++ b/automation/templates/xiaohongshu.html
@@ -139,10 +139,6 @@
diff --git a/automation/templates/zhihu.html b/automation/templates/zhihu.html
index ac653d9..59c93f7 100644
--- a/automation/templates/zhihu.html
+++ b/automation/templates/zhihu.html
@@ -146,10 +146,6 @@
@@ -170,7 +166,6 @@
diff --git a/platform/backend/app/api/metrics.py b/platform/backend/app/api/metrics.py
index 9aa43f6..65c560e 100644
--- a/platform/backend/app/api/metrics.py
+++ b/platform/backend/app/api/metrics.py
@@ -321,6 +321,27 @@ def _extract_zhihu_post_id(url: str) -> str:
raise ValueError("无法从URL中提取知乎文章/回答ID")
+def _fetch_zhihu_page(post_id: str) -> dict:
+ """多种方式尝试获取知乎文章数据,返回原始 JSON"""
+ UA = _ZHIHU_UA
+ # 方式1:专栏 API
+ urls = [
+ f"https://zhuanlan.zhihu.com/api/posts/{post_id}",
+ f"https://www.zhihu.com/api/v4/posts/{post_id}",
+ f"https://www.zhihu.com/api/v4/answers/{post_id}",
+ ]
+ exceptions = []
+ for url in urls:
+ try:
+ resp = http_requests.get(url, headers={"User-Agent": UA}, timeout=10)
+ if resp.status_code == 200:
+ return resp.json()
+ exceptions.append(f"{url} → {resp.status_code}")
+ except Exception as e:
+ exceptions.append(f"{url} → {e}")
+ raise RuntimeError(f"知乎API已封禁,无法获取数据({'; '.join(exceptions)})")
+
+
class ZhihuFetchRequest(BaseModel):
topic_id: str
zhihu_url: str
@@ -338,17 +359,9 @@ def fetch_zhihu_metrics(
raise HTTPException(status_code=404, detail="选题不存在")
post_id = _extract_zhihu_post_id(data.zhihu_url)
- api_url = f"https://zhuanlan.zhihu.com/api/posts/{post_id}"
try:
- resp = http_requests.get(api_url, headers={"User-Agent": _ZHIHU_UA}, timeout=15)
- if resp.status_code == 404:
- api_url = f"https://www.zhihu.com/api/v4/answers/{post_id}"
- resp = http_requests.get(api_url, headers={"User-Agent": _ZHIHU_UA}, timeout=15)
- if resp.status_code != 200:
- raise HTTPException(status_code=502, detail=f"知乎API返回 {resp.status_code}")
-
- raw = resp.json()
+ raw = _fetch_zhihu_page(post_id)
platform = "zhihu"
existing = db.query(ContentMetrics).filter(
diff --git a/platform/backend/app/api/publishing.py b/platform/backend/app/api/publishing.py
index 5a25186..be13748 100644
--- a/platform/backend/app/api/publishing.py
+++ b/platform/backend/app/api/publishing.py
@@ -2,7 +2,7 @@
"""发布管理 API"""
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
-from datetime import datetime
+from datetime import datetime, timezone, timedelta
from typing import Optional, List
from ..database import get_db
@@ -86,8 +86,9 @@ async def create_publish_record(
))
topic.status = '已发布'
- topic.updated_at = datetime.now()
- topic.published_at = datetime.now().date()
+ _now = datetime.now(timezone(timedelta(hours=8)))
+ topic.updated_at = _now
+ topic.published_at = _now.date()
db.commit()
db.expire_all()
db.refresh(topic)
diff --git a/platform/backend/app/api/search_providers.py b/platform/backend/app/api/search_providers.py
new file mode 100644
index 0000000..f365e4d
--- /dev/null
+++ b/platform/backend/app/api/search_providers.py
@@ -0,0 +1,141 @@
+import logging
+import sys
+import json
+from pathlib import Path
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+from typing import List, Optional
+from datetime import datetime, timezone
+
+from ..database import get_db
+from ..models import SearchProvider
+from .auth import get_current_user
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/search-providers", tags=["search_providers"])
+
+
+@router.get("")
+def list_providers(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
+ providers = db.query(SearchProvider).order_by(SearchProvider.priority).all()
+ return [p.to_dict() for p in providers]
+
+
+@router.post("")
+def create_provider(data: dict, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
+ p = SearchProvider(
+ name=data.get("name", ""),
+ provider_type=data.get("provider_type", ""),
+ api_key=data.get("api_key", ""),
+ api_url=data.get("api_url", ""),
+ console_url=data.get("console_url", ""),
+ priority=data.get("priority", 99),
+ enabled=data.get("enabled", True),
+ daily_limit=data.get("daily_limit", 1500),
+ extra_config=data.get("extra_config", {}),
+ )
+ db.add(p)
+ db.commit()
+ db.refresh(p)
+ return p.to_dict()
+
+
+@router.put("/{provider_id}")
+def update_provider(provider_id: int, data: dict, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
+ p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first()
+ if not p:
+ raise HTTPException(status_code=404, detail="Provider not found")
+ for key in ("name", "provider_type", "api_key", "api_url", "console_url", "priority", "enabled", "daily_limit"):
+ if key in data:
+ setattr(p, key, data[key])
+ if "extra_config" in data:
+ p.extra_config = data["extra_config"]
+ p.updated_at = datetime.now(timezone.utc)
+ db.commit()
+ db.refresh(p)
+ return p.to_dict()
+
+
+@router.delete("/{provider_id}")
+def delete_provider(provider_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
+ p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first()
+ if not p:
+ raise HTTPException(status_code=404, detail="Provider not found")
+ db.delete(p)
+ db.commit()
+ return {"ok": True}
+
+
+@router.post("/{provider_id}/test")
+def test_provider(provider_id: int, data: dict = {}, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
+ p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first()
+ if not p:
+ raise HTTPException(status_code=404, detail="Provider not found")
+ query = data.get("query", "测试搜索")
+ try:
+ test_key = data.get("api_key") or p.api_key
+ test_url = data.get("api_url") or p.api_url
+ if p.provider_type == "baidu":
+ import requests
+ resp = requests.post(
+ test_url,
+ headers={"Authorization": f"Bearer {test_key}", "Content-Type": "application/json"},
+ json={"messages": [{"role": "user", "content": query}], "search_source": "baidu_search_v2", "resource_type_filter": [{"type": "web", "top_k": 3}]},
+ timeout=15
+ )
+ if resp.status_code != 200:
+ return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
+ return {"ok": True, "results": resp.json().get("results", [])[:3]}
+ elif p.provider_type == "qiniu":
+ import requests
+ resp = requests.post(
+ test_url,
+ headers={"Authorization": f"Bearer {test_key}", "Content-Type": "application/json"},
+ json={"query": query, "max_results": 3, "search_type": "web"},
+ timeout=15
+ )
+ if resp.status_code != 200:
+ return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
+ return {"ok": True, "results": resp.json().get("results", resp.json().get("data", []))[:3]}
+ elif p.provider_type == "tinyfish":
+ import requests
+ resp = requests.get(
+ test_url,
+ params={"query": query, "max_results": 3},
+ headers={"X-API-Key": test_key},
+ timeout=15
+ )
+ if resp.status_code != 200:
+ return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
+ return {"ok": True, "results": resp.json().get("results", resp.json().get("data", []))[:3]}
+ elif p.provider_type == "bing":
+ import requests
+ resp = requests.get(
+ test_url,
+ params={"q": query, "count": 3, "mkt": "zh-CN"},
+ headers={"Ocp-Apim-Subscription-Key": test_key},
+ timeout=15
+ )
+ if resp.status_code != 200:
+ return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
+ return {"ok": True, "results": resp.json().get("webPages", {}).get("value", [])[:3]}
+ elif p.provider_type == "mcp":
+ import subprocess, json as _json
+ mcp_script = Path(__file__).resolve().parent.parent.parent.parent.parent / "scripts" / "mcp_search_server.py"
+ r = subprocess.run(
+ [sys.executable, str(mcp_script), "--query", query],
+ capture_output=True, text=True, timeout=90,
+ )
+ if r.returncode != 0:
+ return {"ok": False, "error": f"子进程失败: {r.stderr[:200]}"}
+ return {"ok": True, "results": _json.loads(r.stdout)[:3]}
+ return {"ok": False, "error": f"Unknown provider_type: {p.provider_type}"}
+ except Exception as e:
+ return {"ok": False, "error": str(e)}
+
+
+@router.post("/reset-usage")
+def reset_usage(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
+ db.query(SearchProvider).update({SearchProvider.usage_today: 0})
+ db.commit()
+ return {"ok": True}
diff --git a/platform/backend/app/api/system.py b/platform/backend/app/api/system.py
index 7617459..07b1d5c 100644
--- a/platform/backend/app/api/system.py
+++ b/platform/backend/app/api/system.py
@@ -3,16 +3,17 @@ import subprocess
from fastapi import APIRouter, HTTPException, Depends, Body
from sqlalchemy.orm import Session
from sqlalchemy import func
-from datetime import datetime, date
+from datetime import datetime, date, timezone, timedelta
from pathlib import Path
from typing import Dict, Any, List, Optional
import os
+import os
import json
from ..database import get_db
from ..models import Topic, Article, TaskConfig, TaskLog
-from ..core.generator import run_creator, get_generator_status
-from ..core.optimizer import run_optimizer, get_optimizer_status
-from ..core.collector import run_collector, get_collector_status
+from ..core.generator import run_creator, get_generator_status, _running_processes as _generator_running
+from ..core.optimizer import run_optimizer, get_optimizer_status, _running_processes as _optimizer_running
+from ..core.collector import run_collector, get_collector_status, _running_processes as _collector_running
import threading
from ..core.sync import sync_all_topics
from ..core.scheduler import scheduler
@@ -26,6 +27,32 @@ LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/system", tags=["system"])
+_active_monitors: Dict[int, dict] = {}
+
+def _monitor_subprocess(log_id: int, proc, module_id: str, task_name: str, db_session_factory):
+ """阻塞等待子进程退出(最长 1800s),完成后更新 task_logs"""
+ try:
+ returncode = proc.wait(timeout=1800)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ returncode = -1
+ logger.warning("Subprocess %s (pid=%s) killed after 1800s timeout", module_id, proc.pid)
+ finished_at = datetime.now(timezone.utc)
+ try:
+ db = db_session_factory()
+ log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
+ if log:
+ log.status = "success" if returncode == 0 else "failed"
+ log.finished_at = finished_at
+ if log.started_at:
+ log.duration = int((finished_at - log.started_at).total_seconds())
+ db.commit()
+ db.close()
+ except Exception as e:
+ logger.warning("Failed to update task log %s: %s", log_id, e)
+ finally:
+ _active_monitors.pop(log_id, None)
+
def _aggregate_status_counts(q):
"""聚合状态计数,兼容中英文状态值"""
raw = q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
@@ -60,12 +87,22 @@ def get_status(db: Session = Depends(get_db)):
}
}
-@router.post("/generate/run", dependencies=[Depends(get_current_user)])
+@router.post("/generate/run")
def trigger_generation(topic_id: Optional[str] = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
logger.info(f"Generation triggered by {current_user.username}, topic_id={topic_id}")
try:
result = run_creator(topic_id)
- return {"message": "内容创作已后台启动", "pid": result.get("pid")}
+ from ..database import SessionLocal
+ log = TaskLog(module_id="scheduled_generate", task_name="🤖 内容创作", status="running", message="内容创作已启动", triggered_by="manual", started_at=datetime.now(timezone.utc))
+ db.add(log)
+ db.commit()
+ log_id = log.id
+ proc_info = result.get("proc") or result
+ proc = _generator_running.get("generator", {}).get("process") if "pid" in result else None
+ if proc:
+ t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_generate", "🤖 内容创作", SessionLocal), daemon=True)
+ t.start()
+ return {"message": "内容创作已后台启动", "pid": result.get("pid"), "log_id": log_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -76,12 +113,21 @@ def generation_status():
return {"status": "idle", "message": "当前无运行中的创作任务"}
return status
-@router.post("/collect/run", dependencies=[Depends(get_current_user)])
+@router.post("/collect/run")
def trigger_collection(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
logger.info(f"Manual collection triggered by {current_user.username}")
try:
result = run_collector()
- return {"message": "内容采集已后台启动", "result": result}
+ from ..database import SessionLocal
+ log = TaskLog(module_id="scheduled_collect", task_name="📡 内容采集", status="running", message="内容采集已启动", triggered_by="manual", started_at=datetime.now(timezone.utc))
+ db.add(log)
+ db.commit()
+ log_id = log.id
+ proc = _collector_running.get("collector", {}).get("process") if "pid" in result else None
+ if proc:
+ t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_collect", "📡 内容采集", SessionLocal), daemon=True)
+ t.start()
+ return {"message": "内容采集已后台启动", "result": result, "log_id": log_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -92,11 +138,20 @@ def collection_status():
return {"status": "idle", "message": "当前无运行中的采集任务"}
return status
-@router.post("/review/run", dependencies=[Depends(get_current_user)])
+@router.post("/review/run")
def trigger_review(topic_ids: Optional[List[str]] = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
try:
result = run_optimizer(topic_ids)
- return {"message": "合规审查已后台启动", "pid": result.get("pid")}
+ from ..database import SessionLocal
+ log = TaskLog(module_id="scheduled_optimize", task_name="🔍 合规审查", status="running", message="合规审查已启动", triggered_by="manual", started_at=datetime.now(timezone.utc))
+ db.add(log)
+ db.commit()
+ log_id = log.id
+ proc = _optimizer_running.get("optimizer", {}).get("process") if "pid" in result else None
+ if proc:
+ t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_optimize", "🔍 合规审查", SessionLocal), daemon=True)
+ t.start()
+ return {"message": "合规审查已后台启动", "pid": result.get("pid"), "log_id": log_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -177,17 +232,24 @@ def trigger_metrics_sync():
raise HTTPException(status_code=500, detail=str(e))
@router.post("/refresh-search-cache/run")
-def trigger_refresh_search_cache():
+def trigger_refresh_search_cache(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
try:
import sys as sys_mod
scripts_dir = Path(__file__).parent.parent.parent.parent / "scripts"
+ from ..database import SessionLocal as _ss
proc = subprocess.Popen(
[sys_mod.executable, str(scripts_dir / "opencode_search.py"), "--refresh-cache"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
cwd=scripts_dir.parent.parent
)
logger.info("Search cache refresh started (pid=%s)", proc.pid)
- return {"message": "搜索缓存刷新已后台启动", "pid": proc.pid}
+ log = TaskLog(module_id="scheduled_refresh_search_cache", task_name="🔍 搜索缓存", status="running", message="搜索缓存刷新已启动", triggered_by="manual", started_at=datetime.now(timezone.utc), result_data={"pid": proc.pid})
+ db.add(log)
+ db.commit()
+ log_id = log.id
+ t = threading.Thread(target=_monitor_subprocess, args=(log_id, proc, "scheduled_refresh_search_cache", "🔍 搜索缓存", _ss), daemon=True)
+ t.start()
+ return {"message": "搜索缓存刷新已后台启动", "pid": proc.pid, "log_id": log_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -259,6 +321,7 @@ def get_modules_status(db: Session = Depends(get_db)):
"scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00", "params_desc": {"auto_pass_threshold": "自动通过分数阈值"}},
"scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00", "params_desc": {}},
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00", "params_desc": {}},
+ "scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*", "params_desc": {}},
}
modules = []
diff --git a/platform/backend/app/api/task_logs.py b/platform/backend/app/api/task_logs.py
index d211791..5ef9b7f 100644
--- a/platform/backend/app/api/task_logs.py
+++ b/platform/backend/app/api/task_logs.py
@@ -19,6 +19,7 @@ MODULES = {
"scheduled_optimize": "🔍 合规审查",
"scheduled_optimize_sources": "📡 信息源优化",
"scheduled_metrics_sync": "📊 指标同步",
+ "scheduled_task_monitor": "⏰ 任务监控",
}
@router.get("", response_model=List[TaskLogResponse])
@@ -68,6 +69,29 @@ def list_modules(db: Session = Depends(get_db), admin_user=Depends(get_current_a
})
return result
+
+@router.get("/log-types")
+def list_log_types(db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
+ used = db.query(TaskLog.module_id).distinct().all()
+ used_ids = [r[0] for r in used]
+ result = []
+ for mid, name in MODULES.items():
+ if mid in used_ids or True:
+ log_file_map = {
+ "scheduled_refresh_search_cache": "opencode_search",
+ "scheduled_fetch_trends": "trends",
+ "scheduled_collect": "collector",
+ "scheduled_generate": "creator",
+ "scheduled_optimize": "optimizer",
+ "scheduled_optimize_sources": "collector",
+ "scheduled_metrics_sync": "sync",
+ }
+ result.append({"module_id": mid, "name": name, "log_file": log_file_map.get(mid, mid)})
+ for mid in used_ids:
+ if mid not in MODULES:
+ result.append({"module_id": mid, "name": mid, "log_file": mid})
+ return result
+
@router.get("/{log_id}", response_model=TaskLogResponse)
def get_task_log(log_id: int, db: Session = Depends(get_db), admin_user=Depends(get_current_admin)):
log = db.query(TaskLog).filter(TaskLog.id == log_id).first()
diff --git a/platform/backend/app/api/tasks.py b/platform/backend/app/api/tasks.py
index 788192b..7a2a662 100644
--- a/platform/backend/app/api/tasks.py
+++ b/platform/backend/app/api/tasks.py
@@ -1,4 +1,5 @@
import uuid
+import threading
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
@@ -8,6 +9,8 @@ from ..models import ContentTask, Topic
from ..schemas import ContentTaskCreate, ContentTaskResponse
from .auth import get_current_user, org_filter
+_creator_semaphore = threading.Semaphore(3)
+
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@@ -240,6 +243,7 @@ def _get_module_detail_data(module_id: str, db, ROOT, DATA_DIR, LOGS_DIR, today_
"scheduled_optimize": {"name": "🔍 合规审查", "description": "LLM 审查已创作文章,检查合规、打分数、优化建议"},
"scheduled_optimize_sources": {"name": "📡 信息源优化", "description": "AI 分析当前类别和信息源的市场匹配度,给出调整建议"},
"scheduled_metrics_sync": {"name": "📊 指标同步", "description": "从各平台公开 API 获取已发布文章的互动数据(点赞、阅读、评论等)"},
+ "scheduled_task_monitor": {"name": "⏰ 任务监控", "description": "每小时自动检查卡死/中断任务,标记为失败以便重新执行"},
}
meta = MODULE_META.get(module_id, {"name": module_id, "description": ""})
@@ -430,7 +434,6 @@ def run_creator_task(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
- import threading
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
@@ -448,44 +451,51 @@ def run_creator_task(
db.commit()
db.refresh(task)
- from ..core.generator import run_creator
-
def _run():
from ..database import SessionLocal
+ from ..core.generator import run_creator_blocking
from datetime import datetime, timezone
- new_db = SessionLocal()
+ import traceback
+ _creator_semaphore.acquire()
try:
- new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
- if new_task:
- new_task.message = "创作脚本运行中..."
- new_task.progress = 30
- new_db.commit()
- result = run_creator(topic_id)
- new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
- if new_task:
- finished = datetime.now(timezone.utc)
- new_task.status = "completed"
- new_task.finished_at = finished
- new_task.progress = 100
- new_task.message = "创作完成"
- new_task.result_data = result or {}
- if new_task.started_at:
- new_task.duration = int((finished - new_task.started_at).total_seconds())
- new_db.commit()
- except Exception as e:
- new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
- if new_task:
- finished = datetime.now(timezone.utc)
- new_task.status = "failed"
- new_task.finished_at = finished
- new_task.error_msg = str(e)
- if new_task.started_at:
- new_task.duration = int((finished - new_task.started_at).total_seconds())
- new_db.commit()
- finally:
- new_db.close()
+ new_db = SessionLocal()
+ try:
+ new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
+ if new_task:
+ new_task.message = "创作脚本运行中..."
+ new_task.progress = 30
+ new_db.commit()
- thread = threading.Thread(target=_run)
+ result = run_creator_blocking(topic_id)
+
+ new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
+ if new_task:
+ finished = datetime.now(timezone.utc)
+ new_task.status = "completed"
+ new_task.finished_at = finished
+ new_task.progress = 100
+ new_task.message = "创作完成"
+ new_task.result_data = {"stdout": (result or {}).get("stdout", "")[:2000]} if isinstance(result, dict) else {"raw": str(result)[:2000]}
+ if new_task.started_at:
+ new_task.duration = int((finished - new_task.started_at).total_seconds())
+ new_db.commit()
+ except Exception as e:
+ new_db.rollback()
+ new_task = new_db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
+ if new_task:
+ finished = datetime.now(timezone.utc)
+ new_task.status = "failed"
+ new_task.finished_at = finished
+ new_task.error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
+ if new_task.started_at:
+ new_task.duration = int((finished - new_task.started_at).total_seconds())
+ new_db.commit()
+ finally:
+ new_db.close()
+ finally:
+ _creator_semaphore.release()
+
+ thread = threading.Thread(target=_run, daemon=True)
thread.start()
return task
\ No newline at end of file
diff --git a/platform/backend/app/core/scheduler.py b/platform/backend/app/core/scheduler.py
index 1915358..37fcc3f 100644
--- a/platform/backend/app/core/scheduler.py
+++ b/platform/backend/app/core/scheduler.py
@@ -27,6 +27,8 @@ MODULES = {
"scheduled_optimize": {"name": "🔍 合规审查", "cron": "03:00"},
"scheduled_optimize_sources": {"name": "📡 信息源优化", "cron": "05:00"},
"scheduled_metrics_sync": {"name": "📊 指标同步", "cron": "06:00"},
+ "scheduled_reset_search_usage": {"name": "🔁 搜索用量重置", "cron": "00:05"},
+ "scheduled_task_monitor": {"name": "⏰ 任务监控", "cron": "*"},
}
def _log_task(module_id: str, status: str, message: str = None,
@@ -107,6 +109,7 @@ class TaskScheduler:
("scheduled_optimize", self._run_optimize, "合规审查"),
("scheduled_optimize_sources", self._run_optimize_sources, "信息源优化"),
("scheduled_metrics_sync", self._run_metrics_sync, "指标同步"),
+ ("scheduled_reset_search_usage", self._run_reset_search_usage, "搜索用量重置"),
]
for module_id, fn, name in MODULE_JOBS:
@@ -129,6 +132,17 @@ class TaskScheduler:
)
logger.info(f"调度任务: {module_id} -> {schedule}")
+ # 每小时运行的任务监控:检测卡死/中断任务
+ self.scheduler.add_job(
+ self._run_task_monitor,
+ CronTrigger(hour='*/1'),
+ id='scheduled_task_monitor',
+ replace_existing=True,
+ max_instances=1,
+ coalesce=True
+ )
+ logger.info("调度任务: scheduled_task_monitor -> 每小时")
+
self.scheduler.start()
self._started = True
logger.info("Scheduler started with dynamic schedule from TaskConfig")
@@ -441,6 +455,95 @@ class TaskScheduler:
started_at=started, finished_at=datetime.now(timezone.utc))
logger.exception("[Scheduled] Metrics sync failed: %s", e)
+ def _run_reset_search_usage(self):
+ """每日凌晨重置搜索 API 提供商用量计数"""
+ started = datetime.now(timezone.utc)
+ _log_task("scheduled_reset_search_usage", "running", started_at=started)
+ try:
+ from ..database import SessionLocal
+ from ..models import SearchProvider
+ db = SessionLocal()
+ try:
+ total = db.query(SearchProvider).update({SearchProvider.usage_today: 0, SearchProvider.last_used_at: None})
+ db.commit()
+ _log_task("scheduled_reset_search_usage", "success",
+ message=f"已重置 {total} 个提供商用量",
+ result_data={"reset_count": total},
+ started_at=started, finished_at=datetime.now(timezone.utc))
+ logger.info("[Scheduled] Reset %d search providers usage", total)
+ finally:
+ db.close()
+ except Exception as e:
+ _log_task("scheduled_reset_search_usage", "failed",
+ message=str(e),
+ error_trace=traceback.format_exc(),
+ started_at=started, finished_at=datetime.now(timezone.utc))
+ logger.exception("[Scheduled] Reset search usage failed: %s", e)
+
+ def _run_task_monitor(self):
+ """每小时检查卡死/中断的任务,标记为失败"""
+ started = datetime.now(timezone.utc)
+ _log_task("scheduled_task_monitor", "running", started_at=started)
+ stuck_tasklog_timeout = 7200 # 超过2小时视为卡死
+ stuck_contenttask_timeout = 10800 # 超过3小时视为卡死
+ try:
+ from ..database import SessionLocal
+ from ..models import TaskLog, ContentTask
+ db = SessionLocal()
+ try:
+ now = datetime.now(timezone.utc)
+ cutoff_tasklog = now.timestamp() - stuck_tasklog_timeout
+ cutoff_content = now.timestamp() - stuck_contenttask_timeout
+ marked = 0
+
+ # 检查 TaskLog 中卡死的 running 记录
+ stuck_logs = db.query(TaskLog).filter(
+ TaskLog.status == "running",
+ TaskLog.started_at.isnot(None)
+ ).all()
+ for log in stuck_logs:
+ if log.started_at.timestamp() < cutoff_tasklog:
+ log.status = "failed"
+ log.finished_at = now
+ log.error_trace = "系统监控:任务运行超时(超过2小时)或进程中断,已自动标记为失败"
+ if log.started_at:
+ log.duration = int((now - log.started_at).total_seconds())
+ marked += 1
+ logger.warning("[TaskMonitor] 标记 TaskLog %d (%s) 为失败(超时)", log.id, log.module_id)
+
+ # 检查 ContentTask 中卡死的 running 记录
+ stuck_tasks = db.query(ContentTask).filter(
+ ContentTask.status == "running",
+ ContentTask.started_at.isnot(None)
+ ).all()
+ for task in stuck_tasks:
+ if task.started_at.timestamp() < cutoff_content:
+ task.status = "failed"
+ task.finished_at = now
+ task.error_msg = "系统监控:任务运行超时(超过3小时)或进程中断,已自动标记为失败"
+ if task.started_at:
+ task.duration = int((now - task.started_at).total_seconds())
+ marked += 1
+ logger.warning("[TaskMonitor] 标记 ContentTask %s (%s) 为失败(超时)", task.task_id, task.stage)
+
+ if marked:
+ db.commit()
+ logger.info("[TaskMonitor] 已标记 %d 个卡死任务为失败", marked)
+
+ _log_task("scheduled_task_monitor", "success",
+ message=f"检查完成,标记 {marked} 个卡死任务",
+ result_data={"marked_failed": marked},
+ started_at=started, finished_at=datetime.now(timezone.utc))
+ finally:
+ db.close()
+ except Exception as e:
+ import traceback
+ _log_task("scheduled_task_monitor", "failed",
+ message=str(e),
+ error_trace=traceback.format_exc(),
+ started_at=started, finished_at=datetime.now(timezone.utc))
+ logger.exception("[TaskMonitor] 监控检查失败: %s", e)
+
def get_jobs(self):
"""返回当前所有定时任务的状态"""
jobs = []
diff --git a/platform/backend/app/database.py b/platform/backend/app/database.py
index b6f3c32..b67ea58 100644
--- a/platform/backend/app/database.py
+++ b/platform/backend/app/database.py
@@ -53,6 +53,7 @@ def init_db():
for table, col, typ in [
("users", "org_id", "VARCHAR DEFAULT 'default'"),
("topics", "org_id", "VARCHAR DEFAULT 'default'"),
+ ("topics", "reviewed_at", "TIMESTAMP"),
("platform_configs", "requires_image", "BOOLEAN DEFAULT FALSE"),
("platform_configs", "image_count_min", "INTEGER DEFAULT 0"),
("platform_configs", "image_count_max", "INTEGER DEFAULT 0"),
@@ -82,6 +83,7 @@ def init_db():
("prompt_configs", "temperature", "FLOAT"),
("prompt_configs", "max_tokens", "INTEGER"),
("prompt_configs", "created_by", "VARCHAR"),
+ ("search_providers", "console_url", "VARCHAR"),
("keyword_domain_map", "id", "INTEGER PRIMARY KEY"),
("keyword_domain_map", "pattern", "VARCHAR"),
("keyword_domain_map", "domain", "VARCHAR"),
@@ -124,10 +126,11 @@ def init_db():
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {typ}"))
except Exception:
pass
- # Create roles and menus tables if they don't exist
+ # Create roles, menus, and search_providers tables if they don't exist
for tbl_sql in [
"CREATE TABLE IF NOT EXISTS roles (id SERIAL PRIMARY KEY, name VARCHAR UNIQUE NOT NULL, description VARCHAR DEFAULT '', is_system BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now())",
"CREATE TABLE IF NOT EXISTS menus (id SERIAL PRIMARY KEY, parent_id INTEGER REFERENCES menus(id), name VARCHAR NOT NULL, path VARCHAR NOT NULL, icon VARCHAR DEFAULT '', sort_order INTEGER DEFAULT 0, roles JSON DEFAULT '[]'::json, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now())",
+ "CREATE TABLE IF NOT EXISTS search_providers (id SERIAL PRIMARY KEY, name VARCHAR NOT NULL, provider_type VARCHAR NOT NULL, api_key VARCHAR, api_url VARCHAR, priority INTEGER DEFAULT 1, enabled BOOLEAN DEFAULT TRUE, daily_limit INTEGER DEFAULT 1500, usage_today INTEGER DEFAULT 0, extra_config JSON DEFAULT '{}'::json, last_used_at TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT now())",
]:
try:
conn.execute(text(tbl_sql))
diff --git a/platform/backend/app/initial_data.py b/platform/backend/app/initial_data.py
index f65473c..fcfd9e3 100644
--- a/platform/backend/app/initial_data.py
+++ b/platform/backend/app/initial_data.py
@@ -6,7 +6,7 @@ from .database import SessionLocal, init_db
from .models import (
Topic, TopicField, TopicConfigField, TopicStatusConfig,
User, Case, LLMConfig, SystemConfig, PlatformConfig,
- CollectorCategory, CollectorSource, Role, Menu
+ CollectorCategory, CollectorSource, Role, Menu, SearchProvider
)
import bcrypt
@@ -71,6 +71,17 @@ def import_initial_data():
db.commit()
print("✅ 插入默认系统配置")
+ # 初始化默认搜索 API 提供商
+ if db.query(SearchProvider).count() == 0:
+ providers = [
+ SearchProvider(name="百度千帆", provider_type="baidu", api_key="", api_url="https://qianfan.baidubce.com/v2/ai_search/web_search", console_url="https://console.bce.baidu.com/qianfan/ais/console/onlineService", priority=1, enabled=True, daily_limit=50),
+ SearchProvider(name="opencode云搜索", provider_type="mcp", api_key="", api_url="", console_url="https://opencode.ai", priority=2, enabled=True, daily_limit=99999),
+ ]
+ for p in providers:
+ db.add(p)
+ db.commit()
+ print("✅ 插入默认搜索 API 提供商")
+
if db.query(PlatformConfig).count() == 0:
platforms = [
{
diff --git a/platform/backend/app/main.py b/platform/backend/app/main.py
index a9916fe..8015882 100644
--- a/platform/backend/app/main.py
+++ b/platform/backend/app/main.py
@@ -9,7 +9,7 @@ from pathlib import Path
from .database import engine, get_db, init_db
from .models import Base
-from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs
+from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers
from .initial_data import import_initial_data
from .core.scheduler import scheduler
@@ -103,6 +103,7 @@ app.include_router(config_items.router)
app.include_router(role_configs.router)
app.include_router(menu_configs.router)
app.include_router(menu_configs.public_router)
+app.include_router(search_providers.router)
# 挂载自动生成的图片(必须先于前端根挂载)
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
diff --git a/platform/backend/app/models.py b/platform/backend/app/models.py
index 083d9a5..906dfba 100644
--- a/platform/backend/app/models.py
+++ b/platform/backend/app/models.py
@@ -2,7 +2,44 @@ from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boo
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from .database import Base
-from datetime import datetime
+from datetime import datetime, timezone
+
+
+class SearchProvider(Base):
+ __tablename__ = "search_providers"
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ name = Column(String, nullable=False, comment="显示名称")
+ provider_type = Column(String, nullable=False, comment="baidu / qiniu / tinyfish")
+ api_key = Column(String, nullable=True, comment="API密钥")
+ api_url = Column(String, nullable=True, comment="API地址")
+ priority = Column(Integer, default=1, comment="优先级,越小越优先")
+ enabled = Column(Boolean, default=True)
+ daily_limit = Column(Integer, default=1500, comment="每日调用上限")
+ usage_today = Column(Integer, default=0, comment="当日已用次数")
+ console_url = Column(String, nullable=True, comment="官网控制台地址")
+ extra_config = Column(JSON, default=dict, comment="额外配置")
+ last_used_at = Column(DateTime(timezone=True), nullable=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "name": self.name,
+ "provider_type": self.provider_type,
+ "api_key": self.api_key,
+ "api_url": self.api_url,
+ "console_url": self.console_url,
+ "priority": self.priority,
+ "enabled": self.enabled,
+ "daily_limit": self.daily_limit,
+ "usage_today": self.usage_today,
+ "extra_config": self.extra_config or {},
+ "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+ }
class AuditLog(Base):
@@ -221,6 +258,7 @@ class Topic(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
generated_at = Column(DateTime(timezone=True), nullable=True)
+ reviewed_at = Column(DateTime(timezone=True), nullable=True)
ready_at = Column(Date)
published_at = Column(Date)
compliance_score = Column(Integer)
diff --git a/platform/backend/app/schemas.py b/platform/backend/app/schemas.py
index d9c9a93..532238b 100644
--- a/platform/backend/app/schemas.py
+++ b/platform/backend/app/schemas.py
@@ -112,6 +112,7 @@ class TopicResponse(TopicBase):
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
generated_at: Optional[datetime] = None
+ reviewed_at: Optional[datetime] = None
ready_at: Optional[date] = None
published_at: Optional[date] = None
compliance_score: Optional[int] = None
diff --git a/platform/frontend/admin.html b/platform/frontend/admin.html
index ce6a7f8..53154e5 100644
--- a/platform/frontend/admin.html
+++ b/platform/frontend/admin.html
@@ -31,6 +31,7 @@
菜单管理
运行日志
AI 助手
+ 搜索API
@@ -441,16 +442,8 @@
-
-
-
-
-
-
-
-
-
-
+
+
加载日志
@@ -480,7 +473,119 @@
+
+
+ 新增提供商
+ 测试全部
+ 重置用量
+ 共 {{ searchProviders.length }} 个
+
+
加载中...
+
+
+
+
+
+
+
+
+
+ {{ scope.row.provider_type }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ scope.row.usage_today }}/{{ scope.row.daily_limit }}
+
+
+
+
+
+ {{ scope.row.api_url || '-' }}
+
+
+
+
+ 打开
+ -
+
+
+
+
+ 编辑
+ 测试
+ 删除
+
+
+
+
+
+
名称{{ item.name }}
+
类型{{ item.provider_type }}
+
用量{{ item.usage_today }}/{{ item.daily_limit }}
+
编辑删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+ 测试中...
+
+
+ {{ searchProviderTestResult.ok ? '✅ 连接成功' : '❌ 连接失败' }}
+
+
{{ searchProviderTestResult.error }}
+
+
+
{{ r.title }}
+
{{ r.url }}
+
+
+
+ 关闭
+
@@ -761,6 +866,7 @@ const llmConfigs = ref([]);
};
const logType = ref('creator');
+ const logTypes = ref([]);
const users = ref([]);
const usersLoading = ref(false);
const userDialogVisible = ref(false);
@@ -810,10 +916,25 @@ const llmConfigs = ref([]);
const logDate = ref(new Date().toISOString().slice(0, 10));
const logContent = ref('');
const logsLoading = ref(false);
+ const loadLogTypes = async () => {
+ try {
+ logTypes.value = await api.get('/api/admin/task-logs/log-types');
+ logTypes.value.unshift({ module_id: 'creator', name: '创作日志', log_file: 'creator' });
+ logTypes.value.unshift({ module_id: 'optimizer', name: '审查日志', log_file: 'optimizer' });
+ logTypes.value.unshift({ module_id: 'research', name: '研究日志', log_file: 'research' });
+ logTypes.value.unshift({ module_id: 'outline', name: '大纲日志', log_file: 'outline' });
+ logTypes.value.unshift({ module_id: 'writer', name: '写作日志', log_file: 'writer' });
+ logTypes.value.unshift({ module_id: 'publisher', name: '发布日志', log_file: 'publisher' });
+ logTypes.value.unshift({ module_id: 'collector', name: '收集日志', log_file: 'collector' });
+ logTypes.value.unshift({ module_id: 'trends', name: '趋势日志', log_file: 'trends' });
+ } catch (e) { console.error('加载日志类型失败:', e); }
+ };
const fetchLogs = async () => {
logsLoading.value = true;
try {
- const resp = await fetch(`/api/system/logs/${logDate.value}?log_type=${logType.value}`, {
+ const module = logTypes.value.find(t => t.name === logType.value);
+ const logFile = module ? module.log_file : logType.value;
+ const resp = await fetch(`/api/system/logs/${logDate.value}?log_type=${logFile}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken')}` }
});
if (resp.ok) {
@@ -863,12 +984,89 @@ const llmConfigs = ref([]);
} catch (e) { console.error('加载AI助手配置失败', e); }
};
const saveAssistantPrompt = async () => {
- assistantSaving.value = true;
try {
- await api.post('/api/admin/systemconfigs', { key: 'assistant_system_prompt', value: assistantPrompt.value, description: 'AI 助手系统提示词' });
- ElementPlus.ElMessage.success('保存成功');
- } catch (e) { ElementPlus.ElMessage.error('保存失败: ' + e.message); }
- finally { assistantSaving.value = false; }
+ const configs = await api.get('/api/admin/systemconfigs');
+ const cfg = configs.find(c => c.key === 'assistant_system_prompt');
+ if (cfg) { await api.put(`/api/admin/systemconfigs/${cfg.id}`, { value: assistantPrompt.value, description: 'AI 助手系统提示词' }); }
+ else { await api.post('/api/admin/systemconfigs', { key: 'assistant_system_prompt', value: assistantPrompt.value, description: 'AI 助手系统提示词' }); }
+ ElMessage.success('保存成功');
+ } catch (e) { ElMessage.error('保存失败: ' + e.message); }
+ };
+
+ const searchProviders = ref([]);
+ const searchProvidersLoading = ref(false);
+ const searchProviderDialogVisible = ref(false);
+ const searchProviderDialogTitle = ref('新增搜索API');
+ const searchProviderSaving = ref(false);
+ const searchProviderForm = reactive({ id: null, name: '', provider_type: 'baidu', api_key: '', api_url: '', console_url: '', priority: 1, daily_limit: 1500, enabled: true });
+ const searchProviderTestVisible = ref(false);
+ const searchProviderTesting = ref(false);
+ const searchProviderTestResult = ref({ ok: false, error: '', results: [] });
+
+ const loadSearchProviders = async () => {
+ searchProvidersLoading.value = true;
+ try { searchProviders.value = await api.get('/api/search-providers'); }
+ catch (e) { ElMessage.error('加载搜索API失败: ' + e.message); }
+ finally { searchProvidersLoading.value = false; }
+ };
+ const addSearchProvider = () => {
+ searchProviderDialogTitle.value = '新增搜索API';
+ searchProviderForm.id = null;
+ searchProviderForm.name = '';
+ searchProviderForm.provider_type = 'baidu';
+ searchProviderForm.api_key = '';
+ searchProviderForm.api_url = '';
+ searchProviderForm.console_url = '';
+ searchProviderForm.priority = 1;
+ searchProviderForm.daily_limit = 1500;
+ searchProviderForm.enabled = true;
+ searchProviderDialogVisible.value = true;
+ };
+ const editSearchProvider = (row) => {
+ searchProviderDialogTitle.value = '编辑搜索API';
+ Object.assign(searchProviderForm, { id: row.id, name: row.name, provider_type: row.provider_type, api_key: row.api_key, api_url: row.api_url, console_url: row.console_url || '', priority: row.priority, daily_limit: row.daily_limit, enabled: row.enabled });
+ searchProviderDialogVisible.value = true;
+ };
+ const saveSearchProvider = async () => {
+ searchProviderSaving.value = true;
+ try {
+ const body = { name: searchProviderForm.name, provider_type: searchProviderForm.provider_type, api_key: searchProviderForm.api_key, api_url: searchProviderForm.api_url, console_url: searchProviderForm.console_url, priority: searchProviderForm.priority, daily_limit: searchProviderForm.daily_limit, enabled: searchProviderForm.enabled };
+ if (searchProviderForm.id) { await api.put(`/api/search-providers/${searchProviderForm.id}`, body); ElMessage.success('更新成功'); }
+ else { await api.post('/api/search-providers', body); ElMessage.success('创建成功'); }
+ searchProviderDialogVisible.value = false;
+ await loadSearchProviders();
+ } catch (e) { ElMessage.error('保存失败: ' + e.message); }
+ finally { searchProviderSaving.value = false; }
+ };
+ const deleteSearchProvider = async (id) => {
+ try { await ElMessageBox.confirm('确定删除该提供商吗?', '提示', { type: 'warning' }); await api.delete(`/api/search-providers/${id}`); ElMessage.success('删除成功'); await loadSearchProviders(); }
+ catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
+ };
+ const updateSearchProvider = async (row) => {
+ try { await api.put(`/api/search-providers/${row.id}`, { enabled: row.enabled }); }
+ catch (e) { ElMessage.error('更新失败: ' + e.message); }
+ };
+ const testSearchProvider = async (row) => {
+ searchProviderTesting.value = true;
+ searchProviderTestVisible.value = true;
+ searchProviderTestResult.value = { ok: false, error: '', results: [] };
+ try {
+ const r = await api.post(`/api/search-providers/${row.id}/test`, { query: '测试搜索' });
+ searchProviderTestResult.value = r;
+ } catch (e) { searchProviderTestResult.value = { ok: false, error: e.message, results: [] }; }
+ finally { searchProviderTesting.value = false; }
+ };
+ const testAllSearchProviders = async () => {
+ for (const p of searchProviders.value) {
+ try {
+ const r = await api.post(`/api/search-providers/${p.id}/test`, { query: '测试搜索' });
+ ElMessage({ type: r.ok ? 'success' : 'error', message: `${p.name}: ${r.ok ? 'OK' : '失败 ' + (r.error || '')}` });
+ } catch (e) { ElMessage.error(`${p.name} 测试失败: ${e.message}`); }
+ }
+ };
+ const resetSearchUsage = async () => {
+ try { await api.post('/api/search-providers/reset-usage'); ElMessage.success('用量已重置'); await loadSearchProviders(); }
+ catch (e) { ElMessage.error('重置失败: ' + e.message); }
};
const formatDate = (dateStr) => { if (!dateStr) return '-'; return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); };
@@ -877,7 +1075,8 @@ const llmConfigs = ref([]);
const tabLoaders = {
llmconfigs: loadLLMConfigs, platformconfigs: loadPlatformConfigs, systemconfigs: loadSystemConfigs,
users: fetchUsers,
- orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig, logs: fetchLogs,
+ logs: loadLogTypes, orgs: loadOrgs, roles: loadRoles, menus: loadMenus, assistant: loadAssistantConfig,
+ searchproviders: loadSearchProviders,
};
const loadedTabs = new Set([]);
@@ -909,8 +1108,12 @@ const llmConfigs = ref([]);
logout, currentUser, isAdmin, redirectToPage,
users, usersLoading, userDialogVisible, userDialogTitle, userSubmitting, userForm, userPage, userPageSize, paginatedUsers,
fetchUsers, addUser, showEditUserDialog, submitUser, deleteUser,
- logType, logDate, logContent, logsLoading, fetchLogs,
+ logType, logTypes, logDate, logContent, logsLoading, fetchLogs,
assistantPrompt, assistantEnabled, assistantSaving, saveAssistantPrompt,
+ searchProviders, searchProvidersLoading, searchProviderDialogVisible, searchProviderDialogTitle, searchProviderSaving, searchProviderForm,
+ searchProviderTestVisible, searchProviderTesting, searchProviderTestResult,
+ loadSearchProviders, addSearchProvider, editSearchProvider, saveSearchProvider, deleteSearchProvider, updateSearchProvider,
+ testSearchProvider, testAllSearchProviders, resetSearchUsage,
};
}
});
diff --git a/platform/frontend/articles.html b/platform/frontend/articles.html
index 512f0b3..59cc1a5 100644
--- a/platform/frontend/articles.html
+++ b/platform/frontend/articles.html
@@ -222,12 +222,17 @@ const ArticlesApp = {
const titleEl = doc.querySelector('h1');
const title = titleEl ? titleEl.textContent.trim() : (this.previewArticleData.topic_title || '');
const body = doc.body;
- if (body) body.querySelectorAll('script, style, svg, img, nav, footer, .interaction').forEach(el => el.remove());
- const contentEls = body ? Array.from(body.querySelectorAll('p, h1, h2, h3, h4, li')) : [];
- const text = contentEls.map(el => el.textContent.trim()).filter(t => t && t.length > 1).join('\n\n');
- navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`)
- .then(() => this.$message.success('已复制到剪贴板'))
- .catch(() => this.$message.error('复制失败'));
+ if (body) body.querySelectorAll('script, style, nav, footer, .interaction, .ad, aside, .comment').forEach(el => el.remove());
+ const container = doc.createElement('div');
+ if (titleEl) { const h1 = doc.createElement('h1'); h1.textContent = title; container.appendChild(h1); }
+ body.querySelectorAll('h2,h3,h4,p,li,blockquote,img,pre,code,table,hr').forEach(el => container.appendChild(el.cloneNode(true)));
+ const cleanHtml = container.innerHTML;
+ const blob = new Blob([cleanHtml], { type: 'text/html' });
+ const plainText = container.textContent;
+ const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([plainText], { type: 'text/plain' }) });
+ navigator.clipboard.write([item]).then(() => this.$message.success('✅ 已复制(含格式和配图),Ctrl+V 粘贴')).catch(() => {
+ navigator.clipboard.writeText(cleanHtml).then(() => this.$message.success('✅ 已复制 HTML')).catch(() => this.$message.error('❌ 复制失败'));
+ });
},
async deleteArticle(article) {
try {
diff --git a/platform/frontend/calendar.html b/platform/frontend/calendar.html
index 3b908c0..e952712 100644
--- a/platform/frontend/calendar.html
+++ b/platform/frontend/calendar.html
@@ -189,14 +189,15 @@
- {{ platformName(scope.row.platform) }}
+ {{ scope.row._source === 'topic' ? (scope.row.status === 'published' ? '已发布' : '选题') : platformName(scope.row.platform) }}
{{ statusLabel(scope.row.status) }}
- 编辑
+ 查看
+ 编辑
@@ -407,11 +408,51 @@ function getDayMeta(year, month, day) {
loadingCalendar.value = true;
calendarError.value = '';
errorMsg.value = '';
- try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); calendarError.value = e.message; errorMsg.value = '加载失败: ' + e.message; }
+ try {
+ const [calEntries, topicList] = await Promise.all([
+ api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`),
+ api('/api/topics?limit=200'),
+ ]);
+ topics.value = topicList;
+
+ const virtualEntries = [];
+ (topicList || []).forEach(t => {
+ const s = t.status;
+ if ((s === 'ready' || s === '待发布') && t.ready_at) {
+ const d = new Date(t.ready_at);
+ if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) {
+ virtualEntries.push({ id: 'topic_ready_' + t.id, title: t.title, planned_date: t.ready_at, platform: '', status: 'planned', platform_icon: '📋', topic_status: 'ready', _source: 'topic' });
+ }
+ } else if ((s === 'published' || s === '已发布') && t.published_at) {
+ const d = new Date(t.published_at);
+ if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) {
+ virtualEntries.push({ id: 'topic_pub_' + t.id, title: t.title, planned_date: t.published_at, platform: '', status: 'published', platform_icon: '📄', topic_status: 'published', _source: 'topic' });
+ }
+ }
+ });
+ entries.value = [...(calEntries || []), ...virtualEntries];
+ } catch (e) { console.error(e); calendarError.value = e.message; errorMsg.value = '加载失败: ' + e.message; }
finally { loadingCalendar.value = false; }
};
const fetchStats = async () => {
- try { stats.value = await api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); ElMessage.error('加载统计失败: ' + e.message); }
+ try {
+ const [calStats, topicList] = await Promise.all([
+ api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`),
+ api('/api/topics?limit=200').catch(() => []),
+ ]);
+ const s = { planned: calStats.planned || 0, published: calStats.published || 0, delayed: calStats.delayed || 0, cancelled: calStats.cancelled || 0 };
+ (topicList || []).forEach(t => {
+ const st = t.status;
+ if ((st === 'ready' || st === '待发布') && t.ready_at) {
+ const d = new Date(t.ready_at);
+ if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) s.planned++;
+ } else if ((st === 'published' || st === '已发布') && t.published_at) {
+ const d = new Date(t.published_at);
+ if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) s.published++;
+ }
+ });
+ stats.value = s;
+ } catch (e) { console.error(e); ElMessage.error('加载统计失败: ' + e.message); }
};
const fetchTopics = async () => {
try { const res = await api('/api/topics?limit=100'); topics.value = res; } catch (e) { console.error(e); ElMessage.error('加载选题失败: ' + e.message); }
@@ -423,7 +464,14 @@ function getDayMeta(year, month, day) {
const openDayDialog = (day) => { selectedDay.value = day; selectedDayEntries.value = entries.value.filter(e => { const pd = new Date(e.planned_date); return pd.getFullYear() === day.year && pd.getMonth() + 1 === day.month && pd.getDate() === day.day; }); dayDialogVisible.value = true; };
const openCreateDialog = () => { isEdit.value = false; entryForm.value = { id: null, title: '', planned_date: `${selectedDay.value.year}-${selectedDay.value.month}-${selectedDay.value.day}`, platform: 'zhihu', topic_id: null, status: 'planned', notes: '' }; entryDialogVisible.value = true; };
- const openEntryDialog = (entry) => { isEdit.value = true; entryForm.value = { ...entry, planned_date: entry.planned_date }; entryDialogVisible.value = true; };
+ const openEntryDialog = (entry) => {
+ if (entry._source === 'topic') {
+ const topicId = entry.id.replace(/^topic_(ready|pub)_/, '');
+ window.location.href = '/topics.html?topic_id=' + topicId;
+ return;
+ }
+ isEdit.value = true; entryForm.value = { ...entry, planned_date: entry.planned_date }; entryDialogVisible.value = true;
+ };
const saveEntry = async () => {
saving.value = true;
@@ -463,7 +511,6 @@ function getDayMeta(year, month, day) {
isAdmin.value = d.user.role === 'admin';
fetchEntries();
fetchStats();
- fetchTopics();
})
.catch(() => {
localStorage.removeItem('authToken');
diff --git a/platform/frontend/index.html b/platform/frontend/index.html
index e3a8e4f..6773587 100644
--- a/platform/frontend/index.html
+++ b/platform/frontend/index.html
@@ -327,22 +327,51 @@
this.loadingPlan = true;
try {
const token = localStorage.getItem('authToken');
- const now = new Date();
- const year = now.getFullYear();
- const month = now.getMonth() + 1;
- const resp = await fetch(`/api/calendar?year=${year}&month=${month}`, {
- headers: { 'Authorization': 'Bearer ' + token }
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const end = new Date(today);
+ end.setDate(end.getDate() + 7);
+
+ const [entries, topics] = await Promise.all([
+ fetch(`/api/calendar?year=${today.getFullYear()}&month=${today.getMonth() + 1}`, {
+ headers: { 'Authorization': 'Bearer ' + token }
+ }).then(r => r.ok ? r.json() : []),
+ fetch('/api/topics?limit=200', {
+ headers: { 'Authorization': 'Bearer ' + token }
+ }).then(r => r.ok ? r.json() : [])
+ ]);
+
+ const events = [];
+ (entries || []).forEach(e => {
+ events.push({
+ id: 'cal_' + e.id,
+ date: e.planned_date,
+ title: e.title,
+ status: e.status === 'published' ? 'published' : 'planned'
+ });
});
- if (resp.ok) {
- const all = await resp.json();
- const today = new Date();
- today.setHours(0, 0, 0, 0);
- const end = new Date(today);
- end.setDate(end.getDate() + 7);
- this.upcomingEntries = (all || [])
- .filter(e => { const d = new Date(e.planned_date); return d >= today && d < end; })
- .sort((a, b) => a.planned_date.localeCompare(b.planned_date));
- }
+ (topics || []).forEach(t => {
+ const s = t.status;
+ if ((s === 'ready' || s === '待发布') && t.ready_at) {
+ events.push({
+ id: 'topic_ready_' + t.id,
+ date: t.ready_at,
+ title: t.title,
+ status: 'planned'
+ });
+ } else if ((s === 'published' || s === '已发布') && t.published_at) {
+ events.push({
+ id: 'topic_pub_' + t.id,
+ date: t.published_at,
+ title: t.title,
+ status: 'published'
+ });
+ }
+ });
+
+ this.upcomingEntries = events
+ .filter(e => { const d = new Date(e.date); return d >= today && d < end; })
+ .sort((a, b) => a.date.localeCompare(b.date));
} catch (e) {
console.error('获取近期计划失败:', e);
} finally { this.loadingPlan = false; }
diff --git a/platform/frontend/tasks.html b/platform/frontend/tasks.html
index 7efaf78..6bed584 100644
--- a/platform/frontend/tasks.html
+++ b/platform/frontend/tasks.html
@@ -100,9 +100,9 @@
{{ mod.last_status === 'success' ? '正常' : mod.last_status === 'failed' ? '失败' : '空闲' }}
-
最后运行{{ mod.last_run || '从未' }}
+
最后运行{{ mod.last_run || '从未' }}✅成功❌失败⏳运行中
下次运行{{ mod.next_run || '—' }}
-
累计运行{{ mod.total_runs }} 次 {{ mod.success_runs }} 成功 {{ mod.failed_runs }} 失败
+
累计运行{{ mod.total_runs }} 次 {{ mod.success_runs }} 成功 {{ mod.failed_runs }} 失败 {{ mod.running }} 运行中
立即运行
查看详情
@@ -619,6 +619,7 @@ const TasksApp = {
'scheduled_metrics_sync': { icon: 'IconDashboard', name: '指标同步', defaultTime: '06:00' },
'scheduled_refresh_search_cache': { icon: 'IconRefresh', name: '搜索缓存', defaultTime: '01:00' },
'scheduled_fetch_trends': { icon: 'IconRefresh', name: '热点趋势', defaultTime: '01:10' },
+ 'scheduled_task_monitor': { icon: 'IconRefresh', name: '任务监控', defaultTime: '*' },
};
const MODULE_TRIGGER_ENDPOINTS = {
scheduled_collect: '/api/system/collect/run',
@@ -762,7 +763,7 @@ const TasksApp = {
},
async triggerModule(modId) {
const endpoint = this.MODULE_TRIGGER_ENDPOINTS[modId];
- if (!endpoint) { ElMessage.error('未知模块'); return; }
+ if (!endpoint) { ElMessage.info('此模块自动运行,无需手动触发'); return; }
this.runningModule = modId;
try {
await this.api(endpoint, { method: 'POST' });
diff --git a/platform/frontend/topics.html b/platform/frontend/topics.html
index e4ee8a5..fdc8129 100644
--- a/platform/frontend/topics.html
+++ b/platform/frontend/topics.html
@@ -22,8 +22,11 @@
.topic-card-meta { display: flex; flex-direction: column; gap: 3px; font-size: var(--font-size-caption); color: var(--color-text-regular); margin-bottom: 10px; }
.topic-card-actions { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--color-border); }
.topic-card-actions .el-button { margin: 0; width: 100%; justify-content: center; padding: 8px 4px !important; }
- .topic-card.is-checked { border-color: var(--color-primary); box-shadow: 0 0 0 1px rgba(64,158,255,0.2); }
- .topic-card-actions .el-button--danger { grid-column: 1 / -1; }
+ .topic-card.is-checked { border-color: var(--color-primary); box-shadow: 0 0 0 1px rgba(64,158,255,0.2); }
+ .topic-card-actions .el-button--danger { grid-column: 1 / -1; }
+ .search-area { background:#f8faff; border:1px solid #e8edf5; border-radius:8px; padding:12px 16px; margin-bottom:12px; }
+ .search-area .el-form-item { margin-bottom:6px; }
+ .search-toggle { cursor:pointer; user-select:none; font-size:14px; color:#409eff; }
}
@@ -53,9 +56,52 @@
待发布 ({{ statusStats.ready }})
已发布 ({{ statusStats.published }})
-
item.id)">
+
+ 🔍 {{ showSearch ? '收起搜索' : '展开搜索' }}
+ 筛选条件已生效,共 {{ filteredTopics.length }} 条
+
+
+
+
+
+
+
+
+
+
+
+ ~
+
+
+
+
+ ~
+
+
+
+
+
+
+ ~
+
+
+
+
+ ~
+
+
+
+
+
+ 查询
+ 重置
+
+
+
+
+ item.id)" @sort-change="handleSortChange" :default-sort="{ prop: 'created_at', order: 'ascending' }">
-
+
@@ -64,9 +110,10 @@
- {{ formatDate(scope.row.created_at) }}
- {{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}
- {{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}
+ {{ formatDate(scope.row.created_at) }}
+ {{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}
+ {{ scope.row.reviewed_at ? formatDate(scope.row.reviewed_at) : '-' }}
+ {{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}
@@ -100,6 +147,7 @@
@@ -177,6 +225,7 @@
|
状态:{{ getStatusLabel(previewTopic.status) }}
创作:{{ formatDate(previewTopic.generated_at) }}
+ 审查:{{ formatDate(previewTopic.reviewed_at) }}
发布:{{ formatDate(previewTopic.published_at) }}
@@ -204,16 +253,66 @@ const TopicsApp = {
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
publishing: false,
savingContent: false,
- currentPage: 1, pageSize: 10
+ currentPage: 1, pageSize: 10,
+ sortField: 'created_at', sortOrder: 'ascending',
+ showSearch: false,
+ searchForm: { id: '', title: '', field: '', createdStart: null, createdEnd: null, generatedStart: null, generatedEnd: null, reviewedStart: null, reviewedEnd: null, publishedStart: null, publishedEnd: null }
}
},
computed: {
+ searchActive() {
+ const f = this.searchForm;
+ return !!(f.id || f.title || f.field || f.createdStart || f.createdEnd || f.generatedStart || f.generatedEnd || f.reviewedStart || f.reviewedEnd || f.publishedStart || f.publishedEnd);
+ },
filteredTopics() {
- if (this.filterStatus === 'today') return this.todayTopics;
- if (!this.filterStatus) return this.topics;
- const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
- const allowed = map[this.filterStatus] || [this.filterStatus];
- return this.topics.filter(t => allowed.includes(t.status));
+ let list;
+ if (this.filterStatus === 'today') list = this.todayTopics;
+ else if (!this.filterStatus) list = this.topics;
+ else {
+ const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
+ const allowed = map[this.filterStatus] || [this.filterStatus];
+ list = this.topics.filter(t => allowed.includes(t.status));
+ }
+ // 搜索过滤
+ const sf = this.searchForm;
+ if (sf.id || sf.title || sf.field || sf.createdStart || sf.createdEnd || sf.generatedStart || sf.generatedEnd || sf.reviewedStart || sf.reviewedEnd || sf.publishedStart || sf.publishedEnd) {
+ list = list.filter(t => {
+ if (sf.id && !t.id.toLowerCase().includes(sf.id.toLowerCase())) return false;
+ if (sf.title && !t.title.toLowerCase().includes(sf.title.toLowerCase())) return false;
+ if (sf.field && !(t.field || '').toLowerCase().includes(sf.field.toLowerCase())) return false;
+ const inRange = (val, start, end) => {
+ if (!val) return !start && !end;
+ const d = val.slice(0, 10);
+ if (start && d < start) return false;
+ if (end && d > end) return false;
+ return true;
+ };
+ return inRange(t.created_at, sf.createdStart, sf.createdEnd)
+ && inRange(t.generated_at, sf.generatedStart, sf.generatedEnd)
+ && inRange(t.reviewed_at, sf.reviewedStart, sf.reviewedEnd)
+ && inRange(t.published_at, sf.publishedStart, sf.publishedEnd);
+ });
+ }
+ if (this.sortField) {
+ const field = this.sortField;
+ const isDateField = ['created_at','generated_at','reviewed_at','published_at','updated_at'].includes(field);
+ list = [...list].sort((a, b) => {
+ const va = a[field], vb = b[field];
+ if (!va && !vb) return 0;
+ if (!va) return 1;
+ if (!vb) return -1;
+ let cmp;
+ if (isDateField) {
+ const ta = new Date(va.replace(' ', 'T')).getTime();
+ const tb = new Date(vb.replace(' ', 'T')).getTime();
+ cmp = ta - tb;
+ } else {
+ cmp = String(va).localeCompare(String(vb), 'zh', { numeric: true });
+ }
+ return this.sortOrder === 'descending' ? -cmp : cmp;
+ });
+ }
+ return list;
},
paginatedTopics() {
const start = (this.currentPage - 1) * this.pageSize;
@@ -356,13 +455,42 @@ const TopicsApp = {
const title = titleEl ? titleEl.textContent.trim() : (this.previewTopic?.title || '');
const body = doc.body;
if (body) {
- body.querySelectorAll('script, style, nav, .header, .tags, footer, .interaction, svg, img, button').forEach(el => el.remove());
+ body.querySelectorAll('script, style, nav, .header, .tags, footer, .interaction, button, .ad, aside, .comment').forEach(el => el.remove());
+ }
+
+ if (platform === 'xiaohongshu') {
+ const lines = [];
+ body.querySelectorAll('h1,h2,h3,h4,p,li').forEach(el => {
+ const text = el.textContent.trim();
+ if (!text || text.length < 2) return;
+ const tag = el.tagName.toLowerCase();
+ const prefix = tag.startsWith('h') ? '\n### ' : '- ';
+ const clean = text.replace(/\n/g, ' ').replace(/^[\s#]+|[\s#]+$/g, '');
+ if (clean) lines.push(prefix + clean);
+ });
+ const md = `**${title}**\n\n${lines.join('\n')}`;
+ navigator.clipboard.writeText(md).then(() => this.$message.success('✅ 已复制 Markdown(小红书格式)')).catch(() => this.$message.error('❌ 复制失败'));
+ } else {
+ const container = doc.createElement('div');
+ if (titleEl) {
+ const h1 = doc.createElement('h1');
+ h1.textContent = title;
+ container.appendChild(h1);
+ }
+ body.querySelectorAll('h2,h3,h4,p,li,blockquote,img,pre,code,table,hr').forEach(el => {
+ const clone = el.cloneNode(true);
+ container.appendChild(clone);
+ });
+ const cleanHtml = container.innerHTML;
+ const blob = new Blob([cleanHtml], { type: 'text/html' });
+ const richText = new Blob([cleanHtml], { type: 'text/plain' });
+ const item = new ClipboardItem({ 'text/html': blob, 'text/plain': richText });
+ navigator.clipboard.write([item]).then(() => {
+ this.$message.success(`✅ 已复制(含格式和配图),Ctrl+V 粘贴到${platform === 'zhihu' ? '知乎' : '微信公众号'}`);
+ }).catch(() => {
+ navigator.clipboard.writeText(cleanHtml).then(() => this.$message.success('✅ 已复制 HTML')).catch(() => this.$message.error('❌ 复制失败'));
+ });
}
- const contentEls = body ? Array.from(body.querySelectorAll('p, h1, h2, h3, h4, li')) : [];
- const text = contentEls.map(el => el.textContent.trim()).filter(t => t && t.length > 1).join('\n\n');
- navigator.clipboard.writeText(`标题:${title}\n\n内容:\n${text}`)
- .then(() => this.$message.success(`已复制内容,请前往${platform}粘贴发布`))
- .catch(() => this.$message.error('复制失败,请手动复制'));
},
async createTopic(topic) {
if (this.isStatus(topic, 'published')) { this.$message.info('已发布选题不可创作'); return; }
@@ -451,6 +579,11 @@ const TopicsApp = {
} catch (e) { this.$message.error('保存失败: ' + e.message); }
finally { this.savingContent = false; }
},
+ doSearch() { this.currentPage = 1; },
+ resetSearch() {
+ this.searchForm = { id: '', title: '', field: '', createdStart: null, createdEnd: null, generatedStart: null, generatedEnd: null, reviewedStart: null, reviewedEnd: null, publishedStart: null, publishedEnd: null };
+ this.currentPage = 1;
+ },
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' }[status] || status; },
@@ -471,6 +604,11 @@ const TopicsApp = {
table.toggleRowSelection(row, !allChecked);
}
},
+ handleSortChange({ prop, order }) {
+ this.sortField = prop || 'created_at';
+ this.sortOrder = order || 'ascending';
+ this.currentPage = 1;
+ },
formatDate(dateStr) {
if (!dateStr) return '-';
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
@@ -482,8 +620,11 @@ const TopicsApp = {
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
- const urlFilter = new URLSearchParams(window.location.search).get('filter');
+ const params = new URLSearchParams(window.location.search);
+ const urlFilter = params.get('filter');
if (urlFilter) this.filterStatus = urlFilter;
+ const topicId = params.get('topic_id');
+ if (topicId) { this.searchForm.id = topicId; this.showSearch = true; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchTopics(); this.fetchTodayCount(); })
diff --git a/scripts/collector.py b/scripts/collector.py
index 7be6122..c692243 100644
--- a/scripts/collector.py
+++ b/scripts/collector.py
@@ -23,6 +23,7 @@ import subprocess
# scripts/collector.py 位于
/scripts/,因此向上2级即可
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
+sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
# 配置路径
CONFIG_DIR = PROJECT_ROOT / "config"
@@ -39,6 +40,7 @@ logging.basicConfig(
logging.StreamHandler()
]
)
+logger = logging.getLogger(__name__)
DEFAULT_CHINA_PAINS = {
"循环消费": "以旧换新流程繁琐、二手商品信任缺失、租赁市场不规范",
"低碳出行": "新能源车充电设施不足、城市规划不支持骑行、通勤距离长",
@@ -364,8 +366,11 @@ class SustainabilityCollector:
content = title
search_text = (title + content).lower()
- keywords = source_keywords if source_keywords else _load_rss_keywords()
- if any(keyword.lower() in search_text for keyword in keywords):
+ if source_keywords:
+ keyword_match = any(keyword.lower() in search_text for keyword in source_keywords)
+ else:
+ keyword_match = True
+ if keyword_match:
articles.append({
'title': title,
'url': entry.get('link', ''),
@@ -395,7 +400,7 @@ class SustainabilityCollector:
if not query:
logger.warning(f"web_search源 {source.name} 未配置查询词")
return []
- results = search(query, max_results=8, use_cache=False)
+ results = search(query, max_results=8)
articles = []
for r in results:
articles.append({
@@ -943,7 +948,8 @@ def main():
sys.exit(1)
except Exception as e:
- logger.error(f"收集任务失败: {e}")
+ try: logger.error(f"收集任务失败: {e}")
+ except: pass
print(f"ERROR: {e}")
sys.exit(1)
diff --git a/scripts/compliance_checker.py b/scripts/compliance_checker.py
index a7d8c91..ad501e9 100644
--- a/scripts/compliance_checker.py
+++ b/scripts/compliance_checker.py
@@ -35,6 +35,28 @@ PLATFORM_RULES = {
}
}
+# AI 套话检测模式(一旦出现在正文中,说明写作痕迹明显)
+AI_TELTALES = [
+ "说回到",
+ "一个真实的.*案例很能说明问题",
+ "这就是.*被.*后的样子",
+ "如果你也",
+ "值得注意的是",
+ "首先其次最后",
+ "综上所述",
+ "总的来说",
+ "说到这里",
+ "我们来总结一下",
+ "总而言之",
+ "我们不难发现",
+ "我们可以看出",
+ "从以上分析可以看出",
+ "无可否认",
+ "众所周知",
+ "毋庸置疑",
+ "不知大家有没有发现",
+]
+
_cached_sensitive_words = None
_cached_platform_rules = None
@@ -139,15 +161,22 @@ class ComplianceChecker:
# 6. 最小字数检查
self._check_min_length(text, platform)
- # 7. 结构完整性检查(必须包含关键章节)
- self._check_required_sections(text)
+ # 7. 内容质量检查
+ self._check_ai_telltales(text)
+ self._check_pronoun_consistency(text, platform)
+ self._check_reading_experience(text, platform)
+ self._check_platform_engagement(text, platform)
self._check_inline_images(text)
self._check_timeliness(text)
+ hard_types = ('敏感词', '法律法规', '平台规则', '品牌规范', '资源合规')
+ hard_issues = [i for i in self.issues if i['type'] in hard_types]
return {
- "passed": len(self.issues) == 0,
+ "passed": len(hard_issues) == 0,
"issues": self.issues,
- "score": max(0, 100 - len(self.issues) * 10)
+ "score": max(0, 100 - sum(
+ 10 if i['type'] in hard_types else 5 for i in self.issues
+ ))
}
def _check_sensitive_words(self, text: str):
@@ -317,23 +346,120 @@ class ComplianceChecker:
"suggestion": "扩写内容至最低要求"
})
- def _check_required_sections(self, text: str):
- """检查是否包含必要章节(如引言、核心观点、总结等)"""
- required_headings = [
- "引言", "核心观点", "受众痛点", "总结", "行动指南"
- ]
- missing = []
- for heading in required_headings:
- # 检查 h2 或 h3 中是否出现 heading
- if not re.search(r']*>.*' + re.escape(heading) + r'.*', text, re.IGNORECASE):
- missing.append(heading)
- if missing:
+ def _check_ai_telltales(self, text: str):
+ """检查AI套话——正文中出现这些模式说明AI写作痕迹明显"""
+ plain = re.sub(r'<[^>]+>', '', text)
+ for pattern in AI_TELTALES:
+ if re.search(pattern, plain):
+ self.issues.append({
+ "type": "内容质量",
+ "category": "AI套话",
+ "detail": f"正文出现AI套话模式: 「{pattern}」",
+ "suggestion": "删除或替换为自然表达,不要让读者感觉是AI写的"
+ })
+
+ def _check_pronoun_consistency(self, text: str, platform: str):
+ """检查人称一致性(尤其是微信文章)"""
+ if platform != "wechat":
+ return
+ plain = re.sub(r'<[^>]+>', '', text)
+ has_ni = '你' in plain
+ has_nimen = '你们' in plain
+ has_women = '我们' in plain
+ if has_nimen and has_ni:
self.issues.append({
- "type": "结构完整",
- "category": "章节缺失",
- "detail": f"缺少必要章节:{', '.join(missing)}",
- "suggestion": "补充缺失章节"
+ "type": "内容质量",
+ "category": "人称混用",
+ "detail": "微信文章中同时使用「你」和「你们」,建议统一为「你」",
+ "suggestion": "将所有「你们」替换为「你」,保持与读者的单数对话感"
})
+ if has_women and has_ni:
+ self.issues.append({
+ "type": "内容质量",
+ "category": "人称混用",
+ "detail": "微信文章中同时使用「我们」和「你」,建议统一视角",
+ "suggestion": "将「我们」替换为「你」或「我」,保持与读者对话而非说教"
+ })
+
+ def _check_reading_experience(self, text: str, platform: str = ""):
+ """检查阅读体验(段落长度、配图)"""
+ paragraphs = re.findall(r'(.*?)
', text, re.DOTALL)
+ long_paras = [p for p in paragraphs if len(p) > 300]
+ if len(long_paras) > len(paragraphs) * 0.3:
+ self.issues.append({
+ "type": "内容质量",
+ "category": "段落过长",
+ "detail": f"超过30%的段落长度>300字(共{len(paragraphs)}段,{len(long_paras)}段过长),在手机上阅读体验差",
+ "suggestion": "将长段拆分为2-3个短段,每段不超过150-200字"
+ })
+ # 图片检测(各平台阈值不同)
+ imgs = re.findall(r'
]+>', '', text)
+
+ if platform == "zhihu":
+ # 知乎需要讨论引导(评论区互动是核心算法权重)
+ if not any(kw in plain for kw in ('你觉得', '你怎么看', '欢迎在评论区', '说说你的', '欢迎讨论', '你怎么想')):
+ self.issues.append({
+ "type": "内容质量",
+ "category": "缺少互动引导",
+ "detail": "知乎文章建议在末尾加讨论引导(如「你觉得呢?欢迎在评论区聊聊」),提升互动率",
+ "suggestion": "在文章末尾添加一个问题或讨论话题,引导读者评论"
+ })
+ # 检查是否有数据引用(知乎读者看重可信度)
+ if not any(kw in plain for kw in ('据统计', '调研显示', '数据显示', '报告指出', '根据', '研究表明', '调查显示')):
+ self.issues.append({
+ "type": "内容质量",
+ "category": "缺少数据引用",
+ "detail": "知乎文章建议引用具体数据或报告来支撑观点,增强可信度",
+ "suggestion": "在关键观点处引用权威数据来源"
+ })
+
+ if platform == "xiaohongshu":
+ # 小红书需要收藏引导(收藏率是推荐算法核心指标)
+ if '收藏' not in plain:
+ self.issues.append({
+ "type": "内容质量",
+ "category": "缺少收藏引导",
+ "detail": "小红书笔记建议在末尾加收藏引导(如「觉得有用点个收藏」),提升收藏率",
+ "suggestion": "在末尾添加收藏引导语"
+ })
+ # 小红书段落需非常短
+ paragraphs = re.findall(r'(.*?)
', text, re.DOTALL)
+ long_paras = [p for p in paragraphs if len(p) > 150]
+ if len(long_paras) > len(paragraphs) * 0.2:
+ self.issues.append({
+ "type": "内容质量",
+ "category": "段落过长",
+ "detail": f"小红书建议每段不超过80-100字,当前{len(long_paras)}/{len(paragraphs)}段超过150字",
+ "suggestion": "将长段拆分为1-2句的短段落,每段不超过100字"
+ })
+ # 小红书需要至少一些 emoji
+ if not re.search(r'[\U0001F300-\U0001F9FF\u2600-\u27BF]', plain):
+ self.issues.append({
+ "type": "内容质量",
+ "category": "缺少emoji",
+ "detail": "小红书笔记建议适当使用emoji来增加视觉吸引力",
+ "suggestion": "在标题、章节分隔或重点句前添加相关emoji"
+ })
+
def _check_inline_images(self, html: str):
"""检查图片是否以内联方式嵌入(data:image)"""
# 提取所有 img 标签的 src 属性值
diff --git a/scripts/compliance_optimizer.py b/scripts/compliance_optimizer.py
index ac31bdb..782f4e6 100644
--- a/scripts/compliance_optimizer.py
+++ b/scripts/compliance_optimizer.py
@@ -171,7 +171,7 @@ def polish_with_llm(html: str, platform: str, remaining_issues: Optional[List[Di
if remaining_issues:
issues_desc = "\n".join(
- f"- [{i['type']}] {i.get('category','')}: {i.get('detail','')} (建议: {i.get('suggestion','')})"
+ f"- [{i['type']}] {i.get('category','')}: {i.get('detail','')}"
for i in remaining_issues
)
prompt = get_prompt("compliance_fix", issues_desc=issues_desc, html=html)
@@ -183,7 +183,7 @@ def polish_with_llm(html: str, platform: str, remaining_issues: Optional[List[Di
polished = strip_thinking_html(polished)
if '' in polished:
if len(polished) > len(html) * 0.3 and len(polished) > 100:
- if not any(kw in polished[:100] for kw in ['保留', '建议', '可以', '应该', '推荐']):
+ if not any(kw in polished for kw in ['保留', '建议', '可以', '应该', '推荐', '改为', '替换为']):
tag = "针对性修复" if remaining_issues else "常规润色"
return polished, f"LLM {tag}"
logger.warning(f"LLM 优化输出异常(过短或含建议性文字),保留原文 (len={len(polished)})")
@@ -316,7 +316,7 @@ def main(topic_ids: List[str] = None):
for tid, scores in passed_scores.items():
avg_score = sum(scores) // len(scores)
- update_topic_status(tid, 'ready', compliance_score=avg_score)
+ update_topic_status(tid, 'ready', compliance_score=avg_score, reviewed_at=datetime.datetime.now())
logger.info(f"选题 {tid} 状态 → ready(待发布), 合规分={avg_score}")
report_file = DRAFTS_DIR / TODAY / "optimization_report.json"
diff --git a/scripts/content_cleaner.py b/scripts/content_cleaner.py
index c2bbece..80ecd71 100644
--- a/scripts/content_cleaner.py
+++ b/scripts/content_cleaner.py
@@ -110,6 +110,7 @@ def strip_thinking_html(html: str) -> str:
r'
]*>最后.*?
',
r']*>(总的来说|值得注意的是|换句话说|总而言之|简而言之|一言以蔽之|可以说|不难发现|由此可见|综上所述).*?
',
r']*>(总的来说|值得注意的是|换句话说|总而言之|简而言之|一言以蔽之|可以说|不难发现|由此可见|综上所述).*?
',
+ r']*>(开头钩子|核心观点|受众痛点|独特视角|差异化切入|内容形式).*?
',
]
for pat in patterns:
html = re.sub(pat, '', html, flags=re.IGNORECASE)
@@ -176,11 +177,16 @@ def clean_markdown_content(text: str) -> str:
return '\n'.join(cleaned).strip()
def clean_html_content(html: str) -> str:
- """清洗 HTML 输出:去 markdown 代码围栏头尾 + 去 AI 思考注释"""
+ """清洗 HTML 输出:去 markdown 代码围栏 + AI 思考 + 图片占位 + 结构标签"""
html = re.sub(r'^```+\w*\s*\n?', '', html)
html = html.strip()
html = re.sub(r'\n?```+\s*$', '', html)
html = strip_thinking_html(html)
+ # 移除 LLM 插入的建议配图占位()
+ html = re.sub(r'\s*\s*
', '', html, flags=re.DOTALL)
+ # 移除可能变成可见文本的建议配图文字
+ html = re.sub(r'', '', html, flags=re.DOTALL)
+ html = re.sub(r'建议配图:.*?(?=<|$)', '', html)
return html
def clean_full_pipeline(text: str, output_format: str = 'markdown') -> str:
diff --git a/scripts/db_helper.py b/scripts/db_helper.py
index 3e3513b..9d15c57 100644
--- a/scripts/db_helper.py
+++ b/scripts/db_helper.py
@@ -71,7 +71,7 @@ def get_next_topic(priority: Optional[str] = None, db: Optional[Session] = None)
if close_db:
db.close()
-def update_topic_status(topic_id: str, status: str, compliance_score: Optional[int] = None, db: Optional[Session] = None) -> bool:
+def update_topic_status(topic_id: str, status: str, compliance_score: Optional[int] = None, db: Optional[Session] = None, reviewed_at: Optional[datetime] = None) -> bool:
close_db = False
if db is None:
db = SessionLocal()
@@ -84,6 +84,8 @@ def update_topic_status(topic_id: str, status: str, compliance_score: Optional[i
topic.updated_at = datetime.now()
if compliance_score is not None:
topic.compliance_score = compliance_score
+ if reviewed_at is not None:
+ topic.reviewed_at = reviewed_at
if status in ['ready', 'published']:
topic.generated_at = datetime.now()
db.commit()
diff --git a/scripts/image_generator.py b/scripts/image_generator.py
index 26562b8..43cfffe 100644
--- a/scripts/image_generator.py
+++ b/scripts/image_generator.py
@@ -1,547 +1,184 @@
#!/usr/bin/env python3
"""
-文章配图自动生成器
-基于PIL,根据文章标题、内容自动生成适合各平台的配图
+多平台文章配图生成器
+为知乎/公众号/小红书生成平台风格的 SVG 配图(base64 内联,无需外部资源)
"""
+import base64, math, textwrap, re
+from typing import List, Optional
-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
+PLATFORM_STYLES = {
+ "zhihu": {
+ "primary": "#0084ff",
+ "primary_rgb": "0, 132, 255",
+ "accent": "#e8f4fd",
+ "gradient_start": "#e8f4fd",
+ "gradient_end": "#f5f9ff",
+ "card_bg": "#ffffff",
+ "title_color": "#1a1a1a",
+ "dim_color": "#c0c4cc",
+ },
+ "wechat": {
+ "primary": "#07c160",
+ "primary_rgb": "7, 193, 96",
+ "accent": "#f0faf4",
+ "gradient_start": "#f0faf4",
+ "gradient_end": "#e8f5ee",
+ "card_bg": "#ffffff",
+ "title_color": "#1a1a1a",
+ "dim_color": "#c0c4cc",
+ },
+ "xiaohongshu": {
+ "primary": "#ff2442",
+ "primary_rgb": "255, 36, 66",
+ "accent": "#fff5f5",
+ "gradient_start": "#fff5f5",
+ "gradient_end": "#fff0f0",
+ "card_bg": "#ffffff",
+ "title_color": "#262626",
+ "dim_color": "#bfbfbf",
+ },
+}
-import yaml
-
-# from PIL import Image, ImageDraw, ImageFont
-# 使用系统PIL,确保虚拟环境正确安装
-import sys
-sys.path.insert(0, '/usr/local/lib64/python3.11/site-packages')
-sys.path.insert(0, '/usr/lib64/python3.11/site-packages')
-from PIL import Image, ImageDraw, ImageFont
-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"
-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:
- """获取合适的中文字体"""
- # 优先使用系统中文字体
- chinese_fonts = ["/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/zh_CN/SimHei.ttf"]
- for font_path in chinese_fonts + self.font_paths:
- if font_path and os.path.exists(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 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
+FONT = "-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif"
-def save_article_images(topic_id: str, platform: str, images: Dict[str, str]):
- """将图片路径写入 articles 表的 images 字段"""
- db = SessionLocal()
+def _wrap_chinese(text: str, chars_per_line: int = 14) -> List[str]:
+ """将中文文本按字数折行,尽量在标点处断开"""
+ if not text:
+ return [""]
+ lines = []
+ remainder = text
+ while len(remainder) > chars_per_line:
+ chunk = remainder[:chars_per_line]
+ # 尝试在最后一个标点处断开
+ cut = max(chunk.rfind(c) + 1 for c in (",", "、", "。", "!", "?", ":", ";", ")", " ", "—") if c in chunk[:-1])
+ if cut <= 0:
+ cut = chars_per_line
+ lines.append(remainder[:cut].strip())
+ remainder = remainder[cut:].strip()
+ if remainder:
+ lines.append(remainder)
+ return lines
+
+
+def _to_base64(svg: str) -> str:
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()
+ import cairosvg
+ png = cairosvg.svg2png(bytestring=svg.encode('utf-8'))
+ return 'data:image/png;base64,' + base64.b64encode(png).decode('ascii')
except Exception:
- db.rollback()
- raise
- finally:
- db.close()
+ return 'data:image/svg+xml;base64,' + base64.b64encode(svg.encode('utf-8')).decode('ascii')
-def main():
- import argparse
- parser = argparse.ArgumentParser(description='文章配图生成器')
- parser.add_argument('--topic-id', help='选题ID,指定则为选题生成配图')
- args = parser.parse_args()
+def _alt_attr(text: str) -> str:
+ return text.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''')
- 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()
-
- # 测试生成图片
- 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()
\ No newline at end of file
+def _img_tag(src: str, alt: str, width: int = 1080) -> str:
+ return f'
\n'
+
+
+def generate_lead(platform: str, title: str, field: str = "", brand: str = "宇之然") -> str:
+ """生成文章头图(1080×600)"""
+ s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
+ lines = _wrap_chinese(title, 16)
+
+ title_lines = ""
+ y_start = 160
+ for i, line in enumerate(lines[:3]):
+ title_lines += f'{_alt_attr(line)}\n'
+
+ field_badge = ""
+ if field:
+ field_badge = f'''
+
+ {_alt_attr(field)}'''
+
+ svg = f''''''
+ return _img_tag(_to_base64(svg), title)
+
+
+def generate_section_card(platform: str, section_title: str, section_num: int) -> str:
+ """生成章节分隔图(800×160)"""
+ s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
+ num_text = f"{section_num:02d}"
+
+ svg = f''''''
+ return _img_tag(_to_base64(svg), section_title, 800)
+
+
+def generate_quote_card(platform: str, quote: str, source: str = "") -> str:
+ """生成金句卡片(800×280)"""
+ s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
+ lines = _wrap_chinese(quote, 20)
+
+ quote_lines = ""
+ y_start = 110
+ for i, line in enumerate(lines[:4]):
+ quote_lines += f'{_alt_attr(line)}\n'
+
+ source_line = ""
+ if source:
+ source_line = f'{_alt_attr(f"— {source}")}'
+
+ svg = f''''''
+ return _img_tag(_to_base64(svg), f"金句:{quote[:30]}", 800)
+
+
+def generate_data_highlight(platform: str, number: str, label: str) -> str:
+ """生成数据高亮卡片(800×220)"""
+ s = PLATFORM_STYLES.get(platform, PLATFORM_STYLES["zhihu"])
+
+ svg = f''''''
+ return _img_tag(_to_base64(svg), label, 800)
+
+
+def insert_lead_image(html: str, platform: str, title: str, field: str) -> str:
+ """在 HTML 正文开头插入头图(仅必要环节)"""
+ lead = generate_lead(platform, title, field)
+ h1_end = html.find('')
+ if h1_end != -1:
+ html = html[:h1_end + 5] + '\n' + lead + html[h1_end + 5:]
+ else:
+ html = lead + html
+ return html
+
+
+# 平台特定的配图密度
+PLATFORM_IMAGE_COUNTS = {
+ "zhihu": {"lead": True, "sections": True, "quotes": 0, "data": 0, "density": "medium"},
+ "wechat": {"lead": True, "sections": True, "quotes": 0, "data": 0, "density": "medium"},
+ "xiaohongshu": {"lead": True, "sections": True, "quotes": 0, "data": 0, "density": "high"},
+}
diff --git a/scripts/mcp_search_server.py b/scripts/mcp_search_server.py
new file mode 100644
index 0000000..024ed91
--- /dev/null
+++ b/scripts/mcp_search_server.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env python3
+"""
+MCP Search Server — provides web search via opencode infrastructure.
+
+Two search methods (automatic fallback):
+ 1. npx opencode run (rate-limited but returns real web results)
+ 2. opencode-go API + model training data (no rate limit, less fresh)
+
+Usage:
+ python3 scripts/mcp_search_server.py # MCP server (stdio)
+ python3 scripts/mcp_search_server.py --query Q # one-shot search
+ python3 scripts/mcp_search_server.py --url U # one-shot webfetch
+"""
+import json, os, subprocess, sys, time
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+CACHE_FILE = PROJECT_ROOT / "automation" / "data" / "mcp_search_cache.json"
+SESSION_FILE = PROJECT_ROOT / "automation" / "data" / "mcp_session.txt"
+CACHE_TTL = 3600
+SESSION_TITLE = "opencode搜索"
+
+API_BASE = "https://opencode.ai/zen/go/v1"
+API_KEY = os.environ.get("OPENCODE_API_KEY", "")
+if not API_KEY:
+ try:
+ from dotenv import load_dotenv
+ env_path = PROJECT_ROOT / "platform" / "backend" / ".env"
+ load_dotenv(env_path)
+ API_KEY = os.environ.get("OPENCODE_API_KEY", "")
+ except Exception:
+ pass
+
+
+# ── session (reuse same session for all MCP searches) ─────────────
+def _load_session() -> Optional[str]:
+ if SESSION_FILE.exists():
+ try:
+ return SESSION_FILE.read_text().strip() or None
+ except Exception:
+ pass
+ return None
+
+def _save_session_from_output(stdout: str):
+ for line in stdout.strip().split("\n"):
+ try:
+ ev = json.loads(line)
+ sid = ev.get("sessionID") or ev.get("part", {}).get("sessionID")
+ if sid:
+ SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
+ SESSION_FILE.write_text(sid)
+ return
+ except Exception:
+ pass
+
+
+# ── cache ─────────────────────────────────────────────────────────
+def _check_cache(query: str) -> Optional[List[Dict]]:
+ if not CACHE_FILE.exists():
+ return None
+ try:
+ data = json.loads(CACHE_FILE.read_text())
+ entry = data.get(query)
+ if entry and time.time() - entry.get("ts", 0) < CACHE_TTL:
+ return entry.get("results")
+ except Exception:
+ pass
+ return None
+
+def _write_cache(query: str, results: List[Dict]):
+ CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
+ data = {}
+ if CACHE_FILE.exists():
+ try:
+ data = json.loads(CACHE_FILE.read_text())
+ except Exception:
+ pass
+ data[query] = {"ts": time.time(), "results": results}
+ keys = sorted(data.keys(), key=lambda k: data[k].get("ts", 0), reverse=True)[:200]
+ CACHE_FILE.write_text(json.dumps({k: data[k] for k in keys}, ensure_ascii=False))
+
+
+# ── method 1: npx opencode run ────────────────────────────────────
+def _search_via_opencode_cli(query: str, max_results: int) -> Optional[List[Dict]]:
+ """Use npx opencode run to execute websearch tool (short timeout)."""
+ sid = _load_session()
+ args = ["npx", "opencode", "run", f"websearch {query}", "--format", "json", "--title", SESSION_TITLE]
+ if sid:
+ args.extend(["--session", sid, "--continue"])
+ try:
+ r = subprocess.run(
+ args, capture_output=True, text=True, timeout=15,
+ env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"}
+ )
+ except subprocess.TimeoutExpired:
+ return None
+ except Exception:
+ return None
+ if r.returncode != 0:
+ return None
+ # Save session ID for reuse
+ _save_session_from_output(r.stdout)
+ for line in r.stdout.strip().split("\n"):
+ try:
+ ev = json.loads(line)
+ if ev.get("type") == "tool_use":
+ part = ev.get("part", {})
+ state = part.get("state", {})
+ if part.get("tool") == "websearch" and state.get("status") == "completed":
+ data = json.loads(state["output"])
+ results = []
+ for item in (data.get("results") or [])[:max_results]:
+ url = (item.get("url") or "").strip()
+ title = (item.get("title") or "").strip()
+ excerpts = item.get("excerpts") or []
+ content = (excerpts[0] if excerpts else "")[:500]
+ if url and title:
+ results.append({"title": title, "url": url, "content": content, "source": "opencode_cli"})
+ return results
+ except Exception:
+ pass
+ return None
+
+
+# ── method 2: opencode-go API + training data ─────────────────────
+def _search_via_api(query: str, max_results: int) -> Optional[List[Dict]]:
+ """Use opencode-go API to answer query from training data (no rate limit)."""
+ if not API_KEY:
+ return None
+ import requests
+ try:
+ resp = requests.post(
+ f"{API_BASE}/chat/completions",
+ headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
+ json={
+ "model": "deepseek-v4-flash",
+ "messages": [{"role": "user", "content": (
+ f"你现在是一个网络搜索工具。用户查询: {query[:100]}\n\n"
+ f"请根据你的训练数据,提供{max_results}条最相关的网页结果,包含标题、URL和摘要。"
+ f"以JSON格式输出: [{{\"title\":\"...\",\"url\":\"...\",\"content\":\"...\"}}]"
+ f"仅输出JSON数组,不要其他文字。如果URL不确定,用合理占位。"
+ )}],
+ "temperature": 0.3,
+ "max_tokens": 2000,
+ },
+ timeout=30
+ )
+ data = resp.json()
+ content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
+ # Extract JSON array
+ import re as _re
+ m = _re.search(r'\[.*?\]', content, _re.DOTALL)
+ if m:
+ items = json.loads(m.group())
+ if isinstance(items, list):
+ for item in items:
+ item["source"] = "opencode_api"
+ return items[:max_results]
+ except Exception:
+ pass
+ return None
+
+
+# ── search ─────────────────────────────────────────────────────────
+def web_search(query: str, max_results: int = 8) -> List[Dict]:
+ max_results = min(max_results, 10)
+ cached = _check_cache(query)
+ if cached:
+ return cached[:max_results]
+
+ results = _search_via_opencode_cli(query, max_results)
+ if results:
+ _write_cache(query, results)
+ return results
+
+ results = _search_via_api(query, max_results)
+ if results:
+ _write_cache(query, results)
+ return results
+
+ return []
+
+
+def webfetch(url: str) -> Optional[str]:
+ sid = _load_session()
+ args = ["npx", "opencode", "run", f"webfetch {url}", "--format", "json", "--title", SESSION_TITLE]
+ if sid:
+ args.extend(["--session", sid, "--continue"])
+ try:
+ r = subprocess.run(
+ args, capture_output=True, text=True, timeout=60,
+ env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"}
+ )
+ if r.returncode == 0:
+ _save_session_from_output(r.stdout)
+ for line in r.stdout.strip().split("\n"):
+ try:
+ ev = json.loads(line)
+ if ev.get("type") == "tool_use":
+ p = ev.get("part", {})
+ s = p.get("state", {})
+ if p.get("tool") == "webfetch" and s.get("status") == "completed":
+ return s.get("output", "")[:10000]
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return None
+
+
+# ── MCP protocol (JSON-RPC 2.0 over stdio) ────────────────────────
+def _read_msg() -> Optional[Dict]:
+ line = sys.stdin.readline()
+ if not line:
+ return None
+ try:
+ return json.loads(line)
+ except json.JSONDecodeError:
+ return None
+
+def _send_msg(msg: Dict):
+ sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n")
+ sys.stdout.flush()
+
+def _send_error(req_id: Any, code: int, message: str):
+ _send_msg({"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}})
+
+def _send_result(req_id: Any, result: Any):
+ _send_msg({"jsonrpc": "2.0", "id": req_id, "result": result})
+
+
+def serve():
+ sys.stdin.reconfigure(encoding="utf-8")
+ sys.stdout.reconfigure(encoding="utf-8")
+ while True:
+ msg = _read_msg()
+ if msg is None:
+ break
+ req_id = msg.get("id")
+ method = msg.get("method", "")
+ params = msg.get("params", {})
+ if method == "initialize":
+ _send_result(req_id, {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {"tools": {"listChanged": False}},
+ "serverInfo": {"name": "opencode-search-mcp", "version": "1.0.0"}
+ })
+ elif method == "notifications/initialized":
+ pass
+ elif method == "tools/list":
+ _send_result(req_id, {"tools": [
+ {"name": "web_search", "description": "Search the web. Returns up to 10 results with title, url, content.", "inputSchema": {
+ "type": "object", "properties": {
+ "query": {"type": "string", "description": "Search query"},
+ "max_results": {"type": "number", "description": "Max results (1-10)", "default": 8}
+ }, "required": ["query"]
+ }},
+ {"name": "webfetch", "description": "Fetch and extract content from a URL.", "inputSchema": {
+ "type": "object", "properties": {"url": {"type": "string", "description": "URL to fetch"}},
+ "required": ["url"]
+ }}
+ ]})
+ elif method == "tools/call":
+ name = params.get("name", "")
+ args = params.get("arguments", {})
+ try:
+ if name == "web_search":
+ results = web_search(args.get("query", ""), int(args.get("max_results", 8)))
+ _send_result(req_id, {"content": [{"type": "text", "text": json.dumps(results, ensure_ascii=False)}]})
+ elif name == "webfetch":
+ content = webfetch(args.get("url", ""))
+ _send_result(req_id, {"content": [{"type": "text", "text": content or "Failed to fetch URL"}]})
+ else:
+ _send_error(req_id, -32601, f"Unknown tool: {name}")
+ except Exception as e:
+ _send_error(req_id, -32603, str(e))
+ elif method == "shutdown":
+ _send_result(req_id, {})
+ break
+ else:
+ _send_error(req_id, -32601, f"Unknown method: {method}")
+
+
+def main():
+ if "--query" in sys.argv:
+ idx = sys.argv.index("--query")
+ q = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else ""
+ print(json.dumps(web_search(q), ensure_ascii=False, indent=2))
+ return
+ if "--url" in sys.argv:
+ idx = sys.argv.index("--url")
+ u = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else ""
+ print(webfetch(u) or "Failed")
+ return
+ serve()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/opencode_search.py b/scripts/opencode_search.py
index 8969708..aa43c86 100644
--- a/scripts/opencode_search.py
+++ b/scripts/opencode_search.py
@@ -23,13 +23,57 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(level
logger = logging.getLogger(__name__)
SEARCH_CACHE_FILE = PROJECT_ROOT / "automation" / "data" / "search_cache.json"
+SESSION_FILE = PROJECT_ROOT / "automation" / "data" / "opencode_session.txt"
+
+
+def _get_or_create_session() -> Optional[str]:
+ """获取或创建持久 session ID"""
+ if SESSION_FILE.exists():
+ try:
+ sid = SESSION_FILE.read_text().strip()
+ if sid:
+ result = subprocess.run(
+ ["npx", "opencode", "run", "ping", "--session", sid, "--format", "json"],
+ capture_output=True, text=True, timeout=10,
+ cwd=str(PROJECT_ROOT),
+ env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"}
+ )
+ if result.returncode == 0:
+ return sid
+ except Exception:
+ pass
+ result = subprocess.run(
+ ["npx", "opencode", "run", "init", "--format", "json"],
+ capture_output=True, text=True, timeout=30,
+ cwd=str(PROJECT_ROOT),
+ env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"}
+ )
+ for line in result.stdout.strip().split("\n"):
+ try:
+ event = json.loads(line)
+ sid = event.get("sessionID") or event.get("part", {}).get("sessionID")
+ if sid:
+ SESSION_FILE.write_text(sid)
+ return sid
+ except Exception:
+ pass
+ return None
+
+
+_session_id = None
def _run_opencode(prompt: str, timeout: int = 60) -> Optional[str]:
"""调用 opencode run 执行任务,返回文本输出"""
+ global _session_id
+ if _session_id is None:
+ _session_id = _get_or_create_session()
+ args = ["npx", "opencode", "run", prompt, "--format", "json"]
+ if _session_id:
+ args.extend(["--session", _session_id, "--continue"])
try:
result = subprocess.run(
- ["npx", "opencode", "run", prompt, "--format", "json"],
+ args,
capture_output=True, text=True, timeout=timeout,
cwd=str(PROJECT_ROOT),
env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"}
@@ -66,28 +110,23 @@ def _run_opencode(prompt: str, timeout: int = 60) -> Optional[str]:
def search_via_opencode(query: str, max_results: int = 5) -> List[Dict]:
- """通过 opencode 联网搜索"""
- prompt = f"""用 webfetch 搜索:{query}
-只输出 JSON 数组 [{{"title":"标题","url":"链接","content":"摘要"}}],最多 {max_results} 条,不要其他文字。"""
-
- output = _run_opencode(prompt, timeout=90)
- if not output:
- return []
-
- m = re.search(r'\[\s*\{.*\}\s*\]', output, re.DOTALL)
- if not m:
- logger.warning(f"未找到JSON数组: {output[:150]}")
- return []
+ """通过 MCP 搜索工具联网搜索(替代脆弱的 npx prompt 方式)"""
try:
- results = json.loads(m.group())
- if isinstance(results, list):
- for r in results:
- r["source"] = "opencode_webfetch"
- logger.info(f"opencode 搜索 '{query[:20]}': {len(results)} 条")
- return results[:max_results]
+ from search_utils import search
+ return search(query, max_results)
except Exception as e:
- logger.warning(f"JSON解析失败: {e}")
- return []
+ logger.warning("search_utils 不可用,回退子进程: %s", e)
+ result = subprocess.run(
+ [sys.executable, str(PROJECT_ROOT / "scripts" / "mcp_search_server.py"),
+ "--query", query],
+ capture_output=True, text=True, timeout=90,
+ )
+ if result.returncode == 0:
+ try:
+ return json.loads(result.stdout)[:max_results]
+ except Exception:
+ pass
+ return []
def refresh_cache():
diff --git a/scripts/prompt_loader.py b/scripts/prompt_loader.py
index c36f786..2608041 100644
--- a/scripts/prompt_loader.py
+++ b/scripts/prompt_loader.py
@@ -1,3 +1,20 @@
+#!/usr/bin/env python3
+"""
+Prompt 配置加载器
+
+架构:DB 为主,代码仅作初始种子。
+- _PROMPT_DEFAULTS: 仅用于首次自动写入 DB(种子数据),不作为运行时 fallback
+- prompt_configs 表: 运行时唯一来源,修改 prompt 直接改 DB
+- DB 不可用时: 退化到代码默认值(仅用于紧急回退,不是日常模式)
+
+新增 prompt 流程:
+ 1. 在 _PROMPT_DEFAULTS 添加定义
+ 2. 重启后自动补入 DB(仅当该 key 不在 DB 中时)
+ 3. 后续修改直接在 DB 操作,不再改代码
+
+修改 prompt 流程:
+ 直接 UPDATE prompt_configs SET content = '...' WHERE key = '...';
+"""
import os, sys
from pathlib import Path
from typing import Dict, Any, Optional
@@ -6,6 +23,11 @@ PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
+# ============================================================
+# 种子数据(初始默认值)
+# 修改 prompt 请直接在 DB 操作,不要改这里
+# 仅在表里没有该 key 时用于首次填充
+# ============================================================
_PROMPT_DEFAULTS = {
"topics_trends": {
"content": "你是中文互联网趋势分析师。请列出今天({date})中文互联网上最值得创作的10个话题。\n\n要求:\n1. 覆盖领域:{domains}\n2. 从真实用户角度出发\n3. 每个话题需包含:\n - \"domain\": 领域\n - \"topic\": 话题名称\n - \"reason\": 为什么现在讨论这个(1句话,有具体事件/数据支撑)\n - \"hot_keywords\": 3-5个搜索词(含1-2个长尾词)\n - \"platform\": 最适合分发的平台(知乎/小红书/微信/多平台)\n - \"seo_angle\": 从什么角度切入能获得搜索流量(1句话)\n - \"engagement\": 高/中/低\n\n输出 JSON 数组。只输出 JSON,不要其他文字。",
@@ -23,22 +45,22 @@ _PROMPT_DEFAULTS = {
"variables": ["gaps"],
},
"section_expansion": {
- "content": "你是一个资深作者,正在写一篇关于「{topic_title}」的文章。请写「{section_title}」这一节。\n\n今天日期:{date}。\n\n笔记要点:\n{content}\n\n【输出要求】\n输出3-6段纯粹、流畅的段落文字,每节内容根据平台需求控制在200-800字之间。\n\n格式:\n- 禁止任何标题/列表/格式标记(#、-、*、1.、**等)\n- 每段3-5句,段间空行分隔\n- 用「你」或「我们」视角,自然口语化\n\n内容要求(让文章在各平台能被推荐):\n- 开头直接切入痛点或反常识观点,抓住注意力\n- 每个观点配具体案例或数据(用「据统计」「调研显示」等),不要空泛说理\n- 有独特判断和立场,避免正确废话\n- 回答「所以呢」——读者看完能带走什么\n- 结尾有情绪感召力,让人想点赞/收藏/转发\n\n直接输出段落正文,不要任何附加说明。",
+ "content": "你是一个资深作者,正在写一篇关于「{topic_title}」的文章。请写「{section_title}」这一节。\n\n今天日期:{date}。\n\n笔记要点:\n{content}\n\n【输出要求】\n输出3-6段纯粹、流畅的段落文字,每节内容根据平台需求控制在200-800字之间。\n\n格式:\n- 禁止任何标题/列表/格式标记(#、-、*、1.、**等)\n- 每段3-5句,段间空行分隔\n- 用「你」或「我」视角,自然口语化。全文统一使用「你」称呼读者,不要混用「你们」\n- **禁止以「引言」「核心观点」「受众痛点」「总结」「开头钩子」「独特视角」这类结构标签开头**——直接从场景或痛点切入\n\n内容要求(让文章在各平台能被推荐):\n- 开头直接切入场景或痛点,一句话抓住注意力,不要铺垫\n- 每个观点配具体案例或数据(用「据统计」「调研显示」等),不要空泛说理\n- 有独特判断和立场,避免正确废话\n- 回答「所以呢」——读者看完能带走什么\n- **避免AI套话**:不要出现「一个真实的XX案例很能说明问题」「说回到XX这件事」「这就是XX被XX后的样子」「如果你也XX」「值得注意的是」「首先其次最后」「综上所述」\n- 结尾有情绪感召力,让人想点赞/收藏/转发\n- 引用数据或案例时,在行内用(来源:报告/案例名称)标注\n\n直接输出段落正文,不要任何附加说明。\n\n【注意:如果你是写最后一节,在正文写完后加一行 --- 分隔,然后写 **参考资料**,每行一个来源:- 来源名称(简要说明)。前面几节不要加这个。】",
"temperature": 0.75, "max_tokens": 3000,
"variables": ["topic_title", "section_title", "date", "content"],
},
"title_optimize_zhihu": {
- "content": "你是一个知乎内容专家。为以下文章起3个高点击率标题。\n\n标题:{title}\n核心观点:{core}\n受众痛点:{pain}\n领域:{field}\n\n要求:\n- 信息密度高,SEO关键词靠前\n- 偏好数字、对比、悬念、痛点类标题\n- 20字以内\n- 不要「如何...」开头\n- 有独特视角和差异化\n- 能引发讨论\n\n输出3个选项,格式:\n1. 标题A\n2. 标题B\n3. 标题C\n只输出标题,不要其他文字。",
+ "content": "你是一个知乎高赞标题专家。为以下文章起3个高收藏率标题。\n\n标题:{title}\n核心观点:{core}\n受众痛点:{pain}\n领域:{field}\n\n要求:\n- 信息密度高,包含搜索关键词\n- 优先使用数字、对比、悬念、痛点\n- 20字以内\n- 不要「如何...」开头\n- 有独特判断和立场,能引发讨论或反对\n- 制造「不点开就亏了」的紧迫感\n\n输出3个选项,格式:\n1. 标题A\n2. 标题B\n3. 标题C\n只输出标题,不要其他文字。",
"temperature": 0.8, "max_tokens": 1500,
"variables": ["title", "core", "pain", "field"],
},
"title_optimize_wechat": {
- "content": "你是一个公众号资深作者。为以下文章起3个10万+潜力标题。\n\n标题:{title}\n核心观点:{core}\n\n要求:\n- 制造好奇心和话题感\n- 包含微信SEO关键词\n- 口语化,避免感叹号堆砌\n- 15-25字\n- 有情感共鸣或争议性\n\n输出3个选项,格式:\n1. 标题A\n2. 标题B\n3. 标题C\n只输出标题,不要其他文字。",
+ "content": "你是一个10万+公众号爆款标题专家。为以下文章起3个高打开率标题。\n\n文章主题:{title}\n核心观点:{core}\n\n要求:\n- 包含身份标签(如「打工人」「30岁后」「职场人」「普通上班族」)\n- 包含情绪钩子(焦虑/反常识/后悔/稀缺)\n- 包含微信SEO关键词(用户会搜的词)\n- 15-25字\n- 口语化,避免感叹号堆砌\n- 忌笼统——越具体越好\n\n格式模板参考:\n- 「身份+痛点+方案」:打工人学了一堆AI工具,为什么还在加班?\n- 「反常识+数据」:用了AI效率反而更低了?73%的上班族掉进了这个坑\n- 「结果+身份」:每天省出2小时后,我才发现自己以前有多傻\n\n输出3个选项,格式:\n1. 标题A\n2. 标题B\n3. 标题C\n只输出标题,不要其他文字。",
"temperature": 0.8, "max_tokens": 1500,
"variables": ["title", "core"],
},
"title_optimize_xhs": {
- "content": "你是一个小红书爆款专家。为以下文章起3个热门标题。\n\n标题:{title}\n核心观点:{core}\n\n要求:\n- 20字以内\n- 爆款模式:数字+结果 / 痛点+方案 / 反常识\n- 包含小红书SEO关键词\n- 1个精确emoji\n- 有场景感、结果感、满足感\n- 不要「必看/收藏/码住」\n\n输出3个选项,格式:\n1. 标题A\n2. 标题B\n3. 标题C\n只输出标题,不要其他文字。",
+ "content": "你是一个小红书爆款笔记专家。为以下文章起3个高赞标题。\n\n标题:{title}\n核心观点:{core}\n\n要求:\n- 18字以内\n- 爆款公式:身份/场景+数字+结果,或痛点+方案+反差\n- 包含小红书SEO关键词\n- 1个精确emoji(不要用🔥💥❌❓这几个滥用的)\n- 有场景感、结果感、获得感\n- 忌笼统:不要「必看/收藏/码住/绝了」\n\n输出3个选项,格式:\n1. 标题A\n2. 标题B\n3. 标题C\n只输出标题,不要其他文字。",
"temperature": 0.8, "max_tokens": 1500,
"variables": ["title", "core"],
},
@@ -53,7 +75,7 @@ _PROMPT_DEFAULTS = {
"variables": ["date", "year", "title", "field", "core", "pain", "angle", "cases_summary"],
},
"compliance_fix": {
- "content": "你是一个专业的内容合规优化助手。以下文章存在合规问题,请逐一修复并输出完整HTML。\n\n需修复的问题:\n{issues_desc}\n\n原文:\n{html}\n\n要求:\n- 只修复上述问题,不改变文章结构和核心内容\n- 保持, ,
等标签结构不变\n- 修复后内容依然保持可读性和自然语感(不要因为合规变成生硬的表达)\n- 替换敏感词时选择意思相近的替代词,不删节重要信息",
+ "content": "你是一个专业的内容合规与质量优化助手。以下文章存在需要优化的地方,请逐一修复并输出完整HTML。\n\n需修复的问题:\n{issues_desc}\n\n原文:\n{html}\n\n要求:\n- 只修复上述问题,不改变文章结构和核心内容\n- 保持
, ,
等标签结构不变\n- 修复后内容依然保持可读性和自然语感\n- 替换敏感词时选择意思相近的替代词,不删节重要信息\n- 如果问题是AI套话,直接删除或改写那些词句\n- 如果问题是人称混用,统一为「你」\n- 如果问题是缺少配图,在关键位置插入 `
` 空段落占位,配图由后续流程统一处理",
"temperature": 0.3, "max_tokens": 8000,
"variables": ["issues_desc", "html"],
},
@@ -68,7 +90,7 @@ _PROMPT_DEFAULTS = {
"variables": ["n", "cat_names", "n2", "src_summary", "year"],
},
"tags_generation": {
- "content": "为以下文章生成{platform}标签(3-5个)。\n\n标题:{title}\n领域:{field}\n核心观点:{core}\n\n要求:每个2-4字。直接输出标签,空格分隔。不要输出思考过程。",
+ "content": "为以下文章生成{platform}标签(5-8个)。\n\n标题:{title}\n领域:{field}\n核心观点:{core}\n\n要求:\n- 每个标签2-5字\n- 包含1-2个搜索流量词(用户在{platform}会搜的词)\n- 包含1-2个热门话题词\n- 标签之间要有层次:大领域→小话题→具体场景\n- 不要重复意思相近的标签\n\n直接输出标签,空格分隔。不要输出思考过程。",
"temperature": 0.3, "max_tokens": 500,
"variables": ["platform", "title", "field", "core"],
},
@@ -76,12 +98,45 @@ _PROMPT_DEFAULTS = {
_DB_CACHE: Dict[str, Dict[str, Any]] = {}
_CACHE_LOADED = False
+_DB_AVAILABLE = False # True if DB was successfully loaded at least once
+
+
+def _seed_missing_prompts():
+ """将代码默认值中不存在的 prompt 自动补入 DB"""
+ try:
+ if os.getenv('USE_POSTGRES', 'true') == 'true':
+ from app.database import SessionLocal
+ from app.models import PromptConfig
+ db = SessionLocal()
+ try:
+ existing_keys = {p.key for p in db.query(PromptConfig).all()}
+ for key, cfg in _PROMPT_DEFAULTS.items():
+ if key not in existing_keys:
+ pc = PromptConfig(
+ key=key,
+ content=cfg['content'],
+ temperature=cfg.get('temperature'),
+ max_tokens=cfg.get('max_tokens'),
+ enabled=True,
+ module_id='default',
+ variables=cfg.get('variables', []),
+ )
+ db.add(pc)
+ print(f"[prompt_loader] auto-seeded prompt: {key}")
+ if any(key not in existing_keys for key in _PROMPT_DEFAULTS):
+ db.commit()
+ finally:
+ db.close()
+ except Exception:
+ pass
def _ensure_db_loaded():
- global _DB_CACHE, _CACHE_LOADED
+ global _DB_CACHE, _CACHE_LOADED, _DB_AVAILABLE
if _CACHE_LOADED:
return
+ # 自动补缺:将代码默认值中不存在的 prompt 写入 DB
+ _seed_missing_prompts()
try:
if os.getenv('USE_POSTGRES', 'true') == 'true':
from app.database import SessionLocal
@@ -94,6 +149,7 @@ def _ensure_db_loaded():
"temperature": p.temperature,
"max_tokens": p.max_tokens,
}
+ _DB_AVAILABLE = True
finally:
db.close()
except Exception:
@@ -102,16 +158,30 @@ def _ensure_db_loaded():
def get_prompt(key: str, **kwargs) -> str:
+ """获取 prompt 内容
+
+ 优先级:DB → 代码默认值(仅 DB 不可用时)→ 空字符串
+ """
_ensure_db_loaded()
+ cleaned = []
+ for k, v in kwargs.items():
+ cleaned.append((k, str(v)))
+ kwargs = dict(cleaned)
+
if key in _DB_CACHE:
content = _DB_CACHE[key]["content"]
- elif key in _PROMPT_DEFAULTS:
+ for k, v in kwargs.items():
+ content = content.replace("{" + k + "}", str(v))
+ return content
+
+ # 仅 DB 不可用时回退代码默认值
+ if not _DB_AVAILABLE and key in _PROMPT_DEFAULTS:
content = _PROMPT_DEFAULTS[key]["content"]
- else:
- return ""
- for k, v in kwargs.items():
- content = content.replace("{" + k + "}", str(v))
- return content
+ for k, v in kwargs.items():
+ content = content.replace("{" + k + "}", str(v))
+ return content
+
+ return ""
def get_prompt_params(key: str) -> Dict[str, Any]:
@@ -121,7 +191,7 @@ def get_prompt_params(key: str) -> Dict[str, Any]:
"temperature": _DB_CACHE[key].get("temperature"),
"max_tokens": _DB_CACHE[key].get("max_tokens"),
}
- if key in _PROMPT_DEFAULTS:
+ if not _DB_AVAILABLE and key in _PROMPT_DEFAULTS:
return {
"temperature": _PROMPT_DEFAULTS[key].get("temperature"),
"max_tokens": _PROMPT_DEFAULTS[key].get("max_tokens"),
@@ -129,8 +199,47 @@ def get_prompt_params(key: str) -> Dict[str, Any]:
return {}
+def seed_prompts(force: bool = False):
+ """手动触发种子同步(用于 CLI 或 API)
+
+ force=True: 用代码默认值覆盖 DB
+ force=False: 仅补充 DB 中不存在的 key
+ """
+ try:
+ from app.database import SessionLocal
+ from app.models import PromptConfig
+ db = SessionLocal()
+ try:
+ existing = {p.key: p for p in db.query(PromptConfig).all()}
+ for key, cfg in _PROMPT_DEFAULTS.items():
+ if force or key not in existing:
+ if key in existing:
+ p = existing[key]
+ p.content = cfg['content']
+ p.temperature = cfg.get('temperature')
+ p.max_tokens = cfg.get('max_tokens')
+ else:
+ p = PromptConfig(
+ key=key,
+ content=cfg['content'],
+ temperature=cfg.get('temperature'),
+ max_tokens=cfg.get('max_tokens'),
+ enabled=True,
+ module_id='default',
+ variables=cfg.get('variables', []),
+ )
+ db.add(p)
+ db.commit()
+ finally:
+ db.close()
+ return {"ok": True, "action": "force" if force else "seed"}
+ except Exception as e:
+ return {"ok": False, "error": str(e)}
+
+
def reload_prompts():
- global _CACHE_LOADED, _DB_CACHE
+ global _CACHE_LOADED, _DB_CACHE, _DB_AVAILABLE
_CACHE_LOADED = False
_DB_CACHE = {}
- _ensure_db_loaded()
\ No newline at end of file
+ _DB_AVAILABLE = False
+ _ensure_db_loaded()
diff --git a/scripts/search_utils.py b/scripts/search_utils.py
new file mode 100644
index 0000000..9ee9435
--- /dev/null
+++ b/scripts/search_utils.py
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+"""
+统一搜索工具:DB驱动多提供商自动降级
+"""
+import json, logging, os, sys, time
+from pathlib import Path
+from typing import List, Dict, Optional
+
+logger = logging.getLogger(__name__)
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+sys.path.insert(0, str(PROJECT_ROOT / "platform" / "backend"))
+
+try:
+ from app.database import SessionLocal
+ from app.models import SearchProvider
+ HAVE_DB = True
+except ImportError:
+ HAVE_DB = False
+
+SEARCH_CACHE_FILE = PROJECT_ROOT / "automation" / "data" / "search_cache.json"
+
+
+def _get_providers() -> List[Dict]:
+ """从 DB 加载启用的搜索提供商,按优先级排序(自动跨日重置用量)"""
+ if not HAVE_DB:
+ return []
+ try:
+ import datetime as _dt
+ db = SessionLocal()
+ today = _dt.date.today()
+
+ rows = db.query(SearchProvider).filter(
+ SearchProvider.enabled == True
+ ).order_by(SearchProvider.priority).all()
+
+ needs_commit = False
+ for r in rows:
+ if (r.usage_today or 0) > 0 and r.last_used_at:
+ last_date = r.last_used_at
+ if hasattr(last_date, 'date'):
+ last_date = last_date.date()
+ elif isinstance(last_date, _dt.datetime):
+ last_date = last_date.date()
+ if last_date < today:
+ r.usage_today = 0
+ needs_commit = True
+ if needs_commit:
+ db.commit()
+
+ db.close()
+ return [r.to_dict() if hasattr(r, 'to_dict') else {
+ "id": r.id, "name": r.name, "provider_type": r.provider_type,
+ "api_key": r.api_key or "", "api_url": r.api_url or "",
+ "priority": r.priority, "daily_limit": r.daily_limit,
+ "usage_today": getattr(r, 'usage_today', 0),
+ } for r in rows]
+ except Exception as e:
+ logger.warning("加载搜索提供商失败: %s", e)
+ return []
+
+
+def reset_all_usage():
+ """手动重置所有提供商当日用量(供 API/定时任务调用)"""
+ if not HAVE_DB:
+ return
+ try:
+ db = SessionLocal()
+ db.query(SearchProvider).update({SearchProvider.usage_today: 0})
+ db.commit()
+ db.close()
+ logger.info("所有搜索提供商用量已重置")
+ except Exception as e:
+ logger.warning("重置用量失败: %s", e)
+
+
+def _increment_usage(provider_id: int):
+ """增加提供商当日用量"""
+ if not HAVE_DB:
+ return
+ try:
+ db = SessionLocal()
+ p = db.query(SearchProvider).filter(SearchProvider.id == provider_id).first()
+ if p:
+ p.usage_today = (p.usage_today or 0) + 1
+ p.last_used_at = __import__('datetime').datetime.now(__import__('datetime').timezone.utc)
+ db.commit()
+ db.close()
+ except Exception:
+ pass
+
+
+def _call_baidu(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
+ import requests
+ resp = requests.post(
+ api_url or "https://qianfan.baidubce.com/v2/ai_search/web_search",
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+ json={
+ "messages": [{"role": "user", "content": query}],
+ "search_source": "baidu_search_v2",
+ "resource_type_filter": [{"type": "web", "top_k": max_results}],
+ },
+ timeout=15
+ )
+ if resp.status_code != 200:
+ logger.warning("百度搜索返回 %s: %s", resp.status_code, resp.text[:100])
+ return []
+ data = resp.json()
+ results = data.get("results", []) or data.get("webPages", {}).get("value", [])
+ return [{
+ "title": r.get("title", "")[:120],
+ "url": r.get("url", "") or r.get("link", ""),
+ "content": r.get("snippet", "") or r.get("content", "") or r.get("summary", "")[:300],
+ "source": "baidu",
+ } for r in results[:max_results]]
+
+
+def _call_qiniu(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
+ import requests
+ resp = requests.post(
+ api_url or "https://api.qnaigc.com/v1/search/web",
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+ json={"query": query, "max_results": max_results, "search_type": "web"},
+ timeout=15
+ )
+ if resp.status_code != 200:
+ logger.warning("七牛搜索返回 %s: %s", resp.status_code, resp.text[:100])
+ return []
+ data = resp.json()
+ items = data.get("results", data.get("data", []))
+ return [{
+ "title": r.get("title", "")[:120],
+ "url": r.get("url", ""),
+ "content": r.get("content", "") or r.get("snippet", "")[:300],
+ "source": "qiniu",
+ } for r in items[:max_results]]
+
+
+def _call_tinyfish(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
+ import requests
+ resp = requests.get(
+ api_url or "https://api.search.tinyfish.ai",
+ params={"query": query, "location": "CN", "language": "zh"},
+ headers={"X-API-Key": api_key},
+ timeout=15
+ )
+ if resp.status_code != 200:
+ logger.warning("TinyFish搜索返回 %s: %s", resp.status_code, resp.text[:100])
+ return []
+ data = resp.json()
+ items = data.get("results", [])
+ return [{
+ "title": r.get("title", "")[:120],
+ "url": r.get("url", ""),
+ "content": r.get("snippet", "")[:300],
+ "source": "tinyfish",
+ } for r in items[:max_results]]
+
+
+def _call_bing(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
+ import requests
+ resp = requests.get(
+ api_url or "https://api.bing.microsoft.com/v7.0/search",
+ params={"q": query, "count": max_results, "mkt": "zh-CN"},
+ headers={"Ocp-Apim-Subscription-Key": api_key},
+ timeout=15
+ )
+ if resp.status_code != 200:
+ logger.warning("Bing搜索返回 %s: %s", resp.status_code, resp.text[:100])
+ return []
+ data = resp.json()
+ items = data.get("webPages", {}).get("value", [])
+ return [{
+ "title": r.get("name", "")[:120],
+ "url": r.get("url", ""),
+ "content": r.get("snippet", "")[:300],
+ "source": "bing",
+ } for r in items[:max_results]]
+
+
+def _call_mcp(api_key: str, api_url: str, query: str, max_results: int) -> List[Dict]:
+ """Call the MCP search server directly (no API key needed)."""
+ import subprocess
+ try:
+ r = subprocess.run(
+ [sys.executable, str(PROJECT_ROOT / "scripts" / "mcp_search_server.py"),
+ "--query", query],
+ capture_output=True, text=True, timeout=90,
+ )
+ if r.returncode != 0:
+ logger.warning("MCP搜索子进程返回非零: %s", r.stderr[:100])
+ return []
+ results = json.loads(r.stdout)
+ if isinstance(results, list):
+ for res in results:
+ res["source"] = "opencode"
+ return results[:max_results]
+ except json.JSONDecodeError as e:
+ logger.warning("MCP搜索JSON解析失败: %s", e)
+ except subprocess.TimeoutExpired:
+ logger.warning("MCP搜索超时 (90s)")
+ except Exception as e:
+ logger.warning("MCP搜索失败: %s", e)
+ return []
+
+
+_PROVIDER_CALLS = {
+ "baidu": _call_baidu,
+ "qiniu": _call_qiniu,
+ "tinyfish": _call_tinyfish,
+ "bing": _call_bing,
+ "mcp": _call_mcp,
+}
+
+
+def search(query: str, max_results: int = 5) -> List[Dict]:
+ """统一搜索:DB提供商 → 本地缓存 → 空结果"""
+ providers = _get_providers()
+ for p in providers:
+ if (p.get("usage_today") or 0) >= (p.get("daily_limit") or 99999):
+ logger.info("提供商 %s 已达日限 %s,跳过", p.get("name"), p.get("daily_limit"))
+ continue
+ if not p.get("api_key") and p.get("provider_type") != "mcp":
+ logger.info("提供商 %s 未配置 API Key,跳过", p.get("name"))
+ continue
+ call_fn = _PROVIDER_CALLS.get(p.get("provider_type"))
+ if not call_fn:
+ continue
+ try:
+ results = call_fn(p["api_key"], p.get("api_url", ""), query, max_results)
+ if results:
+ _increment_usage(p["id"])
+ logger.info("搜索 '%s' 通过 %s 获得 %d 条结果", query[:20], p.get("name"), len(results))
+ return results
+ logger.warning("提供商 %s 返回空结果", p.get("name"))
+ except Exception as e:
+ logger.warning("提供商 %s 失败: %s", p.get("name"), e)
+ continue
+
+ logger.info("搜索 '%s' 无结果(所有提供商均不可用)", query[:20])
+ return []
+
+
+def search_from_cache(query: str, max_results: int = 5) -> List[Dict]:
+ """从本地缓存读取搜索结果"""
+ if not SEARCH_CACHE_FILE.exists():
+ return []
+ try:
+ cache = json.loads(SEARCH_CACHE_FILE.read_text(encoding="utf-8"))
+ meta = cache.get("_metadata", {})
+ updated = meta.get("updated_at", "")
+ if updated:
+ import datetime
+ age = (datetime.datetime.now() - datetime.datetime.fromisoformat(updated)).total_seconds()
+ if age > 129600:
+ logger.warning("搜索缓存过时(%dh),跳过", int(age // 3600))
+ return []
+ results = cache.get(query, [])
+ return results[:max_results]
+ except Exception:
+ return []
+
+
+def enrich_topic_research(topic: dict, max_results: int = 5) -> str:
+ """对选题进行网络搜索,返回格式化的研究发现文本"""
+ title = topic.get('title', '')
+ field = topic.get('field', '')
+ queries = [title]
+ if field and field not in title:
+ queries.append(f"{field} {title[:40]}")
+ seen_urls = set()
+ results = []
+ for q in queries:
+ for r in search(q, max_results):
+ url = r.get('url', '')
+ if url and url not in seen_urls:
+ seen_urls.add(url)
+ results.append(r)
+ if not results:
+ return ""
+ lines = ["\n## 网络搜索参考", ""]
+ for r in results[:max_results]:
+ snippet = r.get('snippet', r.get('content', ''))
+ lines.append(f"- **{r.get('title', '无标题')}**")
+ lines.append(f" {snippet[:200]}")
+ if r.get('url'):
+ lines.append(f" [{r['url']}]")
+ lines.append("")
+ return "\n".join(lines)
diff --git a/scripts/web_search.py b/scripts/web_search.py
index 42f71d9..1ed8df1 100644
--- a/scripts/web_search.py
+++ b/scripts/web_search.py
@@ -127,7 +127,15 @@ def save_to_cache(query: str, results: List[Dict]):
def search(query: str, max_results: int = 5) -> List[Dict]:
- """统一搜索接口:缓存 → API → 网页抓取"""
+ """统一搜索接口:DB提供商 → 缓存 → API → 网页抓取"""
+ try:
+ from search_utils import search as db_search
+ results = db_search(query, max_results)
+ if results:
+ return results
+ except Exception:
+ pass
+
results = search_from_cache(query, max_results)
if results:
return results
diff --git a/scripts/writer.py b/scripts/writer.py
index 0d26703..e68aedf 100644
--- a/scripts/writer.py
+++ b/scripts/writer.py
@@ -14,6 +14,7 @@ sys.path.insert(0, str(PROJECT_ROOT / 'platform' / 'backend'))
from db_helper import get_topic_by_id, update_topic_status, save_article
from content_cleaner import strip_thinking, strip_ai_preface, clean_markdown_content, clean_html_content
from prompt_loader import get_prompt, get_prompt_params
+from image_generator import insert_lead_image
try:
from app.core.nvidia_client import call_llm
HAVE_LLM = True
@@ -206,6 +207,15 @@ class Writer:
expanded = self._expand_section(sec)
parts.append(expanded + "\n")
full_md = "\n".join(parts).strip()
+ # 收集所有引用来源,统一添加到文末
+ refs = set()
+ for m in re.finditer(r'(来源:([^)]+))', full_md):
+ refs.add(m.group(1).strip())
+ if refs:
+ # 去掉已有参考资料区,重新生成统一的
+ full_md = re.sub(r'\n---\n\*\*参考资料\*\*[\s\S]*$', '', full_md).strip()
+ ref_lines = "\n".join(f"- {r}" for r in sorted(refs))
+ full_md += f"\n\n---\n\n**参考资料**\n{ref_lines}"
return full_md
def _adapt_for_platform(self, markdown: str, platform: str) -> str:
@@ -213,27 +223,71 @@ class Writer:
max_c = cfg['max_chars']
lines = markdown.split('\n')
+ if platform == "zhihu":
+ result = []
+ in_list = False
+ for line in lines:
+ stripped = line.strip()
+ # 数据类行 → 引用格式(知乎文章中引用数据能增强可信度)
+ if any(stripped.startswith(p) for p in ('据统计', '调研显示', '数据显示', '报告指出', '根据', '数据显示')):
+ line = f"> {line}"
+ # 列表保持原样(知乎支持 markdown 列表)
+ if stripped.startswith('- ') or stripped.startswith('* '):
+ if not in_list:
+ result.append('')
+ in_list = True
+ else:
+ in_list = False
+ result.append(line)
+ adapted = '\n'.join(result)
+ # 末尾加讨论引导(知乎算法权重:互动率)
+ if not any(kw in adapted for kw in ('你觉得', '你怎么看', '欢迎在评论区', '说说你的')):
+ adapted += "\n\n---\n\n你觉得这个观点有道理吗?你在工作中有没有类似的经验?欢迎在评论区聊聊。"
+ return adapted
+
if platform == "xiaohongshu":
result = []
char_count = 0
+ last_was_heading = False
for line in lines:
if char_count >= max_c:
break
+ stripped = line.strip()
if line.startswith('## '):
+ if not last_was_heading and result:
+ result.append('')
+ char_count += 1
line = f"## ✨ {line[3:]}"
+ last_was_heading = True
elif line.startswith('### '):
+ if not last_was_heading and result:
+ result.append('')
+ char_count += 1
line = f"### 💡 {line[4:]}"
+ last_was_heading = True
+ else:
+ last_was_heading = False
+ # 超长段落后拆行 + 每段前加点缀
+ if stripped and len(stripped) > 60:
+ sentences = [s.strip() for s in stripped.replace('。', '。\n').split('\n') if s.strip()]
+ for s in sentences:
+ if s and char_count < max_c:
+ result.append(s)
+ char_count += len(s)
+ continue
result.append(line)
char_count += len(line)
adapted = '\n'.join(result)
- if adapted.count('#') == 0:
- adapted = f"# {self.topic['title']}\n\n{adapted}"
+ # 结尾加收藏引导(小红书算法权重:收藏率)
+ if '收藏' not in adapted:
+ adapted += "\n\n✨ 觉得有用的话点个收藏吧,下次需要的时候随时翻出来看~"
return adapted
if platform == "wechat":
result = []
for line in lines:
- line = line.replace('我', '你')
+ # 人称统一:我们→我,你们→你
+ line = line.replace('我们', '我').replace('你们', '你').replace('我', '你')
if line.startswith('### '):
result.append(f"\n**{line[4:]}**\n")
elif line.startswith('## '):
@@ -338,12 +392,20 @@ class Writer:
if not line:
continue
line = re.sub(r'^\d+[.、)\s]+', '', line)
- line = line.strip('*#- \t')
+ line = line.strip('*#- \t"\'"''"')
+ # 跳过思考/建议类输出(如"不如:"、"或者:"、"建议方案"等)
+ if re.match(r'^(不如|或者|建议|推荐|参考|方案[一二三]|第[一二三]种|以[下是]|标题[一二三]|选项)', line):
+ continue
if line:
titles.append(line)
if titles:
- logger.info(f"标题优化 [{platform}]: {titles[0][:50]}...")
- return titles[0]
+ best = titles[0][:80]
+ # 如果优化后标题与原文毫无关联或过短,回退原题
+ if len(best) < 4 or (len(set(best) & set(original)) < 2 and len(original) > 4):
+ logger.warning(f"标题优化结果异常「{best}」,回退原文")
+ return original
+ logger.info(f"标题优化 [{platform}]: {best}")
+ return best
except Exception as e:
logger.warning(f"标题优化失败: {e}")
return original
@@ -361,43 +423,18 @@ class Writer:
html = template.replace("{{TITLE}}", title).replace("{{DATE}}", TODAY).replace("{{GEN_TIME}}", GEN_TIME)
html_content = _md_parser(adapted)
- # WeChat: insert topic-relevant image at start of body
- if platform == "wechat":
- import base64
- topic_title = self.topic.get('title', title)
- topic_field = self.topic.get('field', '')
- safe_title = topic_title.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''')
- safe_field = topic_field.replace('&', '&').replace('<', '<').replace('>', '>')
- lines = []
- chars_per_line = 24
- for i in range(0, len(safe_title), chars_per_line):
- lines.append(safe_title[i:i+chars_per_line])
- if not lines:
- lines = ['配图']
- line_y = 220 - (len(lines) - 1) * 20
- title_texts = ''.join(f'{l}' for i, l in enumerate(lines))
- field_text = f'{safe_field}' if safe_field else ''
- img_svg = f''''''
- img_b64 = 'data:image/svg+xml;base64,' + base64.b64encode(img_svg.encode('utf-8')).decode('ascii')
- img_tag = f'
\n'
- h1_end = html_content.find('')
- if h1_end != -1:
- html_content = html_content[:h1_end + 5] + '\n' + img_tag + html_content[h1_end + 5:]
- else:
- html_content = img_tag + html_content
+ # 仅插入头图(每个平台一篇一张,不过度)
+ html_content = insert_lead_image(
+ html_content, platform,
+ title=self.topic.get('title', title),
+ field=self.topic.get('field', ''),
+ )
html = html.replace("", html_content)
# 防御:清理可能在 LLM 输出中混入的 markdown 代码围栏和文件头
html = re.sub(r'^```+\w*\s*\n?', '', html)
+ html = re.sub(r'\n?```+\s*$', '', html)
html = html.strip()
tags_html = self._get_platform_tags(platform)