
Content Factory
- 12 installs
- 82 repo stars
- Updated August 2, 2026
- aaaaqwq/agi-super-skills
content-factory is a Claude Code skill that runs an automated content pipeline from hot-topic aggregation through LLM scoring, multi-platform content generation, review, and auto-publishing.
About
content-factory is a Claude Code skill implementing an automated content production and distribution pipeline. It aggregates hot topics from 10+ platforms (Bilibili, GitHub Trending, Reddit, YouTube, Weibo, Zhihu, and more), scores topics with an LLM to surface a Top 10, generates platform-specific content for Xiaohongshu, WeChat, and Twitter, reviews drafts, and auto-publishes. It bundles Python scripts for each pipeline stage and uses OpenAI-compatible LLM APIs (DeepSeek for scoring, GLM for generation). The docs are primarily in Chinese.
- Aggregates hot topics from 10+ platforms (Bilibili, GitHub, Reddit, YouTube, Weibo, Zhihu)
- LLM-scores topics into a Top 10, then generates multi-platform content
- Bundles Python scripts for aggregation, scoring, generation, review, and publishing
Content Factory by the numbers
- 12 all-time installs (skills.sh)
- Ranked #1,455 of 2,719 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
content-factory capabilities & compatibility
Requires an OpenAI-compatible LLM API key (default DeepSeek + GLM) and optionally Telegram/Playwright for review and publishing.
- Capabilities
- content generation · topic aggregation · copywriting
- Works with
- github · linkedin
- Use cases
- copywriting · marketing · web scraping
- Pricing
- Bring your own API key
What content-factory says it does
从热点采集到内容生成到多平台发布的全流程自动化。
热点采集(10+平台) → AI选题评分 → 推送Top10给用户
npx skills add https://github.com/aaaaqwq/agi-super-skills --skill content-factoryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 12 |
|---|---|
| repo stars | ★ 82 |
| Last updated | August 2, 2026 |
| Repository | aaaaqwq/agi-super-skills ↗ |
What it does
A developer or creator uses this skill when they want an end-to-end pipeline that aggregates trending topics and generates and publishes content across platforms.
Who is it for?
Running a daily topic-to-publish content pipeline across multiple platforms.
Skip if: GEO/AI-search ranking or pure SEO keyword work, which the docs route to other skills.
When should I use this skill?
The user wants to aggregate hot topics, generate content, or run the content-factory pipeline.
What you get
A ranked Top 10 topic list plus generated, reviewed, and published platform content.
By the numbers
- aggregates from 10+ platforms
- scores topics into a Top 10
- 3 platform templates (Xiaohongshu, WeChat, Twitter)
Files
Content Factory — 内容自动生产分发工厂
- Author: Daniel Li
- Copyright © Daniel Li. All rights reserved.
从热点采集到内容生成到多平台发布的全流程自动化。Daniel 每天只需 2 分钟挑选主题。
核心流程
热点采集(10+平台) → AI选题评分 → 推送Top10给用户
↓
用户选择主题(或自定义)
↓
多平台内容生成(LLM)
↓
草稿审核 → 确认发布
↓
自动发布 → 数据追踪使用场景
✅ USE when:
- "今日热点有什么" / "采集今天的热门话题"
- "帮我生成内容" / "写篇小红书文章"
- "选题评分" / "推荐今天该写什么"
- "发布到小红书/微信/Twitter"
- "运行内容工厂流水线" / "跑一遍完整流程"
- "看看今天的草稿" / "审核内容"
❌ DON'T use when:
- GEO优化(AI搜索排名)→ 用 geo-agent
- 纯SEO关键词优化 → 用 SEO 技能
- 单次写作(无流水线需求)→ 用 content-creator
数据目录
data/
├── hotpool/ # 每日热点池 (YYYY-MM-DD.json)
├── topics/ # 评分选题 (YYYY-MM-DD.json)
├── drafts/ # 生成草稿 (YYYY-MM-DD/)
├── reviewed/ # 审核通过
├── published/ # 已发布记录
├── config/ # 运行配置
│ └── sources.json # 采集源配置
├── templates/ # 平台模板
│ ├── xiaohongshu.md
│ ├── wechat.md
│ └── twitter.md
└── assets/ # 图片等素材脚本说明
| 脚本 | 功能 | 依赖 |
|---|---|---|
scripts/aggregator/fetch_all.py | 10+平台热点采集 | curl, python3 |
scripts/topic_scorer.py | AI选题评分(Top10) | LLM API (DeepSeek/GLM) |
scripts/content_generator.py | 多平台内容生成 | LLM API |
scripts/draft_reviewer.py | 草稿审核推送 | Telegram API |
scripts/auto_publisher.py | 自动发布 | playwright (optional) |
scripts/topic_presenter.py | 选题卡片推送 | Telegram API |
scripts/run_daily.sh | 全流程串联 | bash |
scripts/paths.py | 路径配置(可移植) | - |
采集源 (10+)
| 平台 | 方式 | 内容类型 |
|---|---|---|
| B站热榜 | API | 视频/动态 |
| GitHub Trending | API | 开源项目 |
| API | 讨论/新闻 | |
| YouTube | API | 视频 |
| 微博热搜 | API | 社交热点 |
| 知乎热榜 | API | 深度讨论 |
| 头条 | API | 新闻资讯 |
| 抖音 | API | 短视频 |
| Twitter/X | Syndication | KOL动态 |
| LinuxDo | API | 技术社区 |
安装依赖
cd ~/clawd/skills/content-factory
pip install -r requirements.txt完整流水线
# 手动执行全流程
bash scripts/run_portable.sh
# 或分步执行
python3 scripts/aggregator/fetch_all.py # Step 1: 热点采集
python3 scripts/topic_scorer.py # Step 2: AI评分
python3 scripts/topic_presenter.py # Step 3: 推送选题
python3 scripts/content_generator.py --top 3 # Step 4: 内容生成
python3 scripts/draft_reviewer.py --all # Step 5: 草稿审核
# python3 scripts/auto_publisher.py # Step 6: 发布(需确认)与 Ralph CEO Loop 配合
内容工厂可以通过 Ralph CEO Loop 进行持续迭代:
- 小data: 热点采集 + 数据清洗
- 小research: 话题深度调研
- 小content: 内容生成 + 文案优化
- 小pm: 流程协调 + 质量验收
- 小market: 发布策略 + 渠道优化
配置
采集源配置 (data/config/sources.json)
可启用/禁用各平台,配置关注的账号、频道等。
LLM 配置
评分和生成使用 OpenAI-compatible API:
- 默认: DeepSeek (评分) + GLM-5 (生成)
- 通过环境变量
LLM_API_KEY/LLM_BASE_URL覆盖
X/Twitter 内容模板 v2.0
适用:280字符内单条推文 / Thread(5-10条) | 目标:高互动、高转发
---
模板用途
用于生成 X/Twitter 风格的推文内容,强调 Hook + Insight + CTA 结构。
变量插值
{title}- 推文主题{summary}- 核心要点{source}- 内容来源{angles}- 内容角度
---
一、单条推文模板(280字符内)
Hook + Insight + CTA 结构
[HOOK - 抓眼球]
{惊人数据/反常识观点/个人故事}
[INSIGHT - 价值输出]
{核心洞察/具体方法}
[CTA - 行动召唤]
{提问/引导} 👇
#hashtag1 #hashtag25种Hook公式
1. 数字冲击型
I {做了什么}. Here's what I found:
{数字} {things} that {benefit}.2. 时间对比型
{时间A}: {状态A}
{时间B}: {状态B}
The difference? {关键因素}.3. 秘密揭秘型
Nobody talks about {反常识点}.
Here's the truth:4. 警示型
Stop {错误行为}.
Do this instead:5. 承诺型
I {做了什么}.
Here's {你将获得}:Hashtag建议
- 数量:1-3个(不超过3个)
- 类型:1个大标签 + 1-2个精准标签
- 位置:推文末尾
常用Hashtag
| 领域 | 推荐 |
|---|---|
| AI/科技 | #AI #Tech #AItools |
| 效率 | #Productivity #Growth |
| 创业 | #Startup #BuildInPublic |
| 编程 | #Coding #Dev #Python |
---
二、单条推文示例
示例1:数字冲击型
I tested 50+ AI tools.
Here are the 5 that actually worth your time:
1. ChatGPT - Content
2. Notion AI - Notes
3. Gamma - Presentations
4. Otter.ai - Meetings
5. Canva AI - Design
Time saved: 20+ hours/week
Which one would you try? 👇
#AItools #Productivity示例2:时间对比型
6 months ago:
- Working 60 hrs/week
- Zero life
- Constant burnout
Today:
- Same output
- 30 hrs/week
- Time for family
The difference? AI automation.
What's your biggest time drain? 🧵
#Productivity示例3:秘密揭秘型
Nobody talks about this:
90% of "productivity tips" don't work.
Here's what actually does:
1. Time blocking
2. Deep work sessions
3. Saying "no" more
Simple. But not easy.
What's your real productivity hack? 👇
#Growth---
三、Thread模板(5-10条)
结构公式
Tweet 1: Hook + Promise (钩子+承诺)
Tweet 2-3: Context (背景/问题)
Tweet 4-8: Content (核心内容)
Tweet 9: Summary (总结)
Tweet 10: CTA (行动召唤)Thread模板
[TWEET 1 - Hook]
I {做了什么}.
Here's {你将获得}:
A thread 🧵👇
[TWEET 2 - Context]
First, why this matters:
{痛点/背景}
{数据支撑}
[TWEET 3-N - Content]
{序号}. {名称}
Best for: {用途}
Pro tip: {技巧}
[TWEET N+1 - Summary]
Quick recap:
{列表总结}
[TWEET N+2 - CTA]
That's it!
If helpful:
1. Retweet the first tweet ♻️
2. Follow for more @{username}
{提问} 👇---
四、Thread完整示例
[TWEET 1]
I spent 100+ hours testing AI tools.
Here are the 7 that are actually worth your time:
A thread 🧵👇
[TWEET 2]
First, why this matters:
The average worker spends 28% of their day on email.
That's 2.6 hours. Every day.
AI can cut that in half.
[TWEET 3]
1. ChatGPT (Free)
Best for: Content, coding, brainstorming
Pro tip: Use custom instructions
Time saved: 5+ hrs/week
[TWEET 4]
2. Notion AI ($10/mo)
Best for: Notes, summaries, drafts
Pro tip: Use "/" commands
Time saved: 3+ hrs/week
[TWEET 5]
3. Gamma (Free tier)
Best for: Presentations
Pro tip: Write outline first
Time saved: 4+ hrs/week
[TWEET 6]
4. Otter.ai (Free tier)
Best for: Meeting transcription
Time saved: 2+ hrs/week
[TWEET 7]
5. Canva AI (Free tier)
Best for: Quick graphics
Time saved: 2+ hrs/week
[TWEET 8]
Quick recap:
1. ChatGPT - Content
2. Notion AI - Notes
3. Gamma - Presentations
4. Otter.ai - Meetings
5. Canva AI - Design
Total: 16+ hours/week saved
[TWEET 9]
That's it!
If helpful:
1. Retweet the first tweet ♻️
2. Follow for more tips @username
What's your favorite AI tool? 👇---
五、避免AI味指南
❌ Don't write like this
"In today's fast-paced digital landscape, leveraging AI tools has become increasingly important for maximizing productivity and achieving optimal results..."✅ Write like this
"I tested 50 AI tools so you don't have to. Here are the 5 that actually work:"去AI味技巧
1. 加个人经历 — "I tested..." "I used to..." 2. 用具体数字 — "100+ hours" "5 tools" "20 hrs/week" 3. 短句 — 每句不超过15词 4. 直接 — 不绕弯子,直奔主题
---
六、发布检查清单
- [ ] 单条推文 ≤ 280字符
- [ ] Hook + Insight + CTA 结构完整
- [ ] 1-3个相关Hashtag
- [ ] 有互动元素(提问)
- [ ] 无AI味(个人经历+具体数字)
---
版本:v2.0 | 更新:2026-03-03
微信公众号内容模板 v2.0
适用:2000-3000字深度长文 | 目标:高阅读、高分享、高关注
---
模板用途
用于生成微信公众号风格的深度长文,标题有吸引力但不标题党。
变量插值
{title}- 文章标题{summary}- 核心摘要(50字内){source}- 内容来源/参考{angles}- 内容角度/切入点
---
一、标题规则(有吸引力但不标题党)
核心原则
1. 真实可信 — 不夸大、不欺骗 2. 有吸引力 — 让读者想点击 3. 包含关键词 — 便于搜索 4. 20-35字 — 最佳长度
5种标题公式
公式1:数字 + 方法 + 效果
{数字}个{方法},帮你{效果}示例:5个时间管理方法,帮你每天多出2小时
公式2:提问 + 承诺
{痛点提问}?这篇文章告诉你答案示例:为什么你总是很忙却没成果?这篇文章告诉你答案
公式3:身份 + 价值
致{人群}:{核心价值}示例:致30岁的你:关于职业发展,这3件事越早知道越好
公式4:故事 + 启发
{故事背景},{关键转折},{最终感悟}示例:从月薪8千到年薪40万,我做对了这3件事
公式5:观点 + 论证
{核心观点},{支撑理由}示例:真正的自律,不是逼自己做不喜欢的事
避免标题党
- ❌ 不用极限词:最、第一、必看、震惊
- ❌ 不用虚假承诺:看完月薪翻倍
- ✅ 用具体数字:3个方法、每天省2小时
- ✅ 用真实案例:我的经验、实测有效
---
二、摘要规则(50字内)
摘要公式
{核心价值} + {读者收获}示例
分享5个实测有效的AI工具,帮你每天节省2小时,提升工作效率。摘要检查
- [ ] 50字以内
- [ ] 说清楚文章价值
- [ ] 吸引读者点击
---
三、正文结构(引言→分析→案例→总结)
标准结构
【引言】200-300字
- 开头钩子:提问/数据/故事/反常识观点
- 背景铺垫:为什么这个话题重要
- 内容预告:读者将获得什么
【分析】1000-1500字
- 分3-4个大章节
- 每章节300-400字
- 每节有小标题
- 有数据/引用支撑
【案例】500-800字
- 真实案例/故事
- 具体做法
- 实际效果
【总结】200-300字
- 核心观点回顾
- 行动建议
- 互动引导---
四、正文模板
{开头钩子}
{背景铺垫}
{内容预告}
## 一、{第一个观点/分析}
{展开说明}
### 1. {小标题}
{详细内容}
**要点**:{关键信息}
### 2. {小标题}
{详细内容}
> "引用内容" —— 来源
## 二、{第二个观点/分析}
{展开说明}
### 1. {小标题}
{详细内容}
**数据支撑**:根据{来源}的数据显示...
## 三、{第三个观点/分析}
{展开说明}
## 四、真实案例
{案例背景}
{具体做法}
**效果**:{量化结果}
## 写在最后
{核心观点回顾}
{行动建议}
如果这篇文章对你有帮助,欢迎点赞、在看、转发。
也欢迎在评论区分享你的看法。
我们下期见。---
五、小标题规范
小标题类型
| 类型 | 示例 | 适用场景 |
|---|---|---|
| 提问式 | 为什么90%的人会失败? | 引发思考 |
| 数字式 | 3个核心方法 | 清晰明了 |
| 结论式 | 效率才是核心竞争力 | 直接输出 |
| 行动式 | 如何3天学会X | 强调实操 |
小标题密度
- 每300-500字一个小标题
- 一篇文章5-10个小标题
- 层级清晰:## 一级 → ### 二级
---
六、避免AI味指南
❌ 不要这样写
"在当今社会,随着科技的快速发展,人工智能正在深刻改变着我们的生活方式..."✅ 要这样写
"上周我用ChatGPT写了3篇文章,每篇只用了15分钟。这在以前,至少需要2小时。"去AI味技巧
1. 加个人经历 — "我之前..." "去年我..." 2. 用具体数字 — "15分钟" 而非 "大大缩短时间" 3. 讲真实故事 — 具体的人、具体的事 4. 口语化表达 — 不用书面语、套话
---
七、完整示例
标题:5个AI工具实测:每天省2小时,打工人必备
摘要:作为一个月薪1万的普通打工人,我用AI工具把每天的工作时间从10小时压缩到8小时。这篇文章分享我亲测好用的5个AI工具。
正文:
你有没有这种感觉:每天忙得团团转,但下班时却不知道自己到底做了什么?
我曾经也是这样。直到半年前,我开始系统性地使用AI工具。
结果呢?同样的工作,我每天能省出2小时。
今天,我想把这5个改变我工作效率的AI工具分享给你。
## 一、为什么你需要AI工具?
很多人担心AI会抢走工作。但现实是:AI不会替代你,会用AI的人会替代你。
### 1. 效率提升的核心逻辑
AI工具的本质是什么?是把重复性、低价值的工作自动化。
根据麦肯锡2024年的报告,65%的企业已经采用生成式AI,平均效率提升40%。
### 2. 我的亲身经历
半年前,我每天工作10小时,周末还要加班。
现在我每天8小时就能完成工作,准时下班成为常态。
## 二、5个AI工具实测
### 1. ChatGPT:全能型助手
**是什么**:OpenAI开发的AI对话工具
**能做什么**:
- 写文案、润色文章
- 翻译、总结
- 编程辅助
**我的用法**:每天早上把当天要写的文案丢给它,10分钟搞定。
### 2. Notion AI:笔记神器
**是什么**:Notion自带的AI写作助手
**能做什么**:
- 总结会议纪要
- 扩写、改写
- 生成大纲
**我的用法**:开完会后,一键生成会议纪要,省下30分钟。
### 3. Gamma:PPT秒生成
**是什么**:AI自动生成PPT的工具
**能做什么**:
- 输入主题,3分钟出完整PPT
- 自动排版、配图
**我的用法**:上周领导临时要PPT,我用Gamma在5分钟内搞定。
### 4. 通义听悟:会议记录神器
**是什么**:阿里AI语音转文字工具
**能做什么**:
- 实时转写会议内容
- 区分发言人
- 自动总结要点
### 5. 剪映:视频剪辑小白友好
**是什么**:抖音出品的视频剪辑APP
**能做什么**:
- AI自动字幕
- 一键成片
## 三、我是怎么用这些工具的?
我的工作流程是这样的:
**早上9:00-9:30**
用ChatGPT写当天需要的文案和邮件。
**上午会议**
用通义听悟录音,会后自动生成纪要。
**下午做PPT**
用Gamma快速生成,手动微调。
**临下班前**
用Notion AI总结今天的工作,准备明天的计划。
## 四、效果如何?
我用了一个月后,统计了一下:
- 每天节省约2小时
- 周末加班从4次/月降到1次/月
- 领导对我的效率很满意
## 写在最后
AI工具不是万能的,但不用AI工具是万万不能的。
工具只是手段,核心还是你的思维和能力。
如果这篇文章对你有帮助,欢迎点赞、在看、转发。
也欢迎在评论区分享你最喜欢的AI工具。
我们下期见。---
八、发布前检查清单
- [ ] 标题20-35字,不标题党
- [ ] 摘要50字内,说清价值
- [ ] 正文2000-3000字
- [ ] 结构完整:引言→分析→案例→总结
- [ ] 每节有小标题
- [ ] 有数据/引用支撑
- [ ] 无AI味(有故事、有数字、有个人经历)
---
版本:v2.0 | 更新:2026-03-03
小红书内容模板 v3.0
适用:300-600字图文笔记 | 目标:高互动、高收藏、高转发
---
模板用途
用于生成小红书风格的图文笔记内容,强调真实感和互动性。
变量插值
{title}- 笔记标题{summary}- 核心摘要{source}- 内容来源/参考{angles}- 内容角度/切入点
---
一、标题公式(必须带emoji+数字)
核心规则(必须遵守)
1. 必须带数字 — "3个方法"、"5个技巧"、"7天见效" 2. 必须带emoji — 放在标题开头或结尾 3. 前13字放核心关键词 — 决定搜索曝光 4. 总长 ≤20 字 — 超过会被截断
5种标题公式
公式1:emoji + 数字 + 价值承诺
{emoji} {数字}个{方法}让你{利益点}!示例:🔥 5个AI工具让你效率翻倍!
公式2:数字 + 痛点 + 解决方案 + emoji
{数字}个{方法},终于不{痛点}了!{emoji}示例:3个时间管理法,终于不熬夜了!😭
公式3:身份 + 数字 + 价值 + emoji
{身份}必看!{数字}个{价值}{emoji}示例:程序员必看!5个代码神器💻
公式4:数字 + 反差 + emoji
{数字}个习惯,{反差前}变{反差后}{emoji}示例:3个习惯,普通人变效率达人⚡
公式5:否定 + 数字 + 新知 + emoji
别再{错误}了!{数字}个{新知}{emoji}示例:别再死记硬背了!3个记忆法绝了🤯
标题检查清单
- [ ] 必须带数字(3/5/7/10等)
- [ ] 必须带emoji
- [ ] 前13字包含核心关键词
- [ ] 总长度 ≤20 字
---
二、正文结构(开头必须提问)
万能正文模板
【开头提问】(必须!引发共鸣)
{目标受众}们!有没有觉得{痛点}?🤯
或者
你们是不是也{痛点}?😭
【痛点共鸣】(1-2句,不超过3行)
我之前也是这样,{具体场景}...
那种{负面情绪}的感觉,懂的都懂。
【转折+分享】
直到我发现了这{数字}个{解决方案},真的绝了!👇
【干货内容】(每条不超过3行)
1️⃣ {要点1}
{一句话说明},{具体效果/数字}
2️⃣ {要点2}
{一句话说明},{具体效果/数字}
3️⃣ {要点3}
{一句话说明},{具体效果/数字}
【总结升华】
用好这{数字}个{方法},{效果承诺}!✨
【互动引导】(必须有!)
你们觉得哪个最实用?评论区告诉我👇
或者
你们有没有更好的方法?来聊聊呗~
【求互动】
觉得有用的话,点个小心心❤️ 收藏起来慢慢看!段落规则
- 每段不超过3行 — 超过就拆分
- 短句为主 — 每句不超过15字
- 口语化 — 像朋友聊天,不要书面语
---
三、Emoji使用规范
使用原则
- 标题:1个(开头或结尾)
- 正文:每段1-2个,全文不超过8个
- 位置:句末或段末
常用Emoji速查
| 场景 | 推荐 |
|---|---|
| 标题开头 | 🔥 💡 ⭐ 🌟 💯 |
| 惊喜/绝了 | 🤯 😭 ✨ 🎉 |
| 引导动作 | 👇 👉 📌 💭 |
| 求互动 | ❤️ ⭐ 💬 👍 |
---
四、标签策略(5-8个)
标签构成
#领域 + #场景 + #人群 + #具体话题示例
#职场 #效率工具 #打工人 #AI工具 #准时下班 #职场干货 #工作效率标签检查
- 领域标签 2个:#职场 #效率
- 人群标签 1个:#打工人
- 具体话题 2-3个:#AI工具 #准时下班
- 热门话题 1-2个:#职场干货
---
五、避免AI味指南
❌ 不要这样写
"在当今快节奏的社会中,效率成为了每个人追求的目标..."✅ 要这样写
"姐妹们!每天加班到11点,真的会崩溃的好吗😭"去AI味技巧
1. 加个人经历 — "我之前也是这样..." 2. 用具体数字 — "每天省2小时" 而非 "大大提升效率" 3. 口语化表达 — "绝了" "太香了" "懂的都懂" 4. 加情绪词 — "崩溃" "想哭" "太爽了"
---
六、完整示例
标题:🔥 5个AI工具让我6点下班!第3个绝了
正文:
姐妹们!你们是不是也每天加班到很晚?🤯
我之前也是,每天11点才下班,回家的路上真的想哭。
直到我发现了这5个AI神器,现在每天6点准时走人!👇
1️⃣ ChatGPT
写文案、润色、翻译样样行,每天省1小时!
2️⃣ Notion AI
会议记录一键总结,再也不用手写了。
3️⃣ Gamma(这个绝了!)
输入主题,3分钟出完整PPT,领导都夸我!
4️⃣ 通义听悟
开会自动录音转文字,摸鱼神器哈哈哈。
5️⃣ 剪映
AI自动加字幕,视频剪辑小白也能行。
用好这5个工具,准时下班不是梦!✨
你们公司让用AI工具吗?评论区聊聊👇
觉得有用的话,点个小心心❤️ 收藏起来!标签:
#职场 #效率工具 #打工人 #AI工具 #准时下班 #职场干货 #ChatGPT---
七、JSON输出格式
{
"title": "🔥 5个AI工具让我6点下班!第3个绝了",
"content": "姐妹们!你们是不是也每天加班到很晚?...",
"tags": ["#职场", "#效率工具", "#打工人", "#AI工具", "#准时下班", "#职场干货", "#ChatGPT"],
"cta": "你们公司让用AI工具吗?评论区聊聊👇",
"cover_text": "5个AI工具 / 准时下班"
}---
八、发布前检查清单
- [ ] 标题带数字+emoji,≤20字
- [ ] 开头是提问,引发共鸣
- [ ] 每段不超过3行
- [ ] 结尾有互动引导
- [ ] 标签5-8个,类型完整
- [ ] 无AI味(口语化、有数字、有情绪)
---
版本:v3.0 | 更新:2026-03-03
#!/bin/bash
# Content Factory Agent - Installation Script
# Usage: bash install.sh
set -e
SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "📰 Installing Content Factory Agent..."
# Create data directories
mkdir -p "$SKILL_DIR/data"/{hotpool,topics,drafts,reviewed,published,config,assets,templates}
# Install Python deps
if [ -f "$SKILL_DIR/requirements.txt" ]; then
echo "📦 Installing Python dependencies..."
pip3 install -r "$SKILL_DIR/requirements.txt" 2>/dev/null || \
python3 -m pip install -r "$SKILL_DIR/requirements.txt" 2>/dev/null || \
echo "⚠️ pip install failed, some features may not work"
fi
# Copy default config if not exists
if [ ! -f "$SKILL_DIR/data/config/sources.json" ]; then
cp "$SKILL_DIR/scripts/aggregator/config.json" "$SKILL_DIR/data/config/sources.json" 2>/dev/null || true
fi
echo "✅ Content Factory installed at: $SKILL_DIR"
echo ""
echo "Quick start:"
echo " 1. Run: bash $SKILL_DIR/scripts/run_daily.sh"
echo " 2. Or step by step:"
echo " python3 $SKILL_DIR/scripts/aggregator/fetch_all.py"
echo " python3 $SKILL_DIR/scripts/topic_scorer.py"
echo " python3 $SKILL_DIR/scripts/content_generator.py --top 3"
httpx>=0.24.0
beautifulsoup4>=4.12.0
#!/usr/bin/env python3
"""
统一信息源热点采集器
从 微博/知乎/头条/抖音/B站/GitHub/YouTube/Twitter/Reddit/LinuxDo 等平台采集热门内容
HTTP请求统一用 subprocess+curl 避免 SSL 问题
"""
import json
import re
import subprocess
import sys
import html as htmlmod
import urllib.parse
from datetime import datetime, timezone, timedelta
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "config.json"
# Portable: output to skill data dir
OUTPUT_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "hotpool"
TZ_CST = timezone(timedelta(hours=8))
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
def load_config():
with open(CONFIG_FILE, "r") as f:
return json.load(f)
def curl_get(url, headers=None, timeout=15):
"""用 subprocess+curl 获取 URL 内容,避免 SSL 问题"""
cmd = ["curl", "-s", "--max-time", str(timeout), "-L",
"-H", f"User-Agent: {UA}"]
if headers:
for k, v in headers.items():
cmd += ["-H", f"{k}: {v}"]
cmd.append(url)
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
if r.returncode != 0:
raise RuntimeError(f"curl failed ({r.returncode}): {r.stderr[:200]}")
return r.stdout
def curl_get_json(url, headers=None, timeout=15):
return json.loads(curl_get(url, headers, timeout))
# Legacy compat
http_get = curl_get
http_get_json = curl_get_json
# ── 60s API 通用解析 ──────────────────────────────
def _parse_60s_v2(source_name, api_path, category="热搜"):
"""通用 60s API v2 解析器 (字段: code/data, 每条: title/link/hot_value)"""
items = []
try:
data = curl_get_json(f"https://60s.viki.moe/v2/{api_path}")
if data.get("code") != 200:
print(f" ⚠️ {source_name} 60s API: code={data.get('code')}", file=sys.stderr)
return items
for entry in data.get("data", []):
title = entry.get("title", entry.get("name", entry.get("word", "")))
url = entry.get("link", entry.get("url", entry.get("mobileUrl", "")))
if not title:
continue
item = {
"source": source_name,
"title": title,
"url": url,
"summary": entry.get("desc", entry.get("excerpt", ""))[:200] if entry.get("desc") or entry.get("excerpt") else "",
"category": category,
"engagement": {},
}
hot = entry.get("hot_value", entry.get("hotValue", entry.get("hot", 0)))
if hot:
item["engagement"]["hot_value"] = hot
items.append(item)
except Exception as e:
print(f" ⚠️ {source_name}: {e}", file=sys.stderr)
return items
# ── 微博热搜 (60s API) ────────────────────────────
def fetch_weibo(config):
"""通过 60s API 获取微博热搜"""
return _parse_60s_v2("weibo", "weibo", "微博热搜")
# ── 知乎热榜 (60s API) ────────────────────────────
def fetch_zhihu(config):
"""通过 60s API 获取知乎热榜"""
return _parse_60s_v2("zhihu", "zhihu", "知乎热榜")
# ── 头条热榜 (60s API) ────────────────────────────
def fetch_toutiao(config):
"""通过 60s API 获取今日头条热榜"""
return _parse_60s_v2("toutiao", "toutiao", "头条热榜")
# ── 抖音热搜 (60s API, 替代原生) ──────────────────
def fetch_douyin(config):
"""通过 60s API 获取抖音热搜"""
return _parse_60s_v2("douyin", "douyin", "抖音热搜")
# ── Twitter/X ──────────────────────────────────────
def fetch_twitter(config):
items = []
accounts = config.get("accounts", [])
for account in accounts:
try:
url = f"https://syndication.twitter.com/srv/timeline-profile/screen-name/{account}"
html = curl_get(url)
texts = re.findall(r'"text":"([^"]{20,500})"', html)
for text in texts[:3]:
clean = htmlmod.unescape(text).replace("\\n", " ").strip()
if clean.startswith("RT @"):
continue
items.append({
"source": "twitter",
"title": clean[:120],
"url": f"https://x.com/{account}",
"summary": clean[:300],
"category": "Social/Tech",
"engagement": {},
"author": account,
})
except Exception as e:
print(f" ⚠️ Twitter @{account}: {e}", file=sys.stderr)
return items
# ── YouTube ────────────────────────────────────────
def fetch_youtube(config):
items = []
channels = config.get("channels", {})
for name, channel_id in channels.items():
try:
url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
xml = curl_get(url)
entries = re.findall(
r"<entry>.*?<title>(.+?)</title>.*?<yt:videoId>(.+?)</yt:videoId>.*?<published>(.+?)</published>.*?</entry>",
xml, re.DOTALL
)
for title, vid, published in entries[:3]:
title = htmlmod.unescape(title)
items.append({
"source": "youtube",
"title": title,
"url": f"https://www.youtube.com/watch?v={vid}",
"summary": f"[{name}] {title}",
"category": "Video/Tech",
"engagement": {},
"author": name,
"published": published,
})
except Exception as e:
print(f" ⚠️ YouTube {name}: {e}", file=sys.stderr)
return items
# ── B站 ───────────────────────────────────────────
def fetch_bilibili(config):
items = []
try:
data = curl_get_json(
"https://api.bilibili.com/x/web-interface/ranking/v2?rid=0&type=all",
headers={"Referer": "https://www.bilibili.com"}
)
if data.get("code") == 0:
for v in data["data"]["list"][:15]:
items.append({
"source": "bilibili",
"title": v["title"],
"url": f"https://www.bilibili.com/video/{v['bvid']}",
"summary": v.get("desc", "")[:200],
"category": v.get("tname", "综合"),
"engagement": {
"views": v.get("stat", {}).get("view", 0),
"likes": v.get("stat", {}).get("like", 0),
"comments": v.get("stat", {}).get("reply", 0),
},
"author": v.get("owner", {}).get("name", ""),
})
except Exception as e:
print(f" ⚠️ Bilibili 原生API: {e}, 回退60s", file=sys.stderr)
items.extend(_parse_60s_v2("bilibili", "bili", "B站热门"))
try:
data = curl_get_json(
"https://api.bilibili.com/x/web-interface/wbi/search/square?limit=10",
headers={"Referer": "https://www.bilibili.com"}
)
if data.get("code") == 0:
trending = data.get("data", {}).get("trending", {})
for t in trending.get("list", [])[:10]:
items.append({
"source": "bilibili",
"title": f"[热搜] {t.get('keyword', t.get('show_name', ''))}",
"url": f"https://search.bilibili.com/all?keyword={urllib.parse.quote(t.get('keyword', ''))}",
"summary": t.get("show_name", ""),
"category": "热搜",
"engagement": {},
})
except Exception as e:
print(f" ⚠️ Bilibili 热搜: {e}", file=sys.stderr)
return items
# ── GitHub Trending ────────────────────────────────
def fetch_github(config):
"""GitHub Trending daily(替代原来的全历史star排名)"""
items = []
try:
html = curl_get("https://github.com/trending?since=daily")
repos = re.findall(r'<h2[^>]*>\s*<a[^>]*href="/([^"]+)"[^>]*>', html)
for repo_path in repos[:15]:
repo_path = repo_path.strip()
if not repo_path or repo_path.count('/') != 1:
continue
desc_match = re.search(
rf'href="/{re.escape(repo_path)}".*?<p[^>]*>(.*?)</p>',
html, re.DOTALL
)
desc = re.sub(r'<[^>]+>', '', desc_match.group(1)).strip()[:200] if desc_match else ""
stars_match = re.search(
rf'{re.escape(repo_path)}.*?(\d[\d,]*)\s*stars today', html, re.DOTALL
)
stars_today = int(stars_match.group(1).replace(',', '')) if stars_match else 0
items.append({
"source": "github",
"title": f"{repo_path} 🔥+{stars_today}⭐/today" if stars_today else repo_path,
"url": f"https://github.com/{repo_path}",
"summary": desc,
"category": "GitHub Trending",
"engagement": {"stars_today": stars_today},
"author": repo_path.split('/')[0],
})
except Exception as e:
print(f" ⚠️ GitHub Trending: {e}, 回退Search API", file=sys.stderr)
lookback = config.get("lookback_days", 1)
min_stars = config.get("min_stars", 100)
date_str = (datetime.now(TZ_CST) - timedelta(days=lookback)).strftime("%Y-%m-%d")
try:
url = f"https://api.github.com/search/repositories?q=stars:>{min_stars}+pushed:>{date_str}&sort=stars&per_page=15"
data = curl_get_json(url)
for r in data.get("items", [])[:15]:
items.append({
"source": "github", "title": f"{r['full_name']} ⭐{r['stargazers_count']}",
"url": r["html_url"], "summary": (r.get("description") or "")[:200],
"category": r.get("language", "Unknown"),
"engagement": {"stars": r["stargazers_count"], "forks": r.get("forks_count", 0)},
"author": r["owner"]["login"],
})
except Exception as e2:
print(f" ⚠️ GitHub Search: {e2}", file=sys.stderr)
return items
# ── Reddit ─────────────────────────────────────────
def fetch_reddit(config):
items = []
subreddits = config.get("subreddits", ["technology"])
for sub in subreddits:
try:
url = f"https://api.pullpush.io/reddit/search/submission/?subreddit={sub}&sort=score&sort_type=desc&size=5"
data = curl_get_json(url)
for p in data.get("data", [])[:5]:
items.append({
"source": "reddit", "title": p.get("title", ""),
"url": f"https://reddit.com{p.get('permalink', '')}",
"summary": (p.get("selftext") or "")[:200],
"category": f"r/{sub}",
"engagement": {"upvotes": p.get("score", 0), "comments": p.get("num_comments", 0)},
"author": p.get("author", ""),
})
except Exception as e:
print(f" ⚠️ Reddit r/{sub}: {e}", file=sys.stderr)
return items
# ── LinuxDo ────────────────────────────────────────
def fetch_linuxdo(config):
items = []
cookie_file = Path.home() / ".playwright-data/linuxdo/cookies.txt"
headers = {"Accept": "application/json", "Referer": "https://linux.do/"}
if cookie_file.exists():
headers["Cookie"] = cookie_file.read_text().strip()
try:
data = curl_get_json("https://linux.do/latest.json?order=default", headers=headers)
topics = data.get("topic_list", {}).get("topics", [])
for t in topics[:15]:
items.append({
"source": "linuxdo", "title": t.get("title", ""),
"url": f"https://linux.do/t/{t.get('slug', '')}/{t.get('id', '')}",
"summary": "", "category": str(t.get("category_id", "")),
"engagement": {"views": t.get("views", 0), "likes": t.get("like_count", 0), "comments": t.get("posts_count", 0)},
})
except Exception as e:
print(f" ⚠️ LinuxDo: {e}", file=sys.stderr)
return items
# ── 小红书 ────────────────────────────────────────
def fetch_xiaohongshu(config):
items = []
cookie_file = Path.home() / ".playwright-data/xiaohongshu/cookies.txt"
if not cookie_file.exists():
print(" ⚠️ 小红书: 需要登录态 cookie", file=sys.stderr)
return items
headers = {"Referer": "https://www.xiaohongshu.com/", "Cookie": cookie_file.read_text().strip()}
try:
html = curl_get("https://www.xiaohongshu.com/explore", headers=headers)
match = re.search(r'window\.__INITIAL_STATE__\s*=\s*({.*?})\s*</script>', html, re.DOTALL)
if match:
raw = match.group(1).replace("undefined", "null")
state = json.loads(raw)
for note in state.get("explore", {}).get("feeds", [])[:15]:
nd = note.get("noteCard", note)
items.append({
"source": "xiaohongshu", "title": nd.get("title", nd.get("displayTitle", "")),
"url": f"https://www.xiaohongshu.com/explore/{note.get('id', '')}",
"summary": nd.get("desc", "")[:200], "category": "小红书",
"engagement": {"likes": nd.get("interactInfo", {}).get("likedCount", 0)},
"author": nd.get("user", {}).get("nickname", ""),
})
except Exception as e:
print(f" ⚠️ 小红书: {e}", file=sys.stderr)
return items
# ── 微信公众号 ────────────────────────────────────
def fetch_wechat_mp(config):
items = []
cookie_file = Path.home() / ".playwright-data/sogou-weixin/cookies.txt"
headers = {"Referer": "https://weixin.sogou.com/"}
if cookie_file.exists():
headers["Cookie"] = cookie_file.read_text().strip()
keywords = config.get("keywords", ["AI", "科技", "互联网"])
for kw in keywords[:3]:
try:
url = f"https://weixin.sogou.com/weixin?type=2&query={urllib.parse.quote(kw)}&ie=utf8"
html = curl_get(url, headers=headers)
articles = re.findall(r'<a[^>]*href="([^"]*)"[^>]*target="_blank"[^>]*>(.*?)</a>', html, re.DOTALL)
for href, title_html in articles[:5]:
title = re.sub(r'<[^>]+>', '', title_html).strip()
if len(title) > 5 and "sogou" not in title.lower():
items.append({
"source": "wechat_mp", "title": title,
"url": href if href.startswith("http") else f"https://weixin.sogou.com{href}",
"summary": "", "category": f"公众号/{kw}", "engagement": {},
})
except Exception as e:
print(f" ⚠️ 微信公众号 [{kw}]: {e}", file=sys.stderr)
return items
def fetch_wechat_video(config):
print(" ⚠️ 微信视频号: 无公开 API", file=sys.stderr)
return []
# ── 主流程 ─────────────────────────────────────────
FETCHERS = {
"weibo": fetch_weibo,
"zhihu": fetch_zhihu,
"toutiao": fetch_toutiao,
"douyin": fetch_douyin,
"twitter": fetch_twitter,
"youtube": fetch_youtube,
"bilibili": fetch_bilibili,
"github": fetch_github,
"reddit": fetch_reddit,
"linuxdo": fetch_linuxdo,
"xiaohongshu": fetch_xiaohongshu,
"wechat_mp": fetch_wechat_mp,
"wechat_video": fetch_wechat_video,
}
def main():
import argparse
parser = argparse.ArgumentParser(description="信息源热点采集")
parser.add_argument("--source", choices=list(FETCHERS.keys()), help="只采集指定平台")
parser.add_argument("--dry-run", action="store_true", help="只打印不保存")
args = parser.parse_args()
config = load_config()
now = datetime.now(TZ_CST)
all_items = []
sources = [args.source] if args.source else list(FETCHERS.keys())
for src in sources:
src_config = config.get(src, {})
if not src_config.get("enabled", True):
print(f"⏭️ {src}: disabled")
continue
print(f"🔍 采集 {src}...")
try:
items = FETCHERS[src](src_config)
for item in items:
item["fetched_at"] = now.isoformat()
all_items.extend(items)
print(f" ✅ {len(items)} 条")
except Exception as e:
print(f" ❌ {src}: {e}")
output = {
"date": now.strftime("%Y-%m-%d"),
"fetched_at": now.isoformat(),
"total": len(all_items),
"sources": {src: len([i for i in all_items if i["source"] == src]) for src in sources},
"items": all_items,
}
if args.dry_run:
print(json.dumps(output, indent=2, ensure_ascii=False)[:3000])
print(f"\n... 共 {len(all_items)} 条")
else:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
outfile = OUTPUT_DIR / f"{now.strftime('%Y-%m-%d')}.json"
with open(outfile, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
print(f"\n📁 已保存: {outfile}")
print(f"📊 共 {len(all_items)} 条 ({', '.join(f'{k}:{v}' for k,v in output['sources'].items())})")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""auto_publisher.py — 自动发布模块
支持平台:小红书(xiaohongshu)— MCP优先,Playwright备选
接口:publish(platform, title, content, images, tags) -> {success, url, error}
用法:
python3 auto_publisher.py --platform xiaohongshu --date 2026-03-02 --topic-id 1
python3 auto_publisher.py --platform xiaohongshu --title "测试" --content "内容" --dry-run
"""
import argparse
import json
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from paths import DRAFTS_DIR, DATA_DIR
XHS_MCP_BIN = Path.home() / ".local/bin/xiaohongshu-mcp"
XHS_MCP_ENDPOINT = "http://localhost:18060/mcp"
XHS_COOKIES_DIR = Path.home() / ".playwright-data/xiaohongshu"
XHS_COOKIES_FALLBACK = [
Path.home() / ".xiaohongshu/cookies.json",
Path("/tmp/cookies.json"),
]
PUBLISH_LOG = DATA_DIR / "publish-log.json"
PLATFORMS = ["xiaohongshu"] # Phase 1
# Clear proxy env to avoid socks:// issues with httpx
for _k in ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY']:
os.environ.pop(_k, None)
# ---------------------------------------------------------------------------
# MCP transport
# ---------------------------------------------------------------------------
def _mcp_available() -> bool:
"""Check if xiaohongshu-mcp binary exists and service is reachable."""
if not XHS_MCP_BIN.exists():
return False
try:
import httpx
r = httpx.get(XHS_MCP_ENDPOINT.replace("/mcp", "/health"), timeout=3)
return r.status_code < 500
except Exception:
return False
def _mcp_call(method: str, params: dict) -> dict:
"""Call xiaohongshu-mcp via stdio JSON-RPC."""
import httpx
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": method, "arguments": params},
}
try:
r = httpx.post(XHS_MCP_ENDPOINT, json=payload, timeout=60)
r.raise_for_status()
data = r.json()
if "error" in data:
return {"success": False, "error": str(data["error"])}
return {"success": True, "result": data.get("result")}
except Exception as e:
return {"success": False, "error": str(e)}
def publish_xiaohongshu_mcp(title: str, content: str,
images: list = None, tags: list = None) -> dict:
"""Publish via xiaohongshu-mcp (MCP JSON-RPC)."""
# Step 1: create note
params = {"title": title, "content": content}
if images:
params["images"] = images
if tags:
params["tags"] = tags
result = _mcp_call("create_note", params)
if not result.get("success"):
return {"success": False, "url": "", "error": f"MCP create_note failed: {result.get('error')}"}
note_data = result.get("result", {})
url = note_data.get("url", note_data.get("note_url", ""))
return {"success": True, "url": url, "error": ""}
# ---------------------------------------------------------------------------
# Playwright transport (fallback)
# ---------------------------------------------------------------------------
def _find_cookies_storage() -> Optional[Path]:
"""Find a valid storage state / cookies file for Playwright."""
XHS_COOKIES_DIR.mkdir(parents=True, exist_ok=True)
state = XHS_COOKIES_DIR / "state.json"
if state.exists():
return state
for p in XHS_COOKIES_FALLBACK:
if p.exists():
return p
return None
def publish_xiaohongshu_playwright(title: str, content: str,
images: list = None, tags: list = None) -> dict:
"""Publish via Playwright automation on creator.xiaohongshu.com."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
return {"success": False, "url": "", "error": "playwright not installed (pip install playwright)"}
storage = _find_cookies_storage()
if not storage:
return {"success": False, "url": "",
"error": "无小红书 cookies/storage state,请先登录并保存到 ~/.playwright-data/xiaohongshu/state.json"}
result = {"success": False, "url": "", "error": ""}
browser = None
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context(storage_state=str(storage))
page = ctx.new_page()
# Navigate to creator publish page
page.goto("https://creator.xiaohongshu.com/publish/publish", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
# Check login state
if "login" in page.url.lower():
result["error"] = "未登录:cookies 已过期,请重新登录"
return result
# --- Fill title ---
title_sel = 'input[placeholder*="标题"], input[name="title"], #title'
try:
page.wait_for_selector(title_sel, timeout=5000)
page.fill(title_sel, title)
except Exception:
# Some versions use contenteditable div
page.locator('[contenteditable="true"]').first.fill(title)
# --- Fill content ---
# XHS editor is usually a contenteditable div or ProseMirror
editor_sels = [
'.ql-editor',
'[contenteditable="true"]:not(input)',
'.ProseMirror',
'#content-editable',
]
filled = False
for sel in editor_sels:
try:
el = page.wait_for_selector(sel, timeout=3000)
if el:
el.click()
page.keyboard.type(content, delay=10)
filled = True
break
except Exception:
continue
if not filled:
result["error"] = "未找到内容编辑器选择器,需要更新 selector"
return result
# --- Upload images (if any) ---
if images:
file_input = page.locator('input[type="file"]').first
for img_path in images:
if Path(img_path).exists():
file_input.set_input_files(img_path)
time.sleep(2) # wait for upload
# --- Add tags ---
if tags:
for tag in tags[:5]: # XHS limits tags
tag_input = page.locator('input[placeholder*="标签"], input[placeholder*="话题"]')
if tag_input.count() > 0:
tag_input.first.fill(tag)
page.keyboard.press("Enter")
time.sleep(0.5)
# --- Click publish ---
pub_btn_sels = [
'button:has-text("发布")',
'button:has-text("Publish")',
'.publish-btn',
'[data-testid="publish-btn"]',
]
published = False
for sel in pub_btn_sels:
try:
btn = page.locator(sel).first
if btn.is_visible():
btn.click()
published = True
break
except Exception:
continue
if not published:
result["error"] = "未找到发布按钮"
return result
# Wait for redirect / success indication
page.wait_for_timeout(3000)
result["success"] = True
result["url"] = page.url
# Save updated storage state
ctx.storage_state(path=str(storage))
ctx.close()
browser.close()
browser = None
except Exception as e:
result["error"] = str(e)
finally:
# Force cleanup
if browser:
try:
browser.close()
except Exception:
pass
subprocess.run(["pkill", "-f", "chromium.*--headless"], capture_output=True)
return result
# ---------------------------------------------------------------------------
# Unified publish interface
# ---------------------------------------------------------------------------
def publish(platform: str, title: str, content: str,
images: list = None, tags: list = None,
dry_run: bool = False) -> dict:
"""
发布内容到指定平台。
Returns: {"success": bool, "url": str, "error": str, "method": str}
"""
if platform not in PLATFORMS:
return {"success": False, "url": "", "error": f"不支持的平台: {platform}", "method": "none"}
if dry_run:
print(f"[DRY-RUN] 平台: {platform}")
print(f" 标题: {title}")
print(f" 内容: {content[:200]}...")
print(f" 图片: {images or '无'}")
print(f" 标签: {tags or '无'}")
return {"success": True, "url": "dry-run://ok", "error": "", "method": "dry-run"}
if platform == "xiaohongshu":
# Try MCP first
if _mcp_available():
print("📡 使用 MCP 方式发布...")
r = publish_xiaohongshu_mcp(title, content, images, tags)
r["method"] = "mcp"
if r.get("success"):
return r
print(f"⚠️ MCP 失败: {r.get('error')},回退到 Playwright...")
else:
print("⚠️ MCP 不可用,跳过...")
# Fallback to Playwright
print("🎭 使用 Playwright 方式...")
storage = _find_cookies_storage()
if not storage:
print("⚠️ 登录态检查: 未找到小红书 cookies")
print(" 请先运行: python3 -m playwright install chromium")
print(" 然后手动登录并保存 storage state 到:")
print(f" {XHS_COOKIES_DIR}/state.json")
return {"success": False, "url": "",
"error": "缺少小红书登录态,请先保存 cookies (详见上方提示)",
"method": "playwright"}
print(f"✅ 登录态检查: 找到 cookies → {storage}")
r = publish_xiaohongshu_playwright(title, content, images, tags)
r["method"] = "playwright"
return r
return {"success": False, "url": "", "error": "unreachable", "method": "none"}
def log_publish(date_str: str, topic_id: str, platform: str, result: dict):
"""Append publish result to log file."""
PUBLISH_LOG.parent.mkdir(parents=True, exist_ok=True)
entry = {
"timestamp": datetime.now().isoformat(),
"date": date_str,
"topic_id": topic_id,
"platform": platform,
**result,
}
logs = []
if PUBLISH_LOG.exists():
try:
logs = json.loads(PUBLISH_LOG.read_text())
except Exception:
pass
logs.append(entry)
PUBLISH_LOG.write_text(json.dumps(logs, ensure_ascii=False, indent=2))
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_draft(date_str: str, topic_id: str, platform: str) -> tuple:
"""Read title + content from draft file."""
path = DRAFTS_DIR / date_str / str(topic_id) / f"{platform}.md"
if not path.exists():
raise FileNotFoundError(f"草稿不存在: {path}")
text = path.read_text()
# Try to extract title from first line
lines = text.strip().splitlines()
title = ""
content = text
for line in lines:
stripped = line.strip()
if stripped.startswith("【标题】"):
title = stripped.replace("【标题】", "").strip()
elif stripped.startswith("[TWEET]") or stripped.startswith("# "):
title = stripped.lstrip("#[ ").rstrip("]").strip()
break
if not title and lines:
title = lines[0][:60]
return title, content
def main():
ap = argparse.ArgumentParser(description="自动发布模块")
ap.add_argument("--platform", choices=PLATFORMS, default="xiaohongshu")
ap.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"))
ap.add_argument("--topic-id", type=int, help="从 drafts 读取")
ap.add_argument("--title", help="直接指定标题")
ap.add_argument("--content", help="直接指定内容")
ap.add_argument("--images", nargs="*", help="图片路径")
ap.add_argument("--tags", nargs="*", help="标签")
ap.add_argument("--dry-run", action="store_true", help="仅预览不发布")
args = ap.parse_args()
if args.title and args.content:
title, content = args.title, args.content
elif args.topic_id:
title, content = parse_draft(args.date, str(args.topic_id), args.platform)
else:
print("❌ 需要指定 --topic-id 或 --title + --content", file=sys.stderr)
return 1
print(f"{'🏃' if not args.dry_run else '📝'} 发布: {args.platform} | {title[:40]}")
result = publish(args.platform, title, content, args.images, args.tags, dry_run=args.dry_run)
if result["success"]:
print(f"✅ 发布成功 [{result.get('method', '?')}] → {result.get('url', 'N/A')}")
else:
print(f"❌ 发布失败 [{result.get('method', '?')}]: {result.get('error', '?')}")
if not args.dry_run:
log_publish(args.date, str(args.topic_id or 0), args.platform, result)
return 0 if result["success"] else 1
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""content_generator.py — 多平台内容生成引擎
读取评分选题 + 平台模板 → 调用 LLM (glm-5) → 输出多平台草稿。
用法:
python content_generator.py --topic-id 1 --platform xiaohongshu
python content_generator.py --top 3 --all-platforms
python content_generator.py --topic-id 1 --all-platforms --dry-run
"""
import argparse
import json
import os
import sys
import httpx
from datetime import datetime
from pathlib import Path
# 清除代理,避免 httpx 不支持 socks 协议报错
for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
os.environ.pop(_k, None)
from paths import TOPICS_DIR, DRAFTS_DIR, TEMPLATES_DIR
PLATFORMS = ["xiaohongshu", "twitter", "wechat"]
LLM_BASE = os.environ.get("LLM_BASE_URL", "https://open.bigmodel.cn/api/paas/v4")
LLM_MODEL = os.environ.get("LLM_MODEL", "glm-5")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "120"))
def get_api_key() -> str:
key = os.environ.get("LLM_API_KEY") or os.environ.get("ZAI_API_KEY")
if key:
return key
import subprocess
r = subprocess.run(["pass", "show", "api/zai-glm5-new"], capture_output=True, text=True, timeout=10)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip()
raise RuntimeError("无法获取 API Key,请设置 LLM_API_KEY 或配置 pass")
def load_topics(date_str: str) -> dict:
path = TOPICS_DIR / f"{date_str}.json"
if not path.exists():
files = sorted(TOPICS_DIR.glob("*.json"), reverse=True)
if not files:
raise FileNotFoundError(f"topics 目录为空: {TOPICS_DIR}")
path = files[0]
print(f"⚠️ 回退到: {path.name}", file=sys.stderr)
return json.loads(path.read_text())
def load_system_prompts() -> dict:
path = TEMPLATES_DIR / "system_prompts.json"
return json.loads(path.read_text())
def load_template(platform: str) -> str:
path = TEMPLATES_DIR / f"{platform}.md"
if not path.exists():
return ""
return path.read_text()
def fill_template(template: str, topic: dict) -> str:
title = topic.get("title", "")
summary = topic.get("summary", "")
if not isinstance(summary, str):
summary = str(summary)
source = f"{topic.get('source', '')}/{topic.get('category', '')}"
angle = topic.get("angle", "")
return (template
.replace("{title}", title)
.replace("{summary}", summary)
.replace("{source}", source)
.replace("{angles}", angle)
.replace("{angle}", angle))
def call_llm(system_prompt: str, user_prompt: str, dry_run: bool = False) -> str:
if dry_run:
return f"[DRY-RUN] 不调用LLM\n\n=== SYSTEM ===\n{system_prompt[:200]}...\n\n=== USER ===\n{user_prompt[:500]}..."
api_key = get_api_key()
url = f"{LLM_BASE}/chat/completions"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
body = {
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.8,
"max_tokens": 4096,
}
with httpx.Client(timeout=LLM_TIMEOUT) as client:
resp = client.post(url, headers=headers, json=body)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
def generate_one(topic, topic_idx, platform, date_str, sys_prompts, dry_run):
sp_entry = sys_prompts.get(platform, {})
system_prompt = sp_entry.get("system_prompt", f"你是{platform}内容创作者。")
output_format = sp_entry.get("output_format", "")
template = load_template(platform)
user_prompt = fill_template(template, topic)
if output_format:
user_prompt += f"\n\n请严格按照以下格式输出:\n{output_format}"
content = call_llm(system_prompt, user_prompt, dry_run=dry_run)
out_dir = DRAFTS_DIR / date_str / str(topic_idx)
out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / f"{platform}.md"
out_file.write_text(content)
return out_file
def main():
p = argparse.ArgumentParser(description="多平台内容生成引擎")
p.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"))
p.add_argument("--topic-id", type=int, help="指定选题编号 (1-based)")
p.add_argument("--top", type=int, default=3, help="批量生成 Top N")
p.add_argument("--platform", choices=PLATFORMS, help="指定单一平台")
p.add_argument("--all-platforms", action="store_true", help="生成所有平台版本")
p.add_argument("--dry-run", action="store_true", help="只打印 prompt 不调用 LLM")
args = p.parse_args()
if not args.platform and not args.all_platforms:
args.all_platforms = True
platforms = PLATFORMS if args.all_platforms else [args.platform]
data = load_topics(args.date)
sys_prompts = load_system_prompts()
top_items = data.get("top", [])
if args.topic_id:
idx = args.topic_id - 1
if idx < 0 or idx >= len(top_items):
print(f"❌ topic-id {args.topic_id} 超出范围 (1-{len(top_items)})", file=sys.stderr)
return 1
targets = [(args.topic_id, top_items[idx])]
else:
targets = [(i + 1, t) for i, t in enumerate(top_items[:args.top])]
date_str = data.get("date", args.date)
total = len(targets) * len(platforms)
done = 0
for tid, topic in targets:
title = (topic.get("title") or "")[:40]
for plat in platforms:
done += 1
tag = "🏃" if not args.dry_run else "📝"
print(f"{tag} [{done}/{total}] #{tid} {title} → {plat}")
try:
out = generate_one(topic, tid, plat, date_str, sys_prompts, args.dry_run)
print(f" ✅ {out}")
except Exception as e:
print(f" ❌ {e}", file=sys.stderr)
print(f"\n{'📝 DRY-RUN' if args.dry_run else '✅'} 完成: {done} 篇内容 → {DRAFTS_DIR / date_str}/")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""draft_reviewer.py — 草稿预览推送脚本
功能:
- 读取 drafts/{date}/{topic_id}/{platform}.md
- 支持指定 date/topic/platform;支持 --all 推送当天所有草稿
- 推送到 Daniel (chat_id: 8518085684)
- --dry-run 仅打印
说明:
- 发送动作使用 OpenClaw message tool 更合适;脚本内默认走 newsbot_send.py 作为降级。
- 当前实现:优先调用 newsbot_send.py(如果其支持 --target/--chat-id),否则 dry-run。
"""
import argparse
import os
from pathlib import Path
from datetime import datetime
import textwrap
import subprocess
import sys
# 清除代理
for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
os.environ.pop(_k, None)
from paths import DRAFTS_DIR, NEWSBOT_SEND
DANIEL_CHAT_ID = "8518085684"
PLATFORMS = ["xiaohongshu", "twitter", "wechat"]
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="ignore")
def preview(text: str, n: int = 300) -> str:
t = " ".join(text.split())
return t[:n] + ("..." if len(t) > n else "")
def iter_drafts(date_str: str, topic_id: str | None, platform: str | None):
base = DRAFTS_DIR / date_str
if not base.exists():
return []
items = []
topic_dirs = [base / str(topic_id)] if topic_id else sorted([p for p in base.iterdir() if p.is_dir()], key=lambda p: int(p.name) if p.name.isdigit() else p.name)
for td in topic_dirs:
if not td.exists() or not td.is_dir():
continue
plats = [platform] if platform else PLATFORMS
for plat in plats:
f = td / f"{plat}.md"
if f.exists():
items.append((td.name, plat, f))
return items
def format_message(date_str: str, topic_id: str, platform: str, content: str) -> str:
head = f"📝 Draft Review\n\nDate: {date_str}\nTopic: #{topic_id}\nPlatform: {platform}\n"
body = preview(content, 300)
tail = "\n\n回复:✅ 通过 / ❌ 退回(可附原因)"
return head + "\n" + body + tail
def send_via_newsbotsend(message: str, dry_run: bool):
sender = NEWSBOT_SEND
if dry_run:
print("--- DRY RUN SEND ---")
print(message)
return True
if not sender.exists():
raise FileNotFoundError(f"newsbot_send.py 不存在:{NEWSBOT_SEND} (set NEWSBOT_SEND env var)")
# 尝试把 Daniel chat_id 作为 target 参数传入(兼容不同实现)。
candidates = [
[sys.executable, str(sender), "--target", DANIEL_CHAT_ID, "--message", message],
[sys.executable, str(sender), "--chat-id", DANIEL_CHAT_ID, "--message", message],
[sys.executable, str(sender), "--to", DANIEL_CHAT_ID, "--message", message],
]
last_err = None
for cmd in candidates:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if p.returncode == 0:
return True
last_err = (p.stderr or p.stdout).strip()
raise RuntimeError(f"newsbot_send.py 发送失败(可能不支持指定 chat_id 参数)。最后错误: {last_err}")
def main():
ap = argparse.ArgumentParser(description="推送草稿预览到 Daniel")
ap.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"))
ap.add_argument("--topic-id", type=int, help="topic id (1-based)")
ap.add_argument("--platform", choices=PLATFORMS)
ap.add_argument("--all", action="store_true", help="推送当天所有草稿")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
if args.all:
topic_id = None
else:
topic_id = str(args.topic_id) if args.topic_id else None
drafts = iter_drafts(args.date, topic_id, args.platform)
if not drafts:
print("⚠️ 未找到任何草稿", file=sys.stderr)
return 1
for tid, plat, path in drafts:
content = read_text(path)
msg = format_message(args.date, tid, plat, content)
send_via_newsbotsend(msg, args.dry_run)
print(f"✅ queued: {args.date}/{tid}/{plat}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""
Shared path configuration for Content Factory.
All data paths are relative to SKILL_DIR, making the skill portable.
"""
from pathlib import Path
# SKILL_DIR = parent of scripts/
SKILL_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = SKILL_DIR / "data"
HOTPOOL_DIR = DATA_DIR / "hotpool"
TOPICS_DIR = DATA_DIR / "topics"
DRAFTS_DIR = DATA_DIR / "drafts"
REVIEWED_DIR = DATA_DIR / "reviewed"
PUBLISHED_DIR = DATA_DIR / "published"
TEMPLATES_DIR = DATA_DIR / "templates"
CONFIG_DIR = DATA_DIR / "config"
ASSETS_DIR = DATA_DIR / "assets"
LOG_FILE = DATA_DIR / "daily.log"
# Local aggregator
FETCH_ALL = SKILL_DIR / "scripts" / "aggregator" / "fetch_all.py"
# Newsbot: prefer environment variable, then local scripts
import os
_newsbot_env = os.environ.get("NEWSBOT_SEND")
NEWSBOT_SEND = Path(_newsbot_env) if _newsbot_env else (
Path.home() / "clawd/scripts/newsbot_send.py"
)
# Ensure data dirs exist on import
for d in [HOTPOOL_DIR, TOPICS_DIR, DRAFTS_DIR, REVIEWED_DIR, PUBLISHED_DIR,
TEMPLATES_DIR, CONFIG_DIR, ASSETS_DIR]:
d.mkdir(parents=True, exist_ok=True)
#!/bin/bash
# Content Factory — Daily Pipeline (portable)
# Uses paths relative to skill directory
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPTS="$SKILL_DIR/scripts"
DATA="$SKILL_DIR/data"
LOG="$DATA/daily.log"
# Create data dirs
mkdir -p "$DATA"/{hotpool,topics,drafts,reviewed,published}
# Clear proxy to avoid SSL issues
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG"; }
log "=== 内容工厂每日流程开始 ==="
# Step 1: 热点采集
log "Step 1: 热点采集..."
cd "$SCRIPTS/aggregator"
timeout 120 python3 fetch_all.py >> "$LOG" 2>&1 || log "⚠️ 热点采集超时或部分失败"
log "热点采集完成"
# Step 2: 选题评分
log "Step 2: 选题评分..."
sleep 5
cd "$SCRIPTS"
python3 topic_scorer.py >> "$LOG" 2>&1 || log "⚠️ 选题评分失败"
log "选题评分完成"
# Step 3: 推送选题给用户
log "Step 3: 推送选题..."
sleep 5
python3 topic_presenter.py >> "$LOG" 2>&1 || log "⚠️ 推送失败"
log "选题推送完成"
# Step 4: 内容生成 (Top 3)
log "Step 4: 内容生成 (Top 3)..."
sleep 5
python3 content_generator.py --top 3 >> "$LOG" 2>&1 || log "⚠️ 内容生成失败"
log "草稿生成完成"
# Step 5: 草稿审核
log "Step 5: 草稿审核..."
sleep 5
python3 draft_reviewer.py --all >> "$LOG" 2>&1 || log "⚠️ 草稿审核失败"
log "草稿审核推送完成"
log "=== 内容工厂每日流程结束 ==="
#!/bin/bash
# Content Factory - Portable daily pipeline
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPTS="$SKILL_DIR/scripts"
DATA="$SKILL_DIR/data"
LOG="$DATA/daily.log"
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG"; }
log "=== Content Factory Daily Pipeline ==="
log "Step 1: Fetching hot topics..."
cd "$SCRIPTS/aggregator" && timeout 120 python3 fetch_all.py >> "$LOG" 2>&1 || log "⚠️ Fetch error"
log "Step 2: Scoring topics..."
cd "$SCRIPTS" && python3 topic_scorer.py >> "$LOG" 2>&1 || log "⚠️ Scorer error"
log "Step 3: Presenting topics..."
python3 topic_presenter.py >> "$LOG" 2>&1 || log "⚠️ Presenter error"
log "Step 4: Generating content (Top 3)..."
python3 content_generator.py --top 3 >> "$LOG" 2>&1 || log "⚠️ Generator error"
log "Step 5: Reviewing drafts..."
python3 draft_reviewer.py --all >> "$LOG" 2>&1 || log "⚠️ Reviewer error"
log "=== Pipeline Complete ==="
#!/usr/bin/env python3
"""topic_presenter.py
将评分后的选题(topics/YYYY-MM-DD.json)格式化为 Telegram 卡片消息并推送。
需求:
1) 读取 ~/clawd/workspace/content-pipeline/topics/YYYY-MM-DD.json
2) 格式化消息(编号、标题、分数、来源、角度)
3) 调用 ~/clawd/scripts/newsbot_send.py 推送
4) --dry-run 只打印不发送
5) --top N 推送 Top N
用法:
python topic_presenter.py
python topic_presenter.py --date 2026-03-02 --top 5 --dry-run
注意:脚本不依赖 topic_scorer.py,可独立运行。
"""
import argparse
import json
import sys
import subprocess
from datetime import datetime
from pathlib import Path
from paths import TOPICS_DIR, NEWSBOT_SEND
def load_topics(date_str: str) -> dict:
path = TOPICS_DIR / f"{date_str}.json"
if not path.exists():
# 回退到最新
files = sorted(TOPICS_DIR.glob("*.json"), reverse=True)
if not files:
raise FileNotFoundError(f"topics 目录为空: {TOPICS_DIR}")
path = files[0]
print(f"⚠️ 未找到 {date_str}.json,回退到最新: {path.name}")
data = json.loads(path.read_text())
if "top" not in data:
raise ValueError(f"topics 文件缺少 top 字段: {path}")
return data
def normalize_source(item: dict) -> str:
source = item.get("source", "?")
category = item.get("category", "")
if category:
return f"{source}/{category}"
return str(source)
def format_message(top_items: list, date_str: str, title_prefix: str = "📰 今日选题") -> str:
nums = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]
lines = [
f"{title_prefix} Top {len(top_items)} ({date_str})",
"",
]
for i, item in enumerate(top_items):
num = nums[i] if i < 10 else f"[{i+1}]"
score = item.get("total_score", item.get("score", "?"))
title = (item.get("title") or "").strip()
if len(title) > 80:
title = title[:77] + "..."
src = normalize_source(item)
angle = (item.get("angle") or "").strip()
if not angle:
angle = "(未提供)"
lines.append(f"{num} [{score}分] {title} - {src}")
lines.append(f" 💡 角度:{angle}")
lines.append("")
lines.append("回复编号(如 \"1 3 7\")选择要创作的主题")
return "\n".join(lines).rstrip() + "\n"
def send(message: str) -> None:
sender = NEWSBOT_SEND
if not sender.exists():
raise FileNotFoundError(f"newsbot_send.py 不存在:{NEWSBOT_SEND} (set NEWSBOT_SEND env var)")
proc = subprocess.run(
[sys.executable, str(sender), "--message", message],
capture_output=True,
text=True,
timeout=30,
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "newsbot_send failed")
def main():
parser = argparse.ArgumentParser(description="推送今日选题到 Telegram")
parser.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"), help="topics 日期")
parser.add_argument("--top", type=int, default=10, help="推送 Top N")
parser.add_argument("--dry-run", action="store_true", help="只打印不发送")
args = parser.parse_args()
data = load_topics(args.date)
top_items = data.get("top", [])[: max(args.top, 0)]
if not top_items:
print("⚠️ topics.top 为空,退出")
return 1
date_str = data.get("date", args.date)
msg = format_message(top_items, date_str)
if args.dry_run:
print(msg)
return 0
send(msg)
print("✅ 已推送")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""
选题评分脚本 — 从热点池筛选 Top 10 推荐选题
读取 hotpool/YYYY-MM-DD.json → LLM评分 → 排序 → 生成选题卡片 → 保存+推送
用法:
python topic_scorer.py # 评分今天的热点池
python topic_scorer.py --date 2026-02-19 # 指定日期
python topic_scorer.py --top 5 # 只取 Top 5
python topic_scorer.py --no-send # 不推送,只输出
python topic_scorer.py --dry-run # 不调LLM,用随机分
"""
import json
import os
import sys
import subprocess
import argparse
import time
from datetime import datetime
from pathlib import Path
# 清除代理,避免 SSL EOF 错误
for k in ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY']:
os.environ.pop(k, None)
# === 路径配置 ===
from paths import HOTPOOL_DIR, TOPICS_DIR, FETCH_ALL, NEWSBOT_SEND
# === LLM 配置(多后端,按优先级) ===
API_BACKENDS = [
{
"name": "DeepSeek",
"endpoint": "https://api.deepseek.com/chat/completions",
"model": "deepseek-chat",
"key_cmd": ["pass", "show", "api/deepseek"],
},
{
"name": "ZAI (Zeabur)",
"endpoint": "https://open.zeabur.com/v1/chat/completions",
"model": "glm-5",
"key_cmd": ["pass", "show", "api/zai"],
},
]
# === 评分权重 ===
WEIGHTS = {"heat": 0.35, "timeliness": 0.25, "creativity": 0.40}
# === 批量评分 prompt ===
SCORING_PROMPT = """你是一个内容选题专家。请对以下热点逐条评分(0-100),并推荐创作角度。
评分维度:
- heat(热度):当前关注度和讨论量,越火越高
- timeliness(时效性):话题新鲜度,过时的打低分
- creativity(创作空间):能否写出有价值、有深度的内容,纯新闻搬运打低分
对每条热点输出 JSON(严格格式,不要多余文字):
```json
[
{
"index": 0,
"heat": 85,
"timeliness": 90,
"creativity": 75,
"angle": "推荐的创作角度(一句话)"
},
...
]
```
以下是待评分的热点列表:
{items_text}
"""
BATCH_SIZE = 15
def init_backend():
"""初始化可用的 API 后端,清除代理干扰"""
# 环境里可能存在 socks 代理(不被 httpx 支持方案),这里优先禁用 SOCKS,仅保留 HTTP 代理
for var in ["ALL_PROXY", "all_proxy"]:
os.environ.pop(var, None)
import httpx
# 诊断:显示代理环境(仅供调试,不打印 key)
# print('HTTP_PROXY=', os.environ.get('HTTP_PROXY'), 'HTTPS_PROXY=', os.environ.get('HTTPS_PROXY'))
for backend in API_BACKENDS:
try:
result = subprocess.run(backend["key_cmd"], capture_output=True, text=True, timeout=10)
key = result.stdout.strip()
if not key:
continue
r = httpx.post(
backend["endpoint"],
json={"model": backend["model"], "messages": [{"role": "user", "content": "say ok"}], "max_tokens": 5},
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
timeout=30,
)
if r.status_code == 200:
print(f"✅ 使用 API: {backend['name']} ({backend['model']})")
return {"endpoint": backend["endpoint"], "model": backend["model"], "key": key}
else:
print(f" ⚠️ {backend['name']}: HTTP {r.status_code} {r.text[:100]}")
except Exception as e:
print(f" ⚠️ {backend['name']}: {e}")
print("❌ 无可用 API 后端", file=sys.stderr)
sys.exit(1)
def load_hotpool(date_str: str) -> list:
"""加载热点池"""
path = HOTPOOL_DIR / f"{date_str}.json"
if not path.exists():
print(f"⚠️ {path} 不存在,尝试调用 fetch_all.py 采集...")
if FETCH_ALL.exists():
subprocess.run([sys.executable, str(FETCH_ALL)], timeout=120)
if not path.exists():
files = sorted(HOTPOOL_DIR.glob("*.json"), reverse=True)
if files:
path = files[0]
print(f"📂 使用最新热点池: {path.name}")
else:
print("❌ 无可用热点池", file=sys.stderr)
sys.exit(1)
data = json.loads(path.read_text())
items = data.get("items", data if isinstance(data, list) else [])
valid = [it for it in items if len(it.get("title", "")) > 10]
print(f"📥 加载 {len(valid)} 条有效热点 (共 {len(items)} 条)")
return valid
def call_llm(prompt: str, backend: dict, retries: int = 2) -> str:
"""调用 LLM API"""
import httpx
for attempt in range(retries + 1):
try:
r = httpx.post(
backend["endpoint"],
json={"model": backend["model"], "messages": [{"role": "user", "content": prompt}],
"temperature": 0.3, "max_tokens": 4096},
headers={"Authorization": f"Bearer {backend['key']}", "Content-Type": "application/json"},
timeout=90,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
except Exception as e:
if attempt < retries:
wait = 2 ** (attempt + 1)
print(f" ⏳ LLM 请求失败 ({e}), {wait}s 后重试...")
time.sleep(wait)
else:
raise
def parse_scores(llm_output: str) -> list:
"""从 LLM 输出中解析评分 JSON"""
text = llm_output
if "```json" in text:
text = text.split("```json")[1].split("```")[0]
elif "```" in text:
text = text.split("```")[1].split("```")[0]
start = text.find("[")
end = text.rfind("]")
if start >= 0 and end > start:
text = text[start:end + 1]
try:
return json.loads(text)
except json.JSONDecodeError:
print(f" ⚠️ JSON 解析失败,跳过此批", file=sys.stderr)
return []
def score_items(items: list, backend: dict) -> list:
"""批量评分所有热点"""
scored = []
total_batches = (len(items) + BATCH_SIZE - 1) // BATCH_SIZE
for batch_idx in range(total_batches):
start = batch_idx * BATCH_SIZE
end = min(start + BATCH_SIZE, len(items))
batch = items[start:end]
print(f"🔄 评分第 {batch_idx + 1}/{total_batches} 批 ({len(batch)} 条)...")
items_text = ""
for i, item in enumerate(batch):
source = item.get("source", "unknown")
title = str(item.get("title", "")).strip()
summary = str(item.get("summary", "")).strip()[:200]
author = str(item.get("author", ""))
items_text += f"\n[{i}] 来源:{source} | 作者:{author}\n标题: {title}\n摘要: {summary}\n"
prompt = SCORING_PROMPT.replace("{items_text}", items_text)
try:
output = call_llm(prompt, backend)
scores = parse_scores(output)
for score in scores:
idx = score.get("index", -1)
if 0 <= idx < len(batch):
item = batch[idx].copy()
item["scores"] = {
"heat": score.get("heat", 50),
"timeliness": score.get("timeliness", 50),
"creativity": score.get("creativity", 50),
}
item["angle"] = score.get("angle", "")
item["total_score"] = round(
item["scores"]["heat"] * WEIGHTS["heat"]
+ item["scores"]["timeliness"] * WEIGHTS["timeliness"]
+ item["scores"]["creativity"] * WEIGHTS["creativity"]
)
# 兼容 downstream: 每个 item 必须有 score 字段
item["score"] = item["total_score"]
scored.append(item)
except Exception as e:
print(f" ❌ 第 {batch_idx + 1} 批评分失败: {e}", file=sys.stderr)
for item in batch:
c = item.copy()
c["scores"] = {"heat": 50, "timeliness": 50, "creativity": 50}
c["angle"] = "需人工评估"
c["total_score"] = 50
c["score"] = 50
scored.append(c)
if batch_idx < total_batches - 1:
time.sleep(1)
return scored
def format_telegram_message(top_items: list, date_str: str) -> str:
"""生成 Telegram 选题卡片消息"""
nums = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]
lines = [f"🔥 今日热点选题 Top {len(top_items)} ({date_str})", "━━━━━━━━━━━━━━", ""]
for i, item in enumerate(top_items):
num = nums[i] if i < 10 else f"[{i+1}]"
title = item.get("title", "").strip()
if len(title) > 60:
title = title[:57] + "..."
total = item.get("total_score", 0)
s = item.get("scores", {})
source = item.get("source", "?")
category = item.get("category", "")
angle = item.get("angle", "")
source_tag = f"{source}/{category}" if category else source
lines.append(f"{num} {title} ⭐{total}")
lines.append(f"📊 热度:{s.get('heat',0)} 时效:{s.get('timeliness',0)} 创作:{s.get('creativity',0)}")
lines.append(f"📌 来源: {source_tag} | 💡 角度: {angle}")
lines.append("")
lines.extend(["━━━━━━━━━━━━━━", "📝 回复编号选择主题(如 \"1 3 7\")", "💡 也可自定义主题"])
return "\n".join(lines)
def save_topics(scored_items: list, top_items: list, date_str: str):
"""保存评分结果"""
TOPICS_DIR.mkdir(parents=True, exist_ok=True)
output = {
"date": date_str,
"scored_at": datetime.now().isoformat(),
"total_scored": len(scored_items),
"top_count": len(top_items),
"weights": WEIGHTS,
"top": top_items,
"all_scored": scored_items,
}
path = TOPICS_DIR / f"{date_str}.json"
path.write_text(json.dumps(output, indent=2, ensure_ascii=False))
print(f"💾 保存到 {path}")
def send_via_newsbot(message: str):
"""通过 newsbot_send.py 推送"""
if not NEWSBOT_SEND.exists():
print(f"⚠️ {NEWSBOT_SEND} 不存在,跳过推送")
return False
try:
proc = subprocess.run(
[sys.executable, str(NEWSBOT_SEND), "--message", message],
capture_output=True, text=True, timeout=30,
)
if proc.returncode == 0:
print("📤 推送成功")
return True
print(f"⚠️ 推送失败: {proc.stderr}", file=sys.stderr)
return False
except Exception as e:
print(f"⚠️ 推送异常: {e}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description="热点选题评分")
parser.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"), help="热点池日期")
parser.add_argument("--top", type=int, default=10, help="Top N")
parser.add_argument("--limit", type=int, default=0, help="只评分前N条热点(0表示全部)")
parser.add_argument("--no-send", action="store_true", help="不推送")
parser.add_argument("--dry-run", action="store_true", help="不调LLM,用随机分")
args = parser.parse_args()
print(f"📅 选题评分: {args.date}")
items = load_hotpool(args.date)
if args.limit and args.limit > 0:
items = items[:args.limit]
print(f"✂️ 只评分前 {len(items)} 条热点 (--limit)")
if not items:
print("❌ 热点池为空")
sys.exit(1)
if args.dry_run:
import random
print("🧪 Dry run 模式")
scored = []
for item in items:
c = item.copy()
h, t, cr = random.randint(40, 100), random.randint(40, 100), random.randint(40, 100)
c["scores"] = {"heat": h, "timeliness": t, "creativity": cr}
c["total_score"] = round(h * WEIGHTS["heat"] + t * WEIGHTS["timeliness"] + cr * WEIGHTS["creativity"])
c["score"] = c["total_score"]
c["angle"] = "dry-run"
scored.append(c)
else:
backend = init_backend()
scored = score_items(items, backend)
scored.sort(key=lambda x: x.get("total_score", 0), reverse=True)
top = scored[:args.top]
print(f"\n🏆 Top {len(top)} 选题:")
for i, item in enumerate(top):
print(f" {i+1}. [{item.get('total_score',0)}分] {item.get('title','')[:50]}")
save_topics(scored, top, args.date)
message = format_telegram_message(top, args.date)
print(f"\n{message}")
if not args.no_send:
send_via_newsbot(message)
return message
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
test_modules.py — Verify all Content Factory modules can be imported
and core paths resolve correctly.
"""
import sys
import os
# Ensure scripts/ is on path
SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(SKILL_DIR, "scripts"))
sys.path.insert(0, os.path.join(SKILL_DIR, "scripts", "aggregator"))
passed = 0
failed = 0
errors = []
def check(name, fn):
global passed, failed
try:
fn()
print(f" ✅ {name}")
passed += 1
except Exception as e:
print(f" ❌ {name}: {e}")
errors.append((name, str(e)))
failed += 1
print("🧪 Content Factory Module Tests\n")
# 1. paths module
print("--- paths ---")
check("import paths", lambda: __import__("paths"))
def verify_paths():
from paths import SKILL_DIR, HOTPOOL_DIR, TOPICS_DIR, DRAFTS_DIR, TEMPLATES_DIR, FETCH_ALL
assert SKILL_DIR.exists(), f"SKILL_DIR not found: {SKILL_DIR}"
assert HOTPOOL_DIR.exists(), f"HOTPOOL_DIR not found: {HOTPOOL_DIR}"
assert FETCH_ALL.exists(), f"FETCH_ALL not found: {FETCH_ALL}"
check("paths resolve correctly", verify_paths)
# 2. aggregator
print("\n--- aggregator ---")
def import_aggregator():
old_cwd = os.getcwd()
os.chdir(os.path.join(SKILL_DIR, "scripts", "aggregator"))
import fetch_all
os.chdir(old_cwd)
check("import fetch_all (aggregator)", import_aggregator)
# 3. topic_scorer
print("\n--- topic_scorer ---")
def import_scorer():
os.chdir(os.path.join(SKILL_DIR, "scripts"))
import topic_scorer
check("import topic_scorer", import_scorer)
# 4. content_generator
print("\n--- content_generator ---")
def import_generator():
import content_generator
check("import content_generator", import_generator)
# 5. topic_presenter
print("\n--- topic_presenter ---")
def import_presenter():
import topic_presenter
check("import topic_presenter", import_presenter)
# 6. draft_reviewer
print("\n--- draft_reviewer ---")
def import_reviewer():
import draft_reviewer
check("import draft_reviewer", import_reviewer)
# 7. auto_publisher
print("\n--- auto_publisher ---")
def import_publisher():
import auto_publisher
check("import auto_publisher", import_publisher)
# 8. templates exist
print("\n--- templates ---")
def verify_templates():
from paths import TEMPLATES_DIR
for t in ["xiaohongshu.md", "wechat.md", "twitter.md"]:
assert (TEMPLATES_DIR / t).exists(), f"template missing: {t}"
check("platform templates present", verify_templates)
# 9. config.json
print("\n--- config ---")
def verify_config():
import json
config_path = os.path.join(SKILL_DIR, "scripts", "aggregator", "config.json")
with open(config_path) as f:
cfg = json.load(f)
assert len(cfg) > 0, "config is empty"
check("aggregator config.json valid", verify_config)
# Summary
print(f"\n{'='*40}")
print(f"Results: {passed} passed, {failed} failed")
if errors:
print("\nFailed:")
for name, err in errors:
print(f" - {name}: {err}")
sys.exit(1)
else:
print("🎉 All modules OK!")
sys.exit(0)
Related skills
FAQ
Which platforms does it aggregate from?
10+ platforms including Bilibili, GitHub Trending, Reddit, YouTube, Weibo, Zhihu, and Twitter.
Which LLMs does it use by default?
It defaults to DeepSeek for topic scoring and GLM for content generation via OpenAI-compatible APIs.