美团微信小程序mtgsig参数生成
本文最后更新于10 天前,其中的信息可能已经过时,如有错误请5Yqg5b6u5L+ha2Fud2s2NjY=
"""mtgsig 生成(微信小程序风控请求头),单文件实现,与 @mtfe/wx-jsguard 1.2 逐字节一致。

依赖
    pip install pycryptodome          # AES-128-CBC
    Python 3.7+,无其它第三方依赖

快速上手
    import mtgsig

    s = mtgsig.MtgsigSigner.bootstrap("wx0000000000000000",
                                      dfpid="")            # 传 dfpid=纯离线;留空=自动注册一次
    head, canon = s.sign("POST", url, body_dict, {"Content-Type": "application/json"})
    requests.post(url, data=mtgsig.json_stringify(body_dict), headers={"mtgsig": head, ...})

要点
    - 入签内容 = method + path + 排序后的 query + body 原文;**不含任何请求头**。
    - body 必须按发送时一致的字节入签:dict 用 `json_stringify()`,str 原样。
    - `bootstrap(dfpid=...)` 不产生任何网络请求;`dfpid=""` 会 POST 一次设备注册接口拿 dfpid
      并回填 `server_time_diff`,注册失败直接抛异常(不做静默降级)。
    - `a6` 载荷内嵌 dfpid,注册后会自动重建 `a6`,二者始终保持一致。
    - 设备档案是常量 `DEVICE_PROFILE`(桌面微信端);换端整块替换该常量或传 `profile=`。

公开 API
    MtgsigSigner.bootstrap / .from_capture / .sign / .dfp_request / .dfp_report
    calc_mtgsig(...)                      无状态一次签名,参数全自己给
    decode_mtgsig(head) / decode_a6(a6)   反解原料,用于核对与排错
    json_stringify(obj)                   与 JS JSON.stringify 等价的序列化
    DEVICE_PROFILE / FPV / AES_KEY / ALPHABET 可调常量

算法对应关系(原始 JS 模块内行号,仅供核对)
  ue():998-1058   z = ce(METHOD + " " + path + " " + sorted_query),非 GET 非 form 时追加 body 字节
  ue():1114-1120  l = BE4(ts) ;v = md5hex(ce(a6)+l) ;y = re(v[:15]),y[7]=(env^Gn(l))&0xff,
                  y += l,y += BE4(Gn(y))                        -> siua 头 16 字节
  ue():1121-1381  a5 = base64_custom( y ++ RC4(key=y, plain=json(qn)) )   KSA 比标准多 +31
  ue():1382-1387  x = murmur3(z, ts),_ = murmur3(ce(a5), ts),
                  a4 = hex( BE4(x) ++ BE4(_) ++ LE4[x, _, x^o, x^_^o] )
  ue():1397-1404  M = a1+a2+a3+a4+(_>>>0)+v+a7 -> md5 四字,j=(o<<3)|(o<<29)
                  B0^=j, B1^=f, B2^=f^j, B3^=B0 -> a5 之外的 d1
  2704            localId = ts + 7位(k|65)大写字母 + md5 + crc32(前52字符)十进制前4位
  3012            a6 = "w1.6" + base64(AES-CBC-PKCS7(gzip(按 vt.DFP 九字段投影的采集体)))
"""
import base64
import gzip
import hashlib
import json
import random
import struct
import time
import zlib

try:
    from Crypto.Cipher import AES as _Cipher
except ImportError as _e:                      # 依赖边界,不做降级
    raise ImportError("mtgsig 需要 pycryptodome:pip install pycryptodome") from _e

__all__ = ["MtgsigSigner", "calc_mtgsig", "decode_mtgsig", "decode_a6", "json_stringify",
           "build_canonical", "build_a6", "build_dfp_request", "local_id", "new_profile",
           "DEVICE_PROFILE", "FPV", "AES_KEY", "AES_IV", "ALPHABET"]

# ---------------------------------------------------------------- 基础工具

# jsguard.js:3352 自定义 base64 字母表
ALPHABET = "ZmserbBoHQtNP+wOcza/LpngG8yJq42KWYj0DSfdikx3VT16IlUAFM97hECvuRX5"
_UNRESERVED = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()")
_CRC_POLY = 0xEDB88320
_CRC_FINAL = 306674911
_M = 1540483477  # murmur3 常量 0x5bd1e995


def _js_toint32(x):
    x = int(x) % 0x100000000
    return x - 0x100000000 if x >= 0x80000000 else x


def _js_touint32(x):
    return int(x) % 0x100000000


undefined = object()


def _js_num(x):
    """JS Number -> 字符串:整值浮点不带 .0(31.0 -> "31"),与 JSON.stringify/ToString 一致"""
    if isinstance(x, float):
        if x != x or x in (float("inf"), float("-inf")):
            return "null"
        if x.is_integer() and abs(x) < 1e21:
            return str(int(x))
        return repr(x)
    return str(x)


def _js_str(v):
    """JS ToString 语义(encodeURIComponent/字符串拼接收到的非字符串值走这套)"""
    if v is None:
        return "null"
    if v is True:
        return "true"
    if v is False:
        return "false"
    if isinstance(v, (int, float)):
        return _js_num(v)
    if isinstance(v, dict):
        return "[object Object]"
    if isinstance(v, (list, tuple)):
        return ",".join("" if e is None or e is undefined else _js_str(e) for e in v)
    return v if isinstance(v, str) else str(v)


