@9000ai/cli 0.1.0

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.
Files changed (62) hide show
  1. package/dist/client.d.ts +10 -0
  2. package/dist/client.js +45 -0
  3. package/dist/commands/auth.d.ts +2 -0
  4. package/dist/commands/auth.js +18 -0
  5. package/dist/commands/config.d.ts +2 -0
  6. package/dist/commands/config.js +30 -0
  7. package/dist/commands/feedback.d.ts +2 -0
  8. package/dist/commands/feedback.js +48 -0
  9. package/dist/commands/monitor.d.ts +2 -0
  10. package/dist/commands/monitor.js +101 -0
  11. package/dist/commands/search.d.ts +2 -0
  12. package/dist/commands/search.js +135 -0
  13. package/dist/commands/task.d.ts +2 -0
  14. package/dist/commands/task.js +20 -0
  15. package/dist/commands/transcribe.d.ts +2 -0
  16. package/dist/commands/transcribe.js +59 -0
  17. package/dist/config.d.ts +8 -0
  18. package/dist/config.js +37 -0
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +25 -0
  21. package/dist/output.d.ts +6 -0
  22. package/dist/output.js +49 -0
  23. package/dist/postinstall.d.ts +5 -0
  24. package/dist/postinstall.js +17 -0
  25. package/dist/utils/format.d.ts +1 -0
  26. package/dist/utils/format.js +16 -0
  27. package/package.json +31 -0
  28. package/skills/9000AI-hub/SKILL.md +195 -0
  29. package/skills/9000AI-hub/configure.py +56 -0
  30. package/skills/9000AI-hub/init/SKILL.md +130 -0
  31. package/skills/9000AI-hub/init/templates/CLAUDE.md +24 -0
  32. package/skills/9000AI-hub/init/templates/agents/README.md +7 -0
  33. package/skills/9000AI-hub/init/templates/agents/content-agent.md +181 -0
  34. package/skills/9000AI-hub/init/templates/claims/README.md +91 -0
  35. package/skills/9000AI-hub/init/templates/claims/claims.json +7 -0
  36. package/skills/9000AI-hub/init/templates/guide.md +185 -0
  37. package/skills/9000AI-hub/init/templates/inbox/README.md +26 -0
  38. package/skills/9000AI-hub/init/templates/profile/identity.md +8 -0
  39. package/skills/9000AI-hub/init/templates/profile/product.md +26 -0
  40. package/skills/9000AI-hub/init/templates/profile/topics.md +7 -0
  41. package/skills/9000AI-hub/init/templates/profile/voice.md +8 -0
  42. package/skills/9000AI-hub/init/templates/projects/README.md +5 -0
  43. package/skills/9000AI-hub/references/env.example +5 -0
  44. package/skills/9000AI-hub/references/runner-spec-v1.md +138 -0
  45. package/skills/9000AI-hub/shared/__init__.py +1 -0
  46. package/skills/9000AI-hub/shared/runner.py +135 -0
  47. package/skills/douyin-monitor/SKILL.md +112 -0
  48. package/skills/douyin-monitor/agents/openai.yaml +3 -0
  49. package/skills/douyin-monitor/references/endpoints.md +104 -0
  50. package/skills/douyin-monitor/scripts/douyin_monitor_api.py +273 -0
  51. package/skills/douyin-topic-discovery/SKILL.md +146 -0
  52. package/skills/douyin-topic-discovery/agents/openai.yaml +3 -0
  53. package/skills/douyin-topic-discovery/references/endpoints.md +127 -0
  54. package/skills/douyin-topic-discovery/scripts/douyin_topic_discovery_api.py +497 -0
  55. package/skills/douyin-topic-discovery/workflow/topic-research.md +216 -0
  56. package/skills/feedback/SKILL.md +69 -0
  57. package/skills/feedback/references/endpoints.md +46 -0
  58. package/skills/feedback/scripts/feedback_api.py +93 -0
  59. package/skills/video-transcription/SKILL.md +108 -0
  60. package/skills/video-transcription/agents/openai.yaml +3 -0
  61. package/skills/video-transcription/references/endpoints.md +82 -0
  62. package/skills/video-transcription/scripts/video_transcription_api.py +183 -0
