Files
Agent-Reach/agent_reach/channels/xueqiu.py
T
Pnant 470c1288d0 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>
2026-03-23 18:44:13 +08:00

197 lines
6.9 KiB
Python

# -*- 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: 股票代码,如 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