dsh-js-reverse-plugin 0.1.0

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 (44) hide show
  1. package/cordis.patch.yml +14 -0
  2. package/index.js +61 -0
  3. package/package.json +20 -0
  4. package/skills/iv8-env-runtime/SKILL.md +159 -0
  5. package/skills/iv8-env-runtime/assets/entry-report-template.txt +23 -0
  6. package/skills/iv8-env-runtime/assets/env_starter.py +100 -0
  7. package/skills/iv8-env-runtime/references/case-function-iwencai.md +96 -0
  8. package/skills/iv8-env-runtime/references/case-intercept-douyin.md +133 -0
  9. package/skills/iv8-env-runtime/references/config-defaults-cheatsheet.md +75 -0
  10. package/skills/iv8-env-runtime/references/cross-platform-install.md +108 -0
  11. package/skills/iv8-env-runtime/references/iv8-api-contract.md +97 -0
  12. package/skills/iv8-env-runtime/references/network-and-eventloop.md +113 -0
  13. package/skills/iv8-env-runtime/references/part1-entry-location.md +138 -0
  14. package/skills/iv8-env-runtime/references/part1-mcp-tool-map.md +77 -0
  15. package/skills/iv8-env-runtime/references/part2-discovery.md +80 -0
  16. package/skills/iv8-env-runtime/references/patching-principles.md +91 -0
  17. package/skills/iv8-env-runtime/references/trap-blacklist.md +60 -0
  18. package/skills/iv8-env-runtime/references/version-gotchas.md +128 -0
  19. package/skills/iv8-env-runtime/scripts/consistency_lint.py +211 -0
  20. package/skills/iv8-env-runtime/scripts/defaults_audit.py +143 -0
  21. package/skills/iv8-env-runtime/scripts/monitor_env.py +185 -0
  22. package/skills/iv8-env-runtime/scripts/network_bridge.py +147 -0
  23. package/skills/iv8-env-runtime/scripts/parity_check.py +175 -0
  24. package/skills/iv8-env-runtime/scripts/preflight.py +144 -0
  25. package/skills/iv8-env-runtime/scripts/reproduce_stub_error.py +165 -0
  26. package/skills/iv8-env-runtime/scripts/run_target.py +150 -0
  27. package/skills/iv8-jsvmp-recovery/SKILL.md +129 -0
  28. package/skills/iv8-jsvmp-recovery/agents/openai.yaml +4 -0
  29. package/skills/iv8-jsvmp-recovery/references/case-study-douyin-a-bogus.md +320 -0
  30. package/skills/iv8-jsvmp-recovery/references/controlled-oracle-and-equivalence.md +247 -0
  31. package/skills/iv8-jsvmp-recovery/references/static-trace-evidence.md +289 -0
  32. package/skills/iv8-jsvmp-recovery/references/vmp-domain-model.md +212 -0
  33. package/skills/iv8-jsvmp-recovery/scripts/verify-equivalence.py +281 -0
  34. package/skills/js-ast-deobfuscation/SKILL.md +140 -0
  35. package/skills/js-ast-deobfuscation/agents/openai.yaml +4 -0
  36. package/skills/js-ast-deobfuscation/references/anti-patterns.md +325 -0
  37. package/skills/js-ast-deobfuscation/references/first-pass.md +255 -0
  38. package/skills/js-ast-deobfuscation/references/interaction-and-stopping.md +758 -0
  39. package/skills/js-ast-deobfuscation/references/iv8-runtime-observation.md +773 -0
  40. package/skills/js-ast-deobfuscation/references/jsvmp-identification.md +328 -0
  41. package/skills/js-ast-deobfuscation/references/methods-and-evidence.md +460 -0
  42. package/skills/js-ast-deobfuscation/references/patterns-and-priority.md +516 -0
  43. package/skills/js-ast-deobfuscation/references/validation-contract.md +604 -0
  44. package/skills/js-ast-deobfuscation/scripts/prepare-source.mjs +921 -0
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """defaults_audit.py —— P2/P3 桥梁:审计 iv8 的 environment/config 默认值,算出「真正要配的清单」。
4
+
5
+ 作用(对应 SKILL.md 的 P2「monitor∩defaults 交集=要配的清单」):
6
+ 调 iv8.JSContext.get_defaults()(返回全部支持路径及默认值),做两件事:
7
+ 1) 按前缀分组(location / canvas / navigator / screen / webgl / window / document …)
8
+ 打印,并「标出空/占位默认」(''、about:blank、0.0 等)——这些是最可能需要覆盖的必配候选。
9
+ 2) 若给了「目标读过的字段清单」(monitor_env.py 第①类 getter 的输出),
10
+ 与 defaults 求交集 = 目标真正读到、且 iv8 有默认可覆盖的字段 = 真正要配的清单。
11
+
12
+ 典型默认坑(务必核对,见 references/config-defaults-cheatsheet.md):
13
+ - location.href 默认 'about:blank' —— 几乎必配(目标常读 origin/host/href 做校验)
14
+ - canvas.fingerprint.toDataURL.png 默认 '' —— canvas 指纹站必配
15
+ - navigator.* 已有 200+ 默认 —— 通常只需覆盖 userAgent(及联动字段)
16
+
17
+ environment(画像,JS 可观测,可含数组)与 config(引擎行为,仅标量,无数组)是两条独立通道;
18
+ 本审计只帮定位「哪些默认是空壳需要填」,具体填什么值 / 一致性红线(UA↔platform↔时区↔语言↔webgl)
19
+ 见 references/config-defaults-cheatsheet.md 与 references/patching-principles.md。
20
+
21
+ 关系与边界:
22
+ - 上游:scripts/monitor_env.py 的①类 getter 清单是本脚本 --read-fields 的输入。
23
+ - 下游:交集清单 → 填进 assets/env_starter.py 的 environment/config。
24
+ - 一致性静态校验(填错值自动拒)见 scripts/consistency_lint.py。
25
+ - 权威 API 契约见 references/iv8-api-contract.md。
26
+
27
+ 用法:
28
+ python defaults_audit.py # 全量分组 + 标空默认
29
+ python defaults_audit.py --group navigator # 只看某前缀
30
+ python defaults_audit.py --empty-only # 只列空/占位默认(必配候选)
31
+ python defaults_audit.py --read-fields fields.txt # 与目标读过的字段求交集
32
+ fields.txt:每行一个字段路径,如 navigator.userAgent / location.href
33
+ (可直接从 monitor_env.py 第①类输出里摘)
34
+ """
35
+
36
+ import argparse
37
+ import sys
38
+
39
+ try:
40
+ import iv8
41
+ except ImportError:
42
+ sys.exit("未找到 iv8。先跑 scripts/preflight.py 确认安装(安装见 references/cross-platform-install.md)。")
43
+
44
+
45
+ # 视为「空/占位」的默认值 —— 命中即列为必配候选
46
+ _EMPTY_LITERALS = ("", "about:blank", "about://blank")
47
+
48
+
49
+ def is_empty_default(value):
50
+ """判断一个默认值是否为空/占位(很可能需要覆盖)。"""
51
+ if value is None:
52
+ return True
53
+ if isinstance(value, str):
54
+ return value.strip() in _EMPTY_LITERALS
55
+ return False
56
+
57
+
58
+ def group_of(path):
59
+ """取路径的一级前缀作为分组名。"""
60
+ return path.split(".", 1)[0] if "." in path else path
61
+
62
+
63
+ def load_read_fields(path):
64
+ """读取目标读过的字段清单(每行一个路径,# 起始为注释)。"""
65
+ fields = set()
66
+ with open(path, "r", encoding="utf-8") as f:
67
+ for line in f:
68
+ s = line.strip()
69
+ if not s or s.startswith("#"):
70
+ continue
71
+ # 容忍 monitor 输出里带计数前缀,如 " 42 navigator.userAgent"
72
+ token = s.split()[-1]
73
+ fields.add(token)
74
+ return fields
75
+
76
+
77
+ def main():
78
+ ap = argparse.ArgumentParser(description="审计 iv8 默认值,定位必配候选 / 与目标读过字段求交集")
79
+ ap.add_argument("--group", default="", help="只显示该一级前缀(如 navigator / webgl)")
80
+ ap.add_argument("--empty-only", action="store_true", help="只列空/占位默认(必配候选)")
81
+ ap.add_argument("--read-fields", default="", help="目标读过的字段清单文件,求交集")
82
+ args = ap.parse_args()
83
+
84
+ defaults = iv8.JSContext.get_defaults()
85
+ print(f"iv8 get_defaults(): 共 {len(defaults)} 条支持路径\n")
86
+
87
+ # ---- 分组 ----
88
+ groups = {}
89
+ for path in sorted(defaults):
90
+ groups.setdefault(group_of(path), []).append(path)
91
+
92
+ def emit(path):
93
+ val = defaults[path]
94
+ flag = " ⚠必配候选(空/占位)" if is_empty_default(val) else ""
95
+ print(f" {path} = {val!r}{flag}")
96
+
97
+ for gname in sorted(groups):
98
+ if args.group and gname != args.group:
99
+ continue
100
+ rows = groups[gname]
101
+ if args.empty_only:
102
+ rows = [p for p in rows if is_empty_default(defaults[p])]
103
+ if not rows:
104
+ continue
105
+ print(f"[{gname}] ({len(rows)} 条)")
106
+ for path in rows:
107
+ emit(path)
108
+ print()
109
+
110
+ # ---- 与目标读过的字段求交集 ----
111
+ if args.read_fields:
112
+ read = load_read_fields(args.read_fields)
113
+ default_paths = set(defaults)
114
+ # 直接命中 + 前缀命中(目标读 navigator.userAgent,defaults 有该路径)
115
+ hit = sorted(read & default_paths)
116
+ # 目标读了但 defaults 里没有的路径 → 可能是缺失 API / 陷阱,走 trap 纪律
117
+ miss = sorted(read - default_paths)
118
+
119
+ print("=" * 72)
120
+ print("交集(monitor∩defaults)= 目标读到且 iv8 有默认可覆盖 = 真正要配的清单")
121
+ print("=" * 72)
122
+ for path in hit:
123
+ emit(path)
124
+ if not hit:
125
+ print(" (无交集:目标读的字段都不在 defaults 里,见下方 miss)")
126
+
127
+ if miss:
128
+ print("\n" + "-" * 72)
129
+ print("⚠ 目标读了、但 defaults 无此路径 —— 可能是缺失 API / 反爬陷阱:")
130
+ print(" 默认按陷阱处理,除非真机核对确有值。见 references/trap-blacklist.md")
131
+ print("-" * 72)
132
+ for path in miss:
133
+ print(f" {path}")
134
+
135
+ print(
136
+ "\n[下一步] 交集清单填进 assets/env_starter.py 的 environment/config;\n"
137
+ " 填的值做一致性静态校验 → scripts/consistency_lint.py;\n"
138
+ " 值怎么填 / 一致性红线见 references/config-defaults-cheatsheet.md。"
139
+ )
140
+
141
+
142
+ if __name__ == "__main__":
143
+ main()
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """monitor_env.py —— P2 发现阶段核心工具:跑一遍目标 JS,抓 iv8 C++ 层监控日志分六类。
4
+
5
+ 作用(对应 SKILL.md 的 P2「起手跑 + 发现缺口」):
6
+ 用 iv8 mode='debug' 加载目标 JS 并触发一次签名/取数,把引擎在 C++ 层记录的
7
+ 「浏览器 API 访问链路 + JS 内置反射路径」实时打到 stdout(fd1)/stderr(fd2),
8
+ 本脚本用 fd 重定向把它落盘再正则解析,按六类打印:
9
+ ① 属性读 getter —— 目标读了哪些指纹字段(配 environment 的清单来源)
10
+ ② 属性写 setter —— 目标改/挂了什么(常见是 XHR.open / fetch 的 hook)
11
+ ③ 方法调用 call
12
+ ④ 存在性探测 query(typeof / in,反爬最爱)
13
+ ⑤ 反射层 —— Object.keys / getOwnPropertyDescriptor / toString / JSON.* 等探测
14
+ ⑥ ⚠ 疑似缺失 API —— 目标摸了但 iv8 未实现(单列,见下方处置纪律)
15
+
16
+ 第⑥类「疑似缺失 API」的处置纪律(贯穿本 skill 的三态原则):
17
+ 默认按「陷阱」处理 —— 绝不无脑补。process / _phantom / __webdriver_script_fn /
18
+ StyleMedia / Float16Array 之类是反爬故意探测的不存在物,补了反而暴露。
19
+ 只有在真机(真实 Chrome)核对确认「真实浏览器里此 API 确有值」时,才配置/补它。
20
+ 详见 references/trap-blacklist.md 与 references/patching-principles.md。
21
+
22
+ 关系与边界:
23
+ - 本脚本只做「发现」:告诉你目标读了什么、探测了什么、摸了哪些缺失物。
24
+ - 「读了什么」→ 拿去 environment 配值,交集算法见 scripts/defaults_audit.py。
25
+ - 抛 TypeError 的 stub 窟窿(如 MessageChannel port=null)→ scripts/reproduce_stub_error.py。
26
+ - stub 替换按 references/patching-principles.md 的 iv8 原生补丁契约执行。
27
+ - 权威 API 契约见 references/iv8-api-contract.md;坑位版本门控见 references/version-gotchas.md。
28
+
29
+ 用法:
30
+ python monitor_env.py <目标JS路径> [--url <触发URL>] [--trigger <触发JS表达式文件>]
31
+ [--env <environment JSON文件>] [--log <日志落盘路径>]
32
+
33
+ 最小用法(零配跑,观察默认环境下都缺什么):
34
+ python monitor_env.py ./target.js
35
+ 带触发(脚本加载后还需调一次才产签名):
36
+ python monitor_env.py ./target.js --trigger ./trigger.js
37
+ trigger.js 内是一段 JS(示例):
38
+ var x = new XMLHttpRequest(); x.open('GET','https://api.example.com/d'); x.send();
39
+ window.__iv8__.eventLoop.drain(); window.__iv8__.netLog.entries;
40
+
41
+ 注意:社区版无真实网络栈,--url 只用于把 URL 拼进默认触发表达式;真请求/响应注入见
42
+ scripts/network_bridge.py 与 references/network-and-eventloop.md。
43
+ """
44
+
45
+ import argparse
46
+ import json
47
+ import os
48
+ import re
49
+ import sys
50
+ from collections import Counter
51
+
52
+ try:
53
+ import iv8
54
+ except ImportError:
55
+ sys.exit("未找到 iv8。先跑 scripts/preflight.py 确认安装(安装见 references/cross-platform-install.md)。")
56
+
57
+
58
+ # ---- 日志正则:匹配 iv8 debug 模式在 C++ 层打印的监控行 ----
59
+ ANSI = re.compile(r"\x1b\[[0-9;]*m")
60
+ RE_INST = re.compile(r"实例访问 - (.+?) -> (getter|setter|call|query)")
61
+ RE_REFL = re.compile(r"反射层 - (.+?) - 目标:([^\[\-]+)")
62
+ RE_MISS = re.compile(r"疑似缺失API - (\S+)")
63
+
64
+
65
+ def build_default_trigger(url):
66
+ """没给 --trigger 时的兜底触发:发一次 XHR + drain + 读 netLog。
67
+
68
+ drain 是「drain-before-read」纪律:社区版异步 XHR 回调不排空就读不到结果。
69
+ 完整事件循环推进语义见 references/network-and-eventloop.md。
70
+ """
71
+ if not url:
72
+ # 纯加载即产签名的目标:不触发,只 drain 一轮微任务
73
+ return "window.__iv8__.eventLoop.drain();"
74
+ safe_url = json.dumps(url)
75
+ return (
76
+ "var __x = new XMLHttpRequest();"
77
+ "__x.open('GET', %s, true);"
78
+ "try { __x.send(null); } catch (e) {}"
79
+ "window.__iv8__.eventLoop.drain();"
80
+ "window.__iv8__.netLog.entries;" % safe_url
81
+ )
82
+
83
+
84
+ def run_and_capture(js_path, trigger_src, env, log_path):
85
+ """在 debug 模式下加载目标并触发一次,把 C++ 监控日志重定向到 log_path。"""
86
+ with open(js_path, "r", encoding="utf-8") as f:
87
+ js = f.read()
88
+
89
+ # fd1 + fd2 重定向到文件,捕获引擎层 debug 输出(Python print 也会进同一文件)
90
+ saved_out, saved_err = os.dup(1), os.dup(2)
91
+ fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
92
+ os.dup2(fd, 1)
93
+ os.dup2(fd, 2)
94
+ os.close(fd)
95
+ try:
96
+ # mode='debug' 打开 API 监控;environment 只放 JS 可观测画像(可含数组)
97
+ with iv8.JSContext(environment=env, mode="debug") as ctx:
98
+ ctx.eval(js, name=os.path.basename(js_path))
99
+ if trigger_src:
100
+ ctx.eval(trigger_src, name="__trigger__")
101
+ finally:
102
+ os.dup2(saved_out, 1)
103
+ os.dup2(saved_err, 2)
104
+ os.close(saved_out)
105
+ os.close(saved_err)
106
+
107
+
108
+ def parse_log(log_path):
109
+ """解析监控日志,返回六个 Counter。"""
110
+ reads, writes, calls, queries, refl, missing = (Counter() for _ in range(6))
111
+ total = 0
112
+ with open(log_path, "r", encoding="utf-8", errors="replace") as fh:
113
+ for raw in fh:
114
+ line = ANSI.sub("", raw)
115
+ total += 1
116
+ m = RE_INST.search(line)
117
+ if m:
118
+ api, kind = m.group(1).strip(), m.group(2)
119
+ if "__iv8__" in api: # 工具对象自身不算目标行为
120
+ continue
121
+ {"getter": reads, "setter": writes,
122
+ "call": calls, "query": queries}[kind][api] += 1
123
+ continue
124
+ m = RE_REFL.search(line)
125
+ if m:
126
+ refl[f"{m.group(1).strip()} → {m.group(2).strip()}"] += 1
127
+ continue
128
+ m = RE_MISS.search(line)
129
+ if m:
130
+ missing[m.group(1)] += 1
131
+ return total, reads, writes, calls, queries, refl, missing
132
+
133
+
134
+ def show(title, counter, top):
135
+ print(f"\n{'=' * 72}\n{title}\n 去重 {len(counter)} / 命中 {sum(counter.values())}\n{'=' * 72}")
136
+ for k, v in counter.most_common(top):
137
+ print(f" {v:6d} {k}")
138
+
139
+
140
+ def main():
141
+ ap = argparse.ArgumentParser(description="iv8 debug 监控:跑目标 JS,六类打印 API 访问链路")
142
+ ap.add_argument("js_path", help="目标 JS 文件路径")
143
+ ap.add_argument("--url", default="", help="触发用 URL(拼进默认 XHR 触发表达式)")
144
+ ap.add_argument("--trigger", default="", help="触发 JS 表达式文件路径(覆盖默认触发)")
145
+ ap.add_argument("--env", default="", help="environment 画像 JSON 文件路径(省略=零配跑)")
146
+ ap.add_argument("--log", default="", help="监控日志落盘路径(默认 <js>.monitor.log)")
147
+ args = ap.parse_args()
148
+
149
+ if not os.path.isfile(args.js_path):
150
+ sys.exit(f"目标 JS 不存在: {args.js_path}")
151
+
152
+ env = None
153
+ if args.env:
154
+ with open(args.env, "r", encoding="utf-8") as f:
155
+ env = json.load(f)
156
+
157
+ if args.trigger:
158
+ with open(args.trigger, "r", encoding="utf-8") as f:
159
+ trigger_src = f.read()
160
+ else:
161
+ trigger_src = build_default_trigger(args.url)
162
+
163
+ log_path = args.log or (args.js_path + ".monitor.log")
164
+
165
+ run_and_capture(args.js_path, trigger_src, env, log_path)
166
+ total, reads, writes, calls, queries, refl, missing = parse_log(log_path)
167
+
168
+ print(f"\n日志总行数: {total} (原始: {log_path})")
169
+ show("① 属性读 getter —— 目标读了哪些字段(→ environment 配值清单来源)", reads, 60)
170
+ show("② 属性写 setter —— 目标改/挂了什么(常见 XHR.open / fetch hook)", writes, 30)
171
+ show("③ 方法调用 call —— 目标调了哪些 API", calls, 40)
172
+ show("④ 存在性探测 query(typeof / in,反爬最爱)", queries, 40)
173
+ show("⑤ 反射层 —— Object.keys / getOwnPropertyDescriptor / toString / JSON.*", refl, 30)
174
+ show("⑥ ⚠ 疑似缺失 API(目标摸了但 iv8 未实现)", missing, 80)
175
+ print(
176
+ "\n[⑥ 处置纪律] 默认按陷阱处理,除非真机核对确有值。\n"
177
+ " 绝不无脑补的探测物(process/_phantom/__webdriver_script_fn/StyleMedia/Float16Array 等)\n"
178
+ " 见 references/trap-blacklist.md;三态与补漏原则见 references/patching-principles.md。\n"
179
+ "[下一步] ① 读到的字段 → scripts/defaults_audit.py 求交集算真正要配的;\n"
180
+ " 抛 TypeError 的 stub 窟窿 → scripts/reproduce_stub_error.py。"
181
+ )
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ network_bridge.py — iv8 真实网络桥接(阶段 P3,固化四步握手)
5
+
6
+ ======================================================================
7
+ 四步握手红线(社区版无真实网络传输栈,真请求必须走 Python 侧):
8
+ 1) JS 侧发起 XHR/fetch —— iv8 只记账到 netLog,不真正发出请求。
9
+ 2) 暂停在这里,从 netLog 取出目标请求(url/method/headers/body)。
10
+ 3) Python 侧用真实 HTTP 客户端(requests / curl_cffi ...)真发,
11
+ 代理 / TLS 指纹 / cookie 池 / verify 由调用者决定。
12
+ 4) ctx.add_resource(url, body, status, headers) 注入响应,
13
+ 再 eventLoop.drain() 推进事件循环 —— XHR 回调此时才命中注入的响应。
14
+ 顺序错了(比如没 drain 就读结果,或没 add_resource 就 drain)都会拿到空/半成品。
15
+
16
+ ⚠ GIL 警告:
17
+ ctx.expose() 暴露给 JS 的 Python 函数,在被 JS 调用期间会持有 GIL 并阻塞
18
+ 当前 V8 执行与其他 Python 线程,是完全同步阻塞的。因此:
19
+ - 暴露的桥接函数要尽量轻量,别在里面塞重逻辑;
20
+ - 高并发场景优先用下面 bridge_request() 的「外部真发 + add_resource 注入」模式,
21
+ 而不是把 requests.get 直接 expose 进 JS 里循环调用。
22
+ ======================================================================
23
+
24
+ 用途:
25
+ 提供一个可复用的 bridge_request():在 iv8 里让目标 JS 发起请求 → 从 netLog 捞出
26
+ 该请求 → Python 侧真发 → add_resource 注入响应 → drain 推进 → 返回真实响应。
27
+ 以及 __main__ 里一段最小可跑演示。
28
+
29
+ 用法(库调用):
30
+ import iv8, requests
31
+ from network_bridge import bridge_request
32
+ with iv8.JSContext(environment={...}) as ctx:
33
+ ctx.eval(open("target.js").read())
34
+ # 让目标 JS 发起它的请求(这里只入 netLog,不真发)
35
+ ctx.eval("window.startCollect()")
36
+ resp_text = bridge_request(
37
+ ctx,
38
+ url_substr="/api/collect", # 用于在 netLog 中定位目标请求
39
+ session=requests.Session(), # 由调用者提供,携带代理/cookie/TLS 策略
40
+ verify=False, # curl_cffi/requests 自签名场景可关
41
+ )
42
+
43
+ 本文件属于 iv8-env-runtime skill。
44
+ 交叉引用:事件循环 drain/advance 语义与 netLog 结构见
45
+ references/network-and-eventloop.md;
46
+ iv8 权威 API 见 references/iv8-api-contract.md。
47
+ """
48
+
49
+ import sys
50
+
51
+
52
+ def _find_entry(netlog, url_substr):
53
+ """在 netLog.entries 里按 url 子串定位目标请求,取最后一条匹配(通常是最新)。"""
54
+ match = None
55
+ for e in netlog:
56
+ if not isinstance(e, dict):
57
+ continue
58
+ if url_substr in str(e.get("url", "")):
59
+ match = e
60
+ return match
61
+
62
+
63
+ def bridge_request(ctx, url_substr, session=None, method=None,
64
+ extra_headers=None, verify=True, proxies=None, timeout=15):
65
+ """执行一次四步握手,返回注入到 iv8 后由 JS 侧收到的响应体文本。
66
+
67
+ 参数:
68
+ ctx : 已载入目标 JS 且已触发发请求的 iv8.JSContext。
69
+ url_substr : 在 netLog 中定位目标请求的 url 子串。
70
+ session : 调用者提供的 HTTP 客户端(requests.Session / curl_cffi.Session);
71
+ None 时用一次性 requests。代理/TLS 指纹/cookie 全由它决定。
72
+ method : 覆盖请求方法;None 时用 netLog 记的方法。
73
+ extra_headers: 追加/覆盖到真发请求上的头(如补 cookie/referer)。
74
+ verify : 传给客户端的 TLS 校验开关(curl_cffi 自签名场景常关)。
75
+ proxies : 传给客户端的代理配置。
76
+ timeout : 真发超时秒数。
77
+
78
+ 返回:注入后的响应体文本(同时已 add_resource + drain,JS 侧回调已命中)。
79
+ """
80
+ # 步骤 2:从 netLog 捞出目标请求(此前 JS 已 step 1 发起,仅入账未真发)
81
+ netlog = ctx.eval("window.__iv8__.netLog.entries", to_py=True)
82
+ entry = _find_entry(netlog, url_substr)
83
+ if entry is None:
84
+ raise LookupError(
85
+ "netLog 中没有匹配 '{}' 的请求:确认目标 JS 已真的发起该请求,"
86
+ "且 url_substr 写对了。当前 netLog 有 {} 条。".format(url_substr, len(netlog)))
87
+
88
+ url = entry.get("url")
89
+ req_method = (method or entry.get("method") or "GET").upper()
90
+ headers = dict(entry.get("headers") or {})
91
+ if extra_headers:
92
+ headers.update(extra_headers)
93
+ body = entry.get("body") or entry.get("postData")
94
+
95
+ # 步骤 3:Python 侧真发(客户端由调用者决定,携带代理/cookie/TLS 指纹)
96
+ if session is None:
97
+ import requests
98
+ session = requests.Session()
99
+ resp = session.request(req_method, url, headers=headers, data=body,
100
+ verify=verify, proxies=proxies, timeout=timeout)
101
+
102
+ # 步骤 4:注入响应 → drain 推进 → JS 侧 XHR 回调命中
103
+ ctx.add_resource(
104
+ url=url,
105
+ body=resp.text,
106
+ status=resp.status_code,
107
+ headers=dict(resp.headers),
108
+ )
109
+ ctx.eval("window.__iv8__.eventLoop.drain()")
110
+ return resp.text
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # 最小可跑演示(需已安装 iv8 与 requests):JS 发 XHR → 桥接真发 httpbin → 注入 → 读结果
115
+ # ---------------------------------------------------------------------------
116
+ def _demo():
117
+ try:
118
+ import iv8
119
+ import requests # noqa: F401 —— bridge_request 内部用;此处仅校验可导入
120
+ except Exception as e: # noqa: BLE001
121
+ print("[network_bridge][demo] 依赖缺失:{};先跑 preflight.py。".format(e), file=sys.stderr)
122
+ return 2
123
+
124
+ with iv8.JSContext() as ctx:
125
+ ctx.eval("""
126
+ window.__iv8__.page.load({
127
+ baseURL: 'https://example.com',
128
+ html: '<html><body></body></html>'
129
+ });
130
+ """)
131
+ # 步骤 1:JS 侧发起异步 XHR(社区版只入 netLog,不真发)
132
+ ctx.eval("""
133
+ var xhr = new XMLHttpRequest();
134
+ xhr.open('GET', 'https://httpbin.org/get?foo=bar');
135
+ xhr.onload = function () { window._demoResult = xhr.responseText; };
136
+ xhr.send();
137
+ """)
138
+ # 步骤 2/3/4:桥接
139
+ body = bridge_request(ctx, url_substr="httpbin.org/get")
140
+ print("[network_bridge][demo] 注入响应长度:{}".format(len(body)))
141
+ got = ctx.eval("window._demoResult")
142
+ print("[network_bridge][demo] JS 侧收到长度:{}".format(len(got) if got else 0))
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(_demo())
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """parity_check.py —— P4 验收对账:iv8 产物 vs 真机产物,逐字符/逐字段找首个差异。
4
+
5
+ 作用(对应 SKILL.md 的 P4「parity 对账 + 服务器判决唯一真相」):
6
+ 把「iv8 补环境跑出来的签名/token」和「真实浏览器抓到的同场景签名/token」放一起,
7
+ 逐字符(或逐字段,若是 JSON/query-string)对比,定位第一个不一致的位置,
8
+ 帮你把「补环境哪里还没对齐」收窄到具体字段。
9
+
10
+ ⚠ 铁律(本 skill 的验收真相层):
11
+ 本地一致只是「必要不充分」条件。parity 全绿也不代表过盾——
12
+ 很多签名含时间戳/随机盐,本来就该逐次不同;对账目的是排查「结构性字段」对齐,
13
+ 不是要求整串相等。**唯一的最终真相是把 iv8 产物发到目标服务器,看它判 200 / code=0。**
14
+ 算出来被服务器拒 → 回 P3 继续补环境;返回 403(风控/IP/频率)→ 不是环境问题,别在补环境里空转。
15
+ 服务器判决怎么读见 references/part2-discovery.md。
16
+
17
+ 对账模式(自动判定,也可 --mode 强制):
18
+ - raw :整串逐字符对比,报首个差异下标 + 两侧上下文窗口
19
+ - kv :query-string(a=1&b=2)逐键对比,报缺键/多键/值不同
20
+ - json :JSON 逐字段递归对比,报首个路径差异
21
+
22
+ 关系与边界:
23
+ - 上游:iv8 产物来自 assets/env_starter.py 跑出的签名;真机产物来自 js-reverse MCP
24
+ 或真实浏览器抓包(找入口/抓真机产物见 references/part1-entry-location.md)。
25
+ - 本脚本只做「本地一致性」这一必要条件;充分条件(服务器判决)由你手动发包核对。
26
+
27
+ 用法:
28
+ python parity_check.py <iv8产物> <真机产物> [--mode raw|kv|json] [--file]
29
+ 默认两个位置参数是「值本身」;加 --file 则当作文件路径读取内容。
30
+ 示例:
31
+ python parity_check.py "a_bogus=xxx" "a_bogus=yyy"
32
+ python parity_check.py iv8_out.txt real_out.txt --file
33
+ python parity_check.py iv8.json real.json --file --mode json
34
+ """
35
+
36
+ import argparse
37
+ import json
38
+ import sys
39
+ from urllib.parse import parse_qsl
40
+
41
+
42
+ def _read(value, is_file):
43
+ if not is_file:
44
+ return value
45
+ with open(value, "r", encoding="utf-8") as f:
46
+ return f.read().strip()
47
+
48
+
49
+ def guess_mode(a, b):
50
+ """自动判定对账模式。"""
51
+ for s in (a, b):
52
+ t = s.strip()
53
+ if t[:1] in "{[":
54
+ try:
55
+ json.loads(t)
56
+ return "json"
57
+ except ValueError:
58
+ pass
59
+ if ("=" in a and ("&" in a or "&" in b)) or ("=" in a and "=" in b and " " not in a):
60
+ return "kv"
61
+ return "raw"
62
+
63
+
64
+ def check_raw(a, b):
65
+ """逐字符对比,返回 (是否一致, 报告文本)。"""
66
+ if a == b:
67
+ return True, f"完全一致(长度 {len(a)})"
68
+ n = min(len(a), len(b))
69
+ idx = next((i for i in range(n) if a[i] != b[i]), n)
70
+ lo = max(0, idx - 20)
71
+ win_a = a[lo:idx + 20]
72
+ win_b = b[lo:idx + 20]
73
+ report = [
74
+ f"首个差异位置: 下标 {idx}(长度 iv8={len(a)} / 真机={len(b)})",
75
+ f" iv8 : ...{win_a!r}",
76
+ f" 真机: ...{win_b!r}",
77
+ ]
78
+ if idx < n:
79
+ report.append(f" 差异字符: iv8={a[idx]!r} 真机={b[idx]!r}")
80
+ else:
81
+ longer = "iv8" if len(a) > len(b) else "真机"
82
+ report.append(f" 前 {idx} 字符相同,{longer} 更长(尾部多出内容)")
83
+ return False, "\n".join(report)
84
+
85
+
86
+ def check_kv(a, b):
87
+ """query-string 逐键对比。"""
88
+ da = dict(parse_qsl(a, keep_blank_values=True))
89
+ db = dict(parse_qsl(b, keep_blank_values=True))
90
+ keys = list(dict.fromkeys(list(da) + list(db)))
91
+ diffs = []
92
+ for k in keys:
93
+ if k not in da:
94
+ diffs.append(f" [多键] 真机有、iv8 缺: {k}={db[k]!r}")
95
+ elif k not in db:
96
+ diffs.append(f" [缺键] iv8 有、真机缺: {k}={da[k]!r}")
97
+ elif da[k] != db[k]:
98
+ diffs.append(f" [值异] {k}: iv8={da[k]!r} 真机={db[k]!r}")
99
+ if not diffs:
100
+ return True, f"所有键值一致(共 {len(keys)} 键)"
101
+ return False, f"共 {len(diffs)} 处键差异:\n" + "\n".join(diffs)
102
+
103
+
104
+ def _first_json_diff(a, b, path="$"):
105
+ """递归找 JSON 首个字段差异,返回路径描述或 None。"""
106
+ if type(a) is not type(b) and not (isinstance(a, (int, float)) and isinstance(b, (int, float))):
107
+ return f"{path}: 类型不同 iv8={type(a).__name__} 真机={type(b).__name__}"
108
+ if isinstance(a, dict):
109
+ for k in dict.fromkeys(list(a) + list(b)):
110
+ if k not in a:
111
+ return f"{path}.{k}: iv8 缺此键(真机={b[k]!r})"
112
+ if k not in b:
113
+ return f"{path}.{k}: 真机缺此键(iv8={a[k]!r})"
114
+ d = _first_json_diff(a[k], b[k], f"{path}.{k}")
115
+ if d:
116
+ return d
117
+ return None
118
+ if isinstance(a, list):
119
+ if len(a) != len(b):
120
+ return f"{path}: 数组长度不同 iv8={len(a)} 真机={len(b)}"
121
+ for i, (x, y) in enumerate(zip(a, b)):
122
+ d = _first_json_diff(x, y, f"{path}[{i}]")
123
+ if d:
124
+ return d
125
+ return None
126
+ if a != b:
127
+ return f"{path}: 值不同 iv8={a!r} 真机={b!r}"
128
+ return None
129
+
130
+
131
+ def check_json(a, b):
132
+ try:
133
+ ja, jb = json.loads(a), json.loads(b)
134
+ except ValueError as e:
135
+ return False, f"JSON 解析失败: {e}(改用 --mode raw)"
136
+ diff = _first_json_diff(ja, jb)
137
+ if diff is None:
138
+ return True, "所有字段一致"
139
+ return False, "首个字段差异:\n " + diff
140
+
141
+
142
+ def main():
143
+ ap = argparse.ArgumentParser(description="iv8 产物 vs 真机产物 对账;服务器判决才是最终真相")
144
+ ap.add_argument("iv8_value", help="iv8 补环境产物(值,或加 --file 传路径)")
145
+ ap.add_argument("real_value", help="真机产物(值,或加 --file 传路径)")
146
+ ap.add_argument("--mode", choices=["raw", "kv", "json"], default="auto", help="对账模式(默认自动判定)")
147
+ ap.add_argument("--file", action="store_true", help="把两个位置参数当作文件路径读取")
148
+ args = ap.parse_args()
149
+
150
+ a = _read(args.iv8_value, args.file)
151
+ b = _read(args.real_value, args.file)
152
+
153
+ mode = args.mode if args.mode != "auto" else guess_mode(a, b)
154
+ print(f"对账模式: {mode}\n")
155
+
156
+ ok, report = {"raw": check_raw, "kv": check_kv, "json": check_json}[mode](a, b)
157
+ print(report)
158
+
159
+ print("\n" + "=" * 72)
160
+ if ok:
161
+ print("本地对账: 一致 ✓(必要条件满足)")
162
+ else:
163
+ print("本地对账: 不一致 ✗ → 回 P3 补环境(对准差异字段)")
164
+ print(
165
+ "⚠ 本地一致只是必要不充分。最终真相 = 把 iv8 产物发到目标服务器看判决:\n"
166
+ " 服务器 200 / code=0 才算翻过;被拒→回 P3;403(风控/IP/频率)→ 非环境问题。\n"
167
+ " (含时间戳/随机盐的签名本就逐次不同,对账看结构性字段而非整串相等。)\n"
168
+ " 服务器判决怎么读见 references/part2-discovery.md。"
169
+ )
170
+ # 退出码:本地一致 0 / 不一致 1(便于脚本串联),但仍以服务器判决为准
171
+ sys.exit(0 if ok else 1)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()