diff --git a/agent_reach/channels/bilibili.py b/agent_reach/channels/bilibili.py index 18e08c4..d7aed8f 100644 --- a/agent_reach/channels/bilibili.py +++ b/agent_reach/channels/bilibili.py @@ -1,8 +1,15 @@ # -*- coding: utf-8 -*- -"""Bilibili — video via yt-dlp, search/browse via bili-cli or API.""" +"""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 os import urllib.request from agent_reach.probe import probe_command @@ -28,7 +35,7 @@ def _search_api_ok() -> bool: class BilibiliChannel(Channel): name = "bilibili" description = "B站视频、字幕和搜索" - backends = ["yt-dlp", "bili-cli (可选)", "B站搜索 API"] + backends = ["bili-cli", "OpenCLI", "B站搜索 API"] tier = 1 def can_handle(self, url: str) -> bool: @@ -37,49 +44,76 @@ class BilibiliChannel(Channel): 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 = [] - # 真跑 yt-dlp --version,区分 未装 / 断链 / 异常(which 命中不等于能用) - yt = probe_command("yt-dlp", ["--version"], timeout=10, package="yt-dlp") - if yt.status == "missing": - return "off", "yt-dlp 未安装。安装:pip install yt-dlp" - if yt.status == "broken": - return "error", "yt-dlp 已安装但无法执行\n" + yt.hint - if not yt.ok: - detail = yt.hint or yt.output or yt.status - return "error", f"yt-dlp 探测失败({yt.status}):{detail}" - - self.active_backend = "yt-dlp" - - proxy = (config.get("bilibili_proxy") if config else None) or os.environ.get("BILIBILI_PROXY") - # 真跑 bili --version——断链时旧的 which 检测会误报"bili-cli 可用" - bili = probe_command("bili", ["--version"], timeout=10, package="bilibili-cli") - - parts = [] - - # 视频读取状态 - if proxy: - parts.append("视频读取:yt-dlp(代理已配置)") - else: - parts.append("视频读取:yt-dlp") - - # bili-cli 增强 - if bili.ok: - parts.append("搜索/热门/排行:bili-cli 可用") - status = "ok" - else: - if bili.status == "broken": - parts.append("bili-cli 已安装但无法执行,不计为可用\n" + bili.hint) - elif bili.status in ("timeout", "error"): - parts.append(f"bili-cli 探测失败({bili.status}),不计为可用") - # 降级走搜索 API;只探测一次,message 和 status 共用结果 - api_ok = _search_api_ok() - if api_ok: - parts.append("搜索:B站 API 可用") + for backend in self.ordered_backends(config): + if backend == "bili-cli": + result = self._check_bili_cli() + elif backend == "OpenCLI": + result = self._check_opencli() else: - parts.append("搜索:B站 API 不可达") - if bili.status == "missing": - parts.append("提示:安装 bili-cli 可解锁热门/排行/动态:pipx install bilibili-cli") - status = "ok" if api_ok else "warn" + result = self._check_search_api() + if result is None: + continue + findings.append((backend, *result)) - return status, "。".join(parts) + # 有后端断链时,即使别的候选兜底成功也要把处方带出来 + 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-cli:pipx install bilibili-cli" + ) diff --git a/agent_reach/channels/twitter.py b/agent_reach/channels/twitter.py index 62cd588..c1aba6d 100644 --- a/agent_reach/channels/twitter.py +++ b/agent_reach/channels/twitter.py @@ -8,7 +8,7 @@ from agent_reach.probe import probe_command class TwitterChannel(Channel): name = "twitter" description = "Twitter/X 推文" - backends = ["twitter-cli", "bird CLI (legacy)"] + backends = ["twitter-cli", "OpenCLI", "bird CLI (legacy)"] tier = 1 def can_handle(self, url: str) -> bool: @@ -24,6 +24,8 @@ class TwitterChannel(Channel): 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: @@ -84,6 +86,22 @@ class TwitterChannel(Channel): " 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/birdx(legacy 回退)。返回 None 表示均未安装,否则返回 (status, message)。""" last_failure = None diff --git a/agent_reach/skill/references/social.md b/agent_reach/skill/references/social.md index 351b505..20ce5c4 100644 --- a/agent_reach/skill/references/social.md +++ b/agent_reach/skill/references/social.md @@ -89,12 +89,18 @@ twitter user @username ```bash # 搜索推文(Twitter 频繁改 GraphQL 端点,可能 404) twitter search "query" -n 10 -# 如果 search 返回 404,升级 twitter-cli:pipx upgrade twitter-cli # likes(2024 年后只能看自己的,平台限制) twitter likes ``` +### search 失败时的重试链(按序执行,成功即停) + +1. 直接重试一次(偶发失败常见):`twitter search "query" -n 10` +2. 升级后再试:`pipx upgrade twitter-cli && twitter search "query" -n 10` +3. 换 OpenCLI 备选(桌面,复用浏览器登录态):`opencli twitter search "query" -f yaml` +4. 都不行就改用 `twitter feed` / `twitter user-posts @somebody` 等稳定命令绕路 + ### 重要注意事项 > **安装**: `pipx install twitter-cli`(确保 v0.8.5+) @@ -103,21 +109,25 @@ twitter likes > > **IP 风控**: 不要在 VPS/数据中心 IP 上频繁调用,尤其是 followers/following,有封号风险。使用住宅代理或本地环境。 > -> **search 可能失效**: Twitter 频繁修改 GraphQL API,search 命令可能随时返回 404。如遇到,先 `pipx upgrade twitter-cli`。如果最新版仍不行,说明上游还没跟上 Twitter 的改动,用 `twitter feed` 替代。 +> **OpenCLI 备选**: 桌面装了 OpenCLI 的话,`opencli twitter search/article/user-posts -f yaml` 全套可用(浏览器登录态,无需 cookie 环境变量)。 > > **输出格式**: 建议用 `--yaml` 或 `--json` 获得结构化输出,对 AI agent 更友好。 ## B站 / Bilibili -```bash -# 获取视频元数据 -yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx" +> ⚠️ **不要用 yt-dlp 读 B站**(风控已全面 412 拦截,实测无解)。用 bili-cli / OpenCLI。 -# 下载字幕 -yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --convert-subs vtt --skip-download -o "/tmp/%(id)s" "URL" +```bash +# 搜索 / 热门 / 视频详情(bili-cli,只读无需登录) +bili search "query" --type video -n 5 +bili hot -n 10 +bili video BVxxx + +# 字幕(OpenCLI,需桌面 Chrome) +opencli bilibili subtitle BVxxx ``` -> **注意**: 服务器 IP 可能遇到 412 错误。使用 `--cookies-from-browser chrome` 或配置代理。 +> 详细命令(音频转写、API 直连兜底)见 [references/video.md](video.md)。 ## V2EX (公开 API) diff --git a/agent_reach/skill/references/video.md b/agent_reach/skill/references/video.md index c1408bf..5fdbc3d 100644 --- a/agent_reach/skill/references/video.md +++ b/agent_reach/skill/references/video.md @@ -50,35 +50,48 @@ agent-reach transcribe ./local_audio.mp3 -o /tmp/transcript.txt > 需要先配置 key:`agent-reach configure groq-key gsk_xxx`(免费,console.groq.com) > 或 `agent-reach configure openai-key sk-xxx`。默认 auto 模式:groq 失败自动降级 openai。 -## B站 / Bilibili (yt-dlp + bili-cli) +## B站 / Bilibili(bili-cli 为主,OpenCLI 补字幕) -### 视频元数据 (yt-dlp) +> ⚠️ **不要用 yt-dlp 读 B站**:B站风控已全面 412 拦截 yt-dlp(实测最新版、直连/代理/带 Cookie 全部无效)。yt-dlp 只用于 YouTube。 + +### 视频详情/搜索/热门/排行 (bili-cli,只读无需登录) ```bash -yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx" -``` +# 视频详情(标题/UP主/时长/播放互动数据/字幕可用性) +bili video BVxxx -### 字幕 (yt-dlp) - -```bash -yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --convert-subs vtt --skip-download -o "/tmp/%(id)s" "URL" -``` - -### 搜索/热门/排行 (bili-cli) - -```bash # 搜索视频 bili search "query" --type video -n 5 -# 热门视频 +# 热门视频 / 排行榜 bili hot -n 10 - -# 排行榜 bili rank -n 10 + +# 下载音频并切分为 ASR-ready WAV(无字幕时配合 agent-reach transcribe 转写) +bili audio BVxxx ``` -> **412 风控**: 海外 IP 必须提供 Cookie(`--cookies-from-browser chrome` 或 `--cookies /path/to/cookies.txt`),国内 IP 一般不受影响。 -> **安装 bili-cli**: `pipx install bilibili-cli`,然后 `bili login` 扫码登录。 +### 字幕 (OpenCLI,需要桌面 Chrome) + +```bash +# 字幕逐句带时间轴 +opencli bilibili subtitle BVxxx + +# OpenCLI 也能搜索/读视频元数据(备选) +opencli bilibili search "query" -f yaml +opencli bilibili video BVxxx -f yaml +``` + +### 零配置兜底:搜索 API 直连 + +```bash +UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" +curl -s -c /tmp/bili_ck.txt -o /dev/null -A "$UA" "https://www.bilibili.com/" +curl -s -b /tmp/bili_ck.txt -A "$UA" -e "https://www.bilibili.com/" \ + "https://api.bilibili.com/x/web-interface/search/all/v2?keyword=QUERY&page=1" +``` + +> **安装 bili-cli**: `pipx install bilibili-cli`(上游 2026-03 起停更但实测健康;只读场景无需登录,`bili login` 扫码可解锁动态/收藏等个人功能)。 ## 小宇宙播客 / Xiaoyuzhou Podcast @@ -111,6 +124,7 @@ agent-reach doctor | 场景 | 推荐工具 | |-----|---------| | YouTube 字幕 | yt-dlp | -| B站字幕 | yt-dlp | +| B站视频详情/搜索 | bili-cli | +| B站字幕 | opencli bilibili subtitle | | 播客转录 | 小宇宙 transcribe.sh | -| 无字幕音视频 | agent-reach transcribe | +| 无字幕音视频 | agent-reach transcribe(B站音频先 `bili audio`) | diff --git a/tests/test_channels.py b/tests/test_channels.py index 8a7cc44..20dda94 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -946,9 +946,60 @@ class TestXiaoHongShuChannel: class TestBilibiliChannel: - def test_reports_error_with_reinstall_hint_when_ytdlp_broken(self, monkeypatch): - """yt-dlp which 命中但 exec 失败(venv 断链)→ error + 重装处方。""" - monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/yt-dlp") + """多后端:bili-cli > OpenCLI > 搜索 API。yt-dlp 已退出 B站(412 实锤)。""" + + @staticmethod + def _isolate(monkeypatch, opencli=None, api_ok=False): + import agent_reach.channels.bilibili as bilibili_mod + monkeypatch.setattr( + bilibili_mod.BilibiliChannel, "_check_opencli", lambda self: opencli + ) + monkeypatch.setattr(bilibili_mod, "_search_api_ok", lambda: api_ok) + + def test_bili_cli_ok_is_active_backend(self, monkeypatch): + self._isolate(monkeypatch) + monkeypatch.setattr( + shutil, "which", + lambda cmd: "/usr/local/bin/bili" if cmd == "bili" else None, + ) + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "bili, version 0.6.2", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.bilibili import BilibiliChannel + ch = BilibiliChannel() + status, msg = ch.check() + assert status == "ok" + assert "bili-cli 可用" in msg + assert ch.active_backend == "bili-cli" + + def test_bili_broken_falls_back_to_api_with_hint_kept(self, monkeypatch): + """bili 断链 → API 兜底获胜,但重装处方必须保留在消息里。""" + self._isolate(monkeypatch, api_ok=True) + monkeypatch.setattr( + shutil, "which", + lambda cmd: "/usr/local/bin/bili" if cmd == "bili" else None, + ) + + def fake_run(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.bilibili import BilibiliChannel + ch = BilibiliChannel() + status, msg = ch.check() + assert status == "ok" # 搜索 API 兜底 + assert ch.active_backend == "B站搜索 API" + assert "备选后端异常" in msg + assert "pipx reinstall bilibili-cli" in msg + + def test_bili_broken_and_no_fallback_reports_error(self, monkeypatch): + self._isolate(monkeypatch, api_ok=False) + monkeypatch.setattr( + shutil, "which", + lambda cmd: "/usr/local/bin/bili" if cmd == "bili" else None, + ) def fake_run(cmd, **kwargs): raise FileNotFoundError(cmd[0]) @@ -958,48 +1009,36 @@ class TestBilibiliChannel: ch = BilibiliChannel() status, msg = ch.check() assert status == "error" - assert "无法执行" in msg - assert "uv tool install --force yt-dlp" in msg - assert "pipx reinstall yt-dlp" in msg + assert "uv tool install --force bilibili-cli" in msg assert ch.active_backend is None - def test_active_backend_set_when_ytdlp_and_bili_ok(self, monkeypatch): - monkeypatch.setattr( - shutil, "which", - lambda cmd: f"/usr/local/bin/{cmd}" if cmd in ("yt-dlp", "bili") else None, - ) - - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "") - - monkeypatch.setattr(subprocess, "run", fake_run) + def test_opencli_serves_when_bili_missing(self, monkeypatch): + self._isolate(monkeypatch, opencli=("ok", "OpenCLI 可用(字幕)"), api_ok=True) + monkeypatch.setattr(shutil, "which", lambda _: None) from agent_reach.channels.bilibili import BilibiliChannel ch = BilibiliChannel() status, msg = ch.check() assert status == "ok" - assert "bili-cli 可用" in msg - assert ch.active_backend == "yt-dlp" + assert ch.active_backend == "OpenCLI" - def test_bili_broken_does_not_count_as_available(self, monkeypatch): - """bili-cli 断链时不计为可用,降级走搜索 API;yt-dlp 仍是 active_backend。""" - monkeypatch.setattr( - shutil, "which", - lambda cmd: f"/usr/local/bin/{cmd}" if cmd in ("yt-dlp", "bili") else None, - ) - - def fake_run(cmd, **kwargs): - if "yt-dlp" in cmd[0]: - return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "") - raise FileNotFoundError(cmd[0]) - - monkeypatch.setattr(subprocess, "run", fake_run) - import agent_reach.channels.bilibili as bilibili_mod - monkeypatch.setattr(bilibili_mod, "_search_api_ok", lambda: True) - ch = bilibili_mod.BilibiliChannel() + def test_api_only_still_ok_with_install_nudge(self, monkeypatch): + self._isolate(monkeypatch, api_ok=True) + monkeypatch.setattr(shutil, "which", lambda _: None) + from agent_reach.channels.bilibili import BilibiliChannel + ch = BilibiliChannel() status, msg = ch.check() - assert status == "ok" # 搜索 API 兜底 - assert "不计为可用" in msg - assert ch.active_backend == "yt-dlp" + assert status == "ok" + assert ch.active_backend == "B站搜索 API" + assert "bilibili-cli" in msg + + def test_off_when_everything_unreachable(self, monkeypatch): + self._isolate(monkeypatch, api_ok=False) + monkeypatch.setattr(shutil, "which", lambda _: None) + from agent_reach.channels.bilibili import BilibiliChannel + ch = BilibiliChannel() + status, msg = ch.check() + assert status == "off" + assert ch.active_backend is None class TestYouTubeChannel: