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
7 changed files with 507 additions and 3 deletions
+268
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")