@geoly-ai/social-hub-cli 0.3.46 → 0.3.48

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.
@@ -15,7 +15,7 @@ Pushshift 停摆后的接续者。**无需 auth**,免费公开服务,作者
15
15
  v2.12.26 修掉正文截断后,baseline 卡的长度维度只接受未截断全文。但多数网络
16
16
  环境下 reddit.com 的 `.json` 直采已被拦截(实测本机:`curl (56) CONNECT tunnel
17
17
  failed 403`,去掉代理直连亦为 `403 Blocked`)。Arctic Shift 不走 reddit.com,
18
- 不需要 cookie / curl_cffi 伪装,且**正文不截断**。
18
+ 不需要 cookie 或指纹伪装,且**正文不截断**。
19
19
 
20
20
  ## 覆盖范围(实测,非文档转述)
21
21
 
@@ -1,5 +1,2 @@
1
1
  # Reddit VOC volume — Python dependencies
2
- # 以下两项仅供未调用的旧 Reddit HTTP 函数使用。主流程不实例化旧客户端,
3
- # 不读取浏览器 cookie;不安装也不影响当前模式。
4
- curl_cffi>=0.7.0
5
- browser_cookie3>=0.19.0
2
+ # 无第三方 Python 依赖:脚本只用标准库(Arctic / Social Hub / Firecrawl 均走 urllib 或 CLI)。
@@ -154,7 +154,19 @@ DEFAULT_UA = (
154
154
  "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 reddit-voc-volume/1.0"
155
155
  )
156
156
 
157
- IMPERSONATE = "chrome131"
157
+
158
+ class _DeprecatedViaCurl(argparse.Action):
159
+ """`--via-curl` 自 v2.12.28 起无效果;保留一个发布周期,仅向 stderr 提示,下一版移除。"""
160
+
161
+ def __init__(self, option_strings: Any, dest: str, **kwargs: Any) -> None:
162
+ kwargs["nargs"] = 0
163
+ kwargs.setdefault("default", False)
164
+ kwargs.setdefault("help", "DEPRECATED:已无效果(v2.12.28 起不直连 reddit),下一版移除")
165
+ super().__init__(option_strings, dest, **kwargs)
166
+
167
+ def __call__(self, parser: Any, namespace: Any, values: Any, option_string: Any = None) -> None:
168
+ print("DEPRECATED: --via-curl 已无效果(v2.12.28 起不直连 reddit)", file=sys.stderr)
169
+ setattr(namespace, self.dest, True)
158
170
 
159
171
 
160
172
  def utc_now_iso() -> str:
@@ -177,22 +189,6 @@ def sleep_with_jitter(base_sec: float) -> float:
177
189
  return jitter
178
190
 
179
191
 
180
- def _looks_like_html(text: str) -> bool:
181
- s = (text or "").lstrip()
182
- return s.startswith("<") or "<html" in s[:200].lower() or "theme-beta" in s[:500]
183
-
184
-
185
- def _loads_json_or_raise(text: str, *, url: str) -> Any:
186
- if _looks_like_html(text):
187
- raise ValueError(
188
- "reddit_returned_html_not_json (likely 403/block; try proxy or browser fallback)"
189
- )
190
- try:
191
- return json.loads(text)
192
- except json.JSONDecodeError as e:
193
- raise ValueError(f"invalid_json: {e}") from e
194
-
195
-
196
192
  def _loads_json_response(text: str) -> Any:
197
193
  try:
198
194
  return json.loads(text)
@@ -243,114 +239,11 @@ def resolve_proxy_url() -> str:
243
239
  return _macos_system_proxy_url()
244
240
 
245
241
 
246
- def _proxies_dict() -> dict[str, str]:
247
- p = resolve_proxy_url()
248
- return {"http": p, "https": p} if p else {}
249
-
250
-
251
- def _curl_proxy_args() -> list[str]:
252
- proxy = resolve_proxy_url()
253
- return ["--proxy", proxy] if proxy else []
254
-
255
-
256
242
  def _env_truthy(name: str) -> bool:
257
243
  raw = (os.environ.get(name) or "").strip().lower()
258
244
  return raw in ("1", "true", "yes", "on")
259
245
 
260
246
 
