From 1c8f59a5282656f3bc5e1365f9c0f6ec717d9b86 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Sun, 15 Feb 2026 14:33:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(contacts):=20=E8=81=94=E7=B3=BB=E4=BA=BA?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8B=BC=E9=9F=B3=E5=88=86=E7=BB=84=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=80=A7=E5=88=AB/=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 解析 extra_buffer 补齐 gender/signature\n- 返回 pinyinKey/pinyinInitial,前端按 A-Z/# 分组排序展示\n- tests: 更新联系人导出用例覆盖新增字段 --- frontend/pages/contacts.vue | 95 +++++++++++++++---- .../routers/chat_contacts.py | 87 +++++++++++++++++ tests/test_contacts_export.py | 17 +++- 3 files changed, 177 insertions(+), 22 deletions(-) diff --git a/frontend/pages/contacts.vue b/frontend/pages/contacts.vue index 165c1db..9059632 100644 --- a/frontend/pages/contacts.vue +++ b/frontend/pages/contacts.vue @@ -46,29 +46,34 @@
{{ error }}
暂无联系人
-
-
- -
{{ contact.displayName?.charAt(0) || '?' }}
+
+
+ {{ group.key }}
-
-
{{ contact.displayName }}
-
{{ contact.username }}
-
- 地区:{{ contact.region }} - · - 来源:{{ contact.source }} +
+
+ +
{{ contact.displayName?.charAt(0) || '?' }}
+
+
+
{{ contact.displayName }}
+
{{ contact.username }}
+
+ 地区:{{ contact.region }} + · + 来源:{{ contact.source }} +
+
+
+ {{ typeLabel(contact.type) }}
-
-
- {{ typeLabel(contact.type) }}
@@ -184,6 +189,54 @@ const typeBadgeClass = (type) => { return 'bg-gray-100 text-gray-600' } +const normalizeContactGroupKey = (value) => { + const key = String(value || '').trim().toUpperCase() + if (key.length === 1 && key >= 'A' && key <= 'Z') return key + return '#' +} + +const buildContactSortKey = (contact) => { + const pinyinKey = String(contact?.pinyinKey || '').trim().toLowerCase() + if (pinyinKey) return pinyinKey + const nameKey = String(contact?.displayName || '').trim().toLowerCase() + if (nameKey) return nameKey + return String(contact?.username || '').trim().toLowerCase() +} + +const groupedContacts = computed(() => { + const list = Array.isArray(contacts.value) ? contacts.value : [] + const rows = list.map((contact) => { + return { + contact, + groupKey: normalizeContactGroupKey(contact?.pinyinInitial), + sortKey: buildContactSortKey(contact), + usernameKey: String(contact?.username || '').trim().toLowerCase(), + } + }) + + rows.sort((a, b) => { + if (a.groupKey !== b.groupKey) { + if (a.groupKey === '#') return 1 + if (b.groupKey === '#') return -1 + return a.groupKey.localeCompare(b.groupKey) + } + const cmpKey = a.sortKey.localeCompare(b.sortKey) + if (cmpKey !== 0) return cmpKey + return a.usernameKey.localeCompare(b.usernameKey) + }) + + const groups = [] + for (const row of rows) { + const last = groups[groups.length - 1] + if (!last || last.key !== row.groupKey) { + groups.push({ key: row.groupKey, items: [row.contact] }) + } else { + last.items.push(row.contact) + } + } + return groups +}) + const isDesktopExportRuntime = () => { return !!(process.client && window?.wechatDesktop?.chooseDirectory) } diff --git a/src/wechat_decrypt_tool/routers/chat_contacts.py b/src/wechat_decrypt_tool/routers/chat_contacts.py index 3c94d2a..e6e86ec 100644 --- a/src/wechat_decrypt_tool/routers/chat_contacts.py +++ b/src/wechat_decrypt_tool/routers/chat_contacts.py @@ -3,10 +3,12 @@ import json import re import sqlite3 from datetime import datetime, timezone +from functools import lru_cache from pathlib import Path from typing import Any, Literal, Optional from fastapi import APIRouter, HTTPException, Request +from pypinyin import Style, lazy_pinyin from pydantic import BaseModel, Field from ..chat_helpers import ( @@ -96,6 +98,76 @@ def _to_optional_int(v: Any) -> Optional[int]: return None +_PINYIN_CLEAN_RE = re.compile(r"[^a-z0-9]+") +_PINYIN_ALPHA_RE = re.compile(r"[A-Za-z]") + +# 多音字姓氏:pypinyin 对单字默认读音不一定是姓氏读音(例如:曾= ceng / zeng)。 +# 这里在“姓名首字”场景优先采用常见姓氏读音,用于联系人列表的分组/排序。 +_SURNAME_PINYIN_OVERRIDES: dict[str, str] = { + "曾": "zeng", + "区": "ou", + "仇": "qiu", + "解": "xie", + "单": "shan", + "查": "zha", + "乐": "yue", + "朴": "piao", + "盖": "ge", + "缪": "miao", +} + + +@lru_cache(maxsize=4096) +def _build_contact_pinyin_key(name: str) -> str: + text = _normalize_text(name) + if not text: + return "" + + # Keep non-CJK segments so English names can be sorted/grouped as expected. + first = text[0] + override = _SURNAME_PINYIN_OVERRIDES.get(first) + if override: + rest = text[1:] + parts = [override] + if rest: + parts.extend(lazy_pinyin(rest, style=Style.NORMAL, errors="default")) + else: + parts = lazy_pinyin(text, style=Style.NORMAL, errors="default") + out: list[str] = [] + for part in parts: + cleaned = _PINYIN_CLEAN_RE.sub("", _normalize_text(part).lower()) + if cleaned: + out.append(cleaned) + return "".join(out) + + +@lru_cache(maxsize=4096) +def _build_contact_pinyin_initial(name: str) -> str: + text = _normalize_text(name).lstrip() + if not text: + return "#" + + first = text[0] + if "A" <= first <= "Z": + return first + if "a" <= first <= "z": + return first.upper() + + override = _SURNAME_PINYIN_OVERRIDES.get(first) + if override: + return override[0].upper() + + # For CJK, try to convert the first character to pinyin initial. + parts = lazy_pinyin(first, style=Style.NORMAL, errors="ignore") + if parts: + m = _PINYIN_ALPHA_RE.search(parts[0]) + if m: + return m.group(0).upper() + + # Emoji / digits / symbols, etc. + return "#" + + def _decode_varint(raw: bytes, offset: int) -> tuple[Optional[int], int]: value = 0 shift = 0 @@ -125,6 +197,7 @@ def _decode_proto_text(raw: bytes) -> str: def _parse_contact_extra_buffer(extra_buffer: Any) -> dict[str, Any]: out = { + "gender": 0, "signature": "", "country": "", "province": "", @@ -160,6 +233,9 @@ def _parse_contact_extra_buffer(extra_buffer: Any) -> dict[str, Any]: if val is None: break idx = idx_next + if field_no == 2: + # 性别: 1=男, 2=女, 0=未知 + out["gender"] = int(val) if field_no == 8: out["source_scene"] = int(val) continue @@ -327,6 +403,8 @@ def _load_contact_rows_map(contact_db_path: Path) -> dict[str, dict[str, Any]]: "verify_flag": _to_int(row["verify_flag"] if "verify_flag" in row.keys() else 0), "big_head_url": _normalize_text(row["big_head_url"] if "big_head_url" in row.keys() else ""), "small_head_url": _normalize_text(row["small_head_url"] if "small_head_url" in row.keys() else ""), + "gender": _to_int(extra_info.get("gender")), + "signature": _normalize_text(extra_info.get("signature")), "country": _normalize_text(extra_info.get("country")), "province": _normalize_text(extra_info.get("province")), "city": _normalize_text(extra_info.get("city")), @@ -481,6 +559,8 @@ def _collect_contacts_for_account( province = _normalize_text(row.get("province")) city = _normalize_text(row.get("city")) source_scene = _to_optional_int(row.get("source_scene")) + gender = _to_int(row.get("gender")) + signature = _normalize_text(row.get("signature")) item = { "username": username, @@ -488,6 +568,8 @@ def _collect_contacts_for_account( "remark": _normalize_text(row.get("remark")), "nickname": _normalize_text(row.get("nick_name")), "alias": _normalize_text(row.get("alias")), + "gender": gender, + "signature": signature, "type": contact_type, "country": country, "province": province, @@ -520,6 +602,8 @@ def _collect_contacts_for_account( "remark": "", "nickname": "", "alias": "", + "gender": 0, + "signature": "", "type": "group", "country": "", "province": "", @@ -545,6 +629,9 @@ def _collect_contacts_for_account( ) for item in contacts: item.pop("_sortTs", None) + name_for_pinyin = _normalize_text(item.get("displayName")) or _normalize_text(item.get("username")) + item["pinyinKey"] = _build_contact_pinyin_key(name_for_pinyin) + item["pinyinInitial"] = _build_contact_pinyin_initial(name_for_pinyin) return contacts diff --git a/tests/test_contacts_export.py b/tests/test_contacts_export.py index 4b6dcb2..e773396 100644 --- a/tests/test_contacts_export.py +++ b/tests/test_contacts_export.py @@ -39,9 +39,20 @@ class TestContactsExport(unittest.TestCase): return cls._encode_varint(tag) + cls._encode_varint(int(value)) @classmethod - def _build_extra_buffer(cls, *, country: str, province: str, city: str, source_scene: int) -> bytes: + def _build_extra_buffer( + cls, + *, + country: str, + province: str, + city: str, + source_scene: int, + gender: int = 0, + signature: str = "", + ) -> bytes: return b"".join( [ + cls._encode_field_varint(2, gender), + cls._encode_field_len(4, signature.encode("utf-8")), cls._encode_field_len(5, country.encode("utf-8")), cls._encode_field_len(6, province.encode("utf-8")), cls._encode_field_len(7, city.encode("utf-8")), @@ -88,6 +99,8 @@ class TestContactsExport(unittest.TestCase): province="Sichuan", city="Chengdu", source_scene=14, + gender=1, + signature="自助者天助!!!", ) conn.execute( @@ -320,6 +333,8 @@ class TestContactsExport(unittest.TestCase): self.assertEqual(friend_contact.get("province"), "Sichuan") self.assertEqual(friend_contact.get("city"), "Chengdu") self.assertEqual(friend_contact.get("region"), "中国大陆·Sichuan·Chengdu") + self.assertEqual(friend_contact.get("gender"), 1) + self.assertEqual(friend_contact.get("signature"), "自助者天助!!!") self.assertEqual(friend_contact.get("sourceScene"), 14) self.assertEqual(friend_contact.get("source"), "通过群聊添加")