Guest User

export_sensitive_mac.py

a guest
Nov 6th, 2026
329
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 24.40 KB | Source Code | 0 0
  1. # -*- coding: utf-8 -*-
  2. """微信输入法(WeType) 敏感词库导出工具 —— macOS 通用版
  3.  
  4. 用法:
  5.    python3 export_sensitive_mac.py                 # 自动扫描本机所有 WeType 相关目录
  6.    python3 export_sensitive_mac.py -o out.txt
  7.    python3 export_sensitive_mac.py -f a.zh -f b.zh # 只处理指定文件
  8.    python3 export_sensitive_mac.py -d 某目录        # 额外扫描某目录(可多次)
  9.    python3 export_sensitive_mac.py --all-443       # 另存每个文件 443 trie 的全量词表
  10.  
  11. 不依赖任何固定用户名:家目录用 Path.home(),输出里的路径以 ~ 显示。
  12. 默认输出到当前目录 ./wetype_sensitive_words.txt,按来源文件分块:
  13.  
  14.    # source1: /Library/Input Methods/WeType.app/.../cell_dict.ssrc.zh
  15.    # count1 : 13362
  16.    词
  17.    ...
  18.  
  19. 扫描范围(只看名字含 wetype / com.tencent.inputmethod 的目录):
  20.    /Library/Input Methods, ~/Library/Input Methods,
  21.    /Library/Application Support, ~/Library/Application Support,
  22.    ~/Library/Containers, ~/Library/Group Containers, ~/Library/Caches
  23. 凡含 "Ime trie header" 的文件都会尝试解析;能找到通过结构校验的 kind-440
  24. 屏蔽表(或文件名含 ssrc)的才算敏感词库,其余列在文件头的 skipped 里。
  25.  
  26. 格式说明(沿用 Windows 版对 libwxhld 的逆向结论):
  27.    [443 反向 trie][payload 子块链][32B meta]
  28.    443 SensitiveTrieDict           : 反向 trie,提供可枚举明文
  29.    440 StaticInterceptSensitiveDict: HashMapReader,只存 32 位 check
  30.    导出结果 = 443 中 check 命中 440 的词。440 里没有明文对应的条目
  31.    (例如在线增量更新只下发哈希)无法还原,只在块头列出 check 值。
  32. """
  33. import argparse
  34. import mmap
  35. import os
  36. import struct
  37. import sys
  38. import time
  39. from pathlib import Path
  40.  
  41. HOME = Path.home()
  42. TRIE_MAGIC = b"Ime trie header\x00"
  43. NAME_KEYS = ("wetype", "com.tencent.inputmethod")
  44. SCAN_PARENTS = [
  45.     Path("/Library/Input Methods"),
  46.     HOME / "Library/Input Methods",
  47.     Path("/Library/Application Support"),
  48.     HOME / "Library/Application Support",
  49.     HOME / "Library/Containers",
  50.     HOME / "Library/Group Containers",
  51.     HOME / "Library/Caches",
  52. ]
  53. MAX_FILE = 512 * 1024 * 1024
  54.  
  55. M32 = 0xFFFFFFFF
  56. M64 = 0xFFFFFFFFFFFFFFFF
  57. K0 = 0xC3A5C85C97CB3127
  58. K1 = 0xB492B66FBE98F273
  59. K2 = 0x9AE16A3B2F90404F
  60. KMUL = 0x9DDFEA08EB382D69
  61. CHECK_SEED = 0x1DBBA5
  62.  
  63.  
  64. def pretty(p):
  65.     """家目录替换成 ~,便于分享"""
  66.     p = str(p)
  67.     h = str(HOME)
  68.     return "~" + p[len(h):] if p == h or p.startswith(h + os.sep) else p
  69.  
  70.  
  71. # ============================================================================
  72. # 一、443 反向 trie(SensitiveTrieDict)
  73. # ============================================================================
  74. class Bits:
  75.     __slots__ = ("buf", "nbits", "pre")
  76.  
  77.     def __init__(self, buf, off, nbits):
  78.         self.buf = memoryview(buf)[off:]
  79.         self.nbits = nbits
  80.         nw = (nbits + 63) // 64
  81.         pre = [0] * (nw + 1)
  82.         for i in range(nw):
  83.             w = struct.unpack_from("<Q", self.buf, i * 8)[0]
  84.             pre[i + 1] = pre[i] + w.bit_count()
  85.         self.pre = pre
  86.  
  87.     def get(self, pos):
  88.         if pos < 0 or pos >= self.nbits:
  89.             return 0
  90.         return (self.buf[pos >> 3] >> (pos & 7)) & 1
  91.  
  92.     def rank1(self, pos):
  93.         if pos <= 0:
  94.             return 0
  95.         if pos > self.nbits:
  96.             pos = self.nbits
  97.         w = pos >> 6
  98.         cnt = self.pre[w]
  99.         rem = pos & 63
  100.         if rem:
  101.             word = struct.unpack_from("<Q", self.buf, w * 8)[0]
  102.             cnt += (word & ((1 << rem) - 1)).bit_count()
  103.         return cnt
  104.  
  105.     def select0(self, count):
  106.         if count <= 0:
  107.             return 0
  108.         lo, hi = 0, self.nbits
  109.         while lo < hi:
  110.             mid = (lo + hi) // 2
  111.             if mid - self.rank1(mid) < count:
  112.                 lo = mid + 1
  113.             else:
  114.                 hi = mid
  115.         return lo
  116.  
  117.  
  118. class Trie:
  119.     __slots__ = ("louds", "term", "aux", "labels", "E", "width", "mask",
  120.                  "F_off", "F_len", "u1072", "nested")
  121.  
  122.  
  123. def select1_0based(bv, k):
  124.     lo, hi = 0, bv.nbits
  125.     kk = k + 1
  126.     while lo < hi:
  127.         mid = (lo + hi) // 2
  128.         if bv.rank1(mid + 1) < kk:
  129.             lo = mid + 1
  130.         else:
  131.             hi = mid
  132.     return lo
  133.  
  134.  
  135. def load_trie(data):
  136.     """解析 ssrc 容器内的嵌套 trie,返回最外层 t0(t0.nested 为下一层)。
  137.  
  138.    主词库是 3 层;在线增量更新可能只有 1 层空 trie。
  139.    """
  140.     pos = data.find(TRIE_MAGIC)
  141.     if pos < 0:
  142.         raise ValueError("未找到 'Ime trie header'")
  143.     p = pos + 16
  144.     raw = []
  145.  
  146.     def u32():
  147.         nonlocal p
  148.         v = struct.unpack_from("<I", data, p)[0]
  149.         p += 4
  150.         return v
  151.  
  152.     def u64():
  153.         nonlocal p
  154.         v = struct.unpack_from("<Q", data, p)[0]
  155.         p += 8
  156.         return v
  157.  
  158.     def rd_bv():
  159.         nonlocal p
  160.         sz = u64()
  161.         off = p
  162.         p += 8 * (sz >> 3) + ((-sz) & 7)
  163.         nbits = u32()
  164.         ones = u32()
  165.         it = u64()
  166.         p += 12 * (it // 12) + ((-it) & 7)
  167.         a = u64()
  168.         p += 4 * (a >> 2) + ((-a) & 7)
  169.         b = u64()
  170.         p += 4 * (b >> 2) + ((-b) & 7)
  171.         return dict(off=off, nbits=nbits, ones=ones)
  172.  
  173.     def rd_blob():
  174.         nonlocal p
  175.         n = u64()
  176.         off = p
  177.         p += n + ((-n) & 7)
  178.         return off, n
  179.  
  180.     def rd_trie(depth):
  181.         nonlocal p
  182.         t = {}
  183.         t["louds"] = rd_bv()
  184.         t["term"] = rd_bv()
  185.         t["aux"] = rd_bv()
  186.         t["labels_off"], t["labels_len"] = rd_blob()
  187.         t["E_off"], t["E_len"] = rd_blob()
  188.         t["width"] = u32()
  189.         t["mask"] = u32()
  190.         u64()  # ext_cnt
  191.         t["F_off"], t["F_len"] = rd_blob()
  192.         rd_bv()  # sec3
  193.         t["nested"] = (rd_trie(depth + 1)
  194.                        if (t["aux"]["ones"] and t["F_len"] == 0 and depth < 4) else None)
  195.         S = u64()
  196.         p += 12 * (S // 12) + ((-S) & 7)
  197.         t["u1072"] = u32()
  198.         u32()  # pool_id
  199.         if p > len(data):
  200.             raise ValueError("trie 越界")
  201.         raw.append(t)
  202.         return t
  203.  
  204.     rd_trie(0)
  205.     built = []
  206.     for t in raw:  # 追加顺序: 最内层 -> 最外层
  207.         tr = Trie()
  208.         tr.louds = Bits(data, t["louds"]["off"], t["louds"]["nbits"])
  209.         tr.term = Bits(data, t["term"]["off"], t["term"]["nbits"]) if t["term"]["nbits"] else None
  210.         tr.aux = Bits(data, t["aux"]["off"], t["aux"]["nbits"])
  211.         tr.labels = data[t["labels_off"]:t["labels_off"] + t["labels_len"]]
  212.         tr.E = Bits(data, t["E_off"], t["E_len"] * 8)
  213.         tr.width = t["width"]
  214.         tr.mask = t["mask"]
  215.         tr.F_off = t["F_off"]
  216.         tr.F_len = t["F_len"]
  217.         tr.u1072 = t["u1072"]
  218.         tr.nested = built[-1] if t["nested"] else None
  219.         built.append(tr)
  220.     return built[-1], len(built)
  221.  
  222.  
  223. def handle16(tr, state):
  224.     r = tr.aux.rank1(state)
  225.     v = 0
  226.     for k in range(tr.width):
  227.         v |= tr.E.get(r * tr.width + k) << k
  228.     return ((v & tr.mask) << 8) | tr.labels[state]
  229.  
  230.  
  231. def children_states(tr, state):
  232.     fc = tr.louds.select0(state + 1)
  233.     out = []
  234.     while fc < tr.louds.nbits and tr.louds.get(fc):
  235.         out.append(tr.louds.rank1(fc + 1) - 1)
  236.         fc += 1
  237.     return out
  238.  
  239.  
  240. def parent_state(tr, state):
  241.     return select1_0based(tr.louds, state) - state - 1
  242.  
  243.  
  244. def expand_chain(tr, h, out, data, depth=0):
  245.     if depth > 64:
  246.         return
  247.     while True:
  248.         if tr.aux.get(h):
  249.             h16 = handle16(tr, h)
  250.             if tr.nested is not None:
  251.                 expand_chain(tr.nested, h16, out, data, depth + 1)
  252.             else:
  253.                 seg = h16
  254.                 while seg < tr.F_len and data[tr.F_off + seg] != 0:
  255.                     out.append(data[tr.F_off + seg])
  256.                     seg += 1
  257.         else:
  258.             out.append(tr.labels[h])
  259.         if h <= tr.u1072:
  260.             return
  261.         h = parent_state(tr, h)
  262.         if h < 0:
  263.             return
  264.  
  265.  
  266. def iter_words(t0, data):
  267.     t1 = t0.nested
  268.  
  269.     def step(state, path, depth):
  270.         if depth > 200:
  271.             return
  272.         for c in children_states(t0, state):
  273.             if c >= len(t0.labels):
  274.                 continue
  275.             if t0.aux.get(c) and t1 is not None:
  276.                 out = list(path)
  277.                 expand_chain(t1, handle16(t0, c), out, data)
  278.             else:
  279.                 out = path + [t0.labels[c]]
  280.             if t0.term is not None and t0.term.get(c):
  281.                 yield bytes(out)
  282.             yield from step(c, out, depth + 1)
  283.  
  284.     yield from step(0, [], 0)
  285.  
  286.  
  287. def decode_key(path):
  288.     """key 域 -> 原词: 每字节 -1 -> UTF-8 -> 字符串反转"""
  289.     return bytes((x - 1) & 0xFF for x in path).decode("utf-8")[::-1]
  290.  
  291.  
  292. # ============================================================================
  293. # 二、哈希族(CityHash64 / DefaultKeyCheckHash / DefaultKeyHash 5-12)
  294. # ============================================================================
  295. def rot(v, n):
  296.     v &= M64
  297.     return ((v >> n) | (v << (64 - n))) & M64
  298.  
  299.  
  300. def ror32(v, n):
  301.     v &= M32
  302.     return ((v >> n) | (v << (32 - n))) & M32
  303.  
  304.  
  305. def f32(b, i):
  306.     return struct.unpack_from("<I", b, i)[0]
  307.  
  308.  
  309. def f64(b, i):
  310.     return struct.unpack_from("<Q", b, i)[0]
  311.  
  312.  
  313. def bswap64(v):
  314.     return int.from_bytes((v & M64).to_bytes(8, "little"), "big")
  315.  
  316.  
  317. def shift_mix(v):
  318.     return (v ^ (v >> 47)) & M64
  319.  
  320.  
  321. def hash_len16(u, v, mul):
  322.     a = ((u ^ v) * mul) & M64
  323.     a ^= a >> 47
  324.     b = ((v ^ a) * mul) & M64
  325.     b ^= b >> 47
  326.     return (b * mul) & M64
  327.  
  328.  
  329. def city_0_16(d):
  330.     n = len(d)
  331.     if n >= 8:
  332.         mul = (K2 + 2 * n) & M64
  333.         a = (f64(d, 0) + K2) & M64
  334.         b = f64(d, n - 8)
  335.         c = (rot(b, 37) * mul + a) & M64
  336.         e = ((rot(a, 25) + b) * mul) & M64
  337.         return hash_len16(c, e, mul)
  338.     if n >= 4:
  339.         mul = (K2 + 2 * n) & M64
  340.         a = f32(d, 0)
  341.         return hash_len16((n + (a << 3)) & M64, f32(d, n - 4), mul)
  342.     if n > 0:
  343.         a, b, c = d[0], d[n >> 1], d[n - 1]
  344.         y = (a + (b << 8)) & M32
  345.         z = (n + (c << 2)) & M32
  346.         return (shift_mix(((y * K2) & M64) ^ ((z * K0) & M64)) * K2) & M64
  347.     return K2
  348.  
  349.  
  350. def city_17_32(d):
  351.     n = len(d)
  352.     mul = (K2 + 2 * n) & M64
  353.     a = (f64(d, 0) * K1) & M64
  354.     b = f64(d, 8)
  355.     c = (f64(d, n - 8) * mul) & M64
  356.     dd = (f64(d, n - 16) * K2) & M64
  357.     return hash_len16((rot((a + b) & M64, 43) + rot(c, 30) + dd) & M64,
  358.                       (a + rot((b + K2) & M64, 18) + c) & M64, mul)
  359.  
  360.  
  361. def city_33_64(d):
  362.     n = len(d)
  363.     mul = (K2 + 2 * n) & M64
  364.     a = (f64(d, 0) * K2) & M64
  365.     b = f64(d, 8)
  366.     c = f64(d, n - 24)
  367.     e0 = f64(d, n - 32)
  368.     e = (f64(d, 16) * K2) & M64
  369.     f = (f64(d, 24) * 9) & M64
  370.     g = f64(d, n - 8)
  371.     h = (f64(d, n - 16) * mul) & M64
  372.     u = (rot((a + g) & M64, 43) + ((rot(b, 30) + c) & M64) * 9) & M64
  373.     v = ((((a + g) & M64) ^ e0) + f + 1) & M64
  374.     w = (bswap64((((u + v) & M64) * mul) & M64) + h) & M64
  375.     x = (rot((e + f) & M64, 42) + c) & M64
  376.     y = (((bswap64((((v + w) & M64) * mul) & M64) + g) & M64) * mul) & M64
  377.     z = (e + f + c) & M64
  378.     a2 = (bswap64(((((x + z) & M64) * mul) + y) & M64) + b) & M64
  379.     b2 = (shift_mix(((((z + a2) & M64) * mul) + e0 + h) & M64) * mul) & M64
  380.     return (b2 + x) & M64
  381.  
  382.  
  383. def weak_hash32_seeds(w, x, y, z, a, b):
  384.     a = (a + w) & M64
  385.     b = rot((b + a + z) & M64, 21)
  386.     c = a
  387.     a = (a + x + y) & M64
  388.     b = (b + rot(a, 44)) & M64
  389.     return (a + z) & M64, (b + c) & M64
  390.  
  391.  
  392. def weak_hash32_seeds_at(d, off, a, b):
  393.     return weak_hash32_seeds(f64(d, off), f64(d, off + 8), f64(d, off + 16),
  394.                              f64(d, off + 24), a, b)
  395.  
  396.  
  397. def city_65_plus(d):
  398.     n = len(d)
  399.     x = f64(d, n - 40)
  400.     y = (f64(d, n - 16) + f64(d, n - 56)) & M64
  401.     z = hash_len16((f64(d, n - 48) + n) & M64, f64(d, n - 24), KMUL)
  402.     v0, v1 = weak_hash32_seeds_at(d, n - 64, n, z)
  403.     w0, w1 = weak_hash32_seeds_at(d, n - 32, (y + K1) & M64, x)
  404.     x = (x * K1 + f64(d, 0)) & M64
  405.     remaining = (n - 1) & ~63
  406.     pos = 0
  407.     while True:
  408.         x = (rot((x + y + v0 + f64(d, pos + 8)) & M64, 37) * K1) & M64
  409.         y = (rot((y + v1 + f64(d, pos + 48)) & M64, 42) * K1) & M64
  410.         x ^= w1
  411.         y = (y + v0 + f64(d, pos + 40)) & M64
  412.         z = (rot((z + w0) & M64, 33) * K1) & M64
  413.         v0, v1 = weak_hash32_seeds_at(d, pos, (v1 * K1) & M64, (x + w0) & M64)
  414.         w0, w1 = weak_hash32_seeds_at(d, pos + 32, (z + w1) & M64,
  415.                                       (y + f64(d, pos + 16)) & M64)
  416.         x, z = z, x
  417.         pos += 64
  418.         remaining -= 64
  419.         if remaining == 0:
  420.             break
  421.     return hash_len16(
  422.         (hash_len16(v0, w0, KMUL) + (shift_mix(y) * K1) + z) & M64,
  423.         (hash_len16(v1, w1, KMUL) + x) & M64, KMUL)
  424.  
  425.  
  426. def city64(d):
  427.     n = len(d)
  428.     if n <= 16:
  429.         return city_0_16(d)
  430.     if n <= 32:
  431.         return city_17_32(d)
  432.     if n <= 64:
  433.         return city_33_64(d)
  434.     return city_65_plus(d)
  435.  
  436.  
  437. def check_hash(word_bytes):
  438.     return hash_len16((city64(word_bytes) - K2) & M64, CHECK_SEED, KMUL) & M32
  439.  
  440.  
  441. def key_hash_5_12(buf):
  442.     n = len(buf)
  443.  
  444.     def b30(x, y):
  445.         t = ror32((x * 0xCC9E2D51) & M32, 17)
  446.         u = (y ^ ((t * 0x1B873593) & M32)) & M32
  447.         return (5 * ror32(u, 19) + 0xE6546B64) & M32
  448.  
  449.     h = b30(n + f32(buf, 0), 5 * n)
  450.     h = b30(5 * n + f32(buf, n - 4), h)
  451.     h = b30(f32(buf, (n >> 1) & 4) + 9, h)
  452.     v = h & M32
  453.     v ^= v >> 16
  454.     v = (v * 0x85EBCA6B) & M32
  455.     v ^= v >> 13
  456.     v = (v * 0xC2B2AE35) & M32
  457.     v ^= v >> 16
  458.     return v
  459.  
  460.  
  461. # ============================================================================
  462. # 三、440 HashMapReader(候选直接屏蔽表)
  463. # ============================================================================
  464. class Block:
  465.     """i32 0x80000001 | i32 count | u32 bucket_off[count] | 桶...
  466.  
  467.    桶: u32 n | u32 check[n] | u16 end[n] | blob
  468.    """
  469.  
  470.     def __init__(self, data, flag_off):
  471.         self.data = data
  472.         self.base = flag_off
  473.         flag = struct.unpack_from("<i", data, flag_off)[0]
  474.         if flag >= 0:
  475.             self.version = 0
  476.             self.count = flag
  477.             self.buckets_off = flag_off + 4
  478.         else:
  479.             self.version = flag & 0xFF
  480.             self.count = struct.unpack_from("<i", data, flag_off + 4)[0]
  481.             self.buckets_off = flag_off + 8
  482.  
  483.     def _off(self, h):
  484.         return struct.unpack_from("<I", self.data, self.buckets_off + 4 * h)[0]
  485.  
  486.     def bucket(self, h):
  487.         p = self.base + self._off(h)
  488.         return p, struct.unpack_from("<I", self.data, p)[0]
  489.  
  490.     def bucket_checks(self, h):
  491.         p, n = self.bucket(h)
  492.         return struct.unpack_from("<%dI" % n, self.data, p + 4) if n else ()
  493.  
  494.     def bucket_size(self, h):
  495.         p, n = self.bucket(h)
  496.         end = struct.unpack_from("<H", self.data, p + 4 + 4 * n + 2 * (n - 1))[0] if n else 0
  497.         return 4 + 6 * n + end
  498.  
  499.     def iter_checks(self):
  500.         for h in range(self.count):
  501.             yield from self.bucket_checks(h)
  502.  
  503.     def strict_ok(self):
  504.         """桶必须紧挨着桶数组、首尾相接地排布(比原 sanity 严格得多,
  505.        能把随机命中的 0x80000001 排除掉,因此可放宽 count 下限)"""
  506.         n = len(self.data)
  507.         expect = (self.buckets_off - self.base) + 4 * self.count
  508.         for h in range(self.count):
  509.             off = self._off(h)
  510.             if off != expect:
  511.                 return False
  512.             p = self.base + off
  513.             if p + 4 > n:
  514.                 return False
  515.             k = struct.unpack_from("<I", self.data, p)[0]
  516.             if k > 5000 or p + 4 + 6 * k > n:
  517.                 return False
  518.             expect = off + self.bucket_size(h)
  519.             if self.base + expect > n:
  520.                 return False
  521.         return True
  522.  
  523.  
  524. def find_440_regions(data):
  525.     out = []
  526.     pat = b"\x01\x00\x00\x80"
  527.     i = data.find(pat)
  528.     while i >= 0:
  529.         if i + 8 <= len(data):
  530.             count = struct.unpack_from("<i", data, i + 4)[0]
  531.             if 1 <= count <= 0x100000 and i + 8 + 4 * count <= len(data):
  532.                 try:
  533.                     blk = Block(data, i)
  534.                     if blk.version == 1 and blk.strict_ok():
  535.                         out.append(blk)
  536.                 except struct.error:
  537.                     pass
  538.         i = data.find(pat, i + 1)
  539.     return out
  540.  
  541.  
  542. PROBE = "鸡巴".encode("utf-8")
  543.  
  544.  
  545. def probe_hit(blk):
  546.     return check_hash(PROBE) in blk.bucket_checks(key_hash_5_12(PROBE) % blk.count)
  547.  
  548.  
  549. # ============================================================================
  550. # 四、扫描与分析
  551. # ============================================================================
  552. def scan_dirs(extra_dirs):
  553.     roots = []
  554.     for parent in SCAN_PARENTS:
  555.         try:
  556.             for child in parent.iterdir():
  557.                 low = child.name.lower()
  558.                 if any(k in low for k in NAME_KEYS):
  559.                     roots.append(child)
  560.         except (FileNotFoundError, PermissionError, NotADirectoryError):
  561.             continue
  562.     roots.extend(Path(d).expanduser() for d in extra_dirs)
  563.     return roots
  564.  
  565.  
  566. def iter_candidate_files(roots):
  567.     seen = set()
  568.     for root in roots:
  569.         walker = [(str(root), [], [root.name])] if root.is_file() else os.walk(root, onerror=lambda e: None)
  570.         for dp, _dn, fns in walker:
  571.             for fn in fns:
  572.                 p = os.path.join(dp, fn) if not root.is_file() else str(root)
  573.                 try:
  574.                     st = os.stat(p)
  575.                 except OSError:
  576.                     continue
  577.                 if not os.path.isfile(p) or st.st_size < 64 or st.st_size > MAX_FILE:
  578.                     continue
  579.                 key = (st.st_dev, st.st_ino)
  580.                 if key in seen:
  581.                     continue
  582.                 seen.add(key)
  583.                 try:
  584.                     with open(p, "rb") as f, mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
  585.                         if mm.find(TRIE_MAGIC) >= 0:
  586.                             yield p
  587.                 except (OSError, ValueError):
  588.                     continue
  589.  
  590.  
  591. def analyze(path):
  592.     """返回 dict;kind = 'sensitive' / 'skip'"""
  593.     data = open(path, "rb").read()
  594.     r = dict(path=path, size=len(data), words=set(), layers=0, regions=[],
  595.              checks=set(), hits=[], unresolved=[], note="")
  596.     try:
  597.         t0, r["layers"] = load_trie(data)
  598.         for p in iter_words(t0, data):
  599.             try:
  600.                 r["words"].add(decode_key(p))
  601.             except UnicodeDecodeError:
  602.                 pass
  603.     except Exception as e:  # noqa: BLE001
  604.         r["note"] = "trie 解析失败: %s" % e
  605.  
  606.     by_word = {w: check_hash(w.encode("utf-8")) for w in r["words"]}
  607.     word_checks = set(by_word.values())
  608.     for blk in find_440_regions(data):
  609.         checks = set(blk.iter_checks())
  610.         if not checks:
  611.             continue
  612.         if probe_hit(blk) or (checks & word_checks) or "ssrc" in os.path.basename(path).lower():
  613.             r["regions"].append(blk)
  614.             r["checks"] |= checks
  615.  
  616.     if r["regions"]:
  617.         r["kind"] = "sensitive"
  618.         r["hits"] = sorted(w for w, c in by_word.items() if c in r["checks"])
  619.         r["unresolved"] = sorted(r["checks"] - word_checks)
  620.     elif "ssrc" in os.path.basename(path).lower() and r["words"]:
  621.         r["kind"] = "sensitive"
  622.         r["hits"] = sorted(r["words"])
  623.         r["note"] = (r["note"] + "; " if r["note"] else "") + "未找到 440 表,输出 443 全量"
  624.     else:
  625.         r["kind"] = "skip"
  626.         if not r["note"]:
  627.             r["note"] = "无 440 屏蔽表(%d 个 trie 词,非敏感词库)" % len(r["words"])
  628.     return r
  629.  
  630.  
  631. def main():
  632.     ap = argparse.ArgumentParser(description="微信输入法(macOS) 敏感词库导出(多来源分块)")
  633.     ap.add_argument("-f", "--file", action="append", default=[],
  634.                     help="只处理指定文件(可多次);不给则自动扫描")
  635.     ap.add_argument("-d", "--dir", action="append", default=[],
  636.                     help="额外扫描的目录(可多次)")
  637.     ap.add_argument("-o", "--output", default="wetype_sensitive_words.txt",
  638.                     help="输出 txt(默认 ./wetype_sensitive_words.txt)")
  639.     ap.add_argument("--all-443", action="store_true",
  640.                     help="另存 <输出名>_all443.txt:每个文件 443 trie 全量词表(未经 440 过滤)")
  641.     ap.add_argument("--no-header", action="store_true", help="不写文件总头(块头仍保留)")
  642.     args = ap.parse_args()
  643.  
  644.     if args.file:
  645.         files = [str(Path(f).expanduser()) for f in args.file]
  646.         for f in files:
  647.             if not os.path.isfile(f):
  648.                 raise SystemExit("文件不存在: %s" % f)
  649.     else:
  650.         roots = scan_dirs(args.dir)
  651.         print("scan roots :")
  652.         for r in roots:
  653.             print("  %s" % pretty(r))
  654.         files = sorted(iter_candidate_files(roots))
  655.     if not files:
  656.         raise SystemExit("没有找到含 'Ime trie header' 的文件。微信输入法装了吗?")
  657.  
  658.     results = [analyze(f) for f in files]
  659.     sens = [r for r in results if r["kind"] == "sensitive"]
  660.     skipped = [r for r in results if r["kind"] == "skip"]
  661.  
  662.     # 跨文件补全: 某文件 440 里只有哈希的条目,用其它文件的 443 词再试一次
  663.     pool = {}
  664.     for r in results:
  665.         for w in r["words"]:
  666.             pool.setdefault(check_hash(w.encode("utf-8")), w)
  667.     for r in sens:
  668.         found = [pool[c] for c in r["unresolved"] if c in pool]
  669.         if found:
  670.             r["hits"] = sorted(set(r["hits"]) | set(found))
  671.             r["unresolved"] = [c for c in r["unresolved"] if c not in pool]
  672.             r["note"] = (r["note"] + "; " if r["note"] else "") + \
  673.                 "其中 %d 词借助其它文件的明文还原" % len(found)
  674.  
  675.     lines = []
  676.     if not args.no_header:
  677.         lines += [
  678.             "# 微信输入法敏感词库 (kind-440 StaticInterceptSensitiveDict, macOS)",
  679.             "# generated: %s" % time.strftime("%Y-%m-%d %H:%M:%S"),
  680.             "# sources  : %d" % len(sens),
  681.             "# total    : %d (去重后 %d)" % (sum(len(r["hits"]) for r in sens),
  682.                                           len(set().union(*[r["hits"] for r in sens])) if sens else 0),
  683.         ]
  684.         for r in skipped:
  685.             lines.append("# skipped  : %s  (%s)" % (pretty(r["path"]), r["note"]))
  686.         lines.append("")
  687.  
  688.     print("-" * 60)
  689.     for i, r in enumerate(sens, 1):
  690.         lines.append("# source%d: %s" % (i, pretty(r["path"])))
  691.         lines.append("# count%d : %d" % (i, len(r["hits"])))
  692.         if r["unresolved"]:
  693.             lines.append("# unresolved%d: %d 条仅有哈希、文件内无明文 -> %s" % (
  694.                 i, len(r["unresolved"]),
  695.                 " ".join("0x%08x" % c for c in r["unresolved"][:64])
  696.                 + (" ..." if len(r["unresolved"]) > 64 else "")))
  697.         if r["note"]:
  698.             lines.append("# note%d  : %s" % (i, r["note"]))
  699.         lines.extend(r["hits"])
  700.         lines.append("")
  701.         print("source%d    : %s" % (i, pretty(r["path"])))
  702.         print("  trie      : %d 层, %d 词" % (r["layers"], len(r["words"])))
  703.         print("  440       : %d 表, %d 条 check" % (len(r["regions"]), len(r["checks"])))
  704.         print("  hits      : %d   unresolved: %d" % (len(r["hits"]), len(r["unresolved"])))
  705.     for r in skipped:
  706.         print("skipped    : %s  (%s)" % (pretty(r["path"]), r["note"]))
  707.  
  708.     with open(args.output, "w", encoding="utf-8", newline="\n") as f:
  709.         f.write("\n".join(lines).rstrip("\n") + "\n")
  710.     print("written    : %s" % pretty(os.path.abspath(args.output)))
  711.  
  712.     if args.all_443:
  713.         all_out = os.path.splitext(args.output)[0] + "_all443.txt"
  714.         al = []
  715.         for i, r in enumerate([x for x in results if x["words"]], 1):
  716.             al += ["# source%d: %s" % (i, pretty(r["path"])),
  717.                    "# count%d : %d" % (i, len(r["words"]))]
  718.             al += sorted(r["words"]) + [""]
  719.         with open(all_out, "w", encoding="utf-8", newline="\n") as f:
  720.             f.write("\n".join(al).rstrip("\n") + "\n")
  721.         print("written    : %s" % pretty(os.path.abspath(all_out)))
  722.  
  723.  
  724. if __name__ == "__main__":
  725.     if sys.version_info < (3, 10):
  726.         raise SystemExit("需要 Python 3.10+(用到 int.bit_count)")
  727.     main()
  728.  
Add Comment
Please, Sign In to add comment