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