checkpoint: pipeline + 27k/h baseline before sitemap discovery

This commit is contained in:
qananasikq
2026-04-18 16:34:09 +03:00
parent a8c5f8aa41
commit 5999c2e42d
30 changed files with 4993 additions and 61 deletions

View File

@@ -13,6 +13,10 @@ RUN pip install --no-cache-dir uv \
&& pip install --no-cache-dir -r /tmp/requirements.txt \ && pip install --no-cache-dir -r /tmp/requirements.txt \
&& pip install --no-cache-dir playwright-stealth && 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 # Install both Chromium and Firefox. Chromium is the default engine in Docker
# because it works more reliably with the current IAAI listing page. # because it works more reliably with the current IAAI listing page.
RUN python -m playwright install chromium firefox RUN python -m playwright install chromium firefox

11
_check.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
echo "== LOCK =="
docker exec iaai-redis redis-cli get iaai:locks:sync_listing
docker exec iaai-redis redis-cli ttl iaai:locks:sync_listing
echo "== CHECKPOINT =="
docker exec iaai-redis redis-cli get iaai:state:sync_listing_checkpoint
docker exec iaai-redis redis-cli get iaai:state:sync_full_scan_done
echo "== DB =="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select count(*) as total, count(*) filter (where last_seen_at > now() - interval '3 minutes') as recent from cars;"
echo "== WORKER TAIL =="
docker logs iaai-worker --tail 25 2>&1 | tail -25

26
_check2.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
echo "=========================================="
echo "== ТЕКУЩАЯ АКТИВНОСТЬ SYNC (последние логи) =="
echo "=========================================="
docker logs iaai-worker --tail 30 2>&1 | grep -E "Segment|HTTP-mode page|upserted|finished|ERROR" | tail -20
echo ""
echo "=========================================="
echo "== DB: всего машин и апсерты за 5 минут =="
echo "=========================================="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select 'total=' || count(*), 'last_5min=' || count(*) filter (where last_seen_at > now() - interval '5 minutes'), 'last_1min=' || count(*) filter (where last_seen_at > now() - interval '1 minute') from cars;"
echo ""
echo "=========================================="
echo "== Макс timestamp (когда была последняя запись) =="
echo "=========================================="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select 'max_last_seen=' || max(last_seen_at), 'now=' || now() from cars;"
echo ""
echo "=========================================="
echo "== Топ марок в БД =="
echo "=========================================="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select make, count(*) from cars group by make order by count(*) desc limit 10;"
echo ""
echo "=========================================="
echo "== Очередь Celery =="
echo "=========================================="
docker exec iaai-redis redis-cli llen scraping
echo "lock TTL: $(docker exec iaai-redis redis-cli ttl iaai:locks:sync_listing)s"

15
_check3.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
echo "== ERRORS/EXCEPTIONS за последние 5 минут worker =="
docker logs iaai-worker --since 5m 2>&1 | grep -iE "error|traceback|exception|failed|warning" | grep -v "Segment.*done" | tail -20
echo ""
echo "== Последние события (batch/upsert/save) =="
docker logs iaai-worker --tail 500 2>&1 | grep -iE "batch|upsert|save|Processing|finished|collected" | tail -15
echo ""
echo "== DB: cars schema =="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -c "\d cars" 2>&1 | head -40
echo ""
echo "== DB: real counts =="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select 'total=' || count(*), 'last_5min=' || count(*) filter (where last_seen_at > now() - interval '5 minutes') from cars;"
echo ""
echo "== Текущая активность sync (tail) =="
docker logs iaai-worker --tail 5 2>&1

15
_check4.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
echo "== DB: счётчики =="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select 'total=' || count(*), 'last_10min=' || count(*) filter (where last_seen_at > now() - interval '10 minutes'), 'last_2min=' || count(*) filter (where last_seen_at > now() - interval '2 minutes'), 'max_last_seen=' || max(last_seen_at)::text from cars;"
echo ""
echo "== Топ brand =="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select brand, count(*) from cars group by brand order by count(*) desc limit 10;"
echo ""
echo "== Текущая фаза sync (tail) =="
docker logs iaai-worker --tail 5 2>&1
echo ""
echo "== Последние upserted/batch =="
docker logs iaai-worker --tail 500 2>&1 | grep -iE "upserted|Processing batch|Sync run.*finished|Segment.*done" | tail -10
echo ""
echo "== ERRORS свежие =="
docker logs iaai-worker --since 10m 2>&1 | grep -iE "ERROR|Traceback|Exception" | tail -10

26
_db_now.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
cd /root/iaai_scraper_project
echo "== DB now =="
docker compose exec -T postgres psql -U iaai -d iaai_scraper -c "
SELECT
count(*) AS total,
count(*) FILTER (WHERE last_seen_at > now() - interval '1 minute') AS last_1min,
count(*) FILTER (WHERE last_seen_at > now() - interval '5 minutes') AS last_5min,
count(*) FILTER (WHERE last_seen_at > now() - interval '15 minutes') AS last_15min,
max(last_seen_at) AS max_ts,
now() AS now_ts
FROM cars;"
echo
echo "== last 10 upsert / segment-done events =="
docker compose logs --tail=1000 worker 2>&1 | grep -E 'upserted=|Segment .* done|HTTP-mode batch' | tail -n 15
echo
echo "== last 5 page-progress =="
docker compose logs --tail=200 worker 2>&1 | grep -E 'HTTP-mode page' | tail -n 5
echo
echo "== recent 500s (if any) =="
docker compose logs --tail=500 worker 2>&1 | grep -E 'status=500|status=403' | tail -n 5 || true

19
_db_now2.sh Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
cd /root/iaai_scraper_project
echo "== DB =="
docker compose exec -T postgres psql -U iaai -d iaai_scraper -c "
SELECT
count(*) AS total,
count(*) FILTER (WHERE last_seen_at > now() - interval '1 minute') AS l1,
count(*) FILTER (WHERE last_seen_at > now() - interval '5 minutes') AS l5,
count(*) FILTER (WHERE created_at > now() - interval '15 minutes') AS created_15m,
max(last_seen_at) AS max_seen,
max(created_at) AS max_created,
now() AS now_ts
FROM cars;"
echo
echo "== last 50 lines of worker log (everything) =="
docker compose logs --tail=50 worker 2>&1 | tail -n 50

BIN
_deploy_patch.tar.gz Normal file

Binary file not shown.

