feat: 军事科技每日资讯推送系统 - Docker部署 + 日志系统 + 数据目录重组

This commit is contained in:
poiuy
2026-07-12 20:01:02 +08:00
commit 54ca4b1b6a
267 changed files with 47047 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# LLM API 配置
DEEPSEEK_API_KEY=sk-or-v1-...
# Feishu Webhook
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/...
# 可选:添加 token 的免费限额更高,不加也基本够用
# GITHUB_TOKEN=
# JINA_API_KEY=
+42
View File
@@ -0,0 +1,42 @@
# 敏感文件
.env
!.env.example
# 本地配置(使用时从 config.json.example 拷贝并修改)
config.json
!config.json.example
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
.venv
# 测试
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
# IDE
.idea/
.vscode/
*.swp
*.swo
# 数据文件
news-data/
*.log
# Claude/其他代理数据
.omc/
.claude/
# Git worktrees
.worktrees/
+28
View File
@@ -0,0 +1,28 @@
# AI 每日资讯推送系统
AI 驱动的 RSS 新闻聚合与推送系统,支持 400+ 信息源,使用 LLM 智能评分筛选,定时推送到 Discord/企业微信。
当前阶段:MVP 已完成,支持 RSS 抓取、LLM 评分、定时推送、即时推送。
## 技术栈
- 语言:Python 3.10+
- 框架:asyncio
- 依赖:feedparser, aiohttp, croniter, openai
- 构建:pip
- 测试:pytest
## 开发规则
1. 任何代码改动如果与 docs/ 下的文档不一致,必须同步更新对应文档
2. 产品决策变更(功能取舍、交互调整、设计修改)和任务进度 写入 docs/plan.md 的`## 技术决策记录``## 开发进度`
3. 不确定的产品问题先问用户,不要自行决定
4. 敏感信息(API Keys、Webhook URLs)通过环境变量管理,不硬编码
5. config 有更新需要即时更新对应文档
## 文档索引
- 技术架构 → [docs/tech-spec.md](docs/tech-spec.md)
- 开发计划 → [docs/plan.md](docs/plan.md)
- 早报扩展板块设计(GitHub / HN / 洞察)→ [docs/extra-sections-design.md](docs/extra-sections-design.md)
- 实施计划归档 → [docs/superpowers/plans/](docs/superpowers/plans/)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+598
View File
@@ -0,0 +1,598 @@
<h1 align="center">AI Daily</h1>
<p align="center"><i>筛选值得关注的 AI 信号</i></p>
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License: MIT" /></a>
<img src="https://img.shields.io/badge/python-3.12%2B-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.12+" />
<img src="https://img.shields.io/badge/uv-managed-DE5FE9?style=flat-square&logo=uv&logoColor=white" alt="uv managed" />
<img src="https://img.shields.io/badge/RSS-400%2B%20sources-FFA500?style=flat-square&logo=rss&logoColor=white" alt="RSS 400+" />
<img src="https://img.shields.io/badge/deploy-systemd-orange?style=flat-square&logo=linux&logoColor=white" alt="systemd" />
</p>
[![AI Daily Banner](https://cdn.yeekal.com/yee/visuals/ai-daily-cover.webp)](https://yeekal.com/daily/)
<p align="center">AI 驱动的资讯聚合与推送系统|<b>RSS · GitHub Trending · Hacker News</b> 三大板块|LLM 智能评分|推送到 Discord / 飞书</p>
---
## 核心特性
- 🗞️ **三大内容板块** —— RSS 资讯(400+ 源)+ GitHub Trending + Hacker News 热帖,从媒体、开源、社区三个维度立体捕获 AI 动态
- 🧠 **LLM 智能筛选** —— 评分过滤 + 跨日去重,只留下值得读的
-**即时推送** —— 热点新闻(≥90 分)实时触达,重大发布不错过
- 📬 **每日汇总** —— 定时早晚报,早报叠加跨板块洞察段
- 🔌 **多平台推送** —— 飞书、Discord 开箱即用,可扩展自定义平台
- 🛠️ **零运维部署** —— systemd timer 一键安装,开机自启、故障重启
---
## 三大内容板块
### 📰 RSS 资讯聚合
聚合全球主流 AI 媒体、博客、Twitter 账号,默认 OPML 包含约 420 个优质源(源自 [BestBlogs](https://github.com/ginobefun/BestBlogs))。LLM 逐条评分,只保留高质量内容。
- 默认 60 分钟轮询,异步并发抓取
- 评分维度:相关度、信息密度、时效性
- 跨日上下文去重,同一事件不重复推送
### ⭐ GitHub Trending
抓取 GitHub trending 页面,LLM 深读 README / topics / metadata,从昙花一现的玩具仓库里挑出真正值得关注的项目。
- 从 top 10 候选中精选 3 个(可配置)
- 输出 deep-dive 摘要,附技术亮点与上手建议
- 历史去重索引,推过的项目不再出现
### 💬 Hacker News 热帖
跟踪 HN 首页 AI 相关讨论,整合**外链正文 + 顶层评论树**,产出"内容总结 + 社区观点"双段式摘要。
- 轻量 LLM 从首页 30 条中选出最值得读的故事
- 抓取 L1 顶层评论 + L2 关键回复,字符预算受控
- 外链通过 Jina Reader 拉取 markdown 正文
## 系统架构
```mermaid
flowchart TB
subgraph Sources["📥 数据源"]
RSS["RSS Feeds<br/>400+ sources"]
GH["GitHub Trending"]
HN["Hacker News<br/>Front Page"]
end
subgraph Fetch["⚙️ Fetch 阶段"]
F1["RSS Fetcher<br/>asyncio + feedparser"]
F2["GH Scraper<br/>README deep-dive"]
F3["HN Crawler<br/>Algolia + Jina Reader"]
end
subgraph LLMStage["🧠 LLM 评分与摘要"]
Score["score / score_batch"]
Digest["digest / immediate_push"]
Insight["跨板块 insights"]
end
subgraph Store["💾 存储"]
Files["news-data/<br/>fetch-*.json<br/>push-*.md"]
end
subgraph PushStage["📤 推送渠道"]
Discord["Discord Webhook"]
Feishu["飞书 Webhook"]
end
RSS --> F1 --> Score
GH --> F2 --> Score
HN --> F3 --> Score
Score --> Digest
Score --> Insight
Digest --> Files
Insight --> Files
Files --> Discord
Files --> Feishu
```
**调度说明**
- `dnews-fetch.service`(默认每 60 分钟):抓取 RSS → 评分 → 命中 ≥90 分立即推送
- `dnews-push.service`(按 `push_cron`):生成 digest 推送;当天最早一次额外触发 GitHub / HN / 跨板块洞察
## 快速开始
### 环境要求
- Python 3.12+
- [uv](https://docs.astral.sh/uv/) (Python 包管理器)
- Linux + systemd(推荐使用一键部署)
### 1. 配置环境变量
在项目根目录创建 `.env` 文件,添加以下配置:
```bash
# LLM API OpenAI API 兼容接口)
DEEPSEEK_API_KEY=your_api_key_here
# 飞书 Webhook
FEISHU_WEBHOOK_URL=your_feishu_webhook_url_here
```
从模板拷贝一份本地配置(`config.json` 已加入 `.gitignore`,不会提交):
```bash
cp config.json.example config.json
```
然后在 `config.json` 中修改 `llm``push`
```json
{
"llm": {
"provider": "<whatever>", # openai compatiable
"model": "<model id>",
"baseUrl": "<base url>",
"apiKeyName": "DEEPSEEK_API_KEY", #your api key name in .env>
},
"push": {
"feishu": {
"enabled": true,
"apiKeyName": "FEISHU_WEBHOOK_URL"
}
},
}
```
**获取飞书群机器人 Webhook**
详细教程参考:[我用 RSS + LLM 搭建了一个 AI 热点追踪系统](https://yeekal.com//ai/ai-daily-news-tracker)
1. 群组右上角设置 → 群机器人 → 添加机器人
2. 选择 自定义机器人 → 点击 添加 → 复制 Webhook URL
**获取 Discord Webhook**
1. 进入 Discord 服务器设置 →
2. 整合 → Webhooks
2. 创建新 Webhook,复制 URL
### 2. 一键部署(推荐)
将程序作为 systemd timer 部署,由系统负责定时触发、故障重启、开机自启。
```bash
./scripts/install.sh
```
脚本会自动同步依赖、安装 systemd 服务并按 `config.json` 中的调度配置启动定时任务,**机器重启后自动恢复**。安装成功后无需额外操作。
### 3. 手动运行(可选)
若不使用 systemd,也可以手动运行程序。先同步依赖(uv 会自动创建 `.venv`):
```bash
uv sync
```
程序提供的子命令:
```bash
uv run python -m src.main check # 校验 LLM 接口可达性(部署期使用)
uv run python -m src.main fetch # 单次抓取后退出(systemd timer 调用)
uv run python -m src.main push # 单次推送后退出(systemd timer 调用)
uv run python -m src.main loop # 长跑模式(本地开发/调试用)
uv run python -m src.main github # 单跑 GitHub Trending 板块,打印不推送
uv run python -m src.main hackernews # 单跑 Hacker News 板块,打印不推送
```
首次运行会自动创建 `news-data/` 目录并开始抓取数据。
> 若未配置推送渠道,则可以在 news-data 目录查看生成的push信息
---
## 系统服务管理
部署完成后,使用以下命令管理服务。
### 常用命令
安装后 `daily-news` 进入系统 PATH,可在任意目录调用:
```bash
daily-news status [N] # 查看 timer/service 状态 + 最近 N 行日志(默认 15)
daily-news logs # 实时跟随日志(Ctrl+C 退出)
daily-news start # 启动两个 timer
daily-news stop # 停止两个 timer
daily-news restart # 重启两个 timer(仅重置调度,不立即触发任务)
daily-news help # 用法说明
```
手动立即触发一次任务(不影响下次调度):
```bash
sudo systemctl start dnews-fetch.service
sudo systemctl start dnews-push.service
```
### 修改配置后
```bash
./scripts/install.sh # 重新跑一次即可,幂等(重新渲染单元 + restart timer
```
修改 `config.json` 中的 `schedule``log.retention_days` 等需要重装;修改 `.env` 只需 `daily-news restart`
### 卸载
```bash
./scripts/uninstall.sh
```
卸载会移除 systemd 单元、`/usr/local/bin/daily-news` 和日志保留 drop-in**不会**删除 `news-data/` 数据。
### 日志保留
日志通过 systemd journald 命名空间 `dnews` 隔离,保留天数由 `config.json` 中的 `log.retention_days` 控制(默认 7 天)。不影响系统其他服务的日志。
### 日志查询
```bash
daily-news logs # 实时跟随两个 service 的日志
daily-news status [N] # 查看状态 + 最近 N 行日志(默认 15)
journalctl --namespace=dnews -f # 实时跟随命名空间内全部日志
journalctl --namespace=dnews -u dnews-fetch -f # 仅跟随 fetch service
journalctl --namespace=dnews -u dnews-push -f # 仅跟随 push service
journalctl --namespace=dnews --since "1 hour ago" # 查询近 1 小时日志
journalctl --namespace=dnews --since today # 查询今日日志
journalctl --namespace=dnews -p err # 仅查询 error 级别及以上
journalctl --namespace=dnews --vacuum-time=1s # 手动清空命名空间日志
```
---
## 配置详解(config.json
完整的配置文件结构如下,每个字段都有详细说明:
```json
{
// 订阅源管理
"sources": {
"base_opml": "resources/rss.opml", // 基础OPML文件,包含400+预设源
"add": [ // 自定义添加的RSS源
{
"title": "OpenAI News",
"xmlUrl": "https://openai.com/news/rss.xml",
"category": "AI"
}
],
"block": [ // 手动屏蔽的源,精确匹配xmlUrl
{
"title": "Google Developers Blog",
"xmlUrl": "https://developers.googleblog.com/feeds/posts/default"
}
],
"block_domains": ["*.substack.com", "*.youtube.com"] // 域名屏蔽,支持通配符
},
// 内容过滤
"filter": {
"min_score": 60, // 最低评分阈值,低于此分不推送
"hot_threshold": 90, // 热点阈值,达到立即即时推送
"context_days": 3, // 汇总时参考的历史天数
"keep_days": 7, // 数据保留天数
"push_context_days": 5, // 汇总推送去重的上下文有效天数
"no_content_marker": "[NO_NEW_CONTENT]" // LLM返回的无内容标记,用于判断是否跳过推送
},
// 日志配置(仅对 systemd 部署生效)
"log": {
"retention_days": 7 // journald 命名空间 dnews 的日志保留天数
},
// 调度配置
"schedule": {
"fetch_interval_minutes": 30, // RSS抓取间隔(分钟)
"fetch_lookback_minutes": 120, // RSS冗余缓存时间(分钟),必须大于fetch_interval_minutes,用于防止RSS延迟导致漏读
"push_cron": ["0 8 * * *", "0 17 * * *"], // 定时推送cron表达式
"timezone_hours": 8 // 时区偏移(8=北京时间)
},
// 抓取配置
"fetch": {
"max_workers": 10, // 最大并发数
"timeout": 10 // 单请求超时(秒)
},
// LLM配置
"llm": {
"provider": "openai", // 提供商类型,openai只是知名该api接口时openai接口兼容,代码中并无实际使用
"model": "x-ai/grok-4.1-fast", // 模型名称
"baseUrl": "https://openrouter.ai/api/v1", // API端点
"apiKeyName": "OPENROUTER_API_KEY", // 环境变量名
"max_prompt_chars": 128000, // 单次prompt最大字符数
"max_concurrent_batches": 3, // 最大并发批次数
"prompts": { // prompt文件路径
"score": "prompts/score.txt",
"score_batch": "prompts/score_batch.txt",
"immediate_push": "prompts/immediate_push.txt",
"digest": "prompts/digest.txt"
}
},
// 推送配置
"push": {
"discord": {
"enabled": true, // 是否启用
"apiKeyName": "DISCORD_WEBHOOK_URL" // Webhook环境变量名
},
"feishu": {
"enabled": false,
"apiKeyName": "FEISHU_WEBHOOK_URL"
}
}
}
```
### sources —— 订阅源管理
| 字段 | 类型 | 说明 |
|------|------|------|
| `base_opml` | string | 基础 OPML 文件路径,包含 400+ 预设 RSS 源 |
| `add` | array | 自定义添加的 RSS 源,结构为 `{title, xmlUrl, category}` |
| `block` | array | 手动屏蔽的 RSS 源,精确匹配 `xmlUrl` |
| `block_domains` | array | 域名级别屏蔽,支持通配符(如 `*.substack.com` |
### filter —— 内容过滤
| 字段 | 类型 | 说明 |
|------|------|------|
| `min_score` | number | 最低评分阈值,低于此分数的内容不参与推送(默认60) |
| `hot_threshold` | number | 热点阈值,达到此分数立即触发即时推送(默认90) |
| `context_days` | number | 上下文天数,汇总推送时参考的fetch数据历史天数(默认3天) |
| `keep_days` | number | 数据保留天数,超过天数的 JSON 文件会被清理 |
| `push_context_days` | number | 汇总推送去重的历史push文件有效天数(默认5天) |
| `no_content_marker` | string | LLM 返回的无内容标记,当推送内容包含此字符串时跳过推送(默认"[NO_NEW_CONTENT]" |
### log —— 日志配置
仅对 `scripts/install.sh` 部署的 systemd 服务生效。
| 字段 | 类型 | 说明 |
|------|------|------|
| `retention_days` | number | journald 命名空间 `dnews` 的日志保留天数(默认 7 天)。修改后需要重跑 `./scripts/install.sh` |
### schedule —— 调度配置
| 字段 | 类型 | 说明 |
|------|------|------|
| `fetch_interval_minutes` | number | RSS 抓取间隔,单位分钟(默认30分钟) |
| `fetch_lookback_minutes` | number | RSS 冗余缓存时间(分钟),必须大于 `fetch_interval_minutes`,用于防止 RSS 延迟导致漏读(默认120分钟) |
| `push_cron` | array | 定时推送的 cron 表达式数组,支持多个时间点。**当天最早那次推送**自动作为「早报」触发 GitHub / Hacker News / 跨板块洞察三段;其余时段为默认 RSS digest。若只配置一条 cron,则每次推送都视为早报 |
| `timezone_hours` | number | 时区偏移小时数,用于时间显示(8 = UTC+8 北京时间) |
**cron 表达式说明:**
| 表达式 | 含义 |
|--------|------|
| `0 8 * * *` | 每天早上 8:00 |
| `0 17 * * *` | 每天下午 5:00 |
| `0 9,17 * * *` | 每天早上 9:00 和下午 5:00 |
格式:`minute hour day month weekday`
### fetch —— 抓取配置
| 字段 | 类型 | 说明 |
|------|------|------|
| `max_workers` | number | 最大并发数,同时抓取的 RSS 源数量 |
| `timeout` | number | 单个请求超时时间,单位秒 |
### llm —— 大语言模型配置
| 字段 | 类型 | 说明 |
|------|------|------|
| `provider` | string | LLM 提供商(支持 openai 兼容接口) |
| `model` | string | 模型名称,如 `x-ai/grok-4.1-fast` |
| `baseUrl` | string | API 端点,如 `https://openrouter.ai/api/v1` |
| `apiKeyName` | string | 环境变量名称,系统会自动读取其值 |
| `max_prompt_chars` | number | 单次 prompt 最大字符数,用于分批控制 |
| `max_concurrent_batches` | number | 最大并发批次数 |
| `prompts` | object | prompt 文件路径配置 |
### push —— 推送平台配置
| 字段 | 类型 | 说明 |
|------|------|------|
| `discord.enabled` | boolean | 是否启用 Discord 推送 |
| `discord.apiKeyName` | string | Discord Webhook 的环境变量名 |
| `feishu.enabled` | boolean | 是否启用飞书推送 |
| `feishu.apiKeyName` | string | 飞书 Webhook 的环境变量名 |
### sections (早报扩展板块)
仅在当天最早一次 `push_cron` 触发时生效(即「早报」时段)。详细设计见 `docs/extra-sections-design.md`
#### sections.github_trending
- `enabled`: 是否启用
- `max_items`: LLM 最终选出的项目数上限(默认 3)
- `max_deep_dive`: 单次最多 deep-dive 的候选 repo 数(默认 10)
- `readme_max_chars`: README 截断长度(默认 10000)
- `history_file`: trending 去重索引文件路径
- `request_timeout`: HTTP 超时秒
- `tokenName`: GitHub token 环境变量名;不设时匿名调用(限 60 req/hr)
#### sections.hackernews
- `enabled`: 是否启用
- `select_k`: 轻 LLM 从首页 30 条中挑出的故事数(默认 1)
- `top_comments`: 每个故事抓取的**顶层(L1)评论**数上限(默认 30)
- `top_l2_per_l1`: 每条 L1 下挂的 L2 回复数上限(默认 3)
- `comment_max_chars`: 单条评论(L1 或 L2) markdown 字符上限(默认 2000)
- `comments_total_chars`: 整段评论树的总字符预算硬上限(默认 60000),防止离群 story 撑爆 prompt
- `link_content_max_chars`: 外链正文截断长度(默认 50000)
- `request_timeout`: HTTP 超时秒
- `algolia_base`: Algolia API 基址
- `jinaTokenName`: Jina Reader API key 环境变量名(默认 `JINA_API_KEY`);不设或对应环境变量为空时按匿名额度调用
#### sections.insights
- `enabled`: 是否启用跨板块洞察段
## 扩展指南
### 添加新的 RSS 源
`config.json``sources.add` 中添加:
```json
"add": [
{
"title": "我的自定义源",
"xmlUrl": "https://example.com/feed.xml",
"category": "AI"
}
]
```
### 添加新的推送平台
1.`src/push/` 目录下创建新文件,继承 `PushPlatform` 基类
2. 实现 `validate_config()``send()` 方法
3.`src/push/__init__.py` 中注册
### 修改评分逻辑
编辑 `prompts/score.txt`,调整评分标准和权重。
### 自定义 LLM 模型
修改 `config.json` 中的 `llm` 配置:
```json
"llm": {
"model": "anthropic/claude-3-opus",
"baseUrl": "https://openrouter.ai/api/v1",
"apiKeyName": "OPENROUTER_API_KEY"
}
```
## RSS 源说明
RSS 订阅源文件位于 `resources/rss.opml`,目前包含约 420 个订阅源。
RSS 订阅源初始整理自 [ginobefun/BestBlogs](https://github.com/ginobefun/BestBlogs),包含约 420 个 AI 领域优质信息源。
用户可自行配置 RSS 源文件,只需遵循 OPML 格,在 `config.json``sources.base_opml` 修改文件路径即可。 同时用户可修改 `sources.add`或者 `sources.block`以在不破换OMPL文件的前提下对rss源进行增加或者删除。格式示例:
```json
"sources": {
"base_opml": "resources/rss.opml",
"add": [
{
"title": "OpenAI News",
"xmlUrl": "https://openai.com/news/rss.xml",
"category": "AI"
},
{
"title": "Chrome for Developers",
"xmlUrl": "https://developer.chrome.com/static/blog/feed.xml",
"category": "Chrome"
}
],
"block": [
{
"title": "Google Developers Blog",
"xmlUrl": "https://developers.googleblog.com/feeds/posts/default"
},
{
"title": "Microsoft for Developers",
"xmlUrl": "https://devblogs.microsoft.com/landing"
},
{
"title": "ElevenLabs Blog",
"xmlUrl": "https://api.bestblogs.dev/feed/elevenLabsBlog"
}
],
"block_domains": ["*.substack.com", "*.youtube.com"]
}
```
## 常见问题 FAQ
<details>
<summary><b>LLM 调用费用大概多少?</b></summary>
取决于模型选择和源数量。以 OpenRouter 的 `x-ai/grok-4.1-fast`(便宜模型)为例,日均扫描 2000+ 条 RSS + GitHub + HN**单日成本约 ¥0.5-2 元**。可通过 `filter.min_score``llm.max_concurrent_batches``llm.max_prompt_chars` 进一步控制。
</details>
<details>
<summary><b>如何只跑某一板块进行调试?</b></summary>
```bash
uv run python -m src.main github # 只跑 GitHub Trending
uv run python -m src.main hackernews # 只跑 Hacker News
uv run python -m src.main fetch # 只跑 RSS fetch
```
打印结果到控制台,不触发实际推送。
</details>
<details>
<summary><b>抓取频率会不会被 RSS 站点封禁?</b></summary>
默认 30 分钟轮询一次,远低于大多数 RSS 服务的速率限制。`fetch.max_workers` 控制并发(默认 10),对单个站点的压力可忽略。
</details>
<details>
<summary><b>没配置推送渠道也能用吗?</b></summary>
可以。所有推送 markdown 都会落地到 `news-data/push-*.md`,即使所有推送平台 disabled 也可手动查看。
</details>
<details>
<summary><b>支持哪些 LLM 提供商?</b></summary>
任何 OpenAI API 兼容接口的服务:OpenAI、DeepSeek、OpenRouter、SiliconFlow、阿里云通义千问、Groq 等。修改 `config.json``llm.baseUrl` / `llm.model` / `llm.apiKeyName` 即可切换。
</details>
<details>
<summary><b>GitHub Trending / Hacker News 为什么不出现?</b></summary>
它们**只在当天最早一次 `push_cron` 触发时跑**(即「早报」时段)。若 `push_cron` 只配了一条 cron,则每次推送都视为早报。详见「配置详解 → schedule」一节。
</details>
<details>
<summary><b>数据存储在哪里?多久清理?</b></summary>
- 抓取数据:`news-data/fetch-YYYY-MM-DD.json`
- 推送 markdown`news-data/push-YYYY-MM-DD-HH-MM-SS.md`
- 通知归档:`news-data/notify-YYYY-MM-DD.md`
超过 `filter.keep_days`(默认 7 天)的文件会自动清理;卸载脚本**不会**删除 `news-data/`
</details>
---
## License
MIT License - see [LICENSE](LICENSE) file for details.
+120
View File
@@ -0,0 +1,120 @@
{
"filter": {
"min_score": 60,
"hot_threshold": 90,
"context_days": 2,
"keep_days": 7,
"push_context_days": 5,
"no_content_marker": "[NO_NEW_CONTENT]"
},
"log": {
"retention_days": 7
},
"schedule": {
"fetch_interval_minutes": 60,
"fetch_lookback_minutes": 120,
"push_cron": ["0 8 * * *", "0 17 * * *"],
"timezone_hours": 8
},
"sections": {
"github_trending": {
"enabled": true,
"max_items": 3,
"max_deep_dive": 10,
"readme_max_chars": 10000,
"history_file": "news-data/trending-history.json",
"request_timeout": 10,
"tokenName": "GITHUB_TOKEN"
},
"hackernews": {
"enabled": true,
"select_k": 3,
"top_comments": 50,
"top_l2_per_l1": 3,
"comment_max_chars": 2000,
"comments_total_chars": 80000,
"link_content_max_chars": 50000,
"request_timeout": 10,
"algolia_base": "https://hn.algolia.com/api/v1",
"jinaTokenName": "JINA_API_KEY"
},
"insights": {
"enabled": true
}
},
"fetch": {
"max_workers": 10,
"timeout": 10
},
"llm": {
"provider": "openai",
"model": "deepseek-v4-flash",
"baseUrl": "https://api.deepseek.com",
"apiKeyName": "DEEPSEEK_API_KEY",
"max_prompt_chars": 150000,
"max_concurrent_batches": 3,
"prompts": {
"score_batch": "prompts/score_batch.md",
"immediate_push": "prompts/immediate_push.md",
"digest": "prompts/digest.md",
"section_github": "prompts/section_github.md",
"section_hackernews_select": "prompts/section_hackernews_select.md",
"section_hackernews": "prompts/section_hackernews.md",
"insights": "prompts/insights.md"
}
},
"push": {
"discord": {
"enabled": false,
"apiKeyName": "DISCORD_WEBHOOK_URL"
},
"feishu": {
"enabled": true,
"apiKeyName": "FEISHU_WEBHOOK_URL"
}
},
"sources": {
"base_opml": "resources/rss.opml",
"add": [
{
"title": "OpenAI News",
"xmlUrl": "https://openai.com/news/rss.xml",
"category": "AI"
},
{
"title": "Chrome for Developers",
"xmlUrl": "https://developer.chrome.com/static/blog/feed.xml",
"category": "Chrome"
},
{
"title": "Cloudflare Developers",
"xmlUrl": "https://developers.cloudflare.com/changelog/rss/index.xml",
"category": "Cloudflare"
},
{
"title": "Infoq",
"xmlUrl": "https://feed.infoq.com/",
"category": "News"
}
],
"block": [
{
"title": "Google Developers Blog",
"xmlUrl": "https://developers.googleblog.com/feeds/posts/default"
},
{
"title": "Microsoft for Developers",
"xmlUrl": "https://devblogs.microsoft.com/landing"
},
{
"title": "ElevenLabs Blog",
"xmlUrl": "https://api.bestblogs.dev/feed/elevenLabsBlog"
},
{
"title": "Infoq(en)",
"xmlUrl": "http://www.infoq.com/rss/rss.action"
}
],
"block_domains": ["*.substack.com", "*.youtube.com"]
}
}
+735
View File
@@ -0,0 +1,735 @@
# extra-sections-design.md — GitHub Trending / Hacker News / 行业洞察 板块设计
> 本文是对现有 push job 的能力扩展设计。在保持 RSS 主流程不变的前提下,新增三个板块仅在「早报」时段输出:GitHub 趋势、Hacker News 热议、跨板块行业洞察。
>
> 配套修改的源码模块、配置字段、数据契约见各章节。落地后需要把架构层结论合并回 `docs/tech-spec.md`,运行参数变更同步 `README.md`,进度记录写入 `docs/plan.md`。
>
> created: 2026-05-16
> updated: 2026-05-16(按用户决策重写:四模块独立、单页 GH trending、HN 单选 K=1、insights 结构由 prompt 决定)
## 1. 目标与范围
- 在每天的早报推送中,除了现有的 RSS 精选 digest,再加入:
- **GitHub Trending**:当日热门开源项目中筛选 1-3 个 AI 相关项目
- **Hacker News**HN 首页中筛选 1 个最有讨论价值的 AI 相关热议(带评论与外链正文摘要)
- **行业洞察**:基于上述三个板块 + 历史 insights 段做一段跨板块趋势小结
- 这三段内容**只在早报时段生成**(当天 `schedule.push_cron` 列表里最早那次触发),其余时段维持现有纯 RSS digest 行为
- 单板块失败 → 降级推送其他板块,整体任务仍算成功;RSS 失败 → 同现有行为,任务非 0 退出
- 不引入数据库;状态全部落到 `news-data/` 的本地文件
## 2. 架构总览
```
push_job (cron 触发)
├─ Step 1: 早报判定
│ └─ 否 → 走原有纯 RSS digest 流程,结束
├─ Step 2: 四模块编排(前三路 asyncio.gather 并发,insights 串行后置)
│ ├─ run_rss_section(config, now)
│ ├─ run_github_section(config, now)
│ └─ run_hackernews_section(config, now)
│ ↓
│ └─ run_insights_section(rss_md, gh_md, hn_md, config, now)
└─ Step 3: push_job 上游
└─ sentinel 包裹四段 → 拼装 markdown → 推送 → 写 push-*.md
```
**关键设计选择**
1. **模块自治**。每个板块封装为 `run_xxx_section(...) -> (markdown, error)`,板块内部从抓取、enrich、LLM 总结全包;板块互不感知。
2. **sentinel 由 push_job 统一包**。模块返回**裸 markdown**(不含 sentinel),由上游 `_assemble_with_sentinels()` 包入 `<!-- SECTION:xxx BEGIN/END -->`。这样模块不需要知道自己的板块标识,便于后续替换或加新板块。
3. **失败自吞**。模块内部捕获异常,返回 `("", error)`;上游根据返回值决定是否在最终 push 中省略该段、是否走告警通道。
4. **RSS 是核心**。其他三个模块失败都是降级;RSS 模块失败仍按现有行为整体退出非 0。
**并发模型**`asyncio.gather`,与项目通体异步风格一致。
## 3. 模块结构
```
src/
├── sections/ ← 新增
│ ├── __init__.py # 暴露 run_rss_section / run_github_section / ...
│ ├── rss/
│ │ ├── __init__.py
│ │ └── section.py # 把现有 collect_entries_for_push + compose_digest 流程搬入
│ ├── github/
│ │ ├── __init__.py
│ │ ├── trending_scraper.py # HTML 抓取 + 解析(单页 https://github.com/trending)
│ │ ├── repo_enricher.py # GitHub REST API:metadata + README
│ │ ├── history.py # trending-history.json 读写 + 过期清理
│ │ └── section.py # run_github_section 入口
│ ├── hackernews/
│ │ ├── __init__.py
│ │ ├── frontpage_scraper.py # HTML 抓首页 30 条
│ │ ├── item_enricher.py # Algolia /items/{id} + 外链正文(html_to_markdown)
│ │ └── section.py # run_hackernews_section 入口
│ └── insights/
│ ├── __init__.py
│ └── section.py # run_insights_section 入口
├── llm.py # 新增 4 个函数(GH/HN select/HN summarize/insights)
├── storage.py # 新增:sentinel 切片、trending-history、profile 字段
├── main.py # push_job 升级:早报判定 + 四模块编排
└── push/ # 不变,平台层无感知
prompts/ ← 新增 4 个
├── section_github.md # 输入 enriched repo 数组 → 选 1-3 + 写 markdown
├── section_hackernews_select.md # 30 条 frontpage 元数据 → 选 K 个 id(默认 K=1)
├── section_hackernews.md # enriched story → 写 markdown(K=1 时不挑选,只行文)
└── insights.md # 输入三段成品 + 近 N 天 insights 段历史 → 写洞察段
news-data/
├── fetch-*.json # 不变
├── notify-*.md # 不变
├── push-*.md # 内容升级:含 sentinel 与 profile frontmatter
└── trending-history.json # 新增:GH 已查阅 repo 索引
```
## 4. 数据契约
### 4.1 push 文件分段 sentinel
push 文件保留**完整拼接**写入磁盘和发送各平台,但用 HTML 注释 sentinel 划分板块边界。HTML 注释在 markdown 渲染中不显示,机器可解析,下游做"按段查重"时能精确切片。
```markdown
---
pushDate: "2026-05-16T08:00:03+08:00"
profile: "morning"
sourceCount: 12
totalEntries: 12
---
<!-- SECTION:rss BEGIN -->
# 📰 AI Daily 每日精选 | 2026-05-16
*开头一句定调...*
### 1️⃣ ...
<!-- SECTION:rss END -->
<!-- SECTION:github BEGIN -->
## ⭐ GitHub 趋势
- **owner/repo** ⭐234 — 一句话价值定位
<!-- SECTION:github END -->
<!-- SECTION:hackernews BEGIN -->
## 🟧 Hacker News 热议
### 标题 (120 pts · 45 comments)
- 链接: url
- 要点:...
- HN 讨论: comments_url
<!-- SECTION:hackernews END -->
<!-- SECTION:insights BEGIN -->
## 💡 今日洞察
(行文结构由 prompts/insights.md 决定,代码不强加格式)
<!-- SECTION:insights END -->
```
某板块 markdown 为空 → 对应 sentinel 段**整段省略**(不留空标记,不留空 SECTION)。
### 4.2 分段提取函数(storage.py 新增)
```python
def extract_section(push_md: str, section: str) -> str:
"""从 push 文件内容中切出 <!-- SECTION:{section} BEGIN/END --> 之间的 markdown。
向后兼容:
- 新 push 文件(带 sentinel): 按 sentinel 边界切片
- 老 push 文件(无 sentinel) 且 section=='rss': 返回整个 body(老文件视为全 RSS)
- 老 push 文件且 section in {github, hackernews, insights}: 返回空字符串
"""
def load_recent_section_titles(section: str, days: int, data_dir="news-data") -> str:
"""汇总近 days 天 push-*.md 的指定板块,提取标题级别清单(沿用 _extract_push_titles 思路)。
仅 insights 板块在新增模块中使用本函数加载历史。
GH/HN 板块按用户决策不传历史上下文给 LLM,不调用本函数。
"""
```
四个 LLM 与历史上下文的关系:
| LLM 调用 | recent context 数据源 |
|---|---|
| `compose_digest` (RSS) | 维持现有 `load_recent_push_titles(filter.push_context_days)`(老接口在 sentinel 升级后等价于 `load_recent_section_titles("rss", ...)` |
| `summarize_github_trending` | 不传 |
| `select_ai_related_hn` | 不传 |
| `summarize_hackernews` | 不传 |
| `generate_trend_insights` | `load_recent_section_titles("insights", filter.push_context_days)` |
### 4.3 trending-history.json
```json
{
"repos": {
"https://github.com/owner/repo-a": "2026-05-15",
"https://github.com/owner/repo-b": "2026-05-12"
},
"updated_at": "2026-05-16T08:00:01+08:00"
}
```
**写入语义**(用户决策的精确语义):
每次早报触发,按下列顺序处理:
1. 加载 history,剔除 `last_seen_date < today - filter.keep_days` 的条目
2. 抓 trending 页 → 得到 `all_repos`
3.`all_repos` 中每个 url
- 若已在 history → `history.touch(url, today)`(更新日期),**从候选移除**
- 不在 history → 进入 `candidates`
4.`candidates` 中每个 url 也 `history.touch(url, today)` 写入 history
5. 持久化 history(覆盖写)
6.`candidates`(即今日新出现的 repo)做后续 deep-dive 与 LLM 总结
效果:repo 在 trending 上挂多久就被屏蔽多久;过 `keep_days` 天没再出现则可重新推荐。
## 5. 模块详设
### 5.1 RSS 模块(迁移既有逻辑)
`src/sections/rss/section.py::run_rss_section(config, now) -> (str, Optional[str])`
把现有 `run_push_job` 中"收集 + compose_digest"的部分原样迁过来,返回裸 markdown(不含 sentinel+ 错误信息。无新增功能。
### 5.2 GitHub 模块
`src/sections/github/section.py::run_github_section(config, now) -> (str, Optional[str])`
```
1. 抓取 trending 单页(HTML)
GET https://github.com/trending
解析 → all_repos: [{url, full_name, description, language, stars_today, stars_total}]
2. 加载 history 并清理
history = load_trending_history(sections.github_trending.history_file)
history.cleanup(keep_days=filter.keep_days)
3. 候选筛选(按 §4.3 语义)
candidates = []
for repo in all_repos:
if repo.url in history:
history.touch(repo.url, today)
else:
candidates.append(repo)
4. 候选写回 history + 持久化
for repo in candidates:
history.touch(repo.url, today)
history.save()
5. 数量护栏
if not candidates: return ("", None) # 静默
if len(candidates) > max_deep_dive:
candidates = candidates[:max_deep_dive] # 截断,默认 10
6. 并发 deep-dive(REST API)
async for repo in candidates:
meta = await fetch_repo_metadata(owner, repo) # GET /repos/{o}/{r}
readme = await fetch_readme(owner, repo) # GET /repos/{o}/{r}/readme
enriched = [{...repo, topics, license, pushed_at, readme_excerpt}]
- 单 repo 任一请求失败 → 该 repo 跳过 + 错误聚合,不阻塞其他
7. LLM:summarize_github_trending(enriched, config)
prompt: 候选数组(含 readme_excerpt) → 选 1-max_items + 写 markdown
不传 recent_section_titles
8. 返回 (markdown, error)
```
**REST API 调用细节**
| 调用 | 路径 | 取什么 |
|---|---|---|
| metadata | `GET /repos/{owner}/{repo}` | `description, topics, language, license.spdx_id, pushed_at, stargazers_count, archived` |
| readme | `GET /repos/{owner}/{repo}/readme` | `content` (base64) → decode → 截断到 `readme_max_chars` |
- `archived=true` 的 repo 从候选剔除(trending 偶尔出现僵尸归档项目)
- README 截断策略:前 `readme_max_chars` 字符(默认 10000,基于 trending 页 README 长度分布 p50≈25k 选定,详见 §14 决策记录)
- 鉴权:`config.sections.github_trending.tokenName`(默认 `"GITHUB_TOKEN"`)对应的环境变量存在时走 `Authorization: Bearer {token}`,否则匿名调用并接受 60 req/hr 上限(日 10 个 repo × 2 calls = 20 calls,匿名安全)
**enriched repo 字段(喂给最终 LLM**
```json
{
"url": "https://github.com/owner/repo",
"full_name": "owner/repo",
"description": "(来自 trending 页)",
"language": "Python",
"stars_today": 234,
"stars_total": 12340,
"topics": ["llm", "rag", "agent"],
"license": "MIT",
"pushed_at": "2026-05-15",
"readme_excerpt": "(前 3000 chars)"
}
```
### 5.3 Hacker News 模块
`src/sections/hackernews/section.py::run_hackernews_section(config, now) -> (str, Optional[str])`
```
1. 抓首页(HTML)
GET https://news.ycombinator.com/news
解析 30 条 → front: [{id, title, url, site, points, comments, comments_url}]
2. 轻 LLM 初筛:select_ai_related_hn
输入: 30 条 frontpage 元数据(无正文)
输出: K = sections.hackernews.select_k 个 story id(默认 K=1)
if K 个为空 → return ("", None) # 静默
3. 并发 enrich 选中的 K 个 story
async for story in selected:
- 评论树:Algolia GET /api/v1/items/{id}
→ 取前 top_comments 条 L1(默认 30,按 HN ranking)
→ 每条 L1 下挂前 top_l2_per_l1 条 L2 回复(默认 3)
→ 每条 text 过 html_to_markdown,单条截断到 comment_max_chars(默认 2000)
→ 累计达 comments_total_chars(默认 60000) 立即停止,防离群 story 撑爆 prompt
→ 输出 tree JSON: [{"l1": "...", "replies": ["...", "..."]}, ...]
- 外链正文:
if story.url 指向 https://news.ycombinator.com/item?id=... (Show HN/Ask HN):
从 Algolia 同次返回的 root.text 字段取(无外部请求)
else:
GET story.url → html_to_markdown → 截断到 link_content_max_chars(默认 50000,p50≈10k)
- 单 story 任一失败 → 字段留空,metadata 仍传给最终 LLM
4. LLM:summarize_hackernews(enriched_stories, config)
prompt: 对输入的 K 个 story 全部行文(K 通常 = 1
不传 recent_section_titles
5. 返回 (markdown, error)
```
**Algolia API 接口**
```
GET https://hn.algolia.com/api/v1/items/{id}
```
返回 JSON
```json
{
"id": 12345678,
"title": "...",
"url": "...",
"points": 120,
"author": "...",
"text": null, // Show HN/Ask HN 的正文在这里
"children": [ // 顶层评论数组(按 HN ranking 排序)
{"id": ..., "text": "<HTML>", "author": "...", "children": [...]},
...
]
}
```
**优势 vs HTML 解析**:免去 HN 的 `td.ind[indent="0"]` indent-tree 解析;评论文本是干净 HTML 字符串,直接 `html_to_markdown`
**Show HN / Ask HN 特例**
- 首页解析时 `url` 字段就是 `https://news.ycombinator.com/item?id=X`,作为"非外链"标记
- enrich 时只调一次 Algolia(覆盖评论 + post 正文 `text` 字段),不再发外部请求
**enriched story 字段(喂给最终 LLM**
```json
{
"id": "12345678",
"title": "...",
"url": "...",
"site": "example.com",
"points": 120,
"comments": 45,
"comments_url": "https://news.ycombinator.com/item?id=12345678",
"link_content": "(markdown, ≤3000 chars; Show HN 时是 post 正文)",
"top_comments": [
{"l1": "(markdown, ≤comment_max_chars)", "replies": ["(markdown, ≤comment_max_chars)", "..."]},
...
]
}
```
### 5.4 Insights 模块
`src/sections/insights/section.py::run_insights_section(rss_md, gh_md, hn_md, config, now) -> (str, Optional[str])`
```
1. 加载历史
recent = load_recent_section_titles("insights", filter.push_context_days)
2. LLM:generate_trend_insights
输入: {"rss": rss_md, "github": gh_md, "hackernews": hn_md} + recent
输出: insights 段 markdown
注:行文结构、bullet 数量、风格约束等全部交给 prompts/insights.md,
代码层不强加固定格式
3. 返回 (markdown, error)
```
如果某板块返回空(失败或本日无内容),prompt 里对应键标记 `"(本次无内容)"`LLM 自行适配。
## 6. LLM 调用与 Prompt 策略
### 6.1 新增 LLM 函数(src/llm.py
```python
async def select_ai_related_hn(
candidates: list[dict], # 首页 30 条元数据(无正文)
k: int, # 期望返回数量,默认 1
config: dict,
) -> tuple[list[str], Optional[str]]:
"""轻量 LLM:从 HN 首页候选中挑出 k 个 AI 相关的 story id,只读 title/site/points/comments。
返回 ([id1, ...], error)。"""
async def summarize_github_trending(
enriched_repos: list[dict], # 已 deep-dive 的 repo 候选(含 readme_excerpt + topics)
config: dict,
) -> tuple[str, Optional[str]]:
"""选 1-max_items + 写 markdown 段。不传历史上下文。"""
async def summarize_hackernews(
enriched_stories: list[dict], # 已 enrich(含 link_content 与 top_comments)
config: dict,
) -> tuple[str, Optional[str]]:
"""对输入的 K 个 enriched stories 全部行文。K 由配置 select_k 决定,默认 1。
不传历史上下文。"""
async def generate_trend_insights(
sections: dict[str, str], # {"rss": md, "github": md, "hackernews": md}
recent_insights: str, # load_recent_section_titles("insights", days)
config: dict,
) -> tuple[str, Optional[str]]:
"""输入三段成品 + 近期 insights 标题,返回洞察段 markdown。"""
```
返回风格沿用 `generate_immediate_push`:成功返回 `(content, None)`,失败返回 `("", error_msg)`
**单次早报推送的 LLM 调用预算**
| 调用 | 输入规模 | 用途 |
|---|---|---|
| `compose_digest` | 当日符合条件的 RSS 条目 | 现有,RSS digest 主体 |
| `select_ai_related_hn` | 30 条 HN 首页元数据 | 轻量;只读 title/site/points/comments |
| `summarize_github_trending` | ≤ `max_deep_dive`=10 个 enriched repos | 选 1-3 + 行文 |
| `summarize_hackernews` | `K = select_k` 个 enriched stories(默认 1 | 行文 K 条 |
| `generate_trend_insights` | 三段已生成 markdown + 近期 insights 标题 | 一段洞察 |
合计 5 次 / 早报。晚报维持现有 1 次。
### 6.2 关注领域(GitHub / HN 共用)
为避免领域定义在 3 个 prompt 里漂移,统一在此沉淀;各 prompt 在自身骨架里直接引用本节,不做重新发明。
**正面关注**
- **AI Agent**:智能体架构、工具链、多智能体、自主规划、Agent 框架
- **AI 模型**:训练、推理、微调、量化部署、模型服务、语音 / 多模态 / 视觉模型
- **AI 基础设施**:GPU 调度、芯片硬件、数据中心、推理优化、分布式训练、向量数据库、RAG 框架
- **大厂 / 前沿动态**Apple、Google、Meta、OpenAI、Anthropic、Microsoft、xAI 等公司的官方动作与战略
- **AI 集成的开发者工具**:API 网关、自动化脚本、低代码平台等明确与 AI 协同的工具
- **有创新性的开源产品**:日增长显著且有清晰用户价值(GH 板块专属)
**负面排除(一律剔除)**
- 嵌入式开发(Arduino、ESP32、树莓派、单片机)
- 底层系统编程(内存分配器、编译器、链接器,与 AI 工作负载无明显关联时)
- 通用开发工具(命名规范、代码风格、纯前端模板、UI 组件库、管理后台模板、静态网站主题)
- 学习资源(纯教程仓库、面试题合集、Roadmap,除非是含实用代码的深度技术指南)
- 配置文件集合(Dotfiles、配置模板)
- 与 AI / 科技无关的内容(电子书、资源搬运、刷榜项目、明星项目搬运)
- 纯娱乐 / 高风险误用(deepfake 等无明确基础设施价值的项目)
### 6.3 Prompt 文件
#### prompts/section_github.md(骨架)
- 角色定位("开源情报分析师")
- 输入 schema 说明(JSON 数组:url / full_name / description / language / stars_today / stars_total / topics / license / pushed_at / readme_excerpt
- 关注领域:引用 §6.2(正面列表与负面排除完整复制进 prompt)
- 选项规则:
- 从候选中挑 1-`max_items` 个最值得关注的项目
- 优先信号:stars_today 高 + topics 含 AI 标签(agent/llm/rag/inference/training 等)+ readme 描述明确 + 非纯模板/教程仓库
- 必跳过:`archived=true`(理论上已在 enricher 剔除,prompt 层兜底)、纯 awesome-list、个人配置 dotfiles
- 输出格式(markdown 列表):
- `- **owner/repo** ⭐{stars_today} — 一句话价值定位 [link]`
- 一句话需点明"解决什么问题",避免营销语
- 风格约束:与 `prompts/digest.md` 同源;负面句式("震撼""炸裂""革命性")禁用;避免套话
#### prompts/section_hackernews_select.md(骨架)
- 角色定位("HN 早间选题人"
- 输入:JSON 数组(30 条 frontpage 元数据:id / title / site / points / comments
- 关注领域:引用 §6.2
- 任务:挑 `k` 个最符合关注领域的 story id(K 默认 1
- 决策原则:title + site 不足以判定 AI 相关时,**宁可漏选不可错选**(错选会让最终 LLM 写出与 AI Daily 调性无关的内容)
- 输出:纯 JSON id 数组,如 `["12345"]``[]`(无任何匹配时返回空数组)
- 严禁输出任何解释性文字
#### prompts/section_hackernews.md(骨架)
- 角色定位("HN 早间编辑"
- 输入 schema 说明(K 个 enriched story,含 `link_content``top_comments`
- 关注领域:引用 §6.2(K=1 时通常无需筛选,仅作为行文背景参考)
- 任务:对输入的 `K` 个 enriched stories 全部行文(不再二次挑选)
- 内容要求(每条 story):
- 提炼原文核心(背景 / 要点 / 结论)
- 汇总 HN 评论区的有价值观点(支持 / 反对 / 补充),不是简单复述
- 若评论中出现明显反驳原文的观点,必须保留并标注
- 输出格式建议(最终以 prompt 实测为准):
```
### 标题 (N pts · M comments)
- 链接: url
- 内容总结: 2-3 条核心要点
- 💬 HN 讨论: 1-2 条最有价值的观点(含反对意见)
- 🔗 HN 讨论页: comments_url
```
- 风格约束:客观、犀利、克制;避免与 RSS digest 句式雷同;不做宏大叙事
#### prompts/insights.md(骨架)
- 角色定位("AI 行业观察员"
- 输入:三段成品 markdown + 近 N 天 insights 板块清单
- 任务:基于三段产出做跨板块小结
- 风格约束:避免与 RSS digest 句式雷同;避免简单复述已经在其他板块出现过的具体新闻
- 结构与 bullet 数量交由 prompt 内部约定,code 层不限制
### 6.4 调用顺序与并发
```python
async def _run_morning_push(config):
rss_md, gh_md, hn_md = await asyncio.gather(
run_rss_section(config, now),
run_github_section(config, now),
run_hackernews_section(config, now),
return_exceptions=False, # 各 section 自吞异常,不抛
)
insights_md, _ = await run_insights_section(rss_md, gh_md, hn_md, config, now)
final = _assemble_with_sentinels({
"rss": rss_md,
"github": gh_md,
"hackernews": hn_md,
"insights": insights_md,
})
await send_to_platforms(final, config["push"])
save_push_file(get_push_file(), final, profile="morning", ...)
```
`_assemble_with_sentinels(sections: dict[str, str]) -> str` 的契约:
- 按固定顺序拼装 `rss → github → hackernews → insights`
- 空 markdown 段整段省略(连同 sentinel
- 段间留一个空行
## 7. 行业洞察板块设计
按用户决策,本节**不在 code 层规定 insights 的格式**
- bullet 数量、子标题、字数限制、固定栏目等都属于 prompt 工程范畴
- 调整方法:编辑 `prompts/insights.md` 而非改代码
- 输入合同(code 层保证):
- `sections["rss" | "github" | "hackernews"]` 三个键的 markdown
- 任一板块为空时该键值为 `"(本次无内容)"`
- `recent_insights`:近 `filter.push_context_days` 天 insights 段标题清单(防风格趋同)
- 输出合同(code 层不校验):直接作为 markdown 段插入
## 8. 配置 schema 增量
```json
{
"filter": {
"min_score": 60,
"hot_threshold": 90,
"context_days": 2,
"keep_days": 7,
"push_context_days": 5,
"no_content_marker": "[NO_NEW_CONTENT]"
},
"schedule": {
"fetch_interval_minutes": 30,
"fetch_lookback_minutes": 120,
"push_cron": ["0 8 * * *", "0 17 * * *"],
"timezone_hours": 8
},
"sections": {
"github_trending": {
"enabled": true,
"max_items": 3,
"max_deep_dive": 10,
"readme_max_chars": 10000,
"history_file": "news-data/trending-history.json",
"request_timeout": 10,
"tokenName": "GITHUB_TOKEN"
},
"hackernews": {
"enabled": true,
"select_k": 1,
"top_comments": 30,
"top_l2_per_l1": 3,
"comment_max_chars": 2000,
"comments_total_chars": 60000,
"link_content_max_chars": 50000,
"request_timeout": 10,
"algolia_base": "https://hn.algolia.com/api/v1"
},
"insights": {
"enabled": true
}
},
"llm": {
"prompts": {
"score_batch": "prompts/score_batch.md",
"immediate_push": "prompts/immediate_push.md",
"digest": "prompts/digest.md",
"section_github": "prompts/section_github.md",
"section_hackernews_select": "prompts/section_hackernews_select.md",
"section_hackernews": "prompts/section_hackernews.md",
"insights": "prompts/insights.md"
}
}
}
```
向后兼容:
- `sections` 整段缺失 → 等价于全部 `enabled=false` → push_job 走原有纯 RSS 路径
- `push_cron` 为空 → 无早报触发,不生成新板块
- 旧 push 文件没有 sentinel → `extract_section("rss", ...)` 返回整个 body,其他 section 返回空字符串
- `GITHUB_TOKEN` 未设 → GH 模块匿名调用,照常运行
## 9. 失败隔离与降级策略
| 失败位置 | 行为 |
|---|---|
| `run_rss_section` 失败 | 整个 push_job 退出非 0(核心承诺不变) |
| GH trending 抓取 / 解析失败 | `run_github_section` 返回 `("", error)` → 板块整段省略 → 告警 |
| GH 单 repo metadata/readme 失败 | 该 repo 跳过 + 错误聚合,不阻塞其他 repo |
| GH summarize LLM 失败 | 板块整段省略,告警 `notify_llm_errors("section_github", ...)` |
| HN 首页抓取失败 | 同 GH |
| HN 轻 LLM 初筛失败或返回空 | 板块整段省略,告警(初筛失败)或静默(结果为空) |
| HN 单 story enrich 失败 | 字段留空,metadata 仍传给最终 LLM |
| HN summarize LLM 失败 | 板块整段省略,告警 |
| insights LLM 失败 | 洞察段省略,其他板块照常推送,告警 |
| 早报判定为否 | 完全跳过 GH/HN/insights,不消耗任何额外 API |
| `sections.xxx.enabled=false` | 对应模块直接返回 `("", None)`,静默跳过 |
整体准则:**RSS 是核心,其余是增强**。除 RSS 外的任何失败都不阻塞推送,但都通过现有 `notify_llm_errors` 通道发简单告警,方便事后排查。
## 10. 早报判定逻辑
```python
def is_morning_push(now: datetime, config: Dict) -> bool:
cron_list = config.get("schedule", {}).get("push_cron", [])
if not cron_list:
return False
if len(cron_list) == 1:
return True # 唯一定时即"最早",任何触发都视为早报
base = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_fires = [croniter(c, base).get_next(datetime) for c in cron_list]
closest = min(today_fires, key=lambda f: abs(now - f))
return closest == min(today_fires)
```
规则:`push_cron` 中 now 离哪条 cron 最近就归为那条;最近的那条若是当天最早的 cron,则视为早报。
为什么不再用单独的 `morning_cron` + 容差:
- 复用 `push_cron`,少一个配置项,加 cron 自动延展
- 「最近匹配」自动等价于容差 = 到次近 cron 距离的一半,systemd timer 漂移更稳健
- 单条 cron 部署自然变成"每次推送都是早报",与用户预期一致
## 11. 与现有模块的集成点
- `src/main.py::run_push_job` 改造:
1. 开头加 `is_morning = is_morning_push(now, config)`
2. `is_morning=False` 时走原路径(`compose_digest` only
3. `is_morning=True` 时进入 `_run_morning_push` 编排(四模块 + sentinel 拼装)
- `src/storage.py` 新增:
- `extract_section(content, section)` + `load_recent_section_titles(section, days, data_dir)`
- `load_trending_history(path) -> TrendingHistory`、`TrendingHistory.cleanup/touch/save`
- `save_push_file` 微调:frontmatter 带上 `profile: "morning"|"default"`,方便后续按 profile 分析
- `cleanup_old_files` 增加对 `trending-history.json` 的"过期条目剪枝"(不删整个文件)
- `src/push/` **不动**,平台层无感知
- `src/llm.py` 新增 4 个函数:`select_ai_related_hn / summarize_github_trending / summarize_hackernews / generate_trend_insights`
- `src/sections/` 全新模块树(4 个子包)
## 12. 测试策略
新增单元测试:
- `tests/pytest/test_sections_github_scraper.py`:本地 HTML fixture(保存几个真实 trending 页面快照)→ 测试解析
- `tests/pytest/test_sections_github_history.py`:测试 `TrendingHistory.touch/cleanup`、URL 已存在/不存在分支
- `tests/pytest/test_sections_github_enricher.py`mock `aiohttp` → 测试 REST API 字段映射、archived 过滤、token 鉴权头
- `tests/pytest/test_sections_hackernews_scraper.py`HN 首页 fixture → 测试 30 条解析
- `tests/pytest/test_sections_hackernews_enricher.py`mock Algolia → 测试评论 top_comments 截断、Show HN 特例
- `tests/pytest/test_storage_sections.py`sentinel 切片(含老文件 fallback)、`load_recent_section_titles`
- `tests/pytest/test_morning_detection.py`cron 边界、容差、跨时区
新增交互式脚本(与现有 `tests/*.py` 风格一致):
- `tests/run_morning_push.py`:模拟一次完整早报,强制 `is_morning=True`
- `tests/fetch_trending.py`:单独跑 GH 抓取 + deep-dive
- `tests/fetch_hackernews.py`:单独跑 HN 抓取 + enrich
## 13. 实施步骤建议
按依赖顺序实施,每步可独立 PR / commit
1. **storage 层**sentinel 切片、`trending-history.json` 读写、`load_recent_section_titles`、`save_push_file` profile 字段、`cleanup_old_files` 对 history 文件的处理 + 单测
2. **RSS 模块迁移**:把现有 `run_push_job` 中的 RSS 流程提取为 `run_rss_section`,验证行为不变
3. **GitHub 模块**trending scraper → history → enricher → section 入口 + 单测 + fixture
4. **Hacker News 模块**frontpage scraper → Algolia enricher → section 入口 + 单测 + fixture
5. **LLM 函数 + Prompt 文件**4 个新 LLM 函数 + 4 个 prompt
6. **Insights 模块**:依赖 §5.4,相对简单
7. **push_job 升级**:早报判定 + 四模块编排 + sentinel 拼装 + 失败隔离
8. **配置 schema 升级**`config.json.example` 与 `config.json` 同步;README 配置详解章节补全
9. **文档同步**`docs/tech-spec.md` 把架构升级合并;`docs/plan.md` 写进度
## 14. 关键决策记录
| 决策 | 方案 | 原因 |
|---|---|---|
| 新板块时机 | 仅早报(当天最早一次 `push_cron` 触发) | 板块价值更适合一日一报;晚报维持原有 RSS 节奏;复用 push_cron 不引入新配置 |
| 模块边界 | `src/sections/<board>/` 各自封装抓取+LLM+总结 | 模块自治便于扩展、替换、单测;上游编排极简 |
| sentinel 归属 | push_job 上游统一包 | 模块不感知自己的板块标识;新增板块零修改成本 |
| GH trending 数据源 | 单页 HTML `https://github.com/trending`,无语言/since 过滤 | 用户决策:最简、最稳;语言过滤靠 topics + readme 在 LLM 层判 |
| GH deep-dive 内容 | REST API 拿 metadata + topics + README | topics 是 AI 相关性最强信号;metadata 补 license/pushed_atREADME 给内容深度 |
| GH 鉴权 | GITHUB_TOKEN 可选 | 日 ~20 calls 远低于匿名 60 req/hr 上限;零配置即可跑 |
| GH 筛选策略 | history 过滤 → 全部 deep-dive → 一次 LLM 选 1-3 | 候选量小(5-15);单次 LLM 比两阶段简单且选择质量高 |
| GH 候选护栏 | `max_deep_dive=10` | 极端日(trending 大改)限制 HTTP 与 token 消耗 |
| HN 数据源 | 首页 HTML + 评论/正文 Algolia | 首页要"现场感"走 HTMLAlgolia 评论 JSON 结构清晰,免去 indent-tree 解析 |
| HN 筛选策略 | 30 条 → 轻 LLM 选 K=1 → enrich → 最终 LLM 行文 | 用户决策:把 enrich 工作量压到 1 个 story;轻 LLM 用 title 已足够判 AI 相关 |
| HN 评论结构 | L1 + 每个 L1 下挂 N 条 L2 回复,tree JSON 喂 LLM | 真实数据:L2 信息量与 L1 持平(24 条/8k chars vs 18/6k);拍平丢失父子关系,LLM 无法识别"回复反驳了顶层"。Tree 结构让 LLM 看清论辩链 |
| HN 评论上限 | `top_comments=30`(L1)+ `top_l2_per_l1=3` + `comments_total_chars=60000`(总预算) | 真实平均:L1 18 条/6k chars,L2 24 条/8k。30+3 给足余量但平均只跑 ~14k。总预算硬上限拦住离群 story(309 评论那种) |
| 无历史上下文 | GH / HN 板块均不传 recent_section_titles | 用户决策:避免不必要的上下文污染;GH/HN 风格与 RSS digest 差异已足够大 |
| insights 历史窗口 | 复用 `filter.push_context_days` | 不引入新字段;insights 段需要历史防风格趋同 |
| insights 输出结构 | 由 prompt 决定,code 不强加 | 用户决策:bullet 数量与栏目属于 prompt 工程,便于迭代 |
| GH / HN 关注领域沉淀 | 在 §6.2 集中定义正面列表 + 负面排除,3 个 prompt 引用 | 避免领域定义在 prompt 间漂移;用户已明确兴趣边界(AI Agent / 模型 / 基础设施 / 大厂动态),排除嵌入式、底层系统、纯前端模板、学习资源等 |
| 板块 sentinel 用 HTML 注释 | `<!-- SECTION:xxx BEGIN/END -->` | markdown 渲染不显示;机器易解析;老 push 文件零冲突 |
| 失败降级粒度 | 单板块失败省略本段;RSS 失败整体退出 | RSS 是核心承诺,其他是增强 |
| 早报判定 | cron + 容差,而非"今天第一次" | 早报失败时晚报不会错误升级为长版本 |
| 截断参数初始值(2026-05-17 合入) | `readme_max_chars=3000` / `top_comments=20` / `comment_max_chars=500` / `link_content_max_chars=3000` | 凭直觉给出的保守默认;上线后通过真实数据校准 |
| 截断参数校准(2026-05-17 合入后,激进路径) | `readme_max_chars: 5000→10000` / `top_comments: 20→50` / `link_content_max_chars: 6000→50000` / `comment_max_chars: 800` 不变 / `max_prompt_chars: 64000→150000` | 用户决策:把 LLM 上下文用到 DeepSeek v4 flash 128k tokens 限的合理水平,优先内容深度而非 API 成本。Worst-case 单次 LLM prompt:GH≈110k chars / HN≈95k chars,均在 150k budget 内。中英混合 100k chars ≈ 30-50k tokens,远低于 128k 模型限 |
| 单板块 CLI2026-05-17 合入后) | `python -m src.main github` / `hackernews` | 便于 prompt 调优期反复跑单板块而不消耗全套 LLM 调用 |
hacker news 评论统计
┌─────────────┬──────────┬──────────────┬────────────────┬──────────┐
│ 层级 │ 平均条数 │ 平均 md 字符 │ 平均单条 chars │ 最大单条 │
├─────────────┼──────────┼──────────────┼────────────────┼──────────┤
│ L1 顶层评论 │ 18 │ 5,952 │ ~330 │ 1,755 │
├─────────────┼──────────┼──────────────┼────────────────┼──────────┤
│ L2 一级回复 │ 24 │ 8,166 │ ~340 │ 2,381 │
├─────────────┼──────────┼──────────────┼────────────────┼──────────┤
│ L3+ 更深层 │ 41 │ 13,048 │ ~315 │ 2,291 │
├─────────────┼──────────┼──────────────┼────────────────┼──────────┤
│ ALL 全部 │ 83 │ 27,166 │ ~330 │ 2,381 │
└─────────────┴──────────┴──────────────┴────────────────┴──────────┘
+90
View File
@@ -0,0 +1,90 @@
## TODO
当前待办
- [ ] 优化提示词: 推送格式;参考链接去除非官方信息; insights 不够深度
- [ ] 日志系统,保存到文件,push和fetch分开,
- [ ] 早报内容格式优化:参考appso / xiaohu / ai gap
- [ ] 优先级顺序
- [ ] 美化排版
- [ ] 添加更多信息源,如 TechCrunch、
- [ ] 允许fetch链接中的内容对信息进行扩展
- [ ] 更多信息源以及信息获取不全: https://www.anthropic.com/research/glasswing-initial-update / github blog
长期待办
- [ ] 增加图片/信息图
- [ ] 推送到知乎 / 小红书 / 网站
- [ ] llm api fallback
## 技术决策记录
| 决策 | 方案 | 原因 |
|------|------|------|
| 定时调度 | systemd timer(生产)+ croniterloop 模式) | 进程崩溃/服务器重启可自愈,配置热更新,比内置 asyncio.gather 更稳健 |
| 包管理 | uv | 速度快、单工具管理 venv/pip/lockfile,项目独立 `.venv` |
| LLM 健康检查 | 仅在 `install.sh` / `loop` 启动时校验 | 每次 timer 触发都校验会增加无意义的 LLM API 调用,运行期异常由 `notify_llm_errors` 兜底 |
| 日志方案 | journald 命名空间 `dnews` + `MaxRetentionSec` | 与系统其他服务隔离,按 `log.retention_days` 自动轮转,无需写文件日志 |
| 数据格式 | JSON | 结构清晰、易处理、支持嵌套 |
| 推送文件 | Markdown+YAML | 人工可读、Frontmatter 元数据 |
| LLM 评分 | 批量 JSON | 减少 API 调用次数 |
| 状态追踪 | 文件时间戳 | 无需外部数据库 |
| RSS延迟防护 | fetch_lookback_minutes | 防止RSS延迟导致漏读 |
| LLM异常通知 | 调用方统一上报 | 避免批次级刷屏,同时保留关键异常通知 |
| 报告 metadata 统一 | 全部从 LLM frontmatter 解析(早报/晚报/即时) | 取代之前的 `extract_title_from_content` h1 提取+硬编日期标题;个性化标题 + lead 导读 + highlights 列表统一承载 |
## 开发进度
**2026-05-22**
- ✅ 推送 metadata 统一改造:晚报与即时消息也走 frontmatter(之前只有早报)
- `prompts/digest.md` 增加 frontmattertitle/lead/highlights),删除正文「开头一句话定调」段
- `prompts/insights.md` frontmatter 增加 `lead`(综合三段的 60-100 字前言)与 `highlights`2-3 条卡片重点)
- `prompts/immediate_push.md``# 标题` 迁到 frontmatter `title` 字段
- `src/llm.py` 新增 `parse_digest_with_metadata` / `parse_immediate_push_with_metadata`,统一 `_parse_frontmatter` 帮手;移除 `extract_title_from_content`
- `src/sections/rss/section.py` 返回三元组 `(body, metadata, err)`;早报丢弃 digest metadata,由 insights 段覆盖
- 晚报现在拥有个性化标题 + lead + highlights,与早报对齐
**2026-05-17**
- ✅ 早报扩展板块上线:在 RSS digest 之上叠加 GitHub Trending / Hacker News / 跨板块洞察三段,仅 `schedule.morning_cron` 命中时触发,晚报维持纯 RSS 行为
- 设计与实施详见 [`docs/extra-sections-design.md`](extra-sections-design.md) 与 [`docs/superpowers/plans/2026-05-17-extra-sections.md`](superpowers/plans/2026-05-17-extra-sections.md)
**2026-05-15**
- 优化提示词,修复长时运行下的新闻报告措辞趋同问题
**2026-05-14**
- [x] 外置定时(已切换到 systemd timer
- [x] 系统服务一键运行(`scripts/install.sh`
- 使用uv进行python项目管理
- 配置变更:`config.json` 新增 `log.retention_days` 字段
- 新增 `daily-news` 系统级包装脚本:装在 `/usr/local/bin/daily-news`,提供 `start/stop/restart/status/logs` 等命令,封装 systemctl/journalctl 调用细节
**2026-03-08**
- 新增 LLM 异常通知:`compose_digest``generate_immediate_push``score_batch` 的错误会通过现有推送渠道发送简单告警
- 优化批量评分容错:`score_batch` 在批次返回数量不匹配时会按 `link` 回收可用结果,并聚合错误返回给调用方
- 移除 `generate_immediate_push` 的 fallback 内容,生成失败时由调用方告警并跳过本次即时推送
- 新增启动前 LLM 可用性检查:主程序在启动 fetch/push 双循环前先探测 LLM 接口,失败则直接退出
- 修复 pytest 中遗留的旧推送平台命名问题,将 `wecom` 测试更新为当前 `feishu` 实现
**2026-03-03**
- 采用 MIT 许可开源项目,添加 LICENSE 和 NOTICE 文件
- 更新 RSS 源说明,致谢 BestBlogs 项目
**2026-03-02**
- 修复RSS延迟漏读问题:新增 fetch_lookback_minutes 参数,fetch时读取过去更长一段时间的RSS条目进行去重
- 新增飞书 Webhook 推送支持:使用卡片消息格式,支持 Markdown 渲染
- 新增测试脚本 test_fetch_lookback.py
- 更新 cleanup_old_files 函数支持 notify 文件清理
**2026-03-01**
- 优化评分系统:通过更新 score 提示词提升评分质量
- 即时推送去重:新增 notify-*.md 文件存储即时推送,LLM 调用时传入近期推送上下文避免重复
- 汇总推送优化:新增 push_context_days 配置,汇总推送时传入近期推送上下文进行去重
- 修复 score 类型问题:确保 LLM 返回的 score 为整数类型
- 完善测试脚本:添加上下文参数和保存功能
**2026-02-28**
- 初始化项目,MVP 已完成,支持 RSS 抓取、LLM 评分、定时推送、即时推送。
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
# tech-spec.md - 技术架构总览
> 本文档面向未来的 AI Agent 与核心开发者,目的是在阅读源码之前快速建立对项目的整体认识:项目定位、运行模式、核心数据流、模块边界、部署形态、关键约束。
>
> **本文写什么**:架构层面的"是什么、为什么、边界在哪里";跨模块的数据流和契约;部署/运行环境约束;不读源码就无法获知的设计决策。
>
> **本文不写什么**:源码摘录、函数签名、字段清单、命令行帮助、变更日志、UI/文案。这些信息以源码、`README.md`、`docs/plan.md`、`config.json` 为准。
>
> **维护原则**:当架构边界、数据流、运行环境、部署形态或核心设计决策发生变化时同步更新本文;普通实现调整、字段增删、文案修改不在维护范围内。
>
> update: 2026-05-17
## 项目定位
AI 驱动的 RSS 新闻聚合与推送系统:周期性抓取 400+ AI 领域信息源,调用 LLM 评分筛选,按调度规则将高分内容汇总推送到 Discord / 飞书;高分热点条目在 fetch 阶段即时推送。
面向单机部署、单租户使用,所有状态以本地文件(JSON / Markdown)持久化,不依赖外部数据库或队列。
## 运行模式
| 模式 | 触发方 | 适用场景 |
|------|--------|----------|
| **生产**(推荐) | systemd timer 分别触发 `fetch``push` 单次任务 | 服务器长期运行,依赖 systemd 提供调度、重启、开机自启 |
| **开发** | `loop` 子命令在单进程内并发跑 fetch/push 双循环 | 本地调试,无需 systemd |
CLI 子命令分工(详见 `python -m src.main --help`):
- `check`:**唯一**的 LLM 健康检查入口,仅在部署期由 `install.sh` 调用
- `fetch` / `push`:单次执行后退出,由 systemd timer 触发;运行期不再做 LLM 健康检查,异常由统一的告警通道兜底
- `loop`:开发模式,启动时做一次健康检查,然后并发跑 fetch/push 循环
- `github` / `hackernews`:单板块手动调试入口;只跑对应板块(含 LLM 总结),打印 markdown 到终端,**不**推送、**不**写入 push 文件。仅供 prompt 调优期使用
关键约束:`fetch` / `push` 失败时进程退出码非 0,systemd 据此判定 service 失败,下个 timer 周期自动重试。
## 核心架构
### 调度模型(生产)
```
┌─────────────────────────────────────────────────────────────┐
│ config.json │
│ schedule.fetch_interval_minutes → dnews-fetch.timer │
│ schedule.push_cron → dnews-push.timer │
│ log.retention_days → journald@dnews retention│
└────────────────────────┬────────────────────────────────────┘
│ scripts/install.sh 渲染并安装
┌────────────┴────────────┐
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ dnews-fetch.timer │ │ dnews-push.timer │
└─────────┬─────────┘ └─────────┬─────────┘
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ fetch.service │ │ push.service │
│ 抓取+评分+热点推送│ │ 收集+汇总+推送 │
└─────────┬─────────┘ └─────────┬─────────┘
└────────────┬────────────┘
┌─────────────────┐
│ news-data/ │
│ fetch-*.json │
│ push-*.md │
│ notify-*.md │
└─────────────────┘
```
开发模式(`loop`)以 `asyncio.gather(fetch_loop, push_loop)` 并发运行两条循环,`push_loop` 通过 croniter 计算下次触发时间,行为等价于生产模式但共享单进程。
### 数据流
```mermaid
flowchart LR
subgraph Sources ["📡 RSS Sources"]
RS1[Twitter/X]
RS2[博客 / 媒体]
RS3[微信公众号]
end
subgraph Fetch ["🔄 fetch job (周期触发)"]
F1[抓取 RSS] --> F2[HTML→Markdown] --> F3[LLM 批量评分]
F3 --> HOT{score ≥ hot_threshold?}
HOT -->|是| IP[即时推送 + notify-*.md]
end
subgraph Push ["📅 push job (cron 触发)"]
P1[读取近 N 天 fetch + 历史 push 上下文] --> P2[LLM 汇总去重] --> P3[生成 push-*.md] --> P4[推送 Discord / 飞书]
end
subgraph Storage ["💾 news-data/"]
DB[(JSON / MD)]
end
Sources --> F1
F3 --> DB
IP --> DB
DB --> P1
P3 --> DB
```
**关键数据契约**
- `fetch-YYYY-MM-DD.json`:当日抓取与评分结果(含 score / summary / tags / content
- `push-YYYY-MM-DD.md`:汇总推送内容(YAML frontmatter + Markdown 正文),同时作为下一次 push 的去重上下文
- `notify-YYYY-MM-DD.md`:即时推送记录,作为 LLM 即时推送时的去重上下文
具体字段以源码 `src/storage.py` 与样例文件为准,README "数据示例" 章节给出了一份示例。
## 关键模块边界
```
src/ 运行时代码
├── main.py CLI 入口;定义 fetch_job / push_job / loop 的编排顺序
├── config.py 加载 config.json,合并 OPML + add/block,做配置校验
├── fetcher.py RSS 抓取;并发控制、UA 伪装、域名通配符屏蔽;nitter/xcancel 走独立的 requests+Inoreader UA 低并发池
├── processor.py HTML → Markdown 转换
├── llm.py LLM 客户端;批量评分、即时推送生成、汇总生成、错误聚合
├── storage.py news-data 文件读写;按日期分片;过期清理
└── push/ 推送平台抽象
├── base.py PushPlatform 基类(validate_config / send)
├── discord.py
└── feishu.py
scripts/ 部署脚本(仅生产 systemd 部署使用)
├── install.sh 一键安装:uv sync → LLM check → 渲染单元 → 装入系统 → 启用
├── uninstall.sh 卸载 systemd 单元、daily-news 包装脚本和日志 drop-in;不删数据
├── status.sh 查看 timer/service 状态(daily-news status 包装它)
├── _gen_units.py 从 config.json 渲染 systemd 单元和 daily-news 包装脚本
└── daily-news.tmpl /usr/local/bin/daily-news 的脚本模板,封装 systemctl/journalctl
systemd/ systemd 单元模板(由 _gen_units.py 渲染并装入 /etc/systemd/system/)
├── dnews-fetch.service.tmpl fetch service 单元模板
├── dnews-fetch.timer.tmpl fetch 定时器(OnUnitActiveSec 间隔触发)
├── dnews-push.service.tmpl push service 单元模板
├── dnews-push.timer.tmpl push 定时器(OnCalendar 日历触发)
└── journald-dnews.conf.tmpl journald 命名空间 dnews 的日志保留 drop-in
config.json 主配置;运行参数 + 调度 + LLM + 推送渠道;唯一可热改的运行配置
prompts/ LLM 提示词文本;score / immediate_push / digest 各自独立文件
resources/rss.opml 基础 RSS 订阅源(约 420 个),通过 sources.add/block 增量调整
.env 敏感凭证(API Key / Webhook URL),通过环境变量注入,不入库
```
### 板块化扩展 (morning push)
早报推送在 RSS 之上扩展三个板块:GitHub 趋势 / Hacker News 热议 / 跨板块洞察。模块结构、数据流与失败降级详见 `docs/extra-sections-design.md`。架构层关键约束:
- 仅在当天 `schedule.push_cron` 列表里最早那次触发时启用(单条 cron 时每次都启用),其余时段维持纯 RSS 行为
- 各板块封装为 `src/sections/<board>/section.py::run_xxx_section(config, now) -> (markdown, error)`
- `push_job``asyncio.gather` 并发跑 RSS / GH / HN,串行接 insights;最后用 `<!-- SECTION:xxx BEGIN/END -->` sentinel 包入 push 文件
- 仅 RSS 失败会让 push_job 整体退出非 0;其余板块失败 → 板块整段省略 + 告警
新增持久化文件:`news-data/trending-history.json`(GH 已查阅 repo 索引,按 `filter.keep_days` 过期)
模块协作的关键约定:
- **fetch 与 push 之间通过文件系统解耦**:双方不直接通信,push 只读 fetch 已写入的 JSON
- **LLM 调用的错误处理由调用方决定**:`llm.py` 不做 fallback,失败时返回 `(空内容, 错误列表)`;调用方决定是否告警或跳过推送,避免一个批次失败污染整次任务
- **批量评分按 `link` 字段对齐**:LLM 返回的条目数可能少于输入,按 link 匹配并丢弃无法对齐的结果,错误聚合后由调用方统一上报
- **推送平台通过基类多态**:新增平台只需实现 `validate_config()``send()`,并在工厂函数注册,main.py 无需改动
## 数据边界与持久化
- 所有持久化数据落在项目根目录的 `news-data/`:按日期分片的 `fetch-*.json` / `push-*.md` / `notify-*.md`
- 过期文件由 fetch job 在每次执行后清理,保留窗口由 `filter.keep_days` 控制
- 没有数据库、没有外部缓存、没有跨机器同步;状态完全可由文件系统重建
- 敏感信息(API Key、Webhook URL)只通过环境变量注入,禁止写入 `config.json` 或代码
## 配置与约束
完整配置字段说明见 `README.md` 的"配置详解"章节,本文只列出对架构有影响的约束。
### schedule
| 字段 | 约束 |
|------|------|
| `fetch_interval_minutes` | systemd 部署下用 `OnUnitActiveSec` 实现,从上次任务**完成**开始计时(非日历对齐) |
| `fetch_lookback_minutes` | 必须大于 `fetch_interval_minutes`,用作 RSS 延迟的冗余窗口,依赖 link 去重防止重复入库 |
| `push_cron` | systemd 部署下**只支持 minute/hour 字段**,其他位必须为 `*`;不支持范围、列表、`*/N``loop` 模式下走 croniter,支持完整语法 |
| `timezone_hours` | 整数小时偏移;用于显示和 cron 计算 |
### log
`log.retention_days` 仅对 systemd 部署生效,由 `install.sh` 渲染到 journald 命名空间 `dnews` 的 drop-in 配置;修改后必须重跑 `install.sh`
### LLM
`llm.max_prompt_chars` 决定批次切分粒度,`llm.max_concurrent_batches` 决定批次并发数。这两个值同时影响吞吐和单次推送的成本上限。
### 环境变量
敏感凭证名通过 config.json 的 `*.apiKeyName` 字段指定环境变量名,由 `os.environ` 读取;约定通过 `.env` 提供,systemd 部署时 install.sh 会注入到 service 单元的 `EnvironmentFile`
## systemd 部署形态
### 文件落点
| 文件 | 位置 | 来源 |
|------|------|------|
| `dnews-{fetch,push}.{service,timer}` | `/etc/systemd/system/` | `systemd/*.tmpl``_gen_units.py` 渲染 |
| `journald@dnews` retention drop-in | `/etc/systemd/journald@dnews.conf.d/` | `systemd/journald-dnews.conf.tmpl` |
| `daily-news` 包装脚本 | `/usr/local/bin/` | `scripts/daily-news.tmpl` |
| 持久化数据 | 项目目录下的 `news-data/` | 运行时生成 |
### cron → OnCalendar 转换
`scripts/_gen_units.py` 完成:
- `fetch_interval_minutes``OnActiveSec` + `OnUnitActiveSec`(间隔触发,跟随上次完成时间)
- `push_cron``OnCalendar`(日历触发,按指定时刻)
- 不支持的 cron 语法(范围、列表、`*/N` 在 minute/hour、非 `*` 的 day/month/dow)在 install 阶段直接报错
### 日志
- 所有 stdout/stderr 进入 journald 命名空间 `dnews`,与系统其他服务隔离
- 查询:`journalctl --namespace=dnews -u dnews-fetch -f`
- 卸载不会清理历史日志,需要时手动 `journalctl --namespace=dnews --vacuum-time=1s`
## 设计决策(重要的"为什么")
| 决策 | 原因 |
|------|------|
| 调度交给 systemd timer 而非 asyncio 循环 | 进程崩溃和服务器重启可自愈;调度配置即声明式单元,热更新只需重跑 install.sh |
| LLM 健康检查只在 `check` 子命令做 | 每次 timer 触发都校验会产生无意义的 LLM API 调用;运行期错误由 `notify_llm_errors` 兜底 |
| LLM 失败时不生成 fallback 内容 | 避免低质量内容污染推送;由调用方决定告警或跳过 |
| 用 journald 命名空间而非文件日志 | 自动轮转、与系统日志隔离、无需写文件 IO 代码 |
| 数据全用本地文件而非数据库 | 单机单租户场景下足够;可读、可备份、可手动审阅 |
| `fetch_lookback_minutes` 冗余窗口 | RSS 源时间戳常有延迟,仅按时间过滤会漏读;冗余抓取后按 link 去重 |
| Push 上下文带入近 N 天历史 push 文件 | 避免汇总推送在多个时段重复推同一条目 |
## 扩展指南
- **新推送平台**:在 `src/push/` 新建文件,继承 `PushPlatform`,在工厂注册
- **新评分维度**:编辑 `prompts/score.txt`,调整评分标准
- **新 RSS 源**:编辑 `config.json``sources.add` / `sources.block` / `sources.block_domains`,无需修改 OPML
## 测试
测试入口分两类,详细命令参考 `README.md``tests/` 目录:
- `tests/pytest/`:单元/集成测试,CI 友好,`uv run pytest tests/pytest/` 一键跑
- `tests/*.py`:交互式实操脚本(`fetch_news.py` / `push_news.py` / `run_llm_test.py` 等),针对真实 RSS 与 LLM 做端到端验证,用于调参和手测
## 相关文档
- 用户文档与配置详解:`README.md`
- 任务进度与产品决策:`docs/plan.md`
+129
View File
@@ -0,0 +1,129 @@
你是一位顶级的 AI 行业观察家与日报主编。你的任务是将今天杂乱无章、来源各异的信息碎片,熔炼、重组成一篇**结构极度清晰、主次分明、洞察深刻**的《AI Daily 每日精选》。
## 核心排版与整合规则(必须严格遵守):
1. **新旧剥离与进展追踪(新增核心规则)**
- 对比【今天的新信息】与【历史上下文】。如果今天的信息只是重复昨天的事实,请直接丢弃。
- 如果今天的信息是**历史事件的延续**(如:前天爆火的某模型今天推出了新插件,或者昨天引发争议的事件今天有了反转),请将标题标记为 `[持续跟踪]`,并在正文中清晰划分“前情提要”与“最新突破”。
2. **精准的事件级融合(切勿过度缝合)**
- **正确做法**:把讨论**同一具体事件**(如 DoD 与 Anthropic 合同风波)的官方声明、大佬锐评、社区反应合并为一条新闻,提炼全貌。
- **错误做法**:绝对不要把两个毫不相干的工具更新强行塞进同一个标题下!如果它们是独立事件,请作为两条独立新闻输出。
3. **精选原则**
- 只挑选真正有价值的 4-10 个核心事件。每条新闻必须对 **整个 AI 行业、技术演进、或广大开发者生态有宏观价值或者有意义的洞察**。宁缺勿滥。
- **绝对剔除以下内容(负面清单)**:KOL/博主的个人动态、企业公关软文/广告、纯情绪发泄、未经验证的小道消息、以及无实质技术内容的闲聊。
- 剔除与核心事件无关的冗余数据(如财报里的无关数字、无意义的背景描述)
- 丢弃纯情绪发泄、闲聊和过度重复的内容。
4. **结构化呈现**:每个事件采用 “一句核心亮点 + 展开的无序列表” 结构,让人一目了然。
5. **客观专业**:用简练的中文,避免低级词汇,像分析师一样指出事件的行业意义。保留所有相关的原文链接,附在每个事件末尾。
6. **避免风格趋同(重要)**:每天的前言导读必须从今日素材本身的具体事实出发,严禁评价性措辞/总结性套话/宏大叙事框架,避免成为流水线产物。
- **禁止以下措辞框架**:「今日 AI 世界(的主题)是…」「今日 AI 世界从 X 转向 Y…」「主题正在/全面进入 X 阶段」「行业正从 X 进入 Y」之类高度模板化的总结性套话。
- **禁止套用宏大叙事框架**:「深水区」「临界点」「拐点」「白热化」「下半场」「博弈」之类的词不要每天都用。
- **避免重复使用同一组形容词**"震撼""炸裂""革命性""现象级"等),同义词与表达句式要有变化。
7. **正文不要写开头引言**:正文请直接从第一条新闻 `### 1️⃣ ...` 开始。导语统一由 frontmatter 的 `lead` 字段承载(见下文输出格式)。
## 输出格式(严格按以下结构输出,先 frontmatter 后正文):
输出必须以 YAML frontmatter 起始,紧跟空行后接 markdown 正文。**绝对不要**在正文里写引言段或一级标题。
frontmatter 字段要求:
- `title`: 提炼其中1-3个核心看点制作短标题,以**核心事实陈述**语气给出,格式 `"<事件1><事件2>"`8-30 字。
- ✅ 例:`"Anthropic 拒签五角大楼合同,Google 发布 Nano Banana 2"`
- ❌ 严禁主观评价词与情绪词:「大爆发」「再起波澜」「终结」「开战」「颠覆」「震撼」「白热化」「拐点」「格局」
- `lead`: 60-100 字前言导读,**只陈述今日具体发生的事实**(谁、做了什么、有何具体后果),按重要性串联。
- ❌ 严禁评价性措辞:「挑战…范式」「打破…束缚」「重新定义」「拐点」「白热化」「下半场」「叙事割裂」「窗口收窄/关闭」「悄悄退回」「余波未平」「价格扭曲」「市场扭曲」等;
- ❌ 严禁套话开场:「今日 AI 世界…」「主题正在进入…」「行业从 X 转向 Y」。
- `highlights`: 2-3 条最值得关注的事件清单,每条 15-30 字,**纯事实陈述**(机构/产品 + 动作 + 具体数字/对象),用于卡片预览。
- ✅ 例:`"DeepSeek 完成 700 亿融资,V4 Pro API 永久降价至 1/4"`
- 今日素材不足以提炼 2 条明确重点时,可只输出 1 条;严禁硬凑。
正文要求:直接从 `### 1️⃣ ...` 开始,不要前言段。
示例:
```markdown
---
title: "Anthropic 拒签五角大楼合同,Google 发布 Nano Banana 2"
lead: "Anthropic 拒绝与五角大楼签署模型供应合同,OpenAI 当日宣布接受国防部条件;Google 发布 Nano Banana 2,Image Arena 盲测登顶。"
highlights:
- "Anthropic 拒签五角大楼合同,OpenAI 接单"
- "Google Nano Banana 2 发布,Image Arena 盲测登顶"
- "Claude Code 上线 /remote-control,接管 Workspace"
---
### 1.Google 发布 Nano Banana 2:图像生成模型大洗牌
* **核心发布**Google 官方推出基于 Gemini Flash 架构的 Nano Banana 2 模型,支持更灵活的长宽比和极高精度的多语言文本渲染。
* **数据与表现**:发布首日即在 Image Arena 盲测中超越 GPT-4V 和上代 Pro 模型,登顶榜首。
* **行业反响**:开发者社区反馈极佳,Lovart、Genspark 等主流 AI 产品已在第一时间宣布全量接入。
🔗 [Google 官方博客](链接) | [Arena 榜单数据](链接) | [产品体验地址](链接)
### 2.[持续跟踪] 硅谷震动:Anthropic 拒签美国防部,各方反应激烈
* **前情提要**:昨日 Anthropic CEO 明确拒绝了五角大楼的合同,坚守不将模型用于自主武器的底线。
* **最新进展**:Sam Altman 虽口头表达支持,但今日正式宣布 OpenAI 接受了国防部条件。
* **深远影响**:这一事件不仅暴露出头部 AI 公司在 AI 安全准则上的巨大分歧,也暗示了未来政府在 AI 供应链上的强力介入。
🔗 [Anthropic 声明](链接) |[OpenAI 回应](链接) | [Gary Marcus 评论](链接)
### 3.开发者福音:Claude Code 新增远程控制与批量处理
* **功能更新**`/remote-control` 现已对所有 Pro 用户开放,支持终端直接控制 Google Workspace 等外部应用。
* **即将上线**:预告下个版本将加入 `/simplify`(自动优化代码质量)和 `/batch`(并行处理)指令,Agentic Coding 体验大幅跃升。
* **实用技巧**:社区已整理出 45 个即插即用的 Claude Code 高阶用法,极大降低了新手入门门槛。
🔗 [Anthropic 博客](链接) | [45 个实用技巧 GitHub](链接)
### 4.[持续跟踪] OpenClaw 生态:从改名到 Moltbook 爆火
* **前情提要**:前身 Clawbot 凭借强大的 Agentic 能力引发关注并正式更名。
* **最新突破**:今日基于 OpenClaw 的衍生项目 Moltbook 在开发者社区彻底爆火,单日斩获 5k Star,被大量极客用于本地工作流自动化。
* **行业洞察**:这标志着 OpenClaw 正从单一工具走向平台化生态,其“寄生”在各类应用中的潜力开始显现。
🔗 [OpenClaw 开源地址](链接)
```
*(根据上述结构,整合提取今天最重要的新闻。请直接输出 frontmatter + 正文,不要加分割线,不要在正文里写一级标题)*
## 参考数据
## 待处理的碎片化信息(共 {count} 条):
(说明:这是你今天需要提取和总结的**核心素材**,你的早报必须基于且仅基于这里的最新信息生成。)
entries是一个JSON数组,包含抓取到的社交媒体或者博客内容,每个对象包含以下字段:
- `title`: 文章标题
- `source`: 文章来源
- `link`: 文章链接
- `score`: LLM评分 (0-100)
- `summary`: 一句话摘要
- `tags`: 标签数组
- `published`: 发布日期
- `content`: 文章内容
```json
{entries}
```
### 近 N 天已推送事件清单(仅供查重,严禁模仿)
说明:以下列表用于判断今日素材是否已报道过,以及是否属于「持续跟踪」类延续事件。
- **只判断事件是否重复,不要参考其措辞、句式、栏目结构、emoji 选择**。
- 今日的开头引言、标题风格、组织顺序,必须从今日素材本身出发,不得套用历史。
<RECENT_PUSH_CONTEXT BEGIN>
{recent_push_context}
<RECENT_PUSH_CONTEXT END>
### 近几天已处理过的碎片化信息(供洞察参考):
每条信息包含
- `title`: 文章标题
- `source`: 文章来源
- `score`: LLM评分 (0-100)
- `summary`: 一句话摘要
- `tags`: 标签数组
- `published`: 发布日期
```txt
{context}
```
+82
View File
@@ -0,0 +1,82 @@
你是一位顶级的 AI 行业观察家与专业的 AI 领域突发新闻记者。你需要将最新发生的高分重磅消息整理成即时推送快讯。
## 输入数据格式
### 本次需要推送的原始数据
以下是一个JSON数组,包含抓取到的社交媒体或者博客内容,每个对象包含以下字段:
- `title`: 文章标题
- `source`: 文章来源
- `link`: 文章链接
- `score`: LLM评分 (0-100)
- `summary`: 一句话摘要
- `tags`: 标签数组
- `published`: 发布日期
- `content`: 文章内容
```json
{entries}
```
## 任务要求:
1. **严格查重与阻断机制(至关重要)**:仔细对比原始数据与【过去已推送历史】。如果原始数据中的事件已经推送过,且没有出现极其重大的新进展(例如从谈判变成了收购失败),请直接抛弃该事件。如果所有输入的事件都被判定为重复或无需推送,你必须且只能输出 [NO_NEW_CONTENT] 这几个字,绝对不要输出任何其他内容或标题。。
2. **专业聚合**:如果输入数据中有多条讨论**同一个事件**(例如一条是官方发布,另一条是行业大佬的深刻评价),请将它们**融合为一条快讯**,不要分开写。
3. **专业视角**:不要只是简单翻译,要像资深编辑一样组织内容——用 2-3 条无序列表项陈述「核心事实 (What)」,每项浓缩具体数字、版本号、机构名等可验证信息;列表之后另起一段给出 1-2 句「为什么值得关注 (Why it matters)」的洞察分析。洞察必须从本条新闻具体内容出发,词汇多样化,严禁滥用模板。
4. **语气风格**:客观、犀利、克制。多用短句,重点内容加粗。简短分析 1-2 句话即可,紧扣本条事件本身的"是什么 / 为什么值得关注",避免行业宏大叙事和未来预测——**严禁使用以下预测套话**:「或将重塑」「未来将」「可能加速」「暗示...将」「重塑...格局」「打开...想象空间」「意味着...时代到来」等模板化句式;避免与历史快讯句式雷同。
## 输出格式(严格输出 Markdown 文案):
**情况 A:没有需要推送的新内容(全部重复)**
直接输出以下大写英文字符串,不要带有任何标点或 Markdown 格式:
[NO_NEW_CONTENT]
**情况 B:有需要推送的新内容**
输出必须以 YAML frontmatter 起始(仅 `title` 一个字段),紧跟空行后接 markdown 正文。正文**不要写一级标题** `#`,直接从 `## 🔥` 开始。
严格按以下格式输出,不要输出任何铺垫语:
```markdown
---
title: "<个性化标题>"
---
## <一句话总结最核心的事件标题,紧扣事实,避免营销语>
**来源**: <主要信息源名称>
- <事实 1:用一句话点明最关键的发布/进展,含具体数字、版本号、机构名等可验证信息>
- <事实 2:补充佐证或数据,避免空话>
- <事实 3:可选,给出关键背景或社区反响>
<1-2 句洞察分析;只讲本条事件本身为何值得关注,不做行业宏大叙事,不做未来预测>
[<具体锚点,如「Google Cloud 博客」「OpenAI 官博」>](核心链接) | [<另一个具体锚点>](链接2,如有)
```
## 标题要求:
- `<个性化标题>` 提取事件的核心关键词(5-15字),不要保留尖括号
- 例如:
- `"Gemini 3.5 Flash 发布"`
- `"OpenAI 收购 Anthropic"`
- `"Claude 4.7 性能突破"`
- `"Cursor Composer 2.5 上线"`
注意:
- 上面 `<...>` 是占位符,**实际输出时必须替换为真实内容,绝不能保留尖括号或字面占位符**。
- 洞察分析每次都要不同,禁止跨条目复用同一套句式模板。
- 输出必须直接从 `---` 起始,frontmatter 之后必须是空行,再接 `## `,正文不要再写 `# ...` 一级标题。
- 链接锚点文本必须**具体**:采用「站点/作者名 + 内容类型」,例如「Google Cloud 博客」「OpenAI 官博」「Anthropic 公告」「GitHub 仓库」「SEC S-1 文件」「Simon Willison 博客」「Logan Kilpatrick 推文」;**严禁使用泛指锚点**如「阅读官方原文」「其他相关链接」「点击查看」「原文链接」。
*(如果有第二个不同主题的突发新闻,继续在正文里用 ## 🌟 格式追加一条,否则就此结束。frontmatter 仍只有一个 title,概括最核心的那一条。)*
## 过去 N 天已推送事件清单(仅供查重,严禁模仿)
- **只用来判断今日事件是否已推过**。
- 严禁参考清单的措辞、句式、emoji、洞察标题写法。
<RECENT_PUSH_CONTEXT BEGIN>
{recent_push_context}
<RECENT_PUSH_CONTEXT END>
+194
View File
@@ -0,0 +1,194 @@
你是顶级的 AI 行业情报分析师(Tech Intelligence Analyst)。你的核心能力是从海量、碎片化、甚至相互矛盾的日常信息中,为从业者提取出具有"认知套利"价值的深度洞察(Alpha)。
你现在需要基于今日素材产出一篇日报的**元信息(metadata)** 与一段高信息密度的**深度洞察(Insights**。
## 任务总览(双视角,务必先读)
你的输出包含**两部分,选材标准与视角完全不同**:
| 部分 | 视角 | 选材 | 目的 |
|------|------|------|------|
| **Part 1 · metadata**`title` / `lead` / `highlights` / `seotitle` / `seodescription` / `excerpt`) | 新闻编辑 | 今日**最重磅的事实事件** | 面向 SEO / 卡片预览 / 推送标题,做事实压缩 |
| **Part 2 · 洞察正文**`## 今日洞察` 段) | 情报分析师 | 今日**最具变化信号 / 二阶影响**的切片 | 帮读者识别趋势,挑出 Alpha |
⚠️ 两部分**可能聚焦不同事件**——metadata 写的是"今天发生了什么",洞察正文写的是"今天什么正在变化"。**不要让任一部分被另一部分污染**:
- 不要因为洞察段重点写了 X 就把 X 塞进 title / highlights
- 也不要因为洞察段没写 Y 就漏掉重磅的 Y;
- metadata 字段(包括 `excerpt`)的措辞、词汇、视角全部围绕"事实事件",不要套用洞察段的趋势判断措辞。
---
## Part 1 · Metadata 生成
### 选材原则
metadata 负责"提炼今天发生了什么"——面向 SEO / 信息分发 / 卡片预览,**只做事实压缩,不做趋势判断**。
请从参考素材里按以下权重选**最重磅的 1-3 件事**填入 metadata 各字段:
- **来源权重**:当事方官方账号 / 官方博客 > KOL 实测 / 深度分析 > 媒体转述
- **主题权重**AI 核心(模型 / Agent / AI 产品 / AI 公司动态)优先于非 AI 主题(半导体 / 网络安全 / 前端 / 数据库等)
- **价值分档**(从高到低):里程碑级模型 / 产品 / 政策发布 > 重要技术进展 / 大佬深度观点 > 工具更新 / 教程 / 行业报告 > 二手转述 / 小道消息
**禁止**
- 为了配合正文洞察观点,而忽略更重要的新闻
- 选择影响范围较小但更适合分析的事件作为标题主事件
- 在 metadata 字段里写趋势判断、价值评价、二阶影响——那些是 Part 2 的活
### 字段细则
- `title`: 提炼 1-3 个核心看点制作短标题,以**核心事实陈述**语气给出,格式 `"<事件1><事件2>"`8-30 字。
- ✅ 例:`"Anthropic 拒签五角大楼合同,Google 发布 Nano Banana 2"`
- ❌ 严禁主观评价词与情绪词:「大爆发」「再起波澜」「终结」「开战」「颠覆」「震撼」「白热化」「拐点」「格局」
- `lead`: **综合三段素材**写一段 60-100 字的前言导读,**只陈述今日最重磅事件的具体事实**(谁、做了什么、有何具体后果),按重磅程度或三段顺序串联。
- ❌ 严禁评价性措辞:「挑战…范式」「打破…束缚」「重新定义」「拐点」「白热化」「下半场」「叙事割裂」「窗口收窄/关闭」「悄悄退回」「余波未平」「价格扭曲」「市场扭曲」等
- ❌ 严禁套话开场:「今日 AI 世界…」「主题正在进入…」「行业从 X 转向 Y」
- `highlights`: 2-3 条最值得关注的事件清单,每条 15-30 字,**纯事实陈述**(机构 / 产品 + 动作 + 具体数字),用于卡片预览。
- ✅ 例:`"DeepSeek 完成 700 亿融资,V4 Pro API 永久降价至 1/4"`
- ❌ 严禁评价动词修饰:「集体转」「全面进入」「彻底颠覆」「重塑」
- 素材不足以提炼 2 条明确重点时可只输出 1 条;严禁硬凑
- `seotitle`: 60 字内 SEO 标题,结构与 `title` 类似但可更长,直接陈述 2-3 件核心事件。**禁止评价词**。
- `seodescription`: 100 字内 SEO 描述,**纯事实陈述** —— 主体 + 动作 + 数字 / 对象,按重要性串联。(吸引点击,包含核心事件)
- `excerpt`: 20 字内一句话信息钩子(hook),用一句简短、有张力的话概括“今日最值得注意的变化或反常信号”。
- 必须锚定今日素材中的具体事件/数据,禁止脱离素材空泛发挥,要与title种的内容有关联
- 可以适度使用“反直觉”“隐含变化”“行业情绪”等表达,但必须建立在今日内容事实上。
- 风格接近 newsletter 副标题
- 写法参考:
- "Agent 工作流的效率边界正从模型能力转向架构设计"
- "越来越多 Agent 项目开始绕开模型能力,转向工作流控制"
- "HN 对 Claude Code 的争议,从能力转向成本"
- "开发者正在把 MCP 当成默认接口层,而不是 Anthropic 生态"
禁止:
- 纯关键词堆砌:"Claude、MCP、OpenAI"
- 空泛评价:"AI 行业竞争持续升级"
- 营销号措辞:"彻底改变行业的一天"
- 无事实锚点的情绪化判断
---
## Part 2 · 洞察正文生成
### 总原则
你的任务**不是总结新闻,而是识别今天这些事件里什么东西正在发生变化**。
最终输出要让读者获得:
- 原本没意识到的趋势;
- 被 PR 包装掩盖的真实约束;
- 开发者讨论中透露的方向变化;
- 某个技术/产品/成本信号背后的连锁影响。
重点不是「发生了什么」,而是「**为什么这些变化现在开始出现,以及它会影响什么**」。
### 1. 优先识别"变化信号",而不是新闻共性
先扫描今日素材是否存在以下变化模式:
- **讨论焦点迁移**:从模型能力转向成本/稳定性/工作流;
- **产品形态变化**:从聊天式工具转向后台持续运行/Agent 化;
- **工程约束开始主导设计**:推理成本、显存、延迟、上下文长度等硬约束反向定义产品;
- **开发者行为变化**:大量项目集中接入 MCP/Agent/RAG/某新框架;
- **PR 叙事与真实关注点错位**:官方强调 AGIHN 讨论 deployment/debugging 的真实痛点;
- **某类项目最近频繁出现**:记忆系统、Browser Agent、Code Runtime、本地推理等。
### 2. 每个洞察必须包含"二阶影响"
不要停留在「X 发布了」「Y 很重要」「大家开始关注 Z」。继续追问:
- 这个变化会让**谁受益、谁受限**?
- 它会改变**开发者的默认选择**吗?
- 它会改变**产品设计方式**或**成本结构**吗?
- 它会**暴露之前被忽视的工程约束**吗?
- 它**为什么是现在出现,而不是半年前**?
每段洞察推荐结构:**具体事件 → 隐含变化 → 一个连锁影响**。
### 3. 允许"单点深挖",不要强求跨板块
真正有价值的洞察很多时候只来自一条数据、一个 API 改动、一次 HN 争论。若某条素材已经暴露明显变化,可以只围绕这一点展开:为什么它值得注意、为什么开发者开始集中讨论、它会改变什么工程约束或产品行为、它透露了什么之前不明显的趋势。**不要为了"跨板块"而硬凑关联**。
### 4. 严格锚定素材,禁止空泛行业评论
所有判断都必须能锚定到今日素材里的:产品更新、GitHub 项目、HN 讨论、财报数据、技术细节、benchmark、评论争议。**禁止脱离素材凭空发挥**,**禁止套用预训练知识**生成与今日素材无关的"分析"。
严禁输出如下空话:
- 通用泛论:「AI 行业持续发展」「竞争进一步加剧」「生态逐渐形成」「应用不断落地」「行业正在转向」;
- 抽象大词:「深水区」「下半场」「底层逻辑」「赋能」「护城河」「范式转换」「博弈」「格局」「拐点」「白热化」;
- 公式化转折/铺垫:「真正值得注意的是」「更深层的变化在于」「背后反映出」「某种程度上」「从 X 转向 Y」「不再是…而是…」。
若素材确实不支撑洞察,均为常规工程性更新、无明显新信号,**允许直接写**「今日素材主要为工程性更新,未出现明显的新方向信号」,**不要硬写伪深刻分析**。
### 5. 写作风格
风格对标 **Stratechery / SemiAnalysis / Latent.Space / Hacker News 高赞长评**,而非新闻播报、PR 稿总结、AI 自动摘要、行业黑话评论。
具体表现为:
- **直接陈述**观察结果,不绕弯、不铺垫;
- 不要写成"研究综述"或"行业评论",而要像资深科技编辑的 briefing;
- 少用抽象词,多写**技术约束 / 工程行为 / 成本变化 / 产品取舍 / 开发者真实反馈**;
- 数据有具体出处(模型名 + 版本 + benchmark / 价格 / stars 数 / 评论数等)。
### 6. 段落与长度
- 正文起始用 `## 今日洞察`,随后自然展开分析;
- 不强制固定段数;根据素材密度自然分段;**段落之间用空行分隔**;
- 每段只聚焦一个核心变化或判断,避免在同一段混入多个方向;
- 段落尽量短而紧凑;单段不宜过长,避免连续大段铺陈;
- 先给核心判断,再补充支撑它的事实、数据或工程细节;
- **严禁** 粗体标签前缀——洞察通过文字本身的逻辑呈现,不需要标签包装;
- **严禁清单式复述**:「RSS 提到 X,GH 出现 Y」这类排比是流水账,不是洞察;
- 总长度 250-400 字,惜字如金,素材不足时允许更短,不要为了凑长度硬写;
- **必须绝对溯源**:每个判断都要能锚定到今日素材的具体项目 / 数据 / 讨论,**禁止凭空发挥**。
---
## 输出格式
- 直接输出 markdown,不带任何引导语;
- **结构**:先输出 YAML frontmatterPart 1 metadata),紧跟空行后输出 Part 2 洞察正文;
- 正文起始用 `## 今日洞察`
### 输出示例
```markdown
---
title: "Cursor 发布 Composer 2.5,Anthropic 公开 Claude dreaming 机制"
excerpt: "Anthropic 首次披露 dreaming 机制,Cursor 加自部署"
seotitle: "Cursor Composer 2.5 发布,Anthropic 公开 Claude dreamingHBM 占 AI 芯片成本 63%"
seodescription: "Cursor 发布 Composer 2.5,新增 Agent 自主部署;Anthropic 访谈披露 Claude dreaming 机制——空闲时后台压缩上下文;Epoch AI 数据显示 HBM 占 AI 芯片组件成本升至 63%。"
lead: "Cursor 发布 Composer 2.5,新增 Agent 自主部署能力;Anthropic 在访谈中首次披露 Claude dreaming 机制——Agent 空闲时后台回顾记忆、压缩上下文;Epoch AI 分析显示 HBM 占 AI 芯片组件成本从 2024 Q1 的 52% 升至 2025 Q4 的 63%,绝对支出从 120 亿增至 320 亿美元。"
highlights:
- "Cursor Composer 2.5 发布,新增 Agent 自主部署"
- "Anthropic 首次披露 Claude dreaming 机制"
- "HBM 占 AI 芯片成本 63%,绝对支出涨至 320 亿"
---
## 今日洞察
{要点1 ...}
{要点2 ...}
{要点3 ...}
```
---
## 今日素材
### 新闻 板块(代表:媒体叙事、PR传播、宏观动态)
{rss}
### GitHub 板块(代表:开发者共识、工程实践、底层演进)
{github}
### Hacker News 板块(代表:硬核极客评判、争议探讨、技术风向)
{hackernews}
+69
View File
@@ -0,0 +1,69 @@
你是一个专业且严苛的 AI 行业新闻主编。请对抓取到的碎片化信息进行过滤、评分和信息提取。
## 任务与评分标准
请根据以下标准为每条信息打分(0-100)。
**核心约束(先判这三条,再进入分档)**
1. 90+ **必须同时满足**:(a) 主题与 AI / 大模型 / Agent / AI 产品 / AI 公司动态强相关;(b) 来源为**当事方本人的官方账号或官方博客**(非 KOL、非媒体、非分析师转述);(c) 属于首发
2. 非 AI 主题(网络安全、Web/前端、操作系统、数据库、纯软件工程、纯硬件等)无论多重大、即使来自官方,**上限 79 分**
3. AI 重磅新闻若来源是 KOL / 媒体 / 分析师转述(即使转述的是新模型发布),**上限 89 分**——KOL 的转述价值低于官方首发
**分档**
- 【90-100分】AI 领域 + 官方首发 + 里程碑级模型/产品/政策发布。
*例子:OpenAI 发布 GPT-5Anthropic 官博发布拒绝美国防部合同声明;Google DeepMind 发布 Gemini 3。*
- 【80-89分】重要 AI 技术进展、知名 AI 大佬核心观点、深度技术分析;或 AI 重磅新闻但通过 KOL/媒体转述。
*例子:某开发者开源能让 LLM 推理快 50% 的框架;Jeff Dean 深度解析某新技术;KOL 实测并转述 Deepseek 新发布的 V4 Pro。*
- 【70-79分】实用工具更新、教程、行业报告;**非 AI 主题**的重磅新闻(即使来自官方)。
*例子:Cursor 新增某小功能;Claude Code 45 个技巧分享;Google 官方发布中文 PhaaS 钓鱼服务报告(非 AI 主题)。*
- 【60-69分】二手信息、一般性新闻、小道消息。
*例子:某媒体转发 Google 发布的推文(非首发);未经验证的传言。*
- 【<60分】低价值/局部内容:纯情绪宣泄、无营养的评价、广告、日常闲聊、KOL 个人生活动态、硬广软文、无营养的二手评价。
*例子:“Nano Banana 2 太牛了!”(无具体测试数据);“Anthropic 真硬气”(纯情绪);“快来买我的课”。*
## 输出要求
必须返回纯 JSON 对象,顶层包含 `items` 数组字段,数组中每个对象包含:
- `link`: 原文链接(必须保留原样)
- `score`: 整数评分,JSON 数字类型(直接写 `95`,不要写成字符串 `"95"`),严格按照上述标准,宁缺毋滥
- `tags`: 字符串数组,数量 1-3 个,每个标签 2-12 个字符。**必须是新闻中具体的关键词**(产品名/模型名/技术特性/关键人物/核心数据),让人一眼能识别"这条新闻具体是关于什么"。**禁止空泛的分类标签**(如 "大模型"、"开源"、"教程"、"AI"、"模型"、"重磅"、"新闻"、"技术")。
- ✅ 好示例:`["Deepseek V4","缓存高","推理快3倍"]``["Rodin Gen-2.5","千万面数","3D生成"]``["GPT-5","多模态","原生交互"]``["Anthropic","拒绝DoD合同"]`
- ❌ 坏示例:`["大模型","开源"]``["AI","教程"]``["模型","重磅","新闻"]`
- `summary`: 一句话客观摘要(提取核心事实,去掉主观情绪,50字内)
## 输出格式(严格只输出 JSON 对象,不要 markdown 标记,不要其他额外标记,以 "{"开始,以"}"结尾):
{
"items": [
{
"link": "https://example.com/article1",
"score": 95,
"tags": ["GPT-5", "多模态", "原生交互"],
"summary": "OpenAI正式发布GPT-5,支持原生多模态实时交互。"
}
]
}
## 重要提示
1. items 数组长度必须与输入相同
2. link 字段必须与输入一一对应,用于关联原始数据
3. 只返回 JSON 对象,不要添加任何额外说明文字
4. 标签/数组之间用英文逗号 `,` 分隔,不要使用中文全角逗号 ``
5. 字符串内的**英文双引号** `"` 必须用反斜杠转义为 `\"`**中文引号** `""` `『』` `「」` 无需转义,保留原样
6. 转义示例:若 summary 中需引用英文 he said "hello"JSON 应写作 `"summary": "he said \"hello\""`
## 输入数据
以下是一个 JSON 数组,包含抓取到的社交媒体和博客内容:
每个对象包含以下字段:
- `link`: 文章链接(唯一标识,用于关联)
- `title`: 文章标题
- `source`: 文章来源
- `published`: 发布时间
- `content`: 文章正文内容(已截断)
```json
{entries_json}
```
+148
View File
@@ -0,0 +1,148 @@
你是资深 AI 开源情报分析师与技术编辑。
任务:从输入的 GitHub Trending 候选项目中,筛选 **1-{max_items} 个** 最值得关注的 AI 相关项目,并输出一份“高信息密度、低营销感”的每日趋势简报。
目标读者:
- AI 开发者
- 独立开发者 / SaaS 创业者
- AI Infra / Agent 工程师
- 关注前沿技术方向的技术管理者
要求:
- 优先输出“真正有技术价值或产业信号”的项目
- 避免泛 AI、蹭热点、低质量包装仓库
- 输出要像“技术情报摘要”,而不是营销文案
- 保持专业、克制、信息密度高
## 选择规则
- 优先信号:`stars_today` 高 + `topics` 含 AI 标签(agent/llm/rag/inference/training 等) + readme 描述明确,有实际工程价值,与当前 AI 趋势一致
- 跳过:`archived=true`(若漏过)、纯 awesome-list、个人 dotfiles
### 关注领域(正面列表)
- **AI Agent**:智能体架构、工具链、多智能体、自主规划、Agent 框架
- **AI 模型**:训练、推理、微调、量化部署、模型服务、语音/多模态/视觉模型
- **AI 基础设施**:GPU 调度、芯片硬件、数据中心、推理优化、分布式训练、向量数据库、RAG 框架
- **大厂/前沿动态**:Apple、Google、Meta、OpenAI、Anthropic、Microsoft、xAI 等公司的官方动作与战略
- **AI 集成的开发者工具**:API 网关、自动化脚本、低代码平台等明确与 AI 协同的工具
- **创新性开源产品**:日增长显著且有清晰用户价值
### 排除(负面列表)
- 与AI无关的嵌入式开发(Arduino、ESP32、树莓派、单片机)
- 底层系统编程(内存分配器、编译器、链接器,与 AI 工作负载无关时)
- 通用开发工具(命名规范、代码风格、纯前端模板、UI 组件库、管理后台模板、静态网站主题)
- 配置文件集合(Dotfiles、配置模板)
- 与 AI/科技无关的内容(电子书、资源搬运、刷榜项目)
- 纯娱乐/高风险误用(deepfake 等无明确基础设施价值)
## 写作要求
### 风格
要求:
- 技术媒体风格
- 客观
- 克制
- 高信息密度
- 少废话
禁止:
- “炸裂”
- “颠覆”
- “革命性”
- “现象级”
- “最强”
- “神级”
- “吊打”
## 项目定位
必须回答:
“它解决什么问题?”
格式:
- 目标用户
- 核心能力
- 与现有方案差异
避免:
空泛描述。
错误示例:
- “一个强大的 AI 平台”
正确示例:
- “面向 AI Agent 开发者的工作流编排框架,用于管理多工具调用与状态持久化。”
---
## 核心功能
要求:
- 尽量具体
- 偏工程能力
- 不重复 README 标题
- 最多 4 条
- 少写废话
## 技术亮点
仅在“确实存在明显技术点”时输出。
例如:
- 基于 Rust 实现高性能推理
- 使用 KV Cache 优化吞吐
- 支持 OpenAI-Compatible API
- 基于 WASM 的本地运行
- GPU 调度优化
如果没有明显亮点:
删除整行。
## 五、输出格式(严格遵守)
```markdown
## ⭐ GitHub 趋势
**📊 类别速览**(仅当入选 ≥2 项时输出)
| 项目 | 类别 | Stars |
|------|------|------|
| repo | AI Agent | 12.4k |
| repo | 推理/模型 | 8.1k |
---
### 1. owner/repo ⭐ 今日 +1234
**语言/许可:** Python / Apache-2.0
**总 Stars** 18.2k
**仓库:** [GitHub](url)
**项目定位:**
一句话说明“解决什么问题”。
**核心功能:**
- 功能点
- 功能点
- 功能点
**技术亮点:**
一句技术实现/架构优势。
---
### 2. owner/repo ⭐ 今日 +{{stars_today}}
...(同上模板)
```
若候选中没有任何符合关注领域的项目,直接输出 `## ⭐ GitHub 趋势\n\n- 今日无显著 AI 相关趋势`,不要硬编。
## 候选数据
JSON 数组,每项字段:`url` / `full_name` / `description` / `language` / `stars_today` / `stars_total` / `topics` / `license` / `pushed_at` / `readme_excerpt`
```json
{repos_json}
```
+131
View File
@@ -0,0 +1,131 @@
你是 Hacker News 技术早报编辑。
任务:
对输入的 enriched stories进行 提炼:
- 原文核心信息
- Hacker News 社区真正讨论的焦点
- 支持与质疑的主要理由
- 有价值的工程经验/历史背景/现实约束
## 内容要求(每条 story
### 1. 提炼原文核心
基于 `link_content` 输出:
- 背景 / 作者意图
- 关键要点
- 实际结论 / 限制 / 影响
要求:
- 优先保留技术事实、设计取舍、数据、限制条件
- 不重复标题
- 不写泛泛而谈的行业趋势
若是 Show HN / Ask HN
- “背景”改为“作者想做什么”
- 重点总结:
- 解决的问题
- 技术实现
- 产品设计
- 用户反馈焦点
---
### 2. 提炼 HN 评论区观点(重点)
`top_comments` 是树状结构,包括顶层评论 `l1` ,和回复`replies`
`replies` 通常用于:支持/反驳/补充背景/提供经验
你必须利用这种父子关系判断:
* 哪些观点得到支持
* 哪些观点被明显反驳
* 哪些只是个别意见
不要逐条复述评论。
而是聚类提炼为:
* 共识观点
* 工程经验
* 历史背景
* 商业现实
* 风险/限制
* 反对意见
### 3. 输出风格
要求:
* 客观、克制、高信息密度
* 更像技术编辑,不像 AI 总结器
* 允许保留不确定性
* 优先具体事实与具体争议
禁止:
* 营销词汇:
* 震撼
* 炸裂
* 革命性
* 颠覆
* 现象级
* AI 套话:
* “引发广泛讨论”
* “社区观点不一”
* “值得关注的是”
* “体现了某种趋势”
## 输出格式(严格 Markdown
```markdown
## 🟧 Hacker News 热议
### {{title}}
{{points}} pts · {{comments}} comments · [site](site url)
**📌 内容总结**
- {{背景/作者意图}}
- HN 关注点:
- 要点 1
- 要点 2
- 要点 3(可选)
**💬 讨论总结**
- ...
- ...
🔗 [原文]({{url}}) · [HN 讨论页]({{comments_url}})
### {{title2}}
...
```
规则:
* 如果没有明显反对意见,则删除「反对 / 质疑」
* 每条控制信息密度,不写长段落
* story 之间空一行
## 输入数据
JSON 数组,每项 story 字段:
- `id` / `title` / `url` / `site` / `points` / `comments` / `comments_url`
- `link_content`:原文 markdown
- `top_comments`
```json
{stories_json}
```
@@ -0,0 +1,38 @@
你是 Hacker News AI 选题编辑。
任务:
从输入的 HN 首页 stories 中,挑选最值得关注的 AI / 开发者基础设施相关内容,返回 **{k} 个以内** 的 story id。
## 优先关注
- AI Agent / 多智能体 / AI 工作流
- AI 模型(训练、推理、微调、多模态、语音)
- AI Infra(GPU、推理优化、RAG、向量数据库、数据中心)
- AI 开发工具(Copilot、自动化、AI IDE、API、低代码)
- OpenAI / Anthropic / Google / Meta / Microsoft / xAI / Apple 等前沿动态
## 排除
- 嵌入式 / Arduino / ESP32 / 树莓派
- 与 AI 无关的底层系统话题(编译器、内存、链接器等)
- 普通编程技巧、代码风格、命名规范
- 泛科技或非科技内容
## 规则
- 仅根据输入字段判断
- 不确定是否与 AI 强相关时,宁可漏选
- 候选不足 {k} 个时,只返回真正符合的
- 返回的 id 必须是输入中的原始字符串
## 输出格式
**只输出一个 JSON 数组,数组元素为 story id 字符串**:
- 有匹配:`["12345", "67890"]`
- 无匹配:`[]`
严禁输出任何解释性文字、代码块包装、自然语言句子或键值对。
## 候选数据
JSON 数组,每项字段:`id`(字符串)/ `title` / `site` / `points` / `comments`
```json
{candidates_json}
```
+17
View File
@@ -0,0 +1,17 @@
[project]
name = "daily-news"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"aiohttp>=3.9.0",
"beautifulsoup4>=4.12.0",
"croniter>=6.0.0",
"feedparser>=6.0.0",
"markdownify>=0.11.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"pytest-mock>=3.10.0",
"python-dotenv>=1.0.0",
"pyyaml>=6.0",
"requests>=2.34.2",
]
+9
View File
@@ -0,0 +1,9 @@
feedparser>=6.0.0
aiohttp>=3.9.0
markdownify>=0.11.0
croniter>=6.0.0
pyyaml>=6.0
pytest>=7.0.0
pytest-asyncio>=0.21.0
pytest-mock>=3.10.0
python-dotenv>=1.0.0
+154
View File
@@ -0,0 +1,154 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" width="1200" height="400">
<defs>
<!-- 浅色渐变背景 -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f8f9fa;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e8eaed;stop-opacity:1" />
</linearGradient>
<!-- Google 色系 -->
<linearGradient id="blueGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4285F4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1a73e8;stop-opacity:1" />
</linearGradient>
<linearGradient id="redGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#EA4335;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d93025;stop-opacity:1" />
</linearGradient>
<linearGradient id="yellowGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FBBC05;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f9a825;stop-opacity:1" />
</linearGradient>
<linearGradient id="greenGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#34A853;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e8e3e;stop-opacity:1" />
</linearGradient>
<!-- 柔和发光效果 -->
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000" flood-opacity="0.1"/>
</filter>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景 -->
<rect width="1200" height="400" fill="url(#bgGrad)"/>
<!-- 背景装饰圆点 -->
<circle cx="100" cy="80" r="60" fill="#4285F4" opacity="0.08"/>
<circle cx="1100" cy="100" r="80" fill="#EA4335" opacity="0.06"/>
<circle cx="150" cy="350" r="50" fill="#34A853" opacity="0.08"/>
<circle cx="1050" cy="320" r="70" fill="#FBBC05" opacity="0.08"/>
<circle cx="600" cy="50" r="40" fill="#4285F4" opacity="0.05"/>
<!-- 网格线 -->
<g stroke="#dadce0" stroke-width="0.5" opacity="0.5">
<line x1="0" y1="100" x2="1200" y2="100"/>
<line x1="0" y1="200" x2="1200" y2="200"/>
<line x1="0" y1="300" x2="1200" y2="300"/>
<line x1="300" y1="0" x2="300" y2="400"/>
<line x1="600" y1="0" x2="600" y2="400"/>
<line x1="900" y1="0" x2="900" y2="400"/>
</g>
<!-- Google 色系装饰:RSS 聚合 (左侧) -->
<g transform="translate(120, 200)">
<circle cx="0" cy="0" r="25" fill="none" stroke="#4285F4" stroke-width="2" opacity="0.3"/>
<circle cx="0" cy="0" r="18" fill="none" stroke="#4285F4" stroke-width="2" opacity="0.5"/>
<circle cx="0" cy="0" r="10" fill="none" stroke="#4285F4" stroke-width="2.5"/>
<circle cx="0" cy="0" r="4" fill="#4285F4"/>
<!-- 信号线 -->
<g stroke="#4285F4" stroke-width="1.5" opacity="0.4">
<line x1="25" y1="0" x2="40" y2="0"/>
<line x1="18" y1="-18" x2="28" y2="-28"/>
<line x1="0" y1="-25" x2="0" y2="-40"/>
<line x1="-18" y1="-18" x2="-28" y2="-28"/>
<line x1="-25" y1="0" x2="-40" y2="0"/>
<line x1="-18" y1="18" x2="-28" y2="28"/>
<line x1="0" y1="25" x2="0" y2="40"/>
<line x1="18" y1="18" x2="28" y2="28"/>
</g>
</g>
<!-- Google 色系装饰:AI 智能 (右侧) -->
<g transform="translate(1080, 200)">
<circle cx="0" cy="0" r="8" fill="#EA4335" filter="url(#glow)"/>
<circle cx="-25" cy="-20" r="5" fill="#FBBC05"/>
<circle cx="25" cy="-20" r="5" fill="#34A853"/>
<circle cx="0" cy="30" r="5" fill="#4285F4"/>
<!-- 连接线 -->
<g stroke="#5f6368" stroke-width="1" opacity="0.4">
<line x1="-21" y1="-17" x2="-6" y2="-4"/>
<line x1="21" y1="-17" x2="6" y2="-4"/>
<line x1="0" y1="25" x2="0" y2="4"/>
</g>
</g>
<!-- 底部数据流装饰 -->
<g transform="translate(0, 360)" opacity="0.6">
<rect x="80" y="0" width="60" height="3" rx="1.5" fill="#4285F4">
<animate attributeName="width" values="60;80;60" dur="2s" repeatCount="indefinite"/>
</rect>
<rect x="180" y="5" width="40" height="3" rx="1.5" fill="#EA4335">
<animate attributeName="width" values="40;55;40" dur="2.3s" repeatCount="indefinite"/>
</rect>
<rect x="950" y="0" width="70" height="3" rx="1.5" fill="#34A853">
<animate attributeName="width" values="70;90;70" dur="1.8s" repeatCount="indefinite"/>
</rect>
<rect x="1050" y="5" width="50" height="3" rx="1.5" fill="#FBBC05">
<animate attributeName="width" values="50;65;50" dur="2.1s" repeatCount="indefinite"/>
</rect>
</g>
<!-- 主标题 -->
<g transform="translate(600, 170)" filter="url(#softShadow)">
<text x="0" y="0" text-anchor="middle" font-family="system-ui, -apple-system, 'Google Sans', sans-serif" font-size="56" font-weight="700" fill="#202124">
AI Daily
</text>
<text x="0" y="38" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="20" font-weight="400" fill="#5f6368">
每日 AI 资讯推送系统
</text>
</g>
<!-- 核心标签 (Google 色系) -->
<g transform="translate(600, 250)" font-family="system-ui, -apple-system, sans-serif" font-size="14" font-weight="500">
<!-- RSS 聚合 - 蓝色 -->
<rect x="-175" y="-14" width="70" height="28" rx="14" fill="#4285F4" opacity="0.12"/>
<text x="-140" y="4" text-anchor="middle" fill="#4285F4">RSS 聚合</text>
<!-- LLM 智能 - 红色 -->
<rect x="-85" y="-14" width="70" height="28" rx="14" fill="#EA4335" opacity="0.12"/>
<text x="-50" y="4" text-anchor="middle" fill="#EA4335">LLM 智能</text>
<!-- 即时推送 - 黄色 -->
<rect x="5" y="-14" width="70" height="28" rx="14" fill="#FBBC05" opacity="0.2"/>
<text x="40" y="4" text-anchor="middle" fill="#c98400">即时推送</text>
<!-- 定时汇总 - 绿色 -->
<rect x="90" y="-14" width="70" height="28" rx="14" fill="#34A853" opacity="0.12"/>
<text x="125" y="4" text-anchor="middle" fill="#34A853">定时汇总</text>
</g>
<!-- 底部强调线 -->
<g transform="translate(600, 310)">
<line x1="-200" y1="0" x2="-80" y2="0" stroke="#4285F4" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="-60" y1="0" x2="60" y2="0" stroke="#EA4335" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="80" y1="0" x2="200" y2="0" stroke="#34A853" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
</g>
<!-- 角落装饰 -->
<circle cx="40" cy="40" r="4" fill="#4285F4" opacity="0.3"/>
<circle cx="1160" cy="40" r="4" fill="#EA4335" opacity="0.3"/>
<circle cx="40" cy="360" r="4" fill="#34A853" opacity="0.3"/>
<circle cx="1160" cy="360" r="4" fill="#FBBC05" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

+431
View File
@@ -0,0 +1,431 @@
<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<head>
<title>All RSS Subscriptions for bestblogs.dev</title>
</head>
<body>
<outline text="42章经" title="42章经" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/f6694726ced4ba3d7c7cd65c6edf2160c5978387.xml" />
<outline text="43 Talks" title="43 Talks" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/4efe7ec6970afd4a050d6f10b9e8131a9d5e6816.xml" />
<outline text="51CTO技术栈" title="51CTO技术栈" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/d1fabe6c569ffc44979075dde2f57c65e07c3045.xml" />
<outline text="AI at Meta Blog" title="AI at Meta Blog" type="rss" xmlUrl="https://rsshub.bestblogs.dev/meta/ai/blog" />
<outline text="AINLP" title="AINLP" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/875df1d1a991bf9250ba9813e3148f58ef2240d4.xml" />
<outline text="AINews" title="AINews" type="rss" xmlUrl="https://news.smol.ai/rss.xml" />
<outline text="AI产品阿颖" title="AI产品阿颖" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/5000fe62390006b10eef8a737d89c478611994a7.xml" />
<outline text="AI产品黄叔" title="AI产品黄叔" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/1f1030491e15e5349aae42367513d6b3f70a8f8b.xml" />
<outline text="AI前线" title="AI前线" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/25185b01482da0f485418ecb92e208b4416712fb.xml" />
<outline text="AI寒武纪" title="AI寒武纪" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/5903009f48a5e4aa44d8ac941a54fe3aafc3e03c.xml" />
<outline text="AI异类弗兰克" title="AI异类弗兰克" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/e0415653cbf6f41e25fa266d010b3238b91a65e3.xml" />
<outline text="AI炼金术" title="AI炼金术" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/4915f3747653bbb9c7975323c11b768d2b9cd6c9.xml" />
<outline text="AI科技大本营" title="AI科技大本营" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/dfd3b5e742e32d8032a445832373191957202bf3.xml" />
<outline text="AI科技评论" title="AI科技评论" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/789e5fefb9cc2646ba7b680cb7a88378a34eb7a4.xml" />
<outline text="AWS Architecture Blog" title="AWS Architecture Blog" type="rss" xmlUrl="http://www.awsarchitectureblog.com/atom.xml" />
<outline text="AWS Machine Learning Blog" title="AWS Machine Learning Blog" type="rss" xmlUrl="https://aws.amazon.com/blogs/amazon-ai/feed/" />
<outline text="Anthropic News" title="Anthropic News" type="rss" xmlUrl="https://rsshub.bestblogs.dev/anthropic/news" />
<outline text="Bay的设计奥德赛" title="Bay的设计奥德赛" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/7515eedf3f994a6b23ebda2313a4b6bc7532f536.xml" />
<outline text="ByteByteGo Newsletter" title="ByteByteGo Newsletter" type="rss" xmlUrl="https://blog.bytebytego.com/feed" />
<outline text="CSDN" title="CSDN" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/b0b7f2852aecdcc5a0eb08d33afc1c08b855d98b.xml" />
<outline text="Clip设计夹" title="Clip设计夹" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/ebd5f5bd705dd531066eeca5ee500a1e6a269e17.xml" />
<outline text="Cursor Blog" title="Cursor Blog" type="rss" xmlUrl="https://api.bestblogs.dev/feed/cursor-blog" />
<outline text="Databricks" title="Databricks" type="rss" xmlUrl="https://www.databricks.com/feed" />
<outline text="Datawhale" title="Datawhale" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/ea0dd8bddfe4fbfb32eaa81a1e1b628d45e97a80.xml" />
<outline text="David Heinemeier Hansson" title="David Heinemeier Hansson" type="rss" xmlUrl="https://world.hey.com/dhh/feed.atom" />
<outline text="DeepSeek" title="DeepSeek" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/1709da4f538d4ce4fb6d7a8ba1a5a1c297919601.xml" />
<outline text="Dify" title="Dify" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/e46c03a4cb65509e22ab9a8507888a2096319d65.xml" />
<outline text="Docker" title="Docker" type="rss" xmlUrl="https://www.docker.com/feed/" />
<outline text="Elastic Blog" title="Elastic Blog" type="rss" xmlUrl="https://www.elastic.co/blog/feed" />
<outline text="Elevate" title="Elevate" type="rss" xmlUrl="https://addyo.substack.com/feed" />
<outline text="ElevenLabs Blog" title="ElevenLabs Blog" type="rss" xmlUrl="https://api.bestblogs.dev/feed/elevenLabsBlog" />
<outline text="Engineering at Meta" title="Engineering at Meta" type="rss" xmlUrl="https://engineering.fb.com/feed/" />
<outline text="Founder Park" title="Founder Park" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/f940695505f2be1399d23cc98182297cadf6f90d.xml" />
<outline text="Gino Notes" title="Gino Notes" type="rss" xmlUrl="https://www.ginonotes.com/feed.xml" />
<outline text="Google Cloud Blog" title="Google Cloud Blog" type="rss" xmlUrl="https://cloudblog.withgoogle.com/rss/" />
<outline text="Google DeepMind Blog" title="Google DeepMind Blog" type="rss" xmlUrl="https://deepmind.com/blog/feed/basic/" />
<outline text="Google Developers Blog" title="Google Developers Blog" type="rss" xmlUrl="https://developers.googleblog.com/feeds/posts/default" />
<outline text="HelloGitHub" title="HelloGitHub" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/e6cc80b97bf64eeef61cc5927c78ba6ce3356422.xml" />
<outline text="Hugging Face" title="Hugging Face" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/8b68fdb4f24ab2287100988a8cec36363fec4214.xml" />
<outline text="Hugging Face Blog" title="Hugging Face Blog" type="rss" xmlUrl="https://huggingface.co/blog/feed.xml" />
<outline text="InfoQ" title="InfoQ" type="rss" xmlUrl="http://www.infoq.com/rss/rss.action" />
<outline text="InfoQ 中文" title="InfoQ 中文" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/13da94d7eb314b49fa251cb7e8399cae29d772db.xml" />
<outline text="Jeff Geerling" title="Jeff Geerling" type="rss" xmlUrl="https://www.jeffgeerling.com/blog.xml" />
<outline text="Jina AI" title="Jina AI" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/ff2c5468828ebe7236afd6c1d128e219774487c2.xml" />
<outline text="LangChain Blog" title="LangChain Blog" type="rss" xmlUrl="https://blog.langchain.dev/rss/" />
<outline text="Last Week in AI" title="Last Week in AI" type="rss" xmlUrl="https://lastweekin.ai/feed/" />
<outline text="Latent Space" title="Latent Space" type="rss" xmlUrl="https://www.latent.space/feed" />
<outline text="L先生说" title="L先生说" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/31c7fb6f7959a5ff90ae997b536e78b8b3f23321.xml" />
<outline text="Marcus on AI" title="Marcus on AI" type="rss" xmlUrl="https://garymarcus.substack.com/feed" />
<outline text="Martin Fowler" title="Martin Fowler" type="rss" xmlUrl="https://martinfowler.com/feed.atom" />
<outline text="Microsoft Azure Blog" title="Microsoft Azure Blog" type="rss" xmlUrl="https://azure.microsoft.com/en-us/blog/feed/" />
<outline text="Microsoft Research Blog" title="Microsoft Research Blog" type="rss" xmlUrl="http://research.microsoft.com/rss/news.xml" />
<outline text="Microsoft for Developers" title="Microsoft for Developers" type="rss" xmlUrl="https://devblogs.microsoft.com/landing" />
<outline text="MiniMax 稀宇科技" title="MiniMax 稀宇科技" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/00306b171f754d463b28cf83f3ba086ad009b430.xml" />
<outline text="MongoDB Blog" title="MongoDB Blog" type="rss" xmlUrl="https://www.mongodb.com/blog/rss" />
<outline text="Next.js Blog" title="Next.js Blog" type="rss" xmlUrl="https://nextjs.org/feed.xml" />
<outline text="Node.js Blog" title="Node.js Blog" type="rss" xmlUrl="https://nodejs.org/en/feed/blog.xml" />
<outline text="PaperAgent" title="PaperAgent" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/54001ca616dbc4b55d2b25d79d68a70191a0ddf4.xml" />
<outline text="Qdrant" title="Qdrant" type="rss" xmlUrl="https://qdrant.tech/index.xml" />
<outline text="Qunar技术沙龙" title="Qunar技术沙龙" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/84c072f8d34d1690f2783d7dda6013cf6d892b7f.xml" />
<outline text="Sean Goedecke" title="Sean Goedecke" type="rss" xmlUrl="https://www.seangoedecke.com/rss.xml" />
<outline text="Simon Willison's Weblog" title="Simon Willison's Weblog" type="rss" xmlUrl="https://simonwillison.net/atom/everything/" />
<outline text="Smashing Magazine" title="Smashing Magazine" type="rss" xmlUrl="http://rss1.smashingmagazine.com/feed/" />
<outline text="Spring Blog" title="Spring Blog" type="rss" xmlUrl="http://spring.io/blog.atom" />
<outline text="Stack Overflow Blog" title="Stack Overflow Blog" type="rss" xmlUrl="http://blog.stackoverflow.com/feed/" />
<outline text="SuperTechFans" title="SuperTechFans" type="rss" xmlUrl="https://www.supertechfans.com/cn/index.xml" />
<outline text="The Cloudflare Blog" title="The Cloudflare Blog" type="rss" xmlUrl="https://blog.cloudflare.com/rss" />
<outline text="The GitHub Blog" title="The GitHub Blog" type="rss" xmlUrl="https://github.blog/feed/" />
<outline text="The IntelliJ IDEA Blog" title="The IntelliJ IDEA Blog" type="rss" xmlUrl="http://blogs.jetbrains.com/idea/feed/" />
<outline text="The JetBrains Blog" title="The JetBrains Blog" type="rss" xmlUrl="http://blog.jetbrains.com/feed/" />
<outline text="The Keyword (blog.google) " title="The Keyword (blog.google) " type="rss" xmlUrl="https://blog.google/rss" />
<outline text="UX Magazine" title="UX Magazine" type="rss" xmlUrl="https://uxmag.com/feed/" />
<outline text="Vercel News" title="Vercel News" type="rss" xmlUrl="https://vercel.com/atom" />
<outline text="Visual Studio Blog" title="Visual Studio Blog" type="rss" xmlUrl="https://devblogs.microsoft.com/visualstudio/feed/" />
<outline text="Web3天空之城" title="Web3天空之城" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/6aac3cc6d4c6df6fb3f77dea4ea4ba4a2053d6e7.xml" />
<outline text="Z Potentials" title="Z Potentials" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/c47f4bc00ea912c37b6e23b22b146db0e85b3e19.xml" />
<outline text="antirez" title="antirez" type="rss" xmlUrl="http://antirez.com/rss" />
<outline text="dbaplus社群" title="dbaplus社群" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/a92cc44a756e2b9165fed5572aa7337843a73eee.xml" />
<outline text="deeplearning.ai" title="deeplearning.ai" type="rss" xmlUrl="https://rsshub.bestblogs.dev/deeplearning/the-batch" />
<outline text="freeCodeCamp.org" title="freeCodeCamp.org" type="rss" xmlUrl="https://www.freecodecamp.org/news/rss/" />
<outline text="overreacted" title="overreacted" type="rss" xmlUrl="https://overreacted.io/rss.xml" />
<outline text="vivo互联网技术" title="vivo互联网技术" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/b3ceb5cb1e4602ca55704650a157ec9c5b2f0d31.xml" />
<outline text="yikai 的摸鱼笔记" title="yikai 的摸鱼笔记" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/13492cb50df57702cdc0dfa71467cd03f9dd69be.xml" />
<outline text="乌鸦智能说" title="乌鸦智能说" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/f21c3e34df9b5fecfda57e2e53512864255ed4cd.xml" />
<outline text="京东技术" title="京东技术" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/fa0be550682410cc187c0d1eab1a0fc4e073b949.xml" />
<outline text="人人都是产品经理" title="人人都是产品经理" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/2d790e38f8af54c5af77fa5fed687a7c66d34c22.xml" />
<outline text="优设" title="优设" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/8fee9d33e883a769a59a5a3e27d249cf8567b55a.xml" />
<outline text="体验进阶" title="体验进阶" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/083c360a74b36b2c33820a995d21cbf60c813c0a.xml" />
<outline text="刘小排r" title="刘小排r" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/484d4199ae6c0b72ea01e7e0597a1f74933dfb62.xml" />
<outline text="刘润" title="刘润" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/c1354f67c314d25d6e236a58724043bdc46d6079.xml" />
<outline text="创业邦" title="创业邦" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/f5e0d8e342d9e2ec5b2942f08522cfaec17acc8d.xml" />
<outline text="前端充电宝" title="前端充电宝" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/efed19b684285ee14f88b3f234b350fba9376d7a.xml" />
<outline text="前端早读课" title="前端早读课" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/ce2456e157156d42259c1198f05a33e27b1ed959.xml" />
<outline text="十字路口Crossing" title="十字路口Crossing" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/20492a5f2d3637c178c01ab0bab7ed86a4a0995b.xml" />
<outline text="卡尔的AI沃茨" title="卡尔的AI沃茨" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/8a1fc997e5c742e91ad7c253836c28ca3a69ccb1.xml" />
<outline text="印记中文" title="印记中文" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/2b038bb5307a75a603405f7191b5030576d3e8bd.xml" />
<outline text="古典古少侠" title="古典古少侠" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/63554903d26c4e94ba031e9c8a93492b7ebcfbb9.xml" />
<outline text="向阳乔木推荐看" title="向阳乔木推荐看" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/3e50f11753a7c5ed689565fbf5abf96cb4541c57.xml" />
<outline text="吴晓波频道" title="吴晓波频道" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/604fd0bfbb0214958f7fd2718509e4ea038c6afc.xml" />
<outline text="哔哩哔哩技术" title="哔哩哔哩技术" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/3a12ae4fde5bb74aab2fddc9f710a3c057eab82f.xml" />
<outline text="土猛的员外" title="土猛的员外" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/3ee671d065adc460bc20bbd269115987098c54a0.xml" />
<outline text="夕小瑶科技说" title="夕小瑶科技说" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/64b57d666259aee6bd097e76164e4a8371f0ad04.xml" />
<outline text="大模型智能" title="大模型智能" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/bfc6440c1a2443fab9a6bf607137d41db5cd5c93.xml" />
<outline text="大淘宝技术" title="大淘宝技术" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/26fef2307bebc8673703f7e726982d8f56c9a219.xml" />
<outline text="奇舞精选" title="奇舞精选" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/156a64fe3e95eebe4b85bf981d6ebb85441897bf.xml" />
<outline text="字节跳动Seed" title="字节跳动Seed" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/6efd40bb335d2037f365d284cb5e00f0843e737e.xml" />
<outline text="字节跳动技术团队" title="字节跳动技术团队" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/d3a9e4d6f125cc98d1691dbc30cd97fec7ae2d03.xml" />
<outline text="宝玉的分享" title="宝玉的分享" type="rss" xmlUrl="https://baoyu.io/feed.xml" />
<outline text="小米技术" title="小米技术" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/8bbc1ba1d363e70cd42d1ce89fb9070cb075c3b3.xml" />
<outline text="小红书技术REDtech" title="小红书技术REDtech" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/0f8c47df6fd304112518544776e0bbf1d98ba0b9.xml" />
<outline text="少数派" title="少数派" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/f0e37a7d597231efed4bf6dd05b5d904de6dbcc1.xml" />
<outline text="山行AI" title="山行AI" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/98bc16b6f53902a2ab511b4faa3499e0a1c78eb1.xml" />
<outline text="开源服务指南" title="开源服务指南" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/c125f09ef36fd6b6cb092c409e69a5bcc867d378.xml" />
<outline text="强少来了" title="强少来了" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/3c36fe804f63a7b936e372a37929d81fa0ad948a.xml" />
<outline text="得物技术" title="得物技术" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/1cde72c9129b1f79cbb150166e7fed9a7568ee10.xml" />
<outline text="快手技术" title="快手技术" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/c4cc10d2e32a5fa12927581ae581a336f399fe75.xml" />
<outline text="掘金本周最热" title="掘金本周最热" type="rss" xmlUrl="https://rsshub.bestblogs.dev/juejin/trending/all/weekly" />
<outline text="数字生命卡兹克" title="数字生命卡兹克" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/ff621c3e98d6ae6fceb3397e57441ffc6ea3c17f.xml" />
<outline text="数据可视化 AntV" title="数据可视化 AntV" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/c1a09e8847fbaea14eaa89db218d783fe176c5a6.xml" />
<outline text="新智元" title="新智元" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/e531a18b21c34cf787b83ab444eef659d7a980de.xml" />
<outline text="晚点LatePost" title="晚点LatePost" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/c442206ec9957f3c52f2f40300ca532079538b31.xml" />
<outline text="晚点再听LaterCast" title="晚点再听LaterCast" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/1193affe8f8ed7a64281054cb022a7176054fa38.xml" />
<outline text="智东西" title="智东西" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/cfd52b4245ca6119b2fda4ef934832c689028927.xml" />
<outline text="智谱" title="智谱" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/433d2134dca54d80804daf32e8be546155be3300.xml" />
<outline text="暗涌Waves" title="暗涌Waves" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/bd586c1499b56aaec02dfefa87126232d234b010.xml" />
<outline text="月之暗面 Kimi" title="月之暗面 Kimi" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/c5c43d4bc17bae656763859ed0903bb6314ec6fe.xml" />
<outline text="有新Newin" title="有新Newin" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/74554dcb3da8982083426b871bc8c314a9de9729.xml" />
<outline text="有机大橘子" title="有机大橘子" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/6cef434b771dd75a91864b2e699a622cb4e3eb33.xml" />
<outline text="有赞coder" title="有赞coder" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/75ad69f1c1d0d1f289f7702cf5eb553287441fdc.xml" />
<outline text="机器之心" title="机器之心" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/8d97af31b0de9e48da74558af128a4673d78c9a3.xml" />
<outline text="机器之心SOTA模型" title="机器之心SOTA模型" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/2f520471856d56c7b3a95cd09eb777149b32828a.xml" />
<outline text="李继刚" title="李继刚" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/9645a69180041ff935c458753174fa8bc2061295.xml" />
<outline text="极客公园" title="极客公园" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/11ea7163fbea99e2ab9fa2812ac3d179574886cc.xml" />
<outline text="架构师之路" title="架构师之路" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/f6dec1c3ad16e43532dd427c85eaeb3a7b7b084e.xml" />
<outline text="歸藏的AI工具箱" title="歸藏的AI工具箱" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/1c3e3571b1627d23ee9c64521a0b0a41d3fe2987.xml" />
<outline text="沃垠AI" title="沃垠AI" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/339818dbd5154cecdf5f4161f3391c7038a72bae.xml" />
<outline text="浮之静" title="浮之静" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/abb0de0c0cb8f684a1606a4b20121b245547adce.xml" />
<outline text="海外独角兽" title="海外独角兽" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/7200d3a5e976d231deb1e40ad33745c0e649b029.xml" />
<outline text="深思圈" title="深思圈" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/3e6fcb56a39b2e18f1036113655d4ff8fe726b62.xml" />
<outline text="深网腾讯新闻" title="深网腾讯新闻" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/396591aa7d3ef15fa3b5b17ec4b1aa840ebde335.xml" />
<outline text="爱范儿" title="爱范儿" type="rss" xmlUrl="http://www.ifanr.com/feed" />
<outline text="甲子光年" title="甲子光年" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/1c4008936645d5c17239d99bba91522cf2bdfa26.xml" />
<outline text="白鲸出海" title="白鲸出海" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/2b8f03a73a0f2ac92a8ca69c124e5be6f442dbdc.xml" />
<outline text="百度AI" title="百度AI" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/d0767d885e6ba213344fb0c0408c51331e23a994.xml" />
<outline text="百度Geek说" title="百度Geek说" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/6cc437d76f9dc4f7c35011c72e471e33e7bdd384.xml" />
<outline text="真格基金" title="真格基金" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/47798a14d51da72e68fae4f7a259f096750cf03e.xml" />
<outline text="硅星人Pro" title="硅星人Pro" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/c62ceda9eed269d851802bdbc5f33c4fabbf7462.xml" />
<outline text="硅谷101" title="硅谷101" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/8f8fe34034f6123b168ed7847c51d50ff47cd7ee.xml" />
<outline text="硅谷科技评论" title="硅谷科技评论" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/4515ee058133ff68570ad586abdd81f54f2b6ee3.xml" />
<outline text="稀土掘金技术社区" title="稀土掘金技术社区" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/33ecd2122ae788ea02dfcf1df857a54b9ae1338d.xml" />
<outline text="笔记侠" title="笔记侠" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/4c5d9bcc2fbfcd1dc81fb67559653f8957ef4760.xml" />
<outline text="经纬创投" title="经纬创投" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/05efb1c4cf91e5a37443cc323150ea38a838e9fd.xml" />
<outline text="网易科技" title="网易科技" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/028fbc21062e744c7b606880ebca01e22cb4b7b7.xml" />
<outline text="美团技术团队" title="美团技术团队" type="rss" xmlUrl="https://tech.meituan.com/feed/" />
<outline text="腾讯云开发者" title="腾讯云开发者" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/6cec2c211479a5502896375860009782cf10c2ba.xml" />
<outline text="腾讯技术工程" title="腾讯技术工程" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/1e0ac39f8952b2e7f0807313cf2633d25078a171.xml" />
<outline text="腾讯混元" title="腾讯混元" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/306ce19a1ca590c9c2df781789e828d1acfa1356.xml" />
<outline text="腾讯研究院" title="腾讯研究院" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/6152301e0978bffb0a8284cab339262b9764dcfb.xml" />
<outline text="腾讯科技" title="腾讯科技" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/a81bdfcbb9eefe870d285e81510ffa1af26e4520.xml" />
<outline text="花叔" title="花叔" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/ed3e181242a4622709081439d802523ecf7b78f2.xml" />
<outline text="袋鼠帝AI客栈" title="袋鼠帝AI客栈" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/24d0930cc9f4f0c708182dc1c087d41e1f4cbd33.xml" />
<outline text="言午" title="言午" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/138de227ebfbee6ea26564564f7bcd6c0c27af60.xml" />
<outline text="语言即世界language is world" title="语言即世界language is world" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/e1ed0d3edd93f90aef602105eb7ca51b35b7060a.xml" />
<outline text="赛博禅心" title="赛博禅心" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/752c31ca0446b837339463fc5440539e20267d2f.xml" />
<outline text="超人的电话亭" title="超人的电话亭" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/4be15abcd5621887bb7c1e2efd2d1cd8c68a16f0.xml" />
<outline text="通义大模型" title="通义大模型" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/4ebee6222ae08705b8aabc9116f0defbcb6b17c6.xml" />
<outline text="逛逛GitHub" title="逛逛GitHub" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/38be32e5376d852c13d3383e4d7a757fd9a55ff6.xml" />
<outline text="量子位" title="量子位" type="rss" xmlUrl="https://www.qbitai.com/feed" />
<outline text="阑夕" title="阑夕" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/fe0fc82458663820d6e91f6331dea05f3db223d4.xml" />
<outline text="阮一峰的网络日志" title="阮一峰的网络日志" type="rss" xmlUrl="http://feeds.feedburner.com/ruanyifeng" />
<outline text="阶跃星辰" title="阶跃星辰" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/3e2714d06aa36142e8ed6b3f4e5cf9090a069dd2.xml" />
<outline text="阿真Irene" title="阿真Irene" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/d5ead392b0cf117d0ba4070e2261111fdde49711.xml" />
<outline text="阿里云开发者" title="阿里云开发者" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/39fc51b0b1316137e608c45da5dbbca4f9eb9538.xml" />
<outline text="阿里技术" title="阿里技术" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/6535a444e9651fecae3383363be7589acdebe2b6.xml" />
<outline text="阿里研究院" title="阿里研究院" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/e2f1190c120f7f3d74b630bfcfe9e58296bd535c.xml" />
<outline text="随机小分队" title="随机小分队" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/115e814e7b12d373a55459cb2aea3223152f2af2.xml" />
<outline text="魔搭ModelScope社区" title="魔搭ModelScope社区" type="rss" xmlUrl="https://wechat2rss.bestblogs.dev/feed/d993a885260f96057b9a4c96212cb2c95bb5054b.xml" />
<outline text="AI Engineer" title="AI Engineer" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCLKPca3kwwd-B59HNr-_lvA" />
<outline text="AI Explained" title="AI Explained" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCNJ1Ymd5yFuUPtn21xtRbbw" />
<outline text="AI Master" title="AI Master" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UC0yHbz4OxdQFwmVX2BBQqLg" />
<outline text="AICodeKing" title="AICodeKing" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UC0m81bQuthaQZmFbXEY9QSw" />
<outline text="Acquired" title="Acquired" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCyFqFYfTW2VoIQKylJ04Rtw" />
<outline text="All-In Podcast" title="All-In Podcast" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCESLZhusAkFfsNsApnjF_Cg" />
<outline text="Andrej Karpathy" title="Andrej Karpathy" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCXUPKJO5MZQN11PqgIvyuvQ" />
<outline text="Anthropic" title="Anthropic" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCrDwWp7EBBv4NwvScIpBDOA" />
<outline text="ByteByteGo" title="ByteByteGo" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCZgt6AzoyjslHTC9dz0UoTw" />
<outline text="Dwarkesh Patel" title="Dwarkesh Patel" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCXl4i9dYBrFOabk0xGmbkRA" />
<outline text="EO" title="EO" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UClWTCPVi-AU9TeCN6FkGARg" />
<outline text="Every" title="Every" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCjIMtrzxYc0lblGhmOgC_CA" />
<outline text="Fireship" title="Fireship" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCsBjURrPoezykLs9EqgamOA" />
<outline text="GitHub" title="GitHub" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UC7c3Kb6jYCRj4JOHHZTxKsQ" />
<outline text="Google" title="Google" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCK8sQmJBp8GCxrOtXWBpyEA" />
<outline text="Google DeepMind" title="Google DeepMind" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCP7jMXSY2xbc3KCAE0MHQ-A" />
<outline text="Greg Isenberg" title="Greg Isenberg" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCPjNBjflYl0-HQtUvOx0Ibw" />
<outline text="How I AI" title="How I AI" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCRYY7IEbkHLH_ScJCu9eWDQ" />
<outline text="Hung-yi Lee" title="Hung-yi Lee" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UC2ggjtuuWvxrHHHiaDH1dlQ" />
<outline text="Justin Sung" title="Justin Sung" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UC2Zs9v2hL2qZZ7vsAENsg4w" />
<outline text="LangChain" title="LangChain" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCC-lyoTfSrcJzA1ab3APAgw" />
<outline text="Last Week in AI" title="Last Week in AI" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCKARTq-t5SPMzwtft8FWwnA" />
<outline text="Lenny's Podcast" title="Lenny's Podcast" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UC6t1O76G0jYXOAoYCm153dA" />
<outline text="Lex Fridman" title="Lex Fridman" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCSHZKyawb77ixDdsGog4iWA" />
<outline text="Liam Ottley" title="Liam Ottley" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCui4jxDaMb53Gdh-AZUTPAg" />
<outline text="Matt Wolfe" title="Matt Wolfe" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UChpleBmo18P08aKCIgti38g" />
<outline text="Matthew Berman" title="Matthew Berman" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCawZsQWqfGSbCI5yjkdVkTA" />
<outline text="My First Million" title="My First Million" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCyaN6mg5u8Cjy2ZI4ikWaug" />
<outline text="Nikhil Kamath" title="Nikhil Kamath" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCnC8SAZzQiBGYVSKZ_S3y4Q" />
<outline text="No Priors" title="No Priors" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCSI7h9hydQ40K5MJHnCrQvw" />
<outline text="OpenAI" title="OpenAI" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCXZCJLdBC09xxGZ6gcdrc6A" />
<outline text="PowerfulJRE" title="PowerfulJRE" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCzQUP1qoWDoEbmsQxvdjxgQ" />
<outline text="Product School" title="Product School" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UC6hlQ0x6kPbAGjYkoz53cvA" />
<outline text="Riley Brown" title="Riley Brown" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCMcoud_ZW7cfxeIugBflSBw" />
<outline text="Sequoia Capital" title="Sequoia Capital" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCWrF0oN6unbXrWsTN7RctTw" />
<outline text="Silicon Valley Girl" title="Silicon Valley Girl" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCiq1FIgtEK7LRAOB1JXTPig" />
<outline text="Siraj Raval" title="Siraj Raval" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCWN3xxRkmTPmbKwht9FuE5A" />
<outline text="Spring I/O" title="Spring I/O" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCLMPXsvSrhNPN3i9h-u8PYg" />
<outline text="Stripe" title="Stripe" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCM1guA1E-RHLO2OyfQPOkEQ" />
<outline text="TED" title="TED" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCAuUUnT6oDeKwE6v1NGQxug" />
<outline text="The Diary Of A CEO" title="The Diary Of A CEO" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCGq-a57w-aPwyi3pW7XLiHw" />
<outline text="The Pragmatic Engineer" title="The Pragmatic Engineer" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCPbwhExawYrn9xxI21TFfyw" />
<outline text="Tina Huang" title="Tina Huang" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UC2UXDak6o7rBm23k3Vv5dww" />
<outline text="Wes Roth" title="Wes Roth" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCqcbQf6yw5KzRoDDcZ_wBSw" />
<outline text="Y Combinator" title="Y Combinator" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCcefcZRL2oaA_uBNeo5UOWg" />
<outline text="a16z" title="a16z" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UC9cn0TuPq4dnbTY-CBsm8XA" />
<outline text="freeCodeCamp.org" title="freeCodeCamp.org" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UC8butISFwT-Wl7EV0hUK0BQ" />
<outline text="leerob" title="leerob" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCZMli3czZnd1uoc1ShTouQw" />
<outline text="yobi321" title="yobi321" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=UCB_DbqNN9w30tnyWJSrIwyA" />
<outline text="42章经" title="42章经" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/648b0b641c48983391a63f98" />
<outline text="AI炼金术" title="AI炼金术" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/63e9ef4de99bdef7d39944c8" />
<outline text="TIANYU2FM — 对谈未知领域" title="TIANYU2FM — 对谈未知领域" type="rss" xmlUrl="https://rsshub.xiaowuaiblog.com/xiaoyuzhou/podcast/5f22729f9504bbdb77253e46" />
<outline text="What's Next|科技早知道" title="What's Next|科技早知道" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/5e74b52c418a84a046ecaceb" />
<outline text="三五环" title="三五环" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/5e280fab418a84a0461faa3c" />
<outline text="东腔西调" title="东腔西调" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/5f72b66083c34e85dd14fde9" />
<outline text="乱翻书" title="乱翻书" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/61358d971c5d56efe5bcb5d2" />
<outline text="人民公园说AI" title="人民公园说AI" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/65257ff6e8ce9deaf70a65e9" />
<outline text="保持偏见" title="保持偏见" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/663e3c95af1e22bb157dcee3" />
<outline text="十字路口Crossing" title="十字路口Crossing" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/60502e253c92d4f62c2a9577" />
<outline text="半拿铁 | 商业沉浮录" title="半拿铁 | 商业沉浮录" type="rss" xmlUrl="http://rsshub.bestblogs.dev/xiaoyuzhou/podcast/62382c1103bea1ebfffa1c00" />
<outline text="卫诗婕|商业漫谈Jane's talk" title="卫诗婕|商业漫谈Jane's talk" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/6627fda4b56459544087d86a" />
<outline text="天真不天真" title="天真不天真" type="rss" xmlUrl="http://rsshub.bestblogs.dev/xiaoyuzhou/podcast/65cef9e3cace72dff8d98de3" />
<outline text="屠龙之术" title="屠龙之术" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/6507bc165c88d2412626b401" />
<outline text="开始连接LinkStart" title="开始连接LinkStart" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/63ff0da51b1faf8a0b70b337" />
<outline text="张小珺Jùn|商业访谈录" title="张小珺Jùn|商业访谈录" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/626b46ea9cbbf0451cf5a962" />
<outline text="无人知晓" title="无人知晓" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/611719d3cb0b82e1df0ad29e" />
<outline text="晚点聊 LateTalk" title="晚点聊 LateTalk" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/61933ace1b4320461e91fd55" />
<outline text="枫言枫语" title="枫言枫语" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/5e2864f5418a84a04628e249" />
<outline text="此话当真" title="此话当真" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/646f194853a5e5ea1408d97c" />
<outline text="牛油果烤面包" title="牛油果烤面包" type="rss" xmlUrl="http://rsshub.bestblogs.dev/xiaoyuzhou/podcast/5e7c8b2b418a84a046e3ecbc" />
<outline text="知行小酒馆" title="知行小酒馆" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/6013f9f58e2f7ee375cf4216" />
<outline text="硅谷101" title="硅谷101" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/5e5c52c9418a84a04625e6cc" />
<outline text="硬地骇客" title="硬地骇客" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/640ee2438be5d40013fe4a87" />
<outline text="纵横四海" title="纵横四海" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/62694abdb221dd5908417d1e" />
<outline text="罗永浩的十字路口" title="罗永浩的十字路口" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/68981df29e7bcd326eb91d88" />
<outline text="自习室 STUDY ROOM" title="自习室 STUDY ROOM" type="rss" xmlUrl="http://rsshub.bestblogs.dev/xiaoyuzhou/podcast/65a5fb7540d4ef949c0140ac" />
<outline text="跨国串门儿计划" title="跨国串门儿计划" type="rss" xmlUrl="https://rsshub.bestblogs.dev/xiaoyuzhou/podcast/670f3da40d2f24f28978736f" />
<outline text="AI Breakfast(@AiBreakfast)" title="AI Breakfast(@AiBreakfast)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/0e3ebaf288014c45b0d24b71fe37312b" />
<outline text="AI Engineer(@aiDotEngineer)" title="AI Engineer(@aiDotEngineer)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/7d19a619a1cc4a9896129211269d2c85" />
<outline text="AI SDK(@aisdk)" title="AI SDK(@aisdk)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/22af005b21ec45b1a4503acca777b7f0" />
<outline text="AI Will(@FinanceYF5)" title="AI Will(@FinanceYF5)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/aa74321087f9405a872fd9a76b743bf8" />
<outline text="AI at Meta(@AIatMeta)" title="AI at Meta(@AIatMeta)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/ef7c70f9568d45f4915169fef4ce90b4" />
<outline text="AI产品黄叔(@PMbackttfuture)" title="AI产品黄叔(@PMbackttfuture)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/5b632b7fba274f62928cdcc9d3db4c5e" />
<outline text="AK(@_akhaliq)" title="AK(@_akhaliq)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/341f7b9f8d9b477e8bb200caa7f32c6e" />
<outline text="Aadit Sheth(@aaditsh)" title="Aadit Sheth(@aaditsh)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/179bcc4b8e5d4274b6e9e935f9fd4434" />
<outline text="Adam D'Angelo(@adamdangelo)" title="Adam D'Angelo(@adamdangelo)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/3042b6f912b24f64982cc23f7bd59681" />
<outline text="Addy Osmani(@addyosmani)" title="Addy Osmani(@addyosmani)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/ba36d5d6b5e843e1a2ee7bc0c9c7e666" />
<outline text="Ado(@adocomplete)" title="Ado(@adocomplete)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/5a6249f44fc541ac8260eb9016a12792" />
<outline text="Akshay Kothari(@akothari)" title="Akshay Kothari(@akothari)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/3434c0d56ee0446f991fb6af42bfac4b" />
<outline text="Alex Albert(@alexalbert__)" title="Alex Albert(@alexalbert__)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/524525de0d69407b80f0a7d891fdc8df" />
<outline text="Aman Sanger(@amanrsanger)" title="Aman Sanger(@amanrsanger)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/a02496979a0e4d86baf2b72c24db52a4" />
<outline text="Amjad Masad(@amasad)" title="Amjad Masad(@amasad)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/5fb1814c610c4af2911caa98c5c5ef82" />
<outline text="Andrej Karpathy(@karpathy)" title="Andrej Karpathy(@karpathy)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/edf707b5c0b248579085f66d7a3c5524" />
<outline text="Andrew Ng(@AndrewYNg)" title="Andrew Ng(@AndrewYNg)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/08b5488b20bc437c8bfc317a52e5c26d" />
<outline text="Anthropic(@AnthropicAI)" title="Anthropic(@AnthropicAI)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/fc28a211471b496682feff329ec616e5" />
<outline text="Anton Osika eu/acc(@antonosika)" title="Anton Osika eu/acc(@antonosika)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/5f13b32b124a41cfb659f903a84032b1" />
<outline text="Aravind Srinivas(@AravSrinivas)" title="Aravind Srinivas(@AravSrinivas)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/59e6b63ae9684d11be0ae13d9e7420f2" />
<outline text="Berryxia.AI(@berryxia)" title="Berryxia.AI(@berryxia)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/18e51259aadd41ed872f29abc37b5aa6" />
<outline text="Binyuan Hui(@huybery)" title="Binyuan Hui(@huybery)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/f54b2b40185943ce8f48a880110b7bc2" />
<outline text="Boris Cherny(@bcherny)" title="Boris Cherny(@bcherny)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/ee8646fd533343e09bb210a47e4afbb5" />
<outline text="Browser Use(@browser_use)" title="Browser Use(@browser_use)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/b8d7530f0b294405825013bbc1cc198f" />
<outline text="ChatGPT(@ChatGPTapp)" title="ChatGPT(@ChatGPTapp)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/f7992687b8d74b14bf2341eb3a0a5ec4" />
<outline text="Claude(@claudeai)" title="Claude(@claudeai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/01f60d63a61b44d692cc35c7feb0b4a4" />
<outline text="Cognition(@cognition_labs)" title="Cognition(@cognition_labs)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/4cc14cbd15c74e189d537c415369e1a7" />
<outline text="Cursor(@cursor_ai)" title="Cursor(@cursor_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/5287b4e0e13a4ab7ab7b1d56f9d88960" />
<outline text="DAN KOE(@thedankoe)" title="DAN KOE(@thedankoe)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/6f84bc46da974ac18294fe3f83576108" />
<outline text="DeepLearning.AI(@DeepLearningAI)" title="DeepLearning.AI(@DeepLearningAI)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/42e6b4901b97498eab2ab64c07d56177" />
<outline text="DeepSeek(@deepseek_ai)" title="DeepSeek(@deepseek_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/68b610deb24b47ae9a236811563cda86" />
<outline text="Demis Hassabis(@demishassabis)" title="Demis Hassabis(@demishassabis)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/4a884d5e2f3740c5a26c9c093de6388a" />
<outline text="Dify(@dify_ai)" title="Dify(@dify_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/0be252fedbe84ad7bea21be44b18da89" />
<outline text="Ding(@dingyi)" title="Ding(@dingyi)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/da142413d31e432ea2e850d2e37239eb" />
<outline text="ElevenLabs(@elevenlabsio)" title="ElevenLabs(@elevenlabsio)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/1897eed387064dfab443764d6da50bc6" />
<outline text="Eric Jing(@ericjing_ai)" title="Eric Jing(@ericjing_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/ddfdcdd4e390495c942f0b5da62af0fb" />
<outline text="Fei-Fei Li(@drfeifei)" title="Fei-Fei Li(@drfeifei)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/a4bfe44bfc0d4c949da21ebd3f5f42a5" />
<outline text="Figma(@figma)" title="Figma(@figma)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/f8a106a09a7d404fb8de7eb0c5ddd2a2" />
<outline text="Firecrawl(@firecrawl_dev)" title="Firecrawl(@firecrawl_dev)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/c04abb206bbf4f91b22795024d6c0614" />
<outline text="Fireworks AI(@FireworksAI_HQ)" title="Fireworks AI(@FireworksAI_HQ)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/9f35c76341554bd78c2b9e63dc4fa5d8" />
<outline text="Frank Wang 玉伯(@lifesinger)" title="Frank Wang 玉伯(@lifesinger)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/66b90d7ae5d5425e95470e8e57cee46e" />
<outline text="Gary Marcus(@GaryMarcus)" title="Gary Marcus(@GaryMarcus)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/35a38c5646d946fb894d8c30c1d9629e" />
<outline text="Geek(@geekbb)" title="Geek(@geekbb)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/9cb3b60e689e4445a7fbdfd0be144126" />
<outline text="Genspark(@genspark_ai)" title="Genspark(@genspark_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/71ffd342cb5d478185ef7d55bdfca011" />
<outline text="GitHub(@github)" title="GitHub(@github)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/fa5b15f68a2e4df1ab301e26a4ab9190" />
<outline text="Google AI Developers(@googleaidevs)" title="Google AI Developers(@googleaidevs)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/69d925d4a8d44221b03eecbe07bd0f74" />
<outline text="Google AI(@GoogleAI)" title="Google AI(@GoogleAI)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/4de0bd2d5cef4333a0260dc8157054a7" />
<outline text="Google Antigravity(@antigravity)" title="Google Antigravity(@antigravity)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/193efc3cceb14e318b0aee80059fba1b" />
<outline text="Google DeepMind(@GoogleDeepMind)" title="Google DeepMind(@GoogleDeepMind)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/a99538443a484fcc846bdcc8f50745ec" />
<outline text="Google Gemini App(@GeminiApp)" title="Google Gemini App(@GeminiApp)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/6fb337feeec44ca38b79491b971d868d" />
<outline text="Grant Lee(@thisisgrantlee)" title="Grant Lee(@thisisgrantlee)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/d8c5840226114dc98c98c3673e1a7f3f" />
<outline text="Greg Brockman(@gdb)" title="Greg Brockman(@gdb)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/af19d054e26a49129f23abfa82d9e268" />
<outline text="Groq Inc(@GroqInc)" title="Groq Inc(@GroqInc)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/771b32075fe54a83bdb6966de9647b4f" />
<outline text="Guillermo Rauch(@rauchg)" title="Guillermo Rauch(@rauchg)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/e8750659b8154dbfa0489f451e044af1" />
<outline text="Hailuo AI (MiniMax)(@Hailuo_AI)" title="Hailuo AI (MiniMax)(@Hailuo_AI)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/e65b5e59fcb544918c1ba17f5758f0f8" />
<outline text="Harrison Chase(@hwchase17)" title="Harrison Chase(@hwchase17)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/f299207df53745bca04a03db8d11c5aa" />
<outline text="HeyGen(@HeyGen_Official)" title="HeyGen(@HeyGen_Official)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/a9aff6b016c143ed8728dd86eb70d7db" />
<outline text="Hugging Face(@huggingface)" title="Hugging Face(@huggingface)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/fc16750ce50741f1b1f05ea1fb29436f" />
<outline text="Hunyuan(@TXhunyuan)" title="Hunyuan(@TXhunyuan)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/6e8e7b42cb434818810f87bcf77d86fb" />
<outline text="Ian Goodfellow(@goodfellow_ian)" title="Ian Goodfellow(@goodfellow_ian)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/57831559d22440debbfb2f2528e4ba84" />
<outline text="James Clear(@JamesClear)" title="James Clear(@JamesClear)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/b042dec6a5964d3181cb5df9289e344f" />
<outline text="Jan Leike(@janleike)" title="Jan Leike(@janleike)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/dceb5cd131b34c72a8376cba8ea5d864" />
<outline text="Jeff Dean(@JeffDean)" title="Jeff Dean(@JeffDean)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/b1013166769c49f8aa3fbdc222867054" />
<outline text="Jerry Liu(@jerryjliu0)" title="Jerry Liu(@jerryjliu0)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/b3d904c0d7c446558ef3a1e7f2eb362b" />
<outline text="Jim Fan(@DrJimFan)" title="Jim Fan(@DrJimFan)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/c6cfe7c0d6b74849997073233fdea840" />
<outline text="Jina AI(@JinaAI_)" title="Jina AI(@JinaAI_)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/f510f6e7eecf456ca7e2895a46752888" />
<outline text="Julien Chaumond(@julien_c)" title="Julien Chaumond(@julien_c)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/44d9fa384087448a94d3c8595f8d535e" />
<outline text="Junyang Lin(@JustinLin610)" title="Junyang Lin(@JustinLin610)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/082097117b4543e9a741cd2580f936d3" />
<outline text="Justin Welsh(@thejustinwelsh)" title="Justin Welsh(@thejustinwelsh)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/58894bf2934a426ca833c682da2bc810" />
<outline text="Justine Moore(@venturetwins)" title="Justine Moore(@venturetwins)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/c61046471f174d86bc0eb76cb44a21c3" />
<outline text="Kevin Weil &#x1f1fa;&#x1f1f8;(@kevinweil)" title="Kevin Weil &#x1f1fa;&#x1f1f8;(@kevinweil)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/3ca3c7698fd04611a0e7d14fae93c84c" />
<outline text="Kling AI(@Kling_ai)" title="Kling AI(@Kling_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/564237c3de274d58a04f064920817888" />
<outline text="LangChain(@LangChainAI)" title="LangChain(@LangChainAI)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/862fee50a745423c87e2633b274caf1d" />
<outline text="Latent.Space(@latentspacepod)" title="Latent.Space(@latentspacepod)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/a7be8b61a1264ea7984abfaea3eff686" />
<outline text="Lee Robinson(@leerob)" title="Lee Robinson(@leerob)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/dc2426bc8348495189b45451d1707a1c" />
<outline text="Lenny Rachitsky(@lennysan)" title="Lenny Rachitsky(@lennysan)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/77d5ce4736854b0ebae603e4b54d3095" />
<outline text="Lex Fridman(@lexfridman)" title="Lex Fridman(@lexfridman)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/adf65931519340f795e2336910b4cd15" />
<outline text="Lilian Weng(@lilianweng)" title="Lilian Weng(@lilianweng)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/a8f7e2238039461cbc8bf55f5f194498" />
<outline text="LlamaIndex &#x1f999;(@llama_index)" title="LlamaIndex &#x1f999;(@llama_index)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/67e259bd5be544ce84bbc867eace54c2" />
<outline text="Logan Kilpatrick(@OfficialLoganK)" title="Logan Kilpatrick(@OfficialLoganK)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/4f63d960de644aeebd0aa97e4994dafe" />
<outline text="Lovable(@lovable_dev)" title="Lovable(@lovable_dev)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/639cd13d44284e10ac89fbd1c5399767" />
<outline text="LovartAI(@lovart_ai)" title="LovartAI(@lovart_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/db648e4d4eae4822aa0d34f0faef7ad2" />
<outline text="Luyu Zhang(@goocarlos)" title="Luyu Zhang(@goocarlos)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/bc52f83045964e3cb0ed93f495d58c36" />
<outline text="ManusAI(@ManusAI_HQ)" title="ManusAI(@ManusAI_HQ)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/320181c4651a41a08015946b55f704ab" />
<outline text="Marc Andreessen &#x1f1fa;&#x1f1f8;(@pmarca)" title="Marc Andreessen &#x1f1fa;&#x1f1f8;(@pmarca)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/63316630d94543f5a6480f230f483008" />
<outline text="Martin Fowler(@martinfowler)" title="Martin Fowler(@martinfowler)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/55d2d3f3eaaf4357b3230e0b01a464d7" />
<outline text="Michael Truell(@mntruell)" title="Michael Truell(@mntruell)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/7f30b06ba5bf4841919bd159f68e6484" />
<outline text="Microsoft Research(@MSFTResearch)" title="Microsoft Research(@MSFTResearch)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/61f4b78554fb4b8fa5653ec5d924d15a" />
<outline text="Midjourney(@midjourney)" title="Midjourney(@midjourney)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/72dd496bfd9d44c5a5761a974630376d" />
<outline text="Mike Krieger(@mikeyk)" title="Mike Krieger(@mikeyk)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/78d7b99318b04b309b04000f7e24da29" />
<outline text="Milvus(@milvusio)" title="Milvus(@milvusio)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/424e67b19eed4500b7a440976bbd2ade" />
<outline text="Mistral AI(@MistralAI)" title="Mistral AI(@MistralAI)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/8d2d03aea8af49818096da4ea00409d1" />
<outline text="Mustafa Suleyman(@mustafasuleyman)" title="Mustafa Suleyman(@mustafasuleyman)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/394acfaff8c44e09936f5bc0b8504f2c" />
<outline text="NVIDIA AI(@NVIDIAAI)" title="NVIDIA AI(@NVIDIAAI)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/05f1492e43514dc3862a076d3697c390" />
<outline text="Nano Banana Pro(@NanoBanana)" title="Nano Banana Pro(@NanoBanana)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/02431ffb78124808a0e7abb5f983084a" />
<outline text="Naval(@naval)" title="Naval(@naval)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/b43bc203409e4c5a9c3ae86fe1ac00c9" />
<outline text="Nick St. Pierre(@nickfloats)" title="Nick St. Pierre(@nickfloats)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/6ebdf0d91eef4c149acd0ef110635866" />
<outline text="NotebookLM(@NotebookLM)" title="NotebookLM(@NotebookLM)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/221a88341acb475db221a12fed8208d0" />
<outline text="Notion(@NotionHQ)" title="Notion(@NotionHQ)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/f97a26863aec4425b021720d4f8e4ede" />
<outline text="OpenAI Developers(@OpenAIDevs)" title="OpenAI Developers(@OpenAIDevs)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/971dc1fc90da449bac23e5fad8a33d55" />
<outline text="OpenAI(@OpenAI)" title="OpenAI(@OpenAI)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/0c0856a69f9f49cf961018c32a0b0049" />
<outline text="OpenRouter(@OpenRouterAI)" title="OpenRouter(@OpenRouterAI)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/e503a90c035c4b1d8f8dd34907d15bf4" />
<outline text="Patrick Loeber(@patloeber)" title="Patrick Loeber(@patloeber)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/c65c68f3713747bba863f92d6b5e996f" />
<outline text="Paul Couvert(@itsPaulAi)" title="Paul Couvert(@itsPaulAi)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/b9912ac9a29042cf8c834419dc44cb1f" />
<outline text="Paul Graham(@paulg)" title="Paul Graham(@paulg)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/900549ddadf04e839d3f7a17ebaba3fc" />
<outline text="Perplexity(@perplexity_ai)" title="Perplexity(@perplexity_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/fdd601ea751949e7bec9e4cdad7c8e6c" />
<outline text="Peter Yang(@petergyang)" title="Peter Yang(@petergyang)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/8aca47ab813841bb82211e3a4f203acb" />
<outline text="Philipp Schmid(@_philschmid)" title="Philipp Schmid(@_philschmid)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/ce352bbf72e44033985bc756db2ee0e2" />
<outline text="Poe(@poe_platform)" title="Poe(@poe_platform)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/17687b1051204b2dbaed4ea4c9178f28" />
<outline text="Qdrant(@qdrant_engine)" title="Qdrant(@qdrant_engine)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/a55f6e33dd224235aabaabaaf9d58a06" />
<outline text="Qwen(@Alibaba_Qwen)" title="Qwen(@Alibaba_Qwen)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/80032d016d654eb4afe741ff34b7643d" />
<outline text="Ray Dalio(@RayDalio)" title="Ray Dalio(@RayDalio)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/4838204097ed422eac24ad48e68dc3ff" />
<outline text="Recraft(@recraftai)" title="Recraft(@recraftai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/acc648327c614d9b985b9fc3d737165b" />
<outline text="Replicate(@replicate)" title="Replicate(@replicate)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/12eba9c3db4940c5ab2a72bd00f9ff2c" />
<outline text="Replit ⠕(@Replit)" title="Replit ⠕(@Replit)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/613f859e4bc440c5a28f40732840f5cf" />
<outline text="Rowan Cheung(@rowancheung)" title="Rowan Cheung(@rowancheung)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/a636de3cbda0495daabd15b9fd298614" />
<outline text="Runway(@runwayml)" title="Runway(@runwayml)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/e6bb4f612dd24db5bc1a6811e6dd5820" />
<outline text="Ryo Lu(@ryolu_)" title="Ryo Lu(@ryolu_)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/98cf45d5af9c4a6c9b0c26c7df5e737c" />
<outline text="Sahil Lavingia(@shl)" title="Sahil Lavingia(@shl)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/baad3713defe4182844d2756b4c2c9ed" />
<outline text="Sam Altman(@sama)" title="Sam Altman(@sama)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/e30d4cd223f44bed9d404807105c8927" />
<outline text="Satya Nadella(@satyanadella)" title="Satya Nadella(@satyanadella)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/baa68dbd9a9e461a96fd9b2e3f35dcbf" />
<outline text="Scott Wu(@ScottWu46)" title="Scott Wu(@ScottWu46)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/5fca8ccd87344d388bc863304ed6fd86" />
<outline text="Simon Willison(@simonw)" title="Simon Willison(@simonw)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/30ad80be93c84e44acc37d5ddf31db57" />
<outline text="Skywork(@Skywork_ai)" title="Skywork(@Skywork_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/6d7d398dd80b48d79669c92745d32cf6" />
<outline text="Stanford AI Lab(@StanfordAILab)" title="Stanford AI Lab(@StanfordAILab)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/d5fc365556e641cba2278f501e8c6f92" />
<outline text="Sualeh Asif(@sualehasif996)" title="Sualeh Asif(@sualehasif996)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/fafa6df3c67644b1a367a177240e0173" />
<outline text="Suhail(@Suhail)" title="Suhail(@Suhail)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/c961547e08df4396b3ab69367a07a1cd" />
<outline text="Sundar Pichai(@sundarpichai)" title="Sundar Pichai(@sundarpichai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/8324d65a63dc42c584a8c08cc8323c9f" />
<outline text="Susan STEM(@feltanimalworld)" title="Susan STEM(@feltanimalworld)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/02cb4bc135fc4ac2b6abbbbf6229f8f7" />
<outline text="Taranjeet(@taranjeetio)" title="Taranjeet(@taranjeetio)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/2de92402f4a24c90bb27e7580b93a878" />
<outline text="The Rundown AI(@TheRundownAI)" title="The Rundown AI(@TheRundownAI)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/83b1ea38940b4a1d81ea57d1ffb12ad7" />
<outline text="Thomas Wolf(@Thom_Wolf)" title="Thomas Wolf(@Thom_Wolf)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/4918efb13c47459b8dcaa79cfdf72d09" />
<outline text="Tw93(@HiTw93)" title="Tw93(@HiTw93)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/665fc88440fd4436acbc2e630d824926" />
<outline text="Varun Mohan(@_mohansolo)" title="Varun Mohan(@_mohansolo)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/7794c4268a504019a94af1778857a703" />
<outline text="Viking(@vikingmute)" title="Viking(@vikingmute)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/aab44cb2665a49258cd81f63b0b55192" />
<outline text="Weaviate • vector database(@weaviate_io)" title="Weaviate • vector database(@weaviate_io)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/2f1035ec6b28475987af06b600e1d04c" />
<outline text="Windsurf(@windsurf_ai)" title="Windsurf(@windsurf_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/4a8273800ed34a069eecdb6c5c1b9ccf" />
<outline text="XDash(@XDash)" title="XDash(@XDash)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/0e84eb2b7fdc420182eacdce2cf390b9" />
<outline text="Y Combinator(@ycombinator)" title="Y Combinator(@ycombinator)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/b1ab109f6afd42ab8ea32e17a19a3a3e" />
<outline text="Yangyi(@Yangyixxxx)" title="Yangyi(@Yangyixxxx)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/66c40de71a9842fda4853b7d9d1d20da" />
<outline text="Yann LeCun(@ylecun)" title="Yann LeCun(@ylecun)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/f5f4f928dede472ea55053672ad27ab6" />
<outline text="Zara Zhang(@zarazhangrui)" title="Zara Zhang(@zarazhangrui)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/5db2db649cb44ba8a964bed0f6e91222" />
<outline text="a16z(@a16z)" title="a16z(@a16z)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/f3fedf817599470dbf8d8d11f0872475" />
<outline text="andrew chen(@andrewchen)" title="andrew chen(@andrewchen)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/a3eb6beb2d894da3a9b7ab6d2e46790e" />
<outline text="bolt.new(@boltdotnew)" title="bolt.new(@boltdotnew)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/760ab7cd9708452c9ce1f9144b92a430" />
<outline text="cat(@_catwu)" title="cat(@_catwu)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/66a6b39ddcfa42e39621e0ab293c1bdd" />
<outline text="clem &#x1f917;(@ClementDelangue)" title="clem &#x1f917;(@ClementDelangue)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/5dbd038a8f5140938d0877511571797b" />
<outline text="elvis(@omarsar0)" title="elvis(@omarsar0)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/931d6e88e067496cac6bf23f69d60f33" />
<outline text="eric zakariasson(@ericzakariasson)" title="eric zakariasson(@ericzakariasson)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/65f321be670b4ffba7f40d0afd38c94d" />
<outline text="hidecloud(@hidecloud)" title="hidecloud(@hidecloud)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/23d41992b29340788aa3d09d8364c5f5" />
<outline text="howie.serious(@howie_serious)" title="howie.serious(@howie_serious)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/423dbfd2f9ff4238a4816f945af0f279" />
<outline text="idoubi(@idoubicc)" title="idoubi(@idoubicc)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/3d72acd51d21414ea39871fc01982a65" />
<outline text="koji(@Yuancheng)" title="koji(@Yuancheng)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/4d9ddf8e2e8f4f57a748bdb1cd0a56a9" />
<outline text="lmarena.ai(@lmarena_ai)" title="lmarena.ai(@lmarena_ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/f01b088d5a39473e854b07143df77ec5" />
<outline text="mem0(@mem0ai)" title="mem0(@mem0ai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/94bb691baeff461686326af619beb116" />
<outline text="meng shao(@shao__meng)" title="meng shao(@shao__meng)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/48aae530e0bf413aa7d44380f418e2e3" />
<outline text="ollama(@ollama)" title="ollama(@ollama)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/6326c63a2dfa445bbde88bea0c3112c2" />
<outline text="orange.ai(@oran_ge)" title="orange.ai(@oran_ge)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/0277b0bbefd54df7bc6b7880122da8f7" />
<outline text="v0(@v0)" title="v0(@v0)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/dbf37973e6fc4eae91d4be9669a78fc7" />
<outline text="xAI(@xai)" title="xAI(@xai)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/3953aa71e87a422eb9d7bf6ff1c7c43e" />
<outline text="yan5xu(@yan5xu)" title="yan5xu(@yan5xu)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/8008824b7c5d4816a4d61f9c24af82b8" />
<outline text="傅盛(@FuSheng_0306)" title="傅盛(@FuSheng_0306)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/32e92127f6b24822b5d7693251162ffd" />
<outline text="向阳乔木(@vista8)" title="向阳乔木(@vista8)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/9de19c78f7454ad08c956c1a00d237fe" />
<outline text="宝玉(@dotey)" title="宝玉(@dotey)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/97f1484ae48c430fbbf3438099743674" />
<outline text="小互(@imxiaohu)" title="小互(@imxiaohu)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/74e542992cf7441390c708f5601071d4" />
<outline text="数字生命卡兹克(@Khazix0918)" title="数字生命卡兹克(@Khazix0918)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/7a9955909da04c628fd0ee21203dcab5" />
<outline text="李继刚(@lijigang_com)" title="李继刚(@lijigang_com)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/ca2fa444b6ea4b8b974fe148056e497a" />
<outline text="歸藏(guizang.ai)(@op7418)" title="歸藏(guizang.ai)(@op7418)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/831fac36aa0a49a9af79f35dc1c9b5d9" />
<outline text="马东锡 NLP(@dongxi_nlp)" title="马东锡 NLP(@dongxi_nlp)" type="rss" xmlUrl="https://api.xgo.ing/rss/user/94f06e2ab51544ef8c3fef471d90b71b" />
</body>
</opml>
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""根据 config.json 渲染 systemd 单元模板。
被 scripts/install.sh 调用。本身不直接读环境,需要的运行时参数(user/group/uv 路径)
通过 CLI 注入,便于 install.sh 处理 sudo 场景。
"""
import argparse
import json
import sys
from pathlib import Path
def cron_to_oncalendar(expr: str) -> str:
"""5 段 cron 表达式 → systemd OnCalendar 字符串。
本项目 push_cron 只用到 minute/hour,其他位必须为 `*`。
遇到不支持的语法直接报错,避免静默生成错误的 timer。
"""
parts = expr.split()
if len(parts) != 5:
raise ValueError(f"无效 cron 表达式(必须 5 段): {expr}")
minute, hour, dom, mon, dow = parts
for label, val in [("day-of-month", dom), ("month", mon), ("day-of-week", dow)]:
if val != "*":
raise ValueError(f"暂不支持非 * 的 {label} 字段: {expr}")
try:
m, h = int(minute), int(hour)
except ValueError as e:
raise ValueError(f"minute/hour 必须是整数: {expr}") from e
if not (0 <= m <= 59 and 0 <= h <= 23):
raise ValueError(f"minute/hour 越界: {expr}")
return f"*-*-* {h:02d}:{m:02d}:00"
def render(template_path: Path, variables: dict) -> str:
text = template_path.read_text(encoding="utf-8")
for key, val in variables.items():
text = text.replace(f"{{{{{key}}}}}", str(val))
return text
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--project-dir", required=True)
parser.add_argument("--user", required=True)
parser.add_argument("--group", required=True)
parser.add_argument("--uv-bin", required=True)
parser.add_argument("--output-dir", required=True)
args = parser.parse_args()
project_dir = Path(args.project_dir).resolve()
output_dir = Path(args.output_dir).resolve()
template_dir = project_dir / "systemd"
config_path = project_dir / "config.json"
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
fetch_min = config["schedule"]["fetch_interval_minutes"]
if not isinstance(fetch_min, int) or fetch_min <= 0:
raise ValueError(f"fetch_interval_minutes 必须是正整数: {fetch_min}")
push_crons = config["schedule"]["push_cron"]
if not push_crons:
raise ValueError("config.schedule.push_cron 不能为空")
push_lines = "\n".join(
f"OnCalendar={cron_to_oncalendar(c)}" for c in push_crons
)
log_retention = config.get("log", {}).get("retention_days", 7)
if not isinstance(log_retention, int) or log_retention <= 0:
raise ValueError(f"log.retention_days 必须是正整数: {log_retention}")
common = {
"PROJECT_DIR": str(project_dir),
"USER": args.user,
"GROUP": args.group,
"UV_BIN": args.uv_bin,
}
output_dir.mkdir(parents=True, exist_ok=True)
renderings = [
("dnews-fetch.service.tmpl", "dnews-fetch.service", {}),
("dnews-fetch.timer.tmpl", "dnews-fetch.timer",
{"FETCH_INTERVAL_MIN": fetch_min}),
("dnews-push.service.tmpl", "dnews-push.service", {}),
("dnews-push.timer.tmpl", "dnews-push.timer",
{"PUSH_ONCALENDAR_LINES": push_lines}),
("journald-dnews.conf.tmpl", "journald-dnews.conf",
{"LOG_RETENTION_DAYS": log_retention}),
]
print(f"📂 输出目录: {output_dir}")
for tpl_name, out_name, extra in renderings:
tpl_path = template_dir / tpl_name
if not tpl_path.exists():
print(f"❌ 模板不存在: {tpl_path}", file=sys.stderr)
return 2
rendered = render(tpl_path, {**common, **extra})
(output_dir / out_name).write_text(rendered, encoding="utf-8")
print(f"{out_name}")
print(f"\n生成参数:")
print(f" fetch 间隔 → 每 {fetch_min} 分钟(间隔触发,从上次完成开始计时)")
for c, line in zip(push_crons, push_lines.split("\n")):
print(f" push '{c}'{line}")
print(f" 日志保留 → {log_retention}")
return 0
if __name__ == "__main__":
sys.exit(main())
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# daily-news - Daily News 系统服务管理包装脚本
# 由 scripts/install.sh 自动生成;项目路径:{{PROJECT_DIR}}
set -euo pipefail
TIMERS=(dnews-fetch.timer dnews-push.timer)
SERVICES=(dnews-fetch.service dnews-push.service)
PROJECT_DIR="{{PROJECT_DIR}}"
usage() {
cat <<EOF
用法: daily-news <command>
命令:
start 启动 fetch 和 push timer
stop 停止两个 timer
restart 重启两个 timer(不会立即触发 service,仅重置调度)
status [N] 查看 timer / service 状态 + 最近 N 行日志(默认 15)
logs 实时跟随日志(Ctrl+C 退出)
其他操作:
$PROJECT_DIR/scripts/install.sh 重新生成单元并安装(改 config.json 后用)
$PROJECT_DIR/scripts/uninstall.sh 完全卸载(不删数据)
EOF
}
cmd="${1:-}"
case "$cmd" in
start)
sudo systemctl start "${TIMERS[@]}"
echo "✓ Daily News 已启动"
;;
stop)
sudo systemctl stop "${TIMERS[@]}"
echo "✓ Daily News 已停止"
;;
restart)
sudo systemctl restart "${TIMERS[@]}"
echo "✓ Daily News 已重启"
;;
status)
lines="${2:-15}"
echo "═══ Timer ═══"
systemctl list-timers 'dnews-*' --no-pager || true
echo ""
echo "═══ Service 状态 ═══"
systemctl status "${SERVICES[@]}" --no-pager --lines=0 || true
echo ""
echo "═══ 最近 $lines 行日志 ═══"
journalctl --namespace=dnews -u dnews-fetch -u dnews-push -n "$lines" --no-pager
echo ""
echo "💡 实时跟随:daily-news logs"
;;
logs)
journalctl --namespace=dnews -u dnews-fetch -u dnews-push -f
;;
-h|--help|help|"")
usage
;;
*)
echo "❌ 未知命令: $cmd" >&2
echo ""
usage
exit 1
;;
esac
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Daily News - 系统服务一键安装
# 用法:./scripts/install.sh
# 不要用 sudo 直接调用本脚本;脚本会在需要时自行 sudo 提权。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# ─── 用户检测 ────────────────────────────────────────────────
if [[ $EUID -eq 0 ]]; then
if [[ -z "${SUDO_USER:-}" ]]; then
echo "❌ 请勿直接用 root 运行本脚本,请用普通用户(脚本按需自动提权)"
exit 1
fi
RUN_USER="$SUDO_USER"
else
RUN_USER="$USER"
fi
RUN_GROUP="$(id -gn "$RUN_USER")"
# ─── uv 检测 ─────────────────────────────────────────────────
UV_BIN="$(command -v uv || true)"
if [[ -z "$UV_BIN" ]]; then
echo "❌ 找不到 uv 命令,请先安装:https://docs.astral.sh/uv/"
exit 1
fi
# ─── systemd 检测 ────────────────────────────────────────────
if ! command -v systemctl >/dev/null 2>&1; then
echo "❌ 系统未检测到 systemctl(非 systemd 系统),本脚本仅支持 systemd Linux"
exit 1
fi
cd "$PROJECT_DIR"
echo "🚀 Daily News - 系统服务安装"
echo "────────────────────────────────────────"
echo " 项目目录: $PROJECT_DIR"
echo " 运行身份: $RUN_USER:$RUN_GROUP"
echo " uv 路径: $UV_BIN"
echo "────────────────────────────────────────"
echo ""
# 提前提权一次,后续 sudo 命令直接复用凭据缓存
echo "🔐 需要 sudo 权限来写入 /etc/systemd/system/"
sudo -v
echo ""
# ─── [1/6] 同步依赖 ──────────────────────────────────────────
echo "📦 [1/6] 同步依赖 (uv sync)..."
uv sync
echo "✓ 依赖已就绪"
echo ""
# ─── [2/6] 验证 .env ────────────────────────────────────────
echo "🔐 [2/6] 验证 .env..."
if [[ ! -f "$PROJECT_DIR/.env" ]]; then
echo "❌ .env 不存在,请先复制 .env.example 并填入 API Key"
exit 1
fi
echo "✓ .env 存在"
echo ""
# ─── [3/6] LLM 健康检查 ─────────────────────────────────────
echo "🔍 [3/6] 校验 LLM 接口(仅在此处校验,运行时不再校验)..."
if ! uv run python -m src.main check; then
echo ""
echo "❌ LLM 校验失败,已中止安装"
echo "请检查 config.json 中的 llm.baseUrl/model 以及 .env 中的 API Key"
exit 1
fi
echo ""
# ─── [4/6] 生成单元文件 ─────────────────────────────────────
echo "📝 [4/6] 渲染 systemd 单元模板..."
STAGE_DIR="$(mktemp -d)"
trap 'rm -rf "$STAGE_DIR"' EXIT
uv run python "$PROJECT_DIR/scripts/_gen_units.py" \
--project-dir "$PROJECT_DIR" \
--user "$RUN_USER" \
--group "$RUN_GROUP" \
--uv-bin "$UV_BIN" \
--output-dir "$STAGE_DIR"
# 同时渲染 daily-news 包装脚本(/usr/local/bin/daily-news
sed "s|{{PROJECT_DIR}}|$PROJECT_DIR|g" \
"$PROJECT_DIR/scripts/daily-news.tmpl" > "$STAGE_DIR/daily-news"
echo " ✓ daily-news"
echo ""
# ─── [5/6] 安装到 systemd ───────────────────────────────────
echo "📥 [5/6] 安装到系统..."
sudo install -m 644 "$STAGE_DIR/dnews-fetch.service" /etc/systemd/system/
sudo install -m 644 "$STAGE_DIR/dnews-fetch.timer" /etc/systemd/system/
sudo install -m 644 "$STAGE_DIR/dnews-push.service" /etc/systemd/system/
sudo install -m 644 "$STAGE_DIR/dnews-push.timer" /etc/systemd/system/
# 日志保留策略(journald 命名空间 drop-in
sudo mkdir -p /etc/systemd/journald@dnews.conf.d
sudo install -m 644 "$STAGE_DIR/journald-dnews.conf" \
/etc/systemd/journald@dnews.conf.d/retention.conf
# daily-news 包装脚本到系统 PATH
sudo install -m 755 "$STAGE_DIR/daily-news" /usr/local/bin/daily-news
sudo systemctl daemon-reload
# 命名空间 journald 实例首次会在 fetch/push 首次产出日志时自动拉起;
# 若已存在(重装场景),重启以加载新保留策略。
sudo systemctl restart systemd-journald@dnews.service 2>/dev/null || true
echo "✓ 单元文件 + daily-news 包装脚本已安装"
echo ""
# ─── [6/6] 启用并启动 timer ─────────────────────────────────
echo "🚦 [6/6] 启用并(重)启动 timer..."
# 先 enable 注册开机自启
sudo systemctl enable dnews-fetch.timer dnews-push.timer
# 再 restart 强制按新单元文件重置内存中的 timer 状态
# enable --now 对已运行的 timer 是 no-op,无法应用新 OnCalendar/OnActiveSec 等改动)
sudo systemctl restart dnews-fetch.timer dnews-push.timer
echo ""
# ─── 完成 ───────────────────────────────────────────────────
echo "✅ 安装完成!"
echo ""
echo "下次触发时间:"
systemctl list-timers 'dnews-*' --no-pager || true
echo ""
echo "常用命令(系统 PATH 中可直接调用):"
echo " daily-news status 查看状态 + 最近日志"
echo " daily-news logs 实时跟随日志"
echo " daily-news stop 停止"
echo " daily-news start 启动"
echo " daily-news restart 重启 timer"
echo ""
echo "手动立即触发一次任务(不影响下次调度):"
echo " sudo systemctl start dnews-fetch.service"
echo " sudo systemctl start dnews-push.service"
echo ""
echo "卸载:$PROJECT_DIR/scripts/uninstall.sh"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Daily News - 状态查看
# 显示:timer 下次触发时间、service 上次结果、最近日志
set -euo pipefail
LINES="${1:-15}"
echo "═══ Timer ═══"
systemctl list-timers 'dnews-*' --no-pager || true
echo ""
echo "═══ Service 状态 ═══"
systemctl status dnews-fetch.service --no-pager --lines=0 || true
echo "──────────────────────────────"
systemctl status dnews-push.service --no-pager --lines=0 || true
echo ""
echo "═══ 最近 $LINES 行日志 ═══"
journalctl --namespace=dnews \
-u dnews-fetch.service \
-u dnews-push.service \
-n "$LINES" \
--no-pager
echo ""
echo "💡 实时跟随:journalctl --namespace=dnews -f"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Daily News - 卸载系统服务(不删数据)
set -euo pipefail
echo "🗑 Daily News - 卸载"
echo ""
if ! command -v systemctl >/dev/null 2>&1; then
echo "❌ 系统未检测到 systemctl"
exit 1
fi
echo "🛑 停止并禁用 timer..."
sudo systemctl disable --now dnews-fetch.timer dnews-push.timer 2>/dev/null || true
echo "🧹 清理单元文件..."
sudo rm -f /etc/systemd/system/dnews-fetch.service \
/etc/systemd/system/dnews-fetch.timer \
/etc/systemd/system/dnews-push.service \
/etc/systemd/system/dnews-push.timer
echo "🧹 清理日志保留策略 drop-in..."
sudo rm -f /etc/systemd/journald@dnews.conf.d/retention.conf
sudo rmdir /etc/systemd/journald@dnews.conf.d 2>/dev/null || true
echo "🧹 清理 daily-news 包装脚本..."
sudo rm -f /usr/local/bin/daily-news
sudo systemctl daemon-reload
sudo systemctl stop systemd-journald@dnews.service 2>/dev/null || true
echo ""
echo "✅ 已卸载(news-data/ 与项目代码未删除)"
echo ""
echo "如需清理历史日志:sudo journalctl --namespace=dnews --vacuum-time=1s"
View File
+115
View File
@@ -0,0 +1,115 @@
"""配置加载和源管理"""
import fnmatch
import json
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List
from urllib.parse import urlparse
def _get_local_timezone() -> timezone:
"""自动检测本地时区"""
return datetime.now().astimezone().tzinfo
def get_timezone(config: Dict = None) -> timezone:
"""
获取配置时区,用于推送消息展示本地化时间
读取信息源统一使用 UTC 时间
如果 config 中没有 timezone_hours,则自动检测本地时区
"""
if config is None:
try:
config = load_config()
except Exception:
return _get_local_timezone()
hours = config.get("schedule", {}).get("timezone_hours")
if hours is None:
return _get_local_timezone()
return timezone(timedelta(hours=hours))
# 向后兼容的别名
def get_cst(config: Dict = None) -> timezone:
"""向后兼容,使用 get_timezone"""
return get_timezone(config)
def load_config(config_path: str = "config.json") -> Dict:
"""加载配置文件"""
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"配置文件不存在: {config_path}")
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def parse_opml(opml_path: str) -> List[Dict]:
"""解析OPML文件获取订阅源列表"""
path = Path(opml_path)
if not path.exists():
return []
tree = ET.parse(path)
root = tree.getroot()
feeds = []
for outline in root.findall(".//outline[@type='rss']"):
feeds.append({
"title": outline.get("title", ""),
"xmlUrl": outline.get("xmlUrl", ""),
"category": outline.get("category", "未分类"),
})
return feeds
def merge_sources(sources_config: Dict) -> List[Dict]:
"""合并base_opml + add - block,以xmlUrl为key去重"""
# 1. 解析base OPML
base = parse_opml(sources_config.get("base_opml", ""))
# 2. 添加自定义源
add_list = sources_config.get("add", [])
all_sources = base + add_list
# 3. 应用block (以xmlUrl匹配)
block_list = sources_config.get("block", [])
block_urls = {b.get("xmlUrl", "") for b in block_list}
filtered = [s for s in all_sources if s.get("xmlUrl", "") not in block_urls]
# 4. 应用block_domains (域名屏蔽,支持通配符 *.substack.com)
block_domains = sources_config.get("block_domains", [])
if block_domains:
def is_domain_blocked(url: str) -> bool:
try:
domain = urlparse(url).netloc.lower()
for pattern in block_domains:
# 转换通配符模式为匹配格式
if pattern.startswith("*."):
# *.substack.com 匹配 substack.com 和 addyo.substack.com
suffix = pattern[2:] # substack.com
if domain == suffix or domain.endswith("." + suffix):
return True
elif fnmatch.fnmatch(domain, pattern):
return True
return False
except Exception:
return False
filtered = [s for s in filtered if not is_domain_blocked(s.get("xmlUrl", ""))]
# 5. 去重 (以xmlUrl为key)
seen = set()
result = []
for s in filtered:
url = s.get("xmlUrl", "")
if url and url not in seen:
seen.add(url)
result.append(s)
return result
+213
View File
@@ -0,0 +1,213 @@
"""RSS抓取模块"""
import asyncio
from datetime import datetime, timezone
from typing import Dict, List, Optional
import aiohttp
import feedparser
import requests
# 默认超时配置(秒)
DEFAULT_FEED_TIMEOUT = 5
# title 截断阈值:nitter 会把整条推文塞进 <title>,需要截断
TITLE_MAX_CHARS = 200
# nitter / xcancel 实例:必须用白名单 UA + requests 客户端(aiohttp 的 TLS
# 指纹过不了),详见 nitter-practice.md
NITTER_HOSTS = (
"xcancel.com",
"nitter.net",
"nuku.trabun.org",
)
NITTER_HEADERS = {
"User-Agent": "Inoreader",
"Accept": "application/rss+xml, application/atom+xml, application/xml;q=0.9",
}
# 公益实例,独立的低并发池 + 每次抓完 sleep,避免给上游施压
NITTER_MAX_CONCURRENCY = 2
NITTER_REQUEST_DELAY = 1.0
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
}
def is_nitter_url(url: str) -> bool:
"""判断是否为 nitter / xcancel 实例 URL"""
return any(host in url for host in NITTER_HOSTS)
def parse_entry_time(entry) -> Optional[datetime]:
"""解析条目的发布时间 (返回带 UTC 时区的 datetime)"""
published_parsed = getattr(entry, "published_parsed", None)
if published_parsed is not None:
return datetime(*published_parsed[:6], tzinfo=timezone.utc)
updated_parsed = getattr(entry, "updated_parsed", None)
if updated_parsed is not None:
return datetime(*updated_parsed[:6], tzinfo=timezone.utc)
return None
def _extract_body(entry) -> str:
"""提取条目正文:优先 content(含 <content:encoded>),其次 description,最后 summary"""
content_list = getattr(entry, "content", None)
if content_list:
value = content_list[0].get("value", "")
if value:
return value
description = getattr(entry, "description", "")
if description:
return description
return getattr(entry, "summary", "") or ""
def _truncate_title(title: str) -> str:
if len(title) <= TITLE_MAX_CHARS:
return title
return title[:TITLE_MAX_CHARS].rstrip() + ""
def _parse_feed_entries(content, feed_info: Dict, cutoff_time: datetime) -> List[Dict]:
"""把 feed 字节/字符串解析为条目列表,按 cutoff 时间过滤"""
feed = feedparser.parse(content)
entries = []
for entry in feed.entries:
pub_date = parse_entry_time(entry)
# RSS 通常按时间倒序排列,一旦发现过期直接跳出
if pub_date and pub_date < cutoff_time:
break
entries.append(
{
"title": _truncate_title(entry.get("title", "无标题")),
"link": entry.get("link", ""),
"published": pub_date,
"source": feed_info["title"],
"content": _extract_body(entry),
"tags": [],
"score": 0,
"summary": "",
}
)
return entries
async def _fetch_nitter_content(url: str, timeout: int) -> Optional[bytes]:
"""nitter / xcancel 专用:requests + Inoreader UA,丢线程池避免阻塞 loop"""
def _sync():
try:
r = requests.get(url, headers=NITTER_HEADERS, timeout=timeout)
if r.status_code != 200:
print(f"⚠️ HTTP {r.status_code}: {url}")
return None
return r.content
except Exception as e:
print(f"⚠️ nitter 抓取失败 {url}: {e}")
return None
return await asyncio.to_thread(_sync)
async def _fetch_aiohttp_content(
url: str, timeout: int, session: aiohttp.ClientSession = None
) -> Optional[str]:
"""普通 RSS 源:aiohttp + 浏览器 UA"""
client_timeout = aiohttp.ClientTimeout(total=timeout)
if session is not None:
async with session.get(
url, headers=DEFAULT_HEADERS, timeout=client_timeout
) as resp:
if resp.status != 200:
print(f"⚠️ HTTP {resp.status}: {url}")
return None
return await resp.text()
async with aiohttp.ClientSession() as sess:
async with sess.get(
url, headers=DEFAULT_HEADERS, timeout=client_timeout
) as resp:
if resp.status != 200:
print(f"⚠️ HTTP {resp.status}: {url}")
return None
return await resp.text()
async def fetch_single_feed_async(
feed_info: Dict,
cutoff_time: datetime,
timeout: int = 5,
session: aiohttp.ClientSession = None,
) -> List[Dict]:
"""异步获取单个源的条目"""
try:
if timeout is None:
timeout = DEFAULT_FEED_TIMEOUT
url = feed_info["xmlUrl"]
if is_nitter_url(url):
content = await _fetch_nitter_content(url, timeout)
else:
content = await _fetch_aiohttp_content(url, timeout, session)
if content is None:
return []
return _parse_feed_entries(content, feed_info, cutoff_time)
except Exception as e:
print(f"⚠️ 获取失败 {feed_info['title']}: {e}")
return []
async def fetch_all_feeds(
feeds: List[Dict], cutoff_time: datetime, max_workers: int = 10, timeout: int = None
) -> List[Dict]:
"""并发获取所有源的条目;nitter/xcancel 走独立的低并发池"""
if timeout is None:
timeout = DEFAULT_FEED_TIMEOUT
nitter_feeds = [f for f in feeds if is_nitter_url(f.get("xmlUrl", ""))]
normal_feeds = [f for f in feeds if not is_nitter_url(f.get("xmlUrl", ""))]
normal_sem = asyncio.Semaphore(max_workers)
nitter_sem = asyncio.Semaphore(NITTER_MAX_CONCURRENCY)
async def fetch_normal(feed):
async with normal_sem:
return await fetch_single_feed_async(feed, cutoff_time, timeout)
async def fetch_nitter(feed):
async with nitter_sem:
result = await fetch_single_feed_async(feed, cutoff_time, timeout)
# 公益实例:抓完 sleep,把同一 worker 串内的请求拉开
await asyncio.sleep(NITTER_REQUEST_DELAY)
return result
ordered_feeds = normal_feeds + nitter_feeds
tasks = [fetch_normal(f) for f in normal_feeds] + [
fetch_nitter(f) for f in nitter_feeds
]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_entries = []
for feed, result in zip(ordered_feeds, results):
if isinstance(result, Exception):
print(f"⚠️ 获取失败 {feed['title']}: {result}")
else:
all_entries.extend(result)
return all_entries
+634
View File
@@ -0,0 +1,634 @@
"""LLM模块 - 评分和汇总"""
import asyncio
import json
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from src.markdown_utils import normalize_str_list, parse_frontmatter
def load_prompt(prompt_path: str, **kwargs) -> str:
"""加载提示词模板并填充变量"""
path = Path(prompt_path)
if not path.exists():
raise FileNotFoundError(f"提示词文件不存在: {prompt_path}")
with open(path, "r", encoding="utf-8") as f:
template = f.read()
# 先把模板中的 {{ 和 }} 替换成占位符,避免与format冲突
template = template.replace("{{", "\x00LEFT_BRACE\x00").replace(
"}}", "\x00RIGHT_BRACE\x00"
)
# 替换变量
for key, value in kwargs.items():
template = template.replace(f"{{{key}}}", str(value))
# 恢复 {{ 和 }}
template = template.replace("\x00LEFT_BRACE\x00", "{").replace(
"\x00RIGHT_BRACE\x00", "}"
)
return template
async def call_llm(
prompt: str, config: Dict, response_format: Optional[Dict] = None
) -> str:
"""调用LLM API - 统一使用OpenAI兼容接口"""
model = config.get("model", "gpt-4o-mini")
base_url = config.get("baseUrl", "https://api.openai.com/v1")
api_key_name = config.get("apiKeyName", "OPENAI_API_KEY")
api_key = os.environ.get(api_key_name)
if not api_key:
raise ValueError(f"未设置{api_key_name}环境变量")
import aiohttp
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
}
if response_format is not None:
payload["response_format"] = response_format
url = f"{base_url}/chat/completions"
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload) as resp:
if resp.status != 200:
text = await resp.text()
raise RuntimeError(f"LLM API错误: {resp.status} - {text}")
data = await resp.json()
return data["choices"][0]["message"]["content"]
async def check_llm_available(config: Dict, timeout_seconds: int = 15) -> str:
"""启动时检查 LLM 接口可用性"""
prompt = "Reply with OK only."
try:
response = await asyncio.wait_for(
call_llm(prompt, config), timeout=timeout_seconds
)
except asyncio.TimeoutError as exc:
raise RuntimeError(f"LLM可用性检查超时({timeout_seconds}s)") from exc
except Exception as exc:
raise RuntimeError(f"LLM可用性检查失败: {exc}") from exc
response_text = response.strip()
if not response_text:
raise RuntimeError("LLM可用性检查返回空响应")
return response_text
def _build_batch_prompt(entries: List[Dict], prompt_path: str = None) -> str:
"""构建批量评分prompt"""
# 构建entries JSON列表(只包含必要字段)
entries_for_llm = []
for e in entries:
entries_for_llm.append(
{
"link": e.get("link", ""),
"title": e.get("title", "无标题"),
"source": e.get("source", "未知来源"),
"published": e.get("published", ""),
"content": e.get("content", "")[:2000], # 限制内容长度
}
)
entries_json = json.dumps(entries_for_llm, ensure_ascii=False, indent=2)
# 从文件加载提示词模板,如果未指定则使用默认路径
if prompt_path is None:
prompt_path = "prompts/score_batch.md"
return load_prompt(prompt_path, entries_json=entries_json)
def _parse_llm_json_response(response: str) -> List[Dict]:
"""解析LLM返回的JSON响应"""
text = response.strip()
# 尝试去除markdown代码块
if text.startswith("```json"):
text = text[7:]
elif text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
# 尝试查找JSON数组
if text.startswith("[") and text.endswith("]"):
try:
return json.loads(text)
except json.JSONDecodeError:
print("⚠️ 直接解析JSON失败,尝试从文本中提取JSON数组")
pass
# 尝试从文本中提取JSON数组
match = re.search(r"\[.*\]", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
print("⚠️ 从文本中提取JSON数组失败:", text)
pass
raise ValueError(f"无法从响应中解析JSON: {response[:200]}...")
def _parse_score_response(response: str) -> List[Dict]:
"""解析评分LLM响应。
json_object 模式下应返回 {"items": [...]} 形式的对象;
兼容直接数组与 markdown 包裹作为兜底路径。
"""
text = response.strip()
if text.startswith("```json"):
text = text[7:]
elif text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
parsed = None
try:
parsed = json.loads(text)
except json.JSONDecodeError:
for pattern in (r"\{.*\}", r"\[.*\]"):
match = re.search(pattern, text, re.DOTALL)
if match:
try:
parsed = json.loads(match.group())
break
except json.JSONDecodeError:
continue
if parsed is None:
print(f"无法从响应中解析JSON: {response}")
raise ValueError(f"无法从响应中解析JSON: {response[:200]}...")
if isinstance(parsed, list):
return parsed
if isinstance(parsed, dict):
for key in ("items", "results", "data", "scores"):
if isinstance(parsed.get(key), list):
return parsed[key]
list_values = [v for v in parsed.values() if isinstance(v, list)]
if len(list_values) == 1:
return list_values[0]
print(f"无法从响应中提取评分数组: {response}")
raise ValueError(f"无法从响应中提取评分数组: {response[:200]}...")
def _split_entries_for_batch(
entries: List[Dict], max_prompt_chars: int = 10000
) -> List[List[Dict]]:
"""将entries分成多个批次,每批不超过max_prompt_chars字符"""
if not entries:
return []
batches = []
current_batch = []
current_chars = 0
# 预留prompt模板和JSON包装的空间
overhead = len(_build_batch_prompt([])) + 500
for entry in entries:
# 估算该entry在JSON中的字符数
entry_chars = len(
json.dumps(
{
"link": entry.get("link", ""),
"title": entry.get("title", "")[:100],
"source": entry.get("source", ""),
"published": entry.get("published", ""),
"content": entry.get("content", "")[:2000],
},
ensure_ascii=False,
)
)
# 如果当前批次加上这个entry会超出限制,且当前批次不为空,则创建新批次
if current_chars + entry_chars + overhead > max_prompt_chars and current_batch:
batches.append(current_batch)
current_batch = [entry]
current_chars = entry_chars
else:
current_batch.append(entry)
current_chars += entry_chars
# 添加最后一个批次
if current_batch:
batches.append(current_batch)
return batches
def _reconcile_batch_results(
entries: List[Dict], results: List[Dict], batch_index: int
) -> Tuple[List[Dict], List[str]]:
"""对单批评分结果按 link 过滤,保留可回收结果"""
entry_links = {entry.get("link") for entry in entries if entry.get("link")}
matched_results = []
result_links = set()
for item in results:
if not isinstance(item, dict):
continue
link = item.get("link")
if link:
result_links.add(link)
if link in entry_links:
matched_results.append(item)
errors = []
if len(results) != len(entries) or len(matched_results) != len(entries):
missing_links = sorted(entry_links - result_links)
error_message = (
"批次{batch} 评分结果异常: 输入{input_count}, 返回{output_count}, "
"匹配{matched_count}, 未评分链接({missing_count}): {missing}"
).format(
batch=batch_index + 1,
input_count=len(entries),
output_count=len(results),
matched_count=len(matched_results),
missing_count=len(missing_links),
missing=missing_links,
)
print(f"⚠️ {error_message}")
errors.append(error_message)
return matched_results, errors
async def _score_single_batch(
entries: List[Dict], config: Dict, batch_index: int = 0
) -> Tuple[List[Dict], List[str]]:
"""对单批entries进行评分"""
# 从config获取批量评分提示词路径
prompt_path = config.get("prompts", {}).get("score_batch", "prompts/score_batch.md")
prompt = _build_batch_prompt(entries, prompt_path)
try:
response = await call_llm(
prompt, config, response_format={"type": "json_object"}
)
results = _parse_score_response(response)
if not isinstance(results, list):
raise ValueError(f"LLM返回的不是数组: {type(results)}")
return _reconcile_batch_results(entries, results, batch_index)
except Exception as e:
error_message = f"批次{batch_index + 1} 评分失败: {e}"
print(f"⚠️ {error_message}")
return [], [error_message]
async def score_batch(
entries: List[Dict], config: Dict
) -> Tuple[List[Dict], List[str]]:
"""
批量评分 - 智能分批处理
根据数据量自动决定分批策略:
- 小批量:一次性发送
- 大批量:分成多个批次并行处理
"""
if not entries:
return [], []
# 获取分批配置
max_prompt_chars = config.get("max_prompt_chars", 10000)
max_concurrent_batches = config.get("max_concurrent_batches", 3)
# 分批
batches = _split_entries_for_batch(entries, max_prompt_chars)
print(f"📦 分成 {len(batches)} 个批次评分 (共 {len(entries)} 条)")
# 如果只有一批,直接处理
if len(batches) == 1:
scores, errors = await _score_single_batch(batches[0], config, batch_index=0)
return _merge_scores(entries, scores), errors
# 多批并行处理(限制并发数)
semaphore = asyncio.Semaphore(max_concurrent_batches)
async def score_with_limit(batch_index: int, batch: List[Dict]):
async with semaphore:
return await _score_single_batch(batch, config, batch_index=batch_index)
# 并发处理所有批次
batch_tasks = [
score_with_limit(batch_index, batch)
for batch_index, batch in enumerate(batches)
]
batch_results = await asyncio.gather(*batch_tasks)
# 合并所有评分结果
all_scores = []
all_errors = []
for scores, errors in batch_results:
all_scores.extend(scores)
all_errors.extend(errors)
return _merge_scores(entries, all_scores), all_errors
def _merge_scores(entries: List[Dict], scores: List[Dict]) -> List[Dict]:
"""将评分结果合并到原始entries中"""
# 构建link到score的映射
score_map = {s.get("link"): s for s in scores if s.get("link")}
merged = []
for entry in entries:
link = entry.get("link")
score_data = score_map.get(link, {})
# 确保 score 为整数类型
score_value = score_data.get("score", entry.get("score"))
if isinstance(score_value, str):
try:
score_value = int(score_value)
except (ValueError, TypeError):
score_value = 0
merged.append(
{
**entry,
"tags": score_data.get("tags", entry.get("tags", [])),
"score": score_value,
"summary": score_data.get("summary", entry.get("summary", "")),
}
)
return merged
async def generate_immediate_push(
entries: List[Dict], config: Dict, recent_push_context: str = ""
) -> Tuple[str, Optional[str]]:
"""生成即时推送内容
Args:
entries: 原始entries列表(调用方已筛选好高分条目)
config: LLM配置
recent_push_context: 近期推送上下文,用于去重
"""
prompt_path = config.get("prompts", {}).get(
"immediate_push", "prompts/immediate_push.txt"
)
# 直接使用传入的entries,转为JSON格式传给prompt
prompt = load_prompt(
prompt_path,
count=len(entries),
entries=json.dumps(entries, ensure_ascii=False, indent=2),
recent_push_context=recent_push_context,
)
try:
return await call_llm(prompt, config), None
except Exception as e:
error_message = f"生成即时推送失败: {e}"
print(f"⚠️ {error_message}")
return "", error_message
async def compose_digest(
entries: List[Dict],
context: List[Dict],
config: Dict,
recent_push_context: str = "",
) -> str:
"""生成定时汇总推送内容
Args:
entries: 原始entries列表
context: 历史碎片化信息(用于去重参考),只保留 title, published, tags, summary, source
config: LLM配置
recent_push_context: 近期汇总推送上下文,用于去重
"""
prompt_path = config.get("prompts", {}).get("digest", "prompts/digest.md")
# context 只保留必要字段,拼接成字符串
context_text = []
for c in context:
tags_str = ", ".join(c.get("tags", [])) if c.get("tags") else ""
context_text.append(
f"[score: {c.get('score', 0)}] title:{c.get('title', '')}\n"
f"published: {c.get('published', '')}\n"
f"tags: {tags_str}\n"
f"source: {c.get('source', '')}\n"
f"summary: {c.get('summary', '')}"
)
prompt = load_prompt(
prompt_path,
count=len(entries),
entries=json.dumps(entries, ensure_ascii=False, indent=2),
context="\n\n".join(context_text),
recent_push_context=recent_push_context,
date=datetime.now().strftime("%Y-%m-%d"),
)
try:
return await call_llm(prompt, config)
except Exception:
raise
async def summarize_github_trending(
enriched_repos: List[Dict], config: Dict
) -> Tuple[str, Optional[str]]:
"""GH 板块总结:从 enriched 候选中选 1-max_items + 写 markdown。不传历史上下文。"""
prompt_path = config.get("prompts", {}).get(
"section_github", "prompts/section_github.md"
)
max_items = (
config.get("sections", {}).get("github_trending", {}).get("max_items", 3)
)
prompt = load_prompt(
prompt_path,
repos_json=json.dumps(enriched_repos, ensure_ascii=False, indent=2),
max_items=max_items,
)
try:
return await call_llm(prompt, config), None
except Exception as e:
msg = f"summarize_github_trending 失败: {e}"
print(f"⚠️ {msg}")
return "", msg
async def select_ai_related_hn(
candidates: List[Dict], k: int, config: Dict
) -> Tuple[List[str], Optional[str]]:
"""轻 LLM:从 HN 首页候选元数据中挑 k 个 AI 相关 id。
输入候选只含 id/title/site/points/comments 字段(不含正文)。
"""
prompt_path = config.get("prompts", {}).get(
"section_hackernews_select", "prompts/section_hackernews_select.md"
)
slim = [
{
"id": c.get("id"),
"title": c.get("title", ""),
"site": c.get("site", ""),
"points": c.get("points", 0),
"comments": c.get("comments", 0),
}
for c in candidates
]
prompt = load_prompt(
prompt_path,
k=k,
candidates_json=json.dumps(slim, ensure_ascii=False, indent=2),
)
try:
response = await call_llm(prompt, config)
except Exception as e:
msg = f"select_ai_related_hn 失败: {e}"
print(f"⚠️ {msg}")
return [], msg
try:
ids = _parse_llm_json_response(response)
except ValueError as e:
msg = f"select_ai_related_hn 解析失败: {e}"
print(f"⚠️ {msg}")
return [], msg
if not isinstance(ids, list):
return [], "select_ai_related_hn 返回非数组"
return [str(x) for x in ids][:k], None
async def summarize_hackernews(
enriched_stories: List[Dict], config: Dict
) -> Tuple[str, Optional[str]]:
"""对输入的 K 个 enriched stories 行文(K 由 select_k 决定)。不传历史上下文。"""
prompt_path = config.get("prompts", {}).get(
"section_hackernews", "prompts/section_hackernews.md"
)
prompt = load_prompt(
prompt_path,
stories_json=json.dumps(enriched_stories, ensure_ascii=False, indent=2),
)
try:
return await call_llm(prompt, config), None
except Exception as e:
msg = f"summarize_hackernews 失败: {e}"
print(f"⚠️ {msg}")
return "", msg
async def generate_trend_insights(
sections: Dict[str, str], config: Dict
) -> Tuple[str, Optional[str]]:
"""输入三段成品,返回洞察段 markdown(含 frontmatter)。"""
prompt_path = config.get("prompts", {}).get("insights", "prompts/insights.md")
prompt = load_prompt(
prompt_path,
rss=sections.get("rss", ""),
github=sections.get("github", ""),
hackernews=sections.get("hackernews", ""),
)
try:
return await call_llm(prompt, config), None
except Exception as e:
msg = f"generate_trend_insights 失败: {e}"
print(f"⚠️ {msg}")
return "", msg
def parse_insights_with_metadata(llm_output: str, date: str) -> Tuple[str, Dict]:
"""解析 insights LLM 输出,返回 (insights_md, metadata)。
metadata 字段:title / excerpt / seotitle / seodescription / lead / highlights /
profile / date。缺失字段补默认值。
"""
meta, body = parse_frontmatter(llm_output)
insights_md = body if meta else llm_output
metadata = {
"title": meta.get("title") or f"📰 AI Daily 每日精选 | {date}",
"excerpt": meta.get("excerpt", ""),
"seotitle": meta.get("seotitle", ""),
"seodescription": meta.get("seodescription", ""),
"lead": meta.get("lead", ""),
"highlights": normalize_str_list(meta.get("highlights")),
"profile": "morning",
"date": date,
}
return insights_md, metadata
def parse_digest_with_metadata(llm_output: str, date: str) -> Tuple[str, Dict]:
"""解析 digest LLM 输出,返回 (digest_md, metadata)。
metadata 字段:title / lead / highlights / profile / date。
无 frontmatter 时回退到 "🌙 AI Daily 晚报 | {date}" 标题。
"""
meta, body = parse_frontmatter(llm_output)
digest_md = body if meta else llm_output
metadata = {
"title": meta.get("title") or f"🌙 AI Daily 晚报 | {date}",
"lead": meta.get("lead", ""),
"highlights": normalize_str_list(meta.get("highlights")),
"profile": "default",
"date": date,
}
return digest_md, metadata
def parse_immediate_push_with_metadata(
llm_output: str, default_title: str
) -> Tuple[str, Dict]:
"""解析即时推送 LLM 输出,返回 (body, metadata)。
metadata 仅含 title / profile。无 frontmatter 时降级到旧式 `# ` 标题提取,
再降级到 default_title。
"""
meta, body = parse_frontmatter(llm_output)
if meta and meta.get("title"):
return body, {"title": meta["title"], "profile": "hotspot"}
# 兼容旧格式:从正文一级标题提取
match = re.search(r"^\s*#\s+(.+?)\s*\n(.*)$", llm_output, re.DOTALL | re.MULTILINE)
if match:
return match.group(2).rstrip(), {
"title": match.group(1).strip(),
"profile": "hotspot",
}
return llm_output, {"title": default_title, "profile": "hotspot"}
+714
View File
@@ -0,0 +1,714 @@
"""AI每日资讯推送系统 - 主程序"""
import argparse
import asyncio
import os
import sys
from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional
# 加载 .env 文件
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from croniter import croniter
from src.config import get_timezone, load_config, merge_sources
from src.fetcher import fetch_all_feeds
from src.llm import (
check_llm_available,
generate_immediate_push,
parse_immediate_push_with_metadata,
score_batch,
)
from src.processor import html_to_markdown
from src.push import send_to_platforms
from src.sections.github.section import run_github_section
from src.sections.hackernews.section import run_hackernews_section
from src.sections.insights.section import run_insights_section
from src.sections.rss.section import run_rss_section
from src.storage import (
append_entries,
assemble_with_sentinels,
cleanup_old_files,
get_fetch_file,
get_notify_file,
get_push_file,
load_existing_links,
load_recent_notify_content,
load_recent_push_content,
read_entries,
save_notify_file,
save_push_file,
)
async def notify_llm_errors(stage: str, errors: List[str], config: Dict):
"""发送简单的 LLM 异常通知"""
if not errors:
return
lines = [
"## LLM异常",
"",
f"stage: {stage}",
f"time: {now_local(config).strftime('%Y-%m-%d %H:%M:%S')}",
"",
]
lines.extend(f"- {error}" for error in errors)
try:
await send_to_platforms("\n".join(lines), config["push"])
except Exception as e:
print(f"⚠️ LLM异常通知发送失败: {e}")
def now_local(config: Dict = None) -> datetime:
"""获取配置时区的当前时间"""
return datetime.now(get_timezone(config))
def parse_time_to_local(time_str: str, config: Dict = None) -> Optional[datetime]:
"""解析时间字符串为配置时区的datetime"""
try:
dt = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
return dt.astimezone(get_timezone(config))
except (ValueError, TypeError):
return None
def calculate_push_times(
cron_list: List[str], offset_days: int = 0, config: Dict = None
) -> List[datetime]:
base_date = datetime.now(get_timezone(config)).date() + timedelta(days=offset_days)
times = []
for cron in cron_list:
try:
minute, hour, _, _, _ = cron.split()
t = datetime.combine(
base_date,
datetime.strptime(f"{hour}:{minute}", "%H:%M").time(),
tzinfo=get_timezone(config),
)
times.append(t)
except ValueError:
continue
return sorted(times)
def is_morning_push(now: datetime, config: Dict) -> bool:
"""判定当前 push 是否为「早报」(触发 GH/HN/insights 三段)。
规则:在 `schedule.push_cron` 列表里,now 离哪条 cron 最近,就归为那条;
最近的那条若是当天最早的 cron,则视为早报。
特例:
- `push_cron` 为空 → 不视为早报
- `push_cron` 只有一条 → 该条即"最早"也即"最近",任何触发都视为早报
"""
cron_list = config.get("schedule", {}).get("push_cron", [])
if not cron_list:
return False
if len(cron_list) == 1:
return True
base = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_fires = [croniter(c, base).get_next(datetime) for c in cron_list]
closest = min(today_fires, key=lambda f: abs(now - f))
return closest == min(today_fires)
def collect_entries_for_push(
last_push_time: Optional[datetime],
context_days: int = 2,
min_score: int = 60,
data_dir: str = "news-data",
) -> tuple[List[Dict], List[Dict]]:
"""
收集推送所需的条目,返回 (待推送条目, 上下文条目)
逻辑:
1. 获取 context_days 天内的所有条目
2. 按 min_score 过滤
3. push_time = max(last_push_time, now - 24h)
4. 晚于 push_time 的 → 待推送条目
5. 早于 push_time 的 → 上下文条目(用于LLM去重参考)
"""
tz = get_timezone()
now = datetime.now(tz)
# 获取 context_days 天的所有条目
all_entries = []
today = now.date()
for i in range(context_days):
d = today - timedelta(days=i)
fetch_file = get_fetch_file(d, data_dir)
for entry in read_entries(fetch_file):
all_entries.append(entry)
print(
f"📋 收集总条目: {len(all_entries)} 条 , context_days: {context_days}, min_score:{min_score}"
)
# 按 min_score 过滤
qualified_entries = [e for e in all_entries if (e.get("score") or 0) >= min_score]
print(f"📋 过滤后条目: {len(qualified_entries)}")
# 计算推送时间边界:max(last_push_time, now - 24h)
past_24h = now - timedelta(hours=24)
push_cutoff = (
last_push_time if last_push_time and last_push_time > past_24h else past_24h
)
print(f"推送时间边界: {push_cutoff.strftime('%Y-%m-%d %H:%M:%S')}")
# 分割条目
to_push = []
context = []
# context 只供 LLM 做去重/历史参考,不需要 content/link/fetched_at 等大字段
CONTEXT_FIELDS = ("title", "source", "score", "summary", "tags", "published")
for entry in qualified_entries:
entry_time = parse_time_to_local(entry.get("fetched_at", ""))
if entry_time and entry_time > push_cutoff:
to_push.append(entry)
else:
context.append({k: entry.get(k) for k in CONTEXT_FIELDS})
# 上下文按分数排序,取前50
context = sorted(context, key=lambda x: x.get("score", 0), reverse=True)[:50]
return to_push, context
async def run_fetch_job(config: Dict):
print(f"\n{'=' * 50}")
print(f"🔄 Fetch Job | {now_local().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'=' * 50}")
interval = config["schedule"]["fetch_interval_minutes"]
lookback = config["schedule"].get("fetch_lookback_minutes", 120)
lookback = max(lookback, interval)
threshold = lookback + interval
cutoff = datetime.now(timezone.utc) - timedelta(minutes=lookback)
sources = merge_sources(config["sources"])
print(f"📂 共 {len(sources)} 个订阅源")
if not sources:
print("⚠️ 没有可用的订阅源")
return
max_workers = config.get("fetch", {}).get("max_workers", 20)
timeout = config.get("fetch", {}).get("timeout", 30)
entries = await fetch_all_feeds(
sources, cutoff, max_workers=max_workers, timeout=timeout
)
print(f"📥 抓取到 {len(entries)} 条原始消息")
if not entries:
return
for entry in entries:
entry["content"] = html_to_markdown(
entry.get("content", ""), entry.get("link", "")
)
fetch_file = get_fetch_file()
existing_links = load_existing_links(fetch_file, threshold)
new_entries = [
e for e in entries if e.get("link") and e["link"] not in existing_links
]
print(f"🆕 新消息 {len(new_entries)} 条 | 链接数:{len(existing_links)}")
if not new_entries:
return
print("🤖 LLM评分中...")
# 预处理:将所有 datetime 转换为字符串,避免 JSON 序列化错误
for entry in new_entries:
if isinstance(entry.get("published"), datetime):
entry["published"] = (
entry["published"].astimezone(get_timezone(config)).isoformat()
)
scored, score_errors = await score_batch(new_entries, config["llm"])
if score_errors:
print(f"⚠️ [score_batch] {len(score_errors)} 个错误: {score_errors[0]}")
await notify_llm_errors("score_batch", score_errors, config)
is_new_file = not os.path.exists(fetch_file)
if is_new_file:
cleanup_old_files(days=config["filter"]["keep_days"])
# 添加 fetched_at 时间戳
for entry in scored:
entry["fetched_at"] = now_local().isoformat()
if isinstance(entry.get("published"), datetime):
entry["published"] = (
entry["published"].astimezone(get_timezone(config)).isoformat()
)
# 批量保存到 JSON 文件
from datetime import date
meta = {"date": date.today().isoformat()}
append_entries(fetch_file, scored, meta)
print(f"💾 已保存到 {fetch_file}")
hot_threshold = config["filter"]["hot_threshold"]
no_content_marker = config["filter"].get("no_content_marker", "[NO_NEW_CONTENT]")
hot_entries = [e for e in scored if (e.get("score") or 0) >= hot_threshold]
if hot_entries:
print(f"🔥 发现 {len(hot_entries)} 条热点消息,即时推送...")
# 加载近期已推送内容(仅供 LLM 查重,避免风格趋同)
context_days = config["filter"]["context_days"]
recent_notify = load_recent_notify_content(context_days)
recent_push = load_recent_push_content(context_days)
recent_context = (
f"=== 近期即时推送 ===\n{recent_notify}\n\n"
f"=== 近期汇总推送 ===\n{recent_push}"
)
push_content, immediate_push_error = await generate_immediate_push(
hot_entries, config["llm"], recent_push_context=recent_context
)
if immediate_push_error:
print(f"⚠️ [generate_immediate_push] {immediate_push_error}")
await notify_llm_errors(
"generate_immediate_push", [immediate_push_error], config
)
if not push_content:
print("⚠️ 即时推送内容生成失败,跳过本次热点推送")
print(
f"✅ Fetch Job 完成 | 新消息: {len(scored)} 条 | 热点: {len(hot_entries)}"
)
return
# 检查是否有实际内容需要推送
if no_content_marker in push_content:
print(f"ℹ️ 无新内容需要推送 (LLM判定为重复内容)")
else:
# 提取标题并构建 metadata
now = now_local(config)
timestamp = now.strftime("%Y-%m-%d %H:%M")
content_without_title, metadata = parse_immediate_push_with_metadata(
push_content, f"🚨 AI Daily 快讯 | {timestamp}"
)
metadata["pushTime"] = now.isoformat()
await send_to_platforms(
content_without_title,
config["push"],
"🚨 AI Daily 快讯 | " + metadata["title"],
metadata=metadata,
)
# 保存即时推送内容到notify文件
notify_file = get_notify_file()
save_notify_file(notify_file, content_without_title, metadata)
print(f"💾 已保存即时推送到 {notify_file}")
print(f"✅ Fetch Job 完成 | 新消息: {len(scored)} 条 | 热点: {len(hot_entries)}")
async def run_push_job(config: Dict):
print(f"\n{'=' * 50}")
print(f"📤 Push Job | {now_local().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'=' * 50}")
if is_morning_push(now_local(config), config):
await _run_morning_push(config)
else:
await _run_default_push(config)
async def _run_default_push(config: Dict):
"""晚报或非早报时段:沿用原有纯 RSS digest 流程,委托给 run_rss_section"""
now = now_local(config)
rss_md, metadata, rss_err = await run_rss_section(config, now)
if rss_err and not rss_md:
print(f"⚠️ [compose_digest] {rss_err}")
await notify_llm_errors("compose_digest", [rss_err], config)
raise RuntimeError(f"RSS section failed: {rss_err}")
if not rss_md:
# run_rss_section 在无新消息时已打印 "️ RSS: 无新消息"
return
# metadata 缺失兜底:parse_digest_with_metadata 失败 / LLM 输出无 frontmatter 时可能返回 None
if not metadata:
date_str = now.strftime("%Y-%m-%d")
metadata = {
"title": f"🌙 AI Daily 晚报 | {date_str}",
"lead": "",
"highlights": [],
"profile": "default",
"date": date_str,
}
metadata.setdefault("pushTime", now.isoformat())
await send_to_platforms(
rss_md,
config["push"],
title="📰 AI Daily 每日精选 | " + metadata["title"],
metadata=metadata,
)
push_file = get_push_file()
rss_count = rss_md.count("###")
save_push_file(
push_file,
rss_md,
rss_count,
rss_count,
profile="default",
metadata=metadata,
)
print(f"💾 已保存到 {push_file}")
print(f"✅ Push Job 完成 | 推送条目: {rss_count}")
async def _run_morning_push(config: Dict):
"""早报四模块编排:RSS/GH/HN 并发 → insights 串行 → sentinel 拼装 → 推送 → 落盘。
失败语义:
- RSS 失败 → 整体抛 RuntimeError (核心承诺不变)
- GH/HN/insights 失败 → 该段省略 + 告警,其他段照推
"""
now = now_local(config)
rss_result, gh_result, hn_result = await asyncio.gather(
run_rss_section(config, now),
run_github_section(config, now),
run_hackernews_section(config, now),
)
# 早报场景:digest 的 metadata 通常会被 insights 段覆盖,
# 但保留以便在 insights 失败时作为兜底来源(title 关键词 / lead / highlights)
rss_md, digest_meta, rss_err = rss_result
gh_md, gh_err = gh_result
hn_md, hn_err = hn_result
if gh_err:
print(f"⚠️ [section_github] {gh_err}")
await notify_llm_errors("section_github", [gh_err], config)
if hn_err:
print(f"⚠️ [section_hackernews] {hn_err}")
await notify_llm_errors("section_hackernews", [hn_err], config)
if rss_err and not rss_md:
print(f"⚠️ [compose_digest] {rss_err}")
await notify_llm_errors("compose_digest", [rss_err], config)
raise RuntimeError(f"RSS section failed: {rss_err}")
insights_md, metadata, insights_err = await run_insights_section(
rss_md, gh_md, hn_md, config, now
)
if insights_err:
print(f"⚠️ [insights] {insights_err}")
await notify_llm_errors("insights", [insights_err], config)
# 如果 insights 失败,优先用 digest metadata 兜底;两者都缺再走默认
if not metadata:
date_str = now.strftime("%Y-%m-%d")
fallback = digest_meta or {}
digest_title = fallback.get("title", "")
title = digest_title if digest_title else f"📰 AI Daily 每日精选 | {date_str}"
metadata = {
"date": date_str,
"pushTime": now.isoformat(),
"title": title,
"excerpt": "",
"seotitle": "",
"seodescription": "",
"lead": fallback.get("lead", ""),
"highlights": fallback.get("highlights", []),
"profile": "morning",
}
else:
metadata.setdefault("pushTime", now.isoformat())
final = assemble_with_sentinels(
{
"rss": rss_md,
"github": gh_md,
"hackernews": hn_md,
"insights": insights_md,
}
)
if not final.strip():
print("️ 早报无任何段输出,跳过推送")
return
await send_to_platforms(
final,
config["push"],
title="📰 AI Daily 每日精选 | " + metadata["title"],
metadata=metadata,
)
push_file = get_push_file()
rss_count = rss_md.count("###") if rss_md else 0
save_push_file(
push_file, final, rss_count, rss_count, profile="morning", metadata=metadata
)
print(f"💾 已保存早报到 {push_file}")
async def fetch_loop(config: Dict):
"""Fetch循环 - 修复时间漂移并支持优雅退出"""
import time
interval_seconds = config["schedule"]["fetch_interval_minutes"] * 60
print(f"🔄 Fetch循环已启动 | 严格间隔: {interval_seconds / 60}分钟")
while True:
start_time = time.monotonic() # 使用 monotonic 避免系统时间修改影响
try:
await run_fetch_job(config)
except asyncio.CancelledError:
print("⚠️ Fetch循环被外部取消,正在安全退出...")
break # 允许外部取消任务
except Exception as e:
print(f"❌ Fetch Job 失败: {e}")
# 计算任务耗时
elapsed = time.monotonic() - start_time
# 计算还需要睡多久(如果任务耗时超过间隔,则不睡,立刻进入下一次)
sleep_time = max(0.0, interval_seconds - elapsed)
if sleep_time > 0:
print(f"⏰ 下次抓取: {sleep_time / 60:.1f}分钟后")
try:
await asyncio.sleep(sleep_time)
except asyncio.CancelledError:
print("⚠️ 睡眠被中断,Fetch循环安全退出...")
break
async def push_loop(config: Dict):
"""Push循环 - 无状态 croniter + 原生异步睡眠"""
cron_list = config["schedule"]["push_cron"]
tz = get_timezone(config)
# 1. 启动前预校验 cron 表达式,过滤掉无效配置
valid_crons = []
for cron in cron_list:
if croniter.is_valid(cron):
valid_crons.append(cron)
else:
print(f"⚠️ 忽略无效的 cron 表达式: '{cron}'")
if not valid_crons:
print("❌ 没有有效的推送时间配置,Push循环退出")
return
print(f"📤 Push循环已启动 | 定时: {', '.join(valid_crons)} | 时区: {tz}")
# 2. 主循环
while True:
try:
now = datetime.now(tz)
# 💡 核心优化:无状态计算。
# 每次都基于此刻的真实时间,动态计算所有有效 cron 的下一次时间,取最近的一个。
# 这样无论 run_push_job 执行多久,或者系统休眠过,永远都不会算错。
next_push = min(
croniter(cron, now).get_next(datetime) for cron in valid_crons
)
wait_seconds = (next_push - datetime.now(tz)).total_seconds()
if wait_seconds > 0:
print(
f"⏰ 下次推送: {next_push.strftime('%Y-%m-%d %H:%M:%S')} (等待 {wait_seconds / 60:.1f} 分钟)"
)
# 💡 核心优化:直接 Sleep。asyncio 天生支持被 CancelledError 瞬间打断
await asyncio.sleep(wait_seconds)
# 到达推送时间,执行推送
print(f"📤 执行推送: {datetime.now(tz).strftime('%Y-%m-%d %H:%M:%S')}")
await run_push_job(config)
# 增加 1 秒缓冲:防止 run_push_job 执行过快(不到 1 秒),
# 导致下一个循环的 now 仍停留在当前秒,croniter 算出重复的时间点。
await asyncio.sleep(1)
except asyncio.CancelledError:
print("⚠️ Push循环收到取消信号,安全退出...")
break # 直接 break 退出循环即可
except Exception as e:
print(f"❌ Push 循环异常: {e}")
# 遇到未知异常时休眠 60 秒,防止死循环疯狂报错打满日志
await asyncio.sleep(60)
async def cmd_check(config: Dict) -> int:
"""校验 LLM 接口可达性(部署期使用,运行期不再校验)"""
print("🔍 校验 LLM 接口...")
try:
await check_llm_available(config["llm"])
except Exception as e:
print(f"❌ LLM 接口不可用: {e}")
return 1
print("✅ LLM 接口可用")
return 0
async def cmd_fetch(config: Dict) -> int:
"""单次抓取(systemd timer 调用)"""
try:
await run_fetch_job(config)
return 0
except Exception as e:
print(f"❌ Fetch 任务失败: {e}")
return 1
async def cmd_push(config: Dict) -> int:
"""单次推送(systemd timer 调用)"""
try:
await run_push_job(config)
return 0
except Exception as e:
print(f"❌ Push 任务失败: {e}")
return 1
async def cmd_loop(config: Dict) -> int:
"""长跑模式(本地开发/调试用)"""
print("🔍 检查 LLM 接口可用性...")
try:
await check_llm_available(config["llm"])
print("✅ LLM 接口可用")
except Exception as e:
print(f"❌ LLM 接口不可用: {e}")
return 1
await asyncio.gather(fetch_loop(config), push_loop(config))
return 0
async def cmd_rss(config: Dict) -> int:
"""单独跑一次 RSS digest 板块,打印结果不推送"""
print("📰 RSS Digest 单板块运行")
try:
md, meta, err = await run_rss_section(config, now=now_local(config))
except Exception as e:
print(f"❌ RSS 板块失败: {e}")
return 1
if err:
print(f"{err}")
return 1
if not md:
print("️ 本次无内容")
return 0
print("\n" + "=" * 60)
print("📑 metadata:")
if meta:
import json as _json
print(_json.dumps(meta, ensure_ascii=False, indent=2))
else:
print("(none)")
print("=" * 60)
print(md)
print("=" * 60)
return 0
async def cmd_github(config: Dict) -> int:
"""单独跑一次 GitHub trending 板块,打印结果不推送"""
print("⭐ GitHub Trending 单板块运行")
try:
md, err = await run_github_section(config, now=now_local(config))
except Exception as e:
print(f"❌ GitHub 板块失败: {e}")
return 1
if err:
print(f"{err}")
return 1
if not md:
print("️ 本次无内容")
return 0
print("\n" + "=" * 60)
print(md)
print("=" * 60)
return 0
async def cmd_hackernews(config: Dict) -> int:
"""单独跑一次 Hacker News 板块,打印结果不推送"""
print("🟧 Hacker News 单板块运行")
try:
md, err = await run_hackernews_section(config, now=now_local(config))
except Exception as e:
print(f"❌ Hacker News 板块失败: {e}")
return 1
if err:
print(f"{err}")
return 1
if not md:
print("️ 本次无内容")
return 0
print("\n" + "=" * 60)
print(md)
print("=" * 60)
return 0
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="daily-news",
description="AI 每日资讯推送系统",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("check", help="校验 LLM 接口可达性")
sub.add_parser("fetch", help="单次抓取并退出")
sub.add_parser("push", help="单次推送并退出")
sub.add_parser("loop", help="长跑模式(开发/调试用)")
sub.add_parser("rss", help="单独跑一次 RSS Digest 板块(仅打印,不推送)")
sub.add_parser("github", help="单独跑一次 GitHub Trending 板块(仅打印,不推送)")
sub.add_parser("hackernews", help="单独跑一次 Hacker News 板块(仅打印,不推送)")
return parser.parse_args()
def main() -> int:
print("🚀 AI每日资讯推送系统")
args = _parse_args()
try:
config = load_config()
print("✅ 配置加载成功")
except Exception as e:
print(f"❌ 加载配置失败: {e}")
return 1
handlers = {
"check": cmd_check,
"fetch": cmd_fetch,
"push": cmd_push,
"loop": cmd_loop,
"rss": cmd_rss,
"github": cmd_github,
"hackernews": cmd_hackernews,
}
return asyncio.run(handlers[args.command](config))
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\n👋 程序已退出")
sys.exit(0)
+83
View File
@@ -0,0 +1,83 @@
"""Markdown / YAML frontmatter 公共辅助。
供 storage、llm 等模块复用,避免重复实现。本模块零业务依赖,仅依赖 yaml/json/re。
"""
import json
import re
from typing import Any, Dict, List, Tuple
import yaml
__all__ = [
"yaml_value",
"dump_frontmatter",
"parse_frontmatter",
"normalize_str_list",
]
def yaml_value(v: Any) -> str:
"""把单个值序列化为 YAML 合法的 token,借道 JSON 语法。
依据:JSON 是 YAML 1.2 的真子集,任何 json.dumps 的输出都是合法 YAML 标量/序列/映射。
始终带引号的字符串可以避免 PyYAML 的若干怪癖(折行、未引号字符串歧义、unicode 转义)。
"""
if isinstance(v, (dict, list, str)):
return json.dumps(v, ensure_ascii=False)
if isinstance(v, bool):
return "true" if v else "false"
if v is None:
return ""
return str(v)
def dump_frontmatter(meta: Dict) -> str:
"""把扁平 metadata dict 序列化为 frontmatter 文本(不含包围的 `---`)。
保留插入顺序(title 在前,bookkeeping 字段在后)。仅支持扁平结构 —— 当前所有
metadata 都是扁平的,无需处理嵌套。
"""
return "".join(f"{k}: {yaml_value(v)}\n" for k, v in meta.items())
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
def parse_frontmatter(text: str) -> Tuple[Dict, str]:
"""从 markdown 文本中分离 YAML frontmatter 与正文。
返回 (metadata_dict, body)。无 frontmatter / YAML 解析失败 / 非 dict 时返回
({}, 原文) —— 保证降级路径不丢内容,调用方可凭 metadata_dict 是否为空判定。
"""
match = _FRONTMATTER_RE.match(text.strip())
if not match:
return {}, text
try:
meta = yaml.safe_load(match.group(1)) or {}
except yaml.YAMLError:
print("frontmatter 数据段解析失败")
return {}, text
if not isinstance(meta, dict):
return {}, text
return meta, match.group(2).strip()
def normalize_str_list(value: Any) -> List[str]:
"""将 str/list/None 规整为非空字符串列表。
用于兜底 LLM 输出的列表型字段(如 frontmatter 中的 highlights):单字符串包裹为单元素列表,
null/非列表/非字符串返回空列表,列表中夹杂的空白项过滤掉。不做数量截断。
"""
if not value:
return []
if isinstance(value, str):
items = [value]
elif isinstance(value, list):
items = value
else:
return []
return [str(x).strip() for x in items if str(x).strip()]
+37
View File
@@ -0,0 +1,37 @@
"""内容处理模块 - HTML转Markdown"""
import re
from urllib.parse import urljoin
from markdownify import markdownify as md
def html_to_markdown(html: str, base_url: str = "") -> str:
"""
将HTML转换为Markdown,保留链接和图片
使用markdownify库,并进行后处理优化
"""
# markdownify会自动处理<img>为![](url)<a>为[text](url)
markdown = md(html, heading_style="ATX")
# 处理相对链接
if base_url:
def replace_rel_link(m):
prefix, path, suffix = m.groups()
if path.startswith(("http://", "https://", "data:")):
return m.group(0)
abs_url = urljoin(base_url, path)
return f"{prefix}{abs_url}{suffix}"
markdown = re.sub(r"(!?\[.*?\]\()(.*?)(\))", replace_rel_link, markdown)
# 后处理优化
# 1. 直接匹配移除 xgo.ing 推广链接
markdown = markdown.replace("[⚡ Powered by xgo.ing](https://xgo.ing)", "")
markdown = markdown.replace("[⚡ Powered by xgo.ing](https://xgo.ing/)", "")
# 2. 清理多余空行
markdown = re.sub(r"\n{3,}", "\n\n", markdown)
return markdown.strip()
+42
View File
@@ -0,0 +1,42 @@
"""推送平台模块"""
from typing import Dict, Optional
from .base import PushPlatform
from .discord import DiscordPlatform
from .feishu import FeishuPlatform
from .custom import CustomPlatform
def create_platform(name: str, config: Dict) -> Optional[PushPlatform]:
"""工厂函数,创建推送平台实例"""
platforms = {
"discord": DiscordPlatform,
"feishu": FeishuPlatform,
"custom": CustomPlatform,
}
if name not in platforms:
raise ValueError(f"未知推送平台: {name}")
platform_class = platforms[name]
platform = platform_class(config)
if not platform.validate_config(config):
return None
return platform
async def send_to_platforms(content: str, push_config: Dict, title: str = None, metadata: Optional[Dict] = None):
"""发送内容到所有已启用且配置有效的平台"""
for platform_name, platform_conf in push_config.items():
platform = create_platform(platform_name, platform_conf)
if platform is None:
continue
try:
await platform.send(content, title, metadata)
print(f"✅ 已推送到 {platform_name}")
except Exception as e:
print(f"❌ 推送到 {platform_name} 失败: {e}")
+26
View File
@@ -0,0 +1,26 @@
"""推送平台基类"""
from abc import ABC, abstractmethod
from typing import Dict
class PushPlatform(ABC):
"""推送平台抽象基类"""
def __init__(self, config: Dict):
self.config = config
@abstractmethod
def validate_config(self, config: Dict) -> bool:
"""验证配置是否有效"""
pass
@abstractmethod
async def send(self, content: str, title: str = None, metadata: Dict = None):
"""发送内容
Args:
content: 正文内容
title: 标题(可选,兼容旧接口)
metadata: 元信息(可选,新增参数)
"""
pass
+54
View File
@@ -0,0 +1,54 @@
"""自定义 API 推送平台"""
import os
from typing import Dict, Optional
import aiohttp
from .base import PushPlatform
class CustomPlatform(PushPlatform):
"""自定义 API 推送平台"""
def validate_config(self, config: Dict) -> bool:
"""验证配置"""
if not config.get("enabled", False):
return False
api_key_name = config.get("apiKeyName")
token_key_name = config.get("tokenKeyName")
if not api_key_name or not token_key_name:
print("❌ Custom 平台配置缺少 apiKeyName 或 tokenKeyName")
return False
url = os.getenv(api_key_name)
token = os.getenv(token_key_name)
if not url or not token:
print(f"❌ 环境变量 {api_key_name}{token_key_name} 未设置")
return False
return True
async def send(self, content: str, title: str = None, metadata: Optional[Dict] = None):
"""发送到自定义 API"""
api_key_name = self.config.get("apiKeyName")
token_key_name = self.config.get("tokenKeyName")
url = os.getenv(api_key_name)
token = os.getenv(token_key_name)
payload = {
"content": content,
"metadata": metadata
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, headers=headers) as resp:
if resp.status != 200:
error_text = await resp.text()
raise Exception(f"API 返回错误 {resp.status}: {error_text}")
+60
View File
@@ -0,0 +1,60 @@
"""Discord推送平台"""
import os
from typing import Dict
import aiohttp
from .base import PushPlatform
class DiscordPlatform(PushPlatform):
"""Discord Webhook推送"""
def __init__(self, config: Dict):
super().__init__(config)
self.api_key_name = config.get("apiKeyName", "DISCORD_WEBHOOK_URL")
self.webhook_url = os.environ.get(self.api_key_name, "")
def validate_config(self, config: Dict) -> bool:
"""检查Discord配置是否有效"""
if not config.get("enabled", False):
return False
api_key_name = config.get("apiKeyName", "DISCORD_WEBHOOK_URL")
webhook = os.environ.get(api_key_name, "")
return bool(webhook and webhook.startswith("https://discord.com/api/webhooks/"))
async def send(self, content: str, title: str = None, metadata: Dict = None):
"""发送到Discord(忽略 metadata"""
full_content = f"# {title}\n\n{content}" if title else content
chunks = self._split_content(full_content, limit=2000)
async with aiohttp.ClientSession() as session:
for chunk in chunks:
payload = {"content": chunk}
async with session.post(self.webhook_url, json=payload) as resp:
if resp.status != 204:
text = await resp.text()
raise RuntimeError(f"Discord推送失败: {resp.status} - {text}")
def _split_content(self, content: str, limit: int = 2000) -> list:
"""Discord限制2000字符,需要分割"""
if len(content) <= limit:
return [content]
chunks = []
lines = content.split("\n")
current = ""
for line in lines:
if len(current) + len(line) + 1 > limit:
if current:
chunks.append(current)
current = line
else:
current += "\n" + line if current else line
if current:
chunks.append(current)
return chunks
+92
View File
@@ -0,0 +1,92 @@
"""飞书推送平台"""
import os
from typing import Dict
import aiohttp
from .base import PushPlatform
class FeishuPlatform(PushPlatform):
"""飞书 Webhook 推送"""
def __init__(self, config: Dict):
super().__init__(config)
self.api_key_name = config.get("apiKeyName", "FEISHU_WEBHOOK_URL")
self.webhook_url = os.environ.get(self.api_key_name, "")
def validate_config(self, config: Dict) -> bool:
"""检查飞书配置是否有效"""
if not config.get("enabled", False):
return False
api_key_name = config.get("apiKeyName", "FEISHU_WEBHOOK_URL")
webhook = os.environ.get(api_key_name, "")
return bool(webhook)
async def send(self, content: str, title: str = None, metadata: Dict = None):
"""发送到飞书(忽略 metadata"""
chunks = self._split_content(content, limit=8000)
async with aiohttp.ClientSession() as session:
for chunk in chunks:
payload = self._build_payload(chunk, title)
async with session.post(self.webhook_url, json=payload) as resp:
if resp.status != 200:
text = await resp.text()
raise RuntimeError(f"飞书推送失败: {resp.status} - {text}")
data = await resp.json()
if data.get("code") != 0:
raise RuntimeError(f"飞书推送失败: {data.get('msg')}")
def _build_payload(self, content: str, title: str = None) -> Dict:
"""
构建飞书卡片消息 payload,支持 Markdown
参考 https://open.feishu.cn/document/feishu-cards/card-json-v2-structure
"""
header = {}
if title:
header = {
"title": {"content": title, "tag": "plain_text"},
"template": "blue",
}
return {
"msg_type": "interactive",
"card": {
"schema": "2.0", # 【重点1】显式声明使用 V2 版本结构
"header": header,
"body": { # 【重点2】V2 中,所有的内容元素都必须放在 body 里面
"elements": [
{
"tag": "markdown",
"content": content,
"text_align": "left", # 可选:left / center / right
},
],
},
},
}
def _split_content(self, content: str, limit: int = 8000) -> list:
"""飞书卡片消息 markdown 元素限制 8000 字符"""
if len(content) <= limit:
return [content]
chunks = []
lines = content.split("\n")
current = ""
for line in lines:
if len(current) + len(line) + 1 > limit:
if current:
chunks.append(current)
current = line
else:
current += "\n" + line if current else line
if current:
chunks.append(current)
return chunks
+1
View File
@@ -0,0 +1 @@
"""板块模块包。每个子模块导出 run_<board>_section(config, now) -> (markdown, error)"""
@@ -0,0 +1,111 @@
"""GitHub REST API enrich:metadata + README → enriched repo dict
匿名调用受 60 req/hr 限,设置 GITHUB_TOKEN 环境变量后走 5000 req/hr。
"""
import asyncio
import base64
import os
from typing import Dict, List, Optional, Tuple
import aiohttp
API_BASE = "https://api.github.com"
def _auth_headers(token_env: str = "GITHUB_TOKEN") -> Dict[str, str]:
headers = {"Accept": "application/vnd.github+json"}
token = os.environ.get(token_env)
if token:
headers["Authorization"] = f"Bearer {token}"
print(f"🔑 GH: 已配置 {token_env},鉴权调用 (5000 req/hr)")
else:
print(f"⚠️ GH: 未配置 {token_env},匿名调用 (60 req/hr)")
return headers
async def _get_json(
session: aiohttp.ClientSession, url: str, timeout: int = 10
) -> Optional[Dict]:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status == 404:
return None
if resp.status != 200:
raise RuntimeError(f"GitHub API {resp.status} for {url}")
return await resp.json()
async def enrich_repo(
session: aiohttp.ClientSession,
repo: Dict,
token_env: str = "GITHUB_TOKEN",
readme_max_chars: int = 3000,
timeout: int = 10,
) -> Optional[Dict]:
"""单 repo 双调用 enrich。返回 None 表示该 repo 应剔除(archived 或 metadata 不可达)。
任一调用失败 raise → 调用方按 return_exceptions 模式聚合错误。
"""
full_name = repo["full_name"]
meta_url = f"{API_BASE}/repos/{full_name}"
readme_url = f"{API_BASE}/repos/{full_name}/readme"
meta, readme = await asyncio.gather(
_get_json(session, meta_url, timeout=timeout),
_get_json(session, readme_url, timeout=timeout),
)
if meta is None:
return None
if meta.get("archived"):
return None
license_spdx = ""
if isinstance(meta.get("license"), dict):
license_spdx = meta["license"].get("spdx_id") or ""
readme_excerpt = ""
if readme and readme.get("content"):
try:
raw = base64.b64decode(readme["content"]).decode("utf-8", errors="replace")
readme_excerpt = raw[:readme_max_chars]
except Exception:
readme_excerpt = ""
return {
**repo,
"topics": meta.get("topics") or [],
"license": license_spdx,
"pushed_at": meta.get("pushed_at") or "",
"readme_excerpt": readme_excerpt,
}
async def enrich_repos(
candidates: List[Dict],
token_env: str = "GITHUB_TOKEN",
readme_max_chars: int = 3000,
timeout: int = 10,
) -> Tuple[List[Dict], List[str]]:
"""并发 enrich 多个 repo。返回 (enriched_list_with_archived_filtered, errors)"""
errors: List[str] = []
headers = _auth_headers(token_env)
async with aiohttp.ClientSession(headers=headers) as session:
results = await asyncio.gather(
*[
enrich_repo(session, r, token_env, readme_max_chars, timeout)
for r in candidates
],
return_exceptions=True,
)
enriched: List[Dict] = []
for r, candidate in zip(results, candidates):
if isinstance(r, Exception):
errors.append(f"enrich {candidate['full_name']} 失败: {r}")
elif r is not None:
enriched.append(r)
return enriched, errors
@@ -0,0 +1,104 @@
"""GitHub Trending 板块入口。
流程:trending 抓取 → history 过滤 → 候选写回 history → deep-dive → LLM 总结
"""
from datetime import datetime
from typing import Dict, Optional, Tuple
from src.config import get_timezone
from src.sections.github.repo_enricher import enrich_repos
from src.sections.github.trending_scraper import (
fetch_trending_page,
parse_trending_html,
)
from src.storage import load_trending_history
async def run_github_section(
config: Dict, now: Optional[datetime] = None
) -> Tuple[str, Optional[str]]:
cfg = config.get("sections", {}).get("github_trending", {})
if not cfg.get("enabled", False):
return "", None
# 延迟 import,Task 11 才提供 summarize_github_trending
from src.llm import summarize_github_trending
today = (now or datetime.now(get_timezone())).date()
keep_days = config["filter"]["keep_days"]
timeout = cfg.get("request_timeout", 10)
max_deep_dive = cfg.get("max_deep_dive", 10)
readme_max_chars = cfg.get("readme_max_chars", 3000)
history_path = cfg.get("history_file", "news-data/trending-history.json")
token_env = cfg.get("tokenName", "GITHUB_TOKEN")
# 1. 抓取
print("📥 GH: 抓取 trending 页...")
try:
html = await fetch_trending_page(timeout=timeout)
except Exception as e:
return "", f"GH 抓取失败: {e}"
all_repos = parse_trending_html(html)
print(f"📋 GH: 解析 {len(all_repos)} 个 repo")
if not all_repos:
return "", None
# 2. history 加载 + 清理
history = load_trending_history(history_path)
before_cleanup = len(history.repos)
history.cleanup(today=today, keep_days=keep_days)
after_cleanup = len(history.repos)
if before_cleanup != after_cleanup:
print(
f"🧹 GH: history 清理过期 {before_cleanup - after_cleanup} 条 (剩 {after_cleanup})"
)
# 3. 候选筛选(按 spec §4.3 语义)
candidates = []
already_seen = 0
for repo in all_repos:
if repo["url"] in history:
history.touch(repo["url"], today)
already_seen += 1
else:
candidates.append(repo)
print(f"🔍 GH: history 过滤掉 {already_seen} 条已见,新候选 {len(candidates)}")
if not candidates:
print("️ GH: 无新候选,跳过")
return "", None
if len(candidates) > max_deep_dive:
print(f"✂️ GH: 候选 {len(candidates)} 超 max_deep_dive={max_deep_dive},截断")
candidates = candidates[:max_deep_dive]
# 4. 候选写回 history + 持久化
for repo in candidates:
history.touch(repo["url"], today)
history.save()
# 5. 并发 enrich
print(f"🌐 GH: 并发 enrich {len(candidates)} 个 repo (REST API)...")
enriched, enrich_errors = await enrich_repos(
candidates,
token_env=token_env,
readme_max_chars=readme_max_chars,
timeout=timeout,
)
for e in enrich_errors:
print(f"⚠️ GH enrich: {e}")
print(
f"✅ GH: enrich 成功 {len(enriched)} / 失败 {len(enrich_errors)} / "
f"输入 {len(candidates)}"
)
if not enriched:
return "", None
# 6. LLM 总结
print(f"🤖 GH: summarize {len(enriched)} 个候选...")
md, err = await summarize_github_trending(enriched, config["llm"])
if err:
return "", f"summarize_github_trending: {err}"
print(f"✅ GH: 板块输出 {len(md or '')} chars")
return md or "", None
@@ -0,0 +1,98 @@
"""GitHub Trending 单页 HTML 抓取与解析。
数据源: https://github.com/trending (无 language / since 过滤)
"""
import re
from typing import Dict, List
import aiohttp
from bs4 import BeautifulSoup
TRENDING_URL = "https://github.com/trending"
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
_NUM_RE = re.compile(r"[\d,]+")
def _parse_int(s: str) -> int:
m = _NUM_RE.search(s or "")
if not m:
return 0
return int(m.group(0).replace(",", ""))
async def fetch_trending_page(timeout: int = 10) -> str:
"""抓取 trending 页 HTML;非 200 抛 RuntimeError"""
async with aiohttp.ClientSession(
headers={"User-Agent": USER_AGENT}
) as session:
async with session.get(
TRENDING_URL, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status != 200:
text = await resp.text()
raise RuntimeError(
f"GitHub trending 返回 {resp.status}: {text[:200]}"
)
return await resp.text()
def parse_trending_html(html: str) -> List[Dict]:
"""解析 trending HTML,返回去重后的 repo 字典数组。"""
if not html:
return []
soup = BeautifulSoup(html, "html.parser")
seen_urls = set()
repos: List[Dict] = []
for article in soup.select("article.Box-row"):
h2 = article.find("h2")
a = h2.find("a") if h2 else None
if not a or not a.get("href"):
continue
href = a["href"].strip()
full_name = href.lstrip("/")
url = f"https://github.com/{full_name}"
if url in seen_urls:
continue
seen_urls.add(url)
# description
desc_tag = article.find("p")
description = (desc_tag.get_text(strip=True) if desc_tag else "") or ""
# language
lang_tag = article.find("span", attrs={"itemprop": "programmingLanguage"})
language = (lang_tag.get_text(strip=True) if lang_tag else "") or ""
# stars_total: 第一个指向 /stargazers 的链接
stars_total = 0
star_a = article.find("a", href=re.compile(r"/stargazers$"))
if star_a:
stars_total = _parse_int(star_a.get_text(strip=True))
# stars_today: 末尾的 "N stars today" span
stars_today = 0
for span in article.find_all("span"):
t = span.get_text(strip=True)
if "stars today" in t or "stars this week" in t or "stars this month" in t:
stars_today = _parse_int(t)
break
repos.append(
{
"url": url,
"full_name": full_name,
"description": description,
"language": language,
"stars_today": stars_today,
"stars_total": stars_total,
}
)
return repos
@@ -0,0 +1,91 @@
"""HN 首页 HTML 抓取与解析。
数据源: https://news.ycombinator.com/news (30 条)
"""
import re
from typing import Dict, List
import aiohttp
from bs4 import BeautifulSoup
FRONTPAGE_URL = "https://news.ycombinator.com/news"
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
_NUM_RE = re.compile(r"\d+")
def _first_int(text: str) -> int:
m = _NUM_RE.search(text or "")
return int(m.group(0)) if m else 0
async def fetch_frontpage(timeout: int = 10) -> str:
async with aiohttp.ClientSession(headers={"User-Agent": USER_AGENT}) as session:
async with session.get(
FRONTPAGE_URL, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status != 200:
raise RuntimeError(f"HN frontpage 返回 {resp.status}")
return await resp.text()
def parse_frontpage_html(html: str) -> List[Dict]:
"""解析首页 HTML,返回 [{id, title, url, site, points, comments, comments_url}]
注:HN frontpage 的 athing 行的实际 class 是 'athing submission' (多类),
用 CSS 选择器 'tr.athing' 仍然匹配。
"""
if not html:
return []
soup = BeautifulSoup(html, "html.parser")
stories: List[Dict] = []
for athing in soup.select("tr.athing"):
item_id = athing.get("id")
if not item_id:
continue
title_a = athing.select_one("span.titleline > a")
if not title_a:
continue
title = title_a.get_text(strip=True)
href = title_a.get("href", "")
if href.startswith("item?id="):
url = f"https://news.ycombinator.com/{href}"
site = ""
else:
url = href
site_tag = athing.select_one("span.sitestr")
site = site_tag.get_text(strip=True) if site_tag else ""
sub_tr = athing.find_next_sibling("tr")
points = 0
comments = 0
comments_url = f"https://news.ycombinator.com/item?id={item_id}"
if sub_tr:
score = sub_tr.select_one("span.score")
if score:
points = _first_int(score.get_text(strip=True))
comment_a = None
for a in sub_tr.find_all("a", href=re.compile(r"^item\?id=")):
comment_a = a
if comment_a:
comments = _first_int(comment_a.get_text(strip=True))
comments_url = f"https://news.ycombinator.com/{comment_a['href']}"
stories.append(
{
"id": item_id,
"title": title,
"url": url,
"site": site,
"points": points,
"comments": comments,
"comments_url": comments_url,
}
)
return stories
@@ -0,0 +1,237 @@
"""HN 单 story enrich:Algolia 评论树 + 外链正文。
Algolia API: GET /api/v1/items/{id}
- root.text 是 Show HN / Ask HN 的 post 正文
- root.children[] 是顶层评论(按 HN ranking 排序)
- 每条 child 自己还有 children[],承载嵌套回复
enrich 策略:取 L1 + 每个 L1 下前 N 条 L2 回复,合并为 tree JSON:
[{"l1": "顶层评论", "replies": ["回复 1", "回复 2"]}, ...]
外链正文优先走 Jina Reader (https://r.jina.ai/<url>, 返回 markdown),
失败回退到直接 GET + html_to_markdown。Jina API key 可选(配置后免费额度更高),
环境变量名通过 `sections.hackernews.jinaTokenName` 配置(默认 JINA_API_KEY)。
"""
import asyncio
import os
from typing import Dict, List, Optional, Tuple
import aiohttp
from src.processor import html_to_markdown
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
JINA_READER_BASE = "https://r.jina.ai"
def _is_internal_hn_url(url: str) -> bool:
return url.startswith("https://news.ycombinator.com/item?id=")
async def _fetch_algolia_item(
session: aiohttp.ClientSession, item_id: str, algolia_base: str, timeout: int
) -> Dict:
url = f"{algolia_base}/items/{item_id}"
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status != 200:
raise RuntimeError(f"Algolia /items/{item_id} 返回 {resp.status}")
return await resp.json()
async def _fetch_url_html(
session: aiohttp.ClientSession, url: str, timeout: int
) -> str:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status != 200:
raise RuntimeError(f"外链 {url} 返回 {resp.status}")
return await resp.text()
async def _fetch_via_jina(
session: aiohttp.ClientSession,
url: str,
timeout: int,
jina_token_env: str = "JINA_API_KEY",
) -> str:
"""通过 Jina Reader 拉取外链 markdown。`jina_token_env` 指定 API key 环境变量名,配置后免费额度更高。"""
jina_url = f"{JINA_READER_BASE}/{url}"
headers = {"Accept": "text/markdown"}
api_key = os.environ.get(jina_token_env) if jina_token_env else None
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
async with session.get(
jina_url, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status != 200:
raise RuntimeError(f"Jina Reader {url} 返回 {resp.status}")
return await resp.text()
async def _fetch_external_markdown(
session: aiohttp.ClientSession,
url: str,
timeout: int,
jina_token_env: str = "JINA_API_KEY",
) -> str:
"""获取外链正文 markdown:先走 Jina Reader,失败回退到直接 GET + html_to_markdown。"""
try:
return await _fetch_via_jina(session, url, timeout, jina_token_env=jina_token_env)
except Exception:
html = await _fetch_url_html(session, url, timeout)
return html_to_markdown(html, base_url=url)
def _collect_comments_tree(
root: Dict,
top_comments: int,
top_l2_per_l1: int,
comment_max_chars: int,
comments_total_chars: int,
) -> List[Dict]:
"""从 Algolia 根节点提取评论树,返回 [{l1, replies}]。
规则:
- L1 上限 `top_comments`,每个 L1 下取前 `top_l2_per_l1` 条 L2 作为 replies
- 每条 text 过 html_to_markdown,单条截断到 `comment_max_chars`
- 累计字符达 `comments_total_chars` 时立即停止(防离群 story 撑爆 prompt)
- 跳过空 text;空 replies 仍保留 `replies: []`,schema 一致
"""
out: List[Dict] = []
consumed = 0
l1_children = (root.get("children") or [])[:top_comments]
for l1 in l1_children:
if consumed >= comments_total_chars:
break
l1_raw = (l1 or {}).get("text") or ""
if not l1_raw:
continue
l1_md = html_to_markdown(l1_raw)[:comment_max_chars]
consumed += len(l1_md)
replies: List[str] = []
for l2 in ((l1 or {}).get("children") or [])[:top_l2_per_l1]:
if consumed >= comments_total_chars:
break
l2_raw = (l2 or {}).get("text") or ""
if not l2_raw:
continue
l2_md = html_to_markdown(l2_raw)[:comment_max_chars]
consumed += len(l2_md)
replies.append(l2_md)
out.append({"l1": l1_md, "replies": replies})
return out
async def enrich_story(
session: aiohttp.ClientSession,
story: Dict,
top_comments: int,
top_l2_per_l1: int,
comment_max_chars: int,
comments_total_chars: int,
link_content_max_chars: int,
algolia_base: str,
timeout: int,
jina_token_env: str = "JINA_API_KEY",
) -> Dict:
"""对单 story enrich。任一子任务失败 → 对应字段留空,不抛。"""
item_id = story["id"]
is_internal = _is_internal_hn_url(story["url"])
tasks = [
_fetch_algolia_item(
session, item_id, algolia_base=algolia_base, timeout=timeout
)
]
if not is_internal:
tasks.append(
_fetch_external_markdown(
session, story["url"], timeout=timeout, jina_token_env=jina_token_env
)
)
results = await asyncio.gather(*tasks, return_exceptions=True)
algolia_result = results[0]
external_markdown_result = results[1] if not is_internal else None
comments_tree: List[Dict] = []
post_text = ""
if not isinstance(algolia_result, Exception) and algolia_result:
post_text = algolia_result.get("text") or ""
comments_tree = _collect_comments_tree(
algolia_result,
top_comments=top_comments,
top_l2_per_l1=top_l2_per_l1,
comment_max_chars=comment_max_chars,
comments_total_chars=comments_total_chars,
)
link_content = ""
if is_internal:
if post_text:
link_content = html_to_markdown(post_text)[:link_content_max_chars]
else:
if (
not isinstance(external_markdown_result, Exception)
and external_markdown_result
):
link_content = external_markdown_result[:link_content_max_chars]
return {
**story,
"link_content": link_content,
"top_comments": comments_tree,
}
async def enrich_stories(
stories: List[Dict],
top_comments: int,
top_l2_per_l1: int,
comment_max_chars: int,
comments_total_chars: int,
link_content_max_chars: int,
algolia_base: str = "https://hn.algolia.com/api/v1",
timeout: int = 10,
jina_token_env: str = "JINA_API_KEY",
) -> Tuple[List[Dict], List[str]]:
"""并发 enrich 多个 stories。"""
errors: List[str] = []
if os.environ.get(jina_token_env):
print(f"🔑 HN: 已配置 {jina_token_env},Jina Reader 鉴权调用")
else:
print(f"⚠️ HN: 未配置 {jina_token_env},Jina Reader 匿名调用 (额度受限)")
async with aiohttp.ClientSession(headers={"User-Agent": USER_AGENT}) as session:
results = await asyncio.gather(
*[
enrich_story(
session,
s,
top_comments=top_comments,
top_l2_per_l1=top_l2_per_l1,
comment_max_chars=comment_max_chars,
comments_total_chars=comments_total_chars,
link_content_max_chars=link_content_max_chars,
algolia_base=algolia_base,
timeout=timeout,
jina_token_env=jina_token_env,
)
for s in stories
],
return_exceptions=True,
)
enriched: List[Dict] = []
for r, src in zip(results, stories):
if isinstance(r, Exception):
errors.append(f"enrich story {src['id']} 失败: {r}")
else:
enriched.append(r)
return enriched, errors
@@ -0,0 +1,90 @@
"""HN 板块入口。流程:首页 → 轻 LLM 选 K → enrich → 最终 LLM 行文"""
from datetime import datetime
from typing import Dict, Optional, Tuple
from src.sections.hackernews.frontpage_scraper import (
fetch_frontpage,
parse_frontpage_html,
)
from src.sections.hackernews.item_enricher import enrich_stories
async def run_hackernews_section(
config: Dict, now: Optional[datetime] = None
) -> Tuple[str, Optional[str]]:
cfg = config.get("sections", {}).get("hackernews", {})
if not cfg.get("enabled", False):
return "", None
# 延迟 import (Task 16 提供这两个函数)
from src.llm import select_ai_related_hn, summarize_hackernews
timeout = cfg.get("request_timeout", 10)
select_k = cfg.get("select_k", 1)
top_comments = cfg.get("top_comments", 30)
top_l2_per_l1 = cfg.get("top_l2_per_l1", 3)
comment_max_chars = cfg.get("comment_max_chars", 2000)
comments_total_chars = cfg.get("comments_total_chars", 60000)
link_content_max_chars = cfg.get("link_content_max_chars", 50000)
algolia_base = cfg.get("algolia_base", "https://hn.algolia.com/api/v1")
jina_token_env = cfg.get("jinaTokenName", "JINA_API_KEY")
# 1. 抓首页
print("📥 HN: 抓取首页...")
try:
html = await fetch_frontpage(timeout=timeout)
except Exception as e:
return "", f"HN 首页抓取失败: {e}"
front = parse_frontpage_html(html)
print(f"📋 HN: 解析 {len(front)} 条 frontpage stories")
if not front:
return "", None
# 2. 轻 LLM 初筛
print(f"🤖 HN: 轻 LLM 初筛 (k={select_k})...")
selected_ids, select_err = await select_ai_related_hn(front, k=select_k, config=config["llm"])
if select_err:
return "", f"select_ai_related_hn: {select_err}"
if not selected_ids:
print("ℹ️ HN: 初筛未挑出 AI 相关内容,跳过")
return "", None
selected = [s for s in front if s["id"] in set(selected_ids)]
if not selected:
return "", None
print(
f"🎯 HN: 初筛选出 {len(selected)} 个: "
f"{', '.join(s['id'] for s in selected)}"
)
# 3. enrich
print(f"🌐 HN: enrich {len(selected)} 个 story (Algolia + 外链)...")
enriched, enrich_errors = await enrich_stories(
selected,
top_comments=top_comments,
top_l2_per_l1=top_l2_per_l1,
comment_max_chars=comment_max_chars,
comments_total_chars=comments_total_chars,
link_content_max_chars=link_content_max_chars,
algolia_base=algolia_base,
timeout=timeout,
jina_token_env=jina_token_env,
)
for e in enrich_errors:
print(f"⚠️ HN enrich: {e}")
print(
f"✅ HN: enrich 成功 {len(enriched)} / 失败 {len(enrich_errors)} / "
f"输入 {len(selected)}"
)
if not enriched:
return "", None
# 4. LLM 总结
print(f"🤖 HN: summarize {len(enriched)} 个 enriched story...")
md, err = await summarize_hackernews(enriched, config["llm"])
if err:
return "", f"summarize_hackernews: {err}"
print(f"✅ HN: 板块输出 {len(md or '')} chars")
return md or "", None
@@ -0,0 +1,37 @@
"""Insights 板块:基于 RSS/GH/HN 三段成品做跨板块小结"""
from datetime import datetime
from typing import Dict, Optional, Tuple
EMPTY_MARKER = "(本次无内容)"
async def run_insights_section(
rss_md: str,
gh_md: str,
hn_md: str,
config: Dict,
now: Optional[datetime] = None,
) -> Tuple[str, Optional[Dict], Optional[str]]:
cfg = config.get("sections", {}).get("insights", {})
if not cfg.get("enabled", False):
return "", None, None
from src.llm import generate_trend_insights, parse_insights_with_metadata
sections = {
"rss": rss_md or EMPTY_MARKER,
"github": gh_md or EMPTY_MARKER,
"hackernews": hn_md or EMPTY_MARKER,
}
md, err = await generate_trend_insights(sections, config["llm"])
if err:
return "", None, f"generate_trend_insights: {err}"
if now is None:
now = datetime.now()
date_str = now.strftime("%Y-%m-%d")
insights_md, metadata = parse_insights_with_metadata(md or "", date_str)
return insights_md, metadata, None
@@ -0,0 +1,3 @@
from src.sections.rss.section import run_rss_section
__all__ = ["run_rss_section"]
+62
View File
@@ -0,0 +1,62 @@
"""RSS 板块:沿用现有 collect_entries_for_push + compose_digest 流程
迁移自 src/main.py::run_push_job 中 RSS digest 部分,行为保持一致。
"""
from datetime import datetime
from typing import Dict, Optional, Tuple
from src.llm import compose_digest, parse_digest_with_metadata
from src.storage import (
extract_push_time,
get_last_push_file,
load_recent_push_content,
)
async def run_rss_section(
config: Dict, now: Optional[datetime] = None
) -> Tuple[str, Optional[Dict], Optional[str]]:
"""生成 RSS digest markdown 段(不含 sentinel)。
返回:
(markdown_body, metadata, error)
- 无新内容时返回 ("", None, None)
- compose_digest 失败时返回 ("", None, error_message)
- metadata 字段:title / lead / highlights / profile=default / date
早报场景下调用方可丢弃 metadata(由 insights 段覆盖)
"""
# 延迟 import 避免循环:Task 20-21 后 main.py 会反向 import run_rss_section
from src.main import collect_entries_for_push
last_push_file = get_last_push_file()
last_push_time = extract_push_time(last_push_file) if last_push_file else None
min_score = config["filter"]["min_score"]
context_days = config["filter"]["context_days"]
to_push, context = collect_entries_for_push(
last_push_time=last_push_time,
context_days=context_days,
min_score=min_score,
)
if not to_push:
print("️ RSS: 无新消息")
return "", None, None
push_context_days = config["filter"].get("push_context_days", 5)
recent = load_recent_push_content(push_context_days)
try:
raw = await compose_digest(
to_push, context, config["llm"], recent_push_context=recent
)
except Exception as e:
msg = f"compose_digest 失败: {e}"
print(f"⚠️ RSS: {msg}")
return "", None, msg
date_str = (now or datetime.now()).strftime("%Y-%m-%d")
body, metadata = parse_digest_with_metadata(raw or "", date_str)
return body, metadata, None
+546
View File
@@ -0,0 +1,546 @@
"""数据存储模块 - JSON文件读写"""
import json
import re
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional
from src.config import get_timezone
from src.markdown_utils import dump_frontmatter, parse_frontmatter
def get_fetch_file(d: date = None, data_dir: str = "news-data") -> str:
"""获取fetch文件路径 (使用配置时区)"""
if d is None:
d = datetime.now(get_timezone()).date()
return f"{data_dir}/fetch-{d.isoformat()}.json"
def get_push_file(push_time: datetime = None, data_dir: str = "news-data") -> str:
"""生成push文件路径"""
if push_time is None:
push_time = datetime.now(get_timezone())
time_str = push_time.strftime("%Y-%m-%d-%H-%M-%S")
return f"{data_dir}/push-{time_str}.md"
def get_notify_file(d: date = None, data_dir: str = "news-data") -> str:
"""获取notify文件路径 (使用配置时区)"""
if d is None:
d = datetime.now(get_timezone()).date()
return f"{data_dir}/notify-{d.isoformat()}.md"
def save_notify_file(
filepath: str,
content: str,
metadata: Dict = None,
):
"""保存即时推送文件(Markdown格式),同一天的内容追加到同一文件"""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
notify_time = datetime.now(get_timezone()).isoformat()
if metadata:
frontmatter_dict = metadata.copy()
else:
frontmatter_dict = {"pushTime": notify_time}
frontmatter = dump_frontmatter(frontmatter_dict)
new_content = f"---\n{frontmatter}---\n\n{content}\n\n------\n"
with open(path, "a", encoding="utf-8") as f:
f.write(new_content)
_SECTION_RE_CACHE: Dict[str, re.Pattern] = {}
def _section_re(section: str) -> re.Pattern:
"""获取/缓存 sentinel 正则。section 名做转义,允许字母数字下划线"""
if section not in _SECTION_RE_CACHE:
s = re.escape(section)
pattern = (
rf"<!--\s*SECTION:{s}\s*BEGIN\s*-->(.*?)<!--\s*SECTION:{s}\s*END\s*-->"
)
_SECTION_RE_CACHE[section] = re.compile(pattern, flags=re.DOTALL)
return _SECTION_RE_CACHE[section]
def extract_section(push_md: str, section: str) -> str:
"""从 push 文件内容中切出 <!-- SECTION:{section} BEGIN/END --> 之间的 markdown。
向后兼容:
- 新文件(带 sentinel): 返回 sentinel 边界内的原文(不去边界空行)
- 老文件(无 sentinel) 且 section == 'rss': 返回整个 push_md
- 老文件(无 sentinel) 且 section != 'rss': 返回空字符串
- sentinel 残缺(只有 BEGIN 没有 END): 返回空字符串
"""
match = _section_re(section).search(push_md)
if match:
return match.group(1)
# 老文件兜底:rss 段视为整个 body
has_any_sentinel = "<!-- SECTION:" in push_md
if section == "rss" and not has_any_sentinel:
return push_md
return ""
def load_recent_notify_content(
context_days: int = 3, data_dir: str = "news-data"
) -> str:
"""加载最近 context_days 天 notify 文件正文(去除 frontmatter,仅供 LLM 查重)
notify 文件由多个推送块用 `------` 分隔,每块带各自 frontmatter;这里逐块剥离
frontmatter 后用 `------` 重新拼接,保留事件全文。
"""
data_path = Path(data_dir)
if not data_path.exists():
return ""
tz = get_timezone()
today = datetime.now(tz).date()
blocks: List[str] = []
loaded_files = []
for i in range(context_days):
d = today - timedelta(days=i)
notify_file = data_path / f"notify-{d.isoformat()}.md"
if not notify_file.exists() or notify_file.stat().st_size == 0:
continue
try:
with open(notify_file, "r", encoding="utf-8") as f:
content = f.read()
except Exception:
continue
for block in content.split("------"):
if not block.strip():
continue
_, body = parse_frontmatter(block)
if body:
blocks.append(body)
loaded_files.append(notify_file.name)
if loaded_files:
print(
f" 📂 已加载 {len(loaded_files)} 个 notify 文件: {', '.join(loaded_files)}"
)
return "\n\n------\n\n".join(blocks)
def load_recent_push_content(
context_days: int = 3, data_dir: str = "news-data", section: str = "rss"
) -> str:
"""加载最近 context_days 天 push 文件中指定 section 的正文(去除 frontmatter,仅供 LLM 查重)。
Args:
section: sentinel 段名,默认 "rss"。老文件(无 sentinel) 且 section == "rss"
时会兜底返回整个 body(由 extract_section 处理),其它 section 在
老文件上返回空。
"""
data_path = Path(data_dir)
if not data_path.exists():
return ""
tz = get_timezone()
today = datetime.now(tz).date()
bodies: List[str] = []
loaded_files = []
for i in range(context_days):
d = today - timedelta(days=i)
pattern = f"push-{d.isoformat()}-*.md"
for push_file in sorted(data_path.glob(pattern)):
if push_file.stat().st_size == 0:
continue
try:
with open(push_file, "r", encoding="utf-8") as f:
content = f.read()
except Exception:
continue
section_md = extract_section(content, section)
if not section_md:
continue
# 老文件兜底路径会把整篇文件还回来,此时仍需剥离 frontmatter;
# 新文件 sentinel 内不含 frontmatter,parse_frontmatter 会原样返回。
_, body = parse_frontmatter(section_md)
body = body or section_md
body = body.strip()
if body:
bodies.append(body)
loaded_files.append(push_file.name)
if loaded_files:
print(
f" 📂 已加载 {len(loaded_files)} 个 push 文件 (section={section}): "
f"{', '.join(loaded_files)}"
)
return "\n\n------\n\n".join(bodies)
def get_last_push_file(data_dir: str = "news-data") -> Optional[str]:
"""从news-data目录找到最新的push文件"""
data_path = Path(data_dir)
if not data_path.exists():
return None
push_files = sorted(data_path.glob("push-*.md"))
return str(push_files[-1]) if push_files else None
def extract_push_time(filepath: str) -> Optional[datetime]:
"""从push文件名提取时间"""
try:
basename = Path(filepath).name
time_str = basename.replace("push-", "").replace(".md", "")
dt = datetime.strptime(time_str, "%Y-%m-%d-%H-%M-%S")
return dt.replace(tzinfo=get_timezone())
except (ValueError, AttributeError):
return None
def read_entries(filepath: str) -> List[Dict]:
"""读取fetch文件,返回entries列表"""
path = Path(filepath)
if not path.exists():
return []
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("entries", [])
def read_fetch_data(filepath: str) -> Dict:
"""读取完整的fetch文件数据(包含meta和entries"""
path = Path(filepath)
if not path.exists():
return {"meta": {}, "entries": []}
# 检查文件是否为空
if path.stat().st_size == 0:
return {"meta": {}, "entries": []}
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_fetch_file(filepath: str, meta: Dict, entries: List[Dict]):
"""保存fetch文件(JSON格式)"""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
data = {"meta": meta, "entries": entries}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def append_entries(filepath: str, new_entries: List[Dict], meta: Dict = None):
"""追加条目到fetch文件"""
path = Path(filepath)
# 读取现有数据
if path.exists():
data = read_fetch_data(filepath)
else:
data = {"meta": meta or {}, "entries": []}
# 更新meta(如果提供了)
if meta:
data["meta"].update(meta)
# 去重:基于link字段
existing_links = {e.get("link") for e in data["entries"]}
for entry in new_entries:
if entry.get("link") not in existing_links:
data["entries"].append(entry)
existing_links.add(entry.get("link"))
# 保存
save_fetch_file(filepath, data["meta"], data["entries"])
return len(new_entries)
def format_entry(entry: Dict) -> str:
"""格式化单条条目为Markdown字符串"""
tags = entry.get("tags", [])
tags_str = json.dumps(tags, ensure_ascii=False) if tags else "[]"
score = entry.get("score", "")
summary = entry.get("summary", "")
return f"""## {entry["title"]}
---
source: {entry["source"]}
link: {entry["link"]}
published: {entry["published"]}
fetched_at: {entry["fetched_at"]}
tags: {tags_str}
score: {score}
summary: {summary}
---
{entry["content"]}
------
"""
def json_to_md(data: Dict) -> str:
"""
将JSON格式的fetch数据转换为Markdown格式,便于阅读
Args:
data: {"meta": {...}, "entries": [...]}
Returns:
Markdown格式的字符串
"""
meta = data.get("meta", {})
entries = data.get("entries", [])
lines = []
# 文件头部YAML frontmatter
if meta.get("date"):
lines.append("---")
lines.append(f'date: "{meta["date"]}"')
lines.append("---")
lines.append("")
# 条目
for entry in entries:
lines.append(format_entry(entry))
return "\n".join(lines)
def convert_fetch_json_to_md(json_filepath: str, md_filepath: str = None) -> str:
"""
将fetch JSON文件转换为Markdown文件
Args:
json_filepath: JSON文件路径
md_filepath: 输出MD文件路径,默认为同名.md
Returns:
生成的Markdown内容
"""
data = read_fetch_data(json_filepath)
md_content = json_to_md(data)
if md_filepath:
path = Path(md_filepath)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(md_content)
return md_content
def save_push_file(
filepath: str,
content: str,
source_count: int,
total_entries: int,
profile: str = "default",
metadata: Dict = None,
):
"""保存推送文件(Markdown格式)
Args:
profile: "morning" | "default" ← 早报或常规;写入 frontmatter,便于按 profile 分析
metadata: 元信息(可选),如果提供则使用 metadata,否则使用默认格式
"""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
if metadata:
# 使用提供的 metadata
frontmatter_dict = metadata.copy()
# 添加推送时间和统计信息
frontmatter_dict["pushDate"] = datetime.now(get_timezone()).isoformat()
frontmatter_dict["sourceCount"] = source_count
frontmatter_dict["totalEntries"] = total_entries
else:
# 降级:使用默认格式
push_time = datetime.now(get_timezone())
frontmatter_dict = {
"profile": profile,
"pushDate": push_time.isoformat(),
"sourceCount": source_count,
"totalEntries": total_entries,
}
frontmatter = dump_frontmatter(frontmatter_dict)
full_content = f"---\n{frontmatter}---\n\n{content}"
with open(path, "w", encoding="utf-8") as f:
f.write(full_content)
def load_existing_links(filepath: str, threshold: int = 150) -> set:
"""加载文件中已有的链接(用于去重)
如果当天时间已超过 threshold 分钟,则只需加载当天文件;
否则需要同时加载当天和昨天的文件(用于处理跨天边界情况)。
Args:
filepath: 当天的 fetch 文件路径
threshold: 阈值(分钟),超过此时间只加载当天文件
"""
tz = get_timezone()
now = datetime.now(tz)
current_minutes = now.hour * 60 + now.minute
need_yesterday = current_minutes < threshold
if not need_yesterday:
if not filepath or not Path(filepath).exists():
return set()
entries = read_entries(filepath)
return {e.get("link") for e in entries if e.get("link")}
all_links = set()
if filepath and Path(filepath).exists():
all_links.update(
{e.get("link") for e in read_entries(filepath) if e.get("link")}
)
yesterday = (now - timedelta(days=1)).date()
yesterday_file = get_fetch_file(yesterday)
if Path(yesterday_file).exists():
all_links.update(
{e.get("link") for e in read_entries(yesterday_file) if e.get("link")}
)
return all_links
def cleanup_old_files(days: int = 7, data_dir: str = "news-data"):
"""清理超过days天的旧文件"""
data_path = Path(data_dir)
if not data_path.exists():
return
cutoff = datetime.now() - timedelta(days=days)
deleted_count = 0
for pattern in ["fetch-*.json", "fetch-*.md", "push-*.md", "notify-*.md"]:
for file in data_path.glob(pattern):
try:
date_str = (
file.name.replace("fetch-", "")
.replace("push-", "")
.replace("notify-", "")
.replace(".json", "")
.replace(".md", "")
)
date_parts = date_str.split("-")
if len(date_parts) >= 3:
file_date = date(
int(date_parts[0]), int(date_parts[1]), int(date_parts[2])
)
if file_date < cutoff.date():
file.unlink()
deleted_count += 1
print(f" 🗑️ 删除旧文件: {file.name}")
except (ValueError, OSError):
continue
# trending-history.json: 剪枝过期条目,保留文件本身
trending_path = data_path / "trending-history.json"
if trending_path.exists() and trending_path.stat().st_size > 0:
try:
history = load_trending_history(str(trending_path))
before = len(history.repos)
history.cleanup(today=datetime.now().date(), keep_days=days)
after = len(history.repos)
if after < before:
history.save()
print(f" ✂️ trending-history 剪枝: {before}{after}")
except Exception as e:
print(f" ⚠️ trending-history 剪枝失败: {e}")
if deleted_count > 0:
print(f" ✅ 清理完成: 删除了 {deleted_count} 个旧文件")
class TrendingHistory:
"""GitHub trending 已查阅 repo 索引。
repos 字段:url → last_seen_date (ISO YYYY-MM-DD)。
每次早报 cleanup 一次,touch 完所有今日 URL 后 save。
"""
def __init__(self, path: str, repos: Dict[str, str]):
self._path = path
self.repos: Dict[str, str] = dict(repos)
def __contains__(self, url: str) -> bool:
return url in self.repos
def touch(self, url: str, today: date) -> None:
self.repos[url] = today.isoformat()
def cleanup(self, today: date, keep_days: int) -> None:
cutoff = today - timedelta(days=keep_days)
self.repos = {
url: d
for url, d in self.repos.items()
if _parse_iso_date_safe(d) is not None and _parse_iso_date_safe(d) >= cutoff
}
def save(self) -> None:
path = Path(self._path)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"repos": self.repos,
"updated_at": datetime.now(get_timezone()).isoformat(),
}
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def _parse_iso_date_safe(s: str) -> Optional[date]:
try:
return date.fromisoformat(s)
except (ValueError, TypeError):
return None
def load_trending_history(path: str) -> TrendingHistory:
"""读取 trending-history.json;不存在返回空实例。"""
p = Path(path)
if not p.exists() or p.stat().st_size == 0:
return TrendingHistory(path, {})
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
return TrendingHistory(path, data.get("repos", {}))
except (json.JSONDecodeError, OSError):
print(f"⚠️ trending-history 读取失败,使用空索引: {path}")
return TrendingHistory(path, {})
_SECTION_ORDER = ("rss", "github", "hackernews", "insights")
def assemble_with_sentinels(sections: Dict[str, str]) -> str:
"""按固定顺序拼装四段 markdown,每段包 sentinel;空段整段省略。"""
parts: List[str] = []
for key in _SECTION_ORDER:
body = (sections.get(key) or "").strip()
if not body:
continue
parts.append(
f"<!-- SECTION:{key} BEGIN -->\n{body}\n<!-- SECTION:{key} END -->"
)
return "\n\n".join(parts)
@@ -0,0 +1,15 @@
[Unit]
Description=Daily News - Fetch Job
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User={{USER}}
Group={{GROUP}}
WorkingDirectory={{PROJECT_DIR}}
EnvironmentFile={{PROJECT_DIR}}/.env
ExecStart={{UV_BIN}} run --project {{PROJECT_DIR}} python -m src.main fetch
LogNamespace=dnews
TimeoutStartSec=20min
Nice=10
@@ -0,0 +1,11 @@
[Unit]
Description=Daily News - Fetch Timer
[Timer]
OnActiveSec=1s
OnUnitActiveSec={{FETCH_INTERVAL_MIN}}min
AccuracySec=1s
Unit=dnews-fetch.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,15 @@
[Unit]
Description=Daily News - Push Job
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User={{USER}}
Group={{GROUP}}
WorkingDirectory={{PROJECT_DIR}}
EnvironmentFile={{PROJECT_DIR}}/.env
ExecStart={{UV_BIN}} run --project {{PROJECT_DIR}} python -m src.main push
LogNamespace=dnews
TimeoutStartSec=10min
Nice=10
@@ -0,0 +1,10 @@
[Unit]
Description=Daily News - Push Timer
[Timer]
{{PUSH_ONCALENDAR_LINES}}
AccuracySec=1s
Unit=dnews-push.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,2 @@
[Journal]
MaxRetentionSec={{LOG_RETENTION_DAYS}}day
View File
+45
View File
@@ -0,0 +1,45 @@
"""手动跑一次 HN 首页 + Algolia enrich,验证选择器与 API"""
import asyncio
import json
import os
import sys
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.sections.hackernews.frontpage_scraper import (
fetch_frontpage,
parse_frontpage_html,
)
from src.sections.hackernews.item_enricher import enrich_stories
async def main():
print("📥 抓取 HN 首页...")
html = await fetch_frontpage(timeout=15)
stories = parse_frontpage_html(html)
print(f"📋 解析出 {len(stories)}")
for s in stories[:5]:
print(f" - [{s['points']} pts · {s['comments']} comments] {s['title']} ({s['site']})")
print("\n🔍 enrich 前 1 个外链类故事...")
target = next((s for s in stories if not s["url"].startswith("https://news.ycombinator.com/")), stories[0])
enriched, errors = await enrich_stories(
[target],
top_comments=5,
top_l2_per_l1=3,
comment_max_chars=2000,
comments_total_chars=60000,
link_content_max_chars=1500,
timeout=15,
)
for e in errors:
print(f" ⚠️ {e}")
print(json.dumps(enriched, ensure_ascii=False, indent=2))
if __name__ == "__main__":
asyncio.run(main())
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""获取新闻脚本 - 模块化测试第一步:获取RSS并存储"""
import argparse
import asyncio
import os
import sys
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlparse
# 添加项目根目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from dotenv import load_dotenv
load_dotenv()
from src.config import get_timezone, load_config, merge_sources
from src.fetcher import fetch_all_feeds
from src.processor import html_to_markdown
from src.storage import append_entries, get_fetch_file, load_existing_links
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description="RSS新闻获取测试")
parser.add_argument(
"--hours", "-H", type=int, default=1, help="获取过去多少小时的新闻 (默认: 1)"
)
parser.add_argument("--minutes", "-m", type=int, help="获取过去多少分钟的新闻")
parser.add_argument(
"--output-dir",
"-o",
type=str,
default="tests/news-data",
help="输出目录 (默认: tests/news-data)",
)
parser.add_argument(
"--max-per-domain",
type=int,
default=30,
help="同一域名最大保留源数量 (默认: 30)",
)
return parser.parse_args()
def get_cutoff_time(args) -> datetime:
"""根据参数计算截止时间 (返回 UTC 时间)"""
now = datetime.now(timezone.utc)
if args.minutes:
return now - timedelta(minutes=args.minutes)
return now - timedelta(hours=args.hours)
def limit_sources_by_domain(sources: list, max_per_domain: int = 30) -> list:
"""限制同一域名的源数量"""
domain_sources = defaultdict(list)
for source in sources:
url = source.get("xmlUrl", "")
try:
domain = urlparse(url).netloc.lower()
# 移除 www 前缀
if domain.startswith("www."):
domain = domain[4:]
except Exception:
domain = "unknown"
domain_sources[domain].append(source)
limited_sources = []
domain_counts = {}
for domain, src_list in domain_sources.items():
kept = src_list[:max_per_domain]
limited_sources.extend(kept)
domain_counts[domain] = {"total": len(src_list), "kept": len(kept)}
return limited_sources, domain_counts
async def fetch_news():
"""主函数:获取RSS新闻并存储"""
args = parse_args()
tz = get_timezone()
print("=" * 60)
print("📰 RSS新闻获取测试 (Step 1)")
print("=" * 60)
# 1. 加载配置
print("\n📋 加载配置...")
config = load_config()
all_sources = merge_sources(config["sources"])
print(f" OPML 解析完成: {len(all_sources)} 个源")
# 2. 域名限制
print(f"\n🔍 域名限制 (每域名最多 {args.max_per_domain} 个)...")
sources, domain_stats = limit_sources_by_domain(all_sources, args.max_per_domain)
# 打印域名统计
total_domains = len(domain_stats)
limited_domains = sum(
1 for d in domain_stats.values() if d["total"] > args.max_per_domain
)
print(f" 域名总数: {total_domains}")
print(f" 受限域名: {limited_domains}")
print(f" 最终保留: {len(sources)} 个源")
# 显示受限域名详情
for domain, stats in sorted(domain_stats.items(), key=lambda x: -x[1]["total"])[:5]:
if stats["total"] > args.max_per_domain:
print(f" - {domain}: {stats['total']}{stats['kept']}")
# 3. 计算时间窗口
cutoff = get_cutoff_time(args)
print(f"\n⏰ 时间窗口")
print(f" UTC: {cutoff.strftime('%Y-%m-%d %H:%M')}")
print(
f" Local: {(datetime.now(tz) - (datetime.now(timezone.utc) - cutoff)).strftime('%Y-%m-%d %H:%M')}"
)
# 4. 获取RSS数据
print(f"\n📡 开始获取...")
max_workers = config.get("fetch", {}).get("max_workers", 10)
timeout = config.get("fetch", {}).get("timeout", 5)
entries = await fetch_all_feeds(
sources, cutoff, max_workers=max_workers, timeout=timeout
)
# 5. 统计结果
print(f"\n📊 获取统计")
print(f" 读取源数: {len(all_sources)}")
print(f" 保留源数: {len(sources)}")
print(f" 获取条目: {len(entries)}")
if not entries:
print("\n⚠️ 没有获取到新消息")
return 0
# 6. 转换HTML到Markdown
print("\n📝 处理内容...")
for entry in entries:
entry["content"] = html_to_markdown(
entry.get("content", ""), entry.get("link", "")
)
# 7. 保存到文件
print(f"\n💾 保存到文件...")
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# 使用今天的日期作为文件名(JSON格式)
today = datetime.now(tz).date()
fetch_file = output_dir / f"fetch-{today.isoformat()}.json"
# 加载已有链接去重
existing_links = (
load_existing_links(str(fetch_file)) if fetch_file.exists() else set()
)
new_entries = [
e for e in entries if e.get("link") and e["link"] not in existing_links
]
# 添加时间戳并格式化
for entry in entries:
entry["fetched_at"] = datetime.now(tz).isoformat()
if isinstance(entry.get("published"), datetime):
entry["published"] = entry["published"].astimezone(tz).isoformat()
# 使用新的 append_entries 批量保存
meta = {"date": today.isoformat()}
append_entries(str(fetch_file), entries, meta)
print(f" 文件: {fetch_file}")
print(f" 保存: {len(entries)}")
print(f" 新增: {len(new_entries)}")
print(f" 重复: {len(entries) - len(new_entries)}")
print("\n" + "=" * 60)
print("✅ Step 1 完成: RSS获取并存储")
print("=" * 60)
return len(entries)
if __name__ == "__main__":
try:
count = asyncio.run(fetch_news())
sys.exit(0 if count > 0 else 1)
except KeyboardInterrupt:
print("\n\n👋 已取消")
sys.exit(130)
except Exception as e:
print(f"\n❌ 错误: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
+46
View File
@@ -0,0 +1,46 @@
"""手动跑一次 xcancel/nitter 抓取,验证白名单 UA + requests 路径
用法:python tests/fetch_nitter.py
"""
import asyncio
import os
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.fetcher import fetch_all_feeds
FEEDS = [
{
"title": "Alibaba_Qwen",
"xmlUrl": "https://rss.xcancel.com/Alibaba_Qwen/rss",
},
{
"title": "trq212",
"xmlUrl": "https://rss.xcancel.com/trq212/rss",
},
]
async def main():
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
print(f"📥 抓取 {len(FEEDS)} 个 nitter 源(cutoff={cutoff.isoformat()}...")
entries = await fetch_all_feeds(FEEDS, cutoff)
print(f"📋 拿到 {len(entries)}")
for e in entries[:10]:
published = e["published"].isoformat() if e["published"] else "?"
print(f" - [{published}] {e['source']} {e['title']}")
print(f" {e['link']}")
print(f" {e['content']}\n\n")
if not entries:
print("⚠️ 一条都没拿到 —— 检查 UA / TLS 路径是否生效")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
+44
View File
@@ -0,0 +1,44 @@
"""手动跑一次 GH trending 抓取 + deep-dive,验证选择器与 API 接入"""
import asyncio
import json
import os
import sys
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.config import load_config
from src.sections.github.repo_enricher import enrich_repos
from src.sections.github.trending_scraper import (
fetch_trending_page,
parse_trending_html,
)
async def main():
config = load_config()
print("📥 抓取 GitHub Trending...")
html = await fetch_trending_page(timeout=15)
repos = parse_trending_html(html)
print(f"📋 解析出 {len(repos)} 个 repo")
for r in repos[:5]:
print(f" - {r['full_name']}{r['stars_today']}/{r['stars_total']} | {r['description'][:80]}")
cfg = config["sections"]["github_trending"]
print(f"\n🔍 enrich 前 {min(3, len(repos))} 个...")
enriched, errors = await enrich_repos(
repos[:3],
token_env=cfg.get("tokenName", "GITHUB_TOKEN"),
readme_max_chars=cfg.get("readme_max_chars", 3000),
timeout=15,
)
for e in errors:
print(f" ⚠️ {e}")
print(json.dumps(enriched, ensure_ascii=False, indent=2))
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""手工触发 LLM 错误告警。
用法:
source ../.venv/bin/activate
python tests/manual_trigger_fetch_loop_llm_error.py --scenario score
python tests/manual_trigger_fetch_loop_llm_error.py --scenario immediate
python tests/manual_trigger_fetch_loop_llm_error.py --scenario digest
python tests/manual_trigger_fetch_loop_llm_error.py --scenario all
场景说明:
- score: 走 fetch_loop -> run_fetch_job -> score_batch,触发评分错误通知
- immediate: 走 run_fetch_job -> generate_immediate_push,触发即时推送生成错误通知
- digest: 走 run_push_job -> compose_digest,触发汇总生成错误通知
- all: 依次触发以上三类真实通知
"""
import argparse
import asyncio
import sys
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config import load_config
from src.main import fetch_loop, run_fetch_job, run_push_job
MOCK_FETCH_ENTRIES = [
{
"title": "Manual Mock News 1",
"link": "https://example.com/mock-1",
"published": "2026-03-08T20:00:00+08:00",
"source": "Manual Mock Source",
"content": "<p>Mock content 1</p>",
"tags": [],
"score": 0,
"summary": "",
},
{
"title": "Manual Mock News 2",
"link": "https://example.com/mock-2",
"published": "2026-03-08T20:05:00+08:00",
"source": "Manual Mock Source",
"content": "<p>Mock content 2</p>",
"tags": [],
"score": 0,
"summary": "",
},
]
MOCK_HOT_SCORED_ENTRIES = [
{
"title": "Manual Hot News",
"link": "https://example.com/hot-1",
"published": "2026-03-08T21:00:00+08:00",
"source": "Manual Mock Source",
"content": "mock content",
"tags": ["AI"],
"score": 95,
"summary": "manual hot summary",
}
]
MOCK_DIGEST_ENTRIES = [
{
"title": "Manual Digest News",
"link": "https://example.com/digest-1",
"published": "2026-03-08T19:00:00+08:00",
"fetched_at": "2026-03-08T21:00:00+08:00",
"source": "Manual Mock Source",
"content": "mock digest content",
"tags": ["AI"],
"score": 88,
"summary": "manual digest summary",
}
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="手工触发 LLM 错误告警")
parser.add_argument(
"--scenario",
choices=["score", "immediate", "digest", "all"],
default="score",
help="要触发的错误场景,默认 score",
)
return parser.parse_args()
async def _trigger_score_error(config):
mock_sources = [
{
"title": "Manual Mock Feed",
"xmlUrl": "https://example.com/rss.xml",
"category": "AI",
}
]
tmp_dir = Path("test/news-data")
tmp_dir.mkdir(parents=True, exist_ok=True)
fetch_file = tmp_dir / "manual-fetch-loop-error.json"
async def fake_fetch_all_feeds(*args, **kwargs):
return MOCK_FETCH_ENTRIES
async def fake_sleep(seconds: float):
if seconds <= 0:
return
raise asyncio.CancelledError()
with (
patch("src.main.merge_sources", return_value=mock_sources),
patch("src.main.fetch_all_feeds", side_effect=fake_fetch_all_feeds),
patch("src.main.load_existing_links", return_value=set()),
patch("src.main.get_fetch_file", return_value=str(fetch_file)),
patch(
"src.llm.call_llm",
side_effect=RuntimeError("manual mock llm scoring error"),
),
patch("src.main.asyncio.sleep", side_effect=fake_sleep),
):
await fetch_loop(config)
async def _trigger_immediate_push_error(config):
tmp_dir = Path("test/news-data")
tmp_dir.mkdir(parents=True, exist_ok=True)
fetch_file = tmp_dir / "manual-immediate-push-error.json"
async def fake_fetch_all_feeds(*args, **kwargs):
return [dict(entry) for entry in MOCK_HOT_SCORED_ENTRIES]
with (
patch(
"src.main.merge_sources",
return_value=[
{
"title": "Manual",
"xmlUrl": "https://example.com/rss.xml",
"category": "AI",
}
],
),
patch("src.main.fetch_all_feeds", side_effect=fake_fetch_all_feeds),
patch("src.main.load_existing_links", return_value=set()),
patch("src.main.get_fetch_file", return_value=str(fetch_file)),
patch(
"src.main.score_batch",
return_value=([dict(entry) for entry in MOCK_HOT_SCORED_ENTRIES], []),
),
patch(
"src.main.generate_immediate_push",
return_value=("", "manual mock immediate push error"),
),
):
await run_fetch_job(config)
async def _trigger_digest_error(config):
with (
patch(
"src.main.collect_entries_for_push",
return_value=([dict(entry) for entry in MOCK_DIGEST_ENTRIES], []),
),
patch("src.main.get_last_push_file", return_value=None),
patch(
"src.main.compose_digest",
side_effect=RuntimeError("manual mock compose digest error"),
),
):
await run_push_job(config)
async def _run_selected_scenarios(scenario: str) -> None:
config = load_config()
if scenario in {"score", "all"}:
print("\n🚨 触发 score_batch 错误通知")
await _trigger_score_error(config)
if scenario in {"immediate", "all"}:
print("\n🚨 触发 generate_immediate_push 错误通知")
await _trigger_immediate_push_error(config)
if scenario in {"digest", "all"}:
print("\n🚨 触发 compose_digest 错误通知")
await _trigger_digest_error(config)
def main() -> int:
args = parse_args()
print("🚨 即将触发真实的 LLM 错误提醒")
print(f" - 场景: {args.scenario}")
print(" - 推送: 使用当前 config.json 和环境变量中的真实渠道")
try:
asyncio.run(_run_selected_scenarios(args.scenario))
except KeyboardInterrupt:
print("\n已取消")
return 130
print("✅ 脚本执行完成;如果推送配置正确,你应该已经收到对应错误提醒。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""预览 LLM 查重上下文 —— 直接打印 load_recent_notify_content / load_recent_push_content
返回的内容,便于调试历史去重时实际喂给 LLM 的素材。
Usage:
# 默认:读取 config.json 的 filter.context_days,打印 notify + push 两段
python tests/preview_recent_context.py
# 指定回溯天数
python tests/preview_recent_context.py --days 5
# 只看一种
python tests/preview_recent_context.py --type notify
python tests/preview_recent_context.py --type push
# 自定义数据目录(例如 tests/news-data 里的样本数据)
python tests/preview_recent_context.py --data-dir tests/news-data
"""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config import load_config
from src.storage import load_recent_notify_content, load_recent_push_content
def _resolve_days(cli_days: int | None) -> int:
if cli_days is not None:
return cli_days
try:
config = load_config()
return int(config.get("filter", {}).get("context_days", 3))
except Exception:
return 3
def _print_section(label: str, content: str) -> None:
sep = "=" * 60
print(f"\n{sep}\n{label}\n{sep}")
if not content:
print("(空)")
return
print(content)
print(
f"\n--- {label} 字符数: {len(content)} | 块数(按 `------` 切分): "
f"{len([b for b in content.split('------') if b.strip()])} ---"
)
def main() -> int:
parser = argparse.ArgumentParser(
description="预览近 N 天 notify / push 文件正文 (剥离 frontmatter 后)"
)
parser.add_argument(
"--days",
type=int,
default=None,
help="回溯天数,默认读取 config.filter.context_days(兜底 3)",
)
parser.add_argument(
"--type",
choices=["notify", "push", "both"],
default="both",
help="预览哪种历史,默认 both",
)
parser.add_argument(
"--data-dir",
default="news-data",
help="数据目录,默认 news-data",
)
parser.add_argument(
"--section",
default="rss",
help="push 文件取哪个 sentinel 段,默认 rss(可选 github / hackernews / insights)",
)
args = parser.parse_args()
days = _resolve_days(args.days)
print(
f"📅 回溯天数: {days} | 📂 数据目录: {args.data_dir} | "
f"类型: {args.type} | push.section: {args.section}"
)
if args.type in ("notify", "both"):
notify_md = load_recent_notify_content(days, args.data_dir)
_print_section("近期即时推送 (notify)", notify_md)
if args.type in ("push", "both"):
push_md = load_recent_push_content(days, args.data_dir, section=args.section)
_print_section(f"近期汇总推送 (push.{args.section})", push_md)
return 0
if __name__ == "__main__":
sys.exit(main())
+140
View File
@@ -0,0 +1,140 @@
"""Probe HN: 抓取首页前 10 条,统计每个 story 评论树的深度/数量/字符数。
用于判断 enrich 策略:是否需要 L2 回复、压缩、过滤短评等。
字符数说明:
- "raw" = Algolia 返回的 text 字段(HTML)
- "md" = html_to_markdown 后的字符数(LLM 实际看到的)
"""
import asyncio
import json
import os
import sys
from typing import Dict, List, Tuple
import aiohttp
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.processor import html_to_markdown
from src.sections.hackernews.frontpage_scraper import (
fetch_frontpage,
parse_frontpage_html,
)
ALGOLIA = "https://hn.algolia.com/api/v1/items"
TIMEOUT = 30
async def fetch_item(session: aiohttp.ClientSession, item_id: str) -> Dict:
async with session.get(
f"{ALGOLIA}/{item_id}",
timeout=aiohttp.ClientTimeout(total=TIMEOUT),
) as resp:
if resp.status != 200:
raise RuntimeError(f"{item_id} -> {resp.status}")
return await resp.json()
def walk_tree(node: Dict, depth: int = 0) -> List[Tuple[int, str]]:
"""递归遍历评论树,返回 [(depth, text_html), ...]。depth=0 是 story 自身,1 是顶层评论。"""
result: List[Tuple[int, str]] = []
text = (node or {}).get("text") or ""
if depth > 0 and text:
result.append((depth, text))
for child in (node or {}).get("children") or []:
result.extend(walk_tree(child, depth + 1))
return result
def stats(samples: List[str]) -> Dict:
if not samples:
return {"count": 0, "raw_chars": 0, "md_chars": 0, "avg_md": 0, "max_md": 0}
md_lens = [len(html_to_markdown(s)) for s in samples]
raw_lens = [len(s) for s in samples]
return {
"count": len(samples),
"raw_chars": sum(raw_lens),
"md_chars": sum(md_lens),
"avg_md": round(sum(md_lens) / len(md_lens)),
"max_md": max(md_lens),
}
def by_depth(nodes: List[Tuple[int, str]], d: int) -> List[str]:
return [t for depth, t in nodes if depth == d]
async def probe_one(
session: aiohttp.ClientSession, story: Dict
) -> Dict:
item_id = story["id"]
data = await fetch_item(session, item_id)
flat = walk_tree(data)
l1 = by_depth(flat, 1)
l2 = by_depth(flat, 2)
l3plus = [t for d, t in flat if d >= 3]
all_comments = [t for _, t in flat]
return {
"id": item_id,
"title": story["title"][:60],
"page_comments": story["comments"],
"L1": stats(l1),
"L2": stats(l2),
"L3+": stats(l3plus),
"ALL": stats(all_comments),
}
def print_row(label: str, s: Dict):
print(
f" {label:5s} count={s['count']:4d} md_total={s['md_chars']:7d} "
f"avg={s['avg_md']:5d} max={s['max_md']:6d}"
)
async def main():
print("📥 抓取 HN 首页...")
html = await fetch_frontpage(timeout=15)
stories = parse_frontpage_html(html)[:10]
print(f"📋 取前 10 条 (实际 {len(stories)} 条)\n")
async with aiohttp.ClientSession() as session:
results = await asyncio.gather(
*[probe_one(session, s) for s in stories],
return_exceptions=True,
)
summary = {"L1": [], "L2": [], "L3+": [], "ALL": []}
for r in results:
if isinstance(r, Exception):
print(f"{r}\n")
continue
print(
f"#{r['id']} page_comments={r['page_comments']}\n {r['title']}"
)
print_row("L1", r["L1"])
print_row("L2", r["L2"])
print_row("L3+", r["L3+"])
print_row("ALL", r["ALL"])
print()
for key in summary:
summary[key].append(r[key])
print("=" * 70)
print("📊 10 条 story 汇总(平均/总和)\n")
for key in ["L1", "L2", "L3+", "ALL"]:
rows = summary[key]
total_count = sum(r["count"] for r in rows)
total_md = sum(r["md_chars"] for r in rows)
avg_count_per_story = round(total_count / len(rows), 1) if rows else 0
avg_md_per_story = round(total_md / len(rows)) if rows else 0
print(
f" {key:5s} 总数={total_count:4d} 总md字符={total_md:8d} "
f"平均每story count={avg_count_per_story:5.1f} md_chars={avg_md_per_story:6d}"
)
if __name__ == "__main__":
asyncio.run(main())
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""推送功能测试 (Step 2)
测试内容:
1. 读取已获取的新闻数据
2. 测试推送到所有已启用的平台
3. 验证推送格式和内容
使用方法:
python tests/push_news.py # 默认从 fetch 数据读取发送 (--fake)
python tests/push_news.py --fake # 从 fetch 数据读取发送
python tests/push_news.py --real # 从 news-data/push-*.md 最新文件发送
"""
import argparse
import asyncio
import sys
from datetime import date
from pathlib import Path
from typing import Optional
sys.path.insert(0, str(Path(__file__).parent.parent))
from dotenv import load_dotenv
load_dotenv()
from src.config import load_config
from src.push import send_to_platforms
from src.storage import (
convert_fetch_json_to_md,
get_fetch_file,
get_last_push_file,
read_entries,
)
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description="推送功能测试")
parser.add_argument(
"--fake", action="store_true", help="从 fetch 数据读取发送(默认)"
)
parser.add_argument(
"--real", action="store_true", help="从 news-data/push-*.md 最新文件发送"
)
return parser.parse_args()
def load_push_content() -> Optional[str]:
"""从 news-data 获取最新的 push 文件内容(去掉 frontmatter"""
last_file = get_last_push_file(data_dir="tests/news-data")
if not last_file:
print(" ❌ 未找到 push 文件")
return None
print(f" 📂 读取文件: {last_file}")
with open(last_file, "r", encoding="utf-8") as f:
content = f.read()
# 去掉 frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
content = parts[2].strip()
return content
async def test_push(mode: str = "fake"):
"""测试推送功能"""
print("=" * 60)
print("📤 推送功能测试 (Step 2)")
print(f" 模式: {'real' if mode == 'real' else 'fake (fetch数据)'}")
print("=" * 60)
# 加载配置
print("\n📋 加载配置...")
config = load_config()
content: Optional[str] = None
title: Optional[str] = None
if mode == "real":
# 从 push 文件读取
content = load_push_content()
if content:
# 提取标题
first_line = content.split("\n")[0] if content else ""
if first_line.startswith("# "):
title = first_line.replace("# ", "").strip()
print(f" 📝 内容长度: {len(content)} 字符")
print(f" 📝 标题: {title}")
else:
# 从 fetch 数据读取(默认)
print("\n📖 读取新闻数据...")
fetch_file = get_fetch_file(date.today(), data_dir="tests/news-data")
entries = read_entries(fetch_file)
print(f" 文件: {fetch_file}")
print(f" 获取到 {len(entries)} 条新闻")
# 同时生成 Markdown 版本便于阅读
md_file = str(fetch_file).replace(".json", ".md")
convert_fetch_json_to_md(fetch_file, md_file)
print(f" Markdown版本: {md_file}")
if not entries:
print("\n⚠️ 没有新闻数据,请先运行 fetch_news.py")
return
# 构建测试消息
print("\n📝 构建推送消息...")
content = build_test_message(entries[:5])
print(f" 消息长度: {len(content)} 字符")
if not content:
print("\n⚠️ 没有内容可推送")
return
# 推送到所有已启用平台
print("\n📤 推送消息...")
try:
await send_to_platforms(
content, config["push"], "📰 AI Daily 每日精选 | {Test:YYYY-MM-DD}"
)
except Exception as e:
print(f" ❌ 推送失败: {e}")
raise
print("\n" + "=" * 60)
print("✅ Step 2 完成: 推送测试")
print("=" * 60)
def build_test_message(entries: list) -> str:
"""构建测试消息"""
lines = [
"📰 **新闻推送测试**",
"",
f"共获取 {len(entries)} 条新闻:",
"",
]
for i, entry in enumerate(entries, 1):
title = entry.get("title", "无标题")
source = entry.get("source", "未知来源")
link = entry.get("link", "")
published = entry.get("published", "")
content = entry.get("content", "")[:100]
lines.append(f"**{i}. {title}**")
lines.append(f" 📰 来源: {source}")
if published:
lines.append(f" ⏰ 时间: {published}")
if link:
lines.append(f" 🔗 链接: {link}")
if content:
lines.append(f" 📝 内容: {content}...")
lines.append("")
return "\n".join(lines)
if __name__ == "__main__":
args = parse_args()
mode = "real" if args.real else "fake"
asyncio.run(test_push(mode))
+1
View File
@@ -0,0 +1 @@
# pytest tests for daily-news project
+154
View File
@@ -0,0 +1,154 @@
"""pytest fixtures for daily-news project"""
import json
import pytest
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
from unittest.mock import MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@pytest.fixture
def temp_dir(tmp_path):
"""临时目录fixture"""
return tmp_path
@pytest.fixture
def sample_config():
"""示例配置"""
return {
"sources": {
"base_opml": "resources/rss.opml",
"add": [],
"block": [],
"block_domains": ["*.substack.com"],
},
"filter": {
"min_score": 60,
"hot_threshold": 90,
"context_days": 2,
"keep_days": 7,
},
"schedule": {
"fetch_interval_minutes": 30,
"push_cron": ["0 8 * * *", "0 17 * * *"],
"timezone_hours": 8,
},
"llm": {
"provider": "openai",
"model": "gpt-4o-mini",
"baseUrl": "https://api.openai.com/v1",
"apiKeyName": "OPENAI_API_KEY",
"max_prompt_chars": 10000,
"max_concurrent_batches": 3,
},
"push": {
"discord": {
"enabled": True,
"webhook_url": "https://discord.com/api/webhooks/test/abc",
},
"feishu": {"enabled": False, "apiKeyName": "FEISHU_WEBHOOK_URL"},
},
}
@pytest.fixture
def sample_opml(temp_dir):
"""示例OPML文件"""
opml_content = """<?xml version="1.0"?>
<opml version="2.0">
<body>
<outline title="Feed1" xmlUrl="http://feed1.com/rss" type="rss" category="tech"/>
<outline title="Feed2" xmlUrl="http://feed2.com/rss" type="rss" category="ai"/>
</body>
</opml>"""
opml_file = temp_dir / "test.opml"
opml_file.write_text(opml_content)
return str(opml_file)
@pytest.fixture
def sample_entry():
"""示例新闻条目"""
return {
"title": "Test Article Title",
"link": "https://example.com/article",
"published": datetime.now(timezone.utc).isoformat(),
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "Test Source",
"content": "<p>Test content</p>",
"summary": "Test summary",
"tags": ["AI", "Tech"],
"score": 85,
}
@pytest.fixture
def sample_entries():
"""示例新闻条目列表"""
now = datetime.now(timezone.utc)
return [
{
"title": "Article 1",
"link": "https://example.com/1",
"published": now.isoformat(),
"fetched_at": now.isoformat(),
"source": "Source1",
"content": "Content 1",
"summary": "Summary 1",
"tags": ["AI"],
"score": 85,
},
{
"title": "Article 2",
"link": "https://example.com/2",
"published": (now - timedelta(hours=1)).isoformat(),
"fetched_at": now.isoformat(),
"source": "Source2",
"content": "Content 2",
"summary": "Summary 2",
"tags": ["Tech"],
"score": 70,
},
{
"title": "Article 3",
"link": "https://example.com/3",
"published": (now - timedelta(hours=2)).isoformat(),
"fetched_at": now.isoformat(),
"source": "Source3",
"content": "Content 3",
"summary": "Summary 3",
"tags": ["News"],
"score": 55,
},
]
@pytest.fixture
def sample_fetch_json(temp_dir, sample_entries):
"""示例fetch JSON文件"""
data = {
"meta": {"date": datetime.now().date().isoformat()},
"entries": sample_entries,
}
json_file = temp_dir / "fetch-test.json"
json_file.write_text(json.dumps(data, ensure_ascii=False, indent=2))
return str(json_file)
@pytest.fixture
def mock_httpx_session():
"""Mock httpx/aiohttp session"""
mock_response = MagicMock()
mock_response.status = 200
mock_response.text = "<rss></rss>"
mock_session = MagicMock()
mock_session.__aenter__ = MagicMock(return_value=mock_session)
mock_session.__aexit__ = MagicMock(return_value=None)
mock_session.get = MagicMock(return_value=mock_response)
return mock_session
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+249
View File
@@ -0,0 +1,249 @@
"""配置模块测试"""
import json
import pytest
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from config import (
load_config,
parse_opml,
merge_sources,
get_timezone,
)
class TestLoadConfig:
"""测试配置加载"""
def test_load_valid_config(self, temp_dir):
config = {
"sources": {"base_opml": "test.opml", "add": [], "block": []},
"filter": {"min_score": 60},
"schedule": {"fetch_interval_minutes": 30},
"llm": {"provider": "groq", "model": "moonshotai/kimi-k2-instruct"},
"push": {"discord": {"enabled": False}},
}
config_file = temp_dir / "config.json"
config_file.write_text(json.dumps(config))
result = load_config(str(config_file))
assert result["filter"]["min_score"] == 60
assert result["llm"]["provider"] == "groq"
def test_load_missing_file(self):
with pytest.raises(FileNotFoundError):
load_config("nonexistent.json")
def test_load_invalid_json(self, temp_dir):
config_file = temp_dir / "config.json"
config_file.write_text("invalid json")
with pytest.raises(json.JSONDecodeError):
load_config(str(config_file))
def test_load_config_with_sources(self, temp_dir, sample_opml):
config = {
"sources": {
"base_opml": sample_opml,
"add": [
{
"title": "Add1",
"xmlUrl": "http://add1.com/rss",
"category": "test",
}
],
"block": [],
},
"filter": {"min_score": 60},
"schedule": {"fetch_interval_minutes": 30, "timezone_hours": 8},
"llm": {"provider": "test"},
"push": {"discord": {"enabled": False}},
}
config_file = temp_dir / "config.json"
config_file.write_text(json.dumps(config))
result = load_config(str(config_file))
assert len(result["sources"]["add"]) == 1
class TestParseOpml:
"""测试OPML解析"""
def test_parse_valid_opml(self, temp_dir):
opml_content = """<?xml version="1.0"?>
<opml version="2.0">
<body>
<outline title="Feed1" xmlUrl="http://feed1.com/rss" type="rss"/>
</body>
</opml>"""
opml_file = temp_dir / "test.opml"
opml_file.write_text(opml_content)
feeds = parse_opml(str(opml_file))
assert len(feeds) == 1
assert feeds[0]["title"] == "Feed1"
assert feeds[0]["xmlUrl"] == "http://feed1.com/rss"
def test_parse_missing_file(self):
feeds = parse_opml("nonexistent.opml")
assert feeds == []
def test_parse_opml_with_category(self, temp_dir):
opml_content = """<?xml version="1.0"?>
<opml version="2.0">
<body>
<outline title="TechFeed" xmlUrl="http://tech.com/rss" type="rss" category="技术"/>
<outline title="AIFeed" xmlUrl="http://ai.com/rss" type="rss" category="AI"/>
</body>
</opml>"""
opml_file = temp_dir / "test.opml"
opml_file.write_text(opml_content)
feeds = parse_opml(str(opml_file))
assert len(feeds) == 2
assert feeds[0]["category"] == "技术"
assert feeds[1]["category"] == "AI"
def test_parse_opml_empty_body(self, temp_dir):
opml_content = """<?xml version="1.0"?>
<opml version="2.0">
<body>
</body>
</opml>"""
opml_file = temp_dir / "test.opml"
opml_file.write_text(opml_content)
feeds = parse_opml(str(opml_file))
assert feeds == []
class TestMergeSources:
"""测试源合并"""
def test_merge_base_and_add(self, sample_opml):
config = {
"base_opml": sample_opml,
"add": [
{"title": "Feed3", "xmlUrl": "http://feed3.com/rss", "category": "test"}
],
"block": [],
}
sources = merge_sources(config)
assert len(sources) == 3
def test_block_by_xmlUrl(self, sample_opml):
config = {
"base_opml": sample_opml,
"add": [],
"block": [{"title": "Block1", "xmlUrl": "http://feed1.com/rss"}],
}
sources = merge_sources(config)
assert all(s["xmlUrl"] != "http://feed1.com/rss" for s in sources)
assert len(sources) == 1
def test_deduplicate_by_xmlUrl(self, sample_opml):
config = {
"base_opml": sample_opml,
"add": [
{
"title": "Duplicate",
"xmlUrl": "http://feed1.com/rss",
"category": "test",
}
],
"block": [],
}
sources = merge_sources(config)
urls = [s["xmlUrl"] for s in sources]
assert len(urls) == len(set(urls))
assert len(sources) == 2
def test_block_domains_wildcard(self, temp_dir):
opml_content = """<?xml version="1.0"?>
<opml version="2.0">
<body>
<outline title="Substack" xmlUrl="https://tech.substack.com/rss" type="rss"/>
<outline title="Blog" xmlUrl="https://tech.blog/rss" type="rss"/>
</body>
</opml>"""
opml_file = temp_dir / "test.opml"
opml_file.write_text(opml_content)
config = {
"base_opml": str(opml_file),
"add": [],
"block": [],
"block_domains": ["*.substack.com"],
}
sources = merge_sources(config)
assert len(sources) == 1
assert sources[0]["xmlUrl"] == "https://tech.blog/rss"
def test_block_domains_exact(self, temp_dir):
opml_content = """<?xml version="1.0"?>
<opml version="2.0">
<body>
<outline title="YouTube" xmlUrl="https://youtube.com/feed" type="rss"/>
<outline title="Blog" xmlUrl="https://tech.blog/rss" type="rss"/>
</body>
</opml>"""
opml_file = temp_dir / "test.opml"
opml_file.write_text(opml_content)
config = {
"base_opml": str(opml_file),
"add": [],
"block": [],
"block_domains": ["youtube.com"],
}
sources = merge_sources(config)
assert len(sources) == 1
assert sources[0]["xmlUrl"] == "https://tech.blog/rss"
def test_block_domains_subdomain(self, temp_dir):
opml_content = """<?xml version="1.0"?>
<opml version="2.0">
<body>
<outline title="Substack1" xmlUrl="https://substack.com/feed" type="rss"/>
<outline title="Substack2" xmlUrl="https://ai.substack.com/feed" type="rss"/>
<outline title="Blog" xmlUrl="https://tech.blog/rss" type="rss"/>
</body>
</opml>"""
opml_file = temp_dir / "test.opml"
opml_file.write_text(opml_content)
config = {
"base_opml": str(opml_file),
"add": [],
"block": [],
"block_domains": ["*.substack.com"],
}
sources = merge_sources(config)
assert len(sources) == 1
assert sources[0]["xmlUrl"] == "https://tech.blog/rss"
class TestGetTimezone:
"""测试时区获取"""
def test_get_timezone_from_config(self, sample_config):
tz = get_timezone(sample_config)
assert isinstance(tz, timezone)
assert tz.utcoffset(datetime.now()).total_seconds() == 8 * 3600
def test_get_timezone_none_config(self):
tz = get_timezone(None)
assert isinstance(tz, timezone)
def test_get_timezone_no_timezone_hours(self):
config = {"schedule": {}}
tz = get_timezone(config)
assert isinstance(tz, timezone)
def test_get_timezone_custom_hours(self):
config = {"schedule": {"timezone_hours": -5}}
tz = get_timezone(config)
assert tz.utcoffset(datetime.now()).total_seconds() == -5 * 3600
+190
View File
@@ -0,0 +1,190 @@
"""RSS抓取模块测试"""
import pytest
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch, AsyncMock
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from fetcher import (
parse_entry_time,
fetch_single_feed_async,
fetch_all_feeds,
DEFAULT_FEED_TIMEOUT,
)
class TestParseEntryTime:
"""测试时间解析"""
def test_parse_published_parsed(self):
entry = MagicMock()
entry.published_parsed = (2024, 1, 15, 10, 30, 0, 0, 0, 0)
result = parse_entry_time(entry)
assert result is not None
assert result.year == 2024
assert result.month == 1
assert result.day == 15
assert result.tzinfo == timezone.utc
def test_parse_updated_parsed(self):
entry = MagicMock()
entry.published_parsed = None
entry.updated_parsed = (2024, 1, 15, 10, 30, 0, 0, 0, 0)
result = parse_entry_time(entry)
assert result is not None
assert result.year == 2024
assert result.month == 1
assert result.day == 15
def test_parse_no_time(self):
entry = MagicMock()
entry.published_parsed = None
entry.updated_parsed = None
result = parse_entry_time(entry)
assert result is None
class TestFetchSingleFeedAsync:
"""测试单源抓取"""
@pytest.mark.asyncio
async def test_fetch_success(self, temp_dir):
rss_content = """<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Test Feed</title>
<item>
<title>Article 1</title>
<link>https://example.com/1</link>
<pubDate>Mon, 15 Jan 2024 10:00:00 GMT</pubDate>
<description>Test description</description>
</item>
</channel>
</rss>"""
feed_info = {"title": "Test Feed", "xmlUrl": "http://test.com/rss"}
cutoff = datetime(2024, 1, 1, tzinfo=timezone.utc)
mock_response = MagicMock()
mock_response.status = 200
mock_response.text = AsyncMock(return_value=rss_content)
mock_session = MagicMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_session.get = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=None),
)
)
with patch("aiohttp.ClientSession", return_value=mock_session):
entries = await fetch_single_feed_async(
feed_info, cutoff, session=mock_session
)
assert len(entries) == 1
assert entries[0]["title"] == "Article 1"
assert entries[0]["link"] == "https://example.com/1"
assert entries[0]["source"] == "Test Feed"
@pytest.mark.asyncio
async def test_fetch_http_error(self):
feed_info = {"title": "Test Feed", "xmlUrl": "http://test.com/rss"}
cutoff = datetime(2024, 1, 1, tzinfo=timezone.utc)
mock_response = MagicMock()
mock_response.status = 404
mock_session = MagicMock()
mock_session.get = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=None),
)
)
entries = await fetch_single_feed_async(feed_info, cutoff, session=mock_session)
assert entries == []
@pytest.mark.asyncio
async def test_fetch_timeout(self):
feed_info = {"title": "Test Feed", "xmlUrl": "http://test.com/rss"}
cutoff = datetime(2024, 1, 1, tzinfo=timezone.utc)
import aiohttp
mock_session = MagicMock()
mock_session.get = MagicMock(side_effect=aiohttp.ServerTimeoutError())
entries = await fetch_single_feed_async(feed_info, cutoff, session=mock_session)
assert entries == []
@pytest.mark.asyncio
async def test_fetch_cutoff_filter(self):
feed_info = {"title": "Test Feed", "xmlUrl": "http://test.com/rss"}
cutoff = datetime(2024, 1, 10, tzinfo=timezone.utc)
import feedparser
with patch(
"fetcher.fetch_single_feed_async", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = [
{
"title": "New",
"link": "https://example.com/new",
"published": datetime(2024, 1, 15, tzinfo=timezone.utc),
}
]
result = await mock_fetch(feed_info, cutoff)
assert len(result) == 1
class TestFetchAllFeeds:
"""测试并发抓取"""
@pytest.mark.asyncio
async def test_concurrent_limit(self):
feeds = [
{"title": f"Feed{i}", "xmlUrl": f"http://feed{i}.com/rss"}
for i in range(20)
]
cutoff = datetime(2024, 1, 1, tzinfo=timezone.utc)
with patch(
"fetcher.fetch_single_feed_async", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = []
await fetch_all_feeds(feeds, cutoff, max_workers=5)
assert mock_fetch.call_count == 20
@pytest.mark.asyncio
async def test_empty_feeds(self):
cutoff = datetime(2024, 1, 1, tzinfo=timezone.utc)
entries = await fetch_all_feeds([], cutoff)
assert entries == []
@pytest.mark.asyncio
async def test_default_timeout(self):
feed_info = {"title": "Test", "xmlUrl": "http://test.com"}
cutoff = datetime.now(timezone.utc)
with patch(
"fetcher.fetch_single_feed_async", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = []
await mock_fetch(feed_info, cutoff, timeout=None)
mock_fetch.assert_called_once()
+330
View File
@@ -0,0 +1,330 @@
"""LLM模块测试"""
import json
import pytest
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
from unittest.mock import patch, AsyncMock, MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from llm import (
load_prompt,
_parse_llm_json_response,
_split_entries_for_batch,
_build_batch_prompt,
_merge_scores,
_score_single_batch,
call_llm,
check_llm_available,
generate_immediate_push,
score_batch,
)
class TestLoadPrompt:
"""测试提示词加载"""
def test_load_prompt_basic(self, temp_dir):
prompt_file = temp_dir / "test.txt"
prompt_file.write_text("Hello {name}!")
result = load_prompt(str(prompt_file), name="World")
assert result == "Hello World!"
def test_load_prompt_missing_file(self):
with pytest.raises(FileNotFoundError):
load_prompt("nonexistent.txt")
def test_load_prompt_with_braces(self, temp_dir):
prompt_file = temp_dir / "test.txt"
prompt_file.write_text("Hello {name}, curly braces: { }")
result = load_prompt(str(prompt_file), name="World")
assert result == "Hello World, curly braces: { }"
def test_load_prompt_multiple_vars(self, temp_dir):
prompt_file = temp_dir / "test.txt"
prompt_file.write_text("{greeting} {name}, you have {count} messages")
result = load_prompt(str(prompt_file), greeting="Hi", name="Alice", count=5)
assert result == "Hi Alice, you have 5 messages"
class TestParseLlmJsonResponse:
"""测试LLM响应解析"""
def test_parse_json_array(self):
response = '[{"link": "https://example.com", "score": 80}]'
result = _parse_llm_json_response(response)
assert len(result) == 1
assert result[0]["link"] == "https://example.com"
assert result[0]["score"] == 80
def test_parse_with_markdown_codeblock(self):
response = """```json
[{"link": "https://example.com", "score": 80}]
```"""
result = _parse_llm_json_response(response)
assert len(result) == 1
def test_parse_with_codeblock(self):
response = """```
[{"link": "https://example.com", "score": 80}]
```"""
result = _parse_llm_json_response(response)
assert len(result) == 1
def test_parse_invalid_response(self):
response = "This is not JSON at all"
with pytest.raises(ValueError):
_parse_llm_json_response(response)
class TestSplitEntriesForBatch:
"""测试条目分批"""
def test_split_empty(self):
result = _split_entries_for_batch([])
assert result == []
def test_split_single_batch(self):
entries = [
{
"link": f"https://example.com/{i}",
"title": f"Title{i}",
"content": "x" * 100,
}
for i in range(5)
]
result = _split_entries_for_batch(entries, max_prompt_chars=10000)
assert len(result) == 1
def test_split_multiple_batches(self):
entries = [
{
"link": f"https://example.com/{i}",
"title": f"Title{i}",
"content": "x" * 5000,
}
for i in range(10)
]
result = _split_entries_for_batch(entries, max_prompt_chars=10000)
assert len(result) > 1
class TestBuildBatchPrompt:
"""测试构建批量提示词"""
def test_build_batch_prompt_basic(self):
entries = [
{
"link": "https://example.com/1",
"title": "Title1",
"source": "Source1",
"published": "2024-01-15",
"content": "Content",
}
]
result = _build_batch_prompt(entries)
assert "Title1" in result
assert "https://example.com/1" in result
class TestMergeScores:
"""测试评分合并"""
def test_merge_scores_basic(self):
entries = [
{"link": "https://example.com/1", "title": "Title1"},
{"link": "https://example.com/2", "title": "Title2"},
]
scores = [
{
"link": "https://example.com/1",
"score": 85,
"tags": ["AI"],
"summary": "Summary1",
},
{
"link": "https://example.com/2",
"score": 70,
"tags": ["Tech"],
"summary": "Summary2",
},
]
result = _merge_scores(entries, scores)
assert result[0]["score"] == 85
assert result[0]["tags"] == ["AI"]
assert result[1]["score"] == 70
def test_merge_scores_partial(self):
entries = [
{"link": "https://example.com/1", "title": "Title1", "score": 50},
{"link": "https://example.com/2", "title": "Title2", "score": 60},
]
scores = [{"link": "https://example.com/1", "score": 85}]
result = _merge_scores(entries, scores)
assert result[0]["score"] == 85
assert result[1]["score"] == 60
class TestCallLlm:
"""测试LLM调用"""
@pytest.mark.asyncio
async def test_call_llm_success(self):
config = {
"model": "gpt-4",
"baseUrl": "https://api.openai.com/v1",
"apiKeyName": "OPENAI_API_KEY",
}
with patch("llm.call_llm", new_callable=AsyncMock) as mock_call:
mock_call.return_value = "Test response"
result = await mock_call("Test prompt", config)
assert result == "Test response"
@pytest.mark.asyncio
async def test_call_llm_missing_key(self):
config = {"model": "gpt-4", "apiKeyName": "MISSING_KEY"}
with pytest.raises(ValueError, match="未设置MISSING_KEY"):
await call_llm("Test prompt", config)
class TestLlmHealthCheck:
"""测试LLM可用性检查"""
@pytest.mark.asyncio
async def test_check_llm_available_success(self, sample_config):
with patch("llm.call_llm", new_callable=AsyncMock) as mock_call:
mock_call.return_value = "OK"
result = await check_llm_available(sample_config["llm"])
assert result == "OK"
@pytest.mark.asyncio
async def test_check_llm_available_empty_response(self, sample_config):
with patch("llm.call_llm", new_callable=AsyncMock) as mock_call:
mock_call.return_value = " "
with pytest.raises(RuntimeError, match="返回空响应"):
await check_llm_available(sample_config["llm"])
class TestImmediatePush:
"""测试即时推送生成"""
@pytest.mark.asyncio
async def test_generate_immediate_push_failure_returns_error(
self, sample_entries, sample_config
):
with patch("llm.load_prompt", return_value="prompt"), patch(
"llm.call_llm", new_callable=AsyncMock
) as mock_call:
mock_call.side_effect = RuntimeError("boom")
content, error = await generate_immediate_push(
sample_entries[:1], sample_config["llm"], recent_push_context=""
)
assert content == ""
assert error == "生成即时推送失败: boom"
class TestScoreBatch:
"""测试批量评分"""
@pytest.mark.asyncio
async def test_score_batch_empty(self, sample_config):
result, errors = await score_batch([], sample_config["llm"])
assert result == []
assert errors == []
@pytest.mark.asyncio
async def test_score_batch_single(self, sample_entries, sample_config):
entries = sample_entries[:1]
mock_scores = [
{
"link": entries[0]["link"],
"score": 85,
"tags": ["AI"],
"summary": "Test summary",
}
]
with patch("llm._score_single_batch", new_callable=AsyncMock) as mock_score:
mock_score.return_value = (mock_scores, [])
result, errors = await score_batch(entries, sample_config["llm"])
assert len(result) == 1
assert result[0]["score"] == 85
assert errors == []
@pytest.mark.asyncio
async def test_score_single_batch_failure_returns_empty_results(
self, sample_entries, sample_config
):
with patch("llm.call_llm", new_callable=AsyncMock) as mock_call:
mock_call.side_effect = RuntimeError("boom")
results, errors = await _score_single_batch(
sample_entries[:2], sample_config["llm"]
)
assert results == []
assert errors == ["批次1 评分失败: boom"]
@pytest.mark.asyncio
async def test_score_single_batch_reconcile_partial_results(
self, sample_entries, sample_config
):
entries = sample_entries[:2]
llm_results = [
{
"link": entries[0]["link"],
"score": 91,
"tags": ["AI"],
"summary": "Matched result",
}
]
with patch("llm.call_llm", new_callable=AsyncMock) as mock_call:
mock_call.return_value = json.dumps(llm_results, ensure_ascii=False)
results, errors = await _score_single_batch(entries, sample_config["llm"])
assert len(results) == 1
assert results[0]["score"] == 91
assert len(errors) == 1
assert "评分结果异常" in errors[0]
assert "输入2" in errors[0]
assert "返回1" in errors[0]
assert "匹配1" in errors[0]
@pytest.mark.asyncio
async def test_score_single_batch_keeps_full_results(self, sample_entries, sample_config):
entries = sample_entries[:3]
llm_results = [
{
"link": entry["link"],
"score": 88,
"tags": ["AI"],
"summary": f"Summary for {index}",
}
for index, entry in enumerate(entries, start=1)
]
with patch("llm.call_llm", new_callable=AsyncMock) as mock_call:
mock_call.return_value = json.dumps(llm_results, ensure_ascii=False)
results, errors = await _score_single_batch(entries, sample_config["llm"])
assert len(results) == 3
assert [result["link"] for result in results] == [entry["link"] for entry in entries]
assert errors == []
@@ -0,0 +1,101 @@
"""测试新增 LLM 函数 (summarize_github_trending 等)"""
import sys
from pathlib import Path
from unittest.mock import patch, AsyncMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from llm import summarize_github_trending, select_ai_related_hn, summarize_hackernews
@pytest.mark.asyncio
async def test_summarize_github_trending_happy_path(tmp_path):
prompt_path = tmp_path / "section_github.md"
prompt_path.write_text("Repos: {repos_json}\nmax_items={max_items}", encoding="utf-8")
config = {
"model": "x",
"baseUrl": "http://x",
"apiKeyName": "DEEPSEEK_API_KEY",
"prompts": {"section_github": str(prompt_path)},
"sections": {"github_trending": {"max_items": 3}},
}
enriched = [{"full_name": "o/r", "readme_excerpt": "rm"}]
with patch("llm.call_llm", new=AsyncMock(return_value="## md")):
md, err = await summarize_github_trending(enriched, config)
assert md == "## md"
assert err is None
@pytest.mark.asyncio
async def test_summarize_github_trending_llm_failure_returns_error(tmp_path):
prompt_path = tmp_path / "section_github.md"
prompt_path.write_text("x {repos_json} {max_items}", encoding="utf-8")
config = {
"model": "x",
"baseUrl": "http://x",
"apiKeyName": "DEEPSEEK_API_KEY",
"prompts": {"section_github": str(prompt_path)},
"sections": {"github_trending": {"max_items": 3}},
}
with patch("llm.call_llm", new=AsyncMock(side_effect=RuntimeError("boom"))):
md, err = await summarize_github_trending([{"full_name": "o/r"}], config)
assert md == ""
assert "boom" in err
@pytest.mark.asyncio
async def test_select_ai_related_hn_parses_id_array(tmp_path):
prompt_path = tmp_path / "select.md"
prompt_path.write_text("k={k} candidates={candidates_json}", encoding="utf-8")
config = {
"model": "x",
"baseUrl": "http://x",
"apiKeyName": "DEEPSEEK_API_KEY",
"prompts": {"section_hackernews_select": str(prompt_path)},
}
with patch("llm.call_llm", new=AsyncMock(return_value='["111", "222"]')):
ids, err = await select_ai_related_hn(
[{"id": "111"}, {"id": "222"}, {"id": "333"}], k=2, config=config
)
assert ids == ["111", "222"]
assert err is None
@pytest.mark.asyncio
async def test_select_ai_related_hn_empty_array(tmp_path):
prompt_path = tmp_path / "select.md"
prompt_path.write_text("{k}{candidates_json}", encoding="utf-8")
config = {
"model": "x",
"baseUrl": "http://x",
"apiKeyName": "DEEPSEEK_API_KEY",
"prompts": {"section_hackernews_select": str(prompt_path)},
}
with patch("llm.call_llm", new=AsyncMock(return_value="[]")):
ids, err = await select_ai_related_hn([{"id": "1"}], k=1, config=config)
assert ids == []
assert err is None
@pytest.mark.asyncio
async def test_summarize_hackernews_happy(tmp_path):
prompt_path = tmp_path / "hn.md"
prompt_path.write_text("{stories_json}", encoding="utf-8")
config = {
"model": "x",
"baseUrl": "http://x",
"apiKeyName": "DEEPSEEK_API_KEY",
"prompts": {"section_hackernews": str(prompt_path)},
}
with patch("llm.call_llm", new=AsyncMock(return_value="## HN summary")):
md, err = await summarize_hackernews(
[{"id": "1", "title": "t", "link_content": "x", "top_comments": []}], config
)
assert md == "## HN summary"
assert err is None
+285
View File
@@ -0,0 +1,285 @@
"""主程序逻辑测试"""
import json
import pytest
import sys
from datetime import datetime, timezone, timedelta, date
from pathlib import Path
from unittest.mock import patch, MagicMock, AsyncMock
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from main import (
now_local,
parse_time_to_local,
calculate_push_times,
collect_entries_for_push,
main as run_main,
)
class TestNowLocal:
"""测试获取本地时间"""
def test_now_local_with_config(self, sample_config):
result = now_local(sample_config)
assert isinstance(result, datetime)
assert result.tzinfo is not None
def test_now_local_without_config(self):
result = now_local()
assert isinstance(result, datetime)
class TestParseTimeToLocal:
"""测试时间解析"""
def test_parse_iso_format(self, sample_config):
result = parse_time_to_local("2024-01-15T10:30:00+00:00", sample_config)
assert result is not None
assert result.year == 2024
assert result.month == 1
assert result.day == 15
def test_parse_with_z_suffix(self, sample_config):
result = parse_time_to_local("2024-01-15T10:30:00Z", sample_config)
assert result is not None
assert result.year == 2024
def test_parse_invalid_format(self, sample_config):
result = parse_time_to_local("not-a-date", sample_config)
assert result is None
def test_parse_none(self, sample_config):
result = parse_time_to_local("", sample_config)
assert result is None
class TestCalculatePushTimes:
"""测试推送时间计算"""
def test_calculate_single_cron(self, sample_config):
times = calculate_push_times(["30 8 * * *"], config=sample_config)
assert len(times) == 1
assert times[0].hour == 8
assert times[0].minute == 30
def test_calculate_multiple_crons(self, sample_config):
times = calculate_push_times(["0 8 * * *", "0 17 * * *"], config=sample_config)
assert len(times) == 2
hours = [t.hour for t in times]
assert 8 in hours
assert 17 in hours
def test_calculate_with_offset(self, sample_config):
times = calculate_push_times(["0 8 * * *"], offset_days=1, config=sample_config)
assert len(times) == 1
expected_date = (datetime.now(timezone.utc) + timedelta(days=1)).date()
assert times[0].date() == expected_date
def test_calculate_invalid_cron(self, sample_config):
times = calculate_push_times(["invalid cron"], config=sample_config)
assert times == []
class TestCollectEntriesForPush:
"""测试收集推送条目"""
def test_collect_no_files(self, temp_dir):
to_push, context = collect_entries_for_push(
last_push_time=None, context_days=2, min_score=60, data_dir=str(temp_dir)
)
assert to_push == []
assert context == []
def test_collect_with_low_score(self, temp_dir):
now = datetime.now(timezone.utc)
data = {
"meta": {"date": now.date().isoformat()},
"entries": [
{
"title": "Low Score",
"link": "https://example.com/1",
"score": 30,
"fetched_at": now.isoformat(),
}
],
}
fetch_file = temp_dir / f"fetch-{now.date().isoformat()}.json"
with open(fetch_file, "w") as f:
json.dump(data, f)
to_push, context = collect_entries_for_push(
last_push_time=None, context_days=2, min_score=60, data_dir=str(temp_dir)
)
assert len(to_push) == 0
def test_collect_with_high_score(self, temp_dir):
now = datetime.now(timezone.utc)
data = {
"meta": {"date": now.date().isoformat()},
"entries": [
{
"title": "High Score",
"link": "https://example.com/1",
"score": 85,
"fetched_at": now.isoformat(),
}
],
}
fetch_file = temp_dir / f"fetch-{now.date().isoformat()}.json"
with open(fetch_file, "w") as f:
json.dump(data, f)
to_push, context = collect_entries_for_push(
last_push_time=None, context_days=2, min_score=60, data_dir=str(temp_dir)
)
assert len(to_push) == 1
assert to_push[0]["score"] == 85
def test_collect_with_last_push_time(self, temp_dir):
now = datetime.now(timezone.utc)
last_push = now - timedelta(hours=2)
data = {
"meta": {"date": now.date().isoformat()},
"entries": [
{
"title": "New Entry",
"link": "https://example.com/1",
"score": 80,
"fetched_at": now.isoformat(),
},
{
"title": "Old Entry",
"link": "https://example.com/2",
"score": 80,
"fetched_at": last_push.isoformat(),
},
],
}
fetch_file = temp_dir / f"fetch-{now.date().isoformat()}.json"
with open(fetch_file, "w") as f:
json.dump(data, f)
to_push, context = collect_entries_for_push(
last_push_time=last_push,
context_days=2,
min_score=60,
data_dir=str(temp_dir),
)
assert len(to_push) == 1
assert to_push[0]["title"] == "New Entry"
def test_collect_context_limit(self, temp_dir):
now = datetime.now(timezone.utc)
entries = [
{
"title": f"Entry{i}",
"link": f"https://example.com/{i}",
"score": 50 + i,
"fetched_at": now.isoformat(),
}
for i in range(60)
]
data = {"meta": {"date": now.date().isoformat()}, "entries": entries}
fetch_file = temp_dir / f"fetch-{now.date().isoformat()}.json"
with open(fetch_file, "w") as f:
json.dump(data, f)
to_push, context = collect_entries_for_push(
last_push_time=None, context_days=2, min_score=60, data_dir=str(temp_dir)
)
assert len(context) <= 50
def test_collect_multi_day(self, temp_dir):
from src.config import get_timezone
tz = get_timezone()
today = datetime.now(tz)
yesterday = today - timedelta(days=1)
today_data = {
"meta": {"date": today.date().isoformat()},
"entries": [
{
"title": "Today Entry",
"link": "https://example.com/1",
"score": 80,
"fetched_at": today.isoformat(),
}
],
}
yesterday_data = {
"meta": {"date": yesterday.date().isoformat()},
"entries": [
{
"title": "Yesterday Entry",
"link": "https://example.com/2",
"score": 75,
"fetched_at": yesterday.isoformat(),
}
],
}
(temp_dir / f"fetch-{today.date().isoformat()}.json").write_text(
json.dumps(today_data)
)
(temp_dir / f"fetch-{yesterday.date().isoformat()}.json").write_text(
json.dumps(yesterday_data)
)
to_push, context = collect_entries_for_push(
last_push_time=None, context_days=2, min_score=60, data_dir=str(temp_dir)
)
assert len(to_push) >= 1
class TestMainStartup:
"""测试主程序启动流程"""
@pytest.mark.asyncio
async def test_main_checks_llm_before_starting_loops(self, sample_config):
with patch("main.load_config", return_value=sample_config), patch(
"main.check_llm_available", new_callable=AsyncMock
) as mock_check, patch(
"main.fetch_loop", new_callable=AsyncMock
) as mock_fetch_loop, patch(
"main.push_loop", new_callable=AsyncMock
) as mock_push_loop:
await run_main()
mock_check.assert_awaited_once_with(sample_config["llm"])
mock_fetch_loop.assert_awaited_once_with(sample_config)
mock_push_loop.assert_awaited_once_with(sample_config)
@pytest.mark.asyncio
async def test_main_exits_when_llm_health_check_fails(self, sample_config):
with patch("main.load_config", return_value=sample_config), patch(
"main.check_llm_available", new_callable=AsyncMock
) as mock_check, patch(
"main.fetch_loop", new_callable=AsyncMock
) as mock_fetch_loop, patch(
"main.push_loop", new_callable=AsyncMock
) as mock_push_loop:
mock_check.side_effect = RuntimeError("health failed")
await run_main()
mock_check.assert_awaited_once_with(sample_config["llm"])
mock_fetch_loop.assert_not_called()
mock_push_loop.assert_not_called()
@@ -0,0 +1,123 @@
"""测试早报四模块编排:gather + insights 串行 + sentinel 拼装 + 失败隔离"""
import sys
from pathlib import Path
from unittest.mock import patch, AsyncMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.main import _run_morning_push
def _insights_meta() -> dict:
return {
"title": "📰 AI Daily 每日精选 | 2026-05-22",
"lead": "lead text",
"highlights": ["a"],
"profile": "morning",
"date": "2026-05-22",
"excerpt": "",
"seotitle": "",
"seodescription": "",
}
@pytest.mark.asyncio
async def test_assembles_all_four_sections(sample_config):
sample_config["filter"]["push_context_days"] = 5
sent = {}
async def fake_send(content, push_cfg, title=None, metadata=None):
sent["content"] = content
sent["metadata"] = metadata
saved = {}
def fake_save(
filepath,
content,
source_count,
total_entries,
profile="default",
metadata=None,
):
saved["profile"] = profile
saved["content"] = content
saved["metadata"] = metadata
with patch(
"src.main.run_rss_section", new=AsyncMock(return_value=("R", None, None))
), patch(
"src.main.run_github_section", new=AsyncMock(return_value=("G", None))
), patch(
"src.main.run_hackernews_section", new=AsyncMock(return_value=("H", None))
), patch(
"src.main.run_insights_section",
new=AsyncMock(return_value=("I", _insights_meta(), None)),
), patch(
"src.main.send_to_platforms", new=AsyncMock(side_effect=fake_send)
), patch(
"src.main.save_push_file", side_effect=fake_save
):
await _run_morning_push(sample_config)
assert "SECTION:rss" in sent["content"]
assert "SECTION:github" in sent["content"]
assert "SECTION:hackernews" in sent["content"]
assert "SECTION:insights" in sent["content"]
assert sent["metadata"]["profile"] == "morning"
assert saved["profile"] == "morning"
assert saved["metadata"]["lead"] == "lead text"
@pytest.mark.asyncio
async def test_rss_failure_raises_to_caller(sample_config):
sample_config["filter"]["push_context_days"] = 5
with patch(
"src.main.run_rss_section",
new=AsyncMock(return_value=("", None, "compose_digest 失败")),
), patch(
"src.main.run_github_section", new=AsyncMock(return_value=("G", None))
), patch(
"src.main.run_hackernews_section", new=AsyncMock(return_value=("H", None))
), patch(
"src.main.notify_llm_errors", new=AsyncMock()
):
with pytest.raises(RuntimeError):
await _run_morning_push(sample_config)
@pytest.mark.asyncio
async def test_section_failure_degrades_to_omission(sample_config):
sample_config["filter"]["push_context_days"] = 5
sent = {}
async def fake_send(content, push_cfg, title=None, metadata=None):
sent["content"] = content
with patch(
"src.main.run_rss_section", new=AsyncMock(return_value=("R", None, None))
), patch(
"src.main.run_github_section", new=AsyncMock(return_value=("", "gh down"))
), patch(
"src.main.run_hackernews_section", new=AsyncMock(return_value=("H", None))
), patch(
"src.main.run_insights_section",
new=AsyncMock(return_value=("I", _insights_meta(), None)),
), patch(
"src.main.notify_llm_errors", new=AsyncMock()
), patch(
"src.main.send_to_platforms", new=AsyncMock(side_effect=fake_send)
), patch(
"src.main.save_push_file"
):
await _run_morning_push(sample_config)
assert "SECTION:rss" in sent["content"]
assert "SECTION:github" not in sent["content"]
assert "SECTION:hackernews" in sent["content"]
@@ -0,0 +1,43 @@
"""测试 run_push_job 的早报/默认路径分发"""
import sys
from pathlib import Path
from unittest.mock import patch, AsyncMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.main import run_push_job
@pytest.mark.asyncio
async def test_default_path_when_not_morning(sample_config):
sample_config["schedule"]["push_cron"] = ["0 8 * * *", "0 17 * * *"]
sample_config["filter"]["push_context_days"] = 5
with patch("src.main.is_morning_push", return_value=False), patch(
"src.main._run_default_push", new=AsyncMock(return_value=None)
) as default_path, patch(
"src.main._run_morning_push", new=AsyncMock(return_value=None)
) as morning_path:
await run_push_job(sample_config)
default_path.assert_awaited_once()
morning_path.assert_not_awaited()
@pytest.mark.asyncio
async def test_morning_path_when_morning(sample_config):
sample_config["schedule"]["push_cron"] = ["0 8 * * *", "0 17 * * *"]
sample_config["filter"]["push_context_days"] = 5
with patch("src.main.is_morning_push", return_value=True), patch(
"src.main._run_default_push", new=AsyncMock(return_value=None)
) as default_path, patch(
"src.main._run_morning_push", new=AsyncMock(return_value=None)
) as morning_path:
await run_push_job(sample_config)
morning_path.assert_awaited_once()
default_path.assert_not_awaited()
@@ -0,0 +1,60 @@
"""测试早报判定:push_cron 最近最早匹配"""
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.main import is_morning_push
TZ = timezone(timedelta(hours=8))
def _cfg(push_cron):
return {"schedule": {"push_cron": push_cron, "timezone_hours": 8}}
def test_returns_false_when_push_cron_empty():
assert is_morning_push(datetime(2026, 5, 17, 8, 0, tzinfo=TZ), {"schedule": {}}) is False
assert is_morning_push(datetime(2026, 5, 17, 8, 0, tzinfo=TZ), _cfg([])) is False
def test_single_cron_always_morning():
"""push_cron 只有一项时,任何触发(含手动 / 非整点)都视为早报"""
cfg = _cfg(["0 8 * * *"])
assert is_morning_push(datetime(2026, 5, 17, 8, 0, tzinfo=TZ), cfg) is True
assert is_morning_push(datetime(2026, 5, 17, 12, 30, tzinfo=TZ), cfg) is True
assert is_morning_push(datetime(2026, 5, 17, 23, 59, tzinfo=TZ), cfg) is True
def test_earliest_cron_match_is_morning():
"""触发时刻离最早 cron 最近 → 早报"""
cfg = _cfg(["0 8 * * *", "0 17 * * *"])
assert is_morning_push(datetime(2026, 5, 17, 8, 0, tzinfo=TZ), cfg) is True
assert is_morning_push(datetime(2026, 5, 17, 8, 30, tzinfo=TZ), cfg) is True
assert is_morning_push(datetime(2026, 5, 17, 6, 0, tzinfo=TZ), cfg) is True
def test_later_cron_match_not_morning():
"""触发时刻离非最早 cron 最近 → 默认"""
cfg = _cfg(["0 8 * * *", "0 17 * * *"])
assert is_morning_push(datetime(2026, 5, 17, 17, 0, tzinfo=TZ), cfg) is False
assert is_morning_push(datetime(2026, 5, 17, 16, 30, tzinfo=TZ), cfg) is False
assert is_morning_push(datetime(2026, 5, 17, 22, 0, tzinfo=TZ), cfg) is False
def test_drift_tolerance_via_closest_match():
"""无显式容差,但「最近匹配」自动吸附小幅漂移 (08:01 仍归 08:00)"""
cfg = _cfg(["0 8 * * *", "0 17 * * *"])
assert is_morning_push(datetime(2026, 5, 17, 8, 1, tzinfo=TZ), cfg) is True
assert is_morning_push(datetime(2026, 5, 17, 17, 1, tzinfo=TZ), cfg) is False
def test_three_crons_only_earliest_is_morning():
"""三条 cron 时,只有最早那条对应的触发是早报"""
cfg = _cfg(["0 8 * * *", "0 12 * * *", "0 20 * * *"])
assert is_morning_push(datetime(2026, 5, 17, 8, 0, tzinfo=TZ), cfg) is True
assert is_morning_push(datetime(2026, 5, 17, 12, 0, tzinfo=TZ), cfg) is False
assert is_morning_push(datetime(2026, 5, 17, 20, 0, tzinfo=TZ), cfg) is False
@@ -0,0 +1,87 @@
"""内容处理模块测试"""
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from processor import html_to_markdown
class TestHtmlToMarkdown:
"""测试HTML转Markdown"""
def test_convert_basic_html(self):
html = "<p>Hello World</p>"
result = html_to_markdown(html)
assert "Hello World" in result
def test_convert_with_links(self):
html = '<a href="https://example.com">Click here</a>'
result = html_to_markdown(html)
assert "[Click here](https://example.com)" in result
def test_convert_with_images(self):
html = '<img src="https://example.com/image.png" alt="Image">'
result = html_to_markdown(html)
assert "![Image](https://example.com/image.png)" in result
def test_convert_with_headings(self):
html = "<h1>Title</h1><h2>Subtitle</h2>"
result = html_to_markdown(html)
assert "# Title" in result
assert "## Subtitle" in result
def test_convert_with_lists(self):
html = "<ul><li>Item 1</li><li>Item 2</li></ul>"
result = html_to_markdown(html)
assert "Item 1" in result
assert "Item 2" in result
def test_convert_with_strong_emphasis(self):
html = "<strong>Bold</strong> and <em>italic</em>"
result = html_to_markdown(html)
assert "**Bold**" in result
assert "*italic*" in result
def test_relative_link_conversion(self):
html = '<a href="/article/123">Read more</a>'
result = html_to_markdown(html, base_url="https://example.com/blog")
assert "https://example.com/article/123" in result
def test_relative_image_conversion(self):
html = '<img src="/images/logo.png">'
result = html_to_markdown(html, base_url="https://example.com")
assert "https://example.com/images/logo.png" in result
def test_absolute_link_unchanged(self):
html = '<a href="https://other.com/page">Link</a>'
result = html_to_markdown(html, base_url="https://example.com")
assert "https://other.com/page" in result
def test_remove_xgo_ing_link(self):
html = "<p>Content</p><p>[⚡ Powered by xgo.ing](https://xgo.ing)</p>"
result = html_to_markdown(html)
assert "xgo.ing" not in result
assert "Content" in result
def test_remove_xgo_ing_link_with_slash(self):
html = "<p>Content</p><p>[⚡ Powered by xgo.ing](https://xgo.ing/)</p>"
result = html_to_markdown(html)
assert "xgo.ing" not in result
def test_clean_extra_newlines(self):
html = "<p>Line 1</p>\n\n\n\n<p>Line 2</p>"
result = html_to_markdown(html)
assert "\n\n\n\n" not in result
def test_empty_html(self):
result = html_to_markdown("")
assert result.strip() == ""
def test_html_with_nbsp(self):
html = "<p>Hello&nbsp;World</p>"
result = html_to_markdown(html)
assert "Hello" in result
assert "World" in result
+209
View File
@@ -0,0 +1,209 @@
"""推送模块测试"""
import os
import pytest
import sys
from pathlib import Path
from unittest.mock import patch, AsyncMock, MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from push.discord import DiscordPlatform
from push.feishu import FeishuPlatform
from push import create_platform
class TestDiscordPlatform:
"""测试Discord推送"""
def test_validate_config_valid(self):
config = {
"enabled": True,
"apiKeyName": "DISCORD_WEBHOOK_URL",
}
with patch.dict(
os.environ,
{"DISCORD_WEBHOOK_URL": "https://discord.com/api/webhooks/123456/abcdef"},
):
platform = DiscordPlatform(config)
assert platform.validate_config(config) is True
def test_validate_config_disabled(self):
config = {
"enabled": False,
"apiKeyName": "DISCORD_WEBHOOK_URL",
}
with patch.dict(
os.environ,
{"DISCORD_WEBHOOK_URL": "https://discord.com/api/webhooks/123456/abcdef"},
):
platform = DiscordPlatform(config)
assert platform.validate_config(config) is False
def test_validate_config_missing_webhook(self):
config = {"enabled": True, "apiKeyName": "DISCORD_WEBHOOK_URL"}
with patch.dict(os.environ, {"DISCORD_WEBHOOK_URL": ""}):
platform = DiscordPlatform(config)
assert platform.validate_config(config) is False
def test_validate_config_invalid_url(self):
config = {"enabled": True, "apiKeyName": "DISCORD_WEBHOOK_URL"}
with patch.dict(os.environ, {"DISCORD_WEBHOOK_URL": "not-a-valid-url"}):
platform = DiscordPlatform(config)
assert platform.validate_config(config) is False
def test_validate_config_wrong_domain(self):
config = {"enabled": True, "apiKeyName": "DISCORD_WEBHOOK_URL"}
with patch.dict(
os.environ, {"DISCORD_WEBHOOK_URL": "https://example.com/webhook"}
):
platform = DiscordPlatform(config)
assert platform.validate_config(config) is False
def test_split_content_short(self):
with patch.dict(os.environ, {"DISCORD_WEBHOOK_URL": "https://test.com"}):
config = {"apiKeyName": "DISCORD_WEBHOOK_URL"}
platform = DiscordPlatform(config)
short_content = "Hello"
chunks = platform._split_content(short_content, limit=2000)
assert len(chunks) == 1
assert chunks[0] == "Hello"
def test_split_content_long_message(self):
with patch.dict(os.environ, {"DISCORD_WEBHOOK_URL": "https://test.com"}):
config = {"apiKeyName": "DISCORD_WEBHOOK_URL"}
platform = DiscordPlatform(config)
long_content = "A\n" * 2500
chunks = platform._split_content(long_content, limit=2000)
assert len(chunks) > 1
assert all(len(c) <= 2000 for c in chunks)
def test_split_content_exact_boundary(self):
with patch.dict(os.environ, {"DISCORD_WEBHOOK_URL": "https://test.com"}):
config = {"apiKeyName": "DISCORD_WEBHOOK_URL"}
platform = DiscordPlatform(config)
content = "A" * 2000
chunks = platform._split_content(content, limit=2000)
assert len(chunks) == 1
def test_split_content_unicode(self):
with patch.dict(os.environ, {"DISCORD_WEBHOOK_URL": "https://test.com"}):
config = {"apiKeyName": "DISCORD_WEBHOOK_URL"}
platform = DiscordPlatform(config)
content = "你好" * 500
chunks = platform._split_content(content, limit=100)
assert len(chunks) >= 1
@pytest.mark.asyncio
async def test_send_success(self):
with patch.dict(
os.environ,
{"DISCORD_WEBHOOK_URL": "https://discord.com/api/webhooks/test/abc"},
):
config = {"apiKeyName": "DISCORD_WEBHOOK_URL"}
platform = DiscordPlatform(config)
with patch.object(platform, "send", new_callable=AsyncMock) as mock_send:
mock_send.return_value = True
result = await mock_send("Test message")
assert result is True
@pytest.mark.asyncio
async def test_send_failure(self):
with patch.dict(
os.environ,
{"DISCORD_WEBHOOK_URL": "https://discord.com/api/webhooks/test/abc"},
):
config = {"apiKeyName": "DISCORD_WEBHOOK_URL"}
platform = DiscordPlatform(config)
with patch.object(platform, "send", new_callable=AsyncMock) as mock_send:
mock_send.return_value = False
result = await mock_send("Test message")
assert result is False
class TestFeishuPlatform:
"""测试飞书推送"""
def test_validate_config_valid(self):
config = {
"enabled": True,
"apiKeyName": "FEISHU_WEBHOOK_URL",
}
with patch.dict(os.environ, {"FEISHU_WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/test"}):
platform = FeishuPlatform(config)
assert platform.validate_config(config) is True
def test_validate_config_disabled(self):
config = {
"enabled": False,
"apiKeyName": "FEISHU_WEBHOOK_URL",
}
with patch.dict(os.environ, {"FEISHU_WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/test"}):
platform = FeishuPlatform(config)
assert platform.validate_config(config) is False
def test_validate_config_missing_key(self):
config = {"enabled": True, "apiKeyName": "FEISHU_WEBHOOK_URL"}
with patch.dict(os.environ, {"FEISHU_WEBHOOK_URL": ""}):
platform = FeishuPlatform(config)
assert platform.validate_config(config) is False
def test_validate_config_any_non_empty_webhook(self):
config = {"enabled": True, "apiKeyName": "FEISHU_WEBHOOK_URL"}
with patch.dict(os.environ, {"FEISHU_WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/test"}):
platform = FeishuPlatform(config)
assert platform.validate_config(config) is True
class TestPushFactory:
"""测试平台工厂"""
def test_create_enabled_platform(self):
config = {
"enabled": True,
"apiKeyName": "DISCORD_WEBHOOK_URL",
}
with patch.dict(
os.environ,
{"DISCORD_WEBHOOK_URL": "https://discord.com/api/webhooks/123/abc"},
):
platform = create_platform("discord", config)
assert platform is not None
def test_create_disabled_platform_returns_none(self):
config = {"enabled": False, "apiKeyName": "DISCORD_WEBHOOK_URL"}
platform = create_platform("discord", config)
assert platform is None
def test_create_unknown_platform_raises(self):
with pytest.raises(ValueError):
create_platform("unknown", {})
def test_create_feishu_platform(self):
config = {
"enabled": True,
"apiKeyName": "FEISHU_WEBHOOK_URL",
}
with patch.dict(os.environ, {"FEISHU_WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/test"}):
platform = create_platform("feishu", config)
assert platform is not None
assert isinstance(platform, FeishuPlatform)
def test_create_discord_platform(self):
config = {
"enabled": True,
"apiKeyName": "DISCORD_WEBHOOK_URL",
}
with patch.dict(
os.environ,
{"DISCORD_WEBHOOK_URL": "https://discord.com/api/webhooks/test/abc"},
):
platform = create_platform("discord", config)
assert platform is not None
assert isinstance(platform, DiscordPlatform)
@@ -0,0 +1,107 @@
"""测试 GitHub REST API enrich 字段映射、archived 过滤、token 鉴权头"""
import base64
import sys
from pathlib import Path
from unittest.mock import patch, AsyncMock, MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.sections.github.repo_enricher import enrich_repo, _auth_headers
def test_auth_headers_with_token(monkeypatch):
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secret")
headers = _auth_headers(token_env="GITHUB_TOKEN")
assert headers["Authorization"] == "Bearer ghp_secret"
assert headers["Accept"] == "application/vnd.github+json"
def test_auth_headers_without_token(monkeypatch):
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
headers = _auth_headers(token_env="GITHUB_TOKEN")
assert "Authorization" not in headers
assert headers["Accept"] == "application/vnd.github+json"
@pytest.mark.asyncio
async def test_enrich_repo_merges_metadata_and_readme():
readme_body = "# Title\n\nProject description here."
readme_b64 = base64.b64encode(readme_body.encode("utf-8")).decode("ascii")
metadata_payload = {
"description": "real desc",
"topics": ["llm", "rag"],
"license": {"spdx_id": "MIT"},
"pushed_at": "2026-05-16T10:00:00Z",
"archived": False,
}
readme_payload = {"content": readme_b64, "encoding": "base64"}
async def fake_get_json(session, url, **kwargs):
if url.endswith("/readme"):
return readme_payload
return metadata_payload
base = {
"url": "https://github.com/o/r",
"full_name": "o/r",
"description": "from trending",
"language": "Python",
"stars_today": 100,
"stars_total": 5000,
}
with patch(
"src.sections.github.repo_enricher._get_json", new=AsyncMock(side_effect=fake_get_json)
):
enriched = await enrich_repo(
session=MagicMock(), repo=base, token_env="GITHUB_TOKEN", readme_max_chars=200
)
assert enriched["topics"] == ["llm", "rag"]
assert enriched["license"] == "MIT"
assert enriched["pushed_at"] == "2026-05-16T10:00:00Z"
assert "Project description" in enriched["readme_excerpt"]
assert enriched["stars_today"] == 100 # trending 已有字段保留
@pytest.mark.asyncio
async def test_enrich_repo_returns_none_when_archived():
metadata_payload = {"archived": True, "topics": [], "pushed_at": "x"}
async def fake_get_json(session, url, **kwargs):
if url.endswith("/readme"):
return {"content": ""}
return metadata_payload
base = {"url": "https://github.com/o/r", "full_name": "o/r"}
with patch(
"src.sections.github.repo_enricher._get_json", new=AsyncMock(side_effect=fake_get_json)
):
result = await enrich_repo(
session=MagicMock(), repo=base, token_env="GITHUB_TOKEN", readme_max_chars=200
)
assert result is None
@pytest.mark.asyncio
async def test_enrich_repo_truncates_readme():
readme_body = "x" * 5000
readme_b64 = base64.b64encode(readme_body.encode("utf-8")).decode("ascii")
async def fake_get_json(session, url, **kwargs):
if url.endswith("/readme"):
return {"content": readme_b64, "encoding": "base64"}
return {"archived": False, "topics": [], "pushed_at": "p"}
base = {"url": "https://github.com/o/r", "full_name": "o/r"}
with patch(
"src.sections.github.repo_enricher._get_json", new=AsyncMock(side_effect=fake_get_json)
):
enriched = await enrich_repo(
session=MagicMock(), repo=base, token_env="GITHUB_TOKEN", readme_max_chars=100
)
assert len(enriched["readme_excerpt"]) == 100
@@ -0,0 +1,40 @@
"""测试 GitHub trending HTML 解析"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.sections.github.trending_scraper import parse_trending_html
def test_parse_trending_html_returns_repo_dicts():
fixture = (
Path(__file__).parent / "fixtures" / "github_trending.html"
).read_text(encoding="utf-8")
repos = parse_trending_html(fixture)
assert len(repos) > 0
first = repos[0]
assert first["url"].startswith("https://github.com/")
assert "/" in first["full_name"]
assert isinstance(first["stars_today"], int)
assert isinstance(first["stars_total"], int)
# description / language 可为空字符串但必须是 str
assert isinstance(first["description"], str)
assert isinstance(first["language"], str)
def test_parse_trending_html_dedupes_by_url():
fixture = (
Path(__file__).parent / "fixtures" / "github_trending.html"
).read_text(encoding="utf-8")
repos = parse_trending_html(fixture)
urls = [r["url"] for r in repos]
assert len(urls) == len(set(urls))
def test_parse_trending_html_empty_input():
assert parse_trending_html("") == []
assert parse_trending_html("<html><body>no repos</body></html>") == []
@@ -0,0 +1,145 @@
"""测试 GitHub 板块编排:抓取 → history 过滤 → enrich → LLM 总结"""
import sys
from pathlib import Path
from unittest.mock import patch, AsyncMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.sections.github.section import run_github_section
# Stub summarize_github_trending until Task 11 provides it
import src.llm as _llm
if not hasattr(_llm, "summarize_github_trending"):
async def _stub(*a, **k):
return "", None
_llm.summarize_github_trending = _stub
def _cfg(history_file: str, max_deep_dive: int = 10) -> dict:
return {
"filter": {"keep_days": 7},
"sections": {
"github_trending": {
"enabled": True,
"max_items": 3,
"max_deep_dive": max_deep_dive,
"readme_max_chars": 3000,
"history_file": history_file,
"request_timeout": 10,
"tokenName": "GITHUB_TOKEN",
}
},
"llm": {
"model": "x",
"baseUrl": "http://x",
"apiKeyName": "DEEPSEEK_API_KEY",
"prompts": {"section_github": "prompts/section_github.md"},
},
}
@pytest.mark.asyncio
async def test_disabled_returns_empty(tmp_path):
cfg = _cfg(str(tmp_path / "h.json"))
cfg["sections"]["github_trending"]["enabled"] = False
md, err = await run_github_section(cfg, now=None)
assert md == ""
assert err is None
@pytest.mark.asyncio
async def test_no_candidates_after_history_returns_empty(tmp_path):
history_path = tmp_path / "h.json"
# 预置 history,使得今日 scrape 出来的 repo 都已存在
history_path.write_text(
'{"repos": {"https://github.com/a/b": "2026-05-16"}, "updated_at": "x"}',
encoding="utf-8",
)
cfg = _cfg(str(history_path))
with patch(
"src.sections.github.section.fetch_trending_page", new=AsyncMock(return_value="<html>")
), patch(
"src.sections.github.section.parse_trending_html",
return_value=[{"url": "https://github.com/a/b", "full_name": "a/b"}],
):
md, err = await run_github_section(cfg, now=None)
assert md == ""
assert err is None
@pytest.mark.asyncio
async def test_happy_path_enriches_and_summarizes(tmp_path):
history_path = tmp_path / "h.json"
cfg = _cfg(str(history_path), max_deep_dive=10)
repos = [
{
"url": "https://github.com/o1/r1",
"full_name": "o1/r1",
"description": "d1",
"language": "Python",
"stars_today": 100,
"stars_total": 1000,
}
]
enriched = [{**repos[0], "topics": ["llm"], "license": "MIT", "pushed_at": "p", "readme_excerpt": "rm"}]
with patch(
"src.sections.github.section.fetch_trending_page", new=AsyncMock(return_value="<html>")
), patch(
"src.sections.github.section.parse_trending_html", return_value=repos
), patch(
"src.sections.github.section.enrich_repos",
new=AsyncMock(return_value=(enriched, [])),
), patch(
"src.llm.summarize_github_trending",
new=AsyncMock(return_value=("## GH section md", None)),
):
md, err = await run_github_section(cfg, now=None)
assert md == "## GH section md"
assert err is None
import json as _j
saved = _j.loads(history_path.read_text(encoding="utf-8"))
assert "https://github.com/o1/r1" in saved["repos"]
@pytest.mark.asyncio
async def test_truncates_candidates_to_max_deep_dive(tmp_path):
cfg = _cfg(str(tmp_path / "h.json"), max_deep_dive=2)
repos = [
{"url": f"https://github.com/o/r{i}", "full_name": f"o/r{i}"} for i in range(5)
]
captured = {}
async def fake_enrich(candidates, **kwargs):
captured["count"] = len(candidates)
return [], []
with patch(
"src.sections.github.section.fetch_trending_page", new=AsyncMock(return_value="<html>")
), patch(
"src.sections.github.section.parse_trending_html", return_value=repos
), patch(
"src.sections.github.section.enrich_repos", new=AsyncMock(side_effect=fake_enrich)
):
await run_github_section(cfg, now=None)
assert captured["count"] == 2
@pytest.mark.asyncio
async def test_scrape_failure_returns_error(tmp_path):
cfg = _cfg(str(tmp_path / "h.json"))
with patch(
"src.sections.github.section.fetch_trending_page",
new=AsyncMock(side_effect=RuntimeError("HTTP 500")),
):
md, err = await run_github_section(cfg, now=None)
assert md == ""
assert "HTTP 500" in err
@@ -0,0 +1,246 @@
"""测试 HN enrich(Algolia 评论树 + 外链正文)"""
import sys
from pathlib import Path
from unittest.mock import patch, AsyncMock, MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.sections.hackernews.item_enricher import enrich_story
def _kwargs(**overrides):
base = dict(
top_comments=3,
top_l2_per_l1=2,
comment_max_chars=500,
comments_total_chars=60000,
link_content_max_chars=3000,
algolia_base="https://hn.algolia.com/api/v1",
timeout=10,
)
base.update(overrides)
return base
@pytest.mark.asyncio
async def test_enrich_external_link_story_returns_tree():
story = {
"id": "111",
"title": "T",
"url": "https://example.com/post",
"site": "example.com",
"points": 100,
"comments": 5,
"comments_url": "https://news.ycombinator.com/item?id=111",
}
algolia_payload = {
"text": None,
"children": [
{
"text": "<p>comment one</p>",
"children": [
{"text": "<p>reply 1a</p>"},
{"text": "<p>reply 1b</p>"},
{"text": "<p>reply 1c (should be dropped)</p>"},
],
},
{"text": "<p>comment two</p>", "children": []},
{"text": "<p>comment three</p>"},
{"text": "<p>comment four (over top_comments cap)</p>"},
],
}
async def fake_algolia(session, item_id, **kw):
return algolia_payload
async def fake_external(session, url, **kw):
return "link body"
with patch(
"src.sections.hackernews.item_enricher._fetch_algolia_item",
new=AsyncMock(side_effect=fake_algolia),
), patch(
"src.sections.hackernews.item_enricher._fetch_external_markdown",
new=AsyncMock(side_effect=fake_external),
):
enriched = await enrich_story(
session=MagicMock(), story=story, **_kwargs()
)
tree = enriched["top_comments"]
assert len(tree) == 3
assert "comment one" in tree[0]["l1"]
assert len(tree[0]["replies"]) == 2
assert "reply 1a" in tree[0]["replies"][0]
assert "reply 1b" in tree[0]["replies"][1]
assert tree[1]["replies"] == []
assert tree[2]["replies"] == []
assert "link body" in enriched["link_content"]
@pytest.mark.asyncio
async def test_enrich_show_hn_uses_root_text_no_external_fetch():
story = {
"id": "222",
"title": "Show HN: T",
"url": "https://news.ycombinator.com/item?id=222",
"site": "",
"points": 200,
"comments": 10,
"comments_url": "https://news.ycombinator.com/item?id=222",
}
algolia_payload = {
"text": "<p>post body text</p>",
"children": [{"text": "<p>c1</p>"}],
}
link_calls = []
async def fake_algolia(session, item_id, **kw):
return algolia_payload
async def fake_external(session, url, **kw):
link_calls.append(url)
return "should not be called"
with patch(
"src.sections.hackernews.item_enricher._fetch_algolia_item",
new=AsyncMock(side_effect=fake_algolia),
), patch(
"src.sections.hackernews.item_enricher._fetch_external_markdown",
new=AsyncMock(side_effect=fake_external),
):
enriched = await enrich_story(
session=MagicMock(), story=story, **_kwargs()
)
assert link_calls == []
assert "post body text" in enriched["link_content"]
assert enriched["top_comments"][0]["l1"].startswith("c1") or "c1" in enriched["top_comments"][0]["l1"]
@pytest.mark.asyncio
async def test_enrich_truncates_comments_and_link():
story = {
"id": "333",
"title": "T",
"url": "https://example.com/a",
"site": "example.com",
"points": 100,
"comments": 2,
"comments_url": "x",
}
long_comment = "<p>" + ("y" * 2000) + "</p>"
long_reply = "<p>" + ("z" * 2000) + "</p>"
long_link = "z" * 5000
async def fake_algolia(session, item_id, **kw):
return {
"text": None,
"children": [
{"text": long_comment, "children": [{"text": long_reply}]}
],
}
async def fake_external(session, url, **kw):
return long_link
with patch(
"src.sections.hackernews.item_enricher._fetch_algolia_item",
new=AsyncMock(side_effect=fake_algolia),
), patch(
"src.sections.hackernews.item_enricher._fetch_external_markdown",
new=AsyncMock(side_effect=fake_external),
):
enriched = await enrich_story(
session=MagicMock(),
story=story,
**_kwargs(comment_max_chars=100, link_content_max_chars=200),
)
assert len(enriched["top_comments"][0]["l1"]) <= 100
assert len(enriched["top_comments"][0]["replies"][0]) <= 100
assert len(enriched["link_content"]) <= 200
@pytest.mark.asyncio
async def test_enrich_failure_returns_partial():
story = {
"id": "444",
"title": "T",
"url": "https://example.com/x",
"site": "example.com",
"points": 100,
"comments": 2,
"comments_url": "x",
}
async def fake_algolia(session, item_id, **kw):
raise RuntimeError("algolia down")
async def fake_external(session, url, **kw):
return "ok"
with patch(
"src.sections.hackernews.item_enricher._fetch_algolia_item",
new=AsyncMock(side_effect=fake_algolia),
), patch(
"src.sections.hackernews.item_enricher._fetch_external_markdown",
new=AsyncMock(side_effect=fake_external),
):
enriched = await enrich_story(
session=MagicMock(), story=story, **_kwargs()
)
assert enriched["top_comments"] == []
assert "ok" in enriched["link_content"]
@pytest.mark.asyncio
async def test_enrich_total_budget_stops_early():
"""累计字符达 comments_total_chars 立即停止,后续 L1 / L2 都不再加入"""
story = {
"id": "555",
"title": "T",
"url": "https://example.com/q",
"site": "example.com",
"points": 100,
"comments": 5,
"comments_url": "x",
}
big = "<p>" + ("a" * 500) + "</p>" # markdown 约 500 chars
async def fake_algolia(session, item_id, **kw):
return {
"text": None,
"children": [{"text": big, "children": [{"text": big}, {"text": big}]} for _ in range(10)],
}
async def fake_external(session, url, **kw):
return "x"
with patch(
"src.sections.hackernews.item_enricher._fetch_algolia_item",
new=AsyncMock(side_effect=fake_algolia),
), patch(
"src.sections.hackernews.item_enricher._fetch_external_markdown",
new=AsyncMock(side_effect=fake_external),
):
enriched = await enrich_story(
session=MagicMock(),
story=story,
**_kwargs(
top_comments=10,
top_l2_per_l1=2,
comment_max_chars=500,
comments_total_chars=1500,
),
)
tree = enriched["top_comments"]
total = sum(len(n["l1"]) + sum(len(r) for r in n["replies"]) for n in tree)
# 累计应该在 1500 附近停下(允许多收一条到 ~2000),不应该收全 10*3=30 条
assert total <= 2000
assert len(tree) < 10
@@ -0,0 +1,59 @@
"""测试 HN 首页 HTML 解析"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.sections.hackernews.frontpage_scraper import parse_frontpage_html
def test_parse_frontpage_returns_stories():
fixture = (Path(__file__).parent / "fixtures" / "hn_frontpage.html").read_text(
encoding="utf-8"
)
stories = parse_frontpage_html(fixture)
assert len(stories) >= 25
s = stories[0]
print(json.dumps(stories[:5], indent=4, ensure_ascii=False))
assert s["id"]
assert s["title"]
assert s["url"]
assert isinstance(s["points"], int)
assert isinstance(s["comments"], int)
assert s["comments_url"].startswith("https://news.ycombinator.com/item?id=")
def test_parse_frontpage_detects_show_hn_internal_url():
html = """
<table>
<tr class="athing" id="111">
<td class="title">
<span class="titleline">
<a href="item?id=111">Ask HN: what's new?</a>
</span>
</td>
</tr>
<tr>
<td class="subtext">
<span class="subline">
<span class="score">50 points</span>
by <a href="user?id=alice">alice</a>
<span class="age"><a href="item?id=111">2 hours ago</a></span>
| <a href="item?id=111">5&nbsp;comments</a>
</span>
</td>
</tr>
</table>
"""
stories = parse_frontpage_html(html)
assert len(stories) == 1
s = stories[0]
assert s["id"] == "111"
assert s["url"].startswith("https://news.ycombinator.com/item?id=")
assert s["site"] == ""
assert s["points"] == 50
assert s["comments"] == 5
@@ -0,0 +1,111 @@
"""测试 HN 板块编排:scrape → select → enrich → LLM"""
import sys
from pathlib import Path
from unittest.mock import patch, AsyncMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Stub HN LLM functions until Task 16 lands them
import src.llm as _llm
if not hasattr(_llm, "select_ai_related_hn"):
async def _stub_select(*a, **k):
return [], None
_llm.select_ai_related_hn = _stub_select
if not hasattr(_llm, "summarize_hackernews"):
async def _stub_summarize(*a, **k):
return "", None
_llm.summarize_hackernews = _stub_summarize
from src.sections.hackernews.section import run_hackernews_section
def _cfg() -> dict:
return {
"filter": {"keep_days": 7},
"sections": {
"hackernews": {
"enabled": True,
"select_k": 1,
"top_comments": 20,
"comment_max_chars": 500,
"link_content_max_chars": 3000,
"request_timeout": 10,
"algolia_base": "https://hn.algolia.com/api/v1",
}
},
"llm": {
"model": "x",
"baseUrl": "http://x",
"apiKeyName": "DEEPSEEK_API_KEY",
"prompts": {
"section_hackernews_select": "prompts/section_hackernews_select.md",
"section_hackernews": "prompts/section_hackernews.md",
},
},
}
@pytest.mark.asyncio
async def test_disabled_returns_empty():
cfg = _cfg()
cfg["sections"]["hackernews"]["enabled"] = False
md, err = await run_hackernews_section(cfg, now=None)
assert md == ""
assert err is None
@pytest.mark.asyncio
async def test_select_empty_returns_silent():
cfg = _cfg()
with patch(
"src.sections.hackernews.section.fetch_frontpage", new=AsyncMock(return_value="<html>")
), patch(
"src.sections.hackernews.section.parse_frontpage_html",
return_value=[{"id": "1", "title": "x"}],
), patch(
"src.llm.select_ai_related_hn",
new=AsyncMock(return_value=([], None)),
):
md, err = await run_hackernews_section(cfg, now=None)
assert md == ""
assert err is None
@pytest.mark.asyncio
async def test_happy_path():
cfg = _cfg()
front = [{"id": "1", "title": "AI thing", "url": "https://e.com/a", "site": "e.com", "points": 100, "comments": 5, "comments_url": "x"}]
enriched = [{**front[0], "link_content": "body", "top_comments": ["c1"]}]
with patch(
"src.sections.hackernews.section.fetch_frontpage", new=AsyncMock(return_value="<html>")
), patch(
"src.sections.hackernews.section.parse_frontpage_html", return_value=front
), patch(
"src.llm.select_ai_related_hn",
new=AsyncMock(return_value=(["1"], None)),
), patch(
"src.sections.hackernews.section.enrich_stories",
new=AsyncMock(return_value=(enriched, [])),
), patch(
"src.llm.summarize_hackernews",
new=AsyncMock(return_value=("## HN md", None)),
):
md, err = await run_hackernews_section(cfg, now=None)
assert md == "## HN md"
assert err is None
@pytest.mark.asyncio
async def test_scrape_failure_returns_error():
cfg = _cfg()
with patch(
"src.sections.hackernews.section.fetch_frontpage",
new=AsyncMock(side_effect=RuntimeError("net")),
):
md, err = await run_hackernews_section(cfg, now=None)
assert md == ""
assert "net" in err
@@ -0,0 +1,66 @@
"""测试 insights 模块"""
import sys
from pathlib import Path
from unittest.mock import patch, AsyncMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Stub Task 17's LLM function until it lands
import src.llm as _llm
if not hasattr(_llm, "generate_trend_insights"):
async def _stub(*a, **k):
return "", None
_llm.generate_trend_insights = _stub
from src.sections.insights.section import run_insights_section
def _cfg() -> dict:
return {
"filter": {"push_context_days": 5},
"sections": {"insights": {"enabled": True}},
"llm": {
"model": "x",
"baseUrl": "http://x",
"apiKeyName": "DEEPSEEK_API_KEY",
"prompts": {"insights": "prompts/insights.md"},
},
}
@pytest.mark.asyncio
async def test_disabled_returns_empty():
cfg = _cfg()
cfg["sections"]["insights"]["enabled"] = False
md, meta, err = await run_insights_section("rss", "gh", "hn", cfg, now=None)
assert md == ""
assert meta is None
assert err is None
@pytest.mark.asyncio
async def test_marks_empty_sections_for_llm():
cfg = _cfg()
captured = {}
async def fake_gen(sections, config):
captured["sections"] = sections
return "insights md", None
with patch(
"src.llm.generate_trend_insights",
new=AsyncMock(side_effect=fake_gen),
):
md, meta, err = await run_insights_section("", "gh md", "", cfg, now=None)
assert md == "insights md"
assert err is None
# metadata 由 parse_insights_with_metadata 注入默认标题/profile
assert meta["profile"] == "morning"
assert "📰 AI Daily 每日精选" in meta["title"]
assert captured["sections"]["rss"] == "(本次无内容)"
assert captured["sections"]["github"] == "gh md"
assert captured["sections"]["hackernews"] == "(本次无内容)"
@@ -0,0 +1,76 @@
"""src.main.collect_entries_for_push patched at source (lazy import in section)"""
import sys
from pathlib import Path
from unittest.mock import patch, AsyncMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.sections.rss.section import run_rss_section
@pytest.mark.asyncio
async def test_returns_markdown_when_entries_present(sample_config, tmp_path):
digest_raw = (
"---\n"
'title: "🌙 AI Daily 晚报 | 测试"\n'
'lead: "今日测试导读"\n'
"highlights:\n - 重点1\n"
"---\n\n"
"### 1️⃣ digest body"
)
with patch(
"src.main.collect_entries_for_push",
return_value=([{"link": "x", "title": "t", "score": 80}], []),
), patch(
"src.sections.rss.section.compose_digest",
new=AsyncMock(return_value=digest_raw),
), patch(
"src.sections.rss.section.load_recent_push_content", return_value=""
), patch(
"src.sections.rss.section.get_last_push_file", return_value=None
):
md, meta, err = await run_rss_section(sample_config, now=None)
assert md == "### 1️⃣ digest body"
assert err is None
assert meta["title"] == "🌙 AI Daily 晚报 | 测试"
assert meta["lead"] == "今日测试导读"
assert meta["highlights"] == ["重点1"]
assert meta["profile"] == "default"
@pytest.mark.asyncio
async def test_returns_empty_when_no_entries(sample_config):
with patch(
"src.main.collect_entries_for_push", return_value=([], [])
), patch(
"src.sections.rss.section.get_last_push_file", return_value=None
):
md, meta, err = await run_rss_section(sample_config, now=None)
assert md == ""
assert meta is None
assert err is None
@pytest.mark.asyncio
async def test_returns_error_on_compose_failure(sample_config):
with patch(
"src.main.collect_entries_for_push",
return_value=([{"link": "x"}], []),
), patch(
"src.sections.rss.section.compose_digest",
new=AsyncMock(side_effect=RuntimeError("LLM down")),
), patch(
"src.sections.rss.section.load_recent_push_content", return_value=""
), patch(
"src.sections.rss.section.get_last_push_file", return_value=None
):
md, meta, err = await run_rss_section(sample_config, now=None)
assert md == ""
assert meta is None
assert "LLM down" in err
+257
View File
@@ -0,0 +1,257 @@
"""存储模块测试"""
import json
import pytest
import sys
from datetime import datetime, date, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from storage import (
get_fetch_file,
get_push_file,
get_last_push_file,
extract_push_time,
read_entries,
read_fetch_data,
save_fetch_file,
append_entries,
format_entry,
json_to_md,
save_push_file,
load_existing_links,
cleanup_old_files,
)
class TestGetFetchFile:
"""测试获取fetch文件路径"""
def test_get_fetch_file_default(self):
result = get_fetch_file()
assert "fetch-" in result
assert result.endswith(".json")
def test_get_fetch_file_specific_date(self):
result = get_fetch_file(date(2024, 1, 15))
assert "fetch-2024-01-15.json" in result
class TestGetPushFile:
"""测试获取push文件路径"""
def test_get_push_file_default(self):
result = get_push_file()
assert "push-" in result
assert result.endswith(".md")
def test_get_push_file_specific_time(self):
dt = datetime(2024, 1, 15, 8, 30, 0)
result = get_push_file(dt)
assert "push-2024-01-15-08-30-00.md" in result
class TestExtractPushTime:
"""测试从文件名提取时间"""
def test_extract_valid_time(self):
result = extract_push_time("news-data/push-2024-01-15-08-30-00.md")
assert result is not None
assert result.year == 2024
assert result.month == 1
assert result.day == 15
def test_extract_invalid_filename(self):
result = extract_push_time("invalid.md")
assert result is None
class TestGetLastPushFile:
"""测试获取最新push文件"""
def test_get_last_push_file_empty(self, temp_dir):
result = get_last_push_file(str(temp_dir))
assert result is None
def test_get_last_push_file_exists(self, temp_dir):
(temp_dir / "push-2024-01-14-10-00-00.md").touch()
(temp_dir / "push-2024-01-15-10-00-00.md").touch()
result = get_last_push_file(str(temp_dir))
assert "2024-01-15" in result
class TestReadWriteEntries:
"""测试读写条目"""
def test_read_entries(self, sample_fetch_json):
entries = read_entries(sample_fetch_json)
assert len(entries) == 3
assert entries[0]["title"] == "Article 1"
def test_read_entries_missing_file(self):
entries = read_entries("nonexistent.json")
assert entries == []
def test_read_fetch_data(self, sample_fetch_json):
data = read_fetch_data(sample_fetch_json)
assert "meta" in data
assert "entries" in data
assert len(data["entries"]) == 3
class TestSaveFetchFile:
"""测试保存fetch文件"""
def test_save_fetch_file(self, temp_dir):
filepath = str(temp_dir / "test.json")
meta = {"date": "2024-01-15"}
entries = [{"title": "Test", "link": "https://example.com", "score": 80}]
save_fetch_file(filepath, meta, entries)
data = read_fetch_data(filepath)
assert data["meta"]["date"] == "2024-01-15"
assert len(data["entries"]) == 1
class TestAppendEntries:
"""测试追加条目"""
def test_append_new_entries(self, temp_dir):
filepath = str(temp_dir / "test.json")
meta = {"date": "2024-01-15"}
entries1 = [{"title": "Entry1", "link": "https://example.com/1", "score": 80}]
count1 = append_entries(filepath, entries1, meta)
assert count1 == 1
entries2 = [{"title": "Entry2", "link": "https://example.com/2", "score": 70}]
count2 = append_entries(filepath, entries2, meta)
assert count2 == 1
def test_append_duplicate_entries(self, temp_dir):
filepath = str(temp_dir / "test.json")
meta = {"date": "2024-01-15"}
entries = [{"title": "Entry1", "link": "https://example.com/1", "score": 80}]
append_entries(filepath, entries, meta)
all_entries = read_entries(filepath)
assert len(all_entries) == 1
count = append_entries(filepath, entries, meta)
all_entries_after = read_entries(filepath)
assert len(all_entries_after) == 1
def test_append_to_existing_file(self, temp_dir):
filepath = str(temp_dir / "test.json")
data = {
"meta": {"date": "2024-01-15"},
"entries": [{"title": "Old", "link": "https://old.com", "score": 60}],
}
with open(filepath, "w") as f:
json.dump(data, f)
new_entries = [{"title": "New", "link": "https://new.com", "score": 70}]
count = append_entries(filepath, new_entries)
entries = read_entries(filepath)
assert len(entries) == 2
class TestFormatEntry:
"""测试格式化条目"""
def test_format_entry_basic(self, sample_entry):
result = format_entry(sample_entry)
assert "## Test Article Title" in result
assert "source: Test Source" in result
assert "score: 85" in result
def test_format_entry_with_tags(self, sample_entry):
result = format_entry(sample_entry)
assert "AI" in result
assert "Tech" in result
class TestJsonToMd:
"""测试JSON转Markdown"""
def test_json_to_md_basic(self, sample_fetch_json):
data = read_fetch_data(sample_fetch_json)
result = json_to_md(data)
assert "Article 1" in result
assert "Article 2" in result
assert "Article 3" in result
def test_json_to_md_empty(self):
data = {"meta": {}, "entries": []}
result = json_to_md(data)
assert result == ""
class TestSavePushFile:
"""测试保存推送文件"""
def test_save_push_file(self, temp_dir):
filepath = str(temp_dir / "push-test.md")
content = "# Test Push\n\nContent here"
save_push_file(filepath, content, 5, 10)
with open(filepath, "r") as f:
content = f.read()
assert "pushDate:" in content
assert "sourceCount: 5" in content
assert "totalEntries: 10" in content
assert "# Test Push" in content
class TestLoadExistingLinks:
"""测试加载已有链接"""
def test_load_existing_links_json(self, sample_fetch_json):
links = load_existing_links(sample_fetch_json)
assert len(links) == 3
assert "https://example.com/1" in links
def test_load_existing_links_missing(self):
links = load_existing_links("nonexistent.json")
assert links == set()
def test_load_existing_links_empty_string(self):
links = load_existing_links("")
assert links == set()
class TestCleanupOldFiles:
"""测试清理旧文件"""
def test_cleanup_old_files(self, temp_dir):
old_date = (datetime.now() - timedelta(days=10)).date()
new_date = (datetime.now() - timedelta(days=1)).date()
(temp_dir / f"fetch-{old_date}.json").touch()
(temp_dir / f"fetch-{new_date}.json").touch()
cleanup_old_files(days=7, data_dir=str(temp_dir))
assert not (temp_dir / f"fetch-{old_date}.json").exists()
assert (temp_dir / f"fetch-{new_date}.json").exists()
def test_cleanup_push_files(self, temp_dir):
old_time = datetime.now() - timedelta(days=10)
new_time = datetime.now() - timedelta(days=1)
(temp_dir / f"push-{old_time.strftime('%Y-%m-%d-%H-%M-%S')}.md").touch()
(temp_dir / f"push-{new_time.strftime('%Y-%m-%d-%H-%M-%S')}.md").touch()
cleanup_old_files(days=7, data_dir=str(temp_dir))
files = list(temp_dir.glob("push-*.md"))
assert len(files) == 1
@@ -0,0 +1,163 @@
"""测试新增的 sentinel 切片与 section-aware 读取"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from storage import extract_section
class TestExtractSection:
def test_extract_section_with_sentinel(self):
md = (
"intro\n"
"<!-- SECTION:rss BEGIN -->\n"
"RSS body\n"
"<!-- SECTION:rss END -->\n"
"\n"
"<!-- SECTION:github BEGIN -->\n"
"GH body\n"
"<!-- SECTION:github END -->\n"
)
assert extract_section(md, "rss").strip() == "RSS body"
assert extract_section(md, "github").strip() == "GH body"
assert extract_section(md, "hackernews") == ""
def test_extract_section_legacy_file_rss(self):
legacy = "# AI Daily\n### 1️⃣ foo\n### 2️⃣ bar\n"
assert extract_section(legacy, "rss") == legacy
def test_extract_section_legacy_file_non_rss(self):
legacy = "# AI Daily\n### 1️⃣ foo\n"
assert extract_section(legacy, "github") == ""
assert extract_section(legacy, "hackernews") == ""
assert extract_section(legacy, "insights") == ""
def test_extract_section_missing_end_marker(self):
broken = "<!-- SECTION:rss BEGIN -->\ncontent only\n"
assert extract_section(broken, "rss") == ""
from datetime import date, datetime, timedelta
from storage import save_push_file
from storage import TrendingHistory, load_trending_history
class TestTrendingHistory:
def test_load_missing_file(self, tmp_path):
path = tmp_path / "trending.json"
h = load_trending_history(str(path))
assert h.repos == {}
def test_touch_then_save_then_reload(self, tmp_path):
path = tmp_path / "trending.json"
h = load_trending_history(str(path))
today = date(2026, 5, 17)
h.touch("https://github.com/a/b", today)
h.touch("https://github.com/c/d", today)
h.save()
h2 = load_trending_history(str(path))
assert h2.repos == {
"https://github.com/a/b": "2026-05-17",
"https://github.com/c/d": "2026-05-17",
}
def test_contains_returns_membership(self, tmp_path):
h = load_trending_history(str(tmp_path / "x.json"))
h.touch("https://github.com/a/b", date(2026, 5, 17))
assert "https://github.com/a/b" in h
assert "https://github.com/x/y" not in h
def test_cleanup_removes_expired_entries(self, tmp_path):
path = tmp_path / "trending.json"
path.write_text(
'{"repos": {'
'"https://github.com/old/repo": "2026-05-01", '
'"https://github.com/new/repo": "2026-05-15"'
'}, "updated_at": "2026-05-15T00:00:00+08:00"}',
encoding="utf-8",
)
h = load_trending_history(str(path))
h.cleanup(today=date(2026, 5, 17), keep_days=7)
assert "https://github.com/old/repo" not in h
assert "https://github.com/new/repo" in h
def test_cleanup_keeps_today_inclusive(self, tmp_path):
h = load_trending_history(str(tmp_path / "x.json"))
h.touch("https://github.com/a/b", date(2026, 5, 10))
# 2026-05-10 + 7 days = 2026-05-17 (last_seen 2026-05-10 仍在 keep 区间)
h.cleanup(today=date(2026, 5, 17), keep_days=7)
assert "https://github.com/a/b" in h
# 再过 1 天就出区间
h.cleanup(today=date(2026, 5, 18), keep_days=7)
assert "https://github.com/a/b" not in h
class TestSavePushFileProfile:
def test_default_profile_when_not_specified(self, tmp_path):
f = tmp_path / "push-x.md"
save_push_file(str(f), "body content", source_count=1, total_entries=1)
text = f.read_text(encoding="utf-8")
assert 'profile: "default"' in text
assert "body content" in text
def test_morning_profile(self, tmp_path):
f = tmp_path / "push-x.md"
save_push_file(
str(f), "body", source_count=2, total_entries=3, profile="morning"
)
text = f.read_text(encoding="utf-8")
assert 'profile: "morning"' in text
from storage import cleanup_old_files
import json as _j
class TestCleanupOldFilesTrendingHistory:
def test_prunes_trending_history_entries_not_file(self, tmp_path):
path = tmp_path / "trending-history.json"
old_date = (datetime.now().date() - timedelta(days=30)).isoformat()
fresh_date = datetime.now().date().isoformat()
path.write_text(
'{"repos": {'
f'"https://github.com/a/b": "{old_date}", '
f'"https://github.com/c/d": "{fresh_date}"'
'}, "updated_at": "..."}',
encoding="utf-8",
)
cleanup_old_files(days=7, data_dir=str(tmp_path))
# 文件应保留
assert path.exists()
# 过期条目应被剪枝
data = _j.loads(path.read_text(encoding="utf-8"))
assert "https://github.com/a/b" not in data["repos"]
assert "https://github.com/c/d" in data["repos"]
from storage import assemble_with_sentinels
class TestAssembleWithSentinels:
def test_assembles_all_sections_in_order(self):
out = assemble_with_sentinels(
{"rss": "R", "github": "G", "hackernews": "H", "insights": "I"}
)
assert out.index("SECTION:rss") < out.index("SECTION:github")
assert out.index("SECTION:github") < out.index("SECTION:hackernews")
assert out.index("SECTION:hackernews") < out.index("SECTION:insights")
assert "<!-- SECTION:rss BEGIN -->\nR\n<!-- SECTION:rss END -->" in out
def test_omits_empty_sections(self):
out = assemble_with_sentinels({"rss": "R", "github": "", "hackernews": "H", "insights": ""})
assert "SECTION:github" not in out
assert "SECTION:insights" not in out
assert "SECTION:rss" in out
assert "SECTION:hackernews" in out
def test_returns_empty_when_all_empty(self):
assert assemble_with_sentinels({"rss": "", "github": "", "hackernews": "", "insights": ""}) == ""
+101
View File
@@ -0,0 +1,101 @@
"""时区处理测试"""
import pytest
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from config import get_timezone
class TestTimezoneBasics:
"""测试时区基础功能"""
def test_get_timezone_positive_hours(self):
config = {"schedule": {"timezone_hours": 8}}
tz = get_timezone(config)
offset = tz.utcoffset(datetime.now())
assert offset.total_seconds() == 8 * 3600
def test_get_timezone_negative_hours(self):
config = {"schedule": {"timezone_hours": -5}}
tz = get_timezone(config)
offset = tz.utcoffset(datetime.now())
assert offset.total_seconds() == -5 * 3600
def test_get_timezone_zero_hours(self):
config = {"schedule": {"timezone_hours": 0}}
tz = get_timezone(config)
offset = tz.utcoffset(datetime.now())
assert offset.total_seconds() == 0
def test_get_timezone_missing_schedule(self):
config = {}
tz = get_timezone(config)
assert tz is not None
def test_get_timezone_none_config(self):
tz = get_timezone(None)
assert tz is not None
class TestTimezoneConversions:
"""测试时区转换"""
def test_utc_to_local(self):
config = {"schedule": {"timezone_hours": 8}}
tz = get_timezone(config)
utc_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
local_time = utc_time.astimezone(tz)
assert local_time.hour == 18
def test_cross_day_conversion(self):
config = {"schedule": {"timezone_hours": 8}}
tz = get_timezone(config)
utc_time = datetime(2024, 1, 15, 20, 0, 0, tzinfo=timezone.utc)
local_time = utc_time.astimezone(tz)
assert local_time.day == 16
def test_negative_timezone(self):
config = {"schedule": {"timezone_hours": -5}}
tz = get_timezone(config)
utc_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
local_time = utc_time.astimezone(tz)
assert local_time.hour == 5
class TestTimezoneAwareDatetime:
"""测试带时区的datetime操作"""
def test_now_in_config_timezone(self):
config = {"schedule": {"timezone_hours": 8}}
tz = get_timezone(config)
now_local = datetime.now(tz)
assert now_local.tzinfo == tz
def test_timezone_aware_comparison(self):
config = {"schedule": {"timezone_hours": 8}}
tz = get_timezone(config)
dt1 = datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc)
dt2 = datetime(2024, 1, 15, 18, 0, 0, tzinfo=tz)
assert dt1 == dt2
def test_naive_to_aware(self):
config = {"schedule": {"timezone_hours": 8}}
tz = get_timezone(config)
naive = datetime(2024, 1, 15, 10, 0, 0)
aware = naive.replace(tzinfo=tz)
assert aware.tzinfo == tz
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""重新推送即时消息(notify 文件)
Usage:
python tests/resend_notify.py news-data/notify-2026-05-21.md
python tests/resend_notify.py news-data/notify-2026-05-21.md --index 0 # 推送第一个块
"""
import argparse
import asyncio
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config import load_config
from src.markdown_utils import parse_frontmatter
from src.push import send_to_platforms
def parse_notify_file(filepath: str):
"""解析 notify 文件,返回所有推送块列表
Returns:
List[Dict]: [{"metadata": {...}, "content": "..."}, ...]
"""
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
blocks = []
for block in content.split("------"):
block = block.strip()
if not block:
continue
metadata, body = parse_frontmatter(block)
if not metadata:
continue
blocks.append({"metadata": metadata, "content": body})
return blocks
async def resend_notify(filepath: str, config: dict, index: int = -1):
"""重新推送 notify 文件中的即时消息
Args:
filepath: notify 文件路径
config: 配置字典
index: 推送第几个块(-1 表示最新的一个)
Returns:
bool: 推送是否成功
"""
if not Path(filepath).exists():
print(f"❌ 文件不存在: {filepath}")
return False
blocks = parse_notify_file(filepath)
if not blocks:
print("❌ 文件中没有有效的推送块")
return False
print(f"\n📋 文件中共有 {len(blocks)} 个推送块")
# 选择要推送的块
if index == -1:
block = blocks[-1]
print(f" 使用最新的一个(第 {len(blocks)} 个)")
elif 0 <= index < len(blocks):
block = blocks[index]
print(f" 使用第 {index + 1}")
else:
print(f"❌ 索引超出范围: {index} (有效范围: 0-{len(blocks)-1})")
return False
metadata = block["metadata"]
content = block["content"]
# 拼接推送标题,与 main.py 的即时推送保持一致
raw_title = metadata.get("title", "")
title = "🚨 AI Daily 快讯 | " + raw_title if raw_title else "🚨 AI Daily 快讯"
print(f"\n📤 准备推送:")
print(f" 标题: {title}")
print(f" 时间: {metadata.get('pushTime', 'N/A')}")
# 推送
try:
await send_to_platforms(content, config["push"], title=title, metadata=metadata)
print("\n✅ 推送成功!")
return True
except Exception as e:
print(f"\n❌ 推送失败: {e}")
import traceback
traceback.print_exc()
return False
async def main():
parser = argparse.ArgumentParser(description="重新推送即时消息(notify 文件)")
parser.add_argument("filepath", help="notify 文件路径")
parser.add_argument("--index", type=int, default=-1, help="推送第几个块(-1=最新,默认)")
args = parser.parse_args()
print("=" * 60)
print("🚨 即时消息推送工具")
print("=" * 60)
# 加载配置
try:
config = load_config()
print("✅ 配置加载成功")
except Exception as e:
print(f"❌ 加载配置失败: {e}")
return 1
# 显示推送平台配置状态
print("\n📋 推送平台配置:")
import os
for platform_name, platform_conf in config.get("push", {}).items():
enabled = platform_conf.get("enabled", False)
api_key_name = platform_conf.get("apiKeyName", "")
has_key = bool(os.environ.get(api_key_name, ""))
status = "" if (enabled and has_key) else "⚠️"
print(f" {status} {platform_name}: enabled={enabled}, has_key={has_key}")
# 推送
success = await resend_notify(args.filepath, config, args.index)
return 0 if success else 1
if __name__ == "__main__":
try:
sys.exit(asyncio.run(main()))
except KeyboardInterrupt:
print("\n\n👋 已取消")
sys.exit(130)
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""重新推送已保存的 push 文件
Usage:
python tests/resend_push.py news-data/push-2026-05-21-08-00-00.md
"""
import argparse
import asyncio
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config import load_config
from src.markdown_utils import parse_frontmatter
from src.push import send_to_platforms
async def resend_push_file(filepath: str, config: dict):
"""读取 push 文件,解析 metadata 并重新推送
Args:
filepath: push 文件路径
config: 配置字典
Returns:
bool: 推送是否成功
"""
if not Path(filepath).exists():
print(f"❌ 文件不存在: {filepath}")
return False
# 读取文件内容
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
# 解析 frontmatter(复用 markdown_utils
metadata, body = parse_frontmatter(content)
if not metadata:
print("❌ 文件格式错误:无法解析 YAML frontmatter")
return False
# 拼接推送标题,与 main.py 的 push 任务保持一致
raw_title = metadata.get("title", "")
title = "📰 AI Daily 每日精选 | " + raw_title if raw_title else "📰 AI Daily 每日精选"
print(f"\n📤 准备推送文件: {Path(filepath).name}")
print(f" 标题: {title}")
print(f" Profile: {metadata.get('profile', 'N/A')}")
print(f" 日期: {metadata.get('date') or metadata.get('pushDate', 'N/A')}")
# 推送到所有平台
try:
await send_to_platforms(body, config["push"], title=title, metadata=metadata)
print("\n✅ 推送成功!")
return True
except Exception as e:
print(f"\n❌ 推送失败: {e}")
import traceback
traceback.print_exc()
return False
async def main():
parser = argparse.ArgumentParser(description="重新推送已保存的 push 文件")
parser.add_argument("filepath", help="push 文件路径")
args = parser.parse_args()
print("=" * 60)
print("📤 重新推送工具")
print("=" * 60)
# 加载配置
try:
config = load_config()
print("✅ 配置加载成功")
except Exception as e:
print(f"❌ 加载配置失败: {e}")
return 1
# 显示推送平台配置状态
print("\n📋 推送平台配置:")
import os
for platform_name, platform_conf in config.get("push", {}).items():
enabled = platform_conf.get("enabled", False)
api_key_name = platform_conf.get("apiKeyName", "")
has_key = bool(os.environ.get(api_key_name, ""))
status = "" if (enabled and has_key) else "⚠️"
print(f" {status} {platform_name}: enabled={enabled}, has_key={has_key} ({api_key_name})")
# 推送文件
success = await resend_push_file(args.filepath, config)
return 0 if success else 1
if __name__ == "__main__":
try:
sys.exit(asyncio.run(main()))
except KeyboardInterrupt:
print("\n\n👋 已取消")
sys.exit(130)
+383
View File
@@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""测试LLM评分和推送功能 - 独立运行脚本
Usage:
# 先激活虚拟环境
source ../.venv/bin/activate
# 测试评分
python tests/run_llm_test.py --score
# 测试即时推送
python tests/run_llm_test.py --immediate-push --push
"""
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
# 检查是否在虚拟环境中
if not hasattr(sys, "real_prefix") and not (
hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
):
print("⚠️ 建议先激活虚拟环境: source .venv/bin/activate")
print("")
# 加载 .env 文件
from dotenv import load_dotenv
load_dotenv()
# 添加项目根目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config import get_timezone, load_config
from src.llm import compose_digest, generate_immediate_push, score_batch
from src.push import send_to_platforms
from src.storage import read_fetch_data, save_fetch_file
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description="测试LLM评分和推送")
parser.add_argument(
"--input",
"-i",
type=str,
default="tests/news-data/fetch-{date}.json",
help="输入文件路径,支持{date}占位符 (默认: tests/news-data/fetch-{date}.json)",
)
parser.add_argument(
"--date",
"-d",
type=str,
default=datetime.now(get_timezone()).strftime("%Y-%m-%d"),
help="日期,格式YYYY-MM-DD (默认: 今天)",
)
parser.add_argument(
"--limit", "-l", type=int, default=0, help="测试的消息数量 (默认: 0表示全部)"
)
# 测试模式选择
parser.add_argument("--score", action="store_true", help="测试评分")
parser.add_argument("--immediate-push", action="store_true", help="测试即时推送")
parser.add_argument("--digest", action="store_true", help="测试汇总推送")
parser.add_argument("--push", action="store_true", help="推送到Discord")
parser.add_argument("--all", action="store_true", help="运行所有测试")
return parser.parse_args()
def should_run(args, mode: str) -> bool:
"""判断是否运行某个模式"""
# 如果没有任何特定模式指定,默认运行评分
if args.all:
return True
# 检查是否指定了任何模式
any_mode = args.score or args.immediate_push or args.digest
if mode == "score":
return args.score or not any_mode # 默认运行评分
elif mode == "immediate_push":
return args.immediate_push
elif mode == "digest":
return args.digest
return False
async def run_llm_test():
"""主函数"""
args = parse_args()
print("=" * 60)
print("🤖 LLM测试脚本")
print("=" * 60)
# 构建输入文件路径
input_path = args.input.format(date=args.date)
print(f"\n📂 输入文件: {input_path}")
# 读取数据
if not Path(input_path).exists():
print(f"❌ 文件不存在: {input_path}")
print("\n💡 提示: 先运行 fetch_news.py 获取新闻数据")
print(" python tests/fetch_news.py --hours 1")
return False
print(input_path)
data = read_fetch_data(input_path)
entries = data.get("entries", [])
meta = data.get("meta", {})
print(f" ✓ 共 {len(entries)}")
if not entries:
print("❌ 没有条目可测试")
return False
# 限制测试数量 (0表示全部)
if args.limit > 0:
test_entries = entries[: args.limit]
print(f" 测试前 {len(test_entries)}")
else:
test_entries = entries
print(f" 测试全部 {len(test_entries)}")
# 显示待评分条目
print(f"\n📄 测试条目:")
for i, e in enumerate(test_entries[:5], 1):
print(f" [{i}] {e.get('title', 'N/A')[:45]}...")
print(f" 来源: {e.get('source', 'N/A')}")
# 加载配置
print("\n⚙️ 加载配置...")
config = load_config()
llm_config = config["llm"]
print(f" ✓ 提供商: {llm_config.get('provider', 'openai')}")
print(f" ✓ 模型: {llm_config.get('model', 'N/A')}")
print(f" ✓ BaseURL: {llm_config.get('baseUrl', 'N/A')}")
# 检查API key
api_key_name = llm_config.get("apiKeyName", "OPENAI_API_KEY")
api_key = os.environ.get(api_key_name)
if not api_key:
print(f"\n❌ 未设置环境变量: {api_key_name}")
return False
print(f" ✓ API Key: {api_key[:10]}...")
# 检查是否启用推送
push_enabled = args.push and config.get("push")
if push_enabled:
print("\n🔌 推送已启用 (将推送到所有已配置的平台)")
# ========== 测试评分 ==========
if should_run(args, "score"):
print("\n" + "-" * 60)
print("🎯 测试: 评分 (score_batch)")
print("-" * 60)
try:
scored, score_errors = await score_batch(test_entries, llm_config)
if score_errors:
print("\n⚠️ 评分存在异常:")
for error in score_errors:
print(f" - {error}")
print("\n✅ 评分完成!")
# 显示评分结果
print("\n📊 评分结果:")
for i, e in enumerate(scored[:5], 1):
print(f"\n [{i}] {e['title'][:40]}...")
print(f" 评分: {e.get('score', 'N/A')}/100")
print(f" 标签: {e.get('tags', [])}")
print(f" 摘要: {e.get('summary', 'N/A')[:60]}...")
# 保存评分结果到JSON文件
print(f"\n💾 保存评分结果到: {input_path}")
# 构建link到评分的映射
score_map = {e.get("link"): e for e in scored if e.get("link")}
# 更新所有entries的评分
all_entries = data.get("entries", [])
for i, entry in enumerate(all_entries):
link = entry.get("link")
if link in score_map:
all_entries[i] = score_map[link]
save_fetch_file(input_path, meta, all_entries)
print(f" ✅ 已保存 {len(scored)} 条评分结果")
# 更新test_entries为评分后的数据
test_entries = scored
except Exception as e:
print(f"\n❌ 评分失败: {e}")
import traceback
traceback.print_exc()
return False
# ========== 测试即时推送 ==========
if should_run(args, "immediate_push"):
print("\n" + "-" * 60)
print("🔥 测试: 即时推送 (generate_immediate_push)")
print("-" * 60)
# 筛选高分条目 (>=80分)用于推送
hot_entries = [e for e in test_entries if e.get("score", 0) >= 90]
if not hot_entries:
hot_entries = test_entries[-3:-1] # 如果没有高分,取前2条
print(f"\n使用 {len(hot_entries)} 条高分消息生成推送...")
# 加载近期推送上下文用于测试
context_days = config.get("filter", {}).get("context_days", 3)
from src.llm import parse_immediate_push_with_metadata
from src.storage import (
get_notify_file,
load_recent_notify_content,
load_recent_push_content,
save_notify_file,
)
recent_notify = load_recent_notify_content(context_days)
recent_push = load_recent_push_content(context_days)
recent_context = (
f"=== 近期即时推送 ===\n{recent_notify}\n\n"
f"=== 近期汇总推送 ===\n{recent_push}"
)
try:
# 传入上下文参数
push_content, immediate_push_error = await generate_immediate_push(
hot_entries, llm_config, recent_push_context=recent_context
)
timestamp = datetime.now(get_timezone()).strftime("%Y-%m-%d")
content_without_title, metadata = parse_immediate_push_with_metadata(
push_content, f"🚨 AI Daily 快讯 | {timestamp}"
)
metadata["pushTime"] = datetime.now(get_timezone()).isoformat()
push_content = content_without_title
if immediate_push_error:
print(f"\n⚠️ 即时推送生成异常: {immediate_push_error}")
push_content = ""
print(f"\n✅ 推送内容生成完成!")
print(f"\n📤 推送内容预览:")
print("-" * 40)
print(
push_content[:500] + "..." if len(push_content) > 500 else push_content
)
print("-" * 40)
# 检查是否有实际内容需要推送
no_content_marker = config.get("filter", {}).get(
"no_content_marker", "[NO_NEW_CONTENT]"
)
if no_content_marker in push_content:
print(f"\nℹ️ 无新内容需要推送 (LLM判定为重复内容)")
else:
# 推送到所有启用的平台
if push_enabled:
print("\n📤 推送消息...")
await send_to_platforms(
push_content,
config["push"],
title="🚨 AI Daily 快讯 | " + metadata["title"],
metadata=metadata,
)
print(" ✅ 推送成功!")
# 保存到 notify 文件
notify_file = get_notify_file()
save_notify_file(notify_file, push_content, metadata)
print(f"\n💾 已保存即时推送到 {notify_file}")
except Exception as e:
print(f"\n❌ 即时推送生成失败: {e}")
import traceback
traceback.print_exc()
# ========== 测试汇总推送 ==========
if should_run(args, "digest"):
print("\n" + "-" * 60)
print("📰 测试: 汇总推送 (compose_digest)")
print("-" * 60)
# 构建上下文(从 fetch 文件读取的历史数据)
context = test_entries[:10] # 使用前10条作为模拟上下文
print(f"\n使用 {len(test_entries)} 条消息生成汇总...")
# 加载近期推送上下文
push_context_days = config.get(
"filter",
).get("push_context_days", 5)
from src.storage import get_push_file, load_recent_push_content, save_push_file
recent_push_context_str = load_recent_push_content(push_context_days)
try:
raw_digest = await compose_digest(
test_entries,
context,
llm_config,
recent_push_context=recent_push_context_str,
)
from src.llm import parse_digest_with_metadata
date_str = datetime.now(get_timezone()).strftime("%Y-%m-%d")
digest_content, metadata = parse_digest_with_metadata(raw_digest, date_str)
metadata["pushTime"] = datetime.now(get_timezone()).isoformat()
print(f"\n✅ 汇总内容生成完成!")
print(f" 标题: {metadata['title']}")
print(f" 导读: {metadata.get('lead', '')[:60]}")
print(f" 重点: {metadata.get('highlights', [])}")
print(f"\n📰 汇总内容预览:")
print("-" * 40)
print(
digest_content[:500] + "..."
if len(digest_content) > 500
else digest_content
)
print("-" * 40)
# 推送到所有启用的平台
if push_enabled:
print("\n📤 推送消息...")
await send_to_platforms(
digest_content,
config["push"],
title="📰 AI Daily 每日精选 | " + metadata["title"],
metadata=metadata,
)
print(" ✅ 推送成功!")
# 保存到 push 文件
push_file = get_push_file()
save_push_file(
push_file,
digest_content,
len(test_entries),
len(test_entries),
profile="default",
metadata=metadata,
)
print(f"\n💾 已保存汇总到 {push_file}")
except Exception as e:
print(f"\n❌ 汇总推送生成失败: {e}")
import traceback
traceback.print_exc()
print("\n" + "=" * 60)
print("✅ LLM测试完成!")
print("=" * 60)
return True
if __name__ == "__main__":
try:
success = asyncio.run(run_llm_test())
sys.exit(0 if success else 1)
except KeyboardInterrupt:
print("\n\n👋 已取消")
sys.exit(130)
except Exception as e:
print(f"\n❌ 错误: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
+30
View File
@@ -0,0 +1,30 @@
"""模拟一次完整早报推送(强制 is_morning=True,但不发送到推送渠道)"""
import asyncio
import os
import sys
from unittest.mock import AsyncMock, patch
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.config import load_config
from src.main import _run_morning_push
async def main():
config = load_config()
async def fake_send(content, push_cfg):
print("\n" + "=" * 60)
print("📤 假推送内容(实际不会发送)")
print("=" * 60)
print(content)
with patch("src.main.send_to_platforms", new=AsyncMock(side_effect=fake_send)):
await _run_morning_push(config)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""测试 cleanup_old_files 函数
功能:
1. 在 tests/cleanup_test_data 文件夹内创建不同日期的测试文件
2. 运行 cleanup_old_files
3. 显示清理结果
"""
import os
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.storage import cleanup_old_files
def create_test_files():
"""创建测试文件"""
test_dir = Path("tests/cleanup_test_data")
test_dir.mkdir(parents=True, exist_ok=True)
today = date.today()
created_files = []
# 创建不同日期的文件
file_specs = [
# (文件名模式, 日期偏移, 文件类型)
("fetch", -10, "json"), # 10天前 - 应该删除
("fetch", -8, "json"), # 8天前 - 应该删除
("fetch", -7, "json"), # 7天前 - 应该删除
("fetch", -6, "json"), # 6天前 - 应该保留
("fetch", -3, "json"), # 3天前 - 应该保留
("fetch", -1, "json"), # 1天前 - 应该保留
("fetch", 0, "json"), # 今天 - 应该保留
("push", -10, "md"), # 10天前 - 应该删除
("push", -7, "md"), # 7天前 - 应该删除
("push", -5, "md"), # 5天前 - 应该保留
("push", -2, "md"), # 2天前 - 应该保留
("push", 0, "md"), # 今天 - 应该保留
("notify", -9, "md"), # 9天前 - 应该删除
("notify", -6, "md"), # 6天前 - 应该保留
("notify", -1, "md"), # 1天前 - 应该保留
("notify", 0, "md"), # 今天 - 应该保留
]
print(f"\n📅 今天是: {today}")
print(f" cutoff: {today - timedelta(days=7)} (7天前)")
print(
f" 将删除 < {today - timedelta(days=6)} (< {(today - timedelta(days=6)).strftime('%m-%d')}) 的文件"
)
print(
f" 保留 >= {today - timedelta(days=6)} (>= {(today - timedelta(days=6)).strftime('%m-%d')}) 的文件"
)
print(f"\n📂 创建测试文件到: {test_dir}")
print("-" * 50)
for prefix, offset, ext in file_specs:
file_date = today + timedelta(days=offset)
if prefix == "push":
# push 文件带时间戳
filename = f"push-{file_date.isoformat()}-08-00-00.{ext}"
else:
filename = f"{prefix}-{file_date.isoformat()}.{ext}"
filepath = test_dir / filename
# 创建文件并写入内容
with open(filepath, "w") as f:
f.write(f"测试文件 - {filename}")
status = "🗑️ 将删除" if offset <= -7 else "✅ 将保留"
print(f" {status}: {filename}")
created_files.append(filename)
print("-" * 50)
print(f"✅ 创建了 {len(created_files)} 个测试文件")
return test_dir
def list_files_after_cleanup(test_dir: Path):
"""显示清理后的文件"""
print(f"\n📂 清理后的文件列表:")
print("-" * 50)
files = sorted(test_dir.glob("*"))
if not files:
print(" (空目录)")
else:
for f in files:
print(f"{f.name}")
print("-" * 50)
print(f"{len(files)} 个文件")
def main():
"""主函数"""
print("=" * 60)
print("🧪 cleanup_old_files 测试")
print("=" * 60)
# 1. 创建测试文件
test_dir = create_test_files()
# 2. 列出清理前的文件
print(f"\n📂 清理前的文件列表:")
print("-" * 50)
for f in sorted(test_dir.glob("*")):
print(f" {f.name}")
print("-" * 50)
print(f"{len(list(test_dir.glob('*')))} 个文件")
# 3. 运行清理
print("\n🚀 运行 cleanup_old_files...")
cleanup_old_files(days=7, data_dir=str(test_dir))
# 4. 列出清理后的文件
list_files_after_cleanup(test_dir)
print("\n" + "=" * 60)
print("🎉 测试完成!")
print("=" * 60)
# 自动清理测试目录
import shutil
shutil.rmtree(test_dir)
print(f"✅ 已删除测试目录: {test_dir}")
if __name__ == "__main__":
main()
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""测试 fetch_lookback_minutes 功能脚本
测试内容:
1. cutoff 时间计算是否正确
2. load_existing_links 阈值逻辑
3. 跨天边界的去重逻辑
"""
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent))
from dotenv import load_dotenv
load_dotenv()
from src.config import get_timezone, load_config
from src.storage import get_fetch_file, load_existing_links, read_entries
def test_cutoff_calculation():
"""测试 cutoff 时间计算"""
print("\n" + "=" * 60)
print("📊 Test 1: cutoff 时间计算")
print("=" * 60)
config = load_config()
interval = config["schedule"]["fetch_interval_minutes"]
lookback = config["schedule"].get("fetch_lookback_minutes", 120)
# 确保 lookback >= interval
lookback = max(lookback, interval)
threshold = lookback + interval
print(f"\n配置值:")
print(f" fetch_interval_minutes: {interval}")
print(
f" fetch_lookback_minutes: {config['schedule'].get('fetch_lookback_minutes', 120)}"
)
print(f" 修正后的 lookback: {lookback}")
print(f" threshold (lookback + interval): {threshold}")
# 模拟计算 cutoff
now_utc = datetime.now(timezone.utc)
cutoff = now_utc - timedelta(minutes=lookback)
print(f"\n计算结果:")
print(f" 当前 UTC 时间: {now_utc.strftime('%Y-%m-%d %H:%M:%S')}")
print(f" cutoff 时间: {cutoff.strftime('%Y-%m-%d %H:%M:%S')}")
print(f" 过去 {lookback} 分钟")
assert lookback >= interval, f"lookback ({lookback}) 应该 >= interval ({interval})"
print("\n✅ Test 1 通过: cutoff 计算正确")
def test_threshold_logic():
"""测试 load_existing_links 阈值逻辑"""
print("\n" + "=" * 60)
print("📊 Test 2: load_existing_links 阈值逻辑")
print("=" * 60)
config = load_config()
interval = config["schedule"]["fetch_interval_minutes"]
lookback = config["schedule"].get("fetch_lookback_minutes", 120)
lookback = max(lookback, interval)
threshold = lookback + interval
tz = get_timezone(config)
# 测试不同时间点
test_cases = [
("02:00", threshold, True, "凌晨2点应该需要昨天"),
("02:29", threshold, True, "02:29 应该需要昨天"),
("02:30", threshold, False, "02:30 开始只需要当天"),
("12:00", threshold, False, "中午12点只需要当天"),
("23:59", threshold, False, "23:59 只需要当天"),
]
print(f"\n阈值: {threshold} 分钟 (即 {threshold // 60}小时 {threshold % 60}分钟)")
print(f"\n测试结果:")
all_passed = True
for time_str, thresh, expected_need_yesterday, desc in test_cases:
hour, minute = map(int, time_str.split(":"))
current_minutes = hour * 60 + minute
need_yesterday = current_minutes < thresh
status = "" if need_yesterday == expected_need_yesterday else ""
print(
f" {status} {time_str}: 需要昨天={need_yesterday} (预期: {expected_need_yesterday}) - {desc}"
)
if need_yesterday != expected_need_yesterday:
all_passed = False
if all_passed:
print("\n✅ Test 2 通过: 阈值逻辑正确")
else:
print("\n❌ Test 2 失败: 阈值逻辑有问题")
def test_load_existing_links_files():
"""测试实际加载文件功能"""
print("\n" + "=" * 60)
print("📊 Test 3: 实际加载文件测试")
print("=" * 60)
config = load_config()
interval = config["schedule"]["fetch_interval_minutes"]
lookback = config["schedule"].get("fetch_lookback_minutes", 120)
lookback = max(lookback, interval)
threshold = lookback + interval
tz = get_timezone(config)
now = datetime.now(tz)
current_minutes = now.hour * 60 + now.minute
need_yesterday = current_minutes < threshold
print(f"\n当前时间: {now.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"当前分钟数: {current_minutes}")
print(f"阈值: {threshold}")
print(f"需要加载昨天: {need_yesterday}")
# 测试当天文件
today_file = get_fetch_file()
print(f"\n当天文件: {today_file}")
print(f"文件存在: {Path(today_file).exists()}")
# 测试 load_existing_links 函数
existing = load_existing_links(today_file, threshold)
print(f"加载到的链接数: {len(existing)}")
if need_yesterday:
yesterday = (now - timedelta(days=1)).date()
yesterday_file = get_fetch_file(yesterday)
print(f"\n昨天文件: {yesterday_file}")
print(f"文件存在: {Path(yesterday_file).exists()}")
if Path(yesterday_file).exists():
yesterday_entries = read_entries(yesterday_file)
print(f"昨天文件条目数: {len(yesterday_entries)}")
print("\n✅ Test 3 完成: 文件加载功能正常")
def test_mock_time():
"""模拟不同时间测试阈值逻辑"""
print("\n" + "=" * 60)
print("📊 Test 4: 模拟时间测试")
print("=" * 60)
config = load_config()
interval = config["schedule"]["fetch_interval_minutes"]
lookback = config["schedule"].get("fetch_lookback_minutes", 120)
lookback = max(lookback, interval)
threshold = lookback + interval
print(f"\n配置: interval={interval}, lookback={lookback}, threshold={threshold}")
test_times = [
(0, 0), # 00:00
(2, 20), # 02:20
(2, 30), # 02:30
(3, 0), # 03:00
(8, 0), # 08:00
(12, 0), # 12:00
(23, 59), # 23:59
]
print("\n模拟时间测试:")
for hour, minute in test_times:
current_minutes = hour * 60 + minute
need_yesterday = current_minutes < threshold
time_str = f"{hour:02d}:{minute:02d}"
status = "🔴 需要昨天" if need_yesterday else "🟢 只需当天"
print(f" {time_str} ({current_minutes:4d}分钟): {status}")
print("\n✅ Test 4 完成")
def main():
"""主函数"""
print("\n" + "=" * 60)
print("🧪 fetch_lookback_minutes 功能测试")
print("=" * 60)
try:
test_cutoff_calculation()
test_threshold_logic()
test_load_existing_links_files()
test_mock_time()
print("\n" + "=" * 60)
print("🎉 所有测试完成!")
print("=" * 60)
return 0
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,93 @@
"""测试 insights section - 从早报文件解析三个板块并生成洞察"""
import asyncio
import re
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dotenv import load_dotenv
from src.config import get_timezone, load_config
from src.sections.insights.section import run_insights_section
load_dotenv()
def parse_sections(content: str) -> dict:
"""从早报内容中解析出各个板块"""
sections = {}
pattern = r"<!-- SECTION:(\w+) BEGIN -->\n(.*?)\n<!-- SECTION:\1 END -->"
for match in re.finditer(pattern, content, re.DOTALL):
section_name = match.group(1)
section_content = match.group(2)
sections[section_name] = section_content
return sections
async def main():
if len(sys.argv) < 2:
print("用法: python test_insights_section.py <早报文件路径>")
print(
"示例: python test_insights_section.py news-data/push-2026-05-25-08-00-00.md"
)
sys.exit(1)
filepath = sys.argv[1]
# 读取早报文件
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
# 解析板块
sections = parse_sections(content)
print(f"📄 解析文件: {filepath}")
print(f"📋 找到板块: {list(sections.keys())}")
print()
rss_md = sections.get("rss", "")
gh_md = sections.get("github", "")
hn_md = sections.get("hackernews", "")
if not rss_md and not gh_md and not hn_md:
print("❌ 未找到任何板块内容")
sys.exit(1)
print(f"RSS 板块: {len(rss_md)} 字符")
print(f"GitHub 板块: {len(gh_md)} 字符")
print(f"HackerNews 板块: {len(hn_md)} 字符")
print()
# 加载配置
config = load_config()
now = datetime.now(get_timezone(config))
# 调用 insights section
print("🤖 生成洞察中...")
insights_md, metadata, error = await run_insights_section(
rss_md, gh_md, hn_md, config, now
)
if error:
print(f"❌ 错误: {error}")
sys.exit(1)
print("\n" + "=" * 60)
print("📊 Insights 板块结果:")
print("=" * 60)
print(insights_md)
print("=" * 60)
if metadata:
print("\n📋 Metadata:")
for key, value in metadata.items():
print(f" {key}: {value}")
if __name__ == "__main__":
asyncio.run(main())
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""测试 push_loop 时间逻辑 - 直接调用 main.py"""
import asyncio
import sys
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config import get_timezone, load_config
# 记录调用时间
call_times = []
async def mock_run_push_job(config):
"""模拟 push job"""
now = datetime.now(get_timezone(config))
call_times.append(now)
print(f"\n{'='*40}")
print(f"📤 Mock Push Job 被调用 | {now.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'='*40}\n")
async def main():
print("🧪 测试 push_loop 时间逻辑")
print("="*50)
# 加载配置
config = load_config()
tz = get_timezone(config)
# 动态设置 cron:约 30 秒后 和 60 秒后触发
# 标准 cron 是5字段(分 时 日 月 周),通过计算实现秒级等待
now = datetime.now(tz)
# 计算等待时间:
# 第一次:约 30 秒后(下一分钟,等待 = 60 - 当前秒数)
# 第二次:约 60 秒后(下两分钟,等待 = 120 - 当前秒数)
sec_to_wait_1 = 60 - now.second # 到下一分钟的剩余秒数
sec_to_wait_2 = sec_to_wait_1 + 60 # 再加一分钟
min_1 = (now.minute + 1) % 60
min_2 = (now.minute + 2) % 60
cron1 = f"{min_1} {now.hour} * * *"
cron2 = f"{min_2} {now.hour} * * *"
config['schedule']['push_cron'] = [cron1, cron2]
print(f"\n当前时间: {now.strftime('%H:%M:%S')}")
print(f"测试配置:")
print(f" - 第1次推送: {cron1} (约 {sec_to_wait_1}s 后)")
print(f" - 第2次推送: {cron2} (约 {sec_to_wait_2}s 后)")
print()
# 使用 patch mock run_push_job,设置超时 3 分钟
from src import main as main_module
test_task = None
push_task = None
async def run_test():
with patch.object(main_module, 'run_push_job', mock_run_push_job):
await main_module.push_loop(config)
async def timeout_guard():
await asyncio.sleep(180) # 3分钟超时
print("\n⏱️ 测试超时")
if push_task:
push_task.cancel()
try:
# 同时运行 push_loop 和超时守卫
push_task = asyncio.create_task(run_test())
timeout_task = asyncio.create_task(timeout_guard())
# 等待 push_task 完成或超时
while push_task and not push_task.done():
if len(call_times) >= 2:
push_task.cancel()
break
await asyncio.sleep(0.1)
timeout_task.cancel()
except asyncio.CancelledError:
pass
# 验证结果
print("\n" + "="*50)
print("📊 测试结果验证")
print("="*50)
if len(call_times) >= 2:
print(f"✅ 成功调用 {len(call_times)}")
for i, t in enumerate(call_times, 1):
print(f"{i}次: {t.strftime('%H:%M:%S')}")
interval = (call_times[1] - call_times[0]).total_seconds()
print(f"\n实际间隔: {interval:.1f}")
if 55 <= interval <= 65:
print("✅ 间隔正确 (约60秒)")
else:
print(f"⚠️ 间隔异常 (期望 ~60秒)")
else:
print(f"❌ 只调用了 {len(call_times)} 次,期望 2 次")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n\n👋 测试已取消")
sys.exit(130)
+889
View File
@@ -0,0 +1,889 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
]
[[package]]
name = "aiohttp"
version = "3.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]]
name = "certifi"
version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "croniter"
version = "6.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" },
]
[[package]]
name = "daily-news"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "beautifulsoup4" },
{ name = "croniter" },
{ name = "feedparser" },
{ name = "markdownify" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "requests" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.9.0" },
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
{ name = "croniter", specifier = ">=6.0.0" },
{ name = "feedparser", specifier = ">=6.0.0" },
{ name = "markdownify", specifier = ">=0.11.0" },
{ name = "pytest", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.21.0" },
{ name = "pytest-mock", specifier = ">=3.10.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "requests", specifier = ">=2.34.2" },
]
[[package]]
name = "feedparser"
version = "6.0.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sgmllib3k" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
name = "idna"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "markdownify"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" },
]
[[package]]
name = "multidict"
version = "6.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "propcache"
version = "0.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" },
{ url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" },
{ url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" },
{ url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" },
{ url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" },
{ url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" },
{ url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" },
{ url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" },
{ url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" },
{ url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" },
{ url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" },
{ url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" },
{ url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" },
{ url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" },
{ url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" },
{ url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" },
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" },
{ url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" },
{ url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" },
{ url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" },
{ url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" },
{ url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" },
{ url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" },
{ url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" },
{ url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" },
{ url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" },
{ url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" },
{ url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" },
{ url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" },
{ url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" },
{ url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" },
{ url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" },
{ url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" },
{ url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" },
{ url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" },
{ url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "sgmllib3k"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" }
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "yarl"
version = "1.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" },
{ url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" },
{ url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" },
{ url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" },
{ url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" },
{ url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" },
{ url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" },
{ url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" },
{ url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" },
{ url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" },
{ url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" },
{ url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" },
{ url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" },
{ url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },
{ url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },
{ url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },
{ url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },
{ url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },
{ url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },
{ url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },
{ url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },
{ url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },
{ url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },
{ url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },
{ url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },
{ url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },
{ url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },
{ url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },
{ url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },
{ url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },
{ url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },
{ url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },
{ url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },
{ url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },
{ url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },
{ url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },
{ url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },
{ url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },
{ url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },
{ url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },
{ url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },
{ url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },
{ url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },
{ url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
{ url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
{ url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
{ url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
{ url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
{ url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
{ url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
{ url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
{ url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
{ url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
{ url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
{ url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
{ url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
{ url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
{ url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
{ url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
{ url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
{ url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
{ url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
{ url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
{ url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
{ url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
{ url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
{ url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
{ url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
{ url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
{ url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
{ url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
{ url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
{ url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
{ url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
{ url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
{ url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
]