Files
yunke/yunke-server/app/scraper/base.py
T
浅念 0df53c6b1a fix: wechat account scrape (form-data+dates) + frontend staff page
Backend (scrape_wechat_accounts):
- Send wxUser/list as form-urlencoded (not JSON) with the REQUIRED filters
  isOnJob / departmentId / startdate / enddate + empty userId/salesname/
  weChatNickName, matching the live request (empty fails with code -90000).
- scrape_wechat_accounts now takes department_id (the company-root dept id)
  plus optional start/end defaulting to a recent 7-day UTC window.
- paginate_by_page gains a form=True mode (data= + form content-type).
- Orchestrator sync_wechat_accounts resolves the root dept from the
  Department table (or accepts one) and skips with a warning when unknown.
- seed_accounts.py resolves the root via scrape_departments.

Frontend (yunkes):
- Add StaffList.vue (/staff) with is_on_job filter + pagination.
- Add /staff route and a 员工 nav item between 客户 and 群.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 17:11:17 +08:00

272 lines
11 KiB
Python

"""Reusable building blocks for list scrapers.
This module establishes the pagination patterns every list scraper in the
project reuses (see design doc §6.2 拉取层). It is intentionally small:
* :func:`paginate_by_page` — a generic async generator that walks page-numbered
endpoints until a page comes back empty (the primary stop signal used by the
yunke CRM), with a defensive over-fetch ceiling keyed off the declared total.
* :func:`paginate_by_cursor` — a generic async generator that walks a
cursor-paginated endpoint until the fetch callable reports no more pages.
Used by the WeChat message scraper, whose ``msg/chats`` endpoint pages
BACKWARD by timestamp cursor (``end = prev_end - 1`` until ``hasLast``
flips false).
* :class:`RateLimiter` — a tiny asyncio limiter that enforces a minimum
interval between successive calls. Scrapers that need to be polite to the
upstream construct one and ``await`` :meth:`acquire` before each call.
``paginate_by_page`` talks to :class:`app.session.yunke_client.YunkeClient`
(which already injects auth headers + handles 401 re-login). It is generic
over the response shape via three callables:
* ``parse_total(resp) -> int | None`` — read the declared row count from the
envelope (used only for the over-fetch safety; ``None`` disables it).
* ``parse_rows(resp) -> list[dict]`` — extract the list of records.
* ``build_body(page_num, page_size, **extra) -> dict`` — build the request
body for a given page.
By default the body is sent as JSON. A few yunke endpoints
(e.g. ``msg/chats``, ``wxUser/list``) bind ``@RequestParam`` from
**form-urlencoded** bodies instead — pass ``form=True`` to switch the paginator
to ``data=<body>`` with content-type
``application/x-www-form-urlencoded`` (mirroring the form-encoded
``msg/chats`` request the message scraper already makes directly).
``paginate_by_cursor`` is decoupled from the client entirely: it takes a
single ``fetch(cursor)`` coroutine that performs whatever request the caller
needs and returns ``(rows, next_cursor, has_more)``. The cursor-paginator's
job is just to advance the cursor and stop on ``has_more == False``.
"""
from __future__ import annotations
import asyncio
import random
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
# ``YunkeClient`` is referenced only in the paginator signatures. Kept
# under TYPE_CHECKING so ``app.session.yunke_client`` may import
# :class:`RateLimiter` at runtime without forming an import cycle.
from app.session.yunke_client import YunkeClient
__all__ = [
"FORM_CONTENT_TYPE",
"RateLimiter",
"paginate_by_cursor",
"paginate_by_page",
]
# Content type for form-encoded bodies. Several yunke endpoints (msg/chats,
# wxUser/list) bind @RequestParam from form data rather than JSON, so the
# paginator + the manual message fetcher both send this content-type.
FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
# Informal type aliases for the response-handling callables. The yunke
# response shapes are dict-shaped JSON and do not benefit from finer typing.
ParseTotal = Callable[[Any], "int | None"]
ParseRows = Callable[[Any], "list[dict[str, Any]]"]
BuildBody = Callable[..., "dict[str, Any]"]
# fetch(cursor) -> (rows, next_cursor, has_more) — see ``paginate_by_cursor``.
FetchCursor = Callable[[Any], Awaitable["tuple[list[dict[str, Any]], Any, bool]"]]
async def paginate_by_page(
client: YunkeClient,
path: str,
*,
page_size: int,
parse_total: ParseTotal,
parse_rows: ParseRows,
build_body: BuildBody,
rate_limiter: RateLimiter | None = None,
form: bool = False,
**extra: Any,
) -> AsyncIterator[dict[str, Any]]:
"""Walk a page-numbered list endpoint, yielding one dict per record.
Pagination strategy
-------------------
Start at ``pageNum=1`` and advance while pages are non-empty. The yunke
CRM signals "no more rows" by returning an empty ``data`` array on the
page *after* the last real record; that empty page is the **primary stop
signal**. As a defensive ceiling against a misbehaving upstream that never
sends an empty page, we also stop once we have yielded strictly more rows
than the declared ``total`` (strict ``>`` so an honest source yields
exactly ``total`` rows).
``total`` is parsed lazily from the first response and may be ``None`` if
the envelope does not carry it — in that case only the empty-page signal
terminates the loop.
``rate_limiter`` is an optional :class:`RateLimiter`; when supplied, it is
acquired before every upstream call.
``form`` selects the wire encoding of ``build_body``'s output. The default
(``False``) sends the body as JSON (``client.post(path, json=body)``) —
correct for most yunke list endpoints. Pass ``True`` for endpoints that
bind ``@RequestParam`` from a form-urlencoded body (``msg/chats``,
``wxUser/list``): the paginator then sends
``client.post(path, data=body, headers={"content-type": ...})`` and lets
httpx URL-encode the dict. Either way ``build_body`` returns the same
plain dict of field names → values.
"""
page_num = 1
total: int | None = None
yielded = 0
while True:
if rate_limiter is not None:
await rate_limiter.acquire()
body = build_body(page_num, page_size, **extra)
if form:
resp = await client.post(
path,
data=body,
headers={"content-type": FORM_CONTENT_TYPE},
)
else:
resp = await client.post(path, json=body)
# Read the declared total once from the first response. The yunke
# envelopes keep it on every page, but reading it once is enough for
# the over-fetch ceiling and avoids trusting stale updates.
if total is None:
total = parse_total(resp)
rows = parse_rows(resp)
if not rows:
# Empty page == the upstream's "no more rows" signal. This is the
# normal, primary termination path.
return
for row in rows:
yield row
yielded += 1
# Over-fetch safety: a well-behaved source sends an empty page next,
# but if it never does (or sends duplicates), cap at the declared total.
# Strict ``>`` so we yield exactly ``total`` when the count is honest.
if total is not None and yielded > total:
return
page_num += 1
async def paginate_by_cursor(
fetch: FetchCursor,
*,
initial_cursor: Any,
rate_limiter: RateLimiter | None = None,
) -> AsyncIterator[dict[str, Any]]:
"""Walk a cursor-paginated endpoint, yielding rows until no more pages.
Used by the WeChat message scraper, whose ``msg/chats`` endpoint pages
BACKWARD by timestamp cursor: each response carries ``data.end`` (the
oldest timestamp in the batch) and ``data.hasLast`` (true iff there are
older messages still). The caller advances the cursor to ``end - 1`` and
repeats until ``hasLast`` is false.
The contract is intentionally client-agnostic. Each call to
``fetch(cursor)`` must return a 3-tuple ``(rows, next_cursor, has_more)``:
* ``rows`` — list of records from this page (may be empty).
* ``next_cursor`` — cursor for the next page (e.g. ``prev_end - 1``).
* ``has_more`` — True if there are more pages to fetch.
The loop yields every row of every page, then advances ``cursor`` to
``next_cursor`` and repeats. It stops after:
* a page whose ``has_more`` is False (the documented primary stop signal
for cursor endpoints — e.g. ``hasLast=false``), OR
* an empty ``rows`` list (a defensive secondary stop, so a misbehaving
upstream that never flips ``has_more`` to False cannot loop forever).
``rate_limiter`` is acquired before each fetch when supplied, so cursor
scrapers get the same inter-call spacing as page scrapers.
"""
cursor = initial_cursor
while True:
if rate_limiter is not None:
await rate_limiter.acquire()
rows, next_cursor, has_more = await fetch(cursor)
# Defensive stop: an empty page is treated as end-of-data even if the
# upstream claims has_more. This mirrors paginate_by_page's primary
# stop signal and bounds the worst case (a stuck cursor that never
# advances would otherwise loop forever).
if not rows:
return
for row in rows:
yield row
if not has_more:
return
cursor = next_cursor
class RateLimiter:
"""Minimal asyncio limiter enforcing a minimum interval between calls.
Two pacing modes are supported:
* :meth:`acquire` — gap-based spacing. Call it before each upstream
request; the first call never waits and successive calls wait just long
enough that the gap between dispatches is at least ``min_interval``
seconds. Used by the list paginators (:func:`paginate_by_page` /
:func:`paginate_by_cursor`).
* :meth:`wait` — fixed per-call throttle. Sleeps ``min_interval`` plus a
uniform random jitter in ``[0, jitter)`` every time it is awaited. Used
by :class:`app.session.yunke_client.YunkeClient` to pace every upstream
request with a conservative, slightly randomized cadence (the jitter
prevents a perfectly periodic signature that risk control could finger).
This is deliberately tiny: no tokens, no concurrency cap, no skew handling
— just a floor on inter-call spacing, which is all the yunke scrapers need
to stay polite to the CRM. If you need a semaphore or token bucket, that
belongs in a future helper, not here.
``jitter`` defaults to ``0.0`` so existing callers that pass only
``min_interval`` keep their exact prior behaviour (gap-based ``acquire``
never consults ``jitter``).
"""
def __init__(self, min_interval: float = 0.0, jitter: float = 0.0) -> None:
self._min_interval = float(min_interval)
self._jitter = float(jitter)
self._last: float = 0.0
async def wait(self) -> None:
"""Sleep ``min_interval`` plus a random ``[0, jitter)`` every call.
Unlike :meth:`acquire` this is not gap-aware — it is a flat, slightly
randomized per-call delay, which is what the yunke client wants ahead
of each upstream request. With ``min_interval == 0`` and
``jitter == 0`` it is a no-op.
"""
delay = self._min_interval
if self._jitter > 0:
delay += random.uniform(0.0, self._jitter)
if delay > 0:
await asyncio.sleep(delay)
async def acquire(self) -> None:
"""Block until at least ``min_interval`` has elapsed since the last call."""
if self._min_interval <= 0:
self._last = time.monotonic()
return
now = time.monotonic()
wait = self._min_interval - (now - self._last)
if wait > 0:
await asyncio.sleep(wait)
self._last = time.monotonic()