feat(scraper): 页面抓取器支持消息(导航聊天窗口拦截msg/chats API)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-14 15:04:21 +08:00
parent bead7fdf43
commit c6217ab1a9
+104 -1
View File
@@ -36,6 +36,7 @@ INTERCEPT_PATHS = {
"call_list": "/wechatweb/callRecord/queryWeChatVoiceList", # voice calls
"wa_friend": "/qqweb/WhatsAppUser/businessFriendList", # WA friends + groups
"wa_user": "/qqweb/WhatsAppUser/businessSalesList", # WA accounts
"msg_chats": "/wechatweb/msg/chats", # chat messages
}
@@ -354,10 +355,110 @@ 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).
Navigates to each conversation's chat window. The SPA calls /wechatweb/msg/chats
to load messages — we intercept that response.
"""
from app.db.base import async_session_maker, async_engine
from sqlalchemy import text as sa_text
from app.storage.repos import upsert_messages
# Discover conversations from local customer table
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT DISTINCT owner_account_id, friend_id FROM customer "
"WHERE channel='wechat' AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
"ORDER BY owner_account_id, friend_id"
))
conversations = [(row[0], row[1]) for row in result.fetchall()]
logger.info(f"Discovered {len(conversations)} conversations, will scrape {min(max_conversations, len(conversations))}")
total_fetched = 0
total_upserted = 0
async with PageScraper(cookie_str) as scraper:
for i, (owner_id, friend_id) in enumerate(conversations[:max_conversations]):
# Chat window URL: opens this specific conversation
chat_url = f"{YUNKE_BASE}/cms/wechat/salewechat/message/wechat-window/1/{owner_id}/{friend_id}"
# Intercept the message API response
scraper.start_capture(INTERCEPT_PATHS["msg_chats"])
try:
await scraper.navigate_and_wait(chat_url, wait_selector=".yk-chat", timeout=10000)
await scraper.page.wait_for_timeout(3000) # let the message API complete
except Exception as e:
logger.warning(f" [{i+1}] {owner_id[:12]}:{friend_id[:12]} nav failed: {e}")
continue
responses = scraper.get_captured()
# Extract message records
records = []
for resp in responses:
body = resp.get("body", {})
data = body.get("data", {})
rows = data.get("data", []) or []
for row in rows:
if not isinstance(row, dict):
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,
"talker_id": row.get("talker") or row.get("oriTalker") or friend_id,
"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)
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}/{min(max_conversations, len(conversations))}] msgs total: {total_fetched}")
# Human-like delay
await asyncio.sleep(2 + (i % 3))
logger.info(f"DONE: messages fetched={total_fetched}, upserted={total_upserted}")
return total_fetched, total_upserted
def _normalize_msg_type(raw_type) -> str | None:
"""Normalize a raw message type to our canonical type string."""
if raw_type is None:
return None
t = str(raw_type)
mapping = {"1": "text", "3": "image_legacy", "34": "voice", "43": "video", "47": "emoji", "49": "file"}
return mapping.get(t, "unknown")
async def main():
parser = argparse.ArgumentParser(description="页面浏览器抓取器")
parser.add_argument("--entity", required=True, choices=["friends", "groups"], help="抓取实体")
parser.add_argument("--entity", required=True, choices=["friends", "groups", "messages"], help="抓取实体")
parser.add_argument("--max-employees", type=int, default=100, help="最多遍历多少员工")
parser.add_argument("--max-conversations", type=int, default=100, help="消息模式:最多遍历多少对话")
args = parser.parse_args()
cookie = os.environ.get("YK_COOKIE", "")
@@ -375,6 +476,8 @@ async def main():
await scrape_friends(cookie, args.max_employees)
elif args.entity == "groups":
await scrape_groups(cookie, args.max_employees)
elif args.entity == "messages":
await scrape_messages(cookie, args.max_conversations)
if __name__ == "__main__":