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
+215
View File
@@ -31,6 +31,7 @@ from app.db.models import (
PhoneCall,
Staff,
WechatAccount,
WhatsappAccount,
)
from app.storage.repos import (
_coerce_bool,
@@ -44,6 +45,7 @@ from app.storage.repos import (
upsert_staff_from_account,
upsert_voice_call,
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
finally:
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()