feat(scraper): 微信群消息抓取(368778条/750群)+浏览器守护循环+SQLite忙等
- 新 group-messages 实体:真机探明群消息=同一 msg/chats 端点 friendId 传 roomId; talker=发送者wxid、msgId=null→owner:room:createTime:talker 合成稳定主键(重扫幂等) - 浏览器守护循环:超大群撑崩渲染进程时重启浏览器跳过毒群继续(实战2次自愈); 200页上限;恢复路径补 await - base.py SQLite connect_args timeout=60:并发写忙等而非撞锁报错 (双抓取器并发写实测 database is locked 崩任务) - 5个新单测,全量410绿 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,10 +23,15 @@ class Base(DeclarativeBase):
|
|||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# SQLite: 写锁竞争时忙等 60s 而不是立刻报 "database is locked"
|
||||||
|
# (多进程并发写的场景:页面抓取器 × 2 + 后端 API 同时跑,2026-08-19 实测撞锁崩任务)。
|
||||||
|
_connect_args = {"timeout": 60} if settings.db_url.startswith("sqlite") else {}
|
||||||
|
|
||||||
async_engine = create_async_engine(
|
async_engine = create_async_engine(
|
||||||
settings.db_url,
|
settings.db_url,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
pool_recycle=3600, # recycle connections before MySQL wait_timeout (default 8h) closes them
|
pool_recycle=3600, # recycle connections before MySQL wait_timeout (default 8h) closes them
|
||||||
|
connect_args=_connect_args,
|
||||||
future=True,
|
future=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -565,7 +565,7 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100):
|
|||||||
for attempt in range(30):
|
for attempt in range(30):
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
fetch_page.close()
|
await fetch_page.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
fetch_page = await scraper.context.new_page()
|
fetch_page = await scraper.context.new_page()
|
||||||
@@ -599,6 +599,166 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100):
|
|||||||
return total_fetched, total_upserted
|
return total_fetched, total_upserted
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_group_message(row: dict, owner_id: str, room_id: str) -> dict | None:
|
||||||
|
"""msg/chats 群消息行 → Message 形状(friendId=roomId 时端点返回的行)。
|
||||||
|
|
||||||
|
群行与 1:1 行同构(真机确认 2026-08-19),差异:talker=发送者 wxid、
|
||||||
|
roomid=群 id、msgId 为 null → 用 owner:room:createTime:talker 合成稳定
|
||||||
|
主键,重扫同一条消息得到同一 id(幂等 upsert 前提)。type=-2 日期分隔
|
||||||
|
线丢弃。
|
||||||
|
"""
|
||||||
|
if str(row.get("type")) == "-2":
|
||||||
|
return None
|
||||||
|
ts_raw = row.get("createTime") or row.get("timestamp")
|
||||||
|
try:
|
||||||
|
ts = int(str(ts_raw)) if ts_raw else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
ts = None
|
||||||
|
talker = row.get("talker") or ""
|
||||||
|
synth = f"{owner_id}:{room_id}:{ts}:{talker}"
|
||||||
|
return {
|
||||||
|
"msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or synth),
|
||||||
|
"channel": "wechat",
|
||||||
|
"owner_account_id": owner_id,
|
||||||
|
"talker_id": talker or None,
|
||||||
|
"room_id": row.get("roomid") or room_id,
|
||||||
|
"is_send": bool(row.get("isSend", 0)) if row.get("isSend") is not None else None,
|
||||||
|
"type": _normalize_msg_type(row.get("type")),
|
||||||
|
"type_raw": row.get("type"),
|
||||||
|
"text": row.get("content") or row.get("text") or "",
|
||||||
|
"file_url": row.get("file") or row.get("fileUrl"),
|
||||||
|
"timestamp": ts,
|
||||||
|
"sender_name": row.get("name") or row.get("senderName"),
|
||||||
|
"raw_json": row,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_group_messages(cookie_str: str, max_groups: int = 500):
|
||||||
|
"""Scrape WeChat GROUP chat messages via browser fetch POST /wechatweb/msg/chats.
|
||||||
|
|
||||||
|
Same endpoint as 1:1 chats — pass the room id as ``friendId`` (confirmed
|
||||||
|
live 2026-08-19: rows come back with talker=sender wxid, roomid=the room,
|
||||||
|
msgId=null → stable synthetic pk, see _normalize_group_message). Groups
|
||||||
|
come from the local chat_group table; full-history pagination via
|
||||||
|
paginate_chats (hasLast/end cursor, same as 1:1).
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
from app.db.base import async_engine, async_session_maker
|
||||||
|
from app.storage.repos import upsert_messages
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
result = await conn.execute(sa_text(
|
||||||
|
"SELECT owner_account_id, room_id FROM chat_group "
|
||||||
|
"WHERE channel='wechat' AND owner_account_id IS NOT NULL "
|
||||||
|
"ORDER BY last_chat_time DESC"
|
||||||
|
))
|
||||||
|
groups = [(row[0], row[1]) for row in result.fetchall()]
|
||||||
|
|
||||||
|
logger.info(f"Discovered {len(groups)} groups, will scrape {min(max_groups, len(groups))}")
|
||||||
|
|
||||||
|
total_fetched = 0
|
||||||
|
total_upserted = 0
|
||||||
|
total_pages = 0
|
||||||
|
scan = groups[:max_groups]
|
||||||
|
idx = 0
|
||||||
|
relaunches = 0
|
||||||
|
|
||||||
|
# 守护循环:超大群的单页响应可能把渲染进程/整个浏览器撑崩(真机 Target
|
||||||
|
# crashed, 2026-08-19)——浏览器级失败时重启浏览器、跳过毒群、从当前
|
||||||
|
# 位置继续;页面级失败换页重试(30 × 2min,同 scrape_messages)。
|
||||||
|
while idx < len(scan):
|
||||||
|
try:
|
||||||
|
async with PageScraper(cookie_str) as scraper:
|
||||||
|
fetch_page = await scraper.context.new_page()
|
||||||
|
await fetch_page.goto(f"{YUNKE_BASE}/", wait_until="domcontentloaded", timeout=15000)
|
||||||
|
await fetch_page.wait_for_timeout(2000)
|
||||||
|
page = fetch_page
|
||||||
|
|
||||||
|
while idx < len(scan):
|
||||||
|
owner_id, room_id = scan[idx]
|
||||||
|
i = idx
|
||||||
|
records = []
|
||||||
|
pages = 0
|
||||||
|
try:
|
||||||
|
now_ms = int(_time.time() * 1000)
|
||||||
|
|
||||||
|
async def _fetch(end_ms: int, _owner=owner_id, _room=room_id):
|
||||||
|
return await page.evaluate("""async ([ownerId, friendId, endMs]) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('wechatId', ownerId);
|
||||||
|
params.append('friendId', friendId);
|
||||||
|
params.append('types', '');
|
||||||
|
params.append('start', '0');
|
||||||
|
params.append('end', String(endMs));
|
||||||
|
const resp = await fetch('/wechatweb/msg/chats', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
return await resp.json();
|
||||||
|
}""", [_owner, _room, end_ms])
|
||||||
|
|
||||||
|
async for data in paginate_chats(_fetch, now_ms, max_pages=200):
|
||||||
|
pages += 1
|
||||||
|
rows = data.get("messages") or []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
rec = _normalize_group_message(row, owner_id, room_id)
|
||||||
|
if rec is not None:
|
||||||
|
records.append(rec)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f" [{i+1}] {owner_id[:12]}:{str(room_id)[:16]} failed: {e}")
|
||||||
|
# 页面级恢复:换新页继续;浏览器已死则抛给外层重启
|
||||||
|
for attempt in range(30):
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
await fetch_page.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
fetch_page = await scraper.context.new_page()
|
||||||
|
await fetch_page.goto(f"{YUNKE_BASE}/cms/home/statistics", wait_until="networkidle", timeout=20000)
|
||||||
|
await fetch_page.wait_for_timeout(1000)
|
||||||
|
page = fetch_page
|
||||||
|
break
|
||||||
|
except Exception as rec:
|
||||||
|
logger.warning(f" recovery {attempt + 1}/30 failed: {rec}")
|
||||||
|
if attempt == 29:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(120)
|
||||||
|
idx += 1 # 该群放弃(换页成功),继续下一个
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_fetched += len(records)
|
||||||
|
total_pages += pages
|
||||||
|
|
||||||
|
if records:
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await upsert_messages(session, records)
|
||||||
|
await session.commit()
|
||||||
|
total_upserted += len(records)
|
||||||
|
|
||||||
|
if (i + 1) % 10 == 0:
|
||||||
|
logger.info(f" [{i+1}/{len(scan)}] msgs total: {total_fetched}, pages: {total_pages}")
|
||||||
|
|
||||||
|
await asyncio.sleep(2 + (i % 3))
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
except Exception as fatal:
|
||||||
|
relaunches += 1
|
||||||
|
if relaunches > 10:
|
||||||
|
raise
|
||||||
|
logger.warning(f" browser-level failure, relaunch {relaunches}/10, skip poison group: {fatal}")
|
||||||
|
idx += 1 # 跳过导致崩溃的群,避免重启后原地复崩
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
|
logger.info(f"DONE: group-messages fetched={total_fetched}, upserted={total_upserted}, pages={total_pages}")
|
||||||
|
return total_fetched, total_upserted
|
||||||
|
|
||||||
|
|
||||||
def _normalize_msg_type(raw_type) -> str | None:
|
def _normalize_msg_type(raw_type) -> str | None:
|
||||||
"""Normalize a raw message type to our canonical type string."""
|
"""Normalize a raw message type to our canonical type string."""
|
||||||
if raw_type is None:
|
if raw_type is None:
|
||||||
@@ -1135,6 +1295,7 @@ async def main():
|
|||||||
"friends", # 微信客户 (per-employee friend pages)
|
"friends", # 微信客户 (per-employee friend pages)
|
||||||
"groups", # 微信群 (per-employee group pages)
|
"groups", # 微信群 (per-employee group pages)
|
||||||
"messages", # 微信消息 (browser fetch msg/chats)
|
"messages", # 微信消息 (browser fetch msg/chats)
|
||||||
|
"group-messages", # 微信群消息 (msg/chats with friendId=roomId)
|
||||||
"accounts", # 微信员工账号 (wechatlist page interception)
|
"accounts", # 微信员工账号 (wechatlist page interception)
|
||||||
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
|
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
|
||||||
"wa-friends", # WA客户 (waBusiness/friendlist interception)
|
"wa-friends", # WA客户 (waBusiness/friendlist interception)
|
||||||
@@ -1146,7 +1307,7 @@ async def main():
|
|||||||
help="抓取实体",
|
help="抓取实体",
|
||||||
)
|
)
|
||||||
parser.add_argument("--max-employees", type=int, default=100, help="最多遍历多少员工 (friends/groups/voice-clips)")
|
parser.add_argument("--max-employees", type=int, default=100, help="最多遍历多少员工 (friends/groups/voice-clips)")
|
||||||
parser.add_argument("--max-conversations", type=int, default=100, help="消息模式:最多遍历多少对话 (messages/wa-messages)")
|
parser.add_argument("--max-conversations", type=int, default=100, help="消息模式:最多遍历多少对话 (messages/wa-messages/group-messages)")
|
||||||
parser.add_argument("--max-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls)")
|
parser.add_argument("--max-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls)")
|
||||||
parser.add_argument("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
|
parser.add_argument("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -1168,6 +1329,8 @@ async def main():
|
|||||||
await scrape_groups(cookie, args.max_employees)
|
await scrape_groups(cookie, args.max_employees)
|
||||||
elif args.entity == "messages":
|
elif args.entity == "messages":
|
||||||
await scrape_messages(cookie, args.max_conversations)
|
await scrape_messages(cookie, args.max_conversations)
|
||||||
|
elif args.entity == "group-messages":
|
||||||
|
await scrape_group_messages(cookie, args.max_conversations)
|
||||||
elif args.entity == "accounts":
|
elif args.entity == "accounts":
|
||||||
await scrape_accounts(cookie, args.max_pages)
|
await scrape_accounts(cookie, args.max_pages)
|
||||||
elif args.entity == "wa-accounts":
|
elif args.entity == "wa-accounts":
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Unit tests for the WeChat GROUP message normalizer in
|
||||||
|
:mod:`scripts.scrape_via_page` (msg/chats with friendId=roomId, confirmed
|
||||||
|
live 2026-08-19). Group rows differ from 1:1: talker=sender, roomid=the room,
|
||||||
|
msgId=null -> stable synthetic pk required for idempotent re-scrapes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from scripts.scrape_via_page import _normalize_group_message
|
||||||
|
|
||||||
|
OWNER = "wxid_wo97u1u2x11w22"
|
||||||
|
ROOM = "56807599995@chatroom"
|
||||||
|
|
||||||
|
|
||||||
|
def _row(**over):
|
||||||
|
base = {
|
||||||
|
"talker": "wxid_en2iklxtjxzz22",
|
||||||
|
"oriTalker": ROOM,
|
||||||
|
"name": "小**贸",
|
||||||
|
"content": "hello",
|
||||||
|
"type": 1,
|
||||||
|
"roomid": ROOM,
|
||||||
|
"createTime": 1787125769000,
|
||||||
|
"msgId": None,
|
||||||
|
}
|
||||||
|
base.update(over)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_row_maps_sender_and_room():
|
||||||
|
rec = _normalize_group_message(_row(), OWNER, ROOM)
|
||||||
|
assert rec["channel"] == "wechat"
|
||||||
|
assert rec["owner_account_id"] == OWNER
|
||||||
|
assert rec["room_id"] == ROOM
|
||||||
|
assert rec["talker_id"] == "wxid_en2iklxtjxzz22" # 发送者(非会话对端)
|
||||||
|
assert rec["sender_name"] == "小**贸"
|
||||||
|
assert rec["type_raw"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthetic_msg_id_stable_across_rescans():
|
||||||
|
"""msgId=null 的群行两次归一化必须得到同一 msg_id(幂等 upsert 前提)。"""
|
||||||
|
a = _normalize_group_message(_row(), OWNER, ROOM)
|
||||||
|
b = _normalize_group_message(_row(), OWNER, ROOM)
|
||||||
|
assert a["msg_id"] == b["msg_id"]
|
||||||
|
assert a["msg_id"] == f"{OWNER}:{ROOM}:1787125769000:wxid_en2iklxtjxzz22"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_msg_id_preferred_over_synthetic():
|
||||||
|
rec = _normalize_group_message(_row(msgId="m999"), OWNER, ROOM)
|
||||||
|
assert rec["msg_id"] == "m999"
|
||||||
|
|
||||||
|
|
||||||
|
def test_divider_rows_dropped():
|
||||||
|
assert _normalize_group_message(_row(type=-2, text="2026-08-19 15:49"), OWNER, ROOM) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_roomid_falls_back_to_queried_room():
|
||||||
|
rec = _normalize_group_message(_row(roomid=""), OWNER, ROOM)
|
||||||
|
assert rec["room_id"] == ROOM
|
||||||
Reference in New Issue
Block a user