| name | wx-favorites-report |
| description | End-to-end pipeline to extract, decrypt, and visualize WeChat Mac favorites from encrypted SQLite DB into an interactive HTML report. |
| triggers | ["ๅพฎไฟกๆถ่ๅฏ่งๅ","visualize wechat favorites","extract wechat favorites","generate wechat favorites report","decrypt wechat database","wechat favorites html report","parse wechat favorites db","wechat favorites visualization"] |
wx-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 sudo โ doing so changes the data directory to /var/root/โฆ and breaks DB path resolution.
Step 2 โ Hook PBKDF2 with Frida
Save as hook_wechat.js:
var CCKeyDerivationPBKDF = Module.findExportByName(
"libcommonCrypto.dylib",
"CCKeyDerivationPBKDF"
);
Interceptor.attach(CCKeyDerivationPBKDF, {
onEnter: function (args) {
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.);
entry = {
: .( (salt))
.( b.().(, )).(),
: .( (dk))
.( b.().(, )).(),
: .()
};
line = .(entry) + ;
(line);
} (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 &
Key insight: favorite.db is only opened when the user navigates to the Favorites tab. If you hook before opening Favorites, the key won't appear.
Step 3 โ Match Key to favorite.db
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)
return f.read(16)
def match(db_path: pathlib.Path) -> str | None:
salt_hex = read_salt(db_path).hex()
for line LOG.read_text().splitlines():
:
entry = json.loads(line)
entry[] == salt_hex:
entry[]
Exception:
__name__ == :
db = find_db()
key = (db)
key:
()
()
:
()
Step 4 โ Decrypt the Database
"""
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()
salt = data[:16]
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()
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:
out_pages += b"SQLite format 3\x00" + plaintext[:]
:
out_pages += plaintext
out_pages += (RESERVE)
out_path.write_bytes((out_pages))
()
__name__ == :
sys
enc_path = pathlib.Path(sys.argv[])
key_hex = sys.argv[]
out_path = pathlib.Path(sys.argv[])
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):
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".//")
el.text.strip() el el.text
fav_type == :
result[] = txt() txt()
result[] = txt()
result[] = txt() txt()
result[] = txt()
fav_type == :
result[] = txt()
result[] = txt()
result[] = txt()
result[] = txt()
fav_type (, , ):
item root.findall():
t = item.findtext(, ).strip()
t:
result[] = t
result[] = txt()
:
result[] = txt() txt()
result[] = txt() txt()
result[] = txt()
result
() -> []:
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
rows = con.execute(
).fetchall()
items = []
row rows:
parsed = parse_xml_content(row[] , row[])
items.append({
: row[],
: TYPE_MAP.get(row[], ),
: datetime.utcfromtimestamp(row[]).isoformat(),
: datetime.utcfromtimestamp(row[]).isoformat(),
: parsed[],
: parsed[],
: parsed[],
: parsed[],
: [t.strip() t (row[] ).split() t.strip()],
})
con.close()
items
__name__ == :
sys
db = pathlib.Path(sys.argv[])
out = pathlib.Path(sys.argv[])
data = parse(db)
out.write_text(json.dumps(data, ensure_ascii=, indent=))
()
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) | fav_db_item | 3.x used FavItems |
| Article title field | <pagetitle> | Not <title> |
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:
xxd ~/โฆ/favorite.db | head -2 (bytes 16โ31 after the ASCII header).
"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://
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
tagNames column stores comma-separated tag strings; empty tags are filtered client-side.