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:
parent
881d9b96e2
commit
470c1288d0
7 changed files with 507 additions and 3 deletions
|
|
@ -80,6 +80,7 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去
|
|||
| 💬 **微信公众号** | 搜索 + 阅读公众号文章(全文 Markdown) | — | 无需配置 |
|
||||
| 📰 **微博** | 热搜、搜索内容/用户/话题、用户动态、评论 | — | 无需配置 |
|
||||
| 💻 **V2EX** | 热门帖子、节点帖子、帖子详情+回复、用户信息 | — | 无需配置 |
|
||||
| 📈 **雪球** | 股票行情、搜索股票、热门帖子、热门股票排行 | — | 无需配置 |
|
||||
| 🎙️ **小宇宙播客** | — | 播客音频转文字(Whisper 转录,免费 Key) | 告诉 Agent「帮我配小宇宙播客」 |
|
||||
|
||||
> **不知道怎么配?不用查文档。** 直接告诉 Agent「帮我配 XXX」,它知道需要什么、会一步一步引导你。
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
196
agent_reach/channels/xueqiu.py
Normal file
196
agent_reach/channels/xueqiu.py
Normal 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 ((" ", " "), ("&", "&"), ("<", "<"), (">", ">")):
|
||||
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>今天大盘走势&分析</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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue