Files
yunke/yunke-server/scripts/scrape_via_page.py
T

1954 lines
85 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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):
"""微信客户:浏览器直发 friend_list/es,**逐员工**(userId 过滤)翻页。
部门级全量分页排序不稳定(实测:云客声明 8691 vs 到手 distinct 6563,
8682 行里 2119 行跨页重复挤占页容量漏抓真客户,2026-08-20);逐员工范围
小漂移可忽略,且与云客后台「按员工看客户列表」口径一致。员工清单来自
本地 wechat_account(user_id 列)。
"""
from app.db.base import async_engine
from sqlalchemy import text as sa_text
from app.scraper.wechat import _normalize
from app.storage.repos import upsert_customer
dept = await _root_dept_id()
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT wx_id FROM wechat_account WHERE wx_id IS NOT NULL AND wx_id != ''"
))
employees = [row[0] for row in result.fetchall()][:max_employees]
import datetime as _dt
today = _dt.date.today().isoformat()
total_fetched = 0
total_upserted = 0
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
# ---- 阶段1:逐员工全量(保底完整性;该路径 lastChatTime 有滞后) ----
for i, wx_id in enumerate(employees):
# 真机抓包(2026-08-20 云客客户列表页)确认的过滤参数:wechatId(非
# userId!)+ymd 窗口+friendTypeYunke=1+isDel=0
base_body = {
"ymdStart": "0000-00-00",
"ymdEnd": today,
"wechatId": wx_id,
"salesname": "",
"friendTypeYunke": "1",
"isDel": 0,
}
# total-aware 翻页:该端点分页不稳(中途偶发短页),按声明 total
# 翻满或连续空页才停;跨页重复行 upsert 幂等无害。
rows: list[dict] = []
declared: int | None = None
empty_streak = 0
page_num = 1
while page_num <= 400:
resp = await _browser_fetch(
page, INTERCEPT_PATHS["friend_list"],
json_body={**base_body, "pageNum": page_num, "pageSize": 50},
)
basic = ((resp.get("data") or {}).get("basicResult") or {})
declared = basic.get("total") or declared
page_rows = basic.get("data") or []
if not page_rows:
empty_streak += 1
if empty_streak >= 2 or (declared and len(rows) >= declared):
break
else:
empty_streak = 0
rows.extend(page_rows)
if declared and len(rows) >= declared:
break
page_num += 1
await asyncio.sleep(1)
records = []
for row in rows:
rec = _normalize(row)
if rec.get("friend_id"):
records.append(rec)
total_fetched += len(records)
if records:
await _upsert_all(upsert_customer, records)
total_upserted += len(records)
logger.info(
f" [{i+1}/{len(employees)}] {wx_id[:16]}..: declared={declared} "
f"rows={len(rows)} (total fetched {total_fetched})"
)
await asyncio.sleep(2 + (i % 3))
# ---- 阶段2:全局实时刷新(云客列表页的参数集,跨员工按最近沟通实时排序;
# 阶段1的 lastChatTime 有滞后,这里用实时值覆盖,保证本地排序与云客第一页一致) ----
global_body = {
"friendName": "",
"friendRemark": "",
"saleName": "",
"friendPhone": "",
"realName": "",
"emotionOrder": "",
"emotionStatus": "",
"tagGroupId": "",
"isDel": 0,
"isOnJob": 1,
"showSale": "1",
"friendTab": "all",
"mergeSearchType": "saleName",
"mergeSearchLabel": "",
"ymdStart": today,
"ymdEnd": today,
"friendId": "",
"departmentId": dept or "",
"userId": "",
}
realtime_rows: list[dict] = []
for page_num in range(1, 41): # 前 40 页 x50 = 2000 行,覆盖全部近期活跃客户
resp = await _browser_fetch(
page, INTERCEPT_PATHS["friend_list"],
json_body={**global_body, "pageNum": page_num, "pageSize": 50},
)
basic = ((resp.get("data") or {}).get("basicResult") or {})
page_rows = basic.get("data") or []
if not page_rows:
break
realtime_rows.extend(page_rows)
if len(page_rows) < 50:
break
await asyncio.sleep(1)
# 只刷 last_chat_time 且只增不减(全局结果里也含旧 ES 副本,后写会
# 把实时值覆盖回旧值——max 条件天然免疫,2026-08-20 实测踩坑)
from app.db.base import async_session_maker as _asm
from sqlalchemy import text as _st
updated = 0
async with _asm() as session:
for row in realtime_rows:
fid, ts = row.get("friendWxId"), row.get("lastChatTime")
if not fid or not ts:
continue
r = await session.execute(_st(
"UPDATE customer SET last_chat_time = :ts "
"WHERE channel='wechat' AND owner_account_id = :o AND friend_id = :f "
"AND (last_chat_time IS NULL OR last_chat_time < :ts)"
), {"ts": int(ts), "o": row.get("wxId"), "f": fid})
updated += r.rowcount
await session.commit()
logger.info(f" 实时刷新: {len(realtime_rows)} 行(全局实时排序), last_chat_time 更新 {updated} 条")
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):
"""微信群:浏览器直发 friend_list/es,**逐员工**(wechatId 过滤)翻页。
与好友共用端点,仅 friendTypeYunke=2;部门级分页不稳(见 scrape_friends
三连修,2026-08-20),同模式逐员工 + total-aware 翻页。"""
import datetime as _dt
from app.db.base import async_engine
from sqlalchemy import text as sa_text
from app.scraper.wechat import _normalize_chat_group
from app.storage.repos import upsert_chat_group
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT wx_id FROM wechat_account WHERE wx_id IS NOT NULL AND wx_id != ''"
))
employees = [row[0] for row in result.fetchall()][:max_employees]
today = _dt.date.today().isoformat()
total_fetched = 0
total_upserted = 0
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
for i, wx_id in enumerate(employees):
base_body = {
"ymdStart": "0000-00-00",
"ymdEnd": today,
"wechatId": wx_id,
"salesname": "",
"friendTypeYunke": "2",
"isDel": 0,
}
rows: list[dict] = []
declared: int | None = None
empty_streak = 0
page_num = 1
while page_num <= 200:
resp = await _browser_fetch(
page, INTERCEPT_PATHS["friend_list"],
json_body={**base_body, "pageNum": page_num, "pageSize": 50},
)
basic = ((resp.get("data") or {}).get("basicResult") or {})
declared = basic.get("total") or declared
page_rows = basic.get("data") or []
if not page_rows:
empty_streak += 1
if empty_streak >= 2 or (declared and len(rows) >= declared):
break
else:
empty_streak = 0
rows.extend(page_rows)
if declared and len(rows) >= declared:
break
page_num += 1
await asyncio.sleep(1)
records = []
for row in rows:
rec = _normalize_chat_group(row)
if rec.get("room_id"):
records.append(rec)
total_fetched += len(records)
if records:
await _upsert_all(upsert_chat_group, records)
total_upserted += len(records)
if (i + 1) % 10 == 0:
logger.info(f" [{i+1}/{len(employees)}] employees done, groups total: {total_fetched}")
await asyncio.sleep(2 + (i % 3))
logger.info(f"DONE: groups fetched={total_fetched}, upserted={total_upserted}")
return total_fetched, total_upserted
async def paginate_chats(fetch_page, end_ms: int, max_pages: int = 200, sleep=None):
"""按 hasLast/end 游标翻页 chats 类接口(微信 msg/chats 与 WA chatRecords 同协议)。
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 _discover_conversations(channel: str, voice_channel: str) -> list[tuple[str, str]]:
"""Conversation-pair discovery for the message scrapers (wechat + WA).
Sources (union, dedup):
* customer table — ALL pairs. The old ``last_chat_time IS NOT NULL`` filter
skipped customers whose relationship is voice-call-only (NULL last_chat)
and thus their chat history was never scraped → empty chat pages
(diagnosed 2026-08-18).
* voice-call pairs ``(weChatId, talker)`` from phone_call raw_json — peers
that had calls but have NO customer row at all (deleted friends).
Order: pairs with no messages yet FIRST (new coverage survives an
interruption), then the rest by customer last_chat_time DESC.
"""
import json as _json
from app.db.base import async_engine
from sqlalchemy import text as sa_text
async with async_engine.connect() as conn:
cust = (
await conn.execute(sa_text(
"SELECT owner_account_id, friend_id FROM customer "
"WHERE channel=:ch AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
"ORDER BY last_chat_time DESC"
), {"ch": channel})
).fetchall()
voice = (
await conn.execute(sa_text(
"SELECT raw_json FROM phone_call WHERE channel=:vc"
), {"vc": voice_channel})
).fetchall()
with_msgs = (
await conn.execute(sa_text(
"SELECT DISTINCT owner_account_id, talker_id FROM message "
"WHERE channel=:ch AND (room_id IS NULL OR room_id='')"
), {"ch": channel})
).fetchall()
have = {(r[0], r[1]) for r in with_msgs}
seen: set[tuple[str, str]] = set()
fresh: list[tuple[str, str]] = []
known: list[tuple[str, str]] = []
def _add(key: tuple[str, str]) -> None:
if key in seen:
return
seen.add(key)
(fresh if key not in have else known).append(key)
for owner, friend in cust:
_add((owner, friend))
voice_only = 0
for (rj,) in voice:
try:
d = _json.loads(rj)
except (TypeError, ValueError):
continue
owner, talker = d.get("weChatId"), d.get("talker")
if not owner or not talker or str(talker).endswith("@chatroom"):
continue
before = len(seen)
_add((str(owner), str(talker)))
if len(seen) > before and (str(owner), str(talker)) not in cust:
voice_only += 1
logger.info(
f"discovery[{channel}]: {len(cust)} customer pairs + {voice_only} voice-only pairs, "
f"{len(fresh)} unscanned (first) / {len(known)} already have messages"
)
return fresh + known
async def _room_watermarks() -> dict[str, int]:
"""每群本地最新消息时间(ms)——群消息增量水位(按 room_id)。"""
from app.db.base import async_engine
from sqlalchemy import text as sa_text
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT room_id, max(timestamp) FROM message "
"WHERE room_id IS NOT NULL AND room_id != '' "
"GROUP BY room_id"
))
return {r[0]: r[1] or 0 for r in result.fetchall()}
async def _conversation_watermarks(channel: str) -> dict[tuple[str, str], int]:
"""每对话的本地最新消息时间(ms)——增量同步水位。"""
from app.db.base import async_engine
from sqlalchemy import text as sa_text
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT owner_account_id, talker_id, max(timestamp) FROM message "
"WHERE channel=:ch AND (room_id IS NULL OR room_id='') "
"AND talker_id IS NOT NULL AND talker_id != '' "
"GROUP BY owner_account_id, talker_id"
), {"ch": channel})
return {(r[0], r[1]): r[2] or 0 for r in result.fetchall()}
async def _customer_activity(channel: str) -> dict[tuple[str, str], int]:
"""客户表 last_chat_time(4h 增量的 friends 实时刷新)——判断对话有无新消息。"""
from app.db.base import async_engine
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, last_chat_time FROM customer "
"WHERE channel=:ch AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL"
), {"ch": channel})
return {(r[0], r[1]): r[2] or 0 for r in result.fetchall()}
def _page_min_ts(data: dict) -> int:
"""页内最早消息时间(0 当无有效 ts)。"""
ts_list = [m.get("timestamp") for m in (data.get("messages") or []) if isinstance(m, dict) and m.get("timestamp")]
return min(ts_list) if ts_list else 0
async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False):
"""Scrape wechat messages via browser fetch POST /wechatweb/msg/chats.
**增量同步(默认)**:每对话水位 = 本地 max(timestamp);请求带
``start=水位`` 只拉新消息,翻页遇到 ≤水位的消息即停(已同步区)。
客户表 last_chat_time(friends 实时刷新)≤ 水位的对话**整跳过**(0 请求)
——活跃对话才访问。``full_history=True`` 强制全量(首次/修复用)。
"""
from sqlalchemy import text as sa_text
from app.db.base import async_session_maker
from app.storage.repos import upsert_messages
conversations = await _discover_conversations("wechat", "voice_call")
watermarks = await _conversation_watermarks("wechat")
cust_activity = await _customer_activity("wechat")
# 增量模式:云客侧无新活动的对话跳过(0 请求)——速度来源
skipped = 0
if not full_history:
kept = []
for pair in conversations:
wm = watermarks.get(pair, 0)
act = cust_activity.get(pair, 0)
# 注意:不能用 +60s 缓冲——若新消息恰好落在水位后 60s 内,
# act 永远 ≤ wm+60s → 每轮都跳过 → 该消息**永久漏掉**。
# act==wm 表示云客最新消息==我们最新消息,精确相等才跳。
if wm > 0 and act > 0 and act <= wm:
skipped += 1
else:
kept.append(pair)
conversations = kept
logger.info(
f"Discovered conversations: {len(conversations)} to visit, {skipped} skipped (no new activity)"
+ (" [FULL HISTORY]" if full_history else " [incremental]")
)
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
# 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]):
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)
watermark = 0 if full_history else watermarks.get((owner_id, friend_id), 0)
async def _fetch(end_ms: int, _owner=owner_id, _friend=friend_id, _wm=watermark):
return await page.evaluate("""async ([ownerId, friendId, startMs, endMs]) => {
const params = new URLSearchParams();
params.append('wechatId', ownerId);
params.append('friendId', friendId);
params.append('types', '');
params.append('start', String(startMs));
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, _wm, end_ms])
# 翻页取完整历史:hasLast 真 → 用 data.end-1 继续向前翻(页间 sleep 1s)
async for data in paginate_chats(_fetch, now_ms):
pages += 1
# 增量触底停:页内出现 ≤水位 的消息 = 已同步区,不再向前翻
if watermark and _page_min_ts(data) and _page_min_ts(data) <= watermark:
break
# 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["isSend"]) if row.get("isSend") is not None
else (bool(row["mine"]) if row.get("mine") 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"),
# fileTh/fileHd 常在 file 为空时仍带完整 OSS URL——映射进列,媒体下载器才够得着
"file_th_url": row.get("fileTh"),
"file_hd_url": row.get("fileHd"),
"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}")
# Recreate the fetch page if context was destroyed.
# A network blip / yunke outage also breaks this recovery goto —
# retry patiently (30 × 2min = 1h) so a longer outage doesn't
# kill the whole run (crashed twice on 2026-08-18 this way,
# outages ~3h apart, ~1.5h into each run).
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)
continue
total_fetched += len(records)
total_pages += pages
if records:
async with async_session_maker() as session:
await upsert_messages(session, records)
# 时效:用本对话最新消息时间刷新 customer.last_chat_time
# (云客列表按最近沟通实时排序;friends 快照每日一次,消息增量每4h)
max_ts = max((r.get("timestamp") or 0) for r in records)
if max_ts:
await session.execute(sa_text(
"UPDATE customer SET last_chat_time = :ts "
"WHERE channel='wechat' AND owner_account_id = :o AND friend_id = :f "
"AND (last_chat_time IS NULL OR last_chat_time < :ts)"
), {"ts": max_ts, "o": owner_id, "f": friend_id})
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}, pages: {total_pages}")
# Human-like delay
await asyncio.sleep(2 + (i % 3))
logger.info(f"DONE: messages fetched={total_fetched}, upserted={total_upserted}, pages={total_pages}")
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["isSend"]) if row.get("isSend") is not None
else (bool(row["mine"]) if row.get("mine") 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"),
# 同 1:1:fileTh/fileHd 的 OSS URL 必须映射进列,媒体下载器才够得着
"file_th_url": row.get("fileTh"),
"file_hd_url": row.get("fileHd"),
"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, full_history: bool = False):
"""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()]
room_wms = await _room_watermarks()
logger.info(f"Discovered {len(groups)} groups, will scrape {min(max_groups, len(groups))}"
+ (" [FULL]" if full_history else " [incremental]"))
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)
watermark = 0 if full_history else room_wms.get(room_id, 0)
async def _fetch(end_ms: int, _owner=owner_id, _room=room_id, _wm=watermark):
return await page.evaluate("""async ([ownerId, friendId, startMs, endMs]) => {
const params = new URLSearchParams();
params.append('wechatId', ownerId);
params.append('friendId', friendId);
params.append('types', '');
params.append('start', String(startMs));
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, _wm, end_ms])
async for data in paginate_chats(_fetch, now_ms, max_pages=200):
pages += 1
if watermark and _page_min_ts(data) and _page_min_ts(data) <= watermark:
break
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:
"""Normalize a raw message type to our canonical type string.
微信 type=2 = 图片(新版,fileTh 带完整 OSS URL;真机 A**杰对话确认
2026-08-20——旧映射漏掉它导致图片被存成 unknown 前端不渲染)。
"""
if raw_type is None:
return None
t = str(raw_type)
mapping = {
"1": "text",
"2": "image", # 新版图片(fileTh URL)
"3": "image_legacy", # 旧版图片(md5/键)
"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_basic_result(body: dict) -> list[dict]:
"""friend_list/es 信封: ``data.basicResult.data[]``(好友与群共用)。"""
data = body.get("data") if isinstance(body, dict) else None
basic = data.get("basicResult") if isinstance(data, dict) else None
rows = basic.get("data") if isinstance(basic, dict) else None
return [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
async def _root_dept_id() -> str:
"""本地 department 表的公司根部门 id(多个抓取端点的必填 body 字段)。"""
from app.db.base import async_engine
from sqlalchemy import text as sa_text
async with async_engine.connect() as conn:
row = (await conn.execute(sa_text(
"SELECT dept_id FROM department WHERE parent_id IS NULL LIMIT 1"
))).first()
return row[0] if row else ""
async def _fetch_pages(
page,
path: str,
*,
base_body: dict,
parse_rows,
page_size: int = 50,
max_pages: int = 500,
form: bool = False,
page_key: str = "pageNum",
) -> list[dict]:
"""按页码翻页浏览器直发 POST,直到短页;收集全部 rows 返回。
2026-08-20 起 list 类实体一律走此路径(导航+拦截被登录墙杀死,0 捕获)。
``form=True`` 发 form-urlencoded(Spring @RequestParam,如 wxUser/list);
``page_key`` 兼容 WA 的 ``page``。首页响应 code 异常时醒目告警
cookie 失效(所有 list 端点健康时 code=10000)。
"""
rows_all: list[dict] = []
first_row_sig: str | None = None
page_num = 1
while page_num <= max_pages:
body = {**base_body, page_key: page_num, "pageSize": page_size}
if form:
resp = await _browser_fetch(page, path, form_body=body)
else:
resp = await _browser_fetch(page, path, json_body=body)
if page_num == 1 and isinstance(resp, dict):
code = resp.get("code")
if code is not None and str(code) not in ("10000",):
logger.warning(
f"⚠️ {path} 首页 code={code} ({resp.get('message')}) — "
"cookie 可能已过期/失效,请从浏览器重新复制 YK_COOKIE 到 .env!"
)
rows = parse_rows(resp)
# 粘页检测:端点忽略页码参数时第 2 页起首行与第 1 页相同(实测 wxUser/list
# 的页码字段是 page 而非 pageNum,2026-08-20)——丢弃重复页并停止
if rows:
import json as _json
sig = _json.dumps(rows[0], sort_keys=True, default=str)[:200]
if page_num == 1:
first_row_sig = sig
elif sig == first_row_sig:
logger.warning(f" {path} page {page_num} 与首页相同 — 端点忽略页码参数,停止翻页")
break
rows_all.extend(rows)
logger.info(f" {path.rsplit('/', 1)[-1]} page {page_num}: {len(rows)} rows")
if len(rows) < page_size:
break
page_num += 1
await asyncio.sleep(1.5)
return rows_all
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):
"""微信员工账号:浏览器直发 wxUser/list(form-encoded,2026-08-20 起改 fetch)。
body 必填 isOnJob/departmentId/startdate/enddate(缺则 -90000);时间窗只
影响每账号统计数字不影响返回集合,取近 7 天。"""
import datetime as _dt
from app.scraper.wechat import _normalize_wxuser_account
from app.storage.repos import upsert_wechat_account
dept = await _root_dept_id()
end_date = _dt.datetime.now().date()
start_date = end_date - _dt.timedelta(days=7)
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
rows = await _fetch_pages(
page, INTERCEPT_PATHS["wxUser_list"],
base_body={
"isOnJob": "1",
"departmentId": dept,
"startdate": start_date.isoformat(),
"enddate": end_date.isoformat(),
"userId": "",
"salesname": "",
"weChatNickName": "",
},
parse_rows=_rows_flat,
form=True,
page_key="page", # 此端点页码字段是 page(实测 pageNum 被无视)
max_pages=max_pages,
)
records = []
for idx, row in enumerate(rows):
rec = _normalize_wxuser_account(row)
# 记录云客 API 返回顺序(对齐员工列表排列)
rec["list_order"] = idx
records.append(rec)
await _upsert_all(upsert_wechat_account, records)
total = len(records)
logger.info(f"DONE: accounts fetched={total}, upserted={total}")
return total, total
# ---------------------------------------------------------------------------
# WhatsApp staff accounts (browser fetch)
# ---------------------------------------------------------------------------
async def scrape_wa_accounts(cookie_str: str, max_pages: int = 100):
"""WA 员工账号:浏览器直发 businessSalesList(JSON body {pageNum,pageSize})。"""
from app.scraper.whatsapp import _normalize_whatsapp_account
from app.storage.repos import upsert_whatsapp_account
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
rows = await _fetch_pages(
page, INTERCEPT_PATHS["wa_user"],
base_body={}, parse_rows=_rows_overviews,
max_pages=max_pages,
)
records = [_normalize_whatsapp_account(row) for row in rows]
await _upsert_all(upsert_whatsapp_account, records)
total = len(records)
logger.info(f"DONE: wa-accounts fetched={total}, upserted={total}")
return total, total
# ---------------------------------------------------------------------------
# WhatsApp customers (browser fetch)
# ---------------------------------------------------------------------------
async def scrape_wa_friends(cookie_str: str, max_pages: int = 2000):
"""WA 客户:浏览器直发 businessFriendList type=0,**逐员工**(uid 过滤)翻页。
全局查询有 ~1 万行硬上限(实测云客声明 11931 vs 全局翻页只到 10000,
2026-08-20);逐员工 ``uid`` 过滤(员工 WA 号)范围小无上限,页码字段是
``page``,total-aware 翻页(短页继续,满声明数或连续空页停)。"""
from app.db.base import async_engine
from sqlalchemy import text as sa_text
from app.scraper.whatsapp import _normalize_whatsapp_friend
from app.storage.repos import upsert_customer
dept = await _root_dept_id()
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT uid FROM whatsapp_account WHERE uid IS NOT NULL AND uid != ''"
))
accounts = [row[0] for row in result.fetchall()]
total_fetched = 0
total_upserted = 0
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
for i, wa_uid in enumerate(accounts):
base_body = {
"searchInfo": "",
"friendSearchInfo": "",
"departmentId": dept,
"type": "0",
"uid": wa_uid,
}
rows: list[dict] = []
declared: int | None = None
empty_streak = 0
page_num = 1
while page_num <= max_pages:
resp = await _browser_fetch(
page, INTERCEPT_PATHS["wa_friend"],
json_body={**base_body, "page": page_num, "pageSize": 50},
)
data = resp.get("data") or {}
declared = data.get("totalCount") or declared
page_rows = data.get("overviews") or []
if not page_rows:
empty_streak += 1
if empty_streak >= 2 or (declared and len(rows) >= declared):
break
else:
empty_streak = 0
rows.extend(page_rows)
if declared and len(rows) >= declared:
break
page_num += 1
await asyncio.sleep(1)
records = []
for row in rows:
rec = _normalize_whatsapp_friend(row)
if rec.get("friend_id"):
records.append(rec)
total_fetched += len(records)
if records:
await _upsert_all(upsert_customer, records)
total_upserted += len(records)
logger.info(
f" [{i+1}/{len(accounts)}] {wa_uid[:18]}..: declared={declared} rows={len(rows)}"
)
await asyncio.sleep(2 + (i % 3))
logger.info(f"DONE: wa-friends fetched={total_fetched}, upserted={total_upserted}")
return total_fetched, total_upserted
def _normalize_wa_group_row(row: dict) -> dict | None:
"""businessFriendList type=1 行 → ChatGroup 形状(真机确认 2026-08-20)。
type=1 返回的是好友字段名结构:friendUid=群 id(@g.us 后缀)、nickName=
群名、uid=员工 WA 号、salesNickName=员工 WA 昵称——与微信群的
friendWxId/friendWxNickname 完全不同,故不能复用 _normalize_chat_group。
非 @g.us 行(异常混入)返回 None。
"""
friend_uid = str(row.get("friendUid") or "")
if not friend_uid.endswith("@g.us"):
return None
return {
"room_id": friend_uid,
"channel": "whatsapp",
"room_name": row.get("nickName"),
"owner_account_id": row.get("uid"),
"owner_account_nickname": row.get("salesNickName"),
"owner_user_id": row.get("userId"),
"owner_user_phone": row.get("salePhone"),
"message_count": row.get("messageGroupCount"),
"raw_json": row,
}
async def scrape_wa_groups(cookie_str: str, max_pages: int = 200):
"""Scrape WhatsApp GROUPS via browser fetch POST businessFriendList type=1.
Same endpoint as WA friends with ``type:"1"`` — live-verified 2026-08-20:
rows carry friendUid ending ``@g.us`` (group id), nickName = group name.
Envelope: data.overviews; upsert to chat_group (channel='whatsapp').
"""
from app.db.base import async_engine
from sqlalchemy import text as sa_text
from app.storage.repos import upsert_chat_group
async with async_engine.connect() as conn:
row = (await conn.execute(sa_text(
"SELECT dept_id FROM department WHERE parent_id IS NULL LIMIT 1"
))).first()
department_id = row[0] if row else ""
if not department_id:
logger.warning("wa-groups: no root department in DB — abort")
return 0, 0
page_size = 50
total_fetched = 0
total_upserted = 0
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
page_num = 1
while page_num <= max_pages:
body = await _browser_fetch(
page,
INTERCEPT_PATHS["wa_friend"],
json_body={
"searchInfo": "",
"friendSearchInfo": "",
"page": page_num,
"pageSize": page_size,
"departmentId": department_id,
"type": "1",
},
)
rows = _rows_overviews(body)
records = [rec for r in rows if (rec := _normalize_wa_group_row(r)) is not None]
total_fetched += len(records)
await _upsert_all(upsert_chat_group, records)
total_upserted += len(records)
logger.info(f" wa-groups page {page_num}: {len(rows)} rows, {len(records)} groups")
if len(rows) < page_size:
break
page_num += 1
await asyncio.sleep(1.5)
logger.info(f"DONE: wa-groups 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 channels, upsert to phone_call.
* WeChat: browser-fetch queryWeChatVoiceList (callSource=0) with TWO
passes isRoom=0(好友)/1(群). The voice-call-list page only sends
isRoom=0 on load — group voice calls live behind the 微信群 tab, so
pure navigation+interception never captured them (root cause of the
empty 群 scope tab). Body must include callSource+departmentId+isRoom+
startTime+endTime or the API returns 200 with empty data.
* WA: browser-fetch the same unified endpoint with callSource=4
(no group concept; interception stopped capturing on 2026-08-18).
"""
from app.db.base import async_engine
from sqlalchemy import text as sa_text
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
# queryWeChatVoiceList requires departmentId — take the company-root dept
async with async_engine.connect() as conn:
row = (await conn.execute(sa_text(
"SELECT dept_id FROM department WHERE parent_id IS NULL LIMIT 1"
))).first()
department_id = row[0] if row else ""
if not department_id:
logger.warning("voice-calls: no root department in DB — skipping wechat fetch passes")
page_size = 100
total_fetched = 0
total_upserted = 0
async with PageScraper(cookie_str) as scraper:
# --- WeChat: browser fetch, isRoom 0(好友)/1(群) two passes ---
if department_id:
page = await _open_fetch_page(scraper)
for is_room, label in (("0", "好友"), ("1", "群")):
records = []
page_num = 1
while page_num <= max_pages:
body = await _browser_fetch(
page,
INTERCEPT_PATHS["call_list"],
json_body={
"pageNum": page_num,
"pageSize": page_size,
"callSource": "0",
"isRoom": is_room,
"startTime": "2000-01-01 00:00:00",
"endTime": "2099-12-31 23:59:59",
"sort": "",
"ids": [],
"userId": "",
"departmentId": department_id,
},
)
rows = _rows_flat(body)
for row in rows:
rec = _normalize_voice_call(row)
if rec.get("call_id") is not None:
records.append(rec)
if len(rows) < page_size:
break
page_num += 1
await asyncio.sleep(1)
total_fetched += len(records)
await _upsert_all(upsert_voice_call, records)
total_upserted += len(records)
logger.info(f" wechat isRoom={is_room}({label}): {len(records)} calls, {page_num} page(s)")
await asyncio.sleep(2)
# --- WA: browser fetch (callSource=4, no group concept; same unified endpoint) ---
# Interception on the wavoicecalllist page used to work but captured 0
# responses on 2026-08-18 (page no longer fires the API on load) — direct
# browser fetch with the full body is the reliable path, same as WeChat.
if department_id:
records = []
page_num = 1
while page_num <= max_pages:
body = await _browser_fetch(
page,
INTERCEPT_PATHS["call_list"],
json_body={
"pageNum": page_num,
"pageSize": page_size,
"callSource": "4",
"isRoom": "0",
"startTime": "2000-01-01 00:00:00",
"endTime": "2099-12-31 23:59:59",
"sort": "",
"ids": [],
"userId": "",
"departmentId": department_id,
},
)
rows = _rows_flat(body)
for row in rows:
rec = _normalize_wa_voice_call(row)
if rec.get("call_id") is not None:
records.append(rec)
if len(rows) < page_size:
break
page_num += 1
await asyncio.sleep(1)
total_fetched += len(records)
await _upsert_all(upsert_voice_call, records)
total_upserted += len(records)
logger.info(f" wa(callSource=4): {len(records)} calls, {page_num} page(s)")
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, full_history: bool = False):
"""Scrape WhatsApp chat records via browser fetch POST
/qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}).
Conversation pairs come from _discover_conversations (customer table ∪
voice-call pairs). Pages backward via data.end / data.hasLast
(same cursor strategy as the WeChat msg/chats scraper).
"""
import time as _time
from sqlalchemy import text as sa_text
from app.db.base import async_session_maker
from app.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
from app.storage.repos import upsert_messages
conversations = await _discover_conversations("whatsapp", "wa_voice_call")
watermarks = await _conversation_watermarks("whatsapp")
cust_activity = await _customer_activity("whatsapp")
skipped = 0
if not full_history:
kept = []
for pair in conversations:
wm = watermarks.get(pair, 0)
act = cust_activity.get(pair, 0)
# 注意:不能用 +60s 缓冲——若新消息恰好落在水位后 60s 内,
# act 永远 ≤ wm+60s → 每轮都跳过 → 该消息**永久漏掉**。
# act==wm 表示云客最新消息==我们最新消息,精确相等才跳。
if wm > 0 and act > 0 and act <= wm:
skipped += 1
else:
kept.append(pair)
conversations = kept
logger.info(
f"Discovered WA conversations: {len(conversations)} to visit, {skipped} skipped (no new activity)"
+ (" [FULL]" if full_history else " [incremental]")
)
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 = []
watermark = 0 if full_history else watermarks.get((uid, friend_uid), 0)
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": watermark, "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
# 增量触底停:翻到已同步区
if watermark and _page_min_ts(data) and _page_min_ts(data) <= watermark:
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}")
# Recreate the fetch page; retry patiently on outages (same
# resilience as the WeChat message scraper — 30 × 2min).
for attempt in range(30):
try:
try:
await page.close()
except Exception:
pass
page = await _open_fetch_page(scraper)
break
except Exception as rec:
logger.warning(f" recovery {attempt + 1}/30 failed: {rec}")
if attempt == 29:
raise
await asyncio.sleep(120)
continue
total_fetched += len(records)
if records:
# upsert_messages 收整列表(逐条版 _upsert_all 会把 dict 迭代成
# key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因)
async with async_session_maker() as session:
await upsert_messages(session, records)
# 时效:刷新 customer.last_chat_time(同微信 1:1,云客按最近沟通实时排序)
max_ts = max((r.get("timestamp") or 0) for r in records)
if max_ts:
await session.execute(sa_text(
"UPDATE customer SET last_chat_time = :ts "
"WHERE channel='whatsapp' AND owner_account_id = :o AND friend_id = :f "
"AND (last_chat_time IS NULL OR last_chat_time < :ts)"
), {"ts": max_ts, "o": uid, "f": friend_uid})
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}")
await asyncio.sleep(2 + (i % 3))
logger.info(f"DONE: wa-messages fetched={total_fetched}, upserted={total_upserted}")
return total_fetched, total_upserted
def _normalize_wa_group_message(record: dict) -> dict:
"""chatRecords 群消息行 → Message 形状(friendUid=@g.us,真机确认 2026-08-20)。
与 WA 1:1 行同构,差异:``chatRoomTalker`` = 群内发送者(@lid)、friendUid =
群 id(@g.us);keyId 通常有值(与微信群 msgId=null 不同),缺失时用
owner:room:createTime:chatRoomTalker 合成稳定主键。
"""
from app.scraper.whatsapp import _normalize_whatsapp_message
rec = _normalize_whatsapp_message(record)
rec["room_id"] = record.get("friendUid")
rec["talker_id"] = record.get("chatRoomTalker")
# 群行的 file 常带直连 OSS URL(yk-whatsapp.oss),优先于 1:1 用的 mediaName 不透明名
rec["file_url"] = record.get("file") or record.get("mediaName")
if not rec.get("msg_id"):
rec["msg_id"] = (
f"{record.get('uid')}:{record.get('friendUid')}:"
f"{record.get('createTime')}:{record.get('chatRoomTalker')}"
)
return rec
async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False):
"""WA 群消息:浏览器 fetch chatRecords,friendUid 直接传 @g.us 群 id
(真机确认 2026-08-20,与微信群的 msg/chats+roomId 同套路)。
群来自本地 chat_group(channel='whatsapp');翻页复用 paginate_chats
(hasLast/end 游标);守护循环同微信群抓取器(毒群重启跳过)。
"""
import time as _time
from app.db.base import async_engine, async_session_maker
from app.scraper.whatsapp import _is_wa_divider
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='whatsapp' AND owner_account_id IS NOT NULL"
))
groups = [(row[0], row[1]) for row in result.fetchall()]
room_wms = await _room_watermarks()
logger.info(f"Discovered {len(groups)} WA groups, will scrape {min(max_groups, len(groups))}"
+ (" [FULL]" if full_history else " [incremental]"))
total_fetched = 0
total_upserted = 0
total_pages = 0
scan = groups[:max_groups]
idx = 0
relaunches = 0
while idx < len(scan):
try:
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
while idx < len(scan):
owner_id, room_id = scan[idx]
i = idx
records = []
pages = 0
try:
now_ms = int(_time.time() * 1000)
watermark = 0 if full_history else room_wms.get(room_id, 0)
async def _fetch(end_ms: int, _owner=owner_id, _room=room_id, _wm=watermark):
return await _browser_fetch(
page,
"/qqweb/WhatsAppUser/chatRecords",
json_body={"uid": _owner, "friendUid": _room, "start": _wm, "end": end_ms},
)
async for data in paginate_chats(_fetch, now_ms, max_pages=200):
pages += 1
if watermark and _page_min_ts(data) and _page_min_ts(data) <= watermark:
break
rows = data.get("messages") or []
for row in rows:
if not isinstance(row, dict) or _is_wa_divider(row):
continue
records.append(_normalize_wa_group_message(row))
except Exception as e:
logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}")
for attempt in range(30):
try:
try:
await page.close()
except Exception:
pass
page = await _open_fetch_page(scraper)
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: wa-group-messages fetched={total_fetched}, upserted={total_upserted}, pages={total_pages}")
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)
"group-messages", # 微信群消息 (msg/chats with friendId=roomId)
"accounts", # 微信员工账号 (wechatlist page interception)
"wa-accounts", # WA员工账号 (waBusiness/userlist interception)
"wa-friends", # WA客户 (waBusiness/friendlist interception)
"wa-groups", # WA群 (browser fetch businessFriendList type=1)
"voice-calls", # 语音通话记录 (voice-call-list + wavoicecalllist)
"voice-clips", # 长语音 (browser fetch call/list per employee)
"wa-messages", # WA消息 (browser fetch chatRecords per conversation)
"wa-group-messages", # WA群消息 (chatRecords with friendUid=@g.us)
"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/group-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)")
parser.add_argument("--full-history", action="store_true", help="消息类实体强制全量翻历史(默认增量水位)")
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, args.full_history)
elif args.entity == "group-messages":
await scrape_group_messages(cookie, args.max_conversations, args.full_history)
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 == "wa-groups":
await scrape_wa_groups(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, args.full_history)
elif args.entity == "wa-group-messages":
await scrape_wa_group_messages(cookie, args.max_conversations, args.full_history)
elif args.entity == "media":
await scrape_media(cookie, args.batch_size)
if __name__ == "__main__":
asyncio.run(main())