bcacf80a7b
Co-Authored-By: Claude <noreply@anthropic.com>
1011 lines
43 KiB
Python
1011 lines
43 KiB
Python
#!/usr/bin/env python
|
|
"""页面浏览器抓取器 — 当 API 接口被风控时,用 Playwright 浏览器方式抓取数据。
|
|
|
|
原理:SPA 页面加载时会自己调用 friend_list/es 等接口渲染表格。
|
|
我们拦截页面的网络响应,获取原始 JSON —— 效果等同 API 调用,
|
|
但因为是浏览器页面上下文(非脚本直调),不触发风控。
|
|
|
|
用法:
|
|
cd yunke-server
|
|
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity friends
|
|
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity groups
|
|
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity messages
|
|
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity accounts
|
|
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity wa-accounts
|
|
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity wa-friends
|
|
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity voice-calls
|
|
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity voice-clips
|
|
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity wa-messages
|
|
DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/scrape_via_page.py --entity media
|
|
|
|
Cookie 从 .env 的 YK_COOKIE 读取。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
from urllib.parse import urlparse
|
|
|
|
logger = logging.getLogger("scrape_via_page")
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
|
|
YUNKE_BASE = "https://crm2.yunkecn.com"
|
|
|
|
# API endpoints we intercept (the SPA calls these to render tables)
|
|
INTERCEPT_PATHS = {
|
|
"friend_list": "/wechatweb/wxFriends/friend_list/es", # friends + groups (friendTypeYunke)
|
|
"wxUser_list": "/wechatweb/wxUser/list", # wechat accounts
|
|
"chatroom": "/wechatweb/chatroom/getChatroomList", # legacy groups
|
|
"voice_list": "/wechatweb/call/list", # voice clips
|
|
"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
|
|
}
|
|
|
|
|
|
def parse_cookies(cookie_str: str, domain: str = "crm2.yunkecn.com") -> list[dict]:
|
|
"""Parse a cookie string like 'user=xxx; userToken=yyy' into Playwright cookie dicts."""
|
|
cookies = []
|
|
for pair in cookie_str.split(";"):
|
|
pair = pair.strip()
|
|
if "=" not in pair:
|
|
continue
|
|
name, value = pair.split("=", 1)
|
|
cookies.append({
|
|
"name": name.strip(),
|
|
"value": value.strip(),
|
|
"domain": domain,
|
|
"path": "/",
|
|
})
|
|
return cookies
|
|
|
|
|
|
class PageScraper:
|
|
"""Navigates 云客 pages in a browser, intercepts API responses, extracts JSON."""
|
|
|
|
def __init__(self, cookie_str: str, headless: bool = True):
|
|
self.cookie_str = cookie_str
|
|
self.headless = headless
|
|
self.browser = None
|
|
self.context = None
|
|
self.page = None
|
|
self._captured: list[dict] = []
|
|
self._capture_path: str | None = None
|
|
|
|
async def __aenter__(self):
|
|
from playwright.async_api import async_playwright
|
|
self._pw = await async_playwright().start()
|
|
self.browser = await self._pw.chromium.launch(headless=self.headless)
|
|
self.context = await self.browser.new_context(
|
|
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
|
|
viewport={"width": 1920, "height": 1080},
|
|
)
|
|
# Set cookies
|
|
cookies = parse_cookies(self.cookie_str)
|
|
await self.context.add_cookies(cookies)
|
|
self.page = await self.context.new_page()
|
|
|
|
# Intercept API responses
|
|
self.page.on("response", self._on_response)
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
if self.browser:
|
|
await self.browser.close()
|
|
if self._pw:
|
|
await self._pw.stop()
|
|
|
|
async def _on_response(self, response):
|
|
"""Capture API JSON responses that match our intercept paths."""
|
|
url = response.url
|
|
path = urlparse(url).path
|
|
if self._capture_path and path == self._capture_path:
|
|
try:
|
|
body = await response.json()
|
|
self._captured.append({"url": url, "status": response.status, "body": body})
|
|
logger.info(f" captured {path}: status={response.status}, keys={list(body.keys()) if isinstance(body, dict) else 'N/A'}")
|
|
except Exception as e:
|
|
logger.warning(f" capture failed for {path}: {e}")
|
|
|
|
def start_capture(self, api_path: str):
|
|
"""Start capturing responses for a specific API path."""
|
|
self._capture_path = api_path
|
|
self._captured.clear()
|
|
|
|
def get_captured(self) -> list[dict]:
|
|
"""Return captured responses and stop capturing."""
|
|
self._capture_path = None
|
|
return self._captured
|
|
|
|
async def navigate_and_wait(self, url: str, wait_selector: str = ".ant-table-row", timeout: int = 15000):
|
|
"""Navigate to a URL and wait for table rows to appear."""
|
|
await self.page.goto(url, wait_until="domcontentloaded", timeout=timeout)
|
|
try:
|
|
await self.page.wait_for_selector(wait_selector, timeout=timeout)
|
|
except Exception:
|
|
pass # might be empty page
|
|
await self.page.wait_for_timeout(2000) # extra settle time
|
|
|
|
async def get_total_pages(self) -> int:
|
|
"""Extract total page count from Ant Design pagination."""
|
|
try:
|
|
total_text = await self.page.evaluate("""() => {
|
|
const el = document.querySelector('.ant-pagination-total-text');
|
|
return el ? el.textContent : '';
|
|
}""")
|
|
if total_text:
|
|
import re
|
|
m = re.search(r'(\d+)', total_text)
|
|
if m:
|
|
total = int(m.group(1))
|
|
page_size = 10 # 云客 default
|
|
return (total + page_size - 1) // page_size
|
|
except Exception:
|
|
pass
|
|
return 1
|
|
|
|
async def click_next_page(self) -> bool:
|
|
"""Click the 'next page' button. Returns True if successful."""
|
|
try:
|
|
next_btn = self.page.locator('.ant-pagination-next:not(.ant-pagination-disabled)')
|
|
if await next_btn.count() > 0:
|
|
await next_btn.click()
|
|
await self.page.wait_for_timeout(2000) # wait for data load
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
async def scrape_entity_pages(
|
|
self,
|
|
list_url: str,
|
|
api_path: str,
|
|
max_pages: int = 100,
|
|
) -> list[dict]:
|
|
"""Navigate to a list page, paginate through all pages, capturing API responses.
|
|
|
|
Returns a list of captured JSON response bodies (one per page).
|
|
"""
|
|
self.start_capture(api_path)
|
|
await self.navigate_and_wait(list_url)
|
|
await self.page.wait_for_timeout(1000) # let API call complete
|
|
|
|
all_responses = list(self.get_captured())
|
|
|
|
total_pages = await self.get_total_pages()
|
|
logger.info(f" total pages: {min(total_pages, max_pages)}")
|
|
|
|
page_num = 1
|
|
while page_num < min(total_pages, max_pages):
|
|
self.start_capture(api_path)
|
|
clicked = await self.click_next_page()
|
|
if not clicked:
|
|
break
|
|
await self.page.wait_for_timeout(1500)
|
|
all_responses.extend(self.get_captured())
|
|
page_num += 1
|
|
if page_num % 10 == 0:
|
|
logger.info(f" page {page_num}/{min(total_pages, max_pages)}")
|
|
|
|
logger.info(f" captured {len(all_responses)} API responses across {page_num} pages")
|
|
return all_responses
|
|
|
|
async def get_employee_links(self) -> list[dict]:
|
|
"""Extract employee wxIds + friend-count links from the staff list page."""
|
|
await self.navigate_and_wait(f"{YUNKE_BASE}/cms/wechat/wechatlist")
|
|
|
|
employees = await self.page.evaluate("""() => {
|
|
const rows = document.querySelectorAll('.ant-table-row');
|
|
const result = [];
|
|
for (const tr of rows) {
|
|
// Find the friend-count link (href contains wechatdetail/1/)
|
|
const friendLink = tr.querySelector('a[href*="wechatdetail/1/"]');
|
|
const groupLink = tr.querySelector('a[href*="wechatdetail/2/"]');
|
|
if (friendLink) {
|
|
const href = friendLink.getAttribute('href');
|
|
const wxId = href.match(/wechatdetail\\/1\\/([^?]+)/)?.[1];
|
|
const friendCount = parseInt(friendLink.textContent) || 0;
|
|
const groupHref = groupLink?.getAttribute('href');
|
|
const groupCount = parseInt(groupLink?.textContent) || 0;
|
|
result.push({ wxId, friendCount, groupCount, friendHref: href, groupHref });
|
|
}
|
|
}
|
|
return result;
|
|
}""")
|
|
|
|
# Paginate through ALL staff list pages
|
|
all_employees = list(employees)
|
|
page = 1
|
|
while await self.click_next_page():
|
|
page += 1
|
|
await self.page.wait_for_timeout(2000)
|
|
more = await self.page.evaluate("""() => {
|
|
const rows = document.querySelectorAll('.ant-table-row');
|
|
const result = [];
|
|
for (const tr of rows) {
|
|
const friendLink = tr.querySelector('a[href*="wechatdetail/1/"]');
|
|
const groupLink = tr.querySelector('a[href*="wechatdetail/2/"]');
|
|
if (friendLink) {
|
|
const href = friendLink.getAttribute('href');
|
|
const wxId = href.match(/wechatdetail\\/1\\/([^?]+)/)?.[1];
|
|
const friendCount = parseInt(friendLink.textContent) || 0;
|
|
const groupHref = groupLink?.getAttribute('href');
|
|
const groupCount = parseInt(groupLink?.textContent) || 0;
|
|
result.push({ wxId, friendCount, groupCount, friendHref: href, groupHref });
|
|
}
|
|
}
|
|
return result;
|
|
}""")
|
|
all_employees.extend(more)
|
|
logger.info(f" staff page {page}: +{len(more)} employees (total: {len(all_employees)})")
|
|
|
|
# Dedup by wxId
|
|
seen = set()
|
|
deduped = []
|
|
for emp in all_employees:
|
|
if emp["wxId"] not in seen:
|
|
seen.add(emp["wxId"])
|
|
deduped.append(emp)
|
|
|
|
logger.info(f"Found {len(deduped)} unique employees with friend links ({page} pages)")
|
|
return deduped
|
|
|
|
|
|
async def scrape_friends(cookie_str: str, max_employees: int = 100):
|
|
"""Scrape wechat customers (friends) via page navigation."""
|
|
from app.db.base import async_session_maker
|
|
from app.storage.repos import upsert_customer
|
|
from app.scraper.wechat import _normalize # reuse the existing normalizer
|
|
|
|
total_fetched = 0
|
|
total_upserted = 0
|
|
|
|
async with PageScraper(cookie_str) as scraper:
|
|
employees = await scraper.get_employee_links()
|
|
|
|
for i, emp in enumerate(employees[:max_employees]):
|
|
if emp["friendCount"] == 0:
|
|
continue
|
|
wx_id = emp["wxId"]
|
|
url = f"{YUNKE_BASE}/cms/wechat/wechatlist/wechatdetail/1/{wx_id}?ymdStart=0000-00-00&ymdEnd=2099-12-31"
|
|
logger.info(f"[{i+1}/{len(employees)}] {wx_id}: {emp['friendCount']} friends")
|
|
|
|
responses = await scraper.scrape_entity_pages(
|
|
list_url=url,
|
|
api_path=INTERCEPT_PATHS["friend_list"],
|
|
max_pages=200,
|
|
)
|
|
|
|
# Extract friend records from captured responses
|
|
records = []
|
|
for resp in responses:
|
|
body = resp.get("body", {})
|
|
data = body.get("data", {})
|
|
rows = data.get("basicResult", {}).get("data", []) or data.get("data", [])
|
|
for row in rows:
|
|
if isinstance(row, dict):
|
|
normalized = _normalize(row)
|
|
normalized["channel"] = "wechat"
|
|
normalized["owner_account_id"] = wx_id
|
|
records.append(normalized)
|
|
|
|
total_fetched += len(records)
|
|
|
|
# Batch upsert
|
|
if records:
|
|
async with async_session_maker() as session:
|
|
for rec in records:
|
|
await upsert_customer(session, rec)
|
|
await session.commit()
|
|
total_upserted += len(records)
|
|
logger.info(f" upserted {len(records)} customers (total: {total_upserted})")
|
|
|
|
# Human-like delay between employees
|
|
await asyncio.sleep(3 + (i % 3)) # 3-5s random-ish
|
|
|
|
logger.info(f"DONE: friends fetched={total_fetched}, upserted={total_upserted}")
|
|
return total_fetched, total_upserted
|
|
|
|
|
|
async def scrape_groups(cookie_str: str, max_employees: int = 100):
|
|
"""Scrape wechat groups via page navigation."""
|
|
from app.db.base import async_session_maker
|
|
from app.storage.repos import upsert_chat_group
|
|
from app.scraper.wechat import _normalize_chat_group
|
|
|
|
total_fetched = 0
|
|
total_upserted = 0
|
|
|
|
async with PageScraper(cookie_str) as scraper:
|
|
employees = await scraper.get_employee_links()
|
|
|
|
for i, emp in enumerate(employees[:max_employees]):
|
|
if emp.get("groupCount", 0) == 0 or not emp.get("groupHref"):
|
|
continue
|
|
wx_id = emp["wxId"]
|
|
url = f"{YUNKE_BASE}{emp['groupHref']}"
|
|
logger.info(f"[{i+1}/{len(employees)}] {wx_id}: {emp['groupCount']} groups")
|
|
|
|
responses = await scraper.scrape_entity_pages(
|
|
list_url=url,
|
|
api_path=INTERCEPT_PATHS["friend_list"],
|
|
max_pages=100,
|
|
)
|
|
|
|
records = []
|
|
for resp in responses:
|
|
body = resp.get("body", {})
|
|
data = body.get("data", {})
|
|
rows = data.get("basicResult", {}).get("data", []) or data.get("data", [])
|
|
for row in rows:
|
|
if isinstance(row, dict):
|
|
normalized = _normalize_chat_group(row)
|
|
normalized["channel"] = "wechat"
|
|
records.append(normalized)
|
|
|
|
total_fetched += len(records)
|
|
if records:
|
|
async with async_session_maker() as session:
|
|
for rec in records:
|
|
await upsert_chat_group(session, rec)
|
|
await session.commit()
|
|
total_upserted += len(records)
|
|
logger.info(f" upserted {len(records)} groups (total: {total_upserted})")
|
|
|
|
await asyncio.sleep(3 + (i % 3))
|
|
|
|
logger.info(f"DONE: groups fetched={total_fetched}, upserted={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 — prioritize recent activity
|
|
async with async_engine.connect() as conn:
|
|
result = await conn.execute(sa_text(
|
|
"SELECT owner_account_id, friend_id FROM customer "
|
|
"WHERE channel='wechat' AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
|
|
"AND last_chat_time IS NOT NULL "
|
|
"ORDER BY last_chat_time DESC"
|
|
))
|
|
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:
|
|
# Just load the base URL to establish cookie context — no need for specific page
|
|
# The fetch() calls work from any page (even login redirect) as long as cookies are set
|
|
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 # use the stable page for all evaluate() calls
|
|
|
|
for i, (owner_id, friend_id) in enumerate(conversations[:max_conversations]):
|
|
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}]
|
|
|
|
# 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]}")
|
|
|
|
except Exception as e:
|
|
logger.warning(f" [{i+1}] {owner_id[:12]}:{friend_id[:12]} failed: {e}")
|
|
# Recreate the fetch page if context was destroyed
|
|
try:
|
|
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
|
|
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,
|
|
"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")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared helpers for the fetch()-based entities
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _open_fetch_page(scraper: PageScraper):
|
|
"""Open a stable page in the scraper's browser context for fetch() calls.
|
|
|
|
The fetch() calls work from any same-origin page (even a login redirect)
|
|
as long as the context carries the session cookies — same trick the
|
|
message scraper uses.
|
|
"""
|
|
page = await scraper.context.new_page()
|
|
await page.goto(f"{YUNKE_BASE}/", wait_until="domcontentloaded", timeout=15000)
|
|
await page.wait_for_timeout(2000)
|
|
return page
|
|
|
|
|
|
async def _browser_fetch(page, path: str, json_body: dict | None = None, form_body: dict | None = None):
|
|
"""POST from inside the browser context (proper cookies/referrer => no 风控).
|
|
|
|
``json_body`` sends a JSON body; ``form_body`` sends form-urlencoded.
|
|
Returns the parsed JSON response.
|
|
"""
|
|
if json_body is not None:
|
|
return await page.evaluate(
|
|
"""async ([path, body]) => {
|
|
const resp = await fetch(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return await resp.json();
|
|
}""",
|
|
[path, json_body],
|
|
)
|
|
return await page.evaluate(
|
|
"""async ([path, params]) => {
|
|
const ps = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) ps.append(k, String(v));
|
|
const resp = await fetch(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: ps.toString(),
|
|
});
|
|
return await resp.json();
|
|
}""",
|
|
[path, form_body or {}],
|
|
)
|
|
|
|
|
|
def _rows_flat(body: dict) -> list[dict]:
|
|
"""Extract rows from the flat envelope: ``data.data[]`` (wxUser/list,
|
|
queryWeChatVoiceList, call/list)."""
|
|
data = body.get("data") if isinstance(body, dict) else None
|
|
if not isinstance(data, dict):
|
|
return []
|
|
rows = data.get("data")
|
|
return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
|
|
|
|
|
|
def _rows_overviews(body: dict) -> list[dict]:
|
|
"""Extract rows from the WA envelope: ``data.overviews[]``
|
|
(businessSalesList / businessFriendList)."""
|
|
data = body.get("data") if isinstance(body, dict) else None
|
|
if not isinstance(data, dict):
|
|
return []
|
|
rows = data.get("overviews")
|
|
return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
|
|
|
|
|
|
async def _upsert_all(upsert_fn, records: list[dict]) -> None:
|
|
"""Batch-upsert records (each fn per record) in one committed session."""
|
|
from app.db.base import async_session_maker
|
|
|
|
if not records:
|
|
return
|
|
async with async_session_maker() as session:
|
|
for rec in records:
|
|
await upsert_fn(session, rec)
|
|
await session.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WeChat staff accounts (navigation + interception)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def scrape_accounts(cookie_str: str, max_pages: int = 100):
|
|
"""Scrape WeChat staff accounts: navigate /cms/wechat/wechatlist,
|
|
intercept /wechatweb/wxUser/list, upsert to wechat_account (+staff)."""
|
|
from app.scraper.wechat import _normalize_wxuser_account
|
|
from app.storage.repos import upsert_wechat_account
|
|
|
|
total_fetched = 0
|
|
async with PageScraper(cookie_str) as scraper:
|
|
responses = await scraper.scrape_entity_pages(
|
|
list_url=f"{YUNKE_BASE}/cms/wechat/wechatlist",
|
|
api_path=INTERCEPT_PATHS["wxUser_list"],
|
|
max_pages=max_pages,
|
|
)
|
|
records = []
|
|
for resp in responses:
|
|
for row in _rows_flat(resp.get("body", {})):
|
|
records.append(_normalize_wxuser_account(row))
|
|
total_fetched = len(records)
|
|
await _upsert_all(upsert_wechat_account, records)
|
|
|
|
logger.info(f"DONE: accounts fetched={total_fetched}, upserted={total_fetched}")
|
|
return total_fetched, total_fetched
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WhatsApp staff accounts (navigation + interception)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def scrape_wa_accounts(cookie_str: str, max_pages: int = 100):
|
|
"""Scrape WA staff accounts: navigate /cms/wechat/waBusiness/userlist,
|
|
intercept /qqweb/WhatsAppUser/businessSalesList, upsert to whatsapp_account."""
|
|
from app.scraper.whatsapp import _normalize_whatsapp_account
|
|
from app.storage.repos import upsert_whatsapp_account
|
|
|
|
async with PageScraper(cookie_str) as scraper:
|
|
responses = await scraper.scrape_entity_pages(
|
|
list_url=f"{YUNKE_BASE}/cms/wechat/waBusiness/userlist",
|
|
api_path=INTERCEPT_PATHS["wa_user"],
|
|
max_pages=max_pages,
|
|
)
|
|
records = []
|
|
for resp in responses:
|
|
for row in _rows_overviews(resp.get("body", {})):
|
|
records.append(_normalize_whatsapp_account(row))
|
|
total = len(records)
|
|
await _upsert_all(upsert_whatsapp_account, records)
|
|
|
|
logger.info(f"DONE: wa-accounts fetched={total}, upserted={total}")
|
|
return total, total
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WhatsApp customers (navigation + interception)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def scrape_wa_friends(cookie_str: str, max_pages: int = 2000):
|
|
"""Scrape WA customers: navigate /cms/wechat/waBusiness/friendlist,
|
|
intercept /qqweb/WhatsAppUser/businessFriendList, upsert to customer
|
|
(channel='whatsapp'). Live total ~11k rows => high default max_pages."""
|
|
from app.scraper.whatsapp import _normalize_whatsapp_friend
|
|
from app.storage.repos import upsert_customer
|
|
|
|
total_fetched = 0
|
|
total_upserted = 0
|
|
async with PageScraper(cookie_str) as scraper:
|
|
responses = await scraper.scrape_entity_pages(
|
|
list_url=f"{YUNKE_BASE}/cms/wechat/waBusiness/friendlist",
|
|
api_path=INTERCEPT_PATHS["wa_friend"],
|
|
max_pages=max_pages,
|
|
)
|
|
# Upsert in chunks so one giant page-walk doesn't build a 11k-record list
|
|
# before the first commit.
|
|
chunk: list[dict] = []
|
|
for resp in responses:
|
|
records = [_normalize_whatsapp_friend(row) for row in _rows_overviews(resp.get("body", {}))]
|
|
total_fetched += len(records)
|
|
chunk.extend(records)
|
|
if len(chunk) >= 500:
|
|
await _upsert_all(upsert_customer, chunk)
|
|
total_upserted += len(chunk)
|
|
chunk = []
|
|
await _upsert_all(upsert_customer, chunk)
|
|
total_upserted += len(chunk)
|
|
|
|
logger.info(f"DONE: wa-friends fetched={total_fetched}, upserted={total_upserted}")
|
|
return total_fetched, total_upserted
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Voice call records (navigation + interception, both WeChat + WA pages)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def scrape_voice_calls(cookie_str: str, max_pages: int = 200):
|
|
"""Scrape voice call records from BOTH channel pages, upsert to phone_call.
|
|
|
|
* WeChat page /cms/wechat/voice-call-list -> channel='voice_call'
|
|
* WA page /cms/wechat/waBusiness/wavoicecalllist -> channel='wa_voice_call'
|
|
|
|
Both pages call the SAME unified API (/wechatweb/callRecord/
|
|
queryWeChatVoiceList) with callSource 0/4 — the SPA sends it, we intercept.
|
|
"""
|
|
from app.scraper.wechat import _normalize_voice_call
|
|
from app.scraper.whatsapp import _normalize_wa_voice_call
|
|
from app.storage.repos import upsert_voice_call
|
|
|
|
total_fetched = 0
|
|
total_upserted = 0
|
|
async with PageScraper(cookie_str) as scraper:
|
|
for label, url, normalize in (
|
|
("wechat", f"{YUNKE_BASE}/cms/wechat/voice-call-list", _normalize_voice_call),
|
|
("wa", f"{YUNKE_BASE}/cms/wechat/waBusiness/wavoicecalllist", _normalize_wa_voice_call),
|
|
):
|
|
logger.info(f"voice-calls: navigating {label} page...")
|
|
responses = await scraper.scrape_entity_pages(
|
|
list_url=url,
|
|
api_path=INTERCEPT_PATHS["call_list"],
|
|
max_pages=max_pages,
|
|
)
|
|
records = []
|
|
for resp in responses:
|
|
for row in _rows_flat(resp.get("body", {})):
|
|
rec = normalize(row)
|
|
if rec.get("call_id") is not None:
|
|
records.append(rec)
|
|
total_fetched += len(records)
|
|
await _upsert_all(upsert_voice_call, records)
|
|
total_upserted += len(records)
|
|
logger.info(f" {label}: {len(records)} calls")
|
|
await asyncio.sleep(3)
|
|
|
|
logger.info(f"DONE: voice-calls fetched={total_fetched}, upserted={total_upserted}")
|
|
return total_fetched, total_upserted
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Long voice clips (browser fetch, per employee)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def scrape_voice_clips(cookie_str: str, max_employees: int = 200, page_size: int = 50):
|
|
"""Scrape long voice clips via browser fetch POST /wechatweb/call/list.
|
|
|
|
The endpoint is per-employee: employee user_ids come from the LOCAL
|
|
wechat_account / whatsapp_account tables (channel resolved the same way).
|
|
JSON body {pageNum, pageSize, userId} (真机确认 2026-08-07 spec §2).
|
|
"""
|
|
from app.db.base import async_engine
|
|
from app.scraper.voice_clip import _normalize_voice_clip
|
|
from app.storage.repos import upsert_voice_clip
|
|
from sqlalchemy import text as sa_text
|
|
|
|
# Resolve employee user_id -> channel from local staff-account tables.
|
|
# WeChat wins when an id appears in both tables.
|
|
user_channel: dict[str, str] = {}
|
|
async with async_engine.connect() as conn:
|
|
result = await conn.execute(sa_text(
|
|
"SELECT DISTINCT user_id FROM whatsapp_account "
|
|
"WHERE user_id IS NOT NULL AND user_id != ''"
|
|
))
|
|
for (uid,) in result.fetchall():
|
|
user_channel[uid] = "whatsapp"
|
|
result = await conn.execute(sa_text(
|
|
"SELECT DISTINCT user_id FROM wechat_account "
|
|
"WHERE user_id IS NOT NULL AND user_id != ''"
|
|
))
|
|
for (uid,) in result.fetchall():
|
|
user_channel[uid] = "wechat"
|
|
|
|
employees = sorted(user_channel.items())
|
|
logger.info(f"Found {len(employees)} employees; scraping {min(max_employees, len(employees))}")
|
|
|
|
total_fetched = 0
|
|
total_upserted = 0
|
|
|
|
async with PageScraper(cookie_str) as scraper:
|
|
page = await _open_fetch_page(scraper)
|
|
|
|
for i, (user_id, channel) in enumerate(employees[:max_employees]):
|
|
records = []
|
|
try:
|
|
page_num = 1
|
|
while True:
|
|
body = await _browser_fetch(
|
|
page,
|
|
INTERCEPT_PATHS["voice_list"],
|
|
json_body={"pageNum": page_num, "pageSize": page_size, "userId": user_id},
|
|
)
|
|
rows = _rows_flat(body if isinstance(body, dict) else {})
|
|
if not rows:
|
|
break
|
|
records.extend(_normalize_voice_clip(row, channel=channel) for row in rows)
|
|
data = body.get("data") or {}
|
|
total = data.get("total")
|
|
if isinstance(total, (int, float)) and page_num * page_size >= int(total):
|
|
break
|
|
page_num += 1
|
|
await asyncio.sleep(1)
|
|
except Exception as e:
|
|
logger.warning(f" [{i+1}] user {user_id} failed: {e}")
|
|
try:
|
|
await page.close()
|
|
except Exception:
|
|
pass
|
|
page = await _open_fetch_page(scraper)
|
|
continue
|
|
|
|
total_fetched += len(records)
|
|
if records:
|
|
await _upsert_all(upsert_voice_clip, records)
|
|
total_upserted += len(records)
|
|
|
|
if (i + 1) % 10 == 0 or not records:
|
|
logger.info(f" [{i+1}/{min(max_employees, len(employees))}] {user_id}({channel}): {len(records)} clips (total: {total_fetched})")
|
|
await asyncio.sleep(2 + (i % 3))
|
|
|
|
logger.info(f"DONE: voice-clips fetched={total_fetched}, upserted={total_upserted}")
|
|
return total_fetched, total_upserted
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WhatsApp messages (browser fetch, per conversation, backward cursor)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100):
|
|
"""Scrape WhatsApp chat records via browser fetch POST
|
|
/qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}).
|
|
|
|
Conversation pairs come from the local customer table
|
|
(channel='whatsapp'). Pages backward via data.end / data.hasLast
|
|
(same cursor strategy as the WeChat msg/chats scraper).
|
|
"""
|
|
import time as _time
|
|
from app.db.base import async_engine
|
|
from app.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
|
|
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, friend_id FROM customer "
|
|
"WHERE channel='whatsapp' AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
|
|
"AND last_chat_time IS NOT NULL "
|
|
"ORDER BY last_chat_time DESC"
|
|
))
|
|
conversations = [(row[0], row[1]) for row in result.fetchall()]
|
|
|
|
logger.info(f"Discovered {len(conversations)} WA conversations, will scrape {min(max_conversations, len(conversations))}")
|
|
|
|
total_fetched = 0
|
|
total_upserted = 0
|
|
|
|
async with PageScraper(cookie_str) as scraper:
|
|
page = await _open_fetch_page(scraper)
|
|
|
|
for i, (uid, friend_uid) in enumerate(conversations[:max_conversations]):
|
|
records = []
|
|
try:
|
|
end_ms = int(_time.time() * 1000)
|
|
while True:
|
|
body = await _browser_fetch(
|
|
page,
|
|
"/qqweb/WhatsAppUser/chatRecords",
|
|
json_body={"uid": uid, "friendUid": friend_uid, "start": 0, "end": end_ms},
|
|
)
|
|
data = body.get("data") if isinstance(body, dict) else None
|
|
if not isinstance(data, dict):
|
|
break
|
|
msgs = data.get("messages") or []
|
|
for row in msgs:
|
|
if isinstance(row, dict) and not _is_wa_divider(row):
|
|
records.append(_normalize_whatsapp_message(row))
|
|
if not data.get("hasLast"):
|
|
break
|
|
end = data.get("end")
|
|
if not isinstance(end, (int, float)):
|
|
break
|
|
end_ms = int(end) - 1
|
|
await asyncio.sleep(1)
|
|
except Exception as e:
|
|
logger.warning(f" [{i+1}] {str(uid)[:20]}:{str(friend_uid)[:20]} failed: {e}")
|
|
try:
|
|
await page.close()
|
|
except Exception:
|
|
pass
|
|
page = await _open_fetch_page(scraper)
|
|
continue
|
|
|
|
total_fetched += len(records)
|
|
if records:
|
|
await _upsert_all(upsert_messages, records)
|
|
total_upserted += len(records)
|
|
|
|
if (i + 1) % 10 == 0:
|
|
logger.info(f" [{i+1}/{min(max_conversations, len(conversations))}] msgs total: {total_fetched}")
|
|
await asyncio.sleep(2 + (i % 3))
|
|
|
|
logger.info(f"DONE: wa-messages fetched={total_fetched}, upserted={total_upserted}")
|
|
return total_fetched, total_upserted
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Media downloads (OSS CDN — NOT API calls, safe from 风控)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def scrape_media(cookie_str: str, batch_size: int = 50):
|
|
"""Download media artifacts into the local object store:
|
|
|
|
* call recordings (phone_call.record_file OSS URLs)
|
|
* voice clip audio (voice_clip.oss_file_name OSS URLs)
|
|
* message images/videos (message.file_url / file_th_url / file_hd_url)
|
|
|
|
These are plain OSS CDN downloads (not CRM API calls), so httpx direct
|
|
fetching is safe. The YunkeClient is only used as the httpx transport.
|
|
"""
|
|
from app.config import get_settings
|
|
from app.db.base import async_session_maker
|
|
from app.scraper.media import (
|
|
download_call_recordings,
|
|
download_message_images,
|
|
download_voice_clip_audio,
|
|
)
|
|
from app.session.yunke_client import YunkeClient
|
|
from app.storage.object_store import ObjectStore
|
|
|
|
settings = get_settings()
|
|
cookie_dict = {c["name"]: c["value"] for c in parse_cookies(cookie_str)}
|
|
# Session-mode client purely as an httpx transport for OSS downloads —
|
|
# userid/company only matter for login(), which we never call here.
|
|
client = YunkeClient(
|
|
base_url=YUNKE_BASE,
|
|
userid=settings.yk_userid or "oss",
|
|
company=settings.yk_company or "oss",
|
|
cookies=cookie_dict,
|
|
)
|
|
store = ObjectStore()
|
|
totals: dict[str, tuple[int, int]] = {}
|
|
try:
|
|
async with async_session_maker() as session:
|
|
totals["call_recordings"] = await download_call_recordings(client, store, session, batch_size=batch_size)
|
|
totals["voice_clip_audio"] = await download_voice_clip_audio(client, store, session, batch_size=batch_size)
|
|
totals["message_images"] = await download_message_images(client, store, session, batch_size=batch_size)
|
|
finally:
|
|
await client.aclose()
|
|
|
|
for name, (attempted, localized) in totals.items():
|
|
logger.info(f" {name}: attempted={attempted}, localized={localized}")
|
|
logger.info(f"DONE: media {totals}")
|
|
return totals
|
|
|
|
|
|
async def main():
|
|
parser = argparse.ArgumentParser(description="页面浏览器抓取器")
|
|
parser.add_argument(
|
|
"--entity",
|
|
required=True,
|
|
choices=[
|
|
"friends", # 微信客户 (per-employee friend pages)
|
|
"groups", # 微信群 (per-employee group pages)
|
|
"messages", # 微信消息 (browser fetch msg/chats)
|
|
"accounts", # 微信员工账号 (wechatlist page interception)
|
|
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
|
|
"wa-friends", # WA客户 (waBusiness/friendlist interception)
|
|
"voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist)
|
|
"voice-clips", # 长语音 (browser fetch call/list per employee)
|
|
"wa-messages", # WA消息 (browser fetch chatRecords per conversation)
|
|
"media", # OSS媒体下载 (录音/长语音音频/消息图片)
|
|
],
|
|
help="抓取实体",
|
|
)
|
|
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-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls)")
|
|
parser.add_argument("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
|
|
args = parser.parse_args()
|
|
|
|
cookie = os.environ.get("YK_COOKIE", "")
|
|
if not cookie:
|
|
# Try reading from .env
|
|
from app.config import get_settings
|
|
cookie = get_settings().yk_cookie
|
|
if not cookie:
|
|
logger.error("YK_COOKIE not set in .env or environment")
|
|
return
|
|
|
|
logger.info(f"Starting page-based scrape: entity={args.entity}")
|
|
|
|
if args.entity == "friends":
|
|
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)
|
|
elif args.entity == "accounts":
|
|
await scrape_accounts(cookie, args.max_pages)
|
|
elif args.entity == "wa-accounts":
|
|
await scrape_wa_accounts(cookie, args.max_pages)
|
|
elif args.entity == "wa-friends":
|
|
await scrape_wa_friends(cookie, args.max_pages)
|
|
elif args.entity == "voice-calls":
|
|
await scrape_voice_calls(cookie, args.max_pages)
|
|
elif args.entity == "voice-clips":
|
|
await scrape_voice_clips(cookie, args.max_employees)
|
|
elif args.entity == "wa-messages":
|
|
await scrape_wa_messages(cookie, args.max_conversations)
|
|
elif args.entity == "media":
|
|
await scrape_media(cookie, args.batch_size)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|