@geoly-ai/social-hub-cli 0.3.42 → 0.3.44

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.
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import importlib.util
5
+ import io
6
+ import json
7
+ from pathlib import Path
8
+ import sys
9
+ import unittest
10
+ from unittest import mock
11
+
12
+
13
+ SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "reddit_voc_volume.py"
14
+ SPEC = importlib.util.spec_from_file_location("reddit_voc_links_under_test", SCRIPT)
15
+ assert SPEC is not None and SPEC.loader is not None
16
+ voc = importlib.util.module_from_spec(SPEC)
17
+ SPEC.loader.exec_module(voc)
18
+ POST_URL = "https://www.reddit.com/r/example/comments/abc123/slug/"
19
+
20
+
21
+ class LinksFallbackTests(unittest.TestCase):
22
+ def run_main(self, *args: str) -> tuple[int, dict]:
23
+ stdout = io.StringIO()
24
+ with mock.patch.object(sys, "argv", [str(SCRIPT), *args]), contextlib.redirect_stdout(stdout):
25
+ code = voc.main()
26
+ return code, json.loads(stdout.getvalue())
27
+
28
+ def test_arctic_miss_uses_rendered_page_without_loading_browser_cookies(self) -> None:
29
+ with (
30
+ mock.patch.object(voc.CookieStore, "get", side_effect=AssertionError("browser cookies loaded")) as cookies,
31
+ mock.patch.object(voc, "arctic_post_by_permalink", return_value=None) as archive,
32
+ mock.patch.object(voc, "fetch_page_scrape", return_value={"markdown": "Public page"}) as page,
33
+ mock.patch.object(voc, "collect_subreddit_metrics", return_value=[]),
34
+ mock.patch.object(voc, "hub_writeback_items", return_value={"status": "pending"}),
35
+ mock.patch.object(voc, "sleep_with_jitter", return_value=0.0),
36
+ ):
37
+ code, out = self.run_main("links", POST_URL, "--fallback", "auto", "--sleep-sec", "0")
38
+
39
+ self.assertEqual(code, 0)
40
+ self.assertTrue(out["ok"])
41
+ self.assertEqual(out["items"][0]["item_type"], "firecrawl_page")
42
+ self.assertEqual(out["errors"], [])
43
+ archive.assert_called_once()
44
+ self.assertEqual(page.call_args.kwargs["provider"], "social-hub")
45
+ cookies.assert_not_called()
46
+
47
+ def test_arctic_miss_without_fallback_reports_archive_not_json(self) -> None:
48
+ with (
49
+ mock.patch.object(voc.CookieStore, "get", side_effect=AssertionError("browser cookies loaded")) as cookies,
50
+ mock.patch.object(voc, "arctic_post_by_permalink", side_effect=RuntimeError("archive unavailable")),
51
+ mock.patch.object(voc, "collect_subreddit_metrics", return_value=[]),
52
+ mock.patch.object(voc, "hub_writeback_items", return_value={"status": "not_applicable"}),
53
+ mock.patch.object(voc, "sleep_with_jitter", return_value=0.0),
54
+ ):
55
+ code, out = self.run_main("links", POST_URL, "--fallback", "none", "--sleep-sec", "0")
56
+
57
+ self.assertEqual(code, 1)
58
+ self.assertFalse(out["ok"])
59
+ self.assertTrue(any("archive unavailable" in error for error in out["errors"]))
60
+ self.assertTrue(any("Arctic lookup failed" in note for note in out["notes"]))
61
+ cookies.assert_not_called()
62
+
63
+ def test_auto_fallback_tries_firecrawl_after_social_hub_failure(self) -> None:
64
+ def page_result(_url: str, *, provider: str, **_kwargs: object) -> dict:
65
+ if provider == "social-hub":
66
+ raise RuntimeError("render unavailable")
67
+ return {"markdown": "Public page"}
68
+
69
+ with (
70
+ mock.patch.object(voc.CookieStore, "get", side_effect=AssertionError("browser cookies loaded")) as cookies,
71
+ mock.patch.object(voc, "arctic_post_by_permalink", return_value=None),
72
+ mock.patch.object(voc, "fetch_page_scrape", side_effect=page_result) as page,
73
+ mock.patch.object(voc, "social_hub_unavailable_hint", return_value="Hub unavailable"),
74
+ mock.patch.object(voc, "collect_subreddit_metrics", return_value=[]),
75
+ mock.patch.object(voc, "hub_writeback_items", return_value={"status": "pending"}),
76
+ mock.patch.object(voc, "sleep_with_jitter", return_value=0.0),
77
+ ):
78
+ code, out = self.run_main("links", POST_URL, "--fallback", "auto", "--sleep-sec", "0")
79
+
80
+ self.assertEqual(code, 0)
81
+ self.assertEqual(out["items"][0]["page_provider"], "firecrawl")
82
+ self.assertEqual([call.kwargs["provider"] for call in page.call_args_list], ["social-hub", "firecrawl"])
83
+ cookies.assert_not_called()
84
+
85
+
86
+ if __name__ == "__main__":
87
+ unittest.main()
@@ -42,6 +42,10 @@ worker(单实例 cron)按 active spec 对缺当前 revision 成功 run 的
42
42
  这些 run 可能对应帖子的**旧 revision**。要判断某帖「当前」被分成什么,只能用
