Track bundled vendor runtime sources
This commit is contained in:
316
vendor/Agent-Reach/agent_reach/channels/xueqiu.py
vendored
Normal file
316
vendor/Agent-Reach/agent_reach/channels/xueqiu.py
vendored
Normal file
@@ -0,0 +1,316 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Xueqiu (雪球) — stock quotes, search, trending posts & hot stocks."""
|
||||
|
||||
import http.cookiejar
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from .base import Channel
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
_REFERER = "https://xueqiu.com/"
|
||||
_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 _inject_cookie_string(cookie_str: str) -> None:
|
||||
"""Parse a 'name=value; name2=value2' string and inject into the cookie jar."""
|
||||
for pair in cookie_str.split(";"):
|
||||
pair = pair.strip()
|
||||
if "=" not in pair:
|
||||
continue
|
||||
name, _, value = pair.partition("=")
|
||||
cookie = http.cookiejar.Cookie(
|
||||
version=0,
|
||||
name=name.strip(),
|
||||
value=value.strip(),
|
||||
port=None,
|
||||
port_specified=False,
|
||||
domain=".xueqiu.com",
|
||||
domain_specified=True,
|
||||
domain_initial_dot=True,
|
||||
path="/",
|
||||
path_specified=True,
|
||||
secure=True,
|
||||
expires=None,
|
||||
discard=True,
|
||||
comment=None,
|
||||
comment_url=None,
|
||||
rest={},
|
||||
)
|
||||
_cookie_jar.set_cookie(cookie)
|
||||
|
||||
|
||||
def _load_cookies_from_config() -> bool:
|
||||
"""Try to load Xueqiu cookies from agent-reach config file (xueqiu_cookie key)."""
|
||||
try:
|
||||
from ..config import Config
|
||||
|
||||
cfg = Config()
|
||||
cookie_str = cfg.get("xueqiu_cookie")
|
||||
if not cookie_str:
|
||||
return False
|
||||
_inject_cookie_string(cookie_str)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _load_cookies_from_browser() -> bool:
|
||||
"""Try to silently load Xueqiu cookies from the local Chrome browser.
|
||||
|
||||
Only succeeds when browser_cookie3 is installed AND the user is logged in
|
||||
(xq_a_token present). Failures are silently ignored so that agents without
|
||||
a local browser keep working.
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
import rookiepy
|
||||
cookies = rookiepy.chrome([".xueqiu.com"])
|
||||
if not any(c.get("name") == "xq_a_token" for c in cookies):
|
||||
return False
|
||||
for c in cookies:
|
||||
_cookie_jar.set(c["name"], c["value"], domain=c.get("domain", ".xueqiu.com"))
|
||||
return True
|
||||
except ImportError:
|
||||
import browser_cookie3
|
||||
cookies = list(browser_cookie3.chrome(domain_name=".xueqiu.com"))
|
||||
if not any(c.name == "xq_a_token" for c in cookies):
|
||||
return False
|
||||
for c in cookies:
|
||||
_cookie_jar.set_cookie(c)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_cookies() -> None:
|
||||
"""Populate session cookies using the best available source.
|
||||
|
||||
Priority order:
|
||||
1. Saved cookie string in ~/.agent-reach/config.yaml (set by configure --from-browser)
|
||||
2. Live Chrome browser cookies via rookiepy/browser_cookie3 (if installed + logged in)
|
||||
3. Homepage visit fallback (only yields anti-DDoS acw_tc,
|
||||
not enough for stock APIs)
|
||||
"""
|
||||
global _cookies_initialized
|
||||
if _cookies_initialized:
|
||||
return
|
||||
if _load_cookies_from_config():
|
||||
_cookies_initialized = True
|
||||
return
|
||||
if _load_cookies_from_browser():
|
||||
_cookies_initialized = True
|
||||
return
|
||||
# Fallback: visit homepage to pick up acw_tc anti-DDoS cookie.
|
||||
# This is not sufficient for authenticated APIs but avoids hard failures
|
||||
# on public endpoints that only need the session cookie.
|
||||
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, "Referer": _REFERER}
|
||||
)
|
||||
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 (需要登录 Cookie)"]
|
||||
tier = 1
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# URL routing
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
d = urllib.parse.urlparse(url).netloc.lower()
|
||||
return "xueqiu.com" in d
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Health check
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def check(self, config=None):
|
||||
self.active_backend = 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:
|
||||
self.active_backend = self.backends[0]
|
||||
return "ok", "公开 API 可用(行情、搜索、热帖、热股)"
|
||||
return "warn", "API 响应异常(返回数据为空)"
|
||||
except Exception as e:
|
||||
return "warn", (
|
||||
f"Xueqiu API 连接失败:{e}。"
|
||||
"请先登录雪球后运行:agent-reach configure --from-browser chrome"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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"
|
||||
f"?code={urllib.parse.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:
|
||||
"""获取雪球热门帖子。
|
||||
|
||||
Uses the v4 public timeline endpoint which returns posts in a `list`
|
||||
array. Each item carries a JSON-encoded `data` field containing the
|
||||
actual post payload (title, description, user, like_count, target).
|
||||
|
||||
Args:
|
||||
limit: 最多返回条数(上限 50)
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
id, title, text, author, likes, url
|
||||
"""
|
||||
data = _get_json(
|
||||
"https://xueqiu.com/v4/statuses/public_timeline_by_category.json"
|
||||
"?since_id=-1&max_id=-1&count=20&category=-1"
|
||||
)
|
||||
items = data.get("list") or []
|
||||
results = []
|
||||
for item in items[:limit]:
|
||||
# Each item.data is a JSON string containing the real post payload
|
||||
try:
|
||||
post = (
|
||||
json.loads(item["data"])
|
||||
if isinstance(item.get("data"), str)
|
||||
else {}
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
post = {}
|
||||
user = post.get("user") or {}
|
||||
text = _strip_html(
|
||||
post.get("text") or post.get("description") or ""
|
||||
)
|
||||
target = post.get("target", "")
|
||||
results.append(
|
||||
{
|
||||
"id": post.get("id", 0),
|
||||
"title": post.get("title") or "",
|
||||
"text": text[:200],
|
||||
"author": user.get("screen_name", ""),
|
||||
"likes": post.get("like_count", 0),
|
||||
"url": f"https://xueqiu.com{target}" if 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"
|
||||
f"?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
|
||||
Reference in New Issue
Block a user