fix(xueqiu): fix 400 errors by using browser cookies and correct headers

The Xueqiu stock API requires a login session token (xq_a_token) that
is generated by Xueqiu's frontend JavaScript and cannot be obtained by
simply visiting the homepage. This caused persistent HTTP 400 (error
code 400016) for all users.

Changes:
- _ensure_cookies(): add three-level priority — config file (saved by
  --from-browser) → live Chrome cookies via browser_cookie3 → homepage
  fallback. The homepage-only approach only ever got acw_tc (anti-DDoS
  token), never xq_a_token.
- _get_json(): switch User-Agent from "agent-reach/1.0" to a real Chrome
  UA, and add Referer: https://xueqiu.com/ to all API requests.
- get_hot_posts(): replace the defunct /statuses/hot/listV3.json endpoint
  (returns empty body) with the v4 public timeline endpoint; correctly
  parse item.data as a JSON string to extract author, text, and likes.
- cookie_extract.py: add Xueqiu to PLATFORM_SPECS and configure_from_browser
  so that `agent-reach configure --from-browser chrome` now also saves
  Xueqiu cookies (only when xq_a_token is present).
- check(): improve error message to direct users to --from-browser instead
  of suggesting a proxy.
- Fix urllib.parse.quote usage (was using urllib.request.quote).
- Update tier and backends description to reflect cookie requirement.
- Add 2 new tests: cookie loading from config, Referer/UA header verification.
- Update docs: README, install guide, troubleshooting, SKILL.md, CHANGELOG.
This commit is contained in:
fernando_jacob
2026-03-27 11:55:14 +08:00
parent ca2e85520b
commit d793afaba6
9 changed files with 293 additions and 70 deletions
+92 -39
View File
@@ -473,31 +473,23 @@ class TestXueqiuChannel:
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",
}
},
]
# v4 timeline: each item has a JSON-encoded `data` field
def make_item(id_, title, text, author, likes, target):
post = {
"id": id_,
"title": title,
"text": text,
"user": {"screen_name": author},
"like_count": likes,
"target": target,
}
return {"data": json.dumps(post), "original_status": None}
fake_data = {
"list": [
make_item(111, "市场分析", "<p>今天大盘走势&amp;分析</p>", "投资者A", 42, "/1234567890/111"),
make_item(222, "", "短评", "投资者B", 10, "/9876543210/222"),
]
}
class FakeResponse:
@@ -526,21 +518,20 @@ class TestXueqiuChannel:
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)
]
}
"list": [
{
"data": json.dumps({
"id": i,
"title": f"Post {i}",
"text": f"Content {i}",
"user": {"screen_name": f"User {i}"},
"like_count": i,
"target": f"/user/{i}",
}),
"original_status": None,
}
for i in range(10)
]
}
class FakeResponse:
@@ -594,6 +585,68 @@ class TestXueqiuChannel:
assert stocks[1]["percent"] == -0.8
assert stocks[2]["rank"] == 3
# ------------------------------------------------------------------ #
# Cookie loading
# ------------------------------------------------------------------ #
def test_ensure_cookies_loads_from_config(self, monkeypatch, tmp_path):
"""_ensure_cookies() should inject cookies from the config file."""
import agent_reach.channels.xueqiu as xueqiu_mod
monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", False)
# Provide a fake Config that returns a cookie string with xq_a_token
class FakeConfig:
def get(self, key, default=None):
if key == "xueqiu_cookie":
return "xq_a_token=TESTTOKEN; xq_is_login=1"
return default
import agent_reach.channels.xueqiu as xq_mod
monkeypatch.setattr(
xq_mod,
"_load_cookies_from_config",
lambda: (xq_mod._inject_cookie_string("xq_a_token=TESTTOKEN; xq_is_login=1") or True),
)
monkeypatch.setattr(xq_mod, "_load_cookies_from_browser", lambda: False)
# Patch opener so no real HTTP call is made
class FakeResp:
def __enter__(self): return self
def __exit__(self, *_): pass
def read(self): return b'{"data":{"items":[]}}'
monkeypatch.setattr(xq_mod._opener, "open", lambda req, timeout=None: FakeResp())
xq_mod._ensure_cookies()
assert xq_mod._cookies_initialized is True
cookie_names = {c.name for c in xq_mod._cookie_jar}
assert "xq_a_token" in cookie_names
def test_get_json_sends_referer_and_browser_ua(self, monkeypatch):
"""_get_json() must send Referer and a browser-like User-Agent."""
import agent_reach.channels.xueqiu as xueqiu_mod
monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True)
captured = {}
class FakeResp:
def __enter__(self): return self
def __exit__(self, *_): pass
def read(self): return b'{"data":{"items":[]}}'
def fake_open(req, timeout=None):
captured["ua"] = req.get_header("User-agent")
captured["referer"] = req.get_header("Referer")
return FakeResp()
monkeypatch.setattr(xueqiu_mod._opener, "open", fake_open)
xueqiu_mod._get_json("https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH000001")
assert captured["referer"] == "https://xueqiu.com/"
assert "Mozilla" in captured["ua"]
assert "agent-reach" not in captured["ua"]
class TestXiaoHongShuChannel:
def test_reports_ok_when_server_health_is_ok(self, monkeypatch):