feat: department + wechat account/staff scrapers
M2 organization scrapers for yunke-server: - scrape_departments: POST /usercenter/dept/tree. Single request (no pagination). Defensively unwraps the envelope (data is node list OR an object wrapping nodes under children), flattens BOTH nested and flat shapes, and reconstructs the materialized path by walking the pId chain (ancestor names joined with /). Maps id/pId/name -> dept_id/parent_id/name. - scrape_wechat_accounts: POST /wechatweb/wxUser/list. Page-number pagination via the existing paginate_by_page helper. Envelope differs from friend_list (no basicResult wrapper): data.data/data.total. Maps wechatId/nickName/alias/phone/userId/userName/contactsNum/groupNum/ sendNum/receiveNum/isOnJob/dbSize/onlineStatus/lastUpdate/version to WechatAccount columns; carries embedded staff pair for the repo layer. Repos: - upsert_department: idempotent upsert into Department (PK dept_id). - upsert_staff: primitive idempotent staff upsert (PK user_id, is_on_job coerced to bool). - upsert_wechat_account: now ALSO upserts the embedded Staff row via upsert_staff_from_account (best-effort; skips when account has no user_id). - upsert_staff_from_account: refactored to delegate to upsert_staff and forward phone/sale_phone/is_on_job when present. Tests (TDD red->green): test_scrape_departments (nested + flat + wrapped-data + empty), test_scrape_wechat_accounts (2-page pagination + body advance + empty first page), test_repos extended (dept idempotency/root parent, direct staff upsert + bool coercion, account-also-upserts-staff, re-upsert updates both, no-user_id skips staff). Cassettes: dept_tree_fixture (same 4 nodes in both nested + flat shapes), wxUser_fixture (4 capture-shaped accounts incl. departed + orphan). Suite: 97 -> 111 passing. ruff check clean. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,31 @@
|
||||
"""WeChat scrapers.
|
||||
"""WeChat + organization scrapers.
|
||||
|
||||
Two endpoints today:
|
||||
Endpoints covered today:
|
||||
|
||||
* :func:`scrape_wechat_friends` — wraps ``POST /wechatweb/wxFriends/friend_list/es``
|
||||
and yields normalized dicts ready to be upserted into
|
||||
:class:`app.db.models.Customer`.
|
||||
* :func:`scrape_wechat_messages` — wraps ``POST /wechatweb/msg/chats`` and
|
||||
yields normalized chat-message dicts (see Task 1.6).
|
||||
* :func:`scrape_departments` — wraps ``POST /usercenter/dept/tree`` and
|
||||
yields normalized :class:`app.db.models.Department` dicts. Single request,
|
||||
no pagination; the tree (flat or nested) is flattened and the materialized
|
||||
``path`` is reconstructed from parent links (Task 2.0).
|
||||
* :func:`scrape_wechat_accounts` — wraps ``POST /wechatweb/wxUser/list`` and
|
||||
yields normalized :class:`app.db.models.WechatAccount` dicts (plus the
|
||||
embedded ``userId``/``userName`` staff pair). Page-number pagination
|
||||
(Task 2.1).
|
||||
|
||||
Both yield dicts (not ORM). Persistence is a LATER task — Task 1.7. Each dict
|
||||
carries:
|
||||
All yield dicts (not ORM). Persistence is the caller's job (the
|
||||
:mod:`app.storage.repos` module bridges these dicts to the ORM tables). Each
|
||||
dict carries:
|
||||
|
||||
* snake_case keys matching the target ORM columns (see :func:`_normalize`
|
||||
for friends and :func:`_normalize_message` for messages),
|
||||
* ``channel == "wechat"``,
|
||||
for friends, :func:`_normalize_message` for messages,
|
||||
:func:`_normalize_department` for departments, and
|
||||
:func:`_normalize_wxuser_account` for accounts),
|
||||
* ``channel == "wechat"`` (only on communication-channel entities; the
|
||||
organization entities ``Department`` / ``WechatAccount`` carry no channel),
|
||||
* ``raw_json`` — the original upstream record, verbatim, so nothing captured
|
||||
is lost even when a column has no target field.
|
||||
|
||||
@@ -36,10 +48,17 @@ from app.scraper.base import paginate_by_cursor, paginate_by_page
|
||||
from app.scraper.parsers import normalize_wechat_type
|
||||
from app.session.yunke_client import YunkeClient
|
||||
|
||||
__all__ = ["scrape_wechat_friends", "scrape_wechat_messages"]
|
||||
__all__ = [
|
||||
"scrape_departments",
|
||||
"scrape_wechat_accounts",
|
||||
"scrape_wechat_friends",
|
||||
"scrape_wechat_messages",
|
||||
]
|
||||
|
||||
FRIEND_LIST_PATH = "/wechatweb/wxFriends/friend_list/es"
|
||||
MSG_CHATS_PATH = "/wechatweb/msg/chats"
|
||||
DEPT_TREE_PATH = "/usercenter/dept/tree"
|
||||
WXUSER_LIST_PATH = "/wechatweb/wxUser/list"
|
||||
|
||||
# Upstream sends form-encoded bodies for ``msg/chats`` (NOT JSON), so we have
|
||||
# to override the client's default JSON content-type on every request.
|
||||
@@ -408,3 +427,276 @@ async def scrape_wechat_messages(
|
||||
if _is_divider(record):
|
||||
continue
|
||||
yield _normalize_message(record)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Department tree scraper (Task 2.0) — POST /usercenter/dept/tree
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _extract_dept_node_list(resp: Any) -> list[dict[str, Any]]:
|
||||
"""Extract the raw node list from a ``dept/tree`` response, defensively.
|
||||
|
||||
The envelope's ``data`` may be:
|
||||
|
||||
* a list of nodes (the common case), OR
|
||||
* an object wrapping the node list under ``children`` (or ``list`` /
|
||||
``tree`` / ``data`` — be tolerant of wrapper-key variance).
|
||||
|
||||
Returns ``[]`` on any malformed shape so the scraper yields nothing rather
|
||||
than raising: a broken upstream should not abort the whole sync. Nodes are
|
||||
returned AS-IS (possibly still nested via ``children``); flattening is the
|
||||
next step (:func:`_flatten_dept_tree`).
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except (AttributeError, ValueError):
|
||||
return []
|
||||
data = body.get("data") if isinstance(body, dict) else None
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
# Walk the plausible wrapper keys in priority order. ``children`` is
|
||||
# the documented yunke tree convention; the others are defensive.
|
||||
for key in ("children", "list", "tree", "data"):
|
||||
val = data.get(key)
|
||||
if isinstance(val, list):
|
||||
return val
|
||||
return []
|
||||
|
||||
|
||||
def _flatten_dept_tree(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Flatten a (possibly nested) dept-tree node list depth-first.
|
||||
|
||||
Each node MAY carry a ``children`` array (the nested shape); when absent,
|
||||
the tree is already flat and parents are carried via ``pId``. Both shapes
|
||||
flatten to the same depth-first list.
|
||||
|
||||
Each emitted node is a shallow copy of the source node with the
|
||||
``children`` key stripped, so the ``raw_json`` we store per-dept does not
|
||||
carry a recursive subtree (only that node's own attributes). Non-dict
|
||||
entries are dropped defensively.
|
||||
"""
|
||||
flat: list[dict[str, Any]] = []
|
||||
# Iterative DFS via a stack. Nodes are pushed in reverse so the first
|
||||
# child is popped first (preserves source order in the output).
|
||||
stack: list[dict[str, Any]] = [
|
||||
n for n in reversed(nodes) if isinstance(n, dict)
|
||||
]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
children = node.get("children")
|
||||
# Shallow copy without ``children`` so raw_json stored per-dept is clean.
|
||||
flat_node = {k: v for k, v in node.items() if k != "children"}
|
||||
flat.append(flat_node)
|
||||
if isinstance(children, list) and children:
|
||||
stack.extend(
|
||||
reversed([c for c in children if isinstance(c, dict)])
|
||||
)
|
||||
return flat
|
||||
|
||||
|
||||
def _build_dept_paths(nodes: list[dict[str, Any]]) -> dict[str, str]:
|
||||
"""Compute the materialized path (ancestor names joined by ``/``) per node.
|
||||
|
||||
Returns ``{id: path}``. The path is built by walking up the ``pId`` chain;
|
||||
the walk terminates when it reaches a node whose ``pId`` is falsy
|
||||
(``None`` / ``""`` / ``0`` / ``"0"``) OR whose parent is not in the tree
|
||||
(handles the common yunke convention where root carries ``pId="0"`` and no
|
||||
node with ``id="0"`` exists). A visited set caps the walk so a cyclic
|
||||
``pId`` chain cannot loop forever.
|
||||
|
||||
A node whose own ``id`` is missing is skipped (no path entry) — the caller
|
||||
treats a missing path as empty string.
|
||||
"""
|
||||
by_id: dict[str, dict[str, Any]] = {}
|
||||
for n in nodes:
|
||||
nid = n.get("id")
|
||||
# First occurrence wins so duplicate ids cannot shadow the canonical
|
||||
# node (defensive against an upstream that emits a node twice).
|
||||
if nid is not None and nid not in by_id:
|
||||
by_id[nid] = n
|
||||
|
||||
paths: dict[str, str] = {}
|
||||
for nid, node in by_id.items():
|
||||
parts: list[str] = []
|
||||
seen: set[str] = set()
|
||||
cur: dict[str, Any] | None = node
|
||||
while cur is not None:
|
||||
cid = cur.get("id")
|
||||
if cid is None or cid in seen:
|
||||
break
|
||||
seen.add(cid)
|
||||
name = cur.get("name")
|
||||
if name is not None:
|
||||
parts.append(name)
|
||||
pid = cur.get("pId")
|
||||
# Falsy pId OR unknown parent terminates the walk.
|
||||
cur = by_id.get(pid) if pid else None
|
||||
paths[nid] = "/".join(reversed(parts))
|
||||
return paths
|
||||
|
||||
|
||||
def _normalize_department(
|
||||
node: dict[str, Any], path: str
|
||||
) -> dict[str, Any]:
|
||||
"""Map one dept-tree node to the ``Department`` column shape.
|
||||
|
||||
``parent_id`` is carried verbatim from ``pId`` — the scraper does NOT
|
||||
synthesize ``NULL`` for root nodes; downstream code treats falsy
|
||||
``parent_id`` (``"0"`` / ``""`` / ``None``) as "no parent" consistently
|
||||
with the materialized-path builder. ``path`` is the precomputed
|
||||
slash-joined ancestor-name chain from :func:`_build_dept_paths`.
|
||||
"""
|
||||
return {
|
||||
"dept_id": node.get("id"),
|
||||
"parent_id": node.get("pId"),
|
||||
"name": node.get("name"),
|
||||
"path": path,
|
||||
"raw_json": node,
|
||||
}
|
||||
|
||||
|
||||
async def scrape_departments(
|
||||
client: YunkeClient,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Yield normalized department records from ``POST /usercenter/dept/tree``.
|
||||
|
||||
Single request (no pagination). The endpoint follows yunke's tree
|
||||
convention: nodes carry ``id`` / ``pId`` / ``name`` and MAY be delivered
|
||||
either as a flat list (parents via ``pId``) or as a nested tree (each node
|
||||
optionally carrying ``children``). Both shapes are flattened to one flat
|
||||
list of records, each carrying the materialized ``path`` (ancestor names
|
||||
joined with ``/``).
|
||||
|
||||
Defensive envelope: ``data`` may be the node list directly OR an object
|
||||
wrapping the node list under ``children``; both are unwrapped.
|
||||
|
||||
The async-generator signature mirrors the other scrapers so callers can
|
||||
consume every entity stream with the same ``async for`` pattern, even
|
||||
though this endpoint always resolves in one round-trip.
|
||||
"""
|
||||
resp = await client.post(DEPT_TREE_PATH, json={})
|
||||
nodes = _extract_dept_node_list(resp)
|
||||
flat = _flatten_dept_tree(nodes)
|
||||
paths = _build_dept_paths(flat)
|
||||
for node in flat:
|
||||
nid = node.get("id")
|
||||
path = paths.get(nid, "") if nid is not None else ""
|
||||
yield _normalize_department(node, path)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# WeChat staff-account scraper (Task 2.1) — POST /wechatweb/wxUser/list
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _parse_wxuser_total(resp: Any) -> int | None:
|
||||
"""Read the declared row count from the ``wxUser/list`` envelope.
|
||||
|
||||
Shape: ``data.total`` (an int). 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("total")
|
||||
return int(total) if isinstance(total, (int, float)) and not isinstance(
|
||||
total, bool
|
||||
) else None
|
||||
|
||||
|
||||
def _parse_wxuser_rows(resp: Any) -> list[dict[str, Any]]:
|
||||
"""Extract the account list from the ``wxUser/list`` envelope.
|
||||
|
||||
Shape: ``data.data`` (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("data")
|
||||
return rows if isinstance(rows, list) else []
|
||||
|
||||
|
||||
def _build_wxuser_body(page_num: int, page_size: int) -> dict[str, Any]:
|
||||
"""Build the ``wxUser/list`` request body for a given page.
|
||||
|
||||
Unlike ``friend_list/es``, the ``wxUser/list`` body carries no
|
||||
staff/dept filter parameters in the live capture — just the page slice.
|
||||
"""
|
||||
return {"pageNum": page_num, "pageSize": page_size}
|
||||
|
||||
|
||||
def _normalize_wxuser_account(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map one upstream ``wxUser/list`` record to the ``WechatAccount`` 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 partial payloads do not break the
|
||||
scrape.
|
||||
"""
|
||||
return {
|
||||
"wx_id": record.get("wechatId"),
|
||||
"nick_name": record.get("nickName"),
|
||||
"alias": record.get("alias"),
|
||||
"phone": record.get("phone"),
|
||||
"user_id": record.get("userId"),
|
||||
"user_name": record.get("userName"),
|
||||
"contacts_num": record.get("contactsNum"),
|
||||
"group_num": record.get("groupNum"),
|
||||
"send_num": record.get("sendNum"),
|
||||
"receive_num": record.get("receiveNum"),
|
||||
"is_on_job": record.get("isOnJob"),
|
||||
"db_size": record.get("dbSize"),
|
||||
"online_status": record.get("onlineStatus"),
|
||||
"last_update": record.get("lastUpdate"),
|
||||
"version": record.get("version"),
|
||||
"raw_json": record,
|
||||
}
|
||||
|
||||
|
||||
async def scrape_wechat_accounts(
|
||||
client: YunkeClient,
|
||||
*,
|
||||
page_size: int = 50,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Yield normalized WeChat staff-account records from ``wxUser/list``.
|
||||
|
||||
Walks ``POST /wechatweb/wxUser/list`` page by page until the upstream
|
||||
reports no more rows (empty ``data.data`` array — the primary stop signal),
|
||||
mapping each record to the ``WechatAccount`` column shape plus a verbatim
|
||||
``raw_json`` copy.
|
||||
|
||||
The envelope differs from ``friend_list/es``: there is **no**
|
||||
``basicResult`` wrapper. The pagination fields sit directly under ``data``
|
||||
(``data.data`` / ``data.total`` / ``data.pageNum`` / ``data.pageSize``).
|
||||
|
||||
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 live capture's
|
||||
observed page size for this endpoint).
|
||||
"""
|
||||
async for record in paginate_by_page(
|
||||
client,
|
||||
WXUSER_LIST_PATH,
|
||||
page_size=page_size,
|
||||
parse_total=_parse_wxuser_total,
|
||||
parse_rows=_parse_wxuser_rows,
|
||||
build_body=_build_wxuser_body,
|
||||
):
|
||||
yield _normalize_wxuser_account(record)
|
||||
|
||||
Reference in New Issue
Block a user