43
43
  `get-subject`(精确唯一键查询,proposed / missing 二态)。**不要拿 `recent` 的
44
44
  第一条当 latest 用。**
45
+ - **`recent` 没有分页,返回的是截断后的一页**:`count` / `returned` 是**本页条数,不是总数**;
46
+ `truncated: true` 表示还有更多(服务端多取一条判定,精确),`selectionLimit` 是本次生效上限。
47
+ `--limit` 未到 200 时可以调大(上限 200);**已是 200 仍 `truncated: true`,说明这个 recent 视图枚举不完余项**,
48
+ **不要自己拼 offset**——这个端点根本不接受分页参数。
45
49
 
46
50
  ## 读面
47
51
 
@@ -259,11 +259,32 @@ watchlist 须显式传 `brandIds`。
259
259
  ```bash
260
260
  social-hub intelligence monitors-list [--enabled true|false] [--watchlist <uuid>]
261
261
  social-hub intelligence monitors-enable --watchlist <uuid> [--fetch-limit 100]
262
+ social-hub intelligence monitors-update --watchlist <uuid> --fetch-limit 50 # 只改配置,不碰启停
262
263
  social-hub intelligence monitors-disable --watchlist <uuid> [--purge]
263
264
  social-hub intelligence monitors-run # 手工触发一轮(异步入队)
264
265
  social-hub intelligence monitor-runs-list [--status degraded] [-n 20]
265
266
  ```
266
267
 
268
+ ### 🔴 改抓取上限用 `monitors-update`,⛔ 不要用 `monitors-enable`
269
+
270
+ `monitors-enable` 是个 **upsert**,它的 `DO UPDATE` 里写死了 `enabled = true` ——
271
+ 所以拿它去改一个**已停止**监听的 `--fetch-limit`,会把那个监听**静默重新启用**,
272
+ 该板块的新帖会立刻重新推给负责人。
273
+
274
+ `monitors-update` 背后的 SQL 里**根本不出现 `enabled` 列**,所以它没有能力改启停:
275
+ 停止状态下改完仍是停止,启用状态下改完仍是启用。
276
+
277
+ | 我要做的事 | 用哪个命令 |
278
+ | ------------------- | ----------------------------------- |
279
+ | 加入监听 / 重新启用 | `monitors-enable` |
280
+ | **只改抓取上限** | `monitors-update`(启停状态原样不动) |
281
+ | 停止监听(软停) | `monitors-disable` |
282
+
283
+ ⚠️ `monitors-update` **不会创建**监听:该 watchlist 从没配置过监听时返回 404
284
+ (要新建请显式用 `monitors-enable`)。
285
+ ⚠️ `--fetch-limit` 两个命令同一套边界:整数 1–100。
286
+ ⚠️ `sort` **没有任何写入面**,两个命令都不收 —— DDL 的 CHECK 只允许 `'new'` 一个值。
287
+
267
288
  **停止监听 = 软停**(`enabled=false`),保留观察 ledger,重启后已报告过的帖子不会重复报告。
