Track bundled vendor runtime sources
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled

This commit is contained in:
2026-07-07 10:05:50 +08:00
parent fbe179ab53
commit 52636c91ae
2111 changed files with 1850012 additions and 14 deletions

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""
Channel registry — lists all supported platforms for doctor checks.
"""
from typing import List, Optional
from .base import Channel
# Import all channels
from .web import WebChannel
from .github import GitHubChannel
from .twitter import TwitterChannel
from .youtube import YouTubeChannel
from .reddit import RedditChannel
from .rss import RSSChannel
from .bilibili import BilibiliChannel
from .exa_search import ExaSearchChannel
from .xiaohongshu import XiaoHongShuChannel
from .linkedin import LinkedInChannel
from .xiaoyuzhou import XiaoyuzhouChannel
from .v2ex import V2EXChannel
from .xueqiu import XueqiuChannel
ALL_CHANNELS: List[Channel] = [
GitHubChannel(),
TwitterChannel(),
YouTubeChannel(),
RedditChannel(),
BilibiliChannel(),
XiaoHongShuChannel(),
LinkedInChannel(),
XiaoyuzhouChannel(),
V2EXChannel(),
XueqiuChannel(),
RSSChannel(),
ExaSearchChannel(),
WebChannel(),
]
def get_channel(name: str) -> Optional[Channel]:
"""Get a channel by name."""
for ch in ALL_CHANNELS:
if ch.name == name:
return ch
return None
def get_all_channels() -> List[Channel]:
"""Get all registered channels."""
return ALL_CHANNELS
__all__ = [
"Channel",
"ALL_CHANNELS",
"get_channel", "get_all_channels",
]

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""
Channel base class — platform availability checking.
Each channel represents a platform (YouTube, Twitter, GitHub, etc.)
and provides:
- can_handle(url) → does this URL belong to this platform?
- check(config) → is the upstream tool installed and configured?
After installation, agents call upstream tools directly.
Backend routing semantics:
- `backends` is an ORDERED candidate list: backends[0] is the preferred
backend, the rest are fallbacks. "Switching backends" for a platform
means reordering this list (or a user override) — not rewriting code.
- check() must set `self.active_backend` to the backend that is actually
serving the channel right now (None when nothing usable is found).
shutil.which() alone is NOT proof of health — a stale venv shim passes
which() but cannot execute (see agent_reach.probe). Channels should
really execute a lightweight command before claiming a backend active.
- Users can force a backend with config key `<channel>_backend`
(or env var `<CHANNEL>_BACKEND`); ordered_backends() applies it.
"""
from abc import ABC, abstractmethod
from typing import List, Optional, Tuple
class Channel(ABC):
"""Base class for all channels."""
name: str = "" # e.g. "youtube"
description: str = "" # e.g. "YouTube 视频和字幕"
backends: List[str] = [] # ordered candidates — backends[0] = preferred
tier: int = 0 # 0=zero-config, 1=needs free key, 2=needs setup
#: Backend currently serving this channel; set by check(), None = unavailable.
active_backend: Optional[str] = None
@abstractmethod
def can_handle(self, url: str) -> bool:
"""Check if this channel can handle this URL."""
...
def ordered_backends(self, config=None) -> List[str]:
"""Candidate backends in probe order, honoring the user override.
The config key `<channel>_backend` (env `<CHANNEL>_BACKEND`) moves the
named backend to the front of the list; unknown values are ignored so
a stale override can never hide working backends.
"""
candidates = list(self.backends)
override = config.get(f"{self.name}_backend") if config else None
if override:
for i, b in enumerate(candidates):
if b == override or b.startswith(override):
candidates.insert(0, candidates.pop(i))
break
return candidates
def check(self, config=None) -> Tuple[str, str]:
"""
Check if this channel's upstream tool is available.
Returns (status, message) where status is 'ok'/'warn'/'off'/'error'.
Subclasses with external backends must really probe them (see
agent_reach.probe.probe_command) and set self.active_backend.
"""
self.active_backend = self.backends[0] if self.backends else "内置"
return "ok", f"{''.join(self.backends) if self.backends else '内置'}"

View File

