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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""``tytcli wechat`` —— 公众号文章(WechatPublic 模块)。
|
|
2
|
+
|
|
3
|
+
住在第二个 API 根(``MIDDLE_BASE_URLS``)上,不是核心根;实测得到的契约、以及内部
|
|
4
|
+
交接文档与实测不符的那些地方,都记在 ``client.py`` 的模块 docstring 里。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from ..client import (
|
|
15
|
+
WECHAT_ARTICLE_ID_MAX,
|
|
16
|
+
WECHAT_PAGE_SIZE_MAX,
|
|
17
|
+
WECHAT_PAGE_SIZE_MIN,
|
|
18
|
+
WECHAT_POST_VIEWS,
|
|
19
|
+
)
|
|
20
|
+
from ..errors import UsageError
|
|
21
|
+
from ..output import HINT_KEY, emit_ok
|
|
22
|
+
from .shared import (
|
|
23
|
+
middle_client,
|
|
24
|
+
parse_fields_option,
|
|
25
|
+
raise_for_envelope,
|
|
26
|
+
require_config,
|
|
27
|
+
trim_page,
|
|
28
|
+
unwrap_data,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
#: ``wechat articles`` 默认(裁剪后)输出保留的字段。
|
|
32
|
+
#:
|
|
33
|
+
#: 2026-11-06 在 test 上实测:20 条记录的一页是 66.8 KiB,其中**光 ``cleanBody``
|
|
34
|
+
#: 一个字段就占 74%**(去掉后只剩 17.5 KiB)。``cleanBody`` 是文章正文——那是
|
|
35
|
+
#: *详情*命令的职责,而且交接文档根本没把它列为列表字段。所以默认输出是索引,
|
|
36
|
+
#: 不是内容。
|
|
37
|
+
#:
|
|
38
|
+
#: **绝不静默丢字段**:文档里带着 ``trimmed``、``record_fields`` 与
|
|
39
|
+
#: ``__tytcli_hint__``;``--fields`` 用于显式自选,``--full`` 返回服务端发来的
|
|
40
|
+
#: 每一个字段。
|
|
41
|
+
WECHAT_DEFAULT_FIELDS: tuple[str, ...] = (
|
|
42
|
+
"title",
|
|
43
|
+
"nickname",
|
|
44
|
+
"summary",
|
|
45
|
+
"articleId",
|
|
46
|
+
"publishTime",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _wechat_hint(default_fields: Sequence[str], observed: list[str]) -> str:
|
|
55
|
+
"""裁剪过的文章页所对应的 ``__tytcli_hint__`` 文案。
|
|
56
|
+
|
|
57
|
+
措辞是模块专属的,所以留在这里而不是放进 ``shared``;它周围的机制(观测到了
|
|
58
|
+
哪些字段、什么时候该输出提示)是共享的。
|
|
59
|
+
"""
|
|
60
|
+
extra = [field for field in observed if field not in default_fields]
|
|
61
|
+
parts = [f"默认每条文章仅输出:{', '.join(default_fields)}。"]
|
|
62
|
+
if extra:
|
|
63
|
+
parts.append(f"该接口本次还返回了:{', '.join(extra)}。")
|
|
64
|
+
parts.append(
|
|
65
|
+
"用 --fields a,b,c 自选字段,或 --full 取服务端原样全量"
|
|
66
|
+
"(含 cleanBody 正文,20 条约 67 KiB)。"
|
|
67
|
+
)
|
|
68
|
+
parts.append("articleId 可直接用于:tytcli wechat article --article-id <ID>。")
|
|
69
|
+
# 用 "" 而不是 " " 拼接:每一段本来就以全角句号结尾,句号前多一个空格在
|
|
70
|
+
# 中文里看起来像错字。
|
|
71
|
+
return "".join(parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@click.group()
|
|
75
|
+
def wechat() -> None:
|
|
76
|
+
"""公众号文章。
|
|
77
|
+
|
|
78
|
+
\b
|
|
79
|
+
注意:该模块可能未在所有环境部署。若命令返回 HTTP 404,
|
|
80
|
+
错误的 details.hint 会说明原因——这不是路由拼错。
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@wechat.command("articles")
|
|
85
|
+
@click.option(
|
|
86
|
+
"--view",
|
|
87
|
+
type=click.Choice(list(WECHAT_POST_VIEWS)),
|
|
88
|
+
default="ALL",
|
|
89
|
+
show_default=True,
|
|
90
|
+
help="ALL 全部文章 / SUBSCRIBED 我的订阅 / FAVORITE 我的收藏。",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--fake-id",
|
|
94
|
+
default=None,
|
|
95
|
+
metavar="ID",
|
|
96
|
+
help="只看某个公众号(列表里的 fakeId);省略表示不限。",
|
|
97
|
+
)
|
|
98
|
+
@click.option("--page", "page_no", type=int, default=1, show_default=True, help="页码,从 1 开始。")
|
|
99
|
+
@click.option(
|
|
100
|
+
"--page-size",
|
|
101
|
+
type=int,
|
|
102
|
+
default=20,
|
|
103
|
+
show_default=True,
|
|
104
|
+
help=f"每页条数,{WECHAT_PAGE_SIZE_MIN}–{WECHAT_PAGE_SIZE_MAX}。",
|
|
105
|
+
)
|
|
106
|
+
@click.option(
|
|
107
|
+
"--fields",
|
|
108
|
+
"fields_option",
|
|
109
|
+
default=None,
|
|
110
|
+
metavar="A,B,C",
|
|
111
|
+
help="只输出这些文章字段(逗号分隔)。与 --full 互斥。",
|
|
112
|
+
)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--full",
|
|
115
|
+
"full",
|
|
116
|
+
is_flag=True,
|
|
117
|
+
help="输出服务端返回的全部字段,不裁剪。与 --fields 互斥。",
|
|
118
|
+
)
|
|
119
|
+
@click.pass_context
|
|
120
|
+
def wechat_articles(
|
|
121
|
+
ctx: click.Context,
|
|
122
|
+
view: str,
|
|
123
|
+
fake_id: str | None,
|
|
124
|
+
page_no: int,
|
|
125
|
+
page_size: int,
|
|
126
|
+
fields_option: str | None,
|
|
127
|
+
full: bool,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""分页查询公众号文章。
|
|
130
|
+
|
|
131
|
+
\b
|
|
132
|
+
默认输出是精简索引(title/nickname/summary/articleId/publishTime),
|
|
133
|
+
并在 __tytcli_hint__ 里说明还能取到哪些字段。
|
|
134
|
+
20 条时:默认约 5 KiB,--full 约 67 KiB。
|
|
135
|
+
|
|
136
|
+
\b
|
|
137
|
+
拿到 articleId 后查详情:
|
|
138
|
+
tytcli wechat article --article-id <ID>
|
|
139
|
+
"""
|
|
140
|
+
pretty: bool = ctx.obj["pretty"]
|
|
141
|
+
|
|
142
|
+
if full and fields_option is not None:
|
|
143
|
+
raise UsageError(
|
|
144
|
+
"--full 与 --fields 互斥:前者要全量,后者要自选。",
|
|
145
|
+
code="USAGE_FIELDS_CONFLICT",
|
|
146
|
+
)
|
|
147
|
+
if page_no < 1:
|
|
148
|
+
raise UsageError(
|
|
149
|
+
f"--page 必须 >= 1(当前 {page_no})。",
|
|
150
|
+
code="USAGE_PAGE_OUT_OF_RANGE",
|
|
151
|
+
details={"page": page_no, "min": 1},
|
|
152
|
+
)
|
|
153
|
+
if not WECHAT_PAGE_SIZE_MIN <= page_size <= WECHAT_PAGE_SIZE_MAX:
|
|
154
|
+
raise UsageError(
|
|
155
|
+
f"--page-size 必须在 {WECHAT_PAGE_SIZE_MIN}–{WECHAT_PAGE_SIZE_MAX} 之间"
|
|
156
|
+
f"(当前 {page_size})。",
|
|
157
|
+
code="USAGE_PAGE_SIZE_OUT_OF_RANGE",
|
|
158
|
+
details={
|
|
159
|
+
"page_size": page_size,
|
|
160
|
+
"min": WECHAT_PAGE_SIZE_MIN,
|
|
161
|
+
"max": WECHAT_PAGE_SIZE_MAX,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
selected = parse_fields_option(fields_option) if fields_option is not None else None
|
|
165
|
+
|
|
166
|
+
config = require_config()
|
|
167
|
+
client = middle_client(config)
|
|
168
|
+
envelope = client.wechat_articles(
|
|
169
|
+
view=view,
|
|
170
|
+
fake_id=fake_id,
|
|
171
|
+
page_no=page_no,
|
|
172
|
+
page_size=page_size,
|
|
173
|
+
)
|
|
174
|
+
raise_for_envelope(
|
|
175
|
+
envelope, env_name=config.active_env, base_url=config.middle_base_url
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
inner_raw = unwrap_data(envelope.payload)
|
|
179
|
+
|
|
180
|
+
meta: dict[str, Any] = {
|
|
181
|
+
"env": config.active_env,
|
|
182
|
+
"base_url": config.middle_base_url,
|
|
183
|
+
"api_root": "middleApi",
|
|
184
|
+
"http_status": envelope.http_status,
|
|
185
|
+
"view": view,
|
|
186
|
+
"page_no": page_no,
|
|
187
|
+
"page_size": page_size,
|
|
188
|
+
}
|
|
189
|
+
if fake_id is not None:
|
|
190
|
+
meta["fake_id"] = fake_id
|
|
191
|
+
|
|
192
|
+
page = trim_page(
|
|
193
|
+
inner_raw=inner_raw,
|
|
194
|
+
list_key="records",
|
|
195
|
+
default_fields=WECHAT_DEFAULT_FIELDS,
|
|
196
|
+
selected=selected,
|
|
197
|
+
full=full,
|
|
198
|
+
make_hint=_wechat_hint,
|
|
199
|
+
)
|
|
200
|
+
document_extra: dict[str, Any] = {}
|
|
201
|
+
if page.hint is not None:
|
|
202
|
+
document_extra[HINT_KEY] = page.hint
|
|
203
|
+
emit_ok(page.payload, pretty=pretty, **meta, **page.meta, **document_extra)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@wechat.command("article")
|
|
207
|
+
@click.option(
|
|
208
|
+
"--article-id",
|
|
209
|
+
required=True,
|
|
210
|
+
metavar="HEX64",
|
|
211
|
+
help=f"文章的 articleId(列表返回的 {WECHAT_ARTICLE_ID_MAX} 位十六进制)。",
|
|
212
|
+
)
|
|
213
|
+
@click.pass_context
|
|
214
|
+
def wechat_article(ctx: click.Context, article_id: str) -> None:
|
|
215
|
+
"""查询单篇公众号文章详情。
|
|
216
|
+
|
|
217
|
+
\b
|
|
218
|
+
输出为服务端原样全量(单篇约 14 KiB),含正文与 AI 分析结果
|
|
219
|
+
(观点、段落、话题、关键词)。
|
|
220
|
+
|
|
221
|
+
\b
|
|
222
|
+
articleId 来自 `tytcli wechat articles` 的输出。
|
|
223
|
+
"""
|
|
224
|
+
pretty: bool = ctx.obj["pretty"]
|
|
225
|
+
|
|
226
|
+
# argv 里的空白是 shell 造成的,去掉是安全的。而 identity 标题**内部**的
|
|
227
|
+
# 空白不是——这正是 identity 不适合当定位符的原因之一。
|
|
228
|
+
article_id = article_id.strip()
|
|
229
|
+
if not article_id:
|
|
230
|
+
raise UsageError("--article-id 不能为空。", code="USAGE_ARTICLE_ID_EMPTY")
|
|
231
|
+
if len(article_id) > WECHAT_ARTICLE_ID_MAX:
|
|
232
|
+
raise UsageError(
|
|
233
|
+
f"--article-id 超过 {WECHAT_ARTICLE_ID_MAX} 字符(当前 {len(article_id)})。",
|
|
234
|
+
code="USAGE_ARTICLE_ID_TOO_LONG",
|
|
235
|
+
details={"length": len(article_id), "max": WECHAT_ARTICLE_ID_MAX},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
config = require_config()
|
|
239
|
+
client = middle_client(config)
|
|
240
|
+
envelope = client.wechat_article(article_id=article_id)
|
|
241
|
+
raise_for_envelope(
|
|
242
|
+
envelope, env_name=config.active_env, base_url=config.middle_base_url
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
emit_ok(
|
|
246
|
+
unwrap_data(envelope.payload),
|
|
247
|
+
pretty=pretty,
|
|
248
|
+
env=config.active_env,
|
|
249
|
+
base_url=config.middle_base_url,
|
|
250
|
+
api_root="middleApi",
|
|
251
|
+
http_status=envelope.http_status,
|
|
252
|
+
trimmed=False,
|
|
253
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""``tytcli whoami`` —— 当前登录身份。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..client import TytClient
|
|
10
|
+
from ..output import emit_ok
|
|
11
|
+
from .shared import raise_for_envelope, require_config
|
|
12
|
+
|
|
13
|
+
#: `whoami` 额外发布的身份字段,这样 agent 不必去翻完整 payload——光 `Menus`
|
|
14
|
+
#: 权限树就有好几 KB。
|
|
15
|
+
#:
|
|
16
|
+
#: **刻意排除 `Phone`**:它是 PII,而这份摘要正是最容易被记进对话或日志的部分。
|
|
17
|
+
#: 它仍然存在于 `data` 里,`data` 始终是服务端响应的原样透传。
|
|
18
|
+
IDENTITY_FIELDS = (
|
|
19
|
+
"UserId",
|
|
20
|
+
"UserName",
|
|
21
|
+
"JjtId",
|
|
22
|
+
"CompanyId",
|
|
23
|
+
"CompanyName",
|
|
24
|
+
"Department",
|
|
25
|
+
"Duty",
|
|
26
|
+
"EmpNo",
|
|
27
|
+
"AccountType",
|
|
28
|
+
"EstPerm",
|
|
29
|
+
"EmotionPerm",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.command()
|
|
34
|
+
@click.pass_context
|
|
35
|
+
def whoami(ctx: click.Context) -> None:
|
|
36
|
+
"""查询当前 Token 对应的登录身份。
|
|
37
|
+
|
|
38
|
+
\b
|
|
39
|
+
输出中 `identity` 是关键字段的摘要(不含手机号);
|
|
40
|
+
`data` 是服务端原样透传的完整信息(含权限菜单树)。
|
|
41
|
+
"""
|
|
42
|
+
pretty: bool = ctx.obj["pretty"]
|
|
43
|
+
config = require_config()
|
|
44
|
+
token = config.require_token()
|
|
45
|
+
|
|
46
|
+
client = TytClient(config.base_url, token, timeout=config.timeout_seconds)
|
|
47
|
+
envelope = client.whoami()
|
|
48
|
+
|
|
49
|
+
raise_for_envelope(envelope, env_name=config.active_env, base_url=config.base_url)
|
|
50
|
+
|
|
51
|
+
emit_ok(
|
|
52
|
+
envelope.payload,
|
|
53
|
+
pretty=pretty,
|
|
54
|
+
env=config.active_env,
|
|
55
|
+
base_url=config.base_url,
|
|
56
|
+
http_status=envelope.http_status,
|
|
57
|
+
identity=extract_identity(envelope.payload),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_identity(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
62
|
+
"""从 GetLoginInfoV2 的 payload 里取出标量身份字段。
|
|
63
|
+
|
|
64
|
+
形状与预期不符时返回 ``None``,这样服务端的改动静默降级成"没有摘要",而不是
|
|
65
|
+
让命令崩掉。`data` 里依然带着全部字段。
|
|
66
|
+
"""
|
|
67
|
+
raw_data = payload.get("Data")
|
|
68
|
+
if not isinstance(raw_data, dict):
|
|
69
|
+
return None
|
|
70
|
+
data = cast("dict[str, Any]", raw_data)
|
|
71
|
+
identity: dict[str, Any] = {key: data[key] for key in IDENTITY_FIELDS if key in data}
|
|
72
|
+
menus = data.get("Menus")
|
|
73
|
+
# isinstance() 会把 Any 窄化成 list[Unknown];cast 成具体的 list,
|
|
74
|
+
# 这样 len() 的参数类型才是已知的。
|
|
75
|
+
identity["MenuCount"] = len(cast("list[Any]", menus)) if isinstance(menus, list) else None
|
|
76
|
+
return identity
|