mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
fix(chat): tolerate invalid avatar URLs
This commit is contained in:
@@ -182,6 +182,8 @@ MCP 仅暴露读取数据与获取媒体资源 URL/参数的能力;系统设
|
|||||||
- `wechat.biz`: 公众号/服务号与微信支付记录
|
- `wechat.biz`: 公众号/服务号与微信支付记录
|
||||||
- `wechat.analytics`: 年度总结与聚合分析读取;年度总结只读取应用内已生成的缓存,未生成时请先在应用内打开年度总结
|
- `wechat.analytics`: 年度总结与聚合分析读取;年度总结只读取应用内已生成的缓存,未生成时请先在应用内打开年度总结
|
||||||
|
|
||||||
|
会话列表、联系人和头像相关接口均采用 best-effort 读取策略。即使 `contact.db` 中某些头像字段损坏或无法按 UTF-8 解码,也会继续返回昵称、会话摘要和其他可用内容,头像则自动降级为空或占位,不会阻塞整页数据加载。
|
||||||
|
|
||||||
媒体和视频不会直接塞进 MCP JSON 响应;相关工具返回可访问 URL 或资源参数。
|
媒体和视频不会直接塞进 MCP JSON 响应;相关工具返回可访问 URL 或资源参数。
|
||||||
|
|
||||||
配套 skill 可通过 HTTP 加载,访问时需要带 MCP token:
|
配套 skill 可通过 HTTP 加载,访问时需要带 MCP token:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wechat-data-analysis-desktop",
|
"name": "wechat-data-analysis-desktop",
|
||||||
"version": "1.9.1",
|
"version": "1.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wechat-data-analysis-desktop",
|
"name": "wechat-data-analysis-desktop",
|
||||||
"version": "1.9.1",
|
"version": "1.9.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.7.3"
|
"electron-updater": "^6.7.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wechat-data-analysis-desktop",
|
"name": "wechat-data-analysis-desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.9.1",
|
"version": "1.9.2",
|
||||||
"main": "src/main.cjs",
|
"main": "src/main.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev.cjs",
|
"dev": "node scripts/dev.cjs",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "wechat-decrypt-tool"
|
name = "wechat-decrypt-tool"
|
||||||
version = "1.9.1"
|
version = "1.9.2"
|
||||||
description = "Modern WeChat database decryption tool with React frontend"
|
description = "Modern WeChat database decryption tool with React frontend"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""微信数据库解密工具
|
"""微信数据库解密工具
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "1.9.1"
|
__version__ = "1.9.2"
|
||||||
__author__ = "WeChat Decrypt Tool"
|
__author__ = "WeChat Decrypt Tool"
|
||||||
|
|||||||
@@ -1992,45 +1992,93 @@ def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> di
|
|||||||
return previews
|
return previews
|
||||||
|
|
||||||
|
|
||||||
def _pick_display_name(contact_row: Optional[sqlite3.Row], fallback_username: str) -> str:
|
def _row_get_value(row: Any, key: str, default: Any = None) -> Any:
|
||||||
|
if row is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return row[key]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if isinstance(row, dict):
|
||||||
|
return row.get(key, default)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_contact_text(value: Any) -> str:
|
||||||
|
return _decode_sqlite_text(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_avatar_url(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, memoryview):
|
||||||
|
value = value.tobytes()
|
||||||
|
if isinstance(value, (bytes, bytearray)):
|
||||||
|
raw = bytes(value)
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
# Avatar URLs should be ASCII/UTF-8 HTTP(S) URLs. If invalid bytes were
|
||||||
|
# stored in the TEXT column, ignore that avatar instead of failing the
|
||||||
|
# surrounding chat/contact response.
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
text = str(value or "")
|
||||||
|
|
||||||
|
text = text.strip()
|
||||||
|
if text.lower().startswith(("http://", "https://")):
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_row_to_dict(row: Any) -> dict[str, Any]:
|
||||||
|
username = _normalize_contact_text(_row_get_value(row, "username", ""))
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"remark": _normalize_contact_text(_row_get_value(row, "remark", "")),
|
||||||
|
"nick_name": _normalize_contact_text(_row_get_value(row, "nick_name", "")),
|
||||||
|
"alias": _normalize_contact_text(_row_get_value(row, "alias", "")),
|
||||||
|
"big_head_url": _normalize_avatar_url(_row_get_value(row, "big_head_url", "")),
|
||||||
|
"small_head_url": _normalize_avatar_url(_row_get_value(row, "small_head_url", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_display_name(contact_row: Optional[Any], fallback_username: str) -> str:
|
||||||
if contact_row is None:
|
if contact_row is None:
|
||||||
return fallback_username
|
return fallback_username
|
||||||
|
|
||||||
for key in ("remark", "nick_name", "alias"):
|
for key in ("remark", "nick_name", "alias"):
|
||||||
try:
|
v = _normalize_contact_text(_row_get_value(contact_row, key, ""))
|
||||||
v = contact_row[key]
|
if v:
|
||||||
except Exception:
|
return v
|
||||||
v = None
|
|
||||||
if isinstance(v, str) and v.strip():
|
|
||||||
return v.strip()
|
|
||||||
|
|
||||||
return fallback_username
|
return fallback_username
|
||||||
|
|
||||||
|
|
||||||
def _pick_avatar_url(contact_row: Optional[sqlite3.Row]) -> Optional[str]:
|
def _pick_avatar_url(contact_row: Optional[Any]) -> Optional[str]:
|
||||||
if contact_row is None:
|
if contact_row is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for key in ("big_head_url", "small_head_url"):
|
for key in ("big_head_url", "small_head_url"):
|
||||||
try:
|
v = _normalize_avatar_url(_row_get_value(contact_row, key, ""))
|
||||||
v = contact_row[key]
|
if v:
|
||||||
except Exception:
|
return v
|
||||||
v = None
|
|
||||||
if isinstance(v, str) and v.strip():
|
|
||||||
return v.strip()
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str, sqlite3.Row]:
|
def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str, dict[str, Any]]:
|
||||||
uniq = list(dict.fromkeys([u for u in usernames if u]))
|
uniq = list(dict.fromkeys([u for u in usernames if u]))
|
||||||
if not uniq:
|
if not uniq:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
result: dict[str, sqlite3.Row] = {}
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
conn = sqlite3.connect(str(contact_db_path))
|
conn = sqlite3.connect(str(contact_db_path))
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.text_factory = bytes
|
||||||
try:
|
try:
|
||||||
def query_table(table: str, targets: list[str]) -> None:
|
def query_table(table: str, targets: list[str]) -> None:
|
||||||
if not targets:
|
if not targets:
|
||||||
@@ -2043,7 +2091,10 @@ def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str,
|
|||||||
"""
|
"""
|
||||||
rows = conn.execute(sql, targets).fetchall()
|
rows = conn.execute(sql, targets).fetchall()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
result[r["username"]] = r
|
item = _contact_row_to_dict(r)
|
||||||
|
username = str(item.get("username") or "").strip()
|
||||||
|
if username:
|
||||||
|
result[username] = item
|
||||||
|
|
||||||
query_table("contact", uniq)
|
query_table("contact", uniq)
|
||||||
missing = [u for u in uniq if u not in result]
|
missing = [u for u in uniq if u not in result]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import sqlite3
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
@@ -97,7 +98,101 @@ class TestChatSessionsRealtimeSenderPreview(unittest.TestCase):
|
|||||||
self.assertEqual(len(sessions), 1)
|
self.assertEqual(len(sessions), 1)
|
||||||
self.assertEqual(sessions[0].get("lastMessage"), "群名片B: https://example.com/x")
|
self.assertEqual(sessions[0].get("lastMessage"), "群名片B: https://example.com/x")
|
||||||
|
|
||||||
|
def test_sessions_ignore_invalid_utf8_avatar_url(self):
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
account_dir = Path(td) / "acc"
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
session_conn = sqlite3.connect(str(account_dir / "session.db"))
|
||||||
|
try:
|
||||||
|
session_conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE SessionTable (
|
||||||
|
username TEXT,
|
||||||
|
unread_count INTEGER,
|
||||||
|
is_hidden INTEGER,
|
||||||
|
summary TEXT,
|
||||||
|
draft TEXT,
|
||||||
|
last_timestamp INTEGER,
|
||||||
|
sort_timestamp INTEGER,
|
||||||
|
last_msg_locald_id INTEGER,
|
||||||
|
last_msg_type INTEGER,
|
||||||
|
last_msg_sub_type INTEGER,
|
||||||
|
last_msg_sender TEXT,
|
||||||
|
last_sender_display_name TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
session_conn.execute(
|
||||||
|
"INSERT INTO SessionTable VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("wxid_bad_avatar", 0, 0, "hello", "", 100, 100, 1, 1, 0, "", ""),
|
||||||
|
)
|
||||||
|
session_conn.commit()
|
||||||
|
finally:
|
||||||
|
session_conn.close()
|
||||||
|
|
||||||
|
contact_conn = sqlite3.connect(str(account_dir / "contact.db"))
|
||||||
|
try:
|
||||||
|
contact_conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE contact (
|
||||||
|
username TEXT,
|
||||||
|
remark TEXT,
|
||||||
|
nick_name TEXT,
|
||||||
|
alias TEXT,
|
||||||
|
flag INTEGER,
|
||||||
|
big_head_url TEXT,
|
||||||
|
small_head_url TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
contact_conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE stranger (
|
||||||
|
username TEXT,
|
||||||
|
remark TEXT,
|
||||||
|
nick_name TEXT,
|
||||||
|
alias TEXT,
|
||||||
|
flag INTEGER,
|
||||||
|
big_head_url TEXT,
|
||||||
|
small_head_url TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
contact_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contact
|
||||||
|
(username, remark, nick_name, alias, flag, big_head_url, small_head_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?, CAST(x'fffe687474703a2f2f6578616d706c652e746573742f612e706e67' AS TEXT), ?)
|
||||||
|
""",
|
||||||
|
("wxid_bad_avatar", "", "坏头像好友", "", 0, ""),
|
||||||
|
)
|
||||||
|
contact_conn.commit()
|
||||||
|
finally:
|
||||||
|
contact_conn.close()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
|
||||||
|
patch.object(chat_router.WCDB_REALTIME, "get_status", return_value={}),
|
||||||
|
patch.object(chat_router, "load_session_last_messages", return_value={}),
|
||||||
|
patch.object(chat_router, "_load_latest_message_previews", return_value={}),
|
||||||
|
):
|
||||||
|
resp = chat_router.list_chat_sessions(
|
||||||
|
_DummyRequest(),
|
||||||
|
account="acc",
|
||||||
|
limit=50,
|
||||||
|
include_hidden=True,
|
||||||
|
include_official=True,
|
||||||
|
preview="session",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resp.get("status"), "success")
|
||||||
|
sessions = resp.get("sessions") or []
|
||||||
|
self.assertEqual(len(sessions), 1)
|
||||||
|
self.assertEqual(sessions[0].get("name"), "坏头像好友")
|
||||||
|
self.assertEqual(sessions[0].get("lastMessage"), "hello")
|
||||||
|
self.assertIn("/api/chat/avatar", sessions[0].get("avatar") or "")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
@@ -872,7 +872,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wechat-decrypt-tool"
|
name = "wechat-decrypt-tool"
|
||||||
version = "1.9.1"
|
version = "1.9.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
|
|||||||
Reference in New Issue
Block a user