@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""Bilibili — multi-backend: bili-cli / OpenCLI / search API.
yt-dlp was REMOVED from this channel (live-verified 2026-06): bilibili's
risk control 412-blocks yt-dlp's requests in every configuration we
tried — latest version, direct, proxied, with warmed cookies — while
bili-cli keeps working (search/hot/video detail without login) and
OpenCLI covers subtitles through the browser session. yt-dlp remains the
YouTube backend; it just no longer serves bilibili.
"""
import json
import urllib.request
from agent_reach.probe import probe_command
from .base import Channel
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
_TIMEOUT = 10
_SEARCH_API = "https://api.bilibili.com/x/web-interface/search/all/v2?keyword=test&page=1"
def _search_api_ok() -> bool:
"""Return True if Bilibili search API responds with code 0."""
req = urllib.request.Request(_SEARCH_API, headers={"User-Agent": _UA})
try:
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
data = json.loads(resp.read())
return data.get("code") == 0
except Exception:
return False
class BilibiliChannel(Channel):
name = "bilibili"
description = "B站视频、字幕和搜索"
backends = ["bili-cli", "OpenCLI", "B站搜索 API"]
tier = 1
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
d = urlparse(url).netloc.lower()
return "bilibili.com" in d or "b23.tv" in d
def check(self, config=None):
"""Probe candidates in order; first fully-usable backend wins."""
self.active_backend = None
findings = []
for backend in self.ordered_backends(config):
if backend == "bili-cli":
result = self._check_bili_cli()
elif backend == "OpenCLI":
result = self._check_opencli()
else:
result = self._check_search_api()
if result is None:
continue
findings.append((backend, *result))
# 有后端断链时,即使别的候选兜底成功也要把处方带出来
broken_notes = [m for _, s, m in findings if s == "error"]
for wanted in ("ok", "warn"):
for backend, status, message in findings:
if status == wanted:
self.active_backend = backend
if broken_notes:
message += "\n[备选后端异常] " + "".join(broken_notes)
return status, message
if findings:
return "error", "\n".join(m for _, _, m in findings)
return "off", (
"没有可用的 B站后端搜索 API 也不可达,可能是网络问题)。推荐:\n"
" pipx install bilibili-cli搜索/热门/视频详情,无需登录)\n"
" 或桌面装 OpenCLI额外解锁字幕agent-reach install --channels opencli"
)
def _check_bili_cli(self):
"""bili-cli candidate. None = not installed."""
probe = probe_command("bili", ["--version"], timeout=10, package="bilibili-cli")
if probe.status == "missing":
return None
if probe.status == "broken":
return "error", "bili 命令存在但无法执行\n" + probe.hint
if not probe.ok:
return "warn", f"bili-cli 探测失败({probe.status}),运行 `bili status` 查看详情"
return "ok", (
"bili-cli 可用(搜索/热门/排行/视频详情/音频,无需登录;"
"字幕需 OpenCLI。上游 2026-03 起停更)"
)
def _check_opencli(self):
"""OpenCLI candidate. None = not installed."""
from agent_reach.backends import opencli_status
st = opencli_status()
if not st.installed:
return None
if st.broken:
return "error", st.hint
if st.ready:
return "ok", (
"OpenCLI 可用(复用浏览器登录态)。用法:"
"opencli bilibili search/video/subtitle/ranking -f yaml"
)
return "warn", st.hint
def _check_search_api(self):
"""Zero-dependency search API fallback. None = unreachable."""
if not _search_api_ok():
return None
return "ok", (
"B站搜索 API 可达仅搜索curl 直连)。"
"完整功能建议安装 bili-clipipx install bilibili-cli"
)

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""Exa Search — check if mcporter + Exa MCP is available."""
from agent_reach.probe import probe_command
from .base import Channel
#: mcporter 是 npm 包,断链处方与默认的 pipx/uv 不同
_MCPORTER_BROKEN_HINT = "mcporter 无法执行node 环境损坏),重装:\n npm install -g mcporter"
class ExaSearchChannel(Channel):
name = "exa_search"
description = "全网语义搜索"
backends = ["Exa via mcporter"]
tier = 0
def can_handle(self, url: str) -> bool:
return False # Search-only channel
def check(self, config=None):
self.active_backend = None
probe = probe_command("mcporter", ["config", "list"], timeout=10, package="mcporter")
if probe.status == "missing":
return "off", (
"需要 mcporter + Exa MCP。安装\n"
" npm install -g mcporter\n"
" mcporter config add exa https://mcp.exa.ai/mcp"
)
if probe.status == "broken":
return "error", _MCPORTER_BROKEN_HINT
if not probe.ok: # timeout / error
return "error", f"mcporter 执行异常:{probe.hint or probe.output or probe.status}"
if "exa" in probe.output.lower():
self.active_backend = self.backends[0]
return "ok", "全网语义搜索可用(免费,无需 API Key"
return "off", (
"mcporter 已装但 Exa 未配置。运行:\n"
" mcporter config add exa https://mcp.exa.ai/mcp"
)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""GitHub — check if gh CLI is available."""
from agent_reach.probe import probe_command
from .base import Channel
class GitHubChannel(Channel):
name = "github"
description = "GitHub 仓库和代码"
backends = ["gh CLI"]
tier = 0
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
return "github.com" in urlparse(url).netloc.lower()
def check(self, config=None):
# 真跑 gh auth status 探活。注意:未登录时 rc!=0 是正常业务态warn不是 error。
probe = probe_command("gh", ["auth", "status"], timeout=10, package="gh")
if probe.status == "missing":
self.active_backend = None
return "warn", "gh CLI 未安装。安装https://cli.github.com"
if probe.status == "broken":
# gh 是二进制安装brew/官方包),不是 pip 包——处方不用 pipx/uv 文案
self.active_backend = None
return "error", (
"gh 命令存在但无法执行——安装已损坏。重装即可修复:\n"
" brew reinstall gh\n"
"或从 https://cli.github.com 重新安装 gh CLI"
)
if probe.status == "timeout":
# gh 本体能启动(工具是活的),只是状态检查超时
self.active_backend = "gh CLI"
return "warn", "gh CLI 状态检查超时,运行 gh auth status 查看详情"
if probe.ok:
self.active_backend = "gh CLI"
return "ok", "完整可用读取、搜索、Fork、Issue、PR 等)"
# rc != 0gh 活着但未认证gh auth status 的正常业务态)
self.active_backend = "gh CLI"
return "warn", "gh CLI 已安装但未认证。运行 gh auth login 可解锁完整功能"

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
"""LinkedIn — check if linkedin-scraper-mcp is available."""
from agent_reach.probe import probe_command
from .base import Channel
#: mcporter 是 npm 包,断链处方与默认的 pipx/uv 不同
_MCPORTER_BROKEN_HINT = "mcporter 无法执行node 环境损坏),重装:\n npm install -g mcporter"
class LinkedInChannel(Channel):
name = "linkedin"
description = "LinkedIn 职业社交"
backends = ["linkedin-scraper-mcp", "Jina Reader"]
tier = 2
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
return "linkedin.com" in urlparse(url).netloc.lower()
def check(self, config=None):
self.active_backend = None
probe = probe_command("mcporter", ["config", "list"], timeout=10, package="mcporter")
if probe.status == "missing":
return "off", (
"基本内容可通过 Jina Reader 读取。完整功能需要:\n"
" pip install linkedin-scraper-mcp\n"
" mcporter config add linkedin http://localhost:3000/mcp\n"
" 详见 https://github.com/stickerdaniel/linkedin-mcp-server"
)
if probe.status == "broken":
return "error", _MCPORTER_BROKEN_HINT
if not probe.ok: # timeout / error
return "error", f"mcporter 执行异常:{probe.hint or probe.output or probe.status}"
if "linkedin" in probe.output.lower():
self.active_backend = "linkedin-scraper-mcp"
return "ok", "完整可用Profile、公司、职位搜索"
return "off", (
"mcporter 已装但 LinkedIn MCP 未配置。运行:\n"
" pip install linkedin-scraper-mcp\n"
" mcporter config add linkedin http://localhost:3000/mcp"
)

View File

@@ -0,0 +1,165 @@
# -*- coding: utf-8 -*-
"""Reddit — multi-backend: OpenCLI / rdt-cli. Login is mandatory.
Honest tiering (live-verified 2026-06): there is NO zero-config path.
Anonymous .json endpoints are blocked (403 anti-bot, all variants), and
the official API closed self-service registration in 2025-11 (manual
approval, individual scripts rarely granted — PRAW is only an option for
users who already hold credentials). Every working backend rides a
logged-in session: OpenCLI reuses the browser's, rdt-cli imports cookies.
"""
import json
import shutil
import subprocess
from agent_reach.utils.process import utf8_subprocess_env
from .base import Channel
_CREDENTIAL_FILE = "~/.config/rdt-cli/credential.json"
# Pinned to the 0.4.2 state — PyPI still only has 0.4.1 (upstream issue #10).
_RDT_GIT_SOURCE = "git+https://github.com/public-clis/rdt-cli.git@5e4fb3720d5c174e976cd425ccc3b879d52cac66"
#: shell 对"找到但不可执行/找不到"使用的退出码(对齐 agent_reach.probe
_BROKEN_EXIT_CODES = (126, 127)
#: rdt 应从固定 git 源安装PyPI 落后),断链处方与 probe 默认的 pipx/uv 不同
_RDT_BROKEN_HINT = (
"rdt 命令存在但无法执行——通常是系统 Python 升级后 venv 解释器丢失。\n"
"PyPI 版本落后,推荐用固定 git 源强制重装:\n"
f" pipx install --force '{_RDT_GIT_SOURCE}'"
)
class RedditChannel(Channel):
name = "reddit"
description = "Reddit 帖子和评论"
backends = ["OpenCLI", "rdt-cli"]
tier = 1 # no zero-config path exists — see module docstring
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
d = urlparse(url).netloc.lower()
return "reddit.com" in d or "redd.it" in d
def check(self, config=None):
"""Probe candidates in order; first fully-usable backend wins."""
self.active_backend = None
findings = []
for backend in self.ordered_backends(config):
if backend == "OpenCLI":
result = self._check_opencli()
else:
result = self._check_rdt()
if result is None:
continue
findings.append((backend, *result))
for wanted in ("ok", "warn"):
for backend, status, message in findings:
if status == wanted:
self.active_backend = backend
return status, message
if findings:
return "error", "\n".join(m for _, _, m in findings)
return "off", (
"未安装任何 Reddit 后端。注意Reddit 没有零配置路径"
"(匿名 .json 已被封,官方 API 需人工审批),必须用登录态。推荐:\n"
" 桌面agent-reach install --channels opencli\n"
" (复用 Chrome 登录态,登录过 reddit.com 即可用)\n"
f" 服务器/存量pipx install '{_RDT_GIT_SOURCE}'\n"
" 然后 `rdt login` 或手动写入 Cookie见 doctor 提示)\n"
"中国大陆访问 Reddit 需要代理"
)
def _check_opencli(self):
"""OpenCLI candidate. None = not installed."""
from agent_reach.backends import opencli_status
st = opencli_status()
if not st.installed:
return None
if st.broken:
return "error", st.hint
if st.ready:
return "ok", (
"OpenCLI 可用(复用浏览器登录态)。用法:"
"opencli reddit search/read/subreddit/hot -f yaml"
)
return "warn", st.hint
def _check_rdt(self):
"""rdt-cli candidate. None = not installed."""
rdt = shutil.which("rdt")
if not rdt:
return None
# 不走 probe_command实测 `rdt status --json` 成功时rc=0也会向 stderr
# 打网络重试日志probe 把 stdout+stderr 合并后 JSON 解析必炸。
# 故保留手写 subprocessstdout 单独捕获),但异常分类对齐 probe 语义:
# exec 失败/126/127 → brokenvenv 断链处方TimeoutExpired → 超时。
try:
r = subprocess.run(
[rdt, "status", "--json"],
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=10,
env=utf8_subprocess_env(),
)
except subprocess.TimeoutExpired:
return "error", "rdt 响应超时(>10sReddit 状态未知。稍后重试或运行 `rdt status` 查看详情"
except OSError:
# 含 FileNotFoundErrorwhich 命中但 exec 失败 = venv 断链probe 的 broken
return "error", _RDT_BROKEN_HINT
if r.returncode in _BROKEN_EXIT_CODES:
return "error", _RDT_BROKEN_HINT
if r.returncode != 0:
detail = (r.stderr or r.stdout or "").strip().splitlines()
tail = detail[-1] if detail else "无输出"
return "error", f"rdt 异常退出exit {r.returncode}{tail}。运行 `rdt status` 查看详情"
# 进程正常退出 → rdt 本身是活的(无论登录与否)
try:
data = json.loads(r.stdout or "")
except json.JSONDecodeError:
data = None
if not isinstance(data, dict):
return "warn", "rdt-cli 可用但状态输出无法解析,运行 `rdt status` 查看登录状态"
info = data.get("data")
if not isinstance(info, dict):
info = {}
authenticated = info.get("authenticated", False)
username = info.get("username") or ""
if authenticated:
suffix = f"(已登录:{username}" if username else ""
return "ok", (
f"rdt-cli 可用{suffix}(搜索帖子、阅读全文、查看评论;"
"上游 2026-03 起停更,桌面用户建议迁移到 OpenCLI"
)
return "warn", (
"rdt-cli 已安装但未登录。Reddit 自 2024 年起要求认证,"
"未登录时所有请求均返回 403。\n\n"
"方法一(自动):运行 `rdt login`\n"
" 先在浏览器登录 reddit.com再运行此命令自动提取 Cookie。\n\n"
"方法二(手动,适用于 Chrome/Edge 127+ 无法自动提取时):\n"
" 1. Chrome 应用商店安装 Cookie-Editor 扩展:\n"
" https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm\n"
" 2. 在浏览器打开 reddit.com确保已登录\n"
" 3. 点击 Cookie-Editor 图标,找到 `reddit_session`,复制其 Value\n"
f" 4. 将以下内容写入 {_CREDENTIAL_FILE}\n"
' {"cookies": {"reddit_session": "<粘贴 Value>"}, '
'"source": "manual", "username": "<你的用户名>", '
'"modhash": null, "saved_at": 0, "last_verified_at": null}\n\n'
"验证:`rdt status --json` 确认 authenticated: true"
)

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
"""RSS — check if feedparser is available."""
from .base import Channel
class RSSChannel(Channel):
name = "rss"
description = "RSS/Atom 订阅源"
backends = ["feedparser"]
tier = 0
def can_handle(self, url: str) -> bool:
return any(x in url.lower() for x in ["/feed", "/rss", ".xml", "atom"])
def check(self, config=None):
try:
import feedparser # noqa: F401
except ImportError:
self.active_backend = None
return "off", "feedparser 未安装。安装pip install feedparser"
except Exception as e:
# 已安装但导入期崩溃(半残安装/版本冲突)→ 重装处方
self.active_backend = None
return "error", f"feedparser 导入失败:{e}\n修复pip install --force-reinstall feedparser"
self.active_backend = self.backends[0]
return "ok", "可读取 RSS/Atom 源"

View File

@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
"""Twitter/X — check if twitter-cli or bird CLI is available."""
from .base import Channel
from agent_reach.probe import probe_command
class TwitterChannel(Channel):
name = "twitter"
description = "Twitter/X 推文"
backends = ["twitter-cli", "OpenCLI", "bird CLI (legacy)"]
tier = 1
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
d = urlparse(url).netloc.lower()
return "x.com" in d or "twitter.com" in d
def check(self, config=None):
"""Probe candidates in order; first fully-usable backend wins.
与其他多后端渠道同一套两段式:先收集全部候选状态,第一个 ok 获胜;
没有 ok 才轮到第一个 warn——否则「装了但未登录」的 twitter-cli
会把排在后面、完整可用的 OpenCLI 挡在门外。
"""
self.active_backend = None
findings = []
for backend in self.ordered_backends(config):
if backend == "twitter-cli":
result = self._check_twitter_cli()
elif backend == "OpenCLI":
result = self._check_opencli()
elif backend == "bird CLI (legacy)":
result = self._check_bird()
else:
continue
if result is None:
continue # 未安装——不参与候选
findings.append((backend, *result))
for wanted in ("ok", "warn"):
for backend, status, message in findings:
if status == wanted:
self.active_backend = backend
return status, message
if findings: # 只剩 broken/timeout 候选
return "error", "\n".join(m for _, _, m in findings)
return "warn", (
"Twitter CLI 未安装。安装方式:\n"
" pipx install twitter-cli\n"
"或:\n"
" uv tool install twitter-cli"
)
def _check_twitter_cli(self):
"""探测 twitter-cli。返回 None 表示未安装,否则返回 (status, message)。
`twitter status` 才是健康信号:已登录时输出 "ok: true"
未登录时以非零退出码输出 "not_authenticated"——工具本身是活的,
所以 probe 的 error 状态也要看 output 内容再分类。
"""
probe = probe_command(
"twitter", ["status"], timeout=15, retries=1, package="twitter-cli"
)
if probe.status == "missing":
return None
if probe.status == "broken":
return "error", "twitter-cli 命令存在但无法执行。\n" + probe.hint
if probe.status == "timeout":
return "error", "twitter-cli 健康检查超时(已重试 1 次)。\n" + probe.hint
output = probe.output
if "ok: true" in output:
return "ok", (
"twitter-cli 完整可用(搜索、读推文、时间线、长文/Article、"
"用户查询、Thread"
)
if "not_authenticated" in output:
return "warn", (
"twitter-cli 已安装但未认证。设置方式:\n"
" export TWITTER_AUTH_TOKEN=\"xxx\"\n"
" export TWITTER_CT0=\"yyy\"\n"
"或确保已在浏览器中登录 x.com"
)
return "warn", (
"twitter-cli 已安装但认证检查失败。运行:\n"
" twitter -v status 查看详细信息"
)
def _check_opencli(self):
"""OpenCLI candidate. None = not installed."""
from agent_reach.backends import opencli_status
st = opencli_status()
if not st.installed:
return None
if st.broken:
return "error", st.hint
if st.ready:
return "ok", (
"OpenCLI 可用(复用浏览器登录态)。用法:"
"opencli twitter search/article/user-posts -f yaml"
)
return "warn", st.hint
def _check_bird(self):
"""探测 bird/birdxlegacy 回退)。返回 None 表示均未安装,否则返回 (status, message)。"""
last_failure = None
for cmd in ("bird", "birdx"):
probe = probe_command(
cmd, ["check"], timeout=15, retries=1, package="@steipete/bird"
)
if probe.status == "missing":
continue
if probe.status == "broken":
last_failure = (
"error",
f"{cmd} 命令存在但无法执行bird 是 npm 包,可用 "
"npm install -g @steipete/bird 重装)。\n" + probe.hint,
)
continue # bird 坏了再试 birdx
if probe.status == "timeout":
last_failure = (
"error",
f"{cmd} 健康检查超时(已重试 1 次)。\n" + probe.hint,
)
continue
output = probe.output
if probe.ok:
return "ok", "bird CLI 可用(读取、搜索推文,含长文/X Article"
if "Missing credentials" in output or "missing" in output.lower():
return "warn", (
"bird CLI 已安装但未配置认证。设置环境变量:\n"
" export AUTH_TOKEN=\"xxx\"\n"
" export CT0=\"yyy\""
)
return "warn", (
"bird CLI 已安装但认证检查失败。"
)
return last_failure

View File

@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
"""V2EX — public API channel for topics, nodes, users, and replies."""
import json
import urllib.request
from typing import Any
from .base import Channel
_UA = "agent-reach/1.0"
_TIMEOUT = 10
def _get_json(url: str) -> Any:
"""Fetch *url* and return parsed JSON. Raises on HTTP/network errors."""
req = urllib.request.Request(url, headers={"User-Agent": _UA})
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
class V2EXChannel(Channel):
name = "v2ex"
description = "V2EX 节点、主题与回复"
backends = ["V2EX API (public)"]
tier = 0
# ------------------------------------------------------------------ #
# URL routing
# ------------------------------------------------------------------ #
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
d = urlparse(url).netloc.lower()
return "v2ex.com" in d
# ------------------------------------------------------------------ #
# Health check
# ------------------------------------------------------------------ #
def check(self, config=None):
try:
_get_json(
"https://www.v2ex.com/api/topics/show.json?node_name=python&page=1"
)
self.active_backend = self.backends[0]
return "ok", "公开 API 可用(热门主题、节点浏览、主题详情、用户信息)"
except Exception as e:
self.active_backend = None
return "warn", f"V2EX API 连接失败(可能需要代理):{e}"
# ------------------------------------------------------------------ #
# Data-fetching methods
# ------------------------------------------------------------------ #
def get_hot_topics(self, limit: int = 20) -> list:
"""获取热门帖子列表。
Returns a list of dicts with keys:
title, url, replies, node_name, node_title, content
"""
data = _get_json("https://www.v2ex.com/api/topics/hot.json")
results = []
for item in data[:limit]:
node = item.get("node") or {}
content = item.get("content", "") or ""
results.append(
{
"id": item.get("id", 0),
"title": item.get("title", ""),
"url": item.get("url", ""),
"replies": item.get("replies", 0),
"node_name": node.get("name", ""),
"node_title": node.get("title", ""),
"content": content[:200],
"created": item.get("created", 0),
}
)
return results
def get_node_topics(self, node_name: str, limit: int = 20) -> list:
"""获取指定节点的最新帖子。
Args:
node_name: 节点名称,如 "python""tech""jobs"
limit: 最多返回条数
Returns a list of dicts with keys:
title, url, replies, node_name, node_title, content
"""
url = (
f"https://www.v2ex.com/api/topics/show.json"
f"?node_name={node_name}&page=1"
)
data = _get_json(url)
results = []
for item in data[:limit]:
node = item.get("node") or {}
content = item.get("content", "") or ""
results.append(
{
"id": item.get("id", 0),
"title": item.get("title", ""),
"url": item.get("url", ""),
"replies": item.get("replies", 0),
"node_name": node.get("name", node_name),
"node_title": node.get("title", ""),
"content": content[:200],
"created": item.get("created", 0),
}
)
return results
def get_topic(self, topic_id: int) -> dict:
"""获取单个帖子详情和回复列表。
Args:
topic_id: 帖子 ID从 URL https://www.v2ex.com/t/<id> 中获取)
Returns a dict with keys:
id, title, url, content, replies_count, node_name, node_title,
author, created, replies (list of dicts with: author, content, created)
"""
topic_data = _get_json(
f"https://www.v2ex.com/api/topics/show.json?id={topic_id}"
)
# API returns a list even for single-ID queries
if isinstance(topic_data, list):
topic = topic_data[0] if topic_data else {}
else:
topic = topic_data
node = topic.get("node") or {}
member = topic.get("member") or {}
# Fetch replies (first page)
try:
replies_raw = _get_json(
f"https://www.v2ex.com/api/replies/show.json"
f"?topic_id={topic_id}&page=1"
)
except Exception:
replies_raw = []
replies = [
{
"author": (r.get("member") or {}).get("username", ""),
"content": r.get("content", ""),
"created": r.get("created", 0),
}
for r in (replies_raw or [])
]
return {
"id": topic.get("id", topic_id),
"title": topic.get("title", ""),
"url": topic.get("url", f"https://www.v2ex.com/t/{topic_id}"),
"content": topic.get("content", ""),
"replies_count": topic.get("replies", 0),
"node_name": node.get("name", ""),
"node_title": node.get("title", ""),
"author": member.get("username", ""),
"created": topic.get("created", 0),
"replies": replies,
}
def get_user(self, username: str) -> dict:
"""获取用户信息。
Args:
username: V2EX 用户名
Returns a dict with keys:
id, username, url, website, twitter, psn, github, btc,
location, bio, avatar, created
"""
data = _get_json(
f"https://www.v2ex.com/api/members/show.json?username={username}"
)
return {
"id": data.get("id", 0),
"username": data.get("username", username),
"url": data.get("url", f"https://www.v2ex.com/member/{username}"),
"website": data.get("website", ""),
"twitter": data.get("twitter", ""),
"psn": data.get("psn", ""),
"github": data.get("github", ""),
"btc": data.get("btc", ""),
"location": data.get("location", ""),
"bio": data.get("bio", ""),
"avatar": data.get("avatar_large", data.get("avatar_normal", "")),
"created": data.get("created", 0),
}
def search(self, query: str, limit: int = 10) -> list:
"""搜索帖子。
注意V2EX 公开 API 暂不支持全文搜索端点(/api/search.json 不可用)。
本方法通过 Jina Reader 代理 V2EX 站内搜索页面获取结果(纯文本,无结构化数据)。
如需精确搜索,建议直接访问 https://www.v2ex.com/?q=<query> 或
使用 Exa channel 的 site:v2ex.com 搜索。
Returns:
list of dicts with keys: title, url, snippet
如果搜索不可用,返回包含单条 {"error": str} 的列表。
"""
return [
{
"error": (
"V2EX 公开 API 不提供搜索端点。"
f"建议改用https://www.v2ex.com/?q={query} "
"或通过 Exa channel 使用 site:v2ex.com 搜索。"
)
}
]

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
"""Web — any URL via Jina Reader. Always available."""
import urllib.request
from .base import Channel
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
class WebChannel(Channel):
name = "web"
description = "任意网页"
backends = ["Jina Reader"]
tier = 0
def can_handle(self, url: str) -> bool:
return True # Fallback — handles any URL
def check(self, config=None):
# 恒可用兜底渠道无本地命令、不做网络探测doctor 已有多个渠道触网),保持零开销
self.active_backend = self.backends[0]
return "ok", "通过 Jina Reader 读取任意网页curl https://r.jina.ai/URL"
def read(self, url: str) -> str:
"""通过 Jina Reader 读取网页,返回 Markdown 全文。"""
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=30) as resp:
return resp.read().decode("utf-8")

View File

@@ -0,0 +1,258 @@
# -*- coding: utf-8 -*-
"""XiaoHongShu — multi-backend: OpenCLI / xiaohongshu-mcp / xhs-cli.
Backend order encodes the recommendation, and probing order makes the
environment split automatic: OpenCLI needs a desktop Chrome so it simply
never probes alive on a server, where xiaohongshu-mcp (self-contained
headless browser) takes over. xhs-cli (upstream unmaintained since
2026-03) keeps working for existing installs as the last candidate.
"""
import urllib.error
import urllib.request
from agent_reach.probe import probe_command
from .base import Channel
_MCP_ENDPOINT = "http://localhost:18060/mcp"
_MCP_INSTALL_URL = "https://github.com/xpzouying/xiaohongshu-mcp"
def _mcp_service_reachable(timeout: int = 3) -> bool:
"""True if the xiaohongshu-mcp HTTP service answers on localhost.
Any HTTP response counts (the MCP endpoint replies 405 to GET) —
we only care that the service is up. Proxies are bypassed explicitly:
localhost must never be routed through HTTP_PROXY.
"""
req = urllib.request.Request(_MCP_ENDPOINT, method="GET")
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
opener.open(req, timeout=timeout)
return True
except urllib.error.HTTPError:
return True # 405/404 etc. — service is alive
except Exception:
return False
def format_xhs_result(data):
"""Clean XHS API response, keeping only useful fields.
Handles both single note objects and lists of notes (search results).
Drastically reduces token usage by stripping structural redundancy (#134).
"""
if isinstance(data, list):
return [_clean_note(item) for item in data]
if isinstance(data, dict):
# Handle search_feeds wrapper: {"items": [...]} or {"data": {"items": [...]}}
items = None
if "items" in data:
items = data["items"]
elif "data" in data and isinstance(data.get("data"), dict):
items = data["data"].get("items") or data["data"].get("notes")
if items and isinstance(items, list):
return [_clean_note(item) for item in items]
# Single note
return _clean_note(data)
return data
def _clean_note(note):
"""Extract useful fields from a single XHS note/feed item."""
if not isinstance(note, dict):
return note
# Some responses nest the note under "note_card" or "note"
inner = note.get("note_card") or note.get("note") or note
result = {}
# Basic info
for key in ("id", "note_id", "xsec_token", "title", "desc", "type", "time"):
if key in inner:
result[key] = inner[key]
# Content (may be in desc or content)
if "content" in inner and "desc" not in result:
result["content"] = inner["content"]
# Author
user = inner.get("user") or inner.get("author")
if isinstance(user, dict):
result["user"] = {
k: user[k] for k in ("nickname", "user_id", "nick_name") if k in user
}
# Engagement metrics
interact = inner.get("interact_info") or inner.get("note_interact_info") or {}
if isinstance(interact, dict):
for key in ("liked_count", "collected_count", "comment_count", "share_count"):
if key in interact:
result[key] = interact[key]
# Also check top-level (some API formats)
for key in ("liked_count", "collected_count", "comment_count", "share_count"):
if key in inner and key not in result:
result[key] = inner[key]
# Images — just URLs
images = inner.get("image_list") or inner.get("images_list") or []
if isinstance(images, list):
urls = []
for img in images:
if isinstance(img, dict):
url = img.get("url") or img.get("url_default") or img.get("original")
if url:
urls.append(url)
elif isinstance(img, str):
urls.append(img)
if urls:
result["images"] = urls
# Tags
tags = inner.get("tag_list") or inner.get("tags") or []
if isinstance(tags, list):
tag_names = []
for t in tags:
if isinstance(t, dict) and "name" in t:
tag_names.append(t["name"])
elif isinstance(t, str):
tag_names.append(t)
if tag_names:
result["tags"] = tag_names
# Comments (if present, e.g. from get_feed_detail with comments)
comments = inner.get("comments") or []
if isinstance(comments, list) and comments:
result["comments"] = [_clean_comment(c) for c in comments]
return result
def _clean_comment(comment):
"""Extract useful fields from a comment."""
if not isinstance(comment, dict):
return comment
result = {}
if "content" in comment:
result["content"] = comment["content"]
user = comment.get("user_info") or comment.get("user")
if isinstance(user, dict):
result["user"] = user.get("nickname") or user.get("nick_name", "")
for key in ("like_count", "sub_comment_count"):
if key in comment:
result[key] = comment[key]
return result
class XiaoHongShuChannel(Channel):
name = "xiaohongshu"
description = "小红书笔记"
backends = ["OpenCLI", "xiaohongshu-mcp", "xhs-cli (xiaohongshu-cli)"]
tier = 1
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
d = urlparse(url).netloc.lower()
return "xiaohongshu.com" in d or "xhslink.com" in d
def check(self, config=None):
"""Probe candidates in order; first fully-usable backend wins.
If none is fully usable, the first fixable candidate (warn) is
reported, so the user gets one actionable prescription instead
of three half-relevant ones.
"""
self.active_backend = None
findings = [] # (backend, status, message)
for backend in self.ordered_backends(config):
if backend == "OpenCLI":
result = self._check_opencli()
elif backend == "xiaohongshu-mcp":
result = self._check_mcp()
else:
result = self._check_xhs_cli()
if result is None:
continue # not installed — not a candidate right now
findings.append((backend, *result))
for wanted in ("ok", "warn"):
for backend, status, message in findings:
if status == wanted:
self.active_backend = backend
return status, message
if findings: # only broken candidates left
return "error", "\n".join(m for _, _, m in findings)
return "off", (
"未安装任何小红书后端。推荐:\n"
" 桌面agent-reach install --channels opencli\n"
" (复用 Chrome 登录态,刷过小红书即零配置可用)\n"
f" 服务器xiaohongshu-mcp自带无头浏览器+扫码登录):{_MCP_INSTALL_URL}"
)
def _check_opencli(self):
"""OpenCLI candidate. None = not installed."""
from agent_reach.backends import opencli_status
st = opencli_status()
if not st.installed:
return None
if st.broken:
return "error", st.hint
if st.ready:
return "ok", (
"OpenCLI 可用(复用浏览器登录态)。用法:"
"opencli xiaohongshu search/note/comments/feed -f yaml"
)
return "warn", st.hint
def _check_mcp(self):
"""xiaohongshu-mcp candidate. None = service not running."""
if not _mcp_service_reachable():
return None
mcporter = probe_command(
"mcporter", ["config", "list"], timeout=10, package="mcporter"
)
if mcporter.ok and "xiaohongshu" in mcporter.output:
return "ok", (
"xiaohongshu-mcp 服务运行中"
"mcporter call 'xiaohongshu.search_feeds(keyword: \"...\")')。"
"若未登录,让 agent 调 get_login_qrcode 扫码"
)
return "warn", (
"xiaohongshu-mcp 服务在跑但 mcporter 未接入。运行:\n"
f" mcporter config add xiaohongshu {_MCP_ENDPOINT}"
)
def _check_xhs_cli(self):
"""Legacy xhs-cli candidate. None = not installed."""
probe = probe_command(
"xhs", ["status"], timeout=10, package="xiaohongshu-cli"
)
if probe.status == "missing":
return None
if probe.status == "broken":
return "error", "xhs 命令存在但无法执行\n" + probe.hint
if probe.status == "timeout":
return "warn", "xhs-cli 已安装但状态检测超时\n" + probe.hint
# 进程是活的(执行成功或运行后非零退出)——按输出内容分类
if probe.ok and "ok: true" in probe.output:
return "ok", (
"xhs-cli 可用(搜索、阅读、评论、热门;上游 2026-03 起停更,"
"桌面用户建议迁移到 OpenCLI"
)
if "not_authenticated" in probe.output or "expired" in probe.output:
return "warn", (
"xhs-cli 已安装但未登录。运行:\n"
" xhs login\n"
"(自动从浏览器提取 Cookie或扫码登录"
)
return "warn", (
"xhs-cli 已安装但状态异常。运行:\n"
" xhs -v status 查看详细信息"
)

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""Xiaoyuzhou Podcast (小宇宙播客) — transcribe podcasts via Groq Whisper API."""
import os
from agent_reach.config import Config
from agent_reach.probe import probe_command
from .base import Channel
class XiaoyuzhouChannel(Channel):
name = "xiaoyuzhou"
description = "小宇宙播客转文字"
backends = ["groq-whisper", "ffmpeg"]
tier = 1
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
d = urlparse(url).netloc.lower()
return "xiaoyuzhoufm.com" in d
def check(self, config=None):
self.active_backend = None
# Check ffmpeg — really execute it: a stale pip-installed ffmpeg shim
# passes shutil.which() but cannot run
probe = probe_command("ffmpeg", ["-version"], timeout=10, package="ffmpeg")
if probe.status == "missing":
return "off", (
"需要 ffmpeg音频转码和切片。安装\n"
" Ubuntu/Debian: apt install -y ffmpeg\n"
" macOS: brew install ffmpeg"
)
if not probe.ok:
return "error", (
"ffmpeg 无法执行重装brew install ffmpegmacOS/ apt install ffmpegLinux"
)
# Check script exists
script = os.path.expanduser("~/.agent-reach/tools/xiaoyuzhou/transcribe.sh")
if not os.path.isfile(script):
return "off", (
"转录脚本未安装。运行:\n"
" agent-reach install --env=auto\n"
" 或手动复制 transcribe.sh 到 ~/.agent-reach/tools/xiaoyuzhou/"
)
# Check GROQ_API_KEY — prefer env var, fall back to Agent Reach config
has_key = bool(os.environ.get("GROQ_API_KEY"))
if not has_key:
try:
cfg = config if config is not None else Config()
has_key = bool(cfg.get("groq_api_key"))
except Exception:
has_key = False
if not has_key:
return "warn", (
"需要配置 Groq API Key免费。步骤\n"
" 1. 注册 https://console.groq.com\n"
" 2. 运行: agent-reach configure groq-key gsk_xxxxx"
)
self.active_backend = "groq-whisper"
return "ok", "完整可用(播客下载 + Whisper 转录)"

View File

@@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
"""Xueqiu (雪球) — stock quotes, search, trending posts & hot stocks."""
import http.cookiejar
import json
import re
import urllib.parse
import urllib.request
from typing import Any
from .base import Channel
_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
_REFERER = "https://xueqiu.com/"
_TIMEOUT = 10
_XUEQIU_HOME = "https://xueqiu.com"
# --------------- cookie-aware HTTP helpers --------------- #
_cookie_jar = http.cookiejar.CookieJar()
_opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(_cookie_jar),
)
_cookies_initialized = False
def _inject_cookie_string(cookie_str: str) -> None:
"""Parse a 'name=value; name2=value2' string and inject into the cookie jar."""
for pair in cookie_str.split(";"):
pair = pair.strip()
if "=" not in pair:
continue
name, _, value = pair.partition("=")
cookie = http.cookiejar.Cookie(
version=0,
name=name.strip(),
value=value.strip(),
port=None,
port_specified=False,
domain=".xueqiu.com",
domain_specified=True,
domain_initial_dot=True,
path="/",
path_specified=True,
secure=True,
expires=None,
discard=True,
comment=None,
comment_url=None,
rest={},
)
_cookie_jar.set_cookie(cookie)
def _load_cookies_from_config() -> bool:
"""Try to load Xueqiu cookies from agent-reach config file (xueqiu_cookie key)."""
try:
from ..config import Config
cfg = Config()
cookie_str = cfg.get("xueqiu_cookie")
if not cookie_str:
return False
_inject_cookie_string(cookie_str)
return True
except Exception:
return False
def _load_cookies_from_browser() -> bool:
"""Try to silently load Xueqiu cookies from the local Chrome browser.
Only succeeds when browser_cookie3 is installed AND the user is logged in
(xq_a_token present). Failures are silently ignored so that agents without
a local browser keep working.
"""
try:
try:
import rookiepy
cookies = rookiepy.chrome([".xueqiu.com"])
if not any(c.get("name") == "xq_a_token" for c in cookies):
return False
for c in cookies:
_cookie_jar.set(c["name"], c["value"], domain=c.get("domain", ".xueqiu.com"))
return True
except ImportError:
import browser_cookie3
cookies = list(browser_cookie3.chrome(domain_name=".xueqiu.com"))
if not any(c.name == "xq_a_token" for c in cookies):
return False
for c in cookies:
_cookie_jar.set_cookie(c)
return True
except Exception:
return False
def _ensure_cookies() -> None:
"""Populate session cookies using the best available source.
Priority order:
1. Saved cookie string in ~/.agent-reach/config.yaml (set by configure --from-browser)
2. Live Chrome browser cookies via rookiepy/browser_cookie3 (if installed + logged in)
3. Homepage visit fallback (only yields anti-DDoS acw_tc,
not enough for stock APIs)
"""
global _cookies_initialized
if _cookies_initialized:
return
if _load_cookies_from_config():
_cookies_initialized = True
return
if _load_cookies_from_browser():
_cookies_initialized = True
return
# Fallback: visit homepage to pick up acw_tc anti-DDoS cookie.
# This is not sufficient for authenticated APIs but avoids hard failures
# on public endpoints that only need the session cookie.
req = urllib.request.Request(_XUEQIU_HOME, headers={"User-Agent": _UA})
_opener.open(req, timeout=_TIMEOUT)
_cookies_initialized = True
def _get_json(url: str) -> Any:
"""Fetch *url* with Xueqiu session cookies and return parsed JSON."""
_ensure_cookies()
req = urllib.request.Request(
url, headers={"User-Agent": _UA, "Referer": _REFERER}
)
with _opener.open(req, timeout=_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
def _strip_html(text: str) -> str:
"""Remove HTML tags and decode common entities."""
text = re.sub(r"<[^>]+>", "", text)
for entity, char in (("&nbsp;", " "), ("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">")):
text = text.replace(entity, char)
return text.strip()
class XueqiuChannel(Channel):
name = "xueqiu"
description = "雪球股票行情与社区动态"
backends = ["Xueqiu API (需要登录 Cookie)"]
tier = 1
# ------------------------------------------------------------------ #
# URL routing
# ------------------------------------------------------------------ #
def can_handle(self, url: str) -> bool:
d = urllib.parse.urlparse(url).netloc.lower()
return "xueqiu.com" in d
# ------------------------------------------------------------------ #
# Health check
# ------------------------------------------------------------------ #
def check(self, config=None):
self.active_backend = None
try:
data = _get_json(
"https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH000001"
)
items = (data.get("data") or {}).get("items") or []
if items:
self.active_backend = self.backends[0]
return "ok", "公开 API 可用(行情、搜索、热帖、热股)"
return "warn", "API 响应异常(返回数据为空)"
except Exception as e:
return "warn", (
f"Xueqiu API 连接失败:{e}"
"请先登录雪球后运行agent-reach configure --from-browser chrome"
)
# ------------------------------------------------------------------ #
# Data-fetching methods
# ------------------------------------------------------------------ #
def get_stock_quote(self, symbol: str) -> dict:
"""获取实时股票行情。
Args:
symbol: 股票代码,如 SH600519、SZ000858、AAPL、00700
Returns a dict with keys:
symbol, name, current, percent, chg, high, low, open, last_close,
volume, amount, market_capital, turnover_rate, pe_ttm, timestamp
"""
data = _get_json(
f"https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol={symbol}"
)
items = (data.get("data") or {}).get("items") or []
q = (items[0].get("quote") or {}) if items else {}
return {
"symbol": q.get("symbol", symbol),
"name": q.get("name", ""),
"current": q.get("current"),
"percent": q.get("percent"),
"chg": q.get("chg"),
"high": q.get("high"),
"low": q.get("low"),
"open": q.get("open"),
"last_close": q.get("last_close"),
"volume": q.get("volume"),
"amount": q.get("amount"),
"market_capital": q.get("market_capital"),
"turnover_rate": q.get("turnover_rate"),
"pe_ttm": q.get("pe_ttm"),
"timestamp": q.get("timestamp"),
}
def search_stock(self, query: str, limit: int = 10) -> list:
"""搜索股票。
Args:
query: 股票代码或中文名称,如 "茅台""600519"
limit: 最多返回条数
Returns a list of dicts with keys:
symbol, name, exchange
"""
data = _get_json(
f"https://xueqiu.com/stock/search.json"
f"?code={urllib.parse.quote(query)}&size={limit}"
)
stocks = data.get("stocks") or []
results = []
for s in stocks[:limit]:
results.append(
{
"symbol": s.get("code", ""),
"name": s.get("name", ""),
"exchange": s.get("exchange", ""),
}
)
return results
def get_hot_posts(self, limit: int = 20) -> list:
"""获取雪球热门帖子。
Uses the v4 public timeline endpoint which returns posts in a `list`
array. Each item carries a JSON-encoded `data` field containing the
actual post payload (title, description, user, like_count, target).
Args:
limit: 最多返回条数(上限 50
Returns a list of dicts with keys:
id, title, text, author, likes, url
"""
data = _get_json(
"https://xueqiu.com/v4/statuses/public_timeline_by_category.json"
"?since_id=-1&max_id=-1&count=20&category=-1"
)
items = data.get("list") or []
results = []
for item in items[:limit]:
# Each item.data is a JSON string containing the real post payload
try:
post = (
json.loads(item["data"])
if isinstance(item.get("data"), str)
else {}
)
except (json.JSONDecodeError, KeyError):
post = {}
user = post.get("user") or {}
text = _strip_html(
post.get("text") or post.get("description") or ""
)
target = post.get("target", "")
results.append(
{
"id": post.get("id", 0),
"title": post.get("title") or "",
"text": text[:200],
"author": user.get("screen_name", ""),
"likes": post.get("like_count", 0),
"url": f"https://xueqiu.com{target}" if target else "",
}
)
return results
def get_hot_stocks(self, limit: int = 10, stock_type: int = 10) -> list:
"""获取热门股票排行。
Args:
limit: 最多返回条数(上限 50
stock_type: 10=人气榜默认12=关注榜
Returns a list of dicts with keys:
symbol, name, current, percent, rank
"""
data = _get_json(
f"https://stock.xueqiu.com/v5/stock/hot_stock/list.json"
f"?size={limit}&type={stock_type}"
)
items = (data.get("data") or {}).get("items") or []
results = []
for idx, item in enumerate(items[:limit], 1):
results.append(
{
"symbol": item.get("code") or item.get("symbol", ""),
"name": item.get("name", ""),
"current": item.get("current"),
"percent": item.get("percent"),
"rank": idx,
}
)
return results

View File

@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""YouTube — check if yt-dlp is available with JS runtime."""
import shutil
from agent_reach.probe import probe_command
from agent_reach.utils.paths import get_ytdlp_config_path, render_ytdlp_fix_command
from agent_reach.utils.text import read_utf8_text
from .base import Channel
def _has_js_runtime_config(config_path) -> bool:
"""Return whether yt-dlp config explicitly enables a JS runtime."""
try:
if not config_path.exists():
return False
return "--js-runtimes" in read_utf8_text(config_path)
except OSError:
return False
class YouTubeChannel(Channel):
name = "youtube"
description = "YouTube 视频和字幕"
backends = ["yt-dlp"]
tier = 0
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
d = urlparse(url).netloc.lower()
return "youtube.com" in d or "youtu.be" in d
def check(self, config=None):
# 真跑 yt-dlp --version 探活,区分未装 / venv 断链 / 跑不动
probe = probe_command("yt-dlp", ["--version"], timeout=10, package="yt-dlp")
if probe.status == "missing":
self.active_backend = None
return "off", "yt-dlp 未安装。安装pip install yt-dlp"
if probe.status == "broken":
self.active_backend = None
return "error", f"yt-dlp 已安装但无法执行\n{probe.hint}"
if not probe.ok: # timeout / error装了但跑不动
self.active_backend = None
detail = probe.hint or probe.output or probe.status
return "error", f"yt-dlp 无法正常运行:{detail}"
# yt-dlp 本体是活的;后面的 JS runtime/转写检查只影响 ok/warn不影响后端归属
self.active_backend = "yt-dlp"
# Check JS runtime
has_js = shutil.which("deno") or shutil.which("node")
if not has_js:
return "warn", (
"yt-dlp 已安装但缺少 JS runtimeYouTube 必须)。\n"
" 安装 Node.js 或 deno然后运行agent-reach install"
)
# Check yt-dlp config for --js-runtimes
# Deno works out of the box; Node.js requires explicit config
has_deno = shutil.which("deno")
if not has_deno:
ytdlp_config = get_ytdlp_config_path()
if not _has_js_runtime_config(ytdlp_config):
return "warn", (
f"yt-dlp 已安装但未配置 JS runtime。运行\n {render_ytdlp_fix_command()}"
)
# Surface transcription readiness so `doctor` reports it.
msg = "可提取视频信息和字幕"
if config is not None:
providers = []
if config.is_configured("groq_whisper"):
providers.append("groq")
if config.is_configured("openai_whisper"):
providers.append("openai")
if providers:
if not shutil.which("ffmpeg"):
msg += "(音频转写需安装 ffmpeg"
else:
msg += f",可转写音频({''.join(providers)}"
return "ok", msg
def transcribe(self, url: str, *, provider: str = "auto", config=None) -> str:
"""Download a YouTube video's audio and return its transcript.
Delegates to :func:`agent_reach.transcribe.transcribe`. Imported lazily
so the channel module stays cheap to import for users who never
transcribe.
"""
from agent_reach.transcribe import transcribe as _transcribe
return _transcribe(url, provider=provider, config=config)