feat: enforce community source cooldown
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-05 07:52:37 +07:00
parent 1a09bd59f3
commit 3f468799bd
10 changed files with 114 additions and 8 deletions
+1
View File
@@ -1,6 +1,7 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.pytest_cache/ .pytest_cache/
.cache/
.venv/ .venv/
node_modules/ node_modules/
dist/ dist/
+2 -2
View File
@@ -10,7 +10,7 @@ RF4 Spotter — неофициальный сервис свежих точек
- для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`; - для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`;
- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — сквозная проверка фильтров; - начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — сквозная проверка фильтров;
- RF4DB/RF4-STAT загружаются в изолированный staging; добавлены канонические алиасы и ручная очередь публикации. - RF4DB/RF4-STAT загружаются в изолированный staging; добавлены канонические алиасы и ручная очередь публикации.
- для RF4MAP и RF4 Posts готовы только read-only исследовательские detail-парсеры; продуктивное подключение ожидает согласования условий. - RF4MAP и RF4 Posts разрешены для исследовательского staging с интервалом не менее 30 минут на источник; CLI обеспечивает cooldown, источники выключены и не публикуются автоматически.
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Подробный план и актуальные чекбоксы находятся в [`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 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, не влияющий на публичную статистику: Проверенный JSON можно идемпотентно загрузить в изолированный staging, не влияющий на публичную статистику:
+4
View File
@@ -15,11 +15,15 @@ SOURCE_DEFAULTS = {
"rf4db": ("RF4DB", "https://rf4db.com", 70), "rf4db": ("RF4DB", "https://rf4db.com", 70),
"rf4stat-fishing": ("RF4-STAT fishing", "https://rf4-stat.ru/fishing/", 65), "rf4stat-fishing": ("RF4-STAT fishing", "https://rf4-stat.ru/fishing/", 65),
"rf4stat-post": ("RF4-STAT posts", "https://rf4-stat.ru/posts/", 60), "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 = { SOURCE_HOSTS = {
"rf4db": {"rf4db.com", "download.rf4db.com"}, "rf4db": {"rf4db.com", "download.rf4db.com"},
"rf4stat-fishing": {"rf4-stat.ru"}, "rf4stat-fishing": {"rf4-stat.ru"},
"rf4stat-post": {"rf4-stat.ru"}, "rf4stat-post": {"rf4-stat.ru"},
"rf4map": {"rf4map.ru"},
"rf4posts-spot": {"rf4-posts.com"},
} }
+37 -2
View File
@@ -10,17 +10,24 @@ from sqlalchemy.orm import Session
from app.community_importer import CommunityImportError, stage_observations from app.community_importer import CommunityImportError, stage_observations
from app.database import Base from app.database import Base
from app.models import DataSource, ExternalObservation 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" 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]: 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 { return {
"source_system": source, "source_system": source,
"source_external_id": external_id, "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": "Щука",
"fish_external_id": "pike", "fish_external_id": "pike",
"waterbody": "Тестовое озеро", "waterbody": "Тестовое озеро",
@@ -66,6 +73,14 @@ def test_external_ids_are_isolated_by_source(db: Session) -> None:
assert (created, updated) == (2, 0) 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: def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None:
parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8")) parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8"))
payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str)) 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) 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: def test_invalid_source_rolls_back_caller_transaction(db: Session) -> None:
with pytest.raises(CommunityImportError, match="unsupported source_system"): with pytest.raises(CommunityImportError, match="unsupported source_system"):
stage_observations(db, [record("unknown")]) stage_observations(db, [record("unknown")])
+1 -1
View File
@@ -85,7 +85,7 @@
- [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`. - [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`.
- [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT. - [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT.
- [x] Исследовать дополнительные публичные источники: добавлены read-only detail-парсеры RF4MAP и RF4 Posts, живые контрольные прогоны и тест разделения пространств ID; rf4pro/Farm.Trof отклонены для текущего пилота. - [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`. - [x] Зафиксировать проектное подтверждение разрешений, текущую атрибуцию и консервативные пилотные лимиты в `docs/data-permissions.md`.
- [ ] Приложить или сослаться на первичный документ разрешения и зафиксировать точные продуктивные лимиты, срок хранения, удаление и обязательную атрибуцию до включения scheduler RF4DB/RF4-STAT. - [ ] Приложить или сослаться на первичный документ разрешения и зафиксировать точные продуктивные лимиты, срок хранения, удаление и обязательную атрибуцию до включения scheduler RF4DB/RF4-STAT.
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест). - [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
+1 -1
View File
@@ -64,7 +64,7 @@ Detail-страница дополнительно содержит ветер,
- тесты всех извлекаемых полей, locked-координат и отказа на постороннем HTML; - тесты всех извлекаемых полей, 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 трактуется как агрегированная точка, а не индивидуальный улов.
## Перед продуктивным импортом ## Перед продуктивным импортом
+13 -1
View File
@@ -12,15 +12,27 @@
- Объединение источников: автоматическое склеивание RF4DB и RF4-STAT запрещено, пока нет надёжного общего идентификатора. - Объединение источников: автоматическое склеивание 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: не чаще одного запроса за пять секунд; один ограниченный снимок за ручной запуск. - RF4-STAT: не чаще одного запроса за пять секунд; один ограниченный снимок за ручной запуск.
- RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода. - RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода.
- RF4MAP: не чаще одного запроса раз в 30 минут.
- RF4 Posts: не чаще одного запроса раз в 30 минут.
- регулярные scheduler-задачи для обоих источников выключены; - регулярные scheduler-задачи для обоих источников выключены;
- при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения. - при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения.
## Что ещё требуется зафиксировать ## Что ещё требуется зафиксировать
Перед включением регулярного сбора в этот файл или закрытую операционную документацию необходимо приложить дату, стороны и носитель исходного разрешения, точные суточные/минутные лимиты, обязательную формулировку атрибуции, срок хранения и процедуру удаления. Пока эти поля не заполнены, разрешение считается достаточным только для текущего ручного пилота. Перед внешней публикацией необходимо приложить стороны и носитель исходного разрешения, обязательную формулировку атрибуции, срок хранения и процедуру удаления. Для RF4MAP/RF4 Posts точный минимальный интервал уже зафиксирован; автоматический scheduler пока не добавлен.
+1 -1
View File
@@ -65,7 +65,7 @@ Telegram, Discord и VK могут давать свежие координат
Публичная detail-страница RF4 Posts содержит устойчивый UUID точки, координаты, slug водоёма, список slug рыб, способ ловли, оснастку, клипсу, дату и ссылки на доказательства. Русская локализация позволяет связать slug с отображаемым названием. Контрольный пост дал **6 записей видов рыб из одной точки**. Это инструкция по точке, а не шесть доказанных индивидуальных уловов, поэтому вес остаётся `null`, а происхождение сохраняет общий UUID поста. Публичная 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 считается агрегированной точкой, а не набором взвешенных уловов.
Также проверены два менее пригодных кандидата: Также проверены два менее пригодных кандидата:
+39
View File
@@ -2,8 +2,11 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import sys import sys
import time
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from .community_sources import ( from .community_sources import (
@@ -25,6 +28,35 @@ DETAIL_SOURCES = {
"rf4posts-spot": parse_rf4posts_spot, "rf4posts-spot": parse_rf4posts_spot,
} }
USER_AGENT = "RF4-Spotter/0.1 (authorized data integration)" 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: 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("source", choices=(*SOURCES, *DETAIL_SOURCES))
parser.add_argument("--url", help="Override the configured public page URL") 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("--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) args = parser.parse_args(argv)
if args.source in DETAIL_SOURCES and not args.url: if args.source in DETAIL_SOURCES and not args.url:
parser.error(f"--url is required for {args.source}") parser.error(f"--url is required for {args.source}")
default_url, parse = SOURCES.get(args.source, (None, DETAIL_SOURCES.get(args.source))) default_url, parse = SOURCES.get(args.source, (None, DETAIL_SOURCES.get(args.source)))
url = args.url or default_url url = args.url or default_url
try: try:
enforce_fetch_interval(args.source, state_file=args.state_file)
html = fetch_html(url) 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] records = (parse(html, source_url=url) if args.source in DETAIL_SOURCES else parse(html))[:args.limit]
except Exception as exc: except Exception as exc:
print(f"community source failed: {exc}", file=sys.stderr) print(f"community source failed: {exc}", file=sys.stderr)
+15
View File
@@ -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)