21 Commits

Author SHA1 Message Date
qananasikq
3299f8f372 Add tools 2026-04-27 20:47:44 +03:00
qananasikq
9f215dc70a Update parser 2026-04-27 20:47:44 +03:00
qananasikq
d937aa2386 Add worker fast sync module 2026-04-27 13:09:45 +03:00
qananasikq
1168ed8d4f Harden fast sync progress logging 2026-04-27 13:09:41 +03:00
qananasikq
993f692254 Fix self heal stale progress cleanup 2026-04-27 13:09:34 +03:00
qananasikq
331d8d350e fix: beat schedule should use only_new=False for full scans 2026-04-25 14:55:36 +03:00
qananasikq
f8206615a9 add vps check 2026-04-24 18:36:58 +03:00
qananasikq
31f87a9bdf update parser 2026-04-24 18:36:31 +03:00
qananasikq
1453eee83b update sync 2026-04-24 18:00:09 +03:00
8c441b2aec Fixed proxies 2026-04-24 13:43:30 +03:00
qananasikq
fc1bea562a fast parser 2026-04-24 12:39:05 +03:00
3a3222c7dc Fixed queue name 2026-04-23 23:30:07 +03:00
11b0db5560 Merge pull request 'feat: Prepared for production' (#1) from prod-preparation into main
Reviewed-on: #1
2026-04-23 22:25:06 +02:00
6b69b8c002 Merge branch 'main' into prod-preparation 2026-04-23 22:24:53 +02:00
qananasikq
e726461e75 fix self heal restart 2026-04-23 21:34:57 +03:00
f221aebef0 Prepared for production 2026-04-23 13:54:53 +03:00
qananasikq
6aba4f0dbd fix resume 2026-04-23 09:56:18 +03:00
qananasikq
09fecdae31 fix watchdog 2026-04-23 09:56:18 +03:00
qananasikq
d5d6ba8b7c fix scraper flow 2026-04-22 21:40:40 +03:00
qananasikq
133c125e9c fix: eliminate greenlet crash + memory leak protection
- docker-compose: switch worker to --pool=solo --concurrency=1 --max-tasks-per-child=1
- tasks.py: remove ThreadPoolExecutor wrapper from _run_browser_job (greenlet crash)
- scraper.py: browser fallback runs sequentially instead of ThreadPoolExecutor
- scraper.py: add gc.collect() after scraper close to prevent memory leaks
2026-04-20 17:33:51 +03:00
qananasikq
3c71c098cd fix: remove duplicate index=True from migration 004 columns 2026-04-20 17:14:33 +03:00
53 changed files with 6220 additions and 2952 deletions

View File

@@ -6,7 +6,9 @@ IAAI_MAX_CAPTURED_REQUESTS=40
IAAI_MAX_CAPTURED_JSON_RESPONSES=20
IAAI_CARS_LISTING_URL=https://www.iaai.com/Vehiclelisting/Cars
IAAI_LISTING_SEGMENTS=auto
IAAI_FILTERED_SEARCH_URL=
IAAI_FILTERED_SEARCH_URLS=
IAAI_LISTING_SEGMENTS=runtime
IAAI_MAX_PAGES_PER_RUN=999999
IAAI_MAX_VEHICLES_PER_RUN=999999
IAAI_PAGE_LINK_LIMIT=999999
@@ -55,3 +57,6 @@ CELERY_BROKER_VISIBILITY_TIMEOUT=90000
CELERY_WORKER_CONCURRENCY=1
CELERY_BEAT_SYNC_INTERVAL_MINUTES=60
CELERY_BEAT_SYNC_LIMIT=0
IAAI_ALWAYS_FULL_SCAN=true
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_LIMIT=6
SYNC_LISTING_BOOTSTRAP_CONTINUATION_STREAK_TTL_SECONDS=21600

10
.gitignore vendored
View File

@@ -20,11 +20,5 @@ artifacts/
celerybeat-schedule*
tokens_data/
tokens.json
# Local deployment/debug artifacts
/*.tgz
/*.tar.gz
/*.zip
/NOW()
/_speed_check.sql
/.env.bak*
iaai_deploy.tar.gz
.tmp_iaai_fast_ref/

View File

@@ -13,12 +13,8 @@ RUN pip install --no-cache-dir uv \
&& pip install --no-cache-dir -r /tmp/requirements.txt \
&& pip install --no-cache-dir playwright-stealth
# Speed-up libs: orjson (fast JSON parse), brotli (HTTP br decompress).
# Pinned outside uv.lock to avoid lock-regen churn.
RUN pip install --no-cache-dir "orjson>=3.10.0" "brotli>=1.1.0"
# Install both Chromium and Firefox. Chromium is the default engine in Docker
# because it works more reliably with the current IAAI listing page.
# Ставим Chromium и Firefox.
# По умолчанию используем Chromium.
RUN python -m playwright install chromium firefox
COPY . .
@@ -31,6 +27,6 @@ STOPSIGNAL SIGINT
USER app
# По умолчанию API, но worker/beat переопределяют CMD в docker-compose
# По умолчанию запускаем API.
ENTRYPOINT ["./entrypoint.sh"]
CMD ["uvicorn", "iaai_scraper.api.app:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -112,32 +112,7 @@ docker compose up -d
`entrypoint.sh` поднимает SOCKS5→HTTP proxy bridge (если задан `SOCKS5_PROXY_HOST`) и запускает `Xvfb` только для `worker` и CLI scraping-команд.
По умолчанию `beat` запускает синхронизацию **раз в 1 час** (`CELERY_BEAT_SYNC_INTERVAL_MINUTES=60`).
Текущая стратегия устойчива к багам пагинации IAAI:
- **первый запуск** делает полный bootstrap через `sitemap`;
- если bootstrap не завершился, автоматически продолжается с чекпоинта;
- **после первого полного прохода** каждый час выполняется проход по `sitemap`.
По умолчанию включён режим **rolling refresh**:
- берутся все URL из sitemap,
- новые URL добавляются сразу,
- исчезнувшие URL помечаются как `is_sold=true`,
- уже записанные авто обновляются **батчами по кругу**, а не все разом.
Это даёт более стабильную нагрузку и снижает риск не уложиться в час.
Доступные режимы:
- `IAAI_HOURLY_MODE=rolling_refresh` — рекомендуемый продовый режим;
- `IAAI_HOURLY_MODE=full_refresh` — перепарсить все активные авто за один hourly цикл;
- `IAAI_HOURLY_MODE=diff` — только новые авто + sold.
За счёт этого система:
- не зависит от page 39 / 70 / 100,
- не уходит в бесконечный цикл пагинации,
- даёт стабильный hourly refresh уже записанных авто без монолитного полного hourly прохода,
- остаётся быстрой и устойчивой при hourly sync.
По умолчанию `beat` запускает сбор листинга **раз в 1 час** (`CELERY_BEAT_SYNC_INTERVAL_MINUTES=60`).
Swagger-документация: `http://localhost:8000/docs`
@@ -149,9 +124,10 @@ docker compose ps
- `worker` имеет healthcheck через `celery inspect ping`
- `beat` имеет healthcheck по файлу `celerybeat-schedule`
## API
**Здоровье и статистика:**
**Статистика:**
- `GET /health` — статус сервиса и подключения к БД
- `GET /api/v1/stats` — сколько машин и картинок в базе, топ брендов
@@ -267,11 +243,6 @@ pytest -q
## `pyproject.toml` + `uv.lock`
Проект переведён на современную схему зависимостей:
- `pyproject.toml` — декларация зависимостей и метаданных проекта
- `uv.lock` — зафиксированные версии для воспроизводимых установок
Локально можно использовать:
```bash
@@ -279,10 +250,6 @@ uv sync
uv run pytest -q
```
## Runtime-фильтры
Файл `runtime_config.json` управляет runtime-поведением sync и фильтрацией автомобилей.
### Секция `sync`
Поддерживаются поля:

View File

@@ -14,6 +14,7 @@ from iaai_scraper.core.config import Settings
from iaai_scraper.storage.models import Base
config = context.config
VERSION_TABLE = "iaai_parser_alembic_version"
# Logging
if config.config_file_name is not None:
@@ -32,6 +33,7 @@ def run_migrations_offline() -> None:
context.configure(
url=url,
target_metadata=target_metadata,
version_table=VERSION_TABLE,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
@@ -47,7 +49,11 @@ def run_migrations_online() -> None:
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table=VERSION_TABLE,
)
with context.begin_transaction():
context.run_migrations()

View File

@@ -1,4 +1,4 @@
# Начальная схема: cars, images, sync_runs
# Начальная схема: iaai_cars, iaai_images, iaai_sync_runs
from typing import Sequence, Union
from alembic import op
@@ -10,10 +10,29 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _schema_name() -> str | None:
bind = op.get_bind()
return "public" if bind.dialect.name == "postgresql" else None
def _table_exists(table_name: str) -> bool:
inspector = sa.inspect(op.get_bind())
return table_name in inspector.get_table_names(schema=_schema_name())
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
inspector = sa.inspect(op.get_bind())
indexes = inspector.get_indexes(table_name, schema=_schema_name())
return any(index.get("name") == index_name for index in indexes)
def upgrade() -> None:
# Таблица cars — аукционные машины с IAAI
# Таблица iaai_cars — аукционные машины с IAAI
if not _table_exists("iaai_cars"):
op.create_table(
"cars",
"iaai_cars",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("parser_id", sa.String(50), nullable=False, unique=True),
sa.Column("brand", sa.String(50), nullable=False),
@@ -45,22 +64,27 @@ def upgrade() -> None:
sa.Column("slug", sa.String, nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_cars_origin_url", "cars", ["origin_url"])
op.create_index("ix_cars_origin_id", "cars", ["origin_id"], unique=True)
# Таблица images — изображения машин
if not _index_exists("iaai_cars", "ix_iaai_cars_origin_url"):
op.create_index("ix_iaai_cars_origin_url", "iaai_cars", ["origin_url"])
if not _index_exists("iaai_cars", "ix_iaai_cars_origin_id"):
op.create_index("ix_iaai_cars_origin_id", "iaai_cars", ["origin_id"], unique=True)
# Таблица iaai_images — изображения машин
if not _table_exists("iaai_images"):
op.create_table(
"images",
"iaai_images",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("fullres_image", sa.String, nullable=False),
sa.Column("preview_image", sa.String, nullable=False),
sa.Column("order_index", sa.Integer, nullable=False),
sa.Column("car_id", sa.Integer, sa.ForeignKey("cars.id", ondelete="CASCADE"), nullable=False),
sa.Column("car_id", sa.Integer, sa.ForeignKey("iaai_cars.id", ondelete="CASCADE"), nullable=False),
)
# Таблица sync_runs — логирование синхронизаций
# Таблица iaai_sync_runs — логирование синхронизаций
if not _table_exists("iaai_sync_runs"):
op.create_table(
"sync_runs",
"iaai_sync_runs",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
@@ -73,7 +97,7 @@ def upgrade() -> None:
sa.Column("error_summary", sa.Text, nullable=True),
)
def downgrade() -> None:
op.drop_table("sync_runs")
op.drop_table("images")
op.drop_table("cars")
# Compatibility-only migration for shared production databases.
pass

View File

@@ -10,20 +10,15 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index("ix_cars_brand", "cars", ["brand"])
op.create_index("ix_cars_brand_model", "cars", ["brand", "model"])
op.create_index("ix_cars_year", "cars", ["year"])
op.create_index("ix_cars_is_sold", "cars", ["is_sold"])
op.create_index("ix_cars_last_seen_at", "cars", ["last_seen_at"])
op.create_index("ix_images_car_id", "images", ["car_id"])
op.create_index("ix_sync_runs_status", "sync_runs", ["status"])
op.execute("CREATE INDEX IF NOT EXISTS ix_iaai_cars_brand ON iaai_cars (brand)")
op.execute("CREATE INDEX IF NOT EXISTS ix_iaai_cars_brand_model ON iaai_cars (brand, model)")
op.execute("CREATE INDEX IF NOT EXISTS ix_iaai_cars_year ON iaai_cars (year)")
op.execute("CREATE INDEX IF NOT EXISTS ix_iaai_cars_is_sold ON iaai_cars (is_sold)")
op.execute("CREATE INDEX IF NOT EXISTS ix_iaai_cars_last_seen_at ON iaai_cars (last_seen_at)")
op.execute("CREATE INDEX IF NOT EXISTS ix_iaai_images_car_id ON iaai_images (car_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_iaai_sync_runs_status ON iaai_sync_runs (status)")
def downgrade() -> None:
op.drop_index("ix_sync_runs_status", table_name="sync_runs")
op.drop_index("ix_images_car_id", table_name="images")
op.drop_index("ix_cars_last_seen_at", table_name="cars")
op.drop_index("ix_cars_is_sold", table_name="cars")
op.drop_index("ix_cars_year", table_name="cars")
op.drop_index("ix_cars_brand_model", table_name="cars")
op.drop_index("ix_cars_brand", table_name="cars")
# Compatibility-only migration for shared production databases.
pass

View File

@@ -13,26 +13,24 @@ def upgrade() -> None:
# Partial index для активных машин IAAI (is_sold = FALSE)
# Ускоряет поиск при mark_sold операциях
op.execute(
"CREATE INDEX IF NOT EXISTS ix_cars_active_iaai "
"ON cars (origin_url) "
"CREATE INDEX IF NOT EXISTS ix_iaai_cars_active_iaai "
"ON iaai_cars (origin_url) "
"WHERE is_sold = FALSE AND origin_id LIKE 'iaai:%'"
)
# Composite index для проверки дубликатов изображений
op.create_index(
"ix_images_car_id_fullres",
"images",
["car_id", "fullres_image"],
op.execute(
"CREATE INDEX IF NOT EXISTS ix_iaai_images_car_id_fullres "
"ON iaai_images (car_id, fullres_image)"
)
# Index для быстрого поиска existing машин по origin_url
op.execute(
"CREATE INDEX IF NOT EXISTS ix_cars_origin_url_id "
"ON cars (origin_url, origin_id)"
"CREATE INDEX IF NOT EXISTS ix_iaai_cars_origin_url_id "
"ON iaai_cars (origin_url, origin_id)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_cars_origin_url_id")
op.drop_index("ix_images_car_id_fullres", table_name="images")
op.execute("DROP INDEX IF EXISTS ix_cars_active_iaai")
# Compatibility-only migration for shared production databases.
pass

View File

@@ -1,8 +1,7 @@
# Добавление таблиц для новой архитектуры ingestion: candidates, raw snapshots, parse results, retry queue
# Placeholder migration (ingestion tables not yet needed)
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "004_add_ingestion_tables"
down_revision: Union[str, None] = "003_add_composite_indexes"
@@ -11,71 +10,8 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Таблица vehicle_candidates
op.create_table(
"vehicle_candidates",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), primary_key=True, autoincrement=True),
sa.Column("url", sa.String(), nullable=False, unique=True, index=True),
sa.Column("discovered_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), index=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
)
# Индексы для vehicle_candidates
op.create_index("ix_vehicle_candidates_url", "vehicle_candidates", ["url"])
op.create_index("ix_vehicle_candidates_status_discovered", "vehicle_candidates", ["status", "discovered_at"])
# Таблица vehicle_raw_snapshots
op.create_table(
"vehicle_raw_snapshots",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), primary_key=True, autoincrement=True),
sa.Column("candidate_id", sa.Integer(), sa.ForeignKey("vehicle_candidates.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("captured_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), index=True),
sa.Column("method", sa.String(20), nullable=False),
sa.Column("success", sa.Boolean(), nullable=False),
sa.Column("raw_data", sa.Text(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
)
# Индексы для vehicle_raw_snapshots
op.create_index("ix_vehicle_raw_snapshots_candidate_id", "vehicle_raw_snapshots", ["candidate_id"])
op.create_index("ix_vehicle_raw_snapshots_captured_at", "vehicle_raw_snapshots", ["captured_at"])
# Таблица vehicle_parse_results
op.create_table(
"vehicle_parse_results",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), primary_key=True, autoincrement=True),
sa.Column("snapshot_id", sa.Integer(), sa.ForeignKey("vehicle_raw_snapshots.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("parsed_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), index=True),
sa.Column("success", sa.Boolean(), nullable=False),
sa.Column("parsed_data", sa.Text(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
)
# Индексы для vehicle_parse_results
op.create_index("ix_vehicle_parse_results_snapshot_id", "vehicle_parse_results", ["snapshot_id"])
op.create_index("ix_vehicle_parse_results_parsed_at", "vehicle_parse_results", ["parsed_at"])
# Таблица vehicle_retry_queue
op.create_table(
"vehicle_retry_queue",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), primary_key=True, autoincrement=True),
sa.Column("candidate_id", sa.Integer(), sa.ForeignKey("vehicle_candidates.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("reason", sa.String(50), nullable=False),
sa.Column("retry_at", sa.DateTime(timezone=True), nullable=False, index=True),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
)
# Индексы для vehicle_retry_queue
op.create_index("ix_vehicle_retry_queue_candidate_id", "vehicle_retry_queue", ["candidate_id"])
op.create_index("ix_vehicle_retry_queue_retry_at", "vehicle_retry_queue", ["retry_at"])
pass
def downgrade() -> None:
op.drop_table("vehicle_retry_queue")
op.drop_table("vehicle_parse_results")
op.drop_table("vehicle_raw_snapshots")
op.drop_table("vehicle_candidates")
pass

37
check_vps.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import os
import paramiko
HOST = "2.26.123.84"
USER = "root"
PASSWORD = os.environ["VPS_PASSWORD"]
cmds = [
"cd /root/iaai-parser && docker compose ps",
"docker logs --since 3m iaai-parser-worker-1 2>&1 | tail -n 120",
"docker exec -i iaai-postgres psql -U iaai -d iaai_scraper -c \"SELECT now() AS ts, count(*) AS cars FROM iaai_cars; SELECT count(*) AS runs FROM iaai_sync_runs;\"",
]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
HOST,
username=USER,
password=PASSWORD,
look_for_keys=False,
allow_agent=False,
timeout=30,
banner_timeout=30,
auth_timeout=30,
)
try:
for cmd in cmds:
print(f"REMOTE_RUN {cmd}", flush=True)
stdin, stdout, stderr = client.exec_command(cmd, timeout=120)
code = stdout.channel.recv_exit_status()
print(stdout.read().decode("utf-8", "replace"), end="")
print(stderr.read().decode("utf-8", "replace"), end="")
print(f"EXIT {code}", flush=True)
finally:
client.close()

41
clean_vps_db.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import os
import paramiko
HOST = "2.26.123.84"
USER = "root"
PASSWORD = os.environ["VPS_PASSWORD"]
commands = [
"cd /root/iaai-parser && docker exec -i iaai-postgres psql -U iaai -d iaai_scraper -c 'TRUNCATE TABLE iaai_images, iaai_cars, iaai_sync_runs RESTART IDENTITY CASCADE;'",
"docker exec -i iaai-redis redis-cli FLUSHALL",
"docker exec -i iaai-postgres psql -U iaai -d iaai_scraper -c 'SELECT count(*) AS cars FROM iaai_cars; SELECT count(*) AS runs FROM iaai_sync_runs;'",
"cd /root/iaai-parser && docker compose ps",
"docker logs --since 2m iaai-parser-worker-1 2>&1 | tail -n 100",
]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
HOST,
username=USER,
password=PASSWORD,
look_for_keys=False,
allow_agent=False,
timeout=30,
banner_timeout=30,
auth_timeout=30,
)
try:
for command in commands:
print(f"REMOTE_RUN {command}", flush=True)
stdin, stdout, stderr = client.exec_command(command, timeout=180)
exit_code = stdout.channel.recv_exit_status()
print(stdout.read().decode("utf-8", "replace"), end="")
print(stderr.read().decode("utf-8", "replace"), end="")
print(f"EXIT {exit_code}", flush=True)
if exit_code != 0:
raise SystemExit(exit_code)
finally:
client.close()

135
deploy_vps.py Normal file
View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import os
import posixpath
import stat
import tarfile
import time
import socket
from pathlib import Path
import paramiko
HOST = "2.26.123.84"
USER = "root"
PASSWORD = os.environ["VPS_PASSWORD"]
LOCAL_ROOT = Path(__file__).resolve().parent
ARCHIVE = LOCAL_ROOT / "iaai_deploy.tar.gz"
REMOTE_DIR = "/root/iaai-parser"
REMOTE_ARCHIVE = "/root/iaai_deploy.tar.gz"
EXCLUDE_DIRS = {
".git",
".venv",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
"iaai_scraper.egg-info",
}
EXCLUDE_FILES = {"iaai_deploy.tar.gz", "deploy_vps.py"}
def _include(path: Path) -> bool:
rel = path.relative_to(LOCAL_ROOT)
parts = set(rel.parts)
if parts & EXCLUDE_DIRS:
return False
if path.name in EXCLUDE_FILES:
return False
if path.suffix in {".pyc", ".pyo"}:
return False
return True
def make_archive() -> None:
if ARCHIVE.exists():
ARCHIVE.unlink()
with tarfile.open(ARCHIVE, "w:gz") as tf:
for path in LOCAL_ROOT.rglob("*"):
if not _include(path):
continue
tf.add(path, arcname=str(path.relative_to(LOCAL_ROOT)))
print(f"ARCHIVE_READY {ARCHIVE} {ARCHIVE.stat().st_size} bytes", flush=True)
def connect() -> paramiko.SSHClient:
last_exc: BaseException | None = None
for attempt in range(1, 6):
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
print(f"SSH_CONNECT attempt={attempt}", flush=True)
client.connect(
HOST,
username=USER,
password=PASSWORD,
look_for_keys=False,
allow_agent=False,
timeout=45,
banner_timeout=60,
auth_timeout=45,
)
return client
except (paramiko.SSHException, socket.timeout, OSError) as exc:
last_exc = exc
client.close()
print(f"SSH_CONNECT_RETRY attempt={attempt} error={type(exc).__name__}: {exc}", flush=True)
time.sleep(3 * attempt)
raise SystemExit(f"SSH_CONNECT_FAILED: {last_exc}")
def run(client: paramiko.SSHClient, cmd: str, timeout: int | None = None) -> None:
print(f"REMOTE_RUN {cmd}", flush=True)
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
exit_code = stdout.channel.recv_exit_status()
out = stdout.read().decode("utf-8", "replace")
err = stderr.read().decode("utf-8", "replace")
if out:
print(out, end="", flush=True)
if err:
print(err, end="", flush=True)
if exit_code != 0:
raise SystemExit(f"REMOTE_FAILED code={exit_code}: {cmd}")
def upload_file(sftp: paramiko.SFTPClient, local: Path, remote: str) -> None:
total = local.stat().st_size
last = 0.0
def cb(done: int, _total: int) -> None:
nonlocal last
now = time.time()
if now - last >= 2 or done == total:
print(f"UPLOAD {done}/{total}", flush=True)
last = now
sftp.put(str(local), remote, callback=cb)
def main() -> None:
make_archive()
client = connect()
try:
run(client, "echo VPS_OK && hostname && docker --version && docker compose version")
sftp = client.open_sftp()
try:
upload_file(sftp, ARCHIVE, REMOTE_ARCHIVE)
finally:
sftp.close()
run(
client,
f"mkdir -p {REMOTE_DIR} && cd {REMOTE_DIR} && docker compose down || true && "
f"find {REMOTE_DIR} -mindepth 1 -maxdepth 1 ! -name '.env' -exec rm -rf {{}} + && "
f"tar -xzf {REMOTE_ARCHIVE} -C {REMOTE_DIR} && rm -f {REMOTE_ARCHIVE}",
timeout=300,
)
run(client, f"cd {REMOTE_DIR} && docker compose up -d --build", timeout=1800)
run(client, f"cd {REMOTE_DIR} && docker compose ps", timeout=120)
run(client, "docker logs --since 2m iaai-parser-worker-1 2>&1 | tail -n 120 || docker compose -f /root/iaai-parser/docker-compose.yml logs --since 2m worker | tail -n 120", timeout=120)
finally:
client.close()
if __name__ == "__main__":
main()

View File

@@ -1,60 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/opt/iaai_scraper_project"
REPO_URL="${1:-}"
if [[ -z "${REPO_URL}" ]]; then
echo "Usage: ./deploy_vps.sh <git-repo-url>"
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
gnupg \
lsb-release
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
fi
chmod a+r /etc/apt/keyrings/docker.gpg
ARCH="$(dpkg --print-architecture)"
CODENAME="$(. /etc/os-release && echo "$VERSION_CODENAME")"
echo \
"deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y --no-install-recommends \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
mkdir -p /opt
if [[ -d "${APP_DIR}/.git" ]]; then
git -C "${APP_DIR}" fetch --all --prune
git -C "${APP_DIR}" reset --hard origin/HEAD
else
rm -rf "${APP_DIR}"
git clone "${REPO_URL}" "${APP_DIR}"
fi
cd "${APP_DIR}"
if [[ ! -f .env ]]; then
cp .env.vps.example .env
fi
docker compose down --remove-orphans || true
docker compose up -d --build postgres redis migrate api worker beat
docker compose ps

View File

@@ -1,27 +1,55 @@
x-app-env: &app-env
# NB: hardcoded to Postgres — .env may contain sqlite for local CLI, don't let it leak here
# Всегда используем Postgres.
IAAI_DATABASE_URL: postgresql+psycopg2://iaai:iaai@postgres:5432/iaai_scraper
IAAI_REDIS_URL: ${IAAI_REDIS_URL:-redis://redis:6379/0}
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
IAAI_DATABASE_POOL_RECYCLE_SECONDS: ${IAAI_DATABASE_POOL_RECYCLE_SECONDS:-1800}
CELERY_TASK_SOFT_TIME_LIMIT: ${CELERY_TASK_SOFT_TIME_LIMIT:-900}
CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT:-1200}
CELERY_BROKER_VISIBILITY_TIMEOUT: ${CELERY_BROKER_VISIBILITY_TIMEOUT:-2400}
# 0 => без лимита (в коде интерпретируется как None).
# После первичного полного прохода hourly-режим должен успевать за всеми новыми авто.
# Для больших Search-выборок (десятки тысяч лотов) run должен жить дольше одного батча.
CELERY_TASK_SOFT_TIME_LIMIT: ${CELERY_TASK_SOFT_TIME_LIMIT:-7200}
CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT:-7500}
CELERY_BROKER_VISIBILITY_TIMEOUT: ${CELERY_BROKER_VISIBILITY_TIMEOUT:-10800}
# 0 = без лимита.
CELERY_BEAT_SYNC_LIMIT: ${CELERY_BEAT_SYNC_LIMIT:-0}
CELERY_WORKER_MAX_TASKS_PER_CHILD: ${CELERY_WORKER_MAX_TASKS_PER_CHILD:-3}
CELERY_BATCH_SIZE: ${CELERY_BATCH_SIZE:-200}
IAAI_PARALLEL_TABS: ${IAAI_PARALLEL_TABS:-40}
# Следующий плановый прогон — не раньше чем через час после предыдущего tick beat.
CELERY_BEAT_SYNC_INTERVAL_MINUTES: ${CELERY_BEAT_SYNC_INTERVAL_MINUTES:-60}
# Любой внутренний follow-up после неполного bootstrap тоже не стартует сразу.
IAAI_SYNC_FOLLOWUP_MIN_DELAY_SECONDS: ${IAAI_SYNC_FOLLOWUP_MIN_DELAY_SECONDS:-3600}
CELERY_WORKER_MAX_TASKS_PER_CHILD: ${CELERY_WORKER_MAX_TASKS_PER_CHILD:-5}
CELERY_BATCH_SIZE: ${CELERY_BATCH_SIZE:-1500}
IAAI_INTER_BATCH_DELAY_SECONDS: ${IAAI_INTER_BATCH_DELAY_SECONDS:-0.3}
# Стабильный fast-профиль: не душим IAAI слишком большим числом HTTPS detail-соединений.
IAAI_SCRAPING_PROFILE: fast
IAAI_FETCH_CONCURRENCY: ${IAAI_FETCH_CONCURRENCY:-96}
IAAI_MAX_RETRIES: ${IAAI_MAX_RETRIES:-2}
IAAI_RETRY_DELAY_SECONDS: ${IAAI_RETRY_DELAY_SECONDS:-0.35}
IAAI_CHALLENGE_REFRESH_ATTEMPTS: ${IAAI_CHALLENGE_REFRESH_ATTEMPTS:-1}
CELERY_PARALLEL_SEGMENTS: ${CELERY_PARALLEL_SEGMENTS:-false}
CELERY_WORKER_CONCURRENCY: 4
CELERY_WORKER_POOL: ${CELERY_WORKER_POOL:-prefork}
IAAI_FAIL_RATE_THRESHOLD: ${IAAI_FAIL_RATE_THRESHOLD:-0.9}
IAAI_PARALLEL_TABS: ${IAAI_PARALLEL_TABS:-8}
IAAI_BLOCK_RESOURCES: ${IAAI_BLOCK_RESOURCES:-true}
IAAI_LISTING_SEGMENTS: ${IAAI_LISTING_SEGMENTS:-auto}
IAAI_FAST_PATH_TIMEOUT_MS: ${IAAI_FAST_PATH_TIMEOUT_MS:-10000}
IAAI_LISTING_SEGMENTS: ${IAAI_LISTING_SEGMENTS:-[]}
IAAI_DISCOVERY_MODE: ${IAAI_DISCOVERY_MODE:-listing}
IAAI_HOURLY_MODE: ${IAAI_HOURLY_MODE:-rolling_refresh}
IAAI_HOURLY_REFRESH_BATCH_SIZE: ${IAAI_HOURLY_REFRESH_BATCH_SIZE:-500}
IAAI_MAX_PAGES_PER_RUN: ${IAAI_MAX_PAGES_PER_RUN:-9999}
IAAI_MAX_VEHICLES_PER_RUN: ${IAAI_MAX_VEHICLES_PER_RUN:-50000}
IAAI_HUMAN_PACE_ENABLED: ${IAAI_HUMAN_PACE_ENABLED:-true}
IAAI_ALWAYS_FULL_SCAN: ${IAAI_ALWAYS_FULL_SCAN:-false}
IAAI_HUMAN_PACE_ENABLED: ${IAAI_HUMAN_PACE_ENABLED:-false}
# Первый sync после clean restart стартует сразу; следующий запуск — только через hourly guard/beat.
IAAI_STARTUP_SYNC_ENABLED: ${IAAI_STARTUP_SYNC_ENABLED:-true}
IAAI_TOKENS_FILE: ${IAAI_TOKENS_FILE:-/data/tokens.json}
IAAI_RUNTIME_CONFIG_FILE: ${IAAI_RUNTIME_CONFIG_FILE:-/app/runtime_config.json}
IAAI_BROWSER_ENGINE: chromium
# Self-heal для worker.
IAAI_SELF_HEAL_ENABLED: ${IAAI_SELF_HEAL_ENABLED:-true}
IAAI_SELF_HEAL_CHECK_INTERVAL_SECONDS: ${IAAI_SELF_HEAL_CHECK_INTERVAL_SECONDS:-30}
IAAI_SELF_HEAL_STALL_SECONDS: ${IAAI_SELF_HEAL_STALL_SECONDS:-900}
IAAI_SELF_HEAL_STARTUP_GRACE_SECONDS: ${IAAI_SELF_HEAL_STARTUP_GRACE_SECONDS:-300}
IAAI_SELF_HEAL_RESTART_COOLDOWN_SECONDS: ${IAAI_SELF_HEAL_RESTART_COOLDOWN_SECONDS:-300}
TZ: ${TZ:-UTC}
x-env-file: &env-file
@@ -42,7 +70,7 @@ x-worker-service: &worker-service
- tokens_data:/data
services:
# PostgreSQL
# PostgreSQL.
postgres:
image: postgres:16-alpine
container_name: iaai-postgres
@@ -66,7 +94,7 @@ services:
max-size: "10m"
max-file: "5"
# Redis (Celery broker)
# Redis.
redis:
image: redis:7-alpine
container_name: iaai-redis
@@ -90,7 +118,7 @@ services:
max-size: "10m"
max-file: "5"
# DB migrations
# Миграции.
migrate:
<<: *app-service
container_name: iaai-migrate
@@ -100,7 +128,7 @@ services:
condition: service_healthy
command: alembic upgrade head
# FastAPI
# API.
api:
<<: *app-service
container_name: iaai-api
@@ -128,14 +156,11 @@ services:
max-size: "10m"
max-file: "5"
# Celery Worker
# Worker.
worker:
<<: *worker-service
container_name: iaai-worker
restart: unless-stopped
init: true
mem_limit: ${IAAI_WORKER_MEM_LIMIT:-2g}
memswap_limit: ${IAAI_WORKER_MEM_LIMIT:-2g}
depends_on:
migrate:
condition: service_completed_successfully
@@ -144,11 +169,14 @@ services:
stop_grace_period: 60s
command: >
celery -A iaai_scraper.worker.celery_app worker
--loglevel=info --concurrency=${CELERY_WORKER_CONCURRENCY:-2} --pool=prefork
--loglevel=info
--concurrency=${CELERY_WORKER_CONCURRENCY:-4}
--pool=${CELERY_WORKER_POOL:-prefork}
--pidfile=/tmp/celery-worker.pid
-Q scraping --max-tasks-per-child=${CELERY_WORKER_MAX_TASKS_PER_CHILD:-3}
-Q iaai_sync --max-tasks-per-child=${CELERY_WORKER_MAX_TASKS_PER_CHILD:-5}
healthcheck:
test: ["CMD-SHELL", "test -f /tmp/celery-worker.pid && kill -0 $(cat /tmp/celery-worker.pid)"]
# Проверка worker.
test: ["CMD-SHELL", "test -f /tmp/celery-worker.pid && kill -0 $(cat /tmp/celery-worker.pid) && pgrep -f 'iaai_scraper.worker.self_heal' >/dev/null"]
interval: 60s
timeout: 10s
retries: 3
@@ -159,7 +187,7 @@ services:
max-size: "10m"
max-file: "5"
# Celery Beat (периодический планировщик)
# Beat.
beat:
<<: *worker-service
container_name: iaai-beat

View File

@@ -55,6 +55,35 @@ start_proxy_bridge_if_needed() {
echo "[entrypoint] Proxy bridge started (PID ${BRIDGE_PID})"
}
start_self_heal_watchdog_if_worker() {
if ! is_worker_command "$@"; then
return 0
fi
# После reboot/restart контейнера файловая система контейнера сохраняется,
# поэтому stale pidfile может остаться от прошлого запуска и блокировать старт Celery.
# Удаляем его перед запуском worker-процесса.
rm -f /tmp/celery-worker.pid
if [ "${IAAI_SELF_HEAL_ENABLED:-true}" = "false" ]; then
echo "[entrypoint] Self-heal watchdog disabled"
return 0
fi
echo "[entrypoint] Starting self-heal watchdog for worker..."
python -m iaai_scraper.worker.self_heal &
SELF_HEAL_PID=$!
sleep 1
if ! kill -0 "${SELF_HEAL_PID}" 2>/dev/null; then
echo "[entrypoint] ERROR: self-heal watchdog failed to start"
exit 1
fi
echo "[entrypoint] Self-heal watchdog started (PID ${SELF_HEAL_PID})"
}
start_proxy_bridge_if_needed "$@"
start_self_heal_watchdog_if_worker "$@"
exec "$@"

View File

@@ -7,7 +7,7 @@ from sqlalchemy import select, func
from ..deps import get_persistence
from ...storage.db import PersistenceService
from ...storage.models import SyncRun
from ...worker.celery_app import celery_app
from ...worker.celery_app import IAAI_SYNC_QUEUE, celery_app
from ...worker.tasks import sync_vehicle_task, sync_listing_task
router = APIRouter()
@@ -33,7 +33,7 @@ def start_sync_vehicle(
# Запустить задачу скрапинга одного автомобиля через Celery
result = sync_vehicle_task.apply_async(
kwargs={"vehicle_url": body.vehicle_url, "lane": body.lane},
queue="scraping",
queue=IAAI_SYNC_QUEUE,
)
return {
@@ -56,7 +56,7 @@ def start_sync_listing(
"limit": body.limit,
"only_new": body.only_new,
},
queue="scraping",
queue=IAAI_SYNC_QUEUE,
)
return {

View File

@@ -15,7 +15,7 @@ logger = logging.getLogger("iaai_scraper.browser")
def _build_init_script() -> str:
# Патч признаков автоматизации.
# Маскировка браузера.
hardware_concurrency = random.choice([4, 8, 12, 16])
device_memory = random.choice([4, 8, 16])
languages = ["en-US", "en"]
@@ -69,11 +69,10 @@ class BrowserFactory:
self.settings = settings
def _resolve_engine(self) -> str:
# Выбор движка браузера.
# Выбор движка.
engine = self.settings.browser_engine.strip().lower()
if engine == "auto":
# Для IAAI в headless-режиме Chromium со stealth-скриптами
# значительно стабильнее Firefox по anti-bot.
# Для IAAI стабильнее Chromium.
return "chromium"
if engine in ("firefox", "chromium"):
return engine
@@ -90,11 +89,11 @@ class BrowserFactory:
if proxy_dict:
launch_kwargs["proxy"] = proxy_dict
logger.info("Using proxy: %s", self.settings.proxy.server)
# Параметры Firefox для headless-режима.
# Настройки Firefox.
launch_kwargs["firefox_user_prefs"] = {
"dom.webdriver.enabled": False,
"useAutomationExtension": False,
# Базовые оптимизации для VPS.
# Базовые оптимизации.
"media.autoplay.default": 5,
"media.volume_scale": "0.0",
"media.audio.playback.standalone": False,
@@ -111,7 +110,7 @@ class BrowserFactory:
logger.info("Launching Firefox (headless=%s)", self.settings.headless)
return playwright.firefox.launch(**launch_kwargs)
# Путь запуска Chromium.
# Запуск Chromium.
args = [
"--disable-blink-features=AutomationControlled",
"--no-default-browser-check",
@@ -154,7 +153,7 @@ class BrowserFactory:
}
if is_firefox:
# Заголовки и user-agent для Firefox.
# Заголовки Firefox.
ctx_kwargs["user_agent"] = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
"Gecko/20100101 Firefox/128.0"
@@ -183,7 +182,7 @@ class BrowserFactory:
context.set_default_navigation_timeout(self.settings.default_timeout_ms)
if not is_firefox:
# Патчи маскировки для Chromium.
# Маскировка Chromium.
context.add_init_script(_build_init_script())
if stealth_sync:
context.on("page", lambda page: stealth_sync(page))
@@ -193,7 +192,7 @@ class BrowserFactory:
@staticmethod
def enable_resource_blocking(page) -> None:
# Блокируем тяжёлые ресурсы для ускорения загрузки страниц.
# Блокируем тяжёлые ресурсы.
BLOCKED_TYPES = {"image", "stylesheet", "font", "media"}
BLOCKED_URL_PATTERNS = (
"google-analytics", "googletagmanager", "facebook.net",

View File

@@ -0,0 +1,873 @@
from __future__ import annotations
import html
import json
import logging
import math
import re
import threading
import time
from dataclasses import dataclass
from typing import Any, Iterator
from urllib.parse import quote, urljoin
import requests
from requests.adapters import HTTPAdapter
from ..core.config import Settings
logger = logging.getLogger("iaai_scraper.fast_client")
TRANSIENT_HTTP_CODES = {408, 425, 429, 500, 502, 503, 504}
CHALLENGE_MARKERS = (
"_incapsula_resource",
"incapsula",
"incident id",
"request unsuccessful",
"access denied",
)
COOKIE_ACCEPT_SELECTORS = (
"button:has-text('Accept All')",
"button:has-text('Accept all')",
"button:has-text('I Agree')",
"button:has-text('Agree')",
"button:has-text('Only necessary')",
"button:has-text('Только необходимые')",
"button:has-text('Принять все')",
"[id*='accept']",
"[class*='accept']",
)
LISTING_MARKER = 'id="GBPSearchQuery"'
DETAIL_MARKER = 'id="ProductDetailsVM"'
RESIZER_URL = "https://vis.iaai.com/resizer"
BRAND_SCOPE_OVERRIDES = {
# IAAI does not resolve every rare make through /Vehiclelisting/Cars/{make}.
# CUPRA is available through a saved Search scope URL from the site UI.
"CUPRA": "/Search?url=Ck7mLZr7Vc2sWBshBCBOx9WhRn%2fOPJoWOhUHRQ7JNhQ%3d",
}
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
PLAYWRIGHT_REFRESH_POLLS = 8
@dataclass(frozen=True)
class FastListingVehicle:
inventory_id: str
tenant: str | None
auction_id: str | None
auction_date: str | None
inventory_status: str | None
currency: str | None
timed_auction_closed: bool
timed_auction_indicator: bool
prebid_indicator: bool
buynow_indicator: bool
@dataclass(frozen=True)
class FastListingPage:
vehicles: list[FastListingVehicle]
result_count: int
page_size: int
current_page: int
gbp_search_query: dict[str, Any]
class HybridSessionAuth:
"""Requests session with Playwright cookie refresh fallback.
Fast path is direct HTTP. Playwright is used only to obtain/refresh anti-bot
cookies when IAAI returns a challenge or an expected hidden payload is absent.
"""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._thread_local = threading.local()
self._lock = threading.Lock()
self._refresh_lock = threading.Lock()
self._bootstrap_cookies_loaded = False
self._anonymous_bootstrap_attempted = False
self._refresh_generation = 0
self._latest_refresh_cookies: list[dict[str, Any]] = []
def request(
self,
method: str,
url: str,
*,
timeout: int,
retries: int,
retry_backoff_ms: int,
headers: dict[str, str] | None = None,
data: Any | None = None,
json_body: Any | None = None,
expected_marker: str | None = None,
) -> requests.Response:
session = self._get_session()
self._ensure_anonymous_session_bootstrap(session=session)
self._sync_session_with_latest_refresh(session)
last_error: Exception | None = None
refresh_attempts = 0
attempt = 0
max_refresh_attempts = max(0, int(self._settings.scraping_profile.challenge_refresh_attempts))
while attempt <= retries:
try:
request_started_at = time.perf_counter()
if self._settings.scraping_profile.verbose_http_logs:
logger.debug(
"HTTP request started method=%s url=%s attempt=%s/%s timeout=%s marker=%s",
method,
url,
attempt + 1,
retries + 1,
timeout,
expected_marker,
)
response = session.request(
method=method,
url=url,
headers=headers,
data=data,
json=json_body,
timeout=(min(10, max(1, timeout)), max(1, timeout)),
)
if self._settings.scraping_profile.verbose_http_logs or response.status_code >= 400:
logger.debug(
"HTTP request completed method=%s url=%s status=%s elapsed=%.1fs marker=%s",
method,
url,
response.status_code,
time.perf_counter() - request_started_at,
expected_marker,
)
except requests.RequestException as exc:
last_error = exc
if attempt >= retries:
break
self._sleep_backoff(retry_backoff_ms, attempt)
attempt += 1
continue
if response.status_code in TRANSIENT_HTTP_CODES and attempt < retries:
response.close()
self._sleep_backoff(retry_backoff_ms, attempt)
attempt += 1
continue
if is_challenge_response(
status_code=response.status_code,
body_text=response.text,
expected_marker=expected_marker,
):
response.close()
if not self._settings.scraping_profile.challenge_refresh_enabled or refresh_attempts >= max_refresh_attempts:
raise RuntimeError(
"IAAI challenge persisted after "
f"{refresh_attempts} Playwright refresh attempts for url={url}"
)
refresh_attempts += 1
logger.info(
"Challenge detected for url=%s status=%s marker=%s refresh_attempt=%s/%s",
url,
response.status_code,
expected_marker,
refresh_attempts,
max_refresh_attempts,
)
self._refresh_session_via_playwright(expected_marker=LISTING_MARKER, session=session)
logger.info("Retrying HTTP request after Playwright refresh url=%s", url)
if refresh_attempts > 1:
self._sleep_backoff(retry_backoff_ms, refresh_attempts - 1)
continue
return response
if last_error is not None:
raise RuntimeError(f"Request failed url={url}: {last_error}") from last_error
raise RuntimeError(f"Request failed url={url} after retries")
def persist_storage_state(self) -> None:
session = self._get_session()
with self._lock:
self._save_storage_state(session)
def _get_session(self) -> requests.Session:
session = getattr(self._thread_local, "session", None)
if session is None:
session = requests.Session()
pool_size = max(20, int(self._settings.fetch_concurrency) * 2)
adapter = HTTPAdapter(pool_connections=pool_size, pool_maxsize=pool_size)
session.mount("http://", adapter)
session.mount("https://", adapter)
session.headers.update(
{
"user-agent": DEFAULT_USER_AGENT,
"accept-language": "en-US,en;q=0.9",
"cache-control": "no-cache",
"pragma": "no-cache",
}
)
if self._settings.proxy.enabled:
proxies = self._settings.proxy.to_requests_proxies()
if proxies:
session.proxies.update(proxies)
with self._lock:
self._bootstrap_session_cookies(session)
self._thread_local.session = session
self._thread_local.session_generation = 0
self._sync_session_with_latest_refresh(session)
return session
def _sync_session_with_latest_refresh(self, session: requests.Session) -> None:
with self._lock:
latest_generation = self._refresh_generation
session_generation = getattr(self._thread_local, "session_generation", 0)
if latest_generation <= session_generation or not self._latest_refresh_cookies:
return
cookies = list(self._latest_refresh_cookies)
self._apply_cookies_to_session(session, cookies)
self._thread_local.session_generation = latest_generation
def _ensure_anonymous_session_bootstrap(self, *, session: requests.Session) -> None:
if self._anonymous_bootstrap_attempted or not self._settings.scraping_profile.anonymous_bootstrap_enabled:
return
if self._session_has_iaai_cookies(session):
self._anonymous_bootstrap_attempted = True
return
with self._lock:
if self._anonymous_bootstrap_attempted:
return
self._anonymous_bootstrap_attempted = True
logger.info("No IAAI cookies preloaded. Attempting anonymous session bootstrap via Playwright.")
try:
self._refresh_session_via_playwright(expected_marker=LISTING_MARKER, session=session)
except Exception as exc:
logger.warning("Anonymous session bootstrap via Playwright failed; continuing with direct HTTP flow: %s", exc)
@staticmethod
def _session_has_iaai_cookies(session: requests.Session) -> bool:
for item in session.cookies:
domain = str(getattr(item, "domain", "") or "")
if not domain or "iaai.com" in domain.lower():
return True
return False
def _bootstrap_session_cookies(self, session: requests.Session) -> None:
if self._bootstrap_cookies_loaded:
return
self._load_storage_state_cookies(session)
self._bootstrap_cookies_loaded = True
def _load_storage_state_cookies(self, session: requests.Session) -> None:
tokens_file = self._settings.tokens_file
if not tokens_file:
return
path = __import__("pathlib").Path(tokens_file)
if not path.exists():
return
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("Failed to read storage state file '%s': %s", path, exc)
return
cookies = payload.get("cookies") if isinstance(payload, dict) else None
if not isinstance(cookies, list):
return
applied = 0
for item in cookies:
if not isinstance(item, dict):
continue
name = parse_text(item.get("name"))
value = parse_text(item.get("value"))
if not name or value is None:
continue
domain = parse_text(item.get("domain")) or ".iaai.com"
cookie_path = parse_text(item.get("path")) or "/"
expires = parse_int(item.get("expires"))
session.cookies.set(name, value, domain=domain, path=cookie_path, expires=expires)
applied += 1
if applied:
logger.info("Loaded %s cookies from storage state", applied)
def _refresh_session_via_playwright(
self,
*,
expected_marker: str | None = None,
session: requests.Session | None = None,
) -> None:
target_session = session or self._get_session()
with self._lock:
baseline_generation = self._refresh_generation
with self._refresh_lock:
with self._lock:
if self._refresh_generation > baseline_generation and self._latest_refresh_cookies:
self._apply_cookies_to_session(target_session, self._latest_refresh_cookies)
self._thread_local.session_generation = self._refresh_generation
return
logger.info("IAAI session challenge detected. Refreshing session via Playwright.")
cookies = self._fetch_cookies_via_playwright(expected_marker=expected_marker)
logger.info("Playwright refresh returned %d cookies", len(cookies))
self._apply_cookies_to_session(target_session, cookies)
logger.info("Playwright cookies applied to requests session")
with self._lock:
self._refresh_generation += 1
self._latest_refresh_cookies = list(cookies)
self._anonymous_bootstrap_attempted = True
refreshed_generation = self._refresh_generation
self._thread_local.session_generation = refreshed_generation
logger.info("Playwright refresh completed generation=%s", refreshed_generation)
def _fetch_cookies_via_playwright(self, *, expected_marker: str | None = None) -> list[dict[str, Any]]:
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=self._settings.headless)
try:
context = browser.new_context(
locale=self._settings.fingerprint.locale,
viewport={"width": 1366, "height": 768},
user_agent=DEFAULT_USER_AGENT,
proxy=self._settings.proxy.to_playwright_dict(),
)
page = context.new_page()
home_target = self._settings.home_url
filtered_urls = self._settings.listing.filtered_search_urls
target = filtered_urls[0] if filtered_urls else urljoin(self._settings.home_url, "Vehiclelisting/Cars")
timeout_ms = max(30_000, self._settings.default_timeout_ms)
logger.info("Playwright session refresh opening target=%s marker=%s", target, expected_marker or LISTING_MARKER)
page.goto(home_target, wait_until="domcontentloaded", timeout=timeout_ms)
self._accept_cookie_banner(page)
page.goto(target, wait_until="domcontentloaded", timeout=timeout_ms)
self._accept_cookie_banner(page)
self._wait_until_non_challenge(
page=page,
target=target,
timeout_ms=timeout_ms,
expected_marker=expected_marker or LISTING_MARKER,
)
cookies = context.cookies()
logger.info("Playwright context returned %d cookies", len(cookies))
except PlaywrightTimeoutError as exc:
raise RuntimeError(f"Playwright refresh timed out: {exc}") from exc
finally:
try:
browser.close()
except Exception as exc:
logger.info("Playwright browser close failed after cookie refresh: %s", exc)
if not isinstance(cookies, list) or not cookies:
raise RuntimeError("Playwright refresh did not return cookies")
return [cookie for cookie in cookies if isinstance(cookie, dict)]
@staticmethod
def _apply_cookies_to_session(session: requests.Session, cookies: list[dict[str, Any]]) -> None:
session.cookies.clear()
for cookie in cookies:
name = parse_text(cookie.get("name"))
value = parse_text(cookie.get("value"))
if not name or value is None:
continue
domain = parse_text(cookie.get("domain")) or ".iaai.com"
cookie_path = parse_text(cookie.get("path")) or "/"
expires = parse_int(cookie.get("expires"))
session.cookies.set(name, value, domain=domain, path=cookie_path, expires=expires)
@staticmethod
def _wait_until_non_challenge(*, page: Any, target: str, timeout_ms: int, expected_marker: str | None) -> None:
poll_ms = max(1000, min(5000, timeout_ms // PLAYWRIGHT_REFRESH_POLLS))
navigation_error_count = 0
for poll_index in range(PLAYWRIGHT_REFRESH_POLLS):
try:
page.wait_for_load_state("domcontentloaded", timeout=poll_ms)
except Exception:
pass
page.wait_for_timeout(poll_ms)
body: str | None = None
for _ in range(3):
try:
body = page.content()
break
except Exception as exc:
message = str(exc).lower()
if "page.content" not in message or "navigating and changing the content" not in message:
raise
navigation_error_count += 1
page.wait_for_timeout(max(200, poll_ms // 4))
if body is not None and not is_challenge_response(
status_code=200,
body_text=body,
expected_marker=expected_marker,
):
logger.info(
"Playwright session refresh passed challenge target=%s poll=%s/%s marker=%s",
target,
poll_index + 1,
PLAYWRIGHT_REFRESH_POLLS,
expected_marker,
)
return
try:
logger.info(
"Playwright session refresh still waiting target=%s poll=%s/%s marker=%s",
target,
poll_index + 1,
PLAYWRIGHT_REFRESH_POLLS,
expected_marker,
)
page.goto(target, wait_until="domcontentloaded", timeout=timeout_ms)
except Exception:
pass
raise RuntimeError(
"Playwright refresh completed but challenge page is still active "
f"(navigation_content_errors={navigation_error_count})"
)
@staticmethod
def _accept_cookie_banner(page: Any) -> None:
for selector in COOKIE_ACCEPT_SELECTORS:
try:
locator = page.locator(selector).first
if locator.count() == 0 or not locator.is_visible(timeout=500):
continue
locator.click(timeout=2_000)
page.wait_for_timeout(250)
return
except Exception:
continue
def _save_storage_state(self, session: requests.Session) -> None:
tokens_file = self._settings.tokens_file
if not tokens_file:
return
path = __import__("pathlib").Path(tokens_file)
cookies: list[dict[str, Any]] = []
for cookie in session.cookies:
payload: dict[str, Any] = {
"name": cookie.name,
"value": cookie.value,
"domain": cookie.domain or ".iaai.com",
"path": cookie.path or "/",
"httpOnly": False,
"secure": bool(cookie.secure),
"sameSite": "Lax",
}
if cookie.expires is not None:
payload["expires"] = int(cookie.expires)
cookies.append(payload)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"cookies": cookies, "origins": []}, ensure_ascii=False, indent=2), encoding="utf-8")
except PermissionError as exc:
logger.info("Cannot persist IAAI storage state to '%s': %s", path, exc)
except OSError as exc:
logger.info("Failed to persist IAAI storage state to '%s': %s", path, exc)
@staticmethod
def _sleep_backoff(retry_backoff_ms: int, attempt: int) -> None:
if retry_backoff_ms <= 0:
return
time.sleep(retry_backoff_ms * (2**attempt) / 1000)
class IAAIFastClient:
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._auth = HybridSessionAuth(settings)
def persist_session_state(self) -> None:
self._auth.persist_storage_state()
def iter_listing_vehicles(
self,
*,
listing_start_url: str | None = None,
make: str | None = None,
max_pages: int | None = None,
) -> Iterator[FastListingVehicle]:
seen_inventory_ids: set[str] = set()
scope_paths = resolve_listing_scope_paths(
listing_start_url=listing_start_url or "",
brands={make} if make else set(),
)
for scope_path in scope_paths:
first_page_html = self._fetch_listing_first_page(scope_path)
search_scope_path = build_search_scope_path_from_html(first_page_html)
if search_scope_path and search_scope_path != scope_path:
logger.info(
"Resolved listing scope to fast Search URL: scope=%s search_scope=%s",
scope_path,
search_scope_path,
)
scope_path = search_scope_path
first_page = parse_listing_page(first_page_html)
for vehicle in first_page.vehicles:
if vehicle.inventory_id in seen_inventory_ids:
continue
seen_inventory_ids.add(vehicle.inventory_id)
yield vehicle
page_size = max(1, first_page.page_size)
total_pages = max(1, math.ceil(max(first_page.result_count, len(first_page.vehicles)) / page_size))
if max_pages is not None and max_pages > 0:
total_pages = min(total_pages, max_pages)
gbp_search_query = first_page.gbp_search_query
logger.info(
"Fast listing first page parsed: scope=%s result_count=%s page_size=%s total_pages=%s first_page_vehicles=%s",
scope_path,
first_page.result_count,
page_size,
total_pages,
len(first_page.vehicles),
)
for page_number in range(2, total_pages + 1):
if page_number == 2 or page_number % 25 == 0 or page_number == total_pages:
logger.info("Fast listing page fetch progress: page=%s/%s", page_number, total_pages)
page_html = self._fetch_listing_page(scope_path, gbp_search_query, page_number, page_size)
parsed_page = parse_listing_page(page_html)
gbp_search_query = parsed_page.gbp_search_query
for vehicle in parsed_page.vehicles:
if vehicle.inventory_id in seen_inventory_ids:
continue
seen_inventory_ids.add(vehicle.inventory_id)
yield vehicle
def fetch_vehicle_detail_payload(self, inventory_id: str) -> dict[str, Any]:
escaped_id = quote(inventory_id, safe="~")
url = urljoin(self._settings.home_url, f"VehicleDetail/{escaped_id}")
response = self._auth.request(
"GET",
url,
timeout=max(1, self._settings.fast_path_timeout_ms // 1000),
retries=max(0, self._settings.scraping_profile.detail_retries if self._settings.scraping_profile.detail_retries is not None else self._settings.max_retries),
retry_backoff_ms=int(max(0, self._settings.retry_delay_seconds * 1000)),
headers={"accept": "text/html,application/xhtml+xml"},
expected_marker=DETAIL_MARKER,
)
with response:
if response.status_code >= 400:
raise RuntimeError(f"Vehicle detail request failed id={inventory_id} status={response.status_code}")
return parse_product_details_vm(response.text)
def fetch_vehicle_detail_html(self, inventory_id: str) -> str:
escaped_id = quote(inventory_id, safe="~")
url = urljoin(self._settings.home_url, f"VehicleDetail/{escaped_id}")
response = self._auth.request(
"GET",
url,
timeout=max(1, self._settings.fast_path_timeout_ms // 1000),
retries=max(0, self._settings.scraping_profile.detail_retries if self._settings.scraping_profile.detail_retries is not None else self._settings.max_retries),
retry_backoff_ms=int(max(0, self._settings.retry_delay_seconds * 1000)),
headers={"accept": "text/html,application/xhtml+xml"},
expected_marker=DETAIL_MARKER,
)
with response:
if response.status_code >= 400:
raise RuntimeError(f"Vehicle detail request failed id={inventory_id} status={response.status_code}")
return response.text
def _fetch_listing_first_page(self, scope_path: str) -> str:
url = scope_path if scope_path.lower().startswith(("http://", "https://")) else urljoin(self._settings.home_url, scope_path.lstrip("/"))
response = self._auth.request(
"GET",
url,
timeout=max(1, self._settings.fast_path_timeout_ms // 1000),
retries=max(0, self._settings.scraping_profile.listing_retries if self._settings.scraping_profile.listing_retries is not None else self._settings.max_retries),
retry_backoff_ms=int(max(0, self._settings.retry_delay_seconds * 1000)),
headers={"accept": "text/html,application/xhtml+xml"},
expected_marker=LISTING_MARKER,
)
with response:
if response.status_code >= 400:
raise RuntimeError(f"Listing request failed path={scope_path} status={response.status_code}")
return response.text
def _fetch_listing_page(self, scope_path: str, gbp_search_query: dict[str, Any], page_number: int, page_size: int) -> str:
query_payload = dict(gbp_search_query)
query_payload["CurrentPage"] = page_number
query_payload["PageSize"] = page_size
search_url = urljoin(self._settings.home_url, "Search")
common_headers = {
"accept": "text/html,application/xhtml+xml,*/*",
"x-requested-with": "XMLHttpRequest",
}
attempts: list[tuple[dict[str, str], Any, Any]] = [
({**common_headers, "content-type": "application/json"}, None, query_payload),
({**common_headers, "content-type": "application/json"}, None, {"GBPSearchQuery": query_payload}),
({**common_headers}, {"GBPSearchQuery": json.dumps(query_payload, separators=(",", ":"))}, None),
({**common_headers, "content-type": "application/json"}, json.dumps({"GBPSearchQuery": json.dumps(query_payload, separators=(",", ":"))}), None),
]
attempts = attempts[:max(1, min(len(attempts), int(self._settings.scraping_profile.listing_post_attempts)))]
last_error: Exception | None = None
for headers, data, json_body in attempts:
try:
response = self._auth.request(
"POST",
search_url,
timeout=max(1, self._settings.fast_path_timeout_ms // 1000),
retries=max(0, self._settings.scraping_profile.listing_retries if self._settings.scraping_profile.listing_retries is not None else self._settings.max_retries),
retry_backoff_ms=int(max(0, self._settings.retry_delay_seconds * 1000)),
headers=headers,
data=data,
json_body=json_body,
expected_marker=LISTING_MARKER,
)
with response:
if response.status_code >= 400:
raise RuntimeError(f"Listing page request failed status={response.status_code} page={page_number}")
body = response.text
if LISTING_MARKER not in body:
raise RuntimeError("Listing page response does not include GBPSearchQuery")
return body
except Exception as exc:
last_error = exc
continue
if last_error is not None:
raise RuntimeError(f"Failed to load listing page={page_number} for {scope_path}: {last_error}") from last_error
raise RuntimeError(f"Failed to load listing page={page_number} for {scope_path}")
def build_brand_scope_paths(brands: set[str]) -> list[str]:
if not brands:
return ["/Vehiclelisting/Cars"]
paths: list[str] = []
for brand in sorted(brands):
raw = brand.strip()
if not raw:
continue
override = BRAND_SCOPE_OVERRIDES.get(raw.upper())
if override:
if override not in paths:
paths.append(override)
continue
slug_hyphen = quote(raw.replace(" ", "-"), safe="-")
slug_raw = quote(raw, safe="")
for slug in (slug_hyphen, slug_raw):
path = f"/Vehiclelisting/Cars/{slug}"
if path not in paths:
paths.append(path)
return paths or ["/Vehiclelisting/Cars"]
def resolve_listing_scope_paths(*, listing_start_url: str, brands: set[str]) -> list[str]:
explicit_scope = listing_start_url.strip()
if explicit_scope:
if explicit_scope.lower().startswith(("http://", "https://", "/")):
return [explicit_scope]
return [f"/Search?url={explicit_scope}"]
return build_brand_scope_paths(brands)
def build_search_scope_path_from_html(html_text: str) -> str | None:
tiny_url = parse_attribute_value(html_text, "data-tinyurl")
if tiny_url:
return f"/Search?url={tiny_url}"
data_query_raw = parse_attribute_value(html_text, "data-query")
if data_query_raw:
try:
data_query = json.loads(data_query_raw)
except (json.JSONDecodeError, ValueError, TypeError):
data_query = None
if isinstance(data_query, dict):
url_value = parse_text(data_query.get("Url"))
if url_value:
return f"/Search?url={url_value}"
return None
def parse_listing_page(html_text: str) -> FastListingPage:
gbp_raw = parse_hidden_input_value(html_text, "GBPSearchQuery")
vehicle_raw = parse_hidden_input_value(html_text, "VehicleDetails")
result_count_raw = parse_hidden_input_value(html_text, "ResultCount")
page_size_raw = parse_hidden_input_value(html_text, "PageSize")
current_page_raw = parse_hidden_input_value(html_text, "CurrentPage")
if not gbp_raw:
raise RuntimeError("Listing page missing GBPSearchQuery")
if vehicle_raw is None:
raise RuntimeError("Listing page missing VehicleDetails")
gbp_payload = json.loads(gbp_raw)
if not isinstance(gbp_payload, dict):
raise RuntimeError("GBPSearchQuery payload is not object")
vehicle_payload = json.loads(vehicle_raw)
if not isinstance(vehicle_payload, list):
raise RuntimeError("VehicleDetails payload is not array")
vehicles: list[FastListingVehicle] = []
for item in vehicle_payload:
if not isinstance(item, dict):
continue
inventory_id = parse_text(item.get("Id"))
if not inventory_id:
continue
vehicles.append(
FastListingVehicle(
inventory_id=inventory_id,
tenant=parse_text(item.get("Tenant")),
auction_id=parse_text(item.get("ActnLnId")),
auction_date=parse_text(item.get("AuctionDate")) or parse_text(item.get("ActnDtTm")),
inventory_status=parse_text(item.get("InventoryStatus")),
currency=parse_text(item.get("Currency")),
timed_auction_closed=parse_bool(item.get("TimedAuctionClosedIndicator")),
timed_auction_indicator=parse_bool(item.get("TimedAuctionIndicator")),
prebid_indicator=parse_bool(item.get("PreBidIndicator")),
buynow_indicator=parse_bool(item.get("BuyNowIndicator")),
)
)
return FastListingPage(
vehicles=vehicles,
result_count=parse_int(result_count_raw) or len(vehicles),
page_size=parse_int(page_size_raw) or max(1, len(vehicles)),
current_page=parse_int(current_page_raw) or 1,
gbp_search_query=gbp_payload,
)
def parse_product_details_vm(html_text: str) -> dict[str, Any]:
match = re.search(
r"<script[^>]*id=[\"']ProductDetailsVM[\"'][^>]*>\s*(\{.*?\})\s*</script>",
html_text,
flags=re.DOTALL | re.IGNORECASE,
)
if match is None:
raise RuntimeError("ProductDetailsVM script not found")
payload = json.loads(match.group(1))
if not isinstance(payload, dict):
raise RuntimeError("ProductDetailsVM root is not object")
return payload
def parse_hidden_input_value(html_text: str, input_id: str) -> str | None:
escaped_id = re.escape(input_id)
patterns = (
rf"<input[^>]*\bid=\"{escaped_id}\"[^>]*\bvalue=\"([^\"]*)\"",
rf"<input[^>]*\bid='{escaped_id}'[^>]*\bvalue='([^']*)'",
)
for pattern in patterns:
match = re.search(pattern, html_text, flags=re.IGNORECASE)
if match is not None:
return html.unescape(match.group(1))
return None
def parse_attribute_value(html_text: str, attribute_name: str) -> str | None:
escaped_name = re.escape(attribute_name)
patterns = (
rf"\b{escaped_name}=\"([^\"]*)\"",
rf"\b{escaped_name}='([^']*)'",
)
for pattern in patterns:
match = re.search(pattern, html_text, flags=re.IGNORECASE)
if match is not None:
value = html.unescape(match.group(1)).strip()
return value or None
return None
def build_resizer_images_from_keys(image_keys: list[dict[str, Any]]) -> list[dict[str, str | int]]:
seen_fullres: set[str] = set()
images: list[dict[str, str | int]] = []
for index, item in enumerate(image_keys):
if not isinstance(item, dict):
continue
key = parse_text(item.get("k"))
if key is None:
continue
width = parse_int(item.get("w")) or 1600
height = parse_int(item.get("h")) or 1200
if width <= 0:
width = 1600
if height <= 0:
height = 1200
order_index = parse_int(item.get("i"))
if order_index is None:
order_index = parse_int(item.get("in"))
if order_index is None:
order_index = index
preview_width = min(640, width)
preview_height = max(1, int(round(height * (preview_width / width))))
escaped_key = quote(key, safe="~")
fullres = f"{RESIZER_URL}?imageKeys={escaped_key}&width={width}&height={height}"
preview = f"{RESIZER_URL}?imageKeys={escaped_key}&width={preview_width}&height={preview_height}"
if fullres in seen_fullres:
continue
seen_fullres.add(fullres)
images.append({"order_index": order_index, "fullres_image": fullres, "preview_image": preview})
images.sort(key=lambda row: (parse_int(row.get("order_index")) or 0, str(row.get("fullres_image"))))
return images
def is_challenge_response(*, status_code: int, body_text: str, expected_marker: str | None = None) -> bool:
if status_code in {401, 403}:
return True
if _expected_marker_present(body_text=body_text, expected_marker=expected_marker):
return False
lowered = (body_text or "").lower()
if any(marker in lowered for marker in CHALLENGE_MARKERS):
return True
if expected_marker and not _expected_marker_present(body_text=body_text, expected_marker=expected_marker):
if "<html" in lowered or "<body" in lowered:
return True
return False
def _expected_marker_present(*, body_text: str, expected_marker: str | None) -> bool:
if not expected_marker:
return False
if expected_marker in body_text:
return True
if '"' in expected_marker and expected_marker.replace('"', "'") in body_text:
return True
if "'" in expected_marker and expected_marker.replace("'", '"') in body_text:
return True
marker_match = re.search(r"id=['\"]([^'\"]+)['\"]", expected_marker)
if marker_match is None:
return False
marker_id = re.escape(marker_match.group(1))
return bool(re.search(rf"id\s*=\s*['\"]{marker_id}['\"]", body_text, flags=re.IGNORECASE))
def parse_text(value: Any) -> str | None:
if isinstance(value, str):
text = value.strip()
return text if text else None
return None
def parse_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"true", "1", "yes", "on"}
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value != 0
return False
def parse_int(value: Any) -> int | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(round(value))
if isinstance(value, str):
text = value.strip()
if not text:
return None
normalized = text.replace(",", "").replace(" ", "").replace("$", "")
match = re.search(r"-?\d+(?:\.\d+)?", normalized)
if match is None:
return None
try:
return int(round(float(match.group(0))))
except ValueError:
return None
return None

