feat: 军事科技每日资讯推送系统 - Docker部署 + 日志系统 + 数据目录重组
This commit is contained in:
@@ -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=
|
||||
@@ -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/
|
||||
@@ -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/)
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
[](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.
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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_at;README 给内容深度 |
|
||||
| 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 | 首页要"现场感"走 HTML;Algolia 评论 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 模型限 |
|
||||
| 单板块 CLI(2026-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 │
|
||||
└─────────────┴──────────┴──────────────┴────────────────┴──────────┘
|
||||
@@ -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(生产)+ croniter(loop 模式) | 进程崩溃/服务器重启可自愈,配置热更新,比内置 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` 增加 frontmatter(title/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
@@ -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`
|
||||
@@ -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}
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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 叙事与真实关注点错位**:官方强调 AGI,HN 讨论 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 frontmatter(Part 1 metadata),紧跟空行后输出 Part 2 洞察正文;
|
||||
- 正文起始用 `## 今日洞察`。
|
||||
|
||||
### 输出示例
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Cursor 发布 Composer 2.5,Anthropic 公开 Claude dreaming 机制"
|
||||
excerpt: "Anthropic 首次披露 dreaming 机制,Cursor 加自部署"
|
||||
seotitle: "Cursor Composer 2.5 发布,Anthropic 公开 Claude dreaming,HBM 占 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}
|
||||
@@ -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-5;Anthropic 官博发布拒绝美国防部合同声明;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}
|
||||
```
|
||||
@@ -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}
|
||||
```
|
||||
@@ -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}
|
||||
```
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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 🇺🇸(@kevinweil)" title="Kevin Weil 🇺🇸(@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 🦙(@llama_index)" title="LlamaIndex 🦙(@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 🇺🇸(@pmarca)" title="Marc Andreessen 🇺🇸(@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 🤗(@ClementDelangue)" title="clem 🤗(@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>
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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()]
|
||||
@@ -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>为,<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()
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
# pytest tests for daily-news project
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 "" 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 World</p>"
|
||||
result = html_to_markdown(html)
|
||||
assert "Hello" in result
|
||||
assert "World" in result
|
||||
@@ -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 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
|
||||
@@ -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": ""}) == ""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
Generated
+889
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user