feat: enforce community source cooldown
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.cache/
|
||||
.venv/
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
@@ -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, не влияющий на публичную статистику:
|
||||
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")])
|
||||
|
||||
+1
-1
@@ -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`, сквозной контрактный тест).
|
||||
|
||||
@@ -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 трактуется как агрегированная точка, а не индивидуальный улов.
|
||||
|
||||
## Перед продуктивным импортом
|
||||
|
||||
|
||||
@@ -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 пока не добавлен.
|
||||
|
||||
@@ -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 считается агрегированной точкой, а не набором взвешенных уловов.
|
||||
|
||||
Также проверены два менее пригодных кандидата:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user