First commit
This commit is contained in:
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
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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")
|
||||
Vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+144
File diff suppressed because one or more lines are too long
@@ -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": {}})
|
||||
Reference in New Issue
Block a user