wx-favorites-report
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinesewx-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 SQLCipher database, parses XML-encoded favorites, and renders a single-file interactive HTML report with charts, word cloud, and filterable card browser.
favorite.db由 ara.so 开发的Skill — 属于Daily 2026 Skills合集。
这是一套端到端流程,通过Frida挂钩微信Mac客户端,提取PBKDF2衍生的加密密钥,解密 SQLCipher数据库,解析XML格式的收藏内容,并生成包含图表、词云和可筛选卡片浏览器的单文件交互式HTML报告。
favorite.dbPrerequisites
前置条件
- macOS (Apple Silicon or Intel)
- WeChat Mac 4.x installed and logged in
- Python 3.9+
- Frida 17.x
bash
pip3 install frida frida-tools pycryptodome- macOS(Apple Silicon 或 Intel 芯片)
- 已安装并登录微信Mac 4.x版本
- Python 3.9+
- Frida 17.x
bash
pip3 install frida frida-tools pycryptodomeProject 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~/.claude/skills/wechat-favorites-viz/
├── SKILL.md
└── scripts/
├── parse_favorites.py # SQLite/CSV/JSON → 统一JSON格式
├── generate_report.py # JSON → 单文件HTML报告
└── demo_data.py # 用于测试的合成数据Full Pipeline (Step-by-Step)
完整流程(分步指南)
Step 1 — Strip Hardened Runtime from WeChat
步骤1 — 移除微信的Hardened Runtime保护
The App Store build blocks Frida injection. Copy and re-sign without entitlements:
bash
killall WeChat 2>/dev/null; sleep 2
cp -R /Applications/WeChat.app ~/Desktop/WeChat.app
codesign --force --deep --sign - ~/Desktop/WeChat.appNever run with— doing so changes the data directory tosudoand breaks DB path resolution./var/root/…
App Store版本的微信会阻止Frida注入。请复制应用并重新签名,移除权限限制:
bash
killall WeChat 2>/dev/null; sleep 2
cp -R /Applications/WeChat.app ~/Desktop/WeChat.app
codesign --force --deep --sign - ~/Desktop/WeChat.app切勿使用运行 — 否则数据目录会变为sudo,导致数据库路径解析失败。/var/root/…
Step 2 — Hook PBKDF2 with Frida
步骤2 — 用Frida挂钩PBKDF2函数
Save as :
hook_wechat.jsjavascript
// 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:
bash
frida ~/Desktop/WeChat.app/Contents/MacOS/WeChat \
-l hook_wechat.js \
--runtime=v8 \
2>/dev/null | tee /tmp/wechat_frida_keys.log &将以下代码保存为:
hook_wechat.jsjavascript
// hook_wechat.js — 捕获所有CCKeyDerivationPBKDF调用
var CCKeyDerivationPBKDF = Module.findExportByName(
"libcommonCrypto.dylib",
"CCKeyDerivationPBKDF"
);
Interceptor.attach(CCKeyDerivationPBKDF, {
onEnter: function (args) {
// args[3] = 密码指针, args[4] = 密码长度
// args[5] = 盐指针, args[6] = 盐长度
// args[9] = 迭代次数
// args[10]= 衍生密钥指针, args[11]= 衍生密钥长度
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";
// 通过send()写入日志
send(line);
} catch (e) {}
}
});运行挂钩脚本:
bash
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.
等待约60秒让所有数据库密钥生成完成,然后按Ctrl+C停止。
> **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.
> **关键提示:** `favorite.db`仅在用户打开收藏标签页时才会被打开。如果在打开收藏前就挂钩,将无法获取密钥。Step 3 — Match Key to favorite.db
favorite.db步骤3 — 匹配密钥与favorite.db
favorite.dbpython
undefinedpython
undefinedmatch_key.py
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?")
undefinedimport 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):
"""定位第一个(或指定wxid的)文件夹下的favorite.db。"""
root = DB
candidates = sorted(root.glob("*/db_storage/favorite/favorite.db"))
if not candidates:
raise FileNotFoundError("未找到favorite.db")
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:
"""16字节SQLCipher头之后的前16字节 = 盐值。"""
with open(db_path, "rb") as f:
f.read(16) # 跳过"SQLite format 3\x00"头
return f.read(16) # 盐值
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 (十六进制): {key}")
print(f"数据库路径 : {db}")
else:
print("未找到密钥 — Frida运行时你是否打开了收藏标签页?")
undefinedStep 4 — Decrypt the Database
步骤4 — 解密数据库
python
undefinedpython
undefineddecrypt_db.py
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)
```bash
python3 decrypt_db.py \
~/Library/Containers/.../favorite.db \
<32-byte-key-hex> \
/tmp/favorite_decrypted.db"""
SQLCipher 4 参数:
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将盐值存储在文件的前16字节
salt = data[:16]
# 生成分页密钥和HMAC密钥
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()
# 第一页:跳过16字节盐值头
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:
# 恢复SQLite头
out_pages += b"SQLite format 3\x00" + plaintext[16:]
else:
out_pages += plaintext
# 补零至完整页大小
out_pages += bytes(RESERVE)
out_path.write_bytes(bytes(out_pages))
print(f"解密完成 → {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)
```bash
python3 decrypt_db.py \
~/Library/Containers/.../favorite.db \
<32字节密钥十六进制> \
/tmp/favorite_decrypted.dbStep 5 — Parse Favorites
步骤5 — 解析收藏内容
WeChat 4.x uses a single table with XML content (not the 3.x / split):
fav_db_itemFavItemsFavDataItempython
undefined微信4.x使用单表存储XML格式内容(不同于3.x版本的/拆分结构):
fav_db_itemFavItemsFavDataItempython
undefinedparse_favorites.py (core logic excerpt)
parse_favorites.py (核心逻辑节选)
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 resultdef 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 itemsif 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}")
```bash
python3 parse_favorites.py /tmp/favorite_decrypted.db /tmp/data.jsonimport 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:
"""从XML数据中提取标题、描述、来源、链接。"""
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: # 文章 — 微信4.x使用<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: # 链接
result["title"] = txt("title")
result["url"] = txt("url")
result["source"] = txt("sourcename")
result["desc"] = txt("desc")
elif fav_type in (3, 4, 43): # 语音/视频
for item in root.findall(".//dataitem"):
t = item.findtext("datatitle", "").strip()
if t:
result["title"] = t
break
result["source"] = txt("fromnickname")
else: # 文本、图片等
result["title"] = txt("title") or txt("pagetitle")
result["desc"] = txt("desc") or txt("content")
result["source"] = txt("fromnickname")
return resultdef 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 itemsif 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"解析完成 {len(data)} 条内容 → {out}")
```bash
python3 parse_favorites.py /tmp/favorite_decrypted.db /tmp/data.jsonStep 6 — Generate HTML Report
步骤6 — 生成HTML报告
bash
python3 generate_report.py --input /tmp/data.json --output /tmp/report.htmlServe locally (required — breaks ECharts event delegation):
file://bash
cd /tmp && python3 -m http.server 8765
open http://localhost:8765/report.htmlbash
python3 generate_report.py --input /tmp/data.json --output /tmp/report.html本地启动服务(必须如此 — 协议会导致ECharts事件委托失效):
file://bash
cd /tmp && python3 -m http.server 8765
open http://localhost:8765/report.htmlKey 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 |
| 参数 | 值 | 说明 |
|---|---|---|
| SQLCipher版本 | 4 | 微信4.x版本使用 |
| 加密算法 | AES-256-CBC | — |
| HMAC算法 | HMAC-SHA512 | — |
| KDF迭代次数 | 256 000 | PBKDF2 |
| 页面大小 | 4 096字节 | — |
| 每页预留空间 | 80字节 | 64字节HMAC + 16字节IV |
| 盐值位置 | 文件的0–15字节 | — |
| 表名(4.x) | | 3.x版本使用 |
| 文章标题字段 | | 不是 |
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
- 确认Frida运行时你已打开收藏标签页。
- 检查日志是否有内容:
wc -l /tmp/wechat_frida_keys.log - 盐值不匹配 — 重新读取盐值:(ASCII头之后的16–31字节)。
xxd ~/…/favorite.db | head -2
"database disk image is malformed"
"database disk image is malformed"
Decryption parameters are wrong. Double-check and . If WeChat updated, parameters may have changed — try (SQLCipher 3 default) as a fallback.
KDF_ITER=256000PAGE_SIZE=4096kdf_iter=64000解密参数错误。请仔细检查和。如果微信已更新,参数可能已变更 — 可以尝试回退到(SQLCipher 3默认值)。
KDF_ITER=256000PAGE_SIZE=4096kdf_iter=64000"codesign: No identity found"
"codesign: No identity found"
Use (ad-hoc signing), not a certificate name:
-bash
codesign --force --deep --sign - ~/Desktop/WeChat.app使用进行临时签名,而非证书名称:
-bash
codesign --force --deep --sign - ~/Desktop/WeChat.appReport images broken
报告中图片无法显示
Thumbnail URLs are WeChat CDN links — they require an active network session. Add handler using to avoid quote conflicts in inline HTML:
onerror"python
img_tag = f'<img src="{url}" onerror="this.style.display="none"">'缩略图链接是微信CDN地址 — 需要活跃的网络会话才能访问。可以添加处理函数,使用避免内联HTML中的引号冲突:
onerror"python
img_tag = f'<img src="{url}" onerror="this.style.display="none"">'onclick not firing on file://
file://file://
协议下onclick事件不触发
file://Use event delegation on a parent element instead of inline :
onclickjavascript
document.getElementById("card-list").addEventListener("click", function(e) {
var card = e.target.closest(".fav-card");
if (card) showDetail(card.dataset.id);
});使用父元素的事件委托替代内联:
onclickjavascript
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 () so it is resilient to WeChat binary changes, but the re-signing step must be repeated.
libcommonCrypto.dylib重新复制并重新签名应用包,然后重新运行完整流程。PBKDF2挂钩针对系统库(),因此不受微信二进制变更影响,但必须重复重新签名步骤。
libcommonCrypto.dylibReport 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 |
| 板块 | 图表类型 |
|---|---|
| 概览卡片 | 静态KPI卡片 |
| 月度趋势 | ECharts折线+面积图 |
| 类型分布 | ECharts环形图 |
| Top 15来源 | ECharts横向柱状图 |
| 活跃度热力图 | ECharts热力图(星期×小时) |
| 词云 | echarts-wordcloud |
| 标签云 | CSS弹性布局标签 |
| 收藏浏览器 | 带类型/标签筛选+全文搜索+分页的卡片网格 |
| 详情弹窗 | 完整内容、链接、来源、标签 |
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
- 图片/视频/文件二进制数据存储在微信加密CDN中 — 无法离线预览。
- 密钥提取需要macOS + Frida;不支持Windows/Linux。
- 每次微信更新后,必须重新签名桌面版应用。
- 列存储逗号分隔的标签字符串;空标签会在客户端过滤。
tagNames