feat: add Xueqiu (雪球) channel (#198)

* feat: add Xueqiu (雪球) channel for stock quotes and community posts

Add a Tier 0 (zero-config) channel for Xueqiu, China's popular stock
market and investment community platform. Uses auto-generated session
cookies via http.cookiejar — no login required.

Supported methods:
- get_stock_quote(symbol) — real-time quotes (A/HK/US markets)
- search_stock(query) — search by name or code
- get_hot_posts(limit) — trending community posts
- get_hot_stocks(limit, stock_type) — popular stocks leaderboard

Inspired by https://github.com/jackwener/opencli xueqiu implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add Xueqiu to README platform tables, remove stale Instagram ref

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: fernando_jacob <f.jacob1996@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Pnant 2026-03-23 18:44:13 +08:00 committed by GitHub
parent 881d9b96e2
commit 470c1288d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 507 additions and 3 deletions

View file

@ -80,6 +80,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
| 💬 **微信公众号** | 搜索 + 阅读公众号文章(全文 Markdown | — | 无需配置 |
| 📰 **微博** | 热搜、搜索内容/用户/话题、用户动态、评论 | — | 无需配置 |
| 💻 **V2EX** | 热门帖子、节点帖子、帖子详情+回复、用户信息 | — | 无需配置 |
| 📈 **雪球** | 股票行情、搜索股票、热门帖子、热门股票排行 | — | 无需配置 |
| 🎙️ **小宇宙播客** | — | 播客音频转文字Whisper 转录,免费 Key | 告诉 Agent「帮我配小宇宙播客」 |
> **不知道怎么配?不用查文档。** 直接告诉 Agent「帮我配 XXX」它知道需要什么、会一步一步引导你。

View file

@ -22,6 +22,7 @@ from .wechat import WeChatChannel
from .weibo import WeiboChannel
from .xiaoyuzhou import XiaoyuzhouChannel
from .v2ex import V2EXChannel
from .xueqiu import XueqiuChannel
@ -38,6 +39,7 @@ ALL_CHANNELS: List[Channel] = [
WeiboChannel(),
XiaoyuzhouChannel(),
V2EXChannel(),
XueqiuChannel(),
RSSChannel(),
ExaSearchChannel(),
WebChannel(),

View file

@ -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 (("&nbsp;", " "), ("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">")):
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: 股票代码 SH600519SZ000858AAPL00700
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

View file

@ -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

View file

@ -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 |

View file

@ -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",
}

View file

@ -8,6 +8,7 @@ from urllib.error import URLError
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 @@ class TestV2EXChannel:
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": "<p>今天大盘走势&amp;分析</p>",
"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 "<p>" 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")