From ba8631c867f3b9ef7645eba977c99d08a84a900f Mon Sep 17 00:00:00 2001 From: yumao Date: Thu, 9 Jul 2026 11:33:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BC=81=E5=BE=AE?= =?UTF-8?q?=E5=B7=B2=E6=8E=A8=E9=80=81=E6=96=B0=E9=97=BB=20link=20?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- daily/news/fetch.py | 14 ++++-- daily/news/pushed_links.py | 87 ++++++++++++++++++++++++++++++++++++++ tests/test_wecom_delta.py | 19 +++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 daily/news/pushed_links.py diff --git a/daily/news/fetch.py b/daily/news/fetch.py index 1250f11..237eadc 100644 --- a/daily/news/fetch.py +++ b/daily/news/fetch.py @@ -46,7 +46,7 @@ def _cn_enabled() -> bool: def _hours_window() -> int: - return max(1, env_int("DAILY_AI_NEWS_HOURS", 72)) + return max(1, env_int("DAILY_AI_NEWS_HOURS", 24)) def _per_feed_limit() -> int: @@ -497,7 +497,7 @@ def _format_news_section( return lines -def prepare_wecom_news_items(news: dict[str, Any]) -> list[dict[str, Any]]: +def prepare_wecom_news_items(news: dict[str, Any], *, date_str: str | None = None) -> list[dict[str, Any]]: if not news.get("enabled"): return [] limit = _wecom_limit() @@ -528,10 +528,14 @@ def prepare_wecom_news_items(news: dict[str, Any]) -> list[dict[str, Any]]: "desc_short": _clean_text(item.get("summary", ""), 36), } ) + if date_str: + from daily.news.pushed_links import filter_unpushed_items + + items = filter_unpushed_items(items, date_str=date_str) return items -def prepare_wecom_cn_news_items(news: dict[str, Any]) -> list[dict[str, Any]]: +def prepare_wecom_cn_news_items(news: dict[str, Any], *, date_str: str | None = None) -> list[dict[str, Any]]: if not news.get("enabled"): return [] limit = _wecom_cn_limit() @@ -577,4 +581,8 @@ def prepare_wecom_cn_news_items(news: dict[str, Any]) -> list[dict[str, Any]]: "desc_short": _clean_text(item.get("summary", ""), 36), } ) + if date_str: + from daily.news.pushed_links import filter_unpushed_items + + items = filter_unpushed_items(items, date_str=date_str) return items diff --git a/daily/news/pushed_links.py b/daily/news/pushed_links.py new file mode 100644 index 0000000..eb235ff --- /dev/null +++ b/daily/news/pushed_links.py @@ -0,0 +1,87 @@ +"""已推送企微早报的新闻 link 去重缓存。""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from daily.config import CACHE_DIR, news_dedup_days +from daily.news.fetch import _normalize_link + + +def _cache_path() -> Path: + return CACHE_DIR / "pushed-news-links.json" + + +def _load_raw() -> dict[str, Any]: + path = _cache_path() + if not path.exists(): + return {"dates": {}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {"dates": {}} + if not isinstance(data.get("dates"), dict): + return {"dates": {}} + return data + + +def _save_raw(data: dict[str, Any]) -> None: + path = _cache_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _prune(data: dict[str, Any], *, keep_days: int) -> None: + dates: dict[str, list[str]] = data.setdefault("dates", {}) + try: + anchor = max(datetime.strptime(d, "%Y-%m-%d") for d in dates) + except ValueError: + return + cutoff = anchor - timedelta(days=keep_days) + for key in list(dates.keys()): + try: + if datetime.strptime(key, "%Y-%m-%d") < cutoff: + dates.pop(key, None) + except ValueError: + dates.pop(key, None) + + +def load_pushed_link_set() -> set[str]: + data = _load_raw() + out: set[str] = set() + for links in (data.get("dates") or {}).values(): + if isinstance(links, list): + out.update(str(x) for x in links if x) + return out + + +def filter_unpushed_items( + items: list[dict[str, Any]], + *, + date_str: str, +) -> list[dict[str, Any]]: + del date_str # reserved for per-day scoping if needed later + seen = load_pushed_link_set() + out: list[dict[str, Any]] = [] + for item in items: + link = _normalize_link(str(item.get("link") or "")) + if not link or link in seen: + continue + out.append(item) + return out + + +def record_pushed_links(date_str: str, links: list[str]) -> None: + data = _load_raw() + dates: dict[str, list[str]] = data.setdefault("dates", {}) + normalized: list[str] = [] + for link in links: + clean = _normalize_link(link) + if clean: + normalized.append(clean) + dates[date_str] = sorted(set(normalized)) + _prune(data, keep_days=news_dedup_days()) + _save_raw(data) diff --git a/tests/test_wecom_delta.py b/tests/test_wecom_delta.py index c0f887e..09ef523 100644 --- a/tests/test_wecom_delta.py +++ b/tests/test_wecom_delta.py @@ -3,7 +3,9 @@ from __future__ import annotations import os +import tempfile import unittest +from pathlib import Path from unittest.mock import patch from daily.config import ( @@ -14,6 +16,7 @@ from daily.config import ( skip_push_when_silent, wecom_mode, ) +from daily.news.pushed_links import filter_unpushed_items, record_pushed_links class ConfigHelpersTests(unittest.TestCase): @@ -40,3 +43,19 @@ class ConfigHelpersTests(unittest.TestCase): def test_delta_baseline_fallback_default(self): with patch.dict(os.environ, {}, clear=True): self.assertEqual(delta_baseline_fallback(), "full") + + +class NewsPushedLinksTests(unittest.TestCase): + def test_filter_and_record_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) / "pushed-news-links.json" + items = [ + {"title": "A", "link": "https://example.com/a?utm_source=x"}, + {"title": "B", "link": "https://example.com/b"}, + ] + with patch("daily.news.pushed_links._cache_path", return_value=cache): + with patch("daily.news.pushed_links.news_dedup_days", return_value=7): + record_pushed_links("2026-07-08", ["https://example.com/a"]) + out = filter_unpushed_items(items, date_str="2026-07-09") + self.assertEqual(len(out), 1) + self.assertEqual(out[0]["link"], "https://example.com/b")