def json_stringify(o):
    """JS JSON.stringify:紧凑分隔、整值浮点写整数、undefined 键被丢弃"""
    def conv(v):
        if v is undefined:
            return None
        if isinstance(v, float):
            return int(v) if v.is_integer() and abs(v) < 1e21 else v
        if isinstance(v, dict):
            return {k: conv(x) for k, x in v.items() if x is not undefined}
        if isinstance(v, (list, tuple)):
            return [conv(x) for x in v]
        return v
    return json.dumps(conv(o), ensure_ascii=False, separators=(",", ":"))


def encode_uri_component(s):
    """JS encodeURIComponent:仅不保留 A-Za-z0-9-_.!~*'()"""
    out = []
    for ch in _js_str(s):
        if ch in _UNRESERVED:
            out.append(ch)
        else:
            for b in ch.encode("utf-8"):
                out.append("%%%02X" % b)
    return "".join(out)


def fe(s):
    """jsguard fe():encodeURIComponent 后再把 ! ' ( ) * 转义"""
    return (encode_uri_component(s).replace("!", "%21").replace("'", "%27")
            .replace("(", "%28").replace(")", "%29").replace("*", "%2A"))


def ce(s):
    """jsguard ce():encodeURIComponent(s) 的逐字节数组(%XX 还原为字节)"""
    e = encode_uri_component(s)
    out = []
    i = 0
    while i < len(e):
        if e[i] == "%":
            out.append(int(e[i + 1:i + 3], 16))
            i += 3
        else:
            out.append(ord(e[i]))
            i += 1
    return out


def re_(hexstr):
    """jsguard re():十六进制串 -> 字节数组(奇数长度时末位单独解析)"""
    out = []
    i = 0
    while i < len(hexstr):
        out.append(int(hexstr[i:i + 2], 16))
        i += 2
    return out


def ie(x):
    """jsguard ie():uint32 -> 4 字节大端"""
    x = _js_touint32(x)
    return [(x >> 24) & 255, (x >> 16) & 255, (x >> 8) & 255, x & 255]


def oe(bs):
    return "".join("%02x" % b for b in bs)


_CRC = []
for _r in range(256):
    _t = _r
    for _ in range(8):
        _t = (_t >> 1) ^ _CRC_POLY if _t & 1 else _t >> 1
    _CRC.append(_t)


def Gn(data):
    """jsguard Gn():CRC32 变体,末位异或 306674911"""
    r = 0xFFFFFFFF
    if isinstance(data, str):
        for c in data:
            r = _CRC[(r ^ ord(c)) & 0xFF] ^ (r >> 8)
            r &= 0xFFFFFFFF
    else:
        for b in data:
            r = _CRC[(r ^ b) & 0xFF] ^ (r >> 8)
            r &= 0xFFFFFFFF
    return _CRC_FINAL ^ r


def ge(data, seed):
    """jsguard ge():murmur3-32 变体(长度混入种子、末尾再异或 0x5bd1e995)"""
    n = len(data)
    t = _js_toint32(seed) ^ n
    c = 0
    while n >= 4:
        r = (data[c] & 255) | ((data[c + 1] & 255) << 8) | ((data[c + 2] & 255) << 16) | ((data[c + 3] & 255) << 24)
        c += 4
        r = _js_touint32(_M * (r & 0xFFFF) + ((_M * (r >> 16) & 0xFFFF) << 16))
        t_word = _M * (t & 0xFFFF) + ((_M * (_js_touint32(t) >> 16) & 0xFFFF) << 16)
        r = r ^ (r >> 24)
        r = _js_touint32(_M * (r & 0xFFFF) + ((_M * (_js_touint32(r) >> 16) & 0xFFFF) << 16))
        t = _js_toint32(t_word ^ r)
        n -= 4
    if n == 3:
        t = _js_toint32(t ^ (data[c + 2] << 16))
        t = _js_toint32(t ^ (data[c + 1] << 8))
        t = _js_toint32(t ^ data[c])
        t = _js_toint32(_M * (t & 0xFFFF) + ((_M * (_js_touint32(t) >> 16) & 0xFFFF) << 16))
    elif n == 2:
        t = _js_toint32(t ^ (data[c + 1] << 8))
        t = _js_toint32(t ^ data[c])
        t = _js_toint32(_M * (t & 0xFFFF) + ((_M * (_js_touint32(t) >> 16) & 0xFFFF) << 16))
    elif n == 1:
        t = _js_toint32(t ^ data[c])
        t = _js_toint32(_M * (t & 0xFFFF) + ((_M * (_js_touint32(t) >> 16) & 0xFFFF) << 16))
    t = _js_toint32(t ^ (_js_touint32(t) >> 13))
    t = _M * (t & 0xFFFF) + ((_M * (_js_touint32(t) >> 16) & 0xFFFF) << 16)
    t = _js_toint32(_js_touint32(t ^ (_js_touint32(t) >> 15)) ^ _M)
    return t


