From b62efd0f0a50d10d91493134c8398d6e4b542625 Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:16:05 +0800 Subject: [PATCH 1/3] fix(web): harden reader input and response boundaries --- agent_reach/channels/web.py | 39 ++++++++++- agent_reach/utils/url.py | 79 +++++++++++++++++++++ tests/test_web_channel.py | 135 +++++++++++++++++++++++++++++++++++- 3 files changed, 249 insertions(+), 4 deletions(-) diff --git a/agent_reach/channels/web.py b/agent_reach/channels/web.py index 9d10dfe1..dfbea213 100644 --- a/agent_reach/channels/web.py +++ b/agent_reach/channels/web.py @@ -2,9 +2,33 @@ """Web — any URL via Jina Reader. Always available.""" import urllib.request + +from agent_reach.utils.url import normalize_public_http_url + from .base import Channel _UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" +_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 +_ANTIBOT_SCAN_BYTES = 4096 + + +def _is_antibot_page(body: bytes) -> bool: + """Recognize high-confidence Jina/Cloudflare challenge responses.""" + sample = body[:_ANTIBOT_SCAN_BYTES].decode("utf-8", errors="ignore").casefold() + + jina_captcha_warning = "warning:" in sample and "requiring captcha" in sample + challenge_structure = any( + marker in sample + for marker in ( + "title: just a moment...", + "## performing security verification", + "title: attention required! | cloudflare", + ) + ) + cloudflare_block = "title: attention required! | cloudflare" in sample and ( + "ray id" in sample or "/cdn-cgi/challenge-platform/" in sample + ) + return (jina_captcha_warning and challenge_structure) or cloudflare_block class WebChannel(Channel): @@ -23,12 +47,21 @@ def check(self, config=None): def read(self, url: str) -> str: """通过 Jina Reader 读取网页,返回 Markdown 全文。""" - if not url.startswith(("http://", "https://")): - url = "https://" + url + url = normalize_public_http_url(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") + body = resp.read(_MAX_RESPONSE_BYTES + 1) + if len(body) > _MAX_RESPONSE_BYTES: + raise ValueError( + f"Jina Reader response exceeds {_MAX_RESPONSE_BYTES} byte limit" + ) + if _is_antibot_page(body): + raise RuntimeError( + "Jina Reader 返回了反爬验证页,未获取到目标内容;" + "请改用站点专用工具或浏览器读取" + ) + return body.decode("utf-8") diff --git a/agent_reach/utils/url.py b/agent_reach/utils/url.py index b31f6e82..c88ac22b 100644 --- a/agent_reach/utils/url.py +++ b/agent_reach/utils/url.py @@ -2,8 +2,87 @@ from __future__ import annotations +import ipaddress +import socket from urllib.parse import urlsplit +_BLOCKED_PUBLIC_FETCH_HOSTS = { + "home.arpa", + "instance-data", + "internal", + "ip6-localhost", + "ip6-loopback", + "lan", + "local", + "localdomain", + "localhost", + "metadata.google.internal", +} +_BLOCKED_PUBLIC_FETCH_SUFFIXES = ( + ".home.arpa", + ".internal", + ".lan", + ".local", + ".localdomain", + ".localhost", +) + + +def _literal_ip_address( + host: str, +) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + """Parse canonical and legacy IPv4 literal spellings without DNS.""" + try: + return ipaddress.ip_address(host) + except ValueError: + pass + + try: + packed = socket.inet_aton(host) + except OSError: + return None + return ipaddress.IPv4Address(packed) + + +def normalize_public_http_url(url: str) -> str: + """Normalize a URL or reject targets that are not clearly public HTTP(S).""" + candidate = str(url or "").strip() + if ( + not candidate + or "\\" in candidate + or any( + character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F + for character in candidate + ) + ): + raise ValueError("only public HTTP(S) URLs are allowed") + if "://" not in candidate: + candidate = f"https://{candidate}" + + try: + parsed = urlsplit(candidate) + host = (parsed.hostname or "").lower().rstrip(".") + # Accessing the port rejects malformed or out-of-range authorities. + _ = parsed.port + except (TypeError, ValueError): + raise ValueError("only public HTTP(S) URLs are allowed") from None + + literal_address = _literal_ip_address(host) + if ( + parsed.scheme.lower() not in {"http", "https"} + or not host + or parsed.username is not None + or parsed.password is not None + or "%" in host + or host in _BLOCKED_PUBLIC_FETCH_HOSTS + or host.endswith(_BLOCKED_PUBLIC_FETCH_SUFFIXES) + or ("." not in host and literal_address is None) + or (literal_address is not None and not literal_address.is_global) + ): + raise ValueError("only public HTTP(S) URLs are allowed") + + return parsed.geturl() + def domain_matches(host: str, *domains: str) -> bool: """Match a hostname/cookie domain exactly or as a real subdomain.""" diff --git a/tests/test_web_channel.py b/tests/test_web_channel.py index 2529c784..91606ef1 100644 --- a/tests/test_web_channel.py +++ b/tests/test_web_channel.py @@ -10,7 +10,11 @@ from unittest.mock import MagicMock, patch -from agent_reach.channels.web import WebChannel, _UA +import pytest + +from agent_reach.channels.web import _UA, WebChannel + +_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 def _resp(body=b"# Example\nfull text\n"): @@ -90,3 +94,132 @@ def test_read_decodes_utf8_body(): with patch("urllib.request.urlopen", return_value=_resp("café ☕\n".encode("utf-8"))): out = channel.read("https://example.com") assert out == "café ☕\n" + + +@pytest.mark.parametrize( + "url", + [ + "file:///etc/passwd", + "ftp://example.com/file", + "http://localhost/admin", + "http://intranet/admin", + "http://home.arpa/admin", + "http://metadata.google.internal/latest/meta-data", + "http://127.0.0.1/private", + "http://127.1/private", + "http://169.254.169.254/latest/meta-data", + "http://192.168.1/private", + "http://0/private", + "http://2130706433/private", + "http://0x7f000001/private", + "http://0177.0.0.1/private", + "http://2852039166/latest/meta-data", + "http://0xA9FEA9FE/latest/meta-data", + "http://[::1]/private", + "http://[::ffff:127.0.0.1]/private", + "http://localhost./admin", + "http://127.0.0.1\\example.com/private", + "https://user:password@example.com/private", + ], +) +def test_read_rejects_non_public_urls_before_network(url): + channel = WebChannel() + + with patch("urllib.request.urlopen") as mock_open: + with pytest.raises(ValueError, match="public HTTP"): + channel.read(url) + + mock_open.assert_not_called() + + +@pytest.mark.parametrize("url", ["https://8.8.8.8/page", "http://010.010.010.010/page"]) +def test_read_allows_public_literal_addresses(url): + channel = WebChannel() + with patch("urllib.request.urlopen", return_value=_resp()) as mock_open: + channel.read(url) + mock_open.assert_called_once() + + +def test_read_accepts_response_at_exact_size_limit(): + channel = WebChannel() + response = _resp(b"x" * _MAX_RESPONSE_BYTES) + + with patch("urllib.request.urlopen", return_value=response): + out = channel.read("https://example.com/exact") + + assert len(out) == _MAX_RESPONSE_BYTES + response.__enter__.return_value.read.assert_called_once_with( + _MAX_RESPONSE_BYTES + 1 + ) + + +def test_read_rejects_oversized_reader_response(): + channel = WebChannel() + response = _resp(b"x" * (_MAX_RESPONSE_BYTES + 1)) + + with patch("urllib.request.urlopen", return_value=response): + with pytest.raises(ValueError, match="response exceeds"): + channel.read("https://example.com/large") + + response.__enter__.return_value.read.assert_called_once_with( + _MAX_RESPONSE_BYTES + 1 + ) + + +@pytest.mark.parametrize( + "body", + [ + ( + "Title: Just a moment...\n\n" + "URL Source: https://imginn.com/instagram/\n\n" + "Warning: This page maybe requiring CAPTCHA\n\n" + "Markdown Content:\n\n" + "## Performing security verification\n" + ), + ( + "Title: Attention Required! | Cloudflare\n\n" + "Sorry, you have been blocked.\n\nRay ID: 1234567890abcdef\n" + ), + ], +) +def test_read_rejects_high_confidence_antibot_pages(body): + channel = WebChannel() + + with patch( + "urllib.request.urlopen", return_value=_resp(body.encode("utf-8")) + ) as mock_open: + with pytest.raises(RuntimeError, match="反爬验证页"): + channel.read("https://example.com/protected") + + mock_open.assert_called_once() + + +@pytest.mark.parametrize( + "body", + [ + "# A guide to security verification\n", + "# DDoS protection explained\n", + "# Checking your browser automation\n", + "# Please turn JavaScript on for progressive enhancement\n", + "# A history of cf-browser-verify\n", + "Title: Just a moment...\n\nA short-story review.\n", + ], +) +def test_read_does_not_reject_single_generic_antibot_terms(body): + channel = WebChannel() + + with patch("urllib.request.urlopen", return_value=_resp(body.encode("utf-8"))): + assert channel.read("https://example.com/article") == body + + +def test_antibot_detection_has_a_fixed_scan_window(): + channel = WebChannel() + body = ( + "x" * 4096 + + "Warning: requiring CAPTCHA\n" + + "Title: Just a moment...\n" + + "## Performing security verification\n" + ) + + with patch("urllib.request.urlopen", return_value=_resp(body.encode("utf-8"))): + assert channel.read("https://example.com/long-article") == body From 8809232af71af7ae7942600d54d0ca98cc5beb04 Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:16:05 +0800 Subject: [PATCH 2/3] fix(skill): support OpenCode discovery --- agent_reach/cli.py | 28 ++++++---- agent_reach/skill/SKILL.md | 19 +------ agent_reach/skill/SKILL_en.md | 3 +- tests/test_opencode_skill.py | 101 ++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 30 deletions(-) create mode 100644 tests/test_opencode_skill.py diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 22519749..746fc923 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -411,10 +411,10 @@ def _cmd_install(args): def _install_skill(force: bool = True): - """Install Agent Reach as an agent skill (OpenClaw / Claude Code / .agents).""" + """Install Agent Reach as an agent skill for supported agent clients.""" + import importlib.resources import os import shutil - import importlib.resources def _is_english_locale(value: str) -> bool: normalized = value.strip().lower() @@ -482,25 +482,28 @@ def _copy_skill_dir(target: str) -> str | None: print(f" Warning: Could not install skill: {e}") return None - # Determine skill install path (priority: .agents > openclaw > claude) + # Install into every known skill root that already exists. skill_dirs = [ - os.path.expanduser("~/.agents/skills"), # Generic agents (priority) - os.path.expanduser("~/.openclaw/skills"), # OpenClaw - os.path.expanduser("~/.claude/skills"), # Claude Code (if exists) + (os.path.expanduser("~/.agents/skills"), "Agent"), + (os.path.expanduser("~/.config/opencode/skills"), "OpenCode"), + (os.path.expanduser("~/.openclaw/skills"), "OpenClaw"), + (os.path.expanduser("~/.claude/skills"), "Claude Code"), ] # Insert OPENCLAW_HOME path at the beginning if environment variable is set openclaw_home = os.environ.get("OPENCLAW_HOME") if openclaw_home: - skill_dirs.insert(0, os.path.join(openclaw_home, ".openclaw", "skills")) + skill_dirs.insert( + 0, + (os.path.join(openclaw_home, ".openclaw", "skills"), "OpenClaw"), + ) installed = False - for skill_dir in skill_dirs: + for skill_dir, platform_name in skill_dirs: if os.path.isdir(skill_dir): target = os.path.join(skill_dir, "agent-reach") status = _copy_skill_dir(target) if status: - platform_name = "Agent" if ".agents" in skill_dir else "OpenClaw" if "openclaw" in skill_dir else "Claude Code" if status == "preserved": print(f"Skill already installed for {platform_name}, preserving existing files: {target}") else: @@ -518,7 +521,10 @@ def _copy_skill_dir(target: str) -> str | None: print(f"Skill installed: {target}") else: print(" -- Could not install agent skill (optional)") - print(" -- Tip: install OpenClaw, Claude Code, or create ~/.agents/skills/ manually") + print( + " -- Tip: install OpenCode, OpenClaw, Claude Code, " + "or create ~/.agents/skills/ manually" + ) def _uninstall_skill(): @@ -526,6 +532,7 @@ def _uninstall_skill(): import shutil skill_dirs = [ + ("~/.config/opencode/skills/agent-reach", "OpenCode"), ("~/.openclaw/skills/agent-reach", "OpenClaw"), ("~/.claude/skills/agent-reach", "Claude Code"), ("~/.agents/skills/agent-reach", "Agent"), @@ -1649,6 +1656,7 @@ def _cmd_uninstall(args): # ── 2. Skill files ── skill_dirs = [ + ("~/.config/opencode/skills/agent-reach", "OpenCode"), ("~/.openclaw/skills/agent-reach", "OpenClaw"), ("~/.claude/skills/agent-reach", "Claude Code"), ("~/.agents/skills/agent-reach", "Agent"), diff --git a/agent_reach/skill/SKILL.md b/agent_reach/skill/SKILL.md index d633a6c5..441cebc3 100644 --- a/agent_reach/skill/SKILL.md +++ b/agent_reach/skill/SKILL.md @@ -19,25 +19,8 @@ description: > 【路由方式】SKILL.md 包含路由表和常用命令,复杂场景需按需阅读对应分类的 references/*.md。 分类:search / social (小红书/推特/B站/V2EX/Reddit/Facebook/Instagram) / career(LinkedIn) / dev(github) / web(网页/文章/RSS) / video(YouTube/B站/播客) / finance(雪球/股票)。 -triggers: - - research: 调研/全网调研/帮我调研/研究一下/research/深入了解 - - search: 搜/查/找/search/搜索/查一下/帮我搜/看看大家怎么说 - - social: - - 小红书: xiaohongshu/xhs/小红书/红书 - - Twitter: twitter/推特/x.com/推文 - - B站: bilibili/b站/哔哩哔哩 - - V2EX: v2ex - - Reddit: reddit - - Facebook: facebook/fb/facebook groups - - Instagram: instagram/ig - - career: 招聘/职位/求职/linkedin/领英/找工作 - - dev: github/代码/仓库/gh/issue/pr/分支/commit - - web: 网页/链接/文章/rss/读一下/打开这个 - - video: youtube/视频/播客/字幕/小宇宙/转录/yt - - finance: 雪球/股票/stock/xueqiu/行情/基金 metadata: - openclaw: - homepage: https://github.com/Panniantong/Agent-Reach + homepage: https://github.com/Panniantong/Agent-Reach --- # Agent Reach — 互联网能力路由器 diff --git a/agent_reach/skill/SKILL_en.md b/agent_reach/skill/SKILL_en.md index 0d57dd67..1d4e364a 100644 --- a/agent_reach/skill/SKILL_en.md +++ b/agent_reach/skill/SKILL_en.md @@ -17,8 +17,7 @@ description: > 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 + homepage: https://github.com/Panniantong/Agent-Reach --- # Agent Reach — internet capability router diff --git a/tests/test_opencode_skill.py b/tests/test_opencode_skill.py new file mode 100644 index 00000000..34326de3 --- /dev/null +++ b/tests/test_opencode_skill.py @@ -0,0 +1,101 @@ +"""OpenCode discovery and metadata compatibility for the packaged skill.""" + +from __future__ import annotations + +import importlib.resources +import os +import re +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +import yaml + +from agent_reach.cli import _cmd_uninstall, _install_skill, _uninstall_skill + + +def _frontmatter(resource_name: str) -> dict[str, object]: + text = ( + importlib.resources.files("agent_reach") + .joinpath("skill", resource_name) + .read_text(encoding="utf-8") + ) + match = re.match(r"\A---\n(.*?)\n---\n", text, flags=re.DOTALL) + assert match is not None, f"{resource_name} must start with YAML frontmatter" + return yaml.safe_load(match.group(1)) + + +def test_skill_frontmatter_uses_opencode_supported_fields(): + """Both locale variants must follow OpenCode's documented schema.""" + allowed_fields = { + "name", + "description", + "license", + "compatibility", + "metadata", + } + + for resource_name in ("SKILL.md", "SKILL_en.md"): + frontmatter = _frontmatter(resource_name) + assert set(frontmatter) <= allowed_fields, resource_name + assert frontmatter["name"] == "agent-reach", resource_name + + description = frontmatter["description"] + assert isinstance(description, str), resource_name + assert 1 <= len(description) <= 1024, resource_name + + metadata = frontmatter.get("metadata", {}) + assert isinstance(metadata, dict), resource_name + assert all( + isinstance(key, str) and isinstance(value, str) + for key, value in metadata.items() + ), resource_name + + +def test_install_skill_discovers_opencode_global_directory(tmp_path: Path): + skill_parent = tmp_path / ".config" / "opencode" / "skills" + skill_parent.mkdir(parents=True) + + with patch( + "agent_reach.cli.os.path.expanduser", + side_effect=lambda value: value.replace("~", os.fspath(tmp_path)), + ), patch.dict(os.environ, {}, clear=True): + _install_skill() + + installed = skill_parent / "agent-reach" / "SKILL.md" + assert installed.is_file() + assert "Agent Reach" in installed.read_text(encoding="utf-8") + + +def test_uninstall_skill_removes_opencode_global_directory(tmp_path: Path): + installed = tmp_path / ".config" / "opencode" / "skills" / "agent-reach" + installed.mkdir(parents=True) + (installed / "SKILL.md").write_text("test", encoding="utf-8") + + with patch( + "agent_reach.cli.os.path.expanduser", + side_effect=lambda value: value.replace("~", os.fspath(tmp_path)), + ), patch.dict(os.environ, {}, clear=True): + _uninstall_skill() + + assert not installed.exists() + + +def test_full_uninstall_includes_opencode_directory( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +): + installed = tmp_path / ".config" / "opencode" / "skills" / "agent-reach" + installed.mkdir(parents=True) + + with patch( + "agent_reach.cli.os.path.expanduser", + side_effect=lambda value: value.replace("~", os.fspath(tmp_path)), + ), patch("agent_reach.utils.paths.home_dir", return_value=tmp_path), patch( + "shutil.which", return_value=None + ): + _cmd_uninstall(SimpleNamespace(dry_run=True, keep_config=True)) + + output = capsys.readouterr().out + assert f"Would remove OpenCode skill: {installed}" in output + assert installed.is_dir() From 484394c5e84675a68e2e10d1364915bdd1700c24 Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:16:05 +0800 Subject: [PATCH 3/3] fix(docs): remove retired localized channels --- docs/README_ja.md | 13 +--------- docs/README_ko.md | 40 +----------------------------- tests/test_auth_guidance_policy.py | 8 ++++++ 3 files changed, 10 insertions(+), 51 deletions(-) diff --git a/docs/README_ja.md b/docs/README_ja.md index ae9a9eb7..1cc7be03 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -64,10 +64,8 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma | 🌐 **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)) | | 📕 **小紅書** | 閲覧・検索・コメント | OpenCLI / Cookie | OpenCLI はユーザー管理の既存 Chrome セッションだけを使用。MCP/旧ツールは Cookie-Editor を使用 | -| 🎵 **抖音** | 動画解析・ウォーターマークなしダウンロード | mcporter | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server)、ログイン不要 | | 💼 **LinkedIn** | Jina Reader(公開ページ) | プロフィール、企業、求人検索 | エージェントに「LinkedInの設定を手伝って」と伝えてください | | 💬 **WeChat記事** | 検索 + 閲覧 | 設定不要 | WeChat公式アカウント記事の検索+閲覧(完全Markdown)([Exa](https://exa.ai) + [Camoufox](https://github.com/daijro/camoufox)(オプション)) | -| 📰 **Weibo** | トレンド・検索・フィード・コメント | 設定不要 | ホット検索、コンテンツ/ユーザー/トピック検索、フィード、コメント([mcp-server-weibo](https://github.com/Panniantong/mcp-server-weibo)) | | 💻 **V2EX** | 人気トピック・ノードトピック・トピック詳細+返信・ユーザープロフィール | 設定不要 | 公開JSON API、認証不要。技術コミュニティのコンテンツに最適 | | 📈 **雪球(Xueqiu)** | 株価・検索・人気投稿・人気銘柄 | 設定不要 | 公開APIで自動セッションCookie、ログイン不要 | | 🎙️ **小宇宙Podcast** | 文字起こし | 無料APIキー | Podcast音声 → Groq Whisper(無料)による完全テキスト文字起こし | @@ -206,7 +204,6 @@ channels/ ├── bilibili.py → bili-cli ▸ OpenCLI ▸ 検索 API(yt-dlp は 412 制限により退役) ├── reddit.py → OpenCLI ▸ rdt-cli(ログイン状態が必要) ├── xiaohongshu.py → OpenCLI ▸ xiaohongshu-mcp ▸ xhs-cli -├── douyin.py → mcporter MCP ← 他の抖音ツールに差し替え可能… ├── linkedin.py → linkedin-mcp ← LinkedIn APIに差し替え可能… ├── rss.py → feedparser ← atomaなどに差し替え可能… ├── exa_search.py → mcporter MCP ← Tavily、SerpAPIなどに差し替え可能… @@ -227,10 +224,8 @@ channels/ | GitHub | [gh CLI](https://cli.github.com) | 公式ツール、認証後フルAPI | | RSS閲覧 | [feedparser](https://github.com/kurtmckee/feedparser) | Pythonエコシステムの標準、⭐2.3K | | 小紅書 | [OpenCLI](https://github.com/jackwener/opencli)(デスクトップ)▸ [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp)(サーバー)▸ xhs-cli | OpenCLI は既存のユーザー管理セッションのみ使用。その他は Cookie-Editor で手動設定 | -| 抖音 | [douyin-mcp-server](https://github.com/yzfly/douyin-mcp-server) | MCPサーバー、ログイン不要、動画解析 + ウォーターマークなしダウンロード | | LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+、MCPサーバー、ブラウザ自動化 | | WeChat記事 | [Exa](https://exa.ai)(検索+閲覧)+ [Camoufox](https://github.com/daijro/camoufox)(オプション) | ゼロ設定で検索+全文閲覧、Camoufoxでオプション強化 | -| Weibo | `mcporter` | `mcporter call 'weibo.get_trendings(limit: 10)'` | | 小宇宙Podcast | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh ` | > 📌 これらは*現在*の選択です。気に入らなければファイルを差し替えるだけ。それがスキャフォールディングの要点です。 @@ -293,17 +288,11 @@ Agent Reach はtwitter-cliを使用し、Cookie認証でTwitterにアクセス Agent Reach は小紅書へのログインを代行せず、ブラウザ Cookie も読み取りません。OpenCLI はユーザーが既に所有・管理している Chrome セッションだけを使用します。既存セッションがない場合は自動ログインせず、Cookie-Editor で手動エクスポートして xiaohongshu-mcp または旧ツールを設定してください。`agent-reach configure xhs-cookies` は OpenCLI / Chrome に Cookie を注入しません。 -
-AIエージェントで抖音の動画を解析するには? - -douyin-mcp-serverをインストールすれば、`mcporter call 'douyin.parse_douyin_video_info(share_link: "share_url")'` で動画情報を解析し、ウォーターマークなしのダウンロードリンクを取得できます。ログイン不要 — 抖音のリンクを共有するだけ。詳細は https://github.com/yzfly/douyin-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) +[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) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) ## お問い合わせ diff --git a/docs/README_ko.md b/docs/README_ko.md index 9f06fba3..d8a7c118 100644 --- a/docs/README_ko.md +++ b/docs/README_ko.md @@ -64,10 +64,8 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma | 🌐 **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** | 읽기 · 검색 · 댓글 | OpenCLI / Cookie | OpenCLI는 사용자가 관리하는 기존 Chrome 세션만 사용하며, MCP/기존 도구는 Cookie-Editor 사용 | -| 🎵 **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를 통한 팟캐스트 오디오 → 전체 텍스트 변환 (무료) | @@ -206,7 +204,6 @@ channels/ ├── bilibili.py → bili-cli ▸ OpenCLI ▸ 검색 API (yt-dlp는 412 차단으로 폐기) ├── reddit.py → OpenCLI ▸ rdt-cli (로그인 상태 필요) ├── xiaohongshu.py → OpenCLI ▸ xiaohongshu-mcp ▸ xhs-cli -├── douyin.py → mcporter MCP ← 다른 Douyin 도구로 교체... ├── linkedin.py → linkedin-mcp ← LinkedIn API로 교체... ├── rss.py → feedparser ← atoma로 교체... ├── exa_search.py → mcporter MCP ← Tavily, SerpAPI로 교체... @@ -228,10 +225,8 @@ channels/ | GitHub | [gh CLI](https://cli.github.com) | 공식 도구, 인증 후 전체 API | | RSS 읽기 | [feedparser](https://github.com/kurtmckee/feedparser) | Python 생태계 표준, 2.3K stars | | XiaoHongShu | [OpenCLI](https://github.com/jackwener/opencli) (데스크톱) ▸ [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) (서버) ▸ xhs-cli | OpenCLI는 사용자가 관리하는 기존 세션만 사용하며, 그 외에는 Cookie-Editor로 수동 설정 | -| 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 ` | > 📌 이것은 *현재* 선택입니다. 마음에 안 드나요? 파일을 교체하세요. 그것이 스캐폴딩의 전부입니다. @@ -294,44 +289,11 @@ Agent Reach는 cookie 인증을 통해 Twitter에 접근하는 twitter-cli를 Agent Reach는 XiaoHongShu 로그인을 대신 수행하거나 브라우저 cookie를 읽지 않습니다. OpenCLI는 사용자가 이미 보유하고 명시적으로 관리하는 Chrome 세션만 사용합니다. 기존 세션이 없다면 자동 로그인하지 말고 Cookie-Editor로 수동 내보내 xiaohongshu-mcp 또는 기존 도구를 설정하세요. `agent-reach configure xhs-cookies`는 OpenCLI/Chrome에 cookie를 주입하지 않습니다. -
-AI 에이전트로 Douyin / 抖音 비디오를 파싱하는 방법? - -douyin-mcp-server를 설치한 다음, 에이전트가 `mcporter call 'douyin.parse_douyin_video_info(share_link: "share_url")'`를 사용하여 비디오 정보를 파싱하고 워터마크 없는 다운로드 링크를 가져올 수 있습니다. 로그인 불필요 — Douyin 링크를 공유하기만 하면 됩니다. https://github.com/yzfly/douyin-mcp-server 참조 -
- -
-하나의 MCP로 Douyin과 XiaoHongShu 모두에서 대본을 추출하는 방법? - -다음을 처리할 수 있는 하나의 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` - -이것은 에이전트 워크플로우가 "링크를 붙여넣고, 스크립트 파일을 받음"일 때 유용합니다. -
- --- ## 크레딧 -[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) +[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) ## 연락처 diff --git a/tests/test_auth_guidance_policy.py b/tests/test_auth_guidance_policy.py index 99b173fa..2a2dd8ca 100644 --- a/tests/test_auth_guidance_policy.py +++ b/tests/test_auth_guidance_policy.py @@ -142,6 +142,14 @@ def test_localized_readmes_keep_current_bilibili_and_xhs_routes(): ), path.relative_to(ROOT) +def test_localized_readmes_do_not_advertise_retired_channels(): + """Japanese and Korean docs must match the channels shipped by the CLI.""" + for path in (ROOT / "docs" / "README_ja.md", ROOT / "docs" / "README_ko.md"): + text = path.read_text(encoding="utf-8").lower() + assert "douyin" not in text, path.relative_to(ROOT) + assert "weibo" not in text, path.relative_to(ROOT) + + def test_public_guidance_never_installs_the_unrelated_pypi_package(): """The PyPI name is owned by another project; GitHub URLs are required.""" candidates = _policy_documents() + [