Files
kefu/wechat_rpa/archive_content_parser.py
2026-08-27 14:04:28 +08:00

294 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""归档消息中短文本 protobuf 的保守解析器。"""
from __future__ import annotations
import hashlib
import re
from typing import Any
_HEX_RE = re.compile(r"^[0-9a-fA-F]+$")
_TEXT_TYPES = {"text", "文本"}
_MAX_HEX_CHARS = 256 * 1024
def _read_varint(data: bytes, offset: int) -> tuple[int, int] | None:
value = 0
shift = 0
for index in range(10):
if offset >= len(data):
return None
current = data[offset]
offset += 1
value |= (current & 0x7F) << shift
if current < 0x80:
return value, offset
shift += 7
return None
def _readable_utf8(data: bytes) -> str:
if not data:
return ""
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return ""
if "\x00" in text or "\ufffd" in text:
return ""
if not all(character.isprintable() or character in "\r\n\t" for character in text):
return ""
return text.strip()
def _protobuf_texts(
data: bytes, *, depth: int = 0, max_depth: int = 8
) -> list[tuple[int, str]]:
if not data or depth > max_depth:
return []
offset = 0
result: list[tuple[int, str]] = []
parsed_fields = 0
while offset < len(data):
key_result = _read_varint(data, offset)
if key_result is None:
return []
key, offset = key_result
field_number = key >> 3
wire_type = key & 7
if field_number <= 0:
return []
parsed_fields += 1
if wire_type == 0:
value_result = _read_varint(data, offset)
if value_result is None:
return []
_, offset = value_result
elif wire_type == 1:
offset += 8
elif wire_type == 2:
length_result = _read_varint(data, offset)
if length_result is None:
return []
length, offset = length_result
end = offset + length
if length < 0 or end > len(data):
return []
value = data[offset:end]
offset = end
readable = _readable_utf8(value)
if readable:
result.append((depth, readable))
result.extend(_protobuf_texts(value, depth=depth + 1, max_depth=max_depth))
elif wire_type == 5:
offset += 4
else:
return []
if offset > len(data):
return []
return result if parsed_fields else []
def decode_hex_protobuf_text(value: Any, message_type: Any) -> str:
"""从十六进制 protobuf 中提取文本,只对明确的文本消息生效。"""
if str(message_type or "").strip().casefold() not in _TEXT_TYPES:
return ""
raw = str(value or "").strip()
if not raw or len(raw) > _MAX_HEX_CHARS:
return ""
decoded_parts: list[str] = []
for raw_part in raw.split("|"):
part = re.sub(r"\s+", "", raw_part)
if not part or len(part) % 2 or not _HEX_RE.fullmatch(part):
return ""
try:
data = bytes.fromhex(part)
except ValueError:
return ""
candidates = _protobuf_texts(data)
if not candidates:
return ""
deepest = max(depth for depth, _ in candidates)
texts = [text for depth, text in candidates if depth == deepest]
for text in texts:
if text and text not in decoded_parts:
decoded_parts.append(text)
return " | ".join(decoded_parts)
def _protobuf_fields(data: bytes) -> dict[int, list[tuple[int, Any]]]:
fields: dict[int, list[tuple[int, Any]]] = {}
offset = 0
while offset < len(data):
key_result = _read_varint(data, offset)
if key_result is None:
return {}
key, offset = key_result
field_number = key >> 3
wire_type = key & 7
if field_number <= 0:
return {}
if wire_type == 0:
value_result = _read_varint(data, offset)
if value_result is None:
return {}
value, offset = value_result
elif wire_type == 1:
end = offset + 8
if end > len(data):
return {}
value = data[offset:end]
offset = end
elif wire_type == 2:
length_result = _read_varint(data, offset)
if length_result is None:
return {}
length, offset = length_result
end = offset + length
if end > len(data):
return {}
value = data[offset:end]
offset = end
elif wire_type == 5:
end = offset + 4
if end > len(data):
return {}
value = data[offset:end]
offset = end
else:
return {}
fields.setdefault(field_number, []).append((wire_type, value))
return fields
def parse_file_message_metadata(value: Any, content_type: Any = None) -> dict[str, Any]:
"""解析企业微信 content_type=20 的文件卡片,不暴露远端令牌。"""
try:
if int(content_type) != 20:
return {}
except (TypeError, ValueError):
return {}
if isinstance(value, memoryview):
value = value.tobytes()
if not isinstance(value, bytes) or not value:
return {}
fields = _protobuf_fields(value)
filename_values = fields.get(2) or []
size_values = fields.get(4) or []
checksum_values = fields.get(10) or []
if not filename_values or not size_values:
return {}
filename = _readable_utf8(filename_values[0][1])
filename = re.split(r"[\\/]", filename)[-1].strip()[:512]
if not filename or "." not in filename or any(ord(char) < 32 for char in filename):
return {}
size_bytes = int(size_values[0][1]) if size_values[0][0] == 0 else 0
if size_bytes <= 0:
return {}
checksum = ""
if checksum_values and checksum_values[0][0] == 2:
checksum = _readable_utf8(checksum_values[0][1]).upper()
if not re.fullmatch(r"[0-9A-F]{32,64}", checksum):
checksum = ""
remote_values = fields.get(1) or []
reference_hash = ""
if remote_values and remote_values[0][0] == 2:
reference_hash = hashlib.sha256(remote_values[0][1]).hexdigest()
return {
"original_filename": filename,
"size_bytes": size_bytes,
"checksum": checksum,
"media_type": "file",
"source_reference_sha256": reference_hash,
}
def file_message_content(metadata: dict[str, Any], *, cached: bool) -> str:
filename = str(metadata.get("original_filename") or "未命名文件")
size = int(metadata.get("size_bytes") or 0)
if size >= 1024 * 1024 * 1024:
size_label = f"{size / (1024 * 1024 * 1024):.2f} GB"
elif size >= 1024 * 1024:
size_label = f"{size / (1024 * 1024):.2f} MB"
elif size >= 1024:
size_label = f"{size / 1024:.1f} KB"
else:
size_label = f"{size} B"
availability = "" if cached else ",源文件未缓存"
return f"[文件] {filename}{size_label}{availability}"
def _protobuf_text_field(
fields: dict[int, list[tuple[int, Any]]], field_number: int
) -> str:
values = fields.get(field_number) or []
if not values or values[0][0] != 2:
return ""
return _readable_utf8(values[0][1])
def parse_mini_program_metadata(
value: Any, content_type: Any = None
) -> dict[str, Any]:
"""解析企业微信 content_type=78 的小程序卡片。"""
try:
if int(content_type) != 78:
return {}
except (TypeError, ValueError):
return {}
if isinstance(value, memoryview):
value = value.tobytes()
if not isinstance(value, bytes) or not value:
return {}
top_fields = _protobuf_fields(value)
candidate: dict[int, list[tuple[int, Any]]] = {}
for values in top_fields.values():
for wire_type, nested_value in values:
if wire_type != 2 or not isinstance(nested_value, bytes):
continue
nested = _protobuf_fields(nested_value)
app_id = _protobuf_text_field(nested, 2)
page_path = _protobuf_text_field(nested, 3)
if app_id.startswith("wx") and page_path:
candidate = nested
break
if candidate:
break
if not candidate:
return {}
title = (
_protobuf_text_field(candidate, 7)
or _protobuf_text_field(candidate, 8)
or _protobuf_text_field(top_fields, 3)
)
app_name = _protobuf_text_field(candidate, 10)
page_path = _protobuf_text_field(candidate, 3)
if not title or not page_path:
return {}
return {
"title": title[:512],
"app_name": app_name[:512],
"app_id": _protobuf_text_field(candidate, 2)[:128],
"username": _protobuf_text_field(candidate, 1)[:255],
"page_path": page_path[:2048],
"cover_url": (
_protobuf_text_field(candidate, 6)
or _protobuf_text_field(top_fields, 2)
)[:4096],
}
def mini_program_content(metadata: dict[str, Any]) -> str:
lines = [f"[小程序] {str(metadata.get('title') or '未命名小程序')}"]
if metadata.get("app_name"):
lines.append(f"应用:{metadata['app_name']}")
if metadata.get("app_id"):
lines.append(f"AppID{metadata['app_id']}")
if metadata.get("page_path"):
lines.append(f"页面:{metadata['page_path']}")
return "\n".join(lines)