def md5_words(data):
    """标准 MD5,返回 4 个 uint32 字(对应 jsguard 的 md5Array)"""
    return list(struct.unpack("<4I", hashlib.md5(bytes(data)).digest()))


def le_bytes(words):
    """jsguard md5ToHex()+re():uint32 字数组 <-> 小端字节数组"""
    out = []
    for w in words:
        w &= 0xFFFFFFFF
        out += [w & 0xFF, (w >> 8) & 0xFF, (w >> 16) & 0xFF, (w >> 24) & 0xFF]
    return out


def b64_custom(bs):
    """jsguard se()/base64 VM:标准 base64 流程 + 自定义字母表"""
    out = []
    n = len(bs)
    i = 0
    while i + 3 <= n:
        v = (bs[i] << 16) + (bs[i + 1] << 8) + bs[i + 2]
        out.append(ALPHABET[(v >> 18) & 63] + ALPHABET[(v >> 12) & 63] + ALPHABET[(v >> 6) & 63] + ALPHABET[v & 63])
        i += 3
    rem = n - i
    if rem == 1:
        v = bs[i]
        out.append(ALPHABET[v >> 2] + ALPHABET[(v << 4) & 63] + "==")
    elif rem == 2:
        v = (bs[i] << 8) + bs[i + 1]
        out.append(ALPHABET[v >> 10] + ALPHABET[(v >> 4) & 63] + ALPHABET[(v << 2) & 63] + "=")
    return "".join(out)


def rc4(key, text):
    """jsguard 内层 VM:KSA 多加了常数 31,PRGA 为标准 RC4"""
    s = list(range(256))
    j = 0
    for i in range(256):
        j = (j + s[i] + key[i % len(key)] + 31) % 256
        s[i], s[j] = s[j], s[i]
    out = []
    i = j = 0
    for ch in text:
        i = (i + 1) % 256
        j = (j + s[i]) % 256
        s[i], s[j] = s[j], s[i]
        out.append(ord(ch) ^ s[(s[i] + s[j]) % 256])
    return out


# ---------------------------------------------------------------- 参数规范化

URL_RE = r"^(?:([A-Za-z]+):)?(\/{0,3})([0-9.\-A-Za-z]+)(?::(\d+))?(?:\/([^?#]*))?(?:\?([^#]*))?(?:#(.*))?$"


def parse_url(url):
    """还原 ue() 里的正则解析:只关心 path(5) 与 query(6)"""
    import re
    m = re.match(URL_RE, url)
    if not m:
        return "/", []
    return (m.group(5) or ""), (m.group(6) or "")


def ne(query, keep_undefined=False):
    """jsguard ne():query -> [(k, v)]"""
    pairs = []
    for part in query.split("&"):
        kv = part.split("=")
        if len(kv) < 2:
            pairs.append([_decode(kv[0].replace("+", " ")), "undefined" if keep_undefined else ""])
        else:
            pairs.append([_decode(kv[0].replace("+", " ")), _decode(kv[1].replace("+", " "))])
    return pairs


def _decode(s):
    """JS decodeURIComponent:连续 %XX 组成一个 UTF-8 段严格解码,非法即抛 URIError"""
    out = []
    i = 0
    n = len(s)
    while i < n:
        if s[i] != "%":
            out.append(s[i])
            i += 1
            continue
        run = bytearray()
        while i < n and s[i] == "%":
            hx = s[i + 1:i + 3]
            if len(hx) < 2 or any(c not in "0123456789abcdefABCDEF" for c in hx):
                raise ValueError("URIError: malformed URI")
            run.append(int(hx, 16))
            i += 3
        try:
            out.append(run.decode("utf-8"))
        except UnicodeDecodeError:
            raise ValueError("URIError: malformed URI")
    return "".join(out)


def te(pairs_out, src, encode=False):
    """jsguard te():把 dict 或 [(k,v)] 追加为 [fe(k), fe(v)]"""
    if encode:
        items = (src.items() if isinstance(src, dict) else
                 list(enumerate(src)) if isinstance(src, (list, tuple)) else src)
        for k, v in items:
            if v is undefined:
                pairs_out.append([fe(k), "undefined"])
            elif v is None:
                pairs_out.append([fe(k), "null"])
            elif isinstance(v, (dict, list)):
                pairs_out.append([fe(k), fe(json_stringify(v))])
            else:
                pairs_out.append([fe(k), fe(v)])
    else:
        for k, v in src:
            pairs_out.append([fe(k), fe(v)])


def _pair_sort_key(p):
    return (p[0], p[1])


