Files
yunke/yunke-server/tests/cassettes/wa_friends_fixture.py
T
浅念 8a22fd9ff8 feat: whatsapp account/friend/message scrapers
Mirrors the WeChat scraper patterns (M3) using the shared paginator +
parser + repo building blocks. Three new async generators yield
snake_case dicts + verbatim raw_json, ready for the channel-generic
upsert_customer / upsert_messages repos.

- app/scraper/whatsapp.py (new): scrape_whatsapp_accounts /
  scrape_whatsapp_friends (page-number pagination over the
  data.overviews / data.totalCount envelope) and scrape_whatsapp_messages
  (backward-cursor pagination, JSON body {uid,friendUid,start,end} —
  NOT form-encoded like WeChat msg/chats). Drops type==-2 dividers;
  maps mediaName -> file_url as a deferred-download key.
- app/scraper/parsers.py: add normalize_whatsapp_type (0->text,
  1->image, -2->divider, else unknown; never raises) and a shared
  coerce_int_or_none scraper helper (mirrors repos._coerce_int so the
  scraper emits type_raw ints without a DB dependency).
- app/storage/repos.py: add upsert_whatsapp_account (PK uid) which
  also upserts the embedded Staff(user_id, name) via the existing
  upsert_staff_from_account helper; WhatsappAccount imported.

Tests (respx mocks, no network): 3 cassette fixtures + 3 scraper test
modules (accounts/friends/messages), parsers + repos extended with WA
cases. Divider drop, backward-cursor advance (end == E1-1), JSON body
asserts, embedded-staff upsert, and channel-generic Customer/Message
upserts all covered.

Suite: 182 -> 211 passing. ruff clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 15:48:03 +08:00

130 lines
4.4 KiB
Python

"""Fixture payloads for ``POST /qqweb/WhatsAppUser/businessFriendList`` (M3).
These records mirror the real field names captured from the live CRM (see
phase0-findings.md §5; live total = **11292**):
``uid`` (owning staff ``<phone>@s.whatsapp.net``), ``userId`` / ``userName`` /
``salePhone`` / ``salesNickName`` / ``salesBindPhone`` (owning staff),
``friendUid`` (customer -> ``friend_id`` PK), ``nickName`` (customer nickname),
``phone``, ``createTime`` / ``lastUpdate``, ``groupNum`` / ``contactsNum`` /
``talkerCount`` / ``friendAddNum`` / ``messageCount`` / ``sensitiveWordNum``.
The envelope is the SAME shape as ``businessSalesList``: rows under
``data.overviews``, declared count under ``data.totalCount``, page count under
``data.pageCount``::
{"message":"调用成功","code":"10000","success":true,"traceId":"...",
"data":{"page":.., "pageCount":.., "totalCount":.., "overviews":[...]}}
The fixture's ``TOTAL`` is pinned to the record count (not the live 11292) so
the paginator's over-fetch ceiling does not fire early in the multi-page test.
"""
from __future__ import annotations
import math
from typing import Any
# ---------------------------------------------------------------------------
# Capture-shaped WhatsApp contact records (3 total across 2 pages).
# ---------------------------------------------------------------------------
WA_FRIEND_RECORDS: list[dict[str, Any]] = [
{
"uid": "8613800000001@s.whatsapp.net", # owning staff account
"userId": "wa_staff_001",
"userName": "Alice Wang",
"salePhone": "8613800000001",
"salesNickName": "Alice Sale",
"salesBindPhone": "8613900000001",
"friendUid": "8613800000111@s.whatsapp.net", # customer -> friend_id
"nickName": "Customer One",
"phone": "8613800000111",
"createTime": 1699000000000,
"lastUpdate": 1700000000123,
"groupNum": 2,
"contactsNum": 421,
"talkerCount": 38,
"friendAddNum": 1,
"messageCount": 57,
"sensitiveWordNum": 0,
"version": "v1.20260731.cust1",
},
{
"uid": "8613800000001@s.whatsapp.net",
"userId": "wa_staff_001",
"userName": "Alice Wang",
"salePhone": "8613800000001",
"salesNickName": "Alice Sale",
"salesBindPhone": "8613900000001",
"friendUid": "8613800000222@s.whatsapp.net",
"nickName": "Customer Two",
"phone": "8613800000222",
"createTime": 1699100000000,
"lastUpdate": 1700000000456,
"groupNum": 1,
"contactsNum": 421,
"talkerCount": 38,
"friendAddNum": 1,
"messageCount": 9,
"sensitiveWordNum": 0,
"version": "v1.20260731.cust2",
},
{
# Contact owned by a different staff account.
"uid": "8613800000002@s.whatsapp.net",
"userId": "wa_staff_002",
"userName": "Bob Chen",
"salePhone": "8613800000002",
"salesNickName": "Bob Sale",
"salesBindPhone": "8613900000002",
"friendUid": "8613800000333@s.whatsapp.net",
"nickName": "Customer Three",
"phone": "8613800000333",
"createTime": 1699200000000,
"lastUpdate": 1700000000789,
"groupNum": 0,
"contactsNum": 58,
"talkerCount": 9,
"friendAddNum": 0,
"messageCount": 4,
"sensitiveWordNum": 1,
"version": "v1.20260731.cust3",
},
]
TOTAL = len(WA_FRIEND_RECORDS)
DEFAULT_PAGE_SIZE = 2
def make_wa_friends_response(
records: list[dict[str, Any]],
*,
total_count: int,
page: int = 1,
page_size: int = DEFAULT_PAGE_SIZE,
page_count: int | None = None,
trace_id: str | None = None,
) -> dict[str, Any]:
"""Build a capture-shaped ``businessFriendList`` response envelope.
Shape (same as ``businessSalesList``)::
{"message":"调用成功","code":"10000","success":true,"traceId":"...",
"data":{"page":.., "pageCount":.., "totalCount":.., "overviews":[...]}}
"""
if page_count is None:
page_count = math.ceil(total_count / page_size) if page_size else 1
return {
"message": "调用成功",
"code": "10000",
"success": True,
"traceId": trace_id or f"trace-wa-friend-{page}",
"data": {
"page": page,
"pageCount": page_count,
"totalCount": total_count,
"overviews": list(records),
},
}