View File

@@ -3,12 +3,13 @@ import re
import time
from dataclasses import asdict, dataclass, field
from typing import Any
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
from urllib.parse import urljoin
from playwright.sync_api import Page
from .pace import HumanPacer
from ..core.config import Settings
from ..core.utils import first_non_empty
logger = logging.getLogger("iaai_scraper.listing")
VEHICLE_HREF_RE = re.compile(r"/VehicleDetail/(\d+)(?:~[A-Z]{2})?", re.IGNORECASE)
@@ -43,7 +44,6 @@ class ListingPageResult:
class ListingCollector:
_DEEP_PAGINATION_DIRECT_ONLY_FROM_PAGE = 40
_NEXT_PAGE_SELECTORS: tuple[str, ...] = (
"a[aria-label*='Next']",
"button[aria-label*='Next']",
@@ -103,33 +103,7 @@ class ListingCollector:
return None
@staticmethod
def _get_vehicle_link_fingerprint(page: Page, limit: int = 5) -> tuple[str, ...]:
try:
values = page.evaluate(
"""
(limit) => {
return Array.from(document.querySelectorAll("a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']"))
.map((el) => (el.getAttribute('href') || '').trim())
.filter(Boolean)
.slice(0, limit);
}
""",
limit,
)
if not isinstance(values, list):
return tuple()
return tuple(str(value) for value in values if value)
except Exception:
return tuple()
@staticmethod
def _wait_for_navigation_result(
page: Page,
old_first_href: str,
expected_page_number: int | None,
*,
old_fingerprint: tuple[str, ...] = (),
) -> bool:
def _wait_for_navigation_result(page: Page, old_first_href: str, expected_page_number: int | None) -> bool:
if old_first_href:
try:
page.wait_for_function(
@@ -143,12 +117,9 @@ class ListingCollector:
except Exception:
pass
new_fingerprint = ListingCollector._get_vehicle_link_fingerprint(page)
if expected_page_number is not None:
current_page = ListingCollector._get_current_page_number(page)
if current_page == expected_page_number and (
not old_fingerprint or (new_fingerprint and new_fingerprint != old_fingerprint)
):
if current_page == expected_page_number:
return True
try:
@@ -156,81 +127,33 @@ class ListingCollector:
except Exception:
pass
if not new_fingerprint:
new_fingerprint = ListingCollector._get_vehicle_link_fingerprint(page)
current_page = ListingCollector._get_current_page_number(page)
if new_fingerprint and old_fingerprint and new_fingerprint != old_fingerprint:
return current_page is None or expected_page_number is None or current_page == expected_page_number
if expected_page_number is not None and current_page == expected_page_number:
return not old_fingerprint
return True
return not old_first_href and (not old_fingerprint or bool(new_fingerprint))
def _extract_next_page_href(self, page: Page, expected_page_number: int | None = None) -> str | None:
try:
href = page.evaluate(
"""
(expectedPageNumber) => {
const visible = (el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
const disabled = (el) => {
if (!el) return true;
const cls = (el.getAttribute('class') || '').toLowerCase();
const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase();
return el.hasAttribute('disabled') || ariaDisabled === 'true' || cls.includes('disabled');
};
const controls = Array.from(document.querySelectorAll('a,button,[role="button"]'))
.filter((el) => visible(el) && !disabled(el));
const cleanHref = (el) => {
const href = (el?.getAttribute('href') || '').trim();
if (!href || href.startsWith('javascript:') || href.startsWith('#')) {
return '';
}
return href;
};
if (expectedPageNumber !== null && expectedPageNumber !== undefined) {
const numeric = controls.find((el) => {
const text = (el.textContent || '').trim();
return /^\d+$/.test(text) && parseInt(text, 10) === expectedPageNumber;
});
const numericHref = cleanHref(numeric);
if (numericHref) {
return numericHref;
}
}
const explicitNext = controls.find((el) => {
const text = (el.textContent || '').trim().toLowerCase();
const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase();
const title = (el.getAttribute('title') || '').trim().toLowerCase();
const rel = (el.getAttribute('rel') || '').trim().toLowerCase();
const cls = (el.getAttribute('class') || '').trim().toLowerCase();
const hasRightArrowIcon = !!el.querySelector('img[src*="icon-arrow-right"], img[src*="arrow-right"]');
return rel === 'next' || aria.includes('next') || title.includes('next') || cls.includes('next') || hasRightArrowIcon || ['next', '', '»', '>'].includes(text);
});
return cleanHref(explicitNext);
}
""",
expected_page_number,
)
if not href:
return None
return urljoin(page.url or self.settings.home_url, str(href))
except Exception:
return None
return not old_first_href
@staticmethod
def _build_listing_page_url(url: str, page_number: int) -> str:
parsed = urlparse(url)
query_items = [
(key, value)
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
if key.lower() not in {"page", "pagenumber", "currentpage"}
]
query_items.append(("page", str(page_number)))
return urlunparse(parsed._replace(query=urlencode(query_items)))
def has_page_number(page: Page, target_page_number: int) -> bool:
try:
return bool(page.evaluate(
"""
(targetPageNumber) => {
const visible = (el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
const controls = Array.from(document.querySelectorAll('a,button,[role="button"],span,div'));
return controls.some((el) => {
const text = (el.textContent || '').trim();
const cls = (el.getAttribute('class') || '').toLowerCase();
const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase();
const disabled = el.hasAttribute('disabled') || ariaDisabled === 'true' || cls.includes('disabled');
return visible(el) && !disabled && /^\d+$/.test(text) && parseInt(text, 10) === targetPageNumber;
});
}
""",
target_page_number,
))
except Exception:
return False
def open_cars_listing(self, page: Page, *, url_override: str | None = None) -> None:
url = url_override or self.settings.listing.cars_url
@@ -301,31 +224,6 @@ class ListingCollector:
# Короткая пауза вместо длинного sleep.
time.sleep(1.0)
def _get_listing_html(self, page: Page, page_number: int) -> str:
try:
return page.locator("html").inner_html(timeout=5_000)
except Exception as exc:
logger.warning("listing html read failed on page %d: %s", page_number, exc)
return ""
def _has_next_page_from_html(self, html: str, current_page_number: int | None = None) -> bool:
if not html:
return False
normalized = html.replace("\\/", "/")
if re.search(
r"rel\s*=\s*['\"]next['\"]|aria-label\s*=\s*['\"][^'\"]*next|title\s*=\s*['\"][^'\"]*next|class\s*=\s*['\"][^'\"]*next|icon-arrow-right|arrow-right|>\s*next\s*<|>\s*[›»>]\s*<",
normalized,
re.IGNORECASE,
):
return True
if current_page_number is not None:
next_page = current_page_number + 1
if re.search(rf">\s*{next_page}\s*<", normalized, re.IGNORECASE):
return True
if re.search(rf"page={next_page}(?:\D|$)", normalized, re.IGNORECASE):
return True
return False
def apply_filters(
self,
page: Page,
@@ -403,12 +301,59 @@ class ListingCollector:
return False
def collect_current_page(self, page: Page, page_number: int = 1) -> ListingPageResult:
# Для IAAI HTML/hydration-извлечение стабильнее, чем прямой DOM eval.
# Считываем ссылки одним проходом по DOM.
self._accept_cookie_banner(page)
self._wait_for_listing_content(page)
try:
raw_items = page.eval_on_selector_all(
"a[href], [data-href], [href]",
"""
(nodes) => nodes.map((node) => ({
href:
node.getAttribute('href') ||
node.getAttribute('data-href') ||
node.getAttribute('data-url') ||
'',
title: node.getAttribute('title') || node.getAttribute('aria-label') || '',
text: (node.textContent || '').trim(),
}))
""",
)
except Exception as exc:
logger.warning("collect_current_page failed on page %d: %s", page_number, exc)
raw_items = []
total = min(len(raw_items), self.settings.listing.page_link_limit)
links: list[ListingVehicleLink] = []
seen: set[str] = set()
html = self._get_listing_html(page, page_number)
for idx in range(total):
item = raw_items[idx] if isinstance(raw_items[idx], dict) else {}
href = str(item.get("href") or "")
match = VEHICLE_HREF_RE.search(href)
if not match:
continue
lot_number = match.group(1)
absolute = urljoin(self.settings.home_url, match.group(0))
if absolute in seen:
continue
seen.add(absolute)
title = first_non_empty([item.get("title"), item.get("text"), ""]) or ""
links.append(ListingVehicleLink(href=absolute, title=str(title).strip(), lot_number=lot_number))
if len(links) >= self.settings.listing.max_vehicles_per_run:
break
# Fallback: на IAAI ссылки иногда не рендерятся как <a>,
# но присутствуют в hydration/inline JSON внутри HTML (часто как \/VehicleDetail\/").
if not links:
try:
page.wait_for_timeout(1_500)
except Exception:
pass
try:
html = page.content()
except Exception as exc:
logger.debug("page.content() failed on page %d: %s", page_number, exc)
html = ""
for absolute, lot_number in self._extract_vehicle_links_from_html(html):
if absolute in seen:
continue
@@ -423,9 +368,6 @@ class ListingCollector:
page_number,
len(links),
)
next_page_detected = self._has_next_page_from_html(html, current_page_number=page_number)
if not next_page_detected and not html:
next_page_detected = self._has_next_page(page)
return ListingPageResult(source_url=page.url, page_number=page_number, vehicle_links=links, pagination_available=next_page_detected, next_page_detected=next_page_detected)
@@ -453,59 +395,12 @@ class ListingCollector:
def go_to_next_page(self, page: Page, expected_page_number: int | None = None) -> bool:
# Запоминаем первую ссылку текущей страницы для определения смены контента.
old_first_href = ""
old_fingerprint: tuple[str, ...] = tuple()
try:
first_link = page.locator(VEHICLE_LINK_SELECTOR).first
if first_link.count() > 0:
old_first_href = first_link.get_attribute("href") or ""
except Exception:
pass
old_fingerprint = self._get_vehicle_link_fingerprint(page)
if expected_page_number is not None and expected_page_number > 1:
direct_page_url = self._build_listing_page_url(
page.url or self.settings.listing.cars_url,
expected_page_number,
)
try:
logger.debug("Navigating directly to listing page %d via URL: %s", expected_page_number, direct_page_url)
page.goto(direct_page_url, wait_until="domcontentloaded", timeout=15_000)
if self._wait_for_navigation_result(
page,
old_first_href,
expected_page_number,
old_fingerprint=old_fingerprint,
):
self.pacer.after_page_change()
return True
except Exception as exc:
logger.debug("Direct page-number navigation failed for page %d via %s: %s", expected_page_number, direct_page_url, exc)
next_href = self._extract_next_page_href(page, expected_page_number)
if next_href:
try:
logger.debug("Navigating directly to next listing page: %s", next_href)
page.goto(next_href, wait_until="domcontentloaded", timeout=15_000)
if self._wait_for_navigation_result(
page,
old_first_href,
expected_page_number,
old_fingerprint=old_fingerprint,
):
self.pacer.after_page_change()
return True
except Exception as exc:
logger.debug("Direct next-page navigation failed for %s: %s", next_href, exc)
if (
expected_page_number is not None
and expected_page_number >= self._DEEP_PAGINATION_DIRECT_ONLY_FROM_PAGE
):
logger.warning(
"Deep pagination direct navigation failed for page %d; skipping flaky UI pagination fallbacks",
expected_page_number,
)
return False
for selector in self._NEXT_PAGE_SELECTORS:
locator = page.locator(selector).first
@@ -525,12 +420,7 @@ class ListingCollector:
except Exception:
continue
if self._wait_for_navigation_result(
page,
old_first_href,
expected_page_number,
old_fingerprint=old_fingerprint,
):
if self._wait_for_navigation_result(page, old_first_href, expected_page_number):
self.pacer.after_page_change()
return True
@@ -590,12 +480,7 @@ class ListingCollector:
"""
))
if clicked:
if self._wait_for_navigation_result(
page,
old_first_href,
expected_page_number,
old_fingerprint=old_fingerprint,
):
if self._wait_for_navigation_result(page, old_first_href, expected_page_number):
self.pacer.after_page_change()
return True
except Exception as exc:
@@ -626,12 +511,7 @@ class ListingCollector:
"""
))
if clicked:
if self._wait_for_navigation_result(
page,
old_first_href,
expected_page_number,
old_fingerprint=old_fingerprint,
):
if self._wait_for_navigation_result(page, old_first_href, expected_page_number):
self.pacer.after_page_change()
return True
except Exception as exc:
@@ -716,18 +596,10 @@ class ListingCollector:
len(all_links) >= self.settings.listing.max_vehicles_per_run
or self.settings.listing.collect_current_page_only
or not self.settings.listing.include_pagination
or not page_result.next_page_detected
):
break
blind_page_probe = not page_result.next_page_detected
if not self.go_to_next_page(page, expected_page_number=page_number + 1):
if blind_page_probe:
logger.info(
"Stopping pagination on page %d: direct page probe for %d failed and no next-page control was detected",
page_number,
page_number + 1,
)
break
if not self.go_to_next_page(page):
break
return {
@@ -775,8 +647,24 @@ class ListingCollector:
@staticmethod
def _has_next_page(page: Page) -> bool:
for selector in ListingCollector._NEXT_PAGE_SELECTORS:
if page.locator(selector).count() > 0:
locator = page.locator(selector)
count = locator.count()
if count == 0:
continue
if not hasattr(locator, "nth"):
return True
# Проверяем, что хотя бы один элемент не disabled.
# Disabled "Next" на последней странице не означает наличия следующей.
for i in range(min(count, 3)):
try:
el = locator.nth(i)
disabled_attr = el.get_attribute("disabled", timeout=300)
aria_disabled = el.get_attribute("aria-disabled", timeout=300)
cls = (el.get_attribute("class", timeout=300) or "").lower()
if disabled_attr is None and aria_disabled != "true" and "disabled" not in cls:
return True
except Exception:
continue
try:
return bool(page.evaluate(
"""

View File

@@ -38,17 +38,6 @@ def build_parser() -> argparse.ArgumentParser:
sync_listing_parser.add_argument("--only-new", choices=["true", "false"], default=None)
sync_listing_parser.add_argument("--output", default=str(default_output_dir / "iaai_sync_listing.json"))
# Новые команды для ingestion pipeline
subparsers.add_parser("discover-vehicles", help="Discover new vehicle URLs from sitemap")
fetch_parser = subparsers.add_parser("fetch-pending", help="Fetch raw data for pending candidates")
fetch_parser.add_argument("--limit", type=int, default=10, help="Max candidates to process")
enrich_parser = subparsers.add_parser("enrich-snapshots", help="Parse and enrich raw snapshots")
enrich_parser.add_argument("--limit", type=int, default=10, help="Max snapshots to process")
subparsers.add_parser("run-pipeline", help="Run full ingestion pipeline (discover -> fetch -> enrich)")
return parser
@@ -75,39 +64,6 @@ def main() -> None:
data = scraper.scrape_vehicle_detail(args.vehicle_url)
elif args.command == "sync-vehicle":
data = scraper.sync_vehicle(args.vehicle_url, lane=args.lane)
elif args.command == "sync-listing":
only_new = None if args.only_new is None else args.only_new == "true"
data = scraper.sync_listing(
make=args.make,
model=args.model,
lane=args.lane,
limit=args.limit,
only_new=only_new,
)
elif args.command == "discover-vehicles":
from .discovery_service import DiscoveryService
discovery = DiscoveryService()
count = discovery.discover_new_vehicles()
print(f"Discovered {count} new vehicle candidates")
return
elif args.command == "fetch-pending":
from .fetch_service import FetchService
fetch = FetchService()
count = fetch.process_pending_candidates(limit=args.limit)
print(f"Successfully fetched {count} candidates")
return
elif args.command == "enrich-snapshots":
from .enrichment_service import EnrichmentService
enrichment = EnrichmentService()
count = enrichment.process_unparsed_snapshots(limit=args.limit)
print(f"Successfully enriched {count} snapshots")
return
elif args.command == "run-pipeline":
from .scheduler_service import SchedulerService
scheduler = SchedulerService()
scheduler.run_full_pipeline()
print("Pipeline completed")
return
else:
only_new = None if args.only_new is None else args.only_new == "true"
data = scraper.sync_listing(

View File

@@ -2,6 +2,7 @@ import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import quote, urlsplit, urlunsplit
from dotenv import load_dotenv
@@ -104,9 +105,10 @@ class HumanPaceConfig:
@dataclass(slots=True)
class ListingConfig:
cars_url: str = _env_str("IAAI_CARS_LISTING_URL", "https://www.iaai.com/Vehiclelisting/Cars")
filtered_search_url: str | None = _env_optional_str("IAAI_FILTERED_SEARCH_URL")
filtered_search_urls_raw: str | None = _env_optional_str("IAAI_FILTERED_SEARCH_URLS")
max_pages_per_run: int = _env_int("IAAI_MAX_PAGES_PER_RUN", 9999)
max_vehicles_per_run: int = _env_int("IAAI_MAX_VEHICLES_PER_RUN", 50000)
resume_max_nav_pages: int = _env_int("IAAI_LISTING_RESUME_MAX_NAV_PAGES", 90)
page_link_limit: int = _env_int("IAAI_PAGE_LINK_LIMIT", 500)
include_pagination: bool = _env_bool("IAAI_INCLUDE_PAGINATION", True)
collect_current_page_only: bool = _env_bool("IAAI_COLLECT_CURRENT_PAGE_ONLY", False)
@@ -114,25 +116,31 @@ class ListingConfig:
# прекращаем листать — все новые машины уже найдены. 0 = отключено.
early_stop_threshold: float = _env_float("IAAI_EARLY_STOP_THRESHOLD", 0.8)
# Сегментация листинга по брендам для обхода лимита пагинации IAAI (~22 600 машин).
# JSON-массив объектов: [{"make":"TOYOTA"},{"make":"FORD"},...] или "auto" для авто-списка.
# JSON-массив объектов: [{"make":"TOYOTA"},{"make":"FORD"},...], "auto" для авто-списка
# или "runtime" для построения по runtime_config.filters.brands.
# Пустая строка = без сегментации (backward compatible).
listing_segments_json: str = _env_str("IAAI_LISTING_SEGMENTS", "")
fast_segment_year_splits: bool = _env_bool("IAAI_FAST_SEGMENT_YEAR_SPLITS", True)
@dataclass(slots=True)
class DiscoveryConfig:
mode: str = _env_str("IAAI_DISCOVERY_MODE", "sitemap")
hourly_mode: str = _env_str("IAAI_HOURLY_MODE", "rolling_refresh")
hourly_refresh_batch_size: int = _env_int("IAAI_HOURLY_REFRESH_BATCH_SIZE", 3000)
sitemap_timeout_seconds: int = _env_int("IAAI_SITEMAP_TIMEOUT_SECONDS", 30)
sitemap_retry_attempts: int = _env_int("IAAI_SITEMAP_RETRY_ATTEMPTS", 4)
sitemap_retry_backoff_seconds: float = _env_float("IAAI_SITEMAP_RETRY_BACKOFF_SECONDS", 1.25)
sitemap_direct_probe_limit: int = _env_int("IAAI_SITEMAP_DIRECT_PROBE_LIMIT", 12)
sitemap_direct_probe_stop_after_misses: int = _env_int(
"IAAI_SITEMAP_DIRECT_PROBE_STOP_AFTER_MISSES",
3,
)
sitemap_use_curl_cffi: bool = _env_bool("IAAI_SITEMAP_USE_CURL_CFFI", True)
@property
def filtered_search_urls(self) -> list[str]:
urls: list[str] = []
if self.filtered_search_url and self.filtered_search_url.strip():
urls.append(self.filtered_search_url.strip())
if self.filtered_search_urls_raw and self.filtered_search_urls_raw.strip():
raw = self.filtered_search_urls_raw.strip()
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, ValueError):
parsed = None
if isinstance(parsed, list):
candidates = [str(item or "").strip() for item in parsed]
else:
candidates = [part.strip() for part in raw.replace("\n", ",").split(",")]
for candidate in candidates:
if candidate and candidate not in urls:
urls.append(candidate)
return urls
# Список брендов IAAI для автоматической сегментации.
@@ -166,6 +174,43 @@ _YEAR_SPLITS: tuple[tuple[int, int], ...] = (
)
def build_listing_segments_for_makes(makes: tuple[str, ...] | list[str]) -> list[dict[str, str | int | None]]:
"""Строит сегменты по переданному списку брендов.
Крупные бренды режутся по годовым диапазонам так же, как в ``auto``.
"""
segments: list[dict[str, str | int | None]] = []
seen: set[str] = set()
for raw_make in makes:
make = str(raw_make or "").strip().upper()
if not make or make in seen:
continue
seen.add(make)
if make in _LARGE_MAKES:
for yr_min, yr_max in _YEAR_SPLITS:
segments.append({"make": make, "year_min": yr_min, "year_max": yr_max})
else:
segments.append({"make": make, "year_min": None, "year_max": None})
return segments
def build_fast_listing_segments_for_makes(makes: tuple[str, ...] | list[str]) -> list[dict[str, str | int | None]]:
"""Строит HTTP-first сегменты без UI-фильтров годов.
Это ближе к iaai-fast: один бренд = один hidden-payload HTTP обход.
Годовые split-сегменты требуют браузерный UI-фильтр и ломают стабильность fast-профиля.
"""
segments: list[dict[str, str | int | None]] = []
seen: set[str] = set()
for raw_make in makes:
make = str(raw_make or "").strip().upper()
if not make or make in seen:
continue
seen.add(make)
segments.append({"make": make, "year_min": None, "year_max": None})
return segments
def parse_listing_segments(raw: str) -> list[dict[str, str | int | None]]:
"""Парсит IAAI_LISTING_SEGMENTS в список сегментов.
@@ -177,14 +222,7 @@ def parse_listing_segments(raw: str) -> list[dict[str, str | int | None]]:
if not raw:
return []
if raw.lower() == "auto":
segments: list[dict[str, str | int | None]] = []
for m in IAAI_DEFAULT_MAKES:
if m in _LARGE_MAKES:
for yr_min, yr_max in _YEAR_SPLITS:
segments.append({"make": m, "year_min": yr_min, "year_max": yr_max})
else:
segments.append({"make": m, "year_min": None, "year_max": None})
return segments
return build_listing_segments_for_makes(list(IAAI_DEFAULT_MAKES))
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError):
@@ -226,6 +264,16 @@ class RedisConfig:
health_check_interval_seconds: int = _env_int("IAAI_REDIS_HEALTH_CHECK_INTERVAL_SECONDS", 30)
# --- Конфиг Discovery (режим обнаружения, hourly batch) ---
@dataclass(slots=True)
class DiscoveryConfig:
mode: str = _env_str("IAAI_DISCOVERY_MODE", "sitemap")
hourly_mode: str = _env_str("IAAI_HOURLY_MODE", "rolling_refresh")
hourly_refresh_batch_size: int = _env_int("IAAI_HOURLY_REFRESH_BATCH_SIZE", 500)
always_full_scan: bool = _env_bool("IAAI_ALWAYS_FULL_SCAN", False)
# --- Конфиг Celery (лимиты задач, concurrency, beat-расписание) ---
@dataclass(slots=True)
@@ -236,16 +284,56 @@ class CeleryConfig:
task_time_limit: int = _env_int("CELERY_TASK_TIME_LIMIT", 3600)
task_stall_timeout_seconds: int = _env_int("CELERY_TASK_STALL_TIMEOUT_SECONDS", 600)
worker_concurrency: int = _env_int("CELERY_WORKER_CONCURRENCY", 4)
worker_pool: str = _env_str("CELERY_WORKER_POOL", "prefork")
worker_max_tasks_per_child: int = _env_int("CELERY_WORKER_MAX_TASKS_PER_CHILD", 5)
broker_visibility_timeout: int = _env_int("CELERY_BROKER_VISIBILITY_TIMEOUT", 7200)
beat_sync_interval_minutes: int = _env_int("CELERY_BEAT_SYNC_INTERVAL_MINUTES", 60)
beat_sync_limit: int | None = _env_int("CELERY_BEAT_SYNC_LIMIT", 0) or None
batch_size: int = _env_int("CELERY_BATCH_SIZE", 50)
parallel_tabs: int = _env_int("IAAI_PARALLEL_TABS", 8)
parallel_segments: bool = _env_bool("IAAI_PARALLEL_SEGMENTS", False)
fetch_concurrency: int = _env_int("IAAI_FETCH_CONCURRENCY", _env_int("IAAI_PARALLEL_TABS", 8))
parallel_segments: bool = _env_bool("CELERY_PARALLEL_SEGMENTS", False)
block_resources: bool = _env_bool("IAAI_BLOCK_RESOURCES", True)
@dataclass(slots=True)
class ScrapingProfileConfig:
name: str = _env_str("IAAI_SCRAPING_PROFILE", _env_str("IAAI_PROFILE", "stable")).strip().lower()
http_first: bool = _env_bool("IAAI_HTTP_FIRST", True)
browser_fallback_enabled: bool = _env_bool("IAAI_BROWSER_FALLBACK_ENABLED", True)
anonymous_bootstrap_enabled: bool = _env_bool("IAAI_ANONYMOUS_BOOTSTRAP_ENABLED", True)
verbose_http_logs: bool = _env_bool("IAAI_VERBOSE_HTTP_LOGS", False)
verbose_progress_logs: bool = _env_bool("IAAI_VERBOSE_PROGRESS_LOGS", False)
challenge_refresh_enabled: bool = _env_bool("IAAI_CHALLENGE_REFRESH_ENABLED", True)
challenge_refresh_attempts: int = _env_int("IAAI_CHALLENGE_REFRESH_ATTEMPTS", 3)
listing_post_attempts: int = _env_int("IAAI_LISTING_POST_ATTEMPTS", 4)
request_jitter_max_s: float = _env_float("IAAI_REQUEST_JITTER_MAX_S", 0.15)
detail_retries: int | None = _env_int("IAAI_DETAIL_RETRIES", -1)
listing_retries: int | None = _env_int("IAAI_LISTING_RETRIES", -1)
def __post_init__(self) -> None:
if self.name == "fast":
if "IAAI_BROWSER_FALLBACK_ENABLED" not in os.environ:
self.browser_fallback_enabled = False
if "IAAI_ANONYMOUS_BOOTSTRAP_ENABLED" not in os.environ:
self.anonymous_bootstrap_enabled = False
if "IAAI_CHALLENGE_REFRESH_ATTEMPTS" not in os.environ:
self.challenge_refresh_attempts = 1
if "IAAI_LISTING_POST_ATTEMPTS" not in os.environ:
self.listing_post_attempts = 2
if "IAAI_REQUEST_JITTER_MAX_S" not in os.environ:
self.request_jitter_max_s = 0.0
if "IAAI_DETAIL_RETRIES" not in os.environ:
self.detail_retries = 1
if "IAAI_LISTING_RETRIES" not in os.environ:
self.listing_retries = 1
else:
if self.detail_retries is not None and self.detail_retries < 0:
self.detail_retries = None
if self.listing_retries is not None and self.listing_retries < 0:
self.listing_retries = None
# --- Конфиг прокси (server, username, password) ---
@dataclass(slots=True)
@@ -268,6 +356,32 @@ class ProxyConfig:
result["password"] = self.password
return result
def to_requests_proxy_url(self) -> str | None:
if not self.server:
return None
if not self.username:
return self.server
parts = urlsplit(self.server)
if not parts.scheme or not parts.hostname:
return self.server
username = quote(self.username, safe="")
password = quote(self.password or "", safe="")
host = parts.hostname
if ":" in host and not host.startswith("["):
host = f"[{host}]"
if parts.port is not None:
host = f"{host}:{parts.port}"
netloc = f"{username}:{password}@{host}"
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
def to_requests_proxies(self) -> dict[str, str] | None:
proxy_url = self.to_requests_proxy_url()
if not proxy_url:
return None
return {"http": proxy_url, "https": proxy_url}
# Главный объект настроек: собирает все блоки конфигурации
@@ -276,7 +390,7 @@ class Settings:
home_url: str = "https://www.iaai.com/"
default_timeout_ms: int = _env_int("IAAI_TIMEOUT_MS", 45000)
network_settle_ms: int = _env_int("IAAI_NETWORK_SETTLE_MS", 400)
fast_path_timeout_ms: int = _env_int("IAAI_FAST_PATH_TIMEOUT_MS", 5000)
fast_path_timeout_ms: int = _env_int("IAAI_FAST_PATH_TIMEOUT_MS", 15000)
fast_path_max_attempts: int = _env_int("IAAI_FAST_PATH_MAX_ATTEMPTS", 1)
fallback_navigation_timeout_ms: int = _env_int("IAAI_FALLBACK_NAV_TIMEOUT_MS", 15000)
max_retries: int = _env_int("IAAI_MAX_RETRIES", 3)
@@ -297,16 +411,21 @@ class Settings:
capture: CaptureConfig = field(default_factory=CaptureConfig)
pace: HumanPaceConfig = field(default_factory=HumanPaceConfig)
listing: ListingConfig = field(default_factory=ListingConfig)
discovery: DiscoveryConfig = field(default_factory=DiscoveryConfig)
database: DatabaseConfig = field(default_factory=DatabaseConfig)
redis: RedisConfig = field(default_factory=RedisConfig)
celery: CeleryConfig = field(default_factory=CeleryConfig)
proxy: ProxyConfig = field(default_factory=ProxyConfig)
discovery: DiscoveryConfig = field(default_factory=DiscoveryConfig)
scraping_profile: ScrapingProfileConfig = field(default_factory=ScrapingProfileConfig)
@property
def parallel_tabs(self) -> int:
return self.celery.parallel_tabs
@property
def fetch_concurrency(self) -> int:
return self.celery.fetch_concurrency
@property
def block_resources(self) -> bool:
return self.celery.block_resources

View File

@@ -18,8 +18,9 @@ def set_trace_id(trace_id: str) -> None:
def setup_logging(level: str = "INFO", log_file: str | None = None) -> None:
# stderr — Docker и Celery prefork корректно его подхватывают.
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
# stdout — основной поток для `docker logs`, Docker Desktop и compose logs.
# stderr в PowerShell часто выглядит как NativeCommandError, хотя это обычные логи.
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
if log_file:
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
trace_filter = TraceIdFilter()
@@ -29,9 +30,15 @@ def setup_logging(level: str = "INFO", log_file: str | None = None) -> None:
root = logging.getLogger()
root.setLevel(getattr(logging, level.upper(), logging.INFO))
# Убираем старые хендлеры, чтобы не дублировать после fork.
for old_handler in list(root.handlers):
try:
old_handler.close()
except Exception:
pass
root.handlers.clear()
for handler in handlers:
root.addHandler(handler)
root.propagate = False
fmt = logging.Formatter(
"%(asctime)s | %(levelname)s | %(name)s | trace=%(trace_id)s | %(message)s"
)

View File

@@ -1,17 +1,15 @@
from .sitemap import (
DEFAULT_SITEMAP_INDEX_URL,
SitemapDiscoveryError,
SitemapBlockedError,
SitemapDiscoveryResult,
SitemapDiscoveryStats,
discover_vehicle_urls_from_sitemap,
discover_vehicle_urls_from_sitemap_with_stats,
)
__all__ = [
"DEFAULT_SITEMAP_INDEX_URL",
"SitemapBlockedError",
"SitemapDiscoveryError",
"SitemapDiscoveryResult",
"SitemapDiscoveryStats",
"discover_vehicle_urls_from_sitemap",
"discover_vehicle_urls_from_sitemap_with_stats",
]

View File

@@ -3,222 +3,27 @@ from __future__ import annotations
import gzip
import io
import logging
import random
import re
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Iterable
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin, urlsplit, urlunsplit
from urllib.request import Request, urlopen
from ..core.config import Settings
try:
from curl_cffi import requests as curl_requests
except ImportError: # pragma: no cover - optional runtime dependency guard
curl_requests = None
from urllib.parse import urlsplit, urlunsplit
from urllib.request import Request, urlopen, ProxyHandler, build_opener
import xml.etree.ElementTree as ET
logger = logging.getLogger("iaai_scraper.discovery.sitemap")
DEFAULT_SITEMAP_INDEX_URL = "https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemap_index.xml"
_SITEMAP_TIMEOUT_SECONDS = 30
_LOC_TAG_RE = re.compile(rb"<loc>\s*(.*?)\s*</loc>", re.IGNORECASE | re.DOTALL)
_XML_PREFIX_RE = re.compile(rb"^\s*(<\?xml\b.*?\?>)?\s*<", re.IGNORECASE | re.DOTALL)
_BLOCK_MARKERS = (
b"pardon our interruption",
b"incapsula",
b"please stand by",
b"_incapsula_resource",
b"as you were browsing something about your browser",
)
_HTML_MARKERS = (b"<html", b"<!doctype html", b"<script", b"document.getelementsbyclassname")
_CURL_IMPERSONATE_CHOICES = ("chrome136", "chrome133", "chrome131", "safari184")
class SitemapDiscoveryError(RuntimeError):
pass
class SitemapBlockedError(SitemapDiscoveryError):
pass
@dataclass(slots=True)
class SitemapFetchResult:
url: str
payload: bytes
source: str
blocked: bool = False
status_code: int | None = None
content_type: str | None = None
@dataclass(slots=True)
class SitemapDiscoveryStats:
transport: str
fetched_sitemaps: int = 0
blocked_sitemaps: int = 0
malformed_sitemaps: int = 0
direct_probe_hits: int = 0
direct_probe_misses: int = 0
@dataclass(slots=True)
class SitemapDiscoveryResult:
vehicle_urls: list[str]
stats: SitemapDiscoveryStats
_DEFAULT_HEADERS = {
"Accept": "application/xml,text/xml,application/xhtml+xml,text/html;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Upgrade-Insecure-Requests": "1",
}
class _SitemapDownloader:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.discovery = settings.discovery
self.proxy_url = settings.proxy.server
self.proxy_auth = None
if settings.proxy.username:
password = settings.proxy.password or ""
self.proxy_auth = (settings.proxy.username, password)
self._transport_name = self._resolve_transport_name()
@property
def transport_name(self) -> str:
return self._transport_name
def _resolve_transport_name(self) -> str:
if self.discovery.sitemap_use_curl_cffi and curl_requests is not None:
return "curl_cffi"
return "urllib"
def fetch(self, url: str) -> SitemapFetchResult:
attempts = max(1, int(self.discovery.sitemap_retry_attempts))
last_exc: Exception | None = None
for attempt in range(1, attempts + 1):
try:
result = self._fetch_once(url)
if result.blocked:
raise SitemapBlockedError(f"Anti-bot page returned for {url}")
return result
except SitemapBlockedError as exc:
last_exc = exc
logger.warning(
"Sitemap fetch blocked (attempt %d/%d, transport=%s): %s",
attempt,
attempts,
self.transport_name,
url,
)
except Exception as exc:
last_exc = exc
logger.warning(
"Sitemap fetch failed (attempt %d/%d, transport=%s): %s -> %s",
attempt,
attempts,
self.transport_name,
url,
exc,
)
if attempt >= attempts:
break
time.sleep(self._backoff_delay(attempt))
if last_exc is None:
raise SitemapDiscoveryError(f"Failed to fetch sitemap: {url}")
if isinstance(last_exc, SitemapDiscoveryError):
raise last_exc
raise SitemapDiscoveryError(f"Failed to fetch sitemap {url}: {last_exc}") from last_exc
def _fetch_once(self, url: str) -> SitemapFetchResult:
if self.transport_name == "curl_cffi":
return self._fetch_with_curl_cffi(url)
return self._fetch_with_urllib(url)
def _build_headers(self) -> dict[str, str]:
headers = dict(_DEFAULT_HEADERS)
headers["User-Agent"] = self.settings.fingerprint.user_agent
return headers
def _fetch_with_curl_cffi(self, url: str) -> SitemapFetchResult:
assert curl_requests is not None
response = curl_requests.get(
url,
headers=self._build_headers(),
timeout=self.discovery.sitemap_timeout_seconds,
impersonate=random.choice(_CURL_IMPERSONATE_CHOICES),
proxies={"http": self.proxy_url, "https": self.proxy_url} if self.proxy_url else None,
proxy_auth=self.proxy_auth,
allow_redirects=True,
)
payload = self._decode_payload(
payload=response.content,
encoding=(response.headers.get("Content-Encoding") or ""),
url=url,
)
blocked = _looks_like_block_page(payload)
return SitemapFetchResult(
url=url,
payload=payload,
source="curl_cffi",
blocked=blocked,
status_code=int(response.status_code),
content_type=response.headers.get("Content-Type"),
)
def _fetch_with_urllib(self, url: str) -> SitemapFetchResult:
request = Request(url, headers=self._build_headers())
try:
with urlopen(request, timeout=self.discovery.sitemap_timeout_seconds) as response:
payload = response.read()
decoded = self._decode_payload(
payload=payload,
encoding=(response.headers.get("Content-Encoding") or ""),
url=url,
)
return SitemapFetchResult(
url=url,
payload=decoded,
source="urllib",
blocked=_looks_like_block_page(decoded),
status_code=getattr(response, "status", None),
content_type=response.headers.get("Content-Type"),
)
except HTTPError as exc:
payload = exc.read() if hasattr(exc, "read") else b""
decoded = self._decode_payload(payload=payload, encoding=exc.headers.get("Content-Encoding", ""), url=url)
blocked = exc.code in {403, 429} or _looks_like_block_page(decoded)
if blocked:
raise SitemapBlockedError(f"HTTP {exc.code} for {url}") from exc
raise SitemapDiscoveryError(f"HTTP {exc.code} for {url}") from exc
except URLError as exc:
raise SitemapDiscoveryError(f"Network error for {url}: {exc}") from exc
@staticmethod
def _decode_payload(*, payload: bytes, encoding: str, url: str) -> bytes:
normalized = (encoding or "").lower().strip()
if normalized == "gzip" or url.lower().endswith(".gz"):
try:
return gzip.GzipFile(fileobj=io.BytesIO(payload)).read()
except OSError:
return payload
return payload
def _backoff_delay(self, attempt: int) -> float:
base = max(0.1, float(self.discovery.sitemap_retry_backoff_seconds))
jitter = random.uniform(0.05, 0.35)
return base * (2 ** (attempt - 1)) + jitter
def _is_vehicle_sitemap_url(url: str) -> bool:
lowered = url.strip().lower()
# Берём только sitemap с авто.
if "sitemapbranches" in lowered or "sitemapauctions" in lowered:
return False
return lowered.endswith(".xml") or lowered.endswith(".xml.gz")
@@ -229,21 +34,38 @@ def _normalize_vehicle_url(url: str) -> str:
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
def _download_bytes(url: str, proxy_url: str | None = None) -> bytes:
request = Request(
url,
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/xml,text/xml,application/xhtml+xml,text/html;q=0.9,*/*;q=0.8",
"Accept-Encoding": "gzip",
},
)
if proxy_url:
handler = ProxyHandler({"http": proxy_url, "https": proxy_url})
opener = build_opener(handler)
response = opener.open(request, timeout=_SITEMAP_TIMEOUT_SECONDS)
else:
response = urlopen(request, timeout=_SITEMAP_TIMEOUT_SECONDS)
with response:
payload = response.read()
encoding = str(response.headers.get("Content-Encoding") or "").lower()
if encoding == "gzip" or url.lower().endswith(".gz"):
return gzip.GzipFile(fileobj=io.BytesIO(payload)).read()
return payload
def _local_name(tag: str) -> str:
if "}" in tag:
return tag.rsplit("}", 1)[1]
return tag
def _looks_like_block_page(payload: bytes) -> bool:
sample = payload[:8192].lower()
if any(marker in sample for marker in _BLOCK_MARKERS):
return True
if any(marker in sample for marker in _HTML_MARKERS) and b"<loc>" not in sample:
return True
return False
def _iter_loc_values_fallback(xml_bytes: bytes) -> Iterable[str]:
for match in _LOC_TAG_RE.finditer(xml_bytes):
try:
@@ -255,13 +77,6 @@ def _iter_loc_values_fallback(xml_bytes: bytes) -> Iterable[str]:
def _iter_loc_values(xml_bytes: bytes) -> Iterable[str]:
if _looks_like_block_page(xml_bytes):
raise SitemapBlockedError("Anti-bot content returned instead of sitemap XML")
if not _XML_PREFIX_RE.search(xml_bytes[:256]):
fallback_values = list(_iter_loc_values_fallback(xml_bytes))
if fallback_values:
yield from fallback_values
return
try:
root = ET.fromstring(xml_bytes)
except ET.ParseError as exc:
@@ -303,139 +118,93 @@ def _looks_like_vehicle_detail_sitemap(urls: list[str]) -> bool:
return any("/vehicledetail/" in url.lower() for url in urls)
def _derive_direct_probe_urls(index_url: str, limit: int) -> list[str]:
base_dir = index_url.rsplit("/", 1)[0] + "/"
return [urljoin(base_dir, f"sitemap{idx}.xml") for idx in range(1, max(1, limit) + 1)]
def _discover_sitemap_urls(index_url: str, downloader: _SitemapDownloader, stats: SitemapDiscoveryStats) -> list[str]:
def discover_vehicle_urls_from_sitemap(index_url: str = DEFAULT_SITEMAP_INDEX_URL, proxy_url: str | None = None) -> list[str]:
logger.info("Downloading sitemap index: %s", index_url)
try:
index_result = downloader.fetch(index_url)
sitemap_urls = [url for url in _iter_loc_values(index_result.payload) if _is_vehicle_sitemap_url(url)]
if sitemap_urls:
return sitemap_urls
except SitemapBlockedError as exc:
stats.blocked_sitemaps += 1
logger.warning("Sitemap index blocked, switching to direct probe: %s", exc)
except SitemapDiscoveryError as exc:
stats.malformed_sitemaps += 1
logger.warning("Sitemap index unusable, switching to direct probe: %s", exc)
logger.warning("Sitemap index returned no usable sitemap URLs; switching to direct probe")
return _probe_direct_sitemap_urls(index_url, downloader, stats)
def _probe_direct_sitemap_urls(
index_url: str,
downloader: _SitemapDownloader,
stats: SitemapDiscoveryStats,
) -> list[str]:
discovered: list[str] = []
consecutive_misses = 0
probe_urls = _derive_direct_probe_urls(index_url, downloader.discovery.sitemap_direct_probe_limit)
for sitemap_url in probe_urls:
try:
result = downloader.fetch(sitemap_url)
raw_urls = list(_iter_loc_values(result.payload))
except SitemapBlockedError:
stats.blocked_sitemaps += 1
consecutive_misses += 1
stats.direct_probe_misses += 1
if consecutive_misses >= downloader.discovery.sitemap_direct_probe_stop_after_misses:
break
continue
except SitemapDiscoveryError:
consecutive_misses += 1
stats.direct_probe_misses += 1
if consecutive_misses >= downloader.discovery.sitemap_direct_probe_stop_after_misses:
break
continue
if not raw_urls:
consecutive_misses += 1
stats.direct_probe_misses += 1
if consecutive_misses >= downloader.discovery.sitemap_direct_probe_stop_after_misses:
break
continue
consecutive_misses = 0
stats.direct_probe_hits += 1
discovered.append(sitemap_url)
return discovered
def _collect_vehicle_urls_from_sitemap(
sitemap_url: str,
downloader: _SitemapDownloader,
stats: SitemapDiscoveryStats,
) -> list[str]:
logger.info("Downloading sitemap: %s", sitemap_url)
try:
result = downloader.fetch(sitemap_url)
raw_urls = list(_iter_loc_values(result.payload))
except SitemapBlockedError as exc:
stats.blocked_sitemaps += 1
logger.warning("Skipping blocked sitemap %s: %s", sitemap_url, exc)
return []
except SitemapDiscoveryError as exc:
stats.malformed_sitemaps += 1
logger.warning("Skipping malformed sitemap %s: %s", sitemap_url, exc)
return []
stats.fetched_sitemaps += 1
vehicle_urls = _filter_vehicle_urls(raw_urls)
if raw_urls and not vehicle_urls and not _looks_like_vehicle_detail_sitemap(raw_urls):
logger.info("Skipping non-vehicle sitemap %s", sitemap_url)
return []
logger.info("Sitemap %s yielded %d vehicle URLs", sitemap_url, len(vehicle_urls))
return vehicle_urls
def discover_vehicle_urls_from_sitemap(
index_url: str = DEFAULT_SITEMAP_INDEX_URL,
*,
settings: Settings | None = None,
) -> list[str]:
result = discover_vehicle_urls_from_sitemap_with_stats(index_url=index_url, settings=settings)
return result.vehicle_urls
def discover_vehicle_urls_from_sitemap_with_stats(
index_url: str = DEFAULT_SITEMAP_INDEX_URL,
*,
settings: Settings | None = None,
) -> SitemapDiscoveryResult:
runtime_settings = settings or Settings()
downloader = _SitemapDownloader(runtime_settings)
stats = SitemapDiscoveryStats(transport=downloader.transport_name)
sitemap_urls = _discover_sitemap_urls(index_url, downloader, stats)
index_xml = _download_bytes(index_url, proxy_url=proxy_url)
sitemap_urls = [url for url in _iter_loc_values(index_xml) if _is_vehicle_sitemap_url(url)]
if not sitemap_urls:
raise SitemapDiscoveryError("Sitemap discovery found no sitemap URLs")
raise SitemapDiscoveryError("Sitemap index returned no sitemap URLs")
all_vehicle_urls: list[str] = []
for sitemap_url in sitemap_urls:
all_vehicle_urls.extend(_collect_vehicle_urls_from_sitemap(sitemap_url, downloader, stats))
logger.info("Downloading sitemap: %s", sitemap_url)
try:
sitemap_xml = _download_bytes(sitemap_url, proxy_url=proxy_url)
raw_urls = list(_iter_loc_values(sitemap_xml))
except SitemapDiscoveryError as exc:
logger.warning("Skipping malformed sitemap %s: %s", sitemap_url, exc)
continue
vehicle_urls = _filter_vehicle_urls(raw_urls)
if raw_urls and not vehicle_urls and not _looks_like_vehicle_detail_sitemap(raw_urls):
logger.info("Skipping non-vehicle sitemap %s", sitemap_url)
continue
logger.info("Sitemap %s yielded %d vehicle URLs", sitemap_url, len(vehicle_urls))
all_vehicle_urls.extend(vehicle_urls)
deduped = _filter_vehicle_urls(all_vehicle_urls)
if not deduped:
raise SitemapDiscoveryError(
"No vehicle detail URLs discovered from vehicle sitemaps; likely anti-bot or upstream sitemap issue"
)
raise SitemapDiscoveryError("No vehicle detail URLs discovered from vehicle sitemaps")
logger.info("Sitemap discovery done: %d vehicle URLs", len(deduped))
return deduped
logger.info(
"Sitemap discovery done: %d vehicle URLs (transport=%s fetched=%d blocked=%d malformed=%d direct_hits=%d direct_misses=%d)",
len(deduped),
stats.transport,
stats.fetched_sitemaps,
stats.blocked_sitemaps,
stats.malformed_sitemaps,
stats.direct_probe_hits,
stats.direct_probe_misses,
)
@dataclass
class SitemapDiscoveryStats:
transport: str = "http"
fetched_sitemaps: int = 0
blocked_sitemaps: int = 0
malformed_sitemaps: int = 0
direct_probe_hits: int = 0
direct_probe_misses: int = 0
@dataclass
class SitemapDiscoveryResult:
vehicle_urls: list[str] = field(default_factory=list)
stats: SitemapDiscoveryStats = field(default_factory=SitemapDiscoveryStats)
def discover_vehicle_urls_from_sitemap_with_stats(
settings=None,
index_url: str = DEFAULT_SITEMAP_INDEX_URL,
) -> SitemapDiscoveryResult:
"""Возвращает URL и статистику."""
proxy_url = None
if settings is not None and hasattr(settings, 'proxy') and settings.proxy.server:
proxy_url = settings.proxy.server
stats = SitemapDiscoveryStats(transport="http")
logger.info("Downloading sitemap index: %s", index_url)
index_xml = _download_bytes(index_url, proxy_url=proxy_url)
sitemap_urls = [url for url in _iter_loc_values(index_xml) if _is_vehicle_sitemap_url(url)]
if not sitemap_urls:
raise SitemapDiscoveryError("Sitemap index returned no sitemap URLs")
all_vehicle_urls: list[str] = []
for sitemap_url in sitemap_urls:
logger.info("Downloading sitemap: %s", sitemap_url)
try:
sitemap_xml = _download_bytes(sitemap_url, proxy_url=proxy_url)
raw_urls = list(_iter_loc_values(sitemap_xml))
stats.fetched_sitemaps += 1
except SitemapDiscoveryError as exc:
logger.warning("Skipping malformed sitemap %s: %s", sitemap_url, exc)
stats.malformed_sitemaps += 1
continue
except Exception as exc:
logger.warning("Blocked/failed sitemap %s: %s", sitemap_url, exc)
stats.blocked_sitemaps += 1
continue
vehicle_urls = _filter_vehicle_urls(raw_urls)
if raw_urls and not vehicle_urls and not _looks_like_vehicle_detail_sitemap(raw_urls):
logger.info("Skipping non-vehicle sitemap %s", sitemap_url)
continue
logger.info("Sitemap %s yielded %d vehicle URLs", sitemap_url, len(vehicle_urls))
all_vehicle_urls.extend(vehicle_urls)
deduped = _filter_vehicle_urls(all_vehicle_urls)
if not deduped:
raise SitemapDiscoveryError("No vehicle detail URLs discovered from vehicle sitemaps")
logger.info("Sitemap discovery done: %d vehicle URLs", len(deduped))
return SitemapDiscoveryResult(vehicle_urls=deduped, stats=stats)

View File

@@ -1,129 +0,0 @@
import logging
from datetime import datetime, timezone
from typing import List
from sqlalchemy.orm import Session
from .core.config import settings
from .discovery import discover_vehicle_urls_from_sitemap_with_stats, SitemapDiscoveryError
from .storage.db import get_db_session
from .storage.models import VehicleCandidate
logger = logging.getLogger("iaai_scraper.discovery_service")
class DiscoveryService:
"""Сервис для обнаружения новых автомобилей через sitemap IAAI."""
def __init__(self):
self.db: Session = get_db_session()
def discover_new_vehicles(self, max_urls: int = None) -> int:
"""
Обнаруживает новые URL автомобилей и добавляет их в vehicle_candidates.
Args:
max_urls: Максимальное количество URL для обработки (None = все)
Returns:
Количество добавленных кандидатов
"""
try:
logger.info("Starting vehicle discovery from sitemap")
result = discover_vehicle_urls_from_sitemap_with_stats()
if not result.urls:
logger.info("No new URLs discovered")
return 0
# Ограничиваем количество URL
urls_to_process = result.urls[:max_urls] if max_urls else result.urls
logger.info(f"Discovered {len(urls_to_process)} potential vehicle URLs (from {len(result.urls)} total)")
# Фильтруем уже существующие кандидаты
existing_urls = self._get_existing_candidate_urls(urls_to_process)
new_urls = [url for url in urls_to_process if url not in existing_urls]
if not new_urls:
logger.info("All discovered URLs already exist as candidates")
return 0
# Добавляем новых кандидатов
added_count = self._add_candidates(new_urls)
logger.info(f"Added {added_count} new vehicle candidates")
return added_count
except SitemapDiscoveryError as e:
logger.error(f"Sitemap discovery failed: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error during discovery: {e}")
raise
def _get_existing_candidate_urls(self, urls: List[str]) -> set:
"""Получает множество уже существующих URL кандидатов."""
if not urls:
return set()
# Разбиваем на батчи для эффективности
batch_size = 1000
existing = set()
for i in range(0, len(urls), batch_size):
batch = urls[i:i + batch_size]
result = self.db.query(VehicleCandidate.url).filter(
VehicleCandidate.url.in_(batch)
).all()
existing.update(row[0] for row in result)
return existing
def _add_candidates(self, urls: List[str]) -> int:
"""Добавляет новые кандидаты в базу данных."""
now = datetime.now(timezone.utc)
candidates = [
VehicleCandidate(
url=url,
discovered_at=now,
status="pending",
priority=0
)
for url in urls
]
self.db.add_all(candidates)
self.db.commit()
return len(candidates)
def get_pending_candidates(self, limit: int = 100) -> List[VehicleCandidate]:
"""Получает кандидатов в статусе 'pending' для обработки."""
return self.db.query(VehicleCandidate).filter(
VehicleCandidate.status == "pending"
).order_by(VehicleCandidate.priority.desc(), VehicleCandidate.discovered_at).limit(limit).all()
def mark_candidate_processing(self, candidate_id: int):
"""Помечает кандидата как обрабатываемого."""
self.db.query(VehicleCandidate).filter(
VehicleCandidate.id == candidate_id
).update({
"status": "processing",
"last_attempt_at": datetime.now(timezone.utc),
"attempts": VehicleCandidate.attempts + 1
})
self.db.commit()
def mark_candidate_processed(self, candidate_id: int):
"""Помечает кандидата как обработанного."""
self.db.query(VehicleCandidate).filter(
VehicleCandidate.id == candidate_id
).update({"status": "processed"})
self.db.commit()
def mark_candidate_failed(self, candidate_id: int):
"""Помечает кандидата как неудачного."""
self.db.query(VehicleCandidate).filter(
VehicleCandidate.id == candidate_id
).update({"status": "failed"})
self.db.commit()

View File

@@ -1,140 +0,0 @@
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from .core.config import settings
from .parsing.mapper import CarMapper
from .parsing.parser import VehicleParser
from .storage.db import get_db_session
from .storage.models import VehicleRawSnapshot, VehicleParseResult, Car
from .storage.schemas import CarRecord
logger = logging.getLogger("iaai_scraper.enrichment_service")
class EnrichmentService:
"""Сервис для парсинга сырых данных и обогащения автомобилей."""
def __init__(self):
self.db: Session = get_db_session()
self.parser = VehicleParser()
self.mapper = CarMapper()
def enrich_vehicle(self, snapshot: VehicleRawSnapshot) -> Optional[VehicleParseResult]:
"""
Парсит snapshot и сохраняет результат.
Args:
snapshot: Raw snapshot для парсинга
Returns:
VehicleParseResult если парсинг успешен
"""
logger.info(f"Enriching snapshot {snapshot.id} for candidate {snapshot.candidate_id}")
if not snapshot.success or not snapshot.raw_data:
logger.warning(f"Snapshot {snapshot.id} has no data to parse")
return self._save_parse_result(snapshot.id, False, None, "No raw data available")
try:
# Парсим данные
parsed_data = self._parse_raw_data(snapshot.raw_data)
if not parsed_data:
return self._save_parse_result(snapshot.id, False, None, "Parsing failed")
# Маппим в CarRecord
car_record = self._map_to_car_record(parsed_data)
if not car_record:
return self._save_parse_result(snapshot.id, False, None, "Mapping failed")
# Сохраняем в cars таблицу
self._save_car(car_record)
# Сохраняем успешный результат парсинга
return self._save_parse_result(snapshot.id, True, json.dumps(parsed_data))
except Exception as e:
error_msg = f"Unexpected error during enrichment: {str(e)}"
logger.error(f"Error enriching snapshot {snapshot.id}: {error_msg}")
return self._save_parse_result(snapshot.id, False, None, error_msg)
def _parse_raw_data(self, raw_data: str) -> Optional[dict]:
"""Парсит сырые данные в словарь."""
try:
# Если это JSON от browser capture
if raw_data.strip().startswith('{'):
data = json.loads(raw_data)
# Извлекаем vehicle_summary из scrape result
return data.get("vehicle_summary", {})
# Если HTML, используем VehicleParser
parsed = self.parser.parse_vehicle_page(raw_data, "dummy_url")
return parsed.get("vehicle_summary", {})
except json.JSONDecodeError:
# Если не JSON, пробуем как HTML
try:
parsed = self.parser.parse_vehicle_page(raw_data, "dummy_url")
return parsed.get("vehicle_summary", {})
except Exception:
return None
def _map_to_car_record(self, parsed_data: dict) -> Optional[CarRecord]:
"""Маппит parsed data в CarRecord."""
try:
# Используем CarMapper
payload_insights = {} # TODO: extract from raw data if needed
return self.mapper.map_to_car_record("dummy_url", parsed_data, payload_insights)
except Exception as e:
logger.error(f"Mapping failed: {e}")
return None
def _save_car(self, car_record: CarRecord):
"""Сохраняет CarRecord в базу данных."""
# Используем PersistenceService
from .storage.db import PersistenceService
from .core.config import settings
persistence = PersistenceService(settings)
persistence.create_tables()
upsert_result = persistence.upsert_car(car_record)
logger.info(f"Car upserted: {upsert_result}")
def _save_parse_result(self, snapshot_id: int, success: bool,
parsed_data: Optional[str], error_message: Optional[str] = None) -> VehicleParseResult:
"""Сохраняет результат парсинга."""
result = VehicleParseResult(
snapshot_id=snapshot_id,
success=success,
parsed_data=parsed_data,
error_message=error_message
)
self.db.add(result)
self.db.commit()
self.db.refresh(result)
return result
def process_unparsed_snapshots(self, limit: int = 10) -> int:
"""
Обрабатывает snapshots без результатов парсинга.
Returns:
Количество успешно обогащенных snapshots
"""
# Находим snapshots без parse results
snapshots = self.db.query(VehicleRawSnapshot).outerjoin(
VehicleParseResult, VehicleRawSnapshot.id == VehicleParseResult.snapshot_id
).filter(
VehicleRawSnapshot.success == True,
VehicleParseResult.id.is_(None)
).limit(limit).all()
enriched_count = 0
for snapshot in snapshots:
result = self.enrich_vehicle(snapshot)
if result and result.success:
enriched_count += 1
return enriched_count

486
iaai_scraper/fast_sync.py Normal file
View File

@@ -0,0 +1,486 @@
from __future__ import annotations
import concurrent.futures
import logging
import random
import time
from dataclasses import dataclass
from typing import Any, Callable
from .browser.fast_client import FastListingVehicle, IAAIFastClient
from .core.runtime_config import RuntimeConfig
from .parsing.fast_mapper import INACTIVE_STATUS_VALUES, FastCarMapper
from .storage.db import PersistenceService
from .storage.schemas import CarRecord
logger = logging.getLogger("iaai_scraper.fast_sync")
@dataclass(slots=True)
class FastSyncStats:
ids_fetched: int = 0
cars_upserted: int = 0
cars_failed: int = 0
cars_filtered: int = 0
images_upserted: int = 0
skipped_existing: int = 0
protection_events: int = 0
def passes_condition_check(row: FastListingVehicle) -> bool:
if row.timed_auction_closed:
return False
status = (row.inventory_status or "").strip().upper()
return status not in INACTIVE_STATUS_VALUES
class FastSyncEngine:
"""Full iaai-fast style sync pipeline adapted to this project's storage.
Flow: hidden listing payloads -> concurrent ProductDetailsVM HTTP fetch ->
CarRecord preparation in memory -> single batch DB upsert. Browser is used
only inside IAAIFastClient to refresh cookies when IAAI challenge appears.
"""
def __init__(
self,
*,
client: IAAIFastClient,
mapper: FastCarMapper,
persistence: PersistenceService,
batch_size: int,
fetch_concurrency: int,
report_progress: Callable[[str, Any], None] | None = None,
) -> None:
self.client = client
self.mapper = mapper
self.persistence = persistence
self.batch_size = max(1, int(batch_size))
self.fetch_concurrency = max(1, int(fetch_concurrency))
self.report_progress = report_progress
@staticmethod
def _is_transient_detail_error(exc: Exception) -> bool:
text = str(exc).lower()
return any(
marker in text
for marker in (
"sslerror",
"ssleoferror",
"unexpected_eof_while_reading",
"eof occurred in violation of protocol",
"max retries exceeded",
"read timed out",
"readtimeout",
"connection reset",
"connection aborted",
"connection closed",
"temporarily unavailable",
"too many requests",
"status=429",
"status=500",
"status=502",
"status=503",
"status=504",
)
)
def sync_listing(
self,
*,
runtime_config: RuntimeConfig,
make: str | None = None,
model: str | None = None,
lane: str = "iaai_cars",
limit: int | None = None,
only_new: bool = False,
listing_url: str | None = None,
max_pages: int | None = None,
skip_mark_sold: bool = False,
) -> dict[str, Any]:
del lane
started_at = time.perf_counter()
stats = FastSyncStats()
errors: list[dict[str, str]] = []
selected: dict[str, FastListingVehicle] = {}
rows_seen = 0
rows_skipped_condition = 0
filters = runtime_config.filters
logger.info(
"Fast HTTP-first listing started: make=%s listing_url=%s max_pages=%s concurrency=%s batch_size=%s",
make or "ALL",
listing_url or "default",
max_pages,
self.fetch_concurrency,
self.batch_size,
)
for vehicle in self.client.iter_listing_vehicles(
listing_start_url=listing_url,
make=make,
max_pages=max_pages,
):
rows_seen += 1
if runtime_config.sync.condition_check_enabled and not passes_condition_check(vehicle):
rows_skipped_condition += 1
continue
if vehicle.inventory_id in selected:
continue
selected[vehicle.inventory_id] = vehicle
if limit is not None and limit > 0 and len(selected) >= limit:
break
candidates = list(selected.values())
all_listing_origin_urls = {f"https://www.iaai.com/VehicleDetail/{v.inventory_id}" for v in candidates}
if only_new and candidates:
origin_urls = [f"https://www.iaai.com/VehicleDetail/{v.inventory_id}" for v in candidates]
origin_ids = [f"iaai:{v.inventory_id}" for v in candidates]
existing_urls, existing_ids = self.persistence.get_existing_urls_and_ids(origin_urls, origin_ids)
fresh: list[FastListingVehicle] = []
for vehicle in candidates:
if f"https://www.iaai.com/VehicleDetail/{vehicle.inventory_id}" in existing_urls or f"iaai:{vehicle.inventory_id}" in existing_ids:
stats.skipped_existing += 1
continue
fresh.append(vehicle)
candidates = fresh
self._progress(
"fast_listing_collected",
rows_seen=rows_seen,
rows_filtered_condition=rows_skipped_condition,
rows_selected=len(candidates),
skipped_existing=stats.skipped_existing,
)
logger.info(
"Fast HTTP-first listing collected: rows_seen=%d selected=%d skipped_existing=%d filtered_condition=%d only_new=%s",
rows_seen,
len(candidates),
stats.skipped_existing,
rows_skipped_condition,
only_new,
)
scan_completed = not (limit is not None and limit > 0) and not errors
if not candidates:
return self._result(
started_at=started_at,
stats=stats,
failures=errors,
listing={
"mode": "fast_hidden_payload",
"vehicles_collected": 0,
"vehicle_urls": [],
"early_stopped": False,
"truncated_by_time_budget": False,
"rows_seen": rows_seen,
"rows_filtered_condition": rows_skipped_condition,
},
all_listing_origin_urls=all_listing_origin_urls,
full_scan_completed=scan_completed,
)
prepared_rows: list[CarRecord] = []
db_processed = 0
retry_candidates: list[FastListingVehicle] = []
transient_failure_log_count = 0
started_details = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=min(self.fetch_concurrency, len(candidates))) as executor:
future_to_vehicle = {
executor.submit(self._fetch_detail_payload, vehicle.inventory_id): vehicle
for vehicle in candidates
}
for index, future in enumerate(concurrent.futures.as_completed(future_to_vehicle), start=1):
vehicle = future_to_vehicle[future]
try:
payload = future.result()
record = self.mapper.map_payload_to_record(
detail_payload=payload,
vehicle_url=f"https://www.iaai.com/VehicleDetail/{vehicle.inventory_id}",
listing_vehicle=vehicle,
)
if model and model.casefold() not in record.model.casefold():
stats.cars_filtered += 1
continue
if not filters.matches({
"brand": record.brand,
"model": record.model,
"year": record.year,
"body_type": record.body_type,
"color": record.color,
"drive": record.drive,
"gearbox": record.gearbox,
"price": record.price,
"mileage": record.mileage,
}):
stats.cars_filtered += 1
continue
prepared_rows.append(record)
stats.ids_fetched += 1
if len(prepared_rows) >= self.batch_size:
db_processed += self._flush_db_records(
rows=prepared_rows,
stats=stats,
errors=errors,
processed=db_processed + len(prepared_rows),
total=len(candidates),
)
prepared_rows.clear()
except Exception as exc:
stats.cars_failed += 1
errors.append({"vehicle_url": f"https://www.iaai.com/VehicleDetail/{vehicle.inventory_id}", "error": str(exc)})
if _looks_like_protection(exc):
stats.protection_events += 1
if self._is_transient_detail_error(exc):
retry_candidates.append(vehicle)
transient_failure_log_count += 1
if transient_failure_log_count % 100 == 0:
logger.warning(
"Fast detail transient failures queued for retry: count=%d latest_inventory_id=%s",
transient_failure_log_count,
vehicle.inventory_id,
)
else:
logger.exception("Fast detail parse failed inventory_id=%s: %s", vehicle.inventory_id, exc)
if index % 100 == 0 or index == len(candidates):
elapsed = max(0.001, time.perf_counter() - started_details)
verbose_progress_logs = bool(
getattr(
getattr(getattr(self.client, "_settings", None), "scraping_profile", None),
"verbose_progress_logs",
False,
)
)
if verbose_progress_logs:
logger.info(
"Fast HTTP-first details progress: processed=%d/%d ok=%d failed=%d queued_db=%d rate=%.2f/s",
index,
len(candidates),
stats.ids_fetched,
stats.cars_failed,
len(prepared_rows),
index / elapsed,
)
self._progress(
"fast_detail_progress",
processed=index,
total=len(candidates),
ids_fetched=stats.ids_fetched,
cars_failed=stats.cars_failed,
queued_for_db=len(prepared_rows),
throughput=round(index / elapsed, 2),
)
if retry_candidates:
retry_started = time.perf_counter()
retry_workers = max(1, min(8, self.fetch_concurrency // 2, len(retry_candidates)))
retry_errors: list[dict[str, str]] = []
logger.info(
"Fast HTTP-first retrying transient detail failures: total=%d workers=%d",
len(retry_candidates),
retry_workers,
)
with concurrent.futures.ThreadPoolExecutor(max_workers=retry_workers) as executor:
future_to_vehicle = {
executor.submit(self._fetch_detail_payload, vehicle.inventory_id): vehicle
for vehicle in retry_candidates
}
for retry_index, future in enumerate(concurrent.futures.as_completed(future_to_vehicle), start=1):
vehicle = future_to_vehicle[future]
try:
payload = future.result()
record = self.mapper.map_payload_to_record(
detail_payload=payload,
vehicle_url=f"https://www.iaai.com/VehicleDetail/{vehicle.inventory_id}",
listing_vehicle=vehicle,
)
if model and model.casefold() not in record.model.casefold():
stats.cars_filtered += 1
continue
if not filters.matches({
"brand": record.brand,
"model": record.model,
"year": record.year,
"body_type": record.body_type,
"color": record.color,
"drive": record.drive,
"gearbox": record.gearbox,
"price": record.price,
"mileage": record.mileage,
}):
stats.cars_filtered += 1
continue
prepared_rows.append(record)
stats.ids_fetched += 1
stats.cars_failed = max(0, stats.cars_failed - 1)
if len(prepared_rows) >= self.batch_size:
db_processed += self._flush_db_records(
rows=prepared_rows,
stats=stats,
errors=errors,
processed=db_processed + len(prepared_rows),
total=len(candidates),
)
prepared_rows.clear()
except Exception as exc:
retry_errors.append({
"vehicle_url": f"https://www.iaai.com/VehicleDetail/{vehicle.inventory_id}",
"error": str(exc),
})
if _looks_like_protection(exc):
stats.protection_events += 1
if retry_index % 100 == 0 or retry_index == len(retry_candidates):
elapsed = max(0.001, time.perf_counter() - retry_started)
logger.info(
"Fast HTTP-first retry progress: processed=%d/%d recovered=%d remaining_failed=%d rate=%.2f/s",
retry_index,
len(retry_candidates),
len(retry_candidates) - len(retry_errors),
len(retry_errors),
retry_index / elapsed,
)
transient_urls = {f"https://www.iaai.com/VehicleDetail/{v.inventory_id}" for v in retry_candidates}
errors = [error for error in errors if error.get("vehicle_url") not in transient_urls]
errors.extend(retry_errors)
if prepared_rows:
db_processed += self._flush_db_records(
rows=prepared_rows,
stats=stats,
errors=errors,
processed=db_processed + len(prepared_rows),
total=len(candidates),
)
prepared_rows.clear()
self.client.persist_session_state()
scan_completed = not (limit is not None and limit > 0) and not errors
mark_sold_scope_partial = bool(
(limit is not None and limit > 0)
or make
or model
or listing_url
or only_new
)
if all_listing_origin_urls and not mark_sold_scope_partial and not skip_mark_sold and scan_completed:
try:
sold_count = self.persistence.mark_sold_not_in_listing_by_urls(all_listing_origin_urls, lane="iaai")
except Exception as exc:
sold_count = 0
logger.warning("Fast sold reconcile failed: %s", exc)
else:
sold_count = 0
listing = {
"mode": "fast_hidden_payload",
"vehicles_collected": len(candidates),
"vehicle_urls": [f"https://www.iaai.com/VehicleDetail/{v.inventory_id}" for v in candidates],
"early_stopped": False,
"truncated_by_time_budget": False,
"rows_seen": rows_seen,
"rows_filtered_condition": rows_skipped_condition,
"sold_marked": sold_count,
}
return self._result(
started_at=started_at,
stats=stats,
failures=errors,
listing=listing,
all_listing_origin_urls=all_listing_origin_urls,
full_scan_completed=scan_completed,
)
def _result(
self,
*,
started_at: float,
stats: FastSyncStats,
failures: list[dict[str, str]],
listing: dict[str, Any],
all_listing_origin_urls: set[str],
full_scan_completed: bool,
) -> dict[str, Any]:
status = "success" if not failures else ("partial_success" if stats.cars_upserted else "failed")
total = int(listing.get("vehicles_collected") or 0)
fail_ratio = (stats.cars_failed / total) if total > 0 else 0.0
protection_ratio = (stats.protection_events / total) if total > 0 else 0.0
anti_bot_detected = total > 0 and ((stats.protection_events >= 30 and protection_ratio >= 0.10) or fail_ratio >= 0.30)
return {
"status": status,
"listing": listing,
"total": total,
"total_discovered": total,
"skipped_existing": stats.skipped_existing,
"cars_upserted": stats.cars_upserted,
"cars_failed": stats.cars_failed,
"cars_filtered": stats.cars_filtered,
"images_upserted": stats.images_upserted,
"protection_events": stats.protection_events,
"failures": failures,
"all_listing_origin_urls": all_listing_origin_urls,
"full_scan_completed": full_scan_completed and not anti_bot_detected,
"anti_bot_detected": anti_bot_detected,
"fail_ratio": round(fail_ratio, 4),
"protection_ratio": round(protection_ratio, 4),
"elapsed_seconds": round(time.perf_counter() - started_at, 3),
}
def _progress(self, stage: str, **meta: Any) -> None:
if self.report_progress is None:
return
try:
self.report_progress(stage, **meta)
except Exception:
pass
def _fetch_detail_payload(self, inventory_id: str) -> dict[str, Any]:
jitter = float(self.client._settings.scraping_profile.request_jitter_max_s)
if jitter > 0:
time.sleep(random.uniform(0.0, jitter))
return self.client.fetch_vehicle_detail_payload(inventory_id)
def _flush_db_records(
self,
*,
rows: list[CarRecord],
stats: FastSyncStats,
errors: list[dict[str, str]],
processed: int,
total: int,
) -> int:
if not rows:
return 0
batch = list(rows)
try:
upsert = self.persistence.upsert_cars_batch(batch)
stats.cars_upserted += int(upsert.get("inserted", 0)) + int(upsert.get("updated", 0))
stats.images_upserted += int(upsert.get("images_upserted", 0))
except Exception as exc:
stats.cars_failed += len(batch)
errors.append({"vehicle_url": f"db_batch_{processed - len(batch)}", "error": str(exc)})
logger.exception("Fast DB apply failed processed=%s size=%s: %s", processed, len(batch), exc)
self._progress(
"fast_db_progress",
processed=processed,
total=total,
cars_upserted=stats.cars_upserted,
cars_failed=stats.cars_failed,
images_upserted=stats.images_upserted,
)
logger.info(
"Fast HTTP-first DB batch: processed=%d/%d batch=%d upserted=%d failed=%d images=%d",
processed,
total,
len(batch),
stats.cars_upserted,
stats.cars_failed,
stats.images_upserted,
)
return len(batch)
def _looks_like_protection(exc: Exception) -> bool:
message = str(exc).lower()
return any(token in message for token in ("captcha", "antibot", "challenge", "blocked", "403", "429", "incapsula"))

View File

@@ -1,119 +0,0 @@
import logging
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from .core.config import settings
from .scraper import IAAIScraper
from .storage.db import get_db_session
from .storage.models import VehicleCandidate, VehicleRawSnapshot
logger = logging.getLogger("iaai_scraper.fetch_service")
class FetchService:
"""Сервис для захвата сырых данных автомобилей (HTTP/browser fallback)."""
def __init__(self):
self.db: Session = get_db_session()
self.scraper = IAAIScraper()
def fetch_vehicle_data(self, candidate: VehicleCandidate) -> Optional[VehicleRawSnapshot]:
"""
Захватывает данные для кандидата автомобиля.
Args:
candidate: Кандидат для обработки
Returns:
VehicleRawSnapshot если захват успешен, None если неудача
"""
logger.info(f"Fetching data for candidate {candidate.id}: {candidate.url}")
try:
# Сначала пытаемся HTTP fast-path
raw_data = self._try_http_capture(candidate.url)
if raw_data:
return self._save_snapshot(candidate.id, "http", True, raw_data)
# Если HTTP не сработал, используем browser fallback
logger.info(f"HTTP failed for {candidate.url}, trying browser fallback")
raw_data = self._try_browser_capture(candidate.url)
if raw_data:
return self._save_snapshot(candidate.id, "browser", True, raw_data)
# Оба метода failed
logger.warning(f"Both HTTP and browser capture failed for {candidate.url}")
self._save_snapshot(candidate.id, "browser", False, None, "Both capture methods failed")
return None
except Exception as e:
error_msg = f"Unexpected error during capture: {str(e)}"
logger.error(f"Error fetching {candidate.url}: {error_msg}")
self._save_snapshot(candidate.id, "unknown", False, None, error_msg)
return None
def _try_http_capture(self, url: str) -> Optional[str]:
"""Пытается захватить данные через HTTP."""
try:
# Используем существующий метод из scraper
# Но нам нужно инициализировать scraper с сессией
# Пока заглушка - интегрируем позже
# Для теста вернем None, чтобы использовать browser
return None
except Exception as e:
logger.debug(f"HTTP capture failed for {url}: {e}")
return None
def _try_browser_capture(self, url: str) -> Optional[str]:
"""Пытается захватить данные через browser."""
try:
# Используем scrape_vehicle_detail из scraper
with IAAIScraper() as scraper:
result = scraper.scrape_vehicle_detail(url)
# Возвращаем HTML или JSON данные
return result.get("raw_html") or json.dumps(result)
except Exception as e:
logger.debug(f"Browser capture failed for {url}: {e}")
return None
def _save_snapshot(self, candidate_id: int, method: str, success: bool,
raw_data: Optional[str], error_message: Optional[str] = None) -> VehicleRawSnapshot:
"""Сохраняет snapshot в базу данных."""
snapshot = VehicleRawSnapshot(
candidate_id=candidate_id,
method=method,
success=success,
raw_data=raw_data,
error_message=error_message
)
self.db.add(snapshot)
self.db.commit()
self.db.refresh(snapshot)
return snapshot
def process_pending_candidates(self, limit: int = 10) -> int:
"""
Обрабатывает ожидающих кандидатов.
Returns:
Количество успешно обработанных кандидатов
"""
from .discovery_service import DiscoveryService
discovery = DiscoveryService()
candidates = discovery.get_pending_candidates(limit)
processed_count = 0
for candidate in candidates:
discovery.mark_candidate_processing(candidate.id)
snapshot = self.fetch_vehicle_data(candidate)
if snapshot and snapshot.success:
discovery.mark_candidate_processed(candidate.id)
processed_count += 1
else:
discovery.mark_candidate_failed(candidate.id)
return processed_count

View File

@@ -0,0 +1,368 @@
from __future__ import annotations
import re
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
from ..browser.fast_client import FastListingVehicle, build_resizer_images_from_keys, parse_int, parse_text
from ..storage.enums import BODY_TYPE_ENUM_VALUES, DRIVE_ENUM_VALUES, GEARBOX_ENUM_VALUES
from ..storage.schemas import CarRecord, ImageRecord
ORIGIN_PREFIX = "iaai:"
ORIGIN_URL_BASE = "https://www.iaai.com/VehicleDetail"
DAMAGE_NEUTRAL_VALUES = {
"NORMAL WEAR & TEAR",
"NORMAL WEAR",
"NORMALWEAR&TEAR",
"NONE",
"NO DAMAGE",
"NO VISIBLE DAMAGE",
"MINOR DENT/SCRATCHES",
}
INACTIVE_STATUS_VALUES = {"SOLD", "SO", "CLOSED", "CN", "DELIVERED", "WITHDRAWN", "WDR", "COMPLETE", "COMPLETED"}
class FastCarMapper:
"""Maps IAAI ProductDetailsVM payloads directly to project CarRecord."""
def map_payload_to_record(
self,
*,
detail_payload: dict[str, Any],
vehicle_url: str | None = None,
listing_vehicle: FastListingVehicle | None = None,
) -> CarRecord:
inventory_view = detail_payload.get("inventoryView")
if not isinstance(inventory_view, dict):
raise RuntimeError("detail payload missing inventoryView")
attributes = inventory_view.get("attributes")
if not isinstance(attributes, dict):
raise RuntimeError("detail payload missing inventoryView.attributes")
inventory_id = parse_text(attributes.get("Id"))
if not inventory_id and listing_vehicle is not None:
inventory_id = listing_vehicle.inventory_id
if not inventory_id and vehicle_url:
inventory_id = self._inventory_id_from_url(vehicle_url)
if not inventory_id:
raise RuntimeError("missing inventory id")
brand = self._limit_text(parse_text(attributes.get("Make")) or "UNKNOWN", 50)
model = self._build_model_name(parse_text(attributes.get("Model")), parse_text(attributes.get("Series"))) or "UNKNOWN"
model = self._limit_text(model, 50)
year = self._parse_year(attributes.get("Year"))
auction_info = detail_payload.get("auctionInformation")
auction_info = auction_info if isinstance(auction_info, dict) else {}
bidding_info = auction_info.get("biddingInformation")
bidding_info = bidding_info if isinstance(bidding_info, dict) else {}
prebid_info = auction_info.get("prebidInformation")
prebid_info = prebid_info if isinstance(prebid_info, dict) else {}
high_bid = self._first_positive_int(
prebid_info.get("decimalHighBidAmount"),
prebid_info.get("highBidAmount"),
bidding_info.get("highBidAmount"),
)
buy_now = self._first_positive_int(
bidding_info.get("buyNowAmount"),
prebid_info.get("buyNowPrice"),
bidding_info.get("buyNowPrice"),
)
price = high_bid if high_bid is not None else buy_now
image_dimensions = inventory_view.get("imageDimensions")
image_dimensions = image_dimensions if isinstance(image_dimensions, dict) else {}
keys_container = image_dimensions.get("keys")
keys_container = keys_container if isinstance(keys_container, dict) else {}
image_keys = keys_container.get("$values")
image_keys = image_keys if isinstance(image_keys, list) else []
images = [ImageRecord.model_validate(row) for row in build_resizer_images_from_keys(image_keys)]
primary_damage = parse_text(attributes.get("PrimaryDamageDesc"))
secondary_damage = parse_text(attributes.get("SecondaryDamageDesc"))
origin_url = vehicle_url or f"{ORIGIN_URL_BASE}/{inventory_id}"
normalized_origin_id = self._normalize_origin_inventory_id(inventory_id)
origin_id = f"{ORIGIN_PREFIX}{normalized_origin_id}"
return CarRecord(
parser_id=self._generate_parser_id(origin_id),
brand=brand,
model=model,
year=year,
price=price,
currency=self._map_currency(parse_text(attributes.get("Currency")) or (listing_vehicle.currency if listing_vehicle else None)),
mileage=self._parse_non_negative_int(attributes.get("ODOValue")) or 0,
country=self._map_country(inventory_id),
is_sold=self._is_sold(listing_vehicle),
color=self._normalize_color(parse_text(attributes.get("ExteriorColor")) or parse_text(attributes.get("ColorDesc"))),
drive=self._map_drive(parse_text(attributes.get("DriveLineTypeDesc"))),
gearbox=self._map_gearbox(parse_text(attributes.get("Transmission"))),
steering_wheel="LEFT",
body_type=self._map_body_type(parse_text(attributes.get("BodyStyleName")) or parse_text(attributes.get("VehicleClass"))),
engine_volume=self._parse_engine_volume(parse_text(attributes.get("EngineSize")) or parse_text(attributes.get("EngineInformation"))),
selling_type="AUCTION",
one_owner=False,
new_car=False,
is_hidden=not bool(images),
origin="IAAI",
origin_url=origin_url,
origin_id=origin_id,
is_damaged=self._derive_is_damaged(primary_damage=primary_damage, secondary_damage=secondary_damage),
evaluation=parse_text(attributes.get("VehicleGrade")),
non_smoking=True,
rental=False,
repair_history=False,
slug=self._slugify(" ".join(filter(None, [brand, model, str(year or "")]))),
last_seen_at=datetime.now(timezone.utc),
images=images,
)
def payload_to_summary(self, detail_payload: dict[str, Any], vehicle_url: str) -> dict[str, Any]:
inventory_view = detail_payload.get("inventoryView") if isinstance(detail_payload, dict) else {}
inventory_view = inventory_view if isinstance(inventory_view, dict) else {}
attr = inventory_view.get("attributes")
attr = attr if isinstance(attr, dict) else {}
auction_info = detail_payload.get("auctionInformation") if isinstance(detail_payload, dict) else {}
auction_info = auction_info if isinstance(auction_info, dict) else {}
bidding_info = auction_info.get("biddingInformation")
bidding_info = bidding_info if isinstance(bidding_info, dict) else {}
prebid_info = auction_info.get("prebidInformation")
prebid_info = prebid_info if isinstance(prebid_info, dict) else {}
image_dimensions = inventory_view.get("imageDimensions")
image_dimensions = image_dimensions if isinstance(image_dimensions, dict) else {}
keys_container = image_dimensions.get("keys")
keys_container = keys_container if isinstance(keys_container, dict) else {}
image_keys = keys_container.get("$values")
image_keys = image_keys if isinstance(image_keys, list) else []
image_urls = [str(row["fullres_image"]) for row in build_resizer_images_from_keys(image_keys)]
return {
"source_url": vehicle_url,
"lot_number": attr.get("Id") or attr.get("StockNumber") or attr.get("SalvageId"),
"year": attr.get("Year"),
"make": attr.get("Make"),
"model": attr.get("Model"),
"trim": attr.get("Series"),
"body_type": attr.get("BodyStyleName"),
"drive": attr.get("DriveLineTypeDesc"),
"engine": attr.get("EngineInformation") or attr.get("EngineSize"),
"fuel_type": attr.get("FuelTypeCode"),
"gearbox": attr.get("Transmission"),
"color": attr.get("ExteriorColor"),
"primary_damage": attr.get("PrimaryDamageDesc"),
"secondary_damage": attr.get("SecondaryDamageDesc"),
"odometer": attr.get("ODOValue"),
"location": attr.get("BranchName"),
"auction_date": attr.get("AuctionDateTime"),
"title": attr.get("Title"),
"current_bid": prebid_info.get("highBidAmount") or bidding_info.get("highBidAmount"),
"buy_now": prebid_info.get("buyNowPrice") or bidding_info.get("buyNowPrice"),
"actual_cash_value": attr.get("ProviderACV"),
"estimated_repair_cost": attr.get("EstRepairCost"),
"image_urls": image_urls,
}
@staticmethod
def _inventory_id_from_url(vehicle_url: str) -> str | None:
tail = urlparse(vehicle_url).path.rstrip("/").split("/")[-1]
return tail or None
@staticmethod
def _normalize_origin_inventory_id(inventory_id: str) -> str:
return inventory_id.strip().split("~", 1)[0]
@staticmethod
def _build_model_name(model: str | None, series: str | None) -> str:
unique_parts: list[str] = []
seen: set[str] = set()
for value in (model, series):
if not value:
continue
normalized = " ".join(value.split())
key = normalized.casefold()
if key in seen:
continue
seen.add(key)
unique_parts.append(normalized)
return " ".join(unique_parts).strip()
@staticmethod
def _parse_year(value: Any) -> int | None:
parsed = parse_int(value)
if parsed is None or parsed < 1900 or parsed > 2100:
return None
return parsed
@staticmethod
def _parse_non_negative_int(value: Any) -> int | None:
parsed = parse_int(value)
if parsed is None or parsed < 0:
return None
return parsed
@classmethod
def _first_positive_int(cls, *values: Any) -> int | None:
for value in values:
parsed = cls._parse_non_negative_int(value)
if parsed is not None and parsed > 0:
return parsed
return None
@staticmethod
def _normalize_color(value: str | None) -> str:
if not value:
return "other"
normalized = value.strip().lower()
if "/" in normalized:
normalized = normalized.split("/", 1)[0].strip()
return normalized or "other"
@staticmethod
def _map_currency(value: str | None) -> str:
normalized = (value or "USD").strip().upper()
return normalized if normalized in {"USD", "CAD", "EUR", "JPY", "RUB", "KRW", "AED", "GBP"} else "USD"
@staticmethod
def _map_country(inventory_id: str) -> str:
upper_id = inventory_id.strip().upper()
if upper_id.endswith("~CA"):
return "CA"
if upper_id.endswith("~US"):
return "US"
return "NA"
@staticmethod
def _map_drive(value: str | None) -> str | None:
if not value:
return None
normalized = value.strip().lower()
candidates: tuple[str, ...] | None = None
if "front" in normalized or normalized == "fwd":
candidates = ("FWD",)
elif "all wheel" in normalized or "4x4" in normalized or normalized == "awd" or "four wheel" in normalized:
candidates = ("4WD", "FOUR_WD")
elif "rear" in normalized or normalized == "rwd":
candidates = ("RWD",)
elif "2wd" in normalized or "two wheel" in normalized:
candidates = ("2WD", "TWO_WD")
elif "unknown" in normalized or normalized in {"na", "n/a"}:
candidates = ("NA",)
return FastCarMapper._select_allowed(candidates, DRIVE_ENUM_VALUES) if candidates else None
@staticmethod
def _map_gearbox(value: str | None) -> str | None:
if not value:
return None
normalized = value.strip().lower()
candidates: tuple[str, ...] | None = None
if "cvt" in normalized:
candidates = ("CVT",)
elif "manual" in normalized or normalized == "mt":
candidates = ("MT",)
elif "electric" in normalized or normalized == "ev":
candidates = ("EV",)
elif "auto" in normalized or normalized == "at":
candidates = ("AT",)
elif "unknown" in normalized or normalized in {"na", "n/a"}:
candidates = ("NA",)
return FastCarMapper._select_allowed(candidates, GEARBOX_ENUM_VALUES) if candidates else None
@staticmethod
def _map_body_type(value: str | None) -> str:
if not value:
return "OTHER"
normalized = value.strip().lower()
candidates: tuple[str, ...] | None = None
if "sedan" in normalized:
candidates = ("SEDAN",)
elif "sport utility" in normalized or normalized == "suv" or "crossover" in normalized:
candidates = ("SUV",)
elif "hatch" in normalized:
candidates = ("HATCHBACK",)
elif "wagon" in normalized:
candidates = ("STATION_WAGON", "Station Wagon")
elif "coupe" in normalized:
candidates = ("COUPE",)
elif "pickup" in normalized or ("crew" in normalized and "cab" in normalized):
candidates = ("PICKUP", "Pickup")
elif "convertible" in normalized or "roadster" in normalized or "cabrio" in normalized:
candidates = ("OPEN", "Open")
elif "van" in normalized:
candidates = ("MINIVAN",)
elif "truck" in normalized or "chassis" in normalized:
candidates = ("TRUCK", "Truck")
elif "rv" in normalized or "motorized" in normalized:
candidates = ("RV",)
elif normalized in {"other", "unknown"}:
candidates = ("OTHER", "Other")
return FastCarMapper._select_allowed(candidates, BODY_TYPE_ENUM_VALUES, fallback="OTHER") or "OTHER"
@staticmethod
def _parse_engine_volume(value: str | None) -> int | None:
if not value:
return None
match = re.search(r"(\d+(?:\.\d+)?)\s*[lL]\b", value)
if not match:
return None
try:
liters = float(match.group(1))
except ValueError:
return None
cc = int(round(liters * 1000))
if cc <= 0 or cc > 10000:
return None
return cc
@staticmethod
def _derive_is_damaged(*, primary_damage: str | None, secondary_damage: str | None) -> bool:
neutral = {item.replace(" ", "").strip().upper() for item in DAMAGE_NEUTRAL_VALUES}
for value in (primary_damage, secondary_damage):
if not value:
continue
normalized = value.replace(" ", "").strip().upper()
if normalized and normalized not in neutral:
return True
return False
@staticmethod
def _is_sold(listing_vehicle: FastListingVehicle | None) -> bool:
if listing_vehicle is None:
return False
if listing_vehicle.timed_auction_closed:
return True
status = (listing_vehicle.inventory_status or "").strip().upper()
return status in INACTIVE_STATUS_VALUES
@staticmethod
def _select_allowed(candidates: tuple[str, ...] | None, allowed: tuple[str, ...], fallback: str | None = None) -> str | None:
if not candidates:
return fallback if fallback in allowed else None
allowed_set = set(allowed)
for candidate in candidates:
if candidate in allowed_set:
return candidate
return fallback if fallback in allowed_set else None
@staticmethod
def _limit_text(value: str, max_length: int) -> str:
return value if len(value) <= max_length else value[:max_length].rstrip()
@staticmethod
def _slugify(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "car"
@staticmethod
def _generate_parser_id(origin_id: str) -> str:
import hashlib
from string import ascii_letters, digits
digest = hashlib.sha256(origin_id.encode()).digest()
alphabet = ascii_letters + digits
base = len(alphabet)
num = int.from_bytes(digest[:17], "big")
chars: list[str] = []
for _ in range(22):
num, idx = divmod(num, base)
chars.append(alphabet[idx])
return "car-" + "".join(chars)

View File

@@ -20,7 +20,7 @@ from ..storage.schemas import CarRecord, ImageRecord
class CarMapper:
# Преобразование данных IAAI в CarRecord.
# Маппер IAAI в CarRecord.
BODY_MAP = {
"sedan": "SEDAN", "coupe": "COUPE", "hatchback": "HATCHBACK", "sport utility": "SUV",
@@ -48,7 +48,7 @@ class CarMapper:
NO_DAMAGE_MARKERS = {"normal wear", "normal wear & tear", "normal wear and tear", "n/a", "na", "none", "no damage", "minor dents/scratches"}
def map_to_car_record(self, vehicle_url: str, vehicle_summary: dict[str, Any], payload_insights: dict[str, Any]) -> CarRecord:
# Собираем нормализованную DB-модель.
# Собираем DB-модель.
vehicle_summary = vehicle_summary or {}
payload_insights = payload_insights or {}
core = payload_insights.get("vehicle_core", {})
@@ -77,7 +77,7 @@ class CarMapper:
)
color = self._normalize_color(first_non_empty([core.get("color"), vehicle_summary.get("color"), "other"]))
drive = self._normalize_drive(first_non_empty([core.get("drive"), vehicle_summary.get("drive")]))
# Пытаемся определить привод из строки двигателя.
# Пробуем взять привод из двигателя.
if not drive or drive == "NA":
engine_text = self._as_str(first_non_empty([core.get("engine"), vehicle_summary.get("engine")]))
if engine_text:
@@ -144,7 +144,7 @@ class CarMapper:
@classmethod
def _to_money_int(cls, value: Any) -> int | None:
# Нормализация стоимости из разных форматов.
# Нормализация цены.
if value is None:
return None
if isinstance(value, bool):
@@ -168,14 +168,14 @@ class CarMapper:
for number in numbers:
clean = number.replace(" ", "")
if "," in clean and "." in clean:
# Поддержка 1,234.56 и 1.234,56.
# Поддержка двух форматов.
if clean.rfind(",") > clean.rfind("."):
clean = clean.replace(".", "").replace(",", ".")
else:
clean = clean.replace(",", "")
elif "," in clean:
parts = clean.split(",")
# 123,45 -> 123.45, иначе разделитель тысяч.
# Десятичный или тысячный разделитель.
if len(parts[-1]) in {1, 2} and len(parts) == 2:
clean = clean.replace(",", ".")
else:
@@ -214,7 +214,7 @@ class CarMapper:
@staticmethod
def _parse_odometer(value: Any) -> int:
# Разбор пробега из строк IAAI.
# Разбор пробега.
if value is None:
return 0
text = str(value).strip()
@@ -223,7 +223,7 @@ class CarMapper:
lowered = text.lower()
if any(skip in lowered for skip in ["not required", "exempt", "n/a", "na", "unknown", "tbd"]):
return 0
# Извлекаем число из строкового формата одометра.
# Ищем число.
numbers = re.findall(r"[\d,]+", text)
for num_str in numbers:
clean = num_str.replace(",", "")
@@ -294,7 +294,7 @@ class CarMapper:
empty_default: str | None,
fallback: str | None,
) -> str | None:
# Общий helper для enum-нормализации.
# Общая нормализация enum.
text = self._as_str(value).lower()
if not text:
return empty_default
@@ -329,7 +329,7 @@ class CarMapper:
return any(token in self._as_str(auction.get("sale_status")).lower() for token in ["sold", "closed", "ended"])
def _build_images(self, urls: list[Any]) -> list[ImageRecord]:
# Для imageKeys берем самый большой размер.
# Для imageKeys берём самый большой размер.
best_by_key: dict[str, str] = {}
key_order: list[str] = []
non_keyed: list[str] = []
@@ -375,11 +375,11 @@ class CarMapper:
@classmethod
def _generate_parser_id(cls, origin_id: str) -> str:
# Стабильный parser_id по origin_id.
# Стабильный parser_id.
digest = hashlib.sha256(origin_id.encode()).digest()
alphabet = cls._PARSER_ID_ALPHABET
base = len(alphabet)
num = int.from_bytes(digest[:17], "big") # 17 байт = 136 бит, хватает на 22 символа
num = int.from_bytes(digest[:17], "big") # Хватает на 22 символа.
chars: list[str] = []
for _ in range(22):
num, idx = divmod(num, base)
@@ -387,7 +387,7 @@ class CarMapper:
return "car-" + "".join(chars)
def _build_origin_id(self, vehicle_url: str, vehicle_summary: dict[str, Any], core: dict[str, Any]) -> str:
# Формат: iaai:{lot_number} (аналог copart:94323985).
# Формат: iaai:{lot_number}.
for value in [core.get("lot_number"), vehicle_summary.get("lot_number")]:
text = self._as_str(value)
if text:

View File

@@ -10,9 +10,9 @@ logger = logging.getLogger("iaai_scraper.parsers")
class VehicleParser:
# Парсер данных страницы автомобиля.
# Парсер страницы авто.
# Регулярные выражения для парсинга и детекции защиты.
# Регулярки парсинга и защиты.
_BUY_NOW_RE = re.compile(r"Buy\s+Now[:\s]*\$\s*([\d,]+(?:\.\d{1,2})?)", re.IGNORECASE)
_CAPTCHA_TOKENS = frozenset(["captcha", "verify you are human", "i am human", "recaptcha", "cloudflare"])
_ANTIBOT_TOKENS = frozenset(["incapsula", "access denied", "request unsuccessful", "bot detection"])
@@ -101,11 +101,11 @@ class VehicleParser:
max_fields = len(set(self.DOM_LABEL_MAP.values()))
for i, line in enumerate(lines):
# Ранний выход, когда уже нашли все поля.
# Ранний выход.
if len(result) >= max_fields:
break
# Сценарий 1: "метка: значение" в одной строке.
# Метка и значение в одной строке.
colon_pos = line.find(":")
if colon_pos > 0:
label_part = line[:colon_pos].strip().lower()
@@ -116,7 +116,7 @@ class VehicleParser:
result[field_name] = value_part
continue
# Сценарий 2: метка и значение на соседних строках.
# Метка и значение в соседних строках.
clean = line.rstrip(":").strip().lower()
clean_alt = clean.rstrip("#").strip()
matched_label = None
@@ -164,7 +164,7 @@ class VehicleParser:
page_title = title_match.group(1).strip()
title_parsed = self._parse_title_for_year_make_model(page_title, dom_text)
# Один проход по payload для всех полей.
# Один проход по payload.
all_found = deep_find_all_keys(payloads, self.SUMMARY_KEY_MAP)
summary: dict[str, Any] = {"source_url": vehicle_url}
@@ -199,7 +199,7 @@ class VehicleParser:
p = item.get("payload")
if isinstance(p, (dict, list)):
payloads.append(p)
# Дополнительный проход по встроенному JSON.
# Доп. проход по JSON.
extra = deep_find_all_keys([p], self.SUMMARY_KEY_MAP)
for field, vals in extra.items():
if not summary.get(field):
@@ -207,7 +207,7 @@ class VehicleParser:
if v:
summary[field] = v
# Передаём image_urls без повторного извлечения.
# Передаём готовые image_urls.
image_urls = summary.get("image_urls") or []
return {
"vehicle_summary": summary,
@@ -325,7 +325,7 @@ class VehicleParser:
for script_text in scripts:
if "{" not in script_text and "[" not in script_text:
continue
# Пропускаем слишком большие минифицированные блоки.
# Пропускаем большие блоки.
if len(script_text) > 51_200:
continue
try:

View File

@@ -1,69 +0,0 @@
import logging
import time
from datetime import datetime, timezone, timedelta
from .core.config import settings
from .discovery_service import DiscoveryService
from .fetch_service import FetchService
from .enrichment_service import EnrichmentService
logger = logging.getLogger("iaai_scraper.scheduler_service")
class SchedulerService:
"""Сервис для планирования и координации ingestion pipeline."""
def __init__(self):
self.discovery = DiscoveryService()
self.fetch = FetchService()
self.enrichment = EnrichmentService()
def run_full_pipeline(self):
"""Запускает полный цикл ingestion: discovery -> fetch -> enrichment."""
logger.info("Starting full ingestion pipeline")
try:
# 1. Discovery phase
logger.info("Phase 1: Discovery")
discovered_count = self.discovery.discover_new_vehicles()
logger.info(f"Discovered {discovered_count} new candidates")
# 2. Fetch phase
logger.info("Phase 2: Fetch")
fetched_count = self.fetch.process_pending_candidates(limit=50)
logger.info(f"Successfully fetched {fetched_count} candidates")
# 3. Enrichment phase
logger.info("Phase 3: Enrichment")
enriched_count = self.enrichment.process_unparsed_snapshots(limit=50)
logger.info(f"Successfully enriched {enriched_count} snapshots")
logger.info("Ingestion pipeline completed")
except Exception as e:
logger.error(f"Pipeline failed: {e}")
raise
def run_continuous_pipeline(self, interval_minutes: int = 30):
"""Запускает непрерывный цикл ingestion с интервалом."""
logger.info(f"Starting continuous ingestion pipeline with {interval_minutes}min intervals")
while True:
try:
self.run_full_pipeline()
except Exception as e:
logger.error(f"Pipeline iteration failed: {e}")
logger.info(f"Sleeping for {interval_minutes} minutes")
time.sleep(interval_minutes * 60)
def run_targeted_enrichment(self):
"""Запускает только enrichment для существующих snapshots."""
logger.info("Running targeted enrichment")
enriched_count = self.enrichment.process_unparsed_snapshots(limit=100)
logger.info(f"Enriched {enriched_count} snapshots")
def cleanup_old_data(self, days_to_keep: int = 30):
"""Очищает старые данные (опционально)."""
# TODO: implement if needed
pass

File diff suppressed because it is too large Load Diff

View File

@@ -14,19 +14,13 @@ from .schemas import CarRecord
logger = logging.getLogger("iaai_scraper.db")
def get_db_session() -> Session:
"""Получить новую сессию базы данных."""
settings = Settings()
persistence = PersistenceService(settings)
return persistence.session_factory()
CAR_DB_FIELDS = {
col.key for col in Car.__table__.columns
if col.key not in ("id",)
}
_IN_CHUNK_SIZE = 5000
CAR_TABLE_NAME = Car.__tablename__
class PersistenceService:
@@ -42,11 +36,16 @@ class PersistenceService:
engine_kwargs["max_overflow"] = settings.database.max_overflow
engine_kwargs["pool_pre_ping"] = True
engine_kwargs["pool_recycle"] = settings.database.pool_recycle_seconds
engine_kwargs["pool_timeout"] = 30
# Таймауты запросов и блокировок.
engine_kwargs["connect_args"] = {
"options": "-c statement_timeout=120000 -c lock_timeout=30000"
}
self.engine = create_engine(settings.database.url, **engine_kwargs)
self.session_factory = sessionmaker(bind=self.engine, expire_on_commit=False, future=True)
def create_tables(self) -> None:
# В тестах/локально на SQLite разрешаем create_all; для non-SQLite в проде — только через миграции.
# Для SQLite можно create_all.
is_sqlite = self.settings.database.url.startswith("sqlite")
if not is_sqlite and not self.settings.database.auto_create_tables:
return
@@ -112,7 +111,7 @@ class PersistenceService:
def get_existing_urls_and_ids(
self, origin_urls: list[str], origin_ids: list[str],
) -> tuple[set[str], set[str]]:
# Загрузка существующих URL и origin_id.
# Загрузка URL и origin_id.
if not origin_urls and not origin_ids:
return set(), set()
urls: set[str] = set()
@@ -321,7 +320,7 @@ class PersistenceService:
return {"inserted": inserted, "updated": updated, "images_upserted": images_total}
def upsert_car(self, record: CarRecord):
# Вставка или обновление автомобиля по origin_id/origin_url.
# Вставка или обновление авто.
payload = self._car_payload(record)
images = [image.model_dump(mode="python") for image in record.images]
with self.session_scope() as session:
@@ -382,7 +381,7 @@ class PersistenceService:
- Один DELETE по car_id IN (...) вместо удаления по одному.
- Fallback на по-одному upsert если batch commit упал.
"""
# ── Дедупликация записей внутри батча ──
# Дедупликация батча.
seen_ids: dict[str, int] = {}
unique_records: list[CarRecord] = []
for idx, r in enumerate(records):
@@ -413,20 +412,20 @@ class PersistenceService:
images_total = 0
with self.session_scope() as session:
# Получаем существующие записи chunked-запросами.
# Загружаем существующие записи.
origin_ids = [r.origin_id for r in records if r.origin_id]
origin_urls = [r.origin_url for r in records if r.origin_url]
existing_by_id, existing_by_url = self._load_existing_cars(session, origin_ids, origin_urls)
# Предзагружаем ВСЕ изображения для обновляемых машин одним запросом.
# Предзагружаем изображения.
existing_car_ids = set()
for record in records:
car = existing_by_id.get(record.origin_id) or existing_by_url.get(record.origin_url)
if car is not None:
existing_car_ids.add(int(car.id))
# Строим маппинг car_id → set(image_urls) для сравнения.
# Готовим map car_id -> image_urls.
existing_images_map = self._load_existing_image_urls(session, existing_car_ids)
new_cars: list[tuple[Car, list[dict]]] = []
@@ -448,7 +447,7 @@ class PersistenceService:
car.last_seen_at = record.last_seen_at
updated += 1
# Проверяем, изменились ли изображения.
# Проверяем изменения картинок.
new_image_urls = {img.get("fullres_image", "") for img in images}
old_image_urls = existing_images_map.get(int(car.id), set())
if new_image_urls != old_image_urls:
@@ -456,15 +455,15 @@ class PersistenceService:
else:
images_total += len(old_image_urls)
# Один flush для всех вставок.
# Один flush.
session.flush()
# Добавляем изображения для новых автомобилей.
# Добавляем картинки новым авто.
for car, images in new_cars:
self._add_images(session, int(car.id), images)
images_total += len(images)
# Массово обновляем изображения только для машин с изменёнными картинками.
# Обновляем только изменённые картинки.
if update_cars_needing_images:
update_ids = [int(car.id) for car, _ in update_cars_needing_images]
for i in range(0, len(update_ids), _IN_CHUNK_SIZE):
@@ -477,7 +476,7 @@ class PersistenceService:
return {"inserted": inserted, "updated": updated, "images_upserted": images_total}
def _upsert_cars_individually(self, records: list[CarRecord]) -> dict[str, int]:
# Fallback на поштучный upsert.
# Запасной поштучный upsert.
inserted = 0
updated = 0
images_total = 0
@@ -517,72 +516,107 @@ class PersistenceService:
Для PostgreSQL использует временную таблицу + LEFT JOIN вместо NOT IN,
что кардинально быстрее при больших объёмах (100K+ URLs).
Встроенная защита: нормализация URL (тильда/дефис) + safety-check на аномальный процент.
"""
if not active_origin_urls:
return 0
# Нормализация URL.
def _norm(url: str) -> str:
return url.replace("~", "-")
normalized_urls = {_norm(u) for u in active_origin_urls}
is_postgres = "postgresql" in self.settings.database.url
with self.session_scope() as session:
# Страховка от ложного mark_sold при битом/неполном full-scan:
# если текущий список активных URL аномально мал относительно уже активных машин в БД,
# ничего не помечаем проданным.
active_db_count = int(session.execute(
select(func.count())
.select_from(Car)
.where(Car.is_sold == False) # noqa: E712
.where(Car.origin_id.like(f"{lane}:%"))
).scalar_one() or 0)
if is_postgres:
# Считаем кандидатов на sold.
total_active = session.execute(
text(f"SELECT count(*) FROM {CAR_TABLE_NAME} WHERE is_sold = FALSE AND origin_id LIKE 'iaai:%%'")
).scalar() or 0
current_active_count = len(active_origin_urls)
if active_db_count >= 1000 and current_active_count < max(500, int(active_db_count * 0.25)):
logger.warning(
"Skipping mark_sold: suspiciously small active set (%d URLs vs %d active in DB)",
current_active_count,
active_db_count,
if total_active == 0:
return 0
# Временная таблица URL.
session.execute(text("CREATE TEMP TABLE IF NOT EXISTS _active_urls (url TEXT NOT NULL) ON COMMIT DROP"))
session.execute(text("TRUNCATE _active_urls"))
# Вставляем URL чанками.
url_list = list(normalized_urls)
for i in range(0, len(url_list), _IN_CHUNK_SIZE):
chunk = url_list[i:i + _IN_CHUNK_SIZE]
values = ",".join(f"(:{f'u{j}'})" for j in range(len(chunk)))
params = {f"u{j}": url for j, url in enumerate(chunk)}
session.execute(text(f"INSERT INTO _active_urls (url) VALUES {values}"), params)
# Индекс для JOIN.
session.execute(text("CREATE INDEX IF NOT EXISTS _ix_active_urls ON _active_urls (url)"))
# Считаем будущие sold.
would_mark = session.execute(text("""
SELECT count(*)
FROM {car_table} c
LEFT JOIN _active_urls a ON replace(c.origin_url, '~', '-') = a.url
WHERE a.url IS NULL
AND c.is_sold = FALSE
AND c.origin_id LIKE 'iaai:%%'
""".format(car_table=CAR_TABLE_NAME))).scalar() or 0
# Защита от аномалии.
if total_active > 100 and would_mark > total_active * 0.8:
logger.error(
"mark_sold safety abort: would mark %d/%d (%.0f%%) as sold — likely URL format mismatch",
would_mark, total_active, would_mark / total_active * 100,
)
return 0
if is_postgres:
# Создаём временную таблицу с активными URL.
session.execute(text("CREATE TEMP TABLE IF NOT EXISTS _active_urls (url TEXT PRIMARY KEY) ON COMMIT DROP"))
session.execute(text("TRUNCATE _active_urls"))
# Вставляем активные URL чанками через executemany.
url_list = list(active_origin_urls)
for i in range(0, len(url_list), _IN_CHUNK_SIZE):
chunk = url_list[i:i + _IN_CHUNK_SIZE]
session.execute(
text("INSERT INTO _active_urls (url) VALUES (:url) ON CONFLICT DO NOTHING"),
[{"url": url} for url in chunk],
)
# Массовая пометка проданных в PostgreSQL.
# Массовая пометка sold.
result = session.execute(text("""
UPDATE cars
UPDATE {car_table}
SET is_sold = TRUE
FROM (
SELECT c.id
FROM cars c
LEFT JOIN _active_urls a ON c.origin_url = a.url
FROM {car_table} c
LEFT JOIN _active_urls a ON replace(c.origin_url, '~', '-') = a.url
WHERE a.url IS NULL
AND c.is_sold = FALSE
AND c.origin_id LIKE :lane_prefix
AND c.origin_id LIKE 'iaai:%%'
) sub
WHERE cars.id = sub.id
"""), {"lane_prefix": f"{lane}:%"})
WHERE {car_table}.id = sub.id
""".format(car_table=CAR_TABLE_NAME)))
count = result.rowcount or 0
else:
# Упрощённый путь для SQLite.
stmt = (
update(Car)
.where(Car.origin_url.notin_(active_origin_urls))
.where(Car.is_sold == False) # noqa: E712
.where(Car.origin_id.like(f"{lane}:%"))
.where(Car.origin_id.like("iaai:%"))
.values(is_sold=True)
)
result = session.execute(stmt)
count = result.rowcount or 0
# Загружаем active URL.
all_active = session.execute(
select(Car.id, Car.origin_url).where(
Car.is_sold == False, Car.origin_id.like("iaai:%") # noqa: E712
)
).all()
mark_ids = [row[0] for row in all_active if _norm(row[1]) not in normalized_urls]
if not mark_ids:
return 0
total_active = len(all_active)
if total_active > 100 and len(mark_ids) > total_active * 0.8:
logger.error(
"mark_sold safety abort: would mark %d/%d (%.0f%%) as sold — likely URL format mismatch",
len(mark_ids), total_active, len(mark_ids) / total_active * 100,
)
return 0
for i in range(0, len(mark_ids), _IN_CHUNK_SIZE):
chunk = mark_ids[i:i + _IN_CHUNK_SIZE]
session.execute(update(Car).where(Car.id.in_(chunk)).values(is_sold=True))
count = len(mark_ids)
if count:
logger.info("Marked %d cars as sold by URL (no longer in listing)", count)
@@ -600,40 +634,43 @@ class PersistenceService:
return {str(row[0]) for row in result if row and row[0]}
def get_all_active_origin_urls_for_lane(self, prefix: str = "iaai:") -> set[str]:
"""Возвращает все активные (не sold) origin_url для указанного lane/prefix."""
"""Возвращает origin_url всех активных (не проданных) авто для заданного lane-префикса."""
with self.session_scope() as session:
result = session.execute(
select(Car.origin_url)
.where(Car.origin_id.like(f"{prefix}%"))
.where(Car.is_sold == False) # noqa: E712
.execution_options(yield_per=10000)
select(Car.origin_url).where(
Car.origin_id.like(f"{prefix}%"),
Car.is_sold == False, # noqa: E712
).execution_options(yield_per=10000)
)
return {str(row[0]) for row in result if row and row[0]}
def count_active_cars_for_lane(self, prefix: str = "iaai:") -> int:
"""Возвращает количество активных (не проданных) авто для заданного lane-префикса."""
from sqlalchemy import func as sa_func
with self.session_scope() as session:
result = session.execute(
select(sa_func.count()).select_from(Car).where(
Car.origin_id.like(f"{prefix}%"),
Car.is_sold == False, # noqa: E712
)
)
return int(result.scalar() or 0)
def get_active_origin_urls_batch_for_refresh(
self,
*,
prefix: str = "iaai:",
offset: int = 0,
limit: int = 3000,
limit: int = 500,
) -> list[str]:
"""Возвращает батч активных origin_url для циклического hourly refresh."""
with self.session_scope() as session:
rows = session.execute(
select(Car.origin_url)
.where(Car.origin_id.like(f"{prefix}%"))
.where(Car.is_sold == False) # noqa: E712
.order_by(Car.last_seen_at.asc(), Car.id.asc())
.offset(max(0, int(offset)))
.limit(max(1, int(limit)))
).all()
return [str(row[0]) for row in rows if row and row[0]]
"""Возвращает батч origin_url активных авто для rolling refresh.
def count_active_cars_for_lane(self, prefix: str = "iaai:") -> int:
Сортировка по last_seen_at ASC — давно не обновлённые идут первыми.
"""
with self.session_scope() as session:
return int(session.execute(
select(func.count())
.select_from(Car)
.where(Car.origin_id.like(f"{prefix}%"))
.where(Car.is_sold == False) # noqa: E712
).scalar_one() or 0)
result = session.execute(
select(Car.origin_url).where(
Car.origin_id.like(f"{prefix}%"),
Car.is_sold == False, # noqa: E712
).order_by(Car.last_seen_at.asc()).offset(offset).limit(limit)
)
return [str(row[0]) for row in result if row and row[0]]

View File

@@ -20,10 +20,10 @@ class Base(DeclarativeBase):
class Car(Base):
__tablename__ = "cars"
__tablename__ = "iaai_cars"
__table_args__ = (
Index("ix_cars_brand_model", "brand", "model"),
Index("ix_cars_origin_id_not_sold", "origin_id", "is_sold"),
Index("ix_iaai_cars_brand_model", "brand", "model"),
Index("ix_iaai_cars_origin_id_not_sold", "origin_id", "is_sold"),
)
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
parser_id: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
@@ -59,17 +59,17 @@ class Car(Base):
class Image(Base):
__tablename__ = "images"
__tablename__ = "iaai_images"
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
fullres_image: Mapped[str] = mapped_column(String(), nullable=False)
preview_image: Mapped[str] = mapped_column(String(), nullable=False)
order_index: Mapped[int] = mapped_column(Integer, nullable=False)
car_id: Mapped[int] = mapped_column(Integer, ForeignKey("cars.id", ondelete="CASCADE"), nullable=False, index=True)
car_id: Mapped[int] = mapped_column(Integer, ForeignKey("iaai_cars.id", ondelete="CASCADE"), nullable=False, index=True)
car: Mapped[Car] = relationship("Car", back_populates="images")
class SyncRun(Base):
__tablename__ = "sync_runs"
__tablename__ = "iaai_sync_runs"
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now())
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -80,65 +80,3 @@ class SyncRun(Base):
cars_failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
images_upserted: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
class VehicleCandidate(Base):
__tablename__ = "vehicle_candidates"
__table_args__ = (
Index("ix_vehicle_candidates_url", "url"),
Index("ix_vehicle_candidates_status_discovered", "status", "discovered_at"),
)
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
url: Mapped[str] = mapped_column(String(), nullable=False, unique=True, index=True)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True) # pending, processing, processed, failed
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
raw_snapshots: Mapped[list["VehicleRawSnapshot"]] = relationship("VehicleRawSnapshot", back_populates="candidate", cascade="all, delete-orphan")
class VehicleRawSnapshot(Base):
__tablename__ = "vehicle_raw_snapshots"
__table_args__ = (
Index("ix_vehicle_raw_snapshots_candidate_id", "candidate_id"),
Index("ix_vehicle_raw_snapshots_captured_at", "captured_at"),
)
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
candidate_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle_candidates.id", ondelete="CASCADE"), nullable=False, index=True)
candidate: Mapped[VehicleCandidate] = relationship("VehicleCandidate", back_populates="raw_snapshots")
captured_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True)
method: Mapped[str] = mapped_column(String(20), nullable=False) # http, browser
success: Mapped[bool] = mapped_column(Boolean, nullable=False)
raw_data: Mapped[str | None] = mapped_column(Text, nullable=True) # HTML or JSON content
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
parse_results: Mapped[list["VehicleParseResult"]] = relationship("VehicleParseResult", back_populates="snapshot", cascade="all, delete-orphan")
class VehicleParseResult(Base):
__tablename__ = "vehicle_parse_results"
__table_args__ = (
Index("ix_vehicle_parse_results_snapshot_id", "snapshot_id"),
Index("ix_vehicle_parse_results_parsed_at", "parsed_at"),
)
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
snapshot_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle_raw_snapshots.id", ondelete="CASCADE"), nullable=False, index=True)
snapshot: Mapped[VehicleRawSnapshot] = relationship("VehicleRawSnapshot", back_populates="parse_results")
parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now(), index=True)
success: Mapped[bool] = mapped_column(Boolean, nullable=False)
parsed_data: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON with parsed vehicle data
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
class VehicleRetryQueue(Base):
__tablename__ = "vehicle_retry_queue"
__table_args__ = (
Index("ix_vehicle_retry_queue_candidate_id", "candidate_id"),
Index("ix_vehicle_retry_queue_retry_at", "retry_at"),
)
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
candidate_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle_candidates.id", ondelete="CASCADE"), nullable=False, index=True)
reason: Mapped[str] = mapped_column(String(50), nullable=False) # parse_failed, capture_failed, etc.
retry_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)

View File

@@ -1,6 +1,9 @@
# Инициализация Celery-приложения и периодических задач.
import json
import logging
import os
import time
from celery import Celery
from celery.signals import worker_process_init, worker_ready, setup_logging as celery_setup_logging
@@ -11,6 +14,56 @@ from ..core.logs import setup_logging
logger = logging.getLogger("iaai_scraper.worker.celery_app")
STARTUP_SYNC_DISPATCH_KEY = "iaai:state:startup_sync_dispatched"
IAAI_SYNC_QUEUE = "iaai_sync"
PROGRESS_KEY_PREFIX = "iaai:state:task_progress:"
SYNC_LISTING_LOCK_KEY = "iaai:locks:sync_listing"
SYNC_FULL_SCAN_DONE_KEY = "iaai:state:sync_full_scan_done"
SYNC_LAST_COMPLETED_AT_KEY = "iaai:state:sync_listing_last_completed_at"
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name, "true" if default else "false").strip().lower()
return raw in {"1", "true", "yes", "on"}
def _has_fresh_active_progress(redis_client: Redis, *, max_age_seconds: int = 180) -> bool:
now = int(time.time())
try:
for raw_key in redis_client.scan_iter(f"{PROGRESS_KEY_PREFIX}*"):
payload = redis_client.get(raw_key)
if not payload:
continue
try:
progress = json.loads(payload)
except (TypeError, ValueError):
continue
ts = int(progress.get("ts") or 0)
stage = str(progress.get("stage") or "")
if ts > 0 and now - ts <= max_age_seconds and stage not in {"segment_done", "sync_done", "failed"}:
return True
except Exception:
logger.warning("Failed to inspect startup progress keys", exc_info=True)
return False
def _seconds_until_next_allowed_sync(redis_client: Redis) -> int:
"""Запрещает новый автозапуск раньше чем через интервал beat после завершения полного run."""
min_interval = max(0, int(settings.celery.beat_sync_interval_minutes * 60))
if min_interval <= 0:
return 0
try:
full_done = str(redis_client.get(SYNC_FULL_SCAN_DONE_KEY) or "").strip().lower() in {"1", "true", "yes", "on"}
if not full_done:
return 0
completed_raw = redis_client.get(SYNC_LAST_COMPLETED_AT_KEY)
if not completed_raw:
return 0
completed_at = int(float(completed_raw))
except Exception:
logger.warning("Failed to inspect last sync completion timestamp", exc_info=True)
return 0
elapsed = int(time.time()) - completed_at
return max(0, min_interval - elapsed)
@celery_setup_logging.connect
@@ -48,7 +101,7 @@ _soft = settings.celery.task_soft_time_limit
_hard = settings.celery.task_time_limit
_max_hard = _soft + 120 if _soft else _hard
if _hard > _max_hard:
logger.warning(
logger.info(
"CELERY_TASK_TIME_LIMIT=%d too far from CELERY_TASK_SOFT_TIME_LIMIT=%d; "
"clamping hard limit to %d",
_hard, _soft, _max_hard,
@@ -68,7 +121,7 @@ celery_app.conf.update(
task_track_started=True,
worker_concurrency=settings.celery.worker_concurrency,
worker_max_tasks_per_child=settings.celery.worker_max_tasks_per_child,
worker_pool="prefork",
worker_pool=settings.celery.worker_pool,
worker_prefetch_multiplier=1,
broker_connection_retry_on_startup=True,
broker_transport_options={
@@ -79,15 +132,19 @@ celery_app.conf.update(
worker_hijack_root_logger=False,
beat_schedule={
"periodic-sync-listing": {
"task": "iaai_scraper.worker.tasks.sync_listing_task",
"task": "iaai.sync_cars_feed",
"schedule": settings.celery.beat_sync_interval_minutes * 60.0,
"args": (),
"kwargs": {"limit": settings.celery.beat_sync_limit, "only_new": False},
"options": {"queue": "scraping"},
"options": {
"queue": IAAI_SYNC_QUEUE,
"expires": settings.celery.beat_sync_interval_minutes * 60.0,
},
}
},
task_routes={
"iaai_scraper.worker.tasks.*": {"queue": "scraping"}
"iaai.sync_cars_feed": {"queue": IAAI_SYNC_QUEUE},
"iaai_scraper.worker.tasks.*": {"queue": IAAI_SYNC_QUEUE},
},
)
@@ -96,8 +153,12 @@ celery_app.autodiscover_tasks(["iaai_scraper.worker"])
@worker_ready.connect
def _on_worker_ready(**kwargs):
"""Сразу при старте worker отправляем первую задачу sync_listing,
чтобы не ждать час до первого beat-цикла."""
"""При старте worker отправляем первый sync_listing, если очередь пуста."""
if not _env_bool("IAAI_STARTUP_SYNC_ENABLED", True):
logger.info("Worker ready: startup sync dispatch disabled by IAAI_STARTUP_SYNC_ENABLED")
return
redis_client = None
try:
redis_client = Redis.from_url(
settings.redis.url,
@@ -107,10 +168,50 @@ def _on_worker_ready(**kwargs):
health_check_interval=settings.redis.health_check_interval_seconds,
retry_on_timeout=True,
)
has_fresh_progress = _has_fresh_active_progress(redis_client)
next_allowed_delay = _seconds_until_next_allowed_sync(redis_client)
if next_allowed_delay > 0:
logger.info(
"Worker ready: last full sync finished recently; next auto sync allowed in %ss, skip startup dispatch",
next_allowed_delay,
)
return
for stale_key in (SYNC_LISTING_LOCK_KEY,):
try:
ttl = redis_client.ttl(stale_key)
if ttl is not None and ttl != -2 and not has_fresh_progress:
redis_client.delete(stale_key)
logger.info("Cleared stale lock on startup: %s (ttl was %s)", stale_key, ttl)
elif ttl is not None and ttl != -2:
logger.info("Keeping sync lock on startup because fresh active progress exists: %s (ttl=%s)", stale_key, ttl)
except Exception:
logger.warning("Failed to inspect stale lock %s on startup", stale_key, exc_info=True)
try:
queue_len = int(redis_client.llen(IAAI_SYNC_QUEUE) or 0)
except Exception:
queue_len = 0
if queue_len > 0:
logger.info("Worker ready: iaai_sync queue already has %d task(s); skip startup dispatch", queue_len)
return
should_dispatch = bool(redis_client.set(STARTUP_SYNC_DISPATCH_KEY, "1", nx=True, ex=600))
if not should_dispatch and not has_fresh_progress:
redis_client.delete(STARTUP_SYNC_DISPATCH_KEY)
should_dispatch = bool(redis_client.set(STARTUP_SYNC_DISPATCH_KEY, "1", nx=True, ex=600))
if should_dispatch:
logger.info("Worker ready: stale startup dedupe key ignored because queue is empty and no fresh active progress exists")
except Exception:
logger.warning("Worker ready startup sync dedupe check failed; skipping immediate dispatch", exc_info=True)
return
finally:
if redis_client is not None:
try:
redis_client.close()
except Exception:
pass
if not should_dispatch:
logger.info("Worker ready immediate sync already dispatched recently; skipping duplicate enqueue")
@@ -118,7 +219,8 @@ def _on_worker_ready(**kwargs):
logger.info("Worker ready — dispatching initial sync_listing task")
celery_app.send_task(
"iaai_scraper.worker.tasks.sync_listing_task",
"iaai.sync_cars_feed",
kwargs={"limit": settings.celery.beat_sync_limit, "only_new": False},
queue="scraping",
queue=IAAI_SYNC_QUEUE,
expires=settings.celery.beat_sync_interval_minutes * 60.0,
)

View File

@@ -0,0 +1,479 @@
from __future__ import annotations
import concurrent.futures
import logging
import random
import time
from dataclasses import dataclass
from typing import Any, Callable
from .browser.fast_client import FastListingVehicle, IAAIFastClient
from .core.runtime_config import RuntimeConfig
from .parsing.fast_mapper import INACTIVE_STATUS_VALUES, FastCarMapper
from .storage.db import PersistenceService
from .storage.schemas import CarRecord
logger = logging.getLogger("iaai_scraper.fast_sync")
@dataclass(slots=True)
class FastSyncStats:
ids_fetched: int = 0
cars_upserted: int = 0
cars_failed: int = 0
cars_filtered: int = 0
images_upserted: int = 0
skipped_existing: int = 0
protection_events: int = 0
def passes_condition_check(row: FastListingVehicle) -> bool:
if row.timed_auction_closed:
return False
status = (row.inventory_status or "").strip().upper()
return status not in INACTIVE_STATUS_VALUES
class FastSyncEngine:
"""Full iaai-fast style sync pipeline adapted to this project's storage.
Flow: hidden listing payloads -> concurrent ProductDetailsVM HTTP fetch ->
CarRecord preparation in memory -> single batch DB upsert. Browser is used
only inside IAAIFastClient to refresh cookies when IAAI challenge appears.
"""
def __init__(
self,
*,
client: IAAIFastClient,
mapper: FastCarMapper,
persistence: PersistenceService,
batch_size: int,
fetch_concurrency: int,
report_progress: Callable[[str, Any], None] | None = None,
) -> None:
self.client = client
self.mapper = mapper
self.persistence = persistence
self.batch_size = max(1, int(batch_size))
self.fetch_concurrency = max(1, int(fetch_concurrency))
self.report_progress = report_progress
@staticmethod
def _is_transient_detail_error(exc: Exception) -> bool:
text = str(exc).lower()
return any(
marker in text
for marker in (
"sslerror",
"ssleoferror",
"unexpected_eof_while_reading",
"eof occurred in violation of protocol",
"max retries exceeded",
"read timed out",
"readtimeout",
"connection reset",
"connection aborted",
"connection closed",
"temporarily unavailable",
"too many requests",
"status=429",
"status=500",
"status=502",
"status=503",
"status=504",
)
)
def sync_listing(
self,
*,
runtime_config: RuntimeConfig,
make: str | None = None,
model: str | None = None,
lane: str = "iaai_cars",
limit: int | None = None,
only_new: bool = False,
listing_url: str | None = None,
max_pages: int | None = None,
skip_mark_sold: bool = False,
) -> dict[str, Any]:
del lane
started_at = time.perf_counter()
stats = FastSyncStats()
errors: list[dict[str, str]] = []
selected: dict[str, FastListingVehicle] = {}
rows_seen = 0
rows_skipped_condition = 0
filters = runtime_config.filters
logger.info(
"Fast HTTP-first listing started: make=%s listing_url=%s max_pages=%s concurrency=%s batch_size=%s",
make or "ALL",
listing_url or "default",
max_pages,
self.fetch_concurrency,
self.batch_size,
)
for vehicle in self.client.iter_listing_vehicles(
listing_start_url=listing_url,
make=make,
max_pages=max_pages,
):
rows_seen += 1
if runtime_config.sync.condition_check_enabled and not passes_condition_check(vehicle):
rows_skipped_condition += 1
continue
if vehicle.inventory_id in selected:
continue
selected[vehicle.inventory_id] = vehicle
if limit is not None and limit > 0 and len(selected) >= limit:
break
candidates = list(selected.values())
all_listing_origin_urls = {f"https://www.iaai.com/VehicleDetail/{v.inventory_id}" for v in candidates}
if only_new and candidates:
origin_urls = [f"https://www.iaai.com/VehicleDetail/{v.inventory_id}" for v in candidates]
origin_ids = [f"iaai:{v.inventory_id}" for v in candidates]
existing_urls, existing_ids = self.persistence.get_existing_urls_and_ids(origin_urls, origin_ids)
fresh: list[FastListingVehicle] = []
for vehicle in candidates:
if f"https://www.iaai.com/VehicleDetail/{vehicle.inventory_id}" in existing_urls or f"iaai:{vehicle.inventory_id}" in existing_ids:
stats.skipped_existing += 1
continue
fresh.append(vehicle)
candidates = fresh
self._progress(
"fast_listing_collected",
rows_seen=rows_seen,
rows_filtered_condition=rows_skipped_condition,
rows_selected=len(candidates),
skipped_existing=stats.skipped_existing,
)
logger.info(
"Fast HTTP-first listing collected: rows_seen=%d selected=%d skipped_existing=%d filtered_condition=%d only_new=%s",
rows_seen,
len(candidates),
stats.skipped_existing,
rows_skipped_condition,
only_new,
)
scan_completed = not (limit is not None and limit > 0) and not errors
if not candidates:
return self._result(
started_at=started_at,
stats=stats,
failures=errors,
listing={
"mode": "fast_hidden_payload",
"vehicles_collected": 0,
"vehicle_urls": [],
"early_stopped": False,
"truncated_by_time_budget": False,
"rows_seen": rows_seen,
"rows_filtered_condition": rows_skipped_condition,
},
all_listing_origin_urls=all_listing_origin_urls,
full_scan_completed=scan_completed,
)
prepared_rows: list[CarRecord] = []
db_processed = 0
retry_candidates: list[FastListingVehicle] = []
transient_failure_log_count = 0
started_details = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=min(self.fetch_concurrency, len(candidates))) as executor:
future_to_vehicle = {
executor.submit(self._fetch_detail_payload, vehicle.inventory_id): vehicle
for vehicle in candidates
}
for index, future in enumerate(concurrent.futures.as_completed(future_to_vehicle), start=1):
vehicle = future_to_vehicle[future]
try:
payload = future.result()
record = self.mapper.map_payload_to_record(
detail_payload=payload,
vehicle_url=f"https://www.iaai.com/VehicleDetail/{vehicle.inventory_id}",
listing_vehicle=vehicle,
)
if model and model.casefold() not in record.model.casefold():
stats.cars_filtered += 1
continue
if not filters.matches({
"brand": record.brand,
"model": record.model,
"year": record.year,
"body_type": record.body_type,
"color": record.color,
"drive": record.drive,
"gearbox": record.gearbox,
"price": record.price,
"mileage": record.mileage,
}):
stats.cars_filtered += 1
continue
prepared_rows.append(record)
stats.ids_fetched += 1
if len(prepared_rows) >= self.batch_size:
db_processed += self._flush_db_records(
rows=prepared_rows,
stats=stats,
errors=errors,
processed=db_processed + len(prepared_rows),
total=len(candidates),
)
prepared_rows.clear()
except Exception as exc:
stats.cars_failed += 1
errors.append({"vehicle_url": f"https://www.iaai.com/VehicleDetail/{vehicle.inventory_id}", "error": str(exc)})
if _looks_like_protection(exc):
stats.protection_events += 1
if self._is_transient_detail_error(exc):
retry_candidates.append(vehicle)
transient_failure_log_count += 1
if transient_failure_log_count % 100 == 0:
logger.warning(
"Fast detail transient failures queued for retry: count=%d latest_inventory_id=%s",
transient_failure_log_count,
vehicle.inventory_id,
)
else:
logger.exception("Fast detail parse failed inventory_id=%s: %s", vehicle.inventory_id, exc)
if index % 100 == 0 or index == len(candidates):
elapsed = max(0.001, time.perf_counter() - started_details)
if self.client._settings.scraping_profile.verbose_progress_logs:
logger.info(
"Fast HTTP-first details progress: processed=%d/%d ok=%d failed=%d queued_db=%d rate=%.2f/s",
index,
len(candidates),
stats.ids_fetched,
stats.cars_failed,
len(prepared_rows),
index / elapsed,
)
self._progress(
"fast_detail_progress",
processed=index,
total=len(candidates),
ids_fetched=stats.ids_fetched,
cars_failed=stats.cars_failed,
queued_for_db=len(prepared_rows),
throughput=round(index / elapsed, 2),
)
if retry_candidates:
retry_started = time.perf_counter()
retry_workers = max(1, min(8, self.fetch_concurrency // 2, len(retry_candidates)))
retry_errors: list[dict[str, str]] = []
logger.info(
"Fast HTTP-first retrying transient detail failures: total=%d workers=%d",
len(retry_candidates),
retry_workers,
)
with concurrent.futures.ThreadPoolExecutor(max_workers=retry_workers) as executor:
future_to_vehicle = {
executor.submit(self._fetch_detail_payload, vehicle.inventory_id): vehicle
for vehicle in retry_candidates
}
for retry_index, future in enumerate(concurrent.futures.as_completed(future_to_vehicle), start=1):
vehicle = future_to_vehicle[future]
try:
payload = future.result()
record = self.mapper.map_payload_to_record(
detail_payload=payload,
vehicle_url=f"https://www.iaai.com/VehicleDetail/{vehicle.inventory_id}",
listing_vehicle=vehicle,
)
if model and model.casefold() not in record.model.casefold():
stats.cars_filtered += 1
continue
if not filters.matches({
"brand": record.brand,
"model": record.model,
"year": record.year,
"body_type": record.body_type,
"color": record.color,
"drive": record.drive,
"gearbox": record.gearbox,
"price": record.price,
"mileage": record.mileage,
}):
stats.cars_filtered += 1
continue
prepared_rows.append(record)
stats.ids_fetched += 1
stats.cars_failed = max(0, stats.cars_failed - 1)
if len(prepared_rows) >= self.batch_size:
db_processed += self._flush_db_records(
rows=prepared_rows,
stats=stats,
errors=errors,
processed=db_processed + len(prepared_rows),
total=len(candidates),
)
prepared_rows.clear()
except Exception as exc:
retry_errors.append({
"vehicle_url": f"https://www.iaai.com/VehicleDetail/{vehicle.inventory_id}",
"error": str(exc),
})
if _looks_like_protection(exc):
stats.protection_events += 1
if retry_index % 100 == 0 or retry_index == len(retry_candidates):
elapsed = max(0.001, time.perf_counter() - retry_started)
logger.info(
"Fast HTTP-first retry progress: processed=%d/%d recovered=%d remaining_failed=%d rate=%.2f/s",
retry_index,
len(retry_candidates),
len(retry_candidates) - len(retry_errors),
len(retry_errors),
retry_index / elapsed,
)
transient_urls = {f"https://www.iaai.com/VehicleDetail/{v.inventory_id}" for v in retry_candidates}
errors = [error for error in errors if error.get("vehicle_url") not in transient_urls]
errors.extend(retry_errors)
if prepared_rows:
db_processed += self._flush_db_records(
rows=prepared_rows,
stats=stats,
errors=errors,
processed=db_processed + len(prepared_rows),
total=len(candidates),
)
prepared_rows.clear()
self.client.persist_session_state()
scan_completed = not (limit is not None and limit > 0) and not errors
mark_sold_scope_partial = bool(
(limit is not None and limit > 0)
or make
or model
or listing_url
or only_new
)
if all_listing_origin_urls and not mark_sold_scope_partial and not skip_mark_sold and scan_completed:
try:
sold_count = self.persistence.mark_sold_not_in_listing_by_urls(all_listing_origin_urls, lane="iaai")
except Exception as exc:
sold_count = 0
logger.warning("Fast sold reconcile failed: %s", exc)
else:
sold_count = 0
listing = {
"mode": "fast_hidden_payload",
"vehicles_collected": len(candidates),
"vehicle_urls": [f"https://www.iaai.com/VehicleDetail/{v.inventory_id}" for v in candidates],
"early_stopped": False,
"truncated_by_time_budget": False,
"rows_seen": rows_seen,
"rows_filtered_condition": rows_skipped_condition,
"sold_marked": sold_count,
}
return self._result(
started_at=started_at,
stats=stats,
failures=errors,
listing=listing,
all_listing_origin_urls=all_listing_origin_urls,
full_scan_completed=scan_completed,
)
def _result(
self,
*,
started_at: float,
stats: FastSyncStats,
failures: list[dict[str, str]],
listing: dict[str, Any],
all_listing_origin_urls: set[str],
full_scan_completed: bool,
) -> dict[str, Any]:
status = "success" if not failures else ("partial_success" if stats.cars_upserted else "failed")
total = int(listing.get("vehicles_collected") or 0)
fail_ratio = (stats.cars_failed / total) if total > 0 else 0.0
protection_ratio = (stats.protection_events / total) if total > 0 else 0.0
anti_bot_detected = total > 0 and ((stats.protection_events >= 30 and protection_ratio >= 0.10) or fail_ratio >= 0.30)
return {
"status": status,
"listing": listing,
"total": total,
"total_discovered": total,
"skipped_existing": stats.skipped_existing,
"cars_upserted": stats.cars_upserted,
"cars_failed": stats.cars_failed,
"cars_filtered": stats.cars_filtered,
"images_upserted": stats.images_upserted,
"protection_events": stats.protection_events,
"failures": failures,
"all_listing_origin_urls": all_listing_origin_urls,
"full_scan_completed": full_scan_completed and not anti_bot_detected,
"anti_bot_detected": anti_bot_detected,
"fail_ratio": round(fail_ratio, 4),
"protection_ratio": round(protection_ratio, 4),
"elapsed_seconds": round(time.perf_counter() - started_at, 3),
}
def _progress(self, stage: str, **meta: Any) -> None:
if self.report_progress is None:
return
try:
self.report_progress(stage, **meta)
except Exception:
pass
def _fetch_detail_payload(self, inventory_id: str) -> dict[str, Any]:
jitter = float(self.client._settings.scraping_profile.request_jitter_max_s)
if jitter > 0:
time.sleep(random.uniform(0.0, jitter))
return self.client.fetch_vehicle_detail_payload(inventory_id)
def _flush_db_records(
self,
*,
rows: list[CarRecord],
stats: FastSyncStats,
errors: list[dict[str, str]],
processed: int,
total: int,
) -> int:
if not rows:
return 0
batch = list(rows)
try:
upsert = self.persistence.upsert_cars_batch(batch)
stats.cars_upserted += int(upsert.get("inserted", 0)) + int(upsert.get("updated", 0))
stats.images_upserted += int(upsert.get("images_upserted", 0))
except Exception as exc:
stats.cars_failed += len(batch)
errors.append({"vehicle_url": f"db_batch_{processed - len(batch)}", "error": str(exc)})
logger.exception("Fast DB apply failed processed=%s size=%s: %s", processed, len(batch), exc)
self._progress(
"fast_db_progress",
processed=processed,
total=total,
cars_upserted=stats.cars_upserted,
cars_failed=stats.cars_failed,
images_upserted=stats.images_upserted,
)
logger.info(
"Fast HTTP-first DB batch: processed=%d/%d batch=%d upserted=%d failed=%d images=%d",
processed,
total,
len(batch),
stats.cars_upserted,
stats.cars_failed,
stats.images_upserted,
)
return len(batch)
def _looks_like_protection(exc: Exception) -> bool:
message = str(exc).lower()
return any(token in message for token in ("captcha", "antibot", "challenge", "blocked", "403", "429", "incapsula"))

View File

@@ -0,0 +1,346 @@
import json
import logging
import os
import random
import signal
import time
from redis import Redis
logger = logging.getLogger("iaai_scraper.worker.self_heal")
IAAI_SYNC_QUEUE = "iaai_sync"
GLOBAL_PROGRESS_TS_KEY = "iaai:state:last_progress_ts"
GLOBAL_DB_PROGRESS_TS_KEY = "iaai:state:last_db_progress_ts"
SELF_HEAL_RESTART_LOCK_KEY = "iaai:state:self_heal_restart_in_progress"
SYNC_LISTING_LOCK_KEY = "iaai:locks:sync_listing"
SYNC_FULL_SCAN_DONE_KEY = "iaai:state:sync_full_scan_done"
SYNC_LISTING_CHECKPOINT_KEY = "iaai:state:sync_listing_checkpoint"
SYNC_LISTING_FOLLOWUP_PENDING_KEY = "iaai:state:sync_listing_followup_pending"
SIGKILL_FALLBACK = getattr(signal, "SIGKILL", signal.SIGTERM)
TERMINAL_PROGRESS_STAGES = {
"segment_done",
"segment_failed",
"segment_task_completed",
"segment_task_failed",
"segment_task_soft_timeout",
"sync_done",
"failed",
}
def _env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int:
value = os.getenv(name)
if value is None:
return default
try:
return int(value.strip())
except Exception:
return default
def _get_redis() -> Redis:
url = os.getenv("IAAI_REDIS_URL", "redis://redis:6379/0")
return Redis.from_url(
url,
decode_responses=True,
socket_connect_timeout=5.0,
socket_timeout=10.0,
health_check_interval=30,
retry_on_timeout=True,
)
def _safe_int(value: str | None, default: int = 0) -> int:
if value is None:
return default
try:
return int(str(value).strip())
except Exception:
return default
def _read_last_progress_ts(redis_client: Redis) -> int | None:
raw = redis_client.get(GLOBAL_PROGRESS_TS_KEY)
if raw:
ts = _safe_int(raw)
if ts > 0:
return ts
# Fallback: если глобальный ключ не найден, берём max(ts) из task_progress:*.
# Это дороже, но выполняется только при отсутствии основного маркера.
max_ts = 0
for key in redis_client.scan_iter(match="iaai:state:task_progress:*"):
try:
payload = redis_client.get(key)
if not payload:
continue
data = json.loads(payload)
ts = _safe_int(data.get("ts"), 0)
if ts > max_ts:
max_ts = ts
except Exception:
continue
return max_ts or None
def _read_last_db_progress_ts(redis_client: Redis) -> int | None:
raw = redis_client.get(GLOBAL_DB_PROGRESS_TS_KEY)
if raw:
ts = _safe_int(raw)
if ts > 0:
return ts
max_ts = 0
for key in redis_client.scan_iter(match="iaai:state:task_progress:*"):
try:
payload = redis_client.get(key)
if not payload:
continue
data = json.loads(payload)
if str(data.get("stage") or "") == "fast_db_progress":
ts = _safe_int(data.get("ts"), 0)
else:
ts = _safe_int(data.get("last_db_progress_ts"), 0)
if ts > max_ts:
max_ts = ts
except Exception:
continue
return max_ts or None
def _has_active_recent_progress(redis_client: Redis, now_ts: int, stall_seconds: int) -> bool:
"""Return True when a task is still reporting non-DB progress.
Hourly only_new runs can legitimately skip every listed vehicle as existing.
Those runs may not emit fast_db_progress for a long time, but they still emit
regular listing/segment progress. Treating old DB timestamps as fatal caused
the watchdog to kill healthy production workers during such scans.
"""
for key in redis_client.scan_iter(match="iaai:state:task_progress:*"):
try:
payload = redis_client.get(key)
if not payload:
continue
data = json.loads(payload)
stage = str(data.get("stage") or "")
if stage in {"failed", "sync_done", "segment_task_failed", "segment_task_soft_timeout"}:
continue
ts = _safe_int(data.get("ts"), 0)
if ts > 0 and now_ts - ts <= max(60, int(stall_seconds)):
return True
except Exception:
continue
return False
def _reset_bootstrap_checkpoint_for_db_idle(redis_client: Redis) -> None:
pipe = redis_client.pipeline()
pipe.delete(SYNC_LISTING_CHECKPOINT_KEY)
pipe.delete(SYNC_LISTING_FOLLOWUP_PENDING_KEY)
pipe.delete(GLOBAL_PROGRESS_TS_KEY)
pipe.delete(GLOBAL_DB_PROGRESS_TS_KEY)
pipe.set(SYNC_FULL_SCAN_DONE_KEY, "0")
pipe.execute()
def _has_inflight_work(redis_client: Redis, queue_name: str) -> tuple[bool, dict[str, int]]:
"""Есть ли признаки активной/зависшей работы, даже если очередь пуста."""
queue_len = _safe_int(redis_client.llen(queue_name), 0)
has_lock = 1 if redis_client.get(SYNC_LISTING_LOCK_KEY) else 0
has_task_progress = 0
progress_keys_seen = 0
stale_progress_deleted = 0
stale_seconds = max(180, min(_env_int("IAAI_SELF_HEAL_STALL_SECONDS", 720), 1800))
now_ts = int(time.time())
for key in redis_client.scan_iter(match="iaai:state:task_progress:*"):
progress_keys_seen += 1
if queue_len > 0 or has_lock == 1:
has_task_progress = 1
break
try:
payload = redis_client.get(key)
if not payload:
continue
data = json.loads(payload)
stage = str(data.get("stage") or "")
ts = _safe_int(data.get("ts"), 0)
is_terminal = stage in TERMINAL_PROGRESS_STAGES
is_stale = ts <= 0 or now_ts - ts > stale_seconds
if is_terminal or is_stale:
redis_client.delete(key)
stale_progress_deleted += 1
continue
has_task_progress = 1
break
except Exception:
# Битые progress payload не должны держать worker в вечном false-stall цикле.
try:
redis_client.delete(key)
stale_progress_deleted += 1
except Exception:
pass
flags = {
"queue_len": queue_len,
"has_lock": has_lock,
"has_task_progress": has_task_progress,
"progress_keys_seen": progress_keys_seen,
"stale_progress_deleted": stale_progress_deleted,
}
return (queue_len > 0 or has_lock == 1 or has_task_progress == 1), flags
def _kill_worker_process() -> None:
pid_file = "/tmp/celery-worker.pid"
pid: int | None = None
try:
with open(pid_file, "r", encoding="utf-8") as f:
pid = int(f.read().strip())
except Exception:
pid = None
if not pid:
logger.error("Self-heal: failed to read worker pid from %s", pid_file)
return
logger.error("Self-heal: terminating stuck worker process pid=%s", pid)
try:
os.kill(pid, signal.SIGTERM)
except Exception:
logger.exception("Self-heal: failed to send SIGTERM to pid=%s", pid)
return
time.sleep(20)
try:
# Если процесс ещё жив — принудительно убиваем.
os.kill(pid, 0)
logger.error("Self-heal: worker pid=%s did not stop after SIGTERM; sending SIGKILL", pid)
os.kill(pid, SIGKILL_FALLBACK)
except ProcessLookupError:
pass
except Exception:
logger.exception("Self-heal: failed to send SIGKILL to pid=%s", pid)
def main() -> None:
if not _env_bool("IAAI_SELF_HEAL_ENABLED", True):
logger.info("Self-heal watchdog disabled via IAAI_SELF_HEAL_ENABLED")
return
queue_name = os.getenv("IAAI_CELERY_QUEUE", IAAI_SYNC_QUEUE)
check_interval = max(5, _env_int("IAAI_SELF_HEAL_CHECK_INTERVAL_SECONDS", 30))
stall_seconds = max(180, _env_int("IAAI_SELF_HEAL_STALL_SECONDS", 720))
db_idle_seconds = max(60, _env_int("IAAI_DB_IDLE_RESTART_SECONDS", 3600))
startup_grace = max(30, _env_int("IAAI_SELF_HEAL_STARTUP_GRACE_SECONDS", 300))
restart_cooldown = max(60, _env_int("IAAI_SELF_HEAL_RESTART_COOLDOWN_SECONDS", 300))
logger.info(
"Self-heal watchdog enabled: queue=%s check_interval=%ss stall=%ss db_idle=%ss startup_grace=%ss cooldown=%ss",
queue_name,
check_interval,
stall_seconds,
db_idle_seconds,
startup_grace,
restart_cooldown,
)
started_at = time.time()
redis_client: Redis | None = None
while True:
try:
if redis_client is None:
redis_client = _get_redis()
redis_client.ping()
has_inflight, inflight = _has_inflight_work(redis_client, queue_name)
if not has_inflight:
time.sleep(check_interval)
continue
last_progress_ts = _read_last_progress_ts(redis_client)
now_ts = int(time.time())
age = None if last_progress_ts is None else max(0, now_ts - int(last_progress_ts))
if age is None:
if now_ts - int(started_at) < startup_grace:
time.sleep(check_interval)
continue
logger.warning(
"Self-heal: inflight=%s but no progress timestamp found after startup grace",
inflight,
)
age = stall_seconds + 1
restart_reason = f"progress_age={age}s > {stall_seconds}s"
db_idle_restart = False
if age <= stall_seconds:
# If the task is actively reporting progress, do not require DB writes.
# Hourly only_new listing scans often skip already-known cars and can
# legitimately have no DB writes while still moving through segments.
if _has_active_recent_progress(redis_client, now_ts, stall_seconds):
time.sleep(check_interval)
continue
last_db_ts = _read_last_db_progress_ts(redis_client)
db_age = None if last_db_ts is None else max(0, now_ts - int(last_db_ts))
if db_age is None:
first_allowed_ts = int(started_at) + max(startup_grace, db_idle_seconds)
if now_ts < first_allowed_ts:
time.sleep(check_interval)
continue
db_age = db_idle_seconds + 1
if db_age <= db_idle_seconds:
time.sleep(check_interval)
continue
db_idle_restart = True
restart_reason = f"db_idle_age={db_age}s > {db_idle_seconds}s"
# Глобальный anti-storm lock: чтобы много воркеров не рестартились одновременно.
acquired = bool(
redis_client.set(
SELF_HEAL_RESTART_LOCK_KEY,
str(now_ts),
nx=True,
ex=restart_cooldown,
)
)
if not acquired:
time.sleep(check_interval)
continue
logger.error(
"Self-heal: detected stall (inflight=%s, %s). Restarting worker process...",
inflight,
restart_reason,
)
if db_idle_restart:
logger.error("Self-heal: no DB writes for too long; clearing checkpoint to restart from segment 1")
_reset_bootstrap_checkpoint_for_db_idle(redis_client)
# Небольшой джиттер, чтобы при одинаковом событии у разных контейнеров
# перезапуск был не строго одновременно.
time.sleep(random.uniform(0.3, 2.0))
_kill_worker_process()
# После kill pid1 контейнер будет перезапущен Docker restart-policy.
# На случай неуспеха не молотим цикл.
time.sleep(check_interval)
except Exception:
logger.exception("Self-heal watchdog iteration failed")
redis_client = None
time.sleep(check_interval)
if __name__ == "__main__":
logging.basicConfig(
level=os.getenv("IAAI_LOG_LEVEL", "INFO"),
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
main()

File diff suppressed because it is too large Load Diff

View File

@@ -11,18 +11,16 @@ requires-python = ">=3.11"
dependencies = [
"alembic>=1.14.0",
"celery>=5.4.0",
"curl-cffi>=0.11.0",
"fastapi>=0.115.0",
"playwright>=1.53.0",
"psycopg2-binary>=2.9.9",
"pydantic>=2.8.2",
"python-dotenv>=1.0.1",
"redis>=5.2.0",
"requests>=2.32.0",
"sqlalchemy>=2.0.32",
"uvicorn>=0.34.0",
"urllib3>=2.0.0",
"orjson>=3.10.0",
"brotli>=1.1.0",
]
[project.optional-dependencies]

View File

@@ -1,6 +1,6 @@
{
"sync": {
"name": null,
"name": "https://www.iaai.com/Search?url=B%2bU066dM8%2flZtRvnzTwIEi8Pib9%2fM2fLpCTQDIgQRm4%3d",
"ids_initial_size": null,
"ids_next_size": null,
"ids_max_pages": null,

38
status_vps.py Normal file
View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import os
import paramiko
HOST = "2.26.123.84"
USER = "root"
PASSWORD = os.environ["VPS_PASSWORD"]
commands = [
"cd /root/iaai-parser && docker compose ps",
"cd /root/iaai-parser && docker compose exec -T worker celery -A iaai_scraper.worker.celery_app inspect active reserved scheduled",
"docker exec -i iaai-postgres psql -U iaai -d iaai_scraper -c \"SELECT now() AS ts, (SELECT count(*) FROM iaai_cars) AS cars, (SELECT count(*) FROM iaai_sync_runs) AS runs, (SELECT status FROM iaai_sync_runs ORDER BY id DESC LIMIT 1) AS last_status, (SELECT cars_upserted FROM iaai_sync_runs ORDER BY id DESC LIMIT 1) AS last_upserted, (SELECT cars_failed FROM iaai_sync_runs ORDER BY id DESC LIMIT 1) AS last_failed, (SELECT started_at FROM iaai_sync_runs ORDER BY id DESC LIMIT 1) AS last_started;\"",
"docker logs --since 10m iaai-parser-worker-1 2>&1 | tail -n 160",
]
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
HOST,
username=USER,
password=PASSWORD,
look_for_keys=False,
allow_agent=False,
timeout=30,
banner_timeout=30,
auth_timeout=30,
)
try:
for command in commands:
print(f"\n=== REMOTE_RUN {command} ===", flush=True)
stdin, stdout, stderr = client.exec_command(command, timeout=180)
exit_code = stdout.channel.recv_exit_status()
print(stdout.read().decode("utf-8", "replace"), end="")
print(stderr.read().decode("utf-8", "replace"), end="")
print(f"\n=== EXIT {exit_code} ===", flush=True)
finally:
client.close()

117
tests/test_fast_client.py Normal file
View File

@@ -0,0 +1,117 @@
from __future__ import annotations
import json
from iaai_scraper.browser.fast_client import (
DETAIL_MARKER,
LISTING_MARKER,
build_resizer_images_from_keys,
is_challenge_response,
parse_listing_page,
parse_product_details_vm,
)
from iaai_scraper.parsing.fast_mapper import FastCarMapper
def test_parse_listing_page_extracts_hidden_payloads() -> None:
gbp = {"CurrentPage": 1, "PageSize": 100}
vehicles = [
{
"Id": "45078011~US",
"Tenant": "US",
"InventoryStatus": "RS",
"Currency": "USD",
"PreBidIndicator": True,
}
]
html = (
f'<input id="GBPSearchQuery" value="{json.dumps(gbp).replace(chr(34), "&quot;")}" />'
f'<input id="VehicleDetails" value="{json.dumps(vehicles).replace(chr(34), "&quot;")}" />'
'<input id="ResultCount" value="1" />'
'<input id="PageSize" value="100" />'
'<input id="CurrentPage" value="1" />'
)
parsed = parse_listing_page(html)
assert parsed.result_count == 1
assert parsed.page_size == 100
assert parsed.current_page == 1
assert parsed.vehicles[0].inventory_id == "45078011~US"
assert parsed.vehicles[0].inventory_status == "RS"
def test_parse_product_details_vm_and_map_record() -> None:
payload = {
"inventoryView": {
"attributes": {
"Id": "45078011~US",
"Year": "2002",
"Make": "ACURA",
"Model": "RSX",
"Series": "BASE",
"Currency": "USD",
"ODOValue": "219187",
"ExteriorColor": "GRAY",
"DriveLineTypeDesc": "FWD",
"Transmission": "Automatic",
"BodyStyleName": "COUPE",
"EngineSize": "2.0L I-4",
"VehicleGrade": "50",
"PrimaryDamageDesc": "NORMAL WEAR & TEAR",
},
"imageDimensions": {
"keys": {
"$values": [
{"k": "45078011~US~I1", "w": 1600, "h": 1200, "i": 0},
]
}
},
},
"auctionInformation": {
"prebidInformation": {"decimalHighBidAmount": "600", "highBidAmount": "$600"},
"biddingInformation": {"buyNowPrice": "$0"},
},
}
html = f'<script id="ProductDetailsVM" type="application/json">{json.dumps(payload)}</script>'
parsed = parse_product_details_vm(html)
record = FastCarMapper().map_payload_to_record(
detail_payload=parsed,
vehicle_url="https://www.iaai.com/VehicleDetail/45078011~US",
)
assert record.origin_id == "iaai:45078011~US"
assert record.brand == "ACURA"
assert record.model == "RSX BASE"
assert record.year == 2002
assert record.price == 600
assert record.drive == "FWD"
assert record.gearbox == "AT"
assert record.body_type == "COUPE"
assert record.engine_volume == 2000
assert record.is_damaged is False
assert len(record.images) == 1
def test_build_resizer_images_from_keys_deduplicates_and_orders() -> None:
keys = [
{"k": "abc~1", "w": 2000, "h": 1500, "i": 2},
{"k": "abc~1", "w": 2000, "h": 1500, "i": 2},
{"k": "abc~2", "w": 1800, "h": 1200, "i": 1},
]
images = build_resizer_images_from_keys(keys)
assert len(images) == 2
assert images[0]["order_index"] == 1
assert "imageKeys=abc~2" in str(images[0]["fullres_image"])
def test_is_challenge_response_marker_rules() -> None:
assert is_challenge_response(status_code=403, body_text="", expected_marker=None) is True
assert is_challenge_response(status_code=200, body_text="<html>blocked</html>", expected_marker=LISTING_MARKER) is True
assert is_challenge_response(
status_code=200,
body_text=f'<html>{DETAIL_MARKER}<script src="/_Incapsula_Resource"></script></html>',
expected_marker=DETAIL_MARKER,
) is False

141
tests/test_fast_sync.py Normal file
View File

@@ -0,0 +1,141 @@
from __future__ import annotations
from dataclasses import dataclass
from iaai_scraper.browser.fast_client import FastListingVehicle
from iaai_scraper.core.config import Settings
from iaai_scraper.core.runtime_config import RuntimeConfig, RuntimeFiltersConfig
from iaai_scraper.fast_sync import FastSyncEngine
from iaai_scraper.parsing.fast_mapper import FastCarMapper
def _listing_vehicle(inventory_id: str, *, status: str = "RS") -> FastListingVehicle:
return FastListingVehicle(
inventory_id=inventory_id,
tenant="US",
auction_id="1_1",
auction_date="2026-04-08T08:30:00+00:00",
inventory_status=status,
currency="USD",
timed_auction_closed=False,
timed_auction_indicator=False,
prebid_indicator=True,
buynow_indicator=False,
)
def _detail_payload(inventory_id: str, *, make: str = "ACURA", model: str = "RSX", bid: int = 500) -> dict:
return {
"inventoryView": {
"attributes": {
"Id": inventory_id,
"Year": "2002",
"Make": make,
"Model": model,
"Series": "BASE",
"Currency": "USD",
"ODOValue": "219187",
"ExteriorColor": "GRAY",
"DriveLineTypeDesc": "FWD",
"Transmission": "Automatic",
"BodyStyleName": "COUPE",
"EngineSize": "2.0L I-4",
"VehicleGrade": "50",
"PrimaryDamageDesc": "NORMAL WEAR & TEAR",
},
"imageDimensions": {
"keys": {"$values": [{"k": f"{inventory_id}~I1", "w": 1600, "h": 1200, "i": 0}]}
},
},
"auctionInformation": {
"prebidInformation": {"decimalHighBidAmount": str(bid), "highBidAmount": f"${bid}"},
"biddingInformation": {"buyNowPrice": "$0"},
},
}
class FakeFastClient:
def __init__(self, listing: list[FastListingVehicle], details: dict[str, dict]) -> None:
self.listing = listing
self.details = details
self.detail_calls = 0
self.persist_calls = 0
def iter_listing_vehicles(self, *, listing_start_url=None, make=None, max_pages=None): # noqa: ANN001, ANN202
del listing_start_url, make, max_pages
return iter(self.listing)
def fetch_vehicle_detail_payload(self, inventory_id: str) -> dict:
self.detail_calls += 1
return self.details[inventory_id]
def persist_session_state(self) -> None:
self.persist_calls += 1
@dataclass
class FakePersistence:
inserted: int = 0
images: int = 0
def get_existing_urls_and_ids(self, origin_urls, origin_ids): # noqa: ANN001, ANN202
del origin_urls, origin_ids
return set(), set()
def upsert_cars_batch(self, records): # noqa: ANN001, ANN202
self.inserted += len(records)
self.images += sum(len(record.images) for record in records)
return {"inserted": len(records), "updated": 0, "images_upserted": sum(len(record.images) for record in records)}
def mark_sold_not_in_listing_by_urls(self, active_origin_urls, lane="iaai"): # noqa: ANN001, ANN202
del active_origin_urls, lane
return 0
def test_fast_sync_engine_collects_details_and_bulk_upserts() -> None:
listing = [_listing_vehicle("45078011~US"), _listing_vehicle("45268167~US")]
details = {
"45078011~US": _detail_payload("45078011~US", make="ACURA", model="RSX", bid=500),
"45268167~US": _detail_payload("45268167~US", make="BMW", model="X5", bid=900),
}
client = FakeFastClient(listing, details)
persistence = FakePersistence()
engine = FastSyncEngine(
client=client, # type: ignore[arg-type]
mapper=FastCarMapper(),
persistence=persistence, # type: ignore[arg-type]
batch_size=10,
fetch_concurrency=2,
)
result = engine.sync_listing(runtime_config=RuntimeConfig(), only_new=False)
assert result["status"] == "success"
assert result["cars_upserted"] == 2
assert result["images_upserted"] == 2
assert result["listing"]["mode"] == "fast_hidden_payload"
assert client.detail_calls == 2
assert client.persist_calls == 1
def test_fast_sync_engine_applies_runtime_filters_before_db() -> None:
listing = [_listing_vehicle("45078011~US"), _listing_vehicle("45268167~US")]
details = {
"45078011~US": _detail_payload("45078011~US", make="ACURA", model="RSX", bid=500),
"45268167~US": _detail_payload("45268167~US", make="BMW", model="X5", bid=900),
}
runtime = RuntimeConfig(filters=RuntimeFiltersConfig.from_dict({"brands": ["BMW"]}))
persistence = FakePersistence()
engine = FastSyncEngine(
client=FakeFastClient(listing, details), # type: ignore[arg-type]
mapper=FastCarMapper(),
persistence=persistence, # type: ignore[arg-type]
batch_size=10,
fetch_concurrency=2,
)
result = engine.sync_listing(runtime_config=runtime, only_new=False)
assert result["cars_upserted"] == 1
assert result["cars_filtered"] == 1
assert persistence.inserted == 1

View File

@@ -8,83 +8,26 @@ from iaai_scraper.browser.listing import ListingCollector
class _FakePage:
def __init__(self, counts: dict[str, int], attrs: dict[str, dict[str, str]] | None = None) -> None:
def __init__(self, counts: dict[str, int]) -> None:
self._counts = counts
self._attrs = attrs or {}
self._evaluate_result = False
self._evaluate_values: list[object] = []
self._html = ""
self.url = "https://www.iaai.com/Vehiclelisting/Cars"
self._current_page_number: int | None = None
self._vehicle_hrefs: list[str] = []
self._wait_for_function_error: Exception | None = None
self._clicks: dict[str, int] = {}
self._goto_calls: list[str] = []
class _Locator:
def __init__(self, page: "_FakePage", selector: str, count_value: int) -> None:
self._page = page
self._selector = selector
def __init__(self, count_value: int) -> None:
self._count_value = count_value
@property
def first(self) -> "_FakePage._Locator":
return self
def count(self) -> int:
return self._count_value
def is_visible(self, timeout: int | None = None) -> bool:
_ = timeout
return False
def locator(self, selector: str) -> "_FakePage._Locator":
return _FakePage._Locator(self._counts.get(selector, 0))
def get_attribute(self, name: str, timeout: int | None = None) -> str | None:
_ = timeout
return self._page._attrs.get(self._selector, {}).get(name)
def click(self, timeout: int | None = None) -> None:
_ = timeout
self._page._clicks[self._selector] = self._page._clicks.get(self._selector, 0) + 1
return None
class _HtmlLocator:
def __init__(self, html: str) -> None:
self._html = html
def inner_html(self, timeout: int | None = None) -> str:
_ = timeout
return self._html
def locator(self, selector: str):
if selector == "html":
return _FakePage._HtmlLocator(self._html)
return _FakePage._Locator(self, selector, self._counts.get(selector, 0))
def evaluate(self, script: str, *args):
_ = args
def evaluate(self, _script: str):
if self._evaluate_values:
return self._evaluate_values.pop(0)
if "querySelectorAll" in script and "VehicleDetail" in script:
return list(self._vehicle_hrefs)
if "document.body?.innerText" in script and "aria-current" in script and "parseInt" in script:
return self._current_page_number if self._current_page_number is not None else self._evaluate_result
return self._evaluate_result
def wait_for_selector(self, selector: str, timeout: int | None = None) -> None:
_ = selector, timeout
return None
def wait_for_function(self, script: str, timeout: int | None = None) -> None:
_ = script, timeout
if self._wait_for_function_error is not None:
raise self._wait_for_function_error
return None
def goto(self, url: str, wait_until: str | None = None, timeout: int | None = None) -> None:
_ = wait_until, timeout
self._goto_calls.append(url)
self.url = url
class TestListingUnit(unittest.TestCase):
def test_pagination_detection_and_page_number(self) -> None:
@@ -120,67 +63,6 @@ class TestListingUnit(unittest.TestCase):
],
)
def test_has_next_page_from_html(self) -> None:
collector = ListingCollector(Settings(), HumanPacer(Settings()))
html = '<a class="pagination-next" href="/Vehiclelisting/Cars?page=43">Next</a>'
self.assertTrue(collector._has_next_page_from_html(html, current_page_number=42))
self.assertFalse(collector._has_next_page_from_html("<div>done</div>", current_page_number=42))
def test_build_listing_page_url_replaces_existing_page_parameter(self) -> None:
collector = ListingCollector(Settings(), HumanPacer(Settings()))
url = collector._build_listing_page_url(
"https://www.iaai.com/Vehiclelisting/Cars?Make=TOYOTA&page=43&foo=bar",
44,
)
self.assertEqual(
url,
"https://www.iaai.com/Vehiclelisting/Cars?Make=TOYOTA&foo=bar&page=44",
)
def test_collect_current_page_uses_html_first(self) -> None:
collector = ListingCollector(Settings(), HumanPacer(Settings()))
page = _FakePage({})
page._html = '<a href="/VehicleDetail/555~US">Car 555</a><a class="pagination-next" href="/Vehiclelisting/Cars?page=2">Next</a>'
result = collector.collect_current_page(page, page_number=1)
self.assertEqual(len(result.vehicle_links), 1)
self.assertEqual(result.vehicle_links[0].lot_number, "555")
self.assertTrue(result.next_page_detected)
def test_wait_for_navigation_result_rejects_duplicate_content_even_when_page_number_matches(self) -> None:
page = _FakePage({})
page._wait_for_function_error = RuntimeError("same content")
page._current_page_number = 41
page._vehicle_hrefs = ["/VehicleDetail/111~US", "/VehicleDetail/222~US"]
self.assertFalse(
ListingCollector._wait_for_navigation_result(
page,
"/VehicleDetail/111~US",
41,
old_fingerprint=("/VehicleDetail/111~US", "/VehicleDetail/222~US"),
)
)
def test_go_to_next_page_skips_flaky_ui_fallbacks_on_deep_pages(self) -> None:
collector = ListingCollector(Settings(), HumanPacer(Settings()))
page = _FakePage(
{ListingCollector._NEXT_PAGE_SELECTORS[0]: 1, "a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']": 1},
attrs={
"a[href*='/VehicleDetail/'], a[href*='/vehicledetail/'], a[href*='VehicleDetail'], a[href*='vehicledetail']": {"href": "/VehicleDetail/111~US"},
},
)
page._wait_for_function_error = RuntimeError("same content")
page._current_page_number = 41
page._vehicle_hrefs = ["/VehicleDetail/111~US", "/VehicleDetail/222~US"]
self.assertFalse(collector.go_to_next_page(page, expected_page_number=41))
self.assertGreaterEqual(len(page._goto_calls), 1)
self.assertEqual(page._clicks.get(ListingCollector._NEXT_PAGE_SELECTORS[0], 0), 0)
if __name__ == "__main__":
unittest.main()

79
tests/test_resilience.py Normal file
View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from iaai_scraper.scraper import IAAIScraper
from iaai_scraper.core.config import Settings
from iaai_scraper.worker import tasks
class TestResilience(unittest.TestCase):
def _make_scraper(self) -> IAAIScraper:
s = Settings()
s.log_level = "CRITICAL"
s.database.url = "sqlite://"
return IAAIScraper(s)
def test_update_task_progress_updates_global_marker(self) -> None:
redis_client = MagicMock()
pipe = MagicMock()
redis_client.pipeline.return_value = pipe
tasks._update_task_progress(
redis_client,
task_id="task-abc",
stage="batch_upserted",
ttl_seconds=180,
cars_upserted=10,
)
redis_client.pipeline.assert_called_once()
self.assertEqual(pipe.set.call_count, 2)
first_call = pipe.set.call_args_list[0]
second_call = pipe.set.call_args_list[1]
self.assertEqual(first_call.args[0], tasks._task_progress_key("task-abc"))
self.assertEqual(second_call.args[0], tasks.GLOBAL_PROGRESS_TS_KEY)
pipe.execute.assert_called_once()
def test_streaming_sync_stops_cleanly_when_page_stays_empty(self) -> None:
scraper = self._make_scraper()
scraper.settings.celery.batch_size = 50
page = MagicMock()
empty_page_result = SimpleNamespace(
page_number=1,
vehicle_links=[],
next_page_detected=True,
)
scraper._open_listing_for_stream = MagicMock(return_value=(
page,
{"make": None, "model": None, "year_min": None, "year_max": None},
))
scraper.listing_collector.collect_current_page = MagicMock(return_value=empty_page_result)
scraper._recover_empty_listing_page = MagicMock(return_value=(empty_page_result, []))
scraper.sync_batch = MagicMock()
result = scraper._sync_listing_streaming(
make=None,
model=None,
lane="iaai_cars",
limit=None,
effective_only_new=False,
started_at=0.0,
listing_url="https://www.iaai.com/Vehiclelisting/Cars?Make=TEST",
)
self.assertEqual(result["total"], 0)
self.assertEqual(result["cars_upserted"], 0)
self.assertEqual(result["cars_failed"], 0)
self.assertEqual(result["listing"]["pages_collected"], 1)
scraper._recover_empty_listing_page.assert_called_once()
scraper.sync_batch.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,13 +1,12 @@
from __future__ import annotations
import unittest
from concurrent.futures import TimeoutError as FuturesTimeoutError
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from iaai_scraper.core.config import Settings
from iaai_scraper.core.exceptions import AntiBotDetectedError, SiteStructureChangedError
from iaai_scraper.core.exceptions import ListingResumeError
from iaai_scraper.discovery import SitemapDiscoveryError
from iaai_scraper.scraper import IAAIScraper
from iaai_scraper.storage.schemas import CarRecord
@@ -87,51 +86,6 @@ class TestScraperSync(unittest.TestCase):
scraper2._sync_listing_streaming.assert_called_once()
scraper2.collect_listing.assert_not_called()
def test_full_scan_uses_sitemap_discovery_path(self) -> None:
scraper = self._make_scraper()
scraper.settings.discovery.mode = "listing"
scraper.persistence.create_tables = MagicMock()
scraper.persistence.start_sync_run = MagicMock(return_value=88)
scraper.persistence.finish_sync_run = MagicMock()
scraper.persistence.mark_sold_not_in_listing_by_urls = MagicMock(return_value=0)
scraper._sync_listing_via_sitemap = MagicMock(return_value={
"listing": {
"vehicles_collected": 123,
"early_stopped": False,
"truncated_by_time_budget": False,
"pagination_interrupted": False,
"source": "sitemap",
},
"total": 123,
"skipped_existing": 0,
"cars_upserted": 120,
"cars_failed": 3,
"images_upserted": 500,
"failures": [{"vehicle_url": "v", "error": "e"}],
"all_listing_origin_urls": {"https://www.iaai.com/VehicleDetail/111~US"},
})
scraper._sync_listing_streaming = MagicMock(side_effect=AssertionError("pagination path should not be used"))
result = scraper.sync_listing()
scraper._sync_listing_via_sitemap.assert_called_once()
scraper._sync_listing_streaming.assert_not_called()
self.assertEqual(result["cars_upserted"], 120)
self.assertTrue(result["full_scan_completed"])
def test_full_scan_does_not_fallback_to_pagination_when_sitemap_fails(self) -> None:
scraper = self._make_scraper()
scraper.persistence.create_tables = MagicMock()
scraper.persistence.start_sync_run = MagicMock(return_value=89)
scraper.persistence.finish_sync_run = MagicMock()
scraper.persistence.mark_sold_not_in_listing_by_urls = MagicMock(return_value=0)
scraper._sync_listing_via_sitemap = MagicMock(side_effect=SitemapDiscoveryError("sitemap down"))
result = scraper.sync_listing()
scraper._sync_listing_via_sitemap.assert_called_once()
self.assertEqual(result["status"], "failed")
self.assertEqual(result["cars_upserted"], 0)
def test_segmented_sync_calls_per_segment_and_resumes(self) -> None:
scraper = self._make_scraper()
scraper.persistence.create_tables = MagicMock()
@@ -315,125 +269,50 @@ class TestScraperSync(unittest.TestCase):
self.assertEqual(result["cars_upserted"], 1)
self.assertEqual(result["total"], 1)
def test_sync_listing_marks_pagination_interrupted_when_next_page_resume_fails(self) -> None:
def test_sync_batch_timeout_does_not_duplicate_already_processed_urls(self) -> None:
scraper = self._make_scraper()
scraper.settings.celery.batch_size = 1000
page = MagicMock()
page_result = SimpleNamespace(
page_number=1,
vehicle_links=[SimpleNamespace(href="https://www.iaai.com/VehicleDetail/999~US", lot_number="999")],
next_page_detected=True,
)
scraper._get_page_with_warmup = MagicMock(return_value=page)
scraper.listing_collector.open_cars_listing = MagicMock()
scraper.listing_collector.apply_filters = MagicMock(return_value={
"make": None,
"model": None,
"year_min": None,
"year_max": None,
})
scraper.listing_collector.collect_current_page = MagicMock(return_value=page_result)
scraper.listing_collector.go_to_next_page = MagicMock(return_value=False)
scraper._extract_page_urls = MagicMock(return_value=["https://www.iaai.com/VehicleDetail/999~US"])
scraper._reopen_listing_and_resume = MagicMock(side_effect=ListingResumeError("resume failed"))
scraper.sync_batch = MagicMock(return_value={
"cars_upserted": 0,
"cars_failed": 0,
scraper.persistence.upsert_cars_batch = MagicMock(return_value={
"inserted": 1,
"updated": 0,
"images_upserted": 0,
})
urls = [
"https://www.iaai.com/VehicleDetail/111~US",
"https://www.iaai.com/VehicleDetail/222~US",
"https://www.iaai.com/VehicleDetail/333~US",
]
first_record = CarRecord.model_validate(make_db_record("111"))
class _FakeExecutor:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def map(self, fn, iterable, timeout=None): # noqa: ARG002
yield 0, first_record
raise FuturesTimeoutError()
with patch("iaai_scraper.core.runtime_config.RuntimeFiltersConfig.is_empty", return_value=True), \
patch("iaai_scraper.scraper.ThreadPoolExecutor", _FakeExecutor), \
patch.object(scraper, "_browser_fallback_parallel", return_value={
"records": [],
"failures": [],
})
result = scraper._sync_listing_streaming(
make=None,
model=None,
lane="iaai_cars",
limit=None,
effective_only_new=False,
started_at=0.0,
listing_url="https://www.iaai.com/Vehiclelisting/Cars?Make=TOYOTA",
)
self.assertTrue(result["listing"]["pagination_interrupted"])
def test_sync_listing_attempts_next_page_even_without_detected_next_control(self) -> None:
scraper = self._make_scraper()
scraper.settings.celery.batch_size = 1000
page = MagicMock()
first_page = SimpleNamespace(
page_number=1,
vehicle_links=[SimpleNamespace(href="https://www.iaai.com/VehicleDetail/111~US", lot_number="111")],
next_page_detected=False,
)
second_page = SimpleNamespace(
page_number=2,
vehicle_links=[SimpleNamespace(href="https://www.iaai.com/VehicleDetail/222~US", lot_number="222")],
next_page_detected=False,
)
scraper._get_page_with_warmup = MagicMock(return_value=page)
scraper.listing_collector.open_cars_listing = MagicMock()
scraper.listing_collector.apply_filters = MagicMock(return_value={
"make": None,
"model": None,
"year_min": None,
"year_max": None,
})
scraper.listing_collector.collect_current_page = MagicMock(side_effect=[first_page, second_page])
scraper.listing_collector.go_to_next_page = MagicMock(side_effect=[True, False])
scraper._extract_page_urls = MagicMock(side_effect=[
["https://www.iaai.com/VehicleDetail/111~US"],
["https://www.iaai.com/VehicleDetail/222~US"],
])
scraper.sync_batch = MagicMock(return_value={
"cars_upserted": 2,
"cars_failed": 0,
"images_upserted": 0,
"failures": [],
})
"protection_events": 0,
}) as fallback_mock:
result = scraper.sync_batch(urls)
result = scraper._sync_listing_streaming(
make=None,
model=None,
lane="iaai_cars",
limit=None,
effective_only_new=False,
started_at=0.0,
listing_url="https://www.iaai.com/Vehiclelisting/Cars?Make=TOYOTA",
)
self.assertEqual(scraper.listing_collector.go_to_next_page.call_count, 2)
scraper.listing_collector.go_to_next_page.assert_any_call(page, expected_page_number=2)
self.assertEqual(result["listing"]["pages_collected"], 2)
def test_sync_listing_treats_pagination_interrupted_as_partial_scan(self) -> None:
scraper = self._make_scraper()
scraper.persistence.create_tables = MagicMock()
scraper.persistence.start_sync_run = MagicMock(return_value=77)
scraper.persistence.finish_sync_run = MagicMock()
scraper.persistence.mark_sold_not_in_listing_by_urls = MagicMock()
scraper._sync_listing_streaming = MagicMock(return_value={
"listing": {
"vehicles_collected": 10,
"early_stopped": False,
"truncated_by_time_budget": False,
"pagination_interrupted": True,
},
"total": 10,
"skipped_existing": 0,
"cars_upserted": 10,
"cars_failed": 0,
"images_upserted": 0,
"failures": [],
"all_listing_origin_urls": {"https://www.iaai.com/VehicleDetail/999~US"},
})
result = scraper.sync_listing(listing_url="https://www.iaai.com/Vehiclelisting/Cars?Make=TOYOTA")
self.assertTrue(result["full_scan_completed"] is False)
scraper.persistence.mark_sold_not_in_listing_by_urls.assert_not_called()
self.assertEqual(result["cars_upserted"], 1)
fallback_urls = fallback_mock.call_args.args[0]
self.assertEqual(len(fallback_urls), 2)
self.assertEqual({u for u, _ in fallback_urls}, {urls[1], urls[2]})
self.assertNotIn(urls[0], {u for u, _ in fallback_urls})
if __name__ == "__main__":

195
tests/test_self_heal.py Normal file
View File

@@ -0,0 +1,195 @@
from __future__ import annotations
import json
import unittest
from unittest.mock import MagicMock, patch
from iaai_scraper.worker import self_heal
class TestSelfHeal(unittest.TestCase):
def test_read_last_progress_ts_uses_global_key(self) -> None:
redis_client = MagicMock()
redis_client.get.return_value = "1776800000"
ts = self_heal._read_last_progress_ts(redis_client)
self.assertEqual(ts, 1776800000)
redis_client.scan_iter.assert_not_called()
def test_read_last_progress_ts_fallbacks_to_task_progress_keys(self) -> None:
redis_client = MagicMock()
redis_client.get.side_effect = lambda key: {
self_heal.GLOBAL_PROGRESS_TS_KEY: None,
"iaai:state:task_progress:a": '{"ts": 100}',
"iaai:state:task_progress:b": '{"ts": 250}',
"iaai:state:task_progress:c": '{"ts": 150}',
}.get(key)
redis_client.scan_iter.return_value = [
"iaai:state:task_progress:a",
"iaai:state:task_progress:b",
"iaai:state:task_progress:c",
]
ts = self_heal._read_last_progress_ts(redis_client)
self.assertEqual(ts, 250)
def test_read_last_progress_ts_ignores_broken_payloads(self) -> None:
redis_client = MagicMock()
redis_client.get.side_effect = lambda key: {
self_heal.GLOBAL_PROGRESS_TS_KEY: None,
"iaai:state:task_progress:a": "{bad-json}",
"iaai:state:task_progress:b": '{"foo": "bar"}',
}.get(key)
redis_client.scan_iter.return_value = [
"iaai:state:task_progress:a",
"iaai:state:task_progress:b",
]
ts = self_heal._read_last_progress_ts(redis_client)
self.assertIsNone(ts)
def test_active_recent_progress_prevents_db_idle_restart(self) -> None:
redis_client = MagicMock()
redis_client.scan_iter.return_value = ["iaai:state:task_progress:hourly"]
redis_client.get.return_value = json.dumps(
{
"task_id": "hourly",
"stage": "fast_listing_collected",
"ts": 1000,
"last_db_progress_ts": 1,
"skipped_existing": 2417,
}
)
active = self_heal._has_active_recent_progress(
redis_client,
now_ts=1010,
stall_seconds=900,
)
self.assertTrue(active)
@patch("iaai_scraper.worker.self_heal.time.time", return_value=5000)
def test_has_inflight_work_deletes_stale_terminal_progress_without_work(self, _time_mock) -> None:
redis_client = MagicMock()
redis_client.llen.return_value = 0
redis_client.get.side_effect = lambda key: {
self_heal.SYNC_LISTING_LOCK_KEY: None,
"iaai:state:task_progress:old": json.dumps(
{
"task_id": "old",
"stage": "segment_done",
"ts": 1000,
"segment_full_scan_completed": True,
}
),
}.get(key)
redis_client.scan_iter.return_value = ["iaai:state:task_progress:old"]
has_inflight, flags = self_heal._has_inflight_work(redis_client, self_heal.IAAI_SYNC_QUEUE)
self.assertFalse(has_inflight)
self.assertEqual(flags["has_task_progress"], 0)
redis_client.delete.assert_called_once_with("iaai:state:task_progress:old")
@patch("iaai_scraper.worker.self_heal.time.time", return_value=1010)
def test_has_inflight_work_keeps_recent_non_terminal_progress(self, _time_mock) -> None:
redis_client = MagicMock()
redis_client.llen.return_value = 0
redis_client.get.side_effect = lambda key: {
self_heal.SYNC_LISTING_LOCK_KEY: None,
"iaai:state:task_progress:active": json.dumps(
{
"task_id": "active",
"stage": "fast_listing_collected",
"ts": 1000,
}
),
}.get(key)
redis_client.scan_iter.return_value = ["iaai:state:task_progress:active"]
has_inflight, flags = self_heal._has_inflight_work(redis_client, self_heal.IAAI_SYNC_QUEUE)
self.assertTrue(has_inflight)
self.assertEqual(flags["has_task_progress"], 1)
redis_client.delete.assert_not_called()
@patch("iaai_scraper.worker.self_heal.time.time", return_value=5000)
def test_has_inflight_work_deletes_stale_non_terminal_progress_without_work(self, _time_mock) -> None:
redis_client = MagicMock()
redis_client.llen.return_value = 0
redis_client.get.side_effect = lambda key: {
self_heal.SYNC_LISTING_LOCK_KEY: None,
"iaai:state:task_progress:stale": json.dumps(
{
"task_id": "stale",
"stage": "segment_started",
"ts": 1000,
"segment_index": 11,
}
),
}.get(key)
redis_client.scan_iter.return_value = ["iaai:state:task_progress:stale"]
has_inflight, flags = self_heal._has_inflight_work(redis_client, self_heal.IAAI_SYNC_QUEUE)
self.assertFalse(has_inflight)
self.assertEqual(flags["has_task_progress"], 0)
self.assertEqual(flags["stale_progress_deleted"], 1)
redis_client.delete.assert_called_once_with("iaai:state:task_progress:stale")
@patch("iaai_scraper.worker.self_heal.time.time", return_value=5000)
def test_has_inflight_work_keeps_stale_progress_when_lock_exists(self, _time_mock) -> None:
redis_client = MagicMock()
redis_client.llen.return_value = 0
redis_client.get.side_effect = lambda key: {
self_heal.SYNC_LISTING_LOCK_KEY: "owner",
"iaai:state:task_progress:stale": json.dumps(
{
"task_id": "stale",
"stage": "segment_started",
"ts": 1000,
}
),
}.get(key)
redis_client.scan_iter.return_value = ["iaai:state:task_progress:stale"]
has_inflight, flags = self_heal._has_inflight_work(redis_client, self_heal.IAAI_SYNC_QUEUE)
self.assertTrue(has_inflight)
self.assertEqual(flags["has_lock"], 1)
self.assertEqual(flags["has_task_progress"], 1)
redis_client.delete.assert_not_called()
@patch("iaai_scraper.worker.self_heal.time.sleep", return_value=None)
@patch("iaai_scraper.worker.self_heal.os.kill")
@patch("builtins.open")
def test_kill_worker_process_sends_term_and_kill(self, open_mock, kill_mock, _sleep_mock) -> None:
open_mock.return_value.__enter__.return_value.read.return_value = "123"
# SIGTERM -> process alive check (pid,0) -> SIGKILL
kill_mock.side_effect = [None, None, None]
self_heal._kill_worker_process()
self.assertEqual(kill_mock.call_args_list[0].args[0], 123)
self.assertEqual(kill_mock.call_args_list[1].args, (123, 0))
self.assertEqual(kill_mock.call_args_list[2].args[0], 123)
@patch("iaai_scraper.worker.self_heal.time.sleep", return_value=None)
@patch("iaai_scraper.worker.self_heal.os.kill")
@patch("builtins.open")
def test_kill_worker_process_skips_sigkill_when_already_exited(self, open_mock, kill_mock, _sleep_mock) -> None:
open_mock.return_value.__enter__.return_value.read.return_value = "123"
kill_mock.side_effect = [None, ProcessLookupError()]
self_heal._kill_worker_process()
# Только SIGTERM и проверка существования процесса.
self.assertEqual(len(kill_mock.call_args_list), 2)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,217 +0,0 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from iaai_scraper.core.config import Settings
from iaai_scraper.discovery.sitemap import (
SitemapBlockedError,
SitemapDiscoveryError,
SitemapFetchResult,
_filter_vehicle_urls,
discover_vehicle_urls_from_sitemap,
discover_vehicle_urls_from_sitemap_with_stats,
)
class TestSitemapDiscovery(unittest.TestCase):
@staticmethod
def _fetch_result(url: str, payload: bytes, *, source: str = "curl_cffi") -> SitemapFetchResult:
return SitemapFetchResult(url=url, payload=payload, source=source)
def test_filter_vehicle_urls_dedupes_and_normalizes(self) -> None:
urls = _filter_vehicle_urls([
"https://www.iaai.com/VehicleDetail/111~US?foo=1",
"https://www.iaai.com/VehicleDetail/111~US?bar=2",
"https://www.iaai.com/VehicleDetail/222~US",
"https://www.iaai.com/about",
])
self.assertEqual(
urls,
[
"https://www.iaai.com/VehicleDetail/111~US",
"https://www.iaai.com/VehicleDetail/222~US",
],
)
def test_discover_vehicle_urls_from_sitemap(self) -> None:
index_xml = b"""
<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
<sitemap><loc>https://www.iaai.com/sitemap-a.xml</loc></sitemap>
<sitemap><loc>https://www.iaai.com/sitemap-b.xml</loc></sitemap>
<sitemap><loc>https://www.iaai.com/sitemapauctions1.xml</loc></sitemap>
</sitemapindex>
"""
sitemap_a = b"""
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
<url><loc>https://www.iaai.com/VehicleDetail/111~US</loc></url>
<url><loc>https://www.iaai.com/VehicleDetail/222~US?x=1</loc></url>
</urlset>
"""
sitemap_b = b"""
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
<url><loc>https://www.iaai.com/VehicleDetail/222~US?y=2</loc></url>
<url><loc>https://www.iaai.com/VehicleDetail/333~US</loc></url>
</urlset>
"""
def _fake_fetch(url: str) -> SitemapFetchResult:
if url.endswith("sitemap_index.xml"):
return self._fetch_result(url, index_xml)
if url.endswith("sitemap-a.xml"):
return self._fetch_result(url, sitemap_a)
if url.endswith("sitemap-b.xml"):
return self._fetch_result(url, sitemap_b)
raise AssertionError(f"unexpected url: {url}")
with patch("iaai_scraper.discovery.sitemap._SitemapDownloader.fetch", side_effect=_fake_fetch):
result = discover_vehicle_urls_from_sitemap("https://www.iaai.com/sitemap_index.xml")
self.assertEqual(
result,
[
"https://www.iaai.com/VehicleDetail/111~US",
"https://www.iaai.com/VehicleDetail/222~US",
"https://www.iaai.com/VehicleDetail/333~US",
],
)
def test_discover_vehicle_urls_raises_on_empty_index(self) -> None:
empty_index = b"<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"></sitemapindex>"
with patch(
"iaai_scraper.discovery.sitemap._SitemapDownloader.fetch",
return_value=self._fetch_result("https://www.iaai.com/sitemap_index.xml", empty_index),
):
with self.assertRaises(SitemapDiscoveryError):
discover_vehicle_urls_from_sitemap("https://www.iaai.com/sitemap_index.xml")
def test_discover_vehicle_urls_skips_malformed_and_non_vehicle_sitemaps(self) -> None:
sitemap_vehicle = b"""
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
<url><loc>https://www.iaai.com/VehicleDetail/111~US</loc></url>
</urlset>
"""
sitemap_non_vehicle = b"""
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
<url><loc>https://www.iaai.com/SalesList/111~US/04222026</loc></url>
</urlset>
"""
index_xml = b"""
<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
<sitemap><loc>https://www.iaai.com/sitemap1.xml</loc></sitemap>
<sitemap><loc>https://www.iaai.com/sitemapbranches1.xml</loc></sitemap>
<sitemap><loc>https://www.iaai.com/sitemap2.xml</loc></sitemap>
<sitemap><loc>https://www.iaai.com/sitemap3.xml</loc></sitemap>
</sitemapindex>
"""
def _fake_fetch(url: str) -> SitemapFetchResult:
if url.endswith("sitemap_index.xml"):
return self._fetch_result(url, index_xml)
if url.endswith("sitemap1.xml"):
return self._fetch_result(url, sitemap_vehicle)
if url.endswith("sitemap2.xml"):
return self._fetch_result(url, sitemap_non_vehicle)
if url.endswith("sitemap3.xml"):
raise SitemapDiscoveryError("broken sitemap")
raise AssertionError(f"unexpected url: {url}")
with patch("iaai_scraper.discovery.sitemap._SitemapDownloader.fetch", side_effect=_fake_fetch):
result = discover_vehicle_urls_from_sitemap("https://www.iaai.com/sitemap_index.xml")
self.assertEqual(result, ["https://www.iaai.com/VehicleDetail/111~US"])
def test_discover_vehicle_urls_falls_back_to_regex_loc_extraction(self) -> None:
malformed_index = (
b"<sitemapindex>"
b"<sitemap><loc>https://www.iaai.com/sitemap1.xml</loc></sitemap>"
b"<broken"
)
sitemap_vehicle = b"""
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
<url><loc>https://www.iaai.com/VehicleDetail/111~US</loc></url>
</urlset>
"""
def _fake_fetch(url: str) -> SitemapFetchResult:
if url.endswith("sitemap_index.xml"):
return self._fetch_result(url, malformed_index)
if url.endswith("sitemap1.xml"):
return self._fetch_result(url, sitemap_vehicle)
raise AssertionError(f"unexpected url: {url}")
with patch("iaai_scraper.discovery.sitemap._SitemapDownloader.fetch", side_effect=_fake_fetch):
result = discover_vehicle_urls_from_sitemap("https://www.iaai.com/sitemap_index.xml")
self.assertEqual(result, ["https://www.iaai.com/VehicleDetail/111~US"])
def test_discovery_uses_direct_probe_when_index_has_no_urls(self) -> None:
index_xml = b"<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"></sitemapindex>"
sitemap_one = b"""
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
<url><loc>https://www.iaai.com/VehicleDetail/111~US</loc></url>
</urlset>
"""
settings = Settings()
settings.discovery.sitemap_direct_probe_limit = 2
settings.discovery.sitemap_direct_probe_stop_after_misses = 1
def _fake_fetch(url: str) -> SitemapFetchResult:
if url.endswith("sitemap_index.xml"):
return self._fetch_result(url, index_xml)
if url.endswith("sitemap1.xml"):
return self._fetch_result(url, sitemap_one)
raise SitemapDiscoveryError("not found")
with patch("iaai_scraper.discovery.sitemap._SitemapDownloader.fetch", side_effect=_fake_fetch):
result = discover_vehicle_urls_from_sitemap_with_stats(
"https://www.iaai.com/sitemap_index.xml",
settings=settings,
)
self.assertEqual(result.vehicle_urls, ["https://www.iaai.com/VehicleDetail/111~US"])
self.assertEqual(result.stats.direct_probe_hits, 1)
def test_discovery_stats_report_direct_probe_hits(self) -> None:
index_xml = b"<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"></sitemapindex>"
sitemap1 = b"""
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
<url><loc>https://www.iaai.com/VehicleDetail/111~US</loc></url>
</urlset>
"""
def _fake_fetch(url: str):
if url.endswith("sitemap_index.xml"):
return self._fetch_result(url, index_xml)
if url.endswith("sitemap1.xml"):
return self._fetch_result(url, sitemap1)
raise SitemapDiscoveryError("not found")
with patch("iaai_scraper.discovery.sitemap._SitemapDownloader.fetch", side_effect=_fake_fetch):
result = discover_vehicle_urls_from_sitemap_with_stats("https://www.iaai.com/sitemap_index.xml")
self.assertEqual(result.vehicle_urls, ["https://www.iaai.com/VehicleDetail/111~US"])
self.assertEqual(result.stats.transport, "curl_cffi")
self.assertEqual(result.stats.direct_probe_hits, 1)
self.assertGreaterEqual(result.stats.direct_probe_misses, 1)
def test_block_page_raises_discovery_error(self) -> None:
settings = Settings()
settings.discovery.sitemap_direct_probe_limit = 1
settings.discovery.sitemap_direct_probe_stop_after_misses = 1
with patch(
"iaai_scraper.discovery.sitemap._SitemapDownloader.fetch",
side_effect=SitemapBlockedError("Anti-bot page returned for https://www.iaai.com/sitemap_index.xml"),
):
with self.assertRaises(SitemapDiscoveryError):
discover_vehicle_urls_from_sitemap(
"https://www.iaai.com/sitemap_index.xml",
settings=settings,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -3,7 +3,14 @@ from __future__ import annotations
import unittest
from iaai_scraper.core.utils import deep_find_key
from iaai_scraper.core.config import parse_listing_segments, IAAI_DEFAULT_MAKES, _LARGE_MAKES, _YEAR_SPLITS
from iaai_scraper.core.config import (
IAAI_DEFAULT_MAKES,
_LARGE_MAKES,
_YEAR_SPLITS,
ProxyConfig,
build_listing_segments_for_makes,
parse_listing_segments,
)
class TestDeepFindKey(unittest.TestCase):
@@ -70,6 +77,37 @@ class TestParseListingSegments(unittest.TestCase):
self.assertEqual(segs[0]["year_min"], 2020)
self.assertEqual(segs[0]["year_max"], 2025)
def test_build_listing_segments_for_makes(self) -> None:
segs = build_listing_segments_for_makes(["toyota", "Lexus", "TOYOTA", " "])
toyota = [s for s in segs if s["make"] == "TOYOTA"]
lexus = [s for s in segs if s["make"] == "LEXUS"]
self.assertEqual(len(toyota), len(_YEAR_SPLITS))
self.assertEqual(len(lexus), 1)
self.assertIsNone(lexus[0]["year_min"])
class TestProxyConfig(unittest.TestCase):
def test_requests_proxy_url_includes_encoded_credentials(self) -> None:
cfg = ProxyConfig(
server="http://144.31.139.6:3128",
username="carsproxy",
password="pass@word:with/slash",
)
self.assertEqual(
cfg.to_requests_proxy_url(),
"http://carsproxy:pass%40word%3Awith%2Fslash@144.31.139.6:3128",
)
self.assertEqual(
cfg.to_requests_proxies(),
{
"http": "http://carsproxy:pass%40word%3Awith%2Fslash@144.31.139.6:3128",
"https": "http://carsproxy:pass%40word%3Awith%2Fslash@144.31.139.6:3128",
},
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,214 +1,35 @@
from __future__ import annotations
import json
import os
from dataclasses import replace
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from iaai_scraper.worker import tasks
def make_settings_stub(*, parallel_segments: bool = False) -> MagicMock:
settings_stub = MagicMock()
settings_stub.celery.parallel_segments = parallel_segments
settings_stub.celery.task_soft_time_limit = 3300
settings_stub.celery.task_time_limit = 3600
settings_stub.celery.task_stall_timeout_seconds = 600
settings_stub.listing.listing_segments_json = "ignored"
settings_stub.discovery.mode = "listing"
settings_stub.discovery.hourly_mode = "rolling_refresh"
return settings_stub
from iaai_scraper.core.config import Settings, settings as base_settings
class TestWorkerTaskLockHelpers(unittest.TestCase):
def test_sync_listing_task_uses_hourly_sitemap_even_when_mode_is_not_sitemap(self) -> None:
settings_stub = make_settings_stub(parallel_segments=False)
settings_stub.discovery.mode = "listing"
settings_stub.discovery.hourly_mode = "rolling_refresh"
def test_build_listing_segments_from_runtime_config_brands(self) -> None:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json", delete=False) as fh:
json.dump({"filters": {"brands": ["Toyota", "Lexus", "Toyota"]}}, fh)
runtime_config_file = fh.name
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "Settings", return_value=settings_stub), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=True), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_hourly_sitemap_rolling_refresh_sync", return_value={
"status": "success",
"run_id": None,
"cars_upserted": 25,
"cars_failed": 0,
"images_upserted": 50,
"skipped_existing": 100,
"elapsed_seconds": None,
"failures": [],
"hourly_mode": "sitemap_rolling_refresh",
"discovered_urls": 105,
"new_urls": 5,
"refresh_urls": 20,
"sold_marked": 2,
}) as hourly_sync, \
patch.object(tasks.sync_listing_task, "update_state"):
get_persistence.return_value = MagicMock()
redis_client = MagicMock()
redis_client.get.return_value = None
get_redis.return_value = redis_client
start_heartbeat.return_value = (MagicMock(), MagicMock())
settings = Settings(runtime_config_file=runtime_config_file)
settings.listing.listing_segments_json = "runtime"
settings.scraping_profile.http_first = False
settings.listing.fast_segment_year_splits = True
tasks.sync_listing_task.push_request(id="task-hourly-force-sitemap")
try:
result = tasks.sync_listing_task.run()
finally:
tasks.sync_listing_task.pop_request()
segs = tasks._build_listing_segments(settings)
self.assertEqual(result["status"], "success")
self.assertEqual(result["hourly_mode"], "sitemap_rolling_refresh")
hourly_sync.assert_called_once()
release_lock.assert_called_once()
def test_sync_listing_task_uses_hourly_sitemap_rolling_refresh_after_bootstrap(self) -> None:
settings_stub = make_settings_stub(parallel_segments=False)
settings_stub.discovery.mode = "sitemap"
settings_stub.discovery.hourly_mode = "rolling_refresh"
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "Settings", return_value=settings_stub), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=True), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_hourly_sitemap_rolling_refresh_sync", return_value={
"status": "success",
"run_id": None,
"cars_upserted": 25,
"cars_failed": 0,
"images_upserted": 50,
"skipped_existing": 100,
"elapsed_seconds": None,
"failures": [],
"hourly_mode": "sitemap_rolling_refresh",
"discovered_urls": 105,
"new_urls": 5,
"refresh_urls": 20,
"sold_marked": 2,
}) as hourly_sync, \
patch.object(tasks.sync_listing_task, "update_state"):
get_persistence.return_value = MagicMock()
redis_client = MagicMock()
redis_client.get.return_value = None
get_redis.return_value = redis_client
start_heartbeat.return_value = (MagicMock(), MagicMock())
tasks.sync_listing_task.push_request(id="task-hourly")
try:
result = tasks.sync_listing_task.run()
finally:
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
self.assertEqual(result["hourly_mode"], "sitemap_rolling_refresh")
hourly_sync.assert_called_once()
release_lock.assert_called_once()
def test_sync_listing_task_can_use_hourly_sitemap_diff_when_configured(self) -> None:
settings_stub = make_settings_stub(parallel_segments=False)
settings_stub.discovery.mode = "sitemap"
settings_stub.discovery.hourly_mode = "diff"
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "Settings", return_value=settings_stub), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=True), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_hourly_sitemap_diff_sync", return_value={
"status": "success",
"run_id": None,
"cars_upserted": 5,
"cars_failed": 0,
"images_upserted": 10,
"skipped_existing": 100,
"elapsed_seconds": None,
"failures": [],
"hourly_mode": "sitemap_diff",
"discovered_urls": 105,
"new_urls": 5,
"sold_marked": 2,
}) as hourly_sync, \
patch.object(tasks.sync_listing_task, "update_state"):
get_persistence.return_value = MagicMock()
redis_client = MagicMock()
redis_client.get.return_value = None
get_redis.return_value = redis_client
start_heartbeat.return_value = (MagicMock(), MagicMock())
tasks.sync_listing_task.push_request(id="task-hourly-diff")
try:
result = tasks.sync_listing_task.run()
finally:
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
self.assertEqual(result["hourly_mode"], "sitemap_diff")
hourly_sync.assert_called_once()
release_lock.assert_called_once()
def test_sync_listing_task_forces_sitemap_full_scan_in_bootstrap(self) -> None:
settings_stub = make_settings_stub(parallel_segments=False)
settings_stub.discovery.mode = "listing"
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "Settings", return_value=settings_stub), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=False), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \
patch.object(tasks.sync_listing_task, "update_state"), \
patch("iaai_scraper.worker.tasks.parse_listing_segments", return_value=[{"make": "HONDA"}]):
get_persistence.return_value = MagicMock()
redis_client = MagicMock()
redis_client.get.return_value = None
get_redis.return_value = redis_client
start_heartbeat.return_value = (MagicMock(), MagicMock())
sync_listing_mock = MagicMock(return_value={
"run_id": 99,
"status": "success",
"full_scan_completed": True,
"cars_upserted": 10,
"cars_failed": 0,
"images_upserted": 20,
"skipped_existing": 0,
"elapsed_seconds": 1.0,
"failures": [],
})
sync_listing_segmented_mock = MagicMock(side_effect=AssertionError("segmented path should not be used"))
scraper = MagicMock()
scraper.sync_listing = sync_listing_mock
scraper.sync_listing_segmented = sync_listing_segmented_mock
scraper_ctx = MagicMock()
scraper_ctx.__enter__.return_value = scraper
scraper_ctx.__exit__.return_value = None
with patch.object(tasks, "IAAIScraper", return_value=scraper_ctx):
tasks.sync_listing_task.push_request(id="task-bootstrap-sitemap")
try:
result = tasks.sync_listing_task.run()
finally:
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
sync_listing_mock.assert_called_once_with(
make=None,
model=None,
lane="iaai_cars",
limit=None,
only_new=False,
)
sync_listing_segmented_mock.assert_not_called()
release_lock.assert_called_once()
toyota = [s for s in segs if s["make"] == "TOYOTA"]
lexus = [s for s in segs if s["make"] == "LEXUS"]
self.assertEqual(len(toyota), 3)
self.assertEqual(len(lexus), 1)
self.assertIsNone(lexus[0]["year_min"])
os.unlink(runtime_config_file)
def test_lock_acquire_refresh_release(self) -> None:
redis_client = MagicMock()
@@ -378,17 +199,15 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
self.assertIsNone(tasks._load_last_completed_segment(redis_client))
redis_client.delete.assert_not_called()
def test_sync_listing_bootstrap_prefers_sitemap_over_segment_resume(self) -> None:
def test_sync_listing_resumes_from_next_segment_during_bootstrap(self) -> None:
segments = [
{"make": "ACURA"},
{"make": "AUDI"},
{"make": "BMW"},
{"make": "EAGLE"},
]
settings_stub = make_settings_stub()
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "Settings", return_value=settings_stub), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=False), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
@@ -404,15 +223,13 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
get_redis.return_value = redis_client
start_heartbeat.return_value = (MagicMock(), MagicMock())
sync_segmented_mock = MagicMock(side_effect=AssertionError("segmented path should not be used"))
sync_listing_mock = MagicMock(return_value={
sync_segmented_mock = MagicMock(return_value={
"run_id": 11, "status": "success", "full_scan_completed": True,
"cars_upserted": 1, "cars_failed": 0, "images_upserted": 0,
"skipped_existing": 0, "elapsed_seconds": 1.0, "failures": [],
})
scraper_ctx = MagicMock()
scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock
scraper_ctx.__enter__.return_value.sync_listing = sync_listing_mock
scraper_ctx.__exit__.return_value = None
with patch.object(tasks, "IAAIScraper", return_value=scraper_ctx):
@@ -423,42 +240,21 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
sync_listing_mock.assert_called_once_with(
make=None,
model=None,
lane="iaai_cars",
limit=None,
only_new=False,
)
sync_segmented_mock.assert_not_called()
# last_completed=1 → start_segment=2 (AUDI завершён, возобновляем с BMW).
self.assertEqual(sync_segmented_mock.call_args.kwargs["start_segment"], 2)
self.assertEqual(sync_segmented_mock.call_args.kwargs["start_page"], 1)
release_lock.assert_called_once()
def test_sync_listing_hourly_path_ignores_checkpoint_after_full_scan_completed(self) -> None:
def test_sync_listing_ignores_checkpoint_after_full_scan_completed(self) -> None:
segments = [{"make": "ACURA"}, {"make": "AUDI"}]
settings_stub = make_settings_stub()
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "Settings", return_value=settings_stub), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=True), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_clear_sync_checkpoint") as clear_checkpoint, \
patch.object(tasks, "_hourly_sitemap_rolling_refresh_sync", return_value={
"status": "success",
"run_id": None,
"cars_upserted": 1,
"cars_failed": 0,
"images_upserted": 0,
"skipped_existing": 0,
"elapsed_seconds": None,
"failures": [],
"hourly_mode": "sitemap_rolling_refresh",
"discovered_urls": 10,
"new_urls": 1,
"refresh_urls": 1,
"sold_marked": 0,
}) as hourly_sync, \
patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \
patch.object(tasks.sync_listing_task, "update_state"), \
patch("iaai_scraper.worker.tasks.parse_listing_segments", return_value=segments):
get_persistence.return_value = MagicMock()
@@ -470,6 +266,16 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
get_redis.return_value = redis_client
start_heartbeat.return_value = (MagicMock(), MagicMock())
sync_segmented_mock = MagicMock(return_value={
"run_id": 14, "status": "success", "full_scan_completed": True,
"cars_upserted": 1, "cars_failed": 0, "images_upserted": 0,
"skipped_existing": 0, "elapsed_seconds": 1.0, "failures": [],
})
scraper_ctx = MagicMock()
scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock
scraper_ctx.__exit__.return_value = None
with patch.object(tasks, "IAAIScraper", return_value=scraper_ctx):
tasks.sync_listing_task.push_request(id="task-792")
try:
result = tasks.sync_listing_task.run()
@@ -477,17 +283,15 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
self.assertEqual(result["hourly_mode"], "sitemap_rolling_refresh")
hourly_sync.assert_called_once()
self.assertEqual(sync_segmented_mock.call_args.kwargs["start_segment"], 0)
self.assertIsNone(sync_segmented_mock.call_args.kwargs["progress_callback"])
clear_checkpoint.assert_called()
release_lock.assert_called_once()
def test_sync_listing_bootstrap_ignores_beyond_segment_checkpoint(self) -> None:
def test_sync_listing_checkpoint_beyond_segments_restarts_from_zero(self) -> None:
segments = [{"make": "ACURA"}, {"make": "AUDI"}]
settings_stub = make_settings_stub()
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "Settings", return_value=settings_stub), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=False), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
@@ -503,15 +307,13 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
get_redis.return_value = redis_client
start_heartbeat.return_value = (MagicMock(), MagicMock())
sync_segmented_mock = MagicMock(side_effect=AssertionError("segmented path should not be used"))
sync_listing_mock = MagicMock(return_value={
sync_segmented_mock = MagicMock(return_value={
"run_id": 15, "status": "success", "full_scan_completed": True,
"cars_upserted": 0, "cars_failed": 0, "images_upserted": 0,
"skipped_existing": 0, "elapsed_seconds": 1.0, "failures": [],
})
scraper_ctx = MagicMock()
scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock
scraper_ctx.__enter__.return_value.sync_listing = sync_listing_mock
scraper_ctx.__exit__.return_value = None
with patch.object(tasks, "IAAIScraper", return_value=scraper_ctx):
@@ -522,14 +324,7 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
sync_listing_mock.assert_called_once_with(
make=None,
model=None,
lane="iaai_cars",
limit=None,
only_new=False,
)
sync_segmented_mock.assert_not_called()
self.assertEqual(sync_segmented_mock.call_args.kwargs["start_segment"], 0)
release_lock.assert_called_once()
def test_sync_listing_task_clears_checkpoint_on_hourly_run(self) -> None:
@@ -606,26 +401,17 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
self.assertTrue(should_enqueue)
def test_sync_listing_task_does_not_enqueue_followup_after_bootstrap_error_limit(self) -> None:
settings_stub = make_settings_stub(parallel_segments=False)
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "Settings", return_value=settings_stub), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=False), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_try_set_followup_pending", return_value=True), \
patch.object(
tasks,
"_bump_bootstrap_failure_streak",
return_value=(tasks.SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_LIMIT, False),
) as bump_streak, \
patch.object(tasks, "_bump_bootstrap_failure_streak", return_value=(tasks.SYNC_LISTING_BOOTSTRAP_FAILURE_STREAK_LIMIT, False)) as bump_streak, \
patch.object(tasks, "_clear_followup_pending") as clear_pending, \
patch.object(tasks.sync_listing_task, "update_state"), \
patch.object(
tasks,
"_run_browser_job",
return_value={
patch.object(tasks, "_run_browser_job", return_value={
"run_id": 99,
"status": "failed",
"full_scan_completed": False,
@@ -636,8 +422,7 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
"elapsed_seconds": 1.0,
"failures": [{"vehicle_url": "listing", "error": "bad resume"}],
"listing": {"vehicles_collected": 0},
},
):
}):
get_persistence.return_value = MagicMock()
redis_client = MagicMock()
redis_client.get.return_value = None
@@ -656,6 +441,177 @@ class TestWorkerTaskLockHelpers(unittest.TestCase):
clear_pending.assert_called()
task_app.send_task.assert_not_called()
def test_sync_listing_task_soft_timeout_deduplicates_continuation(self) -> None:
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=True), \
patch.object(tasks, "_start_lock_heartbeat", return_value=(MagicMock(), MagicMock())), \
patch.object(tasks, "_release_lock_if_owner"), \
patch.object(tasks, "_run_browser_job", side_effect=tasks.SoftTimeLimitExceeded()), \
patch.object(tasks, "_try_set_followup_pending", return_value=False) as set_pending, \
patch.object(tasks.sync_listing_task, "update_state"):
get_persistence.return_value = MagicMock()
redis_client = MagicMock()
redis_client.get.return_value = None
get_redis.return_value = redis_client
with patch.object(tasks.sync_listing_task, "app", new=MagicMock()) as task_app:
tasks.sync_listing_task.push_request(id="task-soft-timeout")
try:
result = tasks.sync_listing_task.run(make="Toyota")
finally:
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "timed_out")
set_pending.assert_called_once()
task_app.send_task.assert_not_called()
def test_sync_listing_task_stops_immediate_bootstrap_continuation_after_limit(self) -> None:
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=False), \
patch.object(tasks, "_start_lock_heartbeat", return_value=(MagicMock(), MagicMock())), \
patch.object(tasks, "_release_lock_if_owner"), \
patch.object(tasks, "_try_set_followup_pending", return_value=True), \
patch.object(tasks, "_bump_bootstrap_continuation_streak", return_value=(999, False)), \
patch.object(tasks, "_set_full_scan_done") as set_full_scan_done, \
patch.object(tasks, "_clear_sync_checkpoint") as clear_checkpoint, \
patch.object(tasks.sync_listing_task, "update_state"), \
patch.object(tasks, "_run_browser_job", return_value={
"run_id": 101,
"status": "partial_success",
"full_scan_completed": False,
"cars_upserted": 2,
"cars_failed": 0,
"images_upserted": 1,
"skipped_existing": 0,
"elapsed_seconds": 1.0,
"failures": [],
"listing": {"vehicles_collected": 2},
}):
get_persistence.return_value = MagicMock()
redis_client = MagicMock()
redis_client.get.return_value = None
get_redis.return_value = redis_client
with patch.object(tasks.sync_listing_task, "app", new=MagicMock()) as task_app:
tasks.sync_listing_task.push_request(id="task-breaker-stop")
try:
result = tasks.sync_listing_task.run()
finally:
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "partial_success")
# Сначала bootstrap помечается незавершённым, затем breaker переключает на hourly.
self.assertTrue(any(call.args == (redis_client, False) for call in set_full_scan_done.call_args_list))
self.assertTrue(any(call.args == (redis_client, True) for call in set_full_scan_done.call_args_list))
clear_checkpoint.assert_called_once()
task_app.send_task.assert_not_called()
def test_sync_listing_task_always_full_scan_forces_bootstrap_even_after_done(self) -> None:
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(
tasks,
"Settings",
return_value=replace(
base_settings,
discovery=replace(base_settings.discovery, always_full_scan=True),
),
), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=True), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_set_full_scan_done") as set_full_scan_done, \
patch.object(tasks.sync_listing_task, "update_state"), \
patch.object(tasks, "_run_browser_job") as run_job, \
patch("iaai_scraper.worker.tasks.parse_listing_segments", return_value=[]):
get_persistence.return_value = MagicMock()
redis_client = MagicMock()
redis_client.get.return_value = None
get_redis.return_value = redis_client
start_heartbeat.return_value = (MagicMock(), MagicMock())
run_job.return_value = {
"run_id": 17,
"status": "success",
"full_scan_completed": True,
"cars_upserted": 1,
"cars_failed": 0,
"images_upserted": 0,
"skipped_existing": 0,
"elapsed_seconds": 1.0,
"failures": [],
}
tasks.sync_listing_task.push_request(id="task-always-full")
try:
result = tasks.sync_listing_task.run()
finally:
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
set_full_scan_done.assert_called_with(redis_client, False)
release_lock.assert_called_once()
def test_sync_listing_task_always_full_scan_uses_segmented_resume_path(self) -> None:
segments = [{"make": "TOYOTA"}, {"make": "FORD"}, {"make": "HONDA"}]
with patch.object(tasks, "_get_persistence") as get_persistence, \
patch.object(tasks, "_get_redis") as get_redis, \
patch.object(
tasks,
"Settings",
return_value=replace(
base_settings,
discovery=replace(base_settings.discovery, always_full_scan=True),
),
), \
patch.object(tasks, "_acquire_lock", return_value=True), \
patch.object(tasks, "_is_full_scan_done", return_value=True), \
patch.object(tasks, "_start_lock_heartbeat") as start_heartbeat, \
patch.object(tasks, "_release_lock_if_owner") as release_lock, \
patch.object(tasks, "_run_browser_job", side_effect=lambda fn: fn()), \
patch.object(tasks.sync_listing_task, "update_state"), \
patch("iaai_scraper.worker.tasks.parse_listing_segments", return_value=segments):
get_persistence.return_value = MagicMock()
redis_client = MagicMock()
redis_client.get.side_effect = lambda key: (
"0" if key == tasks.SYNC_LISTING_CHECKPOINT_KEY else None
)
get_redis.return_value = redis_client
start_heartbeat.return_value = (MagicMock(), MagicMock())
sync_segmented_mock = MagicMock(return_value={
"run_id": 18,
"status": "success",
"full_scan_completed": True,
"cars_upserted": 1,
"cars_failed": 0,
"images_upserted": 0,
"skipped_existing": 0,
"elapsed_seconds": 1.0,
"failures": [],
})
scraper_ctx = MagicMock()
scraper_ctx.__enter__.return_value.sync_listing_segmented = sync_segmented_mock
scraper_ctx.__enter__.return_value.sync_listing = MagicMock()
scraper_ctx.__exit__.return_value = None
with patch.object(tasks, "IAAIScraper", return_value=scraper_ctx):
tasks.sync_listing_task.push_request(id="task-always-full-resume")
try:
result = tasks.sync_listing_task.run()
finally:
tasks.sync_listing_task.pop_request()
self.assertEqual(result["status"], "success")
self.assertEqual(sync_segmented_mock.call_args.kwargs["start_segment"], 1)
self.assertEqual(sync_segmented_mock.call_args.kwargs["start_page"], 1)
release_lock.assert_called_once()
if __name__ == "__main__":
unittest.main()

18
vps_check.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
cd /root/iaai-parser
echo '--- services ---'
docker compose ps
echo '--- env speed ---'
grep -nE 'IAAI_PROFILE|IAAI_SCRAPING_PROFILE|IAAI_HTTP_FIRST|IAAI_LISTING_SEGMENTS|IAAI_FAST_SEGMENT_YEAR_SPLITS|CELERY_WORKER_CONCURRENCY|IAAI_FETCH_CONCURRENCY|CELERY_BATCH_SIZE' .env
echo '--- db count ---'
docker compose exec -T postgres psql -U iaai -d iaai_scraper -t -c "select count(*) as cars_count, max(last_seen_at) as last_seen from iaai_cars;"
echo '--- redis progress ---'
docker compose exec -T redis redis-cli MGET iaai:state:sync_listing_checkpoint iaai:state:sync_segments_done iaai:state:sync_segments_total iaai:state:sync_full_scan_done
echo '--- recent worker progress ---'
docker compose logs --tail=80 worker | grep -E 'Segment [0-9]+/[0-9]+|Fast HTTP-first listing started|Fast HTTP-first listing collected|Fast HTTP-first DB batch|ERROR|WARNING' | tail -40

179
vps_monitor.sh Normal file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env bash
set -euo pipefail
# VPS monitor for iaai-parser. Run on the VPS from any directory:
# bash /root/iaai-parser/vps_monitor.sh
# Live mode:
# watch -n 30 bash /root/iaai-parser/vps_monitor.sh
APP_DIR="${APP_DIR:-/root/iaai-parser}"
cd "$APP_DIR"
LINE='============================================================'
SMALL='------------------------------------------------------------'
section() {
echo
echo "$LINE"
echo "$1"
echo "$LINE"
}
subsection() {
echo
echo "$SMALL"
echo "$1"
echo "$SMALL"
}
run_psql() {
docker compose exec -T postgres psql -U iaai -d iaai_scraper "$@"
}
run_redis() {
docker compose exec -T redis redis-cli "$@"
}
now_utc="$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "IAAI VPS monitor | $now_utc | app=$APP_DIR"
section "1) Services / restarts"
docker compose ps
subsection "Container restart counters"
for svc in api worker beat postgres redis; do
cid="$(docker compose ps -q "$svc" 2>/dev/null || true)"
if [[ -z "$cid" ]]; then
printf '%-10s %s\n' "$svc" "not found"
continue
fi
name="$(docker inspect --format '{{.Name}}' "$cid" | sed 's#^/##')"
restarts="$(docker inspect --format '{{.RestartCount}}' "$cid")"
started="$(docker inspect --format '{{.State.StartedAt}}' "$cid")"
status="$(docker inspect --format '{{.State.Status}}' "$cid")"
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}' "$cid")"
printf '%-22s restarts=%-3s started=%s status=%s health=%s\n' "$name" "$restarts" "$started" "$status" "$health"
done
subsection "Startup / restart log markers"
docker compose logs --since '24h' worker beat 2>/dev/null \
| grep -E 'Worker ready|startup sync|dispatching initial|Scheduler: Sending due task|warm shutdown|Restoring|mingle:|ready\.|ERROR|CRITICAL' \
| tail -80 || true
section "2) Main DB result"
run_psql -c "
select
count(*) as total_cars,
count(*) filter (where is_sold = false) as active_cars,
count(*) filter (where is_sold = true) as sold_cars,
count(*) filter (where last_seen_at > now() - interval '5 minutes') as touched_5m,
count(*) filter (where last_seen_at > now() - interval '15 minutes') as touched_15m,
count(*) filter (where last_seen_at > now() - interval '60 minutes') as touched_60m,
max(last_seen_at) as last_seen
from iaai_cars;
"
subsection "Writes from sync_runs"
run_psql -c "
select
coalesce(sum(cars_upserted) filter (where started_at > now() - interval '1 hour'), 0) as cars_upserted_1h,
coalesce(sum(cars_upserted) filter (where started_at > now() - interval '6 hours'), 0) as cars_upserted_6h,
coalesce(sum(cars_upserted) filter (where started_at::date = now()::date), 0) as cars_upserted_today,
count(*) filter (where status = 'running') as running_runs,
count(*) filter (where status = 'success' and started_at > now() - interval '24 hours') as success_runs_24h,
count(*) filter (where status = 'failed' and started_at > now() - interval '24 hours') as failed_runs_24h
from iaai_sync_runs;
"
section "3) Segments / passes"
subsection "Redis checkpoint / active progress"
checkpoint="$(run_redis GET iaai:state:sync_listing_checkpoint || true)"
segments_done="$(run_redis SCARD iaai:state:sync_segments_done 2>/dev/null || echo 0)"
segments_total="$(run_redis GET iaai:state:sync_segments_total || true)"
full_scan_done="$(run_redis GET iaai:state:sync_full_scan_done || true)"
queue_len="$(run_redis LLEN iaai_sync || true)"
lock_value="$(run_redis GET iaai:locks:sync_listing || true)"
echo "checkpoint_last_completed_segment=${checkpoint:-empty}"
echo "segments_done_set_count=${segments_done:-0}"
echo "segments_total=${segments_total:-unknown}"
echo "full_scan_done=${full_scan_done:-empty}"
echo "queue_len=${queue_len:-unknown}"
echo "sync_lock=${lock_value:-empty}"
echo
echo "Active/latest task progress keys:"
progress_keys="$(run_redis --scan --pattern 'iaai:state:task_progress:*' || true)"
if [[ -z "$progress_keys" ]]; then
echo "no task_progress keys"
else
while IFS= read -r key; do
[[ -z "$key" ]] && continue
echo "$key"
run_redis GET "$key" || true
echo
done <<< "$progress_keys"
fi
subsection "Completed full passes by worker logs"
passes_24h="$(docker compose logs --since '24h' worker 2>/dev/null | grep -Ec 'Segment 65/65 done|Segment 64/65 done: RIMAC|Bootstrap full scan completed|sync_listing_task completed: status=success' || true)"
passes_6h="$(docker compose logs --since '6h' worker 2>/dev/null | grep -Ec 'Segment 65/65 done|Segment 64/65 done: RIMAC|Bootstrap full scan completed|sync_listing_task completed: status=success' || true)"
echo "estimated_completed_passes_6h=$passes_6h"
echo "estimated_completed_passes_24h=$passes_24h"
subsection "Recent segment events"
docker compose logs --tail=220 worker 2>/dev/null \
| grep -E 'Segment [0-9]+/[0-9]+|Fast HTTP-first listing collected|Fast HTTP-first DB batch|sync_listing_task completed|Bootstrap full scan completed|Bootstrap continuation stopped|sync_already_running' \
| tail -90 || true
section "4) Errors / warnings"
subsection "Failed sync_runs last 24h"
run_psql -c "
select id, status, cars_upserted, cars_failed, started_at, finished_at, left(coalesce(error_summary, ''), 180) as error_summary
from iaai_sync_runs
where status <> 'success' and started_at > now() - interval '24 hours'
order by id desc
limit 20;
"
subsection "Recent ERROR/WARNING logs"
docker compose logs --since '6h' worker beat api 2>/dev/null \
| grep -E 'ERROR|WARNING|CRITICAL|Traceback|TimeLimitExceeded|SoftTimeLimitExceeded|Read timed out|anti-bot|circuit breaker' \
| tail -120 || true
section "5) Schedule / hourly behavior"
subsection "Relevant environment"
docker compose exec -T worker sh -lc 'env | grep -E "IAAI_STARTUP_SYNC_ENABLED|CELERY_BEAT_SYNC_INTERVAL_MINUTES|IAAI_LISTING_SEGMENTS|IAAI_ALWAYS_FULL_SCAN|IAAI_FULL_SCAN_ON_STARTUP|IAAI_HOURLY_MODE|IAAI_HOURLY_REFRESH_BATCH_SIZE|IAAI_SELF_HEAL_STALL_SECONDS|IAAI_DB_IDLE_RESTART_SECONDS|CELERY_TASK_SOFT_TIME_LIMIT|CELERY_TASK_TIME_LIMIT|CELERY_BROKER_VISIBILITY_TIMEOUT" | sort' || true
subsection "Beat sends / skipped duplicates"
docker compose logs --since '12h' beat worker 2>/dev/null \
| grep -E 'Scheduler: Sending due task periodic-sync-listing|sync_already_running|sync_listing_task hourly|Hourly mode|Worker ready|startup sync dispatch disabled|dispatching initial' \
| tail -120 || true
section "6) Sold cars"
run_psql -c "
select
count(*) filter (where is_sold = true) as sold_total,
count(*) filter (where is_sold = true and last_seen_at > now() - interval '24 hours') as sold_seen_24h,
min(last_seen_at) filter (where is_sold = true) as oldest_sold_seen,
max(last_seen_at) filter (where is_sold = true) as newest_sold_seen
from iaai_cars;
"
subsection "Top sold brands"
run_psql -c "
select brand, count(*) as sold
from iaai_cars
where is_sold = true
group by brand
order by sold desc
limit 15;
"
subsection "Sold marking logs"
docker compose logs --since '24h' worker 2>/dev/null \
| grep -E 'Marked [0-9]+ cars as sold|sold_marked|Fast sold reconcile failed|mark_sold safety abort' \
| tail -80 || true
section "7) Quick health hints"
echo "OK if: worker/beat healthy, queue_len is 0 or small, one sync_lock exists during active run, checkpoint grows, failed_runs_24h is low."
echo "If IAAI_STARTUP_SYNC_ENABLED is empty/true, worker restart can dispatch an immediate sync. Set it to false to rely only on hourly beat."