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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user