Building a Local WeChat Archive on macOS

13 Oct 2026·
赵吉忱
赵吉忱
· 22 min read
blog Case Studies

A mate wanted to keep some WeChat voice messages as ordinary audio files and asked me to help. It sounded like a small conversion job: find the files, turn them into MP3s, and give them sensible names. By 24 October 2026, I had extended the investigation to whole conversations, including the media stored alongside them.

Finding the recordings took more work than converting them. Some promising .silk_temp files weren’t usable SILK audio. What eventually worked was reading VoiceInfo.voice_data from WeChat’s local media databases and matching those bytes to the chat records.

Once that worked, I wanted to keep more of the conversation around the recordings: who said what, the pictures they sent, the replies and the occasional inexplicable sticker. A directory of MP3s was useful, but it left most of the conversation behind.

I wanted an archive I could read later without WeChat, back up as ordinary files and use selectively for AI analysis. That meant preserving readable transcripts, structured records and local attachments, together with a clear account of anything missing.

The examples below explain the database queries, message matching, media recovery and archive checks. Two self-contained scripts cover individual stages: converting an already-extracted recording and checking an archive in the format described here. A third creates an invented message and attachment for trying the checks. Together, they illustrate the approach; they do not extract a complete conversation from WeChat.

Table of Contents

Deciding what an archive should contain

I first had to distinguish a message record from the content it referred to. The record can exist on the Mac without its attachment. A thumbnail can survive after the full image has disappeared. An encrypted file can be present while its key is unavailable. Those are different situations, and the archive needs to keep them distinguishable.

I settled on three outputs that serve different purposes:

OutputPurpose
MarkdownReadable conversations, divided into months, with relative links to media.
JSONLOne normalised message record per line, including identifiers, sender information, structured content, attachment references and warnings.
AttachmentsPreserved source bytes and readable derivatives, with hashes and information about how they were recovered.

An optional offline HTML reader adds playback controls for audio and video supported by the browser. It can be regenerated from a completed archive without opening WeChat. Markdown remains convenient for reading and selecting passages; JSONL retains details that would make the transcript cumbersome.

The directory layout looks roughly like this:

archive/
  README.md
  manifest.json
  conversations/
    conversation--identifier/
      index.md
      2026-10.md
      messages.jsonl
  attachments/
    <sha256>.<extension>

Large months split into smaller parts at message boundaries. Attachments use content hashes in their filenames, so identical bytes can be reused across messages. The original display filename stays in the metadata where available.

There is also an optional raw/ directory for original message payloads. JSONL is a normalised representation, so it cannot preserve every byte or undocumented field on its own. Keeping those payloads gives me more to work with if the decoder improves later. They can also contain sensitive metadata or credentials, so I keep them out of sharing copies. Even with this option, the archive isn’t a complete database backup.

Reading the databases without changing them

I started with the selected account’s database storage, keeping account discovery separate from reading the records. All directory names and identifiers in the examples below are illustrative; none identifies a real account.

Contacts, messages and voice recordings live in separate databases. Message and media databases are split into numbered files, or shards. Resource, hardlink and emoticon indexes can help connect message references to local files when those indexes are present and readable.

The databases use SQLCipher. I used Python’s ctypes to load Homebrew’s SQLCipher library, opened databases with SQLITE_OPEN_READONLY, supplied the key in memory and enabled query_only. A read from sqlite_master verifies that the key works with that database. The database-reading and key-capture approach draws on acmerfight’s MIT-licensed work on macOS WeChat.

Reading live databases also means accounting for SQLite’s write-ahead log. Recent messages may still be in the WAL, so copying only a .db file can leave them out. I read the databases in place and let SQLite handle the sidecar files, without requesting a checkpoint or modifying the databases.

Each read from a message shard uses a transaction. That keeps the queries within that read consistent, but it doesn’t freeze all the databases and media files at the same instant. I recorded per-shard read boundaries in the manifest and added checks for certain source changes. I treat the result as a personal archive, not a forensic snapshot.

