Files
gitlab-instance-0a899031_pa…/app/services/mp_recommend.py
2026-04-02 05:26:11 -05:00

228 lines
7.2 KiB
Python
Raw 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.
"""
公众号助手com.tencent.mp「收到XXX的推荐」通知入库。
仅需 PocketBase 一个集合mp_recommend_events每个 mp_uid + recommender_key 一行,累计计数)。
- username Text 用户名(展示名,如从标题解析的 Eric
- cumulative_count Number 该用户的累计触发次数(同一 mp_uid + recommender_key 每次 webhook 写入时 = 旧值 + 1
- recommender_key Text 归一化键unicode 归一化 + 清洗 + casefold与 username 对应,便于筛选,建议建索引
- mp_uid Text 用户ID用于把同名推荐人按不同账号隔离统计
字段(在 PocketBase 中新建,名称一致):
- recommender_key Text
- username Text
- cumulative_count Number
- package_name Text
- title_line Text
- summary_line Text
- mp_uid Text
- device_time_str Text
- device_model Text
- forward_timestamp_ms Number 可选
- raw_content Text
- source_from Text
- client_ip Text
"""
from __future__ import annotations
import logging
import re
import unicodedata
from typing import Any
from urllib.parse import parse_qs
from .pb import get_pb_client
logger = logging.getLogger(__name__)
COL_EVENTS = "mp_recommend_events"
PACKAGE_MP = "com.tencent.mp"
_TITLE_REC = re.compile(r"收到(.+?)的推荐")
_UID_REC = re.compile(r"UID[:]\s*(\S+)")
def _pb_escape(s: str) -> str:
return (s or "").replace("\\", "\\\\").replace('"', '\\"')
def _normalize_username(raw: str) -> str:
"""
尽量稳定地把展示名归一化到可用于统计/过滤的形式。
- 兼容 emoji/颜文字/特殊字母:保留字符本身,不做 ASCII 限制
- 统一空白:压缩为单个空格
- Unicode 归一化NFKC把全角等兼容形态归一
- 大小写:仅在 key 上 casefold
"""
s = (raw or "")
s = unicodedata.normalize("NFKC", s)
s = s.replace("\u00A0", " ")
s = re.sub(r"\s+", " ", s).strip()
return s
def _username_to_key(username: str) -> str:
u = _normalize_username(username)
return u.casefold() if u else "unknown"
def parse_form_urlencoded_like(body: str) -> dict[str, str]:
q = parse_qs(body, keep_blank_values=True, strict_parsing=False)
return {
"from": (q.get("from") or [""])[0],
"content": (q.get("content") or [""])[0],
"timestamp": (q.get("timestamp") or [""])[0],
}
def parse_mp_recommend_content(content: str) -> dict[str, Any]:
text = (content or "").replace("\r\n", "\n").replace("\r", "\n")
lines = text.split("\n")
stripped = [ln.strip() for ln in lines]
package_name = stripped[0] if stripped else ""
title_line = stripped[1] if len(stripped) > 1 else ""
summary_line = stripped[2] if len(stripped) > 2 else ""
device_time_str = stripped[4] if len(stripped) > 4 else ""
device_model = stripped[5] if len(stripped) > 5 else ""
recommender_name = ""
m = _TITLE_REC.search(title_line or "")
if m:
recommender_name = (m.group(1) or "").strip()
mp_uid = ""
um = _UID_REC.search(text)
if um:
mp_uid = (um.group(1) or "").strip()
return {
"package_name": package_name,
"recommender_name": recommender_name,
"title_line": title_line,
"summary_line": summary_line,
"mp_uid": mp_uid,
"device_time_str": device_time_str,
"device_model": device_model,
"raw_content": text,
}
def _parse_forward_ts_ms(raw: str) -> int | None:
s = (raw or "").strip()
if not s.isdigit():
return None
try:
return int(s)
except ValueError:
return None
def persist_mp_recommend(
*,
source_from: str,
content: str,
timestamp_raw: str,
client_ip: str,
) -> dict[str, Any]:
"""按 mp_uid + recommender_key 聚合更新累计次数(存在则 +1不存在则创建"""
parsed = parse_mp_recommend_content(content)
forward_ts = _parse_forward_ts_ms(timestamp_raw)
username = _normalize_username(parsed.get("recommender_name") or "") or "unknown"
key = _username_to_key(username if username != "unknown" else "")
mp_uid = (parsed.get("mp_uid") or "").strip()
pb = get_pb_client()
events = pb.collection(COL_EVENTS)
filt_parts = [f'recommender_key = "{_pb_escape(key)}"']
if mp_uid:
filt_parts.append(f'mp_uid = "{_pb_escape(mp_uid)}"')
filt = " && ".join(filt_parts)
lst = events.get_list(1, 1, {"filter": filt, "sort": "-updated"})
items = list(getattr(lst, "items", []) or [])
existing = items[0] if items else None
old_count = 0
existing_id = None
if existing is not None:
existing_id = getattr(existing, "id", None)
try:
old_count = int(getattr(existing, "cumulative_count", 0) or 0)
except (TypeError, ValueError):
old_count = 0
cumulative_count = old_count + 1
event_payload: dict[str, Any] = {
"recommender_key": key,
"username": username,
"cumulative_count": cumulative_count,
"package_name": parsed["package_name"],
"title_line": parsed["title_line"],
"summary_line": parsed["summary_line"],
"mp_uid": mp_uid,
"device_time_str": parsed["device_time_str"],
"device_model": parsed["device_model"],
"raw_content": parsed["raw_content"],
"source_from": source_from,
"client_ip": client_ip or "",
}
if forward_ts is not None:
event_payload["forward_timestamp_ms"] = forward_ts
if existing_id:
rec = events.update(existing_id, event_payload)
else:
rec = events.create(event_payload)
event_id = getattr(rec, "id", None) or str(rec)
logger.info(
"[like] mp_recommend stored event_id=%s username=%s cumulative_count=%s",
event_id,
username,
cumulative_count,
)
return {
"event_id": event_id,
"recommender_key": key,
"username": username,
"cumulative_count": cumulative_count,
}
def is_mp_recommend_webhook_body(body_text: str, content_type: str) -> bool:
"""是否为公众号助手 SmsForwarder 表单(可尝试入库)。"""
low = (content_type or "").lower()
if "application/x-www-form-urlencoded" not in low:
return False
fields = parse_form_urlencoded_like(body_text)
if (fields.get("from") or "").strip() != PACKAGE_MP:
return False
return bool((fields.get("content") or "").strip())
def try_persist_from_like_body(
body_text: str,
content_type: str,
client_ip: str,
) -> dict[str, Any] | None:
"""若为公众号助手 urlencoded 通知则入库并返回摘要;否则返回 None。"""
low = (content_type or "").lower()
if "application/x-www-form-urlencoded" not in low:
return None
fields = parse_form_urlencoded_like(body_text)
source_from = (fields.get("from") or "").strip()
if source_from != PACKAGE_MP:
return None
content = fields.get("content") or ""
if not content.strip():
return None
return persist_mp_recommend(
source_from=source_from,
content=content,
timestamp_raw=fields.get("timestamp") or "",
client_ip=client_ip,
)