Compare commits
16 Commits
1dc2fed5fa
...
6ea2a4e4c6
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ea2a4e4c6 | |||
| 6192dd4e2a | |||
| 4a128b0fa6 | |||
| dcd0608b5d | |||
| 54f164cdbf | |||
| d448002e7a | |||
| 0c324f9ace | |||
| f563239e0b | |||
| 02e97e057f | |||
| 3ad2e7c090 | |||
| 588def8eb2 | |||
| b2dd8721d0 | |||
| e3b5623860 | |||
| 2f1fac9308 | |||
| ba8631c867 | |||
| aff75141cb |
62
.env.example
62
.env.example
@@ -1,4 +1,4 @@
|
||||
# 企微群机器人 webhook(早报推送,与 bot API 模式凭证不同)
|
||||
# 企微群机器人 webhook(早报推送)
|
||||
WECOM_WEBHOOK_KEY=your-webhook-key
|
||||
|
||||
# 早报内容
|
||||
@@ -25,14 +25,18 @@ GITHUB_TRENDING_SINCE=daily
|
||||
# GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxx
|
||||
# GITHUB_API_ENRICH=1
|
||||
|
||||
# 企微短版(各榜 Top N,默认 10)
|
||||
DAILY_WECOM_TRENDING=10
|
||||
DAILY_WECOM_HOT=10
|
||||
DAILY_WECOM_GITHUB_TRENDING=10
|
||||
DAILY_WECOM_GITHUB_EMERGING=10
|
||||
DAILY_WECOM_GITHUB_TOPIC=10
|
||||
# 企微短版(各榜 Top N,默认 5;周内不重复见 DAILY_BOARD_DEDUP_DAYS)
|
||||
DAILY_WECOM_TRENDING=5
|
||||
DAILY_WECOM_HOT=5
|
||||
DAILY_WECOM_GITHUB_TRENDING=5
|
||||
DAILY_WECOM_GITHUB_EMERGING=5
|
||||
DAILY_WECOM_GITHUB_TOPIC=5
|
||||
DAILY_WECOM_AI_NEWS=10
|
||||
DAILY_WECOM_CN_AI_NEWS=8
|
||||
# research 模式额外技术类时讯条数(叠加在 AI 时讯精选之上)
|
||||
DAILY_WECOM_AI_NEWS_TECH=5
|
||||
DAILY_WECOM_CN_AI_NEWS=10
|
||||
# 企微新闻摘要字数(句读/词边界截断,不加省略号)
|
||||
# DAILY_WECOM_NEWS_DESC_LIMIT=72
|
||||
# 企微 Skills 合并前扫描池大小(同 source 合并后仍凑满 Top N)
|
||||
# DAILY_WECOM_SKILL_POOL=200
|
||||
|
||||
@@ -42,10 +46,37 @@ DAILY_WECOM_CN_AI_NEWS=8
|
||||
DAILY_WECOM_CHUNK_BYTES=4096
|
||||
# DAILY_WECOM_MAX_PARTS=5
|
||||
|
||||
# 企微列表模式:delta=仅展示新入榜 | full=全量 Top 榜(回退)
|
||||
DAILY_WECOM_MODE=delta
|
||||
# Delta 模式下新入榜优先,不足时用当日 Top 榜补满各区块条数(0=仅展示变化)
|
||||
# 补榜时会排除近 N 天 baseline 已出现过的条目,避免周内重复(默认 7 天)
|
||||
DAILY_WECOM_DELTA_PAD=1
|
||||
# 补榜时从更大候选池选取(默认展示条数×5,至少 50)
|
||||
# DAILY_WECOM_PAD_POOL=50
|
||||
# 无历史 data.json 时:full=首日全量一次 | empty=列表为空
|
||||
DAILY_DELTA_BASELINE_FALLBACK=full
|
||||
# 推送闸门不满足时跳过 webhook(仍写 output)
|
||||
DAILY_SKIP_PUSH_WHEN_SILENT=1
|
||||
# DAILY_FORCE_PUSH=1
|
||||
# 已推送新闻 link 去重天数
|
||||
DAILY_NEWS_DEDUP_DAYS=7
|
||||
|
||||
# 常驻调度(python -m daily schedule)
|
||||
DAILY_SCHEDULE_TZ=Asia/Shanghai
|
||||
DAILY_SCHEDULE_GENERATE_AT=08:50
|
||||
DAILY_SCHEDULE_PUSH_AT=09:00
|
||||
|
||||
# 编辑指定今日首推(可选):关键词,或 关键词|URL
|
||||
# Python Step 0 检索 → featured.json;Agent / classic 企微「今日首推」优先使用
|
||||
# DAILY_FEATURED_PICK=gstack
|
||||
# DAILY_FEATURED_PICK=gstack|https://github.com/you/gstack
|
||||
|
||||
# 国际 AI 时讯(RSS,见 daily/news/feeds.py)
|
||||
DAILY_AI_NEWS=1
|
||||
# 国内 AI 时讯(RSS,见 daily/news/feeds_cn.py)
|
||||
# 国内 AI 时讯(RSS,见 daily/news/feeds_cn.py;research 模式下忽略)
|
||||
DAILY_CN_AI_NEWS=1
|
||||
# AI 时讯来源:rss=RSS 抓取 | research=Cursor SDK + deep-research(WebSearch)
|
||||
# DAILY_AI_NEWS_MODE=research
|
||||
# 英文描述 → 简短中文(DAILY_CURSOR_EDITOR=0 时生效)
|
||||
# DAILY_ZH_DESC=1
|
||||
# DAILY_ZH_DESC_BATCH=20
|
||||
@@ -69,10 +100,21 @@ DAILY_CN_AI_NEWS=1
|
||||
# DAILY_DELTA_LOOKBACK_DAYS=7
|
||||
# DAILY_FULL_DESC_LIMIT=0
|
||||
# DAILY_FULL_NEWS_SUMMARY_LIMIT=0
|
||||
DAILY_AI_NEWS_HOURS=72
|
||||
DAILY_AI_NEWS_HOURS=24
|
||||
DAILY_AI_NEWS_PER_FEED=3
|
||||
DAILY_AI_NEWS_PER_CATEGORY=5
|
||||
|
||||
# 多样性 / 去重(见 docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md)
|
||||
DAILY_BOARD_DEDUP_DAYS=7
|
||||
# DAILY_BOARD_POOL_SIZE=50
|
||||
# DAILY_FEATURED_DEDUP_DAYS=30
|
||||
# DAILY_THEME_BAN_DAYS=7
|
||||
# DAILY_NARRATIVE_AXIS_DAYS=3
|
||||
DAILY_NEWS_BACKFILL=0
|
||||
# 国际时讯:在 24h 滚动窗口基础上,不早于今日 0 点(DAILY_AI_NEWS_TZ)
|
||||
DAILY_AI_NEWS_FLOOR_TODAY=1
|
||||
# DAILY_AI_NEWS_TZ=Asia/Shanghai
|
||||
|
||||
# Reddit RSS(403/429 时在 Reddit 偏好设置 → RSS feeds 复制 user / feed 参数)
|
||||
# REDDIT_RSS_USER=your_username
|
||||
# REDDIT_RSS_FEED=your_feed_token
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -2,7 +2,7 @@
|
||||
.env.local
|
||||
logs/
|
||||
.cache/
|
||||
bot/.env
|
||||
bot/.venv/
|
||||
bot/.cache/
|
||||
output
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.cursor/
|
||||
202
README.md
202
README.md
@@ -1,6 +1,6 @@
|
||||
# skills-hot-daily
|
||||
|
||||
Skills / GitHub 早报推送 + 企微对话机器人(同一仓库、两套企微接入)。
|
||||
Skills / GitHub 早报推送(企微 Webhook)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -8,7 +8,7 @@ Skills / GitHub 早报推送 + 企微对话机器人(同一仓库、两套企
|
||||
skills-hot-daily/
|
||||
├── README.md
|
||||
├── .env.example # 早报 webhook、GitHub 等
|
||||
├── requirements.txt # 早报 Python 依赖
|
||||
├── requirements.txt # Python 依赖
|
||||
├── run-daily.ps1 # 生成 + 推送一条龙
|
||||
├── send-wecom.ps1 # 仅推送
|
||||
├── daily/ # 早报 Python 包
|
||||
@@ -32,24 +32,15 @@ skills-hot-daily/
|
||||
├── skills/daily-editor/ # 早报 Cursor 编辑 Skill
|
||||
│ └── SKILL.md
|
||||
├── logs/
|
||||
├── .cache/
|
||||
└── bot/ # 企微 API 模式对话机器人(独立 venv)
|
||||
├── main.py
|
||||
├── skills_service.py
|
||||
└── scenarios/
|
||||
└── .cache/
|
||||
```
|
||||
|
||||
| 模块 | 配置文件 | 启动方式 |
|
||||
|------|----------|----------|
|
||||
| **早报推送** | 根目录 `.env`(`WECOM_WEBHOOK_KEY` 等) | `.\run-daily.ps1` |
|
||||
| **对话 Bot** | `bot/.env`(`WECOM_BOT_ID` / `SECRET` 等) | `cd bot` → `python main.py` |
|
||||
|
||||
---
|
||||
|
||||
## 一、早报推送
|
||||
## 早报推送
|
||||
|
||||
```powershell
|
||||
cd d:\LY\test\tech\skills-hot-daily
|
||||
cd d:\LY\diy\daily-robots
|
||||
pip install -r requirements.txt
|
||||
copy .env.example .env
|
||||
.\run-daily.ps1
|
||||
@@ -84,15 +75,14 @@ WECOM_WEBHOOK_KEY=your-key
|
||||
| 研究 / 论文 | arXiv cs.CL/AI/LG、HF Papers |
|
||||
| 社区讨论 | HN、Reddit r/LocalLLaMA / ClaudeAI / ML 等 |
|
||||
|
||||
环境变量:`DAILY_AI_NEWS=1` · `DAILY_CN_AI_NEWS=1` · `DAILY_AI_NEWS_HOURS=72` · `DAILY_WECOM_AI_NEWS=10` · `DAILY_WECOM_CN_AI_NEWS=8`
|
||||
环境变量:`DAILY_AI_NEWS=1` · `DAILY_CN_AI_NEWS=1` · `DAILY_AI_NEWS_HOURS=24` · `DAILY_WECOM_AI_NEWS=10` · `DAILY_WECOM_CN_AI_NEWS=10`
|
||||
|
||||
**国内 AI 时讯**(RSS,见 `daily/news/feeds_cn.py`):
|
||||
|
||||
| 类别 | 覆盖 |
|
||||
|------|------|
|
||||
| AI 专业媒体 | 量子位、InfoQ 中文 |
|
||||
| AI 专业媒体 | 量子位 |
|
||||
| 综合科技 | 36氪、雷锋网、Google News 中文 |
|
||||
| 开发者社区 | 掘金(标题 AI 关键词过滤) |
|
||||
|
||||
|
||||
### 生成架构(Tier B · Cursor 编辑层)
|
||||
@@ -135,7 +125,9 @@ Python 抓取 → Step1 趋势分析 → Step2 叙事写稿 → Python 分条推
|
||||
|
||||
```env
|
||||
DAILY_REPORT_MODE=agent
|
||||
DAILY_CURSOR_CWD=d:\LY\diy\skills-hot-daily # 早报 LLM 工作目录(与 bot 的 CURSOR_CWD 独立)
|
||||
CURSOR_API_KEY=cursor_...
|
||||
CURSOR_MODEL=composer-2.5
|
||||
DAILY_CURSOR_CWD=d:\LY\diy\daily-robots
|
||||
```
|
||||
|
||||
| 文件 | 说明 |
|
||||
@@ -146,169 +138,11 @@ DAILY_CURSOR_CWD=d:\LY\diy\skills-hot-daily # 早报 LLM 工作目录(与 bo
|
||||
- 完整版 `YYYY-MM-DD.md` 仍为数据表格归档;企微版由 Agent 直接写 Markdown
|
||||
- Agent 失败自动回退 `classic`,不影响 `run-daily.ps1`
|
||||
|
||||
定时推送:Windows 任务计划程序或 `/loop 1d` 执行 `run-daily.ps1`。
|
||||
定时推送:
|
||||
|
||||
---
|
||||
|
||||
## 二、可对话 Skills 助手(企业微信智能机器人)
|
||||
|
||||
在企微里 @ 机器人即可:
|
||||
- **快查**:`trending 10`、`hot 10`、`搜索 react`(本地 skills 数据,秒回)
|
||||
- **截图预览**:`preview` / `截图`(基于 `.env` 的 `CURSOR_CWD` 启动前端并发图)
|
||||
- **通用任务**:任意自然语言需求,由 **Cursor Agent** 执行并回传结果
|
||||
|
||||
### 1. 创建 API 模式机器人
|
||||
|
||||
1. [企业微信管理后台](https://work.weixin.qq.com/) → **安全与管理** → **管理工具** → **智能机器人** → **创建机器人**
|
||||
2. 选择 **API 模式创建** → **使用长连接**
|
||||
3. 记录 **Bot ID** 和 **Secret**(Secret 只显示一次,请立即保存)
|
||||
4. 设置可见范围,将机器人 **添加到目标群** 或允许成员单聊
|
||||
|
||||
普通成员路径:工作台 → 智能机器人 → 手动创建 → API 模式 → 长连接
|
||||
|
||||
### 2. 启动本地服务
|
||||
|
||||
```powershell
|
||||
cd d:\LY\test\tech\skills-hot-daily\bot
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
copy .env.example .env
|
||||
# 编辑 .env:WECOM_BOT_ID / WECOM_BOT_SECRET / CURSOR_API_KEY
|
||||
python main.py
|
||||
```
|
||||
|
||||
服务需 **常驻运行**(本机、服务器或 Docker)。长连接模式下机器人进程须在线才能收消息。
|
||||
|
||||
### 3. 路由模式(ROUTING_MODE)
|
||||
|
||||
| 模式 | 行为 |
|
||||
|------|------|
|
||||
| `hybrid`(默认) | `trending`/`hot`/`搜索`/`详情` 走本地快查;其余 @ 消息交给 Cursor |
|
||||
| `cursor` | 所有消息都交给 Cursor 执行 |
|
||||
| `skills` | 仅本地 skills 快查(旧行为) |
|
||||
|
||||
**Cursor 任务示例**(群里发送):
|
||||
|
||||
```
|
||||
@test 总结 trending top10,并推荐 3 个适合前端团队的 skill
|
||||
@test 对比 mattpocock/skills 和 obra/superpowers 各有哪些热门 skill
|
||||
@test 帮我写一段 npx skills add 的安装说明
|
||||
```
|
||||
|
||||
Cursor 在本机 `CURSOR_CWD` 目录下运行,默认 `d:\LY\test\tech`。复杂任务可能需要 1–10 分钟,流式消息会显示「Cursor 正在执行任务…」。
|
||||
|
||||
### 4. 前端截图预览(API 模式发图)
|
||||
|
||||
项目路径读取 `.env` 中的 **`CURSOR_CWD`**。机器人会:
|
||||
|
||||
1. 在 `CURSOR_CWD` 检测 `package.json`,若有 `dev` 脚本则执行 `PREVIEW_DEV_COMMAND`(默认 `npm run dev`)
|
||||
2. 等待 `PREVIEW_PORT`(默认 `5173`)就绪,或用 `PREVIEW_URL` 直接访问
|
||||
3. Playwright 打开页面并截图
|
||||
4. 通过 API 模式 **上传图片 + 回复 image 消息** 到群
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `preview` / `截图` / `预览` | 访问 `http://127.0.0.1:5173/` 并截图 |
|
||||
| `preview /login` | 指定路径 |
|
||||
| `preview / 3000` | 指定端口 |
|
||||
| `preview http://127.0.0.1:8080/` | 指定完整 URL |
|
||||
|
||||
**多步网页操作**(登录、点菜单、再截图)见下一节,不再写死在代码里。
|
||||
|
||||
`.env` 可选配置:
|
||||
|
||||
```env
|
||||
CURSOR_CWD=d:\LY\test\tech
|
||||
PREVIEW_PORT=5173
|
||||
PREVIEW_URL=http://127.0.0.1:5173/
|
||||
PREVIEW_DEV_COMMAND=npm run dev
|
||||
PREVIEW_STARTUP_TIMEOUT=120
|
||||
```
|
||||
|
||||
若 `CURSOR_CWD` 下暂无前端项目,可先手动启动 dev server,或设置 `PREVIEW_URL` 指向已运行地址。
|
||||
|
||||
### 4b. 网页操作(Playwright 步骤引擎)
|
||||
|
||||
支持三种方式定义操作流程,**无需改 Python 代码**:
|
||||
|
||||
**1. 自然语言(企微里直接说)**
|
||||
|
||||
```
|
||||
@test 访问登录页,输入账号密码,点击登录后进入主页,点击智能体管理菜单然后截图
|
||||
```
|
||||
|
||||
账号密码从 `.env` 读取(`{{PREVIEW_LOGIN_USER}}` / `{{PREVIEW_LOGIN_PASSWORD}}`),勿在群里发密码。
|
||||
|
||||
**2. 场景文件 YAML**
|
||||
|
||||
`bot/scenarios/xiaobao-agent-manage.yaml` 示例:
|
||||
|
||||
```yaml
|
||||
name: xiaobao-agent-manage
|
||||
steps:
|
||||
- goto: /login
|
||||
- fill:
|
||||
field: 账号
|
||||
value: "{{PREVIEW_LOGIN_USER}}"
|
||||
- fill:
|
||||
field: 密码
|
||||
value: "{{PREVIEW_LOGIN_PASSWORD}}"
|
||||
- click: 登录
|
||||
- wait:
|
||||
url: "**/app/**"
|
||||
- click: 智能体管理
|
||||
- wait: 1500
|
||||
- screenshot
|
||||
```
|
||||
|
||||
触发:`@test browser xiaobao-agent-manage`
|
||||
|
||||
场景搜索路径:`bot/scenarios/`、`CURSOR_CWD/.browser-scenarios/`、环境变量 `BROWSER_SCENARIOS_DIR`。
|
||||
|
||||
**3. 消息内 DSL**
|
||||
|
||||
```
|
||||
browser:
|
||||
goto /login
|
||||
fill 账号 {{PREVIEW_LOGIN_USER}}
|
||||
fill 密码 {{PREVIEW_LOGIN_PASSWORD}}
|
||||
click 登录
|
||||
click 智能体管理
|
||||
screenshot
|
||||
```
|
||||
|
||||
**支持的步骤**:`goto` · `fill` · `click` · `wait` · `screenshot` · `press`
|
||||
|
||||
`.env` 登录与场景配置:
|
||||
|
||||
```env
|
||||
PREVIEW_LOGIN_USER=test_account
|
||||
PREVIEW_LOGIN_PASSWORD=your_password
|
||||
# BROWSER_DEFAULT_SCENARIO=xiaobao-agent-manage
|
||||
```
|
||||
|
||||
### 5. 支持的快查命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `trending 10` / `趋势 10` | 近期增长榜 Top N(默认 10,最大 30) |
|
||||
| `hot 10` / `实时 10` | 实时热度榜 |
|
||||
| `all 10` / `总榜 10` | 历史总安装榜 |
|
||||
| `搜索 react` / `search tdd` | 关键词搜索 |
|
||||
| `详情 find-skills` | 单个 skill 详情 + 安装命令 |
|
||||
| `preview` / `截图` | 启动 CURSOR_CWD 前端并截图发群 |
|
||||
| `帮助` | 命令列表 |
|
||||
|
||||
自然语言(非显式快查命令)会交给 **Cursor** 处理,例如 `@test 查 trending 并写推荐` 。
|
||||
|
||||
### 6. 本地测试(无需企微凭证)
|
||||
|
||||
```powershell
|
||||
cd d:\LY\test\tech\skills-hot-daily\bot
|
||||
python -c "from skills_service import handle_command; print(handle_command('trending 5'))"
|
||||
```
|
||||
- **常驻调度(推荐)**:`python -m daily schedule` 或 `.\run-scheduler.ps1`(默认 08:50 生成、09:00 推送,见 `DAILY_SCHEDULE_*`)
|
||||
- Windows 任务计划:`.\register-daily-task.ps1`
|
||||
- Cursor:`/loop 1d`(时间会漂移,仅临时用)
|
||||
|
||||
---
|
||||
|
||||
@@ -321,14 +155,6 @@ python -c "from skills_service import handle_command; print(handle_command('tren
|
||||
| **邮件 + 企业微信邮箱** | 已有 SMTP | 中 |
|
||||
| **PushPlus / Server酱** | 个人微信中转 | 低(第三方) |
|
||||
|
||||
### 应用消息 API(简要)
|
||||
|
||||
适合「推送给某个人」而非群聊。需在 [企业微信管理后台](https://work.weixin.qq.com/) 创建自建应用,调用:
|
||||
|
||||
`POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN`
|
||||
|
||||
消息体支持 `text` / `markdown` / `news` 等。需先 `gettoken` 再发消息,并维护 access_token 缓存。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# 企业微信智能机器人(API 模式 · 长连接)
|
||||
# 管理后台 → 安全与管理 → 管理工具 → 智能机器人 → 创建 → API 模式 → 使用长连接
|
||||
WECOM_BOT_ID=your-bot-id
|
||||
WECOM_BOT_SECRET=your-bot-secret
|
||||
|
||||
# Cursor SDK(@ 机器人后的通用任务由 Cursor 执行)
|
||||
CURSOR_API_KEY=cursor_...
|
||||
CURSOR_CWD=d:\LY\test\tech
|
||||
CURSOR_MODEL=composer-2.5
|
||||
CURSOR_TIMEOUT=600
|
||||
|
||||
# 前端截图预览(基于 CURSOR_CWD)
|
||||
PREVIEW_PORT=5173
|
||||
PREVIEW_URL=http://127.0.0.1:5173/
|
||||
# PREVIEW_DEV_COMMAND=npm run dev
|
||||
# PREVIEW_STARTUP_TIMEOUT=120
|
||||
|
||||
# 登录后截图(账号密码只放 .env,切勿发到企微群)
|
||||
# PREVIEW_LOGIN_USER=your_account_or_phone
|
||||
# PREVIEW_LOGIN_PASSWORD=your_password
|
||||
# PREVIEW_AFTER_LOGIN_URL=/app/dashboard
|
||||
# PREVIEW_AUTO_LOGIN=true
|
||||
|
||||
# 网页操作场景目录(可选,默认 bot/scenarios 与 CURSOR_CWD/.browser-scenarios)
|
||||
# BROWSER_SCENARIOS_DIR=d:\path\to\scenarios
|
||||
# BROWSER_DEFAULT_SCENARIO=xiaobao-agent-manage
|
||||
|
||||
# hybrid=快查走本地 / 其余走 Cursor | cursor=全部 Cursor | skills=仅本地
|
||||
ROUTING_MODE=hybrid
|
||||
4
bot/.gitignore
vendored
4
bot/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
.cache/
|
||||
.env
|
||||
.venv/
|
||||
.cache/screenshots/
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Bot 内部数据结构。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteResult:
|
||||
source: str
|
||||
text: str
|
||||
image_path: str | None = None
|
||||
@@ -1,49 +0,0 @@
|
||||
"""浏览器场景变量替换与 base URL 解析。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import env_config
|
||||
|
||||
_VAR_PATTERN = re.compile(r"\{\{([A-Z0-9_]+)\}\}")
|
||||
|
||||
|
||||
def interpolate(value: str) -> str:
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
resolved = env_config.env(key)
|
||||
if resolved is None:
|
||||
raise RuntimeError(f"场景变量未配置:{key}")
|
||||
return resolved
|
||||
|
||||
return _VAR_PATTERN.sub(repl, value)
|
||||
|
||||
|
||||
def default_base_url() -> str:
|
||||
explicit = (env_config.env("PREVIEW_BASE_URL") or "").strip()
|
||||
if explicit:
|
||||
return interpolate(explicit.rstrip("/"))
|
||||
|
||||
preview = (env_config.env("PREVIEW_URL") or "").strip()
|
||||
if preview:
|
||||
parsed = urlparse(preview)
|
||||
scheme = parsed.scheme or "http"
|
||||
host = parsed.hostname or "127.0.0.1"
|
||||
port = parsed.port
|
||||
if port and port not in (80, 443):
|
||||
return f"{scheme}://{host}:{port}"
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
port = env_config.env("PREVIEW_PORT", "5173") or "5173"
|
||||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
def resolve_url(base_url: str, target: str) -> str:
|
||||
target = interpolate(target.strip())
|
||||
if target.startswith("http://") or target.startswith("https://"):
|
||||
return target
|
||||
if not target.startswith("/"):
|
||||
target = "/" + target
|
||||
return base_url.rstrip("/") + target
|
||||
@@ -1,269 +0,0 @@
|
||||
"""通用 Playwright 步骤执行器(不写死业务页面)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from browser_env import interpolate, resolve_url
|
||||
from browser_models import BrowserResult, BrowserScenario
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCREENSHOT_DIR = Path(__file__).resolve().parent / ".cache" / "screenshots"
|
||||
|
||||
FIELD_HINTS: dict[str, list[str]] = {
|
||||
"账号": [
|
||||
"#login-username",
|
||||
"input#login-username",
|
||||
"input[autocomplete='username']",
|
||||
"username",
|
||||
"account",
|
||||
"phone",
|
||||
"账号",
|
||||
"手机号",
|
||||
"企业账号",
|
||||
],
|
||||
"密码": [
|
||||
"#login-password input",
|
||||
"#login-password",
|
||||
"input#login-password",
|
||||
"input[type='password']",
|
||||
"password",
|
||||
"密码",
|
||||
],
|
||||
"用户名": ["#login-username", "input#login-username", "username", "account", "账号"],
|
||||
}
|
||||
|
||||
def _step_label(step: dict[str, Any], index: int) -> str:
|
||||
action = step.get("action", "?")
|
||||
target = step.get("target") or step.get("field") or step.get("url") or ""
|
||||
return f"{index + 1}. {action} {target}".strip()
|
||||
|
||||
|
||||
def _resolve_fill_locator(page, field: str, step: dict[str, Any]):
|
||||
if step.get("selector"):
|
||||
return page.locator(interpolate(str(step["selector"])))
|
||||
|
||||
field_key = interpolate(str(field))
|
||||
if step.get("label"):
|
||||
return page.get_by_label(interpolate(str(step["label"])), exact=False)
|
||||
if step.get("placeholder"):
|
||||
return page.get_by_placeholder(interpolate(str(step["placeholder"])), exact=False)
|
||||
|
||||
hints = FIELD_HINTS.get(field_key, [field_key])
|
||||
for hint in hints:
|
||||
if hint.startswith("#") or hint.startswith(".") or hint.startswith("["):
|
||||
locator = page.locator(hint)
|
||||
if locator.count() > 0:
|
||||
return locator.first
|
||||
for getter in (
|
||||
lambda h=hint: page.get_by_label(h, exact=False),
|
||||
lambda h=hint: page.get_by_placeholder(h, exact=False),
|
||||
):
|
||||
locator = getter()
|
||||
if locator.count() > 0:
|
||||
return locator.first
|
||||
|
||||
return page.locator("input, textarea").filter(has_text=field_key).first
|
||||
|
||||
|
||||
def _fill_field(page, field: str, step: dict[str, Any]) -> None:
|
||||
value = interpolate(str(step.get("value", "")))
|
||||
locator = _resolve_fill_locator(page, field, step)
|
||||
locator.click(timeout=10_000)
|
||||
locator.fill("", timeout=5_000)
|
||||
locator.fill(value, timeout=10_000)
|
||||
|
||||
|
||||
def _page_error_text(page) -> str | None:
|
||||
for selector in (
|
||||
".ant-message-error",
|
||||
".ant-form-item-explain-error",
|
||||
".ant-alert-error",
|
||||
):
|
||||
try:
|
||||
locator = page.locator(selector).first
|
||||
if locator.is_visible(timeout=300):
|
||||
text = locator.inner_text(timeout=1_000).strip()
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _pathname_matches(pattern: str, pathname: str) -> bool:
|
||||
pattern = pattern.strip()
|
||||
if pattern in {"**/app/**", "**/app/*", "/app/**"}:
|
||||
return pathname.startswith("/app")
|
||||
if pattern.endswith("/**"):
|
||||
prefix = pattern[:-3].rstrip("/")
|
||||
if prefix.startswith("**/"):
|
||||
prefix = prefix[3:]
|
||||
if not prefix.startswith("/"):
|
||||
prefix = "/" + prefix
|
||||
return pathname.startswith(prefix)
|
||||
if "**" in pattern or "*" in pattern:
|
||||
regex = "^" + re.escape(pattern).replace(r"\*\*", ".*").replace(r"\*", "[^/]*") + "$"
|
||||
return re.search(regex, pathname) is not None
|
||||
return pathname == pattern or pathname.startswith(pattern)
|
||||
|
||||
|
||||
def _wait_for_url_pattern(page, pattern: str, timeout: int = 60_000) -> None:
|
||||
"""SPA 路由用 pathname 轮询;glob 模式不依赖 navigation 事件。"""
|
||||
deadline = time.monotonic() + timeout / 1000
|
||||
last_error: str | None = None
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
pathname = page.evaluate("() => window.location.pathname")
|
||||
if _pathname_matches(pattern, pathname):
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=8_000)
|
||||
except Exception:
|
||||
page.wait_for_timeout(800)
|
||||
return
|
||||
|
||||
err = _page_error_text(page)
|
||||
if err and err != last_error:
|
||||
last_error = err
|
||||
logger.warning("页面提示:%s", err)
|
||||
if "/login" in pathname:
|
||||
raise RuntimeError(f"登录失败:{err}")
|
||||
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
err = _page_error_text(page)
|
||||
hint_parts = [f"当前 URL:`{page.url}`"]
|
||||
if err:
|
||||
hint_parts.append(f"页面错误:{err}")
|
||||
elif last_error:
|
||||
hint_parts.append(f"页面错误:{last_error}")
|
||||
hint_parts.append("请确认 PREVIEW_LOGIN_USER/PASSWORD 正确,且登录 API(内网网关)可达。")
|
||||
raise RuntimeError(f"等待 URL 匹配 `{pattern}` 超时({timeout}ms)。{' '.join(hint_parts)}")
|
||||
|
||||
|
||||
def _click_target(page, target: str) -> None:
|
||||
target = interpolate(target.strip())
|
||||
if target.lower() in {"登录", "login"}:
|
||||
for selector in ("button.login-submit", "button[type='submit']"):
|
||||
locator = page.locator(selector)
|
||||
if locator.count() > 0:
|
||||
locator.first.click(timeout=10_000)
|
||||
return
|
||||
|
||||
candidates = [
|
||||
page.get_by_role("menuitem", name=target, exact=True),
|
||||
page.get_by_role("button", name=target, exact=True),
|
||||
page.get_by_role("link", name=target, exact=True),
|
||||
page.get_by_text(target, exact=True),
|
||||
page.get_by_text(target, exact=False),
|
||||
]
|
||||
for locator in candidates:
|
||||
if locator.count() > 0:
|
||||
locator.first.click(timeout=10_000)
|
||||
return
|
||||
raise RuntimeError(f"未找到可点击元素:{target}")
|
||||
|
||||
|
||||
def _execute_step(page, base_url: str, step: dict[str, Any]) -> None:
|
||||
action = str(step.get("action", "")).lower()
|
||||
if action == "goto":
|
||||
target = step.get("target") or step.get("url") or "/"
|
||||
url = resolve_url(base_url, str(target))
|
||||
page.goto(url, wait_until="networkidle", timeout=60_000)
|
||||
return
|
||||
|
||||
if action == "fill":
|
||||
field = str(step.get("field") or step.get("target") or "账号")
|
||||
_fill_field(page, field, step)
|
||||
return
|
||||
|
||||
if action == "click":
|
||||
target = step.get("target") or step.get("text")
|
||||
if not target:
|
||||
raise RuntimeError("click 步骤缺少 target")
|
||||
_click_target(page, str(target))
|
||||
page.wait_for_timeout(800)
|
||||
return
|
||||
|
||||
if action == "wait":
|
||||
timeout = int(step.get("timeout") or 60_000)
|
||||
if step.get("url"):
|
||||
_wait_for_url_pattern(page, str(step["url"]), timeout=timeout)
|
||||
return
|
||||
if step.get("selector"):
|
||||
page.locator(interpolate(str(step["selector"]))).wait_for(timeout=30_000)
|
||||
return
|
||||
if step.get("text"):
|
||||
page.get_by_text(interpolate(str(step["text"])), exact=False).wait_for(timeout=30_000)
|
||||
return
|
||||
ms = int(step.get("ms") or 1500)
|
||||
page.wait_for_timeout(ms)
|
||||
return
|
||||
|
||||
if action == "press":
|
||||
key = str(step.get("key") or step.get("target") or "Enter")
|
||||
page.keyboard.press(key)
|
||||
return
|
||||
|
||||
if action == "screenshot":
|
||||
return
|
||||
|
||||
raise RuntimeError(f"未知步骤 action={action}")
|
||||
|
||||
|
||||
def run_browser_scenario_sync(scenario: BrowserScenario) -> BrowserResult:
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
slug = (scenario.name or "browser").replace(" ", "-")
|
||||
output = SCREENSHOT_DIR / f"{slug}-{stamp}.png"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
step_log: list[str] = []
|
||||
final_url = scenario.base_url
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 720})
|
||||
|
||||
steps = list(scenario.steps)
|
||||
if steps and steps[-1].get("action") != "screenshot" and not any(
|
||||
s.get("action") == "screenshot" for s in steps
|
||||
):
|
||||
steps.append({"action": "screenshot"})
|
||||
|
||||
for index, step in enumerate(steps):
|
||||
label = _step_label(step, index)
|
||||
logger.info("执行步骤 %s", label)
|
||||
action = str(step.get("action", "")).lower()
|
||||
if action == "screenshot":
|
||||
page.wait_for_timeout(int(step.get("ms") or 1500))
|
||||
page.screenshot(path=str(output), full_page=False, type="png")
|
||||
final_url = page.url
|
||||
step_log.append(label + " ✓")
|
||||
continue
|
||||
try:
|
||||
_execute_step(page, scenario.base_url, step)
|
||||
final_url = page.url
|
||||
step_log.append(label + " ✓")
|
||||
except Exception as exc:
|
||||
err = _page_error_text(page)
|
||||
detail = f"({err})" if err else ""
|
||||
raise RuntimeError(f"步骤失败:{label} @ {page.url}{detail}") from exc
|
||||
|
||||
browser.close()
|
||||
|
||||
return BrowserResult(
|
||||
scenario_name=scenario.name,
|
||||
base_url=scenario.base_url,
|
||||
final_url=final_url,
|
||||
screenshot_path=output,
|
||||
step_count=len(steps),
|
||||
step_log=step_log,
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
"""浏览器自动化步骤模型。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserScenario:
|
||||
name: str | None
|
||||
base_url: str
|
||||
steps: list[dict[str, Any]]
|
||||
source: str = "natural"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserResult:
|
||||
scenario_name: str | None
|
||||
base_url: str
|
||||
final_url: str
|
||||
screenshot_path: Path
|
||||
step_count: int
|
||||
started_dev_server: bool = False
|
||||
step_log: list[str] = field(default_factory=list)
|
||||
@@ -1,307 +0,0 @@
|
||||
"""解析自然语言 / YAML / 场景名 → 浏览器步骤。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
import env_config
|
||||
from browser_env import default_base_url, interpolate
|
||||
from browser_models import BrowserScenario
|
||||
|
||||
SCENARIO_DIRS = [
|
||||
Path(__file__).resolve().parent / "scenarios",
|
||||
Path(__file__).resolve().parent.parent / "scenarios",
|
||||
]
|
||||
|
||||
|
||||
def _project_cwd() -> Path:
|
||||
raw = env_config.env("CURSOR_CWD", r"d:\LY\test\tech") or r"d:\LY\test\tech"
|
||||
return Path(raw).resolve()
|
||||
|
||||
|
||||
def _strip_mention(text: str) -> str:
|
||||
return re.sub(r"@\S+\s*", "", text).strip()
|
||||
|
||||
|
||||
def _scenario_search_dirs() -> list[Path]:
|
||||
dirs = list(SCENARIO_DIRS)
|
||||
dirs.append(_project_cwd() / ".browser-scenarios")
|
||||
custom = (env_config.env("BROWSER_SCENARIOS_DIR") or "").strip()
|
||||
if custom:
|
||||
dirs.append(Path(custom).resolve())
|
||||
return dirs
|
||||
|
||||
|
||||
def is_browser_intent(text: str) -> bool:
|
||||
raw = _strip_mention(text)
|
||||
if not raw:
|
||||
return False
|
||||
if re.match(r"^(browser|网页|网页操作|操作)\b", raw, re.IGNORECASE):
|
||||
return True
|
||||
if re.search(r"```(?:yaml|yml)", raw, re.IGNORECASE):
|
||||
return True
|
||||
if re.search(r"(?m)^browser\s*:", raw, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
if re.match(r"^(preview|截图|预览|截屏)\s", raw, re.IGNORECASE):
|
||||
if not re.search(r"[,,。;;]|然后|输入|点击|填写|访问|打开|登录", raw):
|
||||
return False
|
||||
|
||||
if len(_split_segments(text)) >= 2:
|
||||
return True
|
||||
|
||||
verbs = 0
|
||||
for pattern in (r"访问", r"打开", r"输入", r"填写", r"点击", r"点选", r"选择", r"登录"):
|
||||
if re.search(pattern, raw):
|
||||
verbs += 1
|
||||
return verbs >= 2
|
||||
|
||||
|
||||
def _load_yaml_scenario(path: Path) -> BrowserScenario:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"场景文件格式错误:{path}")
|
||||
base_url = interpolate(str(data.get("base_url") or default_base_url()))
|
||||
steps = data.get("steps")
|
||||
if not isinstance(steps, list) or not steps:
|
||||
raise RuntimeError(f"场景缺少 steps:{path}")
|
||||
return BrowserScenario(
|
||||
name=data.get("name") or path.stem,
|
||||
base_url=base_url,
|
||||
steps=_normalize_steps(steps),
|
||||
source=f"file:{path.name}",
|
||||
)
|
||||
|
||||
|
||||
def _find_scenario_file(name: str) -> Path | None:
|
||||
slug = name.strip().replace(" ", "-")
|
||||
for directory in _scenario_search_dirs():
|
||||
for candidate in (directory / f"{slug}.yaml", directory / f"{slug}.yml"):
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_steps(raw_steps: list[Any]) -> list[dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in raw_steps:
|
||||
if isinstance(item, str):
|
||||
normalized.append({"action": item})
|
||||
continue
|
||||
if not isinstance(item, dict) or not item:
|
||||
raise RuntimeError(f"无效步骤:{item!r}")
|
||||
if "action" in item:
|
||||
normalized.append(dict(item))
|
||||
continue
|
||||
if len(item) == 1:
|
||||
action, payload = next(iter(item.items()))
|
||||
step = {"action": action}
|
||||
if payload is not None:
|
||||
if isinstance(payload, dict):
|
||||
step.update(payload)
|
||||
elif action == "wait" and isinstance(payload, int):
|
||||
step["ms"] = payload
|
||||
elif action == "wait" and isinstance(payload, str) and payload.isdigit():
|
||||
step["ms"] = int(payload)
|
||||
else:
|
||||
step["target"] = payload
|
||||
normalized.append(step)
|
||||
continue
|
||||
raise RuntimeError(f"无效步骤:{item!r}")
|
||||
return normalized
|
||||
|
||||
|
||||
def _parse_inline_dsl(text: str) -> BrowserScenario | None:
|
||||
raw = _strip_mention(text)
|
||||
match = re.search(r"(?ms)^browser\s*:\s*\n(.+)$", raw, re.IGNORECASE)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
steps: list[dict[str, Any]] = []
|
||||
for line in match.group(1).splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
line = re.sub(r"^[-*]\s*", "", line)
|
||||
if not line:
|
||||
continue
|
||||
steps.append(_parse_dsl_line(line))
|
||||
|
||||
if not steps:
|
||||
return None
|
||||
return BrowserScenario(
|
||||
name="inline",
|
||||
base_url=default_base_url(),
|
||||
steps=steps,
|
||||
source="inline-dsl",
|
||||
)
|
||||
|
||||
|
||||
def _parse_dsl_line(line: str) -> dict[str, Any]:
|
||||
parts = line.split(None, 2)
|
||||
action = parts[0].lower()
|
||||
if action == "goto":
|
||||
return {"action": "goto", "target": parts[1] if len(parts) > 1 else "/"}
|
||||
if action == "click":
|
||||
return {"action": "click", "target": " ".join(parts[1:])}
|
||||
if action == "fill":
|
||||
if len(parts) < 3:
|
||||
raise RuntimeError(f"fill 语法:fill 字段 值({line})")
|
||||
return {"action": "fill", "field": parts[1], "value": parts[2]}
|
||||
if action == "wait":
|
||||
payload = parts[1] if len(parts) > 1 else "1500"
|
||||
if payload.isdigit():
|
||||
return {"action": "wait", "ms": int(payload)}
|
||||
return {"action": "wait", "url": payload}
|
||||
if action in {"screenshot", "shot"}:
|
||||
return {"action": "screenshot"}
|
||||
raise RuntimeError(f"未知 DSL 步骤:{line}")
|
||||
|
||||
|
||||
def _split_segments(text: str) -> list[str]:
|
||||
raw = _strip_mention(text)
|
||||
raw = re.sub(r"^(browser|网页|网页操作|操作)\s*[::]?\s*", "", raw, flags=re.IGNORECASE)
|
||||
raw = re.sub(r"然后截图|再截图|最后截图", "截图", raw)
|
||||
chunks = re.split(r"[,,。;;]\s*|\s+然后\s+|\s+接着\s+|\s+并\s*", raw)
|
||||
expanded: list[str] = []
|
||||
for chunk in chunks:
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
subchunks = re.split(r"\s+然后\s+", chunk)
|
||||
if "后" in chunk and len(subchunks) == 1:
|
||||
subchunks = re.split(r"(?<=[登录页表单])后(?=[进入打开等待点击访问])", chunk)
|
||||
for part in subchunks:
|
||||
part = part.strip()
|
||||
if part:
|
||||
expanded.append(part)
|
||||
return expanded
|
||||
|
||||
|
||||
def _parse_segment(segment: str) -> list[dict[str, Any]]:
|
||||
seg = segment.strip()
|
||||
if not seg or seg.lower() in {"browser", "网页操作"}:
|
||||
return []
|
||||
|
||||
if re.fullmatch(r"截图|截屏", seg, re.IGNORECASE):
|
||||
return [{"action": "screenshot"}]
|
||||
|
||||
match = re.search(r"访问登录页|打开登录页|进入登录页|运行登录页", seg, re.IGNORECASE)
|
||||
if match:
|
||||
return [{"action": "goto", "target": "/login"}]
|
||||
|
||||
match = re.search(r"输入账号密码|填写账号密码|输入账号和密码", seg, re.IGNORECASE)
|
||||
if match:
|
||||
return [
|
||||
{"action": "fill", "field": "账号", "value": "{{PREVIEW_LOGIN_USER}}"},
|
||||
{"action": "fill", "field": "密码", "value": "{{PREVIEW_LOGIN_PASSWORD}}"},
|
||||
]
|
||||
|
||||
match = re.search(r"输入账号|填写账号|输入用户名|填写用户名", seg, re.IGNORECASE)
|
||||
if match:
|
||||
return [{"action": "fill", "field": "账号", "value": "{{PREVIEW_LOGIN_USER}}"}]
|
||||
|
||||
match = re.search(r"输入密码|填写密码", seg, re.IGNORECASE)
|
||||
if match:
|
||||
return [{"action": "fill", "field": "密码", "value": "{{PREVIEW_LOGIN_PASSWORD}}"}]
|
||||
|
||||
match = re.search(r"进入主页|进入首页|打开主页|打开首页|等待主页", seg, re.IGNORECASE)
|
||||
if match:
|
||||
return [{"action": "wait", "url": "**/app/**"}]
|
||||
|
||||
match = re.search(r"等待\s*(\d+)\s*秒", seg, re.IGNORECASE)
|
||||
if match:
|
||||
return [{"action": "wait", "ms": int(match.group(1)) * 1000}]
|
||||
|
||||
match = re.search(
|
||||
r"(?:点击|点选|选择)\s*(.+?)(?:菜单|按钮|链接)?$",
|
||||
seg,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if match:
|
||||
target = match.group(1).strip()
|
||||
target = re.sub(r"(然后|再|并)?\s*(截图|截屏).*$", "", target, flags=re.IGNORECASE).strip()
|
||||
target = re.sub(r"(然后|再|之后)$", "", target).strip()
|
||||
target = re.sub(r"(菜单|按钮|链接)$", "", target).strip()
|
||||
if target:
|
||||
return [{"action": "click", "target": target}]
|
||||
|
||||
match = re.search(
|
||||
r"(?:访问|打开|进入)\s*(https?://\S+|/\S+|登录页|主页|首页)",
|
||||
seg,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if match:
|
||||
target = match.group(1)
|
||||
mapping = {"登录页": "/login", "主页": "/app/dashboard", "首页": "/app/dashboard"}
|
||||
return [{"action": "goto", "target": mapping.get(target, target)}]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def parse_natural_language(text: str) -> BrowserScenario | None:
|
||||
segments = _split_segments(text)
|
||||
steps: list[dict[str, Any]] = []
|
||||
for segment in segments:
|
||||
steps.extend(_parse_segment(segment))
|
||||
|
||||
if not steps:
|
||||
return None
|
||||
if not any(step.get("action") == "screenshot" for step in steps):
|
||||
if re.search(r"截图|截屏", text, re.IGNORECASE):
|
||||
steps.append({"action": "screenshot"})
|
||||
if not steps:
|
||||
return None
|
||||
|
||||
return BrowserScenario(
|
||||
name="natural",
|
||||
base_url=default_base_url(),
|
||||
steps=steps,
|
||||
source="natural-language",
|
||||
)
|
||||
|
||||
|
||||
def parse_browser_request(text: str) -> BrowserScenario | None:
|
||||
if not is_browser_intent(text):
|
||||
return None
|
||||
|
||||
raw = _strip_mention(text)
|
||||
yaml_block = re.search(r"```(?:yaml|yml)\s*\n(.+?)```", raw, re.IGNORECASE | re.DOTALL)
|
||||
if yaml_block:
|
||||
data = yaml.safe_load(yaml_block.group(1))
|
||||
if isinstance(data, dict):
|
||||
base_url = interpolate(str(data.get("base_url") or default_base_url()))
|
||||
steps = data.get("steps") or []
|
||||
return BrowserScenario(
|
||||
name=data.get("name") or "yaml-inline",
|
||||
base_url=base_url,
|
||||
steps=_normalize_steps(steps),
|
||||
source="yaml-inline",
|
||||
)
|
||||
|
||||
inline = _parse_inline_dsl(text)
|
||||
if inline:
|
||||
return inline
|
||||
|
||||
match = re.match(r"^(browser|网页|网页操作|操作)\s+([\w\-./]+)\s*$", raw, re.IGNORECASE)
|
||||
if match:
|
||||
path = _find_scenario_file(match.group(2))
|
||||
if not path:
|
||||
raise RuntimeError(f"未找到场景文件:{match.group(2)}.yaml")
|
||||
return _load_yaml_scenario(path)
|
||||
|
||||
scenario = parse_natural_language(text)
|
||||
if scenario:
|
||||
return scenario
|
||||
|
||||
default_name = (env_config.env("BROWSER_DEFAULT_SCENARIO") or "").strip()
|
||||
if default_name:
|
||||
path = _find_scenario_file(default_name)
|
||||
if path:
|
||||
return _load_yaml_scenario(path)
|
||||
|
||||
return None
|
||||
@@ -1,87 +0,0 @@
|
||||
"""浏览器自动化服务:解析场景 + 启动 dev server + 执行步骤。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from browser_executor import run_browser_scenario_sync
|
||||
from browser_models import BrowserResult, BrowserScenario
|
||||
from browser_parser import parse_browser_request
|
||||
from preview_service import (
|
||||
_package_dev_script,
|
||||
_preview_port,
|
||||
_project_cwd,
|
||||
_startup_timeout,
|
||||
_wait_for_port,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_dev_server(base_url: str) -> bool:
|
||||
parsed = urlparse(base_url)
|
||||
host = parsed.hostname or "127.0.0.1"
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
|
||||
if _wait_for_port(host, port, timeout=3):
|
||||
return False
|
||||
|
||||
cwd = _project_cwd()
|
||||
dev_command = _package_dev_script(cwd)
|
||||
if not dev_command:
|
||||
raise RuntimeError(
|
||||
f"无法访问 {base_url},且未找到可启动的 dev 脚本。"
|
||||
"请先手动启动前端,或设置 PREVIEW_URL。"
|
||||
)
|
||||
|
||||
import subprocess
|
||||
|
||||
logger.info("启动 dev server: %s (cwd=%s)", dev_command, cwd)
|
||||
proc = subprocess.Popen(
|
||||
dev_command,
|
||||
cwd=str(cwd),
|
||||
shell=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if not _wait_for_port(host, port, timeout=_startup_timeout()):
|
||||
err = ""
|
||||
if proc.stderr:
|
||||
err = proc.stderr.read().decode("utf-8", errors="replace")[-1000:]
|
||||
proc.kill()
|
||||
raise RuntimeError(
|
||||
f"dev server 在 {_startup_timeout()}s 内未就绪 ({base_url})。"
|
||||
f"{(' 日志: ' + err) if err else ''}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def run_browser_automation(text: str) -> BrowserResult:
|
||||
scenario = parse_browser_request(text)
|
||||
if scenario is None:
|
||||
raise RuntimeError("无法解析网页操作步骤")
|
||||
|
||||
started = await asyncio.to_thread(_ensure_dev_server, scenario.base_url)
|
||||
result = await asyncio.to_thread(run_browser_scenario_sync, scenario)
|
||||
result.started_dev_server = started
|
||||
return result
|
||||
|
||||
|
||||
def format_browser_caption(result: BrowserResult) -> str:
|
||||
lines = [
|
||||
"**网页操作完成**",
|
||||
f"> 场景:`{result.scenario_name or '自定义'}`",
|
||||
f"> 起始:`{result.base_url}`",
|
||||
f"> 最终:`{result.final_url}`",
|
||||
f"> 步骤数:{result.step_count}",
|
||||
]
|
||||
if result.started_dev_server:
|
||||
lines.append("> dev server:已自动启动")
|
||||
if result.step_log:
|
||||
lines.append("")
|
||||
lines.append("执行记录:")
|
||||
for item in result.step_log[-8:]:
|
||||
lines.append(f"- {item}")
|
||||
return "\n".join(lines)
|
||||
@@ -1,106 +0,0 @@
|
||||
"""通过 Cursor SDK 执行用户任务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
import env_config
|
||||
from bridge_manager import warm_cursor_bridge
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_cursor_lock = asyncio.Lock()
|
||||
|
||||
WECHAT_SYSTEM_PREFIX = """你是企业微信群里的 Skills 助手,正在回复群成员的消息。
|
||||
|
||||
要求:
|
||||
- 用简洁的中文回答(除非用户用其他语言提问)
|
||||
- 使用企业微信支持的 Markdown 子集(加粗、链接、列表;避免复杂表格)
|
||||
- 直接给出结论,不要冗长铺垫
|
||||
- 若任务涉及 skills.sh,可说明安装命令 `npx skills add owner/repo/skill-name`
|
||||
- **不要**在回复里写 `[图片]` 占位符;企微无法通过 Markdown 显示图片
|
||||
- 若用户要页面截图,请明确告知其发送:`截图` 或 `preview`(由 bot 自动发图)
|
||||
|
||||
用户任务:
|
||||
"""
|
||||
|
||||
|
||||
def _cursor_settings() -> dict[str, str | int]:
|
||||
timeout_raw = env_config.env("CURSOR_TIMEOUT", "600") or "600"
|
||||
return {
|
||||
"api_key": env_config.env("CURSOR_API_KEY"),
|
||||
"cwd": env_config.env("CURSOR_CWD", r"d:\LY\test\tech") or r"d:\LY\test\tech",
|
||||
"model": env_config.env("CURSOR_MODEL", "composer-2.5") or "composer-2.5",
|
||||
"timeout": int(timeout_raw),
|
||||
}
|
||||
|
||||
|
||||
def strip_mention(text: str) -> str:
|
||||
return re.sub(r"@\S+\s*", "", text).strip()
|
||||
|
||||
|
||||
def _build_prompt(task: str) -> str:
|
||||
return WECHAT_SYSTEM_PREFIX + task.strip()
|
||||
|
||||
|
||||
def execute_cursor_task_sync(task: str) -> str:
|
||||
from cursor_sdk import Agent, AgentOptions, CursorAgentError, LocalAgentOptions
|
||||
|
||||
settings = _cursor_settings()
|
||||
api_key = settings["api_key"]
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"未配置 CURSOR_API_KEY。请在 bot/.env 中设置,"
|
||||
"密钥见 https://cursor.com/dashboard/integrations"
|
||||
)
|
||||
|
||||
warm_cursor_bridge()
|
||||
|
||||
cwd = str(settings["cwd"])
|
||||
prompt = _build_prompt(task)
|
||||
logger.info("Cursor 执行任务 cwd=%s model=%s", cwd, settings["model"])
|
||||
|
||||
try:
|
||||
result = Agent.prompt(
|
||||
prompt,
|
||||
AgentOptions(
|
||||
api_key=api_key,
|
||||
model=settings["model"],
|
||||
local=LocalAgentOptions(cwd=cwd),
|
||||
),
|
||||
)
|
||||
except CursorAgentError as exc:
|
||||
raise RuntimeError(
|
||||
f"Cursor 启动失败:{exc.message}"
|
||||
+ ("(可重试)" if exc.is_retryable else "")
|
||||
) from exc
|
||||
|
||||
if result.status == "error":
|
||||
detail = result.result or "运行失败,无详细错误"
|
||||
raise RuntimeError(f"Cursor 执行失败:{detail}")
|
||||
|
||||
text = (result.result or "").strip()
|
||||
if not text:
|
||||
return "Cursor 已完成任务,但没有返回文本内容。"
|
||||
return text
|
||||
|
||||
|
||||
async def run_cursor_task(
|
||||
task: str,
|
||||
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> str:
|
||||
timeout = int(_cursor_settings()["timeout"])
|
||||
if on_progress:
|
||||
await on_progress("Cursor 正在执行任务,请稍候…")
|
||||
|
||||
async with _cursor_lock:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(execute_cursor_task_sync, task),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise RuntimeError(f"Cursor 执行超时(>{timeout}s)") from exc
|
||||
@@ -1,16 +0,0 @@
|
||||
"""加载 bot/.env,供各模块在 import 时统一读取环境变量。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_BOT_DIR = Path(__file__).resolve().parent
|
||||
load_dotenv(_BOT_DIR / ".env")
|
||||
load_dotenv(_BOT_DIR / ".env.local", override=True)
|
||||
|
||||
|
||||
def env(key: str, default: str | None = None) -> str | None:
|
||||
return os.getenv(key, default)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""从文本/Cursor 回复中解析本地截图路径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import env_config
|
||||
|
||||
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp")
|
||||
|
||||
|
||||
def _project_cwd() -> Path:
|
||||
raw = env_config.env("CURSOR_CWD", r"d:\LY\test\tech") or r"d:\LY\test\tech"
|
||||
return Path(raw).resolve()
|
||||
|
||||
|
||||
def _resolve_candidate(raw: str, cwd: Path) -> Path | None:
|
||||
cleaned = raw.strip().strip("`\"'[]()")
|
||||
if not cleaned or cleaned.startswith("http"):
|
||||
return None
|
||||
path = Path(cleaned)
|
||||
if not path.is_absolute():
|
||||
path = cwd / path
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
except OSError:
|
||||
return None
|
||||
if resolved.is_file() and resolved.suffix.lower() in IMAGE_SUFFIXES:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def find_image_paths(text: str) -> list[Path]:
|
||||
cwd = _project_cwd()
|
||||
seen: set[Path] = set()
|
||||
found: list[Path] = []
|
||||
|
||||
patterns = [
|
||||
r"(?:保存(?:至|到)|saved\s+to|screenshot\s*[::])\s*([^\s\n\]]+\.(?:png|jpe?g|webp))",
|
||||
r"([A-Za-z]:\\[^\s\n\]]+\.(?:png|jpe?g|webp))",
|
||||
r"([^\s\n\]]+\.(?:png|jpe?g|webp))",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
for match in re.finditer(pattern, text, re.IGNORECASE):
|
||||
path = _resolve_candidate(match.group(1), cwd)
|
||||
if path and path not in seen:
|
||||
seen.add(path)
|
||||
found.append(path)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def strip_fake_image_markdown(text: str) -> str:
|
||||
text = re.sub(r"^\s*\[图片\]\s*$", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
156
bot/main.py
156
bot/main.py
@@ -1,156 +0,0 @@
|
||||
"""企业微信智能机器人 · Skills 助手(skills 快查 + 截图预览 + Cursor 执行任务)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import env_config
|
||||
from bridge_manager import shutdown_cursor_bridge, warm_cursor_bridge
|
||||
from router import route_message, routing_mode
|
||||
from skills_service import handle_command, warm_feed_cache
|
||||
from wecom_media import reply_image, upload_image
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("skills-bot")
|
||||
|
||||
BOT_ID = env_config.env("WECOM_BOT_ID") or env_config.env("WECHAT_BOT_ID")
|
||||
BOT_SECRET = env_config.env("WECOM_BOT_SECRET") or env_config.env("WECHAT_BOT_SECRET")
|
||||
|
||||
|
||||
def _require_credentials() -> None:
|
||||
if not BOT_ID or not BOT_SECRET:
|
||||
print(
|
||||
"请设置环境变量 WECOM_BOT_ID 和 WECOM_BOT_SECRET\n"
|
||||
"(企业微信 → 智能机器人 → API 模式 → 长连接)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def create_client():
|
||||
from aibot import WSClient, WSClientOptions, generate_req_id
|
||||
|
||||
ws_client = WSClient(
|
||||
WSClientOptions(
|
||||
bot_id=BOT_ID,
|
||||
secret=BOT_SECRET,
|
||||
)
|
||||
)
|
||||
|
||||
@ws_client.on("authenticated")
|
||||
def on_authenticated():
|
||||
logger.info("企业微信长连接认证成功,路由模式=%s", routing_mode())
|
||||
cursor_key = env_config.env("CURSOR_API_KEY")
|
||||
if cursor_key:
|
||||
logger.info("CURSOR_API_KEY 已加载(%s…)", cursor_key[:8])
|
||||
try:
|
||||
warm_cursor_bridge()
|
||||
logger.info("Cursor bridge 预启动完成")
|
||||
except Exception as exc:
|
||||
logger.warning("Cursor bridge 预启动失败(Cursor 任务时会重试): %s", exc)
|
||||
else:
|
||||
logger.warning("CURSOR_API_KEY 未配置,Cursor 任务将失败")
|
||||
try:
|
||||
warm_feed_cache()
|
||||
logger.info("skills 数据预加载完成")
|
||||
except Exception as exc:
|
||||
logger.warning("skills 数据预加载失败: %s", exc)
|
||||
|
||||
@ws_client.on("event.enter_chat")
|
||||
async def on_enter_chat(frame):
|
||||
help_text = handle_command("help")
|
||||
extra = (
|
||||
"\n\n---\n"
|
||||
"**单页截图**:`preview` / `截图` / `预览 [路径或URL]`\n"
|
||||
"**网页操作**:自然语言多步操作,或 `browser 场景名`\n"
|
||||
"例:`访问登录页,输入账号密码,点击登录,点击智能体管理,截图`\n"
|
||||
"场景文件:`bot/scenarios/*.yaml`(可用 `browser xiaobao-agent-manage`)"
|
||||
)
|
||||
await ws_client.reply_welcome(
|
||||
frame,
|
||||
{
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": help_text + extra},
|
||||
},
|
||||
)
|
||||
|
||||
@ws_client.on("message.text")
|
||||
async def on_text(frame):
|
||||
body = frame.get("body", {})
|
||||
content = body.get("text", {}).get("content", "")
|
||||
logger.info("收到消息: %s", content)
|
||||
|
||||
stream_id = generate_req_id("stream")
|
||||
last_progress = ""
|
||||
|
||||
async def on_progress(message: str) -> None:
|
||||
nonlocal last_progress
|
||||
if message != last_progress:
|
||||
last_progress = message
|
||||
await ws_client.reply_stream(frame, stream_id, message, False)
|
||||
|
||||
await ws_client.reply_stream(frame, stream_id, "收到,正在处理…", False)
|
||||
|
||||
try:
|
||||
result = await route_message(content, on_progress=on_progress)
|
||||
reply = result.text
|
||||
logger.info(
|
||||
"回复来源: %s, 文本长度=%d, 图片=%s",
|
||||
result.source,
|
||||
len(reply),
|
||||
result.image_path or "-",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("处理失败")
|
||||
reply = f"处理失败:{exc}"
|
||||
result = None
|
||||
|
||||
if len(reply) > 3800:
|
||||
reply = reply[:3800] + "\n\n> …内容已截断"
|
||||
|
||||
await ws_client.reply_stream(frame, stream_id, reply, True)
|
||||
|
||||
if result and result.image_path:
|
||||
try:
|
||||
media_id = await upload_image(ws_client, result.image_path)
|
||||
await reply_image(ws_client, frame, media_id)
|
||||
logger.info("图片已发送到企微: %s", result.image_path)
|
||||
except Exception as exc:
|
||||
logger.exception("发送图片失败")
|
||||
await ws_client.reply(
|
||||
frame,
|
||||
{
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": f"截图文件:`{result.image_path}`\n发图失败:{exc}\n\n请确认 bot 已重启,或发送 `截图` 重试。",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@ws_client.on("error")
|
||||
def on_error(error):
|
||||
logger.error("连接错误: %s", error)
|
||||
|
||||
@ws_client.on("disconnected")
|
||||
def on_disconnected(reason):
|
||||
logger.warning("连接断开: %s", reason)
|
||||
|
||||
return ws_client
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import atexit
|
||||
|
||||
atexit.register(shutdown_cursor_bridge)
|
||||
_require_credentials()
|
||||
client = create_client()
|
||||
logger.info("启动 Skills 助手,Bot ID=%s…", BOT_ID[:8] if BOT_ID else "?")
|
||||
client.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,255 +0,0 @@
|
||||
"""在 CURSOR_CWD 启动/访问前端并截图(单页,不含多步操作)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import env_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCREENSHOT_DIR = Path(__file__).resolve().parent / ".cache" / "screenshots"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreviewResult:
|
||||
url: str
|
||||
screenshot_path: Path
|
||||
started_dev_server: bool
|
||||
final_url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreviewRequest:
|
||||
url: str | None
|
||||
port: int | None
|
||||
|
||||
|
||||
def _project_cwd() -> Path:
|
||||
raw = env_config.env("CURSOR_CWD", r"d:\LY\test\tech") or r"d:\LY\test\tech"
|
||||
return Path(raw).resolve()
|
||||
|
||||
|
||||
def _preview_port() -> int:
|
||||
raw = env_config.env("PREVIEW_PORT", "5173") or "5173"
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _startup_timeout() -> int:
|
||||
raw = env_config.env("PREVIEW_STARTUP_TIMEOUT", "120") or "120"
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _dev_command() -> str:
|
||||
return env_config.env("PREVIEW_DEV_COMMAND", "npm run dev") or "npm run dev"
|
||||
|
||||
|
||||
def parse_preview_command(text: str) -> tuple[str | None, int | None] | None:
|
||||
raw = re.sub(r"@\S+\s*", "", text).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
m = re.match(
|
||||
r"^(preview|截图|预览|截屏)(?:\s+(https?://\S+|/\S*))?(?:\s+(\d{2,5}))?$",
|
||||
raw,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
url_part = m.group(2)
|
||||
port_part = m.group(3)
|
||||
port = int(port_part) if port_part else None
|
||||
|
||||
if url_part and url_part.startswith("/"):
|
||||
port = port or _preview_port()
|
||||
return f"http://127.0.0.1:{port}{url_part}", port
|
||||
|
||||
return url_part, port
|
||||
|
||||
|
||||
def resolve_preview_request(text: str) -> PreviewRequest | None:
|
||||
explicit = parse_preview_command(text)
|
||||
if explicit is not None:
|
||||
url_override, port_override = explicit
|
||||
return PreviewRequest(url=url_override, port=port_override)
|
||||
|
||||
if not is_preview_intent(text):
|
||||
return None
|
||||
|
||||
url_override = extract_url_from_text(text)
|
||||
if not url_override:
|
||||
env_url = env_config.env("PREVIEW_URL")
|
||||
url_override = env_url.strip() if env_url else f"http://127.0.0.1:{_preview_port()}/"
|
||||
|
||||
return PreviewRequest(url=url_override, port=None)
|
||||
|
||||
|
||||
_PREVIEW_INTENT = re.compile(
|
||||
r"^(preview|截图|预览|截屏)\b|"
|
||||
r"(页面预览|运行.*(前端|项目|页面)|"
|
||||
r"打开.*(前端|页面|项目)|"
|
||||
r"访问.*(并)?.*(截图|截屏)|"
|
||||
r"启动.*(前端|项目|dev|服务).*(截图|截屏)?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_preview_intent(text: str) -> bool:
|
||||
raw = re.sub(r"@\S+\s*", "", text).strip()
|
||||
if parse_preview_command(text) is not None:
|
||||
return True
|
||||
return bool(_PREVIEW_INTENT.search(raw))
|
||||
|
||||
|
||||
def extract_url_from_text(text: str) -> str | None:
|
||||
raw = re.sub(r"@\S+\s*", "", text)
|
||||
match = re.search(
|
||||
r"(https?://[^\s\]`\"']+|localhost:\d+[/\w\-./]*)",
|
||||
raw,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
url = match.group(1).rstrip(".,,。")
|
||||
if url.lower().startswith("localhost"):
|
||||
url = "http://" + url
|
||||
return url
|
||||
|
||||
|
||||
def _capture_screenshot_sync(url: str, output: Path) -> str:
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1280, "height": 720})
|
||||
page.goto(url, wait_until="networkidle", timeout=60_000)
|
||||
page.wait_for_timeout(1500)
|
||||
page.screenshot(path=str(output), full_page=False, type="png")
|
||||
final_url = page.url
|
||||
browser.close()
|
||||
return final_url
|
||||
|
||||
|
||||
def _wait_for_port(host: str, port: int, timeout: int) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_target_url(url_override: str | None, port_override: int | None) -> tuple[str, str | None]:
|
||||
if url_override:
|
||||
parsed = urlparse(url_override)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return url_override, None
|
||||
raise RuntimeError(f"无效 URL:{url_override}")
|
||||
|
||||
env_url = env_config.env("PREVIEW_URL")
|
||||
if env_url:
|
||||
return env_url.strip(), None
|
||||
|
||||
port = port_override or _preview_port()
|
||||
cwd = _project_cwd()
|
||||
dev_script = _package_dev_script(cwd)
|
||||
base = f"http://127.0.0.1:{port}/"
|
||||
return base, dev_script
|
||||
|
||||
|
||||
def _package_dev_script(cwd: Path) -> str | None:
|
||||
pkg = cwd / "package.json"
|
||||
if not pkg.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(pkg.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
scripts = data.get("scripts") or {}
|
||||
for key in ("dev", "preview", "start"):
|
||||
if scripts.get(key):
|
||||
cmd = _dev_command()
|
||||
if key != "dev" and cmd == "npm run dev":
|
||||
return f"npm run {key}"
|
||||
return cmd
|
||||
return None
|
||||
|
||||
|
||||
def _capture_preview_sync(url: str, dev_command: str | None) -> PreviewResult:
|
||||
cwd = _project_cwd()
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or "127.0.0.1"
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
|
||||
dev_proc: subprocess.Popen | None = None
|
||||
started = False
|
||||
|
||||
if dev_command:
|
||||
if _wait_for_port(host, port, timeout=3):
|
||||
logger.info("检测到端口 %s 已监听,跳过启动 dev server", port)
|
||||
else:
|
||||
logger.info("启动 dev server: %s (cwd=%s)", dev_command, cwd)
|
||||
dev_proc = subprocess.Popen(
|
||||
dev_command,
|
||||
cwd=str(cwd),
|
||||
shell=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
started = True
|
||||
if not _wait_for_port(host, port, timeout=_startup_timeout()):
|
||||
err = ""
|
||||
if dev_proc.stderr:
|
||||
err = dev_proc.stderr.read().decode("utf-8", errors="replace")[-1000:]
|
||||
raise RuntimeError(
|
||||
f"dev server 在 {_startup_timeout()}s 内未就绪 ({url})。"
|
||||
f"{(' 日志: ' + err) if err else ''}"
|
||||
)
|
||||
else:
|
||||
if not _wait_for_port(host, port, timeout=5):
|
||||
raise RuntimeError(
|
||||
f"无法访问 {url}。请在 CURSOR_CWD 放置前端项目,"
|
||||
"或先手动启动 dev server,或设置 PREVIEW_URL。"
|
||||
)
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
output = SCREENSHOT_DIR / f"preview-{stamp}.png"
|
||||
|
||||
try:
|
||||
final_url = _capture_screenshot_sync(url, output)
|
||||
finally:
|
||||
if dev_proc and dev_proc.poll() is None:
|
||||
dev_proc.terminate()
|
||||
try:
|
||||
dev_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
dev_proc.kill()
|
||||
|
||||
return PreviewResult(
|
||||
url=url,
|
||||
screenshot_path=output,
|
||||
started_dev_server=started,
|
||||
final_url=final_url,
|
||||
)
|
||||
|
||||
|
||||
async def capture_preview(
|
||||
url_override: str | None = None,
|
||||
port_override: int | None = None,
|
||||
) -> PreviewResult:
|
||||
url, dev_command = _resolve_target_url(url_override, port_override)
|
||||
return await asyncio.to_thread(_capture_preview_sync, url, dev_command)
|
||||
@@ -1,7 +0,0 @@
|
||||
wecom-aibot-python-sdk>=1.0.2
|
||||
python-dotenv>=1.0.0
|
||||
httpx>=0.27.0
|
||||
certifi>=2024.0.0
|
||||
cursor-sdk>=0.1.0
|
||||
playwright>=1.49.0
|
||||
PyYAML>=6.0.0
|
||||
109
bot/router.py
109
bot/router.py
@@ -1,109 +0,0 @@
|
||||
"""消息路由:skills 快查 / 网页操作 / 截图预览 / Cursor 通用任务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import env_config
|
||||
from browser_parser import is_browser_intent, parse_browser_request
|
||||
from browser_service import format_browser_caption, run_browser_automation
|
||||
from cursor_runner import run_cursor_task, strip_mention
|
||||
from image_extract import find_image_paths, strip_fake_image_markdown
|
||||
from preview_service import capture_preview, is_preview_intent, resolve_preview_request
|
||||
from skills_service import handle_command, parse_command
|
||||
from bot_types import RouteResult
|
||||
|
||||
|
||||
def routing_mode() -> str:
|
||||
return (env_config.env("ROUTING_MODE", "hybrid") or "hybrid").lower()
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
return re.sub(r"@\S+\s*", "", text).strip().lower()
|
||||
|
||||
|
||||
def is_skills_fast_command(text: str) -> bool:
|
||||
raw = _normalize(text)
|
||||
if not raw:
|
||||
return True
|
||||
if raw in {"help", "帮助", "?", "h"}:
|
||||
return True
|
||||
|
||||
cmd = parse_command(text)
|
||||
if cmd.kind in {"help", "list", "detail"}:
|
||||
return True
|
||||
if cmd.kind == "search" and re.match(r"^(search|搜索|find|查)\s+", raw):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _run_browser(text: str, on_progress=None) -> RouteResult:
|
||||
if parse_browser_request(text) is None:
|
||||
raise RuntimeError("无法解析网页操作步骤")
|
||||
|
||||
if on_progress:
|
||||
await on_progress("正在按步骤执行网页操作…")
|
||||
|
||||
result = await run_browser_automation(text)
|
||||
return RouteResult(
|
||||
source="browser",
|
||||
text=format_browser_caption(result),
|
||||
image_path=str(result.screenshot_path),
|
||||
)
|
||||
|
||||
|
||||
async def _run_preview(text: str, on_progress=None) -> RouteResult:
|
||||
preview_req = resolve_preview_request(text)
|
||||
if preview_req is None:
|
||||
raise RuntimeError("无法解析截图请求")
|
||||
|
||||
if on_progress:
|
||||
await on_progress(f"正在访问并截图:{preview_req.url or '默认地址'}…")
|
||||
|
||||
result = await capture_preview(preview_req.url, preview_req.port)
|
||||
caption = (
|
||||
f"**页面预览**\n"
|
||||
f"> URL:`{result.final_url or result.url}`\n"
|
||||
f"> 项目:`{env_config.env('CURSOR_CWD', '')}`\n"
|
||||
f"> dev server:{'已自动启动' if result.started_dev_server else '使用已有服务'}"
|
||||
)
|
||||
return RouteResult(
|
||||
source="preview",
|
||||
text=caption,
|
||||
image_path=str(result.screenshot_path),
|
||||
)
|
||||
|
||||
|
||||
async def route_message(text: str, on_progress=None) -> RouteResult:
|
||||
task = strip_mention(text)
|
||||
if not task:
|
||||
return RouteResult("skills", handle_command("help"))
|
||||
|
||||
if is_browser_intent(text):
|
||||
return await _run_browser(text, on_progress=on_progress)
|
||||
|
||||
if resolve_preview_request(text) is not None:
|
||||
return await _run_preview(text, on_progress=on_progress)
|
||||
|
||||
mode = routing_mode()
|
||||
if mode == "skills":
|
||||
return RouteResult("skills", handle_command(text))
|
||||
|
||||
if mode == "cursor" or not is_skills_fast_command(text):
|
||||
reply = await run_cursor_task(task, on_progress=on_progress)
|
||||
reply = strip_fake_image_markdown(reply)
|
||||
|
||||
image_path: str | None = None
|
||||
paths = find_image_paths(reply)
|
||||
if paths:
|
||||
image_path = str(paths[0])
|
||||
elif is_preview_intent(text) or is_browser_intent(text):
|
||||
if on_progress:
|
||||
await on_progress("未找到截图文件,改用 Playwright 自动执行…")
|
||||
if is_browser_intent(text):
|
||||
return await _run_browser(text, on_progress=on_progress)
|
||||
return await _run_preview(text, on_progress=on_progress)
|
||||
|
||||
return RouteResult("cursor", reply, image_path=image_path)
|
||||
|
||||
return RouteResult("skills", handle_command(text))
|
||||
@@ -1,17 +0,0 @@
|
||||
name: xiaobao-agent-manage
|
||||
description: 登录后打开智能体管理并截图
|
||||
steps:
|
||||
- goto: /login
|
||||
- fill:
|
||||
field: 账号
|
||||
value: "{{PREVIEW_LOGIN_USER}}"
|
||||
- fill:
|
||||
field: 密码
|
||||
value: "{{PREVIEW_LOGIN_PASSWORD}}"
|
||||
- click: 登录
|
||||
- wait:
|
||||
url: "**/app/**"
|
||||
timeout: 60000
|
||||
- click: 智能体管理
|
||||
- wait: 1500
|
||||
- screenshot
|
||||
@@ -1,317 +0,0 @@
|
||||
"""skills.sh 数据查询与命令解析。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FEED_URLS = [
|
||||
# jsDelivr 在国内通常比 raw.githubusercontent.com 更稳定
|
||||
"https://cdn.jsdelivr.net/gh/NeverSight/skills.sh_feed@main/data/feed.json",
|
||||
"https://raw.githubusercontent.com/NeverSight/skills.sh_feed/main/data/feed.json",
|
||||
]
|
||||
CACHE_TTL_SECONDS = 600
|
||||
CACHE_DIR = Path(__file__).resolve().parent / ".cache"
|
||||
CACHE_FILE = CACHE_DIR / "feed.json"
|
||||
|
||||
_cache: dict[str, Any] = {"data": None, "fetched_at": 0.0}
|
||||
|
||||
Board = Literal["trending", "hot", "all"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Command:
|
||||
kind: Literal["help", "list", "search", "detail"]
|
||||
board: Board = "trending"
|
||||
limit: int = 10
|
||||
query: str = ""
|
||||
|
||||
|
||||
def _fetch_json(url: str) -> dict[str, Any]:
|
||||
headers = {
|
||||
"User-Agent": "skills-hot-bot/1.0",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
with httpx.Client(
|
||||
timeout=httpx.Timeout(20.0, connect=10.0),
|
||||
verify=certifi.where(),
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
resp = client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _load_disk_cache() -> dict[str, Any] | None:
|
||||
if not CACHE_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("读取本地缓存失败: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _save_disk_cache(data: dict[str, Any]) -> None:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CACHE_FILE.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def load_feed(force: bool = False) -> dict[str, Any]:
|
||||
now = time.time()
|
||||
if not force and _cache["data"] and now - _cache["fetched_at"] < CACHE_TTL_SECONDS:
|
||||
return _cache["data"]
|
||||
|
||||
errors: list[str] = []
|
||||
for url in FEED_URLS:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
data = _fetch_json(url)
|
||||
_cache["data"] = data
|
||||
_cache["fetched_at"] = now
|
||||
_save_disk_cache(data)
|
||||
logger.info("skills 数据已更新: %s", url)
|
||||
return data
|
||||
except Exception as exc:
|
||||
msg = f"{url} (#{attempt + 1}): {exc}"
|
||||
errors.append(msg)
|
||||
logger.debug("拉取失败 %s", msg)
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
|
||||
stale = _load_disk_cache()
|
||||
if stale:
|
||||
logger.warning("网络不可用,回退到本地缓存")
|
||||
_cache["data"] = stale
|
||||
_cache["fetched_at"] = now
|
||||
return stale
|
||||
|
||||
raise RuntimeError(f"无法获取 skills 数据。最近错误: {errors[-1]}")
|
||||
|
||||
|
||||
def warm_feed_cache() -> None:
|
||||
"""启动时预加载,避免首条消息才触发网络请求。"""
|
||||
load_feed(force=True)
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
text = re.sub(r"@\S+\s*", "", text)
|
||||
return text.strip().lower()
|
||||
|
||||
|
||||
def _parse_limit(raw: str | None, default: int = 10) -> int:
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
n = int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
return max(1, min(n, 30))
|
||||
|
||||
|
||||
def _match_list(raw: str, board: Board, aliases: str) -> Command | None:
|
||||
m = re.match(rf"^({aliases})(?:\s+top)?\s*(\d+)?$", raw)
|
||||
if m:
|
||||
return Command(kind="list", board=board, limit=_parse_limit(m.group(2)))
|
||||
m = re.match(rf"^(查|查询)\s+({aliases})(?:\s+top)?\s*(\d+)?$", raw)
|
||||
if m:
|
||||
return Command(kind="list", board=board, limit=_parse_limit(m.group(3)))
|
||||
return None
|
||||
|
||||
|
||||
def parse_command(text: str) -> Command:
|
||||
raw = _normalize_text(text)
|
||||
if not raw or raw in {"help", "帮助", "?", "h"}:
|
||||
return Command(kind="help")
|
||||
|
||||
for board, aliases in (
|
||||
("trending", "trending|趋势|top"),
|
||||
("hot", "hot|实时|热门"),
|
||||
("all", "all|总榜|alltime|all-time"),
|
||||
):
|
||||
cmd = _match_list(raw, board, aliases)
|
||||
if cmd:
|
||||
return cmd
|
||||
|
||||
m = re.match(r"^(search|搜索|find|查)\s+(.+)$", raw)
|
||||
if m:
|
||||
return Command(kind="search", query=m.group(2).strip(), limit=5)
|
||||
|
||||
m = re.match(r"^(detail|详情|skill|info)\s+(.+)$", raw)
|
||||
if m:
|
||||
return Command(kind="detail", query=m.group(2).strip())
|
||||
|
||||
if raw.startswith("trending") or raw.startswith("趋势"):
|
||||
parts = raw.split(maxsplit=1)
|
||||
return Command(kind="list", board="trending", limit=_parse_limit(parts[1] if len(parts) > 1 else None))
|
||||
|
||||
return Command(kind="search", query=raw, limit=5)
|
||||
|
||||
|
||||
def _format_installs(n: int | float) -> str:
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.1f}M"
|
||||
if n >= 1_000:
|
||||
return f"{n / 1_000:.1f}K"
|
||||
return str(int(n))
|
||||
|
||||
|
||||
def _board_items(feed: dict[str, Any], board: Board) -> list[dict[str, Any]]:
|
||||
key = {"trending": "topTrending", "hot": "topHot", "all": "topAllTime"}[board]
|
||||
return feed.get(key, [])
|
||||
|
||||
|
||||
def _board_title(board: Board) -> str:
|
||||
return {
|
||||
"trending": "Trending(近期增长)",
|
||||
"hot": "Hot(实时热度)",
|
||||
"all": "All Time(总安装榜)",
|
||||
}[board]
|
||||
|
||||
|
||||
def format_list(board: Board, limit: int) -> str:
|
||||
feed = load_feed()
|
||||
items = _board_items(feed, board)[:limit]
|
||||
updated = feed.get("updatedAt", "未知")[:10]
|
||||
|
||||
lines = [
|
||||
f"**skills.sh {_board_title(board)} Top {limit}**",
|
||||
f"> 数据更新:{updated}",
|
||||
"",
|
||||
]
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
title = item.get("title", "?")
|
||||
source = item.get("source", "?")
|
||||
installs = _format_installs(item.get("installs", 0))
|
||||
desc = item.get("description", "")
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
link = item.get("link", "")
|
||||
lines.append(f"{i}. **{title}** · {installs}")
|
||||
lines.append(f" `{source}`")
|
||||
if desc:
|
||||
lines.append(f" {desc}")
|
||||
if link:
|
||||
lines.append(f" [查看]({link})")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def format_search(query: str, limit: int) -> str:
|
||||
feed = load_feed()
|
||||
q = query.lower()
|
||||
seen: set[str] = set()
|
||||
matches: list[dict[str, Any]] = []
|
||||
|
||||
for board in ("topTrending", "topHot", "topAllTime"):
|
||||
for item in feed.get(board, []):
|
||||
item_id = item.get("id") or item.get("title", "")
|
||||
if item_id in seen:
|
||||
continue
|
||||
haystack = " ".join(
|
||||
[
|
||||
item.get("title", ""),
|
||||
item.get("source", ""),
|
||||
item.get("description", ""),
|
||||
]
|
||||
).lower()
|
||||
if q in haystack:
|
||||
seen.add(item_id)
|
||||
matches.append(item)
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
|
||||
if not matches:
|
||||
return f"未找到与 **{query}** 相关的 skill。\n\n试试:`trending 10` / `hot 10` / `搜索 react`"
|
||||
|
||||
lines = [f"**搜索「{query}」** 共 {len(matches)} 条", ""]
|
||||
for i, item in enumerate(matches, 1):
|
||||
title = item.get("title", "?")
|
||||
source = item.get("source", "?")
|
||||
installs = _format_installs(item.get("installs", 0))
|
||||
link = item.get("link", "")
|
||||
lines.append(f"{i}. **{title}** · {installs} · `{source}`")
|
||||
if link:
|
||||
lines.append(f" [查看]({link})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_detail(name: str) -> str:
|
||||
feed = load_feed()
|
||||
q = name.lower().strip()
|
||||
best: dict[str, Any] | None = None
|
||||
|
||||
for board in ("topTrending", "topHot", "topAllTime"):
|
||||
for item in feed.get(board, []):
|
||||
title = (item.get("title") or "").lower()
|
||||
item_id = (item.get("id") or "").lower()
|
||||
if title == q or q in title or q in item_id:
|
||||
if best is None or item.get("installs", 0) > best.get("installs", 0):
|
||||
best = item
|
||||
|
||||
if not best:
|
||||
return f"未找到 skill:**{name}**\n\n试试:`搜索 {name}`"
|
||||
|
||||
desc = best.get("description", "无描述")
|
||||
return "\n".join(
|
||||
[
|
||||
f"**{best.get('title', '?')}**",
|
||||
f"`{best.get('source', '?')}`",
|
||||
f"安装量:**{_format_installs(best.get('installs', 0))}**",
|
||||
"",
|
||||
desc,
|
||||
"",
|
||||
f"[skills.sh 详情]({best.get('link', 'https://skills.sh')})",
|
||||
"",
|
||||
f"安装:`npx skills add {best.get('source', '')}/{best.get('title', '')}`",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def format_help() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"**Skills 助手 · 命令帮助**",
|
||||
"",
|
||||
"`trending 10` / `趋势 10` — 近期增长榜",
|
||||
"`hot 10` / `实时 10` — 实时热度榜",
|
||||
"`all 10` / `总榜 10` — 历史总安装榜",
|
||||
"`搜索 react` / `search tdd` — 关键词搜索",
|
||||
"`详情 find-skills` — 查看单个 skill",
|
||||
"`preview` / `截图` / `预览` — 单页截图",
|
||||
"`browser 场景名` — 执行 YAML 场景(见 bot/scenarios/)",
|
||||
"自然语言 — 如:访问登录页,输入账号密码,点击登录,点击智能体管理,截图",
|
||||
"`preview /about 5173` — 指定路径和端口",
|
||||
"",
|
||||
"示例:",
|
||||
"• trending top10",
|
||||
"• 查 grill",
|
||||
"• 详情 remotion-render",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def handle_command(text: str) -> str:
|
||||
cmd = parse_command(text)
|
||||
if cmd.kind == "help":
|
||||
return format_help()
|
||||
if cmd.kind == "list":
|
||||
return format_list(cmd.board, cmd.limit)
|
||||
if cmd.kind == "search":
|
||||
return format_search(cmd.query, cmd.limit)
|
||||
if cmd.kind == "detail":
|
||||
return format_detail(cmd.query)
|
||||
return format_help()
|
||||
@@ -1,96 +0,0 @@
|
||||
"""企业微信 API 模式:上传图片并回复。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aibot import generate_req_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 512 * 1024
|
||||
MAX_IMAGE_BYTES = 9 * 1024 * 1024
|
||||
|
||||
|
||||
def _ensure_image_size(path: Path) -> bytes:
|
||||
data = path.read_bytes()
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise RuntimeError(
|
||||
f"截图过大({len(data) // 1024}KB),请缩小页面或使用 viewport 截图(上限 9MB)"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _response_body(frame: dict[str, Any]) -> dict[str, Any]:
|
||||
if frame.get("errcode", 0) != 0:
|
||||
raise RuntimeError(
|
||||
f"企微接口错误 errcode={frame.get('errcode')} errmsg={frame.get('errmsg')}"
|
||||
)
|
||||
body = frame.get("body")
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
async def upload_image(ws_client: Any, image_path: str | Path) -> str:
|
||||
path = Path(image_path)
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"截图不存在: {path}")
|
||||
|
||||
data = _ensure_image_size(path)
|
||||
md5 = hashlib.md5(data).hexdigest()
|
||||
chunks = [data[i : i + CHUNK_SIZE] for i in range(0, len(data), CHUNK_SIZE)]
|
||||
total_chunks = len(chunks)
|
||||
|
||||
manager = ws_client._ws_manager
|
||||
|
||||
init_frame = await manager.send_reply(
|
||||
generate_req_id("upload_init"),
|
||||
{
|
||||
"type": "image",
|
||||
"filename": path.name,
|
||||
"total_size": len(data),
|
||||
"total_chunks": total_chunks,
|
||||
"md5": md5,
|
||||
},
|
||||
"aibot_upload_media_init",
|
||||
)
|
||||
upload_id = _response_body(init_frame).get("upload_id")
|
||||
if not upload_id:
|
||||
raise RuntimeError("上传初始化失败:未返回 upload_id")
|
||||
|
||||
for index, chunk in enumerate(chunks):
|
||||
chunk_frame = await manager.send_reply(
|
||||
generate_req_id("upload_chunk"),
|
||||
{
|
||||
"upload_id": upload_id,
|
||||
"chunk_index": index,
|
||||
"base64_data": base64.b64encode(chunk).decode("ascii"),
|
||||
},
|
||||
"aibot_upload_media_chunk",
|
||||
)
|
||||
_response_body(chunk_frame)
|
||||
|
||||
finish_frame = await manager.send_reply(
|
||||
generate_req_id("upload_finish"),
|
||||
{"upload_id": upload_id},
|
||||
"aibot_upload_media_finish",
|
||||
)
|
||||
media_id = _response_body(finish_frame).get("media_id")
|
||||
if not media_id:
|
||||
raise RuntimeError("上传完成但未返回 media_id")
|
||||
|
||||
logger.info("图片已上传 media_id=%s…", str(media_id)[:12])
|
||||
return str(media_id)
|
||||
|
||||
|
||||
async def reply_image(ws_client: Any, frame: dict[str, Any], media_id: str) -> None:
|
||||
await ws_client.reply(
|
||||
frame,
|
||||
{
|
||||
"msgtype": "image",
|
||||
"image": {"media_id": media_id},
|
||||
},
|
||||
)
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
|
||||
from daily.generate import main as generate_main
|
||||
from daily.scheduler import main as schedule_main
|
||||
from daily.webhook import main as push_main
|
||||
|
||||
|
||||
@@ -14,7 +15,14 @@ def main() -> int:
|
||||
return generate_main()
|
||||
if cmd in {"push", "send", "webhook"}:
|
||||
return push_main(sys.argv[2:])
|
||||
print(f"未知命令: {cmd}\n用法: python -m daily [generate|push] [report_path]", file=sys.stderr)
|
||||
if cmd in {"schedule", "scheduler", "daemon"}:
|
||||
return schedule_main()
|
||||
print(
|
||||
f"未知命令: {cmd}\n"
|
||||
"用法: python -m daily [generate|push|schedule] [report_path]\n"
|
||||
" python -m daily schedule [--once] [--dry-run]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -59,11 +59,41 @@ def _extract_markdown(text: str) -> str:
|
||||
|
||||
|
||||
def analyze_trends(llm_input: dict[str, Any], *, date_str: str) -> dict[str, Any] | None:
|
||||
from daily.config import theme_ban_days
|
||||
from daily.narrative_axis import (
|
||||
enforce_narrative_axis,
|
||||
load_recent_axes,
|
||||
load_recent_theme_summaries,
|
||||
pick_narrative_axis,
|
||||
)
|
||||
|
||||
skill = _load_skill()
|
||||
featured_note = ""
|
||||
if llm_input.get("featured_pick"):
|
||||
featured_note = (
|
||||
"\n输入已含 **featured_pick**(编辑指定今日首推);"
|
||||
"top_picks.skill 必须以 featured_pick 为准;"
|
||||
"why/opening 不得向读者提及「编辑指定」。\n"
|
||||
)
|
||||
used_axes = set(load_recent_axes(date_str))
|
||||
axis = pick_narrative_axis(used_axes)
|
||||
llm_input["required_narrative_axis"] = axis
|
||||
llm_input["narrative_axis"] = axis
|
||||
theme_ban = load_recent_theme_summaries(date_str, theme_ban_days())
|
||||
ban_note = ""
|
||||
if theme_ban:
|
||||
ban_note = (
|
||||
"\n近几日已用过的主题/导语(请软避开同类开场,勿原样复用):\n- "
|
||||
+ "\n- ".join(theme_ban)
|
||||
+ "\n"
|
||||
)
|
||||
system = (
|
||||
f"{skill}\n\n"
|
||||
f"{featured_note}"
|
||||
f"{ban_note}"
|
||||
"当前执行 **Step 1:趋势分析**。\n"
|
||||
"只输出 trends JSON(headline, opening, themes, top_picks, signals),不要 Markdown。"
|
||||
f"**required_narrative_axis** = `{axis}`;输出 JSON 必须含 `narrative_axis` 且等于该值。\n"
|
||||
"只输出 trends JSON(headline, opening, themes, top_picks, signals, narrative_axis),不要 Markdown。"
|
||||
)
|
||||
user = json.dumps(llm_input, ensure_ascii=False, indent=2)
|
||||
try:
|
||||
@@ -77,8 +107,9 @@ def analyze_trends(llm_input: dict[str, Any], *, date_str: str) -> dict[str, Any
|
||||
if not parsed.get("headline") and not parsed.get("opening"):
|
||||
logger.warning("Agent Step1 JSON 无效")
|
||||
return None
|
||||
parsed = enforce_narrative_axis(parsed, axis)
|
||||
save_json(trends_json_path(date_str), parsed)
|
||||
logger.info("Agent Step1 完成:%s", parsed.get("headline", "?"))
|
||||
logger.info("Agent Step1 完成:%s [%s]", parsed.get("headline", "?"), axis)
|
||||
return parsed
|
||||
|
||||
|
||||
@@ -91,8 +122,17 @@ def write_wecom_report(
|
||||
updated: str,
|
||||
) -> str | None:
|
||||
skill = _load_skill()
|
||||
featured_note = ""
|
||||
if llm_input.get("featured_pick"):
|
||||
featured_note = (
|
||||
"\n输入 data 已含 **featured_pick**;"
|
||||
"今日首推区块须使用 featured_pick.why_today;"
|
||||
"链接行用 Markdown [标题](URL),勿用反引号裸 URL;"
|
||||
"读者可见文案不得出现「编辑指定」等元信息。\n"
|
||||
)
|
||||
system = (
|
||||
f"{skill}\n\n"
|
||||
f"{featured_note}"
|
||||
"当前执行 **Step 2:撰写企微早报**。\n"
|
||||
f"日期={date_str},时间={time_str},数据截至={updated}。\n"
|
||||
"只输出企微 Markdown 正文,不要代码块,不要 JSON。"
|
||||
|
||||
163
daily/board_history.py
Normal file
163
daily/board_history.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""企微展示历史:读写 data.wecom_shown_keys,与 movement_baseline 严格分离。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from daily.config import OUTPUT_DIR, board_dedup_days
|
||||
from daily.delta import RECENT_BOARD_KEYS, skill_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BOARD_KEYS = RECENT_BOARD_KEYS
|
||||
|
||||
_GITHUB_REPO_RE = re.compile(r"github\.com/([\w.-]+/[\w.-]+)", re.I)
|
||||
_SKILL_SH_RE = re.compile(r"skills\.sh/([\w.-]+/[\w.-]+(?:/[\w.-]+)?)", re.I)
|
||||
|
||||
# 与企微正文榜单标题对齐;顺序用于切分相邻 section
|
||||
_WECOM_SECTION_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
("skills_trending", re.compile(r"Skills\s+Trending", re.I)),
|
||||
("skills_hot", re.compile(r"Skills\s+Hot", re.I)),
|
||||
("github_trending", re.compile(r"GitHub\s+Trending", re.I)),
|
||||
("github_emerging", re.compile(r"GitHub\s+新兴", re.I)),
|
||||
("github_topic", re.compile(r"Topic\s+", re.I)),
|
||||
)
|
||||
|
||||
|
||||
def extract_shown_keys(board: str, items: list[dict[str, Any]]) -> list[str]:
|
||||
"""从最终展示 items 抽取稳定 identity key。
|
||||
|
||||
Skills 榜同时写入 skill id 与 source,便于周去重按仓屏蔽。
|
||||
"""
|
||||
keys: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in items:
|
||||
if board.startswith("skills_"):
|
||||
candidates = [skill_id(item), str(item.get("source") or "").strip()]
|
||||
else:
|
||||
candidates = [str(item.get("repo") or "")]
|
||||
for key in candidates:
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
keys.append(key)
|
||||
return keys
|
||||
|
||||
|
||||
def _keys_from_board_items(board: str, data: dict[str, Any]) -> set[str]:
|
||||
if board == "github_topic":
|
||||
topic = data.get("github_topic") or {}
|
||||
items = topic.get("repos") if isinstance(topic, dict) else []
|
||||
else:
|
||||
items = data.get(board) or []
|
||||
if not isinstance(items, list):
|
||||
return set()
|
||||
return set(extract_shown_keys(board, items))
|
||||
|
||||
|
||||
def parse_wecom_shown_keys(md: str) -> dict[str, set[str]]:
|
||||
"""从企微 Markdown 按榜单 section 解析已展示 keys(冷启动兼容)。"""
|
||||
out: dict[str, set[str]] = {board: set() for board in BOARD_KEYS}
|
||||
if not (md or "").strip():
|
||||
return out
|
||||
|
||||
hits: list[tuple[int, str]] = []
|
||||
for board, pattern in _WECOM_SECTION_PATTERNS:
|
||||
for match in pattern.finditer(md):
|
||||
hits.append((match.start(), board))
|
||||
if not hits:
|
||||
return out
|
||||
hits.sort(key=lambda x: x[0])
|
||||
|
||||
for idx, (start, board) in enumerate(hits):
|
||||
end = hits[idx + 1][0] if idx + 1 < len(hits) else len(md)
|
||||
chunk = md[start:end]
|
||||
if board.startswith("skills_"):
|
||||
out[board].update(_SKILL_SH_RE.findall(chunk))
|
||||
else:
|
||||
out[board].update(_GITHUB_REPO_RE.findall(chunk))
|
||||
return out
|
||||
|
||||
|
||||
def _load_shown_keys_for_day(path: Path, date_str: str) -> dict[str, set[str]] | None:
|
||||
"""读一日历史:优先 wecom_shown_keys;缺省则回退 wecom.md,再回退 data 榜字段。"""
|
||||
empty = {board: set() for board in BOARD_KEYS}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("读取 wecom_shown_keys %s 失败:%s", path, exc)
|
||||
return None
|
||||
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
return empty
|
||||
|
||||
out: dict[str, set[str]] = {board: set() for board in BOARD_KEYS}
|
||||
shown = data.get("wecom_shown_keys")
|
||||
if isinstance(shown, dict):
|
||||
for board in BOARD_KEYS:
|
||||
keys = shown.get(board) or []
|
||||
if isinstance(keys, list):
|
||||
out[board].update(str(k) for k in keys if k)
|
||||
if any(out.values()):
|
||||
return out
|
||||
|
||||
wecom_path = OUTPUT_DIR / f"{date_str}.wecom.md"
|
||||
if wecom_path.exists():
|
||||
try:
|
||||
md = wecom_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.warning("读取 wecom.md 回退 %s 失败:%s", wecom_path, exc)
|
||||
else:
|
||||
parsed = parse_wecom_shown_keys(md)
|
||||
if any(parsed.values()):
|
||||
return parsed
|
||||
|
||||
for board in BOARD_KEYS:
|
||||
out[board].update(_keys_from_board_items(board, data))
|
||||
return out
|
||||
|
||||
|
||||
def load_recent_shown_keys(
|
||||
date_str: str,
|
||||
*,
|
||||
lookback_days: int | None = None,
|
||||
) -> dict[str, set[str]]:
|
||||
"""近 N 日已展示 keys 并集(不含当日)。缺省或读失败视为空集。"""
|
||||
empty = {board: set() for board in BOARD_KEYS}
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return empty
|
||||
|
||||
days = lookback_days if lookback_days is not None else board_dedup_days()
|
||||
out: dict[str, set[str]] = {board: set() for board in BOARD_KEYS}
|
||||
|
||||
for day_offset in range(1, days + 1):
|
||||
prev_date = (dt - timedelta(days=day_offset)).strftime("%Y-%m-%d")
|
||||
path = OUTPUT_DIR / f"{prev_date}.data.json"
|
||||
if not path.exists():
|
||||
continue
|
||||
day_keys = _load_shown_keys_for_day(path, prev_date)
|
||||
if day_keys is None:
|
||||
continue
|
||||
for board in BOARD_KEYS:
|
||||
out[board].update(day_keys.get(board) or set())
|
||||
return out
|
||||
|
||||
|
||||
def merge_wecom_shown_into_data(
|
||||
data: dict[str, Any],
|
||||
shown: dict[str, list[str]],
|
||||
) -> dict[str, Any]:
|
||||
"""写入 wecom_shown_keys,不修改 movement_baseline。"""
|
||||
merged = dict(data)
|
||||
merged["wecom_shown_keys"] = {
|
||||
board: list(keys) for board, keys in shown.items()
|
||||
}
|
||||
return merged
|
||||
48
daily/board_select.py
Normal file
48
daily/board_select.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""五榜唯一列表主人:周去重 + 深池补满。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from daily.delta import skill_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def board_select(
|
||||
*,
|
||||
board: str,
|
||||
items: list[dict[str, Any]],
|
||||
recent_keys: set[str],
|
||||
limit: int,
|
||||
pool_size: int,
|
||||
kind: Literal["skill", "github"],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""从深池过滤近 N 日已展示 key,按原顺序取满 limit;不足则短榜。"""
|
||||
if kind == "skill":
|
||||
from daily.skills_group import group_skills_by_source
|
||||
|
||||
pool = group_skills_by_source(items, limit=pool_size, pool_size=pool_size)
|
||||
else:
|
||||
pool = items[: max(pool_size, limit)]
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in pool:
|
||||
if kind == "skill":
|
||||
key = skill_id(item)
|
||||
source = str(item.get("source") or "").strip()
|
||||
if (key and key in recent_keys) or (source and source in recent_keys):
|
||||
continue
|
||||
if not key and not source:
|
||||
continue
|
||||
else:
|
||||
key = str(item.get("repo") or "")
|
||||
if not key or key in recent_keys:
|
||||
continue
|
||||
out.append(item)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
if len(out) < limit:
|
||||
logger.info("board_short:%s:%s", board, len(out))
|
||||
return out
|
||||
@@ -12,7 +12,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
import env_config
|
||||
from daily.config import ROOT, env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +22,7 @@ _bridge_process: subprocess.Popen[bytes] | None = None
|
||||
|
||||
|
||||
def _cursor_cwd() -> str:
|
||||
return env_config.env("CURSOR_CWD", r"d:\LY\test\tech") or r"d:\LY\test\tech"
|
||||
return env("DAILY_CURSOR_CWD") or env("CURSOR_CWD") or str(ROOT)
|
||||
|
||||
|
||||
def _parse_discovery_line(line: str) -> Mapping[str, Any] | None:
|
||||
104
daily/config.py
104
daily/config.py
@@ -3,13 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
BOT_DIR = ROOT / "bot"
|
||||
OUTPUT_DIR = ROOT / "output"
|
||||
LOG_DIR = ROOT / "logs"
|
||||
CACHE_DIR = ROOT / ".cache"
|
||||
@@ -32,6 +30,24 @@ def wecom_skill_desc_limit() -> int:
|
||||
return env_int("DAILY_WECOM_SKILL_DESC_LIMIT", 56)
|
||||
|
||||
|
||||
def wecom_news_desc_limit() -> int:
|
||||
"""企微新闻摘要建议字数;在句读/词边界截断,不加省略号。"""
|
||||
return max(24, env_int("DAILY_WECOM_NEWS_DESC_LIMIT", 72))
|
||||
|
||||
|
||||
def wecom_ai_news_tech_limit() -> int:
|
||||
"""research 模式下技术类时讯条数(叠加在 DAILY_WECOM_AI_NEWS 之上)。"""
|
||||
return max(0, env_int("DAILY_WECOM_AI_NEWS_TECH", 5))
|
||||
|
||||
|
||||
def wecom_pad_pool_size(display_limit: int) -> int:
|
||||
"""Delta 补榜候选池大小(展示条数之上多取,避免去重后凑不满)。"""
|
||||
explicit = env_int("DAILY_WECOM_PAD_POOL", -1)
|
||||
if explicit > 0:
|
||||
return explicit
|
||||
return max(display_limit * 5, 50)
|
||||
|
||||
|
||||
def full_desc_limit() -> int:
|
||||
"""完整版早报摘要长度;0 表示不截断。"""
|
||||
return env_int("DAILY_FULL_DESC_LIMIT", 0)
|
||||
@@ -45,16 +61,11 @@ def wecom_max_bytes() -> int:
|
||||
"""兼容旧配置名。"""
|
||||
return wecom_chunk_bytes()
|
||||
|
||||
|
||||
load_dotenv(ROOT / ".env")
|
||||
load_dotenv(ROOT / ".env.local", override=True)
|
||||
|
||||
|
||||
def ensure_bot_on_path() -> None:
|
||||
bot = str(BOT_DIR)
|
||||
if bot not in sys.path:
|
||||
sys.path.insert(0, bot)
|
||||
|
||||
|
||||
def _clean_env_value(raw: str | None) -> str | None:
|
||||
if raw is None:
|
||||
return None
|
||||
@@ -79,3 +90,80 @@ def env_int(key: str, default: int) -> int:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def env_bool(key: str, default: bool) -> bool:
|
||||
raw = env(key)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def wecom_mode() -> str:
|
||||
raw = (env("DAILY_WECOM_MODE") or "delta").strip().lower()
|
||||
return raw if raw in {"full", "delta"} else "delta"
|
||||
|
||||
|
||||
def news_dedup_days() -> int:
|
||||
return max(1, env_int("DAILY_NEWS_DEDUP_DAYS", 7))
|
||||
|
||||
|
||||
def skip_push_when_silent() -> bool:
|
||||
return env_bool("DAILY_SKIP_PUSH_WHEN_SILENT", True)
|
||||
|
||||
|
||||
def delta_baseline_fallback() -> str:
|
||||
raw = (env("DAILY_DELTA_BASELINE_FALLBACK") or "full").strip().lower()
|
||||
return raw if raw in {"full", "empty"} else "full"
|
||||
|
||||
|
||||
def wecom_delta_pad() -> bool:
|
||||
"""Delta 模式下新入榜优先,不足时用当日 Top 榜补满;补榜排除近 N 天 baseline 已出现条目。"""
|
||||
return env_bool("DAILY_WECOM_DELTA_PAD", True)
|
||||
|
||||
|
||||
def delta_pad_lookback_days() -> int:
|
||||
"""补榜时排除近 N 天 baseline 已出现过的条目(默认与异动对比窗口一致)。"""
|
||||
fallback = env_int("DAILY_DELTA_LOOKBACK_DAYS", 7)
|
||||
return max(1, env_int("DAILY_DELTA_PAD_LOOKBACK_DAYS", fallback))
|
||||
|
||||
|
||||
def force_push() -> bool:
|
||||
return env_bool("DAILY_FORCE_PUSH", False)
|
||||
|
||||
|
||||
def schedule_timezone_name() -> str:
|
||||
return (env("DAILY_SCHEDULE_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
|
||||
|
||||
|
||||
def schedule_generate_at() -> str:
|
||||
return (env("DAILY_SCHEDULE_GENERATE_AT") or "08:50").strip() or "08:50"
|
||||
|
||||
|
||||
def schedule_push_at() -> str:
|
||||
return (env("DAILY_SCHEDULE_PUSH_AT") or "09:00").strip() or "09:00"
|
||||
|
||||
|
||||
def board_dedup_days() -> int:
|
||||
return max(1, env_int("DAILY_BOARD_DEDUP_DAYS", 7))
|
||||
|
||||
|
||||
def board_pool_size() -> int:
|
||||
fallback = env_int("DAILY_WECOM_SKILL_POOL", 400)
|
||||
return max(1, env_int("DAILY_BOARD_POOL_SIZE", max(200, fallback)))
|
||||
|
||||
|
||||
def featured_dedup_days() -> int:
|
||||
return max(1, env_int("DAILY_FEATURED_DEDUP_DAYS", 30))
|
||||
|
||||
|
||||
def theme_ban_days() -> int:
|
||||
return max(1, env_int("DAILY_THEME_BAN_DAYS", 7))
|
||||
|
||||
|
||||
def narrative_axis_days() -> int:
|
||||
return max(1, env_int("DAILY_NARRATIVE_AXIS_DAYS", 3))
|
||||
|
||||
|
||||
def news_backfill_enabled() -> bool:
|
||||
return env_bool("DAILY_NEWS_BACKFILL", False)
|
||||
|
||||
@@ -8,13 +8,22 @@ from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from daily.config import OUTPUT_DIR, env_int
|
||||
from daily.config import OUTPUT_DIR, delta_baseline_fallback, env_int, wecom_mode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KeyFn = Callable[[dict[str, Any]], str]
|
||||
|
||||
|
||||
RECENT_BOARD_KEYS = (
|
||||
"skills_trending",
|
||||
"skills_hot",
|
||||
"github_trending",
|
||||
"github_emerging",
|
||||
"github_topic",
|
||||
)
|
||||
|
||||
|
||||
def compare_depth() -> int:
|
||||
return env_int("DAILY_DELTA_COMPARE_DEPTH", 15)
|
||||
|
||||
@@ -39,6 +48,23 @@ def _key_set(items: list[dict[str, Any]], key_fn: KeyFn, *, depth: int) -> set[s
|
||||
return {key_fn(item) for item in items[:depth] if key_fn(item)}
|
||||
|
||||
|
||||
def load_recent_board_keys(
|
||||
date_str: str,
|
||||
*,
|
||||
lookback_days: int | None = None,
|
||||
) -> dict[str, set[str]]:
|
||||
"""近 N 天各榜已展示过的 skill id / repo(不含当日,供补榜去重)。
|
||||
|
||||
委托 board_history.load_recent_shown_keys,只读 wecom_shown_keys,
|
||||
不读 movement_baseline。
|
||||
"""
|
||||
from daily.board_history import load_recent_shown_keys
|
||||
from daily.config import board_dedup_days
|
||||
|
||||
days = lookback_days if lookback_days is not None else board_dedup_days()
|
||||
return load_recent_shown_keys(date_str, lookback_days=days)
|
||||
|
||||
|
||||
def find_previous_data(date_str: str) -> tuple[str, dict[str, Any]] | None:
|
||||
"""查找最近一份早于 date_str 的 data.json。"""
|
||||
try:
|
||||
@@ -311,3 +337,40 @@ def build_movement_context(
|
||||
"skills_stable": not skills_trending_all and not skills_hot_all,
|
||||
"github_stable": not github_trending_all and not github_emerging_all and not github_topic_all,
|
||||
}
|
||||
|
||||
|
||||
def effective_wecom_mode(*, date_str: str, configured_mode: str | None = None) -> str:
|
||||
mode = configured_mode or wecom_mode()
|
||||
if mode != "delta":
|
||||
return "full"
|
||||
if find_previous_data(date_str) is None and delta_baseline_fallback() == "full":
|
||||
return "full"
|
||||
return "delta"
|
||||
|
||||
|
||||
def partition_skill_moves_for_wecom(
|
||||
trending_moves: list[dict[str, Any]],
|
||||
hot_moves: list[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
hot_by_id = {skill_id(m): m for m in hot_moves if skill_id(m)}
|
||||
trending_out: list[dict[str, Any]] = []
|
||||
consumed_hot: set[str] = set()
|
||||
for move in trending_moves:
|
||||
sid = skill_id(move)
|
||||
copy = dict(move)
|
||||
badges = [f"Trending #{move.get('rank', '?')}"]
|
||||
hot_match = hot_by_id.get(sid)
|
||||
if hot_match:
|
||||
badges.append(f"Hot #{hot_match.get('rank', '?')}")
|
||||
consumed_hot.add(sid)
|
||||
copy["badge"] = " · ".join(badges)
|
||||
trending_out.append(copy)
|
||||
hot_out: list[dict[str, Any]] = []
|
||||
for move in hot_moves:
|
||||
sid = skill_id(move)
|
||||
if sid in consumed_hot:
|
||||
continue
|
||||
copy = dict(move)
|
||||
copy["badge"] = f"Hot #{move.get('rank', '?')}"
|
||||
hot_out.append(copy)
|
||||
return trending_out, hot_out
|
||||
|
||||
545
daily/featured_pick.py
Normal file
545
daily/featured_pick.py
Normal file
@@ -0,0 +1,545 @@
|
||||
"""今日首推:解析 DAILY_FEATURED_PICK → 定人(月去重)→ LLM 检索 → featured JSON。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from daily.config import OUTPUT_DIR, ROOT, env, featured_dedup_days
|
||||
from daily.llm_client import extract_json_object, has_llm_configured, llm_chat
|
||||
from daily.report_data import featured_json_path, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SKILL_DIR = ROOT / "skills" / "daily-featured-pick"
|
||||
_GITHUB_REPO_RE = re.compile(r"github\.com/([^/\s#?]+/[^/\s#?]+)", re.I)
|
||||
|
||||
|
||||
def featured_identity_key(featured: dict[str, Any] | None) -> str:
|
||||
"""稳定身份:skill→id,github→repo,兜底从 url 解析。"""
|
||||
if not featured:
|
||||
return ""
|
||||
typ = str(featured.get("type") or "").lower()
|
||||
if typ == "skill" or featured.get("id"):
|
||||
sid = str(featured.get("id") or "").strip()
|
||||
if sid:
|
||||
return sid
|
||||
repo = str(featured.get("repo") or "").strip()
|
||||
if repo:
|
||||
return repo
|
||||
for field in ("url", "command", "link"):
|
||||
url = str(featured.get(field) or "")
|
||||
m = _GITHUB_REPO_RE.search(url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def load_recent_featured_keys(date_str: str, days: int | None = None) -> set[str]:
|
||||
"""近 N 日 data.featured_pick_key 并集(不含当日)。"""
|
||||
lookback = days if days is not None else featured_dedup_days()
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for day_offset in range(1, lookback + 1):
|
||||
prev = (dt - timedelta(days=day_offset)).strftime("%Y-%m-%d")
|
||||
path = OUTPUT_DIR / f"{prev}.data.json"
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("读取 featured_pick_key %s 失败:%s", path, exc)
|
||||
continue
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
key = str(data.get("featured_pick_key") or "").strip()
|
||||
if not key:
|
||||
featured = data.get("featured_pick")
|
||||
if isinstance(featured, dict):
|
||||
key = featured_identity_key(featured)
|
||||
if key:
|
||||
out.add(key)
|
||||
return out
|
||||
|
||||
|
||||
def load_yesterday_featured_key(date_str: str) -> str | None:
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
prev = (dt - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
path = OUTPUT_DIR / f"{prev}.data.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
key = str(data.get("featured_pick_key") or "").strip()
|
||||
if key:
|
||||
return key
|
||||
featured = data.get("featured_pick")
|
||||
if isinstance(featured, dict):
|
||||
return featured_identity_key(featured) or None
|
||||
return None
|
||||
|
||||
|
||||
def _featured_rng(date_str: str) -> random.Random:
|
||||
seed = int(hashlib.sha256(f"{date_str}:featured".encode()).hexdigest()[:16], 16)
|
||||
return random.Random(seed)
|
||||
|
||||
|
||||
def _stub_from_pool_item(item: dict[str, Any]) -> dict[str, Any]:
|
||||
repo = str(item.get("repo") or "").strip()
|
||||
if repo:
|
||||
url = str(item.get("url") or f"https://github.com/{repo}").strip()
|
||||
return {
|
||||
"type": "github",
|
||||
"title": repo.split("/")[-1],
|
||||
"repo": repo,
|
||||
"url": url,
|
||||
"command": url,
|
||||
"summary": str(item.get("description") or "")[:160],
|
||||
"why_today": "",
|
||||
"evidence": [],
|
||||
"tags": [],
|
||||
}
|
||||
sid = str(item.get("id") or "").strip()
|
||||
source = str(item.get("source") or "").strip()
|
||||
title = str(item.get("title") or "").strip()
|
||||
return {
|
||||
"type": "skill",
|
||||
"id": sid,
|
||||
"title": title or sid,
|
||||
"command": _skill_command(item),
|
||||
"url": str(item.get("link") or ""),
|
||||
"summary": str(item.get("description") or "")[:160],
|
||||
"why_today": "",
|
||||
"evidence": [],
|
||||
"tags": [],
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def featured_resolve(
|
||||
*,
|
||||
date_str: str,
|
||||
candidate: dict[str, Any] | None,
|
||||
pool_a: list[dict[str, Any]],
|
||||
pool_b: list[dict[str, Any]],
|
||||
recent_featured: set[str] | None = None,
|
||||
yesterday_key: str | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""若与昨日同一身份则改推;返回 (seed_stub, identity_key),不含完整 why。"""
|
||||
if not candidate:
|
||||
return None, None
|
||||
key = featured_identity_key(candidate)
|
||||
if not yesterday_key or key != yesterday_key:
|
||||
return candidate, key or None
|
||||
|
||||
blocked = set(recent_featured or set()) | {yesterday_key}
|
||||
picker = rng or _featured_rng(date_str)
|
||||
|
||||
def _choices(pool: list[dict[str, Any]]) -> list[tuple[str, dict[str, Any]]]:
|
||||
out: list[tuple[str, dict[str, Any]]] = []
|
||||
seen: set[str] = set()
|
||||
for item in pool:
|
||||
ik = featured_identity_key(item)
|
||||
if not ik or ik in blocked or ik in seen:
|
||||
continue
|
||||
seen.add(ik)
|
||||
out.append((ik, item))
|
||||
return out
|
||||
|
||||
options = _choices(pool_a)
|
||||
if not options:
|
||||
options = _choices(pool_b)
|
||||
if not options:
|
||||
logger.info("featured_fallback_exhausted")
|
||||
return candidate, key
|
||||
|
||||
chosen_key, chosen_item = picker.choice(options)
|
||||
return _stub_from_pool_item(chosen_item), chosen_key
|
||||
|
||||
|
||||
def _config_from_candidate(candidate: dict[str, Any]) -> dict[str, str]:
|
||||
typ = str(candidate.get("type") or "").lower()
|
||||
if typ == "skill" or candidate.get("id"):
|
||||
query = str(candidate.get("id") or candidate.get("title") or "").strip()
|
||||
return {"query": query, "url_hint": str(candidate.get("url") or "")}
|
||||
repo = str(candidate.get("repo") or "").strip()
|
||||
if repo:
|
||||
return {
|
||||
"query": repo,
|
||||
"url_hint": str(candidate.get("url") or f"https://github.com/{repo}"),
|
||||
}
|
||||
query = str(candidate.get("title") or candidate.get("url") or "").strip()
|
||||
return {"query": query or "featured", "url_hint": str(candidate.get("url") or "")}
|
||||
|
||||
|
||||
def _seed_candidate_from_config(
|
||||
config: dict[str, str],
|
||||
llm_input: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
matches = match_in_data(llm_input, config["query"])
|
||||
if matches["skills"]:
|
||||
return _stub_from_pool_item(matches["skills"][0])
|
||||
if matches["github"]:
|
||||
return _stub_from_pool_item(matches["github"][0])
|
||||
url = config.get("url_hint") or ""
|
||||
seed: dict[str, Any] = {
|
||||
"type": "other",
|
||||
"title": config["query"],
|
||||
"url": url,
|
||||
"command": url or config["query"],
|
||||
}
|
||||
m = _GITHUB_REPO_RE.search(url)
|
||||
if m:
|
||||
seed["type"] = "github"
|
||||
seed["repo"] = m.group(1)
|
||||
return seed
|
||||
|
||||
|
||||
def parse_featured_pick() -> dict[str, str] | None:
|
||||
"""解析 DAILY_FEATURED_PICK:query 或 query|url。"""
|
||||
raw = (env("DAILY_FEATURED_PICK") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if "|" in raw:
|
||||
query, url_hint = raw.split("|", 1)
|
||||
query = query.strip()
|
||||
url_hint = url_hint.strip()
|
||||
if not query:
|
||||
return None
|
||||
payload: dict[str, str] = {"query": query}
|
||||
if url_hint:
|
||||
payload["url_hint"] = url_hint
|
||||
return payload
|
||||
return {"query": raw}
|
||||
|
||||
|
||||
def _matches_query(text: str, query: str) -> bool:
|
||||
return query.lower() in (text or "").lower()
|
||||
|
||||
|
||||
def _skill_matches(item: dict[str, Any], query: str) -> bool:
|
||||
for key in ("id", "title", "source"):
|
||||
if _matches_query(str(item.get(key) or ""), query):
|
||||
return True
|
||||
for sub in item.get("cluster_skills") or []:
|
||||
if isinstance(sub, str) and _matches_query(sub, query):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def match_in_data(llm_input: dict[str, Any], query: str) -> dict[str, list[dict[str, Any]]]:
|
||||
"""在榜单数据中模糊匹配 query。"""
|
||||
skills: list[dict[str, Any]] = []
|
||||
seen_skill: set[str] = set()
|
||||
for board in ("skills_trending", "skills_hot"):
|
||||
for item in llm_input.get(board) or []:
|
||||
sid = str(item.get("id") or "")
|
||||
if sid in seen_skill:
|
||||
continue
|
||||
if _skill_matches(item, query):
|
||||
seen_skill.add(sid)
|
||||
skills.append({**item, "board": board})
|
||||
if len(skills) >= 5:
|
||||
break
|
||||
if len(skills) >= 5:
|
||||
break
|
||||
|
||||
github: list[dict[str, Any]] = []
|
||||
seen_repo: set[str] = set()
|
||||
for board in ("github_trending", "github_emerging"):
|
||||
for item in llm_input.get(board) or []:
|
||||
repo = str(item.get("repo") or "")
|
||||
if not repo or repo in seen_repo:
|
||||
continue
|
||||
if _matches_query(repo, query):
|
||||
seen_repo.add(repo)
|
||||
github.append({**item, "board": board})
|
||||
if len(github) >= 5:
|
||||
break
|
||||
|
||||
topic = llm_input.get("github_topic") or {}
|
||||
for item in topic.get("repos") or []:
|
||||
repo = str(item.get("repo") or "")
|
||||
if not repo or repo in seen_repo:
|
||||
continue
|
||||
if _matches_query(repo, query):
|
||||
seen_repo.add(repo)
|
||||
github.append({**item, "board": "github_topic"})
|
||||
if len(github) >= 5:
|
||||
break
|
||||
|
||||
return {"skills": skills, "github": github}
|
||||
|
||||
|
||||
def _load_skill() -> str:
|
||||
path = _SKILL_DIR / "SKILL.md"
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
return "你是早报编辑。根据输入检索今日首推信息,只输出 JSON。"
|
||||
|
||||
|
||||
def _skill_command(item: dict[str, Any]) -> str:
|
||||
source = str(item.get("source") or "").strip()
|
||||
title = str(item.get("title") or "").strip()
|
||||
if source and title:
|
||||
return f"npx skills add {source}/{title}"
|
||||
sid = str(item.get("id") or "").strip()
|
||||
if sid.count("/") >= 2:
|
||||
parts = sid.split("/", 2)
|
||||
return f"npx skills add {parts[0]}/{parts[1]}/{parts[2]}"
|
||||
if sid.count("/") == 1:
|
||||
return f"npx skills add {sid}"
|
||||
return ""
|
||||
|
||||
|
||||
def _evidence_from_skill(item: dict[str, Any]) -> list[str]:
|
||||
board = item.get("board", "")
|
||||
board_label = {
|
||||
"skills_trending": "Skills Trending",
|
||||
"skills_hot": "Skills Hot",
|
||||
}.get(str(board), str(board))
|
||||
installs = item.get("installs_fmt") or item.get("installs")
|
||||
title = item.get("title") or item.get("id") or "?"
|
||||
if installs:
|
||||
return [f"{board_label} 匹配 · {title} · {installs}"]
|
||||
return [f"{board_label} 匹配 · {title}"]
|
||||
|
||||
|
||||
def _evidence_from_github(item: dict[str, Any]) -> list[str]:
|
||||
board = item.get("board", "")
|
||||
board_label = {
|
||||
"github_trending": "GitHub Trending",
|
||||
"github_emerging": "GitHub 新兴",
|
||||
"github_topic": "GitHub Topic",
|
||||
}.get(str(board), str(board))
|
||||
repo = item.get("repo") or "?"
|
||||
stars = item.get("total_stars_fmt") or ""
|
||||
if stars:
|
||||
return [f"{board_label} 匹配 · {repo} · ⭐{stars}"]
|
||||
return [f"{board_label} 匹配 · {repo}"]
|
||||
|
||||
|
||||
def _fallback_featured(
|
||||
config: dict[str, str],
|
||||
llm_input: dict[str, Any],
|
||||
*,
|
||||
partial: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""LLM 不可用或失败时,用榜单匹配 + 配置回退。"""
|
||||
partial = partial or {}
|
||||
matches = match_in_data(llm_input, config["query"])
|
||||
skill = matches["skills"][0] if matches["skills"] else None
|
||||
gh = matches["github"][0] if matches["github"] else None
|
||||
|
||||
if skill:
|
||||
featured: dict[str, Any] = {
|
||||
"title": str(skill.get("title") or config["query"]),
|
||||
"type": "skill",
|
||||
"command": _skill_command(skill),
|
||||
"url": str(skill.get("link") or config.get("url_hint") or ""),
|
||||
"summary": str(partial.get("summary") or skill.get("description") or "")[:160],
|
||||
"why_today": str(
|
||||
partial.get("why_today")
|
||||
or f"今日 Skills 榜匹配到 **{skill.get('title') or config['query']}**,适合作为首推。"
|
||||
),
|
||||
"evidence": list(partial.get("evidence") or _evidence_from_skill(skill)),
|
||||
"tags": list(partial.get("tags") or []),
|
||||
}
|
||||
if skill.get("id"):
|
||||
featured["id"] = skill["id"]
|
||||
return featured
|
||||
|
||||
if gh:
|
||||
return {
|
||||
"title": str(gh.get("repo") or config["query"]).split("/")[-1],
|
||||
"type": "github",
|
||||
"command": str(gh.get("url") or config.get("url_hint") or ""),
|
||||
"url": str(gh.get("url") or config.get("url_hint") or ""),
|
||||
"summary": str(partial.get("summary") or gh.get("description") or "")[:160],
|
||||
"why_today": str(
|
||||
partial.get("why_today")
|
||||
or f"今日 GitHub 榜匹配到 **{gh.get('repo')}**,适合作为首推。"
|
||||
),
|
||||
"evidence": list(partial.get("evidence") or _evidence_from_github(gh)),
|
||||
"tags": list(partial.get("tags") or []),
|
||||
"repo": gh.get("repo"),
|
||||
}
|
||||
|
||||
url = config.get("url_hint") or ""
|
||||
return {
|
||||
"title": config["query"],
|
||||
"type": "other",
|
||||
"command": url or config["query"],
|
||||
"url": url,
|
||||
"summary": str(partial.get("summary") or "")[:160],
|
||||
"why_today": str(
|
||||
partial.get("why_today")
|
||||
or f"**{config['query']}** 未出现在今日 Top 榜,仍值得单独关注。"
|
||||
),
|
||||
"evidence": list(partial.get("evidence") or ([f"主推 · {config['query']}"])),
|
||||
"tags": list(partial.get("tags") or []),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_featured(
|
||||
raw: dict[str, Any],
|
||||
config: dict[str, str],
|
||||
llm_input: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""补齐 command / url / evidence,并与榜单数字对齐。"""
|
||||
matches = match_in_data(llm_input, config["query"])
|
||||
skill = matches["skills"][0] if matches["skills"] else None
|
||||
gh = matches["github"][0] if matches["github"] else None
|
||||
|
||||
featured = dict(raw)
|
||||
featured.setdefault("title", config["query"])
|
||||
featured.setdefault("type", "other")
|
||||
|
||||
if skill and featured.get("type") in {"skill", "other", ""}:
|
||||
featured.setdefault("id", skill.get("id"))
|
||||
featured.setdefault("command", _skill_command(skill))
|
||||
featured.setdefault("url", skill.get("link") or config.get("url_hint") or "")
|
||||
if not featured.get("evidence"):
|
||||
featured["evidence"] = _evidence_from_skill(skill)
|
||||
featured["type"] = "skill"
|
||||
elif gh and featured.get("type") in {"github", "other", ""}:
|
||||
featured.setdefault("repo", gh.get("repo"))
|
||||
featured.setdefault("url", gh.get("url") or config.get("url_hint") or "")
|
||||
featured.setdefault("command", featured.get("url") or gh.get("url") or "")
|
||||
if not featured.get("evidence"):
|
||||
featured["evidence"] = _evidence_from_github(gh)
|
||||
featured["type"] = "github"
|
||||
|
||||
featured.setdefault("command", config.get("url_hint") or config["query"])
|
||||
featured.setdefault("url", config.get("url_hint") or "")
|
||||
featured.setdefault("summary", "")
|
||||
featured.setdefault("why_today", featured.get("summary") or "")
|
||||
featured.setdefault("evidence", [])
|
||||
featured.setdefault("tags", [])
|
||||
return featured
|
||||
|
||||
|
||||
def research_featured_pick(
|
||||
llm_input: dict[str, Any],
|
||||
*,
|
||||
date_str: str,
|
||||
config: dict[str, str] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Step 0:检索今日首推;成功返回 featured dict,未配置返回 None。"""
|
||||
config = config or parse_featured_pick()
|
||||
if not config:
|
||||
return None
|
||||
|
||||
matches = match_in_data(llm_input, config["query"])
|
||||
payload = {
|
||||
"query": config["query"],
|
||||
"url_hint": config.get("url_hint"),
|
||||
"cwd": env("DAILY_CURSOR_CWD") or str(ROOT),
|
||||
"data_matches": matches,
|
||||
}
|
||||
|
||||
if not has_llm_configured():
|
||||
featured = _fallback_featured(config, llm_input)
|
||||
save_json(featured_json_path(date_str), featured)
|
||||
logger.info("Featured pick(无 LLM,规则回退):%s", featured.get("title"))
|
||||
return featured
|
||||
|
||||
skill = _load_skill()
|
||||
system = (
|
||||
f"{skill}\n\n"
|
||||
"当前执行 **Step 0:今日首推检索**。\n"
|
||||
"只输出 featured JSON(title, type, command, url, summary, why_today, evidence, tags),"
|
||||
"不要 Markdown,不要解释。"
|
||||
)
|
||||
user = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
try:
|
||||
raw = llm_chat(system, user)
|
||||
except Exception as exc:
|
||||
logger.warning("Featured pick LLM 失败,回退规则模式:%s", exc)
|
||||
featured = _fallback_featured(config, llm_input)
|
||||
save_json(featured_json_path(date_str), featured)
|
||||
return featured
|
||||
|
||||
if not raw:
|
||||
featured = _fallback_featured(config, llm_input)
|
||||
save_json(featured_json_path(date_str), featured)
|
||||
return featured
|
||||
|
||||
parsed = extract_json_object(raw)
|
||||
if not parsed.get("why_today") and not parsed.get("summary"):
|
||||
logger.warning("Featured pick JSON 无效,回退规则模式")
|
||||
featured = _fallback_featured(config, llm_input, partial=parsed)
|
||||
save_json(featured_json_path(date_str), featured)
|
||||
return featured
|
||||
|
||||
featured = _normalize_featured(parsed, config, llm_input)
|
||||
save_json(featured_json_path(date_str), featured)
|
||||
logger.info("Featured pick 完成:%s", featured.get("title"))
|
||||
return featured
|
||||
|
||||
|
||||
def apply_featured_pick(
|
||||
llm_input: dict[str, Any],
|
||||
*,
|
||||
date_str: str,
|
||||
pool_a: list[dict[str, Any]] | None = None,
|
||||
pool_b: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""先定人(相对昨日改推 + 月去重),再 research,写入 featured_pick / featured_pick_key。"""
|
||||
config = parse_featured_pick()
|
||||
if not config:
|
||||
return None
|
||||
|
||||
seed = _seed_candidate_from_config(config, llm_input)
|
||||
recent = load_recent_featured_keys(date_str)
|
||||
yesterday = load_yesterday_featured_key(date_str)
|
||||
resolved, identity_key = featured_resolve(
|
||||
date_str=date_str,
|
||||
candidate=seed,
|
||||
pool_a=pool_a or [],
|
||||
pool_b=pool_b or [],
|
||||
recent_featured=recent,
|
||||
yesterday_key=yesterday,
|
||||
rng=_featured_rng(date_str),
|
||||
)
|
||||
research_config = config
|
||||
if resolved and identity_key and featured_identity_key(seed) != identity_key:
|
||||
research_config = _config_from_candidate(resolved)
|
||||
|
||||
featured = research_featured_pick(
|
||||
llm_input, date_str=date_str, config=research_config
|
||||
)
|
||||
if featured:
|
||||
llm_input["featured_pick"] = featured
|
||||
key = identity_key or featured_identity_key(featured)
|
||||
if key:
|
||||
llm_input["featured_pick_key"] = key
|
||||
return featured
|
||||
|
||||
|
||||
def pick_command_from_featured(featured: dict[str, Any] | None) -> str | None:
|
||||
cmd = str((featured or {}).get("command") or "").strip()
|
||||
return cmd or None
|
||||
|
||||
|
||||
def pick_why_from_featured(featured: dict[str, Any] | None) -> str | None:
|
||||
why = str((featured or {}).get("why_today") or "").strip()
|
||||
return why or None
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from daily.config import wecom_skill_desc_limit
|
||||
from daily.localize import LocalizeJob, localize_brief_descriptions, needs_chinese
|
||||
from daily.skills_group import group_skills_by_source
|
||||
from daily.text_utils import trim_brief
|
||||
|
||||
ICONS = {
|
||||
@@ -42,7 +43,7 @@ def _skill_line(rank: int, item: dict[str, Any], *, badge: str = "") -> list[str
|
||||
lines = [head]
|
||||
if sample or desc:
|
||||
hint = desc or sample
|
||||
lines.append(f" > {hint}")
|
||||
lines.append(f" {hint}")
|
||||
return lines
|
||||
title = item.get("title", "?")
|
||||
if link:
|
||||
@@ -51,29 +52,181 @@ def _skill_line(rank: int, item: dict[str, Any], *, badge: str = "") -> list[str
|
||||
head = f"{rank}. {badge_prefix}**{title}** · `{source}` · **{installs}**"
|
||||
lines = [head]
|
||||
if desc:
|
||||
lines.append(f" > {desc}")
|
||||
lines.append(f" {desc}")
|
||||
return lines
|
||||
|
||||
|
||||
def _ai_news_lines(items: list[dict[str, Any]]) -> list[str]:
|
||||
def _ai_news_link_label(item: dict[str, Any], *, merged: bool = False) -> str:
|
||||
title = (item.get("title") or "?").strip() or "?"
|
||||
source = (item.get("source_name") or "").strip()
|
||||
if merged and source:
|
||||
return f"{source} - {title}"
|
||||
desc = (item.get("desc_short") or "").strip()
|
||||
if desc:
|
||||
return desc
|
||||
return title
|
||||
|
||||
|
||||
def _ai_news_lines(items: list[dict[str, Any]], *, merged: bool = False) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for i, item in enumerate(items, 1):
|
||||
title = item.get("title", "?")
|
||||
label = _ai_news_link_label(item, merged=merged)
|
||||
link = item.get("link", "")
|
||||
source = item.get("source_name", "?")
|
||||
pub = item.get("published_fmt", "")
|
||||
desc = item.get("desc_short", "")
|
||||
pub_suffix = f" · {pub}" if pub else ""
|
||||
desc = (item.get("desc_short") or "").strip()
|
||||
pub_suffix = "" if merged else (f" · {pub}" if pub else "")
|
||||
if link:
|
||||
head = f"{i}. [**{title}**]({link}) · `{source}`{pub_suffix}"
|
||||
if merged and desc:
|
||||
head = f"{i}. [{label}]({link}) — {desc}{pub_suffix}"
|
||||
elif merged:
|
||||
head = f"{i}. [{label}]({link}){pub_suffix}"
|
||||
else:
|
||||
head = f"{i}. [{label}]({link}) · `{source}`{pub_suffix}"
|
||||
elif merged and desc:
|
||||
head = f"{i}. {label} — {desc}{pub_suffix}"
|
||||
else:
|
||||
head = f"{i}. **{title}** · `{source}`{pub_suffix}"
|
||||
head = f"{i}. {label} · `{source}`{pub_suffix}"
|
||||
lines.append(head)
|
||||
if desc:
|
||||
lines.append(f" > {desc}")
|
||||
return lines
|
||||
|
||||
|
||||
_NEWS_BLOCK_END = re.compile(
|
||||
r"\n\n(📈|🔥|🐙|🌱|🤖|📦|🎯|💡|🌍|🇨🇳|📰|🔧)",
|
||||
)
|
||||
|
||||
|
||||
def _build_merged_news_block(
|
||||
items: list[dict[str, Any]],
|
||||
tech_items: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
combined = list(items or [])
|
||||
if tech_items:
|
||||
combined.extend(tech_items)
|
||||
if not combined:
|
||||
return ""
|
||||
parts = [f"📰 **AI 时讯精选 Top {len(combined)}**"]
|
||||
parts.extend(_ai_news_lines(combined, merged=True))
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def _replace_merged_news_block(
|
||||
md: str,
|
||||
items: list[dict[str, Any]],
|
||||
tech_items: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
if not items and not tech_items:
|
||||
return md
|
||||
block = _build_merged_news_block(items, tech_items)
|
||||
start_pat = re.compile(
|
||||
r"^📰 \*\*AI 时讯精选[^\n]*\*\*\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
match = start_pat.search(md)
|
||||
if not match:
|
||||
anchor = re.search(r"^(💡|🎯).*$", md, re.MULTILINE)
|
||||
if anchor:
|
||||
insert_at = anchor.end()
|
||||
return md[:insert_at] + "\n\n" + block + md[insert_at:].lstrip("\n")
|
||||
anchor2 = re.search(r"^(🌍|🇨🇳|📈|🔧).*$", md, re.MULTILINE)
|
||||
if anchor2:
|
||||
insert_at = anchor2.start()
|
||||
return md[:insert_at] + block + md[insert_at:].lstrip("\n")
|
||||
return md.rstrip() + "\n\n" + block
|
||||
start = match.start()
|
||||
tail = md[match.end() :]
|
||||
end_rel = _NEWS_BLOCK_END.search(tail)
|
||||
end = match.end() + (end_rel.start() if end_rel else len(tail))
|
||||
return md[:start] + block + md[end:].lstrip("\n")
|
||||
|
||||
|
||||
def _remove_news_blocks(md: str, icons: tuple[str, ...], *, label_must_contain: str = "") -> str:
|
||||
out = md
|
||||
for icon in icons:
|
||||
start_pat = re.compile(rf"^{re.escape(icon)} \*\*[^\n]+\*\*\s*$", re.MULTILINE)
|
||||
while True:
|
||||
match = None
|
||||
for candidate in start_pat.finditer(out):
|
||||
if label_must_contain and label_must_contain not in candidate.group(0):
|
||||
continue
|
||||
match = candidate
|
||||
break
|
||||
if not match:
|
||||
break
|
||||
start = match.start()
|
||||
tail = out[match.end() :]
|
||||
end_rel = _NEWS_BLOCK_END.search(tail)
|
||||
end = match.end() + (end_rel.start() if end_rel else len(tail))
|
||||
out = out[:start] + out[end:].lstrip("\n")
|
||||
return out
|
||||
|
||||
|
||||
def _replace_news_block(
|
||||
md: str,
|
||||
icon: str,
|
||||
items: list[dict[str, Any]],
|
||||
label: str,
|
||||
*,
|
||||
merged: bool = False,
|
||||
) -> str:
|
||||
if not items:
|
||||
return md
|
||||
if merged:
|
||||
start_pat = re.compile(
|
||||
rf"^{re.escape(icon)} \*\*{re.escape(label)}[^\n]*\*\*\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
else:
|
||||
start_pat = re.compile(rf"^{re.escape(icon)} \*\*[^\n]+\*\*\s*$", re.MULTILINE)
|
||||
match = start_pat.search(md)
|
||||
block = (
|
||||
f"{icon} **{label} Top {len(items)}**\n"
|
||||
+ "\n".join(_ai_news_lines(items, merged=merged))
|
||||
+ "\n"
|
||||
)
|
||||
if not match:
|
||||
anchor = re.search(r"^(💡|🎯).*$", md, re.MULTILINE)
|
||||
if anchor:
|
||||
insert_at = anchor.end()
|
||||
return md[:insert_at] + "\n\n" + block + md[insert_at:].lstrip("\n")
|
||||
# 插在 🌍/🇨🇳 原位置,或 Skills 区块前
|
||||
anchor2 = re.search(r"^(🌍|🇨🇳|📈).*$", md, re.MULTILINE)
|
||||
if anchor2:
|
||||
insert_at = anchor2.start()
|
||||
return md[:insert_at] + block + md[insert_at:].lstrip("\n")
|
||||
return md.rstrip() + "\n\n" + block
|
||||
start = match.start()
|
||||
tail = md[match.end() :]
|
||||
end_rel = _NEWS_BLOCK_END.search(tail)
|
||||
end = match.end() + (end_rel.start() if end_rel else len(tail))
|
||||
return md[:start] + block + md[end:].lstrip("\n")
|
||||
|
||||
|
||||
def replace_wecom_news_sections(
|
||||
md: str,
|
||||
*,
|
||||
ai_news: list[dict[str, Any]] | None = None,
|
||||
cn_ai_news: list[dict[str, Any]] | None = None,
|
||||
tech_ai_news: list[dict[str, Any]] | None = None,
|
||||
merged: bool = False,
|
||||
) -> str:
|
||||
"""用 Python 整理后的新闻列表替换 Agent/模板中的时讯区块。"""
|
||||
if merged and (ai_news or tech_ai_news):
|
||||
out = _remove_news_blocks(md, ("🌍", "🇨🇳"))
|
||||
out = _remove_news_blocks(out, ("📰",), label_must_contain="AI 时讯精选")
|
||||
out = _remove_news_blocks(out, ("🔧",), label_must_contain="技术类时讯")
|
||||
return _replace_merged_news_block(out, ai_news or [], tech_ai_news)
|
||||
out = md
|
||||
if cn_ai_news:
|
||||
out = _replace_news_block(out, "🇨🇳", cn_ai_news, "国内 AI 时讯")
|
||||
if ai_news:
|
||||
out = _replace_news_block(out, "🌍", ai_news, "国际 AI 时讯")
|
||||
return out
|
||||
|
||||
|
||||
WECOM_GITHUB_DESC_LIMIT = 40
|
||||
|
||||
|
||||
def _github_repo_lines(repos: list[dict[str, Any]], *, show_created: bool = False) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for i, repo in enumerate(repos, 1):
|
||||
@@ -94,12 +247,45 @@ def _github_repo_lines(repos: list[dict[str, Any]], *, show_created: bool = Fals
|
||||
meta_parts.append(f"创建于 {created}")
|
||||
meta = f" · {' · '.join(meta_parts)}" if meta_parts else ""
|
||||
lines.append(f"{i}. [{name}]({url}){meta}")
|
||||
desc = repo.get("desc_short") or repo.get("description", "")
|
||||
desc = repo.get("wecom_desc") or repo.get("desc_short") or repo.get("description", "")
|
||||
if desc:
|
||||
lines.append(f" > {desc}")
|
||||
lines.append(f" {desc}")
|
||||
return lines
|
||||
|
||||
|
||||
def finalize_wecom_github_repos(
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
desc_limit: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""为企微 GitHub 条目生成简短中文简介。"""
|
||||
if desc_limit is None:
|
||||
desc_limit = WECOM_GITHUB_DESC_LIMIT
|
||||
limit = desc_limit if desc_limit > 0 else 40
|
||||
copies: list[tuple[str, dict[str, Any]]] = []
|
||||
jobs: list[LocalizeJob] = []
|
||||
for item in items:
|
||||
copy = dict(item)
|
||||
desc = (copy.get("description") or copy.get("desc_short") or "").strip()
|
||||
key = f"github:{copy.get('repo', '?')}"
|
||||
if needs_chinese(desc) or len(desc) > limit:
|
||||
jobs.append(LocalizeJob(key, desc, limit))
|
||||
else:
|
||||
copy["wecom_desc"] = desc
|
||||
copies.append((key, copy))
|
||||
|
||||
zh_map = localize_brief_descriptions(jobs, archive=True)
|
||||
out: list[dict[str, Any]] = []
|
||||
for key, copy in copies:
|
||||
if key in zh_map:
|
||||
copy["wecom_desc"] = zh_map[key]
|
||||
elif "wecom_desc" not in copy:
|
||||
fallback = (copy.get("description") or copy.get("desc_short") or "").strip()
|
||||
copy["wecom_desc"] = _brief_fallback_desc(fallback, limit) if fallback else ""
|
||||
out.append(copy)
|
||||
return out
|
||||
|
||||
|
||||
def _fallback_skill_desc(item: dict[str, Any]) -> str:
|
||||
if item.get("cluster"):
|
||||
count = int(item.get("cluster_count") or 1)
|
||||
@@ -173,11 +359,13 @@ def _grouped_skill_to_wecom_item(
|
||||
if not item.get("wecom_desc"):
|
||||
desc = _brief_fallback_desc(desc, limit)
|
||||
return {
|
||||
"id": str(item.get("id") or f"{item.get('source', '?')}/{item.get('title', '')}"),
|
||||
"title": item.get("title", ""),
|
||||
"source": item.get("source", "?"),
|
||||
"installs_fmt": installs_fmt,
|
||||
"link": item.get("link", ""),
|
||||
"desc_short": desc,
|
||||
"badge": item.get("badge", ""),
|
||||
"cluster": bool(item.get("cluster")),
|
||||
"cluster_count": item.get("cluster_count"),
|
||||
"cluster_titles": item.get("cluster_titles"),
|
||||
@@ -193,19 +381,560 @@ def build_skills_board_section(icon_key: str, board_label: str, items: list[dict
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _move_to_wecom_skill_item(move: dict[str, Any]) -> dict[str, Any]:
|
||||
installs = int(move.get("installs") or 0)
|
||||
return {
|
||||
"title": move.get("title", "?"),
|
||||
"source": move.get("source", "?"),
|
||||
"installs_fmt": move.get("installs_fmt") or str(installs),
|
||||
"link": move.get("link", ""),
|
||||
"description": (move.get("description") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _move_to_skill_row(move: dict[str, Any]) -> dict[str, Any]:
|
||||
title = str(move.get("title") or "?")
|
||||
source = str(move.get("source") or "?")
|
||||
installs = int(move.get("installs") or 0)
|
||||
sid = str(move.get("id") or f"{source}/{title}")
|
||||
return {
|
||||
"id": sid,
|
||||
"title": title,
|
||||
"source": source,
|
||||
"installs": installs,
|
||||
"link": move.get("link", ""),
|
||||
"description": (move.get("description") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _flatten_skill_board_item(item: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if item.get("cluster"):
|
||||
source = str(item.get("source") or "?")
|
||||
installs = int(item.get("installs") or 0)
|
||||
titles = [str(t) for t in (item.get("cluster_skills") or []) if t]
|
||||
if not titles:
|
||||
titles = [str(item.get("title") or "?")]
|
||||
top_title = str(item.get("title") or titles[0])
|
||||
rows: list[dict[str, Any]] = []
|
||||
for title in titles:
|
||||
link = item.get("link", "")
|
||||
if title != top_title:
|
||||
link = f"https://www.skills.sh/{source}/{title}"
|
||||
rows.append(
|
||||
{
|
||||
"id": f"{source}/{title}",
|
||||
"title": title,
|
||||
"source": source,
|
||||
"installs": installs,
|
||||
"link": link,
|
||||
"description": (item.get("description") or "").strip(),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
title = str(item.get("title") or "?")
|
||||
source = str(item.get("source") or "?")
|
||||
return [
|
||||
{
|
||||
"id": str(item.get("id") or f"{source}/{title}"),
|
||||
"title": title,
|
||||
"source": source,
|
||||
"installs": int(item.get("installs") or 0),
|
||||
"link": item.get("link", ""),
|
||||
"description": (item.get("description") or "").strip(),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _skill_keys_in_board_item(item: dict[str, Any]) -> set[str]:
|
||||
if item.get("cluster"):
|
||||
source = str(item.get("source") or "?")
|
||||
titles = item.get("cluster_skills") or [item.get("title", "")]
|
||||
return {f"{source}/{t}" for t in titles if t}
|
||||
return {_skill_group_key(item)}
|
||||
|
||||
|
||||
def _prepare_grouped_wecom_skills(
|
||||
flat_rows: list[dict[str, Any]],
|
||||
*,
|
||||
limit: int,
|
||||
) -> tuple[list[dict[str, Any]], set[str]]:
|
||||
if not flat_rows:
|
||||
return [], set()
|
||||
grouped = group_skills_by_source(flat_rows, limit=limit, pool_size=max(len(flat_rows), limit))
|
||||
prepared = finalize_wecom_skill_groups(grouped)
|
||||
wecom_items = [_grouped_skill_to_wecom_item(x) for x in prepared]
|
||||
keys: set[str] = set()
|
||||
for item in grouped:
|
||||
keys.update(_skill_keys_in_board_item(item))
|
||||
return wecom_items[:limit], keys
|
||||
|
||||
|
||||
def _normalize_skill_source_groups(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""将条目规范为按 source 合并的榜单项(展示始终为合并态)。"""
|
||||
flat: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
flat.extend(_flatten_skill_board_item(item))
|
||||
if not flat:
|
||||
return []
|
||||
return group_skills_by_source(flat, limit=len(flat), pool_size=len(flat))
|
||||
|
||||
|
||||
def _skill_primary_id(item: dict[str, Any]) -> str:
|
||||
return str(item.get("id") or f"{item.get('source')}/{item.get('title')}" or "").strip()
|
||||
|
||||
|
||||
def _source_from_skill_key(key: str) -> str:
|
||||
from daily.skills_group import source_from_skill_key
|
||||
|
||||
return source_from_skill_key(key)
|
||||
|
||||
|
||||
def expand_skill_recent_keys(keys: set[str] | None) -> set[str]:
|
||||
from daily.skills_group import expand_skill_recent_keys as _expand
|
||||
|
||||
return _expand(keys)
|
||||
|
||||
|
||||
def _merge_skill_board_items(
|
||||
moves: list[dict[str, Any]],
|
||||
full_items: list[dict[str, Any]],
|
||||
limit: int,
|
||||
*,
|
||||
exclude_keys: set[str] | None = None,
|
||||
recent_keys: set[str] | None = None,
|
||||
) -> tuple[list[dict[str, Any]], set[str]]:
|
||||
"""异动优先,不足时用深池补满;按 source 合并态取条。
|
||||
|
||||
周去重按 source(含从 skill id 展开);同日避开其它榜时也按 source。
|
||||
"""
|
||||
from daily.delta import skill_id as move_skill_id
|
||||
|
||||
exclude = exclude_keys or set()
|
||||
recent = expand_skill_recent_keys(recent_keys)
|
||||
exclude_sources = {_source_from_skill_key(k) for k in exclude if k}
|
||||
exclude_sources.update(k for k in exclude if k)
|
||||
|
||||
def _blocked(item: dict[str, Any]) -> bool:
|
||||
primary = _skill_primary_id(item)
|
||||
source = str(item.get("source") or "").strip()
|
||||
if primary and primary in recent:
|
||||
return True
|
||||
if source and source in recent:
|
||||
return True
|
||||
if primary and primary in exclude:
|
||||
return True
|
||||
if source and (source in exclude_sources or source in exclude):
|
||||
return True
|
||||
return False
|
||||
|
||||
groups: list[dict[str, Any]] = []
|
||||
seen_sources: set[str] = set()
|
||||
|
||||
move_rows: list[dict[str, Any]] = []
|
||||
seen_move_ids: set[str] = set()
|
||||
for move in moves:
|
||||
key = move_skill_id(move)
|
||||
if not key or key in seen_move_ids:
|
||||
continue
|
||||
move_source = str(move.get("source") or "").strip()
|
||||
if key in exclude or (move_source and move_source in exclude_sources):
|
||||
continue
|
||||
if key in recent or (move_source and move_source in recent):
|
||||
continue
|
||||
seen_move_ids.add(key)
|
||||
move_rows.append(_move_to_skill_row(move))
|
||||
|
||||
for group in _normalize_skill_source_groups(move_rows):
|
||||
source = str(group.get("source") or "?")
|
||||
if source in seen_sources or _blocked(group):
|
||||
continue
|
||||
seen_sources.add(source)
|
||||
groups.append(group)
|
||||
if len(groups) >= limit:
|
||||
break
|
||||
|
||||
if len(groups) < limit:
|
||||
for group in _normalize_skill_source_groups(full_items):
|
||||
if len(groups) >= limit:
|
||||
break
|
||||
source = str(group.get("source") or "?")
|
||||
if source in seen_sources or _blocked(group):
|
||||
continue
|
||||
seen_sources.add(source)
|
||||
groups.append(group)
|
||||
|
||||
if not groups:
|
||||
return [], set()
|
||||
prepared = finalize_wecom_skill_groups(groups[:limit])
|
||||
wecom_items = [_grouped_skill_to_wecom_item(x) for x in prepared]
|
||||
# 供同日 Hot 排除:主键 + source
|
||||
keys: set[str] = set()
|
||||
for item in groups[:limit]:
|
||||
primary = _skill_primary_id(item)
|
||||
if primary:
|
||||
keys.add(primary)
|
||||
source = str(item.get("source") or "").strip()
|
||||
if source:
|
||||
keys.add(source)
|
||||
return wecom_items[:limit], keys
|
||||
|
||||
|
||||
def build_skills_delta_sections(
|
||||
trending_moves: list[dict[str, Any]],
|
||||
hot_moves: list[dict[str, Any]],
|
||||
*,
|
||||
trending_full: list[dict[str, Any]] | None = None,
|
||||
hot_full: list[dict[str, Any]] | None = None,
|
||||
trending_limit: int = 5,
|
||||
hot_limit: int = 5,
|
||||
pad: bool = False,
|
||||
recent_trending: set[str] | None = None,
|
||||
recent_hot: set[str] | None = None,
|
||||
) -> str:
|
||||
sections: list[str] = []
|
||||
trending_keys: set[str] = set()
|
||||
if pad:
|
||||
skill_recent = expand_skill_recent_keys(
|
||||
(recent_trending or set()) | (recent_hot or set())
|
||||
)
|
||||
t_items, trending_keys = _merge_skill_board_items(
|
||||
trending_moves,
|
||||
trending_full or [],
|
||||
trending_limit,
|
||||
recent_keys=skill_recent,
|
||||
)
|
||||
if t_items:
|
||||
lines = [f"{ICONS['trending']} **Skills Trending Top {len(t_items)}**"]
|
||||
for rank, item in enumerate(t_items, 1):
|
||||
lines.extend(_skill_line(rank, item))
|
||||
sections.append("\n".join(lines))
|
||||
h_items, _ = _merge_skill_board_items(
|
||||
hot_moves,
|
||||
hot_full or [],
|
||||
hot_limit,
|
||||
exclude_keys=trending_keys,
|
||||
recent_keys=skill_recent,
|
||||
)
|
||||
if h_items:
|
||||
lines = [f"{ICONS['hot']} **Skills Hot Top {len(h_items)}**"]
|
||||
for rank, item in enumerate(h_items, 1):
|
||||
lines.extend(_skill_line(rank, item))
|
||||
sections.append("\n".join(lines))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
if trending_moves:
|
||||
flat = [_move_to_skill_row(m) for m in trending_moves]
|
||||
items, _ = _prepare_grouped_wecom_skills(flat, limit=len(flat))
|
||||
lines = [f"{ICONS['trending']} **Skills Trending 变化**"]
|
||||
for rank, item in enumerate(items, 1):
|
||||
lines.extend(_skill_line(rank, item))
|
||||
sections.append("\n".join(lines))
|
||||
if hot_moves:
|
||||
flat = [_move_to_skill_row(m) for m in hot_moves]
|
||||
items, _ = _prepare_grouped_wecom_skills(flat, limit=len(flat))
|
||||
lines = [f"{ICONS['hot']} **Skills Hot 变化**"]
|
||||
for rank, item in enumerate(items, 1):
|
||||
lines.extend(_skill_line(rank, item))
|
||||
sections.append("\n".join(lines))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _github_move_to_repo(move: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"repo": move.get("repo", "?"),
|
||||
"url": move.get("url", ""),
|
||||
"language": move.get("language", ""),
|
||||
"stars_today_fmt": move.get("stars_today_fmt", ""),
|
||||
"total_stars_fmt": move.get("total_stars_fmt", ""),
|
||||
"created_at": move.get("created_at", ""),
|
||||
"description": move.get("description", ""),
|
||||
"desc_short": (move.get("description") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _merge_github_board_items(
|
||||
moves: list[dict[str, Any]],
|
||||
full_repos: list[dict[str, Any]],
|
||||
limit: int,
|
||||
*,
|
||||
recent_repos: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
recent = recent_repos or set()
|
||||
seen: set[str] = set()
|
||||
merged: list[dict[str, Any]] = []
|
||||
for move in moves:
|
||||
repo = _github_move_to_repo(move)
|
||||
key = str(repo.get("repo") or "")
|
||||
if not key or key in seen or key in recent:
|
||||
continue
|
||||
seen.add(key)
|
||||
merged.append(repo)
|
||||
for repo in full_repos:
|
||||
if len(merged) >= limit:
|
||||
break
|
||||
key = str(repo.get("repo") or "")
|
||||
if not key or key in seen or key in recent:
|
||||
continue
|
||||
seen.add(key)
|
||||
merged.append(repo)
|
||||
return finalize_wecom_github_repos(merged)[:limit]
|
||||
|
||||
|
||||
def build_github_delta_sections(
|
||||
movement: dict[str, Any],
|
||||
*,
|
||||
topic_name: str,
|
||||
github_trending: list[dict[str, Any]] | None = None,
|
||||
github_emerging: list[dict[str, Any]] | None = None,
|
||||
github_topic: list[dict[str, Any]] | None = None,
|
||||
trending_limit: int = 5,
|
||||
emerging_limit: int = 5,
|
||||
topic_limit: int = 5,
|
||||
pad: bool = False,
|
||||
recent_board_keys: dict[str, set[str]] | None = None,
|
||||
) -> str:
|
||||
sections: list[str] = []
|
||||
recent = recent_board_keys or {}
|
||||
if pad:
|
||||
github_recent = (
|
||||
(recent.get("github_trending") or set())
|
||||
| (recent.get("github_emerging") or set())
|
||||
| (recent.get("github_topic") or set())
|
||||
)
|
||||
mapping = [
|
||||
("github_trending_moves", "github_trending", github_trending or [], trending_limit, "github", "GitHub Trending", False),
|
||||
("github_emerging_moves", "github_emerging", github_emerging or [], emerging_limit, "emerging", "GitHub 新兴", True),
|
||||
("github_topic_moves", "github_topic", github_topic or [], topic_limit, "topic", f"Topic `{topic_name}`", False),
|
||||
]
|
||||
for move_key, board_key, full_repos, limit, icon_key, label, show_created in mapping:
|
||||
repos = _merge_github_board_items(
|
||||
movement.get(move_key) or [],
|
||||
full_repos,
|
||||
limit,
|
||||
recent_repos=github_recent,
|
||||
)
|
||||
github_recent |= {str(r.get("repo") or "") for r in repos if r.get("repo")}
|
||||
if not repos:
|
||||
continue
|
||||
lines = [f"{ICONS[icon_key]} **{label} Top {len(repos)}**"]
|
||||
lines.extend(_github_repo_lines(repos, show_created=show_created))
|
||||
sections.append("\n".join(lines))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
mapping = [
|
||||
("github_trending_moves", "github", "GitHub Trending 变化", False),
|
||||
("github_emerging_moves", "emerging", "GitHub 新兴 变化", True),
|
||||
("github_topic_moves", "topic", f"Topic `{topic_name}` 变化", False),
|
||||
]
|
||||
for key, icon_key, label, show_created in mapping:
|
||||
moves = movement.get(key) or []
|
||||
if not moves:
|
||||
continue
|
||||
lines = [f"{ICONS[icon_key]} **{label}**"]
|
||||
repos = finalize_wecom_github_repos([_github_move_to_repo(m) for m in moves])
|
||||
lines.extend(_github_repo_lines(repos, show_created=show_created))
|
||||
sections.append("\n".join(lines))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
_SKILL_SECTIONS = re.compile(
|
||||
r"📈 \*\*Skills Trending.*?(?=🐙 \*\*GitHub Trending)",
|
||||
re.DOTALL,
|
||||
)
|
||||
_SKILL_TRENDING_BLOCK = re.compile(r"📈 \*\*Skills Trending[^\n]*\n(?:.*?\n)*?(?=\n🔥 \*\*Skills Hot|\n🐙 |\n🌱 |\n🤖 |\Z)", re.DOTALL)
|
||||
_SKILL_HOT_BLOCK = re.compile(r"🔥 \*\*Skills Hot[^\n]*\n(?:.*?\n)*?(?=\n🐙 |\n🌱 |\n🤖 |\Z)", re.DOTALL)
|
||||
_GITHUB_SECTIONS = re.compile(r"🐙 \*\*GitHub Trending.*", re.DOTALL)
|
||||
|
||||
|
||||
def replace_wecom_skill_sections(
|
||||
md: str,
|
||||
def _strip_board_sections(md: str) -> str:
|
||||
md = _SKILL_TRENDING_BLOCK.sub("", md)
|
||||
md = _SKILL_HOT_BLOCK.sub("", md)
|
||||
if _GITHUB_SECTIONS.search(md):
|
||||
md = _GITHUB_SECTIONS.sub("", md)
|
||||
return re.sub(r"\n{3,}", "\n\n", md).rstrip()
|
||||
|
||||
|
||||
def resolve_wecom_board_items(
|
||||
*,
|
||||
mode: str,
|
||||
movement: dict[str, Any],
|
||||
trending: list[dict[str, Any]],
|
||||
hot: list[dict[str, Any]],
|
||||
topic_name: str,
|
||||
github_trending: list[dict[str, Any]] | None = None,
|
||||
github_emerging: list[dict[str, Any]] | None = None,
|
||||
github_topic: list[dict[str, Any]] | None = None,
|
||||
wecom_trending: int = 5,
|
||||
wecom_hot: int = 5,
|
||||
wecom_github: int = 5,
|
||||
wecom_emerging: int = 5,
|
||||
wecom_topic: int = 5,
|
||||
pad: bool = False,
|
||||
date_str: str | None = None,
|
||||
trending_pad: list[dict[str, Any]] | None = None,
|
||||
hot_pad: list[dict[str, Any]] | None = None,
|
||||
github_trending_pad: list[dict[str, Any]] | None = None,
|
||||
github_emerging_pad: list[dict[str, Any]] | None = None,
|
||||
github_topic_pad: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""返回最终企微正文各榜 items(与 replace_wecom_board_sections 同源),供写回 shown。"""
|
||||
from daily.delta import load_recent_board_keys, partition_skill_moves_for_wecom
|
||||
|
||||
if mode != "delta":
|
||||
return {
|
||||
"skills_trending": list(trending),
|
||||
"skills_hot": list(hot),
|
||||
"github_trending": list(github_trending or []),
|
||||
"github_emerging": list(github_emerging or []),
|
||||
"github_topic": list(github_topic or []),
|
||||
}
|
||||
|
||||
recent_board_keys: dict[str, set[str]] = {}
|
||||
skill_recent: set[str] = set()
|
||||
github_recent: set[str] = set()
|
||||
if pad and date_str:
|
||||
recent_board_keys = load_recent_board_keys(date_str)
|
||||
# Trending / Hot 共用周去重:任一类出现过的 source 两边都不再展示
|
||||
skill_recent = expand_skill_recent_keys(
|
||||
(recent_board_keys.get("skills_trending") or set())
|
||||
| (recent_board_keys.get("skills_hot") or set())
|
||||
)
|
||||
# GitHub 三榜共用周去重:任一类出现过的 repo 各榜都不再展示
|
||||
github_recent = (
|
||||
(recent_board_keys.get("github_trending") or set())
|
||||
| (recent_board_keys.get("github_emerging") or set())
|
||||
| (recent_board_keys.get("github_topic") or set())
|
||||
)
|
||||
|
||||
t_moves, h_moves = partition_skill_moves_for_wecom(
|
||||
movement.get("skills_trending_moves") or [],
|
||||
movement.get("skills_hot_moves") or [],
|
||||
)
|
||||
if pad:
|
||||
t_items, trending_keys = _merge_skill_board_items(
|
||||
t_moves,
|
||||
trending_pad if trending_pad else trending,
|
||||
wecom_trending,
|
||||
recent_keys=skill_recent,
|
||||
)
|
||||
h_items, _ = _merge_skill_board_items(
|
||||
h_moves,
|
||||
hot_pad if hot_pad else hot,
|
||||
wecom_hot,
|
||||
exclude_keys=trending_keys,
|
||||
recent_keys=skill_recent,
|
||||
)
|
||||
gt_items = _merge_github_board_items(
|
||||
movement.get("github_trending_moves") or [],
|
||||
github_trending_pad if github_trending_pad else (github_trending or []),
|
||||
wecom_github,
|
||||
recent_repos=github_recent,
|
||||
)
|
||||
github_recent |= {str(r.get("repo") or "") for r in gt_items if r.get("repo")}
|
||||
ge_items = _merge_github_board_items(
|
||||
movement.get("github_emerging_moves") or [],
|
||||
github_emerging_pad if github_emerging_pad else (github_emerging or []),
|
||||
wecom_emerging,
|
||||
recent_repos=github_recent,
|
||||
)
|
||||
github_recent |= {str(r.get("repo") or "") for r in ge_items if r.get("repo")}
|
||||
gtopic_items = _merge_github_board_items(
|
||||
movement.get("github_topic_moves") or [],
|
||||
github_topic_pad if github_topic_pad else (github_topic or []),
|
||||
wecom_topic,
|
||||
recent_repos=github_recent,
|
||||
)
|
||||
return {
|
||||
"skills_trending": t_items,
|
||||
"skills_hot": h_items,
|
||||
"github_trending": gt_items,
|
||||
"github_emerging": ge_items,
|
||||
"github_topic": gtopic_items,
|
||||
}
|
||||
|
||||
t_flat = [_move_to_skill_row(m) for m in t_moves]
|
||||
h_flat = [_move_to_skill_row(m) for m in h_moves]
|
||||
t_items, _ = _prepare_grouped_wecom_skills(t_flat, limit=len(t_flat) or 1) if t_flat else ([], set())
|
||||
h_items, _ = _prepare_grouped_wecom_skills(h_flat, limit=len(h_flat) or 1) if h_flat else ([], set())
|
||||
return {
|
||||
"skills_trending": t_items,
|
||||
"skills_hot": h_items,
|
||||
"github_trending": finalize_wecom_github_repos(
|
||||
[_github_move_to_repo(m) for m in (movement.get("github_trending_moves") or [])]
|
||||
),
|
||||
"github_emerging": finalize_wecom_github_repos(
|
||||
[_github_move_to_repo(m) for m in (movement.get("github_emerging_moves") or [])]
|
||||
),
|
||||
"github_topic": finalize_wecom_github_repos(
|
||||
[_github_move_to_repo(m) for m in (movement.get("github_topic_moves") or [])]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def replace_wecom_board_sections(
|
||||
md: str,
|
||||
*,
|
||||
mode: str,
|
||||
movement: dict[str, Any],
|
||||
trending: list[dict[str, Any]],
|
||||
hot: list[dict[str, Any]],
|
||||
topic_name: str,
|
||||
github_trending: list[dict[str, Any]] | None = None,
|
||||
github_emerging: list[dict[str, Any]] | None = None,
|
||||
github_topic: list[dict[str, Any]] | None = None,
|
||||
wecom_trending: int = 5,
|
||||
wecom_hot: int = 5,
|
||||
wecom_github: int = 5,
|
||||
wecom_emerging: int = 5,
|
||||
wecom_topic: int = 5,
|
||||
pad: bool = False,
|
||||
date_str: str | None = None,
|
||||
trending_pad: list[dict[str, Any]] | None = None,
|
||||
hot_pad: list[dict[str, Any]] | None = None,
|
||||
github_trending_pad: list[dict[str, Any]] | None = None,
|
||||
github_emerging_pad: list[dict[str, Any]] | None = None,
|
||||
github_topic_pad: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""用 Python 合并后的 Skills 榜替换或插入 Agent 早报中的对应区块。"""
|
||||
from daily.delta import load_recent_board_keys, partition_skill_moves_for_wecom
|
||||
|
||||
recent_board_keys: dict[str, set[str]] = {}
|
||||
if pad and date_str:
|
||||
recent_board_keys = load_recent_board_keys(date_str)
|
||||
|
||||
if mode == "delta":
|
||||
t_moves, h_moves = partition_skill_moves_for_wecom(
|
||||
movement.get("skills_trending_moves") or [],
|
||||
movement.get("skills_hot_moves") or [],
|
||||
)
|
||||
skills_sec = build_skills_delta_sections(
|
||||
t_moves,
|
||||
h_moves,
|
||||
trending_full=trending_pad if pad and trending_pad else trending,
|
||||
hot_full=hot_pad if pad and hot_pad else hot,
|
||||
trending_limit=wecom_trending,
|
||||
hot_limit=wecom_hot,
|
||||
pad=pad,
|
||||
recent_trending=recent_board_keys.get("skills_trending"),
|
||||
recent_hot=recent_board_keys.get("skills_hot"),
|
||||
)
|
||||
github_sec = build_github_delta_sections(
|
||||
movement,
|
||||
topic_name=topic_name,
|
||||
github_trending=github_trending_pad if pad and github_trending_pad else github_trending,
|
||||
github_emerging=github_emerging_pad if pad and github_emerging_pad else github_emerging,
|
||||
github_topic=github_topic_pad if pad and github_topic_pad else github_topic,
|
||||
trending_limit=wecom_github,
|
||||
emerging_limit=wecom_emerging,
|
||||
topic_limit=wecom_topic,
|
||||
pad=pad,
|
||||
recent_board_keys=recent_board_keys,
|
||||
)
|
||||
board_block = "\n\n".join(x for x in [skills_sec, github_sec] if x)
|
||||
md = _strip_board_sections(md)
|
||||
if board_block:
|
||||
return md + "\n\n" + board_block + "\n"
|
||||
return md + "\n"
|
||||
|
||||
trending_sec = build_skills_board_section("trending", "Skills Trending", trending)
|
||||
hot_sec = build_skills_board_section("hot", "Skills Hot", hot)
|
||||
replacement = f"{trending_sec}\n\n{hot_sec}\n\n"
|
||||
@@ -218,6 +947,76 @@ def replace_wecom_skill_sections(
|
||||
return md.rstrip() + "\n\n" + replacement
|
||||
|
||||
|
||||
def replace_wecom_skill_sections(
|
||||
md: str,
|
||||
*,
|
||||
trending: list[dict[str, Any]],
|
||||
hot: list[dict[str, Any]],
|
||||
mode: str = "full",
|
||||
movement: dict[str, Any] | None = None,
|
||||
topic_name: str = "llm",
|
||||
github_trending: list[dict[str, Any]] | None = None,
|
||||
github_emerging: list[dict[str, Any]] | None = None,
|
||||
github_topic: list[dict[str, Any]] | None = None,
|
||||
wecom_trending: int = 5,
|
||||
wecom_hot: int = 5,
|
||||
wecom_github: int = 5,
|
||||
wecom_emerging: int = 5,
|
||||
wecom_topic: int = 5,
|
||||
pad: bool = False,
|
||||
date_str: str | None = None,
|
||||
trending_pad: list[dict[str, Any]] | None = None,
|
||||
hot_pad: list[dict[str, Any]] | None = None,
|
||||
github_trending_pad: list[dict[str, Any]] | None = None,
|
||||
github_emerging_pad: list[dict[str, Any]] | None = None,
|
||||
github_topic_pad: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""用 Python 合并后的 Skills 榜替换或插入 Agent 早报中的对应区块。"""
|
||||
return replace_wecom_board_sections(
|
||||
md,
|
||||
mode=mode,
|
||||
movement=movement or {},
|
||||
trending=trending,
|
||||
hot=hot,
|
||||
topic_name=topic_name,
|
||||
github_trending=github_trending,
|
||||
github_emerging=github_emerging,
|
||||
github_topic=github_topic,
|
||||
wecom_trending=wecom_trending,
|
||||
wecom_hot=wecom_hot,
|
||||
wecom_github=wecom_github,
|
||||
wecom_emerging=wecom_emerging,
|
||||
wecom_topic=wecom_topic,
|
||||
pad=pad,
|
||||
date_str=date_str,
|
||||
trending_pad=trending_pad,
|
||||
hot_pad=hot_pad,
|
||||
github_trending_pad=github_trending_pad,
|
||||
github_emerging_pad=github_emerging_pad,
|
||||
github_topic_pad=github_topic_pad,
|
||||
)
|
||||
|
||||
|
||||
def _format_pick_link(pick_command: str, *, title: str = "", url: str = "") -> str:
|
||||
cmd = pick_command.strip()
|
||||
if not cmd:
|
||||
return ""
|
||||
label = title.strip()
|
||||
if cmd.startswith("http://") or cmd.startswith("https://"):
|
||||
if not label:
|
||||
m = re.match(r"https?://github\.com/([^/\s#?]+/[^/\s#?]+)", cmd)
|
||||
label = m.group(1) if m else cmd
|
||||
return f"[{label}]({cmd})"
|
||||
m = re.match(r"npx skills add (\S+)", cmd)
|
||||
if m:
|
||||
skill_path = m.group(1)
|
||||
if not label:
|
||||
label = skill_path.split("/")[-1]
|
||||
href = url.strip() or f"https://skills.sh/{skill_path}"
|
||||
return f"[{label}]({href})"
|
||||
return f"`{cmd}`"
|
||||
|
||||
|
||||
def build_wecom_report(
|
||||
*,
|
||||
date_str: str,
|
||||
@@ -233,7 +1032,13 @@ def build_wecom_report(
|
||||
topic_repos: list[dict[str, Any]],
|
||||
ai_news: list[dict[str, Any]] | None = None,
|
||||
cn_ai_news: list[dict[str, Any]] | None = None,
|
||||
merged_ai_news: list[dict[str, Any]] | None = None,
|
||||
merged_tech_ai_news: list[dict[str, Any]] | None = None,
|
||||
pick_command: str,
|
||||
pick_why: str = "",
|
||||
pick_title: str = "",
|
||||
pick_url: str = "",
|
||||
include_boards: bool = True,
|
||||
) -> str:
|
||||
lines = [
|
||||
f"{ICONS['header']} **早报 · {date_str}**",
|
||||
@@ -247,42 +1052,51 @@ def build_wecom_report(
|
||||
lines.append(f"{ICONS['theme']} {theme_line}")
|
||||
lines.append("")
|
||||
|
||||
if ai_news:
|
||||
lines.append(f"{ICONS['ainews']} **国际 AI 时讯 Top {len(ai_news)}**")
|
||||
lines.extend(_ai_news_lines(ai_news))
|
||||
if merged_ai_news or merged_tech_ai_news:
|
||||
block = _build_merged_news_block(merged_ai_news or [], merged_tech_ai_news)
|
||||
if block:
|
||||
lines.append(block.rstrip())
|
||||
lines.append("")
|
||||
else:
|
||||
if ai_news:
|
||||
lines.append(f"{ICONS['ainews']} **国际 AI 时讯 Top {len(ai_news)}**")
|
||||
lines.extend(_ai_news_lines(ai_news))
|
||||
lines.append("")
|
||||
|
||||
if cn_ai_news:
|
||||
lines.append(f"{ICONS['cnainews']} **国内 AI 时讯 Top {len(cn_ai_news)}**")
|
||||
lines.extend(_ai_news_lines(cn_ai_news))
|
||||
lines.append("")
|
||||
|
||||
if include_boards:
|
||||
lines.append(f"{ICONS['trending']} **Skills Trending Top {len(trending)}**")
|
||||
for rank, item in enumerate(trending, 1):
|
||||
lines.extend(_skill_line(rank, item, badge=item.get("badge", "")))
|
||||
lines.append("")
|
||||
|
||||
if cn_ai_news:
|
||||
lines.append(f"{ICONS['cnainews']} **国内 AI 时讯 Top {len(cn_ai_news)}**")
|
||||
lines.extend(_ai_news_lines(cn_ai_news))
|
||||
lines.append(f"{ICONS['hot']} **Skills Hot Top {len(hot)}**")
|
||||
for rank, item in enumerate(hot, 1):
|
||||
lines.extend(_skill_line(rank, item, badge=item.get("badge", "")))
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"{ICONS['trending']} **Skills Trending Top {len(trending)}**")
|
||||
for rank, item in enumerate(trending, 1):
|
||||
lines.extend(_skill_line(rank, item, badge=item.get("badge", "")))
|
||||
lines.append("")
|
||||
if repos:
|
||||
lines.append(f"{ICONS['github']} **GitHub Trending Top {len(repos)}**")
|
||||
lines.extend(_github_repo_lines(finalize_wecom_github_repos(repos)))
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"{ICONS['hot']} **Skills Hot Top {len(hot)}**")
|
||||
for rank, item in enumerate(hot, 1):
|
||||
lines.extend(_skill_line(rank, item, badge=item.get("badge", "")))
|
||||
lines.append("")
|
||||
if emerging:
|
||||
lines.append(f"{ICONS['emerging']} **新兴项目 Top {len(emerging)}**")
|
||||
lines.extend(_github_repo_lines(finalize_wecom_github_repos(emerging), show_created=True))
|
||||
lines.append("")
|
||||
|
||||
if repos:
|
||||
lines.append(f"{ICONS['github']} **GitHub Trending Top {len(repos)}**")
|
||||
lines.extend(_github_repo_lines(repos))
|
||||
lines.append("")
|
||||
|
||||
if emerging:
|
||||
lines.append(f"{ICONS['emerging']} **新兴项目 Top {len(emerging)}**")
|
||||
lines.extend(_github_repo_lines(emerging, show_created=True))
|
||||
lines.append("")
|
||||
|
||||
if topic_repos:
|
||||
lines.append(f"{ICONS['topic']} **Topic `{topic_name}` Top {len(topic_repos)}**")
|
||||
lines.extend(_github_repo_lines(topic_repos))
|
||||
lines.append("")
|
||||
if topic_repos:
|
||||
lines.append(f"{ICONS['topic']} **Topic `{topic_name}` Top {len(topic_repos)}**")
|
||||
lines.extend(_github_repo_lines(finalize_wecom_github_repos(topic_repos)))
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"{ICONS['pick']} **今日首推**")
|
||||
lines.append(f"`{pick_command}`")
|
||||
lines.append(_format_pick_link(pick_command, title=pick_title, url=pick_url))
|
||||
if pick_why:
|
||||
lines.append(f"> {pick_why}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -22,15 +22,38 @@ from daily.config import (
|
||||
LOG_DIR,
|
||||
OUTPUT_DIR,
|
||||
SNAPSHOT_FILE,
|
||||
ensure_bot_on_path,
|
||||
board_pool_size,
|
||||
env,
|
||||
env_int,
|
||||
full_desc_limit,
|
||||
news_summary_limit,
|
||||
wecom_delta_pad,
|
||||
wecom_news_desc_limit,
|
||||
wecom_pad_pool_size,
|
||||
wecom_skill_desc_limit,
|
||||
)
|
||||
from daily.format_wecom import build_wecom_report, finalize_wecom_skill_groups, replace_wecom_skill_sections
|
||||
from daily.board_history import (
|
||||
BOARD_KEYS,
|
||||
extract_shown_keys,
|
||||
load_recent_shown_keys,
|
||||
merge_wecom_shown_into_data,
|
||||
)
|
||||
from daily.board_select import board_select
|
||||
from daily.format_wecom import (
|
||||
build_wecom_report,
|
||||
finalize_wecom_skill_groups,
|
||||
replace_wecom_board_sections,
|
||||
replace_wecom_news_sections,
|
||||
replace_wecom_skill_sections,
|
||||
resolve_wecom_board_items,
|
||||
)
|
||||
from daily.agent_workflow import is_agent_mode, run_agent_workflow
|
||||
from daily.featured_pick import (
|
||||
apply_featured_pick,
|
||||
featured_identity_key,
|
||||
pick_command_from_featured,
|
||||
pick_why_from_featured,
|
||||
)
|
||||
from daily.delta import compare_depth
|
||||
from daily.cursor_editor import (
|
||||
apply_descriptions,
|
||||
@@ -47,20 +70,25 @@ from daily.news.fetch import (
|
||||
fetch_cn_ai_news,
|
||||
format_cn_news_section,
|
||||
format_news_section,
|
||||
finalize_wecom_news_items,
|
||||
prepare_wecom_cn_news_items,
|
||||
prepare_wecom_news_items,
|
||||
sync_wecom_news_rows,
|
||||
)
|
||||
from daily.news.pushed_links import record_pushed_links
|
||||
from daily.news.research import (
|
||||
fetch_ai_news_research,
|
||||
format_research_news_section,
|
||||
is_research_mode,
|
||||
)
|
||||
from daily.push_gate import evaluate_push_gate
|
||||
from daily.report_data import (
|
||||
build_full_payload,
|
||||
build_llm_input,
|
||||
data_json_path,
|
||||
save_json,
|
||||
)
|
||||
from daily.skills_board import load_boards
|
||||
from daily.skills_group import group_skills_by_source
|
||||
|
||||
ensure_bot_on_path()
|
||||
from skills_service import _format_installs, load_feed # noqa: E402
|
||||
from daily.skills_board import format_installs, load_boards, load_feed
|
||||
|
||||
THEME_RULES: list[tuple[str, str, list[str]]] = [
|
||||
("🎬", "AI 多媒体 / 视频", ["runcomfy", "remotion", "video", "seedance", "inpaint", "lipsync"]),
|
||||
@@ -132,7 +160,30 @@ def _localize_descriptions_in_place(
|
||||
seen_news.add(link)
|
||||
summary = (item.get("summary") or "").strip()
|
||||
if summary:
|
||||
jobs.append(LocalizeJob(f"news:{link}", summary, news_limit))
|
||||
jobs.append(
|
||||
LocalizeJob(
|
||||
f"news:{link}",
|
||||
summary,
|
||||
news_limit if news_limit > 0 else wecom_news_desc_limit(),
|
||||
)
|
||||
)
|
||||
|
||||
if cn_ai_news and cn_ai_news.get("enabled"):
|
||||
seen_cn: set[str] = set()
|
||||
for item in cn_ai_news.get("flat") or []:
|
||||
link = item.get("link", "")
|
||||
if not link or link in seen_cn:
|
||||
continue
|
||||
seen_cn.add(link)
|
||||
summary = (item.get("summary") or "").strip()
|
||||
if summary and needs_chinese(summary):
|
||||
jobs.append(
|
||||
LocalizeJob(
|
||||
f"news:{link}",
|
||||
summary,
|
||||
news_limit if news_limit > 0 else wecom_news_desc_limit(),
|
||||
)
|
||||
)
|
||||
|
||||
zh_map = localize_descriptions(jobs, archive=True)
|
||||
if not zh_map and not jobs:
|
||||
@@ -158,6 +209,11 @@ def _localize_descriptions_in_place(
|
||||
key = f"news:{item.get('link', '')}"
|
||||
if key in mapping:
|
||||
item["summary"] = mapping[key]
|
||||
if cn_ai_news and cn_ai_news.get("enabled"):
|
||||
for item in cn_ai_news.get("flat") or []:
|
||||
key = f"news:{item.get('link', '')}"
|
||||
if key in mapping:
|
||||
item["summary"] = mapping[key]
|
||||
|
||||
_apply_zh(zh_map)
|
||||
|
||||
@@ -191,7 +247,29 @@ def _localize_descriptions_in_place(
|
||||
seen_news.add(link)
|
||||
summary = (item.get("summary") or "").strip()
|
||||
if needs_chinese(summary):
|
||||
retry_jobs.append(LocalizeJob(f"news:{link}", summary, news_limit))
|
||||
retry_jobs.append(
|
||||
LocalizeJob(
|
||||
f"news:{link}",
|
||||
summary,
|
||||
news_limit if news_limit > 0 else wecom_news_desc_limit(),
|
||||
)
|
||||
)
|
||||
if cn_ai_news and cn_ai_news.get("enabled"):
|
||||
seen_cn: set[str] = set()
|
||||
for item in cn_ai_news.get("flat") or []:
|
||||
link = item.get("link", "")
|
||||
if not link or link in seen_cn:
|
||||
continue
|
||||
seen_cn.add(link)
|
||||
summary = (item.get("summary") or "").strip()
|
||||
if needs_chinese(summary):
|
||||
retry_jobs.append(
|
||||
LocalizeJob(
|
||||
f"news:{link}",
|
||||
summary,
|
||||
news_limit if news_limit > 0 else wecom_news_desc_limit(),
|
||||
)
|
||||
)
|
||||
|
||||
if retry_jobs:
|
||||
_apply_zh(localize_descriptions(retry_jobs, archive=True))
|
||||
@@ -232,7 +310,7 @@ def _prepare_skill_item(item: dict[str, Any], prev_ids: set[str], rank: int) ->
|
||||
badge = "🆕"
|
||||
elif rank == 1:
|
||||
badge = "👑"
|
||||
installs_fmt = item.get("installs_fmt") or _format_installs(item.get("installs", 0))
|
||||
installs_fmt = item.get("installs_fmt") or format_installs(item.get("installs", 0))
|
||||
title = item.get("source", "?") if item.get("cluster") else item.get("title", "?")
|
||||
desc = item.get("wecom_desc") or item.get("description") or item.get("cluster_titles") or ""
|
||||
limit = wecom_skill_desc_limit()
|
||||
@@ -303,7 +381,7 @@ def _build_highlights(
|
||||
)
|
||||
if trending:
|
||||
t0 = trending[0]
|
||||
points.append(f"📈 Skills 榜首 **{t0.get('title')}**({_format_installs(t0.get('installs', 0))})")
|
||||
points.append(f"📈 Skills 榜首 **{t0.get('title')}**({format_installs(t0.get('installs', 0))})")
|
||||
if github_trending:
|
||||
g0 = github_trending[0]
|
||||
stars = g0.get("stars_today_fmt", "")
|
||||
@@ -315,7 +393,7 @@ def _build_highlights(
|
||||
points.append(f"🌱 新兴 [{e0['repo']}]({e0['url']})(⭐ {e0.get('total_stars_fmt', '?')})")
|
||||
elif hot:
|
||||
h0 = hot[0]
|
||||
points.append(f"🔥 Skills Hot 榜首 **{h0.get('title')}**(1H {_format_installs(h0.get('installs', 0))})")
|
||||
points.append(f"🔥 Skills Hot 榜首 **{h0.get('title')}**(1H {format_installs(h0.get('installs', 0))})")
|
||||
while len(points) < 3 and len(trending) > len(points):
|
||||
item = trending[len(points)]
|
||||
points.append(f"✨ **{item.get('title')}** · `{item.get('source')}`")
|
||||
@@ -326,6 +404,27 @@ def _prepare_github_item(item: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**item, "desc_short": _wecom_desc(item.get("description", ""), 40)}
|
||||
|
||||
|
||||
def _sync_movement_github_descriptions(
|
||||
movement: dict[str, Any],
|
||||
*,
|
||||
github_trending: list[dict[str, Any]],
|
||||
github_emerging: list[dict[str, Any]],
|
||||
github_topic: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""将已中文化的 GitHub 描述同步到 movement 新入榜条目(供 Delta 企微列表使用)。"""
|
||||
by_repo: dict[str, str] = {}
|
||||
for item in github_trending + github_emerging + github_topic:
|
||||
repo = str(item.get("repo") or "")
|
||||
desc = (item.get("description") or "").strip()
|
||||
if repo and desc:
|
||||
by_repo[repo] = desc
|
||||
for key in ("github_trending_moves", "github_emerging_moves", "github_topic_moves"):
|
||||
for move in movement.get(key) or []:
|
||||
repo = str(move.get("repo") or "")
|
||||
if repo in by_repo:
|
||||
move["description"] = by_repo[repo]
|
||||
|
||||
|
||||
def _fetch_latest_release_title(repo: str) -> str | None:
|
||||
atom_url = f"https://github.com/{repo}/releases.atom"
|
||||
try:
|
||||
@@ -397,7 +496,7 @@ def _format_skill_section(items: list[dict[str, Any]], *, hot: bool = False) ->
|
||||
for i, item in enumerate(items, 1):
|
||||
skill_id = item.get("id") or f"{item.get('source', '?')}/{item.get('title', '?')}"
|
||||
link = item.get("link", "")
|
||||
installs = _format_installs(item.get("installs", 0))
|
||||
installs = format_installs(item.get("installs", 0))
|
||||
meta = f"1H {installs}" if hot else f"总安装 {installs}"
|
||||
if link:
|
||||
lines.append(f"{i}. **[{skill_id}]({link})** · {meta}")
|
||||
@@ -411,21 +510,26 @@ def _format_skill_section(items: list[dict[str, Any]], *, hot: bool = False) ->
|
||||
|
||||
|
||||
def generate_report() -> tuple[str, str, Path, Path]:
|
||||
trending_n = env_int("DAILY_TRENDING_LIMIT", 150)
|
||||
hot_n = max(env_int("DAILY_HOT_LIMIT", 150), compare_depth())
|
||||
# Hot/Trending 前排同 source 极密,需更深抓取才能凑够展示用的唯一 source
|
||||
trending_n = env_int("DAILY_TRENDING_LIMIT", 400)
|
||||
hot_n = max(env_int("DAILY_HOT_LIMIT", 400), compare_depth())
|
||||
compare_n = compare_depth()
|
||||
skill_pool = max(10, env_int("DAILY_WECOM_SKILL_POOL", 200))
|
||||
wecom_trending = env_int("DAILY_WECOM_TRENDING", 10)
|
||||
wecom_hot = env_int("DAILY_WECOM_HOT", 10)
|
||||
skill_pool = max(10, env_int("DAILY_WECOM_SKILL_POOL", 400))
|
||||
wecom_trending = env_int("DAILY_WECOM_TRENDING", 5)
|
||||
wecom_hot = env_int("DAILY_WECOM_HOT", 5)
|
||||
pad_pool = wecom_pad_pool_size(max(wecom_trending, wecom_hot, 5))
|
||||
skill_pool = max(skill_pool, pad_pool)
|
||||
github_limit = env_int("DAILY_GITHUB_TRENDING_LIMIT", 10)
|
||||
wecom_github = env_int("DAILY_WECOM_GITHUB_TRENDING", env_int("DAILY_WECOM_REPOS", 10))
|
||||
github_fetch_n = max(github_limit, compare_n, wecom_github)
|
||||
wecom_github = env_int("DAILY_WECOM_GITHUB_TRENDING", env_int("DAILY_WECOM_REPOS", 5))
|
||||
# 周去重后顶刊 sticky,HTML/~30 条不够补满;深池默认 100(Search 已分页)
|
||||
github_pool = max(pad_pool, env_int("DAILY_GITHUB_POOL", 100))
|
||||
github_fetch_n = max(github_limit, compare_n, wecom_github, github_pool)
|
||||
emerging_limit = env_int("DAILY_GITHUB_EMERGING_LIMIT", 10)
|
||||
wecom_emerging = env_int("DAILY_WECOM_GITHUB_EMERGING", 10)
|
||||
emerging_fetch_n = max(emerging_limit, compare_n, wecom_emerging)
|
||||
wecom_emerging = env_int("DAILY_WECOM_GITHUB_EMERGING", 5)
|
||||
emerging_fetch_n = max(emerging_limit, compare_n, wecom_emerging, github_pool)
|
||||
topic_limit = env_int("DAILY_GITHUB_TOPIC_LIMIT", 10)
|
||||
wecom_topic = env_int("DAILY_WECOM_GITHUB_TOPIC", 10)
|
||||
topic_fetch_n = max(topic_limit, compare_n, wecom_topic)
|
||||
wecom_topic = env_int("DAILY_WECOM_GITHUB_TOPIC", 5)
|
||||
topic_fetch_n = max(topic_limit, compare_n, wecom_topic, github_pool)
|
||||
|
||||
feed = load_feed(force=True)
|
||||
prev_ids = _load_snapshot()
|
||||
@@ -440,8 +544,27 @@ def generate_report() -> tuple[str, str, Path, Path]:
|
||||
github_emerging = fetch_emerging_repos(emerging_fetch_n, exclude=seen_repos)
|
||||
seen_repos.update(r["repo"] for r in github_emerging)
|
||||
topic_name, github_topic = fetch_topic_hot_repos(topic_fetch_n, exclude=seen_repos)
|
||||
ai_news = fetch_ai_news()
|
||||
cn_ai_news = fetch_cn_ai_news()
|
||||
|
||||
news_merged = is_research_mode()
|
||||
ai_news_research: dict[str, Any] | None = None
|
||||
wecom_news: list[dict[str, Any]] = []
|
||||
wecom_tech_news: list[dict[str, Any]] = []
|
||||
if news_merged:
|
||||
ai_news_research = fetch_ai_news_research(date_str=date_str)
|
||||
wecom_news = list(ai_news_research.get("items") or [])
|
||||
wecom_tech_news = list(ai_news_research.get("tech_items") or [])
|
||||
ai_news = {
|
||||
"enabled": ai_news_research.get("enabled", False),
|
||||
"mode": "research",
|
||||
"hours": ai_news_research.get("hours", 24),
|
||||
"flat": ai_news_research.get("flat") or [],
|
||||
"categories": [],
|
||||
"stats": ai_news_research.get("stats") or {},
|
||||
}
|
||||
cn_ai_news = {"enabled": False, "categories": [], "flat": [], "stats": {}}
|
||||
else:
|
||||
ai_news = fetch_ai_news()
|
||||
cn_ai_news = fetch_cn_ai_news()
|
||||
|
||||
wecom_limits = {
|
||||
"trending": wecom_trending,
|
||||
@@ -452,7 +575,69 @@ def generate_report() -> tuple[str, str, Path, Path]:
|
||||
"emerging": wecom_emerging,
|
||||
"topic": wecom_topic,
|
||||
"ai_news": env_int("DAILY_WECOM_AI_NEWS", 10),
|
||||
"cn_ai_news": env_int("DAILY_WECOM_CN_AI_NEWS", 8),
|
||||
"cn_ai_news": env_int("DAILY_WECOM_CN_AI_NEWS", 10),
|
||||
}
|
||||
pool = max(board_pool_size(), skill_pool, pad_pool)
|
||||
recent_shown = load_recent_shown_keys(date_str)
|
||||
from daily.skills_group import expand_skill_recent_keys
|
||||
|
||||
skill_recent = expand_skill_recent_keys(
|
||||
recent_shown["skills_trending"] | recent_shown["skills_hot"]
|
||||
)
|
||||
# GitHub 三榜共用周去重:任一类出现过的 repo 各榜都不再展示
|
||||
github_recent = (
|
||||
recent_shown["github_trending"]
|
||||
| recent_shown["github_emerging"]
|
||||
| recent_shown["github_topic"]
|
||||
)
|
||||
selected_trending = board_select(
|
||||
board="skills_trending",
|
||||
items=trending,
|
||||
recent_keys=skill_recent,
|
||||
limit=wecom_trending,
|
||||
pool_size=pool,
|
||||
kind="skill",
|
||||
)
|
||||
selected_hot = board_select(
|
||||
board="skills_hot",
|
||||
items=hot,
|
||||
recent_keys=skill_recent,
|
||||
limit=wecom_hot,
|
||||
pool_size=pool,
|
||||
kind="skill",
|
||||
)
|
||||
selected_github = board_select(
|
||||
board="github_trending",
|
||||
items=github_trending,
|
||||
recent_keys=github_recent,
|
||||
limit=wecom_github,
|
||||
pool_size=pool,
|
||||
kind="github",
|
||||
)
|
||||
github_recent |= {str(r.get("repo") or "") for r in selected_github if r.get("repo")}
|
||||
selected_emerging = board_select(
|
||||
board="github_emerging",
|
||||
items=github_emerging,
|
||||
recent_keys=github_recent,
|
||||
limit=wecom_emerging,
|
||||
pool_size=pool,
|
||||
kind="github",
|
||||
)
|
||||
github_recent |= {str(r.get("repo") or "") for r in selected_emerging if r.get("repo")}
|
||||
selected_topic = board_select(
|
||||
board="github_topic",
|
||||
items=github_topic,
|
||||
recent_keys=github_recent,
|
||||
limit=wecom_topic,
|
||||
pool_size=pool,
|
||||
kind="github",
|
||||
)
|
||||
boards_for_wecom = {
|
||||
"skills_trending": selected_trending,
|
||||
"skills_hot": selected_hot,
|
||||
"github_trending": selected_github,
|
||||
"github_emerging": selected_emerging,
|
||||
"github_topic": selected_topic,
|
||||
}
|
||||
llm_input = build_llm_input(
|
||||
date_str=date_str,
|
||||
@@ -466,18 +651,67 @@ def generate_report() -> tuple[str, str, Path, Path]:
|
||||
ai_news=ai_news,
|
||||
cn_ai_news=cn_ai_news,
|
||||
wecom_limits=wecom_limits,
|
||||
research_items=wecom_news if news_merged else None,
|
||||
research_tech_items=wecom_tech_news if news_merged else None,
|
||||
boards_for_wecom=boards_for_wecom,
|
||||
)
|
||||
save_json(
|
||||
data_json_path(date_str),
|
||||
build_full_payload(
|
||||
llm_input,
|
||||
meta={
|
||||
"generated_at": now.isoformat(),
|
||||
"report_mode": "agent" if is_agent_mode() else "classic",
|
||||
"cursor_editor": cursor_editor_enabled() and not is_agent_mode(),
|
||||
},
|
||||
),
|
||||
pool_a: list[dict[str, Any]] = []
|
||||
pool_a_keys: set[str] = set()
|
||||
for board_name, items in boards_for_wecom.items():
|
||||
for item in items:
|
||||
keyed = dict(item)
|
||||
keyed["board"] = board_name
|
||||
pool_a.append(keyed)
|
||||
ik = featured_identity_key(keyed)
|
||||
if ik:
|
||||
pool_a_keys.add(ik)
|
||||
pool_b: list[dict[str, Any]] = []
|
||||
for board_name, raw_items, kind in (
|
||||
("skills_trending", trending, "skill"),
|
||||
("skills_hot", hot, "skill"),
|
||||
("github_trending", github_trending, "github"),
|
||||
("github_emerging", github_emerging, "github"),
|
||||
("github_topic", github_topic, "github"),
|
||||
):
|
||||
deep = board_select(
|
||||
board=board_name,
|
||||
items=raw_items,
|
||||
recent_keys=set(),
|
||||
limit=pool,
|
||||
pool_size=pool,
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
)
|
||||
for item in deep:
|
||||
keyed = dict(item)
|
||||
keyed["board"] = board_name
|
||||
ik = featured_identity_key(keyed)
|
||||
if ik and ik not in pool_a_keys:
|
||||
pool_b.append(keyed)
|
||||
featured = apply_featured_pick(
|
||||
llm_input,
|
||||
date_str=date_str,
|
||||
pool_a=pool_a,
|
||||
pool_b=pool_b,
|
||||
)
|
||||
movement = llm_input["movement"]
|
||||
eff_mode = llm_input["effective_wecom_mode"]
|
||||
if news_merged:
|
||||
wecom_ai: list[dict[str, Any]] = []
|
||||
wecom_cn: list[dict[str, Any]] = []
|
||||
else:
|
||||
wecom_ai = prepare_wecom_news_items(ai_news, date_str=date_str)
|
||||
wecom_cn = prepare_wecom_cn_news_items(cn_ai_news, date_str=date_str)
|
||||
push_gate = evaluate_push_gate(
|
||||
movement=movement,
|
||||
ai_news_items=wecom_news if news_merged else wecom_ai,
|
||||
cn_ai_news_items=wecom_cn,
|
||||
featured_pick=featured,
|
||||
)
|
||||
llm_input["push_gate"] = {
|
||||
"should_push": push_gate.should_push,
|
||||
"silent": push_gate.silent,
|
||||
"reasons": push_gate.reasons,
|
||||
}
|
||||
|
||||
agent_wecom: str | None = None
|
||||
if is_agent_mode():
|
||||
@@ -513,6 +747,17 @@ def generate_report() -> tuple[str, str, Path, Path]:
|
||||
_localize_descriptions_in_place(
|
||||
trending, hot, github_trending, github_emerging, github_topic, ai_news, cn_ai_news
|
||||
)
|
||||
if not news_merged:
|
||||
sync_wecom_news_rows(wecom_ai, ai_news.get("flat") or [])
|
||||
sync_wecom_news_rows(wecom_cn, cn_ai_news.get("flat") or [])
|
||||
finalize_wecom_news_items(wecom_ai, force_chinese=True)
|
||||
finalize_wecom_news_items(wecom_cn, force_chinese=False)
|
||||
_sync_movement_github_descriptions(
|
||||
movement,
|
||||
github_trending=github_trending,
|
||||
github_emerging=github_emerging,
|
||||
github_topic=github_topic,
|
||||
)
|
||||
|
||||
themes = _theme_clusters(feed)
|
||||
|
||||
@@ -521,7 +766,8 @@ def generate_report() -> tuple[str, str, Path, Path]:
|
||||
"",
|
||||
f"> 生成时间:{now.strftime('%Y-%m-%d %H:%M')} (UTC+8) ",
|
||||
f"> skills 数据更新:{updated} ",
|
||||
"> 数据来源:[skills.sh/trending](https://skills.sh/trending) · [skills.sh/hot](https://skills.sh/hot) · 国际/国内 AI RSS",
|
||||
"> 数据来源:[skills.sh/trending](https://skills.sh/trending) · [skills.sh/hot](https://skills.sh/hot)"
|
||||
+ (" · AI 时讯 Deep Research" if news_merged else " · 国际/国内 AI RSS"),
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
@@ -563,10 +809,14 @@ def generate_report() -> tuple[str, str, Path, Path]:
|
||||
lines.append("")
|
||||
|
||||
section_no = 6
|
||||
lines.extend(format_news_section(ai_news, section_no=section_no))
|
||||
section_no += 1
|
||||
lines.extend(format_cn_news_section(cn_ai_news, section_no=section_no))
|
||||
section_no += 1
|
||||
if news_merged and ai_news_research is not None:
|
||||
lines.extend(format_research_news_section(ai_news_research, section_no=section_no))
|
||||
section_no += 1
|
||||
else:
|
||||
lines.extend(format_news_section(ai_news, section_no=section_no))
|
||||
section_no += 1
|
||||
lines.extend(format_cn_news_section(cn_ai_news, section_no=section_no))
|
||||
section_no += 1
|
||||
|
||||
watch = (env("GITHUB_REPOS") or "").strip()
|
||||
if watch:
|
||||
@@ -586,11 +836,14 @@ def generate_report() -> tuple[str, str, Path, Path]:
|
||||
|
||||
pick_src = trending[0].get("source", "") if trending else ""
|
||||
pick_name = trending[0].get("title", "") if trending else ""
|
||||
pick_command = (
|
||||
pick_command = pick_command_from_featured(featured) or (
|
||||
f"npx skills add {pick_src}/{pick_name}"
|
||||
if pick_src and pick_name
|
||||
else "npx skills add vercel-labs/skills/find-skills"
|
||||
)
|
||||
pick_why = pick_why_from_featured(featured) or ""
|
||||
pick_title = str((featured or {}).get("title") or pick_name or "").strip()
|
||||
pick_url = str((featured or {}).get("url") or "").strip()
|
||||
|
||||
lines.extend(["---", "", "## 安装示例", "", "```bash"])
|
||||
for item in trending[:4]:
|
||||
@@ -600,10 +853,90 @@ def generate_report() -> tuple[str, str, Path, Path]:
|
||||
lines.extend(["```", "", f"*企微短版见 `output/{date_str}.wecom.md`*"])
|
||||
|
||||
markdown = "\n".join(lines)
|
||||
gt = selected_trending
|
||||
gh = selected_hot
|
||||
gt_pad = board_select(
|
||||
board="skills_trending",
|
||||
items=trending,
|
||||
recent_keys=skill_recent,
|
||||
limit=pad_pool,
|
||||
pool_size=pool,
|
||||
kind="skill",
|
||||
)
|
||||
gh_pad = board_select(
|
||||
board="skills_hot",
|
||||
items=hot,
|
||||
recent_keys=skill_recent,
|
||||
limit=pad_pool,
|
||||
pool_size=pool,
|
||||
kind="skill",
|
||||
)
|
||||
wecom_github_items = [_prepare_github_item(item) for item in selected_github]
|
||||
wecom_emerging_items = [_prepare_github_item(item) for item in selected_emerging]
|
||||
wecom_topic_items = [_prepare_github_item(item) for item in selected_topic]
|
||||
github_pad_recent = (
|
||||
recent_shown["github_trending"]
|
||||
| recent_shown["github_emerging"]
|
||||
| recent_shown["github_topic"]
|
||||
)
|
||||
wecom_github_pad = [
|
||||
_prepare_github_item(item)
|
||||
for item in board_select(
|
||||
board="github_trending",
|
||||
items=github_trending,
|
||||
recent_keys=github_pad_recent,
|
||||
limit=pad_pool,
|
||||
pool_size=pool,
|
||||
kind="github",
|
||||
)
|
||||
]
|
||||
wecom_emerging_pad = [
|
||||
_prepare_github_item(item)
|
||||
for item in board_select(
|
||||
board="github_emerging",
|
||||
items=github_emerging,
|
||||
recent_keys=github_pad_recent,
|
||||
limit=pad_pool,
|
||||
pool_size=pool,
|
||||
kind="github",
|
||||
)
|
||||
]
|
||||
wecom_topic_pad = [
|
||||
_prepare_github_item(item)
|
||||
for item in board_select(
|
||||
board="github_topic",
|
||||
items=github_topic,
|
||||
recent_keys=github_pad_recent,
|
||||
limit=pad_pool,
|
||||
pool_size=pool,
|
||||
kind="github",
|
||||
)
|
||||
]
|
||||
delta_pad = eff_mode == "delta" and wecom_delta_pad()
|
||||
board_kwargs = {
|
||||
"mode": eff_mode,
|
||||
"movement": movement,
|
||||
"trending": gt,
|
||||
"hot": gh,
|
||||
"topic_name": topic_name,
|
||||
"github_trending": wecom_github_items,
|
||||
"github_emerging": wecom_emerging_items,
|
||||
"github_topic": wecom_topic_items,
|
||||
"wecom_trending": wecom_trending,
|
||||
"wecom_hot": wecom_hot,
|
||||
"wecom_github": wecom_github,
|
||||
"wecom_emerging": wecom_emerging,
|
||||
"wecom_topic": wecom_topic,
|
||||
"pad": delta_pad,
|
||||
"date_str": date_str,
|
||||
"trending_pad": gt_pad,
|
||||
"hot_pad": gh_pad,
|
||||
"github_trending_pad": wecom_github_pad,
|
||||
"github_emerging_pad": wecom_emerging_pad,
|
||||
"github_topic_pad": wecom_topic_pad,
|
||||
}
|
||||
if agent_wecom:
|
||||
gt = group_skills_by_source(trending, limit=wecom_trending, pool_size=skill_pool)
|
||||
gh = group_skills_by_source(hot, limit=wecom_hot, pool_size=skill_pool)
|
||||
wecom_md = replace_wecom_skill_sections(agent_wecom, trending=gt, hot=gh)
|
||||
wecom_md = replace_wecom_skill_sections(agent_wecom, **board_kwargs)
|
||||
else:
|
||||
wecom_md = build_wecom_report(
|
||||
date_str=date_str,
|
||||
@@ -612,32 +945,71 @@ def generate_report() -> tuple[str, str, Path, Path]:
|
||||
highlights=editorial_highlights
|
||||
or _build_highlights(trending, hot, github_trending, github_emerging, ai_news, cn_ai_news),
|
||||
theme_line=editorial_theme or _detect_theme_line(feed),
|
||||
ai_news=prepare_wecom_news_items(ai_news),
|
||||
cn_ai_news=prepare_wecom_cn_news_items(cn_ai_news),
|
||||
ai_news=wecom_ai if not news_merged else None,
|
||||
cn_ai_news=wecom_cn if not news_merged else None,
|
||||
merged_ai_news=wecom_news if news_merged else None,
|
||||
merged_tech_ai_news=wecom_tech_news if news_merged else None,
|
||||
trending=[
|
||||
_prepare_skill_item(item, prev_ids, r)
|
||||
for r, item in enumerate(
|
||||
finalize_wecom_skill_groups(
|
||||
group_skills_by_source(trending, limit=wecom_trending, pool_size=skill_pool)
|
||||
),
|
||||
1,
|
||||
)
|
||||
for r, item in enumerate(finalize_wecom_skill_groups(gt), 1)
|
||||
],
|
||||
hot=[
|
||||
_prepare_skill_item(item, prev_ids, r)
|
||||
for r, item in enumerate(
|
||||
finalize_wecom_skill_groups(
|
||||
group_skills_by_source(hot, limit=wecom_hot, pool_size=skill_pool)
|
||||
),
|
||||
1,
|
||||
)
|
||||
for r, item in enumerate(finalize_wecom_skill_groups(gh), 1)
|
||||
],
|
||||
repos=[_prepare_github_item(item) for item in github_trending[:wecom_github]],
|
||||
emerging=[_prepare_github_item(item) for item in github_emerging[:wecom_emerging]],
|
||||
repos=wecom_github_items,
|
||||
emerging=wecom_emerging_items,
|
||||
topic_name=topic_name,
|
||||
topic_repos=[_prepare_github_item(item) for item in github_topic[:wecom_topic]],
|
||||
topic_repos=wecom_topic_items,
|
||||
pick_command=pick_command,
|
||||
pick_why=pick_why,
|
||||
pick_title=pick_title,
|
||||
pick_url=pick_url,
|
||||
include_boards=(eff_mode == "full"),
|
||||
)
|
||||
wecom_md = replace_wecom_board_sections(wecom_md, **board_kwargs)
|
||||
|
||||
wecom_md = replace_wecom_news_sections(
|
||||
wecom_md,
|
||||
ai_news=wecom_news if news_merged else wecom_ai,
|
||||
cn_ai_news=None if news_merged else wecom_cn,
|
||||
tech_ai_news=wecom_tech_news if news_merged else None,
|
||||
merged=news_merged,
|
||||
)
|
||||
|
||||
if push_gate.should_push:
|
||||
if news_merged:
|
||||
links = [x["link"] for x in wecom_news + wecom_tech_news if x.get("link")]
|
||||
else:
|
||||
links = [x["link"] for x in wecom_ai + wecom_cn if x.get("link")]
|
||||
record_pushed_links(date_str, links)
|
||||
|
||||
final_boards = resolve_wecom_board_items(**board_kwargs)
|
||||
shown_keys = {
|
||||
board: extract_shown_keys(board, final_boards.get(board) or [])
|
||||
for board in BOARD_KEYS
|
||||
}
|
||||
llm_input = merge_wecom_shown_into_data(llm_input, shown_keys)
|
||||
|
||||
save_json(
|
||||
data_json_path(date_str),
|
||||
build_full_payload(
|
||||
llm_input,
|
||||
meta={
|
||||
"generated_at": now.isoformat(),
|
||||
"report_mode": "agent" if is_agent_mode() else "classic",
|
||||
"ai_news_mode": "research" if news_merged else "rss",
|
||||
"cursor_editor": cursor_editor_enabled() and not is_agent_mode(),
|
||||
"featured_pick": featured.get("title") if featured else None,
|
||||
"effective_wecom_mode": eff_mode,
|
||||
"push_gate": {
|
||||
"should_push": push_gate.should_push,
|
||||
"silent": push_gate.silent,
|
||||
"reasons": push_gate.reasons,
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
_save_snapshot(feed, date_str)
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -43,38 +43,59 @@ def search_github_repos(
|
||||
logger.warning("GitHub Search 需要 GITHUB_TOKEN: %s", query[:80])
|
||||
return []
|
||||
|
||||
target = max(1, min(int(limit), 1000))
|
||||
per_page = min(100, target)
|
||||
repos: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
page = 1
|
||||
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=20.0,
|
||||
verify=certifi.where(),
|
||||
headers=github_api_headers(),
|
||||
) as client:
|
||||
resp = client.get(
|
||||
"https://api.github.com/search/repositories",
|
||||
params={
|
||||
"q": query,
|
||||
"sort": sort,
|
||||
"order": "desc",
|
||||
"per_page": min(max(limit, 1), 30),
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("GitHub Search 失败 (%s): %s", resp.status_code, query[:80])
|
||||
return []
|
||||
items = resp.json().get("items") or []
|
||||
while len(repos) < target:
|
||||
resp = client.get(
|
||||
"https://api.github.com/search/repositories",
|
||||
params={
|
||||
"q": query,
|
||||
"sort": sort,
|
||||
"order": "desc",
|
||||
"per_page": per_page,
|
||||
"page": page,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"GitHub Search 失败 (%s page=%s): %s",
|
||||
resp.status_code,
|
||||
page,
|
||||
query[:80],
|
||||
)
|
||||
break
|
||||
items = resp.json().get("items") or []
|
||||
if not items:
|
||||
break
|
||||
for item in items:
|
||||
full_name = item.get("full_name") or ""
|
||||
if not full_name or full_name in seen:
|
||||
continue
|
||||
seen.add(full_name)
|
||||
repos.append(_repo_from_api_item(item, source="api-search"))
|
||||
if len(repos) >= target:
|
||||
break
|
||||
if len(items) < per_page:
|
||||
break
|
||||
page += 1
|
||||
# Search API 最多约 1000 条 / 10 页
|
||||
if page > 10:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning("GitHub Search 异常: %s", exc)
|
||||
return []
|
||||
return repos[:target]
|
||||
|
||||
repos: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
full_name = item.get("full_name") or ""
|
||||
if not full_name:
|
||||
continue
|
||||
repos.append(_repo_from_api_item(item, source="api-search"))
|
||||
if len(repos) >= limit:
|
||||
break
|
||||
return repos
|
||||
return repos[:target]
|
||||
|
||||
|
||||
def _date_days_ago(days: int) -> str:
|
||||
|
||||
@@ -76,16 +76,9 @@ def _cursor_chat(system: str, user: str) -> str:
|
||||
return ""
|
||||
from cursor_sdk import Agent, AgentOptions, CursorAgentError, LocalAgentOptions
|
||||
|
||||
from daily.config import ensure_bot_on_path
|
||||
|
||||
ensure_bot_on_path()
|
||||
try:
|
||||
from bridge_manager import warm_cursor_bridge
|
||||
except ImportError:
|
||||
warm_cursor_bridge = lambda: None # noqa: E731
|
||||
from daily.bridge_manager import warm_cursor_bridge
|
||||
|
||||
cwd = env("DAILY_CURSOR_CWD") or str(ROOT)
|
||||
# bridge_manager 读 bot env_config 的 CURSOR_CWD,早报侧须先对齐工作目录
|
||||
os.environ["CURSOR_CWD"] = cwd
|
||||
warm_cursor_bridge()
|
||||
model = env("CURSOR_MODEL") or "composer-2.5"
|
||||
@@ -115,5 +108,16 @@ def llm_chat(system: str, user: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def has_cursor_configured() -> bool:
|
||||
return bool((env("CURSOR_API_KEY") or "").strip())
|
||||
|
||||
|
||||
def cursor_agent_prompt(system: str, user: str) -> str:
|
||||
"""仅 Cursor SDK Agent(可用 WebSearch 等工具),不走 OpenAI 兼容 API。"""
|
||||
if not has_cursor_configured():
|
||||
return ""
|
||||
return _cursor_chat(system, user)
|
||||
|
||||
|
||||
def has_llm_configured() -> bool:
|
||||
return bool(env("DAILY_LLM_API_KEY") or env("OPENAI_API_KEY") or env("CURSOR_API_KEY"))
|
||||
|
||||
103
daily/narrative_axis.py
Normal file
103
daily/narrative_axis.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""叙事轴硬互斥:代码选定轴,注入 Agent Step1 并强制覆写。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from daily.config import OUTPUT_DIR, narrative_axis_days
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NARRATIVE_AXES: tuple[str, ...] = (
|
||||
"政策监管",
|
||||
"模型发布",
|
||||
"工具链/Agent",
|
||||
"芯片算力",
|
||||
"开源生态",
|
||||
"应用落地",
|
||||
"安全/诉讼",
|
||||
)
|
||||
|
||||
|
||||
def pick_narrative_axis(
|
||||
used: set[str],
|
||||
*,
|
||||
rng: random.Random | None = None,
|
||||
) -> str:
|
||||
"""从固定轴枚举中排除已用轴后随机选取;全用尽则回退全表。"""
|
||||
available = [a for a in NARRATIVE_AXES if a not in used]
|
||||
pool = available or list(NARRATIVE_AXES)
|
||||
picker = rng or random.Random()
|
||||
return picker.choice(pool)
|
||||
|
||||
|
||||
def load_recent_axes(date_str: str, days: int | None = None) -> list[str]:
|
||||
"""近 N 日 data.narrative_axis(不含当日,按时间从近到远)。"""
|
||||
lookback = days if days is not None else narrative_axis_days()
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return []
|
||||
axes: list[str] = []
|
||||
for day_offset in range(1, lookback + 1):
|
||||
prev = (dt - timedelta(days=day_offset)).strftime("%Y-%m-%d")
|
||||
path = OUTPUT_DIR / f"{prev}.data.json"
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("读取 narrative_axis %s 失败:%s", path, exc)
|
||||
continue
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
axis = str(data.get("narrative_axis") or "").strip()
|
||||
if axis:
|
||||
axes.append(axis)
|
||||
return axes
|
||||
|
||||
|
||||
def load_recent_theme_summaries(date_str: str, days: int) -> list[str]:
|
||||
"""近 N 日 theme/opening 摘要,供 Step1 软禁参考。"""
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return []
|
||||
summaries: list[str] = []
|
||||
for day_offset in range(1, days + 1):
|
||||
prev = (dt - timedelta(days=day_offset)).strftime("%Y-%m-%d")
|
||||
path = OUTPUT_DIR / f"{prev}.data.json"
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
theme = str(data.get("theme") or data.get("editorial_theme") or "").strip()
|
||||
opening = ""
|
||||
trends = data.get("trends") if isinstance(data.get("trends"), dict) else {}
|
||||
if isinstance(trends, dict):
|
||||
opening = str(trends.get("opening") or "").strip()
|
||||
if not theme:
|
||||
themes = trends.get("themes") or []
|
||||
if themes and isinstance(themes[0], dict):
|
||||
theme = str(themes[0].get("title") or "").strip()
|
||||
bit = " · ".join(x for x in (prev, theme, opening[:40]) if x)
|
||||
if bit:
|
||||
summaries.append(bit)
|
||||
return summaries
|
||||
|
||||
|
||||
def enforce_narrative_axis(trends: dict[str, Any], axis: str) -> dict[str, Any]:
|
||||
"""强制 trends['narrative_axis'] = axis。"""
|
||||
out = dict(trends)
|
||||
out["narrative_axis"] = axis
|
||||
return out
|
||||
@@ -1,13 +1,26 @@
|
||||
from daily.news.fetch import (
|
||||
|
||||
fetch_ai_news,
|
||||
|
||||
fetch_cn_ai_news,
|
||||
|
||||
format_cn_news_section,
|
||||
|
||||
format_news_section,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
"fetch_ai_news",
|
||||
|
||||
"fetch_cn_ai_news",
|
||||
|
||||
"format_news_section",
|
||||
|
||||
"format_cn_news_section",
|
||||
|
||||
]
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ NEWS_CATEGORIES: tuple[NewsCategory, ...] = (
|
||||
name="厂商官方",
|
||||
icon="🏢",
|
||||
feeds=(
|
||||
NewsFeed("Anthropic Claude 更新", "https://docs.anthropic.com/en/release-notes/feed"),
|
||||
NewsFeed("Anthropic Claude 更新", "https://platform.claude.com/docs/en/release-notes/overview"),
|
||||
NewsFeed("OpenAI", "https://openai.com/news/rss.xml"),
|
||||
NewsFeed("Google AI", "https://blog.google/technology/ai/rss/"),
|
||||
NewsFeed("DeepMind", "https://deepmind.google/blog/rss.xml"),
|
||||
|
||||
@@ -44,7 +44,6 @@ CN_NEWS_CATEGORIES: tuple[NewsCategory, ...] = (
|
||||
icon="📰",
|
||||
feeds=(
|
||||
NewsFeed("量子位", "https://www.qbitai.com/feed"),
|
||||
NewsFeed("InfoQ 中文", "https://www.infoq.cn/feed/AI"),
|
||||
),
|
||||
),
|
||||
NewsCategory(
|
||||
@@ -60,12 +59,4 @@ CN_NEWS_CATEGORIES: tuple[NewsCategory, ...] = (
|
||||
),
|
||||
),
|
||||
),
|
||||
NewsCategory(
|
||||
id="dev",
|
||||
name="开发者社区",
|
||||
icon="💻",
|
||||
feeds=(
|
||||
NewsFeed("掘金", "https://juejin.cn/rss", ai_filter=True),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,17 +8,19 @@ import time
|
||||
import html
|
||||
import xml.etree.ElementTree as ET
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import datetime, time as dt_time, timezone, timedelta
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
|
||||
from daily.config import env, env_int, news_summary_limit
|
||||
from daily.config import env, env_int, news_summary_limit, wecom_news_desc_limit
|
||||
from daily.news.feeds import NEWS_CATEGORIES, NewsCategory, NewsFeed
|
||||
from daily.news.feeds_cn import CN_AI_TITLE_KEYWORDS, CN_NEWS_CATEGORIES
|
||||
from daily.text_utils import trim_brief
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,15 +48,40 @@ def _cn_enabled() -> bool:
|
||||
|
||||
|
||||
def _hours_window() -> int:
|
||||
return max(1, env_int("DAILY_AI_NEWS_HOURS", 72))
|
||||
return max(1, env_int("DAILY_AI_NEWS_HOURS", 24))
|
||||
|
||||
|
||||
def _news_tz_name() -> str:
|
||||
return (env("DAILY_AI_NEWS_TZ") or env("DAILY_SCHEDULE_TZ") or "Asia/Shanghai").strip()
|
||||
|
||||
|
||||
def _floor_today_enabled() -> bool:
|
||||
raw = env("DAILY_AI_NEWS_FLOOR_TODAY")
|
||||
if raw is None:
|
||||
return True
|
||||
return raw.strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def _cutoff_datetime(*, floor_today: bool) -> datetime:
|
||||
"""滚动 N 小时窗口;国际新闻可叠加「不早于今日 0 点(本地时区)」。"""
|
||||
now = _now_utc()
|
||||
rolling = now - timedelta(hours=_hours_window())
|
||||
if not floor_today:
|
||||
return rolling
|
||||
tz = ZoneInfo(_news_tz_name())
|
||||
local = now.astimezone(tz)
|
||||
start_today = local.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
|
||||
return max(rolling, start_today)
|
||||
|
||||
|
||||
def _per_feed_limit() -> int:
|
||||
return max(1, env_int("DAILY_AI_NEWS_PER_FEED", 3))
|
||||
want = max(_wecom_limit(), _wecom_cn_limit())
|
||||
return max(want // 2, env_int("DAILY_AI_NEWS_PER_FEED", 5))
|
||||
|
||||
|
||||
def _per_category_limit() -> int:
|
||||
return max(1, env_int("DAILY_AI_NEWS_PER_CATEGORY", 5))
|
||||
want = max(_wecom_limit(), _wecom_cn_limit())
|
||||
return max(want, env_int("DAILY_AI_NEWS_PER_CATEGORY", 10))
|
||||
|
||||
|
||||
def _wecom_limit() -> int:
|
||||
@@ -62,7 +89,7 @@ def _wecom_limit() -> int:
|
||||
|
||||
|
||||
def _wecom_cn_limit() -> int:
|
||||
return max(1, env_int("DAILY_WECOM_CN_AI_NEWS", 8))
|
||||
return max(1, env_int("DAILY_WECOM_CN_AI_NEWS", 10))
|
||||
|
||||
|
||||
def _matches_cn_ai_title(title: str) -> bool:
|
||||
@@ -102,7 +129,6 @@ def _parse_datetime(value: str | None) -> datetime | None:
|
||||
for fmt in (
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%dT%H:%M:%S%z",
|
||||
"%Y-%m-%d",
|
||||
):
|
||||
try:
|
||||
dt = datetime.strptime(text[: len(fmt.replace("%z", "+0000"))], fmt.replace("%z", ""))
|
||||
@@ -111,17 +137,103 @@ def _parse_datetime(value: str | None) -> datetime | None:
|
||||
return dt.astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
if re.match(r"^\d{4}-\d{2}-\d{2}$", text):
|
||||
try:
|
||||
tz = ZoneInfo(_news_tz_name())
|
||||
day = datetime.strptime(text, "%Y-%m-%d").date()
|
||||
# 仅日期时按本地中午估算,避免 UTC 0 点误判为「前一天」
|
||||
dt = datetime.combine(day, dt_time(12, 0), tzinfo=tz)
|
||||
return dt.astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _clean_text(text: str | None, limit: int = 200) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
plain = STRIP_HTML.sub(" ", html.unescape(text))
|
||||
plain = WS.sub(" ", plain).strip()
|
||||
plain = _strip_summary_plain(text)
|
||||
if limit <= 0 or len(plain) <= limit:
|
||||
return plain
|
||||
return plain[: limit - 3] + "..."
|
||||
return trim_brief(plain, limit)
|
||||
|
||||
|
||||
def _strip_summary_plain(text: str | None) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
plain = STRIP_HTML.sub(" ", html.unescape(text))
|
||||
return WS.sub(" ", plain).strip()
|
||||
|
||||
|
||||
_JUNK_SUMMARY_RE = re.compile(
|
||||
r"^(点击查看原文|article url:|comments url:|discussion on hn|read more)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_junk_news_summary(text: str) -> bool:
|
||||
if not text:
|
||||
return True
|
||||
if _JUNK_SUMMARY_RE.match(text.strip()):
|
||||
return True
|
||||
if text.strip().endswith(">") and "点击" in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def brief_news_summary(text: str | None, limit: int | None = None) -> str:
|
||||
"""企微新闻一句摘要:去 HTML、过滤占位文案、句读处截断。"""
|
||||
plain = _strip_summary_plain(text)
|
||||
if _is_junk_news_summary(plain):
|
||||
return ""
|
||||
lim = wecom_news_desc_limit() if limit is None else limit
|
||||
return trim_brief(plain, lim)
|
||||
|
||||
|
||||
def sync_wecom_news_rows(items: list[dict[str, Any]], flat: list[dict[str, Any]]) -> None:
|
||||
"""中文化后,用 flat 最新 summary 刷新企微 desc_short。"""
|
||||
by_link = {_normalize_link(str(i.get("link") or "")): i for i in flat if i.get("link")}
|
||||
for row in items:
|
||||
link = _normalize_link(str(row.get("link") or ""))
|
||||
src = by_link.get(link)
|
||||
if src:
|
||||
row["desc_short"] = brief_news_summary(src.get("summary"))
|
||||
|
||||
|
||||
def finalize_wecom_news_items(
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
force_chinese: bool = False,
|
||||
) -> None:
|
||||
"""企微新闻摘要:确保 desc_short 为中文(国际源 force_chinese=True)。"""
|
||||
from daily.localize import LocalizeJob, localize_brief_descriptions, needs_chinese
|
||||
from daily.news.sanitize import strip_relax_window_prefix
|
||||
|
||||
limit = wecom_news_desc_limit()
|
||||
jobs: list[LocalizeJob] = []
|
||||
keyed: list[tuple[str, dict[str, Any]]] = []
|
||||
for idx, item in enumerate(items):
|
||||
text = strip_relax_window_prefix(
|
||||
(item.get("desc_short") or item.get("summary_plain") or "").strip()
|
||||
)
|
||||
if text:
|
||||
item["desc_short"] = text
|
||||
if not text or _is_junk_news_summary(text):
|
||||
item["desc_short"] = ""
|
||||
continue
|
||||
if force_chinese or needs_chinese(text):
|
||||
key = f"wecom-news:{item.get('link') or idx}"
|
||||
jobs.append(LocalizeJob(key, text, limit))
|
||||
keyed.append((key, item))
|
||||
elif not item.get("desc_short"):
|
||||
item["desc_short"] = brief_news_summary(text, limit)
|
||||
|
||||
if not jobs:
|
||||
return
|
||||
zh_map = localize_brief_descriptions(jobs, archive=True)
|
||||
for key, item in keyed:
|
||||
if key in zh_map:
|
||||
item["desc_short"] = strip_relax_window_prefix(zh_map[key])
|
||||
|
||||
|
||||
def _normalize_link(link: str) -> str:
|
||||
@@ -317,10 +429,119 @@ def _dedupe_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
def _filter_flat_in_window(
|
||||
flat: list[dict[str, Any]],
|
||||
*,
|
||||
floor_today: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
cutoff = _cutoff_datetime(floor_today=floor_today)
|
||||
items = [i for i in _dedupe_items(flat) if _within_window(i, cutoff)]
|
||||
items.sort(key=_sort_key, reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
def _pick_news_items(
|
||||
flat: list[dict[str, Any]],
|
||||
limit: int,
|
||||
preferred: tuple[str, ...],
|
||||
*,
|
||||
one_per_source: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
picked: list[dict[str, Any]] = []
|
||||
seen_links: set[str] = set()
|
||||
seen_sources: set[str] = set()
|
||||
|
||||
def _try_take(item: dict[str, Any]) -> bool:
|
||||
link = _normalize_link(item.get("link", ""))
|
||||
if not link or link in seen_links:
|
||||
return False
|
||||
if one_per_source:
|
||||
source = item.get("source_name", "?")
|
||||
if source in seen_sources:
|
||||
return False
|
||||
seen_sources.add(source)
|
||||
seen_links.add(link)
|
||||
picked.append(item)
|
||||
return True
|
||||
|
||||
for cat in preferred:
|
||||
for item in flat:
|
||||
if item.get("category_id") != cat:
|
||||
continue
|
||||
if _try_take(item) and len(picked) >= limit:
|
||||
return picked[:limit]
|
||||
|
||||
for item in flat:
|
||||
if _try_take(item) and len(picked) >= limit:
|
||||
break
|
||||
return picked[:limit]
|
||||
|
||||
|
||||
def _fill_picked_to_limit(
|
||||
picked: list[dict[str, Any]],
|
||||
pools: list[list[dict[str, Any]]],
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
seen_links = {_normalize_link(i.get("link", "")) for i in picked}
|
||||
for pool in pools:
|
||||
for item in pool:
|
||||
if len(picked) >= limit:
|
||||
return picked[:limit]
|
||||
link = _normalize_link(item.get("link", ""))
|
||||
if not link or link in seen_links:
|
||||
continue
|
||||
picked.append(item)
|
||||
seen_links.add(link)
|
||||
return picked[:limit]
|
||||
|
||||
|
||||
def _to_wecom_news_row(item: dict[str, Any]) -> dict[str, Any]:
|
||||
plain = _strip_summary_plain(item.get("summary", ""))
|
||||
return {
|
||||
"title": item.get("title", "?"),
|
||||
"link": item.get("link", ""),
|
||||
"source_name": item.get("source_name", "?"),
|
||||
"published_fmt": item.get("published_fmt", ""),
|
||||
"desc_short": brief_news_summary(plain),
|
||||
"summary_plain": plain,
|
||||
}
|
||||
|
||||
|
||||
def _apply_pushed_dedup_with_backfill(
|
||||
items: list[dict[str, Any]],
|
||||
picked: list[dict[str, Any]],
|
||||
*,
|
||||
date_str: str | None,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not date_str:
|
||||
return items[:limit]
|
||||
from daily.config import news_backfill_enabled
|
||||
from daily.news.pushed_links import filter_unpushed_items
|
||||
|
||||
fresh = filter_unpushed_items(items, date_str=date_str)
|
||||
if len(fresh) >= limit:
|
||||
return fresh[:limit]
|
||||
if not news_backfill_enabled():
|
||||
if len(fresh) < limit:
|
||||
logger.info("news_short:%s", len(fresh))
|
||||
return fresh[:limit]
|
||||
seen = {_normalize_link(i.get("link", "")) for i in fresh if i.get("link")}
|
||||
for item in picked:
|
||||
if len(fresh) >= limit:
|
||||
break
|
||||
link = _normalize_link(item.get("link", ""))
|
||||
if not link or link in seen:
|
||||
continue
|
||||
fresh.append(_to_wecom_news_row(item))
|
||||
seen.add(link)
|
||||
return fresh[:limit]
|
||||
|
||||
|
||||
def _within_window(item: dict[str, Any], cutoff: datetime) -> bool:
|
||||
dt = _entry_datetime(item)
|
||||
if dt is None:
|
||||
return True
|
||||
return False
|
||||
return dt >= cutoff
|
||||
|
||||
|
||||
@@ -331,11 +552,11 @@ def _sort_key(item: dict[str, Any]) -> tuple[int, datetime]:
|
||||
return (0, dt)
|
||||
|
||||
|
||||
def _fetch_news(categories: tuple[NewsCategory, ...]) -> dict[str, Any]:
|
||||
def _fetch_news(categories: tuple[NewsCategory, ...], *, floor_today: bool = False) -> dict[str, Any]:
|
||||
hours = _hours_window()
|
||||
per_feed = _per_feed_limit()
|
||||
per_category = _per_category_limit()
|
||||
cutoff = _now_utc() - timedelta(hours=hours)
|
||||
cutoff = _cutoff_datetime(floor_today=floor_today)
|
||||
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*"}
|
||||
tasks: list[tuple[NewsCategory, NewsFeed]] = []
|
||||
@@ -406,6 +627,7 @@ def _fetch_news(categories: tuple[NewsCategory, ...]) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": True,
|
||||
"hours": hours,
|
||||
"floor_today": floor_today,
|
||||
"categories": categories_out,
|
||||
"flat": flat,
|
||||
"stats": stats,
|
||||
@@ -416,7 +638,7 @@ def fetch_ai_news() -> dict[str, Any]:
|
||||
"""按类别抓取国际 AI 时讯,返回 {enabled, hours, categories, flat, stats}。"""
|
||||
if not _enabled():
|
||||
return {"enabled": False, "categories": [], "flat": [], "stats": {}}
|
||||
return _fetch_news(NEWS_CATEGORIES)
|
||||
return _fetch_news(NEWS_CATEGORIES, floor_today=_floor_today_enabled())
|
||||
|
||||
|
||||
def fetch_cn_ai_news() -> dict[str, Any]:
|
||||
@@ -459,12 +681,13 @@ def _format_news_section(
|
||||
|
||||
categories = news.get("categories") or []
|
||||
hours = news.get("hours", 72)
|
||||
floor_note = " · 仅今日" if news.get("floor_today") else ""
|
||||
lines = [
|
||||
"---",
|
||||
"",
|
||||
f"## {section_no}、{title}",
|
||||
"",
|
||||
f"> 近 **{hours}h** · {news.get('stats', {}).get('feeds_ok', 0)}/{news.get('stats', {}).get('feeds_total', 0)} 源可用",
|
||||
f"> 近 **{hours}h**{floor_note} · {news.get('stats', {}).get('feeds_ok', 0)}/{news.get('stats', {}).get('feeds_total', 0)} 源可用",
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -497,84 +720,27 @@ def _format_news_section(
|
||||
return lines
|
||||
|
||||
|
||||
def prepare_wecom_news_items(news: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
def prepare_wecom_news_items(news: dict[str, Any], *, date_str: str | None = None) -> list[dict[str, Any]]:
|
||||
if not news.get("enabled"):
|
||||
return []
|
||||
limit = _wecom_limit()
|
||||
flat = _dedupe_items(news.get("flat") or [])
|
||||
flat.sort(key=_sort_key, reverse=True)
|
||||
floor = bool(news.get("floor_today", _floor_today_enabled()))
|
||||
flat_strict = _filter_flat_in_window(news.get("flat") or [], floor_today=floor)
|
||||
flat_relaxed = _filter_flat_in_window(news.get("flat") or [], floor_today=False)
|
||||
preferred = ("media", "newsletter", "official", "community", "research", "developer")
|
||||
picked: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for cat in preferred:
|
||||
for item in flat:
|
||||
link = _normalize_link(item.get("link", ""))
|
||||
if item.get("category_id") != cat or link in seen:
|
||||
continue
|
||||
picked.append(item)
|
||||
seen.add(link)
|
||||
if len(picked) >= limit:
|
||||
break
|
||||
if len(picked) >= limit:
|
||||
break
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in picked[:limit]:
|
||||
items.append(
|
||||
{
|
||||
"title": item.get("title", "?"),
|
||||
"link": item.get("link", ""),
|
||||
"source_name": item.get("source_name", "?"),
|
||||
"published_fmt": item.get("published_fmt", ""),
|
||||
"desc_short": _clean_text(item.get("summary", ""), 36),
|
||||
}
|
||||
)
|
||||
return items
|
||||
picked = _pick_news_items(flat_strict, limit, preferred)
|
||||
picked = _fill_picked_to_limit(picked, [flat_relaxed, news.get("flat") or []], limit)
|
||||
items = [_to_wecom_news_row(item) for item in picked[:limit]]
|
||||
return _apply_pushed_dedup_with_backfill(items, picked, date_str=date_str, limit=limit)
|
||||
|
||||
|
||||
def prepare_wecom_cn_news_items(news: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
def prepare_wecom_cn_news_items(news: dict[str, Any], *, date_str: str | None = None) -> list[dict[str, Any]]:
|
||||
if not news.get("enabled"):
|
||||
return []
|
||||
limit = _wecom_cn_limit()
|
||||
flat = _dedupe_items(news.get("flat") or [])
|
||||
flat.sort(key=_sort_key, reverse=True)
|
||||
preferred = ("media", "tech", "dev")
|
||||
picked: list[dict[str, Any]] = []
|
||||
seen_links: set[str] = set()
|
||||
seen_sources: set[str] = set()
|
||||
|
||||
for cat in preferred:
|
||||
for item in flat:
|
||||
link = _normalize_link(item.get("link", ""))
|
||||
source = item.get("source_name", "?")
|
||||
if item.get("category_id") != cat or not link or link in seen_links or source in seen_sources:
|
||||
continue
|
||||
picked.append(item)
|
||||
seen_links.add(link)
|
||||
seen_sources.add(source)
|
||||
if len(picked) >= limit:
|
||||
break
|
||||
if len(picked) >= limit:
|
||||
break
|
||||
|
||||
if len(picked) < limit:
|
||||
for item in flat:
|
||||
link = _normalize_link(item.get("link", ""))
|
||||
if not link or link in seen_links:
|
||||
continue
|
||||
picked.append(item)
|
||||
seen_links.add(link)
|
||||
if len(picked) >= limit:
|
||||
break
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in picked[:limit]:
|
||||
items.append(
|
||||
{
|
||||
"title": item.get("title", "?"),
|
||||
"link": item.get("link", ""),
|
||||
"source_name": item.get("source_name", "?"),
|
||||
"published_fmt": item.get("published_fmt", ""),
|
||||
"desc_short": _clean_text(item.get("summary", ""), 36),
|
||||
}
|
||||
)
|
||||
return items
|
||||
flat = _filter_flat_in_window(news.get("flat") or [], floor_today=False)
|
||||
preferred = ("media", "tech")
|
||||
picked = _pick_news_items(flat, limit, preferred, one_per_source=True)
|
||||
picked = _fill_picked_to_limit(picked, [news.get("flat") or []], limit)
|
||||
items = [_to_wecom_news_row(item) for item in picked[:limit]]
|
||||
return _apply_pushed_dedup_with_backfill(items, picked, date_str=date_str, limit=limit)
|
||||
|
||||
87
daily/news/pushed_links.py
Normal file
87
daily/news/pushed_links.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""已推送企微早报的新闻 link 去重缓存。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from daily.config import CACHE_DIR, news_dedup_days
|
||||
from daily.news.fetch import _normalize_link
|
||||
|
||||
|
||||
def _cache_path() -> Path:
|
||||
return CACHE_DIR / "pushed-news-links.json"
|
||||
|
||||
|
||||
def _load_raw() -> dict[str, Any]:
|
||||
path = _cache_path()
|
||||
if not path.exists():
|
||||
return {"dates": {}}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {"dates": {}}
|
||||
if not isinstance(data.get("dates"), dict):
|
||||
return {"dates": {}}
|
||||
return data
|
||||
|
||||
|
||||
def _save_raw(data: dict[str, Any]) -> None:
|
||||
path = _cache_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _prune(data: dict[str, Any], *, keep_days: int) -> None:
|
||||
dates: dict[str, list[str]] = data.setdefault("dates", {})
|
||||
try:
|
||||
anchor = max(datetime.strptime(d, "%Y-%m-%d") for d in dates)
|
||||
except ValueError:
|
||||
return
|
||||
cutoff = anchor - timedelta(days=keep_days)
|
||||
for key in list(dates.keys()):
|
||||
try:
|
||||
if datetime.strptime(key, "%Y-%m-%d") < cutoff:
|
||||
dates.pop(key, None)
|
||||
except ValueError:
|
||||
dates.pop(key, None)
|
||||
|
||||
|
||||
def load_pushed_link_set() -> set[str]:
|
||||
data = _load_raw()
|
||||
out: set[str] = set()
|
||||
for links in (data.get("dates") or {}).values():
|
||||
if isinstance(links, list):
|
||||
out.update(str(x) for x in links if x)
|
||||
return out
|
||||
|
||||
|
||||
def filter_unpushed_items(
|
||||
items: list[dict[str, Any]],
|
||||
*,
|
||||
date_str: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
del date_str # reserved for per-day scoping if needed later
|
||||
seen = load_pushed_link_set()
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
link = _normalize_link(str(item.get("link") or ""))
|
||||
if not link or link in seen:
|
||||
continue
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def record_pushed_links(date_str: str, links: list[str]) -> None:
|
||||
data = _load_raw()
|
||||
dates: dict[str, list[str]] = data.setdefault("dates", {})
|
||||
normalized: list[str] = []
|
||||
for link in links:
|
||||
clean = _normalize_link(link)
|
||||
if clean:
|
||||
normalized.append(clean)
|
||||
dates[date_str] = sorted(set(normalized))
|
||||
_prune(data, keep_days=news_dedup_days())
|
||||
_save_raw(data)
|
||||
324
daily/news/research.py
Normal file
324
daily/news/research.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""Cursor SDK + deep-research 工作流:采集 AI 时讯(方案 A,内置 WebSearch)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from daily.config import OUTPUT_DIR, ROOT, env, env_int, wecom_ai_news_tech_limit
|
||||
from daily.llm_client import cursor_agent_prompt, extract_json_object, has_cursor_configured
|
||||
from daily.news.fetch import brief_news_summary, _normalize_link
|
||||
from daily.news.pushed_links import filter_unpushed_items
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SKILL_DIR = ROOT / "skills" / "daily-ai-news-research"
|
||||
_DEEP_RESEARCH_CANDIDATES = (
|
||||
ROOT / "skills" / "deep-research" / "SKILL.md",
|
||||
Path.home() / ".agents" / "skills" / "deep-research" / "SKILL.md",
|
||||
Path.home() / ".cursor" / "skills" / "deep-research" / "SKILL.md",
|
||||
)
|
||||
|
||||
|
||||
def ai_news_mode() -> str:
|
||||
return (env("DAILY_AI_NEWS_MODE") or "rss").strip().lower()
|
||||
|
||||
|
||||
def is_research_mode() -> bool:
|
||||
return ai_news_mode() == "research"
|
||||
|
||||
|
||||
def research_hours() -> int:
|
||||
return max(1, env_int("DAILY_AI_NEWS_HOURS", 24))
|
||||
|
||||
|
||||
def research_limit() -> int:
|
||||
return max(1, env_int("DAILY_WECOM_AI_NEWS", 10))
|
||||
|
||||
|
||||
def research_json_path(date_str: str) -> Path:
|
||||
return OUTPUT_DIR / f"{date_str}.ai-news-research.json"
|
||||
|
||||
|
||||
def _save_research_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _load_skill() -> str:
|
||||
parts: list[str] = []
|
||||
for path in _DEEP_RESEARCH_CANDIDATES:
|
||||
if path.exists():
|
||||
parts.append(path.read_text(encoding="utf-8").strip())
|
||||
break
|
||||
local = _SKILL_DIR / "SKILL.md"
|
||||
if local.exists():
|
||||
parts.append(local.read_text(encoding="utf-8").strip())
|
||||
if not parts:
|
||||
return "你是 AI 时讯调研员,只输出 JSON。"
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
def _guess_source_name(link: str, explicit: str) -> str:
|
||||
name = (explicit or "").strip()
|
||||
if name:
|
||||
return name
|
||||
host = urlparse(link).netloc.lower().removeprefix("www.")
|
||||
mapping = {
|
||||
"techcrunch.com": "TechCrunch",
|
||||
"theverge.com": "The Verge",
|
||||
"openai.com": "OpenAI",
|
||||
"anthropic.com": "Anthropic",
|
||||
"arxiv.org": "arXiv",
|
||||
"qbitai.com": "量子位",
|
||||
"36kr.com": "36氪",
|
||||
"leiphone.com": "雷锋网",
|
||||
}
|
||||
for key, label in mapping.items():
|
||||
if host.endswith(key) or key in host:
|
||||
return label
|
||||
return host.split(".")[0].capitalize() if host else "?"
|
||||
|
||||
|
||||
def _normalize_research_item(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||
title = str(raw.get("title") or "").strip()
|
||||
link = _normalize_link(str(raw.get("link") or ""))
|
||||
if not title or not link or not link.startswith("http"):
|
||||
return None
|
||||
desc = brief_news_summary(str(raw.get("desc_short") or raw.get("summary") or ""))
|
||||
return {
|
||||
"title": title,
|
||||
"link": link,
|
||||
"source_name": _guess_source_name(link, str(raw.get("source_name") or "")),
|
||||
"published_fmt": str(raw.get("published_fmt") or "").strip(),
|
||||
"desc_short": desc,
|
||||
"summary_plain": desc,
|
||||
}
|
||||
|
||||
|
||||
def research_tech_limit() -> int:
|
||||
return wecom_ai_news_tech_limit()
|
||||
|
||||
|
||||
def _parse_items_array(
|
||||
items_raw: Any,
|
||||
*,
|
||||
limit: int,
|
||||
seen: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not isinstance(items_raw, list):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in items_raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
item = _normalize_research_item(row)
|
||||
if not item:
|
||||
continue
|
||||
if item["link"] in seen:
|
||||
continue
|
||||
seen.add(item["link"])
|
||||
out.append(item)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def parse_research_response(
|
||||
raw: str,
|
||||
*,
|
||||
limit: int,
|
||||
tech_limit: int = 0,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
parsed = extract_json_object(raw)
|
||||
seen: set[str] = set()
|
||||
items = _parse_items_array(parsed.get("items"), limit=limit, seen=seen)
|
||||
tech_items = _parse_items_array(parsed.get("tech_items"), limit=tech_limit, seen=seen) if tech_limit else []
|
||||
return items, tech_items
|
||||
|
||||
|
||||
def _apply_pushed_dedup(items: list[dict[str, Any]], *, date_str: str, limit: int) -> list[dict[str, Any]]:
|
||||
from daily.config import news_backfill_enabled
|
||||
from daily.news.sanitize import strip_relax_window_prefix
|
||||
|
||||
for item in items:
|
||||
if item.get("desc_short"):
|
||||
item["desc_short"] = strip_relax_window_prefix(str(item.get("desc_short") or ""))
|
||||
fresh = filter_unpushed_items(items, date_str=date_str)
|
||||
if len(fresh) >= limit:
|
||||
return fresh[:limit]
|
||||
if not news_backfill_enabled():
|
||||
if len(fresh) < limit:
|
||||
logger.info("news_short:%s", len(fresh))
|
||||
return fresh[:limit]
|
||||
seen = {i.get("link") for i in fresh}
|
||||
for item in items:
|
||||
if len(fresh) >= limit:
|
||||
break
|
||||
if item.get("link") not in seen:
|
||||
fresh.append(item)
|
||||
seen.add(item.get("link"))
|
||||
return fresh[:limit]
|
||||
|
||||
|
||||
def fetch_ai_news_research(
|
||||
*,
|
||||
date_str: str,
|
||||
hours: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Cursor Agent 调研 AI 时讯;返回 {enabled, mode, hours, items, flat, stats}。"""
|
||||
h = hours if hours is not None else research_hours()
|
||||
lim = limit if limit is not None else research_limit()
|
||||
tech_lim = research_tech_limit()
|
||||
|
||||
if not has_cursor_configured():
|
||||
logger.warning("DAILY_AI_NEWS_MODE=research 但未配置 CURSOR_API_KEY")
|
||||
return {
|
||||
"enabled": False,
|
||||
"mode": "research",
|
||||
"items": [],
|
||||
"tech_items": [],
|
||||
"flat": [],
|
||||
"stats": {"error": "no_cursor_key"},
|
||||
}
|
||||
|
||||
skill = _load_skill()
|
||||
now_cst = datetime.now(timezone(timedelta(hours=8)))
|
||||
tech_clause = ""
|
||||
if tech_lim:
|
||||
tech_clause = (
|
||||
f"\n另输出 **tech_items 恰好 {tech_lim} 条**,聚焦工程技术:"
|
||||
"模型/框架发布、开源项目、芯片算力、开发者工具、推理与工程实践。"
|
||||
"与 items 不得重复 link。"
|
||||
)
|
||||
system = (
|
||||
f"{skill}\n\n"
|
||||
"当前执行 **早报 AI 时讯调研**。\n"
|
||||
f"时间窗口:近 **{h}** 小时(截至 {now_cst.strftime('%Y-%m-%d %H:%M')} UTC+8)。\n"
|
||||
f"输出 **恰好 {lim} 条** items,按重要性排序。{tech_clause}\n"
|
||||
"使用 WebSearch 检索;不要读取本项目文档或 RSS 配置。"
|
||||
)
|
||||
user = (
|
||||
f"/deep-research 获取近 {h} 小时的 AI 人工智能新闻资讯,"
|
||||
"不区分国内国外,合并精选。"
|
||||
f"只输出 JSON,items 长度={lim}"
|
||||
+ (f",tech_items 长度={tech_lim}" if tech_lim else "")
|
||||
+ "。"
|
||||
)
|
||||
|
||||
try:
|
||||
raw = cursor_agent_prompt(system, user)
|
||||
except Exception as exc:
|
||||
logger.warning("AI 时讯 research 失败:%s", exc)
|
||||
return {
|
||||
"enabled": False,
|
||||
"mode": "research",
|
||||
"items": [],
|
||||
"tech_items": [],
|
||||
"flat": [],
|
||||
"stats": {"error": str(exc)},
|
||||
}
|
||||
|
||||
if not raw:
|
||||
return {
|
||||
"enabled": False,
|
||||
"mode": "research",
|
||||
"items": [],
|
||||
"tech_items": [],
|
||||
"flat": [],
|
||||
"stats": {"error": "empty_response"},
|
||||
}
|
||||
|
||||
items, tech_items = parse_research_response(raw, limit=lim, tech_limit=tech_lim)
|
||||
payload = extract_json_object(raw)
|
||||
if payload:
|
||||
_save_research_json(research_json_path(date_str), payload)
|
||||
|
||||
if not items and not tech_items:
|
||||
logger.warning("AI 时讯 research JSON 无效或无条目")
|
||||
return {
|
||||
"enabled": False,
|
||||
"mode": "research",
|
||||
"items": [],
|
||||
"tech_items": [],
|
||||
"flat": [],
|
||||
"stats": {"error": "invalid_json"},
|
||||
}
|
||||
|
||||
items = _apply_pushed_dedup(items, date_str=date_str, limit=lim)
|
||||
if tech_items:
|
||||
tech_items = _apply_pushed_dedup(tech_items, date_str=date_str, limit=tech_lim)
|
||||
logger.info("AI 时讯 research 完成:%d 条 + %d 技术", len(items), len(tech_items))
|
||||
|
||||
flat = [
|
||||
{
|
||||
"title": i["title"],
|
||||
"link": i["link"],
|
||||
"summary": i.get("summary_plain") or i.get("desc_short") or "",
|
||||
"source_name": i["source_name"],
|
||||
"published_fmt": i.get("published_fmt") or "",
|
||||
"category_id": "research",
|
||||
"category_name": "Deep Research",
|
||||
"category_icon": "🔍",
|
||||
}
|
||||
for i in items + tech_items
|
||||
]
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"mode": "research",
|
||||
"hours": h,
|
||||
"items": items,
|
||||
"tech_items": tech_items,
|
||||
"flat": flat,
|
||||
"stats": {"source": "cursor_research", "items": len(items), "tech_items": len(tech_items)},
|
||||
}
|
||||
|
||||
|
||||
def format_research_news_section(
|
||||
research: dict[str, Any],
|
||||
*,
|
||||
section_no: int,
|
||||
wecom_limit: int | None = None,
|
||||
) -> list[str]:
|
||||
if not research.get("enabled"):
|
||||
hint = research.get("stats", {}).get("error", "调研失败或未配置 CURSOR_API_KEY")
|
||||
return [
|
||||
"---",
|
||||
"",
|
||||
f"## {section_no}、AI 时讯精选(Deep Research)",
|
||||
"",
|
||||
f"*不可用:{hint}*",
|
||||
"",
|
||||
]
|
||||
|
||||
hours = research.get("hours", 24)
|
||||
items = (research.get("flat") or [])[: wecom_limit or research_limit()]
|
||||
lines = [
|
||||
"---",
|
||||
"",
|
||||
f"## {section_no}、AI 时讯精选(Deep Research)",
|
||||
"",
|
||||
f"> 近 **{hours}h** · Cursor Agent WebSearch · {len(items)} 条",
|
||||
"",
|
||||
]
|
||||
if not items:
|
||||
lines.append("*暂无可用条目。*")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
pub = f" · {item['published_fmt']}" if item.get("published_fmt") else ""
|
||||
lines.append(
|
||||
f"{i}. **[{item['title']}]({item['link']})** · `{item['source_name']}`{pub}"
|
||||
)
|
||||
summary = item.get("summary") or ""
|
||||
if summary:
|
||||
lines.append(f" - {summary}")
|
||||
lines.append("")
|
||||
return lines
|
||||
18
daily/news/sanitize.py
Normal file
18
daily/news/sanitize.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""新闻文案清洗:剥离「放宽窗口」类凑数前缀。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_RELAX_PREFIX = re.compile(
|
||||
r"^(?:放宽窗口|放宽至[^::]*)\s*[::]\s*",
|
||||
re.UNICODE,
|
||||
)
|
||||
|
||||
|
||||
def strip_relax_window_prefix(text: str) -> str:
|
||||
"""去掉开头的「放宽窗口:」/「放宽至…:」前缀。"""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
return _RELAX_PREFIX.sub("", raw, count=1).strip()
|
||||
51
daily/push_gate.py
Normal file
51
daily/push_gate.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""企微早报推送闸门。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from daily.config import force_push, skip_push_when_silent
|
||||
|
||||
|
||||
@dataclass
|
||||
class PushGateResult:
|
||||
should_push: bool
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
silent: bool = False
|
||||
|
||||
|
||||
def _has_board_moves(movement: dict[str, Any]) -> bool:
|
||||
keys = (
|
||||
"skills_trending_moves",
|
||||
"skills_hot_moves",
|
||||
"github_trending_moves",
|
||||
"github_emerging_moves",
|
||||
"github_topic_moves",
|
||||
)
|
||||
return any(movement.get(k) for k in keys)
|
||||
|
||||
|
||||
def evaluate_push_gate(
|
||||
*,
|
||||
movement: dict[str, Any],
|
||||
ai_news_items: list[dict[str, Any]],
|
||||
cn_ai_news_items: list[dict[str, Any]],
|
||||
featured_pick: dict[str, Any] | None,
|
||||
) -> PushGateResult:
|
||||
if force_push():
|
||||
return PushGateResult(should_push=True, reasons=["force_push"], silent=False)
|
||||
|
||||
reasons: list[str] = []
|
||||
if _has_board_moves(movement):
|
||||
reasons.append("board_moves")
|
||||
if ai_news_items:
|
||||
reasons.append("ai_news")
|
||||
if cn_ai_news_items:
|
||||
reasons.append("cn_ai_news")
|
||||
if featured_pick:
|
||||
reasons.append("featured_pick")
|
||||
|
||||
should = bool(reasons)
|
||||
silent = not should and skip_push_when_silent()
|
||||
return PushGateResult(should_push=should, reasons=reasons, silent=silent)
|
||||
@@ -6,10 +6,11 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from daily.config import OUTPUT_DIR, env_int
|
||||
from daily.delta import build_movement_baseline, build_movement_context, compare_depth
|
||||
from daily.config import OUTPUT_DIR, env_int, wecom_mode
|
||||
from daily.delta import build_movement_baseline, build_movement_context, compare_depth, effective_wecom_mode
|
||||
from daily.news.fetch import prepare_wecom_cn_news_items, prepare_wecom_news_items
|
||||
from daily.skills_group import group_skills_by_source
|
||||
from daily.text_utils import trim_brief
|
||||
|
||||
|
||||
def skill_id(item: dict[str, Any]) -> str:
|
||||
@@ -59,16 +60,20 @@ def _slim_news_items(
|
||||
limit: int,
|
||||
*,
|
||||
prepare=prepare_wecom_news_items,
|
||||
date_str: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in prepare(ai_news):
|
||||
for item in prepare(ai_news, date_str=date_str):
|
||||
items.append(
|
||||
{
|
||||
"link": item.get("link", ""),
|
||||
"title": item.get("title", ""),
|
||||
"source_name": item.get("source_name", ""),
|
||||
"published_fmt": item.get("published_fmt", ""),
|
||||
"summary": item.get("desc_short") or "",
|
||||
"summary": trim_brief(
|
||||
item.get("summary_plain") or item.get("desc_short") or "",
|
||||
120,
|
||||
),
|
||||
}
|
||||
)
|
||||
if len(items) >= limit:
|
||||
@@ -89,7 +94,7 @@ def _slim_news_items(
|
||||
|
||||
|
||||
def _wecom_skill_pool() -> int:
|
||||
return max(10, env_int("DAILY_WECOM_SKILL_POOL", 200))
|
||||
return max(10, env_int("DAILY_WECOM_SKILL_POOL", 400))
|
||||
|
||||
|
||||
def build_llm_input(
|
||||
@@ -105,10 +110,46 @@ def build_llm_input(
|
||||
ai_news: dict[str, Any],
|
||||
cn_ai_news: dict[str, Any],
|
||||
wecom_limits: dict[str, int],
|
||||
research_items: list[dict[str, Any]] | None = None,
|
||||
research_tech_items: list[dict[str, Any]] | None = None,
|
||||
boards_for_wecom: dict[str, list[dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""供 Cursor 编辑的精简 JSON(不含完整 markdown)。"""
|
||||
news_limit = wecom_limits.get("ai_news", 10)
|
||||
cn_news_limit = wecom_limits.get("cn_ai_news", 8)
|
||||
if research_items is not None:
|
||||
slim_research = [
|
||||
{
|
||||
"link": item.get("link", ""),
|
||||
"title": item.get("title", ""),
|
||||
"source_name": item.get("source_name", ""),
|
||||
"published_fmt": item.get("published_fmt", ""),
|
||||
"summary": trim_brief(item.get("desc_short") or "", 120),
|
||||
}
|
||||
for item in research_items[:news_limit]
|
||||
]
|
||||
ai_news_payload = slim_research
|
||||
tech_news_payload = [
|
||||
{
|
||||
"link": item.get("link", ""),
|
||||
"title": item.get("title", ""),
|
||||
"source_name": item.get("source_name", ""),
|
||||
"published_fmt": item.get("published_fmt", ""),
|
||||
"summary": trim_brief(item.get("desc_short") or "", 120),
|
||||
}
|
||||
for item in (research_tech_items or [])
|
||||
]
|
||||
cn_news_payload: list[dict[str, Any]] = []
|
||||
ai_news_mode = "research"
|
||||
else:
|
||||
ai_news_payload = _slim_news_items(ai_news, news_limit, date_str=date_str) if ai_news.get("enabled") else []
|
||||
cn_news_payload = (
|
||||
_slim_news_items(cn_ai_news, cn_news_limit, prepare=prepare_wecom_cn_news_items, date_str=date_str)
|
||||
if cn_ai_news.get("enabled")
|
||||
else []
|
||||
)
|
||||
ai_news_mode = "rss"
|
||||
tech_news_payload: list[dict[str, Any]] = []
|
||||
depth = compare_depth()
|
||||
trend_cmp = trending[:depth]
|
||||
hot_cmp = hot[:depth]
|
||||
@@ -116,19 +157,26 @@ def build_llm_input(
|
||||
emerging_cmp = github_emerging[:depth]
|
||||
topic_cmp = github_topic[:depth]
|
||||
|
||||
trending_slice = group_skills_by_source(
|
||||
trending,
|
||||
limit=wecom_limits.get("trending", 10),
|
||||
pool_size=wecom_limits.get("trending_pool", _wecom_skill_pool()),
|
||||
)
|
||||
hot_slice = group_skills_by_source(
|
||||
hot,
|
||||
limit=wecom_limits.get("hot", 10),
|
||||
pool_size=wecom_limits.get("hot_pool", _wecom_skill_pool()),
|
||||
)
|
||||
github_slice = github_trending[: wecom_limits.get("github", 5)]
|
||||
emerging_slice = github_emerging[: wecom_limits.get("emerging", 3)]
|
||||
topic_slice = github_topic[: wecom_limits.get("topic", 3)]
|
||||
if boards_for_wecom:
|
||||
trending_slice = boards_for_wecom.get("skills_trending") or []
|
||||
hot_slice = boards_for_wecom.get("skills_hot") or []
|
||||
github_slice = boards_for_wecom.get("github_trending") or []
|
||||
emerging_slice = boards_for_wecom.get("github_emerging") or []
|
||||
topic_slice = boards_for_wecom.get("github_topic") or []
|
||||
else:
|
||||
trending_slice = group_skills_by_source(
|
||||
trending,
|
||||
limit=wecom_limits.get("trending", 10),
|
||||
pool_size=wecom_limits.get("trending_pool", _wecom_skill_pool()),
|
||||
)
|
||||
hot_slice = group_skills_by_source(
|
||||
hot,
|
||||
limit=wecom_limits.get("hot", 10),
|
||||
pool_size=wecom_limits.get("hot_pool", _wecom_skill_pool()),
|
||||
)
|
||||
github_slice = github_trending[: wecom_limits.get("github", 5)]
|
||||
emerging_slice = github_emerging[: wecom_limits.get("emerging", 3)]
|
||||
topic_slice = github_topic[: wecom_limits.get("topic", 3)]
|
||||
|
||||
movement = build_movement_context(
|
||||
date_str=date_str,
|
||||
@@ -147,10 +195,13 @@ def build_llm_input(
|
||||
github_topic=[_slim_github(x) for x in topic_cmp],
|
||||
depth=depth,
|
||||
)
|
||||
eff_mode = effective_wecom_mode(date_str=date_str)
|
||||
|
||||
return {
|
||||
"date": date_str,
|
||||
"data_updated": updated,
|
||||
"wecom_mode": wecom_mode(),
|
||||
"effective_wecom_mode": eff_mode,
|
||||
"skills_trending": [_slim_skill(x) for x in trending_slice],
|
||||
"skills_hot": [_slim_skill(x) for x in hot_slice],
|
||||
"github_trending": [_slim_github(x) for x in github_slice],
|
||||
@@ -159,12 +210,10 @@ def build_llm_input(
|
||||
"topic": topic_name,
|
||||
"repos": [_slim_github(x) for x in topic_slice],
|
||||
},
|
||||
"ai_news": _slim_news_items(ai_news, news_limit) if ai_news.get("enabled") else [],
|
||||
"cn_ai_news": _slim_news_items(
|
||||
cn_ai_news, cn_news_limit, prepare=prepare_wecom_cn_news_items
|
||||
)
|
||||
if cn_ai_news.get("enabled")
|
||||
else [],
|
||||
"ai_news": ai_news_payload,
|
||||
"tech_ai_news": tech_news_payload,
|
||||
"cn_ai_news": cn_news_payload,
|
||||
"ai_news_mode": ai_news_mode,
|
||||
"movement": movement,
|
||||
"movement_baseline": movement_baseline,
|
||||
}
|
||||
@@ -186,6 +235,10 @@ def editorial_json_path(date_str: str) -> Path:
|
||||
return OUTPUT_DIR / f"{date_str}.editorial.json"
|
||||
|
||||
|
||||
def featured_json_path(date_str: str) -> Path:
|
||||
return OUTPUT_DIR / f"{date_str}.featured.json"
|
||||
|
||||
|
||||
def save_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
284
daily/scheduler.py
Normal file
284
daily/scheduler.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""常驻调度:按配置时刻生成早报并推送企微。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time as dt_time, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from daily.config import (
|
||||
CACHE_DIR,
|
||||
LOG_DIR,
|
||||
OUTPUT_DIR,
|
||||
ROOT,
|
||||
schedule_generate_at,
|
||||
schedule_push_at,
|
||||
schedule_timezone_name,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STATE_FILE = CACHE_DIR / "scheduler-state.json"
|
||||
_POLL_SECONDS = 15
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClockTime:
|
||||
hour: int
|
||||
minute: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchedulerState:
|
||||
last_generate_date: str | None = None
|
||||
last_push_date: str | None = None
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> SchedulerState:
|
||||
if not _STATE_FILE.exists():
|
||||
return cls()
|
||||
try:
|
||||
raw = json.loads(_STATE_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return cls()
|
||||
if not isinstance(raw, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
last_generate_date=raw.get("last_generate_date"),
|
||||
last_push_date=raw.get("last_push_date"),
|
||||
)
|
||||
|
||||
def save(self) -> None:
|
||||
_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"last_generate_date": self.last_generate_date,
|
||||
"last_push_date": self.last_push_date,
|
||||
}
|
||||
_STATE_FILE.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def parse_hhmm(value: str) -> ClockTime:
|
||||
raw = (value or "").strip()
|
||||
parts = raw.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"无效时间格式: {value!r},应为 HH:MM")
|
||||
hour = int(parts[0])
|
||||
minute = int(parts[1])
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
raise ValueError(f"无效时间: {value!r}")
|
||||
return ClockTime(hour=hour, minute=minute)
|
||||
|
||||
|
||||
def load_timezone() -> ZoneInfo:
|
||||
name = schedule_timezone_name()
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"无效时区 DAILY_SCHEDULE_TZ={name!r}") from exc
|
||||
|
||||
|
||||
def _localize(day: date, clock: ClockTime, tz: ZoneInfo) -> datetime:
|
||||
return datetime.combine(day, dt_time(clock.hour, clock.minute), tz)
|
||||
|
||||
|
||||
def next_occurrence_after(clock: ClockTime, tz: ZoneInfo, after: datetime) -> datetime:
|
||||
local = after.astimezone(tz)
|
||||
candidate = local.replace(hour=clock.hour, minute=clock.minute, second=0, microsecond=0)
|
||||
if candidate <= local:
|
||||
candidate += timedelta(days=1)
|
||||
return candidate
|
||||
|
||||
|
||||
def _today_slot(day: date, clock: ClockTime, tz: ZoneInfo) -> datetime:
|
||||
return _localize(day, clock, tz)
|
||||
|
||||
|
||||
def _run_daily_subcommand(subcmd: str, *extra: str) -> int:
|
||||
cmd = [sys.executable, "-m", "daily", subcmd, *extra]
|
||||
logger.info("执行: %s", " ".join(cmd))
|
||||
proc = subprocess.run(cmd, cwd=str(ROOT), check=False)
|
||||
return int(proc.returncode)
|
||||
|
||||
|
||||
def run_generate() -> int:
|
||||
return _run_daily_subcommand("generate")
|
||||
|
||||
|
||||
def run_push_for_date(date_str: str) -> int:
|
||||
report = OUTPUT_DIR / f"{date_str}.wecom.md"
|
||||
if not report.exists():
|
||||
logger.error("推送失败:报告不存在 %s", report)
|
||||
return 1
|
||||
return _run_daily_subcommand("push", str(report))
|
||||
|
||||
|
||||
def _setup_logging() -> Path:
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_path = LOG_DIR / "scheduler.log"
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(log_path, encoding="utf-8"),
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
)
|
||||
return log_path
|
||||
|
||||
|
||||
def _sleep_until(target: datetime, tz: ZoneInfo) -> None:
|
||||
while True:
|
||||
now = datetime.now(tz)
|
||||
seconds = (target - now).total_seconds()
|
||||
if seconds <= 0:
|
||||
return
|
||||
time.sleep(min(seconds, _POLL_SECONDS))
|
||||
|
||||
|
||||
def plan_next_action(
|
||||
*,
|
||||
now: datetime,
|
||||
tz: ZoneInfo,
|
||||
state: SchedulerState,
|
||||
generate_at: ClockTime,
|
||||
push_at: ClockTime,
|
||||
) -> tuple[datetime, str] | None:
|
||||
"""返回下一次应执行的动作;若今日已全部完成则返回明日 generate。"""
|
||||
today = now.astimezone(tz).date()
|
||||
today_str = today.isoformat()
|
||||
gen_done = state.last_generate_date == today_str
|
||||
push_done = state.last_push_date == today_str
|
||||
gen_slot = _today_slot(today, generate_at, tz)
|
||||
push_slot = _today_slot(today, push_at, tz)
|
||||
|
||||
# 推送窗口内:generate 未做则立即补跑(须先于 push)
|
||||
if not gen_done and gen_slot <= now <= push_slot:
|
||||
return now, "generate"
|
||||
# 已过推送时刻:仅当 generate 已完成时补跑 push
|
||||
if not push_done and gen_done and now >= push_slot:
|
||||
return now, "push"
|
||||
|
||||
candidates: list[tuple[datetime, str]] = []
|
||||
if not gen_done and gen_slot > now:
|
||||
candidates.append((gen_slot, "generate"))
|
||||
if not push_done and push_slot > now:
|
||||
candidates.append((push_slot, "push"))
|
||||
if candidates:
|
||||
return min(candidates, key=lambda item: item[0])
|
||||
|
||||
tomorrow_gen = next_occurrence_after(generate_at, tz, now)
|
||||
return tomorrow_gen, "generate"
|
||||
|
||||
|
||||
def run_scheduled_action(action: str, *, today_str: str) -> int:
|
||||
if action == "generate":
|
||||
return run_generate()
|
||||
if action == "push":
|
||||
return run_push_for_date(today_str)
|
||||
raise ValueError(f"未知动作: {action}")
|
||||
|
||||
|
||||
def tick_once(
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
tz: ZoneInfo | None = None,
|
||||
state: SchedulerState | None = None,
|
||||
generate_at: ClockTime | None = None,
|
||||
push_at: ClockTime | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> SchedulerState:
|
||||
tz = tz or load_timezone()
|
||||
now = now or datetime.now(tz)
|
||||
state = state or SchedulerState.load()
|
||||
generate_at = generate_at or parse_hhmm(schedule_generate_at())
|
||||
push_at = push_at or parse_hhmm(schedule_push_at())
|
||||
|
||||
today_str = now.astimezone(tz).date().isoformat()
|
||||
planned = plan_next_action(
|
||||
now=now,
|
||||
tz=tz,
|
||||
state=state,
|
||||
generate_at=generate_at,
|
||||
push_at=push_at,
|
||||
)
|
||||
if not planned:
|
||||
return state
|
||||
|
||||
run_at, action = planned
|
||||
if run_at > now:
|
||||
if not dry_run:
|
||||
logger.info("下次 %s @ %s (%s)", action, run_at.isoformat(), tz.key)
|
||||
_sleep_until(run_at, tz)
|
||||
elif not dry_run:
|
||||
slot = _today_slot(now.astimezone(tz).date(), generate_at if action == "generate" else push_at, tz)
|
||||
logger.info(
|
||||
"补跑 %s(计划 %02d:%02d,当前 %s)",
|
||||
action,
|
||||
slot.hour,
|
||||
slot.minute,
|
||||
now.astimezone(tz).strftime("%H:%M"),
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logger.info("[dry-run] 将执行 %s @ %s", action, run_at.isoformat())
|
||||
return state
|
||||
|
||||
logger.info("开始 %s(%s)", action, today_str)
|
||||
code = run_scheduled_action(action, today_str=today_str)
|
||||
if code != 0:
|
||||
logger.error("%s 失败,exit=%s", action, code)
|
||||
else:
|
||||
if action == "generate":
|
||||
state.last_generate_date = today_str
|
||||
elif action == "push":
|
||||
state.last_push_date = today_str
|
||||
state.save()
|
||||
logger.info("%s 完成", action)
|
||||
return state
|
||||
|
||||
|
||||
def main() -> int:
|
||||
dry_run = "--dry-run" in sys.argv[1:]
|
||||
once = "--once" in sys.argv[1:]
|
||||
|
||||
log_path = _setup_logging()
|
||||
tz = load_timezone()
|
||||
generate_at = parse_hhmm(schedule_generate_at())
|
||||
push_at = parse_hhmm(schedule_push_at())
|
||||
|
||||
logger.info(
|
||||
"调度器启动 tz=%s generate=%02d:%02d push=%02d:%02d log=%s",
|
||||
tz.key,
|
||||
generate_at.hour,
|
||||
generate_at.minute,
|
||||
push_at.hour,
|
||||
push_at.minute,
|
||||
log_path,
|
||||
)
|
||||
|
||||
state = SchedulerState.load()
|
||||
try:
|
||||
while True:
|
||||
state = tick_once(
|
||||
tz=tz,
|
||||
state=state,
|
||||
generate_at=generate_at,
|
||||
push_at=push_at,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
if once or dry_run:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
logger.info("调度器已停止(KeyboardInterrupt)")
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
|
||||
from daily.config import env
|
||||
from daily.config import CACHE_DIR, env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,6 +19,81 @@ Board = Literal["trending", "hot"]
|
||||
SKILLS_SITE = "https://www.skills.sh"
|
||||
USER_AGENT = "Mozilla/5.0 (compatible; skills-hot-daily/1.0; +https://skills.sh)"
|
||||
|
||||
FEED_URLS = [
|
||||
"https://cdn.jsdelivr.net/gh/NeverSight/skills.sh_feed@main/data/feed.json",
|
||||
"https://raw.githubusercontent.com/NeverSight/skills.sh_feed/main/data/feed.json",
|
||||
]
|
||||
FEED_CACHE_TTL = 600
|
||||
FEED_CACHE_FILE = CACHE_DIR / "feed.json"
|
||||
|
||||
_feed_cache: dict[str, Any] = {"data": None, "fetched_at": 0.0}
|
||||
|
||||
|
||||
def format_installs(n: int | float) -> str:
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.1f}M"
|
||||
if n >= 1_000:
|
||||
return f"{n / 1_000:.1f}K"
|
||||
return str(int(n))
|
||||
|
||||
|
||||
def _fetch_feed_json(url: str) -> dict[str, Any]:
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
with httpx.Client(
|
||||
timeout=httpx.Timeout(20.0, connect=10.0),
|
||||
verify=certifi.where(),
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
resp = client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _load_feed_disk_cache() -> dict[str, Any] | None:
|
||||
if not FEED_CACHE_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(FEED_CACHE_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("读取 feed 本地缓存失败: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _save_feed_disk_cache(data: dict[str, Any]) -> None:
|
||||
FEED_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
FEED_CACHE_FILE.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def load_feed(force: bool = False) -> dict[str, Any]:
|
||||
now = time.time()
|
||||
if not force and _feed_cache["data"] and now - _feed_cache["fetched_at"] < FEED_CACHE_TTL:
|
||||
return _feed_cache["data"]
|
||||
|
||||
errors: list[str] = []
|
||||
for url in FEED_URLS:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
data = _fetch_feed_json(url)
|
||||
_feed_cache["data"] = data
|
||||
_feed_cache["fetched_at"] = now
|
||||
_save_feed_disk_cache(data)
|
||||
logger.info("skills feed 已更新: %s", url)
|
||||
return data
|
||||
except Exception as exc:
|
||||
msg = f"{url} (#{attempt + 1}): {exc}"
|
||||
errors.append(msg)
|
||||
logger.debug("拉取失败 %s", msg)
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
|
||||
stale = _load_feed_disk_cache()
|
||||
if stale:
|
||||
logger.warning("网络不可用,回退到 feed 本地缓存")
|
||||
_feed_cache["data"] = stale
|
||||
_feed_cache["fetched_at"] = now
|
||||
return stale
|
||||
|
||||
raise RuntimeError(f"无法获取 skills 数据。最近错误: {errors[-1] if errors else 'unknown'}")
|
||||
|
||||
_SKILL_RE = re.compile(
|
||||
r'\{"source":"(?P<source>[^"]+)","skillId":"(?P<skill_id>[^"]+)",'
|
||||
r'"name":"(?P<name>[^"]+)","installs":(?P<installs>\d+)'
|
||||
|
||||
@@ -9,6 +9,28 @@ def skill_id(item: dict[str, Any]) -> str:
|
||||
return str(item.get("id") or f"{item.get('source')}/{item.get('title')}")
|
||||
|
||||
|
||||
def source_from_skill_key(key: str) -> str:
|
||||
"""从 skill id(source/title…)还原 source:去掉最后一段 title。"""
|
||||
parts = [p for p in str(key or "").split("/") if p]
|
||||
if len(parts) >= 2:
|
||||
return "/".join(parts[:-1])
|
||||
return str(key or "").strip()
|
||||
|
||||
|
||||
def expand_skill_recent_keys(keys: set[str] | None) -> set[str]:
|
||||
"""周去重 blocklist:保留原始 key,并展开为 source。"""
|
||||
out: set[str] = set()
|
||||
for key in keys or set():
|
||||
k = str(key or "").strip()
|
||||
if not k:
|
||||
continue
|
||||
out.add(k)
|
||||
src = source_from_skill_key(k)
|
||||
if src:
|
||||
out.add(src)
|
||||
return out
|
||||
|
||||
|
||||
def format_installs(n: int | float) -> str:
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.1f}M"
|
||||
|
||||
@@ -15,7 +15,7 @@ def clip_text(text: str, limit: int) -> str:
|
||||
|
||||
|
||||
def trim_brief(text: str, limit: int) -> str:
|
||||
"""企微简要:控制在 limit 内,优先在句读处截断,不加省略号。"""
|
||||
"""企微简要:控制在 limit 内,优先在句读/词边界截断,不加省略号。"""
|
||||
text = _WS.sub(" ", (text or "").strip())
|
||||
if not text or limit <= 0 or len(text) <= limit:
|
||||
return text
|
||||
@@ -27,4 +27,12 @@ def trim_brief(text: str, limit: int) -> str:
|
||||
pos = text.find(sep)
|
||||
if pos != -1 and pos + 1 <= limit:
|
||||
return text[: pos + 1]
|
||||
return text[:limit].rstrip(",、;: ")
|
||||
for sep in (". ", "! ", "? ", "; "):
|
||||
pos = text.rfind(sep, 0, limit + 1)
|
||||
if pos != -1 and pos + 1 >= min(limit // 2, 20):
|
||||
return text[: pos + 1].rstrip()
|
||||
if len(text) > limit:
|
||||
space = text.rfind(" ", 0, limit + 1)
|
||||
if space >= min(limit // 2, 20):
|
||||
return text[:space].rstrip(",、;: ,.;")
|
||||
return text[:limit].rstrip(",、;: ,.;")
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -13,6 +15,7 @@ from daily.config import OUTPUT_DIR, ROOT, env_int, wecom_chunk_bytes
|
||||
from daily.wecom_split import split_wecom_messages
|
||||
|
||||
_PUSH_GAP_MS = 300
|
||||
_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})\.wecom\.md$")
|
||||
|
||||
|
||||
def _load_webhook_key() -> str:
|
||||
@@ -43,6 +46,31 @@ def _resolve_report_path(arg: str | None) -> Path:
|
||||
raise RuntimeError("未找到 .wecom.md 报告,请先运行 python -m daily")
|
||||
|
||||
|
||||
def _push_gate_for_report(path: Path) -> dict | None:
|
||||
match = _DATE_RE.search(path.name)
|
||||
if not match:
|
||||
return None
|
||||
data_path = path.parent / f"{match.group(1)}.data.json"
|
||||
if not data_path.exists():
|
||||
data_path = OUTPUT_DIR / f"{match.group(1)}.data.json"
|
||||
if not data_path.exists():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(data_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
meta = payload.get("meta") or {}
|
||||
gate = meta.get("push_gate")
|
||||
return gate if isinstance(gate, dict) else None
|
||||
|
||||
|
||||
def should_skip_push(report_path: Path) -> bool:
|
||||
gate = _push_gate_for_report(report_path)
|
||||
if not gate:
|
||||
return False
|
||||
return bool(gate.get("silent")) and not gate.get("should_push")
|
||||
|
||||
|
||||
def _post_markdown(client: httpx.Client, url: str, content: str) -> None:
|
||||
payload = {"msgtype": "markdown", "markdown": {"content": content}}
|
||||
resp = client.post(url, json=payload)
|
||||
@@ -54,6 +82,9 @@ def _post_markdown(client: httpx.Client, url: str, content: str) -> None:
|
||||
|
||||
def send_report(report_path: Path | None = None) -> None:
|
||||
path = _resolve_report_path(str(report_path) if report_path else None)
|
||||
if should_skip_push(path):
|
||||
print(f"[silent] no push gate matched for {path.name}")
|
||||
return
|
||||
if not path.exists():
|
||||
raise RuntimeError(f"报告文件不存在: {path}")
|
||||
|
||||
|
||||
273
docs/design-wecom-delta-mode.md
Normal file
273
docs/design-wecom-delta-mode.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Design: 企微早报 Delta 模式
|
||||
|
||||
Generated: 2026-07-09
|
||||
Repo: daily-robots
|
||||
Status: DRAFT
|
||||
Mode: Builder
|
||||
|
||||
## Problem Statement
|
||||
|
||||
企微早报每天推送五榜 Top 10 + 18 条新闻,内容与前几日高度重复(`find-skills`、`openclaw`、飞书集群等长期霸榜)。读者真实需求是「今天有什么新变化」,而非「再读一遍黄页」。
|
||||
|
||||
根因:
|
||||
|
||||
1. `daily-agent/SKILL.md` 要求即使较昨日无新增,仍须完整列出 Top 榜。
|
||||
2. `movement` 仅用于 opening / signals,列表区块仍全量渲染。
|
||||
3. Trending 与 Hot 独立展示,同一 skill 描述写两遍。
|
||||
4. 新闻 `DAILY_AI_NEWS_HOURS=72`,无已推送 link 去重,旧闻可连续出现。
|
||||
|
||||
## What Makes This Cool
|
||||
|
||||
把早报从「日报复印机」变成「变化通知」:只有新入榜、新新闻、编辑推荐时才占版面;榜全稳且无新新闻时静默不推。读者打开企微即知「今天值得扫一眼的是什么」。
|
||||
|
||||
## Explicit Non-Goals(已否决方案)
|
||||
|
||||
以下方案**不在本设计范围内**:
|
||||
|
||||
| 方案 | 状态 |
|
||||
|------|------|
|
||||
| 静态页 / 外链档案库 | ❌ 不做 |
|
||||
| 今日一装(每天一个 `npx skills add`) | ❌ 不做 |
|
||||
| 按星期轮换版面 | ❌ 不做 |
|
||||
| 榜首锚点(稳定日仍展示 #1) | ❌ 不做 |
|
||||
|
||||
## Premises
|
||||
|
||||
1. 重复感主要来自**列表区块全量复印**,而非 opening 里引用榜首数字。
|
||||
2. `output/*.data.json` 与 `daily/delta.py` 已具备新入榜对比能力,应上升为**列表渲染主数据源**。
|
||||
3. 企微消息仍在应用内读完,不依赖外部页面。
|
||||
4. 叙事层(opening、信号、首推、新闻)保持充实;缩短的是**榜单列表**,不是整报。
|
||||
|
||||
## Recommended Approach: Delta 模式
|
||||
|
||||
### 环境变量
|
||||
|
||||
```env
|
||||
# full = 现有行为(全量 Top 榜列表)
|
||||
# delta = 本设计(默认推荐)
|
||||
DAILY_WECOM_MODE=delta
|
||||
|
||||
# 无对比基准时(首日或缺历史 data.json)是否自动 full 一次
|
||||
DAILY_DELTA_BASELINE_FALLBACK=full # full | empty
|
||||
|
||||
# 推送闸门全不满足时是否跳过 webhook(仍写 output 文件)
|
||||
DAILY_SKIP_PUSH_WHEN_SILENT=1
|
||||
|
||||
# 强制推送(忽略静默)
|
||||
# DAILY_FORCE_PUSH=1
|
||||
|
||||
# 新闻:缩短窗口 + 去重天数
|
||||
DAILY_AI_NEWS_HOURS=24
|
||||
DAILY_NEWS_DEDUP_DAYS=7
|
||||
```
|
||||
|
||||
### 推送闸门(Push Gate)
|
||||
|
||||
满足**任一**条件则生成并推送企微早报:
|
||||
|
||||
| 条件 | 数据源 |
|
||||
|------|--------|
|
||||
| 任榜单有新入条目 | `movement.*_moves` 非空 |
|
||||
| 去重后仍有新新闻 | 国际或国内 AI 时讯 |
|
||||
| 存在 `featured_pick` | Step 0 编辑推荐 |
|
||||
| `DAILY_FORCE_PUSH=1` | 环境变量 |
|
||||
|
||||
**静默日**:以上皆不满足 → 不调用 webhook(`DAILY_SKIP_PUSH_WHEN_SILENT=1` 时)。
|
||||
仍执行 `daily generate`,写入 `output/{date}.md`、`output/{date}.wecom.md`、`output/{date}.data.json` 留档。
|
||||
|
||||
**注意**:仅新闻有新、榜单全稳时**仍推送**,但 Skills/GitHub 列表区块整块省略(不是全天静默)。
|
||||
|
||||
### 列表渲染(Delta 列表)
|
||||
|
||||
`DAILY_WECOM_MODE=delta` 时:
|
||||
|
||||
#### Skills
|
||||
|
||||
- **仅展示** `movement.skills_trending_moves` / `movement.skills_hot_moves` 中的新入榜条目。
|
||||
- **跨榜去重**:按 `skill_id`(`id` 或 `source/title`)合并;同一 skill 只出现一次,标注来源榜(如 `Trending #4 · Hot #2`)。
|
||||
- **无新入**:该榜区块**整块不出现**(不写多行「较昨日无新增」)。
|
||||
|
||||
#### GitHub
|
||||
|
||||
- 仅展示 `movement.github_trending_moves`、`github_emerging_moves`、`github_topic_moves`。
|
||||
- 无新入则区块省略。
|
||||
|
||||
#### 不包含
|
||||
|
||||
- 全量 Top N 列表
|
||||
- 榜首锚点
|
||||
- `(新入 … #n)` 括号标注(与现 `agent_workflow._strip_new_entry_notes` 一致,列表标题用 `[新入 #n]` 前缀即可)
|
||||
|
||||
### 固定骨架(不因 Delta 缩短)
|
||||
|
||||
Agent 模式(`DAILY_REPORT_MODE=agent`)下,以下区块**保持**:
|
||||
|
||||
- opening(2–3 句,首句含具体证据)
|
||||
- headline / 今日主题
|
||||
- 今日信号(3–5 条)
|
||||
- 今日首推
|
||||
- 国际 AI / 国内 AI 精选(条数仍由 `DAILY_WECOM_AI_NEWS` 等控制)
|
||||
|
||||
榜单变短;叙事与新闻不主动砍到 0。
|
||||
|
||||
### Full 模式逃生口
|
||||
|
||||
`DAILY_WECOM_MODE=full` 时行为与**当前生产一致**(`format_wecom.build_wecom_report` / `replace_wecom_skill_sections` 全量 Top N)。用于手动切回或对比测试。
|
||||
|
||||
### 首日 / 无历史基准
|
||||
|
||||
`find_previous_data(date)` 返回 `None` 时:
|
||||
|
||||
| `DAILY_DELTA_BASELINE_FALLBACK` | 行为 |
|
||||
|----------------------------------|------|
|
||||
| `full`(推荐) | 当日按 full 模式渲染列表一次;次日起 delta |
|
||||
| `empty` | 当日列表区块为空;opening 须说明「首日报,暂无对比基准」 |
|
||||
|
||||
实现时在 `generate_report` 或 `build_llm_input` 传入 `baseline_date` 供 Agent 引用。
|
||||
|
||||
## News Dedup
|
||||
|
||||
### P0(本阶段)
|
||||
|
||||
- 维护 `cache/pushed-news-links.json`(或写入 `output/` 旁 cache):最近 `DAILY_NEWS_DEDUP_DAYS` 天已出现在企微早报中的 `link` 集合。
|
||||
- `prepare_wecom_news_items` / `prepare_wecom_cn_news_items` 输出前过滤已见 link。
|
||||
- `DAILY_AI_NEWS_HOURS` 默认改为 `24`(`.env.example` 同步)。
|
||||
|
||||
### P1(可选后续)
|
||||
|
||||
- 标题归一化去重(同一事件多源报道)
|
||||
- 每 `source_name` 每日上限 N 条
|
||||
|
||||
## Agent Skill 变更
|
||||
|
||||
文件:`skills/daily-agent/SKILL.md`
|
||||
|
||||
### 删除 / 修改
|
||||
|
||||
- 删除规则:「即使某榜较昨日无新增,仍须完整列出 Top 榜条目」。
|
||||
- 删除:「禁止改用 movement 作为列表来源」(在 delta 模式下反转)。
|
||||
|
||||
### 新增
|
||||
|
||||
当 `DAILY_WECOM_MODE=delta`(或 llm_input 含 `wecom_mode: delta`):
|
||||
|
||||
1. Agent **不写** Skills Trending / Hot / GitHub 列表(仍由 Python 插入,与现流程一致)。
|
||||
2. opening / signals **可引用**榜首与 movement 摘要;禁止在 signals 重复列表已展示的同一事实。
|
||||
3. 榜全稳时,signals 聚焦新闻与首推,不必编造榜单变化。
|
||||
|
||||
当 `wecom_mode: full` 时保持现有 SKILL 规则。
|
||||
|
||||
## Python 模块变更
|
||||
|
||||
| 模块 | 变更 |
|
||||
|------|------|
|
||||
| `daily/config.py` | `wecom_mode()`, `news_dedup_days()`, `skip_push_when_silent()`, `delta_baseline_fallback()` |
|
||||
| `daily/delta.py` | 可选:`merge_skill_moves_for_wecom(trending_moves, hot_moves)` 跨榜去重 |
|
||||
| `daily/format_wecom.py` | `build_skills_delta_section()`, `build_github_delta_section()`;`replace_wecom_skill_sections` 支持 delta |
|
||||
| `daily/news/fetch.py` | `filter_pushed_news()` + cache 读写 |
|
||||
| `daily/generate.py` | 推送闸门;baseline fallback;静默 skip push |
|
||||
| `daily/report_data.py` | `llm_input` 增加 `wecom_mode`, `push_gate` 摘要 |
|
||||
| `daily/agent_workflow.py` | 无逻辑变更;依赖 Python 插入 delta 列表 |
|
||||
| `.env.example` | 新 env 文档 |
|
||||
|
||||
## 企微消息示例
|
||||
|
||||
### 有变化日
|
||||
|
||||
```markdown
|
||||
📰 **早报 · 2026-07-10**
|
||||
|
||||
[opening:今天最大变化,含数字/条目名]
|
||||
|
||||
🎯 **{headline}**
|
||||
|
||||
💡 **今日信号**
|
||||
> ...
|
||||
|
||||
📦 **今日首推**
|
||||
[...]
|
||||
|
||||
🌍 **国际 AI · 精选 N**
|
||||
...
|
||||
|
||||
📈 **Skills Trending 变化**
|
||||
1. [新入 #4] [**xxx**](...) · ...
|
||||
描述一行
|
||||
|
||||
🔥 **Skills Hot 变化**
|
||||
1. [新入 #2] [**yyy**](...) · ...
|
||||
|
||||
🐙 **GitHub Trending 变化**
|
||||
1. [新入 #4] [owner/repo](...) · ...
|
||||
```
|
||||
|
||||
### 仅新闻有新(榜稳)
|
||||
|
||||
- 无 📈/🔥/🐙 区块
|
||||
- opening 可一句:「榜单较昨日 Top15 无新入;以下为今日 AI 时讯。」
|
||||
|
||||
### 静默日
|
||||
|
||||
- 不推送企微
|
||||
- `output/` 仍落盘;日志:`[silent] no push gate matched for 2026-07-10`
|
||||
|
||||
## Approaches Considered
|
||||
|
||||
### Approach A: 配置瘦身(缩 Top N、24h 新闻)
|
||||
|
||||
- Effort: S | Risk: Low
|
||||
- 只减篇幅,榜头仍天天重复;未解决根因。
|
||||
|
||||
### Approach B: Delta 列表 + 推送闸门 + 新闻去重(本设计)
|
||||
|
||||
- Effort: M | Risk: Med
|
||||
- 复用 `delta.py`;改 format + skill + push 逻辑。
|
||||
|
||||
### Approach C: 仅改 Agent 文案
|
||||
|
||||
- Effort: S | Risk: High
|
||||
- Python 仍插入全量列表,规则冲突,不可持续。
|
||||
|
||||
**Recommendation: B** — 数据层与展示层一致,静默日与跨榜去重可测。
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. 连续 3 天对比 `output/*.data.json`:企微列表区块**重复 skill_id 占比**显著下降。
|
||||
2. 榜全稳且无新新闻日:`DAILY_SKIP_PUSH_WHEN_SILENT=1` 时不发 webhook。
|
||||
3. Trending/Hot 同一 skill 在列表中**最多出现 1 次**。
|
||||
4. `DAILY_WECOM_MODE=full` 与现网行为一致(回归用)。
|
||||
5. 首日 `DAILY_DELTA_BASELINE_FALLBACK=full` 不产生空列表投诉。
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. 静默日是否需要在企微发一行「今日无更新」?当前设计:**不发**。
|
||||
2. 新闻去重 cache 是否纳入 git?建议:**否**,放 `cache/`(已在 `.gitignore`)。
|
||||
3. Classic 模式(非 agent)是否同步 delta?建议:**是**,同一 `format_wecom` 路径。
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
| ID | Priority | Task | Files |
|
||||
|----|----------|------|-------|
|
||||
| T1 | P1 | 新增 config helpers + `.env.example` | `daily/config.py`, `.env.example` |
|
||||
| T2 | P1 | 新闻 link 去重 cache | `daily/news/fetch.py`, `daily/config.py` |
|
||||
| T3 | P1 | Delta 列表渲染 + 跨榜去重 | `daily/format_wecom.py`, `daily/delta.py` |
|
||||
| T4 | P1 | 推送闸门 + 静默 skip push | `daily/generate.py`, `daily/webhook.py` |
|
||||
| T5 | P1 | baseline fallback full 一次 | `daily/generate.py` |
|
||||
| T6 | P1 | 更新 `daily-agent/SKILL.md` | `skills/daily-agent/SKILL.md` |
|
||||
| T7 | P2 | `llm_input` 传 `wecom_mode` / push 摘要 | `daily/report_data.py` |
|
||||
| T8 | P2 | 单元测试:跨榜去重、推送闸门、新闻去重 | `tests/test_wecom_delta.py` |
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] 有 `2026-07-09.data.json` 时生成 `2026-07-10`:列表仅含新入项
|
||||
- [ ] 人造「全稳 + 无新新闻」:不 push
|
||||
- [ ] 人造「全稳 + 有新新闻」:push,无 Skills/GitHub 块
|
||||
- [ ] `DAILY_WECOM_MODE=full` 输出与改前 `2026-07-09.wecom.md` 结构一致
|
||||
- [ ] 无 baseline + `DAILY_DELTA_BASELINE_FALLBACK=full`:首日全量列表
|
||||
- [ ] 同一 skill 在 Trending/Hot moves 均出现:列表只 1 条
|
||||
|
||||
## What I Noticed
|
||||
|
||||
- 重复感是**产品形态**问题,不是 Agent 文笔问题。
|
||||
- 明确否决静态页、今日一装、轮换、锚点后,方案边界清晰,实现可分期。
|
||||
- 推送闸门必须**把新闻算进去**,否则静默日会被新闻绕过。
|
||||
1162
docs/superpowers/plans/2026-07-09-wecom-delta-mode.md
Normal file
1162
docs/superpowers/plans/2026-07-09-wecom-delta-mode.md
Normal file
File diff suppressed because it is too large
Load Diff
604
docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md
Normal file
604
docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md
Normal file
@@ -0,0 +1,604 @@
|
||||
# 企微早报多样性与去重 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现企微早报硬去重多样性:五榜周去重(深池补满)、首推与昨日相同则改推(月去重)、叙事轴代码互斥、取消新闻「放宽窗口」凑数;且 `movement_baseline` 与 `wecom_shown_keys` 严格分离。
|
||||
|
||||
**Architecture:** 在 `daily generate` 管线加代码选择器:`board_select` 为 full/delta 唯一列表主人;周历史只读写 `data.wecom_shown_keys`(post-render);`movement_baseline` 仍为 raw Top compare;`featured_resolve` 先定人再 research;`pick_narrative_axis` 代码选轴注入 Agent Step1;新闻关 backfill + 剥「放宽」前缀。
|
||||
|
||||
**Tech Stack:** Python 3.13+、现有 `unittest`/`pytest`、`daily/delta.py` / `format_wecom.py` / `featured_pick.py` / `news/fetch.py`、`output/*.data.json`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md`(Status: APPROVED)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `movement_baseline` **禁止**被展示历史覆写;周去重只读 `wecom_shown_keys`
|
||||
- full/delta **唯一列表主人** = `board_select`(delta 的 pad 共用同一套 shown 历史)
|
||||
- `DAILY_BOARD_DEDUP_DAYS` 默认 `7`;与 pad lookback 对齐且数据源同一
|
||||
- `DAILY_FEATURED_DEDUP_DAYS` 默认 `30`
|
||||
- `DAILY_NARRATIVE_AXIS_DAYS` 默认 `3`;轴枚举固定 7 个(见 Task 5)
|
||||
- `DAILY_NEWS_BACKFILL` 默认 `0`(禁止旧闻凑数)
|
||||
- research 补新闻年龄上限 = `DAILY_AI_NEWS_HOURS`(不得变相 48h 放宽)
|
||||
- 不做:语义「同一类」、同日 Trending↔Hot 互斥、`FEATURED_FORCE`、关键短语硬匹配
|
||||
- Commit 信息正文用中文(若本任务含 Commit 步)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `daily/config.py` | 新 env:`board_dedup_days`、`board_pool_size`、`featured_dedup_days`、`theme_ban_days`、`narrative_axis_days`、`news_backfill_enabled` |
|
||||
| `daily/board_history.py` | **新建** — `load_recent_shown_keys` / `extract_shown_keys` / `attach_wecom_shown_keys`(读写 `data.wecom_shown_keys`) |
|
||||
| `daily/board_select.py` | **新建** — `board_select(...)` 周过滤+深池 |
|
||||
| `daily/delta.py` | `load_recent_board_keys` 改为委托 `load_recent_shown_keys`(保留函数名兼容);**不**改 `build_movement_baseline` |
|
||||
| `daily/format_wecom.py` | pad 使用 shown keys;可选返回最终展示 items 供写回 |
|
||||
| `daily/featured_pick.py` | `featured_identity_key`、`featured_resolve`、先定人再 research |
|
||||
| `daily/narrative_axis.py` | **新建** — `NARRATIVE_AXES`、`pick_narrative_axis`、`load_recent_axes` |
|
||||
| `daily/news/fetch.py` | `_apply_pushed_dedup_with_backfill` 尊重 `news_backfill_enabled()`;默认不塞回 |
|
||||
| `daily/news/research.py` + `skills/daily-ai-news-research/SKILL.md` | 禁放宽文案;补入不超时窗 |
|
||||
| `daily/text_utils.py` 或 `daily/news/sanitize.py` | `strip_news_relax_prefix(desc)` |
|
||||
| `daily/agent_workflow.py` | Step1 注入 axis + 近 7 日 theme 软禁;强制覆写冲突轴 |
|
||||
| `daily/generate.py` | 串联:select → featured → editorial → render → persist shown/key/axis |
|
||||
| `daily/report_data.py` | data.json 可携 `wecom_shown_keys` / `featured_pick_key` / `narrative_axis`(写回可由 generate 合并) |
|
||||
| `.env.example` | 文档化新变量 |
|
||||
| `skills/daily-agent/SKILL.md` | `narrative_axis` 必填且等于输入指定轴 |
|
||||
| `tests/test_board_select.py` | **新建** |
|
||||
| `tests/test_board_history.py` | **新建** |
|
||||
| `tests/test_featured_resolve.py` | **新建** |
|
||||
| `tests/test_narrative_axis.py` | **新建** |
|
||||
| `tests/test_news_relax.py` | **新建** |
|
||||
| `tests/test_wecom_delta.py` | 回归:pad 不读 movement 当展示史 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Config + shown-keys 历史层
|
||||
|
||||
**Files:**
|
||||
- Modify: `daily/config.py`(文件末尾追加)
|
||||
- Create: `daily/board_history.py`
|
||||
- Modify: `daily/delta.py`(`load_recent_board_keys` 改委托)
|
||||
- Test: `tests/test_board_history.py`
|
||||
- Modify: `.env.example`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `OUTPUT_DIR`、现有 `delta.skill_id` / repo key 约定
|
||||
- Produces:
|
||||
- `board_dedup_days() -> int`(默认 7)
|
||||
- `board_pool_size() -> int`(默认 `max(50, env WECOM_SKILL_POOL)`)
|
||||
- `featured_dedup_days() -> int`(默认 30)
|
||||
- `theme_ban_days() -> int`(默认 7)
|
||||
- `narrative_axis_days() -> int`(默认 3)
|
||||
- `news_backfill_enabled() -> bool`(默认 False;env `DAILY_NEWS_BACKFILL`)
|
||||
- `extract_shown_keys(board: str, items: list[dict]) -> list[str]`
|
||||
- `load_recent_shown_keys(date_str: str, *, lookback_days: int | None = None) -> dict[str, set[str]]`
|
||||
- `merge_wecom_shown_into_data(data: dict, shown: dict[str, list[str]]) -> dict`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_board_history.py
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from daily.board_history import extract_shown_keys, load_recent_shown_keys, merge_wecom_shown_into_data
|
||||
from daily.config import board_dedup_days, news_backfill_enabled
|
||||
|
||||
|
||||
class ConfigDiversityTests(unittest.TestCase):
|
||||
def test_board_dedup_days_default(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(board_dedup_days(), 7)
|
||||
|
||||
def test_news_backfill_default_off(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertFalse(news_backfill_enabled())
|
||||
|
||||
|
||||
class ShownKeysTests(unittest.TestCase):
|
||||
def test_extract_github_repo_keys(self):
|
||||
items = [{"repo": "a/b"}, {"repo": "c/d"}]
|
||||
self.assertEqual(extract_shown_keys("github_trending", items), ["a/b", "c/d"])
|
||||
|
||||
def test_load_recent_reads_wecom_shown_not_baseline(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp)
|
||||
# 前日:shown 只有 x/y;baseline raw 含 a/b —— 周去重只能看到 x/y
|
||||
payload = {
|
||||
"data": {
|
||||
"date": "2026-07-13",
|
||||
"movement_baseline": {
|
||||
"github_trending": [{"repo": "a/b"}, {"repo": "x/y"}],
|
||||
},
|
||||
"wecom_shown_keys": {"github_trending": ["x/y"]},
|
||||
}
|
||||
}
|
||||
(out / "2026-07-13.data.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
with patch("daily.board_history.OUTPUT_DIR", out):
|
||||
keys = load_recent_shown_keys("2026-07-14", lookback_days=7)
|
||||
self.assertEqual(keys["github_trending"], {"x/y"})
|
||||
self.assertNotIn("a/b", keys["github_trending"])
|
||||
|
||||
def test_merge_shown_does_not_touch_baseline(self):
|
||||
data = {
|
||||
"movement_baseline": {"github_trending": [{"repo": "raw/one"}]},
|
||||
}
|
||||
merged = merge_wecom_shown_into_data(
|
||||
data, {"github_trending": ["shown/one"]}
|
||||
)
|
||||
self.assertEqual(
|
||||
merged["movement_baseline"]["github_trending"][0]["repo"], "raw/one"
|
||||
)
|
||||
self.assertEqual(merged["wecom_shown_keys"]["github_trending"], ["shown/one"])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_board_history.py -v`
|
||||
Expected: FAIL(模块/函数不存在)
|
||||
|
||||
- [ ] **Step 3: Implement config + board_history + delta 委托**
|
||||
|
||||
在 `daily/config.py` 追加:
|
||||
|
||||
```python
|
||||
def board_dedup_days() -> int:
|
||||
return max(1, env_int("DAILY_BOARD_DEDUP_DAYS", 7))
|
||||
|
||||
def board_pool_size() -> int:
|
||||
fallback = env_int("DAILY_WECOM_SKILL_POOL", 50)
|
||||
return max(1, env_int("DAILY_BOARD_POOL_SIZE", max(50, fallback)))
|
||||
|
||||
def featured_dedup_days() -> int:
|
||||
return max(1, env_int("DAILY_FEATURED_DEDUP_DAYS", 30))
|
||||
|
||||
def theme_ban_days() -> int:
|
||||
return max(1, env_int("DAILY_THEME_BAN_DAYS", 7))
|
||||
|
||||
def narrative_axis_days() -> int:
|
||||
return max(1, env_int("DAILY_NARRATIVE_AXIS_DAYS", 3))
|
||||
|
||||
def news_backfill_enabled() -> bool:
|
||||
return env_bool("DAILY_NEWS_BACKFILL", False)
|
||||
```
|
||||
|
||||
新建 `daily/board_history.py`:实现 `BOARD_KEYS` 与 `delta.RECENT_BOARD_KEYS` 同五榜;Skills 用 `delta.skill_id`;GitHub 用 `repo`;`load_recent_shown_keys` **只**读各日 `data.wecom_shown_keys`,缺省空集,读写失败打 log 后当空集。
|
||||
|
||||
修改 `daily/delta.py` 的 `load_recent_board_keys`:改为
|
||||
|
||||
```python
|
||||
def load_recent_board_keys(date_str: str, *, lookback_days: int | None = None) -> dict[str, set[str]]:
|
||||
from daily.board_history import load_recent_shown_keys
|
||||
from daily.config import board_dedup_days
|
||||
days = lookback_days if lookback_days is not None else board_dedup_days()
|
||||
return load_recent_shown_keys(date_str, lookback_days=days)
|
||||
```
|
||||
|
||||
删除(或不再走)原「从 movement_baseline 抽 keys」逻辑,避免 pad 继续把 raw Top 当展示史。
|
||||
|
||||
`.env.example` 追加注释块:
|
||||
|
||||
```env
|
||||
# 多样性 / 去重(见 docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md)
|
||||
# DAILY_BOARD_DEDUP_DAYS=7
|
||||
# DAILY_BOARD_POOL_SIZE=50
|
||||
# DAILY_FEATURED_DEDUP_DAYS=30
|
||||
# DAILY_THEME_BAN_DAYS=7
|
||||
# DAILY_NARRATIVE_AXIS_DAYS=3
|
||||
DAILY_NEWS_BACKFILL=0
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `pytest tests/test_board_history.py tests/test_wecom_delta.py -v`
|
||||
Expected: `test_board_history` PASS;既有 delta 测试若依赖「baseline 即 recent」行为,按 Task 1 语义改断言为 shown_keys(本 Task 内修回归,勿留红)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add daily/config.py daily/board_history.py daily/delta.py .env.example tests/test_board_history.py tests/test_wecom_delta.py
|
||||
git commit -m "feat: 拆分 wecom_shown_keys 与 movement_baseline 历史层"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `board_select` 周去重 + 深池
|
||||
|
||||
**Files:**
|
||||
- Create: `daily/board_select.py`
|
||||
- Test: `tests/test_board_select.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `extract_shown_keys` / `skill_id`;`group_skills_by_source`(Skills 板)
|
||||
- Produces:
|
||||
- `board_select(*, board: str, items: list[dict], recent_keys: set[str], limit: int, pool_size: int, kind: Literal["skill","github"]) -> list[dict]`
|
||||
- 日志短榜:`board_short:{board}:{n}`(`logging.getLogger(__name__).info`)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_board_select.py
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from daily.board_select import board_select
|
||||
|
||||
|
||||
def _gh(repo: str) -> dict:
|
||||
return {"repo": repo, "description": repo}
|
||||
|
||||
|
||||
class BoardSelectTests(unittest.TestCase):
|
||||
def test_filters_recent_and_keeps_order(self):
|
||||
pool = [_gh(f"o/r{i}") for i in range(20)]
|
||||
recent = {"o/r0", "o/r1", "o/r2"}
|
||||
out = board_select(
|
||||
board="github_trending",
|
||||
items=pool,
|
||||
recent_keys=recent,
|
||||
limit=5,
|
||||
pool_size=20,
|
||||
kind="github",
|
||||
)
|
||||
keys = [x["repo"] for x in out]
|
||||
self.assertEqual(keys, ["o/r3", "o/r4", "o/r5", "o/r6", "o/r7"])
|
||||
|
||||
def test_deep_pool_fills_after_filter(self):
|
||||
pool = [_gh(f"o/r{i}") for i in range(8)]
|
||||
recent = {f"o/r{i}" for i in range(6)} # 前 6 全封
|
||||
out = board_select(
|
||||
board="github_emerging",
|
||||
items=pool,
|
||||
recent_keys=recent,
|
||||
limit=5,
|
||||
pool_size=8,
|
||||
kind="github",
|
||||
)
|
||||
self.assertEqual([x["repo"] for x in out], ["o/r6", "o/r7"]) # 短榜
|
||||
|
||||
def test_skill_uses_skill_id(self):
|
||||
items = [
|
||||
{"id": "a/b/s1", "source": "a/b", "title": "s1", "installs": 10},
|
||||
{"id": "c/d/s2", "source": "c/d", "title": "s2", "installs": 9},
|
||||
]
|
||||
out = board_select(
|
||||
board="skills_trending",
|
||||
items=items,
|
||||
recent_keys={"a/b/s1"},
|
||||
limit=10,
|
||||
pool_size=50,
|
||||
kind="skill",
|
||||
)
|
||||
self.assertEqual([x["id"] for x in out], ["c/d/s2"])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_board_select.py -v`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement `board_select`**
|
||||
|
||||
```python
|
||||
# daily/board_select.py — 核心逻辑示意
|
||||
def board_select(*, board, items, recent_keys, limit, pool_size, kind):
|
||||
if kind == "skill":
|
||||
from daily.skills_group import group_skills_by_source
|
||||
pool = group_skills_by_source(items, limit=pool_size, pool_size=pool_size)
|
||||
def key_fn(x): return skill_id(x)
|
||||
else:
|
||||
pool = items[: max(pool_size, limit)]
|
||||
def key_fn(x): return str(x.get("repo") or "")
|
||||
out = []
|
||||
for item in pool:
|
||||
k = key_fn(item)
|
||||
if not k or k in recent_keys:
|
||||
continue
|
||||
out.append(item)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
if len(out) < limit:
|
||||
logger.info("board_short:%s:%s", board, len(out))
|
||||
return out
|
||||
```
|
||||
|
||||
Skills:输入可为未 group 的 raw;函数内 group。GitHub:输入为 repo 列表。
|
||||
|
||||
- [ ] **Step 4: Run tests — expect PASS**
|
||||
|
||||
Run: `pytest tests/test_board_select.py -v`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add daily/board_select.py tests/test_board_select.py
|
||||
git commit -m "feat: 实现 board_select 周去重与深池补满"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 接入 generate / format_wecom(唯一列表主人 + post-render 写回)
|
||||
|
||||
**Files:**
|
||||
- Modify: `daily/generate.py`(选榜、传入 pad、渲染后 merge shown)
|
||||
- Modify: `daily/format_wecom.py`(delta pad 已通过改写后的 `load_recent_board_keys` 读 shown;确保传入的 `*_pad` 池已是 `board_select` 深池结果)
|
||||
- Modify: `daily/report_data.py`(可选:llm_input 切片改为 board_select 后列表,避免 Agent 看见未去重 Top)
|
||||
- Test: `tests/test_board_history.py` 增补「shown ≠ baseline 推导」集成断言;`tests/test_wecom_delta.py` pad 用例
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task1–2
|
||||
- Produces: 每次成功 generate 后 `output/{date}.data.json` 含 `data.wecom_shown_keys`
|
||||
|
||||
- [ ] **Step 1: Write / extend failing integration test**
|
||||
|
||||
```python
|
||||
def test_persist_shown_keys_differs_from_baseline_keys(self):
|
||||
# 构造:raw trending 前 3 名本周已 shown;board_select 选出 3..;
|
||||
# movement_baseline 仍含 0..compare_depth
|
||||
# 断言 data["wecom_shown_keys"]["github_trending"] 与 baseline repos 集合不等
|
||||
...
|
||||
```
|
||||
|
||||
(可用临时 `OUTPUT_DIR` + 调用抽取出的 `persist` 辅助,或测 `merge_wecom_shown_into_data` + `board_select` 组合。)
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL(generate 尚未写 shown)**
|
||||
|
||||
- [ ] **Step 3: Wire generate**
|
||||
|
||||
在 `generate_report` 中,在组装 wecom 榜之前:
|
||||
|
||||
1. `recent = load_recent_shown_keys(date_str)`
|
||||
2. 对五榜分别 `board_select(...)` 得到 `selected_*`(limit=wecom_*,pool=`board_pool_size()`)
|
||||
3. full:渲染用 `selected_*`
|
||||
4. delta:`trending_pad`/`github_*_pad` = 同规则更大 pool 的 select 结果(或 raw 深池再 select);`replace_wecom_board_sections(..., pad=True)` 内部 recent 已是 shown
|
||||
5. 渲染后根据**最终写入正文的 items**(full=selected;delta=函数返回或并行计算最终列表)调用 `extract_shown_keys`,`merge_wecom_shown_into_data`,写回 data.json(在现有 `save_json` 路径合并字段)
|
||||
|
||||
注意:`movement_baseline` 仍用 **raw** compare 切片构建(`report_data.build_llm_input` 现逻辑保留)。
|
||||
|
||||
若 `build_llm_input` 当前把未过滤 Top 塞进 Agent:改为传入 `selected_*`(或另字段 `boards_for_wecom`),避免 opening 引用已周封杀的榜首。
|
||||
|
||||
辅助:在 `format_wecom` 增加 `resolve_wecom_board_items(...)` 返回最终 items dict,供写回与 featured 池 A 共用,避免正文与 history 分叉。
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `pytest tests/test_board_select.py tests/test_board_history.py tests/test_wecom_delta.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add daily/generate.py daily/format_wecom.py daily/report_data.py tests/
|
||||
git commit -m "feat: generate 以 board_select 为唯一列表主人并写回 shown keys"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `featured_resolve`(先定人再 research)
|
||||
|
||||
**Files:**
|
||||
- Modify: `daily/featured_pick.py`
|
||||
- Modify: `daily/generate.py`(调用顺序)
|
||||
- Test: `tests/test_featured_resolve.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 最终展示 items(池 A)、raw 深池(池 B)、近 30 日 `featured_pick_key`
|
||||
- Produces:
|
||||
- `featured_identity_key(featured: dict) -> str`(`type==skill` → id;github → repo;兜底 url path)
|
||||
- `load_recent_featured_keys(date_str, days) -> set[str]`
|
||||
- `featured_resolve(*, date_str, candidate: dict | None, pool_a: list[dict], pool_b: list[dict], rng: random.Random | None) -> tuple[dict | None, str | None]`
|
||||
返回 `(resolved_seed_or_featured_stub, identity_key)`;**不含**完整 why(why 由后续 research 写)
|
||||
- 改 `apply_featured_pick`:先 resolve 身份(若与昨日冲突则换候选写入 query),再 `research_featured_pick`
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
```python
|
||||
# tests/test_featured_resolve.py
|
||||
def test_same_as_yesterday_picks_from_pool_a(self):
|
||||
yesterday_key = "headroomlabs-ai/headroom"
|
||||
pool_a = [
|
||||
{"repo": "headroomlabs-ai/headroom", "board": "github_topic"},
|
||||
{"repo": "ollama/ollama", "board": "github_trending"},
|
||||
]
|
||||
rng = random.Random(0)
|
||||
resolved, key = featured_resolve(
|
||||
date_str="2026-07-14",
|
||||
candidate={"type": "github", "url": "https://github.com/headroomlabs-ai/headroom", "title": "headroom"},
|
||||
pool_a=pool_a,
|
||||
pool_b=[],
|
||||
recent_featured={yesterday_key},
|
||||
yesterday_key=yesterday_key,
|
||||
rng=rng,
|
||||
)
|
||||
self.assertNotEqual(key, yesterday_key)
|
||||
self.assertEqual(key, "ollama/ollama")
|
||||
|
||||
def test_pool_a_before_pool_b(self):
|
||||
...
|
||||
|
||||
def test_exhausted_keeps_original(self):
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
`featured_resolve`:若无 candidate 或与 `yesterday_key` 不同 → 原样返回。
|
||||
冲突时:过滤 `recent_featured | {yesterday_key}`,先从 pool_a 建可选项(每项抽 identity),`rng.choice`;空则 pool_b;仍空 log `featured_fallback_exhausted` 并保留原 candidate。
|
||||
|
||||
`apply_featured_pick` / generate 流程:
|
||||
|
||||
1. 解析 env 得到初始 query/candidate
|
||||
2. `featured_resolve`(此时池 A 已是 board_select 结果)
|
||||
3. 若换人:用新 repo/skill 构造 config,再 `research_featured_pick`
|
||||
4. 写入 `llm_input["featured_pick"]` 与之后 data.`featured_pick_key`
|
||||
|
||||
随机默认:`random.Random(int(hashlib.sha256(f"{date_str}:featured".encode()).hexdigest()[:16], 16))`
|
||||
|
||||
- [ ] **Step 4: pytest PASS**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add daily/featured_pick.py daily/generate.py tests/test_featured_resolve.py
|
||||
git commit -m "feat: 首推与昨日冲突时改推并保证一月不重复"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `narrative_axis` 硬互斥 + Step1 软禁 theme
|
||||
|
||||
**Files:**
|
||||
- Create: `daily/narrative_axis.py`
|
||||
- Modify: `daily/agent_workflow.py`(`analyze_trends`)
|
||||
- Modify: `skills/daily-agent/SKILL.md`
|
||||
- Modify: `daily/generate.py`(落盘 `narrative_axis`;注入 llm_input)
|
||||
- Test: `tests/test_narrative_axis.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `NARRATIVE_AXES: tuple[str, ...] = ("政策监管", "模型发布", "工具链/Agent", "芯片算力", "开源生态", "应用落地", "安全/诉讼")`
|
||||
- `pick_narrative_axis(used: set[str], *, rng: random.Random | None = None) -> str`
|
||||
- `load_recent_axes(date_str, days) -> list[str]`(近 N 日 data.`narrative_axis`)
|
||||
- `enforce_narrative_axis(trends: dict, axis: str) -> dict`(强制 trends["narrative_axis"]=axis)
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
```python
|
||||
def test_pick_excludes_used(self):
|
||||
used = {"政策监管", "模型发布", "工具链/Agent"}
|
||||
for _ in range(20):
|
||||
axis = pick_narrative_axis(used, rng=random.Random(1))
|
||||
self.assertNotIn(axis, used)
|
||||
|
||||
def test_enforce_overwrites_llm(self):
|
||||
trends = {"narrative_axis": "开源生态", "opening": "..."}
|
||||
out = enforce_narrative_axis(trends, "芯片算力")
|
||||
self.assertEqual(out["narrative_axis"], "芯片算力")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: FAIL → Step 3 implement**
|
||||
|
||||
`analyze_trends`:计算 `axis = pick_narrative_axis(set(load_recent_axes(...)))`;把 `required_narrative_axis` 与近 `theme_ban_days` 的 theme/opening 摘要列表注入 system prompt;要求 JSON 含 `narrative_axis` 且必须等于 required。解析后 `enforce_narrative_axis`。
|
||||
generate 将 axis 写入 data.json。
|
||||
|
||||
SKILL.md Step1 schema 增加 `narrative_axis` 字段说明。
|
||||
|
||||
- [ ] **Step 4–5: pytest + commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat: 代码选定叙事轴并注入 Agent 开场约束"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 取消新闻「放宽窗口」
|
||||
|
||||
**Files:**
|
||||
- Modify: `daily/news/fetch.py`(`_apply_pushed_dedup_with_backfill`)
|
||||
- Modify: `daily/news/research.py`(确认只 filter_unpushed;不足不拉超窗)
|
||||
- Create: `daily/news/sanitize.py`(或放入 `text_utils`)— `strip_relax_window_prefix(text: str) -> str`
|
||||
- Modify: `skills/daily-ai-news-research/SKILL.md`(删除「放宽至 48h 并注明」;改为不足则少返回、禁止标注)
|
||||
- Modify: `daily/generate.py` / news finalize(对 desc_short 剥前缀)
|
||||
- Test: `tests/test_news_relax.py`;扩展 `tests/test_news_fetch_window.py`
|
||||
|
||||
**Interfaces:**
|
||||
- `news_backfill_enabled() == False` 时:`_apply_pushed_dedup_with_backfill` 等价于只返回 `filter_unpushed_items(...)[:limit]`,**不**再从 `picked` 塞回
|
||||
- `strip_relax_window_prefix`:去掉开头的 `放宽窗口[::]?` / `放宽至[^::]*[::]`
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
```python
|
||||
def test_backfill_disabled_does_not_reinsert_pushed(self):
|
||||
# fresh 不足 limit;picked 含已推;BACKFILL=0 → 结果不含已推 link
|
||||
...
|
||||
|
||||
def test_strip_relax_prefix(self):
|
||||
self.assertEqual(
|
||||
strip_relax_window_prefix("放宽窗口:苹果起诉 OpenAI"),
|
||||
"苹果起诉 OpenAI",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2–4: 实现并跑 `pytest tests/test_news_relax.py tests/test_news_fetch_window.py -v`**
|
||||
|
||||
Research 路径:SKUILL 改完后,代码侧对 items 统一 `strip`;不足时 log `news_short:{n}`,接受短列表。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "fix: 关闭新闻放宽凑数并剥离放宽窗口文案"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 端到端回归与文档对齐
|
||||
|
||||
**Files:**
|
||||
- Modify: 如有遗漏的 `.env.example` / SKILL
|
||||
- Test: 全量相关测试
|
||||
|
||||
- [ ] **Step 1: 跑全套**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pytest tests/test_board_history.py tests/test_board_select.py tests/test_featured_resolve.py tests/test_narrative_axis.py tests/test_news_relax.py tests/test_news_fetch_window.py tests/test_wecom_delta.py tests/test_featured_pick.py -v
|
||||
```
|
||||
|
||||
Expected: 全部 PASS
|
||||
|
||||
- [ ] **Step 2: Spec 对照清单(人工)**
|
||||
|
||||
| Spec 要求 | 任务 |
|
||||
|-----------|------|
|
||||
| wecom_shown_keys ≠ movement_baseline | T1, T3 |
|
||||
| board_select 唯一主人 + delta pad 共用 shown | T2, T3 |
|
||||
| 首推月去重 A→B、先定人再 why | T4 |
|
||||
| narrative_axis 硬保证 | T5 |
|
||||
| 禁放宽 backfill + 剥前缀 + hours 窗 | T6 |
|
||||
| 成功标准可测 | 各测覆盖 |
|
||||
|
||||
- [ ] **Step 3: Commit(若有收尾文档)**
|
||||
|
||||
```bash
|
||||
git add -u
|
||||
git commit -m "test: 多样性去重全链路回归通过"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec Coverage Self-Review
|
||||
|
||||
| Spec 节 | 计划任务 |
|
||||
|---------|----------|
|
||||
| 1.1 双基准分离 | T1 |
|
||||
| 1.2 唯一列表主人 | T2–T3 |
|
||||
| 1.3 真相表 | T1, T3–T5 落盘字段 |
|
||||
| 2.1 board_select | T2 |
|
||||
| 2.2 featured_resolve | T4 |
|
||||
| 3.1 narrative_axis + 软 theme | T5 |
|
||||
| 3.2 取消放宽 | T6 |
|
||||
| 4.x 配置/降级/测试 | T1–T7 |
|
||||
|
||||
无 TBD;Commit 信息均为中文描述体。
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md`.
|
||||
|
||||
**两种执行方式:**
|
||||
|
||||
1. **Subagent-Driven(推荐)** — 每任务新开子代理,任务间审查
|
||||
2. **Inline Execution** — 本会话按 `executing-plans` 连续做完,设检查点
|
||||
|
||||
要哪个?
|
||||
@@ -0,0 +1,271 @@
|
||||
# Design: 企微早报多样性与去重
|
||||
|
||||
Generated: 2026-07-14
|
||||
Repo: daily-robots
|
||||
Status: APPROVED
|
||||
Mode: Builder
|
||||
Related: `docs/design-wecom-delta-mode.md`(Delta 列表模式)
|
||||
Revision: office-hours A —— 拆分展示历史、单一列表主人、历史真相表(2026-07-14)
|
||||
|
||||
## Problem Statement
|
||||
|
||||
近两日企微早报(如 2026-07-13 / 07-14)骨架相同,且:
|
||||
|
||||
- **今日首推**连续两天同为 `headroom`
|
||||
- **开场主题**同属「上下文压缩 + 视频/Skills」腔调
|
||||
- **AI 时讯**出现「放宽窗口」旧闻凑数标注
|
||||
- **Skills / GitHub 各榜**(尤其新兴榜)周内大量重复展示
|
||||
|
||||
读者需要「今天新信息」,而不是换日期的复印机。
|
||||
|
||||
## Decisions(已确认)
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|--------|------|
|
||||
| 实现路径 | **A:管线选择器**(代码硬保证去重;LLM 只写开场/理由/摘要;中文化仍走现有 `localize`) |
|
||||
| Skills「同一类」 | 暂不管;沿用现有 `group_skills_by_source`(**已知残留**:同 source 换 skill id 仍可能周内再出现) |
|
||||
| 周去重后不足 Top N | **深池补满**,仍保证周内未出现;池空则短榜,不破周约束 |
|
||||
| 首推改推候选 | **先展示榜(池 A),再 raw 深池(池 B)**;一月内首推不重复 |
|
||||
| 开场主题 | **近 7 天禁主题/句式(软)+ 叙事轴与近 3 天不同(硬,代码选轴)** |
|
||||
| 取消「放宽窗口」 | **禁止旧闻/已推凑数**;不够则深度检索补新闻;禁止任何「放宽」文案标注;仍不够则短列表 |
|
||||
| 展示历史 vs 异动基准 | **必须拆开**:`movement_baseline` ≠ `wecom_shown_keys` |
|
||||
| full/delta 列表主人 | **唯一主人** = `board_select`(含 full 与 delta 的 moves∪pad);禁止二次独立选榜 |
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| 语义级 Skill「同一类」分类 | ❌ 本期不做 |
|
||||
| 同日 Skills Trending ↔ Hot 互斥 | ❌ 本期不做 |
|
||||
| 独立 editorial 微服务 | ❌ 不做 |
|
||||
| 改写 `localize` 为脚本机翻 | ❌ 保持 LLM + 缓存 |
|
||||
| 编辑指定首推豁免改推(`FEATURED_FORCE`) | ❌ 本期不做 |
|
||||
| 「开场关键短语」硬匹配算法 | ❌ 本期不做(仅 prompt 软约束;不进硬成功标准) |
|
||||
|
||||
## Recommended Approach: 管线选择器(路径 A)
|
||||
|
||||
在现有 `daily generate` 内增加选条/裁决层,不新起进程:
|
||||
|
||||
```
|
||||
采集 raw 榜 + 新闻
|
||||
→ build movement_baseline(raw Top compare_depth —— 仅供次日「新入榜」,禁止写展示历史)
|
||||
→ board_select(读 wecom_shown_keys 周历史;周去重 + 深池;输出当日最终展示列表)
|
||||
· full:直接取 board_select 结果前 N
|
||||
· delta:在 board_select 候选池内做 moves∪pad(pad 也只从该池/同规则深池取,不再另起一套历史)
|
||||
→ featured_resolve(与昨日首推相同则改推;池 A = 本 run 最终展示 keys)
|
||||
→ research why(先定人,再写 why_today)
|
||||
→ news_select(禁放宽凑数;深检索补满;剥「放宽」前缀)
|
||||
→ editorial(代码选 narrative_axis;prompt 附近 7 天 theme 软禁)
|
||||
→ 渲染 wecom
|
||||
→ 写回 wecom_shown_keys = 最终进入企微正文的榜条目 keys(post-render)
|
||||
→ 其余 history(首推月、axis)写入 data.json 约定字段
|
||||
```
|
||||
|
||||
**LLM 负责**:`opening` / `theme_line`、首推 `why_today`、新闻与榜单项中文摘要(`localize`)。
|
||||
**代码负责**:谁上榜、首推换谁、周/月去重、`narrative_axis` 选取、是否允许旧闻。
|
||||
|
||||
### Approaches Considered
|
||||
|
||||
| | A 管线选择器(采用) | B 偏 LLM 约束 | C 独立 editorial 服务 |
|
||||
|--|--|--|--|
|
||||
| 优点 | 可测;与 pushed-links 模式一致 | 改 prompt 快 | 边界清晰 |
|
||||
| 缺点 | 需动 generate / featured / news / format | 易漏、难测 | 过重 |
|
||||
|
||||
---
|
||||
|
||||
## Section 1 — 总览、列表主人、历史真相表
|
||||
|
||||
### 1.1 两种「基准」禁止混用
|
||||
|
||||
| 字段 | 含义 | 写入时机 | 读者 |
|
||||
|------|------|----------|------|
|
||||
| `movement_baseline` | **Raw** 各榜 Top `compare_depth`(现网语义不变) | `build_llm_input` / 采集后尽早 | `build_movement_context`(新入榜) |
|
||||
| `wecom_shown_keys` | **读者实际见到**的各榜 key 集合(及可选 rank) | **wecom 渲染完成之后** | `board_select` 周去重;delta pad;测试 |
|
||||
|
||||
**禁止**:把 `wecom_shown_keys` 写入或覆写 `movement_baseline`。
|
||||
**禁止**:让 `load_recent_board_keys` 继续读 `movement_baseline` 充当「已展示」——应改为读近 N 日 `wecom_shown_keys`(可保留函数名,换数据源;或新建 `load_recent_shown_keys`)。
|
||||
|
||||
### 1.2 唯一列表主人
|
||||
|
||||
`board_select`(模块可挂在 `daily/board_select.py` 或扩 `delta.py`)是各榜**最终展示行**的唯一生产者:
|
||||
|
||||
| 模式 | 行为 |
|
||||
|------|------|
|
||||
| `full` | `board_select(raw, shown_history) →` 至多 N 条,直接渲染 |
|
||||
| `delta` | 先算相对 `movement_baseline` 的 moves;展示 = `moves`(已在候选内)∪ `pad`;**pad 候选必须来自同一周去重池**(与 full 同一套 `board_select` 规则),不得再读 raw baseline 当「已展示」 |
|
||||
|
||||
交互影响(非「完全正交」):周去重会减少可展示重复项 → delta 日可能更短、silent/gate 行为可能变化。`DAILY_WECOM_MODE` 枚举语义不变,但列表密度会变。
|
||||
|
||||
### 1.3 历史真相表(单一来源)
|
||||
|
||||
全部落在 `output/{date}.data.json`(新闻 pushed-links 例外,沿用现网 cache)。
|
||||
|
||||
| 字段路径 | 窗口 | Key 规则 | 写者 | 读者 |
|
||||
|----------|------|----------|------|------|
|
||||
| `data.movement_baseline` | 次日对比用 | raw 条目切片 | `build_movement_baseline` | movement |
|
||||
| `data.wecom_shown_keys.{board}` | 滚动 7 天(读近 7 日文件) | Skills:与现网 `_skill_keys_in_board_item` / `skill_id` 一致;GitHub:`owner/repo` | post-render persist | `board_select` / pad |
|
||||
| `data.featured_pick_key` | 滚动 30 天 | skill id 或 `owner/repo` | `featured_resolve` 成功后 | 月去重 |
|
||||
| `data.narrative_axis` | 滚动 3 天 | 枚举字符串 | 代码 `pick_narrative_axis` | Step 1 约束 / 校验 |
|
||||
| `data.theme_line` / trends opening | 近 7 日供 prompt | 原文 | editorial 落盘 | Step 1 软禁(不硬匹配) |
|
||||
| `CACHE_DIR/pushed-news-links.json` | `DAILY_NEWS_DEDUP_DAYS` | 规范化 URL | 推送成功后 | news filter |
|
||||
|
||||
不另建平行 CACHE「board-history.json」,避免双源漂移。冷启动:缺文件 = 空集合。
|
||||
|
||||
### 1.4 数据流挂点
|
||||
|
||||
| 逻辑 | 挂点 |
|
||||
|------|------|
|
||||
| `movement_baseline` | 现网:raw 榜入库时(不变) |
|
||||
| `board_select` | 渲染前;输出写入供 Agent/`llm_input` 与 wecom 共用的最终列表字段 |
|
||||
| delta pad | **调用同一周去重历史**(`wecom_shown_keys`),不再独立解释 `movement_baseline` 为展示史 |
|
||||
| `featured_resolve` | **先于** why 检索;池 A = 本 run `board_select`(delta 则为本 run 最终展示列表) |
|
||||
| 新闻 | 所有 prepare 路径关 backfill;research SKILL 改文案规则;后处理剥「放宽」 |
|
||||
| `narrative_axis` | 代码先选轴再注入 Step 1;LLM 不得另选冲突轴 |
|
||||
| `wecom_shown_keys` 写回 | `replace_wecom_*` / `build_wecom_report` 之后,与最终正文列表一致 |
|
||||
|
||||
---
|
||||
|
||||
## Section 2 — 各榜选条 + 今日首推改推
|
||||
|
||||
### 2.1 `board_select`(五榜共用)
|
||||
|
||||
适用:`skills_trending` / `skills_hot` / `github_trending` / `github_emerging` / `github_topic`。
|
||||
|
||||
```
|
||||
输入:当日 raw 池(深,pool ≥ DAILY_BOARD_POOL_SIZE)
|
||||
历史:近 DAILY_BOARD_DEDUP_DAYS 的 wecom_shown_keys[board]
|
||||
输出:至多 N 条(N = 现有 wecom Top 配置)
|
||||
|
||||
1. 现有整理(Skills:source 合并;GitHub:repo key)
|
||||
2. 滤掉近 7 天该榜 wecom_shown_keys
|
||||
3. 按原排名取前 N
|
||||
4. 不足 → 继续扫深池,仍排除周历史,直到满 N 或池空
|
||||
5. 池空仍不足 → 短榜;日志 board_short:{board}:{n};不回填周内已展示条目
|
||||
```
|
||||
|
||||
分榜独立历史:Trending 出过的 skill,Hot 仍可出。
|
||||
|
||||
Post-render:将**实际写入企微的** keys 写入当日 `wecom_shown_keys`(测试断言:history ⊆ / == 渲染列表,**≠** `movement_baseline`)。
|
||||
|
||||
### 2.2 `featured_resolve`
|
||||
|
||||
**触发**:本 run 拟用首推身份与**前一天** `featured_pick_key`(或等价 data 字段)相同。
|
||||
身份函数:skill → `skill_id`;github → `owner/repo`。
|
||||
含 `DAILY_FEATURED_PICK` 与自动首推;本期不豁免。无昨日文件 → 不改推。
|
||||
|
||||
**顺序(硬)**:定候选 → 再 `research`/`why_today`(禁止先写旧条目 why 再改人却不重写)。
|
||||
|
||||
**候选**:
|
||||
|
||||
1. **池 A**:本 run **最终会展示**的 Skills + GitHub 榜条目(与 `wecom_shown_keys` 同源结构)
|
||||
2. **池 B**:raw 深池中尚未进入本 run 展示者
|
||||
|
||||
**过滤**:近 30 天 `featured_pick_key`;排除冲突项自身。
|
||||
|
||||
**抽取**:`hash(date_str + "featured")` 可复现;测试可注入 RNG。先 A 后 B;仍空 → 保留原首推 + `featured_fallback_exhausted`。
|
||||
|
||||
**落盘**:`data.featured_pick_key`。
|
||||
|
||||
---
|
||||
|
||||
## Section 3 — 开场主题 + AI 时讯
|
||||
|
||||
### 3.1 开场主题
|
||||
|
||||
| 机制 | 强度 | 规则 |
|
||||
|------|------|------|
|
||||
| `narrative_axis` | **硬** | 代码 `pick_narrative_axis(used_last_N)` 从剩余枚举选取;注入 prompt;LLM 输出须等于该轴;冲突则重试 1 次,再失败则**强制覆写为代码所选轴**再落盘(保证成功标准可测) |
|
||||
| theme/opening 软禁 | **软** | prompt 附近 7 天 `theme_line`/opening 摘要;禁止复述;**无** n-gram 硬匹配;**不**列入硬成功标准 |
|
||||
|
||||
**叙事轴枚举**:
|
||||
|
||||
`政策监管` · `模型发布` · `工具链/Agent` · `芯片算力` · `开源生态` · `应用落地` · `安全/诉讼`
|
||||
|
||||
`opening` 首句证据须来自当日数据;首推改推后须跟新首推或当日主轴新闻。
|
||||
|
||||
### 3.2 AI 时讯:取消「放宽窗口」
|
||||
|
||||
目标条数 = 现网配置之和(如 `DAILY_WECOM_AI_NEWS` + tech/CN 等),文档不写死「15」。
|
||||
|
||||
1. **所有 prepare 路径**关闭「不够塞回已推/旧条」(`DAILY_NEWS_BACKFILL=0` 默认);`pushed-news-links` 过滤保留。
|
||||
2. 不够 → 深度检索补新闻(须:https link、未 pushed、可核实);**补入年龄上限** = `DAILY_AI_NEWS_HOURS`(与主窗一致),禁止借 research 变相放宽到任意旧闻。
|
||||
3. 改 research SKILL:删除「放宽至 48h 并注明」;后处理剥 `放宽窗口`/`放宽至` 前缀或丢弃。
|
||||
4. 仍不足 → 短列表 + `news_short:{n}`。
|
||||
|
||||
中文化:`daily/localize.py`(不变)。
|
||||
|
||||
---
|
||||
|
||||
## Section 4 — 配置、错误处理、测试
|
||||
|
||||
### 4.1 环境变量
|
||||
|
||||
| 变量 | 默认 | 含义 |
|
||||
|------|------|------|
|
||||
| `DAILY_BOARD_DEDUP_DAYS` | `7` | 读 `wecom_shown_keys` 的滚动天数 |
|
||||
| `DAILY_BOARD_POOL_SIZE` | ≥50 / 与现有 skill pool 对齐 | 深池扫描深度 |
|
||||
| `DAILY_FEATURED_DEDUP_DAYS` | `30` | 今日首推月去重 |
|
||||
| `DAILY_THEME_BAN_DAYS` | `7` | 软禁:注入 prompt 的 theme 天数 |
|
||||
| `DAILY_NARRATIVE_AXIS_DAYS` | `3` | 叙事轴互斥窗 |
|
||||
| `DAILY_NEWS_BACKFILL` | `0` | `0`=禁止旧闻凑数 |
|
||||
| `DAILY_NEWS_DEDUP_DAYS` | 已有 `7` | pushed-links |
|
||||
|
||||
`DAILY_DELTA_PAD_LOOKBACK_DAYS` 应与 `DAILY_BOARD_DEDUP_DAYS` 对齐,且 **pad 与 board_select 共用 `wecom_shown_keys`**(窗口对齐不够,数据源必须同一)。
|
||||
|
||||
### 4.2 错误与降级
|
||||
|
||||
| 情况 | 行为 |
|
||||
|------|------|
|
||||
| 无 `wecom_shown_keys` 历史 | 空集合,正常满榜 |
|
||||
| 周去重后深池不足 | 短榜 + `board_short` |
|
||||
| 首推冲突且 A/B 空 | 保留原首推 + `featured_fallback_exhausted` |
|
||||
| LLM 轴与代码轴冲突 | 覆写为代码轴 |
|
||||
| 深检索仍不足时讯 | 短列表;禁止 backfill |
|
||||
| history 读写失败 | 当次按空历史 + error 日志 |
|
||||
|
||||
### 4.3 测试(pytest)
|
||||
|
||||
1. `board_select`:假 `wecom_shown_keys` + 深池 → 无周交集;深池补满;不足短榜
|
||||
2. **回归钉死**:写回后 `wecom_shown_keys` ≠ 用 `movement_baseline` 推导的集合(构造 raw Top 与展示 Top 故意不同)
|
||||
3. delta:pad 不引入近 7 日 `wecom_shown_keys` 内 key
|
||||
4. `featured_resolve`:先定人再 why;A 优先 B;月未见;可注入 RNG
|
||||
5. news:`BACKFILL=0`;剥「放宽*」;research 补入不超 hours 窗
|
||||
6. `pick_narrative_axis`:近 3 天互斥;落盘轴 == 代码轴
|
||||
7. 既有 delta / pushed_links / wecom 回归不挂
|
||||
|
||||
### 4.4 成功标准(硬)
|
||||
|
||||
- 连续两天:**首推 key 不同**(除非 `featured_fallback_exhausted`)
|
||||
- 同一榜近 7 日 `wecom_shown_keys`:**无重复 key**(池足够时)
|
||||
- 时讯:无「放宽*」标注;无 backfill 已推 link
|
||||
- 近 3 天 `narrative_axis`:**两两不同**(代码保证)
|
||||
|
||||
软标准(不闸门):opening 读感不像连续复印。
|
||||
|
||||
---
|
||||
|
||||
## Implementation Sketch(非计划明细)
|
||||
|
||||
1. `data.json` 增加 `wecom_shown_keys`;改 `load_recent_*` 数据源
|
||||
2. `board_select` + 让 delta pad 共用
|
||||
3. post-render persist shown keys
|
||||
4. `featured_resolve` 时序修正
|
||||
5. news backfill off + SKILL + 剥前缀
|
||||
6. `pick_narrative_axis` + prompt 注入
|
||||
7. 测试如上
|
||||
|
||||
正式任务拆解 → `writing-plans`。
|
||||
|
||||
## Office-hours Review Notes
|
||||
|
||||
- 对抗审阅质量约 4/10 → 本修订处理三大硬伤(存储拆分、列表主人、真相表)。
|
||||
- 未纳入本期(原选项 B):首推质量加权、关键短语硬匹配。
|
||||
- 已知残留:source 级「同类」周内可再现。
|
||||
|
||||
## Spec Self-Review
|
||||
|
||||
- [x] `movement_baseline` 与 `wecom_shown_keys` 职责分离写死
|
||||
- [x] 单一列表主人 + full/delta 交互说明
|
||||
- [x] 历史真相表无「与/或」双源
|
||||
- [x] 轴硬 / 短语软;成功标准不含无法验证的短语匹配
|
||||
- [x] 周不足=深池、首推=A→B、新闻禁放宽 与访谈一致
|
||||
43
run-scheduler.ps1
Normal file
43
run-scheduler.ps1
Normal file
@@ -0,0 +1,43 @@
|
||||
# Start the daily report scheduler (generate @ 08:50, push @ 09:00 by default)
|
||||
# Usage:
|
||||
# .\run-scheduler.ps1
|
||||
# .\run-scheduler.ps1 -DryRun
|
||||
# .\run-scheduler.ps1 -Once
|
||||
|
||||
param(
|
||||
[switch]$DryRun,
|
||||
[switch]$Once
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $Root
|
||||
|
||||
function Import-DotEnvFile {
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path $Path)) { return }
|
||||
Get-Content $Path -Encoding UTF8 | ForEach-Object {
|
||||
if ($_ -match '^\s*#' -or $_ -notmatch '=') { return }
|
||||
$pair = $_ -split '=', 2
|
||||
if ($pair.Count -eq 2) {
|
||||
$name = $pair[0].Trim()
|
||||
$value = $pair[1].Trim().Trim('"').Trim("'")
|
||||
if ($name -and $value) {
|
||||
Set-Item -Path "Env:$name" -Value $value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Import-DotEnvFile (Join-Path $Root ".env")
|
||||
Import-DotEnvFile (Join-Path $Root ".env.local")
|
||||
|
||||
$args = @("python", "-m", "daily", "schedule")
|
||||
if ($DryRun) { $args += "--dry-run" }
|
||||
if ($Once) { $args += "--once" }
|
||||
|
||||
Write-Host "Starting scheduler: $($args -join ' ')"
|
||||
& $args[0] $args[1..($args.Length - 1)]
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "scheduler failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
# 早报 Agent 工作流
|
||||
|
||||
你是 **Skills / GitHub / AI 时讯早报** 的主编 Agent。Python 已完成数据抓取;你分 **两步** 产出可读性强的企微早报。
|
||||
你是 **Skills / GitHub / AI 时讯早报** 的主编 Agent。Python 已完成数据抓取;若配置了 `DAILY_FEATURED_PICK`,Python 会先完成 **Step 0 首推检索** 并注入 `featured_pick`。你再分 **两步** 产出可读性强的企微早报。
|
||||
|
||||
## 工作流
|
||||
|
||||
```
|
||||
Step 0 首推检索(Python + daily-featured-pick,输出 featured_pick)
|
||||
Step 1 读话题 → 识别热门趋势(输出 trends JSON)
|
||||
Step 2 基于趋势 + 原始数据 → 写企微 Markdown 早报
|
||||
```
|
||||
@@ -27,21 +28,31 @@ Step 2 基于趋势 + 原始数据 → 写企微 Markdown 早报
|
||||
| `github_emerging` | GitHub **新兴** 当前 Top N |
|
||||
| `github_topic.repos` | GitHub **Topic** 当前 Top N |
|
||||
| `github_topic.topic` | Topic 名称,用于区块标题(如 `llm`) |
|
||||
| `movement.*_moves` | 较昨日新增(**仅**用于 opening / signals,**不**用于列表区块) |
|
||||
| `movement.*_moves` | 较昨日新增(**仅**用于 opening / signals;`effective_wecom_mode=delta` 时列表由 Python 插入) |
|
||||
| `movement.*_summary` | 新增摘要(可选写入 signals) |
|
||||
| `ai_news` | 国际 AI 时讯 Top N |
|
||||
| `cn_ai_news` | 国内 AI 时讯 Top N |
|
||||
| `ai_news` | 国际 AI 时讯 Top N(RSS 模式) |
|
||||
| `cn_ai_news` | 国内 AI 时讯 Top N(RSS 模式) |
|
||||
| `ai_news_mode` | `research` 时仅 `ai_news` 有 10 条合并精选,`cn_ai_news` 为空 |
|
||||
| `featured_pick` | **可选**,编辑指定今日首推(Step 0 产出;含 command / why_today / evidence) |
|
||||
|
||||
**禁止**使用已合并的 `skills_moves` / `github_moves` 自行扩写;**禁止**排名变化、安装涨跌。
|
||||
|
||||
Step 1 的 `signals` 与 `top_picks` **优先引用 Top 榜榜首/前列条目、movement 新增与 AI 时讯**。
|
||||
|
||||
### 当输入含 `featured_pick` 时(Step 1)
|
||||
|
||||
- `top_picks.skill` **必须**使用 `featured_pick`:`id`/`title`/`command`/`url` 来自 featured,`why` 来自 `featured_pick.why_today`
|
||||
- `opening` 首句优先引用 `featured_pick.evidence` 中的数字或条目名
|
||||
- `signals` 至少 1 条与 featured 相关
|
||||
- featured 不在 Top 榜时仍可作为首推,`why` 须来自 `featured_pick.evidence`,禁止编造榜单排名
|
||||
|
||||
**只输出 JSON:**
|
||||
|
||||
```json
|
||||
{
|
||||
"headline": "8–16字焦点标题",
|
||||
"opening": "2–3句中文导语;首句必须是具体证据(榜首 skill+安装量 / 头条新闻 / GitHub #1),再解释为什么值得看",
|
||||
"narrative_axis": "必须等于输入 required_narrative_axis(政策监管|模型发布|工具链/Agent|芯片算力|开源生态|应用落地|安全/诉讼)",
|
||||
"themes": [
|
||||
{
|
||||
"title": "主题名",
|
||||
@@ -50,7 +61,7 @@ Step 1 的 `signals` 与 `top_picks` **优先引用 Top 榜榜首/前列条目
|
||||
}
|
||||
],
|
||||
"top_picks": {
|
||||
"skill": { "id": "owner/repo/skill", "title": "...", "why": "中文,为什么今天首推" },
|
||||
"skill": { "id": "owner/repo/skill", "title": "...", "command": "npx skills add ...", "why": "中文,为什么今天首推" },
|
||||
"github": { "repo": "owner/repo", "why": "中文" },
|
||||
"news": { "link": "完整URL", "title_zh": "中文标题", "why": "中文一句话" }
|
||||
},
|
||||
@@ -64,16 +75,17 @@ Step 1 的 `signals` 与 `top_picks` **优先引用 Top 榜榜首/前列条目
|
||||
要求:
|
||||
|
||||
- 所有结论必须能在输入 JSON 中找到依据,禁止编造
|
||||
- `narrative_axis` **必填**,且必须等于输入中的 `required_narrative_axis`(代码已选定;勿自选其它轴)
|
||||
- `opening` 遵循 **article-writing Newsletter** 规则:首句用数字/条目名/新闻标题开头,不用「今天有三条线」「值得关注」等空框架
|
||||
- `signals` 3–5 条,每条单行,可含 emoji 前缀;与 `opening` 不重复同一句信息
|
||||
- `top_picks.why` 用「事实/数字 + 一句判断」,不用空泛形容词
|
||||
- `top_picks` 必须引用输入中真实存在的 id/repo/link
|
||||
- `top_picks` 必须引用输入中真实存在的 id/repo/link;有 `featured_pick` 时 skill 首推以 featured 为准
|
||||
|
||||
---
|
||||
|
||||
## Step 2:撰写企微早报
|
||||
|
||||
你会收到 **原始数据 JSON** + **Step 1 的 trends JSON**。
|
||||
你会收到 **原始数据 JSON**(含可选 `featured_pick`)+ **Step 1 的 trends JSON**。
|
||||
|
||||
**只输出企微 Markdown 正文**(不要代码块包裹,不要解释)。
|
||||
|
||||
@@ -93,21 +105,33 @@ Step 1 的 `signals` 与 `top_picks` **优先引用 Top 榜榜首/前列条目
|
||||
> {signal 3}
|
||||
|
||||
📦 **今日首推**
|
||||
`npx skills add {source}/{skill}`
|
||||
> {top_picks.skill.why}
|
||||
[{featured_pick.title 或 repo 或 skill 名}]({featured_pick.url 或 command 或 skills.sh 链接})
|
||||
> {featured_pick.why_today 或 top_picks.skill.why}
|
||||
|
||||
### 当输入含 `featured_pick` 时(Step 2)
|
||||
|
||||
- **今日首推**链接行用 Markdown `[标题](URL)`,与新闻条目同格式;**不要**用反引号裸 URL 或裸 `npx` 命令
|
||||
- GitHub 首推:标题用 `owner/repo`(如 `[garrytan/gstack](https://github.com/...)`)
|
||||
- Skill 首推:标题用 skill 名,链接用 `featured_pick.url` 或 `https://skills.sh/{id}`
|
||||
- 说明用 `featured_pick.why_today`;**不要**改回 Trending 榜首
|
||||
- 读者可见文案(opening / 今日首推说明 / why)**不得**写「编辑指定首推」等元信息,只陈述项目事实与判断
|
||||
|
||||
🌍 **国际 AI · 精选 10**
|
||||
1. [{title_zh}]({link}) — {why 或摘要}
|
||||
2. ...(**必须 10 条**,来自 `ai_news`,按重要性排序)
|
||||
|
||||
🇨🇳 **国内 AI · 精选 8**
|
||||
🇨🇳 **国内 AI · 精选 10**
|
||||
1. [{title}]({link}) — {why 或摘要}
|
||||
2. ...(**必须 8 条**,来自 `cn_ai_news`,按重要性排序;标题已是中文,可微调润色)
|
||||
2. ...(**必须 10 条**,来自 `cn_ai_news`;RSS 模式)
|
||||
|
||||
<!-- **不要写** Skills Trending / Skills Hot 区块,Python 会在推送前按 source 合并后自动插入 -->
|
||||
📰 **AI 时讯精选 · 15**(当 `ai_news_mode=research`)
|
||||
1. [{title}]({link}) — {why 或摘要}
|
||||
2. ...(**必须 15 条**:前 10 条综合精选 + 后 5 条偏工程技术,来自 `ai_news` 与 `tech_ai_news`;**不要**再写 🌍/🇨🇳/🔧 分块)
|
||||
|
||||
<!-- delta 模式(effective_wecom_mode=delta):不要写任何 Skills / GitHub 榜单区块,Python 会插入变化列表 -->
|
||||
|
||||
🐙 **GitHub Trending Top {N}**
|
||||
<!-- 只列 data.github_trending -->
|
||||
<!-- full 模式:只列 data.github_trending -->
|
||||
1. [{repo}]({url}) · {lang} · ⭐{stars} — {中文一句话}
|
||||
|
||||
🌱 **GitHub 新兴 Top {N}**
|
||||
@@ -121,14 +145,16 @@ Step 1 的 `signals` 与 `top_picks` **优先引用 Top 榜榜首/前列条目
|
||||
|
||||
### 榜单选取规则(top_n)
|
||||
|
||||
1. **五个 GitHub 区块分开写**:GitHub Trending / 新兴 / Topic,**禁止合并**
|
||||
1. **五个 GitHub 区块分开写**(仅 `effective_wecom_mode=full`):GitHub Trending / 新兴 / Topic,**禁止合并**
|
||||
2. **Skills Trending / Hot 由 Python 自动插入**,Agent 不要写这两段
|
||||
3. **禁止**改用 `movement.*_moves` 作为列表来源;movement 仅用于 opening / signals 描述「今日新增」
|
||||
3. 当 `data.effective_wecom_mode` 为 `delta` 时:**禁止写** Skills Trending / Hot / GitHub 列表区块(Python 插入变化列表);movement 可用于 opening / signals。`full` 模式保持原 GitHub 列表规则
|
||||
4. **禁止**在条目后写 `(新入 … #n)` 类括号标注
|
||||
5. **即使某榜较昨日无新增,仍须完整列出 Top 榜条目**
|
||||
5. `effective_wecom_mode=full` 时:即使某榜较昨日无新增,仍须完整列出 Top 榜条目
|
||||
6. **国际 AI 必须 10 条**(来自 `ai_news`)
|
||||
7. **国内 AI 必须 8 条**(来自 `cn_ai_news`;无数据时写「暂无可用条目」)
|
||||
8. 禁止排名变化、安装涨跌、连霸描述
|
||||
7. **国内 AI 必须 10 条**(来自 `cn_ai_news`;无数据时写「暂无可用条目」)
|
||||
8. **`ai_news_mode=research` 时**:只写 **📰 AI 时讯精选 15 条**(`ai_news` 10 条 + `tech_ai_news` 5 条合并展示),不写 🌍/🇨🇳/🔧 分块;Python 会用调研结果覆盖该区块
|
||||
9. 禁止排名变化、安装涨跌、连霸描述
|
||||
10. 榜全稳(movement 各 `*_stable` 为 true)时,signals 聚焦新闻与首推,不编造榜单变化
|
||||
|
||||
```markdown
|
||||
📈 **Skills Trending Top 10**
|
||||
@@ -161,6 +187,7 @@ Step 1 的 `signals` 与 `top_picks` **优先引用 Top 榜榜首/前列条目
|
||||
**禁止(Banned Patterns):**
|
||||
|
||||
- 「据悉」「值得关注」「快速演进」「In today's rapidly evolving landscape」
|
||||
- 「编辑指定首推」「编辑今日首推」等内部流程用语(读者不应感知编辑配置)
|
||||
- 「今天有三条线叠在一起」这类无证据的空框架开场
|
||||
- 无证据的「为什么这很重要」「 here's why this matters」
|
||||
- 结尾硬塞互动问句(如「你怎么看?」「值得花十分钟扫一眼」)
|
||||
|
||||
58
skills/daily-ai-news-research/SKILL.md
Normal file
58
skills/daily-ai-news-research/SKILL.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# AI 时讯 Deep Research(早报专用)
|
||||
|
||||
你是 **AI 时讯调研员**。使用 **WebSearch** 与网页抓取工具,收集近 N 小时全球 AI 新闻(不区分国内/国外),输出供企微早报使用的结构化 JSON。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 将任务拆成 3–5 个子问题(模型发布、监管政策、大厂动态、芯片算力、研究突破等)
|
||||
2. 每个子问题用 WebSearch 检索 2–3 组关键词(中英文混合)
|
||||
3. 交叉验证:优先权威媒体 / 官方博客 / 学术来源
|
||||
4. 精选最多 **10 条**最重要、可核实的新闻(`items`);窗口内不足则少返回,勿凑数
|
||||
5. 另精选最多 **5 条**工程技术向新闻(`tech_items`):模型/框架发布、开源、芯片算力、开发者工具、推理与工程实践;不得与 `items` 重复 link;不足则少返回
|
||||
6. **只输出 JSON**,不要 Markdown 报告,不要代码块
|
||||
|
||||
## 质量规则
|
||||
|
||||
1. 每条必须有可访问的 `link`(https://)
|
||||
2. 禁止编造未在搜索结果中出现的事实
|
||||
3. 优先近 N 小时内的新闻;若不足目标条数,**少返回**即可,禁止放宽至 48 小时凑数,禁止在 `desc_short` 标注「放宽窗口」
|
||||
4. `desc_short` 用中文一句话摘要(≤72 字)
|
||||
5. `title` 保留原文标题;中文源可用中文标题
|
||||
6. `source_name` 为媒体/站点简称(如 TechCrunch、量子位、OpenAI Blog)
|
||||
|
||||
## 输出格式(严格 JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"title": "Apple sues OpenAI over trade secret theft",
|
||||
"link": "https://techcrunch.com/...",
|
||||
"source_name": "TechCrunch",
|
||||
"desc_short": "苹果起诉 OpenAI 涉嫌窃取硬件商业机密",
|
||||
"published_fmt": "07-11 05:00"
|
||||
}
|
||||
],
|
||||
"tech_items": [
|
||||
{
|
||||
"title": "Meta Iris AI chip enters production",
|
||||
"link": "https://example.com/...",
|
||||
"source_name": "TechCrunch",
|
||||
"desc_short": "Meta 自研 Iris 芯片 9 月量产",
|
||||
"published_fmt": ""
|
||||
}
|
||||
],
|
||||
"methodology": "检索 6 组 query,分析 12 源,子问题:诉讼、模型安全、监管"
|
||||
}
|
||||
```
|
||||
|
||||
- `items` 数组长度 **必须等于** 请求的 limit(默认 10)
|
||||
- `tech_items` 数组长度 **必须等于** 请求的 tech limit(默认 5);聚焦工程技术,可与 `items` 主题重叠但 link 不得重复
|
||||
- `published_fmt` 格式 `MM-DD HH:MM`(UTC+8),无法确定则留空字符串
|
||||
- 不要输出 `items` 以外的长文;`methodology` 可选,一行即可
|
||||
|
||||
## 禁止
|
||||
|
||||
- 不要输出 ```json 代码块包裹(直接输出 JSON 对象)
|
||||
- 不要输出 Executive Summary / Key Takeaways 等报告章节
|
||||
- 不要使用本项目 RSS 或本地文档作为来源
|
||||
87
skills/daily-featured-pick/SKILL.md
Normal file
87
skills/daily-featured-pick/SKILL.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 早报今日首推检索
|
||||
|
||||
你是 **Skills / GitHub / AI 时讯早报** 的编辑研究员。Python 已完成榜单抓取;你负责为 **编辑指定的今日首推** 收集可核实信息,供后续趋势分析与写稿使用。
|
||||
|
||||
## 场景
|
||||
|
||||
- 触发:环境变量 `DAILY_FEATURED_PICK` 有值(如 `gstack` 或 `gstack|https://github.com/you/gstack`)
|
||||
- 输出:严格 JSON,写入 `output/YYYY-MM-DD.featured.json`
|
||||
- 你 **不** 写整篇早报、 **不** 改榜单顺序、 **不** 推送
|
||||
|
||||
## 输入
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "gstack",
|
||||
"url_hint": "https://github.com/you/gstack",
|
||||
"cwd": "D:\\path\\to\\workspace",
|
||||
"data_matches": {
|
||||
"skills": [],
|
||||
"github": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `query` | 编辑指定的关键词(skill 名 / repo 名片段) |
|
||||
| `url_hint` | 可选,项目主页或仓库 URL |
|
||||
| `cwd` | Cursor 工作目录,可在此检索 README / SKILL.md |
|
||||
| `data_matches` | Python 已在今日 Top 榜中预匹配的条目(**优先使用其数字**) |
|
||||
|
||||
## 检索顺序
|
||||
|
||||
1. **读 `data_matches`**:若 skills/github 有匹配, installs / star / repo / link **必须来自此处**,不得改写
|
||||
2. **读 `cwd` 本地仓库**:搜索 README、SKILL.md、package.json 描述,提炼「做什么 + 技术栈」
|
||||
3. **用 `url_hint`**:作为项目主页;无本地文件时可仅基于 URL 与 query 写 summary(须标注 evidence 来源)
|
||||
4. **禁止编造**:未在 data_matches / 本地文件 / url_hint 出现的数字、功能、版本一律不写
|
||||
|
||||
## 输出
|
||||
|
||||
**只输出一个 JSON 对象**,不要 markdown 围栏,不要解释。
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "gstack",
|
||||
"type": "skill|github|other",
|
||||
"command": "npx skills add owner/repo/skill",
|
||||
"url": "https://...",
|
||||
"summary": "2–3 句中文:做什么 + 技术栈/场景",
|
||||
"why_today": "中文,为什么今天主推(事实 + 一句判断)",
|
||||
"evidence": ["Skills Trending 匹配 · remotion-render · 22.3K", "README: Agent 工作流 CLI"],
|
||||
"tags": ["agent", "workflow"]
|
||||
}
|
||||
```
|
||||
|
||||
### 字段要求
|
||||
|
||||
| 字段 | 要求 |
|
||||
|------|------|
|
||||
| `title` | 展示名,通常与 query 或匹配条目 title/repo 短名一致 |
|
||||
| `type` | `skill` = Skills 条目;`github` = 仓库;`other` = 仅关键词/URL |
|
||||
| `command` | Skill:`npx skills add {source}/{title}`;GitHub:仓库 URL;other:url_hint 或 query |
|
||||
| `url` | 可点击链接,来自 data_matches.link / repo url / url_hint |
|
||||
| `summary` | 36–80 字中文,动词开头,说清用途 |
|
||||
| `why_today` | 40–80 字,「事实/数字 + 判断」,不用空泛形容词;**不得**出现「编辑指定」「编辑首推」等内部流程用语 |
|
||||
| `evidence` | 2–4 条短字符串,标明信息来源 |
|
||||
| `tags` | 0–4 个英文或中文关键词 |
|
||||
|
||||
### type 与 command 示例
|
||||
|
||||
- Skill 匹配:`type=skill`,`command=npx skills add vercel-labs/skills/find-skills`
|
||||
- GitHub 匹配:`type=github`,`command=https://github.com/openclaw/openclaw`
|
||||
- 仅关键词:`type=other`,`command` 用 url_hint
|
||||
|
||||
## 写作原则
|
||||
|
||||
1. **事实优先**:why_today 每条 claim 能在 evidence 或 data_matches 中找到
|
||||
2. **数字必真**:installs、star 与 data_matches 完全一致
|
||||
3. **中文叙述**:summary / why_today 全中文;skill/repo 名保留英文
|
||||
4. **克制**:不写「值得关注」「game-changer」等空话
|
||||
|
||||
## 输出前自检
|
||||
|
||||
- [ ] 仅有 JSON,无围栏、无前后说明
|
||||
- [ ] data_matches 有数字时,summary/why_today 已引用
|
||||
- [ ] command / url 与 type 一致
|
||||
- [ ] 未编造未检索到的事实
|
||||
117
tests/test_ai_news_research.py
Normal file
117
tests/test_ai_news_research.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""AI 时讯 deep-research 解析与企微格式。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from daily.format_wecom import _ai_news_lines, replace_wecom_news_sections
|
||||
from daily.news.research import parse_research_response
|
||||
|
||||
|
||||
class TestAiNewsResearchParse(unittest.TestCase):
|
||||
def test_parse_items(self):
|
||||
raw = """
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"title": "Apple sues OpenAI",
|
||||
"link": "https://techcrunch.com/2026/07/10/apple/",
|
||||
"source_name": "TechCrunch",
|
||||
"desc_short": "苹果起诉 OpenAI 涉嫌窃取商业机密",
|
||||
"published_fmt": "07-11 05:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
items, tech = parse_research_response(raw, limit=10)
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]["source_name"], "TechCrunch")
|
||||
self.assertIn("苹果", items[0]["desc_short"])
|
||||
self.assertEqual(tech, [])
|
||||
|
||||
def test_dedupe_links(self):
|
||||
raw = """{"items": [
|
||||
{"title": "A", "link": "https://example.com/a", "source_name": "Ex", "desc_short": "一"},
|
||||
{"title": "B", "link": "https://example.com/a", "source_name": "Ex", "desc_short": "二"}
|
||||
]}"""
|
||||
items, _ = parse_research_response(raw, limit=10)
|
||||
self.assertEqual(len(items), 1)
|
||||
|
||||
def test_parse_tech_items(self):
|
||||
raw = """{"items": [
|
||||
{"title": "A", "link": "https://example.com/a", "source_name": "Ex", "desc_short": "一"}
|
||||
], "tech_items": [
|
||||
{"title": "B", "link": "https://example.com/b", "source_name": "Ex", "desc_short": "二"}
|
||||
]}"""
|
||||
items, tech = parse_research_response(raw, limit=10, tech_limit=5)
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(len(tech), 1)
|
||||
self.assertEqual(tech[0]["title"], "B")
|
||||
|
||||
|
||||
class TestMergedWecomNews(unittest.TestCase):
|
||||
def test_merged_block_format(self):
|
||||
items = [
|
||||
{
|
||||
"title": "Apple sues OpenAI",
|
||||
"link": "https://techcrunch.com/x",
|
||||
"source_name": "TechCrunch",
|
||||
"desc_short": "苹果起诉 OpenAI",
|
||||
"published_fmt": "07-11 05:00",
|
||||
}
|
||||
]
|
||||
lines = _ai_news_lines(items, merged=True)
|
||||
self.assertIn("TechCrunch - Apple sues OpenAI", lines[0])
|
||||
self.assertIn("— 苹果起诉 OpenAI", lines[0])
|
||||
self.assertNotIn("07-11", lines[0])
|
||||
|
||||
def test_merged_with_tech_block(self):
|
||||
md = """📰 **早报**
|
||||
|
||||
📈 **Skills Trending Top 1**
|
||||
1. skill
|
||||
"""
|
||||
main = [
|
||||
{"title": "Main", "link": "https://example.com/m", "source_name": "Src", "desc_short": "主条", "published_fmt": "07-11"}
|
||||
]
|
||||
tech = [
|
||||
{"title": "Tech", "link": "https://example.com/t", "source_name": "Src2", "desc_short": "技术条", "published_fmt": "07-12"}
|
||||
]
|
||||
out = replace_wecom_news_sections(md, ai_news=main, tech_ai_news=tech, merged=True)
|
||||
self.assertIn("📰 **AI 时讯精选 Top 2**", out)
|
||||
self.assertNotIn("技术类时讯", out)
|
||||
self.assertNotIn("🔧", out)
|
||||
self.assertNotIn("07-11", out)
|
||||
self.assertNotIn("07-12", out)
|
||||
self.assertIn("2. [Src2 - Tech]", out)
|
||||
|
||||
def test_replace_merged_removes_split_blocks(self):
|
||||
md = """📰 **早报**
|
||||
|
||||
🌍 **国际 AI 时讯 Top 1**
|
||||
1. [old](https://example.com/old) · `X`
|
||||
|
||||
🇨🇳 **国内 AI 时讯 Top 1**
|
||||
1. [old2](https://example.com/old2) · `Y`
|
||||
|
||||
📈 **Skills Trending Top 1**
|
||||
1. skill
|
||||
"""
|
||||
items = [
|
||||
{
|
||||
"title": "New story",
|
||||
"link": "https://example.com/new",
|
||||
"source_name": "Fortune",
|
||||
"desc_short": "新故事",
|
||||
"published_fmt": "",
|
||||
}
|
||||
]
|
||||
out = replace_wecom_news_sections(md, ai_news=items, merged=True)
|
||||
self.assertIn("📰 **AI 时讯精选 Top 1**", out)
|
||||
self.assertNotIn("国际 AI 时讯", out)
|
||||
self.assertNotIn("国内 AI 时讯", out)
|
||||
self.assertIn("📈 **Skills Trending Top 1**", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
140
tests/test_board_history.py
Normal file
140
tests/test_board_history.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# tests/test_board_history.py
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from daily.board_history import extract_shown_keys, load_recent_shown_keys, merge_wecom_shown_into_data
|
||||
from daily.config import board_dedup_days, news_backfill_enabled
|
||||
|
||||
|
||||
class ConfigDiversityTests(unittest.TestCase):
|
||||
def test_board_dedup_days_default(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(board_dedup_days(), 7)
|
||||
|
||||
def test_news_backfill_default_off(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertFalse(news_backfill_enabled())
|
||||
|
||||
|
||||
class ShownKeysTests(unittest.TestCase):
|
||||
def test_extract_github_repo_keys(self):
|
||||
items = [{"repo": "a/b"}, {"repo": "c/d"}]
|
||||
self.assertEqual(extract_shown_keys("github_trending", items), ["a/b", "c/d"])
|
||||
|
||||
def test_extract_skill_keys_include_source(self):
|
||||
items = [
|
||||
{
|
||||
"id": "open.feishu.cn/lark-drive",
|
||||
"source": "open.feishu.cn",
|
||||
"title": "lark-drive",
|
||||
}
|
||||
]
|
||||
self.assertEqual(
|
||||
extract_shown_keys("skills_trending", items),
|
||||
["open.feishu.cn/lark-drive", "open.feishu.cn"],
|
||||
)
|
||||
|
||||
def test_load_recent_reads_wecom_shown_not_baseline(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp)
|
||||
# 前日:shown 只有 x/y;baseline raw 含 a/b —— 周去重只能看到 x/y
|
||||
payload = {
|
||||
"data": {
|
||||
"date": "2026-07-13",
|
||||
"movement_baseline": {
|
||||
"github_trending": [{"repo": "a/b"}, {"repo": "x/y"}],
|
||||
},
|
||||
"wecom_shown_keys": {"github_trending": ["x/y"]},
|
||||
}
|
||||
}
|
||||
(out / "2026-07-13.data.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
with patch("daily.board_history.OUTPUT_DIR", out):
|
||||
keys = load_recent_shown_keys("2026-07-14", lookback_days=7)
|
||||
self.assertEqual(keys["github_trending"], {"x/y"})
|
||||
self.assertNotIn("a/b", keys["github_trending"])
|
||||
|
||||
def test_load_recent_falls_back_to_wecom_md_when_shown_missing(self):
|
||||
"""旧日 data 无 wecom_shown_keys 时,从同日 wecom.md 解析实际展示 keys。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp)
|
||||
payload = {
|
||||
"data": {
|
||||
"date": "2026-07-13",
|
||||
"github_trending": [{"repo": "other/top"}],
|
||||
}
|
||||
}
|
||||
(out / "2026-07-13.data.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
(out / "2026-07-13.wecom.md").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"🐙 **GitHub Trending Top 2**",
|
||||
"1. [vinta/awesome-python](https://github.com/vinta/awesome-python)",
|
||||
"2. [react/react](https://github.com/react/react)",
|
||||
"",
|
||||
"🌱 **GitHub 新兴 Top 1**",
|
||||
"1. [elder-plinius/T3MP3ST](https://github.com/elder-plinius/T3MP3ST)",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("daily.board_history.OUTPUT_DIR", out):
|
||||
keys = load_recent_shown_keys("2026-07-14", lookback_days=7)
|
||||
self.assertEqual(
|
||||
keys["github_trending"],
|
||||
{"vinta/awesome-python", "react/react"},
|
||||
)
|
||||
self.assertEqual(keys["github_emerging"], {"elder-plinius/T3MP3ST"})
|
||||
self.assertNotIn("other/top", keys["github_trending"])
|
||||
|
||||
def test_merge_shown_does_not_touch_baseline(self):
|
||||
data = {
|
||||
"movement_baseline": {"github_trending": [{"repo": "raw/one"}]},
|
||||
}
|
||||
merged = merge_wecom_shown_into_data(
|
||||
data, {"github_trending": ["shown/one"]}
|
||||
)
|
||||
self.assertEqual(
|
||||
merged["movement_baseline"]["github_trending"][0]["repo"], "raw/one"
|
||||
)
|
||||
self.assertEqual(merged["wecom_shown_keys"]["github_trending"], ["shown/one"])
|
||||
|
||||
def test_persist_shown_keys_differs_from_baseline_keys(self):
|
||||
from daily.board_select import board_select
|
||||
|
||||
raw = [{"repo": f"o/r{i}"} for i in range(10)]
|
||||
recent = {f"o/r{i}" for i in range(3)}
|
||||
selected = board_select(
|
||||
board="github_trending",
|
||||
items=raw,
|
||||
recent_keys=recent,
|
||||
limit=5,
|
||||
pool_size=50,
|
||||
kind="github",
|
||||
)
|
||||
baseline_keys = [x["repo"] for x in raw[:5]]
|
||||
shown = extract_shown_keys("github_trending", selected)
|
||||
data = {
|
||||
"movement_baseline": {
|
||||
"github_trending": [{"repo": k} for k in baseline_keys],
|
||||
},
|
||||
}
|
||||
merged = merge_wecom_shown_into_data(data, {"github_trending": shown})
|
||||
self.assertNotEqual(
|
||||
set(merged["wecom_shown_keys"]["github_trending"]),
|
||||
{x["repo"] for x in merged["movement_baseline"]["github_trending"]},
|
||||
)
|
||||
self.assertEqual(shown, ["o/r3", "o/r4", "o/r5", "o/r6", "o/r7"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
91
tests/test_board_select.py
Normal file
91
tests/test_board_select.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# tests/test_board_select.py
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from daily.board_select import board_select
|
||||
|
||||
|
||||
def _gh(repo: str) -> dict:
|
||||
return {"repo": repo, "description": repo}
|
||||
|
||||
|
||||
class BoardSelectTests(unittest.TestCase):
|
||||
def test_filters_recent_and_keeps_order(self):
|
||||
pool = [_gh(f"o/r{i}") for i in range(20)]
|
||||
recent = {"o/r0", "o/r1", "o/r2"}
|
||||
out = board_select(
|
||||
board="github_trending",
|
||||
items=pool,
|
||||
recent_keys=recent,
|
||||
limit=5,
|
||||
pool_size=20,
|
||||
kind="github",
|
||||
)
|
||||
keys = [x["repo"] for x in out]
|
||||
self.assertEqual(keys, ["o/r3", "o/r4", "o/r5", "o/r6", "o/r7"])
|
||||
|
||||
def test_deep_pool_fills_after_filter(self):
|
||||
pool = [_gh(f"o/r{i}") for i in range(8)]
|
||||
recent = {f"o/r{i}" for i in range(6)} # 前 6 全封
|
||||
out = board_select(
|
||||
board="github_emerging",
|
||||
items=pool,
|
||||
recent_keys=recent,
|
||||
limit=5,
|
||||
pool_size=8,
|
||||
kind="github",
|
||||
)
|
||||
self.assertEqual([x["repo"] for x in out], ["o/r6", "o/r7"]) # 短榜
|
||||
|
||||
def test_skill_uses_skill_id(self):
|
||||
items = [
|
||||
{"id": "a/b/s1", "source": "a/b", "title": "s1", "installs": 10},
|
||||
{"id": "c/d/s2", "source": "c/d", "title": "s2", "installs": 9},
|
||||
]
|
||||
out = board_select(
|
||||
board="skills_trending",
|
||||
items=items,
|
||||
recent_keys={"a/b/s1"},
|
||||
limit=10,
|
||||
pool_size=50,
|
||||
kind="skill",
|
||||
)
|
||||
self.assertEqual([x["id"] for x in out], ["c/d/s2"])
|
||||
|
||||
def test_skill_filters_recent_by_source(self):
|
||||
items = [
|
||||
{"id": "a/b/s-new", "source": "a/b", "title": "s-new", "installs": 10},
|
||||
{"id": "c/d/s2", "source": "c/d", "title": "s2", "installs": 9},
|
||||
]
|
||||
out = board_select(
|
||||
board="skills_hot",
|
||||
items=items,
|
||||
recent_keys={"a/b"}, # source-level history
|
||||
limit=10,
|
||||
pool_size=50,
|
||||
kind="skill",
|
||||
)
|
||||
self.assertEqual([x["id"] for x in out], ["c/d/s2"])
|
||||
|
||||
def test_skill_filters_recent_skill_id_as_same_source(self):
|
||||
from daily.format_wecom import expand_skill_recent_keys
|
||||
|
||||
items = [
|
||||
{"id": "open.feishu.cn/lark-drive", "source": "open.feishu.cn", "title": "lark-drive", "installs": 10},
|
||||
{"id": "fresh/src/s", "source": "fresh/src", "title": "s", "installs": 9},
|
||||
]
|
||||
recent = expand_skill_recent_keys({"open.feishu.cn/lark-doc"})
|
||||
out = board_select(
|
||||
board="skills_trending",
|
||||
items=items,
|
||||
recent_keys=recent,
|
||||
limit=10,
|
||||
pool_size=50,
|
||||
kind="skill",
|
||||
)
|
||||
self.assertEqual([x["id"] for x in out], ["fresh/src/s"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
95
tests/test_featured_pick.py
Normal file
95
tests/test_featured_pick.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Tests for daily.featured_pick."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from daily.featured_pick import (
|
||||
apply_featured_pick,
|
||||
match_in_data,
|
||||
parse_featured_pick,
|
||||
pick_command_from_featured,
|
||||
pick_why_from_featured,
|
||||
research_featured_pick,
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_INPUT = {
|
||||
"skills_trending": [
|
||||
{
|
||||
"id": "foo/bar/gstack-cli",
|
||||
"title": "gstack-cli",
|
||||
"source": "foo/bar",
|
||||
"installs": 1200,
|
||||
"installs_fmt": "1.2K",
|
||||
"link": "https://skills.sh/foo/bar/gstack-cli",
|
||||
"description": "Agent workflow CLI",
|
||||
}
|
||||
],
|
||||
"skills_hot": [],
|
||||
"github_trending": [
|
||||
{
|
||||
"repo": "acme/gstack",
|
||||
"url": "https://github.com/acme/gstack",
|
||||
"total_stars_fmt": "3.2K",
|
||||
"description": "GStack toolkit",
|
||||
}
|
||||
],
|
||||
"github_emerging": [],
|
||||
"github_topic": {"topic": "llm", "repos": []},
|
||||
}
|
||||
|
||||
|
||||
class ParseFeaturedPickTests(unittest.TestCase):
|
||||
def test_empty(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertIsNone(parse_featured_pick())
|
||||
|
||||
def test_query_only(self):
|
||||
with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack"}, clear=True):
|
||||
self.assertEqual(parse_featured_pick(), {"query": "gstack"})
|
||||
|
||||
def test_query_with_url(self):
|
||||
with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack|https://example.com"}, clear=True):
|
||||
self.assertEqual(
|
||||
parse_featured_pick(),
|
||||
{"query": "gstack", "url_hint": "https://example.com"},
|
||||
)
|
||||
|
||||
|
||||
class MatchInDataTests(unittest.TestCase):
|
||||
def test_matches_skill_and_github(self):
|
||||
matches = match_in_data(SAMPLE_INPUT, "gstack")
|
||||
self.assertEqual(len(matches["skills"]), 1)
|
||||
self.assertEqual(matches["skills"][0]["title"], "gstack-cli")
|
||||
self.assertEqual(len(matches["github"]), 1)
|
||||
self.assertEqual(matches["github"][0]["repo"], "acme/gstack")
|
||||
|
||||
|
||||
class FeaturedPickWorkflowTests(unittest.TestCase):
|
||||
def test_fallback_without_llm(self):
|
||||
llm_input = dict(SAMPLE_INPUT)
|
||||
with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack"}, clear=True):
|
||||
with patch("daily.featured_pick.has_llm_configured", return_value=False):
|
||||
featured = research_featured_pick(llm_input, date_str="2026-07-03")
|
||||
self.assertIsNotNone(featured)
|
||||
assert featured is not None
|
||||
self.assertEqual(featured["type"], "skill")
|
||||
self.assertIn("npx skills add foo/bar/gstack-cli", featured["command"])
|
||||
self.assertTrue(featured["why_today"])
|
||||
|
||||
def test_apply_featured_pick_mutates_input(self):
|
||||
llm_input = dict(SAMPLE_INPUT)
|
||||
with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack"}, clear=True):
|
||||
with patch("daily.featured_pick.has_llm_configured", return_value=False):
|
||||
featured = apply_featured_pick(llm_input, date_str="2026-07-03")
|
||||
self.assertIsNotNone(featured)
|
||||
self.assertIn("featured_pick", llm_input)
|
||||
self.assertEqual(pick_command_from_featured(featured), llm_input["featured_pick"]["command"])
|
||||
self.assertEqual(pick_why_from_featured(featured), llm_input["featured_pick"]["why_today"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
132
tests/test_featured_resolve.py
Normal file
132
tests/test_featured_resolve.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# tests/test_featured_resolve.py
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from daily.featured_pick import (
|
||||
featured_identity_key,
|
||||
featured_resolve,
|
||||
load_recent_featured_keys,
|
||||
load_yesterday_featured_key,
|
||||
)
|
||||
|
||||
|
||||
class FeaturedResolveTests(unittest.TestCase):
|
||||
def test_same_as_yesterday_picks_from_pool_a(self):
|
||||
yesterday_key = "headroomlabs-ai/headroom"
|
||||
pool_a = [
|
||||
{"repo": "headroomlabs-ai/headroom", "board": "github_topic"},
|
||||
{"repo": "ollama/ollama", "board": "github_trending"},
|
||||
]
|
||||
rng = random.Random(0)
|
||||
resolved, key = featured_resolve(
|
||||
date_str="2026-07-14",
|
||||
candidate={
|
||||
"type": "github",
|
||||
"url": "https://github.com/headroomlabs-ai/headroom",
|
||||
"title": "headroom",
|
||||
},
|
||||
pool_a=pool_a,
|
||||
pool_b=[],
|
||||
recent_featured={yesterday_key},
|
||||
yesterday_key=yesterday_key,
|
||||
rng=rng,
|
||||
)
|
||||
self.assertNotEqual(key, yesterday_key)
|
||||
self.assertEqual(key, "ollama/ollama")
|
||||
self.assertIsNotNone(resolved)
|
||||
assert resolved is not None
|
||||
self.assertEqual(resolved.get("repo"), "ollama/ollama")
|
||||
|
||||
def test_pool_a_before_pool_b(self):
|
||||
yesterday_key = "blocked/one"
|
||||
pool_a = [{"repo": "pool-a/repo", "board": "github_trending"}]
|
||||
pool_b = [{"repo": "pool-b/repo", "board": "github_emerging"}]
|
||||
resolved, key = featured_resolve(
|
||||
date_str="2026-07-14",
|
||||
candidate={"type": "github", "repo": "blocked/one", "title": "one"},
|
||||
pool_a=pool_a,
|
||||
pool_b=pool_b,
|
||||
recent_featured={yesterday_key},
|
||||
yesterday_key=yesterday_key,
|
||||
rng=random.Random(1),
|
||||
)
|
||||
self.assertEqual(key, "pool-a/repo")
|
||||
assert resolved is not None
|
||||
self.assertEqual(resolved.get("repo"), "pool-a/repo")
|
||||
|
||||
def test_exhausted_keeps_original(self):
|
||||
yesterday_key = "only/one"
|
||||
candidate = {"type": "github", "repo": "only/one", "title": "one"}
|
||||
resolved, key = featured_resolve(
|
||||
date_str="2026-07-14",
|
||||
candidate=candidate,
|
||||
pool_a=[{"repo": "only/one", "board": "github_trending"}],
|
||||
pool_b=[],
|
||||
recent_featured={yesterday_key},
|
||||
yesterday_key=yesterday_key,
|
||||
rng=random.Random(2),
|
||||
)
|
||||
self.assertEqual(key, yesterday_key)
|
||||
self.assertEqual(resolved, candidate)
|
||||
|
||||
def test_identity_key_skill_and_github(self):
|
||||
self.assertEqual(
|
||||
featured_identity_key({"type": "skill", "id": "a/b/c"}),
|
||||
"a/b/c",
|
||||
)
|
||||
self.assertEqual(
|
||||
featured_identity_key(
|
||||
{"type": "github", "url": "https://github.com/foo/bar"}
|
||||
),
|
||||
"foo/bar",
|
||||
)
|
||||
|
||||
def test_load_yesterday_falls_back_to_featured_pick(self):
|
||||
"""缺 featured_pick_key 时从 featured_pick.url 推导身份,避免连日重复首推。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp)
|
||||
payload = {
|
||||
"data": {
|
||||
"date": "2026-07-13",
|
||||
"featured_pick": {
|
||||
"type": "github",
|
||||
"title": "headroom",
|
||||
"url": "https://github.com/headroomlabs-ai/headroom",
|
||||
},
|
||||
}
|
||||
}
|
||||
(out / "2026-07-13.data.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
with patch("daily.featured_pick.OUTPUT_DIR", out):
|
||||
key = load_yesterday_featured_key("2026-07-14")
|
||||
self.assertEqual(key, "headroomlabs-ai/headroom")
|
||||
|
||||
def test_load_recent_falls_back_to_featured_pick(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp)
|
||||
payload = {
|
||||
"data": {
|
||||
"date": "2026-07-13",
|
||||
"featured_pick": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/headroomlabs-ai/headroom",
|
||||
},
|
||||
}
|
||||
}
|
||||
(out / "2026-07-13.data.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
with patch("daily.featured_pick.OUTPUT_DIR", out):
|
||||
keys = load_recent_featured_keys("2026-07-14", days=7)
|
||||
self.assertIn("headroomlabs-ai/headroom", keys)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
71
tests/test_github_search.py
Normal file
71
tests/test_github_search.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Tests for GitHub Search pagination / deep pool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class SearchGithubReposPaginationTests(unittest.TestCase):
|
||||
def test_search_paginates_beyond_first_page_of_30(self):
|
||||
from daily.github.search import search_github_repos
|
||||
|
||||
def make_items(start: int, n: int) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"full_name": f"org/repo{i}",
|
||||
"html_url": f"https://github.com/org/repo{i}",
|
||||
"description": f"desc {i}",
|
||||
"language": "Python",
|
||||
"stargazers_count": 1000 - i,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
for i in range(start, start + n)
|
||||
]
|
||||
|
||||
responses = [
|
||||
MagicMock(status_code=200, json=lambda: {"items": make_items(1, 100)}),
|
||||
MagicMock(status_code=200, json=lambda: {"items": make_items(101, 50)}),
|
||||
]
|
||||
client = MagicMock()
|
||||
client.__enter__.return_value = client
|
||||
client.__exit__.return_value = False
|
||||
client.get.side_effect = responses
|
||||
|
||||
with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}, clear=False):
|
||||
with patch("daily.github.search.httpx.Client", return_value=client):
|
||||
with patch("daily.github.search.github_token", return_value="test-token"):
|
||||
repos = search_github_repos("stars:>50", 120, require_token=True)
|
||||
|
||||
self.assertEqual(len(repos), 120)
|
||||
self.assertEqual(repos[0]["repo"], "org/repo1")
|
||||
self.assertEqual(repos[119]["repo"], "org/repo120")
|
||||
self.assertEqual(client.get.call_count, 2)
|
||||
first_params = client.get.call_args_list[0].kwargs["params"]
|
||||
self.assertEqual(first_params["per_page"], 100)
|
||||
self.assertEqual(first_params["page"], 1)
|
||||
|
||||
|
||||
class GithubBoardDeepPoolTests(unittest.TestCase):
|
||||
def test_board_select_fills_ten_when_deep_pool_has_fresh_repos(self):
|
||||
from daily.board_select import board_select
|
||||
|
||||
recent = {f"old/r{i}" for i in range(1, 33)}
|
||||
items = [{"repo": f"old/r{i}"} for i in range(1, 31)] + [
|
||||
{"repo": f"fresh/r{i}"} for i in range(1, 20)
|
||||
]
|
||||
selected = board_select(
|
||||
board="github_trending",
|
||||
items=items,
|
||||
recent_keys=recent,
|
||||
limit=10,
|
||||
pool_size=100,
|
||||
kind="github",
|
||||
)
|
||||
self.assertEqual(len(selected), 10)
|
||||
self.assertTrue(all(r["repo"].startswith("fresh/") for r in selected))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
34
tests/test_narrative_axis.py
Normal file
34
tests/test_narrative_axis.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# tests/test_narrative_axis.py
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from daily.narrative_axis import (
|
||||
NARRATIVE_AXES,
|
||||
enforce_narrative_axis,
|
||||
pick_narrative_axis,
|
||||
)
|
||||
|
||||
|
||||
class NarrativeAxisTests(unittest.TestCase):
|
||||
def test_pick_excludes_used(self):
|
||||
used = {"政策监管", "模型发布", "工具链/Agent"}
|
||||
for _ in range(20):
|
||||
axis = pick_narrative_axis(used, rng=random.Random(1))
|
||||
self.assertNotIn(axis, used)
|
||||
self.assertIn(axis, NARRATIVE_AXES)
|
||||
|
||||
def test_enforce_overwrites_llm(self):
|
||||
trends = {"narrative_axis": "开源生态", "opening": "..."}
|
||||
out = enforce_narrative_axis(trends, "芯片算力")
|
||||
self.assertEqual(out["narrative_axis"], "芯片算力")
|
||||
|
||||
def test_pick_when_all_used_falls_back(self):
|
||||
used = set(NARRATIVE_AXES)
|
||||
axis = pick_narrative_axis(used, rng=random.Random(0))
|
||||
self.assertIn(axis, NARRATIVE_AXES)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
156
tests/test_news_fetch_window.py
Normal file
156
tests/test_news_fetch_window.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Tests for news time window filtering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from daily.news.fetch import _cutoff_datetime, _parse_datetime, _within_window
|
||||
|
||||
|
||||
class NewsWindowTests(unittest.TestCase):
|
||||
def test_cutoff_floor_today_excludes_yesterday_even_within_24h(self):
|
||||
tz = ZoneInfo("Asia/Shanghai")
|
||||
# 2026-07-09 09:00 CST = 2026-07-09 01:00 UTC
|
||||
fixed = datetime(2026, 7, 9, 1, 0, tzinfo=timezone.utc)
|
||||
with patch("daily.news.fetch._now_utc", return_value=fixed):
|
||||
with patch.dict(os.environ, {"DAILY_AI_NEWS_HOURS": "24"}, clear=False):
|
||||
cutoff = _cutoff_datetime(floor_today=True)
|
||||
start_today_cst = datetime(2026, 7, 9, 0, 0, tzinfo=tz).astimezone(timezone.utc)
|
||||
self.assertEqual(cutoff, start_today_cst)
|
||||
yesterday = datetime(2026, 7, 8, 20, 0, tzinfo=tz).astimezone(timezone.utc)
|
||||
self.assertFalse(_within_window({"published": yesterday.isoformat()}, cutoff))
|
||||
|
||||
def test_cutoff_rolling_only_includes_last_24h(self):
|
||||
fixed = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc)
|
||||
with patch("daily.news.fetch._now_utc", return_value=fixed):
|
||||
with patch.dict(os.environ, {"DAILY_AI_NEWS_HOURS": "24"}, clear=False):
|
||||
cutoff = _cutoff_datetime(floor_today=False)
|
||||
self.assertEqual(cutoff, fixed - timedelta(hours=24))
|
||||
|
||||
def test_within_window_rejects_missing_datetime(self):
|
||||
cutoff = datetime(2026, 7, 9, 0, 0, tzinfo=timezone.utc)
|
||||
self.assertFalse(_within_window({"title": "x", "link": "https://a.com"}, cutoff))
|
||||
|
||||
def test_parse_date_only_uses_local_noon(self):
|
||||
with patch.dict(os.environ, {"DAILY_AI_NEWS_TZ": "Asia/Shanghai"}, clear=False):
|
||||
dt = _parse_datetime("2026-07-09")
|
||||
self.assertIsNotNone(dt)
|
||||
assert dt is not None
|
||||
local = dt.astimezone(ZoneInfo("Asia/Shanghai"))
|
||||
self.assertEqual(local.hour, 12)
|
||||
|
||||
|
||||
class NewsFormatTests(unittest.TestCase):
|
||||
def test_ai_news_lines_use_desc_as_link_text(self):
|
||||
from daily.format_wecom import _ai_news_lines
|
||||
|
||||
lines = _ai_news_lines(
|
||||
[
|
||||
{
|
||||
"title": "English Title",
|
||||
"link": "https://example.com/a",
|
||||
"source_name": "Src",
|
||||
"published_fmt": "07-11",
|
||||
"desc_short": "中文摘要一句",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(len(lines), 1)
|
||||
self.assertIn("[中文摘要一句](https://example.com/a)", lines[0])
|
||||
self.assertNotIn("English Title", lines[0])
|
||||
self.assertNotIn("> ", lines[0])
|
||||
|
||||
def test_ai_news_lines_fallback_to_title(self):
|
||||
from daily.format_wecom import _ai_news_lines
|
||||
|
||||
lines = _ai_news_lines(
|
||||
[
|
||||
{
|
||||
"title": "仅标题",
|
||||
"link": "https://example.com/b",
|
||||
"source_name": "Src",
|
||||
"published_fmt": "",
|
||||
"desc_short": "",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertIn("[仅标题](https://example.com/b)", lines[0])
|
||||
|
||||
|
||||
class NewsSummaryTests(unittest.TestCase):
|
||||
def test_brief_news_summary_no_ellipsis(self):
|
||||
from daily.news.fetch import brief_news_summary
|
||||
|
||||
text = (
|
||||
"Meta told Dylan Byers, of Puck News, that the company removed "
|
||||
"the controversial AI feature after user backlash on Instagram."
|
||||
)
|
||||
out = brief_news_summary(text, limit=72)
|
||||
self.assertNotIn("...", out)
|
||||
self.assertLessEqual(len(out), 72)
|
||||
self.assertTrue(out.startswith("Meta told"))
|
||||
|
||||
def test_brief_news_summary_filters_junk(self):
|
||||
from daily.news.fetch import brief_news_summary
|
||||
|
||||
self.assertEqual(brief_news_summary("点击查看原文>"), "")
|
||||
self.assertEqual(brief_news_summary("Article URL: https://example.com"), "")
|
||||
|
||||
def test_sync_wecom_news_rows_after_localize(self):
|
||||
from daily.news.fetch import _to_wecom_news_row, sync_wecom_news_rows
|
||||
|
||||
row = _to_wecom_news_row(
|
||||
{
|
||||
"title": "t",
|
||||
"link": "https://a.com/x",
|
||||
"source_name": "s",
|
||||
"published_fmt": "07-11",
|
||||
"summary": "Short english stub that was truncated early...",
|
||||
}
|
||||
)
|
||||
flat = [
|
||||
{
|
||||
"link": "https://a.com/x",
|
||||
"summary": "苹果指控 OpenAI 窃取硬件商业机密,诉讼称 misconduct 涉及多名前员工。",
|
||||
}
|
||||
]
|
||||
sync_wecom_news_rows([row], flat)
|
||||
self.assertNotIn("...", row["desc_short"])
|
||||
self.assertIn("苹果", row["desc_short"])
|
||||
|
||||
|
||||
def test_finalize_wecom_news_forces_chinese(self):
|
||||
from daily.news.fetch import finalize_wecom_news_items
|
||||
|
||||
items = [
|
||||
{
|
||||
"link": "https://a.com/1",
|
||||
"desc_short": "Meta removed the feature after backlash.",
|
||||
"summary_plain": "Meta removed the feature after backlash.",
|
||||
}
|
||||
]
|
||||
with patch(
|
||||
"daily.localize.localize_brief_descriptions",
|
||||
return_value={"wecom-news:https://a.com/1": "Meta 在舆论压力下移除了该功能"},
|
||||
):
|
||||
finalize_wecom_news_items(items, force_chinese=True)
|
||||
self.assertIn("Meta", items[0]["desc_short"])
|
||||
self.assertNotIn("backlash", items[0]["desc_short"])
|
||||
|
||||
|
||||
class NewsPickTests(unittest.TestCase):
|
||||
def test_pick_and_backfill_to_limit(self):
|
||||
from daily.news.fetch import _fill_picked_to_limit, _pick_news_items
|
||||
|
||||
flat = [
|
||||
{"link": f"https://a.com/{i}", "title": f"t{i}", "category_id": "media", "summary": "s"}
|
||||
for i in range(12)
|
||||
]
|
||||
picked = _pick_news_items(flat, 10, ("media",))
|
||||
self.assertEqual(len(picked), 10)
|
||||
picked = _fill_picked_to_limit(picked[:3], [flat], 10)
|
||||
self.assertEqual(len(picked), 10)
|
||||
62
tests/test_news_relax.py
Normal file
62
tests/test_news_relax.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# tests/test_news_relax.py
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class NewsRelaxTests(unittest.TestCase):
|
||||
def test_strip_relax_prefix(self):
|
||||
from daily.news.sanitize import strip_relax_window_prefix
|
||||
|
||||
self.assertEqual(
|
||||
strip_relax_window_prefix("放宽窗口:苹果起诉 OpenAI"),
|
||||
"苹果起诉 OpenAI",
|
||||
)
|
||||
self.assertEqual(
|
||||
strip_relax_window_prefix("放宽至48小时:某新闻"),
|
||||
"某新闻",
|
||||
)
|
||||
self.assertEqual(
|
||||
strip_relax_window_prefix("正常摘要无前缀"),
|
||||
"正常摘要无前缀",
|
||||
)
|
||||
|
||||
def test_backfill_disabled_does_not_reinsert_pushed(self):
|
||||
from daily.news.fetch import _apply_pushed_dedup_with_backfill
|
||||
|
||||
fresh_only = [
|
||||
{
|
||||
"link": "https://example.com/fresh",
|
||||
"title": "fresh",
|
||||
"source_name": "S",
|
||||
"published_fmt": "07-14",
|
||||
"desc_short": "新",
|
||||
"summary_plain": "新",
|
||||
}
|
||||
]
|
||||
picked = [
|
||||
{
|
||||
"link": "https://example.com/old",
|
||||
"title": "old",
|
||||
"source_name": "S",
|
||||
"published": "2026-07-13T10:00:00+00:00",
|
||||
"summary": "旧闻",
|
||||
}
|
||||
]
|
||||
with patch("daily.news.pushed_links.filter_unpushed_items", return_value=list(fresh_only)):
|
||||
with patch.dict(os.environ, {"DAILY_NEWS_BACKFILL": "0"}, clear=False):
|
||||
out = _apply_pushed_dedup_with_backfill(
|
||||
fresh_only + [{"link": "https://example.com/old", "title": "old"}],
|
||||
picked,
|
||||
date_str="2026-07-14",
|
||||
limit=5,
|
||||
)
|
||||
links = [x.get("link") for x in out]
|
||||
self.assertIn("https://example.com/fresh", links)
|
||||
self.assertNotIn("https://example.com/old", links)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
118
tests/test_scheduler.py
Normal file
118
tests/test_scheduler.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Tests for daily.scheduler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from daily.scheduler import (
|
||||
ClockTime,
|
||||
SchedulerState,
|
||||
next_occurrence_after,
|
||||
parse_hhmm,
|
||||
plan_next_action,
|
||||
)
|
||||
|
||||
|
||||
class ParseHhmmTests(unittest.TestCase):
|
||||
def test_parse(self):
|
||||
t = parse_hhmm("08:50")
|
||||
self.assertEqual((t.hour, t.minute), (8, 50))
|
||||
|
||||
def test_invalid(self):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_hhmm("25:00")
|
||||
|
||||
|
||||
class PlanNextActionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tz = ZoneInfo("Asia/Shanghai")
|
||||
self.gen = ClockTime(8, 50)
|
||||
self.push = ClockTime(9, 0)
|
||||
|
||||
def test_before_generate_waits_for_generate(self):
|
||||
now = datetime(2026, 7, 9, 8, 30, tzinfo=self.tz)
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=SchedulerState(),
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "generate")
|
||||
self.assertEqual(run_at.hour, 8)
|
||||
self.assertEqual(run_at.minute, 50)
|
||||
|
||||
def test_after_generate_before_push_waits_for_push(self):
|
||||
now = datetime(2026, 7, 9, 8, 55, tzinfo=self.tz)
|
||||
state = SchedulerState(last_generate_date="2026-07-09")
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=state,
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "push")
|
||||
self.assertEqual(run_at.hour, 9)
|
||||
|
||||
def test_catch_up_generate_when_started_late(self):
|
||||
now = datetime(2026, 7, 9, 8, 55, tzinfo=self.tz)
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=SchedulerState(),
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "generate")
|
||||
self.assertEqual(run_at, now)
|
||||
|
||||
def test_next_day_after_both_done(self):
|
||||
now = datetime(2026, 7, 9, 10, 0, tzinfo=self.tz)
|
||||
state = SchedulerState(last_generate_date="2026-07-09", last_push_date="2026-07-09")
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=state,
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "generate")
|
||||
self.assertEqual(run_at.date().isoformat(), "2026-07-10")
|
||||
|
||||
def test_evening_start_waits_for_tomorrow_generate(self):
|
||||
now = datetime(2026, 7, 9, 20, 35, tzinfo=self.tz)
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=SchedulerState(),
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "generate")
|
||||
self.assertEqual(run_at.date().isoformat(), "2026-07-10")
|
||||
self.assertEqual((run_at.hour, run_at.minute), (8, 50))
|
||||
|
||||
def test_catch_up_push_when_generate_done(self):
|
||||
now = datetime(2026, 7, 9, 20, 35, tzinfo=self.tz)
|
||||
state = SchedulerState(last_generate_date="2026-07-09")
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=state,
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "push")
|
||||
self.assertEqual(run_at, now)
|
||||
|
||||
|
||||
class NextOccurrenceTests(unittest.TestCase):
|
||||
def test_tomorrow_when_past(self):
|
||||
tz = ZoneInfo("Asia/Shanghai")
|
||||
now = datetime(2026, 7, 9, 10, 0, tzinfo=tz)
|
||||
nxt = next_occurrence_after(ClockTime(8, 50), tz, now)
|
||||
self.assertEqual(nxt.date().isoformat(), "2026-07-10")
|
||||
self.assertEqual((nxt.hour, nxt.minute), (8, 50))
|
||||
804
tests/test_wecom_delta.py
Normal file
804
tests/test_wecom_delta.py
Normal file
@@ -0,0 +1,804 @@
|
||||
"""Tests for WeCom delta mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from daily.config import (
|
||||
delta_baseline_fallback,
|
||||
env_bool,
|
||||
force_push,
|
||||
news_dedup_days,
|
||||
skip_push_when_silent,
|
||||
wecom_mode,
|
||||
)
|
||||
from daily.news.pushed_links import filter_unpushed_items, record_pushed_links
|
||||
|
||||
|
||||
class ConfigHelpersTests(unittest.TestCase):
|
||||
def test_wecom_mode_defaults_delta(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(wecom_mode(), "delta")
|
||||
|
||||
def test_wecom_mode_full(self):
|
||||
with patch.dict(os.environ, {"DAILY_WECOM_MODE": "full"}, clear=True):
|
||||
self.assertEqual(wecom_mode(), "full")
|
||||
|
||||
def test_env_bool_truthy(self):
|
||||
with patch.dict(os.environ, {"DAILY_FORCE_PUSH": "1"}, clear=True):
|
||||
self.assertTrue(env_bool("DAILY_FORCE_PUSH", False))
|
||||
|
||||
def test_skip_push_when_silent_default(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertTrue(skip_push_when_silent())
|
||||
|
||||
def test_news_dedup_days_default(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(news_dedup_days(), 7)
|
||||
|
||||
def test_delta_baseline_fallback_default(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(delta_baseline_fallback(), "full")
|
||||
|
||||
def test_wecom_delta_pad_default_true(self):
|
||||
from daily.config import wecom_delta_pad
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertTrue(wecom_delta_pad())
|
||||
|
||||
|
||||
class NewsPushedLinksTests(unittest.TestCase):
|
||||
def test_filter_and_record_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache = Path(tmp) / "pushed-news-links.json"
|
||||
items = [
|
||||
{"title": "A", "link": "https://example.com/a?utm_source=x"},
|
||||
{"title": "B", "link": "https://example.com/b"},
|
||||
]
|
||||
with patch("daily.news.pushed_links._cache_path", return_value=cache):
|
||||
with patch("daily.news.pushed_links.news_dedup_days", return_value=7):
|
||||
record_pushed_links("2026-07-08", ["https://example.com/a"])
|
||||
out = filter_unpushed_items(items, date_str="2026-07-09")
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0]["link"], "https://example.com/b")
|
||||
|
||||
|
||||
class SkillMovePartitionTests(unittest.TestCase):
|
||||
def test_partition_dedupes_across_boards(self):
|
||||
trending = [
|
||||
{
|
||||
"id": "a/b/foo",
|
||||
"rank": 4,
|
||||
"title": "foo",
|
||||
"source": "a/b",
|
||||
"installs": 1,
|
||||
"link": "",
|
||||
"description": "",
|
||||
}
|
||||
]
|
||||
hot = [
|
||||
{
|
||||
"id": "a/b/foo",
|
||||
"rank": 2,
|
||||
"title": "foo",
|
||||
"source": "a/b",
|
||||
"installs": 1,
|
||||
"link": "",
|
||||
"description": "",
|
||||
}
|
||||
]
|
||||
from daily.delta import partition_skill_moves_for_wecom
|
||||
|
||||
t_out, h_out = partition_skill_moves_for_wecom(trending, hot)
|
||||
self.assertEqual(len(t_out), 1)
|
||||
self.assertEqual(len(h_out), 0)
|
||||
self.assertIn("Trending #4", t_out[0]["badge"])
|
||||
self.assertIn("Hot #2", t_out[0]["badge"])
|
||||
|
||||
def test_effective_mode_fallback_full_without_baseline(self):
|
||||
from daily.delta import effective_wecom_mode
|
||||
|
||||
with patch("daily.delta.find_previous_data", return_value=None):
|
||||
with patch("daily.delta.wecom_mode", return_value="delta"):
|
||||
with patch("daily.delta.delta_baseline_fallback", return_value="full"):
|
||||
self.assertEqual(effective_wecom_mode(date_str="2026-07-10"), "full")
|
||||
|
||||
|
||||
class DeltaFormatTests(unittest.TestCase):
|
||||
def test_skills_delta_omits_empty_board(self):
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
moves = [
|
||||
{
|
||||
"id": "x/y/z",
|
||||
"rank": 3,
|
||||
"title": "z",
|
||||
"source": "x/y",
|
||||
"installs": 10,
|
||||
"installs_fmt": "10",
|
||||
"link": "https://skills.sh/x/y/z",
|
||||
"description": "d",
|
||||
"badge": "Trending #3",
|
||||
}
|
||||
]
|
||||
text = build_skills_delta_sections(moves, [])
|
||||
self.assertIn("Skills Trending 变化", text)
|
||||
self.assertNotIn("[新入 #", text)
|
||||
self.assertNotIn("Skills Hot 变化", text)
|
||||
|
||||
def test_github_delta_omits_stable_board(self):
|
||||
from daily.format_wecom import build_github_delta_sections
|
||||
|
||||
movement = {
|
||||
"github_trending_moves": [
|
||||
{
|
||||
"repo": "a/b",
|
||||
"url": "https://github.com/a/b",
|
||||
"rank": 1,
|
||||
"language": "Go",
|
||||
"description": "open-source codebase and curriculum",
|
||||
}
|
||||
],
|
||||
"github_emerging_moves": [],
|
||||
"github_topic_moves": [],
|
||||
}
|
||||
with patch(
|
||||
"daily.format_wecom.localize_brief_descriptions",
|
||||
return_value={"github:a/b": "开源代码库与课程体系"},
|
||||
):
|
||||
text = build_github_delta_sections(movement, topic_name="llm")
|
||||
self.assertIn("开源代码库", text)
|
||||
self.assertIn("GitHub Trending 变化", text)
|
||||
self.assertNotIn("新兴", text)
|
||||
self.assertNotIn("[新入 #", text)
|
||||
self.assertNotIn("\n > ", text)
|
||||
|
||||
def test_delta_pad_groups_same_source_moves(self):
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
moves = [
|
||||
{
|
||||
"id": f"lllllllama/rigorpilot-skills/s{i}",
|
||||
"title": f"s{i}",
|
||||
"source": "lllllllama/rigorpilot-skills",
|
||||
"installs": 250 - i,
|
||||
"installs_fmt": str(250 - i),
|
||||
"link": f"https://www.skills.sh/lllllllama/rigorpilot-skills/s{i}",
|
||||
"description": f"skill {i}",
|
||||
}
|
||||
for i in range(1, 11)
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(moves, [], trending_limit=10, pad=True)
|
||||
self.assertIn("10 skills", text)
|
||||
self.assertNotIn("[**s2**]", text)
|
||||
|
||||
def test_delta_pad_fills_skills_to_limit(self):
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
moves = [
|
||||
{
|
||||
"id": "a/b/new",
|
||||
"rank": 3,
|
||||
"title": "new",
|
||||
"source": "a/b",
|
||||
"installs": 99,
|
||||
"installs_fmt": "99",
|
||||
"link": "https://skills.sh/a/b/new",
|
||||
"description": "new skill",
|
||||
}
|
||||
]
|
||||
full = [
|
||||
{
|
||||
"id": "a/b/new",
|
||||
"title": "new",
|
||||
"source": "a/b",
|
||||
"installs": 99,
|
||||
"installs_fmt": "99",
|
||||
"link": "https://skills.sh/a/b/new",
|
||||
"description": "new skill",
|
||||
},
|
||||
*[
|
||||
{
|
||||
"id": f"src{i}/skill",
|
||||
"title": "skill",
|
||||
"source": f"src{i}/pkg",
|
||||
"installs": 100 - i,
|
||||
"installs_fmt": str(100 - i),
|
||||
"link": f"https://skills.sh/src{i}/pkg/skill",
|
||||
"description": f"skill from src{i}",
|
||||
}
|
||||
for i in range(1, 12)
|
||||
],
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(
|
||||
moves,
|
||||
[],
|
||||
trending_full=full,
|
||||
hot_full=[],
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
)
|
||||
self.assertIn("Skills Trending Top 10", text)
|
||||
self.assertNotIn("Skills Trending 变化", text)
|
||||
|
||||
def test_delta_pad_keeps_large_clusters_merged_and_fills_limit(self):
|
||||
"""补榜按 source 合并态取条,大 cluster 不得撑爆 flat 预算导致短榜。"""
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
def cluster(source: str, n: int, installs: int) -> dict:
|
||||
titles = [f"t{i}" for i in range(n)]
|
||||
return {
|
||||
"id": f"{source}/{titles[0]}",
|
||||
"title": titles[0],
|
||||
"source": source,
|
||||
"installs": installs,
|
||||
"installs_fmt": str(installs),
|
||||
"cluster": True,
|
||||
"cluster_count": n,
|
||||
"cluster_skills": titles,
|
||||
"cluster_titles": ", ".join(titles[:4]) + "…",
|
||||
"link": f"https://skills.sh/{source}/{titles[0]}",
|
||||
"description": f"{source} cluster",
|
||||
}
|
||||
|
||||
full = [cluster(f"big{i}/pkg", 20, 1000 - i) for i in range(1, 5)] + [
|
||||
{
|
||||
"id": f"other{n}/pkg/skill",
|
||||
"title": "skill",
|
||||
"source": f"other{n}/pkg",
|
||||
"installs": 50 - n,
|
||||
"installs_fmt": str(50 - n),
|
||||
"link": f"https://skills.sh/other{n}/pkg/skill",
|
||||
"description": f"other {n}",
|
||||
}
|
||||
for n in range(1, 12)
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=full,
|
||||
hot_full=[],
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
)
|
||||
self.assertIn("Skills Trending Top 10", text)
|
||||
self.assertIn("20 skills", text)
|
||||
self.assertIn("other6/pkg", text)
|
||||
|
||||
def test_delta_pad_recent_blocks_same_source_not_just_primary_id(self):
|
||||
"""周去重按 source:换同仓另一个 skill id 不得再上榜。"""
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
titles = [f"t{i}" for i in range(20)]
|
||||
full = [
|
||||
{
|
||||
"id": f"big/pkg/{titles[0]}",
|
||||
"title": titles[0],
|
||||
"source": "big/pkg",
|
||||
"installs": 999,
|
||||
"installs_fmt": "999",
|
||||
"cluster": True,
|
||||
"cluster_count": 20,
|
||||
"cluster_skills": titles,
|
||||
"cluster_titles": ", ".join(titles[:4]) + "…",
|
||||
"link": f"https://skills.sh/big/pkg/{titles[0]}",
|
||||
"description": "big cluster",
|
||||
},
|
||||
*[
|
||||
{
|
||||
"id": f"other{n}/pkg/skill",
|
||||
"title": "skill",
|
||||
"source": f"other{n}/pkg",
|
||||
"installs": 50 - n,
|
||||
"installs_fmt": str(50 - n),
|
||||
"link": f"https://skills.sh/other{n}/pkg/skill",
|
||||
"description": f"other {n}",
|
||||
}
|
||||
for n in range(1, 12)
|
||||
],
|
||||
]
|
||||
# 昨日展示的是同 source 另一 skill id(非今日 primary)
|
||||
recent = {f"big/pkg/{titles[5]}"}
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=full,
|
||||
hot_full=[],
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
recent_trending=recent,
|
||||
)
|
||||
self.assertIn("Skills Trending Top 10", text)
|
||||
self.assertNotIn("big/pkg", text)
|
||||
self.assertIn("other1/pkg", text)
|
||||
|
||||
def test_delta_pad_hot_recent_unions_trending_history_by_source(self):
|
||||
"""Skills Hot 周去重合并 Trending 历史:隔日换榜也不能同 source 再出现。"""
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
hot_full = [
|
||||
{
|
||||
"id": "101-skills/skills/ai-music",
|
||||
"title": "ai-music",
|
||||
"source": "101-skills/skills",
|
||||
"installs": 200,
|
||||
"installs_fmt": "200",
|
||||
"link": "https://skills.sh/101-skills/skills/ai-music",
|
||||
"description": "hot candidate",
|
||||
},
|
||||
{
|
||||
"id": "fresh/src/skill",
|
||||
"title": "skill",
|
||||
"source": "fresh/src",
|
||||
"installs": 100,
|
||||
"installs_fmt": "100",
|
||||
"link": "https://skills.sh/fresh/src/skill",
|
||||
"description": "fresh",
|
||||
},
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=[],
|
||||
hot_full=hot_full,
|
||||
trending_limit=10,
|
||||
hot_limit=10,
|
||||
pad=True,
|
||||
recent_trending={"101-skills/skills/ai-video-generation"},
|
||||
recent_hot=set(),
|
||||
)
|
||||
self.assertIn("Skills Hot Top 1", text)
|
||||
self.assertIn("fresh/src", text)
|
||||
self.assertNotIn("101-skills", text)
|
||||
|
||||
def test_delta_pad_hot_excludes_trending_by_source(self):
|
||||
"""同日 Hot 补榜按 source 避开 Trending,而非展开全部 cluster skill id。"""
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
trending_full = [
|
||||
{
|
||||
"id": "same/src/a",
|
||||
"title": "a",
|
||||
"source": "same/src",
|
||||
"installs": 100,
|
||||
"installs_fmt": "100",
|
||||
"link": "https://skills.sh/same/src/a",
|
||||
"description": "trending item",
|
||||
}
|
||||
]
|
||||
hot_full = [
|
||||
{
|
||||
"id": "same/src/b",
|
||||
"title": "b",
|
||||
"source": "same/src",
|
||||
"installs": 90,
|
||||
"installs_fmt": "90",
|
||||
"link": "https://skills.sh/same/src/b",
|
||||
"description": "hot twin",
|
||||
},
|
||||
{
|
||||
"id": "fresh/src/skill",
|
||||
"title": "skill",
|
||||
"source": "fresh/src",
|
||||
"installs": 80,
|
||||
"installs_fmt": "80",
|
||||
"link": "https://skills.sh/fresh/src/skill",
|
||||
"description": "fresh hot",
|
||||
},
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=trending_full,
|
||||
hot_full=hot_full,
|
||||
trending_limit=10,
|
||||
hot_limit=10,
|
||||
pad=True,
|
||||
)
|
||||
self.assertIn("Skills Hot Top 1", text)
|
||||
self.assertIn("fresh/src", text)
|
||||
self.assertNotIn("same/src/b", text)
|
||||
|
||||
def test_delta_pad_uses_large_pool_when_recent_excludes_top(self):
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
full_small = [
|
||||
{
|
||||
"id": f"seen/src/s{i}",
|
||||
"title": f"s{i}",
|
||||
"source": "seen/src",
|
||||
"installs": 100 - i,
|
||||
"installs_fmt": str(100 - i),
|
||||
"link": f"https://skills.sh/seen/src/s{i}",
|
||||
"description": f"seen {i}",
|
||||
}
|
||||
for i in range(1, 11)
|
||||
]
|
||||
full_large = [
|
||||
{
|
||||
"id": f"fresh/src{n}/skill",
|
||||
"title": "skill",
|
||||
"source": f"fresh/src{n}",
|
||||
"installs": 50 - n,
|
||||
"installs_fmt": str(50 - n),
|
||||
"link": f"https://skills.sh/fresh/src{n}/skill",
|
||||
"description": f"fresh {n}",
|
||||
}
|
||||
for n in range(1, 11)
|
||||
]
|
||||
recent = {f"seen/src/s{i}" for i in range(1, 11)}
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
small = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=full_small,
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
recent_trending=recent,
|
||||
)
|
||||
large = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=full_large,
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
recent_trending=recent,
|
||||
)
|
||||
self.assertNotIn("Skills Trending Top 10", small)
|
||||
self.assertIn("Skills Trending Top 10", large)
|
||||
self.assertIn("fresh/src1", large)
|
||||
|
||||
def test_delta_pad_fills_github_to_limit(self):
|
||||
from daily.format_wecom import build_github_delta_sections
|
||||
|
||||
movement = {"github_trending_moves": [], "github_emerging_moves": [], "github_topic_moves": []}
|
||||
full = [
|
||||
{
|
||||
"repo": f"org/r{i}",
|
||||
"url": f"https://github.com/org/r{i}",
|
||||
"language": "Go",
|
||||
"stars_today_fmt": "100",
|
||||
"total_stars_fmt": "1K",
|
||||
"description": f"repo {i}",
|
||||
"desc_short": f"repo {i}",
|
||||
}
|
||||
for i in range(1, 12)
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
text = build_github_delta_sections(
|
||||
movement,
|
||||
topic_name="llm",
|
||||
github_trending=full,
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
)
|
||||
self.assertIn("GitHub Trending Top 10", text)
|
||||
self.assertNotIn("GitHub Trending 变化", text)
|
||||
|
||||
def test_delta_pad_github_unions_recent_across_boards(self):
|
||||
"""GitHub 三榜共用周去重:Trending 出过的 repo,新兴/Topic 不得再出。"""
|
||||
from daily.format_wecom import build_github_delta_sections
|
||||
|
||||
movement = {"github_trending_moves": [], "github_emerging_moves": [], "github_topic_moves": []}
|
||||
shared = {
|
||||
"repo": "seen/repo",
|
||||
"url": "https://github.com/seen/repo",
|
||||
"language": "Go",
|
||||
"stars_today_fmt": "100",
|
||||
"total_stars_fmt": "1K",
|
||||
"created_at": "2026-07-01",
|
||||
"description": "already shown",
|
||||
"desc_short": "already shown",
|
||||
}
|
||||
fresh = {
|
||||
"repo": "fresh/repo",
|
||||
"url": "https://github.com/fresh/repo",
|
||||
"language": "Go",
|
||||
"stars_today_fmt": "90",
|
||||
"total_stars_fmt": "900",
|
||||
"created_at": "2026-07-02",
|
||||
"description": "fresh",
|
||||
"desc_short": "fresh",
|
||||
}
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
text = build_github_delta_sections(
|
||||
movement,
|
||||
topic_name="llm",
|
||||
github_trending=[],
|
||||
github_emerging=[shared, fresh],
|
||||
github_topic=[shared],
|
||||
emerging_limit=5,
|
||||
topic_limit=5,
|
||||
pad=True,
|
||||
recent_board_keys={"github_trending": {"seen/repo"}},
|
||||
)
|
||||
self.assertIn("fresh/repo", text)
|
||||
self.assertNotIn("seen/repo", text)
|
||||
|
||||
def test_delta_pad_skips_recent_skills(self):
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
full = [
|
||||
{
|
||||
"id": f"x/y/s{i}",
|
||||
"title": f"s{i}",
|
||||
"source": "x/y",
|
||||
"installs": 100 - i,
|
||||
"installs_fmt": str(100 - i),
|
||||
"link": f"https://skills.sh/x/y/s{i}",
|
||||
"description": f"skill {i}",
|
||||
}
|
||||
for i in range(4, 6)
|
||||
] + [
|
||||
{
|
||||
"id": "fresh/src/skill",
|
||||
"title": "skill",
|
||||
"source": "fresh/src",
|
||||
"installs": 50,
|
||||
"installs_fmt": "50",
|
||||
"link": "https://skills.sh/fresh/src/skill",
|
||||
"description": "fresh skill",
|
||||
}
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=full,
|
||||
hot_full=[],
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
# 同仓历史 skill id → 整仓 source 去重;仅保留其它 source
|
||||
recent_trending={f"x/y/s{i}" for i in range(1, 4)},
|
||||
)
|
||||
self.assertIn("Skills Trending Top 1", text)
|
||||
self.assertIn("fresh/src", text)
|
||||
self.assertNotIn("x/y", text)
|
||||
|
||||
def test_delta_pad_skips_recent_github(self):
|
||||
from daily.format_wecom import build_github_delta_sections
|
||||
|
||||
movement = {"github_trending_moves": [], "github_emerging_moves": [], "github_topic_moves": []}
|
||||
full = [
|
||||
{
|
||||
"repo": f"org/r{i}",
|
||||
"url": f"https://github.com/org/r{i}",
|
||||
"language": "Go",
|
||||
"total_stars_fmt": "1K",
|
||||
"description": f"repo {i}",
|
||||
"desc_short": f"repo {i}",
|
||||
}
|
||||
for i in range(1, 6)
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
text = build_github_delta_sections(
|
||||
movement,
|
||||
topic_name="llm",
|
||||
github_trending=full,
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
recent_board_keys={"github_trending": {f"org/r{i}" for i in range(1, 4)}},
|
||||
)
|
||||
self.assertIn("GitHub Trending Top 2", text)
|
||||
self.assertIn("org/r4", text)
|
||||
self.assertNotIn("org/r1", text)
|
||||
|
||||
def test_delta_pad_skips_recent_github_moves(self):
|
||||
"""异动新入榜若昨日企微已展示,pad 时仍应排除(不只滤补榜)。"""
|
||||
from daily.format_wecom import build_github_delta_sections
|
||||
|
||||
movement = {
|
||||
"github_trending_moves": [
|
||||
{
|
||||
"repo": "vinta/awesome-python",
|
||||
"url": "https://github.com/vinta/awesome-python",
|
||||
"language": "Python",
|
||||
"total_stars_fmt": "308K",
|
||||
"description": "list",
|
||||
}
|
||||
],
|
||||
"github_emerging_moves": [],
|
||||
"github_topic_moves": [],
|
||||
}
|
||||
full = [
|
||||
{
|
||||
"repo": "fresh/repo",
|
||||
"url": "https://github.com/fresh/repo",
|
||||
"language": "Go",
|
||||
"total_stars_fmt": "1K",
|
||||
"description": "fresh",
|
||||
"desc_short": "fresh",
|
||||
}
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
text = build_github_delta_sections(
|
||||
movement,
|
||||
topic_name="llm",
|
||||
github_trending=full,
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
recent_board_keys={"github_trending": {"vinta/awesome-python"}},
|
||||
)
|
||||
self.assertIn("fresh/repo", text)
|
||||
self.assertNotIn("vinta/awesome-python", text)
|
||||
|
||||
def test_load_recent_board_keys_from_data_json(self):
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from daily.delta import load_recent_board_keys
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp)
|
||||
payload = {
|
||||
"data": {
|
||||
"date": "2026-07-09",
|
||||
"movement_baseline": {
|
||||
"skills_trending": [
|
||||
{"id": "a/b/raw", "title": "raw", "source": "a/b"},
|
||||
],
|
||||
"skills_hot": [],
|
||||
"github_trending": [{"repo": "org/raw"}],
|
||||
"github_emerging": [],
|
||||
"github_topic": [],
|
||||
},
|
||||
"wecom_shown_keys": {
|
||||
"skills_trending": ["a/b/foo"],
|
||||
"github_trending": ["org/bar"],
|
||||
},
|
||||
}
|
||||
}
|
||||
(out / "2026-07-09.data.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||
with patch("daily.board_history.OUTPUT_DIR", out):
|
||||
recent = load_recent_board_keys("2026-07-10", lookback_days=7)
|
||||
self.assertIn("a/b/foo", recent["skills_trending"])
|
||||
self.assertIn("org/bar", recent["github_trending"])
|
||||
self.assertNotIn("a/b/raw", recent["skills_trending"])
|
||||
self.assertNotIn("org/raw", recent["github_trending"])
|
||||
|
||||
|
||||
class PushGateTests(unittest.TestCase):
|
||||
def test_push_when_board_has_moves(self):
|
||||
from daily.push_gate import evaluate_push_gate
|
||||
|
||||
gate = evaluate_push_gate(
|
||||
movement={"skills_trending_moves": [{"id": "a/b/c"}], "skills_hot_moves": [], "github_trending_moves": [], "github_emerging_moves": [], "github_topic_moves": []},
|
||||
ai_news_items=[],
|
||||
cn_ai_news_items=[],
|
||||
featured_pick=None,
|
||||
)
|
||||
self.assertTrue(gate.should_push)
|
||||
self.assertIn("board_moves", gate.reasons)
|
||||
|
||||
def test_silent_when_all_empty(self):
|
||||
from daily.push_gate import evaluate_push_gate
|
||||
|
||||
gate = evaluate_push_gate(
|
||||
movement={
|
||||
"skills_trending_moves": [],
|
||||
"skills_hot_moves": [],
|
||||
"github_trending_moves": [],
|
||||
"github_emerging_moves": [],
|
||||
"github_topic_moves": [],
|
||||
},
|
||||
ai_news_items=[],
|
||||
cn_ai_news_items=[],
|
||||
featured_pick=None,
|
||||
)
|
||||
self.assertFalse(gate.should_push)
|
||||
self.assertTrue(gate.silent)
|
||||
|
||||
def test_force_push_overrides_silent(self):
|
||||
from daily.push_gate import evaluate_push_gate
|
||||
|
||||
with patch("daily.push_gate.force_push", return_value=True):
|
||||
gate = evaluate_push_gate(
|
||||
movement={
|
||||
"skills_trending_moves": [],
|
||||
"skills_hot_moves": [],
|
||||
"github_trending_moves": [],
|
||||
"github_emerging_moves": [],
|
||||
"github_topic_moves": [],
|
||||
},
|
||||
ai_news_items=[],
|
||||
cn_ai_news_items=[],
|
||||
featured_pick=None,
|
||||
)
|
||||
self.assertTrue(gate.should_push)
|
||||
self.assertIn("force_push", gate.reasons)
|
||||
|
||||
|
||||
class WebhookSilentTests(unittest.TestCase):
|
||||
def test_skip_when_silent(self):
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from daily.webhook import should_skip_push
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
data = root / "2026-07-10.data.json"
|
||||
data.write_text(
|
||||
json.dumps(
|
||||
{"meta": {"push_gate": {"should_push": False, "silent": True, "reasons": []}}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
report = root / "2026-07-10.wecom.md"
|
||||
report.write_text("📰 test", encoding="utf-8")
|
||||
self.assertTrue(should_skip_push(report))
|
||||
|
||||
|
||||
class E2ESmokeTests(unittest.TestCase):
|
||||
def test_delta_mode_no_full_top_label(self):
|
||||
from daily.format_wecom import replace_wecom_board_sections
|
||||
|
||||
md = "📰 **早报 · 2026-07-10**\n\n🌍 **国际 AI · 精选 1**\n1. [x](https://a.com)\n"
|
||||
movement = {
|
||||
"skills_trending_moves": [
|
||||
{
|
||||
"id": "a/b/c",
|
||||
"rank": 2,
|
||||
"title": "c",
|
||||
"source": "a/b",
|
||||
"installs": 1,
|
||||
"link": "https://skills.sh/a/b/c",
|
||||
"description": "d",
|
||||
}
|
||||
],
|
||||
"skills_hot_moves": [],
|
||||
"github_trending_moves": [],
|
||||
"github_emerging_moves": [],
|
||||
"github_topic_moves": [],
|
||||
}
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
out = replace_wecom_board_sections(
|
||||
md,
|
||||
mode="delta",
|
||||
movement=movement,
|
||||
trending=[],
|
||||
hot=[],
|
||||
topic_name="llm",
|
||||
pad=False,
|
||||
)
|
||||
self.assertIn("Skills Trending 变化", out)
|
||||
self.assertNotIn("Skills Trending Top", out)
|
||||
|
||||
|
||||
class SyncMovementGithubTests(unittest.TestCase):
|
||||
def test_sync_copies_localized_description(self):
|
||||
from daily.generate import _sync_movement_github_descriptions
|
||||
|
||||
movement = {
|
||||
"github_trending_moves": [{"repo": "a/b", "description": "english"}],
|
||||
"github_emerging_moves": [],
|
||||
"github_topic_moves": [],
|
||||
}
|
||||
_sync_movement_github_descriptions(
|
||||
movement,
|
||||
github_trending=[{"repo": "a/b", "description": "中文描述"}],
|
||||
github_emerging=[],
|
||||
github_topic=[],
|
||||
)
|
||||
self.assertEqual(movement["github_trending_moves"][0]["description"], "中文描述")
|
||||
Reference in New Issue
Block a user