feat: check source links during scheduled fetches
This commit is contained in:
@@ -42,7 +42,7 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
|||||||
|
|
||||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. На странице сочетания оба знака собираются в единую атласную эмблему, detail-иерархию связывает breadcrumb-леска с текстовыми узлами, а боковые переходы повторяют знаки связанных сущностей. Это не карта и не игровая география. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения.
|
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Каталоги оформлены как полевой атлас: тёмный seal показывает объём справочника, карточки рыб используют смысловые SVG-силуэты, а каждый водоём — собственный детерминированный абстрактный отпечаток берега, волн, точки и индекса. На странице сочетания оба знака собираются в единую атласную эмблему, detail-иерархию связывает breadcrumb-леска с текстовыми узлами, а боковые переходы повторяют знаки связанных сущностей. Это не карта и не игровая география. Пустые состояния используют статичную CSS-иллюстрацию поплавка; смысловые анимации полностью учитывают системное ограничение движения.
|
||||||
|
|
||||||
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Тот же запрос служит проверкой точной исходной ссылки: `404/410` означает `missing`, `401/403/429` — `blocked`, остальные сбои — `temporary_error`; отдельного link-checker и дополнительных обращений нет. Пропажа элемента из агрегатного списка сама по себе удалением не считается. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
||||||
|
|
||||||
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .config import settings
|
|||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .models import CommunityImportRun, DataSource
|
from .models import CommunityImportRun, DataSource
|
||||||
|
from .source_lifecycle import classify_source_failure, record_scheduled_source_check
|
||||||
|
|
||||||
logger = logging.getLogger("rf4.community_scheduler")
|
logger = logging.getLogger("rf4.community_scheduler")
|
||||||
MAX_BACKOFF_SECONDS = 24 * 60 * 60
|
MAX_BACKOFF_SECONDS = 24 * 60 * 60
|
||||||
@@ -102,11 +103,27 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
|
|||||||
html = fetch_html(url)
|
html = fetch_html(url)
|
||||||
records = parser(html, source_url=url) if source_system in {"rf4map", "rf4posts-spot"} else parser(html)
|
records = parser(html, source_url=url) if source_system in {"rf4map", "rf4posts-spot"} else parser(html)
|
||||||
created, updated = stage_observations(session, [asdict(item) for item in records])
|
created, updated = stage_observations(session, [asdict(item) for item in records])
|
||||||
|
record_scheduled_source_check(
|
||||||
|
session, source_system=source_system, source_url=url,
|
||||||
|
status="available", checked_at=current,
|
||||||
|
)
|
||||||
run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated
|
run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
|
source_status = classify_source_failure(exc)
|
||||||
|
checked = datetime.now(timezone.utc)
|
||||||
|
affected = record_scheduled_source_check(
|
||||||
|
session,
|
||||||
|
source_system=source_system,
|
||||||
|
source_url=url,
|
||||||
|
status=source_status,
|
||||||
|
checked_at=checked,
|
||||||
|
)
|
||||||
run.status, run.error_summary = "failed", f"{type(exc).__name__}: {str(exc)[:500]}"
|
run.status, run.error_summary = "failed", f"{type(exc).__name__}: {str(exc)[:500]}"
|
||||||
logger.exception("community import failed", extra={"event":"community_import_failed", "source_system":source_system})
|
logger.exception("community import failed", extra={
|
||||||
|
"event":"community_import_failed", "source_system":source_system,
|
||||||
|
"source_check_status": source_status, "affected_observations": affected,
|
||||||
|
})
|
||||||
run.finished_at = datetime.now(timezone.utc); session.commit()
|
run.finished_at = datetime.now(timezone.utc); session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .models import ExternalObservation, ModerationStatus
|
from .models import ExternalObservation, ModerationStatus
|
||||||
@@ -11,6 +13,16 @@ from .models import ExternalObservation, ModerationStatus
|
|||||||
SourceCheckStatus = Literal["available", "missing", "temporary_error", "blocked"]
|
SourceCheckStatus = Literal["available", "missing", "temporary_error", "blocked"]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_source_failure(exc: Exception) -> SourceCheckStatus:
|
||||||
|
"""Classify the result of the scheduled request without retrying it."""
|
||||||
|
if isinstance(exc, HTTPError):
|
||||||
|
if exc.code in {404, 410}:
|
||||||
|
return "missing"
|
||||||
|
if exc.code in {401, 403, 429}:
|
||||||
|
return "blocked"
|
||||||
|
return "temporary_error"
|
||||||
|
|
||||||
|
|
||||||
def record_source_check(
|
def record_source_check(
|
||||||
session: Session,
|
session: Session,
|
||||||
observation: ExternalObservation,
|
observation: ExternalObservation,
|
||||||
@@ -24,7 +36,18 @@ def record_source_check(
|
|||||||
data. Transient errors and access blocks remain diagnostic and never remove
|
data. Transient errors and access blocks remain diagnostic and never remove
|
||||||
an observation from activity.
|
an observation from activity.
|
||||||
"""
|
"""
|
||||||
current = checked_at or datetime.now(timezone.utc)
|
_apply_source_check(observation, status, checked_at or datetime.now(timezone.utc))
|
||||||
|
session.commit()
|
||||||
|
return observation
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_source_check(
|
||||||
|
observation: ExternalObservation,
|
||||||
|
status: SourceCheckStatus,
|
||||||
|
checked_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
"""Mutate one observation; the caller owns the transaction boundary."""
|
||||||
|
current = checked_at
|
||||||
observation.source_check_status = status
|
observation.source_check_status = status
|
||||||
observation.source_checked_at = current
|
observation.source_checked_at = current
|
||||||
if status == "missing" and observation.status != "withdrawn":
|
if status == "missing" and observation.status != "withdrawn":
|
||||||
@@ -34,5 +57,27 @@ def record_source_check(
|
|||||||
observation.review_note = "Source record missing; withdrawn pending moderator review"
|
observation.review_note = "Source record missing; withdrawn pending moderator review"
|
||||||
observation.reviewed_at = current
|
observation.reviewed_at = current
|
||||||
observation.moderation_version += 1
|
observation.moderation_version += 1
|
||||||
|
|
||||||
|
|
||||||
|
def record_scheduled_source_check(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
source_system: str,
|
||||||
|
source_url: str,
|
||||||
|
status: SourceCheckStatus,
|
||||||
|
checked_at: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Apply one scheduled request result only to observations with that exact URL.
|
||||||
|
|
||||||
|
Aggregate pages cannot prove that an omitted record was deleted, so absence
|
||||||
|
from a parsed listing is deliberately ignored.
|
||||||
|
"""
|
||||||
|
observations = list(session.scalars(select(ExternalObservation).where(
|
||||||
|
ExternalObservation.source_system == source_system,
|
||||||
|
ExternalObservation.source_url == source_url,
|
||||||
|
)))
|
||||||
|
current = checked_at or datetime.now(timezone.utc)
|
||||||
|
for observation in observations:
|
||||||
|
_apply_source_check(observation, status, current)
|
||||||
session.commit()
|
session.commit()
|
||||||
return observation
|
return len(observations)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.community_importer import CommunityImportError, stage_observations
|
from app.community_importer import CommunityImportError, stage_observations
|
||||||
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
|
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
|
||||||
from app.source_lifecycle import record_source_check
|
from app.source_lifecycle import record_scheduled_source_check, record_source_check
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
||||||
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
|
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
|
||||||
@@ -186,6 +186,35 @@ def test_non_authoritative_source_failures_do_not_withdraw(db: Session, status:
|
|||||||
assert item.source_check_status == status
|
assert item.source_check_status == status
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduled_failure_only_affects_exact_source_url(db: Session) -> None:
|
||||||
|
stage_observations(db, [
|
||||||
|
record(external_id="matching"),
|
||||||
|
record(external_id="other") | {"source_url": "https://rf4db.com/catches/other"},
|
||||||
|
])
|
||||||
|
|
||||||
|
affected = record_scheduled_source_check(
|
||||||
|
db,
|
||||||
|
source_system="rf4db",
|
||||||
|
source_url="https://rf4db.com/ru/catches/matching",
|
||||||
|
status="missing",
|
||||||
|
)
|
||||||
|
items = {item.source_external_id: item for item in db.scalars(select(ExternalObservation))}
|
||||||
|
|
||||||
|
assert affected == 1
|
||||||
|
assert items["matching"].status == "withdrawn"
|
||||||
|
assert items["other"].status != "withdrawn"
|
||||||
|
assert items["other"].source_check_status == "available"
|
||||||
|
|
||||||
|
record_scheduled_source_check(
|
||||||
|
db,
|
||||||
|
source_system="rf4db",
|
||||||
|
source_url="https://rf4db.com/ru/catches/matching",
|
||||||
|
status="available",
|
||||||
|
)
|
||||||
|
assert items["matching"].source_check_status == "available"
|
||||||
|
assert items["matching"].status == "withdrawn"
|
||||||
|
|
||||||
|
|
||||||
def test_auto_publication_requires_enabled_source(db: Session) -> None:
|
def test_auto_publication_requires_enabled_source(db: Session) -> None:
|
||||||
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False)
|
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False)
|
||||||
fish = Fish(slug="pike", name_ru="Щука")
|
fish = Fish(slug="pike", name_ru="Щука")
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, _static_registry, oldest_site_source, retry_delay
|
from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, _static_registry, oldest_site_source, retry_delay
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
|
from app.source_lifecycle import classify_source_failure
|
||||||
|
|
||||||
|
|
||||||
def test_all_authorized_sources_are_scheduled() -> None:
|
def test_all_authorized_sources_are_scheduled() -> None:
|
||||||
@@ -23,6 +25,16 @@ def test_failed_runs_back_off_but_success_resets_delay() -> None:
|
|||||||
assert retry_delay(["success", "failed"]) == 1800
|
assert retry_delay(["success", "failed"]) == 1800
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("code", "expected"), [(404, "missing"), (410, "missing"), (403, "blocked"), (429, "blocked"), (500, "temporary_error")])
|
||||||
|
def test_source_http_failure_classification(code: int, expected: str) -> None:
|
||||||
|
error = HTTPError("https://rf4.example/source", code, "failure", {}, None)
|
||||||
|
assert classify_source_failure(error) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_http_source_failure_is_temporary() -> None:
|
||||||
|
assert classify_source_failure(TimeoutError("timeout")) == "temporary_error"
|
||||||
|
|
||||||
|
|
||||||
def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
|
def test_same_site_endpoints_rotate_by_oldest_attempt() -> None:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
all_keys = {"rf4db", "rf4stat-fishing", "rf4stat-post", "rf4map", "rf4posts-spot"}
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@
|
|||||||
Подтверждено:
|
Подтверждено:
|
||||||
|
|
||||||
- [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md);
|
- [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md);
|
||||||
- [x] полный Python suite: **130 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой;
|
- [x] полный Python suite: **154 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой;
|
||||||
- [x] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит;
|
- [x] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит;
|
||||||
- [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**;
|
- [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**;
|
||||||
- [x] локальная БД и чистый bootstrap достигают Alembic head `20260910_recovery`;
|
- [x] локальная БД и чистый bootstrap достигают Alembic head `20260910_recovery`;
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
- [ ] **Q01 · Документы источников — реестр готов, нужны первичные подтверждения.** Создан единый production-gate с атрибуцией, общим лимитом 30 минут, хранением и процедурой отзыва для RF4DB, RF4-STAT, RF4MAP, RF4 Posts и официального RF4. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник.
|
- [ ] **Q01 · Документы источников — реестр готов, нужны первичные подтверждения.** Создан единый production-gate с атрибуцией, общим лимитом 30 минут, хранением и процедурой отзыва для RF4DB, RF4-STAT, RF4MAP, RF4 Posts и официального RF4. До открытой публикации приложить устойчивые ссылки/копии первичных разрешений, контакты, даты и отдельно подтвердить право на изображения; пустое поле блокирует соответствующий источник.
|
||||||
- [x] **Q02 · Управляемое удаление источника.** Изменение ранее опубликованной записи возвращает её в staging, сбрасывает сопоставление и переводит связанный улов в pending с новой версией решения. Результат проверки хранится как `available`, `missing`, `temporary_error` или `blocked`: только подтверждённый `missing` отзывает публикацию, временная ошибка и блокировка остаются диагностикой. Повторное появление требует ручного подтверждения. Withdrawn-записи исключены из публичной активности, а статус и время проверки доступны в admin provenance и журнале решений. Отдельного сетевого обхода нет: Q03 подключит эту реакцию к разрешённому плановому запросу.
|
- [x] **Q02 · Управляемое удаление источника.** Изменение ранее опубликованной записи возвращает её в staging, сбрасывает сопоставление и переводит связанный улов в pending с новой версией решения. Результат проверки хранится как `available`, `missing`, `temporary_error` или `blocked`: только подтверждённый `missing` отзывает публикацию, временная ошибка и блокировка остаются диагностикой. Повторное появление требует ручного подтверждения. Withdrawn-записи исключены из публичной активности, а статус и время проверки доступны в admin provenance и журнале решений. Отдельного сетевого обхода нет: Q03 подключит эту реакцию к разрешённому плановому запросу.
|
||||||
- [ ] **Q03 · Целостность ссылок.** Проверять исходные ссылки только во время разрешённого планового обращения к площадке, разделяя `missing`, `temporary_error` и `blocked`; не создавать дополнительный сетевой цикл.
|
- [x] **Q03 · Целостность ссылок.** Результат уже разрешённого scheduler-запроса классифицируется без retry и дополнительного HTTP: `404/410` — `missing`, `401/403/429` — `blocked`, остальные сетевые/парсерные сбои — `temporary_error`. Результат применяется только к наблюдениям с точным совпадением source system и запрошенного URL. Отсутствие записи в агрегатном списке намеренно не считается удалением; появившиеся в успешном ответе записи отмечаются `available` обычным staging-проходом. Все попытки по-прежнему резервируются до запроса и расходуют общий cooldown домена.
|
||||||
- [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…».
|
- [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…».
|
||||||
- [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером.
|
- [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером.
|
||||||
- [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска.
|
- [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска.
|
||||||
|
|||||||
Reference in New Issue
Block a user