@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.
- package/CHANGELOG.md +11 -0
- package/dist/cmd-manifest.json +5 -2
- package/dist/cmd-manifest.test.js +3 -0
- package/dist/cmd-manifest.test.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/permissions-gates-admin.d.ts.map +1 -1
- package/dist/permissions-gates-admin.js +12 -0
- package/dist/permissions-gates-admin.js.map +1 -1
- package/dist/register-extensions.d.ts.map +1 -1
- package/dist/register-extensions.js +15 -1
- package/dist/register-extensions.js.map +1 -1
- package/dist/register-feishu-identities.d.ts +47 -0
- package/dist/register-feishu-identities.d.ts.map +1 -0
- package/dist/register-feishu-identities.js +346 -0
- package/dist/register-feishu-identities.js.map +1 -0
- package/dist/register-feishu-identities.test.d.ts +2 -0
- package/dist/register-feishu-identities.test.d.ts.map +1 -0
- package/dist/register-feishu-identities.test.js +124 -0
- package/dist/register-feishu-identities.test.js.map +1 -0
- package/package.json +3 -3
- package/skills/manifest.json +1 -1
- package/skills/reddit-matrix.lock.json +1 -1
- package/skills/reddit-voc-volume/SKILL.md +2 -7
- package/skills/reddit-voc-volume/matrix-contract.md +4 -3
- package/skills/reddit-voc-volume/references/arctic-shift.md +2 -1
- package/skills/reddit-voc-volume/scripts/reddit_voc_volume.py +157 -69
- package/skills/reddit-voc-volume/tests/test_archived_comments.py +104 -0
- package/skills/social-hub-admin/SKILL.md +47 -1
- package/skills/social-hub-cli/SKILL.md +1 -0
- package/skills/social-hub-posts/SKILL.md +14 -1
|
@@ -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.
|
|
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.
|
|
26
|
-
"@geoly-ai/social-hub-sdk": "0.0.
|
|
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",
|
package/skills/manifest.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"contentSha256": "234d7fd1d8f2db68444674c20418c836c1b2488305b20399114dec89a0de32fc"
|
|
29
29
|
},
|
|
30
30
|
"reddit-voc-volume": {
|
|
31
|
-
"contentSha256": "
|
|
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
|
|
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
|
|
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
|
|
32
|
-
set `tree_complete=false` even when `collapsed[]` is empty.
|
|
33
|
-
`score_realtime=false` are binding: archived comment scores
|
|
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.
|
|
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]
|
|
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
|
-
|
|
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/"
|
|
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
|
-
|
|
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
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
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
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
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
|
|
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
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
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
|
-
|
|
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
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
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
|
|
3574
|
-
by_id = {
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
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":
|
|
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,
|