def build_canonical(method, url, data, headers):
    """构造 z 字节数组(jsguard ue() 1011-1058 行)"""
    method = (method or "GET").upper()
    headers = headers or {}
    path, query = parse_url(url)
    path = "/" + path.lstrip("/") if path else "/"
    g = ne(query) if query else []   # JS: b[6] && (g = ne(b[6])) —— 空 query 不解析
    is_form = method != "GET" and _content_type_is_form(headers)
    pairs = []
    body_str = ""
    if method == "GET":
        if isinstance(data, (dict, list, tuple)) and len(data) > 0:
            te(pairs, data, True)
            if query and g:
                keys = set(data) if isinstance(data, dict) else {str(i) for i in range(len(data))}
                extra = {}
                for k, v in ne(query, True):   # JS: v[key]=value,重复键后者覆盖
                    if k not in keys:
                        extra[k] = v
                te(pairs, extra, True)
        else:
            te(pairs, g)
    else:
        te(pairs, g)
        if is_form:
            if isinstance(data, str):
                body_str = data
            elif isinstance(data, dict):
                body_str = "&".join(f"{encode_uri_component(k)}={encode_uri_component(v)}" for k, v in data.items())
            elif isinstance(data, (list, tuple)):
                body_str = "&".join(f"{i}={encode_uri_component(v)}" for i, v in enumerate(data))
    pairs.sort(key=_pair_sort_key)
    canon = method + " " + path + " " + "&".join(f"{k}={v}" for k, v in pairs)
    z = ce(canon)
    if not (is_form or method == "GET" or data is None):
        raw = data if isinstance(data, str) else json_stringify(data)
        z += ce(raw)[:16200]
    if len(body_str) > 0:
        z += ce(body_str)[:16200]
    return z, canon


def _content_type_is_form(headers):
    """jsguard ue() 998-1006 行:只有 content-type 以 form-urlencoded 开头才为 True"""
    found = False
    for k, v in headers.items():
        if k.lower() == "content-type":
            found = True
            if v and str(v).lower().startswith("application/x-www-form-urlencoded"):
                return True
    return False


# ---------------------------------------------------------------- 签名主体

def calc_mtgsig(method, url, data=None, headers=None, *, ts, dfpid, appid,
                token="", qn=None, env=0, x0=3, version="1.2", route="", sig_extra="00000"):
    """生成 mtgsig 头。ts 必须为毫秒整数(对应 a2),qn 为指纹 JSON 字符串。

    env 取 0(微信端常见值,可由 decode_mtgsig 从既有头反解核对);qn 为 None 时用 _build_qn 造最小指纹体。
    """
    o = _js_touint32(ts)
    l = ie(o)
    if qn is None:
        qn = _build_qn(ts, appid, route, sig_extra)
    # siua 头 16 字节
    v = hashlib.md5(bytes(ce(token) + l)).hexdigest()
    y = re_(v[:15])
    y[7] = 255 & (env ^ Gn(l))
    y += l
    y += ie(_js_touint32(Gn(y)))
    # a5 = base64( y ++ rc4(key=y, data=qn) )
    enc = y + rc4(y, qn)
    a5 = b64_custom(enc)

    z, canon = build_canonical(method, url, data, headers)
    x = ge(z, ts)
    _u = ge(ce(a5), ts)
    f = _js_touint32(_u)
    a4 = oe(ie(x) + ie(_u) + le_bytes([x, _u, x ^ o, x ^ _u ^ o]))
    sig = {"a1": version, "a2": ts, "a3": dfpid, "a4": a4, "a5": a5, "a6": token, "a7": appid, "x0": x0}
    m_str = "".join([sig["a1"], str(ts), sig["a3"], sig["a4"], str(f), v, sig["a7"]])
    b = md5_words(ce(m_str))
    j = ((o << x0) & 0xFFFFFFFF) | ((o << (32 - x0)) & 0xFFFFFFFF)
    b[0] ^= j
    b[1] ^= f
    b[2] ^= f ^ j
    b[3] ^= b[0]
    sig["d1"] = "".join("%02x" % c for c in le_bytes(b))
    return sig, canon


def b64_custom_decode(s):
    out, acc, bits = [], 0, 0
    for ch in s.rstrip("="):
        acc = acc * 64 + ALPHABET.index(ch)
        bits += 6
        if bits >= 8:
            bits -= 8
            out.append((acc >> bits) & 255)
    return out


def decode_mtgsig(header_value):
    """把一个真实抓到的 mtgsig 头拆回原料:ts / dfpid / token / appid / env / qn 明文。
    用于在离线端复刻同一台设备的签名上下文(对应 calc 的全部输入)。
    """
    sig = json.loads(header_value) if isinstance(header_value, str) else dict(header_value)
    raw = b64_custom_decode(sig["a5"])
    y, cipher = raw[:16], raw[16:]
    ts = sig["a2"]
    l = ie(_js_touint32(ts))
    env = (y[7] ^ Gn(l)) & 0xFF
    qn = _rc4_decrypt(y, cipher)
    return {"fields": sig, "ts": ts, "dfpid": sig["a3"], "token": sig.get("a6", ""),
            "appid": sig.get("a7", ""), "env": env, "y": y, "qn": qn,
            "x0": sig.get("x0", 3), "version": sig.get("a1", "1.2")}


def _rc4_decrypt(key, data):
    """RC4 对称:把密文字节映射回可 XOR 的字符序列(js 侧明文是 charCode 串)"""
    s = list(range(256))
    j = 0
    for i in range(256):
        j = (j + s[i] + key[i % len(key)] + 31) % 256
        s[i], s[j] = s[j], s[i]
    i = j = 0
    out = []
    for b in data:
        i = (i + 1) % 256
        j = (j + s[i]) % 256
        s[i], s[j] = s[j], s[i]
        out.append(chr(b ^ s[(s[i] + s[j]) % 256]))
    return "".join(out)


