tytcli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tytcli/__init__.py +7 -0
- tytcli/cli.py +174 -0
- tytcli/client.py +832 -0
- tytcli/commands/__init__.py +52 -0
- tytcli/commands/auth.py +147 -0
- tytcli/commands/config.py +61 -0
- tytcli/commands/env.py +103 -0
- tytcli/commands/help.py +105 -0
- tytcli/commands/roadshow.py +898 -0
- tytcli/commands/sentiment.py +200 -0
- tytcli/commands/shared.py +210 -0
- tytcli/commands/upgrade.py +95 -0
- tytcli/commands/wechat.py +253 -0
- tytcli/commands/whoami.py +76 -0
- tytcli/config.py +578 -0
- tytcli/errors.py +71 -0
- tytcli/output.py +82 -0
- tytcli/selfupdate.py +639 -0
- tytcli/skill/SKILL.md +122 -0
- tytcli/skill/references/output-contract.md +93 -0
- tytcli/skill/references/roadshow.md +176 -0
- tytcli/skill/references/selfupdate.md +62 -0
- tytcli/skill/references/sentiment.md +68 -0
- tytcli/skill/references/setup.md +105 -0
- tytcli/skill/references/wechat.md +87 -0
- tytcli/skill/references/whoami.md +42 -0
- tytcli/skill.py +118 -0
- tytcli-0.2.0.dist-info/METADATA +437 -0
- tytcli-0.2.0.dist-info/RECORD +32 -0
- tytcli-0.2.0.dist-info/WHEEL +4 -0
- tytcli-0.2.0.dist-info/entry_points.txt +2 -0
- tytcli-0.2.0.dist-info/licenses/LICENSE.txt +16 -0
tytcli/client.py
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
"""Minimal HTTP client for the 投研通 (TYT) API.
|
|
2
|
+
|
|
3
|
+
Contract facts below were verified against the live service on 2026-11-06 by
|
|
4
|
+
probing both environments. They are load-bearing — do not "simplify" them
|
|
5
|
+
without re-verifying:
|
|
6
|
+
|
|
7
|
+
1. ``POST`` only. A ``GET`` to the same URL returns ``ErrorCode -1``.
|
|
8
|
+
2. A JSON request body is **mandatory**, but ``{}`` is sufficient. Sending no
|
|
9
|
+
body at all returns ``ErrorCode -1`` ("您访问了错误的地址或没有按规定访问").
|
|
10
|
+
No ``userId`` / ``passportId`` / ``deviceid`` / ``version`` / ``product`` /
|
|
11
|
+
``plat`` fields are required.
|
|
12
|
+
3. The ``TYT_Token`` cookie is the only credential needed. ``Origin``,
|
|
13
|
+
``Referer``, ``User-Agent`` and all ``sec-ch-ua*`` / ``Sec-Fetch-*`` headers
|
|
14
|
+
are unnecessary and are deliberately not sent.
|
|
15
|
+
4. HTTP status is **200 even for failures**. Success must be read from the
|
|
16
|
+
JSON body's ``Success`` field.
|
|
17
|
+
5. The response envelope uses PascalCase and prod may add extra fields such as
|
|
18
|
+
``TotalCount``. Payloads are passed through untouched.
|
|
19
|
+
|
|
20
|
+
Known ``ErrorCode`` values:
|
|
21
|
+
|
|
22
|
+
-100 登录超时,请重新登录 -> AUTH_EXPIRED (exit 3)
|
|
23
|
+
-1 错误的地址/访问方式 -> BAD_REQUEST (route, method or missing body)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
公众号 (WeChat Official Account) module — ``WechatPublic/*``
|
|
27
|
+
-----------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
Probed live on ``test`` 2026-11-06. Several findings contradict the internal
|
|
30
|
+
frontend hand-off document (xuewei, 2026-11-04; deliberately not committed to
|
|
31
|
+
this repo), so trust this section over that file:
|
|
32
|
+
|
|
33
|
+
1. **Different root.** Routes live under ``/middleApi/WechatPublic/``, not under
|
|
34
|
+
``BASE_URLS``. ``{test_base}/WechatPublic/getPostPage`` answers ``-1``; the
|
|
35
|
+
``/middleApi`` form works. Hence ``MIDDLE_BASE_URLS`` in ``config.py``.
|
|
36
|
+
Path case matters: lowercase ``wechatPublic`` gives HTTP **404**.
|
|
37
|
+
2. **The cookie alone is the credential — and ``authkey`` breaks it.** The doc
|
|
38
|
+
recommends sending ``authkey`` *and* ``Cookie: TYT_Token``. Measured: cookie
|
|
39
|
+
alone succeeds; ``authkey`` alone gives ``-100 未登录或鉴权失败``; and
|
|
40
|
+
**sending both also gives ``-100``**. So this client must NOT add an
|
|
41
|
+
``authkey`` header. Cookie-only matches the core API, so no new auth path.
|
|
42
|
+
3. **``getPostDetail`` accepts ``articleId``**, which the doc does not mention.
|
|
43
|
+
``articleId`` and ``articleKey`` hold the *same* 64-hex value, yet only the
|
|
44
|
+
name ``articleId`` works for detail; ``{"articleKey": …}`` returns
|
|
45
|
+
``-1 文章 identity 参数不完整或超长``. Bad value → ``-1 文章不存在或已失效``;
|
|
46
|
+
empty/over-64 → ``-1 articleId 不能为空或超过64字符``. The documented
|
|
47
|
+
``identity`` triple still works and is kept as a fallback.
|
|
48
|
+
4. ``identity`` must be byte-exact: appending a single space to ``title`` yields
|
|
49
|
+
``-1 文章不存在或已失效``.
|
|
50
|
+
5. **``keyword`` search works again.** The doc's §10 blocker (non-empty keyword
|
|
51
|
+
→ ``-1 网络通信异常``) no longer reproduces: ``央行``→4 hits, ``Choice``→1,
|
|
52
|
+
whitespace-only→same as no keyword. ``--keyword`` is deliberately *not*
|
|
53
|
+
exposed yet, per product decision; adding it later is a new flag, not a break.
|
|
54
|
+
6. Unlike the core API, **no body at all succeeds** here (defaults applied).
|
|
55
|
+
We still always send one, for consistency.
|
|
56
|
+
7. Validation messages, verbatim: ``pageNo 必须大于0,pageSize 必须在1到100之间``
|
|
57
|
+
and ``view 只能为 ALL、SUBSCRIBED 或 FAVORITE``. ``pageNo``/``pageSize`` may
|
|
58
|
+
be explicit ``null`` (defaults applied), but ``pageNo=0`` is rejected.
|
|
59
|
+
8. Responses carry fields the doc does not list — ``articleId``, ``cleanBody``,
|
|
60
|
+
``summary``, ``viewpoints``, and in detail ``paragraphsJson``, ``topicsJson``,
|
|
61
|
+
``keywordsJson``, ``parseRunId``. The doc's "不做 AI 总结" is stale. Payloads
|
|
62
|
+
are passed through untouched, so these simply appear.
|
|
63
|
+
9. **``prod`` has no such module**: every ``/middleApi/`` path 404s there while
|
|
64
|
+
``Login/GetLoginInfoV2`` returns ``-100``, proving prod is reachable. The 404
|
|
65
|
+
is reported as-is with a hint rather than special-cased, so a future prod
|
|
66
|
+
deploy needs no code change.
|
|
67
|
+
9. **``prod`` has no such module**: every ``/middleApi/`` path 404s there while
|
|
68
|
+
``Login/GetLoginInfoV2`` returns ``-100``, proving prod is reachable. The 404
|
|
69
|
+
is reported as-is with a hint rather than special-cased, so a future prod
|
|
70
|
+
deploy needs no code change.
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
``sentimentIndex`` module — probed live on ``test`` 2026-11-06
|
|
74
|
+
--------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
The Swagger document at ``{middle_base}/v2/api-docs`` (basePath ``/middleApi``,
|
|
77
|
+
413 paths) is the spec of record for this module, and it is wrong in two places:
|
|
78
|
+
|
|
79
|
+
1. **``getDailyReportDates`` takes NO body.** Swagger says ``consumes:
|
|
80
|
+
application/json``, and the core ``Login`` endpoint *requires* a body, but
|
|
81
|
+
here it is the opposite: sending ``{}`` yields ``-101 服务器繁忙,请稍后再试!``
|
|
82
|
+
while an absent body succeeds. Three endpoints, three body conventions --
|
|
83
|
+
do not assume one from another.
|
|
84
|
+
2. **``getDailyReportImage`` returns an image, not the JSON envelope.** Swagger
|
|
85
|
+
declares ``ResponseBodyDecorator«boolean»``; the real response is
|
|
86
|
+
``image/png`` at ~4.3 MiB. So this client has a binary transport path
|
|
87
|
+
(:class:`BinaryResponse`) alongside the JSON one.
|
|
88
|
+
|
|
89
|
+
Also verified:
|
|
90
|
+
|
|
91
|
+
* ``reportDate`` validation is a trap. Empty, wrong-format and past-without-a-
|
|
92
|
+
report dates ALL return ``-101 服务器繁忙`` (a useless message); only a
|
|
93
|
+
*future* date returns the honest ``-1 指定日期未找到日报``. The CLI therefore
|
|
94
|
+
validates the format client-side and reports a usage error instead of
|
|
95
|
+
forwarding a date the server would answer with "server busy".
|
|
96
|
+
* Auth is the same cookie-only scheme; ``authkey`` fails as elsewhere.
|
|
97
|
+
* ``GET`` gives HTTP 405, an unknown method name gives HTTP 404.
|
|
98
|
+
|
|
99
|
+
RoadShowCenter (路演中心) -- verified live on ``test`` 2026-11-07
|
|
100
|
+
---------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
Same root as the two modules above (``MIDDLE_BASE_URLS`` + ``/RoadShowCenter/``).
|
|
103
|
+
Swagger declares 18 endpoints under the 路演中心 tag; the CLI exposes six.
|
|
104
|
+
The handoff document (路演预约与提醒_前端接口对接文档_20261107) was cross-checked
|
|
105
|
+
against the live service and, unlike the 公众号 one, its parameter names and
|
|
106
|
+
Chinese error strings were all accurate. What probing still changed:
|
|
107
|
+
|
|
108
|
+
1. **A JSON body is required, and omitting it fails differently here.**
|
|
109
|
+
``{}`` succeeds; sending *no* body returns **HTTP 400** -- not the core API's
|
|
110
|
+
``ErrorCode -1``, and not sentimentIndex's ``-101``. That is a fourth failure
|
|
111
|
+
signal in this codebase, so ``post_middle_json(send_body=True)`` (the default)
|
|
112
|
+
is what every roadshow call uses. Do not infer one module's body convention
|
|
113
|
+
from another's.
|
|
114
|
+
2. **``companyId`` / ``userNo`` are not needed.** Swagger lists both on every
|
|
115
|
+
request DTO and the document says the server derives the user from the cookie.
|
|
116
|
+
Verified: ``{"appId": ...}`` alone succeeds. Do not add them "for safety" --
|
|
117
|
+
the same trap existed for the core API and for 公众号.
|
|
118
|
+
3. **``prod`` HAS this module.** A prod call answers ``-100 登录超时`` rather than
|
|
119
|
+
404, and per §1 of this docstring that is proof the route exists. This is the
|
|
120
|
+
opposite of 公众号 and sentimentIndex (both 404 on prod), which matters because
|
|
121
|
+
``bookRoadShow`` / ``cancelRoadShowBooking`` are **writes**: on prod they would
|
|
122
|
+
really create and cancel bookings. That is why the CLI defaults to a read-only
|
|
123
|
+
pre-flight and requires an explicit ``--yes`` to write.
|
|
124
|
+
4. **Writes return a bare boolean.** ``Data: true`` on success, verified for both
|
|
125
|
+
book and cancel. There is no object to unwrap, so the commands report the
|
|
126
|
+
boolean plus a read-back of ``getRoadShowBookingStatus``.
|
|
127
|
+
5. **Both writes are idempotent, verified end to end.** Booking twice succeeds
|
|
128
|
+
both times and ``bookTime`` does not change (``16:45:06`` on both reads);
|
|
129
|
+
cancelling twice succeeds both times. The document claimed this; the probe
|
|
130
|
+
confirmed it, including that a repeat cancel of an already-cancelled booking
|
|
131
|
+
is a success rather than an error.
|
|
132
|
+
6. **Field presence varies between rows of one response.** Rows filtered by
|
|
133
|
+
``theme="1"`` came back without ``duration`` / ``meetingDuration`` /
|
|
134
|
+
``meetingPwd`` / ``meetingUrl`` / ``summary``, while unfiltered rows had them.
|
|
135
|
+
So the trimmed-field set must be derived from the response (see
|
|
136
|
+
``commands/shared.observed_fields``), never hardcoded.
|
|
137
|
+
7. **``getPublicRoadShowSummary`` returns 17 fields, Swagger declares 22.**
|
|
138
|
+
Absent means omitted, not null -- confirmed again by ``bookTime`` disappearing
|
|
139
|
+
from the status payload after a cancel. Both ``summary`` (brief) and
|
|
140
|
+
``detail`` (full text) plus ten topic fields arrive in ONE response, so
|
|
141
|
+
"brief vs detailed" is field selection, not two endpoints.
|
|
142
|
+
``getRoadShowSummaryDetailById`` takes ``{id, publishPath, type}`` (a summary
|
|
143
|
+
id, not an appId) and is deliberately not exposed.
|
|
144
|
+
8. **Empty page returns ``pageNum: 0``.** ``infos=[] totalData=0 totalPage=0
|
|
145
|
+
pageNum=0 pageSize=20``. Feeding that 0 back as the next page number would
|
|
146
|
+
then fail with ``页码和每页条数必须为正整数``, so paging must keep the caller's
|
|
147
|
+
own page number.
|
|
148
|
+
9. **``endDate`` is exclusive of that day's meetings.** ``startDate=endDate=
|
|
149
|
+
2026-11-13`` returned 0 rows while ``2026-11-01..2026-11-13`` returned 2, i.e.
|
|
150
|
+
the cut is 2026-11-13 00:00:00. ``getMyRoadShowBookings`` uses the *opposite*
|
|
151
|
+
convention (``meetingEndDate`` includes the whole day) and a different format
|
|
152
|
+
(``yyyy-MM-dd`` vs the listing's ``yyyy/M/d``). The two must not be conflated;
|
|
153
|
+
the CLI accepts ISO dates for both and translates per endpoint.
|
|
154
|
+
10. **``theme`` accepts ``"1"``..``"6"``; ``"0"``, ``""`` and absent all mean
|
|
155
|
+
"everything".** Verified by row counts: theme=1 -> 102 of 467.
|
|
156
|
+
11. **Validation errors are HTTP 200 + ``ErrorCode -1`` with these verbatim
|
|
157
|
+
strings**: ``预约状态只能为BOOKED已预约或ENDED已结束``,
|
|
158
|
+
``开始时间不能晚于结束时间``, ``页码和每页条数必须为正整数``,
|
|
159
|
+
``请选择路演后重试`` (missing appId), ``路演ID格式不正确`` (bad appId).
|
|
160
|
+
12. **Do not branch on the message text of a write rejection.** book says
|
|
161
|
+
``您预约的[标题]路演已经结束`` with half-width brackets while cancel says
|
|
162
|
+
``您预约的「标题」路演已经结束,无法取消预约`` with corner brackets, and the
|
|
163
|
+
document warns the punctuation differs by code path. Branch on ``code``.
|
|
164
|
+
13. Wrong path casing -> HTTP 404; ``GET`` -> HTTP 405. Same as the other two
|
|
165
|
+
middle modules.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
from __future__ import annotations
|
|
169
|
+
|
|
170
|
+
import time
|
|
171
|
+
from dataclasses import dataclass
|
|
172
|
+
from typing import Any, cast
|
|
173
|
+
|
|
174
|
+
import httpx
|
|
175
|
+
|
|
176
|
+
from . import __version__
|
|
177
|
+
from .errors import ApiError, AuthError, NetworkError, TytCliError
|
|
178
|
+
|
|
179
|
+
ERROR_CODE_BAD_REQUEST = -1
|
|
180
|
+
ERROR_CODE_AUTH_TIMEOUT = -100
|
|
181
|
+
#: ``-101 服务器繁忙,请稍后再试!`` -- on some middleApi endpoints this is a
|
|
182
|
+
#: catch-all for bad input, NOT a real server fault (see sentimentIndex §2).
|
|
183
|
+
ERROR_CODE_SERVER_BUSY = -101
|
|
184
|
+
|
|
185
|
+
#: Floor for binary downloads. ``sentimentIndex/getDailyReportImage`` makes the
|
|
186
|
+
#: server render a ~4.3 MiB PNG on demand; a cold render exceeded the default
|
|
187
|
+
#: 15 s timeout on 2026-11-06 while warm calls take ~9 s. The configured
|
|
188
|
+
#: timeout is tuned for small JSON queries, so the binary path raises the
|
|
189
|
+
#: floor (a user who configured MORE keeps theirs via max()).
|
|
190
|
+
BINARY_DOWNLOAD_TIMEOUT_FLOOR = 60.0
|
|
191
|
+
|
|
192
|
+
#: Extra attempts for getDailyReportImage when the server answers -101
|
|
193
|
+
#: because the PNG is not rendered yet (see the method's docstring).
|
|
194
|
+
SENTIMENT_IMAGE_RETRIES = 2
|
|
195
|
+
SENTIMENT_IMAGE_RETRY_DELAY = 2.0
|
|
196
|
+
|
|
197
|
+
# Derived, never hardcoded. A literal `"tytcli/0.1.0"` lived here and silently
|
|
198
|
+
# went stale on the 0.1.1 bump -- the client kept advertising the old version.
|
|
199
|
+
# test_client.py now pins it to __version__ so it cannot drift again.
|
|
200
|
+
DEFAULT_USER_AGENT = f"tytcli/{__version__}"
|
|
201
|
+
COOKIE_NAME = "TYT_Token"
|
|
202
|
+
|
|
203
|
+
#: Path prefix of the 公众号 module, relative to ``MIDDLE_BASE_URLS``.
|
|
204
|
+
WECHAT_PREFIX = "WechatPublic"
|
|
205
|
+
|
|
206
|
+
#: Valid ``view`` values for ``getPostPage`` (server-validated; the server's own
|
|
207
|
+
#: message is ``view 只能为 ALL、SUBSCRIBED 或 FAVORITE``).
|
|
208
|
+
WECHAT_POST_VIEWS: tuple[str, ...] = ("ALL", "SUBSCRIBED", "FAVORITE")
|
|
209
|
+
|
|
210
|
+
#: Server-enforced paging bounds for ``getPostPage``.
|
|
211
|
+
WECHAT_PAGE_SIZE_MIN = 1
|
|
212
|
+
WECHAT_PAGE_SIZE_MAX = 100
|
|
213
|
+
|
|
214
|
+
#: ``articleId`` is a 64-hex digest; the server rejects longer values with
|
|
215
|
+
#: ``articleId 不能为空或超过64字符``.
|
|
216
|
+
WECHAT_ARTICLE_ID_MAX = 64
|
|
217
|
+
|
|
218
|
+
#: Path prefix of the 情绪指数 module, relative to ``MIDDLE_BASE_URLS``.
|
|
219
|
+
SENTIMENT_PREFIX = "sentimentIndex"
|
|
220
|
+
|
|
221
|
+
#: Path prefix of the 路演中心 module, relative to ``MIDDLE_BASE_URLS``.
|
|
222
|
+
ROADSHOW_PREFIX = "RoadShowCenter"
|
|
223
|
+
|
|
224
|
+
#: Valid ``bookingViewState`` values for ``getMyRoadShowBookings``. The server's
|
|
225
|
+
#: own rejection message is ``预约状态只能为BOOKED已预约或ENDED已结束``; it does
|
|
226
|
+
#: not accept CANCELLED, Chinese text, or numeric states.
|
|
227
|
+
ROADSHOW_BOOKING_STATES: tuple[str, ...] = ("BOOKED", "ENDED")
|
|
228
|
+
|
|
229
|
+
#: Industry codes for the listing's ``theme`` filter. ``"0"``, ``""`` and an
|
|
230
|
+
#: absent value all mean "no filter" (verified by row count: 102 of 467 for
|
|
231
|
+
#: theme=1). Accepted as either the code or the Chinese label on the CLI.
|
|
232
|
+
ROADSHOW_THEMES: dict[str, str] = {
|
|
233
|
+
"1": "权益",
|
|
234
|
+
"2": "固收+",
|
|
235
|
+
"3": "纯债",
|
|
236
|
+
"4": "互认",
|
|
237
|
+
"5": "策略",
|
|
238
|
+
"6": "热点",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#: ``reportDate`` format, per the Swagger example and the live service.
|
|
242
|
+
SENTIMENT_DATE_FORMAT = "%Y-%m-%d"
|
|
243
|
+
|
|
244
|
+
#: Magic bytes accepted for a downloaded report image. Anything else is
|
|
245
|
+
#: refused rather than saved under a misleading extension.
|
|
246
|
+
IMAGE_MAGICS: tuple[tuple[bytes, str, str], ...] = (
|
|
247
|
+
(b"\x89PNG\r\n\x1a\n", "png", ".png"),
|
|
248
|
+
(b"\xff\xd8\xff", "jpeg", ".jpg"),
|
|
249
|
+
(b"GIF8", "gif", ".gif"),
|
|
250
|
+
(b"RIFF", "webp", ".webp"),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
BODY_PREVIEW_LIMIT = 400
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True)
|
|
257
|
+
class ApiEnvelope:
|
|
258
|
+
"""A parsed TYT response envelope."""
|
|
259
|
+
|
|
260
|
+
http_status: int
|
|
261
|
+
success: bool
|
|
262
|
+
error_code: int | None
|
|
263
|
+
error_message: str | None
|
|
264
|
+
payload: dict[str, Any]
|
|
265
|
+
url: str
|
|
266
|
+
|
|
267
|
+
def to_dict(self) -> dict[str, Any]:
|
|
268
|
+
return {
|
|
269
|
+
"http_status": self.http_status,
|
|
270
|
+
"success": self.success,
|
|
271
|
+
"error_code": self.error_code,
|
|
272
|
+
"error_message": self.error_message,
|
|
273
|
+
"payload": self.payload,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class BinaryResponse:
|
|
279
|
+
"""A non-envelope response: raw bytes plus enough metadata to trust them.
|
|
280
|
+
|
|
281
|
+
``sentimentIndex/getDailyReportImage`` is the first (and so far only)
|
|
282
|
+
endpoint that does not speak the JSON envelope, so it needs its own type
|
|
283
|
+
rather than being shoehorned into :class:`ApiEnvelope`.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
http_status: int
|
|
287
|
+
content_type: str
|
|
288
|
+
content: bytes
|
|
289
|
+
url: str
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def build_url(base_url: str, path: str) -> str:
|
|
293
|
+
"""Join a base URL that may carry a path prefix with an endpoint path.
|
|
294
|
+
|
|
295
|
+
``https://tytapi.1234567.com.cn`` + ``Login/GetLoginInfoV2``
|
|
296
|
+
-> ``https://tytapi.1234567.com.cn/Login/GetLoginInfoV2``
|
|
297
|
+
``https://tytapitest.1234567.com.cn/api`` + ``Login/GetLoginInfoV2``
|
|
298
|
+
-> ``https://tytapitest.1234567.com.cn/api/Login/GetLoginInfoV2``
|
|
299
|
+
"""
|
|
300
|
+
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _pick(payload: dict[str, Any], *names: str) -> Any:
|
|
304
|
+
"""Read the first present key, tolerating case differences."""
|
|
305
|
+
for name in names:
|
|
306
|
+
if name in payload:
|
|
307
|
+
return payload[name]
|
|
308
|
+
lowered = {str(k).lower(): v for k, v in payload.items()}
|
|
309
|
+
for name in names:
|
|
310
|
+
key = name.lower()
|
|
311
|
+
if key in lowered:
|
|
312
|
+
return lowered[key]
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class TytClient:
|
|
317
|
+
"""Talks to one TYT environment using one ``TYT_Token``."""
|
|
318
|
+
|
|
319
|
+
def __init__(
|
|
320
|
+
self,
|
|
321
|
+
base_url: str,
|
|
322
|
+
token: str,
|
|
323
|
+
*,
|
|
324
|
+
middle_base_url: str | None = None,
|
|
325
|
+
timeout: float = 15.0,
|
|
326
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
327
|
+
transport: httpx.BaseTransport | None = None,
|
|
328
|
+
) -> None:
|
|
329
|
+
self.base_url = base_url.rstrip("/")
|
|
330
|
+
self.middle_base_url = middle_base_url.rstrip("/") if middle_base_url else None
|
|
331
|
+
self.token = token
|
|
332
|
+
self.timeout = timeout
|
|
333
|
+
self.user_agent = user_agent
|
|
334
|
+
self._transport = transport
|
|
335
|
+
|
|
336
|
+
# -- public endpoints ------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def whoami(self) -> ApiEnvelope:
|
|
339
|
+
"""``POST /Login/GetLoginInfoV2`` — resolve the current login identity."""
|
|
340
|
+
return self.post_json("Login/GetLoginInfoV2", {})
|
|
341
|
+
|
|
342
|
+
def wechat_articles(
|
|
343
|
+
self,
|
|
344
|
+
*,
|
|
345
|
+
view: str = "ALL",
|
|
346
|
+
fake_id: str | None = None,
|
|
347
|
+
page_no: int = 1,
|
|
348
|
+
page_size: int = 20,
|
|
349
|
+
) -> ApiEnvelope:
|
|
350
|
+
"""``POST /middleApi/WechatPublic/getPostPage`` — paginate 公众号 articles.
|
|
351
|
+
|
|
352
|
+
``view`` is one of :data:`WECHAT_POST_VIEWS`. ``fake_id`` narrows to one
|
|
353
|
+
account; omit it (or pass ``None``) for all accounts. ``keyword`` is
|
|
354
|
+
intentionally not plumbed through — see module docstring §5.
|
|
355
|
+
"""
|
|
356
|
+
payload: dict[str, Any] = {"view": view, "pageNo": page_no, "pageSize": page_size}
|
|
357
|
+
# Omitted rather than sent as null: the server accepts both, but an
|
|
358
|
+
# absent key cannot be mistaken for an intentional filter.
|
|
359
|
+
if fake_id is not None:
|
|
360
|
+
payload["fakeId"] = fake_id
|
|
361
|
+
return self.post_middle_json(f"{WECHAT_PREFIX}/getPostPage", payload)
|
|
362
|
+
|
|
363
|
+
def wechat_article(self, *, article_id: str) -> ApiEnvelope:
|
|
364
|
+
"""``POST /middleApi/WechatPublic/getPostDetail`` — one article's detail.
|
|
365
|
+
|
|
366
|
+
``article_id`` is the 64-hex value the list returns. Probing confirmed
|
|
367
|
+
the documented ``identity`` triple (``{publishTime, account, title}``)
|
|
368
|
+
locates the same article, but it is deliberately not plumbed through:
|
|
369
|
+
the title is a long CJK string that must be reproduced byte-exactly (one
|
|
370
|
+
trailing space yields ``-1 文章不存在或已失效``), which makes it a poor
|
|
371
|
+
fit for a CLI argument. ``articleKey`` holds the same value yet is NOT
|
|
372
|
+
accepted under that name — see module docstring §3.
|
|
373
|
+
"""
|
|
374
|
+
return self.post_middle_json(
|
|
375
|
+
f"{WECHAT_PREFIX}/getPostDetail", {"articleId": article_id}
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def sentiment_daily_report_dates(self) -> ApiEnvelope:
|
|
379
|
+
"""``POST /middleApi/sentimentIndex/getDailyReportDates``.
|
|
380
|
+
|
|
381
|
+
``Data`` is a plain list of ``yyyy-MM-dd`` strings, newest first.
|
|
382
|
+
Sends NO body on purpose -- ``{}`` is rejected with ``-101``.
|
|
383
|
+
"""
|
|
384
|
+
return self.post_middle_json(
|
|
385
|
+
f"{SENTIMENT_PREFIX}/getDailyReportDates", send_body=False
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def sentiment_daily_report_image(self, report_date: str) -> BinaryResponse:
|
|
389
|
+
"""``POST /middleApi/sentimentIndex/getDailyReportImage``.
|
|
390
|
+
|
|
391
|
+
Returns the report as an image (``image/png`` in practice), not the
|
|
392
|
+
JSON envelope.
|
|
393
|
+
|
|
394
|
+
Retries up to :data:`SENTIMENT_IMAGE_RETRIES` extra times on ``-101``:
|
|
395
|
+
verified 2026-11-06, a *valid* date whose PNG has not been rendered yet
|
|
396
|
+
also answers ``-101 服务器繁忙``, and the next attempt succeeds. Without
|
|
397
|
+
the retry the command would fail intermittently on first use of a date.
|
|
398
|
+
Other error codes are raised immediately -- retrying a genuinely bad
|
|
399
|
+
date cannot help.
|
|
400
|
+
"""
|
|
401
|
+
last: ApiError | None = None
|
|
402
|
+
for attempt in range(SENTIMENT_IMAGE_RETRIES + 1):
|
|
403
|
+
if attempt:
|
|
404
|
+
time.sleep(SENTIMENT_IMAGE_RETRY_DELAY)
|
|
405
|
+
try:
|
|
406
|
+
return self.post_middle_binary(
|
|
407
|
+
f"{SENTIMENT_PREFIX}/getDailyReportImage",
|
|
408
|
+
{"reportDate": report_date},
|
|
409
|
+
)
|
|
410
|
+
except ApiError as exc:
|
|
411
|
+
if exc.details.get("error_code") != ERROR_CODE_SERVER_BUSY:
|
|
412
|
+
raise
|
|
413
|
+
last = exc
|
|
414
|
+
assert last is not None
|
|
415
|
+
last.details["retries_exhausted"] = SENTIMENT_IMAGE_RETRIES
|
|
416
|
+
raise last
|
|
417
|
+
|
|
418
|
+
def roadshow_search(
|
|
419
|
+
self,
|
|
420
|
+
*,
|
|
421
|
+
page_num: int = 1,
|
|
422
|
+
page_size: int = 20,
|
|
423
|
+
title: str | None = None,
|
|
424
|
+
institution: str | None = None,
|
|
425
|
+
speaker: str | None = None,
|
|
426
|
+
theme: str | None = None,
|
|
427
|
+
start_date: str | None = None,
|
|
428
|
+
end_date: str | None = None,
|
|
429
|
+
) -> ApiEnvelope:
|
|
430
|
+
"""``POST /middleApi/RoadShowCenter/getRoadShowInfo`` — search roadshows.
|
|
431
|
+
|
|
432
|
+
``speaker`` maps to the server's ``roadSter`` (its spelling, kept as-is).
|
|
433
|
+
``start_date`` / ``end_date`` are the listing's own ``yyyy/M/d`` format —
|
|
434
|
+
callers pass ISO dates and the command layer translates, because this
|
|
435
|
+
endpoint's ``endDate`` excludes that day's meetings while
|
|
436
|
+
``getMyRoadShowBookings``' includes it.
|
|
437
|
+
|
|
438
|
+
Optional filters are omitted rather than sent as ``null`` or ``""``: an
|
|
439
|
+
absent key cannot be mistaken for an intentional filter.
|
|
440
|
+
"""
|
|
441
|
+
payload: dict[str, Any] = {"pageNum": page_num, "pageSize": page_size}
|
|
442
|
+
for key, value in (
|
|
443
|
+
("title", title),
|
|
444
|
+
("institution", institution),
|
|
445
|
+
("roadSter", speaker),
|
|
446
|
+
("theme", theme),
|
|
447
|
+
("startDate", start_date),
|
|
448
|
+
("endDate", end_date),
|
|
449
|
+
):
|
|
450
|
+
if value is not None:
|
|
451
|
+
payload[key] = value
|
|
452
|
+
return self.post_middle_json(f"{ROADSHOW_PREFIX}/getRoadShowInfo", payload)
|
|
453
|
+
|
|
454
|
+
def roadshow_my_bookings(
|
|
455
|
+
self,
|
|
456
|
+
*,
|
|
457
|
+
page_num: int = 1,
|
|
458
|
+
page_size: int = 20,
|
|
459
|
+
booking_view_state: str | None = None,
|
|
460
|
+
meeting_start_date: str | None = None,
|
|
461
|
+
meeting_end_date: str | None = None,
|
|
462
|
+
book_start_time: str | None = None,
|
|
463
|
+
book_end_time: str | None = None,
|
|
464
|
+
) -> ApiEnvelope:
|
|
465
|
+
"""``POST /middleApi/RoadShowCenter/getMyRoadShowBookings``.
|
|
466
|
+
|
|
467
|
+
Returns the caller's *non-cancelled* bookings, newest booking first.
|
|
468
|
+
``meeting_start_date`` / ``meeting_end_date`` are ``yyyy-MM-dd`` and the
|
|
469
|
+
end date **includes the whole day** — the opposite of the listing's
|
|
470
|
+
``endDate``. ``book_start_time`` / ``book_end_time`` are
|
|
471
|
+
``yyyy-MM-dd HH:mm:ss`` and the upper bound is inclusive.
|
|
472
|
+
"""
|
|
473
|
+
payload: dict[str, Any] = {"pageNum": page_num, "pageSize": page_size}
|
|
474
|
+
for key, value in (
|
|
475
|
+
("bookingViewState", booking_view_state),
|
|
476
|
+
("meetingStartDate", meeting_start_date),
|
|
477
|
+
("meetingEndDate", meeting_end_date),
|
|
478
|
+
("bookStartTime", book_start_time),
|
|
479
|
+
("bookEndTime", book_end_time),
|
|
480
|
+
):
|
|
481
|
+
if value is not None:
|
|
482
|
+
payload[key] = value
|
|
483
|
+
return self.post_middle_json(f"{ROADSHOW_PREFIX}/getMyRoadShowBookings", payload)
|
|
484
|
+
|
|
485
|
+
def roadshow_booking_status(self, *, app_id: str) -> ApiEnvelope:
|
|
486
|
+
"""``POST /middleApi/RoadShowCenter/getRoadShowBookingStatus``.
|
|
487
|
+
|
|
488
|
+
``Data`` carries ``isBooked`` / ``canBook`` / ``canCancel`` / ``reason``
|
|
489
|
+
and, when booked, ``bookTime``. A meeting that exists but cannot be
|
|
490
|
+
acted on answers ``Success=true`` with the reason in ``Data.reason``;
|
|
491
|
+
a missing, delisted or malformed id answers ``Success=false``.
|
|
492
|
+
|
|
493
|
+
There is **no batch form** of this endpoint, so a search result cannot
|
|
494
|
+
show booking state — it has to be asked for one meeting at a time.
|
|
495
|
+
"""
|
|
496
|
+
return self.post_middle_json(
|
|
497
|
+
f"{ROADSHOW_PREFIX}/getRoadShowBookingStatus", {"appId": app_id}
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def roadshow_summary(self, *, app_id: str) -> ApiEnvelope:
|
|
501
|
+
"""``POST /middleApi/RoadShowCenter/getPublicRoadShowSummary``.
|
|
502
|
+
|
|
503
|
+
One response holds both the brief (``summary``) and the full text
|
|
504
|
+
(``detail``) plus ten topic fields, so "brief vs detailed" is field
|
|
505
|
+
selection rather than two endpoints.
|
|
506
|
+
|
|
507
|
+
``Data`` may be **absent** (not null) when there is no public summary.
|
|
508
|
+
The endpoint only ever returns audit-approved non-draft records, so
|
|
509
|
+
"none yet", "pending review", "draft" and "deleted" are indistinguishable
|
|
510
|
+
— there is no progress status to report, and the command layer says so
|
|
511
|
+
explicitly instead of letting an empty ``detail`` read as "empty summary".
|
|
512
|
+
"""
|
|
513
|
+
return self.post_middle_json(
|
|
514
|
+
f"{ROADSHOW_PREFIX}/getPublicRoadShowSummary", {"appId": app_id}
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def roadshow_book(self, *, app_id: str) -> ApiEnvelope:
|
|
518
|
+
"""``POST /middleApi/RoadShowCenter/bookRoadShow`` — **write**.
|
|
519
|
+
|
|
520
|
+
``Data`` is a bare ``true`` on success. Idempotent: booking an already
|
|
521
|
+
booked meeting succeeds again without changing ``bookTime`` (verified).
|
|
522
|
+
Rejections are HTTP 200 + ``ErrorCode -1`` with a Chinese message whose
|
|
523
|
+
bracket style differs from cancel's — branch on the code, never the text.
|
|
524
|
+
"""
|
|
525
|
+
return self.post_middle_json(
|
|
526
|
+
f"{ROADSHOW_PREFIX}/bookRoadShow", {"appId": app_id}
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
def roadshow_cancel_booking(self, *, app_id: str) -> ApiEnvelope:
|
|
530
|
+
"""``POST /middleApi/RoadShowCenter/cancelRoadShowBooking`` — **write**.
|
|
531
|
+
|
|
532
|
+
``Data`` is a bare ``true`` on success. Idempotent: cancelling twice, or
|
|
533
|
+
cancelling something never booked, both succeed (verified). Only allowed
|
|
534
|
+
while the meeting is a visible preview and more than 15 minutes away.
|
|
535
|
+
"""
|
|
536
|
+
return self.post_middle_json(
|
|
537
|
+
f"{ROADSHOW_PREFIX}/cancelRoadShowBooking", {"appId": app_id}
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# -- transport -------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
def post_json(self, path: str, payload: dict[str, Any] | None = None) -> ApiEnvelope:
|
|
543
|
+
"""POST to the core API root (``BASE_URLS``)."""
|
|
544
|
+
return self._post(build_url(self.base_url, path), payload)
|
|
545
|
+
|
|
546
|
+
def post_middle_json(
|
|
547
|
+
self,
|
|
548
|
+
path: str,
|
|
549
|
+
payload: dict[str, Any] | None = None,
|
|
550
|
+
*,
|
|
551
|
+
send_body: bool = True,
|
|
552
|
+
) -> ApiEnvelope:
|
|
553
|
+
"""POST to the ``/middleApi`` root (``MIDDLE_BASE_URLS``).
|
|
554
|
+
|
|
555
|
+
Kept separate from :meth:`post_json` because the two roots are siblings
|
|
556
|
+
on the same host, not prefixes of each other -- ``build_url`` cannot
|
|
557
|
+
reach ``/middleApi`` from a base that already ends in ``/api``.
|
|
558
|
+
|
|
559
|
+
``send_body=False`` omits the body entirely. Needed because the module's
|
|
560
|
+
body conventions disagree: ``Login`` requires one, ``WechatPublic``
|
|
561
|
+
tolerates either, and ``sentimentIndex/getDailyReportDates`` *rejects*
|
|
562
|
+
``{}`` with ``-101`` (client docstring, sentimentIndex §1).
|
|
563
|
+
"""
|
|
564
|
+
if self.middle_base_url is None:
|
|
565
|
+
raise ApiError(
|
|
566
|
+
"该命令需要 middleApi 根地址,但客户端未配置。",
|
|
567
|
+
code="CLIENT_MIDDLE_ROOT_MISSING",
|
|
568
|
+
details={"path": path},
|
|
569
|
+
)
|
|
570
|
+
return self._post(build_url(self.middle_base_url, path), payload, send_body=send_body)
|
|
571
|
+
|
|
572
|
+
def post_middle_binary(self, path: str, payload: dict[str, Any]) -> BinaryResponse:
|
|
573
|
+
"""POST to ``/middleApi`` expecting a binary (non-envelope) response.
|
|
574
|
+
|
|
575
|
+
``sentimentIndex/getDailyReportImage`` answers ``image/png`` despite
|
|
576
|
+
Swagger claiming a boolean envelope. If the server does answer JSON,
|
|
577
|
+
that is an error envelope (``-100`` / ``-1`` / ``-101``), so it is parsed
|
|
578
|
+
and raised through the normal path instead of being saved as an image.
|
|
579
|
+
"""
|
|
580
|
+
if self.middle_base_url is None:
|
|
581
|
+
raise ApiError(
|
|
582
|
+
"该命令需要 middleApi 根地址,但客户端未配置。",
|
|
583
|
+
code="CLIENT_MIDDLE_ROOT_MISSING",
|
|
584
|
+
details={"path": path},
|
|
585
|
+
)
|
|
586
|
+
url = build_url(self.middle_base_url, path)
|
|
587
|
+
response = self._request(
|
|
588
|
+
url, payload, timeout=max(self.timeout, BINARY_DOWNLOAD_TIMEOUT_FLOOR)
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
content_type = response.headers.get("content-type", "")
|
|
592
|
+
if content_type.startswith("application/json"):
|
|
593
|
+
envelope = self._parse(response, url)
|
|
594
|
+
error = error_for_envelope(envelope, base_url=self.middle_base_url)
|
|
595
|
+
if error is not None:
|
|
596
|
+
raise error
|
|
597
|
+
raise ApiError(
|
|
598
|
+
"该接口应返回图片,服务端却返回了成功的 JSON 信封。",
|
|
599
|
+
code="UNEXPECTED_SHAPE",
|
|
600
|
+
details={
|
|
601
|
+
"url": url,
|
|
602
|
+
"http_status": envelope.http_status,
|
|
603
|
+
"content_type": content_type,
|
|
604
|
+
"payload_keys": sorted(envelope.payload),
|
|
605
|
+
},
|
|
606
|
+
)
|
|
607
|
+
if response.status_code != 200:
|
|
608
|
+
raise ApiError(
|
|
609
|
+
f"HTTP {response.status_code}:{url}",
|
|
610
|
+
code="HTTP_ERROR",
|
|
611
|
+
details={
|
|
612
|
+
"url": url,
|
|
613
|
+
"http_status": response.status_code,
|
|
614
|
+
"content_type": content_type,
|
|
615
|
+
"body_preview": _preview(response.text),
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
return BinaryResponse(
|
|
619
|
+
http_status=response.status_code,
|
|
620
|
+
content_type=content_type,
|
|
621
|
+
content=response.content,
|
|
622
|
+
url=url,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
def _post(
|
|
626
|
+
self, url: str, payload: dict[str, Any] | None, *, send_body: bool = True
|
|
627
|
+
) -> ApiEnvelope:
|
|
628
|
+
return self._parse(self._request(url, payload, send_body=send_body), url)
|
|
629
|
+
|
|
630
|
+
def _request(
|
|
631
|
+
self,
|
|
632
|
+
url: str,
|
|
633
|
+
payload: dict[str, Any] | None,
|
|
634
|
+
*,
|
|
635
|
+
send_body: bool = True,
|
|
636
|
+
timeout: float | None = None,
|
|
637
|
+
) -> httpx.Response:
|
|
638
|
+
headers = {
|
|
639
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
640
|
+
"Accept": "application/json, text/plain, */*",
|
|
641
|
+
"Cookie": f"{COOKIE_NAME}={self.token}",
|
|
642
|
+
"User-Agent": self.user_agent,
|
|
643
|
+
# No `authkey` header. The 公众号 hand-off doc recommends one, but
|
|
644
|
+
# probing showed that adding it turns a working cookie-only request
|
|
645
|
+
# into `-100 未登录或鉴权失败` (client.py docstring §2).
|
|
646
|
+
}
|
|
647
|
+
try:
|
|
648
|
+
with httpx.Client(
|
|
649
|
+
timeout=timeout if timeout is not None else self.timeout,
|
|
650
|
+
follow_redirects=False,
|
|
651
|
+
transport=self._transport,
|
|
652
|
+
) as client:
|
|
653
|
+
if send_body:
|
|
654
|
+
# ``json=`` is used even for an empty dict so that a body is
|
|
655
|
+
# always sent; an absent body makes the core API answer
|
|
656
|
+
# ErrorCode -1. Some middleApi endpoints are the opposite
|
|
657
|
+
# (see post_middle_json), hence the flag.
|
|
658
|
+
response = client.post(url, json=dict(payload or {}), headers=headers)
|
|
659
|
+
else:
|
|
660
|
+
response = client.post(url, headers=headers)
|
|
661
|
+
except httpx.TimeoutException as exc:
|
|
662
|
+
effective = timeout if timeout is not None else self.timeout
|
|
663
|
+
raise NetworkError(
|
|
664
|
+
f"请求超时({effective}s):{url}",
|
|
665
|
+
code="NETWORK_TIMEOUT",
|
|
666
|
+
details={"url": url, "timeout_seconds": effective},
|
|
667
|
+
) from exc
|
|
668
|
+
except httpx.HTTPError as exc:
|
|
669
|
+
raise NetworkError(
|
|
670
|
+
f"网络错误:{type(exc).__name__}: {exc}",
|
|
671
|
+
code="NETWORK_ERROR",
|
|
672
|
+
details={"url": url, "error_type": type(exc).__name__},
|
|
673
|
+
) from exc
|
|
674
|
+
|
|
675
|
+
return response
|
|
676
|
+
|
|
677
|
+
def _parse(self, response: httpx.Response, url: str) -> ApiEnvelope:
|
|
678
|
+
content_type = response.headers.get("content-type", "")
|
|
679
|
+
if response.status_code != 200:
|
|
680
|
+
details: dict[str, Any] = {
|
|
681
|
+
"url": url,
|
|
682
|
+
"http_status": response.status_code,
|
|
683
|
+
"content_type": content_type,
|
|
684
|
+
"body_preview": _preview(response.text),
|
|
685
|
+
}
|
|
686
|
+
if response.status_code == 404:
|
|
687
|
+
# Not special-cased into its own error code: 404 already means
|
|
688
|
+
# "this route does not exist here", which HTTP_ERROR expresses.
|
|
689
|
+
# The hint exists because the 公众号 module is deployed on test
|
|
690
|
+
# but not on prod, and a bare 404 reads like a typo otherwise.
|
|
691
|
+
# Names no environment: this reaches end users, and AGENTS.md
|
|
692
|
+
# forbids advertising the dev env from user-visible output. The
|
|
693
|
+
# fact that only `test` deployed the module is recorded in
|
|
694
|
+
# AGENTS.md and in this module's docstring §9 instead.
|
|
695
|
+
details["hint"] = (
|
|
696
|
+
"该路由在此环境不存在(模块可能未部署),不是路由拼写错误。"
|
|
697
|
+
"只有已部署该模块的环境才能使用本命令;"
|
|
698
|
+
"用 tytcli env show 查看当前生效的环境。"
|
|
699
|
+
)
|
|
700
|
+
raise ApiError(
|
|
701
|
+
f"HTTP {response.status_code}:{url}",
|
|
702
|
+
code="HTTP_ERROR",
|
|
703
|
+
details=details,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
decoded = response.json()
|
|
708
|
+
except ValueError as exc:
|
|
709
|
+
raise ApiError(
|
|
710
|
+
"响应不是合法 JSON(可能是网关拦截页或 HTML 错误页)",
|
|
711
|
+
code="NON_JSON_RESPONSE",
|
|
712
|
+
details={
|
|
713
|
+
"url": url,
|
|
714
|
+
"http_status": response.status_code,
|
|
715
|
+
"content_type": content_type,
|
|
716
|
+
"body_preview": _preview(response.text),
|
|
717
|
+
},
|
|
718
|
+
) from exc
|
|
719
|
+
|
|
720
|
+
if not isinstance(decoded, dict):
|
|
721
|
+
raise ApiError(
|
|
722
|
+
"响应 JSON 顶层不是对象",
|
|
723
|
+
code="UNEXPECTED_SHAPE",
|
|
724
|
+
details={"url": url, "payload_type": type(decoded).__name__},
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
# `response.json()` is typed `Any`; narrow it once so everything below
|
|
728
|
+
# is `dict[str, Any]` rather than an untyped `dict[Unknown, Unknown]`.
|
|
729
|
+
data = cast("dict[str, Any]", decoded)
|
|
730
|
+
|
|
731
|
+
success = _pick(data, "Success", "success")
|
|
732
|
+
error_code = _pick(data, "ErrorCode", "errorCode", "error_code")
|
|
733
|
+
error_message = _pick(data, "Error", "error", "Message", "message")
|
|
734
|
+
|
|
735
|
+
if isinstance(error_code, bool) or not isinstance(error_code, int):
|
|
736
|
+
try:
|
|
737
|
+
error_code = int(error_code) if error_code is not None else None
|
|
738
|
+
except (TypeError, ValueError):
|
|
739
|
+
error_code = None
|
|
740
|
+
|
|
741
|
+
return ApiEnvelope(
|
|
742
|
+
http_status=response.status_code,
|
|
743
|
+
success=bool(success),
|
|
744
|
+
error_code=error_code,
|
|
745
|
+
error_message=str(error_message) if error_message is not None else None,
|
|
746
|
+
payload=data,
|
|
747
|
+
url=url,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def error_for_envelope(
|
|
752
|
+
envelope: ApiEnvelope, *, env_name: str | None = None, base_url: str | None = None
|
|
753
|
+
) -> TytCliError | None:
|
|
754
|
+
"""Map a non-success envelope onto the error it represents, else ``None``.
|
|
755
|
+
|
|
756
|
+
This lives in the protocol layer because two transports need it: the JSON
|
|
757
|
+
path (wrapped by ``commands.shared.raise_for_envelope``) and the binary
|
|
758
|
+
path, where an error envelope arrives on an endpoint that normally answers
|
|
759
|
+
image bytes. ``_parse`` deliberately does NOT raise -- it only parses -- so
|
|
760
|
+
every caller must consult this mapping; getting that wrong once turned a
|
|
761
|
+
``-1`` business error into a bogus UNEXPECTED_SHAPE.
|
|
762
|
+
"""
|
|
763
|
+
if envelope.success:
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
common: dict[str, Any] = {
|
|
767
|
+
"url": envelope.url,
|
|
768
|
+
"http_status": envelope.http_status,
|
|
769
|
+
"error_code": envelope.error_code,
|
|
770
|
+
}
|
|
771
|
+
if env_name is not None:
|
|
772
|
+
common["env"] = env_name
|
|
773
|
+
if base_url is not None:
|
|
774
|
+
common["base_url"] = base_url
|
|
775
|
+
|
|
776
|
+
if envelope.error_code == ERROR_CODE_AUTH_TIMEOUT:
|
|
777
|
+
return AuthError(
|
|
778
|
+
envelope.error_message or "登录超时,请重新登录。",
|
|
779
|
+
code="AUTH_EXPIRED",
|
|
780
|
+
details={
|
|
781
|
+
**common,
|
|
782
|
+
"hint": (
|
|
783
|
+
# Names no specific environment: this text is user-visible,
|
|
784
|
+
# and AGENTS.md forbids advertising the dev env from it.
|
|
785
|
+
# (It used to hardcode "test 的 Token 在 prod 不能用".)
|
|
786
|
+
f"TYT_Token 对 '{env_name}' 环境无效或已过期(Token 按环境隔离,"
|
|
787
|
+
f"不能跨环境复用)。运行:tytcli auth --env {env_name} --token <TOKEN>"
|
|
788
|
+
),
|
|
789
|
+
},
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
if envelope.error_code == ERROR_CODE_BAD_REQUEST:
|
|
793
|
+
return ApiError(
|
|
794
|
+
envelope.error_message or "服务端拒绝了该请求(地址或访问方式不正确)。",
|
|
795
|
+
code="API_BAD_REQUEST",
|
|
796
|
+
details={
|
|
797
|
+
**common,
|
|
798
|
+
"hint": (
|
|
799
|
+
"服务端表示路由不存在或请求不合规定。base_url 已固定在代码中,"
|
|
800
|
+
"所以这通常意味着端点路径写错了,而不是环境选错了。"
|
|
801
|
+
),
|
|
802
|
+
},
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
if envelope.error_code == ERROR_CODE_SERVER_BUSY:
|
|
806
|
+
# Verified 2026-11-06 on sentimentIndex/getDailyReportImage: empty,
|
|
807
|
+
# wrong-format and past-without-a-report dates all answer -101, while a
|
|
808
|
+
# future date answers the honest -1. So -101 here usually means "your
|
|
809
|
+
# input is unusable", and retrying cannot help.
|
|
810
|
+
common = {
|
|
811
|
+
**common,
|
|
812
|
+
"hint": (
|
|
813
|
+
"-101「服务器繁忙」在部分 middleApi 接口上是兜底码,有两种含义:"
|
|
814
|
+
"① 入参不可用(实测 getDailyReportImage 对空值、格式错误、无日报的"
|
|
815
|
+
"历史日期都返回它,重试无用,用 sentiment dates 核对日期);"
|
|
816
|
+
"② 服务端尚未渲染完成(实测有效日期的首次请求也可能返回它,"
|
|
817
|
+
"重试可成功;本 CLI 的图片命令已内置有限重试)。"
|
|
818
|
+
),
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return ApiError(
|
|
822
|
+
envelope.error_message or "API 返回业务失败。",
|
|
823
|
+
code="API_BUSINESS_ERROR",
|
|
824
|
+
details=common,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _preview(text: str) -> str:
|
|
829
|
+
text = (text or "").strip()
|
|
830
|
+
if len(text) <= BODY_PREVIEW_LIMIT:
|
|
831
|
+
return text
|
|
832
|
+
return text[:BODY_PREVIEW_LIMIT] + "…"
|