全局重命名: - 包名: agent_eyes → agent_reach - CLI: agent-eyes → agent-reach - 类名: AgentEyes → AgentReach - 显示名: Agent Eyes → Agent Reach - GitHub: Panniantong/agent-eyes → Panniantong/Agent-Reach 所有 36 个测试通过,CLI/doctor/read/search 全部正常。
140 lines
4 KiB
Python
140 lines
4 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Channel base class — the universal interface for all platforms.
|
||
|
||
Every channel (YouTube, Twitter, GitHub, etc.) implements this interface.
|
||
The backend tool can be swapped anytime without changing anything else.
|
||
|
||
Example:
|
||
class YouTubeChannel(Channel):
|
||
name = "youtube"
|
||
backends = ["yt-dlp"] # current backend, can be swapped
|
||
|
||
async def read(self, url, config):
|
||
# Just call yt-dlp, return standardized dict
|
||
...
|
||
"""
|
||
|
||
import shutil
|
||
from abc import ABC, abstractmethod
|
||
from dataclasses import dataclass
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
|
||
@dataclass
|
||
class ReadResult:
|
||
"""Standardized read result. Every channel returns this."""
|
||
title: str
|
||
content: str
|
||
url: str
|
||
author: str = ""
|
||
date: str = ""
|
||
platform: str = ""
|
||
extra: dict = None
|
||
|
||
def __post_init__(self):
|
||
self.extra = self.extra or {}
|
||
|
||
def to_dict(self) -> dict:
|
||
d = {
|
||
"title": self.title,
|
||
"content": self.content,
|
||
"url": self.url,
|
||
"platform": self.platform,
|
||
}
|
||
if self.author:
|
||
d["author"] = self.author
|
||
if self.date:
|
||
d["date"] = self.date
|
||
if self.extra:
|
||
d["extra"] = self.extra
|
||
return d
|
||
|
||
|
||
@dataclass
|
||
class SearchResult:
|
||
"""Standardized search result."""
|
||
title: str
|
||
url: str
|
||
snippet: str = ""
|
||
author: str = ""
|
||
date: str = ""
|
||
score: float = 0
|
||
extra: dict = None
|
||
|
||
def __post_init__(self):
|
||
self.extra = self.extra or {}
|
||
|
||
def to_dict(self) -> dict:
|
||
d = {
|
||
"title": self.title,
|
||
"url": self.url,
|
||
"snippet": self.snippet,
|
||
}
|
||
if self.author:
|
||
d["author"] = self.author
|
||
if self.date:
|
||
d["date"] = self.date
|
||
if self.extra:
|
||
d["extra"] = self.extra
|
||
return d
|
||
|
||
|
||
class Channel(ABC):
|
||
"""
|
||
Base class for all channels.
|
||
|
||
Subclasses just need to implement:
|
||
- read(url, config) → ReadResult
|
||
- can_handle(url) → bool
|
||
- check(config) → (status, message)
|
||
|
||
Optionally:
|
||
- search(query, config, **kwargs) → list[SearchResult]
|
||
"""
|
||
|
||
name: str = "" # e.g. "youtube"
|
||
description: str = "" # e.g. "YouTube video transcripts"
|
||
backends: List[str] = [] # e.g. ["yt-dlp"] — what external tool is used
|
||
requires_config: List[str] = [] # e.g. ["reddit_proxy"]
|
||
requires_tools: List[str] = [] # e.g. ["yt-dlp"]
|
||
tier: int = 0 # 0=zero-config, 1=needs free key, 2=needs setup
|
||
|
||
@abstractmethod
|
||
async def read(self, url: str, config=None) -> ReadResult:
|
||
"""Read content from a URL. Must return ReadResult."""
|
||
...
|
||
|
||
@abstractmethod
|
||
def can_handle(self, url: str) -> bool:
|
||
"""Check if this channel can handle this URL."""
|
||
...
|
||
|
||
def check(self, config=None) -> Tuple[str, str]:
|
||
"""
|
||
Check if this channel is available.
|
||
Returns (status, message) where status is 'ok'/'warn'/'off'/'error'.
|
||
"""
|
||
# Check required tools
|
||
for tool in self.requires_tools:
|
||
if not shutil.which(tool):
|
||
return "off", f"需要安装:pip install {tool}"
|
||
|
||
# Check required config
|
||
for key in self.requires_config:
|
||
if config and not config.get(key):
|
||
return "off", f"需要配置 {key},运行 agent-reach setup"
|
||
|
||
return "ok", f"{'、'.join(self.backends) if self.backends else '内置'}"
|
||
|
||
async def search(self, query: str, config=None, **kwargs) -> List[SearchResult]:
|
||
"""Search this platform. Override if supported."""
|
||
raise NotImplementedError(f"{self.name} does not support search")
|
||
|
||
def can_search(self) -> bool:
|
||
"""Whether this channel supports search."""
|
||
try:
|
||
# Check if search is overridden
|
||
return type(self).search is not Channel.search
|
||
except:
|
||
return False
|