diff --git a/README.md b/README.md index ff70ce6..89284ee 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去 | 💬 **微信公众号** | 搜索 + 阅读公众号文章(全文 Markdown) | — | 无需配置 | | 📰 **微博** | 热搜、搜索内容/用户/话题、用户动态、评论 | — | 无需配置 | | 💻 **V2EX** | 热门帖子、节点帖子、帖子详情+回复、用户信息 | — | 无需配置 | +| 📈 **雪球** | 股票行情、搜索股票、热门帖子、热门股票排行 | — | 无需配置 | | 🎙️ **小宇宙播客** | — | 播客音频转文字(Whisper 转录,免费 Key) | 告诉 Agent「帮我配小宇宙播客」 | > **不知道怎么配?不用查文档。** 直接告诉 Agent「帮我配 XXX」,它知道需要什么、会一步一步引导你。 diff --git a/agent_reach/channels/__init__.py b/agent_reach/channels/__init__.py index a402a81..f2a0149 100644 --- a/agent_reach/channels/__init__.py +++ b/agent_reach/channels/__init__.py @@ -22,6 +22,7 @@ from .weibo import WeiboChannel from .xiaoyuzhou import XiaoyuzhouChannel from .v2ex import V2EXChannel +from .xueqiu import XueqiuChannel @@ -38,6 +39,7 @@ WeiboChannel(), XiaoyuzhouChannel(), V2EXChannel(), + XueqiuChannel(), RSSChannel(), ExaSearchChannel(), WebChannel(), diff --git a/agent_reach/channels/xueqiu.py b/agent_reach/channels/xueqiu.py new file mode 100644 index 0000000..0a6e922 --- /dev/null +++ b/agent_reach/channels/xueqiu.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +"""Xueqiu (雪球) — stock quotes, search, trending posts & hot stocks.""" + +import http.cookiejar +import json +import re +import urllib.request +from typing import Any + +from .base import Channel + +_UA = "agent-reach/1.0" +_TIMEOUT = 10 +_XUEQIU_HOME = "https://xueqiu.com" + +# --------------- cookie-aware HTTP helpers --------------- # + +_cookie_jar = http.cookiejar.CookieJar() +_opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(_cookie_jar), +) +_cookies_initialized = False + + +def _ensure_cookies() -> None: + """Visit xueqiu.com homepage once to obtain session cookies.""" + global _cookies_initialized + if _cookies_initialized: + return + req = urllib.request.Request(_XUEQIU_HOME, headers={"User-Agent": _UA}) + _opener.open(req, timeout=_TIMEOUT) + _cookies_initialized = True + + +def _get_json(url: str) -> Any: + """Fetch *url* with Xueqiu session cookies and return parsed JSON.""" + _ensure_cookies() + req = urllib.request.Request(url, headers={"User-Agent": _UA}) + with _opener.open(req, timeout=_TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _strip_html(text: str) -> str: + """Remove HTML tags and decode common entities.""" + text = re.sub(r"<[^>]+>", "", text) + for entity, char in ((" ", " "), ("&", "&"), ("<", "<"), (">", ">")): + text = text.replace(entity, char) + return text.strip() + + +class XueqiuChannel(Channel): + name = "xueqiu" + description = "雪球股票行情与社区动态" + backends = ["Xueqiu API (public)"] + tier = 0 + + # ------------------------------------------------------------------ # + # URL routing + # ------------------------------------------------------------------ # + + def can_handle(self, url: str) -> bool: + from urllib.parse import urlparse + + d = urlparse(url).netloc.lower() + return "xueqiu.com" in d + + # ------------------------------------------------------------------ # + # Health check + # ------------------------------------------------------------------ # + + def check(self, config=None): + try: + data = _get_json("https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH000001") + items = (data.get("data") or {}).get("items") or [] + if items: + return "ok", "公开 API 可用(行情、搜索、热帖、热股)" + return "warn", "API 响应异常(返回数据为空)" + except Exception as e: + return "warn", f"Xueqiu API 连接失败(可能需要代理):{e}" + + # ------------------------------------------------------------------ # + # Data-fetching methods + # ------------------------------------------------------------------ # + + def get_stock_quote(self, symbol: str) -> dict: + """获取实时股票行情。 + + Args: + symbol: 股票代码,如 SH600519(沪)、SZ000858(深)、AAPL(美)、00700(港) + + Returns a dict with keys: + symbol, name, current, percent, chg, high, low, open, last_close, + volume, amount, market_capital, turnover_rate, pe_ttm, timestamp + """ + data = _get_json(f"https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol={symbol}") + items = (data.get("data") or {}).get("items") or [] + q = (items[0].get("quote") or {}) if items else {} + return { + "symbol": q.get("symbol", symbol), + "name": q.get("name", ""), + "current": q.get("current"), + "percent": q.get("percent"), + "chg": q.get("chg"), + "high": q.get("high"), + "low": q.get("low"), + "open": q.get("open"), + "last_close": q.get("last_close"), + "volume": q.get("volume"), + "amount": q.get("amount"), + "market_capital": q.get("market_capital"), + "turnover_rate": q.get("turnover_rate"), + "pe_ttm": q.get("pe_ttm"), + "timestamp": q.get("timestamp"), + } + + def search_stock(self, query: str, limit: int = 10) -> list: + """搜索股票。 + + Args: + query: 股票代码或中文名称,如 "茅台"、"600519" + limit: 最多返回条数 + + Returns a list of dicts with keys: + symbol, name, exchange + """ + data = _get_json( + f"https://xueqiu.com/stock/search.json?code={urllib.request.quote(query)}&size={limit}" + ) + stocks = data.get("stocks") or [] + results = [] + for s in stocks[:limit]: + results.append( + { + "symbol": s.get("code", ""), + "name": s.get("name", ""), + "exchange": s.get("exchange", ""), + } + ) + return results + + def get_hot_posts(self, limit: int = 20) -> list: + """获取雪球热门帖子。 + + Args: + limit: 最多返回条数(上限 50) + + Returns a list of dicts with keys: + id, title, text, author, likes, url + """ + data = _get_json("https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1") + items = (data.get("data") or {}).get("items") or [] + results = [] + for item in items[:limit]: + original = item.get("original_status") or item + text = _strip_html(original.get("text") or original.get("description") or "") + user = original.get("user") or {} + results.append( + { + "id": original.get("id", 0), + "title": original.get("title") or "", + "text": text[:200], + "author": user.get("screen_name", ""), + "likes": original.get("like_count", 0), + "url": f"https://xueqiu.com{original['target']}" + if original.get("target") + else "", + } + ) + return results + + def get_hot_stocks(self, limit: int = 10, stock_type: int = 10) -> list: + """获取热门股票排行。 + + Args: + limit: 最多返回条数(上限 50) + stock_type: 10=人气榜(默认),12=关注榜 + + Returns a list of dicts with keys: + symbol, name, current, percent, rank + """ + data = _get_json( + f"https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size={limit}&type={stock_type}" + ) + items = (data.get("data") or {}).get("items") or [] + results = [] + for idx, item in enumerate(items[:limit], 1): + results.append( + { + "symbol": item.get("code") or item.get("symbol", ""), + "name": item.get("name", ""), + "current": item.get("current"), + "percent": item.get("percent"), + "rank": idx, + } + ) + return results diff --git a/agent_reach/skill/SKILL.md b/agent_reach/skill/SKILL.md index ffca794..5aca3be 100644 --- a/agent_reach/skill/SKILL.md +++ b/agent_reach/skill/SKILL.md @@ -2,15 +2,16 @@ name: agent-reach description: > Give your AI agent eyes to see the entire internet. - Search and read 16 platforms: Twitter/X, Reddit, YouTube, GitHub, Bilibili, + Search and read 17 platforms: Twitter/X, Reddit, YouTube, GitHub, Bilibili, XiaoHongShu, Douyin, Weibo, WeChat Articles, Xiaoyuzhou Podcast, LinkedIn, - Instagram, V2EX, RSS, Exa web search, and any web page. + V2EX, Xueqiu, RSS, Exa web search, and any web page. Zero config for 8 channels. Use when user asks to search, read, or interact on any supported platform, shares a URL, or asks to search the web. Triggers: "搜推特", "搜小红书", "看视频", "搜一下", "上网搜", "帮我查", "search twitter", "youtube transcript", "search reddit", "read this link", "B站", "bilibili", "抖音视频", "微信文章", "公众号", "微博", "V2EX", - "小宇宙", "播客", "podcast", "web search", "research", "帮我安装". + "小宇宙", "播客", "podcast", "雪球", "股票", "stock quote", + "web search", "research", "帮我安装". metadata: openclaw: homepage: https://github.com/Panniantong/Agent-Reach @@ -247,6 +248,40 @@ print(result[0]["error"]) # 提示使用站内搜索或 Exa channel > No auth required. Results are public JSON. V2EX 节点名见 https://www.v2ex.com/planes +## 雪球 / Xueqiu (public API) + +```python +from agent_reach.channels.xueqiu import XueqiuChannel + +ch = XueqiuChannel() + +# 获取股票行情(符号格式:SH600519 沪市、SZ000858 深市、AAPL 美股、00700 港股) +# 返回字段:symbol, name, current, percent, chg, high, low, open, last_close, +# volume, amount, market_capital, turnover_rate, pe_ttm, timestamp +quote = ch.get_stock_quote("SH600519") +print(f"{quote['name']} ({quote['symbol']}): {quote['current']} ({quote['percent']}%)") + +# 搜索股票 +# 返回字段:symbol, name, exchange +stocks = ch.search_stock("茅台", limit=5) +for s in stocks: + print(f"{s['name']} ({s['symbol']}) - {s['exchange']}") + +# 热门帖子 +# 返回字段:id, title, text(前200字), author, likes, url +posts = ch.get_hot_posts(limit=10) +for p in posts: + print(f"{p['author']}: {p['text'][:50]}... ({p['likes']} 赞)") + +# 热门股票(stock_type=10 人气榜,stock_type=12 关注榜) +# 返回字段: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']}%)") +``` + +> 无需登录。自动获取会话 Cookie,所有公开 API 均可直接使用。 + ## RSS (feedparser) ## RSS diff --git a/docs/README_en.md b/docs/README_en.md index 03bd313..ef271fc 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -69,6 +69,7 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma | 💬 **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 | +| 📈 **Xueqiu (雪球)** | Stock quotes · Search · Hot posts · Hot stocks | Zero config | Public API with auto session cookies, no login required | | 🎙️ **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)) | | 📦 **GitHub** | Read · Search | Zero config | [gh CLI](https://cli.github.com) powered. Public repos work immediately. `gh auth login` unlocks Fork, Issue, PR | diff --git a/tests/test_channel_contracts.py b/tests/test_channel_contracts.py index f0b3818..5fe4b74 100644 --- a/tests/test_channel_contracts.py +++ b/tests/test_channel_contracts.py @@ -117,6 +117,7 @@ def test_channel_can_handle_contract(): "linkedin": "https://www.linkedin.com/in/test", "weibo": "https://weibo.com/u/1749127163", "rss": "https://example.com/feed.xml", + "xueqiu": "https://xueqiu.com/S/SH600519", "exa_search": "https://example.com", "web": "https://example.com", } diff --git a/tests/test_channels.py b/tests/test_channels.py index 1785de8..62ac663 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -8,6 +8,7 @@ from agent_reach.channels import get_all_channels, get_channel from agent_reach.channels.xiaohongshu import XiaoHongShuChannel +from agent_reach.channels.xueqiu import XueqiuChannel from agent_reach.channels.v2ex import V2EXChannel @@ -327,6 +328,273 @@ def test_search_returns_unavailable_notice(self): assert "V2EX" in result[0]["error"] +class TestXueqiuChannel: + def test_can_handle_xueqiu_urls(self): + ch = XueqiuChannel() + assert ch.can_handle("https://xueqiu.com/S/SH600519") + assert ch.can_handle("https://stock.xueqiu.com/v5/stock/batch/quote.json") + assert ch.can_handle("https://www.xueqiu.com/1234567890/12345") + assert not ch.can_handle("https://github.com/user/repo") + assert not ch.can_handle("https://v2ex.com/t/123") + + def test_check_ok_when_api_reachable(self, monkeypatch): + import agent_reach.channels.xueqiu as xueqiu_mod + + monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True) + + fake_response_data = { + "data": { + "items": [ + {"quote": {"symbol": "SH000001", "name": "上证指数", "current": 3200.0}} + ] + } + } + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def read(self): + return json.dumps(fake_response_data).encode() + + monkeypatch.setattr(xueqiu_mod._opener, "open", lambda req, timeout=None: FakeResponse()) + status, msg = XueqiuChannel().check() + assert status == "ok" + assert "公开 API 可用" in msg + + def test_check_warn_when_api_unreachable(self, monkeypatch): + import agent_reach.channels.xueqiu as xueqiu_mod + + monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True) + + def raise_error(req, timeout=None): + raise URLError("connection refused") + + monkeypatch.setattr(xueqiu_mod._opener, "open", raise_error) + status, msg = XueqiuChannel().check() + assert status == "warn" + assert "失败" in msg + + # ------------------------------------------------------------------ # + # get_stock_quote + # ------------------------------------------------------------------ # + + def test_get_stock_quote(self, monkeypatch): + import agent_reach.channels.xueqiu as xueqiu_mod + + monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True) + + fake_data = { + "data": { + "items": [ + { + "quote": { + "symbol": "SH600519", + "name": "贵州茅台", + "current": 1800.0, + "percent": 1.5, + "chg": 26.6, + "high": 1810.0, + "low": 1770.0, + "open": 1775.0, + "last_close": 1773.4, + "volume": 12345678, + "amount": 22000000000, + "market_capital": 2260000000000, + "turnover_rate": 0.098, + "pe_ttm": 30.5, + "timestamp": 1700000000000, + } + } + ] + } + } + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def read(self): + return json.dumps(fake_data).encode() + + monkeypatch.setattr(xueqiu_mod._opener, "open", lambda req, timeout=None: FakeResponse()) + quote = XueqiuChannel().get_stock_quote("SH600519") + assert quote["symbol"] == "SH600519" + assert quote["name"] == "贵州茅台" + assert quote["current"] == 1800.0 + assert quote["percent"] == 1.5 + assert quote["volume"] == 12345678 + + # ------------------------------------------------------------------ # + # search_stock + # ------------------------------------------------------------------ # + + def test_search_stock(self, monkeypatch): + import agent_reach.channels.xueqiu as xueqiu_mod + + monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True) + + fake_data = { + "stocks": [ + {"code": "SH600519", "name": "贵州茅台", "exchange": "SHA"}, + {"code": "SZ000858", "name": "五粮液", "exchange": "SZA"}, + ] + } + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def read(self): + return json.dumps(fake_data).encode() + + monkeypatch.setattr(xueqiu_mod._opener, "open", lambda req, timeout=None: FakeResponse()) + results = XueqiuChannel().search_stock("茅台", limit=5) + assert len(results) == 2 + assert results[0]["symbol"] == "SH600519" + assert results[0]["name"] == "贵州茅台" + assert results[1]["exchange"] == "SZA" + + # ------------------------------------------------------------------ # + # get_hot_posts + # ------------------------------------------------------------------ # + + def test_get_hot_posts_returns_list(self, monkeypatch): + import agent_reach.channels.xueqiu as xueqiu_mod + + monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True) + + fake_data = { + "data": { + "items": [ + { + "original_status": { + "id": 111, + "title": "市场分析", + "text": "