42
_diag_vps.sh Normal file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
echo "=========================================="
echo "== 1. PROXY/HTTP CONFIG в .env =="
echo "=========================================="
cd /root/iaai_scraper_project
grep -iE "PROXY|HTTP_MODE|USER_AGENT|IAAI_BASE|IAAI_.*URL|BRIGHT" .env | grep -v "^#"
echo ""
echo "=========================================="
echo "== 2. ВНЕШНИЙ IP VPS =="
echo "=========================================="
curl -s --max-time 10 https://api.ipify.org; echo ""
echo ""
echo "=========================================="
echo "== 3. ПРЯМОЙ ЗАПРОС IAAI (без прокси) =="
echo "=========================================="
curl -s -o /dev/null -w "HTTP=%{http_code} time=%{time_total}s size=%{size_download}\n" --max-time 20 "https://www.iaai.com/"
curl -s -o /tmp/iaai_root.html -w "HTTP=%{http_code} size=%{size_download}\n" --max-time 20 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" "https://www.iaai.com/"
echo "--- первые 400 байт ответа ---"
head -c 400 /tmp/iaai_root.html; echo ""
echo "--- признак Incapsula/bot-check ---"
grep -oiE "incapsula|_Incapsula_|captcha|access denied|bot|cloudflare" /tmp/iaai_root.html | sort -u
echo ""
echo "=========================================="
echo "== 4. POST /Search (как это делает парсер) =="
echo "=========================================="
curl -s -o /tmp/iaai_search.html -w "HTTP=%{http_code} size=%{size_download}\n" --max-time 20 \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Accept: application/json, text/html, */*" \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \
-X POST "https://www.iaai.com/Search" \
--data "Make=TOYOTA&YearMin=1900&YearMax=2012&Page=1"
head -c 400 /tmp/iaai_search.html; echo ""
echo ""
echo "=========================================="
echo "== 5. PROXY env внутри worker-контейнера =="
echo "=========================================="
docker exec iaai-worker env | grep -iE "PROXY|IAAI|HTTP_" | sort
echo ""
echo "=========================================="
echo "== 6. Последние ERROR-логи worker =="
echo "=========================================="
docker logs iaai-worker --tail 200 2>&1 | grep -iE "proxy|bright|incapsula|403|500|429|blocked" | tail -15

26
_dispatch_check.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
cd /root/iaai_scraper_project
echo "== dispatch =="
TID=$(docker compose exec -T worker python -c "from iaai_scraper.worker.tasks import sync_listing_task; print(sync_listing_task.delay().id)")
echo "dispatched: $TID"
echo "== wait 120s =="
sleep 120
echo
echo "== DB =="
docker compose exec -T postgres psql -U iaai -d iaai_scraper -c "SELECT count(*) AS total, count(*) FILTER (WHERE last_seen_at > now() - interval '5 minutes') AS last_5min, max(last_seen_at) AS max FROM cars;"
echo
echo "== upsert / batch / segment-done events (last 40) =="
docker compose logs --tail=800 worker 2>&1 | grep -E 'HTTP-mode batch|upserted=|HTTP-mode listing done|Segment .* done|HTTP fast-path using proxy|Sync run .* finished' | tail -n 40
echo
echo "== page-progress tail =="
docker compose logs --tail=400 worker 2>&1 | grep -E 'HTTP-mode page' | tail -n 15
echo
echo "== errors if any =="
docker compose logs --tail=400 worker 2>&1 | grep -Ei 'error|exception|403|500|incapsula|pardon' | tail -n 15 || true

55
_enable_proxy.sh Normal file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
cd /root/iaai_scraper_project
echo "== patch .env =="
# удалить старые SOCKS5_*/IAAI_PROXY_* строки если есть
sed -i '/^SOCKS5_PROXY_/d; /^IAAI_PROXY_/d' .env
cat >> .env <<'EOF'
# --- SOCKS5 US residential proxy (BulletProof) ---
SOCKS5_PROXY_HOST=residential.bpproxy.at
SOCKS5_PROXY_PORT=1002
SOCKS5_PROXY_USER=bpuser-S7o3VlC0
SOCKS5_PROXY_PASS=YKmRzvjvnnvyQdEUQ8hM_country-US_session-q3TGNZZO_lifetime-30
EOF
grep -E '^(SOCKS5_|IAAI_PROXY_)' .env
echo "== restart worker =="
docker compose stop worker
docker compose up -d worker
echo "== wait 8s for worker + bridge =="
sleep 8
echo "== verify proxy bridge + external IP =="
docker compose exec -T worker sh -c 'curl -s --max-time 20 --proxy http://127.0.0.1:8899 https://api.ipify.org' || echo "proxy check FAILED"
echo
echo "== direct check (no proxy) =="
docker compose exec -T worker sh -c 'curl -s --max-time 10 https://api.ipify.org' || true
echo
echo "== clear locks/state =="
docker compose exec -T redis redis-cli DEL iaai:locks:sync_listing iaai:state:startup_sync_dispatched iaai:state:sync_listing_followup_pending
echo "== dispatch fresh task =="
TID=$(docker compose exec -T worker python -c "from iaai_scraper.worker.tasks import sync_listing; print(sync_listing.delay().id)")
echo "dispatched: $TID"
echo "== wait 90s =="
sleep 90
echo
echo "== DB =="
docker compose exec -T postgres psql -U iaai -d iaai_scraper -c "SELECT count(*) AS total, count(*) FILTER (WHERE last_seen_at > now() - interval '5 minutes') AS last_5min, max(last_seen_at) AS max FROM cars;"
echo
echo "== last 30 log lines (batch / upserted / HTTP-mode page) =="
docker compose logs --tail=500 worker 2>&1 | grep -E 'HTTP-mode batch|upserted=|HTTP-mode listing done|Segment .* done|HTTP fast-path using proxy|proxy_bridge' | tail -n 30
echo
echo "== sample of recent pagination lines =="
docker compose logs --tail=300 worker 2>&1 | grep -E 'HTTP-mode page' | tail -n 10

13
_kick.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
set -e
echo "== Cleaning stale Redis state =="
docker exec iaai-redis redis-cli del iaai:locks:sync_listing iaai:state:startup_sync_dispatched iaai:state:sync_listing_followup_pending iaai:state:sync_listing_bootstrap_failure_streak
echo "== Dispatching fresh sync_listing_task =="
docker exec iaai-worker python -c "from iaai_scraper.worker.celery_app import celery_app; r=celery_app.send_task('iaai_scraper.worker.tasks.sync_listing_task', kwargs={'only_new': False}, queue='scraping'); print('dispatched:', r.id)"
sleep 30
echo "== Worker logs =="
docker logs iaai-worker --tail 30 2>&1 | grep -E "Segment|sync_listing_task|progress|upserted|ERROR|clamp" | tail -20
echo "== Lock TTL now =="
docker exec iaai-redis redis-cli ttl iaai:locks:sync_listing
echo "== DB recent =="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select count(*) as total, count(*) filter (where last_seen_at > now() - interval '2 minutes') as recent from cars;"