Key acquisition is a separate operation. Valid saved keys can be reused without restarting WeChat. For key capture, I first close WeChat, then run a temporary, re-signed app copy under LLDB on native Apple Silicon macOS and validate candidate keys against database-page HMACs and read-only SQLCipher queries before saving them. This leaves the installed app and SIP unchanged. The temporary app still uses the real account’s data, however, so WeChat itself may change caches or login state during that process. I remove the temporary copy only after its process has stopped; a forced termination can leave files that need checking afterwards.

These database queries and the capture approach are specific to the WeChat 4.1.15 layout I examined on macOS. Future client versions may require changes. I kept key capture separate from routine export, which neither launches WeChat nor fetches missing media from the network.

Keeping message identity straight

A readable transcript is only useful if the messages belong to the right conversation and the right people.

In this schema, a conversation table is named Msg_ followed by the MD5 digest of the conversation’s username identifier. Discovery combines contact information with identifiers found in message shards, rather than assuming every surviving conversation still has a current contact entry.

Each shard has its own Name2Id mapping. A numeric sender ID from one database cannot safely be looked up in another. Group messages make that especially important: the conversation identifies the group, while the sender identifies a participant.

For example, this builds the voice-message query for a conversation:

import hashlib

def voice_message_query(conversation):
    table = "Msg_" + hashlib.md5(conversation.encode("utf-8")).hexdigest()
    return f"""
        SELECT m.local_id, m.server_id, m.create_time,
               n.user_name AS sender
        FROM "{table}" AS m
        LEFT JOIN Name2Id AS n ON n.rowid = m.real_sender_id
        WHERE (m.local_type & 4294967295) = 34
    """

The table name is constructed from a fixed prefix and a hexadecimal digest. I check that it exists before querying each shard. The low 32 bits of local_type identify the base message type; 34 is voice in this layout. For a full conversation, I read all message types and decode their payloads separately.

Date filters use an explicit timezone. An inclusive end date becomes midnight at the start of the following day, with a strict upper bound, so messages near midnight stay in the intended day. Ordering uses the timestamp and sequence information, with source shard and local row ID as tie-breakers.

I also record where each displayed name came from. A current contact name makes the transcript easier to read, but it doesn’t establish which name was displayed when an old message was sent. Unknown senders and uncertain sent/received directions remain labelled unknown.

Within an account’s archive, deduplication uses the conversation identifier together with a non-zero server message ID. When that ID is absent, the source shard and local row ID provide a fallback. A simplified version of the identity calculation is:

import hashlib

def message_identity(conversation, server_id, source_database, local_id):
    server_id = str(server_id or "0")
    source_id = (
        server_id
        if server_id != "0"
        else f"{source_database}:{local_id}"
    )
    value = f"{conversation}:{source_id}"
    return hashlib.sha256(value.encode("utf-8")).hexdigest()[:24]

A shared identity isn’t enough to discard a duplicate. I compare timestamps, ordering information, message type, sender and original payload fields before merging records. Equivalent records can share a list of source locations. Conflicting versions are retained and marked, rather than choosing whichever shard happened to be read first.

Turning message payloads into something readable

Plain text is the easy case once its bytes have been decoded. Message payloads can also be compressed, and other message types may contain XML, quotes or forwarded bundles with their own nested content.

The decoding layer first handles the bytes, including supported Zstandard compression, then interprets the message type. It gives the Markdown and HTML renderers a consistent record format. Recognised decoding failures become labelled outcomes rather than empty messages; other errors, including some resource-limit failures, can stop the export and leave it marked incomplete.

Replies and forwards need particular care. A forwarded bundle may contain several images and files, each with its own media hints. Applying a child’s digest to the parent, or borrowing the bundle’s server ID for every child, can attach the wrong file to otherwise plausible text. Nested records therefore get their own identities and attachment lookups.

Some quoted media can be resolved through a unique original message in the same conversation and selected export scope. When that evidence is missing or ambiguous, I leave the gap visible rather than search unrelated conversations for a convenient substitute.

