Files
yunke/yunke-server/app/scraper/base.py
T
浅念 46e3880c0b feat: anti-risk-control stealth layer (browser headers, jittered throttle, backoff, volume cap)
Centralize scraping stealth in YunkeClient.request() so automated traffic
resembles the normal CRM web app and is paced conservatively to avoid
triggering 云客 risk control (pulls M6 rate-limit work forward):

- app/config.py: add 6 tunable settings with conservative defaults
  (request_min_interval=1.0, request_jitter=0.8, max_requests_per_run=0,
  risk_backoff_{base=2.0, max=60.0, attempts=4}).
- app/scraper/base.py: RateLimiter gains optional jitter + a new wait()
  method (fixed min+jitter per-call sleep); gap-based acquire() is unchanged
  for backward compat (default jitter=0.0).
- app/session/yunke_client.py: every request now carries browser-identical
  headers (UA/sec-ch-ua/accept/accept-language/referer) merged with the auth
  headers (per-request overrides win); awaits the shared jittered limiter
  before sending; enforces a per-run request cap (RequestCapReached); and
  backs off exponentially on risk signals (HTTP 429 or 200 body carrying
  频繁/风控/限制/稍后), distinct from the existing 401 relogin-once path.
- tests/conftest.py: zero the throttle env in the test suite so the
  conservative prod defaults don't slow unit tests.
- tests/test_yunke_client_stealth.py: 9 new tests (browser headers,
  override, limiter spy, 429 backoff, 200-marker backoff, persistent-429
  raise, volume cap, unlimited, 401-relogin preserved). TDD red->green.

Suite: 171 -> 181 passing. ruff clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 14:51:46 +08:00

238 lines
9.5 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.
``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__ = ["RateLimiter", "paginate_by_cursor", "paginate_by_page"]
# 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,
**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.
"""
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)
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()