From 0e21545bc59513a7274e980c7b72850c825f5022 Mon Sep 17 00:00:00 2001 From: Always lucky <266541610+Chewy-b0t@users.noreply.github.com> Date: Mon, 13 Apr 2026 04:17:20 -0500 Subject: [PATCH] Add English skill locale support (#223) feat: add English skill locale support with SKILL_en.md --- agent_reach/cli.py | 30 +++- agent_reach/skill/SKILL_en.md | 312 ++++++++++++++++++++++++++++++++++ docs/README_en.md | 5 + pyproject.toml | 1 + tests/test_skill_command.py | 38 +++++ 5 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 agent_reach/skill/SKILL_en.md diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 89ebd42..4065b91 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -326,8 +326,30 @@ def _install_skill(): import shutil import importlib.resources + def _is_english_locale(value: str) -> bool: + 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: + return skill_pkg.joinpath(resource_name).read_text(encoding="utf-8") + except FileNotFoundError: + return skill_pkg.joinpath("SKILL.md").read_text(encoding="utf-8") + def _copy_skill_dir(target: str) -> bool: - """Copy entire skill directory (SKILL.md + references/).""" + """Copy entire skill directory (locale-specific SKILL.md + references/).""" try: # Clear existing installation if os.path.exists(target): @@ -337,13 +359,13 @@ def _install_skill(): # Get skill directory from package (with fallback for editable installs) try: skill_pkg = importlib.resources.files("agent_reach").joinpath("skill") - skill_md = skill_pkg.joinpath("SKILL.md").read_text(encoding="utf-8") + skill_md = _read_skill_markdown(skill_pkg) except Exception: from pathlib import Path skill_pkg = Path(__file__).resolve().parent / "skill" - skill_md = (skill_pkg / "SKILL.md").read_text(encoding="utf-8") + skill_md = _read_skill_markdown(skill_pkg) - # Copy SKILL.md + # Copy SKILL.md using the selected locale file with open(os.path.join(target, "SKILL.md"), "w", encoding="utf-8") as f: f.write(skill_md) diff --git a/agent_reach/skill/SKILL_en.md b/agent_reach/skill/SKILL_en.md new file mode 100644 index 0000000..8c272f5 --- /dev/null +++ b/agent_reach/skill/SKILL_en.md @@ -0,0 +1,312 @@ +--- +name: agent-reach +description: > + Give your AI agent eyes to see the entire internet. + Search and read 17 platforms: Twitter/X, Reddit, YouTube, GitHub, Bilibili, + XiaoHongShu, Douyin, Weibo, WeChat Articles, Xiaoyuzhou Podcast, LinkedIn, + V2EX, Xueqiu, RSS, Exa web search, and any web page. + Zero config for 8 channels. Use when the user asks to search, read, or interact + on any supported platform, shares a URL, or asks to search the web. + Triggers: "search twitter", "search xiaohongshu", "watch this video", + "search the web", "look this up", "research", "youtube transcript", + "search reddit", "read this link", "bilibili", "douyin video", + "wechat article", "wechat official account", "weibo", "V2EX", + "xiaoyuzhou", "podcast", "xueqiu", "stock quote", + "install agent reach". +metadata: + openclaw: + homepage: https://github.com/Panniantong/Agent-Reach +--- + +# Agent Reach — Usage Guide + +Upstream tools for 17 platforms. Call them directly. + +Run `agent-reach doctor` to check which channels are available. + +## ⚠️ Workspace Rules + +**Never create files in the agent workspace.** Use `/tmp/` for temporary output and `~/.agent-reach/` for persistent data. + +## Web — Any URL + +```bash +curl -s "https://r.jina.ai/URL" +``` + +## Web Search (Exa) + +```bash +mcporter call 'exa.web_search_exa(query: "query", numResults: 5)' +mcporter call 'exa.get_code_context_exa(query: "code question", tokensNum: 3000)' +``` + +## Twitter/X (bird) + +```bash +bird search "query" -n 10 # search +bird read URL_OR_ID # read tweet (supports /status/ and /article/ URLs) +bird user-tweets @username -n 20 # user timeline +bird thread URL_OR_ID # full thread +``` + +## YouTube (yt-dlp) + +```bash +yt-dlp --dump-json "URL" # video metadata +yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --skip-download -o "/tmp/%(id)s" "URL" + # download subtitles, then read the .vtt file +yt-dlp --dump-json "ytsearch5:query" # search +``` + +## Bilibili (yt-dlp) + +```bash +yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx" +yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --convert-subs vtt --skip-download -o "/tmp/%(id)s" "URL" +``` + +> Server IPs may get 412. Use `--cookies-from-browser chrome` or configure a proxy. + +## Reddit + +```bash +curl -s "https://www.reddit.com/r/SUBREDDIT/hot.json?limit=10" -H "User-Agent: agent-reach/1.0" +curl -s "https://www.reddit.com/search.json?q=QUERY&limit=10" -H "User-Agent: agent-reach/1.0" +``` + +> Server IPs may get 403. Search via Exa instead, or configure a proxy. + +## GitHub (gh CLI) + +```bash +gh search repos "query" --sort stars --limit 10 +gh repo view owner/repo +gh search code "query" --language python +gh issue list -R owner/repo --state open +gh issue view 123 -R owner/repo +``` + +## XiaoHongShu (mcporter) + +```bash +mcporter call 'xiaohongshu.search_feeds(keyword: "query")' +mcporter call 'xiaohongshu.get_feed_detail(feed_id: "xxx", xsec_token: "yyy")' +mcporter call 'xiaohongshu.get_feed_detail(feed_id: "xxx", xsec_token: "yyy", load_all_comments: true)' +mcporter call 'xiaohongshu.publish_content(title: "Title", content: "Body text", images: ["/path/img.jpg"], tags: ["tag"])' +``` + +> Requires login. Use Cookie-Editor to import cookies. + +> **Tip: Clean bloated output.** The XHS API returns large JSON with many unused fields. +> Pipe through the formatter to save context: +> ```bash +> mcporter call 'xiaohongshu.search_feeds(keyword: "query")' | agent-reach format xhs +> ``` +> This keeps only: title, content, author, engagement counts, image URLs, and tags. + +## Douyin (mcporter) + +```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/")' +``` + +> No login needed. + +## WeChat Articles + +**Search** (`miku_ai`): +```bash +# miku_ai is installed inside the agent-reach Python environment. +# Use the same interpreter that runs agent-reach (handles pipx / venv installs): +AGENT_REACH_PYTHON=$(python3 -c "import agent_reach, sys; print(sys.executable)" 2>/dev/null || echo python3) +$AGENT_REACH_PYTHON -c " +import asyncio +from miku_ai import get_wexin_article +async def s(): + for a in await get_wexin_article('query', 5): + print(f'{a[\"title\"]} | {a[\"url\"]}') +asyncio.run(s()) +" +``` + +**Read** (Camoufox — bypasses WeChat anti-bot): +```bash +cd ~/.agent-reach/tools/wechat-article-for-ai && python3 main.py "https://mp.weixin.qq.com/s/ARTICLE_ID" +``` + +> WeChat articles cannot be read with Jina Reader or curl. Use Camoufox. + +## Weibo (mcporter) + +```bash +# Trending topics +mcporter call 'weibo.get_trendings(limit: 20)' + +# Search users +mcporter call 'weibo.search_users(keyword: "Lei Jun", limit: 10)' + +# Get a user profile +mcporter call 'weibo.get_profile(uid: "1195230310")' + +# Get a user's feed +mcporter call 'weibo.get_feeds(uid: "1195230310", limit: 20)' + +# Get a user's hot posts +mcporter call 'weibo.get_hot_feeds(uid: "1195230310", limit: 10)' + +# Search post content +mcporter call 'weibo.search_content(keyword: "artificial intelligence", limit: 20)' + +# Search topics +mcporter call 'weibo.search_topics(keyword: "AI", limit: 10)' + +# Get post comments +mcporter call 'weibo.get_comments(mid: "5099916367123456", limit: 50)' + +# Get fans +mcporter call 'weibo.get_fans(uid: "1195230310", limit: 20)' + +# Get followings +mcporter call 'weibo.get_followers(uid: "1195230310", limit: 20)' +``` + +> Zero config. No login needed. Uses the mobile API with auto-generated visitor cookies. + +## Xiaoyuzhou Podcast (groq-whisper + ffmpeg) + +```bash +# Transcribe a single podcast episode (outputs text to /tmp/) +~/.agent-reach/tools/xiaoyuzhou/transcribe.sh "https://www.xiaoyuzhoufm.com/episode/EPISODE_ID" +``` + +> Requires `ffmpeg` and a Groq API key (free). +> Configure the key with `agent-reach configure groq-key YOUR_KEY`. +> On first run, install the tools with `agent-reach install --env=auto`. +> Run `agent-reach doctor` to check status. +> Output Markdown files are saved to `/tmp/` by default. + +## LinkedIn (mcporter) + +```bash +mcporter call 'linkedin.get_person_profile(linkedin_url: "https://linkedin.com/in/username")' +mcporter call 'linkedin.search_people(keyword: "AI engineer", limit: 10)' +``` + +Fallback: `curl -s "https://r.jina.ai/https://linkedin.com/in/username"` + +## V2EX (public API) + +```bash +# Hot topics +curl -s "https://www.v2ex.com/api/topics/hot.json" -H "User-Agent: agent-reach/1.0" + +# Topics in a node (node_name examples: python, tech, jobs, qna) +curl -s "https://www.v2ex.com/api/topics/show.json?node_name=python&page=1" -H "User-Agent: agent-reach/1.0" + +# Topic details (extract topic_id from URLs like https://www.v2ex.com/t/1234567) +curl -s "https://www.v2ex.com/api/topics/show.json?id=TOPIC_ID" -H "User-Agent: agent-reach/1.0" + +# Topic replies +curl -s "https://www.v2ex.com/api/replies/show.json?topic_id=TOPIC_ID&page=1" -H "User-Agent: agent-reach/1.0" + +# User profile +curl -s "https://www.v2ex.com/api/members/show.json?username=USERNAME" -H "User-Agent: agent-reach/1.0" +``` + +Python example (`V2EXChannel`): + +```python +from agent_reach.channels.v2ex import V2EXChannel + +ch = V2EXChannel() + +# Get hot topics (default 20 items) +# Returned fields: id, title, url, replies, node_name, node_title, content(first 200 chars), created +topics = ch.get_hot_topics(limit=10) +for t in topics: + print(f"[{t['node_title']}] {t['title']} ({t['replies']} replies) {t['url']}") + print(f" id={t['id']} created={t['created']}") + +# Get latest topics for a specific node +# Returned fields: id, title, url, replies, node_name, node_title, content(first 200 chars), created +node_topics = ch.get_node_topics("python", limit=5) +for t in node_topics: + print(t["id"], t["title"], t["url"]) + +# Get one topic plus replies +# Returned fields: id, title, url, content, replies_count, node_name, node_title, +# author, created, replies (list of {author, content, created}) +topic = ch.get_topic(1234567) +print(topic["title"], "—", topic["author"]) +for r in topic["replies"]: + print(f" {r['author']}: {r['content'][:80]}") + +# Get user info +# Returned fields: id, username, url, website, twitter, psn, github, btc, location, bio, avatar, created +user = ch.get_user("Livid") +print(user["username"], user["bio"], user["github"]) + +# Search (not supported by the public V2EX API; returns guidance instead) +result = ch.search("asyncio") +print(result[0]["error"]) # Use built-in site search or the Exa channel instead +``` + +> No auth required. Results are public JSON. V2EX node names are listed at https://www.v2ex.com/planes + +## Xueqiu (public API) + +```python +from agent_reach.channels.xueqiu import XueqiuChannel + +ch = XueqiuChannel() + +# Get stock quotes (symbol examples: SH600519 mainland China, SZ000858 Shenzhen, AAPL US, 00700 HK) +# Returned fields: symbol, name, current, percent, chg, high, low, open, last_close, +# volume, amount, market_capital, turnover_rate, pe_ttm, timestamp +quote = ch.get_stock_quote("AAPL") +print(f"{quote['name']} ({quote['symbol']}): {quote['current']} ({quote['percent']}%)") + +# Search stocks +# Returned fields: symbol, name, exchange +stocks = ch.search_stock("Apple", limit=5) +for s in stocks: + print(f"{s['name']} ({s['symbol']}) - {s['exchange']}") + +# Hot posts +# Returned fields: id, title, text(first 200 chars), author, likes, url +posts = ch.get_hot_posts(limit=10) +for p in posts: + print(f"{p['author']}: {p['text'][:50]}... ({p['likes']} likes)") + +# Hot stocks (stock_type=10 popularity ranking, stock_type=12 watchlist ranking) +# Returned fields: symbol, name, current, percent, rank +hot = ch.get_hot_stocks(limit=10, stock_type=10) +for s in hot: + print(f"#{s['rank']} {s['name']} ({s['symbol']}): {s['current']} ({s['percent']}%)") +``` + +> No login required. Agent Reach auto-fetches session cookies, and all public APIs can be used directly. + +## RSS (feedparser) + +```python +python3 -c " +import feedparser +for e in feedparser.parse('FEED_URL').entries[:5]: + print(f'{e.title} — {e.link}') +" +``` + +## Troubleshooting + +- **Channel not working?** Run `agent-reach doctor` — it shows status and fix instructions. +- **Twitter fetch failed?** Ensure `undici` is installed: `npm install -g undici`. Configure a proxy if needed: `agent-reach configure proxy URL`. + +## Setting Up a Channel ("help me configure XXX") + +If a channel needs setup (cookies, Docker, etc.), fetch the install guide: +https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md + +The user only provides cookies. Everything else is your job. diff --git a/docs/README_en.md b/docs/README_en.md index 0fc5c9b..dc1cedf 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -116,6 +116,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. > 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. --- diff --git a/pyproject.toml b/pyproject.toml index 277a540..7fb545d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ packages = ["agent_reach"] [tool.hatch.build.targets.wheel.force-include] "agent_reach/guides" = "agent_reach/guides" +# Keep the whole skill directory so SKILL.md, SKILL_en.md, and references/ ship together. "agent_reach/skill" = "agent_reach/skill" "agent_reach/scripts" = "agent_reach/scripts" diff --git a/tests/test_skill_command.py b/tests/test_skill_command.py index 0bd29df..ff76233 100644 --- a/tests/test_skill_command.py +++ b/tests/test_skill_command.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Tests for 'agent-reach skill' command and _install_skill / _uninstall_skill.""" +import importlib.resources import os import tempfile import unittest @@ -12,6 +13,16 @@ from agent_reach.cli import _install_skill, _uninstall_skill class TestSkillCommand(unittest.TestCase): """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): """_install_skill should create SKILL.md in the first available skill dir.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -85,6 +96,33 @@ class TestSkillCommand(unittest.TestCase): content = f.read() 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) as f: + content = f.read() + self.assertTrue(content.strip()) + self.assertIn("Give your AI agent eyes to see the entire internet.", content) + self.assertNotIn("搜推特", content) + self.assertTrue( + os.path.exists(os.path.join(skill_parent, "agent-reach", "references")) + ) + if __name__ == "__main__": unittest.main()