@geoly-ai/social-hub-cli 0.3.17 → 0.3.18

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 (32) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/cmd-manifest.json +5 -2
  3. package/dist/cmd-manifest.test.js +3 -0
  4. package/dist/cmd-manifest.test.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +5 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/permissions-gates-admin.d.ts.map +1 -1
  9. package/dist/permissions-gates-admin.js +12 -0
  10. package/dist/permissions-gates-admin.js.map +1 -1
  11. package/dist/register-extensions.d.ts.map +1 -1
  12. package/dist/register-extensions.js +15 -1
  13. package/dist/register-extensions.js.map +1 -1
  14. package/dist/register-feishu-identities.d.ts +47 -0
  15. package/dist/register-feishu-identities.d.ts.map +1 -0
  16. package/dist/register-feishu-identities.js +346 -0
  17. package/dist/register-feishu-identities.js.map +1 -0
  18. package/dist/register-feishu-identities.test.d.ts +2 -0
  19. package/dist/register-feishu-identities.test.d.ts.map +1 -0
  20. package/dist/register-feishu-identities.test.js +124 -0
  21. package/dist/register-feishu-identities.test.js.map +1 -0
  22. package/package.json +3 -3
  23. package/skills/manifest.json +1 -1
  24. package/skills/reddit-matrix.lock.json +1 -1
  25. package/skills/reddit-voc-volume/SKILL.md +2 -7
  26. package/skills/reddit-voc-volume/matrix-contract.md +4 -3
  27. package/skills/reddit-voc-volume/references/arctic-shift.md +2 -1
  28. package/skills/reddit-voc-volume/scripts/reddit_voc_volume.py +157 -69
  29. package/skills/reddit-voc-volume/tests/test_archived_comments.py +104 -0
  30. package/skills/social-hub-admin/SKILL.md +47 -1
  31. package/skills/social-hub-cli/SKILL.md +1 -0
  32. package/skills/social-hub-posts/SKILL.md +14 -1
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=register-feishu-identities.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register-feishu-identities.test.d.ts","sourceRoot":"","sources":["../src/register-feishu-identities.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { classifyHit, evaluateMassNotFound, } from "./register-feishu-identities.js";
3
+ /**
4
+ * `not_found` 会**清空**已有的 open_id(离职语义)。如果 lark-cli 挂在另一个飞书
5
+ * 租户的授权上,全体查不到,一次 `--apply` 就能抹掉所有映射 —— 而这与「公司集体
6
+ * 离职」在数据上不可区分。这道判据是唯一挡在中间的东西,必须被锁住。
7
+ */
8
+ describe("evaluateMassNotFound", () => {
9
+ const previously = new Set(["u1", "u2", "u3", "u4"]);
10
+ it("全部既有 resolved 变 not_found —— 停机", () => {
11
+ const result = evaluateMassNotFound(previously, [...previously].map((userId) => ({
12
+ userId,
13
+ resolveStatus: "not_found",
14
+ })));
15
+ expect(result).toEqual({ regressions: 4, massNotFound: true });
16
+ });
17
+ it("只掉一个人(真的有人离职)—— 放行", () => {
18
+ const result = evaluateMassNotFound(previously, [
19
+ { userId: "u1", resolveStatus: "not_found" },
20
+ { userId: "u2", resolveStatus: "resolved", openId: "ou_a" },
21
+ { userId: "u3", resolveStatus: "resolved", openId: "ou_b" },
22
+ { userId: "u4", resolveStatus: "resolved", openId: "ou_c" },
23
+ ]);
24
+ expect(result).toEqual({ regressions: 1, massNotFound: false });
25
+ });
26
+ /**
27
+ * `lookup_failed` 不清空 open_id,所以「整批查询失败」不该触发这道闸
28
+ * —— 否则真正危险的信号会被日常噪音淹没。
29
+ */
30
+ it("整批 lookup_failed 不算回归", () => {
31
+ const result = evaluateMassNotFound(previously, [...previously].map((userId) => ({
32
+ userId,
33
+ resolveStatus: "lookup_failed",
34
+ lastErrorCode: "token_expired",
35
+ })));
36
+ expect(result).toEqual({ regressions: 0, massNotFound: false });
37
+ });
38
+ it("此前一个都没映射上时不停机(首次同步)", () => {
39
+ const result = evaluateMassNotFound(new Set(), [
40
+ { userId: "u1", resolveStatus: "not_found" },
41
+ { userId: "u2", resolveStatus: "not_found" },
42
+ ]);
43
+ expect(result).toEqual({ regressions: 0, massNotFound: false });
44
+ });
45
+ it("恰好一半不触发,过半才触发(边界)", () => {
46
+ const half = evaluateMassNotFound(new Set(["u1", "u2"]), [
47
+ { userId: "u1", resolveStatus: "not_found" },
48
+ { userId: "u2", resolveStatus: "resolved", openId: "ou_a" },
49
+ ]);
50
+ expect(half.massNotFound).toBe(false);
51
+ const overHalf = evaluateMassNotFound(new Set(["u1", "u2", "u3"]), [
52
+ { userId: "u1", resolveStatus: "not_found" },
53
+ { userId: "u2", resolveStatus: "not_found" },
54
+ { userId: "u3", resolveStatus: "resolved", openId: "ou_a" },
55
+ ]);
56
+ expect(overHalf.massNotFound).toBe(true);
57
+ });
58
+ it("不在既有 resolved 名单里的 not_found 不算回归(本来就没映射)", () => {
59
+ const result = evaluateMassNotFound(new Set(["u1"]), [
60
+ { userId: "u9", resolveStatus: "not_found" },
61
+ { userId: "u8", resolveStatus: "not_found" },
62
+ { userId: "u1", resolveStatus: "resolved", openId: "ou_a" },
63
+ ]);
64
+ expect(result).toEqual({ regressions: 0, massNotFound: false });
65
+ });
66
+ });
67
+ /**
68
+ * 分类判据。只有「飞书压根没返回这个邮箱」才允许写 `not_found`
69
+ * —— 那一条会**清空**既有 open_id。其余"没拿到可用答案"必须是 `lookup_failed`。
70
+ */
71
+ describe("classifyHit", () => {
72
+ it("飞书静默省略该邮箱 → not_found(真的不在通讯录)", () => {
73
+ expect(classifyHit("u1", undefined)).toEqual({
74
+ userId: "u1",
75
+ resolveStatus: "not_found",
76
+ });
77
+ });
78
+ it("命中了人但没有 open_id → lookup_failed,不清空既有映射", () => {
79
+ expect(classifyHit("u1", {})).toEqual({
80
+ userId: "u1",
81
+ resolveStatus: "lookup_failed",
82
+ lastErrorCode: "missing_open_id",
83
+ });
84
+ });
85
+ it("一个邮箱命中多个不同的人 → lookup_failed(歧义,不能挑一个)", () => {
86
+ expect(classifyHit("u1", { ambiguous: true })).toEqual({
87
+ userId: "u1",
88
+ resolveStatus: "lookup_failed",
89
+ lastErrorCode: "ambiguous_match",
90
+ });
91
+ });
92
+ it.each([
93
+ ["注入串", "ou_x></at><at user_id=all"],
94
+ ["缺前缀", "abc123"],
95
+ ["带引号", 'ou_abc"'],
96
+ ])("open_id 畸形(%s) → lookup_failed 而不是写进库", (_label, openId) => {
97
+ expect(classifyHit("u1", { openId })).toMatchObject({
98
+ resolveStatus: "lookup_failed",
99
+ lastErrorCode: "malformed_open_id",
100
+ });
101
+ });
102
+ it("union_id 畸形 → lookup_failed(不退化成只写 open_id)", () => {
103
+ expect(classifyHit("u1", { openId: "ou_ok1", unionId: "on_x></at>" })).toMatchObject({
104
+ resolveStatus: "lookup_failed",
105
+ lastErrorCode: "malformed_union_id",
106
+ });
107
+ });
108
+ it("合法命中 → resolved", () => {
109
+ expect(classifyHit("u1", { openId: "ou_ok1", unionId: "on_ok1" })).toEqual({
110
+ userId: "u1",
111
+ resolveStatus: "resolved",
112
+ openId: "ou_ok1",
113
+ unionId: "on_ok1",
114
+ });
115
+ });
116
+ it("没有 union_id 也能 resolved(该字段可缺)", () => {
117
+ expect(classifyHit("u1", { openId: "ou_ok1" })).toEqual({
118
+ userId: "u1",
119
+ resolveStatus: "resolved",
120
+ openId: "ou_ok1",
121
+ });
122
+ });
123
+ });
124
+ //# sourceMappingURL=register-feishu-identities.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register-feishu-identities.test.js","sourceRoot":"","sources":["../src/register-feishu-identities.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geoly-ai/social-hub-cli",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "type": "module",
5
5
  "description": "social-hub CLI for Social Ops Hub",