29
_reduce50.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
set -e
cd /root/iaai_scraper_project
echo "== update .env =="
sed -i 's/^IAAI_MAX_PAGES_PER_RUN=.*/IAAI_MAX_PAGES_PER_RUN=50/' .env
grep MAX_PAGES .env
echo ""
echo "== restart worker (wait for finish) =="
docker compose stop worker
docker compose up -d worker
sleep 10
echo ""
echo "== clear lock =="
docker exec iaai-redis redis-cli del iaai:locks:sync_listing iaai:state:startup_sync_dispatched iaai:state:sync_listing_followup_pending
echo ""
echo "== dispatch =="
docker exec iaai-worker python -c "from iaai_scraper.worker.celery_app import celery_app; r=celery_app.send_task('iaai_scraper.worker.tasks.sync_listing_task', kwargs={'only_new': False}, queue='scraping'); print('dispatched:', r.id)"
echo ""
echo "== wait 90s =="
sleep 90
echo ""
echo "== DB =="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select 'total=' || count(*), 'last_5min=' || count(*) filter (where last_seen_at > now() - interval '5 minutes'), 'max=' || max(last_seen_at)::text from cars;"
echo ""
echo "== BATCH + UPSERT EVENTS =="
docker logs iaai-worker --tail 5000 2>&1 | grep -E "HTTP-mode batch|HTTP-mode listing done|Segment.*done|upserted" | tail -20
echo ""
echo "== TAIL =="
docker logs iaai-worker --tail 8

24
_rollback_limits.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
set -e
cd /root/iaai_scraper_project
echo "== 1. Verify .env =="
grep -E "MAX_PAGES|MAX_VEHICLES" .env
echo ""
echo "== 2. Restart worker + beat (не трогаем redis/postgres) =="
docker compose restart worker beat
sleep 5
echo ""
echo "== 3. Clear Redis lock =="
docker exec iaai-redis redis-cli del iaai:locks:sync_listing iaai:state:startup_sync_dispatched iaai:state:sync_listing_followup_pending
echo ""
echo "== 4. Dispatch fresh task =="
docker exec iaai-worker python -c "from iaai_scraper.worker.celery_app import celery_app; r=celery_app.send_task('iaai_scraper.worker.tasks.sync_listing_task', kwargs={'only_new': False}, queue='scraping'); print('dispatched:', r.id)"
echo ""
echo "== 5. Wait 45s, then check =="
sleep 45
echo ""
echo "== LOGS =="
docker logs iaai-worker --tail 250 2>&1 | grep -E "Segment|HTTP-mode page [0-9]+0:|HTTP-mode batch|HTTP-mode listing done|Sync run.*finished|upserted=" | tail -25
echo ""
echo "== DB =="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select 'total=' || count(*), 'last_2min=' || count(*) filter (where last_seen_at > now() - interval '2 minutes'), 'max=' || max(last_seen_at)::text from cars;"

10
_wait_check.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
sleep 60
echo "== DB =="
docker exec iaai-postgres psql -U iaai -d iaai_scraper -t -c "select 'total=' || count(*), 'last_3min=' || count(*) filter (where last_seen_at > now() - interval '3 minutes'), 'max=' || max(last_seen_at)::text from cars;"
echo ""
echo "== BATCH + SEGMENT DONE EVENTS =="
docker logs iaai-worker --tail 3000 2>&1 | grep -E "HTTP-mode batch|HTTP-mode listing done: [1-9]|Segment.*done.*upserted=[1-9]|Sync run.*finished: [1-9]" | tail -15
echo ""
echo "== последние 10 строк =="
docker logs iaai-worker --tail 10

12
burst_test.ps1 Normal file
View File

@@ -0,0 +1,12 @@
$ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
$sw=[Diagnostics.Stopwatch]::StartNew()
$results=@()
1..15 | ForEach-Object {
$id = 45184893 + $_
$code = curl.exe -s -o NUL -A $ua -w "%{http_code}" "https://www.iaai.com/VehicleDetail/$id~US"
$results += [pscustomobject]@{Idx=$_; Id=$id; Code=$code}
}
$sw.Stop()
$results | Format-Table -AutoSize | Out-String | Set-Content burst_results.txt
("elapsed_total_s={0}" -f [math]::Round($sw.Elapsed.TotalSeconds,2)) | Add-Content burst_results.txt
("avg_per_page_s={0}" -f [math]::Round($sw.Elapsed.TotalSeconds/15,2)) | Add-Content burst_results.txt

View File