class MtgsigSigner:
    """签名上下文:一台设备的 dfpid / appid / 指纹体 qn / env,之后按请求刷新 ts。

    三种用法:
      1) 复用一条已有头:MtgsigSigner.from_capture(mtgsig_str)
      2) 复用已注册设备:MtgsigSigner.bootstrap(appid, dfpid="<上次拿到的 data.dfp>")  —— 纯离线
      3) 首次/无 dfpid:MtgsigSigner.bootstrap(appid)  —— 会真发一次 /v1/wxdfpid 注册
    """

    def __init__(self, appid, dfpid, qn=None, env=0, token="", version="1.2",
                 route="", server_time_diff=0, profile=None, openid="", accuracy="", sig_flags="00000"):
        self.appid = appid
        self.dfpid = dfpid
        self.env = env
        self.token = token
        self.version = version
        self.route = route
        self.server_time_diff = server_time_diff
        self.profile = profile
        self.openid = openid
        self.accuracy = accuracy
        self.sig_flags = sig_flags
        self._qn_fields = None
        self.counter = 1
        if qn is not None:
            self._qn_fields = json.loads(qn)
            self.counter = int(self._qn_fields.get("b8", 1))

    @classmethod
    def from_capture(cls, mtgsig_str, server_time_diff=0):
        """用一条已有的 mtgsig 建立签名上下文:qn 按字段还原,b8 计数继续自增。

        qn 字段语义:b7=guard 初始化秒级时间戳(整个会话冻结)、
        b1=getAccountInfoSync()、b6=openId、b8=签名调用次数、b12=appid、
        b2=当前页 route、b9=生命周期标志位 5 元组、b11=定位精度 acc_hAcc_vAcc。
        """
        d = decode_mtgsig(mtgsig_str)
        return cls(appid=d["appid"], dfpid=d["dfpid"], env=d["env"], token=d["token"],
                   version=d["version"], qn=d["qn"], server_time_diff=server_time_diff)

    @classmethod
    def bootstrap(cls, appid, dfpid="", profile=None, openid="", session_id="", accuracy="",
                  server_time_diff=0, sig_flags="22122", timeout=10):
        """自建上下文(不依赖任何已有样本):a6 走 i() 本地算,设备档案取常量 DEVICE_PROFILE(桌面端)。

        dfpid 传入则直接复用(不发网络请求);留空则现场注册一次 —— 会 POST
        https://msp.meituan.com/v1/wxdfpid 并回填 a3 与 serverTimeDiff。
        注册失败会抛异常,不做静默降级:a3 用本地 localId 兜底虽能签出格式合法的头,
        但那是不被服务端认识的设备,失败应当让你看见。
        """
        p = dict(profile or new_profile(appid))
        p.setdefault("app", appid)
        filetime = int(p.get("filetime") or time.time() * 1000)
        system = p["system"]
        lid = local_id(system.get("model"), system, filetime, openid)
        los = system.get("LaunchOptionsSync") or {}
        route = los.get("path", "") if isinstance(los, dict) else json.loads(los or "{}").get("path", "")
        a6, n = build_a6(p, dfpid or lid, lid, filetime, filetime // 1000, session_id)
        sig = cls(appid=appid, dfpid=dfpid or lid, env=0, token=a6, route=route,
                  server_time_diff=server_time_diff, profile=p, openid=openid,
                  accuracy=accuracy, sig_flags=sig_flags)
        sig.filetime = filetime
        sig.localid = lid
        sig.session_id = session_id
        sig.n = n
        sig.device = {k: system.get(k) for k in ("platform", "brand", "model", "system", "SDKVersion", "version")}
        if not dfpid:
            sig.dfp_report(timeout=timeout)
        return sig

    def _qn(self, ts):
        if self._qn_fields is not None:
            fields = dict(self._qn_fields)
            fields["b8"] = self.counter
            return json.dumps(fields, ensure_ascii=False, separators=(",", ":"))
        b7 = (getattr(self, "filetime", ts) or ts) // 1000
        return _build_qn(b7 * 1000, self.appid, self.route, self.sig_flags,
                         self.openid, self.accuracy)

    def dfp_request(self, err_stack=""):
        """生成 /v1/wxdfpid 注册请求体(换服务端 dfpid 用),dfp_report() 可直接发送。"""
        p = self.profile or new_profile(self.appid)
        filetime = int(getattr(self, "filetime", 0) or time.time() * 1000)
        lid = getattr(self, "localid", "") or local_id(p["system"].get("model"), p["system"], filetime, self.openid)
        return build_dfp_request(p, self.dfpid, lid, filetime, int(time.time() * 1000), err_stack)

    def dfp_report(self, ua="", timeout=10):
        """真发送 /v1/wxdfpid,返回 (dfpid, serverTimeDiff) 并写入上下文。dfp_request() 为其离线构造。"""
        import urllib.request
        req = self.dfp_request()
        headers = {"Content-Type": "application/json"}
        if ua:
            headers["User-Agent"] = ua
        r = urllib.request.Request(req["url"], data=req["body"].encode("utf-8"), headers=headers, method="POST")
        with urllib.request.urlopen(r, timeout=timeout) as resp:
            js = json.loads(resp.read().decode("utf-8"))
        d = js.get("data") or {}
        if not d.get("dfp"):
            raise ValueError("dfp 接口未返回 data.dfp: %s" % json.dumps(js, ensure_ascii=False)[:200])
        now_ms = int(time.time() * 1000)
        self.dfpid = d["dfp"]
        self.server_time_diff = int(d.get("serverTimestamp", now_ms)) - now_ms
        if self.profile is not None:
            # a6 投影槽 1 就是 dfpid,注册后必须用新 dfpid 重新生成,否则 a3 与 a6 内不一致
            self.token, self.n = build_a6(self.profile, self.dfpid, self.localid, self.filetime,
                                          self.filetime // 1000, getattr(self, "session_id", ""))
        return self.dfpid, self.server_time_diff

    def sign(self, method, url, data=None, headers=None, ts=None):
        """返回 (mtgsig 头字符串, 规范化请求串)。ts 为毫秒时间戳,默认取当前时间。"""
        base_ts = int(ts if ts is not None else time.time() * 1000) + self.server_time_diff
        sig, canon = calc_mtgsig(method, url, data, headers, ts=base_ts, dfpid=self.dfpid,
                                 appid=self.appid, token=self.token, qn=self._qn(base_ts),
                                 env=self.env, version=self.version)
        self.counter += 1
        return json.dumps(sig, ensure_ascii=False, separators=(",", ":")), canon


# ---------------------------------------------------------- 指纹上报(a6 / dfp 注册)
# jsguard:2704 起 —— _n 采集体 + vt 模式投影 + sjcl AES-128-CBC(PKCS7) + gzip
AES_KEY = b"z7Jut6Ywr2Pe5Nhx"   # jsguard:802 常量 1(hex-XOR 解出)
AES_IV = b"0807060504030201"    # jsguard:802 常量 2
DFP_PREFIX = "WX__ver1.2.0_CCCC_"   # jsguard:2704 Dn
FPV = "2.5.0"
DFP_URL = "https://msp.meituan.com/v1/wxdfpid"   # jsguard:2696/2843
JSGUARD_TWEAK_POS = {"key": (2, 5, 8, 9), "iv": (3, 5, 6, 9, 10)}

_DFP_SCHEMA = ["app", "dfpid", "filetime", "fpv", "localid", "system", "timestamp", "ext", "sessionId"]
_SYSTEM_SCHEMA = ["accelerometer", "albumAuthorized", "BatteryInfo", "batteryLevel", "Beacons", "benchmarkLevel",
                  "bluetoothEnabled", "brand", "brightness", "cameraAuthorized", "compass", "deviceOrientation",
                  "devicePixelRatio", "enableDebug", "errMsg", "fontSizeSetting", "language", "LaunchOptionsSync",
                  "locationAuthorized", "locationEnabled", "locationReducedAccuracy", "microphoneAuthorized",
                  "model", "networkType", "notificationAlertAuthorized", "notificationAuthorized",
                  "notificationBadgeAuthorized", "notificationSoundAuthorized", "pixelRatio", "platform",
                  "safeArea", "screenHeight", "screenTop", "screenWidth", "SDKVersion", "statusBarHeight",
                  "system", "version", "wifiEnabled", "WifiInfo", "windowHeight", "windowWidth",
                  "screenRecord", "isPrivacy", "hasSystemProxy", "captureRecord"]
_SUB_SCHEMA = {
    "BatteryInfo": ["errMsg", "isCharging", "level"],
    "safeArea": ["left", "right", "top", "bottom", "width", "height"],
    "WifiInfo": ["SSID", "BSSID", "autoJoined", "signalStrength", "justJoined", "secure", "frequency"],
}


def _pkcs7(data, block=16):
    pad = block - len(data) % block
    return data + bytes([pad]) * pad


def _unpkcs7(data):
    return data[:-data[-1]]


def aes_encrypt_b64(raw, tweak=""):
    """jsguard Z(e):sjcl AES-128-CBC + PKCS7 + 标准 base64(与 JS 侧双向互通已实测)"""
    key, iv = _tweak_kv(tweak) if len(tweak) == 6 else (AES_KEY, AES_IV)
    ct = _Cipher.new(key, _Cipher.MODE_CBC, iv).encrypt(_pkcs7(raw))
    return base64.b64encode(ct).decode()


def aes_decrypt_b64(b64_str, tweak=""):
    key, iv = _tweak_kv(tweak) if len(tweak) == 6 else (AES_KEY, AES_IV)
    pt = _Cipher.new(key, _Cipher.MODE_CBC, iv).decrypt(base64.b64decode(b64_str + "=" * (-len(b64_str) % 4)))
    return _unpkcs7(pt)


def _tweak_kv(tweak):
    """jsguard:814 —— 6 位码插入 key[2,5,8,9](取 t0,t1,t2,t4)与 iv[3,5,6,9,10](取 t0,t2,t3,t4,t5)"""
    k = list(AES_KEY.decode())
    for pos, ch in zip(JSGUARD_TWEAK_POS["key"], [tweak[i] for i in (0, 1, 2, 4)]):
        k[pos] = ch
    v = list(AES_IV.decode())
    for pos, ch in zip(JSGUARD_TWEAK_POS["iv"], [tweak[i] for i in (0, 2, 3, 4, 5)]):
        v[pos] = ch
    return "".join(k).encode(), "".join(v).encode()


def project(obj, schema):
    """jsguard:3090 的模式投影:dict -> 按 schema 取值的数组(含三处特殊整形)"""
    out = []
    for key in schema:
        v = obj.get(key)
        if key == "LaunchOptionsSync" and v:
            parsed = json.loads(v) if isinstance(v, str) else v
            v = json.dumps({"path": parsed.get("path"), "scene": parsed.get("scene")},
                           ensure_ascii=False, separators=(",", ":"))
        elif key == "accelerometer" and isinstance(v, list) and v:
            v = [[s["x"], s["y"], s["z"]] for s in v if s.get("x") and s.get("y") and s.get("z")]
        elif key in ("BatteryInfo", "WifiInfo", "safeArea") and isinstance(v, str):
            v = json.loads(v)
        if isinstance(v, dict) and key in _SUB_SCHEMA:
            v = project(v, _SUB_SCHEMA[key])
        out.append(v)
    return out


def local_id(model, system, filetime, openid):
    """jsguard:2704 An():ts + 7 位奇数字母 + md5 + crc32 十进制前 4 位"""
    rand = ""
    for _ in range(7):
        rand += chr(int(random.random() * 25) | 65)  # JS: Math.random()*(90-65) | 0+65 -> (k|65)
    core = json_stringify({"model": model, "system": system, "timestamp": filetime, "openid": openid}).encode()
    body = "" + str(filetime) + rand + hashlib.md5(core).hexdigest()
    return body + str(zlib.crc32(body.encode()) & 0xFFFFFFFF)[:4]


def build_a6(profile, dfpid, localid, filetime, now_sec, session_id="", ext=None, openid=""):
    """jsguard:3012 i() -> ("w1.6" + base64(AES(gzip(project(_n, DFP)))), _n)"""
    n = dict(profile)
    n["app"] = profile.get("app", "")
    n["dfpid"] = dfpid
    n["localid"] = localid
    n["filetime"] = filetime
    n["fpv"] = FPV
    n["timestamp"] = now_sec
    n["ext"] = ext if ext is not None else profile.get("ext", [0, 1, 2, 0, 4])
    n["sessionId"] = session_id
    arr = project(n, _DFP_SCHEMA)
    js = json_stringify(arr).encode()
    return "w1.6" + aes_encrypt_b64(gzip.compress(js)), n


def decode_a6(token):
    """反向核对:解开任意 a6(本地自造或既有头里的),按 DFP 模式还原成 dict"""
    raw = json.loads(gzip.decompress(aes_decrypt_b64(token[len("w1.6"):])))
    out = {}
    for key, val in zip(_DFP_SCHEMA, raw):
        if isinstance(val, list) and key in _SUB_SCHEMA:
            val = dict(zip(_SUB_SCHEMA[key], val))
        out[key] = val
    if isinstance(out.get("system"), list):
        out["system"] = dict(zip(_SYSTEM_SCHEMA, out["system"]))
    return out


def build_dfp_request(profile, dfpid, localid, filetime, now_ms, err_stack="", tweak_key=""):
    """jsguard:2821 Pn 的出站体:POST {DFP_URL},data = "WX__ver1.2.0_CCCC_" + AES(json(_n'))"""
    n = dict(profile)
    n["ext"] = profile.get("ext", [0, 1, 2, 0, 4])
    n["fsmode"] = profile.get("fsmode", [])
    n["e"] = err_stack
    n["app"] = profile.get("app")
    n["dfpid"] = dfpid
    n["localid"] = localid
    n["filetime"] = filetime
    n["fpv"] = FPV
    n["timestamp"] = now_ms // 1000
    n["reportTick"] = 0
    body = {"jsgVersion": FPV,
            "data": DFP_PREFIX + aes_encrypt_b64(json_stringify(n).encode(), tweak_key),
            "jsdfpVersion": FPV,
            "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now_ms / 1000)),
            "os": "wechat"}
    return {"url": DFP_URL, "method": "POST",
            "headers": {"content-type": "application/json"},
            "body": json.dumps(body, ensure_ascii=False, separators=(",", ":"))}


# 固定桌面端指纹(微信小程序 PC 端)。一次定好后写死,运行期不再随机;换端整块替换。
# 来源说明:brand/model/platform/system/SDKVersion/version/权限位/hasSystemProxy 是桌面微信端常见取值;
# 窗口几何(480x960)、亮度、scene 是自选值 —— PC 上这些由窗口大小和用户设置决定,没有标准答案。
DEVICE_PROFILE = {
    "accelerometer": [], "albumAuthorized": True,
    "BatteryInfo": {"errMsg": "getBatteryInfo:ok", "isCharging": True, "level": 100},
    "batteryLevel": None, "Beacons": None, "benchmarkLevel": -1, "bluetoothEnabled": False,
    "brand": "microsoft", "brightness": 0.6, "cameraAuthorized": True, "compass": [],
    "deviceOrientation": None, "devicePixelRatio": 1.5, "enableDebug": False,
    "errMsg": "getSystemInfo:ok", "fontSizeSetting": None, "language": "zh_CN",
    "LaunchOptionsSync": {"path": "index/pages/mt/mt", "scene": 1256},
    "locationAuthorized": True, "locationEnabled": True, "locationReducedAccuracy": None,
    "microphoneAuthorized": True, "model": "microsoft", "networkType": "wifi",
    "notificationAlertAuthorized": None, "notificationAuthorized": True,
    "notificationBadgeAuthorized": None, "notificationSoundAuthorized": None,
    "pixelRatio": 1.5, "platform": "windows",
    "safeArea": {"left": 0, "right": 480, "top": 0, "bottom": 960, "width": 480, "height": 960},
    "screenHeight": 960, "screenRecord": None, "screenTop": None, "screenWidth": 480,
    "SDKVersion": "3.17.3", "statusBarHeight": 20, "system": "Windows 11 x64",
    "version": "4.1.13.65", "wifiEnabled": True, "WifiInfo": None,
    "windowHeight": 960, "windowWidth": 480, "captureRecord": "[]", "isPrivacy": 1, "hasSystemProxy": -1,
}


def new_profile(appid, system=None):
    """jsguard:2702 的 _n 采集体骨架"""
    return {"system": dict(DEVICE_PROFILE if system is None else system), "fpv": FPV, "app": appid,
            "ext": [0, 1, 2, 0, 4], "fsmode": []}


def _build_qn(ts, appid, route, sig_extra, openid="", accuracy=""):
    """qn 指纹体:字段顺序与采集端插入顺序一致(b7,b1[,b6],b8,b12,b2,b9[,b11])"""
    payload = {"b7": ts // 1000,
               "b1": {"miniProgram": {"appId": appid, "envVersion": "release", "version": "10.39.1"}}}
    if openid:
        payload["b6"] = openid
    payload["b8"] = 1
    payload["b12"] = appid
    payload["b2"] = route
    payload["b9"] = sig_extra
    if accuracy:
        payload["b11"] = accuracy
    return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))


