fix scraper flow
This commit is contained in:
@@ -14,13 +14,6 @@ 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",)
|
||||
@@ -42,11 +35,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 +110,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 +319,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 +380,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 +411,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 +446,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 +454,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 +475,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 +515,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)
|
||||
|
||||
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,
|
||||
)
|
||||
return 0
|
||||
|
||||
if is_postgres:
|
||||
# Создаём временную таблицу с активными URL.
|
||||
session.execute(text("CREATE TEMP TABLE IF NOT EXISTS _active_urls (url TEXT PRIMARY KEY) ON COMMIT DROP"))
|
||||
# Считаем кандидатов на sold.
|
||||
total_active = session.execute(
|
||||
text("SELECT count(*) FROM cars WHERE is_sold = FALSE AND origin_id LIKE 'iaai:%%'")
|
||||
).scalar() or 0
|
||||
|
||||
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 чанками через executemany.
|
||||
url_list = list(active_origin_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]
|
||||
session.execute(
|
||||
text("INSERT INTO _active_urls (url) VALUES (:url) ON CONFLICT DO NOTHING"),
|
||||
[{"url": url} for url in chunk],
|
||||
)
|
||||
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)
|
||||
|
||||
# Массовая пометка проданных в PostgreSQL.
|
||||
# Индекс для 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 cars 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:%%'
|
||||
""")).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
|
||||
|
||||
# Массовая пометка sold.
|
||||
result = session.execute(text("""
|
||||
UPDATE cars
|
||||
SET is_sold = TRUE
|
||||
FROM (
|
||||
SELECT c.id
|
||||
FROM cars c
|
||||
LEFT JOIN _active_urls a ON c.origin_url = a.url
|
||||
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}:%"})
|
||||
"""))
|
||||
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 +633,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]]
|
||||
Reference in New Issue
Block a user