feat(scraper): map customer add_time from raw_json
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -154,6 +154,28 @@ def _build_body(
|
||||
return body
|
||||
|
||||
|
||||
# WeChat ``addTime`` is captured as ``'YYYY-MM-DD HH:mm:ss'`` (the common live
|
||||
# shape; confirmed in smoke.db raw_json). Some captures instead carry a numeric
|
||||
# ms-epoch. Both formats are accepted; first match wins.
|
||||
_ADD_TIME_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _parse_add_time(value: Any) -> int | None:
|
||||
"""Coerce a wechat ``addTime`` to a ms-epoch ``int`` (naive UTC).
|
||||
|
||||
Accepted shapes:
|
||||
|
||||
* ``int`` / ``float`` — treated as ms-epoch already, returned as ``int``.
|
||||
* ``str`` matching one of :data:`_ADD_TIME_FORMATS` — parsed via the shared
|
||||
:func:`app.scraper.parsers.parse_datetime_to_ms` (naive UTC, same
|
||||
convention as ``lastUpdate`` / ``startTime`` elsewhere in this layer).
|
||||
* numeric ``str`` (e.g. ``"1690000000000"``) -> ``int`` (no datetime
|
||||
round-trip).
|
||||
* ``None`` / empty / unparseable -> ``None`` (None-safe field).
|
||||
"""
|
||||
return parse_datetime_to_ms(value, _ADD_TIME_FORMATS)
|
||||
|
||||
|
||||
def _normalize(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map one upstream friend record to the ``Customer`` column shape.
|
||||
|
||||
@@ -181,6 +203,10 @@ def _normalize(record: dict[str, Any]) -> dict[str, Any]:
|
||||
# Prefer friendHeadImageUrl; fall back to the legacy headImg field.
|
||||
"head_image_url": record.get("friendHeadImageUrl") or record.get("headImg"),
|
||||
"last_chat_time": record.get("lastChatTime"),
|
||||
# addTime is delivered as 'YYYY-MM-DD HH:mm:ss' (confirmed in smoke.db
|
||||
# raw_json); some captures carry a numeric ms-epoch instead. Both shapes
|
||||
# collapse to a ms-epoch int via _parse_add_time; None when missing/empty.
|
||||
"add_time": _parse_add_time(record.get("addTime")),
|
||||
"send_num": record.get("sendNum"),
|
||||
"receive_num": record.get("receiveNum"),
|
||||
"message_count": record.get("messageCount"),
|
||||
|
||||
@@ -248,6 +248,13 @@ def _normalize_whatsapp_friend(record: dict[str, Any]) -> dict[str, Any]:
|
||||
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.
|
||||
|
||||
``add_time`` is deliberately ``None``: the WhatsApp ``businessFriendList``
|
||||
payload carries no customer-side add-time field (the ``createTime`` field
|
||||
on this record is the **owner-staff** account's createTime, not the
|
||||
contact's — confirmed via smoke.db raw_json dump). Mapping it to
|
||||
``createTime`` would be semantically wrong; the column stays NULL for WA
|
||||
contacts (the wechat channel carries the truthful ``addTime``).
|
||||
"""
|
||||
return {
|
||||
"friend_id": record.get("friendUid"),
|
||||
@@ -259,6 +266,8 @@ def _normalize_whatsapp_friend(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"user_id": record.get("userId"),
|
||||
"message_count": record.get("messageCount"),
|
||||
"last_chat_time": parse_datetime_to_ms(record.get("lastUpdate"), _WA_DATETIME_FORMATS),
|
||||
# No customer-side add-time on this payload (see docstring); left NULL.
|
||||
"add_time": None,
|
||||
"version": record.get("version"),
|
||||
"raw_json": record,
|
||||
}
|
||||
|
||||
@@ -152,3 +152,49 @@ async def test_scrape_wechat_friends_stops_on_empty_first_page():
|
||||
|
||||
assert results == []
|
||||
assert route.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_time mapping: addTime (string 'YYYY-MM-DD HH:mm:ss' OR ms-epoch int)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_maps_add_time_string_to_epoch():
|
||||
"""addTime as a wall-clock string parses to a plausible recent ms epoch.
|
||||
|
||||
The CRM delivers wechat ``addTime`` as ``'YYYY-MM-DD HH:mm:ss'`` (confirmed
|
||||
in smoke.db raw_json). ``parse_datetime_to_ms`` parses naive UTC; we assert
|
||||
only that the result is an int in the right magnitude for a 2026 date
|
||||
(any 2026 moment > 1.76e12) rather than pinning a specific epoch so the
|
||||
test is not brittle to the exact format-resolution / tz convention.
|
||||
"""
|
||||
from app.scraper.wechat import _normalize
|
||||
|
||||
record = {
|
||||
"friendWxId": "x",
|
||||
"addTime": "2026-04-28 16:24:39",
|
||||
"lastChatTime": 1700000000000,
|
||||
}
|
||||
out = _normalize(record)
|
||||
assert isinstance(out["add_time"], int)
|
||||
assert out["add_time"] > 1_760_000_000_000
|
||||
|
||||
|
||||
def test_normalize_maps_add_time_int_passthrough():
|
||||
"""addTime as a numeric ms-epoch passes through verbatim (int or float)."""
|
||||
from app.scraper.wechat import _normalize
|
||||
|
||||
record = {"friendWxId": "x", "addTime": 1690000000000, "lastChatTime": None}
|
||||
out = _normalize(record)
|
||||
assert out["add_time"] == 1690000000000
|
||||
|
||||
|
||||
def test_normalize_add_time_none_when_missing():
|
||||
"""Missing / empty addTime maps to None (None-safe field)."""
|
||||
from app.scraper.wechat import _normalize
|
||||
|
||||
out = _normalize({"friendWxId": "x"})
|
||||
assert out["add_time"] is None
|
||||
|
||||
out_empty = _normalize({"friendWxId": "x", "addTime": ""})
|
||||
assert out_empty["add_time"] is None
|
||||
|
||||
Reference in New Issue
Block a user