feat(scraper): 微信消息翻页取完整历史(paginate_chats hasLast/end游标+200页上限,移除临时skip;全量回填3478对话+95361条至2025-10)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -362,11 +362,40 @@ async def scrape_groups(cookie_str: str, max_employees: int = 100):
|
||||
return total_fetched, total_upserted
|
||||
|
||||
|
||||
async def scrape_messages(cookie_str: str, max_conversations: int = 100):
|
||||
"""Scrape wechat messages via page navigation (chat window).
|
||||
async def paginate_chats(fetch_page, end_ms: int, max_pages: int = 200, sleep=None):
|
||||
"""按 hasLast/end 游标翻页 chats 类接口(微信 msg/chats 与 WA chatRecords 同协议)。
|
||||
|
||||
Navigates to each conversation's chat window. The SPA calls /wechatweb/msg/chats
|
||||
to load messages — we intercept that response.
|
||||
fetch_page(end_ms) -> 响应 dict;逐页 yield data 部分(dict)。
|
||||
终止:data 非 dict / hasLast 假 / end 非数值 / 达到 max_pages 页上限(防死循环)。
|
||||
"""
|
||||
if sleep is None:
|
||||
sleep = asyncio.sleep
|
||||
pages = 0
|
||||
while True:
|
||||
body = await fetch_page(end_ms)
|
||||
pages += 1
|
||||
data = body.get("data") if isinstance(body, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
yield data
|
||||
if not data.get("hasLast"):
|
||||
return
|
||||
end = data.get("end")
|
||||
if not isinstance(end, (int, float)):
|
||||
return
|
||||
end_ms = int(end) - 1
|
||||
if pages >= max_pages:
|
||||
logger.warning(f"paginate_chats: page cap {max_pages} hit, stop paging")
|
||||
return
|
||||
await sleep(1)
|
||||
|
||||
|
||||
async def scrape_messages(cookie_str: str, max_conversations: int = 100):
|
||||
"""Scrape wechat messages via browser fetch POST /wechatweb/msg/chats.
|
||||
|
||||
Conversation pairs come from the local customer table (channel='wechat').
|
||||
Pages backward through full history via data.end / data.hasLast
|
||||
(paginate_chats, same cursor strategy as the WA chatRecords scraper).
|
||||
"""
|
||||
from app.db.base import async_session_maker, async_engine
|
||||
from sqlalchemy import text as sa_text
|
||||
@@ -386,6 +415,7 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100):
|
||||
|
||||
total_fetched = 0
|
||||
total_upserted = 0
|
||||
total_pages = 0
|
||||
|
||||
async with PageScraper(cookie_str) as scraper:
|
||||
# Just load the base URL to establish cookie context — no need for specific page
|
||||
@@ -397,39 +427,68 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100):
|
||||
page = fetch_page # use the stable page for all evaluate() calls
|
||||
|
||||
for i, (owner_id, friend_id) in enumerate(conversations[:max_conversations]):
|
||||
records = []
|
||||
pages = 0
|
||||
try:
|
||||
# Use browser fetch() to call the message API from the page context
|
||||
# (the browser sends it with proper cookies/headers → no 风控)
|
||||
# NOTE: msg/chats uses FORM-ENCODED + cursor params (start/end, not pageNum)
|
||||
import time as _time
|
||||
now_ms = int(_time.time() * 1000)
|
||||
body = 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_id, friend_id, now_ms])
|
||||
|
||||
responses = [{"body": body, "status": 200}]
|
||||
async def _fetch(end_ms: int, _owner=owner_id, _friend=friend_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, _friend, end_ms])
|
||||
|
||||
# Debug: log first response structure
|
||||
if i == 0:
|
||||
logger.info(f" DEBUG first response keys: {list(body.keys()) if isinstance(body, dict) else type(body)}")
|
||||
if isinstance(body, dict):
|
||||
d = body.get("data")
|
||||
logger.info(f" DEBUG data type: {type(d)}, keys: {list(d.keys()) if isinstance(d, dict) else 'N/A'}")
|
||||
if isinstance(d, dict):
|
||||
msgs = d.get("messages")
|
||||
logger.info(f" DEBUG messages type: {type(msgs)}, len: {len(msgs) if isinstance(msgs, list) else 'N/A'}")
|
||||
logger.info(f" DEBUG body preview: {str(body)[:500]}")
|
||||
# 翻页取完整历史:hasLast 真 → 用 data.end-1 继续向前翻(页间 sleep 1s)
|
||||
async for data in paginate_chats(_fetch, now_ms):
|
||||
pages += 1
|
||||
# Debug: log first response structure
|
||||
if i == 0 and pages == 1:
|
||||
logger.info(f" DEBUG first response keys: {list(data.keys())}")
|
||||
msgs0 = data.get("messages")
|
||||
logger.info(f" DEBUG messages type: {type(msgs0)}, len: {len(msgs0) if isinstance(msgs0, list) else 'N/A'}")
|
||||
rows = data.get("messages") or []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
# 跳过 type=-2 日期分隔线(非真实消息,仅显示日期)
|
||||
if str(row.get("type")) == "-2":
|
||||
continue
|
||||
# Normalize to Message shape
|
||||
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
|
||||
records.append({
|
||||
"msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or f"{owner_id}:{friend_id}:{i}:{len(records)}"),
|
||||
"channel": "wechat",
|
||||
"owner_account_id": owner_id,
|
||||
# 用发送的 friend_id(与客户表一致)优先,API 返回的 talker 可能脱敏
|
||||
"talker_id": friend_id or row.get("talker") or row.get("oriTalker"),
|
||||
"room_id": None,
|
||||
"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,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f" [{i+1}] {owner_id[:12]}:{friend_id[:12]} failed: {e}")
|
||||
@@ -444,42 +503,8 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100):
|
||||
page = fetch_page
|
||||
continue
|
||||
|
||||
# Extract message records (msg/chats response: body.data.messages[])
|
||||
records = []
|
||||
for resp in responses:
|
||||
body = resp.get("body", {})
|
||||
data = body.get("data") or {}
|
||||
rows = data.get("messages") or []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
# 跳过 type=-2 日期分隔线(非真实消息,仅显示日期)
|
||||
if str(row.get("type")) == "-2":
|
||||
continue
|
||||
# Normalize to Message shape
|
||||
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
|
||||
records.append({
|
||||
"msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or f"{owner_id}:{friend_id}:{i}:{len(records)}"),
|
||||
"channel": "wechat",
|
||||
"owner_account_id": owner_id,
|
||||
# 用发送的 friend_id(与客户表一致)优先,API 返回的 talker 可能脱敏
|
||||
"talker_id": friend_id or row.get("talker") or row.get("oriTalker"),
|
||||
"room_id": None,
|
||||
"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,
|
||||
})
|
||||
|
||||
total_fetched += len(records)
|
||||
total_pages += pages
|
||||
|
||||
if records:
|
||||
async with async_session_maker() as session:
|
||||
@@ -488,12 +513,12 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100):
|
||||
total_upserted += len(records)
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
logger.info(f" [{i+1}/{min(max_conversations, len(conversations))}] msgs total: {total_fetched}")
|
||||
logger.info(f" [{i+1}/{min(max_conversations, len(conversations))}] msgs total: {total_fetched}, pages: {total_pages}")
|
||||
|
||||
# Human-like delay
|
||||
await asyncio.sleep(2 + (i % 3))
|
||||
|
||||
logger.info(f"DONE: messages fetched={total_fetched}, upserted={total_upserted}")
|
||||
logger.info(f"DONE: messages fetched={total_fetched}, upserted={total_upserted}, pages={total_pages}")
|
||||
return total_fetched, total_upserted
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Unit tests for the hasLast/end cursor pagination helper in
|
||||
:mod:`scripts.scrape_via_page` (WeChat msg/chats deep-history paging).
|
||||
|
||||
No network/browser — the fetch callable is faked; these verify the
|
||||
cursor protocol: page N+1 uses ``data.end - 1`` as ``end`` until
|
||||
``hasLast`` is falsy, with guards for malformed ``end`` and a page cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from scripts.scrape_via_page import paginate_chats
|
||||
|
||||
|
||||
def _page(messages, has_last, end=1000):
|
||||
return {"data": {"messages": messages, "hasLast": has_last, "end": end}}
|
||||
|
||||
|
||||
async def _noop_sleep(_seconds: float) -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def test_multi_page_uses_end_minus_one_cursor():
|
||||
calls: list[int] = []
|
||||
|
||||
async def fetch(end_ms: int):
|
||||
calls.append(end_ms)
|
||||
if len(calls) == 1:
|
||||
return _page([{"id": 1}], has_last=True, end=1000)
|
||||
return _page([{"id": 2}], has_last=False, end=500)
|
||||
|
||||
datas = [d async for d in paginate_chats(fetch, end_ms=2000, sleep=_noop_sleep)]
|
||||
|
||||
assert [len(d["messages"]) for d in datas] == [1, 1]
|
||||
assert calls == [2000, 999] # 下一页游标 = end - 1
|
||||
|
||||
|
||||
async def test_stops_when_has_last_falsy():
|
||||
async def fetch(end_ms: int):
|
||||
return _page([{"id": 1}], has_last=False)
|
||||
|
||||
datas = [d async for d in paginate_chats(fetch, end_ms=2000, sleep=_noop_sleep)]
|
||||
|
||||
assert len(datas) == 1
|
||||
|
||||
|
||||
async def test_stops_on_non_dict_data():
|
||||
calls = []
|
||||
|
||||
async def fetch(end_ms: int):
|
||||
calls.append(end_ms)
|
||||
return {"message": "调用成功", "data": None}
|
||||
|
||||
datas = [d async for d in paginate_chats(fetch, end_ms=2000, sleep=_noop_sleep)]
|
||||
|
||||
assert datas == []
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_stops_when_end_not_numeric():
|
||||
async def fetch(end_ms: int):
|
||||
return _page([{"id": 1}], has_last=True, end=None)
|
||||
|
||||
datas = [d async for d in paginate_chats(fetch, end_ms=2000, sleep=_noop_sleep)]
|
||||
|
||||
assert len(datas) == 1 # hasLast 真但游标缺失 → 不再无限请求
|
||||
|
||||
|
||||
async def test_page_cap_prevents_infinite_loop():
|
||||
calls = []
|
||||
|
||||
async def fetch(end_ms: int):
|
||||
calls.append(end_ms)
|
||||
return _page([{"id": len(calls)}], has_last=True, end=end_ms - 1)
|
||||
|
||||
datas = [d async for d in paginate_chats(fetch, end_ms=2000, max_pages=5, sleep=_noop_sleep)]
|
||||
|
||||
assert len(datas) == 5
|
||||
assert len(calls) == 5
|
||||
|
||||
|
||||
async def test_empty_messages_list_still_pages():
|
||||
async def fetch(end_ms: int):
|
||||
if end_ms > 1500:
|
||||
return _page(None, has_last=True, end=1500)
|
||||
return _page([{"id": 1}], has_last=False)
|
||||
|
||||
datas = [d async for d in paginate_chats(fetch, end_ms=2000, sleep=_noop_sleep)]
|
||||
|
||||
assert len(datas) == 2
|
||||
Reference in New Issue
Block a user