49 Commits

Author SHA1 Message Date
Pnant 71b85f8b97 docs(readme): clarify browser action boundary
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled
ci / test (3.13) (push) Has been cancelled
ci / wheel-gate (push) Has been cancelled
2026-06-12 22:54:54 +08:00
Pnant 80fcb33f4d docs: add AtomGit mirror badge and link (G-Star onboarding)
Project is now mirrored on AtomGit (auto pull-sync from GitHub, enabled
with pull-f) for faster cloning in China. Badge + mirror notice added to
both READMEs per the G-Star certification requirements.

Mirror: https://atomgit.com/qq_51337814/Agent-Reach

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:11:10 +08:00
Pnant f65526cbaa docs(readme): full-pass audit — purge pre-v1.5 worldview from both READMEs
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled
ci / test (3.13) (push) Has been cancelled
ci / wheel-gate (push) Has been cancelled
Two factual errors and a batch of stale-world copy found by auditing the
full README against v1.5.0 reality:

- "what install does" claimed twitter-cli/rdt-cli are installed by default
  — they never are (opt-in via --channels); now describes the real flow
  incl. the channel menu step
- zero-config examples listed "read a tweet" — Twitter is tier 1, not
  zero-config; list now shows truly free channels (incl. bili search)
- FAQ rewrites: xiaohongshu (OpenCLI-first, mcp for servers, xhs-cli as
  legacy fallback), Reddit (login mandatory, OpenCLI first, pinned rdt
  source), free-cost answer (tool list updated, proxy rescoped to
  restricted networks)
- platform table stops advertising write ops (发推/Post·Comment·Like) —
  aligned with the skill's fetch-only boundary
- bilibili pain-point line updated to the 412 reality
- credits gain OpenCLI and xiaohongshu-mcp
- README_en: same FAQ/table/doctor-sample fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:23:00 +08:00
Pnant 5e6fc45785 docs(review): round-3 nits — last copy inconsistencies from Codex review
- install --proxy help text: generic network-proxy wording (was
  "Reddit/Bilibili")
- setup wizard: Reddit line no longer claims 无需配置 right before
  demanding rdt login
- README_en: bilibili proxy-era claims replaced with bili-cli reality
  (proxy section rescoped to restricted networks, doctor sample updated)
- install.md: rdt-cli example pinned to the same SHA as _RDT_GIT_SOURCE

Codex final verdict across 3 rounds: SHIP (all earlier fixes verified,
remaining items were these non-blocking nits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:09:38 +08:00
Pnant e570d08772 fix(review): round-2 findings — dead proxy key, unbounded transcribe, doc pins
- configure proxy: the bilibili_proxy key lost its only reader when yt-dlp
  exited the bilibili channel (PR5), leaving docs promising an unlock that
  did nothing. Now saves a generic `proxy` key (legacy key kept in sync),
  and every wording — CLI output, server tip, install.md quick reference —
  says what it really is: a saved address agents read to export
  HTTP(S)_PROXY, not an unlock switch
- transcribe: subprocess calls get timeouts (yt-dlp download 1800s, ffmpeg
  600s) so a stalled network read can't hang the CLI forever
- docs/update.md: rdt-cli install uses the same pinned git SHA as the
  code's _RDT_GIT_SOURCE instead of floating HEAD
- probe.py: documented as side-effect-free-probes-only (retries re-run
  verbatim with no backoff)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:01:47 +08:00
Pnant cc90e10b19 fix(review): four findings from adversarial Codex review round 1
- twitter: align backend selection with the two-phase findings pattern —
  an unauthenticated twitter-cli (warn) no longer blocks a fully-working
  OpenCLI (ok) further down the candidate list
- watch: use _is_newer_version like check-update (the != comparison kept
  the downgrade prompt this branch fixed elsewhere)
- uninstall: third copy of the skill-dir removal also gets the symlink
  guard (full `agent-reach uninstall` path)
- doctor: a stale active_backend from a previous check on the singleton
  channel no longer leaks into an errored result

+4 regression tests (162 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:53:37 +08:00
Pnant fc24fb699b feat(experience): one-liner install/update docs catch up + version-watching skill
The one-liner entry docs were untouched by the backend upgrade — new users
following "帮我安装 Agent Reach" would still get the old world. Now:

- docs/install.md: xiaohongshu section rewritten (desktop OpenCLI with the
  one manual extension click / server xiaohongshu-mcp with QR + 150MB
  first-run warning / legacy xhs-cli kept as fallback); Reddit section says
  plainly there is no zero-config path; optional-channel menu gains OpenCLI;
  upstream table matches the new routing with a doctor --json pointer
- docs/update.md: full rewrite — upgrade-only-what-exists tool refresh
  (incl. npm update for OpenCLI/mcporter, pinned git source for rdt-cli),
  one-time OpenCLI offer for desktops, skill sync now rides doctor's
  auto-sync instead of an inline python blob, WeChat-era content removed
- check-update / watch: "update available" now leads with the one-liner
  (帮我更新 Agent Reach:<update.md>) which updates package + tools + skill;
  bare pip stays as the package-only alternative
- fix: version compare was `!=` — a local build AHEAD of the latest release
  (installed from main during a release window) was told to "update" into a
  downgrade; now semantic tuple compare with fallback (+4 tests)
- skill (zh+en): description gains a NOT-for boundary (fetch-only, no write
  ops, defer to dedicated skills); new standing rule — after substantial
  tasks run check-update and append a one-liner update hint to the wrap-up,
  never interrupting or nagging
- SKILL_en.md restructured to the same three-layer skeleton as the Chinese
  version (124-line body, shared references/) instead of a 263-line
  everything-inline page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:14:57 +08:00
Pnant 380cfd565d feat(identity): capability-layer positioning + skill rewrite, v1.5.0
SKILL.md (per the skill-writing guide):
- description rewritten trigger-first: 调研/全网调研/帮我调研/research lead
  the text (the owner's own phrasing "去全网调研" previously had NO matching
  trigger word — the exact undertrigger the guide warns about); platform
  keywords front-loaded against the 1536-char truncation
- standing rules added: probe `doctor --json` active_backend before acting
  on multi-backend platforms, announce which skill/backend is in use,
  follow the documented retry chains, compose multi-platform research
- quick commands updated to the new reality: bili-cli for bilibili search,
  Reddit/xiaohongshu moved to a "login-backed platforms" section
- SKILL_en.md: same description surgery + the actively harmful stale
  advice removed (anonymous reddit .json curl, yt-dlp for bilibili)

README / README_en:
- tagline gains the capability-layer subline: backends are chosen,
  installed and health-checked for us to swap — the user never notices
- "设计理念" reframed from scaffolding to capability layer (selection /
  install / doctor / routing); channels diagram now shows ordered backend
  lists; selection table split into primary + fallback columns with the
  live-test rationale per row
- platform table: honest rows for bilibili (bili-cli, yt-dlp retired),
  Reddit (no zero-config path), xiaohongshu (OpenCLI / mcp / legacy)

Version 1.5.0 across pyproject / __init__ / CLAUDE.md / test fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:30:56 +08:00
Pnant 2c766af477 feat(bilibili,twitter): yt-dlp exits bilibili; OpenCLI joins both
bilibili:
- live-verified 2026-06: bilibili 412-blocks yt-dlp in every
  configuration (latest version, direct, proxied, warmed cookies) while
  bili-cli works fine without login — so yt-dlp no longer serves this
  channel (it remains the YouTube backend)
- backends = [bili-cli, OpenCLI, B站搜索 API]: bili-cli covers
  search/hot/rank/video-detail/audio, OpenCLI adds subtitles through the
  browser session, the search API is the zero-dependency fallback
- when a broken candidate is bypassed by a working fallback, its
  reinstall prescription is appended to the winning message instead of
  being swallowed
- skill docs (social.md + video.md): "do NOT use yt-dlp for bilibili"
  warning, bili-cli/OpenCLI command groups, curl fallback recipe,
  B站 audio transcription path via `bili audio` + agent-reach transcribe

twitter:
- OpenCLI joins as the middle candidate [twitter-cli, OpenCLI, bird]
- social.md: explicit 4-step search retry chain (retry → upgrade →
  OpenCLI → stable-command detour)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:18:00 +08:00
Pnant d7551bf19d feat(reddit): honest tiering — OpenCLI / rdt-cli, no zero-config path
- live-verified 2026-06: anonymous .json endpoints are 403-blocked (all
  variants) and the official API closed self-service registration in
  2025-11 — so the channel now says plainly: every Reddit backend needs
  a logged-in session, mainland China needs a proxy
- backends = [OpenCLI, rdt-cli]: OpenCLI rides the browser session
  (desktop preferred); rdt-cli stays for servers and existing installs
  (upstream unmaintained since 2026-03, noted in the ok message)
- install: desktop routes to OpenCLI, server installs rdt-cli from the
  pinned git source (split out as _install_rdt_cli)
- skill/references/social.md: Reddit section rewritten as two backend
  command groups + a PRAW note scoped to users who already hold
  pre-2025-11 credentials (explicitly not recommended for new users)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:10:04 +08:00
Pnant 3e5f9df5b8 feat(xiaohongshu): multi-backend — OpenCLI / xiaohongshu-mcp / xhs-cli
- backends becomes the ordered candidate list [OpenCLI, xiaohongshu-mcp,
  xhs-cli]; probing order makes the desktop/server split automatic:
  OpenCLI never probes alive headless, so servers fall through to
  xiaohongshu-mcp; first fully-usable candidate wins, fixable (warn)
  candidates only win when nothing is fully usable
- xiaohongshu-mcp probing: HTTP reachability of localhost:18060
  (proxy-bypassed) + mcporter config presence; guides through
  `mcporter config add` when half-wired
- opencli backend: treat a sleeping extension service worker as ready —
  verified live that `daemon status` reports disconnected while any real
  command wakes it; disambiguate "sleeping" vs "never installed" via the
  Chrome Extensions directory on disk (fixes active_backend flapping
  between OpenCLI and xhs-cli across doctor runs)
- install: desktop installs OpenCLI; server prints the xiaohongshu-mcp
  guide (binary to ~/.agent-reach/tools/, QR login, mcporter add);
  xhs-cli is no longer installed by default (upstream unmaintained since
  2026-03) but existing installs keep working as the last candidate
- skill/references/social.md: xiaohongshu section rewritten as three
  backend command groups keyed off `doctor --json` active_backend,
  including the 120s-timeout and login-first caveats for the mcp path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:05:00 +08:00
Pnant 0e8dd3f412 feat(backends): onboard OpenCLI as cross-channel desktop backend
- new agent_reach/backends/opencli.py: probes install + daemon/extension
  state via `opencli daemon status` (pure query — `opencli doctor`
  auto-starts the daemon, a side effect health checks must avoid)
- `agent-reach install --channels opencli`: npm install + Chrome Web
  Store guide (extension install cannot be automated — Chrome security
  model — so we print the one-click path)
- server env skips OpenCLI (rides a real desktop Chrome session)
- channels will adopt it as a backend candidate in follow-up PRs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:53:51 +08:00
Pnant 762824c590 feat(routing): ordered backend candidates + real-probing doctor
- backends is now an ordered candidate list (first = preferred); channels
  report the backend actually serving via active_backend, surfaced in the
  doctor text report and --json
- new agent_reach/probe.py really executes upstream commands and tells
  apart missing / broken (stale venv shebang after a system Python
  upgrade) / timeout, with a reinstall prescription for broken installs
- all 13 channels migrated off which()-only checks: fixes bilibili
  false-positive "bili-cli 可用" on broken shims, misleading xiaohongshu
  "连接失败", rdt OSError crashing doctor, mcporter breakage masquerading
  as "未配置"
- twitter: 15s probe + 1 retry (flaky 10s timeout), broken twitter-cli
  now falls back to bird instead of aborting the check
- doctor survives per-channel exceptions; config supports per-channel
  backend override (<channel>_backend / <CHANNEL>_BACKEND env)
- fix skill install/uninstall crash on symlinked skill dirs (the
  "[Errno None] None" warning from shutil.rmtree on a symlink)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:48:33 +08:00
Pnant 447dc4acc4 docs: trim friend-links to Tencent Cloud OpenClaw only (#352)
Remove the FluxNode and OpenClaw-for-Enterprise placements from the
friend-links section across README.md / README_en / README_ko; keep the
Tencent Cloud OpenClaw entry.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:07:56 +08:00
@aaronjmars 373704a683 fix(security): create credential files atomically with 0o600 (#350)
Routes the two credential-writing call sites (xfetch session sync, no-Docker XHS cookie fallback) through the same atomic os.open(..., 0o600) pattern Config.save() already uses, closing a TOCTOU window where credential files were briefly world-readable (CWE-732). Also hardens _sync_bird_env() with shlex.quote against a shell-injection breakout in the sourceable env file — verified exploitable on the pre-fix code. Maintainer follow-up: scoped the injection-probe test markers to tmp_path so they can't poison reruns. 107 tests pass.
2026-06-10 16:49:15 +08:00
Pnant 97e9e63f42 chore: bump version to 1.4.2 (#349)
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled
ci / test (3.13) (push) Has been cancelled
ci / wheel-gate (push) Has been cancelled
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:01:57 +08:00
Pnant 853e5190e4 feat(doctor): add legend line and --json output (#348)
The ok/warn/off symbols had no explanation for non-technical users;
--json gives agents and scripts a machine-readable health check.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:00:20 +08:00
Pnant 7015b08063 refactor: remove Douyin, Weibo and WeChat channels (#347)
All three had rotted past honest usability:
- Douyin's upstream (yzfly/douyin-mcp-server) is archived and required a
  4-step manual local-server setup nobody could complete
- Weibo depended on an unmaintained personal fork (mcp-server-weibo)
- WeChat full-article reading was increasingly blocked by anti-bot
  (#339) while doctor still advertised it as zero-config

Removes the channel files, installers, skill routing/trigger entries,
reference sections and README rows (zh+en). Honest counts: 13 platforms,
6 zero-config. They can return when maintained upstreams exist.

Follows the v1.4.0 precedent of removing Discord/Toutiao (#234).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:57:05 +08:00
Pnant b0e010c6cc fix(windows): force UTF-8 in channel subprocesses and MCP server env (#345)
Extracted from #318 (the UTF-8/doctor core, minus the env-wrapper feature):

- new agent_reach/utils/process.py: utf8_subprocess_env() +
  mcporter_utf8_env_args() — Windows GBK consoles otherwise corrupt
  Chinese output from mcporter/MCP child processes
- weibo/douyin/linkedin checks and weibo install/registration now pass
  the UTF-8 env (and register the MCP server with --env PYTHONUTF8=1)
- youtube: extract _has_js_runtime_config() with an OSError guard so an
  unreadable yt-dlp config can't crash doctor
- test_skill_command: open SKILL.md with explicit utf-8 (Windows GBK
  default broke these tests)

Co-authored-by: chidao <2980933590@qq.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:09:42 +08:00
ming 4ca570a39f feat(transcribe): Whisper transcription module with Groq→OpenAI fallback (#277)
Adds agent_reach/transcribe.py (download → compress → chunk → transcribe with provider fallback, fully mocked tests). Maintainer follow-up on the branch: wired an agent-reach transcribe CLI subcommand + skill docs so agents can actually invoke it, added the missing configure openai-key branch the error hint referenced, removed two dead static methods. 103 tests pass; wheel-gate clean.
2026-06-10 15:03:37 +08:00
Andrew Barnes 9045fee67e fix(reddit): install rdt-cli from GitHub source pinned to 0.4.2 (#326)
PyPI still only has rdt-cli 0.4.1 while the doctor hint required >=0.4.2, so the suggested install command always failed. Installs from the upstream GitHub repo pinned to the 0.4.2 commit instead, and syncs all four docs that still taught the PyPI path. Verified locally: clean-venv install from the pinned source yields rdt 0.4.2 and rdt status works. 86 tests pass. Fixes #294.
2026-06-10 14:59:16 +08:00
hymansun 5b07d95e7e feat(xiaoyuzhou): Chinese punctuation prompt + optional --polish flag (#291)
Adds a punctuation-aware Whisper prompt and an optional --polish step (free Llama 3.3 70B on Groq) for Chinese podcast transcripts. Maintainer follow-up on the branch: replaced ASCII quotes that bash swallowed inside the prompt (hexdump-verified), added the same prompt to the 429-retry call, and documented --polish as optional. bash -n passes.
2026-06-10 14:56:38 +08:00
Alfred a827452881 feat(skill): optimize SKILL.md description for better auto-triggering (#324)
Rewrites the skill description with platform aliases (zh+en) and action triggers so agents reliably auto-invoke the skill. Maintainer follow-up on the branch: removed nonexistent skill_view tool reference, dropped finance category (no references/finance.md), updated the EN-locale test assertion to the new description. YAML validated, 85 tests pass. Fixes #316.
2026-06-10 14:54:18 +08:00
Pnant 781496540b fix: correct stale twitter flag, MP3 case match, reddit tier, bird residue (#344)
Four small correctness fixes found while live-testing community PRs:

- SKILL.md taught agents 'twitter search --limit 10' but twitter-cli
  v0.8.5 has no --limit (real flag is -n/--max) — every agent following
  the skill got a usage error
- transcribe_xiaoyuzhou.sh audio regex only matched lowercase .m4a/.mp3;
  current episodes serve uppercase .MP3 (verified on a live episode) —
  add /i flag
- reddit.py declared tier=0 (zero-config) while its own docstring says
  Reddit requires login since 2024 — set tier=1 to match reality (tier
  is doctor display metadata only)
- docs/README_en.md referenced the retired 'bird' CLI name once

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:16:25 +08:00
nyxst4ck bdf692c391 docs(en): document safe mode and OpenClaw exec requirement (#330)
The English README was missing two things present in the Chinese README: the OpenClaw 'exec' permission prerequisite (without it the Agent silently can't run install commands) and the --safe install mode. Both refer to real, supported features (cli.py exposes --safe; install.md documents it). Also align the Chinese README's Python badge alt-text (3.8+ -> 3.10+) with requires-python and the badge image.

Co-authored-by: nyxst4ck <289980115+nyxst4ck@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:12:15 +08:00
Yui(ゆい) 40963eb6b7 docs(install): document Windows python3 Store alias workaround (#325)
Documents the py -3 launcher workaround for the Microsoft Store python3 stub. Fixes #304.
2026-06-10 14:12:11 +08:00
Alfred 6bc92a0ac7 fix(docs): Reddit requires authentication, correct misleading claims (#323)
Matches reddit.py behavior and rdt-cli reality (verified locally: rdt requires login since 2024). Fixes #314.
2026-06-10 14:12:08 +08:00
hymansun 5dfd29eafd fix(xiaoyuzhou): replace grep -oP with perl for macOS BSD grep compat (#290)
BSD grep failure reproduced locally; perl extraction verified against a live episode page (title + 429-wait parsing). Fixes #289.
2026-06-10 14:12:02 +08:00
Yuhao Chen dd1af8fc17 fix(config): update stale comment from bird CLI to twitter-cli (#284)
The project has migrated from bird to twitter-cli, but this inline
comment still referenced the old name. Consistent with the fix in #282.
2026-06-10 14:11:58 +08:00
yagyesh-twofourlabs 7e264cbde6 docs(skill_en): replace stale bird commands with twitter-cli (#287)
All five commands verified against twitter-cli v0.8.5 (-c flag, search -n, tweet, article, user-posts, feed all exist; live-tested). Fixes #282.
2026-06-10 14:11:54 +08:00
Pnant c2911cd4a2 chore: stop tracking .claude/settings.local.json (personal local config) (#342)
It's Claude Code's per-user permission allowlist — auto-modified on every
permission approval, so it kept dirtying the working tree and risked
leaking personal settings into the public repo. Local file is preserved.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:14:25 +08:00
Pnant 84b474c79b chore: bump version to 1.4.1 (#341)
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled
ci / test (3.13) (push) Has been cancelled
ci / wheel-gate (push) Has been cancelled
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:47:32 +08:00
Pnant b6241e8e67 ci: add wheel-build gate (duplicate-entry check + clean-venv smoke install) (#340)
Editable installs in CI never exercise wheel packaging, which let the
force-include duplication ship broken source installs while tests stayed
green (#308 #315 #328 #332 #334). This gate builds the real wheel, fails
on any duplicate archive entry, asserts SKILL.md/guides/scripts ship,
and smoke-installs into a clean venv.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:45:08 +08:00
nyxst4ck 607f0d2c56 fix(build): drop duplicate force-include that breaks source installs (#329)
Removes the [tool.hatch.build.targets.wheel.force-include] block that duplicated files already included via packages=["agent_reach"], which made modern hatchling fail source installs with 'ValueError: A second file is being added to the wheel archive at the same path'.

Verified before merge: wheel builds clean (48 entries, no duplicates), SKILL.md / guides / scripts all ship in site-packages, 85 tests pass on Python 3.11.

Fixes #334, fixes #332, fixes #328, fixes #315, fixes #308.
2026-06-10 11:41:46 +08:00
Pnant 17624268a0 docs: update Reddit auth requirement and fix architecture diagrams across all READMEs
Reddit requires authentication since 2024 (all API requests return 403
without a valid session cookie). Updated all 4 language versions (zh, en,
ja, ko) to reflect this: platform tables, tool choice tables, architecture
diagrams, and FAQ sections.

Also fixed Chinese README architecture diagram: reddit.py backend corrected
from Exa to rdt-cli, wechat.py backend corrected from camoufox+miku to Exa.
Fixed Japanese README reddit.py swap suggestions (removed outdated PRAW/Pushshift).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:45:43 +08:00
Hak Lee 575cd3b479 docs: add Korean README translation (docs/README_ko.md) (#251)
docs: add Korean README translation (closes #250)
2026-04-13 17:17:28 +08:00
Always lucky 0e21545bc5 Add English skill locale support (#223)
feat: add English skill locale support with SKILL_en.md
2026-04-13 17:17:20 +08:00
Sri Sowmya Nemani 28e6f07f8e Create SECURITY.md (#254)
docs: add SECURITY.md with responsible disclosure process
2026-04-13 17:17:16 +08:00
Thomas Lau 0aee897ef4 fix(reddit): detect auth status in doctor check (#261)
fix(reddit): detect auth status in doctor check, show recovery paths
2026-04-13 17:16:19 +08:00
Harshit Anand e36f737bb6 docs: fix Douyin MCP installation and configuration instructions (#256) (#257)
docs: fix Douyin MCP installation instructions
2026-04-13 17:16:11 +08:00
Pgooone a45147c53d fix(wechat): Windows 系统上 doctor 误判微信公众号渠道不可用 (#263)
fix(wechat): Windows GBK encoding causes false negative in doctor check
2026-04-13 17:16:05 +08:00
Pnant def2ce932e docs: update all remaining files for v1.4.0 upstream migrations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:27:32 +08:00
Pnant a1cc229239 docs: update README for v1.4.0 upstream migrations
Update all tool references across both Chinese and English READMEs:
- bird CLI → twitter-cli
- Exa Reddit → rdt-cli
- Docker xiaohongshu-mcp → xhs-cli (pipx)
- miku_ai → Exa (WeChat)
- Add bili-cli to Bilibili
- Update stars counts, install commands, FAQ answers, credits

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:13:38 +08:00
Pnant e178470320 fix(twitter): support both twitter-cli and bird CLI, never uninstall user tools
CRITICAL FIX: update.md was telling agents to uninstall bird CLI,
which broke users who had working bird installations. Now:

- twitter.py: prefers twitter-cli, falls back to bird/birdx if installed
- update.md: removed "clean up deprecated tools" step entirely
- Added explicit rule: "Never uninstall any existing tools the user already has"
- Tests cover twitter-cli primary + bird fallback + preference order

78 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:01:09 +08:00
Pnant 7acf3c1367 docs(skill): add Twitter upstream caveats (search instability, IP risk, cookie issues)
Based on investigation of public-clis/twitter-cli 47 issues:
- search may 404 when Twitter changes GraphQL endpoints
- followers command has account ban risk on datacenter IPs
- Cookie auto-extraction fragile (macOS Keychain, Windows DPAPI)
- Recommend Cookie-Editor export + env vars over auto-extraction
- Ensure v0.8.5+ for Windows pipe fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:13:31 +08:00
Pnant 04fd645966 docs(skill): add upstream caveats for Reddit, B站, YouTube
Based on upstream issue investigation:
- Reddit (rdt-cli): ensure v0.4.2+, note login-required features
- B站: 412 is overseas-IP + no-cookie, add bili-cli commands
- YouTube: --write-comments is best-effort, auto-subs may duplicate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:12:44 +08:00
Pnant d877c09cd1 docs(skill): add XHS upstream caveats (xsec_token, rate limits, unstable commands)
Based on investigation of jackwener/xiaohongshu-cli issues:
- xsec_token: can't read by bare note_id, must search/feed first
- Rate limiting: high-freq requests trigger CAPTCHA
- v0.6.4: user/user-posts/favorites may return API error
- POST operations: may 406 due to signature issues in v0.6.x

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:10:36 +08:00
Pnant 65ff8d69d0 docs: rewrite update.md for v1.4.0 upstream migrations
update.md now handles the full update lifecycle autonomously:
- Installs new CLI tools (twitter-cli, rdt-cli, xhs-cli, bili-cli)
- Removes deprecated bird CLI
- Updates SKILL.md + references/ with fallback
- Reports changes to user

install.md: update tool references (bird→twitter-cli, Exa→rdt-cli).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:36:43 +08:00
Pnant 0f9cfe66ec fix(web): add read() via Jina Reader + skill install fallback (#238)
- WebChannel.read(): reads any URL via Jina Reader (r.jina.ai), returns Markdown
- _install_skill(): add fallback from importlib.resources to Path(__file__) for editable installs
- Explicit UTF-8 encoding on all file read/write operations

Inspired by PR #215, implemented correctly (Jina instead of raw requests).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:31:48 +08:00
66 changed files with 4343 additions and 2928 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"WebFetch(domain:community.groq.com)"
]
}
}
+3
View File
@@ -13,3 +13,6 @@
# Groq Whisper (optional, for video transcription) — https://console.groq.com # Groq Whisper (optional, for video transcription) — https://console.groq.com
# GROQ_API_KEY=gsk_your_key_here # GROQ_API_KEY=gsk_your_key_here
# OpenAI Whisper (optional fallback when Groq is rate-limited) — https://platform.openai.com
# OPENAI_API_KEY=sk-your_key_here
+40
View File
@@ -28,3 +28,43 @@ jobs:
- name: Run tests - name: Run tests
run: | run: |
pytest -q pytest -q
# Editable installs (-e) never exercise wheel packaging, so a broken wheel
# can pass tests and still fail every real `pip install` from source.
# This job builds the actual wheel and installs it into a clean venv.
wheel-gate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build wheel
run: |
python -m pip install --upgrade pip build
python -m build
- name: Verify wheel has no duplicate entries and ships data files
run: |
python - <<'PY'
import glob, zipfile, collections
whl = glob.glob("dist/*.whl")[0]
names = zipfile.ZipFile(whl).namelist()
dupes = [n for n, c in collections.Counter(names).items() if c > 1]
assert not dupes, f"duplicate entries in wheel: {dupes}"
assert "agent_reach/skill/SKILL.md" in names, "SKILL.md missing from wheel"
for prefix in ("agent_reach/guides/", "agent_reach/scripts/", "agent_reach/skill/references/"):
assert any(n.startswith(prefix) for n in names), f"{prefix} missing from wheel"
print(f"wheel OK: {len(names)} entries, no duplicates, data files present")
PY
- name: Smoke-install wheel into clean venv
run: |
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install --quiet dist/*.whl
/tmp/smoke/bin/agent-reach version
cd /tmp && /tmp/smoke/bin/python -c "import agent_reach; from importlib.resources import files; assert (files('agent_reach')/'skill'/'SKILL.md').is_file(); print('SKILL.md ships in site-packages OK')"
+4
View File
@@ -8,3 +8,7 @@ build/
.env .env
.agent-reach/ .agent-reach/
*.log *.log
# Claude Code personal permission settings — local only, never commit
.claude/settings.local.json
uv.lock
+2 -2
View File
@@ -1,9 +1,9 @@
# CLAUDE.md # CLAUDE.md
## Project ## Project
Agent Reach — Python CLI + library that gives AI agents read/search access to 14+ internet platforms. Agent Reach — Python CLI + library that gives AI agents read/search access to 13 internet platforms.
Positioning: installer + doctor + config tool. NOT a wrapper — after install, agents call upstream tools directly. Positioning: installer + doctor + config tool. NOT a wrapper — after install, agents call upstream tools directly.
Repo: github.com/Panniantong/Agent-Reach | License: MIT | Version: 1.3.0 Repo: github.com/Panniantong/Agent-Reach | License: MIT | Version: 1.5.0
## Commands ## Commands
- `pip install -e .` — Dev install - `pip install -e .` — Dev install
+72 -97
View File
@@ -5,13 +5,22 @@
</p> </p>
<p align="center"> <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a> 当下最稳的接入方式,替你选好、装好、体检好——接入方式会换代,你不用操心
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.8+"></a>
<a href="https://github.com/Panniantong/agent-reach/stargazers"><img src="https://img.shields.io/github/stars/Panniantong/agent-reach?style=for-the-badge" alt="GitHub Stars"></a>
</p> </p>
<p align="center"> <p align="center">
<a href="#快速上手">快速开始</a> · <a href="docs/README_en.md">English</a> · <a href="docs/README_ja.md">日本語</a> · <a href="#支持的平台">支持平台</a> · <a href="#设计理念">设计理念</a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
<a href="https://github.com/Panniantong/agent-reach/stargazers"><img src="https://img.shields.io/github/stars/Panniantong/agent-reach?style=for-the-badge" alt="GitHub Stars"></a>
<a href="https://atomgit.com/qq_51337814/Agent-Reach"><img src="https://atomgit.com/qq_51337814/Agent-Reach/star/badge.svg" alt="AtomGit Stars"></a>
</p>
<p align="center">
🇨🇳 国内访问:本项目托管于 <a href="https://atomgit.com/qq_51337814/Agent-Reach">AtomGit 镜像</a>(与 GitHub 自动同步,克隆更快)
</p>
<p align="center">
<a href="#快速上手">快速开始</a> · <a href="docs/README_en.md">English</a> · <a href="docs/README_ja.md">日本語</a> · <a href="docs/README_ko.md">한국어</a> · <a href="#支持的平台">支持平台</a> · <a href="#设计理念">设计理念</a>
</p> </p>
--- ---
@@ -24,7 +33,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
- 🐦 "帮我搜一下推特上大家怎么评价这个产品" → **搜不了**Twitter API 要付费 - 🐦 "帮我搜一下推特上大家怎么评价这个产品" → **搜不了**Twitter API 要付费
- 📖 "去 Reddit 上看看有没有人遇到过同样的 bug" → **403 被封**,服务器 IP 被拒 - 📖 "去 Reddit 上看看有没有人遇到过同样的 bug" → **403 被封**,服务器 IP 被拒
- 📕 "帮我看看小红书上这个品的口碑" → **打不开**,必须登录才能看 - 📕 "帮我看看小红书上这个品的口碑" → **打不开**,必须登录才能看
- 📺 "B站上有个技术视频,帮我总结一下" → **连不上**海外/服务器 IP 被屏蔽 - 📺 "B站上有个技术视频,帮我总结一下" → **拿不到**通用下载工具被 B站风控全面拦截
- 🔍 "帮我在网上搜一下最新的 LLM 框架对比" → **没有好用的搜索**,要么付费要么质量差 - 🔍 "帮我在网上搜一下最新的 LLM 框架对比" → **没有好用的搜索**,要么付费要么质量差
- 🌐 "帮我看看这个网页写了啥" → **抓回来一堆 HTML 标签**,根本没法读 - 🌐 "帮我看看这个网页写了啥" → **抓回来一堆 HTML 标签**,根本没法读
- 📦 "这个 GitHub 仓库是干嘛的?Issue 里说了什么?" → 能用,但认证配置很麻烦 - 📦 "这个 GitHub 仓库是干嘛的?Issue 里说了什么?" → 能用,但认证配置很麻烦
@@ -56,7 +65,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|---|---| |---|---|
| 💰 **完全免费** | 所有工具开源、所有 API 免费。唯一可能花钱的是服务器代理($1/月),本地电脑不需要 | | 💰 **完全免费** | 所有工具开源、所有 API 免费。唯一可能花钱的是服务器代理($1/月),本地电脑不需要 |
| 🔒 **隐私安全** | Cookie 只存在你本地,不上传不外传。代码完全开源,随时可审查 | | 🔒 **隐私安全** | Cookie 只存在你本地,不上传不外传。代码完全开源,随时可审查 |
| 🔄 **持续更新** | 底层工具(yt-dlp、bird、Jina Reader 等)定期追踪更新到最新版,你不用自己盯 | | 🔄 **持续换代** | 每个平台都是「首选 + 备选」多后端路由。某个接入方式失效了,我们换下一个,你无感(2026-06 实例:yt-dlp 被 B站风控封死 → 已切换 bili-cli,用户零操作) |
| 🤖 **兼容所有 Agent** | Claude Code、OpenClaw、Cursor、Windsurf……任何能跑命令行的 Agent 都能用 | | 🤖 **兼容所有 Agent** | Claude Code、OpenClaw、Cursor、Windsurf……任何能跑命令行的 Agent 都能用 |
| 🩺 **自带诊断** | `agent-reach doctor` 一条命令告诉你哪个通、哪个不通、怎么修 | | 🩺 **自带诊断** | `agent-reach doctor` 一条命令告诉你哪个通、哪个不通、怎么修 |
@@ -71,14 +80,11 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
| 📡 **RSS** | 阅读任意 RSS/Atom 源 | — | 无需配置 | | 📡 **RSS** | 阅读任意 RSS/Atom 源 | — | 无需配置 |
| 🔍 **全网搜索** | — | 全网语义搜索 | 自动配置(MCP 接入,免费无需 Key) | | 🔍 **全网搜索** | — | 全网语义搜索 | 自动配置(MCP 接入,免费无需 Key) |
| 📦 **GitHub** | 读公开仓库 + 搜索 | 私有仓库、提 Issue/PR、Fork | 告诉 Agent「帮我登录 GitHub」 | | 📦 **GitHub** | 读公开仓库 + 搜索 | 私有仓库、提 Issue/PR、Fork | 告诉 Agent「帮我登录 GitHub」 |
| 🐦 **Twitter/X** | 读单条推文 | 搜索推文、浏览时间线、发推 | 告诉 Agent「帮我配 Twitter」 | | 🐦 **Twitter/X** | 读单条推文 | 搜索推文、浏览时间线、读长文 | 告诉 Agent「帮我配 Twitter」 |
| 📺 **B站** | 本地:字幕提取 + 搜索 | 服务器也能用 | 告诉 Agent「帮我配代理」 | | 📺 **B站** | 搜索 + 视频详情(bili-cli,无需登录) | 字幕(OpenCLI) | 告诉 Agent「帮我配 B站」 |
| 📖 **Reddit** | 搜索 + 读帖子和评论(通过 Exa) | — | 无需配置(自动通过 Exa) | | 📖 **Reddit** | —(没有零配置路径:匿名接口已被封) | 搜索 + 读帖子和评论 | 桌面装 OpenCLI 用浏览器登录态;或 rdt-cli + Cookie |
| 📕 **小红书** | — | 阅读、搜索、发帖、评论、点赞 | 告诉 Agent「帮我配小红书」 | | 📕 **小红书** | — | 搜索、阅读、评论 | 桌面装 OpenCLI(刷过小红书即可用);服务器用 xiaohongshu-mcp 扫码 |
| 🎵 **抖音** | — | 视频解析、无水印下载链接获取 | 告诉 Agent「帮我配抖音」 |
| 💼 **LinkedIn** | Jina Reader 读公开页面 | Profile 详情、公司页面、职位搜索 | 告诉 Agent「帮我配 LinkedIn」 | | 💼 **LinkedIn** | Jina Reader 读公开页面 | Profile 详情、公司页面、职位搜索 | 告诉 Agent「帮我配 LinkedIn」 |
| 💬 **微信公众号** | 搜索 + 阅读公众号文章(全文 Markdown) | — | 无需配置 |
| 📰 **微博** | 热搜、搜索内容/用户/话题、用户动态、评论 | — | 无需配置 |
| 💻 **V2EX** | 热门帖子、节点帖子、帖子详情+回复、用户信息 | — | 无需配置 | | 💻 **V2EX** | 热门帖子、节点帖子、帖子详情+回复、用户信息 | — | 无需配置 |
| 📈 **雪球** | 股票行情、搜索股票、热门帖子、热门股票排行 | — | 告诉 Agent「帮我配雪球」 | | 📈 **雪球** | 股票行情、搜索股票、热门帖子、热门股票排行 | — | 告诉 Agent「帮我配雪球」 |
| 🎙️ **小宇宙播客** | — | 播客音频转文字(Whisper 转录,免费 Key) | 告诉 Agent「帮我配小宇宙播客」 | | 🎙️ **小宇宙播客** | — | 播客音频转文字(Whisper 转录,免费 Key) | 告诉 Agent「帮我配小宇宙播客」 |
@@ -96,7 +102,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
> ⚠️ **OpenClaw 用户请先确认 exec 权限已开启** > ⚠️ **OpenClaw 用户请先确认 exec 权限已开启**
> >
> Agent Reach 依赖 Agent 执行 shell 命令(`pip install`、`mcporter`、`bird` 等)。如果你的 OpenClaw 使用了默认的 `messaging` 工具配置,Agent 将无法执行命令。**安装前请先开启 exec 权限**: > Agent Reach 依赖 Agent 执行 shell 命令(`pip install`、`mcporter`、`twitter` 等)。如果你的 OpenClaw 使用了默认的 `messaging` 工具配置,Agent 将无法执行命令。**安装前请先开启 exec 权限**:
> >
> ```bash > ```bash
> openclaw config set tools.profile "coding" > openclaw config set tools.profile "coding"
@@ -126,13 +132,14 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
<details> <details>
<summary>它会做什么?(点击展开)</summary> <summary>它会做什么?(点击展开)</summary>
1. **安装 CLI 工具**`pip install` 装好 `agent-reach` 命令行 1. **安装 CLI 工具**`pip install` 装好 `agent-reach` 命令行(自带 yt-dlp、feedparser
2. **安装系统依赖** — 自动检测并安装 Node.js、gh CLI、mcporter、bird 等 2. **安装系统基建** — 自动检测并安装 Node.js、gh CLI、mcporter
3. **配置搜索引擎** — 通过 MCP 接入 Exa(免费,无需 API Key 3. **配置搜索引擎** — 通过 MCP 接入 Exa(免费,无需 API Key
4. **检测环境** — 判断是本地电脑还是服务器,给出对应的配置建议 4. **检测环境** — 判断是本地电脑还是服务器,给出对应的配置建议
5. **注册 SKILL.md** — 在 Agent 的 skills 目录安装使用指南,以后 Agent 遇到"搜推特"、"看视频"这类需求,会自动知道该调哪个上游工具 5. **注册 SKILL.md** — 在 Agent 的 skills 目录安装使用指南,以后 Agent 遇到"全网调研"、"搜推特"、"看视频"这类需求,会自动知道该调哪个上游工具
6. **问你要不要更多** — 默认只激活 6 个零配置渠道;小红书、Twitter、Reddit 这些需要登录的,Agent 会列菜单问你要哪些,点名才装
安装完之后,`agent-reach doctor` 一条命令告诉你每个渠道的状态。 安装完之后,`agent-reach doctor` 一条命令告诉你每个渠道的状态、当前走哪条路
</details> </details>
--- ---
@@ -143,90 +150,68 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
- "帮我看看这个链接" → `curl https://r.jina.ai/URL` 读任意网页 - "帮我看看这个链接" → `curl https://r.jina.ai/URL` 读任意网页
- "这个 GitHub 仓库是做什么的" → `gh repo view owner/repo` - "这个 GitHub 仓库是做什么的" → `gh repo view owner/repo`
- "这个视频讲了什么" → `yt-dlp --dump-json URL` 提取字幕 - "这个 YouTube 视频讲了什么" → `yt-dlp` 提取字幕
- "帮我看看这条推文" → `bird read URL` - "B站搜一下 AI 教程" → `bili search`(无需登录)
- "全网搜一下 LLM 框架对比" → Exa 语义搜索
- "订阅这个 RSS" → `feedparser` 解析 - "订阅这个 RSS" → `feedparser` 解析
- "搜一下 GitHub 上有什么 LLM 框架" → `gh search repos "LLM framework"`
**不需要记命令。** Agent 读了 SKILL.md 之后自己知道该调什么。 **不需要记命令。** Agent 读了 SKILL.md 之后自己知道该调什么。需要登录的平台(小红书、Twitter、Reddit),告诉 Agent「帮我配 XXX」即可解锁。
---
## 能力边界:读内容 vs 操作网页
Agent Reach 解决的是让 Agent **读到和搜到** 互联网上的内容,不替代用户完成登录后的网页操作、表单提交、多账号隔离、并行浏览器会话等流程。
如果自动化流程中遇到登录、验证、风控提示等高摩擦环节,需要人工接手或真实浏览器会话,可以配合 [BrowserAct](https://browseract.com) 这类浏览器自动化工具:30+ 预制平台技能,支持 Claude Code / OpenClaw / Cursor 等主流 Agent。
--- ---
## 设计理念 ## 设计理念
**Agent Reach 是一个脚手架(scaffolding),不是框架** **Agent Reach 是一个能力层(capability layer),不是又一个工具**
你给一个新 Agent 装环境的时候,总要花时间去找工具、装依赖、调配置——Twitter 用什么读?Reddit 怎么绕封?YouTube 字幕怎么提取?每次都要重新踩一遍 它比任何具体实现高一层——负责**选型、安装、体检、路由**,不负责底层读取本身。读取由 Agent 直接调用上游工具完成,没有包装层
Agent Reach 做的事情很简单:**帮你把这些选型和配置的活儿做完了。** 你给一个新 Agent 装环境的时候,总要花时间去找工具、装依赖、调配置——Twitter 用什么读?Reddit 怎么登录?小红书的 CLI 停更了换什么?每次都要重新踩一遍。Agent Reach 做的事情很简单:**当下最稳的接入方式,我们替你选好、装好、体检好。接入方式会换代(2026 年 3 月一批单平台 CLI 集体停更,我们换了路由),你不用操心。**
安装完成后,Agent 直接调用上游工具(bird CLI、yt-dlp、mcporter、gh CLI 等),不需要经过 Agent Reach 的包装层。 ### 🔌 每个平台 = 首选 + 备选的有序后端列表
### 🔌 每个渠道都是可插拔的 换接入方式 = 调整列表顺序,不是重写代码。`agent-reach doctor` 会告诉你每个平台**当前在用哪个后端**。
每个平台背后是一个独立的上游工具。**不满意?换掉就行。**
``` ```
channels/ channels/
├── web.py → Jina Reader ← 可以换成 Firecrawl、Crawl4AI…… ├── web.py → Jina Reader
├── twitter.py → bird ← 可以换成 Nitter、官方 API…… ├── twitter.py → twitter-cli ▸ OpenCLI ▸ bird
├── youtube.py → yt-dlp ← 可以换成 YouTube API、Whisper…… ├── youtube.py → yt-dlp
├── github.py → gh CLI ← 可以换成 REST API、PyGithub…… ├── github.py → gh CLI
├── bilibili.py → yt-dlp ← 可以换成 bilibili-api…… ├── bilibili.py → bili-cli ▸ OpenCLI ▸ 搜索 APIyt-dlp 已被 B站风控封死,退役)
├── reddit.py → Exa ← 搜索+阅读,无需代理 ├── reddit.py → OpenCLI ▸ rdt-cli(无零配置路径,必须登录态)
├── xiaohongshu.py → mcporter MCP ← 可以换成其他 XHS 工具…… ├── xiaohongshu.py → OpenCLI ▸ xiaohongshu-mcp ▸ xhs-cli
├── douyin.py → mcporter MCP ← 可以换成其他抖音工具…… ├── linkedin.py → linkedin-mcp ▸ Jina Reader
├── linkedin.py → linkedin-mcp ← 可以换成 LinkedIn API…… ├── rss.py → feedparser
├── wechat.py → camoufox+miku ← 搜索+阅读微信公众号文章 ├── exa_search.py → Exa via mcporter
├── rss.py → feedparser ← 可以换成 atoma……
├── exa_search.py → mcporter MCP ← 可以换成 Tavily、SerpAPI……
└── __init__.py → 渠道注册(doctor 检测用) └── __init__.py → 渠道注册(doctor 检测用)
``` ```
每个渠道文件只负责检测对应上游工具是否可用(`check()` 方法),给 `agent-reach doctor` 提供状态信息。实际的读取和搜索由 Agent 直接调用上游工具完成。 每个渠道文件按序**真实探测**各候选后端(不只是看命令存不存在),第一个完整可用的当选;坏掉的会给出修复处方。实际的读取和搜索由 Agent 直接调用上游工具完成。
### 当前选型 ### 当前选型
| 场景 | 选 | 为什么选 | | 场景 | 首选 | 备选 | 为什么这么选 |
|------|------|-----------| |------|------|------|-----------|
| 读网页 | [Jina Reader](https://github.com/jina-ai/reader) | 9.8K Star免费,不需要 API Key | | 读网页 | [Jina Reader](https://github.com/jina-ai/reader) | — | 免费,不需要 API Key |
| 读推特 | [bird](https://www.npmjs.com/package/@steipete/bird) | Cookie 登录,免费。官方 API 按量付费(读一条 $0.005) | | 读推特 | [twitter-cli](https://github.com/public-clis/twitter-cli) | [OpenCLI](https://github.com/jackwener/opencli) | 实测搜索稳定;OpenCLI 走浏览器登录态兜底 |
| 视频字幕 + 搜索 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | 148K StarYouTube + B站 + 1800 站通吃 | | Reddit | [OpenCLI](https://github.com/jackwener/opencli)(桌面) | [rdt-cli](https://github.com/public-clis/rdt-cli) | 匿名接口已被封、官方 API 审批制——只剩登录态路线 |
| 搜全网 | [Exa](https://exa.ai) via [mcporter](https://github.com/steipete/mcporter) | AI 语义搜索,MCP 接入免 Key | | YouTube 字幕 + 搜索 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | — | 154K StarYouTube 仍是最佳(注意:不再用于 B站) |
| GitHub | [gh CLI](https://cli.github.com) | 官方工具,认证后完整 API 能力 | | B站 | [bili-cli](https://github.com/public-clis/bilibili-cli) | OpenCLI ▸ 搜索 API | yt-dlp 被 B站风控 412 封死(2026-06 实测),bili-cli 无登录可搜可读 |
| 读 RSS | [feedparser](https://github.com/kurtmckee/feedparser) | Python 生态标准选择,2.3K Star | | 搜全网 | [Exa](https://exa.ai) via [mcporter](https://github.com/nicobailon/mcporter) | — | AI 语义搜索,MCP 接入免 Key |
| 小红书 | [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) | ⭐9K+Go 语言,Docker 一键部署 | | GitHub | [gh CLI](https://cli.github.com) | — | 官方工具,认证后完整 API 能力 |
| 抖音 | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCP 服务,无需登录,视频解析 + 无水印下载 | | 读 RSS | [feedparser](https://github.com/kurtmckee/feedparser) | — | Python 生态标准选择 |
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+,MCP 服务,浏览器自动化 | | 小红书 | [OpenCLI](https://github.com/jackwener/opencli)(桌面) | [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp)(服务器)▸ xhs-cli | xhs-cli 作者已转投 OpenCLI24K Star);浏览器登录态零摩擦 |
| 微信公众号 | [wechat-article-for-ai](https://github.com/Panniantong/wechat-article-for-ai) + [miku_ai](https://github.com/GobinFan/Miku_Spider) | Camoufox 隐身浏览器读全文 + 搜狗搜索 | | LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | Jina Reader | MCP 服务,浏览器自动化 |
> 📌 这些都是「当前选型」。不满意?换掉对应文件就行。这正是脚手架的意义 > 📌 这些都是「当前选型」,基于真机实测定期复核。某条路失效了我们换下一条——`agent-reach doctor` 永远告诉你现在走的是哪条
### 抖音 / 小红书脚本提取的可选实现
如果你不只是想“解析抖音视频信息”,还想统一处理:
- 抖音视频脚本提取
- 小红书视频笔记脚本提取
- 小红书图文笔记正文 + 图片文字提取
- 固定输出 `script.md``info.json`
可以把 `douyin` 这个 mcporter alias 指向另一个兼容实现:
- [social-post-extractor-mcp](https://github.com/JNHFlow21/social-post-extractor-mcp)
这个实现保留了旧工具名兼容性:
- `parse_douyin_video_info`
- `get_douyin_download_link`
- `extract_douyin_text`
同时新增统一工具:
- `parse_social_post_info`
- `extract_social_post_script`
所以从 Agent Reach 的视角看,它依然只是一个 `mcporter` 里的 `douyin` server,只是能力更完整。
--- ---
@@ -309,19 +294,19 @@ Star 一下,下次需要的时候能找到。⭐
<details> <details>
<summary><strong>AI Agent 怎么搜索 Twitter / X?不想付 API 费用</strong></summary> <summary><strong>AI Agent 怎么搜索 Twitter / X?不想付 API 费用</strong></summary>
Agent Reach 使用 [bird CLI](https://www.npmjs.com/package/@steipete/bird) 通过 Cookie 认证访问 Twitter,完全免费。安装 Agent Reach 后,用 Cookie-Editor 导出你的 Twitter Cookie,运行 `agent-reach configure twitter-cookies "your_cookies"` 即可。之后 Agent 就可以用 `bird search "关键词"` 搜索推文了。 Agent Reach 使用 [twitter-cli](https://github.com/public-clis/twitter-cli) 通过 Cookie 认证访问 Twitter,完全免费。安装:`pipx install twitter-cli`,确保浏览器已登录 x.comAgent 就可以用 `twitter search "关键词"` 搜索`twitter tweet URL`推文了。
</details> </details>
<details> <details>
<summary><strong>How to search Twitter/X with AI agent for free (no API)?</strong></summary> <summary><strong>How to search Twitter/X with AI agent for free (no API)?</strong></summary>
Agent Reach uses the bird CLI with cookie auth — zero API fees. After installing, export your Twitter cookies with the Cookie-Editor extension, run `agent-reach configure twitter-cookies "your_cookies"`, then your agent can search with `bird search "query"`. Agent Reach uses twitter-cli with cookie auth — zero API fees. Install with `pipx install twitter-cli`, make sure you're logged into x.com in your browser, then your agent can search with `twitter search "query"` and read tweets with `twitter tweet URL`.
</details> </details>
<details> <details>
<summary><strong>Reddit 返回 403 怎么办?</strong></summary> <summary><strong>Reddit 返回 403 怎么办?</strong></summary>
Agent Reach 已改为通过 Exa 搜索和阅读 Reddit 内容,完全绕过 Reddit API 的 IP 封锁。无需代理,无需额外配置。运行 `agent-reach install --env=auto` 自动安装 Exa Reddit 所有访问都需要登录态(匿名接口已被全面封锁,官方 API 需人工审批)。桌面首选 **OpenCLI**:浏览器里登录过 reddit.com 即可直接 `opencli reddit search "关键词"`。备选 [rdt-cli](https://github.com/public-clis/rdt-cli)`pipx install 'git+https://github.com/public-clis/rdt-cli.git@5e4fb3720d5c174e976cd425ccc3b879d52cac66'`(与代码同一钉定版本,PyPI 落后),然后 `rdt login`。中国大陆网络访问 Reddit 需要代理
</details> </details>
<details> <details>
@@ -333,13 +318,7 @@ Agent Reach 已改为通过 Exa 搜索和阅读 Reddit 内容,完全绕过 Red
<details> <details>
<summary><strong>怎么让 AI Agent 读小红书?</strong></summary> <summary><strong>怎么让 AI Agent 读小红书?</strong></summary>
小红书需要通过 Docker 运行一个 MCP 服务。安装 Docker 后,运行 `agent-reach install` 会自动配置。之后 Agent 就能`mcporter call 'xiaohongshu.get_feed_detail(...)'`笔记`mcporter call 'xiaohongshu.search_feeds(keyword: "关键词")'` 搜索了 桌面电脑首选 **OpenCLI**`agent-reach install --channels opencli`)——它复用你浏览器里的登录态,平时刷过小红书就直接能用,零配置;装完在 Chrome 商店点一次「添加扩展」即可。之后 Agent 用 `opencli xiaohongshu search "关键词"` 搜索、`opencli xiaohongshu note URL` 读笔记。服务器上用 [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp)(自带无头浏览器,扫码登录)。已装过 xhs-cli 的老用户不受影响,它仍是备选后端(上游 2026-03 起停更,不推荐新装)
</details>
<details>
<summary><strong>怎么让 AI Agent 解析抖音视频?</strong></summary>
安装 douyin-mcp-server 后,Agent 就能用 `mcporter call 'douyin.parse_douyin_video_info(share_link: "分享链接")'` 解析视频信息、获取无水印下载链接。不需要登录,把抖音分享链接发给 Agent 就行。详见 https://github.com/yzfly/douyin-mcp-server
</details> </details>
<details> <details>
@@ -353,14 +332,14 @@ Yes! Agent Reach is an installer + configuration tool — any AI coding agent th
<details> <details>
<summary><strong>Is this free? Any API costs?</strong></summary> <summary><strong>Is this free? Any API costs?</strong></summary>
100% free. All backends are open-source tools (bird CLI, yt-dlp, Jina Reader, Exa, etc.) that don't require paid API keys. The only optional cost is a residential proxy (~$1/month) if you need Bilibili access from a server. Reddit now works free via Exa without any proxy. 100% free. All backends are open-source tools (OpenCLI, twitter-cli, bili-cli, rdt-cli, yt-dlp, Jina Reader, Exa, xiaohongshu-mcp, etc.) that don't require paid API keys. The only optional cost is a residential proxy (~$1/month) if your network blocks Reddit/Twitter (e.g. mainland China).
</details> </details>
--- ---
## 致谢 ## 致谢
[Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [bird](https://www.npmjs.com/package/@steipete/bird) · [Exa](https://exa.ai) · [mcporter](https://github.com/steipete/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) · [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) [OpenCLI](https://github.com/jackwener/opencli) · [twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server)
## 联系 ## 联系
@@ -381,10 +360,6 @@ Yes! Agent Reach is an installer + configuration tool — any AI coding agent th
## 友情链接 ## 友情链接
[FluxNode](https://fluxnode.org) — 低价 AI API 中转站,官方一折,可按量或按套餐付费。可用于 OpenClaw、Claude Code 等一切 Agent。
[OpenClaw for Enterprise](https://github.com/littleben/openclaw-for-enterprise) — 企业级 OpenClaw 多用户部署方案,飞书里直接用 AI,容器隔离,一条命令管理。
[腾讯云 OpenClaw](https://www.tencentcloud.com/act/pro/intl-openclaw?referral_code=G76Y819A&lang=zh&pg=) — 在腾讯云Lighthouse秒级部署OpenClaw全能助手,可通过对话丝滑接入Agent Reach,给你的OpenClaw一键装上互联网能力。 [腾讯云 OpenClaw](https://www.tencentcloud.com/act/pro/intl-openclaw?referral_code=G76Y819A&lang=zh&pg=) — 在腾讯云Lighthouse秒级部署OpenClaw全能助手,可通过对话丝滑接入Agent Reach,给你的OpenClaw一键装上互联网能力。
## Star History ## Star History
+51
View File
@@ -0,0 +1,51 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|-----------|
| Latest | ✅ Yes |
## Reporting a Vulnerability
If you discover a security vulnerability in Agent-Reach, please report
it responsibly by using GitHub's private security advisory feature:
👉 **[Report a vulnerability](https://github.com/Panniantong/Agent-Reach/security/advisories/new)**
Please do NOT open a public GitHub issue for security vulnerabilities.
## What to Include
- Description of the vulnerability
- Steps to reproduce
- Affected versions
- Potential impact
- Suggested fix (if any)
## Response Timeline
- Acknowledgement within **48 hours**
- Status update within **7 days**
- Fix timeline communicated within **14 days**
## Scope
The following are considered in scope:
- Authentication and authorization bypass
- Remote code execution
- Path traversal / arbitrary file read
- Server-Side Request Forgery (SSRF)
- Injection vulnerabilities (SQL, command, prompt)
- Sensitive data exposure
## Out of Scope
- Vulnerabilities in dependencies (report to the dependency maintainer)
- Social engineering attacks
- Denial of service via resource exhaustion
## Credits
We appreciate responsible disclosure and will credit researchers
in our release notes unless anonymity is requested.
+1 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Agent Reach — Give your AI Agent eyes to see the entire internet.""" """Agent Reach — Give your AI Agent eyes to see the entire internet."""
__version__ = "1.4.0" __version__ = "1.5.0"
__author__ = "Neo Reid" __author__ = "Neo Reid"
from agent_reach.core import AgentReach from agent_reach.core import AgentReach
+16
View File
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
"""Cross-channel backends.
A backend here is an upstream runtime that serves MULTIPLE channels
(e.g. OpenCLI covers xiaohongshu/reddit/bilibili/twitter through one
browser session), as opposed to the per-platform tools probed inside
each channel file.
"""
from .opencli import ( # noqa: F401
OPENCLI_EXTENSION_URL,
OPENCLI_PACKAGE,
OpenCLIStatus,
opencli_status,
opencli_summary,
)
+136
View File
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""OpenCLI backend probing.
OpenCLI (github.com/jackwener/opencli) drives the user's real Chrome via a
browser-bridge extension + local daemon, reusing existing login sessions —
zero per-platform configuration, desktop-only (no headless).
Probing notes (verified live):
- `opencli doctor` AUTO-STARTS the daemon — a side effect, so health
checks must use `opencli daemon status` (pure query) instead.
- Exit codes are always 0; status must be parsed from text output.
- "Extension: disconnected" does NOT mean unusable: the extension's
service worker sleeps and any real opencli command wakes it up
(verified: status flips disconnected→connected after one call).
Since daemon status can't tell "sleeping" from "never installed",
we check Chrome's Extensions directory on disk to disambiguate.
"""
import glob
import os
from dataclasses import dataclass
from agent_reach.probe import probe_command
OPENCLI_PACKAGE = "@jackwener/opencli"
OPENCLI_EXTENSION_ID = "ildkmabpimmkaediidaifkhjpohdnifk"
OPENCLI_EXTENSION_URL = (
f"https://chromewebstore.google.com/detail/opencli/{OPENCLI_EXTENSION_ID}"
)
#: Chrome-family profile roots that contain <Profile>/Extensions/<id>/
_CHROME_PROFILE_ROOTS = (
"~/Library/Application Support/Google/Chrome", # macOS Chrome
"~/Library/Application Support/Chromium", # macOS Chromium
"~/.config/google-chrome", # Linux Chrome
"~/.config/chromium", # Linux Chromium
)
def _extension_installed_on_disk() -> bool:
"""True if the OpenCLI extension exists in any Chrome profile.
Store-installed extensions always live under
<profile>/Extensions/<extension id>/ — this disambiguates a sleeping
service worker from a never-installed extension. Dev installs via
"Load unpacked" are not covered (those users can read `opencli doctor`).
"""
roots = [os.path.expanduser(p) for p in _CHROME_PROFILE_ROOTS]
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data: # Windows
roots.append(os.path.join(local_app_data, "Google", "Chrome", "User Data"))
for root in roots:
if glob.glob(os.path.join(root, "*", "Extensions", OPENCLI_EXTENSION_ID)):
return True
return False
@dataclass
class OpenCLIStatus:
installed: bool = False
broken: bool = False
daemon_running: bool = False
extension_connected: bool = False
extension_installed: bool = False
version: str = ""
hint: str = ""
@property
def ready(self) -> bool:
"""Usable now or on first call.
A live connection counts, and so does an installed-but-sleeping
extension: its service worker wakes on the first real command.
"""
return self.installed and not self.broken and (
self.extension_connected or self.extension_installed
)
def opencli_status(timeout: int = 10) -> OpenCLIStatus:
"""Probe OpenCLI install + daemon/extension state without side effects."""
version_probe = probe_command(
"opencli", ["--version"], timeout=timeout, package=OPENCLI_PACKAGE
)
if version_probe.status == "missing":
return OpenCLIStatus(installed=False)
if not version_probe.ok:
return OpenCLIStatus(
installed=True,
broken=True,
hint=(
"opencli 命令存在但无法执行(node 环境损坏),重装:\n"
f" npm install -g {OPENCLI_PACKAGE}"
),
)
st = OpenCLIStatus(installed=True, version=version_probe.output.strip())
daemon_probe = probe_command(
"opencli", ["daemon", "status"], timeout=timeout, package=OPENCLI_PACKAGE
)
output = daemon_probe.output if daemon_probe.ok else ""
# `opencli daemon status` prints lines like:
# Daemon: running (PID 37389) / Daemon: not running
# Extension: connected / Extension: disconnected
for line in output.splitlines():
line = line.strip().lower()
if line.startswith("daemon:"):
st.daemon_running = "not running" not in line and "running" in line
elif line.startswith("extension:"):
st.extension_connected = "disconnected" not in line and "connected" in line
if not st.extension_connected:
st.extension_installed = _extension_installed_on_disk()
if not st.extension_installed:
st.hint = (
"OpenCLI 已安装,但 Chrome 扩展未安装。\n"
f" 1. 安装扩展(需手动点一次):{OPENCLI_EXTENSION_URL}\n"
" 2. 保持 Chrome 打开,运行 `opencli doctor` 验证"
)
return st
def opencli_summary(st: OpenCLIStatus) -> str:
"""One-line state description for channel messages / install output."""
if not st.installed:
return "OpenCLI 未安装"
if st.broken:
return "OpenCLI 无法执行(node 环境损坏)"
if st.extension_connected:
return f"OpenCLI 可用(浏览器登录态,v{st.version}"
if st.ready:
return "OpenCLI 可用(扩展睡眠中,调用时自动唤醒)"
if st.daemon_running:
return "OpenCLI 已安装,等待 Chrome 扩展安装"
return "OpenCLI 已安装(daemon 未运行,使用时自动启动;需 Chrome 扩展)"
-6
View File
@@ -16,10 +16,7 @@ from .rss import RSSChannel
from .bilibili import BilibiliChannel from .bilibili import BilibiliChannel
from .exa_search import ExaSearchChannel from .exa_search import ExaSearchChannel
from .xiaohongshu import XiaoHongShuChannel from .xiaohongshu import XiaoHongShuChannel
from .douyin import DouyinChannel
from .linkedin import LinkedInChannel from .linkedin import LinkedInChannel
from .wechat import WeChatChannel
from .weibo import WeiboChannel
from .xiaoyuzhou import XiaoyuzhouChannel from .xiaoyuzhou import XiaoyuzhouChannel
from .v2ex import V2EXChannel from .v2ex import V2EXChannel
from .xueqiu import XueqiuChannel from .xueqiu import XueqiuChannel
@@ -32,10 +29,7 @@ ALL_CHANNELS: List[Channel] = [
RedditChannel(), RedditChannel(),
BilibiliChannel(), BilibiliChannel(),
XiaoHongShuChannel(), XiaoHongShuChannel(),
DouyinChannel(),
LinkedInChannel(), LinkedInChannel(),
WeChatChannel(),
WeiboChannel(),
XiaoyuzhouChannel(), XiaoyuzhouChannel(),
V2EXChannel(), V2EXChannel(),
XueqiuChannel(), XueqiuChannel(),
+37 -3
View File
@@ -8,11 +8,22 @@ and provides:
- check(config) → is the upstream tool installed and configured? - check(config) → is the upstream tool installed and configured?
After installation, agents call upstream tools directly. 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.
""" """
import shutil
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Tuple from typing import List, Optional, Tuple
class Channel(ABC): class Channel(ABC):
@@ -20,17 +31,40 @@ class Channel(ABC):
name: str = "" # e.g. "youtube" name: str = "" # e.g. "youtube"
description: str = "" # e.g. "YouTube 视频和字幕" description: str = "" # e.g. "YouTube 视频和字幕"
backends: List[str] = [] # e.g. ["yt-dlp"] — what upstream tool is used backends: List[str] = [] # ordered candidates — backends[0] = preferred
tier: int = 0 # 0=zero-config, 1=needs free key, 2=needs setup 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 @abstractmethod
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
"""Check if this channel can handle this URL.""" """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]: def check(self, config=None) -> Tuple[str, str]:
""" """
Check if this channel's upstream tool is available. Check if this channel's upstream tool is available.
Returns (status, message) where status is 'ok'/'warn'/'off'/'error'. 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 '内置'}" return "ok", f"{''.join(self.backends) if self.backends else '内置'}"
+82 -29
View File
@@ -1,11 +1,19 @@
# -*- coding: utf-8 -*- # -*- 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 json
import os
import shutil
import subprocess
import urllib.request import urllib.request
from agent_reach.probe import probe_command
from .base import Channel from .base import Channel
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" _UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
@@ -27,7 +35,7 @@ def _search_api_ok() -> bool:
class BilibiliChannel(Channel): class BilibiliChannel(Channel):
name = "bilibili" name = "bilibili"
description = "B站视频、字幕和搜索" description = "B站视频、字幕和搜索"
backends = ["yt-dlp", "bili-cli (可选)", "B站搜索 API"] backends = ["bili-cli", "OpenCLI", "B站搜索 API"]
tier = 1 tier = 1
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
@@ -36,31 +44,76 @@ class BilibiliChannel(Channel):
return "bilibili.com" in d or "b23.tv" in d return "bilibili.com" in d or "b23.tv" in d
def check(self, config=None): def check(self, config=None):
if not shutil.which("yt-dlp"): """Probe candidates in order; first fully-usable backend wins."""
return "off", "yt-dlp 未安装。安装:pip install yt-dlp" self.active_backend = None
findings = []
proxy = (config.get("bilibili_proxy") if config else None) or os.environ.get("BILIBILI_PROXY") for backend in self.ordered_backends(config):
has_bili_cli = bool(shutil.which("bili")) if backend == "bili-cli":
result = self._check_bili_cli()
parts = [] elif backend == "OpenCLI":
result = self._check_opencli()
# 视频读取状态
if proxy:
parts.append("视频读取:yt-dlp(代理已配置)")
else: else:
parts.append("视频读取:yt-dlp") result = self._check_search_api()
if result is None:
continue
findings.append((backend, *result))
# bili-cli 增强 # 有后端断链时,即使别的候选兜底成功也要把处方带出来
if has_bili_cli: broken_notes = [m for _, s, m in findings if s == "error"]
parts.append("搜索/热门/排行:bili-cli 可用")
else:
# 检测搜索 API 连通性
api_ok = _search_api_ok()
if api_ok:
parts.append("搜索:B站 API 可用")
else:
parts.append("搜索:B站 API 不可达")
parts.append("提示:安装 bili-cli 可解锁热门/排行/动态:pipx install bilibili-cli")
status = "ok" if has_bili_cli or _search_api_ok() else "warn" for wanted in ("ok", "warn"):
return status, "".join(parts) 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"
)
-56
View File
@@ -1,56 +0,0 @@
# -*- coding: utf-8 -*-
"""Douyin (抖音) — check if mcporter + douyin-mcp-server is available."""
import shutil
import subprocess
from .base import Channel
class DouyinChannel(Channel):
name = "douyin"
description = "抖音短视频"
backends = ["douyin-mcp-server"]
tier = 2
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
d = urlparse(url).netloc.lower()
return "douyin.com" in d or "iesdouyin.com" in d
def check(self, config=None):
mcporter = shutil.which("mcporter")
if not mcporter:
return "off", (
"需要 mcporter + douyin-mcp-server。安装步骤:\n"
" 1. npm install -g mcporter\n"
" 2. pip install douyin-mcp-server\n"
" 3. 启动服务(见下方说明)\n"
" 4. mcporter config add douyin http://localhost:18070/mcp\n"
" 详见 https://github.com/yzfly/douyin-mcp-server"
)
try:
r = subprocess.run(
[mcporter, "config", "list"], capture_output=True,
encoding="utf-8", errors="replace", timeout=5
)
if "douyin" not in r.stdout:
return "off", (
"mcporter 已装但抖音 MCP 未配置。运行:\n"
" pip install douyin-mcp-server\n"
" # 启动服务后:\n"
" mcporter config add douyin http://localhost:18070/mcp"
)
except Exception:
return "off", "mcporter 连接异常"
# Verify MCP connectivity by listing available tools instead of
# calling with a hardcoded (invalid) share link that always fails.
try:
r = subprocess.run(
[mcporter, "list", "douyin"],
capture_output=True, encoding="utf-8", errors="replace", timeout=15
)
if r.returncode == 0 and r.stdout.strip():
return "ok", "完整可用(视频解析、下载链接获取)"
return "warn", "MCP 已连接但工具列表为空,检查 douyin-mcp-server 服务是否在运行"
except Exception:
return "warn", "MCP 连接异常,检查 douyin-mcp-server 服务是否在运行"
+14 -12
View File
@@ -1,10 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Exa Search — check if mcporter + Exa MCP is available.""" """Exa Search — check if mcporter + Exa MCP is available."""
import shutil from agent_reach.probe import probe_command
import subprocess
from .base import Channel from .base import Channel
#: mcporter 是 npm 包,断链处方与默认的 pipx/uv 不同
_MCPORTER_BROKEN_HINT = "mcporter 无法执行(node 环境损坏),重装:\n npm install -g mcporter"
class ExaSearchChannel(Channel): class ExaSearchChannel(Channel):
name = "exa_search" name = "exa_search"
@@ -16,23 +19,22 @@ class ExaSearchChannel(Channel):
return False # Search-only channel return False # Search-only channel
def check(self, config=None): def check(self, config=None):
mcporter = shutil.which("mcporter") self.active_backend = None
if not mcporter: probe = probe_command("mcporter", ["config", "list"], timeout=10, package="mcporter")
if probe.status == "missing":
return "off", ( return "off", (
"需要 mcporter + Exa MCP。安装:\n" "需要 mcporter + Exa MCP。安装:\n"
" npm install -g mcporter\n" " npm install -g mcporter\n"
" mcporter config add exa https://mcp.exa.ai/mcp" " mcporter config add exa https://mcp.exa.ai/mcp"
) )
try: if probe.status == "broken":
r = subprocess.run( return "error", _MCPORTER_BROKEN_HINT
[mcporter, "config", "list"], capture_output=True, if not probe.ok: # timeout / error
encoding="utf-8", errors="replace", timeout=5 return "error", f"mcporter 执行异常:{probe.hint or probe.output or probe.status}"
) if "exa" in probe.output.lower():
if "exa" in r.stdout.lower(): self.active_backend = self.backends[0]
return "ok", "全网语义搜索可用(免费,无需 API Key)" return "ok", "全网语义搜索可用(免费,无需 API Key)"
return "off", ( return "off", (
"mcporter 已装但 Exa 未配置。运行:\n" "mcporter 已装但 Exa 未配置。运行:\n"
" mcporter config add exa https://mcp.exa.ai/mcp" " mcporter config add exa https://mcp.exa.ai/mcp"
) )
except Exception:
return "off", "mcporter 连接异常"
+21 -11
View File
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""GitHub — check if gh CLI is available.""" """GitHub — check if gh CLI is available."""
import shutil from agent_reach.probe import probe_command
import subprocess
from .base import Channel from .base import Channel
@@ -17,16 +17,26 @@ class GitHubChannel(Channel):
return "github.com" in urlparse(url).netloc.lower() return "github.com" in urlparse(url).netloc.lower()
def check(self, config=None): def check(self, config=None):
gh = shutil.which("gh") # 真跑 gh auth status 探活。注意:未登录时 rc!=0 是正常业务态(warn),不是 error。
if not gh: 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" return "warn", "gh CLI 未安装。安装:https://cli.github.com"
try: if probe.status == "broken":
r = subprocess.run( # gh 是二进制安装(brew/官方包),不是 pip 包——处方不用 pipx/uv 文案
[gh, "auth", "status"], self.active_backend = None
capture_output=True, encoding="utf-8", errors="replace", timeout=5 return "error", (
"gh 命令存在但无法执行——安装已损坏。重装即可修复:\n"
" brew reinstall gh\n"
"或从 https://cli.github.com 重新安装 gh CLI"
) )
if r.returncode == 0: 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 等)" return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)"
# rc != 0gh 活着但未认证(gh auth status 的正常业务态)
self.active_backend = "gh CLI"
return "warn", "gh CLI 已安装但未认证。运行 gh auth login 可解锁完整功能" return "warn", "gh CLI 已安装但未认证。运行 gh auth login 可解锁完整功能"
except Exception:
return "warn", "gh CLI 状态检查失败,运行 gh auth status 查看详情"
+14 -12
View File
@@ -1,10 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""LinkedIn — check if linkedin-scraper-mcp is available.""" """LinkedIn — check if linkedin-scraper-mcp is available."""
import shutil from agent_reach.probe import probe_command
import subprocess
from .base import Channel from .base import Channel
#: mcporter 是 npm 包,断链处方与默认的 pipx/uv 不同
_MCPORTER_BROKEN_HINT = "mcporter 无法执行(node 环境损坏),重装:\n npm install -g mcporter"
class LinkedInChannel(Channel): class LinkedInChannel(Channel):
name = "linkedin" name = "linkedin"
@@ -17,23 +20,22 @@ class LinkedInChannel(Channel):
return "linkedin.com" in urlparse(url).netloc.lower() return "linkedin.com" in urlparse(url).netloc.lower()
def check(self, config=None): def check(self, config=None):
mcporter = shutil.which("mcporter") self.active_backend = None
if not mcporter: probe = probe_command("mcporter", ["config", "list"], timeout=10, package="mcporter")
if probe.status == "missing":
return "off", ( return "off", (
"基本内容可通过 Jina Reader 读取。完整功能需要:\n" "基本内容可通过 Jina Reader 读取。完整功能需要:\n"
" pip install linkedin-scraper-mcp\n" " pip install linkedin-scraper-mcp\n"
" mcporter config add linkedin http://localhost:3000/mcp\n" " mcporter config add linkedin http://localhost:3000/mcp\n"
" 详见 https://github.com/stickerdaniel/linkedin-mcp-server" " 详见 https://github.com/stickerdaniel/linkedin-mcp-server"
) )
try: if probe.status == "broken":
r = subprocess.run( return "error", _MCPORTER_BROKEN_HINT
[mcporter, "config", "list"], capture_output=True, if not probe.ok: # timeout / error
encoding="utf-8", errors="replace", timeout=5 return "error", f"mcporter 执行异常:{probe.hint or probe.output or probe.status}"
) if "linkedin" in probe.output.lower():
if "linkedin" in r.stdout.lower(): self.active_backend = "linkedin-scraper-mcp"
return "ok", "完整可用(Profile、公司、职位搜索)" return "ok", "完整可用(Profile、公司、职位搜索)"
except Exception:
pass
return "off", ( return "off", (
"mcporter 已装但 LinkedIn MCP 未配置。运行:\n" "mcporter 已装但 LinkedIn MCP 未配置。运行:\n"
" pip install linkedin-scraper-mcp\n" " pip install linkedin-scraper-mcp\n"
+146 -12
View File
@@ -1,31 +1,165 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Reddit — search and read via rdt-cli (public-clis/rdt-cli).""" """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 shutil
import subprocess import subprocess
from agent_reach.utils.process import utf8_subprocess_env
from .base import Channel 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): class RedditChannel(Channel):
name = "reddit" name = "reddit"
description = "Reddit 帖子和评论" description = "Reddit 帖子和评论"
backends = ["rdt-cli"] backends = ["OpenCLI", "rdt-cli"]
tier = 0 tier = 1 # no zero-config path exists — see module docstring
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse from urllib.parse import urlparse
d = urlparse(url).netloc.lower() d = urlparse(url).netloc.lower()
return "reddit.com" in d or "redd.it" in d return "reddit.com" in d or "redd.it" in d
def check(self, config=None): def check(self, config=None):
rdt = shutil.which("rdt") """Probe candidates in order; first fully-usable backend wins."""
if rdt: self.active_backend = None
return "ok", ( findings = []
"rdt-cli 可用(搜索帖子、阅读全文、查看评论,无需登录)"
) 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", ( return "off", (
"需要安装 rdt-cli\n" "未安装任何 Reddit 后端。注意:Reddit 没有零配置路径"
" pipx install rdt-cli\n" "(匿名 .json 已被封,官方 API 需人工审批),必须用登录态。推荐:\n"
"或:\n" " 桌面:agent-reach install --channels opencli\n"
" uv tool install rdt-cli" " (复用 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 解析必炸。
# 故保留手写 subprocess(stdout 单独捕获),但异常分类对齐 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 响应超时(>10s),Reddit 状态未知。稍后重试或运行 `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"
) )
+8 -2
View File
@@ -15,7 +15,13 @@ class RSSChannel(Channel):
def check(self, config=None): def check(self, config=None):
try: try:
import feedparser import feedparser # noqa: F401
return "ok", "可读取 RSS/Atom 源"
except ImportError: except ImportError:
self.active_backend = None
return "off", "feedparser 未安装。安装:pip install feedparser" 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 源"
+108 -16
View File
@@ -1,15 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Twitter/X — check if twitter-cli (public-clis/twitter-cli) is available.""" """Twitter/X — check if twitter-cli or bird CLI is available."""
import shutil
import subprocess
from .base import Channel from .base import Channel
from agent_reach.probe import probe_command
class TwitterChannel(Channel): class TwitterChannel(Channel):
name = "twitter" name = "twitter"
description = "Twitter/X 推文" description = "Twitter/X 推文"
backends = ["twitter-cli"] backends = ["twitter-cli", "OpenCLI", "bird CLI (legacy)"]
tier = 1 tier = 1
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
@@ -18,24 +17,66 @@ class TwitterChannel(Channel):
return "x.com" in d or "twitter.com" in d return "x.com" in d or "twitter.com" in d
def check(self, config=None): def check(self, config=None):
twitter = shutil.which("twitter") """Probe candidates in order; first fully-usable backend wins.
if not twitter:
与其他多后端渠道同一套两段式:先收集全部候选状态,第一个 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", ( return "warn", (
"twitter-cli 未安装。安装方式:\n" "Twitter CLI 未安装。安装方式:\n"
" pipx install twitter-cli\n" " pipx install twitter-cli\n"
"或:\n" "或:\n"
" uv tool install twitter-cli" " uv tool install twitter-cli"
) )
try: def _check_twitter_cli(self):
r = subprocess.run( """探测 twitter-cli。返回 None 表示未安装,否则返回 (status, message)。
[twitter, "status"], capture_output=True,
encoding="utf-8", errors="replace", timeout=10 `twitter status` 才是健康信号:已登录时输出 "ok: true"
未登录时以非零退出码输出 "not_authenticated"——工具本身是活的,
所以 probe 的 error 状态也要看 output 内容再分类。
"""
probe = probe_command(
"twitter", ["status"], timeout=15, retries=1, package="twitter-cli"
) )
output = (r.stdout or "") + (r.stderr or "") if probe.status == "missing":
if r.returncode == 0 and "ok: true" in output: 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", ( return "ok", (
"完整可用(搜索、读推文、时间线、长文/Article、" "twitter-cli 完整可用(搜索、读推文、时间线、长文/Article、"
"用户查询、Thread" "用户查询、Thread"
) )
if "not_authenticated" in output: if "not_authenticated" in output:
@@ -49,5 +90,56 @@ class TwitterChannel(Channel):
"twitter-cli 已安装但认证检查失败。运行:\n" "twitter-cli 已安装但认证检查失败。运行:\n"
" twitter -v status 查看详细信息" " twitter -v status 查看详细信息"
) )
except Exception:
return "warn", "twitter-cli 已安装但连接失败" 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
+2
View File
@@ -41,8 +41,10 @@ class V2EXChannel(Channel):
_get_json( _get_json(
"https://www.v2ex.com/api/topics/show.json?node_name=python&page=1" "https://www.v2ex.com/api/topics/show.json?node_name=python&page=1"
) )
self.active_backend = self.backends[0]
return "ok", "公开 API 可用(热门主题、节点浏览、主题详情、用户信息)" return "ok", "公开 API 可用(热门主题、节点浏览、主题详情、用户信息)"
except Exception as e: except Exception as e:
self.active_backend = None
return "warn", f"V2EX API 连接失败(可能需要代理):{e}" return "warn", f"V2EX API 连接失败(可能需要代理):{e}"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
+17
View File
@@ -1,8 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Web — any URL via Jina Reader. Always available.""" """Web — any URL via Jina Reader. Always available."""
import urllib.request
from .base import Channel from .base import Channel
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
class WebChannel(Channel): class WebChannel(Channel):
name = "web" name = "web"
@@ -14,4 +17,18 @@ class WebChannel(Channel):
return True # Fallback — handles any URL return True # Fallback — handles any URL
def check(self, config=None): def check(self, config=None):
# 恒可用兜底渠道:无本地命令、不做网络探测(doctor 已有多个渠道触网),保持零开销
self.active_backend = self.backends[0]
return "ok", "通过 Jina Reader 读取任意网页(curl https://r.jina.ai/URL" 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")
-63
View File
@@ -1,63 +0,0 @@
# -*- coding: utf-8 -*-
"""WeChat Official Account articles — read and search.
Read: Exa crawling (primary) / Camoufox stealth browser (optional)
Search: Exa web_search with includeDomains mp.weixin.qq.com
"""
import shutil
import subprocess
from .base import Channel
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
class WeChatChannel(Channel):
name = "wechat"
description = "微信公众号文章"
backends = ["Exa via mcporter (搜索+阅读)", "Camoufox (可选阅读)"]
tier = 0
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
d = urlparse(url).netloc.lower()
return "mp.weixin.qq.com" in d or "weixin.qq.com" in d
def check(self, config=None):
has_exa = _exa_available()
has_camoufox = False
try:
import camoufox # noqa: F401
has_camoufox = True
except ImportError:
pass
if has_exa and has_camoufox:
return "ok", "完整可用(Exa 搜索 + Exa/Camoufox 阅读公众号文章)"
elif has_exa:
return "ok", (
"通过 Exa 搜索和阅读微信公众号文章(免费,无需额外配置)。"
"可选安装 Camoufox 获得更好的全文阅读效果。"
)
elif has_camoufox:
return "warn", (
"Camoufox 可阅读公众号文章,但搜索功能需要 Exa。"
"运行 `agent-reach install --env=auto` 安装 Exa。"
)
else:
return "off", (
"需要 mcporter + Exa MCP 来搜索和阅读微信公众号文章。\n"
"运行 `agent-reach install --env=auto` 安装。"
)
-52
View File
@@ -1,52 +0,0 @@
# -*- coding: utf-8 -*-
"""Weibo (微博) — check if mcporter + mcp-server-weibo is available."""
import shutil
import subprocess
from .base import Channel
class WeiboChannel(Channel):
name = "weibo"
description = "微博动态与热搜"
backends = ["mcp-server-weibo"]
tier = 1
def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse
d = urlparse(url).netloc.lower()
return "weibo.com" in d or "weibo.cn" in d
def check(self, config=None):
mcporter = shutil.which("mcporter")
if not mcporter:
return "off", (
"需要 mcporter + mcp-server-weibo。安装步骤:\n"
" 1. npm install -g mcporter\n"
" 2. pip install git+https://github.com/Panniantong/mcp-server-weibo.git\n"
" 3. mcporter config add weibo --command 'mcp-server-weibo'\n"
" 详见 https://github.com/Panniantong/mcp-server-weibo"
)
try:
r = subprocess.run(
[mcporter, "config", "list"], capture_output=True,
encoding="utf-8", errors="replace", timeout=5
)
if "weibo" not in r.stdout:
return "off", (
"mcporter 已装但微博 MCP 未配置。运行:\n"
" pip install git+https://github.com/Panniantong/mcp-server-weibo.git\n"
" mcporter config add weibo --command 'mcp-server-weibo'"
)
except Exception:
return "off", "mcporter 连接异常"
try:
r = subprocess.run(
[mcporter, "list", "weibo"], capture_output=True,
encoding="utf-8", errors="replace", timeout=15
)
if r.returncode == 0 and "search_users" in r.stdout:
return "ok", "完整可用(热搜、搜索、用户动态、评论)"
return "warn", "MCP 已配置但工具加载失败,检查 mcp-server-weibo 版本"
except Exception:
return "warn", "MCP 连接异常,检查 mcp-server-weibo 是否可用"
+119 -23
View File
@@ -1,10 +1,41 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""XiaoHongShu — check if xhs-cli (xiaohongshu-cli) is available.""" """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
import shutil
import subprocess
from .base import Channel 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): def format_xhs_result(data):
"""Clean XHS API response, keeping only useful fields. """Clean XHS API response, keeping only useful fields.
@@ -118,7 +149,7 @@ def _clean_comment(comment):
class XiaoHongShuChannel(Channel): class XiaoHongShuChannel(Channel):
name = "xiaohongshu" name = "xiaohongshu"
description = "小红书笔记" description = "小红书笔记"
backends = ["xhs-cli (xiaohongshu-cli)"] backends = ["OpenCLI", "xiaohongshu-mcp", "xhs-cli (xiaohongshu-cli)"]
tier = 1 tier = 1
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
@@ -127,28 +158,95 @@ class XiaoHongShuChannel(Channel):
return "xiaohongshu.com" in d or "xhslink.com" in d return "xiaohongshu.com" in d or "xhslink.com" in d
def check(self, config=None): def check(self, config=None):
xhs = shutil.which("xhs") """Probe candidates in order; first fully-usable backend wins.
if not xhs:
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", ( return "off", (
"需要安装 xhs-cli\n" "未安装任何小红书后端。推荐\n"
" pipx install xiaohongshu-cli\n" " 桌面:agent-reach install --channels opencli\n"
"或:\n" " (复用 Chrome 登录态,刷过小红书即零配置可用)\n"
" uv tool install xiaohongshu-cli\n" f" 服务器:xiaohongshu-mcp(自带无头浏览器+扫码登录):{_MCP_INSTALL_URL}"
"安装后运行 `xhs login` 登录"
) )
try: def _check_opencli(self):
r = subprocess.run( """OpenCLI candidate. None = not installed."""
[xhs, "status"], capture_output=True, from agent_reach.backends import opencli_status
encoding="utf-8", errors="replace", timeout=10,
) st = opencli_status()
output = (r.stdout or "") + (r.stderr or "") if not st.installed:
if r.returncode == 0 and "ok: true" in output: return None
if st.broken:
return "error", st.hint
if st.ready:
return "ok", ( return "ok", (
"完整可用(搜索、阅读、评论、发帖、热门、" "OpenCLI 可用(复用浏览器登录态)。用法:"
"收藏、关注、用户查询)" "opencli xiaohongshu search/note/comments/feed -f yaml"
) )
if "not_authenticated" in output or "expired" in output: 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", ( return "warn", (
"xhs-cli 已安装但未登录。运行:\n" "xhs-cli 已安装但未登录。运行:\n"
" xhs login\n" " xhs login\n"
@@ -158,5 +256,3 @@ class XiaoHongShuChannel(Channel):
"xhs-cli 已安装但状态异常。运行:\n" "xhs-cli 已安装但状态异常。运行:\n"
" xhs -v status 查看详细信息" " xhs -v status 查看详细信息"
) )
except Exception:
return "warn", "xhs-cli 已安装但连接失败"
+12 -3
View File
@@ -2,8 +2,8 @@
"""Xiaoyuzhou Podcast (小宇宙播客) — transcribe podcasts via Groq Whisper API.""" """Xiaoyuzhou Podcast (小宇宙播客) — transcribe podcasts via Groq Whisper API."""
import os import os
import shutil
from agent_reach.config import Config from agent_reach.config import Config
from agent_reach.probe import probe_command
from .base import Channel from .base import Channel
@@ -19,13 +19,21 @@ class XiaoyuzhouChannel(Channel):
return "xiaoyuzhoufm.com" in d return "xiaoyuzhoufm.com" in d
def check(self, config=None): def check(self, config=None):
# Check ffmpeg self.active_backend = None
if not shutil.which("ffmpeg"):
# 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", ( return "off", (
"需要 ffmpeg(音频转码和切片)。安装:\n" "需要 ffmpeg(音频转码和切片)。安装:\n"
" Ubuntu/Debian: apt install -y ffmpeg\n" " Ubuntu/Debian: apt install -y ffmpeg\n"
" macOS: brew install ffmpeg" " macOS: brew install ffmpeg"
) )
if not probe.ok:
return "error", (
"ffmpeg 无法执行,重装:brew install ffmpegmacOS/ apt install ffmpegLinux"
)
# Check script exists # Check script exists
script = os.path.expanduser("~/.agent-reach/tools/xiaoyuzhou/transcribe.sh") script = os.path.expanduser("~/.agent-reach/tools/xiaoyuzhou/transcribe.sh")
@@ -51,4 +59,5 @@ class XiaoyuzhouChannel(Channel):
" 2. 运行: agent-reach configure groq-key gsk_xxxxx" " 2. 运行: agent-reach configure groq-key gsk_xxxxx"
) )
self.active_backend = "groq-whisper"
return "ok", "完整可用(播客下载 + Whisper 转录)" return "ok", "完整可用(播客下载 + Whisper 转录)"
+2
View File
@@ -162,12 +162,14 @@ class XueqiuChannel(Channel):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def check(self, config=None): def check(self, config=None):
self.active_backend = None
try: try:
data = _get_json( data = _get_json(
"https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH000001" "https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH000001"
) )
items = (data.get("data") or {}).get("items") or [] items = (data.get("data") or {}).get("items") or []
if items: if items:
self.active_backend = self.backends[0]
return "ok", "公开 API 可用(行情、搜索、热帖、热股)" return "ok", "公开 API 可用(行情、搜索、热帖、热股)"
return "warn", "API 响应异常(返回数据为空)" return "warn", "API 响应异常(返回数据为空)"
except Exception as e: except Exception as e:
+53 -8
View File
@@ -3,12 +3,23 @@
import shutil 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.paths import get_ytdlp_config_path, render_ytdlp_fix_command
from agent_reach.utils.text import read_utf8_text from agent_reach.utils.text import read_utf8_text
from .base import Channel 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): class YouTubeChannel(Channel):
name = "youtube" name = "youtube"
description = "YouTube 视频和字幕" description = "YouTube 视频和字幕"
@@ -17,12 +28,25 @@ class YouTubeChannel(Channel):
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
from urllib.parse import urlparse from urllib.parse import urlparse
d = urlparse(url).netloc.lower() d = urlparse(url).netloc.lower()
return "youtube.com" in d or "youtu.be" in d return "youtube.com" in d or "youtu.be" in d
def check(self, config=None): def check(self, config=None):
if not shutil.which("yt-dlp"): # 真跑 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" 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 # Check JS runtime
has_js = shutil.which("deno") or shutil.which("node") has_js = shutil.which("deno") or shutil.which("node")
if not has_js: if not has_js:
@@ -35,12 +59,33 @@ class YouTubeChannel(Channel):
has_deno = shutil.which("deno") has_deno = shutil.which("deno")
if not has_deno: if not has_deno:
ytdlp_config = get_ytdlp_config_path() ytdlp_config = get_ytdlp_config_path()
has_js_config = False if not _has_js_runtime_config(ytdlp_config):
if ytdlp_config.exists():
has_js_config = "--js-runtimes" in read_utf8_text(ytdlp_config)
if not has_js_config:
return "warn", ( return "warn", (
"yt-dlp 已安装但未配置 JS runtime。运行:\n" f"yt-dlp 已安装但未配置 JS runtime。运行:\n {render_ytdlp_fix_command()}"
f" {render_ytdlp_fix_command()}"
) )
return "ok", "可提取视频信息和字幕" # 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)
+267 -177
View File
@@ -17,6 +17,9 @@ import time
from agent_reach import __version__ from agent_reach import __version__
# 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"
def _ensure_utf8_console(): def _ensure_utf8_console():
"""Best-effort Windows console UTF-8 setup for CLI runtime only.""" """Best-effort Windows console UTF-8 setup for CLI runtime only."""
@@ -63,20 +66,21 @@ def main():
p_install.add_argument("--env", choices=["local", "server", "auto"], default="auto", p_install.add_argument("--env", choices=["local", "server", "auto"], default="auto",
help="Environment: local, server, or auto-detect") help="Environment: local, server, or auto-detect")
p_install.add_argument("--proxy", default="", p_install.add_argument("--proxy", default="",
help="Residential proxy for Reddit/Bilibili (http://user:pass@ip:port)") help="Network proxy saved for agents to export as HTTP(S)_PROXY "
"in restricted networks (http://user:pass@ip:port)")
p_install.add_argument("--safe", action="store_true", p_install.add_argument("--safe", action="store_true",
help="Safe mode: skip automatic system changes, show what's needed instead") help="Safe mode: skip automatic system changes, show what's needed instead")
p_install.add_argument("--dry-run", action="store_true", p_install.add_argument("--dry-run", action="store_true",
help="Show what would be done without making any changes") help="Show what would be done without making any changes")
p_install.add_argument("--channels", default="", p_install.add_argument("--channels", default="",
help="Comma-separated optional channels to install " help="Comma-separated optional channels to install "
"(twitter,weibo,wechat,xiaoyuzhou,xueqiu,xiaohongshu," "(twitter,xiaoyuzhou,xueqiu,xiaohongshu,"
"reddit,bilibili,douyin,linkedin,all)") "reddit,bilibili,linkedin,all)")
# ── configure ── # ── configure ──
p_conf = sub.add_parser("configure", help="Set a config value or auto-extract from browser") p_conf = sub.add_parser("configure", help="Set a config value or auto-extract from browser")
p_conf.add_argument("key", nargs="?", default=None, p_conf.add_argument("key", nargs="?", default=None,
choices=["proxy", "github-token", "groq-key", choices=["proxy", "github-token", "groq-key", "openai-key",
"twitter-cookies", "youtube-cookies", "twitter-cookies", "youtube-cookies",
"xhs-cookies"], "xhs-cookies"],
help="What to configure (omit if using --from-browser)") help="What to configure (omit if using --from-browser)")
@@ -86,7 +90,9 @@ def main():
help="Auto-extract ALL platform cookies from browser (chrome/firefox/edge/brave/opera)") help="Auto-extract ALL platform cookies from browser (chrome/firefox/edge/brave/opera)")
# ── doctor ── # ── doctor ──
sub.add_parser("doctor", help="Check platform availability") p_doctor = sub.add_parser("doctor", help="Check platform availability")
p_doctor.add_argument("--json", action="store_true",
help="Output machine-readable JSON instead of the text report")
# ── uninstall ── # ── uninstall ──
p_uninstall = sub.add_parser("uninstall", help="Remove all Agent Reach config, tokens, and skill files") p_uninstall = sub.add_parser("uninstall", help="Remove all Agent Reach config, tokens, and skill files")
@@ -108,6 +114,14 @@ def main():
p_format.add_argument("platform", choices=["xhs"], help="Platform to format (xhs)") p_format.add_argument("platform", choices=["xhs"], help="Platform to format (xhs)")
# ── check-update ── # ── check-update ──
# ── transcribe ──
p_tr = sub.add_parser("transcribe", help="Transcribe a URL or local audio file (Whisper via Groq/OpenAI)")
p_tr.add_argument("source", help="Audio/video URL or local file path")
p_tr.add_argument("--provider", choices=["auto", "groq", "openai"], default="auto",
help="Transcription provider (default: auto = groq → openai fallback)")
p_tr.add_argument("-o", "--output", default=None,
help="Write transcript to a file instead of stdout")
sub.add_parser("check-update", help="Check for new versions and changes") sub.add_parser("check-update", help="Check for new versions and changes")
# ── watch ── # ── watch ──
@@ -130,7 +144,7 @@ def main():
sys.exit(0) sys.exit(0)
if args.command == "doctor": if args.command == "doctor":
_cmd_doctor() _cmd_doctor(args)
elif args.command == "check-update": elif args.command == "check-update":
_cmd_check_update() _cmd_check_update()
elif args.command == "watch": elif args.command == "watch":
@@ -147,6 +161,8 @@ def main():
_cmd_skill(args) _cmd_skill(args)
elif args.command == "format": elif args.command == "format":
_cmd_format(args) _cmd_format(args)
elif args.command == "transcribe":
_cmd_transcribe(args)
# ── Command handlers ──────────────────────────────── # ── Command handlers ────────────────────────────────
@@ -180,14 +196,13 @@ def _cmd_install(args):
# ── Parse --channels ── # ── Parse --channels ──
CHANNEL_INSTALLERS = { CHANNEL_INSTALLERS = {
"twitter": _install_twitter_deps, "twitter": _install_twitter_deps,
"weibo": _install_weibo_deps,
"wechat": _install_wechat_deps,
"xiaoyuzhou": _install_xiaoyuzhou_deps, "xiaoyuzhou": _install_xiaoyuzhou_deps,
"xiaohongshu": _install_xhs_deps, "xiaohongshu": _install_xhs_deps,
"reddit": _install_reddit_deps, "reddit": _install_reddit_deps,
"bilibili": _install_bili_deps, "bilibili": _install_bili_deps,
"opencli": _install_opencli_deps, # cross-channel backend, desktop only
# xueqiu: cookie-only, no install step # xueqiu: cookie-only, no install step
# douyin/linkedin: manual setup, no auto-install # linkedin: manual setup, no auto-install
} }
COOKIE_CHANNELS = {"twitter", "xueqiu", "bilibili"} COOKIE_CHANNELS = {"twitter", "xueqiu", "bilibili"}
@@ -195,7 +210,7 @@ def _cmd_install(args):
if args.channels: if args.channels:
raw = [c.strip().lower() for c in args.channels.split(",") if c.strip()] raw = [c.strip().lower() for c in args.channels.split(",") if c.strip()]
if "all" in raw: if "all" in raw:
requested_channels = set(CHANNEL_INSTALLERS.keys()) | {"xueqiu", "douyin", "linkedin"} requested_channels = set(CHANNEL_INSTALLERS.keys()) | {"xueqiu", "linkedin"}
else: else:
requested_channels = set(raw) requested_channels = set(raw)
@@ -212,10 +227,11 @@ def _cmd_install(args):
# Apply explicit flags # Apply explicit flags
if args.proxy: if args.proxy:
if dry_run: if dry_run:
print(f"[dry-run] Would configure proxy for Bilibili") print(f"[dry-run] Would save network proxy")
else: else:
config.set("bilibili_proxy", args.proxy) config.set("proxy", args.proxy)
print(f"✅ Proxy configured for Bilibili") config.set("bilibili_proxy", args.proxy) # legacy key
print(f"✅ 代理已保存(Agent 访问受限网络时使用)")
# ── Install core system dependencies (lightweight, always) ── # ── Install core system dependencies (lightweight, always) ──
print() print()
@@ -239,6 +255,10 @@ def _cmd_install(args):
if requested_channels and not dry_run and not safe_mode: if requested_channels and not dry_run and not safe_mode:
print() print()
print("Installing optional channels...") print("Installing optional channels...")
if env == "server" and "opencli" in requested_channels:
# OpenCLI rides a real desktop Chrome session — useless headless
requested_channels.discard("opencli")
print(" -- OpenCLI 需要桌面环境 + Chrome,服务器环境跳过")
for ch_name in sorted(requested_channels): for ch_name in sorted(requested_channels):
installer = CHANNEL_INSTALLERS.get(ch_name) installer = CHANNEL_INSTALLERS.get(ch_name)
if installer: if installer:
@@ -280,9 +300,9 @@ def _cmd_install(args):
# Environment-specific advice # Environment-specific advice
if env == "server": if env == "server":
print() print()
print("Tip: Bilibili may block server IPs.") print("Tip: 部分平台对服务器 IP 有风控。")
print(" Reddit: rdt-cli works without proxy (pipx install rdt-cli).") print(" Reddit 必须登录态(rdt-cli + Cookie,见 doctor 提示),中国大陆网络还需代理。")
print(" For Bilibili full access: agent-reach configure proxy http://user:pass@ip:port") print(" 保存代理供 Agent 使用:agent-reach configure proxy http://user:pass@ip:port")
print(" Cheap option: https://www.webshare.io ($1/month)") print(" Cheap option: https://www.webshare.io ($1/month)")
# Test channels # Test channels
@@ -307,7 +327,7 @@ def _cmd_install(args):
# First install — hint about optional channels # First install — hint about optional channels
print() print()
print("More channels available! Use --channels to install:") print("More channels available! Use --channels to install:")
print(" agent-reach install --channels=twitter,weibo,xiaohongshu,...") print(" agent-reach install --channels=twitter,xiaohongshu,reddit,...")
print(" agent-reach install --channels=all (install everything)") print(" agent-reach install --channels=all (install everything)")
# Star reminder # Star reminder
@@ -326,20 +346,50 @@ def _install_skill():
import shutil import shutil
import importlib.resources import importlib.resources
def _copy_skill_dir(target: str) -> bool: def _is_english_locale(value: str) -> bool:
"""Copy entire skill directory (SKILL.md + references/).""" normalized = value.strip().lower()
return normalized.startswith("en") or normalized.startswith("english")
def _skill_resource_name() -> str:
locale_candidates = (
os.environ.get("AGENT_REACH_LANG", ""),
os.environ.get("LC_ALL", ""),
os.environ.get("LC_MESSAGES", ""),
os.environ.get("LANG", ""),
)
if any(_is_english_locale(candidate) for candidate in locale_candidates):
return "SKILL_en.md"
return "SKILL.md"
def _read_skill_markdown(skill_pkg):
resource_name = _skill_resource_name()
try: try:
# Clear existing installation return skill_pkg.joinpath(resource_name).read_text(encoding="utf-8")
if os.path.exists(target): except FileNotFoundError:
return skill_pkg.joinpath("SKILL.md").read_text(encoding="utf-8")
def _copy_skill_dir(target: str) -> bool:
"""Copy entire skill directory (locale-specific SKILL.md + references/)."""
try:
# Clear existing installation. A symlinked skill dir (dotfiles
# setups) breaks shutil.rmtree — unlink the link itself instead.
if os.path.islink(target):
os.unlink(target)
elif os.path.exists(target):
shutil.rmtree(target) shutil.rmtree(target)
os.makedirs(target, exist_ok=True) os.makedirs(target, exist_ok=True)
# Get skill directory from package # Get skill directory from package (with fallback for editable installs)
try:
skill_pkg = importlib.resources.files("agent_reach").joinpath("skill") skill_pkg = importlib.resources.files("agent_reach").joinpath("skill")
skill_md = _read_skill_markdown(skill_pkg)
except Exception:
from pathlib import Path
skill_pkg = Path(__file__).resolve().parent / "skill"
skill_md = _read_skill_markdown(skill_pkg)
# Copy SKILL.md # Copy SKILL.md using the selected locale file
skill_md = skill_pkg.joinpath("SKILL.md").read_text() with open(os.path.join(target, "SKILL.md"), "w", encoding="utf-8") as f:
with open(os.path.join(target, "SKILL.md"), "w") as f:
f.write(skill_md) f.write(skill_md)
# Copy references/ directory # Copy references/ directory
@@ -348,9 +398,10 @@ def _install_skill():
os.makedirs(refs_target, exist_ok=True) os.makedirs(refs_target, exist_ok=True)
for ref_file in refs_pkg.iterdir(): for ref_file in refs_pkg.iterdir():
if ref_file.suffix == ".md": name = ref_file.name if hasattr(ref_file, 'name') else str(ref_file).split('/')[-1]
content = ref_file.read_text() if name.endswith(".md"):
with open(os.path.join(refs_target, ref_file.name), "w") as f: content = ref_file.read_text(encoding="utf-8") if hasattr(ref_file, 'read_text') else ref_file.read_text()
with open(os.path.join(refs_target, name), "w", encoding="utf-8") as f:
f.write(content) f.write(content)
return True return True
@@ -413,6 +464,9 @@ def _uninstall_skill():
skill_path = os.path.expanduser(skill_path_template) skill_path = os.path.expanduser(skill_path_template)
if os.path.isdir(skill_path): if os.path.isdir(skill_path):
try: try:
if os.path.islink(skill_path):
os.unlink(skill_path)
else:
shutil.rmtree(skill_path) shutil.rmtree(skill_path)
print(f" Removed {platform_name} skill: {skill_path}") print(f" Removed {platform_name} skill: {skill_path}")
removed = True removed = True
@@ -575,7 +629,7 @@ def _install_system_deps():
except Exception: except Exception:
print(" -- Could not configure yt-dlp JS runtime (YouTube may not work)") print(" -- Could not configure yt-dlp JS runtime (YouTube may not work)")
# NOTE: twitter-cli, weibo, xiaoyuzhou, wechat, xhs-cli etc. are optional. # NOTE: twitter-cli, xiaoyuzhou, xhs-cli etc. are optional.
# They are installed via --channels flag, not here. # They are installed via --channels flag, not here.
# See CHANNEL_INSTALLERS in _cmd_install(). # See CHANNEL_INSTALLERS in _cmd_install().
@@ -647,30 +701,99 @@ def _install_twitter_deps():
def _install_xhs_deps(): def _install_xhs_deps():
"""Install xhs-cli (xiaohongshu-cli) for XiaoHongShu.""" """Set up XiaoHongShu — backend depends on environment.
Desktop: OpenCLI (reuses the browser session, zero config).
Server: xiaohongshu-mcp guide (self-contained headless browser + QR
login; we don't manage long-running services, so guide only).
xhs-cli is no longer installed by default — upstream unmaintained
since 2026-03; existing installs keep working as a fallback backend.
"""
import shutil
print("Setting up XiaoHongShu...")
if _detect_environment() == "server":
print(" 服务器环境推荐 xiaohongshu-mcp(自带无头浏览器,扫码登录):")
print(" 1. 下载 binaryhttps://github.com/xpzouying/xiaohongshu-mcp/releases")
print(" (建议放到 ~/.agent-reach/tools/ 下)")
print(" 2. 启动服务(首次运行会下载约 150MB 浏览器,请等待完成)")
print(" 3. 扫码登录后接入:mcporter config add xiaohongshu http://localhost:18060/mcp")
print(" 4. 验证:agent-reach doctor")
return
_install_opencli_deps()
if shutil.which("xhs"):
print(" ✅ 检测到存量 xhs-cli,将作为备选后端继续可用")
def _install_opencli_deps():
"""Install OpenCLI — cross-platform backend riding the user's Chrome session.
Desktop-only. The npm package installs automatically; the Chrome
extension CANNOT be installed programmatically (Chrome security model),
so we print a one-click guide instead.
"""
import shutil import shutil
import subprocess import subprocess
print("Setting up XiaoHongShu (xhs-cli)...") from agent_reach.backends import (
if shutil.which("xhs"): OPENCLI_EXTENSION_URL,
print(" ✅ xhs-cli already installed") OPENCLI_PACKAGE,
opencli_status,
opencli_summary,
)
print("Setting up OpenCLI (browser-session backend, desktop only)...")
st = opencli_status()
if st.installed and not st.broken:
print(f"{opencli_summary(st)}")
if not st.ready:
print(f" {st.hint}")
return return
for tool, cmd in [("pipx", ["pipx", "install", "xiaohongshu-cli"]),
("uv", ["uv", "tool", "install", "xiaohongshu-cli"])]: if not shutil.which("npm"):
if shutil.which(tool): print(" [!] OpenCLI requires Node.js ≥ 20. Install Node first:")
print(" https://nodejs.org (或 brew install node")
return
try: try:
subprocess.run(cmd, capture_output=True, encoding="utf-8", subprocess.run(
errors="replace", timeout=120) ["npm", "install", "-g", OPENCLI_PACKAGE],
if shutil.which("xhs"): capture_output=True, encoding="utf-8", errors="replace", timeout=300,
print(" ✅ xhs-cli installed (run `xhs login` to authenticate)") )
return
except Exception: except Exception:
pass pass
print(" [!] xhs-cli install failed. Run: pipx install xiaohongshu-cli")
st = opencli_status()
if st.installed and not st.broken:
print(" ✅ OpenCLI installed")
print(" 最后一步(必须手动,Chrome 安全限制):安装浏览器扩展")
print(f" 1. 打开 {OPENCLI_EXTENSION_URL}")
print(" 2. 点「添加至 Chrome」")
print(" 3. 运行 `opencli doctor` 验证连接")
else:
print(f" [!] OpenCLI install failed. Run: npm install -g {OPENCLI_PACKAGE}")
def _install_reddit_deps(): def _install_reddit_deps():
"""Install rdt-cli for Reddit search + reading.""" """Set up Reddit — desktop prefers OpenCLI, rdt-cli for servers/legacy.
No zero-config path exists (anonymous .json blocked, official API
approval-gated since 2025-11) — every backend needs a logged-in session.
"""
if _detect_environment() != "server":
_install_opencli_deps()
print(" Reddit 走 OpenCLI(浏览器里登录过 reddit.com 即可用)")
import shutil
if shutil.which("rdt"):
print(" ✅ 检测到存量 rdt-cli,将作为备选后端继续可用")
return
_install_rdt_cli()
def _install_rdt_cli():
"""Install rdt-cli (pinned git source — PyPI lags upstream)."""
import shutil import shutil
import subprocess import subprocess
@@ -678,8 +801,10 @@ def _install_reddit_deps():
if shutil.which("rdt"): if shutil.which("rdt"):
print(" ✅ rdt-cli already installed") print(" ✅ rdt-cli already installed")
return return
for tool, cmd in [("pipx", ["pipx", "install", "rdt-cli"]), for tool, cmd in [
("uv", ["uv", "tool", "install", "rdt-cli"])]: ("pipx", ["pipx", "install", _RDT_GIT_SOURCE]),
("uv", ["uv", "tool", "install", "--from", _RDT_GIT_SOURCE, "rdt-cli"]),
]:
if shutil.which(tool): if shutil.which(tool):
try: try:
subprocess.run(cmd, capture_output=True, encoding="utf-8", subprocess.run(cmd, capture_output=True, encoding="utf-8",
@@ -689,7 +814,7 @@ def _install_reddit_deps():
return return
except Exception: except Exception:
pass pass
print(" [!] rdt-cli install failed. Run: pipx install rdt-cli") print(f" [!] rdt-cli install failed. Run: pipx install '{_RDT_GIT_SOURCE}'")
def _install_bili_deps(): def _install_bili_deps():
@@ -715,123 +840,6 @@ def _install_bili_deps():
print(" [!] bili-cli install failed. Run: pipx install bilibili-cli") print(" [!] bili-cli install failed. Run: pipx install bilibili-cli")
def _install_weibo_deps():
"""Install Weibo MCP server (Panniantong fork with visitor passport auth)."""
import shutil
import subprocess
print("Setting up Weibo MCP server...")
# Check if already installed and working
mcporter = shutil.which("mcporter")
if mcporter:
try:
r = subprocess.run(
[mcporter, "config", "list"], capture_output=True,
encoding="utf-8", errors="replace", timeout=5
)
if "weibo" in r.stdout:
print(" ✅ Weibo MCP already configured")
return
except Exception:
pass
# Install from our fork (has visitor passport auth fix)
try:
subprocess.run(
[sys.executable, "-m", "pip", "install", "-q",
"git+https://github.com/Panniantong/mcp-server-weibo.git"],
check=True, timeout=120
)
print(" ✅ mcp-server-weibo installed (Panniantong fork)")
except Exception as e:
print(f" [!] mcp-server-weibo install failed: {e}")
return
# Register with mcporter
if mcporter:
try:
subprocess.run(
[mcporter, "config", "add", "weibo", "--command", "mcp-server-weibo"],
check=True, capture_output=True, timeout=10
)
print(" ✅ Weibo MCP registered with mcporter")
except Exception:
print(" [!] mcporter config add failed. Run manually: mcporter config add weibo --command 'mcp-server-weibo'")
else:
print(" -- mcporter not found, skipping MCP registration. Install mcporter first, then run: mcporter config add weibo --command 'mcp-server-weibo'")
def _install_wechat_deps():
"""Install WeChat article reading and search dependencies."""
import subprocess
print("Setting up WeChat article tools...")
# Check if already installed
has_camoufox = False
has_miku = False
try:
import camoufox # noqa: F401
has_camoufox = True
except ImportError:
pass
try:
import miku_ai # noqa: F401
has_miku = True
except ImportError:
pass
# Install Python packages
if has_camoufox and has_miku:
print(" ✅ WeChat Python packages already installed")
else:
pkgs = []
if not has_camoufox:
pkgs.extend(["camoufox[geoip]", "markdownify", "beautifulsoup4", "httpx"])
if not has_miku:
pkgs.append("miku_ai")
try:
cmd = [sys.executable, "-m", "pip", "install", "--break-system-packages", "-q"] + pkgs
subprocess.run(cmd, capture_output=True, encoding="utf-8", errors="replace", timeout=120)
# Verify
ok = True
try:
import importlib
if not has_camoufox:
importlib.import_module("camoufox")
if not has_miku:
importlib.import_module("miku_ai")
except ImportError:
ok = False
if ok:
print(f" ✅ WeChat Python packages installed ({', '.join(pkgs)})")
else:
print(f" [!] Some WeChat packages failed to install. Try: pip install {' '.join(pkgs)}")
except Exception:
print(f" [!] WeChat packages install failed. Try: pip install {' '.join(pkgs)}")
# Clone wechat-article-for-ai tool
tools_dir = os.path.expanduser("~/.agent-reach/tools")
wechat_dir = os.path.join(tools_dir, "wechat-article-for-ai")
if os.path.isfile(os.path.join(wechat_dir, "main.py")):
print(" ✅ wechat-article-for-ai tool already installed")
else:
try:
os.makedirs(tools_dir, exist_ok=True)
subprocess.run(
["git", "clone", "--depth", "1",
"https://github.com/Panniantong/wechat-article-for-ai.git", wechat_dir],
capture_output=True, encoding="utf-8", errors="replace", timeout=60,
)
if os.path.isfile(os.path.join(wechat_dir, "main.py")):
print(" ✅ wechat-article-for-ai tool installed")
else:
print(" [!] wechat-article-for-ai clone failed. Try: git clone https://github.com/Panniantong/wechat-article-for-ai.git " + wechat_dir)
except Exception:
print(" [!] wechat-article-for-ai clone failed. Try: git clone https://github.com/Panniantong/wechat-article-for-ai.git " + wechat_dir)
def _install_system_deps_safe(): def _install_system_deps_safe():
"""Safe mode: check what's installed, print instructions for what's missing.""" """Safe mode: check what's installed, print instructions for what's missing."""
import shutil import shutil
@@ -1030,9 +1038,14 @@ def _cmd_configure(args):
return return
if args.key == "proxy": if args.key == "proxy":
# Generic network proxy for restricted environments. Nothing reads
# this key at runtime — agents read it back and export HTTP(S)_PROXY
# before invoking upstream tools (see docs/install.md). The legacy
# bilibili_proxy key is kept in sync for older configs.
config.set("proxy", value)
config.set("bilibili_proxy", value) config.set("bilibili_proxy", value)
print(f"Proxy configured for Bilibili!") print("代理已保存(供 Agent 在访问 Reddit/Twitter 等需要代理的网络时设置 HTTP_PROXY/HTTPS_PROXY")
print(" Note: Reddit 已改为通过 rdt-cli 访问,无需代理。") print(" Note: B站走 bili-cli,国内网络无需代理。")
elif args.key == "twitter-cookies": elif args.key == "twitter-cookies":
# Accept two formats: # Accept two formats:
@@ -1092,6 +1105,29 @@ def _cmd_configure(args):
config.set("groq_api_key", value) config.set("groq_api_key", value)
print(f"✅ Groq key configured!") print(f"✅ Groq key configured!")
elif args.key == "openai-key":
config.set("openai_api_key", value)
print(f"✅ OpenAI key configured!")
def _cmd_transcribe(args):
"""Transcribe a URL or local audio file via Whisper (Groq → OpenAI fallback)."""
from pathlib import Path
from agent_reach.transcribe import TranscribeError, transcribe
try:
text = transcribe(args.source, provider=args.provider)
except TranscribeError as e:
print(f"{e}")
sys.exit(1)
if args.output:
Path(args.output).write_text(text + "\n", encoding="utf-8")
print(f"✅ Transcript written to {args.output}")
else:
print(text)
def _parse_twitter_cookie_input(value: str): def _parse_twitter_cookie_input(value: str):
"""Parse Twitter cookie input from either separate values or a cookie header.""" """Parse Twitter cookie input from either separate values or a cookie header."""
@@ -1197,11 +1233,28 @@ def _configure_xhs_cookies(value):
# Find the container # Find the container
docker = shutil.which("docker") docker = shutil.which("docker")
if not docker: if not docker:
# No Docker - write to a local file for manual import # No Docker - write to a local file for manual import.
# Create with 0o600 atomically so the file is never world-readable
# between open() and a follow-up chmod() (same pattern Config.save()
# uses in config.py).
import stat
cookie_path = os.path.expanduser("~/.agent-reach/xhs-cookies.json") cookie_path = os.path.expanduser("~/.agent-reach/xhs-cookies.json")
with open(cookie_path, "w") as f: try:
fd = os.open(
cookie_path,
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
stat.S_IRUSR | stat.S_IWUSR, # 0o600
)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(cookies_json) f.write(cookies_json)
except OSError:
# Windows / unsupported flags — fall back to plain open + chmod.
with open(cookie_path, "w", encoding="utf-8") as f:
f.write(cookies_json)
try:
os.chmod(cookie_path, 0o600) os.chmod(cookie_path, 0o600)
except OSError:
pass
print(f" Cookies saved to {cookie_path}") print(f" Cookies saved to {cookie_path}")
print(" Docker not found. Copy manually:") print(" Docker not found. Copy manually:")
print(f" docker cp {cookie_path} xiaohongshu-mcp:/app/data/cookies.json") print(f" docker cp {cookie_path} xiaohongshu-mcp:/app/data/cookies.json")
@@ -1341,6 +1394,9 @@ def _cmd_uninstall(args):
print(f"[dry-run] Would remove {platform_name} skill: {skill_path}") print(f"[dry-run] Would remove {platform_name} skill: {skill_path}")
else: else:
try: try:
if os.path.islink(skill_path):
os.unlink(skill_path)
else:
shutil.rmtree(skill_path) shutil.rmtree(skill_path)
print(f" Removed {platform_name} skill: {skill_path}") print(f" Removed {platform_name} skill: {skill_path}")
removed_any = True removed_any = True
@@ -1388,7 +1444,7 @@ def _cmd_uninstall(args):
print(" npm uninstall -g undici") print(" npm uninstall -g undici")
def _cmd_doctor(): def _cmd_doctor(args=None):
from agent_reach.config import Config from agent_reach.config import Config
from agent_reach.doctor import check_all, format_report from agent_reach.doctor import check_all, format_report
try: try:
@@ -1397,6 +1453,11 @@ def _cmd_doctor():
rprint = print rprint = print
config = Config() config = Config()
results = check_all(config) results = check_all(config)
if args is not None and getattr(args, "json", False):
print(json.dumps(results, ensure_ascii=False, indent=2))
return
rprint(format_report(results)) rprint(format_report(results))
# Auto-install skill if not already present (fixes #154) # Auto-install skill if not already present (fixes #154)
@@ -1466,8 +1527,9 @@ def _cmd_setup():
print() print()
# Step 3: Reddit — rdt-cli # Step 3: Reddit — rdt-cli
print("【信息】Reddit — 通过 rdt-cli 搜索和阅读,无需配置") print("【信息】Reddit — 必须登录态(无零配置路径)。桌面推荐 OpenCLI;或 rdt-cli")
print(" 安装:pipx install rdt-cli") print(f" 安装:pipx install '{_RDT_GIT_SOURCE}'")
print(" 然后运行:rdt login(需先在浏览器登录 reddit.com)")
print() print()
# Step 4: Groq (Whisper) # Step 4: Groq (Whisper)
@@ -1583,6 +1645,35 @@ def _github_get_with_retry(url, timeout=10, retries=3, sleeper=time.sleep):
return None, "unknown", retries return None, "unknown", retries
#: Full update = package + upstream tools + skill. The one-liner walks an
#: agent through all three (docs/update.md); bare pip only updates the package.
_UPDATE_INSTRUCTIONS = (
"更新方式(推荐,复制这句话给你的 AI Agent,会完整更新本体+上游工具+skill):\n"
" 帮我更新 Agent Reachhttps://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md\n"
"仅更新本体(不含上游工具和 skill):\n"
" pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip"
)
def _is_newer_version(remote: str, local: str) -> bool:
"""True if remote is strictly newer than local (semantic compare).
A plain != would tell users "update available" when their local build is
AHEAD of the latest release (e.g. installed from main during a release
window) — and walk them into a downgrade.
"""
def parse(v):
try:
return tuple(int(x) for x in v.strip().split("."))
except ValueError:
return None
r, l = parse(remote), parse(local)
if r is None or l is None:
return remote != local # unparseable — fall back to old behavior
return r > l
def _cmd_check_update(): def _cmd_check_update():
"""Check for newer versions on GitHub.""" """Check for newer versions on GitHub."""
from agent_reach import __version__ from agent_reach import __version__
@@ -1602,7 +1693,7 @@ def _cmd_check_update():
latest = data.get("tag_name", "").lstrip("v") latest = data.get("tag_name", "").lstrip("v")
body = data.get("body", "") body = data.get("body", "")
if latest and latest != __version__: if latest and _is_newer_version(latest, __version__):
print(f"最新版本: v{latest} ← 有更新!") print(f"最新版本: v{latest} ← 有更新!")
if body: if body:
print() print()
@@ -1611,8 +1702,7 @@ def _cmd_check_update():
for line in body.strip().split("\n")[:20]: for line in body.strip().split("\n")[:20]:
print(f" {line}") print(f" {line}")
print() print()
print("更新命令:") print(_UPDATE_INSTRUCTIONS)
print(" pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip")
return "update_available" return "update_available"
print(f"✅ 已是最新版本") print(f"✅ 已是最新版本")
return "up_to_date" return "up_to_date"
@@ -1634,8 +1724,7 @@ def _cmd_check_update():
date = commit.get("commit", {}).get("committer", {}).get("date", "")[:10] date = commit.get("commit", {}).get("committer", {}).get("date", "")[:10]
print(f"最新提交: {sha} ({date}) {msg}") print(f"最新提交: {sha} ({date}) {msg}")
print() print()
print("更新命令:") print(_UPDATE_INSTRUCTIONS)
print(" pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip")
return "unknown" return "unknown"
commit_err = _classify_github_response_error(resp2) commit_err = _classify_github_response_error(resp2)
@@ -1683,7 +1772,7 @@ def _cmd_watch():
if not err and resp and resp.status_code == 200: if not err and resp and resp.status_code == 200:
data = resp.json() data = resp.json()
latest = data.get("tag_name", "").lstrip("v") latest = data.get("tag_name", "").lstrip("v")
if latest and latest != __version__: if latest and _is_newer_version(latest, __version__):
update_available = True update_available = True
new_version = latest new_version = latest
release_body = data.get("body", "") release_body = data.get("body", "")
@@ -1708,7 +1797,8 @@ def _cmd_watch():
if release_body: if release_body:
for line in release_body.strip().split("\n")[:10]: for line in release_body.strip().split("\n")[:10]:
print(f" {line}") print(f" {line}")
print(f" 更新: pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip") print(" 更新(一句话发给 Agent 即可完整更新):")
print(" 帮我更新 Agent Reachhttps://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md")
if __name__ == "__main__": if __name__ == "__main__":
+2 -1
View File
@@ -21,8 +21,9 @@ class Config:
# Feature → required config keys # Feature → required config keys
FEATURE_REQUIREMENTS = { FEATURE_REQUIREMENTS = {
"exa_search": ["exa_api_key"], "exa_search": ["exa_api_key"],
"twitter_xreach": ["twitter_auth_token", "twitter_ct0"], # legacy key name; used by bird CLI "twitter_xreach": ["twitter_auth_token", "twitter_ct0"], # legacy key name; used by twitter-cli
"groq_whisper": ["groq_api_key"], "groq_whisper": ["groq_api_key"],
"openai_whisper": ["openai_api_key"],
"github_token": ["github_token"], "github_token": ["github_token"],
} }
+29 -6
View File
@@ -148,6 +148,28 @@ def extract_all(browser: str = "chrome") -> Dict[str, dict]:
return results return results
def _open_owner_only(path: str):
"""Open *path* for writing, atomically creating it with mode 0o600.
Mirrors the pattern used by Config.save() in config.py: O_WRONLY|O_CREAT|
O_TRUNC + an explicit mode argument so the file is never briefly
world-readable between open() and a later os.chmod(). On Windows (or any
OS that rejects the open flags) we fall back to a plain open().
"""
import os
import stat
try:
fd = os.open(
path,
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
stat.S_IRUSR | stat.S_IWUSR, # 0o600
)
return os.fdopen(fd, "w", encoding="utf-8")
except OSError:
return open(path, "w", encoding="utf-8")
def _sync_xfetch_session(auth_token: str, ct0: str) -> None: def _sync_xfetch_session(auth_token: str, ct0: str) -> None:
"""Sync Twitter credentials to ~/.config/xfetch/session.json (legacy xreach compat).""" """Sync Twitter credentials to ~/.config/xfetch/session.json (legacy xreach compat)."""
import json import json
@@ -166,9 +188,8 @@ def _sync_xfetch_session(auth_token: str, ct0: str) -> None:
session_data = {} session_data = {}
session_data["authToken"] = auth_token session_data["authToken"] = auth_token
session_data["ct0"] = ct0 session_data["ct0"] = ct0
with open(session_path, "w", encoding="utf-8") as sf: with _open_owner_only(session_path) as sf:
json.dump(session_data, sf, indent=2) json.dump(session_data, sf, indent=2)
os.chmod(session_path, 0o600)
except Exception: except Exception:
# Non-fatal: agent-reach config is the source of truth, xfetch sync is best-effort # Non-fatal: agent-reach config is the source of truth, xfetch sync is best-effort
pass pass
@@ -179,17 +200,19 @@ def _sync_bird_env(auth_token: str, ct0: str) -> None:
bird reads AUTH_TOKEN and CT0 from environment variables. This writes a bird reads AUTH_TOKEN and CT0 from environment variables. This writes a
shell-sourceable file so users can `source ~/.config/bird/credentials.env`. shell-sourceable file so users can `source ~/.config/bird/credentials.env`.
Values are passed through shlex.quote so a token containing a quote, $, or
backtick cannot break out into shell syntax when the file is sourced.
""" """
import os import os
import shlex
try: try:
bird_dir = os.path.join(os.path.expanduser("~"), ".config", "bird") bird_dir = os.path.join(os.path.expanduser("~"), ".config", "bird")
os.makedirs(bird_dir, exist_ok=True) os.makedirs(bird_dir, exist_ok=True)
env_path = os.path.join(bird_dir, "credentials.env") env_path = os.path.join(bird_dir, "credentials.env")
with open(env_path, "w", encoding="utf-8") as f: with _open_owner_only(env_path) as f:
f.write(f'AUTH_TOKEN="{auth_token}"\n') f.write(f"AUTH_TOKEN={shlex.quote(auth_token)}\n")
f.write(f'CT0="{ct0}"\n') f.write(f"CT0={shlex.quote(ct0)}\n")
os.chmod(env_path, 0o600)
except Exception: except Exception:
# Non-fatal: agent-reach config is the source of truth, bird env sync is best-effort # Non-fatal: agent-reach config is the source of truth, bird env sync is best-effort
pass pass
+1 -1
View File
@@ -3,7 +3,7 @@
AgentReach — installer, doctor, and configuration tool. AgentReach — installer, doctor, and configuration tool.
Agent Reach helps AI agents install and configure upstream platform tools Agent Reach helps AI agents install and configure upstream platform tools
(bird CLI, yt-dlp, mcporter, gh CLI, etc.). After installation, agents (twitter-cli, yt-dlp, mcporter, gh CLI, etc.). After installation, agents
call the upstream tools directly — no wrapper layer needed. call the upstream tools directly — no wrapper layer needed.
Usage: Usage:
+25 -6
View File
@@ -10,20 +10,40 @@ from agent_reach.channels import get_all_channels
def check_all(config: Config) -> Dict[str, dict]: def check_all(config: Config) -> Dict[str, dict]:
"""Check all channels and return status dict.""" """Check all channels and return status dict.
A single misbehaving channel must never take the whole report down,
so per-channel exceptions degrade to status="error".
"""
results = {} results = {}
for ch in get_all_channels(): for ch in get_all_channels():
try:
status, message = ch.check(config) status, message = ch.check(config)
active = getattr(ch, "active_backend", None)
except Exception as e: # noqa: BLE001 — doctor must survive any channel
# Channels are registry singletons: a stale active_backend from a
# previous check must not leak into an errored result.
status, message, active = "error", f"体检异常:{e}", None
results[ch.name] = { results[ch.name] = {
"status": status, "status": status,
"name": ch.description, "name": ch.description,
"message": message, "message": message,
"tier": ch.tier, "tier": ch.tier,
"backends": ch.backends, "backends": ch.backends,
"active_backend": active,
} }
return results return results
def _name_msg(r: dict, escape) -> str:
"""Render one channel line; show the active backend when there is a choice."""
text = f"[bold]{escape(r['name'])}[/bold] — {escape(r['message'])}"
active = r.get("active_backend")
if active and len(r.get("backends", [])) > 1:
text += f" [dim](当前后端:{escape(active)}[/dim]"
return text
def format_report(results: Dict[str, dict]) -> str: def format_report(results: Dict[str, dict]) -> str:
"""Format results as a readable text report (with Rich markup).""" """Format results as a readable text report (with Rich markup)."""
try: try:
@@ -34,6 +54,7 @@ def format_report(results: Dict[str, dict]) -> str:
lines = [] lines = []
lines.append("[bold cyan]Agent Reach 状态[/bold cyan]") lines.append("[bold cyan]Agent Reach 状态[/bold cyan]")
lines.append("[cyan]" + "=" * 40 + "[/cyan]") lines.append("[cyan]" + "=" * 40 + "[/cyan]")
lines.append("图例:[green]✅[/green] 可用 [yellow][!][/yellow] 已装但需配置/登录 [red][X][/red] 未安装")
ok_count = sum(1 for r in results.values() if r["status"] == "ok") ok_count = sum(1 for r in results.values() if r["status"] == "ok")
total = len(results) total = len(results)
@@ -43,7 +64,7 @@ def format_report(results: Dict[str, dict]) -> str:
lines.append("[bold]✅ 装好即用:[/bold]") lines.append("[bold]✅ 装好即用:[/bold]")
for key, r in results.items(): for key, r in results.items():
if r["tier"] == 0: if r["tier"] == 0:
name_msg = f"[bold]{escape(r['name'])}[/bold] — {escape(r['message'])}" name_msg = _name_msg(r, escape)
if r["status"] == "ok": if r["status"] == "ok":
lines.append(f" [green]✅[/green] {name_msg}") lines.append(f" [green]✅[/green] {name_msg}")
elif r["status"] == "warn": elif r["status"] == "warn":
@@ -59,8 +80,7 @@ def format_report(results: Dict[str, dict]) -> str:
lines.append("") lines.append("")
lines.append("[bold]可选渠道(已安装):[/bold]") lines.append("[bold]可选渠道(已安装):[/bold]")
for key, r in tier1_active.items(): for key, r in tier1_active.items():
name_msg = f"[bold]{escape(r['name'])}[/bold] — {escape(r['message'])}" lines.append(f" [green]✅[/green] {_name_msg(r, escape)}")
lines.append(f" [green]✅[/green] {name_msg}")
# Tier 2 — optional complex setup # Tier 2 — optional complex setup
tier2 = {k: r for k, r in results.items() if r["tier"] == 2} tier2 = {k: r for k, r in results.items() if r["tier"] == 2}
@@ -71,8 +91,7 @@ def format_report(results: Dict[str, dict]) -> str:
lines.append("") lines.append("")
lines.append("[bold]可选渠道(已安装):[/bold]") lines.append("[bold]可选渠道(已安装):[/bold]")
for key, r in tier2_active.items(): for key, r in tier2_active.items():
name_msg = f"[bold]{escape(r['name'])}[/bold] — {escape(r['message'])}" lines.append(f" [green]✅[/green] {_name_msg(r, escape)}")
lines.append(f" [green]✅[/green] {name_msg}")
lines.append("") lines.append("")
status_color = "green" if ok_count == total else ("yellow" if ok_count > 0 else "red") status_color = "green" if ok_count == total else ("yellow" if ok_count > 0 else "red")
+22 -13
View File
@@ -4,42 +4,51 @@
Reddit 封锁了几乎所有非浏览器的直接访问(包括数据中心和 ISP 代理 IP),JSON API 返回 403。 Reddit 封锁了几乎所有非浏览器的直接访问(包括数据中心和 ISP 代理 IP),JSON API 返回 403。
Agent Reach 通过 **Exa** 实现 Reddit 的搜索和阅读功能: Agent Reach 通过 **rdt-cli** 实现 Reddit 的搜索和阅读功能:
- **搜索**`web_search_exa` + `includeDomains: ["reddit.com"]` - **搜索**`rdt search "关键词"`
- **阅读完整帖子+评论**`crawling_exa` 读取 Reddit URL 的缓存内容 - **阅读完整帖子+评论**`rdt read POST_ID`
免费,无需代理,无需 API Key。 免费,无需代理,无需 API Key。需要登录认证(`rdt login`,自动从浏览器提取 Cookie)。
## Agent 可自动完成的步骤 ## Agent 可自动完成的步骤
1. 检查 Exa 是否可用: 1. 检查 rdt-cli 是否可用:
```bash ```bash
agent-reach doctor | grep -E "Reddit|Exa" which rdt && echo "installed" || echo "not installed"
``` ```
2. 如果 Exa 未安装,自动安装: 2. 如果未安装,自动安装(PyPI 版本暂时落后,从 GitHub 安装最新版)
```bash ```bash
npm install -g mcporter pipx install 'git+https://github.com/public-clis/rdt-cli.git'
mcporter config add exa https://mcp.exa.ai/mcp
``` ```
或一键安装: 或一键安装:
```bash ```bash
agent-reach install --env=auto agent-reach install --env=auto --channels=reddit
``` ```
## 使用示例 ## 使用示例
搜索 Reddit 内容: 搜索 Reddit 内容:
```bash ```bash
mcporter call 'exa.web_search_exa(query: "python best practices", numResults: 5, includeDomains: ["reddit.com"])' rdt search "python best practices" -n 5
``` ```
阅读完整帖子和评论: 阅读完整帖子和评论:
```bash ```bash
mcporter call 'exa.crawling_exa(urls: ["https://www.reddit.com/r/python/comments/xxx/"], maxCharacters: 10000)' rdt read POST_ID
``` ```
## 需要用户手动做的步骤 ## 需要用户手动做的步骤
无。Exa 通过 `agent-reach install --env=auto` 自动配置 无。rdt-cli 通过 `agent-reach install --env=auto` 自动安装
## FallbackExa 搜索
如果你已经配置了 Exa(通过 mcporter),也可以通过 Exa 搜索 Reddit 内容:
```bash
mcporter call 'exa.web_search_exa(query: "python best practices", numResults: 5, includeDomains: ["reddit.com"])'
```
rdt-cli 是当前推荐方案,无需额外配置即可使用。
+21 -18
View File
@@ -1,35 +1,34 @@
# Twitter 高级功能配置指南(bird CLI # Twitter 高级功能配置指南(twitter-cli
Twitter 基础阅读通过 Jina Reader 免费可用,无需配置。 Twitter 基础阅读通过 Jina Reader 免费可用,无需配置。
高级功能需要 bird CLI@steipete/bird): 高级功能需要 twitter-cli@public-clis/twitter-cli):
- 搜索推文(`bird search` - 搜索推文(`twitter search`
- 读取完整推文和对话链(`bird read``bird thread` - 读取完整推文和对话链(`twitter tweet``twitter thread`
- 用户时间线(`bird user-tweets` - 用户时间线(`twitter timeline`
- 长文阅读(`twitter article`
bird 是免费开源工具(npm 包 @steipete/bird),但需要你的 Twitter 账号 cookie。 twitter-cli 是免费开源工具(pipx 安装),但需要你的 Twitter 账号 cookie。
## 快速配置 ## 快速配置
1. 检查 bird 是否安装: 1. 检查 twitter-cli 是否安装:
```bash ```bash
which bird && echo "installed" || echo "not installed" which twitter && echo "installed" || echo "not installed"
``` ```
2. 安装 bird 2. 安装 twitter-cli
```bash ```bash
npm install -g @steipete/bird pipx install twitter-cli
``` ```
> 备选包:`npm install -g @connormartin/bird`
3. 测试是否配置好: 3. 测试是否配置好:
```bash ```bash
AUTH_TOKEN="xxx" CT0="yyy" bird search "test" -n 1 twitter search "test" -n 1
``` ```
## 获取 CookieCookie-Editor 方式,推荐) ## 获取 CookieCookie-Editor 方式,推荐)
@@ -49,7 +48,7 @@ agent-reach configure twitter-cookies "粘贴的 cookie JSON"
如果你已经知道 `auth_token``ct0` 如果你已经知道 `auth_token``ct0`
1. 安装 bird(如果没装):`npm install -g @steipete/bird` 1. 安装 twitter-cli(如果没装):`pipx install twitter-cli`
2. 设置环境变量: 2. 设置环境变量:
@@ -61,21 +60,25 @@ export CT0="你的ct0"
3. 测试: 3. 测试:
```bash ```bash
bird search "test" -n 1 twitter search "test" -n 1
``` ```
## 代理配置 ## 代理配置
> bird CLI 支持通过环境变量设置代理: > twitter-cli 支持通过环境变量设置代理:
```bash ```bash
export HTTP_PROXY="http://user:pass@host:port" export HTTP_PROXY="http://user:pass@host:port"
export HTTPS_PROXY="http://user:pass@host:port" export HTTPS_PROXY="http://user:pass@host:port"
bird search "test" -n 1 twitter search "test" -n 1
``` ```
也可以使用全局代理工具: 也可以使用全局代理工具:
```bash ```bash
proxychains bird search "test" -n 1 proxychains twitter search "test" -n 1
``` ```
## Fallbackbird CLI
如果你已经安装了 [bird CLI](https://www.npmjs.com/package/@steipete/bird)`npm install -g @steipete/bird`),它也能正常工作。Agent Reach 会自动检测并使用已安装的 bird。两者功能类似,twitter-cli 是当前推荐方案。
-40
View File
@@ -1,40 +0,0 @@
# 微信公众号配置指南
## 功能说明
读取微信公众号文章。需要 Playwright 来处理微信的反爬机制。
## Agent 可自动完成的步骤
1. 检查 Playwright 是否安装:
```bash
python3 -c "import playwright; print('installed')" 2>&1
```
2. 安装 Playwright + 浏览器:
```bash
pip install playwright
playwright install chromium
```
3. 安装完成后测试:
```bash
curl -s "https://r.jina.ai/https://mp.weixin.qq.com/s/一个测试链接" -H "Accept: text/markdown"
```
## 需要用户手动做的步骤
请告诉用户:
> 微信公众号的配置很简单,只需要安装一个浏览器组件(约 150MB)。
>
> 我来帮你安装,你不需要做任何事情。安装过程大约 1-2 分钟。
>
> 安装好之后就可以直接读取微信公众号文章了,不需要登录。
## Agent 操作流程
1. 安装 Playwright`pip install playwright`
2. 安装 Chromium`playwright install chromium`
3. 测试:读一篇微信文章
4. 反馈:"✅ 微信公众号已配置!发给我任何公众号文章链接,我都能读取。"
5. 如果安装失败(空间不足等):"❌ 浏览器组件安装失败。可能是磁盘空间不足(需要约 150MB)。"
+45 -56
View File
@@ -1,51 +1,36 @@
# 小红书配置指南 # 小红书配置指南
## 功能说明 ## 功能说明
读取和搜索小红书笔记。通过 [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) 实现(⭐9K+,Go 语言,内置 Chrome 浏览器) 读取和搜索小红书笔记。通过 [xhs-cli](https://github.com/jackwener/xiaohongshu-cli)(⭐1.5K,pipx 一行安装)实现
## 前置条件 ## 前置条件
- Docker(用来运行 xiaohongshu-mcp 服务 - Python 3.10+pipx 安装
- mcporter CLIMCP 协议桥接工具 - 浏览器已登录 xiaohongshu.com(用于导出 Cookie
## Agent 可自动完成的步骤 ## Agent 可自动完成的步骤
### 1. 安装 mcporter ### 1. 安装 xhs-cli
```bash ```bash
npm install -g mcporter pipx install xiaohongshu-cli
``` ```
### 2. 启动 xiaohongshu-mcp 服务 ### 2. 登录(从浏览器提取 Cookie
```bash ```bash
docker run -d \ xhs login
--name xiaohongshu-mcp \
-p 18060:18060 \
xpzouying/xiaohongshu-mcp
``` ```
> 如需代理(服务器部署推荐): > 这会自动从浏览器提取 Cookie。如果自动提取失败,可以手动导入(见下方)。
> ```bash
> docker run -d \
> --name xiaohongshu-mcp \
> -p 18060:18060 \
> -e XHS_PROXY=http://user:pass@ip:port \
> xpzouying/xiaohongshu-mcp
> ```
### 3. 注册到 mcporter ### 3. 验证
```bash
mcporter config add xiaohongshu http://localhost:18060/mcp
```
### 4. 验证
```bash ```bash
agent-reach doctor agent-reach doctor
``` ```
应该看到小红书显示为 ✅ 或 ⚠️MCP 已连接但未登录) 应该看到小红书显示为 ✅。
## 需要用户手动做的步骤 ## 需要用户手动做的步骤
如果 doctor 显示"MCP 已连接但未登录",需要导入 cookies 如果 `xhs login` 自动提取失败,需要手动导入 cookies
> **推荐方式:Cookie-Editor 浏览器导出(最可靠)** > **推荐方式:Cookie-Editor 浏览器导出(最可靠)**
> >
@@ -54,43 +39,47 @@ agent-reach doctor
> 3. 点击 Cookie-Editor 图标 → Export → Header String > 3. 点击 Cookie-Editor 图标 → Export → Header String
> 4. 把导出的字符串发给 Agent,运行:`agent-reach configure xhs-cookies "导出的cookie字符串"` > 4. 把导出的字符串发给 Agent,运行:`agent-reach configure xhs-cookies "导出的cookie字符串"`
> >
> **注意**`http://localhost:18060` 根路径可能返回 404,这是正常的——MCP 服务在 `/mcp` 路径 > **注意**不要依赖 QR 扫码登录,Cookie-Editor 导出方式最简单可靠
> 不要依赖 QR 扫码登录,Docker 容器内的 QR 登录页面不一定可用,且 cookies 不会自动共享到 MCP 服务。
## 使用示例
搜索笔记:
```bash
xhs search "关键词"
```
阅读笔记详情:
```bash
xhs read NOTE_ID
```
查看评论:
```bash
xhs comments NOTE_ID
```
## 常见问题 ## 常见问题
**Q: Docker 容器重启后 cookie 了?** **Q: Cookie 过期了?**
A: 挂载数据卷持久化: A: 重新运行 `xhs login` 或通过 Cookie-Editor 重新导出。
**Q: 小红书提示 IP 风险?**
A: 推荐使用住宅代理:`export HTTP_PROXY="http://user:pass@ip:port"`
**Q: xhs-cli 不支持我的系统?**
A: 确保 Python 3.10+ 和 pipx 已安装。运行 `pipx install xiaohongshu-cli` 即可。
## 备选方案:Docker MCP
如果你已经在使用 [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) Docker 方案,它也能正常工作:
```bash ```bash
docker run -d \ docker run -d \
--name xiaohongshu-mcp \ --name xiaohongshu-mcp \
-p 18060:18060 \ -p 18060:18060 \
-v xhs-data:/app/data \
xpzouying/xiaohongshu-mcp xpzouying/xiaohongshu-mcp
mcporter config add xiaohongshu http://localhost:18060/mcp
``` ```
**Q: 服务器上小红书提示 IP 风险?** xhs-cli 是当前推荐方案,不需要 Docker,安装更简单。
A: 加代理参数 `-e XHS_PROXY=http://user:pass@ip:port`,推荐住宅代理。
**Q: Docker 镜像不支持 ARM64 / Apple Silicon**
A: 上游镜像暂无 ARM64 版本,两种解决办法:
方法一:使用 Rosetta 模拟运行(推荐,最简单)
```bash
docker run -d \
--name xiaohongshu-mcp \
-p 18060:18060 \
--platform linux/amd64 \
xpzouying/xiaohongshu-mcp
```
方法二:从源码编译原生 ARM64 版本
```bash
git clone https://github.com/xpzouying/xiaohongshu-mcp
cd xiaohongshu-mcp
docker build -t xiaohongshu-mcp .
docker run -d --name xiaohongshu-mcp -p 18060:18060 xiaohongshu-mcp
```
**Q: 我不想用 Docker**
A: 可以从源码编译:https://github.com/xpzouying/xiaohongshu-mcp
+1 -1
View File
@@ -5,7 +5,7 @@ Agent Reach MCP Server — expose doctor/status as MCP tool.
Run: python -m agent_reach.integrations.mcp_server Run: python -m agent_reach.integrations.mcp_server
Agent Reach is an installer + doctor tool. For actual reading/searching, Agent Reach is an installer + doctor tool. For actual reading/searching,
agents should call upstream tools directly (bird, yt-dlp, mcporter, etc.). agents should call upstream tools directly (twitter-cli, yt-dlp, mcporter, etc.).
""" """
import asyncio import asyncio
+103
View File
@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
"""Lightweight upstream command probing.
Distinguishes the three failure modes that look identical to shutil.which():
- missing: command not on PATH
- broken: command exists but cannot execute — most commonly a stale venv
shebang after a system Python upgrade (pipx/uv tool installs break this
way: which() finds the shim, but exec fails with FileNotFoundError
pointing at the shim itself)
- timeout/error: command runs but misbehaves
Channels use probe_command() inside check() so doctor reports real health,
not just file existence.
"""
import shutil
import subprocess
from dataclasses import dataclass
from typing import Optional, Sequence
from agent_reach.utils.process import utf8_subprocess_env
#: Exit codes shells use for "found but not executable" / "not found".
_BROKEN_EXIT_CODES = (126, 127)
@dataclass
class ProbeResult:
status: str # "ok" | "missing" | "broken" | "timeout" | "error"
output: str = ""
hint: str = ""
@property
def ok(self) -> bool:
return self.status == "ok"
def reinstall_hint(package: str) -> str:
"""Prescription for a broken (stale-venv) CLI install."""
return (
f"命令存在但无法执行——通常是系统 Python 升级后 venv 解释器丢失。重装即可修复:\n"
f" uv tool install --force {package}\n"
f"或:pipx reinstall {package}"
)
def probe_command(
cmd: str,
args: Sequence[str] = ("--version",),
timeout: int = 10,
retries: int = 0,
package: Optional[str] = None,
) -> ProbeResult:
"""Actually execute `cmd *args` and classify the result.
Intended for SIDE-EFFECT-FREE health probes only (version/status
commands): retries re-run the command verbatim with no backoff, so a
non-idempotent command would repeat its effect.
package: pip/pipx package name used in the broken-install hint
(defaults to cmd).
"""
path = shutil.which(cmd)
if not path:
return ProbeResult("missing")
last: Optional[ProbeResult] = None
for _ in range(retries + 1):
last = _run_once(path, args, timeout, package or cmd)
if last.ok:
return last
# missing/broken won't heal between retries — only transient
# failures (timeout/error) are worth a second attempt
if last.status in ("missing", "broken"):
return last
return last
def _run_once(path: str, args: Sequence[str], timeout: int, package: str) -> ProbeResult:
try:
r = subprocess.run(
[path, *args],
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
env=utf8_subprocess_env(),
)
except FileNotFoundError:
# which() found it but exec failed: the shebang interpreter is gone
return ProbeResult("broken", hint=reinstall_hint(package))
except OSError:
return ProbeResult("broken", hint=reinstall_hint(package))
except subprocess.TimeoutExpired:
return ProbeResult("timeout", hint=f"`{path}` 响应超时(>{timeout}s")
if r.returncode in _BROKEN_EXIT_CODES:
return ProbeResult("broken", hint=reinstall_hint(package))
output = (r.stdout or "") + (r.stderr or "")
if r.returncode != 0:
return ProbeResult("error", output=output.strip())
return ProbeResult("ok", output=output.strip())
+107 -5
View File
@@ -1,11 +1,29 @@
#!/bin/bash #!/bin/bash
# 小宇宙播客转文字脚本 # 小宇宙播客转文字脚本
# 用法: bash transcribe.sh <小宇宙链接> [输出文件路径] # 用法: bash transcribe.sh [--polish] <小宇宙链接> [输出文件路径]
# 环境变量: GROQ_API_KEY (必须) # 环境变量: GROQ_API_KEY (必须)
#
# --polish: 转录后调用 Groq Llama 3.3 70B 给文稿补中文标点+合理分段
# (Whisper 对中文标点支持较弱,开启后阅读体验显著更好)
set -e set -e
URL="${1:?用法: bash transcribe.sh <小宇宙链接> [输出文件路径]}" POLISH=0
while [ $# -gt 0 ]; do
case "$1" in
--polish) POLISH=1; shift ;;
--) shift; break ;;
-h|--help)
echo "用法: bash transcribe.sh [--polish] <小宇宙链接> [输出文件路径]"
exit 0 ;;
--*)
echo "未知选项: $1" >&2
exit 1 ;;
*) break ;;
esac
done
URL="${1:?用法: bash transcribe.sh [--polish] <小宇宙链接> [输出文件路径]}"
OUTPUT="${2:-/tmp/podcast_transcript.txt}" OUTPUT="${2:-/tmp/podcast_transcript.txt}"
TMPDIR="/tmp/xiaoyuzhou_$$" TMPDIR="/tmp/xiaoyuzhou_$$"
@@ -35,8 +53,8 @@ echo "===================="
# Step 1: 提取音频 URL 和标题 # Step 1: 提取音频 URL 和标题
echo "🔍 正在解析页面..." echo "🔍 正在解析页面..."
PAGE=$(curl -s "$URL") PAGE=$(curl -s "$URL")
AUDIO_URL=$(echo "$PAGE" | grep -oP 'https://media\.xyzcdn\.net/[^"]*\.(m4a|mp3)' | head -1) AUDIO_URL=$(echo "$PAGE" | perl -ne 'while (/(https:\/\/media\.xyzcdn\.net\/[^"]*\.(?:m4a|mp3))/gi) { print "$1\n" }' | head -1)
TITLE=$(echo "$PAGE" | grep -oP '"title":"[^"]*"' | head -1 | sed 's/"title":"//;s/"//') TITLE=$(echo "$PAGE" | perl -ne 'if (/"title":"([^"]*)"/) { print "$1\n"; last }' | head -1)
if [ -z "$AUDIO_URL" ]; then if [ -z "$AUDIO_URL" ]; then
echo "❌ 无法从页面提取音频链接" echo "❌ 无法从页面提取音频链接"
@@ -99,6 +117,7 @@ for i in $(seq 0 $((NUM_CHUNKS - 1))); do
-F file="@$TMPDIR/chunk_${i}.mp3" \ -F file="@$TMPDIR/chunk_${i}.mp3" \
-F model="whisper-large-v3" \ -F model="whisper-large-v3" \
-F language="zh" \ -F language="zh" \
-F prompt="以下是一段中文普通话播客录音,请输出包含完整中文标点(,。?!:;“”‘’)的转写文本。" \
-F response_format="text") -F response_format="text")
HTTP_CODE=$(echo "$RESPONSE" | tail -1) HTTP_CODE=$(echo "$RESPONSE" | tail -1)
@@ -111,7 +130,7 @@ for i in $(seq 0 $((NUM_CHUNKS - 1))); do
# 如果是速率限制,等待后重试 # 如果是速率限制,等待后重试
if [ "$HTTP_CODE" = "429" ]; then if [ "$HTTP_CODE" = "429" ]; then
# 从错误信息中提取等待时间,默认 120 秒 # 从错误信息中提取等待时间,默认 120 秒
WAIT_SEC=$(echo "$BODY" | grep -oP 'in \K[0-9]+m' | sed 's/m//' | head -1) WAIT_SEC=$(echo "$BODY" | perl -ne 'if (/in (\d+)m/) { print "$1\n"; exit }')
WAIT_SEC=${WAIT_SEC:-2} WAIT_SEC=${WAIT_SEC:-2}
WAIT_SEC=$((WAIT_SEC * 60 + 30)) WAIT_SEC=$((WAIT_SEC * 60 + 30))
echo " ⏳ 速率限制,等待 ${WAIT_SEC} 秒后重试..." echo " ⏳ 速率限制,等待 ${WAIT_SEC} 秒后重试..."
@@ -122,6 +141,7 @@ for i in $(seq 0 $((NUM_CHUNKS - 1))); do
-F file="@$TMPDIR/chunk_${i}.mp3" \ -F file="@$TMPDIR/chunk_${i}.mp3" \
-F model="whisper-large-v3" \ -F model="whisper-large-v3" \
-F language="zh" \ -F language="zh" \
-F prompt="以下是一段中文普通话播客录音,请输出包含完整中文标点(,。?!:;“”‘’)的转写文本。" \
-F response_format="text") -F response_format="text")
HTTP_CODE=$(echo "$RESPONSE" | tail -1) HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d') BODY=$(echo "$RESPONSE" | sed '$d')
@@ -140,6 +160,81 @@ for i in $(seq 0 $((NUM_CHUNKS - 1))); do
echo "✅ ($CHARS 字)" echo "✅ ($CHARS 字)"
done done
# Step 6.5 (可选): 用 Llama 3.3 70B 给文稿补标点+分段
if [ "$POLISH" = "1" ]; then
echo "✨ 正在润色(Llama 3.3 70B 加标点+分段)..."
for i in $(seq 0 $((NUM_CHUNKS - 1))); do
echo -n "$((i+1))/$NUM_CHUNKS... "
IN_FILE="$TMPDIR/transcript_${i}.txt" \
OUT_FILE="$TMPDIR/polished_${i}.txt" \
GROQ_API_KEY="$GROQ_API_KEY" \
python3 <<'PY'
import json, os, sys, urllib.request, urllib.error
KEY = os.environ["GROQ_API_KEY"]
IN = os.environ["IN_FILE"]
OUT = os.environ["OUT_FILE"]
MODEL = "llama-3.3-70b-versatile"
MAX_DEPTH = 3
PROMPT_TMPL = (
"以下是一段中文普通话播客的语音转写片段,由于 Whisper 对中文标点支持较弱,"
"整段几乎没有标点。请你**只做一件事**:在合适位置补充中文标点(,。!?:;),"
"可以适度分段。\n\n"
"**严格要求**\n"
"- 不得修改、删除、增加任何汉字或英文/数字\n"
"- 不得改写、润色、总结\n"
"- 不得添加任何解释、前言、后记\n"
"- 直接输出加好标点+合理分段后的全文\n\n"
"原文:\n{}"
)
def call_groq(text):
body = json.dumps({
"model": MODEL,
"temperature": 0.2,
"max_completion_tokens": 8192,
"messages": [{"role": "user", "content": PROMPT_TMPL.format(text)}],
}).encode()
req = urllib.request.Request(
"https://api.groq.com/openai/v1/chat/completions",
data=body,
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
"User-Agent": "agent-reach-xiaoyuzhou/1.0",
},
)
with urllib.request.urlopen(req, timeout=180) as r:
resp = json.load(r)
return (
resp["choices"][0]["message"]["content"].strip(),
resp["choices"][0].get("finish_reason"),
)
def polish(text, depth=0):
try:
out, fr = call_groq(text)
except urllib.error.HTTPError as e:
sys.stderr.write(f"polish HTTP {e.code}: {e.read().decode(errors='replace')[:200]}\n")
return text # fallback to raw
except Exception as e:
sys.stderr.write(f"polish error: {e}\n")
return text
if fr != "length" or depth >= MAX_DEPTH:
return out
# 输出被截断:从中点切两半递归处理
mid = len(text) // 2
return polish(text[:mid], depth + 1) + polish(text[mid:], depth + 1)
content = open(IN, encoding="utf-8").read().strip()
result = polish(content)
open(OUT, "w", encoding="utf-8").write(result + "\n")
print(f"✅ ({len(result)} 字)")
PY
done
fi
# Step 7: 合并输出 # Step 7: 合并输出
echo "📄 正在合并文字稿..." echo "📄 正在合并文字稿..."
@@ -149,12 +244,19 @@ echo "📄 正在合并文字稿..."
echo "来源: $URL" echo "来源: $URL"
echo "时长: ${DURATION_MIN}${DURATION_SEC}" echo "时长: ${DURATION_MIN}${DURATION_SEC}"
echo "转录时间: $(date '+%Y-%m-%d %H:%M')" echo "转录时间: $(date '+%Y-%m-%d %H:%M')"
if [ "$POLISH" = "1" ]; then
echo "润色: Groq Llama 3.3 70B"
fi
echo "" echo ""
echo "---" echo "---"
echo "" echo ""
for i in $(seq 0 $((NUM_CHUNKS - 1))); do for i in $(seq 0 $((NUM_CHUNKS - 1))); do
if [ "$POLISH" = "1" ] && [ -f "$TMPDIR/polished_${i}.txt" ]; then
cat "$TMPDIR/polished_${i}.txt"
else
cat "$TMPDIR/transcript_${i}.txt" cat "$TMPDIR/transcript_${i}.txt"
fi
echo "" echo ""
done done
} > "$OUTPUT" } > "$OUTPUT"
+58 -32
View File
@@ -1,28 +1,36 @@
--- ---
name: agent-reach name: agent-reach
description: > description: >
Give your AI agent eyes to see the entire internet. MUST USE when user wants to 调研/research/搜索/search/查/找/look up anything
17 platforms via CLI, MCP, curl, and Python scripts. on the internet — e.g. 全网调研 X / 帮我调研一下 X / 查一下 X / 搜搜 X /
Zero config for 8 channels. 看看大家怎么评价 X / X 上有什么讨论 / research this topic。
Also MUST USE when user mentions any platform or shares any URL/链接:
小红书/xiaohongshu/xhs, Twitter/推特/X, B站/bilibili, Reddit, V2EX,
LinkedIn/领英/招聘/求职/jobs, YouTube, GitHub code search, 小宇宙播客,
雪球/股票行情, RSS feeds, or any web URL.
13 platforms, multi-backend routing (OpenCLI / per-platform CLIs / APIs).
Zero config for 6 channels. Run `agent-reach doctor --json` to see which
backend serves each platform right now.
NOT for: 写报告/数据分析/翻译等内容加工(本 skill 只负责从互联网获取内容);
发帖/评论/点赞等写操作;已有专门 skill 的平台(先用专门 skill)。
【路由方式】SKILL.md 包含路由表和常用命令,复杂场景需按需阅读对应分类的 references/*.md。 【路由方式】SKILL.md 包含路由表和常用命令,复杂场景需按需阅读对应分类的 references/*.md。
分类:search / social (小红书/抖音/微博/推特/B站/V2EX/Reddit) / career(LinkedIn) / dev(github) / web(网页/文章/公众号/RSS) / video(YouTube/B站/播客). 分类:search / social (小红书/推特/B站/V2EX/Reddit) / career(LinkedIn) / dev(github) / web(网页/文章/RSS) / video(YouTube/B站/播客)
Use when user asks to search, read, or interact on any supported platform,
shares a URL, or asks to search the web.
triggers: triggers:
- search: 搜/查/找/search/搜索/查一下/帮我搜 - research: 调研/全网调研/帮我调研/研究一下/research/深入了解
- search: 搜/查/找/search/搜索/查一下/帮我搜/看看大家怎么说
- social: - social:
- 小红书: xiaohongshu/xhs/小红书/红书 - 小红书: xiaohongshu/xhs/小红书/红书
- 抖音: douyin/抖音
- Twitter: twitter/推特/x.com/推文 - Twitter: twitter/推特/x.com/推文
- 微博: weibo/微博
- B站: bilibili/b站/哔哩哔哩 - B站: bilibili/b站/哔哩哔哩
- V2EX: v2ex - V2EX: v2ex
- Reddit: reddit - Reddit: reddit
- career: 招聘/职位/求职/linkedin/领英/找工作 - career: 招聘/职位/求职/linkedin/领英/找工作
- dev: github/代码/仓库/gh/issue/pr/分支/commit - dev: github/代码/仓库/gh/issue/pr/分支/commit
- web: 网页/链接/文章/公众号/微信文章/rss/读一下/打开这个 - web: 网页/链接/文章/rss/读一下/打开这个
- video: youtube/视频/播客/字幕/小宇宙/转录/yt - video: youtube/视频/播客/字幕/小宇宙/转录/yt
- finance: 雪球/股票/stock/xueqiu/行情/基金 - finance: 雪球/股票/stock/xueqiu/行情/基金
metadata: metadata:
@@ -30,19 +38,32 @@ metadata:
homepage: https://github.com/Panniantong/Agent-Reach homepage: https://github.com/Panniantong/Agent-Reach
--- ---
# Agent Reach — 路由器 # Agent Reach — 互联网能力路由器
17 平台工具集合。根据用户意图选择对应分类。 13 平台、多后端。**本 skill 存在时必须用它访问这些平台,不要自己发明方案。**
## 常驻规则(全程适用)
1. **动手前先体检**:多后端平台(小红书/Reddit/B站/Twitter)先跑
`agent-reach doctor --json`,按各平台 `active_backend` 字段选命令组。
2. **声明你在用什么**:开始干活前说一句「使用 agent-reach 的 X 平台 / Y 后端」。
3. **失败按 references 里的重试链处理**,不要瞎猜命令。
4. **全网调研类任务**:组合多平台(Exa 搜索 + Twitter/Reddit 看讨论 + 小红书/B站看中文场景),并行收集再汇总。
5. **替用户盯版本**:完成一次较大的调研/多平台任务后,顺手跑
`agent-reach check-update`(很快,一个 API 调用)。有新版就在收尾汇报里附一句:
「Agent Reach 有新版 vX.Y.Z,复制这句话给我即可更新:帮我更新 Agent Reach
https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md」。
不要中断当前任务去更新,也不要重复提醒同一个版本。
## 路由表 ## 路由表
| 用户意图 | 分类 | 详细文档 | | 用户意图 | 分类 | 详细文档 |
|---------|------|---------| |---------|------|---------|
| 网页搜索/代码搜索 | search | [references/search.md](references/search.md) | | 网页搜索/代码搜索 | search | [references/search.md](references/search.md) |
| 小红书/抖音/微博/推特/B站/V2EX/Reddit | social | [references/social.md](references/social.md) | | 小红书/推特/B站/V2EX/Reddit | social | [references/social.md](references/social.md) |
| 招聘/职位/LinkedIn | career | [references/career.md](references/career.md) | | 招聘/职位/LinkedIn | career | [references/career.md](references/career.md) |
| GitHub/代码 | dev | [references/dev.md](references/dev.md) | | GitHub/代码 | dev | [references/dev.md](references/dev.md) |
| 网页/文章/公众号/RSS | web | [references/web.md](references/web.md) | | 网页/文章/RSS | web | [references/web.md](references/web.md) |
| YouTube/B站/播客字幕 | video | [references/video.md](references/video.md) | | YouTube/B站/播客字幕 | video | [references/video.md](references/video.md) |
## 零配置快速命令 ## 零配置快速命令
@@ -57,30 +78,35 @@ curl -s "https://r.jina.ai/URL"
# GitHub 搜索 # GitHub 搜索
gh search repos "query" --sort stars --limit 10 gh search repos "query" --sort stars --limit 10
# Twitter 搜索 # YouTube 字幕(注意:B站不要用 yt-dlp,见 video.md
twitter search "query" --limit 10
# YouTube/B站字幕
yt-dlp --write-sub --skip-download -o "/tmp/%(id)s" "URL" yt-dlp --write-sub --skip-download -o "/tmp/%(id)s" "URL"
# Reddit 搜索
rdt search "query" --limit 10
# Reddit 读帖 + 评论
rdt read POST_ID
# V2EX 热门 # V2EX 热门
curl -s "https://www.v2ex.com/api/topics/hot.json" -H "User-Agent: agent-reach/1.0" curl -s "https://www.v2ex.com/api/topics/hot.json" -H "User-Agent: agent-reach/1.0"
# B站搜索(bili-cli,无需登录)
bili search "query" --type video -n 5
```
## 需登录态的平台(按 doctor 的 active_backend 选命令)
```bash
# Twitter 搜索(twitter-cli 首选;失败重试链见 social.md)
twitter search "query" -n 10
# Reddit(无零配置路径:OpenCLI 或 rdt-cli,必须登录态)
opencli reddit search "query" -f yaml # 桌面
rdt search "query" --limit 10 # 存量/服务器
# 小红书(桌面首选 OpenCLI
opencli xiaohongshu search "query" -f yaml
``` ```
## 环境检查 ## 环境检查
```bash ```bash
# 检查可用 channel # 检查可用 channel 与每个平台当前激活的后端
agent-reach doctor agent-reach doctor --json
# 查看所有 MCP 服务
mcporter_list_servers()
``` ```
## 工作区规则 ## 工作区规则
@@ -92,10 +118,10 @@ mcporter_list_servers()
根据用户需求,阅读对应的详细文档: 根据用户需求,阅读对应的详细文档:
- [搜索工具](references/search.md) — Exa AI 搜索 - [搜索工具](references/search.md) — Exa AI 搜索
- [社交媒体](references/social.md) — 小红书, 抖音, Twitter, B站, V2EX, Reddit - [社交媒体](references/social.md) — 小红书, Twitter, B站, V2EX, Reddit(多后端命令组)
- [职场招聘](references/career.md) — LinkedIn - [职场招聘](references/career.md) — LinkedIn
- [开发工具](references/dev.md) — GitHub CLI - [开发工具](references/dev.md) — GitHub CLI
- [网页阅读](references/web.md) — Jina Reader, 微信公众号, RSS - [网页阅读](references/web.md) — Jina Reader, RSS
- [视频播客](references/video.md) — YouTube, B站, 小宇宙 - [视频播客](references/video.md) — YouTube, B站, 小宇宙
## 配置渠道 ## 配置渠道
+125
View File
@@ -0,0 +1,125 @@
---
name: agent-reach
description: >
MUST USE when user wants to research/search/look up/find anything on the
internet — e.g. "research this topic", "do a deep dive on X", "search the
web for X", "see what people say about X", "look this up".
Also MUST USE when user mentions any platform or shares any URL/link:
Twitter/X, Reddit, YouTube, GitHub, Bilibili, XiaoHongShu,
Xiaoyuzhou Podcast, LinkedIn/jobs/recruiting, V2EX, Xueqiu (stocks), RSS.
13 platforms, multi-backend routing (OpenCLI / per-platform CLIs / APIs).
Zero config for 6 channels. Run `agent-reach doctor --json` to see which
backend serves each platform right now.
NOT for: writing reports/analysis/translation (this skill only FETCHES
internet content); posting/commenting/liking (write operations); platforms
that already have a dedicated skill installed (prefer that skill).
metadata:
openclaw:
homepage: https://github.com/Panniantong/Agent-Reach
---
# Agent Reach — internet capability router
13 platforms, multiple backends each. **When this skill exists, use it for
these platforms — do not invent your own approach.**
## Standing rules (apply for the whole session)
1. **Health-check before acting**: for multi-backend platforms (XiaoHongShu /
Reddit / Bilibili / Twitter), run `agent-reach doctor --json` first and
pick the command group matching each platform's `active_backend`.
2. **Announce what you use**: say "using agent-reach, platform X via backend Y"
before starting.
3. **On failure, follow the retry chains in references/** — never guess
commands.
4. **For broad research tasks**: combine platforms (Exa for web search +
Twitter/Reddit for discussions + XiaoHongShu/Bilibili for Chinese
perspectives), collect in parallel, then synthesize.
5. **Watch versions for the user**: after finishing a substantial
multi-platform task, run `agent-reach check-update` (fast, one API call).
If a new version exists, append one line to your wrap-up: "Agent Reach
vX.Y.Z is available — paste this to me to update: 帮我更新 Agent Reach
https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md".
Never interrupt the current task to update; never nag about the same version twice.
## Routing table
| User intent | Category | Details |
|---------|------|---------|
| Web / code search | search | [references/search.md](references/search.md) |
| XiaoHongShu / Twitter / Bilibili / V2EX / Reddit | social | [references/social.md](references/social.md) |
| Jobs / LinkedIn | career | [references/career.md](references/career.md) |
| GitHub / code | dev | [references/dev.md](references/dev.md) |
| Web pages / articles / RSS | web | [references/web.md](references/web.md) |
| YouTube / Bilibili / podcast transcripts | video | [references/video.md](references/video.md) |
## Zero-config quick commands
```bash
# Exa web search
mcporter call 'exa.web_search_exa(query: "query", numResults: 5)'
# Read any web page
curl -s "https://r.jina.ai/URL"
# GitHub search
gh search repos "query" --sort stars --limit 10
# YouTube subtitles (NOTE: never use yt-dlp for Bilibili — see video.md)
yt-dlp --write-sub --skip-download -o "/tmp/%(id)s" "URL"
# V2EX hot topics
curl -s "https://www.v2ex.com/api/topics/hot.json" -H "User-Agent: agent-reach/1.0"
# Bilibili search (bili-cli, no login needed)
bili search "query" --type video -n 5
```
## Login-backed platforms (pick by doctor's active_backend)
```bash
# Twitter search (twitter-cli preferred; retry chain in social.md)
twitter search "query" -n 10
# Reddit (NO zero-config path — OpenCLI or rdt-cli, login required)
opencli reddit search "query" -f yaml # desktop
rdt search "query" --limit 10 # legacy/server
# XiaoHongShu (desktop prefers OpenCLI)
opencli xiaohongshu search "query" -f yaml
```
## Environment check
```bash
# Channel availability + which backend serves each platform
agent-reach doctor --json
```
## Workspace rules
**Never create files in the agent workspace.** Use `/tmp/` for temporary
output and `~/.agent-reach/` for persistent data.
## Detailed references
Read the matching file when you need specifics (commands above cover the
common cases; references hold per-backend command groups, caveats, retry
chains — note: reference docs are written in Chinese, commands are universal):
- [Search](references/search.md) — Exa AI search
- [Social](references/social.md) — XiaoHongShu, Twitter, Bilibili, V2EX, Reddit (multi-backend groups)
- [Career](references/career.md) — LinkedIn
- [Dev](references/dev.md) — GitHub CLI
- [Web](references/web.md) — Jina Reader, RSS
- [Video](references/video.md) — YouTube, Bilibili, Xiaoyuzhou
## Configure a channel
If a channel needs setup, fetch the install guide:
https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
The user only provides cookies / one extension click; the agent does the rest.
+115 -59
View File
@@ -1,57 +1,75 @@
# 社交媒体 & 社区 # 社交媒体 & 社区
小红书、抖音、Twitter/X、微博、B站、V2EX、Reddit。 小红书、Twitter/X、B站、V2EX、Reddit。
## 小红书 / XiaoHongShu (xhs-cli) ## 小红书 / XiaoHongShu(多后端)
小红书有三个后端,**先跑 `agent-reach doctor --json` 看 xiaohongshu 的 `active_backend` 是哪个**,再用对应命令组。
### 后端 A:OpenCLI(桌面首选,复用浏览器登录态)
```bash ```bash
# 搜索笔记 # 搜索笔记
xhs search "query" opencli xiaohongshu search "query" -f yaml
# 读笔记详情 # 读笔记正文+互动数据(用搜索结果里的完整 URL,含 xsec_token
xhs read NOTE_ID_OR_URL opencli xiaohongshu note "NOTE_URL" -f yaml
# 查看评论 # 评论(支持楼中楼)
xhs comments NOTE_ID_OR_URL opencli xiaohongshu comments NOTE_ID -f yaml
# 浏览热门 # 首页推荐 feed
xhs hot opencli xiaohongshu feed -f yaml
# 推荐 feed # 用户主页公开笔记
xhs feed opencli xiaohongshu user USER_ID -f yaml
# 用户主页
xhs user USER_ID
xhs user-posts USER_ID
# 发帖/互动
xhs post --title "标题" --content "正文" --images img1.jpg img2.jpg
xhs like NOTE_ID
xhs comment NOTE_ID "评论内容"
``` ```
> **安装**: `pipx install xiaohongshu-cli`,然后 `xhs login`(自动从浏览器提取 Cookie) > 要求 Chrome 打开且装了 OpenCLI 扩展。报 AUTH_REQUIRED 说明浏览器里没登录小红书,让用户在 Chrome 里登录一次即可
## 抖音 / Douyin ### 后端 Bxiaohongshu-mcp(服务器场景)
```bash ```bash
# 解析视频信息 # 未登录时:先查状态,再取二维码给用户扫
mcporter call 'douyin.parse_douyin_video_info(share_link: "https://v.douyin.com/xxx/")' mcporter call 'xiaohongshu.check_login_status()' --timeout 120000
mcporter call 'xiaohongshu.get_login_qrcode()' --timeout 120000
# 获取无水印下载链接 # 搜索
mcporter call 'douyin.get_douyin_download_link(share_link: "https://v.douyin.com/xxx/")' mcporter call 'xiaohongshu.search_feeds(keyword: "query")' --timeout 120000
# 提取视频文案 # 笔记详情+评论(feed_id 和 xsec_token 从搜索结果取)
mcporter call 'douyin.extract_douyin_text(share_link: "https://v.douyin.com/xxx/")' mcporter call 'xiaohongshu.get_feed_detail(feed_id: "...", xsec_token: "...")' --timeout 120000
``` ```
> **无需登录** > 首次调用会自动下载约 150MB 无头浏览器,务必带 `--timeout 120000`。未登录时 search 会挂死,先 check_login_status。
### 后端 Cxhs-cli(存量备选,上游 2026-03 起停更)
```bash
xhs search "query" # 搜索
xhs read NOTE_ID_OR_URL # 读笔记(必须用搜索结果中的 URL/ID,不能裸 note_id
xhs comments NOTE_ID_OR_URL # 评论
xhs hot # 热门
xhs feed # 推荐
```
> 已知不稳定:`xhs user` / `xhs user-posts` / `xhs favorites` 可能返回 API error(上游停更无人修)。新装用户建议直接走后端 A/B。
### 通用注意事项
> **xsec_token 限制**: 小红书强制 xsec_token 机制,**不能直接用裸 note_id 去读**。正确流程:先搜索/feed 拿结果,再用结果中的完整 URL/ID 去读。三个后端都一样。
>
> **频率控制**: 高频请求(批量搜索、深翻评论)会触发验证码,平台限制无法绕过。每次操作间隔 2-3 秒。
>
> **写操作(发帖/评论/点赞)**: 建议只读。xhs-cli v0.6.x 写操作可能因签名问题返回 406。
## Twitter/X (twitter-cli) ## Twitter/X (twitter-cli)
### 稳定命令
```bash ```bash
# 搜索推文 # 首页时间线(最稳定)
twitter search "query" --limit 10 twitter feed -n 20
# 读取单条推文(含回复) # 读取单条推文(含回复)
twitter tweet URL_OR_ID twitter tweet URL_OR_ID
@@ -60,38 +78,56 @@ twitter tweet URL_OR_ID
twitter article URL_OR_ID twitter article URL_OR_ID
# 用户时间线 # 用户时间线
twitter user-posts @username --limit 20 twitter user-posts @username -n 20
# 用户资料 # 用户资料
twitter user @username twitter user @username
# 首页时间线
twitter feed --limit 20
``` ```
> **安装**: `pipx install twitter-cli` 或 `uv tool install twitter-cli` ### 可能不稳定的命令
> **认证**: 设置 `TWITTER_AUTH_TOKEN` + `TWITTER_CT0` 环境变量,或确保浏览器已登录 x.com。
## 微博 / Weibo
```bash ```bash
# 使用 Jina Reader 读取 # 搜索推文(Twitter 频繁改 GraphQL 端点,可能 404
curl -s "https://r.jina.ai/https://weibo.com/USER_ID/POST_ID" twitter search "query" -n 10
# 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+
>
> **认证**: 推荐用 Cookie-Editor 导出后设置环境变量 `TWITTER_AUTH_TOKEN` + `TWITTER_CT0`。自动提取在 SSH/Docker/无头环境不可用。
>
> **IP 风控**: 不要在 VPS/数据中心 IP 上频繁调用,尤其是 followers/following,有封号风险。使用住宅代理或本地环境。
>
> **OpenCLI 备选**: 桌面装了 OpenCLI 的话,`opencli twitter search/article/user-posts -f yaml` 全套可用(浏览器登录态,无需 cookie 环境变量)。
>
> **输出格式**: 建议用 `--yaml` 或 `--json` 获得结构化输出,对 AI agent 更友好。
## B站 / Bilibili ## B站 / Bilibili
```bash > ⚠️ **不要用 yt-dlp 读 B站**(风控已全面 412 拦截,实测无解)。用 bili-cli / OpenCLI。
# 获取视频元数据
yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx"
# 下载字幕 ```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,只读无需登录)
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) ## V2EX (公开 API)
@@ -154,23 +190,43 @@ user = ch.get_user("Livid")
> **节点列表**: https://www.v2ex.com/planes > **节点列表**: https://www.v2ex.com/planes
## Reddit (rdt-cli) ## Reddit(多后端,必须登录态)
**Reddit 没有零配置路径**:匿名 `.json` 端点已被封(403),官方 API 自 2025-11 起人工审批基本不批。两个后端都靠登录态,先跑 `agent-reach doctor --json` 看 reddit 的 `active_backend`。中国大陆访问需代理。
### 后端 A:OpenCLI(桌面首选,复用浏览器登录态)
```bash ```bash
# 搜索帖子 # 搜索帖子
rdt search "query" --limit 10 opencli reddit search "query" -f yaml
# 读帖子全文 + 评论 # 读帖子全文 + 评论
rdt read POST_ID opencli reddit read POST_ID -f yaml
# 浏览 subreddit # 浏览 subreddit / 热门 / Popular
rdt sub python --limit 20 opencli reddit subreddit LocalLLaMA -f yaml
opencli reddit hot -f yaml
opencli reddit popular -f yaml
# 浏览热门 # subreddit 元信息(订阅数、简介)
rdt popular --limit 10 opencli reddit subreddit-info LocalLLaMA -f yaml
# 浏览 /r/all
rdt all --limit 10
``` ```
> **安装**: `pipx install rdt-cli`。无需登录即可搜索和阅读 > 要求 Chrome 打开且浏览器里登录过 reddit.com
### 后端 B:rdt-cli(存量/服务器备选,上游 2026-03 起停更)
```bash
rdt search "query" --limit 10 # 搜索帖子
rdt read POST_ID # 读帖子全文 + 评论
rdt sub python --limit 20 # 浏览 subreddit
rdt popular --limit 10 # 浏览热门
rdt all --limit 10 # 浏览 /r/all
```
> **安装**: `pipx install 'git+https://github.com/public-clis/rdt-cli.git'`PyPI 版本落后,需从 GitHub 装 v0.4.2+)。先 `rdt login` 才能搜索和阅读(服务器无浏览器时手动写 Cookie,见 doctor 提示)。
> 建议使用 `--yaml` 输出,对 AI agent 更友好。
### 高级选项:官方 API + PRAW(仅限已有凭证的用户)
2025-11 前注册过 Reddit script app(持有 client_id/client_secret)的用户可以用 PRAW 走官方 API(100 QPM 免费)。新申请需人工审批且个人项目基本不批,**不要推荐新用户走这条路**。
+65 -23
View File
@@ -20,37 +20,90 @@ yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --skip-download -
cat /tmp/VIDEO_ID.*.vtt cat /tmp/VIDEO_ID.*.vtt
``` ```
### 获取评论
```bash
# 提取评论(best-effort,不保证完整)
yt-dlp --write-comments --skip-download --write-info-json \
--extractor-args "youtube:max_comments=20" \
-o "/tmp/%(id)s" "URL"
# 评论在 .info.json 的 comments 字段中
```
### 搜索视频 ### 搜索视频
```bash ```bash
yt-dlp --dump-json "ytsearch5:query" yt-dlp --dump-json "ytsearch5:query"
``` ```
## B站 / Bilibili (yt-dlp) > **字幕注意**: 手动上传的字幕提取可靠;自动生成字幕可能存在行间重复,需后处理。
> **评论注意**: `--write-comments` 基于网页抓取(非 YouTube Data API),部分评论可能丢失。
### 获取视频元数据 ### 无字幕兜底:Whisper 音频转写
```bash ```bash
yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx" # 视频没有字幕时的兜底:下载音频并用 Whisper 转写(Groq 免费 key 即可)
agent-reach transcribe "https://www.youtube.com/watch?v=VIDEO_ID"
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站 / Bilibilibili-cli 为主,OpenCLI 补字幕)
> ⚠️ **不要用 yt-dlp 读 B站**B站风控已全面 412 拦截 yt-dlp(实测最新版、直连/代理/带 Cookie 全部无效)。yt-dlp 只用于 YouTube。
### 视频详情/搜索/热门/排行 (bili-cli,只读无需登录)
```bash ```bash
yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --convert-subs vtt --skip-download -o "/tmp/%(id)s" "URL" # 视频详情(标题/UP主/时长/播放互动数据/字幕可用性)
bili video BVxxx
# 搜索视频
bili search "query" --type video -n 5
# 热门视频 / 排行榜
bili hot -n 10
bili rank -n 10
# 下载音频并切分为 ASR-ready WAV(无字幕时配合 agent-reach transcribe 转写)
bili audio BVxxx
``` ```
> **注意**: 服务器 IP 可能遇到 412 错误。使用 `--cookies-from-browser chrome` 或配置代理。 ### 字幕 (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 ## 小宇宙播客 / Xiaoyuzhou Podcast
### 转录单集播客 ### 转录单集播客(可选 --polish 增强标点)
```bash ```bash
# 输出 Markdown 文件到 /tmp/ # 输出 Markdown 文件到 /tmp/。--polish 让 Llama 3.3 70B 给文稿补中文标点+合理分段
~/.agent-reach/tools/xiaoyuzhou/transcribe.sh "https://www.xiaoyuzhoufm.com/episode/EPISODE_ID" ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh --polish "https://www.xiaoyuzhoufm.com/episode/EPISODE_ID"
``` ```
> 转写 prompt 已要求 Whisper 输出中文标点;若标点效果仍不理想,可加 `--polish` 用 Groq 上免费的 Llama 3.3 70B 补标点+合理分段(9 分钟播客约多 ~7 秒)。每次转写多一轮 LLM 调用,按需使用。
### 前置要求 ### 前置要求
1. **ffmpeg**: `brew install ffmpeg` 1. **ffmpeg**: `brew install ffmpeg`
@@ -66,23 +119,12 @@ agent-reach doctor
> 输出 Markdown 文件默认保存到 `/tmp/`。 > 输出 Markdown 文件默认保存到 `/tmp/`。
## 抖音视频解析
```bash
# 解析视频信息
mcporter call 'douyin.parse_douyin_video_info(share_link: "https://v.douyin.com/xxx/")'
# 获取无水印下载链接
mcporter call 'douyin.get_douyin_download_link(share_link: "https://v.douyin.com/xxx/")'
```
> 详见 [social.md](social.md#抖音--douyin)
## 选择指南 ## 选择指南
| 场景 | 推荐工具 | | 场景 | 推荐工具 |
|-----|---------| |-----|---------|
| YouTube 字幕 | yt-dlp | | YouTube 字幕 | yt-dlp |
| B站字幕 | yt-dlp | | B站视频详情/搜索 | bili-cli |
| B站字幕 | opencli bilibili subtitle |
| 播客转录 | 小宇宙 transcribe.sh | | 播客转录 | 小宇宙 transcribe.sh |
| 音视频解析 | douyin MCP | | 无字幕音视频 | agent-reach transcribeB站音频先 `bili audio` |
+1 -27
View File
@@ -1,6 +1,6 @@
# 网页阅读 # 网页阅读
通用网页、微信公众号、RSS。 通用网页、RSS。
## 通用网页 (Jina Reader) ## 通用网页 (Jina Reader)
@@ -29,30 +29,6 @@ mcporter call 'web-reader.webReader(url: "https://example.com", return_format: "
**适用场景**: 需要更精确控制输出格式时使用。 **适用场景**: 需要更精确控制输出格式时使用。
## 微信公众号 / WeChat Articles
### 搜索公众号文章(通过 Exa)
```bash
# 搜索微信公众号文章
mcporter call 'exa.web_search_exa(query: "搜索关键词", numResults: 5, includeDomains: ["mp.weixin.qq.com"])'
```
### 阅读公众号文章全文(通过 Exa)
```bash
# 抓取文章全文
mcporter call 'exa.crawling_exa(urls: ["https://mp.weixin.qq.com/s/ARTICLE_ID"], maxCharacters: 10000)'
```
### 可选:Camoufox 阅读(反爬更强)
```bash
cd ~/.agent-reach/tools/wechat-article-for-ai && python3 main.py "https://mp.weixin.qq.com/s/ARTICLE_ID"
```
> **注意**: Jina Reader 无法读取微信文章(被 CAPTCHA 拦截),推荐用 Exa。
## RSS (feedparser) ## RSS (feedparser)
```python ```python
@@ -71,6 +47,4 @@ for e in feedparser.parse('FEED_URL').entries[:5]:
|-----|---------| |-----|---------|
| 通用网页 | Jina Reader (`curl r.jina.ai`) | | 通用网页 | Jina Reader (`curl r.jina.ai`) |
| 需要图片/格式控制 | web-reader MCP | | 需要图片/格式控制 | web-reader MCP |
| 微信公众号 | Exa (搜索+阅读) / Camoufox (可选阅读) |
| RSS 订阅 | feedparser | | RSS 订阅 | feedparser |
| 微博/知乎等 | Jina Reader |
+261
View File
@@ -0,0 +1,261 @@
# -*- coding: utf-8 -*-
"""Whisper audio transcription with Groq → OpenAI fallback.
Downloads audio (yt-dlp), compresses + chunks (ffmpeg), and posts to a
Whisper-compatible API. Defaults to Groq's free `whisper-large-v3` and falls
back to OpenAI's `whisper-1` on HTTP error.
Public entry point:
transcribe(source, *, provider="auto", out_dir=None, config=None) -> str
Designed to be importable from channels (e.g. YouTubeChannel.transcribe).
"""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import List, Optional
import requests
from agent_reach.config import Config
# Whisper API limit is 25MB; leave headroom for multipart overhead.
SIZE_LIMIT_BYTES = 24 * 1024 * 1024
CHUNK_SECONDS = 600 # 10 min — small enough that boundary cuts rarely lose meaning
PROVIDERS = {
"groq": {
"endpoint": "https://api.groq.com/openai/v1/audio/transcriptions",
"model": "whisper-large-v3",
"key_field": "groq_api_key",
},
"openai": {
"endpoint": "https://api.openai.com/v1/audio/transcriptions",
"model": "whisper-1",
"key_field": "openai_api_key",
},
}
class TranscribeError(RuntimeError):
"""Raised when transcription cannot complete."""
class MissingDependency(TranscribeError):
"""Raised when a required external binary is missing."""
class NoProviderConfigured(TranscribeError):
"""Raised when no provider has an API key configured."""
def _require(binary: str) -> None:
if not shutil.which(binary):
raise MissingDependency(f"{binary} not found in PATH")
def _run(cmd: List[str], timeout: int = 600) -> None:
"""Run a subprocess, raising TranscribeError on nonzero exit or timeout.
cmd carries user-supplied URLs/paths into yt-dlp/ffmpeg — a stalled
network read or a hung probe must not block the CLI forever.
"""
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise TranscribeError(f"{cmd[0]} timed out after {timeout}s")
if proc.returncode != 0:
raise TranscribeError(
f"{cmd[0]} failed (exit {proc.returncode}): {proc.stderr.strip()[:300]}"
)
def download_audio(url: str, out_dir: Path) -> Path:
"""Download audio with yt-dlp into out_dir; return the resulting file path."""
_require("yt-dlp")
template = out_dir / "source.%(ext)s"
_run(
[
"yt-dlp",
"-x",
"--audio-format",
"m4a",
"--audio-quality",
"0",
"-o",
str(template),
url,
],
timeout=1800, # long podcasts over slow networks — generous but bounded
)
files = sorted(out_dir.glob("source.*"))
if not files:
raise TranscribeError("yt-dlp produced no output file")
return files[0]
def compress_audio(src: Path, out_dir: Path) -> Path:
"""Re-encode to mono / 16kHz / 32kbps m4a — keeps most content under 25MB."""
_require("ffmpeg")
dst = out_dir / "compressed.m4a"
_run(
[
"ffmpeg",
"-loglevel",
"error",
"-y",
"-i",
str(src),
"-vn",
"-ac",
"1",
"-ar",
"16000",
"-b:a",
"32k",
str(dst),
]
)
return dst
def chunk_audio(src: Path, out_dir: Path, segment_seconds: int = CHUNK_SECONDS) -> List[Path]:
"""Split src into segments. Re-encodes each segment so cuts align to keyframes."""
_require("ffmpeg")
pattern = out_dir / "chunk_%03d.m4a"
_run(
[
"ffmpeg",
"-loglevel",
"error",
"-y",
"-i",
str(src),
"-f",
"segment",
"-segment_time",
str(segment_seconds),
"-ac",
"1",
"-ar",
"16000",
"-b:a",
"32k",
str(pattern),
]
)
chunks = sorted(out_dir.glob("chunk_*.m4a"))
if not chunks:
raise TranscribeError("ffmpeg produced no chunks")
return chunks
def _provider_key(provider: str, config: Config) -> Optional[str]:
field = PROVIDERS[provider]["key_field"]
val = config.get(field)
return val or None
def transcribe_chunk(
chunk: Path,
provider: str,
*,
config: Optional[Config] = None,
timeout: int = 120,
) -> str:
"""Transcribe one chunk via the named provider. Raises TranscribeError on failure."""
if provider not in PROVIDERS:
raise TranscribeError(f"unknown provider: {provider}")
cfg = config or Config()
key = _provider_key(provider, cfg)
if not key:
raise NoProviderConfigured(
f"{provider}: missing {PROVIDERS[provider]['key_field']} "
f"(configure with `agent-reach configure {provider}-key ...`)"
)
info = PROVIDERS[provider]
with chunk.open("rb") as fh:
try:
resp = requests.post(
info["endpoint"],
headers={"Authorization": f"Bearer {key}"},
files={"file": (chunk.name, fh, "audio/m4a")},
data={"model": info["model"], "response_format": "text"},
timeout=timeout,
)
except requests.RequestException as e:
raise TranscribeError(f"{provider}: network error: {e}") from e
if not resp.ok:
raise TranscribeError(f"{provider}: HTTP {resp.status_code}: {resp.text[:300]}")
return resp.text
def _provider_order(provider: str) -> List[str]:
if provider == "auto":
return ["groq", "openai"]
if provider in PROVIDERS:
return [provider]
raise TranscribeError(f"unknown provider: {provider} (use groq|openai|auto)")
def transcribe(
source: str,
*,
provider: str = "auto",
out_dir: Optional[Path] = None,
config: Optional[Config] = None,
) -> str:
"""Transcribe a URL or local file path. Returns the joined transcript text.
`provider` is one of `auto` (groq → openai), `groq`, or `openai`.
`out_dir` defaults to a fresh temp directory; intermediate files stay there.
"""
cfg = config or Config()
order = _provider_order(provider)
# Validate at least one provider is configured before doing expensive work.
if not any(_provider_key(p, cfg) for p in order):
names = ", ".join(PROVIDERS[p]["key_field"] for p in order)
raise NoProviderConfigured(f"no provider key configured (need one of: {names})")
work_dir = Path(out_dir) if out_dir else Path(tempfile.mkdtemp(prefix="transcribe-"))
work_dir.mkdir(parents=True, exist_ok=True)
src_path = Path(source)
if src_path.is_file():
audio = src_path
else:
audio = download_audio(source, work_dir)
compressed = compress_audio(audio, work_dir)
if compressed.stat().st_size <= SIZE_LIMIT_BYTES:
chunks = [compressed]
else:
chunks = chunk_audio(compressed, work_dir)
pieces: List[str] = []
for chunk in chunks:
text = _transcribe_with_fallback(chunk, order, cfg)
pieces.append(text.strip())
return "\n".join(p for p in pieces if p)
def _transcribe_with_fallback(chunk: Path, order: List[str], config: Config) -> str:
"""Try each provider in order; return first success or raise the last error."""
last_err: Optional[Exception] = None
for p in order:
if not _provider_key(p, config):
# Skip silently — caller already validated at least one is configured.
continue
try:
return transcribe_chunk(chunk, p, config=config)
except TranscribeError as e:
last_err = e
continue
raise TranscribeError(f"all providers failed for {chunk.name}: {last_err}")
+26
View File
@@ -0,0 +1,26 @@
"""Subprocess helpers for consistent cross-platform text handling."""
from __future__ import annotations
import os
from collections.abc import Mapping
UTF8_ENV = {
"PYTHONUTF8": "1",
"PYTHONIOENCODING": "utf-8",
}
def utf8_subprocess_env(base: Mapping[str, str] | None = None) -> dict[str, str]:
"""Return an environment that forces Python child processes into UTF-8 mode."""
env = dict(base or os.environ)
env.update(UTF8_ENV)
return env
def mcporter_utf8_env_args() -> list[str]:
"""Return mcporter --env arguments for UTF-8 Python stdio servers."""
args = []
for key, value in UTF8_ENV.items():
args.extend(["--env", f"{key}={value}"])
return args
+81 -96
View File
@@ -5,13 +5,22 @@
</p> </p>
<p align="center"> <p align="center">
<a href="../LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a> The most reliable access path for each platform — chosen, installed, and health-checked for you. Backends come and go; you won't notice.
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
<a href="https://github.com/Panniantong/agent-reach/stargazers"><img src="https://img.shields.io/github/stars/Panniantong/agent-reach?style=for-the-badge" alt="GitHub Stars"></a>
</p> </p>
<p align="center"> <p align="center">
<a href="#quick-start">Quick Start</a> · <a href="../README.md">中文</a> · <a href="README_ja.md">日本語</a> · <a href="#supported-platforms">Platforms</a> · <a href="#design-philosophy">Philosophy</a> <a href="../LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
<a href="https://github.com/Panniantong/agent-reach/stargazers"><img src="https://img.shields.io/github/stars/Panniantong/agent-reach?style=for-the-badge" alt="GitHub Stars"></a>
<a href="https://atomgit.com/qq_51337814/Agent-Reach"><img src="https://atomgit.com/qq_51337814/Agent-Reach/star/badge.svg" alt="AtomGit Stars"></a>
</p>
<p align="center">
🇨🇳 In China? This project is also hosted on the <a href="https://atomgit.com/qq_51337814/Agent-Reach">AtomGit mirror</a> (auto-synced with GitHub, faster cloning)
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> · <a href="../README.md">中文</a> · <a href="README_ja.md">日本語</a> · <a href="README_ko.md">한국어</a> · <a href="#supported-platforms">Platforms</a> · <a href="#design-philosophy">Philosophy</a>
</p> </p>
--- ---
@@ -51,7 +60,7 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma
|---|---| |---|---|
| 💰 **Completely free** | All tools are open source, all APIs are free. The only possible cost is a server proxy ($1/month) — local computers don't need one | | 💰 **Completely free** | All tools are open source, all APIs are free. The only possible cost is a server proxy ($1/month) — local computers don't need one |
| 🔒 **Privacy safe** | Cookies stay local. Never uploaded. Fully open source — audit anytime | | 🔒 **Privacy safe** | Cookies stay local. Never uploaded. Fully open source — audit anytime |
| 🔄 **Kept up to date** | Upstream tools (yt-dlp, bird, Jina Reader, etc.) are tracked and updated regularly | | 🔄 **Kept up to date** | Every platform routes through a primary + fallback backend list. When an access path dies, we switch to the next — you won't notice (June 2026: Bilibili 412-blocked yt-dlp → switched to bili-cli, zero action on your side) |
| 🤖 **Works with any Agent** | Claude Code, OpenClaw, Cursor, Windsurf… any Agent that can run commands | | 🤖 **Works with any Agent** | Claude Code, OpenClaw, Cursor, Windsurf… any Agent that can run commands |
| 🩺 **Built-in diagnostics** | `agent-reach doctor` — one command shows what works, what doesn't, and how to fix it | | 🩺 **Built-in diagnostics** | `agent-reach doctor` — one command shows what works, what doesn't, and how to fix it |
@@ -62,21 +71,18 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma
| Platform | Capabilities | Setup | Notes | | Platform | Capabilities | Setup | Notes |
|----------|-------------|:-----:|-------| |----------|-------------|:-----:|-------|
| 🌐 **Web** | Read | Zero config | Any URL → clean Markdown ([Jina Reader](https://github.com/jina-ai/reader) ⭐9.8K) | | 🌐 **Web** | Read | Zero config | Any URL → clean Markdown ([Jina Reader](https://github.com/jina-ai/reader) ⭐9.8K) |
| 🐦 **Twitter/X** | Read · Search | Zero config / Cookie | Single tweets readable out of the box. Cookie unlocks search, timeline, posting ([bird](https://www.npmjs.com/package/@steipete/bird)) | | 🐦 **Twitter/X** | Read · Search | Cookie | Cookie unlocks search, timeline, tweet reading, articles ([twitter-cli](https://github.com/public-clis/twitter-cli)) |
| 📕 **XiaoHongShu** | Read · Search · **Post · Comment · Like** | mcporter | Via [xiaohongshu-mcp](https://github.com/user/xiaohongshu-mcp) internal API, install and go | | 📕 **XiaoHongShu** | Read · Search · Comments | OpenCLI / MCP | Desktop: [OpenCLI](https://github.com/jackwener/opencli) (reuses browser session); Server: [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) (QR login); legacy xhs-cli still works |
| 🎵 **Douyin** | Video parsing · Watermark-free download | mcporter | Via [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server), no login needed |
| 💼 **LinkedIn** | Jina Reader (public pages) | Full profiles, companies, job search | Tell your Agent "help me set up LinkedIn" | | 💼 **LinkedIn** | Jina Reader (public pages) | Full profiles, companies, job search | Tell your Agent "help me set up LinkedIn" |
| 💬 **WeChat Articles** | Search + Read | Zero config | Search + read WeChat Official Account articles (full Markdown) ([wechat-article-for-ai](https://github.com/Panniantong/wechat-article-for-ai) + [miku_ai](https://github.com/GobinFan/Miku_Spider)) |
| 📰 **Weibo** | Trending · Search · Feeds · Comments | Zero config | Hot search, content/user/topic search, feeds, comments ([mcp-server-weibo](https://github.com/Panniantong/mcp-server-weibo)) |
| 💻 **V2EX** | Hot topics · Node topics · Topic detail + replies · User profile | Zero config | Public JSON API, no auth required. Great for tech community content | | 💻 **V2EX** | Hot topics · Node topics · Topic detail + replies · User profile | Zero config | Public JSON API, no auth required. Great for tech community content |
| 📈 **Xueqiu (雪球)** | Stock quotes · Search · Hot posts · Hot stocks | Browser cookie | Tell your Agent "help me set up Xueqiu" | | 📈 **Xueqiu (雪球)** | Stock quotes · Search · Hot posts · Hot stocks | Browser cookie | Tell your Agent "help me set up Xueqiu" |
| 🎙️ **Xiaoyuzhou Podcast** | Transcription | Free API key | Podcast audio → full text transcript via Groq Whisper (free) | | 🎙️ **Xiaoyuzhou Podcast** | Transcription | Free API key | Podcast audio → full text transcript via Groq Whisper (free) |
| 🔍 **Web Search** | Search | Auto-configured | Auto-configured during install, free, no API key ([Exa](https://exa.ai) via [mcporter](https://github.com/nicepkg/mcporter)) | | 🔍 **Web Search** | Search | Auto-configured | Auto-configured during install, free, no API key ([Exa](https://exa.ai) via [mcporter](https://github.com/nicepkg/mcporter)) |
| 📦 **GitHub** | Read · Search | Zero config | [gh CLI](https://cli.github.com) powered. Public repos work immediately. `gh auth login` unlocks Fork, Issue, PR | | 📦 **GitHub** | Read · Search | Zero config | [gh CLI](https://cli.github.com) powered. Public repos work immediately. `gh auth login` unlocks Fork, Issue, PR |
| 📺 **YouTube** | Read · **Search** | Zero config | Subtitles + search across 1800+ video sites ([yt-dlp](https://github.com/yt-dlp/yt-dlp) ⭐148K) | | 📺 **YouTube** | Read · **Search** | Zero config | Subtitles + search across 1800+ video sites ([yt-dlp](https://github.com/yt-dlp/yt-dlp) ⭐148K) |
| 📺 **Bilibili** | Read · **Search** | Zero config / Proxy | Video info + subtitles + search. Local works directly, servers need a proxy ([yt-dlp](https://github.com/yt-dlp/yt-dlp)) | | 📺 **Bilibili** | Read · **Search** | Zero config | Search + video detail via [bili-cli](https://github.com/public-clis/bilibili-cli) (no login needed); subtitles via [OpenCLI](https://github.com/jackwener/opencli). yt-dlp is 412-blocked by Bilibili and no longer used here |
| 📡 **RSS** | Read | Zero config | Any RSS/Atom feed ([feedparser](https://github.com/kurtmckee/feedparser) ⭐2.3K) | | 📡 **RSS** | Read | Zero config | Any RSS/Atom feed ([feedparser](https://github.com/kurtmckee/feedparser) ⭐2.3K) |
| 📖 **Reddit** | Search · Read | Zero config | Search and read via Exa (free, no proxy needed) | | 📖 **Reddit** | Search · Read | OpenCLI / Cookie | No zero-config path (anonymous endpoints blocked). Desktop: [OpenCLI](https://github.com/jackwener/opencli) via browser session; or [rdt-cli](https://github.com/public-clis/rdt-cli) + cookie |
> **Setup levels:** Zero config = install and go · Auto-configured = handled during install · mcporter = needs MCP service · Cookie = export from browser · Proxy = $1/month > **Setup levels:** Zero config = install and go · Auto-configured = handled during install · mcporter = needs MCP service · Cookie = export from browser · Proxy = $1/month
@@ -84,6 +90,15 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma
## Quick Start ## Quick Start
> ⚠️ **OpenClaw users: enable `exec` permission first**
>
> Agent Reach relies on the Agent running shell commands (`pip install`, `mcporter`, `twitter`, etc.). If your OpenClaw uses the default `messaging` tool profile, the Agent won't be able to run them. **Enable `exec` before installing:**
>
> ```bash
> openclaw config set tools.profile "coding"
> ```
> Or set `"tools": { "profile": "coding" }` in `~/.openclaw/openclaw.json`. After changing it, restart the Gateway (`openclaw gateway restart`) and start a new conversation. Other platforms (Claude Code, Cursor, Windsurf, etc.) are not affected.
Copy this to your AI Agent (Claude Code, OpenClaw, Cursor, etc.): Copy this to your AI Agent (Claude Code, OpenClaw, Cursor, etc.):
``` ```
@@ -97,6 +112,12 @@ The Agent auto-installs, detects your environment, and tells you what's ready.
> Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md > Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
> ``` > ```
> 🛡️ **Worried about security?** Use safe mode — it won't auto-install system packages, it only tells you what you need:
> ```
> Install Agent Reach (safe mode): https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
> Use the --safe flag during install
> ```
<details> <details>
<summary>Manual install</summary> <summary>Manual install</summary>
@@ -116,6 +137,11 @@ npx skills add Panniantong/Agent-Reach@agent-reach
After the Skill is installed, the Agent will auto-detect whether `agent-reach` CLI is available and install it if needed. After the Skill is installed, the Agent will auto-detect whether `agent-reach` CLI is available and install it if needed.
> If you install via `agent-reach install`, the skill is registered automatically — no extra steps needed. > If you install via `agent-reach install`, the skill is registered automatically — no extra steps needed.
>
> Prefer an English-only skill file? Set an English locale or export `AGENT_REACH_LANG=en`
> before running `agent-reach install --env=auto` or `agent-reach skill --install`.
> The installed file is always written as `SKILL.md`, so switching languages means rerunning
> the install command with the new locale and replacing the previously installed skill file.
</details> </details>
--- ---
@@ -127,7 +153,7 @@ No configuration needed — just tell your Agent:
- "Read this link" → `curl https://r.jina.ai/URL` for any web page - "Read this link" → `curl https://r.jina.ai/URL` for any web page
- "What's this GitHub repo about?" → `gh repo view owner/repo` - "What's this GitHub repo about?" → `gh repo view owner/repo`
- "What does this video cover?" → `yt-dlp --dump-json URL` for subtitles - "What does this video cover?" → `yt-dlp --dump-json URL` for subtitles
- "Read this tweet" → `bird read URL` - "Read this tweet" → `twitter tweet URL`
- "Subscribe to this RSS" → `feedparser` to parse feeds - "Subscribe to this RSS" → `feedparser` to parse feeds
- "Search GitHub for LLM frameworks" → `gh search repos "LLM framework"` - "Search GitHub for LLM frameworks" → `gh search repos "LLM framework"`
@@ -143,11 +169,11 @@ Don't use it? Don't configure it. Every step is optional.
Tell your Agent "help me configure Twitter cookies" — it'll guide you through exporting from your browser. Local computers can auto-import. Tell your Agent "help me configure Twitter cookies" — it'll guide you through exporting from your browser. Local computers can auto-import.
### 🌐 Proxy — $1/month, servers only ### 🌐 Proxy — $1/month, restricted networks only
Bilibili blocks server IPs. Get a proxy ([Webshare](https://webshare.io) recommended, $1/month) and send the address to your Agent. Most users need no proxy. If your network blocks Reddit/Twitter (e.g. mainland China) get one ([Webshare](https://webshare.io) recommended, $1/month) and send the address to your Agent — it saves it and exports HTTP(S)_PROXY when calling those tools.
> Reddit now works free via Exa without any proxy. Local computers don't need a proxy for Bilibili either. > Reddit needs a logged-in session either way — OpenCLI rides your browser session, or rdt-cli after `rdt login`. Bilibili works via bili-cli without a proxy.
--- ---
@@ -163,7 +189,7 @@ $ agent-reach doctor
✅ GitHub repos and code — public repos readable and searchable ✅ GitHub repos and code — public repos readable and searchable
✅ Twitter/X tweets — readable. Cookie unlocks search and posting ✅ Twitter/X tweets — readable. Cookie unlocks search and posting
✅ YouTube video subtitles — yt-dlp ✅ YouTube video subtitles — yt-dlp
⚠️ Bilibili video info — server IPs may be blocked, configure proxy Bilibili search & video detail — bili-cli (subtitles via OpenCLI)
✅ RSS/Atom feeds — feedparser ✅ RSS/Atom feeds — feedparser
✅ Web pages (any URL) — Jina Reader API ✅ Web pages (any URL) — Jina Reader API
@@ -171,8 +197,8 @@ $ agent-reach doctor
⬜ Web semantic search — sign up at exa.ai for free key ⬜ Web semantic search — sign up at exa.ai for free key
🔧 Configurable: 🔧 Configurable:
Reddit posts and comments — search and read via Exa (free, no proxy) Reddit posts and comments — needs login: rdt-cli after `rdt login`, or OpenCLI browser session
⬜ XiaoHongShu notes — needs cookie. Export from browser ⬜ XiaoHongShu notes — desktop: OpenCLI (browser session); server: xiaohongshu-mcp (QR)
Status: 6/9 channels available Status: 6/9 channels available
``` ```
@@ -181,54 +207,50 @@ Status: 6/9 channels available
## Design Philosophy ## Design Philosophy
**Agent Reach is a scaffolding tool, not a framework.** **Agent Reach is a capability layer, not yet another tool.**
Every time you spin up a new Agent, you spend time finding tools, installing deps, and debugging configs — what reads Twitter? How do you bypass Reddit blocks? How do you extract YouTube subtitles? Every time, you re-do the same work. It sits one level above any specific implementation — it handles **selection, installation, health checks, and routing**, not the reading itself. Reading is done by your Agent calling upstream tools directly; there is no wrapper layer.
Agent Reach does one simple thing: **it makes those tool selection and configuration decisions for you.** Every time you spin up a new Agent, you spend time finding tools, installing deps, and debugging configs — what reads Twitter? How do you log into Reddit? What replaces a discontinued XiaoHongShu CLI? Every time, you re-do the same work. Agent Reach does one simple thing: **the most reliable access path for each platform, chosen, installed, and health-checked for you. Access paths come and go (in March 2026 a batch of single-platform CLIs went unmaintained — we re-routed), so you don't have to care.**
After installation, your Agent calls the upstream tools directly (bird CLI, yt-dlp, mcporter, gh CLI, etc.) — no wrapper layer in between. ### 🔌 Every platform = an ordered backend list (primary + fallbacks)
### 🔌 Every Channel is Pluggable Switching access paths means reordering the list, not rewriting code. `agent-reach doctor` tells you **which backend each platform is currently using**.
Each platform maps to an upstream tool. **Don't like one? Swap it out.**
``` ```
channels/ channels/
├── web.py → Jina Reader ← swap to Firecrawl, Crawl4AI… ├── web.py → Jina Reader
├── twitter.py → bird CLI ← swap to Nitter, official API… ├── twitter.py → twitter-cli ▸ OpenCLI ▸ bird
├── youtube.py → yt-dlp ← swap to YouTube API, Whisper… ├── youtube.py → yt-dlp
├── github.py → gh CLI ← swap to REST API, PyGithub… ├── github.py → gh CLI
├── bilibili.py → yt-dlp ← swap to bilibili-api… ├── bilibili.py → bili-cli ▸ OpenCLI ▸ search API (yt-dlp retired, 412-blocked)
├── reddit.py → Exa ← search + read, no proxy needed ├── reddit.py → OpenCLI ▸ rdt-cli (no zero-config path, login required)
├── xiaohongshu.py → mcporter MCP ← swap to other XHS tools… ├── xiaohongshu.py → OpenCLI ▸ xiaohongshu-mcp ▸ xhs-cli
├── douyin.py → mcporter MCP ← swap to other Douyin tools… ├── linkedin.py → linkedin-mcp ▸ Jina Reader
├── linkedin.py → linkedin-mcp ← swap to LinkedIn API… ├── rss.py → feedparser
├── rss.py → feedparser ← swap to atoma… ├── exa_search.py → Exa via mcporter
├── exa_search.py → mcporter MCP ← swap to Tavily, SerpAPI…
└── __init__.py → Channel registry (for doctor checks) └── __init__.py → Channel registry (for doctor checks)
``` ```
Each channel file only checks whether its upstream tool is installed and working (`check()` method for `agent-reach doctor`). The actual reading and searching is done by calling the upstream tools directly. Each channel file **actually probes** its candidate backends in order (not just checking that a command exists) — the first fully working one becomes the active backend, and broken ones come with a fix prescription. The actual reading and searching is done by the Agent calling the upstream tools directly.
### Current Tool Choices ### Current Tool Choices
| Scenario | Tool | Why | | Scenario | Primary | Fallback | Why |
|----------|------|-----| |----------|---------|----------|-----|
| Read web pages | [Jina Reader](https://github.com/jina-ai/reader) | 9.8K stars, free, no API key needed | | Read web pages | [Jina Reader](https://github.com/jina-ai/reader) | — | Free, no API key needed |
| Read tweets | [bird](https://www.npmjs.com/package/@steipete/bird) | Cookie auth, free. Official API is pay-per-use ($0.005/post read) | | Read tweets | [twitter-cli](https://github.com/public-clis/twitter-cli) | [OpenCLI](https://github.com/jackwener/opencli) | Reliable search in real-world tests; OpenCLI falls back on your browser session |
| Video subtitles + search | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | 148K stars, YouTube + Bilibili + 1800 sites | | Reddit | [OpenCLI](https://github.com/jackwener/opencli) (desktop) | [rdt-cli](https://github.com/public-clis/rdt-cli) | Anonymous endpoints blocked, official API gated — logged-in sessions are the only route left |
| Search the web | [Exa](https://exa.ai) via [mcporter](https://github.com/nicepkg/mcporter) | AI semantic search, MCP integration, no API key | | YouTube subtitles + search | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | — | 154K stars, still the best for YouTube (no longer used for Bilibili) |
| GitHub | [gh CLI](https://cli.github.com) | Official tool, full API after auth | | Bilibili | [bili-cli](https://github.com/public-clis/bilibili-cli) | OpenCLI ▸ search API | yt-dlp is 412-blocked by Bilibili (verified June 2026); bili-cli searches and reads without login |
| Read RSS | [feedparser](https://github.com/kurtmckee/feedparser) | Python ecosystem standard, 2.3K stars | | Search the web | [Exa](https://exa.ai) via [mcporter](https://github.com/nicobailon/mcporter) | — | AI semantic search, MCP integration, no API key |
| XiaoHongShu | [xiaohongshu-mcp](https://github.com/user/xiaohongshu-mcp) | Internal API, bypasses anti-bot | | GitHub | [gh CLI](https://cli.github.com) | — | Official tool, full API after auth |
| Douyin | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCP server, no login needed, video parsing + watermark-free download | | Read RSS | [feedparser](https://github.com/kurtmckee/feedparser) | — | Python ecosystem standard |
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | 900+ stars, MCP server, browser automation | | XiaoHongShu | [OpenCLI](https://github.com/jackwener/opencli) (desktop) | [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) (server) ▸ xhs-cli | The xhs-cli author moved to OpenCLI (24K stars); browser sessions mean zero friction |
| WeChat Articles | [wechat-article-for-ai](https://github.com/Panniantong/wechat-article-for-ai) + [miku_ai](https://github.com/GobinFan/Miku_Spider) | Stealth browser for full article reading + Sogou search | | LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | Jina Reader | MCP server, browser automation |
| Weibo | `mcporter` | `mcporter call 'weibo.get_trendings(limit: 10)'` | | Xiaoyuzhou Podcast | `transcribe.sh` | — | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` |
| Xiaoyuzhou Podcast | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` |
> 📌 These are the *current* choices. Don't like one? Swap out the file. That's the whole point of scaffolding. > 📌 These are the *current* choices, re-verified regularly on real machines. When a path dies we switch to the next — `agent-reach doctor` always tells you which one is active.
--- ---
@@ -249,7 +271,7 @@ This project was entirely vibe-coded 🎸 There might be rough edges here and th
<details> <details>
<summary><strong>How to search Twitter/X with AI agent without paying for API?</strong></summary> <summary><strong>How to search Twitter/X with AI agent without paying for API?</strong></summary>
Agent Reach uses the [bird CLI](https://www.npmjs.com/package/@steipete/bird) with cookie-based authentication — completely free, no Twitter API subscription needed. After installing Agent Reach, export your Twitter cookies using the Cookie-Editor Chrome extension, run `agent-reach configure twitter-cookies "your_cookies"`, and your agent can search with `bird search "query" -n 10`. Agent Reach uses [twitter-cli](https://github.com/public-clis/twitter-cli) with cookie-based authentication — completely free, no Twitter API subscription needed. Install with `pipx install twitter-cli`, make sure you're logged into x.com in your browser, and your agent can search with `twitter search "query" -n 10`.
</details> </details>
<details> <details>
@@ -261,7 +283,7 @@ Agent Reach uses the [bird CLI](https://www.npmjs.com/package/@steipete/bird) wi
<details> <details>
<summary><strong>Reddit returns 403 from server / datacenter IP blocked?</strong></summary> <summary><strong>Reddit returns 403 from server / datacenter IP blocked?</strong></summary>
Agent Reach now uses Exa to search and read Reddit content, completely bypassing Reddit's IP blocks. No proxy needed. Run `agent-reach install --env=auto` to set up Exa automatically. Reddit requires a logged-in session for everything (anonymous endpoints are blocked, and official API registration has been approval-gated since 2025-11). On desktop, the preferred path is OpenCLI riding your browser's reddit.com session. Otherwise install rdt-cli from the pinned git source (`pipx install 'git+https://github.com/public-clis/rdt-cli.git'` — PyPI lags), then `rdt login`. Your agent can then search with `rdt search "query"` and read full posts + comments with `rdt read POST_ID`.
</details> </details>
<details> <details>
@@ -273,59 +295,26 @@ Yes! Agent Reach is an installer + configuration tool. Any AI coding agent that
<details> <details>
<summary><strong>Is Agent Reach free? Any API costs?</strong></summary> <summary><strong>Is Agent Reach free? Any API costs?</strong></summary>
100% free and open source. All backends (bird CLI, yt-dlp, Jina Reader, Exa) are free tools that don't require paid API keys. The only optional cost is a residential proxy (~$1/month) if you need Bilibili access from a server. Reddit works free via Exa without any proxy. 100% free and open source. All backends (twitter-cli, rdt-cli, xhs-cli, yt-dlp, Jina Reader, Exa) are free tools that don't require paid API keys. The only optional cost is a residential proxy (~$1/month) for some server scenarios. Reddit costs nothing but needs a logged-in session (rdt-cli after `rdt login`, or OpenCLI reusing your browser session).
</details> </details>
<details> <details>
<summary><strong>Free alternative to Twitter API for web scraping?</strong></summary> <summary><strong>Free alternative to Twitter API for web scraping?</strong></summary>
Agent Reach uses bird CLI which accesses Twitter via cookie auth — same as your browser session. No API fees, no rate limit tiers, no developer account needed. Supports search, read tweets, read profiles, and timelines. Agent Reach uses twitter-cli which accesses Twitter via cookie auth — same as your browser session. No API fees, no rate limit tiers, no developer account needed. Supports search, read tweets, read profiles, and timelines.
</details> </details>
<details> <details>
<summary><strong>How to read XiaoHongShu / 小红书 content programmatically?</strong></summary> <summary><strong>How to read XiaoHongShu / 小红书 content programmatically?</strong></summary>
Agent Reach integrates with xiaohongshu-mcp (runs in Docker). After setup, use `mcporter call 'xiaohongshu.get_feed_detail(...)'` to read notes or `mcporter call 'xiaohongshu.search_feeds(keyword: "query")'` to search. On desktop, prefer **OpenCLI** (`agent-reach install --channels opencli`) — it reuses your browser's logged-in session, so if you've browsed XiaoHongShu you're set; one Chrome Web Store click installs the extension. Then `opencli xiaohongshu search "query"` / `opencli xiaohongshu note URL`. On servers use [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) (bundled headless browser, QR login). Existing xhs-cli installs keep working as a fallback backend (upstream unmaintained since 2026-03, not recommended for new setups).
</details>
<details>
<summary><strong>How to parse Douyin / 抖音 videos with AI agent?</strong></summary>
Install douyin-mcp-server, then your agent can use `mcporter call 'douyin.parse_douyin_video_info(share_link: "share_url")'` to parse video info and get watermark-free download links. No login required — just share the Douyin link. See https://github.com/yzfly/douyin-mcp-server
</details>
<details>
<summary><strong>How to extract scripts from both Douyin and XiaoHongShu with one MCP?</strong></summary>
If you want one MCP server that can handle:
- Douyin videos
- XiaoHongShu video notes
- XiaoHongShu image notes
and directly write `script.md` + `info.json`, you can point the existing `douyin` mcporter alias at:
- https://github.com/JNHFlow21/social-post-extractor-mcp
It keeps backward compatibility with:
- `parse_douyin_video_info`
- `get_douyin_download_link`
- `extract_douyin_text`
and adds unified tools:
- `parse_social_post_info`
- `extract_social_post_script`
This is useful when your agent workflow is “paste a link, get a script file”.
</details> </details>
--- ---
## Credits ## Credits
[Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [bird](https://www.npmjs.com/package/@steipete/bird) · [Exa](https://exa.ai) · [feedparser](https://github.com/kurtmckee/feedparser) · [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) [twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server)
## Contact ## Contact
@@ -346,10 +335,6 @@ For collaboration or questions, add me on WeChat — I'll invite you to the comm
## Friends ## Friends
[FluxNode](https://fluxnode.org) — Low-cost AI API gateway, 90% off official pricing, pay-as-you-go or subscription. Works with OpenClaw, Claude Code, and any Agent.
[OpenClaw for Enterprise](https://github.com/littleben/openclaw-for-enterprise) — Enterprise-grade multi-user OpenClaw deployment, use AI directly in Feishu/Lark, container isolation, one-command management.
[OpenClaw on Tencent Cloud](https://www.tencentcloud.com/act/pro/intl-openclaw?referral_code=G76Y819A&lang=en&pg=) — One-click OpenClaw on Tencent Cloud: chat to connect Agent Reach & unlock internet power. [OpenClaw on Tencent Cloud](https://www.tencentcloud.com/act/pro/intl-openclaw?referral_code=G76Y819A&lang=en&pg=) — One-click OpenClaw on Tencent Cloud: chat to connect Agent Reach & unlock internet power.
## Star History ## Star History
+22 -22
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#クイックスタート">クイックスタート</a> · <a href="../README.md">中文</a> · <a href="README_en.md">English</a> · <a href="#対応プラットフォーム">プラットフォーム</a> · <a href="#設計思想">設計思想</a> <a href="#クイックスタート">クイックスタート</a> · <a href="../README.md">中文</a> · <a href="README_en.md">English</a> · <a href="README_ko.md">한국어</a> · <a href="#対応プラットフォーム">プラットフォーム</a> · <a href="#設計思想">設計思想</a>
</p> </p>
--- ---
@@ -51,7 +51,7 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma
|---|---| |---|---|
| 💰 **完全無料** | すべてのツールはオープンソース、すべてのAPIは無料。唯一のコストはサーバープロキシ(月額$1)の可能性のみ — ローカルPCでは不要 | | 💰 **完全無料** | すべてのツールはオープンソース、すべてのAPIは無料。唯一のコストはサーバープロキシ(月額$1)の可能性のみ — ローカルPCでは不要 |
| 🔒 **プライバシー安全** | Cookieはローカルに保存。アップロードされることはありません。完全オープンソース — いつでも監査可能 | | 🔒 **プライバシー安全** | Cookieはローカルに保存。アップロードされることはありません。完全オープンソース — いつでも監査可能 |
| 🔄 **常に最新** | 上流ツール(yt-dlp、bird、Jina Reader等)を定期的に追跡・更新 | | 🔄 **常に最新** | 上流ツール(yt-dlp、twitter-cli、rdt-cli、Jina Reader等)を定期的に追跡・更新 |
| 🤖 **あらゆるエージェントに対応** | Claude Code、OpenClaw、Cursor、Windsurf… コマンドを実行できるすべてのエージェント | | 🤖 **あらゆるエージェントに対応** | Claude Code、OpenClaw、Cursor、Windsurf… コマンドを実行できるすべてのエージェント |
| 🩺 **組み込み診断** | `agent-reach doctor` — 1コマンドで何が動き、何が動かないか、どう修正するかを表示 | | 🩺 **組み込み診断** | `agent-reach doctor` — 1コマンドで何が動き、何が動かないか、どう修正するかを表示 |
@@ -62,11 +62,11 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma
| プラットフォーム | 機能 | セットアップ | 備考 | | プラットフォーム | 機能 | セットアップ | 備考 |
|-----------------|------|:----------:|------| |-----------------|------|:----------:|------|
| 🌐 **Web** | 閲覧 | 設定不要 | 任意のURL → クリーンなMarkdown[Jina Reader](https://github.com/jina-ai/reader) ⭐9.8K | | 🌐 **Web** | 閲覧 | 設定不要 | 任意のURL → クリーンなMarkdown[Jina Reader](https://github.com/jina-ai/reader) ⭐9.8K |
| 🐦 **Twitter/X** | 閲覧・検索 | 設定不要 / Cookie | 単一ツイートはすぐに閲覧可能。Cookieで検索、タイムライン、投稿が解放([bird](https://www.npmjs.com/package/@steipete/bird) | | 🐦 **Twitter/X** | 閲覧・検索 | 設定不要 / Cookie | 単一ツイートはすぐに閲覧可能。Cookieで検索、タイムライン、投稿が解放([twitter-cli](https://github.com/public-clis/twitter-cli) |
| 📕 **小紅書** | 閲覧・検索・**投稿・コメント・いいね** | mcporter | [xiaohongshu-mcp](https://github.com/user/xiaohongshu-mcp) 内部APIで、インストールしてすぐ使える | | 📕 **小紅書** | 閲覧・検索・**投稿・コメント・いいね** | Cookie | `pipx install xiaohongshu-cli` + `xhs login`[xhs-cli](https://github.com/jackwener/xiaohongshu-cli) |
| 🎵 **抖音** | 動画解析・ウォーターマークなしダウンロード | mcporter | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server)、ログイン不要 | | 🎵 **抖音** | 動画解析・ウォーターマークなしダウンロード | mcporter | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server)、ログイン不要 |
| 💼 **LinkedIn** | Jina Reader(公開ページ) | プロフィール、企業、求人検索 | エージェントに「LinkedInの設定を手伝って」と伝えてください | | 💼 **LinkedIn** | Jina Reader(公開ページ) | プロフィール、企業、求人検索 | エージェントに「LinkedInの設定を手伝って」と伝えてください |
| 💬 **WeChat記事** | 検索 + 閲覧 | 設定不要 | WeChat公式アカウント記事の検索+閲覧(完全Markdown)([wechat-article-for-ai](https://github.com/Panniantong/wechat-article-for-ai) + [miku_ai](https://github.com/GobinFan/Miku_Spider) | | 💬 **WeChat記事** | 検索 + 閲覧 | 設定不要 | WeChat公式アカウント記事の検索+閲覧(完全Markdown)([Exa](https://exa.ai) + [Camoufox](https://github.com/daijro/camoufox)(オプション) |
| 📰 **Weibo** | トレンド・検索・フィード・コメント | 設定不要 | ホット検索、コンテンツ/ユーザー/トピック検索、フィード、コメント([mcp-server-weibo](https://github.com/Panniantong/mcp-server-weibo) | | 📰 **Weibo** | トレンド・検索・フィード・コメント | 設定不要 | ホット検索、コンテンツ/ユーザー/トピック検索、フィード、コメント([mcp-server-weibo](https://github.com/Panniantong/mcp-server-weibo) |
| 💻 **V2EX** | 人気トピック・ノードトピック・トピック詳細+返信・ユーザープロフィール | 設定不要 | 公開JSON API、認証不要。技術コミュニティのコンテンツに最適 | | 💻 **V2EX** | 人気トピック・ノードトピック・トピック詳細+返信・ユーザープロフィール | 設定不要 | 公開JSON API、認証不要。技術コミュニティのコンテンツに最適 |
| 📈 **雪球(Xueqiu** | 株価・検索・人気投稿・人気銘柄 | 設定不要 | 公開APIで自動セッションCookie、ログイン不要 | | 📈 **雪球(Xueqiu** | 株価・検索・人気投稿・人気銘柄 | 設定不要 | 公開APIで自動セッションCookie、ログイン不要 |
@@ -76,7 +76,7 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma
| 📺 **YouTube** | 閲覧・**検索** | 設定不要 | 字幕 + 1800以上の動画サイトでの検索([yt-dlp](https://github.com/yt-dlp/yt-dlp) ⭐148K | | 📺 **YouTube** | 閲覧・**検索** | 設定不要 | 字幕 + 1800以上の動画サイトでの検索([yt-dlp](https://github.com/yt-dlp/yt-dlp) ⭐148K |
| 📺 **Bilibili** | 閲覧・**検索** | 設定不要 / プロキシ | 動画情報 + 字幕 + 検索。ローカルはそのまま動作、サーバーはプロキシが必要([yt-dlp](https://github.com/yt-dlp/yt-dlp) | | 📺 **Bilibili** | 閲覧・**検索** | 設定不要 / プロキシ | 動画情報 + 字幕 + 検索。ローカルはそのまま動作、サーバーはプロキシが必要([yt-dlp](https://github.com/yt-dlp/yt-dlp) |
| 📡 **RSS** | 閲覧 | 設定不要 | 任意のRSS/Atomフィード([feedparser](https://github.com/kurtmckee/feedparser) ⭐2.3K | | 📡 **RSS** | 閲覧 | 設定不要 | 任意のRSS/Atomフィード([feedparser](https://github.com/kurtmckee/feedparser) ⭐2.3K |
| 📖 **Reddit** | 検索・閲覧 | 無料 / プロキシ | 検索はExa(無料)経由。投稿の閲覧はサーバーでプロキシが必要 | | 📖 **Reddit** | 検索・閲覧 | Cookie | 2024年以降認証が必要 — インストール後 `rdt login` を実行([rdt-cli](https://github.com/public-clis/rdt-cli) |
> **セットアップレベル:** 設定不要 = インストールしてすぐ使える · 自動設定 = インストール時に処理 · mcporter = MCPサービスが必要 · Cookie = ブラウザからエクスポート · プロキシ = 月額$1 > **セットアップレベル:** 設定不要 = インストールしてすぐ使える · 自動設定 = インストール時に処理 · mcporter = MCPサービスが必要 · Cookie = ブラウザからエクスポート · プロキシ = 月額$1
@@ -127,7 +127,7 @@ Skillインストール後、エージェントは`agent-reach` CLIが利用可
- 「このリンクを読んで」→ `curl https://r.jina.ai/URL` で任意のWebページ - 「このリンクを読んで」→ `curl https://r.jina.ai/URL` で任意のWebページ
- 「このGitHubリポジトリは何?」→ `gh repo view owner/repo` - 「このGitHubリポジトリは何?」→ `gh repo view owner/repo`
- 「この動画の内容は?」→ `yt-dlp --dump-json URL` で字幕取得 - 「この動画の内容は?」→ `yt-dlp --dump-json URL` で字幕取得
- 「このツイートを読んで」→ `bird read URL` - 「このツイートを読んで」→ `twitter tweet URL`
- 「このRSSを購読して」→ `feedparser` でフィード解析 - 「このRSSを購読して」→ `feedparser` でフィード解析
- 「GitHubでLLMフレームワークを検索して」→ `gh search repos "LLM framework"` - 「GitHubでLLMフレームワークを検索して」→ `gh search repos "LLM framework"`
@@ -147,7 +147,7 @@ Skillインストール後、エージェントは`agent-reach` CLIが利用可
RedditとBilibiliはサーバーIPをブロックします。プロキシを取得し([Webshare](https://webshare.io) 推奨、月額$1)、アドレスをエージェントに伝えてください。 RedditとBilibiliはサーバーIPをブロックします。プロキシを取得し([Webshare](https://webshare.io) 推奨、月額$1)、アドレスをエージェントに伝えてください。
> ローカルPCではプロキシは不要です。Reddit検索はプロキシなしでもExaで無料で動作します。 > ローカルPCではプロキシは不要です。Reddit検索はプロキシなしでもrdt-cliで無料で動作します。
--- ---
@@ -171,7 +171,7 @@ $ agent-reach doctor
⬜ Webセマンティック検索 — exa.aiで無料キーを取得 ⬜ Webセマンティック検索 — exa.aiで無料キーを取得
🔧 設定可能: 🔧 設定可能:
Reddit投稿とコメント — 検索はExa(無料)経由。閲覧にはプロキシが必要 Reddit投稿とコメント — rdt-cliで検索+閲覧(無料、プロキシ不要)
⬜ 小紅書ノート — Cookieが必要。ブラウザからエクスポート ⬜ 小紅書ノート — Cookieが必要。ブラウザからエクスポート
ステータス: 9チャンネル中6チャンネルが利用可能 ステータス: 9チャンネル中6チャンネルが利用可能
@@ -187,7 +187,7 @@ $ agent-reach doctor
Agent Reach はシンプルなことを1つだけ行います:**ツールの選定と設定の判断をあなたの代わりに行います。** Agent Reach はシンプルなことを1つだけ行います:**ツールの選定と設定の判断をあなたの代わりに行います。**
インストール後、エージェントは上流ツール(bird CLI、yt-dlp、mcporter、gh CLI等)を直接呼び出します — 間にラッパーレイヤーはありません。 インストール後、エージェントは上流ツール(twitter-cli、rdt-cli、xhs-cli、yt-dlp、mcporter、gh CLI等)を直接呼び出します — 間にラッパーレイヤーはありません。
### 🔌 すべてのチャンネルはプラグ可能 ### 🔌 すべてのチャンネルはプラグ可能
@@ -196,12 +196,12 @@ Agent Reach はシンプルなことを1つだけ行います:**ツールの
``` ```
channels/ channels/
├── web.py → Jina Reader ← Firecrawl、Crawl4AIなどに差し替え可能… ├── web.py → Jina Reader ← Firecrawl、Crawl4AIなどに差し替え可能…
├── twitter.py → bird CLI Nitter、公式APIなどに差し替え可能… ├── twitter.py → twitter-cli ← 公式APIなどに差し替え可能…
├── youtube.py → yt-dlp ← YouTube API、Whisperなどに差し替え可能… ├── youtube.py → yt-dlp ← YouTube API、Whisperなどに差し替え可能…
├── github.py → gh CLI ← REST API、PyGithubなどに差し替え可能… ├── github.py → gh CLI ← REST API、PyGithubなどに差し替え可能…
├── bilibili.py → yt-dlp ← bilibili-apiなどに差し替え可能… ├── bilibili.py → yt-dlp ← bilibili-apiなどに差し替え可能…
├── reddit.py → JSON API + Exa ← PRAW、Pushshiftなどに差し替え可能… ├── reddit.py → rdt-cli ← 検索+閲覧、Cookie認証が必要
├── xiaohongshu.py → mcporter MCP ← 他のXHSツールに差し替え可能… ├── xiaohongshu.py → xhs-cli ← 他のXHSツールに差し替え可能…
├── douyin.py → mcporter MCP ← 他の抖音ツールに差し替え可能… ├── douyin.py → mcporter MCP ← 他の抖音ツールに差し替え可能…
├── linkedin.py → linkedin-mcp ← LinkedIn APIに差し替え可能… ├── linkedin.py → linkedin-mcp ← LinkedIn APIに差し替え可能…
├── rss.py → feedparser ← atomaなどに差し替え可能… ├── rss.py → feedparser ← atomaなどに差し替え可能…
@@ -216,15 +216,15 @@ channels/
| シナリオ | ツール | 理由 | | シナリオ | ツール | 理由 |
|----------|--------|------| |----------|--------|------|
| Webページ閲覧 | [Jina Reader](https://github.com/jina-ai/reader) | ⭐9.8K、無料、APIキー不要 | | Webページ閲覧 | [Jina Reader](https://github.com/jina-ai/reader) | ⭐9.8K、無料、APIキー不要 |
| ツイート閲覧 | [bird](https://www.npmjs.com/package/@steipete/bird) | Cookie認証、無料。公式APIは従量課金(投稿閲覧1件$0.005) | | ツイート閲覧 | [twitter-cli](https://github.com/public-clis/twitter-cli) | 2.1K Star、Cookie認証、検索/閲覧/タイムライン/長文 |
| 動画字幕 + 検索 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | ⭐148K、YouTube + Bilibili + 1800サイト | | 動画字幕 + 検索 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | ⭐148K、YouTube + Bilibili + 1800サイト |
| Web検索 | [Exa](https://exa.ai)[mcporter](https://github.com/nicepkg/mcporter)経由) | AIセマンティック検索、MCP統合、APIキー不要 | | Web検索 | [Exa](https://exa.ai)[mcporter](https://github.com/nicepkg/mcporter)経由) | AIセマンティック検索、MCP統合、APIキー不要 |
| GitHub | [gh CLI](https://cli.github.com) | 公式ツール、認証後フルAPI | | GitHub | [gh CLI](https://cli.github.com) | 公式ツール、認証後フルAPI |
| RSS閲覧 | [feedparser](https://github.com/kurtmckee/feedparser) | Pythonエコシステムの標準、⭐2.3K | | RSS閲覧 | [feedparser](https://github.com/kurtmckee/feedparser) | Pythonエコシステムの標準、⭐2.3K |
| 小紅書 | [xiaohongshu-mcp](https://github.com/user/xiaohongshu-mcp) | 内部API、アンチボット回避 | | 小紅書 | [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) | 1.5K Star、pipxインストール、検索/閲覧/コメント/投稿 |
| 抖音 | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCPサーバー、ログイン不要、動画解析 + ウォーターマークなしダウンロード | | 抖音 | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCPサーバー、ログイン不要、動画解析 + ウォーターマークなしダウンロード |
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+、MCPサーバー、ブラウザ自動化 | | LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+、MCPサーバー、ブラウザ自動化 |
| WeChat記事 | [wechat-article-for-ai](https://github.com/Panniantong/wechat-article-for-ai) + [miku_ai](https://github.com/GobinFan/Miku_Spider) | ステルスブラウザで記事全文閲覧 + Sogou検索 | | WeChat記事 | [Exa](https://exa.ai)(検索+閲覧)+ [Camoufox](https://github.com/daijro/camoufox)(オプション) | ゼロ設定で検索+全文閲覧、Camoufoxでオプション強化 |
| Weibo | `mcporter` | `mcporter call 'weibo.get_trendings(limit: 10)'` | | Weibo | `mcporter` | `mcporter call 'weibo.get_trendings(limit: 10)'` |
| 小宇宙Podcast | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` | | 小宇宙Podcast | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` |
@@ -249,7 +249,7 @@ channels/
<details> <details>
<summary><strong>Twitter/X APIに課金せずにAIエージェントで検索するには?</strong></summary> <summary><strong>Twitter/X APIに課金せずにAIエージェントで検索するには?</strong></summary>
Agent Reach は [bird CLI](https://www.npmjs.com/package/@steipete/bird) をCookie認証で使用します — 完全無料、Twitter APIのサブスクリプションは不要です。Agent Reach インストール後、Cookie-Editor Chrome拡張機能でTwitterのCookieをエクスポートし、`agent-reach configure twitter-cookies "your_cookies"` を実行すれば、`bird search "query" -n 10` でエージェントが検索できるようになります。 Agent Reach は [twitter-cli](https://github.com/public-clis/twitter-cli) をCookie認証で使用します — 完全無料、Twitter APIのサブスクリプションは不要です。`pipx install twitter-cli`インストール後、Cookie-Editor Chrome拡張機能でTwitterのCookieをエクスポートし、`agent-reach configure twitter-cookies "your_cookies"` を実行すれば、`twitter search "query" -n 10` でエージェントが検索できるようになります。
</details> </details>
<details> <details>
@@ -261,7 +261,7 @@ Agent Reach は [bird CLI](https://www.npmjs.com/package/@steipete/bird) をCook
<details> <details>
<summary><strong>サーバー/データセンターIPからRedditが403を返す?</strong></summary> <summary><strong>サーバー/データセンターIPからRedditが403を返す?</strong></summary>
Redditはデータセンター IPをブロックします。レジデンシャルプロキシを設定してください:`agent-reach configure proxy http://user:pass@ip:port`。推奨:Webshare(月額約$1)。ローカルマシンでは通常この問題は発生しません Agent Reach は [rdt-cli](https://github.com/public-clis/rdt-cli) でRedditにアクセスします。2024年以降、RedditはすべてのAPIリクエストに認証を要求しています。`pipx install rdt-cli` でインストール後、`rdt login`(ブラウザからCookieを自動抽出)を実行してください。その後 `rdt search "query"` で検索、`rdt read POST_ID` で投稿+コメントの閲覧ができます
</details> </details>
<details> <details>
@@ -273,19 +273,19 @@ Redditはデータセンター IPをブロックします。レジデンシャ
<details> <details>
<summary><strong>Agent Reach は無料?APIのコストは?</strong></summary> <summary><strong>Agent Reach は無料?APIのコストは?</strong></summary>
100%無料でオープンソース。すべてのバックエンド(bird CLI、yt-dlp、Jina Reader、Exa)は有料APIキーが不要な無料ツールです。唯一のオプションコストは、サーバーからReddit/Bilibiliにアクセスする場合のレジデンシャルプロキシ(月額約$1)です。 100%無料でオープンソース。すべてのバックエンド(twitter-cli、rdt-cli、xhs-cli、yt-dlp、Jina Reader、Exa)は有料APIキーが不要な無料ツールです。唯一のオプションコストは、サーバーからBilibiliにアクセスする場合のレジデンシャルプロキシ(月額約$1)です。
</details> </details>
<details> <details>
<summary><strong>Twitter APIの無料代替 — Webスクレイピング用</strong></summary> <summary><strong>Twitter APIの無料代替 — Webスクレイピング用</strong></summary>
Agent Reach はbird CLIを使用し、Cookie認証でTwitterにアクセスします — ブラウザセッションと同じです。API料金なし、レート制限のティアなし、開発者アカウント不要。検索、ツイート閲覧、プロフィール閲覧、タイムラインに対応。 Agent Reach はtwitter-cliを使用し、Cookie認証でTwitterにアクセスします — ブラウザセッションと同じです。API料金なし、レート制限のティアなし、開発者アカウント不要。検索、ツイート閲覧、プロフィール閲覧、タイムラインに対応。
</details> </details>
<details> <details>
<summary><strong>小紅書のコンテンツをプログラムで読むには?</strong></summary> <summary><strong>小紅書のコンテンツをプログラムで読むには?</strong></summary>
Agent Reach は xiaohongshu-mcpDockerで実行)と統合されています。セットアップ後、`mcporter call 'xiaohongshu.get_feed_detail(...)'` でノートを閲覧`mcporter call 'xiaohongshu.search_feeds(keyword: "query")'` で検索できます。 `pipx install xiaohongshu-cli` でインストール後、`xhs login`(ブラウザからCookieを自動抽出)。エージェントは `xhs search "query"` でノートを検索`xhs read NOTE_ID` で詳細を閲覧、`xhs comments NOTE_ID` でコメントを表示できます。Dockerは不要です。
</details> </details>
<details> <details>
@@ -298,7 +298,7 @@ douyin-mcp-serverをインストールすれば、`mcporter call 'douyin.parse_d
## クレジット ## クレジット
[Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [bird](https://www.npmjs.com/package/@steipete/bird) · [Exa](https://exa.ai) · [feedparser](https://github.com/kurtmckee/feedparser) · [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) [twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Exa](https://exa.ai) · [feedparser](https://github.com/kurtmckee/feedparser) · [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server)
## お問い合わせ ## お問い合わせ
+355
View File
@@ -0,0 +1,355 @@
<h1 align="center">👁️ Agent Reach</h1>
<p align="center">
<strong>AI 에이전트가 인터넷 전체에 접근할 수 있도록 한 번에 설정해 드립니다</strong>
</p>
<p align="center">
<a href="../LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.10+-green.svg?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+"></a>
<a href="https://github.com/Panniantong/agent-reach/stargazers"><img src="https://img.shields.io/github/stars/Panniantong/agent-reach?style=for-the-badge" alt="GitHub Stars"></a>
</p>
<p align="center">
<a href="#빠른-시작">빠른 시작</a> · 한국어 · <a href="../README.md">中文</a> · <a href="README_en.md">English</a> · <a href="README_ja.md">日本語</a> · <a href="#지원-플랫폼">지원 플랫폼</a> · <a href="#설계-철학">설계 철학</a>
</p>
---
## Agent Reach가 필요한 이유
AI 에이전트는 이미 인터넷에 접근할 수 있습니다 — 하지만 "인터넷에 접속할 수 있다"는 것은 시작에 불과합니다.
가장 가치 있는 정보는 소셜 미디어와 특화된 플랫폼에 분포되어 있습니다: Twitter 토론, Reddit 피드백, YouTube 튜토리얼, XiaoHongShu 리뷰, Bilibili 비디오, GitHub 활동... **여기가 정보 밀도가 가장 높은 곳**이지만, 각 플랫폼은 고유한 진입장벽이 있습니다:
| 문제점 | 현실 |
|------------|---------|
| Twitter API | 유료 사용, 중간 정도 사용량 ~월 $215 |
| Reddit | 서버 IP가 403 오류 발생 |
| XiaoHongShu | 둘러보기 위해 로그인 필요 |
| Bilibili | 해외/서버 IP 차단 |
에이전트를 이 플랫폼에 연결하려면 도구를 찾고, 의존성을 설치하고, 설정을 디버깅해야 합니다 — 하나씩 직접.
**Agent Reach는 이를 하나의 명령으로 바꿉니다:**
```
Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
```
이 명령을 에이전트에 복사해서 붙여넣으세요. 몇 분 뒤에는 트윗을 읽고, Reddit을 검색하고, Bilibili를 볼 수 있게 됩니다.
**이미 설치하셨나요? 한 번에 업데이트하세요:**
```
Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
```
### ✅ 시작하기 전에 알면 좋은 것들
| | |
|---|---|
| 💰 **완전 무료** | 모든 도구는 오픈 소스, 모든 API는 무료입니다. 유일한 비용은 서버 프록시(월 $1)일 수 있습니다 — 로컬 컴퓨터에서는 불필요 |
| 🔒 **프라이버시 안전** | Cookie는 로컬에 유지됩니다. 업로드되지 않습니다. 완전 오픈 소스 — 언제든지 감사 가능 |
| 🔄 **최신 상태 유지** | 업스트림 도구(yt-dlp, twitter-cli, rdt-cli, Jina Reader 등)를 추적하고 정기적으로 업데이트 |
| 🤖 **모든 에이전트와 호환** | Claude Code, OpenClaw, Cursor, Windsurf... 명령을 실행할 수 있는 모든 에이전트 |
| 🩺 **내장 진단 도구** | `agent-reach doctor` — 하나의 명령으로 작동 항목, 작동하지 않는 항목, 수정 방법 표시 |
---
## 지원 플랫폼
| 플랫폼 | 기능 | 설정 | 참고 |
|----------|-------------|:-----:|-------|
| 🌐 **Web** | 읽기 | 없음 | 모든 URL → 깨끗한 Markdown ([Jina Reader](https://github.com/jina-ai/reader) ⭐9.8K) |
| 🐦 **Twitter/X** | 읽기 · 검색 | Cookie | Cookie로 검색, 타임라인, 트윗 읽기, 아티클 읽기 가능 ([twitter-cli](https://github.com/public-clis/twitter-cli)) |
| 📕 **XiaoHongShu** | 읽기 · 검색 · **게시글 작성 · 댓글 · 좋아요** | Cookie | `pipx install xiaohongshu-cli` + `xhs login` ([xhs-cli](https://github.com/jackwener/xiaohongshu-cli)) |
| 🎵 **Douyin** | 비디오 파싱 · 워터마크 없는 다운로드 | mcporter | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) 통해, 로그인 불필요 |
| 💼 **LinkedIn** | Jina Reader (공개 페이지) | Cookie | 전체 프로필, 회사, 채용 공고 검색 가능. 에이전트에 "LinkedIn 설정 도와줘"라고 말하세요 |
| 💬 **WeChat Articles** | 검색 + 읽기 | 없음 | Exa를 통한 WeChat 공식 계정 게시글 검색 + 읽기 (설정 없음) + 선택적 [Camoufox](https://github.com/daijro/camoufox) |
| 📰 **Weibo** | 인기 · 검색 · 피드 · 댓글 | 없음 | 핫 검색, 콘텐츠/사용자/주제 검색, 피드, 댓글 ([mcp-server-weibo](https://github.com/Panniantong/mcp-server-weibo)) |
| 💻 **V2EX** | 인기 주제 · 노드 주제 · 주제 상세 + 답글 · 사용자 프로필 | 없음 | 공개 JSON API, 인증 없음. 기술 커뮤니티 콘텐츠에 적합 |
| 📈 **Xueqiu (雪球)** | 주식 시세 · 검색 · 인기 글 · 인기 종목 | 브라우저 Cookie | 에이전트에 "Xueqiu 설정 도와줘"라고 말하세요 |
| 🎙️ **Xiaoyuzhou Podcast** | 음성 변환 | 무료 API key | Groq Whisper를 통한 팟캐스트 오디오 → 전체 텍스트 변환 (무료) |
| 🔍 **Web Search** | 검색 | 자동 설정 | 설치 시 자동 설정, 무료, API key 불필요 ([Exa](https://exa.ai) via [mcporter](https://github.com/nicepkg/mcporter)) |
| 📦 **GitHub** | 읽기 · 검색 | 없음 | [gh CLI](https://cli.github.com) 기반. 공개 저장소는 즉시 사용 가능. `gh auth login`으로 Fork, Issue, PR 기능 활성화 |
| 📺 **YouTube** | 읽기 · **검색** | 없음 | 자막 + 1800+ 비디오 사이트 검색 ([yt-dlp](https://github.com/yt-dlp/yt-dlp) ⭐148K) |
| 📺 **Bilibili** | 읽기 · **검색** | 없음 / 프록시 | 비디오 정보 + 자막 + 검색. 로컬은 바로 작동, 서버는 프록시 필요 ([yt-dlp](https://github.com/yt-dlp/yt-dlp)) |
| 📡 **RSS** | 읽기 | 없음 | 모든 RSS/Atom 피드 ([feedparser](https://github.com/kurtmckee/feedparser) ⭐2.3K) |
| 📖 **Reddit** | 검색 · 읽기 | Cookie | 2024년부터 인증 필요 — 설치 후 `rdt login` 실행 ([rdt-cli](https://github.com/public-clis/rdt-cli)) |
> **설정 단계:** 없음 = 설치 후 바로 사용 · 자동 = 설치 시 처리 · mcporter = MCP 서비스 필요 · Cookie = 브라우저에서 내보내기 · 프록시 = 월 $1
---
## 빠른 시작
이 명령을 AI 에이전트(Claude Code, OpenClaw, Cursor 등)에 입력하세요:
```
Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md
```
에이전트가 자동으로 설치하고, 환경을 감지하고, 준비된 항목을 알려줍니다.
> 🔄 **이미 설치하셨나요?** 한 번에 업데이트:
> ```
> Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md
> ```
<details>
<summary>수동 설치</summary>
```bash
pip install https://github.com/Panniantong/agent-reach/archive/main.zip
agent-reach install --env=auto
```
</details>
<details>
<summary>Skill로 설치 (Claude Code / OpenClaw / Skill을 지원하는 모든 에이전트)</summary>
```bash
npx skills add Panniantong/Agent-Reach@agent-reach
```
Skill이 설치된 후, 에이전트는 `agent-reach` CLI 사용 가능 여부를 자동 감지하고 필요한 경우 설치합니다.
> `agent-reach install`을 통해 설치하면 Skill이 자동으로 등록됩니다 — 추가 단계 불필요.
</details>
---
## 별도 설정 없이 바로 사용
별도의 설정이 필요 없습니다. 에이전트에게 요청하기만 하면 됩니다:
- "이 링크 읽어줘" → 모든 웹 페이지에 대해 `curl https://r.jina.ai/URL`
- "이 GitHub 저장소는 무엇인가요?" → `gh repo view owner/repo`
- "이 비디오는 무엇을 다루나요?" → 자막을 위해 `yt-dlp --dump-json URL`
- "이 트윗 읽어줘" → `twitter tweet URL`
- "이 RSS 구독해줘" → 피드 파싱을 위해 `feedparser`
- "GitHub에서 LLM 프레임워크 검색" → `gh search repos "LLM framework"`
**기억할 명령이 없습니다.** 에이전트가 SKILL.md를 읽고 무엇을 호출할지 알고 있습니다.
---
## 필요할 때 설정
사용하지 않나요? 설정하지 마세요. 모든 단계는 선택 사항입니다.
### 🍪 Cookies — 무료, 2분
에이전트에 "Twitter 쿠키 설정 도와줘"라고 말하세요 — 브라우저에서 내보내는 과정을 안내해 줍니다. 로컬 컴퓨터는 자동으로 가져올 수 있습니다.
### 🌐 Proxy — 월 $1, 서버 전용
Bilibili은 서버 IP를 차단합니다. 프록시를 가져오세요([Webshare](https://webshare.io) 추천, 월 $1)하고 주소를 에이전트에 보내세요.
> Reddit은 이제 프록시 없이 rdt-cli를 통해 무료로 작동합니다. 로컬 컴퓨터는 Bilibili에도 프록시가 필요 없습니다.
---
## 한눈에 보는 상태
```
$ agent-reach doctor
👁️ Agent Reach 상태
========================================
✅ 사용 가능:
✅ GitHub 저장소 및 코드 — 공개 저장소 읽기 및 검색 가능
✅ Twitter/X 트윗 — 읽기 가능. Cookie로 검색 및 게시 가능
✅ YouTube 비디오 자막 — yt-dlp
⚠️ Bilibili 비디오 정보 — 서버 IP가 차단될 수 있음, 프록시 설정
✅ RSS/Atom 피드 — feedparser
✅ 웹 페이지 (모든 URL) — Jina Reader API
🔍 검색 (무료 Exa key로 잠금 해제):
⬜ 웹 시맨틱 검색 — exa.ai에서 무료 key 발급
🔧 설정 가능:
✅ Reddit 글 및 댓글 — rdt-cli를 통한 검색 및 읽기 (무료, 프록시 없음)
⬜ XiaoHongShu 노트 — 쿠키 필요. 브라우저에서 내보내기
상태: 6/9 채널 사용 가능
```
---
## 설계 철학
**Agent Reach는 스캐폴딩(scaffolding) 도구이지, 프레임워크가 아닙니다.**
새 에이전트를 실행할 때마다 도구를 찾고, 의존성을 설치하고, 설정을 디버깅하는 데 시간을 보내게 됩니다 — Twitter는 무엇으로 읽나요? Reddit 차단을 어떻게 우회하나요? YouTube 자막은 어떻게 추출하나요? 매번 동일한 작업을 반복해야 합니다.
Agent Reach는 한 가지 간단한 작업을 수행합니다: **도구 선택 및 설정 결정을 대신 해줍니다.**
설치 후, 에이전트는 업스트림 도구(twitter-cli, rdt-cli, xhs-cli, yt-dlp, mcporter, gh CLI 등)를 직접 호출합니다 — 중간에 래퍼 계층이 없습니다.
### 🔌 모든 채널은 플러그인 가능
각 플랫폼은 업스트림 도구에 매핑됩니다. **마음에 안 드나요? 교체하세요.**
```
channels/
├── web.py → Jina Reader ← Firecrawl, Crawl4AI로 교체...
├── twitter.py → twitter-cli ← 공식 API로 교체...
├── youtube.py → yt-dlp ← YouTube API, Whisper로 교체...
├── github.py → gh CLI → REST API, PyGithub로 교체...
├── bilibili.py → yt-dlp → bilibili-api로 교체...
├── reddit.py → rdt-cli → 검색 + 읽기, cookie 인증 필요
├── xiaohongshu.py → mcporter MCP ← 다른 XHS 도구로 교체...
├── douyin.py → mcporter MCP ← 다른 Douyin 도구로 교체...
├── linkedin.py → linkedin-mcp ← LinkedIn API로 교체...
├── rss.py → feedparser ← atoma로 교체...
├── exa_search.py → mcporter MCP ← Tavily, SerpAPI로 교체...
└── __init__.py → 채널 레지스트리 (doctor 검사용)
```
각 채널 파일은 업스트림 도구가 설치되어 작동하는지만 확인합니다(`agent-reach doctor``check()` 메서드). 실제 읽기 및 검색은 업스트림 도구를 직접 호출하여 수행합니다.
### 현재 도구 선택
| 시나리오 | 도구 | 이유 |
|----------|------|-----|
| 웹 페이지 읽기 | [Jina Reader](https://github.com/jina-ai/reader) | 9.8K stars, 무료, API key 불필요 |
| 트윗 읽기 | [twitter-cli](https://github.com/public-clis/twitter-cli) | 2.1K stars, cookie 인증, 검색/읽기/타임라인/글 |
| Reddit | [rdt-cli](https://github.com/public-clis/rdt-cli) | 304 stars, cookie 인증, 검색 + 전체 글 + 댓글 |
| 비디오 자막 + 검색 | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | 154K stars, YouTube + Bilibili + 1800 사이트 |
| Bilibili 향상 | [bili-cli](https://github.com/public-clis/bilibili-cli) | 590 stars, 인기/순위/검색/피드 |
| 웹 검색 | [Exa](https://exa.ai) via [mcporter](https://github.com/nicobailon/mcporter) | AI 시맨틱 검색, MCP 통합, API key 불필요 |
| GitHub | [gh CLI](https://cli.github.com) | 공식 도구, 인증 후 전체 API |
| RSS 읽기 | [feedparser](https://github.com/kurtmckee/feedparser) | Python 생태계 표준, 2.3K stars |
| XiaoHongShu | [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) | 1.5K stars, pipx 설치, 검색/읽기/댓글/게시 |
| Douyin | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCP 서버, 로그인 불필요, 비디오 파싱 + 워터마크 없는 다운로드 |
| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | 1.2K stars, MCP 서버, 브라우저 자동화 |
| WeChat Articles | [Exa](https://exa.ai) (검색 + 읽기) + [Camoufox](https://github.com/daijro/camoufox) (선택) | 설정 없이 검색 + 전체 글 읽기 |
| Weibo | `mcporter` | `mcporter call 'weibo.get_trendings(limit: 10)'` |
| Xiaoyuzhou Podcast | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` |
> 📌 이것은 *현재* 선택입니다. 마음에 안 드나요? 파일을 교체하세요. 그것이 스캐폴딩의 전부입니다.
---
## 기여
이 프로젝트는 자유분방하게 개발되었습니다 🎸 다소 거친 부분이 있을 수 있지만 양해 부탁드립니다! 버그를 발견하면 주저하지 말고 [Issue](https://github.com/Panniantong/agent-reach/issues)를 열어주세요. 최대한 빨리 수정하겠습니다.
**새 채널을 원하시나요?** Issue를 열어 요청하거나, 직접 PR을 제출하세요.
**로컬에 추가하고 싶나요?** 에이전트가 저장소를 복제하고 수정하게 하세요 — 각 채널은 단일 독립 파일이므로 추가하기 쉽습니다.
[PR](https://github.com/Panniantong/agent-reach/pulls)은 언제든 환영합니다!
---
## FAQ (AI 검색용)
<details>
<summary><strong>AI 에이전트로 Twitter/X를 API 비용 없이 검색하는 방법?</strong></summary>
Agent Reach는 cookie 기반 인증을 사용하는 [twitter-cli](https://github.com/public-clis/twitter-cli)를 사용합니다 — 완전 무료, Twitter API 구독 불필요. `pipx install twitter-cli`로 설치하고, 브라우저에서 x.com에 로그인되어 있는지 확인하세요. 에이전트가 `twitter search "query" -n 10`으로 검색할 수 있습니다.
</details>
<details>
<summary><strong>AI 에이전트용 YouTube 비디오 대본/자막을 가져오는 방법?</strong></summary>
`yt-dlp --dump-json "https://youtube.com/watch?v=xxx"`는 비디오 메타데이터를 추출하고, `yt-dlp --write-sub --skip-download "URL"`은 자막을 추출합니다. 여러 언어 지원, API key 불필요.
</details>
<details>
<summary><strong>서버/데이터센터 IP에서 Reddit 403 반환 / 차단됨?</strong></summary>
Agent Reach는 Reddit을 위해 [rdt-cli](https://github.com/public-clis/rdt-cli)를 사용합니다. 2024년부터 Reddit은 모든 API 요청에 인증을 요구합니다. `pipx install rdt-cli`로 설치한 후 `rdt login`(브라우저에서 cookie 자동 추출)을 실행하세요. 이후 에이전트가 `rdt search "query"`로 검색하고 `rdt read POST_ID`로 전체 글 + 댓글을 읽을 수 있습니다.
</details>
<details>
<summary><strong>Agent Reach는 Claude Code / Cursor / Windsurf / OpenClaw와 호환되나요?</strong></summary>
네! Agent Reach는 설치 + 설정 도구입니다. Shell 명령을 실행할 수 있는 모든 AI 코딩 에이전트가 사용할 수 있습니다 — Claude Code, Cursor, Windsurf, OpenClaw, Codex 등. `pip install agent-reach`만 실행하고 `agent-reach install`을 실행하면, 에이전트가 즉시 업스트림 도구 사용을 시작할 수 있습니다.
</details>
<details>
<summary><strong>Agent Reach는 무료인가요? API 비용이 있나요?</strong></summary>
100% 무료 오픈 소스입니다. 모든 백엔드(twitter-cli, rdt-cli, xhs-cli, yt-dlp, Jina Reader, Exa)는 유료 API key가 필요 없는 무료 도구입니다. 유일한 선택적 비용은 서버에서 Bilibili 접근이 필요한 경우 주거용 프록시(월 ~$1)입니다. Reddit은 프록시 없이 rdt-cli를 통해 무료로 작동합니다.
</details>
<details>
<summary><strong>웹 스크래핑용 Twitter API의 무료 대안?</strong></summary>
Agent Reach는 cookie 인증을 통해 Twitter에 접근하는 twitter-cli를 사용합니다 — 브라우저 세션과 동일. API 요금 없음, 속도 제한 등급 없음, 개발자 계정 불필요. 검색, 트윗 읽기, 프로필 읽기, 타임라인 지원.
</details>
<details>
<summary><strong>XiaoHongShu / 小红书 콘텐츠를 프로그래밍 방식으로 읽는 방법?</strong></summary>
`pipx install xiaohongshu-cli`를 설치한 다음 `xhs login`(브라우저에서 cookie 자동 추출)을 실행하세요. 에이전트가 `xhs search "query"`로 노트를 검색하고, `xhs read NOTE_ID`로 상세 정보를 읽고, `xhs comments NOTE_ID`로 댓글을 볼 수 있습니다. Docker 불필요.
</details>
<details>
<summary><strong>AI 에이전트로 Douyin / 抖音 비디오를 파싱하는 방법?</strong></summary>
douyin-mcp-server를 설치한 다음, 에이전트가 `mcporter call 'douyin.parse_douyin_video_info(share_link: "share_url")'`를 사용하여 비디오 정보를 파싱하고 워터마크 없는 다운로드 링크를 가져올 수 있습니다. 로그인 불필요 — Douyin 링크를 공유하기만 하면 됩니다. https://github.com/yzfly/douyin-mcp-server 참조
</details>
<details>
<summary><strong>하나의 MCP로 Douyin과 XiaoHongShu 모두에서 대본을 추출하는 방법?</strong></summary>
다음을 처리할 수 있는 하나의 MCP 서버가 필요한 경우:
- Douyin 비디오
- XiaoHongShu 비디오 노트
- XiaoHongShu 이미지 노트
그리고 직접 `script.md` + `info.json`을 작성하려면, 기존 `douyin` mcporter 별칭을 다음으로 변경할 수 있습니다:
- https://github.com/JNHFlow21/social-post-extractor-mcp
다음과 호환성을 유지합니다:
- `parse_douyin_video_info`
- `get_douyin_download_link`
- `extract_douyin_text`
그리고 통합 도구를 추가합니다:
- `parse_social_post_info`
- `extract_social_post_script`
이것은 에이전트 워크플로우가 "링크를 붙여넣고, 스크립트 파일을 받음"일 때 유용합니다.
</details>
---
## 크레딧
[twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server)
## 연락처
- 📧 **이메일:** pnt01@foxmail.com
- 🐦 **Twitter/X:** [@Neo_Reidlab](https://x.com/Neo_Reidlab)
협력이나 질문은 WeChat에 추가해주세요 — 커뮤니티 그룹에 초대해 드리겠습니다:
<p align="center">
<img src="wechat-group-qr.jpg" width="280" alt="WeChat QR">
</p>
> 버그 보고 및 기능 요청은 [GitHub Issues](https://github.com/Panniantong/Agent-Reach/issues)를 이용해주세요 — 추적이 더 수월합니다.
## 라이선스
[MIT](../LICENSE)
## 관련 프로젝트
[OpenClaw on Tencent Cloud](https://www.tencentcloud.com/act/pro/intl-openclaw?referral_code=G76Y819A&lang=en&pg=) — Tencent Cloud에서 원클릭 OpenClaw: 채팅으로 Agent Reach를 연결하고 인터넷 기능을 활성화하세요.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=Panniantong/Agent-Reach&type=Date&v=20260309)](https://star-history.com/#Panniantong/Agent-Reach&Date)
+63 -128
View File
@@ -20,7 +20,7 @@ Copy this to your AI Agent:
### Goal ### Goal
Install Agent Reach and all upstream tools so your user's agent has full internet access. After installation, you'll use the upstream tools directly (bird CLI, yt-dlp, mcporter, gh CLI, etc.) — Agent Reach is just the installer and health checker. Install Agent Reach and all upstream tools so your user's agent has full internet access. After installation, you'll use the upstream tools directly (OpenCLI, twitter-cli, bili-cli, rdt-cli, yt-dlp, mcporter, gh CLI, etc.) — Agent Reach is the selector, installer, health checker and router, never a wrapper.
### ⚠️ Boundaries ### ⚠️ Boundaries
@@ -40,7 +40,7 @@ All Agent Reach files go in dedicated directories — **never in the agent works
| Purpose | Directory | Example | | Purpose | Directory | Example |
|---------|-----------|---------| |---------|-----------|---------|
| Config & tokens | `~/.agent-reach/` | `~/.agent-reach/config.json` | | Config & tokens | `~/.agent-reach/` | `~/.agent-reach/config.json` |
| Upstream tool repos | `~/.agent-reach/tools/` | `~/.agent-reach/tools/douyin-mcp-server/` | | Upstream tool repos | `~/.agent-reach/tools/` | `~/.agent-reach/tools/xiaoyuzhou/` |
| Temporary files | `/tmp/` | `/tmp/yt-dlp-output/` | | Temporary files | `/tmp/` | `/tmp/yt-dlp-output/` |
| Skills | `~/.openclaw/skills/agent-reach/` | SKILL.md | | Skills | `~/.openclaw/skills/agent-reach/` | SKILL.md |
@@ -61,6 +61,19 @@ pip install https://github.com/Panniantong/agent-reach/archive/main.zip
agent-reach install --env=auto agent-reach install --env=auto
``` ```
> 💡 **Windows / Microsoft Store Python alias?**
> 如果 `python3 --version` 打开 Microsoft Store,或 `where python3` 指向
> `...\AppData\Local\Microsoft\WindowsApps\python3.exe`,说明 `python3` 是 Windows
> 的 Store alias,不是可用的 Python 安装。请改用 Python Launcher `py -3`,或实际安装目录里的 `python.exe`
>
> PowerShell 示例:
> ```powershell
> py -3 -m venv $env:USERPROFILE\.agent-reach-venv
> $env:USERPROFILE\.agent-reach-venv\Scripts\Activate.ps1
> python -m pip install https://github.com/Panniantong/agent-reach/archive/main.zip
> agent-reach install --env=auto
> ```
This installs core infrastructure (gh CLI, Node.js, mcporter, Exa search, yt-dlp config) and activates these zero-config channels: This installs core infrastructure (gh CLI, Node.js, mcporter, Exa search, yt-dlp config) and activates these zero-config channels:
- Web (Jina Reader), YouTube, GitHub, RSS, Exa Search, V2EX, Bilibili (basic) - Web (Jina Reader), YouTube, GitHub, RSS, Exa Search, V2EX, Bilibili (basic)
@@ -83,27 +96,25 @@ After installing the basics, **ask the user** which additional channels they nee
> >
> 还有这些可选渠道,你需要哪些? > 还有这些可选渠道,你需要哪些?
> >
> - 🌟 **OpenCLI**(桌面推荐)— 一次安装,小红书/Reddit/B站字幕/Twitter 备选全解锁(复用浏览器登录态,零配置;只需在 Chrome 商店点一次"添加扩展")
> - 🐦 **Twitter/X** — 搜推文、看时间线(需要登录 Cookie) > - 🐦 **Twitter/X** — 搜推文、看时间线(需要登录 Cookie)
> - 📰 **微博** — 热搜、搜索、用户动态
> - 💬 **微信公众号** — 搜索和阅读公众号文章
> - 📈 **雪球** — 股票行情、热门帖子(需要登录 Cookie) > - 📈 **雪球** — 股票行情、热门帖子(需要登录 Cookie)
> - 🎙️ **小宇宙播客** — 音频转文字(需要免费 Groq Key) > - 🎙️ **小宇宙播客** — 音频转文字(需要免费 Groq Key)
> - 📕 **小红书**阅读、搜索、发帖(需要登录 > - 📕 **小红书**搜索、阅读、评论(桌面走 OpenCLI;服务器用 xiaohongshu-mcp 扫码
> - 📖 **Reddit** — 搜索和阅读帖子 > - 📖 **Reddit** — 搜索和阅读帖子(必须登录态:桌面 OpenCLI 或 rdt-cli + Cookie
> - 📺 **B站完整版** — 热门、排行、搜索 > - 📺 **B站完整版** — 热门、排行、搜索、视频详情(bili-cli,无需登录)
> - 🎵 **抖音** — 视频解析
> - 💼 **LinkedIn** — Profile、职位搜索 > - 💼 **LinkedIn** — Profile、职位搜索
> >
> 告诉我你要哪些,比如"帮我装微博和 Twitter"。或者说"全部装"。 > 告诉我你要哪些,比如"帮我装小红书和 Twitter"。或者说"全部装"。
Based on the user's choice, run: Based on the user's choice, run:
```bash ```bash
agent-reach install --env=auto --channels=twitter,weibo # Example: user chose Twitter + Weibo agent-reach install --env=auto --channels=opencli,xiaohongshu # Example: desktop user chose XHS (OpenCLI-backed)
agent-reach install --env=auto --channels=all # User wants everything agent-reach install --env=auto --channels=all # User wants everything
``` ```
Supported channel names: `twitter`, `weibo`, `wechat`, `xiaoyuzhou`, `xueqiu`, `xiaohongshu`, `reddit`, `bilibili`, `douyin`, `linkedin`, `all` Supported channel names: `opencli`, `twitter`, `xiaoyuzhou`, `xueqiu`, `xiaohongshu`, `reddit`, `bilibili`, `linkedin`, `all`
### Step 3: Fix what's broken ### Step 3: Fix what's broken
@@ -140,8 +151,7 @@ agent-reach configure twitter-cookies "PASTED_STRING"
> **代理说明(中国大陆等需要翻墙的网络环境):** > **代理说明(中国大陆等需要翻墙的网络环境):**
> >
> bird CLI 使用 Node.js,在需要代理的网络环境下可通过环境变量配置代理。 > twitter-cli 和 rdt-cli 使用 Python,在需要代理的网络环境下可通过环境变量配置代理。
> Agent Reach 安装时会自动安装 `undici``npm install -g undici`)以提供代理支持。
> >
> **你(Agent)需要做的:** > **你(Agent)需要做的:**
> 1. 确认用户配了代理:`agent-reach configure proxy http://user:pass@ip:port` > 1. 确认用户配了代理:`agent-reach configure proxy http://user:pass@ip:port`
@@ -150,51 +160,45 @@ agent-reach configure twitter-cookies "PASTED_STRING"
> >
> 如果用户报告 "fetch failed",参考 [troubleshooting.md](troubleshooting.md) > 如果用户报告 "fetch failed",参考 [troubleshooting.md](troubleshooting.md)
**Reddit & Bilibili full access (server users):** **Reddit (login is mandatory — no zero-config path):**
> "Reddit and Bilibili block server IPs. To unlock full access, I need a residential proxy. You can get one at https://webshare.io ($1/month). Send me the proxy address." > Reddit 的匿名接口已被封、官方 API 需人工审批。桌面用户首选 OpenCLI(浏览器里登录过 reddit.com 即可用);服务器/存量用户用 rdt-cli:
```bash ```bash
agent-reach configure proxy http://user:pass@ip:port # PyPI 落后,从 GitHub 装(与代码内 _RDT_GIT_SOURCE 同一钉定版本)
pipx install 'git+https://github.com/public-clis/rdt-cli.git@5e4fb3720d5c174e976cd425ccc3b879d52cac66'
rdt login # 自动提取浏览器 Cookie;服务器无浏览器时按 doctor 提示手动写 Cookie
``` ```
**XiaoHongShu / 小红书 (需要 Docker):** > 中国大陆访问 Reddit 需要代理;服务器 IP 被风控时可配住宅代理(如 https://webshare.io,约 $1/月):
> "小红书需要一个 MCP 服务。需要你的机器上有 Docker。安装好 Docker 后我来搞定剩下的。"
```bash
docker run -d --name xiaohongshu-mcp -p 18060:18060 xpzouying/xiaohongshu-mcp
mcporter config add xiaohongshu http://localhost:18060/mcp
```
> 如果在服务器上,建议加代理避免 IP 风控:
> `docker run -d --name xiaohongshu-mcp -p 18060:18060 -e XHS_PROXY=http://user:pass@ip:port xpzouying/xiaohongshu-mcp`
>
> **登录方式(优先用 Cookie-Editor,最简单):**
> 1. 用户在自己的浏览器登录小红书 (xiaohongshu.com)
> 2. 用 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) 插件导出 CookieJSON 或 Header String 格式均可)
> 3. 把 Cookie 字符串发给 Agent
> 4. Agent 运行命令完成登录:
>
> ```bash > ```bash
> # JSON 格式(Cookie-Editor → Export → JSON > agent-reach configure proxy http://user:pass@ip:port
> agent-reach configure xhs-cookies '[{"name":"web_session","value":"xxx","domain":".xiaohongshu.com",...}]' > ```
**XiaoHongShu / 小红书(多后端,按环境选):**
> **桌面电脑(推荐 OpenCLI):**
> "小红书走 OpenCLI——复用你浏览器里的登录态,平时刷过小红书就直接能用,零配置。"
```bash
agent-reach install --channels opencli
```
> 装完后引导用户做唯一一步手动操作(Chrome 安全限制,无法代劳):
> 1. 打开 https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk
> 2. 点「添加至 Chrome」
> 3. 运行 `opencli doctor` 验证(显示 Extension: connected 即成功)
> >
> # 或 Header String 格式(Cookie-Editor → Export → Header String > **服务器 / 无桌面环境(xiaohongshu-mcp):**
> 1. 从 https://github.com/xpzouying/xiaohongshu-mcp/releases 下载对应平台 binary 到 `~/.agent-reach/tools/`
> 2. 启动服务(首次运行会自动下载约 150MB 无头浏览器,耐心等完成)
> 3. 让用户扫码登录(agent 调 `get_login_qrcode` 工具取二维码)
> 4. 接入:`mcporter config add xiaohongshu http://localhost:18060/mcp`
> 5. 调用时务必带 `--timeout 120000`
>
> **存量用户(xhs-cli):** 已装好的 xhs-cli 继续作为备选后端工作(上游 2026-03 起停更,不推荐新装)。`xhs login` 自动提取浏览器 Cookie;失败时用 Cookie-Editor 导出后:
> ```bash
> agent-reach configure xhs-cookies "key1=val1; key2=val2; ..." > agent-reach configure xhs-cookies "key1=val1; key2=val2; ..."
> ``` > ```
>
> **注意:** `http://localhost:18060` 根路径可能返回 404,MCP 服务在 `/mcp` 路径。推荐使用 Cookie-Editor 导出方式,不要依赖 Docker 容器内的 QR 扫码登录。
**微博 / Weibo (mcp-server-weibo):**
> "微博已默认安装,装好即用。可搜索微博内容、查看热搜、获取用户动态和评论。"
如果自动安装失败,手动安装:
```bash
pip install git+https://github.com/Panniantong/mcp-server-weibo.git
mcporter config add weibo --command 'mcp-server-weibo'
```
> 无需登录、无需 Cookie、无需代理。海外服务器也可以直接访问。
**雪球 / Xueqiu (股票行情 + 热门帖子):** **雪球 / Xueqiu (股票行情 + 热门帖子):**
> "雪球需要登录后的 Cookie。请先在 Chrome 里登录 xueqiu.com,然后运行:" > "雪球需要登录后的 Cookie。请先在 Chrome 里登录 xueqiu.com,然后运行:"
@@ -234,75 +238,6 @@ agent-reach configure groq-key gsk_xxxxx
> - 转录质量高(Whisper large-v3),但不区分说话人 > - 转录质量高(Whisper large-v3),但不区分说话人
> - 2 小时以上的播客建议分批处理 > - 2 小时以上的播客建议分批处理
**抖音 / Douyin (douyin-mcp-server):**
> "抖音视频解析需要一个 MCP 服务。安装 douyin-mcp-server 后即可解析视频、获取无水印下载链接。"
```bash
# 1. 安装
pip install douyin-mcp-server
# 2. 启动 HTTP 服务(端口 18070
# 方式一:用 uv(推荐)
mkdir -p ~/.agent-reach/tools && cd ~/.agent-reach/tools
git clone https://github.com/yzfly/douyin-mcp-server.git && cd douyin-mcp-server
uv sync && uv run python run_http.py
# 方式二:直接用 Python 启动
python -c "
from douyin_mcp_server.server import mcp
mcp.settings.host = '127.0.0.1'
mcp.settings.port = 18070
mcp.run(transport='streamable-http')
"
# 3. 注册到 mcporter
mcporter config add douyin http://localhost:18070/mcp
```
> 无需认证即可解析视频信息和获取下载链接。
> 如需 AI 语音识别提取文案功能,需要配置硅基流动 API Key(`export API_KEY="sk-xxx"`)。
>
> 详见 https://github.com/yzfly/douyin-mcp-server
**可选实现:Douyin + XiaoHongShu unified extractor**
> "如果你想把抖音和小红书统一成一个 MCP,并直接输出 `script.md``info.json`,可以改用 social-post-extractor-mcp。"
适用场景:
- 抖音视频转文字稿
- 小红书视频笔记转文字稿
- 小红书图文笔记正文 + 图片文字提取
兼容性:
- 仍然可以注册成 `douyin` 这个 mcporter server 名称
- 兼容旧工具名 `parse_douyin_video_info` / `get_douyin_download_link` / `extract_douyin_text`
- 同时新增 `parse_social_post_info` / `extract_social_post_script`
示例配置:
```bash
git clone https://github.com/JNHFlow21/social-post-extractor-mcp.git
cd social-post-extractor-mcp
uv sync
mcporter config add douyin \
--command /bin/zsh \
--arg -lc \
--arg "cd '$PWD' && exec '.venv/bin/python' -m social_post_extractor_mcp" \
--env ASR_PROVIDER=bailian \
--env ASR_MODEL=paraformer-v2 \
--env VISION_PROVIDER=bailian \
--env VISION_MODEL=qwen3-vl-flash \
--env CLEAN_PROVIDER=bailian \
--env CLEAN_MODEL=qwen-flash \
--env BAILIAN_API_KEY=YOUR_BAILIAN_API_KEY
```
> 这个实现更适合“把链接直接交给 Agent,然后拿到脚本文件”的工作流。
>
> 详见 https://github.com/JNHFlow21/social-post-extractor-mcp
**LinkedIn (可选 — linkedin-scraper-mcp):** **LinkedIn (可选 — linkedin-scraper-mcp):**
> "LinkedIn 基本内容可通过 Jina Reader 读取。完整功能(Profile 详情、职位搜索)需要 linkedin-scraper-mcp。" > "LinkedIn 基本内容可通过 Jina Reader 读取。完整功能(Profile 详情、职位搜索)需要 linkedin-scraper-mcp。"
@@ -358,7 +293,7 @@ If the user agrees, create a **cron job** (daily, `sessionTarget: "isolated"`, `
运行 agent-reach watch 命令。 运行 agent-reach watch 命令。
如果输出包含"全部正常",不需要通知用户,静默结束。 如果输出包含"全部正常",不需要通知用户,静默结束。
如果输出包含问题(❌ ⚠️)或新版本(🆕),把完整报告发给用户,并建议修复方案。 如果输出包含问题(❌ ⚠️)或新版本(🆕),把完整报告发给用户,并建议修复方案。
如果有新版本可用,问用户是否要升级(升级命令:pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip)。 如果有新版本可用,问用户是否要升级(把这句话发给用户的 Agent 即可完整更新:帮我更新 Agent Reachhttps://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md)。
``` ```
If the user wants a different agent to handle it, let them choose. If the user wants a different agent to handle it, let them choose.
@@ -370,7 +305,7 @@ If the user wants a different agent to handle it, let them choose.
| Command | What it does | | Command | What it does |
|---------|-------------| |---------|-------------|
| `agent-reach install --env=auto` | Install core channels (lightweight, zero-config) | | `agent-reach install --env=auto` | Install core channels (lightweight, zero-config) |
| `agent-reach install --env=auto --channels=twitter,weibo` | Install core + optional channels | | `agent-reach install --env=auto --channels=twitter,xiaohongshu` | Install core + optional channels |
| `agent-reach install --env=auto --channels=all` | Install everything | | `agent-reach install --env=auto --channels=all` | Install everything |
| `agent-reach install --env=auto --safe` | Safe setup (no auto system changes) | | `agent-reach install --env=auto --safe` | Safe setup (no auto system changes) |
| `agent-reach install --env=auto --dry-run` | Preview what would be done | | `agent-reach install --env=auto --dry-run` | Preview what would be done |
@@ -378,23 +313,23 @@ If the user wants a different agent to handle it, let them choose.
| `agent-reach watch` | Quick health + update check (for scheduled tasks) | | `agent-reach watch` | Quick health + update check (for scheduled tasks) |
| `agent-reach check-update` | Check for new versions | | `agent-reach check-update` | Check for new versions |
| `agent-reach configure twitter-cookies "..."` | Unlock Twitter search + posting | | `agent-reach configure twitter-cookies "..."` | Unlock Twitter search + posting |
| `agent-reach configure proxy URL` | Unlock Reddit + Bilibili on servers | | `agent-reach configure proxy URL` | 保存代理地址(Agent 访问 Reddit/Twitter 等受限网络时读取它设置 HTTP_PROXY/HTTPS_PROXY,不是自动解锁开关) |
| `agent-reach configure groq-key gsk_xxx` | Unlock Xiaoyuzhou podcast transcription | | `agent-reach configure groq-key gsk_xxx` | Unlock Xiaoyuzhou podcast transcription |
After installation, use upstream tools directly. See SKILL.md for the full command reference: After installation, use upstream tools directly. See SKILL.md for the full command reference:
| Platform | Upstream Tool | Example | | Platform | Upstream Tool | Example |
|----------|--------------|---------| |----------|--------------|---------|
| Twitter/X | `bird` | `bird search "query" -n 10` | | Twitter/X | `twitter`(备选 `opencli` | `twitter search "query" -n 10` |
| YouTube | `yt-dlp` | `yt-dlp --dump-json URL` | | YouTube | `yt-dlp` | `yt-dlp --dump-json URL` |
| Bilibili | `yt-dlp` | `yt-dlp --dump-json URL` | | Bilibili | `bili`(字幕走 `opencli` | `bili search "query" --type video` / `opencli bilibili subtitle BVxxx` |
| Reddit | `mcporter` (Exa) | `mcporter call 'exa.web_search_exa(query: "...", includeDomains: ["reddit.com"])'` | | Reddit | `opencli`(备选 `rdt` | `opencli reddit search "query" -f yaml` / `rdt read POST_ID` |
| GitHub | `gh` | `gh search repos "query"` | | GitHub | `gh` | `gh search repos "query"` |
| Web | `curl` + Jina | `curl -s "https://r.jina.ai/URL"` | | Web | `curl` + Jina | `curl -s "https://r.jina.ai/URL"` |
| Exa Search | `mcporter` | `mcporter call 'exa.web_search_exa(...)'` | | Exa Search | `mcporter` | `mcporter call 'exa.web_search_exa(...)'` |
| 小红书 | `mcporter` | `mcporter call 'xiaohongshu.search_feeds(...)'` | | 小红书 | `opencli`(服务器 `mcporter` | `opencli xiaohongshu search "query" -f yaml` |
| 微博 | `mcporter` | `mcporter call 'weibo.get_trendings(limit: 10)'` |
| 小宇宙播客 | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` | | 小宇宙播客 | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh <URL>` |
| 抖音 | `mcporter` | `mcporter call 'douyin.parse_douyin_video_info(...)'` |
| LinkedIn | `mcporter` | `mcporter call 'linkedin.get_person_profile(...)'` | | LinkedIn | `mcporter` | `mcporter call 'linkedin.get_person_profile(...)'` |
| RSS | `feedparser` | `python3 -c "import feedparser; ..."` | | RSS | `feedparser` | `python3 -c "import feedparser; ..."` |
> 多后端平台以 `agent-reach doctor --json``active_backend` 为准。
+11 -9
View File
@@ -16,11 +16,11 @@ agent-reach configure --from-browser chrome
--- ---
## Twitter/X: bird CLI 连接失败 ## Twitter/X: twitter-cli 连接失败
**症状:** `bird search` 或其他命令返回错误 **症状:** `twitter search` 或其他命令返回错误
**原因:** bird CLI 需要 AUTH_TOKEN 和 CT0 环境变量才能访问 Twitter API。如果你的网络环境需要代理才能访问 x.com,需要配置代理。 **原因:** twitter-cli 需要 AUTH_TOKEN 和 CT0 环境变量才能访问 Twitter API。如果你的网络环境需要代理才能访问 x.com,需要配置代理。
**解决方案:** **解决方案:**
@@ -29,22 +29,22 @@ agent-reach configure --from-browser chrome
```bash ```bash
export HTTP_PROXY="http://user:pass@host:port" export HTTP_PROXY="http://user:pass@host:port"
export HTTPS_PROXY="http://user:pass@host:port" export HTTPS_PROXY="http://user:pass@host:port"
bird search "test" -n 1 twitter search "test" -n 1
``` ```
### 方案 2:使用全局代理工具 ### 方案 2:使用全局代理工具
让代理工具接管所有网络流量,这样 bird 的请求也会走代理: 让代理工具接管所有网络流量,这样 twitter-cli 的请求也会走代理:
```bash ```bash
# macOS — ClashX / Surge 开启"增强模式" # macOS — ClashX / Surge 开启"增强模式"
# Linux — proxychains 或 tun2socks # Linux — proxychains 或 tun2socks
proxychains bird search "test" -n 1 proxychains twitter search "test" -n 1
``` ```
### 方案 3:不用 bird,用 Exa 搜索替代 ### 方案 3:不用 twitter-cli,用 Exa 搜索替代
bird 不可用时,可以直接用 Exa 搜索 Twitter 内容: twitter-cli 不可用时,可以直接用 Exa 搜索 Twitter 内容:
```bash ```bash
mcporter call 'exa.web_search_exa(query: "site:x.com 搜索词", numResults: 5)' mcporter call 'exa.web_search_exa(query: "site:x.com 搜索词", numResults: 5)'
@@ -53,7 +53,9 @@ mcporter call 'exa.web_search_exa(query: "site:x.com 搜索词", numResults: 5)'
### 方案 4:检查认证 ### 方案 4:检查认证
```bash ```bash
bird check twitter check
``` ```
> 如果返回 "Missing credentials",需要设置 AUTH_TOKEN 和 CT0 环境变量。 > 如果返回 "Missing credentials",需要设置 AUTH_TOKEN 和 CT0 环境变量。
>
> **Fallback** 如果你已经安装了 bird CLI`npm install -g @steipete/bird`),它也能正常工作。Agent Reach 会自动检测已安装的工具。
+64 -24
View File
@@ -24,7 +24,7 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma
### Goal ### Goal
Update Agent Reach to the latest version, then verify everything still works. Update Agent Reach to the latest version, refresh upstream tools, migrate from retired backends, and verify everything works. The user should not need to do anything manually (except things only a human can do, like clicking a browser-extension install button).
### Step 1: Check current version ### Step 1: Check current version
@@ -32,44 +32,84 @@ Update Agent Reach to the latest version, then verify everything still works.
agent-reach check-update agent-reach check-update
``` ```
If it says "已是最新版本", you're done — tell the user. If it says "已是最新版本", skip to Step 4 (verify). Otherwise continue.
### Step 2: Update ### Step 2: Update Agent Reach package
```bash ```bash
pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip
``` ```
### Step 3: Verify > If pip complains about `externally-managed-environment` (PEP 668), the user
> originally installed via pipx or a venv — use the matching command:
> `pipx install --force https://github.com/Panniantong/agent-reach/archive/main.zip`
> or activate `~/.agent-reach-venv` first.
### Step 3: Refresh upstream tools
Run these to keep installed tools current. **Only upgrade what is already
installed — do not install new tools the user never asked for** (the one
exception: OpenCLI on desktop, see below).
```bash
# Python-based CLIs the user already has (upgrade keeps signatures fresh)
which twitter >/dev/null 2>&1 && { pipx upgrade twitter-cli 2>/dev/null || uv tool upgrade twitter-cli 2>/dev/null; }
which bili >/dev/null 2>&1 && { pipx upgrade bilibili-cli 2>/dev/null || uv tool upgrade bilibili-cli 2>/dev/null; }
which xhs >/dev/null 2>&1 && { pipx upgrade xiaohongshu-cli 2>/dev/null || uv tool upgrade xiaohongshu-cli 2>/dev/null; }
which yt-dlp >/dev/null 2>&1 && { pipx upgrade yt-dlp 2>/dev/null || uv tool upgrade yt-dlp 2>/dev/null || pip install -U yt-dlp 2>/dev/null; }
# rdt-cli is pinned to a git source (PyPI lags upstream) — same pin as the code's _RDT_GIT_SOURCE
which rdt >/dev/null 2>&1 && pipx install --force 'git+https://github.com/public-clis/rdt-cli.git@5e4fb3720d5c174e976cd425ccc3b879d52cac66' 2>/dev/null
# npm-based
which mcporter >/dev/null 2>&1 && npm update -g mcporter 2>/dev/null
which opencli >/dev/null 2>&1 && npm update -g @jackwener/opencli 2>/dev/null
```
**Desktop users without OpenCLI**: since v1.5.0 OpenCLI is the preferred
backend for 小红书/Reddit (and adds B站 subtitles) by riding the user's
browser session. Offer it once:
> "这次更新引入了 OpenCLI 后端(复用你的浏览器登录态,小红书/Reddit 零配置)。要装吗?装完只需你在 Chrome 商店点一次『添加扩展』。"
If yes: `agent-reach install --channels opencli` and guide them through the
extension click. If no, everything keeps working on existing backends.
### Step 4: Coexistence (DO NOT uninstall old tools)
**Never uninstall tools the user already has.** Retired backends (e.g. yt-dlp
no longer serves Bilibili; xhs-cli is no longer installed by default) keep
working as fallbacks where they still function. Agent Reach routes around
them automatically — removal is the user's call, not yours.
### Step 5: Verify
```bash ```bash
agent-reach version agent-reach version
agent-reach doctor agent-reach doctor
``` ```
Report the new version and channel status to the user. If any channel changed status (was ✅ now ❌), investigate and fix. Running `agent-reach doctor` (text mode) also auto-syncs the bundled skill
(SKILL.md + references) into every detected agent skill directory — no
separate skill-update step is needed.
### Step 4: Update SKILL.md (if installed) Check the doctor output:
If the user has the Agent Reach skill installed, update it too: - Every channel shows ✅ / [!] with a clear message, and multi-backend
channels (小红书/Reddit/B站/Twitter) report `当前后端:…`
- If a previously-working channel now shows [X]/error, the message contains
the exact fix (e.g. a venv-reinstall prescription) — run it, then re-check
- `--json` gives the same data machine-readably (`active_backend` per channel)
```bash ### Step 6: Report to user
agent-reach install --skill-only
```
If the `--skill-only` flag is not available, manually copy the latest SKILL.md: Tell the user:
```bash 1. What version they're on now (`agent-reach version`)
python3 -c " 2. How many channels are available, and which backend each multi-backend
import importlib.resources, shutil, os platform is using (from doctor)
skill = importlib.resources.files('agent_reach').joinpath('skill', 'SKILL.md').read_text() 3. Anything that needs their action (e.g. Chrome extension click, `xhs login`,
for d in [os.path.expanduser('~/.openclaw/skills/agent-reach'), QR scan for xiaohongshu-mcp on servers)
os.path.expanduser('~/.claude/skills/agent-reach')]: 4. What changed in this update (release notes shown by `check-update`)
if os.path.isdir(d):
with open(os.path.join(d, 'SKILL.md'), 'w') as f:
f.write(skill)
print(f'✅ Updated: {d}')
"
```
Done. Tell the user what version they're now on and how many channels are available. Done.
+1 -6
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "agent-reach" name = "agent-reach"
version = "1.4.0" version = "1.5.0"
description = "Give your AI Agent eyes to see the entire internet. Search + Read 10+ platforms." description = "Give your AI Agent eyes to see the entire internet. Search + Read 10+ platforms."
readme = "README.md" readme = "README.md"
license = {text = "MIT"} license = {text = "MIT"}
@@ -64,11 +64,6 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["agent_reach"] packages = ["agent_reach"]
[tool.hatch.build.targets.wheel.force-include]
"agent_reach/guides" = "agent_reach/guides"
"agent_reach/skill" = "agent_reach/skill"
"agent_reach/scripts" = "agent_reach/scripts"
[tool.ruff] [tool.ruff]
target-version = "py310" target-version = "py310"
line-length = 100 line-length = 100
+73 -45
View File
@@ -1,10 +1,17 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Contract tests for channel adapters.""" """Contract tests for channel adapters."""
import subprocess
from agent_reach.channels import get_all_channels from agent_reach.channels import get_all_channels
from agent_reach.config import Config from agent_reach.config import Config
def _fake_run_ok(cmd, **kwargs):
"""Pretend any probed CLI executes fine and prints a version."""
return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "")
def test_channel_registry_contract(): def test_channel_registry_contract():
channels = get_all_channels() channels = get_all_channels()
assert channels, "channel registry must not be empty" assert channels, "channel registry must not be empty"
@@ -29,6 +36,67 @@ def test_channel_check_contract_with_minimal_runtime(monkeypatch, tmp_path):
assert isinstance(message, str) and message.strip() assert isinstance(message, str) and message.strip()
def test_channel_active_backend_attribute_contract():
"""Every channel exposes active_backend (default None, or str once set)."""
for ch in get_all_channels():
assert hasattr(ch, "active_backend")
# Fresh instances must default to None / str (class attribute on base)
fresh = type(ch)()
assert fresh.active_backend is None or isinstance(fresh.active_backend, str)
def test_channel_active_backend_set_by_check(monkeypatch, tmp_path):
"""After check(), active_backend is None or a str — never anything else."""
monkeypatch.setattr("shutil.which", lambda _cmd: None)
# Keep the network-based channels (V2EX/Xueqiu/Bilibili API) deterministic.
import urllib.request
from urllib.error import URLError
def _no_net(*_a, **_k):
raise URLError("offline")
monkeypatch.setattr(urllib.request, "urlopen", _no_net)
import agent_reach.channels.xueqiu as xueqiu_mod
monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True)
monkeypatch.setattr(xueqiu_mod._opener, "open", _no_net)
config = Config(config_path=tmp_path / "config.yaml")
for ch in get_all_channels():
ch.check(config)
assert ch.active_backend is None or isinstance(ch.active_backend, str), (
f"{ch.name}: active_backend must be None or str after check()"
)
def test_ordered_backends_contract(tmp_path):
"""ordered_backends(config) is a reordering (same multiset) of backends."""
config = Config(config_path=tmp_path / "config.yaml")
for ch in get_all_channels():
ordered = ch.ordered_backends(config)
assert isinstance(ordered, list)
assert sorted(ordered) == sorted(ch.backends), (
f"{ch.name}: ordered_backends must be a permutation of backends"
)
# And without any config at all
ordered_none = ch.ordered_backends(None)
assert sorted(ordered_none) == sorted(ch.backends)
def test_ordered_backends_override_moves_backend_to_front():
"""Config key <channel>_backend promotes the named backend to front."""
from agent_reach.channels.twitter import TwitterChannel
ch = TwitterChannel()
ordered = ch.ordered_backends({"twitter_backend": "bird"})
assert ordered[0] == "bird CLI (legacy)"
assert sorted(ordered) == sorted(ch.backends)
# Unknown override is ignored — never hides working backends
ordered_unknown = ch.ordered_backends({"twitter_backend": "no-such-tool"})
assert ordered_unknown == list(ch.backends)
def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path): def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path):
"""YouTube should warn when only Node.js is installed but no yt-dlp config exists.""" """YouTube should warn when only Node.js is installed but no yt-dlp config exists."""
from agent_reach.channels.youtube import YouTubeChannel from agent_reach.channels.youtube import YouTubeChannel
@@ -41,6 +109,7 @@ def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path):
return None # deno not installed return None # deno not installed
monkeypatch.setattr("shutil.which", fake_which) monkeypatch.setattr("shutil.which", fake_which)
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
# Point to a non-existent config file # Point to a non-existent config file
monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / ".config/yt-dlp/config")) monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / ".config/yt-dlp/config"))
@@ -48,6 +117,7 @@ def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path):
status, message = ch.check() status, message = ch.check()
assert status == "warn" assert status == "warn"
assert "--js-runtimes" in message assert "--js-runtimes" in message
assert ch.active_backend == "yt-dlp" # 本体活着,warn 只关乎 JS runtime
def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path): def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path):
@@ -62,6 +132,7 @@ def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path):
return None return None
monkeypatch.setattr("shutil.which", fake_which) monkeypatch.setattr("shutil.which", fake_which)
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
monkeypatch.setattr("agent_reach.utils.paths.sys.platform", "win32") monkeypatch.setattr("agent_reach.utils.paths.sys.platform", "win32")
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
@@ -84,53 +155,12 @@ def test_youtube_ok_when_deno_installed(monkeypatch):
return None return None
monkeypatch.setattr("shutil.which", fake_which) monkeypatch.setattr("shutil.which", fake_which)
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
ch = YouTubeChannel() ch = YouTubeChannel()
status, _msg = ch.check() status, _msg = ch.check()
assert status == "ok" assert status == "ok"
assert ch.active_backend == "yt-dlp"
def test_douyin_check_does_not_call_with_invalid_url(monkeypatch, tmp_path):
"""Douyin check should use 'mcporter list' instead of calling with a hardcoded URL."""
import subprocess
from agent_reach.channels.douyin import DouyinChannel
calls = []
original_run = subprocess.run
def tracking_run(cmd, **kwargs):
calls.append(cmd)
# Simulate mcporter config list returning douyin
if "config" in cmd and "list" in cmd:
class R:
stdout = "douyin http://localhost:18070/mcp"
returncode = 0
return R()
# Simulate mcporter list douyin returning tools
if "list" in cmd and "douyin" in cmd:
class R:
stdout = "parse_douyin_video_info"
returncode = 0
return R()
return original_run(cmd, **kwargs)
monkeypatch.setattr(
"shutil.which", lambda cmd: "/usr/bin/mcporter" if cmd == "mcporter" else None
)
monkeypatch.setattr("subprocess.run", tracking_run)
ch = DouyinChannel()
status, _msg = ch.check()
# Should NOT contain any hardcoded douyin.com URL in subprocess calls
for call in calls:
call_str = " ".join(call) if isinstance(call, list) else str(call)
assert "https://www.douyin.com" not in call_str
def test_channel_can_handle_contract(): def test_channel_can_handle_contract():
@@ -141,9 +171,7 @@ def test_channel_can_handle_contract():
"reddit": "https://reddit.com/r/python", "reddit": "https://reddit.com/r/python",
"bilibili": "https://www.bilibili.com/video/BV1xx411", "bilibili": "https://www.bilibili.com/video/BV1xx411",
"xiaohongshu": "https://www.xiaohongshu.com/explore/123", "xiaohongshu": "https://www.xiaohongshu.com/explore/123",
"douyin": "https://www.douyin.com/video/123",
"linkedin": "https://www.linkedin.com/in/test", "linkedin": "https://www.linkedin.com/in/test",
"weibo": "https://weibo.com/u/1749127163",
"rss": "https://example.com/feed.xml", "rss": "https://example.com/feed.xml",
"xueqiu": "https://xueqiu.com/S/SH600519", "xueqiu": "https://xueqiu.com/S/SH600519",
"exa_search": "https://example.com", "exa_search": "https://example.com",
+540 -9
View File
@@ -7,9 +7,9 @@ import subprocess
from urllib.error import URLError from urllib.error import URLError
from agent_reach.channels import get_all_channels, get_channel from agent_reach.channels import get_all_channels, get_channel
from agent_reach.channels.v2ex import V2EXChannel
from agent_reach.channels.xiaohongshu import XiaoHongShuChannel from agent_reach.channels.xiaohongshu import XiaoHongShuChannel
from agent_reach.channels.xueqiu import XueqiuChannel from agent_reach.channels.xueqiu import XueqiuChannel
from agent_reach.channels.v2ex import V2EXChannel
class TestChannelRegistry: class TestChannelRegistry:
@@ -223,8 +223,6 @@ class TestV2EXChannel:
}, },
] ]
call_count = {"n": 0}
class FakeResponse: class FakeResponse:
def __init__(self, payload): def __init__(self, payload):
self._payload = payload self._payload = payload
@@ -648,8 +646,158 @@ class TestXueqiuChannel:
assert "agent-reach" not in captured["ua"] assert "agent-reach" not in captured["ua"]
class TestRedditChannel:
"""多后端:OpenCLI > rdt-cli,没有零配置路径。"""
@staticmethod
def _isolate(monkeypatch, opencli=None):
"""隔离 OpenCLI 候选(None = 未安装),聚焦 rdt-cli 路径。"""
from agent_reach.channels.reddit import RedditChannel
monkeypatch.setattr(RedditChannel, "_check_opencli", lambda self: opencli)
def test_reports_off_when_nothing_installed(self, monkeypatch):
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: None)
from agent_reach.channels.reddit import RedditChannel
status, msg = RedditChannel().check()
assert status == "off"
# 诚实口径:明说没有零配置路径,推荐 OpenCLI + rdt git 源
assert "零配置" in msg
assert "opencli" in msg
assert "git+https://github.com/public-clis/rdt-cli.git" in msg
def test_opencli_ready_wins(self, monkeypatch):
self._isolate(monkeypatch, opencli=("ok", "OpenCLI 可用(复用浏览器登录态)"))
monkeypatch.setattr(shutil, "which", lambda _: None)
from agent_reach.channels.reddit import RedditChannel
ch = RedditChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "OpenCLI"
def test_reports_ok_when_authenticated(self, monkeypatch):
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
fake_output = json.dumps({
"ok": True,
"schema_version": "1",
"data": {"authenticated": True, "username": "testuser", "cookie_count": 1},
})
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, fake_output, "")
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.reddit import RedditChannel
ch = RedditChannel()
status, msg = ch.check()
assert status == "ok"
assert "testuser" in msg
assert ch.active_backend == "rdt-cli"
def test_reports_warn_when_not_authenticated(self, monkeypatch):
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
fake_output = json.dumps({
"ok": True,
"schema_version": "1",
"data": {"authenticated": False, "username": None, "cookie_count": 0},
})
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, fake_output, "")
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.reddit import RedditChannel
ch = RedditChannel()
status, msg = ch.check()
assert status == "warn"
assert "403" in msg
assert "rdt login" in msg
assert "Cookie-Editor" in msg
assert "chromewebstore.google.com" in msg
# 未登录是业务态:进程活着,后端仍然算可用
assert ch.active_backend == "rdt-cli"
def test_reports_error_when_status_check_fails(self, monkeypatch):
"""rdt 非零退出且输出不可解析 → 工具异常(error),不再算 warn。"""
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 1, "not valid json{{{", "")
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.reddit import RedditChannel
ch = RedditChannel()
status, msg = ch.check()
assert status == "error"
assert "rdt 异常退出" in msg
assert ch.active_backend is None
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
"""which 命中但 exec 抛 FileNotFoundErrorvenv 断链)→ error + 重装处方。"""
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
def fake_run(cmd, **kwargs):
raise FileNotFoundError("/usr/local/bin/rdt")
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.reddit import RedditChannel
ch = RedditChannel()
status, msg = ch.check()
assert status == "error"
assert "无法执行" in msg
assert "pipx install --force" in msg # rdt 专用 git 源重装处方
assert "git+https://github.com/public-clis/rdt-cli.git" in msg
assert ch.active_backend is None
def test_reports_error_with_reinstall_hint_on_exit_127(self, monkeypatch):
"""退出码 127(找到但跑不动)同样按断链处理。"""
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 127, "", "")
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.reddit import RedditChannel
ch = RedditChannel()
status, msg = ch.check()
assert status == "error"
assert "pipx install --force" in msg
assert ch.active_backend is None
def test_can_handle_reddit_urls(self):
from agent_reach.channels.reddit import RedditChannel
ch = RedditChannel()
assert ch.can_handle("https://www.reddit.com/r/python/comments/abc123/")
assert ch.can_handle("https://redd.it/abc123")
assert not ch.can_handle("https://github.com/user/repo")
assert not ch.can_handle("https://v2ex.com/t/123")
class TestXiaoHongShuChannel: class TestXiaoHongShuChannel:
"""多后端选择逻辑:OpenCLI > xiaohongshu-mcp > xhs-cli,第一个完整可用者获胜。"""
@staticmethod
def _isolate(monkeypatch, opencli=None, mcp_reachable=False):
"""隔离 OpenCLI / mcp 候选,让测试聚焦目标后端。
opencli: None 表示未安装否则传入 (status, message) 二元组
"""
import agent_reach.channels.xiaohongshu as xhs_mod
monkeypatch.setattr(
XiaoHongShuChannel, "_check_opencli", lambda self: opencli
)
monkeypatch.setattr(
xhs_mod, "_mcp_service_reachable", lambda timeout=3: mcp_reachable
)
def test_reports_ok_when_cli_authenticated(self, monkeypatch): def test_reports_ok_when_cli_authenticated(self, monkeypatch):
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs): def fake_run(cmd, **kwargs):
@@ -657,11 +805,14 @@ class TestXiaoHongShuChannel:
monkeypatch.setattr(subprocess, "run", fake_run) monkeypatch.setattr(subprocess, "run", fake_run)
status, msg = XiaoHongShuChannel().check() ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "ok" assert status == "ok"
assert "完整可用" in msg assert "xhs-cli 可用" in msg
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
def test_reports_warn_when_not_authenticated(self, monkeypatch): def test_reports_warn_when_not_authenticated(self, monkeypatch):
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs): def fake_run(cmd, **kwargs):
@@ -669,12 +820,392 @@ class TestXiaoHongShuChannel:
monkeypatch.setattr(subprocess, "run", fake_run) monkeypatch.setattr(subprocess, "run", fake_run)
status, msg = XiaoHongShuChannel().check() ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "warn" assert status == "warn"
assert "xhs login" in msg assert "xhs login" in msg
# 未登录是业务态:工具进程活着,后端仍可用
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
def test_reports_off_when_not_installed(self, monkeypatch): def test_reports_off_when_nothing_installed(self, monkeypatch):
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: None) monkeypatch.setattr(shutil, "which", lambda _: None)
status, msg = XiaoHongShuChannel().check() ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "off" assert status == "off"
assert "xiaohongshu-cli" in msg # off 指引推荐当代后端,而非停更的 xhs-cli
assert "opencli" in msg
assert "xiaohongshu-mcp" in msg
assert ch.active_backend is None
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
"""which 命中但 exec 抛 FileNotFoundErrorvenv 断链)→ error + 重装处方。"""
self._isolate(monkeypatch)
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs):
raise FileNotFoundError("/usr/local/bin/xhs")
monkeypatch.setattr(subprocess, "run", fake_run)
ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "error"
assert "无法执行" in msg
assert "uv tool install --force xiaohongshu-cli" in msg
assert "pipx reinstall xiaohongshu-cli" in msg
assert ch.active_backend is None
def test_opencli_ready_wins_over_cli(self, monkeypatch):
"""OpenCLI 完整可用时按序获胜,即使 xhs-cli 也完整可用。"""
self._isolate(monkeypatch, opencli=("ok", "OpenCLI 可用(复用浏览器登录态)"))
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "ok: true\n", "")
monkeypatch.setattr(subprocess, "run", fake_run)
ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "OpenCLI"
def test_opencli_warn_loses_to_usable_cli(self, monkeypatch):
"""OpenCLI 装了但扩展未连(warn)时,完整可用的 xhs-cli 获胜。"""
self._isolate(monkeypatch, opencli=("warn", "扩展未连接"))
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "ok: true\n", "")
monkeypatch.setattr(subprocess, "run", fake_run)
ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
def test_mcp_service_wins_when_opencli_absent(self, monkeypatch):
"""服务器场景:OpenCLI 未装、mcp 服务可达且 mcporter 已接入 → mcp 获胜。"""
self._isolate(monkeypatch, mcp_reachable=True)
def fake_which(name):
return "/usr/local/bin/mcporter" if name == "mcporter" else None
monkeypatch.setattr(shutil, "which", fake_which)
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "exa\nxiaohongshu\n", "")
monkeypatch.setattr(subprocess, "run", fake_run)
ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "xiaohongshu-mcp"
assert "search_feeds" in msg
def test_mcp_reachable_but_mcporter_unconfigured_warns(self, monkeypatch):
self._isolate(monkeypatch, mcp_reachable=True)
def fake_which(name):
return "/usr/local/bin/mcporter" if name == "mcporter" else None
monkeypatch.setattr(shutil, "which", fake_which)
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "exa\n", "")
monkeypatch.setattr(subprocess, "run", fake_run)
ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "warn"
assert "mcporter config add xiaohongshu" in msg
assert ch.active_backend == "xiaohongshu-mcp"
def test_backend_override_prefers_cli(self, monkeypatch):
"""config xiaohongshu_backend=xhs-cli 时,即使 OpenCLI ready 也用 xhs-cli。"""
self._isolate(monkeypatch, opencli=("ok", "OpenCLI 可用"))
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "ok: true\n", "")
monkeypatch.setattr(subprocess, "run", fake_run)
class _Cfg:
def get(self, key, default=None):
return "xhs-cli" if key == "xiaohongshu_backend" else default
ch = XiaoHongShuChannel()
status, _ = ch.check(_Cfg())
assert status == "ok"
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
class TestBilibiliChannel:
"""多后端: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])
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.bilibili import BilibiliChannel
ch = BilibiliChannel()
status, msg = ch.check()
assert status == "error"
assert "uv tool install --force bilibili-cli" in msg
assert ch.active_backend is None
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 ch.active_backend == "OpenCLI"
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"
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:
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
"""yt-dlp which 命中但 exec 抛 FileNotFoundError → error + 重装处方。"""
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/yt-dlp")
def fake_run(cmd, **kwargs):
raise FileNotFoundError(cmd[0])
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.youtube import YouTubeChannel
ch = YouTubeChannel()
status, msg = ch.check()
assert status == "error"
assert "无法执行" in msg
assert "uv tool install --force yt-dlp" in msg
assert ch.active_backend is None
class TestGitHubChannel:
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
"""gh which 命中但 exec 失败 → error + brew 重装处方(gh 不是 pip 包)。"""
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/gh")
def fake_run(cmd, **kwargs):
raise FileNotFoundError(cmd[0])
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.github import GitHubChannel
ch = GitHubChannel()
status, msg = ch.check()
assert status == "error"
assert "无法执行" in msg
assert "brew reinstall gh" in msg
assert ch.active_backend is None
def test_active_backend_set_when_authenticated(self, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/gh")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "Logged in to github.com", "")
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.github import GitHubChannel
ch = GitHubChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "gh CLI"
def test_active_backend_set_when_unauthenticated(self, monkeypatch):
"""gh auth status 非零退出是正常业务态(未登录):warn 但后端可用。"""
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/gh")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 1, "", "You are not logged in")
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.github import GitHubChannel
ch = GitHubChannel()
status, msg = ch.check()
assert status == "warn"
assert "gh auth login" in msg
assert ch.active_backend == "gh CLI"
class TestLinkedInChannel:
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
"""mcporter which 命中但 exec 失败 → error + npm 重装处方。"""
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter")
def fake_run(cmd, **kwargs):
raise FileNotFoundError(cmd[0])
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.linkedin import LinkedInChannel
ch = LinkedInChannel()
status, msg = ch.check()
assert status == "error"
assert "npm install -g mcporter" in msg
assert ch.active_backend is None
def test_active_backend_set_when_linkedin_configured(self, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "linkedin http://localhost:3000/mcp", "")
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.linkedin import LinkedInChannel
ch = LinkedInChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "linkedin-scraper-mcp"
def test_off_without_backend_when_linkedin_not_configured(self, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "exa https://mcp.exa.ai/mcp", "")
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.linkedin import LinkedInChannel
ch = LinkedInChannel()
status, msg = ch.check()
assert status == "off"
assert ch.active_backend is None
class TestExaSearchChannel:
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
"""mcporter which 命中但 exec 失败 → error + npm 重装处方。"""
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter")
def fake_run(cmd, **kwargs):
raise FileNotFoundError(cmd[0])
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.exa_search import ExaSearchChannel
ch = ExaSearchChannel()
status, msg = ch.check()
assert status == "error"
assert "npm install -g mcporter" in msg
assert ch.active_backend is None
def test_active_backend_set_when_exa_configured(self, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "exa https://mcp.exa.ai/mcp", "")
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.exa_search import ExaSearchChannel
ch = ExaSearchChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "Exa via mcporter"
class TestXiaoyuzhouChannel:
def test_reports_error_with_reinstall_hint_when_ffmpeg_broken(self, monkeypatch):
"""ffmpeg which 命中但 exec 失败(pip 假 ffmpeg 断链)→ error + 重装处方。"""
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/ffmpeg")
def fake_run(cmd, **kwargs):
raise FileNotFoundError(cmd[0])
monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.xiaoyuzhou import XiaoyuzhouChannel
ch = XiaoyuzhouChannel()
status, msg = ch.check()
assert status == "error"
assert "无法执行" in msg
assert "brew install ffmpeg" in msg
assert ch.active_backend is None
def test_active_backend_set_when_fully_configured(self, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/ffmpeg")
def fake_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "ffmpeg version 7.0", "")
monkeypatch.setattr(subprocess, "run", fake_run)
monkeypatch.setattr("os.path.isfile", lambda p: True) # transcribe.sh 已安装
monkeypatch.setenv("GROQ_API_KEY", "gsk_test")
from agent_reach.channels.xiaoyuzhou import XiaoyuzhouChannel
ch = XiaoyuzhouChannel()
status, msg = ch.check()
assert status == "ok"
assert ch.active_backend == "groq-whisper"
+100 -2
View File
@@ -1,9 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Tests for Agent Reach CLI.""" """Tests for Agent Reach CLI."""
import shutil
import subprocess
from unittest.mock import patch
import pytest import pytest
import requests import requests
from unittest.mock import patch
import agent_reach.cli as cli import agent_reach.cli as cli
from agent_reach.cli import main from agent_reach.cli import main
@@ -30,6 +33,21 @@ class TestCLI:
assert "Agent Reach" in captured.out assert "Agent Reach" in captured.out
assert "" in captured.out assert "" in captured.out
def test_transcribe_command_prints_text(self, capsys):
with patch("agent_reach.transcribe.transcribe", return_value="hello transcript"):
with patch("sys.argv", ["agent-reach", "transcribe", "audio.mp3"]):
main()
captured = capsys.readouterr()
assert "hello transcript" in captured.out
def test_transcribe_command_writes_output_file(self, capsys, tmp_path):
out_file = tmp_path / "t.txt"
with patch("agent_reach.transcribe.transcribe", return_value="saved text"):
with patch("sys.argv", ["agent-reach", "transcribe", "audio.mp3", "-o", str(out_file)]):
main()
assert out_file.read_text(encoding="utf-8").strip() == "saved text"
assert "Transcript written" in capsys.readouterr().out
def test_parse_twitter_cookie_input_separate_values(self): def test_parse_twitter_cookie_input_separate_values(self):
auth_token, ct0 = cli._parse_twitter_cookie_input("token123 ct0abc") auth_token, ct0 = cli._parse_twitter_cookie_input("token123 ct0abc")
assert auth_token == "token123" assert auth_token == "token123"
@@ -42,6 +60,47 @@ class TestCLI:
assert auth_token == "token123" assert auth_token == "token123"
assert ct0 == "ct0abc" assert ct0 == "ct0abc"
def test_install_rdt_cli_prefers_github_source(self, monkeypatch, capsys):
state = {"rdt_installed": False}
commands = []
def fake_which(name):
if name == "rdt":
return "/usr/local/bin/rdt" if state["rdt_installed"] else None
if name == "pipx":
return "/usr/local/bin/pipx"
return None
def fake_run(cmd, **kwargs):
commands.append(cmd)
state["rdt_installed"] = True
return subprocess.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr(shutil, "which", fake_which)
monkeypatch.setattr(subprocess, "run", fake_run)
cli._install_rdt_cli()
out = capsys.readouterr().out
assert commands == [["pipx", "install", cli._RDT_GIT_SOURCE]]
assert "✅ rdt-cli installed" in out
def test_install_reddit_deps_routes_by_environment(self, monkeypatch):
"""桌面 → OpenCLI;服务器 → rdt-cli(钉 git 源)。"""
calls = []
monkeypatch.setattr(cli, "_install_opencli_deps", lambda: calls.append("opencli"))
monkeypatch.setattr(cli, "_install_rdt_cli", lambda: calls.append("rdt"))
monkeypatch.setattr(shutil, "which", lambda _: None)
monkeypatch.setattr(cli, "_detect_environment", lambda: "local")
cli._install_reddit_deps()
assert calls == ["opencli"]
calls.clear()
monkeypatch.setattr(cli, "_detect_environment", lambda: "server")
cli._install_reddit_deps()
assert calls == ["rdt"]
class TestCheckUpdateRetry: class TestCheckUpdateRetry:
def test_retry_timeout_classification(self): def test_retry_timeout_classification(self):
@@ -89,7 +148,7 @@ class TestCheckUpdateRetry:
sequence = [ sequence = [
R(429, headers={"Retry-After": "3"}), R(429, headers={"Retry-After": "3"}),
R(200, payload={"tag_name": "v1.4.0"}), R(200, payload={"tag_name": "v1.5.0"}),
] ]
with patch("requests.get", side_effect=sequence): with patch("requests.get", side_effect=sequence):
@@ -124,3 +183,42 @@ class TestCheckUpdateRetry:
assert result == "error" assert result == "error"
assert "网络超时" in captured.out assert "网络超时" in captured.out
assert "已重试 3 次" in captured.out assert "已重试 3 次" in captured.out
class TestVersionCompare:
def test_newer_remote_triggers_update(self):
assert cli._is_newer_version("1.5.0", "1.4.2") is True
def test_equal_versions_no_update(self):
assert cli._is_newer_version("1.5.0", "1.5.0") is False
def test_local_ahead_of_release_no_downgrade_prompt(self):
"""发版窗口期本地装了 main(更新)时,不能提示"有更新"诱导降级。"""
assert cli._is_newer_version("1.4.2", "1.5.0") is False
def test_unparseable_falls_back_to_inequality(self):
assert cli._is_newer_version("2026.06-beta", "1.5.0") is True
assert cli._is_newer_version("1.5.0", "1.5.0-dev") is True
class TestWatchVersionCompare:
def test_watch_does_not_prompt_downgrade(self, monkeypatch, capsys):
"""watch 与 check-update 同语义:本地领先远端 release 时不提示更新。"""
class R:
status_code = 200
headers = {}
@staticmethod
def json():
return {"tag_name": "v1.4.2", "body": ""}
monkeypatch.setattr(cli, "_github_get_with_retry", lambda *a, **k: (R(), None, 1))
monkeypatch.setattr(
"agent_reach.doctor.check_all",
lambda config: {"web": {"status": "ok", "name": "任意网页", "message": "ok",
"tier": 0, "backends": ["Jina Reader"], "active_backend": "Jina Reader"}},
)
cli._cmd_watch()
out = capsys.readouterr().out
assert "新版本可用" not in out
assert "全部正常" in out
+89
View File
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""Verify credential files written by cookie sync helpers and CLI helpers
are owner-only (0o600) and that values containing shell metacharacters do
not break the shell-sourceable env file produced by _sync_bird_env().
Companion to tests/test_config.py::test_save_creates_file_with_restricted_permissions
the same threat-model claim ("Cookie/Token only stored locally, 600
permissions") covers these paths.
"""
import json
import os
import stat
import subprocess
import sys
import tempfile
import pytest
from agent_reach.cookie_extract import _sync_bird_env, _sync_xfetch_session
def _owner_only(path: str) -> bool:
mode = os.stat(path).st_mode
return not (mode & (stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH))
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perm semantics only")
def test_sync_xfetch_session_writes_0600(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
_sync_xfetch_session("auth_xxx", "ct0_yyy")
session_path = tmp_path / ".config" / "xfetch" / "session.json"
assert session_path.exists(), "expected ~/.config/xfetch/session.json"
assert _owner_only(str(session_path)), "session.json must be 0o600"
# Round-trip the content so we know we didn't accidentally corrupt JSON.
data = json.loads(session_path.read_text(encoding="utf-8"))
assert data["authToken"] == "auth_xxx"
assert data["ct0"] == "ct0_yyy"
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perm semantics only")
def test_sync_bird_env_writes_0600(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
_sync_bird_env("auth_xxx", "ct0_yyy")
env_path = tmp_path / ".config" / "bird" / "credentials.env"
assert env_path.exists(), "expected ~/.config/bird/credentials.env"
assert _owner_only(str(env_path)), "credentials.env must be 0o600"
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX sh needed for sourcing")
def test_sync_bird_env_quotes_shell_metachars(tmp_path, monkeypatch):
"""Tokens containing ", $, `, ; etc. must not break out of the assignment.
Prior implementation used `f'AUTH_TOKEN="{auth_token}"'` which an attacker-
controlled cookie containing a literal `"` could break out of, turning a
later `source ~/.config/bird/credentials.env` into arbitrary shell.
"""
monkeypatch.setenv("HOME", str(tmp_path))
# Side-effect markers live under tmp_path (auto-cleaned by pytest) rather
# than a shared absolute /tmp path — otherwise one vulnerable run leaves a
# marker behind that fails every later run on the same machine/CI runner.
pwn_auth = tmp_path / "pwn-auth"
pwn_ct0 = tmp_path / "pwn-ct0"
hostile_auth = f'inj"; touch {pwn_auth}; #'
hostile_ct0 = f"ct0_$(touch {pwn_ct0})"
_sync_bird_env(hostile_auth, hostile_ct0)
env_path = tmp_path / ".config" / "bird" / "credentials.env"
# Sourcing the file must NOT execute the injected payload. Read back the
# exported values from a subshell instead — they should equal the originals.
probe = (
f". {env_path}; "
f'printf "AUTH=%s\\nCT0=%s\\n" "$AUTH_TOKEN" "$CT0"'
)
result = subprocess.run(
["sh", "-c", probe],
capture_output=True,
text=True,
timeout=5,
)
assert result.returncode == 0, result.stderr
lines = dict(
line.split("=", 1) for line in result.stdout.strip().splitlines() if "=" in line
)
assert lines["AUTH"] == hostile_auth, "auth_token round-trip broke — injection possible"
assert lines["CT0"] == hostile_ct0, "ct0 round-trip broke — injection possible"
# And no side-effect files materialised.
assert not pwn_auth.exists()
assert not pwn_ct0.exists()
+28 -2
View File
@@ -8,13 +8,15 @@ from agent_reach.config import Config
class _StubChannel: class _StubChannel:
def __init__(self, name, description, tier, status, message, backends=None): def __init__(self, name, description, tier, status, message, backends=None,
active_backend=None):
self.name = name self.name = name
self.description = description self.description = description
self.tier = tier self.tier = tier
self._status = status self._status = status
self._message = message self._message = message
self.backends = backends or [] self.backends = backends or []
self.active_backend = active_backend
def check(self, config=None): def check(self, config=None):
return self._status, self._message return self._status, self._message
@@ -31,7 +33,8 @@ class TestDoctor:
doctor, doctor,
"get_all_channels", "get_all_channels",
lambda: [ lambda: [
_StubChannel("web", "网页", 0, "ok", "可抓取网页", ["requests"]), _StubChannel("web", "网页", 0, "ok", "可抓取网页", ["requests"],
active_backend="requests"),
_StubChannel("github", "GitHub", 0, "warn", "gh 未安装", ["gh"]), _StubChannel("github", "GitHub", 0, "warn", "gh 未安装", ["gh"]),
_StubChannel("exa_search", "全网语义搜索", 1, "off", "mcporter 未配置", ["Exa"]), _StubChannel("exa_search", "全网语义搜索", 1, "off", "mcporter 未配置", ["Exa"]),
], ],
@@ -46,6 +49,7 @@ class TestDoctor:
"message": "可抓取网页", "message": "可抓取网页",
"tier": 0, "tier": 0,
"backends": ["requests"], "backends": ["requests"],
"active_backend": "requests",
}, },
"github": { "github": {
"status": "warn", "status": "warn",
@@ -53,6 +57,7 @@ class TestDoctor:
"message": "gh 未安装", "message": "gh 未安装",
"tier": 0, "tier": 0,
"backends": ["gh"], "backends": ["gh"],
"active_backend": None,
}, },
"exa_search": { "exa_search": {
"status": "off", "status": "off",
@@ -60,6 +65,7 @@ class TestDoctor:
"message": "mcporter 未配置", "message": "mcporter 未配置",
"tier": 1, "tier": 1,
"backends": ["Exa"], "backends": ["Exa"],
"active_backend": None,
}, },
} }
@@ -98,3 +104,23 @@ class TestDoctor:
assert "1/3 个渠道可用" in plain assert "1/3 个渠道可用" in plain
# Inactive optional channels should be summarized in one line # Inactive optional channels should be summarized in one line
assert "可选渠道可以解锁" in plain assert "可选渠道可以解锁" in plain
def test_stale_active_backend_does_not_leak_into_errored_result(monkeypatch):
"""渠道单例上一轮的 active_backend 不得泄漏进本轮异常结果(Codex review 发现)。"""
from agent_reach import doctor
class _ExplodingChannel:
name = "boom"
description = "爆炸渠道"
tier = 0
backends = ["a", "b"]
active_backend = "a" # 上一轮成功的残留
def check(self, config=None):
raise RuntimeError("boom")
monkeypatch.setattr(doctor, "get_all_channels", lambda: [_ExplodingChannel()])
results = doctor.check_all(config=None)
assert results["boom"]["status"] == "error"
assert results["boom"]["active_backend"] is None
+100
View File
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""Tests for the OpenCLI cross-channel backend probing."""
from unittest.mock import patch
from agent_reach.backends import opencli_status, opencli_summary
from agent_reach.probe import ProbeResult
def _status_with(version_probe, daemon_probe=None, ext_on_disk=False):
"""Run opencli_status with probe_command and disk check patched."""
calls = []
def fake_probe(cmd, args=("--version",), **kwargs):
calls.append(list(args))
if list(args) == ["--version"]:
return version_probe
return daemon_probe or ProbeResult("missing")
with patch("agent_reach.backends.opencli.probe_command", side_effect=fake_probe), \
patch(
"agent_reach.backends.opencli._extension_installed_on_disk",
return_value=ext_on_disk,
):
return opencli_status(), calls
def test_not_installed():
st, _ = _status_with(ProbeResult("missing"))
assert not st.installed
assert not st.ready
assert "未安装" in opencli_summary(st)
def test_broken_node_env_gives_npm_hint():
st, _ = _status_with(ProbeResult("broken", hint="x"))
assert st.installed and st.broken
assert "npm install -g @jackwener/opencli" in st.hint
assert not st.ready
def test_daemon_running_extension_connected_is_ready():
daemon_out = "Daemon: running (PID 37389)\nVersion: v1.8.3\nExtension: connected\n"
st, _ = _status_with(
ProbeResult("ok", output="1.8.3"),
ProbeResult("ok", output=daemon_out),
)
assert st.installed and st.daemon_running and st.extension_connected
assert st.ready
assert "1.8.3" in opencli_summary(st)
def test_extension_never_installed_not_ready_with_store_guide():
daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n"
st, _ = _status_with(
ProbeResult("ok", output="1.8.3"),
ProbeResult("ok", output=daemon_out),
ext_on_disk=False,
)
assert st.daemon_running and not st.extension_connected
assert not st.ready
assert "chromewebstore.google.com" in st.hint
def test_sleeping_extension_counts_as_ready():
"""实测:扩展 service worker 睡眠时 daemon status 报 disconnected,
但任何真实命令会唤醒它装在磁盘上即视为可用"""
daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n"
st, _ = _status_with(
ProbeResult("ok", output="1.8.3"),
ProbeResult("ok", output=daemon_out),
ext_on_disk=True,
)
assert not st.extension_connected
assert st.extension_installed
assert st.ready
assert "唤醒" in opencli_summary(st)
assert st.hint == ""
def test_daemon_not_running_parsed_correctly():
st, _ = _status_with(
ProbeResult("ok", output="1.8.3"),
ProbeResult("ok", output="Daemon: not running\n"),
)
assert st.installed
assert not st.daemon_running
assert not st.extension_connected
assert "自动启动" in opencli_summary(st)
def test_probe_uses_daemon_status_not_doctor():
"""`opencli doctor` auto-starts the daemon (side effect) — health checks
must only ever call `daemon status`."""
_, calls = _status_with(
ProbeResult("ok", output="1.8.3"),
ProbeResult("ok", output="Daemon: not running\n"),
)
assert ["daemon", "status"] in calls
assert ["doctor"] not in calls
+90
View File
@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""Tests for agent_reach.probe — real-execution probing and failure classification."""
import os
import stat
import sys
import pytest
from agent_reach.probe import ProbeResult, probe_command, reinstall_hint
def _make_executable(path, content):
path.write_text(content)
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return str(path)
def test_missing_command():
r = probe_command("definitely-not-a-real-command-xyz")
assert r.status == "missing"
assert not r.ok
@pytest.mark.skipif(sys.platform == "win32", reason="shebang semantics are POSIX-only")
def test_broken_shebang_detected_as_broken(tmp_path, monkeypatch):
"""A stale venv shim: which() finds it, exec raises FileNotFoundError."""
script = _make_executable(
tmp_path / "stale-tool", "#!/nonexistent/venv/bin/python\nprint('hi')\n"
)
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
r = probe_command("stale-tool", package="stale-tool-pkg")
assert r.status == "broken"
assert "uv tool install --force stale-tool-pkg" in r.hint
assert "pipx reinstall stale-tool-pkg" in r.hint
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
def test_healthy_command_returns_ok_with_output(tmp_path, monkeypatch):
script = _make_executable(
tmp_path / "healthy-tool", "#!/bin/sh\necho 'healthy-tool 1.2.3'\n"
)
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
r = probe_command("healthy-tool")
assert r.ok
assert "1.2.3" in r.output
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
def test_nonzero_exit_classified_as_error(tmp_path, monkeypatch):
script = _make_executable(
tmp_path / "failing-tool", "#!/bin/sh\necho 'boom' >&2\nexit 3\n"
)
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
r = probe_command("failing-tool")
assert r.status == "error"
assert "boom" in r.output
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
def test_exit_127_classified_as_broken(tmp_path, monkeypatch):
script = _make_executable(tmp_path / "wrapper-tool", "#!/bin/sh\nexit 127\n")
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
r = probe_command("wrapper-tool", package="wrapper-pkg")
assert r.status == "broken"
assert "wrapper-pkg" in r.hint
@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only")
def test_retries_help_transient_failures(tmp_path, monkeypatch):
"""First call fails (exit 1), second succeeds — retries=1 should return ok."""
marker = tmp_path / "ran-once"
script = _make_executable(
tmp_path / "flaky-tool",
f"#!/bin/sh\nif [ -f {marker} ]; then echo ok; exit 0; fi\ntouch {marker}\nexit 1\n",
)
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", ""))
r = probe_command("flaky-tool", retries=1)
assert r.ok
def test_reinstall_hint_mentions_both_installers():
hint = reinstall_hint("some-pkg")
assert "uv tool install --force some-pkg" in hint
assert "pipx reinstall some-pkg" in hint
+18
View File
@@ -0,0 +1,18 @@
from agent_reach.utils.process import mcporter_utf8_env_args, utf8_subprocess_env
def test_utf8_subprocess_env_forces_python_utf8():
env = utf8_subprocess_env({"PYTHONUTF8": "0", "OTHER": "value"})
assert env["PYTHONUTF8"] == "1"
assert env["PYTHONIOENCODING"] == "utf-8"
assert env["OTHER"] == "value"
def test_mcporter_utf8_env_args():
assert mcporter_utf8_env_args() == [
"--env",
"PYTHONUTF8=1",
"--env",
"PYTHONIOENCODING=utf-8",
]
+41 -6
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Tests for 'agent-reach skill' command and _install_skill / _uninstall_skill.""" """Tests for 'agent-reach skill' command and _install_skill / _uninstall_skill."""
import importlib.resources
import os import os
import tempfile import tempfile
import unittest import unittest
@@ -12,6 +13,16 @@ from agent_reach.cli import _install_skill, _uninstall_skill
class TestSkillCommand(unittest.TestCase): class TestSkillCommand(unittest.TestCase):
"""Test skill install and uninstall via CLI helpers.""" """Test skill install and uninstall via CLI helpers."""
def test_skill_resources_include_both_locales(self):
"""Package resources should expose both default and English skill markdown files."""
skill_dir = importlib.resources.files("agent_reach").joinpath("skill")
default_skill = skill_dir.joinpath("SKILL.md").read_text(encoding="utf-8")
english_skill = skill_dir.joinpath("SKILL_en.md").read_text(encoding="utf-8")
self.assertTrue(default_skill.strip())
self.assertTrue(english_skill.strip())
def test_install_skill_creates_skill_md(self): def test_install_skill_creates_skill_md(self):
"""_install_skill should create SKILL.md in the first available skill dir.""" """_install_skill should create SKILL.md in the first available skill dir."""
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
@@ -28,14 +39,11 @@ class TestSkillCommand(unittest.TestCase):
with patch.dict(os.environ, env, clear=True): with patch.dict(os.environ, env, clear=True):
_install_skill() _install_skill()
target = os.path.join(skill_dir, "agent-reach", "SKILL.md")
# Check at least one known skill dir pattern # Check at least one known skill dir pattern
found = False
for dirpath, _, filenames in os.walk(tmpdir): for dirpath, _, filenames in os.walk(tmpdir):
if "SKILL.md" in filenames: if "SKILL.md" in filenames:
found = True
# Verify content is non-empty # Verify content is non-empty
with open(os.path.join(dirpath, "SKILL.md")) as f: with open(os.path.join(dirpath, "SKILL.md"), encoding="utf-8") as f:
content = f.read() content = f.read()
self.assertIn("Agent Reach", content) self.assertIn("Agent Reach", content)
# _install_skill may or may not find dirs depending on mock; just ensure no crash # _install_skill may or may not find dirs depending on mock; just ensure no crash
@@ -47,7 +55,7 @@ class TestSkillCommand(unittest.TestCase):
# Create a fake skill installation # Create a fake skill installation
skill_path = os.path.join(tmpdir, ".openclaw", "skills", "agent-reach") skill_path = os.path.join(tmpdir, ".openclaw", "skills", "agent-reach")
os.makedirs(skill_path) os.makedirs(skill_path)
with open(os.path.join(skill_path, "SKILL.md"), "w") as f: with open(os.path.join(skill_path, "SKILL.md"), "w", encoding="utf-8") as f:
f.write("test") f.write("test")
self.assertTrue(os.path.exists(skill_path)) self.assertTrue(os.path.exists(skill_path))
@@ -81,10 +89,37 @@ class TestSkillCommand(unittest.TestCase):
target = os.path.join(skill_parent, "agent-reach", "SKILL.md") target = os.path.join(skill_parent, "agent-reach", "SKILL.md")
self.assertTrue(os.path.exists(target)) self.assertTrue(os.path.exists(target))
with open(target) as f: with open(target, encoding="utf-8") as f:
content = f.read() content = f.read()
self.assertIn("Agent Reach", content) self.assertIn("Agent Reach", content)
def test_install_uses_english_skill_for_english_locale(self):
"""_install_skill should install the English skill file for English locales."""
with tempfile.TemporaryDirectory() as tmpdir:
skill_parent = os.path.join(tmpdir, ".openclaw", "skills")
os.makedirs(skill_parent)
with patch(
"agent_reach.cli.os.path.expanduser",
side_effect=lambda p: p.replace("~", tmpdir),
):
env = os.environ.copy()
env.pop("OPENCLAW_HOME", None)
env["LANG"] = "en_US.UTF-8"
with patch.dict(os.environ, env, clear=True):
_install_skill()
target = os.path.join(skill_parent, "agent-reach", "SKILL.md")
self.assertTrue(os.path.exists(target))
with open(target, encoding="utf-8") as f:
content = f.read()
self.assertTrue(content.strip())
self.assertIn("Xiaoyuzhou Podcast, LinkedIn", content)
self.assertNotIn("搜推特", content)
self.assertTrue(
os.path.exists(os.path.join(skill_parent, "agent-reach", "references"))
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+262
View File
@@ -0,0 +1,262 @@
# -*- coding: utf-8 -*-
"""Tests for agent_reach.transcribe — provider routing, fallback, and errors."""
from typing import List
import pytest
from agent_reach import transcribe as tr
from agent_reach.config import Config
# --- Fixtures ----------------------------------------------------------- #
@pytest.fixture
def fake_config(tmp_path, monkeypatch):
"""A Config that writes to a temp dir and never touches the user's HOME."""
cfg_path = tmp_path / "config.yaml"
monkeypatch.setattr(Config, "CONFIG_DIR", tmp_path)
monkeypatch.setattr(Config, "CONFIG_FILE", cfg_path)
cfg = Config(config_path=cfg_path)
return cfg
@pytest.fixture
def chunk_file(tmp_path):
p = tmp_path / "chunk.m4a"
p.write_bytes(b"\x00fake-m4a-bytes")
return p
class FakeResponse:
def __init__(self, status_code: int, text: str = ""):
self.status_code = status_code
self.text = text
@property
def ok(self) -> bool:
return 200 <= self.status_code < 300
# --- transcribe_chunk: provider routing -------------------------------- #
class TestTranscribeChunk:
def test_routes_to_groq_endpoint(self, monkeypatch, fake_config, chunk_file):
fake_config.set("groq_api_key", "gsk_test")
captured = {}
def fake_post(url, headers=None, files=None, data=None, timeout=None):
captured["url"] = url
captured["headers"] = headers
captured["model"] = data["model"]
return FakeResponse(200, "hello world")
monkeypatch.setattr(tr.requests, "post", fake_post)
text = tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
assert text == "hello world"
assert captured["url"] == tr.PROVIDERS["groq"]["endpoint"]
assert captured["model"] == "whisper-large-v3"
assert captured["headers"]["Authorization"] == "Bearer gsk_test"
def test_routes_to_openai_endpoint(self, monkeypatch, fake_config, chunk_file):
fake_config.set("openai_api_key", "sk-test")
captured = {}
def fake_post(url, headers=None, files=None, data=None, timeout=None):
captured["url"] = url
captured["model"] = data["model"]
return FakeResponse(200, "openai output")
monkeypatch.setattr(tr.requests, "post", fake_post)
text = tr.transcribe_chunk(chunk_file, "openai", config=fake_config)
assert text == "openai output"
assert captured["url"] == tr.PROVIDERS["openai"]["endpoint"]
assert captured["model"] == "whisper-1"
def test_raises_when_key_missing(self, fake_config, chunk_file):
with pytest.raises(tr.NoProviderConfigured):
tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
def test_raises_on_http_error(self, monkeypatch, fake_config, chunk_file):
fake_config.set("groq_api_key", "gsk_test")
monkeypatch.setattr(
tr.requests,
"post",
lambda *a, **k: FakeResponse(429, "rate limited"),
)
with pytest.raises(tr.TranscribeError, match="HTTP 429"):
tr.transcribe_chunk(chunk_file, "groq", config=fake_config)
def test_unknown_provider(self, fake_config, chunk_file):
with pytest.raises(tr.TranscribeError, match="unknown provider"):
tr.transcribe_chunk(chunk_file, "azure", config=fake_config)
# --- _transcribe_with_fallback ----------------------------------------- #
class TestFallback:
def test_groq_succeeds_no_openai_call(self, monkeypatch, fake_config, chunk_file):
fake_config.set("groq_api_key", "gsk_test")
fake_config.set("openai_api_key", "sk-test")
calls: List[str] = []
def fake_post(url, headers=None, files=None, data=None, timeout=None):
calls.append(url)
return FakeResponse(200, "from-groq")
monkeypatch.setattr(tr.requests, "post", fake_post)
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
assert text == "from-groq"
assert calls == [tr.PROVIDERS["groq"]["endpoint"]]
def test_groq_429_falls_back_to_openai(self, monkeypatch, fake_config, chunk_file):
fake_config.set("groq_api_key", "gsk_test")
fake_config.set("openai_api_key", "sk-test")
calls: List[str] = []
def fake_post(url, headers=None, files=None, data=None, timeout=None):
calls.append(url)
if url == tr.PROVIDERS["groq"]["endpoint"]:
return FakeResponse(429, "rate limited")
return FakeResponse(200, "from-openai")
monkeypatch.setattr(tr.requests, "post", fake_post)
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
assert text == "from-openai"
assert calls == [
tr.PROVIDERS["groq"]["endpoint"],
tr.PROVIDERS["openai"]["endpoint"],
]
def test_skip_unconfigured_provider(self, monkeypatch, fake_config, chunk_file):
# Only openai key configured — fallback should skip groq silently.
fake_config.set("openai_api_key", "sk-test")
calls: List[str] = []
def fake_post(url, headers=None, files=None, data=None, timeout=None):
calls.append(url)
return FakeResponse(200, "via-openai")
monkeypatch.setattr(tr.requests, "post", fake_post)
text = tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
assert text == "via-openai"
assert calls == [tr.PROVIDERS["openai"]["endpoint"]]
def test_all_fail_raises_with_last_error(self, monkeypatch, fake_config, chunk_file):
fake_config.set("groq_api_key", "gsk_test")
fake_config.set("openai_api_key", "sk-test")
monkeypatch.setattr(
tr.requests,
"post",
lambda *a, **k: FakeResponse(500, "boom"),
)
with pytest.raises(tr.TranscribeError, match="all providers failed"):
tr._transcribe_with_fallback(chunk_file, ["groq", "openai"], fake_config)
# --- transcribe (orchestrator) ---------------------------------------- #
class TestOrchestrator:
def test_local_file_skips_yt_dlp(self, monkeypatch, fake_config, tmp_path, chunk_file):
fake_config.set("groq_api_key", "gsk_test")
def boom_download(*a, **k):
raise AssertionError("yt-dlp must not be called for local files")
# Stub heavy external steps to no-ops that keep file paths valid.
compressed = tmp_path / "compressed.m4a"
compressed.write_bytes(b"x" * 1024)
def fake_compress(src, out_dir):
return compressed
monkeypatch.setattr(tr, "download_audio", boom_download)
monkeypatch.setattr(tr, "compress_audio", fake_compress)
monkeypatch.setattr(
tr.requests,
"post",
lambda *a, **k: FakeResponse(200, "transcript text"),
)
text = tr.transcribe(
str(chunk_file),
out_dir=tmp_path / "work",
config=fake_config,
)
assert text == "transcript text"
def test_chunks_concatenated_with_newlines(
self, monkeypatch, fake_config, tmp_path, chunk_file
):
fake_config.set("groq_api_key", "gsk_test")
# Force the "needs chunking" path by writing a file above the size limit.
big = tmp_path / "compressed.m4a"
big.write_bytes(b"x" * (tr.SIZE_LIMIT_BYTES + 1))
monkeypatch.setattr(tr, "compress_audio", lambda src, out_dir: big)
c1 = tmp_path / "chunk_001.m4a"
c2 = tmp_path / "chunk_002.m4a"
c1.write_bytes(b"a")
c2.write_bytes(b"b")
monkeypatch.setattr(tr, "chunk_audio", lambda src, out_dir: [c1, c2])
responses = iter(["part one ", "part two "])
monkeypatch.setattr(
tr.requests,
"post",
lambda *a, **k: FakeResponse(200, next(responses)),
)
text = tr.transcribe(
str(chunk_file),
out_dir=tmp_path / "work",
config=fake_config,
)
assert text == "part one\npart two"
def test_no_provider_configured_fails_fast(self, fake_config, chunk_file):
with pytest.raises(tr.NoProviderConfigured):
tr.transcribe(str(chunk_file), config=fake_config)
def test_invalid_provider_string(self, fake_config, chunk_file):
with pytest.raises(tr.TranscribeError, match="unknown provider"):
tr.transcribe(str(chunk_file), provider="azure", config=fake_config)
# --- YouTubeChannel integration --------------------------------------- #
class TestYouTubeChannelTranscribe:
def test_delegates_to_transcribe(self, monkeypatch, fake_config):
from agent_reach.channels.youtube import YouTubeChannel
captured = {}
def fake_transcribe(source, *, provider="auto", out_dir=None, config=None):
captured["source"] = source
captured["provider"] = provider
captured["config"] = config
return "delegated text"
monkeypatch.setattr(tr, "transcribe", fake_transcribe)
out = YouTubeChannel().transcribe(
"https://youtu.be/abc", provider="groq", config=fake_config
)
assert out == "delegated text"
assert captured["source"] == "https://youtu.be/abc"
assert captured["provider"] == "groq"
assert captured["config"] is fake_config
# --- Config feature requirement --------------------------------------- #
class TestConfigOpenAIWhisper:
def test_openai_whisper_feature_registered(self, fake_config):
assert "openai_whisper" in Config.FEATURE_REQUIREMENTS
assert Config.FEATURE_REQUIREMENTS["openai_whisper"] == ["openai_api_key"]
assert not fake_config.is_configured("openai_whisper")
fake_config.set("openai_api_key", "sk-test")
assert fake_config.is_configured("openai_whisper")
+127 -15
View File
@@ -13,22 +13,26 @@ def _cp(stdout="", stderr="", returncode=0):
return m return m
# --- twitter-cli tests ---
def test_check_twitter_cli_found_and_auth_ok(): def test_check_twitter_cli_found_and_auth_ok():
"""twitter-cli found + twitter status ok → ok.""" """twitter-cli found + twitter status ok → ok."""
channel = TwitterChannel() channel = TwitterChannel()
with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None), patch(
"subprocess.run", "subprocess.run",
return_value=_cp(stdout="ok: true\nusername: testuser\n", returncode=0), return_value=_cp(stdout="ok: true\nusername: testuser\n", returncode=0),
): ):
status, message = channel.check() status, message = channel.check()
assert status == "ok" assert status == "ok"
assert "twitter-cli" in message
assert "完整可用" in message assert "完整可用" in message
assert channel.active_backend == "twitter-cli"
def test_check_twitter_cli_found_auth_missing(): def test_check_twitter_cli_found_auth_missing():
"""twitter-cli found + not_authenticated → warn about auth.""" """twitter-cli found + not_authenticated → warn about auth."""
channel = TwitterChannel() channel = TwitterChannel()
with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None), patch(
"subprocess.run", "subprocess.run",
return_value=_cp( return_value=_cp(
stderr="ok: false\nerror:\n code: not_authenticated\n", stderr="ok: false\nerror:\n code: not_authenticated\n",
@@ -38,35 +42,143 @@ def test_check_twitter_cli_found_auth_missing():
status, message = channel.check() status, message = channel.check()
assert status == "warn" assert status == "warn"
assert "未认证" in message assert "未认证" in message
# 未认证是业务态:工具进程活着,后端仍可用
assert channel.active_backend == "twitter-cli"
def test_check_twitter_cli_not_found(): # --- bird CLI fallback tests ---
"""twitter-cli not found → warn with install hint."""
def test_check_bird_fallback_auth_ok():
"""No twitter-cli, but bird found + bird check ok → ok."""
channel = TwitterChannel()
def which_side_effect(name):
if name == "bird":
return "/usr/local/bin/bird"
return None
with patch("shutil.which", side_effect=which_side_effect), patch(
"subprocess.run",
return_value=_cp(stdout="Authenticated as @user\n", returncode=0),
):
status, message = channel.check()
assert status == "ok"
assert "bird" in message
assert channel.active_backend == "bird CLI (legacy)"
def test_check_bird_fallback_auth_missing():
"""No twitter-cli, bird found but Missing credentials → warn."""
channel = TwitterChannel()
def which_side_effect(name):
if name == "bird":
return "/usr/local/bin/bird"
return None
with patch("shutil.which", side_effect=which_side_effect), patch(
"subprocess.run",
return_value=_cp(stderr="Missing credentials\n", returncode=1),
):
status, message = channel.check()
assert status == "warn"
assert "未配置认证" in message
# --- neither installed ---
def test_check_nothing_installed():
"""Neither twitter-cli nor bird → warn with install hint."""
channel = TwitterChannel() channel = TwitterChannel()
with patch("shutil.which", return_value=None): with patch("shutil.which", return_value=None):
status, message = channel.check() status, message = channel.check()
assert status == "warn" assert status == "warn"
assert "twitter-cli" in message assert "twitter-cli" in message
assert channel.active_backend is None
def test_check_twitter_cli_generic_failure(): # --- twitter-cli preferred over bird ---
"""twitter status returns 1 without not_authenticated → generic warn."""
def test_twitter_cli_preferred_over_bird():
"""When both are installed, twitter-cli is used."""
channel = TwitterChannel() channel = TwitterChannel()
with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( def which_side_effect(name):
if name == "twitter":
return "/usr/local/bin/twitter"
if name == "bird":
return "/usr/local/bin/bird"
return None
with patch("shutil.which", side_effect=which_side_effect), patch(
"subprocess.run", "subprocess.run",
return_value=_cp(stderr="some error\n", returncode=1), return_value=_cp(stdout="ok: true\n", returncode=0),
): ):
status, message = channel.check() status, message = channel.check()
assert status == "warn" assert status == "ok"
assert "认证检查失败" in message assert "twitter-cli" in message
assert channel.active_backend == "twitter-cli"
def test_check_twitter_cli_exception(): # --- broken install (stale venv shim) ---
"""twitter status throws exception → warn."""
def test_check_twitter_cli_broken_reports_error_with_reinstall_hint():
"""which 命中但 exec 抛 FileNotFoundErrorvenv 断链)→ error + 重装处方。"""
channel = TwitterChannel() channel = TwitterChannel()
with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch( with patch(
"subprocess.run", side_effect=Exception("timeout"), "shutil.which",
side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None,
), patch("subprocess.run", side_effect=FileNotFoundError("/usr/local/bin/twitter")):
status, message = channel.check()
assert status == "error"
assert "无法执行" in message
assert "uv tool install --force twitter-cli" in message
assert "pipx reinstall twitter-cli" in message
assert channel.active_backend is None
def test_check_twitter_cli_broken_falls_back_to_bird():
"""twitter-cli 断链但 bird 健康 → 回退到 bird,后端正确归属。"""
channel = TwitterChannel()
def which_side_effect(name):
if name in ("twitter", "bird"):
return f"/usr/local/bin/{name}"
return None
def run_side_effect(cmd, **kwargs):
if "twitter" in cmd[0]:
raise FileNotFoundError(cmd[0])
return _cp(stdout="Authenticated as @user\n", returncode=0)
with patch("shutil.which", side_effect=which_side_effect), patch(
"subprocess.run", side_effect=run_side_effect
): ):
status, message = channel.check() status, message = channel.check()
assert status == "ok"
assert "bird" in message
assert channel.active_backend == "bird CLI (legacy)"
def test_unauthenticated_twitter_cli_does_not_block_working_opencli():
"""warn 候选不得屏蔽排在后面的 ok 候选(Codex review 发现)。"""
channel = TwitterChannel()
with patch.object(
TwitterChannel, "_check_twitter_cli",
return_value=("warn", "twitter-cli 已安装但未认证"),
), patch.object(
TwitterChannel, "_check_opencli",
return_value=("ok", "OpenCLI 可用(复用浏览器登录态)"),
), patch.object(TwitterChannel, "_check_bird", return_value=None):
status, msg = channel.check()
assert status == "ok"
assert channel.active_backend == "OpenCLI"
def test_all_warn_falls_back_to_first_warn():
channel = TwitterChannel()
with patch.object(
TwitterChannel, "_check_twitter_cli",
return_value=("warn", "twitter-cli 未认证"),
), patch.object(
TwitterChannel, "_check_opencli",
return_value=("warn", "扩展未连接"),
), patch.object(TwitterChannel, "_check_bird", return_value=None):
status, msg = channel.check()
assert status == "warn" assert status == "warn"
assert "连接失败" in message assert channel.active_backend == "twitter-cli"
assert "未认证" in msg
Generated
-1640
View File
File diff suppressed because it is too large Load Diff