Trending-skills wx-favorites-report
End-to-end pipeline to extract, decrypt, and visualize WeChat Mac favorites from encrypted SQLite DB into an interactive HTML report.
git clone https://github.com/Aradotso/trending-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/Aradotso/trending-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/wx-favorites-report" ~/.claude/skills/aradotso-trending-skills-wx-favorites-report && rm -rf "$T"
skills/wx-favorites-report/SKILL.mdwx-favorites-report
Skill by ara.so — Daily 2026 Skills collection.
End-to-end pipeline that hooks into the WeChat Mac client via Frida, extracts PBKDF2-derived encryption keys, decrypts the
favorite.db SQLCipher database, parses XML-encoded favorites, and renders a single-file interactive HTML report with charts, word cloud, and filterable card browser.
Prerequisites
- macOS (Apple Silicon or Intel)
- WeChat Mac 4.x installed and logged in
- Python 3.9+
- Frida 17.x
pip3 install frida frida-tools pycryptodome
Project Layout
~/.claude/skills/wechat-favorites-viz/ ├── SKILL.md └── scripts/ ├── parse_favorites.py # SQLite/CSV/JSON → unified JSON ├── generate_report.py # JSON → single-file HTML └── demo_data.py # synthetic data for testing
Full Pipeline (Step-by-Step)
Step 1 — Strip Hardened Runtime from WeChat
The App Store build blocks Frida injection. Copy and re-sign without entitlements:
killall WeChat 2>/dev/null; sleep 2 cp -R /Applications/WeChat.app ~/Desktop/WeChat.app codesign --force --deep --sign - ~/Desktop/WeChat.app
Never run with
— doing so changes the data directory tosudoand breaks DB path resolution./var/root/…
Step 2 — Hook PBKDF2 with Frida
Save as
hook_wechat.js:
// hook_wechat.js — capture all CCKeyDerivationPBKDF calls var CCKeyDerivationPBKDF = Module.findExportByName( "libcommonCrypto.dylib", "CCKeyDerivationPBKDF" ); Interceptor.attach(CCKeyDerivationPBKDF, { onEnter: function (args) { // args[3] = password ptr, args[4] = password len // args[5] = salt ptr, args[6] = salt len // args[9] = iterations // args[10]= derived key ptr, args[11]= derived key len this.saltPtr = args[5]; this.saltLen = args[6].toInt32(); this.dkPtr = args[10]; this.dkLen = args[11].toInt32(); }, onLeave: function (_retval) { try { var salt = Memory.readByteArray(this.saltPtr, this.saltLen); var dk = Memory.readByteArray(this.dkPtr, this.dkLen); var entry = { salt: Array.from(new Uint8Array(salt)) .map(b => b.toString(16).padStart(2, "0")).join(""), key: Array.from(new Uint8Array(dk)) .map(b => b.toString(16).padStart(2, "0")).join(""), ts: Date.now() }; var line = JSON.stringify(entry) + "\n"; // Write to log via send() send(line); } catch (e) {} } });
Run the hook:
frida ~/Desktop/WeChat.app/Contents/MacOS/WeChat \ -l hook_wechat.js \ --runtime=v8 \ 2>/dev/null | tee /tmp/wechat_frida_keys.log & # WeChat will launch — log in, then open the 收藏 (Favorites) tab. # Wait ~60 seconds for all DB keys to be derived, then Ctrl+C.
Key insight:
is only opened when the user navigates to the Favorites tab. If you hook before opening Favorites, the key won't appear.favorite.db
Step 3 — Match Key to favorite.db
favorite.db# match_key.py import json, sqlite3, pathlib LOG = pathlib.Path("/tmp/wechat_frida_keys.log") DB = pathlib.Path.home() / ( "Library/Containers/com.tencent.xinWeChat/Data/Documents/" "xwechat_files" ) def find_db(wxid=None): """Locate favorite.db under the first (or named) wxid folder.""" root = DB candidates = sorted(root.glob("*/db_storage/favorite/favorite.db")) if not candidates: raise FileNotFoundError("favorite.db not found") if wxid: return next(p for p in candidates if wxid in str(p)) return candidates[0] def read_salt(db_path: pathlib.Path) -> bytes: """First 16 bytes after the 16-byte SQLCipher header = salt.""" with open(db_path, "rb") as f: f.read(16) # skip "SQLite format 3\x00" return f.read(16) # salt def match(db_path: pathlib.Path) -> str | None: salt_hex = read_salt(db_path).hex() for line in LOG.read_text().splitlines(): try: entry = json.loads(line) if entry["salt"] == salt_hex: return entry["key"] except Exception: continue return None if __name__ == "__main__": db = find_db() key = match(db) if key: print(f"enc_key (hex): {key}") print(f"db path : {db}") else: print("Key not found — did you open the Favorites tab while Frida was running?")
Step 4 — Decrypt the Database
# decrypt_db.py """ SQLCipher 4 parameters: cipher : AES-256-CBC hmac : HMAC-SHA512 kdf_iter : 256000 page_size : 4096 reserve : 80 (64 HMAC + 16 IV) """ import hashlib, hmac, struct, pathlib from Crypto.Cipher import AES PAGE_SIZE = 4096 RESERVE = 80 IV_SIZE = 16 HMAC_SIZE = 64 KDF_ITER = 256000 def decrypt_db(enc_path: pathlib.Path, key_hex: str, out_path: pathlib.Path): raw_key = bytes.fromhex(key_hex) data = enc_path.read_bytes() # SQLCipher stores salt in first 16 bytes of file salt = data[:16] # Derive page key and HMAC key page_key = hashlib.pbkdf2_hmac("sha512", raw_key, salt, KDF_ITER, dklen=32) hmac_key = hashlib.pbkdf2_hmac("sha512", page_key, salt, 1, dklen=32) out_pages = bytearray() # Page 1: skip 16-byte salt header pages = [data[16:PAGE_SIZE]] + [ data[i:i+PAGE_SIZE] for i in range(PAGE_SIZE, len(data), PAGE_SIZE) ] for page_num, page in enumerate(pages, start=1): content = page[:PAGE_SIZE - RESERVE] reserved = page[PAGE_SIZE - RESERVE:] iv = reserved[HMAC_SIZE:HMAC_SIZE + IV_SIZE] cipher = AES.new(page_key, AES.MODE_CBC, iv) plaintext = cipher.decrypt(content) if page_num == 1: # Restore SQLite header out_pages += b"SQLite format 3\x00" + plaintext[16:] else: out_pages += plaintext # Zero-pad to full page size out_pages += bytes(RESERVE) out_path.write_bytes(bytes(out_pages)) print(f"Decrypted → {out_path}") if __name__ == "__main__": import sys enc_path = pathlib.Path(sys.argv[1]) key_hex = sys.argv[2] out_path = pathlib.Path(sys.argv[3]) decrypt_db(enc_path, key_hex, out_path)
python3 decrypt_db.py \ ~/Library/Containers/.../favorite.db \ <32-byte-key-hex> \ /tmp/favorite_decrypted.db
Step 5 — Parse Favorites
WeChat 4.x uses a single table
fav_db_item with XML content (not the 3.x FavItems/FavDataItem split):
# parse_favorites.py (core logic excerpt) import sqlite3, json, re, pathlib from datetime import datetime from xml.etree import ElementTree as ET TYPE_MAP = { 1: "text", 2: "image", 3: "voice", 4: "video", 5: "playlist", 6: "location", 7: "attachment", 8: "article", 43: "video_channel", 49: "link", } def parse_xml_content(xml_str: str, fav_type: int) -> dict: """Extract title, desc, source, url from XML blob.""" result = {"title": "", "desc": "", "source": "", "url": ""} if not xml_str: return result try: root = ET.fromstring(xml_str) except ET.ParseError: return result def txt(tag): el = root.find(f".//{tag}") return el.text.strip() if el is not None and el.text else "" if fav_type == 8: # article — WeChat 4.x uses <pagetitle> result["title"] = txt("pagetitle") or txt("title") result["url"] = txt("url") result["source"] = txt("sourcename") or txt("fromnickname") result["desc"] = txt("desc") elif fav_type == 49: # link result["title"] = txt("title") result["url"] = txt("url") result["source"] = txt("sourcename") result["desc"] = txt("desc") elif fav_type in (3, 4, 43): # voice/video for item in root.findall(".//dataitem"): t = item.findtext("datatitle", "").strip() if t: result["title"] = t break result["source"] = txt("fromnickname") else: # text, image, etc. result["title"] = txt("title") or txt("pagetitle") result["desc"] = txt("desc") or txt("content") result["source"] = txt("fromnickname") return result def parse(db_path: pathlib.Path) -> list[dict]: con = sqlite3.connect(db_path) con.row_factory = sqlite3.Row rows = con.execute( "SELECT localId, favLocalId, type, createTime, updateTime, " " xmlBuf, tagNames " "FROM fav_db_item " "ORDER BY createTime" ).fetchall() items = [] for row in rows: parsed = parse_xml_content(row["xmlBuf"] or "", row["type"]) items.append({ "id": row["localId"], "type": TYPE_MAP.get(row["type"], f"unknown_{row['type']}"), "created_at": datetime.utcfromtimestamp(row["createTime"]).isoformat(), "updated_at": datetime.utcfromtimestamp(row["updateTime"]).isoformat(), "title": parsed["title"], "desc": parsed["desc"], "source": parsed["source"], "url": parsed["url"], "tags": [t.strip() for t in (row["tagNames"] or "").split(",") if t.strip()], }) con.close() return items if __name__ == "__main__": import sys db = pathlib.Path(sys.argv[1]) out = pathlib.Path(sys.argv[2]) data = parse(db) out.write_text(json.dumps(data, ensure_ascii=False, indent=2)) print(f"Parsed {len(data)} items → {out}")
python3 parse_favorites.py /tmp/favorite_decrypted.db /tmp/data.json
Step 6 — Generate HTML Report
python3 generate_report.py --input /tmp/data.json --output /tmp/report.html
Serve locally (required —
file:// breaks ECharts event delegation):
cd /tmp && python3 -m http.server 8765 open http://localhost:8765/report.html
Key Configuration Reference
| Parameter | Value | Notes |
|---|---|---|
| SQLCipher version | 4 | WeChat 4.x |
| Cipher | AES-256-CBC | — |
| HMAC | HMAC-SHA512 | — |
| KDF iterations | 256 000 | PBKDF2 |
| Page size | 4 096 bytes | — |
| Reserve per page | 80 bytes | 64 HMAC + 16 IV |
| Salt location | bytes 0–15 of file | — |
| Table name (4.x) | | 3.x used |
| Article title field | | Not |
Common Issues & Fixes
"Key not found in log"
- Confirm you opened the 收藏 tab while Frida was attached.
- Check log for any entries:
wc -l /tmp/wechat_frida_keys.log - Salt mismatch — re-read salt:
(bytes 16–31 after the ASCII header).xxd ~/…/favorite.db | head -2
"database disk image is malformed"
Decryption parameters are wrong. Double-check
KDF_ITER=256000 and PAGE_SIZE=4096. If WeChat updated, parameters may have changed — try kdf_iter=64000 (SQLCipher 3 default) as a fallback.
"codesign: No identity found"
Use
- (ad-hoc signing), not a certificate name:
codesign --force --deep --sign - ~/Desktop/WeChat.app
Report images broken
Thumbnail URLs are WeChat CDN links — they require an active network session. Add
onerror handler using " to avoid quote conflicts in inline HTML:
img_tag = f'<img src="{url}" onerror="this.style.display="none"">'
onclick not firing on file://
file://Use event delegation on a parent element instead of inline
onclick:
document.getElementById("card-list").addEventListener("click", function(e) { var card = e.target.closest(".fav-card"); if (card) showDetail(card.dataset.id); });
WeChat updated — hook stopped working
Re-copy and re-sign the app bundle, then re-run the full pipeline. The PBKDF2 hook targets a system library (
libcommonCrypto.dylib) so it is resilient to WeChat binary changes, but the re-signing step must be repeated.
Report Features
| Section | Chart type |
|---|---|
| Summary cards | Static KPI tiles |
| Monthly trend | ECharts line + area |
| Type distribution | ECharts doughnut |
| Top 15 sources | ECharts horizontal bar |
| Activity heatmap | ECharts heatmap (weekday × hour) |
| Word cloud | echarts-wordcloud |
| Tag cloud | CSS flex tags |
| Favorites browser | Card grid with type/tag filter + full-text search + pagination |
| Detail modal | Full content, URL, source, tags |
Known Limitations
- Image/video/file binary blobs are stored in WeChat's encrypted CDN — not previewable offline.
- Key extraction requires macOS + Frida; no Windows/Linux support.
- After each WeChat update, the Desktop copy must be re-signed.
- The
column stores comma-separated tag strings; empty tags are filtered client-side.tagNames