今天大盘走势&分析

", + "user": {"screen_name": "投资者A"}, + "like_count": 42, + "target": "/1234567890/111", + } + }, + { + "original_status": { + "id": 222, + "title": "", + "text": "短评", + "user": {"screen_name": "投资者B"}, + "like_count": 10, + "target": "/9876543210/222", + } + }, + ] + } + } + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def read(self): + return json.dumps(fake_data).encode() + + monkeypatch.setattr(xueqiu_mod._opener, "open", lambda req, timeout=None: FakeResponse()) + posts = XueqiuChannel().get_hot_posts(limit=10) + assert len(posts) == 2 + assert posts[0]["id"] == 111 + assert posts[0]["author"] == "投资者A" + assert posts[0]["likes"] == 42 + assert "今天大盘走势&分析" in posts[0]["text"] # HTML stripped + assert "

" not in posts[0]["text"] + assert posts[0]["url"] == "https://xueqiu.com/1234567890/111" + + def test_get_hot_posts_respects_limit(self, monkeypatch): + import agent_reach.channels.xueqiu as xueqiu_mod + + monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True) + + fake_data = { + "data": { + "items": [ + { + "original_status": { + "id": i, + "title": f"Post {i}", + "text": f"Content {i}", + "user": {"screen_name": f"User {i}"}, + "like_count": i, + "target": f"/user/{i}", + } + } + for i in range(10) + ] + } + } + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def read(self): + return json.dumps(fake_data).encode() + + monkeypatch.setattr(xueqiu_mod._opener, "open", lambda req, timeout=None: FakeResponse()) + posts = XueqiuChannel().get_hot_posts(limit=3) + assert len(posts) == 3 + + # ------------------------------------------------------------------ # + # get_hot_stocks + # ------------------------------------------------------------------ # + + def test_get_hot_stocks(self, monkeypatch): + import agent_reach.channels.xueqiu as xueqiu_mod + + monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True) + + fake_data = { + "data": { + "items": [ + {"code": "SH600519", "name": "贵州茅台", "current": 1800.0, "percent": 1.5}, + {"code": "SZ000858", "name": "五粮液", "current": 160.0, "percent": -0.8}, + {"code": "SH601318", "name": "中国平安", "current": 45.0, "percent": 0.3}, + ] + } + } + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def read(self): + return json.dumps(fake_data).encode() + + monkeypatch.setattr(xueqiu_mod._opener, "open", lambda req, timeout=None: FakeResponse()) + stocks = XueqiuChannel().get_hot_stocks(limit=10, stock_type=10) + assert len(stocks) == 3 + assert stocks[0]["symbol"] == "SH600519" + assert stocks[0]["rank"] == 1 + assert stocks[1]["percent"] == -0.8 + assert stocks[2]["rank"] == 3 + + class TestXiaoHongShuChannel: def test_reports_ok_when_server_health_is_ok(self, monkeypatch): monkeypatch.setattr(shutil, "which", lambda _: "/opt/homebrew/bin/mcporter")