v2.0.0 — Pure glue architecture: zero copied code, pluggable channels

BREAKING: Complete architectural rewrite.

Before: Copied x-reader's fetcher code into readers/ (1205 lines of borrowed code)
After: Pluggable channel system where each channel is a thin wrapper (~50 lines)
       around the best external tool for that platform. Zero copied code.

Architecture:
- channels/base.py — Universal Channel interface (read, search, check)
- channels/web.py — Jina Reader API (swappable)
- channels/github.py — GitHub API (swappable)
- channels/twitter.py — birdx + Jina fallback (swappable)
- channels/youtube.py — yt-dlp (swappable)
- channels/reddit.py — Reddit JSON API + proxy (swappable)
- channels/rss.py — feedparser (swappable)
- channels/bilibili.py — Bilibili API (swappable)
- channels/exa_search.py — Exa semantic search (swappable)

Key design: every backend can be swapped by changing ONE file.
YouTube dies? Change youtube.py. Exa sucks? Swap exa_search.py for Tavily.
Nothing else changes.

Removed: reader.py, schema.py, readers/, search/, utils/ (all x-reader code)
Tests: 36/36 passing
This commit is contained in:
Panniantong
2026-02-24 05:38:21 +01:00
parent 7e4cd961ee
commit 74c3df5c3d
38 changed files with 1155 additions and 2725 deletions
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""Tests for the channel system."""
import pytest
from unittest.mock import patch, MagicMock
from agent_eyes.channels import get_channel_for_url, get_channel, get_all_channels
from agent_eyes.channels.base import ReadResult, SearchResult
class TestChannelRouting:
def test_github_url(self):
ch = get_channel_for_url("https://github.com/openai/gpt-4")
assert ch.name == "github"
def test_twitter_url(self):
ch = get_channel_for_url("https://x.com/elonmusk/status/123")
assert ch.name == "twitter"
def test_youtube_url(self):
ch = get_channel_for_url("https://youtube.com/watch?v=abc")
assert ch.name == "youtube"
def test_reddit_url(self):
ch = get_channel_for_url("https://reddit.com/r/test")
assert ch.name == "reddit"
def test_bilibili_url(self):
ch = get_channel_for_url("https://bilibili.com/video/BV1xx")
assert ch.name == "bilibili"
def test_rss_url(self):
ch = get_channel_for_url("https://example.com/feed.xml")
assert ch.name == "rss"
def test_generic_url_fallback(self):
ch = get_channel_for_url("https://example.com")
assert ch.name == "web"
def test_get_channel_by_name(self):
ch = get_channel("github")
assert ch is not None
assert ch.name == "github"
def test_all_channels_registered(self):
channels = get_all_channels()
names = [ch.name for ch in channels]
assert "web" in names
assert "github" in names
assert "twitter" in names
class TestReadResult:
def test_to_dict(self):
r = ReadResult(title="Test", content="Body", url="https://example.com", platform="web")
d = r.to_dict()
assert d["title"] == "Test"
assert d["content"] == "Body"
assert d["platform"] == "web"
def test_to_dict_optional_fields(self):
r = ReadResult(title="T", content="C", url="u", author="A", date="2025-01-01")
d = r.to_dict()
assert d["author"] == "A"
assert d["date"] == "2025-01-01"
class TestSearchResult:
def test_to_dict(self):
r = SearchResult(title="Test", url="https://example.com", snippet="A snippet")
d = r.to_dict()
assert d["title"] == "Test"
assert d["snippet"] == "A snippet"
class TestGitHubChannel:
@patch("agent_eyes.channels.github.requests.get")
@pytest.mark.asyncio
async def test_search(self, mock_get):
mock_resp = MagicMock()
mock_resp.json.return_value = {
"items": [{"full_name": "test/repo", "html_url": "https://github.com/test/repo",
"description": "A test", "stargazers_count": 100, "forks_count": 10,
"language": "Python", "updated_at": "2025-01-01"}]
}
mock_resp.raise_for_status = MagicMock()
mock_get.return_value = mock_resp
ch = get_channel("github")
results = await ch.search("test query")
assert len(results) == 1
assert results[0].title == "test/repo"
class TestExaSearch:
@patch("agent_eyes.channels.exa_search.requests.post")
@pytest.mark.asyncio
async def test_search(self, mock_post):
from agent_eyes.config import Config
config = Config(config_path="/tmp/test-exa-config.yaml")
config.set("exa_api_key", "test-key")
mock_resp = MagicMock()
mock_resp.json.return_value = {
"results": [{"title": "Result", "url": "https://example.com",
"text": "snippet", "publishedDate": "", "score": 0.9}]
}
mock_resp.raise_for_status = MagicMock()
mock_post.return_value = mock_resp
ch = get_channel("exa_search")
results = await ch.search("test", config=config)
assert len(results) == 1
assert results[0].title == "Result"
+4 -9
View File
@@ -2,9 +2,6 @@
"""Tests for AgentEyes core class."""
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from pathlib import Path
from agent_eyes.config import Config
from agent_eyes.core import AgentEyes
@@ -18,22 +15,20 @@ def eyes(tmp_path):
class TestAgentEyes:
def test_init(self, eyes):
assert eyes.config is not None
assert eyes.reader is not None
def test_detect_platform(self, eyes):
assert eyes.detect_platform("https://github.com/openai/gpt-4") == "github"
assert eyes.detect_platform("https://github.com/test/repo") == "github"
assert eyes.detect_platform("https://reddit.com/r/test") == "reddit"
assert eyes.detect_platform("https://x.com/elonmusk/status/123") == "twitter"
assert eyes.detect_platform("https://x.com/user/status/123") == "twitter"
assert eyes.detect_platform("https://youtube.com/watch?v=abc") == "youtube"
assert eyes.detect_platform("https://bilibili.com/video/BV1xx") == "bilibili"
assert eyes.detect_platform("https://mp.weixin.qq.com/s/abc") == "wechat"
assert eyes.detect_platform("https://example.com") == "generic"
assert eyes.detect_platform("https://example.com") == "web"
def test_doctor(self, eyes):
results = eyes.doctor()
assert isinstance(results, dict)
assert "web" in results
assert "github_read" in results
assert "github" in results
def test_doctor_report(self, eyes):
report = eyes.doctor_report()
+13 -44
View File
@@ -1,11 +1,9 @@
# -*- coding: utf-8 -*-
"""Tests for Agent Eyes doctor module."""
"""Tests for doctor module."""
import pytest
from unittest.mock import patch, MagicMock
from agent_eyes.config import Config
from agent_eyes.doctor import check_all, format_report, STATUS_OK, STATUS_OFF
from agent_eyes.doctor import check_all, format_report
@pytest.fixture
@@ -14,54 +12,25 @@ def tmp_config(tmp_path):
class TestDoctor:
def test_zero_config_platforms_always_ok(self, tmp_config):
def test_zero_config_channels_ok(self, tmp_config):
results = check_all(tmp_config)
assert results["web"]["status"] == STATUS_OK
assert results["github_read"]["status"] == STATUS_OK
assert results["bilibili"]["status"] == STATUS_OK
assert results["rss"]["status"] == STATUS_OK
assert results["tweet_read"]["status"] == STATUS_OK
assert results["web"]["status"] == "ok"
assert results["github"]["status"] == "ok"
assert results["bilibili"]["status"] == "ok"
assert results["rss"]["status"] == "ok"
def test_search_off_without_exa_key(self, tmp_config):
def test_exa_off_without_key(self, tmp_config):
results = check_all(tmp_config)
assert results["search_web"]["status"] == STATUS_OFF
assert results["search_reddit"]["status"] == STATUS_OFF
assert results["search_twitter"]["status"] == STATUS_OFF
assert results["exa_search"]["status"] == "off"
def test_search_on_with_exa_key(self, tmp_config):
def test_exa_on_with_key(self, tmp_config):
tmp_config.set("exa_api_key", "test-key")
results = check_all(tmp_config)
assert results["search_web"]["status"] == STATUS_OK
assert results["search_reddit"]["status"] == STATUS_OK
assert results["search_twitter"]["status"] == STATUS_OK
assert results["exa_search"]["status"] == "ok"
def test_github_search_always_on(self, tmp_config):
results = check_all(tmp_config)
assert results["search_github"]["status"] == STATUS_OK
def test_reddit_full_off_without_proxy(self, tmp_config):
results = check_all(tmp_config)
assert results["reddit_full"]["status"] == STATUS_OFF
def test_reddit_full_on_with_proxy(self, tmp_config):
tmp_config.set("reddit_proxy", "http://user:pass@ip:port")
results = check_all(tmp_config)
assert results["reddit_full"]["status"] == STATUS_OK
@patch("agent_eyes.doctor._check_command")
def test_birdx_detection(self, mock_cmd, tmp_config):
mock_cmd.return_value = True
results = check_all(tmp_config)
assert results["twitter_advanced"]["status"] == STATUS_OK
def test_format_report_is_string(self, tmp_config):
def test_format_report(self, tmp_config):
results = check_all(tmp_config)
report = format_report(results)
assert isinstance(report, str)
assert "Agent Eyes" in report
assert "" in report
def test_format_report_shows_count(self, tmp_config):
results = check_all(tmp_config)
report = format_report(results)
assert "platforms active" in report
assert "channels active" in report
-142
View File
@@ -1,142 +0,0 @@
# -*- coding: utf-8 -*-
"""Tests for Agent Eyes search modules."""
import pytest
from unittest.mock import patch, MagicMock
from agent_eyes.config import Config
@pytest.fixture
def tmp_config(tmp_path):
c = Config(config_path=tmp_path / "config.yaml")
c.set("exa_api_key", "test-key")
return c
class TestExaSearch:
@patch("agent_eyes.search.exa.requests.post")
@pytest.mark.asyncio
async def test_search_web(self, mock_post, tmp_config):
mock_resp = MagicMock()
mock_resp.json.return_value = {
"results": [
{
"title": "Test Result",
"url": "https://example.com",
"text": "This is a test snippet",
"publishedDate": "2025-01-01",
"score": 0.95,
}
]
}
mock_resp.raise_for_status = MagicMock()
mock_post.return_value = mock_resp
from agent_eyes.search.exa import search_web
results = await search_web("test query", config=tmp_config)
assert len(results) == 1
assert results[0]["title"] == "Test Result"
assert results[0]["url"] == "https://example.com"
assert results[0]["snippet"] == "This is a test snippet"
def test_search_web_requires_key(self):
from agent_eyes.search.exa import _get_api_key
with pytest.raises(ValueError, match="Exa API key"):
_get_api_key(Config(config_path="/tmp/nonexistent/config.yaml"))
class TestRedditSearch:
@patch("agent_eyes.search.exa.requests.post")
@pytest.mark.asyncio
async def test_search_reddit(self, mock_post, tmp_config):
mock_resp = MagicMock()
mock_resp.json.return_value = {"results": []}
mock_resp.raise_for_status = MagicMock()
mock_post.return_value = mock_resp
from agent_eyes.search.reddit import search_reddit
results = await search_reddit("test", config=tmp_config)
# Verify it searched site:reddit.com
call_args = mock_post.call_args
query = call_args[1]["json"]["query"]
assert "site:reddit.com" in query
@patch("agent_eyes.search.exa.requests.post")
@pytest.mark.asyncio
async def test_search_reddit_with_sub(self, mock_post, tmp_config):
mock_resp = MagicMock()
mock_resp.json.return_value = {"results": []}
mock_resp.raise_for_status = MagicMock()
mock_post.return_value = mock_resp
from agent_eyes.search.reddit import search_reddit
await search_reddit("test", subreddit="LocalLLaMA", config=tmp_config)
call_args = mock_post.call_args
query = call_args[1]["json"]["query"]
assert "site:reddit.com/r/LocalLLaMA" in query
class TestGitHubSearch:
@patch("agent_eyes.search.github.requests.get")
@pytest.mark.asyncio
async def test_search_github(self, mock_get):
mock_resp = MagicMock()
mock_resp.json.return_value = {
"items": [
{
"full_name": "owner/repo",
"html_url": "https://github.com/owner/repo",
"description": "A test repo",
"stargazers_count": 100,
"forks_count": 20,
"language": "Python",
"updated_at": "2025-01-01",
"topics": ["ai"],
}
]
}
mock_resp.raise_for_status = MagicMock()
mock_get.return_value = mock_resp
from agent_eyes.search.github import search_github
results = await search_github("test")
assert len(results) == 1
assert results[0]["name"] == "owner/repo"
assert results[0]["stars"] == 100
@patch("agent_eyes.search.github.requests.get")
@pytest.mark.asyncio
async def test_search_github_with_language(self, mock_get):
mock_resp = MagicMock()
mock_resp.json.return_value = {"items": []}
mock_resp.raise_for_status = MagicMock()
mock_get.return_value = mock_resp
from agent_eyes.search.github import search_github
await search_github("test", language="python")
call_args = mock_get.call_args
assert "language:python" in call_args[1]["params"]["q"]
class TestTwitterSearch:
@patch("agent_eyes.search.twitter.shutil.which", return_value=None)
@patch("agent_eyes.search.exa.requests.post")
@pytest.mark.asyncio
async def test_search_twitter_exa_fallback(self, mock_post, mock_which, tmp_config):
mock_resp = MagicMock()
mock_resp.json.return_value = {"results": []}
mock_resp.raise_for_status = MagicMock()
mock_post.return_value = mock_resp
from agent_eyes.search.twitter import search_twitter
results = await search_twitter("test", config=tmp_config)
call_args = mock_post.call_args
query = call_args[1]["json"]["query"]
assert "site:x.com" in query