268
289
  `--purge` 是另一个动作:**连历史观察一起硬删**,只有要删掉整个 watchlist 时才需要
269
290
  (monitor 配置对 watchlist 的外键是 RESTRICT,不先 purge 就删不掉)——purge 后重新监听
@@ -278,7 +299,8 @@ social-hub intelligence monitor-runs-list [--status degraded] [-n 20]
278
299
  social-hub intelligence monitor-ruleset-create --monitor <uuid> --lookback 1440 \
279
300
  --note "第一版:品牌词 + 竞品词" \
280
301
  -j '[{"term":"aporro","scope":"both","matchMode":"word","weight":3},
281
- {"term":"cold brew","scope":"body","matchMode":"phrase"}]'
302
+ {"term":"cold brew","scope":"body","matchMode":"phrase"},
303
+ {"term":"espresso martini","polarity":"exclude","matchMode":"phrase"}]'
282
304
 
283
305
  # 2) 🔴 试跑:拿过去 24h 的历史帖子跑一遍,看会命中什么(只读,不发任何通知)
284
306
  social-hub intelligence monitor-ruleset-dry-run --monitor <uuid> --rule-set <uuid> --lookback 1440
@@ -301,7 +323,8 @@ social-hub intelligence monitor-brand-keyword-candidates --monitor <uuid> [--bra
301
323
  1. **active 之后不可原地修改,只能新建版本**(DB 触发器焊死)。改词的正确流程是
302
324
  create draft → dry-run → activate,不是去改已激活的那一版。
303
325
  2. **激活/停用不会重发历史命中**。观察的水位在被评估的那一轮就推进了,**无论命中与否** ——
304
- 否则调宽一次关键词,负责人就被历史帖子刷一次屏。要追溯旧帖需要显式 replay(尚未实现)。
326
+ 否则调宽一次关键词,负责人就被历史帖子刷一次屏。要看「新规则放回历史窗口会怎样」,
327
+ 用 `monitor-replay-run`(见下面「回放」)—— 它**同样不发通知**。
305
328
  3. **停用 ≠ 静音**。retire 之后该监听回到「不设关键词门」,报告**恢复全量新帖**。
306
329
  要减少推送就改规则,不要指望 retire。
307
330
  4. **词边界**:`matchMode=word` 时 `art` **不会**命中 `earth`,但会命中 `art-house`。
@@ -321,9 +344,82 @@ social-hub intelligence monitor-brand-keyword-candidates --monitor <uuid> [--bra
321
344
  watchlist 与品牌是多对多,若监听直接读品牌词并集,「给某品牌加一个词」或「把品牌绑到
322
345
  这个 watchlist」都会让**监听范围悄然扩大**,而运营没做过任何监听侧的动作。
323
346
 
324
- **卡片上「未发现新帖」要分三种读法**(0168 起卡片会自己区分,别再一律当成「没动静」):
347
+ **卡片上「未发现新帖」要分五种读法**(卡片会自己区分,别再一律当成「没动静」):
325
348
  覆盖完整且没被过滤 ⇒ 真的没新帖;板块疑似截断 / 拿不到分页游标 ⇒ **采集没抓全**,
326
- 按采集问题排查;全被关键词门筛掉 ⇒ 去看规则集。
349
+ 按采集问题排查;全被**排除词**否掉 ⇒ 去看排除词是不是配宽了;
350
+ 全都没命中 include 词 ⇒ 去看 include 词是不是配窄了;
351
+ 正文补抓全部失败 ⇒ **采集问题**,关键词只在正文里的帖子这轮判不了。
352
+ 🔴 后两组的处置方向**相反**(放宽 vs 收窄),所以卡片刻意分开表述,别当成同一句话。
353
+
354
+ ### 排除词 / 布尔规则(0171)
355
+
356
+ 规则对象加 `"polarity":"exclude"` 就是排除词(缺省 `include`)。
357
+
358
+ ```bash
359
+ # espresso 收进来,但 espresso martini 排出去 —— 两条可以共存(唯一键含 polarity)
360
+ -j '[{"term":"espresso","scope":"both","matchMode":"word"},
361
+ {"term":"espresso martini","polarity":"exclude","matchMode":"phrase"}]'
362
+ ```
363
+
364
+ **四条口径**:
365
+
366
+ 1. **exclude 只撤销,自己不产生命中**。所以**一条 include 都没有的规则集不许激活**
367
+ (409 `monitor_rule_set_exclude_only`)—— 那等于把整个板块静音,而卡片上看起来
368
+ 只是「今天没有相关新帖」。
369
+ 2. **撤销按来源优先级由低到高**:`manual`(30) > `brand_keyword`(20) > `expansion`(10)。
370
+ 一条 exclude 只能撤销**优先级不高于它自己**的 include 命中 —— 近义词扩展出来的
371
+ 否定词压不住运营亲手填的 include 词。同源的一对(都是 manual)**可以**互相作用。
372
+ 3. **被排除掉 ≠ 没命中**。dry-run 与回放都把它们分成 `excluded` / `no_match` 两个数,
373
+ 卡片上也是两句话。⛔ 别合并看:前者去看排除词是不是配宽了,后者去看 include 词
374
+ 是不是配窄了 —— 方向相反。
375
+ 4. **正文拿不到时排除判定也可能不完整**:有 `scope=body` 的排除词而正文没采到时,
376
+ 帖子**照发**,但卡片标注「正文缺失,排除词未能完整判定(可能本应被排除)」。
377
+ 取向与 include 侧一致:拿不准时不敢当成已排除,那会漏报。
378
+
379
+ ⚠️ **加了排除词一定先 dry-run**。一条配宽的排除词能把整个板块吃掉,
380
+ 而通知上看起来只是「今天没有相关新帖」。
381
+
382
+ ### 回放:改完规则想看效果,别等下一轮 cron(0171)
383
+
384
+ ```bash
385
+ # 把某版规则(缺省 active;可传 draft)放回指定历史窗口重判
386
+ social-hub intelligence monitor-replay-run --monitor <uuid> \
387
+ --from 2026-09-01T00:00:00Z --to 2026-09-08T00:00:00Z \
388
+ [--rule-set <draft-uuid>] [-n 500] [--note "第 3 版排除词效果"]
389
+
390
+ social-hub intelligence monitor-replays-list --monitor <uuid>
391
+ social-hub intelligence monitor-replay-get --monitor <uuid> --replay <uuid> \
392
+ [--decision matched|indeterminate|excluded|no_match]
393
+ ```
394
+
395
+ 🔴 **回放不写命中账本、不推进投递水位、不发任何通知**。它算的是「如果当时用这套规则
396
+ 会怎样」这个**假设**,结果落在独立的回放表上 —— 原始命中账本(「当初按哪版规则、
397
+ 因为什么命中」这个审计事实)一个字都不会被改。响应里的 `sideEffects` 显式声明了这三条。
398
+
399
+ **差分怎么读**(`diff`,回放的产品价值就在这里):
400
+
401
+ | 字段 | 含义 | 怎么用 |
402
+ | -------------------- | ------------------------------ | ----------------------------- |
403
+ | `wouldStopReporting` | 当初报过、新规则下不再报 | 排除词的收益;顺便看有没有误杀 |
404
+ | `wouldNewlyReport` | 当初**被筛掉**、新规则下会报 | 新加的 include 词的收益 |
405
+ | `neverObserved` | 当时**根本没被这个监听观察到** | ⛔ **不是**「规则漏了」 |
406
+
407
+ 🔴 `neverObserved` 单列的理由:命中账本对「不命中」**一条都不写**,所以「当初没有账本行」
408
+ 既可能是被规则筛掉、也可能是监听那时还没开(或这条帖子是 VOC / 回灌进库的)。
409
+ 把两者合并成一句「原来没报」,会让「监听还没开」被读成「规则漏了」。
410
+
411
+ **与 dry-run 的分工**:dry-run 是**不落库**的即时预览、窗口只能取 720/1440;
412
+ 回放**落库**、窗口任意(最长 30 天)、并给出上面那份差分。要拿去做上线决策的用回放。
413
+
414
+ ### 正文补抓(0171)
415
+
416
+ listing 走渲染兜底时经常拿不到正文,采集端如实标 `unknown`,匹配侧据此判 `missing`。
417
+ 0171 起,当该板块**确实有 `scope=body`/`both` 的规则**时,系统会对这些帖子回头补抓一次
418
+ (走既有的 Firecrawl 兜底链,每轮硬顶 12 条、看 deadline、失败不影响报告)。
419
+
420
+ 卡片覆盖行会给出「候选 N 条 · 补到 M 条 · 仍拿不到 K 条」。
421
+ 🔴 **只报成功数是不够的**:「12 条候选补了 0 条」与「根本没有候选」在通知上必须能分辨 ——
422
+ 前者意味着关键词只出现在正文里的帖子这一轮判不了,是**采集问题**。
327
423
 
328
424
  ### 近义词扩展(0169)
329
425
 
@@ -529,4 +625,6 @@ WebSocket `runs/ws` 为 watch 能力,不适合普通 CLI 批处理。
529
625
 
530
626
  ## 通用网页抓取
531
627
 
532
- 本 skill 只覆盖**系统内部**的 Reddit 情报采集(落库 + 聚合,走 worker 直连 curl_cffi + 住宅代理)。若要**按任意 URL 临时抓取**(Reddit `.json` 走直连、HTML/普通网页走 Firecrawl),用独立的 **`social-hub-scrape`** skill(`social-hub scrape url …`)。
628
+ 本 skill 只覆盖**系统内部**的 Reddit 情报采集(落库 + 聚合)。worker 侧仍先试 `.json`(curl_cffi + 住宅代理),**但 `.json` 已被 Reddit 全面拦截**(2026-09-18 实测住宅代理同样 403)——真正出数的是封禁熔断器之后的 **Firecrawl 渲染兜底**(需 worker 配 `FIRECRAWL_BASE_URL`,`REDDIT_FIRECRAWL_FALLBACK_ENABLED` 不显式关即生效)。
629
+
630
+ 若要**按任意 URL 临时抓取非 Reddit 页面**(HTML/普通网页走 Firecrawl),用独立的 **`social-hub-scrape`** skill(`social-hub scrape url …`)。⛔ **不要用它抓 Reddit**:该端点的 `.json` 直连与 Firecrawl 渲染两条路都被拦(见该 skill 顶部红字)。
@@ -129,22 +129,27 @@ social-hub query subreddit-candidates -t <team-id> --account A1 --action browse
129
129
 
130
130
  **响应要点**(`subreddit-candidates`):
131
131
 
132
- | 字段 | 说明 |
133
- | ----------------------------------------- | ---------------------------------------------------------------------------------------- |
134
- | `items[].source` | `system_pool`、`persona.*`、`brand.binding`;`fallback_team_pool` 仅表示是否用系统池补足 |
135
- | `items[].sourceScope` | `system`(全局 catalog)或 `account`(人设/品牌展开) |
136
- | `items[].poolKind` | `pet` / `common` / `industry` / `brand` / `direct` / `other` |
137
- | `systemPools.expandedByKind` | 系统级 pet/common/industry/brand 汇总(选板前先看) |
138
- | `items[].subreddit` | 可直接消费的 `r/Subreddit` 格式 |
139
- | `items[].selectionWeight` | 加权随机用;**禁止直接取 `items[0]`** |
140
- | `items[].cooldownUntil` | 未过期时禁止 `comment`/`reply` |
141
- | `items[].allowedActions` | 当前动作允许的操作 |
142
- | `personaPools.expandedByKind` | 按来源汇总:`direct` / `common` / `industry` / `pet` / `brand` 的 `r/...` 列表 |
143
- | `personaPools.expandedPools[].subreddits` | 每个 pool ref 展开后的具体板块(可多条) |
144
- | `personaPools.brandPools` | 账号品牌绑定及对应 `brand_pools.*` ref 展开结果 |
145
- | `personaPools.unresolvedRefs` | alias 表无法解析的 ref;正常 seed 后 P01-P57 的 `starter/advanced/pet_pool` 不应出现 |
146
- | `fallback.reason` | 如 `candidate_pool_exhausted` 须在 Agent 回执说明 |
147
- | `exhaustion.reason` | **`items` 为空时是哪一类空** —— 用它判「要不要惊动人」,见下 |
132
+ | 字段 | 说明 |
133
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
134
+ | `items[].source` | `system_pool`、`persona.*`、`brand.binding`;`fallback_team_pool` 仅表示是否用系统池补足 |
135
+ | `items[].sourceScope` | `system`(全局 catalog)或 `account`(人设/品牌展开) |
136
+ | `items[].poolKind` | `pet` / `common` / `industry` / `brand` / `direct` / `other` |
137
+ | `systemPools.expandedByKind` | 系统级 pet/common/industry/brand 汇总(选板前先看) |
138
+ | `items[].subreddit` | 可直接消费的 `r/Subreddit` 格式 |
139
+ | `items[].selectionWeight` | 加权随机用;**禁止直接取 `items[0]`** |
140
+ | `items[].cooldownUntil` | 未过期时禁止 `comment`/`reply` |
141
+ | `items[].allowedActions` | 当前动作允许的操作 |
142
+ | `personaPools.expandedByKind` | 按来源汇总:`direct` / `common` / `industry` / `pet` / `brand` 的 `r/...` 列表 |
143
+ | `personaPools.expandedPools[].subreddits` | 每个 pool ref 展开后的具体板块(可多条) |
144
+ | `personaPools.brandPools` | 账号品牌绑定及对应 `brand_pools.*` ref 展开结果 |
145
+ | `personaPools.unresolvedRefs` | alias 表无法解析的 ref;正常 seed 后 P01-P57 的 `starter/advanced/pet_pool` 不应出现 |
146
+ | `fallback.reason` | 如 `candidate_pool_exhausted` 须在 Agent 回执说明 |
147
+ | `exhaustion.reason` | **`items` 为空时是哪一类空** —— 用它判「要不要惊动人」,见下 |
148
+ | `selectionLimit` / `returned` | 本次生效的 limit(含默认值)与实际返回条数(= `items.length`) |
149
+ | `truncated` | **有候选被截掉了**。分档配额 / limit 造成的:调大 `--limit` 或收窄 `--industry`/`--tier`;单 key 行数上限(500)造成的:上游固定帽,调 limit 补不全,只能收窄 industry 或找人处理。评论动作下配额阶段尚未过冷却/affinity,可能偏多报 |
150
+
151
+ `account-pools` 同样返回 `selectionLimit` / `returned` / `truncated`(口径一致,不带 `exhaustion`)。
152
+ ⛔ `truncated: false` 只说明「没被截断」,**不**说明池子够用。
148
153
 
149
154
  ### `items: []` 到底是哪一类空(`exhaustion`)
150
155
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: social-hub-scrape
3
3
  description: >-
4
- 通用网页抓取:HTML/普通网页经自托管 Firecrawl 返回 markdown/html/rawHtml/links;
5
- Reddit .json 走 API 内直连(curl_cffi + 住宅代理 + Chrome TLS 指纹)返回结构化 JSON。
6
- JSON 和 HTML 都能抓。用户要抓某个网页、取证、拉页面正文/链接、拉 Reddit .json,
7
- 或说 social-hub scrape 时使用。不套系统内部的 Reddit 采集/情报逻辑
8
- (那些用 social-hub-intelligence);不用于绕过登录墙/验证码,也不做全站爬取。
4
+ 通用网页抓取:HTML/普通网页经自托管 Firecrawl 返回 markdown/html/rawHtml/links。
5
+ ⛔ Reddit 不要用本端点:.json 直连已被 Reddit 全面拦截(2026-09-18 实测住宅代理同样 403),
6
+ 本端点对 Reddit 的两条路径(.json 直连、Firecrawl 渲染)都取不到数据,要 Reddit 数据走
7
+ social-hub-intelligence 的采集链路。用户要抓某个非 Reddit 网页、取证、拉页面正文/链接,
8
+ 或说 social-hub scrape 时使用;不用于绕过登录墙/验证码,也不做全站爬取。
9
9
  metadata:
10
10
  cliVersion: ">=0.0.12"
11
11
  ---
@@ -14,7 +14,11 @@ metadata:
14
14
 
15
15
  > **前置条件:** 先读 [`../social-hub-shared/SKILL.md`](../social-hub-shared/SKILL.md),完成 `auth login`。
16
16
 
17
- **按你给的 URL 抓**,不套系统内部的 Reddit 采集逻辑。适合临时取证、抓任意公开页面正文/链接,或拉 Reddit `.json` 结构化数据。端点自动分流:**Reddit `.json` 走直连 curl_cffi + 住宅代理 + Chrome TLS 指纹**(Reddit 反爬浏览器,Firecrawl 拿不了 .json),**HTML/普通网页走 Firecrawl**。
17
+ **按你给的 URL 抓**,不套系统内部的 Reddit 采集逻辑。适合临时取证、抓任意公开页面正文/链接。端点分流:**Reddit 主机 + `.json`(或 `--reddit-json`)→ API 内直连 curl_cffi**,**其余 HTML/普通网页 → Firecrawl**。
18
+
19
+ > 🔴 **Reddit 这条路现在是死的,别用本端点核实 Reddit 内容。** 2026-09-01 与 2026-09-18 两次实测:`.json` + 住宅代理 → `outcome: blocked`(403);Firecrawl `html`/`rawHtml` 渲染单帖页与板块列表页 → `outcome: captcha`;`old.reddit.com` 同样 captcha。API 侧只有这两条路,都被拦着。
20
+ > **要 Reddit 数据走 worker 侧采集**(`social-hub-intelligence`):那边另有 Reddit OAuth、`REDDIT_FIRECRAWL_FALLBACK_ENABLED` 渲染兜底与 `.json` 封禁熔断器,实测仍有产量。
21
+ > ⚠️ 「这次抓到了」不能推出「这个通道可用」——成败取决于出口 IP 当时的状态。
18
22
 
19
23
  ## 命令
20
24
 
@@ -25,13 +29,9 @@ social-hub scrape url "https://example.com/article"
25
29
  # 指定输出格式(markdown|html|rawHtml|links,逗号分隔)
26
30
  social-hub scrape url "https://example.com" -f markdown,links
27
31
 
28
- # Reddit .json:直连拿结构化 JSON(--reddit-json 或 URL 以 .json 结尾自动走直连)
29
- social-hub scrape url "https://www.reddit.com/r/espresso/about.json" --reddit-json
30
- social-hub scrape url "https://www.reddit.com/r/espresso/hot.json?limit=10"
31
- social-hub scrape url "https://www.reddit.com/search.json?q=plaud&sort=new"
32
-
33
- # 抓 Reddit 页面正文(HTML,经 Firecrawl 渲染)
34
- social-hub scrape url "https://www.reddit.com/r/espresso"
32
+ # ⛔ Reddit(.json 直连与 HTML 渲染都已被拦,实测取不到,保留仅为说明分流规则)
33
+ # social-hub scrape url "https://www.reddit.com/r/espresso/about.json" --reddit-json
34
+ # social-hub scrape url "https://www.reddit.com/r/espresso"
35
35
 
36
36
  # 超时(毫秒,1000-60000)
37
37
  social-hub scrape url "https://example.com" --timeout 20000
@@ -42,8 +42,9 @@ social-hub scrape status
42
42
 
43
43
  ## 边界与约束
44
44
 
45
- - **HTML/普通网页需先部署 Firecrawl**(`docker/docker-compose.firecrawl.yml`)并给 API 配 `FIRECRAWL_BASE_URL`;未配时抓 HTML 报「未配置」。**Reddit `.json` 不依赖 Firecrawl**,靠 API 内直连(需 API 配 `REDDIT_HTTP_PROXY` 住宅代理,机房 IP 会被 Reddit 拦)。
46
- - **分流规则**:Reddit 主机 + (`--reddit-json` 或 URL 以 `.json` 结尾)→ 直连 curl_cffi;其余 → Firecrawl。
45
+ - **HTML/普通网页需先部署 Firecrawl**(`docker/docker-compose.firecrawl.yml`)并给 API 配 `FIRECRAWL_BASE_URL`;未配时抓 HTML 报「未配置」。
46
+ - **Reddit:两条路径当前都不可用**(见上方红字)。`.json` 直连不依赖 Firecrawl、靠 API 内 curl_cffi + `REDDIT_HTTP_PROXY`,但住宅代理也已被 403;换代理后是否恢复要实测,不要假设。
47
+ - **分流规则**:Reddit 主机 + (`--reddit-json` 或 URL 以 `.json` 结尾)→ 直连 curl_cffi;其余 → Firecrawl。⚠️ 分流规则照旧,但 Reddit 这一侧当前取不到数据(见上方红字)。
47
48
  - **权限 `scrape.create`**(运维/合规向;client 角色无权)。
48
49
  - **SSRF 防护**:仅 http/https,拒绝私网/环回/云 metadata 地址(含 IPv6 私网段、DNS rebinding、重定向落地复检);可配 `SCRAPE_ALLOWED_HOSTS` 域名白名单收窄可抓范围。
49
50
  - **并发受控**:API 侧非阻塞信号量(`SCRAPE_MAX_CONCURRENCY`,默认 4),满载返回 **429**,需重试。
@@ -52,4 +53,5 @@ social-hub scrape status
52
53
  ## 与其它 skill 的分工
53
54
 
54
55
  - 要跑**系统内部**的 Reddit 情报/VOC 采集(watchlists、hot-posts、insights、fetch-by-\*,落库 + 聚合)→ `social-hub-intelligence`。
55
- - 本 skill 是**通用/临时抓取**:给什么 URL 抓什么(Reddit `.json` + HTML + 普通网页),输出原始内容而非系统结构化情报——覆盖 reddit-voc-volume 那类 `.json` 取证场景。
56
+ - 本 skill 是**通用/临时抓取**:给什么 URL 抓什么(HTML + 普通网页),输出原始内容而非系统结构化情报。
57
+ - **Reddit 取证不要用本 skill**:`reddit-voc-volume` 自 v2.12.28 起已不打 reddit.com `.json`,历史帖子/评论走 Arctic Shift 归档,渲染页证据走 Hub / Firecrawl。