@@ -5,13 +5,13 @@ x-app-env: &app-env
CELERY_BROKER_URL: ${CELERY_BROKER_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} CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
IAAI_DATABASE_POOL_RECYCLE_SECONDS: ${IAAI_DATABASE_POOL_RECYCLE_SECONDS:-1800} IAAI_DATABASE_POOL_RECYCLE_SECONDS: ${IAAI_DATABASE_POOL_RECYCLE_SECONDS:-1800}
CELERY_TASK_SOFT_TIME_LIMIT: ${CELERY_TASK_SOFT_TIME_LIMIT:-3300} CELERY_TASK_SOFT_TIME_LIMIT: ${CELERY_TASK_SOFT_TIME_LIMIT:-900}
CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT:-3600} CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT:-1200}
CELERY_BROKER_VISIBILITY_TIMEOUT: ${CELERY_BROKER_VISIBILITY_TIMEOUT:-7200} CELERY_BROKER_VISIBILITY_TIMEOUT: ${CELERY_BROKER_VISIBILITY_TIMEOUT:-2400}
# 0 => без лимита (в коде интерпретируется как None). # 0 => без лимита (в коде интерпретируется как None).
# После первичного полного прохода hourly-режим должен успевать за всеми новыми авто. # После первичного полного прохода hourly-режим должен успевать за всеми новыми авто.
CELERY_BEAT_SYNC_LIMIT: ${CELERY_BEAT_SYNC_LIMIT:-0} CELERY_BEAT_SYNC_LIMIT: ${CELERY_BEAT_SYNC_LIMIT:-0}
CELERY_WORKER_MAX_TASKS_PER_CHILD: ${CELERY_WORKER_MAX_TASKS_PER_CHILD:-5} CELERY_WORKER_MAX_TASKS_PER_CHILD: ${CELERY_WORKER_MAX_TASKS_PER_CHILD:-3}
CELERY_BATCH_SIZE: ${CELERY_BATCH_SIZE:-200} CELERY_BATCH_SIZE: ${CELERY_BATCH_SIZE:-200}
IAAI_PARALLEL_TABS: ${IAAI_PARALLEL_TABS:-40} IAAI_PARALLEL_TABS: ${IAAI_PARALLEL_TABS:-40}
IAAI_BLOCK_RESOURCES: ${IAAI_BLOCK_RESOURCES:-true} IAAI_BLOCK_RESOURCES: ${IAAI_BLOCK_RESOURCES:-true}
@@ -134,6 +134,8 @@ services:
container_name: iaai-worker container_name: iaai-worker
restart: unless-stopped restart: unless-stopped
init: true init: true
mem_limit: ${IAAI_WORKER_MEM_LIMIT:-2g}
memswap_limit: ${IAAI_WORKER_MEM_LIMIT:-2g}
depends_on: depends_on:
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
@@ -142,9 +144,9 @@ services:
stop_grace_period: 60s stop_grace_period: 60s
command: > command: >
celery -A iaai_scraper.worker.celery_app worker celery -A iaai_scraper.worker.celery_app worker
--loglevel=info --concurrency=${CELERY_WORKER_CONCURRENCY:-4} --pool=prefork --loglevel=info --concurrency=${CELERY_WORKER_CONCURRENCY:-2} --pool=prefork
--pidfile=/tmp/celery-worker.pid --pidfile=/tmp/celery-worker.pid
-Q scraping --max-tasks-per-child=${CELERY_WORKER_MAX_TASKS_PER_CHILD:-5} -Q scraping --max-tasks-per-child=${CELERY_WORKER_MAX_TASKS_PER_CHILD:-3}
healthcheck: healthcheck:
test: ["CMD-SHELL", "test -f /tmp/celery-worker.pid && kill -0 $(cat /tmp/celery-worker.pid)"] test: ["CMD-SHELL", "test -f /tmp/celery-worker.pid && kill -0 $(cat /tmp/celery-worker.pid)"]
interval: 60s interval: 60s

View File

@@ -17,8 +17,7 @@ def first_non_empty(values: Iterable[Any]) -> Any | None:
return None return None
# Регулярные выражения для VIN, lot и price. # Регулярные выражения для lot и price.
VIN_RE = re.compile(r"\b([A-HJ-NPR-Z0-9]{17})\b", re.IGNORECASE)
LOT_RE = re.compile(r"\b(\d{7,10})\b") LOT_RE = re.compile(r"\b(\d{7,10})\b")
PRICE_RE = re.compile(r"\$\s?([\d,]+(?:\.\d{1,2})?)") PRICE_RE = re.compile(r"\$\s?([\d,]+(?:\.\d{1,2})?)")

View File

