feat(tradein/scraper): чекпоинты для avito_newbuilding_sweep — страница как единица (#3074)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
Последний длинный свип без возобновления: при обрыве прогон начинался с первой страницы, а собранное терялось целиком — save_listings вызывался один раз на весь sweep. Единица возобновления — страница выдачи, по образцу якорей в city sweep. `_paginate_sweep`/`fetch_newbuildings` получили `start_page` (уже собранные страницы не запрашиваются) и колбэк `on_page`, который вызывается только после того, как страница пройдена до конца. Сохранение стало постраничным, номера пройденных страниц копятся в `scrape_runs.counters.done_buckets` мержем через `update_heartbeat`. Два инварианта, без которых фича вредна: 1. В чекпоинт попадает только страница, чьи лоты СОХРАНЕНЫ. Отказ save_listings перехвачен и прогон продолжается, но отметить такую страницу пройденной значило бы, что следующий прогон её пропустит и объявления оттуда не соберутся никогда — молча, потому что прогон завершится штатно. 2. Подхват начинается с ПЕРВОЙ несобранной страницы, а не с max+1. Дыра в чекпоинте возможна ровно из-за п.1, и max+1 перепрыгнул бы её навсегда. Страницы после дыры перечитаются — это дешевле потери и безопасно, повторная запись схлопывается по dedup_hash. Оба инварианта закрыты тестами, которые падают при их нарушении.
This commit is contained in:
parent
ba57cf7c05
commit
5a410687ac
5 changed files with 400 additions and 28 deletions
|
|
@ -0,0 +1,266 @@
|
||||||
|
"""Чекпоинт по страницам для avito_newbuilding_sweep (#3074).
|
||||||
|
|
||||||
|
Последний длинный свип без чекпоинтов: 27.08 прогон убит деплоем на 343-й
|
||||||
|
минуте, собранное потеряно целиком — save_listings был ОДИН на весь sweep,
|
||||||
|
ждать в конце было нечего. Единица возобновления — СТРАНИЦА выдачи (у функции
|
||||||
|
уже есть `pages`; цикл по страницам живёт в `AvitoScraper._paginate_sweep`).
|
||||||
|
|
||||||
|
Ключ чекпоинта — номер страницы. В отличие от якорей/combo, страницы строго
|
||||||
|
последовательны (break-on-empty), поэтому resume — это `start_page =
|
||||||
|
max(done_buckets) + 1`, без skip-набора произвольных элементов.
|
||||||
|
|
||||||
|
ИНВАРИАНТЫ, РАДИ КОТОРЫХ ТЕСТ:
|
||||||
|
1. Резюм пропускает уже собранные страницы БЕЗ единого запроса к источнику.
|
||||||
|
2. В чекпоинт попадает только страница, пройденная до конца — оборванная
|
||||||
|
исключением/break страница НЕ фиксируется, иначе следующий прогон
|
||||||
|
пропустил бы её навсегда, причём молча (прогон завершится штатно).
|
||||||
|
3. Запись идёт мержем (`counters || :counters`), а не заменой — посторонний
|
||||||
|
ключ в `counters` текущего run'а переживает запись чекпоинта.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Settings собирается автофикстурой conftest'а и требует database_url. Выставляем
|
||||||
|
# до остальных импортов — так же, как в test_3074_avito_anchor_checkpoint.py.
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import json
|
||||||
|
import types
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDb:
|
||||||
|
"""Резюм-SELECT отдаёт counters предшественника; heartbeat'ы записываются."""
|
||||||
|
|
||||||
|
def __init__(self, prev_counters: dict[str, Any] | None = None) -> None:
|
||||||
|
self.prev_counters = prev_counters or {}
|
||||||
|
self.heartbeats: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
if params and "counters" in params:
|
||||||
|
self.heartbeats.append(json.loads(params["counters"]))
|
||||||
|
return MagicMock()
|
||||||
|
if params and "rid" in params:
|
||||||
|
return MagicMock(fetchone=lambda: types.SimpleNamespace(counters=self.prev_counters))
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
def commit(self) -> None: ...
|
||||||
|
def rollback(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _MergingFakeDb(_FakeDb):
|
||||||
|
"""Как _FakeDb, но heartbeat-запись мержит в текущую строку (jsonb `||`),
|
||||||
|
а не просто копится списком — для теста инварианта #3 (посторонний ключ)."""
|
||||||
|
|
||||||
|
def __init__(self, prev_counters: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__(prev_counters)
|
||||||
|
self.row: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
if params and "counters" in params:
|
||||||
|
incoming = json.loads(params["counters"])
|
||||||
|
self.row = {**self.row, **incoming}
|
||||||
|
self.heartbeats.append(dict(self.row))
|
||||||
|
return MagicMock()
|
||||||
|
return super().execute(_stmt, params)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncSession:
|
||||||
|
def __init__(self, *_a: Any, **_kw: Any) -> None: ...
|
||||||
|
async def __aenter__(self) -> _FakeAsyncSession:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_e: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lot(tag: str) -> MagicMock:
|
||||||
|
m = MagicMock()
|
||||||
|
m.source_id = tag
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeScraper:
|
||||||
|
"""Двойник AvitoScraper: помнит реально пройденные страницы, умеет упасть."""
|
||||||
|
|
||||||
|
visited_pages: list[int] = [] # noqa: RUF012 — тестовый сборник
|
||||||
|
fail_on: int | None = None
|
||||||
|
|
||||||
|
def __init__(self, *_a: Any, **_kw: Any) -> None:
|
||||||
|
self._browser = None
|
||||||
|
self._cffi = None
|
||||||
|
|
||||||
|
async def fetch_newbuildings(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
pages: int,
|
||||||
|
start_page: int = 1,
|
||||||
|
on_page: Any = None,
|
||||||
|
delay_override_sec: float | None = None,
|
||||||
|
) -> list[Any]:
|
||||||
|
all_lots: list[Any] = []
|
||||||
|
for page in range(start_page, pages + 1):
|
||||||
|
_FakeScraper.visited_pages.append(page)
|
||||||
|
if _FakeScraper.fail_on == page:
|
||||||
|
# Страница оборвана (сеть/парсинг) — break, on_page НЕ зовём.
|
||||||
|
break
|
||||||
|
new_lots = [_lot(f"p{page}-{i}") for i in range(2)]
|
||||||
|
all_lots.extend(new_lots)
|
||||||
|
if on_page is not None:
|
||||||
|
on_page(page, new_lots)
|
||||||
|
return all_lots
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> types.SimpleNamespace:
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
scraper_fetch_mode="cffi",
|
||||||
|
scraper_proxy_url=None,
|
||||||
|
use_proxy_pool_browser=False,
|
||||||
|
browser_http_endpoint=None,
|
||||||
|
environment="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_save(fail_on_call: int | None):
|
||||||
|
"""save_listings, падающий на N-м вызове — имитация отказа БД на одной странице."""
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def _save(*_a, **_kw):
|
||||||
|
calls["n"] += 1
|
||||||
|
if fail_on_call is not None and calls["n"] == fail_on_call:
|
||||||
|
raise RuntimeError(f"save_listings упал на странице {calls['n']}")
|
||||||
|
return (2, 0)
|
||||||
|
|
||||||
|
return _save
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(
|
||||||
|
db: _FakeDb,
|
||||||
|
*,
|
||||||
|
resume_run_id: int | None,
|
||||||
|
pages: int = 4,
|
||||||
|
fail_on: int | None = None,
|
||||||
|
save_fail_on_call: int | None = None,
|
||||||
|
):
|
||||||
|
from scraper_kit.orchestration import pipeline as pl
|
||||||
|
|
||||||
|
_FakeScraper.visited_pages = []
|
||||||
|
_FakeScraper.fail_on = fail_on
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(pl, "AvitoScraper", _FakeScraper),
|
||||||
|
patch.object(pl, "AsyncSession", _FakeAsyncSession),
|
||||||
|
patch.object(pl, "save_listings", _make_save(save_fail_on_call)),
|
||||||
|
):
|
||||||
|
await pl.run_avito_newbuilding_sweep(
|
||||||
|
db, # type: ignore[arg-type]
|
||||||
|
run_id=8001,
|
||||||
|
config=_config(),
|
||||||
|
matcher=MagicMock(),
|
||||||
|
pages=pages,
|
||||||
|
request_delay_sec=0.0,
|
||||||
|
resume_run_id=resume_run_id,
|
||||||
|
)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _last_checkpoint(db: _FakeDb) -> list[int]:
|
||||||
|
with_ckpt = [hb for hb in db.heartbeats if "done_buckets" in hb]
|
||||||
|
assert with_ckpt, "ни один heartbeat не унёс done_buckets — чекпоинт не персистится"
|
||||||
|
return with_ckpt[-1]["done_buckets"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resume_skips_done_pages_and_fetches_the_rest() -> None:
|
||||||
|
"""Резюм с чекпоинтом [1,2] на pages=4 обходит только страницы 3 и 4 —
|
||||||
|
ни одного запроса к уже собранным."""
|
||||||
|
db = _FakeDb({"done_buckets": [1, 2]})
|
||||||
|
await _run(db, resume_run_id=7999, pages=4)
|
||||||
|
|
||||||
|
assert _FakeScraper.visited_pages == [3, 4], (
|
||||||
|
"уже собранные страницы 1/2 не должны опрашиваться повторно"
|
||||||
|
)
|
||||||
|
assert _last_checkpoint(db) == [1, 2, 3, 4], "чекпоинт дописывается поверх унаследованного"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_without_resume_starts_from_page_one() -> None:
|
||||||
|
"""Без resume_run_id поведение прежнее — обход с первой страницы."""
|
||||||
|
db = _FakeDb(None)
|
||||||
|
await _run(db, resume_run_id=None, pages=3)
|
||||||
|
|
||||||
|
assert _FakeScraper.visited_pages == [1, 2, 3]
|
||||||
|
assert _last_checkpoint(db) == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_page_does_not_enter_checkpoint() -> None:
|
||||||
|
"""Страница, оборванная на середине (fail_on=2), НЕ считается пройденной.
|
||||||
|
|
||||||
|
Иначе следующий прогон пропустит её навсегда молча — sweep завершится
|
||||||
|
штатно, просто часть выдачи не соберётся никогда.
|
||||||
|
"""
|
||||||
|
db = _FakeDb(None)
|
||||||
|
await _run(db, resume_run_id=None, pages=3, fail_on=2)
|
||||||
|
|
||||||
|
# Страница 2 была АТАКОВАНА (попытка была — visited), но не пройдена до конца.
|
||||||
|
assert _FakeScraper.visited_pages == [1, 2]
|
||||||
|
ckpt = _last_checkpoint(db)
|
||||||
|
assert ckpt == [1], "упавшая страница 2 (и не начатая 3) не должны попасть в чекпоинт"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_checkpoint_write_merges_and_preserves_foreign_key() -> None:
|
||||||
|
"""Запись чекпоинта — мерж (`counters || :counters`), не замена.
|
||||||
|
|
||||||
|
Симулируем текущую строку run'а с посторонним ключом (например, оставленным
|
||||||
|
другим писателем/предыдущим heartbeat'ом) — он обязан пережить наши записи.
|
||||||
|
"""
|
||||||
|
db = _MergingFakeDb(None)
|
||||||
|
db.row = {"foreign_key": "survives-me"}
|
||||||
|
|
||||||
|
await _run(db, resume_run_id=None, pages=1)
|
||||||
|
|
||||||
|
assert db.row.get("foreign_key") == "survives-me", (
|
||||||
|
"посторонний ключ в counters не пережил запись чекпоинта — запись была заменой, не мержем"
|
||||||
|
)
|
||||||
|
assert "done_buckets" in db.row
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_page_whose_save_failed_does_not_enter_checkpoint() -> None:
|
||||||
|
"""Страница собрана, но её save упал — в чекпоинт она попасть не должна.
|
||||||
|
|
||||||
|
Отказ save_listings перехватывается и прогон продолжается (это осознанно:
|
||||||
|
одна упавшая страница не должна ронять весь sweep). Но если отметить её
|
||||||
|
пройденной, следующий прогон её пропустит, и объявления оттуда не соберутся
|
||||||
|
НИКОГДА — молча, потому что прогон завершится штатно.
|
||||||
|
"""
|
||||||
|
db = _FakeDb(None)
|
||||||
|
await _run(db, resume_run_id=None, pages=3, save_fail_on_call=2)
|
||||||
|
|
||||||
|
assert _FakeScraper.visited_pages == [1, 2, 3]
|
||||||
|
ckpt = _last_checkpoint(db)
|
||||||
|
assert 2 not in ckpt, "страница с упавшим save попала в чекпоинт — покрытие потеряно молча"
|
||||||
|
assert ckpt == [1, 3]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resume_starts_at_first_gap_not_after_the_last_page() -> None:
|
||||||
|
"""Дыра в чекпоинте перечитывается, а не перепрыгивается.
|
||||||
|
|
||||||
|
`max(done)+1` пропустил бы страницу 2 навсегда. Продолжаем с первой
|
||||||
|
несобранной: страницы после дыры перечитаются, что дешевле потери и
|
||||||
|
безопасно — повторная запись схлопывается по dedup_hash.
|
||||||
|
"""
|
||||||
|
db = _FakeDb({"done_buckets": [1, 3]})
|
||||||
|
await _run(db, resume_run_id=7777, pages=4)
|
||||||
|
|
||||||
|
assert _FakeScraper.visited_pages[0] == 2, (
|
||||||
|
"подхват начался не с дыры — пропущенная страница не соберётся никогда"
|
||||||
|
)
|
||||||
|
|
@ -495,10 +495,21 @@ async def _drive_nb_sweep(
|
||||||
recorder = _RunsRecorder()
|
recorder = _RunsRecorder()
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
lots = [MagicMock() for _ in range(6)]
|
lots = [MagicMock() for _ in range(6)]
|
||||||
|
|
||||||
|
async def _fake_fetch_newbuildings(
|
||||||
|
*, pages: int, start_page: int = 1, on_page: Any = None, delay_override_sec: Any = None
|
||||||
|
) -> list[Any]:
|
||||||
|
# #3074: реальный fetch_newbuildings зовёт on_page ПОСЛЕ каждой пройденной
|
||||||
|
# страницы (инкрементальный save) — двойник имитирует одну страницу с
|
||||||
|
# ВСЕМИ 6 лотами, чтобы save_mock/счётчики остались как раньше.
|
||||||
|
if on_page is not None:
|
||||||
|
on_page(start_page, lots)
|
||||||
|
return lots
|
||||||
|
|
||||||
scraper = MagicMock()
|
scraper = MagicMock()
|
||||||
scraper._cffi = None
|
scraper._cffi = None
|
||||||
scraper._browser = None
|
scraper._browser = None
|
||||||
scraper.fetch_newbuildings = AsyncMock(return_value=lots)
|
scraper.fetch_newbuildings = AsyncMock(side_effect=_fake_fetch_newbuildings)
|
||||||
save_mock = MagicMock(side_effect=[(5, 1)])
|
save_mock = MagicMock(side_effect=[(5, 1)])
|
||||||
avito_scraper_cls = MagicMock(return_value=scraper)
|
avito_scraper_cls = MagicMock(return_value=scraper)
|
||||||
if capture is not None:
|
if capture is not None:
|
||||||
|
|
|
||||||
|
|
@ -1947,6 +1947,7 @@ async def run_avito_newbuilding_sweep(
|
||||||
pages: int = 20,
|
pages: int = 20,
|
||||||
request_delay_sec: float = 7.0,
|
request_delay_sec: float = 7.0,
|
||||||
region_code: int = DEFAULT_REGION_CODE,
|
region_code: int = DEFAULT_REGION_CODE,
|
||||||
|
resume_run_id: int | None = None,
|
||||||
) -> NewbuildingSweepCounters:
|
) -> NewbuildingSweepCounters:
|
||||||
"""Citywide-обход ЕКБ-выборки только новостроек (novostroyka-filter) → save.
|
"""Citywide-обход ЕКБ-выборки только новостроек (novostroyka-filter) → save.
|
||||||
|
|
||||||
|
|
@ -1962,9 +1963,91 @@ async def run_avito_newbuilding_sweep(
|
||||||
|
|
||||||
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
|
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
|
||||||
прямых импортов app.* (см. scraper_kit.contracts).
|
прямых импортов app.* (см. scraper_kit.contracts).
|
||||||
|
|
||||||
|
── Checkpoint/resume (#3074): единица обхода — СТРАНИЦА выдачи ────────────
|
||||||
|
Последний длинный свип без чекпоинтов: прогон убивался деплоем посреди
|
||||||
|
обхода (страница 300+ при pages в разы больше дефолта), и всё собранное
|
||||||
|
терялось целиком, потому что save_listings раньше был ОДИН на весь sweep —
|
||||||
|
ждать в конце было нечего. Здесь (как и у combo в yandex-свипе) save
|
||||||
|
происходит инкрементально в on_page-callback: сразу после того, как
|
||||||
|
страница пройдена до конца, её лоты сохраняются и её номер уходит в
|
||||||
|
чекпоинт `done_buckets` (список номеров страниц). Страницы — единственная и
|
||||||
|
строго последовательная единица обхода (break-on-empty), поэтому resume —
|
||||||
|
это просто `start_page = max(done_buckets) + 1`, без skip-набора: страница,
|
||||||
|
оборванная исключением, callback не получает и в чекпоинт не попадает —
|
||||||
|
иначе следующий прогон пропустил бы её навсегда, причём молча.
|
||||||
"""
|
"""
|
||||||
counters = NewbuildingSweepCounters()
|
counters = NewbuildingSweepCounters()
|
||||||
|
|
||||||
|
_done_pages: set[int] = set()
|
||||||
|
if resume_run_id is not None:
|
||||||
|
_prev = db.execute(
|
||||||
|
text("SELECT counters FROM scrape_runs WHERE id = CAST(:rid AS bigint)"),
|
||||||
|
{"rid": resume_run_id},
|
||||||
|
).fetchone()
|
||||||
|
if _prev is not None and _prev.counters:
|
||||||
|
_pc: dict = _prev.counters if isinstance(_prev.counters, dict) else {}
|
||||||
|
_done_pages = {int(p) for p in _pc.get("done_buckets", [])}
|
||||||
|
logger.info(
|
||||||
|
"nb-sweep run_id=%d: resuming from run %s — %d страниц уже собрано",
|
||||||
|
run_id,
|
||||||
|
resume_run_id,
|
||||||
|
len(_done_pages),
|
||||||
|
)
|
||||||
|
# #3074: продолжаем с ПЕРВОЙ несобранной страницы, а не с max+1. Дыра в
|
||||||
|
# чекпоинте возможна (страница собрана, но её save упал — она не отмечена,
|
||||||
|
# а следующие отмечены), и max+1 перепрыгнул бы её навсегда: молча, потому
|
||||||
|
# что прогон завершится штатно. Уже собранные страницы после дыры при этом
|
||||||
|
# перечитаются — это дешевле потери, а повторная запись идемпотентна
|
||||||
|
# (save_listings схлопывает по dedup_hash).
|
||||||
|
_start_page = 1
|
||||||
|
while _start_page in _done_pages:
|
||||||
|
_start_page += 1
|
||||||
|
|
||||||
|
def _on_page(page: int, new_lots: list[ScrapedLot]) -> None:
|
||||||
|
"""Страница собрана и СОХРАНЕНА — только тогда чекпоинт (мерж, не замена)."""
|
||||||
|
counters.lots_fetched += len(new_lots)
|
||||||
|
_saved_ok = True
|
||||||
|
if new_lots:
|
||||||
|
try:
|
||||||
|
# #2594: citywide novostroyka-обход — только ЕКБ (см. docstring).
|
||||||
|
ins, upd = save_listings(
|
||||||
|
db,
|
||||||
|
new_lots,
|
||||||
|
matcher=matcher,
|
||||||
|
region_code=region_code,
|
||||||
|
run_id=run_id,
|
||||||
|
city=EKATERINBURG_CITY_NAME,
|
||||||
|
)
|
||||||
|
counters.lots_inserted += ins
|
||||||
|
counters.lots_updated += upd
|
||||||
|
except Exception as save_exc:
|
||||||
|
logger.exception(
|
||||||
|
"nb-sweep run_id=%d page=%d: save_listings failed: %s",
|
||||||
|
run_id,
|
||||||
|
page,
|
||||||
|
save_exc,
|
||||||
|
)
|
||||||
|
counters.errors_count += 1
|
||||||
|
_saved_ok = False
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# #3074: страница попадает в чекпоинт ТОЛЬКО если её лоты сохранены.
|
||||||
|
# Отказ save_listings перехвачен выше и прогон продолжается — но отметить
|
||||||
|
# страницу пройденной значило бы, что следующий прогон её пропустит, а
|
||||||
|
# объявления оттуда не соберутся НИКОГДА, причём молча: прогон завершится
|
||||||
|
# штатно. Тот же инвариант, что у якоря в avito city sweep и у бакета в
|
||||||
|
# cian full-load (_mark_bucket): в чекпоинт — только полностью собранная
|
||||||
|
# единица. Heartbeat обновляем в любом случае, иначе reap_zombies посчитает
|
||||||
|
# живой прогон мёртвым.
|
||||||
|
if _saved_ok:
|
||||||
|
_done_pages.add(page)
|
||||||
|
runs.update_heartbeat(
|
||||||
|
db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_pages)}
|
||||||
|
)
|
||||||
|
|
||||||
browser_mode = config.scraper_fetch_mode == "browser"
|
browser_mode = config.scraper_fetch_mode == "browser"
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
session: AsyncSession | None = None
|
session: AsyncSession | None = None
|
||||||
|
|
@ -2016,8 +2099,12 @@ async def run_avito_newbuilding_sweep(
|
||||||
scraper._cffi = session
|
scraper._cffi = session
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lots: list[ScrapedLot] = await scraper.fetch_newbuildings(
|
# #3074: save + чекпоинт идут инкрементально внутри _on_page —
|
||||||
|
# aggregate `lots` ниже используется только для лог-сообщения.
|
||||||
|
await scraper.fetch_newbuildings(
|
||||||
pages=pages,
|
pages=pages,
|
||||||
|
start_page=_start_page,
|
||||||
|
on_page=_on_page,
|
||||||
delay_override_sec=request_delay_sec,
|
delay_override_sec=request_delay_sec,
|
||||||
)
|
)
|
||||||
except (AvitoBlockedError, AvitoRateLimitedError) as e:
|
except (AvitoBlockedError, AvitoRateLimitedError) as e:
|
||||||
|
|
@ -2027,30 +2114,6 @@ async def run_avito_newbuilding_sweep(
|
||||||
)
|
)
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
counters.lots_fetched += len(lots)
|
|
||||||
if lots:
|
|
||||||
try:
|
|
||||||
# #2594: citywide novostroyka-обход — только ЕКБ (см. docstring).
|
|
||||||
ins, upd = save_listings(
|
|
||||||
db,
|
|
||||||
lots,
|
|
||||||
matcher=matcher,
|
|
||||||
region_code=region_code,
|
|
||||||
run_id=run_id,
|
|
||||||
city=EKATERINBURG_CITY_NAME,
|
|
||||||
)
|
|
||||||
counters.lots_inserted += ins
|
|
||||||
counters.lots_updated += upd
|
|
||||||
except Exception as save_exc:
|
|
||||||
logger.exception(
|
|
||||||
"nb-sweep run_id=%d: save_listings failed: %s", run_id, save_exc
|
|
||||||
)
|
|
||||||
counters.errors_count += 1
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
runs.update_heartbeat(db, run_id, counters.to_dict())
|
runs.update_heartbeat(db, run_id, counters.to_dict())
|
||||||
runs.mark_done(db, run_id, counters.to_dict())
|
runs.mark_done(db, run_id, counters.to_dict())
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -860,6 +860,9 @@ async def _job_avito_newbuilding_sweep(
|
||||||
proxy_provider=ctx.proxy_provider,
|
proxy_provider=ctx.proxy_provider,
|
||||||
pages=int(params.get("pages", 20)),
|
pages=int(params.get("pages", 20)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
||||||
|
# #3074: подхват страниц у оборванного предшественника — см.
|
||||||
|
# _job_avito_city_sweep выше.
|
||||||
|
resume_run_id=_pick_resume(db, run_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,7 @@ def _avito_bisection_config(cap: int) -> BisectionConfig:
|
||||||
probe_fail_policy=ProbeFailPolicy.SPLIT_OR_SKIP,
|
probe_fail_policy=ProbeFailPolicy.SPLIT_OR_SKIP,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# HTTP 429 в curl_cffi-режиме через backconnect-прокси (mproxy.site) — НЕ IP-ban, а
|
# HTTP 429 в curl_cffi-режиме через backconnect-прокси (mproxy.site) — НЕ IP-ban, а
|
||||||
# transient «слишком много одновременных соединений» (лимит 5). Проходит на коротком
|
# transient «слишком много одновременных соединений» (лимит 5). Проходит на коротком
|
||||||
# retry без ротации IP. Делаем до _AVITO_429_MAX_RETRIES коротких пауз; если они
|
# retry без ротации IP. Делаем до _AVITO_429_MAX_RETRIES коротких пауз; если они
|
||||||
|
|
@ -1803,13 +1804,28 @@ class AvitoScraper(BaseScraper):
|
||||||
*,
|
*,
|
||||||
label: str,
|
label: str,
|
||||||
delay_override_sec: float | None = None,
|
delay_override_sec: float | None = None,
|
||||||
|
start_page: int = 1,
|
||||||
|
on_page: Callable[[int, list[ScrapedLot]], None] | None = None,
|
||||||
) -> list[ScrapedLot]:
|
) -> list[ScrapedLot]:
|
||||||
"""Общий paginated-обход ЕКБ (citywide / novostroyka) с break-on-empty.
|
"""Общий paginated-обход ЕКБ (citywide / novostroyka) с break-on-empty.
|
||||||
|
|
||||||
url_builder(page) — функция построения URL страницы (citywide или
|
url_builder(page) — функция построения URL страницы (citywide или
|
||||||
novostroyka). Сохраняет anti-block pipeline (_fetch_serp_html: firewall,
|
novostroyka). Сохраняет anti-block pipeline (_fetch_serp_html: firewall,
|
||||||
IP rotation, ретраи), дедуп по source_id, break-on-empty. label — только
|
IP rotation, ретраи), дедуп по source_id, break-on-empty. label — только
|
||||||
для логов. Не меняет наблюдаемое поведение fetch_city_wide.
|
для логов. start_page/on_page по умолчанию не меняют поведение —
|
||||||
|
fetch_city_wide вызывает без них.
|
||||||
|
|
||||||
|
start_page (#3074): страница, с которой начать обход — страницы до
|
||||||
|
неё уже собраны предыдущим оборванным прогоном (checkpoint/resume), по
|
||||||
|
ним не делается ни одного HTTP-запроса.
|
||||||
|
on_page (#3074): опциональный callback(page: int, new_lots: list[ScrapedLot])
|
||||||
|
-> None, вызывается СРАЗУ после того, как страница пройдена до конца
|
||||||
|
(в т.ч. с пустым new_lots — иначе последнюю пустую страницу
|
||||||
|
перечитывали бы вечно при resume). Страница, оборванная исключением
|
||||||
|
или break, callback не получает — вызов означает «страница пройдена
|
||||||
|
до конца». Позволяет инкрементальный save за пределами этого метода:
|
||||||
|
без него собранное часами обхода терялось целиком при убийстве
|
||||||
|
процесса (SIGKILL/деплой) — единственный save в конце ждать было нечем.
|
||||||
"""
|
"""
|
||||||
if delay_override_sec is not None:
|
if delay_override_sec is not None:
|
||||||
self.request_delay_sec = delay_override_sec
|
self.request_delay_sec = delay_override_sec
|
||||||
|
|
@ -1817,7 +1833,7 @@ class AvitoScraper(BaseScraper):
|
||||||
all_lots: list[ScrapedLot] = []
|
all_lots: list[ScrapedLot] = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
for page in range(1, pages + 1):
|
for page in range(start_page, pages + 1):
|
||||||
url = url_builder(page)
|
url = url_builder(page)
|
||||||
try:
|
try:
|
||||||
html = await self._fetch_serp_html_with_retry(url, page)
|
html = await self._fetch_serp_html_with_retry(url, page)
|
||||||
|
|
@ -1864,6 +1880,11 @@ class AvitoScraper(BaseScraper):
|
||||||
len(lots) - len(new_lots),
|
len(lots) - len(new_lots),
|
||||||
len(all_lots),
|
len(all_lots),
|
||||||
)
|
)
|
||||||
|
if on_page is not None:
|
||||||
|
try:
|
||||||
|
on_page(page, new_lots)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("avito %s: on_page callback failed page=%d", label, page)
|
||||||
if page < pages:
|
if page < pages:
|
||||||
await self.sleep_between_requests()
|
await self.sleep_between_requests()
|
||||||
return all_lots
|
return all_lots
|
||||||
|
|
@ -1873,6 +1894,8 @@ class AvitoScraper(BaseScraper):
|
||||||
pages: int = 30,
|
pages: int = 30,
|
||||||
*,
|
*,
|
||||||
delay_override_sec: float | None = None,
|
delay_override_sec: float | None = None,
|
||||||
|
start_page: int = 1,
|
||||||
|
on_page: Callable[[int, list[ScrapedLot]], None] | None = None,
|
||||||
) -> list[ScrapedLot]:
|
) -> list[ScrapedLot]:
|
||||||
"""Обход ЕКБ-выборки только новостроек (novostroyka-filter), paginated.
|
"""Обход ЕКБ-выборки только новостроек (novostroyka-filter), paginated.
|
||||||
|
|
||||||
|
|
@ -1886,6 +1909,10 @@ class AvitoScraper(BaseScraper):
|
||||||
pages: максимальное число страниц (default 30).
|
pages: максимальное число страниц (default 30).
|
||||||
delay_override_sec: если задан — переопределяет request_delay_sec для
|
delay_override_sec: если задан — переопределяет request_delay_sec для
|
||||||
этого вызова.
|
этого вызова.
|
||||||
|
start_page (#3074): страница, с которой продолжить обход после
|
||||||
|
checkpoint/resume — см. _paginate_sweep.
|
||||||
|
on_page (#3074): callback(page, new_lots) после каждой пройденной
|
||||||
|
страницы — см. _paginate_sweep.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Список ScrapedLot новостроек (все страницы, дедуп по source_id).
|
Список ScrapedLot новостроек (все страницы, дедуп по source_id).
|
||||||
|
|
@ -1895,6 +1922,8 @@ class AvitoScraper(BaseScraper):
|
||||||
self._build_newbuilding_url,
|
self._build_newbuilding_url,
|
||||||
label="newbuilding",
|
label="newbuilding",
|
||||||
delay_override_sec=delay_override_sec,
|
delay_override_sec=delay_override_sec,
|
||||||
|
start_page=start_page,
|
||||||
|
on_page=on_page,
|
||||||
)
|
)
|
||||||
logger.info("avito fetch_newbuildings pages=%d total_lots=%d", pages, len(all_lots))
|
logger.info("avito fetch_newbuildings pages=%d total_lots=%d", pages, len(all_lots))
|
||||||
return all_lots
|
return all_lots
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue