@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.
@@ -0,0 +1,315 @@
1
+ """v2.12.28+ 契约:脚本不得直连 reddit.com,`request_identity` 输出形状保持兼容。
2
+
3
+ 静态扫描 + 输出契约;全部离线。成对对照组保证检查器本身不会静默变空。
4
+
5
+ 注意:静态部分是**启发式防回归**,不是「无直连」的完整证明——它只看 urllib 请求调用的实参
6
+ (字面量、同函数内的局部赋值、模块级常量)是否指向 reddit.com 主机,动态拼接的 URL 抓不到。
7
+ 运行时的兜底是 test_links_fallback / 本文件中把 `urlopen` 打成「调用即失败」的用例。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import contextlib
13
+ import importlib.util
14
+ import io
15
+ import json
16
+ from pathlib import Path
17
+ import re
18
+ import sys
19
+ import unittest
20
+ from unittest import mock
21
+
22
+
23
+ SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "reddit_voc_volume.py"
24
+ SPEC = importlib.util.spec_from_file_location("reddit_voc_no_direct_under_test", SCRIPT)
25
+ assert SPEC is not None and SPEC.loader is not None
26
+ voc = importlib.util.module_from_spec(SPEC)
27
+ SPEC.loader.exec_module(voc)
28
+
29
+ FORBIDDEN_TOKENS = (
30
+ "curl_cffi",
31
+ "browser_cookie3",
32
+ "http_get_json",
33
+ "cffi_requests",
34
+ "RedditHttpClient",
35
+ "CookieStore",
36
+ "IMPERSONATE",
37
+ )
38
+ # 精确匹配 reddit.com 主机:不误报 photon-reddit.com、reddit.com.example.org。
39
+ REDDIT_URL_RE = re.compile(r"^https?://(?:[\w-]+\.)*reddit\.com(?:[:/?#]|$)")
40
+ # urllib 请求调用;subprocess 仅在命令里出现 curl/wget 时才算 HTTP 请求。
41
+ URLLIB_CALLS = {"urlopen", "Request", "firecrawl_urlopen"}
42
+ SUBPROCESS_CALLS = {"run", "Popen", "check_output"}
43
+ HTTP_CLIENT_BINARIES = {"curl", "wget"}
44
+ POST_URL = "https://www.reddit.com/r/example/comments/abc123/slug/"
45
+ IDENTITY_KEYS = {
46
+ "user_agent_kind",
47
+ "fetch_mode",
48
+ "used_cookies",
49
+ "cookie_source",
50
+ "cookie_cache_reused",
51
+ "impersonation",
52
+ "proxy_used",
53
+ }
54
+
55
+
56
+ def _strings(node: ast.AST) -> list[str]:
57
+ return [n.value for n in ast.walk(node) if isinstance(n, ast.Constant) and isinstance(n.value, str)]
58
+
59
+
60
+ def _has_reddit_url(node: ast.AST) -> bool:
61
+ return any(REDDIT_URL_RE.match(s) for s in _strings(node))
62
+
63
+
64
+ def _call_name(call: ast.Call) -> str:
65
+ f = call.func
66
+ if isinstance(f, ast.Attribute):
67
+ return f.attr
68
+ if isinstance(f, ast.Name):
69
+ return f.id
70
+ return ""
71
+
72
+
73
+ def _names_bound_to_reddit_url(scope: ast.AST) -> set[str]:
74
+ out: set[str] = set()
75
+ for n in ast.walk(scope):
76
+ if isinstance(n, ast.Assign) and _has_reddit_url(n.value):
77
+ out.update(t.id for t in n.targets if isinstance(t, ast.Name))
78
+ return out
79
+
80
+
81
+ def _is_http_request(call: ast.Call) -> bool:
82
+ name = _call_name(call)
83
+ if name in URLLIB_CALLS:
84
+ return True
85
+ if name in SUBPROCESS_CALLS:
86
+ return any(s in HTTP_CLIENT_BINARIES for s in _strings(call))
87
+ return False
88
+
89
+
90
+ def functions_requesting_reddit(source: str) -> tuple[list[str], list[str]]:
91
+ """返回 (发起 HTTP 请求的函数名, 其中请求实参指向 reddit.com 的函数名)。
92
+
93
+ 只检查请求调用的实参:reddit URL 作为 permalink / 展示字段,或放进发给 Hub 的 payload,
94
+ 都不在请求实参里,不算违规。
95
+ """
96
+ tree = ast.parse(source)
97
+ module_consts = _names_bound_to_reddit_url(ast.Module(body=[n for n in tree.body if isinstance(n, ast.Assign)], type_ignores=[]))
98
+ requesting: list[str] = []
99
+ offending: list[str] = []
100
+ for fn in ast.walk(tree):
101
+ if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
102
+ continue
103
+ calls = [n for n in ast.walk(fn) if isinstance(n, ast.Call) and _is_http_request(n)]
104
+ if not calls:
105
+ continue
106
+ requesting.append(fn.name)
107
+ reddit_names = module_consts | _names_bound_to_reddit_url(fn)
108
+ for call in calls:
109
+ arg_nodes = [*call.args, *(kw.value for kw in call.keywords)]
110
+ used = {n.id for a in arg_nodes for n in ast.walk(a) if isinstance(n, ast.Name)}
111
+ if any(_has_reddit_url(a) for a in arg_nodes) or used & reddit_names:
112
+ offending.append(fn.name)
113
+ break
114
+ return requesting, offending
115
+
116
+
117
+ class StaticScanTests(unittest.TestCase):
118
+ @classmethod
119
+ def setUpClass(cls) -> None:
120
+ cls.source = SCRIPT.read_text(encoding="utf-8")
121
+
122
+ def test_legacy_direct_fetch_tokens_are_gone(self) -> None:
123
+ for token in FORBIDDEN_TOKENS:
124
+ with self.subTest(token=token):
125
+ self.assertNotIn(token, self.source)
126
+
127
+ def test_no_function_that_sends_requests_references_reddit_com(self) -> None:
128
+ requesting, offending = functions_requesting_reddit(self.source)
129
+ # 对照:真实脚本里确实有发请求的函数(Arctic / Firecrawl / Hub),扫描器不是空转。
130
+ self.assertIn("arctic_get", requesting)
131
+ self.assertGreaterEqual(len(requesting), 3)
132
+ self.assertEqual(offending, [])
133
+
134
+ def test_scanner_control_flags_a_direct_reddit_request(self) -> None:
135
+ bad_literal = (
136
+ "import urllib.request\n"
137
+ "def fetch():\n"
138
+ " return urllib.request.urlopen('https://www.reddit.com/r/x/.json')\n"
139
+ )
140
+ bad_module_const = (
141
+ "import urllib.request\n"
142
+ "REDDIT_URL = 'https://old.reddit.com/r/x/'\n"
143
+ "def fetch():\n"
144
+ " req = urllib.request.Request(REDDIT_URL)\n"
145
+ " return urllib.request.urlopen(req)\n"
146
+ )
147
+ bad_local_var = (
148
+ "import urllib.request\n"
149
+ "def fetch(sub):\n"
150
+ " url = f'https://www.reddit.com/r/{sub}/about.json'\n"
151
+ " return urllib.request.urlopen(url)\n"
152
+ )
153
+ bad_curl = (
154
+ "import subprocess\n"
155
+ "def fetch():\n"
156
+ " return subprocess.run(['curl', 'https://www.reddit.com/.json'])\n"
157
+ )
158
+ for name, src in (
159
+ ("literal", bad_literal),
160
+ ("module_const", bad_module_const),
161
+ ("local_var", bad_local_var),
162
+ ("curl", bad_curl),
163
+ ):
164
+ with self.subTest(case=name):
165
+ self.assertEqual(functions_requesting_reddit(src)[1], ["fetch"])
166
+
167
+ def test_scanner_control_does_not_flag_display_payload_or_lookalike_hosts(self) -> None:
168
+ good = (
169
+ "import subprocess, urllib.request\n"
170
+ "REDDIT_URL = 'https://www.reddit.com/'\n"
171
+ "def fetch():\n"
172
+ " return urllib.request.urlopen('https://arctic-shift.photon-reddit.com/api')\n"
173
+ "def lookalike():\n"
174
+ " return urllib.request.urlopen('https://reddit.com.example.org/x')\n"
175
+ "def permalink(sub):\n"
176
+ " return f'https://www.reddit.com/r/{sub}/'\n"
177
+ "def display(post):\n"
178
+ " post['url'] = REDDIT_URL + 'r/x'\n"
179
+ " return urllib.request.urlopen('https://arctic-shift.photon-reddit.com/api')\n"
180
+ "def to_hub(url):\n"
181
+ " payload = {'url': 'https://www.reddit.com/r/x/', 'target': REDDIT_URL}\n"
182
+ " return subprocess.run(['social-hub', 'scrape', '--json', str(payload)])\n"
183
+ )
184
+ requesting, offending = functions_requesting_reddit(good)
185
+ # 检查器确实扫到了这些请求函数,不是因为扫描为空才没有误报。
186
+ self.assertTrue({"fetch", "lookalike", "display"} <= set(requesting))
187
+ self.assertEqual(offending, [])
188
+
189
+ def test_requirements_have_no_legacy_packages(self) -> None:
190
+ req = (SCRIPT.parents[1] / "requirements.txt").read_text(encoding="utf-8")
191
+ lines = [ln.strip() for ln in req.splitlines() if ln.strip() and not ln.strip().startswith("#")]
192
+ self.assertEqual(lines, [])
193
+
194
+ def test_via_curl_is_deprecated_noop_on_all_four_subcommands(self) -> None:
195
+ cmds = {
196
+ "keyword": ["keyword", "battery"],
197
+ "subreddit": ["subreddit", "battery", "-r", "example"],
198
+ "stickies": ["stickies", "-r", "example"],
199
+ "links": ["links", POST_URL],
200
+ }
201
+ parser_help = io.StringIO()
202
+ for mode, argv in cmds.items():
203
+ with self.subTest(mode=mode):
204
+ stderr = io.StringIO()
205
+ with (
206
+ mock.patch.object(sys, "argv", [str(SCRIPT), *argv, "--via-curl", "--help"]),
207
+ contextlib.redirect_stderr(stderr),
208
+ contextlib.redirect_stdout(parser_help),
209
+ self.assertRaises(SystemExit) as ctx,
210
+ ):
211
+ voc.main()
212
+ self.assertEqual(ctx.exception.code, 0) # --help 正常退出,说明参数已注册
213
+ self.assertIn("DEPRECATED", parser_help.getvalue())
214
+
215
+ def test_links_with_via_curl_warns_and_keeps_output_shape(self) -> None:
216
+ def run(extra: list[str]) -> tuple[int, dict, str]:
217
+ stdout, stderr = io.StringIO(), io.StringIO()
218
+ with (
219
+ mock.patch.object(voc.urllib.request, "urlopen", side_effect=AssertionError("direct network call")),
220
+ mock.patch.object(voc, "resolve_proxy_url", return_value=""),
221
+ mock.patch.object(voc, "arctic_post_by_permalink", return_value=None),
222
+ mock.patch.object(voc, "fetch_page_scrape", return_value={"markdown": "Public page"}),
223
+ mock.patch.object(voc, "collect_subreddit_metrics", return_value=[]),
224
+ mock.patch.object(voc, "hub_writeback_items", return_value={"status": "pending"}),
225
+ mock.patch.object(voc, "sleep_with_jitter", return_value=0.0),
226
+ mock.patch.object(sys, "argv", [str(SCRIPT), "links", POST_URL, "--fallback", "auto", "--sleep-sec", "0", *extra]),
227
+ contextlib.redirect_stdout(stdout),
228
+ contextlib.redirect_stderr(stderr),
229
+ ):
230
+ code = voc.main()
231
+ return code, json.loads(stdout.getvalue()), stderr.getvalue()
232
+
233
+ code_a, out_a, err_a = run([])
234
+ code_b, out_b, err_b = run(["--via-curl"])
235
+ self.assertEqual((code_a, code_b), (0, 0))
236
+ self.assertNotIn("DEPRECATED", err_a) # 对照:不传则无告警
237
+ self.assertIn("DEPRECATED: --via-curl 已无效果(v2.12.28 起不直连 reddit)", err_b)
238
+ self.assertEqual(set(out_a), set(out_b))
239
+ self.assertEqual(out_a["request_identity"], out_b["request_identity"])
240
+
241
+
242
+ class RequestIdentityContractTests(unittest.TestCase):
243
+ def build(self, proxy: str, mode: str | None = None) -> dict:
244
+ tracker = voc.RequestTracker()
245
+ if mode is not None:
246
+ tracker.last_fetch_mode = mode
247
+ with mock.patch.object(voc, "resolve_proxy_url", return_value=proxy):
248
+ return voc.build_output(
249
+ ok=True,
250
+ mode="keyword",
251
+ query="q",
252
+ source_urls=[],
253
+ items=[],
254
+ subreddit_metrics=[],
255
+ requested_limit=10,
256
+ effective_limit=10,
257
+ limit_truncated=False,
258
+ sleep_sec=6.0,
259
+ max_pages=1,
260
+ tracker=tracker,
261
+ notes=[],
262
+ errors=[],
263
+ )
264
+
265
+ def test_request_identity_shape_and_fixed_values(self) -> None:
266
+ identity = self.build("")["request_identity"]
267
+ self.assertEqual(set(identity), IDENTITY_KEYS)
268
+ self.assertEqual(
269
+ identity,
270
+ {
271
+ "user_agent_kind": "chrome",
272
+ "fetch_mode": "none",
273
+ "used_cookies": False,
274
+ "cookie_source": None,
275
+ "cookie_cache_reused": False,
276
+ "impersonation": None,
277
+ "proxy_used": False,
278
+ },
279
+ )
280
+
281
+ def test_proxy_used_follows_resolved_proxy(self) -> None:
282
+ # 对照组:proxy_used 是唯一随环境变化的键,防止断言被写死成常量。
283
+ self.assertIs(self.build("http://127.0.0.1:7890")["request_identity"]["proxy_used"], True)
284
+ self.assertIs(self.build("")["request_identity"]["proxy_used"], False)
285
+
286
+ def test_fetch_mode_reports_tracker_mode_but_never_curl_identity(self) -> None:
287
+ identity = self.build("", mode="browser_fallback")["request_identity"]
288
+ self.assertEqual(identity["fetch_mode"], "browser_fallback")
289
+ self.assertIsNone(identity["impersonation"])
290
+ self.assertIs(identity["used_cookies"], False)
291
+
292
+ def test_links_run_emits_same_identity_without_network(self) -> None:
293
+ stdout = io.StringIO()
294
+ with (
295
+ mock.patch.object(voc.urllib.request, "urlopen", side_effect=AssertionError("direct network call")) as urlopen,
296
+ mock.patch.object(voc, "resolve_proxy_url", return_value=""),
297
+ mock.patch.object(voc, "arctic_post_by_permalink", return_value=None),
298
+ mock.patch.object(voc, "fetch_page_scrape", return_value={"markdown": "Public page"}),
299
+ mock.patch.object(voc, "collect_subreddit_metrics", return_value=[]),
300
+ mock.patch.object(voc, "hub_writeback_items", return_value={"status": "pending"}),
301
+ mock.patch.object(voc, "sleep_with_jitter", return_value=0.0),
302
+ mock.patch.object(sys, "argv", [str(SCRIPT), "links", POST_URL, "--fallback", "auto", "--sleep-sec", "0"]),
303
+ contextlib.redirect_stdout(stdout),
304
+ ):
305
+ code = voc.main()
306
+ out = json.loads(stdout.getvalue())
307
+ self.assertEqual(code, 0)
308
+ self.assertEqual(set(out["request_identity"]), IDENTITY_KEYS)
309
+ self.assertEqual(out["request_identity"]["fetch_mode"], "none")
310
+ self.assertIs(out["request_identity"]["used_cookies"], False)
311
+ urlopen.assert_not_called()
312
+
313
+
314
+ if __name__ == "__main__":
315
+ unittest.main()