@@ -1,12 +1,13 @@
import json import json
import logging import logging
import os import os
import random
import re import re
import signal import signal
import time import time
import uuid import uuid
import html as html_module import html as html_module
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
@@ -15,6 +16,13 @@ from urllib.request import Request, urlopen
import urllib3 import urllib3
try:
import orjson as _orjson # ~3-5× быстрее stdlib json на парсинге
_HAS_ORJSON = True
except ImportError:
_orjson = None
_HAS_ORJSON = False
from playwright.sync_api import Error as PlaywrightError from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import BrowserContext, Page, sync_playwright from playwright.sync_api import BrowserContext, Page, sync_playwright
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
@@ -37,6 +45,69 @@ VEHICLE_ID_RE = re.compile(r"/VehicleDetail/(\d+)(?:~[A-Z]{2})?", re.IGNORECASE)
HTML_TAG_RE = re.compile(r"<[^>]+>") HTML_TAG_RE = re.compile(r"<[^>]+>")
SCRIPT_STYLE_RE = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL) SCRIPT_STYLE_RE = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
# ── Anti-stall watchdog ──
# Жёсткие верхние границы на per-page операции браузера. Если Playwright "тихо" завис
# (частая реакция IAAI на затяжной скрапинг — page hangs без raise), SIGALRM вернёт
# управление и существующая recover-логика (_reopen_listing_and_resume) стартует
# новый context с нуля.
_PAGE_COLLECT_TIMEOUT_S = 90
_PAGE_NEXT_TIMEOUT_S = 60
# Проактивная ротация контекста отключена: reactive-восстановление
# (retry при пустой странице + _reopen_listing_and_resume) уже закрывает
# anti-bot соскоки, а проактивная ротация требовала долистывать кликами
# после reopen и конфликтовала с лимитом max_nav_pages в recovery-пути.
# Включить можно переменной окружения IAAI_CONTEXT_ROTATE_EVERY_PAGES.
_CONTEXT_ROTATE_EVERY_PAGES = int(os.environ.get("IAAI_CONTEXT_ROTATE_EVERY_PAGES", "0") or 0)
class PageOperationTimeoutError(Exception):
"""Browser page operation exceeded per-op watchdog timeout."""
class _PageOpWatchdog:
"""SIGALRM-based watchdog.
Работает только в главном потоке процесса (Celery prefork child — это ок).
В других контекстах — no-op.
"""
def __init__(self, seconds: int, label: str) -> None:
self.seconds = max(1, int(seconds))
self.label = label
self._old_handler = None
self._active = False
def _handler(self, signum, frame): # noqa: ARG002
raise PageOperationTimeoutError(
f"Page operation '{self.label}' exceeded {self.seconds}s watchdog"
)
def __enter__(self):
import threading as _threading
if _threading.current_thread() is not _threading.main_thread():
return self
if not hasattr(signal, "SIGALRM"):
return self
try:
self._old_handler = signal.signal(signal.SIGALRM, self._handler)
signal.alarm(self.seconds)
self._active = True
except (ValueError, OSError):
# signal можно выставлять только из main thread; в остальном пропускаем.
self._active = False
return self
def __exit__(self, exc_type, exc, tb):
if not self._active:
return False
try:
signal.alarm(0)
if self._old_handler is not None:
signal.signal(signal.SIGALRM, self._old_handler)
except (ValueError, OSError):
pass
return False
class IAAIScraper: class IAAIScraper:
@@ -120,13 +191,24 @@ class IAAIScraper:
self.car_mapper = CarMapper() self.car_mapper = CarMapper()
self.persistence = PersistenceService(self.settings) self.persistence = PersistenceService(self.settings)
self._shutdown_requested = False self._shutdown_requested = False
# HTTP pool: при parallel_tabs=24 и concurrency=4 пик ≈ 96 конкурентных сокетов;
# даём запас до 256, чтобы не упираться в PoolError под всплесками PX/retry.
# TCP_NODELAY выключает Nagle: для коротких HTTPS-запросов с keep-alive
# это снижает tail-latency на десятки ms.
import socket as _socket
_socket_options = [
(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1),
(_socket.SOL_SOCKET, _socket.SO_KEEPALIVE, 1),
]
proxy_url = self.settings.proxy.server proxy_url = self.settings.proxy.server
if proxy_url: if proxy_url:
_proxy_kwargs: dict = { _proxy_kwargs: dict = {
"num_pools": 4, "num_pools": 8,
"maxsize": 64, "maxsize": 256,
"block": False,
"retries": False, "retries": False,
"timeout": urllib3.Timeout(connect=5, read=10), "timeout": urllib3.Timeout(connect=5, read=20),
"socket_options": _socket_options,
} }
if self.settings.proxy.username: if self.settings.proxy.username:
_proxy_kwargs["proxy_headers"] = urllib3.make_headers( _proxy_kwargs["proxy_headers"] = urllib3.make_headers(
@@ -136,10 +218,12 @@ class IAAIScraper:
logger.info("HTTP fast-path using proxy: %s", proxy_url) logger.info("HTTP fast-path using proxy: %s", proxy_url)
else: else:
self._http_pool = urllib3.PoolManager( self._http_pool = urllib3.PoolManager(
num_pools=4, num_pools=8,
maxsize=64, maxsize=256,
block=False,
retries=False, retries=False,
timeout=urllib3.Timeout(connect=5, read=10), timeout=urllib3.Timeout(connect=5, read=20),
socket_options=_socket_options,
) )
def _new_trace_id(self, prefix: str) -> str: def _new_trace_id(self, prefix: str) -> str:
@@ -515,32 +599,44 @@ class IAAIScraper:
"all_listing_origin_urls": all_listing_origin_urls, "all_listing_origin_urls": all_listing_origin_urls,
} }
def _process_pending_batch(batch_urls: list[str], batch_start: int) -> bool: # ── Pipeline-режим: батчи (HTTP + DB upsert) выполняются в фоне,
nonlocal cars_upserted, cars_failed, images_upserted, failures # пока главный поток продолжает собирать listing-страницы через
if not batch_urls: # Playwright. Это убирает простой Playwright во время HTTP-фазы
return True # (~50-65 сек на 400 машин) и даёт ~25-30% к throughput.
# max_workers=1: батчи исполняются последовательно, но ВНЕ
# главного потока, чтобы не блокировать сбор listing-а.
# Browser fallback откладывается (skip_browser_fallback=True),
# потому что Playwright не thread-safe — обработаем после loop'а.
batch_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="batch-pipeline")
pending_future: Future | None = None
pending_future_meta: tuple[int, int] | None = None # (batch_start, batch_size)
deferred_fallbacks: list[tuple[str, int]] = []
logger.info( def _drain_pending_future() -> bool:
"Processing batch %d-%d of %d", """Waits for in-flight batch and accumulates its result.
batch_start + 1,
batch_start + len(batch_urls), Returns False if a non-recoverable error occurred and remaining
total, batches should be aborted (mirrors the legacy contract).
) """
try: nonlocal pending_future, pending_future_meta
batch_result = self.sync_batch(batch_urls, lane=lane) nonlocal cars_upserted, cars_failed, images_upserted, failures
cars_upserted += batch_result.get("cars_upserted", 0) if pending_future is None:
cars_failed += batch_result.get("cars_failed", 0)
images_upserted += batch_result.get("images_upserted", 0)
failures.extend(batch_result.get("failures", []))
return True return True
future = pending_future
meta = pending_future_meta or (0, 0)
pending_future = None
pending_future_meta = None
batch_start, batch_size_actual = meta
try:
batch_result = future.result()
except PlaywrightError as pw_exc: except PlaywrightError as pw_exc:
logger.warning( logger.warning(
"Batch %d-%d browser crashed: %s. Reinitializing browser context.", "Batch %d-%d browser crashed: %s. Reinitializing browser context.",
batch_start + 1, batch_start + 1,
batch_start + len(batch_urls), batch_start + batch_size_actual,
pw_exc, pw_exc,
) )
cars_failed += len(batch_urls) cars_failed += batch_size_actual
failures.append({"vehicle_url": f"batch_{batch_start}", "error": str(pw_exc)}) failures.append({"vehicle_url": f"batch_{batch_start}", "error": str(pw_exc)})
try: try:
self._new_context() self._new_context()
@@ -553,12 +649,51 @@ class IAAIScraper:
logger.error( logger.error(
"Batch %d-%d failed: %s. Continuing with next batch.", "Batch %d-%d failed: %s. Continuing with next batch.",
batch_start + 1, batch_start + 1,
batch_start + len(batch_urls), batch_start + batch_size_actual,
batch_exc, batch_exc,
) )
cars_failed += len(batch_urls) cars_failed += batch_size_actual
failures.append({"vehicle_url": f"batch_{batch_start}", "error": str(batch_exc)}) failures.append({"vehicle_url": f"batch_{batch_start}", "error": str(batch_exc)})
return True return True
cars_upserted += batch_result.get("cars_upserted", 0)
cars_failed += batch_result.get("cars_failed", 0)
images_upserted += batch_result.get("images_upserted", 0)
failures.extend(batch_result.get("failures", []))
deferred_fallbacks.extend(batch_result.get("deferred_fallback_urls", []))
return True
def _process_pending_batch(batch_urls: list[str], batch_start: int) -> bool:
nonlocal pending_future, pending_future_meta
# Drain previous batch first (if still running).
if not _drain_pending_future():
return False
if not batch_urls:
return True
logger.info(
"Processing batch %d-%d of %d (pipelined)",
batch_start + 1,
batch_start + len(batch_urls),
total,
)
# Cookie header обязательно вычисляем в главном потоке — Playwright
# context.cookies() использует greenlet, который нельзя дёргать из background thread'а.
cookie_header = self._cookie_header_for_context()
pending_future = batch_executor.submit(
self.sync_batch,
batch_urls,
lane=lane,
skip_browser_fallback=True,
cookie_header_override=cookie_headerkground thread'а.
cookie_header = self._cookie_header_for_context()
pending_future = batch_executor.submit(
self.sync_batch,
batch_urls,
lane=lane,
skip_browser_fallback=True,
cookie_header_override=cookie_header,
)
pending_future_meta = (batch_start, len(batch_urls))
return True
page = None page = None
try: try:
@@ -570,12 +705,77 @@ class IAAIScraper:
year_max=year_max, year_max=year_max,
) )
pages_since_rotation = 0
for page_number in range(1, self.settings.listing.max_pages_per_run + 1): for page_number in range(1, self.settings.listing.max_pages_per_run + 1):
page_result = self.listing_collector.collect_current_page(page, page_number=page_number) # Проактивная ротация контекста (опционально, по умолчанию выключена).
# Если включена — требует долистывания до page_number после reopen,
# поэтому max_nav_pages поднят под реальную глубину.
if _CONTEXT_ROTATE_EVERY_PAGES > 0 and pages_since_rotation >= _CONTEXT_ROTATE_EVERY_PAGES:
logger.warning(
"Rotating browser context at page %d (every %d pages) to reset anti-bot state",
page_number, _CONTEXT_ROTATE_EVERY_PAGES,
)
try:
page.close()
except Exception:
pass
page = None
try:
page, _ = self._reopen_listing_and_resume(
target_page_number=page_number,
make=make,
model=model,
listing_url=listing_url,
year_min=year_min,
year_max=year_max,
max_nav_pages=max(page_number, 500),
)
pages_since_rotation = 0
except ListingResumeError as resume_exc:
logger.warning(
"Context rotation could not resume at page %d (%s) — stopping segment",
page_number, resume_exc,
)
break
try:
with _PageOpWatchdog(_PAGE_COLLECT_TIMEOUT_S, f"collect page {page_number}"):
page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
except (PageOperationTimeoutError, PlaywrightError) as op_exc:
logger.warning(
"Page %d collect stalled/failed (%s) — reopening listing and resuming",
page_number, op_exc,
)
try:
page.close()
except Exception:
pass
page = None
try:
page, _ = self._reopen_listing_and_resume(
target_page_number=page_number,
make=make,
model=model,
listing_url=listing_url,
year_min=year_min,
year_max=year_max,
max_nav_pages=max(page_number, 500),
)
pages_since_rotation = 0
with _PageOpWatchdog(_PAGE_COLLECT_TIMEOUT_S, f"collect page {page_number} (after reopen)"):
page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
except (ListingResumeError, PageOperationTimeoutError, PlaywrightError) as resume_exc:
logger.warning(
"Cannot recover at page %d (%s) — stopping pagination for this segment",
page_number, resume_exc,
)
break
pages_info.append({ pages_info.append({
"page_number": page_result.page_number, "page_number": page_result.page_number,
"links_found": len(page_result.vehicle_links), "links_found": len(page_result.vehicle_links),
}) })
pages_since_rotation += 1
page_urls = self._extract_page_urls( page_urls = self._extract_page_urls(
page_result, page_result,
@@ -604,6 +804,7 @@ class IAAIScraper:
listing_url=listing_url, listing_url=listing_url,
year_min=year_min, year_min=year_min,
year_max=year_max, year_max=year_max,
max_nav_pages=max(page_number, 500),
) )
page_result = self.listing_collector.collect_current_page(page, page_number=page_number) page_result = self.listing_collector.collect_current_page(page, page_number=page_number)
page_urls = self._extract_page_urls( page_urls = self._extract_page_urls(
@@ -694,9 +895,21 @@ class IAAIScraper:
or not page_result.next_page_detected or not page_result.next_page_detected
): ):
break break
if not self.listing_collector.go_to_next_page(page, expected_page_number=page_number + 1): try:
with _PageOpWatchdog(_PAGE_NEXT_TIMEOUT_S, f"next page {page_number + 1}"):
next_ok = self.listing_collector.go_to_next_page(page, expected_page_number=page_number + 1)
except (PageOperationTimeoutError, PlaywrightError) as nav_exc:
logger.warning(
"go_to_next_page stalled/failed at page %d (%s) — will reopen",
page_number + 1, nav_exc,
)
next_ok = False
if not next_ok:
logger.warning("Failed to navigate to page %d — reopening listing and resuming", page_number + 1) logger.warning("Failed to navigate to page %d — reopening listing and resuming", page_number + 1)
page.close() try:
page.close()
except Exception:
pass
page = None page = None
try: try:
page, _ = self._reopen_listing_and_resume( page, _ = self._reopen_listing_and_resume(
@@ -706,7 +919,9 @@ class IAAIScraper:
listing_url=listing_url, listing_url=listing_url,
year_min=year_min, year_min=year_min,
year_max=year_max, year_max=year_max,
max_nav_pages=max(page_number + 1, 500),
) )
pages_since_rotation = 0
except ListingResumeError as resume_exc: except ListingResumeError as resume_exc:
logger.warning( logger.warning(
"Cannot resume at page %d (%s) — treating pagination as exhausted", "Cannot resume at page %d (%s) — treating pagination as exhausted",
@@ -717,6 +932,35 @@ class IAAIScraper:
if pending_urls: if pending_urls:
_process_pending_batch(pending_urls, cars_upserted + cars_failed) _process_pending_batch(pending_urls, cars_upserted + cars_failed)
# Финальный drain последнего in-flight батча.
_drain_pending_future()
# Обрабатываем отложенные browser fallback'и (Playwright теперь свободен).
if deferred_fallbacks:
fb_pages = min(len(deferred_fallbacks), self._FALLBACK_PARALLEL_PAGES)
logger.info(
"Processing %d deferred browser fallbacks (%d parallel pages)",
len(deferred_fallbacks), fb_pages,
)
try:
fb_results = self._browser_fallback_parallel(
deferred_fallbacks, len(deferred_fallbacks), fb_pages,
)
fb_records = fb_results.get("records", [])
if fb_records:
try:
upsert_result = self.persistence.upsert_cars_batch(fb_records)
cars_upserted += upsert_result["inserted"] + upsert_result["updated"]
images_upserted += upsert_result["images_upserted"]
except Exception as exc:
logger.error("Deferred fallback upsert failed: %s", exc)
cars_failed += len(fb_records)
cars_failed += fb_results.get("cars_failed", 0)
failures.extend(fb_results.get("failures", []))
except Exception as fb_exc:
logger.error("Deferred fallback batch failed: %s", fb_exc)
cars_failed += len(deferred_fallbacks)
logger.warning( logger.warning(
"sync_listing final progress: pages=%d, discovered=%d, upserted=%d, failed=%d", "sync_listing final progress: pages=%d, discovered=%d, upserted=%d, failed=%d",
len(pages_info), len(pages_info),
@@ -725,6 +969,12 @@ class IAAIScraper:
cars_failed, cars_failed,
) )
finally: finally:
# Ensure no future is left dangling on exception path.
try:
_drain_pending_future()
except Exception:
pass
batch_executor.shutdown(wait=True)
if page is not None: if page is not None:
page.close() page.close()
@@ -979,7 +1229,8 @@ class IAAIScraper:
"""Извлекает IAAI inventoryView.attributes из HTML без браузера. """Извлекает IAAI inventoryView.attributes из HTML без браузера.
Использует json.JSONDecoder.raw_decode для быстрого поиска JSON Использует json.JSONDecoder.raw_decode для быстрого поиска JSON
вместо посимвольного сканирования скобок. вместо посимвольного сканирования скобок. Если получившийся срез
целиком отдаётся декодеру — пробуем orjson (3-5× быстрее stdlib).
""" """
search = "inventoryView" search = "inventoryView"
pos = html.find(search) pos = html.find(search)
@@ -996,11 +1247,24 @@ class IAAIScraper:
if content_start < 0: if content_start < 0:
return None return None
decoder = json.JSONDecoder() # Быстрый путь: вырезаем всё до </script>, пытаемся orjson; иначе stdlib.
try: obj: dict | None = None
obj, _ = decoder.raw_decode(html, content_start) if _HAS_ORJSON:
except (json.JSONDecodeError, ValueError): script_end = html.find("</script>", content_start)
return None if script_end > content_start:
# Подрезаем хвост до последней закрывающей скобки JSON.
snippet = html[content_start:script_end].rstrip().rstrip(";")
try:
obj = _orjson.loads(snippet)
except Exception:
obj = None
if obj is None:
decoder = json.JSONDecoder()
try:
obj, _ = decoder.raw_decode(html, content_start)
except (json.JSONDecodeError, ValueError):
return None
iv = obj.get("inventoryView") iv = obj.get("inventoryView")
if not iv or not iv.get("attributes"): if not iv or not iv.get("attributes"):
@@ -1118,11 +1382,19 @@ class IAAIScraper:
cookie_header: str, cookie_header: str,
user_agent: str, user_agent: str,
) -> CarRecord: ) -> CarRecord:
"""Быстрый HTTP-запрос через urllib3 (connection pooling / keep-alive).""" """Быстрый HTTP-запрос через urllib3 (connection pooling / keep-alive).
Заголовок Accept-Encoding включает gzip/deflate/br — IAAI отдаёт HTML
со сжатием 5-10×, что снижает байты по сети и время декода.
urllib3 сам прозрачно распаковывает gzip и deflate; для br нужен
пакет `brotli` (он в зависимостях). Если brotli не установлен — он
просто не появится в Accept-Encoding и сервер ответит gzip.
"""
headers = { headers = {
"User-Agent": user_agent, "User-Agent": user_agent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9", "Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": self._ACCEPT_ENCODING,
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Pragma": "no-cache", "Pragma": "no-cache",
"Connection": "keep-alive", "Connection": "keep-alive",
@@ -1131,7 +1403,10 @@ class IAAIScraper:
if cookie_header: if cookie_header:
headers["Cookie"] = cookie_header headers["Cookie"] = cookie_header
response = self._http_pool.request("GET", vehicle_url, headers=headers) # decode_content=True — urllib3 сам распакует gzip/deflate/br.
response = self._http_pool.request(
"GET", vehicle_url, headers=headers, decode_content=True,
)
if response.status >= 400: if response.status >= 400:
raise RuntimeError(f"HTTP fetch failed for {vehicle_url}: {response.status}") raise RuntimeError(f"HTTP fetch failed for {vehicle_url}: {response.status}")
html = response.data.decode("utf-8", errors="ignore") html = response.data.decode("utf-8", errors="ignore")
@@ -1205,6 +1480,10 @@ class IAAIScraper:
_HTTP_RETRY_DELAYS = (0.4, 1.0) # Задержки между повторами _HTTP_RETRY_DELAYS = (0.4, 1.0) # Задержки между повторами
_FALLBACK_PARALLEL_PAGES = 4 # Параллельных вкладок для fallback _FALLBACK_PARALLEL_PAGES = 4 # Параллельных вкладок для fallback
_FALLBACK_NAV_TIMEOUT_MS = 8000 # Таймаут навигации fallback _FALLBACK_NAV_TIMEOUT_MS = 8000 # Таймаут навигации fallback
# gzip+deflate: нативно в urllib3 (zlib, ~0 CPU). Brotli отключён намеренно —
# python-brotli декодирует на CPU и под 32 параллельными потоками становится
# узким местом (хуже, чем гонять чуть больше байт по сети).
_ACCEPT_ENCODING = "gzip, deflate"
def _browser_fallback_parallel( def _browser_fallback_parallel(
self, self,
@@ -1359,11 +1638,15 @@ class IAAIScraper:
"protection_events": protection_events, "protection_events": protection_events,
} }
cookie_header_override: str | None = None,
def sync_batch( def sync_batch(
self, self,
vehicle_urls: list[str], vehicle_urls: list[str],
lane: str = "iaai_cars", lane: str = "iaai_cars",
parallel_tabs: int | None = None, parallel_tabs: int | None = None,
*,
skip_browser_fallback: bool = False,
cookie_header_override: str | None = None,
) -> dict: ) -> dict:
trace_id = self._new_trace_id("sync-batch") trace_id = self._new_trace_id("sync-batch")
started_at = time.perf_counter() started_at = time.perf_counter()
@@ -1374,7 +1657,13 @@ class IAAIScraper:
cars_failed = 0 cars_failed = 0
images_upserted = 0 images_upserted = 0
records: list[CarRecord] = [] records: list[CarRecord] = []
failures: list[dict[str, str]] = [] # cookie_header_override передаётся из главного потока при pipeline-вызове —
# вычисление cookie_header из background thread'а ломает Playwright sync greenlet.
cookie_header = (
cookie_header_override
if cookie_header_override is not None
else self._cookie_header_for_context()
http_successes = 0 http_successes = 0
http_fallbacks = 0 http_fallbacks = 0
protection_events = 0 protection_events = 0
@@ -1382,17 +1671,25 @@ class IAAIScraper:
total = len(vehicle_urls) total = len(vehicle_urls)
logger.info("sync_batch: %d vehicles, %d parallel workers", total, num_workers) logger.info("sync_batch: %d vehicles, %d parallel workers", total, num_workers)
cookie_header = self._cookie_header_for_context() # cookie_header_override передаётся из главного потока при pipeline-вызове —
user_agent = ( # вычисление cookie_header из background thread'а ломает Playwright sync greenlet.
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) " cookie_header = (
"Gecko/20100101 Firefox/128.0" cookie_header_override
if cookie_header_override is not None
else self._cookie_header_for_context()
) )
# UA должен совпадать с UA Playwright-контекста, иначе Imperva видит
# «cookies от Chrome, ходим под Firefox» — типичный сигнал бота.
user_agent = self.settings.fingerprint.user_agent
# ── Фаза 1: HTTP fast-path ── # ── Фаза 1: HTTP fast-path ──
fallback_urls: list[tuple[str, int]] = [] fallback_urls: list[tuple[str, int]] = []
def _fetch_one_with_retry(idx_url: tuple[int, str]) -> tuple[int, CarRecord | Exception]: def _fetch_one_with_retry(idx_url: tuple[int, str]) -> tuple[int, CarRecord | Exception]:
idx, url = idx_url idx, url = idx_url
# Небольшой анти-бёрст джиттер: разносим старт запросов в пуле,
# чтобы N воркеров не били в одну TLS-/PX-волну.
time.sleep(random.uniform(0.0, 0.15))
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(1 + self._HTTP_MAX_RETRIES): for attempt in range(1 + self._HTTP_MAX_RETRIES):
try: try:
@@ -1431,16 +1728,28 @@ class IAAIScraper:
) )
# ── Фаза 2: браузерный fallback ── # ── Фаза 2: браузерный fallback ──
# При skip_browser_fallback=True (pipeline-режим) возвращаем fallback_urls
# вызывающему — Playwright занят сбором листинга в главном потоке,
# его нельзя трогать из фонового executor'а.
deferred_fallback_urls: list[tuple[str, int]] = []
if fallback_urls: if fallback_urls:
logger.info( if skip_browser_fallback:
"Fallback browser mode: %d/%d URLs, single page", deferred_fallback_urls = fallback_urls
len(fallback_urls), total, logger.info(
) "Deferring browser fallback: %d/%d URLs (pipeline mode)",
fb_results = self._browser_fallback_parallel(fallback_urls, total, 1) len(fallback_urls), total,
records.extend(fb_results["records"]) )
cars_failed += fb_results["cars_failed"] else:
protection_events += fb_results["protection_events"] fb_pages = min(len(fallback_urls), self._FALLBACK_PARALLEL_PAGES)
failures.extend(fb_results["failures"]) logger.info(
"Fallback browser mode: %d/%d URLs, %d parallel pages",
len(fallback_urls), total, fb_pages,
)
fb_results = self._browser_fallback_parallel(fallback_urls, total, fb_pages)
records.extend(fb_results["records"])
cars_failed += fb_results["cars_failed"]
protection_events += fb_results["protection_events"]
failures.extend(fb_results["failures"])
# ── Фаза 3: запись в БД ── # ── Фаза 3: запись в БД ──
if records: if records:
@@ -1475,6 +1784,7 @@ class IAAIScraper:
"protection_events": protection_events, "protection_events": protection_events,
"elapsed_seconds": round(time.perf_counter() - started_at, 3), "elapsed_seconds": round(time.perf_counter() - started_at, 3),
"failures": failures, "failures": failures,
"deferred_fallback_urls": deferred_fallback_urls,
} }
def sync_listing( def sync_listing(

View File

@@ -608,7 +608,10 @@ def sync_listing_task(
) )
if force_bootstrap_full_scan: if force_bootstrap_full_scan:
_set_full_scan_done(redis_client, False) _set_full_scan_done(redis_client, False)
_enqueue_bootstrap_followup("soft_time_limit_exceeded", count_as_failure=False) # SoftTimeLimit считаем failure для circuit breaker:
# если сегмент систематически не укладывается в time limit,
# нельзя крутить followup бесконечно.
_enqueue_bootstrap_followup("soft_time_limit_exceeded", count_as_failure=True)
# Partial progress уже записан в БД через finish_sync_run. # Partial progress уже записан в БД через finish_sync_run.
# Не retry — следующий запуск продолжит обработку по расписанию. # Не retry — следующий запуск продолжит обработку по расписанию.
return { return {

View File

@@ -20,6 +20,8 @@ dependencies = [
"sqlalchemy>=2.0.32", "sqlalchemy>=2.0.32",
"uvicorn>=0.34.0", "uvicorn>=0.34.0",
"urllib3>=2.0.0", "urllib3>=2.0.0",
"orjson>=3.10.0",
"brotli>=1.1.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

1
sitemap1.xml Normal file

File diff suppressed because one or more lines are too long

1
sitemap2.xml Normal file

File diff suppressed because one or more lines are too long

1
sitemap3.xml Normal file

File diff suppressed because one or more lines are too long

1
sitemapMake1.xml Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
sitemap_test.xml Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><!--List of sitemaps created--><sitemapindex xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemap1.xml</loc><lastmod>2026-04-18T04:03:01-05:00</lastmod></sitemap><sitemap><loc>https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemap2.xml</loc><lastmod>2026-04-18T04:03:01-05:00</lastmod></sitemap><sitemap><loc>https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemap3.xml</loc><lastmod>2026-04-18T04:03:01-05:00</lastmod></sitemap><sitemap><loc>https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemapbranches1.xml</loc><lastmod>2026-04-18T04:03:01-05:00</lastmod></sitemap><sitemap><loc>https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemapauctions1.xml</loc><lastmod>2026-04-18T04:03:01-05:00</lastmod></sitemap><sitemap><loc>https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemapMake1.xml</loc><lastmod>2026-04-18T04:03:01-05:00</lastmod></sitemap><sitemap><loc>https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemapMakeModel1.xml</loc><lastmod>2026-04-18T04:03:01-05:00</lastmod></sitemap><sitemap><loc>https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemapMakeModelYear1.xml</loc><lastmod>2026-04-18T04:03:01-05:00</lastmod></sitemap><sitemap><loc>https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemapVehicleTypeMake1.xml</loc><lastmod>2026-04-18T04:03:01-05:00</lastmod></sitemap><sitemap><loc>https://www.iaai.com/Xj9rDOVMEi0hc38S/sitemapVehicleType.xml</loc><lastmod>2026-04-18T04:03:01-05:00</lastmod></sitemap></sitemapindex>

1
sitemapauctions1.xml Normal file

File diff suppressed because one or more lines are too long

1
sitemapbranches1.xml Normal file

File diff suppressed because one or more lines are too long

4281
vd_test.html Normal file

File diff suppressed because one or more lines are too long