diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..cb53030
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "WebFetch(domain:community.groq.com)"
+ ]
+ }
+}
diff --git a/agent_reach/channels/__init__.py b/agent_reach/channels/__init__.py
index 5f8d5d8..f367f2a 100644
--- a/agent_reach/channels/__init__.py
+++ b/agent_reach/channels/__init__.py
@@ -23,8 +23,6 @@ from .weibo import WeiboChannel
from .xiaoyuzhou import XiaoyuzhouChannel
from .v2ex import V2EXChannel
from .xueqiu import XueqiuChannel
-from .toutiao import ToutiaoChannel
-from .discord import DiscordChannel
ALL_CHANNELS: List[Channel] = [
@@ -41,8 +39,6 @@ ALL_CHANNELS: List[Channel] = [
XiaoyuzhouChannel(),
V2EXChannel(),
XueqiuChannel(),
- ToutiaoChannel(),
- DiscordChannel(),
RSSChannel(),
ExaSearchChannel(),
WebChannel(),
diff --git a/agent_reach/channels/discord.py b/agent_reach/channels/discord.py
deleted file mode 100644
index 35cb83a..0000000
--- a/agent_reach/channels/discord.py
+++ /dev/null
@@ -1,97 +0,0 @@
-# -*- coding: utf-8 -*-
-"""Discord channel — search via Exa, read public server info via Invite API."""
-
-import json
-import shutil
-import subprocess
-import urllib.request
-from typing import Tuple
-
-from .base import Channel
-
-_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
-_INVITE_API = "https://discord.com/api/v10/invites/{code}?with_counts=true"
-
-
-def _exa_available() -> bool:
- mcporter = shutil.which("mcporter")
- if not mcporter:
- return False
- try:
- r = subprocess.run(
- [mcporter, "config", "list"],
- capture_output=True, text=True, timeout=5
- )
- return "exa" in r.stdout.lower()
- except Exception:
- return False
-
-
-def _get_invite_code(url: str) -> str:
- """Extract invite code from discord.gg/xxx or discord.com/invite/xxx URLs."""
- url = url.rstrip("/")
- for prefix in ("discord.gg/", "discord.com/invite/"):
- if prefix in url:
- return url.split(prefix)[-1].split("/")[0].split("?")[0]
- return ""
-
-
-class DiscordChannel(Channel):
- name = "discord"
- description = "Discord 服务器信息与内容搜索"
- backends = ["Exa via mcporter (搜索)", "Discord Invite API (服务器信息)"]
- tier = 0
-
- def can_handle(self, url: str) -> bool:
- return "discord.gg" in url or "discord.com" in url
-
- def check(self, config=None) -> Tuple[str, str]:
- if _exa_available():
- return "ok", "Discord 可用:Exa 搜索内容,Invite API 读取公开服务器信息"
- return "warn", (
- "Discord Invite API 可用(无需配置),但搜索功能需要 mcporter + Exa MCP。"
- "运行 `agent-reach install --env=auto` 安装 Exa。"
- )
-
- def read(self, url: str) -> str:
- """读取 Discord 服务器公开信息(Invite API)或通过 Jina 读取页面。"""
- code = _get_invite_code(url)
- if code:
- api_url = _INVITE_API.format(code=code)
- req = urllib.request.Request(api_url, headers={"User-Agent": _UA})
- with urllib.request.urlopen(req, timeout=10) as resp:
- data = json.loads(resp.read().decode("utf-8"))
- guild = data.get("guild", {})
- channel = data.get("channel", {})
- members = data.get("approximate_member_count", "未知")
- online = data.get("approximate_presence_count", "未知")
- return (
- f"# {guild.get('name', '未知服务器')}\n\n"
- f"**描述**: {guild.get('description') or '无'}\n"
- f"**成员**: {members:,} 人(在线 {online:,})\n"
- f"**频道**: #{channel.get('name', '未知')}\n"
- f"**邀请链接**: {url}\n"
- )
- # Fallback: 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=15) as resp:
- return resp.read().decode("utf-8")
-
- def search(self, query: str, limit: int = 5) -> str:
- """通过 Exa 搜索 Discord 内容(服务器、帖子、讨论)。"""
- mcporter = shutil.which("mcporter")
- if not mcporter:
- return "搜索需要 mcporter,请运行 `npm install -g mcporter` 安装。"
- cmd = (
- f"mcporter call 'exa.web_search_exa("
- f"query: \"{query} site:discord.com OR site:discord.gg\", "
- f"numResults: {limit}, "
- f"includeDomains: [\"discord.com\", \"discord.gg\"])'"
- )
- r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
- return r.stdout or r.stderr
diff --git a/agent_reach/channels/toutiao.py b/agent_reach/channels/toutiao.py
deleted file mode 100644
index f91d0df..0000000
--- a/agent_reach/channels/toutiao.py
+++ /dev/null
@@ -1,128 +0,0 @@
-# -*- 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_discord_channel.py b/tests/test_discord_channel.py
deleted file mode 100644
index 18fa939..0000000
--- a/tests/test_discord_channel.py
+++ /dev/null
@@ -1,87 +0,0 @@
-# -*- coding: utf-8 -*-
-"""Tests for DiscordChannel."""
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from agent_reach.channels.discord import DiscordChannel, _get_invite_code
-
-
-@pytest.fixture
-def ch():
- return DiscordChannel()
-
-
-class TestDiscordChannelAttributes:
- def test_name(self, ch):
- assert ch.name == "discord"
-
- def test_tier(self, ch):
- assert ch.tier == 0
-
- def test_backends(self, ch):
- assert ch.backends
-
-
-class TestDiscordCanHandle:
- def test_discord_gg(self, ch):
- assert ch.can_handle("https://discord.gg/python")
-
- def test_discord_com(self, ch):
- assert ch.can_handle("https://discord.com/invite/rust")
-
- def test_discord_channel_url(self, ch):
- assert ch.can_handle("https://discord.com/channels/123/456")
-
- def test_rejects_other(self, ch):
- assert not ch.can_handle("https://www.slack.com")
-
-
-class TestGetInviteCode:
- def test_discord_gg(self):
- assert _get_invite_code("https://discord.gg/python") == "python"
-
- def test_discord_com_invite(self):
- assert _get_invite_code("https://discord.com/invite/rust-lang") == "rust-lang"
-
- def test_trailing_slash(self):
- assert _get_invite_code("https://discord.gg/python/") == "python"
-
- def test_no_invite_code(self):
- assert _get_invite_code("https://discord.com/channels/123/456") == ""
-
-
-class TestDiscordCheck:
- def test_check_ok_with_exa(self, ch):
- with patch("agent_reach.channels.discord._exa_available", return_value=True):
- status, msg = ch.check()
- assert status == "ok"
- assert "Exa" in msg
-
- def test_check_warn_without_exa(self, ch):
- with patch("agent_reach.channels.discord._exa_available", return_value=False):
- status, msg = ch.check()
- assert status == "warn"
- assert "Invite API" in msg
-
-
-class TestDiscordRead:
- def test_read_invite_url(self, ch):
- mock_data = {
- "guild": {"name": "Python", "description": "The Python community"},
- "channel": {"name": "general"},
- "approximate_member_count": 400000,
- "approximate_presence_count": 30000,
- }
- mock_resp = MagicMock()
- mock_resp.__enter__ = lambda s: s
- mock_resp.__exit__ = MagicMock(return_value=False)
- mock_resp.read.return_value = __import__("json").dumps(mock_data).encode()
-
- with patch("urllib.request.urlopen", return_value=mock_resp):
- result = ch.read("https://discord.gg/python")
-
- assert "Python" in result
- assert "400,000" in result
- assert "general" in result
diff --git a/tests/test_toutiao_channel.py b/tests/test_toutiao_channel.py
deleted file mode 100644
index 07c2ebf..0000000
--- a/tests/test_toutiao_channel.py
+++ /dev/null
@@ -1,117 +0,0 @@
-# -*- 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) == []