There are limits here. A payment card, call record or interactive message may have a readable representation without reproducing everything WeChat displayed or allowed someone to do. Unknown types and partially understood fields stay labelled accordingly. Limits on payload size, nesting depth and item counts bound the work spent decompressing and parsing messages.

Finding media, then checking what was found

Media recovery has two separate questions: does this file belong to this message, and what can I faithfully recover from it?

For voice, the match uses both the conversation and server message ID. The audio row lives in a media shard with its own name mapping. A zero server ID cannot establish the match. For the conversation archive, I collapse byte-identical audio candidates but mark distinct payloads as ambiguous. An unreadable media shard is recorded as an access gap, so the lookup cannot be mistaken for a complete one.

The corresponding media lookup has this shape:

SELECT hex(v.voice_data) AS body
FROM VoiceInfo AS v
JOIN Name2Id AS n ON n.rowid = v.chat_name_id
WHERE n.user_name = :conversation AND v.svr_id = :server_id;

With a SQL interface that supports bound parameters, the conversation identifier and server ID are supplied separately from the query. My small C-API reader uses sqlite3_exec, which has no parameter-binding interface, so I quote string literals by doubling single quotes and validate the numeric ID before constructing SQL. The hex() call matters because that reader’s callback receives text: bytes.fromhex() recovers the BLOB without treating binary audio as a NUL-terminated string. Ordinary Python sqlite3 cannot open these encrypted databases merely by supplying a key.

The conversion path is SILK → PCM → MP3. pilk decodes at 24 kHz; FFmpeg reads mono, signed 16-bit little-endian PCM and encodes the MP3. I then decode the whole MP3, check its codec and compare its duration with the PCM, allowing a difference of up to 0.25 seconds. Original SILK is retained alongside the derivative in chat archives, since MP3 is lossy.

Trying the voice conversion separately

Copy the following script into convert_voice.py in a working directory alongside an already-extracted SILK file. It doesn’t open databases or acquire keys. It retains the original, converts the audio and writes a small manifest containing durations and hashes.

#!/usr/bin/env python3
"""Convert one already-extracted SILK recording; never opens WeChat or its databases.

Python 3.10+, pilk 0.2.4, ffmpeg and ffprobe are required.
Usage: python convert_voice.py input.silk new-output-directory
A failed attempt may leave a partial directory. Retry into a new directory.
"""
import argparse
import hashlib
import json
import math
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile


