All checks were successful
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
N1.ru перестал быть самостоятельным источником (переехал в Cian, подтверждено владельцем 2026-06). Активный скрап прекращён; 389 исторических listings в БД сохраняются без изменений. Убрано: - app/services/scrapers/n1.py (удалён) - run_n1_city_sweep + N1CitySweepCounters + N1_SWEEP_MAX_CONSECUTIVE_FAILURES из scrape_pipeline.py - trigger_n1_city_sweep_run + dispatch elif в scheduler.py - POST /scrape/n1 + GET /scrape/n1/runs endpoints из admin.py - N1Scraper из scrape_around dispatch dict и ScrapeRequest.multi_room_n1 - "n1": 5.0 из scraper_settings._DEFAULT_DELAY_BY_SOURCE - tests/test_n1_city_sweep.py, tests/test_n1_floor_parse.py (удалены) Добавлено: - data/sql/114_disable_n1_sweep.sql: UPDATE scrape_schedules SET enabled=false WHERE source='n1_city_sweep' (idempotent, строка сохраняется для audit-истории) Сохранено (историческое): - schemas/search.py Literal[..."n1"] — API-фильтр исторических listings - config.py scrape_allowed_hosts n1.ru / ekaterinburg.n1.ru - exporters/trade_in_pdf.py — цвет/лейбл N1 для отображения старых записей - geocoder.py и admin.py docstring N1-комментарии где не активный код 1802 passed, 2 deselected (test_search_cache_hit, test_cian_valuation.cache_hit)
136 lines
4.8 KiB
Python
136 lines
4.8 KiB
Python
"""scraper_settings.py — загрузка задержек парсеров из таблицы scraper_settings.
|
||
|
||
Кеш в памяти с TTL 60 секунд — не нагружает БД при каждом запросе.
|
||
invalidate_cache() вызывается из admin API PUT для немедленного применения.
|
||
|
||
Ключевая логика:
|
||
get_scraper_delay(source) = max(per_source_delay, global_delay)
|
||
Строка source='global' задаёт нижнюю планку для ВСЕХ парсеров.
|
||
Если global=0 — используется только per-source значение.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
from threading import Lock
|
||
|
||
from sqlalchemy import text
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Специальный ключ глобальной задержки (строка в scraper_settings с этим source).
|
||
_GLOBAL_KEY = "global"
|
||
|
||
# In-process cache: {source: (delay_sec, fetched_at_epoch)}
|
||
_CACHE: dict[str, tuple[float, float]] = {}
|
||
_CACHE_LOCK = Lock()
|
||
_CACHE_TTL_SEC = 60.0
|
||
|
||
# Default fallback if DB row missing or DB unreachable
|
||
_DEFAULT_DELAY_BY_SOURCE: dict[str, float] = {
|
||
"avito": 7.0,
|
||
"cian": 5.0,
|
||
"domklik": 8.0,
|
||
"yandex": 5.0,
|
||
"yandex_detail": 5.0,
|
||
"yandex_newbuilding": 5.0,
|
||
"yandex_valuation": 5.0,
|
||
"domrf": 5.0,
|
||
"rosreestr": 5.0,
|
||
}
|
||
_GLOBAL_DEFAULT_DELAY = 5.0
|
||
|
||
# Yandex sub-scrapers all read the umbrella "yandex" key.
|
||
# Map: scraper_name -> DB row key.
|
||
_KEY_ALIASES: dict[str, str] = {
|
||
"yandex": "yandex",
|
||
"yandex_detail": "yandex",
|
||
"yandex_newbuilding": "yandex",
|
||
"yandex_realty_nb": "yandex", # BaseScraper.name used by YandexNewbuildingScraper
|
||
"yandex_valuation": "yandex",
|
||
# Non-Yandex sources resolve to their own keys for forward-compat
|
||
# (no rows exist yet for them; fall back to default).
|
||
}
|
||
|
||
|
||
def _open_session() -> object:
|
||
"""Return a new SessionLocal context-manager instance.
|
||
|
||
Deferred import avoids triggering Settings() validation at module import
|
||
time (unit tests run without DATABASE_URL env var).
|
||
Extracted as a named function so tests can patch it.
|
||
"""
|
||
from app.core.db import SessionLocal
|
||
|
||
return SessionLocal()
|
||
|
||
|
||
def _get_setting_cached(key: str) -> float:
|
||
"""Cache-aware read of one source key from scraper_settings.
|
||
|
||
Returns request_delay_sec from DB (with TTL=60s cache).
|
||
Missing row: falls back to _DEFAULT_DELAY_BY_SOURCE, or 0.0 for _GLOBAL_KEY
|
||
(meaning no global floor is applied when the row hasn't been seeded yet).
|
||
"""
|
||
now = time.time()
|
||
with _CACHE_LOCK:
|
||
cached = _CACHE.get(key)
|
||
if cached is not None and now - cached[1] < _CACHE_TTL_SEC:
|
||
return cached[0]
|
||
|
||
try:
|
||
with _open_session() as db:
|
||
row = db.execute(
|
||
text("SELECT request_delay_sec FROM scraper_settings WHERE source = :s"),
|
||
{"s": key},
|
||
).first()
|
||
if row is not None:
|
||
value = float(row[0])
|
||
elif key == _GLOBAL_KEY:
|
||
# Global row not yet seeded — don't apply floor.
|
||
value = 0.0
|
||
else:
|
||
value = _DEFAULT_DELAY_BY_SOURCE.get(key, _GLOBAL_DEFAULT_DELAY)
|
||
except Exception as e:
|
||
logger.warning("scraper_settings: load failed for %s -- using default: %s", key, e)
|
||
value = (
|
||
0.0 if key == _GLOBAL_KEY else _DEFAULT_DELAY_BY_SOURCE.get(key, _GLOBAL_DEFAULT_DELAY)
|
||
)
|
||
|
||
with _CACHE_LOCK:
|
||
_CACHE[key] = (value, now)
|
||
return value
|
||
|
||
|
||
def get_scraper_delay(source: str) -> float:
|
||
"""Return the configured request_delay_sec for `source`.
|
||
|
||
Effective delay = max(per_source, global).
|
||
- Maps Yandex sub-scrapers to the umbrella 'yandex' key.
|
||
- Cached 60s in-process to avoid per-instance DB query.
|
||
- source='global' sets a floor across all scrapers (0 = disabled).
|
||
- Any DB error -> returns the hardcoded default for that source.
|
||
"""
|
||
key = _KEY_ALIASES.get(source, source)
|
||
per_source = _get_setting_cached(key)
|
||
global_value = _get_setting_cached(_GLOBAL_KEY)
|
||
return max(per_source, global_value)
|
||
|
||
|
||
def invalidate_cache(source: str | None = None) -> None:
|
||
"""Clear the cache entry for `source`, or the whole cache if None.
|
||
|
||
Called by admin endpoint after PUT to force a re-read on next get_scraper_delay().
|
||
"""
|
||
with _CACHE_LOCK:
|
||
if source is None:
|
||
_CACHE.clear()
|
||
logger.info("scraper_settings: cache cleared (all sources)")
|
||
else:
|
||
key = _KEY_ALIASES.get(source, source)
|
||
_CACHE.pop(key, None)
|
||
# Also clear the source itself if it differed from key (alias case).
|
||
if key != source:
|
||
_CACHE.pop(source, None)
|
||
logger.info("scraper_settings: cache cleared for source=%s", source)
|