@@ -0,0 +1,127 @@
1
+ # 抖音选题发现接口
2
+
3
+ 基础地址默认:
4
+
5
+ ```text
6
+ http://127.0.0.1:8025
7
+ ```
8
+
9
+ 所有接口都需要:
10
+
11
+ ```text
12
+ X-API-Key: <你的 key>
13
+ ```
14
+
15
+ 本地固定配置文件:
16
+
17
+ ```text
18
+ douyin-topic-discovery-9000AI/local/config.json
19
+ ```
20
+
21
+ ## 查看当前调用方
22
+
23
+ ```text
24
+ GET /api/v1/auth/me
25
+ ```
26
+
27
+ ## 查看当前已开通能力
28
+
29
+ ```text
30
+ GET /api/v1/auth/capability-permissions
31
+ ```
32
+
33
+ ## 获取热榜
34
+
35
+ ```text
36
+ GET /api/v1/douyin/discovery/hot-board?type=society&count=20
37
+ ```
38
+
39
+ 可选参数:
40
+
41
+ - `type`: `hot | city | seeding | entertainment | society | challenge`
42
+ - `count`: `1-50`
43
+ - `city`: 仅 `type=city` 时使用
44
+
45
+ ## 同步关键词试搜
46
+
47
+ ```text
48
+ POST /api/v1/douyin/discovery/search-videos
49
+ ```
50
+
51
+ 这个接口现在实际走的是 `search_general_v3`。
52
+
53
+ 请求体:
54
+
55
+ ```json
56
+ {
57
+ "keywords": ["AI创业", "副业"],
58
+ "count": 30,
59
+ "sort": 0,
60
+ "time": 0,
61
+ "content_type": 1,
62
+ "filter_duration": "0",
63
+ "min_likes": 0,
64
+ "min_comments": 0,
65
+ "max_concurrent": 3
66
+ }
67
+ ```
68
+
69
+ 说明:
70
+
71
+ - `count` 现在支持到 `30`
72
+ - 后端会基于 `search_general_v3` 自动翻页聚合,不需要自己传 `cursor`
73
+ - skill 层的 `search` 命令不会把这些执行参数全部暴露给 agent
74
+ - skill 默认按每个关键词 `30` 条提交
75
+ - `max_concurrent` 由后端执行策略控制,不建议 agent 自己调
76
+
77
+ ## 异步批量搜索
78
+
79
+ 提交:
80
+
81
+ ```text
82
+ POST /api/v1/douyin/discovery/search-videos/batch
83
+ ```
84
+
85
+ 查询聚合结果:
86
+
87
+ ```text
88
+ GET /api/v1/douyin/discovery/search-videos/batch/{batch_id}/results
89
+ ```
90
+
91
+ skill 工作流:
92
+
93
+ 1. `search` 只提交 batch,立即返回 `batch_id`
94
+ 2. `batch-result` 单独查询结果
95
+ 3. 查询成功后,skill 再落 `latest_search.json/tsv`
96
+
97
+ ## Skill output 文件
98
+
99
+ 热榜结果:
100
+
101
+ ```text
102
+ douyin-topic-discovery-9000AI/output/latest_hot.json
103
+ douyin-topic-discovery-9000AI/output/latest_hot.tsv
104
+ ```
105
+
106
+ 搜索流结果:
107
+
108
+ ```text
109
+ douyin-topic-discovery-9000AI/output/latest_search.json
110
+ douyin-topic-discovery-9000AI/output/latest_search.tsv
111
+ ```
112
+
113
+ 每条搜索结果会带:
114
+
115
+ - `row_no`
116
+ - `video_id`
117
+ - `title`
118
+ - `author_name`
119
+ - `author_uid`
120
+ - `author_sec_uid`
121
+ - `author_unique_id`
122
+ - `video_url`
123
+ - `play_url`
124
+ - `download_url`
125
+ - `media_url`
126
+ - `duration`
127
+ - `likes/comments/shares/collects/plays`
@@ -0,0 +1,497 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ import sys
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ REPO_ROOT = Path(__file__).resolve().parents[2]
12
+ HUB_ROOT = REPO_ROOT / "9000AI-hub-9000AI"
13
+ if str(HUB_ROOT) not in sys.path:
14
+ sys.path.insert(0, str(HUB_ROOT))
15
+
16
+ from shared.runner import ( # noqa: E402
17
+ ModuleSpec,
18
+ configure_stdout_encoding,
19
+ ensure_output_dir as runner_ensure_output_dir,
20
+ print_json,
21
+ request_json,
22
+ resolve_api_key,
23
+ resolve_base_url,
24
+ save_local_config,
25
+ )
26
+
27
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
28
+ SEARCH_RESULT_COUNT = 30
29
+ MODULE_SPEC = ModuleSpec(
30
+ module="douyin-topic-discovery",
31
+ skill_root=SKILL_ROOT,
32
+ )
33
+
34
+
35
+ def ensure_output_dir() -> Path:
36
+ return runner_ensure_output_dir(MODULE_SPEC)
37
+
38
+
39
+ def timestamp_slug() -> str:
40
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
41
+
42
+
43
+ def build_hot_bundle(api_response: dict[str, Any], *, board_type: str, count: int, city: str | None) -> dict[str, Any]:
44
+ data = api_response.get("data", {})
45
+ items = []
46
+ for item in data.get("items", []):
47
+ items.append(
48
+ {
49
+ "row_no": len(items) + 1,
50
+ "rank": item.get("rank"),
51
+ "topic": item.get("word", ""),
52
+ "hot_value": item.get("hot_value", 0),
53
+ "view_count": item.get("view_count", 0),
54
+ "video_count": item.get("video_count", 0),
55
+ "source_id": item.get("sentence_id", ""),
56
+ }
57
+ )
58
+
59
+ return {
60
+ "bundle_type": "douyin_hot_board",
61
+ "saved_at": datetime.now().astimezone().isoformat(),
62
+ "query": {
63
+ "board_type": board_type,
64
+ "board_name": data.get("board_name"),
65
+ "city": city,
66
+ "count": count,
67
+ },
68
+ "response_meta": {
69
+ "message": api_response.get("message"),
70
+ "trace_id": api_response.get("trace_id"),
71
+ "total": data.get("total", len(items)),
72
+ },
73
+ "items": items,
74
+ }
75
+
76
+
77
+ def normalize_search_item(item: dict[str, Any], row_no: int) -> dict[str, Any]:
78
+ play_url = item.get("play_url")
79
+ download_url = item.get("download_url")
80
+ return {
81
+ "row_no": row_no,
82
+ "keyword": item.get("keyword", ""),
83
+ "item_type": item.get("type", 0),
84
+ "video_id": item.get("aweme_id"),
85
+ "title": item.get("desc", ""),
86
+ "desc": item.get("desc", ""),
87
+ "create_time": item.get("create_time"),
88
+ "author_name": item.get("author_name", ""),
89
+ "author_uid": item.get("author_uid"),
90
+ "author_sec_uid": item.get("author_sec_uid"),
91
+ "author_unique_id": item.get("author_unique_id"),
92
+ "video_url": item.get("video_url", ""),
93
+ "play_url": play_url,
94
+ "download_url": download_url,
95
+ "media_url": play_url,
96
+ "media_is_ephemeral": bool(play_url),
97
+ "duration": item.get("duration", 0),
98
+ "image_count": item.get("image_count", 0),
99
+ "aweme_type": item.get("aweme_type"),
100
+ "media_type": item.get("media_type"),
101
+ "stats": {
102
+ "likes": item.get("likes", 0),
103
+ "comments": item.get("comments", 0),
104
+ "shares": item.get("shares", 0),
105
+ "collects": item.get("collects", 0),
106
+ "plays": item.get("plays", 0),
107
+ },
108
+ }
109
+
110
+
111
+ def build_search_batch_bundle(api_response: dict[str, Any], *, request_payload: dict[str, Any], batch_id: str) -> dict[str, Any]:
112
+ data = api_response.get("data", {})
113
+ items = []
114
+ for item in data.get("items", []):
115
+ items.append(normalize_search_item(item, len(items) + 1))
116
+
117
+ task_results = []
118
+ for task in data.get("tasks", []):
119
+ task_results.append(
120
+ {
121
+ "task_id": task.get("task_id"),
122
+ "keyword": task.get("keyword", ""),
123
+ "status": task.get("status", ""),
124
+ "error": task.get("error"),
125
+ "item_count": task.get("item_count", 0),
126
+ }
127
+ )
128
+
129
+ return {
130
+ "bundle_type": "douyin_video_search",
131
+ "saved_at": datetime.now().astimezone().isoformat(),
132
+ "query": request_payload,
133
+ "response_meta": {
134
+ "mode": "async_batch",
135
+ "search_endpoint": "search_general_v3",
136
+ "message": api_response.get("message"),
137
+ "trace_id": api_response.get("trace_id"),
138
+ "batch_id": batch_id,
139
+ "status": data.get("status"),
140
+ "total_tasks": data.get("total_tasks", 0),
141
+ "completed_tasks": data.get("completed_tasks", 0),
142
+ "success_tasks": data.get("success_tasks", 0),
143
+ "failed_tasks": data.get("failed_tasks", 0),
144
+ "total": data.get("total_items", len(items)),
145
+ "failed_keywords": data.get("failed_keywords", []),
146
+ "successful_keywords": data.get("successful_keywords", []),
147
+ "task_results": task_results,
148
+ },
149
+ "items": items,
150
+ }
151
+
152
+
153
+ def write_json(path: Path, payload: dict[str, Any]) -> None:
154
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
155
+
156
+
157
+ def write_hot_tsv(path: Path, bundle: dict[str, Any]) -> None:
158
+ with path.open("w", encoding="utf-8-sig", newline="") as handle:
159
+ writer = csv.DictWriter(
160
+ handle,
161
+ fieldnames=["row_no", "rank", "topic", "hot_value", "view_count", "video_count", "source_id"],
162
+ delimiter="\t",
163
+ )
164
+ writer.writeheader()
165
+ for item in bundle["items"]:
166
+ writer.writerow(item)
167
+
168
+
169
+ def write_search_tsv(path: Path, bundle: dict[str, Any]) -> None:
170
+ with path.open("w", encoding="utf-8-sig", newline="") as handle:
171
+ writer = csv.DictWriter(
172
+ handle,
173
+ fieldnames=[
174
+ "row_no",
175
+ "keyword",
176
+ "video_id",
177
+ "title",
178
+ "author_name",
179
+ "author_uid",
180
+ "author_sec_uid",
181
+ "author_unique_id",
182
+ "likes",
183
+ "comments",
184
+ "shares",
185
+ "collects",
186
+ "plays",
187
+ "duration",
188
+ "image_count",
189
+ "video_url",
190
+ "play_url",
191
+ "download_url",
192
+ "media_url",
193
+ "media_is_ephemeral",
194
+ ],
195
+ delimiter="\t",
196
+ )
197
+ writer.writeheader()
198
+ for item in bundle["items"]:
199
+ writer.writerow(
200
+ {
201
+ "row_no": item["row_no"],
202
+ "keyword": item["keyword"],
203
+ "video_id": item["video_id"],
204
+ "title": item["title"],
205
+ "author_name": item["author_name"],
206
+ "author_uid": item["author_uid"],
207
+ "author_sec_uid": item["author_sec_uid"],
208
+ "author_unique_id": item["author_unique_id"],
209
+ "likes": item["stats"]["likes"],
210
+ "comments": item["stats"]["comments"],
211
+ "shares": item["stats"]["shares"],
212
+ "collects": item["stats"]["collects"],
213
+ "plays": item["stats"]["plays"],
214
+ "duration": item["duration"],
215
+ "image_count": item["image_count"],
216
+ "video_url": item["video_url"],
217
+ "play_url": item["play_url"],
218
+ "download_url": item["download_url"],
219
+ "media_url": item["media_url"],
220
+ "media_is_ephemeral": item["media_is_ephemeral"],
221
+ }
222
+ )
223
+
224
+
225
+ def save_bundle(bundle: dict[str, Any], *, latest_name: str) -> dict[str, str]:
226
+ output_dir = ensure_output_dir()
227
+ stamp = timestamp_slug()
228
+ json_latest = output_dir / f"{latest_name}.json"
229
+ tsv_latest = output_dir / f"{latest_name}.tsv"
230
+ json_snapshot = output_dir / f"{latest_name}_{stamp}.json"
231
+ tsv_snapshot = output_dir / f"{latest_name}_{stamp}.tsv"
232
+
233
+ write_json(json_latest, bundle)
234
+ write_json(json_snapshot, bundle)
235
+ if bundle["bundle_type"] == "douyin_hot_board":
236
+ write_hot_tsv(tsv_latest, bundle)
237
+ write_hot_tsv(tsv_snapshot, bundle)
238
+ else:
239
+ write_search_tsv(tsv_latest, bundle)
240
+ write_search_tsv(tsv_snapshot, bundle)
241
+
242
+ return {
243
+ "json_latest": str(json_latest),
244
+ "tsv_latest": str(tsv_latest),
245
+ "json_snapshot": str(json_snapshot),
246
+ "tsv_snapshot": str(tsv_snapshot),
247
+ }
248
+
249
+
250
+ def resolve_output_path(value: str) -> Path:
251
+ output_dir = ensure_output_dir()
252
+ aliases = {
253
+ "latest_hot": output_dir / "latest_hot.json",
254
+ "latest_search": output_dir / "latest_search.json",
255
+ }
256
+ if value in aliases:
257
+ return aliases[value]
258
+ candidate = Path(value)
259
+ if candidate.exists():
260
+ return candidate
261
+ fallback = output_dir / value
262
+ if fallback.exists():
263
+ return fallback
264
+ raise SystemExit(f"找不到结果文件: {value}")
265
+
266
+
267
+ def load_bundle(path_value: str) -> dict[str, Any]:
268
+ path = resolve_output_path(path_value)
269
+ return json.loads(path.read_text(encoding="utf-8"))
270
+
271
+
272
+ def build_parser() -> argparse.ArgumentParser:
273
+ common = argparse.ArgumentParser(add_help=False)
274
+ common.add_argument("--base-url", default=None)
275
+ common.add_argument("--api-key", default=None)
276
+
277
+ parser = argparse.ArgumentParser(description="Douyin topic discovery API client", parents=[common])
278
+ subparsers = parser.add_subparsers(dest="command", required=True)
279
+
280
+ subparsers.add_parser("configure", help="写入本地配置文件", parents=[common])
281
+ subparsers.add_parser("whoami", help="查看当前 key 对应的调用方", parents=[common])
282
+ subparsers.add_parser("capabilities", help="查看当前 key 已开通的能力", parents=[common])
283
+
284
+ hot = subparsers.add_parser("hot", help="查询抖音热点榜并落 output 文件", parents=[common])
285
+ hot.add_argument("--type", default="hot", choices=["hot", "city", "seeding", "entertainment", "society", "challenge"])
286
+ hot.add_argument("--count", type=int, default=20)
287
+ hot.add_argument("--city", default=None)
288
+
289
+ search = subparsers.add_parser("search", help="按关键词异步提交抖音搜索流批次任务", parents=[common])
290
+ search.add_argument("keywords", nargs="+")
291
+ search.add_argument("--sort", type=int, default=0)
292
+ search.add_argument("--time", type=int, default=0)
293
+ search.add_argument("--content-type", type=int, default=0, choices=[0, 1, 2])
294
+ search.add_argument("--filter-duration", default="")
295
+ search.add_argument("--min-likes", type=int, default=0)
296
+ search.add_argument("--min-comments", type=int, default=0)
297
+
298
+ batch_status = subparsers.add_parser("batch-result", help="按 batch_id 查看异步搜索批次结果并落 output 文件", parents=[common])
299
+ batch_status.add_argument("--batch-id", required=True)
300
+
301
+ subparsers.add_parser("list-output", help="查看 output 目录下的结果文件")
302
+
303
+ show_result = subparsers.add_parser("show-result", help="按行查看结果文件中的单条记录")
304
+ show_result.add_argument("--input", default="latest_search")
305
+ show_result.add_argument("--row", type=int, required=True)
306
+
307
+ export_media = subparsers.add_parser("export-media", help="输出某一行的媒体引用,供后续脚本使用")
308
+ export_media.add_argument("--input", default="latest_search")
309
+ export_media.add_argument("--row", type=int, required=True)
310
+ return parser
311
+
312
+
313
+ def main() -> None:
314
+ configure_stdout_encoding()
315
+ parser = build_parser()
316
+ args = parser.parse_args()
317
+
318
+ if args.command == "configure":
319
+ if not args.base_url or not args.api_key:
320
+ raise SystemExit("configure 需要同时传 --base-url 和 --api-key")
321
+ runner_save_local_config = save_local_config
322
+ runner_save_local_config(MODULE_SPEC, base_url=args.base_url, api_key=args.api_key)
323
+ print_json({"message": "配置已写入", "config_path": str(MODULE_SPEC.config_path)})
324
+ return
325
+
326
+ if args.command == "list-output":
327
+ output_dir = ensure_output_dir()
328
+ files = [
329
+ {"name": path.name, "path": str(path), "size": path.stat().st_size}
330
+ for path in sorted(output_dir.glob("*"))
331
+ if path.is_file()
332
+ ]
333
+ print_json({"output_dir": str(output_dir), "files": files})
334
+ return
335
+
336
+ if args.command == "show-result":
337
+ bundle = load_bundle(args.input)
338
+ row = next((item for item in bundle.get("items", []) if item.get("row_no") == args.row), None)
339
+ if not row:
340
+ raise SystemExit(f"结果文件里不存在 row={args.row}")
341
+ print_json(row)
342
+ return
343
+
344
+ if args.command == "export-media":
345
+ bundle = load_bundle(args.input)
346
+ row = next((item for item in bundle.get("items", []) if item.get("row_no") == args.row), None)
347
+ if not row:
348
+ raise SystemExit(f"结果文件里不存在 row={args.row}")
349
+ payload = {
350
+ "row_no": row.get("row_no"),
351
+ "video_id": row.get("video_id"),
352
+ "author_uid": row.get("author_uid"),
353
+ "author_sec_uid": row.get("author_sec_uid"),
354
+ "author_unique_id": row.get("author_unique_id"),
355
+ "video_url": row.get("video_url"),
356
+ "play_url": row.get("play_url"),
357
+ "download_url": row.get("download_url"),
358
+ "media_url": row.get("media_url"),
359
+ "media_is_ephemeral": row.get("media_is_ephemeral"),
360
+ }
361
+ print_json(payload)
362
+ return
363
+
364
+ base_url = resolve_base_url(MODULE_SPEC, args.base_url)
365
+ api_key = resolve_api_key(MODULE_SPEC, args.api_key)
366
+
367
+ if args.command == "whoami":
368
+ print_json(
369
+ request_json(MODULE_SPEC, method="GET", base_url=base_url, api_key=api_key, path="/api/v1/auth/me")
370
+ )
371
+ return
372
+
373
+ if args.command == "capabilities":
374
+ print_json(
375
+ request_json(
376
+ MODULE_SPEC,
377
+ method="GET",
378
+ base_url=base_url,
379
+ api_key=api_key,
380
+ path="/api/v1/auth/capability-permissions",
381
+ )
382
+ )
383
+ return
384
+
385
+ if args.command == "hot":
386
+ path = f"/api/v1/douyin/discovery/hot-board?type={args.type}&count={args.count}"
387
+ if args.city:
388
+ path += f"&city={args.city}"
389
+ api_response = request_json(MODULE_SPEC, method="GET", base_url=base_url, api_key=api_key, path=path)
390
+ bundle = build_hot_bundle(api_response, board_type=args.type, count=args.count, city=args.city)
391
+ saved = save_bundle(bundle, latest_name="latest_hot")
392
+ summary = [
393
+ {
394
+ "row_no": item["row_no"],
395
+ "rank": item["rank"],
396
+ "topic": item["topic"],
397
+ "hot_value": item["hot_value"],
398
+ }
399
+ for item in bundle["items"][:10]
400
+ ]
401
+ print_json(
402
+ {
403
+ "status": "completed",
404
+ "message": "已返回抖音热点榜结果",
405
+ "artifacts": [saved["json_latest"], saved["tsv_latest"]],
406
+ "data": {"saved": saved, "summary": summary, "total": bundle["response_meta"]["total"]},
407
+ }
408
+ )
409
+ return
410
+
411
+ if args.command == "batch-result":
412
+ api_response = request_json(
413
+ MODULE_SPEC,
414
+ method="GET",
415
+ base_url=base_url,
416
+ api_key=api_key,
417
+ path=f"/api/v1/douyin/discovery/search-videos/batch/{args.batch_id}/results",
418
+ )
419
+ bundle = build_search_batch_bundle(api_response, request_payload={"batch_id": args.batch_id}, batch_id=args.batch_id)
420
+ saved = None
421
+ status_value = str(bundle["response_meta"].get("status") or "").lower()
422
+ artifacts: list[str] = []
423
+ if status_value in {"completed", "partial_success", "failed"}:
424
+ saved = save_bundle(bundle, latest_name="latest_search")
425
+ artifacts = [saved["json_latest"], saved["tsv_latest"]]
426
+ print_json(
427
+ {
428
+ "status": status_value or "running",
429
+ "message": "已查询批次结果",
430
+ "batch_id": args.batch_id,
431
+ "artifacts": artifacts,
432
+ "data": {
433
+ "saved": saved,
434
+ "status": bundle["response_meta"].get("status"),
435
+ "total": bundle["response_meta"].get("total"),
436
+ "success_tasks": bundle["response_meta"].get("success_tasks"),
437
+ "failed_tasks": bundle["response_meta"].get("failed_tasks"),
438
+ "failed_keywords": bundle["response_meta"].get("failed_keywords"),
439
+ "summary": [
440
+ {
441
+ "row_no": item["row_no"],
442
+ "keyword": item["keyword"],
443
+ "title": item["title"],
444
+ "author_name": item["author_name"],
445
+ "author_sec_uid": item["author_sec_uid"],
446
+ "likes": item["stats"]["likes"],
447
+ "video_id": item["video_id"],
448
+ }
449
+ for item in bundle["items"][:10]
450
+ ],
451
+ },
452
+ }
453
+ )
454
+ return
455
+
456
+ if args.command == "search":
457
+ payload = {
458
+ "keywords": args.keywords,
459
+ "count": SEARCH_RESULT_COUNT,
460
+ "sort": args.sort,
461
+ "time": args.time,
462
+ "content_type": args.content_type,
463
+ "filter_duration": args.filter_duration,
464
+ "min_likes": args.min_likes,
465
+ "min_comments": args.min_comments,
466
+ }
467
+ submit_response = request_json(
468
+ MODULE_SPEC,
469
+ method="POST",
470
+ base_url=base_url,
471
+ api_key=api_key,
472
+ path="/api/v1/douyin/discovery/search-videos/batch",
473
+ payload=payload,
474
+ )
475
+ submit_data = submit_response.get("data", {})
476
+ batch_id = submit_data.get("batch_id")
477
+ if not batch_id:
478
+ raise SystemExit(json.dumps(submit_response, ensure_ascii=False, indent=2))
479
+ print_json(
480
+ {
481
+ "status": "submitted",
482
+ "message": "批次已提交。稍后用 batch-result --batch-id <batch_id> 查询并落 latest_search 文件。",
483
+ "batch_id": batch_id,
484
+ "artifacts": [],
485
+ "data": {
486
+ "submitted_keywords": args.keywords,
487
+ "tasks": submit_data.get("tasks", []),
488
+ "total_tasks": submit_data.get("total_tasks"),
489
+ "result_count_per_keyword": SEARCH_RESULT_COUNT,
490
+ },
491
+ }
492
+ )
493
+ return
494
+
495
+
496
+ if __name__ == "__main__":
497
+ main()