if __name__ == "__main__":
    import sys

    APPID = "wx0000000000000000"                              # 换成你的小程序 appid
    DFPID = sys.argv[1] if len(sys.argv) > 1 else "0" * 56     # 不给参数用占位 dfpid,自检不打网络
    signer = MtgsigSigner.bootstrap(APPID, dfpid=DFPID)
    print("dfpid  :", signer.dfpid, "(%s)" % ("复用传入" if len(sys.argv) > 1 else "占位,未发请求"))
    print("时差   :", signer.server_time_diff, "ms")
    print("设备   :", json.dumps(signer.device, ensure_ascii=False))

    # ---------- 案例 1:GET,参数全在 query,无 body ----------
    get_url = ("https://wx.meituan.com/weapp/api/poi/v2/poi_detail?poi_id=123456&region_id=6"
               "&yodaReady=wx&csecplatform=3&csecappid=%s" % APPID)
    head_get, canon_get = signer.sign("GET", get_url, headers={"Content-Type": "application/json"})
    print("\n[GET ] canonical :", canon_get)
    print("[GET ] mtgsig    :", head_get)
    # 真正发请求时(示例,不执行):
    #   requests.get(get_url, headers={"mtgsig": head_get, "token": <登录态>, "openId": ..., "uuid": ...})

    # ---------- 案例 2:POST,JSON body ----------
    post_url = ("https://web.meituan.com/api/miniprogram/index/aggregate?riskLevel=71"
                "&csecappid=%s&csecplatform=3&csecversion=3.0.2" % APPID)
    post_body = {"ci": 673, "districtId": 2915, "lat": 31.037891, "lng": 117.08113499999999,
                 "scene": 1256, "platform": 1, "globalId": ""}
    body_str = json_stringify(post_body)      # 实际发送必须用这个串,和签名逐字节一致
    head_post, canon_post = signer.sign("POST", post_url, post_body, {"Content-Type": "application/json"})
    z_post, _ = build_canonical("POST", post_url, post_body, {"Content-Type": "application/json"})
    print("\n[POST] canonical :", canon_post)
    print("[POST] 实际入签   : 上面这段 + body 原文,合计 %d 字节(body 在 z 里,canonical 只显示前半)" % len(z_post))
    print("[POST] body      :", body_str)
    print("[POST] mtgsig     :", head_post)
    #   requests.post(post_url, data=body_str,
    #                 headers={"mtgsig": head_post, "Content-Type": "application/json",
    #                          "token": <登录态>, "openId": ..., "uuid": ..., "csecuuid": ...})

    # ---------- 表单类 POST(content-type 决定 body 怎么进签名)----------
    form_url = "https://epassport.meituan.com/authorize?yoda_req=1"
    head_form, canon_form = signer.sign("POST", form_url, {"code": "abc 123", "state": "x/y"},
                                        {"Content-Type": "application/x-www-form-urlencoded"})
    print("\n[FORM] canonical :", canon_form)

    # ---------- 自检 ----------
    d = decode_mtgsig(json.loads(head_post))
    print("\n自检: a3==signer.dfpid %s | a6 内 dfpid==a3 %s | qn 可解 %s | b8 计数 %s(每次 sign +1)" % (
        d["dfpid"] == signer.dfpid,
        decode_a6(d["token"])["dfpid"] == d["dfpid"],
        d["qn"].startswith('{"b7"'),
        json.loads(d["qn"])["b8"]))
文末附加内容
上一篇