6
6
  "repository": {
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "commander": "^12.1.0",
25
- "@geoly-ai/social-hub-authz": "0.0.14",
26
- "@geoly-ai/social-hub-sdk": "0.0.54"
25
+ "@geoly-ai/social-hub-authz": "0.0.17",
26
+ "@geoly-ai/social-hub-sdk": "0.0.55"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.10.2",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
3
  "package": "social-ops-hub",
4
- "cliVersion": "0.3.17",
4
+ "cliVersion": "0.3.18",
5
5
  "repository": "social-ops-hub",
6
6
  "publish": {
7
7
  "registry": "https://skill.sh",
@@ -28,7 +28,7 @@
28
28
  "contentSha256": "234d7fd1d8f2db68444674c20418c836c1b2488305b20399114dec89a0de32fc"
29
29
  },
30
30
  "reddit-voc-volume": {
31
- "contentSha256": "45332556a0835a5c16386cb31550c04ea2447e2326a7a4ff15fd050f1ae91d55"
31
+ "contentSha256": "a5f21cc499688b245cfa3381ffdb88fa681cd5e93f3f1ef68d925b7ae220de2f"
32
32
  },
33
33
  "reddit-brand-risk-response": {
34
34
  "contentSha256": "dbba80345ebe68818a25e7604dfaa6951c6bf0e4d785d5013be7a06b22ec5b7c"
@@ -1,12 +1,7 @@
1
1
  ---
2
2
  name: reddit-voc-volume
3
3
  description: |
4
- 获取 Reddit VOC 声量、历史帖文、**历史评论正文/完整评论树**与公开 URL 证据:按关键词/品牌词搜索、指定 subreddit 扫描、批量链接汇总帖子量/评论量/score/子版分布,**按时间窗回溯 2005 年至今的历史帖子(正文全文,不截断)**,按帖子 URL/ID Arctic Shift 拉取归档评论树或筛选/ID 批查评论正文,读取版规/wiki/subreddit 元数据,并通过 Social Hub 或自托管 Firecrawl 读取公开 About 页周访客/周贡献/周活跃用户兼容字段、Installed Apps、flair filters、通用 page_signals,以及**核验归档 score 与版规是否仍然现行**,输出结构化 JSON
5
- 用户提到 Reddit VOC、品牌词/竞品讨论量、subreddit 声量、帖子评论量统计、**历史评论/评论正文/评论树/thread replies**、舆情采样、批量 Reddit 链接、about/rules/wiki、版规、**历史帖子/往年数据/时间窗回溯/某年某月的帖子/长期趋势**、置顶帖/pinned/sticky/megathread、公开 About 页周指标、Installed Apps、flair filters、页面公开链接信号,或需要**建 subreddit baseline 卡/统计正文长度分布**时必须用本 skill。
6
- v2.12.28 起 reddit `.json` 直采已全部移除:搜索/帖子/about/版规/wiki 一律走 Arctic Shift 归档 API(`history` 模式 + Emergency Acquisition),正文不截断;归档 `score` 带 `score_status`,只有 `Backfilled`/`VerifiedLive` 可用于声量,`--verify-scores` / `--verify-rules` 用 Firecrawl 渲染页核验实时值。**置顶是 live 语义,本地已无采集途径,只能走 Social Hub**,取不到标 NotVisible,不得用归档 stickied 代替。
7
- **历史数据类意图直连 Arctic,不走 Hub-First**:历史帖子 / 往年数据 / 时间窗回溯 / 某年某月 / 长期趋势 / 建 subreddit baseline 卡需历史样本,一律直接路由到 `history` 模式(Arctic Shift 深度归档,权威源,正文不截断),**不经 Hub-First 四级链路**——Hub 只有近实时缓存、不持有深度归档,历史查 Hub 是浪费且拿不到。仅「当下声量」类(`keyword` / `subreddit` 及证据类模式)才走 Hub-First。
8
- Hub-First 链路(仅当下声量/证据类,不含 history/comments):`keyword` / `subreddit` 及证据类模式先查 Social Hub(voc-query/evidence-status),缺口由 Hub 派发(dispatch-on-gap/evidence-fetch)并轮询 runs(partial 定向重派一次);仅 Hub failed/timeout/unreachable 才进入本地 Emergency Acquisition,本地成功结果自动回灌 Hub(hot-posts-create/evidence-import),coverage 满足但读不到 items 时输出 itemsUnavailable 而非假成功。不用于全站爬取、绕过登录墙或读取 mod 后台。
9
- v2.12.31 新增 `comments`:默认 `/api/comments/tree` 拉取最多 25,000 条归档评论并保留层级;也支持 `/api/comments/search` 筛选及 `/api/comments/ids` 最多 500 ID 批查。该模式固定直连 Arctic,不走 Hub-First;归档通常滞后约 2 小时且无 uptime 保证,评论 `score` 不是实时值。
4
+ 获取 Reddit VOC 声量、历史帖子/评论正文与可还原评论树,以及公开 URL、about、rules、wiki 和页面证据,输出结构化 JSON。用户询问关键词/品牌/竞品讨论量、subreddit 声量、帖子评论量、历史帖子/往年/绝对时间窗/长期趋势、历史评论/评论正文/评论树/thread replies、批量 Reddit 链接、版规/wiki/about、置顶帖、flair filters、Installed Apps、周访客/贡献/活跃用户,或建立 subreddit baseline 时使用。历史帖子与 comments 固定直连 Arctic Shift,不走 Hub-First;comments 默认 tree(最多 25,000,保留 parent_id 与树坐标),也支持 search body/author/after/before/parent_id 筛选和 IDs 批查。Arctic 通常滞后约 2 小时、无 uptime 保证,归档评论 score 非实时。当下 keyword/subreddit/证据模式走 Hub-First,缺口才本地 Emergency Acquisition;置顶为 live 语义,只走 Social Hub,取不到标 NotVisible
10
5
  ---
11
6
 
12
7
  # Reddit VOC 声量
@@ -186,7 +181,7 @@ python3 "$VOC" links \
186
181
  若 Arctic 因上限或宽深度折叠返回 `kind=more`,这些节点不会伪装成评论;它们原样进入
187
182
  `collapsed[]`(含 `parent_id` / `children` / `count` / 树坐标),同时
188
183
  `tree_complete=false`。请求失败或响应不是合法的 Arctic `data[]` 评论树时同样输出
189
- `ok=false`、`tree_complete=false`;只有请求成功、结构合法且 `collapsed_count=0`
184
+ `ok=false`、`tree_complete=false`;只有请求成功、结构合法、`collapsed_count=0` 且未触达请求 limit
190
185
  才能把当次结果视为完整树。
191
186
 
192
187
  - `--source search` 调 `/api/comments/search`,上限 100;支持 `--body`(Arctic FTS)、
@@ -28,9 +28,10 @@ Search/subreddit/post samples are preference signals only. They must not be used
28
28
  `comments` bodies are historical VOC / preference evidence only. Consumers may reconstruct
29
29
  the thread from `parent_id`, `depth`, `sibling_index`, `tree_order`, and `tree_path`. They must
30
30
  require `tree_complete=true` before describing a result as the complete thread; otherwise
31
- `collapsed[]` records the unexpanded IDs. Fetch failures and malformed tree payloads explicitly
32
- set `tree_complete=false` even when `collapsed[]` is empty. `score_status=ArchivedSnapshot` and
33
- `score_realtime=false` are binding: archived comment scores cannot support current-hot claims.
31
+ `collapsed[]` records the unexpanded IDs. Fetch failures, malformed tree payloads, and an exact
32
+ tree limit hit explicitly set `tree_complete=false` even when `collapsed[]` is empty.
33
+ `score_status=ArchivedSnapshot` and `score_realtime=false` are binding: archived comment scores
34
+ cannot support current-hot claims.
34
35
 
35
36
  `links` output for `subreddit_rules`, `wiki_page`, and `subreddit_about` can be used
36
37
  as public source evidence for `reddit-subreddit-compliance`. VOC still does not
@@ -53,7 +53,8 @@ Skill 入口统一为 `comments`。默认 tree;扁平 item 保留完整 `body`
53
53
  `tree_path` / `child_ids`。`kind=more` 单独进入 `collapsed[]`;只要存在折叠节点,
54
54
  `tree_complete=false`,不得声称已经拿到完整评论树。请求失败或响应不是合法
55
55
  `data[]` 树时也必须 `ok=false`、`tree_complete=false`,不能因为 `collapsed=[]`
56
- 误报完整。
56
+ 误报完整。若返回的实际评论数恰好触达本次 `limit`,即使没有 `kind=more` 也无法
57
+ 排除上游截断;此时 `tree_limit_reached=true`、`tree_complete=false`,继续保持保守语义。
57
58
 
58
59
  `parent_id` 语义按端点区分:tree 只接受裸/t1_ 评论 ID(取子树);search 的
59
60
  `--parent-id top|root` 会显式发送空 `parent_id=` 以筛顶层评论,不能伪造成 t3_ post ID。
@@ -145,7 +145,7 @@ SOCIAL_HUB_REQUIRED_SKILLS = ("social-hub-cli", "social-hub-shared")
145
145
  HUB_DEFAULT_POLL_TIMEOUT_SEC = 600.0
146
146
  HUB_POLL_BACKOFF_SEC = (5.0, 10.0, 20.0, 30.0)
147
147
  HUB_INTELLIGENCE_TIMEOUT_SEC = 30.0
148
- COLLECTOR_VERSION = "2.12.31"
148
+ COLLECTOR_VERSION = "2.12.32"
149
149
  ACQUISITION_POLICIES = ("hub-first", "hub-only", "self-first")
150
150
  DEFAULT_FLAIR_FILTER_PAGES = ("", "hot", "new", "top")
151
151
 
@@ -3285,7 +3285,7 @@ def arctic_search_posts(
3285
3285
  return [arctic_post_item(r) for r in rows if isinstance(r, dict)]
3286
3286
 
3287
3287
 
3288
- _ARCTIC_BASE36_ID_RE = re.compile(r"^[0-9a-z]+$", re.I)
3288
+ _ARCTIC_BASE36_ID_RE = re.compile(r"^[0-9a-z]{1,32}$", re.I)
3289
3289
 
3290
3290
 
3291
3291
  def normalize_reddit_post_id(target: str) -> str:
@@ -3300,14 +3300,17 @@ def normalize_reddit_post_id(target: str) -> str:
3300
3300
  parsed = urllib.parse.urlparse(raw)
3301
3301
  host = (parsed.hostname or "").lower().rstrip(".")
3302
3302
  if host == "redd.it" or host.endswith(".redd.it"):
3303
- candidate = parsed.path.strip("/").split("/", 1)[0]
3303
+ path_parts = parsed.path.strip("/").split("/")
3304
+ candidate = path_parts[0] if len(path_parts) == 1 else ""
3304
3305
  elif host == "reddit.com" or host.endswith(".reddit.com"):
3305
3306
  match = re.search(r"/comments/([0-9a-z]+)(?:/|$)", parsed.path, re.I)
3306
3307
  candidate = match.group(1) if match else ""
3307
3308
  else:
3308
3309
  raise ValueError(f"unsupported post URL host: {host or '<missing>'}")
3309
- elif "/comments/" in raw:
3310
+ elif re.match(r"^/?(?:r/[^/]+/)?comments/", raw, re.I):
3310
3311
  match = re.search(r"/comments/([0-9a-z]+)(?:/|$)", raw, re.I)
3312
+ if match is None:
3313
+ match = re.search(r"^comments/([0-9a-z]+)(?:/|$)", raw, re.I)
3311
3314
  candidate = match.group(1) if match else ""
3312
3315
  else:
3313
3316
  candidate = raw[3:] if raw.lower().startswith("t3_") else raw
@@ -3361,6 +3364,18 @@ def normalize_comment_parent_id(parent_id: str | None, *, allow_top_level: bool
3361
3364
  return f"t1_{normalize_reddit_comment_id(raw)}"
3362
3365
 
3363
3366
 
3367
+ def _arctic_comment_rows(payload: Any, *, endpoint: str) -> list[dict[str, Any]]:
3368
+ """Require the documented ``{"data": object[]}`` comment response shape."""
3369
+ if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
3370
+ raise ArcticError(f"arctic_comments_{endpoint}_malformed_payload: expected data[]")
3371
+ rows = payload["data"]
3372
+ if any(not isinstance(row, dict) for row in rows):
3373
+ raise ArcticError(
3374
+ f"arctic_comments_{endpoint}_malformed_payload: data must contain object rows"
3375
+ )
3376
+ return rows
3377
+
3378
+
3364
3379
  def arctic_comment_item(
3365
3380
  row: dict[str, Any],
3366
3381
  *,
@@ -3372,10 +3387,15 @@ def arctic_comment_item(
3372
3387
  ) -> dict[str, Any]:
3373
3388
  """Map an Arctic comment row without truncating its archived body."""
3374
3389
  body = row.get("body")
3390
+ if body is not None and not isinstance(body, str):
3391
+ raise ArcticError("arctic_comments_malformed_comment: body must be a string or null")
3375
3392
  body_text = body if isinstance(body, str) else ("" if body is None else str(body))
3376
3393
  body_status = classify_body(body_text)
3377
3394
  measurable = body_status not in ("Removed", "Deleted")
3378
- comment_id = str(row.get("id") or "").removeprefix("t1_").lower()
3395
+ try:
3396
+ comment_id = normalize_reddit_comment_id(str(row.get("id") or ""))
3397
+ except ValueError as exc:
3398
+ raise ArcticError(f"arctic_comments_malformed_comment: {exc}") from exc
3379
3399
  permalink = str(row.get("permalink") or "")
3380
3400
  if permalink.startswith("/"):
3381
3401
  permalink = f"https://www.reddit.com{permalink}"
@@ -3434,63 +3454,93 @@ def flatten_arctic_comment_tree(payload: Any) -> tuple[list[dict[str, Any]], lis
3434
3454
  roots = payload.get("data")
3435
3455
  if any(not isinstance(node, dict) for node in roots):
3436
3456
  raise ArcticError("arctic_comments_tree_malformed_payload: data must contain object nodes")
3437
- nodes = roots
3438
3457
  items: list[dict[str, Any]] = []
3439
3458
  collapsed: list[dict[str, Any]] = []
3440
- order = 0
3441
-
3442
- def walk(children: list[dict[str, Any]], *, depth: int, path: list[int], parent_id: str | None) -> None:
3443
- nonlocal order
3444
- for sibling_index, node in enumerate(children):
3445
- kind = str(node.get("kind") or "")
3446
- data = node.get("data")
3447
- if not isinstance(data, dict):
3448
- raise ArcticError("arctic_comments_tree_malformed_payload: node.data must be an object")
3449
- node_path = [*path, sibling_index]
3450
- node_order = order
3451
- order += 1
3452
- if kind == "more":
3453
- child_ids = [
3454
- normalize_reddit_comment_id(str(value))
3455
- for value in (data.get("children") or [])
3456
- if str(value or "").strip()
3457
- ]
3458
- collapsed.append(
3459
- {
3460
- "kind": "more",
3461
- "id": data.get("id"),
3462
- "parent_id": data.get("parent_id") or parent_id,
3463
- "count": data.get("count"),
3464
- "children": child_ids,
3465
- "depth": depth,
3466
- "tree_order": node_order,
3467
- "sibling_index": sibling_index,
3468
- "tree_path": node_path,
3469
- }
3459
+ seen_comment_ids: set[str] = set()
3460
+ # Iterative preorder traversal: a valid 25,000-comment chain must not hit
3461
+ # Python's recursion limit merely because the archived thread is deep.
3462
+ stack: list[tuple[dict[str, Any], int, list[int], str | None, int]] = [
3463
+ (node, 0, [sibling_index], None, sibling_index)
3464
+ for sibling_index, node in reversed(list(enumerate(roots)))
3465
+ ]
3466
+
3467
+ while stack:
3468
+ node, depth, node_path, parent_id, sibling_index = stack.pop()
3469
+ kind = str(node.get("kind") or "")
3470
+ data = node.get("data")
3471
+ if not isinstance(data, dict):
3472
+ raise ArcticError("arctic_comments_tree_malformed_payload: node.data must be an object")
3473
+ node_order = len(items) + len(collapsed)
3474
+ if kind == "more":
3475
+ raw_child_ids = data.get("children")
3476
+ if raw_child_ids is None:
3477
+ raw_child_ids = []
3478
+ if not isinstance(raw_child_ids, list):
3479
+ raise ArcticError(
3480
+ "arctic_comments_tree_malformed_payload: more.children must be an array"
3470
3481
  )
3471
- continue
3472
- if kind != "t1":
3473
- raise ArcticError(f"arctic_comments_tree_malformed_payload: unexpected kind={kind or '<empty>'}")
3474
-
3475
- replies = _arctic_tree_children(data.get("replies"))
3476
- item = arctic_comment_item(
3477
- data,
3478
- depth=depth,
3479
- tree_order=node_order,
3480
- sibling_index=sibling_index,
3481
- tree_path=node_path,
3482
- tree_parent_id=parent_id,
3482
+ try:
3483
+ child_ids = [normalize_reddit_comment_id(str(value)) for value in raw_child_ids]
3484
+ except ValueError as exc:
3485
+ raise ArcticError(
3486
+ f"arctic_comments_tree_malformed_payload: invalid more child ID: {exc}"
3487
+ ) from exc
3488
+ collapsed.append(
3489
+ {
3490
+ "kind": "more",
3491
+ "id": data.get("id"),
3492
+ "parent_id": data.get("parent_id") or parent_id,
3493
+ "count": data.get("count"),
3494
+ "children": child_ids,
3495
+ "depth": depth,
3496
+ "tree_order": node_order,
3497
+ "sibling_index": sibling_index,
3498
+ "tree_path": node_path,
3499
+ }
3500
+ )
3501
+ continue
3502
+ if kind != "t1":
3503
+ raise ArcticError(
3504
+ f"arctic_comments_tree_malformed_payload: unexpected kind={kind or '<empty>'}"
3505
+ )
3506
+
3507
+ replies = _arctic_tree_children(data.get("replies"))
3508
+ item = arctic_comment_item(
3509
+ data,
3510
+ depth=depth,
3511
+ tree_order=node_order,
3512
+ sibling_index=sibling_index,
3513
+ tree_path=node_path,
3514
+ tree_parent_id=parent_id,
3515
+ )
3516
+ if item["id"] in seen_comment_ids:
3517
+ raise ArcticError(
3518
+ f"arctic_comments_tree_malformed_payload: duplicate comment ID {item['id']}"
3483
3519
  )
3520
+ seen_comment_ids.add(item["id"])
3521
+ try:
3484
3522
  item["child_ids"] = [
3485
- str(child.get("data", {}).get("id") or "").removeprefix("t1_").lower()
3523
+ normalize_reddit_comment_id(str(child["data"].get("id") or ""))
3486
3524
  for child in replies
3487
3525
  if child.get("kind") == "t1" and isinstance(child.get("data"), dict)
3488
3526
  ]
3489
- items.append(item)
3490
- current_parent = str(data.get("name") or (f"t1_{item['id']}" if item.get("id") else "")) or None
3491
- walk(replies, depth=depth + 1, path=node_path, parent_id=current_parent)
3527
+ except ValueError as exc:
3528
+ raise ArcticError(
3529
+ f"arctic_comments_tree_malformed_payload: invalid child comment ID: {exc}"
3530
+ ) from exc
3531
+ items.append(item)
3532
+ current_parent = str(data.get("name") or f"t1_{item['id']}")
3533
+ for child_index, child in reversed(list(enumerate(replies))):
3534
+ stack.append(
3535
+ (
3536
+ child,
3537
+ depth + 1,
3538
+ [*node_path, child_index],
3539
+ current_parent,
3540
+ child_index,
3541
+ )
3542
+ )
3492
3543
 
3493
- walk(nodes, depth=0, path=[], parent_id=None)
3494
3544
  return items, collapsed
3495
3545
 
3496
3546
 
@@ -3516,7 +3566,13 @@ def arctic_comments_tree(
3516
3566
  user_agent=user_agent,
3517
3567
  tracker=tracker,
3518
3568
  )
3519
- return flatten_arctic_comment_tree(payload)
3569
+ items, collapsed = flatten_arctic_comment_tree(payload)
3570
+ expected_link_id = f"t3_{post_id}"
3571
+ if any(str(item.get("link_id") or "").lower() != expected_link_id for item in items):
3572
+ raise ArcticError(
3573
+ "arctic_comments_tree_malformed_payload: comment link_id does not match requested post"
3574
+ )
3575
+ return items, collapsed
3520
3576
 
3521
3577
 
3522
3578
  def arctic_search_comments(
@@ -3548,14 +3604,17 @@ def arctic_search_comments(
3548
3604
  tracker=tracker,
3549
3605
  keep_empty_params={"parent_id"} if parent_id == "" else None,
3550
3606
  )
3551
- rows = payload.get("data")
3552
- if not isinstance(rows, list):
3553
- return []
3554
- return [
3555
- arctic_comment_item(row, tree_order=index)
3556
- for index, row in enumerate(rows)
3557
- if isinstance(row, dict)
3558
- ]
3607
+ rows = _arctic_comment_rows(payload, endpoint="search")
3608
+ items = [arctic_comment_item(row, tree_order=index) for index, row in enumerate(rows)]
3609
+ expected_link_id = f"t3_{post_id}"
3610
+ if any(str(item.get("link_id") or "").lower() != expected_link_id for item in items):
3611
+ raise ArcticError(
3612
+ "arctic_comments_search_malformed_payload: comment link_id does not match requested post"
3613
+ )
3614
+ item_ids = [str(item["id"]) for item in items]
3615
+ if len(item_ids) != len(set(item_ids)):
3616
+ raise ArcticError("arctic_comments_search_malformed_payload: duplicate comment IDs")
3617
+ return items
3559
3618
 
3560
3619
 
3561
3620
  def arctic_comments_by_ids(
@@ -3570,12 +3629,24 @@ def arctic_comments_by_ids(
3570
3629
  user_agent=user_agent,
3571
3630
  tracker=tracker,
3572
3631
  )
3573
- rows = payload.get("data")
3574
- by_id = {
3575
- str(row.get("id") or "").removeprefix("t1_").lower(): row
3576
- for row in (rows if isinstance(rows, list) else [])
3577
- if isinstance(row, dict)
3578
- }
3632
+ rows = _arctic_comment_rows(payload, endpoint="ids")
3633
+ by_id: dict[str, dict[str, Any]] = {}
3634
+ for row in rows:
3635
+ try:
3636
+ item_id = normalize_reddit_comment_id(str(row.get("id") or ""))
3637
+ except ValueError as exc:
3638
+ raise ArcticError(f"arctic_comments_ids_malformed_payload: {exc}") from exc
3639
+ if item_id in by_id:
3640
+ raise ArcticError(
3641
+ f"arctic_comments_ids_malformed_payload: duplicate comment ID {item_id}"
3642
+ )
3643
+ by_id[item_id] = row
3644
+ unexpected_ids = sorted(set(by_id).difference(comment_ids))
3645
+ if unexpected_ids:
3646
+ raise ArcticError(
3647
+ "arctic_comments_ids_malformed_payload: unexpected comment IDs "
3648
+ + ",".join(unexpected_ids[:10])
3649
+ )
3579
3650
  items = [
3580
3651
  arctic_comment_item(by_id[comment_id], tree_order=index)
3581
3652
  for index, comment_id in enumerate(comment_ids)
@@ -4991,6 +5062,7 @@ def main() -> int:
4991
5062
  missing_ids: list[str] = []
4992
5063
  items: list[dict[str, Any]] = []
4993
5064
  tree_fetch_succeeded = False
5065
+ tree_limit_reached = False
4994
5066
  try:
4995
5067
  if retrieval_mode == "ids":
4996
5068
  items, missing_ids = arctic_comments_by_ids(
@@ -5022,6 +5094,9 @@ def main() -> int:
5022
5094
  tracker=tracker,
5023
5095
  )
5024
5096
  tree_fetch_succeeded = True
5097
+ # At the exact cap, absence of `kind=more` is not proof that the
5098
+ # upstream did not truncate. Completeness must remain fail-closed.
5099
+ tree_limit_reached = len(items) >= limit
5025
5100
  except ArcticError as exc:
5026
5101
  errors.append(f"arctic_comments_failed:{exc}"[:300])
5027
5102
  if missing_ids:
@@ -5038,6 +5113,11 @@ def main() -> int:
5038
5113
  f"评论树含 {len(collapsed)} 个 kind=more 折叠节点;"
5039
5114
  "tree_complete=false,collapsed[].children 保留尚未展开的评论 ID。"
5040
5115
  )
5116
+ if tree_limit_reached:
5117
+ notes.append(
5118
+ f"评论数已触达本次 tree limit={limit};即使没有 kind=more,也不能证明树完整,"
5119
+ "tree_complete=false。"
5120
+ )
5041
5121
  fetched_at = utc_now_iso()
5042
5122
  canonical_source = f"https://www.reddit.com/comments/{post_id}/" if post_id else None
5043
5123
  fingerprint = hashlib.sha256(
@@ -5081,7 +5161,12 @@ def main() -> int:
5081
5161
  "sort": args.sort or ("asc" if retrieval_mode == "search" else None),
5082
5162
  },
5083
5163
  "count": len(items),
5084
- "tree_complete": tree_fetch_succeeded and not collapsed if retrieval_mode == "tree" else None,
5164
+ "tree_complete": (
5165
+ tree_fetch_succeeded and not collapsed and not tree_limit_reached
5166
+ if retrieval_mode == "tree"
5167
+ else None
5168
+ ),
5169
+ "tree_limit_reached": tree_limit_reached if retrieval_mode == "tree" else None,
5085
5170
  "collapsed_count": len(collapsed),
5086
5171
  "collapsed": collapsed,
5087
5172
  "body_stats": {
@@ -5099,6 +5184,9 @@ def main() -> int:
5099
5184
  "requested_limit": len(comment_ids) if retrieval_mode == "ids" else limit,
5100
5185
  "effective_limit": len(comment_ids) if retrieval_mode == "ids" else limit,
5101
5186
  "http_requests": tracker.count,
5187
+ "truncated": bool(collapsed or tree_limit_reached)
5188
+ if retrieval_mode == "tree"
5189
+ else False,
5102
5190
  "rate_limited": tracker.rate_limited,
5103
5191
  },
5104
5192
  "fetched_at": fetched_at,