feat(scraper): 五实体导航拦截全改浏览器fetch(登录墙杀死拦截方式0捕获)

- friends/groups/friend_list/es 部门级全量(记录自带owner,告别逐员工遍历)
- accounts/wxUser/list form模式(页码字段是page非pageNum,粘页检测保险)
- wa-accounts/wa-friends 直发;wa-friends 200页1万行(疑端点1万行硬上限)
- _fetch_pages 通用翻页助手:cookie失效首页code告警+粘页检测
- 实测:微信客户5916→6563/WA客户8058→8585/群851→873/账号50→55(捞回登录墙期间漏抓)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-20 10:41:33 +08:00
parent 10cdd81d5c
commit b522be828a
+186 -134
View File
@@ -256,108 +256,68 @@ class PageScraper:
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
"""微信客户:浏览器直发 friend_list/es 部门级全量翻页(2026-08-20 起,
旧导航+拦截被登录墙杀死 0 捕获)。记录自带 owner(wxId),无需逐员工遍历;
``max_employees`` 参数保留兼容 CLI,已不使用。"""
from app.scraper.wechat import _normalize
from app.storage.repos import upsert_customer
from app.scraper.wechat import _normalize # reuse the existing normalizer
total_fetched = 0
total_upserted = 0
dept = await _root_dept_id()
base_body = {"departmentId": dept} if dept else {}
async with PageScraper(cookie_str) as scraper:
employees = await scraper.get_employee_links()
page = await _open_fetch_page(scraper)
rows = await _fetch_pages(
page, INTERCEPT_PATHS["friend_list"],
base_body=base_body, parse_rows=_rows_basic_result,
)
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")
records = []
for row in rows:
rec = _normalize(row)
if rec.get("friend_id"):
records.append(rec)
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
total_upserted = 0
for i in range(0, len(records), 500):
await _upsert_all(upsert_customer, records[i:i + 500])
total_upserted += len(records[i:i + 500])
total_fetched = len(records)
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
"""微信群:浏览器直发 friend_list/es + friendTypeYunke=2 部门级全量翻页
(与好友共用端点,仅 body 开关不同;2026-08-20 起改 fetch)。"""
from app.scraper.wechat import _normalize_chat_group
from app.storage.repos import upsert_chat_group
total_fetched = 0
total_upserted = 0
dept = await _root_dept_id()
base_body: dict = {"friendTypeYunke": "2"}
if dept:
base_body["departmentId"] = dept
async with PageScraper(cookie_str) as scraper:
employees = await scraper.get_employee_links()
page = await _open_fetch_page(scraper)
rows = await _fetch_pages(
page, INTERCEPT_PATHS["friend_list"],
base_body=base_body, parse_rows=_rows_basic_result,
)
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")
records = []
for row in rows:
rec = _normalize_chat_group(row)
if rec.get("room_id"):
records.append(rec)
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))
total_upserted = 0
for i in range(0, len(records), 500):
await _upsert_all(upsert_chat_group, records[i:i + 500])
total_upserted += len(records[i:i + 500])
total_fetched = len(records)
logger.info(f"DONE: groups fetched={total_fetched}, upserted={total_upserted}")
return total_fetched, total_upserted
@@ -835,6 +795,80 @@ def _rows_flat(body: dict) -> list[dict]:
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)."""
@@ -863,91 +897,109 @@ async def _upsert_all(upsert_fn, records: list[dict]) -> None:
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)."""
"""微信员工账号:浏览器直发 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
total_fetched = 0
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:
responses = await scraper.scrape_entity_pages(
list_url=f"{YUNKE_BASE}/cms/wechat/wechatlist",
api_path=INTERCEPT_PATHS["wxUser_list"],
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 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
records = [_normalize_wxuser_account(row) for row in rows]
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 (navigation + interception)
# WhatsApp staff accounts (browser fetch)
# ---------------------------------------------------------------------------
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."""
"""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:
responses = await scraper.scrape_entity_pages(
list_url=f"{YUNKE_BASE}/cms/wechat/waBusiness/userlist",
api_path=INTERCEPT_PATHS["wa_user"],
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 = []
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)
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 (navigation + interception)
# WhatsApp customers (browser fetch)
# ---------------------------------------------------------------------------
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."""
"""WA 客户:浏览器直发 businessFriendList type=0(与 WA 群共用端点,
2026-08-20 起改 fetch;注意此端点页码字段是 ``page`` 非 ``pageNum``)。"""
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)
dept = await _root_dept_id()
base_body: dict = {
"searchInfo": "",
"friendSearchInfo": "",
"departmentId": dept,
"type": "0",
}
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
rows = await _fetch_pages(
page, INTERCEPT_PATHS["wa_friend"],
base_body=base_body, parse_rows=_rows_overviews,
page_key="page", max_pages=max_pages,
)
records = []
for row in rows:
rec = _normalize_whatsapp_friend(row)
if rec.get("friend_id"):
records.append(rec)
total_upserted = 0
for i in range(0, len(records), 500):
await _upsert_all(upsert_customer, records[i:i + 500])
total_upserted += len(records[i:i + 500])
total_fetched = len(records)
logger.info(f"DONE: wa-friends fetched={total_fetched}, upserted={total_upserted}")
return total_fetched, total_upserted