diff --git a/.gitignore b/.gitignore index b640edc..a8efa7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__/ *.py[cod] .pytest_cache/ +.cache/ .venv/ node_modules/ dist/ diff --git a/README.md b/README.md index 8aec0c1..2acbdde 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ RF4 Spotter — неофициальный сервис свежих точек - для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`; - начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — сквозная проверка фильтров; - RF4DB/RF4-STAT загружаются в изолированный staging; добавлены канонические алиасы и ручная очередь публикации. -- для RF4MAP и RF4 Posts готовы только read-only исследовательские detail-парсеры; продуктивное подключение ожидает согласования условий. +- RF4MAP и RF4 Posts разрешены для исследовательского staging с интервалом не менее 30 минут на источник; CLI обеспечивает cooldown, источники выключены и не публикуются автоматически. Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). @@ -28,7 +28,7 @@ python -m rf4_research.community_cli rf4map-point --url https://rf4map.ru/points python -m rf4_research.community_cli rf4posts-spot --url https://rf4-posts.com/ru/spots/UUID --limit 25 ``` -Команды печатают нормализованный JSON в stdout и ничего не записывают в базу. Detail-команды требуют явный публичный URL и не обходят запрещённые `/api/`. Для регулярного получения необходимо соблюдать согласованные лимиты; для RF4-STAT — не менее пяти секунд между запросами разных страниц. RF4MAP/RF4 Posts пока предназначены только для разового исследования. +Команды печатают нормализованный JSON в stdout и ничего не записывают в базу. Detail-команды требуют явный публичный URL и не обходят запрещённые `/api/`. Для RF4-STAT действует пауза не менее пяти секунд между разными страницами; для RF4MAP/RF4 Posts CLI хранит состояние в `.cache/community-fetch-state.json` и блокирует повтор того же источника раньше 30 минут. Проверенный JSON можно идемпотентно загрузить в изолированный staging, не влияющий на публичную статистику: diff --git a/apps/api/app/community_importer.py b/apps/api/app/community_importer.py index 1fc1856..84aefbb 100644 --- a/apps/api/app/community_importer.py +++ b/apps/api/app/community_importer.py @@ -15,11 +15,15 @@ SOURCE_DEFAULTS = { "rf4db": ("RF4DB", "https://rf4db.com", 70), "rf4stat-fishing": ("RF4-STAT fishing", "https://rf4-stat.ru/fishing/", 65), "rf4stat-post": ("RF4-STAT posts", "https://rf4-stat.ru/posts/", 60), + "rf4map": ("RF4MAP", "https://rf4map.ru", 55), + "rf4posts-spot": ("RF4 Posts spots", "https://rf4-posts.com", 50), } SOURCE_HOSTS = { "rf4db": {"rf4db.com", "download.rf4db.com"}, "rf4stat-fishing": {"rf4-stat.ru"}, "rf4stat-post": {"rf4-stat.ru"}, + "rf4map": {"rf4map.ru"}, + "rf4posts-spot": {"rf4-posts.com"}, } diff --git a/apps/api/tests/test_community_importer.py b/apps/api/tests/test_community_importer.py index 4e85269..cf90f92 100644 --- a/apps/api/tests/test_community_importer.py +++ b/apps/api/tests/test_community_importer.py @@ -10,17 +10,24 @@ from sqlalchemy.orm import Session from app.community_importer import CommunityImportError, stage_observations from app.database import Base from app.models import DataSource, ExternalObservation -from rf4_research.community_sources import parse_rf4db_catches +from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "rf4db_catches_sample.html" +FIXTURES = FIXTURE.parent def record(source: str = "rf4db", external_id: str = "catch-1") -> dict[str, object]: + urls = { + "rf4db": f"https://rf4db.com/ru/catches/{external_id}", + "rf4stat-fishing": f"https://rf4-stat.ru/fishing/{external_id}", + "rf4map": f"https://rf4map.ru/points/{external_id}", + "rf4posts-spot": f"https://rf4-posts.com/ru/spots/{external_id}", + } return { "source_system": source, "source_external_id": external_id, - "source_url": f"https://rf4db.com/ru/catches/{external_id}" if source == "rf4db" else f"https://rf4-stat.ru/fishing/{external_id}", + "source_url": urls.get(source, f"https://rf4-stat.ru/fishing/{external_id}"), "fish": "Щука", "fish_external_id": "pike", "waterbody": "Тестовое озеро", @@ -66,6 +73,14 @@ def test_external_ids_are_isolated_by_source(db: Session) -> None: assert (created, updated) == (2, 0) +def test_research_sources_can_enter_disabled_staging(db: Session) -> None: + created, updated = stage_observations(db, [record("rf4map"), record("rf4posts-spot")]) + + assert (created, updated) == (2, 0) + assert db.get(DataSource, "rf4map").enabled is False + assert db.get(DataSource, "rf4posts-spot").enabled is False + + def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None: parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8")) payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str)) @@ -76,6 +91,26 @@ def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> Non assert (item.source_system, item.fish_external_id, item.x, item.y) == ("rf4db", "pike", 71, 92) +@pytest.mark.parametrize(("fixture_name", "source_url", "parser"), [ + ("rf4map_point_sample.html", "https://rf4map.ru/points/275", parse_rf4map_point), + ( + "rf4posts_spot_sample.html", + "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee", + parse_rf4posts_spot, + ), +]) +def test_new_parser_json_can_be_staged( + db: Session, fixture_name: str, source_url: str, parser, +) -> None: + parsed = parser((FIXTURES / fixture_name).read_text(encoding="utf-8"), source_url=source_url) + payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str)) + + created, updated = stage_observations(db, payload) + + assert (created, updated) == (len(parsed), 0) + assert db.scalar(select(func.count()).select_from(ExternalObservation)) == len(parsed) + + def test_invalid_source_rolls_back_caller_transaction(db: Session) -> None: with pytest.raises(CommunityImportError, match="unsupported source_system"): stage_observations(db, [record("unknown")]) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b1eab8e..bc27539 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -85,7 +85,7 @@ - [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`. - [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT. - [x] Исследовать дополнительные публичные источники: добавлены read-only detail-парсеры RF4MAP и RF4 Posts, живые контрольные прогоны и тест разделения пространств ID; rf4pro/Farm.Trof отклонены для текущего пилота. -- [ ] До подключения RF4MAP/RF4 Posts получить разрешение и зафиксировать лимиты, атрибуцию, правила изображений и семантику агрегированной точки RF4 Posts. +- [x] Получить разрешение RF4MAP/RF4 Posts и зафиксировать интервал не менее 30 минут, хранение только URL изображений и семантику агрегированной точки; источники добавлены в выключенный staging, CLI блокирует ранний повтор. - [x] Зафиксировать проектное подтверждение разрешений, текущую атрибуцию и консервативные пилотные лимиты в `docs/data-permissions.md`. - [ ] Приложить или сослаться на первичный документ разрешения и зафиксировать точные продуктивные лимиты, срок хранения, удаление и обязательную атрибуцию до включения scheduler RF4DB/RF4-STAT. - [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест). diff --git a/docs/community-source-pilot.md b/docs/community-source-pilot.md index 33832a5..0b2d715 100644 --- a/docs/community-source-pilot.md +++ b/docs/community-source-pilot.md @@ -64,7 +64,7 @@ Detail-страница дополнительно содержит ветер, - тесты всех извлекаемых полей, locked-координат и отказа на постороннем HTML; - живой контрольный прогон без сохранения персональных данных и изображений в репозиторий. -RF4MAP и RF4 Posts остаются исследовательскими: на живых HTML-снимках получены соответственно 30 индивидуальных наблюдений и 6 видов рыб одной точки. Оба результата укладываются в nullable-контракт, имеют отдельные пространства ID и не смешиваются с RF4DB/RF4-STAT. Они не заведены в staging до согласования условий использования; RF4 Posts дополнительно требует отдельного решения, поскольку пост описывает точку, а не индивидуальный улов. +RF4MAP и RF4 Posts остаются исследовательскими: на живых HTML-снимках получены соответственно 30 индивидуальных наблюдений и 6 видов рыб одной точки. Оба результата укладываются в nullable-контракт, имеют отдельные пространства ID и не смешиваются с RF4DB/RF4-STAT. Разрешение и минимальный интервал 30 минут подтверждены; источники разрешены для staging, но остаются выключенными. RF4 Posts трактуется как агрегированная точка, а не индивидуальный улов. ## Перед продуктивным импортом diff --git a/docs/data-permissions.md b/docs/data-permissions.md index c5f8f27..8549936 100644 --- a/docs/data-permissions.md +++ b/docs/data-permissions.md @@ -12,15 +12,27 @@ - Объединение источников: автоматическое склеивание RF4DB и RF4-STAT запрещено, пока нет надёжного общего идентификатора. - Публикация: только вручную, после канонического сопоставления; обязательны рыба, водоём, координаты и вес. +## RF4MAP и RF4 Posts + +- Статус: владелец проекта подтвердил разрешение использовать данные обоих источников. +- Зафиксировано: 5 сентября 2026 года в рабочей переписке проекта. +- Частота: не чаще одного получения страницы каждого источника раз в 30 минут. +- Разрешённый контур: публичные HTML-страницы, нормализация в `ExternalCatch` и закрытый staging; запрещённые в `robots.txt` API не запрашиваются. +- Изображения: сохраняются только исходные URL доказательств; файлы не скачиваются и не проксируются. +- Публикация: только вручную и только при выполнении общего требования рыба + водоём + координаты + вес. Текущие записи RF4MAP/RF4 Posts не имеют веса и остаются в staging. +- Семантика RF4 Posts: несколько видов одного поста остаются связанными с одним UUID точки и не считаются отдельными подтверждёнными взвешиваниями. + ## Лимиты запросов До приложения первичного подтверждения действуют более строгие технические ограничения: - RF4-STAT: не чаще одного запроса за пять секунд; один ограниченный снимок за ручной запуск. - RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода. +- RF4MAP: не чаще одного запроса раз в 30 минут. +- RF4 Posts: не чаще одного запроса раз в 30 минут. - регулярные scheduler-задачи для обоих источников выключены; - при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения. ## Что ещё требуется зафиксировать -Перед включением регулярного сбора в этот файл или закрытую операционную документацию необходимо приложить дату, стороны и носитель исходного разрешения, точные суточные/минутные лимиты, обязательную формулировку атрибуции, срок хранения и процедуру удаления. Пока эти поля не заполнены, разрешение считается достаточным только для текущего ручного пилота. +Перед внешней публикацией необходимо приложить стороны и носитель исходного разрешения, обязательную формулировку атрибуции, срок хранения и процедуру удаления. Для RF4MAP/RF4 Posts точный минимальный интервал уже зафиксирован; автоматический scheduler пока не добавлен. diff --git a/docs/data-source-audit.md b/docs/data-source-audit.md index 05a2892..f16f4f8 100644 --- a/docs/data-source-audit.md +++ b/docs/data-source-audit.md @@ -65,7 +65,7 @@ Telegram, Discord и VK могут давать свежие координат Публичная detail-страница RF4 Posts содержит устойчивый UUID точки, координаты, slug водоёма, список slug рыб, способ ловли, оснастку, клипсу, дату и ссылки на доказательства. Русская локализация позволяет связать slug с отображаемым названием. Контрольный пост дал **6 записей видов рыб из одной точки**. Это инструкция по точке, а не шесть доказанных индивидуальных уловов, поэтому вес остаётся `null`, а происхождение сохраняет общий UUID поста. -Оба fail-closed парсера добавлены только в read-only исследовательский CLI. Они совместимы с `ExternalCatch`, но не включены в `data_source`, staging или расписание: сначала нужны разрешение, лимиты, правила атрибуции/изображений и решение о том, допустимо ли считать RF4 Posts наблюдением улова. +Оба fail-closed парсера добавлены в read-only исследовательский CLI и разрешены владельцем проекта с интервалом не менее 30 минут на источник. Источники зарегистрированы для изолированного staging выключенными по умолчанию. CLI хранит время последнего успешного получения и отклоняет слишком ранний повтор. Автоматического расписания и публикации нет; RF4 Posts считается агрегированной точкой, а не набором взвешенных уловов. Также проверены два менее пригодных кандидата: diff --git a/rf4_research/community_cli.py b/rf4_research/community_cli.py index 8c2890a..f7d3b1f 100644 --- a/rf4_research/community_cli.py +++ b/rf4_research/community_cli.py @@ -2,8 +2,11 @@ from __future__ import annotations import argparse import json +import os import sys +import time from dataclasses import asdict +from pathlib import Path from urllib.request import Request, urlopen from .community_sources import ( @@ -25,6 +28,35 @@ DETAIL_SOURCES = { "rf4posts-spot": parse_rf4posts_spot, } USER_AGENT = "RF4-Spotter/0.1 (authorized data integration)" +MIN_FETCH_INTERVAL_SECONDS = 30 * 60 +DEFAULT_STATE_FILE = Path(".cache/community-fetch-state.json") + + +def enforce_fetch_interval( + source: str, *, state_file: Path, now: float | None = None, +) -> None: + now = time.time() if now is None else now + try: + state = json.loads(state_file.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + state = {} + last_fetch = state.get(source) + if isinstance(last_fetch, (int, float)) and now - last_fetch < MIN_FETCH_INTERVAL_SECONDS: + wait = int(MIN_FETCH_INTERVAL_SECONDS - (now - last_fetch)) + raise RuntimeError(f"source cooldown is active; retry in {wait} seconds") + + +def mark_fetch(source: str, *, state_file: Path, now: float | None = None) -> None: + now = time.time() if now is None else now + try: + state = json.loads(state_file.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + state = {} + state[source] = now + state_file.parent.mkdir(parents=True, exist_ok=True) + temporary = state_file.with_suffix(".tmp") + temporary.write_text(json.dumps(state, sort_keys=True), encoding="utf-8") + temporary.replace(state_file) def fetch_html(url: str, *, timeout: float = 30) -> str: @@ -40,13 +72,20 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("source", choices=(*SOURCES, *DETAIL_SOURCES)) parser.add_argument("--url", help="Override the configured public page URL") parser.add_argument("--limit", type=int, default=100, choices=range(1, 501), metavar="1..500") + parser.add_argument( + "--state-file", type=Path, + default=Path(os.environ.get("RF4_COMMUNITY_FETCH_STATE", DEFAULT_STATE_FILE)), + help="Persistent per-source cooldown state", + ) args = parser.parse_args(argv) if args.source in DETAIL_SOURCES and not args.url: parser.error(f"--url is required for {args.source}") default_url, parse = SOURCES.get(args.source, (None, DETAIL_SOURCES.get(args.source))) url = args.url or default_url try: + enforce_fetch_interval(args.source, state_file=args.state_file) html = fetch_html(url) + mark_fetch(args.source, state_file=args.state_file) records = (parse(html, source_url=url) if args.source in DETAIL_SOURCES else parse(html))[:args.limit] except Exception as exc: print(f"community source failed: {exc}", file=sys.stderr) diff --git a/tests/test_community_cli.py b/tests/test_community_cli.py new file mode 100644 index 0000000..afcb9f0 --- /dev/null +++ b/tests/test_community_cli.py @@ -0,0 +1,15 @@ +from pathlib import Path + +import pytest + +from rf4_research.community_cli import enforce_fetch_interval, mark_fetch + + +def test_fetch_cooldown_is_persistent_per_source(tmp_path: Path) -> None: + state_file = tmp_path / "fetch-state.json" + mark_fetch("rf4map-point", state_file=state_file, now=1_000) + + with pytest.raises(RuntimeError, match="retry in 1800 seconds"): + enforce_fetch_interval("rf4map-point", state_file=state_file, now=1_000) + enforce_fetch_interval("rf4posts-spot", state_file=state_file, now=1_000) + enforce_fetch_interval("rf4map-point", state_file=state_file, now=2_800)