First commit

This commit is contained in:
牟不懂哎
2026-04-02 21:38:29 +08:00
commit 22326cf1b2
85 changed files with 24385 additions and 0 deletions
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+81
View File
@@ -0,0 +1,81 @@
import argparse
import os
import sys
DEBUG_PORT = 9421
CDP_PORT = 62000
class CliOptions:
def __init__(self, debug_port: int, cdp_port: int, debug_main: bool, debug_frida: bool,
scripts_dir: str = "", script_files: list = None):
self.debug_port = debug_port
self.cdp_port = cdp_port
self.debug_main = debug_main
self.debug_frida = debug_frida
self.scripts_dir = scripts_dir
self.script_files = script_files or []
def parse_port(name: str, value, default_value: int) -> int:
if value is None:
return default_value
try:
port = int(value)
except (ValueError, TypeError):
raise ValueError(f"[main] invalid {name}: {value}")
if port < 1 or port > 65535:
raise ValueError(f"[main] invalid {name}: {value}")
return port
def parse_cli_options() -> CliOptions:
parser = argparse.ArgumentParser(
description="First - WeChat Miniapp Debugger",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f"""Examples:
python main.py
python main.py --debug-port 9421 --cdp-port 62000
python main.py --debug-main --debug-frida
Default ports:
Debug server: {DEBUG_PORT}
CDP proxy: {CDP_PORT}
Usage:
Connect Chrome DevTools to: devtools://devtools/bundled/inspector.html?ws=127.0.0.1:<cdp-port>"""
)
parser.add_argument("--debug-port", type=str, default=None,
help=f"Remote debug server port (default: {DEBUG_PORT})")
parser.add_argument("--cdp-port", type=str, default=None,
help=f"CDP proxy server port (default: {CDP_PORT})")
parser.add_argument("--debug-main", action="store_true", default=False,
help="Output main process debug messages")
parser.add_argument("--debug-frida", action="store_true", default=False,
help="Output Frida client messages")
parser.add_argument("--scripts-dir", type=str, default=None,
help="UserScripts directory path (default: ./userscripts)")
parser.add_argument("--script", type=str, action="append", default=None,
help="Specific .js file(s) to inject (can be used multiple times). "
"When set, --scripts-dir is ignored.")
args = parser.parse_args()
if args.scripts_dir:
scripts_dir = os.path.abspath(args.scripts_dir)
else:
scripts_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "userscripts")
script_files = []
if args.script:
for f in args.script:
script_files.append(os.path.abspath(f))
return CliOptions(
debug_port=parse_port("--debug-port", args.debug_port, DEBUG_PORT),
cdp_port=parse_port("--cdp-port", args.cdp_port, CDP_PORT),
debug_main=args.debug_main,
debug_frida=args.debug_frida,
scripts_dir=scripts_dir,
script_files=script_files,
)
+245
View File
@@ -0,0 +1,245 @@
"""
CloudAuditor — 全局云函数捕获(只显示实际触发过的)+ 手动调用
"""
import asyncio
import json
from pathlib import Path
_CLOUD_JS = (Path(__file__).parent / "cloud_audit_inject.js").read_text(encoding="utf-8")
class CloudAuditor:
"""全局云函数 Hook 捕获"""
def __init__(self, engine):
self.engine = engine
self._injected = False
self._enabled = False
self._seen_count = 0 # 上次拉取到的 hookedCalls 长度
async def inject(self):
await self.engine.evaluate_js(_CLOUD_JS, timeout=10.0)
self._injected = True
async def start(self):
"""启动全局 Hook:注入 JS,安装 hook,启动自动扫描新 frame"""
if self._enabled:
return {"ok": True, "already": True}
await self.inject()
result = await self.engine.evaluate_js(
"JSON.stringify(window.cloudAudit.installHook())", timeout=5.0)
value = self._extract_value(result)
if value:
try:
info = json.loads(value)
if info.get("ok"):
self._enabled = True
return info
except (json.JSONDecodeError, TypeError):
pass
return {"ok": False}
async def stop(self):
"""停止 Hook"""
if not self._enabled:
return
self._enabled = False
try:
await self.inject()
await self.engine.evaluate_js(
"window.cloudAudit.uninstallHook()", timeout=5.0)
except Exception:
pass
async def poll(self):
"""拉取新捕获的云函数调用,只返回增量"""
if not self._enabled:
return []
try:
await self.inject()
result = await self.engine.evaluate_js(
"JSON.stringify(window.cloudAudit.getHookedCalls())", timeout=5.0)
value = self._extract_value(result)
if not value:
return []
calls = json.loads(value)
if len(calls) <= self._seen_count:
return []
new_calls = calls[self._seen_count:]
self._seen_count = len(calls)
return new_calls
except Exception:
return []
async def clear(self):
"""清空捕获记录"""
self._seen_count = 0
try:
await self.inject()
await self.engine.evaluate_js(
"window.cloudAudit.clearHookedCalls()", timeout=3.0)
except Exception:
pass
async def static_scan(self, on_progress=None):
"""静态扫描:通过 CDP Debugger 获取 JS 源码,提取云函数引用"""
import re
_RE_NAME = re.compile(r'name\s*:\s*["\']([^"\']+)["\']')
_RE_DATA = re.compile(r'data\s*:\s*\{([^}]{1,500})\}')
_RE_FIELD = re.compile(r'(\w+)\s*:')
_RE_COLL = re.compile(r'\.collection\s*\(\s*["\']([^"\']+)["\']\s*\)')
_SKIP = {"name", "success", "fail", "complete", "config", "env", "data"}
_FILE_M = {"uploadFile", "downloadFile", "deleteFile", "getTempFileURL"}
_DB_OPS = {"add", "get", "update", "remove", "count", "aggregate", "doc", "where"}
# 获取当前 appId
await self.inject()
try:
r = await self.engine.evaluate_js(
"JSON.stringify(window.cloudAudit.detectEnv())", timeout=5.0)
v = self._extract_value(r)
if v:
info = json.loads(v)
if info.get("ok"):
self._current_appid = info.get("appId", "")
except Exception:
pass
appid = getattr(self, '_current_appid', '')
# 收集脚本
script_ids = []
def _on_parsed(data):
p = data.get("params", {})
sid = p.get("scriptId")
if sid:
script_ids.append((sid, p.get("url", "")))
self.engine.on_cdp_event("Debugger.scriptParsed", _on_parsed)
try:
try:
await self.engine.send_cdp_command("Debugger.disable", timeout=3.0)
except Exception:
pass
await asyncio.sleep(0.2)
await self.engine.send_cdp_command("Debugger.enable", timeout=5.0)
await asyncio.sleep(1.5)
prev = 0
for _ in range(5):
if len(script_ids) == prev and prev > 0:
break
prev = len(script_ids)
await asyncio.sleep(0.4)
except Exception:
pass
finally:
self.engine.off_cdp_event("Debugger.scriptParsed", _on_parsed)
if on_progress:
on_progress(f"发现 {len(script_ids)} 个脚本,开始扫描...")
found = {}
for i, (sid, url) in enumerate(script_ids):
if on_progress and i % 10 == 0:
on_progress(f"扫描中... ({i}/{len(script_ids)}) 已发现 {len(found)} 个")
try:
resp = await self.engine.send_cdp_command(
"Debugger.getScriptSource", {"scriptId": sid}, timeout=8.0)
source = resp.get("result", {}).get("scriptSource", "")
if not source:
continue
# callFunction
pos = 0
while True:
pos = source.find("callFunction", pos)
if pos == -1:
break
w = source[pos:pos+1000]
nm = _RE_NAME.search(w)
if nm:
name = nm.group(1)
key = f"fn:{name}"
if key not in found:
found[key] = {"type": "function", "name": name, "params": [], "count": 0}
found[key]["count"] += 1
dm = _RE_DATA.search(w)
if dm:
for fm in _RE_FIELD.finditer(dm.group(1)):
f = fm.group(1)
if f not in _SKIP and f not in found[key]["params"]:
found[key]["params"].append(f)
pos += 12
# collection
for m in _RE_COLL.finditer(source):
coll = m.group(1)
key = f"db:{coll}"
if key not in found:
found[key] = {"type": "database", "name": coll, "params": [], "count": 0}
found[key]["count"] += 1
after = source[m.end():m.end()+300]
for op in _DB_OPS:
if f".{op}(" in after and op not in found[key]["params"]:
found[key]["params"].append(op)
# storage
for fm in _FILE_M:
if fm in source:
key = f"storage:{fm}"
if key not in found:
found[key] = {"type": "storage", "name": fm, "params": [], "count": 0}
found[key]["count"] += 1
except Exception:
continue
try:
await self.engine.send_cdp_command("Debugger.disable", timeout=3.0)
except Exception:
pass
results = []
for key, info in found.items():
results.append({
"name": info["name"],
"type": info["type"],
"appId": appid,
"params": info["params"],
"count": info["count"],
})
if on_progress:
on_progress(f"扫描完成,发现 {len(results)} 个云函数引用")
return results
async def call_function(self, name, data=None):
await self.inject()
safe_name = name.replace("'", "\\'")
safe_data = json.dumps(data or {}, ensure_ascii=False)
await self.engine.evaluate_js(
"window._cloudAuditLastResult=null", timeout=3.0)
js = (f"window.cloudAudit.callFunction('{safe_name}', {safe_data})"
f".then(function(r){{window._cloudAuditLastResult=JSON.stringify(r)}})"
f"['catch'](function(e){{window._cloudAuditLastResult="
f"JSON.stringify({{ok:false,status:'fail',error:e.message||String(e)}})}})")
await self.engine.evaluate_js(js, timeout=15.0)
for _ in range(24):
await asyncio.sleep(0.5)
result = await self.engine.evaluate_js(
"window._cloudAuditLastResult", timeout=5.0)
value = self._extract_value(result)
if value:
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return {"ok": False, "status": "fail", "reason": "parse error"}
return {"ok": False, "status": "fail", "reason": "调用超时,无响应"}
def export_report(self, all_items, call_history):
return {
"captured_calls": all_items,
"call_history": {k: v for k, v in call_history.items() if v},
}
@staticmethod
def _extract_value(result):
if not result:
return None
r = result.get("result", {})
inner = r.get("result", {})
return inner.get("value")
+525
View File
@@ -0,0 +1,525 @@
(function() {
if (window._cloudAuditInjected) return;
window._cloudAuditInjected = true;
var cloudAudit = {
hookedCalls: [],
_hookedClouds: [], // [{cloud, appId, origMethods}]
_autoHookTimer: null,
autoHookEnabled: false,
// ── 扫描所有 frame,收集所有 wx.cloud 实例 ──
_findAllFrames: function() {
var frames = [];
var seen = [];
function tryAdd(w) {
try {
if (!w || !w.wx || !w.__wxConfig) return;
for (var i = 0; i < seen.length; i++) { if (seen[i] === w) return; }
seen.push(w);
frames.push(w);
} catch(e) {}
}
tryAdd(window);
var sources = [window];
try { if (window.parent && window.parent !== window) sources.push(window.parent); } catch(e) {}
for (var s = 0; s < sources.length; s++) {
try {
var src = sources[s];
if (src.frames) {
for (var i = 0; i < src.frames.length; i++) {
try { tryAdd(src.frames[i]); } catch(e) {}
}
}
} catch(e) {}
}
return frames;
},
_getAppIdFromFrame: function(frame) {
try {
var cfg = frame.__wxConfig || {};
if (cfg.accountInfo && cfg.accountInfo.appId) return cfg.accountInfo.appId;
if (cfg.appId) return cfg.appId;
} catch(e) {}
return '';
},
_getEnvFromFrame: function(frame) {
try {
var cfg = frame.__wxConfig || {};
if (cfg.envList) return cfg.envList;
if (cfg.cloud && cfg.cloud.env) return cfg.cloud.env;
if (frame.wx.cloud && frame.wx.cloud._config) return frame.wx.cloud._config.env;
} catch(e) {}
return null;
},
// ── 环境探测 (返回所有发现的小程序) ──
detectEnv: function() {
var frames = this._findAllFrames();
if (frames.length === 0) {
return { ok: false, reason: 'no miniprogram frames found' };
}
var apps = [];
for (var i = 0; i < frames.length; i++) {
var f = frames[i];
try {
if (!f.wx || !f.wx.cloud) continue;
apps.push({
appId: this._getAppIdFromFrame(f),
env: this._getEnvFromFrame(f),
inited: !!(f.wx.cloud._config || f.wx.cloud.inited)
});
} catch(e) {}
}
return {
ok: apps.length > 0,
reason: apps.length === 0 ? 'wx.cloud not available' : undefined,
hasCloud: apps.length > 0,
apps: apps,
// 兼容旧接口
appId: apps.length > 0 ? apps[0].appId : '',
env: apps.length > 0 ? apps[0].env : null,
inited: apps.length > 0 ? apps[0].inited : false
};
},
// ── 通用工具 ──
_safeClone: function(obj) {
try { return JSON.parse(JSON.stringify(obj)); } catch(e) { return String(obj); }
},
_record: function(type, name, appId, data, result, status, error) {
var r = {
type: type, name: name, appId: appId,
data: data, timestamp: new Date().toLocaleTimeString(),
ts: Date.now(), status: status || 'pending'
};
if (result !== undefined) r.result = result;
if (error !== undefined) r.error = error;
this.hookedCalls.push(r);
},
// ── 通用 hook 包装器 ──
_wrapMethod: function(cloud, method, type, nameExtract, appId, origStore) {
var orig = cloud[method];
if (!orig || typeof orig !== 'function') return false;
origStore[method] = orig.bind(cloud);
var self = this;
cloud[method] = function(options) {
options = options || {};
var callName = nameExtract ? nameExtract(options) : method;
var callData = self._safeClone(options);
delete callData.success; delete callData.fail; delete callData.complete;
delete callData.filePath; delete callData.tempFilePath;
var recorded = false;
var hasCb = !!(options.success || options.fail);
var origSuccess = options.success;
var origFail = options.fail;
options.success = function(res) {
if (!recorded) { recorded = true; self._record(type, callName, appId, callData, self._safeClone(res), 'success'); }
if (origSuccess) origSuccess(res);
};
options.fail = function(err) {
if (!recorded) { recorded = true; self._record(type, callName, appId, callData, null, 'fail', err ? (err.errMsg || JSON.stringify(err)) : 'unknown'); }
if (origFail) origFail(err);
};
var ret = origStore[method](options);
if (!hasCb && ret && typeof ret.then === 'function') {
ret.then(function(res) {
if (!recorded) { recorded = true; self._record(type, callName, appId, callData, self._safeClone(res), 'success'); }
})['catch'](function(err) {
if (!recorded) { recorded = true; self._record(type, callName, appId, callData, null, 'fail', err ? (err.errMsg || JSON.stringify(err)) : 'unknown'); }
});
}
return ret;
};
return true;
},
// ── Hook 数据库 ──
_hookDatabase: function(cloud, appId, origStore) {
var origDb = cloud.database;
if (!origDb) return;
origStore['database'] = origDb.bind(cloud);
var self = this;
cloud.database = function(opts) {
var db = origStore['database'](opts);
if (!db) return db;
self._proxyDbCollection(db, appId);
return db;
};
},
_proxyDbCollection: function(db, appId) {
var origCol = db.collection;
if (!origCol) return;
var self = this;
db.collection = function(collName) {
var col = origCol.call(db, collName);
if (!col) return col;
// Hook 终端操作
['add', 'get', 'update', 'remove', 'count'].forEach(function(m) {
if (col[m]) self._wrapTerminal(col, m, 'db.' + m, collName, appId);
});
// Hook doc()
if (col.doc) {
var origDoc = col.doc.bind(col);
col.doc = function(docId) {
var ref = origDoc(docId);
if (!ref) return ref;
['get', 'update', 'set', 'remove'].forEach(function(m) {
if (ref[m]) self._wrapTerminal(ref, m, 'db.doc.' + m, collName + '/' + docId, appId);
});
return ref;
};
}
// Hook where()
if (col.where) {
var origWhere = col.where.bind(col);
col.where = function(cond) {
var q = origWhere(cond);
if (!q) return q;
['get', 'update', 'remove', 'count'].forEach(function(m) {
if (q[m]) self._wrapTerminal(q, m, 'db.where.' + m, collName, appId, { where: self._safeClone(cond) });
});
return q;
};
}
// Hook aggregate()
if (col.aggregate) {
var origAgg = col.aggregate.bind(col);
col.aggregate = function() {
var agg = origAgg();
if (!agg || !agg.end) return agg;
self._wrapTerminal(agg, 'end', 'db.aggregate', collName, appId);
return agg;
};
}
return col;
};
},
// 包装一个终端方法(支持 callback + promise)
_wrapTerminal: function(obj, method, type, name, appId, extraData) {
var orig = obj[method].bind(obj);
var self = this;
obj[method] = function(opts) {
opts = opts || {};
var callData = extraData ? self._safeClone(extraData) : {};
if (opts.data) callData.data = self._safeClone(opts.data);
var recorded = false;
var hasCb = !!(opts.success || opts.fail);
var origSuccess = opts.success;
var origFail = opts.fail;
opts.success = function(res) {
if (!recorded) { recorded = true; self._record(type, name, appId, callData, self._safeClone(res), 'success'); }
if (origSuccess) origSuccess(res);
};
opts.fail = function(err) {
if (!recorded) { recorded = true; self._record(type, name, appId, callData, null, 'fail', err ? (err.errMsg || JSON.stringify(err)) : 'unknown'); }
if (origFail) origFail(err);
};
var ret = orig(opts);
if (!hasCb && ret && typeof ret.then === 'function') {
ret.then(function(res) {
if (!recorded) { recorded = true; self._record(type, name, appId, callData, self._safeClone(res), 'success'); }
})['catch'](function(err) {
if (!recorded) { recorded = true; self._record(type, name, appId, callData, null, 'fail', err ? (err.errMsg || JSON.stringify(err)) : 'unknown'); }
});
}
return ret;
};
},
// ── 已知方法的类型和名称提取器 ──
_knownMethods: {
'callFunction': { type: 'function', ne: function(o) { return o.name || 'unknown'; } },
'uploadFile': { type: 'storage', ne: function(o) { return 'uploadFile: ' + (o.cloudPath || ''); } },
'downloadFile': { type: 'storage', ne: function(o) { return 'downloadFile: ' + (o.fileID || ''); } },
'deleteFile': { type: 'storage', ne: function(o) { return 'deleteFile(' + (o.fileList||[]).length + ')'; } },
'getTempFileURL': { type: 'storage', ne: function(o) { return 'getTempFileURL(' + (o.fileList||[]).length + ')'; } },
'callContainer': { type: 'container', ne: function(o) { return o.path || 'callContainer'; } },
'connectContainer': { type: 'container', ne: function(o) { return 'connectContainer: ' + (o.service || ''); } }
},
_skipProps: { 'init':1, 'database':1, 'CloudID':1, 'constructor':1, 'prototype':1, '__proto__':1 },
// ── 对一个 cloud 实例安装全部 hook ──
_hookOneCloud: function(cloud, appId) {
var origStore = {};
var hookedList = [];
// 动态枚举所有方法
var keys = [];
try { keys = Object.keys(cloud); } catch(e) {}
try {
var proto = Object.getPrototypeOf(cloud);
if (proto) {
var pk = Object.getOwnPropertyNames(proto);
for (var i = 0; i < pk.length; i++) { if (keys.indexOf(pk[i]) === -1) keys.push(pk[i]); }
}
} catch(e) {}
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (this._skipProps[k] || k.charAt(0) === '_') continue;
try { if (typeof cloud[k] !== 'function') continue; } catch(e) { continue; }
var known = this._knownMethods[k];
var type = known ? known.type : 'cloud';
var ne = known ? known.ne : (function(name) { return function() { return name; }; })(k);
if (this._wrapMethod(cloud, k, type, ne, appId, origStore)) {
hookedList.push(k);
}
}
// 数据库特殊处理
this._hookDatabase(cloud, appId, origStore);
this._hookedClouds.push({ cloud: cloud, appId: appId, origMethods: origStore });
return hookedList;
},
// ── 自动扫描所有 frame 并 hook ──
autoHookScan: function() {
var frames = this._findAllFrames();
var newApps = [];
for (var i = 0; i < frames.length; i++) {
var f = frames[i];
try {
if (!f.wx || !f.wx.cloud) continue;
var cloud = f.wx.cloud;
// 检查是否已经 hook 过这个 cloud 实例
var alreadyHooked = false;
for (var j = 0; j < this._hookedClouds.length; j++) {
if (this._hookedClouds[j].cloud === cloud) { alreadyHooked = true; break; }
}
if (alreadyHooked) continue;
var appId = this._getAppIdFromFrame(f);
var methods = this._hookOneCloud(cloud, appId);
newApps.push({ appId: appId, methods: methods });
} catch(e) {}
}
return newApps;
},
// ── 公开 API ──
installHook: function() {
var newApps = this.autoHookScan();
if (this._hookedClouds.length === 0) {
return { ok: false, reason: 'wx.cloud not available in any frame' };
}
// 启动自动扫描定时器(每 3 秒扫描新 frame)
this._startAutoScan();
return {
ok: true,
totalHooked: this._hookedClouds.length,
newApps: newApps,
hookedMethods: newApps.length > 0 ? newApps[0].methods : []
};
},
_startAutoScan: function() {
if (this._autoHookTimer) return;
this.autoHookEnabled = true;
var self = this;
this._autoHookTimer = setInterval(function() {
if (!self.autoHookEnabled) return;
self.autoHookScan();
}, 3000);
},
stopAutoHook: function() {
this.autoHookEnabled = false;
if (this._autoHookTimer) {
clearInterval(this._autoHookTimer);
this._autoHookTimer = null;
}
},
uninstallHook: function() {
this.stopAutoHook();
for (var i = 0; i < this._hookedClouds.length; i++) {
var entry = this._hookedClouds[i];
try {
for (var m in entry.origMethods) {
try { entry.cloud[m] = entry.origMethods[m]; } catch(e) {}
}
} catch(e) {}
}
this._hookedClouds = [];
},
getHookedCalls: function() { return this.hookedCalls.slice(); },
clearHookedCalls: function() { this.hookedCalls = []; },
getHookedApps: function() {
var apps = [];
for (var i = 0; i < this._hookedClouds.length; i++) {
apps.push(this._hookedClouds[i].appId);
}
return apps;
},
getDiscoveredFunctions: function() {
var map = {};
for (var i = 0; i < this.hookedCalls.length; i++) {
var c = this.hookedCalls[i];
var key = (c.type || 'function') + ':' + c.appId + ':' + c.name;
if (!map[key]) {
map[key] = { name: c.name, type: c.type || 'function', appId: c.appId || '', params: [], count: 0 };
}
map[key].count++;
if (c.data && typeof c.data === 'object') {
var keys = Object.keys(c.data);
for (var k = 0; k < keys.length; k++) {
if (map[key].params.indexOf(keys[k]) === -1) map[key].params.push(keys[k]);
}
}
}
var result = [];
for (var k in map) { result.push(map[k]); }
return result;
},
// ── 补充扫描 ──
scanCloudFunctions: function() {
var frames = this._findAllFrames();
var found = {};
for (var fi = 0; fi < frames.length; fi++) {
var f = frames[fi];
var appCodes = [];
try { if (f.__wxAppCode__) appCodes.push(f.__wxAppCode__); } catch(e) {}
for (var a = 0; a < appCodes.length; a++) {
var code = appCodes[a];
for (var key in code) {
try {
var val = code[key];
var src = typeof val === 'string' ? val :
(typeof val === 'function' ? val.toString() : null);
if (src && src.length > 20) {
if (src.indexOf('callFunction') !== -1) this._extractCalls(src, found);
if (src.indexOf('.collection(') !== -1) this._extractDbOps(src, found);
this._extractFileOps(src, found);
}
} catch(e) {}
}
}
}
var result = [];
for (var name in found) {
result.push({ name: name, type: found[name].type, params: found[name].params, count: found[name].count });
}
return result;
},
_extractCalls: function(src, found) {
var re = /callFunction\s*\(\s*\{[^}]{0,500}?name\s*:\s*["']([^"']+)["']/g;
var m;
while ((m = re.exec(src)) !== null) {
var key = 'fn:' + m[1];
if (!found[key]) found[key] = { type: 'function', params: [], count: 0 };
found[key].count++;
var after = src.substring(m.index, Math.min(m.index + 600, src.length));
var dm = after.match(/data\s*:\s*\{([^}]{1,400})\}/);
if (dm) {
var fields = dm[1].match(/(\w+)\s*:/g);
if (fields) {
for (var i = 0; i < fields.length; i++) {
var fn = fields[i].replace(/\s*:$/, '');
if (fn !== 'name' && fn !== 'success' && fn !== 'fail' && fn !== 'complete'
&& found[key].params.indexOf(fn) === -1) found[key].params.push(fn);
}
}
}
}
},
_extractDbOps: function(src, found) {
var re = /\.collection\s*\(\s*["']([^"']+)["']\s*\)/g;
var m;
while ((m = re.exec(src)) !== null) {
var key = 'db:' + m[1];
if (!found[key]) found[key] = { type: 'database', params: [], count: 0 };
found[key].count++;
var after = src.substring(m.index, Math.min(m.index + 300, src.length));
['add','get','update','remove','count','aggregate','doc','where'].forEach(function(op) {
if (after.indexOf('.' + op + '(') !== -1 && found[key].params.indexOf(op) === -1)
found[key].params.push(op);
});
}
},
_extractFileOps: function(src, found) {
['uploadFile','downloadFile','deleteFile','getTempFileURL'].forEach(function(m) {
if (src.indexOf(m) !== -1) {
var key = 'storage:' + m;
if (!found[key]) found[key] = { type: 'storage', params: [], count: 0 };
found[key].count++;
}
});
},
// ── 手动调用云函数 ──
callFunction: function(name, data) {
// 从已 hook 的 cloud 中找一个可用的 callFunction
var caller = null;
for (var i = 0; i < this._hookedClouds.length; i++) {
var entry = this._hookedClouds[i];
if (entry.origMethods['callFunction']) {
caller = entry.origMethods['callFunction'];
break;
}
}
if (!caller) {
// 没 hook 过则尝试直接找
var frames = this._findAllFrames();
for (var i = 0; i < frames.length; i++) {
try {
if (frames[i].wx && frames[i].wx.cloud && frames[i].wx.cloud.callFunction) {
caller = frames[i].wx.cloud.callFunction.bind(frames[i].wx.cloud);
break;
}
} catch(e) {}
}
}
if (!caller) return Promise.resolve({ ok: false, reason: 'wx.cloud not available' });
return new Promise(function(resolve) {
var t = setTimeout(function() {
resolve({ ok: true, status: 'timeout', error: '调用超时(10s)' });
}, 10000);
caller({
name: name, data: data || {},
success: function(res) {
clearTimeout(t);
try { resolve({ ok: true, status: 'success', result: JSON.parse(JSON.stringify(res && res.result ? res.result : res)) }); }
catch(e) { resolve({ ok: true, status: 'success', result: String(res) }); }
},
fail: function(err) {
clearTimeout(t);
resolve({ ok: true, status: 'fail', error: err ? (err.errMsg || JSON.stringify(err)) : 'unknown' });
}
});
});
}
};
window.cloudAudit = cloudAudit;
})();
+304
View File
@@ -0,0 +1,304 @@
"""
Port of RemoteDebugCodex.js - Protocol encode/decode for WeChat Remote Debug.
Handles protobuf wrapping/unwrapping of debug messages.
"""
import zlib
from .constants import DebugMessageCategory, CompressAlgo
from .third_party import wmpf_debug_pb2 as proto
def wrap_debug_message_data(data: dict, category: str, compress_algo: int = 0):
"""
Encode data into a protobuf debug message payload based on category.
Returns dict with 'buffer' (bytes) and 'originalSize' (int).
"""
buf = None
if category == DebugMessageCategory.CallInterface:
msg = proto.WARemoteDebug_CallInterface()
msg.objName = data.get("name", "")
msg.methodName = data.get("method", "")
for arg in data.get("args", []):
msg.methodArgList.append(str(arg))
if "call_id" in data and data["call_id"] is not None:
msg.callId = data["call_id"]
buf = msg.SerializeToString()
elif category == DebugMessageCategory.EvaluateJavascriptResult:
msg = proto.WARemoteDebug_EvaluateJavascriptResult()
if data.get("ret") is not None:
msg.ret = data["ret"]
if data.get("evaluate_id") is not None:
msg.evaluateId = data["evaluate_id"]
buf = msg.SerializeToString()
elif category == DebugMessageCategory.Ping:
msg = proto.WARemoteDebug_Ping()
if data.get("ping_id") is not None:
msg.pingId = data["ping_id"]
if data.get("payload") is not None:
msg.payload = data["payload"]
buf = msg.SerializeToString()
elif category == DebugMessageCategory.Breakpoint:
msg = proto.WARemoteDebug_Breakpoint()
msg.isHit = bool(data.get("is_hit", False))
buf = msg.SerializeToString()
elif category == DebugMessageCategory.DomOp:
msg = proto.WARemoteDebug_DomOp()
if data.get("params") is not None:
msg.params = data["params"]
if data.get("webview_id") is not None:
msg.webviewId = data["webview_id"]
buf = msg.SerializeToString()
elif category == DebugMessageCategory.DomEvent:
msg = proto.WARemoteDebug_DomEvent()
if data.get("params") is not None:
msg.params = data["params"]
if data.get("webview_id") is not None:
msg.webviewId = data["webview_id"]
buf = msg.SerializeToString()
elif category == DebugMessageCategory.ChromeDevtools:
msg = proto.WARemoteDebug_ChromeDevtools()
if data.get("op_id") is not None:
msg.opId = data["op_id"]
if data.get("payload") is not None:
msg.payload = data["payload"]
if data.get("jscontext_id") is not None:
msg.jscontextId = data["jscontext_id"]
buf = msg.SerializeToString()
elif category == DebugMessageCategory.ConnectJsContext:
msg = proto.WARemoteDebug_ConnectJsContext()
if data.get("jscontext_id") is not None:
msg.jscontextId = data["jscontext_id"]
buf = msg.SerializeToString()
elif category == DebugMessageCategory.CustomMessage:
msg = proto.WARemoteDebug_CustomMessage()
if data.get("method") is not None:
msg.method = data["method"]
if data.get("payload") is not None:
msg.payload = data["payload"]
if data.get("raw") is not None:
msg.raw = data["raw"]
buf = msg.SerializeToString()
else:
raise ValueError(f"invalid debug message category: {category}")
original_size = 0
if buf is not None and compress_algo and (compress_algo & CompressAlgo.Zlib) != 0:
original_size = len(buf)
buf = zlib.compress(buf)
return {
"buffer": buf,
"originalSize": original_size,
}
def unwrap_debug_message_data(msg) -> dict:
"""
Decode a WARemoteDebug_DebugMessage protobuf object into a plain dict.
msg can be a protobuf message object or a dict-like with fields:
seq, after, category, data, compressAlgo, originalSize
"""
raw_data = msg.data if hasattr(msg, "data") else msg.get("data", b"")
category = msg.category if hasattr(msg, "category") else msg.get("category", "")
compress_algo = msg.compressAlgo if hasattr(msg, "compressAlgo") else msg.get("compressAlgo", 0)
seq = msg.seq if hasattr(msg, "seq") else msg.get("seq", 0)
after = msg.after if hasattr(msg, "after") else msg.get("after", 0)
original_size_field = msg.originalSize if hasattr(msg, "originalSize") else msg.get("originalSize", 0)
if isinstance(raw_data, str):
raw_data = raw_data.encode("latin-1")
buf = raw_data
compressed_size = 0
if buf and compress_algo and (compress_algo & CompressAlgo.Zlib) != 0:
compressed_size = len(buf)
buf = zlib.decompress(buf)
decoded = None
if buf:
if category == DebugMessageCategory.Breakpoint:
m = proto.WARemoteDebug_Breakpoint()
m.ParseFromString(buf)
decoded = {"is_hit": 1 if m.isHit else 0}
elif category == DebugMessageCategory.CallInterface:
m = proto.WARemoteDebug_CallInterface()
m.ParseFromString(buf)
decoded = {
"name": m.objName,
"method": m.methodName,
"args": list(m.methodArgList),
"call_id": m.callId,
}
elif category == DebugMessageCategory.CallInterfaceResult:
m = proto.WARemoteDebug_CallInterfaceResult()
m.ParseFromString(buf)
decoded = {
"ret": m.ret,
"call_id": m.callId,
"debug_info": m.debugInfo,
}
elif category == DebugMessageCategory.EvaluateJavascript:
m = proto.WARemoteDebug_EvaluateJavascript()
m.ParseFromString(buf)
decoded = {
"script": m.script,
"evaluate_id": m.evaluateId,
"debug_info": m.debugInfo,
}
elif category == DebugMessageCategory.EvaluateJavascriptResult:
m = proto.WARemoteDebug_EvaluateJavascriptResult()
m.ParseFromString(buf)
decoded = {
"ret": m.ret,
"evaluate_id": m.evaluateId,
}
elif category == DebugMessageCategory.Ping:
m = proto.WARemoteDebug_Ping()
m.ParseFromString(buf)
decoded = {
"ping_id": m.pingId,
"payload": m.payload,
}
elif category == DebugMessageCategory.Pong:
m = proto.WARemoteDebug_Pong()
m.ParseFromString(buf)
decoded = {
"ping_id": m.pingId,
"network_type": m.networkType,
"payload": m.payload,
}
elif category == DebugMessageCategory.SetupContext:
m = proto.WARemoteDebug_SetupContext()
m.ParseFromString(buf)
ri = m.registerInterface if m.HasField("registerInterface") else None
di = m.deviceInfo if m.HasField("deviceInfo") else None
decoded = {
"register_interface": {
"obj_name": ri.objName if ri else "",
"obj_methods": [
{
"method_name": meth.methodName,
"method_args": list(meth.methodArgList),
}
for meth in (ri.objMethodList if ri else [])
],
} if ri else {},
"configure_js": m.configureJs,
"public_js_md5": m.publicJsMd5,
"three_js_md5": m.threeJsMd5,
"device_info": {
"device_name": di.deviceName if di else "",
"device_model": di.deviceModel if di else "",
"os": di.systemVersion if di else "",
"wechat_version": di.wechatVersion if di else "",
"pixel_ratio": di.pixelRatio if di else 0,
"screen_width": di.screenWidth if di else 0,
"publib": di.publibVersion if di else 0,
"user_agent": di.userAgent if di else "",
} if di else {},
"support_compress_algo": m.supportCompressAlgo,
}
elif category == DebugMessageCategory.DomOp:
m = proto.WARemoteDebug_DomOp()
m.ParseFromString(buf)
decoded = {
"params": m.params,
"webview_id": m.webviewId,
}
elif category == DebugMessageCategory.DomEvent:
m = proto.WARemoteDebug_DomEvent()
m.ParseFromString(buf)
decoded = {
"params": m.params,
"webview_id": m.webviewId,
}
elif category == DebugMessageCategory.NetworkDebugAPI:
m = proto.WARemoteDebug_NetworkDebugAPI()
m.ParseFromString(buf)
decoded = {
"api_name": m.apiName,
"task_id": m.taskId,
"request_headers": m.requestHeaders,
"timestamp": m.timestamp,
}
elif category == DebugMessageCategory.ChromeDevtools:
m = proto.WARemoteDebug_ChromeDevtools()
m.ParseFromString(buf)
decoded = {
"op_id": m.opId,
"payload": m.payload,
"jscontext_id": m.jscontextId,
}
elif category == DebugMessageCategory.ChromeDevtoolsResult:
m = proto.WARemoteDebug_ChromeDevtoolsResult()
m.ParseFromString(buf)
decoded = {
"op_id": m.opId,
"payload": m.payload,
"jscontext_id": m.jscontextId,
}
elif category == DebugMessageCategory.AddJsContext:
m = proto.WARemoteDebug_AddJsContext()
m.ParseFromString(buf)
decoded = {
"jscontext_id": m.jscontextId,
"jscontext_name": m.jscontextName,
}
elif category == DebugMessageCategory.RemoveJsContext:
m = proto.WARemoteDebug_RemoveJsContext()
m.ParseFromString(buf)
decoded = {
"jscontext_id": m.jscontextId,
}
elif category == DebugMessageCategory.ConnectJsContext:
m = proto.WARemoteDebug_ConnectJsContext()
m.ParseFromString(buf)
decoded = {
"jscontext_id": m.jscontextId,
}
elif category == DebugMessageCategory.CustomMessage:
m = proto.WARemoteDebug_CustomMessage()
m.ParseFromString(buf)
decoded = {
"method": m.method,
"payload": m.payload,
"raw": m.raw,
}
else:
decoded = None
return {
"seq": seq or 0,
"delay": after or 0,
"category": category or DebugMessageCategory.Ping,
"data": decoded or {},
"compress_algo": compress_algo or 0,
"original_size": original_size_field or compressed_size,
}
+143
View File
@@ -0,0 +1,143 @@
"""
Port of RemoteDebugConstants.js - Protocol constants for WeChat Remote Debug.
"""
class ResponseType:
Heartbeat = 2001
Login = 2002
EventNotifyBegin = 3001
EventNotifyEnd = 3002
EventNotifyBlock = 3003
JoinRoom = 2003
SendDebugMessage = 2000
SendDebugMessageParallelly = 2006
QuitRoom = 2004
MessageNotify = 1000
MessageNotifyParallelly = 1006
SyncMessage = 2005
Unknown = -1
class CompressAlgo:
Non = 0
Zlib = 1
class RequestType:
Heartbeat = 2001
Login = 2002
EventNotifyBegin = 3001
EventNotifyEnd = 3002
EventNotifyBlock = 3003
JoinRoom = 2003
SendDebugMessage = 2000
SendDebugMessageParallelly = 2006
QuitRoom = 2004
MessageNotify = 1000
MessageNotifyParallelly = 1006
SyncMessage = 2005
Unknown = -1
class ClientRequestType:
Heartbeat = 1001
Login = 1002
EventNotifyBegin = 3001
EventNotifyEnd = 3002
EventNotifyBlock = 3003
JoinRoom = 1003
SendDebugMessage = 1000
SendDebugMessageParallelly = 1006
QuitRoom = 1004
MessageNotify = 2000
MessageNotifyParallelly = 2006
SyncMessage = 1005
Unknown = -1
class ClientResponseType:
Heartbeat = 1001
Login = 1002
EventNotifyBegin = 3001
EventNotifyEnd = 3002
EventNotifyBlock = 3003
JoinRoom = 1003
SendDebugMessage = 1000
SendDebugMessageParallelly = 1006
QuitRoom = 1004
MessageNotify = 2000
MessageNotifyParallelly = 2006
SyncMessage = 1005
Unknown = -1
class RequestCmd:
Heartbeat = 2001
Login = 2002
EventNotifyBegin = 3001
EventNotifyEnd = 3002
EventNotifyBlock = 3003
JoinRoom = 2003
SendDebugMessage = 2000
SendDebugMessageParallelly = 2006
QuitRoom = 2004
MessageNotify = 1000
MessageNotifyParallelly = 1006
SyncMessage = 2005
Unknown = -1
class ClientRequestCmd:
Heartbeat = 1001
Login = 1002
EventNotifyBegin = 3001
EventNotifyEnd = 3002
EventNotifyBlock = 3003
JoinRoom = 1003
SendDebugMessage = 1000
SendDebugMessageParallelly = 1006
QuitRoom = 1004
MessageNotify = 2000
MessageNotifyParallelly = 2006
SyncMessage = 1005
Unknown = -1
class DebugMessageCategory:
SetupContext = "setupContext"
CallInterface = "callInterface"
EvaluateJavascript = "evaluateJavascript"
CallInterfaceResult = "callInterfaceResult"
EvaluateJavascriptResult = "evaluateJavascriptResult"
Breakpoint = "breakpoint"
Ping = "ping"
Pong = "pong"
DomOp = "domOp"
DomEvent = "domEvent"
NetworkDebugAPI = "networkDebugAPI"
ChromeDevtools = "chromeDevtools"
ChromeDevtoolsResult = "chromeDevtoolsResult"
AddJsContext = "addJsContext"
RemoveJsContext = "removeJsContext"
ConnectJsContext = "connectJsContext"
EngineEvent = "engineEvent"
EngineOp = "engineOp"
CustomMessage = "customMessage"
class KnownErrorCode:
OK = 0
ERR_SYS = -1
NOT_EXIST = 1
INVALID_ARGS = -2
SYSTEM_BUSY = -3
INVALID_LOGIN_TICKET = -50001
HAS_NO_PERMISSION = -50002
ROOM_IN_DEBUGGING = -50003
NO_EXIST_ROOM = -50004
MD5_NOT_MATCH = -50005
USER_IN_DEBUGGING = -50006
SEQ_ERROR = -50010
SEND_MSG_BUSY = -50011
SEND_MSG_SEQ_RANGE_ERROR = -50012
+505
View File
@@ -0,0 +1,505 @@
"""
DebugEngine - wraps debug_server, proxy_server, frida_server into a reusable class.
"""
import asyncio
import json
import random
import re
import sys
from collections import Counter
from pathlib import Path
import frida
import websockets
import websockets.server
import websockets.exceptions
from .codex import wrap_debug_message_data, unwrap_debug_message_data
from .third_party import wmpf_debug_pb2 as proto
from .userscript import (
build_cdp_enable_page_command,
build_cdp_add_script_command,
build_injection_wrapper,
)
class DebugMessageBus:
"""Central event bus bridging debug server and CDP proxy server."""
def __init__(self):
self._cdp_callbacks = []
self._proxy_callbacks = []
def on_cdp_message(self, callback):
self._cdp_callbacks.append(callback)
def on_proxy_message(self, callback):
self._proxy_callbacks.append(callback)
def emit_cdp_message(self, message: str):
for cb in self._cdp_callbacks:
cb(message)
def emit_proxy_message(self, message: str):
for cb in self._proxy_callbacks:
cb(message)
def buffer_to_hex_string(data: bytes) -> str:
return data.hex()
def _build_protobuf_cdp_message(cdp_json: str, seq: int) -> bytes:
"""Wrap a CDP JSON command into protobuf binary for sending to the miniapp runtime."""
raw_payload = {
"jscontext_id": "",
"op_id": round(100 * random.random()),
"payload": cdp_json,
}
wrapped = wrap_debug_message_data(raw_payload, "chromeDevtools", 0)
out_msg = proto.WARemoteDebug_DebugMessage()
out_msg.seq = seq
out_msg.category = "chromeDevtools"
out_msg.data = wrapped["buffer"]
out_msg.compressAlgo = 0
out_msg.originalSize = wrapped["originalSize"]
return out_msg.SerializeToString()
class DebugEngine:
"""Wraps all three subsystems: debug server, proxy server, frida injection."""
def __init__(self, options, logger, userscripts=None):
self.options = options
self.logger = logger
self.bus = DebugMessageBus()
self.userscripts = userscripts or []
self.debug_srv = None
self.proxy_srv = None
self.frida_session = None
self.frida_script = None
self.miniapp_clients = set()
self.devtools_clients = set()
self.message_counter = 0
self._pending_responses = {}
self._cmd_counter = 80000
self._status_callbacks = []
self._event_listeners = {} # method -> [callback, ...]
self.status = {"frida": False, "miniapp": False, "devtools": False}
def on_status_change(self, callback):
self._status_callbacks.append(callback)
def _notify_status(self, key, value):
self.status[key] = value
for cb in self._status_callbacks:
try:
cb(dict(self.status))
except Exception:
pass
def _next_cmd_id(self):
self._cmd_counter += 1
return self._cmd_counter
async def start(self):
"""Start all three subsystems."""
self.debug_srv = await self._start_debug_server()
self.proxy_srv = await self._start_proxy_server()
try:
self.frida_session, self.frida_script = await self._start_frida()
self._notify_status("frida", True)
except Exception as e:
self.logger.error(str(e))
raise
async def stop(self):
"""Graceful shutdown."""
if self.debug_srv:
self.debug_srv.close()
await self.debug_srv.wait_closed()
self.debug_srv = None
if self.proxy_srv:
self.proxy_srv.close()
await self.proxy_srv.wait_closed()
self.proxy_srv = None
if self.frida_script:
try:
self.frida_script.unload()
except Exception:
pass
self.frida_script = None
if self.frida_session:
try:
self.frida_session.detach()
except Exception:
pass
self.frida_session = None
self._notify_status("frida", False)
self._notify_status("miniapp", False)
self._notify_status("devtools", False)
self.miniapp_clients.clear()
self.devtools_clients.clear()
self.message_counter = 0
self._pending_responses.clear()
self.logger.info("[server] engine stopped")
async def evaluate_js(self, expression, timeout=5.0):
"""Send Runtime.evaluate via CDP to the miniapp, return result dict."""
if not self.miniapp_clients:
raise RuntimeError("No miniapp client connected")
cmd_id = self._next_cmd_id()
cdp_cmd = json.dumps({
"id": cmd_id,
"method": "Runtime.evaluate",
"params": {"expression": expression, "returnByValue": True}
})
loop = asyncio.get_event_loop()
future = loop.create_future()
self._pending_responses[cmd_id] = future
self.bus.emit_proxy_message(cdp_cmd)
try:
return await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError:
self._pending_responses.pop(cmd_id, None)
raise
async def send_cdp_command(self, method, params=None, timeout=5.0):
"""Send an arbitrary CDP command and return the response."""
if not self.miniapp_clients:
raise RuntimeError("No miniapp client connected")
cmd_id = self._next_cmd_id()
cdp_cmd = json.dumps({"id": cmd_id, "method": method, "params": params or {}})
loop = asyncio.get_event_loop()
future = loop.create_future()
self._pending_responses[cmd_id] = future
self.bus.emit_proxy_message(cdp_cmd)
try:
return await asyncio.wait_for(future, timeout=timeout)
except asyncio.TimeoutError:
self._pending_responses.pop(cmd_id, None)
raise
async def set_extra_headers(self, headers: dict):
"""Enable Network domain and set extra HTTP headers via CDP."""
await self.send_cdp_command("Network.enable")
await self.send_cdp_command("Network.setExtraHTTPHeaders",
{"headers": headers})
def on_cdp_event(self, method, callback):
"""Subscribe to a CDP event by method name (e.g. 'Debugger.scriptParsed')."""
self._event_listeners.setdefault(method, []).append(callback)
def off_cdp_event(self, method, callback):
"""Unsubscribe from a CDP event."""
cbs = self._event_listeners.get(method, [])
if callback in cbs:
cbs.remove(callback)
def _handle_cdp_response(self, message_str):
"""Check if a CDP response matches a pending evaluate_js call, or dispatch events."""
try:
data = json.loads(message_str)
except (json.JSONDecodeError, TypeError):
return
msg_id = data.get("id")
if msg_id is not None and msg_id in self._pending_responses:
future = self._pending_responses.pop(msg_id)
if not future.done():
future.set_result(data)
# Dispatch CDP events (messages with "method" but no "id")
method = data.get("method")
if method and method in self._event_listeners:
for cb in self._event_listeners[method]:
try:
cb(data)
except Exception:
pass
# ── Debug Server ──
async def _start_debug_server(self):
engine = self
logger = self.logger
bus = self.bus
userscripts = self.userscripts
scripts_injected = False
def on_proxy_message(message: str):
engine.message_counter += 1
raw_payload = {
"jscontext_id": "",
"op_id": round(100 * random.random()),
"payload": str(message),
}
logger.main_debug(raw_payload)
wrapped = wrap_debug_message_data(raw_payload, "chromeDevtools", 0)
out_msg = proto.WARemoteDebug_DebugMessage()
out_msg.seq = engine.message_counter
out_msg.category = "chromeDevtools"
out_msg.data = wrapped["buffer"]
out_msg.compressAlgo = 0
out_msg.originalSize = wrapped["originalSize"]
encoded = out_msg.SerializeToString()
clients_snapshot = list(engine.miniapp_clients)
for ws in clients_snapshot:
try:
task = asyncio.ensure_future(ws.send(encoded))
task.add_done_callback(lambda t: t.exception() if t.done() and not t.cancelled() and t.exception() else None)
except Exception:
pass
bus.on_proxy_message(on_proxy_message)
async def handler(websocket):
nonlocal scripts_injected
engine.miniapp_clients.add(websocket)
engine._notify_status("miniapp", True)
logger.info("[miniapp] miniapp client connected")
if userscripts and not scripts_injected:
scripts_injected = True
logger.info("[userscript] registering scripts immediately on connection...")
try:
seq = engine.message_counter
cmd_id = 90000
seq += 1
await websocket.send(_build_protobuf_cdp_message(
build_cdp_enable_page_command(cmd_id), seq
))
cmd_id += 1
seq += 1
await websocket.send(_build_protobuf_cdp_message(
json.dumps({"id": cmd_id, "method": "Debugger.enable", "params": {}}), seq
))
cmd_id += 1
logger.info("[anti-debug] Debugger.enable sent")
seq += 1
await websocket.send(_build_protobuf_cdp_message(
json.dumps({"id": cmd_id, "method": "Debugger.setSkipAllPauses", "params": {"skip": True}}), seq
))
cmd_id += 1
logger.info("[anti-debug] Debugger.setSkipAllPauses(true) sent")
for script in userscripts:
seq += 1
await websocket.send(_build_protobuf_cdp_message(
build_cdp_add_script_command(script, cmd_id), seq
))
cmd_id += 1
logger.info(f"[userscript] registered (persistent): {script.name}")
engine.message_counter = seq
logger.info("[userscript] registration done, scripts will run on page load")
except Exception as e:
logger.error(f"[userscript] registration error: {e}")
immediate_done = False
try:
async for message in websocket:
if isinstance(message, str):
message = message.encode("utf-8")
msg_category = ""
try:
decoded_msg = proto.WARemoteDebug_DebugMessage()
decoded_msg.ParseFromString(message)
msg_category = decoded_msg.category
except Exception:
pass
self._process_miniapp_message(message)
if msg_category == "setupContext" and userscripts and not immediate_done:
immediate_done = True
await asyncio.sleep(0.5)
try:
seq = engine.message_counter
cmd_id = 91000
seq += 1
await websocket.send(_build_protobuf_cdp_message(
json.dumps({"id": cmd_id, "method": "Debugger.enable", "params": {}}), seq
))
cmd_id += 1
seq += 1
await websocket.send(_build_protobuf_cdp_message(
json.dumps({"id": cmd_id, "method": "Debugger.setSkipAllPauses", "params": {"skip": True}}), seq
))
cmd_id += 1
logger.info("[anti-debug] Debugger.setSkipAllPauses re-sent after setupContext")
for script in userscripts:
wrapped = build_injection_wrapper(script)
seq += 1
cdp_cmd = json.dumps({
"id": cmd_id,
"method": "Runtime.evaluate",
"params": {
"expression": wrapped,
"includeCommandLineAPI": True,
"silent": False,
},
})
await websocket.send(_build_protobuf_cdp_message(cdp_cmd, seq))
cmd_id += 1
logger.info(f"[userscript] immediate inject (Runtime.evaluate): {script.name}")
engine.message_counter = seq
except Exception as e:
logger.error(f"[userscript] immediate inject error: {e}")
except websockets.exceptions.ConnectionClosed:
pass
except Exception as e:
logger.error(f"[miniapp] miniapp client err: {e}")
finally:
engine.miniapp_clients.discard(websocket)
if not engine.miniapp_clients:
engine._notify_status("miniapp", False)
logger.info("[miniapp] miniapp client disconnected")
server = await websockets.server.serve(
handler, "0.0.0.0", self.options.debug_port,
max_size=None,
)
logger.info(f"[server] debug server running on ws://localhost:{self.options.debug_port}")
logger.info("[server] debug server waiting for miniapp to connect...")
return server
def _process_miniapp_message(self, message: bytes):
self.logger.main_debug(
f"[miniapp] client received raw message (hex): {buffer_to_hex_string(message)}"
)
unwrapped_data = None
try:
decoded = proto.WARemoteDebug_DebugMessage()
decoded.ParseFromString(message)
unwrapped_data = unwrap_debug_message_data(decoded)
self.logger.main_debug("[miniapp] [DEBUG] decoded data:")
self.logger.main_debug(unwrapped_data)
except Exception as e:
self.logger.error(f"[miniapp] miniapp client err: {e}")
if unwrapped_data is None:
return
if unwrapped_data.get("category") == "chromeDevtoolsResult":
payload = unwrapped_data["data"].get("payload", "")
self.bus.emit_cdp_message(payload)
# Also check for pending evaluate_js responses
self._handle_cdp_response(payload)
# ── Proxy Server ──
async def _start_proxy_server(self):
engine = self
logger = self.logger
bus = self.bus
def on_cdp_message(message: str):
clients_snapshot = list(engine.devtools_clients)
for ws in clients_snapshot:
try:
task = asyncio.ensure_future(ws.send(message))
task.add_done_callback(lambda t: t.exception() if t.done() and not t.cancelled() and t.exception() else None)
except Exception:
pass
bus.on_cdp_message(on_cdp_message)
async def handler(websocket):
engine.devtools_clients.add(websocket)
engine._notify_status("devtools", True)
logger.info("[cdp] CDP client connected")
try:
async for message in websocket:
if isinstance(message, bytes):
message = message.decode("utf-8")
bus.emit_proxy_message(message)
except websockets.exceptions.ConnectionClosed:
pass
except Exception as e:
logger.error(f"[cdp] CDP client err: {e}")
finally:
engine.devtools_clients.discard(websocket)
if not engine.devtools_clients:
engine._notify_status("devtools", False)
logger.info("[cdp] CDP client disconnected")
server = await websockets.server.serve(
handler, "0.0.0.0", self.options.cdp_port,
max_size=None,
)
logger.info(f"[server] proxy server running on ws://localhost:{self.options.cdp_port}")
logger.info(f"[server] link: devtools://devtools/bundled/inspector.html?ws=127.0.0.1:{self.options.cdp_port}")
return server
# ── Frida ──
async def _start_frida(self):
logger = self.logger
device = frida.get_local_device()
processes = device.enumerate_processes(scope="metadata")
wmpf_processes = [p for p in processes if p.name == "WeChatAppEx.exe"]
if not wmpf_processes:
raise RuntimeError("[frida] WeChatAppEx.exe process not found")
wmpf_ppids = []
for p in wmpf_processes:
ppid = p.parameters.get("ppid", 0)
wmpf_ppids.append(ppid if ppid else 0)
if not wmpf_ppids:
raise RuntimeError("[frida] WeChatAppEx.exe process not found")
pid_counts = Counter(wmpf_ppids)
wmpf_pid = pid_counts.most_common(1)[0][0]
if wmpf_pid == 0:
raise RuntimeError("[frida] WeChatAppEx.exe process not found")
wmpf_process = None
for p in processes:
if p.pid == wmpf_pid:
wmpf_process = p
break
if wmpf_process is None:
raise RuntimeError("[frida] Could not find main WMPF process")
wmpf_process_path = wmpf_process.parameters.get("path", "")
version_matches = re.findall(r"\d+", wmpf_process_path)
if not version_matches:
raise RuntimeError("[frida] error in find wmpf version")
wmpf_version = int(version_matches[-1])
if wmpf_version == 0:
raise RuntimeError("[frida] error in find wmpf version")
session = device.attach(wmpf_pid)
if getattr(sys, 'frozen', False):
# PyInstaller onefile: resources in _MEIPASS; onedir: next to exe
project_root = Path(getattr(sys, '_MEIPASS', Path(sys.executable).parent)).resolve()
else:
project_root = Path(__file__).parent.parent.resolve()
hook_path = project_root / "frida" / "hook.js"
if not hook_path.exists():
raise RuntimeError("[frida] hook script not found")
script_content = hook_path.read_text(encoding="utf-8")
config_path = project_root / "frida" / "config" / f"addresses.{wmpf_version}.json"
if not config_path.exists():
raise RuntimeError(f"[frida] version config not found: {wmpf_version}")
config_content = config_path.read_text(encoding="utf-8")
config_content = json.dumps(json.loads(config_content))
final_script = script_content.replace("@@CONFIG@@", config_content)
script = session.create_script(final_script)
def on_message(message, data):
if message.get("type") == "error":
logger.error("[frida client]", message)
return
logger.frida_debug("[frida client]", message.get("payload", ""))
script.on("message", on_message)
script.load()
logger.info(f"[frida] script loaded, WMPF version: {wmpf_version}, pid: {wmpf_pid}")
logger.info("[frida] you can now open any miniapps")
return session, script
+1119
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
import sys
from .cli import CliOptions
class Logger:
def __init__(self, options: CliOptions):
self._debug_main = options.debug_main
self._debug_frida = options.debug_frida
self._output_callback = None
def set_output_callback(self, callback):
"""Set callback(level, text) for GUI log capture."""
self._output_callback = callback
def _emit(self, level, *messages):
text = " ".join(str(m) for m in messages)
print(text, flush=True, file=sys.stderr if level == "error" else sys.stdout)
if self._output_callback:
try:
self._output_callback(level, text)
except Exception:
pass
def info(self, *messages):
self._emit("info", *messages)
def error(self, *messages):
self._emit("error", *messages)
def main_debug(self, *messages):
if self._debug_main:
self._emit("debug", *messages)
def frida_debug(self, *messages):
if self._debug_frida:
self._emit("frida", *messages)
def create_logger(options: CliOptions) -> Logger:
return Logger(options)
+352
View File
@@ -0,0 +1,352 @@
(function() {
// 允许重新注入以支持切换小程序
if (window._navInjected && window.nav && window.nav.wxFrame) {
// 检测 wxFrame 是否仍有效
try {
var testConfig = window.nav.wxFrame.__wxConfig;
if (testConfig && testConfig.pages) return; // 仍有效,跳过
} catch(e) {}
}
window._navInjected = true;
class UniversalMiniProgramNavigator {
constructor() {
this.wxFrame = null;
this.config = null;
this.allPages = [];
this.tabBarPages = [];
this.categorizedPages = {};
this.menuItems = [];
this._customHeaders = {};
this._redirectGuard = false;
this._blockedRedirects = [];
this.init();
}
init() {
if (!this.detectMiniProgramEnvironment()) {
console.error('未检测到小程序环境');
return;
}
this.loadConfiguration();
this.categorizePages();
}
detectMiniProgramEnvironment() {
if (typeof wx !== 'undefined' && typeof getCurrentPages !== 'undefined') {
this.wxFrame = window;
return true;
}
if (typeof window !== 'undefined' && window.frames) {
for (let i = 0; i < window.frames.length; i++) {
try {
const frame = window.frames[i];
if (frame.wx && frame.__wxConfig) {
this.wxFrame = frame;
return true;
}
} catch (e) {}
}
}
try {
if (window.parent && window.parent.frames) {
for (let i = 0; i < window.parent.frames.length; i++) {
try {
const frame = window.parent.frames[i];
if (frame.wx && frame.__wxConfig) {
this.wxFrame = frame;
return true;
}
} catch (e) {}
}
}
} catch (e) {}
return false;
}
loadConfiguration() {
this.config = this.wxFrame.__wxConfig;
this.allPages = [].concat(this.config.pages || []);
// 补充分包页面
var subPkgs = this.config.subPackages || this.config.subpackages || [];
var allPages = this.allPages;
subPkgs.forEach(function(pkg) {
(pkg.pages || []).forEach(function(page) {
var fullPath = pkg.root + '/' + page;
if (allPages.indexOf(fullPath) === -1) {
allPages.push(fullPath);
}
});
});
// 去重
var seen = {};
this.allPages = this.allPages.filter(function(p) {
if (seen[p]) return false;
seen[p] = true;
return true;
});
if (this.config.tabBar && this.config.tabBar.list) {
this.tabBarPages = this.config.tabBar.list.map(function(tab) {
return tab.pagePath.replace('.html', '');
});
}
}
categorizePages() {
this.categorizedPages = {
tabbar: [], auth: [], home: [], list: [], detail: [],
form: [], user: [], order: [], payment: [], setting: [], other: []
};
var self = this;
this.tabBarPages.forEach(function(page) {
self.categorizedPages.tabbar.push({
url: page, name: page, method: 'switchTab', type: 'tabbar'
});
});
var categoryKeywords = {
auth: ['login', 'register', 'auth', 'sign', 'entry', 'bridge'],
home: ['home', 'index', 'main', 'dashboard'],
list: ['list', 'List'],
detail: ['detail', 'Detail', 'info', 'Info'],
form: ['form', 'Form', 'add', 'Add', 'edit', 'Edit', 'confirm'],
user: ['user', 'User', 'profile', 'Profile', 'person'],
order: ['order', 'Order', 'transaction', 'record'],
payment: ['pay', 'Pay', 'payment', 'recharge'],
setting: ['setting', 'Setting', 'config', 'Config']
};
this.allPages.forEach(function(page) {
if (self.tabBarPages.includes(page)) return;
var pageInfo = { url: page, name: page, method: 'navigateTo', type: 'normal' };
var categorized = false;
for (var category in categoryKeywords) {
var keywords = categoryKeywords[category];
if (keywords.some(function(kw) { return page.toLowerCase().includes(kw.toLowerCase()); })) {
self.categorizedPages[category].push(pageInfo);
categorized = true;
break;
}
}
if (!categorized) self.categorizedPages.other.push(pageInfo);
});
// Build flat menuItems
var items = [];
Object.keys(this.categorizedPages).forEach(function(cat) {
self.categorizedPages[cat].forEach(function(p) { items.push(p); });
});
this.menuItems = items;
}
// 获取原始导航方法(绕过防跳转 hook)
_getNav(method) {
if (this._redirectGuard) {
if (method === 'redirectTo' && this._origRedirectTo) return this._origRedirectTo;
if (method === 'reLaunch' && this._origReLaunch) return this._origReLaunch;
if (method === 'navigateTo' && this._origNavigateTo) return this._origNavigateTo;
}
return this.wxFrame.wx[method];
}
goTo(url) {
var isTabBar = this.tabBarPages.some(function(page) {
return page === url || page === url.replace('/', '') || ('/' + page) === url;
});
var options = {
url: url.startsWith('/') ? url : '/' + url,
success: function() {},
fail: function() {}
};
if (isTabBar) {
this.wxFrame.wx.switchTab(options);
} else {
this._getNav('navigateTo').call(this.wxFrame.wx, options);
}
}
_safeNavigate(pageUrl) {
var self = this;
return new Promise(function(resolve) {
var url = pageUrl.startsWith('/') ? pageUrl : '/' + pageUrl;
self._getNav('reLaunch').call(self.wxFrame.wx, {
url: url,
success: function() { resolve(true); },
fail: function() {
self.wxFrame.wx.switchTab({
url: url,
success: function() { resolve(true); },
fail: function() {
self._getNav('redirectTo').call(self.wxFrame.wx, {
url: url,
success: function() { resolve(true); },
fail: function() { resolve(false); }
});
}
});
}
});
});
}
back(delta) {
delta = delta || 1;
this.wxFrame.wx.navigateBack({
delta: delta,
success: function() {},
fail: function() {}
});
}
current() {
try {
if (this.wxFrame.getCurrentPages) {
var pages = this.wxFrame.getCurrentPages();
if (pages.length > 0) {
var cur = pages[pages.length - 1];
return cur.route || cur.__route__ || '';
}
}
return '';
} catch (e) {
return '';
}
}
_installHook() {
if (this._hooked) return;
this._hooked = true;
this._capturedAPIs = [];
this._allCapturedAPIs = {};
this._globalAPIs = [];
this._globalAPISet = new Set();
var wx = this.wxFrame.wx;
var self = this;
var methods = ['request', 'uploadFile', 'downloadFile'];
this._originalMethods = {};
methods.forEach(function(method) {
if (wx[method]) {
self._originalMethods[method] = wx[method];
wx[method] = function(options) {
var reqMethod = (options.method || (method === 'request' ? 'GET' : method)).toUpperCase();
var url = options.url || '';
var apiInfo = { url: url, method: reqMethod, timestamp: new Date().toLocaleTimeString(), type: method };
self._capturedAPIs.push(apiInfo);
var dedupeKey = reqMethod + '|' + self._extractPath(url);
if (!self._globalAPISet.has(dedupeKey)) {
self._globalAPISet.add(dedupeKey);
self._globalAPIs.push(apiInfo);
}
// merge custom headers
if (self._customHeaders && Object.keys(self._customHeaders).length > 0) {
options.header = options.header || {};
for (var hk in self._customHeaders) {
if (self._customHeaders.hasOwnProperty(hk)) {
options.header[hk] = self._customHeaders[hk];
}
}
}
return self._originalMethods[method].call(wx, options);
};
}
});
}
_uninstallHook() {
if (!this._hooked) return;
var wx = this.wxFrame.wx;
var self = this;
Object.keys(this._originalMethods).forEach(function(method) {
wx[method] = self._originalMethods[method];
});
this._hooked = false;
}
_extractPath(url) {
if (!this._pathCache) this._pathCache = {};
if (this._pathCache[url]) return this._pathCache[url];
var path;
try { path = new URL(url).pathname; } catch(e) { path = url.split('?')[0]; }
this._pathCache[url] = path;
return path;
}
_sleep(ms) {
return new Promise(function(resolve) { setTimeout(resolve, ms); });
}
stopAutoVisit() {
this._isAutoVisiting = false;
}
getAPIs() {
return this._globalAPIs || [];
}
getResults() {
return this._autoVisitResults || {};
}
enableRedirectGuard() {
if (this._redirectGuard) return {ok:true, already:true};
this._redirectGuard = true;
this._blockedRedirects = [];
var wx = this.wxFrame.wx;
var self = this;
this._origRedirectTo = wx.redirectTo;
this._origReLaunch = wx.reLaunch;
this._origNavigateTo = wx.navigateTo;
// hook redirectTo — 拦截强制跳转,调用 success 防止页面卡死
wx.redirectTo = function(options) {
var url = (options && options.url) || '';
self._blockedRedirects.push({type:'redirectTo', url:url, time:new Date().toLocaleTimeString()});
console.warn('[防跳转] 已拦截 redirectTo:', url);
if (options && options.success) options.success({errMsg:'redirectTo:ok'});
if (options && options.complete) options.complete({errMsg:'redirectTo:ok'});
};
// hook reLaunch — 拦截强制重启
wx.reLaunch = function(options) {
var url = (options && options.url) || '';
self._blockedRedirects.push({type:'reLaunch', url:url, time:new Date().toLocaleTimeString()});
console.warn('[防跳转] 已拦截 reLaunch:', url);
if (options && options.success) options.success({errMsg:'reLaunch:ok'});
if (options && options.complete) options.complete({errMsg:'reLaunch:ok'});
};
// hook navigateTo — 拦截跳转新页面
wx.navigateTo = function(options) {
var url = (options && options.url) || '';
self._blockedRedirects.push({type:'navigateTo', url:url, time:new Date().toLocaleTimeString()});
console.warn('[防跳转] 已拦截 navigateTo:', url);
if (options && options.success) options.success({errMsg:'navigateTo:ok'});
if (options && options.complete) options.complete({errMsg:'navigateTo:ok'});
};
return {ok:true};
}
disableRedirectGuard() {
if (!this._redirectGuard) return;
this._redirectGuard = false;
var wx = this.wxFrame.wx;
if (this._origRedirectTo) wx.redirectTo = this._origRedirectTo;
if (this._origReLaunch) wx.reLaunch = this._origReLaunch;
if (this._origNavigateTo) wx.navigateTo = this._origNavigateTo;
this._origRedirectTo = null;
this._origReLaunch = null;
this._origNavigateTo = null;
}
getBlockedRedirects() {
return this._blockedRedirects || [];
}
isRedirectGuardOn() {
return !!this._redirectGuard;
}
}
try {
window.nav = new UniversalMiniProgramNavigator();
} catch (e) {
console.error('Navigator init failed:', e.message);
}
})();
+149
View File
@@ -0,0 +1,149 @@
"""
MiniProgramNavigator - inject JS navigator and control mini program pages.
"""
import asyncio
import json
from pathlib import Path
_NAV_JS = (Path(__file__).parent / "nav_inject.js").read_text(encoding="utf-8")
class MiniProgramNavigator:
"""Inject universal navigator JS, then use window.nav for all operations."""
def __init__(self, engine):
self.engine = engine
self.pages = []
self.tab_bar_pages = []
self.app_info = {}
self._injected = False
async def _ensure(self, force=False):
if force or not self._injected:
await self.engine.evaluate_js(_NAV_JS, timeout=10.0)
self._injected = True
async def fetch_config(self):
"""Inject navigator and read pages/tabBar/appid from window.nav."""
# 强制重新注入,以支持切换小程序后获取新配置
await self._ensure(force=True)
result = await self.engine.evaluate_js(
"JSON.stringify({pages:window.nav?window.nav.allPages:[],"
"tabBar:window.nav?window.nav.tabBarPages:[],"
"appid:window.nav&&window.nav.config?(window.nav.config.appid||''):'',"
"entry:window.nav&&window.nav.config?(window.nav.config.entryPagePath||''):'',"
"name:(function(){try{var a=window.__wxConfig||window.wx&&window.wx.__wxConfig||{};"
"var b=a.accountInfo&&a.accountInfo.appAccount;"
"return b&&b.nickname||a.appname||''}catch(e){return ''}})()"
"})",
timeout=5.0,
)
value = self._extract_value(result)
if not value:
return
try:
config = json.loads(value)
except (json.JSONDecodeError, TypeError):
return
self.pages = config.get("pages", [])
self.tab_bar_pages = config.get("tabBar", [])
self.app_info = {
"appid": config.get("appid", ""),
"entry": config.get("entry", ""),
"name": config.get("name", ""),
}
async def navigate_to(self, route):
"""Smart navigate via window.nav.goTo (switchTab for tabbar, navigateTo for others)."""
await self._ensure()
safe = route.replace("'", "\\'")
await self.engine.evaluate_js(f"window.nav.goTo('{safe}')", timeout=5.0)
async def redirect_to(self, route):
"""wx.redirectTo via the detected wxFrame."""
await self._ensure()
safe = route.replace("'", "\\'")
await self.engine.evaluate_js(
f"window.nav.wxFrame.wx.redirectTo({{url:'/{safe}'}})", timeout=5.0
)
async def relaunch_to(self, route):
"""Safe navigate: reLaunch → switchTab → redirectTo fallback chain."""
await self._ensure()
safe = route.replace("'", "\\'")
await self.engine.evaluate_js(
f"window.nav._safeNavigate('{safe}')", timeout=5.0
)
async def navigate_back(self, delta=1):
"""wx.navigateBack via window.nav.back."""
await self._ensure()
await self.engine.evaluate_js(f"window.nav.back({delta})", timeout=5.0)
async def get_current_route(self):
"""Get current page route via the detected wxFrame."""
await self._ensure()
result = await self.engine.evaluate_js(
"(function(){try{if(window.nav&&window.nav.wxFrame){"
"var p=window.nav.wxFrame.getCurrentPages();"
"return p.length?p[p.length-1].route||p[p.length-1].__route__||'':''"
"}return ''}catch(e){return ''}})()",
timeout=3.0,
)
return self._extract_value(result) or ""
async def auto_visit(self, pages, delay=2.0, on_progress=None, cancel_event=None):
"""Visit pages sequentially using safe navigation."""
total = len(pages)
for i, route in enumerate(pages):
if cancel_event and cancel_event.is_set():
break
if on_progress:
on_progress(i, total, route)
try:
await self.relaunch_to(route)
except Exception:
pass
await asyncio.sleep(delay)
if on_progress:
on_progress(total, total, "done")
async def enable_redirect_guard(self):
"""开启防强制跳转"""
await self._ensure()
result = await self.engine.evaluate_js(
"JSON.stringify(window.nav.enableRedirectGuard())", timeout=5.0)
value = self._extract_value(result)
if value:
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
pass
return {"ok": False}
async def disable_redirect_guard(self):
"""关闭防强制跳转"""
await self._ensure()
await self.engine.evaluate_js(
"window.nav.disableRedirectGuard()", timeout=5.0)
async def get_blocked_redirects(self):
"""获取被拦截的跳转记录"""
await self._ensure()
result = await self.engine.evaluate_js(
"JSON.stringify(window.nav.getBlockedRedirects())", timeout=5.0)
value = self._extract_value(result)
if value:
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
pass
return []
@staticmethod
def _extract_value(result):
if not result:
return None
r = result.get("result", {})
inner = r.get("result", {})
return inner.get("value")
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+111
View File
@@ -0,0 +1,111 @@
"""
UserScript loader - parses Tampermonkey-style metadata and builds CDP injection commands.
"""
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import List
@dataclass
class UserScript:
name: str
description: str
match: str
run_at: str
inject_into: str
source: str
file_path: str
def __repr__(self):
return f"UserScript(name={self.name!r}, run_at={self.run_at!r}, file={os.path.basename(self.file_path)!r})"
def parse_metadata(source: str) -> dict:
metadata = {}
in_block = False
for line in source.splitlines():
stripped = line.strip()
if stripped == "// ==UserScript==":
in_block = True
continue
if stripped == "// ==/UserScript==":
break
if in_block and stripped.startswith("// @"):
m = re.match(r"^//\s*@(\S+)\s*(.*?)\s*$", stripped)
if m:
metadata[m.group(1)] = m.group(2)
return metadata
def load_userscripts(scripts_dir: str) -> List[UserScript]:
scripts = []
scripts_path = Path(scripts_dir)
if not scripts_path.exists():
return scripts
for js_file in sorted(scripts_path.glob("*.js")):
script = _load_single_file(js_file)
if script:
scripts.append(script)
return scripts
def load_userscripts_by_files(file_paths: List[str]) -> List[UserScript]:
scripts = []
for fp in file_paths:
p = Path(fp)
if not p.exists():
continue
script = _load_single_file(p)
if script:
scripts.append(script)
return scripts
def _load_single_file(js_file: Path) -> UserScript | None:
try:
source = js_file.read_text(encoding="utf-8")
except Exception:
return None
meta = parse_metadata(source)
return UserScript(
name=meta.get("name", js_file.stem),
description=meta.get("description", ""),
match=meta.get("match", "*"),
run_at=meta.get("run-at", "document-start"),
inject_into=meta.get("inject-into", "page"),
source=source,
file_path=str(js_file),
)
def _indent(text: str, spaces: int) -> str:
prefix = " " * spaces
return "\n".join(prefix + line for line in text.splitlines())
def build_injection_wrapper(script: UserScript) -> str:
safe_name = script.name.replace("\\", "\\\\").replace("'", "\\'")
return f"""(function() {{
'use strict';
try {{
{_indent(script.source, 4)}
}} catch(__e__) {{
console.error('[UserScript] Error in "{safe_name}":', __e__);
}}
}})();"""
def build_cdp_add_script_command(script: UserScript, cmd_id: int) -> str:
wrapped = build_injection_wrapper(script)
return json.dumps({
"id": cmd_id,
"method": "Page.addScriptToEvaluateOnNewDocument",
"params": {"source": wrapped},
})
def build_cdp_enable_page_command(cmd_id: int) -> str:
return json.dumps({"id": cmd_id, "method": "Page.enable", "params": {}})