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>
This commit is contained in:
浅念
2026-08-01 15:48:03 +08:00
parent 2508b5aec1
commit 8a22fd9ff8
11 changed files with 1854 additions and 12 deletions
+81 -11
View File
@@ -1,24 +1,29 @@
"""Field / type-code normalization helpers for upstream record parsers. """Field / type-code normalization helpers for upstream record parsers.
The yunke CRM uses opaque integer type codes (``type`` / ``messageType``) on The yunke CRM uses opaque integer type codes (``type`` / ``messageType`` on
WeChat message records. The storage layer prefers stable enum strings so that WeChat, ``type`` / ``msgType`` on WhatsApp) on message records. The storage
media dispatch (image / voice / video / file) and presentation (text vs. layer prefers stable enum strings so that media dispatch (image / voice /
date divider) can be keyed off a single normalized value rather than the video / file) and presentation (text vs. date divider) can be keyed off a
raw code. single normalized value rather than the raw code.
This module is intentionally tiny: one total function that maps the This module is intentionally tiny: one total function per channel that maps
Phase-0 confirmed codes and falls through to ``"unknown"`` for everything the Phase-0 confirmed codes and falls through to ``"unknown"`` for everything
else. It MUST NOT raise — every record pulled during a scrape passes through else. Each MUST NOT raise — every record pulled during a scrape passes through
here, and one unexpected code must not abort the whole pull. The raw code is here, and one unexpected code must not abort the whole pull. The raw code is
preserved on the record's ``raw_json`` so the persistence layer can recover preserved on the record's ``raw_json`` so the persistence layer can recover it
it when needed. when needed.
:func:`coerce_int_or_none` is the shared scraper-layer int coercion (mirrors
:func:`app.storage.repos._coerce_int`) used to emit a ready-to-store
``type_raw`` int and to test raw type codes (e.g. the ``-2`` divider drop)
without the scraper taking a DB dependency.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
__all__ = ["normalize_wechat_type"] __all__ = ["coerce_int_or_none", "normalize_wechat_type", "normalize_whatsapp_type"]
# Phase-0 confirmed WeChat message type codes (see # Phase-0 confirmed WeChat message type codes (see
# docs/superpowers/specs/phase0-findings.md §2). Captured by live inspection # docs/superpowers/specs/phase0-findings.md §2). Captured by live inspection
@@ -63,3 +68,68 @@ def normalize_wechat_type(raw: Any) -> str:
except (TypeError, ValueError): except (TypeError, ValueError):
return "unknown" return "unknown"
return _WECHAT_TYPE_NAMES.get(code, "unknown") return _WECHAT_TYPE_NAMES.get(code, "unknown")
# Phase-0 confirmed WhatsApp message type codes (see data dictionary §3 +
# phase0-findings.md §5). Captured by live inspection of the yunke CRM.
# * ``0`` — plain text.
# * ``1`` — image / media. ``mediaName`` is a deferred-download FILENAME
# (not a URL); the media layer resolves it via a separate endpoint.
# * ``-2`` — date divider. NOT a real message; the scraper uses this label as
# its drop signal (filtered out, never yielded, never persisted).
# Other codes pass through as ``"unknown"`` so the message is still ingested
# (raw code kept on ``raw_json``) rather than dropped. Extend this table as
# each is confirmed.
_WHATSAPP_TYPE_NAMES: dict[int, str] = {
0: "text",
1: "image",
-2: "divider",
}
def normalize_whatsapp_type(raw: Any) -> str:
"""Map an upstream WhatsApp ``type`` / ``msgType`` code to a stable string.
Confirmed mappings: ``0→text``, ``1→image``, ``-2→divider``. Any other /
missing / unparsable value returns ``"unknown"``.
Never raises: an unexpected type must not break the scrape. The raw code
is preserved on ``raw_json`` so the persistence layer can recover it.
"""
try:
code = int(raw)
except (TypeError, ValueError):
return "unknown"
return _WHATSAPP_TYPE_NAMES.get(code, "unknown")
def coerce_int_or_none(value: Any) -> int | None:
"""Coerce a possibly-string numeric value to ``int``; ``None`` preserved.
Mirrors :func:`app.storage.repos._coerce_int` so the scraper can emit a
ready-to-store ``type_raw`` int and check raw type codes (e.g. the ``-2``
divider drop) without depending on the storage layer (the scraper has no
DB dependency by design). A non-numeric value (``"text"`` / ``None`` /
missing) returns ``None``.
Kept in this module (rather than per-scraper) so both the WeChat and
WhatsApp scrapers share one well-tested coercion instead of each carrying
a private copy.
"""
if value is None:
return None
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
s = value.strip()
if not s:
return None
try:
return int(s)
except ValueError:
return None
return None
+460
View File
@@ -0,0 +1,460 @@
"""WhatsApp scrapers (M3).
Endpoints covered:
* :func:`scrape_whatsapp_accounts` — wraps ``POST /qqweb/WhatsAppUser/businessSalesList``
and yields normalized :class:`app.db.models.WhatsappAccount` dicts (plus the
embedded ``userId`` / ``userName`` staff pair). Page-number pagination.
* :func:`scrape_whatsapp_friends` — wraps ``POST /qqweb/WhatsAppUser/businessFriendList``
and yields normalized dicts ready to be upserted into
:class:`app.db.models.Customer` with ``channel="whatsapp"``. Page-number
pagination.
* :func:`scrape_whatsapp_messages` — wraps ``POST /qqweb/WhatsAppUser/chatRecords``
and yields normalized chat-message dicts with ``channel="whatsapp"``.
Backward-cursor pagination by timestamp (the same strategy as WeChat
``msg/chats``), but with a **JSON** request body (NOT form-encoded).
All three reuse :func:`app.scraper.base.paginate_by_page` /
:func:`paginate_by_cursor` and emit snake_case dicts + a verbatim ``raw_json``
copy, mirroring :mod:`app.scraper.wechat`.
Envelope difference from WeChat list endpoints: rows sit under
``data.overviews`` (NOT ``data.data``) and the declared row count is
``data.totalCount`` (NOT ``data.total``); the page count is ``data.pageCount``.
The same envelope is shared by ``businessSalesList`` and ``businessFriendList``.
Auth is the same crm2 session cookie the WeChat scrapers use (Phase-0 §1
confirmed cookie-only auth suffices for every data endpoint).
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
from app.scraper.base import paginate_by_cursor, paginate_by_page
from app.scraper.parsers import coerce_int_or_none, normalize_whatsapp_type
from app.session.yunke_client import YunkeClient
__all__ = [
"scrape_whatsapp_accounts",
"scrape_whatsapp_friends",
"scrape_whatsapp_messages",
]
WHATSAPP_ACCOUNTS_PATH = "/qqweb/WhatsAppUser/businessSalesList"
WHATSAPP_FRIENDS_PATH = "/qqweb/WhatsAppUser/businessFriendList"
WHATSAPP_MESSAGES_PATH = "/qqweb/WhatsAppUser/chatRecords"
# ===========================================================================
# Account scraper (M3) — POST /qqweb/WhatsAppUser/businessSalesList
# ===========================================================================
def _parse_wa_account_total(resp: Any) -> int | None:
"""Read the declared row count from the ``businessSalesList`` envelope.
Shape: ``data.totalCount`` (an int). The page count is ``data.pageCount``
but the paginator only consumes the row-count ceiling; the empty-page
signal remains the primary stop. Returns ``None`` if the path is absent so
the paginator falls back to the empty-page stop signal.
"""
try:
body = resp.json()
except (AttributeError, ValueError):
return None
data = body.get("data") if isinstance(body, dict) else None
if not isinstance(data, dict):
return None
total = data.get("totalCount")
if isinstance(total, bool):
return None
return int(total) if isinstance(total, (int, float)) else None
def _parse_wa_account_rows(resp: Any) -> list[dict[str, Any]]:
"""Extract the account list from the ``businessSalesList`` envelope.
Shape: ``data.overviews`` (a list of records). Missing / non-list values
collapse to an empty list so the paginator sees "empty page".
"""
try:
body = resp.json()
except (AttributeError, ValueError):
return []
data = body.get("data") if isinstance(body, dict) else None
if not isinstance(data, dict):
return []
rows = data.get("overviews")
return rows if isinstance(rows, list) else []
def _build_wa_account_body(page_num: int, page_size: int) -> dict[str, Any]:
"""Build the ``businessSalesList`` request body for a given page.
The live capture carries no filter parameters — just the page slice.
"""
return {"pageNum": page_num, "pageSize": page_size}
def _normalize_whatsapp_account(record: dict[str, Any]) -> dict[str, Any]:
"""Map one ``businessSalesList`` record to the ``WhatsappAccount`` shape.
Carries the embedded staff pair (``user_id`` <- ``userId``,
``user_name`` <- ``userName``) so the persistence layer can upsert the
owning :class:`app.db.models.Staff` row alongside the account. Adds
``raw_json`` (the verbatim upstream record). ``None``-safe: missing fields
become ``None`` rather than raising, so a partial payload does not break
the scrape.
"""
return {
"uid": record.get("uid"),
"user_id": record.get("userId"),
"user_name": record.get("userName"),
"nick_name": record.get("nickName"),
"phone": record.get("phone"),
"sale_phone": record.get("salePhone"),
"sales_bind_phone": record.get("salesBindPhone"),
"contacts_num": record.get("contactsNum"),
"group_num": record.get("groupNum"),
"talker_count": record.get("talkerCount"),
"friend_add_num": record.get("friendAddNum"),
"is_on_job": record.get("isOnJob"),
"last_update": record.get("lastUpdate"),
"version": record.get("version"),
"raw_json": record,
}
async def scrape_whatsapp_accounts(
client: YunkeClient,
*,
page_size: int = 50,
) -> AsyncIterator[dict[str, Any]]:
"""Yield normalized WhatsApp staff-account records from ``businessSalesList``.
Walks the endpoint page by page until the upstream reports no more rows
(empty ``data.overviews`` array — the primary stop signal), mapping each
record to the ``WhatsappAccount`` column shape plus a verbatim ``raw_json``
copy.
The envelope differs from WeChat ``wxUser/list``: rows sit under
``data.overviews`` (not ``data.data``) and the declared total is
``data.totalCount`` (not ``data.total``). The page count is
``data.pageCount``.
Parameters
----------
client:
Authenticated :class:`YunkeClient`. Auth headers / 401 re-login are
handled by the client.
page_size:
Page size to request. Defaults to 50 (matches the observed live page
size for this endpoint).
"""
async for record in paginate_by_page(
client,
WHATSAPP_ACCOUNTS_PATH,
page_size=page_size,
parse_total=_parse_wa_account_total,
parse_rows=_parse_wa_account_rows,
build_body=_build_wa_account_body,
):
yield _normalize_whatsapp_account(record)
# ===========================================================================
# Friend scraper (M3) — POST /qqweb/WhatsAppUser/businessFriendList
# ===========================================================================
def _parse_wa_friend_total(resp: Any) -> int | None:
"""Read the declared row count from the ``businessFriendList`` envelope.
Shape: ``data.totalCount`` (live total is 11292 per phase0-findings.md §5).
"""
try:
body = resp.json()
except (AttributeError, ValueError):
return None
data = body.get("data") if isinstance(body, dict) else None
if not isinstance(data, dict):
return None
total = data.get("totalCount")
if isinstance(total, bool):
return None
return int(total) if isinstance(total, (int, float)) else None
def _parse_wa_friend_rows(resp: Any) -> list[dict[str, Any]]:
"""Extract the friend list from the ``businessFriendList`` envelope.
Shape: ``data.overviews`` (same envelope as ``businessSalesList``).
"""
try:
body = resp.json()
except (AttributeError, ValueError):
return []
data = body.get("data") if isinstance(body, dict) else None
if not isinstance(data, dict):
return []
rows = data.get("overviews")
return rows if isinstance(rows, list) else []
def _build_wa_friend_body(page_num: int, page_size: int) -> dict[str, Any]:
"""Build the ``businessFriendList`` request body for a given page."""
return {"pageNum": page_num, "pageSize": page_size}
def _normalize_whatsapp_friend(record: dict[str, Any]) -> dict[str, Any]:
"""Map one ``businessFriendList`` record to the ``Customer`` column shape.
Adds ``channel="whatsapp"``. Field mapping per phase0-findings.md §5:
* ``friend_id`` <- ``friendUid`` (PK, ``<phone>@s.whatsapp.net``)
* ``owner_account_id`` <- ``uid`` (the owning staff WA account)
* ``user_id`` <- ``userId`` (owning staff)
* ``sale_name`` <- ``salesNickName`` (owning staff sale name)
* ``friend_nickname`` <- ``nickName``
* ``phone`` <- ``phone``
* ``message_count`` <- ``messageCount``
* ``last_chat_time`` <- ``lastUpdate`` (ms epoch of last activity)
Preserves the verbatim record under ``raw_json`` (so the owner-staff
fields ``userName`` / ``salePhone`` / ``salesBindPhone`` / ``createTime``
and the per-contact counts ``groupNum`` / ``contactsNum`` / ``talkerCount``
/ ``friendAddNum`` / ``sensitiveWordNum`` survive end-to-end even though
they have no direct ``Customer`` column). ``None``-safe on every field.
"""
return {
"friend_id": record.get("friendUid"),
"channel": "whatsapp",
"owner_account_id": record.get("uid"),
"friend_nickname": record.get("nickName"),
"phone": record.get("phone"),
"sale_name": record.get("salesNickName"),
"user_id": record.get("userId"),
"message_count": record.get("messageCount"),
"last_chat_time": record.get("lastUpdate"),
"version": record.get("version"),
"raw_json": record,
}
async def scrape_whatsapp_friends(
client: YunkeClient,
*,
page_size: int = 50,
) -> AsyncIterator[dict[str, Any]]:
"""Yield normalized WhatsApp contact records from ``businessFriendList``.
Walks the endpoint page by page until the upstream reports no more rows
(empty ``data.overviews`` array — the primary stop signal), mapping each
record to the ``Customer`` column shape with ``channel="whatsapp"`` plus a
verbatim ``raw_json`` copy.
Parameters
----------
client:
Authenticated :class:`YunkeClient`. Auth headers / 401 re-login are
handled by the client.
page_size:
Page size to request. Defaults to 50.
"""
async for record in paginate_by_page(
client,
WHATSAPP_FRIENDS_PATH,
page_size=page_size,
parse_total=_parse_wa_friend_total,
parse_rows=_parse_wa_friend_rows,
build_body=_build_wa_friend_body,
):
yield _normalize_whatsapp_friend(record)
# ===========================================================================
# Message scraper (M3) — POST /qqweb/WhatsAppUser/chatRecords
# ===========================================================================
async def _fetch_wa_msg_page(
client: YunkeClient,
*,
uid: str,
friend_uid: str,
start: int,
end_cursor: int,
) -> tuple[list[dict[str, Any]], int, bool]:
"""Fetch one backward page of ``chatRecords``.
Returns the ``(rows, next_cursor, has_more)`` triple expected by
:func:`paginate_by_cursor`. Unlike WeChat ``msg/chats`` this endpoint takes
a **JSON** body (content-type ``application/json``), not form-encoded.
``next_cursor`` is ``data.end - 1`` so the next call's window is
``(end-1, ...]`` — i.e. strictly older than the oldest message in this
batch; ``has_more`` is ``data.hasLast``.
"""
resp = await client.post(
WHATSAPP_MESSAGES_PATH,
json={
"uid": uid,
"friendUid": friend_uid,
"start": start,
"end": end_cursor,
},
)
try:
body = resp.json()
except (AttributeError, ValueError):
# Malformed envelope — treat as empty + no-more so the paginator stops.
return [], end_cursor, False
data = body.get("data") if isinstance(body, dict) else None
if not isinstance(data, dict):
return [], end_cursor, False
rows = data.get("messages")
if not isinstance(rows, list):
rows = []
end = data.get("end")
next_cursor = (
int(end) - 1
if isinstance(end, (int, float)) and not isinstance(end, bool)
else end_cursor
)
has_more = bool(data.get("hasLast"))
return rows, next_cursor, has_more
def _normalize_whatsapp_message(record: dict[str, Any]) -> dict[str, Any]:
"""Map one ``chatRecords`` record to the ``Message`` column shape.
Adds ``channel="whatsapp"`` and preserves the original record under
``raw_json``. Per design §4.3 the message type is carried as TWO fields:
* ``type`` — the normalized enum label (``"text"`` / ``"image"`` /
``"unknown"`` / ...) via :func:`normalize_whatsapp_type`.
* ``type_raw`` — the original upstream type code (``type`` falling back to
``msgType``), coerced to ``int | None`` so the raw code survives
end-to-end without round-tripping through ``raw_json``.
``mediaName`` (a deferred-download filename, NOT a URL — see phase0 §6) is
carried on ``file_url`` so the media layer can route on ``channel`` /
``type``; this mirrors the WeChat ``type=3`` image-legacy pattern where an
opaque download key (not a URL) is stored on ``file_url``. ``raw_json``
preserves it verbatim either way. ``quotedId`` / ``quotedMessage`` collapse
into ``refer_msg`` as a small dict when either is present. No ``room_id``
for WA 1:1 chats.
``None``-safe on every field.
"""
# ``type`` is the documented field; fall back to ``msgType`` (some captures
# populate one, some the other — same disambiguation strategy as WeChat).
raw_type = record.get("type")
if raw_type is None:
raw_type = record.get("msgType")
# ``createTime`` is the canonical timestamp; fall back to ``dateTime``.
timestamp = record.get("createTime")
if timestamp is None:
timestamp = record.get("dateTime")
# Quoted-message reference: preserve both id + content when either present
# (a bare None/None pair would just be noise on the JSON column).
quoted_id = record.get("quotedId")
quoted_message = record.get("quotedMessage")
refer_msg = (
{"quotedId": quoted_id, "quotedMessage": quoted_message}
if quoted_id is not None or quoted_message is not None
else None
)
return {
"msg_id": record.get("keyId"),
"channel": "whatsapp",
"owner_account_id": record.get("uid"),
"talker_id": record.get("friendUid"),
"is_send": record.get("isSend"),
"type": normalize_whatsapp_type(raw_type),
"type_raw": coerce_int_or_none(raw_type),
"text": record.get("content"),
"file_url": record.get("mediaName"),
"refer_msg": refer_msg,
"sender_name": record.get("nickName"),
"timestamp": timestamp,
"raw_json": record,
}
def _is_wa_divider(record: dict[str, Any]) -> bool:
"""True iff this record is a date-divider (raw type code ``-2``).
These are not real messages — the upstream inserts them as visual date
bars in the chat UI — and must never be yielded or persisted. Per the M3
spec we drop a record when EITHER ``type`` or ``msgType`` is ``-2``; all
other unknown codes pass through.
The check is on the RAW code (not the normalized label) so the drop
decision is robust against any future change to
:func:`normalize_whatsapp_type`'s label vocabulary.
"""
return coerce_int_or_none(record.get("type")) == -2 or (
coerce_int_or_none(record.get("msgType")) == -2
)
async def scrape_whatsapp_messages(
client: YunkeClient,
uid: str,
friend_uid: str,
start: int,
end: int,
) -> AsyncIterator[dict[str, Any]]:
"""Yield normalized WhatsApp chat messages, paging backward by timestamp.
Wraps ``POST /qqweb/WhatsAppUser/chatRecords`` with a **JSON** body
(``{uid, friendUid, start, end}`` — NOT form-encoded like WeChat
``msg/chats``). The endpoint pages BACKWARD: each response carries
``data.end`` (oldest timestamp in this batch) and ``data.hasLast`` (older
messages exist?). The scraper advances the cursor to ``end - 1`` and
repeats until ``hasLast`` is false, then stops.
Date dividers (``type == -2``) are dropped — they are UI chrome, not real
messages. All other type codes pass through (mapped to ``"unknown"`` when
not in our small known-table) so no real message is silently lost.
Parameters
----------
client:
Authenticated :class:`YunkeClient`. Auth headers / 401 re-login are
handled by the client.
uid:
Owning staff WhatsApp account id (``uid`` JSON field, of the form
``<phone>@s.whatsapp.net``) — maps to ``owner_account_id`` on each
yielded record.
friend_uid:
Customer WhatsApp id (``friendUid`` JSON field) — selects the 1:1
conversation to pull.
start, end:
Time window in ms epoch. ``end`` is also the initial backward cursor;
the first request queries the ``(start, end]`` window and the cursor
walks toward ``start`` from there.
"""
async for record in paginate_by_cursor(
lambda end_cursor: _fetch_wa_msg_page(
client,
uid=uid,
friend_uid=friend_uid,
start=start,
end_cursor=end_cursor,
),
initial_cursor=end,
):
if _is_wa_divider(record):
continue
yield _normalize_whatsapp_message(record)
+21
View File
@@ -63,6 +63,7 @@ from app.db.models import (
PhoneCall, PhoneCall,
Staff, Staff,
WechatAccount, WechatAccount,
WhatsappAccount,
) )
__all__ = [ __all__ = [
@@ -76,6 +77,7 @@ __all__ = [
"upsert_staff_from_account", "upsert_staff_from_account",
"upsert_voice_call", "upsert_voice_call",
"upsert_wechat_account", "upsert_wechat_account",
"upsert_whatsapp_account",
] ]
@@ -411,6 +413,25 @@ async def upsert_staff_from_account(session: AsyncSession, rec: dict[str, Any])
await upsert_staff(session, staff_rec) await upsert_staff(session, staff_rec)
async def upsert_whatsapp_account(session: AsyncSession, rec: dict[str, Any]) -> None:
"""Idempotently upsert a :class:`WhatsappAccount` AND its embedded Staff row.
PK = ``uid`` (of the form ``<phone>@s.whatsapp.net``). Mirrors
:func:`upsert_wechat_account`: the companion :func:`upsert_staff_from_account`
covers the embedded staff row (``user_id`` + ``user_name``); this helper
calls it automatically so an account scrape that lands accounts also lands
the owning staff — callers do not need to upsert staff separately.
The WA account payload (unlike the WeChat one) DOES carry ``sale_phone``,
so the embedded staff row picks it up via the shared staff-from-account
helper. The staff upsert is best-effort: an account record with no
``user_id`` still writes the account row; the staff upsert silently
no-ops.
"""
await _upsert_one(session, WhatsappAccount, rec)
await upsert_staff_from_account(session, rec)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ChatGroup + GroupMember upsert (M2) # ChatGroup + GroupMember upsert (M2)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -0,0 +1,148 @@
"""Fixture payloads for ``POST /qqweb/WhatsAppUser/businessSalesList`` (M3).
These records mirror the real field names captured from the live CRM (see the
data dictionary §3 and phase0-findings.md §5):
``uid`` (PK, ``<phone>@s.whatsapp.net``), ``userId`` / ``userName`` (owning
staff real name), ``nickName``, ``phone`` / ``salePhone`` / ``salesBindPhone``,
``contactsNum`` / ``groupNum`` / ``talkerCount`` / ``friendAddNum``,
``isOnJob``, ``lastUpdate``.
The envelope differs from the WeChat list endpoints: rows sit under
``data.overviews`` (NOT ``data.data``) and the declared row count is
``data.totalCount`` (NOT ``data.total``); the page count is ``data.pageCount``::
{"message":"调用成功","code":"10000","success":true,"traceId":"...",
"data":{"page":.., "pageCount":.., "totalCount":.., "overviews":[...]}}
The cassette ships two pages so a test can drive the page-number paginator
across the empty-page stop signal:
* page 1 -> 2 of the 4 fixture records, ``totalCount=4``
* page 2 -> the other 2 records, ``totalCount=4``
* (page 3 -> empty ``overviews`` array, served inline by the test)
Records carry every field with a target ``WhatsappAccount`` column so the test
can assert a full mapping end-to-end, plus the embedded ``userId`` + ``userName``
staff pair (the owning employee) so the embedded-staff repo upsert can be
exercised against capture-shaped input (including a no-``userId`` orphan to
exercise the best-effort skip path).
"""
from __future__ import annotations
from typing import Any
# ---------------------------------------------------------------------------
# Capture-shaped WhatsApp staff-account records (4 total across 2 pages).
# ---------------------------------------------------------------------------
WA_ACCOUNT_RECORDS: list[dict[str, Any]] = [
{
"uid": "8613800000001@s.whatsapp.net",
"userId": "wa_staff_001",
"userName": "Alice Wang",
"nickName": "Alice WA",
"phone": "8613800000001",
"salePhone": "8613800000001",
"salesBindPhone": "8613900000001",
"contactsNum": 421,
"groupNum": 12,
"talkerCount": 38,
"friendAddNum": 7,
"isOnJob": 1,
"lastUpdate": 1700000000123,
"version": "v1.20260731.alice.wa",
},
{
"uid": "8613800000002@s.whatsapp.net",
"userId": "wa_staff_002",
"userName": "Bob Chen",
"nickName": "Bob WA",
"phone": "8613800000002",
"salePhone": "8613800000002",
"salesBindPhone": "8613900000002",
"contactsNum": 58,
"groupNum": 2,
"talkerCount": 9,
"friendAddNum": 1,
"isOnJob": 1,
"lastUpdate": 1700000000456,
"version": "v1.20260731.bob.wa",
},
{
"uid": "8613800000003@s.whatsapp.net",
"userId": "wa_staff_003",
"userName": "Carol Liu",
"nickName": "Carol WA",
"phone": "8613800000003",
"salePhone": "8613800000003",
"salesBindPhone": "8613900000003",
"contactsNum": 1899,
"groupNum": 64,
"talkerCount": 210,
"friendAddNum": 33,
"isOnJob": 0, # departed staff — exercises the falsy isOnJob path
"lastUpdate": 1700000000789,
"version": "v1.20260731.carol.wa",
},
{
# Account with NO embedded staff (userId missing) — the scraper must
# still yield it; the embedded-staff repo upsert must skip it cleanly.
"uid": "8613800000004@s.whatsapp.net",
"userId": None,
"userName": None,
"nickName": "Orphan WA",
"phone": "8613800000004",
"salePhone": "8613800000004",
"salesBindPhone": "8613900000004",
"contactsNum": 3,
"groupNum": 0,
"talkerCount": 1,
"friendAddNum": 0,
"isOnJob": 0,
"lastUpdate": 1700000001000,
"version": "v1.20260731.orphan.wa",
},
]
# Default page size for the fixture (and the scraper). totalCount=4 means the
# fixture fills exactly 2 pages of size 2 plus a terminating empty page.
TOTAL = len(WA_ACCOUNT_RECORDS)
DEFAULT_PAGE_SIZE = 2
def make_wa_accounts_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 ``businessSalesList`` response envelope.
Shape::
{"message":"调用成功","code":"10000","success":true,"traceId":"...",
"data":{"page":.., "pageCount":.., "totalCount":.., "overviews":[...]}}
``page_count`` defaults to ``ceil(total_count / page_size)`` when omitted.
"""
if page_count is None:
import math
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-acct-{page}",
"data": {
"page": page,
"pageCount": page_count,
"totalCount": total_count,
"overviews": list(records),
},
}
@@ -0,0 +1,129 @@
"""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),
},
}
@@ -0,0 +1,142 @@
"""Fixture payloads for ``POST /qqweb/WhatsAppUser/chatRecords`` (M3).
These records mirror the real field names captured from the live CRM (see the
data dictionary §3 + phase0-findings.md §6):
``keyId`` (PK), ``type`` / ``msgType`` (``0``=text, ``1``=image/media,
``-2``=date divider), ``content`` (text), ``mediaName`` (media FILENAME — NOT a
URL; deferred download), ``nickName`` (sender), ``uid`` (owning staff account),
``friendUid`` (the other party), ``isSend``, ``createTime`` / ``dateTime``,
``timezone``, ``companyCode``, ``quotedId`` / ``quotedMessage`` (quoted reply).
The request is a **JSON body** ``{uid, friendUid, start, end}`` (ms epochs) —
NOT form-encoded like WeChat ``msg/chats``. The response pages BACKWARD: each
page carries ``data.end`` (oldest timestamp in this batch) and ``data.hasLast``
(older messages exist?); the scraper advances the cursor to ``end - 1`` and
repeats until ``hasLast`` is false::
{"message":"调用成功","code":"10000","success":true,"traceId":"...",
"data":{"messages":[...], "start":.., "end":.., "hasNext":.., "hasLast":..}}
The cassette is shaped to drive the backward-cursor paginator across two pages:
* page 1 -> ``messages=[m1, m_datebar(-2), m2]``, ``end=E1``, ``hasLast=true``
* page 2 -> ``messages=[m3]``, ``end=E2``, ``hasLast=false``
The ``-2`` record is a date divider that the scraper must DROP. ``E1`` is the
oldest timestamp in page 1's batch, so the backward-cursor advance must set the
next request's ``end`` to ``E1 - 1``.
"""
from __future__ import annotations
from typing import Any
# Oldest-timestamp-in-batch returned by each page; the scraper uses these to
# advance the backward cursor (next_request_end = page_end - 1).
E1 = 1784517338999
E2 = 1784517000000
# Page 1 records, EXACT live-capture field names. m1 is text/incoming,
# m_datebar is a -2 divider (must be filtered), m2 is image/outgoing.
WA_MESSAGE_RECORDS_PAGE1: list[dict[str, Any]] = [
{ # m1 — text message, incoming.
"keyId": "wa_1000001",
"type": 0,
"msgType": 0,
"content": "hello from whatsapp",
"nickName": "Customer One",
"uid": "8613800000001@s.whatsapp.net",
"friendUid": "8613800000111@s.whatsapp.net",
"isSend": 0,
"createTime": E1 + 600000,
"dateTime": E1 + 600000,
"timezone": "Asia/Shanghai",
"companyCode": "8m0atd",
"version": "v1.0",
},
{ # date divider — type == -2; scraper MUST drop this, never yield it.
"keyId": "wa_1000000",
"type": -2,
"msgType": -2,
"content": "2026-01-01",
"nickName": "",
"uid": "8613800000001@s.whatsapp.net",
"friendUid": "8613800000111@s.whatsapp.net",
"isSend": 0,
"createTime": E1 + 300000,
"dateTime": E1 + 300000,
"timezone": "Asia/Shanghai",
"companyCode": "8m0atd",
"version": "v1.0",
},
{ # m2 — image message, outgoing, with a media filename (deferred download).
"keyId": "wa_1000002",
"type": 1,
"msgType": 1,
"content": "[image]",
"mediaName": "8613800000001@s.whatsapp.net_wa_1000002.jpg",
"nickName": "Alice Wang",
"uid": "8613800000001@s.whatsapp.net",
"friendUid": "8613800000111@s.whatsapp.net",
"isSend": 1,
"createTime": E1 + 100,
"dateTime": E1 + 100,
"timezone": "Asia/Shanghai",
"companyCode": "8m0atd",
"version": "v1.0",
},
]
# Page 2 record — text message with a quoted reply; oldest in the window.
WA_MESSAGE_RECORDS_PAGE2: list[dict[str, Any]] = [
{
"keyId": "wa_999999",
"type": 0,
"msgType": 0,
"content": "replying to an earlier message",
"nickName": "Customer One",
"uid": "8613800000001@s.whatsapp.net",
"friendUid": "8613800000111@s.whatsapp.net",
"isSend": 0,
"createTime": E2,
"dateTime": E2,
"timezone": "Asia/Shanghai",
"companyCode": "8m0atd",
"quotedId": "wa_888888",
"quotedMessage": "the earlier message text",
"version": "v1.0",
},
]
def make_wa_messages_response(
messages: list[dict[str, Any]],
*,
end: int,
has_last: bool,
start: int = 1,
has_next: bool = False,
trace_id: str = "trace-wa-msg-1",
) -> dict[str, Any]:
"""Build a capture-shaped ``chatRecords`` response envelope.
Shape (EXACT, per M3 spec)::
{"message":"调用成功","code":"10000","success":true,"traceId":"...",
"data":{"messages":[...], "start":.., "end":<oldest ts in batch>,
"hasNext":<bool>, "hasLast":<bool — are there older messages?>}}
"""
return {
"message": "调用成功",
"code": "10000",
"success": True,
"traceId": trace_id,
"data": {
"messages": list(messages),
"start": start,
"end": end,
"hasNext": has_next,
"hasLast": has_last,
},
}
+64 -1
View File
@@ -12,7 +12,11 @@ from __future__ import annotations
import pytest import pytest
from app.scraper.parsers import normalize_wechat_type from app.scraper.parsers import (
coerce_int_or_none,
normalize_wechat_type,
normalize_whatsapp_type,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -52,3 +56,62 @@ def test_normalize_wechat_type_never_raises():
out = normalize_wechat_type(raw) out = normalize_wechat_type(raw)
assert isinstance(out, str) assert isinstance(out, str)
assert out == "unknown" assert out == "unknown"
# ===========================================================================
# WhatsApp message-type normalization (M3)
# ===========================================================================
@pytest.mark.parametrize(
"raw,expected",
[
# Phase-0 confirmed WhatsApp codes (see data dictionary §3):
(0, "text"),
(1, "image"), # image / media
(-2, "divider"), # date separator — NOT a real message (scraper drops it)
# Unconfirmed / other media codes — pass through as "unknown", never dropped.
(2, "unknown"),
(3, "unknown"),
(13, "unknown"),
(100, "unknown"),
# Stringified ints (the upstream API may send either form).
("0", "text"),
("1", "image"),
("-2", "divider"),
("2", "unknown"),
],
)
def test_normalize_whatsapp_type_maps_known_and_unknown(raw, expected):
assert normalize_whatsapp_type(raw) == expected
def test_normalize_whatsapp_type_never_raises():
"""Whatever we throw at it, it returns a string — never raises."""
for raw in (None, "", "junk", "1.5", [], {}, object(), float("nan")):
out = normalize_whatsapp_type(raw)
assert isinstance(out, str)
assert out == "unknown"
# ===========================================================================
# coerce_int_or_none — shared scraper helper (M3)
# ===========================================================================
def test_coerce_int_or_none_handles_documented_cases():
"""``type_raw``-style fields: str -> int; None preserved; 0 preserved."""
assert coerce_int_or_none(None) is None
assert coerce_int_or_none(0) == 0
assert coerce_int_or_none(2) == 2
assert coerce_int_or_none(-2) == -2
assert coerce_int_or_none("0") == 0
assert coerce_int_or_none("2") == 2
assert coerce_int_or_none("-2") == -2
def test_coerce_int_or_none_returns_none_for_non_numeric_string():
"""A non-numeric value (e.g. a label) -> None."""
assert coerce_int_or_none("text") is None
assert coerce_int_or_none("") is None
assert coerce_int_or_none("unknown") is None
+215
View File
@@ -31,6 +31,7 @@ from app.db.models import (
PhoneCall, PhoneCall,
Staff, Staff,
WechatAccount, WechatAccount,
WhatsappAccount,
) )
from app.storage.repos import ( from app.storage.repos import (
_coerce_bool, _coerce_bool,
@@ -44,6 +45,7 @@ from app.storage.repos import (
upsert_staff_from_account, upsert_staff_from_account,
upsert_voice_call, upsert_voice_call,
upsert_wechat_account, upsert_wechat_account,
upsert_whatsapp_account,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1004,3 +1006,216 @@ async def test_upsert_voice_call_null_record_file_and_coerces_ints():
assert fetched.incoming_call == 1 assert fetched.incoming_call == 1
finally: finally:
await engine.dispose() await engine.dispose()
# ---------------------------------------------------------------------------
# WhatsappAccount + embedded Staff upsert (M3)
# ---------------------------------------------------------------------------
async def test_upsert_whatsapp_account_writes_account_and_embedded_staff():
"""WhatsappAccount upsert creates the account row AND the embedded Staff row."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec = {
"uid": "8613800000001@s.whatsapp.net",
"user_id": "wa_u1",
"user_name": "Alice Wang",
"nick_name": "Alice WA",
"phone": "8613800000001",
"sale_phone": "8613800000001",
"sales_bind_phone": "8613900000001",
"contacts_num": 421,
"group_num": 12,
"talker_count": 38,
"friend_add_num": 7,
"is_on_job": 1,
"last_update": 1700000000123,
}
await upsert_whatsapp_account(session, rec)
await session.commit()
acct = (
await session.execute(
sa.select(WhatsappAccount).where(
WhatsappAccount.uid == "8613800000001@s.whatsapp.net"
)
)
).scalar_one()
assert acct.user_id == "wa_u1"
assert acct.user_name == "Alice Wang"
assert acct.sale_phone == "8613800000001"
assert acct.sales_bind_phone == "8613900000001"
assert acct.contacts_num == 421
assert acct.is_on_job is True
assert acct.last_update == 1700000000123
# Embedded staff row created (PK = user_id), name forwarded.
staff = (
await session.execute(
sa.select(Staff).where(Staff.user_id == "wa_u1")
)
).scalar_one()
assert staff.name == "Alice Wang"
assert staff.is_on_job is True
assert staff.sale_phone == "8613800000001"
finally:
await engine.dispose()
async def test_upsert_whatsapp_account_is_idempotent_and_refreshes_staff():
"""Re-upserting the account refreshes BOTH account + embedded staff rows."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec1 = {
"uid": "8613800000002@s.whatsapp.net",
"user_id": "wa_u2",
"user_name": "Old Name",
"nick_name": "Old Nick",
"is_on_job": 1,
}
rec2 = {
"uid": "8613800000002@s.whatsapp.net",
"user_id": "wa_u2",
"user_name": "New Name",
"nick_name": "New Nick",
"is_on_job": 0, # staff departed since last scrape
}
await upsert_whatsapp_account(session, rec1)
await session.commit()
await upsert_whatsapp_account(session, rec2)
await session.commit()
# One account row, refreshed.
acct_rows = (
await session.execute(
sa.select(WhatsappAccount)
.where(WhatsappAccount.uid == "8613800000002@s.whatsapp.net")
.execution_options(populate_existing=True)
)
).scalars().all()
assert len(acct_rows) == 1
assert acct_rows[0].nick_name == "New Nick"
# One staff row, refreshed (not duplicated).
staff_rows = (
await session.execute(
sa.select(Staff)
.where(Staff.user_id == "wa_u2")
.execution_options(populate_existing=True)
)
).scalars().all()
assert len(staff_rows) == 1
assert staff_rows[0].name == "New Name"
assert staff_rows[0].is_on_job is False
finally:
await engine.dispose()
async def test_upsert_whatsapp_account_without_user_id_skips_staff():
"""Account with no user_id is written; embedded-staff upsert is skipped."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec = {
"uid": "8613800000009@s.whatsapp.net",
"nick_name": "Orphan WA",
# no user_id / user_name
}
await upsert_whatsapp_account(session, rec)
await session.commit()
# Account row written.
acct = (
await session.execute(
sa.select(WhatsappAccount).where(
WhatsappAccount.uid == "8613800000009@s.whatsapp.net"
)
)
).scalar_one()
assert acct.nick_name == "Orphan WA"
# No staff rows at all.
staff_rows = (
await session.execute(sa.select(Staff))
).scalars().all()
assert staff_rows == []
finally:
await engine.dispose()
async def test_upsert_customer_with_whatsapp_channel():
"""WA contacts flow through the channel-generic upsert_customer.
The ``Customer`` table is shared across WeChat / WhatsApp / SMS peers; the
per-row ``channel`` value disambiguates them. This pins that a WA-shaped
record (``channel="whatsapp"``) upserts cleanly against the same PK contract.
"""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec = {
"friend_id": "8613800000111@s.whatsapp.net",
"channel": "whatsapp",
"owner_account_id": "8613800000001@s.whatsapp.net",
"friend_nickname": "Customer One",
"phone": "8613800000111",
"sale_name": "Alice Sale",
"user_id": "wa_staff_001",
"message_count": 57,
}
await upsert_customer(session, rec)
await session.commit()
fetched = (
await session.execute(
sa.select(Customer).where(
Customer.friend_id == "8613800000111@s.whatsapp.net"
)
)
).scalar_one()
assert fetched.channel == "whatsapp"
assert fetched.owner_account_id == "8613800000001@s.whatsapp.net"
assert fetched.friend_nickname == "Customer One"
assert fetched.sale_name == "Alice Sale"
assert fetched.message_count == 57
finally:
await engine.dispose()
async def test_upsert_messages_with_whatsapp_channel():
"""WA messages flow through the channel-generic upsert_messages."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
rec = {
"msg_id": "wa_msg_1",
"channel": "whatsapp",
"owner_account_id": "8613800000001@s.whatsapp.net",
"talker_id": "8613800000111@s.whatsapp.net",
"is_send": 0,
"type": "text",
"type_raw": 0,
"text": "hi",
}
await upsert_messages(session, [rec])
await session.commit()
fetched = (
await session.execute(
sa.select(Message).where(Message.msg_id == "wa_msg_1")
)
).scalar_one()
assert fetched.channel == "whatsapp"
assert fetched.type == "text"
assert fetched.type_raw == 0
assert fetched.is_send is False
finally:
await engine.dispose()
@@ -0,0 +1,217 @@
"""Tests for the WhatsApp staff-account scraper (M3).
Drives the real page-number pagination + normalization logic through
``respx`` by wiring a real ``YunkeClient`` (primed with a fake session so the
login flow is skipped) against a mocked transport.
The fixture ships 4 records across 2 pages of size 2, plus a terminating
empty page (the documented primary stop signal for page-number endpoints):
* page 1 -> 2 records, ``totalCount=4`` (rows under ``data.overviews``)
* page 2 -> 2 records, ``totalCount=4``
* page 3 -> empty ``overviews`` array (no more rows)
The scraper must yield exactly the 4 records (normalized to ``WhatsappAccount``
snake_case columns + ``raw_json`` + the embedded staff pair), make exactly 3
calls, and stop without a 4th. The empty-page stop is the primary signal;
``totalCount=4`` is only a defensive over-fetch ceiling that must NOT trigger
early (we yield exactly 4 == total, so the strict-``>`` ceiling never fires).
"""
from __future__ import annotations
import json as _json
import httpx
import respx
from app.scraper.whatsapp import scrape_whatsapp_accounts
from app.session.yunke_client import YunkeClient
from tests.cassettes.wa_accounts_fixture import (
DEFAULT_PAGE_SIZE,
TOTAL,
WA_ACCOUNT_RECORDS,
make_wa_accounts_response,
)
BASE = "https://crm2.yunkecn.com"
WA_ACCOUNTS_URL = f"{BASE}/qqweb/WhatsAppUser/businessSalesList"
USERID = "a" * 32
COMPANY = "8m0atd"
def _primed_client() -> YunkeClient:
"""A YunkeClient already in a logged-in state (login flow skipped)."""
return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
# ---------------------------------------------------------------------------
# End-to-end: pagination + field mapping + embedded staff + raw_json
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_whatsapp_accounts_yields_records_and_paginates():
"""Two full pages + a terminating empty page -> 4 records, 3 calls."""
page1 = make_wa_accounts_response(
WA_ACCOUNT_RECORDS[:2], total_count=TOTAL, page=1, page_size=DEFAULT_PAGE_SIZE
)
page2 = make_wa_accounts_response(
WA_ACCOUNT_RECORDS[2:], total_count=TOTAL, page=2, page_size=DEFAULT_PAGE_SIZE
)
page3 = make_wa_accounts_response(
[], total_count=TOTAL, page=3, page_size=DEFAULT_PAGE_SIZE
)
route = respx.post(WA_ACCOUNTS_URL).mock(
side_effect=[
httpx.Response(200, json=page1),
httpx.Response(200, json=page2),
httpx.Response(200, json=page3),
]
)
client = _primed_client()
try:
results = [
r
async for r in scrape_whatsapp_accounts(client, page_size=DEFAULT_PAGE_SIZE)
]
finally:
await client.aclose()
# Pagination: exactly 3 calls (page1, page2, empty-page stop).
assert route.call_count == 3
# Exactly the 4 fixture records (not duplicated, not dropped).
assert len(results) == 4
assert {r["uid"] for r in results} == {rec["uid"] for rec in WA_ACCOUNT_RECORDS}
# Field mapping for each record (compare against the source record).
for result, record in zip(results, WA_ACCOUNT_RECORDS, strict=True):
# Identity PK.
assert result["uid"] == record["uid"]
# Embedded staff pair (the owning employee).
assert result["user_id"] == record["userId"]
assert result["user_name"] == record["userName"]
# Naming + phones.
assert result["nick_name"] == record["nickName"]
assert result["phone"] == record["phone"]
assert result["sale_phone"] == record["salePhone"]
assert result["sales_bind_phone"] == record["salesBindPhone"]
# Counts.
assert result["contacts_num"] == record["contactsNum"]
assert result["group_num"] == record["groupNum"]
assert result["talker_count"] == record["talkerCount"]
assert result["friend_add_num"] == record["friendAddNum"]
# Status / timestamps.
assert result["is_on_job"] == record["isOnJob"]
assert result["last_update"] == record["lastUpdate"]
# Version carried verbatim; raw_json preserves the original record.
assert result["version"] == record["version"]
assert result["raw_json"] == record
# ---------------------------------------------------------------------------
# Pagination body: pageNum/pageSize advanced correctly
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_whatsapp_accounts_sends_pagination_body():
"""Outgoing request body carries pageNum/pageSize; advances per page."""
page1 = make_wa_accounts_response(
WA_ACCOUNT_RECORDS[:1], total_count=TOTAL, page=1, page_size=1
)
page2 = make_wa_accounts_response([], total_count=TOTAL, page=2, page_size=1)
route = respx.post(WA_ACCOUNTS_URL).mock(
side_effect=[
httpx.Response(200, json=page1),
httpx.Response(200, json=page2),
]
)
client = _primed_client()
try:
results = [r async for r in scrape_whatsapp_accounts(client, page_size=1)]
finally:
await client.aclose()
# One record on page 1, empty page 2 stops the loop.
assert len(results) == 1
assert route.call_count == 2
# First request: pageNum=1.
body1 = _json.loads(route.calls[0].request.content.decode())
assert body1["pageNum"] == 1
assert body1["pageSize"] == 1
# Second request: pageNum advanced to 2.
body2 = _json.loads(route.calls[1].request.content.decode())
assert body2["pageNum"] == 2
assert body2["pageSize"] == 1
# ---------------------------------------------------------------------------
# Empty first page -> no yields, single call
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_whatsapp_accounts_stops_on_empty_first_page():
"""If the very first page is empty, scraper stops without over-fetching."""
empty = make_wa_accounts_response([], total_count=0, page=1)
route = respx.post(WA_ACCOUNTS_URL).mock(
return_value=httpx.Response(200, json=empty)
)
client = _primed_client()
try:
results = [r async for r in scrape_whatsapp_accounts(client)]
finally:
await client.aclose()
assert results == []
assert route.call_count == 1
# ---------------------------------------------------------------------------
# Envelope: rows are read from data.overviews (NOT data.data)
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_whatsapp_accounts_reads_overviews_envelope():
"""Rows under ``data.overviews`` are picked up (not the WeChat ``data.data``)."""
# A response that ALSO carries an empty ``data.data`` list must not confuse
# the scraper — it must read ``overviews`` and yield the record.
page = make_wa_accounts_response(
WA_ACCOUNT_RECORDS[:1], total_count=1, page=1, page_size=1
)
page["data"]["data"] = [] # WeChat-style key present but empty
respx.post(WA_ACCOUNTS_URL).mock(return_value=httpx.Response(200, json=page))
# Serve an empty page next so the paginator stops.
respx.post(WA_ACCOUNTS_URL).mock(
side_effect=[
httpx.Response(200, json=page),
httpx.Response(
200,
json=make_wa_accounts_response([], total_count=1, page=2, page_size=1),
),
]
)
client = _primed_client()
try:
results = [r async for r in scrape_whatsapp_accounts(client, page_size=1)]
finally:
await client.aclose()
assert len(results) == 1
assert results[0]["uid"] == WA_ACCOUNT_RECORDS[0]["uid"]
@@ -0,0 +1,169 @@
"""Tests for the WhatsApp contact-list scraper (M3).
Drives the real page-number pagination + normalization logic through
``respx`` by wiring a real ``YunkeClient`` (primed with a fake session so the
login flow is skipped) against a mocked transport.
The fixture ships 3 records across 2 pages of size 2, plus a terminating
empty page (the documented primary stop signal):
* page 1 -> 2 records, ``totalCount=3`` (rows under ``data.overviews``)
* page 2 -> 1 record, ``totalCount=3``
* page 3 -> empty ``overviews`` array (no more rows)
The scraper must yield exactly the 3 records normalized to the ``Customer``
column shape with ``channel="whatsapp"``, ``friend_id`` <- ``friendUid`` and
``owner_account_id`` <- ``uid``.
"""
from __future__ import annotations
import json as _json
import httpx
import respx
from app.scraper.whatsapp import scrape_whatsapp_friends
from app.session.yunke_client import YunkeClient
from tests.cassettes.wa_friends_fixture import (
DEFAULT_PAGE_SIZE,
TOTAL,
WA_FRIEND_RECORDS,
make_wa_friends_response,
)
BASE = "https://crm2.yunkecn.com"
WA_FRIENDS_URL = f"{BASE}/qqweb/WhatsAppUser/businessFriendList"
USERID = "a" * 32
COMPANY = "8m0atd"
def _primed_client() -> YunkeClient:
"""A YunkeClient already in a logged-in state (login flow skipped)."""
return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
# ---------------------------------------------------------------------------
# End-to-end: pagination + field mapping + channel/ownership + raw_json
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_whatsapp_friends_yields_normalized_records_and_paginates():
"""Two full pages + a terminating empty page -> 3 records, 3 calls."""
page1 = make_wa_friends_response(
WA_FRIEND_RECORDS[:2], total_count=TOTAL, page=1, page_size=DEFAULT_PAGE_SIZE
)
page2 = make_wa_friends_response(
WA_FRIEND_RECORDS[2:], total_count=TOTAL, page=2, page_size=DEFAULT_PAGE_SIZE
)
page3 = make_wa_friends_response(
[], total_count=TOTAL, page=3, page_size=DEFAULT_PAGE_SIZE
)
route = respx.post(WA_FRIENDS_URL).mock(
side_effect=[
httpx.Response(200, json=page1),
httpx.Response(200, json=page2),
httpx.Response(200, json=page3),
]
)
client = _primed_client()
try:
results = [
r async for r in scrape_whatsapp_friends(client, page_size=DEFAULT_PAGE_SIZE)
]
finally:
await client.aclose()
# Pagination: exactly 3 calls (page1, page2, empty-page stop).
assert route.call_count == 3
# Exactly the 3 fixture records (not duplicated, not dropped).
assert len(results) == 3
assert {r["friend_id"] for r in results} == {
rec["friendUid"] for rec in WA_FRIEND_RECORDS
}
# Field mapping for each record (compare against the source record).
for result, record in zip(results, WA_FRIEND_RECORDS, strict=True):
# Identity / ownership / channel.
assert result["friend_id"] == record["friendUid"]
assert result["channel"] == "whatsapp"
assert result["owner_account_id"] == record["uid"]
assert result["user_id"] == record["userId"]
# Naming / phone.
assert result["friend_nickname"] == record["nickName"]
assert result["phone"] == record["phone"]
assert result["sale_name"] == record["salesNickName"]
# Counts / timestamps.
assert result["message_count"] == record["messageCount"]
assert result["last_chat_time"] == record["lastUpdate"]
# Version carried verbatim; raw_json preserves the original record.
assert result["version"] == record["version"]
assert result["raw_json"] == record
# ---------------------------------------------------------------------------
# Pagination body: pageNum/pageSize advanced correctly
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_whatsapp_friends_sends_pagination_body():
"""Outgoing request body carries pageNum/pageSize; advances per page."""
page1 = make_wa_friends_response(
WA_FRIEND_RECORDS[:1], total_count=TOTAL, page=1, page_size=1
)
page2 = make_wa_friends_response([], total_count=TOTAL, page=2, page_size=1)
route = respx.post(WA_FRIENDS_URL).mock(
side_effect=[
httpx.Response(200, json=page1),
httpx.Response(200, json=page2),
]
)
client = _primed_client()
try:
results = [r async for r in scrape_whatsapp_friends(client, page_size=1)]
finally:
await client.aclose()
assert len(results) == 1
assert route.call_count == 2
body1 = _json.loads(route.calls[0].request.content.decode())
assert body1["pageNum"] == 1
assert body1["pageSize"] == 1
body2 = _json.loads(route.calls[1].request.content.decode())
assert body2["pageNum"] == 2
assert body2["pageSize"] == 1
# ---------------------------------------------------------------------------
# Empty first page -> no yields, single call
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_whatsapp_friends_stops_on_empty_first_page():
"""If the very first page is empty, scraper stops without over-fetching."""
empty = make_wa_friends_response([], total_count=0, page=1)
route = respx.post(WA_FRIENDS_URL).mock(
return_value=httpx.Response(200, json=empty)
)
client = _primed_client()
try:
results = [r async for r in scrape_whatsapp_friends(client)]
finally:
await client.aclose()
assert results == []
assert route.call_count == 1
@@ -0,0 +1,208 @@
"""Tests for the WhatsApp message scraper (M3).
Drives the real backward-cursor pagination + normalization logic through
``respx`` by wiring a real ``YunkeClient`` (primed with a fake session so the
login flow is skipped) against a mocked transport. Two pages are served:
* page 1 -> ``messages=[m1, m_datebar(-2), m2]``, ``end=E1``, ``hasLast=true``
* page 2 -> ``messages=[m3]``, ``end=E2``, ``hasLast=false``
The scraper must yield exactly ``[m1, m2, m3]`` (the ``-2`` divider dropped),
map fields per the live-capture names, set ``channel="whatsapp"``, send a
**JSON** body (``uid``/``friendUid``/``start``/``end``), AND the 2nd request
body's ``end`` must equal ``E1 - 1`` (proving the backward cursor advances by
one ms).
"""
from __future__ import annotations
import json as _json
from typing import Any
import httpx
import respx
from app.scraper.whatsapp import scrape_whatsapp_messages
from app.session.yunke_client import YunkeClient
from tests.cassettes.wa_messages_fixture import (
E1,
E2,
WA_MESSAGE_RECORDS_PAGE1,
WA_MESSAGE_RECORDS_PAGE2,
make_wa_messages_response,
)
BASE = "https://crm2.yunkecn.com"
WA_MESSAGES_URL = f"{BASE}/qqweb/WhatsAppUser/chatRecords"
USERID = "a" * 32
COMPANY = "8m0atd"
UID = "8613800000001@s.whatsapp.net"
FRIEND_UID = "8613800000111@s.whatsapp.net"
START = 1
END = 1784518000000 # the initial upper-bound cursor
def _primed_client() -> YunkeClient:
"""A YunkeClient already in a logged-in state (login flow skipped)."""
return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
def _json_body(sent: httpx.Request) -> dict[str, Any]:
"""Parse the JSON body of an outgoing request into a dict."""
return _json.loads(sent.content.decode())
# ---------------------------------------------------------------------------
# Test A: end-to-end backward-cursor pagination + divider drop + field mapping
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_whatsapp_messages_drops_divider_and_paginates_backward():
page1 = make_wa_messages_response(
WA_MESSAGE_RECORDS_PAGE1, end=E1, has_last=True, trace_id="trace-wa-1"
)
page2 = make_wa_messages_response(
WA_MESSAGE_RECORDS_PAGE2, end=E2, has_last=False, trace_id="trace-wa-2"
)
route = respx.post(WA_MESSAGES_URL).mock(
side_effect=[
httpx.Response(200, json=page1),
httpx.Response(200, json=page2),
]
)
client = _primed_client()
try:
results = [
r
async for r in scrape_whatsapp_messages(
client, UID, FRIEND_UID, START, END
)
]
finally:
await client.aclose()
# --- Exactly 2 calls: hasLast=false on page 2 means no 3rd fetch. ---
assert route.call_count == 2
# --- Exactly [m1, m2, m3] yielded (the -2 divider dropped). ---
assert [r["msg_id"] for r in results] == ["wa_1000001", "wa_1000002", "wa_999999"]
assert len(results) == 3
r1, r2, r3 = results
# m1 — text / incoming.
assert r1["msg_id"] == "wa_1000001"
assert r1["channel"] == "whatsapp"
assert r1["owner_account_id"] == UID
assert r1["talker_id"] == FRIEND_UID
assert r1["is_send"] == 0
assert r1["type"] == "text" # code 0 -> text (phase0 §3)
assert r1["type_raw"] == 0 # original raw code preserved
assert r1["text"] == "hello from whatsapp"
assert r1["timestamp"] == E1 + 600000
assert r1["sender_name"] == "Customer One"
assert r1["raw_json"] == WA_MESSAGE_RECORDS_PAGE1[0]
# m2 — image / outgoing / media filename (deferred download, not a URL).
assert r2["msg_id"] == "wa_1000002"
assert r2["is_send"] == 1
assert r2["type"] == "image" # code 1 -> image/media (phase0 §3)
assert r2["type_raw"] == 1
assert r2["text"] == "[image]"
assert r2["file_url"] == "8613800000001@s.whatsapp.net_wa_1000002.jpg"
assert r2["raw_json"] == WA_MESSAGE_RECORDS_PAGE1[2]
# m3 — text / incoming / quoted reply.
assert r3["msg_id"] == "wa_999999"
assert r3["type"] == "text"
assert r3["type_raw"] == 0
assert r3["text"] == "replying to an earlier message"
assert r3["refer_msg"] == {
"quotedId": "wa_888888",
"quotedMessage": "the earlier message text",
}
assert r3["raw_json"] == WA_MESSAGE_RECORDS_PAGE2[0]
# --- Backward cursor advance: 2nd request body's end == E1 - 1. ---
first_req = route.calls[0].request
second_req = route.calls[1].request
body1 = _json_body(first_req)
body2 = _json_body(second_req)
assert body1["end"] == END
assert body2["end"] == E1 - 1
# --- JSON body is actually sent (NOT form-encoded). ---
assert first_req.headers["content-type"] == "application/json"
assert body1["uid"] == UID
assert body1["friendUid"] == FRIEND_UID
assert body1["start"] == START
assert body1["end"] == END
# ``start`` is constant across pages (only ``end`` walks backward).
assert body2["start"] == START
assert body2["uid"] == UID
assert body2["friendUid"] == FRIEND_UID
# ---------------------------------------------------------------------------
# Test B: hasLast=false on first page -> stop after one call
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_whatsapp_messages_stops_when_has_last_false_on_first_page():
"""A single page with hasLast=false: scraper yields the records and stops."""
page = make_wa_messages_response(
[WA_MESSAGE_RECORDS_PAGE2[0]], end=E2, has_last=False
)
route = respx.post(WA_MESSAGES_URL).mock(
return_value=httpx.Response(200, json=page)
)
client = _primed_client()
try:
results = [
r
async for r in scrape_whatsapp_messages(
client, UID, FRIEND_UID, START, END
)
]
finally:
await client.aclose()
assert [r["msg_id"] for r in results] == ["wa_999999"]
assert route.call_count == 1
# ---------------------------------------------------------------------------
# Test C: empty messages array on the first page -> stop without a 3rd fetch
# ---------------------------------------------------------------------------
@respx.mock
async def test_scrape_whatsapp_messages_empty_first_page_yields_nothing():
"""An empty messages array is a defensive stop signal — no rows, one call."""
page = make_wa_messages_response([], end=E1, has_last=True)
route = respx.post(WA_MESSAGES_URL).mock(
return_value=httpx.Response(200, json=page)
)
client = _primed_client()
try:
results = [
r
async for r in scrape_whatsapp_messages(
client, UID, FRIEND_UID, START, END
)
]
finally:
await client.aclose()
assert results == []
assert route.call_count == 1