wx-favorites-report

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

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.

ara.so 开发的Skill — 属于Daily 2026 Skills合集。
这是一套端到端流程,通过Frida挂钩微信Mac客户端,提取PBKDF2衍生的加密密钥,解密
favorite.db
SQLCipher数据库,解析XML格式的收藏内容,并生成包含图表、词云和可筛选卡片浏览器的单文件交互式HTML报告。

Prerequisites

前置条件

  • 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 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

~/.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.app
Never run with
sudo
— doing so changes the data directory to
/var/root/…
and breaks DB path resolution.
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.js
:
javascript
// 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.js
javascript
// 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

步骤3 — 匹配密钥与
favorite.db

python
undefined
python
undefined

match_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?")
undefined
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): """定位第一个(或指定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运行时你是否打开了收藏标签页?")
undefined

Step 4 — Decrypt the Database

步骤4 — 解密数据库

python
undefined
python
undefined

decrypt_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.db

Step 5 — Parse Favorites

步骤5 — 解析收藏内容

WeChat 4.x uses a single table
fav_db_item
with XML content (not the 3.x
FavItems
/
FavDataItem
split):
python
undefined
微信4.x使用单表
fav_db_item
存储XML格式内容(不同于3.x版本的
FavItems
/
FavDataItem
拆分结构):
python
undefined

parse_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 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}")

```bash
python3 parse_favorites.py /tmp/favorite_decrypted.db /tmp/data.json
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: """从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 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"解析完成 {len(data)} 条内容 → {out}")

```bash
python3 parse_favorites.py /tmp/favorite_decrypted.db /tmp/data.json

Step 6 — Generate HTML Report

步骤6 — 生成HTML报告

bash
python3 generate_report.py --input /tmp/data.json --output /tmp/report.html
Serve locally (required —
file://
breaks ECharts event delegation):
bash
cd /tmp && python3 -m http.server 8765
open http://localhost:8765/report.html

bash
python3 generate_report.py --input /tmp/data.json --output /tmp/report.html
本地启动服务(必须如此 —
file://
协议会导致ECharts事件委托失效):
bash
cd /tmp && python3 -m http.server 8765
open http://localhost:8765/report.html

Key Configuration Reference

关键配置参考

ParameterValueNotes
SQLCipher version4WeChat 4.x
CipherAES-256-CBC
HMACHMAC-SHA512
KDF iterations256 000PBKDF2
Page size4 096 bytes
Reserve per page80 bytes64 HMAC + 16 IV
Salt locationbytes 0–15 of file
Table name (4.x)
fav_db_item
3.x used
FavItems
Article title field
<pagetitle>
Not
<title>

参数说明
SQLCipher版本4微信4.x版本使用
加密算法AES-256-CBC
HMAC算法HMAC-SHA512
KDF迭代次数256 000PBKDF2
页面大小4 096字节
每页预留空间80字节64字节HMAC + 16字节IV
盐值位置文件的0–15字节
表名(4.x)
fav_db_item
3.x版本使用
FavItems
文章标题字段
<pagetitle>
不是
<title>

Common Issues & Fixes

常见问题与解决方案

"Key not found in log"

"日志中未找到密钥"

  1. Confirm you opened the 收藏 tab while Frida was attached.
  2. Check log for any entries:
    wc -l /tmp/wechat_frida_keys.log
  3. Salt mismatch — re-read salt:
    xxd ~/…/favorite.db | head -2
    (bytes 16–31 after the ASCII header).
  1. 确认Frida运行时你已打开收藏标签页。
  2. 检查日志是否有内容:
    wc -l /tmp/wechat_frida_keys.log
  3. 盐值不匹配 — 重新读取盐值:
    xxd ~/…/favorite.db | head -2
    (ASCII头之后的16–31字节)。

"database disk image is malformed"

"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.
解密参数错误。请仔细检查
KDF_ITER=256000
PAGE_SIZE=4096
。如果微信已更新,参数可能已变更 — 可以尝试回退到
kdf_iter=64000
(SQLCipher 3默认值)。

"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.app

Report images broken

报告中图片无法显示

Thumbnail URLs are WeChat CDN links — they require an active network session. Add
onerror
handler using
&quot;
to avoid quote conflicts in inline HTML:
python
img_tag = f'<img src="{url}" onerror="this.style.display=&quot;none&quot;">'
缩略图链接是微信CDN地址 — 需要活跃的网络会话才能访问。可以添加
onerror
处理函数,使用
&quot;
避免内联HTML中的引号冲突:
python
img_tag = f'<img src="{url}" onerror="this.style.display=&quot;none&quot;">'

onclick not firing on
file://

file://
协议下onclick事件不触发

Use event delegation on a parent element instead of inline
onclick
:
javascript
document.getElementById("card-list").addEventListener("click", function(e) {
  var card = e.target.closest(".fav-card");
  if (card) showDetail(card.dataset.id);
});
使用父元素的事件委托替代内联
onclick
javascript
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.

重新复制并重新签名应用包,然后重新运行完整流程。PBKDF2挂钩针对系统库(
libcommonCrypto.dylib
),因此不受微信二进制变更影响,但必须重复重新签名步骤。

Report Features

报告功能

SectionChart type
Summary cardsStatic KPI tiles
Monthly trendECharts line + area
Type distributionECharts doughnut
Top 15 sourcesECharts horizontal bar
Activity heatmapECharts heatmap (weekday × hour)
Word cloudecharts-wordcloud
Tag cloudCSS flex tags
Favorites browserCard grid with type/tag filter + full-text search + pagination
Detail modalFull 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
    tagNames
    column stores comma-separated tag strings; empty tags are filtered client-side.
  • 图片/视频/文件二进制数据存储在微信加密CDN中 — 无法离线预览。
  • 密钥提取需要macOS + Frida;不支持Windows/Linux。
  • 每次微信更新后,必须重新签名桌面版应用。
  • tagNames
    列存储逗号分隔的标签字符串;空标签会在客户端过滤。