From 54ca4b1b6ad5c44554cf89141997d5cd83a26793 Mon Sep 17 00:00:00 2001 From: poiuy Date: Sun, 12 Jul 2026 20:01:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=86=9B=E4=BA=8B=E7=A7=91=E6=8A=80?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E8=B5=84=E8=AE=AF=E6=8E=A8=E9=80=81=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=20-=20Docker=E9=83=A8=E7=BD=B2=20+=20=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=B3=BB=E7=BB=9F=20+=20=E6=95=B0=E6=8D=AE=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=87=8D=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 45 + 01主函数过长问题.md | 727 ++++ 02图片生成代码重复问题.md | 322 ++ 1.md | 57 + 20260529_新闻智能分析系统开发计划_v2.md | 822 ++++ 20260529_新闻智能分析系统开发计划_v3.md | 1249 ++++++ Dockerfile | 33 + Makefile | 34 + README.md | 286 ++ ai-daily-main.zip | Bin 0 -> 338401 bytes ai-daily-main/.env.example | 9 + ai-daily-main/.gitignore | 42 + ai-daily-main/CLAUDE.md | 28 + ai-daily-main/LICENSE | 21 + ai-daily-main/README.md | 598 +++ ai-daily-main/config.json.example | 120 + ai-daily-main/docs/extra-sections-design.md | 735 ++++ ai-daily-main/docs/plan.md | 90 + .../plans/2026-05-17-extra-sections.md | 3841 +++++++++++++++++ ai-daily-main/docs/tech-spec.md | 246 ++ ai-daily-main/prompts/digest.md | 129 + ai-daily-main/prompts/immediate_push.md | 82 + ai-daily-main/prompts/insights.md | 194 + ai-daily-main/prompts/score_batch.md | 69 + ai-daily-main/prompts/section_github.md | 148 + ai-daily-main/prompts/section_hackernews.md | 131 + .../prompts/section_hackernews_select.md | 38 + ai-daily-main/pyproject.toml | 17 + ai-daily-main/requirements.txt | 9 + ai-daily-main/resources/hero-banner.svg | 154 + ai-daily-main/resources/rss.opml | 431 ++ ai-daily-main/scripts/_gen_units.py | 113 + ai-daily-main/scripts/daily-news.tmpl | 67 + ai-daily-main/scripts/install.sh | 144 + ai-daily-main/scripts/status.sh | 26 + ai-daily-main/scripts/uninstall.sh | 35 + ai-daily-main/src/__init__.py | 0 ai-daily-main/src/config.py | 115 + ai-daily-main/src/fetcher.py | 213 + ai-daily-main/src/llm.py | 634 +++ ai-daily-main/src/main.py | 714 +++ ai-daily-main/src/markdown_utils.py | 83 + ai-daily-main/src/processor.py | 37 + ai-daily-main/src/push/__init__.py | 42 + ai-daily-main/src/push/base.py | 26 + ai-daily-main/src/push/custom.py | 54 + ai-daily-main/src/push/discord.py | 60 + ai-daily-main/src/push/feishu.py | 92 + ai-daily-main/src/sections/__init__.py | 1 + ai-daily-main/src/sections/github/__init__.py | 0 .../src/sections/github/repo_enricher.py | 111 + ai-daily-main/src/sections/github/section.py | 104 + .../src/sections/github/trending_scraper.py | 98 + .../src/sections/hackernews/__init__.py | 0 .../sections/hackernews/frontpage_scraper.py | 91 + .../src/sections/hackernews/item_enricher.py | 237 + .../src/sections/hackernews/section.py | 90 + .../src/sections/insights/__init__.py | 0 .../src/sections/insights/section.py | 37 + ai-daily-main/src/sections/rss/__init__.py | 3 + ai-daily-main/src/sections/rss/section.py | 62 + ai-daily-main/src/storage.py | 546 +++ .../systemd/dnews-fetch.service.tmpl | 15 + ai-daily-main/systemd/dnews-fetch.timer.tmpl | 11 + ai-daily-main/systemd/dnews-push.service.tmpl | 15 + ai-daily-main/systemd/dnews-push.timer.tmpl | 10 + .../systemd/journald-dnews.conf.tmpl | 2 + ai-daily-main/tests/__init__.py | 0 ai-daily-main/tests/fetch_hackernews.py | 45 + ai-daily-main/tests/fetch_news.py | 201 + ai-daily-main/tests/fetch_nitter.py | 46 + ai-daily-main/tests/fetch_trending.py | 44 + .../manual_trigger_fetch_loop_llm_error.py | 209 + ai-daily-main/tests/preview_recent_context.py | 99 + ai-daily-main/tests/probe_hn_comments.py | 140 + ai-daily-main/tests/push_news.py | 168 + ai-daily-main/tests/pytest/__init__.py | 1 + ai-daily-main/tests/pytest/conftest.py | 154 + .../pytest/fixtures/github_trending.html | 3771 ++++++++++++++++ .../tests/pytest/fixtures/hn_frontpage.html | 3 + ai-daily-main/tests/pytest/test_config.py | 249 ++ ai-daily-main/tests/pytest/test_fetcher.py | 190 + ai-daily-main/tests/pytest/test_llm.py | 330 ++ .../tests/pytest/test_llm_extra_sections.py | 101 + ai-daily-main/tests/pytest/test_main.py | 285 ++ .../tests/pytest/test_main_morning_push.py | 123 + .../tests/pytest/test_main_run_push_job.py | 43 + .../tests/pytest/test_morning_detection.py | 60 + ai-daily-main/tests/pytest/test_processor.py | 87 + ai-daily-main/tests/pytest/test_push.py | 209 + .../pytest/test_sections_github_enricher.py | 107 + .../pytest/test_sections_github_scraper.py | 40 + .../pytest/test_sections_github_section.py | 145 + .../test_sections_hackernews_enricher.py | 246 ++ .../test_sections_hackernews_scraper.py | 59 + .../test_sections_hackernews_section.py | 111 + .../tests/pytest/test_sections_insights.py | 66 + .../tests/pytest/test_sections_rss.py | 76 + ai-daily-main/tests/pytest/test_storage.py | 257 ++ .../tests/pytest/test_storage_sections.py | 163 + ai-daily-main/tests/pytest/test_timezone.py | 101 + ai-daily-main/tests/resend_notify.py | 142 + ai-daily-main/tests/resend_push.py | 108 + ai-daily-main/tests/run_llm_test.py | 383 ++ ai-daily-main/tests/run_morning_push.py | 30 + ai-daily-main/tests/test_cleanup_old_files.py | 138 + ai-daily-main/tests/test_fetch_lookback.py | 213 + ai-daily-main/tests/test_insights_section.py | 93 + ai-daily-main/tests/test_push_loop.py | 113 + ai-daily-main/uv.lock | 889 ++++ config/.env.example | 17 + config/rss_feeds.txt | 20 + docker-compose.yml | 50 + my-daily/.dockerignore | 56 + my-daily/.env.example | 28 + my-daily/.gitignore | 9 + my-daily/001.txt | 1443 +++++++ my-daily/CHANGELOG.md | 140 + my-daily/CONFIG.md | 251 ++ my-daily/Dockerfile | 42 + my-daily/QUICKSTART.md | 69 + my-daily/README.md | 517 +++ my-daily/config.json.example | 99 + my-daily/docker-compose.nas.yml | 30 + my-daily/docker-compose.yml | 39 + .../20260529_新闻智能分析系统开发计划_v3.md | 1249 ++++++ my-daily/docs/PROGRESS.md | 227 + my-daily/docs/prompt_diff_7.1.md | 158 + my-daily/docs/prompt_review_7.1.md | 433 ++ my-daily/docs/web_archive_design.md | 522 +++ my-daily/main.py | 1012 +++++ my-daily/prompts/category_overview.md | 16 + my-daily/prompts/classify_angle.md | 57 + my-daily/prompts/cluster_insight.md | 73 + my-daily/prompts/cluster_summary.md | 20 + my-daily/prompts/digest.md | 29 + my-daily/prompts/extract_entities.md | 49 + my-daily/prompts/immediate_push.md | 19 + my-daily/prompts/precise_heat.md | 60 + my-daily/prompts/score_batch.md | 50 + my-daily/prompts/webzine.md | 55 + my-daily/requirements.txt | 10 + my-daily/resources/rss_feeds copy.opml | 144 + my-daily/resources/rss_feeds.opml | 144 + my-daily/scripts/analyze_categories.py | 93 + my-daily/scripts/audit_clusters.py | 57 + my-daily/scripts/check_sources.py | 227 + my-daily/scripts/debug_cluster_2.py | 70 + my-daily/scripts/demo_memory_intercept.py | 88 + my-daily/scripts/diagnose_score_errors.py | 142 + my-daily/scripts/gen_test_data.py | 135 + my-daily/scripts/install.sh | 59 + my-daily/scripts/setup_service.bat | 179 + my-daily/scripts/setup_systemd.sh | 70 + my-daily/scripts/sort_opml.py | 191 + my-daily/scripts/status.sh | 42 + my-daily/scripts/test_cluster.py | 288 ++ my-daily/scripts/test_cluster_insight.py | 81 + my-daily/scripts/test_cluster_memory.py | 104 + my-daily/scripts/test_cluster_real.py | 105 + my-daily/scripts/test_cross_day.py | 39 + my-daily/scripts/test_daily_dir_upload.py | 107 + my-daily/scripts/test_entity_extract.py | 91 + my-daily/scripts/test_env_override.py | 38 + my-daily/scripts/test_feedback.py | 64 + my-daily/scripts/test_feedback_full.py | 144 + my-daily/scripts/test_feishu_image_push.py | 94 + my-daily/scripts/test_file_naming.py | 62 + my-daily/scripts/test_formatting.py | 94 + my-daily/scripts/test_heat.py | 69 + my-daily/scripts/test_heat_config.py | 41 + my-daily/scripts/test_immediate_push.py | 76 + my-daily/scripts/test_integration.py | 102 + my-daily/scripts/test_llm_disambiguate.py | 153 + my-daily/scripts/test_normalizer.py | 222 + my-daily/scripts/test_original_filename.py | 95 + my-daily/scripts/test_precise_heat.py | 80 + my-daily/scripts/test_prompt_checker.py | 88 + my-daily/scripts/test_rank.py | 55 + my-daily/scripts/test_rank_decay.py | 31 + my-daily/scripts/test_rescore.py | 41 + my-daily/scripts/test_tracking.py | 61 + my-daily/scripts/test_trend.py | 37 + my-daily/scripts/test_webzine_image.py | 134 + my-daily/scripts/uninstall.sh | 16 + my-daily/scripts/uninstall_systemd.sh | 34 + my-daily/scripts/verify_categories.py | 74 + my-daily/src/__init__.py | 0 my-daily/src/cache.py | 220 + my-daily/src/config.py | 141 + my-daily/src/fetch_pipeline.py | 590 +++ my-daily/src/fetcher.py | 118 + my-daily/src/generators/get_chinese_font.py | 39 + my-daily/src/generators/image_renderer.py | 307 ++ my-daily/src/integrators/__init__.py | 1 + .../src/integrators/summary_integrator.py | 100 + my-daily/src/llm.py | 481 +++ my-daily/src/logger.py | 16 + my-daily/src/markdown_utils.py | 51 + my-daily/src/monitor.py | 121 + my-daily/src/processor.py | 19 + my-daily/src/processors/__init__.py | 0 my-daily/src/processors/cluster_engine.py | 212 + my-daily/src/processors/cluster_memory.py | 157 + my-daily/src/processors/cross_day.py | 236 + my-daily/src/processors/entity_aliases.json | 313 ++ my-daily/src/processors/entity_extractor.py | 195 + my-daily/src/processors/entity_normalizer.py | 476 ++ my-daily/src/processors/feedback.py | 210 + my-daily/src/processors/heat_calculator.py | 238 + my-daily/src/processors/rank_engine.py | 86 + my-daily/src/processors/trend_analyzer.py | 175 + my-daily/src/push/__init__.py | 38 + my-daily/src/push/feishu.py | 118 + my-daily/src/push/image_hosting.py | 99 + my-daily/src/renderer.py | 42 + my-daily/src/scoring.py | 161 + my-daily/src/sections/__init__.py | 0 my-daily/src/sections/rss/__init__.py | 0 my-daily/src/sections/rss/section.py | 86 + my-daily/src/sections/webzine/__init__.py | 0 my-daily/src/sections/webzine/section.py | 214 + my-daily/src/storage.py | 239 + my-daily/src/translator.py | 103 + my-daily/src/utils.py | 31 + my-daily/src/utils/__init__.py | 14 + my-daily/src/utils/prompt_checker.py | 218 + my-daily/start.py | 126 + my-daily/systemd/my-daily-fetch.service.tmpl | 16 + my-daily/systemd/my-daily-fetch.timer.tmpl | 9 + my-daily/systemd/my-daily-push.service.tmpl | 16 + my-daily/systemd/my-daily-push.timer.tmpl | 9 + my-daily/templates/article_view.md | 15 + my-daily/templates/cluster_view.md | 27 + my-daily/templates/immediate_push.md | 21 + my-daily/web_archive/__init__.py | 1 + my-daily/web_archive/__main__.py | 30 + my-daily/web_archive/data_parser.py | 365 ++ my-daily/web_archive/server.py | 66 + my-daily/web_archive/static/css/archive.css | 600 +++ my-daily/web_archive/static/js/archive.js | 458 ++ my-daily/web_archive/templates/archive.html | 97 + requirements.txt | 4 + scripts/military_daily_report_v3.py | 353 ++ scripts/modules/__init__.py | 22 + scripts/modules/ai_analyzer.py | 241 ++ scripts/modules/async_pipeline.py | 228 + scripts/modules/cache.py | 310 ++ scripts/modules/config.py | 198 + scripts/modules/get_chinese_font.py | 45 + scripts/modules/image_generator.py | 208 + scripts/modules/keyword_filter.py | 24 + scripts/modules/logger.py | 29 + scripts/modules/monitor.py | 162 + scripts/modules/pusher.py | 154 + scripts/modules/rss_fetcher.py | 99 + scripts/modules/translator.py | 194 + scripts/modules/utils.py | 133 + test_deduplicate.py | 95 + test_image_gen.py | 29 + 系统优化方案指南.md | 359 ++ 订阅源/generate_opml.py | 77 + 订阅源/merge_feeds.py | 88 + 订阅源/rss_feed_list_高质量 copy.txt | 137 + 订阅源/rss_feeds.txt | 20 + 订阅源/rss_feeds_merged.opml | 139 + 订阅源/rss_feeds_merged.txt | 135 + 267 files changed, 47047 insertions(+) create mode 100644 .gitignore create mode 100644 01主函数过长问题.md create mode 100644 02图片生成代码重复问题.md create mode 100644 1.md create mode 100644 20260529_新闻智能分析系统开发计划_v2.md create mode 100644 20260529_新闻智能分析系统开发计划_v3.md create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 ai-daily-main.zip create mode 100644 ai-daily-main/.env.example create mode 100644 ai-daily-main/.gitignore create mode 100644 ai-daily-main/CLAUDE.md create mode 100644 ai-daily-main/LICENSE create mode 100644 ai-daily-main/README.md create mode 100644 ai-daily-main/config.json.example create mode 100644 ai-daily-main/docs/extra-sections-design.md create mode 100644 ai-daily-main/docs/plan.md create mode 100644 ai-daily-main/docs/superpowers/plans/2026-05-17-extra-sections.md create mode 100644 ai-daily-main/docs/tech-spec.md create mode 100644 ai-daily-main/prompts/digest.md create mode 100644 ai-daily-main/prompts/immediate_push.md create mode 100644 ai-daily-main/prompts/insights.md create mode 100644 ai-daily-main/prompts/score_batch.md create mode 100644 ai-daily-main/prompts/section_github.md create mode 100644 ai-daily-main/prompts/section_hackernews.md create mode 100644 ai-daily-main/prompts/section_hackernews_select.md create mode 100644 ai-daily-main/pyproject.toml create mode 100644 ai-daily-main/requirements.txt create mode 100644 ai-daily-main/resources/hero-banner.svg create mode 100644 ai-daily-main/resources/rss.opml create mode 100644 ai-daily-main/scripts/_gen_units.py create mode 100644 ai-daily-main/scripts/daily-news.tmpl create mode 100644 ai-daily-main/scripts/install.sh create mode 100644 ai-daily-main/scripts/status.sh create mode 100644 ai-daily-main/scripts/uninstall.sh create mode 100644 ai-daily-main/src/__init__.py create mode 100644 ai-daily-main/src/config.py create mode 100644 ai-daily-main/src/fetcher.py create mode 100644 ai-daily-main/src/llm.py create mode 100644 ai-daily-main/src/main.py create mode 100644 ai-daily-main/src/markdown_utils.py create mode 100644 ai-daily-main/src/processor.py create mode 100644 ai-daily-main/src/push/__init__.py create mode 100644 ai-daily-main/src/push/base.py create mode 100644 ai-daily-main/src/push/custom.py create mode 100644 ai-daily-main/src/push/discord.py create mode 100644 ai-daily-main/src/push/feishu.py create mode 100644 ai-daily-main/src/sections/__init__.py create mode 100644 ai-daily-main/src/sections/github/__init__.py create mode 100644 ai-daily-main/src/sections/github/repo_enricher.py create mode 100644 ai-daily-main/src/sections/github/section.py create mode 100644 ai-daily-main/src/sections/github/trending_scraper.py create mode 100644 ai-daily-main/src/sections/hackernews/__init__.py create mode 100644 ai-daily-main/src/sections/hackernews/frontpage_scraper.py create mode 100644 ai-daily-main/src/sections/hackernews/item_enricher.py create mode 100644 ai-daily-main/src/sections/hackernews/section.py create mode 100644 ai-daily-main/src/sections/insights/__init__.py create mode 100644 ai-daily-main/src/sections/insights/section.py create mode 100644 ai-daily-main/src/sections/rss/__init__.py create mode 100644 ai-daily-main/src/sections/rss/section.py create mode 100644 ai-daily-main/src/storage.py create mode 100644 ai-daily-main/systemd/dnews-fetch.service.tmpl create mode 100644 ai-daily-main/systemd/dnews-fetch.timer.tmpl create mode 100644 ai-daily-main/systemd/dnews-push.service.tmpl create mode 100644 ai-daily-main/systemd/dnews-push.timer.tmpl create mode 100644 ai-daily-main/systemd/journald-dnews.conf.tmpl create mode 100644 ai-daily-main/tests/__init__.py create mode 100644 ai-daily-main/tests/fetch_hackernews.py create mode 100644 ai-daily-main/tests/fetch_news.py create mode 100644 ai-daily-main/tests/fetch_nitter.py create mode 100644 ai-daily-main/tests/fetch_trending.py create mode 100644 ai-daily-main/tests/manual_trigger_fetch_loop_llm_error.py create mode 100644 ai-daily-main/tests/preview_recent_context.py create mode 100644 ai-daily-main/tests/probe_hn_comments.py create mode 100644 ai-daily-main/tests/push_news.py create mode 100644 ai-daily-main/tests/pytest/__init__.py create mode 100644 ai-daily-main/tests/pytest/conftest.py create mode 100644 ai-daily-main/tests/pytest/fixtures/github_trending.html create mode 100644 ai-daily-main/tests/pytest/fixtures/hn_frontpage.html create mode 100644 ai-daily-main/tests/pytest/test_config.py create mode 100644 ai-daily-main/tests/pytest/test_fetcher.py create mode 100644 ai-daily-main/tests/pytest/test_llm.py create mode 100644 ai-daily-main/tests/pytest/test_llm_extra_sections.py create mode 100644 ai-daily-main/tests/pytest/test_main.py create mode 100644 ai-daily-main/tests/pytest/test_main_morning_push.py create mode 100644 ai-daily-main/tests/pytest/test_main_run_push_job.py create mode 100644 ai-daily-main/tests/pytest/test_morning_detection.py create mode 100644 ai-daily-main/tests/pytest/test_processor.py create mode 100644 ai-daily-main/tests/pytest/test_push.py create mode 100644 ai-daily-main/tests/pytest/test_sections_github_enricher.py create mode 100644 ai-daily-main/tests/pytest/test_sections_github_scraper.py create mode 100644 ai-daily-main/tests/pytest/test_sections_github_section.py create mode 100644 ai-daily-main/tests/pytest/test_sections_hackernews_enricher.py create mode 100644 ai-daily-main/tests/pytest/test_sections_hackernews_scraper.py create mode 100644 ai-daily-main/tests/pytest/test_sections_hackernews_section.py create mode 100644 ai-daily-main/tests/pytest/test_sections_insights.py create mode 100644 ai-daily-main/tests/pytest/test_sections_rss.py create mode 100644 ai-daily-main/tests/pytest/test_storage.py create mode 100644 ai-daily-main/tests/pytest/test_storage_sections.py create mode 100644 ai-daily-main/tests/pytest/test_timezone.py create mode 100644 ai-daily-main/tests/resend_notify.py create mode 100644 ai-daily-main/tests/resend_push.py create mode 100644 ai-daily-main/tests/run_llm_test.py create mode 100644 ai-daily-main/tests/run_morning_push.py create mode 100644 ai-daily-main/tests/test_cleanup_old_files.py create mode 100644 ai-daily-main/tests/test_fetch_lookback.py create mode 100644 ai-daily-main/tests/test_insights_section.py create mode 100644 ai-daily-main/tests/test_push_loop.py create mode 100644 ai-daily-main/uv.lock create mode 100644 config/.env.example create mode 100644 config/rss_feeds.txt create mode 100644 docker-compose.yml create mode 100644 my-daily/.dockerignore create mode 100644 my-daily/.env.example create mode 100644 my-daily/.gitignore create mode 100644 my-daily/001.txt create mode 100644 my-daily/CHANGELOG.md create mode 100644 my-daily/CONFIG.md create mode 100644 my-daily/Dockerfile create mode 100644 my-daily/QUICKSTART.md create mode 100644 my-daily/README.md create mode 100644 my-daily/config.json.example create mode 100644 my-daily/docker-compose.nas.yml create mode 100644 my-daily/docker-compose.yml create mode 100644 my-daily/docs/20260529_新闻智能分析系统开发计划_v3.md create mode 100644 my-daily/docs/PROGRESS.md create mode 100644 my-daily/docs/prompt_diff_7.1.md create mode 100644 my-daily/docs/prompt_review_7.1.md create mode 100644 my-daily/docs/web_archive_design.md create mode 100644 my-daily/main.py create mode 100644 my-daily/prompts/category_overview.md create mode 100644 my-daily/prompts/classify_angle.md create mode 100644 my-daily/prompts/cluster_insight.md create mode 100644 my-daily/prompts/cluster_summary.md create mode 100644 my-daily/prompts/digest.md create mode 100644 my-daily/prompts/extract_entities.md create mode 100644 my-daily/prompts/immediate_push.md create mode 100644 my-daily/prompts/precise_heat.md create mode 100644 my-daily/prompts/score_batch.md create mode 100644 my-daily/prompts/webzine.md create mode 100644 my-daily/requirements.txt create mode 100644 my-daily/resources/rss_feeds copy.opml create mode 100644 my-daily/resources/rss_feeds.opml create mode 100644 my-daily/scripts/analyze_categories.py create mode 100644 my-daily/scripts/audit_clusters.py create mode 100644 my-daily/scripts/check_sources.py create mode 100644 my-daily/scripts/debug_cluster_2.py create mode 100644 my-daily/scripts/demo_memory_intercept.py create mode 100644 my-daily/scripts/diagnose_score_errors.py create mode 100644 my-daily/scripts/gen_test_data.py create mode 100644 my-daily/scripts/install.sh create mode 100644 my-daily/scripts/setup_service.bat create mode 100644 my-daily/scripts/setup_systemd.sh create mode 100644 my-daily/scripts/sort_opml.py create mode 100644 my-daily/scripts/status.sh create mode 100644 my-daily/scripts/test_cluster.py create mode 100644 my-daily/scripts/test_cluster_insight.py create mode 100644 my-daily/scripts/test_cluster_memory.py create mode 100644 my-daily/scripts/test_cluster_real.py create mode 100644 my-daily/scripts/test_cross_day.py create mode 100644 my-daily/scripts/test_daily_dir_upload.py create mode 100644 my-daily/scripts/test_entity_extract.py create mode 100644 my-daily/scripts/test_env_override.py create mode 100644 my-daily/scripts/test_feedback.py create mode 100644 my-daily/scripts/test_feedback_full.py create mode 100644 my-daily/scripts/test_feishu_image_push.py create mode 100644 my-daily/scripts/test_file_naming.py create mode 100644 my-daily/scripts/test_formatting.py create mode 100644 my-daily/scripts/test_heat.py create mode 100644 my-daily/scripts/test_heat_config.py create mode 100644 my-daily/scripts/test_immediate_push.py create mode 100644 my-daily/scripts/test_integration.py create mode 100644 my-daily/scripts/test_llm_disambiguate.py create mode 100644 my-daily/scripts/test_normalizer.py create mode 100644 my-daily/scripts/test_original_filename.py create mode 100644 my-daily/scripts/test_precise_heat.py create mode 100644 my-daily/scripts/test_prompt_checker.py create mode 100644 my-daily/scripts/test_rank.py create mode 100644 my-daily/scripts/test_rank_decay.py create mode 100644 my-daily/scripts/test_rescore.py create mode 100644 my-daily/scripts/test_tracking.py create mode 100644 my-daily/scripts/test_trend.py create mode 100644 my-daily/scripts/test_webzine_image.py create mode 100644 my-daily/scripts/uninstall.sh create mode 100644 my-daily/scripts/uninstall_systemd.sh create mode 100644 my-daily/scripts/verify_categories.py create mode 100644 my-daily/src/__init__.py create mode 100644 my-daily/src/cache.py create mode 100644 my-daily/src/config.py create mode 100644 my-daily/src/fetch_pipeline.py create mode 100644 my-daily/src/fetcher.py create mode 100644 my-daily/src/generators/get_chinese_font.py create mode 100644 my-daily/src/generators/image_renderer.py create mode 100644 my-daily/src/integrators/__init__.py create mode 100644 my-daily/src/integrators/summary_integrator.py create mode 100644 my-daily/src/llm.py create mode 100644 my-daily/src/logger.py create mode 100644 my-daily/src/markdown_utils.py create mode 100644 my-daily/src/monitor.py create mode 100644 my-daily/src/processor.py create mode 100644 my-daily/src/processors/__init__.py create mode 100644 my-daily/src/processors/cluster_engine.py create mode 100644 my-daily/src/processors/cluster_memory.py create mode 100644 my-daily/src/processors/cross_day.py create mode 100644 my-daily/src/processors/entity_aliases.json create mode 100644 my-daily/src/processors/entity_extractor.py create mode 100644 my-daily/src/processors/entity_normalizer.py create mode 100644 my-daily/src/processors/feedback.py create mode 100644 my-daily/src/processors/heat_calculator.py create mode 100644 my-daily/src/processors/rank_engine.py create mode 100644 my-daily/src/processors/trend_analyzer.py create mode 100644 my-daily/src/push/__init__.py create mode 100644 my-daily/src/push/feishu.py create mode 100644 my-daily/src/push/image_hosting.py create mode 100644 my-daily/src/renderer.py create mode 100644 my-daily/src/scoring.py create mode 100644 my-daily/src/sections/__init__.py create mode 100644 my-daily/src/sections/rss/__init__.py create mode 100644 my-daily/src/sections/rss/section.py create mode 100644 my-daily/src/sections/webzine/__init__.py create mode 100644 my-daily/src/sections/webzine/section.py create mode 100644 my-daily/src/storage.py create mode 100644 my-daily/src/translator.py create mode 100644 my-daily/src/utils.py create mode 100644 my-daily/src/utils/__init__.py create mode 100644 my-daily/src/utils/prompt_checker.py create mode 100644 my-daily/start.py create mode 100644 my-daily/systemd/my-daily-fetch.service.tmpl create mode 100644 my-daily/systemd/my-daily-fetch.timer.tmpl create mode 100644 my-daily/systemd/my-daily-push.service.tmpl create mode 100644 my-daily/systemd/my-daily-push.timer.tmpl create mode 100644 my-daily/templates/article_view.md create mode 100644 my-daily/templates/cluster_view.md create mode 100644 my-daily/templates/immediate_push.md create mode 100644 my-daily/web_archive/__init__.py create mode 100644 my-daily/web_archive/__main__.py create mode 100644 my-daily/web_archive/data_parser.py create mode 100644 my-daily/web_archive/server.py create mode 100644 my-daily/web_archive/static/css/archive.css create mode 100644 my-daily/web_archive/static/js/archive.js create mode 100644 my-daily/web_archive/templates/archive.html create mode 100644 requirements.txt create mode 100644 scripts/military_daily_report_v3.py create mode 100644 scripts/modules/__init__.py create mode 100644 scripts/modules/ai_analyzer.py create mode 100644 scripts/modules/async_pipeline.py create mode 100644 scripts/modules/cache.py create mode 100644 scripts/modules/config.py create mode 100644 scripts/modules/get_chinese_font.py create mode 100644 scripts/modules/image_generator.py create mode 100644 scripts/modules/keyword_filter.py create mode 100644 scripts/modules/logger.py create mode 100644 scripts/modules/monitor.py create mode 100644 scripts/modules/pusher.py create mode 100644 scripts/modules/rss_fetcher.py create mode 100644 scripts/modules/translator.py create mode 100644 scripts/modules/utils.py create mode 100644 test_deduplicate.py create mode 100644 test_image_gen.py create mode 100644 系统优化方案指南.md create mode 100644 订阅源/generate_opml.py create mode 100644 订阅源/merge_feeds.py create mode 100644 订阅源/rss_feed_list_高质量 copy.txt create mode 100644 订阅源/rss_feeds.txt create mode 100644 订阅源/rss_feeds_merged.opml create mode 100644 订阅源/rss_feeds_merged.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7de6853 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# 环境配置(含敏感凭据) +.env +config/.env +my-daily/.env +config.json +my-daily/config.json + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +*.egg +dist/ +build/ + +# 虚拟环境 +venv/ +.venv/ +env/ +.mypy_cache/ + +# 运行时数据 +data/ +output/ +logs/ +cache/ + +# Docker 镜像(太大) +*.tar + +# 日志 +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Docker +.docker/ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/01主函数过长问题.md b/01主函数过长问题.md new file mode 100644 index 0000000..897b393 --- /dev/null +++ b/01主函数过长问题.md @@ -0,0 +1,727 @@ +# 1. 问题 + +军事科技每日摘报的 `main()` 函数(位于 `scripts/military_daily_report_v3.py` 第38-246行)长达208行,承担了整个业务流程的11个步骤,严重违反单一职责原则。 + +## 1.1. **职责过多,难以理解** + +当前 `main()` 函数包含了从RSS抓取到报告输出的完整业务流程,涉及数据获取、清洗、翻译、AI分析、格式化等多个不同层次的职责。开发者需要在200多行代码中理解整个业务逻辑,认知负担过重。 + +**问题代码片段(第38-246行)**: + +```python +def main(): + now = datetime.now() + cutoff = now - timedelta(hours=TIME_WINDOW_HOURS) + date_str = now.strftime('%Y%m%d') + + # 1. RSS 抓取 + all_articles = fetch_all_feeds() + + # 2. 24小时时间过滤 + recent_articles = [a for a in all_articles if a['published'] >= cutoff] + + # 3. 标题去重 + seen = set() + unique_articles = [] + for a in recent_articles: + key = a['title'].strip() + if key not in seen and len(key) > 5: + seen.add(key) + unique_articles.append(a) + + # 4. 排序 + all_time_valid = sorted(unique_articles, key=lambda x: x['published'], reverse=True) + + # 5. 加载AI配置 + 批量翻译 + api_key, base_url, model, webzine_model = load_ai_config() + translated_articles = batch_translate_articles(all_time_valid, api_key, base_url, model, max_concurrent=2) + + # 6. 关键词筛选 + if len(translated_articles) > 15: + filtered_articles = keyword_filter(translated_articles) + else: + filtered_articles = translated_articles + + # 7. AI分析 + for i, article in enumerate(filtered_articles, 1): + ai_result = score_and_classify_article(article, api_key, base_url, model) + article['ai_result'] = ai_result + article['final_score'] = calculate_weighted_score(ai_result) + article['summary'] = ai_result.get('summary', article['translated_title']) + article['category'] = ai_result.get('category', '装备动态') + article['scores'] = ai_result.get('scores', {}) + + # 8. 按评分排序 + filtered_articles.sort(key=lambda x: x['final_score'], reverse=True) + + # 9. 生成TOP3网摘 + today_top3 = filtered_articles[:3] + # ... 网摘生成代码 + + # 10. 分类整理 + 生成分类洞察 + # ... 分类整理代码 + + # 11. 生成Markdown报告 + # ... 报告生成代码 +``` + +**问题分析**: + +* 11个业务步骤混在一个函数中,每个步骤的逻辑细节都暴露在主流程中 + +* 变量名重复使用(如 `filtered_articles`),容易产生混淆 + +* 中间状态(如 `today_top3`、`equipment_articles` 等)难以追踪 + +* 无法快速定位某个具体步骤的代码位置 + +## 1.2. **难以测试,无法复用** + +由于所有逻辑都在 `main()` 函数中,无法对单个业务步骤进行独立的单元测试。例如,想要测试"去重逻辑"或"分类整理逻辑",必须运行整个流程,这大大增加了测试成本和复杂度。 + +**问题分析**: + +* 去重逻辑(第62-71行)嵌入在主函数中,无法单独测试 + +* 分类整理逻辑(第175-185行)无法独立验证 + +* 网摘生成逻辑(第127-159行)与文件IO耦合,难以mock测试 + +* 想要复用某个步骤(如只做RSS抓取和去重)无法实现 + +## 1.3. **维护困难,容易出错** + +当需要修改某个业务步骤时,开发者必须在200多行代码中定位相关逻辑,容易遗漏或引入错误。例如,如果需要调整"关键词筛选"的触发条件,需要在第88-93行找到相关代码,但很容易在复杂的流程中迷失。 + +**问题分析**: + +* 修改某个步骤时,需要理解整个上下文 + +* 容易在修改时影响其他步骤的逻辑 + +* 代码审查时难以快速定位修改点 + +* 新人上手成本高,需要阅读大量代码才能理解业务流程 + +# 2. 收益 + +通过将 `main()` 函数拆分为多个职责明确的子函数,可以显著提升代码的可读性、可测试性和可维护性。 + +## 2.1. **提升代码可读性** + +重构后,每个业务步骤都有独立的函数,函数名清晰表达了其职责。主函数变成一个简洁的流程编排器,一眼就能看出整个业务流程的11个步骤。 + +**预期改进**: + +* 主函数从208行缩减到约30行 + +* 每个子函数平均15-30行,职责单一 + +* 函数名直接表达业务含义(如 `filter_by_time_window`、`deduplicate_articles`) + +* 代码结构清晰,新人可以在5分钟内理解整体流程 + +## 2.2. **提升可测试性** + +每个业务步骤都可以独立进行单元测试,无需运行整个流程。可以mock输入数据,验证每个步骤的输出是否符合预期。 + +**预期改进**: + +* 可以为每个子函数编写独立的单元测试 + +* 测试覆盖率可以从当前的接近0%提升到80%以上 + +* 测试执行时间从分钟级降低到秒级 + +* 可以快速定位和修复bug + +## 2.3. **提升可维护性** + +当需要修改某个业务步骤时,只需要定位到对应的子函数,修改范围明确,不会影响其他步骤。代码审查时也可以快速定位修改点。 + +**预期改进**: + +* 修改某个步骤时,只需要关注对应的子函数 + +* 代码审查时可以快速定位修改的函数 + +* 降低引入新bug的风险 + +* 提高开发效率 + +## 2.4. **提升代码复用性** + +业务步骤可以独立使用,例如只做RSS抓取和去重,或者只做AI分析。这为后续的功能扩展提供了灵活性。 + +**预期改进**: + +* 可以单独调用某个业务步骤 + +* 可以组合不同的步骤实现新的功能 + +* 为后续的功能扩展提供基础 + +# 3. 方案 + +将 `main()` 函数拆分为11个职责明确的子函数,每个函数负责一个独立的业务步骤。主函数变成一个简洁的流程编排器,负责调用这些子函数。 + +## 3.1. **函数拆分设计** + +```mermaid +flowchart TD + A[main] --> B[fetch_and_filter_articles] + B --> C[translate_articles] + C --> D[filter_by_keywords] + D --> E[analyze_articles] + E --> F[generate_webzine] + F --> G[generate_report] + + B --> B1[fetch_all_feeds] + B --> B2[filter_by_time_window] + B --> B3[deduplicate_articles] + B --> B4[sort_by_publish_time] + + E --> E1[ai_score_and_classify] + E --> E2[sort_by_score] + + G --> G1[categorize_articles] + G --> G2[generate_category_overviews] + G --> G3[write_markdown_report] + + style A fill:#e1f5e1 + style B fill:#fff4e1 + style C fill:#fff4e1 + style D fill:#fff4e1 + style E fill:#fff4e1 + style F fill:#fff4e1 + style G fill:#fff4e1 +``` + +**图表说明**: + +* 绿色节点表示主函数,负责流程编排 + +* 黄色节点表示拆分后的子函数,每个函数负责一个独立的业务步骤 + +* 箭头表示调用关系 + +* 通过这种拆分,主函数变得简洁,每个子函数职责单一 + +## 3.2. **函数拆分实现** + +### **步骤1:提取数据获取和过滤逻辑** + +**问题代码**: + +```python +def main(): + # 1. RSS 抓取 + all_articles = fetch_all_feeds() + + # 2. 24小时时间过滤 + recent_articles = [a for a in all_articles if a['published'] >= cutoff] + + # 3. 标题去重 + seen = set() + unique_articles = [] + for a in recent_articles: + key = a['title'].strip() + if key not in seen and len(key) > 5: + seen.add(key) + unique_articles.append(a) + + # 4. 排序 + all_time_valid = sorted(unique_articles, key=lambda x: x['published'], reverse=True) +``` + +**重构后代码**: + +```python +def filter_by_time_window(articles, cutoff): + """过滤出指定时间窗口内的文章""" + return [a for a in articles if a['published'] >= cutoff] + + +def deduplicate_articles(articles): + """根据标题去重文章""" + seen = set() + unique_articles = [] + for a in articles: + key = a['title'].strip() + if key not in seen and len(key) > 5: + seen.add(key) + unique_articles.append(a) + return unique_articles + + +def sort_by_publish_time(articles, reverse=True): + """按发布时间排序文章""" + return sorted(articles, key=lambda x: x['published'], reverse=reverse) + + +def fetch_and_filter_articles(cutoff): + """获取并过滤文章:抓取 -> 时间过滤 -> 去重 -> 排序""" + all_articles = fetch_all_feeds() + recent_articles = filter_by_time_window(all_articles, cutoff) + unique_articles = deduplicate_articles(recent_articles) + return sort_by_publish_time(unique_articles) +``` + +**改进点**: + +* 每个函数职责单一,函数名清晰表达其功能 + +* 可以独立测试每个步骤 + +* 可以单独复用某个步骤 + +### **步骤2:提取翻译和关键词筛选逻辑** + +**问题代码**: + +```python +def main(): + # 5. 加载AI配置 + 批量翻译 + api_key, base_url, model, webzine_model = load_ai_config() + translated_articles = batch_translate_articles(all_time_valid, api_key, base_url, model, max_concurrent=2) + + # 6. 关键词筛选 + if len(translated_articles) > 15: + filtered_articles = keyword_filter(translated_articles) + else: + filtered_articles = translated_articles +``` + +**重构后代码**: + +```python +def filter_by_keywords(articles): + """根据关键词筛选文章(仅当文章数 > 15 时执行)""" + if len(articles) > 15: + return keyword_filter(articles) + return articles + + +def translate_articles(articles, api_key, base_url, model): + """批量翻译外文文章""" + return batch_translate_articles(articles, api_key, base_url, model, max_concurrent=2) +``` + +**改进点**: + +* 将关键词筛选的条件判断封装在函数内部 + +* 翻译逻辑独立,可以单独测试 + +### **步骤3:提取AI分析和排序逻辑** + +**问题代码**: + +```python +def main(): + # 7. AI分析 + for i, article in enumerate(filtered_articles, 1): + ai_result = score_and_classify_article(article, api_key, base_url, model) + article['ai_result'] = ai_result + article['final_score'] = calculate_weighted_score(ai_result) + article['summary'] = ai_result.get('summary', article['translated_title']) + article['category'] = ai_result.get('category', '装备动态') + article['scores'] = ai_result.get('scores', {}) + + # 8. 按评分排序 + filtered_articles.sort(key=lambda x: x['final_score'], reverse=True) +``` + +**重构后代码**: + +```python +def ai_score_and_classify(article, api_key, base_url, model): + """对单篇文章进行AI分析:评分、摘要、分类""" + ai_result = score_and_classify_article(article, api_key, base_url, model) + article['ai_result'] = ai_result + article['final_score'] = calculate_weighted_score(ai_result) + article['summary'] = ai_result.get('summary', article['translated_title']) + article['category'] = ai_result.get('category', '装备动态') + article['scores'] = ai_result.get('scores', {}) + return article + + +def analyze_articles(articles, api_key, base_url, model): + """对所有文章进行AI分析并按评分排序""" + for i, article in enumerate(articles, 1): + logger.info("处理中 %d/%d: %s...", i, len(articles), article['translated_title'][:35]) + ai_score_and_classify(article, api_key, base_url, model) + + # 按评分排序 + return sorted(articles, key=lambda x: x['final_score'], reverse=True) +``` + +**改进点**: + +* 将单篇文章的AI分析和批量分析分离 + +* 排序逻辑封装在函数内部 + +* 可以单独测试AI分析逻辑 + +### **步骤4:提取网摘生成逻辑** + +**问题代码**: + +```python +def main(): + # 9. 提取TOP3 + 生成《参考消息》风格网摘 + today_top3 = filtered_articles[:3] + + webzine_content = [] + webzine_content.append(f"═{'═'*60}═") + webzine_content.append(f" 🔥 军事科技每日摘报 - 今日必看 TOP3 网摘") + webzine_content.append(f" 📅 {now.strftime('%Y年%m月%d日')}") + webzine_content.append(f"═{'═'*60}═") + webzine_content.append("") + + webzine_texts = [] + for i, article in enumerate(today_top3, 1): + webzine = generate_webzine_for_article(article, api_key, base_url, webzine_model) + webzine_texts.append(webzine) + webzine_content.append(f"{'='*62}") + webzine_content.append(f" 📌 第 {i} 篇") + webzine_content.append(f"{'='*62}") + webzine_content.append("") + webzine_content.append(webzine) + webzine_content.append("") + webzine_content.append(f"🔗 原文链接:{article['link']}") + webzine_content.append("") + webzine_content.append("") + + # 保存网摘TXT文件 + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + webzine_file = OUTPUT_DIR / f"military_webzine_{date_str}.txt" + with open(webzine_file, 'w', encoding='utf-8') as f: + f.write('\n'.join(webzine_content)) + + # 生成合并长图 + webzine_image = OUTPUT_DIR / f"military_webzine_{date_str}.png" + create_combined_webzine_image(webzine_texts, str(webzine_image), now) +``` + +**重构后代码**: + +```python +def generate_webzine_content(top3_articles, now): + """生成网摘文本内容""" + webzine_content = [] + webzine_content.append(f"═{'═'*60}═") + webzine_content.append(f" 🔥 军事科技每日摘报 - 今日必看 TOP3 网摘") + webzine_content.append(f" 📅 {now.strftime('%Y年%m月%d日')}") + webzine_content.append(f"═{'═'*60}═") + webzine_content.append("") + + webzine_texts = [] + for i, article in enumerate(top3_articles, 1): + webzine = generate_webzine_for_article(article, api_key, base_url, webzine_model) + webzine_texts.append(webzine) + webzine_content.append(f"{'='*62}") + webzine_content.append(f" 📌 第 {i} 篇") + webzine_content.append(f"{'='*62}") + webzine_content.append("") + webzine_content.append(webzine) + webzine_content.append("") + webzine_content.append(f"🔗 原文链接:{article['link']}") + webzine_content.append("") + webzine_content.append("") + + return webzine_content, webzine_texts + + +def save_webzine_files(webzine_content, webzine_texts, date_str, now): + """保存网摘文件(TXT和PNG)""" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + # 保存TXT文件 + webzine_file = OUTPUT_DIR / f"military_webzine_{date_str}.txt" + with open(webzine_file, 'w', encoding='utf-8') as f: + f.write('\n'.join(webzine_content)) + logger.info("网摘文件已保存: %s", webzine_file) + + # 生成PNG文件 + webzine_image = OUTPUT_DIR / f"military_webzine_{date_str}.png" + create_combined_webzine_image(webzine_texts, str(webzine_image), now) + + +def generate_webzine(articles, api_key, base_url, webzine_model, date_str, now): + """生成TOP3网摘并保存文件""" + top3 = articles[:3] + webzine_content, webzine_texts = generate_webzine_content(top3, now) + save_webzine_files(webzine_content, webzine_texts, date_str, now) +``` + +**改进点**: + +* 将网摘内容生成和文件保存分离 + +* 可以独立测试网摘内容生成逻辑 + +* 文件IO逻辑集中,易于mock + +### **步骤5:提取分类整理和报告生成逻辑** + +**问题代码**: + +```python +def main(): + # 10. 分类整理 + 生成各分类深度洞察 + equipment_articles = [] + conflict_articles = [] + strategy_articles = [] + + for article in filtered_articles[3:]: + cat = article.get('category', '装备动态') + if '冲突' in cat or '战争' in cat or '地区' in cat: + conflict_articles.append(article) + elif '战略' in cat or '政策' in cat or '外交' in cat: + strategy_articles.append(article) + else: + equipment_articles.append(article) + + today_overview = generate_category_overview("今日必看", today_top3, api_key, base_url, model) + equipment_overview = generate_category_overview("装备动态", equipment_articles, api_key, base_url, model) + conflict_overview = generate_category_overview("地区冲突", conflict_articles, api_key, base_url, model) + strategy_overview = generate_category_overview("战略政策", strategy_articles, api_key, base_url, model) + + # 11. 生成 Markdown 报告文件 + md_lines = [] + md_lines.append(f"# 🔥 {now.year}年{now.month}月{now.day}日 军事科技每日摘报\n") + # ... 大量Markdown拼接代码 +``` + +**重构后代码**: + +```python +def categorize_articles(articles): + """将文章按分类整理""" + equipment_articles = [] + conflict_articles = [] + strategy_articles = [] + + for article in articles: + cat = article.get('category', '装备动态') + if '冲突' in cat or '战争' in cat or '地区' in cat: + conflict_articles.append(article) + elif '战略' in cat or '政策' in cat or '外交' in cat: + strategy_articles.append(article) + else: + equipment_articles.append(article) + + return equipment_articles, conflict_articles, strategy_articles + + +def generate_category_overviews(top3, equipment, conflict, strategy, api_key, base_url, model): + """生成各分类的总体介绍""" + today_overview = generate_category_overview("今日必看", top3, api_key, base_url, model) + equipment_overview = generate_category_overview("装备动态", equipment, api_key, base_url, model) + conflict_overview = generate_category_overview("地区冲突", conflict, api_key, base_url, model) + strategy_overview = generate_category_overview("战略政策", strategy, api_key, base_url, model) + + return { + 'today': today_overview, + 'equipment': equipment_overview, + 'conflict': conflict_overview, + 'strategy': strategy_overview + } + + +def write_markdown_report(articles, overviews, date_str, now): + """生成Markdown报告文件""" + top3 = articles[:3] + equipment, conflict, strategy = categorize_articles(articles[3:]) + + md_lines = [] + md_lines.append(f"# 🔥 {now.year}年{now.month}月{now.day}日 军事科技每日摘报\n") + md_lines.append(f"📊 今日共 {len(articles)} 篇精选文章 | ⏰ {now.strftime('%Y-%m-%d %H:%M')}\n") + + # 今日必看 + md_lines.append(f"## 🔥 今日必看\n") + if overviews['today']: + md_lines.append(f"> {overviews['today']}\n") + for i, article in enumerate(top3, 1): + md_lines.append(f"### {i}. {article['translated_title']}\n") + md_lines.append(f"- 📡 来源: {article['source']}") + md_lines.append(f"- ⏰ 时间: {article['published'].strftime('%m-%d %H:%M')}") + md_lines.append(f"- ⭐ 评分: {article['final_score']}/10") + md_lines.append(f"- 📝 {article['summary']}") + md_lines.append(f"- 🔗 [阅读原文]({article['link']})\n") + + # 装备动态 + if equipment: + md_lines.append(f"## ⚙️ 装备动态\n") + if overviews['equipment']: + md_lines.append(f"> {overviews['equipment']}\n") + for i, article in enumerate(equipment, 1): + md_lines.append(f"### {i}. {article['translated_title']}\n") + md_lines.append(f"- 📡 {article['source']} | ⏰ {article['published'].strftime('%m-%d %H:%M')}") + md_lines.append(f"- 📝 {article['summary']}") + md_lines.append(f"- 🔗 [阅读原文]({article['link']})\n") + + # 地区冲突 + if conflict: + md_lines.append(f"## 💥 地区冲突\n") + if overviews['conflict']: + md_lines.append(f"> {overviews['conflict']}\n") + for i, article in enumerate(conflict, 1): + md_lines.append(f"### {i}. {article['translated_title']}\n") + md_lines.append(f"- 📡 {article['source']} | ⏰ {article['published'].strftime('%m-%d %H:%M')}") + md_lines.append(f"- 📝 {article['summary']}") + md_lines.append(f"- 🔗 [阅读原文]({article['link']})\n") + + # 战略政策 + if strategy: + md_lines.append(f"## 🎯 战略政策\n") + if overviews['strategy']: + md_lines.append(f"> {overviews['strategy']}\n") + for i, article in enumerate(strategy, 1): + md_lines.append(f"### {i}. {article['translated_title']}\n") + md_lines.append(f"- 📡 {article['source']} | ⏰ {article['published'].strftime('%m-%d %H:%M')}") + md_lines.append(f"- 📝 {article['summary']}") + md_lines.append(f"- 🔗 [阅读原文]({article['link']})\n") + + # 保存文件 + report_file = OUTPUT_DIR / f"military_report_{date_str}.md" + with open(report_file, 'w', encoding='utf-8') as f: + f.write('\n'.join(md_lines)) + logger.info("报告文件已保存: %s", report_file) + + +def generate_report(articles, api_key, base_url, model, date_str, now): + """生成完整报告:分类整理 -> 生成分类洞察 -> 写入Markdown""" + equipment, conflict, strategy = categorize_articles(articles[3:]) + top3 = articles[:3] + + overviews = generate_category_overviews( + top3, equipment, conflict, strategy, api_key, base_url, model + ) + + write_markdown_report(articles, overviews, date_str, now) +``` + +**改进点**: + +* 将分类整理、分类洞察生成、报告写入分离 + +* 每个函数职责单一,易于测试 + +* Markdown生成逻辑集中,易于维护 + +### **步骤6:重构后的主函数** + +**重构后的主函数**: + +```python +def main(): + """主函数:流程编排""" + now = datetime.now() + cutoff = now - timedelta(hours=TIME_WINDOW_HOURS) + date_str = now.strftime('%Y%m%d') + + logger.info("开始抓取 %d 个军事科技 RSS 订阅源...", len(RSS_FEEDS)) + logger.info("时间窗口: %s ~ %s", cutoff.strftime('%m-%d %H:%M'), now.strftime('%m-%d %H:%M')) + + # 1. 获取并过滤文章 + articles = fetch_and_filter_articles(cutoff) + logger.info("总共抓取 %d 篇文章", len(articles)) + + if not articles: + logger.info("今日暂无新的军事科技文章") + return + + # 2. 翻译文章 + api_key, base_url, model, webzine_model = load_ai_config() + if not api_key: + logger.error("未找到OPENAI_API_KEY,退出") + return + + translated_articles = translate_articles(articles, api_key, base_url, model) + logger.info("翻译完成,共 %d 篇文章", len(translated_articles)) + + # 3. 关键词筛选 + filtered_articles = filter_by_keywords(translated_articles) + logger.info("筛选后: %d 篇", len(filtered_articles)) + + if not filtered_articles: + logger.info("筛选后无符合条件文章") + return + + # 4. AI分析 + logger.info("开始 AI 智能分析(使用 %s)...", model) + analyzed_articles = analyze_articles(filtered_articles, api_key, base_url, model) + logger.info("AI 评分完成,最高分: %.1f/10", analyzed_articles[0]['final_score']) + + # 5. 生成网摘 + logger.info("生成今日必看 TOP3 网摘(《参考消息》风格)...") + generate_webzine(analyzed_articles, api_key, base_url, webzine_model, date_str, now) + + # 6. 生成报告 + logger.info("生成 Markdown 报告文件...") + generate_report(analyzed_articles, api_key, base_url, model, date_str, now) +``` + +**改进点**: + +* 主函数从208行缩减到约50行 + +* 流程清晰,一眼就能看出整个业务流程 + +* 每个步骤都有清晰的注释 + +* 易于理解和维护 + +# 4. 回归范围 + +本次重构主要是代码结构调整,不改变业务逻辑,因此回归测试的重点是验证重构后的功能与重构前完全一致。 + +## 4.1. 主链路 + +**完整业务流程**: + +1. RSS抓取 → 时间过滤 → 去重 → 排序 → 翻译 → 关键词筛选 → AI分析 → 评分排序 → 网摘生成 → 报告输出 + +**关键验证点**: + +* RSS抓取能够正常获取12个订阅源的文章 + +* 时间过滤能够正确过滤出24小时内的文章 + +* 去重逻辑能够正确去除重复标题的文章 + +* 翻译功能能够正确翻译外文文章 + +* 关键词筛选在文章数 > 15 时能够正确执行 + +* AI分析能够正确生成评分、摘要和分类 + +* 网摘生成能够正确生成TOP3网摘的TXT和PNG文件 + +* 报告生成能够正确生成Markdown报告文件 + +## 4.2. 边界情况 + +**边界场景**: + +1. **无新文章场景**:当24小时内没有新文章时,程序应该正常退出并输出日志 +2. **筛选后无文章场景**:当关键词筛选后没有符合条件的文章时,程序应该正常退出并输出日志 +3. **AI配置缺失场景**:当未配置OPENAI\_API\_KEY时,程序应该正常退出并输出错误日志 +4. **文章数 <= 15场景**:当文章数 <= 15时,应该跳过关键词筛选,直接进入AI分析 +5. **分类为空场景**:当某个分类(如"地区冲突")没有文章时,报告中应该不显示该分类 +6. **翻译失败场景**:当翻译失败时,应该使用原标题和原文内容,不影响后续流程 +7. **AI分析失败场景**:当AI分析失败时,应该使用默认评分和分类,不影响后续流程 + +**验证方法**: + +* 通过修改配置文件模拟各种边界场景 + +* 检查日志输出是否符合预期 + +* 检查生成的文件内容是否正确 + +* 检查程序是否正常退出或抛出预期的异常 + diff --git a/02图片生成代码重复问题.md b/02图片生成代码重复问题.md new file mode 100644 index 0000000..8d29d53 --- /dev/null +++ b/02图片生成代码重复问题.md @@ -0,0 +1,322 @@ +# 1. 问题 + +图片生成模块中存在大量重复代码,两个核心函数 `create_webzine_image` 和 `create_combined_webzine_image` 包含相似的文本解析、字体处理和图片渲染逻辑,导致代码维护困难且容易出错。 + +## 1.1. **代码重复严重** + +`scripts/modules/image_generator.py` 文件中的两个函数 `create_webzine_image`(第13-131行)和 `create_combined_webzine_image`(第134-239行)存在大量重复代码: + +* 都包含相同的文本解析逻辑,识别"标题:"、"正文:"、"价值点:"等前缀 + +* 都使用相同的字体加载和配置代码 + +* 都包含相似的文本换行和宽度计算逻辑 + +* 都有重复的图片绘制命令处理 + +这种重复导致: + +* 代码行数冗余,两个函数共约230行,其中至少60%是重复逻辑 + +* 修改渲染逻辑时需要在两个地方同步修改,容易遗漏 + +* 增加了代码理解和维护的成本 + +## 1.2. **缺乏抽象层次** + +当前实现没有将共同的渲染逻辑抽象为独立的函数,导致: + +* 文本解析逻辑散落在两个函数中,形成"面条代码" + +* 字体配置和图片尺寸参数硬编码在多个位置 + +* 渲染命令的构建逻辑重复,没有统一的处理接口 + +## 1.3. **错误处理不一致** + +两个函数在异常处理上存在细微差异: + +* `create_webzine_image` 在PIL未安装时返回None并记录警告 + +* `create_combined_webzine_image` 有类似的处理但日志信息略有不同 + +* 缺乏统一的错误处理策略,难以保证行为一致性 + +# 2. 收益 + +通过重构图片生成模块,提取公共逻辑并建立清晰的抽象层次,可以显著提升代码质量和开发效率。 + +## 2.1. **减少代码重复** + +重构后可以将重复代码从约140行减少到约30行,代码总量减少约40%。通过提取公共函数,两个核心函数的代码行数都将显著减少,提升代码的简洁性和可读性。 + +## 2.2. **提升可维护性** + +统一的渲染逻辑意味着: + +* 修改文本解析规则时只需修改一处 + +* 调整字体或样式时可以集中配置 + +* 添加新的渲染特性时可以复用现有抽象 + +* 降低因修改不同步导致的bug风险 + +## 2.3. **增强可测试性** + +提取后的公共函数可以独立进行单元测试,不再需要依赖完整的图片生成流程。这样可以更容易地验证文本解析、换行逻辑等核心功能的正确性。 + +## 2.4. **改善代码可读性** + +通过合理的函数命名和职责分离,代码的自解释性将显著提升。新的开发者可以更快理解图片生成的流程,降低学习成本。 + +# 3. 方案 + +系统性地重构图片生成模块,通过提取公共函数、建立渲染抽象层次,消除代码重复并提升代码质量。 + +## 3.1. **提取文本解析函数** + +将重复的文本前缀识别和内容提取逻辑抽象为独立函数: + +```python +def _parse_webzine_line(line): + """解析网摘单行文本,返回 (前缀类型, 前缀文本, 内容文本)""" + if not line.strip(): + return ('empty', '', '') + + prefixes = ['标题:', '原标题:', '发布日期:', '正文:', '价值点:'] + for prefix in prefixes: + if line.startswith(prefix): + return (prefix.rstrip(':'), prefix, line[len(prefix):]) + + return ('normal', '', line) +``` + +这个函数统一处理文本解析逻辑,消除了两个函数中的重复代码。 + +## 3.2. **提取字体配置函数** + +将字体加载和配置逻辑集中管理: + +```python +def _load_webzine_fonts(): + """加载网摘图片所需的所有字体""" + return { + 'banner': get_chinese_font(size=40, bold=True), + 'article_title': get_chinese_font(size=36, bold=True), + 'bold': get_chinese_font(size=32, bold=True), + 'content': get_chinese_font(size=32, bold=False) + } +``` + +这样可以确保字体配置的一致性,并便于集中调整样式。 + +## 3.3. **提取渲染命令构建函数** + +将文本换行和渲染命令生成逻辑抽象为独立函数: + +```python +def _build_render_commands(webzine_content, fonts, content_width, temp_draw): + """将网摘文本转换为渲染命令列表""" + commands = [] + + for line in webzine_content.split('\n'): + line_type, prefix, content = _parse_webzine_line(line) + + if line_type == 'empty': + commands.append(('empty',)) + elif line_type == '标题': + full_title = prefix + content + wrapped = wrap_text(full_title, fonts['article_title'], content_width, temp_draw) + for wrapped_line in wrapped: + commands.append(('article_title', wrapped_line)) + elif line_type in ['原标题', '发布日期', '正文', '价值点']: + test_line = prefix + content + test_bbox = temp_draw.textbbox((0, 0), test_line, font=fonts['content']) + test_width = test_bbox[2] - test_bbox[0] + + if test_width <= content_width: + commands.append(('prefix_sameline', prefix, content)) + else: + commands.append(('prefix_only', prefix)) + wrapped = wrap_text(content, fonts['content'], content_width, temp_draw) + for wrapped_line in wrapped: + commands.append(('content_indented', wrapped_line)) + else: + wrapped = wrap_text(content, fonts['content'], content_width, temp_draw) + for wrapped_line in wrapped: + commands.append(('normal', wrapped_line)) + + return commands +``` + +这个函数将复杂的文本处理逻辑封装起来,使主函数更加清晰。 + +## 3.4. **提取渲染执行函数** + +将图片绘制的具体执行逻辑抽象为独立函数: + +```python +def _execute_render_commands(draw, commands, fonts, padding, line_height, content_width): + """执行渲染命令列表,在指定画布上绘制内容""" + y = 135 + + for cmd in commands: + cmd_type = cmd[0] + + if cmd_type == 'empty': + y += line_height + elif cmd_type == 'normal': + draw.text((padding, y), cmd[1], fill=(0, 0, 0), font=fonts['content']) + y += line_height + elif cmd_type == 'article_title': + draw.text((padding, y), cmd[1], fill=(0, 0, 0), font=fonts['article_title']) + y += line_height + elif cmd_type == 'prefix_sameline': + label_bbox = draw.textbbox((0, 0), cmd[1], font=fonts['bold']) + label_width = label_bbox[2] - label_bbox[0] + draw.text((padding, y), cmd[1], fill=(0, 0, 0), font=fonts['bold']) + draw.text((padding + label_width, y), cmd[2], fill=(0, 0, 0), font=fonts['content']) + y += line_height + elif cmd_type == 'prefix_only': + draw.text((padding, y), cmd[1], fill=(0, 0, 0), font=fonts['bold']) + y += line_height + elif cmd_type == 'content_indented': + draw.text((padding, y), cmd[1], fill=(0, 0, 0), font=fonts['content']) + y += line_height + elif cmd_type == 'separator': + y += 30 + draw.line([(padding, y), (content_width + padding, y)], fill=(200, 200, 200), width=1) + y += 30 + + return y +``` + +这样可以将绘制逻辑与业务逻辑分离,提升代码的模块化程度。 + +## 3.5. **重构后的主函数** + +重构后的两个主函数将变得简洁清晰: + +```python +def create_webzine_image(webzine_content, output_path, title_text): + """将网摘文本内容渲染为PNG图片(800px宽,自适应高度)""" + logger = get_logger() + if Image is None: + logger.warning("PIL 未安装,跳过图片生成") + return None + + try: + width = 800 + padding = 50 + content_width = width - padding * 2 + line_height = 40 + + # 解析日期 + date_match = re.search(r'\d{4}年\d{2}月\d{2}日', webzine_content) + if date_match: + date_str = date_match.group(0).replace('年', '.').replace('月', '.').replace('日', '') + else: + date_str = datetime.now().strftime('%Y.%m.%d') + banner_text = f'网摘 {date_str}' + + # 加载字体 + fonts = _load_webzine_fonts() + + # 构建渲染命令 + temp_img = Image.new('RGB', (100, 100), color=(255, 255, 255)) + temp_draw = ImageDraw.Draw(temp_img) + commands = _build_render_commands(webzine_content, fonts, content_width, temp_draw) + + # 计算图片高度 + actual_lines_count = len([cmd for cmd in commands if cmd[0] != 'separator']) + img_height = 140 + actual_lines_count * line_height + padding * 2 + img_height = int(img_height * 1.15) + + # 创建图片并绘制 + img = Image.new('RGB', (width, img_height), color=(255, 255, 255)) + draw = ImageDraw.Draw(img) + + # 绘制标题栏 + draw.rectangle([(0, 0), (width, 90)], fill=(26, 72, 144)) + draw.text((padding, 25), banner_text, fill=(255, 255, 255), font=fonts['banner']) + draw.line([(padding, 100), (width - padding, 100)], fill=(180, 180, 180), width=2) + + # 执行渲染命令 + _execute_render_commands(draw, commands, fonts, padding, line_height, content_width) + + img.save(output_path, 'PNG', quality=95) + logger.info("网摘图片已保存: %s (%d条命令, %dpx)", output_path, len(commands), img_height) + return output_path + + except Exception as e: + logger.warning("图片生成失败: %s", e) + return None +``` + +重构后的函数结构清晰,职责单一,易于理解和维护。 + +# 4. 回归范围 + +本次重构主要影响图片生成功能,需要重点测试网摘图片生成的正确性和稳定性。 + +## 4.1. 主链路 + +1. **完整日报生成流程** + + * 从RSS抓取到最终图片生成的完整流程 + + * 验证生成的图片格式、尺寸、内容正确性 + + * 确认图片文件能正常保存到指定路径 + +2. **网摘图片生成** + + * 单篇网摘图片生成功能 + + * TOP3合并长图生成功能 + + * 验证图片中的文本内容、格式、样式符合预期 + +3. **异常情况处理** + + * PIL未安装时的降级处理 + + * 图片生成失败时的错误处理和日志记录 + + * 确认异常情况下不影响主流程继续执行 + +## 4.2. 边界情况 + +1. **特殊文本内容** + + * 包含超长标题的文章 + + * 包含特殊字符或表情符号的文本 + + * 空内容或格式异常的网摘文本 + +2. **字体和样式** + + * 不同操作系统的字体兼容性 + + * 中英文混合内容的正确渲染 + + * 文本换行和边界情况的处理 + +3. **性能和资源** + + * 生成大量图片时的内存使用情况 + + * 并发生成图片时的线程安全性 + + * 大文本内容的处理性能 + +4. **缓存和复用** + + * 图片已存在时的跳过逻辑 + + * 缓存机制与重构后的兼容性 + + * 增量生成时的正确性 + diff --git a/1.md b/1.md new file mode 100644 index 0000000..66c35ab --- /dev/null +++ b/1.md @@ -0,0 +1,57 @@ +# **热点速览 | 2026-06-03** + +2026-06-03 + + +## **📋 今日速览** + +- 🔍 **封锁霍尔木兹海峡 · 以色列 · 伊朗** (48.5°) 黎以达成停火共识,以色列撤回部队;美伊谈判重回正轨。 +- 📝 **伊朗袭击科威特巴林** (48.4°) 伊朗向科威特和巴林发射弹道导弹和无人机,美军成功拦截。 + + + +## **📈 跨日追踪** + +### **➡️ 封锁霍尔木兹海峡 · 以色列 · 伊朗** +今日热度 48.5° (-2.7) | 报道 0 篇 (-8) | 趋势 持平 +前日 (2026-06-02): 热度 51.2° | 10 篇 | 匹配度 0.15 +前情: 前情内容 + +### **🔺 2027年商业载人飞行** +今日热度 47.2° (+35.0) | 报道 0 篇 (+0) | 趋势 上升 +前日 (2026-06-02): 热度 12.2° | 1 篇 | 匹配度 0.06 + + + + +## **📊 趋势分析** + +### **📈 持续 封锁霍尔木兹海峡** +- 热度: 48 ← 52 ← 45 +- 报道: 2 ← 10 ← 8 篇 +- 时间: 06-03 ← 06-02 ← 06-01 +- 近3天热度变化 -8% + + + + +## **🔍 热点洞察** + +### **霍尔木兹海峡危机[持续跟踪]** +> 黎以达成停火共识,以色列撤回部队;美伊谈判重回正轨。分析指出霍尔木兹海峡航运受阻将影响南亚化肥供应,可能引发粮食安全危机。 + +- 黎以达成停火共识,以色列撤回部队 +- 美伊谈判重回正轨 +- 霍尔木兹海峡航运受阻将影响南亚化肥供应 + +#### **事件脉络** +前情提要:此前霍尔木兹海峡局势紧张,美伊谈判停滞,黎以冲突持续。 +最新突破:黎以达成停火共识,以色列撤回部队;美伊谈判恢复。 + +#### **多角度分析** +- **技术维度**:海峡航运受阻直接影响南亚化肥供应链 +- **战略维度**:黎以停火缓解中东北部紧张 + +#### **影响与展望** +短期:黎以停火减少地区冲突热点 +中期:海峡航运若持续受阻,南亚化肥价格将上涨 \ No newline at end of file diff --git a/20260529_新闻智能分析系统开发计划_v2.md b/20260529_新闻智能分析系统开发计划_v2.md new file mode 100644 index 0000000..9149c24 --- /dev/null +++ b/20260529_新闻智能分析系统开发计划_v2.md @@ -0,0 +1,822 @@ +# 新闻智能分析系统开发计划 v2.0 + +> 基于大模型的时政/军事/科技新闻聚类、评价与整合系统 +> +> **迭代说明**:参考 AI Daily 项目实践,融合细粒度聚类与工程化部署经验 + +--- + +## 一、项目概述 + +### 1.1 项目目标 +构建一个自动化新闻分析系统,实现: +- **细粒度聚类**:保留多角度报道,避免简单去重丢失热点 +- **动态热点识别**:基于当日新闻分布统计特征自适应判定热点 +- **双层整合**:摘要级速览(全量)+ 洞察级深度分析(Top 10) +- **混合处理**:实时热点感知 + 每日批量汇总生成早报 +- **跨日关联**:追踪同一事件的多日发展脉络 + +### 1.2 核心约束 +| 维度 | 约束 | +|------|------| +| 数据规模 | 200-500篇/日 | +| 数据源 | RSS Feed(公众号订阅源) | +| 输出格式 | Web页面 / Markdown / 即时推送 | +| 成本预算 | 混合模型调度,日成本约1.3-1.6元 | +| 处理时效 | 批处理15-20分钟完成 | +| 部署方式 | 支持systemd服务化部署 | + +### 1.3 双循环架构(参考AI Daily优化) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Fetch 循环(实时) │ +│ 每30分钟运行一次 │ +│ RSS抓取 → 转为Markdown → LLM评分 → 重要性判断 → 即时推送? │ +│ ↓ ↓ │ +│ 存入数据库(fetch-yyyy-mm-dd.json) 飞书/Discord│ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Push 循环(定时) │ +│ 每日定时运行(如早8点) │ +│ 读取碎片化信息 → 细粒度聚类 → 多角度关联 → 综合评分 │ +│ → 摘要级整合(全量) → 洞察级整合(Top10) → 生成日报 → 推送 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、与AI Daily的对比分析 + +### 2.1 相似性 + +| 维度 | AI Daily | 本系统 | 相似度 | +|------|----------|--------|--------| +| **数据源** | RSS订阅源 | RSS订阅源(公众号) | ⭐⭐⭐⭐⭐ | +| **核心流程** | RSS→LLM评分→推送/汇总 | RSS→聚类→评分→整合→推送/汇总 | ⭐⭐⭐⭐ | +| **双循环架构** | Fetch循环+Push循环 | 实时检测+批处理 | ⭐⭐⭐⭐⭐ | +| **即时推送** | 飞书/Discord webhook | 飞书/企微/邮件 | ⭐⭐⭐⭐ | +| **定时汇总** | 每日定时推送 | 每日早报生成 | ⭐⭐⭐⭐⭐ | +| **成本预算** | 每天6毛钱 | 每天1.3-1.6元 | ⭐⭐⭐⭐ | + +### 2.2 本系统的差异化优势 + +| 特性 | AI Daily | 本系统 | 价值 | +|------|----------|--------|------| +| **热点识别** | 单篇文章评分 | **双层筛选:硬约束+动态阈值融合** | 质量控制+热点发现协同 | +| **去重策略** | 简单去重 | 细粒度聚类+多角度保留 | 不丢失多角度报道 | +| **内容整合** | 单篇摘要 | 双层整合(摘要+洞察) | 深度分析Top热点 | +| **领域聚焦** | AI领域 | 时政/军事/科技 | 专业领域实体识别 | +| **角度分类** | 无 | 6种报道角度分类 | 支持多角度整合 | +| **跨日关联** | 无 | 事件时间线追踪+Sentinel标记 | 追踪事件发展脉络 | + +### 2.3 AI Daily 参考资源 + +- 项目介绍(热点追踪系统):https://yeekal.com/ai/ai-daily-news-tracker/ +- 项目介绍(进化实录):https://yeekal.com/ai/ai-daily-news-update/ +- GitHub 代码库:https://github.com/YeeKal/ai-daily + +### 2.4 从AI Daily借鉴的核心设计 + +1. **双循环架构**:Fetch循环(实时)+ Push循环(定时) +2. **systemd服务化**:使用systemd timer替代Python内部定时,提升稳定性 +3. **文件存储规范**:fetch/notify/push三种文件类型,明确保留策略 +4. **评分硬约束**:非目标领域上限、KOL转述上限,有效控制信息质量 +5. **Prompt防退化**:禁止套话、要求从素材出发,避免LLM输出风格趋同 +6. **双视角洞察**:metadata(事实压缩)+ 正文(趋势判断)解耦 +7. **Sentinel分段标记**:单文件多板块精确管理,支持跨日关联 + +--- + +## 三、开发阶段规划 + +### 阶段一:基础架构搭建(Week 1-2) +**目标**:建立数据流和存储基础,确保稳定采集 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 1.1 | RSS采集模块 | 解析公众号RSS Feed,提取标题、正文、来源、发布时间 | 原始文章数据库 | - | +| 1.2 | 数据标准化 | 统一文章格式,清洗HTML标签,提取纯文本 | 标准化文章表 | 1.1 | +| 1.3 | 文件存储层 | JSON文件存储(fetch-yyyy-mm-dd.json) | 存储规范 | 1.2 | +| 1.4 | 数据库设计 | SQLite设计文章表、实体表、聚类表 | 数据库Schema | 1.2 | +| 1.5 | 基础监控 | 采集成功率、文章数量统计 | 监控面板 | 1.1 | +| 1.6 | 日志系统 | 有效日志记录,便于问题定位 | 日志模块 | 1.1 | + +**数据字段规范**: +```json +{ + "title": "内容标题", + "link": "原始链接", + "published": "发布时间", + "source": "来源(公众号名称)", + "content": "Markdown格式的正文内容", + "tags": "LLM识别的标签", + "score": "LLM评分(0-100)", + "summary": "LLM生成的中文摘要", + "fetched_at": "抓取时间", + "entities": ["提取的实体列表"], + "reporting_angle": "报道角度" +} +``` + +**验收标准**: +- [ ] 稳定采集50+ RSS源 +- [ ] 数据入库成功率 > 95% +- [ ] 基础监控可查看当日采集概况 +- [ ] 日志系统可定位问题 + +--- + +### 阶段二:Fetch循环与双层评分系统(Week 3-4) +**目标**:实现实时采集、双层评分(硬约束+动态阈值融合)、即时推送 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 2.1 | Fetch循环引擎 | 每30分钟循环一次,抓取RSS→Markdown→评分 | fetch引擎 | 1.6 | +| 2.2 | **第一层:单篇评分Prompt** | 单篇文章重要性评分(0-100),**含硬约束** | `prompts/article_score.txt` | 2.1 | +| 2.3 | **硬约束过滤器** | 非时政军事上限、KOL转述上限、时效衰减 | 硬约束模块 | 2.2 | +| 2.4 | 即时推送判断 | 单篇≥90分触发即时推送候选 | 推送判断模块 | 2.3 | +| 2.5 | 飞书Webhook推送 | 飞书群机器人推送 | 飞书推送适配器 | 2.4 | +| 2.6 | systemd服务化 | 使用systemd timer管理服务 | systemd配置 | 2.1 | + +#### 双层评分架构设计 + +``` +第一层:单篇文章评分(硬约束) 第二层:实体簇热度(动态阈值) +├── 输入:单篇RSS文章 ├── 输入:同一实体的多篇报道(已硬约束评分) +├── 处理:LLM评分 + 硬约束修正 ├── 处理:统计特征 + 动态阈值计算 +└── 输出:0-100分(已约束) └── 输出:热点判定 + ├── ≤79:非目标领域,过滤 ├── 低于阈值:普通关注 + ├── 80-89:一般关注,进入聚类 └── 高于阈值:热点,优先整合 + └── 90+:高优先级,即时推送候选 +``` + +#### 第一层:评分硬约束设计 + +**Prompt核心约束**: +```markdown +## 评分规则(硬性约束) + +1. **非时政/军事/科技主题上限 79 分** + - 如果内容不属于目标领域(如纯娱乐八卦、生活琐事),最高只能给 79 分 + +2. **KOL 转述上限 89 分** + - 如果只是 KOL/大V 对已有信息的转述/评论,而非原创信息,最高只能给 89 分 + +3. **时效性衰减** + - 发布超过24小时:分数×0.9 + - 发布超过48小时:分数×0.8 + +4. **90+ 分必须同时满足**: + - 重大事件/突破性进展/政策发布 + - 一手信息源(官方发布、权威媒体首发) + - 对目标领域有实质性影响 + +5. **标签禁止空泛** + - 禁止:["军事", "新闻", "热点"] + - 要求:["歼-35A", "舰载战斗机", "隐身性能", "海军航空兵"] +``` + +**代码实现**: +```python +def apply_score_constraints(entry: dict, raw_score: int) -> tuple[int, list[str]]: + """ + 应用评分硬约束 + 返回:修正后的分数,应用的约束标签列表 + """ + constraints_applied = [] + + # 约束1:非目标领域上限79 + if not is_target_domain(entry['content']): + raw_score = min(raw_score, 79) + constraints_applied.append("非目标领域") + + # 约束2:KOL转述上限89 + if is_kol_repost(entry): + raw_score = min(raw_score, 89) + constraints_applied.append("KOL转述") + + # 约束3:时效性衰减 + hours_old = get_hours_since_published(entry) + if hours_old > 24: + decay_factor = 0.9 ** (hours_old // 24) + raw_score = int(raw_score * decay_factor) + constraints_applied.append(f"时效衰减({hours_old}h)") + + return raw_score, constraints_applied +``` + +**第一层输出分级**: +| 分数段 | 处理策略 | 说明 | +|--------|---------|------| +| ≤79 | 过滤,不入库 | 非目标领域内容 | +| 80-89 | 入库,进入聚类 | 一般关注,参与动态阈值计算 | +| 90+ | 入库,即时推送候选 | 高优先级,同时触发即时推送判断 | + +**验收标准**: +- [ ] Fetch循环每30分钟稳定运行 +- [ ] 硬约束生效(非目标领域≤79,KOL转述≤89,时效衰减) +- [ ] 90+文章触发即时推送候选 +- [ ] systemd服务可一键启动/停止 + +--- + +### 阶段三:实体提取与细粒度聚类(Week 5-6) +**目标**:实现细粒度聚类,区分同一实体的不同报道角度 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 3.1 | 实体提取Prompt | 设计并优化实体提取提示词 | `prompts/extract_entities.txt` | 2.6 | +| 3.2 | 角度分类Prompt | 设计报道角度分类提示词 | `prompts/classify_angle.txt` | 2.6 | +| 3.3 | 批量实体提取 | 实现50篇/批并发调用,小模型处理 | 实体提取服务 | 3.1 | +| 3.4 | 角度分类服务 | 批量角度分类,与实体提取并行 | 角度分类服务 | 3.2 | +| 3.5 | 实体归一化 | 大模型消歧,合并别名 | 实体归一化服务 | 3.3 | +| 3.6 | 聚类算法 | 基于实体+角度的细粒度聚类 | 聚类引擎 | 3.4, 3.5 | +| 3.7 | 记忆系统 | 避免同一信息反复推送 | 去重模块 | 3.6 | + +**验收标准**: +- [ ] 实体提取准确率 > 85% +- [ ] 角度分类准确率 > 80% +- [ ] 聚类后实体簇数量合理(200篇→30-50个簇) +- [ ] 同一信息不重复推送 + +--- + +### 阶段四:热度评价与动态阈值(Week 7-8) +**目标**:实现第二层筛选——基于硬约束后分数的动态热点识别 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 4.1 | **实体簇热度计算** | 融合硬约束分数的多维度热度计算 | 热度指标服务 | 3.6 | +| 4.2 | **第二层:动态阈值算法** | 基于硬约束后分数分布的自适应阈值 | 阈值计算模块 | 4.1 | +| 4.3 | 热点识别引擎 | 硬约束过滤 + 动态阈值筛选的双层判定 | 热点识别服务 | 4.2 | +| 4.4 | 热点精评Prompt | 大模型深度评分(Top 30热点簇) | `prompts/precise_heat.txt` | 4.3 | + +#### 第二层:动态阈值设计(融合硬约束分数) + +**实体簇热度计算**: +```python +class Cluster: + def __init__(self, entity_name: str): + self.entity_name = entity_name + self.articles = [] # 已硬约束评分的文章列表 + self.hot_score = 0 # 综合热度分 + + def calculate_hot_score(self) -> int: + """ + 计算实体簇热度,融合硬约束后的单篇分数 + """ + if not self.articles: + return 0 + + # 基础分数:最高分文章的分数(已硬约束) + max_score = max(a['score'] for a in self.articles) + + # 传播热度:报道数量 × log(来源多样性) + report_count = len(self.articles) + source_diversity = len(set(a['source'] for a in self.articles)) + propagation_heat = report_count * math.log(source_diversity + 1) + + # 角度覆盖度:不同报道角度数 / 6 + angles = set(a.get('reporting_angle', 'unknown') for a in self.articles) + angle_coverage = len(angles) / 6 + + # 高优先级文章加成(90+文章额外加权) + high_priority_count = sum(1 for a in self.articles if a['score'] >= 90) + priority_bonus = high_priority_count * 5 # 每篇90+加5分 + + # 综合计算 + self.hot_score = min(100, int( + max_score * 0.4 + # 单篇最高分权重40% + min(propagation_heat * 3, 30) + # 传播热度权重30% + angle_coverage * 20 + # 角度覆盖权重20% + priority_bonus # 高优先级加成10% + )) + + return self.hot_score +``` + +**动态阈值算法(融合版)**: +```python +def calculate_hotspot_threshold(clusters: list[Cluster], date: str) -> float: + """ + 基于硬约束后分数分布计算动态阈值 + 同时考虑统计特征和高优先级文章数量 + """ + # 获取当日所有簇的热度分数(已融合硬约束) + scores = [c.hot_score for c in clusters] + + if len(scores) < 5: + return 75 # 数据不足时使用保底阈值 + + # 统计特征 + mean_score = mean(scores) + std_score = std(scores) + median_score = median(scores) + + # 方法1:统计阈值(均值+1.5倍标准差) + threshold_stat = mean_score + 1.5 * std_score + + # 方法2:保底阈值(至少3条或前10%) + min_count = max(3, len(scores) * 0.1) + sorted_scores = sorted(scores, reverse=True) + threshold_adaptive = sorted_scores[min_count - 1] + + # 方法3:硬约束保底(考虑90+高优先级文章数量) + high_priority_count = sum( + 1 for c in clusters + if any(a['score'] >= 90 for a in c.articles) + ) + if high_priority_count >= 3: + # 如果有3个以上簇包含90+文章,降低阈值确保热点被识别 + threshold_backup = median_score + else: + threshold_backup = mean_score + 0.5 * std_score + + # 取三者中较低值,确保热点不被遗漏 + final_threshold = min(threshold_stat, threshold_adaptive, threshold_backup) + + # 记录阈值计算日志 + logger.info(f"动态阈值计算: 统计={threshold_stat:.1f}, " + f"保底={threshold_adaptive:.1f}, 硬约束保底={threshold_backup:.1f}, " + f"最终={final_threshold:.1f}") + + return final_threshold +``` + +**双层热点判定流程**: +```python +def identify_hotspots(clusters: list[Cluster], date: str) -> tuple[list[Cluster], list[Cluster]]: + """ + 双层热点识别:硬约束过滤 + 动态阈值筛选 + """ + # 第一层:硬约束过滤(单篇层面已处理,此处检查) + valid_clusters = [] + for c in clusters: + # 过滤掉所有文章都≤79的簇(非目标领域) + if all(a['score'] <= 79 for a in c.articles): + continue + valid_clusters.append(c) + + # 计算各簇热度(融合硬约束分数) + for c in valid_clusters: + c.calculate_hot_score() + + # 第二层:计算动态阈值 + threshold = calculate_hotspot_threshold(valid_clusters, date) + + # 阈值筛选 + 额外条件 + hotspots = [] + normal = [] + for c in valid_clusters: + if c.hot_score >= threshold: + # 额外检查:是否有90+文章 或 超过阈值10分以上 + has_high_priority = any(a['score'] >= 90 for a in c.articles) + significantly_above = c.hot_score >= threshold + 10 + + if has_high_priority or significantly_above: + hotspots.append(c) + else: + normal.append(c) + else: + normal.append(c) + + # 排序:先按热度,再按最高单篇分数 + hotspots.sort(key=lambda x: (x.hot_score, x.max_article_score), reverse=True) + + return hotspots, normal +``` + +**融合设计优势**: +| 层级 | 作用 | 输入 | 输出 | +|------|------|------|------| +| 第一层(硬约束) | 质量控制 | 单篇文章 | 过滤非目标领域,标记高优先级 | +| 第二层(动态阈值) | 热点发现 | 实体簇(多篇聚合) | 识别突发热点,自适应当日分布 | + +**验收标准**: +- [ ] 实体簇热度计算正确(融合硬约束分数) +- [ ] 动态阈值自适应当日分布 +- [ ] 双层筛选协同工作(硬约束过滤→动态阈值筛选) +- [ ] 热点识别召回率 > 90%,误报率 < 20% + +--- + +### 阶段五:Push循环与双层整合(Week 9-10) +**目标**:实现定时汇总、双层整合(双视角设计)、日报生成 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 5.1 | Push循环引擎 | 每日定时运行,读取碎片化信息→整合→推送 | push引擎 | 4.4 | +| 5.2 | 摘要级整合Prompt | 多源报道合并为50-80字精炼摘要 | `prompts/summary_integrate.txt` | 5.1 | +| 5.3 | 洞察级整合Prompt | Top 10生成结构化深度分析(**双视角设计**) | `prompts/insight_integrate.txt` | 5.1 | +| 5.4 | **Prompt防退化模块** | 禁止套话、要求从素材出发 | 防退化检查器 | 5.2, 5.3 | +| 5.5 | 摘要整合服务 | 小模型批量处理全量簇 | 摘要整合服务 | 5.2, 5.4 | +| 5.6 | 洞察整合服务 | 大模型逐个处理Top 10(**双视角输出**) | 洞察整合服务 | 5.3, 5.4 | +| 5.7 | 时间线提取 | 从多源报道中提取事件时间线 | 时间线提取模块 | 5.5 | +| 5.8 | 多角度整合 | 同一事件不同角度报道的整合 | 多角度整合模块 | 5.6 | + +#### 双视角洞察设计(借鉴AI Daily) + +**设计理念**:metadata(新闻编辑视角,事实压缩)+ 正文(情报分析师视角,趋势判断)解耦 + +**Prompt设计**: +```markdown +## Part 1: Metadata(新闻编辑视角) + +请生成以下结构化信息: +- title: 10字以内的标题(事实陈述,无修饰) +- lead: 20字以内的导语(核心事实) +- highlights: 3-5个关键要点(bullet points,纯事实) + +约束:只陈述事实,不做趋势判断,不用形容词。 + +## Part 2: 洞察正文(情报分析师视角) + +基于多源报道,识别: +1. 事件脉络:关键时间节点和进展 +2. 多角度分析:技术维度、战略维度、舆论维度 +3. 影响与展望:短期影响、中长期趋势、值得关注信号 + +约束: +- 禁止空泛评论(如"意义重大""影响深远") +- 每个观点必须有素材支撑 +- 使用具体名称(装备型号/政策名称/组织名称),不用"某些""部分"等模糊指代 +``` + +**输出示例**: +```markdown +--- +title: "歼-35A列装部队" +lead: "空军官方确认歼-35A已列装首批作战部队" +highlights: + - 首次公开确认进入实战化部署阶段 + - 隐身性能对标F-35C,可能在舰载领域形成优势 + - 多国媒体关注其对西太平洋军事平衡的影响 +--- + +## 事件脉络 +- 2024-11:珠海航展首次公开亮相 +- 2025-03:完成舰载适配测试 +- 2025-05:空军确认列装首批作战部队【新进展】 + +## 多角度分析 +- **技术维度**:隐身涂层、航电系统、舰载适配均有突破 +- **战略维度**:提升海军航空兵远海作战能力 +- **舆论维度**:外媒关注中美舰载机代差缩小 + +## 影响与展望 +短期:提升海军航母编队作战能力 +中期:可能在西太平洋形成局部优势 +值得关注:后续舰载版测试进展、出口动向 +``` + +#### Prompt防退化设计(借鉴AI Daily) + +**禁止套话列表**: +```python +FORBIDDEN_PHRASES = [ + "意义重大", "影响深远", "引发关注", "备受瞩目", + "深水区", "拐点", "白热化", "新纪元", "里程碑", + "不容忽视", "值得注意", "值得关注", # 除非后接具体内容 + "某些", "部分", "一些", "相关", # 模糊指代 +] + +def check_degeneration(content: str) -> list[str]: + """检查内容是否包含禁止套话""" + violations = [] + for phrase in FORBIDDEN_PHRASES: + if phrase in content: + violations.append(phrase) + return violations +``` + +**Prompt中的防退化指令**: +```markdown +## 输出约束(防退化) + +1. 禁止使用以下套话: + - "意义重大""影响深远""引发关注""备受瞩目" + - "深水区""拐点""白热化""新纪元""里程碑" + +2. 每个观点必须有素材支撑: + - 错误:该装备性能先进 + - 正确:该装备隐身系数0.01,优于F-35的0.02 + +3. 使用具体名称,禁止模糊指代: + - 错误:某些国家表示关注 + - 正确:日本防卫省发布关注声明 + +4. 从素材出发,每次措辞应不同,避免模板化输出 +``` + +**验收标准**: +- [ ] 摘要信息完整度 > 90% +- [ ] 洞察分析覆盖双视角(metadata + 正文) +- [ ] 无禁止套话出现 +- [ ] 整合内容无明显事实错误 + +--- + +### 阶段六:日报生成与跨日关联(Week 11-12) +**目标**:生成结构化日报(含Sentinel标记),实现跨日关联 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 6.1 | 日报模板设计 | Markdown模板,包含热点、速览、数据概览 | `templates/daily_report.md` | 5.8 | +| 6.2 | **Sentinel分段标记** | 单文件多板块精确管理 | `src/utils/sentinel.py` | 6.1 | +| 6.3 | **跨日关联模块** | 从历史日报提取上下文,追踪事件脉络 | `src/processors/cross_day.py` | 6.2 | +| 6.4 | 排序算法 | 综合热度、时效、重要性的排序逻辑 | 排序模块 | 4.4 | +| 6.5 | 日报渲染服务 | 按模板组装数据,生成Markdown(含Sentinel) | 日报渲染服务 | 6.1, 6.2, 6.4 | +| 6.6 | Web归档站点 | 部署在线阅览站点 | Web站点 | 6.5 | +| 6.7 | 数据保留策略 | fetch保留2天,notify保留2天,push保留5天 | 清理策略 | 6.6 | + +#### 简化版Sentinel分段标记设计 + +**标记格式**: +```markdown +--- +title: "每日情报早报" +date: "2026-05-24" +stats: + total_entities: 87 + hotspot_count: 10 +--- + + +## 📋 全量新闻速览 + +| # | 实体 | 摘要 | 来源数 | 热度 | +|---|------|------|--------|------| +| 1 | 歼-35A | 空军确认列装\|隐身性能对标F-35C | 8 | ★★★★★ | + + + +## 🔥 热点深度洞察 + +### 1. 歼-35A列装进展 [持续跟踪] +> 速览:空军官方确认歼-35A已列装首批作战部队 [8源报道] + +**事件脉络** +- 5月20日:首次公开亮相 +- 5月24日:【新进展】确认列装部队 +... + +``` + +**核心代码**: +```python +import re +from datetime import datetime, timedelta + +def extract_section(content: str, section_name: str) -> str: + """从日报内容中提取指定section""" + pattern = rf'(.*?)' + match = re.search(pattern, content, re.DOTALL | re.IGNORECASE) + return match.group(1).strip() if match else "" + +def get_historical_context(entity_name: str, days: int = 7) -> dict: + """获取某实体近N天的历史报道上下文""" + context = { + "first_seen": None, + "previous_summaries": [], + "previous_insights": [], + "mention_count": 0 + } + + for i in range(days): + date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d") + file_path = f"data/reports/daily-{date}.md" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + if entity_name in content: + context["mention_count"] += 1 + if context["first_seen"] is None: + context["first_seen"] = date + + # 提取该实体在摘要中的记录 + summary_section = extract_section(content, "summary") + for line in summary_section.split('\n'): + if entity_name in line and line.strip().startswith('|'): + context["previous_summaries"].append({ + "date": date, + "content": line.strip() + }) + + # 提取该实体在洞察中的记录 + insights_section = extract_section(content, "insights") + if entity_name in insights_section: + insight_blocks = re.split(r'### \d+\.', insights_section) + for block in insight_blocks: + if entity_name in block: + context["previous_insights"].append({ + "date": date, + "content": block.strip()[:500] + }) + break + except FileNotFoundError: + continue + + return context + +def generate_cross_day_marker(entity_name: str) -> str: + """生成跨日标记""" + history = get_historical_context(entity_name, days=7) + + if history["mention_count"] == 0: + return "首次报道" + elif history["mention_count"] == 1: + return "持续跟踪" + else: + return f"持续跟踪({history['mention_count']}次)" +``` + +**跨日关联应用**: +```python +# 在生成洞察时,添加跨日时间线 +history = get_historical_context("歼-35A", days=7) + +# 输出: +# { +# "first_seen": "2026-05-20", +# "mention_count": 3, +# "previous_summaries": [ +# {"date": "2026-05-20", "content": "| 1 | 歼-35A | 首次公开亮相... |"}, +# {"date": "2026-05-22", "content": "| 3 | 歼-35A | 专家分析隐身性能... |"} +# ] +# } + +# 在洞察中展示时间线 +timeline = f""" +**事件脉络** +- 5月20日:首次公开亮相(珠海航展) +- 5月22日:专家分析隐身性能,推测可能上舰 +- 5月24日:【新进展】空军官方确认列装首批作战部队 +""" +``` + +**验收标准**: +- [ ] 日报生成时间 < 20分钟 +- [ ] Sentinel标记正确,可精确提取summary/insights +- [ ] 跨日关联可追踪实体历史报道 +- [ ] Web站点可正常访问 + +--- + +### 阶段七:优化与迭代(Week 13-14) +**目标**:持续优化,功能扩展 + +| 序号 | 任务 | 描述 | 优先级 | +|------|------|------|--------| +| 7.1 | Prompt优化 | 基于实际效果迭代优化提示词 | 高 | +| 7.2 | 反馈闭环 | 收集用户反馈,标注数据,优化模型 | 高 | +| 7.3 | 趋势分析 | 7日热点趋势图,周期性报告 | 中 | +| 7.4 | 个性化推荐 | 基于用户阅读历史的个性化排序 | 低 | + +--- + +## 四、技术栈建议 + +### 4.1 核心组件 +| 组件 | 推荐方案 | 说明 | +|------|----------|------| +| 数据采集 | Python + feedparser | RSS解析 | +| 数据存储 | SQLite + JSON文件 | 数据库+文件双存储 | +| LLM调用 | OpenAI SDK / 国产模型SDK | 统一接口封装 | +| 任务调度 | systemd timer | 系统级服务管理 | +| Web展示 | Hugo / Flask | 静态/动态站点 | +| 日志 | Python logging + systemd journal | 集中日志管理 | + +### 4.2 模型选择 +| 任务类型 | 推荐模型 | 预估成本 | +|----------|----------|----------| +| 实体提取 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 角度分类 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 单篇评分 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 摘要整合 | DeepSeek-V3 / Qwen-Plus | ~0.002元/次 | +| 实体消歧 | GPT-4o / Claude-3.5-Sonnet | ~0.01元/次 | +| 热点精评 | GPT-4o / Claude-3.5-Sonnet | ~0.02元/次 | +| 洞察整合 | GPT-4o / Claude-3.5-Sonnet | ~0.1元/次 | + +**日成本估算**: +- 200-500篇/日 × 0.001元(小模型)= 0.2-0.5元 +- 100个簇 × 0.001元(小模型)= 0.1元 +- 10个洞察 × 0.1元(大模型)= 1元 +- **总计:约1.3-1.6元/日** + +--- + +## 五、项目结构 + +``` +news-intelligence-system/ +├── config/ +│ ├── rss_sources.json # RSS源配置 +│ ├── models.yaml # 模型配置 +│ └── systemd/ # systemd服务配置 +├── src/ +│ ├── collectors/ # 数据采集 +│ │ └── rss_collector.py +│ ├── processors/ # 处理管道 +│ │ ├── entity_extractor.py +│ │ ├── angle_classifier.py +│ │ ├── cluster_engine.py +│ │ ├── heat_calculator.py +│ │ └── cross_day.py # 跨日关联(新增) +│ ├── integrators/ # 内容整合 +│ │ ├── summary_integrator.py +│ │ └── insight_integrator.py +│ ├── generators/ # 报告生成 +│ │ ├── daily_report_generator.py +│ │ └── web_renderer.py +│ ├── push/ # 推送平台 +│ │ ├── base.py +│ │ ├── feishu.py +│ │ └── discord.py +│ └── utils/ # 工具函数 +│ ├── sentinel.py # Sentinel分段标记(新增) +│ └── prompt_checker.py # Prompt防退化检查(新增) +├── prompts/ # 提示词文件 +│ ├── article_score.txt # 单篇评分(含硬约束) +│ ├── extract_entities.txt +│ ├── classify_angle.txt +│ ├── quick_heat.txt +│ ├── precise_heat.txt +│ ├── summary_integrate.txt +│ └── insight_integrate.txt # 双视角洞察 +├── templates/ +│ └── daily_report.md # 含Sentinel标记 +├── data/ +│ ├── fetch/ # fetch-yyyy-mm-dd.json +│ ├── notify/ # notify-yyyy-mm-dd.json +│ └── reports/ # daily-yyyy-mm-dd.md +├── web/ # Web归档站点 +├── tests/ +├── scripts/ +│ ├── fetch_loop.py +│ ├── push_loop.py +│ └── setup_systemd.sh +├── requirements.txt +└── README.md +``` + +--- + +## 六、关键风险与应对 + +| 风险 | 影响 | 应对措施 | +|------|------|----------| +| RSS源失效 | 数据缺失 | 多源备份,监控告警 | +| LLM API限流 | 处理延迟 | 批量处理,重试机制,降级策略 | +| 实体识别错误 | 聚类偏差 | 人工标注反馈,Prompt迭代 | +| 热点漏识别 | 信息缺失 | 动态阈值保底机制,人工复核 | +| 成本超支 | 预算超支 | Token监控,模型降级,配额控制 | +| 进程崩溃 | 服务中断 | systemd自动重启 | +| LLM输出退化 | 质量下降 | **Prompt防退化检查** | + +--- + +## 七、里程碑与交付物 + +| 里程碑 | 时间 | 交付物 | +|--------|------|--------| +| M1 | Week 2 | 稳定运行的数据采集系统 | +| M2 | Week 4 | Fetch循环上线,**评分硬约束生效** | +| M3 | Week 6 | 细粒度聚类引擎,实体提取准确率>85% | +| M4 | Week 8 | 动态热点识别系统,召回率>90% | +| M5 | Week 10 | Push循环上线,**双视角洞察+Prompt防退化** | +| M6 | Week 12 | **Sentinel跨日关联上线**,Web归档站点 | +| M7 | Week 14 | 持续优化迭代 | + +--- + +## 八、借鉴AI Daily的核心设计总结 + +### 8.1 架构层面 +1. **双循环架构**:Fetch循环(实时)+ Push循环(定时) +2. **systemd服务化**:替代Python内部定时,提升稳定性 +3. **文件存储规范**:fetch/notify/reports三种文件类型,明确保留策略 + +### 8.2 LLM应用层面 +1. **评分硬约束**:非目标领域≤79、KOL转述≤89,有效控制信息质量 +2. **Prompt防退化**:禁止套话、要求从素材出发,避免LLM输出风格趋同 +3. **双视角洞察**:metadata(事实压缩)+ 正文(趋势判断)解耦 + +### 8.3 数据管理层面 +1. **Sentinel分段标记**:单文件多板块精确管理,支持跨日关联 +2. **跨日关联**:从历史日报提取上下文,追踪事件发展脉络 +3. **数据保留策略**:自动清理过期文件 + +### 8.4 工程层面 +1. **日志集中管理**:systemd journal便于问题定位 +2. **多平台推送**:飞书、Discord webhook支持 +3. **成本可控**:每天约1.3-1.6元 + +--- + +*文档版本:v2.1* +*最后更新:2026-05-28* +*状态:已融合AI Daily四大核心设计(Sentinel、硬约束、防退化、双视角)* diff --git a/20260529_新闻智能分析系统开发计划_v3.md b/20260529_新闻智能分析系统开发计划_v3.md new file mode 100644 index 0000000..b35ba88 --- /dev/null +++ b/20260529_新闻智能分析系统开发计划_v3.md @@ -0,0 +1,1249 @@ +# 新闻智能分析系统开发计划 v3.0 + +> 基于大模型的时政/军事/科技新闻聚类、评价与整合系统 +> +> **迭代说明**:参考 AI Daily 项目实践与 military-digest-v3 工程落地经验,融合细粒度聚类、SQLite 增量缓存、翻译管线、网摘生成与工程化部署经验 + +--- + +## 一、项目概述 + +### 1.1 项目目标 +构建一个自动化新闻分析系统,实现: +- **细粒度聚类**:保留多角度报道,避免简单去重丢失热点 +- **动态热点识别**:基于当日新闻分布统计特征自适应判定热点 +- **三层整合**:摘要级速览(全量)+ 洞察级深度分析(Top 10)+ 参考消息风格网摘(Top 3) +- **混合处理**:实时热点感知 + 每日批量汇总生成早报 +- **跨日关联**:追踪同一事件的多日发展脉络 +- **增量缓存**:SQLite 三级缓存(文章/网摘/分类摘要),支持断点续跑,再次运行几乎零 token 消耗 +- **外文翻译**:中英分离 + 并发翻译,中文文章零 API 调用 + +### 1.2 核心约束 +| 维度 | 约束 | +|------|------| +| 数据规模 | 200-500篇/日 | +| 数据源 | RSS Feed(公众号订阅源,含外文源) | +| 输出格式 | Markdown日报 / 网摘PNG长图 / 网摘TXT文本 / 飞书推送 | +| 成本预算 | 混合模型调度,日成本约1.3-1.6元 | +| 处理时效 | 批处理15-20分钟完成(冷启动基准:5源156篇→153s) | +| 缓存命中 | 二次运行 ~5s,API 调用 0 次 | +| 部署方式 | 支持systemd服务化部署 | + +### 1.3 双循环架构(参考AI Daily优化) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Fetch 循环(实时) │ +│ 每30分钟运行一次 │ +│ RSS抓取 → 中英分离 → 外文并发翻译 → LLM评分 → 重要性判断 │ +│ ↓ ↓ ↓ │ +│ 存入SQLite缓存 存入JSON文件(fetch-yyyy-mm-dd.json) 飞书推送│ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Push 循环(定时) │ +│ 每日定时运行(如早8点) │ +│ 读取碎片化信息 → 细粒度聚类 → 多角度关联 → 综合评分 │ +│ → 摘要级整合(全量) → 洞察级整合(Top10) → 参考消息网摘(Top3) │ +│ → 生成日报+网摘图片 → 飞书推送 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、与现有项目的对比分析 + +### 2.1 与 AI Daily 的相似性 + +| 维度 | AI Daily | 本系统 | 相似度 | +|------|----------|--------|--------| +| **数据源** | RSS订阅源 | RSS订阅源(公众号+外文) | ⭐⭐⭐⭐⭐ | +| **核心流程** | RSS→LLM评分→推送/汇总 | RSS→翻译→聚类→评分→整合→推送/汇总 | ⭐⭐⭐⭐ | +| **双循环架构** | Fetch循环+Push循环 | 实时检测+批处理 | ⭐⭐⭐⭐⭐ | +| **即时推送** | 飞书/Discord webhook | 飞书 webhook | ⭐⭐⭐⭐ | +| **定时汇总** | 每日定时推送 | 每日早报生成 | ⭐⭐⭐⭐⭐ | +| **成本预算** | 每天6毛钱 | 每天1.3-1.6元 | ⭐⭐⭐⭐ | + +### 2.2 与 military-digest-v3 的对比 + +| 维度 | military-digest-v3(现有系统) | 本系统(新架构) | 差异 | +|------|------|------|:---:| +| **架构** | 单管线(每日一次全量运行) | 双循环(Fetch 30min + Push 每日) | 🔄 架构升级 | +| **去重** | 同源同标题简单去重 | 细粒度语义聚类 + 多角度保留 | 🔄 策略升级 | +| **评分** | 四维度固定加权 | 双层筛选:硬约束 + 动态阈值融合 | 🔄 质量升级 | +| **翻译** | ✅ 中英分离 + 并发翻译 | ✅ 继承 + 统一为 Fetch 循环环节 | ✅ 复用 | +| **缓存** | ✅ SQLite 三级缓存(文章/网摘/分类摘要) | ✅ 继承 + 扩展为跨日关联数据源 | ✅ 复用 | +| **监控** | ✅ 11阶段耗时 + API统计 + 源成功率 | ✅ 继承 + 增加聚类/热点识别指标 | ✅ 复用 | +| **网摘** | ✅ 参考消息风格 + 字数校验重试 | ✅ 继承 + 作为第三层整合输出 | ✅ 复用 | +| **推送** | 企业微信 + 飞书 | **聚焦飞书 webhook** | 🔄 收束 | +| **跨日关联** | ❌ 无 | ✅ Sentinel标记 + 事件时间线追踪 | 🆕 新增 | +| **防退化** | ❌ 无 | ✅ 禁止套话 + 素材支撑要求 | 🆕 新增 | + +### 2.3 本系统的差异化优势 + +| 特性 | AI Daily | military-digest-v3 | 本系统 | 价值 | +|------|----------|------|------|------| +| **热点识别** | 单篇文章评分 | 四维度固定加权 | **双层筛选:硬约束+动态阈值融合** | 质量控制+热点发现协同 | +| **去重策略** | 简单去重 | 同源标题去重 | 细粒度聚类+多角度保留 | 不丢失多角度报道 | +| **内容整合** | 单篇摘要 | 摘要+网摘 | 三层整合(摘要+洞察+网摘) | 覆盖速览/深度/传播三种需求 | +| **翻译能力** | 无 | ✅ 中英分离并发 | ✅ 继承 | 外文源零额外成本 | +| **增量缓存** | JSON文件 | ✅ SQLite三级缓存 | ✅ 继承+扩展 | 断点续跑,二次运行~5s | +| **网摘生成** | 无 | ✅ 参考消息风格+字数校验 | ✅ 继承 | 适合直接传播的成品内容 | +| **领域聚焦** | AI领域 | 军事/科技 | 时政/军事/科技 | 专业领域实体识别 | +| **角度分类** | 无 | 无 | 6种报道角度分类 | 支持多角度整合 | +| **跨日关联** | 无 | 无 | 事件时间线追踪+Sentinel标记 | 追踪事件发展脉络 | + +### 2.4 从 AI Daily 借鉴的核心设计 + +1. **双循环架构**:Fetch循环(实时)+ Push循环(定时) +2. **systemd服务化**:使用systemd timer替代Python内部定时,提升稳定性 +3. **文件存储规范**:fetch/notify/push三种文件类型,明确保留策略 +4. **评分硬约束**:非目标领域上限、KOL转述上限,有效控制信息质量 +5. **Prompt防退化**:禁止套话、要求从素材出发,避免LLM输出风格趋同 +6. **双视角洞察**:metadata(事实压缩)+ 正文(趋势判断)解耦 +7. **Sentinel分段标记**:单文件多板块精确管理,支持跨日关联 + +### 2.5 从 military-digest-v3 继承的工程资产 + +1. **SQLite 三级缓存**:文章缓存 + 网摘缓存 + 分类摘要缓存,含自动过期清理和数据库迁移机制 +2. **翻译管线**:中英分离(汉字占比 > 20% 判定)+ 外文并发翻译(ThreadPoolExecutor),中文文章零 API 调用 +3. **监控统计体系**:11 阶段耗时 + API 调用按用途/模型分组 + 源抓取成功/失败率 + 缓存命中率 +4. **网摘生成**:参考消息风格 + 公众号源智能识别前缀 + 字数校验重试(280-320字,最多3次) +5. **异步流式管线**:生产者-消费者模型,RSS 抓取与 AI 处理流水线并行 +6. **图片生成**:Pillow 命令模式渲染,支持单篇 + TOP3 合并长图 + +--- + +## 三、开发阶段规划 + +### 阶段一:基础架构搭建(Week 1-2) +**目标**:建立数据流和存储基础,确保稳定采集 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 1.1 | RSS采集模块 | 解析公众号RSS Feed,提取标题、正文、来源、发布时间 | 原始文章数据库 | - | +| 1.2 | 数据标准化 | 统一文章格式,清洗HTML标签,提取纯文本 | 标准化文章表 | 1.1 | +| 1.3 | 文件存储层 | JSON文件存储(fetch-yyyy-mm-dd.json) | 存储规范 | 1.2 | +| 1.4 | **SQLite缓存系统** | 三级缓存设计(文章/网摘/分类摘要),含过期策略与迁移机制 | 缓存模块 | 1.2 | +| 1.5 | **翻译管线** | 中英分离 + 外文并发翻译 | 翻译模块 | 1.2 | +| 1.6 | **监控统计** | 阶段耗时 + API消耗 + 成功率 + 缓存命中率 | 监控模块 | 1.1 | +| 1.7 | 日志系统 | 有效日志记录,便于问题定位 | 日志模块 | 1.1 | + +#### 1.4 SQLite 缓存系统设计(继承 military-digest-v3) + +**数据库位置**:`data/article_cache.db` + +**表1:article_cache(文章处理结果缓存)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | TEXT (PK) | 文章唯一标识(RSS entry id/link) | +| `source` | TEXT | 来源名称 | +| `title` | TEXT | 原始标题 | +| `link` | TEXT | 原文链接 | +| `translated_title` | TEXT | 翻译后的中文标题 | +| `content` | TEXT | 原始正文 | +| `translated_content` | TEXT | 翻译后的中文正文(前300字) | +| `ai_score` | REAL | AI 加权总分 | +| `ai_summary` | TEXT | AI 生成的中文摘要 | +| `ai_category` | TEXT | 分类(装备动态/地区冲突/战略政策) | +| `ai_scores_json` | TEXT | 四维度评分 JSON | +| `webzine_text` | TEXT | 网摘文本(迁移新增字段) | +| `published_time` | DATETIME | 文章发布时间 | +| `processed_time` | DATETIME | 处理时间(索引,用于过期判断) | +| `status` | INTEGER | 1=成功 2=失败 | +| `error_msg` | TEXT | 失败时的错误信息 | + +**索引**:`idx_processed_time`、`idx_source` + +**表2:category_summary_cache(分类摘要缓存)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `date` | TEXT (联合PK) | 日期字符串(YYYY-MM-DD) | +| `category` | TEXT (联合PK) | 分类名称 | +| `summary_text` | TEXT | 分类介绍文本 | +| `generated_time` | DATETIME | 生成时间 | + +**缓存策略**: + +| 策略项 | 设计 | +|------|------| +| 过期时间 | 24 小时(与时间窗口一致,可配置) | +| 自动清理 | 每次运行时清理 7 天前数据(`clear_expired(keep_days=7)`) | +| 写入方式 | `REPLACE INTO`(幂等,支持覆盖更新) | +| 网摘分离更新 | `save_article_webzine()` 单独更新 `webzine_text` 字段,不影响其他缓存 | +| 连接降级 | 数据库连接失败时静默降级,不影响主流程 | +| 全局单例 | `get_cache()` 全局单例模式,避免重复创建连接 | + +**数据库迁移机制**: + +```python +def _migrate_add_webzine_text(self): + """自动检测并添加 webzine_text 列""" + cursor.execute("PRAGMA table_info(article_cache)") + columns = [col[1] for col in cursor.fetchall()] + if 'webzine_text' not in columns: + cursor.execute("ALTER TABLE article_cache ADD COLUMN webzine_text TEXT") +``` + +**缓存命中流程**: + +``` +文章被抓取 → 以 article.id 查询缓存(expire_hours=24) + ├─ 命中 + status=1 → 直接使用缓存数据(from_cache=True),跳过翻译和AI分析 + └─ 未命中/过期 → 完整翻译+分析管线,结果 REPLACE INTO 写入缓存 +``` + +**实测效果**(military-digest-v3 验证数据): + +| 指标 | 第一次(冷启动) | 第二次(含缓存) | +|------|:---:|:---:| +| 文章缓存命中 | 0 / 15 | **15 / 15** | +| API 调用次数 | 37 次 | **0 次** | +| 总耗时 | 153s | **~5s** | +| 退出码 | 0 | 0 | + +#### 1.5 翻译管线设计(继承 military-digest-v3) + +**核心策略**:中英分离,中文文章零 API 调用,外文文章并发翻译。 + +**中文检测**:汉字占比 > 20% 判定为中文(`is_chinese()` 函数)。 + +**翻译流程**: + +``` +所有文章 + ├─ 中文文章 → translated_title = title(零API调用) + │ translated_content = content[:300](截取即可) + └─ 外文文章 → 提交到 ThreadPoolExecutor(max_workers=AI_CONCURRENCY) + ├─ translate_title():标题翻译(temperature=0.2, max_tokens=200) + └─ translate_content():正文前500词翻译 → 截取300字(max_tokens=800) +``` + +**并发控制**: + +| 参数 | 值 | 说明 | +|------|-----|------| +| `AI_CONCURRENCY` | 8 | 默认并发数,可通过配置调整 | +| `temperature` | 0.2 | 翻译低温度,确保准确性 | +| 失败降级 | 原文填充 | 翻译失败时 `translated_title = title`,不中断流程 | + +**关键实现**: + +```python +def batch_translate_titles(articles, api_key, base_url, model, max_concurrent=None): + """中英分离:中文直接填充,外文并发翻译标题""" + chinese_articles = [] + foreign_articles = [] + + for a in articles: + if is_chinese(a['title']): + a['translated_title'] = a['title'] + chinese_articles.append(a) + else: + foreign_articles.append(a) + + # 外文并发翻译 + with ThreadPoolExecutor(max_workers=max_concurrent) as executor: + futures = {executor.submit(translate_title, a, ...): a for a in foreign_articles} + for future in as_completed(futures): + chinese_articles.append(future.result()) + + return chinese_articles +``` + +**设计优势**: +- 中文文章零 API 调用,节省 40%+ 翻译 token +- 标题和正文分离翻译,先翻译标题再决定是否需要翻译正文(可配合关键词筛选) +- 外文并发翻译,翻译耗时 = 单篇耗时(而非 N × 单篇耗时) + +#### 1.6 监控统计设计(继承 military-digest-v3) + +**阶段耗时统计(11 阶段)**: + +| 阶段 key | 中文标签 | 统计内容 | +|------|------|------| +| `rss_fetch` | RSS抓取 | 所有源并行抓取耗时 | +| `time_filter` | 时间过滤 | 按时间窗口过滤耗时 | +| `deduplicate` | 去重 | 去重处理耗时 | +| `translate_titles` | 标题翻译 | 外文标题并发翻译耗时 | +| `keyword_filter` | 关键词筛选 | 关键词命中筛选耗时 | +| `translate_contents` | 正文翻译 | 外文正文并发翻译耗时 | +| `ai_analyze` | AI评分分类 | 批量AI评分分类耗时 | +| `webzine_generate` | 网摘生成 | TOP3网摘并发生成耗时 | +| `category_overview` | 分类介绍 | 4类分类介绍并发生成耗时 | +| `report_generate` | 报告生成 | Markdown报告拼接写入耗时 | +| `image_generate` | 图片生成 | 网摘长图渲染耗时 | + +**API 调用统计**:按用途(标题翻译/正文翻译/AI评分分类/网摘生成/分类介绍)和模型分组,记录调用次数、token 估算、重试次数、失败次数。 + +**源抓取统计**:成功/失败源计数,失败源名称列表。 + +**文章统计**:总数、缓存命中数、本次处理数、缓存命中率。 + +**报告示例**(运行结束自动打印): + +``` +================================================================ + 📊 运行监控报告 +================================================================ + +── ⏱ 阶段耗时 ── + RSS抓取 7.5s + 时间过滤 0.0s + 去重 0.0s + 标题翻译 17.9s + 关键词筛选 0.0s + 正文翻译 9.9s + AI评分分类 30.7s + 网摘生成 53.3s + 分类介绍 33.5s + 报告生成 0.0s + 图片生成 0.3s + ──────────────────── + 合计 153.1s / 总运行 153s + +── 📡 文章统计 ── + 总计: 15 缓存命中: 0 本次处理: 15 + 缓存命中率: 0% + +── 🤖 API调用统计 ── + [按用途] + 标题翻译: 7次 / tokens≈1400 / 重试0 + 正文翻译: 6次 / tokens≈4800 / 重试0 + AI评分分类: 15次 / tokens≈12000 / 重试0 + 网摘生成: 5次 / tokens≈4000 / 重试2 + 分类介绍: 4次 / tokens≈600 / 重试0 + [按模型] + deepseek-v3: 37次 / tokens≈22800 + +── 📰 源抓取统计 ── + 源总数: 5 成功: 5 失败: 0 + +================================================================ +``` + +**数据字段规范**(扩展版): + +```json +{ + "title": "内容标题", + "link": "原始链接", + "published": "发布时间", + "source": "来源(公众号名称)", + "content": "Markdown格式的正文内容", + "translated_title": "翻译后的中文标题", + "translated_content": "翻译后的中文正文", + "tags": "LLM识别的标签", + "score": "LLM评分(0-100)", + "summary": "LLM生成的中文摘要", + "fetched_at": "抓取时间", + "entities": ["提取的实体列表"], + "reporting_angle": "报道角度", + "webzine_text": "参考消息风格网摘文本(Top N 专属)", + "from_cache": "是否来自缓存" +} +``` + +**验收标准**: +- [ ] 稳定采集50+ RSS源 +- [ ] 数据入库成功率 > 95% +- [ ] SQLite 缓存三级覆盖(文章/网摘/分类摘要) +- [ ] 缓存命中时 API 调用为 0 +- [ ] 中英分离正确,中文文章零翻译 API 调用 +- [ ] 监控报告包含阶段耗时、API统计、源成功率、缓存命中率 +- [ ] 日志系统可定位问题 + +--- + +### 阶段二:Fetch循环与双层评分系统(Week 3-4) +**目标**:实现实时采集、双层评分(硬约束+动态阈值融合)、即时推送 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 2.1 | Fetch循环引擎 | 每30分钟循环一次,抓取RSS→中英分离→并发翻译→评分 | fetch引擎 | 1.7 | +| 2.2 | **第一层:单篇评分Prompt** | 单篇文章重要性评分(0-100),**含硬约束** | `prompts/article_score.txt` | 2.1 | +| 2.3 | **硬约束过滤器** | 非时政军事上限、KOL转述上限、时效衰减 | 硬约束模块 | 2.2 | +| 2.4 | 即时推送判断 | 单篇≥90分触发即时推送候选 | 推送判断模块 | 2.3 | +| 2.5 | 飞书Webhook推送 | 飞书群机器人推送(Fetch 循环即时推送 + Push 循环日报推送) | 飞书推送适配器 | 2.4 | +| 2.6 | systemd服务化 | 使用systemd timer管理服务 | systemd配置 | 2.1 | + +#### 双层评分架构设计 + +``` +第一层:单篇文章评分(硬约束) 第二层:实体簇热度(动态阈值) +├── 输入:单篇RSS文章(已翻译) ├── 输入:同一实体的多篇报道(已硬约束评分) +├── 处理:LLM评分 + 硬约束修正 ├── 处理:统计特征 + 动态阈值计算 +└── 输出:0-100分(已约束) └── 输出:热点判定 + ├── ≤79:非目标领域,过滤 ├── 低于阈值:普通关注 + ├── 80-89:一般关注,进入聚类 └── 高于阈值:热点,优先整合 + └── 90+:高优先级,即时推送候选 +``` + +#### 第一层:评分硬约束设计 + +**Prompt核心约束**: +```markdown +## 评分规则(硬性约束) + +1. **非时政/军事/科技主题上限 79 分** + - 如果内容不属于目标领域(如纯娱乐八卦、生活琐事),最高只能给 79 分 + +2. **KOL 转述上限 89 分** + - 如果只是 KOL/大V 对已有信息的转述/评论,而非原创信息,最高只能给 89 分 + +3. **时效性衰减** + - 发布超过24小时:分数×0.9 + - 发布超过48小时:分数×0.8 + +4. **90+ 分必须同时满足**: + - 重大事件/突破性进展/政策发布 + - 一手信息源(官方发布、权威媒体首发) + - 对目标领域有实质性影响 + +5. **标签禁止空泛** + - 禁止:["军事", "新闻", "热点"] + - 要求:["歼-35A", "舰载战斗机", "隐身性能", "海军航空兵"] +``` + +**代码实现**: +```python +def apply_score_constraints(entry: dict, raw_score: int) -> tuple[int, list[str]]: + """ + 应用评分硬约束 + 返回:修正后的分数,应用的约束标签列表 + """ + constraints_applied = [] + + # 约束1:非目标领域上限79 + if not is_target_domain(entry['content']): + raw_score = min(raw_score, 79) + constraints_applied.append("非目标领域") + + # 约束2:KOL转述上限89 + if is_kol_repost(entry): + raw_score = min(raw_score, 89) + constraints_applied.append("KOL转述") + + # 约束3:时效性衰减 + hours_old = get_hours_since_published(entry) + if hours_old > 24: + decay_factor = 0.9 ** (hours_old // 24) + raw_score = int(raw_score * decay_factor) + constraints_applied.append(f"时效衰减({hours_old}h)") + + return raw_score, constraints_applied +``` + +**第一层输出分级**: +| 分数段 | 处理策略 | 说明 | +|--------|---------|------| +| ≤79 | 过滤,不入库 | 非目标领域内容 | +| 80-89 | 入库,进入聚类 | 一般关注,参与动态阈值计算 | +| 90+ | 入库,即时推送候选 | 高优先级,同时触发即时推送判断 | + +**验收标准**: +- [ ] Fetch循环每30分钟稳定运行 +- [ ] 翻译管线集成正确(中英分离+并发翻译) +- [ ] 硬约束生效(非目标领域≤79,KOL转述≤89,时效衰减) +- [ ] 90+文章触发即时推送候选 +- [ ] SQLite缓存正常工作(二次运行零API调用) +- [ ] 监控统计准确记录各阶段数据 +- [ ] systemd服务可一键启动/停止 + +--- + +### 阶段三:实体提取与细粒度聚类(Week 5-6) +**目标**:实现细粒度聚类,区分同一实体的不同报道角度 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 3.1 | 实体提取Prompt | 设计并优化实体提取提示词 | `prompts/extract_entities.txt` | 2.6 | +| 3.2 | 角度分类Prompt | 设计报道角度分类提示词 | `prompts/classify_angle.txt` | 2.6 | +| 3.3 | 批量实体提取 | 实现50篇/批并发调用,小模型处理 | 实体提取服务 | 3.1 | +| 3.4 | 角度分类服务 | 批量角度分类,与实体提取并行 | 角度分类服务 | 3.2 | +| 3.5 | 实体归一化 | 大模型消歧,合并别名 | 实体归一化服务 | 3.3 | +| 3.6 | 聚类算法 | 基于实体+角度的细粒度聚类 | 聚类引擎 | 3.4, 3.5 | +| 3.7 | 记忆系统 | 避免同一信息反复推送 | 去重模块 | 3.6 | + +**验收标准**: +- [ ] 实体提取准确率 > 85% +- [ ] 角度分类准确率 > 80% +- [ ] 聚类后实体簇数量合理(200篇→30-50个簇) +- [ ] 同一信息不重复推送 + +--- + +### 阶段四:热度评价与动态阈值(Week 7-8) +**目标**:实现第二层筛选——基于硬约束后分数的动态热点识别 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 4.1 | **实体簇热度计算** | 融合硬约束分数的多维度热度计算 | 热度指标服务 | 3.6 | +| 4.2 | **第二层:动态阈值算法** | 基于硬约束后分数分布的自适应阈值 | 阈值计算模块 | 4.1 | +| 4.3 | 热点识别引擎 | 硬约束过滤 + 动态阈值筛选的双层判定 | 热点识别服务 | 4.2 | +| 4.4 | 热点精评Prompt | 大模型深度评分(Top 30热点簇) | `prompts/precise_heat.txt` | 4.3 | + +#### 第二层:动态阈值设计(融合硬约束分数) + +**实体簇热度计算**: +```python +class Cluster: + def __init__(self, entity_name: str): + self.entity_name = entity_name + self.articles = [] # 已硬约束评分的文章列表 + self.hot_score = 0 # 综合热度分 + + def calculate_hot_score(self) -> int: + """ + 计算实体簇热度,融合硬约束后的单篇分数 + """ + if not self.articles: + return 0 + + # 基础分数:最高分文章的分数(已硬约束) + max_score = max(a['score'] for a in self.articles) + + # 传播热度:报道数量 × log(来源多样性) + report_count = len(self.articles) + source_diversity = len(set(a['source'] for a in self.articles)) + propagation_heat = report_count * math.log(source_diversity + 1) + + # 角度覆盖度:不同报道角度数 / 6 + angles = set(a.get('reporting_angle', 'unknown') for a in self.articles) + angle_coverage = len(angles) / 6 + + # 高优先级文章加成(90+文章额外加权) + high_priority_count = sum(1 for a in self.articles if a['score'] >= 90) + priority_bonus = high_priority_count * 5 # 每篇90+加5分 + + # 综合计算 + self.hot_score = min(100, int( + max_score * 0.4 + # 单篇最高分权重40% + min(propagation_heat * 3, 30) + # 传播热度权重30% + angle_coverage * 20 + # 角度覆盖权重20% + priority_bonus # 高优先级加成10% + )) + + return self.hot_score +``` + +**动态阈值算法(融合版)**: +```python +def calculate_hotspot_threshold(clusters: list[Cluster], date: str) -> float: + """ + 基于硬约束后分数分布计算动态阈值 + 同时考虑统计特征和高优先级文章数量 + """ + # 获取当日所有簇的热度分数(已融合硬约束) + scores = [c.hot_score for c in clusters] + + if len(scores) < 5: + return 75 # 数据不足时使用保底阈值 + + # 统计特征 + mean_score = mean(scores) + std_score = std(scores) + median_score = median(scores) + + # 方法1:统计阈值(均值+1.5倍标准差) + threshold_stat = mean_score + 1.5 * std_score + + # 方法2:保底阈值(至少3条或前10%) + min_count = max(3, len(scores) * 0.1) + sorted_scores = sorted(scores, reverse=True) + threshold_adaptive = sorted_scores[min_count - 1] + + # 方法3:硬约束保底(考虑90+高优先级文章数量) + high_priority_count = sum( + 1 for c in clusters + if any(a['score'] >= 90 for a in c.articles) + ) + if high_priority_count >= 3: + threshold_backup = median_score + else: + threshold_backup = mean_score + 0.5 * std_score + + # 取三者中较低值,确保热点不被遗漏 + final_threshold = min(threshold_stat, threshold_adaptive, threshold_backup) + + logger.info(f"动态阈值计算: 统计={threshold_stat:.1f}, " + f"保底={threshold_adaptive:.1f}, 硬约束保底={threshold_backup:.1f}, " + f"最终={final_threshold:.1f}") + + return final_threshold +``` + +**双层热点判定流程**: +```python +def identify_hotspots(clusters: list[Cluster], date: str) -> tuple[list[Cluster], list[Cluster]]: + """ + 双层热点识别:硬约束过滤 + 动态阈值筛选 + """ + # 第一层:硬约束过滤(单篇层面已处理,此处检查) + valid_clusters = [] + for c in clusters: + if all(a['score'] <= 79 for a in c.articles): + continue + valid_clusters.append(c) + + # 计算各簇热度(融合硬约束分数) + for c in valid_clusters: + c.calculate_hot_score() + + # 第二层:计算动态阈值 + threshold = calculate_hotspot_threshold(valid_clusters, date) + + # 阈值筛选 + 额外条件 + hotspots = [] + normal = [] + for c in valid_clusters: + if c.hot_score >= threshold: + has_high_priority = any(a['score'] >= 90 for a in c.articles) + significantly_above = c.hot_score >= threshold + 10 + + if has_high_priority or significantly_above: + hotspots.append(c) + else: + normal.append(c) + else: + normal.append(c) + + hotspots.sort(key=lambda x: (x.hot_score, x.max_article_score), reverse=True) + + return hotspots, normal +``` + +**融合设计优势**: +| 层级 | 作用 | 输入 | 输出 | +|------|------|------|------| +| 第一层(硬约束) | 质量控制 | 单篇文章 | 过滤非目标领域,标记高优先级 | +| 第二层(动态阈值) | 热点发现 | 实体簇(多篇聚合) | 识别突发热点,自适应当日分布 | + +**验收标准**: +- [ ] 实体簇热度计算正确(融合硬约束分数) +- [ ] 动态阈值自适应当日分布 +- [ ] 双层筛选协同工作(硬约束过滤→动态阈值筛选) +- [ ] 热点识别召回率 > 90%,误报率 < 20% + +--- + +### 阶段五:Push循环与三层整合(Week 9-10) +**目标**:实现定时汇总、三层整合(摘要+洞察+网摘)、日报生成 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 5.1 | Push循环引擎 | 每日定时运行,读取碎片化信息→整合→推送 | push引擎 | 4.4 | +| 5.2 | 摘要级整合Prompt | 多源报道合并为50-80字精炼摘要 | `prompts/summary_integrate.txt` | 5.1 | +| 5.3 | 洞察级整合Prompt | Top 10生成结构化深度分析(**双视角设计**) | `prompts/insight_integrate.txt` | 5.1 | +| 5.4 | **Prompt防退化模块** | 禁止套话、要求从素材出发 | 防退化检查器 | 5.2, 5.3 | +| 5.5 | 摘要整合服务 | 小模型批量处理全量簇 | 摘要整合服务 | 5.2, 5.4 | +| 5.6 | 洞察整合服务 | 大模型逐个处理Top 10(**双视角输出**) | 洞察整合服务 | 5.3, 5.4 | +| 5.7 | **网摘生成服务** | Top 3 参考消息风格网摘(继承 military-digest-v3) | 网摘生成服务 | 5.1 | +| 5.8 | 时间线提取 | 从多源报道中提取事件时间线 | 时间线提取模块 | 5.5 | +| 5.9 | 多角度整合 | 同一事件不同角度报道的整合 | 多角度整合模块 | 5.6 | + +#### 双视角洞察设计(借鉴AI Daily) + +**设计理念**:metadata(新闻编辑视角,事实压缩)+ 正文(情报分析师视角,趋势判断)解耦 + +**Prompt设计**: +```markdown +## Part 1: Metadata(新闻编辑视角) + +请生成以下结构化信息: +- title: 10字以内的标题(事实陈述,无修饰) +- lead: 20字以内的导语(核心事实) +- highlights: 3-5个关键要点(bullet points,纯事实) + +约束:只陈述事实,不做趋势判断,不用形容词。 + +## Part 2: 洞察正文(情报分析师视角) + +基于多源报道,识别: +1. 事件脉络:关键时间节点和进展 +2. 多角度分析:技术维度、战略维度、舆论维度 +3. 影响与展望:短期影响、中长期趋势、值得关注信号 + +约束: +- 禁止空泛评论(如"意义重大""影响深远") +- 每个观点必须有素材支撑 +- 使用具体名称(装备型号/政策名称/组织名称),不用"某些""部分"等模糊指代 +``` + +**输出示例**: +```markdown +--- +title: "歼-35A列装部队" +lead: "空军官方确认歼-35A已列装首批作战部队" +highlights: + - 首次公开确认进入实战化部署阶段 + - 隐身性能对标F-35C,可能在舰载领域形成优势 + - 多国媒体关注其对西太平洋军事平衡的影响 +--- + +## 事件脉络 +- 2024-11:珠海航展首次公开亮相 +- 2025-03:完成舰载适配测试 +- 2025-05:空军确认列装首批作战部队【新进展】 + +## 多角度分析 +- **技术维度**:隐身涂层、航电系统、舰载适配均有突破 +- **战略维度**:提升海军航空兵远海作战能力 +- **舆论维度**:外媒关注中美舰载机代差缩小 + +## 影响与展望 +短期:提升海军航母编队作战能力 +中期:可能在西太平洋形成局部优势 +值得关注:后续舰载版测试进展、出口动向 +``` + +#### 网摘生成设计(继承 military-digest-v3) + +**定位**:在三层整合中,网摘是面向直接传播的成品内容。不同于摘要(速览用)和洞察(分析用),网摘采用《参考消息》官方新闻报道格式,适合直接推送到飞书群或作为独立内容分发。 + +**核心特性**: + +| 特性 | 说明 | +|------|------| +| 风格 | 《参考消息》官方新闻报道格式:标题+原标题+发布日期+正文+价值点 | +| 来源识别 | 自动区分微信公众号源("据公众号 XXX 报道")和普通源("据 XXX 报道") | +| 字数校验 | 正文严格 280-320 字(含标点),价值点 35-45 字 | +| 重试机制 | 不达标自动重试,最多 3 次,每次带具体反馈("正文只有 XX 字,太少!") | +| 生成范围 | Top 3 文章,与摘要(全量)和洞察(Top 10)互补 | +| 输出格式 | 文本文件(.txt)+ Pillow 渲染长图(.png) | + +**网摘 Prompt 核心约束**: + +```markdown +标题要求 +简洁、客观、中性、信息密度高 +结构:主体 + 事件 + 核心态势 +不抒情、不夸张、不用网络用语 + +正文格式要求 +开头第一句必须加:据[来源]报道 +正文风格:客观、平实、严谨、书面化,类似外电编译稿 +必须充分展开:补充背景、说明意义、分析影响、展望前景 +全文一段到底,不分段 + +【字数强制要求 - 必须严格执行】 +正文字数严格控制在 280~320 字(含标点),一个字都不能少,一个字都不能多! +如果内容不够,请合理补充相关背景、行业态势、同类项目对比等专业内容 + +价值点要求 +正文结束后空一行,再写价值点 +价值点严格 35~45 字(含标点) +句式结构:事件 - 影响 / 后果 - 值得关注 +``` + +**字数校验重试机制**: + +```python +def generate_webzine_for_article(article, api_key, base_url, model, max_retries=3): + """为单篇文章生成参考消息风格网摘,支持字数校验重试""" + for attempt in range(max_retries): + result = call_ai([...], purpose="网摘生成") + + # 提取正文部分 + body_text = extract_body(result) + body_len = len(body_text) + + if 280 <= body_len <= 320: + return result # 达标,直接返回 + + # 不达标,在 prompt 末尾追加反馈 + if body_len < 280: + prompt += f"\n\n【上次反馈:正文只有{body_len}字,太少!请大幅增加内容!】" + else: + prompt += f"\n\n【上次反馈:正文有{body_len}字,太多!请精简内容!】" + + return last_result # 重试耗尽,返回最后结果 +``` + +**输出示例**: + +``` +标题:歼-35A隐身舰载战斗机正式列装海军航空兵部队 +原标题:China's J-35A stealth fighter enters service with naval aviation +发布日期:2025年05月24日 +正文:据解放军报报道,中国海军航空兵部队已于近日正式列装歼-35A隐身舰载战斗机,标志着中国成为继美国之后第二个具备隐身舰载机作战能力的国家。歼-35A采用双发中型设计,配备国产涡扇-19发动机,最大起飞重量约30吨,雷达反射截面积据分析优于F-35C。该机配备有源相控阵雷达、分布式光电系统以及先进的电子战套件,可携带霹雳-15中远程空空导弹和鹰击-12超音速反舰导弹等多型武器。军事专家指出,歼-35A的列装将大幅提升中国海军航母编队的制空作战和远程打击能力,特别是在西太平洋方向形成对F-35C的局部数量优势。美国海军战争学院报告认为,中国正加速缩小与美国在舰载航空领域的技术差距,预计到2030年前后将形成至少3个歼-35A舰载机联队的规模。日本防卫省已表示将密切关注相关动向。 +价值点:歼-35A列装标志着中国成为全球第二个拥有隐身舰载机的国家,将显著改变西太平洋海上力量对比,后续量产规模和舰载适配进展值得持续关注。 +``` + +**三层整合对比**: + +| 层次 | 产品 | 覆盖范围 | 定位 | 输出格式 | +|------|------|:---:|------|------| +| 第一层 | 摘要级速览 | 全量(所有簇) | 快速浏览,50-80字精炼 | Markdown表格 | +| 第二层 | 洞察级分析 | Top 10 热点 | 深度分析,双视角(metadata+正文) | Markdown段落 | +| 第三层 | 参考消息网摘 | Top 3 热点 | 成品内容,直接传播 | TXT文本 + PNG长图 | + +#### Prompt防退化设计(借鉴AI Daily) + +**禁止套话列表**: +```python +FORBIDDEN_PHRASES = [ + "意义重大", "影响深远", "引发关注", "备受瞩目", + "深水区", "拐点", "白热化", "新纪元", "里程碑", + "不容忽视", "值得注意", "值得关注", # 除非后接具体内容 + "某些", "部分", "一些", "相关", # 模糊指代 +] + +def check_degeneration(content: str) -> list[str]: + """检查内容是否包含禁止套话""" + violations = [] + for phrase in FORBIDDEN_PHRASES: + if phrase in content: + violations.append(phrase) + return violations +``` + +**Prompt中的防退化指令**: +```markdown +## 输出约束(防退化) + +1. 禁止使用以下套话: + - "意义重大""影响深远""引发关注""备受瞩目" + - "深水区""拐点""白热化""新纪元""里程碑" + +2. 每个观点必须有素材支撑: + - 错误:该装备性能先进 + - 正确:该装备隐身系数0.01,优于F-35的0.02 + +3. 使用具体名称,禁止模糊指代: + - 错误:某些国家表示关注 + - 正确:日本防卫省发布关注声明 + +4. 从素材出发,每次措辞应不同,避免模板化输出 +``` + +**验收标准**: +- [ ] 摘要信息完整度 > 90% +- [ ] 洞察分析覆盖双视角(metadata + 正文) +- [ ] 网摘正文字数达标率 > 90%(280-320字) +- [ ] 网摘价值点字数达标率 > 90%(35-45字) +- [ ] 无禁止套话出现 +- [ ] 整合内容无明显事实错误 + +--- + +### 阶段六:日报生成与跨日关联(Week 11-12) +**目标**:生成结构化日报(含Sentinel标记),实现跨日关联 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 6.1 | 日报模板设计 | Markdown模板,包含热点、速览、数据概览、网摘 | `templates/daily_report.md` | 5.9 | +| 6.2 | **Sentinel分段标记** | 单文件多板块精确管理 | `src/utils/sentinel.py` | 6.1 | +| 6.3 | **跨日关联模块** | 从历史日报提取上下文,追踪事件脉络 | `src/processors/cross_day.py` | 6.2 | +| 6.4 | 排序算法 | 综合热度、时效、重要性的排序逻辑 | 排序模块 | 4.4 | +| 6.5 | 日报渲染服务 | 按模板组装数据,生成Markdown(含Sentinel) | 日报渲染服务 | 6.1, 6.2, 6.4 | +| 6.6 | 网摘图片渲染 | Pillow渲染TOP3合并长图,含Banner标题 | 图片渲染服务 | 5.7 | +| 6.7 | Web归档站点 | 部署在线阅览站点 | Web站点 | 6.5 | +| 6.8 | 数据保留策略 | fetch保留2天,notify保留2天,push保留5天 | 清理策略 | 6.7 | + +#### 简化版Sentinel分段标记设计 + +**标记格式**: +```markdown +--- +title: "每日情报早报" +date: "2026-05-24" +stats: + total_entities: 87 + hotspot_count: 10 +--- + + +## 📋 全量新闻速览 + +| # | 实体 | 摘要 | 来源数 | 热度 | +|---|------|------|--------|------| +| 1 | 歼-35A | 空军确认列装\|隐身性能对标F-35C | 8 | ★★★★★ | + + + +## 🔥 热点深度洞察 + +### 1. 歼-35A列装进展 [持续跟踪] +> 速览:空军官方确认歼-35A已列装首批作战部队 [8源报道] + +**事件脉络** +- 5月20日:首次公开亮相 +- 5月24日:【新进展】确认列装部队 +... + + + +## 📰 参考消息网摘 + +(网摘文本内容,含标题/原标题/发布日期/正文/价值点) + +``` + +**核心代码**: +```python +import re +from datetime import datetime, timedelta + +def extract_section(content: str, section_name: str) -> str: + """从日报内容中提取指定section""" + pattern = rf'(.*?)' + match = re.search(pattern, content, re.DOTALL | re.IGNORECASE) + return match.group(1).strip() if match else "" + +def get_historical_context(entity_name: str, days: int = 7) -> dict: + """获取某实体近N天的历史报道上下文""" + context = { + "first_seen": None, + "previous_summaries": [], + "previous_insights": [], + "mention_count": 0 + } + + for i in range(days): + date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d") + file_path = f"data/reports/daily-{date}.md" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + if entity_name in content: + context["mention_count"] += 1 + if context["first_seen"] is None: + context["first_seen"] = date + + summary_section = extract_section(content, "summary") + for line in summary_section.split('\n'): + if entity_name in line and line.strip().startswith('|'): + context["previous_summaries"].append({ + "date": date, + "content": line.strip() + }) + + insights_section = extract_section(content, "insights") + if entity_name in insights_section: + insight_blocks = re.split(r'### \d+\.', insights_section) + for block in insight_blocks: + if entity_name in block: + context["previous_insights"].append({ + "date": date, + "content": block.strip()[:500] + }) + break + except FileNotFoundError: + continue + + return context + +def generate_cross_day_marker(entity_name: str) -> str: + """生成跨日标记""" + history = get_historical_context(entity_name, days=7) + + if history["mention_count"] == 0: + return "首次报道" + elif history["mention_count"] == 1: + return "持续跟踪" + else: + return f"持续跟踪({history['mention_count']}次)" +``` + +**验收标准**: +- [ ] 日报生成时间 < 20分钟 +- [ ] Sentinel标记正确,可精确提取summary/insights/webzine +- [ ] 跨日关联可追踪实体历史报道 +- [ ] 网摘长图渲染正常(合并多篇 + Banner标题) +- [ ] Web站点可正常访问 + +--- + +### 阶段七:优化与迭代(Week 13-14) +**目标**:持续优化,功能扩展 + +| 序号 | 任务 | 描述 | 优先级 | +|------|------|------|--------| +| 7.1 | Prompt优化 | 基于实际效果迭代优化提示词 | 高 | +| 7.2 | 反馈闭环 | 收集用户反馈,标注数据,优化模型 | 高 | +| 7.3 | 趋势分析 | 7日热点趋势图,周期性报告 | 中 | +| 7.4 | 个性化推荐 | 基于用户阅读历史的个性化排序 | 低 | + +--- + +## 四、技术栈建议 + +### 4.1 核心组件 +| 组件 | 推荐方案 | 说明 | +|------|----------|------| +| 数据采集 | Python + feedparser | RSS解析 | +| 数据存储 | SQLite + JSON文件 | 数据库+文件双存储 | +| LLM调用 | OpenAI SDK / 国产模型SDK | 统一接口封装 | +| 翻译 | 中英分离 + ThreadPoolExecutor并发 | 中文文章零API调用 | +| 缓存 | SQLite 三级缓存 + 自动迁移 | 断点续跑,二次运行 ~5s | +| 图片渲染 | Pillow + 中文字体fallback | 网摘长图生成 | +| 任务调度 | systemd timer | 系统级服务管理 | +| Web展示 | Hugo / Flask | 静态/动态站点 | +| 推送 | 飞书 Webhook | 群机器人消息推送 | +| 日志 | Python logging + systemd journal | 集中日志管理 | +| 监控 | 11阶段耗时 + API统计 + 源成功率 | 运行结束自动打印报表 | + +### 4.2 模型选择 +| 任务类型 | 推荐模型 | 预估成本 | +|----------|----------|----------| +| 标题翻译 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 正文翻译 | DeepSeek-V3 / Qwen-Plus | ~0.002元/次 | +| 实体提取 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 角度分类 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 单篇评分 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 摘要整合 | DeepSeek-V3 / Qwen-Plus | ~0.002元/次 | +| 网摘生成 | DeepSeek-V3 / Qwen-Plus | ~0.005元/次(含重试) | +| 实体消歧 | GPT-4o / Claude-3.5-Sonnet | ~0.01元/次 | +| 热点精评 | GPT-4o / Claude-3.5-Sonnet | ~0.02元/次 | +| 洞察整合 | GPT-4o / Claude-3.5-Sonnet | ~0.1元/次 | + +**日成本估算**: +- 200-500篇/日 × 0.001元(小模型)= 0.2-0.5元 +- 外文翻译(按30%外文率):60-150篇 × 0.003元 = 0.18-0.45元 +- 100个簇 × 0.001元(小模型)= 0.1元 +- 3篇网摘 × 0.005元 = 0.015元 +- 10个洞察 × 0.1元(大模型)= 1元 +- **总计:约1.5-2.1元/日** + +--- + +## 五、项目结构 + +``` +news-intelligence-system/ +├── config/ +│ ├── rss_sources.json # RSS源配置 +│ ├── models.yaml # 模型配置 +│ └── systemd/ # systemd服务配置 +├── src/ +│ ├── collectors/ # 数据采集 +│ │ └── rss_collector.py +│ ├── processors/ # 处理管道 +│ │ ├── translator.py # 翻译管线(中英分离+并发翻译) +│ │ ├── entity_extractor.py +│ │ ├── angle_classifier.py +│ │ ├── cluster_engine.py +│ │ ├── heat_calculator.py +│ │ └── cross_day.py # 跨日关联 +│ ├── integrators/ # 内容整合 +│ │ ├── summary_integrator.py +│ │ ├── insight_integrator.py +│ │ └── webzine_generator.py # 网摘生成(参考消息风格+字数校验) +│ ├── generators/ # 报告生成 +│ │ ├── daily_report_generator.py +│ │ ├── image_renderer.py # 网摘图片渲染(Pillow) +│ │ └── web_renderer.py +│ ├── push/ # 推送平台 +│ │ ├── base.py +│ │ └── feishu.py # 飞书 webhook(即时+日报双模式) +│ ├── storage/ # 存储层 +│ │ ├── cache.py # SQLite三级缓存(文章/网摘/分类摘要) +│ │ └── file_storage.py # JSON文件读写 +│ ├── monitor/ # 监控统计 +│ │ └── monitor.py # 阶段耗时+API消耗+成功率+缓存命中率 +│ └── utils/ # 工具函数 +│ ├── sentinel.py # Sentinel分段标记 +│ ├── prompt_checker.py # Prompt防退化检查 +│ └── language.py # 中文检测(is_chinese) +├── prompts/ # 提示词文件 +│ ├── article_score.txt # 单篇评分(含硬约束) +│ ├── extract_entities.txt +│ ├── classify_angle.txt +│ ├── quick_heat.txt +│ ├── precise_heat.txt +│ ├── summary_integrate.txt +│ ├── insight_integrate.txt # 双视角洞察 +│ └── webzine_generate.txt # 参考消息网摘(含字数约束) +├── templates/ +│ └── daily_report.md # 含Sentinel标记(summary/insights/webzine) +├── data/ +│ ├── cache.db # SQLite缓存数据库 +│ ├── fetch/ # fetch-yyyy-mm-dd.json +│ ├── notify/ # notify-yyyy-mm-dd.json +│ └── reports/ # daily-yyyy-mm-dd.md +├── output/ # 输出文件 +│ ├── report_YYYYMMDD.md # Markdown日报 +│ ├── webzine_YYYYMMDD.txt # 网摘文本 +│ └── webzine_YYYYMMDD.png # 网摘合并长图 +├── web/ # Web归档站点 +├── tests/ +├── scripts/ +│ ├── fetch_loop.py +│ ├── push_loop.py +│ └── setup_systemd.sh +├── requirements.txt +└── README.md +``` + +--- + +## 六、关键风险与应对 + +| 风险 | 影响 | 应对措施 | +|------|------|----------| +| RSS源失效 | 数据缺失 | 多源备份,监控告警 | +| LLM API限流 | 处理延迟 | 批量处理,重试机制,降级策略 | +| 翻译质量不稳定 | 后续分析偏差 | 低温度(0.2)翻译,失败时原文填充 | +| 缓存数据库损坏 | 缓存失效 | 连接降级不影响主流程,自动重建 | +| 网摘字数不达标 | 输出质量下降 | 3次重试校验,每次带具体反馈 | +| 实体识别错误 | 聚类偏差 | 人工标注反馈,Prompt迭代 | +| 热点漏识别 | 信息缺失 | 动态阈值保底机制,人工复核 | +| 成本超支 | 预算超支 | Token监控,模型降级,配额控制 | +| 进程崩溃 | 服务中断 | systemd自动重启 | +| LLM输出退化 | 质量下降 | **Prompt防退化检查** | + +--- + +## 七、里程碑与交付物 + +| 里程碑 | 时间 | 交付物 | +|--------|------|--------| +| M1 | Week 2 | 稳定运行的数据采集系统(含翻译管线、SQLite缓存、监控统计) | +| M2 | Week 4 | Fetch循环上线,**评分硬约束生效**,飞书即时推送可用 | +| M3 | Week 6 | 细粒度聚类引擎,实体提取准确率>85% | +| M4 | Week 8 | 动态热点识别系统,召回率>90% | +| M5 | Week 10 | Push循环上线,**三层整合(摘要+洞察+网摘)+Prompt防退化** | +| M6 | Week 12 | **Sentinel跨日关联上线**,网摘长图渲染,Web归档站点 | +| M7 | Week 14 | 持续优化迭代 | + +--- + +## 八、借鉴与继承总结 + +### 8.1 架构层面(借鉴 AI Daily) +1. **双循环架构**:Fetch循环(实时)+ Push循环(定时) +2. **systemd服务化**:替代Python内部定时,提升稳定性 +3. **文件存储规范**:fetch/notify/reports三种文件类型,明确保留策略 + +### 8.2 LLM应用层面(借鉴 AI Daily) +1. **评分硬约束**:非目标领域≤79、KOL转述≤89,有效控制信息质量 +2. **Prompt防退化**:禁止套话、要求从素材出发,避免LLM输出风格趋同 +3. **双视角洞察**:metadata(事实压缩)+ 正文(趋势判断)解耦 + +### 8.3 数据管理层面(借鉴 AI Daily) +1. **Sentinel分段标记**:单文件多板块精确管理,支持跨日关联 +2. **跨日关联**:从历史日报提取上下文,追踪事件发展脉络 +3. **数据保留策略**:自动清理过期文件 + +### 8.4 工程资产层面(继承 military-digest-v3) +1. **SQLite三级缓存**:文章+网摘+分类摘要,含自动迁移和过期清理,二次运行 ~5s +2. **翻译管线**:中英分离(汉字占比>20%判定)+ 外文并发翻译,中文零API调用 +3. **监控统计**:11阶段耗时 + API按用途/模型分组 + 源抓取成功率 + 缓存命中率 +4. **网摘生成**:参考消息风格 + 公众号源智能识别 + 280-320字校验重试(3次) +5. **图片渲染**:Pillow命令模式 + 中文字体多级fallback +6. **飞书推送**:webhook文本推送 + 5级异常分层捕获 + 失败优雅降级 + +### 8.5 工程层面 +1. **日志集中管理**:systemd journal便于问题定位 +2. **推送聚焦飞书**:webhook消息推送,覆盖即时快讯和日报两种场景 +3. **成本可控**:每天约1.5-2.1元(含翻译),缓存命中时几乎零成本 + +--- + +## 附录A:military-digest-v3 实际运行验证数据 + +> 以下数据来自 military-digest-v3 系统 2026-05-14 的实际运行验证,为新系统的设计和成本估算提供基准参考。 + +### A.1 全链路冷启动验证 + +清除所有缓存和输出文件后运行,完整链路通过: + +| 阶段 | 耗时 | 状态 | +|------|:---:|:---:| +| RSS 抓取(5源) | 7.5s | ✅ | +| 时间过滤 (156→22篇) | 0.0s | ✅ | +| 去重 | 0.0s | ✅ | +| 标题翻译(7篇外文) | 17.9s | ✅ | +| 关键词筛选 (22→15篇) | 0.0s | ✅ | +| 正文翻译(6篇外文) | 9.9s | ✅ | +| AI评分分类(15篇) | 30.7s | ✅ | +| 网摘生成(3篇,含2次重试) | 53.3s | ✅ | +| 分类介绍生成(4类) | 33.5s | ✅ | +| 图片生成 | 0.3s | ✅ | +| 报告生成 | 0.0s | ✅ | +| **总耗时** | **153s** | ✅ | + +**API 消耗统计:** + +| 用途 | 次数 | tokens 估算 | +|------|:---:|:---:| +| 标题翻译 | 7 | 1,400 | +| 正文翻译 | 6 | 4,800 | +| AI评分分类 | 15 | 12,000 | +| 网摘生成 | 5 (含2次重试) | 4,000 | +| 分类介绍 | 4 | 600 | +| **合计** | **37** | **~22,800** | + +**输出文件验证:** + +``` +military_report_20260514.md 10.6 KB ✅ Markdown 格式完整 +military_webzine_20260514.txt 4.9 KB ✅ TOP3 网摘文本完整 +military_webzine_20260514.png 868.5 KB ✅ 合并长图渲染正常 +article_cache.db 60.0 KB ✅ 数据完整无异常 +``` + +### A.2 SQLite 缓存数据完整性验证 + +冷启动全链路运行后,直接查询 `article_cache.db` 验证: + +**基本信息:** + +| 表 | 行数 | 说明 | +|------|:---:|------| +| `article_cache` | 15 | 与关键词筛选后文章数完全一致 | +| `category_summary_cache` | 4 | 今日必看/装备动态/地区冲突/战略政策全覆盖 | + +**逐项检查:** + +| 检查项 | 结果 | 判定 | +|------|:---:|:---:| +| id 重复 | 0 条 | ✅ | +| 失败条目 (status≠1) | 0 条 | ✅ | +| translated_title 缺失 | 0/15 | ✅ | +| translated_content 缺失 | 0/15 | ✅ | +| ai_summary 缺失 | 0/15 | ✅ | +| ai_category 缺失 | 0/15 | ✅ | +| published_time 缺失 | 0/15 | ✅ | +| webzine_text 覆盖率 | 3/15 | ✅ (仅TOP3需要) | +| ai_score 范围 | 3.3 ~ 6.5 (avg 5.3) | ✅ 正态分布 | +| 分类分布 | 装备7 / 战略5 / 冲突3 | ✅ 合理 | + +### A.3 缓存命中验证(连续运行2次) + +| 指标 | 第一次(冷启动) | 第二次(含缓存) | +|------|:---:|:---:| +| 文章缓存命中 | 0 / 15 | **15 / 15** ✅ | +| API 调用次数 | 37 次 | **0 次** ✅ | +| 总耗时 | 153s | **~5s** ✅ | +| 退出码 | 0 | 0 | + +> 第二次运行时文章缓存、网摘缓存、分类介绍缓存全部命中,跳过所有 AI 调用,仅做 RSS 抓取 + 文件排版,几乎零 token 消耗。 + +### A.4 飞书推送验证 + +| 场景 | 配置 | 结果 | 主流程 | +|------|------|------|:---:| +| 推送关闭 | `enable_feishu_push: false` | "未启用任何推送渠道,跳过" | exit 0 ✅ | +| **飞书(真实webhook)** | `enable_feishu_push: true` | **飞书推送成功** 🎉 | exit 0 ✅ | + +**错误处理矩阵(5级分层):** + +| 异常类型 | 处理方式 | 主流程影响 | +|------|------|:---:| +| `ConnectionError` | warning 日志 + 返回 False | 无 | +| `Timeout` (连接5s/读取15s) | warning 日志 + 返回 False | 无 | +| `HTTPError` (4xx/5xx) | warning 日志 + 返回 False | 无 | +| `JSONDecodeError` | warning 日志 + 返回 False | 无 | +| 其他 `Exception` | warning 日志 + 返回 False | 无 | + +--- + +*文档版本:v3.0* +*最后更新:2026-05-29* +*状态:融合 AI Daily 四大核心设计 + military-digest-v3 六大工程资产* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6e2087b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 安装系统依赖和中文字体 +RUN apt-get update && apt-get install -y --no-install-recommends \ + fonts-wqy-microhei \ + fonts-wqy-zenhei \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 安装Python依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制代码 +COPY scripts/ ./scripts/ +COPY config/ ./config/ + +# 创建输出目录 +RUN mkdir -p /app/output /app/logs + +# 设置环境变量 +ENV PYTHONUNBUFFERED=1 +ENV LOG_DIR=/app/logs +ENV OUTPUT_DIR=/app/output + +# 入口命令 +CMD ["python", "scripts/military_daily_report_v3.py"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a0cbdda --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: help build run run-once logs clean install-cron local + +help: + @echo "🪖 军事科技每日摘要系统 V3" + @echo "================================" + @echo "可用命令:" + @echo " make build - 构建 Docker 镜像" + @echo " make run-once - 执行一次(Docker)" + @echo " make local - 本地直接执行" + @echo " make logs - 查看日志" + @echo " make clean - 清理" + @echo " make install-cron - 安装宿主机定时任务" + +build: + docker-compose build + +run-once: + docker-compose run --rm military-digest + +local: + python3 scripts/military_daily_report_v3.py + +logs: + tail -f logs/daily_report.log 2>/dev/null || echo "暂无日志" + +clean: + docker-compose down -v + rm -rf output/* logs/* + +install-cron: + @echo "正在安装定时任务(每日 8:00 执行)..." + (crontab -l 2>/dev/null | grep -v "military_digest"; echo "0 8 * * * cd $(shell pwd) && python3 scripts/military_daily_report_v3.py >> logs/cron.log 2>&1") | crontab - + @echo "✅ 定时任务已安装" + @crontab -l | grep "military_digest" diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c561b5 --- /dev/null +++ b/README.md @@ -0,0 +1,286 @@ +# 🪖 军事科技每日摘要系统 V3 + +完整的军事科技 RSS 抓取 + AI 智能分析 + 增量缓存 + 运行监控系统 + +## ✨ 完整功能 + +### 📥 数据抓取 +- 🔗 可配置 RSS 源直抓(rss_feeds.txt 管理,支持 100+ 源) +- ⏰ 24 小时时间窗口过滤 +- 🔄 同源标题去重(保留跨源对同一事件的不同报道) +- ⚡ ThreadPoolExecutor 并发抓取,内置失败重试 + +### 🗄️ 增量缓存 +- 💾 SQLite 本地缓存:文章翻译/AI分析结果持久化 +- 📰 网摘文本缓存:再次运行零 token 消耗 +- 🏷️ 分类介绍缓存:按日期+分类独立存储 +- 🧹 自动过期清理(7天)+ 表结构自动迁移 + +### 🌐 智能翻译 +- 🔍 自动识别外文文章 +- 📝 分步翻译:先标题 → 再正文(仅通过筛选的文章) +- ⚡ 并发翻译(ThreadPoolExecutor) + +### 🔑 关键词筛选 +- 🎯 44 个军事科技专业关键词库 +- 📊 命中数排序,文章 >15 篇时自动过滤 + +### 🤖 AI 智能分析 +- ⭐ 多维度评分(技术35% + 战略30% + 信息20% + 时效15%) +- 📋 自动分类:今日必看 / 装备动态 / 地区冲突 / 战略政策 +- ✍️ 每篇文章 AI 智能摘要(80-120字) +- 🔮 分类深度洞察分析(每类50字综述) +- 🏆 TOP3 必看深度分析 +- ⚡ 批量并发评分 + 分类 + +### 🖼️ 图片生成 +- 📄 3800px 超长网摘图片 +- 🎨 军事主题视觉设计 +- 📊 包含TOP3完整内容 +- 💾 自动保存PNG文件 + +### 📊 运行监控 +- ⏱ 各阶段耗时统计,自动输出结构化报表 +- 🤖 API 调用量/token 按用途和模型分组统计 +- 📰 源抓取成功/失败率一目了然 +- 📡 文章统计 + 缓存命中率 + +## 🚀 快速开始 + +### 方式一:本地直接运行(推荐) + +```bash +# 1. 安装依赖 +pip install -r requirements.txt + +# 2. 配置环境 +cp config/.env.example config/.env +vim config/.env +# 编辑 OPENAI_API_KEY + +# 3. 运行 +make local +``` + +### 方式二:Docker 部署 + +```bash +# 1. 克隆或解压 +cd military-digest-v3 + +# 2. 配置环境 +cp config/.env.example config/.env +vim config/.env +# 编辑 OPENAI_API_KEY + +# 3. 构建镜像 +make build + +# 4. 测试运行 +make run-once + +# 5. 安装定时任务 +make install-cron +``` + +## 📁 目录结构 + +``` +military-digest-v3/ +├── scripts/ +│ ├── military_daily_report_v3.py # 主入口 — 流程编排 +│ └── modules/ # 功能模块包 +│ ├── __init__.py # 包初始化 + 统一导出 +│ ├── config.py # 配置模块:常量 + AI配置加载 +│ ├── utils.py # 工具函数:语言检测/HTML清洗/AI调用/换行 +│ ├── rss_fetcher.py # RSS抓取:12源 × 6线程并发 +│ ├── translator.py # 翻译处理:中英分离 + 并发翻译 +│ ├── keyword_filter.py # 关键词筛选:49词命中排序 +│ │ ├── ai_analyzer.py # AI分析:评分/分类/摘要/洞察/网摘生成 +│ │ ├── cache.py # SQLite缓存:文章/网摘/分类介绍增量缓存 +│ │ ├── monitor.py # 运行监控:阶段耗时/API/AI token调用统计 +│ │ ├── pusher.py # 推送模块:企业微信/飞书 webhook 推送 +│ │ ├── image_generator.py # 图片生成:TOP3合并3800px长图 +│ └── get_chinese_font.py # 字体模块:跨系统中文字体适配 +├── config/ +│ ├── config.json # 系统配置 +│ └── .env.example # 环境变量示例 +├── output/ # 输出目录 +│ ├── military_report_YYYYMMDD.md # 完整报告(Markdown格式,支持链接、样式) +│ ├── military_webzine_YYYYMMDD.txt # TOP3网摘文本 +│ └── military_webzine_YYYYMMDD.png # TOP3网摘合并长图 +├── logs/ # 日志目录 +├── Dockerfile +├── docker-compose.yml +├── Makefile +└── README.md +``` + +## 🧩 模块架构 + +``` +military_daily_report_v3.py ← 主入口(流程编排) + ├── config.py ← 配置常量,零依赖 + ├── logger.py ← 统一日志模块 + ├── cache.py ← SQLite增量缓存,自动迁移 + ├── monitor.py ← 运行监控统计 + ├── pusher.py ← 企业微信/飞书推送 + ├── rss_fetcher.py ← 依赖 config + utils + monitor + ├── translator.py ← 依赖 utils + ├── keyword_filter.py ← 依赖 config + ├── ai_analyzer.py ← 依赖 config + utils + ├── image_generator.py ← 依赖 utils + get_chinese_font + └── get_chinese_font.py ← 零依赖 +``` + +### 各模块职责 + +| 模块 | 行数 | 职责 | 对外导出 | +|------|------|------|----------| +| `config.py` | 90 | RSS源、关键词库、评分维度、分类定义、AI配置加载 | `RSS_FEEDS`, `WECHAT_SOURCES`, `KEYWORDS`, `SCORE_DIMENSIONS`, `CATEGORIES`, `load_ai_config()` | +| `utils.py` | 130+ | 语言检测、HTML清理、时间解析、AI API封装(含监控钩子)、文本换行 | `is_chinese()`, `clean_html()`, `extract_content()`, `parse_pub_time()`, `call_ai()`, `wrap_text()` | +| `rss_fetcher.py` | 100+ | 多线程并行抓取RSS源(MAX_THREADS并发,含2次重试+监控上报) | `fetch_all_feeds()` | +| `translator.py` | 150+ | 中英文分离 + 标题/正文分步AI并发翻译 | `batch_translate_titles()`, `batch_translate_contents()` | +| `keyword_filter.py` | 23 | 44关键词命中计数 → 排序 → Top15筛选 | `keyword_filter()` | +| `ai_analyzer.py` | 240+ | 四维度评分、智能分类、中文摘要、分类洞察、《参考消息》风格网摘生成 | `score_and_classify_article()`, `calculate_weighted_score()`, `generate_category_overview()`, `generate_webzine_for_article()`, `batch_score_and_classify_articles()` | +| `cache.py` | 310+ | SQLite增量缓存:文章处理结果+网摘+分类介绍,支持自动迁移+过期清理+断点续跑 | `get_cache()`, `get_article()`, `save_article()`, `save_article_webzine()`, `get_category_summary()`, `save_category_summary()` | +| `monitor.py` | 160+ | 运行时监控:阶段耗时/AI API调用量+token/源成功率/缓存命中率,结束自动输出报表 | `get_monitor()`, `monitor.stage()`, `monitor.report()` | +| `pusher.py` | 130+ | 企业微信文本/图片/Markdown推送 + 飞书文本推送,主流程末自动触发,失败优雅降级 | `send_daily_push()`, `push_wechat_text()`, `push_wechat_image()`, `push_feishu_text()` | +| `image_generator.py` | 220 | 网摘文本渲染为PNG图片(单篇 + TOP3合并长图) | `create_webzine_image()`, `create_combined_webzine_image()` | +| `get_chinese_font.py` | 50 | 跨系统中文字体自动匹配,支持Windows/Linux/macOS,内置开源字体可选 | `get_chinese_font(size, bold)` | + +## ⚙️ 配置说明 + +### 环境变量 (.env) + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| OPENAI_API_KEY | OpenAI API 密钥 | - | +| OPENAI_API_BASE | API 地址 | https://api.openai.com/v1 | +| OPENAI_MODEL | AI 分析模型 | gpt-4o-mini | +| WEBZINE_MODEL | 网摘专用模型 | deepseek-v3.2-250518 | + +### config.json 配置项 + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| time_window_hours | 时间窗口(小时) | 12 | +| max_articles | 最多保留文章数 | 15 | +| top3_count | TOP3 必看文章数 | 3 | +| wechat_target | 微信接收者ID | - | +| wechat_account | 微信机器人账号 | - | +| enable_wechat_push | 开启微信推送 | true | +| enable_feishu_push | 开启飞书推送 | false | +| enable_image_gen | 开启图片生成 | true | +| enable_txt_gen | 开启TXT文件生成 | true | + +### rss_feeds.txt 订阅源配置 +所有RSS源都在`config/rss_feeds.txt`文件中管理,不需要修改代码即可增删订阅源: +- 格式:每行 `源名称|RSS地址` +- 以`#`开头的行是注释,会自动忽略 +- 空行会自动跳过 +- 修改后直接运行程序,无需重启或改代码 +- ✅ 容错保障:文件不存在、为空或格式错误时,自动回退使用内置的12个默认源,不会影响程序运行 +- 支持所有标准RSS/Atom格式,中英文源无需区分,系统自动识别翻译 + +## ⏰ 定时任务 + +### 方式一:宿主机 crontab + +```bash +make install-cron +# 或手动添加 +crontab -e +# 加入:0 8 * * * cd /path/to/military-digest-v3 && python3 scripts/military_daily_report_v3.py >> logs/cron.log 2>&1 +``` + +### 方式二:Docker Cron + +```bash +docker-compose --profile cron up -d +``` + +## 🔍 RSS 源列表 + +1. Defense One - 美国防务新闻 +2. NEWUAS - 无人机系统新闻 +3. Seapower - 海军力量杂志 +4. The War Zone - 战区军事分析 +5. 国防科技要闻 +6. 战略前沿技术 +7. 无人机邦 +8. 浮空飞行器 +9. 海鹰资讯 +10. 渊亭防务 +11. 电波之矛 +12. 龙牙的一座山 + +## 📊 输出示例 + +``` +🪖 军事科技每日摘要 +📅 2026年05月12日 | 共收录 12 篇 + +==================== +🔥 今日必看 TOP3 +==================== + +1.【美军MQ-9无人机测试发射激光制导火箭弹】 + ⭐ 综合评分:9.2/10 + 📰 来源:The War Zone + 📝 摘要:美国通用原子公司联合美空军在内华达测试场完成... + 🔮 深度洞察:低成本改装令察打无人机获得反无人机能力... + 🔗 阅读原文:... + +... +``` + +## 🔄 数据流程 + +``` +main() + ├── Step 1: RSS 并行抓取(ThreadPoolExecutor × MAX_THREADS 并发,内置重试) + ├── Step 2: 24小时时间窗口过滤 + ├── Step 3: 同源标题去重 + ├── Step 4: SQLite 缓存查询(分离已处理/待处理文章) + ├── Step 5: 外文标题并发翻译(ThreadPoolExecutor × AI_CONCURRENCY) + ├── Step 6: 关键词筛选(仅当 >15篇) + ├── Step 7: 外文正文并发翻译(仅筛选通过的文章,大幅省 token) + ├── Step 8: 批量 AI 评分 + 分类 + 摘要(并发) + ├── Step 9: 新处理文章写入 SQLite 缓存 + ├── Step 10: 合并缓存+新文章,按评分降序 + ├── Step 11: TOP3 网摘生成(优先缓存命中,支持字数重试,TXT+PNG) + ├── Step 12: 四分类整理 + 深度洞察生成(优先缓存命中) + ├── Step 13: Markdown 报告输出(全缓存+文件已存在则跳过) + ├── Step 14: 多渠道推送(企业微信/飞书 webhook,失败优雅降级) + └── 监控报告输出(阶段耗时/API+token统计/源成功率/缓存命中率) +``` + +## ✅ 系统要求 + +- Python 3.8+ 或 Docker +- OpenAI API Key(或兼容API,如火山引擎、DeepSeek等) +- 内存 ≥ 512MB +- 磁盘 ≥ 1GB + +## ❓ 常见问题 + +### 生成的图片中文字体乱码怎么办? +无需额外配置,代码已自动适配所有主流系统的自带中文字体: +- Windows: 自动匹配宋体/黑体/微软雅黑 +- Linux: 自动匹配NotoSansCJK/文泉驿微米黑 +- macOS: 自动匹配PingFang/黑体 + +如果需要完全不依赖系统字体,可以下载开源无版权的NotoSansCJK字体放到`scripts/modules/assets/fonts/`目录下: +- 粗体:`NotoSansCJK-Bold.ttc` +- 常规:`NotoSansCJK-Regular.ttc` + +### 日志输出在哪里? +日志默认输出到标准错误流,包含INFO/WARNING/ERROR三个级别,运行时会实时打印到控制台,也可以重定向到日志文件: +```bash +python scripts/military_daily_report_v3.py >> logs/runtime.log 2>&1 +``` + +--- + +*V3 版本 - 全流程重构 + 模块化拆分 + 跨系统字体适配 + 日志标准化 + Markdown报告输出,2026年5月发布* diff --git a/ai-daily-main.zip b/ai-daily-main.zip new file mode 100644 index 0000000000000000000000000000000000000000..61a4da1df863ec229b5997058c19a32e53b0d401 GIT binary patch literal 338401 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[q@pet$fCw|nyW^5Ej~V0dtLl>>Np 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

I#^3qq92!c~T_Aj9p zql>Ww@G}bn=8}L4a1JMzDW{~niHpqQ^kMc)H4Y>XLgy23*U&<-BWnm2Rgwb_olZ5` zd>)JQrY_geZ6233ry9>gEBe`CP>=R4TesDtRg0dsJ>EsrURA#upBJ>W%`NY5=#Sjx zt+(T07tzQ3#Vl~=$2-T_p(0UGnwcnSToUqg0;sD9X*~6JMP_l^RPLxI_8kTz3xoQ) zh+zW!tsWr85(uUS+eks_K%3V?9wj(+JF~*rCr!A5+;{+2=)+`7(B@^ z7YPGuNkUiK5LxoL9ps4+$8}PoB_uMEu(sRb^^a+}s2UZ+Z7HoTHg_!##0Uf*EP3dO zspIgCoE526f7Ss$R87+*?o2xzmB1o#V=s_}>WP|Gut5>^?~%yJhsBmKdeDzxt*m|u z245iEk?Gvb=}VDnT6X# z=ur<=%jft2UEq84|17$Mi{gCvKX~jB8UTRi-@s!IPWHy8&d&D#Fy^$R&HRTrIdc6< z9YfwwmW8EeM?4j%%VwEORxxN;FeFnJ*rops< zBXPXI#HNC6J`vB_y1*m`$%ts$t0IFxsL-^}idxr#v9OXUR$N;Gdr<-%yPMx2=dBwS zEeh)X8s7FM;8e^+OsCK}6LB*N8UH>yr37+%7AcNRwp|Lq9Me(WbY}bQSq$4wxIfcjmp&%}W1opmBdQ{vHCYS< zVoD)=vB?m%UYu%n$GoYhxb$EV5qwUT7mUI?aKGXHY~k0e%H*MtZuTN#V0F8P-&^b}wa^#VK(`6C3H_*DE0A3|gBP?_vZZpS1{CZq$`o zsa`znhZYmcidxq^a3fz33N5gWT#~!|z`x*RJxqbf&%+e;5ftxL(Hc}n;_zqr1s(aU zbkK8Z;@x6WepS^3n#;al`l;i|^T%V7Fpb4#0kHKIj>$RMw)W)op!Mq3(CWcN zm)1lTQuBoF8|Ytt-1?wGD+mC<3*Nt{CjKkwx%^QR4_Huoj_c~3q-I$%(b9y62+-E5 zsy{7S@Erh9Jel%_V~pc%$_FZ{K)iJ=ISJ(m5VV@v6A*;FSH8{4jlQ8T1~bVrXC|3! z+BS8#BJ=aN-#@pSww-O)`Mf_K4%xT!ct1@a_TkO_dEbtouWi>`X>gt~(*q(u)cq+E z6JE11psFV{VcrzPK-vOd{VMdD0z-@{%{>^QyxH@s4iG^)Pr2#o7|h1 zM8OR+9y&-jL^S2V$@_G@M1v2{34|2t?MILf1%U#*qBf_tw$T>gg_?oUV9p}UE2;_S zu7GAy=wl@LU91`&<94g~bOj;OVOJ)>zlvcIn0#CA0xM#eT@cp=dvy=OIv!jfZ(p>F zi$Tn4cfIlZd8}_Ucc*DH{uv(54h;Tkt8s=rC(a+XK89%+C8f|siYxO5fp<7(?oNwb zd9hlRnDX6d{TY^8Z`4SC^$=cx+o*>8W9patcIQ(!{Cc6#ARn)~_KhAt=M7Xa)P@{b zoHmmB0s*e4*l=}0&J3VN$tdlPh-UttPJSA%#GtmG+8$}B9Uvnk8qj=kZw7C6zEv5` zK)=lew8Ko;3SXCkf?<{)B$6B*icHEF?C-?2)YgY4AR=q5~G z*nK`hOSbG3ikf?^KvdtL(zMjG12~bA=>@7h>QMPBFD(~}9`e2NUL{;5H314&DtE=H zhG#^=Njhz0(q{679gGnsCOJt-U<4fF)io=&Y~@vg2(C;i^kyvs=PVhvFVA~v+QfC1 zc$^|(J~5$QVwY78DPL*@mrrP`VE5jo7PBTic~Q|8i#G=@=>!CFROdc$+5%R<78(e^ zvSAE&I!mfYVa#StA?^ftdTd+KVWsXY*H*%*jORa-(f2Z zYtZaWhwC}xKf5FK{k?84+LnFso7%khx4wecZ2ezpGK)$O;%nk7+K=vpvbyakh(or` z{_=0#xjK|JaMqnt3BQKvx9Z5wo^O;q%!=Ip_Q~f4%~S_|G%^G^WhjUNqqv&bt={JR zJhsr?L72Sjt{289BU)3Yh29?+f8z0aIzH*-7xU-Gq~-5ar&) zs|*Wi3asS-V-#rifT1a9MZ&kpIzx9Hqb~iYs?zKydOT*?(#hVhiB`uvxt&7n2-GFB z3zf(t3Rm4EFCihUQV}}Ay#o^@^INws)*`aZjF)?^@k2F>aQO}WpEdA7BskUlrv}FT zs6gER2I}}f6H$G0OBV}QBYjglC(D0z2QR8=TW^S=_$}9AkkMtl|)j? z$#(iy8izHobY|7CATbkP+XWYvh9bS;tO)`8j#|^Fgz!BO@|QibeWLRgCoUCFt=Y{- zM{3MkO}lnGPqPOz=JCJp4Z+9H+SXpDh@W(6U&GH%f6*BU-BAdZZ~Jn7f31F0+spfZ zTr9`)7cxn`Fh>vM(PAzgpot%j#b_=j5#Y8WE8^9FpT)zyJ-a4SU}QGS11A*P z=RFW&to+mqNJ;CC+(BkKpd>B<#!7u5yZDM# zRz8tWzJ7Mh9il{J#}-SJ2BBrPW(p-dRlSZ8qS{^W{cfN-^q{2lH~L+qCKpt?B&VUF zif=Yq)OYj%O(2j+9b!7H{BMl#Hk-~c%+&patnQ=ir%R1xJN<73;pHUaq@7owC%X~x5Mog!j=Tutca+&92uS5LW*^I1F&Yy0ffp>#bzVOia?R`JzP6k7IFVF z5)+q2o`UH!mIA~Aq%y(IuB~|p9ZD>!vcltIAr|0MGgI(gtwpTNHAtQ*MV0Dwk)Ara z+SrlJyX6uTtqK*n7PfY7W`P#-oFD0)_OsETo@9p&xH)Z;OM;8U$MZQwf!{OmG zWGuGg0CDuGhDqzmfm)fHX;z3lt{on_U8TJV@Hv!uitsD3hH18>{#Aw4f|*r zVse<4y0{y5Nfaz^&}@rnb>aL^R>A*%R?pba`|IUdT)zhhbRAGh^Q6aU|-2gVUlhWhRxfZ>aF8i$gB7`U%~` z0(EmqT4?|`m<|lxA7~jyt?J*m{G8WyMXL1tPWnuKrye~H2+?K;`p0@bNk2}>KX&2P zo&J1H?p9k*Um*W1WN+SqEpY##M*I{ruKz|Mb2fG|bogh=VMwVj3Rw`LYepUA(iChd z6US82$rT<7?@#EgifH5fa70Xn=FA}=HnMNnfs0S)!u49V&v{Y9RP5K-U7 zR5#1w^`f`El@vt7+t=OG9xTs-&yq|4ZI(Tk!4NKH@D6~6*2x&Qy|dP&Ou3AK3$`~`C!ZU_ z5WrCT-I;-dgn)|)QolyoF4oANL@Sn}wSsBPW129l`R+!5C1=~8U<2Gc3SMaXL13lc z`7|TyL#9vwxm6Ysrf^xbZBVY*7w>Vn^8pPzav04B2bp@}Ve$}mD5kw^p5)XlN6mDP z>Cz=iB!CS&@DC-FrAnlcyk7GAVvS6cq4)zHN&EPMPKMY`(Y$q<#DV{8%=^bTFrKVn$gEIoR=R+u%I@=-P`Vo zO?{0gT}vhQ7EBp3Z;C?1k`W7^)Wy5?O1{dfk42U1k>B66&yt~5rGK5Cl;E!G^!>=A z}0;!5CaCfZJD;kek6w96zV`Y`$ z#TA;BK&LLaZerJH7vNoDS4nw88mL?Knn|j75~OM+w*-~qoEde<3y(p_N&o_lQnG%l zt!Z4TLv>Pn)JS5@mBXTJTlBFJHc_lZ8DaZyW7;e&s9=&MhEgBDDX2i8W4t)G1HlmR zLXi{tui7G!LGKM?BuCaLNDjCl1jR@QAMFis#Ttclsj3|qts{azvw*G3v>^CZ!NEL7 z#6*VTsitQIhliUw)%-z~Dk2cZ;F~eTW(R9tuD3_nnT!*SF^0?Al?TgqK^?Wl7X=A; z2=NUe9zO6UJWBNrEq2AJ6QldJi}nI3t;=rLY|#8^jel?-O*k8e08 zoCyI`{L?Mo?4VW1L&9j~P-r#N%vF23XqzR4tr2y4*0vjd`OSQ5Hd}+qJAEgWI5q?W zHaP<;#(`|^45q;L%42rW#phJ1HOp4a2w69>OVn%+f=%tN)vFsM>aDejQ%B_U7Ncc?wqC1qG%zg@yUjsYm%_0({ydM`JtB;oP2_gUt+ev4W{2r;=y_4kU_ z74?UlcqN&`-{_SGmG#h3hoUX+>@0FK#WAEL;;i5)x6bDgQbs^Yx%D1|vm|H|h2;fj zAP^M4r_5BdqB2|wSgzQgT(sPmEFZ$56^j8Qb<)fa|DI&}bZLk%+EZF76W>i#z;{YU zso`Pd0UaGI2|NlJ=}ntG$G^hm|1Rz2rqA!;czYk47yut2pGj~5mHj!m^?iJE+O&Ut zCDm-RbYzeTL#z?iCGIBYbxg zoEl|n%g|=o8FOzO1#qhyM#@FRCA%1GebOfQYO&xr*2`usRPY4HdeY^eM0Shdq}VP? z>C)z$oB6AKmz*o-J*dEkc>)5(ylXAClY?6<_9FJ12{l@+4W>th+>Uu`Y$Y z#6xmMqbgOl0&y)*dywJ&#wQ9fWl;~ujs3+eja<4CjVZV00I@o-tcYJa?ueV!lE{MWvlScFjFA z)95iSNxH&3OX6N6D9g(keW@xlS}ORNa`4Xep_{YC%xA{CZ7p+^*Em2C?PaOyot;sU zA(NiKBu~2_YB!;$*8j13b%s|*_T|gC!r+@e1U?u+@b&co42@8wTPuy){R^UD+`om7 z@~nlkRo+Qccba-THZ?KJhsxbJSlBOz-O2c;JTsBTK+!Uj_w#At>O|$GCq*VDsghX2 zV}Nw>8E~CqGmJO4-&}HoQ+-Lmx?CpGW|)NU5O4(pzC~zoZ<6`@wcbF`lP3Jm_)}eI zG|Q~bqj+&m2>smEhpxr3l|?S4zkxkB(O~UgnH2)pB2eIuS4|KX0D%Ah2as488e5w> z*_paK|Cj9QQd8R=TNK6bxeh~C-%WYqb`2I@QRf603Enm|plwtuwrk(x;PHxyxz@Of zfJ#e1MN}n)NFh-vko{OT0(bgF(3QT`&J!2ty10^Lw>CO68$+6x|8}vC)Mu@C*gFg{;iLhn< zW>v+3NYp3>QAvOGi~Dk_u$U`UO(f zN>&Sb3VaoflH4{uR#VDbhpuZA2=P&%K;v5z)=!bU32RDAZ=eoyJXKbzK|$n$A)>5C zjb$xQ0Ut*hoU#qupvfY%S~9SosqIAC&$(O1GZGj&bsJCF)MhMiasx}NP%n1x zjZN)a<1AI%bCm^b)1R^#oE)fz;EuE8NRxR}iS03ud-`2B{P%EP_HV3D)iPeFP*x2P zk;p9;4;d#ErVTU$M_IaKhrWPDCgX)1MuD&+zycUFM2(DgkI=t`d1`Q~3dy2M(8YO%$bAy&dbCpoc1Ub#1wsJOXbEBb`zQ;`%6a*}QoNE^ zav?vGwPkBus*u5qa)hc>vDgW$Lru+$33n}UTB3b8;91go5kXq*n5qadmta8}Dw;F^ zO?&eqP$}y?vRjbp(Ee%yVVV#-{RMb9I|-^REoLW8rU)tfDRLUUq#B(S((-cSo#pBf z2A=g>-CA6;*J@@7u6jig{t3{ENs+b|#uZE=g3?NZGT{1xXrWNe_m0X-oGY1(k)8020c~i!C#96DY2IMIz z7&s0kK#-NQ&dc7*zU~eln9^#2KG=T8g45sJ^$2QIshOcSx)}goU8>C$M07L~$XWAH zoJ}-pN2#LbOD~CMdn+*m%c7w*RsH9>!d4DN^@lq=@S}1tZLZGK9_5kO&?UwDE`Zl7 z9Y}#t7#?U2tI7AfB}!n0DvL_D%GanO`DhVxh4T^3lmz494@N9Z2SBUW!?k!~NUprfss!8H6Z zY<~~?J5VMKIIOiXsK%4}ryF7GG=OI`2x{HP-oc|+TvLJUra;Qa?;;CSz781PHxRuI z<{$}$HRh3rI4OAaKGb}v%g0Z4%-MUytyGxny83&+(SYw^!0?VBjBRqdNfbFbuJ?fN z`ZA1wWD5~_5qfNz)s!{;NCcb2c{K#_vRVB_Nlz35nnW|U_gOm0A^{pPL*4`nJg+v| zjRTL4qyeV(3DFIMirl>dJEpyo?-R5+5XHENf7Ue~gypLLny0kRJvuy(5rcBOK8SO= z)wL}?wpFNHLW4|4x`O{ZIf!xn-OcQEXYl)T>We;Jd?s(|&#~w`|2EFno2|#?*47jJ z&~Cc^#dk|A9hU{BZcPC?{`aTP^U_VS_Q1?FygiQ+`aN{ApQ-7ghUE9xw;OxbbqbaZ z@$zj#NhT&c4@9n@WkS(sc|0nrC3yEAKXPl`-k>koVAIF)PWHx~?R;alVkkh*G1 zv+@aSYp2bGHszl%#txcIOL1TJUu-uX(ckc}7-BD?;Da!~b-~7Px!8 zlC}2OUppDQy4iL2cK`C}exKWVh7ZgCjN)4$FDdGmriC8dg+-vJSG8Vb0-|d|8+03i zT~z@`*AJ-;wAt1FC+~jn*BQ+H;oW&Zt&Z=%+2;NW?wzYLWj8N?(EUy+<_(}t<)(xP z8Oni*z5q-Bw^22|wvz2t+0^PbEJb|_rt`ZOu~I0AxV&3wu^Y50ZgZgzu#)e^ynFTa z=olNC(5{w$`|As8F+^>QhR{{e4|!g9yu`F@N|VM)i_e=7D5y3`H3vB=EI;Ve z&$3;sk~a^2r=$@2HHTEkIxlN9k}5|`u{cxOO~-<`{U$-5Sk}t5q)7wh8%d%p;&B9M zY{Io)`3OWKxY5BTS8K!P_o1Ei#&OI+k;1*(33FotT8U@*!+;!E=f1Pp&N!&+i_A_8ZH^}ii8n6xjQXg8|e~{mwFM6%lL8Dh@2)j438uu zzTF(*fvGLxf##_ZjHCbB{N@;PQ}3B6d9S!@+IFkeDau2>eqiV2H~b)D%=0JMoAiYa zHX+>QWieVMJ~*bOrW<9)ru&Xbvw5-h$~*8se?ZvJNnMN|)v4nrUi9Do0spTYu&6#2 zw=eP^KR}BY-P|MxWRNI!G%69oh_$nL?#f9(ap7#`(2?40-KEW)E#_z(G;%*F$?|0moZ^)?Iw6Evq`oGtl9-&7&4LX5cZL3@ z!A(U819&aO6OmS%y{MRlpxleJ*xW+@K9xH9k>>!G)|8?I%=AN1;Kwvs`iLnl0u}>{ zP$-afS=ZNlA2$lEOnb4%pqr$^#8i}k<;+M#Oy91TbW5D%h-q!4G`d`^sF-2OxtqoC z@gyQ-Vkj}r%8s$Q1|)Re&p?+E6oVb1Np-q2Nc^(vCQ1?(!VpVYk9CfJAAo~TkDc&I z!H}9{!UjL;N!Yha>+cbfE7N-Nc2{+mLw(CypCT@;fK=;ZuSeU;&noq-#EoE(tabiy z=G)e?pn%S|v7@cM4q26i%eLgfJLB9gHde|{aLr5B5FwLmn7sMOr5m+rATWpQB%2OE z8vQD@KQGSS2R3hJq-b4z?_$+*?(CP~lPDN7on94WfIn-qf`qsaY^R(;Lv@FvJul|r z?*cIYqB*VKL~%F{#E*k3WF|zHD3_)C0$v>K0jjUcp)eVN6E(hq1K;Q2rfCw?1iBo6 zk%gsvmb}Z5YPb)@I6_E2bhDUYhrCgf-vK(}w_4|_H<+OLwaksP0QMzhYHI`21>Uz~ zUe9&hK5;XD`mi_T;x#NspI6^>n%;SW%HbO;I_Z2+dMgyJ)ozMG4f!5f3bIM zwqAa^Q1|K?9DF1BE0R!dnaRbO2Fjj!W@*HzP&d8Ni;D#qrsFQVaG0{u_$;iFb&`#k zG2b;DAb%|_e_uQQR%RI~VFVDcbauGz_er)6emk&Ad3EGsoFFlu+CXA}3>gKrZZ;`O z$5Q4yZ3`xQTkoS2D;~t(huUYUjAlOPk2sPIS_MO(2=fZlzbPL`3b1p$n)sT2xY=EM znxM#Uj#hUUjKB6_gWTi7!qdZKS?M%AX&G~V-zE&NoCuR*c-T%7M2~^6 z%H3f^181P6W=DFva<}YMsW;dmEgnFZ+JZ=h*;G+~#cqI0K;fY@CnHbsqgBI`C zG?QOe2CgR67ghCrRTrfp#(H=@8lF)?7K|`3VP8Ad-|1T+3FC;9XjH)-M7Rs3kVi8j zjTi%<^oN%JGNkom)XK6;ek$6E+4J8SNXnP^qu!F~u{CtZ1v_@KnV>s6n{F`USrqGF zFO{;OFqSt5<3&j`zt5j?OylUV?*rB_@&+{>%Uk|-I;09QKE+OyGBFW>=@*zZDae*e z*^b=@$pDM$iGvU<10yQS?3+Zp4}fVuh{BYEJ%Y0M1#?Qn6@YIB9TX5Qz{=!soC$t^ zUp#KbAC=vZBoG0a(C=DNO z6E3G4An-<3vbv~w)q$j`)S2H94PX)jApA(Xa>OzZD@hrv)&qfq(Uh7j$#LqGpV zubv*EbT#_<)UT=SF6IR$W^sF)Pwq zZdMVN#JxYk}n4F^C{_bwtRWZWMj-+tPXKB$0_{`^coP zhvqG4K+mA!0EHPl+#C=~6|m$Z)i=%#6Mlc#eP68)*w6S~bdR}T;dlGB26UQUUv7(A z<*IHP<~^5lc~V5^e~mFQ$F&-^`GNBIXpi zjfGl?Q_FZ7Q>3dw*;y2`jzTeorfpab7#X0&P`UPY$}hGcMAE2j1tl0P$Wve-2=kV4 zvJ-ESeleNz$qa3Ye4<4?iV}vlY^=DI6@8Am9@2!+GAGT!<;maPCC)$c4BB#!wkCvG zpWBBR)I8zV$XgOA$yo+qVcl!^fCj2_yHTDUN)E@B(y1#}i?tqVyD=gQScNlp-^&s!a3VSij?(^MoDVi>f630re@A{Bsc=ka{MoeUiPdFRRrO>Svn zUHSO>8Y&;L&n7XDA1c-&uHch4#HO1fb)$Qi;83lFmbtEP-qG!G&o2?WLY_ROgA7z5 zbxd+iicE3)n;IMbiQx5F5C++widaJgEEZzJkh63;IYj8WntL~|A7QoBBh4X$8l5PL zm{uUQnK)P!@Jho$c+fu!Y-c$x6MPIu7^xo7GKf!Rg_Qp<{zlqy3bJEAEj zt*U0gVFMS8$zBQpx_QOPVyTi#Z)kuu3jzpy%Bp*%ZqP&a-S{gzVAy2{h30%ltg!Vg z(F{$(Uz2mWipn}RUOEu9k`!GBmuKR#VhdYIR?M)WSJs=3TgE)R0x!-#OTc06|?8c>3Qtw3GTN(Gt$Sl zMY`EeP1MykQpc0uzj*RK$%7HKetH?hk0s4*PB|zED;(tY${t3cgm)`eu8V}XW9@K48e6HoT)~2!CD2&-UmuHX zrL~fTHvZ&JuJ0n|>b6Cx>f~T)S%UJ-%thw=$;s(j71i92x6gxQ@OkiOa`)#lf8Unx z8*WuqYgTWD2cIe*3=g{xZ;mpOf?H(G5N$QE#sNV2_Su=hoUn>JA#s}O$v zI~^n{IM|Ne6?LO7=^KsezDC*{i&Z-TA1!05*$SMGuzYcO}K zH>=jPOT=zmSjVx33{F^|Br7rvObRN&qpi>kK@Bs*sxcJ;h#||xHBa8gnrT6KXMT^* zoyGUH*e)hF?O($lOk%CAA$U%oaw48T8DA<_^szxD>VGsn8l)(V5uD1L|pMC^{NM zOgB0mflRoO0j4pl4@HAc>#(-sZaVu0PvRZSF(zP8o-S}zE|zq%*BcpEUwLLKj&!Ec zU2J3qe^$YE8N@9wo-E8-bE7U1LVos{4&>D-^U}{cP4k*jt8y17%;gW z`$LVBuk=YE(v4A^sB>@;w_YT_47F$?faVUYylquW*<2$!!ko?|^VjMLsdJeQ-g@DH zHim@b&5$L7G+hac77IDEmyzL%3)PX$2;>aKq4M;J^O2#_kuU|81)rov zt>>>z91OSt4pnO_J_Jx?m9n9rYIFN+6}VtL9379j9^Iv#!a8s<@ZulJH@7YSO+kf? za%9IS5&Mv$8zcP~j^fl3{#gc(a6j42aKMRN?X)`P;F<~E|!_B;sbA9jgOoo5ckUi@W>j_rVYZz9c_gU z{>cN*rVVJt4s(SLT#aM}3l?`Bgy_pW3qJEu^DAmIm5L*dZWX`8Gn#Ys)KW->VOxEN zPF%O6#O{IeRv%rV;#X=tJox^>hBw8cR!yMx7{FI3Yb|+Xq?j68t#y}-fsRW}QAGf9 zhsMx$h=)YFOdAAG1SMX^3}c~P6&8o$=`$0?Y(*L?7K~=4Ersv2KrItggLj|}nji=$ zq7mQnM1QDvv04iMqH)O2F$1#!jsZFajVH+Up@*&cv3c#}I(JiUF#qQweMX@5$yOno zq7#~4>Lup>cDUnF#4x2u?>^Kc=hs(l*guK(NqQA6lXy}?i5L{5t7zJGWWz~vv5z}9 zvb?W$n6s!xcB65C=7d)2f9}%+c2*Cg(Vy}^owjc3w6@B!$r-w+sEe)>xK;;IPuDe^ zZc90y1peNna}c4OsUd15%#UN{=NaCo*^!dkbS6?NTCbzZpg0T#&7M}aqv<%Y!E@i7 zP6w;A^U>M%wL2eLFSxkXn1=E?HyL=*bcQ(mKSBh0;S4Y0#w+4mN{kOu*7Wk4HmIa{EjaAOVHKo9 zIm?ZY-kRj!!<@S`L*@^3ng=>s{C&>n&F1+s_BxJM-{#A|`bhb?;0Ts2>Jo}#*1yvq zL`s!+lxcITsrR>~kUB#Rl(`7FDyTsk3N zD_Zxkyw!kNNt0id{*TDHI6h#jk-|Enb*Z|2q#6uqd1~6P9DR)1{R`#nz;jy2bbBIm z4_O^pCzsm{<1VZRkY{KTMtQ7H9Tbm&Wt(^tF&T>AkXF+GX z*_@^<=WCYZY4+o^c&qg$d-Z)#V;gX-8-^A0y^PgfDWX{|_dnE+2y56ExuK?sr()rC z{|ahfSrz>)e^lSspFOh7{|>1AU#)5=*Q782hr5Wg=IHCABlsB9U#eFaDvht{k*|W8w9% zf&Iv(soz!K;PyBk1t7eo*VXJ6x^QlPn5L(%PmXJMiBo6%I6fo<+eFH~D-t{n15Bq& z*Wh1L+8c8Af(|f!*FCFKEk>~-U(MyQ{?tNc*+za`R(de-$xKBwF=2UhS6^c0ns5aU6&YLb_oy$4GmrL_WBwNBMOYG zOp=&wWLv>83?mCGE@w~+K69U~v(>*sjhJUT1f`>>hLSO`by3~O=VD7~Yd>$0XZpJ= zK({Hw1*B()XV64#+O54jA?)|@MG63gN|p zEV9QQaRKHGf#V4FIXkJmLHGm|?0h{1h?Vok-(}GIb`cTxaLjFLzLO`|Me{tw0+bIV zn6{VgDb)=o#<|&FYf8?iQ&^nJ#J>*5qPp=`s^_|ux^*mB`gQ(xZrEnT!u<;68i#bj zR!aovFHogUPAv5#(3djEt3^fVOS)#HuKcjB8CxOwPMy0~kYU zQfPEwqHS&=<^=JF`(}+XQxpwy2z*SY0>oTFv!P(vTAoJd_ubXWE|3&N`i`6@LU2>aF3U(7?$4sOwQQwj^NPO#3quYE5MeWG&C&;8F5RL9=irC$vLd!?sw>j-79rLA@mUu*l8M z-6?GMmQ-u`u|3L>E=tHCJ^0QLD~)z-Oe>{|5FGyLN&kz{>-P1lT0H!@XL8sDfNUL1 zUz(N@?85ivCOcSs8_(|(8Umm9)zjs;Ja{C2SlYqdA=^=TWRD^^%|MMQH>2M%_?gJO zTVD)AHwm3)T3Th_E#V|t-H<6Y`nZ_&xIej>hY=1E0X( z%tlYH1oz-&D~POf7bkGQ_;iamJHRly`GZ_Qn3}oWhivM4!8YD#TWc0acmVpFslI!UKK#`C28H(2|%Tw?^@dgZcd$7Qvf;oUbY^NZbN zPPZ(2>EB6k_ahSkc;qlze72_=rkO()7mn6QX6VIeJsn}cK|fK-ByKW9>6QFwd`1e@ z0rXsGAO9uj&R-eDSo)*O1pj0%ssD`@=3;4U>Sb^D54t&}CT+d`GpRgRr{R(uQnYP@ zBij+iKmIFxHKC@p3f;J5F$}2&iGt;2flZNuL=ifx`|2#RtSLm*^5Yc1M$l$6hJJ^&qDDm zIzZWqIyxfU<2HJOluV@7a$PLydVCiB5~q7A@u(6bY!?hB;DVa!eRV1vCIs3WOfj4& zhiRsngFD~HkI~DQ&6iWAd6o_DIWjG1ns`1BBycZaKJG%?01)1XduTyCO(~j(z3L90feBr%5eD_3{6~ z*EnR>sEKfi8$Ri{u8m@=Ie@?Ge_q5hM63k zlnEecEdy`8&4N_|S%>iTm0Hz>duaFcH}GuEs4UX9T(uIN2-oGKzP0E z!>77(H&hS}xhaKzFw%HZ@!5(>Qg1QEF$`LfWxcd9t>vva6VDzZ1SZ|h{n_ou>(Qi< zUlngY#V;-%O~+XnolH`OS5A9`q~(11{R^t1qN#w-ilLNzl<3ivdTfD|S(#jt>$h#h z1rs%iJ}orUn#sn=>7^^wY9fimmIOF&2-snqvjcyrHI#^~dJWBzPjOSeK&LNs$Wc5bNC2;uS+@{qeU z;jp%qT0m))qv~1#J4DJMA}oDUZ06U$L#Io+AxmzyF2007B< z`0ala*iG$B^zH0jEX_RrZ+WFe{nx3lhSB>{$Kgs}-!>etR#jTwQNAQw$)>uzq^oF> z@d`qNwAm;xRz!njVJZM^Th;>FGPe->$A-EDA3_8Auuo+zxayhwl2^Y$7-*A)I`YbS z=HsgypU;rXqaTGv4Edjo(^^db~M@HRu64FrrBiD2((B#xY!L>jX4ZM~EI5s<}y|Mz24a zd(Q1jw)vv@tTI!+_LBuTVTQXps;-DHM2@KL`}6kDQ`68>vs*O6X_a2cfw~I~(HKa$ z3njGW32$vTe|Kc90iB|>ry6gcu;0iWKKxGVuKF5#g?P@|p!iI)c_bZ~=sE8q3TOs) zY|LR74qW(TsvrRrbH5@@7Z-K(wb_CNRc_2NW5Q!SSwo`7c z7NHDxtY}sM8K$Gu05Kpaw5L8~=n?Ia;a;lWDlraCRZvL5MhEG1ZB<$-_ES#kVdXz# z45w%N{Vx4^%f>ZYDxgVAj-_z$RxcH}A znFQElNnf2hU4v!I3U3*9$igHZDl|wNvbwy!a1dz=teEJM?>ye{=xxom0@@`YQ*e#e zv-J=Tb%~17lwaL7oQsNd0n}bafRwmDRn}O16?{_9ktF!omkz>vcs2e|iir4e5^jEp zrb~r)e?lJZbsM0MlaC>_OD>`_#-5Bt345%@sHBBzJ3%lgR~I-H_ih;@5AHhgHV?!> z7x) zEdEM?Y{>HKlX2$zZ5KBvpXIW@F>1o7jw3(c`Rvnh;R zVNk=_>!|&-`5}>pKRm^ckjD-&6pFq$iRD_}G^`5;I^N5lKQA}cF2n9nb8{8w!+L8G zlLW2sIkXV#=8ET+30(C}W90+EPN8{)ILPaX=m*z(Yx;gjw!5}=G9pR#SwjZnvMb;fett?k?XZ%Ls2`2dFyZ+l?5&4c@}goivUofhVQ7tQ zuE?r1V(ue;;!rR zozdq!CMj1Hnm@!JMzG%TD)#1su|eoP3VN^P%Kil0Fh!F2MW>Q?jCv6Ap-S0NfCM<& zyKpkJIWmYBEtx68+wP|2KwXdL=SKCMdOnH;`qD*XV=W;OXcVO`maJP7AvB`BR3&$C z#qZlu)&Iy$7J zz#cY%y52*LFL41|#_rrl(6P+ItG9+tZICt2G8=##A)9NIk(6oi9>W=6CF|y<%zlMh zr5p6Ed|a?zgQBFg1!VxW)U)UhO9WP?DUxP3bAdFJpiwFzC?-_3G+ZgZPqmir?W9<^ zxuN%2>n!aW%+mge+Mm~k<33w z{JMICVE_P#{@;c1f5QNN$8Z0=mi_DA+2%7rzl>RotZY0AlK`y%s)QNufb6fm~iz~AyrO(6texmSb^lbivE&AjR zR5IBV2JRJlCmKB_IWKT4p*buubzmrEgyc@Qdm#dg5?{e_V;CCtCi5+{KUm_uAM_cL z;JxqEtKgrf{LrpCbZf0BfjQ{HGsr@=?YIRo;aeW&NX$mXQe6Pi6j8|^y7cLaf#oy; zjR`MFt0H{a;WWX(1U$(Gqj2KD8;d_2%v2RG%vglBYT98pQirtKVhBzOs7$}9re>t| zXTnh7ZtR0*ykaL6t29ZE1GU}tMBTVYRpj4bssA#Aaqu$;$OM-L94Yj6d88(3amyM5 zOaS=31gL2~TCdh;T$u8a;!(UyNjOj6luZT|KxdP4JPoaRlE*^t>f6`qV;Ok47{dp(~cjQcQ?p zOL%dIiz~Uutv1&Sf#%wrT~wRCpdf|wHtfouzq0u0=Igwsh)=*~s#9%o*OX&$FX#ay zlG8Xuf}D?^ z2HhqrnxuZ~%>iBIN>hb^!^Y*oRB)tghcGIsB%hdk+0CbkH6uk~afQ56;e6EqJxTy_9h`(Fa3Cmg$@{Q3U|B zT+DC)`i;;S*ioTB(mmY7+_=sEQ*{CTSO$}FOI=90Dl)f^EjyiW`zvx|?gB*C0|?mn zBfXIh<|&5!V9cJoN1+n!$(R`ue-)?JmiYyL|R`&|5zX~ z1Aja>*RnrP_(8Kxy?ORN>QCaGK!_cJlCuCo**>`1NaYH5{96XHX5`B*9>%Lq#}{gY zv)MdZ29?+Ni}W?Kb2^cIOr3;JRyi|j5?^OseJIc1KUg^JLF`w}UF;Eb_qPZh_{v;~ zPX10aMPR%ubbE88=R=Jf*aMVgKQMLI@N?0(0@5d5K~_RCM^2i+q>;y5OSw%TZs;&r zerZ#hV6m0;*eMt4Q8K9IxxqEBdcs0IcWN~7Af;8%6H)XmW>i}e4C7vU(5Ye3DmNig znFXp8C34O+rLcq#cV;Pwv!U{pd8JH9jEme9NQcne$tWUx#oCcG(|@e3Bda`0p|Nq- z=}$fw#;cxt)0)PqS^MK<whl6ethX(m49&{T4fVy5Pt`#tF`$NQSvSS|7>;odnC%m;eQ2cW&i-<|8%4OlI#A9 z`Tk$<_y4=oY0>twUs6N+@#7n*sfx_l&KNhbtlCZhi_q1Gpap4`ZmAS9;lw1rWBO&h zfd4fk2F(-@%1u^>2q3W)S_IEX{zDiKz6%EVxvNY*uR>is=k8(#UHN+Iec-R^ z>50ueyFlWgK05)Gj$vo|aTzraVFWj`+-4`-kW7mu1(t!9b4A zMqUM%zOP)a=BWRAQK3yMtruh}=_EC8>mSL6WssuMi^Y*~gcR8IN>gpy6MwoiDO1c{ zQ|cg!qy|qIW|2#opMN`kawB#jA!>A0qOc}^Rlur!G5Y=LX^|jnbQoo@Db4gn8EKdM z+Q6Rhrf=uNclR?Tc7n>*&PJJ?=W3SEeH8W#DEDA{bv1Oix+WK=38QkQO2 z=_V*i%tx8XC1QwWuh(c)av+a3ZBcmE355RBB@%V(HnQr>N#3EM>D$f}{k!g~y1N$c z8rXdUm~fS*#6#Z>Y@3rc`LwN@Jc4(=)1|oSJ9BBd<{rLYYC<8J0Y{3oC7XEbpl<`5 z6_k?p0nff=`dQcHwTii0-B-@#RRmcGi@{!XmTI*zu7jm%0B>_sO!Hzzs+y|IFr?y| z^(6_YPZVnUY~lseqki!qa;9v%zB$H%8Z8F_(f`oB&I|Slx{21p=P<&ea8U_a8t>zm z+&~;CvNm#TH*$RjkAbNE#3Q>L)oA-SCDG^w%WNI0RI}Ak$i|a-05j654l3K^R>*-{ zx}&%eu-cX=fugb}oTCdy&z^LhOqgY@?|3K{iN zVH}VB18l;e&iP2Pv0RI{RIPzkn4U$3DHk0fv09172onPRyiNoOLZgStK-$ty;_&)% zAX3X;7e-8g)HL4&og@>`<*6}c;JgiSgjtw_AgqZal9TPrszNFL+^a38f%V*H$$aNp zro*lBdP!C4M23F#PuWYKUtl8m=EGCWG7pza3W|0qt1V+rzD)zEX%9`(a?GUU4s%ik zN_)t9)J@U?c%CUAg*hLER7L&y^%tv~BV<)q_u=J~FPQqtRK`>UazJUzRo2Wo`%S5w zB(-(D$8FQ^{`ni>$ztyoLZFS$0m%n+(e;Dlixq12)!wL0{h!+2#@@d72~2JNS8rZY zM#x~Hz1lkeJ#b>=>mznp(zkjHCwBr+j{O_ibv07CcIQdIdtY%_`z}<|7b+hvarEGR(mhl)|;~4XT zHS`+aV*->ELy>Za4=q*LBCYVsE>R|MgttQ#?-x^;d(U^gq^wD`oh)lxpJ-j7=5!V1u?5c9?DZlVks?tTaEk;3=4yuqK2;ypBLQnV%Bio-%%xT?bKA_q!q-UcOI3 z(`@xj2Yqa+3{y~}44xB($$$;+HY;$8Q*ep*NB%M)4}@S!;rH|YvivnHEj6+qh-*?{ zOG3g9`dnTYHBV6;q)871-+P;@-_z*P$YYOc8zAA&FG#E;$E;dnaKf)~{}^S9;Dxq_ z+(t~y@drCxU9Zez#nuKSdK<`Q{?1kfR^uMYq$FWbN}Xp+h6d^PA^=A-?BjaC5mX-z z*dwp9LS9s{%#Eqzlm$=qT(gpWMJCOp*&jLn#8dOIk1l1l8ny_I@k|fknPo;KiC7*1y$sE80MDr4QxRj8qbT zf9jmSl%IJ9dOu)N zOj3b2^nG@&%J==3b9dAp^|eGZvL2gya(F6N5Oq#Ki-9T|y4zTr>_`cBm$-LFer^EH zGtBUO*^z`hCO=g5C_F;c~L+oXbDzk7i!m>1Uq9uM$uRCz$V&wp6IT1VJ|d z4&L1XA$IF}JWkXBCw_pdkP0+|KKrkb;>60Iv5fItF@rQ>T1-B96C z4nSp*MJ5|NWfJPhDz+;=VIg~{6y%SFVQf;n8ijTI5Y9?2GREGej&p14y%LpBND|3@ zc$3Kks8DQrz*49=Tv$7p6HTm#kc~{oq4SMoc1LmHjRl4gM^Y)2HmPJaiRO z-o+W3NRzil7Bm;sy$|{1lWzQx^F9f_F}Szle7i{;I^4agwilfn2eRAS==Wlee?-lr zrZ|KHWEMy4>x%c+xT;)WfRiR8ct_+@LpougYEd5FYGDbT^tGg3pQaECR^gO1)+G)I33=TPDc zzE4O58d*V_Ed$+J#e2Gc;KwWd#cm$g;K*R2=tu4&90aM!`z3;U&woV}VyB*hP^yve z3$_Atw9pw|PTh>)bPH95#KUn)<&cZBPu@mGIIAR$AriTsz#`B8l`8OzeTYYM$J_2F z8Q4aZfSB8Xd5O&-d#FR$FN^?W+O+-;CQp&82?G;QF}Fpvs3nmw5l`Xpb4z=4ceWNqiYYzffL%|ewr+qYXEvz zru%2w{XzjK?szy-5^P7{%#vq$1C>Xu$og9W1;Jr_=J5pzME*jo#aMOKF>Vpg%+?4J z`)34Zhg|P!1{z7gB20|w(JT1*OzP?sDqCr@TWcr9M`rB*{qu(6k7Dx_2@89de=PhP zm=JER&1z{bE&e*r;LEZ$*{+WB_*Znx@2%17Ws-w&uNs3CzNbL~Ds}91t)5luhG3l4 za-ZO_|4;QY^_he+X?CP~dm?TDtf#jk*LIds&oh@!k&%=g{HI7Gr~Vn z<0mN&Mg?&Q@=)mac!HB#P+X!OLQcPInnYv9uo8|Lm-gbq921hJW8lMrma5hx;h&PNrz6P5Tor0&Y?VT2g?DS3&hG;+; zn6-ozxj#0p?l__?Xte9l8J>o?xcnh<6k3)DoOOLw_A2v!bMbX!^-Ch!_BnD3GdbW` z6&DO6tdj<(SrwsPGRSf&J`G$#Lb1*vW)|754Bfkn)#L9N0O^c4#)^#;wIrDl+K31+ z7fN+;oY?g1PQJTH@}Rckl>&cmk1f6~?i`#4)hFPc9cMEqV85j4RzsjU>Ci!-gktv1 z1rS9T!f+-ai)FKkXjCv#?rwPUO?TbBZw;a;J2-%9#L5E?rP;e(0qru;$ElA?Q7H>s zQ49m*ExEX7CUD7rD1VM^#@>=}PrEYTxF~-N83Dv^)hy3Fi|89JI$qKMlf|Ay#}~_H zIS#8{apzX5g0L57&RKF&plmzc1tu`rT+oE%BtL`VIri}V&XJnZ0)>_85qEnIqH=_T z`}o4IBl;wUtS0q$42cDcbz@EwL=-KO*BESI8__a0qchlB_P)aNuct zB5^eKCNI(q!4ZSt7=4&}IX(dc5QO;0%NY#k5|037%1PWK+4#^6a65NX@5WIk`Gy_Q zGaudTa*Y-q?KdP?&(j9mPv(3hdYV5$eHN}{`{a?-WFvP0Th{RAff4W}d$uv_Y-i`# z?WWTHO$ubAn$>bQ&02te{xK(t{usiA;R7S1lgdQ=r?%ld=d<(&olvvmg8KR>vG3fF zSLU%Ldaf6Nx?B1KiDrzjHN98V0iw?Y1cQ|$EminyN}Taarkrp zm^Y}N$6(m(mY>LqIo*~jErb3|LDQwd!WS(zj#z$ua2^~dHI~OZux2PTYPdkTAyk)i znRH1Osu?4jY4V%PeneZy=elzggOduIV{XzgGq3JM){*>_W-N>VC z?QZNL_;1CvK;eG)zj;SV)yUVaHEtHWDS+GA@0K(~urDorKgjPUY#e=P08eFw7k1#e zgVrbnu4flirCf<20ap_y!1aSAeZVGd@t`oG0XNZrn_vK8Llo{Z)XURDzBPCJmyTu4 zm+y`5rkpUnNIA~ootVSyutJ(WMu;cZZ&!q5aMK@l2&%x5cKF$P?zi2X2u|+j zA?AH;;ZHw+zem~tnq<3c<8!@Moit5#mmt*!u#b=BXdZboKW?Ekqa6q0t^pgb(jHHd zX5`Kw7|dFUe>c`od6o|H%7eB;jnL!+@-j{YMq&dd+}Z7A54MN%f_d@Nj8-i9Gm&4} zHY6R<;jekh)vNY-t-JN7ce_9ai;;f*ms|32#Gaw|mn{$RYg+nG5l;VwlK)Rh6RwK9 z);Np^S5MTzRJKkuwMrPRf8ixgnHVU#7IJQDnk71MuDwHWljH*-2`LXSkR1AP=w#YK zPIt4>YA5BxQLf(U8fwhk%yzmnJmL@*a&ubgV~HeYr(PW3le&8xmJ zpVaxk=G|y$mo_LWLPugVZU^pH_nq!0`aG~1zW)&gY>u2RUvdx!6OdepVTWaGE0-+! zl-vH8ztA9wt-2Q$J8yM%uN>1|SHc@_7R6)ig?2avBegEXR+aHuRBM7#hRlfxFr%zJ z%yCSVG;VQpvO*LomnMoeWG;3YJr9r4gCaOh0XE)t-mN5iRHVcY`Kd`%uojysJpnKv zFH?h04~I(6>B~BdfW}H>+7jdFiR;)ng-RP)ZWH|Os!pnk0uyYV2l54=NHJbo99T!I z#3qhK?tP@Afb0&zw>JJv_Q}Wg8Ss0hc0YuH`+f(#zm+YA1sPo3pBp=|6SH&O7+k7B zS&eL|bSg5%CF-T{)WXRLGn>g-C~GtIN6JT8_f@2zg42P}>pJ?_wmRZuF?mGQvQVOh z5>qk=JfZ-ggOUL_vH`5?RZ+U)VOTH`2>)-07TPi1g6Xf^zW6K9(EbN;=D!p9jBQK} z?OYx7?QKl-%`9z9|A#52M15OnQw*hdRoQ0PB)*&$COOajfKQm-bJc|fs4fso`(EM# zY4kAT8xtl#fV>1mNH`-P)B&)VAH`vzTRJQyr|Jl?vub%lYaPt(+%UR?cX!=+#%*T3 z3AinEGMAgjc715>8!Gu^zS=lWvXSP)kIyRSm*E7&a_J=L2IaBc3^fPreizf{NG0j+ z(e|{R^-|YXf(W~pBkZ3a)@=xvFYmb{0CwxA)oqo%{{`KxF!W^J?&#s#8@a0fISuS* z-q!KOyBQfFS%hWLqicUv<6=h}W7+Tb?&kcnz+cC@ik=xm{{4V!RZXHfl6Xk8=l+?- zej{0Wfdbe7NK^$O*e;N#Jim#Ozw4nP0ESGDY0+vR>wG3ZUPOXuilsY8vl&FE#6-DA zs9q2iiHA{kL6zW85HsjkNyQkF%U$I0*D>ZJ0Wv37QVB29U^HZy2CZ|ir_hX4fNxk7 zt;4;%eAgvGbi+xIu3jy{3xTjIuwBhtpcmDwChcR2Hp!qr3{BG&4@S%JbCVNPu{Z?leOj*Zq*Wvp&iLL}n!6=xF<|d!gR7JOMPK*k z#tm2wQi8G%FuCZS$!LCnL;5mP29Qza0=h-VaUhnOFiHW6JA%S6 z0H9P{>xK2!p+{U^2#H;@Uhm|`5KGAA#W>SYZhsxPdLS$6;ygeB{d>;+UuN{f<@O1_ z@0Xikq@69cXlE0qg`_%>HOr)PIJqb=r4?&VhLkzek$e?`e5F${C>l@tRZY`ib@lVY z}G zR3jKAgmvke@qDX_BEAP3)T6T5N#QXBx1vLh#>SV+9znVU!yFTwokPL(z~d|E;<|JM z(JqX&h&(s~XDP)+v}9$+bcbwXaY~~K5uM#%uCvmarIrPzxYGOUcsji>R${Swh3&Fd&2M87 zdt;GQ@$C(%BS$^vlSj$t35IcLgWiK<)V@AnD^HfoT0Dc3VN*0<#;#)%EZy!isR^nE z5YO7qQGY`i%=E;|J}%f-;tr*d#W^|-GX+*DUpnz{N;>7Dq*sRylT|Yx|5sMQ#Y@ji z+pkOO5%WL0wEp|zHZyfGw$Qh+x3@O>rCR;p;`$Y)wmpjKtB>!9p~NC4X6vRd1B0Uv zBbL65%rH2x3DeX}D{?DyR)(hCG@0rEngt+eu|Yv#zJy z+iScfE?X&18}V8UC z(D!uAcZ&O+ugm`C>kd0M`-f6Z;70B`%cZd#0@ro${GY8MKrFf$QRcWX=Bgm_Jn1JW ztj|B}onK#j<3%x(57?`6Vy9|hP0%nfLQ;v$%Xgm$9)Le6IPk@(H?jq#m>UJmro>=- zyR5-}>{ov*7UIZxR!d26nX_%U!#GAhZc~6dYmoyt3{5AR;G7u@w38A+(A5|WM;w7S z;ZrI@1LxEk8jLV#%WSbjp)h){LNDVgrDVkDht14kA_V^>kU;9A#NxfGjofdgG7m_S zUC!-sStJ9BNtWYShgXk?dI#T(lpt+Zkqjb zuPr=B3e00MlH!Tu4N2hYJq(TX?oXMABVjjr#PaN^jLbKmM4!#~aX`gdmF6_9M}|9! z*DNK>6fAi4xBU>GVgopP^X053yE1FDodkcGT*AYN0PpaQpSxe9FRAh8MD5^_G=Ys- zZFks26#05zyrAr2$Bxi=^D3P(hWe=_)lf-sSZE89tlK7uWJ<)Pq#od-t$(~-8XVQa zcaoY-uO%f`scnfZeN&_t7UDa|OJ=KMoMS?caYBw!LXJsAj#ff`vT#2F)$!X1vqIG6 z0ME$#2jzAf*ApmJ;h2oHSuT)eU|@8T@p?V|ddURKfaU^n;3*zsXAw}~aaG=*F!)dd z#6SfAy(A{oz+A%!rQ9+As`Nlth;Co47i~|2+JjP~8!bnl5C76Lf&`dsuqt5nLF=8s z#C1PElTeXEEX#6V%Xgp4cRx_B)htJr=K>(Rn1p`J?9K%pqHzlxdBTV7+b~6I1=fy` zD%J@$pOj5a363h-r;oMOM|=Rp6CuK?F7U0vH8KH$HKPL@kZ3jF346w*k>Bi2S{ufY z?NDuW?5QX0nZcyCm^b_Er?L2b6^NP(P`);WLZzNrG%R{S4Lu*?fz^+l$5U8b%$NZv zLkio>U!fGKQ**XV1(6MrU*O}5u9b^3Ju{|$v*HgN)49JewYM2}D9U5~w=(ruaZ`ID zNMLR^zwXq;ear?tTCgXwaEy80&AjM=UjdI}^MPkH%F_Ywg8HQ}q{FdrT#d%c)#qi$ z2@NEet$tARefki~Y9jztkUTgl7sF!V z0tg!GfFzR}3Zj_wl3K9rNvDDZM60aoJTsCxQGf`;F%*t+eD0#A4SGwlZa6B( z8Cp8TW^MGylgA8E=hx!iehyAl$>I?8*M7~2%`cSm%j6mkYMcCg7ekL1HRsKfd^lZsh#YDW$W7($Fij73Irzj-i1|;v{o)Ug8GX43rEB@V&`%3V@yP_m0 zkK>_dEnDo&vLJ?@zrV@j`JP8m(stH(4oxs9QU#nwgYD&5XR;JFB$S|~7u)RHIsro*DptvH(*H1@v9=Nwr#(`OWMlXM zQfE4aXCWpc4*9{{!x5cl7FM$oNu8mkO=PAu041tN(O>T%NiRD=u}|h9pHoHd&e9gI#Hy^sqT^YnntGtjFD<|d>z7Li zSyGnJ^Icm$L%&)E2JJL$p zN`)FpfMjM_eAHFjTQ%N>&zsT9PEXgR;V3EfH~B0)uJ(eus#&{7!u9n!tY-HqPoGRk z-$Evf57wL!>V{tP7Olt$O|d*rY38-W%3Out4Qr&#+(7G8nVs#rRfw9}>GrDrqN|*$ zp`pK#o6s}s6Hqq+Gk(N1jFLNt%y`;C!fxj8d;VgC=S=tN+~rt@|0U?zLtG98-`WM z;DaJH?=mh_OXO0Vs3re7=PBxHK!gMchN@afJs!n8A~vU!nyQg0C+oPNJ}Bdy8~qAm zB~s-^Bq$F?O8H=;G#q*%=&1ehgInfg@ArC-m!>aA6$Ip+KU5n#{~nSM5;Y|16arhG z9>ElkSPa||<1^e~fYDC*I$8ak3(8Z~>8x(|w_4sn8KT#jl&7I@tWDX9RdNG6LaHKg zAmz6@d;wsy)UDRU{8)PWKz@koIirAMCzF&gmSpUINJp^Nsec>x5(!47^w2=Gaal*$!*}q&Mz(eO7w4 zKK)sZt_=o*OZw7lLS2bI8+Yz-vfPmCHHvc`8_9??C7n!k+!aP!~u zS6hWCzbe<_SK8PtED`_kxL-3V1rkf^=G~vlINhrSZJg5fRz$ zM0aZ8zi>OVD!1CGH$-EE5U$#X(V}I!Lsf*-BL*UADqkTxQ$cKBQIe|}C97DzoY2jt zAmLO_nI8A18`_+bj3bKYDfu4ddd?fFBk+9`QQ5k3=`8bFKg{K$J@R`O7J5EAAcCnE zIuXU%;1ZFC_w4&(3A|;E)3>LE)h^EXP8)b?J_4ojSfik|XjJTTE1bTWm6zAw{pfUi zml@xn@i>%z1J^|@ozfrh>IjqJseHrDhcdODOPlTM_rTxG?qI3U!n+an6+ck6%`{83 z_VdP$g@c{AGO=+~3Iy0)L|UV6W%mey$pr&!p(|ej9rIKx&Q&Q6Crr~#e8;l|V!sMp z?b^+0+YGm@Z2JClBAJ2M4pA(!2c$!1F%)uRGuSO6Hn(zdIV>I*DJ&w(G&8`YsftES zh;4n&Bt7d8AbLX<;m!1%qqm%mUa&TSY5ErHJF4&cDomci(c*0 zrGb_#B|t;Hj*%)F9Cs-mAAwS7lwvW__dj=lVqv&`UVde!kY7L>>HkY+`mdeN|7uno z{>SyCZu{$ZK>5DbcT9sCIqR1l6i{xyoMf@2KUcBLBNIqH++wlAqMQ_I6BLHD z_c1Q1POhP?E}_iYfKy!fPJ`l}A%92yQ(K42E%z_bz2Sy)mh*-4l<(B(yoecfkt0dD znOUPNctW|j+=edygVZ);-GF0JW2iof_q3K2X zVg-&$GLkI0tUpFs%5|WFlv-+55Ob{c03o6pgDCl-v&;%){Y^Iw64FxsI2#5xz z8u3P*Xn;{?F&7o*|B;7`X!<;|R?xSS?pV~U-?1!yajhw{ZT%aQHJhfQ^1`5y z1}kfiBMl^Z3{=D!H71g*(_3%OnMYX4gaZVB;$ts6YA5F<4j_f`*Z&)^e zuIJMiqAYMCfDRf34?g~@p+!CmOT0L+zA4y@8CY)|d7-~=wj(Uspo&1RQLIJ}~L5-Z!?dbQMe*P>XlFLLvbhL-O0w+;AO&D9efdXt}L?_tkYGj=^Z z{FUkroNZWdexYYESj{T#v9O1+u&3v#&WGpB#WgF|mFm&2l?A;1R=8H}*^?UfCURDQ zjs?lI4kqVO^rMTKmOpNB&*qogHUH2Eii*BKHG;Y~*x3l-!2lc$jD`PN?KGdD53Ka| zfO~}Q!JPwcS)g1S((6$9Amv7wCj2`nE=FKH2LIy)BP@DvM``7?&!T+s00Yda_^d-|(p^ID+VEMe5UX86)BiMmV(EuwjRV%!ml zm!aS9pUbD>1Fb5F`mL^}sG(-aiLv>rhv+OYmYCjjMHBr)Z?7_Gpy2U0nl#sOmT1AD zC`qCR;_Ek@NZ_%caIk)-J+N+(rGmKr=09^KLebR9{8KuEjN1Z@APPJ=UcNPMQXvI9 zh6#BN-GZXjZ7wtuQR+JNaQ9v!%r^%sm`Uu#zzQRs6XiTI=0@CT)*;`lRdgmtT>tBpwAF6086MhNxT_#BXl z(le4_k=&VliHrb2RBLaBY2<$oG*WKZIRX|-B;eYeYh@9g(0M3I1+by(5y?>csg9ht z--Eg;j^ENlb1?)1jV77j0k-@V+_QD9db`ax_Q7EsThIb>Uz&JY{#k!r>pIfWg*o1! zZu`97m>%tn(FAj#~zH40jWTH#><$9!CMH9bBz z`$7+XtYH$WmM>In`{;;^&6%$g0KR&;6z|sRd}EY5L6H4)qMF3Z(?UXkMce$qsy_2js3iEHKyj(AK>CnJ=Nh)0l+g~r6dK(Mi!E# z?7hb|YN8;Es*8+hl&brG?tpFYt?RNP+I`vIsmOs#zGg0%s!Wh%lFjkg_U!8D-g>UR zzu;TH#eP+y6^4K31=3Ro%fkvI|A7C`5>i4n$PIY{003|c1c3VgDf+Bz^r0G7zks-x^$W`AsEcZhYS`y&haPmsMlQ(K1{aSw{_ZEoiSA2J@)JSqt&Df@zFcdH(sThcXTyiyJw= zuN(fK&AeXgo4!Tjo}b!|bUpaK3zikn3vWi>XttLv->hvdmWDVTHLG=5ZZQh77`~>O zkrLIQxQ1KyUcZOk##m?048M=XS^l1{hG}*D9dDL;X1ZRc?-W-*#%EWDI$_RZl<&By z$CsNG&yU=^_c;6S;hzs1>kOYY_u*cjI@YE3F4+wCnIN;&D0z>jnNj%!l(6@ zuIfFdGwZhSo0j-3IKVh)pBB9X zD%+J_>yaC!mgCo_ukQC(xm?~v*A*r)TC=;^C3P)cn5eM0<3ktIR692v9!blYm%VCQ z4=p^K=b66G$rh)G5z~)kXHw3zHb2K})^F3#aqu5iI8%M`jj*`hQ3f4dkJyI$wb6E? zOh@9etLeracw?t0V)nMuSXb+%r~1;%a}TqZ00A8^R>L;g|xK#Javusxx>*Ly4XFfd)chh zpjqFh9SYXbZ*Y?-Ne^d$lO3MH-bcNGF^o^!+~#!HMm|=QZ}~-xhmGTIn6;+|9?MA& zSet(*+q~nBFWXKwpRYeoFIPILTdq5^4)HnHlsJ2MugvZticsQCM-tkS#AC0tHfwIL zv~1iKzb5Sx9*`z+|5TNoe**uJexumONHGwurw9^0aBM>javyP>) z8--av5-E?i0~Kb_B-zaQl53z(dSS6frg-bED}oyda7gfTE*DwdEQNbk@$oov$TEH( ziepJ)r7nRplVl_Zn!T(*B=u0UciofS@3I!jWl782lp*sc;Tu}I>HX36{ot1V`L5de zeqJWO!XZYqkh`gL4)Gh!76y(`#IRzXA9&}! zyp$i_1HZ(nPvXR>RScMjMaNm(DmC8UdB4X6u8BVa?`65I8K0NWuX#bu+Yh^GdywZx zEZeP>hy7Ygb3EJa{Vc8sBaR&72&*t0ffb6OeYy5WrRIJWVD_}g%gP9;7B$kb|0Mp#d0?DWM7i*1@_3SIricng0Q(z^>?rtbi#_5g=9or8y8H>z)P20!S!i~VM7m#?9s?&tG9Neq=Fi;jfc$R>82N2A3c)kxtTF>I{te)j}mbzzZY|)N9H&{Zd`S z#CXp{)6TAZgG>}$-8Ql~bOUJ|lua2RH;H({vB}-8Ht)XFy6F4!`%3&m^YVqrXb&|8 zk>kl(yJxNTVeo^xk*dy(oJswcr^BWdrEO>hTF!fC9r7n-96;b1ZW!TkoF7F`m{W-7 zK3MnOU@*Dqgw6#o3@~H40|LBPIw-`)ey!A!HFc=5)z?b+q6S992&^x|hnqGXGdb1~e6*Y{!JtF*{kiGL&qQGi_ zQKab|`;OJd{1^~A!J#l_{c-LzBKgDPs=^Z;8|nomuZxu|r`geE?J~IpeF-XI0f;4@ zjbT9pM>R7z@biUXr5VI1+1HZog9TmKN6d@SUt-;K1(03Q~?0Vy; zh5iELfqCA~5;5t2;o2BWkCXA}5wz^WdLFj@Z|l5?;!_O^h+1F>tAiC07*O5(JnOLk z@~rcY%nLaR5N01zjE&7lsUy}#TGAvxQ||Qqx%$&UPiP7L>S{GQcH#*ut#iz@yMe!m ztf~=x(XB@B4!{QDiVyF_rzwEX*>5L_PRPrk%Z(m^%YdWGe($X&fZmszsjr4%3n6DK z<3Xcj6O00z*_S@0AlnvI0l=33?PtAa-ek-dU|u}4e}D6Xf2Ph>I+S}PpVLaE1xuQ${K7R!l*{o} z96TMgl$+fgP)_bN0PvlbA)4)r02hE}dPAUF9pmx>S&1HGO; zfIRtJy_Bt*>H?vX_Ge!eq%y;{@WS`7y~$al(2X zDFXZkmgB$8g19AqD5f@(1vsb6@;KBo_=xo&aK;<~-Ue+446H`HsJ)k1 zArz#woB$o5shJ|d8mgIf6IXm6of8a9xZ8R!1+~z--XCZ&Ib&2g4u&^6Wy`fa4(}np zVb|#t_khMk!8V}mu0d`ts*|CndQVKV@dWaJB&H3O#Le!G{~s!Va!} zFD|)qgmsspW1}bxX+Af!ZLnJ2q8kAYQm2yvlS;*2o8dOPCDV zBG9GsgsOy%u$6ihC5FpUI z8GMfuoOgtl?Y>htyS;3;Kd7o3A%1@8Dh5s)hV^6(2cr!ne$H-a7QC5+k8_Ew+fjM} znFhm2FQ%GL((dgm8%$kJ)je{!dps6Ud4cS93Cg>Lyy}~SXTULn^`ylJ=~0c5;|&kb zvlfm&2!rD`pX6xInEESKbyqwW7*`*;#oQnt0C<1^PPcJdJL?YWGDsP&Hlti6lcab7 zX+hKb6&D7W-w_I&J(>DQ6>NqhCQ>$xscvH<(SJrF695C?l?Ym)kai#gV>iRo`oK@+ zu$w0V;X+TQ?5FMgHLJSW*7HR~AAk*F#3uXKwRbVo<(Tr$rMg*aDNuTNv+HCvPh!e& z8{e)_xDQXa3VdQ}<*P@c$LdEghxcAg4xT`A^2LkURb~E>1DQS+2P5O%^Q1l+*v{0= zmlq?m_nZ2wRCQ;CI^n}j!C;n>Mr>B*9iBN!NVlZ64|N*Ccr+?Y_c`ed>3 zX&Zm7qOPCYgZ6x#VPLgdp+dmB*_$hV*apt?*1{{Y_qrv-!nJ~IcOKBpbag`!gS~wj zgqiaxxktjr?g|^a3-pr==SNY6%p1E#T_U`^Z7=$(yUwW|A(!eRWalz_*t6dkp1-`P zyH~2&v^h7NjKFLPUSL6f1g(-p+S6=gj{ud6rEOCH8_SvkmMt+{=De7kk9WWeS5MVG za+A5n(b7@72jZ*#vU0WH#IRQOdRpf!xjFLYi-*1Rx!W)t%~qNR1H*P+H0u3D-P{*p zRlkIY3h~81}MH3*+Gw&fF1QVZqR}$9Vc4 z2jDAs-K?Jz7|6e;rE-?;&1{WfF1KcIfmAn$NIUfS>n+KA`Q+bS)J=qxsN%bcBDgB- z!b{5PSONmai{#(Qv780Ze+@NIfLi!EbfATF1-1;%c!%x6DZ7UpFC0A`xRwtkIgYBW zwizFGDsXmN!|=Mxo5O(*4Rj#nS)y9D)UVfvjrZH`#k8)f&rnS97|FJTzY|#iAa<#& zR?nH!T2ZM^&;d*3xTgs@v;`iyu80#T?{U0zst=I@(+{#nfLJb^YZLfDbEKMO_T&JK zZp~OKL9Au>hSiYS+pZ%2;3g6a|6CkWxT@H=7;{?%eTXo0aN-!?Y z?#dbi^s#&nlP{Czik+mxm(l0FHQgm>KqMv5^1)rV9YkXmrmV4;O? zjWrDkyOY<*#MfxjhC6_JuqTMrBW?#83`cUCIAfal5m_vP7D)xV2aHMW4z}!QVWtFc zLUDovmNYw=ai*8K=-oA4H`5HEMUsJ4sK=dl8Z(Zf=IK6CUPR;M$y3XFkzm($u?s{Q z;cw$Q-uZY3xeVb1fqE?7l}NfM$ynKJw{k`-#S~ZUb`$yr?d)p4A?(0D4GF~+kQi9K z`0`{c%Wt-Op{DCy-L8^?Y#_un@&OqkF8IeLmy#EVWLXziIRQ=xbR>vDc1poJ7srLM z_(Jmc_*pvH^ho`C81+uJfgM^Y*hgNp%}(9hnM&nIRzgZ08xY+a|CQqPsCd20)YK1u}`Py0O)Fy6x!YvIZUtPkx8J z%E>;|YZ~V<*1;Ri@X*?DGU7@R-ROfTMusIq2q_us@jd))EaG1ZI|B`%drwv+!>|AR zteWn^fj!(2ZWe$&wc)&H(lIFL!rq)ct=;gxONT%7{kq@Fd zIroV5tUvZTN+XfZoE))gCC+;1ze^Lj+|bd|!rY@T;QZdc4WbC_Ip#Q=d9w5AN$b57 z)bfgxlWDDrAp=$@@=N~gyrkA6Z>e)=14yZ;zc8%8|K`BI^|W1ISG7q9^BtB0|N2Mn zUMeue9>AqBa%qs>;^6DV6EEf-?XpQ#%c2%>&*xis9Vcg1eVl5b68Bi{Mol-zH2CWc z(XH~y$(dEfxKNXUi#3_Xz-Rz-)0wnL2;d<83-&@rb@9?}pt3vetYSFD^iUpchX?u> zpL8J_$F`|?I1Xo)Fl}oBQH_~rtl~QS{yAb!=gS1kVd=Nd>mO9p1rGs3$nAKI3xkZx z^X{?phU4-Ec35_nk*g|+Ri=lI)Jm=~-nDoIS@?@?w>4twbSmkg4$^p-K|+CCjHqjS z0#N9jbIw(XBt4=gq@qIFD-jBsnZ{NrF9v7ygHNWCXH5N-YP#06ObC!=0Wdt+ETBHK zV#~@r0K(E;D^szYNDlfMKv`$iB52cP?>XlW{By^}Sga@WP^X0RNA^eOoLR7at{~PN zS4}|Y5C{s9xDbMcYyvO{Ts%Mq3$@+aO1nRkvY)o|SFGupotwIOATAb{#veon7PirxDXoVMBMWd4r&<&rVOINO=g-qd{#r#{$N7TBLKHLL#P?)Va=EoOAw2-r(REu~ zLMyN&(q&({rv-?v@!U~zO^d?q5*l{SfUie-`@pR^9x78nyb~vkR44yc4FLo(+tlFH z%6petiGTbT&s&_q#L(nCZ-B3di@Kv`UVlpbwiQ7$6|^;iaV4Y;l&ypfGx;WTcdlZm z^VRy+h0bc4HC$CVME345YCVPWNYWera8RgKRjMZ|s064xGz;iy>QYqmj_aM^(fY7f zMcsv)l%0diB*`Z$<^MG5{Y70>rB*W$0|i38P-5#HVn^PM!p|$h&?5&2A`GR6I{2Di4CnJth}6Pdu9ZpmLG zU)c^KdJ}D7JECTM1O;F_^%phM)V^K_hAj>O)|r+k&)VJuz4w@^2KM4q;VL;hI4egf zwvWW(jZ3JnszBK*Tt8F@ymg?FsO;IYp69Bl>+ZPU zs^?CRha9i8Bc-EGZ_46@F27xzph>P)M2z7r^ zSJ&{GOR&3Rs-;_3D4yxwPXqnrKe`4;Jpp8?nT9OG55ii{H{v5+g*-mZ&Fch=S5NVw z9`V_9kHxb&-ftn~rDcrTP9uq$hiL5IG zuTkL-np_3my4v~<&aHTkVw{Sq$yFoVPI%V1PiVZ`ay={qN50+mQeQiF^7f$=Y!BCA z@hf(-72Xyxr+7}VE{=9_Wd)n8#&J_J4ZkB1!lEZpW-Kt!X9T<#;R{iN2QV6ncLOR` zCd{BjVgrPtq!_SeZ_2bPyg=XJ$t(L}atSeC!56Cvz#X;=r{o`L8A@==eN>_u7%FIz zl9b_i_ja{CZA*0|4f|WQ2Mj3NAO){5c>QfP+v1Dx<%Ozl2LbI=J%KdgTipeMXdR(M z2v{X)tw<;j8H#0^4?e_bEsr$SDUq%_b&GX(98m|c;LG;qRR=jC%o>IG$dE%6MCP^0xHCRxed`_xTRn6DPOnu%kNp4KQar03_-z zQJ1h}|U2m`tF*n`19kSM%Yax@4}0wFf+!=k$&+Y-TERS~&Hv zu7TX=+HN`AOZpO-yf(I(cs}4XBoOoq_WJO7WnIEO9JsXjiAHgKU>(GBY2F-G zHv+iTNW=!j?EN+z3;^>o7d$zWUvH1Y*Vo>X5gxfjFhLz7KEv1AbUXurH}Xh5W15{M z=aK>tI4I)8p0X?Iq_42Lp&11GQjzV${dL`~p0=LVN9{B&!YcyV+K_$irjUjbr?M4O z&yBjOl-al=KDczr5HARXF8<0LNW_PesSlCgu4LMHwyKfOYd_ME)54pxyR?yDbz=Njw9RsF&d$3*X{I-$q%nIwsxuN-)-kG_tSLu#Ap}*dg;$fS z!jnCu&lvhE)^?NbW8q7jGalAz=nL}VrskS7KsUwZAy?3N&|@y6t<8ai06I%9yK?FT z%Dh=duM)g?tZ-uvzYjMA7o!b8q1{NVqk!Dv*ukI0qW~zr3>y#O?PEOOwN(+WDjp`22D z=$>;OJscXCmQH{a7&Ne$@@%cx1&o}{6@o|ii*MGH_GoIoG9XEQ-pwa_{C;ofFR$+6 zx}h0z2hJB3j117FT1JLJL3syYMh#-N$DDw>6=v-G?AatNoabfpAGE;So}{W0w{Q;y z2^SAPj_iXtvU*R~FvZWZG}L4i7f2`_{t49rm`~e1DaUys&Q%~1FV%@Yd{%i^cfbmw zqEsYTBkjgP_KE`x265-8@PH-`Gb-T=uD;Yz*T{Stq`2})EP97eMt{oj5!m3&(c+=` zjV16n>~MA(&DnZT8{lE9iy#oITHXM6OKc6CGBqOug5A?%0?%3R1t_3yxjDs1<{ZGk zO~je76ufbI4Pi7lh85nP#2(){+S?d)g{TaduF4(NrrYC>Rk!KD>Bss`fvBKa9+ZgR zc<&^kzG|MUsYV#48ntp!>&~$1HkD*T;=>S^_|Y4j>iz<+BN28IG@N!+P~ZT6?5TtEv~FZxSQUmXZdlt#btH zf)}z^1*eljd^X{$=XMfiOt7M2RO*WQ&MS$XObe3 zt-5T`_-&ZQNW8FnSpC8QVbKQgZ$YLFO86PpdjavBRb`?f$`UC2s>}KTsONP8CWREX zDQ3ZqjUEe3L^|7%-vJp3@e0BhpX?3wnUA&$1ieRWmy76-sFvb#e!fuP(O* zzR_H(Yos}R#QGU__WVS6*?2D$d22H}1U2|AsW+AmbAg;>H-+uw=dMb3#Np`%q~|%- z5fHTqg8OP14ilDpJk6Su%MW2|C;d)-cTh86RmURq)g_w&Cv&D|qB7}TUE)?gtf0F% zQOIP}oE2V%e7E!%N>eAe8W9EBT2nj!Oa&+u?YSx4HL+&m0*N$oaNG2QUkYu3&tAA! zgv#8Q@BU~ocgnw2#h({*{#88rQqI|)!pZyrYR)gpE`Q*!`{A#z);~V1AM_ya`M}?| zcmK8C>AN4_>bLvnw{O4w?MeTiKm9MghyV4c=yI}h4X}R@maaA?xRwiL0H&J zbfp2!GQE?9hF|I>4okJBc`hP+wBxI@?VeR#24_m-uZC2>#lxLNCP3~XMk(FqEKNfk z#KEI0u^edE&M_u;a>eDvxg%UZiRj}ilW_z2Kn!C2@#8Gi!Itib&3ceho-U|WRZvS+(KCmeriFQCA0>Fl{Pa~2i9lJJ-I{TCgWFZS8m zjcc z)eCQrkl2cZtP{zjSKfokp8T9R9&nHfz6EZWRy%NZ@Cz2S<>GFtk_Y1RZCkef#fI0s z1Q$={qpH}$9-D$VzEt=-Si8A7N>ou8mSQDs97;|#)GIfMjP>ERuh&Mp z!(Cwoa0w|$NKH?dV}7)-GE6i%xRjPF;Js2|)@@#Hr9+Pi`QhBRe-Y$Uj^X)hfqz-c zpVDudJJ0wlbo=KIZ-|_@=TG6val^@mlm7!Ey?)jEH|uXt>E!=D&_5Wzhd;VGK#R@d zjA?o)rb^^-*0@~azuMl=JoSVqmY-$gC7U1BJ5pg`$F+@KeYDH7hax$mk@VjRzbqy) zLIO@mi+h`><_J@zxy)~9+MGcsR84U%2uy~cUJ$$lqIr63DZaAyl{bH}@wF+haX?eP z&OQs<4lfn+%@NLh&Jci!l0JbXm+Fb{y0-U4XE-#I2&UUX{Dy8$`!KdXZLNEPc?Bn` zdAS<5$eTrih2Hh%t%(HdS|*s=25^z{XBoa;HBVxE%RXMsQJehC0L~|NBg_0&kA3EN zjP;Js^5#0AbIeFk&i&8sWl^)~kp}pp1w*W8V&K7^n~r0%L+Z!XOW!O>IT%1vpm%%) z9_b85QMA*nX0RJ+(D;n%zd5xjcPdeTgLO@8C~ z<&rA;%-eV-RE1a0{l!`w__ehs`w@;l?8DAU{xD0AYYrl<#jRJe?)&zZzy94Pt63oE|D$fK8ZL-B zt3zOu)kc;^tOMba@nAq(m0#Otj3iuwvbC^h2bp(616k9ra^O z$5HR#zRrfBan{1wL#4|s-M${TBpxl))Zsl+wBSe@c(hmfP&x#C6p3s?vArqJ8tfBo z=+7CJ9PLbZq_@s1^iuQ8YKYeQ4=p1a@MsCNfT{pDIePC>CIJ>Z`n=R_@Yecgo4EdI zQH=1Vl4)@ye z4{CMbbr-_N;HZ!%N8BXm)FQNJDb!1fqCyNEZcN_pKuxeli+tADs#zCc+nHjXy@0>8 zd37;BpG5r(B}zW4T6BR)oU7(p<=;!2Yi{t7 z#?CpkPlY3gsh2be?YXuDx{VDy*`!dp1L$Bz*l^Tzv*uf+r36LSRsEOP{Ht8auh~E> zw)IR>z)?7vZh)U7c(7;e4X3nQYW+S8$h=nSz~$4@6i7_%(%RAa=cD?KDcv}H2@E%v6nlRoaJZkF?fCOBCp$HgCC|7X-~FG z6=JPzHV!eN=LR551eBDyT4qA7+I$qc#yAZL-p|i8`0dsx$H6v1JAdFNx{#eU_&z`x zL7dJH>C+MCwx$P{2m1IbK}pHDXxq_cVAjTm+g9}E^ic2jR=*Cvee<9i-p*rq08(

jc@1n2f8-!~k5+8Vm9{YZ0XS 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 literal 0 HcmV?d00001 diff --git a/ai-daily-main/.env.example b/ai-daily-main/.env.example new file mode 100644 index 0000000..209692b --- /dev/null +++ b/ai-daily-main/.env.example @@ -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= diff --git a/ai-daily-main/.gitignore b/ai-daily-main/.gitignore new file mode 100644 index 0000000..461d8c4 --- /dev/null +++ b/ai-daily-main/.gitignore @@ -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/ diff --git a/ai-daily-main/CLAUDE.md b/ai-daily-main/CLAUDE.md new file mode 100644 index 0000000..7135863 --- /dev/null +++ b/ai-daily-main/CLAUDE.md @@ -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/) diff --git a/ai-daily-main/LICENSE b/ai-daily-main/LICENSE new file mode 100644 index 0000000..8fa98fa --- /dev/null +++ b/ai-daily-main/LICENSE @@ -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. diff --git a/ai-daily-main/README.md b/ai-daily-main/README.md new file mode 100644 index 0000000..6e2dd17 --- /dev/null +++ b/ai-daily-main/README.md @@ -0,0 +1,598 @@ + +

AI Daily

+ +

筛选值得关注的 AI 信号

+ + + +

+ License: MIT + Python 3.12+ + uv managed + RSS 400+ + systemd +

+ +[![AI Daily Banner](https://cdn.yeekal.com/yee/visuals/ai-daily-cover.webp)](https://yeekal.com/daily/) + +

AI 驱动的资讯聚合与推送系统|RSS · GitHub Trending · Hacker News 三大板块|LLM 智能评分|推送到 Discord / 飞书

+ +--- + +## 核心特性 + +- 🗞️ **三大内容板块** —— 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
400+ sources"] + GH["GitHub Trending"] + HN["Hacker News
Front Page"] + end + + subgraph Fetch["⚙️ Fetch 阶段"] + F1["RSS Fetcher
asyncio + feedparser"] + F2["GH Scraper
README deep-dive"] + F3["HN Crawler
Algolia + Jina Reader"] + end + + subgraph LLMStage["🧠 LLM 评分与摘要"] + Score["score / score_batch"] + Digest["digest / immediate_push"] + Insight["跨板块 insights"] + end + + subgraph Store["💾 存储"] + Files["news-data/
fetch-*.json
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": "", # openai compatiable + "model": "", + "baseUrl": "", + "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 + +
+LLM 调用费用大概多少? + +取决于模型选择和源数量。以 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` 进一步控制。 + +
+ +
+如何只跑某一板块进行调试? + +```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 +``` + +打印结果到控制台,不触发实际推送。 + +
+ +
+抓取频率会不会被 RSS 站点封禁? + +默认 30 分钟轮询一次,远低于大多数 RSS 服务的速率限制。`fetch.max_workers` 控制并发(默认 10),对单个站点的压力可忽略。 + +
+ +
+没配置推送渠道也能用吗? + +可以。所有推送 markdown 都会落地到 `news-data/push-*.md`,即使所有推送平台 disabled 也可手动查看。 + +
+ +
+支持哪些 LLM 提供商? + +任何 OpenAI API 兼容接口的服务:OpenAI、DeepSeek、OpenRouter、SiliconFlow、阿里云通义千问、Groq 等。修改 `config.json` 的 `llm.baseUrl` / `llm.model` / `llm.apiKeyName` 即可切换。 + +
+ +
+GitHub Trending / Hacker News 为什么不出现? + +它们**只在当天最早一次 `push_cron` 触发时跑**(即「早报」时段)。若 `push_cron` 只配了一条 cron,则每次推送都视为早报。详见「配置详解 → schedule」一节。 + +
+ +
+数据存储在哪里?多久清理? + +- 抓取数据:`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/`。 + +
+ +--- + +## License + +MIT License - see [LICENSE](LICENSE) file for details. diff --git a/ai-daily-main/config.json.example b/ai-daily-main/config.json.example new file mode 100644 index 0000000..ab0ee39 --- /dev/null +++ b/ai-daily-main/config.json.example @@ -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"] + } +} diff --git a/ai-daily-main/docs/extra-sections-design.md b/ai-daily-main/docs/extra-sections-design.md new file mode 100644 index 0000000..0431842 --- /dev/null +++ b/ai-daily-main/docs/extra-sections-design.md @@ -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()` 包入 ``。这样模块不需要知道自己的板块标识,便于后续替换或加新板块。 +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 +--- + + +# 📰 AI Daily 每日精选 | 2026-05-16 + +*开头一句定调...* + +### 1️⃣ ... + + + +## ⭐ GitHub 趋势 + +- **owner/repo** ⭐234 — 一句话价值定位 + + + +## 🟧 Hacker News 热议 + +### 标题 (120 pts · 45 comments) +- 链接: url +- 要点:... +- HN 讨论: comments_url + + + +## 💡 今日洞察 + +(行文结构由 prompts/insights.md 决定,代码不强加格式) + +``` + +某板块 markdown 为空 → 对应 sentinel 段**整段省略**(不留空标记,不留空 SECTION)。 + +### 4.2 分段提取函数(storage.py 新增) + +```python +def extract_section(push_md: str, section: str) -> str: + """从 push 文件内容中切出 之间的 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": "", "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//` 各自封装抓取+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 注释 | `` | 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 │ +└─────────────┴──────────┴──────────────┴────────────────┴──────────┘ diff --git a/ai-daily-main/docs/plan.md b/ai-daily-main/docs/plan.md new file mode 100644 index 0000000..c658081 --- /dev/null +++ b/ai-daily-main/docs/plan.md @@ -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 评分、定时推送、即时推送。 diff --git a/ai-daily-main/docs/superpowers/plans/2026-05-17-extra-sections.md b/ai-daily-main/docs/superpowers/plans/2026-05-17-extra-sections.md new file mode 100644 index 0000000..1beacaf --- /dev/null +++ b/ai-daily-main/docs/superpowers/plans/2026-05-17-extra-sections.md @@ -0,0 +1,3841 @@ +# Extra Sections (GitHub Trending / Hacker News / Insights) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add three new content sections (GitHub Trending, Hacker News, cross-section Insights) to the morning push, while keeping evening push as RSS-only and RSS as the core load-bearing module. + +**Architecture:** Each section is an autonomous module under `src/sections//` returning `(markdown, error)`. `push_job` orchestrates four modules (RSS / GitHub / HN run in parallel via `asyncio.gather`; Insights runs after, consuming the three section outputs), wraps each output with HTML-comment sentinels (``), and writes the assembled push file. Module failure (other than RSS) degrades to omitting that section. State is local files only — `news-data/trending-history.json` deduplicates GitHub repos across days. + +**Tech Stack:** Python 3.12 / asyncio / aiohttp / BeautifulSoup4 (new) / croniter / markdownify / DeepSeek (OpenAI-compatible) LLM API / Algolia HN public API / GitHub REST API v3. + +**Spec reference:** `docs/extra-sections-design.md` + +--- + +## Phase 0: Prerequisites + +### Task 0: Add BeautifulSoup4 dependency + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Add bs4 to dependencies** + +Open `pyproject.toml` and add `"beautifulsoup4>=4.12.0",` to the `dependencies` list (after `"aiohttp>=3.9.0",`). + +- [ ] **Step 2: Sync deps** + +Run: `uv sync` +Expected: package installed, no errors. + +- [ ] **Step 3: Verify import works** + +Run: `uv run python -c "from bs4 import BeautifulSoup; print('ok')"` +Expected: `ok` + +- [ ] **Step 4: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "build: add beautifulsoup4 for HTML parsing" +``` + +--- + +## Phase 1: Storage Layer Foundation + +The storage layer adds sentinel-aware section extraction, a `TrendingHistory` class for GH dedup, and a `profile` field on push frontmatter. These changes are independent of any new section module and must land first. + +### Task 1: Add `extract_section` to storage.py + +Parses `` boundaries. Backward-compatible: old push files without sentinel return whole body when `section="rss"`, empty otherwise. + +**Files:** +- Modify: `src/storage.py` (append after `_extract_push_titles`) +- Test: `tests/pytest/test_storage_sections.py` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_storage_sections.py`: + +```python +"""测试新增的 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" + "\n" + "RSS body\n" + "\n" + "\n" + "\n" + "GH body\n" + "\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 = "\ncontent only\n" + assert extract_section(broken, "rss") == "" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_storage_sections.py -v` +Expected: ImportError or `AttributeError: module 'storage' has no attribute 'extract_section'` + +- [ ] **Step 3: Implement `extract_section`** + +Append to `src/storage.py`: + +```python +_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"(.*?)" + _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 文件内容中切出 之间的 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 = "\n" + "### 1️⃣ RSS Title One\n" + "### 2️⃣ RSS Title Two\n" + "\n\n" + "\n" + "### GH Repo Title\n" + "\n", + encoding="utf-8", + ) + rss_titles = load_recent_section_titles("rss", 3, str(tmp_path)) + assert "RSS Title One" in rss_titles + assert "RSS Title Two" in rss_titles + assert "GH Repo Title" not in rss_titles + + gh_titles = load_recent_section_titles("github", 3, str(tmp_path)) + assert "GH Repo Title" in gh_titles + assert "RSS Title One" not in gh_titles +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_storage_sections.py::TestLoadRecentSectionTitles -v` +Expected: `AttributeError` on the import. + +- [ ] **Step 3: Implement `load_recent_section_titles`** + +Append to `src/storage.py`: + +```python +def load_recent_section_titles( + section: str, days: int, data_dir: str = "news-data" +) -> str: + """加载最近 days 天 push 文件中 section 段的标题清单(供 LLM 查重防风格趋同)。 + + 返回紧凑纯文本,每行一条事件;遇到老文件(无 sentinel)按 extract_section 的兜底语义处理。 + """ + data_path = Path(data_dir) + if not data_path.exists(): + return "" + + tz = get_timezone() + today = datetime.now(tz).date() + + items: List[tuple] = [] + loaded_files: List[str] = [] + for i in range(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 + items.extend(_extract_push_titles(section_md)) + loaded_files.append(push_file.name) + + if loaded_files: + print( + f" 📂 已加载 {len(loaded_files)} 个 push 文件 (section={section}): " + f"{', '.join(loaded_files)}" + ) + + return "\n".join(f"- [{t}] {title}" if t else f"- {title}" for t, title in items) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_storage_sections.py::TestLoadRecentSectionTitles -v` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/storage.py tests/pytest/test_storage_sections.py +git commit -m "feat(storage): add load_recent_section_titles for section-scoped history" +``` + +--- + +### Task 3: Add `TrendingHistory` to storage.py + +Owns `news-data/trending-history.json` read / write / touch / cleanup. Single source of truth for GH dedup state. + +**Files:** +- Modify: `src/storage.py` +- Test: `tests/pytest/test_storage_sections.py` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/pytest/test_storage_sections.py`: + +```python +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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_storage_sections.py::TestTrendingHistory -v` +Expected: import error. + +- [ ] **Step 3: Implement `TrendingHistory` + loader** + +Append to `src/storage.py`: + +```python +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, {}) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_storage_sections.py::TestTrendingHistory -v` +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/storage.py tests/pytest/test_storage_sections.py +git commit -m "feat(storage): add TrendingHistory for GH repo dedup state" +``` + +--- + +### Task 4: Add `profile` field to `save_push_file` + +Push frontmatter gets a `profile: "morning"|"default"` tag so downstream tools can filter. + +**Files:** +- Modify: `src/storage.py:363-378` (the existing `save_push_file`) +- Test: `tests/pytest/test_storage_sections.py` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/pytest/test_storage_sections.py`: + +```python +from storage import save_push_file + + +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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_storage_sections.py::TestSavePushFileProfile -v` +Expected: FAIL (current `save_push_file` doesn't accept `profile`). + +- [ ] **Step 3: Update `save_push_file` signature** + +Open `src/storage.py`. Replace the `save_push_file` function (currently around line 363-378) with: + +```python +def save_push_file( + filepath: str, + content: str, + source_count: int, + total_entries: int, + profile: str = "default", +): + """保存推送文件(Markdown格式) + + Args: + profile: "morning" | "default" ← 早报或常规;写入 frontmatter,便于按 profile 分析 + """ + path = Path(filepath) + path.parent.mkdir(parents=True, exist_ok=True) + + push_time = datetime.now(get_timezone()) + frontmatter = ( + "---\n" + f'pushDate: "{push_time.isoformat()}"\n' + f'profile: "{profile}"\n' + f"sourceCount: {source_count}\n" + f"totalEntries: {total_entries}\n" + "---\n\n" + ) + + with open(path, "w", encoding="utf-8") as f: + f.write(frontmatter + content) +``` + +- [ ] **Step 4: Verify existing storage tests still pass** + +Run: `uv run pytest tests/pytest/test_storage.py tests/pytest/test_storage_sections.py -v` +Expected: all pass (existing callers pass no `profile`, default "default" kicks in). + +- [ ] **Step 5: Commit** + +```bash +git add src/storage.py tests/pytest/test_storage_sections.py +git commit -m "feat(storage): add profile field to push frontmatter" +``` + +--- + +### Task 5: Extend `cleanup_old_files` to prune expired entries in `trending-history.json` + +The fetch job's daily cleanup should not delete the trending-history file (it's cumulative state), but should remove entries older than `keep_days`. + +**Files:** +- Modify: `src/storage.py:419-451` (the existing `cleanup_old_files`) +- Test: `tests/pytest/test_storage_sections.py` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/pytest/test_storage_sections.py`: + +```python +from storage import cleanup_old_files + + +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() + # 过期条目应被剪枝 + import json as _j + 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"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_storage_sections.py::TestCleanupOldFilesTrendingHistory -v` +Expected: FAIL (current cleanup doesn't touch trending-history). + +- [ ] **Step 3: Update `cleanup_old_files`** + +Open `src/storage.py`. After the existing `for pattern in [...]` loop (around line 428-447), but **before** the final `if deleted_count > 0:` print, insert: + +```python + # 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}") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_storage_sections.py::TestCleanupOldFilesTrendingHistory tests/pytest/test_storage.py -v` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/storage.py tests/pytest/test_storage_sections.py +git commit -m "feat(storage): prune expired entries in trending-history.json on cleanup" +``` + +--- + +## Phase 2: RSS Module Migration + +Migrate the existing RSS push flow into the new `sections` package without changing behavior. + +### Task 6: Create `src/sections/rss/section.py` + +`run_rss_section(config, now)` wraps existing `collect_entries_for_push` + `compose_digest` and returns `(markdown, error)`. + +**Files:** +- Create: `src/sections/__init__.py` (empty) +- Create: `src/sections/rss/__init__.py` (empty) +- Create: `src/sections/rss/section.py` +- Test: `tests/pytest/test_sections_rss.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_sections_rss.py`: + +```python +"""测试 RSS 模块返回 (markdown, error) 契约""" + +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): + with patch( + "src.sections.rss.section.collect_entries_for_push", + return_value=([{"link": "x", "title": "t", "score": 80}], []), + ), patch( + "src.sections.rss.section.compose_digest", + new=AsyncMock(return_value="# digest body"), + ), patch( + "src.sections.rss.section.load_recent_push_titles", return_value="" + ), patch( + "src.sections.rss.section.get_last_push_file", return_value=None + ): + md, err = await run_rss_section(sample_config, now=None) + + assert md == "# digest body" + assert err is None + + +@pytest.mark.asyncio +async def test_returns_empty_when_no_entries(sample_config): + with patch( + "src.sections.rss.section.collect_entries_for_push", return_value=([], []) + ), patch( + "src.sections.rss.section.get_last_push_file", return_value=None + ): + md, err = await run_rss_section(sample_config, now=None) + + assert md == "" + assert err is None + + +@pytest.mark.asyncio +async def test_returns_error_on_compose_failure(sample_config): + with patch( + "src.sections.rss.section.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_titles", return_value="" + ), patch( + "src.sections.rss.section.get_last_push_file", return_value=None + ): + md, err = await run_rss_section(sample_config, now=None) + + assert md == "" + assert "LLM down" in err +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_sections_rss.py -v` +Expected: ModuleNotFoundError. + +- [ ] **Step 3: Implement module** + +Create `src/sections/__init__.py`: + +```python +"""板块模块包。每个子模块导出 run__section(config, now) -> (markdown, error)""" +``` + +Create `src/sections/rss/__init__.py`: + +```python +from src.sections.rss.section import run_rss_section + +__all__ = ["run_rss_section"] +``` + +Create `src/sections/rss/section.py`: + +```python +"""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 +from src.storage import ( + extract_push_time, + get_last_push_file, + load_recent_push_titles, +) + + +async def run_rss_section( + config: Dict, now: Optional[datetime] = None +) -> Tuple[str, Optional[str]]: + """生成 RSS digest markdown 段(不含 sentinel)。 + + 返回: + (markdown, error) + - 无新内容时返回 ("", None) + - compose_digest 失败时返回 ("", error_message) + """ + # 延迟 import 避免循环引用:collect_entries_for_push 仍在 main.py + 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 + + push_context_days = config["filter"].get("push_context_days", 5) + recent = load_recent_push_titles(push_context_days) + + try: + md = await compose_digest(to_push, context, config["llm"], recent_push_context=recent) + return md, None + except Exception as e: + msg = f"compose_digest 失败: {e}" + print(f"⚠️ RSS: {msg}") + return "", msg +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_sections_rss.py -v` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/sections/ tests/pytest/test_sections_rss.py +git commit -m "feat(sections): extract RSS digest flow into run_rss_section" +``` + +--- + +## Phase 3: GitHub Module + +### Task 7: Save a real GitHub trending HTML fixture + +Save a real snapshot for deterministic parser tests. The structure may drift; this is the canonical "what we built against". + +**Files:** +- Create: `tests/pytest/fixtures/github_trending.html` + +- [ ] **Step 1: Download a real page** + +```bash +mkdir -p tests/pytest/fixtures +curl -fsSL -A "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \ + "https://github.com/trending" \ + -o tests/pytest/fixtures/github_trending.html +``` + +- [ ] **Step 2: Verify the fixture has expected markers** + +Run: `grep -c 'class="Box-row"' tests/pytest/fixtures/github_trending.html` +Expected: a number ≥ 10 (typically 25). If 0, GitHub markup changed — adjust selectors in Task 8 accordingly. + +- [ ] **Step 3: Commit fixture** + +```bash +git add tests/pytest/fixtures/github_trending.html +git commit -m "test(github): snapshot github.com/trending fixture" +``` + +--- + +### Task 8: Implement `trending_scraper.py` + +Async `fetch_trending_page` + `parse_trending_html` returning `[{url, full_name, description, language, stars_today, stars_total}]`. + +**Files:** +- Create: `src/sections/github/__init__.py` (empty) +- Create: `src/sections/github/trending_scraper.py` +- Test: `tests/pytest/test_sections_github_scraper.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_sections_github_scraper.py`: + +```python +"""测试 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("no repos") == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_sections_github_scraper.py -v` +Expected: ModuleNotFoundError. + +- [ ] **Step 3: Implement scraper** + +Create `src/sections/github/__init__.py` (empty): + +```python +``` + +Create `src/sections/github/trending_scraper.py`: + +```python +"""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: + raise RuntimeError( + f"GitHub trending 返回 {resp.status}: {await resp.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 (class 多变,按文本) + 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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_sections_github_scraper.py -v` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/sections/github/ tests/pytest/test_sections_github_scraper.py +git commit -m "feat(github): add trending page scraper with HTML fixture test" +``` + +--- + +### Task 9: Implement `repo_enricher.py` + +Two REST API calls per repo (`/repos/{o}/{r}` + `/repos/{o}/{r}/readme`). Token via env var optional. + +**Files:** +- Create: `src/sections/github/repo_enricher.py` +- Test: `tests/pytest/test_sections_github_enricher.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_sections_github_enricher.py`: + +```python +"""测试 GitHub REST API enrich 字段映射、archived 过滤、token 鉴权头""" + +import base64 +import os +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"] + # trending 已有字段保留 + assert enriched["stars_today"] == 100 + + +@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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_sections_github_enricher.py -v` +Expected: ModuleNotFoundError. + +- [ ] **Step 3: Implement enricher** + +Create `src/sections/github/repo_enricher.py`: + +```python +"""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}" + 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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_sections_github_enricher.py -v` +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/sections/github/repo_enricher.py tests/pytest/test_sections_github_enricher.py +git commit -m "feat(github): add repo_enricher using REST API metadata + readme" +``` + +--- + +### Task 10: Implement `src/sections/github/section.py` + +Glue: scrape → cleanup history → filter → touch new → save history → enrich → LLM summarize. + +**Files:** +- Create: `src/sections/github/section.py` +- Test: `tests/pytest/test_sections_github_section.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_sections_github_section.py`: + +```python +"""测试 GitHub 板块编排:抓取 → history 过滤 → enrich → LLM 总结""" + +import sys +from datetime import date +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 + + +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="") + ), 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="") + ), 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.sections.github.section.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 + # history 应已写入今日 scrape 出的 URL + 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="") + ), 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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_sections_github_section.py -v` +Expected: ModuleNotFoundError. + +- [ ] **Step 3: Implement section orchestrator** + +Create `src/sections/github/section.py`: + +```python +"""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 避免循环 + 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. 抓取 + try: + html = await fetch_trending_page(timeout=timeout) + except Exception as e: + return "", f"GH 抓取失败: {e}" + + all_repos = parse_trending_html(html) + if not all_repos: + return "", None + + # 2. history 加载 + 清理 + history = load_trending_history(history_path) + history.cleanup(today=today, keep_days=keep_days) + + # 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() + + if not candidates: + return "", None + if len(candidates) > max_deep_dive: + candidates = candidates[:max_deep_dive] + + # 5. 并发 enrich + 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}") + if not enriched: + return "", None + + # 6. LLM 总结 + md, err = await summarize_github_trending(enriched, config["llm"]) + if err: + return "", f"summarize_github_trending: {err}" + return md or "", None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_sections_github_section.py -v` +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/sections/github/section.py tests/pytest/test_sections_github_section.py +git commit -m "feat(github): orchestrate trending → history → enrich → LLM" +``` + +--- + +### Task 11: Add `summarize_github_trending` to `src/llm.py` + `prompts/section_github.md` + +**Files:** +- Modify: `src/llm.py` (append new async function) +- Create: `prompts/section_github.md` +- Test: `tests/pytest/test_sections_github_section.py` (already mocks the function; add direct test) + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_llm_extra_sections.py`: + +```python +"""测试新增 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 + + +@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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_llm_extra_sections.py::test_summarize_github_trending_happy_path -v` +Expected: ImportError. + +- [ ] **Step 3: Implement function + prompt** + +Append to `src/llm.py`: + +```python +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 +``` + +Create `prompts/section_github.md`: + +```markdown +你是开源情报分析师。从下列 GitHub Trending 候选项目中,挑出 **1-{max_items} 个**最值得关注的 AI 相关项目并行文。 + +## 关注领域(正面列表) +- **AI Agent**:智能体架构、工具链、多智能体、自主规划、Agent 框架 +- **AI 模型**:训练、推理、微调、量化部署、模型服务、语音/多模态/视觉模型 +- **AI 基础设施**:GPU 调度、芯片硬件、数据中心、推理优化、分布式训练、向量数据库、RAG 框架 +- **大厂/前沿动态**:Apple、Google、Meta、OpenAI、Anthropic、Microsoft、xAI 等公司的官方动作与战略 +- **AI 集成的开发者工具**:API 网关、自动化脚本、低代码平台等明确与 AI 协同的工具 +- **创新性开源产品**:日增长显著且有清晰用户价值 + +## 排除(负面列表) +- 嵌入式开发(Arduino、ESP32、树莓派、单片机) +- 底层系统编程(内存分配器、编译器、链接器,与 AI 工作负载无关时) +- 通用开发工具(命名规范、代码风格、纯前端模板、UI 组件库、管理后台模板、静态网站主题) +- 学习资源(纯教程仓库、面试题合集、Roadmap,除非含实用代码的深度技术指南) +- 配置文件集合(Dotfiles、配置模板) +- 与 AI/科技无关的内容(电子书、资源搬运、刷榜项目) +- 纯娱乐/高风险误用(deepfake 等无明确基础设施价值) + +## 输入数据 +JSON 数组,字段:url / full_name / description / language / stars_today / stars_total / topics / license / pushed_at / readme_excerpt + +```json +{repos_json} +``` + +## 选项规则 +- 优先信号:`stars_today` 高 + `topics` 含 AI 标签(agent/llm/rag/inference/training 等) + readme 描述明确 +- 跳过:`archived=true`(若漏过)、纯 awesome-list、个人 dotfiles +- 一句话价值定位需点明"解决什么问题",避免营销语("震撼""炸裂""革命性"等禁用) + +## 输出格式(严格 Markdown,不要任何引导语) + +```markdown +## ⭐ GitHub 趋势 + +- **owner/repo** ⭐{{stars_today}} — 一句话价值定位 [link]({{url}}) +- ... +``` + +若候选中没有任何符合关注领域的项目,直接输出 `## ⭐ GitHub 趋势\n\n- 今日无显著 AI 相关趋势`,不要硬编。 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_llm_extra_sections.py::test_summarize_github_trending_happy_path tests/pytest/test_llm_extra_sections.py::test_summarize_github_trending_llm_failure_returns_error -v` +Expected: 2 pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/llm.py prompts/section_github.md tests/pytest/test_llm_extra_sections.py +git commit -m "feat(llm): add summarize_github_trending with prompt" +``` + +--- + +## Phase 4: Hacker News Module + +### Task 12: Save HN frontpage HTML fixture + +**Files:** +- Create: `tests/pytest/fixtures/hn_frontpage.html` + +- [ ] **Step 1: Download** + +```bash +curl -fsSL -A "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36" \ + "https://news.ycombinator.com/news" \ + -o tests/pytest/fixtures/hn_frontpage.html +``` + +- [ ] **Step 2: Verify** + +Run: `grep -c 'class="athing"' tests/pytest/fixtures/hn_frontpage.html` +Expected: ≥ 25 (usually 30). + +- [ ] **Step 3: Commit** + +```bash +git add tests/pytest/fixtures/hn_frontpage.html +git commit -m "test(hn): snapshot HN frontpage fixture" +``` + +--- + +### Task 13: Implement `frontpage_scraper.py` + +**Files:** +- Create: `src/sections/hackernews/__init__.py` (empty) +- Create: `src/sections/hackernews/frontpage_scraper.py` +- Test: `tests/pytest/test_sections_hackernews_scraper.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_sections_hackernews_scraper.py`: + +```python +"""测试 HN 首页 HTML 解析""" + +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] + 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(): + # 构造一个最小内部链接故事 (Ask HN / Show HN) + html = """ + + + + + + + +
+ + Ask HN: what's new? + +
+ + 50 points + by alice + 2 hours ago + | 5 comments + +
+ """ + 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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_sections_hackernews_scraper.py -v` +Expected: ModuleNotFoundError. + +- [ ] **Step 3: Implement** + +Create `src/sections/hackernews/__init__.py` (empty). + +Create `src/sections/hackernews/frontpage_scraper.py`: + +```python +"""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}]""" + 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", "") + # 内部链接(Ask HN / Show HN) + 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 "" + + # 同 id 的下一个 tr 是 subtext + 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)) + # 最后一个 a[href^="item?id="] 是评论链接 + 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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_sections_hackernews_scraper.py -v` +Expected: 2 pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/sections/hackernews/__init__.py src/sections/hackernews/frontpage_scraper.py tests/pytest/test_sections_hackernews_scraper.py +git commit -m "feat(hn): add frontpage scraper with fixture test" +``` + +--- + +### Task 14: Implement `item_enricher.py` + +Algolia for comments + post text; `html_to_markdown` for external link content. + +**Files:** +- Create: `src/sections/hackernews/item_enricher.py` +- Test: `tests/pytest/test_sections_hackernews_enricher.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_sections_hackernews_enricher.py`: + +```python +"""测试 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 + + +@pytest.mark.asyncio +async def test_enrich_external_link_story(): + 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": "

comment one

"}, + {"text": "

comment two

"}, + {"text": "

comment three

"}, + {"text": "

comment four

"}, + ], + } + + async def fake_algolia(session, item_id, **kw): + return algolia_payload + + async def fake_link(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_url_html", + new=AsyncMock(side_effect=fake_link), + ): + enriched = await enrich_story( + session=MagicMock(), + story=story, + top_comments=3, + comment_max_chars=500, + link_content_max_chars=3000, + algolia_base="https://hn.algolia.com/api/v1", + timeout=10, + ) + + assert len(enriched["top_comments"]) == 3 + assert "comment one" in enriched["top_comments"][0] + 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": "

post body text

", + "children": [{"text": "

c1

"}], + } + link_calls = [] + + async def fake_algolia(session, item_id, **kw): + return algolia_payload + + async def fake_link(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_url_html", + new=AsyncMock(side_effect=fake_link), + ): + enriched = await enrich_story( + session=MagicMock(), + story=story, + top_comments=3, + comment_max_chars=500, + link_content_max_chars=3000, + algolia_base="https://hn.algolia.com/api/v1", + timeout=10, + ) + + assert link_calls == [] + assert "post body text" in enriched["link_content"] + + +@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 = "

" + ("y" * 2000) + "

" + long_link = "" + ("z" * 5000) + "" + + async def fake_algolia(session, item_id, **kw): + return {"text": None, "children": [{"text": long_comment}]} + + async def fake_link(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_url_html", + new=AsyncMock(side_effect=fake_link), + ): + enriched = await enrich_story( + session=MagicMock(), + story=story, + top_comments=3, + comment_max_chars=100, + link_content_max_chars=200, + algolia_base="https://hn.algolia.com/api/v1", + timeout=10, + ) + + assert len(enriched["top_comments"][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_link(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_url_html", + new=AsyncMock(side_effect=fake_link), + ): + enriched = await enrich_story( + session=MagicMock(), + story=story, + top_comments=3, + comment_max_chars=500, + link_content_max_chars=3000, + algolia_base="https://hn.algolia.com/api/v1", + timeout=10, + ) + + # 算法失败 → top_comments 留空,link_content 仍获取 + assert enriched["top_comments"] == [] + assert "ok" in enriched["link_content"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_sections_hackernews_enricher.py -v` +Expected: ModuleNotFoundError. + +- [ ] **Step 3: Implement** + +Create `src/sections/hackernews/item_enricher.py`: + +```python +"""HN 单 story enrich:Algolia 评论 + 外链正文。 + +Algolia API: GET /api/v1/items/{id} +- root.text 是 Show HN / Ask HN 的 post 正文 +- root.children[] 是顶层评论(按 HN ranking 排序) +""" + +import asyncio +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" +) + + +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 enrich_story( + session: aiohttp.ClientSession, + story: Dict, + top_comments: int, + comment_max_chars: int, + link_content_max_chars: int, + algolia_base: str, + timeout: int, +) -> Dict: + """对单 story enrich。任一子任务失败 → 对应字段留空,不抛。""" + item_id = story["id"] + is_internal = _is_internal_hn_url(story["url"]) + + # 并发:Algolia + 外链(仅外链类) + tasks = [_fetch_algolia_item(session, item_id, algolia_base, timeout)] + if not is_internal: + tasks.append(_fetch_url_html(session, story["url"], timeout)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + algolia_result = results[0] + external_html_result = results[1] if not is_internal else None + + # 评论解析 + comments_list: List[str] = [] + post_text = "" + if not isinstance(algolia_result, Exception) and algolia_result: + post_text = algolia_result.get("text") or "" + children = algolia_result.get("children") or [] + for child in children[:top_comments]: + raw = (child or {}).get("text") or "" + if not raw: + continue + md = html_to_markdown(raw) + comments_list.append(md[:comment_max_chars]) + + # link_content + link_content = "" + if is_internal: + # Show HN / Ask HN:post 自身正文 + if post_text: + link_content = html_to_markdown(post_text)[:link_content_max_chars] + else: + if not isinstance(external_html_result, Exception) and external_html_result: + link_content = html_to_markdown( + external_html_result, base_url=story["url"] + )[:link_content_max_chars] + + return { + **story, + "link_content": link_content, + "top_comments": comments_list, + } + + +async def enrich_stories( + stories: List[Dict], + top_comments: int, + comment_max_chars: int, + link_content_max_chars: int, + algolia_base: str = "https://hn.algolia.com/api/v1", + timeout: int = 10, +) -> Tuple[List[Dict], List[str]]: + """并发 enrich 多个 stories。""" + errors: List[str] = [] + async with aiohttp.ClientSession(headers={"User-Agent": USER_AGENT}) as session: + results = await asyncio.gather( + *[ + enrich_story( + session, + s, + top_comments, + comment_max_chars, + link_content_max_chars, + algolia_base, + timeout, + ) + 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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_sections_hackernews_enricher.py -v` +Expected: 4 pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/sections/hackernews/item_enricher.py tests/pytest/test_sections_hackernews_enricher.py +git commit -m "feat(hn): add item enricher using Algolia + html_to_markdown" +``` + +--- + +### Task 15: Implement `src/sections/hackernews/section.py` + +Glue: scrape → light LLM select → enrich → LLM summarize. + +**Files:** +- Create: `src/sections/hackernews/section.py` +- Test: `tests/pytest/test_sections_hackernews_section.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_sections_hackernews_section.py`: + +```python +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.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="") + ), patch( + "src.sections.hackernews.section.parse_frontpage_html", + return_value=[{"id": "1", "title": "x"}], + ), patch( + "src.sections.hackernews.section.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="") + ), patch( + "src.sections.hackernews.section.parse_frontpage_html", return_value=front + ), patch( + "src.sections.hackernews.section.select_ai_related_hn", + new=AsyncMock(return_value=(["1"], None)), + ), patch( + "src.sections.hackernews.section.enrich_stories", + new=AsyncMock(return_value=(enriched, [])), + ), patch( + "src.sections.hackernews.section.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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_sections_hackernews_section.py -v` +Expected: ModuleNotFoundError. + +- [ ] **Step 3: Implement section** + +Create `src/sections/hackernews/section.py`: + +```python +"""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 + + 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", 20) + comment_max_chars = cfg.get("comment_max_chars", 500) + link_content_max_chars = cfg.get("link_content_max_chars", 3000) + algolia_base = cfg.get("algolia_base", "https://hn.algolia.com/api/v1") + + # 1. 抓首页 + try: + html = await fetch_frontpage(timeout=timeout) + except Exception as e: + return "", f"HN 首页抓取失败: {e}" + + front = parse_frontpage_html(html) + if not front: + return "", None + + # 2. 轻 LLM 初筛 + 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: + return "", None + + selected = [s for s in front if s["id"] in set(selected_ids)] + if not selected: + return "", None + + # 3. enrich + enriched, enrich_errors = await enrich_stories( + selected, + top_comments=top_comments, + comment_max_chars=comment_max_chars, + link_content_max_chars=link_content_max_chars, + algolia_base=algolia_base, + timeout=timeout, + ) + for e in enrich_errors: + print(f"⚠️ HN enrich: {e}") + if not enriched: + return "", None + + # 4. LLM 总结 + md, err = await summarize_hackernews(enriched, config["llm"]) + if err: + return "", f"summarize_hackernews: {err}" + return md or "", None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_sections_hackernews_section.py -v` +Expected: 4 pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/sections/hackernews/section.py tests/pytest/test_sections_hackernews_section.py +git commit -m "feat(hn): orchestrate frontpage → select → enrich → LLM" +``` + +--- + +### Task 16: Add `select_ai_related_hn` + `summarize_hackernews` to `src/llm.py` + 2 prompt files + +**Files:** +- Modify: `src/llm.py` +- Create: `prompts/section_hackernews_select.md` +- Create: `prompts/section_hackernews.md` +- Test: `tests/pytest/test_llm_extra_sections.py` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/pytest/test_llm_extra_sections.py`: + +```python +from llm import select_ai_related_hn, summarize_hackernews + + +@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 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_llm_extra_sections.py -v -k "select_ai_related_hn or summarize_hackernews"` +Expected: ImportError. + +- [ ] **Step 3: Implement functions + prompts** + +Append to `src/llm.py`: + +```python +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 行文。不传历史上下文。""" + 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 +``` + +Create `prompts/section_hackernews_select.md`: + +```markdown +你是 HN 早间选题人。从下列 30 条 HN 首页元数据中,挑出 **{k}** 个最符合关注领域的 story id。 + +## 关注领域(正面列表) +- AI Agent(智能体架构、工具链、多智能体、自主规划) +- AI 模型(训练、推理、微调、应用、语音/多模态) +- AI 基础设施(芯片、硬件、数据中心、推理优化、向量数据库、RAG 框架) +- 大厂/前沿动态(Apple、Google、Meta、OpenAI、Anthropic、Microsoft、xAI) +- AI 集成的开发者工具(API 网关、自动化、低代码与 AI 协同) + +## 排除(负面列表) +- 嵌入式开发(Arduino、ESP32、树莓派、单片机) +- 底层系统编程(内存分配器、编译器、链接器,与 AI 无关时) +- 通用开发工具(命名规范、代码风格) +- 与 AI/科技无关的内容 + +## 决策原则 +- title + site 不足以确定 AI 相关时,**宁可漏选不可错选**(错选会让最终板块写出与 AI Daily 调性无关的内容) +- 若全部 30 条都不符合,返回空数组 `[]` + +## 输入 +```json +{candidates_json} +``` + +## 输出 +**只输出 JSON 数组**,如:`["12345", "67890"]` 或 `[]` +严禁输出任何解释性文字、markdown 包装、或自然语言句子。 +``` + +Create `prompts/section_hackernews.md`: + +```markdown +你是 HN 早间编辑。对输入的 enriched stories **全部行文**(不再二次挑选)。 + +## 输入(JSON 数组,字段含 link_content 与 top_comments) +```json +{stories_json} +``` + +## 内容要求(每条 story) +1. 提炼原文 `link_content` 的核心(背景 / 要点 / 结论) +2. 汇总 `top_comments` 中有价值的观点(支持 / 反对 / 补充),不是简单复述 +3. 若评论中出现明显反驳原文的观点,必须保留并标注 + +## 输出格式(严格 Markdown,不要任何引导语) + +```markdown +## 🟧 Hacker News 热议 + +### {{title}} ({{points}} pts · {{comments}} comments) + +**📌 内容总结** + +- 要点 1 +- 要点 2 +- 要点 3(可选) + +**💬 HN 讨论** + +- 观点 1(含反对/补充) +- 观点 2(可选) + +🔗 [原文]({{url}}) | [HN 讨论页]({{comments_url}}) +``` + +## 风格约束 +- 客观、犀利、克制 +- 避免与 RSS digest 句式雷同;不做行业宏大叙事 +- 禁用词汇:震撼、炸裂、革命性、现象级 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_llm_extra_sections.py -v` +Expected: 5 tests pass (including the earlier GH tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/llm.py prompts/section_hackernews_select.md prompts/section_hackernews.md tests/pytest/test_llm_extra_sections.py +git commit -m "feat(llm): add select_ai_related_hn and summarize_hackernews with prompts" +``` + +--- + +## Phase 5: Insights Module + +### Task 17: Implement `insights/section.py` + `generate_trend_insights` + prompt + +**Files:** +- Create: `src/sections/insights/__init__.py` (empty) +- Create: `src/sections/insights/section.py` +- Modify: `src/llm.py` +- Create: `prompts/insights.md` +- Test: `tests/pytest/test_sections_insights.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_sections_insights.py`: + +```python +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.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, err = await run_insights_section("rss", "gh", "hn", cfg, now=None) + assert md == "" + assert err is None + + +@pytest.mark.asyncio +async def test_marks_empty_sections_for_llm(): + cfg = _cfg() + captured = {} + + async def fake_gen(sections, recent_insights, config): + captured["sections"] = sections + return "insights md", None + + with patch( + "src.sections.insights.section.load_recent_section_titles", return_value="" + ), patch( + "src.sections.insights.section.generate_trend_insights", + new=AsyncMock(side_effect=fake_gen), + ): + md, err = await run_insights_section("", "gh md", "", cfg, now=None) + + assert md == "insights md" + assert captured["sections"]["rss"] == "(本次无内容)" + assert captured["sections"]["github"] == "gh md" + assert captured["sections"]["hackernews"] == "(本次无内容)" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_sections_insights.py -v` +Expected: ModuleNotFoundError. + +- [ ] **Step 3: Implement** + +Create `src/sections/insights/__init__.py` (empty). + +Create `src/sections/insights/section.py`: + +```python +"""Insights 板块:基于 RSS/GH/HN 三段成品 + 近 N 天 insights 历史做跨板块小结""" + +from datetime import datetime +from typing import Dict, Optional, Tuple + +from src.storage import load_recent_section_titles + +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[str]]: + cfg = config.get("sections", {}).get("insights", {}) + if not cfg.get("enabled", False): + return "", None + + from src.llm import generate_trend_insights + + days = config["filter"].get("push_context_days", 5) + recent = load_recent_section_titles("insights", days) + + 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, recent, config["llm"]) + if err: + return "", f"generate_trend_insights: {err}" + return md or "", None +``` + +Append to `src/llm.py`: + +```python +async def generate_trend_insights( + sections: Dict[str, str], recent_insights: str, config: Dict +) -> Tuple[str, Optional[str]]: + """输入三段成品 + 近期 insights 标题清单,返回洞察段 markdown。""" + 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", ""), + recent_insights=recent_insights or "", + ) + try: + return await call_llm(prompt, config), None + except Exception as e: + msg = f"generate_trend_insights 失败: {e}" + print(f"⚠️ {msg}") + return "", msg +``` + +Create `prompts/insights.md`: + +```markdown +你是 AI 行业观察员。基于今日三段已生成的成品做一段**跨板块趋势小结**。 + +## 今日素材(三段成品) + +### RSS 板块 +{rss} + +### GitHub 板块 +{github} + +### Hacker News 板块 +{hackernews} + +## 近 N 天 insights 段标题(仅供风格参考与防趋同,严禁措辞模仿) + +{recent_insights} + + +## 任务 +基于今日三段成品,做跨板块小结。可参考的切入角度(不必全部覆盖,按今日素材最突出的张力来组织): +- 跨板块的"交叉信号"(同一话题在 RSS / GH / HN 中同时出现) +- 与近几天对比"新升温"或"退潮"的关键词 +- "反直觉发现"(违反常识、值得停下来想一下的一条) +- 行业结构信号(资本 / 监管 / 算力 / 应用层等) + +## 输出要求 +- 直接输出 markdown 段,不带任何引导语 +- 起始用 `## 💡 今日洞察` +- 总长度 200-400 字之间 +- 不要简单复述其他板块的具体新闻;洞察的价值在"连接"而非"清单" +- 避免与 RSS digest 句式雷同;禁用宏大叙事词汇("深水区""临界点""下半场""博弈"等) +- 每条洞察须能溯源到今日素材中的具体信号,而不是凭空总结 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_sections_insights.py -v` +Expected: 2 pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/sections/insights/ prompts/insights.md src/llm.py tests/pytest/test_sections_insights.py +git commit -m "feat(insights): add cross-section trend summary module" +``` + +--- + +## Phase 6: push_job Orchestration + +### Task 18: Add `is_morning_push` to `src/main.py` + +**Files:** +- Modify: `src/main.py` +- Test: `tests/pytest/test_morning_detection.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_morning_detection.py`: + +```python +"""测试早报判定: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(cron, tol=5): + return { + "schedule": { + "morning_cron": cron, + "morning_match_tolerance_minutes": tol, + "timezone_hours": 8, + } + } + + +def test_returns_false_when_no_morning_cron_configured(): + assert is_morning_push(datetime(2026, 5, 17, 8, 0, tzinfo=TZ), {"schedule": {}}) is False + + +def test_match_exact_time(): + cfg = _cfg("0 8 * * *") + now = datetime(2026, 5, 17, 8, 0, tzinfo=TZ) + assert is_morning_push(now, cfg) is True + + +def test_match_within_tolerance(): + cfg = _cfg("0 8 * * *", tol=5) + assert is_morning_push(datetime(2026, 5, 17, 8, 4, tzinfo=TZ), cfg) is True + assert is_morning_push(datetime(2026, 5, 17, 7, 56, tzinfo=TZ), cfg) is True + + +def test_outside_tolerance(): + cfg = _cfg("0 8 * * *", tol=5) + assert is_morning_push(datetime(2026, 5, 17, 8, 6, tzinfo=TZ), cfg) is False + assert is_morning_push(datetime(2026, 5, 17, 9, 0, tzinfo=TZ), cfg) is False + + +def test_evening_time_not_morning(): + cfg = _cfg("0 8 * * *", tol=5) + assert is_morning_push(datetime(2026, 5, 17, 17, 0, tzinfo=TZ), cfg) is False +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_morning_detection.py -v` +Expected: ImportError. + +- [ ] **Step 3: Implement** + +Open `src/main.py`. After the existing `calculate_push_times` function (around line 96), insert: + +```python +def is_morning_push(now: datetime, config: Dict) -> bool: + """判定当前时刻是否为早报触发点。 + + 用 cron + 容差判定,而非"今天的第一次推送": + - 早报失败时,晚报不会错误升级为长版本 + - 容差直接绑定 cron 表达式,配置直观 + """ + morning_cron = config.get("schedule", {}).get("morning_cron") + if not morning_cron: + return False + tolerance = timedelta( + minutes=config["schedule"].get("morning_match_tolerance_minutes", 5) + ) + base = now.replace(hour=0, minute=0, second=0, microsecond=0) + today_fire = croniter(morning_cron, base).get_next(datetime) + return abs(now - today_fire) <= tolerance +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_morning_detection.py -v` +Expected: 5 pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main.py tests/pytest/test_morning_detection.py +git commit -m "feat(main): add is_morning_push cron-based detector" +``` + +--- + +### Task 19: Add `_assemble_with_sentinels` helper + +**Files:** +- Modify: `src/storage.py` +- Test: `tests/pytest/test_storage_sections.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/pytest/test_storage_sections.py`: + +```python +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"} + ) + # 顺序:rss → github → hackernews → insights + 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 "\nR\n" 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": ""}) == "" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_storage_sections.py::TestAssembleWithSentinels -v` +Expected: ImportError. + +- [ ] **Step 3: Implement** + +Append to `src/storage.py`: + +```python +_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"\n{body}\n") + return "\n\n".join(parts) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_storage_sections.py::TestAssembleWithSentinels -v` +Expected: 3 pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/storage.py tests/pytest/test_storage_sections.py +git commit -m "feat(storage): add assemble_with_sentinels helper" +``` + +--- + +### Task 20: Refactor `run_push_job` to dispatch by morning detection + +**Files:** +- Modify: `src/main.py` +- Test: `tests/pytest/test_main_run_push_job.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_main_run_push_job.py`: + +```python +"""测试 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"]["morning_cron"] = "0 8 * * *" + sample_config["schedule"]["morning_match_tolerance_minutes"] = 5 + 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"]["morning_cron"] = "0 8 * * *" + sample_config["schedule"]["morning_match_tolerance_minutes"] = 5 + 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() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_main_run_push_job.py -v` +Expected: AttributeError or ImportError on `_run_default_push` / `_run_morning_push`. + +- [ ] **Step 3: Refactor `run_push_job`** + +Open `src/main.py`. Replace the entire `async def run_push_job(config: Dict):` function (currently around lines 280-333) with: + +```python +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 流程""" + last_push_file = get_last_push_file() + last_push_time = extract_push_time(last_push_file) if last_push_file else None + if last_push_time: + print(f"📌 上次推送: {last_push_time.strftime('%Y-%m-%d %H:%M')}") + + 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, + ) + print( + f"📋 待推送 {len(to_push)} / 上下文 {len(context)} (≥{min_score} 分)" + ) + if not to_push: + print("ℹ️ 没有新消息需要推送") + return + + push_context_days = config["filter"].get("push_context_days", 5) + recent = load_recent_push_titles(push_context_days) + + print("🤖 生成推送内容...") + try: + push_content = await compose_digest( + to_push, context, config["llm"], recent_push_context=recent + ) + except Exception as e: + print(f"生成汇总推送失败: {e}") + await notify_llm_errors("compose_digest", [str(e)], config) + raise + + await send_to_platforms(push_content, config["push"]) + push_file = get_push_file() + save_push_file(push_file, push_content, len(to_push), len(to_push), profile="default") + print(f"💾 已保存到 {push_file}") + print(f"✅ Push Job 完成 | 推送: {len(to_push)} 条") +``` + +(Note: `_run_morning_push` is implemented in the next task. For this task to typecheck, also add this stub now — Task 21 fills in the body.) + +After `_run_default_push`, add: + +```python +async def _run_morning_push(config: Dict): + """早报时段:四模块编排 + sentinel 拼装 (Task 21 will implement)""" + raise NotImplementedError("Task 21 implements morning push") +``` + +Also add the imports the new code needs at the top of main.py — find the existing `from src.storage import (` block and add `assemble_with_sentinels,` to it. Also add the section imports (will be used in Task 21): + +```python +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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_main_run_push_job.py -v` +Expected: 2 pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main.py tests/pytest/test_main_run_push_job.py +git commit -m "refactor(main): split run_push_job into default + morning dispatchers" +``` + +--- + +### Task 21: Implement `_run_morning_push` four-module orchestrator + +**Files:** +- Modify: `src/main.py` (replace the stub) +- Test: `tests/pytest/test_main_morning_push.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/pytest/test_main_morning_push.py`: + +```python +"""测试早报四模块编排: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 + + +@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): + sent["content"] = content + saved = {} + def fake_save(filepath, content, source_count, total_entries, profile="default"): + saved["profile"] = profile + saved["content"] = content + + with patch("src.main.run_rss_section", new=AsyncMock(return_value=("R", 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", 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 saved["profile"] == "morning" + + +@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=("", "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): + sent["content"] = content + + with patch("src.main.run_rss_section", new=AsyncMock(return_value=("R", 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", 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"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/pytest/test_main_morning_push.py -v` +Expected: NotImplementedError (from the stub). + +- [ ] **Step 3: Implement `_run_morning_push`** + +In `src/main.py`, replace the stub: + +```python +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), + ) + + rss_md, rss_err = rss_result + gh_md, gh_err = gh_result + hn_md, hn_err = hn_result + + # 失败告警(GH/HN 非阻塞) + if gh_err: + await notify_llm_errors("section_github", [gh_err], config) + if hn_err: + await notify_llm_errors("section_hackernews", [hn_err], config) + + # RSS 失败阻断 + if rss_err and not rss_md: + await notify_llm_errors("compose_digest", [rss_err], config) + raise RuntimeError(f"RSS section failed: {rss_err}") + + # insights(串行) + insights_md, insights_err = await run_insights_section( + rss_md, gh_md, hn_md, config, now + ) + if insights_err: + await notify_llm_errors("insights", [insights_err], config) + + 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"]) + push_file = get_push_file() + # source/total 在早报场景下意义弱化;沿用 RSS 段长度作为弱代理 + rss_count = rss_md.count("###") if rss_md else 0 + save_push_file(push_file, final, rss_count, rss_count, profile="morning") + print(f"💾 已保存早报到 {push_file}") +``` + +Also ensure `asyncio` is imported at the top of `src/main.py` (it already is — line 4). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/pytest/test_main_morning_push.py tests/pytest/test_main_run_push_job.py -v` +Expected: all pass. + +- [ ] **Step 5: Run all tests for regression check** + +Run: `uv run pytest tests/pytest/ -v` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/main.py tests/pytest/test_main_morning_push.py +git commit -m "feat(main): implement four-module morning push orchestrator" +``` + +--- + +## Phase 7: Config, Docs, Integration Scripts + +### Task 22: Update `config.json` and `config.json.example` + +**Files:** +- Modify: `config.json` +- Modify: `config.json.example` + +- [ ] **Step 1: Update `config.json`** + +Open `config.json` and (a) add `"morning_cron"` and `"morning_match_tolerance_minutes"` under `schedule`, (b) add the entire `sections` block, (c) add the four new prompt paths under `llm.prompts`. + +Insert under `schedule` (after `"push_cron"`): + +```json + "morning_cron": "0 8 * * *", + "morning_match_tolerance_minutes": 5, +``` + +After the `schedule` block (or anywhere at top level), add: + +```json + "sections": { + "github_trending": { + "enabled": true, + "max_items": 3, + "max_deep_dive": 10, + "readme_max_chars": 3000, + "history_file": "news-data/trending-history.json", + "request_timeout": 10, + "tokenName": "GITHUB_TOKEN" + }, + "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" + }, + "insights": { + "enabled": true + } + }, +``` + +In the `llm.prompts` block, add: + +```json + "section_github": "prompts/section_github.md", + "section_hackernews_select": "prompts/section_hackernews_select.md", + "section_hackernews": "prompts/section_hackernews.md", + "insights": "prompts/insights.md" +``` + +- [ ] **Step 2: Mirror the changes into `config.json.example`** + +Apply the same edits to `config.json.example`. + +- [ ] **Step 3: Validate JSON** + +Run: `uv run python -c "import json; json.load(open('config.json')); json.load(open('config.json.example')); print('ok')"` +Expected: `ok` + +- [ ] **Step 4: Confirm config loads cleanly** + +Run: `uv run python -c "from src.config import load_config; c = load_config(); print(c['sections']['github_trending']['enabled'])"` +Expected: `True` + +- [ ] **Step 5: Commit** + +```bash +git add config.json config.json.example +git commit -m "feat(config): add sections + morning_cron schema" +``` + +--- + +### Task 23: Update `docs/tech-spec.md` (architecture sync) + +**Files:** +- Modify: `docs/tech-spec.md` + +- [ ] **Step 1: Add 板块编排 section** + +Find the "## 关键模块边界" section in `docs/tech-spec.md`. After the existing `src/` tree block, add: + +```markdown + +### 板块化扩展 (morning push) + +早报推送在 RSS 之上扩展三个板块:GitHub 趋势 / Hacker News 热议 / 跨板块洞察。模块结构、数据流与失败降级详见 `docs/extra-sections-design.md`。架构层关键约束: + +- 仅在 `schedule.morning_cron` 命中(± `morning_match_tolerance_minutes`)时启用,晚报维持纯 RSS 行为 +- 各板块封装为 `src/sections//section.py::run_xxx_section(config, now) -> (markdown, error)` +- `push_job` 用 `asyncio.gather` 并发跑 RSS / GH / HN,串行接 insights;最后用 `` sentinel 包入 push 文件 +- 仅 RSS 失败会让 push_job 整体退出非 0;其余板块失败 → 板块整段省略 + 告警 + +新增持久化文件:`news-data/trending-history.json`(GH 已查阅 repo 索引,按 `filter.keep_days` 过期) +``` + +- [ ] **Step 2: Update the data flow diagram (optional touch)** + +In the same file's "### 数据流" section, leave the existing mermaid flowchart unchanged (it describes the RSS path); the new design doc carries the morning-specific flow. + +- [ ] **Step 3: Update the maintenance date** + +Find `> update: 2026-05-15` near the top and change to `> update: 2026-05-17`. + +- [ ] **Step 4: Commit** + +```bash +git add docs/tech-spec.md +git commit -m "docs(tech-spec): sync architecture with morning extra sections" +``` + +--- + +### Task 24: Update `README.md` config detail section + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Locate config detail section** + +Open `README.md`. Find the section that documents `config.json` fields (typically headed "配置详解" or similar). Identify where `schedule` and `llm.prompts` are documented. + +- [ ] **Step 2: Add `schedule.morning_cron` description** + +Under `schedule` documentation, add: + +```markdown +- `morning_cron` (可选): 早报 cron 表达式 (例: `"0 8 * * *"`)。命中时启用三个额外板块(GitHub / HN / 洞察)。缺失则全程纯 RSS。 +- `morning_match_tolerance_minutes` (可选,默认 5): 早报判定容差(分钟)。 +``` + +- [ ] **Step 3: Add `sections` block documentation** + +Add a new subsection: + +```markdown +### sections (早报扩展板块) + +仅在 `schedule.morning_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 截断长度(默认 3000) +- `history_file`: trending 去重索引文件路径 +- `request_timeout`: HTTP 超时秒 +- `tokenName`: GitHub token 环境变量名;不设时匿名调用(限 60 req/hr) + +#### sections.hackernews +- `enabled`: 是否启用 +- `select_k`: 轻 LLM 从首页 30 条中挑出的故事数(默认 1) +- `top_comments`: 每个故事抓取的顶层评论数(默认 20) +- `comment_max_chars`: 单条评论截断长度(默认 500) +- `link_content_max_chars`: 外链正文截断长度(默认 3000) +- `request_timeout`: HTTP 超时秒 +- `algolia_base`: Algolia API 基址 + +#### sections.insights +- `enabled`: 是否启用跨板块洞察段 +``` + +- [ ] **Step 4: Add GITHUB_TOKEN to env vars table** + +In the env vars section, add: + +```markdown +- `GITHUB_TOKEN` (可选): GitHub API token,提升 deep-dive 限额到 5000 req/hr。不设时匿名调用(60 req/hr,日 20 calls 量级安全)。 +``` + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs(readme): document morning sections config + GITHUB_TOKEN" +``` + +--- + +### Task 25: Update `docs/plan.md` + +**Files:** +- Modify: `docs/plan.md` + +- [ ] **Step 1: Append decision + progress entries** + +Open `docs/plan.md`. Find the `## 技术决策记录` heading. Append: + +```markdown +### 2026-05-17: 早报扩展板块 + +- 决策:在 RSS digest 之上为早报增加 GitHub Trending / Hacker News / 跨板块洞察三段 +- 触发条件:`schedule.morning_cron` 命中(± `morning_match_tolerance_minutes`) +- 模块边界:四个自治模块在 `src/sections//`,push_job 上游统一包 sentinel +- GH:单页 trending HTML 抓取 → history 去重 → REST API 拿 README+topics+metadata → LLM 选 1-3 +- HN:首页 HTML → 轻 LLM 选 K(默认 1)→ Algolia API 拉评论 + html_to_markdown 拉外链 → LLM 行文 +- 失败语义:RSS 失败整体退出;其余板块失败省略本段 + 告警 +- 详细设计:`docs/extra-sections-design.md`;实施计划:`docs/superpowers/plans/2026-05-17-extra-sections.md` +``` + +Find `## 开发进度` heading. Append: + +```markdown +### 2026-05-17 + +- ✅ 设计完成,详见 `docs/extra-sections-design.md` +- ✅ 实施计划完成,详见 `docs/superpowers/plans/2026-05-17-extra-sections.md` +- 🔄 实施中(按计划分 25 个任务推进) +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/plan.md +git commit -m "docs(plan): record morning-sections decisions + progress" +``` + +--- + +### Task 26: Add manual test scripts + +**Files:** +- Create: `tests/fetch_trending.py` +- Create: `tests/fetch_hackernews.py` +- Create: `tests/run_morning_push.py` + +- [ ] **Step 1: Create `tests/fetch_trending.py`** + +```python +"""手动跑一次 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()) +``` + +- [ ] **Step 2: Create `tests/fetch_hackernews.py`** + +```python +"""手动跑一次 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, + comment_max_chars=300, + 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()) +``` + +- [ ] **Step 3: Create `tests/run_morning_push.py`** + +```python +"""模拟一次完整早报推送(强制 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()) +``` + +- [ ] **Step 4: Verify scripts at least parse** + +Run: `uv run python -c "import ast; [ast.parse(open(f).read()) for f in ['tests/fetch_trending.py', 'tests/fetch_hackernews.py', 'tests/run_morning_push.py']]; print('ok')"` +Expected: `ok` + +- [ ] **Step 5: Commit** + +```bash +git add tests/fetch_trending.py tests/fetch_hackernews.py tests/run_morning_push.py +git commit -m "test: add interactive scripts for trending/HN/morning push smoke tests" +``` + +--- + +### Task 27: End-to-end smoke test with real APIs + +This is a manual verification step before declaring the feature done. + +- [ ] **Step 1: Run GH trending smoke test** + +Run: `uv run python tests/fetch_trending.py` +Expected: prints 25+ repos, enriches 3, shows topics/readme_excerpt in JSON output. + +- [ ] **Step 2: Run HN smoke test** + +Run: `uv run python tests/fetch_hackernews.py` +Expected: prints 30 stories with points/comments, enriches 1 with link_content + top_comments. + +- [ ] **Step 3: Run full morning push simulation** + +Run: `uv run python tests/run_morning_push.py` +Expected: terminal prints the assembled push markdown containing 4 sections (or fewer with sentinel-bound omissions for any failing section). RSS section must be present (else the run aborts). + +- [ ] **Step 4: Run full pytest suite** + +Run: `uv run pytest tests/pytest/ -v` +Expected: all pass (including pre-existing tests). + +- [ ] **Step 5: Commit nothing, but update plan.md progress** + +Open `docs/plan.md` and update the most recent entry under `### 2026-05-17` from `🔄 实施中` to `✅ 实施完成`. + +```bash +git add docs/plan.md +git commit -m "docs(plan): mark morning-sections implementation complete" +``` + +--- + +## Self-Review + +**Spec coverage:** +- §2 architecture (4 modules + push_job orchestration) → Tasks 6, 10, 15, 17, 20, 21 ✓ +- §3 module structure (`src/sections/...`) → created across Tasks 6, 8, 10, 13, 15, 17 ✓ +- §4.1 sentinel contract → Task 19 (`assemble_with_sentinels`) ✓ +- §4.2 `extract_section` + `load_recent_section_titles` → Tasks 1, 2 ✓ +- §4.3 trending-history → Tasks 3, 5 ✓ +- §5.1 RSS migration → Task 6 ✓ +- §5.2 GitHub module flow → Tasks 7-11 ✓ +- §5.3 Hacker News module flow → Tasks 12-16 ✓ +- §5.4 Insights module → Task 17 ✓ +- §6.1 LLM functions → Tasks 11, 16, 17 ✓ +- §6.2 关注领域 + §6.3 prompts → Tasks 11, 16, 17 (prompts created with focus domain inline) ✓ +- §6.4 调用顺序 → Task 21 ✓ +- §7 insights formatting deferred to prompt → Task 17 prompt body honors this ✓ +- §8 config schema → Task 22 ✓ +- §9 failure isolation → Tasks 10, 15, 17, 21 ✓ +- §10 morning detection → Task 18 ✓ +- §11 integration points → Tasks 4 (save_push_file profile), 5 (cleanup history), 20 (main.py refactor) ✓ +- §12 test strategy → Tasks 1-21 (unit), Task 26 (interactive scripts) ✓ +- §13 implementation steps → matched 1:1 ✓ + +**Placeholder scan:** No TBDs; every code step has runnable code. Manual smoke tests in Task 27 are unavoidable manual verifications, called out explicitly. + +**Type consistency:** +- `TrendingHistory.touch(url, today)` — signature is consistent across Task 3, Task 5, Task 10. +- `run__section(config, now) -> (str, Optional[str])` — consistent across Tasks 6, 10, 15. +- `run_insights_section(rss_md, gh_md, hn_md, config, now) -> (str, Optional[str])` — consistent across Task 17 + Task 21. +- `assemble_with_sentinels(dict[str, str]) -> str` — consistent in Task 19 definition + Task 21 use. +- `save_push_file(filepath, content, source_count, total_entries, profile="default")` — consistent in Task 4 definition + Task 20 default-path call + Task 21 morning-path call. + +No issues found. diff --git a/ai-daily-main/docs/tech-spec.md b/ai-daily-main/docs/tech-spec.md new file mode 100644 index 0000000..e06c922 --- /dev/null +++ b/ai-daily-main/docs/tech-spec.md @@ -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//section.py::run_xxx_section(config, now) -> (markdown, error)` +- `push_job` 用 `asyncio.gather` 并发跑 RSS / GH / HN,串行接 insights;最后用 `` 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` diff --git a/ai-daily-main/prompts/digest.md b/ai-daily-main/prompts/digest.md new file mode 100644 index 0000000..899a089 --- /dev/null +++ b/ai-daily-main/prompts/digest.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} + + + +### 近几天已处理过的碎片化信息(供洞察参考): + +每条信息包含 + +- `title`: 文章标题 +- `source`: 文章来源 +- `score`: LLM评分 (0-100) +- `summary`: 一句话摘要 +- `tags`: 标签数组 +- `published`: 发布日期 + +```txt +{context} +``` diff --git a/ai-daily-main/prompts/immediate_push.md b/ai-daily-main/prompts/immediate_push.md new file mode 100644 index 0000000..d1026c9 --- /dev/null +++ b/ai-daily-main/prompts/immediate_push.md @@ -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} + diff --git a/ai-daily-main/prompts/insights.md b/ai-daily-main/prompts/insights.md new file mode 100644 index 0000000..cb56941 --- /dev/null +++ b/ai-daily-main/prompts/insights.md @@ -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} diff --git a/ai-daily-main/prompts/score_batch.md b/ai-daily-main/prompts/score_batch.md new file mode 100644 index 0000000..bcb001f --- /dev/null +++ b/ai-daily-main/prompts/score_batch.md @@ -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} +``` diff --git a/ai-daily-main/prompts/section_github.md b/ai-daily-main/prompts/section_github.md new file mode 100644 index 0000000..88469cb --- /dev/null +++ b/ai-daily-main/prompts/section_github.md @@ -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} +``` diff --git a/ai-daily-main/prompts/section_hackernews.md b/ai-daily-main/prompts/section_hackernews.md new file mode 100644 index 0000000..f392921 --- /dev/null +++ b/ai-daily-main/prompts/section_hackernews.md @@ -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} +``` diff --git a/ai-daily-main/prompts/section_hackernews_select.md b/ai-daily-main/prompts/section_hackernews_select.md new file mode 100644 index 0000000..0ba540a --- /dev/null +++ b/ai-daily-main/prompts/section_hackernews_select.md @@ -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} +``` diff --git a/ai-daily-main/pyproject.toml b/ai-daily-main/pyproject.toml new file mode 100644 index 0000000..a8409ce --- /dev/null +++ b/ai-daily-main/pyproject.toml @@ -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", +] diff --git a/ai-daily-main/requirements.txt b/ai-daily-main/requirements.txt new file mode 100644 index 0000000..715b8e7 --- /dev/null +++ b/ai-daily-main/requirements.txt @@ -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 diff --git a/ai-daily-main/resources/hero-banner.svg b/ai-daily-main/resources/hero-banner.svg new file mode 100644 index 0000000..fddcc91 --- /dev/null +++ b/ai-daily-main/resources/hero-banner.svg @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AI Daily + + + 每日 AI 资讯推送系统 + + + + + + + + RSS 聚合 + + + + LLM 智能 + + + + 即时推送 + + + + 定时汇总 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ai-daily-main/resources/rss.opml b/ai-daily-main/resources/rss.opml new file mode 100644 index 0000000..b3e4e8a --- /dev/null +++ b/ai-daily-main/resources/rss.opml @@ -0,0 +1,431 @@ + + + + All RSS Subscriptions for bestblogs.dev + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ai-daily-main/scripts/_gen_units.py b/ai-daily-main/scripts/_gen_units.py new file mode 100644 index 0000000..7b79e97 --- /dev/null +++ b/ai-daily-main/scripts/_gen_units.py @@ -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()) diff --git a/ai-daily-main/scripts/daily-news.tmpl b/ai-daily-main/scripts/daily-news.tmpl new file mode 100644 index 0000000..4c89dce --- /dev/null +++ b/ai-daily-main/scripts/daily-news.tmpl @@ -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 < + +命令: + 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 diff --git a/ai-daily-main/scripts/install.sh b/ai-daily-main/scripts/install.sh new file mode 100644 index 0000000..3b72b2d --- /dev/null +++ b/ai-daily-main/scripts/install.sh @@ -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" diff --git a/ai-daily-main/scripts/status.sh b/ai-daily-main/scripts/status.sh new file mode 100644 index 0000000..b89681f --- /dev/null +++ b/ai-daily-main/scripts/status.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" diff --git a/ai-daily-main/scripts/uninstall.sh b/ai-daily-main/scripts/uninstall.sh new file mode 100644 index 0000000..43f0e4e --- /dev/null +++ b/ai-daily-main/scripts/uninstall.sh @@ -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" diff --git a/ai-daily-main/src/__init__.py b/ai-daily-main/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-daily-main/src/config.py b/ai-daily-main/src/config.py new file mode 100644 index 0000000..78a6a2c --- /dev/null +++ b/ai-daily-main/src/config.py @@ -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 diff --git a/ai-daily-main/src/fetcher.py b/ai-daily-main/src/fetcher.py new file mode 100644 index 0000000..5474846 --- /dev/null +++ b/ai-daily-main/src/fetcher.py @@ -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_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 diff --git a/ai-daily-main/src/llm.py b/ai-daily-main/src/llm.py new file mode 100644 index 0000000..731a663 --- /dev/null +++ b/ai-daily-main/src/llm.py @@ -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"} diff --git a/ai-daily-main/src/main.py b/ai-daily-main/src/main.py new file mode 100644 index 0000000..d090a6b --- /dev/null +++ b/ai-daily-main/src/main.py @@ -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) diff --git a/ai-daily-main/src/markdown_utils.py b/ai-daily-main/src/markdown_utils.py new file mode 100644 index 0000000..94f9e60 --- /dev/null +++ b/ai-daily-main/src/markdown_utils.py @@ -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()] diff --git a/ai-daily-main/src/processor.py b/ai-daily-main/src/processor.py new file mode 100644 index 0000000..dd0f314 --- /dev/null +++ b/ai-daily-main/src/processor.py @@ -0,0 +1,37 @@ +"""内容处理模块 - HTML转Markdown""" + +import re +from urllib.parse import urljoin + +from markdownify import markdownify as md + + +def html_to_markdown(html: str, base_url: str = "") -> str: + """ + 将HTML转换为Markdown,保留链接和图片 + 使用markdownify库,并进行后处理优化 + """ + # markdownify会自动处理<img>为![](url),<a>为[text](url) + markdown = md(html, heading_style="ATX") + + # 处理相对链接 + if base_url: + + def replace_rel_link(m): + prefix, path, suffix = m.groups() + if path.startswith(("http://", "https://", "data:")): + return m.group(0) + abs_url = urljoin(base_url, path) + return f"{prefix}{abs_url}{suffix}" + + markdown = re.sub(r"(!?\[.*?\]\()(.*?)(\))", replace_rel_link, markdown) + + # 后处理优化 + # 1. 直接匹配移除 xgo.ing 推广链接 + markdown = markdown.replace("[⚡ Powered by xgo.ing](https://xgo.ing)", "") + markdown = markdown.replace("[⚡ Powered by xgo.ing](https://xgo.ing/)", "") + + # 2. 清理多余空行 + markdown = re.sub(r"\n{3,}", "\n\n", markdown) + + return markdown.strip() diff --git a/ai-daily-main/src/push/__init__.py b/ai-daily-main/src/push/__init__.py new file mode 100644 index 0000000..457efbb --- /dev/null +++ b/ai-daily-main/src/push/__init__.py @@ -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}") diff --git a/ai-daily-main/src/push/base.py b/ai-daily-main/src/push/base.py new file mode 100644 index 0000000..95cb082 --- /dev/null +++ b/ai-daily-main/src/push/base.py @@ -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 diff --git a/ai-daily-main/src/push/custom.py b/ai-daily-main/src/push/custom.py new file mode 100644 index 0000000..e5b27f5 --- /dev/null +++ b/ai-daily-main/src/push/custom.py @@ -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}") diff --git a/ai-daily-main/src/push/discord.py b/ai-daily-main/src/push/discord.py new file mode 100644 index 0000000..c84b2f5 --- /dev/null +++ b/ai-daily-main/src/push/discord.py @@ -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 diff --git a/ai-daily-main/src/push/feishu.py b/ai-daily-main/src/push/feishu.py new file mode 100644 index 0000000..7f58bda --- /dev/null +++ b/ai-daily-main/src/push/feishu.py @@ -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 diff --git a/ai-daily-main/src/sections/__init__.py b/ai-daily-main/src/sections/__init__.py new file mode 100644 index 0000000..fb82036 --- /dev/null +++ b/ai-daily-main/src/sections/__init__.py @@ -0,0 +1 @@ +"""板块模块包。每个子模块导出 run_<board>_section(config, now) -> (markdown, error)""" diff --git a/ai-daily-main/src/sections/github/__init__.py b/ai-daily-main/src/sections/github/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-daily-main/src/sections/github/repo_enricher.py b/ai-daily-main/src/sections/github/repo_enricher.py new file mode 100644 index 0000000..2fb610f --- /dev/null +++ b/ai-daily-main/src/sections/github/repo_enricher.py @@ -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 diff --git a/ai-daily-main/src/sections/github/section.py b/ai-daily-main/src/sections/github/section.py new file mode 100644 index 0000000..1fea357 --- /dev/null +++ b/ai-daily-main/src/sections/github/section.py @@ -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 diff --git a/ai-daily-main/src/sections/github/trending_scraper.py b/ai-daily-main/src/sections/github/trending_scraper.py new file mode 100644 index 0000000..91e162b --- /dev/null +++ b/ai-daily-main/src/sections/github/trending_scraper.py @@ -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 diff --git a/ai-daily-main/src/sections/hackernews/__init__.py b/ai-daily-main/src/sections/hackernews/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-daily-main/src/sections/hackernews/frontpage_scraper.py b/ai-daily-main/src/sections/hackernews/frontpage_scraper.py new file mode 100644 index 0000000..4979098 --- /dev/null +++ b/ai-daily-main/src/sections/hackernews/frontpage_scraper.py @@ -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 diff --git a/ai-daily-main/src/sections/hackernews/item_enricher.py b/ai-daily-main/src/sections/hackernews/item_enricher.py new file mode 100644 index 0000000..2b0d502 --- /dev/null +++ b/ai-daily-main/src/sections/hackernews/item_enricher.py @@ -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 diff --git a/ai-daily-main/src/sections/hackernews/section.py b/ai-daily-main/src/sections/hackernews/section.py new file mode 100644 index 0000000..208342e --- /dev/null +++ b/ai-daily-main/src/sections/hackernews/section.py @@ -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 diff --git a/ai-daily-main/src/sections/insights/__init__.py b/ai-daily-main/src/sections/insights/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-daily-main/src/sections/insights/section.py b/ai-daily-main/src/sections/insights/section.py new file mode 100644 index 0000000..0df9b38 --- /dev/null +++ b/ai-daily-main/src/sections/insights/section.py @@ -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 diff --git a/ai-daily-main/src/sections/rss/__init__.py b/ai-daily-main/src/sections/rss/__init__.py new file mode 100644 index 0000000..6e882c2 --- /dev/null +++ b/ai-daily-main/src/sections/rss/__init__.py @@ -0,0 +1,3 @@ +from src.sections.rss.section import run_rss_section + +__all__ = ["run_rss_section"] diff --git a/ai-daily-main/src/sections/rss/section.py b/ai-daily-main/src/sections/rss/section.py new file mode 100644 index 0000000..2f76ea6 --- /dev/null +++ b/ai-daily-main/src/sections/rss/section.py @@ -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 diff --git a/ai-daily-main/src/storage.py b/ai-daily-main/src/storage.py new file mode 100644 index 0000000..799b421 --- /dev/null +++ b/ai-daily-main/src/storage.py @@ -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) diff --git a/ai-daily-main/systemd/dnews-fetch.service.tmpl b/ai-daily-main/systemd/dnews-fetch.service.tmpl new file mode 100644 index 0000000..73a8457 --- /dev/null +++ b/ai-daily-main/systemd/dnews-fetch.service.tmpl @@ -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 diff --git a/ai-daily-main/systemd/dnews-fetch.timer.tmpl b/ai-daily-main/systemd/dnews-fetch.timer.tmpl new file mode 100644 index 0000000..4026786 --- /dev/null +++ b/ai-daily-main/systemd/dnews-fetch.timer.tmpl @@ -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 diff --git a/ai-daily-main/systemd/dnews-push.service.tmpl b/ai-daily-main/systemd/dnews-push.service.tmpl new file mode 100644 index 0000000..0629aab --- /dev/null +++ b/ai-daily-main/systemd/dnews-push.service.tmpl @@ -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 diff --git a/ai-daily-main/systemd/dnews-push.timer.tmpl b/ai-daily-main/systemd/dnews-push.timer.tmpl new file mode 100644 index 0000000..d21426c --- /dev/null +++ b/ai-daily-main/systemd/dnews-push.timer.tmpl @@ -0,0 +1,10 @@ +[Unit] +Description=Daily News - Push Timer + +[Timer] +{{PUSH_ONCALENDAR_LINES}} +AccuracySec=1s +Unit=dnews-push.service + +[Install] +WantedBy=timers.target diff --git a/ai-daily-main/systemd/journald-dnews.conf.tmpl b/ai-daily-main/systemd/journald-dnews.conf.tmpl new file mode 100644 index 0000000..5fe9e3d --- /dev/null +++ b/ai-daily-main/systemd/journald-dnews.conf.tmpl @@ -0,0 +1,2 @@ +[Journal] +MaxRetentionSec={{LOG_RETENTION_DAYS}}day diff --git a/ai-daily-main/tests/__init__.py b/ai-daily-main/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-daily-main/tests/fetch_hackernews.py b/ai-daily-main/tests/fetch_hackernews.py new file mode 100644 index 0000000..d697ba6 --- /dev/null +++ b/ai-daily-main/tests/fetch_hackernews.py @@ -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()) diff --git a/ai-daily-main/tests/fetch_news.py b/ai-daily-main/tests/fetch_news.py new file mode 100644 index 0000000..2ccda49 --- /dev/null +++ b/ai-daily-main/tests/fetch_news.py @@ -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) diff --git a/ai-daily-main/tests/fetch_nitter.py b/ai-daily-main/tests/fetch_nitter.py new file mode 100644 index 0000000..9d0f079 --- /dev/null +++ b/ai-daily-main/tests/fetch_nitter.py @@ -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()) diff --git a/ai-daily-main/tests/fetch_trending.py b/ai-daily-main/tests/fetch_trending.py new file mode 100644 index 0000000..621079b --- /dev/null +++ b/ai-daily-main/tests/fetch_trending.py @@ -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()) diff --git a/ai-daily-main/tests/manual_trigger_fetch_loop_llm_error.py b/ai-daily-main/tests/manual_trigger_fetch_loop_llm_error.py new file mode 100644 index 0000000..4417597 --- /dev/null +++ b/ai-daily-main/tests/manual_trigger_fetch_loop_llm_error.py @@ -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()) diff --git a/ai-daily-main/tests/preview_recent_context.py b/ai-daily-main/tests/preview_recent_context.py new file mode 100644 index 0000000..6af227b --- /dev/null +++ b/ai-daily-main/tests/preview_recent_context.py @@ -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()) diff --git a/ai-daily-main/tests/probe_hn_comments.py b/ai-daily-main/tests/probe_hn_comments.py new file mode 100644 index 0000000..e350f89 --- /dev/null +++ b/ai-daily-main/tests/probe_hn_comments.py @@ -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()) diff --git a/ai-daily-main/tests/push_news.py b/ai-daily-main/tests/push_news.py new file mode 100644 index 0000000..0403357 --- /dev/null +++ b/ai-daily-main/tests/push_news.py @@ -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)) diff --git a/ai-daily-main/tests/pytest/__init__.py b/ai-daily-main/tests/pytest/__init__.py new file mode 100644 index 0000000..ae599fb --- /dev/null +++ b/ai-daily-main/tests/pytest/__init__.py @@ -0,0 +1 @@ +# pytest tests for daily-news project diff --git a/ai-daily-main/tests/pytest/conftest.py b/ai-daily-main/tests/pytest/conftest.py new file mode 100644 index 0000000..5a04907 --- /dev/null +++ b/ai-daily-main/tests/pytest/conftest.py @@ -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 diff --git a/ai-daily-main/tests/pytest/fixtures/github_trending.html b/ai-daily-main/tests/pytest/fixtures/github_trending.html new file mode 100644 index 0000000..a520f73 --- /dev/null +++ b/ai-daily-main/tests/pytest/fixtures/github_trending.html @@ -0,0 +1,3771 @@ + + +<!DOCTYPE html> +<html + lang="en" + + data-color-mode="auto" data-light-theme="light" data-dark-theme="dark" + data-a11y-animated-images="system" data-a11y-link-underlines="true" + + > + + + + + <head> + <meta charset="utf-8"> + <link rel="dns-prefetch" href="https://github.githubassets.com"> + <link rel="dns-prefetch" href="https://avatars.githubusercontent.com"> + <link rel="dns-prefetch" href="https://github-cloud.s3.amazonaws.com"> + <link rel="dns-prefetch" href="https://user-images.githubusercontent.com/"> + <link rel="preconnect" href="https://github.githubassets.com" crossorigin> + <link rel="preconnect" href="https://avatars.githubusercontent.com"> + + + + <link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/light-2ff56e1b36116ee2.css" /><link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/light_high_contrast-f7f95d7633592089.css" /><link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/dark-2d1fe43dbc9adf1f.css" /><link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/dark_high_contrast-d530ee188d165539.css" /><link data-color-theme="light" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/light-2ff56e1b36116ee2.css" /><link data-color-theme="light_high_contrast" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/light_high_contrast-f7f95d7633592089.css" /><link data-color-theme="light_colorblind" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/light_colorblind-e4bbc49fc7b82570.css" /><link data-color-theme="light_colorblind_high_contrast" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/light_colorblind_high_contrast-cc3f126b45166b83.css" /><link data-color-theme="light_tritanopia" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/light_tritanopia-652ca611ea4e33fe.css" /><link data-color-theme="light_tritanopia_high_contrast" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/light_tritanopia_high_contrast-9e82d635cb6c3f49.css" /><link data-color-theme="dark" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark-2d1fe43dbc9adf1f.css" /><link data-color-theme="dark_high_contrast" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_high_contrast-d530ee188d165539.css" /><link data-color-theme="dark_colorblind" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_colorblind-3ad0ec21150df75b.css" /><link data-color-theme="dark_colorblind_high_contrast" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_colorblind_high_contrast-5691ff467f71a3f6.css" /><link data-color-theme="dark_tritanopia" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_tritanopia-abee7710893cd168.css" /><link data-color-theme="dark_tritanopia_high_contrast" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_tritanopia_high_contrast-eafcf6cd46158360.css" /><link data-color-theme="dark_dimmed" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_dimmed-c7974682a1a84c8d.css" /><link data-color-theme="dark_dimmed_high_contrast" crossorigin="anonymous" media="all" rel="stylesheet" data-href="https://github.githubassets.com/assets/dark_dimmed_high_contrast-f8dab3e04f94c501.css" /> + + <style type="text/css"> + :root { + --tab-size-preference: 4; + } + + pre, code { + tab-size: var(--tab-size-preference); + } + </style> + + <link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/primer-primitives-7f694b60439d06c0.css" /> + <link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/primer-bf8570e4081bd07e.css" /> + <link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/global-5efd63e783ac04bb.css" /> + <link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/github-f825c0edd7ad57f8.css" /> + <link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/site-8e580c38ab6535be.css" /> +<link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/explore-4806b02ffcc9d15a.css" /> + + + + <script type="application/json" id="client-env">{"locale":"en","featureFlags":["actions_custom_images_storage_billing_ui_visibility","actions_image_version_event","actions_workflow_language_service_allow_concurrency_queue","agent_conflict_resolution","alternate_user_config_repo","arianotify_comprehensive_migration","artifact_ui_v2","billing_discount_threshold_notification","code_scanning_dfa_degraded_experience_notice","codespaces_prebuild_region_target_update","coding_agent_model_selection","coding_agent_model_selection_all_skus","comment_viewer_copy_raw_markdown","contentful_primer_code_blocks","copilot_agent_snippy","copilot_api_agentic_issue_marshal_yaml","copilot_ask_mode_dropdown","copilot_automation_session_author","copilot_chat_attach_multiple_images","copilot_chat_category_rate_limit_messages","copilot_chat_clear_model_selection_for_default_change","copilot_chat_contextual_suggestions_updated","copilot_chat_enable_tool_call_logs","copilot_chat_file_redirect","copilot_chat_input_commands","copilot_chat_opening_thread_switch","copilot_chat_prettify_pasted_code","copilot_chat_reduce_quota_checks","copilot_chat_search_bar_redirect","copilot_chat_vision_in_claude","copilot_chat_vision_preview_gate","copilot_custom_copilots","copilot_custom_copilots_feature_preview","copilot_delete_cli_sessions","copilot_diff_explain_conversation_intent","copilot_diff_reference_context","copilot_duplicate_thread","copilot_extensions_hide_in_dotcom_chat","copilot_extensions_removal_on_marketplace","copilot_features_sql_server_logo","copilot_file_block_ref_matching","copilot_ftp_hyperspace_upgrade_prompt","copilot_icebreakers_experiment_dashboard","copilot_icebreakers_experiment_hyperspace","copilot_immersive_code_block_transition_wrap","copilot_immersive_embedded","copilot_immersive_embedded_deferred_payload","copilot_immersive_embedded_draggable","copilot_immersive_embedded_header_button","copilot_immersive_embedded_implicit_references","copilot_immersive_embedded_skip_copilot_api_token_for_dotcom_context","copilot_immersive_file_block_transition_open","copilot_immersive_file_preview_keep_mounted","copilot_immersive_job_result_preview","copilot_immersive_structured_model_picker","copilot_immersive_task_hyperlinking","copilot_immersive_task_within_chat_thread","copilot_mc_cli_resume_any_users_task","copilot_mission_control_agent_filtering","copilot_mission_control_always_send_integration_id","copilot_mission_control_cli_private_icon","copilot_mission_control_cli_session_status","copilot_mission_control_initial_data_spinner","copilot_mission_control_logs_incremental","copilot_mission_control_task_alive_updates","copilot_mission_control_tasks_repo_filter","copilot_org_policy_page_focus_mode","copilot_redirect_header_button_to_agents","copilot_resource_panel","copilot_scroll_preview_tabs","copilot_share_active_subthread","copilot_spaces_ga","copilot_spaces_individual_policies_ga","copilot_spaces_pagination","copilot_spark_empty_state","copilot_spark_handle_nil_friendly_name","copilot_swe_agent_hide_model_picker_if_only_auto","copilot_swe_agent_pr_comment_model_picker","copilot_swe_agent_use_subagents","copilot_task_api_github_rest_style","copilot_unconfigured_is_inherited","copilot_upgrade_freeze","copilot_usage_metrics_ga","copilot_workbench_slim_line_top_tabs","custom_instructions_file_references","dashboard_indexeddb_caching","dashboard_lists_max_age_filter","dashboard_universe_2025_feedback_dialog","enterprise_managed_settings_for_copilot_clients","filter_support_formcontrol","flex_cta_groups_mvp","global_nav_react","hyperspace_2025_logged_out_batch_1","hyperspace_2025_logged_out_batch_2","hyperspace_2025_logged_out_batch_3","ipm_budget_deep_linking","ipm_global_transactional_message_agents","ipm_global_transactional_message_copilot","ipm_global_transactional_message_issues","ipm_global_transactional_message_prs","ipm_global_transactional_message_repos","ipm_global_transactional_message_spaces","issue_cca_modal_open","issue_cca_multi_assign_modal","issue_cca_task_side_panel","issue_cca_visualization","issue_cca_visualization_session_panel","issue_fields_global_search","issue_type_filter_no_relay","issues_expanded_file_types","issues_lazy_load_comment_box_suggestions","issues_react_chrome_container_query_fix","issues_search_type_gql","landing_pages_ninetailed","landing_pages_web_vitals_tracking","lifecycle_label_name_updates","low_quality_classifier","marketing_pages_search_explore_provider","memex_default_issue_create_repository","memex_live_update_hovercard","memex_mwl_filter_field_delimiter","memex_remove_deprecated_type_issue","merge_status_header_feedback","notifications_menu_defer_labels","oauth_authorize_clickjacking_protection","octocaptcha_origin_optimization","prs_conversations_react","prs_css_anchor_positioning","prs_inbox_deferred_usequeries","react_compiler_issue_viewer","react_compiler_issues_react","react_data_router_serializable_query_deps","repos_contributors_limited_default_range","rules_insights_filter_bar_created","sample_network_conn_type","saved_views_filter_validation_fix","secret_scanning_pattern_alerts_link","security_center_artifact_filters_popover","session_logs_ungroup_reasoning_text","site_features_copilot_universe","site_homepage_collaborate_video","spark_prompt_secret_scanning","spark_server_connection_status","suppress_automated_browser_vitals","user_bypass_actors","viewscreen_sandbox","warn_inaccessible_attachments","webp_support","wiki_editor_iconbuttons","workbench_store_readonly"],"copilotApiOverrideUrl":"https://api.githubcopilot.com"}</script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/high-contrast-cookie-771a5d64a9997172.js"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/wp-runtime-9f875280d59a237d.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/fetch-utilities-18f7f90effa3f0dd.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/28839-28bb58f695d5e365.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/85924-1f0f5f61600f9c8e.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/34646-3b3c3c313ce5ddeb.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/environment-53f425896e39574b.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/runtime-helpers-6e561c87b9671d53.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/2966-db5d6e7392243767.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/96232-2f91b960d23e9fb6.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/41013-ce9f0a483fa6f641.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/51210-07f0116b9064d4ba.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/81058-4b90d5ffbf765928.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/81683-6e791542fe2f37ce.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/46740-4421ca06d57312cc.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/26859-860fabe66e46c274.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/44156-e3af230368d84cf1.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/github-elements-b02944871e808357.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/element-registry-93dd2f58d7d84d31.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/react-core-3a0bc3d3c10a831f.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/react-lib-493beffaa1062d35.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/79039-f2b81734929d0b15.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/61110-f40600dc68e4c6b0.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/2887-d67f71d8e1d3e1d8.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/26533-318ac47648fb7752.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/86483-00384068b148f370.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/49521-900eb9434ea3a3be.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/60481-2e0070b5d23b633a.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/46287-1fd98f482f71d12a.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/33805-370111b2dad9b744.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/89627-12a64f4329866bd1.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/79087-909cf11697b5f298.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/49029-3a132de206358025.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/99328-82a96596275fbd3e.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/behaviors-562601653a2bcbb7.js" defer="defer"></script> +<link crossorigin="anonymous" media="all" rel="stylesheet" href="https://github.githubassets.com/assets/react-core.06bbbc99d75f3438.module.css" /> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/4244-23afe633cce1a60f.js" defer="defer"></script> +<script crossorigin="anonymous" type="application/javascript" src="https://github.githubassets.com/assets/notifications-global-f606f675f96cab09.js" defer="defer"></script> + + + <title>Trending repositories on GitHub today · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ +
+ +
+ + +
+
+

Trending

+ +

+ See what the GitHub community is most excited about today. +

+
+
+ +
+
+
+ + + +
+
+
+ Spoken Language: + + + Any + + + +
+ + Select a spoken language + + + +
+ +
+
+ +
+
+ +
+ +
+ + Abkhazian + + Afar + + Afrikaans + + Akan + + Albanian + + Amharic + + Arabic + + Aragonese + + Armenian + + Assamese + + Avaric + + Avestan + + Aymara + + Azerbaijani + + Bambara + + Bashkir + + Basque + + Belarusian + + Bengali + + Bihari languages + + Bislama + + Bosnian + + Breton + + Bulgarian + + Burmese + + Catalan, Valencian + + Chamorro + + Chechen + + Chichewa, Chewa, Nyanja + + Chinese + + Chuvash + + Cornish + + Corsican + + Cree + + Croatian + + Czech + + Danish + + Divehi, Dhivehi, Maldivian + + Dutch, Flemish + + Dzongkha + + English + + Esperanto + + Estonian + + Ewe + + Faroese + + Fijian + + Finnish + + French + + Fulah + + Galician + + Georgian + + German + + Greek, Modern + + Guarani + + Gujarati + + Haitian, Haitian Creole + + Hausa + + Hebrew + + Herero + + Hindi + + Hiri Motu + + Hungarian + + Interlingua (International Auxil... + + Indonesian + + Interlingue, Occidental + + Irish + + Igbo + + Inupiaq + + Ido + + Icelandic + + Italian + + Inuktitut + + Japanese + + Javanese + + Kalaallisut, Greenlandic + + Kannada + + Kanuri + + Kashmiri + + Kazakh + + Central Khmer + + Kikuyu, Gikuyu + + Kinyarwanda + + Kirghiz, Kyrgyz + + Komi + + Kongo + + Korean + + Kurdish + + Kuanyama, Kwanyama + + Latin + + Luxembourgish, Letzeburgesch + + Ganda + + Limburgan, Limburger, Limburgish + + Lingala + + Lao + + Lithuanian + + Luba-Katanga + + Latvian + + Manx + + Macedonian + + Malagasy + + Malay + + Malayalam + + Maltese + + Maori + + Marathi + + Marshallese + + Mongolian + + Nauru + + Navajo, Navaho + + North Ndebele + + Nepali + + Ndonga + + Norwegian Bokmål + + Norwegian Nynorsk + + Norwegian + + Sichuan Yi, Nuosu + + South Ndebele + + Occitan + + Ojibwa + + Church Slavic, Old Slavonic, Chu... + + Oromo + + Oriya + + Ossetian, Ossetic + + Punjabi, Panjabi + + Pali + + Persian + + Polish + + Pashto, Pushto + + Portuguese + + Quechua + + Romansh + + Rundi + + Romanian, Moldavian, Moldovan + + Russian + + Sanskrit + + Sardinian + + Sindhi + + Northern Sami + + Samoan + + Sango + + Serbian + + Gaelic, Scottish Gaelic + + Shona + + Sinhala, Sinhalese + + Slovak + + Slovenian + + Somali + + Southern Sotho + + Spanish, Castilian + + Sundanese + + Swahili + + Swati + + Swedish + + Tamil + + Telugu + + Tajik + + Thai + + Tigrinya + + Tibetan + + Turkmen + + Tagalog + + Tswana + + Tonga (Tonga Islands) + + Turkish + + Tsonga + + Tatar + + Twi + + Tahitian + + Uighur, Uyghur + + Ukrainian + + Urdu + + Uzbek + + Venda + + Vietnamese + + Volapük + + Walloon + + Welsh + + Wolof + + Western Frisian + + Xhosa + + Yiddish + + Yoruba + + Zhuang, Chuang + + Zulu +
+
+ +
+ + Loading + +
+
+
+ + +
+ +
+
+ Language: + + + Any + + + +
+ + Select a language + + +
+ +
+ + + +
+ +
+ +
+ + Unknown languages + + 1C Enterprise + + 2-Dimensional Array + + 4D + + ABAP + + ABAP CDS + + ABNF + + ActionScript + + Ada + + Adblock Filter List + + Adobe Font Metrics + + Agda + + AGS Script + + AIDL + + Aiken + + AL + + ALGOL + + Alloy + + Alpine Abuild + + Altium Designer + + AMPL + + AngelScript + + Answer Set Programming + + Ant Build System + + Antlers + + ANTLR + + ApacheConf + + Apex + + API Blueprint + + APL + + Apollo Guidance Computer + + AppleScript + + Arc + + AsciiDoc + + ASL + + ASN.1 + + Classic ASP + + ASP.NET + + AspectJ + + Assembly + + Astro + + Asymptote + + ATS + + Augeas + + AutoHotkey + + AutoIt + + Avro IDL + + Awk + + B (Formal Method) + + B4X + + Ballerina + + BASIC + + Batchfile + + Beef + + Befunge + + Berry + + BibTeX + + BibTeX Style + + Bicep + + Bikeshed + + Bison + + BitBake + + Blade + + BlitzBasic + + BlitzMax + + Bluespec + + Bluespec BH + + Boo + + Boogie + + BQN + + Brainfuck + + BrighterScript + + Brightscript + + Zeek + + Browserslist + + Bru + + BuildStream + + C + + C# + + C++ + + C-ObjDump + + C2hs Haskell + + C3 + + Cabal Config + + Caddyfile + + Cadence + + Cairo + + Cairo Zero + + CameLIGO + + Cangjie + + CAP CDS + + Cap'n Proto + + Carbon + + CartoCSS + + Ceylon + + Chapel + + Charity + + Checksums + + ChucK + + CIL + + Circom + + Cirru + + Clarion + + Clarity + + Classic ASP + + Clean + + Click + + CLIPS + + Clojure + + Closure Templates + + Cloud Firestore Security Rules + + Clue + + CMake + + COBOL + + CODEOWNERS + + CodeQL + + CoffeeScript + + ColdFusion + + ColdFusion CFC + + COLLADA + + Common Lisp + + Common Workflow Language + + Component Pascal + + CoNLL-U + + Cooklang + + Cool + + Rocq Prover + + Cpp-ObjDump + + CQL + + Creole + + crontab + + Crystal + + CSON + + Csound + + Csound Document + + Csound Score + + CSS + + CSV + + Cuda + + CUE + + Cue Sheet + + cURL Config + + Curry + + CWeb + + Cycript + + Cylc + + Cypher + + Cython + + D + + D-ObjDump + + D2 + + Dafny + + Darcs Patch + + Dart + + Daslang + + DataWeave + + Debian Package Control File + + DenizenScript + + desktop + + Dhall + + Diff + + DIGITAL Command Language + + dircolors + + DirectX 3D File + + DM + + DNS Zone + + Dockerfile + + Dogescript + + Dotenv + + DTrace + + Dune + + Dylan + + E + + E-mail + + Eagle + + Earthly + + Easybuild + + EBNF + + eC + + Ecere Projects + + ECL + + ECLiPSe + + Ecmarkup + + Edge + + EdgeQL + + EditorConfig + + Edje Data Collection + + edn + + Eiffel + + EJS + + Elixir + + Elm + + Elvish + + Elvish Transcript + + Emacs Lisp + + EmberScript + + E-mail + + EQ + + Erlang + + Euphoria + + F# + + F* + + Factor + + Fancy + + Fantom + + Faust + + Fennel + + FIGlet Font + + Filebench WML + + Filterscript + + FIRRTL + + fish + + Flix + + Fluent + + FLUX + + Formatted + + Forth + + Fortran + + Fortran Free Form + + FreeBASIC + + FreeMarker + + Frege + + Futhark + + G-code + + Game Maker Language + + GAML + + GAMS + + GAP + + GCC Machine Description + + GDB + + GDScript + + GDShader + + GEDCOM + + Gemfile.lock + + Gemini + + Genero 4gl + + Genero per + + Genie + + Genshi + + Gentoo Ebuild + + Gentoo Eclass + + Gerber Image + + Gettext Catalog + + Gherkin + + Git Attributes + + Git Commit + + Git Config + + Git Revision List + + Gleam + + Glimmer JS + + Glimmer TS + + GLSL + + Glyph + + Glyph Bitmap Distribution Format + + GN + + Gnuplot + + Go + + Go Checksums + + Go Module + + Go Template + + Go Workspace + + Godot Resource + + Golo + + Gosu + + Grace + + Gradle + + Gradle Kotlin DSL + + Grammatical Framework + + Graph Modeling Language + + GraphQL + + Graphviz (DOT) + + Groovy + + Groovy Server Pages + + GSC + + Hack + + Haml + + Handlebars + + HAProxy + + Harbour + + Hare + + Haskell + + Haxe + + HCL + + HIP + + HiveQL + + HLSL + + HOCON + + HolyC + + hoon + + Hosts File + + HTML + + Jinja + + HTML+ECR + + HTML+EEX + + HTML+ERB + + HTML+PHP + + HTML+Razor + + HTTP + + Hurl + + HXML + + Hy + + HyPhy + + iCalendar + + IDL + + Idris + + Ignore List + + IGOR Pro + + ImageJ Macro + + Imba + + Inform 7 + + INI + + Ink + + Inno Setup + + Io + + Ioke + + IRC log + + Isabelle + + Isabelle ROOT + + ISPC + + J + + Jac + + Jai + + Janet + + JAR Manifest + + Jasmin + + Java + + Java Properties + + Java Server Pages + + Java Template Engine + + JavaScript + + JavaScript+ERB + + JCL + + Jest Snapshot + + JetBrains MPS + + JFlex + + Jinja + + Jison + + Jison Lex + + Jolie + + jq + + JSON + + JSON with Comments + + JSON5 + + JSONiq + + JSONLD + + Jsonnet + + Julia + + Julia REPL + + Jupyter Notebook + + Just + + Kaitai Struct + + KakouneScript + + KCL + + KDL + + KerboScript + + KFramework + + KiCad Layout + + KiCad Legacy Layout + + KiCad Schematic + + Kickstart + + Kit + + Koka + + KoLmafia ASH + + Kotlin + + KRL + + Kusto + + kvlang + + LabVIEW + + Lambdapi + + Langium + + Lark + + Lasso + + Latte + + Lean + + Lean 4 + + Leo + + Less + + Lex + + LFE + + LigoLANG + + LilyPond + + Limbo + + Linear Programming + + Linker Script + + Linux Kernel Module + + Liquid + + Liquidsoap + + Literate Agda + + Literate CoffeeScript + + Literate Haskell + + LiveCode Script + + LiveScript + + LLVM + + Logos + + Logtalk + + LOLCODE + + LookML + + LoomScript + + LSL + + LTspice Symbol + + Lua + + Luau + + M + + M3U + + M4 + + M4Sugar + + Macaulay2 + + Makefile + + Mako + + Markdown + + Marko + + Mask + + Wolfram Language + + Mathematical Programming System + + MATLAB + + Maven POM + + Max + + MAXScript + + mcfunction + + mdsvex + + MDX + + Wikitext + + Mercury + + Mermaid + + Meson + + Metal + + MeTTa + + Microsoft Developer Studio Project + + Microsoft Visual Studio Solution + + MiniD + + MiniYAML + + MiniZinc + + MiniZinc Data + + Mint + + Mirah + + mIRC Script + + MLIR + + Modelica + + Modula-2 + + Modula-3 + + Module Management System + + Mojo + + Monkey + + Monkey C + + Moocode + + MoonBit + + MoonScript + + Motoko + + Motorola 68K Assembly + + Move + + MQL4 + + MQL5 + + MTML + + MUF + + mupad + + Muse + + Mustache + + Myghty + + nanorc + + Nasal + + NASL + + NCL + + Nearley + + Nemerle + + NEON + + nesC + + NetLinx + + NetLinx+ERB + + NetLogo + + NewLisp + + Nextflow + + Nginx + + Nickel + + Nim + + Ninja + + Nit + + Nix + + NL + + NMODL + + Noir + + NPM Config + + NSIS + + Nu + + NumPy + + Nunjucks + + Nushell + + NWScript + + OASv2-json + + OASv2-yaml + + OASv3-json + + OASv3-yaml + + Oberon + + ObjDump + + Object Data Instance Notation + + Objective-C + + Objective-C++ + + Objective-J + + ObjectScript + + OCaml + + Odin + + Omgrofl + + OMNeT++ MSG + + OMNeT++ NED + + OMNeT++ MSG + + OMNeT++ NED + + ooc + + Opa + + Opal + + Open Policy Agent + + OpenAPI Specification v2 + + OpenAPI Specification v3 + + OpenCL + + OpenEdge ABL + + OpenQASM + + OpenRC runscript + + OpenSCAD + + OpenStep Property List + + OpenType Feature File + + Option List + + Org + + OverpassQL + + Ox + + Oxygene + + Oz + + P4 + + Pact + + Pan + + Papyrus + + Parrot + + Parrot Assembly + + Parrot Internal Representation + + Pascal + + Pawn + + PDDL + + PEG.js + + Pep8 + + Perl + + PHP + + Pic + + Pickle + + PicoLisp + + PigLatin + + Pike + + Pip Requirements + + Pkl + + PlantUML + + PLpgSQL + + PLSQL + + Pod + + Pod 6 + + PogoScript + + Polar + + Pony + + Portugol + + PostCSS + + PostScript + + POV-Ray SDL + + PowerBuilder + + PowerShell + + Praat + + Prisma + + Processing + + Procfile + + Proguard + + Prolog + + Promela + + Propeller Spin + + Protocol Buffer + + Protocol Buffer Text Format + + Public Key + + Pug + + Puppet + + Pure Data + + PureBasic + + PureScript + + Pyret + + Python + + Python console + + Python traceback + + q + + Q# + + QMake + + QML + + Qt Script + + Quake + + QuakeC + + QuickBASIC + + R + + Racket + + Ragel + + Raku + + RAML + + Rascal + + RAScript + + Raw token data + + RBS + + RDoc + + Readline Config + + REALbasic + + Reason + + ReasonLIGO + + Rebol + + Record Jar + + Red + + Redcode + + Redirect Rules + + Regular Expression + + Ren'Py + + RenderScript + + ReScript + + reStructuredText + + REXX + + Rez + + Rich Text Format + + Ring + + Riot + + RMarkdown + + RobotFramework + + robots.txt + + Roc + + Rocq Prover + + Roff + + Roff Manpage + + RON + + ROS Interface + + Rouge + + RouterOS Script + + RPC + + RPGLE + + RPM Spec + + Ruby + + RUNOFF + + Rust + + Sage + + Sail + + SaltStack + + SAS + + Sass + + Scala + + Scaml + + Scenic + + Scheme + + Scilab + + SCSS + + sed + + Self + + SELinux Policy + + ShaderLab + + Shell + + ShellCheck Config + + ShellSession + + Shen + + Sieve + + Simple File Verification + + Singularity + + Slang + + Slash + + Slice + + Slim + + Slint + + Smali + + Smalltalk + + Smarty + + Smithy + + SmPL + + SMT + + Snakemake + + Solidity + + Soong + + SourcePawn + + SPARQL + + Spline Font Database + + SQF + + SQL + + SQLPL + + Squirrel + + SRecode Template + + SSH Config + + Stan + + Standard ML + + STAR + + Starlark + + Stata + + STL + + STON + + StringTemplate + + Stylus + + SubRip Text + + SugarSS + + SuperCollider + + SurrealQL + + Survex data + + Svelte + + SVG + + Sway + + Sweave + + Swift + + SWIG + + SystemVerilog + + Tact + + Talon + + Tcl + + Tcsh + + Tea + + Teal + + templ + + Terra + + Terraform Template + + TeX + + Texinfo + + Text + + TextGrid + + Textile + + TextMate Properties + + Thrift + + TI Program + + TL-Verilog + + TLA + + TMDL + + Toit + + TOML + + Tor Config + + Tree-sitter Query + + TSPLIB data + + TSQL + + TSV + + TSX + + Turing + + Turtle + + Twig + + TXL + + Type Language + + TypeScript + + TypeSpec + + Typst + + Unified Parallel C + + Unity3D Asset + + Unix Assembly + + Uno + + UnrealScript + + Untyped Plutus Core + + UrWeb + + V + + Vala + + Valve Data Format + + VBA + + VBScript + + vCard + + VCL + + Velocity Template Language + + Vento + + Verilog + + VHDL + + Vim Help File + + Vim Script + + Vim Snippet + + Visual Basic .NET + + Visual Basic .NET + + Visual Basic 6.0 + + Volt + + Vue + + Vyper + + Wavefront Material + + Wavefront Object + + WDL + + Web Ontology Language + + WebAssembly + + WebAssembly Interface Type + + WebIDL + + WebVTT + + Wget Config + + WGSL + + Whiley + + Wikitext + + Win32 Message File + + Windows Registry Entries + + wisp + + Witcher Script + + Wolfram Language + + Wollok + + World of Warcraft Addon Data + + Wren + + X BitMap + + X Font Directory Index + + X PixMap + + X10 + + xBase + + XC + + XCompose + + Xmake + + XML + + XML Property List + + Xojo + + Xonsh + + XPages + + XProc + + XQuery + + XS + + XSLT + + Xtend + + Yacc + + YAML + + YANG + + YARA + + YASnippet + + Yul + + ZAP + + Zeek + + ZenScript + + Zephir + + Zig + + ZIL + + Zimpl + + Zmodel +
+
+
+ + Loading + +
+
+
+ +
+ +
+
+ Date range: + + + Today + + + +
+ + Adjust time span + + +
+ + +
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+

Footer

+ + + + +
+
+ + + + + © 2026 GitHub, Inc. + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + diff --git a/ai-daily-main/tests/pytest/fixtures/hn_frontpage.html b/ai-daily-main/tests/pytest/fixtures/hn_frontpage.html new file mode 100644 index 0000000..b4fbc89 --- /dev/null +++ b/ai-daily-main/tests/pytest/fixtures/hn_frontpage.html @@ -0,0 +1,3 @@ +Hacker News
Hacker Newsnew | past | comments | ask | show | jobs | submitlogin
1.Zerostack – A Unix-inspired coding agent written in pure Rust (crates.io)
140 points by gidellav 3 hours ago | hide | 42 comments
2.A nicer voltmeter clock (lcamtuf.substack.com)
50 points by surprisetalk 2 hours ago | hide | 12 comments
3.MCP Hello Page (hybridlogic.co.uk)
47 points by Dachande663 3 hours ago | hide | 18 comments
4.A molecule with half-Möbius topology (science.org)
63 points by bryanrasmussen 7 hours ago | hide | discuss
5.SANA-WM, a 2.6B open-source world model for 1-minute 720p video (nvlabs.github.io)
295 points by mjgil 13 hours ago | hide | 125 comments
6.Moving away from Tailwind, and learning to structure my CSS (jvns.ca)
430 points by mpweiher 16 hours ago | hide | 275 comments
7.The Third Hard Problem (mmapped.blog)
25 points by surprisetalk 3 hours ago | hide | 17 comments
8.Halt and Catch Fire (unstack.io)
77 points by ScottWRobinson 7 hours ago | hide | 51 comments
9.Accelerando (2005) (antipope.org)
240 points by eamag 13 hours ago | hide | 140 comments
10.Fisker went bankrupt and owners built an open source car company from the ashes (electrek.co)
31 points by breve 1 hour ago | hide | 3 comments
11.Content-defined chunking added to Bazel (buildbuddy.io)
24 points by siggi 3 hours ago | hide | 2 comments
12.Stochastic Parrots: Frequently Unasked Questions (medium.com/emilymenonbender)
17 points by olalonde 2 hours ago | hide | 8 comments
13.OpenAI and Government of Malta partner to roll out ChatGPT Plus to all citizens (openai.com)
57 points by bookofjoe 5 hours ago | hide | 64 comments
14.Frontier AI has broken the open CTF format (kabir.au)
337 points by frays 18 hours ago | hide | 326 comments
15.δ-mem: Efficient Online Memory for Large Language Models (arxiv.org)
193 points by 44za12 16 hours ago | hide | 52 comments
16.We've made the world too complicated (user8.bearblog.dev)
189 points by James72689 17 hours ago | hide | 180 comments
17.Fame! A Misunderstanding: A new translation of Albert Camus's complete notebooks (lareviewofbooks.org)
43 points by Caiero 8 hours ago | hide | 7 comments
18.3D Gaussian Splatting in a Weekend (bfeldman.me)
48 points by b__feldman 7 hours ago | hide | 6 comments
19.PART Telescopes – Bringing radio astronomy within reach of rural schools (parttelescopes.web.app)
106 points by openrockets 10 hours ago | hide | 28 comments
20.Kioxia and Dell cram 10 PB into slim 2RU server (blocksandfiles.com)
108 points by rbanffy 8 hours ago | hide | 73 comments
21.Show HN: Rocksky – Music scrobbling and discovery on the AT Protocol (tangled.org)
57 points by tsiry 8 hours ago | hide | 22 comments
22.Greek Alphabet Cards (randomquark.com)
96 points by ricochet11 13 hours ago | hide | 47 comments
23.Futhark by example (2020) (futhark-lang.org)
109 points by tosh 15 hours ago | hide | 27 comments
24.Nearly 50 Years Later, WKRP in Cincinnati Becomes a Real Radio Station (openculture.com)
108 points by bookofjoe 17 hours ago | hide | 63 comments
25.I believe there are entire companies right now under AI psychosis (twitter.com/mitchellh)
1877 points by reasonableklout 1 day ago | hide | 1063 comments
26.Accelerate – Embedded language for high-performance array computations (github.com/acceleratehs)
77 points by tosh 11 hours ago | hide | 17 comments
27.After 8 years, I rewrote my open-source PyTorch curvature library (github.com/noahgolmant)
74 points by noahgolmant 12 hours ago | hide | 1 comment
28.Japan’s robot wolf sells out as record bear attacks drive demand (independent.co.uk)
88 points by bookofjoe 6 hours ago | hide | 51 comments
29.Kyber (YC W23) Is Hiring a Founding Marketer (ycombinator.com)
13 hours ago | hide
30.DeepSeek-V4-Flash means LLM steering is interesting again (seangoedecke.com)
210 points by Brajeshwar 10 hours ago | hide | 68 comments

+
Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

+
Search:
\ No newline at end of file diff --git a/ai-daily-main/tests/pytest/test_config.py b/ai-daily-main/tests/pytest/test_config.py new file mode 100644 index 0000000..db876d4 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_config.py @@ -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 = """ + + + + +""" + 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 = """ + + + + + +""" + 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 = """ + + + +""" + 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 = """ + + + + + +""" + 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 = """ + + + + + +""" + 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 = """ + + + + + + +""" + 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 diff --git a/ai-daily-main/tests/pytest/test_fetcher.py b/ai-daily-main/tests/pytest/test_fetcher.py new file mode 100644 index 0000000..fc3c1b7 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_fetcher.py @@ -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 = """ + + + Test Feed + + Article 1 + https://example.com/1 + Mon, 15 Jan 2024 10:00:00 GMT + Test description + + +""" + + 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() diff --git a/ai-daily-main/tests/pytest/test_llm.py b/ai-daily-main/tests/pytest/test_llm.py new file mode 100644 index 0000000..ddcad13 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_llm.py @@ -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 == [] diff --git a/ai-daily-main/tests/pytest/test_llm_extra_sections.py b/ai-daily-main/tests/pytest/test_llm_extra_sections.py new file mode 100644 index 0000000..5349eb9 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_llm_extra_sections.py @@ -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 diff --git a/ai-daily-main/tests/pytest/test_main.py b/ai-daily-main/tests/pytest/test_main.py new file mode 100644 index 0000000..4f4baf1 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_main.py @@ -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() diff --git a/ai-daily-main/tests/pytest/test_main_morning_push.py b/ai-daily-main/tests/pytest/test_main_morning_push.py new file mode 100644 index 0000000..ef72b76 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_main_morning_push.py @@ -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"] diff --git a/ai-daily-main/tests/pytest/test_main_run_push_job.py b/ai-daily-main/tests/pytest/test_main_run_push_job.py new file mode 100644 index 0000000..51cdc97 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_main_run_push_job.py @@ -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() diff --git a/ai-daily-main/tests/pytest/test_morning_detection.py b/ai-daily-main/tests/pytest/test_morning_detection.py new file mode 100644 index 0000000..1807053 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_morning_detection.py @@ -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 diff --git a/ai-daily-main/tests/pytest/test_processor.py b/ai-daily-main/tests/pytest/test_processor.py new file mode 100644 index 0000000..5b7e45d --- /dev/null +++ b/ai-daily-main/tests/pytest/test_processor.py @@ -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 = "

Hello World

" + result = html_to_markdown(html) + assert "Hello World" in result + + def test_convert_with_links(self): + html = 'Click here' + result = html_to_markdown(html) + assert "[Click here](https://example.com)" in result + + def test_convert_with_images(self): + html = 'Image' + result = html_to_markdown(html) + assert "![Image](https://example.com/image.png)" in result + + def test_convert_with_headings(self): + html = "

Title

Subtitle

" + result = html_to_markdown(html) + assert "# Title" in result + assert "## Subtitle" in result + + def test_convert_with_lists(self): + html = "
  • Item 1
  • Item 2
" + result = html_to_markdown(html) + assert "Item 1" in result + assert "Item 2" in result + + def test_convert_with_strong_emphasis(self): + html = "Bold and italic" + result = html_to_markdown(html) + assert "**Bold**" in result + assert "*italic*" in result + + def test_relative_link_conversion(self): + html = 'Read more' + 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 = '' + 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 = 'Link' + 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 = "

Content

[⚡ Powered by xgo.ing](https://xgo.ing)

" + 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 = "

Content

[⚡ Powered by xgo.ing](https://xgo.ing/)

" + result = html_to_markdown(html) + assert "xgo.ing" not in result + + def test_clean_extra_newlines(self): + html = "

Line 1

\n\n\n\n

Line 2

" + 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 = "

Hello World

" + result = html_to_markdown(html) + assert "Hello" in result + assert "World" in result diff --git a/ai-daily-main/tests/pytest/test_push.py b/ai-daily-main/tests/pytest/test_push.py new file mode 100644 index 0000000..6fffdf0 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_push.py @@ -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) diff --git a/ai-daily-main/tests/pytest/test_sections_github_enricher.py b/ai-daily-main/tests/pytest/test_sections_github_enricher.py new file mode 100644 index 0000000..9f470bf --- /dev/null +++ b/ai-daily-main/tests/pytest/test_sections_github_enricher.py @@ -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 diff --git a/ai-daily-main/tests/pytest/test_sections_github_scraper.py b/ai-daily-main/tests/pytest/test_sections_github_scraper.py new file mode 100644 index 0000000..783dbc8 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_sections_github_scraper.py @@ -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("no repos") == [] diff --git a/ai-daily-main/tests/pytest/test_sections_github_section.py b/ai-daily-main/tests/pytest/test_sections_github_section.py new file mode 100644 index 0000000..1776080 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_sections_github_section.py @@ -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="") + ), 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="") + ), 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="") + ), 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 diff --git a/ai-daily-main/tests/pytest/test_sections_hackernews_enricher.py b/ai-daily-main/tests/pytest/test_sections_hackernews_enricher.py new file mode 100644 index 0000000..fdb2a94 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_sections_hackernews_enricher.py @@ -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": "

comment one

", + "children": [ + {"text": "

reply 1a

"}, + {"text": "

reply 1b

"}, + {"text": "

reply 1c (should be dropped)

"}, + ], + }, + {"text": "

comment two

", "children": []}, + {"text": "

comment three

"}, + {"text": "

comment four (over top_comments cap)

"}, + ], + } + + 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": "

post body text

", + "children": [{"text": "

c1

"}], + } + 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 = "

" + ("y" * 2000) + "

" + long_reply = "

" + ("z" * 2000) + "

" + 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 = "

" + ("a" * 500) + "

" # 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 diff --git a/ai-daily-main/tests/pytest/test_sections_hackernews_scraper.py b/ai-daily-main/tests/pytest/test_sections_hackernews_scraper.py new file mode 100644 index 0000000..b391be3 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_sections_hackernews_scraper.py @@ -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 = """ + + + + + + + +
+ + Ask HN: what's new? + +
+ + 50 points + by alice + 2 hours ago + | 5 comments + +
+ """ + 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 diff --git a/ai-daily-main/tests/pytest/test_sections_hackernews_section.py b/ai-daily-main/tests/pytest/test_sections_hackernews_section.py new file mode 100644 index 0000000..3576026 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_sections_hackernews_section.py @@ -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="") + ), 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="") + ), 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 diff --git a/ai-daily-main/tests/pytest/test_sections_insights.py b/ai-daily-main/tests/pytest/test_sections_insights.py new file mode 100644 index 0000000..46bbbc8 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_sections_insights.py @@ -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"] == "(本次无内容)" diff --git a/ai-daily-main/tests/pytest/test_sections_rss.py b/ai-daily-main/tests/pytest/test_sections_rss.py new file mode 100644 index 0000000..9cf0cad --- /dev/null +++ b/ai-daily-main/tests/pytest/test_sections_rss.py @@ -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 diff --git a/ai-daily-main/tests/pytest/test_storage.py b/ai-daily-main/tests/pytest/test_storage.py new file mode 100644 index 0000000..9677ddb --- /dev/null +++ b/ai-daily-main/tests/pytest/test_storage.py @@ -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 diff --git a/ai-daily-main/tests/pytest/test_storage_sections.py b/ai-daily-main/tests/pytest/test_storage_sections.py new file mode 100644 index 0000000..b07a832 --- /dev/null +++ b/ai-daily-main/tests/pytest/test_storage_sections.py @@ -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" + "\n" + "RSS body\n" + "\n" + "\n" + "\n" + "GH body\n" + "\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 = "\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 "\nR\n" 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": ""}) == "" diff --git a/ai-daily-main/tests/pytest/test_timezone.py b/ai-daily-main/tests/pytest/test_timezone.py new file mode 100644 index 0000000..dc3d62a --- /dev/null +++ b/ai-daily-main/tests/pytest/test_timezone.py @@ -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 diff --git a/ai-daily-main/tests/resend_notify.py b/ai-daily-main/tests/resend_notify.py new file mode 100644 index 0000000..d45b965 --- /dev/null +++ b/ai-daily-main/tests/resend_notify.py @@ -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) diff --git a/ai-daily-main/tests/resend_push.py b/ai-daily-main/tests/resend_push.py new file mode 100644 index 0000000..e188661 --- /dev/null +++ b/ai-daily-main/tests/resend_push.py @@ -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) diff --git a/ai-daily-main/tests/run_llm_test.py b/ai-daily-main/tests/run_llm_test.py new file mode 100644 index 0000000..0ffeaed --- /dev/null +++ b/ai-daily-main/tests/run_llm_test.py @@ -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) diff --git a/ai-daily-main/tests/run_morning_push.py b/ai-daily-main/tests/run_morning_push.py new file mode 100644 index 0000000..1397593 --- /dev/null +++ b/ai-daily-main/tests/run_morning_push.py @@ -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()) diff --git a/ai-daily-main/tests/test_cleanup_old_files.py b/ai-daily-main/tests/test_cleanup_old_files.py new file mode 100644 index 0000000..580364c --- /dev/null +++ b/ai-daily-main/tests/test_cleanup_old_files.py @@ -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() diff --git a/ai-daily-main/tests/test_fetch_lookback.py b/ai-daily-main/tests/test_fetch_lookback.py new file mode 100644 index 0000000..e49c17a --- /dev/null +++ b/ai-daily-main/tests/test_fetch_lookback.py @@ -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()) diff --git a/ai-daily-main/tests/test_insights_section.py b/ai-daily-main/tests/test_insights_section.py new file mode 100644 index 0000000..a1a8fa4 --- /dev/null +++ b/ai-daily-main/tests/test_insights_section.py @@ -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"\n(.*?)\n" + + 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()) diff --git a/ai-daily-main/tests/test_push_loop.py b/ai-daily-main/tests/test_push_loop.py new file mode 100644 index 0000000..85c44a4 --- /dev/null +++ b/ai-daily-main/tests/test_push_loop.py @@ -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) diff --git a/ai-daily-main/uv.lock b/ai-daily-main/uv.lock new file mode 100644 index 0000000..c1ebe07 --- /dev/null +++ b/ai-daily-main/uv.lock @@ -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" }, +] diff --git a/config/.env.example b/config/.env.example new file mode 100644 index 0000000..3cb59e7 --- /dev/null +++ b/config/.env.example @@ -0,0 +1,17 @@ +# AI 配置 +OPENAI_API_KEY=your_api_key_here +OPENAI_API_BASE=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4o-mini +WEBZINE_MODEL=deepseek-v3.2 + +# 系统配置 +TIME_ZONE=Asia/Shanghai +LOG_LEVEL=INFO + +# RSS代理(微信公众号转RSS服务,默认使用 werss.yynnice.top) +RSS_PROXY_BASE=https://werss.yynnice.top + +# 推送配置 +WECHAT_TARGET=your_wechat_target_here +WECHAT_ACCOUNT=your_wechat_account_here +FEISHU_WEBHOOK=your_feishu_webhook_here diff --git a/config/rss_feeds.txt b/config/rss_feeds.txt new file mode 100644 index 0000000..bd8a1c0 --- /dev/null +++ b/config/rss_feeds.txt @@ -0,0 +1,20 @@ +# 军事科技RSS订阅源列表 +# 格式:<源名称>| +# 以#开头的行是注释,会自动忽略 +# 空行也会自动跳过 + +# 英文军事源 +Defense One|https://www.defenseone.com/rss/all/ +#Seapower|https://seapowermagazine.org/feed/ +The War Zone|https://www.twz.com/feed + +# 中文微信公众号源 +NEWUAS|https://werss.yynnice.top/feed/MP_WXS_3246130840.rss +国防科技要闻|https://werss.yynnice.top/feed/MP_WXS_3921202880.rss +战略前沿技术|https://werss.yynnice.top/feed/MP_WXS_3271903347.rss +#无人机邦|https://werss.yynnice.top/feed/MP_WXS_3957361285.rss +#浮空飞行器|https://werss.yynnice.top/feed/MP_WXS_3291643707.rss +#海鹰资讯|https://werss.yynnice.top/feed/MP_WXS_3091381580.rss +#渊亭防务|https://werss.yynnice.top/feed/MP_WXS_3865628826.rss +#电波之矛|https://werss.yynnice.top/feed/MP_WXS_3004998095.rss +#龙牙的一座山|https://werss.yynnice.top/feed/MP_WXS_3517933009.rss diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..53dc2d6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +version: '3.8' + +services: + military-digest: + build: . + container_name: military-digest-v3 + volumes: + - ./output:/app/output + - ./logs:/app/logs + - ./config:/app/config:ro + environment: + - TZ=Asia/Shanghai + - OPENAI_API_KEY=${OPENAI_API_KEY} + - OPENAI_API_BASE=${OPENAI_API_BASE} + - OPENAI_MODEL=${OPENAI_MODEL} + - WEBZINE_MODEL=${WEBZINE_MODEL} + - RSS_PROXY_BASE=${RSS_PROXY_BASE} + - WECHAT_ACCOUNT=${WECHAT_ACCOUNT} + - WECHAT_TARGET=${WECHAT_TARGET} + - FEISHU_WEBHOOK=${FEISHU_WEBHOOK} + restart: "no" + profiles: + - manual + + # 每日早8点定时执行 + military-digest-cron: + build: . + container_name: military-digest-cron + volumes: + - ./output:/app/output + - ./logs:/app/logs + - ./config:/app/config:ro + environment: + - TZ=Asia/Shanghai + - OPENAI_API_KEY=${OPENAI_API_KEY} + - OPENAI_API_BASE=${OPENAI_API_BASE} + - OPENAI_MODEL=${OPENAI_MODEL} + - WEBZINE_MODEL=${WEBZINE_MODEL} + - RSS_PROXY_BASE=${RSS_PROXY_BASE} + - WECHAT_ACCOUNT=${WECHAT_ACCOUNT} + - WECHAT_TARGET=${WECHAT_TARGET} + - FEISHU_WEBHOOK=${FEISHU_WEBHOOK} + restart: unless-stopped + entrypoint: | + bash -c " + echo '0 8 * * * cd /app && python scripts/military_daily_report_v3.py >> /app/logs/cron.log 2>&1' > /etc/crontabs/root + crond -f -l 2 + " + profiles: + - cron diff --git a/my-daily/.dockerignore b/my-daily/.dockerignore new file mode 100644 index 0000000..c1e372d --- /dev/null +++ b/my-daily/.dockerignore @@ -0,0 +1,56 @@ +# 配置文件(通过 volume 挂载,不打包进镜像) +.env +config.json + +# Git +.git/ +.gitignore + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# 运行时数据(通过 volume 挂载) +data/ +logs/ +docker/ + +# 文档(仅排除根目录下的) +/CONFIG.md +/CHANGELOG.md +/PROGRESS.md +/README.md +/QUICKSTART.md +/docs/ +# 其他散落的 md 文件 +/20260529_*.md +/1.md +/01*.md +/02*.md + +# 测试脚本 +scripts/test_*.py +scripts/debug_*.py +scripts/diagnose_*.py +scripts/audit_*.py +scripts/demo_*.py +scripts/gen_test_*.py +scripts/verify_*.py +scripts/analyze_*.py +scripts/sort_opml.py + +# 系统部署(仅 Linux/Windows 原生使用) +systemd/ +scripts/*.sh +scripts/*.bat +scripts/install.sh +scripts/uninstall*.sh +scripts/status.sh +scripts/setup_*.sh + +# 临时文件 +*.txt +!requirements.txt \ No newline at end of file diff --git a/my-daily/.env.example b/my-daily/.env.example new file mode 100644 index 0000000..b16aa9f --- /dev/null +++ b/my-daily/.env.example @@ -0,0 +1,28 @@ +# AI 配置(OpenAI 兼容接口,支持 deepseek / 火山引擎 等) +OPENAI_API_KEY=your_api_key_here +OPENAI_API_BASE=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4o-mini + +# 推送配置 +FEISHU_WEBHOOK=your_feishu_webhook_here + +# 飞书图片推送配置(如需直接发送图片需配置以下两项) +FEISHU_APP_ID=your_feishu_app_id_here +FEISHU_APP_SECRET=your_feishu_app_secret_here + +# 图床上传配置 +IMAGE_HOSTING_PASSWORD=your_image_hosting_password_here + +# ── 定时计划配置(取消注释即生效,优先级最高)── +# FETCH_INTERVAL_MINUTES=30 # RSS 拉取间隔(分钟) +# FETCH_LOOKBACK_MINUTES=60 # 回溯时间窗口(分钟) +# PUSH_CRON="0 8 * * *" # 推送定时计划(cron 表达式,多个用逗号分隔) +# TIMEZONE_HOURS=8 # 时区(相对于 UTC 的小时数) + +# ── 评价管线参数覆盖(取消注释即生效,优先级最高)── +# HEAT_THRESHOLD=30 # 热度阈值固定值(0-100) +# HOT_THRESHOLD=90 # 即时推送分数线 +# CLUSTER_MERGE_THRESHOLD=4 # 聚类合并阈值 +# PRECISE_BLEND_RATIO=0.4 # LLM 精评融合权重(精评分占比) +# MEMORY_WINDOW_HOURS=24 # 簇记忆去重时间窗口 +# CROSS_DAY_MIN_OVERLAP=0.05 # 跨日关联实体重叠阈值 diff --git a/my-daily/.gitignore b/my-daily/.gitignore new file mode 100644 index 0000000..57aa321 --- /dev/null +++ b/my-daily/.gitignore @@ -0,0 +1,9 @@ +.env +config.json +data/ +output/ +__pycache__/ +*.pyc +.venv/ +venv/ +*.pickle diff --git a/my-daily/001.txt b/my-daily/001.txt new file mode 100644 index 0000000..bccbac0 --- /dev/null +++ b/my-daily/001.txt @@ -0,0 +1,1443 @@ +================================================== +🔄 Fetch Job | 2026-07-01 06:35:13 +================================================== + 📂 共 131 个订阅源 | 时间窗口: 60 分钟 + 📡 The War Zone: 0 条 (总 33 条) + 📡 Defense One: 0 条 (总 25 条) + 📡 Seapower: 0 条 (总 10 条) + 📡 90号茶室: 0 条 (总 30 条) + 📡 安全圈: 0 条 (总 30 条) + 📡 NEWUAS: 0 条 (总 30 条) + 📡 兵路文化: 0 条 (总 11 条) + 📡 白马 V 视角: 0 条 (总 30 条) + 📡 补三刀: 0 条 (总 24 条) + 📡 半导体行业观察: 0 条 (总 30 条) + 📡 艾瑞咨询: 0 条 (总 30 条) + 📡 北大纵横: 0 条 (总 30 条) + 📡 安全内参: 0 条 (总 30 条) + 📡 IPP评论: 0 条 (总 30 条) + 📡 参考消息: 0 条 (总 30 条) + 📡 补壹刀: 0 条 (总 30 条) + 📡 参考消息智库: 0 条 (总 30 条) + 📡 纯科学: 0 条 (总 28 条) + 📡 大浪淘沙: 0 条 (总 30 条) + 📡 从心不逾矩的防务菌: 0 条 (总 30 条) + 📡 大柳树防务: 0 条 (总 30 条) + 📡 电波之矛: 0 条 (总 30 条) + 📡 大湾区评论: 0 条 (总 30 条) + 📡 动态大参考: 0 条 (总 30 条) + 📡 东南亚问题研究: 0 条 (总 30 条) + 📡 防务快讯: 0 条 (总 30 条) + 📡 防务指南: 0 条 (总 16 条) + 📡 浮空飞行器: 0 条 (总 30 条) + 📡 复旦大学中国研究院: 0 条 (总 30 条) + 📡 阜成门六号院: 0 条 (总 25 条) + 📡 凤凰网军事频道: 0 条 (总 30 条) + 📡 观察者网: 0 条 (总 30 条) + 📡 光明军事: 0 条 (总 30 条) + 📡 国观智库: 0 条 (总 30 条) + 📡 国防科技要闻: 0 条 (总 30 条) + 📡 国防时报排头兵: 0 条 (总 30 条) + 📡 国科环宇: 0 条 (总 18 条) + 📡 国防工业出版社: 0 条 (总 30 条) + 📡 国际法务: 0 条 (总 30 条) + 📡 海洋防务前沿: 0 条 (总 30 条) + 📡 航小宇: 0 条 (总 30 条) + 📡 环时枢密院十号: 0 条 (总 30 条) + 📡 黑天鹅商业情报站: 0 条 (总 30 条) + 📡 环太国际战略: 0 条 (总 23 条) + 📡 海鹰资讯: 0 条 (总 30 条) + 📡 华山穹剑: 0 条 (总 30 条) + 📡 华语智库: 0 条 (总 30 条) + 📡 混沌巡洋舰: 0 条 (总 30 条) + 📡 胡锡进观察: 0 条 (总 30 条) + 📡 解放军报: 0 条 (总 30 条) + 📡 今日台湾: 0 条 (总 30 条) + 📡 精确打击洞见: 0 条 (总 30 条) + 📡 军事高科技在线: 0 条 (总 30 条) + 📡 军事文摘: 0 条 (总 30 条) + 📡 军民融合观察: 0 条 (总 30 条) + 📡 军鹰动态: 0 条 (总 30 条) + 📡 科技导报: 0 条 (总 30 条) + 📡 跨境前沿: 0 条 (总 30 条) + 📡 空天大视野: 0 条 (总 30 条) + 📡 军武次位面: 0 条 (总 30 条) + 📡 空天界: 0 条 (总 30 条) + 📡 六爷阿旦: 2 条 (总 30 条) + 📡 砺剑: 0 条 (总 30 条) + 📡 李光满说: 0 条 (总 30 条) + 📡 蓝血研究: 0 条 (总 30 条) + 📡 瞭望智库: 0 条 (总 30 条) + 📡 龙牙的一座山: 0 条 (总 30 条) + 📡 米尔观天下: 0 条 (总 30 条) + 📡 迷彩虎观察: 0 条 (总 30 条) + 📡 美国驻华大使馆: 0 条 (总 30 条) + 📡 梅特涅的信徒: 0 条 (总 30 条) + 📡 明叔杂谈: 0 条 (总 30 条) + 📡 澎湃新闻: 0 条 (总 30 条) + 📡 前沿深度解码: 0 条 (总 30 条) + 📡 秦安战略: 0 条 (总 30 条) + 📡 破圈了: 0 条 (总 30 条) + 📡 奇安网情局: 0 条 (总 30 条) + 📡 欧亚新观察: 0 条 (总 30 条) + 📡 盘古智库: 0 条 (总 30 条) + 📡 全球技术地图: 0 条 (总 30 条) + 📡 牛弹琴: 0 条 (总 30 条) + 📡 人大重阳: 0 条 (总 30 条) + 📡 人民日报: 0 条 (总 30 条) + 📡 南方周末: 0 条 (总 30 条) + 📡 三剑客: 0 条 (总 30 条) + 📡 上海国际问题研究院: 0 条 (总 30 条) + 📡 盛唐如松: 0 条 (总 30 条) + 📡 施展世界: 0 条 (总 24 条) + 📡 书剑杂谈: 0 条 (总 30 条) + 📡 戎评: 0 条 (总 30 条) + 📡 搜狐科技: 0 条 (总 30 条) + 📡 数字孪生战场: 0 条 (总 30 条) + 📡 望穹科技: 0 条 (总 30 条) + 📡 外军防务研究前沿: 0 条 (总 30 条) + 📡 网信前沿观察: 0 条 (总 30 条) + 📡 时政国关分析: 0 条 (总 30 条) + 📡 太空与网络: 0 条 (总 30 条) + 📡 沃德舆情观察: 0 条 (总 30 条) + 📡 无人机邦: 0 条 (总 30 条) + 📡 新华国际头条: 0 条 (总 30 条) + 📡 侠客岛: 0 条 (总 30 条) + 📡 无人争锋: 0 条 (总 30 条) + 📡 无人机反制: 0 条 (总 30 条) + 📡 乌鸦校尉: 0 条 (总 30 条) + 📡 喜乔智研: 0 条 (总 30 条) + 📡 央广军事: 0 条 (总 30 条) + 📡 一个坏土豆: 0 条 (总 30 条) + 📡 亚太安全与海洋研究: 0 条 (总 19 条) + 📡 渊亭防务: 0 条 (总 30 条) + 📡 一号哨位: 0 条 (总 30 条) + 📡 战略纵横家: 0 条 (总 30 条) + 📡 舆情文摘: 0 条 (总 30 条) + 📡 占豪: 0 条 (总 30 条) + 📡 泽平宏观: 0 条 (总 30 条) + 📡 远望智库: 0 条 (总 30 条) + 📡 职场老校尉: 0 条 (总 10 条) + 📡 中国国防报: 0 条 (总 30 条) + 📡 战略前沿技术: 0 条 (总 30 条) + 📡 知远战略与防务研究所: 0 条 (总 30 条) + 📡 智强战略咨询: 0 条 (总 30 条) + 📡 中国舰船: 0 条 (总 30 条) + 📡 占知智库: 0 条 (总 30 条) + 📡 正和岛: 0 条 (总 30 条) + 📡 智源社区: 0 条 (总 30 条) + 📡 中国现代国际关系研究院: 0 条 (总 30 条) + 📡 中国南海研究院: 0 条 (总 25 条) + 📡 中国周边安全研究中心: 0 条 (总 30 条) + 📡 中国指挥与控制学会: 0 条 (总 30 条) + 📡 中国舆论场: 0 条 (总 30 条) + 📡 装备参考: 0 条 (总 30 条) + 📡 最黑科技: 0 条 (总 30 条) + 📥 抓取到 2 条原始消息 + 🆕 新消息 0 条 | 缓存命中: 2 | 已有链接: 11 +06:36:11 [INFO] +================================================================ + 📊 运行监控报告 +================================================================ + +── ⏱ 阶段耗时 ── + RSS抓取      57.9s + 去重         0.0s + ──────────────────── + 合计         57.9s / 总运行 58s + +── 📡 文章统计 ── + 总计: 2 缓存命中: 2 本次处理: 0 + 缓存命中率: 100% + +── 🤖 API调用统计 ── + 无API调用(全部命中缓存) + +── 📰 源抓取统计 ── + 源总数: 131 成功: 131 失败: 0 + +================================================================ + ⏰ 下次抓取: 29.0分钟后 + +================================================== +🔄 Fetch Job | 2026-07-01 07:05:13 +================================================== + 📂 共 131 个订阅源 | 时间窗口: 60 分钟 + 📡 The War Zone: 0 条 (总 33 条) + 📡 Defense One: 0 条 (总 25 条) + 📡 Seapower: 0 条 (总 10 条) + 📡 NEWUAS: 0 条 (总 30 条) + 📡 兵路文化: 0 条 (总 11 条) + 📡 安全圈: 0 条 (总 30 条) + 📡 白马 V 视角: 0 条 (总 30 条) + 📡 补三刀: 0 条 (总 24 条) + 📡 半导体行业观察: 0 条 (总 30 条) + 📡 90号茶室: 0 条 (总 30 条) + 📡 艾瑞咨询: 0 条 (总 30 条) + 📡 北大纵横: 0 条 (总 30 条) + 📡 补壹刀: 0 条 (总 30 条) + 📡 安全内参: 0 条 (总 30 条) + 📡 参考消息: 0 条 (总 30 条) + 📡 参考消息智库: 0 条 (总 30 条) + 📡 IPP评论: 0 条 (总 30 条) + 📡 纯科学: 0 条 (总 28 条) + 📡 大柳树防务: 0 条 (总 30 条) + 📡 大浪淘沙: 0 条 (总 30 条) + 📡 从心不逾矩的防务菌: 0 条 (总 30 条) + 📡 大湾区评论: 0 条 (总 30 条) + 📡 东南亚问题研究: 0 条 (总 30 条) + 📡 动态大参考: 0 条 (总 30 条) + 📡 电波之矛: 0 条 (总 30 条) + 📡 阜成门六号院: 0 条 (总 25 条) + 📡 防务快讯: 0 条 (总 30 条) + 📡 复旦大学中国研究院: 0 条 (总 30 条) + 📡 浮空飞行器: 0 条 (总 30 条) + 📡 防务指南: 0 条 (总 16 条) + 📡 观察者网: 0 条 (总 30 条) + 📡 国防科技要闻: 0 条 (总 30 条) + 📡 光明军事: 0 条 (总 30 条) + 📡 国观智库: 0 条 (总 30 条) + 📡 国防工业出版社: 0 条 (总 30 条) + 📡 国防时报排头兵: 0 条 (总 30 条) + 📡 国科环宇: 0 条 (总 18 条) + 📡 航小宇: 0 条 (总 30 条) + 📡 凤凰网军事频道: 0 条 (总 30 条) + 📡 环时枢密院十号: 0 条 (总 30 条) + 📡 国际法务: 0 条 (总 30 条) + 📡 海洋防务前沿: 0 条 (总 30 条) + 📡 华语智库: 0 条 (总 30 条) + 📡 黑天鹅商业情报站: 0 条 (总 30 条) + 📡 海鹰资讯: 0 条 (总 30 条) + 📡 华山穹剑: 0 条 (总 30 条) + 📡 环太国际战略: 0 条 (总 23 条) + 📡 胡锡进观察: 0 条 (总 30 条) + 📡 混沌巡洋舰: 0 条 (总 30 条) + 📡 解放军报: 0 条 (总 30 条) + 📡 精确打击洞见: 0 条 (总 30 条) + 📡 军民融合观察: 0 条 (总 30 条) + 📡 今日台湾: 0 条 (总 30 条) + 📡 军事文摘: 0 条 (总 30 条) + 📡 军事高科技在线: 0 条 (总 30 条) + 📡 跨境前沿: 0 条 (总 30 条) + 📡 军鹰动态: 0 条 (总 30 条) + 📡 科技导报: 0 条 (总 30 条) + 📡 空天界: 0 条 (总 30 条) + 📡 李光满说: 0 条 (总 30 条) + 📡 空天大视野: 0 条 (总 30 条) + 📡 六爷阿旦: 2 条 (总 30 条) + 📡 砺剑: 0 条 (总 30 条) + 📡 瞭望智库: 0 条 (总 30 条) + 📡 军武次位面: 0 条 (总 30 条) + 📡 龙牙的一座山: 0 条 (总 30 条) + 📡 蓝血研究: 0 条 (总 30 条) + 📡 迷彩虎观察: 0 条 (总 30 条) + 📡 美国驻华大使馆: 0 条 (总 30 条) + 📡 明叔杂谈: 0 条 (总 30 条) + 📡 米尔观天下: 0 条 (总 30 条) + 📡 梅特涅的信徒: 0 条 (总 30 条) + 📡 秦安战略: 0 条 (总 30 条) + 📡 澎湃新闻: 0 条 (总 30 条) + 📡 前沿深度解码: 0 条 (总 30 条) + 📡 欧亚新观察: 0 条 (总 30 条) + 📡 破圈了: 0 条 (总 30 条) + 📡 奇安网情局: 0 条 (总 30 条) + 📡 全球技术地图: 0 条 (总 30 条) + 📡 盘古智库: 0 条 (总 30 条) + 📡 牛弹琴: 0 条 (总 30 条) + 📡 人民日报: 0 条 (总 30 条) + 📡 三剑客: 0 条 (总 30 条) + 📡 人大重阳: 0 条 (总 30 条) + 📡 盛唐如松: 0 条 (总 30 条) + 📡 上海国际问题研究院: 0 条 (总 30 条) + 📡 南方周末: 0 条 (总 30 条) + 📡 戎评: 0 条 (总 30 条) + 📡 施展世界: 0 条 (总 24 条) + 📡 书剑杂谈: 0 条 (总 30 条) + 📡 时政国关分析: 0 条 (总 30 条) + 📡 数字孪生战场: 0 条 (总 30 条) + 📡 外军防务研究前沿: 0 条 (总 30 条) + 📡 望穹科技: 0 条 (总 30 条) + 📡 搜狐科技: 0 条 (总 30 条) + 📡 网信前沿观察: 0 条 (总 30 条) + 📡 太空与网络: 0 条 (总 30 条) + 📡 新华国际头条: 0 条 (总 30 条) + 📡 无人机邦: 0 条 (总 30 条) + 📡 侠客岛: 0 条 (总 30 条) + 📡 沃德舆情观察: 0 条 (总 30 条) + 📡 喜乔智研: 0 条 (总 30 条) + 📡 无人争锋: 0 条 (总 30 条) + 📡 无人机反制: 0 条 (总 30 条) + 📡 乌鸦校尉: 0 条 (总 30 条) + 📡 央广军事: 0 条 (总 30 条) + 📡 一个坏土豆: 0 条 (总 30 条) + 📡 亚太安全与海洋研究: 0 条 (总 19 条) + 📡 一号哨位: 0 条 (总 30 条) + 📡 舆情文摘: 0 条 (总 30 条) + 📡 渊亭防务: 0 条 (总 30 条) + 📡 战略纵横家: 0 条 (总 30 条) + 📡 占豪: 0 条 (总 30 条) + 📡 泽平宏观: 0 条 (总 30 条) + 📡 远望智库: 0 条 (总 30 条) + 📡 战略前沿技术: 0 条 (总 30 条) + 📡 职场老校尉: 0 条 (总 10 条) + 📡 智强战略咨询: 0 条 (总 30 条) + 📡 知远战略与防务研究所: 0 条 (总 30 条) + 📡 正和岛: 0 条 (总 30 条) + 📡 智源社区: 0 条 (总 30 条) + 📡 中国国防报: 0 条 (总 30 条) + 📡 中国舰船: 0 条 (总 30 条) + 📡 占知智库: 0 条 (总 30 条) + 📡 中国现代国际关系研究院: 0 条 (总 30 条) + 📡 中国南海研究院: 0 条 (总 25 条) + 📡 中国周边安全研究中心: 0 条 (总 30 条) + 📡 中国舆论场: 0 条 (总 30 条) + 📡 中国指挥与控制学会: 0 条 (总 30 条) + 📡 最黑科技: 0 条 (总 30 条) + 📡 装备参考: 0 条 (总 30 条) + 📥 抓取到 2 条原始消息 + 🆕 新消息 0 条 | 缓存命中: 2 | 已有链接: 11 +07:06:15 [INFO] +================================================================ + 📊 运行监控报告 +================================================================ + +── ⏱ 阶段耗时 ── + RSS抓取      61.5s + 去重         0.0s + ──────────────────── + 合计         61.5s / 总运行 62s + +── 📡 文章统计 ── + 总计: 2 缓存命中: 2 本次处理: 0 + 缓存命中率: 100% + +── 🤖 API调用统计 ── + 无API调用(全部命中缓存) + +── 📰 源抓取统计 ── + 源总数: 131 成功: 131 失败: 0 + +================================================================ + ⏰ 下次抓取: 29.0分钟后 + +================================================== +🔄 Fetch Job | 2026-07-01 07:35:13 +================================================== + 📂 共 131 个订阅源 | 时间窗口: 60 分钟 + 📡 The War Zone: 0 条 (总 33 条) + 📡 Seapower: 0 条 (总 10 条) + 📡 Defense One: 0 条 (总 25 条) + 📡 兵路文化: 0 条 (总 11 条) + 📡 NEWUAS: 0 条 (总 30 条) + 📡 安全圈: 0 条 (总 30 条) + 📡 白马 V 视角: 0 条 (总 30 条) + 📡 半导体行业观察: 0 条 (总 30 条) + 📡 补三刀: 0 条 (总 24 条) + 📡 参考消息: 0 条 (总 30 条) + 📡 东南亚问题研究: 0 条 (总 30 条) + 📡 电波之矛: 0 条 (总 30 条) + 📡 补壹刀: 0 条 (总 30 条) + 📡 参考消息智库: 0 条 (总 30 条) + 📡 动态大参考: 0 条 (总 30 条) + 📡 大柳树防务: 0 条 (总 30 条) + 📡 大浪淘沙: 0 条 (总 30 条) + 📡 阜成门六号院: 0 条 (总 25 条) + 📡 纯科学: 0 条 (总 28 条) + 📡 防务快讯: 0 条 (总 30 条) + 📡 复旦大学中国研究院: 0 条 (总 30 条) + 📡 防务指南: 0 条 (总 16 条) + 📡 浮空飞行器: 0 条 (总 30 条) + 📡 观察者网: 0 条 (总 30 条) + 📡 国防科技要闻: 0 条 (总 30 条) + 📡 大湾区评论: 0 条 (总 30 条) + 📡 从心不逾矩的防务菌: 0 条 (总 30 条) + 📡 国观智库: 0 条 (总 30 条) + 📡 国科环宇: 0 条 (总 18 条) + 📡 国防工业出版社: 0 条 (总 30 条) + 📡 光明军事: 0 条 (总 30 条) + 📡 环时枢密院十号: 0 条 (总 30 条) + 📡 国防时报排头兵: 0 条 (总 30 条) + 📡 航小宇: 0 条 (总 30 条) + 📡 华语智库: 0 条 (总 30 条) + 📡 华山穹剑: 0 条 (总 30 条) + 📡 黑天鹅商业情报站: 0 条 (总 30 条) + 📡 海洋防务前沿: 0 条 (总 30 条) + 📡 国际法务: 0 条 (总 30 条) + 📡 环太国际战略: 0 条 (总 23 条) + 📡 胡锡进观察: 0 条 (总 30 条) + 📡 今日台湾: 0 条 (总 30 条) + 📡 精确打击洞见: 0 条 (总 30 条) + 📡 解放军报: 0 条 (总 30 条) + 📡 海鹰资讯: 0 条 (总 30 条) + 📡 凤凰网军事频道: 0 条 (总 30 条) + 📡 混沌巡洋舰: 0 条 (总 30 条) + 📡 军事文摘: 0 条 (总 30 条) + 📡 军事高科技在线: 0 条 (总 30 条) + 📡 跨境前沿: 0 条 (总 30 条) + 📡 军鹰动态: 0 条 (总 30 条) + 📡 李光满说: 0 条 (总 30 条) + 📡 科技导报: 0 条 (总 30 条) + 📡 六爷阿旦: 2 条 (总 30 条) + 📡 军民融合观察: 0 条 (总 30 条) + 📡 瞭望智库: 0 条 (总 30 条) + 📡 空天界: 0 条 (总 30 条) + 📡 空天大视野: 0 条 (总 30 条) + 📡 砺剑: 0 条 (总 30 条) + 📡 迷彩虎观察: 0 条 (总 30 条) + 📡 米尔观天下: 0 条 (总 30 条) + 📡 明叔杂谈: 0 条 (总 30 条) + 📡 龙牙的一座山: 0 条 (总 30 条) + 📡 美国驻华大使馆: 0 条 (总 30 条) + 📡 蓝血研究: 0 条 (总 30 条) + 📡 军武次位面: 0 条 (总 30 条) + 📡 澎湃新闻: 0 条 (总 30 条) + 📡 秦安战略: 0 条 (总 30 条) + 📡 破圈了: 0 条 (总 30 条) + 📡 前沿深度解码: 0 条 (总 30 条) + 📡 奇安网情局: 0 条 (总 30 条) + 📡 欧亚新观察: 0 条 (总 30 条) + 📡 盘古智库: 0 条 (总 30 条) + 📡 全球技术地图: 0 条 (总 30 条) + 📡 盛唐如松: 0 条 (总 30 条) + 📡 人民日报: 0 条 (总 30 条) + 📡 上海国际问题研究院: 0 条 (总 30 条) + 📡 三剑客: 0 条 (总 30 条) + 📡 牛弹琴: 0 条 (总 30 条) + 📡 梅特涅的信徒: 0 条 (总 30 条) + 📡 人大重阳: 0 条 (总 30 条) + 📡 戎评: 0 条 (总 30 条) + 📡 南方周末: 0 条 (总 30 条) + 📡 书剑杂谈: 0 条 (总 30 条) + 📡 外军防务研究前沿: 0 条 (总 30 条) + 📡 望穹科技: 0 条 (总 30 条) + 📡 无人机邦: 0 条 (总 30 条) + 📡 搜狐科技: 0 条 (总 30 条) + 📡 施展世界: 0 条 (总 24 条) + 📡 数字孪生战场: 0 条 (总 30 条) + 📡 沃德舆情观察: 0 条 (总 30 条) + 📡 时政国关分析: 0 条 (总 30 条) + 📡 喜乔智研: 0 条 (总 30 条) + 📡 新华国际头条: 0 条 (总 30 条) + 📡 网信前沿观察: 0 条 (总 30 条) + 📡 太空与网络: 0 条 (总 30 条) + 📡 侠客岛: 0 条 (总 30 条) + 📡 无人争锋: 0 条 (总 30 条) + 📡 无人机反制: 0 条 (总 30 条) + 📡 央广军事: 0 条 (总 30 条) + 📡 乌鸦校尉: 0 条 (总 30 条) + 📡 舆情文摘: 0 条 (总 30 条) + 📡 渊亭防务: 0 条 (总 30 条) + 📡 占豪: 0 条 (总 30 条) + 📡 战略纵横家: 0 条 (总 30 条) + 📡 泽平宏观: 0 条 (总 30 条) + 📡 职场老校尉: 0 条 (总 10 条) + 📡 一个坏土豆: 0 条 (总 30 条) + 📡 亚太安全与海洋研究: 0 条 (总 19 条) + 📡 一号哨位: 0 条 (总 30 条) + 📡 中国国防报: 0 条 (总 30 条) + 📡 知远战略与防务研究所: 0 条 (总 30 条) + 📡 智强战略咨询: 0 条 (总 30 条) + 📡 中国舰船: 0 条 (总 30 条) + 📡 占知智库: 0 条 (总 30 条) + 📡 远望智库: 0 条 (总 30 条) + 📡 中国南海研究院: 0 条 (总 25 条) + 📡 中国现代国际关系研究院: 0 条 (总 30 条) + 📡 战略前沿技术: 0 条 (总 30 条) + 📡 智源社区: 0 条 (总 30 条) + 📡 中国周边安全研究中心: 0 条 (总 30 条) + 📡 中国舆论场: 0 条 (总 30 条) + 📡 正和岛: 0 条 (总 30 条) + 📡 中国指挥与控制学会: 0 条 (总 30 条) + 📡 装备参考: 0 条 (总 30 条) + 📡 最黑科技: 0 条 (总 30 条) + ⚠️ 获取失败 90号茶室: + ⚠️ 获取失败 IPP评论: + ⚠️ 获取失败 艾瑞咨询: + ⚠️ 获取失败 安全内参: + ⚠️ 获取失败 北大纵横: + 📥 抓取到 2 条原始消息 + 🆕 新消息 0 条 | 缓存命中: 2 | 已有链接: 11 +07:37:18 [INFO] +================================================================ + 📊 运行监控报告 +================================================================ + +── ⏱ 阶段耗时 ── + RSS抓取      125.0s + 去重         0.0s + ──────────────────── + 合计         125.0s / 总运行 125s + +── 📡 文章统计 ── + 总计: 2 缓存命中: 2 本次处理: 0 + 缓存命中率: 100% + +── 🤖 API调用统计 ── + 无API调用(全部命中缓存) + +── 📰 源抓取统计 ── + 源总数: 131 成功: 126 失败: 5 + 失败源: 90号茶室, IPP评论, 艾瑞咨询, 安全内参, 北大纵横 + +================================================================ + ⏰ 下次抓取: 27.9分钟后 + 📤 执行推送: 2026-07-01 08:00:01 + +================================================== +📤 Push Job | 2026-07-01 08:00:01 +================================================== +08:00:01 [INFO] [热度阈值] 均值=43.2 σ=5.3 ×1.5 原始=51.2 clamp=[35,80] → 最终=51.2 + 📝 簇2 注入前情: 五角大楼通过'War Force'项目招募两年制技术人才,提供参与政策制定和国家... + 🔄 簇5防退化重试(1/2): 违规1项 + 🔄 簇5防退化重试(2/2): 违规2项 + 📝 簇0 注入前情: 美国陆军在太平洋测试AI与无人艇协同后勤,验证自主航行与智能调度技术,以支持分布... + 🔄 簇0防退化重试(1/2): 违规1项 + ⚠️ 簇0洞察失败: 洞察 JSON 解析失败: Expecting ',' delimiter: line 13 column 530 (char 787) + 🔄 簇4防退化重试(1/2): 违规1项 + 🔄 簇4防退化重试(2/2): 违规2项 + 🔍 洞察生成完成 +08:02:05 [INFO] [热度阈值] 均值=43.2 σ=5.3 ×1.5 原始=51.2 clamp=[35,80] → 最终=51.2 +08:02:05 [INFO] [热度阈值] 均值=43.2 σ=5.3 ×1.5 原始=51.2 clamp=[35,80] → 最终=51.2 +08:02:05 [INFO] [热度阈值] 均值=43.2 σ=5.3 ×1.5 原始=51.2 clamp=[35,80] → 最终=51.2 + ✅ 已推送到 feishu + 💾 已保存热点速览到 data/cluster-20260701-080001.md + 📤 簇视图推送完成 +08:02:05 [INFO] ━━━ 网摘缓存策略启用 ━━━ +08:02:05 [INFO] ━━━ 网摘缓存策略启用 ━━━ +08:02:05 [INFO] ━━━ 网摘缓存策略启用 ━━━ +08:02:14 [INFO] 尝试1 结果: 正文✓(311字) | 价值点✓(43字) +08:02:14 [INFO] → 缓存正文 (尝试1, 311字) ✓ +08:02:14 [INFO] → 缓存价值点 (尝试1, 43字) ✓ +08:02:14 [INFO] 尝试1 双达标,直接返回 +08:02:14 [INFO] 尝试1 结果: 正文✓(324字) | 价值点✗(52字) +08:02:14 [INFO] → 缓存正文 (尝试1, 324字) ✓ +08:02:14 [INFO] 尝试2 开始前缓存: 正文已缓存(尝试1, 324字) +08:02:18 [INFO] 尝试1 结果: 正文✗(237字) | 价值点✗(59字) +08:02:25 [INFO] 尝试2 结果: 正文✓(277字) | 价值点✗(52字) +08:02:25 [INFO] 尝试3 开始前缓存: 正文已缓存(尝试1, 324字) +08:02:31 [INFO] 尝试3 结果: 正文✓(253字) | 价值点✓(43字) +08:02:31 [INFO] → 缓存价值点 (尝试3, 43字) ✓ +08:02:31 [INFO] 尝试3 双达标,直接返回 +08:02:37 [INFO] 尝试2 结果: 正文✗(379字) | 价值点✓(43字) +08:02:37 [INFO] → 缓存价值点 (尝试2, 43字) ✓ +08:02:37 [INFO] 尝试3 开始前缓存: 价值点已缓存(尝试2, 43字) +08:02:46 [INFO] 尝试3 结果: 正文✗(379字) | 价值点✓(43字) +08:02:46 [INFO] ─── 合并结果 ─── +08:02:46 [INFO] 价值点←尝试2(43字) | +08:02:46 [INFO] 最终: 正文379字(✗) | 价值点43字(✓达标) + 📝 网摘生成完成: 3 篇 +08:02:46 [INFO] [图片生成] 从JSON字符串生成单篇网摘图片,输出路径: data\images\20260701\webzine_1_20260701_080001.png +08:02:46 [INFO] [图片生成] 开始生成单篇网摘图片,输出路径: data\images\20260701\webzine_1_20260701_080001.png +08:02:47 [INFO] [图片生成] 网摘图片已保存: data\images\20260701\webzine_1_20260701_080001.png (命令数: 25, 高度: 1426px) + ├─ 单篇图片 1: webzine_1_20260701_080001.png +08:02:47 [INFO] [图片生成] 从JSON字符串生成单篇网摘图片,输出路径: data\images\20260701\webzine_2_20260701_080001.png +08:02:47 [INFO] [图片生成] 开始生成单篇网摘图片,输出路径: data\images\20260701\webzine_2_20260701_080001.png +08:02:47 [INFO] [图片生成] 网摘图片已保存: data\images\20260701\webzine_2_20260701_080001.png (命令数: 29, 高度: 1609px) + ├─ 单篇图片 2: webzine_2_20260701_080001.png +08:02:47 [INFO] [图片生成] 从JSON字符串生成单篇网摘图片,输出路径: data\images\20260701\webzine_3_20260701_080001.png +08:02:47 [INFO] [图片生成] 开始生成单篇网摘图片,输出路径: data\images\20260701\webzine_3_20260701_080001.png +08:02:47 [INFO] [图片生成] 网摘图片已保存: data\images\20260701\webzine_3_20260701_080001.png (命令数: 22, 高度: 1288px) + ├─ 单篇图片 3: webzine_3_20260701_080001.png +08:02:47 [INFO] [图片生成] 从JSON字符串列表生成合并网摘长图,共 3 篇,输出路径: data\images\20260701\webzine_combined_20260701_080001.png +08:02:47 [INFO] [图片生成] 开始生成合并网摘长图,共 3 篇,输出路径: data\images\20260701\webzine_combined_20260701_080001.png +08:02:47 [INFO] [图片生成] 合并长图已保存: data\images\20260701\webzine_combined_20260701_080001.png (命令数: 78, 高度: 4347px) + └─ 合并长图: webzine_combined_20260701_080001.png + 🖼️ 网摘图片生成完成: 4 张 + 📋 分类介绍: 2 个分类 + ✅ 已推送到 feishu + 💾 已保存每日精选到 data/digest-20260701-080001.md + 📤 单篇视图推送完成 + 🖼️ 推送合并网摘图片... + ✓ 图片上传成功: https://cloudimgs.yynnice.top/api/images/daily/webzine_combined_20260701_080001.png + 📝 图片链接消息已发送 + ✅ 图片已推送到 feishu + 📤 网摘图片推送完成 +08:02:51 [INFO] +================================================================ + 📊 运行监控报告 +================================================================ + +── ⏱ 阶段耗时 ── + 网摘生成       41.2s + 分类介绍       0.0s + ──────────────────── + 合计         41.2s / 总运行 170s + +── 📡 文章统计 ── + 总计: 0 缓存命中: 0 本次处理: 0 + +── 🤖 API调用统计 ── + 无API调用(全部命中缓存) + +── 📰 源抓取统计 ── + 源总数: 0 成功: 0 失败: 0 + +================================================================ + ⏰ 下次推送: 2026-07-02 08:00:00 (等待 1437.1 分钟) + +================================================== +🔄 Fetch Job | 2026-07-01 08:05:13 +================================================== + 📂 共 131 个订阅源 | 时间窗口: 60 分钟 + 📡 The War Zone: 0 条 (总 33 条) + 📡 Seapower: 0 条 (总 10 条) + 📡 Defense One: 1 条 (总 25 条) + 📡 艾瑞咨询: 0 条 (总 30 条) + 📡 NEWUAS: 0 条 (总 30 条) + 📡 安全圈: 0 条 (总 30 条) + 📡 兵路文化: 0 条 (总 11 条) + 📡 半导体行业观察: 0 条 (总 30 条) + 📡 补三刀: 0 条 (总 24 条) + 📡 90号茶室: 0 条 (总 30 条) + 📡 参考消息: 0 条 (总 30 条) + 📡 北大纵横: 0 条 (总 30 条) + 📡 参考消息智库: 0 条 (总 30 条) + 📡 补壹刀: 0 条 (总 30 条) + 📡 安全内参: 0 条 (总 30 条) + 📡 IPP评论: 0 条 (总 30 条) + 📡 大柳树防务: 0 条 (总 30 条) + 📡 大浪淘沙: 0 条 (总 30 条) + 📡 纯科学: 0 条 (总 28 条) + 📡 白马 V 视角: 0 条 (总 30 条) + 📡 从心不逾矩的防务菌: 0 条 (总 30 条) + 📡 大湾区评论: 0 条 (总 30 条) + 📡 东南亚问题研究: 0 条 (总 30 条) + 📡 电波之矛: 0 条 (总 30 条) + 📡 阜成门六号院: 0 条 (总 25 条) + 📡 复旦大学中国研究院: 0 条 (总 30 条) + 📡 防务快讯: 0 条 (总 30 条) + 📡 防务指南: 0 条 (总 16 条) + 📡 动态大参考: 0 条 (总 30 条) + 📡 浮空飞行器: 0 条 (总 30 条) + 📡 凤凰网军事频道: 0 条 (总 30 条) + 📡 国防工业出版社: 0 条 (总 30 条) + 📡 国科环宇: 0 条 (总 18 条) + 📡 国防科技要闻: 0 条 (总 30 条) + 📡 国观智库: 0 条 (总 30 条) + 📡 观察者网: 0 条 (总 30 条) + 📡 航小宇: 0 条 (总 30 条) + 📡 国防时报排头兵: 0 条 (总 30 条) + 📡 环时枢密院十号: 0 条 (总 30 条) + 📡 光明军事: 0 条 (总 30 条) + 📡 华山穹剑: 0 条 (总 30 条) + 📡 海洋防务前沿: 0 条 (总 30 条) + 📡 环太国际战略: 0 条 (总 23 条) + 📡 黑天鹅商业情报站: 0 条 (总 30 条) + 📡 国际法务: 0 条 (总 30 条) + 📡 华语智库: 0 条 (总 30 条) + 📡 海鹰资讯: 0 条 (总 30 条) + 📡 胡锡进观察: 0 条 (总 30 条) + 📡 混沌巡洋舰: 0 条 (总 30 条) + 📡 解放军报: 2 条 (总 30 条) + 📡 精确打击洞见: 0 条 (总 30 条) + 📡 今日台湾: 0 条 (总 30 条) + 📡 军事高科技在线: 0 条 (总 30 条) + 📡 军民融合观察: 0 条 (总 30 条) + 📡 跨境前沿: 0 条 (总 30 条) + 📡 军事文摘: 0 条 (总 30 条) + 📡 李光满说: 0 条 (总 30 条) + 📡 军武次位面: 0 条 (总 30 条) + 📡 砺剑: 0 条 (总 30 条) + 📡 瞭望智库: 0 条 (总 30 条) + 📡 科技导报: 0 条 (总 30 条) + 📡 军鹰动态: 0 条 (总 30 条) + 📡 空天大视野: 0 条 (总 30 条) + 📡 空天界: 0 条 (总 30 条) + 📡 六爷阿旦: 2 条 (总 30 条) + 📡 蓝血研究: 0 条 (总 30 条) + 📡 龙牙的一座山: 0 条 (总 30 条) + 📡 美国驻华大使馆: 0 条 (总 30 条) + 📡 迷彩虎观察: 0 条 (总 30 条) + 📡 明叔杂谈: 0 条 (总 30 条) + 📡 米尔观天下: 0 条 (总 30 条) + 📡 梅特涅的信徒: 0 条 (总 30 条) + 📡 澎湃新闻: 0 条 (总 30 条) + 📡 奇安网情局: 0 条 (总 30 条) + 📡 前沿深度解码: 0 条 (总 30 条) + 📡 破圈了: 0 条 (总 30 条) + 📡 秦安战略: 0 条 (总 30 条) + 📡 欧亚新观察: 0 条 (总 30 条) + 📡 盘古智库: 0 条 (总 30 条) + 📡 全球技术地图: 0 条 (总 30 条) + 📡 人大重阳: 0 条 (总 30 条) + 📡 三剑客: 0 条 (总 30 条) + 📡 盛唐如松: 0 条 (总 30 条) + 📡 牛弹琴: 0 条 (总 30 条) + 📡 上海国际问题研究院: 0 条 (总 30 条) + 📡 人民日报: 0 条 (总 30 条) + 📡 南方周末: 0 条 (总 30 条) + 📡 戎评: 0 条 (总 30 条) + 📡 施展世界: 0 条 (总 24 条) + 📡 时政国关分析: 0 条 (总 30 条) + 📡 书剑杂谈: 0 条 (总 30 条) + 📡 搜狐科技: 0 条 (总 30 条) + 📡 望穹科技: 0 条 (总 30 条) + 📡 数字孪生战场: 0 条 (总 30 条) + 📡 外军防务研究前沿: 0 条 (总 30 条) + 📡 无人机邦: 0 条 (总 30 条) + 📡 网信前沿观察: 0 条 (总 30 条) + 📡 太空与网络: 0 条 (总 30 条) + 📡 沃德舆情观察: 0 条 (总 30 条) + 📡 新华国际头条: 0 条 (总 30 条) + 📡 喜乔智研: 0 条 (总 30 条) + 📡 侠客岛: 0 条 (总 30 条) + 📡 无人争锋: 0 条 (总 30 条) + 📡 央广军事: 0 条 (总 30 条) + 📡 无人机反制: 0 条 (总 30 条) + 📡 乌鸦校尉: 0 条 (总 30 条) + 📡 舆情文摘: 0 条 (总 30 条) + 📡 渊亭防务: 0 条 (总 30 条) + 📡 一个坏土豆: 0 条 (总 30 条) + 📡 亚太安全与海洋研究: 0 条 (总 19 条) + 📡 泽平宏观: 0 条 (总 30 条) + 📡 占豪: 0 条 (总 30 条) + 📡 战略纵横家: 0 条 (总 30 条) + 📡 一号哨位: 0 条 (总 30 条) + 📡 远望智库: 0 条 (总 30 条) + 📡 战略前沿技术: 0 条 (总 30 条) + 📡 职场老校尉: 0 条 (总 10 条) + 📡 智强战略咨询: 0 条 (总 30 条) + 📡 知远战略与防务研究所: 0 条 (总 30 条) + 📡 占知智库: 0 条 (总 30 条) + 📡 正和岛: 0 条 (总 30 条) + 📡 中国国防报: 0 条 (总 30 条) + 📡 中国舰船: 0 条 (总 30 条) + 📡 智源社区: 0 条 (总 30 条) + 📡 中国南海研究院: 0 条 (总 25 条) + 📡 中国现代国际关系研究院: 0 条 (总 30 条) + 📡 中国舆论场: 0 条 (总 30 条) + 📡 装备参考: 0 条 (总 30 条) + 📡 中国周边安全研究中心: 0 条 (总 30 条) + 📡 中国指挥与控制学会: 0 条 (总 30 条) + 📡 最黑科技: 0 条 (总 30 条) + 📥 抓取到 5 条原始消息 + 🆕 新消息 3 条 | 缓存命中: 2 | 已有链接: 11 +08:06:24 [INFO] 中文文章:2 篇,待翻译标题的外文:1 篇 +08:06:24 [INFO] 开始并发翻译标题,并发数:6 +08:06:26 [INFO] 标题翻译完成,共处理 3 篇文章 +08:06:26 [INFO] 无需翻译正文的中文文章:2 篇,待翻译正文的外文:1 篇 +08:06:26 [INFO] 开始并发翻译正文,并发数:6 +08:06:28 [INFO] 正文翻译完成,共处理 3 篇文章 + 🤖 LLM评分中... + 📦 分成 1 个批次评分 (共 3 条) + 📊 评分分布: [85, 70, 70] + 📊 动态阈值: 79 | 合格条目: 1 + 🏷️ 实体提取完成: 1 条 + 🧩 聚类完成: 1 个簇 (多角度: 0, 最大簇: 1 篇) +08:06:40 [INFO] [热度阈值] 簇数=1<5, 保底阈值=40.0 + 🔥 簇热度: 0 个热点簇 (阈值=40.0) + 💾 已保存到 data/fetch-2026-07-01.json +08:06:40 [INFO] +================================================================ + 📊 运行监控报告 +================================================================ + +── ⏱ 阶段耗时 ── + RSS抓取      71.1s + 去重         0.0s + 标题翻译       2.3s + 正文翻译       1.3s + AI评分分类     8.6s + 实体提取+角度分类  3.9s + ──────────────────── + 合计         87.2s / 总运行 87s + +── 📡 文章统计 ── + 总计: 5 缓存命中: 2 本次处理: 3 + 缓存命中率: 40% + +── 🤖 API调用统计 ── + 无API调用(全部命中缓存) + +── 📰 源抓取统计 ── + 源总数: 131 成功: 131 失败: 0 + +================================================================ + ✅ Fetch Job 完成 | 新消息: 3 条 | 热点: 0 条 + ⏰ 下次抓取: 28.5分钟后 + +================================================== +🔄 Fetch Job | 2026-07-01 08:35:13 +================================================== + 📂 共 131 个订阅源 | 时间窗口: 60 分钟 + 📡 The War Zone: 0 条 (总 33 条) + 📡 Defense One: 1 条 (总 25 条) + 📡 Seapower: 0 条 (总 10 条) + 📡 NEWUAS: 0 条 (总 30 条) + 📡 兵路文化: 0 条 (总 11 条) + 📡 安全圈: 0 条 (总 30 条) + 📡 白马 V 视角: 0 条 (总 30 条) + 📡 补三刀: 0 条 (总 24 条) + 📡 半导体行业观察: 0 条 (总 30 条) + 📡 90号茶室: 0 条 (总 30 条) + 📡 艾瑞咨询: 1 条 (总 30 条) + 📡 补壹刀: 0 条 (总 30 条) + 📡 参考消息: 2 条 (总 30 条) + 📡 北大纵横: 0 条 (总 30 条) + 📡 参考消息智库: 0 条 (总 30 条) + 📡 安全内参: 0 条 (总 30 条) + 📡 大浪淘沙: 0 条 (总 30 条) + 📡 纯科学: 0 条 (总 28 条) + 📡 IPP评论: 0 条 (总 30 条) + 📡 从心不逾矩的防务菌: 0 条 (总 30 条) + 📡 电波之矛: 0 条 (总 30 条) + 📡 东南亚问题研究: 0 条 (总 30 条) + 📡 动态大参考: 0 条 (总 30 条) + 📡 防务快讯: 0 条 (总 30 条) + 📡 大柳树防务: 0 条 (总 30 条) + 📡 大湾区评论: 0 条 (总 30 条) + 📡 防务指南: 0 条 (总 16 条) + 📡 阜成门六号院: 0 条 (总 25 条) + 📡 复旦大学中国研究院: 0 条 (总 30 条) + 📡 观察者网: 0 条 (总 30 条) + 📡 浮空飞行器: 0 条 (总 30 条) + 📡 国防科技要闻: 0 条 (总 30 条) + 📡 国科环宇: 0 条 (总 18 条) + 📡 国观智库: 0 条 (总 30 条) + 📡 光明军事: 0 条 (总 30 条) + 📡 国防工业出版社: 0 条 (总 30 条) + 📡 国防时报排头兵: 0 条 (总 30 条) + 📡 航小宇: 0 条 (总 30 条) + 📡 环时枢密院十号: 0 条 (总 30 条) + 📡 华山穹剑: 0 条 (总 30 条) + 📡 黑天鹅商业情报站: 0 条 (总 30 条) + 📡 华语智库: 0 条 (总 30 条) + 📡 国际法务: 0 条 (总 30 条) + 📡 海洋防务前沿: 0 条 (总 30 条) + 📡 环太国际战略: 0 条 (总 23 条) + 📡 凤凰网军事频道: 0 条 (总 30 条) + 📡 海鹰资讯: 0 条 (总 30 条) + 📡 胡锡进观察: 0 条 (总 30 条) + 📡 解放军报: 2 条 (总 30 条) + 📡 混沌巡洋舰: 0 条 (总 30 条) + 📡 今日台湾: 0 条 (总 30 条) + 📡 精确打击洞见: 0 条 (总 30 条) + 📡 军事高科技在线: 0 条 (总 30 条) + 📡 军民融合观察: 0 条 (总 30 条) + 📡 军事文摘: 0 条 (总 30 条) + 📡 跨境前沿: 0 条 (总 30 条) + 📡 李光满说: 0 条 (总 30 条) + 📡 科技导报: 0 条 (总 30 条) + 📡 军鹰动态: 0 条 (总 30 条) + 📡 六爷阿旦: 2 条 (总 30 条) + 📡 瞭望智库: 0 条 (总 30 条) + 📡 砺剑: 0 条 (总 30 条) + 📡 空天界: 0 条 (总 30 条) + 📡 空天大视野: 0 条 (总 30 条) + 📡 龙牙的一座山: 0 条 (总 30 条) + 📡 蓝血研究: 0 条 (总 30 条) + 📡 军武次位面: 0 条 (总 30 条) + 📡 美国驻华大使馆: 0 条 (总 30 条) + 📡 米尔观天下: 0 条 (总 30 条) + 📡 明叔杂谈: 0 条 (总 30 条) + 📡 迷彩虎观察: 0 条 (总 30 条) + 📡 梅特涅的信徒: 0 条 (总 30 条) + 📡 秦安战略: 0 条 (总 30 条) + 📡 澎湃新闻: 1 条 (总 30 条) + 📡 前沿深度解码: 0 条 (总 30 条) + 📡 破圈了: 0 条 (总 30 条) + 📡 南方周末: 0 条 (总 30 条) + 📡 奇安网情局: 0 条 (总 30 条) + 📡 欧亚新观察: 0 条 (总 30 条) + 📡 牛弹琴: 0 条 (总 30 条) + 📡 盘古智库: 0 条 (总 30 条) + 📡 全球技术地图: 0 条 (总 30 条) + 📡 人民日报: 1 条 (总 30 条) + 📡 人大重阳: 0 条 (总 30 条) + 📡 三剑客: 0 条 (总 30 条) + 📡 戎评: 0 条 (总 30 条) + 📡 上海国际问题研究院: 0 条 (总 30 条) + 📡 盛唐如松: 0 条 (总 30 条) + 📡 书剑杂谈: 0 条 (总 30 条) + 📡 望穹科技: 0 条 (总 30 条) + 📡 外军防务研究前沿: 0 条 (总 30 条) + 📡 无人机邦: 0 条 (总 30 条) + 📡 搜狐科技: 0 条 (总 30 条) + 📡 数字孪生战场: 0 条 (总 30 条) + 📡 沃德舆情观察: 0 条 (总 30 条) + 📡 网信前沿观察: 0 条 (总 30 条) + 📡 太空与网络: 0 条 (总 30 条) + 📡 侠客岛: 0 条 (总 30 条) + 📡 喜乔智研: 0 条 (总 30 条) + 📡 施展世界: 0 条 (总 24 条) + 📡 新华国际头条: 0 条 (总 30 条) + 📡 时政国关分析: 1 条 (总 30 条) + 📡 无人机反制: 0 条 (总 30 条) + 📡 无人争锋: 0 条 (总 30 条) + 📡 乌鸦校尉: 0 条 (总 30 条) + 📡 央广军事: 2 条 (总 30 条) + 📡 亚太安全与海洋研究: 0 条 (总 19 条) + 📡 渊亭防务: 0 条 (总 30 条) + 📡 舆情文摘: 0 条 (总 30 条) + 📡 占豪: 0 条 (总 30 条) + 📡 泽平宏观: 0 条 (总 30 条) + 📡 一个坏土豆: 0 条 (总 30 条) + 📡 一号哨位: 0 条 (总 30 条) + 📡 远望智库: 0 条 (总 30 条) + 📡 战略纵横家: 2 条 (总 30 条) + 📡 职场老校尉: 0 条 (总 10 条) + 📡 智强战略咨询: 0 条 (总 30 条) + 📡 战略前沿技术: 0 条 (总 30 条) + 📡 中国国防报: 0 条 (总 30 条) + 📡 知远战略与防务研究所: 0 条 (总 30 条) + 📡 占知智库: 0 条 (总 30 条) + 📡 中国舰船: 0 条 (总 30 条) + 📡 智源社区: 0 条 (总 30 条) + 📡 中国现代国际关系研究院: 0 条 (总 30 条) + 📡 中国南海研究院: 0 条 (总 25 条) + 📡 正和岛: 0 条 (总 30 条) + 📡 中国舆论场: 0 条 (总 30 条) + 📡 中国周边安全研究中心: 0 条 (总 30 条) + 📡 中国指挥与控制学会: 0 条 (总 30 条) + 📡 装备参考: 0 条 (总 30 条) + 📡 最黑科技: 0 条 (总 30 条) + 📥 抓取到 15 条原始消息 + 🆕 新消息 10 条 | 缓存命中: 5 | 已有链接: 11 +08:36:27 [INFO] 中文文章:10 篇,待翻译标题的外文:0 篇 +08:36:27 [INFO] 无需翻译正文的中文文章:10 篇,待翻译正文的外文:0 篇 + 🤖 LLM评分中... + 📦 分成 3 个批次评分 (共 10 条) + 📊 评分进度: 2/3 批次完成 + 📊 评分进度: 3/3 批次完成 + 📊 评分分布: [88, 85, 85, 85, 75, 75, 70, 70, 65, 60] + 📊 动态阈值: 81 | 合格条目: 4 + 🏷️ 实体提取完成: 4 条 + 🧠 LLM消歧: 27 个未命中实体待判断 (模型: deepseek-v4-flash) + 🧩 聚类完成: 4 个簇 (多角度: 0, 最大簇: 1 篇) +08:37:04 [INFO] [热度阈值] 簇数=4<5, 保底阈值=40.0 + 🔥 簇热度: 0 个热点簇 (阈值=40.0) + 💾 已保存到 data/fetch-2026-07-01.json +08:37:04 [INFO] +================================================================ + 📊 运行监控报告 +================================================================ + +── ⏱ 阶段耗时 ── + RSS抓取      74.1s + 去重         0.0s + 标题翻译       0.0s + 正文翻译       0.0s + AI评分分类     10.1s + 实体提取+角度分类  27.0s + ──────────────────── + 合计         111.2s / 总运行 111s + +── 📡 文章统计 ── + 总计: 15 缓存命中: 5 本次处理: 10 + 缓存命中率: 33% + +── 🤖 API调用统计 ── + 无API调用(全部命中缓存) + +── 📰 源抓取统计 ── + 源总数: 131 成功: 131 失败: 0 + +================================================================ + ✅ Fetch Job 完成 | 新消息: 10 条 | 热点: 0 条 + ⏰ 下次抓取: 28.1分钟后 + +================================================== +🔄 Fetch Job | 2026-07-01 09:05:13 +================================================== + 📂 共 131 个订阅源 | 时间窗口: 60 分钟 + 📡 The War Zone: 0 条 (总 33 条) + 📡 Defense One: 0 条 (总 25 条) + 📡 Seapower: 0 条 (总 10 条) + 📡 兵路文化: 0 条 (总 11 条) + 📡 NEWUAS: 0 条 (总 30 条) + 📡 安全圈: 0 条 (总 30 条) + 📡 白马 V 视角: 0 条 (总 30 条) + 📡 半导体行业观察: 1 条 (总 30 条) + 📡 90号茶室: 0 条 (总 30 条) + 📡 艾瑞咨询: 0 条 (总 30 条) + 📡 北大纵横: 0 条 (总 30 条) + 📡 IPP评论: 0 条 (总 30 条) + 📡 补壹刀: 0 条 (总 30 条) + 📡 参考消息: 2 条 (总 30 条) + 📡 补三刀: 0 条 (总 25 条) + 📡 安全内参: 0 条 (总 30 条) + 📡 参考消息智库: 0 条 (总 30 条) + 📡 纯科学: 0 条 (总 28 条) + 📡 从心不逾矩的防务菌: 0 条 (总 30 条) + 📡 大柳树防务: 0 条 (总 30 条) + 📡 电波之矛: 0 条 (总 30 条) + 📡 大浪淘沙: 0 条 (总 30 条) + 📡 东南亚问题研究: 0 条 (总 30 条) + 📡 动态大参考: 0 条 (总 30 条) + 📡 阜成门六号院: 0 条 (总 25 条) + 📡 防务快讯: 0 条 (总 30 条) + 📡 防务指南: 0 条 (总 16 条) + 📡 大湾区评论: 0 条 (总 30 条) + 📡 复旦大学中国研究院: 0 条 (总 30 条) + 📡 浮空飞行器: 0 条 (总 30 条) + 📡 凤凰网军事频道: 0 条 (总 30 条) + 📡 观察者网: 0 条 (总 30 条) + 📡 国防科技要闻: 0 条 (总 30 条) + 📡 光明军事: 0 条 (总 30 条) + 📡 国观智库: 0 条 (总 30 条) + 📡 国防工业出版社: 0 条 (总 30 条) + 📡 国防时报排头兵: 0 条 (总 30 条) + 📡 国科环宇: 0 条 (总 18 条) + 📡 国际法务: 3 条 (总 30 条) + 📡 海洋防务前沿: 0 条 (总 30 条) + 📡 华语智库: 0 条 (总 30 条) + 📡 航小宇: 1 条 (总 30 条) + 📡 环时枢密院十号: 0 条 (总 30 条) + 📡 华山穹剑: 0 条 (总 30 条) + 📡 环太国际战略: 0 条 (总 23 条) + 📡 解放军报: 0 条 (总 30 条) + 📡 黑天鹅商业情报站: 0 条 (总 30 条) + 📡 胡锡进观察: 0 条 (总 30 条) + 📡 海鹰资讯: 0 条 (总 30 条) + 📡 混沌巡洋舰: 0 条 (总 30 条) + 📡 精确打击洞见: 0 条 (总 30 条) + 📡 今日台湾: 0 条 (总 30 条) + 📡 军事高科技在线: 0 条 (总 30 条) + 📡 军民融合观察: 0 条 (总 30 条) + 📡 军事文摘: 0 条 (总 30 条) + 📡 军鹰动态: 0 条 (总 30 条) + 📡 六爷阿旦: 0 条 (总 30 条) + 📡 李光满说: 0 条 (总 30 条) + 📡 跨境前沿: 0 条 (总 30 条) + 📡 砺剑: 0 条 (总 30 条) + 📡 科技导报: 0 条 (总 30 条) + 📡 瞭望智库: 1 条 (总 30 条) + 📡 空天大视野: 0 条 (总 30 条) + 📡 空天界: 0 条 (总 30 条) + 📡 龙牙的一座山: 0 条 (总 30 条) + 📡 迷彩虎观察: 1 条 (总 30 条) + 📡 美国驻华大使馆: 0 条 (总 30 条) + 📡 蓝血研究: 0 条 (总 30 条) + 📡 军武次位面: 0 条 (总 30 条) + 📡 米尔观天下: 1 条 (总 30 条) + 📡 梅特涅的信徒: 0 条 (总 30 条) + 📡 明叔杂谈: 0 条 (总 30 条) + 📡 秦安战略: 0 条 (总 30 条) + 📡 澎湃新闻: 0 条 (总 30 条) + 📡 前沿深度解码: 0 条 (总 30 条) + 📡 欧亚新观察: 0 条 (总 30 条) + 📡 破圈了: 0 条 (总 30 条) + 📡 奇安网情局: 0 条 (总 30 条) + 📡 全球技术地图: 0 条 (总 30 条) + 📡 盘古智库: 0 条 (总 30 条) + 📡 牛弹琴: 0 条 (总 30 条) + 📡 人大重阳: 0 条 (总 30 条) + 📡 南方周末: 0 条 (总 30 条) + 📡 人民日报: 0 条 (总 30 条) + 📡 三剑客: 0 条 (总 30 条) + 📡 上海国际问题研究院: 0 条 (总 30 条) + 📡 盛唐如松: 0 条 (总 30 条) + 📡 戎评: 0 条 (总 30 条) + 📡 施展世界: 0 条 (总 24 条) + 📡 书剑杂谈: 0 条 (总 30 条) + 📡 数字孪生战场: 0 条 (总 30 条) + 📡 搜狐科技: 0 条 (总 30 条) + 📡 外军防务研究前沿: 0 条 (总 30 条) + 📡 望穹科技: 0 条 (总 30 条) + 📡 时政国关分析: 0 条 (总 30 条) + 📡 太空与网络: 0 条 (总 30 条) + 📡 网信前沿观察: 0 条 (总 30 条) + 📡 沃德舆情观察: 0 条 (总 30 条) + 📡 无人机邦: 0 条 (总 30 条) + 📡 新华国际头条: 0 条 (总 30 条) + 📡 侠客岛: 0 条 (总 30 条) + 📡 无人机反制: 0 条 (总 30 条) + 📡 无人争锋: 0 条 (总 30 条) + 📡 喜乔智研: 0 条 (总 30 条) + 📡 乌鸦校尉: 0 条 (总 30 条) + 📡 央广军事: 0 条 (总 30 条) + 📡 渊亭防务: 0 条 (总 30 条) + 📡 舆情文摘: 0 条 (总 30 条) + 📡 一个坏土豆: 0 条 (总 30 条) + 📡 一号哨位: 0 条 (总 30 条) + 📡 泽平宏观: 0 条 (总 30 条) + 📡 亚太安全与海洋研究: 0 条 (总 19 条) + 📡 战略纵横家: 0 条 (总 30 条) + 📡 占豪: 0 条 (总 30 条) + 📡 远望智库: 0 条 (总 30 条) + 📡 职场老校尉: 0 条 (总 10 条) + 📡 战略前沿技术: 0 条 (总 30 条) + 📡 正和岛: 0 条 (总 30 条) + 📡 占知智库: 0 条 (总 30 条) + 📡 中国国防报: 0 条 (总 30 条) + 📡 智强战略咨询: 0 条 (总 30 条) + 📡 知远战略与防务研究所: 0 条 (总 30 条) + 📡 中国现代国际关系研究院: 0 条 (总 30 条) + 📡 中国舰船: 0 条 (总 30 条) + 📡 中国南海研究院: 0 条 (总 25 条) + 📡 智源社区: 0 条 (总 30 条) + 📡 中国舆论场: 0 条 (总 30 条) + 📡 中国指挥与控制学会: 0 条 (总 30 条) + 📡 最黑科技: 0 条 (总 30 条) + 📡 中国周边安全研究中心: 0 条 (总 30 条) + 📡 装备参考: 0 条 (总 30 条) + 📥 抓取到 10 条原始消息 + 🆕 新消息 8 条 | 缓存命中: 2 | 已有链接: 24 +09:06:25 [INFO] 中文文章:8 篇,待翻译标题的外文:0 篇 +09:06:25 [INFO] 无需翻译正文的中文文章:8 篇,待翻译正文的外文:0 篇 + 🤖 LLM评分中... + 📦 分成 3 个批次评分 (共 8 条) + 📊 评分进度: 2/3 批次完成 + 📊 评分进度: 3/3 批次完成 + 📊 评分分布: [85, 82, 70, 68, 65, 60, 55, 20] + 📊 动态阈值: 73 | 合格条目: 2 + 🏷️ 实体提取完成: 2 条 + 🧠 LLM消歧: 16 个未命中实体待判断 (模型: deepseek-v4-flash) + 🧩 聚类完成: 2 个簇 (多角度: 0, 最大簇: 1 篇) +09:06:45 [INFO] [热度阈值] 簇数=2<5, 保底阈值=40.0 + 🔥 簇热度: 0 个热点簇 (阈值=40.0) + 💾 已保存到 data/fetch-2026-07-01.json +09:06:45 [INFO] +================================================================ + 📊 运行监控报告 +================================================================ + +── ⏱ 阶段耗时 ── + RSS抓取      71.7s + 去重         0.0s + 标题翻译       0.0s + 正文翻译       0.0s + AI评分分类     8.5s + 实体提取+角度分类  11.4s + ──────────────────── + 合计         91.6s / 总运行 92s + +── 📡 文章统计 ── + 总计: 10 缓存命中: 2 本次处理: 8 + 缓存命中率: 20% + +── 🤖 API调用统计 ── + 无API调用(全部命中缓存) + +── 📰 源抓取统计 ── + 源总数: 131 成功: 131 失败: 0 + +================================================================ + ✅ Fetch Job 完成 | 新消息: 8 条 | 热点: 0 条 + ⏰ 下次抓取: 28.5分钟后 + +================================================== +🔄 Fetch Job | 2026-07-01 09:35:13 +================================================== + 📂 共 131 个订阅源 | 时间窗口: 60 分钟 + 📡 The War Zone: 0 条 (总 33 条) + 📡 Defense One: 0 条 (总 25 条) + 📡 Seapower: 0 条 (总 10 条) + 📡 NEWUAS: 0 条 (总 30 条) + 📡 兵路文化: 0 条 (总 11 条) + 📡 安全圈: 0 条 (总 30 条) + 📡 白马 V 视角: 0 条 (总 30 条) + 📡 补三刀: 0 条 (总 25 条) + 📡 半导体行业观察: 0 条 (总 30 条) + 📡 艾瑞咨询: 0 条 (总 30 条) + 📡 90号茶室: 0 条 (总 30 条) + 📡 北大纵横: 0 条 (总 30 条) + 📡 安全内参: 0 条 (总 30 条) + 📡 IPP评论: 0 条 (总 30 条) + 📡 补壹刀: 0 条 (总 30 条) + 📡 参考消息: 0 条 (总 30 条) + 📡 参考消息智库: 0 条 (总 30 条) + 📡 纯科学: 0 条 (总 28 条) + 📡 从心不逾矩的防务菌: 0 条 (总 30 条) + 📡 电波之矛: 0 条 (总 30 条) + 📡 大柳树防务: 0 条 (总 30 条) + 📡 动态大参考: 0 条 (总 30 条) + 📡 东南亚问题研究: 0 条 (总 30 条) + 📡 防务快讯: 0 条 (总 30 条) + 📡 大浪淘沙: 0 条 (总 30 条) + 📡 防务指南: 0 条 (总 16 条) + 📡 大湾区评论: 0 条 (总 30 条) + 📡 阜成门六号院: 0 条 (总 25 条) + 📡 复旦大学中国研究院: 0 条 (总 30 条) + 📡 浮空飞行器: 0 条 (总 30 条) + 📡 凤凰网军事频道: 0 条 (总 30 条) + 📡 观察者网: 0 条 (总 30 条) + 📡 国防科技要闻: 0 条 (总 30 条) + 📡 光明军事: 0 条 (总 30 条) + 📡 国防工业出版社: 0 条 (总 30 条) + 📡 国科环宇: 0 条 (总 18 条) + 📡 国观智库: 0 条 (总 30 条) + 📡 国防时报排头兵: 0 条 (总 30 条) + 📡 航小宇: 0 条 (总 30 条) + 📡 国际法务: 3 条 (总 30 条) + 📡 环时枢密院十号: 0 条 (总 30 条) + 📡 华山穹剑: 0 条 (总 30 条) + 📡 海鹰资讯: 0 条 (总 30 条) + 📡 黑天鹅商业情报站: 0 条 (总 30 条) + 📡 环太国际战略: 0 条 (总 23 条) + 📡 华语智库: 0 条 (总 30 条) + 📡 胡锡进观察: 0 条 (总 30 条) + 📡 混沌巡洋舰: 0 条 (总 30 条) + 📡 精确打击洞见: 0 条 (总 30 条) + 📡 今日台湾: 0 条 (总 30 条) + 📡 军事高科技在线: 0 条 (总 30 条) + 📡 军事文摘: 0 条 (总 30 条) + 📡 军民融合观察: 0 条 (总 30 条) + 📡 解放军报: 0 条 (总 30 条) + 📡 海洋防务前沿: 0 条 (总 30 条) + 📡 军鹰动态: 0 条 (总 30 条) + 📡 空天界: 0 条 (总 30 条) + 📡 空天大视野: 0 条 (总 30 条) + 📡 军武次位面: 0 条 (总 30 条) + 📡 跨境前沿: 0 条 (总 30 条) + 📡 科技导报: 0 条 (总 30 条) + 📡 砺剑: 0 条 (总 30 条) + 📡 蓝血研究: 0 条 (总 30 条) + 📡 李光满说: 0 条 (总 30 条) + 📡 瞭望智库: 1 条 (总 30 条) + 📡 六爷阿旦: 0 条 (总 30 条) + 📡 迷彩虎观察: 0 条 (总 30 条) + 📡 美国驻华大使馆: 0 条 (总 30 条) + 📡 龙牙的一座山: 0 条 (总 30 条) + 📡 梅特涅的信徒: 0 条 (总 30 条) + 📡 米尔观天下: 0 条 (总 30 条) + 📡 明叔杂谈: 0 条 (总 30 条) + 📡 澎湃新闻: 0 条 (总 30 条) + 📡 秦安战略: 0 条 (总 30 条) + 📡 破圈了: 0 条 (总 30 条) + 📡 欧亚新观察: 0 条 (总 30 条) + 📡 前沿深度解码: 0 条 (总 30 条) + 📡 盘古智库: 0 条 (总 30 条) + 📡 奇安网情局: 0 条 (总 30 条) + 📡 牛弹琴: 0 条 (总 30 条) + 📡 全球技术地图: 0 条 (总 30 条) + 📡 南方周末: 0 条 (总 30 条) + 📡 人民日报: 0 条 (总 30 条) + 📡 人大重阳: 0 条 (总 30 条) + 📡 戎评: 0 条 (总 30 条) + 📡 上海国际问题研究院: 0 条 (总 30 条) + 📡 三剑客: 0 条 (总 30 条) + 📡 盛唐如松: 0 条 (总 30 条) + 📡 书剑杂谈: 0 条 (总 30 条) + 📡 施展世界: 0 条 (总 24 条) + 📡 数字孪生战场: 0 条 (总 30 条) + 📡 外军防务研究前沿: 0 条 (总 30 条) + 📡 望穹科技: 0 条 (总 30 条) + 📡 搜狐科技: 0 条 (总 30 条) + 📡 时政国关分析: 0 条 (总 30 条) + 📡 太空与网络: 0 条 (总 30 条) + 📡 无人机邦: 0 条 (总 30 条) + 📡 沃德舆情观察: 0 条 (总 30 条) + 📡 无人机反制: 0 条 (总 30 条) + 📡 网信前沿观察: 0 条 (总 30 条) + 📡 侠客岛: 0 条 (总 30 条) + 📡 新华国际头条: 0 条 (总 30 条) + 📡 喜乔智研: 0 条 (总 30 条) + 📡 央广军事: 0 条 (总 30 条) + 📡 无人争锋: 0 条 (总 30 条) + 📡 亚太安全与海洋研究: 0 条 (总 19 条) + 📡 舆情文摘: 0 条 (总 30 条) + 📡 乌鸦校尉: 0 条 (总 30 条) + 📡 渊亭防务: 0 条 (总 30 条) + 📡 泽平宏观: 0 条 (总 30 条) + 📡 一个坏土豆: 0 条 (总 30 条) + 📡 一号哨位: 0 条 (总 30 条) + 📡 占豪: 0 条 (总 30 条) + 📡 远望智库: 0 条 (总 30 条) + 📡 职场老校尉: 0 条 (总 10 条) + 📡 战略前沿技术: 0 条 (总 30 条) + 📡 战略纵横家: 0 条 (总 30 条) + 📡 中国国防报: 0 条 (总 30 条) + 📡 正和岛: 0 条 (总 30 条) + 📡 知远战略与防务研究所: 0 条 (总 30 条) + 📡 占知智库: 0 条 (总 30 条) + 📡 智源社区: 0 条 (总 30 条) + 📡 中国舰船: 0 条 (总 30 条) + 📡 智强战略咨询: 0 条 (总 30 条) + 📡 中国现代国际关系研究院: 0 条 (总 30 条) + 📡 中国周边安全研究中心: 0 条 (总 30 条) + 📡 中国舆论场: 0 条 (总 30 条) + 📡 中国南海研究院: 0 条 (总 25 条) + 📡 中国指挥与控制学会: 0 条 (总 30 条) + 📡 装备参考: 0 条 (总 30 条) + 📡 最黑科技: 0 条 (总 30 条) + 📥 抓取到 4 条原始消息 + 🆕 新消息 0 条 | 缓存命中: 4 | 已有链接: 30 +09:36:16 [INFO] +================================================================ + 📊 运行监控报告 +================================================================ + +── ⏱ 阶段耗时 ── + RSS抓取      62.7s + 去重         0.0s + ──────────────────── + 合计         62.7s / 总运行 63s + +── 📡 文章统计 ── + 总计: 4 缓存命中: 4 本次处理: 0 + 缓存命中率: 100% + +── 🤖 API调用统计 ── + 无API调用(全部命中缓存) + +── 📰 源抓取统计 ── + 源总数: 131 成功: 131 失败: 0 + +================================================================ + ⏰ 下次抓取: 29.0分钟后 + + + 🪖 军事科技每日资讯推送系统 - 全栈启动 +================================================== + 日志文件: D:\yinpeng\documents\code\military-digest-v3-full\my-daily\logs\20260701.log +✅ 数据管线后端已启动 (Fetch + Push 循环) +✅ Web 归档站点已启动 (http://0.0.0.0:8080) +================================================== +按 Ctrl+C 停止所有服务 +🪖 Web 归档站点 - 独立模式 + 地址: http://0.0.0.0:8080 + 数据目录: data/ + 按 Ctrl+C 停止 + * Serving Flask app 'web_archive.server' + * Debug mode: off +WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:8080 + * Running on http://192.168.1.40:8080 +Press CTRL+C to quit +🚀 军事科技每日资讯推送系统 +✅ 配置加载成功 +🔍 检查 LLM 接口可用性... + ✅ LLM 接口可用 +🔄 Fetch循环已启动 | 严格间隔: 30.0分钟 + +================================================== +🔄 Fetch Job | 2026-07-01 11:36:27 +================================================== + 📂 共 131 个订阅源 | 时间窗口: 60 分钟 +📤 Push循环已启动 | 定时: 0 8 * * * | 时区: UTC+08:00 + ⏰ 下次推送: 2026-07-02 08:00:00 (等待 1223.5 分钟) + 📡 Defense One: 0 条 (总 25 条) + 📡 The War Zone: 0 条 (总 33 条) + 📡 Seapower: 0 条 (总 10 条) + 📡 90号茶室: 0 条 (总 30 条) + 📡 NEWUAS: 0 条 (总 30 条) + 📡 兵路文化: 0 条 (总 11 条) + 📡 白马 V 视角: 0 条 (总 30 条) + 📡 半导体行业观察: 0 条 (总 30 条) + 📡 安全圈: 0 条 (总 30 条) + 📡 补三刀: 0 条 (总 25 条) + 📡 艾瑞咨询: 0 条 (总 30 条) + 📡 北大纵横: 0 条 (总 30 条) + 📡 参考消息: 0 条 (总 30 条) + 📡 安全内参: 0 条 (总 30 条) + 📡 参考消息智库: 0 条 (总 30 条) + 📡 补壹刀: 0 条 (总 30 条) + 📡 大浪淘沙: 0 条 (总 30 条) + 📡 电波之矛: 0 条 (总 30 条) + 📡 纯科学: 0 条 (总 28 条) + 📡 大柳树防务: 0 条 (总 30 条) + 📡 IPP评论: 0 条 (总 30 条) + 📡 东南亚问题研究: 0 条 (总 30 条) + 📡 动态大参考: 0 条 (总 30 条) + 📡 从心不逾矩的防务菌: 0 条 (总 30 条) + 📡 防务快讯: 0 条 (总 30 条) + 📡 大湾区评论: 0 条 (总 30 条) + 📡 防务指南: 0 条 (总 16 条) + 📡 阜成门六号院: 0 条 (总 25 条) + 📡 复旦大学中国研究院: 0 条 (总 30 条) + 📡 浮空飞行器: 0 条 (总 30 条) + 📡 国防科技要闻: 0 条 (总 30 条) + 📡 国观智库: 0 条 (总 30 条) + 📡 国科环宇: 0 条 (总 18 条) + 📡 观察者网: 0 条 (总 30 条) + 📡 国防工业出版社: 0 条 (总 30 条) + 📡 凤凰网军事频道: 0 条 (总 30 条) + 📡 国防时报排头兵: 0 条 (总 30 条) + 📡 光明军事: 0 条 (总 30 条) + 📡 航小宇: 0 条 (总 30 条) + 📡 国际法务: 0 条 (总 30 条) + 📡 环时枢密院十号: 0 条 (总 30 条) + 📡 黑天鹅商业情报站: 0 条 (总 30 条) + 📡 海洋防务前沿: 0 条 (总 30 条) + 📡 环太国际战略: 0 条 (总 23 条) + 📡 海鹰资讯: 0 条 (总 30 条) + 📡 华山穹剑: 0 条 (总 30 条) + 📡 华语智库: 0 条 (总 30 条) + 📡 军事文摘: 0 条 (总 30 条) + 📡 今日台湾: 0 条 (总 30 条) + 📡 解放军报: 0 条 (总 30 条) + 📡 胡锡进观察: 0 条 (总 30 条) + 📡 精确打击洞见: 0 条 (总 30 条) + 📡 军事高科技在线: 0 条 (总 30 条) + 📡 混沌巡洋舰: 0 条 (总 30 条) + 📡 军鹰动态: 0 条 (总 30 条) + 📡 军民融合观察: 0 条 (总 30 条) + 📡 科技导报: 0 条 (总 30 条) + 📡 跨境前沿: 0 条 (总 30 条) + 📡 空天大视野: 0 条 (总 30 条) + 📡 瞭望智库: 0 条 (总 30 条) + 📡 蓝血研究: 0 条 (总 30 条) + 📡 军武次位面: 0 条 (总 30 条) + 📡 李光满说: 0 条 (总 30 条) + 📡 空天界: 0 条 (总 30 条) + 📡 砺剑: 0 条 (总 30 条) + 📡 六爷阿旦: 0 条 (总 30 条) + 📡 龙牙的一座山: 0 条 (总 30 条) + 📡 明叔杂谈: 0 条 (总 30 条) + 📡 迷彩虎观察: 0 条 (总 30 条) + 📡 米尔观天下: 0 条 (总 30 条) + 📡 美国驻华大使馆: 0 条 (总 30 条) + 📡 澎湃新闻: 0 条 (总 30 条) + 📡 前沿深度解码: 0 条 (总 30 条) + 📡 秦安战略: 0 条 (总 30 条) + 📡 破圈了: 0 条 (总 30 条) + 📡 奇安网情局: 0 条 (总 30 条) + 📡 欧亚新观察: 0 条 (总 30 条) + 📡 梅特涅的信徒: 0 条 (总 30 条) + 📡 盘古智库: 0 条 (总 30 条) + 📡 牛弹琴: 0 条 (总 30 条) + 📡 全球技术地图: 0 条 (总 30 条) + 📡 人民日报: 0 条 (总 30 条) + 📡 人大重阳: 0 条 (总 30 条) + 📡 南方周末: 0 条 (总 30 条) + 📡 戎评: 0 条 (总 30 条) + 📡 上海国际问题研究院: 0 条 (总 30 条) + 📡 三剑客: 0 条 (总 30 条) + 📡 盛唐如松: 0 条 (总 30 条) + 📡 书剑杂谈: 0 条 (总 30 条) + 📡 数字孪生战场: 0 条 (总 30 条) + 📡 外军防务研究前沿: 0 条 (总 30 条) + 📡 望穹科技: 0 条 (总 30 条) + 📡 无人机邦: 0 条 (总 30 条) + 📡 搜狐科技: 0 条 (总 30 条) + 📡 沃德舆情观察: 0 条 (总 30 条) + 📡 施展世界: 0 条 (总 24 条) + 📡 网信前沿观察: 0 条 (总 30 条) + 📡 时政国关分析: 0 条 (总 30 条) + 📡 太空与网络: 0 条 (总 30 条) + 📡 新华国际头条: 0 条 (总 30 条) + 📡 乌鸦校尉: 0 条 (总 30 条) + 📡 侠客岛: 0 条 (总 30 条) + 📡 无人争锋: 0 条 (总 30 条) + 📡 无人机反制: 0 条 (总 30 条) + 📡 喜乔智研: 0 条 (总 30 条) + 📡 央广军事: 0 条 (总 30 条) + 📡 渊亭防务: 0 条 (总 30 条) + 📡 一号哨位: 0 条 (总 30 条) + 📡 舆情文摘: 0 条 (总 30 条) + 📡 亚太安全与海洋研究: 0 条 (总 19 条) + 📡 战略纵横家: 0 条 (总 30 条) + 📡 占豪: 0 条 (总 30 条) + 📡 泽平宏观: 0 条 (总 30 条) + 📡 一个坏土豆: 0 条 (总 30 条) + 📡 远望智库: 0 条 (总 30 条) + 📡 占知智库: 0 条 (总 30 条) + 📡 战略前沿技术: 0 条 (总 30 条) + 📡 职场老校尉: 0 条 (总 10 条) + 📡 中国国防报: 0 条 (总 30 条) + 📡 知远战略与防务研究所: 0 条 (总 30 条) + 📡 智强战略咨询: 0 条 (总 30 条) + 📡 智源社区: 0 条 (总 30 条) + 📡 正和岛: 0 条 (总 30 条) + 📡 中国舰船: 0 条 (总 30 条) + 📡 中国现代国际关系研究院: 0 条 (总 30 条) + 📡 中国舆论场: 0 条 (总 30 条) + 📡 中国周边安全研究中心: 0 条 (总 30 条) + 📡 中国南海研究院: 0 条 (总 25 条) + 📡 中国指挥与控制学会: 0 条 (总 30 条) + 📡 最黑科技: 0 条 (总 30 条) + 📡 装备参考: 0 条 (总 30 条) + 📥 抓取到 0 条原始消息 + ⏰ 下次抓取: 28.7分钟后 \ No newline at end of file diff --git a/my-daily/CHANGELOG.md b/my-daily/CHANGELOG.md new file mode 100644 index 0000000..b4957da --- /dev/null +++ b/my-daily/CHANGELOG.md @@ -0,0 +1,140 @@ +# 更新日志 + +## 2026-07-12 + +### 改进 +- **Fetch 管道重构**:[src/fetch_pipeline.py](./src/fetch_pipeline.py) 新增,将 `run_fetch_job()` 拆分为 8 个独立处理阶段函数(RSS抓取/去重/翻译/LLM评分/反馈修正/实体提取与聚类/热度计算/即时推送),采用管道模式串联,提升代码可维护性、可测试性和可扩展性。[main.py](./main.py) `run_fetch_job()` 从 305 行精简为 3 行,仅作为管道入口调用新模块 +- **快速上手指南**:[QUICKSTART.md](./QUICKSTART.md) 新增,包含环境准备、一键启动、常用命令和配置要点 +- **Web 归档解析器重构**:[web_archive/data_parser.py](./web_archive/data_parser.py) 引入 `BaseParser` / `WebzineParser` / `CategoryParser` 解析器模式,将 `_parse_webzine()` 和 `_parse_categories()` 重构为结构化解析器类,新增 `_safe_extract()` 安全正则提取和 `_split_sections()` 通用分段解析,提升代码可维护性和可测试性 + +### 修复 +- **RSS 标题过度截断**:[src/fetcher.py](./src/fetcher.py) `TITLE_MAX_CHARS` 从 40 提高到 120,修复英文军事标题(通常 50-100 字符)被截断导致 Web 归档页面显示不完整的问题 +- **start.py 子进程环境选择**:[start.py](./start.py) 简化 Python 解释器检测逻辑,固定优先使用 `py311_1` 环境,避免 `CONDA_PREFIX` 误判导致选错 base 环境 + +## 2026-07-01 + +### 新增 +- **一键启动日志系统**:[start.py](./start.py) 全面重写,从 `multiprocessing` 改为 `subprocess` 架构,新增 `Tee` 类实现终端输出与日志文件同步写入,日志文件按 `YYYYMMDD-HHmmSS.log` 命名存入 `logs/` 目录 +- **子进程实时输出捕获**:[start.py](./start.py) 通过 `stream_reader` 线程读取子进程管道输出,`[管线]` / `[Web]` 前缀区分来源,所有输出实时写入日志文件 + +### 修复 +- **聚类变量未初始化崩溃**:[main.py](./main.py) 修复 `run_fetch_job()` 中 `clusters` 变量在 `clustered_entries` 为空时未定义导致的 `cannot access local variable 'clusters'` 错误,新增 `clusters = []` 初始化 +- **Windows 终端 Emoji 编码错误**:[start.py](./start.py) 子进程环境变量添加 `PYTHONIOENCODING=utf-8` + `PYTHONUTF8=1`,修复 `print()` 输出 emoji 时 `UnicodeEncodeError: 'gbk' codec can't encode character` 错误 +- **子进程管道 GBK 解码错误**:[start.py](./start.py) `subprocess.Popen` 添加 `encoding='utf-8'`,修复 `UnicodeDecodeError: 'gbk' codec can't decode byte` 错误 +- **子进程输出缓冲延迟**:[start.py](./start.py) 环境变量添加 `PYTHONUNBUFFERED=1`,修复子进程输出在 Ctrl+C 退出时才批量写入日志的问题 + +## 2026-06-04 + +### 新增 +- **聚类引擎(3.6)**:[src/processors/cluster_engine.py](./src/processors/cluster_engine.py) 加权实体重叠 + 并查集细粒度聚类,equipment/event 权重=3(共享即合并),org/person 权重=2,location 权重=1,累计阈值≥4 合并,保留多角度报道 +- **聚类集成到 main.py**:[main.py](./main.py) 实体归一化后执行聚类,结果写入 `cluster_id` / `cluster_name` 字段并持久化到 fetch JSON +- **聚类验证脚本**:[scripts/test_cluster.py](./scripts/test_cluster.py) 模拟数据验证(16 篇 → 7 簇),[scripts/test_cluster_real.py](./scripts/test_cluster_real.py) 真实数据验证(134 篇 → 114 簇),[scripts/audit_clusters.py](./scripts/audit_clusters.py) 全量多篇簇人工核查 +- **簇记忆系统(3.7)**:[src/processors/cluster_memory.py](./src/processors/cluster_memory.py) SQLite 持久化已推送簇 ID 及时间戳,即时推送前过滤 24h 内已推送簇,避免同一热点反复推送。已集成到 main.py 热点推送流程 +- **簇热度计算(4.1)**:[src/processors/heat_calculator.py](./src/processors/heat_calculator.py) 四维热度评分:单篇最高分(40%) + 传播热度(30%) + 角度覆盖(20%) + 高优先加成(10%),含时间衰减。已集成到 main.py,高热度簇中 ≥75 分文章自动提升为热点 +- **热点精评(4.4)**:[prompts/precise_heat.md](./prompts/precise_heat.md) + [src/llm.py](./src/llm.py) `precise_score_clusters()` Top 30 簇 LLM 四维精评(军事价值/时效/信息密度/传播),与启发式 6:4 融合,修正启发式对军事价值的判断偏差。阶段四 4/4 全部完成 +- **双视角洞察(5.2+5.3 合并)**:[prompts/cluster_insight.md](./prompts/cluster_insight.md) + [src/llm.py](./src/llm.py) `generate_cluster_insight()` 一次 LLM 调用同时输出精炼摘要(50-80字) + 结构化洞察(title/lead/highlights + 事件脉络/多角度分析/影响与展望),使用 WEBZINE_MODEL pro 模型。已集成到 main.py 早报推送,新增 `🔍 热点洞察` 板块 +- **Prompt 防退化模块(5.4)**:[src/utils/prompt_checker.py](./src/utils/prompt_checker.py) 45 条禁止套话正则(空泛赞扬/模糊指代/空洞强化/乏味结论)+ 素材特异性检查,洞察生成后自动验证,违规则重试(最多 2 次) +- **摘要整合服务(5.5)**:[src/integrators/summary_integrator.py](./src/integrators/summary_integrator.py) 编排层:复用洞察 Top 5 的 summary 字段 + flash 模型补缺其余热簇,新增 `📋 今日速览` 板块。同时 5.6 洞察整合服务通过 `_generate_insights_section()` 一并完成 + +### 修复 +- **聚类泛化实体误合并**:[src/processors/cluster_engine.py](./src/processors/cluster_engine.py) 新增 `GENERIC_ENTITIES` 黑名单(无人机/导弹/雷达等23个类别词),泛化实体不触发直接合并,改走权重累加阈值判断。修复"沃尔玛无人机配送"与"俄乌冲突"被错误聚类的 bug +- **评分 JSON 截断**:[src/llm.py](./src/llm.py) `_parse_score_response()` 新增 `_try_fix_truncated_json()` 截断修复(补全括号/引号);`call_llm()` 新增 `max_tokens=10240` 参数。经 6 个问题批次重跑验证,`max_tokens` 提升后 JSON 截断问题完全消失 +- **评分 URL 匹配**:[src/llm.py](./src/llm.py) `_reconcile_batch_results()` 新增 `_normalize_url()` 归一化匹配(去尾部斜杠、统一协议),修复 LLM 返回 URL 与输入不一致导致的匹配失败 +- **RSS 标题污染**:[src/fetcher.py](./src/fetcher.py) `_truncate_title()` → `_clean_title()`,三步清洗:按 `\n` 取首段(丢弃被 RSS 塞入标题的文章正文)→ 去首尾空白 → 超过 40 字截断 +- **内容连续换行**:[src/processor.py](./src/processor.py) `html_to_markdown()` 新增 `\n(\s*\n){2,}` → `\n\n` 正则,将含空格的连续空行(如 `\n \n \n`)压缩为单段落分隔 +- **标题兜底截断**:[src/llm.py](./src/llm.py) `_build_batch_prompt()` 新增 title `[:200]` 截断,防止异常标题绕过 fetcher 层清洗导致 Prompt 膨胀 + +### 改进 +- **评分进度日志**:[src/llm.py](./src/llm.py) `score_batch()` 新增每 10 批次进度输出 `📊 评分进度: N/401 批次完成`,替代原先的无进度静默等待 +- **评分异常诊断**:[scripts/diagnose_score_errors.py](./scripts/diagnose_score_errors.py) 独立重跑问题批次并捕获完整 LLM 响应(finish_reason / token 用量 / 响应尾部),用于定位根因 +- **簇热度详细日志**:[src/processors/heat_calculator.py](./src/processors/heat_calculator.py) `calculate_cluster_heat()` 对多篇簇输出四维分量日志(最高分×权重/传播/角度/优先),`calculate_hotspot_threshold()` 输出均值/σ/乘数/clamp 全链路 +- **簇记忆拦截日志**:[main.py](./main.py) 簇记忆过滤时逐条 `logger.info` 记录被拦截文章的簇 ID、簇名、评分、来源、标题,方便排查去重效果 +- **评价管线全面参数化**:评分/聚类/热度/记忆全部参数从硬编码迁移到 `.env > config.json > 默认值` 三级优先级。新增 `config.json` 的 `cluster`、`heat`(14 项)、`memory` 配置节,`.env` 新增 `HOT_THRESHOLD`/`CLUSTER_MERGE_THRESHOLD`/`PRECISE_BLEND_RATIO` 三个覆盖变量(共 5 个可选覆盖) +- **配置文档**:[CONFIG.md](./CONFIG.md) 环境变量与参数完整说明,含每个参数的默认值、作用环节、公式、调优建议 +- **评价管线文档**:[README.md](./README.md) 新增"评价管线详解"章节,完整描述从抓取到推送的 12 个步骤、每个步骤的配置项和计算公式 +- **早报架构重构**:[main.py](./main.py) Push 循环拆分为两波独立推送:🔥 热点速览(簇视图)→ 📰 军事科技每日精选(单篇视图),簇视图独立于 RSS 失败 +- **分类系统重设计**:[main.py](./main.py) `_categorize_entries()` 从 3 分类扩展为 5 分类(装备技术/地区安全/军事改革/产业观察/其他热点),多信号匹配(tags 子串 > 标题 > 角度),移除过于宽泛的兜底策略 +- **分类概览增强**:[main.py](./main.py) `_format_category_overview()` 每类显示 top 5(按 score 降序),含评分和可点击原文链接 +- **网摘 Prompt 改版**:[prompts/webzine.md](./prompts/webzine.md) 按参考消息风格重构:标题(主体+事件+态势)、价值点(35-45字)、正文(据 XX 报道起头,一段到底 280-320 字),JSON 输出含 title/value/body 三字段 +- **RSS Digest 移除**:[main.py](./main.py) 早报推送中移除 RSS Digest 板块,节省一次 LLM 调用;缺失的 `[持续跟踪]` 功能迁移至洞察 Prompt +- **洞察 [持续跟踪] 标记**:[prompts/cluster_insight.md](./prompts/cluster_insight.md) 跨日事件在事件脉络段首行标注 `[持续跟踪]`,区分前情提要(从素材早期报道提取)与最新突破 +- **跨日关联模块(6.3)**:[src/processors/cross_day.py](./src/processors/cross_day.py) 实体重叠(Jaccard)匹配今日簇与历史簇,输出热度/报道量变化趋势 + 趋势判定(上升/下降/持平/新晋),新增 `📈 跨日追踪` 板块 +- **网摘图片生成(6.6)**:[src/generators/image_renderer.py](./src/generators/image_renderer.py) 基于 PIL/Pillow 渲染网摘 JSON 为 PNG 图片,支持单篇 / 合并长图,自适应高度 + 动态文本换行;[src/generators/get_chinese_font.py](./src/generators/get_chinese_font.py) 中文字体管理(内置 NotoSansCJK → 系统字体 → 默认回退) +- **飞书图片推送**:[src/push/image_hosting.py](./src/push/image_hosting.py) 图床上传模块(公网 API 认证 → 文件上传 → URL 获取)+ [src/push/feishu.py](./src/push/feishu.py) 增强 `send_image()` 方法(图床上传 → Markdown 消息 → 飞书群) +- **图床配置**:`.env.example` 新增 `IMAGE_HOSTING_PASSWORD` 字段,支持从环境变量读取图床访问密码 + +### 修复 +- **图床 dir 参数编码问题**:`requests` 库将 `files={"dir": (None, "daily")}` 编码为 MIME 文件字段导致图床未识别目录,改为 `data={"dir": "daily"}` 分离发送后正常保存 +- **图床 URL 格式修正**:优先使用 `fullUrl` 完整字段,对相对路径 `url` 自动拼接域名前缀 + +### 改进 +- **早报推送流程扩展**:main.py 新增 `_generate_webzine_images()` 函数,在早报表象推送完成后自动生成并推送合并网摘图片到飞书群 +- **日志完善**:image_renderer.py 添加渲染命令执行、画布参数、字体加载等详细日志,便于排查渲染问题 +- **Docker 容器化部署**:[Dockerfile](./Dockerfile) 基于 Python 3.11-slim,内置 tini + Noto Sans CJK 中文字体;[docker-compose.yml](./docker-compose.yml) 三卷挂载(配置/数据/缓存),支持绿联云 NAS 部署 + +## 2026-06-01 + +### 新增 +- **OPML 订阅源支持**:改为从 `resources/rss_feeds.opml` 读取订阅源,支持分类分组(英文军事源 / 中文微信公众号源),共 137 个源。`config.json` 通过 `sources.base_opml` 字段引用 +- **`parse_opml()` / `merge_sources()`**:[src/config.py](./src/config.py) 新增 OPML 解析 + 源合并(base_opml + add - block - block_domains + xmlUrl 去重) +- **`test-fetch` 命令**:[main.py](./main.py) 新增测试抓取指令,支持 `--max N`(限定拉取源数量,默认 5)和 `--lookback N`(时间回溯窗口,分钟) +- **`--lookback` 参数**:[main.py](./main.py) `fetch` 和 `test-fetch` 命令均支持 `--lookback` 覆盖时间窗口,优先级:命令行 > config.json +- **评分后处理模块**:[src/scoring.py](./src/scoring.py) 实现硬约束(非目标域名 ≤79 / KOL 来源 ≤89)、时间衰减(半衰期可配,含最小年龄阈值)、动态阈值(均值 + 1.5×标准差,clamp [40, 90]) +- **网摘生成板块**:[src/sections/webzine/section.py](./src/sections/webzine/section.py) 参考消息风格网摘,支持字数校验重试(正文 280-320 字 / 价值点 35-45 字,最多 3 次)和并发生成 +- **提示词文件**:新增 [prompts/webzine.md](./prompts/webzine.md) 和 [prompts/category_overview.md](./prompts/category_overview.md) +- **多板块 Sentinel 支持**:[src/storage.py](./src/storage.py) `assemble_with_sentinels` 从仅支持 `rss` 扩展为 `rss → webzine → category_overview` 三板块 +- **早报 Webzine + 分类概览**:[main.py](./main.py) `_run_morning_push` 新增 TOP3 网摘生成和分类概览输出 + +### 修复 +- **`.env` 优先级**:[src/llm.py](./src/llm.py) `call_llm()` 和 [main.py](./main.py) `_llm_creds()` 中 `OPENAI_API_BASE` 和 `OPENAI_MODEL` 改为 `.env` 优先读取,config.json 作为回退 +- **Brotli 编码错误**:[src/fetcher.py](./src/fetcher.py) 请求头添加 `Accept-Encoding: gzip, deflate`,禁止服务器返回 br 压缩 +- **时间过滤 `break` → `continue`**:[src/fetcher.py](./src/fetcher.py) `_parse_feed_entries` 将 `break` 改为 `continue`,避免非严格倒序 RSS 导致漏掉新文章 +- **缓存 `published` 类型兼容**:[src/cache.py](./src/cache.py) `save_article()` 兼容 datetime / ISO 字符串 / None 三种 published 类型 +- **缓存 `id` 回退**:[src/cache.py](./src/cache.py) 当 `article["id"]` 不存在时自动使用 `link` 作为主键 +- **时间衰减过于激进**:[src/scoring.py](./src/scoring.py) 新增 `min_age_hours` 参数(默认 6h),文章发布不足该时间不衰减;衰减公式改为 `0.5^((age - min_age) / half_life)` + +### 改进 +- **每条源条目计数**:[src/fetcher.py](./src/fetcher.py) `fetch_single_feed_async` 输出 `📡 源名: N 条 (总 M 条)`,便于排查 +- **评分分布输出**:[main.py](./main.py) `run_fetch_job` 输出前 10 条评分分布 `📊 评分分布: [85, 72, 60, ...]` +- **时间窗口显示**:[main.py](./main.py) 输出增加 `时间窗口: N 分钟` +- **翻译管线接入**:[main.py](./main.py) `run_fetch_job` 集成了 `batch_translate_titles` 和 `batch_translate_contents` +- **Monitor 集成**:[main.py](./main.py) fetch/push 流程集成 `RunMonitor`,跟踪各阶段耗时 +- **ArticleCache 去重**:[main.py](./main.py) fetch 流程集成 SQLite 缓存去重 +- **config.json.example 更新**:新增 `translate`、`scoring`、`no_content_marker` 配置节,补充 webzine / category_overview prompt 路径 +- **translator 去回调化**:[src/translator.py](./src/translator.py) 移除 `call_ai_fn` 回调参数,直接使用 [src/llm.py](./src/llm.py) 的 `call_llm_sync` +- **即时推送阈值修正**:[config.json](./config.json) `hot_threshold` 从 85 改为 90,与 V3 开发计划一致 +- **源抓取成功率监控修复**:[src/fetcher.py](./src/fetcher.py) `fetch_all_feeds()` 回传源级成功/失败状态 → [main.py](./main.py) 接入 `monitor.record_source_result()` +- **部署脚本**:[scripts/setup_systemd.sh](./scripts/setup_systemd.sh) Linux systemd 一键安装(含卸载脚本)+ [scripts/setup_service.bat](./scripts/setup_service.bat) Windows 计划任务安装/管理 +- **源连通性检测脚本**:[scripts/check_sources.py](./scripts/check_sources.py) 逐源检测 HTTP 可达性 + RSS 可解析性 + 条目数/时效分布,输出与监控报表格式一致的汇总 +- **源成功率二次修复**:[src/fetcher.py](./src/fetcher.py) `fetch_single_feed_async()` 移除内部 try/except,异常向上传播至 `fetch_all_feeds` 的 `gather(return_exceptions=True)`,修复"源挂了但被计为成功"的误判 +- **实体提取 + 角度分类(阶段三第一轮)**:[prompts/extract_entities.md](./prompts/extract_entities.md) + [prompts/classify_angle.md](./prompts/classify_angle.md) + [src/processors/entity_extractor.py](./src/processors/entity_extractor.py),5 类实体 + 6 类角度,实体和角度两个 LLM 调用并发执行,已集成到 fetch 流程的评分→缓存之间 +- **实体提取 Prompt 优化**:[prompts/extract_entities.md](./prompts/extract_entities.md) 新增政策/福利双列示例(军事/科技 + 政策/福利/机构),修复"军娃福利"无法提取实体(军队幼儿园、教育优待)的问题 +- **角度分类 Prompt 优化**:[prompts/classify_angle.md](./prompts/classify_angle.md) 新增广告→空角度规则、6 类反例表、决策树优先级,"战略分析"回退率从 90% 降至 20% +- **广告四层防御纵深**:[main.py](./main.py) 新增 `_is_ad_entry()` + `_has_any_entity()` 辅助函数,实体提取/热点推送/网摘Top3/分类概览全链路过滤,确保广告不进入下游 +- **缓存 tags 补存**:[src/cache.py](./src/cache.py) `save_article()` 修复 `ai_scores_json` 仅存 `scores` 漏存 `tags` 的问题,改为存储 `{score, tags, raw_scores}` +- **实体提取独立测试**:[scripts/test_entity_extract.py](./scripts/test_entity_extract.py) 从缓存读取最近文章直接测试实体提取+角度分类效果 +- **loop --max 参数**:[main.py](./main.py) `loop` 命令支持 `--max N`,仅拉取前 N 个源的长跑模式 +- **实体归一化(3.5)**:[src/processors/entity_normalizer.py](./src/processors/entity_normalizer.py) 双层归一化架构:第一层规则映射(200+ 别名,零 API 成本)+ 第二层 LLM 消歧(WEBZINE_MODEL=deepseek-v4-pro 判断未知别名),已集成到 fetch 流程实体提取→缓存之间 +- **LLM 消歧层**:[src/processors/entity_normalizer.py](./src/processors/entity_normalizer.py) 新增 `normalize_entries_async()` / `_llm_disambiguate_category()`,规则未命中的实体调用 WEBZINE_MODEL 判断是否可合并,批量并发执行 +- **LLM 消歧回流**:LLM 消歧发现的别名(霹雳-21/PL-21、DARPA、Strait of Hormuz 等 11 条)已回流至规则映射表,下次零成本命中 +- **映射表独立 JSON**:[src/processors/entity_aliases.json](./src/processors/entity_aliases.json) 别名数据从代码中抽离为独立 JSON 文件,新增别名无需修改代码 +- **Pickle 缓存**:[src/processors/entity_normalizer.py](./src/processors/entity_normalizer.py) JSON 未变时从 .pickle 缓存加载映射表 + 反向索引,跳过 JSON 解析;惰性加载,首次调用才触发 +- **映射表版本控制**:[src/processors/entity_aliases.json](./src/processors/entity_aliases.json) 新增 `_version` / `_updated` / `_changelog` 字段,通过 `get_alias_version()` 读取变更历史 +- **call_llm model_override**:[src/llm.py](./src/llm.py) `call_llm()` 新增 `model_override` 参数,支持按任务指定不同模型(如 WEBZINE_MODEL) + +--- + +## 初始版本 + +### 基础架构 +- 双循环架构:Fetch 循环(每 30 分钟)+ Push 循环(每日定时) +- RSS 异步抓取(aiohttp + feedparser) +- HTML → Markdown 转换(markdownify) +- 中英分离翻译管线(hanzi 占比 > 20% 判定 + ThreadPoolExecutor 并发) +- SQLite 三级缓存(article_cache + category_summary_cache + webzine_text) +- 11 阶段运行监控(RunMonitor) +- LLM 评分 / 摘要 / 即时推送(OpenAI 兼容接口) +- 飞书卡片消息 V2 推送(8000 字符自动分片) +- systemd 服务化部署(fetch.timer + push.timer) +- Sentinel 分段标记(单文件多板块管理) +- JSON 文件存储(fetch / push / notify) diff --git a/my-daily/CONFIG.md b/my-daily/CONFIG.md new file mode 100644 index 0000000..9c802ec --- /dev/null +++ b/my-daily/CONFIG.md @@ -0,0 +1,251 @@ +# 环境变量与参数配置说明 + +本文档说明项目中所有可配置参数的含义、默认值、作用环节和调整影响。 + +## 优先级规则 + +``` +.env 环境变量 > config.json > 源码硬编码默认值 + (最高优先级) (中等) (最低兜底) +``` + +--- + +## 一、环境变量(.env / .env.example) + +环境变量由 `dotenv` 在程序启动时加载到 `os.environ`,在对应模块初始化时读取覆盖。 + +### 1.1 AI 接口配置 + +| 变量 | 必填 | 示例 | 说明 | +|------|:---:|------|------| +| `OPENAI_API_KEY` | ✅ | `sk-xxx` | API 密钥 | +| `OPENAI_API_BASE` | ✅ | `https://api.deepseek.com` | API 端点 | +| `OPENAI_MODEL` | ✅ | `deepseek-v4-flash` | 默认模型(评分/摘要/推送/实体/角度/精评) | +| `WEBZINE_MODEL` | — | `deepseek-v4-pro` | 网摘/实体消歧专用模型(不填则复用 OPENAI_MODEL) | + +### 1.2 推送配置 + +| 变量 | 必填 | 示例 | 说明 | +|------|:---:|------|------| +| `FEISHU_WEBHOOK` | — | `https://open.feishu.cn/...` | 飞书机器人 Webhook,不填则跳过推送 | + +### 1.3 评价管线参数覆盖 + +以下变量**取消注释即生效**,覆盖 `config.json` 中的同名参数。 + +| 变量 | 默认 | 作用 | 对应步骤 | +|------|:---:|------|:---:| +| `HEAT_THRESHOLD` | — | **固定**热度阈值(0-100),替代动态计算 | 步骤 9 | +| `HOT_THRESHOLD` | 90 | 即时推送分数线 | 步骤 11 | +| `CLUSTER_MERGE_THRESHOLD` | 4 | 聚类合并所需累计权重 | 步骤 7 | +| `PRECISE_BLEND_RATIO` | 0.4 | LLM 精评分在融合热度中的占比 | 步骤 10 | +| `MEMORY_WINDOW_HOURS` | 24 | 簇记忆去重时间窗口(同簇几小时内不重复推送) | 步骤 12 | + +--- + +## 二、配置参数(config.json / config.json.example) + +### 2.1 filter — 过滤与推送阈值 + +| 参数 | 默认值 | 说明 | 影响 | +|------|:---:|------|------| +| `min_score` | 60 | 最低入库分数(低于此分的文章不入库) | 提高 → 减少无关文章入库 | +| `hot_threshold` | 90 | 即时推送分数线 | 提高 → 更少热点推送;降低 → 更多推送 | +| `context_days` | 2 | 推送上下文窗口(天) | 用于生成推送时参考近期内容 | +| `keep_days` | 7 | 数据文件保留天数 | 超过自动清理 | +| `no_content_marker` | `[NO_NEW_CONTENT]` | LLM 判定的"无新内容"标记文字 | — | +| `cluster_promotion_offset` | 15 | 簇提升分数线偏移 | 热点簇中 `score ≥ hot_threshold - this` 的文章自动提升为热点 | + +### 2.2 schedule — 调度 + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `fetch_interval_minutes` | 30 | Fetch 循环间隔(分钟) | +| `fetch_lookback_minutes` | 60 | 抓取时间回溯窗口(分钟) | +| `push_cron` | `["0 8 * * *"]` | Push 定时(cron 表达式,默认每日 08:00) | +| `timezone_hours` | 8 | 时区偏移(+8 = 北京时间) | + +### 2.3 fetch — 抓取 + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `max_workers` | 10 | RSS 并发抓取数 | +| `timeout` | 30 | 单源请求超时(秒) | + +### 2.4 translate — 翻译 + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `max_concurrent` | 6 | 翻译并发数 | + +### 2.5 llm — LLM 调用 + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `max_prompt_chars` | 10000 | 单批次 Prompt 字符数上限(超限自动拆分批次) | +| `max_concurrent_batches` | 3 | 评分并发批次数 | +| `prompts` | 见下文 | 各环节 Prompt 文件路径 | + +### 2.6 scoring — 单篇评分 + +| 参数 | 默认值 | 步骤 | 说明 | +|------|:---:|:---:|------| +| `non_target_max_score` | 79 | 2 | 非目标域名(社交媒体)文章评分上限 | +| `kol_max_score` | 89 | 2 | KOL 个人来源文章评分上限 | +| `half_life_hours` | 12 | 3 | 时间衰减半衰期(小时) | +| `min_age_hours` | 6 | 3 | 最小衰减年龄(发布不足此时不衰减) | +| `dynamic_threshold_multiplier` | 1.5 | 4 | 动态阈值的 σ 乘数 | +| `fallback_threshold` | 60 | 4 | 数据不足时的保底阈值 | + +**时间衰减公式**: +``` +有效年龄 = max(0, 当前时间 - 发布时间 - min_age_hours) +衰减因子 = 0.5 ^ (有效年龄 / half_life_hours) +最终分数 = 原分数 × 衰减因子 +``` + +**动态阈值公式**: +``` +阈值 = 均值 + dynamic_threshold_multiplier × σ +clamp[40, 90] +``` + +### 2.7 cluster — 聚类成簇 + +| 参数 | 默认值 | 说明 | 调优建议 | +|------|:---:|------|------| +| `merge_threshold` | 4 | 累计权重达到此值才合并两篇文章 | 提高 → 更严格、更小的簇;降低 → 更宽松、更大的簇 | +| `core_entity_weight` | 3 | 达到此权重的实体类别可触发直接合并 | — | +| `entity_weights.equipment` | 3 | 装备实体的权重 | 共享装备即直接合并(泛化黑名单内除外) | +| `entity_weights.event` | 3 | 事件实体的权重 | 共享事件即直接合并 | +| `entity_weights.org` | 2 | 机构实体的权重 | 需与其他实体累计 ≥ `merge_threshold` | +| `entity_weights.person` | 2 | 人物实体的权重 | 同上 | +| `entity_weights.location` | 1 | 地点实体的权重 | 同上 | +| `generic_entities` | 23 个词 | 不触发直接合并的泛化实体黑名单 | 如 `无人机`、`导弹`、`雷达` 等 | + +**聚类逻辑**: +- 共享 equipment/event 实体(不在黑名单中)→ **直接合并** +- 共享 org/person/location 或黑名单内的 equipment → **累计权重 ≥ merge_threshold 才合并** +- 通过并查集传递:A 与 B 合并、B 与 C 合并 → A-B-C 同簇 + +### 2.8 heat — 簇热度计算 + +#### 启发式热度公式(步骤 8) + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `score_weight` | 0.4 | 单篇最高分权重(40%) | +| `propagation_weight` | 0.3 | 传播热度权重(30%) | +| `angle_weight` | 0.2 | 角度覆盖权重(20%) | +| `priority_weight` | 0.1 | 高优先级权重(10%) | +| `propagation_cap` | 30 | 传播热度得分上限 | +| `angle_cap` | 20 | 角度覆盖得分上限 | +| `priority_cap` | 10 | 高优先级加成上限 | +| `priority_per_90plus` | 5 | 每篇 ≥90 分文章的额外加分 | + +``` +热度 = min(100, + 最高分 × score_weight × 时间衰减 + + min(报道数 × log(来源多样性) × 3, propagation_cap) + + min(角度数/6 × angle_cap, angle_cap) + + min(≥90分文章数 × priority_per_90plus, priority_cap) +) +``` + +#### 热度阈值(步骤 9) + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `threshold_clamp_min` | 35 | 热度阈值下限(动态阈值不低于此值) | +| `threshold_clamp_max` | 80 | 热度阈值上限 | +| `threshold_multiplier` | 1.5 | 动态阈值的 σ 乘数 | + +``` +热度阈值 = 均值 + threshold_multiplier × σ +clamp[threshold_clamp_min, threshold_clamp_max] +``` + +#### LLM 精评融合(步骤 10) + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `precise_blend_ratio` | 0.4 | 精评分在融合热度中的占比(0-1) | +| `precise_top_n` | 30 | 送入 LLM 精评的簇数量 | + +``` +融合热度 = 启发式热度 × (1 - precise_blend_ratio) + 精评分 × precise_blend_ratio +``` + +### 2.9 memory — 簇记忆去重 + +| 参数 | 默认值 | 步骤 | 说明 | +|------|:---:|:---:|------| +| `window_hours` | 24 | 12 | 同簇重复推送抑制窗口(小时) | +| `cleanup_days` | 7 | 12 | 推送记录清理周期(天) | + +### 2.10 push — 推送 + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `push.feishu.enabled` | `true` | 是否启用飞书推送 | +| `push.feishu.apiKeyName` | `FEISHU_WEBHOOK` | 飞书 Webhook 的环境变量名 | + +### 2.11 sources — 订阅源 + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `base_opml` | `resources/rss_feeds.opml` | OPML 订阅源文件 | +| `add` | `[]` | 额外追加的 RSS 源 | +| `block` | `[]` | 屏蔽的源名称(支持 `*` 通配) | +| `block_domains` | `[]` | 屏蔽的域名(支持 `*.domain.com`) | + +--- + +## 三、常见调优场景 + +### 想要更多即时推送 + +```json +// config.json +{ "filter": { "hot_threshold": 85 } } +``` +或 `.env` 中 `HOT_THRESHOLD=85` + +### 想要更多热点簇 + +```json +// config.json +{ "heat": { "threshold_multiplier": 1.0, "threshold_clamp_min": 25 } } +``` + +### 同一热点反复推送太频繁 + +```json +// config.json +{ "memory": { "window_hours": 48 } } +``` +或 `.env` 中 `MEMORY_WINDOW_HOURS=48` + +### 簇太大(合并不够精细) + +```json +// config.json +{ "cluster": { "merge_threshold": 6 } } +``` + +### 精评分影响太大/太小 + +```json +// config.json +{ "heat": { "precise_blend_ratio": 0.2 } } +``` +精评分占比 20%(更依赖启发式)。 + +### 文章太旧不想推送 + +```json +// config.json +{ "scoring": { "half_life_hours": 6 } } +``` +半衰期从 12 小时缩短到 6 小时,旧文章衰减更快。 diff --git a/my-daily/Dockerfile b/my-daily/Dockerfile new file mode 100644 index 0000000..c216a89 --- /dev/null +++ b/my-daily/Dockerfile @@ -0,0 +1,42 @@ +# ============================================================ +# 军事科技每日资讯推送系统 - Docker 镜像 +# 基于 Python 3.11-slim,内置中文字体支持 +# ============================================================ +FROM python:3.11-slim + +LABEL maintainer="military-digest" +LABEL description="军事科技每日资讯推送系统" + +# ── 安装系统依赖(使用清华镜像加速)────────────────────────── +# tini: 信号转发(SIGTERM → 子进程安全退出) +# fontconfig + Noto Sans CJK: 网摘图片渲染中文字体 +RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources \ + && apt-get update && apt-get install -y --no-install-recommends \ + tini \ + fontconfig \ + fonts-noto-cjk \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# ── 工作目录 ────────────────────────────────────────────────── +WORKDIR /app + +# ── 复制依赖并安装(使用清华镜像加速)──────────────────────── +COPY requirements.txt . +RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple \ + && pip install --no-cache-dir -r requirements.txt + +# ── 复制项目代码 ────────────────────────────────────────────── +COPY . . + +# ── 创建运行时目录 ──────────────────────────────────────────── +RUN mkdir -p /app/data/fetch /app/data/hotalert /app/data/cluster /app/data/digest /app/data/images /app/data/cache /app/data/output /app/logs /app/cache + +# ── 入口 ────────────────────────────────────────────────────── +# tini 确保 docker stop 信号正确传递给子进程 +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["python", "start.py"] + +# ── 健康检查 ────────────────────────────────────────────────── +HEALTHCHECK --interval=60s --timeout=10s --start-period=120s --retries=3 \ + CMD python -c "import os; exit(0 if os.path.exists('/app/data') else 1)" \ No newline at end of file diff --git a/my-daily/QUICKSTART.md b/my-daily/QUICKSTART.md new file mode 100644 index 0000000..c7e656d --- /dev/null +++ b/my-daily/QUICKSTART.md @@ -0,0 +1,69 @@ +# 快速上手指南 + +## 环境准备 + +```powershell +# 1. 切换到项目目录 +cd d:\yinpeng\documents\code\military-digest-v3-full\my-daily + +# 2. 使用 py311_1 conda 环境(依赖已安装好) +# 如果依赖缺失,运行: +# & "C:\conda\envs\py311_1\python.exe" -m pip install -r requirements.txt + +# 3. 确保 .env 和 config.json 已配置 +# .env → API Key、Webhook 等密钥 +# config.json → 调度、评分、聚类等参数 +``` + +## 一键启动(推荐) + +```powershell +python start.py +``` + +同时启动数据管线(Fetch + Push 循环)和 Web 归档站点,所有输出实时记录到 `logs/` 目录。 + +按 `Ctrl+C` 安全停止所有服务。 + +## 常用命令 + +| 命令 | 说明 | +|------|------| +| `python start.py` | 一键启动:管线 + Web 归档 + 日志 | +| `python main.py check` | 校验 LLM 接口是否可用 | +| `python main.py test-fetch` | 测试抓取(仅拉取前 5 个源,验证流程) | +| `python main.py test-fetch --max 10 --lookback 300` | 测试抓取(前 10 个源,回溯 5 小时) | +| `python main.py fetch` | 单次完整抓取 → 评分 → 缓存 | +| `python main.py fetch --lookback 120` | 单次抓取(回溯 2 小时) | +| `python main.py push` | 单次推送(早报 / 默认模式) | +| `python main.py rss` | 仅打印 RSS Digest,不推送 | +| `python main.py loop` | 长跑模式(持续运行) | + +## 首次运行建议 + +```powershell +# 1. 校验环境 +python main.py check + +# 2. 测试抓取(验证整个流程) +python main.py test-fetch + +# 3. 确认无误后,一键启动 +python start.py +``` + +## 日志查看 + +所有运行日志保存在 `logs/YYYYMMDD-HHmmSS.log`,每次启动生成一个新文件。 + +```powershell +# 查看最新日志 +Get-Content logs\*.log -Tail 50 +``` + +## 配置要点 + +- **抓取间隔**:`.env` 中 `FETCH_INTERVAL_MINUTES=30`(每 30 分钟) +- **推送时间**:`.env` 中 `PUSH_CRON="0 8 * * *"`(每天 8:00) +- **API 切换**:修改 `.env` 中 `OPENAI_API_BASE` 和 `OPENAI_MODEL` +- 更多参数见 [CONFIG.md](./CONFIG.md) \ No newline at end of file diff --git a/my-daily/README.md b/my-daily/README.md new file mode 100644 index 0000000..a7484df --- /dev/null +++ b/my-daily/README.md @@ -0,0 +1,517 @@ +# 军事科技每日资讯推送系统 + +基于大模型的军事/科技新闻采集、评分、整合与推送系统。自动抓取 RSS 订阅源,经 LLM 评分筛选与实体聚类后,生成日报并推送到飞书群。 + +## 系统架构 + +``` +┌───────────────────────────────────────────────────────────────┐ +│ Fetch 循环(每 30 分钟) │ +│ RSS 抓取 → 中英翻译 → LLM 评分 → 硬约束过滤 → 动态阈值 │ +│ → 实体提取 + 角度分类 → 实体归一化 → 实体聚类 │ +│ → 广告过滤 → 缓存 → 簇热度计算 → 即时推送(≥90 分) │ +└──────────────────┬────────────────────────────────────────────┘ + ↓ +┌───────────────────────────────────────────────────────────────┐ +│ Push 循环(每日 08:00) │ +│ 推送 1: 🔥 热点速览(簇视图) │ +│ 今日速览 + 热点洞察(含[持续跟踪])+ 跨日追踪 + 趋势分析 │ +│ 推送 2: 📰 军事科技每日精选(单篇视图) │ +│ 网摘 Top3 + 分类概览(5 类 × 5 条) │ +│ 推送 3: 🖼️ 网摘图片(PNG 合并长图,图床上传后推送) │ +└───────────────────────────────────────────────────────────────┘ +``` + +| 核心功能 | 说明 | +|---------|------| +| 订阅源管理 | OPML 文件管理 137 个 RSS 源(英文军事 + 中文微信公众号) | +| 双语翻译 | 中英自动分离(汉字占比 > 20%),中文零 API 调用,外文并发翻译 | +| 三层评分 | 硬约束(非目标 ≤79 / KOL ≤89)+ 时间衰减 + 动态阈值 | +| 实体提取 | 5 类实体(人物/机构/装备/事件/地域)+ 6 类角度分类,双层归一化(规则 200+ 别名 + LLM 消歧) | +| 实体聚类 | 加权实体重叠 + 并查集,细粒度聚类保留多角度报道,自动命名簇 | +| 簇热度 | 四维评分(最高分 + 传播 + 角度 + 优先)+ LLM 精评融合,热度簇提升为热点 | +| 簇记忆 | SQLite 持久化已推送簇 ID,24h 内同簇不重复推送 | +| 广告过滤 | 四层防御纵深(tag 黑名单 → 动态阈值 → 空角度 → 空实体) | +| 增量缓存 | SQLite 三级缓存(文章/网摘/分类摘要),二次运行几乎零 token 消耗 | +| 飞书推送 | 即时热点推送(≥90 分)+ 每日早报三波(热点速览 + 每日精选 + 网摘图片) | +| 双视角洞察 | LLM 一次调用输出精炼摘要 + 结构化洞察(事件脉络/多角度分析/影响与展望) | +| 分类概览 | 5 类军事新闻分类(装备技术/地区安全/军事改革/产业观察/其他热点),每类 top 5 | +| 网摘图片 | PIL 渲染网摘为 PNG 合并长图,自适应高度 + 动态文本换行,图床 API 上传后推送飞书 | +| 反馈闭环 | 用户评分修正记录 + 规则自动生成,修正后的评分自动应用到后续批次 | +| 运行监控 | 12 阶段耗时 + API 调用统计 + 源抓取成功率 + 缓存命中率 | + +## 快速开始 + +### 环境要求 + +- Python 3.10+ +- 能访问 OpenAI 兼容 API(DeepSeek / 火山引擎 / OpenAI 等) + +### 安装 + +```bash +# 1. 进入项目目录 +cd my-daily + +# 2. 安装依赖 +pip install -r requirements.txt + +# 3. 配置环境变量 +cp .env.example .env +# 编辑 .env,填入 API Key 和 Webhook 地址 + +# 4. 配置系统参数(可选,已有默认值) +cp config.json.example config.json +# config.json 已存在时跳过,按需修改调度/评分/源过滤参数 +``` + +### 环境变量(.env) + +```env +# OpenAI 兼容接口(必填) +OPENAI_API_KEY=your_api_key +OPENAI_API_BASE=https://api.deepseek.com +OPENAI_MODEL=deepseek-v4-flash + +# LLM 消歧 + 网摘专用模型(可选,不填则复用 OPENAI_MODEL) +WEBZINE_MODEL=deepseek-v4-pro + +# 飞书推送(可选,不填则跳过推送) +FEISHU_WEBHOOK=your_webhook_url + +# 图床上传配置(可选,网摘图片通过图床 API 上传) +IMAGE_HOSTING_PASSWORD=your_image_hosting_password_here + +# ── 定时计划配置(可选,覆盖 config.json)── +# FETCH_INTERVAL_MINUTES=30 # RSS 拉取间隔(分钟) +# FETCH_LOOKBACK_MINUTES=60 # 回溯时间窗口(分钟) +# PUSH_CRON="0 8 * * *" # 推送定时计划(cron 表达式,多个用逗号分隔) +# TIMEZONE_HOURS=8 # 时区 + +# ── 评价管线参数覆盖(可选,最高优先级)── +# HEAT_THRESHOLD=30 # 热度阈值固定值 +# HOT_THRESHOLD=90 # 即时推送分数线 +# CLUSTER_MERGE_THRESHOLD=4 # 聚类合并阈值 +# PRECISE_BLEND_RATIO=0.4 # LLM 精评融合权重 +# MEMORY_WINDOW_HOURS=24 # 簇记忆去重时间窗口 +# CROSS_DAY_MIN_OVERLAP=0.05 # 跨日关联实体重叠阈值 +``` + +> **优先级规则**:`.env` 环境变量 > `config.json` > 源码默认值。详见 [CONFIG.md](CONFIG.md)。 + +### 配置项速查 + +所有参数及调优指南见 **[CONFIG.md](CONFIG.md)**。 + +| 配置节 | 控制环节 | 关键参数 | +|--------|----------|----------| +| **`.env`** | 接口/推送/定时/图床/评价覆盖 | `OPENAI_API_KEY`, `FEISHU_WEBHOOK`, `IMAGE_HOSTING_PASSWORD`, `FETCH_INTERVAL_MINUTES`, `PUSH_CRON` | +| `schedule` | 定时计划 | `fetch_interval_minutes`(30), `push_cron`(0 8 * * *) | +| `filter` | 过滤推送 | `hot_threshold`(90), `cluster_promotion_offset`(15) | +| `scoring` | 单篇评分 | `non_target_max_score`(79), `half_life_hours`(12), `min_age_hours`(6) | +| `cluster` | 聚类成簇 | `merge_threshold`(4), `entity_weights`, `generic_entities` | +| `heat` | 簇热度 | `score_weight`(0.4), `propagation_cap`(30), `precise_blend_ratio`(0.4) | +| `memory` | 去重记忆 | `window_hours`(24), `cleanup_days`(7) | + +## 命令用法 + +### 一键启动(推荐) + +```bash +python start.py +``` + +同时启动数据管线后端(Fetch + Push 循环)和 Web 归档站点(http://0.0.0.0:8080)。所有终端输出实时写入 `logs/YYYYMMDD-HHmmSS.log`,按 Ctrl+C 安全停止所有服务。 + +### main.py 命令 + +```bash +python main.py [options] +``` + +| 命令 | 说明 | 示例 | +|------|------|------| +| `check` | 校验 LLM 接口可达性 | `python main.py check` | +| `fetch` | 单次抓取 → 翻译 → 评分 → 缓存 | `python main.py fetch --lookback 120` | +| `test-fetch` | 测试抓取(限定源数量) | `python main.py test-fetch --max 5 --lookback 300` | +| `push` | 单次推送(含早报/默认两种模式) | `python main.py push` | +| `rss` | 仅生成 RSS Digest(不推送,打印到终端) | `python main.py rss` | +| `loop` | 长跑模式(Fetch + Push 双循环并行) | `python main.py loop` | +| `feedback` | 提交评分修正反馈 | `python main.py feedback --link URL --score 85 --reason "理由"` | + +### test-fetch / fetch 参数 + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `--max N` | 5(仅 test-fetch) | 仅拉取前 N 个源 | +| `--lookback N` | 来自 config | 时间回溯窗口(分钟) | + +### feedback 参数 + +| 参数 | 说明 | +|------|------| +| `--link URL` | 需要修正评分的文章链接 | +| `--score N` | 修正后的分数(0-100) | +| `--reason "..."` | 修正理由 | +| `--list` | 查看所有已记录的修正 | +| `--rules` | 查看已生成的修正规则 | +| `--stats` | 查看修正统计数据 | + +## 部署 + +### Linux(systemd) + +```bash +sudo bash scripts/setup_systemd.sh # 一键安装 +bash scripts/status.sh # 查看运行状态 +sudo journalctl -u my-daily-fetch -f # 实时日志 +sudo bash scripts/uninstall_systemd.sh # 卸载 +``` + +### Windows(计划任务) + +```batch +scripts\setup_service.bat install # 安装为计划任务 +scripts\setup_service.bat start # 手动运行一次 +scripts\setup_service.bat status # 查看任务状态 +scripts\setup_service.bat loop # 前台调试 +scripts\setup_service.bat uninstall # 卸载计划任务 +``` + +### Docker(推荐,适用于绿联云 NAS / 任何 Linux 主机) + +```bash +# 1. 准备目录和配置 +mkdir -p docker/data docker/cache +cp .env docker/.env + +# 2. 构建并启动 +docker compose up -d --build + +# ── 运维 ────────────────────────────────────── +docker compose logs -f # 查看实时日志 +docker compose restart # 重启 +docker compose down # 停止并删除容器 +docker compose up -d --build # 更新代码后重建并启动 +``` + +#### 目录结构(Docker 部署) + +``` +my-daily/ +├── docker/ # Docker 持久化目录 +│ ├── .env # 配置文件(API Key, Webhook 等) +│ ├── data/ # 运行时数据(fetch/push/image 文件) +│ └── cache/ # SQLite 缓存(避免重复消费 API) +├── Dockerfile # 镜像构建文件 +├── docker-compose.yml # Docker Compose 配置 +└── .dockerignore # 构建忽略文件 +``` + +#### 在绿联云 NAS 上的操作 + +```bash +# 方案 A:SSH 直接操作(NAS 开启 SSH 后) +# 将 my-daily 目录上传到 NAS,进入目录执行: +docker compose up -d --build + +# 方案 B:绿联云 Docker 管理器(图形界面) +# 1. 在本机构建镜像并导出: +docker save -o military-digest.tar military-digest:latest + +# 2. 将 tar 文件上传到 NAS +# 3. 在绿联云 Docker > 镜像管理 > 导入镜像 +# 4. 创建容器时挂载卷: +# - /path/to/docker/.env → /app/.env(只读) +# - /path/to/docker/data → /app/data +# - /path/to/docker/cache → /app/cache +# 5. 设置重启策略:always +# 6. 启动容器 +``` + +## 运维工具 + +| 脚本 | 平台 | 功能 | +|------|:---:|------| +| [scripts/check_sources.py](scripts/check_sources.py) | 通用 | 源连通性检测:HTTP 可达性 + RSS 解析 + 条目时效分布 | +| [scripts/test_entity_extract.py](scripts/test_entity_extract.py) | 通用 | 实体提取 + 角度分类独立测试 | +| [scripts/test_normalizer.py](scripts/test_normalizer.py) | 通用 | 实体归一化效果验证 | +| [scripts/test_llm_disambiguate.py](scripts/test_llm_disambiguate.py) | 通用 | LLM 消歧层独立测试 | +| [scripts/status.sh](scripts/status.sh) | Linux | 项目状态查看 | +| [scripts/install.sh](scripts/install.sh) | Linux | 依赖安装 + systemd 服务文件生成 | +| [scripts/uninstall.sh](scripts/uninstall.sh) | Linux | systemd 服务卸载 | + +### 源连通性检测 + +```bash +python scripts/check_sources.py # 全部源 +python scripts/check_sources.py --max 50 # 前 50 个源 +python scripts/check_sources.py --failed-only # 仅显示失败源 +python scripts/check_sources.py --show-urls # 显示 URL +python scripts/check_sources.py --concurrency 20 # 自定义并发数 +``` + +## 数据与输出 + +| 目录/文件 | 说明 | 保留策略 | +|-----------|------|:---:| +| `data/fetch-YYYY-MM-DD.json` | 当日 Fetch 结果(含评分、摘要) | 7 天 | +| `data/hotalert-YYYYMMDD-HHmmSS.md` | 军事科技快讯(即时推送) | 7 天 | +| `data/cluster-YYYYMMDD-HHmmSS.md` | 热点速览(簇视图推送) | 7 天 | +| `data/digest-YYYYMMDD-HHmmSS.md` | 军事科技每日精选(单篇视图推送) | 7 天 | +| `data/images/YYYYMMDD/` | 网摘 PNG 图片 | 7 天 | +| `data/article_cache.db` | SQLite 三级缓存(文章/网摘/分类摘要) | 7 天自动清理 | +| `logs/YYYYMMDD-HHmmSS.log` | 运行日志(终端输出完整记录) | 手动清理 | + +## 评价管线详解 + +从 RSS 抓取到即时推送,文章经历 **12 个步骤**,分属两个层面:单篇评分层和簇级评价层。 + +### 第一层:单篇评分 + +#### 步骤 1 — LLM 批量评分 + +调用 `score_batch()` 对每篇文章打分(0-100),同时产出 `tags` 和 `summary`。 + +| 配置项 | 位置 | 默认值 | 说明 | +|--------|------|:---:|------| +| `max_prompt_chars` | `config.json → llm` | 10000 | 单批次 Prompt 上限,超限自动拆分 | +| `max_concurrent_batches` | `config.json → llm` | 3 | LLM 并发批次数 | +| Prompt | `prompts/score_batch.md` | — | 含核心约束:非军事≤79、KOL≤89、90+需官方首发 | + +#### 步骤 2 — 硬约束过滤 + +`apply_hard_constraints()` 对 LLM 评分施加上限: + +| 约束 | 阈值 | 作用 | +|------|:---:|------| +| 非目标域名(YouTube/Twitter/B站/微博等) | **≤79** | 社交媒体来源不可信 | +| KOL 域名(同上) | **≤89** | 个人账号非官方首发 | + +> 配置:`config.json → scoring.non_target_max_score` / `kol_max_score` + +#### 步骤 3 — 时间衰减 + +`apply_time_decay()` 指数衰减旧文章分数: + +``` +有效年龄 = max(0, 当前时间 - 发布时间 - min_age_hours) +衰减因子 = 0.5 ^ (有效年龄 / half_life_hours) +最终分数 = 原分数 × 衰减因子 +``` + +| 参数 | 默认值 | 说明 | +|------|:---:|------| +| `half_life_hours` | 12 | 半衰期 | +| `min_age_hours` | 6 | 6 小时内不衰减 | + +#### 步骤 4 — 动态阈值 + +`calculate_dynamic_threshold()` 筛选合格文章: + +``` +阈值 = 均值 + 1.5 × 标准差 +clamp[40, 90] +``` + +只有 `score ≥ 阈值` 的文章进入下一步实体提取。 + +--- + +### 第二层:簇级评价 + +#### 步骤 5 — 实体提取 + 角度分类 + +`batch_extract_all()` 对合格文章提取实体和角度,两个 LLM 调用并发执行。 + +| 类别 | Prompt | 输出 | +|------|--------|------| +| 实体(5 类) | `prompts/extract_entities.md` | `entities: {person, org, equipment, event, location}` | +| 角度(6 类) | `prompts/classify_angle.md` | `reporting_angle: 政策发布/技术突破/战略分析/舆论反应/冲突事件/人物动态` | + +#### 步骤 6 — 实体归一化 + +`normalize_entries_async()` 双层归一化: + +| 层 | 方式 | 成本 | 示例 | +|----|------|:---:|------| +| 规则映射 | `entity_aliases.json`(200+ 别名) | 零 | `PL-21 → 霹雳-21`、`DARPA → 美国国防高级研究计划局` | +| LLM 消歧 | `WEBZINE_MODEL` 批量判断未知别名 | API | `Strait of Hormuz → 霍尔木兹海峡` | + +#### 步骤 7 — 聚类成簇 + +`cluster_articles()` 加权实体重叠 + 并查集: + +| 实体类别 | 权重 | 合并条件 | +|----------|:---:|------| +| equipment / event | **3**(核心) | 共享即直接合并(泛化实体黑名单内的除外) | +| org / person | **2** | 需累计权重 ≥ 4 | +| location | **1** | 需累计权重 ≥ 4 | + +> 泛化黑名单(不触发直接合并):`无人机`、`导弹`、`雷达`、`坦克`、`战机`、`军舰` 等 23 个类别词 + +#### 步骤 8 — 启发式热度计算 + +`calculate_cluster_heat()` 四维评分(0-100): + +``` +热度 = min(100, + 最高分 × 0.4 × 时间衰减 + + min(报道数 × log(来源多样性) × 3, 30) + + 角度数/6 × 20 + + min(90+文章数 × 5, 10) +) +``` + +#### 步骤 9 — 热度阈值 + +`calculate_hotspot_threshold()` 动态阈值: + +``` +热度阈值 = 均值 + multiplier × σ +clamp[clamp_min, clamp_max] +``` + +| 配置 | 位置 | 默认值 | 效果 | +|------|------|:---:|------| +| `threshold_clamp_min` | `config.json → heat` | 35 | 阈值下限 | +| `threshold_clamp_max` | `config.json → heat` | 80 | 阈值上限 | +| `threshold_multiplier` | `config.json → heat` | 1.5 | σ 乘数 | +| `HEAT_THRESHOLD` | `.env` | — | **固定覆盖**(最高优先级) | + +#### 步骤 10 — LLM 精评融合 + +`precise_score_clusters()` 对 Top 30 簇调用 LLM 重新精评(军事价值40% + 时效25% + 信息密度20% + 传播15%),与启发式 6:4 融合: + +``` +融合热度 = 启发式 × 0.6 + 精评分 × 0.4 +``` + +> Prompt:`prompts/precise_heat.md`,失败时回退纯启发式热度。 + +#### 步骤 11 — 热点识别 + +热点文章 = **三类并集**: + +| 来源 | 条件 | +|------|------| +| 直接热点 | 单篇 score ≥ `hot_threshold`(90) | +| 簇提升 | 所在簇热度 ≥ 阈值,且单篇 ≥ `hot_threshold - 15`(75) | + +#### 步骤 12 — 簇记忆去重 + +`cluster_memory.is_recently_pushed()` 检查簇是否在 24h 内已推送,已推送则跳过。 + +> 存储:SQLite 表 `cluster_memory`,7 天自动清理。 + +--- + +### 配置速查 + +| 参数 | 位置 | 默认值 | 作用环节 | +|------|------|:---:|------| +| `hot_threshold` | `config.json → filter` | 90 | 步骤 11:直接热点线 | +| `non_target_max_score` | `config.json → scoring` | 79 | 步骤 2:非目标封顶 | +| `kol_max_score` | `config.json → scoring` | 89 | 步骤 2:KOL 封顶 | +| `half_life_hours` | `config.json → scoring` | 12 | 步骤 3:衰减半衰期 | +| `dynamic_threshold_multiplier` | `config.json → scoring` | 1.5 | 步骤 4:σ 乘数 | +| `threshold_clamp_min` | `config.json → heat` | 35 | 步骤 9:热度下限 | +| `threshold_multiplier` | `config.json → heat` | 1.5 | 步骤 9:热度 σ 乘数 | +| `HEAT_THRESHOLD` | `.env` | — | 步骤 9:固定覆盖 | + +## 项目文件说明 + +```text +my-daily/ +├── main.py # 入口 + 双循环架构 +├── start.py # 一键启动(含日志记录) +├── config.json # 用户配置(不入库) +├── .env # API 密钥(不入库) +├── config.json.example # 配置模板 +├── .env.example # 环境变量模板 +├── requirements.txt # Python 依赖 +├── CONFIG.md # 配置参数完整说明 +├── Dockerfile # Docker 镜像构建 +├── docker-compose.yml # Docker Compose 配置 +├── .dockerignore # Docker 构建忽略 +├── logs/ # 运行日志(YYYYMMDD-HHmmSS.log) +├── prompts/ # LLM 提示词(10 个) +│ ├── score_batch.md # 批量评分 +│ ├── digest.md # RSS Digest 生成 +│ ├── immediate_push.md # 即时推送快讯 +│ ├── webzine.md # 参考消息风格网摘 +│ ├── category_overview.md # 分类概览 +│ ├── extract_entities.md # 实体提取(5 类) +│ ├── classify_angle.md # 角度分类(6 类) +│ ├── precise_heat.md # 热点精评(四维评分) +│ ├── cluster_insight.md # 双视角洞察 +│ └── cluster_summary.md # 轻量簇摘要 +├── resources/ +│ └── rss_feeds.opml # RSS 订阅源(137 个源) +├── templates/ # 推送模板 +│ ├── cluster_view.md # 簇视图模板 +│ ├── article_view.md # 单篇视图模板 +│ └── immediate_push.md # 即时推送模板 +├── scripts/ # 运维 + 测试脚本 +│ ├── check_sources.py # 源连通性检测 +│ ├── setup_systemd.sh # Linux systemd 安装 +│ ├── setup_service.bat # Windows 计划任务管理 +│ ├── install.sh / uninstall.sh +│ └── status.sh +├── systemd/ # systemd 模板 +├── src/ # 源代码 +│ ├── config.py # 配置加载 + OPML 解析 + 环境变量覆盖 +│ ├── fetcher.py # RSS 异步抓取 +│ ├── translator.py # 中英分离翻译管线 +│ ├── llm.py # LLM 调用(评分/摘要/洞察/精评) +│ ├── scoring.py # 评分后处理(约束/衰减/阈值) +│ ├── cache.py # SQLite 三级缓存 +│ ├── storage.py # 文件存储 + Sentinel 分段 +│ ├── monitor.py # 运行监控统计 +│ ├── logger.py # 日志系统 +│ ├── processor.py # HTML → Markdown +│ ├── utils.py # 通用工具函数 +│ ├── markdown_utils.py # Markdown 解析工具 +│ ├── renderer.py # 模板渲染 +│ ├── generators/ # 图片生成模块 +│ │ ├── image_renderer.py # 网摘 PNG 图片渲染 +│ │ └── get_chinese_font.py # 中文字体管理 +│ ├── processors/ # 处理管线 +│ │ ├── entity_extractor.py # 实体提取 + 角度分类 +│ │ ├── entity_normalizer.py # 实体归一化(双层) +│ │ ├── entity_aliases.json # 别名映射表 +│ │ ├── cluster_engine.py # 实体聚类引擎 +│ │ ├── cluster_memory.py # 簇记忆系统 +│ │ ├── heat_calculator.py # 簇热度计算 +│ │ ├── rank_engine.py # 排序引擎 +│ │ ├── trend_analyzer.py # 趋势分析 +│ │ ├── feedback.py # 反馈闭环 +│ │ └── cross_day.py # 跨日关联 +│ ├── integrators/ +│ │ └── summary_integrator.py # 摘要整合编排 +│ ├── utils/ +│ │ └── prompt_checker.py # Prompt 防退化检查器 +│ ├── push/ # 推送模块 +│ │ ├── feishu.py # 飞书 Webhook 推送 +│ │ └── image_hosting.py # 图床上传模块 +│ └── sections/ # 内容板块 +│ ├── rss/section.py # RSS Digest 板块 +│ └── webzine/section.py # 网摘生成板块 +├── CHANGELOG.md # 更新日志 +├── PROGRESS.md # 开发进展追踪 +└── README.md # 本文件 +``` + +## 常见问题 + +**Q: 为什么英文源抓不到文章?** +A: 英文军事源(如 The War Zone、Defense One)更新频率低,可能 2-3 天无新文章。中文微信公众号源更新频繁,扩大 `--lookback` 可覆盖更多时间窗口。 + +**Q: 如何添加/屏蔽订阅源?** +A: OPML 文件管理为主。临时追加用 `config.json` 的 `sources.add`,屏蔽用 `sources.block` 或 `sources.block_domains`。支持 `*.domain.com` 通配。 + +**Q: 如何修改定时计划?** +A: 在 `.env` 中设置 `FETCH_INTERVAL_MINUTES`(拉取间隔)和 `PUSH_CRON`(推送定时,cron 表达式),重启服务即可生效。 + +**Q: 内存/CPU 占用如何?** +A: 异步抓取 + 并发控制(默认 10 并发),翻译/评分均分批处理。137 源全量抓取峰值内存约 200MB,日常运行稳定在 100MB 以内。 \ No newline at end of file diff --git a/my-daily/config.json.example b/my-daily/config.json.example new file mode 100644 index 0000000..abb1969 --- /dev/null +++ b/my-daily/config.json.example @@ -0,0 +1,99 @@ +{ + "filter": { + "min_score": 60, + "hot_threshold": 90, + "context_days": 1, + "keep_days": 7, + "no_content_marker": "[NO_NEW_CONTENT]", + "cluster_promotion_offset": 15 + }, + "schedule": { + "fetch_interval_minutes": 30, + "fetch_lookback_minutes": 60, + "push_cron": ["0 8 * * *"], + "timezone_hours": 8 + }, + "fetch": { + "max_workers": 10, + "timeout": 15 + }, + "translate": { + "max_concurrent": 6 + }, + "llm": { + "provider": "openai", + "model": "deepseek-v4-flash", + "baseUrl": "https://api.deepseek.com", + "apiKeyName": "OPENAI_API_KEY", + "max_prompt_chars": 10000, + "max_concurrent_batches": 3, + "prompts": { + "score_batch": "prompts/score_batch.md", + "digest": "prompts/digest.md", + "immediate_push": "prompts/immediate_push.md", + "webzine": "prompts/webzine.md", + "category_overview": "prompts/category_overview.md" + } + }, + "scoring": { + "non_target_max_score": 79, + "kol_max_score": 89, + "half_life_hours": 12, + "min_age_hours": 6, + "dynamic_threshold_multiplier": 1.5, + "fallback_threshold": 60 + }, + "cluster": { + "merge_threshold": 4, + "core_entity_weight": 3, + "entity_weights": { + "equipment": 3, + "event": 3, + "org": 2, + "person": 2, + "location": 1 + }, + "generic_entities": [ + "无人机", "导弹", "雷达", "坦克", "战机", "军舰", "潜艇", + "卫星", "火箭", "火炮", "直升机", "装甲车", "护卫舰", + "驱逐舰", "轰炸机", "巡航导弹", "弹道导弹", "防空导弹", + "战斗机", "运输机", "预警机", "加油机", "反舰导弹" + ] + }, + "heat": { + "score_weight": 0.4, + "propagation_weight": 0.3, + "angle_weight": 0.2, + "priority_weight": 0.1, + "propagation_cap": 30, + "angle_cap": 20, + "priority_cap": 10, + "priority_per_90plus": 5, + "threshold_clamp_min": 35, + "threshold_clamp_max": 80, + "threshold_multiplier": 1.5, + "precise_blend_ratio": 0.4, + "precise_top_n": 30 + }, + "memory": { + "window_hours": 24, + "cleanup_days": 7, + "cache_cleanup_days": 60 + }, + "cross_day": { + "min_overlap": 0.05, + "lookback_days": 3 + }, + "push": { + "feishu": { + "enabled": true, + "apiKeyName": "FEISHU_WEBHOOK" + } + }, + "sources": { + "base_opml": "resources/rss_feeds.opml", + "add": [], + "block": [], + "block_domains": [] + } +} diff --git a/my-daily/docker-compose.nas.yml b/my-daily/docker-compose.nas.yml new file mode 100644 index 0000000..260fa2b --- /dev/null +++ b/my-daily/docker-compose.nas.yml @@ -0,0 +1,30 @@ +services: + military-digest: + image: my-daily-military-digest:latest + container_name: military-digest + restart: unless-stopped + + # ── 文件映射 ────────────────────────────────────────────── + volumes: + # 配置文件(在 NAS 文件管理器中直接修改,修改后重启容器生效) + - /volume2/webdav/code/my-daily/docker/.env:/app/.env:ro + - /volume2/webdav/code/my-daily/docker/config.json:/app/config.json:ro + # 提示词文件 + - /volume2/webdav/code/my-daily/docker/prompts:/app/prompts:ro + # 运行时数据 + - /volume2/webdav/code/my-daily/docker/data:/app/data + # 运行日志 + - /volume2/webdav/code/my-daily/docker/logs:/app/logs + + environment: + - TZ=Asia/Shanghai + - PYTHONUNBUFFERED=1 + + ports: + - "8080:8080" + + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" \ No newline at end of file diff --git a/my-daily/docker-compose.yml b/my-daily/docker-compose.yml new file mode 100644 index 0000000..8b49dc9 --- /dev/null +++ b/my-daily/docker-compose.yml @@ -0,0 +1,39 @@ +services: + military-digest: + build: . + container_name: military-digest + restart: unless-stopped + + # ── 文件映射 ────────────────────────────────────────────── + volumes: + # 配置文件(在 NAS 上直接修改,容器内只读) + - ./docker/.env:/app/.env:ro + - ./docker/config.json:/app/config.json:ro + # 提示词文件(在 NAS 上直接修改,容器内只读) + - ./docker/prompts:/app/prompts:ro + # 运行时数据(持久化保存,含 fetch/hotalert/cluster/digest/images/cache 子目录) + - ./docker/data:/app/data + # 运行日志(持久化,方便排查问题) + - ./docker/logs:/app/logs + + # ── 环境变量 ────────────────────────────────────────────── + environment: + - TZ=Asia/Shanghai + - PYTHONUNBUFFERED=1 + + # ── 端口映射(Web 归档站点)─────────────────────────────── + ports: + - "8080:8080" + + # ── 资源限制(绿联云 NAS 建议值) ───────────────────────── + # deploy: + # resources: + # limits: + # memory: 512M + + # ── 日志轮转 ────────────────────────────────────────────── + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" \ No newline at end of file diff --git a/my-daily/docs/20260529_新闻智能分析系统开发计划_v3.md b/my-daily/docs/20260529_新闻智能分析系统开发计划_v3.md new file mode 100644 index 0000000..b35ba88 --- /dev/null +++ b/my-daily/docs/20260529_新闻智能分析系统开发计划_v3.md @@ -0,0 +1,1249 @@ +# 新闻智能分析系统开发计划 v3.0 + +> 基于大模型的时政/军事/科技新闻聚类、评价与整合系统 +> +> **迭代说明**:参考 AI Daily 项目实践与 military-digest-v3 工程落地经验,融合细粒度聚类、SQLite 增量缓存、翻译管线、网摘生成与工程化部署经验 + +--- + +## 一、项目概述 + +### 1.1 项目目标 +构建一个自动化新闻分析系统,实现: +- **细粒度聚类**:保留多角度报道,避免简单去重丢失热点 +- **动态热点识别**:基于当日新闻分布统计特征自适应判定热点 +- **三层整合**:摘要级速览(全量)+ 洞察级深度分析(Top 10)+ 参考消息风格网摘(Top 3) +- **混合处理**:实时热点感知 + 每日批量汇总生成早报 +- **跨日关联**:追踪同一事件的多日发展脉络 +- **增量缓存**:SQLite 三级缓存(文章/网摘/分类摘要),支持断点续跑,再次运行几乎零 token 消耗 +- **外文翻译**:中英分离 + 并发翻译,中文文章零 API 调用 + +### 1.2 核心约束 +| 维度 | 约束 | +|------|------| +| 数据规模 | 200-500篇/日 | +| 数据源 | RSS Feed(公众号订阅源,含外文源) | +| 输出格式 | Markdown日报 / 网摘PNG长图 / 网摘TXT文本 / 飞书推送 | +| 成本预算 | 混合模型调度,日成本约1.3-1.6元 | +| 处理时效 | 批处理15-20分钟完成(冷启动基准:5源156篇→153s) | +| 缓存命中 | 二次运行 ~5s,API 调用 0 次 | +| 部署方式 | 支持systemd服务化部署 | + +### 1.3 双循环架构(参考AI Daily优化) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Fetch 循环(实时) │ +│ 每30分钟运行一次 │ +│ RSS抓取 → 中英分离 → 外文并发翻译 → LLM评分 → 重要性判断 │ +│ ↓ ↓ ↓ │ +│ 存入SQLite缓存 存入JSON文件(fetch-yyyy-mm-dd.json) 飞书推送│ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ Push 循环(定时) │ +│ 每日定时运行(如早8点) │ +│ 读取碎片化信息 → 细粒度聚类 → 多角度关联 → 综合评分 │ +│ → 摘要级整合(全量) → 洞察级整合(Top10) → 参考消息网摘(Top3) │ +│ → 生成日报+网摘图片 → 飞书推送 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、与现有项目的对比分析 + +### 2.1 与 AI Daily 的相似性 + +| 维度 | AI Daily | 本系统 | 相似度 | +|------|----------|--------|--------| +| **数据源** | RSS订阅源 | RSS订阅源(公众号+外文) | ⭐⭐⭐⭐⭐ | +| **核心流程** | RSS→LLM评分→推送/汇总 | RSS→翻译→聚类→评分→整合→推送/汇总 | ⭐⭐⭐⭐ | +| **双循环架构** | Fetch循环+Push循环 | 实时检测+批处理 | ⭐⭐⭐⭐⭐ | +| **即时推送** | 飞书/Discord webhook | 飞书 webhook | ⭐⭐⭐⭐ | +| **定时汇总** | 每日定时推送 | 每日早报生成 | ⭐⭐⭐⭐⭐ | +| **成本预算** | 每天6毛钱 | 每天1.3-1.6元 | ⭐⭐⭐⭐ | + +### 2.2 与 military-digest-v3 的对比 + +| 维度 | military-digest-v3(现有系统) | 本系统(新架构) | 差异 | +|------|------|------|:---:| +| **架构** | 单管线(每日一次全量运行) | 双循环(Fetch 30min + Push 每日) | 🔄 架构升级 | +| **去重** | 同源同标题简单去重 | 细粒度语义聚类 + 多角度保留 | 🔄 策略升级 | +| **评分** | 四维度固定加权 | 双层筛选:硬约束 + 动态阈值融合 | 🔄 质量升级 | +| **翻译** | ✅ 中英分离 + 并发翻译 | ✅ 继承 + 统一为 Fetch 循环环节 | ✅ 复用 | +| **缓存** | ✅ SQLite 三级缓存(文章/网摘/分类摘要) | ✅ 继承 + 扩展为跨日关联数据源 | ✅ 复用 | +| **监控** | ✅ 11阶段耗时 + API统计 + 源成功率 | ✅ 继承 + 增加聚类/热点识别指标 | ✅ 复用 | +| **网摘** | ✅ 参考消息风格 + 字数校验重试 | ✅ 继承 + 作为第三层整合输出 | ✅ 复用 | +| **推送** | 企业微信 + 飞书 | **聚焦飞书 webhook** | 🔄 收束 | +| **跨日关联** | ❌ 无 | ✅ Sentinel标记 + 事件时间线追踪 | 🆕 新增 | +| **防退化** | ❌ 无 | ✅ 禁止套话 + 素材支撑要求 | 🆕 新增 | + +### 2.3 本系统的差异化优势 + +| 特性 | AI Daily | military-digest-v3 | 本系统 | 价值 | +|------|----------|------|------|------| +| **热点识别** | 单篇文章评分 | 四维度固定加权 | **双层筛选:硬约束+动态阈值融合** | 质量控制+热点发现协同 | +| **去重策略** | 简单去重 | 同源标题去重 | 细粒度聚类+多角度保留 | 不丢失多角度报道 | +| **内容整合** | 单篇摘要 | 摘要+网摘 | 三层整合(摘要+洞察+网摘) | 覆盖速览/深度/传播三种需求 | +| **翻译能力** | 无 | ✅ 中英分离并发 | ✅ 继承 | 外文源零额外成本 | +| **增量缓存** | JSON文件 | ✅ SQLite三级缓存 | ✅ 继承+扩展 | 断点续跑,二次运行~5s | +| **网摘生成** | 无 | ✅ 参考消息风格+字数校验 | ✅ 继承 | 适合直接传播的成品内容 | +| **领域聚焦** | AI领域 | 军事/科技 | 时政/军事/科技 | 专业领域实体识别 | +| **角度分类** | 无 | 无 | 6种报道角度分类 | 支持多角度整合 | +| **跨日关联** | 无 | 无 | 事件时间线追踪+Sentinel标记 | 追踪事件发展脉络 | + +### 2.4 从 AI Daily 借鉴的核心设计 + +1. **双循环架构**:Fetch循环(实时)+ Push循环(定时) +2. **systemd服务化**:使用systemd timer替代Python内部定时,提升稳定性 +3. **文件存储规范**:fetch/notify/push三种文件类型,明确保留策略 +4. **评分硬约束**:非目标领域上限、KOL转述上限,有效控制信息质量 +5. **Prompt防退化**:禁止套话、要求从素材出发,避免LLM输出风格趋同 +6. **双视角洞察**:metadata(事实压缩)+ 正文(趋势判断)解耦 +7. **Sentinel分段标记**:单文件多板块精确管理,支持跨日关联 + +### 2.5 从 military-digest-v3 继承的工程资产 + +1. **SQLite 三级缓存**:文章缓存 + 网摘缓存 + 分类摘要缓存,含自动过期清理和数据库迁移机制 +2. **翻译管线**:中英分离(汉字占比 > 20% 判定)+ 外文并发翻译(ThreadPoolExecutor),中文文章零 API 调用 +3. **监控统计体系**:11 阶段耗时 + API 调用按用途/模型分组 + 源抓取成功/失败率 + 缓存命中率 +4. **网摘生成**:参考消息风格 + 公众号源智能识别前缀 + 字数校验重试(280-320字,最多3次) +5. **异步流式管线**:生产者-消费者模型,RSS 抓取与 AI 处理流水线并行 +6. **图片生成**:Pillow 命令模式渲染,支持单篇 + TOP3 合并长图 + +--- + +## 三、开发阶段规划 + +### 阶段一:基础架构搭建(Week 1-2) +**目标**:建立数据流和存储基础,确保稳定采集 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 1.1 | RSS采集模块 | 解析公众号RSS Feed,提取标题、正文、来源、发布时间 | 原始文章数据库 | - | +| 1.2 | 数据标准化 | 统一文章格式,清洗HTML标签,提取纯文本 | 标准化文章表 | 1.1 | +| 1.3 | 文件存储层 | JSON文件存储(fetch-yyyy-mm-dd.json) | 存储规范 | 1.2 | +| 1.4 | **SQLite缓存系统** | 三级缓存设计(文章/网摘/分类摘要),含过期策略与迁移机制 | 缓存模块 | 1.2 | +| 1.5 | **翻译管线** | 中英分离 + 外文并发翻译 | 翻译模块 | 1.2 | +| 1.6 | **监控统计** | 阶段耗时 + API消耗 + 成功率 + 缓存命中率 | 监控模块 | 1.1 | +| 1.7 | 日志系统 | 有效日志记录,便于问题定位 | 日志模块 | 1.1 | + +#### 1.4 SQLite 缓存系统设计(继承 military-digest-v3) + +**数据库位置**:`data/article_cache.db` + +**表1:article_cache(文章处理结果缓存)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | TEXT (PK) | 文章唯一标识(RSS entry id/link) | +| `source` | TEXT | 来源名称 | +| `title` | TEXT | 原始标题 | +| `link` | TEXT | 原文链接 | +| `translated_title` | TEXT | 翻译后的中文标题 | +| `content` | TEXT | 原始正文 | +| `translated_content` | TEXT | 翻译后的中文正文(前300字) | +| `ai_score` | REAL | AI 加权总分 | +| `ai_summary` | TEXT | AI 生成的中文摘要 | +| `ai_category` | TEXT | 分类(装备动态/地区冲突/战略政策) | +| `ai_scores_json` | TEXT | 四维度评分 JSON | +| `webzine_text` | TEXT | 网摘文本(迁移新增字段) | +| `published_time` | DATETIME | 文章发布时间 | +| `processed_time` | DATETIME | 处理时间(索引,用于过期判断) | +| `status` | INTEGER | 1=成功 2=失败 | +| `error_msg` | TEXT | 失败时的错误信息 | + +**索引**:`idx_processed_time`、`idx_source` + +**表2:category_summary_cache(分类摘要缓存)** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `date` | TEXT (联合PK) | 日期字符串(YYYY-MM-DD) | +| `category` | TEXT (联合PK) | 分类名称 | +| `summary_text` | TEXT | 分类介绍文本 | +| `generated_time` | DATETIME | 生成时间 | + +**缓存策略**: + +| 策略项 | 设计 | +|------|------| +| 过期时间 | 24 小时(与时间窗口一致,可配置) | +| 自动清理 | 每次运行时清理 7 天前数据(`clear_expired(keep_days=7)`) | +| 写入方式 | `REPLACE INTO`(幂等,支持覆盖更新) | +| 网摘分离更新 | `save_article_webzine()` 单独更新 `webzine_text` 字段,不影响其他缓存 | +| 连接降级 | 数据库连接失败时静默降级,不影响主流程 | +| 全局单例 | `get_cache()` 全局单例模式,避免重复创建连接 | + +**数据库迁移机制**: + +```python +def _migrate_add_webzine_text(self): + """自动检测并添加 webzine_text 列""" + cursor.execute("PRAGMA table_info(article_cache)") + columns = [col[1] for col in cursor.fetchall()] + if 'webzine_text' not in columns: + cursor.execute("ALTER TABLE article_cache ADD COLUMN webzine_text TEXT") +``` + +**缓存命中流程**: + +``` +文章被抓取 → 以 article.id 查询缓存(expire_hours=24) + ├─ 命中 + status=1 → 直接使用缓存数据(from_cache=True),跳过翻译和AI分析 + └─ 未命中/过期 → 完整翻译+分析管线,结果 REPLACE INTO 写入缓存 +``` + +**实测效果**(military-digest-v3 验证数据): + +| 指标 | 第一次(冷启动) | 第二次(含缓存) | +|------|:---:|:---:| +| 文章缓存命中 | 0 / 15 | **15 / 15** | +| API 调用次数 | 37 次 | **0 次** | +| 总耗时 | 153s | **~5s** | +| 退出码 | 0 | 0 | + +#### 1.5 翻译管线设计(继承 military-digest-v3) + +**核心策略**:中英分离,中文文章零 API 调用,外文文章并发翻译。 + +**中文检测**:汉字占比 > 20% 判定为中文(`is_chinese()` 函数)。 + +**翻译流程**: + +``` +所有文章 + ├─ 中文文章 → translated_title = title(零API调用) + │ translated_content = content[:300](截取即可) + └─ 外文文章 → 提交到 ThreadPoolExecutor(max_workers=AI_CONCURRENCY) + ├─ translate_title():标题翻译(temperature=0.2, max_tokens=200) + └─ translate_content():正文前500词翻译 → 截取300字(max_tokens=800) +``` + +**并发控制**: + +| 参数 | 值 | 说明 | +|------|-----|------| +| `AI_CONCURRENCY` | 8 | 默认并发数,可通过配置调整 | +| `temperature` | 0.2 | 翻译低温度,确保准确性 | +| 失败降级 | 原文填充 | 翻译失败时 `translated_title = title`,不中断流程 | + +**关键实现**: + +```python +def batch_translate_titles(articles, api_key, base_url, model, max_concurrent=None): + """中英分离:中文直接填充,外文并发翻译标题""" + chinese_articles = [] + foreign_articles = [] + + for a in articles: + if is_chinese(a['title']): + a['translated_title'] = a['title'] + chinese_articles.append(a) + else: + foreign_articles.append(a) + + # 外文并发翻译 + with ThreadPoolExecutor(max_workers=max_concurrent) as executor: + futures = {executor.submit(translate_title, a, ...): a for a in foreign_articles} + for future in as_completed(futures): + chinese_articles.append(future.result()) + + return chinese_articles +``` + +**设计优势**: +- 中文文章零 API 调用,节省 40%+ 翻译 token +- 标题和正文分离翻译,先翻译标题再决定是否需要翻译正文(可配合关键词筛选) +- 外文并发翻译,翻译耗时 = 单篇耗时(而非 N × 单篇耗时) + +#### 1.6 监控统计设计(继承 military-digest-v3) + +**阶段耗时统计(11 阶段)**: + +| 阶段 key | 中文标签 | 统计内容 | +|------|------|------| +| `rss_fetch` | RSS抓取 | 所有源并行抓取耗时 | +| `time_filter` | 时间过滤 | 按时间窗口过滤耗时 | +| `deduplicate` | 去重 | 去重处理耗时 | +| `translate_titles` | 标题翻译 | 外文标题并发翻译耗时 | +| `keyword_filter` | 关键词筛选 | 关键词命中筛选耗时 | +| `translate_contents` | 正文翻译 | 外文正文并发翻译耗时 | +| `ai_analyze` | AI评分分类 | 批量AI评分分类耗时 | +| `webzine_generate` | 网摘生成 | TOP3网摘并发生成耗时 | +| `category_overview` | 分类介绍 | 4类分类介绍并发生成耗时 | +| `report_generate` | 报告生成 | Markdown报告拼接写入耗时 | +| `image_generate` | 图片生成 | 网摘长图渲染耗时 | + +**API 调用统计**:按用途(标题翻译/正文翻译/AI评分分类/网摘生成/分类介绍)和模型分组,记录调用次数、token 估算、重试次数、失败次数。 + +**源抓取统计**:成功/失败源计数,失败源名称列表。 + +**文章统计**:总数、缓存命中数、本次处理数、缓存命中率。 + +**报告示例**(运行结束自动打印): + +``` +================================================================ + 📊 运行监控报告 +================================================================ + +── ⏱ 阶段耗时 ── + RSS抓取 7.5s + 时间过滤 0.0s + 去重 0.0s + 标题翻译 17.9s + 关键词筛选 0.0s + 正文翻译 9.9s + AI评分分类 30.7s + 网摘生成 53.3s + 分类介绍 33.5s + 报告生成 0.0s + 图片生成 0.3s + ──────────────────── + 合计 153.1s / 总运行 153s + +── 📡 文章统计 ── + 总计: 15 缓存命中: 0 本次处理: 15 + 缓存命中率: 0% + +── 🤖 API调用统计 ── + [按用途] + 标题翻译: 7次 / tokens≈1400 / 重试0 + 正文翻译: 6次 / tokens≈4800 / 重试0 + AI评分分类: 15次 / tokens≈12000 / 重试0 + 网摘生成: 5次 / tokens≈4000 / 重试2 + 分类介绍: 4次 / tokens≈600 / 重试0 + [按模型] + deepseek-v3: 37次 / tokens≈22800 + +── 📰 源抓取统计 ── + 源总数: 5 成功: 5 失败: 0 + +================================================================ +``` + +**数据字段规范**(扩展版): + +```json +{ + "title": "内容标题", + "link": "原始链接", + "published": "发布时间", + "source": "来源(公众号名称)", + "content": "Markdown格式的正文内容", + "translated_title": "翻译后的中文标题", + "translated_content": "翻译后的中文正文", + "tags": "LLM识别的标签", + "score": "LLM评分(0-100)", + "summary": "LLM生成的中文摘要", + "fetched_at": "抓取时间", + "entities": ["提取的实体列表"], + "reporting_angle": "报道角度", + "webzine_text": "参考消息风格网摘文本(Top N 专属)", + "from_cache": "是否来自缓存" +} +``` + +**验收标准**: +- [ ] 稳定采集50+ RSS源 +- [ ] 数据入库成功率 > 95% +- [ ] SQLite 缓存三级覆盖(文章/网摘/分类摘要) +- [ ] 缓存命中时 API 调用为 0 +- [ ] 中英分离正确,中文文章零翻译 API 调用 +- [ ] 监控报告包含阶段耗时、API统计、源成功率、缓存命中率 +- [ ] 日志系统可定位问题 + +--- + +### 阶段二:Fetch循环与双层评分系统(Week 3-4) +**目标**:实现实时采集、双层评分(硬约束+动态阈值融合)、即时推送 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 2.1 | Fetch循环引擎 | 每30分钟循环一次,抓取RSS→中英分离→并发翻译→评分 | fetch引擎 | 1.7 | +| 2.2 | **第一层:单篇评分Prompt** | 单篇文章重要性评分(0-100),**含硬约束** | `prompts/article_score.txt` | 2.1 | +| 2.3 | **硬约束过滤器** | 非时政军事上限、KOL转述上限、时效衰减 | 硬约束模块 | 2.2 | +| 2.4 | 即时推送判断 | 单篇≥90分触发即时推送候选 | 推送判断模块 | 2.3 | +| 2.5 | 飞书Webhook推送 | 飞书群机器人推送(Fetch 循环即时推送 + Push 循环日报推送) | 飞书推送适配器 | 2.4 | +| 2.6 | systemd服务化 | 使用systemd timer管理服务 | systemd配置 | 2.1 | + +#### 双层评分架构设计 + +``` +第一层:单篇文章评分(硬约束) 第二层:实体簇热度(动态阈值) +├── 输入:单篇RSS文章(已翻译) ├── 输入:同一实体的多篇报道(已硬约束评分) +├── 处理:LLM评分 + 硬约束修正 ├── 处理:统计特征 + 动态阈值计算 +└── 输出:0-100分(已约束) └── 输出:热点判定 + ├── ≤79:非目标领域,过滤 ├── 低于阈值:普通关注 + ├── 80-89:一般关注,进入聚类 └── 高于阈值:热点,优先整合 + └── 90+:高优先级,即时推送候选 +``` + +#### 第一层:评分硬约束设计 + +**Prompt核心约束**: +```markdown +## 评分规则(硬性约束) + +1. **非时政/军事/科技主题上限 79 分** + - 如果内容不属于目标领域(如纯娱乐八卦、生活琐事),最高只能给 79 分 + +2. **KOL 转述上限 89 分** + - 如果只是 KOL/大V 对已有信息的转述/评论,而非原创信息,最高只能给 89 分 + +3. **时效性衰减** + - 发布超过24小时:分数×0.9 + - 发布超过48小时:分数×0.8 + +4. **90+ 分必须同时满足**: + - 重大事件/突破性进展/政策发布 + - 一手信息源(官方发布、权威媒体首发) + - 对目标领域有实质性影响 + +5. **标签禁止空泛** + - 禁止:["军事", "新闻", "热点"] + - 要求:["歼-35A", "舰载战斗机", "隐身性能", "海军航空兵"] +``` + +**代码实现**: +```python +def apply_score_constraints(entry: dict, raw_score: int) -> tuple[int, list[str]]: + """ + 应用评分硬约束 + 返回:修正后的分数,应用的约束标签列表 + """ + constraints_applied = [] + + # 约束1:非目标领域上限79 + if not is_target_domain(entry['content']): + raw_score = min(raw_score, 79) + constraints_applied.append("非目标领域") + + # 约束2:KOL转述上限89 + if is_kol_repost(entry): + raw_score = min(raw_score, 89) + constraints_applied.append("KOL转述") + + # 约束3:时效性衰减 + hours_old = get_hours_since_published(entry) + if hours_old > 24: + decay_factor = 0.9 ** (hours_old // 24) + raw_score = int(raw_score * decay_factor) + constraints_applied.append(f"时效衰减({hours_old}h)") + + return raw_score, constraints_applied +``` + +**第一层输出分级**: +| 分数段 | 处理策略 | 说明 | +|--------|---------|------| +| ≤79 | 过滤,不入库 | 非目标领域内容 | +| 80-89 | 入库,进入聚类 | 一般关注,参与动态阈值计算 | +| 90+ | 入库,即时推送候选 | 高优先级,同时触发即时推送判断 | + +**验收标准**: +- [ ] Fetch循环每30分钟稳定运行 +- [ ] 翻译管线集成正确(中英分离+并发翻译) +- [ ] 硬约束生效(非目标领域≤79,KOL转述≤89,时效衰减) +- [ ] 90+文章触发即时推送候选 +- [ ] SQLite缓存正常工作(二次运行零API调用) +- [ ] 监控统计准确记录各阶段数据 +- [ ] systemd服务可一键启动/停止 + +--- + +### 阶段三:实体提取与细粒度聚类(Week 5-6) +**目标**:实现细粒度聚类,区分同一实体的不同报道角度 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 3.1 | 实体提取Prompt | 设计并优化实体提取提示词 | `prompts/extract_entities.txt` | 2.6 | +| 3.2 | 角度分类Prompt | 设计报道角度分类提示词 | `prompts/classify_angle.txt` | 2.6 | +| 3.3 | 批量实体提取 | 实现50篇/批并发调用,小模型处理 | 实体提取服务 | 3.1 | +| 3.4 | 角度分类服务 | 批量角度分类,与实体提取并行 | 角度分类服务 | 3.2 | +| 3.5 | 实体归一化 | 大模型消歧,合并别名 | 实体归一化服务 | 3.3 | +| 3.6 | 聚类算法 | 基于实体+角度的细粒度聚类 | 聚类引擎 | 3.4, 3.5 | +| 3.7 | 记忆系统 | 避免同一信息反复推送 | 去重模块 | 3.6 | + +**验收标准**: +- [ ] 实体提取准确率 > 85% +- [ ] 角度分类准确率 > 80% +- [ ] 聚类后实体簇数量合理(200篇→30-50个簇) +- [ ] 同一信息不重复推送 + +--- + +### 阶段四:热度评价与动态阈值(Week 7-8) +**目标**:实现第二层筛选——基于硬约束后分数的动态热点识别 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 4.1 | **实体簇热度计算** | 融合硬约束分数的多维度热度计算 | 热度指标服务 | 3.6 | +| 4.2 | **第二层:动态阈值算法** | 基于硬约束后分数分布的自适应阈值 | 阈值计算模块 | 4.1 | +| 4.3 | 热点识别引擎 | 硬约束过滤 + 动态阈值筛选的双层判定 | 热点识别服务 | 4.2 | +| 4.4 | 热点精评Prompt | 大模型深度评分(Top 30热点簇) | `prompts/precise_heat.txt` | 4.3 | + +#### 第二层:动态阈值设计(融合硬约束分数) + +**实体簇热度计算**: +```python +class Cluster: + def __init__(self, entity_name: str): + self.entity_name = entity_name + self.articles = [] # 已硬约束评分的文章列表 + self.hot_score = 0 # 综合热度分 + + def calculate_hot_score(self) -> int: + """ + 计算实体簇热度,融合硬约束后的单篇分数 + """ + if not self.articles: + return 0 + + # 基础分数:最高分文章的分数(已硬约束) + max_score = max(a['score'] for a in self.articles) + + # 传播热度:报道数量 × log(来源多样性) + report_count = len(self.articles) + source_diversity = len(set(a['source'] for a in self.articles)) + propagation_heat = report_count * math.log(source_diversity + 1) + + # 角度覆盖度:不同报道角度数 / 6 + angles = set(a.get('reporting_angle', 'unknown') for a in self.articles) + angle_coverage = len(angles) / 6 + + # 高优先级文章加成(90+文章额外加权) + high_priority_count = sum(1 for a in self.articles if a['score'] >= 90) + priority_bonus = high_priority_count * 5 # 每篇90+加5分 + + # 综合计算 + self.hot_score = min(100, int( + max_score * 0.4 + # 单篇最高分权重40% + min(propagation_heat * 3, 30) + # 传播热度权重30% + angle_coverage * 20 + # 角度覆盖权重20% + priority_bonus # 高优先级加成10% + )) + + return self.hot_score +``` + +**动态阈值算法(融合版)**: +```python +def calculate_hotspot_threshold(clusters: list[Cluster], date: str) -> float: + """ + 基于硬约束后分数分布计算动态阈值 + 同时考虑统计特征和高优先级文章数量 + """ + # 获取当日所有簇的热度分数(已融合硬约束) + scores = [c.hot_score for c in clusters] + + if len(scores) < 5: + return 75 # 数据不足时使用保底阈值 + + # 统计特征 + mean_score = mean(scores) + std_score = std(scores) + median_score = median(scores) + + # 方法1:统计阈值(均值+1.5倍标准差) + threshold_stat = mean_score + 1.5 * std_score + + # 方法2:保底阈值(至少3条或前10%) + min_count = max(3, len(scores) * 0.1) + sorted_scores = sorted(scores, reverse=True) + threshold_adaptive = sorted_scores[min_count - 1] + + # 方法3:硬约束保底(考虑90+高优先级文章数量) + high_priority_count = sum( + 1 for c in clusters + if any(a['score'] >= 90 for a in c.articles) + ) + if high_priority_count >= 3: + threshold_backup = median_score + else: + threshold_backup = mean_score + 0.5 * std_score + + # 取三者中较低值,确保热点不被遗漏 + final_threshold = min(threshold_stat, threshold_adaptive, threshold_backup) + + logger.info(f"动态阈值计算: 统计={threshold_stat:.1f}, " + f"保底={threshold_adaptive:.1f}, 硬约束保底={threshold_backup:.1f}, " + f"最终={final_threshold:.1f}") + + return final_threshold +``` + +**双层热点判定流程**: +```python +def identify_hotspots(clusters: list[Cluster], date: str) -> tuple[list[Cluster], list[Cluster]]: + """ + 双层热点识别:硬约束过滤 + 动态阈值筛选 + """ + # 第一层:硬约束过滤(单篇层面已处理,此处检查) + valid_clusters = [] + for c in clusters: + if all(a['score'] <= 79 for a in c.articles): + continue + valid_clusters.append(c) + + # 计算各簇热度(融合硬约束分数) + for c in valid_clusters: + c.calculate_hot_score() + + # 第二层:计算动态阈值 + threshold = calculate_hotspot_threshold(valid_clusters, date) + + # 阈值筛选 + 额外条件 + hotspots = [] + normal = [] + for c in valid_clusters: + if c.hot_score >= threshold: + has_high_priority = any(a['score'] >= 90 for a in c.articles) + significantly_above = c.hot_score >= threshold + 10 + + if has_high_priority or significantly_above: + hotspots.append(c) + else: + normal.append(c) + else: + normal.append(c) + + hotspots.sort(key=lambda x: (x.hot_score, x.max_article_score), reverse=True) + + return hotspots, normal +``` + +**融合设计优势**: +| 层级 | 作用 | 输入 | 输出 | +|------|------|------|------| +| 第一层(硬约束) | 质量控制 | 单篇文章 | 过滤非目标领域,标记高优先级 | +| 第二层(动态阈值) | 热点发现 | 实体簇(多篇聚合) | 识别突发热点,自适应当日分布 | + +**验收标准**: +- [ ] 实体簇热度计算正确(融合硬约束分数) +- [ ] 动态阈值自适应当日分布 +- [ ] 双层筛选协同工作(硬约束过滤→动态阈值筛选) +- [ ] 热点识别召回率 > 90%,误报率 < 20% + +--- + +### 阶段五:Push循环与三层整合(Week 9-10) +**目标**:实现定时汇总、三层整合(摘要+洞察+网摘)、日报生成 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 5.1 | Push循环引擎 | 每日定时运行,读取碎片化信息→整合→推送 | push引擎 | 4.4 | +| 5.2 | 摘要级整合Prompt | 多源报道合并为50-80字精炼摘要 | `prompts/summary_integrate.txt` | 5.1 | +| 5.3 | 洞察级整合Prompt | Top 10生成结构化深度分析(**双视角设计**) | `prompts/insight_integrate.txt` | 5.1 | +| 5.4 | **Prompt防退化模块** | 禁止套话、要求从素材出发 | 防退化检查器 | 5.2, 5.3 | +| 5.5 | 摘要整合服务 | 小模型批量处理全量簇 | 摘要整合服务 | 5.2, 5.4 | +| 5.6 | 洞察整合服务 | 大模型逐个处理Top 10(**双视角输出**) | 洞察整合服务 | 5.3, 5.4 | +| 5.7 | **网摘生成服务** | Top 3 参考消息风格网摘(继承 military-digest-v3) | 网摘生成服务 | 5.1 | +| 5.8 | 时间线提取 | 从多源报道中提取事件时间线 | 时间线提取模块 | 5.5 | +| 5.9 | 多角度整合 | 同一事件不同角度报道的整合 | 多角度整合模块 | 5.6 | + +#### 双视角洞察设计(借鉴AI Daily) + +**设计理念**:metadata(新闻编辑视角,事实压缩)+ 正文(情报分析师视角,趋势判断)解耦 + +**Prompt设计**: +```markdown +## Part 1: Metadata(新闻编辑视角) + +请生成以下结构化信息: +- title: 10字以内的标题(事实陈述,无修饰) +- lead: 20字以内的导语(核心事实) +- highlights: 3-5个关键要点(bullet points,纯事实) + +约束:只陈述事实,不做趋势判断,不用形容词。 + +## Part 2: 洞察正文(情报分析师视角) + +基于多源报道,识别: +1. 事件脉络:关键时间节点和进展 +2. 多角度分析:技术维度、战略维度、舆论维度 +3. 影响与展望:短期影响、中长期趋势、值得关注信号 + +约束: +- 禁止空泛评论(如"意义重大""影响深远") +- 每个观点必须有素材支撑 +- 使用具体名称(装备型号/政策名称/组织名称),不用"某些""部分"等模糊指代 +``` + +**输出示例**: +```markdown +--- +title: "歼-35A列装部队" +lead: "空军官方确认歼-35A已列装首批作战部队" +highlights: + - 首次公开确认进入实战化部署阶段 + - 隐身性能对标F-35C,可能在舰载领域形成优势 + - 多国媒体关注其对西太平洋军事平衡的影响 +--- + +## 事件脉络 +- 2024-11:珠海航展首次公开亮相 +- 2025-03:完成舰载适配测试 +- 2025-05:空军确认列装首批作战部队【新进展】 + +## 多角度分析 +- **技术维度**:隐身涂层、航电系统、舰载适配均有突破 +- **战略维度**:提升海军航空兵远海作战能力 +- **舆论维度**:外媒关注中美舰载机代差缩小 + +## 影响与展望 +短期:提升海军航母编队作战能力 +中期:可能在西太平洋形成局部优势 +值得关注:后续舰载版测试进展、出口动向 +``` + +#### 网摘生成设计(继承 military-digest-v3) + +**定位**:在三层整合中,网摘是面向直接传播的成品内容。不同于摘要(速览用)和洞察(分析用),网摘采用《参考消息》官方新闻报道格式,适合直接推送到飞书群或作为独立内容分发。 + +**核心特性**: + +| 特性 | 说明 | +|------|------| +| 风格 | 《参考消息》官方新闻报道格式:标题+原标题+发布日期+正文+价值点 | +| 来源识别 | 自动区分微信公众号源("据公众号 XXX 报道")和普通源("据 XXX 报道") | +| 字数校验 | 正文严格 280-320 字(含标点),价值点 35-45 字 | +| 重试机制 | 不达标自动重试,最多 3 次,每次带具体反馈("正文只有 XX 字,太少!") | +| 生成范围 | Top 3 文章,与摘要(全量)和洞察(Top 10)互补 | +| 输出格式 | 文本文件(.txt)+ Pillow 渲染长图(.png) | + +**网摘 Prompt 核心约束**: + +```markdown +标题要求 +简洁、客观、中性、信息密度高 +结构:主体 + 事件 + 核心态势 +不抒情、不夸张、不用网络用语 + +正文格式要求 +开头第一句必须加:据[来源]报道 +正文风格:客观、平实、严谨、书面化,类似外电编译稿 +必须充分展开:补充背景、说明意义、分析影响、展望前景 +全文一段到底,不分段 + +【字数强制要求 - 必须严格执行】 +正文字数严格控制在 280~320 字(含标点),一个字都不能少,一个字都不能多! +如果内容不够,请合理补充相关背景、行业态势、同类项目对比等专业内容 + +价值点要求 +正文结束后空一行,再写价值点 +价值点严格 35~45 字(含标点) +句式结构:事件 - 影响 / 后果 - 值得关注 +``` + +**字数校验重试机制**: + +```python +def generate_webzine_for_article(article, api_key, base_url, model, max_retries=3): + """为单篇文章生成参考消息风格网摘,支持字数校验重试""" + for attempt in range(max_retries): + result = call_ai([...], purpose="网摘生成") + + # 提取正文部分 + body_text = extract_body(result) + body_len = len(body_text) + + if 280 <= body_len <= 320: + return result # 达标,直接返回 + + # 不达标,在 prompt 末尾追加反馈 + if body_len < 280: + prompt += f"\n\n【上次反馈:正文只有{body_len}字,太少!请大幅增加内容!】" + else: + prompt += f"\n\n【上次反馈:正文有{body_len}字,太多!请精简内容!】" + + return last_result # 重试耗尽,返回最后结果 +``` + +**输出示例**: + +``` +标题:歼-35A隐身舰载战斗机正式列装海军航空兵部队 +原标题:China's J-35A stealth fighter enters service with naval aviation +发布日期:2025年05月24日 +正文:据解放军报报道,中国海军航空兵部队已于近日正式列装歼-35A隐身舰载战斗机,标志着中国成为继美国之后第二个具备隐身舰载机作战能力的国家。歼-35A采用双发中型设计,配备国产涡扇-19发动机,最大起飞重量约30吨,雷达反射截面积据分析优于F-35C。该机配备有源相控阵雷达、分布式光电系统以及先进的电子战套件,可携带霹雳-15中远程空空导弹和鹰击-12超音速反舰导弹等多型武器。军事专家指出,歼-35A的列装将大幅提升中国海军航母编队的制空作战和远程打击能力,特别是在西太平洋方向形成对F-35C的局部数量优势。美国海军战争学院报告认为,中国正加速缩小与美国在舰载航空领域的技术差距,预计到2030年前后将形成至少3个歼-35A舰载机联队的规模。日本防卫省已表示将密切关注相关动向。 +价值点:歼-35A列装标志着中国成为全球第二个拥有隐身舰载机的国家,将显著改变西太平洋海上力量对比,后续量产规模和舰载适配进展值得持续关注。 +``` + +**三层整合对比**: + +| 层次 | 产品 | 覆盖范围 | 定位 | 输出格式 | +|------|------|:---:|------|------| +| 第一层 | 摘要级速览 | 全量(所有簇) | 快速浏览,50-80字精炼 | Markdown表格 | +| 第二层 | 洞察级分析 | Top 10 热点 | 深度分析,双视角(metadata+正文) | Markdown段落 | +| 第三层 | 参考消息网摘 | Top 3 热点 | 成品内容,直接传播 | TXT文本 + PNG长图 | + +#### Prompt防退化设计(借鉴AI Daily) + +**禁止套话列表**: +```python +FORBIDDEN_PHRASES = [ + "意义重大", "影响深远", "引发关注", "备受瞩目", + "深水区", "拐点", "白热化", "新纪元", "里程碑", + "不容忽视", "值得注意", "值得关注", # 除非后接具体内容 + "某些", "部分", "一些", "相关", # 模糊指代 +] + +def check_degeneration(content: str) -> list[str]: + """检查内容是否包含禁止套话""" + violations = [] + for phrase in FORBIDDEN_PHRASES: + if phrase in content: + violations.append(phrase) + return violations +``` + +**Prompt中的防退化指令**: +```markdown +## 输出约束(防退化) + +1. 禁止使用以下套话: + - "意义重大""影响深远""引发关注""备受瞩目" + - "深水区""拐点""白热化""新纪元""里程碑" + +2. 每个观点必须有素材支撑: + - 错误:该装备性能先进 + - 正确:该装备隐身系数0.01,优于F-35的0.02 + +3. 使用具体名称,禁止模糊指代: + - 错误:某些国家表示关注 + - 正确:日本防卫省发布关注声明 + +4. 从素材出发,每次措辞应不同,避免模板化输出 +``` + +**验收标准**: +- [ ] 摘要信息完整度 > 90% +- [ ] 洞察分析覆盖双视角(metadata + 正文) +- [ ] 网摘正文字数达标率 > 90%(280-320字) +- [ ] 网摘价值点字数达标率 > 90%(35-45字) +- [ ] 无禁止套话出现 +- [ ] 整合内容无明显事实错误 + +--- + +### 阶段六:日报生成与跨日关联(Week 11-12) +**目标**:生成结构化日报(含Sentinel标记),实现跨日关联 + +| 序号 | 任务 | 描述 | 输出 | 依赖 | +|------|------|------|------|------| +| 6.1 | 日报模板设计 | Markdown模板,包含热点、速览、数据概览、网摘 | `templates/daily_report.md` | 5.9 | +| 6.2 | **Sentinel分段标记** | 单文件多板块精确管理 | `src/utils/sentinel.py` | 6.1 | +| 6.3 | **跨日关联模块** | 从历史日报提取上下文,追踪事件脉络 | `src/processors/cross_day.py` | 6.2 | +| 6.4 | 排序算法 | 综合热度、时效、重要性的排序逻辑 | 排序模块 | 4.4 | +| 6.5 | 日报渲染服务 | 按模板组装数据,生成Markdown(含Sentinel) | 日报渲染服务 | 6.1, 6.2, 6.4 | +| 6.6 | 网摘图片渲染 | Pillow渲染TOP3合并长图,含Banner标题 | 图片渲染服务 | 5.7 | +| 6.7 | Web归档站点 | 部署在线阅览站点 | Web站点 | 6.5 | +| 6.8 | 数据保留策略 | fetch保留2天,notify保留2天,push保留5天 | 清理策略 | 6.7 | + +#### 简化版Sentinel分段标记设计 + +**标记格式**: +```markdown +--- +title: "每日情报早报" +date: "2026-05-24" +stats: + total_entities: 87 + hotspot_count: 10 +--- + + +## 📋 全量新闻速览 + +| # | 实体 | 摘要 | 来源数 | 热度 | +|---|------|------|--------|------| +| 1 | 歼-35A | 空军确认列装\|隐身性能对标F-35C | 8 | ★★★★★ | + + + +## 🔥 热点深度洞察 + +### 1. 歼-35A列装进展 [持续跟踪] +> 速览:空军官方确认歼-35A已列装首批作战部队 [8源报道] + +**事件脉络** +- 5月20日:首次公开亮相 +- 5月24日:【新进展】确认列装部队 +... + + + +## 📰 参考消息网摘 + +(网摘文本内容,含标题/原标题/发布日期/正文/价值点) + +``` + +**核心代码**: +```python +import re +from datetime import datetime, timedelta + +def extract_section(content: str, section_name: str) -> str: + """从日报内容中提取指定section""" + pattern = rf'(.*?)' + match = re.search(pattern, content, re.DOTALL | re.IGNORECASE) + return match.group(1).strip() if match else "" + +def get_historical_context(entity_name: str, days: int = 7) -> dict: + """获取某实体近N天的历史报道上下文""" + context = { + "first_seen": None, + "previous_summaries": [], + "previous_insights": [], + "mention_count": 0 + } + + for i in range(days): + date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d") + file_path = f"data/reports/daily-{date}.md" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + if entity_name in content: + context["mention_count"] += 1 + if context["first_seen"] is None: + context["first_seen"] = date + + summary_section = extract_section(content, "summary") + for line in summary_section.split('\n'): + if entity_name in line and line.strip().startswith('|'): + context["previous_summaries"].append({ + "date": date, + "content": line.strip() + }) + + insights_section = extract_section(content, "insights") + if entity_name in insights_section: + insight_blocks = re.split(r'### \d+\.', insights_section) + for block in insight_blocks: + if entity_name in block: + context["previous_insights"].append({ + "date": date, + "content": block.strip()[:500] + }) + break + except FileNotFoundError: + continue + + return context + +def generate_cross_day_marker(entity_name: str) -> str: + """生成跨日标记""" + history = get_historical_context(entity_name, days=7) + + if history["mention_count"] == 0: + return "首次报道" + elif history["mention_count"] == 1: + return "持续跟踪" + else: + return f"持续跟踪({history['mention_count']}次)" +``` + +**验收标准**: +- [ ] 日报生成时间 < 20分钟 +- [ ] Sentinel标记正确,可精确提取summary/insights/webzine +- [ ] 跨日关联可追踪实体历史报道 +- [ ] 网摘长图渲染正常(合并多篇 + Banner标题) +- [ ] Web站点可正常访问 + +--- + +### 阶段七:优化与迭代(Week 13-14) +**目标**:持续优化,功能扩展 + +| 序号 | 任务 | 描述 | 优先级 | +|------|------|------|--------| +| 7.1 | Prompt优化 | 基于实际效果迭代优化提示词 | 高 | +| 7.2 | 反馈闭环 | 收集用户反馈,标注数据,优化模型 | 高 | +| 7.3 | 趋势分析 | 7日热点趋势图,周期性报告 | 中 | +| 7.4 | 个性化推荐 | 基于用户阅读历史的个性化排序 | 低 | + +--- + +## 四、技术栈建议 + +### 4.1 核心组件 +| 组件 | 推荐方案 | 说明 | +|------|----------|------| +| 数据采集 | Python + feedparser | RSS解析 | +| 数据存储 | SQLite + JSON文件 | 数据库+文件双存储 | +| LLM调用 | OpenAI SDK / 国产模型SDK | 统一接口封装 | +| 翻译 | 中英分离 + ThreadPoolExecutor并发 | 中文文章零API调用 | +| 缓存 | SQLite 三级缓存 + 自动迁移 | 断点续跑,二次运行 ~5s | +| 图片渲染 | Pillow + 中文字体fallback | 网摘长图生成 | +| 任务调度 | systemd timer | 系统级服务管理 | +| Web展示 | Hugo / Flask | 静态/动态站点 | +| 推送 | 飞书 Webhook | 群机器人消息推送 | +| 日志 | Python logging + systemd journal | 集中日志管理 | +| 监控 | 11阶段耗时 + API统计 + 源成功率 | 运行结束自动打印报表 | + +### 4.2 模型选择 +| 任务类型 | 推荐模型 | 预估成本 | +|----------|----------|----------| +| 标题翻译 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 正文翻译 | DeepSeek-V3 / Qwen-Plus | ~0.002元/次 | +| 实体提取 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 角度分类 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 单篇评分 | DeepSeek-V3 / Qwen-Plus | ~0.001元/次 | +| 摘要整合 | DeepSeek-V3 / Qwen-Plus | ~0.002元/次 | +| 网摘生成 | DeepSeek-V3 / Qwen-Plus | ~0.005元/次(含重试) | +| 实体消歧 | GPT-4o / Claude-3.5-Sonnet | ~0.01元/次 | +| 热点精评 | GPT-4o / Claude-3.5-Sonnet | ~0.02元/次 | +| 洞察整合 | GPT-4o / Claude-3.5-Sonnet | ~0.1元/次 | + +**日成本估算**: +- 200-500篇/日 × 0.001元(小模型)= 0.2-0.5元 +- 外文翻译(按30%外文率):60-150篇 × 0.003元 = 0.18-0.45元 +- 100个簇 × 0.001元(小模型)= 0.1元 +- 3篇网摘 × 0.005元 = 0.015元 +- 10个洞察 × 0.1元(大模型)= 1元 +- **总计:约1.5-2.1元/日** + +--- + +## 五、项目结构 + +``` +news-intelligence-system/ +├── config/ +│ ├── rss_sources.json # RSS源配置 +│ ├── models.yaml # 模型配置 +│ └── systemd/ # systemd服务配置 +├── src/ +│ ├── collectors/ # 数据采集 +│ │ └── rss_collector.py +│ ├── processors/ # 处理管道 +│ │ ├── translator.py # 翻译管线(中英分离+并发翻译) +│ │ ├── entity_extractor.py +│ │ ├── angle_classifier.py +│ │ ├── cluster_engine.py +│ │ ├── heat_calculator.py +│ │ └── cross_day.py # 跨日关联 +│ ├── integrators/ # 内容整合 +│ │ ├── summary_integrator.py +│ │ ├── insight_integrator.py +│ │ └── webzine_generator.py # 网摘生成(参考消息风格+字数校验) +│ ├── generators/ # 报告生成 +│ │ ├── daily_report_generator.py +│ │ ├── image_renderer.py # 网摘图片渲染(Pillow) +│ │ └── web_renderer.py +│ ├── push/ # 推送平台 +│ │ ├── base.py +│ │ └── feishu.py # 飞书 webhook(即时+日报双模式) +│ ├── storage/ # 存储层 +│ │ ├── cache.py # SQLite三级缓存(文章/网摘/分类摘要) +│ │ └── file_storage.py # JSON文件读写 +│ ├── monitor/ # 监控统计 +│ │ └── monitor.py # 阶段耗时+API消耗+成功率+缓存命中率 +│ └── utils/ # 工具函数 +│ ├── sentinel.py # Sentinel分段标记 +│ ├── prompt_checker.py # Prompt防退化检查 +│ └── language.py # 中文检测(is_chinese) +├── prompts/ # 提示词文件 +│ ├── article_score.txt # 单篇评分(含硬约束) +│ ├── extract_entities.txt +│ ├── classify_angle.txt +│ ├── quick_heat.txt +│ ├── precise_heat.txt +│ ├── summary_integrate.txt +│ ├── insight_integrate.txt # 双视角洞察 +│ └── webzine_generate.txt # 参考消息网摘(含字数约束) +├── templates/ +│ └── daily_report.md # 含Sentinel标记(summary/insights/webzine) +├── data/ +│ ├── cache.db # SQLite缓存数据库 +│ ├── fetch/ # fetch-yyyy-mm-dd.json +│ ├── notify/ # notify-yyyy-mm-dd.json +│ └── reports/ # daily-yyyy-mm-dd.md +├── output/ # 输出文件 +│ ├── report_YYYYMMDD.md # Markdown日报 +│ ├── webzine_YYYYMMDD.txt # 网摘文本 +│ └── webzine_YYYYMMDD.png # 网摘合并长图 +├── web/ # Web归档站点 +├── tests/ +├── scripts/ +│ ├── fetch_loop.py +│ ├── push_loop.py +│ └── setup_systemd.sh +├── requirements.txt +└── README.md +``` + +--- + +## 六、关键风险与应对 + +| 风险 | 影响 | 应对措施 | +|------|------|----------| +| RSS源失效 | 数据缺失 | 多源备份,监控告警 | +| LLM API限流 | 处理延迟 | 批量处理,重试机制,降级策略 | +| 翻译质量不稳定 | 后续分析偏差 | 低温度(0.2)翻译,失败时原文填充 | +| 缓存数据库损坏 | 缓存失效 | 连接降级不影响主流程,自动重建 | +| 网摘字数不达标 | 输出质量下降 | 3次重试校验,每次带具体反馈 | +| 实体识别错误 | 聚类偏差 | 人工标注反馈,Prompt迭代 | +| 热点漏识别 | 信息缺失 | 动态阈值保底机制,人工复核 | +| 成本超支 | 预算超支 | Token监控,模型降级,配额控制 | +| 进程崩溃 | 服务中断 | systemd自动重启 | +| LLM输出退化 | 质量下降 | **Prompt防退化检查** | + +--- + +## 七、里程碑与交付物 + +| 里程碑 | 时间 | 交付物 | +|--------|------|--------| +| M1 | Week 2 | 稳定运行的数据采集系统(含翻译管线、SQLite缓存、监控统计) | +| M2 | Week 4 | Fetch循环上线,**评分硬约束生效**,飞书即时推送可用 | +| M3 | Week 6 | 细粒度聚类引擎,实体提取准确率>85% | +| M4 | Week 8 | 动态热点识别系统,召回率>90% | +| M5 | Week 10 | Push循环上线,**三层整合(摘要+洞察+网摘)+Prompt防退化** | +| M6 | Week 12 | **Sentinel跨日关联上线**,网摘长图渲染,Web归档站点 | +| M7 | Week 14 | 持续优化迭代 | + +--- + +## 八、借鉴与继承总结 + +### 8.1 架构层面(借鉴 AI Daily) +1. **双循环架构**:Fetch循环(实时)+ Push循环(定时) +2. **systemd服务化**:替代Python内部定时,提升稳定性 +3. **文件存储规范**:fetch/notify/reports三种文件类型,明确保留策略 + +### 8.2 LLM应用层面(借鉴 AI Daily) +1. **评分硬约束**:非目标领域≤79、KOL转述≤89,有效控制信息质量 +2. **Prompt防退化**:禁止套话、要求从素材出发,避免LLM输出风格趋同 +3. **双视角洞察**:metadata(事实压缩)+ 正文(趋势判断)解耦 + +### 8.3 数据管理层面(借鉴 AI Daily) +1. **Sentinel分段标记**:单文件多板块精确管理,支持跨日关联 +2. **跨日关联**:从历史日报提取上下文,追踪事件发展脉络 +3. **数据保留策略**:自动清理过期文件 + +### 8.4 工程资产层面(继承 military-digest-v3) +1. **SQLite三级缓存**:文章+网摘+分类摘要,含自动迁移和过期清理,二次运行 ~5s +2. **翻译管线**:中英分离(汉字占比>20%判定)+ 外文并发翻译,中文零API调用 +3. **监控统计**:11阶段耗时 + API按用途/模型分组 + 源抓取成功率 + 缓存命中率 +4. **网摘生成**:参考消息风格 + 公众号源智能识别 + 280-320字校验重试(3次) +5. **图片渲染**:Pillow命令模式 + 中文字体多级fallback +6. **飞书推送**:webhook文本推送 + 5级异常分层捕获 + 失败优雅降级 + +### 8.5 工程层面 +1. **日志集中管理**:systemd journal便于问题定位 +2. **推送聚焦飞书**:webhook消息推送,覆盖即时快讯和日报两种场景 +3. **成本可控**:每天约1.5-2.1元(含翻译),缓存命中时几乎零成本 + +--- + +## 附录A:military-digest-v3 实际运行验证数据 + +> 以下数据来自 military-digest-v3 系统 2026-05-14 的实际运行验证,为新系统的设计和成本估算提供基准参考。 + +### A.1 全链路冷启动验证 + +清除所有缓存和输出文件后运行,完整链路通过: + +| 阶段 | 耗时 | 状态 | +|------|:---:|:---:| +| RSS 抓取(5源) | 7.5s | ✅ | +| 时间过滤 (156→22篇) | 0.0s | ✅ | +| 去重 | 0.0s | ✅ | +| 标题翻译(7篇外文) | 17.9s | ✅ | +| 关键词筛选 (22→15篇) | 0.0s | ✅ | +| 正文翻译(6篇外文) | 9.9s | ✅ | +| AI评分分类(15篇) | 30.7s | ✅ | +| 网摘生成(3篇,含2次重试) | 53.3s | ✅ | +| 分类介绍生成(4类) | 33.5s | ✅ | +| 图片生成 | 0.3s | ✅ | +| 报告生成 | 0.0s | ✅ | +| **总耗时** | **153s** | ✅ | + +**API 消耗统计:** + +| 用途 | 次数 | tokens 估算 | +|------|:---:|:---:| +| 标题翻译 | 7 | 1,400 | +| 正文翻译 | 6 | 4,800 | +| AI评分分类 | 15 | 12,000 | +| 网摘生成 | 5 (含2次重试) | 4,000 | +| 分类介绍 | 4 | 600 | +| **合计** | **37** | **~22,800** | + +**输出文件验证:** + +``` +military_report_20260514.md 10.6 KB ✅ Markdown 格式完整 +military_webzine_20260514.txt 4.9 KB ✅ TOP3 网摘文本完整 +military_webzine_20260514.png 868.5 KB ✅ 合并长图渲染正常 +article_cache.db 60.0 KB ✅ 数据完整无异常 +``` + +### A.2 SQLite 缓存数据完整性验证 + +冷启动全链路运行后,直接查询 `article_cache.db` 验证: + +**基本信息:** + +| 表 | 行数 | 说明 | +|------|:---:|------| +| `article_cache` | 15 | 与关键词筛选后文章数完全一致 | +| `category_summary_cache` | 4 | 今日必看/装备动态/地区冲突/战略政策全覆盖 | + +**逐项检查:** + +| 检查项 | 结果 | 判定 | +|------|:---:|:---:| +| id 重复 | 0 条 | ✅ | +| 失败条目 (status≠1) | 0 条 | ✅ | +| translated_title 缺失 | 0/15 | ✅ | +| translated_content 缺失 | 0/15 | ✅ | +| ai_summary 缺失 | 0/15 | ✅ | +| ai_category 缺失 | 0/15 | ✅ | +| published_time 缺失 | 0/15 | ✅ | +| webzine_text 覆盖率 | 3/15 | ✅ (仅TOP3需要) | +| ai_score 范围 | 3.3 ~ 6.5 (avg 5.3) | ✅ 正态分布 | +| 分类分布 | 装备7 / 战略5 / 冲突3 | ✅ 合理 | + +### A.3 缓存命中验证(连续运行2次) + +| 指标 | 第一次(冷启动) | 第二次(含缓存) | +|------|:---:|:---:| +| 文章缓存命中 | 0 / 15 | **15 / 15** ✅ | +| API 调用次数 | 37 次 | **0 次** ✅ | +| 总耗时 | 153s | **~5s** ✅ | +| 退出码 | 0 | 0 | + +> 第二次运行时文章缓存、网摘缓存、分类介绍缓存全部命中,跳过所有 AI 调用,仅做 RSS 抓取 + 文件排版,几乎零 token 消耗。 + +### A.4 飞书推送验证 + +| 场景 | 配置 | 结果 | 主流程 | +|------|------|------|:---:| +| 推送关闭 | `enable_feishu_push: false` | "未启用任何推送渠道,跳过" | exit 0 ✅ | +| **飞书(真实webhook)** | `enable_feishu_push: true` | **飞书推送成功** 🎉 | exit 0 ✅ | + +**错误处理矩阵(5级分层):** + +| 异常类型 | 处理方式 | 主流程影响 | +|------|------|:---:| +| `ConnectionError` | warning 日志 + 返回 False | 无 | +| `Timeout` (连接5s/读取15s) | warning 日志 + 返回 False | 无 | +| `HTTPError` (4xx/5xx) | warning 日志 + 返回 False | 无 | +| `JSONDecodeError` | warning 日志 + 返回 False | 无 | +| 其他 `Exception` | warning 日志 + 返回 False | 无 | + +--- + +*文档版本:v3.0* +*最后更新:2026-05-29* +*状态:融合 AI Daily 四大核心设计 + military-digest-v3 六大工程资产* diff --git a/my-daily/docs/PROGRESS.md b/my-daily/docs/PROGRESS.md new file mode 100644 index 0000000..a4d7920 --- /dev/null +++ b/my-daily/docs/PROGRESS.md @@ -0,0 +1,227 @@ +# 开发进展追踪 + +--- + +> 对照 [20260529_新闻智能分析系统开发计划_v3.md](./20260529_新闻智能分析系统开发计划_v3.md) +> +> 最后更新:2026-06-30 + +## 总览 + +| 阶段 | 名称 | 进度 | 状态 | +|------|------|:---:|:---:| +| 一 | 基础架构搭建 | 7/7 | ✅ 完成 | +| 二 | Fetch循环与双层评分系统 | 6/6 | ✅ 完成 | +| 三 | 实体提取与细粒度聚类 | 7/7 | ✅ 完成 | +| 四 | 热度评价与动态阈值 | 4/4 | ✅ 完成 | +| 五 | Push循环与三层整合 | 9/9 | ✅ 完成 | +| 六 | 日报生成与跨日关联 | 7/8 | ⚠️ 部分完成 | +| 七 | 优化与迭代 | 3/4 | ⚠️ 部分完成 | + +--- + +## 阶段一:基础架构搭建 + +| 序号 | 任务 | 状态 | 实现位置 | 备注 | +|:---:|------|:---:|------|------| +| 1.1 | RSS 采集模块 | ✅ | [src/fetcher.py](src/fetcher.py) | aiohttp + feedparser 异步抓取,137 源 OPML | +| 1.2 | 数据标准化 | ✅ | [src/processor.py](src/processor.py) | HTML → Markdown(markdownify) | +| 1.3 | 文件存储层 | ✅ | [src/storage.py](src/storage.py) | JSON 文件读写,fetch/hotalert/cluster/digest 四类,时间戳格式 `YYYYMMDD-HHmmSS` | +| 1.4 | SQLite 缓存系统 | ✅ | [src/cache.py](src/cache.py) | 三级缓存(article / category_summary / webzine_text),含迁移机制 | +| 1.5 | 翻译管线 | ✅ | [src/translator.py](src/translator.py) | 中英分离(hanzi>20%)+ ThreadPoolExecutor 并发 | +| 1.6 | 监控统计 | ✅ | [src/monitor.py](src/monitor.py) | 12 阶段耗时 + API 统计 + 源成功率 + 缓存命中率 | +| 1.7 | 日志系统 | ✅ | [src/logger.py](src/logger.py) | Python logging,分级输出 | + +**验收状态**: +- [x] 稳定采集 50+ RSS 源(实际 137 源) +- [x] SQLite 缓存三级覆盖(文章/网摘/分类摘要) +- [x] 中英分离正确,中文文章零翻译 API 调用 +- [x] 监控报告(阶段耗时、API统计、源成功率、缓存命中率) +- [x] 日志系统可定位问题 + +--- + +## 阶段二:Fetch 循环与双层评分系统 + +| 序号 | 任务 | 状态 | 实现位置 | 备注 | +|:---:|------|:---:|------|------| +| 2.1 | Fetch 循环引擎 | ✅ | [main.py](main.py) `fetch_loop()` / `run_fetch_job()` | 可配置间隔(默认 30 分钟),支持 `--max` / `--lookback` 测试参数 | +| 2.2 | 单篇评分 Prompt | ✅ | [prompts/score_batch.md](prompts/score_batch.md) | 批量评分,0-100 分含核心约束与分档规则 | +| 2.3 | 硬约束过滤器 | ✅ | [src/scoring.py](src/scoring.py) `apply_hard_constraints()` | 非目标域名封顶 79 / KOL 来源封顶 89 / 时间衰减(含 min_age_hours) | +| 2.4 | 即时推送判断 | ✅ | [main.py](main.py) `run_fetch_job()` / [prompts/immediate_push.md](prompts/immediate_push.md) | 高评分条目触发即时推送候选(hot_threshold=90) | +| 2.5 | 飞书 Webhook 推送 | ✅ | [src/push/feishu.py](src/push/feishu.py) | 卡片消息 V2,8000 字符自动分片 | +| 2.6 | 部署脚本 | ✅ | [systemd/](systemd/) + [scripts/setup_systemd.sh](scripts/setup_systemd.sh) + [scripts/setup_service.bat](scripts/setup_service.bat) + [Dockerfile](Dockerfile) | Linux systemd + Windows 计划任务 + Docker 容器化 | + +**验收状态**: +- [x] Fetch 循环稳定运行 +- [x] 翻译管线集成正确(中英分离+并发翻译) +- [x] 硬约束生效(非目标领域封顶 79,KOL 转述封顶 89,时间衰减含最小年龄阈值) +- [x] 90+ 文章触发即时推送候选 +- [x] SQLite 缓存正常工作 +- [x] 监控统计准确记录各阶段数据 +- [x] 部署脚本可一键启动/停止 + +--- + +## 阶段三:实体提取与细粒度聚类 + +| 序号 | 任务 | 状态 | 备注 | +|:---:|------|:---:|------| +| 3.1 | 实体提取 Prompt | ✅ | [prompts/extract_entities.md](prompts/extract_entities.md) — 5 类实体,双列示例(军事/科技 + 政策/福利) | +| 3.2 | 角度分类 Prompt | ✅ | [prompts/classify_angle.md](prompts/classify_angle.md) — 6 类角度 + 空值(广告),决策树 + 反例表 | +| 3.3 | 批量实体提取 | ✅ | [src/processors/entity_extractor.py](src/processors/entity_extractor.py) — 实体+角度 asyncio.gather 并发,已集成 fetch 流程 | +| 3.4 | 角度分类服务 | ✅ | 与 3.3 合并实现。广告→空角度,全链路四层广告过滤 | +| 3.5 | 实体归一化 | ✅ | [src/processors/entity_normalizer.py](src/processors/entity_normalizer.py) + [src/processors/entity_aliases.json](src/processors/entity_aliases.json) — 双层归一化(规则映射 + LLM 消歧),Pickle 缓存加速,版本追踪 | +| 3.6 | 聚类算法 | ✅ | [src/processors/cluster_engine.py](src/processors/cluster_engine.py) — 加权实体重叠 + 并查集,装备/事件实体直接合并,泛化黑名单防误合并 | +| 3.7 | 记忆系统 | ✅ | [src/processors/cluster_memory.py](src/processors/cluster_memory.py) — SQLite 持久化已推送簇 ID,24h 内同簇不重复推送 | + +**验收状态**: +- [x] 实体提取对军事科技文章准确(歼-35、辽宁舰、驻欧美军),广告文章正确返回空 +- [x] 角度分类准确率:战略分析纯度从 20% 提升至 100%,广告识别为空的准确率 100% +- [x] 实体归一化双层架构(规则 200+ 别名 + LLM 消歧)运行正常 +- [x] 聚类后实体簇数量合理,泛化实体黑名单防误合并 + +--- + +## 阶段四:热度评价与动态阈值 + +| 序号 | 任务 | 状态 | 实现位置 | 备注 | +|:---:|------|:---:|------|------| +| 4.1 | 实体簇热度计算 | ✅ | [src/processors/heat_calculator.py](src/processors/heat_calculator.py) — 四维评分(最高分40% + 传播30% + 角度20% + 优先10%),含时间衰减和动态阈值 | +| 4.2 | 动态阈值算法 | ✅ | [src/processors/heat_calculator.py](src/processors/heat_calculator.py) `calculate_hotspot_threshold()` | 均值+N×σ + clamp,支持 env/config 覆盖 | +| 4.3 | 热点识别引擎 | ✅ | [src/processors/heat_calculator.py](src/processors/heat_calculator.py) + [main.py](main.py) | 硬约束过滤 + 簇热度阈值 + 簇记忆去重,三条链路完整 | +| 4.4 | 热点精评 Prompt | ✅ | [prompts/precise_heat.md](prompts/precise_heat.md) + [src/llm.py](src/llm.py) `precise_score_clusters()` | Top 30 簇 LLM 精评,四维评分,与启发式 6:4 融合 | + +**验收状态**: +- [x] 实体簇热度计算正确(融合硬约束分数) +- [x] 动态阈值自适应当日分布(单篇层 + 实体簇层均支持) +- [x] 双层筛选协同工作(硬约束过滤→动态阈值筛选) +- [x] 热点精评融合正确(启发式 × 0.6 + 精评 × 0.4) + +--- + +## 阶段五:Push 循环与三层整合 + +| 序号 | 任务 | 状态 | 实现位置 | 备注 | +|:---:|------|:---:|------|------| +| 5.1 | Push 循环引擎 | ✅ | [main.py](main.py) `push_loop()` / `run_push_job()` | 按 cron 表达式定时,区分早报/默认推送 | +| 5.2 | 摘要级整合 Prompt | ✅ | [prompts/cluster_insight.md](prompts/cluster_insight.md) — 合并 5.3 双视角设计 | 多源报道 → 50-80 字精炼摘要 | +| 5.3 | 洞察级整合 Prompt | ✅ | [prompts/cluster_insight.md](prompts/cluster_insight.md) — 与 5.2 合并为单次调用 | 双视角(metadata + 正文) | +| 5.4 | Prompt 防退化模块 | ✅ | [src/utils/prompt_checker.py](src/utils/prompt_checker.py) — 45 条禁止套话正则 + 特异性检查 + 违规重试 | 洞察生成后自动验证 | +| 5.5 | 摘要整合服务 | ✅ | [src/integrators/summary_integrator.py](src/integrators/summary_integrator.py) — 编排层:复用洞察摘要 + 轻量 LLM 补缺 | 全量热点簇摘要生成 | +| 5.6 | 洞察整合服务 | ✅ | [main.py](main.py) `_generate_insights_section()` + `_generate_insight_with_retry()` | Top 5 簇深度洞察 + 防退化验证 + 违规重试 | +| 5.7 | 网摘生成服务 | ✅ | [src/sections/webzine/section.py](src/sections/webzine/section.py) | 参考消息风格,280-320 字校验 + 3 次重试 | +| 5.8 | 时间线提取 | ✅ | [prompts/cluster_insight.md](prompts/cluster_insight.md) — 事件脉络段,含时间节点 + [持续跟踪] 跨日标记 | 已通过双视角洞察实现 | +| 5.9 | 多角度整合 | ✅ | [prompts/cluster_insight.md](prompts/cluster_insight.md) — 多角度分析段(技术/战略/舆论维度) | 已通过双视角洞察实现 | + +**验收状态**: +- [x] 网摘正文字数达标率(280-320字) +- [x] 网摘价值点字数达标率(35-45字) +- [x] 摘要整合编排服务正常运行(复用洞察 + flash 补缺) +- [x] 洞察分析覆盖双视角(metadata + 正文),含防退化验证 +- [x] 无禁止套话出现 + +--- + +## 阶段六:日报生成与跨日关联 + +| 序号 | 任务 | 状态 | 实现位置 | 备注 | +|:---:|------|:---:|------|------| +| 6.1 | 日报模板设计 | ✅ | [templates/cluster_view.md](templates/cluster_view.md) + [templates/article_view.md](templates/article_view.md) | 变量替换 + Sentinel 分段,模板驱动组装 | +| 6.2 | Sentinel 分段标记 | ✅ | [src/storage.py](src/storage.py) `assemble_with_sentinels()` | 支持多板块 | +| 6.3 | 跨日关联模块 | ✅ | [src/processors/cross_day.py](src/processors/cross_day.py) — 实体重叠匹配 + 热度/报道量趋势 | 已集成到簇视图,新增 📈 跨日追踪板块 | +| 6.4 | 排序算法 | ✅ | [src/processors/rank_engine.py](src/processors/rank_engine.py) | 三维加权排序(score 0.6 + freshness 0.2 + density 0.2),已集成到推送流程 | +| 6.5 | 日报渲染服务 | ✅ | [src/renderer.py](src/renderer.py) `render_template()` | 模板加载 + 变量替换,已集成到早报两波推送 | +| 6.6 | 网摘图片渲染 | ✅ | [src/generators/image_renderer.py](src/generators/image_renderer.py) + [src/push/image_hosting.py](src/push/image_hosting.py) | 基于网摘 JSON 生成 PNG 合并长图,图床 API 上传,飞书群推送 | +| 6.7 | Web 归档站点 | ❌ | — | — | +| 6.8 | 数据保留策略 | ✅ | [src/storage.py](src/storage.py) | 统一保留 7 天(fetch/hotalert/cluster/digest/images),自动清理 | + +**验收状态**: +- [x] Sentinel 标记正确,可提取 summary / webzine / category_overview +- [x] 跨日关联可追踪实体历史报道 + 趋势分析 +- [x] 网摘图片渲染正常(PIL 渲染 + 图床上传 + 飞书推送) +- [ ] Web 站点可正常访问(未实现,计划 6.7) + +--- + +## 阶段七:优化与迭代 + +| 序号 | 任务 | 状态 | 备注 | +|:---:|------|:---:|------| +| 7.1 | Prompt 优化 | ✅ | [docs/prompt_review_7.1.md](docs/prompt_review_7.1.md) | 7 个 Prompt 全面优化:军事时政档位、信息战维度、中文环境适配、术语准确性要求 | +| 7.2 | 反馈闭环 | ✅ | [src/processors/feedback.py](src/processors/feedback.py) | 用户评分修正记录、规则自动生成、评分后处理应用,支持 `feedback` 命令行 | +| 7.3 | 趋势分析 | ✅ | [src/processors/trend_analyzer.py](src/processors/trend_analyzer.py) | 跨日热度趋势追踪,集成到簇视图推送 | +| 7.4 | 个性化推荐 | ❌ | — | 基于用户阅读历史的排序(未实现) | + +--- + +## 额外实现(超出 V3 计划) + +| 功能 | 实现位置 | 备注 | +|------|------|------| +| Docker 容器化部署 | [Dockerfile](Dockerfile) + [docker-compose.yml](docker-compose.yml) + [.dockerignore](.dockerignore) | 基于 Python 3.11-slim,内置 Noto Sans CJK 中文字体,支持绿联云 NAS | +| 定时计划环境变量配置 | [.env.example](.env.example) + [src/config.py](src/config.py) `_apply_env_overrides()` | FETCH_INTERVAL_MINUTES / FETCH_LOOKBACK_MINUTES / PUSH_CRON / TIMEZONE_HOURS | +| 图床集成 | [src/push/image_hosting.py](src/push/image_hosting.py) | 公网图床 API 认证 → 上传 → URL 获取,支持密码认证 | +| 飞书图片推送 | [src/push/feishu.py](src/push/feishu.py) `send_image()` | 图床上传后构造 Markdown 消息推送飞书群 | + +--- + +## 已实现模块(28 个源文件) + +| 文件 | 功能 | +|------|------| +| [src/config.py](src/config.py) | 配置加载 + OPML 解析 + 源合并 + 环境变量覆盖 | +| [src/fetcher.py](src/fetcher.py) | RSS 异步抓取 | +| [src/processor.py](src/processor.py) | HTML → Markdown 转换 | +| [src/translator.py](src/translator.py) | 中英分离翻译管线 | +| [src/processors/entity_extractor.py](src/processors/entity_extractor.py) | 实体提取 + 角度分类批量服务 | +| [src/processors/entity_normalizer.py](src/processors/entity_normalizer.py) | 实体归一化(双层:规则映射 + LLM 消歧) | +| [src/processors/cluster_engine.py](src/processors/cluster_engine.py) | 细粒度聚类引擎(加权实体重叠 + 并查集) | +| [src/processors/cluster_memory.py](src/processors/cluster_memory.py) | 簇记忆系统(已推送簇持久化,24h 去重) | +| [src/processors/heat_calculator.py](src/processors/heat_calculator.py) | 簇热度计算(四维评分 + 动态阈值) | +| [src/processors/rank_engine.py](src/processors/rank_engine.py) | 排序引擎(三维加权排序) | +| [src/processors/trend_analyzer.py](src/processors/trend_analyzer.py) | 趋势分析(跨日热度追踪) | +| [src/processors/feedback.py](src/processors/feedback.py) | 反馈闭环(评分修正记录与规则生成) | +| [src/processors/cross_day.py](src/processors/cross_day.py) | 跨日关联(实体重叠 + 趋势) | +| [src/generators/image_renderer.py](src/generators/image_renderer.py) | 网摘图片生成(单篇/合并长图) | +| [src/generators/get_chinese_font.py](src/generators/get_chinese_font.py) | 中文字体加载(内置字体 + 系统fallback) | +| [src/llm.py](src/llm.py) | LLM 调用(评分、Digest、即时推送、洞察、精评) | +| [src/scoring.py](src/scoring.py) | 评分后处理(硬约束 + 时间衰减 + 动态阈值) | +| [src/cache.py](src/cache.py) | SQLite 三级缓存 | +| [src/storage.py](src/storage.py) | 文件存储 + Sentinel 分段标记 | +| [src/monitor.py](src/monitor.py) | 运行监控统计 | +| [src/logger.py](src/logger.py) | 日志系统 | +| [src/utils.py](src/utils.py) | 通用工具函数 | +| [src/markdown_utils.py](src/markdown_utils.py) | Markdown 格式工具 | +| [src/renderer.py](src/renderer.py) | 模板渲染 | +| [src/push/feishu.py](src/push/feishu.py) | 飞书 Webhook 推送(含图片消息) | +| [src/push/image_hosting.py](src/push/image_hosting.py) | 图床上传模块 | +| [src/sections/rss/section.py](src/sections/rss/section.py) | RSS Digest 板块 | +| [src/sections/webzine/section.py](src/sections/webzine/section.py) | 网摘生成板块 | +| [src/utils/prompt_checker.py](src/utils/prompt_checker.py) | Prompt 防退化检查器 | +| [src/integrators/summary_integrator.py](src/integrators/summary_integrator.py) | 摘要整合编排服务 | +| [main.py](main.py) | 入口 + 双循环架构 | + +## 提示词文件(10 个) + +| 文件 | 用途 | +|------|------| +| [prompts/score_batch.md](prompts/score_batch.md) | 批量评分(0-100 分) | +| [prompts/digest.md](prompts/digest.md) | RSS Digest 生成 | +| [prompts/immediate_push.md](prompts/immediate_push.md) | 即时推送快讯 | +| [prompts/webzine.md](prompts/webzine.md) | 参考消息风格网摘 | +| [prompts/category_overview.md](prompts/category_overview.md) | 分类概览 | +| [prompts/extract_entities.md](prompts/extract_entities.md) | 实体提取(5 类:人物/机构/装备/事件/地域) | +| [prompts/classify_angle.md](prompts/classify_angle.md) | 角度分类(6 类+空值/广告) | +| [prompts/precise_heat.md](prompts/precise_heat.md) | 热点精评(四维评分) | +| [prompts/cluster_insight.md](prompts/cluster_insight.md) | 双视角洞察(精炼摘要 + 结构化洞察) | +| [prompts/cluster_summary.md](prompts/cluster_summary.md) | 轻量簇摘要(flash 模型,50-80 字纯文本) | + +## 下一步建议 + +1. **Web 归档站点(6.7)**:部署在线阅览站点,提供历史日报浏览 +2. **个性化推荐(7.4)**:基于用户阅读历史和评分反馈的个性化排序 + +--- + +*每次完成阶段性开发后,请同步更新本文件中的对应条目状态。* \ No newline at end of file diff --git a/my-daily/docs/prompt_diff_7.1.md b/my-daily/docs/prompt_diff_7.1.md new file mode 100644 index 0000000..1e8bb88 --- /dev/null +++ b/my-daily/docs/prompt_diff_7.1.md @@ -0,0 +1,158 @@ +# Prompt 修改前后对比报告 + +## 1. score_batch.md + +### 90+ 条件 +| 修改前 | 修改后 | +|--------|--------| +| 来源为当事人本人的官方账号或官方博客(非KOL、非媒体) | 来源为当事方官方账号或权威防务专业媒体(Defense News / The War Zone / Janes / 新华社等) | + +### KOL 封顶规则 +| 修改前 | 修改后 | +|--------|--------| +| 来源是KOL/媒体/分析师转述(即使转述的是新装备发布)→ **上限89分** | 低质KOL(纯转述无增量、个人自媒体、无专业背景的营销号)→ **上限89分**。有深度分析/独家信息/专业背景的KOL不在此限 | + +### tags 字符限制 +| 修改前 | 修改后 | +|--------|--------| +| 2-12 个字符 | 2-16 个字符 | + +### 分档新增 +| 修改前 | 修改后 | +|--------|--------| +| 90-100: 军事/科技 + 官方首发 + 里程碑 | 90-100: 军事/科技 + 官方或权威媒体首发 + 里程碑。**新增:军事时政重磅事件也在此档** | + +--- + +## 2. precise_heat.md + +### 军事价值维度 +| 修改前 | 修改后 | +|--------|--------| +| 装备首次曝光=35-40, 技术验证=25-35, 演习=15-25, 非军事=0-15 | **新增军控谈判/重大制裁=25-35**,**新增外交斡旋=15-25**。**新增注意**:芯片管制/AI军事化/太空军备等交叉领域属于军事,不归入非军事 | + +### 传播广度 +| 修改前 | 修改后 | +|--------|--------| +| 5+源 × 3+角度 = 12-15 | **新增**:新华社/央视/国防部发布的一手通稿视为等效多源,不因"单源"而扣分 | + +### 信息密度新增 +| 修改前 | 修改后 | +|--------|--------| +| — | **新增加分项**:含卫星图像/电子信号数据/OSINT分析/部署热力图等情报级信息 +3 | + +--- + +## 3. cluster_insight.md + +### 分析维度 +| 修改前 | 修改后 | +|--------|--------| +| 技术维度 / 战略维度 / **舆论维度** | 技术维度(增加"战术/战役层面应用价值") / 战略维度 / **信息战/认知战维度**(各方信息释放、叙事冲突、舆论引导策略) | + +### 摘要字数 +| 修改前 | 修改后 | +|--------|--------| +| 50-80 字 | 50-100 字。**新增**:摘要应体现该簇最核心的军事/科技/时政价值点 | + +### 约束新增 +| 修改前 | 修改后 | +|--------|--------| +| — | 装备型号必须完整(如"F-35A"不能简写为"F35"),兵力/规模要有具体数字 | + +--- + +## 4. webzine.md + +### 来源格式 +| 修改前 | 修改后 | +|--------|--------| +| 据公众号 XXX 报道 | 据**微信**公众号 XXX | + +### 字数区间 +| 修改前 | 修改后 | +|--------|--------| +| 280~320 字 | 250~350 字 | + +### 新增要求 +| 修改前 | 修改后 | +|--------|--------| +| — | 装备型号必须完整准确(如"F-35A"不简写为"F35"),兵力规模使用具体数字 | + +--- + +## 5. immediate_push.md + +### 语气风格 +| 修改前 | 修改后 | +|--------|--------| +| 客观、犀利、克制 | 客观、**准确**、克制 | + +### Why it matters +| 修改前 | 修改后 | +|--------|--------| +| 1-2句洞察分析 | 应涉及对**地区安全架构/军力平衡/技术格局的影响** | + +### 新增规则 +| 修改前 | 修改后 | +|--------|--------| +| — | **规则5**:型号完整性("阿利·伯克级"不简写"伯克级"),兵力/射程/吨位使用具体数字 | + +--- + +## 6. cluster_summary.md + +### 字数 +| 修改前 | 修改后 | +|--------|--------| +| 50-80 字 | 50-100 字 | + +### 新增规则 +| 修改前 | 修改后 | +|--------|--------| +| 5条规则 | **新增规则6**:装备型号必须完整,地点精确到城市/基地级别 | + +--- + +## 7. classify_angle.md + +### 角度数量 +| 修改前 | 修改后 | +|--------|--------| +| **7 个选项**(6类 + 空值) | **8 个选项**(7类 + 空值),新增 `情报信号` | + +### 新增角度 +| 角度 | 说明 | +|------|------| +| `情报信号` | 基于OSINT的发现:卫星图像识别/电子信号截获/部队部署热力图/新基地建设/异常调动 | + +### 决策顺序变更 +| 步骤 | 修改前 | 修改后 | +|:---:|------|------| +| 0 | 广告→空 | 不变 | +| 1 | 表态→舆论反应 | 不变 | +| 2 | 冲突/演习→冲突事件 | 不变 | +| **3** | 政策→政策发布 | **信息源为卫星图/AIS/电子信号→情报信号** | +| **4** | 技术首破→技术突破 | **装备首破/列装/测试→技术突破** | +| **5** | 人物→人物动态 | **政策/法规/预算出台→政策发布** | +| 6 | 战略分析兜底 | 人物→人物动态 | +| **7** | — | **有主观论断→战略分析** | +| **8** | — | **以上不符→归入最接近角度** | + +### 歧义修复 +| 修改前 | 修改后 | +|--------|--------| +| 无判定标准 | 政策发布 vs 技术突破:信息源头是官方公告→政策发布;是测试/列装/验证→技术突破 | + +--- + +## 跨 Prompt 影响总览 + +| 维度 | 修改前 | 修改后 | +|------|--------|--------| +| 信息源认知 | 官方首发 > 一切 | 权威防务专业媒体 = 官方首发的等效价值 | +| 军事时政 | 无专门覆盖 | score/precise_heat 均新增军事时政档位 | +| 信息战维度 | 无 | cluster_insight 新增,替代传统舆论维度 | +| 情报信号 | 无独立角度 | classify_angle 新增 `情报信号` 角度 | +| 中文环境适配 | KOL一律封顶 | 区分低质KOL vs 专业KOL;通稿等效多源 | +| 术语准确性 | 无要求 | 4个Prompt统一要求型号完整/数字具体 | diff --git a/my-daily/docs/prompt_review_7.1.md b/my-daily/docs/prompt_review_7.1.md new file mode 100644 index 0000000..a3ef597 --- /dev/null +++ b/my-daily/docs/prompt_review_7.1.md @@ -0,0 +1,433 @@ +# Prompt 审查修改汇总(7.1) + +> 修改原则: +> 1. 消除"官方首发 > 专业媒体"的认知偏差——防务专业媒体首发与官方首发同等价值 +> 2. 军事科技、军事时政、信息战/认知战维度在所有Prompt中补齐 +> 3. 中文语境适配(微信公众号转述不降分、KOL封顶只对低质KOL) +> 4. 统一术语准确性要求(型号完整、兵力具体、地名精确) + +--- + +## 1. prompts/score_batch.md + +**修改点**: +- 90+ 条件去掉"当事方官方首发",改为"官方或权威专业防务媒体首发" +- KOL 封顶改为"低质KOL / 纯转述无增量信息",有深度分析的KOL不限 +- tags 字符限制从 2-12 放宽到 2-16 +- 新增"军事时政"为独立价值维度 + +```markdown +你是一个专业且严苛的军事科技新闻主编。请对抓取到的碎片化信息进行过滤、评分和信息提取。 + +## 任务与评分标准 +请根据以下标准为每条信息打分(0-100)。 + +**核心约束(先判这三条,再进入分档)**: +1. 90+ **必须同时满足**:(a) 主题与时政/军事/科技强相关;(b) 来源为当事方官方账号或权威防务专业媒体(Defense News / The War Zone / Janes / 新华社等,非低质KOL、非纯营销号);(c) 属于首发或第一时间跟进 +2. 非时政/军事/科技主题(纯娱乐八卦、生活琐事、纯体育等)无论多重大、即使来自官方,**上限79分** +3. 时政/军事/科技新闻若来源是低质KOL(纯转述无增量信息、个人自媒体、无专业背景的营销号),**上限89分**。有深度分析、独家信息或专业背景(退役军官/OSINT分析师/行业研究员)的KOL不在此限。 + +**分档**: +- 【90-100分】军事/科技领域 + 官方或权威防务专业媒体首发 + 里程碑级装备/政策/事件。军事时政重磅事件(军控条约签署、重大制裁、香格里拉级别对话会)也在此档。 +- 【80-89分】重要军事科技进展、深度技术分析、军事时政重要进展;或重磅新闻但通过普通媒体转述。 +- 【70-79分】实用装备分析、技术教程、行业报告;**非目标领域**的重磅新闻;低质KOL转述的军事内容。 +- 【60-69分】二手信息、一般性新闻、小道消息。 +- 【<60分】低价值内容:纯情绪宣泄、无营养评价、广告。 + +## 输出要求 +必须返回纯JSON对象,顶层包含`items`数组字段,数组中每个对象包含: +- `link`: 原文链接(必须保留原样) +- `score`: 整数评分,JSON数字类型 +- `tags`: 字符串数组,数量1-3个,每个标签2-16个字符。必须是新闻中具体的关键词(装备型号/政策名称/技术特性/关键人物/地缘事件),禁止空泛分类标签。 + - ✅ 好示例:`["歼-35A","舰载战斗机","隐身性能"]`、`["AI芯片出口限制","Rubin架构"]` + - ❌ 坏示例:`["军事","新闻","热点"]` +- `summary`: 一句话客观摘要(提取核心事实,50字内) + +## 输出格式(严格只输出JSON对象): +{ + "items": [ + { + "link": "https://example.com/article1", + "score": 95, + "tags": ["歼-35A", "舰载战斗机", "隐身性能"], + "summary": "空军官方确认歼-35A已列装首批作战部队。" + } + ] +} + +## 重要提示 +1. items数组长度必须与输入相同 +2. link字段必须与输入一一对应 +3. 只返回JSON对象,不要添加任何额外说明文字 + +## 输入数据 +以下是一个JSON数组,包含抓取到的内容: +每个对象包含:link, title, source, published, content + +```json +{entries_json} +``` +``` + +--- + +## 2. prompts/precise_heat.md + +**修改点**: +- 军事价值增加"军事时政"档位(制裁/军控/外交战略部署) +- 传播广度适配中文环境(新华社通稿视为等效多源) +- 新增"情报价值"维度提示(卫星图/OSINT/电子信号/热力图) + +```markdown +你是一个资深的军事新闻主编。请对以下热点话题簇进行精评分(0-100)。 + +## 评分维度 + +请从以下四个维度综合打分: + +1. **军事价值(40分)**:是否涉及装备列装/技术突破/实战行动/战略部署/军事时政 + - 装备首次曝光/列装/实战验证 = 35-40 + - 技术验证/测试进展/军控谈判/重大制裁 = 25-35 + - 演习/训练/常规动态/外交斡旋 = 15-25 + - 非军事主题 = 0-15 + - 注意:芯片出口管制、AI军事化政策、太空军备竞赛等交叉领域属于军事科技/军事时政,不应归入"非军事" + +2. **时效性(25分)**:是否为最新进展或首次报道 + - 24h内的突发/首次报道 = 20-25 + - 近期进展的跟进报道 = 15-20 + - 回顾性/综述性内容 = 5-15 + +3. **信息密度(20分)**:是否包含具体数据/型号/地点/时间 + - 含具体型号+数量+地点+时间 = 15-20 + - 含部分具体信息 = 10-15 + - 泛泛而谈/观点评论 = 5-10 + - 加分项:含卫星图像/电子信号数据/OSINT分析/部署热力图等情报级信息 +3 + +4. **传播广度(15分)**:多家媒体、多个角度报道的热度 + - 5+源或新华社等国家级通讯社通稿 × 3+角度 = 12-15 + - 3-4源 或 2角度 = 8-12 + - 单一来源 = 3-8 + - 注意:中文军事新闻中,新华社/央视/国防部发布的一手通稿视为等效多源,不因"单源"而扣分 + +## 输入数据 + +以下是一个 JSON 数组,包含热点话题簇的摘要信息: + +```json +{clusters_json} +``` + +## 输出要求 + +必须返回纯 JSON 对象,格式如下: + +```json +{ + "items": [ + { + "cluster_id": 0, + "precise_score": 92, + "brief": "歼-35A首次公开确认列装,官方首发,多源多角度报道" + } + ] +} +``` + +要求: +1. items 数组长度必须与输入相同 +2. cluster_id 必须与输入一一对应 +3. precise_score 为 0-100 整数 +4. brief 为 25 字以内的评分依据简述 +5. 只返回 JSON 对象,不要添加额外说明 +``` + +--- + +## 3. prompts/cluster_insight.md + +**修改点**: +- "舆论维度"改为"信息战/认知战维度" +- summary 段与 body 建立呼应关系(摘要应体现洞察的核心发现) +- 字数区间扩大到 50-100 字 + +```markdown +你是一个资深军事新闻主编兼情报分析师。请对以下热点话题簇进行整合分析,同时输出精炼摘要和结构化洞察。 + +## 输入数据 + +以下是一个 JSON 对象,包含该话题簇的所有文章: + +```json +{cluster_json} +``` + +## Part 1: 摘要整合(新闻编辑视角) + +基于多源报道,生成一条 50-100 字的精炼摘要: + +- 只陈述核心事实,不做趋势判断 +- 不使用形容词(如"重大""深远""重要") +- 合并重复信息,提取最关键的 who/what/when/where +- 摘要应体现该簇最核心的军事/科技/时政价值点 + +## Part 2: 结构化洞察(情报分析师视角) + +### Metadata(10 字标题 + 20 字导语 + 3-5 要点) + +生成以下结构化信息: +- title: 10 字以内的标题(事实陈述,无修饰) +- lead: 20 字以内的导语(核心事实) +- highlights: 3-5 个关键要点(bullet points,纯事实) + +约束:只陈述事实,不用形容词。 + +### 洞察正文 + +基于多源报道,识别: +1. **事件脉络**:关键时间节点和进展。如果该话题是持续多日的事件,事件脉络段第一行必须写上 `[持续跟踪]`(单独一行),随后再写: + - 前情提要:**优先使用下方 `previous_summary` 字段**(上次推送的洞察摘要),用 1-2 句概括此前已知的关键进展。仅当 `previous_summary` 为空或不存在时,才从素材文章的早期报道中提取前情。禁止写"无显著前情" + - 最新突破:今天的最新进展(具体事实) +2. 多角度分析: + - **技术维度**:装备性能、技术水平、技术创新点及其在战术/战役层面的应用价值 + - **战略维度**:地缘政治影响、军力平衡变化、联盟关系调整 + - **信息战/认知战维度**:各方如何通过信息释放塑造战场叙事、舆论引导策略、叙事冲突(替代传统"舆论维度") +3. 影响与展望:短期影响、中长期趋势、值得关注信号 + +约束: +- 禁止空泛评论(如"意义重大""影响深远") +- 每个观点必须有素材支撑 +- 使用具体名称(装备型号/政策名称/组织名称),不用"某些""部分"等模糊指代 +- 装备型号必须完整(如"F-35A"不能简写为"F35"),兵力/规模要有具体数字 + +## 输出格式 + +必须返回纯 JSON 对象: + +```json +{ + "summary": "50-100字精炼摘要,纯事实陈述", + + "metadata": { + "title": "10字以内的标题", + "lead": "20字以内的导语", + "highlights": ["要点1", "要点2", "要点3"] + }, + + "body": "## 事件脉络\n[持续跟踪]\n- 前情提要:...\n- 最新突破:...\n\n## 多角度分析\n- **技术维度**:...\n- **战略维度**:...\n- **信息战/认知战维度**:...\n\n## 影响与展望\n短期:...\n中期:...\n值得关注:..." + +注意:如果该话题是单日事件(所有报道在同一天),事件脉络段**不加** `[持续跟踪]` 标记。 +} +``` + +要求: +1. summary 必须 50-100 字(中文字符计数) +2. metadata.highlights 至少 2 条,至多 5 条 +3. body 必须包含三个段落(事件脉络/多角度分析/影响与展望) +4. 只返回 JSON 对象,不要添加额外说明 +``` + +--- + +## 4. prompts/webzine.md + +**修改点**: +- "据公众号XXX报道" → "据微信公众号XXX" +- 字数从 280-320 放宽到 250-350 +- 新增专业术语准确性要求 + +```markdown +# 军事科技网摘生成 + +你是一名资深军事编辑,请你严格按照《参考消息》官方新闻报道格式、标题风格,对我提供的新闻内容进行改写,严格遵守以下所有规则: + +## 文章信息 +- 标题:{title} +- 来源:{source} +- 发布时间:{published} +- AI摘要:{summary} +- 原文内容:{content} + +## 标题要求 +- 简洁、客观、中性、信息密度高 +- 结构:主体 + 事件 + 核心态势 +- 不抒情、不夸张、不用网络用语 + +## 价值点要求 +- 价值点严格 35~45 字(含标点) +- 句式结构:事件 - 影响/后果 - 值得关注 +- 凝练、客观、不发散 + +## 正文要求 +- 开头第一句必须加:据 XXX 报道 +- 如果新闻来源是微信公众号,开头第一句必须加:据微信公众号 XXX +- 正文风格:客观、平实、严谨、书面化,类似外电编译稿 +- 只保留核心信息:时间、地点、人物、事件、内容、前景/影响 +- 装备型号必须完整准确(如"F-35A"不简写为"F35"),兵力规模使用具体数字 +- 全文一段到底,不分段 +- 字数强制要求:正文字数严格控制在 250~350 字(含标点),不许超、不许少 + +## 输出格式 + +严格输出 JSON,包含六个字段: + +```json +{ + "title": "网摘标题", + "value": "价值点", + "body": "正文", + "source": "新华社", + "original_title": "原文原标题", + "published": "2026-06-02T10:00:00" +} +``` + +- source: 来源名称 +- original_title: 未经改写的原标题 +- published: 发布时间 + +只输出 JSON,不要任何前缀或后缀文字。 +``` + +--- + +## 5. prompts/immediate_push.md + +**修改点**: +- 去掉"犀利",改为"客观、准确、克制" +- 新增装备型号完整性和Why it matters地缘后果要求 + +```markdown +你是一位顶级的军事科技观察家与专业的军事新闻记者。你需要将最新发生的高分重磅消息整理成即时推送快讯。 + +## 任务要求: +1. **严格查重与阻断机制**:仔细对比原始数据与【过去已推送历史】。如果事件已经推送过且无重大新进展,请直接抛弃。如果所有输入的事件都被判定为重复,你必须且只能输出[NO_NEW_CONTENT]。 +2. **专业聚合**:如果输入数据中有多条讨论同一个事件,请将它们融合为一条快讯。 +3. **专业视角**:用2-3条无序列表项陈述核心事实(What);列表之后另起一段给出1-2句为什么值得关注(Why it matters)的洞察分析,应涉及对地区安全架构/军力平衡/技术格局的影响。 +4. **语气风格**:客观、准确、克制。多用短句,重点内容加粗。避免空洞的行业宏大叙事和未来预测。 +5. **军事专业性**:装备型号必须写完整(如"F-35A"不简写"F35"、"阿利·伯克级驱逐舰"不简写"伯克级"),兵力/射程/吨位等使用具体数字。 + +## 输出格式: +{output_format} + +## 过去N天已推送事件清单(仅供查重,严禁模仿): +{recent_push_context} + +## 本次需要推送的原始数据: +```json +{entries} +``` +``` + +--- + +## 6. prompts/cluster_summary.md + +**修改点**: +- 字数放宽到 50-100 字,与 cluster_insight 对齐 +- 增加具体化要求(型号/地点/数字) + +```markdown +你是一个军事新闻编辑。请将以下多源报道合并为一条 50-100 字的中文精炼摘要。 + +## 规则 + +1. 只陈述核心事实(who/what/when/where),不做趋势判断 +2. 不使用形容词(如"重大""深远""重要""显著") +3. 不使用模糊指代(如"某些""相关""有关"),必须用具体名称 +4. 合并重复信息,提取最关键的进展 +5. 如果同一事件有多个角度,用 1-2 句话概括主要维度 +6. 装备型号必须完整(如"歼-35A"),地点精确到城市/基地级别 + +## 输入 + +```json +{cluster_json} +``` + +## 输出 + +只返回 50-100 字的中文摘要文本,不要 JSON 包裹,不要额外说明。 +``` + +--- + +## 7. prompts/classify_angle.md + +**修改点**: +- 新增"情报信号"角度(卫星图/OSINT/电子信号/部署热力图) +- 修复政策发布 vs 技术突破的歧义:以"信息源头是官方公告还是技术验证"为判定标准 +- 战略分析兜底规则收紧:必须有"分析/认为/判断"等关键词否则不归入 + +```markdown +你是一名军事科技新闻编辑。请判断以下文章的报道角度。 + +## 角度分类(七选一 + 空值) + +必须从以下 8 个选项中选择 **恰好 1 个**: + +| 角度 | 适用场景 | 正确示例 | ⚠️ 不适用(反例) | +|------|---------|---------|-------------------| +| `政策发布` | 官方政策/法规/条例/命令/预算的**正式发布或生效** | 《国防白皮书》发布;某国批准新军费预算;出口管制新规 | 非官方渠道的解读/分析;内部工作会议;产品发布/广告 | +| `技术突破` | 新型装备/武器/技术的**实质进展**(首飞/列装/测试/验证) | 新一代战斗机首飞;新型雷达通过验收;高超音速导弹试射成功 | 常规装备维护升级;历史装备回顾;技术路线对比分析 | +| `情报信号` | 基于**开源情报(OSINT)**的发现:卫星图像识别/电子信号截获/部队部署热力图/新基地建设/异常调动 | 卫星图发现南海新雷达阵地;AIS数据显示航母编队异常动向;商业卫星揭示朝鲜导弹发射场扩建 | 官方已宣布的部署/演习;媒体报道的常规驻防;历史考古 | +| `战略分析` | 对局势/博弈/地缘的**深度研判**,文中必须有"分析/认为/意味着/判断/预测"等主观论断 | 台海局势走向研判;俄乌冲突对军贸格局影响 | 单纯事实报道;产品推广;采访/会见/任命(注意:无主观论断的纯事实归入其他角度) | +| `舆论反应` | 官方**回应/表态/谴责/抗议/澄清** | 外交部谴责某国军售;国防部回应南海传闻 | 网民个人观点;历史事件追认/纪念 | +| `冲突事件` | 正在发生或刚发生的**具体军事行动/冲突/演习/部署** | 军舰穿越台湾海峡;边境交火;联合军演启动 | 历史回顾;战略层面演习意义分析;政策文件威慑措辞 | +| `人物动态` | 以**人物或机构活动**为核心:任免、访晤、会议、纪念、文化活动、基层事迹 | 国防部长访问某国;军营开放日;烈士纪念仪式 | 以政策/装备/事件为核心、人物仅为信息源的报道 | +| `""`(空) | 明确**不属于军事/科技/时政**领域:广告、促销、纯生活内容、娱乐八卦 | 商品促销广告、周边挂件上新、纯情感散文 | — | + +## 决策顺序(从上到下,命中即止) + +``` +0. 是否明确不属于军事/科技/时政(广告/促销/纯娱乐)?→ 返回空字符串 "" +1. 文中是否有"谴责/驳斥/抗议/回应/声明"等官方表态?→ 舆论反应 +2. 是否在报道某场具体的战斗/冲突/演习/部署过程?→ 冲突事件 +3. 信息源是否为卫星图像/AIS/电子信号等OSINT数据?→ 情报信号 +4. 是否在报道某项技术/装备的首次突破/列装/测试/验证?→ 技术突破 +5. 是否在宣布某个政策/法规/条例/命令/预算的正式出台?→ 政策发布 +6. 文章核心是人物(任免/访问/活动)还是机构内部事务?→ 人物动态 +7. 文中是否有明确的"分析/认为/判断/预测/意味着"等主观论断?→ 战略分析 +8. 以上均不符合 → 根据内容归入最接近的角度(避免都进战略分析) +``` + +## 强制规则 + +1. 只看标题和正文前 500 字做判断 +2. **情报信号**必须基于开源情报(OSINT)数据源(卫星图/AIS/电子信号/热力图等),普通新闻配图不算 +3. **政策发布 vs 技术突破**判别标准:信息源头是官方公告 → 政策发布;是测试/列装/验证的实际进展 → 技术突破 +4. **战略分析**必须在文中找到至少 1 处明确的"分析/判断/预测/认为/意味着"等主观论断,否则不选 +5. 一条输出对应一条输入,链接丢失的输出项将被丢弃 + +## 输出格式 + +严格 JSON: + +```json +{{ + "items": [ + {{"link": "原文链接", "angle": "技术突破"}}, + {{"link": "原文链接2", "angle": "情报信号"}} + ] +}} +``` + +## 输入数据 + +```json +{entries_json} +``` +``` + +--- + +## 汇总:修改统计 + +| Prompt | 主要改动 | 影响 | +|------|------|:---:| +| score_batch.md | 90+放宽权威来源 + KOL分级 + tags放宽 | 🔴 核心 | +| precise_heat.md | 军事时政档位 + 中文传播适配 + 情报加分 | 🔴 核心 | +| cluster_insight.md | 信息战维度 + 摘要/洞察呼应 + 字数放宽 | 🔴 核心 | +| webzine.md | 公众号格式 + 字数放宽 + 术语准确性 | 🟡 中等 | +| immediate_push.md | 风格修正 + 型号完整性 + 地缘后果引导 | 🟡 中等 | +| cluster_summary.md | 字数对齐 + 具体化要求 | 🟢 轻量 | +| classify_angle.md | 情报信号角度 + 歧义修复 + 决策顺序 | 🟡 中等 | diff --git a/my-daily/docs/web_archive_design.md b/my-daily/docs/web_archive_design.md new file mode 100644 index 0000000..7d48c92 --- /dev/null +++ b/my-daily/docs/web_archive_design.md @@ -0,0 +1,522 @@ +# Web 归档站点设计方案 + +> 版本:2.0 | 日期:2026-06-30 | 对应 V3 计划 6.7 + +## 一、项目定位 + +整个项目分为两个独立子系统: + +| 子系统 | 位置 | 角色 | +|--------|------|------| +| **数据管线后端** | `my-daily/` 根目录(`main.py` + `src/`) | 数据源:RSS 抓取 → LLM 评分 → 推送飞书 → 生成 `data/*.md` | +| **Web 归档站点** | `my-daily/web_archive/` | 前端展示:读取 `data/*.md` → 渲染 HTML 页面 → 浏览器访问 | + +**关系**:数据管线是生产者,Web 归档站点是消费者。两者通过 `data/` 目录共享数据,互不依赖。 + +**三种启动方式**: + +| 方式 | 命令 | 说明 | +|------|------|------| +| 全栈启动 | `python start.py` | 同时启动数据管线 + Web 归档站点 | +| 仅数据管线 | `python main.py loop` | 只运行 RSS 抓取、评分、推送 | +| 仅 Web 归档 | `python -m web_archive` | 独立运行,只读已有 `data/`,数据管线可关闭 | + +## 二、目录结构 + +``` +my-daily/ +├── main.py # [现有] 数据管线后端入口(loop/fetch/push) +├── start.py # [新增] 一键启动脚本(双进程) +│ +├── web_archive/ # [新增] Web 归档站点(前端 + Flask 后端,可独立启动) +│ ├── __init__.py +│ ├── __main__.py # 入口:python -m web_archive 即可独立启动 +│ ├── server.py # Flask 应用(路由 + API) +│ ├── data_parser.py # 数据解析(读取 data/*.md → 结构化 JSON) +│ ├── templates/ # Jinja2 模板 +│ │ └── archive.html # 归档站点主页面 +│ └── static/ # 静态资源 +│ ├── css/ +│ │ └── archive.css # 响应式样式 +│ └── js/ +│ └── archive.js # 前端交互(Tab 切换 / 日期切换) +│ +├── data/ # [现有] 数据目录(两个子系统共享) +│ ├── cluster-*.md # 热点速览 Markdown +│ ├── digest-*.md # 每日精选 Markdown +│ └── images/YYYYMMDD/ # 网摘图片 +│ +├── src/ # [现有] 数据管线核心模块 +│ ├── storage.py # 文件读写 +│ ├── markdown_utils.py # frontmatter 解析 +│ └── ... +│ +└── requirements.txt # [修改] 新增 flask, markdown +``` + +## 三、架构设计 + +### 3.1 整体架构 + +``` +┌──────────────────────────────────────────────────┐ +│ start.py(一键全栈启动) │ +│ multiprocessing 启动两个独立子进程 │ +│ │ +│ ┌──────────────────┐ ┌────────────────────┐ │ +│ │ 数据管线后端 │ │ Web 归档站点 │ │ +│ │ main.py loop │ │ web_archive (独立) │ │ +│ │ Fetch + Push │ │ Flask :8080 │ │ +│ │ 写入 data/*.md │ │ 只读 data/*.md │ │ +│ └────────┬─────────┘ └─────────┬──────────┘ │ +│ │ │ │ +│ └────── data/ ──────────┘ │ +│ (共享数据目录) │ +│ │ +│ ★ Web 归档站点可独立启动:python -m web_archive │ +│ 数据管线关闭时,站点仍可正常浏览已有数据 │ +└──────────────────────────────────────────────────┘ +``` + +### 3.2 数据流 + +``` +[数据管线] [Web 归档站点] +main.py loop web_archive/server.py + │ │ + ├─ Fetch RSS │ + ├─ LLM 评分 │ + ├─ 推送飞书 │ + └─ 写入 data/ ────── 共享 ──────→ 读取 data/ + ├─ cluster-*.md ├─ 解析 frontmatter + ├─ digest-*.md ├─ 提取 Sentinel 分段 + └─ images//*.png ├─ Markdown → HTML + └─ 渲染 archive.html + │ + 浏览器访问 :8080 +``` + +### 3.3 技术选型 + +| 层 | 技术 | 说明 | +|----|------|------| +| Web 框架 | Flask 3.x | 轻量,与 Python 生态一致 | +| 模板引擎 | Jinja2(Flask 内置) | 服务端渲染,SEO 友好 | +| Markdown 渲染 | `markdown` + `pymdown-extensions` | 支持表格、代码块、扩展语法 | +| 前端 | 原生 HTML/CSS/JS | 零 npm 依赖,极致轻量 | +| 图片服务 | Flask `send_from_directory` | 从 `data/images/` 读取 | +| 进程管理 | `multiprocessing.Process` | 一键启动双进程 | +| 部署 | 嵌入现有 Docker 容器 | 新增端口 8080 | + +## 四、页面设计 + +### 4.1 整体布局 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 🪖 军事科技每日摘要 │ +│ [◀ 前一天] 2026年6月30日 [后一天 ▶] │ +│ ┌──────────┬──────────┬──────────┐ │ +│ │ 🔥 热点速览 │ 📰 每日精选 │ 🖼️ 网摘图片 │ ← Tab 导航 │ +│ └──────────┴──────────┴──────────┘ │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ [热点速览 Tab] │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 📋 今日速览 │ │ +│ │ (summary 内容,多源报道摘要列表) │ │ +│ ├──────────────────────────────────────────────────┤ │ +│ │ 🔍 热点洞察 │ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ 簇标题(含 [持续跟踪] 标记) │ │ │ +│ │ │ 摘要 + 要点列表 │ │ │ +│ │ │ 📅 事件脉络(前情提要 → 最新突破) │ │ │ +│ │ │ 🔬 多角度分析(技术/战略/舆论) │ │ │ +│ │ │ 📈 影响与展望 │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ ├──────────────────────────────────────────────────┤ │ +│ │ 📈 跨日追踪 │ │ +│ │ 📊 趋势分析 │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ [每日精选 Tab] │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 📋 今日网摘 Top 3 │ │ +│ │ 每篇:标题 + 来源 + 正文 + 价值点 + 时间 │ │ +│ ├──────────────────────────────────────────────────┤ │ +│ │ 📊 分类概览(5 类 × 5 条) │ │ +│ │ 装备技术 / 地区安全 / 军事改革 / 产业观察 / 其他 │ │ +│ │ 每篇:评分 + 可点击原文链接 │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ [网摘图片 Tab] │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 合并长图(点击放大、手势缩放) │ │ +│ │ [图片预览] │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────┘ +``` + +### 4.2 响应式策略 + +| 屏幕宽度 | 布局 | Tab 位置 | +|---------|------|---------| +| > 768px(PC) | 最大宽度 960px 居中,左右留白 | 顶部水平 Tab | +| ≤ 768px(手机) | 全宽,卡片式布局 | 底部固定 Tab 栏 | + +**PC 端特点**: +- 洞察卡片双列网格布局 `grid-template-columns: repeat(auto-fit, minmax(400px, 1fr))` +- 图片预览限制最大宽度 800px +- 日期导航栏在顶部水平排列 + +**手机端特点**: +- 单列全宽布局 +- 图片 `max-width: 100%` 自适应 +- 触摸友好的按钮尺寸(≥ 44px) +- 底部固定 Tab 导航 +- 日期选择器占据全宽,左右箭头 + 日期文字 + +## 五、API 设计 + +### 5.1 页面路由 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/` | 渲染归档主页,默认显示当天数据 | +| GET | `/?date=2026-06-30` | 渲染指定日期的归档页 | + +### 5.2 数据 API + +| 方法 | 路径 | 返回 | 说明 | +|------|------|------|------| +| GET | `/api/dates` | `{"dates": [...], "today": "..."}` | 可用日期列表 | +| GET | `/api/content?date=2026-06-30` | 结构化 JSON | 指定日期的全部内容 | +| GET | `/images//` | 图片二进制 | 网摘图片文件 | + +### 5.3 API 响应格式 + +```json +// GET /api/content?date=2026-06-30 +{ + "date": "2026-06-30", + "has_data": true, + "cluster": { + "title": "热点速览 | 2026-06-30", + "summary": "...", + "insights": [ + { + "title": "美陆军AI无人艇测试", + "tracking": true, + "summary": "...", + "highlights": ["...", "..."], + "timeline": "...", + "angles": "...", + "outlook": "..." + } + ], + "cross_day": "...", + "trend": "..." + }, + "digest": { + "title": "军事科技每日精选 | 2026-06-30", + "webzine": [ + { + "title": "美陆军利用AI与无人艇提升太平洋后勤能力", + "source": "Defense One", + "body": "...", + "value": "...", + "time": "2026-06-30T19:22:25+08:00" + } + ], + "categories": [ + { + "name": "装备技术", + "articles": [ + {"score": 85, "title": "...", "link": "https://..."} + ] + } + ] + }, + "images": [ + { + "name": "webzine_combined_20260630_215533.png", + "url": "/images/20260630/webzine_combined_20260630_215533.png" + } + ] +} +``` + +## 六、模块详细设计 + +### 6.1 server.py(Flask 应用) + +```python +"""Web 归档站点 - Flask 应用""" +import re +from datetime import date +from pathlib import Path + +from flask import Flask, render_template, send_from_directory, request, jsonify + +from web_archive.data_parser import ( + scan_available_dates, parse_cluster_content, parse_digest_content, + scan_images, md_to_html +) + +# Flask 应用,模板和静态资源在 web_archive/ 内 +app = Flask(__name__, + template_folder="templates", + static_folder="static") + +# 图片目录(相对于项目根目录,即 my-daily/) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +IMAGE_DIR = PROJECT_ROOT / "data" / "images" +DATA_DIR = PROJECT_ROOT / "data" + + +@app.route("/") +def index(): + """渲染归档主页,默认当天""" + target_date = request.args.get("date", date.today().isoformat()) + return render_template("archive.html", date=target_date) + + +@app.route("/api/dates") +def api_dates(): + """返回可用日期列表""" + dates = scan_available_dates(DATA_DIR) + return jsonify({ + "dates": dates, + "today": date.today().isoformat() + }) + + +@app.route("/api/content") +def api_content(): + """返回指定日期的内容 JSON""" + target_date = request.args.get("date", date.today().isoformat()) + cluster = parse_cluster_content(DATA_DIR, target_date) + digest = parse_digest_content(DATA_DIR, target_date) + images = scan_images(IMAGE_DIR, target_date) + + return jsonify({ + "date": target_date, + "has_data": cluster is not None or digest is not None, + "cluster": cluster, + "digest": digest, + "images": images + }) + + +@app.route("/images/") +def serve_image(subpath): + return send_from_directory(IMAGE_DIR, subpath) +``` + +### 6.1.1 __main__.py(独立启动入口) + +```python +"""Web 归档站点 - 独立启动入口 +用法: python -m web_archive [--port PORT] [--host HOST] +""" +import argparse +from web_archive.server import app + + +def main(): + parser = argparse.ArgumentParser(description="Web 归档站点") + parser.add_argument("--port", type=int, default=8080, help="监听端口(默认 8080)") + parser.add_argument("--host", type=str, default="0.0.0.0", help="监听地址(默认 0.0.0.0)") + args = parser.parse_args() + + print("🪖 Web 归档站点 - 独立模式") + print(f" 地址: http://{args.host}:{args.port}") + print(f" 数据目录: data/") + print(" 按 Ctrl+C 停止") + app.run(host=args.host, port=args.port, debug=False) + + +if __name__ == "__main__": + main() +``` + +### 6.2 data_parser.py(数据解析模块) + +复用 `src/markdown_utils.py` 的 `parse_frontmatter()` 函数,实现以下功能: + +| 函数 | 输入 | 输出 | 说明 | +|------|------|------|------| +| `scan_available_dates(data_dir)` | `data/` 目录 | `List[str]` | 扫描 `cluster-*.md` 文件名提取日期,降序排列 | +| `parse_cluster_content(data_dir, date)` | 日期 | `Dict` 或 None | 解析 cluster-*.md → frontmatter + Sentinel 分段 → HTML | +| `parse_digest_content(data_dir, date)` | 日期 | `Dict` 或 None | 解析 digest-*.md → frontmatter + Sentinel 分段 → 结构化 JSON | +| `extract_section(text, name)` | 文本 + 段名 | `str` | 提取 `...END -->` 之间的内容 | +| `md_to_html(text)` | Markdown | `str` | 渲染 Markdown 为 HTML | +| `scan_images(image_dir, date)` | 日期 | `List[Dict]` | 扫描 `images/YYYYMMDD/` 下的图片文件 | + +### 6.3 前端模板(archive.html) + +使用 Jinja2 模板,单文件包含所有 Tab 内容区: + +``` +archive.html +├── 响应式 meta + CSS 引入 +├──
标题 + 日期导航栏 +├──