261
- def _use_browser_cookies() -> bool:
262
- raw = (os.environ.get("REDDIT_USE_BROWSER_COOKIES") or "1").strip().lower()
263
- return raw not in ("0", "false", "no", "off")
264
-
265
-
266
- class CookieStore:
267
- """Load reddit.com cookies once per process and reuse for all requests."""
268
-
269
- _instance: CookieStore | None = None
270
-
271
- def __init__(self) -> None:
272
- self._cookies: dict[str, str] = {}
273
- self.source: str | None = None
274
- self.loaded = False
275
-
276
- @classmethod
277
- def get(cls) -> CookieStore:
278
- if cls._instance is None:
279
- cls._instance = cls()
280
- cls._instance.load_once()
281
- return cls._instance
282
-
283
- def load_once(self) -> dict[str, str]:
284
- if self.loaded:
285
- return self._cookies
286
- self.loaded = True
287
- raw = (os.environ.get("REDDIT_COOKIES_JSON") or "").strip()
288
- if raw:
289
- try:
290
- obj = json.loads(raw)
291
- if isinstance(obj, dict):
292
- self._cookies = {str(k): str(v) for k, v in obj.items() if k and v is not None}
293
- self.source = "env_json"
294
- return self._cookies
295
- except json.JSONDecodeError:
296
- pass
297
- if not _use_browser_cookies():
298
- self.source = None
299
- return self._cookies
300
- try:
301
- import browser_cookie3 # type: ignore
302
- except ImportError:
303
- self.source = None
304
- return self._cookies
305
- for name in ("chrome", "safari", "firefox"):
306
- fn = getattr(browser_cookie3, name, None)
307
- if not callable(fn):
308
- continue
309
- try:
310
- jar = fn(domain_name="reddit.com")
311
- cookies = {c.name: c.value for c in jar if c.name and c.value}
312
- if cookies:
313
- self._cookies = cookies
314
- self.source = "browser"
315
- # Share with child processes in same sync job (e.g. feishu sync)
316
- os.environ.setdefault("REDDIT_COOKIES_JSON", json.dumps(cookies))
317
- return self._cookies
318
- except Exception:
319
- continue
320
- self.source = None
321
- return self._cookies
322
-
323
- @property
324
- def cookies(self) -> dict[str, str]:
325
- return self._cookies
326
-
327
-
328
- def _load_browser_cookies() -> dict[str, str]:
329
- return CookieStore.get().cookies
330
-
331
-
332
- def _browser_headers(user_agent: str) -> dict[str, str]:
333
- return {
334
- "User-Agent": user_agent,
335
- "Accept": "application/json, text/plain, */*",
336
- "Accept-Language": "en-US,en;q=0.9",
337
- "Referer": "https://www.reddit.com/",
338
- "Origin": "https://www.reddit.com",
339
- "Sec-Fetch-Dest": "empty",
340
- "Sec-Fetch-Mode": "cors",
341
- "Sec-Fetch-Site": "same-origin",
342
- }
343
-
344
-
345
- def _curl_cffi_available() -> bool:
346
- try:
347
- import curl_cffi # noqa: F401
348
-
349
- return True
350
- except ImportError:
351
- return False
352
-
353
-
354
247
  class RequestTracker:
355
248
  def __init__(self) -> None:
356
249
  self.count = 0
@@ -358,9 +251,6 @@ class RequestTracker:
358
251
  self.rate_limited = False
359
252
  self.truncated = False
360
253
  self.last_fetch_mode = "none"
361
- self.used_cookies = False
362
- self.cookie_source: str | None = None
363
- self.cookie_cache_reused = False
364
254
 
365
255
  def bump(self) -> None:
366
256
  self.count += 1
@@ -368,108 +258,6 @@ class RequestTracker:
368
258
  self.truncated = True
369
259
  raise RuntimeError("max_http_requests_exceeded")
370
260
 
