support wxgf emoji

This commit is contained in:
Yuxin Wu
2025-01-30 12:07:31 -08:00
parent c782325cfa
commit b271469b94
6 changed files with 67 additions and 28 deletions
-1
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: UTF-8 -*-
import os import os
import sys import sys
import argparse import argparse
+16 -8
View File
@@ -1,7 +1,6 @@
import os import os
from pathlib import Path from pathlib import Path
import logging import logging
import tempfile
import io import io
import requests import requests
import base64 import base64
@@ -10,6 +9,7 @@ from PIL import Image
import pickle import pickle
from Crypto.Cipher import AES from Crypto.Cipher import AES
from .wxgf import WxgfAndroidDecoder, is_wxgf_buffer
from .parser import WeChatDBParser from .parser import WeChatDBParser
from .common.textutil import md5 as get_md5_hex, get_file_b64, get_file_md5 from .common.textutil import md5 as get_md5_hex, get_file_b64, get_file_md5
@@ -23,21 +23,20 @@ def _get_aes_key(md5):
# ascii representation of the first half of md5 is used as aes key # ascii representation of the first half of md5 is used as aes key
assert len(md5) == 32 assert len(md5) == 32
return md5[:16].encode('ascii') return md5[:16].encode('ascii')
# ret = ""
# for ch in md5[:16]:
# ret += format(ord(ch), 'x')
# return ret
class EmojiReader: class EmojiReader:
def __init__(self, def __init__(self,
resource_dir: str, resource_dir: str,
parser: WeChatDBParser, parser: WeChatDBParser,
*,
wxgf_decoder: WxgfAndroidDecoder,
cache_file: str=None): cache_file: str=None):
""" """
Args: Args:
resource_dir: path to resource/ resource_dir: path to resource/
parser: Database parser parser: Database parser
wxgf_decoder: Wxgf image decoder
cache_file: a cache file to store emoji downloaded from URLs. cache_file: a cache file to store emoji downloaded from URLs.
default to a emoji.cache file under wechat-dump. default to a emoji.cache file under wechat-dump.
""" """
@@ -48,6 +47,7 @@ class EmojiReader:
# mapping from md5 to the (cdnurl, encrypturl, aeskey) # mapping from md5 to the (cdnurl, encrypturl, aeskey)
# columns in EmojiInfo table. # columns in EmojiInfo table.
self.cache_file = cache_file or DEFAULT_EMOJI_CACHE self.cache_file = cache_file or DEFAULT_EMOJI_CACHE
self.wxgf_decoder = wxgf_decoder
# cache stores md5 -> (base64str, format) # cache stores md5 -> (base64str, format)
if os.path.isfile(self.cache_file): if os.path.isfile(self.cache_file):
@@ -133,9 +133,14 @@ class EmojiReader:
content = self._decode_emoji(fname) content = self._decode_emoji(fname)
data_md5 = get_md5_hex(content) data_md5 = get_md5_hex(content)
if data_md5 != md5: if data_md5 != md5:
if content.startswith(b"wxgf"): if is_wxgf_buffer(content):
raise ValueError("Unsupported mysterious image format: wxgf") content = self.wxgf_decoder.decode_with_cache(fname, content)
raise ValueError("Decoded data mismatch md5!") if content is None:
if not self.wxgf_decoder.has_server():
logger.warning("wxgf decoder server is not provided. Cannot decode wxgf emojis.")
raise ValueError("Failed to decode wxgf file.")
else:
raise ValueError("Decoded data mismatch md5!")
im = Image.open(io.BytesIO(content)) im = Image.open(io.BytesIO(content))
return (base64.b64encode(content).decode('ascii'), im.format.lower()) return (base64.b64encode(content).decode('ascii'), im.format.lower())
except Exception as e: except Exception as e:
@@ -183,6 +188,9 @@ class EmojiReader:
try: try:
logger.info("Requesting encrypted emoji {} from {} ...".format(md5, encrypturl)) logger.info("Requesting encrypted emoji {} from {} ...".format(md5, encrypturl))
buf = requests.get(encrypturl).content buf = requests.get(encrypturl).content
if buf == b'':
logger.error(f"Failed to download emoji {md5}")
return None, None
aeskey = bytes.fromhex(aeskey) aeskey = bytes.fromhex(aeskey)
cipher = AES.new(aeskey, AES.MODE_CBC, iv=aeskey) cipher = AES.new(aeskey, AES.MODE_CBC, iv=aeskey)
decoded_buf = cipher.decrypt(buf) decoded_buf = cipher.decrypt(buf)
+30 -15
View File
@@ -3,6 +3,7 @@
import os import os
from collections import Counter from collections import Counter
from functools import lru_cache
import glob import glob
from pyquery import PyQuery from pyquery import PyQuery
import logging import logging
@@ -35,10 +36,19 @@ TEMPLATES_FILES = {TYPE_MSG: "TP_MSG",
TYPE_VIDEO_FILE: "TP_VIDEO_FILE", TYPE_VIDEO_FILE: "TP_VIDEO_FILE",
TYPE_QQMUSIC: "TP_QQMUSIC", TYPE_QQMUSIC: "TP_QQMUSIC",
} }
TEMPLATES = {
k: open(os.path.join(STATIC_PATH, '{}.html'.format(v))).read()
for k, v in TEMPLATES_FILES.items() @lru_cache()
} def get_template(name: str | int) -> str | None:
"""Return the html template given a file name or msg type."""
if isinstance(name, int):
name = TEMPLATES_FILES.get(name, None)
if name is None:
return None
html_path = os.path.join(STATIC_PATH, f"{name}.html")
with open(html_path) as f:
return f.read()
class HTMLRender(object): class HTMLRender(object):
def __init__(self, parser, res=None): def __init__(self, parser, res=None):
@@ -107,7 +117,7 @@ class HTMLRender(object):
format_dict['nickname'] = ' ' format_dict['nickname'] = ' '
def fallback(): def fallback():
template = TEMPLATES[TYPE_MSG] template = get_template(TYPE_MSG)
content = msg.msg_str() content = msg.msg_str()
content = self.smiley.replace_smileycode(content) content = self.smiley.replace_smileycode(content)
if not msg.known_type: if not msg.known_type:
@@ -115,7 +125,7 @@ class HTMLRender(object):
content = html.escape(content) content = html.escape(content)
return template.format(content=content, **format_dict) return template.format(content=content, **format_dict)
template = TEMPLATES.get(msg.type) template = get_template(msg.type)
if msg.type == TYPE_SPEAK: if msg.type == TYPE_SPEAK:
audio_str, duration = self.res.get_voice_mp3(msg.imgPath) audio_str, duration = self.res.get_voice_mp3(msg.imgPath)
format_dict['voice_duration'] = duration format_dict['voice_duration'] = duration
@@ -140,10 +150,13 @@ class HTMLRender(object):
jobj = json.loads(msg.msg_str()) jobj = json.loads(msg.msg_str())
content = f"{jobj['title']} - {jobj['singer']}" content = f"{jobj['title']} - {jobj['singer']}"
# imgPath was original THUMBNAIL_DIRPATH://th_xxxxxxxxx if msg.imgPath is not None:
imgpath = msg.imgPath.split('_')[-1] # imgPath was original THUMBNAIL_DIRPATH://th_xxxxxxxxx
img = self.res.get_img([imgpath]) imgpath = msg.imgPath.split('_')[-1]
format_dict['img'] = (img, 'jpeg') img = self.res.get_img([imgpath])
format_dict['img'] = (img, 'jpeg')
else:
template = get_template("TP_QQMUSIC_NOIMG")
return template.format(url=jobj['url'], content=content, **format_dict) return template.format(url=jobj['url'], content=content, **format_dict)
elif msg.type == TYPE_EMOJI or msg.type == TYPE_CUSTOM_EMOJI: elif msg.type == TYPE_EMOJI or msg.type == TYPE_CUSTOM_EMOJI:
if 'emoticonmd5' in msg.content: if 'emoticonmd5' in msg.content:
@@ -171,7 +184,12 @@ class HTMLRender(object):
return template.format(**format_dict) return template.format(**format_dict)
elif msg.type == TYPE_VIDEO_FILE: elif msg.type == TYPE_VIDEO_FILE:
video = self.res.get_video(msg.imgPath) video = self.res.get_video(msg.imgPath)
if video.endswith(".mp4"): if video is None:
logger.warning(f"Cannot find video {msg.imgPath} ({msg.createTime})")
# fallback
format_dict['content'] = f"VIDEO FILE {msg.imgPath}"
return get_template(TYPE_MSG).format(**format_dict)
elif video.endswith(".mp4"):
video_str = get_file_b64(video) video_str = get_file_b64(video)
format_dict["video_str"] = video_str format_dict["video_str"] = video_str
return template.format(**format_dict) return template.format(**format_dict)
@@ -179,10 +197,7 @@ class HTMLRender(object):
# only has thumbnail # only has thumbnail
image_str = get_file_b64(video) image_str = get_file_b64(video)
format_dict["img"] = (image_str, 'jpeg') format_dict["img"] = (image_str, 'jpeg')
return TEMPLATES[TYPE_IMG].format(**format_dict) return get_template(TYPE_IMG).format(**format_dict)
# fallback
format_dict['content'] = f"VIDEO FILE {msg.imgPath}"
return TEMPLATES_FILES[TYPE_MSG].format(**format_dict)
elif msg.type == TYPE_WX_VIDEO: elif msg.type == TYPE_WX_VIDEO:
# TODO: fetch video from resource # TODO: fetch video from resource
return fallback() return fallback()
+3 -4
View File
@@ -52,7 +52,7 @@ class Resource(object):
self.video_dir = os.path.join(res_dir, VIDEO_DIRNAME) self.video_dir = os.path.join(res_dir, VIDEO_DIRNAME)
self.avt_reader = AvatarReader(res_dir, avt_db) self.avt_reader = AvatarReader(res_dir, avt_db)
self.wxgf_decoder = WxgfAndroidDecoder(wxgf_server) self.wxgf_decoder = WxgfAndroidDecoder(wxgf_server)
self.emoji_reader = EmojiReader(res_dir, self.parser) self.emoji_reader = EmojiReader(res_dir, self.parser, wxgf_decoder=self.wxgf_decoder)
def _get_voice_filename(self, imgpath): def _get_voice_filename(self, imgpath):
fname = get_md5_hex(imgpath.encode('ascii')) fname = get_md5_hex(imgpath.encode('ascii'))
@@ -195,12 +195,11 @@ class Resource(object):
""" Returns: (b64 encoded img string, format) """ """ Returns: (b64 encoded img string, format) """
return self.emoji_reader.get_emoji(md5) return self.emoji_reader.get_emoji(md5)
def get_video(self, videoid): def get_video(self, videoid) -> str | None:
video_file = os.path.join(self.video_dir, videoid + ".mp4") video_file = os.path.join(self.video_dir, videoid + ".mp4")
video_thumbnail_file = os.path.join(self.video_dir, videoid + ".jpg") video_thumbnail_file = os.path.join(self.video_dir, videoid + ".jpg")
if os.path.exists(video_file): if os.path.exists(video_file):
return video_file return video_file
elif os.path.exists(video_thumbnail_file): elif os.path.exists(video_thumbnail_file):
return video_thumbnail_file return video_thumbnail_file
logger.warning(f"Cannot find video {videoid}") return None
return ""
+15
View File
@@ -0,0 +1,15 @@
<div class="chatItem {sender_label}">
<div class="chatItemContent">
<span class="avatar"></span>
<div class="cloud cloudText">
<div class="cloudPannel" title="{time}" {nickname}>
<div class="cloudBody">
<div class="cloudContent">
<pre style="white-space:pre-wrap">
QQ Music:<a href="{url}" target="_blank">{content}</a> </pre>
</div>
</div>
</div>
</div>
</div>
</div>
+3
View File
@@ -59,3 +59,6 @@ class WxgfAndroidDecoder:
def is_wxgf_file(fname): def is_wxgf_file(fname):
with open(fname, 'rb') as f: with open(fname, 'rb') as f:
return f.read(4) == WXGF_HEADER return f.read(4) == WXGF_HEADER
def is_wxgf_buffer(buf: bytes):
return buf[:4] == WXGF_HEADER