feat(channels): add V2EX support via public API (zero-config, tier=0)
* feat(channels): add V2EX support via public API V2EX provides a public JSON API that requires no authentication. This PR adds: - agent_reach/channels/v2ex.py: V2EXChannel (tier=0, zero-config) - can_handle() matches v2ex.com URLs - check() verifies API reachability via urllib (no extra deps) - Register V2EXChannel in channels/__init__.py - SKILL.md: add V2EX section with curl examples for hot topics, node browsing, topic detail, replies, and user info - tests/test_channels.py: URL matching + mocked ok/warn check tests V2EX API endpoints used: GET /api/v2/topics/hot — hot topics GET /api/topics/show.json — node topics / topic detail GET /api/replies/show.json — topic replies GET /api/members/show.json — user info * feat(channels): expand V2EX channel with data-fetching methods Add get_hot_topics, get_node_topics, get_topic, get_user, and search methods to V2EXChannel using stdlib urllib only (no new dependencies). Update unit tests and SKILL.md with Python call examples. * feat(v2ex): add data fetching methods to V2EXChannel
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for channel registry basics and health checks."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from urllib.error import URLError
|
||||
|
||||
from agent_reach.channels import get_all_channels, get_channel
|
||||
from agent_reach.channels.xiaohongshu import XiaoHongShuChannel
|
||||
from agent_reach.channels.v2ex import V2EXChannel
|
||||
|
||||
|
||||
class TestChannelRegistry:
|
||||
@@ -23,6 +26,305 @@ class TestChannelRegistry:
|
||||
assert "web" in names
|
||||
assert "github" in names
|
||||
assert "twitter" in names
|
||||
assert "v2ex" in names
|
||||
|
||||
|
||||
class TestV2EXChannel:
|
||||
def test_can_handle_v2ex_urls(self):
|
||||
ch = V2EXChannel()
|
||||
assert ch.can_handle("https://www.v2ex.com/t/1234567")
|
||||
assert ch.can_handle("https://v2ex.com/go/python")
|
||||
assert not ch.can_handle("https://github.com/user/repo")
|
||||
assert not ch.can_handle("https://reddit.com/r/Python")
|
||||
|
||||
def test_check_ok_when_api_reachable(self, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def read(self):
|
||||
return b"[]"
|
||||
|
||||
monkeypatch.setattr(
|
||||
urllib.request,
|
||||
"urlopen",
|
||||
lambda req, timeout=None: FakeResponse(),
|
||||
)
|
||||
status, msg = V2EXChannel().check()
|
||||
assert status == "ok"
|
||||
assert "公开 API 可用" in msg
|
||||
|
||||
def test_check_warn_when_api_unreachable(self, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
def raise_error(req, timeout=None):
|
||||
raise URLError("connection refused")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", raise_error)
|
||||
status, msg = V2EXChannel().check()
|
||||
assert status == "warn"
|
||||
assert "失败" in msg
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# get_hot_topics
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_get_hot_topics_returns_list(self, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
fake_data = [
|
||||
{
|
||||
"id": 111,
|
||||
"title": "Python 3.13 发布了",
|
||||
"url": "https://www.v2ex.com/t/111",
|
||||
"replies": 42,
|
||||
"content": "发布公告内容",
|
||||
"created": 1700000000,
|
||||
"node": {"name": "python", "title": "Python"},
|
||||
},
|
||||
{
|
||||
"id": 222,
|
||||
"title": "Rust 好学吗",
|
||||
"url": "https://www.v2ex.com/t/222",
|
||||
"replies": 10,
|
||||
"content": "",
|
||||
"created": 1700000001,
|
||||
"node": {"name": "rust", "title": "Rust"},
|
||||
},
|
||||
]
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
pass
|
||||
|
||||
def read(self):
|
||||
return json.dumps(fake_data).encode()
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: FakeResponse())
|
||||
topics = V2EXChannel().get_hot_topics(limit=5)
|
||||
assert len(topics) == 2
|
||||
assert topics[0]["id"] == 111
|
||||
assert topics[0]["title"] == "Python 3.13 发布了"
|
||||
assert topics[0]["replies"] == 42
|
||||
assert topics[0]["node_name"] == "python"
|
||||
assert topics[0]["node_title"] == "Python"
|
||||
assert topics[0]["created"] == 1700000000
|
||||
|
||||
def test_get_hot_topics_respects_limit(self, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
fake_data = [
|
||||
{"id": i, "title": f"Topic {i}", "url": f"https://v2ex.com/t/{i}", "replies": i,
|
||||
"content": "", "created": 1700000000 + i, "node": {"name": "tech", "title": "Tech"}}
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_): pass
|
||||
def read(self): return json.dumps(fake_data).encode()
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: FakeResponse())
|
||||
topics = V2EXChannel().get_hot_topics(limit=3)
|
||||
assert len(topics) == 3
|
||||
|
||||
def test_get_hot_topics_truncates_content(self, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
long_content = "A" * 300
|
||||
fake_data = [
|
||||
{"id": 1, "title": "Long post", "url": "https://v2ex.com/t/1", "replies": 0,
|
||||
"content": long_content, "created": 1700000000, "node": {"name": "tech", "title": "Tech"}}
|
||||
]
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_): pass
|
||||
def read(self): return json.dumps(fake_data).encode()
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: FakeResponse())
|
||||
topics = V2EXChannel().get_hot_topics(limit=1)
|
||||
assert len(topics[0]["content"]) == 200
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# get_node_topics
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_get_node_topics(self, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
fake_data = [
|
||||
{
|
||||
"id": 333,
|
||||
"title": "Flask 部署问题",
|
||||
"url": "https://www.v2ex.com/t/333",
|
||||
"replies": 5,
|
||||
"content": "求帮助",
|
||||
"created": 1710000000,
|
||||
"node": {"name": "python", "title": "Python"},
|
||||
}
|
||||
]
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_): pass
|
||||
def read(self): return json.dumps(fake_data).encode()
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: FakeResponse())
|
||||
topics = V2EXChannel().get_node_topics("python")
|
||||
assert len(topics) == 1
|
||||
assert topics[0]["id"] == 333
|
||||
assert topics[0]["node_name"] == "python"
|
||||
assert topics[0]["title"] == "Flask 部署问题"
|
||||
assert topics[0]["created"] == 1710000000
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# get_topic
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_get_topic_returns_detail_and_replies(self, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
topic_data = [
|
||||
{
|
||||
"id": 999,
|
||||
"title": "测试帖子",
|
||||
"url": "https://www.v2ex.com/t/999",
|
||||
"content": "帖子正文",
|
||||
"replies": 2,
|
||||
"node": {"name": "qna", "title": "问与答"},
|
||||
"member": {"username": "alice"},
|
||||
"created": 1700000000,
|
||||
}
|
||||
]
|
||||
replies_data = [
|
||||
{
|
||||
"member": {"username": "bob"},
|
||||
"content": "第一条回复",
|
||||
"created": 1700000100,
|
||||
},
|
||||
{
|
||||
"member": {"username": "carol"},
|
||||
"content": "第二条回复",
|
||||
"created": 1700000200,
|
||||
},
|
||||
]
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_): pass
|
||||
def read(self): return json.dumps(self._payload).encode()
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
url = req.full_url
|
||||
if "replies" in url:
|
||||
return FakeResponse(replies_data)
|
||||
return FakeResponse(topic_data)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
result = V2EXChannel().get_topic(999)
|
||||
|
||||
assert result["id"] == 999
|
||||
assert result["title"] == "测试帖子"
|
||||
assert result["author"] == "alice"
|
||||
assert result["node_name"] == "qna"
|
||||
assert len(result["replies"]) == 2
|
||||
assert result["replies"][0]["author"] == "bob"
|
||||
assert result["replies"][1]["content"] == "第二条回复"
|
||||
|
||||
def test_get_topic_handles_empty_replies(self, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
topic_data = [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "孤独帖子",
|
||||
"url": "https://www.v2ex.com/t/1",
|
||||
"content": "",
|
||||
"replies": 0,
|
||||
"node": {"name": "offtopic", "title": "水"},
|
||||
"member": {"username": "dave"},
|
||||
"created": 0,
|
||||
}
|
||||
]
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload): self._payload = payload
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_): pass
|
||||
def read(self): return json.dumps(self._payload).encode()
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
if "replies" in req.full_url:
|
||||
return FakeResponse([])
|
||||
return FakeResponse(topic_data)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
result = V2EXChannel().get_topic(1)
|
||||
assert result["replies"] == []
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# get_user
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_get_user_returns_profile(self, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
fake_user = {
|
||||
"id": 42,
|
||||
"username": "alice",
|
||||
"url": "https://www.v2ex.com/member/alice",
|
||||
"website": "https://alice.dev",
|
||||
"twitter": "alice_tw",
|
||||
"psn": "",
|
||||
"github": "alice",
|
||||
"btc": "",
|
||||
"location": "Shanghai",
|
||||
"bio": "Python dev",
|
||||
"avatar_large": "https://cdn.v2ex.com/avatars/alice_large.png",
|
||||
"created": 1500000000,
|
||||
}
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_): pass
|
||||
def read(self): return json.dumps(fake_user).encode()
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: FakeResponse())
|
||||
user = V2EXChannel().get_user("alice")
|
||||
|
||||
assert user["id"] == 42
|
||||
assert user["username"] == "alice"
|
||||
assert user["github"] == "alice"
|
||||
assert user["location"] == "Shanghai"
|
||||
assert "alice_large.png" in user["avatar"]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# search
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_search_returns_unavailable_notice(self):
|
||||
result = V2EXChannel().search("python asyncio")
|
||||
assert len(result) == 1
|
||||
assert "error" in result[0]
|
||||
assert "V2EX" in result[0]["error"]
|
||||
|
||||
|
||||
class TestXiaoHongShuChannel:
|
||||
|
||||
Reference in New Issue
Block a user