371
- def record_identity(self, mode: str, store: CookieStore) -> None:
372
- self.last_fetch_mode = mode
373
- if store.cookies:
374
- self.used_cookies = True
375
- self.cookie_source = store.source
376
- if store.loaded and self.count > 1:
377
- self.cookie_cache_reused = True
378
-
379
-
380
- class RedditHttpClient:
381
- """Reuse cookies + curl_cffi session within a single CLI run."""
382
-
383
- def __init__(self, user_agent: str, *, force_curl: bool = False) -> None:
384
- self.user_agent = user_agent
385
- self.force_curl = force_curl
386
- self.cookie_store = CookieStore.get()
387
- self._cffi_session: Any = None
388
- self._warmed = False
389
-
390
- def _ensure_cffi_session(self) -> Any:
391
- if self._cffi_session is None:
392
- from curl_cffi import requests as cffi_requests
393
-
394
- self._cffi_session = cffi_requests.Session(impersonate=IMPERSONATE)
395
- if not self._warmed:
396
- headers = _browser_headers(self.user_agent)
397
- proxies = _proxies_dict()
398
- cookies = self.cookie_store.cookies
399
- try:
400
- self._cffi_session.get(
401
- "https://www.reddit.com/",
402
- headers=headers,
403
- proxies=proxies or None,
404
- cookies=cookies or None,
405
- timeout=30.0,
406
- )
407
- except Exception:
408
- pass
409
- self._warmed = True
410
- return self._cffi_session
411
-
412
- def fetch_via_curl_cffi(self, url: str, timeout: float = 30.0) -> tuple[Any, str]:
413
- session = self._ensure_cffi_session()
414
- headers = _browser_headers(self.user_agent)
415
- proxies = _proxies_dict()
416
- cookies = self.cookie_store.cookies
417
- resp = session.get(
418
- url,
419
- headers=headers,
420
- proxies=proxies or None,
421
- cookies=cookies or None,
422
- timeout=timeout,
423
- )
424
- if resp.status_code == 429:
425
- raise RuntimeError("http_status:429")
426
- if resp.status_code == 403:
427
- raise RuntimeError("http_status:403")
428
- if resp.status_code >= 400:
429
- raise RuntimeError(f"http_status:{resp.status_code}")
430
- return _loads_json_or_raise(resp.text, url=url), "curl_cffi"
431
-
432
-
433
- def http_get_json_via_curl(url: str, user_agent: str, timeout: int = 30) -> tuple[Any, str]:
434
- curl = shutil.which("curl")
435
- if not curl:
436
- raise RuntimeError("curl not found in PATH")
437
- cmd = [
438
- curl,
439
- "-sS",
440
- "-L",
441
- "--max-time",
442
- str(int(timeout)),
443
- "-A",
444
- user_agent,
445
- "-H",
446
- "Accept: application/json, text/plain, */*",
447
- "-H",
448
- "Accept-Language: en-US,en;q=0.9",
449
- "-H",
450
- "Referer: https://www.reddit.com/",
451
- *_curl_proxy_args(),
452
- url,
453
- ]
454
- proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
455
- if proc.returncode != 0:
456
- err = (proc.stderr or proc.stdout or "curl failed").strip()
457
- if "429" in err:
458
- raise RuntimeError("http_status:429")
459
- raise RuntimeError(err)
460
- return _loads_json_or_raise(proc.stdout, url=url), "curl"
461
-
462
-
463
- def http_get_json_urllib(url: str, user_agent: str, timeout: float = 25.0) -> tuple[Any, str]:
464
- req = urllib.request.Request(url, headers=_browser_headers(user_agent), method="GET")
465
- try:
466
- with urllib.request.urlopen(req, timeout=timeout) as resp:
467
- charset = resp.headers.get_content_charset() or "utf-8"
468
- raw = resp.read().decode(charset)
469
- except urllib.error.HTTPError as e:
470
- raise RuntimeError(f"http_status:{e.code}") from e
471
- return _loads_json_or_raise(raw, url=url), "urllib"
472
-
473
261
 
474
262
  def normalize_firecrawl_base_url(base_url: str | None = None) -> str:
475
263
  raw = (base_url or os.environ.get("FIRECRAWL_BASE_URL") or DEFAULT_FIRECRAWL_BASE_URL).strip()
