feat(scraper): 页面抓取器支持消息(导航聊天窗口拦截msg/chats API)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ INTERCEPT_PATHS = {
|
|||||||
"call_list": "/wechatweb/callRecord/queryWeChatVoiceList", # voice calls
|
"call_list": "/wechatweb/callRecord/queryWeChatVoiceList", # voice calls
|
||||||
"wa_friend": "/qqweb/WhatsAppUser/businessFriendList", # WA friends + groups
|
"wa_friend": "/qqweb/WhatsAppUser/businessFriendList", # WA friends + groups
|
||||||
"wa_user": "/qqweb/WhatsAppUser/businessSalesList", # WA accounts
|
"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
|
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():
|
async def main():
|
||||||
parser = argparse.ArgumentParser(description="页面浏览器抓取器")
|
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-employees", type=int, default=100, help="最多遍历多少员工")
|
||||||
|
parser.add_argument("--max-conversations", type=int, default=100, help="消息模式:最多遍历多少对话")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
cookie = os.environ.get("YK_COOKIE", "")
|
cookie = os.environ.get("YK_COOKIE", "")
|
||||||
@@ -375,6 +476,8 @@ async def main():
|
|||||||
await scrape_friends(cookie, args.max_employees)
|
await scrape_friends(cookie, args.max_employees)
|
||||||
elif args.entity == "groups":
|
elif args.entity == "groups":
|
||||||
await scrape_groups(cookie, args.max_employees)
|
await scrape_groups(cookie, args.max_employees)
|
||||||
|
elif args.entity == "messages":
|
||||||
|
await scrape_messages(cookie, args.max_conversations)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user