diff --git a/agent_reach/channels/__init__.py b/agent_reach/channels/__init__.py index f2a0149..4893590 100644 --- a/agent_reach/channels/__init__.py +++ b/agent_reach/channels/__init__.py @@ -23,7 +23,7 @@ from .weibo import WeiboChannel from .xiaoyuzhou import XiaoyuzhouChannel from .v2ex import V2EXChannel from .xueqiu import XueqiuChannel - +from .toutiao import ToutiaoChannel ALL_CHANNELS: List[Channel] = [ @@ -40,6 +40,7 @@ ALL_CHANNELS: List[Channel] = [ XiaoyuzhouChannel(), V2EXChannel(), XueqiuChannel(), + ToutiaoChannel(), RSSChannel(), ExaSearchChannel(), WebChannel(), diff --git a/agent_reach/channels/toutiao.py b/agent_reach/channels/toutiao.py new file mode 100644 index 0000000..f91d0df --- /dev/null +++ b/agent_reach/channels/toutiao.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""Toutiao (今日头条) — search articles and trending content.""" + +import json +import re +import urllib.parse +import urllib.request +from typing import List, Tuple + +from .base import Channel + +_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" +_TIMEOUT = 15 +_SEARCH_URL = ( + "https://so.toutiao.com/search" + "?dvpf=pc&source=input&keyword={keyword}&enable_druid_v2=1" +) + +_SKIP_TEMPLATES = ("Search", "Bottom", "76-", "20-", "26-", "67-baike") + + +def _fetch_html(url: str) -> str: + """Fetch URL and return HTML string.""" + req = urllib.request.Request(url, headers={"User-Agent": _UA}) + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: + return resp.read().decode("utf-8") + + +def _parse_search_results(html: str) -> list: + """Extract article results from Toutiao search page HTML. + + The page embeds each search result as a JSON object inside a ", html, re.DOTALL) + articles = [] + for s in scripts: + if len(s) < 1000: + continue + if not s.strip().startswith("{"): + continue + try: + data = json.loads(s).get("data", {}) + except (json.JSONDecodeError, ValueError): + continue + if not isinstance(data, dict): + continue + + title = data.get("title", "") + tpl = data.get("template_key", "") + if not title: + continue + if any(tpl.startswith(p) for p in _SKIP_TEMPLATES): + continue + + # article_url is the primary field; display paths are fallbacks + article_url = ( + data.get("article_url", "") + or data.get("source_url", "") + or (data.get("display") or {}).get("info", {}).get("url", "") + ) + + if not article_url: + continue + + articles.append({ + "title": title, + "url": article_url, + "source": data.get("media_name", "") or data.get("source", ""), + "abstract": (data.get("abstract", "") or "")[:300], + "publish_time": data.get("publish_time"), + "read_count": data.get("read_count"), + "comment_count": data.get("comment_count"), + }) + return articles + + +class ToutiaoChannel(Channel): + name = "toutiao" + description = "今日头条搜索与资讯" + backends = ["Toutiao Web (public)"] + tier = 0 + + def can_handle(self, url: str) -> bool: + from urllib.parse import urlparse + d = urlparse(url).netloc.lower() + return "toutiao.com" in d + + def check(self, config=None) -> Tuple[str, str]: + try: + test_url = _SEARCH_URL.format(keyword="test") + req = urllib.request.Request(test_url, headers={"User-Agent": _UA}) + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: + if resp.status == 200: + content = resp.read().decode("utf-8") + if "data" in content or "title" in content: + return "ok", "头条搜索可用(搜索文章、视频、资讯)" + return "warn", "头条搜索返回非预期内容" + return "warn", "头条搜索返回非 200 状态" + except Exception as e: + return "warn", f"头条搜索连接失败(可能需要代理):{e}" + + def read(self, url: str) -> str: + """通过 Jina Reader 读取头条文章正文。""" + if not url.startswith(("http://", "https://")): + url = "https://" + url + jina_url = f"https://r.jina.ai/{url}" + req = urllib.request.Request( + jina_url, + headers={"User-Agent": _UA, "Accept": "text/plain"}, + ) + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: + return resp.read().decode("utf-8") + + def search(self, keyword: str, limit: int = 10) -> list: + """搜索头条文章。 + + Args: + keyword: 搜索关键词 + limit: 最多返回条数 + + Returns: + list of dicts with keys: + title, url, source, abstract, publish_time, read_count, comment_count + """ + encoded = urllib.parse.quote(keyword) + url = _SEARCH_URL.format(keyword=encoded) + html = _fetch_html(url) + return _parse_search_results(html)[:limit] diff --git a/tests/test_toutiao_channel.py b/tests/test_toutiao_channel.py new file mode 100644 index 0000000..07c2ebf --- /dev/null +++ b/tests/test_toutiao_channel.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +"""Tests for ToutiaoChannel.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from agent_reach.channels.toutiao import ToutiaoChannel, _parse_search_results + + +@pytest.fixture +def ch(): + return ToutiaoChannel() + + +class TestToutiaoChannelAttributes: + def test_name(self, ch): + assert ch.name == "toutiao" + + def test_tier(self, ch): + assert ch.tier == 0 + + def test_backends(self, ch): + assert ch.backends + + +class TestToutiaoCanHandle: + def test_matches_toutiao(self, ch): + assert ch.can_handle("https://www.toutiao.com/article/123") + + def test_matches_search(self, ch): + assert ch.can_handle("https://so.toutiao.com/search?keyword=ai") + + def test_rejects_other(self, ch): + assert not ch.can_handle("https://www.baidu.com") + + +class TestToutiaoCheck: + def test_check_ok(self, ch): + mock_resp = MagicMock() + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + mock_resp.status = 200 + mock_resp.read.return_value = b'{"data": {"title": "test"}}' + with patch("urllib.request.urlopen", return_value=mock_resp): + status, msg = ch.check() + assert status == "ok" + + def test_check_exception(self, ch): + with patch("urllib.request.urlopen", side_effect=Exception("timeout")): + status, msg = ch.check() + assert status == "warn" + assert "timeout" in msg + + +class TestParseSearchResults: + def _make_script(self, data: dict) -> str: + # Production code skips scripts < 1000 chars; pad to exceed threshold + payload = json.dumps({"data": data}) + padding = "x" * max(0, 1001 - len(payload)) + padded = payload[:-1] + f',"_pad":"{padding}"' + payload[-1] + return f'' + + def test_extracts_article_url_first(self): + html = self._make_script({ + "title": "测试文章", + "article_url": "https://www.toutiao.com/article/1", + "template_key": "undefined-default", + }) + results = _parse_search_results(html) + assert len(results) == 1 + assert results[0]["url"] == "https://www.toutiao.com/article/1" + assert results[0]["title"] == "测试文章" + + def test_fallback_to_source_url(self): + html = self._make_script({ + "title": "备用 URL 文章", + "source_url": "https://www.toutiao.com/source/1", + "template_key": "undefined-default", + }) + results = _parse_search_results(html) + assert len(results) == 1 + assert results[0]["url"] == "https://www.toutiao.com/source/1" + + def test_skips_entries_without_url(self): + html = self._make_script({ + "title": "无 URL 文章", + "template_key": "undefined-default", + }) + results = _parse_search_results(html) + assert results == [] + + def test_skips_entries_without_title(self): + html = self._make_script({ + "article_url": "https://www.toutiao.com/article/1", + "template_key": "undefined-default", + }) + results = _parse_search_results(html) + assert results == [] + + def test_skips_skip_templates(self): + html = self._make_script({ + "title": "广告", + "article_url": "https://www.toutiao.com/article/1", + "template_key": "Bottom-ad", + }) + results = _parse_search_results(html) + assert results == [] + + def test_empty_html(self): + assert _parse_search_results("") == [] + + def test_short_scripts_ignored(self): + # script < 1000 chars should be skipped + html = '' + assert _parse_search_results(html) == []