@@ -4693,10 +4481,10 @@ def build_output(
4693
4481
  "request_identity": {
4694
4482
  "user_agent_kind": "chrome",
4695
4483
  "fetch_mode": tracker.last_fetch_mode,
4696
- "used_cookies": tracker.used_cookies,
4697
- "cookie_source": tracker.cookie_source,
4698
- "cookie_cache_reused": tracker.cookie_cache_reused,
4699
- "impersonation": IMPERSONATE if tracker.last_fetch_mode == "curl_cffi" else None,
4484
+ "used_cookies": False,
4485
+ "cookie_source": None,
4486
+ "cookie_cache_reused": False,
4487
+ "impersonation": None,
4700
4488
  "proxy_used": bool(proxy),
4701
4489
  },
4702
4490
  "notes": notes,
@@ -4833,7 +4621,7 @@ def main() -> int:
4833
4621
  pk.add_argument("--max-pages", type=int, default=DEFAULT_MAX_PAGES)
4834
4622
  pk.add_argument("--sleep-sec", type=float, default=DEFAULT_SLEEP_SEC)
4835
4623
  pk.add_argument("--user-agent", default=DEFAULT_UA)
4836
- pk.add_argument("--via-curl", action="store_true")
4624
+ pk.add_argument("--via-curl", action=_DeprecatedViaCurl)
4837
4625
  pk.add_argument("--pretty", action="store_true")
4838
4626
  add_hub_voc_options(pk)
4839
4627
 
@@ -4846,7 +4634,7 @@ def main() -> int:
4846
4634
  ps.add_argument("--max-pages", type=int, default=DEFAULT_MAX_PAGES)
4847
4635
  ps.add_argument("--sleep-sec", type=float, default=DEFAULT_SLEEP_SEC)
4848
4636
  ps.add_argument("--user-agent", default=DEFAULT_UA)
4849
- ps.add_argument("--via-curl", action="store_true")
4637
+ ps.add_argument("--via-curl", action=_DeprecatedViaCurl)
4850
4638
  ps.add_argument("--pretty", action="store_true")
4851
4639
  add_hub_voc_options(ps)
4852
4640
 
@@ -4855,7 +4643,7 @@ def main() -> int:
4855
4643
  pst.add_argument("--limit", type=int, default=10, help="Hot listing size per subreddit before filtering stickied posts")
4856
4644
  pst.add_argument("--sleep-sec", type=float, default=DEFAULT_SLEEP_SEC)
4857
4645
  pst.add_argument("--user-agent", default=DEFAULT_UA)
4858
- pst.add_argument("--via-curl", action="store_true")
4646
+ pst.add_argument("--via-curl", action=_DeprecatedViaCurl)
4859
4647
  pst.add_argument("--pretty", action="store_true")
4860
4648
  add_hub_voc_options(pst)
4861
4649
 
@@ -4922,7 +4710,7 @@ def main() -> int:
4922
4710
  pl.add_argument("--file", "-f", help="File with one URL per line")
4923
4711
  pl.add_argument("--sleep-sec", type=float, default=DEFAULT_SLEEP_SEC)
4924
4712
  pl.add_argument("--user-agent", default=DEFAULT_UA)
4925
- pl.add_argument("--via-curl", action="store_true")
4713
+ pl.add_argument("--via-curl", action=_DeprecatedViaCurl)
4926
4714
  pl.add_argument(
4927
4715
  "--fallback",
4928
4716
  choices=["none", "firecrawl", "social-hub", "auto"],
@@ -5363,7 +5151,7 @@ def main() -> int:
5363
5151
  errors = list(acquisition.get("errors") or [])
5364
5152
  if acquisition.get("should_local"):
5365
5153
  # Emergency Acquisition 缺省走 Arctic Shift 归档 API。
5366
- # 相对 .json 直采:正文不截断、不需要 cookie/curl 伪装、不受 Reddit 反爬拦截;
5154
+ # 相对 .json 直采:正文不截断、不受 Reddit 反爬拦截;
5367
5155
  # 代价是 score 有约 1/3 为陈旧值(已逐条标 score_status),且无 live 语义。
5368
5156
  items = []
5369
5157
  for idx, sub in enumerate(subreddits or [None]):
@@ -36,44 +36,8 @@ find_python() {
36
36
  return 1
37
37
  }
38
38
 
39
- ensure_curl() {
40
- if command -v curl >/dev/null 2>&1; then
41
- echo " curl: $(command -v curl)"
42
- return 0
43
- fi
44
- echo " curl: not found — attempting install..."
45
- if [[ "$SKIP_BREW" != 1 ]] && command -v brew >/dev/null 2>&1; then
46
- brew install curl || true
47
- fi
48
- if [[ "$SKIP_APT" != 1 ]] && command -v apt-get >/dev/null 2>&1; then
49
- if [[ "$ASSUME_YES" == 1 ]]; then
50
- sudo apt-get update -qq && sudo apt-get install -y curl || true
51
- else
52
- echo " Run: sudo apt-get install -y curl (or re-run with --yes if sudo is OK)"
53
- fi
54
- fi
55
- if command -v curl >/dev/null 2>&1; then
56
- echo " curl: installed at $(command -v curl)"
57
- return 0
58
- fi
59
- echo " curl: still missing (urllib/curl_cffi may still work; install curl manually for fallback)"
60
- return 0
61
- }
62
-
63
- ensure_pip_packages() {
64
- local py="$1"
65
- echo " pip packages: checking curl_cffi, browser_cookie3..."
66
- if "$py" -c "import curl_cffi, browser_cookie3" 2>/dev/null; then
67
- echo " pip packages: OK"
68
- return 0
69
- fi
70
- echo " pip packages: installing from ${REQ}..."
71
- "$py" -m pip install --upgrade pip >/dev/null 2>&1 || true
72
- if ! "$py" -m pip install -r "$REQ"; then
73
- echo " pip install failed — try: $py -m pip install -r $REQ" >&2
74
- return 1
75
- fi
76
- echo " pip packages: installed"
39
+ report_pip_packages() {
40
+ echo " pip packages: none required (stdlib only; see ${REQ})"
77
41
  }
78
42
 
79
43
  main() {
@@ -84,8 +48,7 @@ main() {
84
48
  exit 1
85
49
  fi
86
50
  echo " python: $PYTHON ($("$PYTHON" --version 2>&1))"
87
- ensure_curl
88
- ensure_pip_packages "$PYTHON"
51
+ report_pip_packages
89
52
  echo "=== setup complete ==="
90
53
  }
91
54