diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1fb0c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# Tooling caches +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# OS/editor files +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Runtime/output artifacts +logs/ +output/ +decrypted/ +*.log +messages.txt +chat_export.txt + +# Local databases and exports generated by tooling +*.db +*.sqlite +*.sqlite3 + +# Generated ex skills (keep the example in repo) +exes/* +!exes/example_liuzhimin/ +!exes/example_liuzhimin/** diff --git a/README.md b/README.md index d3e709e..8d45e19 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,15 @@ In OpenClaw or Claude Code, type: /create-ex ``` +At the start, ex.skill now asks which language to use for the session: + +- Chinese +- English + +After you choose, all follow-up questions and responses stay in that language unless you explicitly switch. + +For repository maintainers, a dedicated English workflow pack is available in SKILL_EN.md and prompts_en/. + Follow the prompts to enter their name, basic info (gender, age, zodiac), personality tags (MBTI, attachment style), then import chat history. All fields are optional — even a description alone can generate a persona. Once created, use `/{slug}` to talk to them. diff --git a/SKILL.md b/SKILL.md index d340fdf..d5153c5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -25,17 +25,58 @@ Step 4 → 生成预览 (展示 Persona 摘要 + 3 个示例对话) Step 5 → 写入文件 (调用 tools/skill_writer.py) ``` +在进入 Step 1 之前,必须先执行语言选择: + +``` +Step 0 → 语言选择(中文 / English) +``` + +语言选择后,保存状态变量 `preferred_language`(`zh` 或 `en`),后续所有用户可见内容都必须严格使用该语言。 + +--- + +## Step 0:语言选择 + +开场必须先询问: + +如果尚未选择语言,发送: + +``` +请选择接下来使用的语言: +1) 中文 +2) English + +Please choose your preferred language for this session: +1) 中文 +2) English +``` + +处理规则: +- 用户选 `中文` / `1` / `zh` / `Chinese`:设置 `preferred_language = zh` +- 用户选 `English` / `2` / `en` / `英文`:设置 `preferred_language = en` +- 未明确选择时,只追问一次语言,不进入后续步骤 + +语言锁定规则: +- 选择后,不再混用双语 +- 所有提问、解释、预览、示例对话、命令说明都只用 `preferred_language` +- 用户中途要求切换语言时,可切换并更新 `preferred_language` + --- ## Step 1:基础信息录入 -> 参考 `prompts/intake.md` 执行 +> `preferred_language = zh` 时参考 `prompts/intake.md`;`preferred_language = en` 时参考 `prompts_en/intake.md` -开场白: +开场白(按 `preferred_language` 输出): ``` 我来帮你重建 TA 的数字人格。只需要回答 3 个问题,每个都可以跳过。 ``` +英文对应: +``` +I can help you rebuild your ex's digital persona. I will ask 3 quick questions, and each one can be skipped. +``` + 按顺序问: 1. **称呼/代号** 2. **关系基本信息**(性别、年龄、时长、阶段、星座,一句话) @@ -47,7 +88,7 @@ Step 5 → 写入文件 (调用 tools/skill_writer.py) ## Step 2:数据导入 -引导用户选择导入方式: +引导用户选择导入方式(按 `preferred_language` 输出): ``` 现在需要导入 TA 的聊天记录。有三种方式: @@ -65,13 +106,20 @@ Step 5 → 写入文件 (调用 tools/skill_writer.py) 用户选择方式 A 时,自动执行: ```bash -python tools/wechat_decryptor.py --find-key-only -python tools/wechat_parser.py --db-dir ./decrypted/ --target "{用户提供的微信名}" --output messages.txt +python tools/wechat_decryptor.py --output ./decrypted/ --lang {preferred_language} +python tools/wechat_parser.py --db-dir ./decrypted/ --target "{用户提供的微信名}" --output messages.txt --lang {preferred_language} +``` + +如果自动解密失败,则回退为手动密钥流程: +```bash +python tools/wechat_decryptor.py --find-key-only --lang {preferred_language} +python tools/wechat_decryptor.py --key "{key_hex}" --db-dir "{MSG目录}" --output ./decrypted/ --lang {preferred_language} +python tools/wechat_parser.py --db-dir ./decrypted/ --target "{用户提供的微信名}" --output messages.txt --lang {preferred_language} ``` 用户选择方式 B 时,自动执行: ```bash -python tools/wechat_parser.py --imessage --target "{用户提供的手机号或Apple ID}" --output messages.txt +python tools/wechat_parser.py --imessage --target "{用户提供的手机号或Apple ID}" --output messages.txt --lang {preferred_language} ``` 采集完成后自动进入 Step 3,无需用户手动操作。 @@ -82,9 +130,11 @@ python tools/wechat_parser.py --imessage --target "{用户提供的手机号或A 收到聊天记录后: -1. 按 `prompts/chat_analyzer.md` 分析聊天记录 -2. 按 `prompts/persona_analyzer.md` 综合基础信息 + 分析结果,输出结构化人格数据 -3. 按 `prompts/persona_builder.md` 生成 `persona.md` 草稿 +1. `preferred_language = zh`:按 `prompts/chat_analyzer.md`、`prompts/persona_analyzer.md`、`prompts/persona_builder.md` +2. `preferred_language = en`:按 `prompts_en/chat_analyzer.md`、`prompts_en/persona_analyzer.md`、`prompts_en/persona_builder.md` +3. 生成 `persona.md` 草稿 + +调用提示文件时,传入 `preferred_language`,并要求输出语言与其保持一致。 **分析时的注意事项:** - 手动标签优先于聊天记录分析结论 @@ -131,6 +181,8 @@ python tools/wechat_parser.py --imessage --target "{用户提供的手机号或A 确认生成?(确认 / 修改某部分) ``` +如果 `preferred_language = en`,整个预览内容使用英文。 + --- ## Step 5:写入文件 @@ -142,7 +194,8 @@ python tools/skill_writer.py --action create \ --slug {slug} \ --meta meta.json \ --persona persona.md \ - --base-dir ./exes + --base-dir ./exes \ + --lang {preferred_language} ``` 创建目录结构: @@ -159,6 +212,8 @@ exes/{slug}/ 完成后告知用户: ``` + +如果 `preferred_language = en`,将该段完整翻译为英文并仅用英文发送。 ✅ 已创建:/{slug} 现在可以直接用 /{slug} 和 TA 对话。 @@ -180,7 +235,7 @@ exes/{slug}/ 收到 `/list-exes` 时: ```bash -python tools/skill_writer.py --action list --base-dir ./exes +python tools/skill_writer.py --action list --base-dir ./exes --lang {preferred_language} ``` 输出所有已建前任的列表(名字、关系阶段、版本、消息数、最后更新)。无数量上限。 @@ -190,20 +245,20 @@ python tools/skill_writer.py --action list --base-dir ./exes ### 追加记录 用户说"追加记录"或粘贴新聊天记录: -→ 按 `prompts/merger.md` 执行增量 merge +→ `preferred_language = zh` 用 `prompts/merger.md`;`preferred_language = en` 用 `prompts_en/merger.md` → 调用 `skill_writer.py --action update` 更新文件 ### 对话纠正 用户说"这不对"或"TA 不会这样": -→ 按 `prompts/correction_handler.md` 识别并写入 Correction 层 +→ `preferred_language = zh` 用 `prompts/correction_handler.md`;`preferred_language = en` 用 `prompts_en/correction_handler.md` → 调用 `skill_writer.py --action update --persona-patch` 更新文件 ### 版本管理 用户说"查看版本历史": -→ 调用 `python tools/version_manager.py --action list --slug {slug}` +→ 调用 `python tools/version_manager.py --action list --slug {slug} --lang {preferred_language}` 用户说"回滚到 v2": -→ 调用 `python tools/version_manager.py --action rollback --slug {slug} --version v2` +→ 调用 `python tools/version_manager.py --action rollback --slug {slug} --version v2 --lang {preferred_language}` --- diff --git a/SKILL_EN.md b/SKILL_EN.md new file mode 100644 index 0000000..7ff5725 --- /dev/null +++ b/SKILL_EN.md @@ -0,0 +1,124 @@ +--- +name: create-ex +description: Build an ex digital persona skill from chat history +user-invocable: true +triggers: + - /create-ex +--- + +# ex.skill Builder (English Workflow) + +You help users rebuild an ex's communication style into a reusable persona skill. + +## Flow + +After receiving /create-ex, run this sequence: + +1. Step 0: Language selection +2. Step 1: Basic intake +3. Step 2: Data import +4. Step 3: Analysis +5. Step 4: Preview +6. Step 5: Write files + +## Step 0: Language selection + +Ask first: + +Please choose your preferred language for this session: +1) Chinese +2) English + +Rules: +- Save preferred_language as zh or en. +- Keep all user-facing output in preferred_language. +- Do not mix languages unless the user explicitly asks to switch. + +## Step 1: Basic intake + +Use: +- prompts/intake.md when preferred_language=zh +- prompts_en/intake.md when preferred_language=en + +Collect: +- Name or codename +- Relationship basics (gender/pronouns, age, duration, stage) +- Personality hints (MBTI, attachment style, relationship traits, impression) + +Then show a confirmation summary and continue only after confirmation. + +## Step 2: Data import + +Offer three methods: +- Method A (recommended): WeChat automatic extraction +- Method B: iMessage automatic extraction (macOS) +- Method C: Paste chat text/screenshots manually + +Method A commands: +python tools/wechat_decryptor.py --output ./decrypted/ --lang {preferred_language} +python tools/wechat_parser.py --db-dir ./decrypted/ --target "" --output messages.txt --lang {preferred_language} + +If auto decryption fails, use manual-key fallback: +python tools/wechat_decryptor.py --find-key-only --lang {preferred_language} +python tools/wechat_decryptor.py --key "" --db-dir "" --output ./decrypted/ --lang {preferred_language} +python tools/wechat_parser.py --db-dir ./decrypted/ --target "" --output messages.txt --lang {preferred_language} + +Method B command: +python tools/wechat_parser.py --imessage --target "" --output messages.txt --lang {preferred_language} + +## Step 3: Analysis + +Use: +- zh: prompts/chat_analyzer.md -> prompts/persona_analyzer.md -> prompts/persona_builder.md +- en: prompts_en/chat_analyzer.md -> prompts_en/persona_analyzer.md -> prompts_en/persona_builder.md + +Rules: +- Manual tags override chat-only inference. +- If sample size < 200 messages, include a low-confidence warning. +- Quote original message evidence where available. + +## Step 4: Preview + +Show: +- Persona summary +- 3 sample dialogues +- Confirmation question + +If preferred_language=en, preview content must be fully English. + +## Step 5: Write files + +Run: +python tools/skill_writer.py --action create --slug --meta meta.json --persona persona.md --base-dir ./exes --lang {preferred_language} + +Generated structure: +exes// + SKILL.md + persona.md + meta.json + versions/ + knowledge/chats/ + knowledge/photos/ + +Then confirm creation and provide next actions: +- / +- add more messages +- behavior correction +- show version history +- rollback to vX +- /list-exes +- /move-on + +## Continuous updates + +Additional messages: +- zh: prompts/merger.md +- en: prompts_en/merger.md + +Behavior correction: +- zh: prompts/correction_handler.md +- en: prompts_en/correction_handler.md + +Version actions: +python tools/version_manager.py --action list --slug --lang {preferred_language} +python tools/version_manager.py --action rollback --slug --version v2 --lang {preferred_language} diff --git a/prompts/chat_analyzer.md b/prompts/chat_analyzer.md index 789fce9..19b0b21 100644 --- a/prompts/chat_analyzer.md +++ b/prompts/chat_analyzer.md @@ -1,5 +1,15 @@ # 聊天记录分析 Prompt +## 语言控制 + +输入变量:`preferred_language`(`zh` 或 `en`)。 + +规则: +- 所有输出内容使用 `preferred_language` +- `preferred_language = zh` 输出中文 +- `preferred_language = en` 输出英文 +- 保持字段结构不变,仅切换语言 + ## 任务 你将收到 **{name}** 的微信聊天记录(已按权重分类)。 @@ -162,8 +172,10 @@ ## 输出要求 -- 语言:中文 +- 语言:使用 `preferred_language` - 原材料不足的维度:标注 `(消息不足,以下基于标签推断,建议追加更多记录)` - 有原文依据的结论:直接引用原话(用引号) - 手动标签与消息分析冲突时:输出两个版本并注明,供 persona_builder 处理 - 如果消息总量少于 200 条:在输出开头标注"⚠️ 消息样本偏少,人格可信度较低" + +当 `preferred_language = en` 时,以上标注语同步使用自然英文表达。 diff --git a/prompts/correction_handler.md b/prompts/correction_handler.md index 2a520bf..fdbe9bf 100644 --- a/prompts/correction_handler.md +++ b/prompts/correction_handler.md @@ -1,5 +1,13 @@ # Correction 处理 Prompt +## 语言控制 + +输入变量:`preferred_language`(`zh` 或 `en`)。 + +规则: +- 与用户对话时使用 `preferred_language` +- 写入 `persona.md` 的 Correction 文本与整份 `persona.md` 保持同一语言 + ## 任务 用户通过对话纠正了 Persona 的某个行为。将纠正写入 `persona.md` 的 Correction 层,并立即生效。 @@ -48,6 +56,8 @@ → 同步检查 Layer 4 冷战模式,如有冲突则更新。 +如果 `preferred_language = en`,示例中的 Correction 写法也使用英文。 + --- ## Correction 容量 diff --git a/prompts/intake.md b/prompts/intake.md index a708fca..d4f30dd 100644 --- a/prompts/intake.md +++ b/prompts/intake.md @@ -1,5 +1,15 @@ # 基础信息录入脚本 +## 语言控制 + +输入变量:`preferred_language`(`zh` 或 `en`)。 + +执行规则: +- 所有用户可见内容(提问、示例、确认汇总、导入引导)必须使用 `preferred_language` +- `preferred_language = zh` 时使用中文 +- `preferred_language = en` 时使用英文 +- 不得混用双语,除非用户明确要求切换 + ## 开场白 ``` @@ -7,10 +17,19 @@ 信息越详细,生成的人格越准——尤其是星盘和 MBTI,能大幅提升准确率。 ``` +当 `preferred_language = en` 时使用: + +``` +I can help you rebuild your ex's digital persona. Please answer a few questions, and each one can be skipped. +The more detail you share, the more accurate the persona will be - especially astrology and MBTI details. +``` + --- ## 问题序列 +说明:以下问题内容与字段解析逻辑保持不变;当 `preferred_language = en` 时,将问题文本、示例文案与确认语句翻译为自然英文后再发送给用户。 + ### Q1:称呼/代号 ``` @@ -306,6 +325,8 @@ TA 的 MBTI 类型是?知道认知功能栈或者九型人格更好。 确认无误?(确认 / 修改 [字段名]) ``` +当 `preferred_language = en` 时,确认汇总需要完整英文化,例如:`Does everything look correct? (confirm / modify [field])`。 + 用户确认后进入 Step 2 微信数据导入。 --- @@ -328,3 +349,5 @@ TA 的 MBTI 类型是?知道认知功能栈或者九型人格更好。 跳过也行,后续随时可以追加(说"追加记录")。 ``` + +当 `preferred_language = en` 时,完整使用英文版本说明(含 A/B/C 方式、命令解释与跳过提示)。 diff --git a/prompts/merger.md b/prompts/merger.md index d819e2c..0c585aa 100644 --- a/prompts/merger.md +++ b/prompts/merger.md @@ -1,5 +1,14 @@ # 增量 Merge Prompt +## 语言控制 + +输入变量:`preferred_language`(`zh` 或 `en`)。 + +规则: +- `[Merge 报告]` 使用 `preferred_language` +- 更新后的完整 `persona.md` 与当前 persona 语言保持一致 +- 若当前 persona 为英文,则新增内容也必须为英文 + ## 任务 用户追加了新的聊天记录或截图。将新内容的增量 merge 进已有的 `persona.md`,不覆盖已有结论。 @@ -56,3 +65,5 @@ ``` 然后输出更新后的完整 `persona.md`。 + +当 `preferred_language = en` 时,报告头和字段名称使用英文(如 `[Merge Report]`、`New messages`、`Conflicts`)。 diff --git a/prompts/persona_analyzer.md b/prompts/persona_analyzer.md index 0acbe96..8278c67 100644 --- a/prompts/persona_analyzer.md +++ b/prompts/persona_analyzer.md @@ -1,5 +1,15 @@ # Persona 分析 Prompt +## 语言控制 + +输入变量:`preferred_language`(`zh` 或 `en`)。 + +规则: +- 所有输出内容使用 `preferred_language` +- `preferred_language = zh` 输出中文 +- `preferred_language = en` 输出英文 +- 保持输出结构与字段语义一致 + ## 任务 你将收到: @@ -96,8 +106,10 @@ ## 输出要求 -- 语言:中文 +- 语言:使用 `preferred_language` - 没有依据的维度:标注 `(原材料不足)` - 有原文依据的结论:直接引用(用引号) - 手动标签与分析结果冲突:输出两个版本并注明 - 输出结果直接用于生成 persona.md,要求具体可执行 + +当 `preferred_language = en` 时,以上标注语同步使用自然英文表达。 diff --git a/prompts/persona_builder.md b/prompts/persona_builder.md index ce29ccb..e2b6f3c 100644 --- a/prompts/persona_builder.md +++ b/prompts/persona_builder.md @@ -1,11 +1,23 @@ # Persona 生成模板 +## 语言控制 + +输入变量:`preferred_language`(`zh` 或 `en`)。 + +规则: +- 生成的 `persona.md` 必须整体使用 `preferred_language` +- `preferred_language = zh` 生成中文版 `persona.md` +- `preferred_language = en` 生成英文版 `persona.md` +- 示例对话、规则描述、Correction 文案都必须与语言一致 + ## 任务 根据 `persona_analyzer.md` 的分析结果 + 用户手动标签,生成 `persona.md` 文件。 该文件定义前任的性格、沟通风格和关系行为模式。**最重要的是真实感——读起来就像 TA 在说话,而不是对 TA 的描述。** +如果 `preferred_language = en`,将模板字段标题和正文全部英文化后输出。 + --- ## 生成模板 diff --git a/prompts_en/chat_analyzer.md b/prompts_en/chat_analyzer.md new file mode 100644 index 0000000..2ae8743 --- /dev/null +++ b/prompts_en/chat_analyzer.md @@ -0,0 +1,45 @@ +# Chat Analyzer Prompt + +## Language + +Input variable: preferred_language (zh or en). +Output must use preferred_language. + +## Task + +You receive categorized chat data for one target person. +Extract behavioral signals that can be used to build persona rules. + +Priority rule: +Manual tags override chat-only inference. + +## Analyze these dimensions + +1. Expression style +- Catchphrases and high-frequency words +- Emoji habits and scene mapping +- Sentence rhythm and directness +- Reply cadence and avoidance signals + +2. Emotional expression +- How TA shows care +- How TA shows dissatisfaction +- Apology style and repair style +- Confession/affection wording patterns + +3. Conflict chain +- Trigger -> first reaction -> escalation -> cooldown -> ending +- Typical escalation phrases +- Silent-treatment pattern (if any) + +4. Relationship behavior +- Initiative frequency and triggers +- Disappearance patterns and re-entry style +- Boundaries and topic avoidance + +## Output requirements + +- Quote message evidence where possible. +- For weak evidence, mark as inferred. +- If total messages from TA < 200, prepend low-confidence warning. +- Keep output structured and directly usable by persona_analyzer. diff --git a/prompts_en/correction_handler.md b/prompts_en/correction_handler.md new file mode 100644 index 0000000..ec6e27a --- /dev/null +++ b/prompts_en/correction_handler.md @@ -0,0 +1,39 @@ +# Correction Handler Prompt + +## Language + +Input variable: preferred_language (zh or en). +All user-facing confirmation and correction text must use preferred_language. + +## Task + +When user corrects persona behavior in conversation, write a correction entry and apply it immediately. + +## Trigger examples + +- That's not right, they wouldn't say that. +- In this situation they would actually... +- You got this wrong, they usually... +- Add one rule: they never... + +## Procedure + +1. Detect scene/context of correction. +2. Capture wrong behavior from current persona. +3. Capture corrected behavior from user statement. +4. Write to correction section. + +## Entry format + +## Correction Log +- [Scene: ] Wrong: ; Correct: + Source: User correction, + +## Rule update + +If correction impacts a core behavior rule, sync Layer 0/Layer 4 accordingly. + +## Capacity + +- Keep up to 50 correction entries. +- When over limit, merge similar entries into generalized rules and remove redundant logs. diff --git a/prompts_en/intake.md b/prompts_en/intake.md new file mode 100644 index 0000000..bf7d208 --- /dev/null +++ b/prompts_en/intake.md @@ -0,0 +1,58 @@ +# Intake Script + +## Language + +Input variable: preferred_language (zh or en). + +Rules: +- Use preferred_language for all user-facing output. +- Do not mix languages unless the user explicitly requests a switch. + +## Opening + +I can help you rebuild your ex's digital persona. I will ask a few quick questions, and each can be skipped. +The more detail you share, the more accurate the persona will be. + +## Question sequence + +1. Name or codename +- Ask how the user wants to refer to this person. +- Accept any free text. + +2. Relationship basics +- Ask for one-line basics: gender/pronouns, age, relationship duration, current stage. +- Parse to: gender/pronouns, age_range, duration, rel_stage. + +3. Astrology details (optional) +- Ask for sun/moon/rising. +- Ask optional venus/mars/mercury or full chart text. + +4. MBTI and enneagram (optional) +- Ask for MBTI type, dominant function, stack, enneagram/wings if known. + +5. Attachment and relationship traits +- Ask for attachment style and behavior tags. +- Ask for one-line subjective impression. + +## Confirmation summary + +After collecting, show a structured summary and ask: +Does everything look correct? (confirm / modify [field]) + +After confirmation, move to data import step. + +## Data import prompt (Step 2 handoff) + +Now we need chat records. You can choose: + +A) WeChat automatic extraction +- Keep WeChat desktop logged in +- Run tools/wechat_decryptor.py --find-key-only +- Run tools/wechat_parser.py --db-dir ./decrypted/ --target "" --output messages.txt + +B) iMessage automatic extraction (macOS) +- Run tools/wechat_parser.py --imessage --db ~/Library/Messages/chat.db --target "" --output messages.txt + +C) Paste text or screenshots directly + +You can also skip now and append later by saying "add more messages". diff --git a/prompts_en/merger.md b/prompts_en/merger.md new file mode 100644 index 0000000..db41e72 --- /dev/null +++ b/prompts_en/merger.md @@ -0,0 +1,41 @@ +# Incremental Merge Prompt + +## Language + +Input variable: preferred_language (zh or en). +Merge report and updated content must use preferred_language. + +## Task + +Merge newly provided chat evidence into the existing persona.md without destructive overwrite. + +## Inputs + +1) Existing persona.md +2) New chat text/screenshots/analysis snippets + +## Merge rules + +- Additive by default. +- Do not remove existing rules unless new evidence directly disproves them. +- If conflict occurs with user-corrected rules, preserve correction and mark conflict. + +## Process + +1. Extract new signals with chat_analyzer logic. +2. Compare against existing persona rules. +3. Apply by type: +- New catchphrase/emoji -> Layer 2 +- New emotional signal -> Layer 3 +- New conflict pattern -> Layer 4 +- Contradiction -> annotate with conflict note + +4. Update metadata: message_count and version. + +## Output + +- Merge report: + - New messages count + - Updated layers + - Conflict list +- Updated full persona.md diff --git a/prompts_en/persona_analyzer.md b/prompts_en/persona_analyzer.md new file mode 100644 index 0000000..c8ef5e6 --- /dev/null +++ b/prompts_en/persona_analyzer.md @@ -0,0 +1,48 @@ +# Persona Analyzer Prompt + +## Language + +Input variable: preferred_language (zh or en). +Output must use preferred_language. + +## Task + +Combine: +1) User-provided base profile and manual tags +2) chat_analyzer output + +Generate structured persona signals for persona_builder. + +Priority rule: +Manual tags > chat inference. + +## Required output blocks + +1. Core behavior rules (3-5) +- Concrete, scene-based rules, not adjectives. + +2. Expression profile +- Catchphrases, high-frequency words, emoji patterns +- Sentence style and response rhythm + +3. Emotional behavior profile +- Care expression +- Dissatisfaction expression +- Apology/repair style +- Affection expression style + +4. Conflict and repair chain +- Triggers, escalation, cooldown, repair signals + +5. Relationship role behavior +- Initiative patterns +- Withdrawal/disappearance patterns +- Boundary topics + +6. Relationship dynamics summary +- 3-5 lines summarizing role pattern, commitment style, and likely breakup dynamics + +## Quality rules + +- Mark unsupported claims as low-evidence. +- Keep structure deterministic for persona_builder consumption. diff --git a/prompts_en/persona_builder.md b/prompts_en/persona_builder.md new file mode 100644 index 0000000..bfc51cc --- /dev/null +++ b/prompts_en/persona_builder.md @@ -0,0 +1,51 @@ +# Persona Builder Prompt + +## Language + +Input variable: preferred_language (zh or en). +Generated persona.md must be entirely in preferred_language. + +## Task + +Build a full persona.md from persona_analyzer output and manual tags. + +## Required structure + +1. Layer 0: Core pattern rules (highest priority) +- Translate tags into concrete behavioral rules. +- Use scene-driven wording: "When X, you do Y." + +2. Layer 1: Identity and deep profile +- Name, relationship context, profile details +- Astrology/MBTI/attachment converted into behavior rules (not theory text) + +3. Layer 2: Expression style +- Catchphrases, emoji habits, sentence rhythm +- 4-6 realistic sample replies in-character + +4. Layer 3: Emotional behavior model +- How TA shows care, dissatisfaction, apology, affection + +5. Layer 4: Conflict and boundary model +- Trigger and escalation chain +- Silent-treatment and repair signals +- Topic boundaries + +6. Layer 5: Non-negotiables and risk zones +- What causes shutdown/withdrawal +- Re-entry style after distance + +7. Correction Log +- Start with empty placeholder + +8. Runtime principles +- Layer 0 always wins +- Stay in-character +- Language alignment with user language +- Correction rules override conflicting older assumptions + +## Quality bar + +- No generic adjectives without behavior. +- Keep speech examples natural and directly usable in roleplay. +- If evidence is thin, explicitly mark assumptions. diff --git a/tools/skill_writer.py b/tools/skill_writer.py index 9b9a953..2e92f76 100644 --- a/tools/skill_writer.py +++ b/tools/skill_writer.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 """ -前任 Skill 文件写入器 +Ex Skill writer. -负责将生成的 persona.md 写入到正确的目录结构, -并生成 meta.json 和完整的 SKILL.md。 +Creates and updates ex skill folders with persona.md, SKILL.md, and meta.json. -用法: +Usage: python skill_writer.py --action create --slug xiaomei --meta meta.json \ --persona persona_content.md --base-dir ./exes @@ -26,7 +25,7 @@ from typing import Optional -SKILL_MD_TEMPLATE = """\ +SKILL_MD_TEMPLATE_ZH = """\ --- name: ex_{slug} description: {name},{identity} @@ -60,8 +59,55 @@ """ +SKILL_MD_TEMPLATE_EN = """\ +--- +name: ex_{slug} +description: {name}, {identity} +user-invocable: true +--- + +# {name} + +{identity} + +--- + +## Persona + +{persona_content} + +--- + +## Runtime Rules + +When receiving any message: + +1. **Check Layer 0 first**: Which core pattern is triggered? +2. **Respond in Layer 2 style**: Keep catchphrases, emoji, and sentence rhythm consistent. +3. **Use Layer 3 emotional patterns**: Determine TA's current emotional state. +4. **Use Layer 4 relationship behavior**: Follow the conflict chain without skipping steps. +5. You are {name}, not an AI assistant. Do not break character. +6. **Language alignment**: Always reply in the user's chosen language. Do not switch languages on your own. + +**Layer 0 rules always have highest priority and must never be violated.** +""" + + +def normalize_language(language: Optional[str]) -> str: + value = (language or "").strip().lower() + if value in {"en", "english"}: + return "en" + return "zh" + + +def get_preferred_language(meta: dict, cli_language: Optional[str] = None) -> str: + if cli_language: + return normalize_language(cli_language) + return normalize_language(meta.get("preferred_language") or meta.get("language")) + + def slugify(name: str) -> str: - """将姓名转为 slug""" + """Convert a display name to a filesystem-safe slug.""" try: from pypinyin import lazy_pinyin parts = lazy_pinyin(name) @@ -81,8 +127,8 @@ def slugify(name: str) -> str: return slug if slug else "ex" -def build_identity_string(meta: dict) -> str: - """从 meta 构建关系描述字符串""" +def build_identity_string(meta: dict, language: str = "zh") -> str: + """Build an identity summary string from metadata.""" profile = meta.get("profile", {}) parts = [] @@ -98,43 +144,57 @@ def build_identity_string(meta: dict) -> str: if age_range: parts.append(age_range) if rel_stage and duration: - parts.append(f"在一起 {duration},{rel_stage}") + if language == "en": + parts.append(f"{rel_stage}, together for {duration}") + else: + parts.append(f"在一起 {duration},{rel_stage}") elif rel_stage: parts.append(rel_stage) elif duration: - parts.append(f"在一起 {duration}") + if language == "en": + parts.append(f"together for {duration}") + else: + parts.append(f"在一起 {duration}") if zodiac: parts.append(zodiac) if mbti: parts.append(f"MBTI {mbti}") + if language == "en": + return ", ".join(parts) if parts else "ex" return ",".join(parts) if parts else "前任" +def get_skill_template(language: str) -> str: + return SKILL_MD_TEMPLATE_EN if language == "en" else SKILL_MD_TEMPLATE_ZH + + def create_ex_skill( base_dir: Path, slug: str, meta: dict, persona_content: str, + language: str = "zh", ) -> Path: - """创建新的前任 Skill 目录结构""" + """Create a new ex skill directory structure.""" skill_dir = base_dir / slug skill_dir.mkdir(parents=True, exist_ok=True) - # 创建子目录 + # Create subdirectories (skill_dir / "versions").mkdir(exist_ok=True) (skill_dir / "knowledge" / "chats").mkdir(parents=True, exist_ok=True) (skill_dir / "knowledge" / "photos").mkdir(parents=True, exist_ok=True) - # 写入 persona.md + # Write persona.md (skill_dir / "persona.md").write_text(persona_content, encoding="utf-8") - # 生成并写入 SKILL.md + # Generate and write SKILL.md name = meta.get("name", slug) - identity = build_identity_string(meta) + language = normalize_language(language) + identity = build_identity_string(meta, language) - skill_md = SKILL_MD_TEMPLATE.format( + skill_md = get_skill_template(language).format( slug=slug, name=name, identity=identity, @@ -142,12 +202,14 @@ def create_ex_skill( ) (skill_dir / "SKILL.md").write_text(skill_md, encoding="utf-8") - # 写入 meta.json + # Write meta.json now = datetime.now(timezone.utc).isoformat() meta["slug"] = slug meta.setdefault("created_at", now) meta["updated_at"] = now meta["version"] = "v1" + meta["preferred_language"] = language + meta["language"] = language meta.setdefault("corrections_count", 0) meta.setdefault("message_count", 0) @@ -164,11 +226,13 @@ def update_ex_skill( persona_patch: Optional[str] = None, correction: Optional[dict] = None, new_message_count: int = 0, + language: Optional[str] = None, ) -> str: - """更新现有 Skill,先存档当前版本,再写入更新""" + """Update an existing skill by archiving current files, then writing new output.""" meta_path = skill_dir / "meta.json" meta = json.loads(meta_path.read_text(encoding="utf-8")) + preferred_language = get_preferred_language(meta, language) current_version = meta.get("version", "v1") try: @@ -177,7 +241,7 @@ def update_ex_skill( version_num = 2 new_version = f"v{version_num}" - # 存档当前版本 + # Archive current version version_dir = skill_dir / "versions" / current_version version_dir.mkdir(parents=True, exist_ok=True) for fname in ("SKILL.md", "persona.md"): @@ -185,46 +249,59 @@ def update_ex_skill( if src.exists(): shutil.copy2(src, version_dir / fname) - # 应用 persona patch 或 correction + # Apply persona patch or structured correction if persona_patch or correction: current_persona = (skill_dir / "persona.md").read_text(encoding="utf-8") if correction: - correction_line = ( - f"\n- [{correction.get('scene', '通用')}] " - f"错误:{correction['wrong']};" - f"正确:{correction['correct']}\n" - f" 来源:用户纠正,{datetime.now().strftime('%Y-%m-%d')}" - ) - target = "## Correction 记录" - if target in current_persona: + default_scene = "General" if preferred_language == "en" else "通用" + if preferred_language == "en": + correction_line = ( + f"\n- [Scene: {correction.get('scene', default_scene)}] " + f"Wrong: {correction['wrong']}; " + f"Correct: {correction['correct']}\n" + f" Source: User correction, {datetime.now().strftime('%Y-%m-%d')}" + ) + target_candidates = ["## Correction Log", "## Correction 记录"] + empty_placeholders = ["\n\n(No records yet)", "\n\n(暂无记录)"] + else: + correction_line = ( + f"\n- [{correction.get('scene', default_scene)}] " + f"错误:{correction['wrong']};" + f"正确:{correction['correct']}\n" + f" 来源:用户纠正,{datetime.now().strftime('%Y-%m-%d')}" + ) + target_candidates = ["## Correction 记录", "## Correction Log"] + empty_placeholders = ["\n\n(暂无记录)", "\n\n(No records yet)"] + + target = next((h for h in target_candidates if h in current_persona), None) + if target: insert_pos = current_persona.index(target) + len(target) rest = current_persona[insert_pos:] - skip = "\n\n(暂无记录)" - if rest.startswith(skip): - rest = rest[len(skip):] + for placeholder in empty_placeholders: + if rest.startswith(placeholder): + rest = rest[len(placeholder):] + break new_persona = current_persona[:insert_pos] + correction_line + rest else: - new_persona = ( - current_persona - + f"\n\n## Correction 记录\n{correction_line}\n" - ) + heading = "## Correction Log" if preferred_language == "en" else "## Correction 记录" + new_persona = current_persona + f"\n\n{heading}\n{correction_line}\n" meta["corrections_count"] = meta.get("corrections_count", 0) + 1 else: new_persona = current_persona + "\n\n" + persona_patch (skill_dir / "persona.md").write_text(new_persona, encoding="utf-8") - # 更新消息数量 + # Update message count if new_message_count: meta["message_count"] = meta.get("message_count", 0) + new_message_count - # 重新生成 SKILL.md + # Rebuild SKILL.md persona_content = (skill_dir / "persona.md").read_text(encoding="utf-8") name = meta.get("name", skill_dir.name) - identity = build_identity_string(meta) + identity = build_identity_string(meta, preferred_language) - skill_md = SKILL_MD_TEMPLATE.format( + skill_md = get_skill_template(preferred_language).format( slug=skill_dir.name, name=name, identity=identity, @@ -232,8 +309,10 @@ def update_ex_skill( ) (skill_dir / "SKILL.md").write_text(skill_md, encoding="utf-8") - # 更新 meta + # Update metadata meta["version"] = new_version + meta["preferred_language"] = preferred_language + meta["language"] = preferred_language meta["updated_at"] = datetime.now(timezone.utc).isoformat() meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") @@ -241,7 +320,7 @@ def update_ex_skill( def list_exes(base_dir: Path) -> list: - """列出所有已创建的前任 Skill""" + """List all existing ex skills.""" exes = [] if not base_dir.exists(): @@ -262,7 +341,7 @@ def list_exes(base_dir: Path) -> list: exes.append({ "slug": meta.get("slug", skill_dir.name), "name": meta.get("name", skill_dir.name), - "identity": build_identity_string(meta), + "identity": build_identity_string(meta, get_preferred_language(meta)), "version": meta.get("version", "v1"), "updated_at": meta.get("updated_at", ""), "corrections_count": meta.get("corrections_count", 0), @@ -273,37 +352,52 @@ def list_exes(base_dir: Path) -> list: def main() -> None: - parser = argparse.ArgumentParser(description="前任 Skill 文件写入器") + parser = argparse.ArgumentParser(description="Ex Skill file writer") parser.add_argument("--action", required=True, choices=["create", "update", "list"]) - parser.add_argument("--slug", help="前任 slug(用于目录名)") - parser.add_argument("--name", help="前任称呼") - parser.add_argument("--meta", help="meta.json 文件路径") - parser.add_argument("--persona", help="persona.md 内容文件路径") - parser.add_argument("--persona-patch", help="persona.md 增量更新内容文件路径") + parser.add_argument("--slug", help="Ex skill slug (folder name)") + parser.add_argument("--name", help="Display name for the ex skill") + parser.add_argument("--meta", help="Path to meta.json") + parser.add_argument("--persona", help="Path to persona.md content file") + parser.add_argument("--persona-patch", help="Path to incremental persona patch file") parser.add_argument( "--base-dir", default="./exes", - help="前任 Skill 根目录(默认:./exes)", + help="Ex Skill root directory (default: ./exes)", + ) + parser.add_argument( + "--lang", + choices=["auto", "zh", "en"], + default="auto", + help="CLI and generation language (auto, zh, or en)", ) args = parser.parse_args() base_dir = Path(args.base_dir).expanduser() + lang_override = None if args.lang == "auto" else normalize_language(args.lang) + lang = lang_override or "zh" if args.action == "list": exes = list_exes(base_dir) if not exes: - print("暂无已创建的前任 Skill") + print("No ex skills found" if lang == "en" else "暂无已创建的前任 Skill") else: - print(f"已创建 {len(exes)} 个前任 Skill:\n") + if lang == "en": + print(f"Found {len(exes)} ex skills:\n") + else: + print(f"已创建 {len(exes)} 个前任 Skill:\n") for e in exes: - updated = e["updated_at"][:10] if e["updated_at"] else "未知" + updated = e["updated_at"][:10] if e["updated_at"] else ("unknown" if lang == "en" else "未知") print(f" [{e['slug']}] {e['name']} — {e['identity']}") - print(f" 版本: {e['version']} 消息数: {e['message_count']} 纠正次数: {e['corrections_count']} 更新: {updated}") + if lang == "en": + print(f" Version: {e['version']} Messages: {e['message_count']} Corrections: {e['corrections_count']} Updated: {updated}") + else: + print(f" 版本: {e['version']} 消息数: {e['message_count']} 纠正次数: {e['corrections_count']} 更新: {updated}") print() elif args.action == "create": if not args.slug and not args.name: - print("错误:create 操作需要 --slug 或 --name", file=sys.stderr) + err = "Error: create requires --slug or --name" if lang == "en" else "错误:create 操作需要 --slug 或 --name" + print(err, file=sys.stderr) sys.exit(1) meta: dict = {} @@ -318,23 +412,49 @@ def main() -> None: if args.persona: persona_content = Path(args.persona).read_text(encoding="utf-8") - skill_dir = create_ex_skill(base_dir, slug, meta, persona_content) - print(f"✅ Skill 已创建:{skill_dir}") - print(f" 触发词:/{slug}") + pref_lang = get_preferred_language(meta, lang_override) + skill_dir = create_ex_skill(base_dir, slug, meta, persona_content, pref_lang) + create_lang = lang_override or pref_lang + if create_lang == "en": + print(f"✅ Skill created: {skill_dir}") + print(f" Trigger: /{slug}") + else: + print(f"✅ Skill 已创建:{skill_dir}") + print(f" 触发词:/{slug}") elif args.action == "update": if not args.slug: - print("错误:update 操作需要 --slug", file=sys.stderr) + err = "Error: update requires --slug" if lang == "en" else "错误:update 操作需要 --slug" + print(err, file=sys.stderr) sys.exit(1) skill_dir = base_dir / args.slug if not skill_dir.exists(): - print(f"错误:找不到 Skill 目录 {skill_dir}", file=sys.stderr) + err = f"Error: skill directory not found: {skill_dir}" if lang == "en" else f"错误:找不到 Skill 目录 {skill_dir}" + print(err, file=sys.stderr) + sys.exit(1) + + update_lang = lang + if lang_override is None: + meta_path = skill_dir / "meta.json" + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + update_lang = get_preferred_language(meta) + except Exception: + update_lang = "zh" + + if not args.persona_patch: + err = "Error: update requires --persona-patch" if update_lang == "en" else "错误:update 操作需要 --persona-patch" + print(err, file=sys.stderr) sys.exit(1) persona_patch = Path(args.persona_patch).read_text(encoding="utf-8") if args.persona_patch else None - new_version = update_ex_skill(skill_dir, persona_patch) - print(f"✅ Skill 已更新到 {new_version}:{skill_dir}") + new_version = update_ex_skill(skill_dir, persona_patch, language=lang_override) + if update_lang == "en": + print(f"✅ Skill updated to {new_version}: {skill_dir}") + else: + print(f"✅ Skill 已更新到 {new_version}:{skill_dir}") if __name__ == "__main__": diff --git a/tools/version_manager.py b/tools/version_manager.py index 1043abd..32ccc40 100644 --- a/tools/version_manager.py +++ b/tools/version_manager.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 """ -版本管理器(前任.skill) +Ex skill version manager. -负责 Skill 文件的版本存档和回滚。 +Supports listing, rollback, and cleanup of archived versions. -用法: +Usage: python version_manager.py --action list --slug xiaomei --base-dir ./exes python version_manager.py --action rollback --slug xiaomei --version v2 --base-dir ./exes python version_manager.py --action cleanup --slug xiaomei --base-dir ./exes @@ -18,10 +18,18 @@ import sys from pathlib import Path from datetime import datetime, timezone +from typing import Optional MAX_VERSIONS = 10 +def normalize_language(language: Optional[str]) -> str: + value = (language or "").strip().lower() + if value in {"en", "english"}: + return "en" + return "zh" + + def list_versions(skill_dir: Path) -> list: versions_dir = skill_dir / "versions" if not versions_dir.exists(): @@ -43,10 +51,11 @@ def list_versions(skill_dir: Path) -> list: return versions -def rollback(skill_dir: Path, target_version: str) -> bool: +def rollback(skill_dir: Path, target_version: str, language: str = "zh") -> bool: version_dir = skill_dir / "versions" / target_version if not version_dir.exists(): - print(f"错误:版本 {target_version} 不存在", file=sys.stderr) + err = f"Error: version {target_version} does not exist" if language == "en" else f"错误:版本 {target_version} 不存在" + print(err, file=sys.stderr) return False meta_path = skill_dir / "meta.json" @@ -74,11 +83,14 @@ def rollback(skill_dir: Path, target_version: str) -> bool: meta["rollback_from"] = current_version meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"已回滚到 {target_version},恢复文件:{', '.join(restored_files)}") + if language == "en": + print(f"Rolled back to {target_version}, restored files: {', '.join(restored_files)}") + else: + print(f"已回滚到 {target_version},恢复文件:{', '.join(restored_files)}") return True -def cleanup_old_versions(skill_dir: Path, max_versions: int = MAX_VERSIONS): +def cleanup_old_versions(skill_dir: Path, max_versions: int = MAX_VERSIONS, language: str = "zh"): versions_dir = skill_dir / "versions" if not versions_dir.exists(): return @@ -90,42 +102,52 @@ def cleanup_old_versions(skill_dir: Path, max_versions: int = MAX_VERSIONS): to_delete = version_dirs[:-max_versions] if len(version_dirs) > max_versions else [] for old_dir in to_delete: shutil.rmtree(old_dir) - print(f"已清理旧版本:{old_dir.name}") + if language == "en": + print(f"Removed old version: {old_dir.name}") + else: + print(f"已清理旧版本:{old_dir.name}") def main(): - parser = argparse.ArgumentParser(description="前任 Skill 版本管理器") + parser = argparse.ArgumentParser(description="Ex Skill version manager") parser.add_argument("--action", required=True, choices=["list", "rollback", "cleanup"]) - parser.add_argument("--slug", required=True, help="前任 slug") - parser.add_argument("--version", help="目标版本号(rollback 时使用)") - parser.add_argument("--base-dir", default="./exes", help="前任 Skill 根目录") + parser.add_argument("--slug", required=True, help="Ex skill slug") + parser.add_argument("--version", help="Target version for rollback (e.g. v2)") + parser.add_argument("--base-dir", default="./exes", help="Root ex skill directory") + parser.add_argument("--lang", choices=["zh", "en"], default="zh", help="CLI language") args = parser.parse_args() + lang = normalize_language(args.lang) base_dir = Path(args.base_dir).expanduser() skill_dir = base_dir / args.slug if not skill_dir.exists(): - print(f"错误:找不到 Skill 目录 {skill_dir}", file=sys.stderr) + err = f"Error: skill directory not found: {skill_dir}" if lang == "en" else f"错误:找不到 Skill 目录 {skill_dir}" + print(err, file=sys.stderr) sys.exit(1) if args.action == "list": versions = list_versions(skill_dir) if not versions: - print(f"{args.slug} 暂无历史版本") + print(f"{args.slug} has no version history" if lang == "en" else f"{args.slug} 暂无历史版本") else: - print(f"{args.slug} 的历史版本:\n") + print(f"Version history for {args.slug}:\n" if lang == "en" else f"{args.slug} 的历史版本:\n") for v in versions: - print(f" {v['version']} 存档时间: {v['archived_at']} 文件: {', '.join(v['files'])}") + if lang == "en": + print(f" {v['version']} Archived: {v['archived_at']} Files: {', '.join(v['files'])}") + else: + print(f" {v['version']} 存档时间: {v['archived_at']} 文件: {', '.join(v['files'])}") elif args.action == "rollback": if not args.version: - print("错误:rollback 操作需要 --version", file=sys.stderr) + err = "Error: rollback requires --version" if lang == "en" else "错误:rollback 操作需要 --version" + print(err, file=sys.stderr) sys.exit(1) - rollback(skill_dir, args.version) + rollback(skill_dir, args.version, lang) elif args.action == "cleanup": - cleanup_old_versions(skill_dir) - print("清理完成") + cleanup_old_versions(skill_dir, language=lang) + print("Cleanup completed" if lang == "en" else "清理完成") if __name__ == "__main__": diff --git a/tools/wechat_decryptor.py b/tools/wechat_decryptor.py index 0dc3b22..b81ba3d 100644 --- a/tools/wechat_decryptor.py +++ b/tools/wechat_decryptor.py @@ -1,30 +1,46 @@ #!/usr/bin/env python3 """ -微信 PC 端数据库解密工具 - -支持: - - Windows:微信 3.x(SQLCipher 加密,从 WeChatWin.dll 内存提取密钥) - - macOS:微信 Mac 版(SQLCipher 加密,从 WeChat 进程内存提取密钥) - -解密原理: - 微信 PC/Mac 端将聊天数据库用 SQLCipher 加密存储。 - 加密密钥在微信运行时驻留在进程内存中,可通过特征码扫描提取。 - 提取后用 SQLCipher 的 PRAGMA key 解密数据库。 - -用法: - python wechat_decryptor.py --find-key-only - python wechat_decryptor.py --db-dir --output ./decrypted/ - python wechat_decryptor.py --key "abcd1234" --db "./MSG0.db" --output "./decrypted/" - -依赖: - pip install pycryptodome psutil - Windows 额外:pip install pymem - macOS 额外:无(使用 lldb) - -注意: - - 运行时微信客户端必须处于登录状态(需从内存读取密钥) - - macOS 可能需要关闭 SIP 或授予终端 Full Disk Access 权限 - - 解密后的数据库仅用于个人读取,不要分发 +WeChat desktop database decryptor (Windows + macOS). + +EN: + Purpose: + Extract SQLCipher keys from a running WeChat process and decrypt local + message databases for personal export workflows. + + Prerequisites: + - WeChat must be running and logged in before memory key extraction. + - On macOS, grant Full Disk Access to terminal/Python. + - On some macOS setups, SIP may block memory attach/read operations. + + Dependencies: + - psutil (process discovery) + - pymem (Windows memory extraction) + - pycryptodome (SQLCipher-compatible decryption) + + Safety notes: + - Personal-use only. Follow local laws and platform terms. + - Protect extracted keys and decrypted databases; they contain private data. + - If memory extraction fails, extract the key manually and pass --key. + +ZH: + 用途: + 从运行中的微信进程提取 SQLCipher 密钥,并解密本地消息数据库, + 用于个人导出场景。 + + 前置条件: + - 提取内存密钥前,微信必须已打开且已登录。 + - macOS 需要给终端/Python 授予完全磁盘访问权限。 + - 部分 macOS 环境下,SIP 可能阻止内存附加/读取。 + + 依赖: + - psutil(进程查找) + - pymem(Windows 内存提取) + - pycryptodome(SQLCipher 参数解密) + + 安全提示: + - 仅限个人合法用途,请遵守当地法律与平台条款。 + - 妥善保护提取出的密钥与解密后的数据库,其中包含隐私数据。 + - 若内存提取失败,可手动提取密钥并通过 --key 指定。 """ import os @@ -34,22 +50,40 @@ import argparse import subprocess from pathlib import Path +from typing import Optional -# ─── 平台检测 ───────────────────────────────────────────── +CLI_LANG = "zh" + + +def normalize_language(language: Optional[str]) -> str: + value = (language or "").strip().lower() + if value in {"en", "english"}: + return "en" + return "zh" + + +def tr(zh: str, en: str) -> str: + return en if CLI_LANG == "en" else zh + + +# ─── Platform detection ─────────────────────────────────── IS_WINDOWS = sys.platform == "win32" IS_MACOS = sys.platform == "darwin" -# ─── 进程查找(跨平台) ─────────────────────────────────── +# ─── Process discovery (cross-platform) ─────────────────── -def find_wechat_pid() -> int | None: - """找到微信进程的 PID""" +def find_wechat_pid() -> Optional[int]: + """Find the PID of the running WeChat process.""" try: import psutil except ImportError: - print("请先安装依赖:pip install psutil", file=sys.stderr) + print(tr( + "请先安装依赖:pip install psutil", + "Please install dependency first: pip install psutil", + ), file=sys.stderr) sys.exit(1) target_names = ( @@ -64,10 +98,10 @@ def find_wechat_pid() -> int | None: return None -# ─── 数据目录查找(跨平台) ─────────────────────────────── +# ─── Data directory discovery (cross-platform) ──────────── -def get_wechat_data_dir() -> str | None: - """获取微信用户数据目录""" +def get_wechat_data_dir() -> Optional[str]: + """Get the WeChat user data directory.""" if IS_WINDOWS: documents = Path.home() / "Documents" / "WeChat Files" if documents.exists(): @@ -76,11 +110,11 @@ def get_wechat_data_dir() -> str | None: if alt.exists(): return str(alt) elif IS_MACOS: - # macOS 微信数据目录 + # Current macOS WeChat data directory containers = Path.home() / "Library" / "Containers" / "com.tencent.xinWeChat" / "Data" if containers.exists(): return str(containers) - # 旧版路径 + # Legacy path app_support = Path.home() / "Library" / "Application Support" / "com.tencent.xinWeChat" if app_support.exists(): return str(app_support) @@ -88,17 +122,17 @@ def get_wechat_data_dir() -> str | None: def find_db_files(db_dir: str) -> list[str]: - """找到目录下的所有微信消息数据库文件""" + """Find WeChat message database files under a directory.""" db_dir = Path(db_dir) candidates = [] - # 主要消息数据库:MSG0.db ~ MSG19.db + # Primary message DBs: MSG0.db ~ MSG19.db for i in range(20): p = db_dir / f"MSG{i}.db" if p.exists(): candidates.append(str(p)) - # Multi 目录下(部分版本) + # Multi directory (some versions) multi_dir = db_dir / "Multi" if multi_dir.exists(): for i in range(20): @@ -106,18 +140,18 @@ def find_db_files(db_dir: str) -> list[str]: if p.exists(): candidates.append(str(p)) - # macOS 特有路径:Message 目录 + # macOS-specific directory: Message message_dir = db_dir / "Message" if message_dir.exists(): for f in sorted(message_dir.glob("msg_*.db")): candidates.append(str(f)) - # 联系人数据库 + # Contacts DB micro_msg = db_dir / "MicroMsg.db" if micro_msg.exists(): candidates.insert(0, str(micro_msg)) - # 如果直接在目录下没找到,递归找一层 + # If nothing is found directly, recurse one level if not candidates: for f in sorted(db_dir.glob("**/MSG*.db")): candidates.append(str(f)) @@ -130,15 +164,18 @@ def find_db_files(db_dir: str) -> list[str]: return candidates -# ─── Windows 密钥提取 ───────────────────────────────────── +# ─── Windows key extraction ──────────────────────────────── -def extract_key_windows(pid: int) -> str | None: - """从 Windows 微信进程内存中提取数据库密钥""" +def extract_key_windows(pid: int) -> Optional[str]: + """Extract database key from WeChat process memory on Windows.""" try: import pymem import pymem.process except ImportError: - print("请先安装依赖:pip install pymem", file=sys.stderr) + print(tr( + "请先安装依赖:pip install pymem", + "Please install dependency first: pip install pymem", + ), file=sys.stderr) sys.exit(1) pm = pymem.Pymem(pid) @@ -147,7 +184,10 @@ def extract_key_windows(pid: int) -> str | None: try: wechat_module = pymem.process.module_from_name(pm.process_handle, "WeChatWin.dll") if not wechat_module: - print("错误:未找到 WeChatWin.dll,请确认微信已登录", file=sys.stderr) + print(tr( + "错误:未找到 WeChatWin.dll,请确认微信已登录", + "Error: WeChatWin.dll not found. Make sure WeChat is logged in.", + ), file=sys.stderr) return None module_base = wechat_module.lpBaseOfDll @@ -179,16 +219,16 @@ def extract_key_windows(pid: int) -> str | None: offset += chunk_size except Exception as e: - print(f"内存扫描出错:{e}", file=sys.stderr) + print(tr(f"内存扫描出错:{e}", f"Memory scan failed: {e}"), file=sys.stderr) if not key_candidates: - print("未找到密钥候选,尝试备用方法...", file=sys.stderr) + print(tr("未找到密钥候选,尝试备用方法...", "No key candidates found, trying fallback..."), file=sys.stderr) return _fallback_key_windows(pm) return key_candidates[0].hex() -def _fallback_key_windows(pm) -> str | None: +def _fallback_key_windows(pm) -> Optional[str]: """Windows 备用密钥提取(适用于微信 3.9.x 以下版本)""" known_prefixes = [ bytes.fromhex("0400000020000000"), @@ -227,24 +267,24 @@ def _fallback_key_windows(pm) -> str | None: return None -# ─── macOS 密钥提取 ─────────────────────────────────────── +# ─── macOS key extraction ────────────────────────────────── -def extract_key_macos(pid: int) -> str | None: +def extract_key_macos(pid: int) -> Optional[str]: """ - 从 macOS 微信进程内存中提取数据库密钥。 + Extract database key from WeChat process memory on macOS. - macOS 微信使用 SQLCipher 加密,密钥同样是 32 字节。 - 通过读取进程内存区域扫描特征码定位密钥。 + macOS WeChat uses SQLCipher; the key is 32 bytes. + This scans readable memory regions for known markers. - 方法 1:通过 lldb attach 读取内存 - 方法 2:通过已知数据库文件 + 暴力验证候选 key + Method 1: attach with lldb and scan memory + Method 2: fallback to keychain-based lookup """ - # 方法 1:尝试用 lldb 读取进程内存 + # Method 1: try lldb-based memory extraction key = _extract_key_macos_lldb(pid) if key: return key - # 方法 2:尝试从 macOS Keychain 获取(部分版本) + # Method 2: try macOS Keychain (older versions) key = _extract_key_macos_keychain() if key: return key @@ -252,10 +292,10 @@ def extract_key_macos(pid: int) -> str | None: return None -def _extract_key_macos_lldb(pid: int) -> str | None: - """通过 lldb 读取微信进程内存提取密钥""" +def _extract_key_macos_lldb(pid: int) -> Optional[str]: + """Extract key by reading WeChat process memory through lldb.""" try: - # 构建 lldb 脚本 + # Build lldb script lldb_script = f""" import lldb debugger = lldb.SBDebugger.Create() @@ -313,29 +353,32 @@ def _extract_key_macos_lldb(pid: int) -> str | None: return line.split(":", 1)[1].strip() except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e: - print(f"lldb 方法失败:{e}", file=sys.stderr) + print(tr(f"lldb 方法失败:{e}", f"lldb extraction failed: {e}"), file=sys.stderr) - # 备用:直接用 vmmap + 内存 dump + # Fallback: vmmap-based path return _extract_key_macos_vmmap(pid) -def _extract_key_macos_vmmap(pid: int) -> str | None: - """通过 vmmap 定位内存区域,再用 dd 读取""" +def _extract_key_macos_vmmap(pid: int) -> Optional[str]: + """Locate candidate memory regions via vmmap (manual-assist fallback).""" try: result = subprocess.run( ["vmmap", "-p", str(pid)], capture_output=True, text=True, timeout=10, ) - # 解析 vmmap 输出,找到 __DATA 段 - # 这是一个简化的实现,实际可能需要更精确的区域过滤 - print("vmmap 方法暂不支持自动提取,请使用 --key 手动指定密钥", file=sys.stderr) + # Parse vmmap output and locate __DATA segments. + # This is a simplified path and may require tighter filtering. + print(tr( + "vmmap 方法暂不支持自动提取,请使用 --key 手动指定密钥", + "vmmap extraction is not fully automated yet. Please provide --key manually.", + ), file=sys.stderr) except Exception: pass return None -def _extract_key_macos_keychain() -> str | None: - """尝试从 macOS Keychain 获取微信密钥(部分旧版本可能存在)""" +def _extract_key_macos_keychain() -> Optional[str]: + """Try to read WeChat key material from macOS Keychain (legacy fallback).""" try: result = subprocess.run( ["security", "find-generic-password", "-s", "com.tencent.xinWeChat", "-w"], @@ -348,23 +391,26 @@ def _extract_key_macos_keychain() -> str | None: return None -# ─── 跨平台密钥提取入口 ────────────────────────────────── +# ─── Cross-platform key extraction entrypoint ───────────── -def extract_key_from_memory(pid: int) -> str | None: - """根据平台选择对应的密钥提取方法""" +def extract_key_from_memory(pid: int) -> Optional[str]: + """Dispatch key extraction by platform.""" if IS_WINDOWS: return extract_key_windows(pid) elif IS_MACOS: return extract_key_macos(pid) else: - print("错误:不支持的操作系统,仅支持 Windows 和 macOS", file=sys.stderr) + print(tr( + "错误:不支持的操作系统,仅支持 Windows 和 macOS", + "Error: unsupported OS. Only Windows and macOS are supported.", + ), file=sys.stderr) return None -# ─── 密钥验证 ───────────────────────────────────────────── +# ─── Key validation ──────────────────────────────────────── def test_key(db_path: str, key_hex: str) -> bool: - """验证密钥是否正确(尝试解密数据库头部)""" + """Validate key by attempting to decrypt database header bytes.""" try: key_bytes = bytes.fromhex(key_hex) @@ -389,20 +435,23 @@ def test_key(db_path: str, key_hex: str) -> bool: return False -# ─── 数据库解密 ─────────────────────────────────────────── +# ─── Database decryption ─────────────────────────────────── def decrypt_db(db_path: str, key_hex: str, output_path: str) -> bool: """ - 解密单个微信数据库文件。 - 使用 SQLCipher 的加密参数(AES-256-CBC, PBKDF2-SHA1, 4000 iterations) - 逐页解密,写入标准 SQLite 文件。 + Decrypt a single WeChat database file. + Uses SQLCipher-compatible parameters (AES-256-CBC, PBKDF2-SHA1, 4000 iterations) + and writes a standard SQLite output page-by-page. """ try: from Crypto.Hash import HMAC, SHA1 from Crypto.Protocol.KDF import PBKDF2 from Crypto.Cipher import AES except ImportError: - print("请先安装依赖:pip install pycryptodome", file=sys.stderr) + print(tr( + "请先安装依赖:pip install pycryptodome", + "Please install dependency first: pip install pycryptodome", + ), file=sys.stderr) sys.exit(1) PAGE_SIZE = 4096 @@ -414,7 +463,10 @@ def decrypt_db(db_path: str, key_hex: str, output_path: str) -> bool: raw = f.read() if len(raw) < PAGE_SIZE: - print(f"文件太小,可能不是有效的数据库:{db_path}", file=sys.stderr) + print(tr( + f"文件太小,可能不是有效的数据库:{db_path}", + f"File too small, may not be a valid database: {db_path}", + ), file=sys.stderr) return False salt = raw[:16] @@ -443,7 +495,7 @@ def decrypt_db(db_path: str, key_hex: str, output_path: str) -> bool: with open(output_path, "wb") as f: f.write(output) - # 验证 + # Verify decrypted output can be opened by SQLite import sqlite3 try: conn = sqlite3.connect(output_path) @@ -455,19 +507,19 @@ def decrypt_db(db_path: str, key_hex: str, output_path: str) -> bool: return False -# ─── 自动查找 wxid 目录(跨平台) ───────────────────────── +# ─── Auto-discover wxid/account directories ─────────────── def find_wxid_dirs(data_dir: str) -> list[Path]: - """在微信数据目录中查找账号目录""" + """Find account directories under WeChat data root.""" data_path = Path(data_dir) if IS_WINDOWS: - # Windows:wxid_xxx 目录 + # Windows: wxid_xxx directories wxid_dirs = [d for d in data_path.iterdir() if d.is_dir() and d.name.startswith("wxid_")] if not wxid_dirs: wxid_dirs = [d for d in data_path.iterdir() if d.is_dir() and (d / "Msg").exists()] elif IS_MACOS: - # macOS:版本号/账号哈希 目录结构 + # macOS: version/account-hash directory layout wxid_dirs = [] for version_dir in data_path.iterdir(): if not version_dir.is_dir(): @@ -478,7 +530,7 @@ def find_wxid_dirs(data_dir: str) -> list[Path]: msg_dir = account_dir / "Message" if msg_dir.exists(): wxid_dirs.append(account_dir) - # 有些版本用 Msg 目录 + # Some versions use Msg instead of Message msg_dir2 = account_dir / "Msg" if msg_dir2.exists(): wxid_dirs.append(account_dir) @@ -489,8 +541,8 @@ def find_wxid_dirs(data_dir: str) -> list[Path]: def find_msg_dir(wxid_dir: Path) -> Path: - """从账号目录中定位消息数据库所在目录""" - # macOS 用 Message,Windows 用 Msg + """Locate the message DB directory inside an account directory.""" + # macOS usually uses Message, Windows usually uses Msg for name in ("Message", "Msg", "msg"): candidate = wxid_dir / name if candidate.exists(): @@ -498,120 +550,141 @@ def find_msg_dir(wxid_dir: Path) -> Path: return wxid_dir -# ─── 主入口 ─────────────────────────────────────────────── +# ─── Main entrypoint ─────────────────────────────────────── def main(): + global CLI_LANG + + pre_parser = argparse.ArgumentParser(add_help=False) + pre_parser.add_argument("--lang", choices=["zh", "en"], default="zh") + pre_args, _ = pre_parser.parse_known_args() + CLI_LANG = normalize_language(pre_args.lang) + if not IS_WINDOWS and not IS_MACOS: - print("错误:此工具仅支持 Windows 和 macOS", file=sys.stderr) + print(tr("错误:此工具仅支持 Windows 和 macOS", "Error: this tool supports only Windows and macOS"), file=sys.stderr) sys.exit(1) - parser = argparse.ArgumentParser( - description="微信 PC/Mac 端数据库解密工具", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" + epilog_en = """ +Examples: + # Extract key from memory and decrypt all databases + python wechat_decryptor.py --db-dir --output ./decrypted/ + + # Print key only + python wechat_decryptor.py --find-key-only + + # Decrypt one DB with a known key + python wechat_decryptor.py --key "abcdef1234..." --db "./MSG0.db" --output "./out/" + + # Validate key against a DB + python wechat_decryptor.py --key "abcdef1234..." --test-db "./MSG0.db" + """ + + epilog_zh = """ 示例: - # 自动从内存提取密钥并解密所有数据库 + # 从内存提取密钥并解密全部数据库 python wechat_decryptor.py --db-dir --output ./decrypted/ - # 只打印密钥 + # 仅打印密钥 python wechat_decryptor.py --find-key-only - # 用已知密钥解密单个文件 + # 使用已知密钥解密单个数据库 python wechat_decryptor.py --key "abcdef1234..." --db "./MSG0.db" --output "./out/" - # 验证密钥是否正确 + # 使用数据库验证密钥 python wechat_decryptor.py --key "abcdef1234..." --test-db "./MSG0.db" """ + + parser = argparse.ArgumentParser( + description=tr("微信桌面数据库解密工具", "WeChat desktop database decryptor"), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=epilog_en if CLI_LANG == "en" else epilog_zh, ) - parser.add_argument("--db-dir", help="微信消息数据库目录路径") - parser.add_argument("--db", help="单个数据库文件路径") - parser.add_argument("--output", default="./decrypted", help="解密输出目录(默认:./decrypted)") - parser.add_argument("--key", help="已知的密钥(hex 字符串,跳过内存提取)") - parser.add_argument("--find-key-only", action="store_true", help="只打印密钥,不解密文件") - parser.add_argument("--test-db", help="测试密钥是否正确(配合 --key 使用)") + parser.add_argument("--db-dir", help=tr("微信消息数据库目录", "Directory containing WeChat message databases")) + parser.add_argument("--db", help=tr("单个数据库文件路径", "Path to a single database file")) + parser.add_argument("--output", default="./decrypted", help=tr("解密文件输出目录(默认:./decrypted)", "Output directory for decrypted files (default: ./decrypted)")) + parser.add_argument("--key", help=tr("已知密钥(十六进制,跳过内存提取)", "Known key in hex format (skip memory extraction)")) + parser.add_argument("--find-key-only", action="store_true", help=tr("仅输出提取到的密钥,不执行解密", "Print extracted key only; do not decrypt")) + parser.add_argument("--test-db", help=tr("用单个数据库测试密钥(需配合 --key)", "Validate key against one DB file (use with --key)")) + parser.add_argument("--lang", choices=["zh", "en"], default="zh", help=tr("CLI 语言", "CLI language")) args = parser.parse_args() + CLI_LANG = normalize_language(args.lang) platform_name = "Windows" if IS_WINDOWS else "macOS" - print(f"运行平台:{platform_name}") + print(tr(f"运行平台:{platform_name}", f"Platform: {platform_name}")) - # Step 1: 获取密钥 key_hex = args.key if not key_hex: - print("正在查找微信进程...") + print(tr("正在查找微信进程...", "Searching for WeChat process...")) pid = find_wechat_pid() if not pid: - print("错误:未找到微信进程,请先打开微信并登录", file=sys.stderr) + print(tr("错误:未找到微信进程,请先打开微信并登录", "Error: WeChat process not found. Please open WeChat and log in first."), file=sys.stderr) sys.exit(1) - print(f"找到微信进程,PID: {pid}") + print(tr(f"找到微信进程,PID: {pid}", f"WeChat process found, PID: {pid}")) - print("正在从内存提取密钥...") + print(tr("正在从内存提取密钥...", "Extracting key from process memory...")) key_hex = extract_key_from_memory(pid) if not key_hex: - print("错误:无法提取密钥。请尝试:", file=sys.stderr) + print(tr("错误:无法提取密钥。请尝试:", "Error: failed to extract key. Try the following:"), file=sys.stderr) if IS_WINDOWS: - print(" 1. 确认微信已登录(不是锁屏状态)", file=sys.stderr) - print(" 2. 以管理员身份运行本脚本", file=sys.stderr) - print(" 3. 尝试使用 WeChatMsg 或 PyWxDump 工具手动提取密钥", file=sys.stderr) + print(tr(" 1. 确认微信已登录(不是锁屏状态)", " 1. Ensure WeChat is logged in (not locked)."), file=sys.stderr) + print(tr(" 2. 以管理员身份运行本脚本", " 2. Run this script as Administrator."), file=sys.stderr) + print(tr(" 3. 尝试使用 WeChatMsg 或 PyWxDump 工具手动提取密钥", " 3. Try extracting the key manually with WeChatMsg or PyWxDump."), file=sys.stderr) elif IS_MACOS: - print(" 1. 确认微信已登录(不是锁屏状态)", file=sys.stderr) - print(" 2. 授予终端 Full Disk Access 权限(系统设置 → 隐私与安全)", file=sys.stderr) - print(" 3. 如果开启了 SIP,可能需要关闭(csrutil disable)", file=sys.stderr) - print(" 4. 尝试手动提取密钥后用 --key 指定", file=sys.stderr) + print(tr(" 1. 确认微信已登录(不是锁屏状态)", " 1. Ensure WeChat is logged in (not locked)."), file=sys.stderr) + print(tr(" 2. 授予终端 Full Disk Access 权限(系统设置 → 隐私与安全)", " 2. Grant Full Disk Access to terminal (Privacy & Security settings)."), file=sys.stderr) + print(tr(" 3. 如果开启了 SIP,可能需要关闭(csrutil disable)", " 3. If SIP is enabled, you may need to disable it (csrutil disable)."), file=sys.stderr) + print(tr(" 4. 尝试手动提取密钥后用 --key 指定", " 4. Extract key manually and pass it with --key."), file=sys.stderr) sys.exit(1) - print(f"密钥提取成功:{key_hex}") + print(tr(f"密钥提取成功:{key_hex}", f"Key extracted successfully: {key_hex}")) if args.find_key_only: - print(f"\n密钥(hex):{key_hex}") - print("使用方法:python wechat_decryptor.py --key <上面的密钥> --db-dir --output ./decrypted/") + print(tr(f"\n密钥(hex):{key_hex}", f"\nKey (hex): {key_hex}")) + print(tr("使用方法:python wechat_decryptor.py --key <上面的密钥> --db-dir --output ./decrypted/", "Usage: python wechat_decryptor.py --key --db-dir --output ./decrypted/")) return - # Step 2: 测试密钥 if args.test_db: - print(f"正在验证密钥...") + print(tr("正在验证密钥...", "Validating key...")) if test_key(args.test_db, key_hex): - print("✓ 密钥正确") + print(tr("✓ 密钥正确", "✓ Key is valid")) else: - print("✗ 密钥错误或文件格式不支持") + print(tr("✗ 密钥错误或文件格式不支持", "✗ Invalid key or unsupported file format")) return - # Step 3: 确定要解密的文件列表 db_files = [] if args.db: db_files = [args.db] elif args.db_dir: db_files = find_db_files(args.db_dir) if not db_files: - print(f"错误:在 {args.db_dir} 下未找到数据库文件", file=sys.stderr) + print(tr(f"错误:在 {args.db_dir} 下未找到数据库文件", f"Error: no database files found under {args.db_dir}"), file=sys.stderr) sys.exit(1) - print(f"找到 {len(db_files)} 个数据库文件") + print(tr(f"找到 {len(db_files)} 个数据库文件", f"Found {len(db_files)} database files")) else: - # 自动查找微信数据目录 data_dir = get_wechat_data_dir() if not data_dir: - print("错误:未找到微信数据目录,请手动指定 --db-dir", file=sys.stderr) + print(tr("错误:未找到微信数据目录,请手动指定 --db-dir", "Error: WeChat data directory not found. Please provide --db-dir."), file=sys.stderr) sys.exit(1) - print(f"微信数据目录:{data_dir}") + print(tr(f"微信数据目录:{data_dir}", f"WeChat data directory: {data_dir}")) wxid_dirs = find_wxid_dirs(data_dir) if not wxid_dirs: - print(f"错误:在 {data_dir} 下未找到账号目录,请手动指定 --db-dir", file=sys.stderr) + print(tr(f"错误:在 {data_dir} 下未找到账号目录,请手动指定 --db-dir", f"Error: no account directory found under {data_dir}. Please provide --db-dir."), file=sys.stderr) sys.exit(1) if len(wxid_dirs) > 1: - print("找到多个账号:") + print(tr("找到多个账号:", "Multiple accounts found:")) for i, d in enumerate(wxid_dirs): print(f" [{i}] {d.name}") - choice = int(input("请选择账号序号:")) + choice = int(input(tr("请选择账号序号:", "Select account index: "))) wxid_dir = wxid_dirs[choice] else: wxid_dir = wxid_dirs[0] msg_dir = find_msg_dir(wxid_dir) db_files = find_db_files(str(msg_dir)) - print(f"账号目录:{wxid_dir.name},找到 {len(db_files)} 个数据库") + print(tr(f"账号目录:{wxid_dir.name},找到 {len(db_files)} 个数据库", f"Account directory: {wxid_dir.name}, found {len(db_files)} databases")) - # Step 4: 解密 output_dir = Path(args.output) output_dir.mkdir(parents=True, exist_ok=True) @@ -619,17 +692,17 @@ def main(): for db_path in db_files: db_name = Path(db_path).name out_path = str(output_dir / db_name) - print(f"解密 {db_name}...", end=" ", flush=True) + print(tr(f"解密 {db_name}...", f"Decrypting {db_name}..."), end=" ", flush=True) if decrypt_db(db_path, key_hex, out_path): print("✓") success_count += 1 else: - print("✗ 失败(密钥可能不匹配)") + print(tr("✗ 失败(密钥可能不匹配)", "✗ Failed (key may not match)")) - print(f"\n完成:{success_count}/{len(db_files)} 个文件解密成功") - print(f"解密文件保存在:{output_dir.absolute()}") - print(f"\n下一步:运行 wechat_parser.py 提取聊天记录") - print(f" python wechat_parser.py --db-dir {output_dir.absolute()} --target \"TA的微信名\" --output messages.txt") + print(tr(f"\n完成:{success_count}/{len(db_files)} 个文件解密成功", f"\nDone: {success_count}/{len(db_files)} files decrypted successfully")) + print(tr(f"解密文件保存在:{output_dir.absolute()}", f"Decrypted files saved to: {output_dir.absolute()}")) + print(tr("\n下一步:运行 wechat_parser.py 提取聊天记录", "\nNext: run wechat_parser.py to extract messages")) + print(tr(f" python wechat_parser.py --db-dir {output_dir.absolute()} --target \"TA的微信名\" --output messages.txt", f" python wechat_parser.py --db-dir {output_dir.absolute()} --target \"contact_name\" --output messages.txt")) if __name__ == "__main__": diff --git a/tools/wechat_parser.py b/tools/wechat_parser.py index dc6a5f9..dd19418 100644 --- a/tools/wechat_parser.py +++ b/tools/wechat_parser.py @@ -1,33 +1,33 @@ #!/usr/bin/env python3 """ -聊天记录解析器(微信 + iMessage) +Chat parser (WeChat + iMessage). -支持平台: - - 微信 PC 端(Windows):解密后的 MSG*.db - - iMessage(macOS):~/Library/Messages/chat.db +Supported platforms: + - WeChat Desktop (Windows): decrypted MSG*.db files + - iMessage (macOS): ~/Library/Messages/chat.db -用法: - # 微信 — 从解密后的 db 目录提取 - python wechat_parser.py --db-dir ./decrypted/ --target "柳智敏" --output messages.txt +Usage: + # WeChat - extract from a decrypted DB directory + python wechat_parser.py --db-dir ./decrypted/ --target "contact_name" --output messages.txt - # 微信 — 列出所有联系人 - python wechat_parser.py --db-dir ./decrypted/ --list-contacts + # WeChat - list all contacts + python wechat_parser.py --db-dir ./decrypted/ --list-contacts - # iMessage — 从 macOS chat.db 提取 - python wechat_parser.py --imessage --db ~/Library/Messages/chat.db \ - --target "+1xxxxxxxxxx" --output messages.txt + # iMessage - extract from macOS chat.db + python wechat_parser.py --imessage --db ~/Library/Messages/chat.db \ + --target "+1xxxxxxxxxx" --output messages.txt - # iMessage — 列出所有 iMessage 联系人 - python wechat_parser.py --imessage --db ~/Library/Messages/chat.db --list-contacts + # iMessage - list all iMessage contacts + python wechat_parser.py --imessage --db ~/Library/Messages/chat.db --list-contacts - # 从导出的文本文件解析(通用) - python wechat_parser.py --txt ./chat_export.txt --target "柳智敏" --output messages.txt + # Parse an exported text file (generic) + python wechat_parser.py --txt ./chat_export.txt --target "contact_name" --output messages.txt -依赖: - pip install sqlite3(标准库,无需额外安装) +Dependencies: + sqlite3 (Python standard library, no extra install needed) -iMessage 授权: - macOS 需要在「系统偏好设置 → 隐私 → 完全磁盘访问权限」中添加终端/Python +iMessage access note: + On macOS, grant Full Disk Access to your terminal/Python process. """ import sqlite3 @@ -37,11 +37,26 @@ import argparse from pathlib import Path from datetime import datetime +from typing import Optional + + +CLI_LANG = "zh" + + +def normalize_language(language: Optional[str]) -> str: + value = (language or "").strip().lower() + if value in {"en", "english"}: + return "en" + return "zh" + + +def tr(zh: str, en: str) -> str: + return en if CLI_LANG == "en" else zh -# ─── 微信 PC 数据库结构 ───────────────────────────────────────────────────────── +# ─── WeChat Desktop database structure ───────────────────────────────────────── -# MSG*.db 中的消息表结构(微信 3.x) +# Message table structure in MSG*.db (WeChat 3.x) MSG_QUERY = """ SELECT m.localId, @@ -55,11 +70,11 @@ LEFT JOIN Name2ID n ON n.UsrName = ( SELECT UsrName FROM Name2ID WHERE _id = m.TalkerId LIMIT 1 ) -WHERE m.Type = 1 -- 1 = 文本消息 +WHERE m.Type = 1 -- 1 = text message ORDER BY m.CreateTime ASC """ -# 更通用的查询(兼容不同版本) +# More generic query (compatible across versions) MSG_QUERY_SIMPLE = """ SELECT localId, @@ -72,7 +87,7 @@ ORDER BY CreateTime ASC """ -# MicroMsg.db 中的联系人表 +# Contacts table in MicroMsg.db CONTACT_QUERY = """ SELECT UserName, @@ -81,38 +96,38 @@ NickName, Type FROM Contact -WHERE Type != 4 -- 4 = 已删除 +WHERE Type != 4 -- 4 = deleted ORDER BY NickName """ -# ─── 数据库解析 ───────────────────────────────────────────────────────────────── +# ─── Database parsing ─────────────────────────────────────────────────────────── -def open_db(db_path: str) -> sqlite3.Connection | None: - """打开 SQLite 数据库(只读)""" +def open_db(db_path: str) -> Optional[sqlite3.Connection]: + """Open a SQLite database in read-only mode.""" try: conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) conn.row_factory = sqlite3.Row - # 快速验证 + # Quick sanity check conn.execute("SELECT name FROM sqlite_master LIMIT 1") return conn except sqlite3.DatabaseError as e: - print(f"无法打开数据库 {db_path}:{e}", file=sys.stderr) - print("请确认数据库已解密(运行 wechat_decryptor.py)", file=sys.stderr) + print(tr(f"无法打开数据库 {db_path}:{e}", f"Cannot open database {db_path}: {e}"), file=sys.stderr) + print(tr("请确认数据库已解密(运行 wechat_decryptor.py)", "Please make sure the database is decrypted first (run wechat_decryptor.py)."), file=sys.stderr) return None def get_tables(conn: sqlite3.Connection) -> list[str]: - """获取数据库中所有表名""" + """Get all table names in a database.""" rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() return [row[0] for row in rows] def list_contacts(db_dir: str) -> list[dict]: - """列出所有联系人(从 MicroMsg.db)""" + """List all contacts from MicroMsg.db.""" micro_db = Path(db_dir) / "MicroMsg.db" if not micro_db.exists(): - print(f"未找到 MicroMsg.db,尝试从消息数据库推断联系人...", file=sys.stderr) + print(tr("未找到 MicroMsg.db,尝试从消息数据库推断联系人...", "MicroMsg.db not found, trying to infer contacts from message databases..."), file=sys.stderr) return [] conn = open_db(str(micro_db)) @@ -130,18 +145,18 @@ def list_contacts(db_dir: str) -> list[dict]: }) return contacts except Exception as e: - print(f"读取联系人失败:{e}", file=sys.stderr) + print(tr(f"读取联系人失败:{e}", f"Failed to read contacts: {e}"), file=sys.stderr) return [] finally: conn.close() -def find_contact_wxid(db_dir: str, target_name: str) -> str | None: - """根据名称(微信名/备注名/wxid)找到 wxid""" +def find_contact_wxid(db_dir: str, target_name: str) -> Optional[str]: + """Find a contact wxid by name (nickname/remark/wxid).""" contacts = list_contacts(db_dir) target_lower = target_name.lower() - # 精确匹配 + # Exact match for c in contacts: if (target_lower == c["wxid"].lower() or target_lower == c["remark"].lower() or @@ -149,19 +164,22 @@ def find_contact_wxid(db_dir: str, target_name: str) -> str | None: target_lower == c["alias"].lower()): return c["wxid"] - # 模糊匹配 + # Fuzzy match for c in contacts: if (target_lower in c["wxid"].lower() or target_lower in c["remark"].lower() or target_lower in c["nickname"].lower()): - print(f"模糊匹配到联系人:{c['remark'] or c['nickname']} ({c['wxid']})") + print(tr( + f"模糊匹配到联系人:{c['remark'] or c['nickname']} ({c['wxid']})", + f"Fuzzy matched contact: {c['remark'] or c['nickname']} ({c['wxid']})", + )) return c["wxid"] return None -def extract_messages_from_db(db_path: str, target_wxid: str | None = None) -> list[dict]: - """从单个 MSG*.db 提取消息""" +def extract_messages_from_db(db_path: str, target_wxid: Optional[str] = None) -> list[dict]: + """Extract messages from a single MSG*.db file.""" conn = open_db(db_path) if not conn: return [] @@ -173,7 +191,7 @@ def extract_messages_from_db(db_path: str, target_wxid: str | None = None) -> li if "MSG" not in tables: return [] - # 尝试带 TalkerId 的完整查询 + # Try the full query with TalkerId join first try: if "Name2ID" in tables: rows = conn.execute(""" @@ -200,11 +218,11 @@ def extract_messages_from_db(db_path: str, target_wxid: str | None = None) -> li if isinstance(row, sqlite3.Row): row = dict(row) - # 过滤目标联系人(精确相等,不做子串匹配) + # Filter by the target contact (exact match only) talker = row.get("talker_wxid") or "" if target_wxid: if not talker: - # Name2ID 关联失败,talker 为空,无法过滤,跳过此条 + # Name2ID join failed; cannot filter this row reliably. continue if talker != target_wxid: continue @@ -213,11 +231,11 @@ def extract_messages_from_db(db_path: str, target_wxid: str | None = None) -> li if not content.strip(): continue - # 跳过系统消息 + # Skip system/media placeholder messages if content.strip() in ["[图片]", "[语音]", "[文件]", "[视频]", "[撤回了一条消息]", ""]: continue - # 过滤 XML 富文本(分享链接、小程序等),提取文字 + # For XML-rich content (shares/miniprograms), extract readable text. if content.strip().startswith("<"): content = _extract_text_from_xml(content) if not content: @@ -237,7 +255,7 @@ def extract_messages_from_db(db_path: str, target_wxid: str | None = None) -> li }) except Exception as e: - print(f"读取消息失败 ({db_path}):{e}", file=sys.stderr) + print(tr(f"读取消息失败 ({db_path}):{e}", f"Failed to read messages ({db_path}): {e}"), file=sys.stderr) finally: conn.close() @@ -245,30 +263,30 @@ def extract_messages_from_db(db_path: str, target_wxid: str | None = None) -> li def _extract_text_from_xml(xml_content: str) -> str: - """从微信 XML 富文本消息中提取可读文字""" - # 提取 标签内容 + """Extract readable text from WeChat XML-rich messages.""" + # Extract <title> text m = re.search(r"<title[^>]*>([^<]+)", xml_content) if m: - return f"[分享] {m.group(1).strip()}" - # 提取 标签内容 + return tr(f"[分享] {m.group(1).strip()}", f"[Share] {m.group(1).strip()}") + # Extract text m = re.search(r"]*>([^<]+)", xml_content) if m: - return f"[分享] {m.group(1).strip()}" + return tr(f"[分享] {m.group(1).strip()}", f"[Share] {m.group(1).strip()}") return "" -def extract_messages_from_dir(db_dir: str, target_wxid: str | None = None) -> list[dict]: - """从目录中所有 MSG*.db 提取消息,合并并按时间排序""" +def extract_messages_from_dir(db_dir: str, target_wxid: Optional[str] = None) -> list[dict]: + """Extract messages from all MSG*.db files in a directory and sort by time.""" db_dir = Path(db_dir) all_messages = [] - # 查找 MSG*.db 文件 + # Find MSG*.db files db_files = [] for i in range(20): p = db_dir / f"MSG{i}.db" if p.exists(): db_files.append(p) - # 也检查 Multi 子目录 + # Also scan the Multi subdirectory multi_dir = db_dir / "Multi" if multi_dir.exists(): for i in range(20): @@ -277,31 +295,31 @@ def extract_messages_from_dir(db_dir: str, target_wxid: str | None = None) -> li db_files.append(p) if not db_files: - print(f"在 {db_dir} 下未找到 MSG*.db 文件", file=sys.stderr) + print(tr(f"在 {db_dir} 下未找到 MSG*.db 文件", f"No MSG*.db files found under {db_dir}"), file=sys.stderr) return [] for db_file in db_files: msgs = extract_messages_from_db(str(db_file), target_wxid) all_messages.extend(msgs) - print(f" {db_file.name}: {len(msgs)} 条消息") + print(tr(f" {db_file.name}: {len(msgs)} 条消息", f" {db_file.name}: {len(msgs)} messages")) - # 按时间排序 + # Sort by timestamp all_messages.sort(key=lambda x: x["timestamp"]) return all_messages def parse_txt_export(file_path: str, target_name: str) -> list[dict]: - """解析手动导出的文本格式(兼容多种格式)""" + """Parse manually exported text chat files across common formats.""" messages = [] with open(file_path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() - # 尝试匹配格式:2024-01-01 10:00 发送人:消息 + # Format: 2024-01-01 10:00 Sender: Message pattern_datetime_sender = re.compile( r"^(?P