def convert(source, destination):
    source, destination = Path(source), Path(destination)
    if source.stat().st_size > 64 * 1024 * 1024:
        raise ValueError("Recording exceeds this example's 64 MiB input limit")
    data = source.read_bytes()
    if not data.startswith((b"#!SILK_V3", b"\x02#!SILK_V3")):
        raise ValueError("Not recognised SILK audio")
    ffmpeg, ffprobe = shutil.which("ffmpeg"), shutil.which("ffprobe")
    if not ffmpeg or not ffprobe:
        raise RuntimeError("FFmpeg and ffprobe are required")
    import pilk  # Check the selected Python environment before creating output.

    previous = os.umask(0o077)
    try:
        destination.mkdir(mode=0o700, exist_ok=False)
        original = destination / "original.silk"
        original.write_bytes(data)
        with tempfile.TemporaryDirectory(prefix="voice-conversion-") as folder:
            pcm = Path(folder) / "voice.pcm"
            subprocess.run([
                sys.executable, "-c",
                "import pilk,sys; pilk.decode(sys.argv[1],sys.argv[2],pcm_rate=24000)",
                str(original), str(pcm),
            ], check=True, capture_output=True, timeout=60)
            size = pcm.stat().st_size
            if not size or size % 2:
                raise ValueError("Empty or incomplete 16-bit PCM")
            decoded_seconds = size / 48000
            mp3 = destination / "voice.mp3"
            subprocess.run([
                ffmpeg, "-v", "error", "-nostdin", "-n", "-f", "s16le",
                "-ar", "24000", "-ac", "1", "-i", str(pcm),
                "-c:a", "libmp3lame", "-q:a", "2", str(mp3),
            ], check=True, capture_output=True, timeout=120)
        subprocess.run([
            ffmpeg, "-v", "error", "-xerror", "-nostdin", "-i", str(mp3),
            "-map", "0:a:0", "-f", "null", "-",
        ], check=True, capture_output=True, timeout=120)
        probe = json.loads(subprocess.check_output([
            ffprobe, "-v", "error", "-show_entries",
            "format=duration:stream=codec_name", "-of", "json", str(mp3),
        ], stderr=subprocess.PIPE, timeout=60))
        duration = float(probe["format"]["duration"])
        if (not probe["streams"] or probe["streams"][0]["codec_name"] != "mp3"
                or not math.isfinite(duration) or abs(duration - decoded_seconds) > 0.25):
            raise ValueError("MP3 codec or duration check failed")
        result = {
            "status": "completed", "decoded_seconds": decoded_seconds,
            "mp3_seconds": duration,
            "silk_sha256": hashlib.sha256(data).hexdigest(),
            "mp3_sha256": hashlib.sha256(mp3.read_bytes()).hexdigest(),
        }
        with (destination / "manifest.json").open("x", encoding="utf-8") as stream:
            json.dump(result, stream, indent=2)
        return result
    finally:
        os.umask(previous)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("source", type=Path)
    parser.add_argument("destination", type=Path)
    args = parser.parse_args()
    try:
        convert(args.source, args.destination)
    except (OSError, ValueError, RuntimeError, ImportError, KeyError, IndexError,
            subprocess.SubprocessError):
        print("Conversion failed. Check input, dependencies and destination; retain any partial output.", file=sys.stderr)
        return 1
    print("Conversion and checks completed; original SILK retained.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

It requires Python 3.10 or later, pilk 0.2.4 and FFmpeg/ffprobe. Run these commands from the same working directory:

python3 -m venv .venv
.venv/bin/python -m pip --require-virtualenv install 'pilk==0.2.4'
brew install ffmpeg
.venv/bin/python convert_voice.py input.silk converted-voice

These are illustrative relative filenames. The output directory must be new. The final FFmpeg pass uses -xerror so that reported decoding errors fail the check. The script limits input to 64 MiB and bounds decoder runtime; a failed attempt may leave partial output, so a retry needs another directory. Its small conversion manifest is separate from the conversation-archive manifest described below.

Recovering other attachments

Images, videos, files and stickers require other evidence: content digests, account-local indexes and specific cache layouts. A plausible filename alone doesn’t establish that a file is the original. Some known cache paths provide weaker evidence, which is labelled explicitly. Each attachment records how it was found and how faithfully the saved result represents the source, even when a preview can be displayed.

ResultWhat the archive needs to say
Verified original or supported decoded mediaWhich source and checks established the result.
Thumbnail onlyThe thumbnail is available; the original hasn’t been recovered.
Derived previewThe source is preserved, but the preview has stated fidelity limits.
Ambiguous alternativesMore than one associated candidate remains unresolved.
Unavailable or undecodable contentWhether the obstacle was missing bytes, a key, an index, an unsupported format or a decoding failure.

That last distinction matters. An unreadable index cannot prove that a file is physically absent. Likewise, a viewable alternative doesn’t prove that it is the original attachment.

Pictures introduced another layer. Some were ordinary image files; others used XOR encoding or encrypted DAT wrappers. DAT image keys are separate from SQLCipher database keys, so opening a message database doesn’t establish that its images can be decoded. I retain the encoded source and validate the decoded result against the available digest and format evidence. Unsupported layouts or missing image keys remain explicit gaps.

For videos, I preserve the local container and use ffprobe to inspect its streams and duration where available, rather than transcode it just to make a copy. Ordinary file attachments are preserved without being executed. Their original display names remain metadata; the stored extension follows the detected format, which can differ from the original filename. Browser playback support is a separate question from whether the bytes were preserved.

Taking a closer look at stickers

Ordinary GIF and WebP stickers can retain their original bytes and animation. Other local assets use encrypted wrappers or WXGF containers, and a successfully decrypted container still needs a decoder.

For the WXGF layouts I could decode, I generated PNG or animated WebP previews. Some multipart stickers combine HEVC colour data with JBIG masks, so producing an image requires assembling both colour and transparency. Frame timing and repetition also need attention: getting one frame to display says little about whether the animation is right.

I check the layout before decoding, retain the timing information, and limit frame counts, pixel totals and decoder runtime. Source bytes and decrypted containers remain available beside the preview. Unrecognised structures stay preserved with a reason instead of being treated as a successful partial decode.

Even with these checks, WXGF output remains labelled preview_only. The work covers observed layouts and behaviour from the installed client; it doesn’t establish equivalence with every WeChat version’s rendering. That qualification belongs with the attachment, where a later reader can still find it.

Making completion measurable

An export can finish reading the selected history while leaving some attachments unavailable. It can also produce files successfully and then be damaged by an incomplete copy. Those need different checks.

I separated three questions:

  1. Did the export finish reading the selected history? The manifest records states such as in_progress, completed_with_warnings, failed and interrupted.
  2. Are the saved records and media internally consistent? Verification checks JSONL record and file checksums, referenced attachment and raw-payload hashes, paths and counts. It doesn’t verify the rendered Markdown or HTML pages.
  3. How much referenced content was recovered? Coverage groups originals and supported decoded media together, and separately reports previews or variants, thumbnails, incomplete previews, ambiguous results, preserved-only bytes and unavailable media.

Coverage counts message references separately from distinct attachment contents. If the same sticker appears twenty times, one saved file may satisfy twenty references. Counting files alone would make that result hard to interpret. Nested records also need their own counts rather than being mistaken for additional top-level messages.

The JSONL writer gives each record a checksum before writing it. This simplified function shows what is covered:

import hashlib
import json

def record_digest(record):
    content = {
        key: value
        for key, value in record.items()
        if key != "integrity_sha256"
    }
    encoded = json.dumps(
        content,
        sort_keys=True,
        ensure_ascii=False,
        separators=(",", ":"),
    ).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()

Sorting keys and using fixed separators makes the calculation independent of dictionary insertion order and JSON formatting. The checksum field itself is excluded. Each completed line is flushed and synced to disk; the manifest is updated separately through a temporary file and atomic replacement.

These hashes detect accidental changes and support recovery checks. They don’t authenticate an archive against someone who rewrites both the data and the hashes, and they cannot prove that all phone history reached the Mac.

The following archive checker uses only Python’s standard library. Copy it into verify_archive.py. It reads the completed layout described here, including nested attachment references, and rejects missing checksums, mismatched counts, referenced paths outside the archive and symlinks along those paths. It does not modify files, inspect unreferenced files or attempt to repair an interrupted archive.

#!/usr/bin/env python3
"""Read-only checks for the completed example archive layout described in the article.

Python 3.10+, standard library only. Does not open WeChat, load keys or write files.
Usage: python verify_archive.py archive-directory
This checks internal consistency, not authenticity or completeness of source history.
"""
import argparse
import hashlib
import json
from pathlib import Path, PurePosixPath
import re
import sys


def archive_path(root, relative):
    if not isinstance(relative, str) or not relative or "\\" in relative:
        raise ValueError("Invalid relative archive path")
    part = PurePosixPath(relative)
    if part.is_absolute() or ".." in part.parts:
        raise ValueError("Path leaves the archive")
    path = root
    for component in part.parts:
        path = path / component
        if path.is_symlink():
            raise ValueError("Symlinks are not accepted")
    if not path.resolve().is_relative_to(root.resolve()):
        raise ValueError("Path leaves the archive")
    return path


def file_digest(path):
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for block in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def record_digest(row):
    value = {key: item for key, item in row.items() if key != "integrity_sha256"}
    return hashlib.sha256(json.dumps(
        value, sort_keys=True, ensure_ascii=False, separators=(",", ":"),
    ).encode("utf-8")).hexdigest()


def nested_records(row, depth=0):
    if depth > 8:
        raise ValueError("Unexpected nesting depth")
    yield row
    structured = row.get("structured") or {}
    children = list(structured.get("items") or [])
    if isinstance(structured.get("reply"), dict):
        children.append(structured["reply"])
    for child in children:
        yield from nested_records(child, depth + 1)


def verify(directory):
    root = Path(directory).resolve(strict=True)
    checked = set()

    def check(relative, expected, size=None):
        if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-f]{64}", expected):
            raise ValueError("Missing or invalid checksum")
        path = archive_path(root, relative)
        if not path.is_file() or file_digest(path) != expected:
            raise ValueError("Missing file or checksum mismatch")
        if size is not None and path.stat().st_size != size:
            raise ValueError("Size mismatch")
        checked.add(relative)
        return path

    manifest = json.loads(archive_path(root, "manifest.json").read_text(encoding="utf-8"))
    if manifest["schema_version"] != 1 or manifest["status"] not in ("completed", "completed_with_warnings"):
        raise ValueError("This example checks completed version-1 archives only")
    count = 0
    identities = set()
    for chat in manifest["conversations"]:
        path = check(chat["directory"] + "/messages.jsonl", chat["jsonl_sha256"])
        local_count = 0
        with path.open("r", encoding="utf-8") as stream:
            for line in stream:
                if not line.endswith("\n"):
                    raise ValueError("Unfinished JSONL record")
                row = json.loads(line)
                if record_digest(row) != row["integrity_sha256"]:
                    raise ValueError("Record checksum mismatch")
                identity = (row["conversation_id"], row["id"])
                if identity in identities:
                    raise ValueError("Duplicate message identity")
                identities.add(identity)
                for item in nested_records(row):
                    for asset in item.get("attachments", []):
                        if asset.get("path"):
                            size = asset["bytes"]
                            if not isinstance(size, int) or isinstance(size, bool) or size < 0:
                                raise ValueError("Missing or invalid attachment size")
                            check(asset["path"], asset["sha256"], size)
                for raw in set((row.get("raw_fields") or {}).values()):
                    check(raw, PurePosixPath(raw).stem)
                local_count += 1
        if local_count != chat["record_count"]:
            raise ValueError("Conversation count mismatch")
        count += local_count
    if count != manifest["record_count"]:
        raise ValueError("Archive count mismatch")
    return {"records": count, "files_checked": len(checked)}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("archive", type=Path)
    args = parser.parse_args()
    try:
        result = verify(args.archive)
    except (OSError, ValueError, KeyError, TypeError, AttributeError, RecursionError):
        print("Archive check failed: incomplete structure, unsafe path, or inconsistent data.", file=sys.stderr)
        return 1
    print(json.dumps(result))
    print("Internal checks passed; missing source content may still be recorded in the archive.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Trying the checks with invented data

Copy this small script into make_example.py, beside verify_archive.py. It creates one invented message and a text attachment in a new example-archive directory. It refuses to overwrite an existing directory.

#!/usr/bin/env python3
"""Create an entirely invented archive for the checker; refuse an existing folder."""
import hashlib
import json
import os
from pathlib import Path

from verify_archive import record_digest


def main():
    previous = os.umask(0o077)
    try:
        root = Path("example-archive")
        root.mkdir(mode=0o700, exist_ok=False)
        folder = root / "conversations" / "example"
        folder.mkdir(parents=True, mode=0o700)
        (root / "attachments").mkdir(mode=0o700)

        data = b"An invented attachment for checking an archive.\n"
        digest = hashlib.sha256(data).hexdigest()
        relative = "attachments/" + digest + ".txt"
        (root / relative).write_bytes(data)
        record = {
            "id": "example-message",
            "conversation_id": "example-conversation",
            "text": "An invented message with a text attachment.",
            "attachments": [{
                "path": relative,
                "sha256": digest,
                "bytes": len(data),
            }],
        }
        record["integrity_sha256"] = record_digest(record)
        lines = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8")
        (folder / "messages.jsonl").write_bytes(lines)
        manifest = {
            "schema_version": 1,
            "status": "completed",
            "record_count": 1,
            "conversations": [{
                "directory": "conversations/example",
                "record_count": 1,
                "jsonl_sha256": hashlib.sha256(lines).hexdigest(),
            }],
        }
        (root / "manifest.json").write_text(
            json.dumps(manifest, indent=2), encoding="utf-8",
        )
    finally:
        os.umask(previous)


if __name__ == "__main__":
    main()

Run both scripts from that working directory:

python3 make_example.py
python3 verify_archive.py example-archive

The checker should report one record and two checked files: the JSONL file and the attachment. Edit the text attachment and run the checker again to see the integrity check fail.

The checker expects a versioned manifest.json with completion status and record counts, plus each conversation’s relative directory and JSONL checksum. Each JSONL record carries its own checksum; saved attachment references carry their relative path, size and SHA-256. Optional raw-payload filenames contain their SHA-256. The example generates these fields and their checksums. A successful check confirms the listed hashes and counts, even if the records describe missing source content. It is not a complete schema validator; run it on a finished local copy that is not being modified during the check.

I check the results in that order: export, verify, inspect coverage and then read the archive. Finishing the export is only the first check; it can still leave gaps in the recovered content.

Preserving earlier exports

A later export may contain less history than an earlier one. Messages can disappear from the source, and local media caches can change. Replacing an old archive wholesale would risk losing material I’d already recovered.

I therefore write subsequent exports to a new directory, keeping the old archive intact. Before combining old and new records, I check the earlier archive’s integrity and require the same account, conversation selection, date filters, timezone and raw-payload setting. Earlier records and media are retained, including conflicting versions.

Interrupted exports use the same principle. Complete records with valid checksums can be recovered even if the manifest’s counts lag behind. An incomplete final line stays in the old archive; it isn’t treated as a complete message.

Improving a preview later is a narrower task: once a completed archive has passed verification, I can generate new derivatives from WXGF containers it already holds. It cannot retrieve an absent original or invent a missing key. Keeping these two operations separate makes it clear when access to the original account is still needed.

Reading and sharing selected history

The archive is useful on its own, but I also wanted to select parts of it for analysis. Monthly Markdown makes that manageable without feeding an entire account’s history into another tool.

For sharing, I create a separate copy containing text and selected metadata, with consistent pseudonyms for senders and conversations within that copy. I leave out dedicated source-identifier fields and raw payloads, and include attachments only when I deliberately select them. Creating the copy does not upload anything.

Pseudonyms only address part of the problem. Names and identifiers can still appear in message text, timestamps can reveal routines, and retained location details, images or speech can identify someone directly. I still need to review the selected material. A relative media link in Markdown also doesn’t give an AI tool access to that file. OCR and speech transcription would be separate steps; neither is part of this process.

Keys and archives use private filesystem permissions, but those permissions aren’t encryption. The whole archive, including attachments and manifests, belongs in the same encrypted storage and backup arrangements as other personal files.

What the checks can establish

I used synthetic databases, message payloads and media to test cases such as shard-local sender mappings, conflicting duplicates, missing attachments, nested records, interrupted writes and archive tampering. App operations during key refresh are mocked in those tests. This makes awkward cases reproducible without using private conversations as test fixtures.

Those tests don’t establish support for every account or future client version. Database schemas can change, unfamiliar payloads can remain partial, and a correct decoder cannot recover bytes that were never stored locally. This process also provides no way to import the archive back into WeChat.

The investigation went much further than the original MP3 request. Most of the extra work went into deciding what a recovered file actually meant, keeping it attached to the right message and recording the cases where the evidence ran out. I now get conversations I can read outside WeChat, together with enough detail to see where the archive is incomplete. That makes it much easier to decide what to keep, what to check and what may be worth trying again later.