feat: add offline waterbody source crosswalk
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("waterbody detail preserves confirmed facts and empty activity state", async ({ page }) => {
|
||||
await page.goto("/waterbodies/р-вьюнок");
|
||||
|
||||
await expect(page.locator("h1")).toHaveText("р. Вьюнок");
|
||||
await expect(page.getByText("В карточке источника указано видов рыб: 29.")).toBeVisible();
|
||||
await expect(page.getByText("Изображений-кандидатов: 2; публикация требует отдельной проверки.")).toBeVisible();
|
||||
await expect(page.getByText("Свежих подтверждённых видов пока нет.")).toBeVisible();
|
||||
await expect(page.getByText("Свежих точек пока нет")).toBeVisible();
|
||||
});
|
||||
|
||||
test("unknown waterbody slug has a navigable not-found state", async ({ page }) => {
|
||||
await page.goto("/waterbodies/not-a-real-waterbody");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Водоём не найден" })).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toContainText("Такого водоёма нет в справочнике");
|
||||
await expect(page.getByRole("link", { name: "Открыть каталог" })).toHaveAttribute("href", "/waterbodies");
|
||||
});
|
||||
+2
-2
@@ -53,10 +53,10 @@
|
||||
- [ ] **W02 · Карточки водоёмов RF4DB.** Строгий fixture-based parser `rf4db-waterbody` расширен под реальный DOM `/fishes/` и `/positions/`, regression-тесты, безопасный `update_waterbody_detail` и атомарный batch `update_waterbody_details`/`import-waterbody-details` готовы. Snapshots `level_000_home`, `level_001_lake` и `level_002_kirzah_river` сохранены. Добавлены guarded launcher `scripts/fetch-waterbodies-chromium.mjs` для обычной Chromium-сессии и offline-режим `scripts/fetch-waterbodies.py --html`; оба сохраняют snapshots атомарно, не импортируют их автоматически и используют общий cooldown, а Chromium-launcher сначала делает отдельную reserve-only операцию. Повторный разрешённый запрос `level_002_kirzah_river` 20.09 вернул совпадающие с canonical-каталогом имя, `29/29` видов и два URL изображений; snapshot импортирован в Compose PostgreSQL, `/health` и `/ready` успешны, публичная `/waterbodies/р-вьюнок` отвечает `200`. Описание и позиции источник не вернул, поэтому они оставлены `null`/пустыми без домыслов; изображения остаются только provenance-кандидатами и не публиковались. Три разрешённых запроса `level_019_american_pond` 20.09 завершились `403 Forbidden`, а текущий Chromium показывает защитную страницу Cloudflare: это блокировка HTTP-клиента источником, не ошибка атомарного сохранения launcher-а; snapshot и импорт не создавались, прежние подтверждённые данные и media не менялись. Одно промежуточное разрешённое окно 20.09 было зарезервировано CLI, но завершилось локальной DNS-ошибкой до ответа источника; повтор с сетевым доступом был остановлен общим cooldown до запроса. Следующий сетевой запуск допускается только после общего cooldown; полнота ответа остаётся неподтверждённой. CAPTCHA/challenge не автоматизируются. Осталось последовательно разобрать 16 detail-страниц.
|
||||
- [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентный upsert применён к canonical snapshot: `19/19`, без missing/duplicate/provenance issues. Осталось применить подтверждённые detail snapshots и отдельно разделить игровые и редакционные тексты при подключении detail-данных.
|
||||
- [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника.
|
||||
- [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. Осталось подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта.
|
||||
- [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. RF4MAP/RF4-STAT directory parsers теперь преобразуются в crosswalk identities без переноса метрик или автоматической привязки. Осталось подать полный набор реальных RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта.
|
||||
- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Исправлена spot detail: точность (`точные`/`приблизительные`/`район`/`не указаны`) теперь видна рядом с координатами и покрыта smoke regression. In-app Chromium и focused smoke подтвердили `/spots/vyunok-321x654` на desktop/mobile без overflow. Осталась полная browser QA матрица для всех precision/error-состояний.
|
||||
- [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Осталось подключить только проверенные waterbody media roles и завершить browser QA, включая различение карты, заставки и абстрактного отпечатка.
|
||||
- [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; остаются полная browser QA error/empty matrix и закрытие внешних detail-данных. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff.
|
||||
- [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; 20.09 targeted waterbody E2E подтвердил detail с пустой активностью и отдельный not-found state, полный web E2E прошёл `27 passed, 1 skipped`, visual-matrix также прошёл. Остаются полная browser QA error/empty matrix и закрытие внешних detail-данных. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff.
|
||||
|
||||
### Каталог снастей, наживок и прочей оснастки
|
||||
|
||||
|
||||
@@ -57,14 +57,16 @@ Detail-страница дополнительно содержит ветер,
|
||||
|
||||
## Реализовано
|
||||
|
||||
- `rf4_research/community_sources.py` — пять fail-closed HTML-парсеров;
|
||||
- `rf4_research/community_sources.py` — fail-closed HTML-парсеры уловов, точек и вторичных каталогов;
|
||||
- `parse_rf4map_waterbodies` — отдельный fail-closed каталог вторичных RF4MAP candidates;
|
||||
- `parse_rf4stat_waterbodies` — отдельный fail-closed каталог вторичных RF4-STAT metrics;
|
||||
- `parse_rf4db_detail` — отдельное обогащение одного RF4DB-улова;
|
||||
- `python -m rf4_research.community_cli` — read-only CLI одного ограниченного снимка;
|
||||
- обезличенные минимальные фикстуры для каждого контракта;
|
||||
- тесты всех извлекаемых полей, locked-координат и отказа на постороннем HTML;
|
||||
- живой контрольный прогон без сохранения персональных данных и изображений в репозиторий.
|
||||
|
||||
На живых HTML-снимках RF4MAP и RF4 Posts получены соответственно 30 индивидуальных наблюдений и 6 видов рыб одной точки. Оба результата укладываются в nullable-контракт, имеют отдельные пространства ID и не смешиваются с RF4DB/RF4-STAT. Разрешение и минимальный интервал 30 минут подтверждены; все источники включены миграцией `0012`. RF4 Posts трактуется как агрегированная точка, а не индивидуальный улов.
|
||||
На живых HTML-снимках RF4MAP и RF4 Posts получены соответственно 30 индивидуальных наблюдений и 6 видов рыб одной точки. Оба результата укладываются в nullable-контракт, имеют отдельные пространства ID и не смешиваются с RF4DB/RF4-STAT. Для RF4MAP добавлен отдельный ручной parser каталога `/lakes`: он возвращает только вторичные candidates имени, внешнего ID и видового счётчика, не создаёт canonical waterbody и не назначает media. Разрешение и минимальный интервал 30 минут подтверждены; все источники включены миграцией `0012`. RF4 Posts трактуется как агрегированная точка, а не индивидуальный улов.
|
||||
|
||||
## Перед продуктивным импортом
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@ Telegram, Discord и VK могут давать свежие координат
|
||||
|
||||
Публичная detail-страница RF4MAP содержит в серверном HTML устойчивый ID наблюдения, координаты, ID и название рыбы, ID водоёма, дату, клипсу, необязательные приманку, автора и изображения. Контрольная точка содержала **30 отдельных наблюдений**. Веса нет. `robots.txt` разрешает публичные страницы и запрещает `/api/`; исследование использует только HTML одной страницы.
|
||||
|
||||
20 сентября 2026 года отдельный ручной каталог `https://rf4map.ru/lakes` успешно вернул **16** кандидатов водоёмов с внешними ID, названиями и частичным числом видов рыб. Это меньше canonical 19, поэтому результат остаётся вторичным crosswalk-набором и не импортируется в `waterbody` автоматически. Для запуска используется `python -m rf4_research.community_cli rf4map-waterbodies`; общий cooldown ключуется как `rf4map.ru`.
|
||||
|
||||
Для RF4-STAT добавлен аналогичный ручной parser публичного каталога `https://en.rf4-stat.ru/locations/`. Он сохраняет внешний ID, имя и видимые агрегаты уловов/видов рыб/наживок/точек, но не считает их canonical detail-фактами. Англоязычный домен нормализуется к общему cooldown `rf4-stat.ru`; автоматического импорта и объединения с RF4DB нет. В live-странице подтверждены ссылки на location IDs и публичные метрики; detail-страницы не запрашиваются автоматически.
|
||||
|
||||
Первый guarded probe этого CLI 20 сентября получил HTML, но не совпал с fixture-контрактом (`locations/location/*` не обнаружены). JSON не создан и импорт не выполнялся; до уточнения текущего DOM источник остаётся fail-closed.
|
||||
|
||||
Публичная detail-страница RF4 Posts содержит устойчивый UUID точки, координаты, slug водоёма, список slug рыб, способ ловли, оснастку, клипсу, дату и ссылки на доказательства. Русская локализация позволяет связать slug с отображаемым названием. Контрольный пост дал **6 записей видов рыб из одной точки**. Это инструкция по точке, а не шесть доказанных индивидуальных уловов, поэтому вес остаётся `null`, а происхождение сохраняет общий UUID поста.
|
||||
|
||||
Оба fail-closed парсера добавлены в read-only исследовательский CLI и разрешены владельцем проекта с интервалом не менее 30 минут на сайт. CLI резервирует домен до запроса и отклоняет слишком ранний повтор любого endpoint, включая повтор после ошибки. В production все разрешённые адаптеры подключены к scheduler и staging; полные записи с подтверждёнными алиасами публикуются автоматически, остальные требуют проверки. RF4 Posts считается агрегированной точкой, а не набором взвешенных уловов.
|
||||
|
||||
@@ -18,9 +18,11 @@ from .community_sources import (
|
||||
parse_rf4db_waterbodies,
|
||||
parse_rf4db_waterbody_detail,
|
||||
parse_rf4map_point,
|
||||
parse_rf4map_waterbodies,
|
||||
parse_rf4posts_spot,
|
||||
parse_rf4stat_fishing,
|
||||
parse_rf4stat_posts,
|
||||
parse_rf4stat_waterbodies,
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +31,8 @@ SOURCES = {
|
||||
"rf4db-waterbodies": ("https://rf4db.com/ru/maps", parse_rf4db_waterbodies),
|
||||
"rf4stat-fishing": ("https://rf4-stat.ru/fishing/", parse_rf4stat_fishing),
|
||||
"rf4stat-posts": ("https://rf4-stat.ru/posts/", parse_rf4stat_posts),
|
||||
"rf4map-waterbodies": ("https://rf4map.ru/lakes", parse_rf4map_waterbodies),
|
||||
"rf4stat-locations": ("https://en.rf4-stat.ru/locations/", parse_rf4stat_waterbodies),
|
||||
}
|
||||
DETAIL_SOURCES = {
|
||||
"rf4db-waterbody": parse_rf4db_waterbody_detail,
|
||||
@@ -40,7 +44,7 @@ MIN_FETCH_INTERVAL_SECONDS = 30 * 60
|
||||
DEFAULT_STATE_FILE = Path(".cache/community-fetch-state.json")
|
||||
ALLOWED_HOSTS = frozenset({
|
||||
"download.rf4db.com", "rf4db.com", "oss.rf4db.com",
|
||||
"rf4-stat.ru",
|
||||
"rf4-stat.ru", "en.rf4-stat.ru",
|
||||
"rf4map.ru", "gw.rf4map.ru", "hb.ru-msk.vkcloud-storage.ru",
|
||||
"rf4-posts.com",
|
||||
"rf4game.de", "rf4game.ru",
|
||||
@@ -111,6 +115,8 @@ def fetch_site_key(url: str) -> str:
|
||||
hostname = hostname[4:]
|
||||
elif hostname.startswith("cdn."):
|
||||
hostname = hostname[4:]
|
||||
elif hostname.endswith(".rf4-stat.ru"):
|
||||
hostname = "rf4-stat.ru"
|
||||
if hostname == "gw.rf4map.ru":
|
||||
hostname = "rf4map.ru"
|
||||
if not hostname:
|
||||
@@ -304,6 +310,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Fetch one authorized RF4 community source page")
|
||||
parser.add_argument("source", choices=(*SOURCES, *DETAIL_SOURCES))
|
||||
parser.add_argument("--url", help="Override the configured public page URL")
|
||||
parser.add_argument("--html", type=Path, help="Parse a previously saved HTML file without network or cooldown")
|
||||
parser.add_argument("--limit", type=int, default=100, choices=range(1, 501), metavar="1..500")
|
||||
parser.add_argument(
|
||||
"--state-file", type=Path,
|
||||
@@ -315,18 +322,23 @@ def main(argv: list[str] | None = None) -> int:
|
||||
help="Reserve the shared site cooldown and stop before making an HTTP request",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.html and args.reserve_only:
|
||||
parser.error("--html cannot be combined with --reserve-only")
|
||||
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:
|
||||
site_key = fetch_site_key(url)
|
||||
# Single atomic check-and-reserve before network I/O: failed attempts count toward the limit too.
|
||||
check_and_reserve(site_key, state_file=args.state_file)
|
||||
if args.reserve_only:
|
||||
print(json.dumps({"reserved": True, "source": args.source, "site_key": site_key}, ensure_ascii=False))
|
||||
return 0
|
||||
html = fetch_html(url)
|
||||
if args.html:
|
||||
html = args.html.read_text(encoding="utf-8")
|
||||
else:
|
||||
site_key = fetch_site_key(url)
|
||||
# Single atomic check-and-reserve before network I/O: failed attempts count toward the limit too.
|
||||
check_and_reserve(site_key, state_file=args.state_file)
|
||||
if args.reserve_only:
|
||||
print(json.dumps({"reserved": True, "source": args.source, "site_key": site_key}, ensure_ascii=False))
|
||||
return 0
|
||||
html = fetch_html(url)
|
||||
parsed = (
|
||||
parse(html, source_url=url)
|
||||
if args.source in DETAIL_SOURCES or args.source == "rf4db-waterbodies"
|
||||
|
||||
@@ -127,6 +127,31 @@ class RF4DBWaterbodyDetail:
|
||||
point_urls: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RF4MapWaterbodyCandidate:
|
||||
"""Secondary waterbody directory facts; never a canonical catalog row."""
|
||||
|
||||
source_system: str
|
||||
source_external_id: str
|
||||
source_url: str
|
||||
name: str
|
||||
fish_species_count: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RF4StatWaterbodyCandidate:
|
||||
"""Secondary RF4-STAT location metrics for crosswalk and comparison."""
|
||||
|
||||
source_system: str
|
||||
source_external_id: str
|
||||
source_url: str
|
||||
name: str
|
||||
catches_count: int | None
|
||||
fish_species_count: int | None
|
||||
bait_count: int | None
|
||||
spot_count: int | None
|
||||
|
||||
|
||||
def _text(node: Tag | None) -> str:
|
||||
return " ".join(node.get_text(" ", strip=True).split()) if node else ""
|
||||
|
||||
@@ -254,6 +279,88 @@ def parse_rf4db_waterbodies(
|
||||
return result
|
||||
|
||||
|
||||
def parse_rf4map_waterbodies(
|
||||
html: str, *, base_url: str = "https://rf4map.ru/lakes",
|
||||
) -> list[RF4MapWaterbodyCandidate]:
|
||||
"""Parse the public RF4MAP directory as secondary crosswalk candidates."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
result: list[RF4MapWaterbodyCandidate] = []
|
||||
seen: set[str] = set()
|
||||
for link in soup.select('a[href^="/lakes/"]'):
|
||||
href = link.get("href")
|
||||
external_id = _key(href)
|
||||
if not isinstance(href, str) or not external_id or external_id in seen:
|
||||
continue
|
||||
label = _text(link)
|
||||
fish_match = re.search(r"(\d+)\s+вид(?:а|ов)?\s+рыб", label, flags=re.I)
|
||||
name = re.sub(r"\s*\d+\s+вид(?:а|ов)?\s+рыб(?:ы)?\s*$", "", label, flags=re.I).strip()
|
||||
name = re.sub(r"^\+\s*\d+\s+", "", name).strip()
|
||||
if not name:
|
||||
continue
|
||||
seen.add(external_id)
|
||||
result.append(RF4MapWaterbodyCandidate(
|
||||
source_system="rf4map-waterbodies",
|
||||
source_external_id=external_id,
|
||||
source_url=urljoin(base_url, href),
|
||||
name=name,
|
||||
fish_species_count=int(fish_match.group(1)) if fish_match else None,
|
||||
))
|
||||
if not result:
|
||||
raise CommunityParseError("RF4MAP waterbody directory not found")
|
||||
return result
|
||||
|
||||
|
||||
def parse_rf4stat_waterbodies(
|
||||
html: str, *, base_url: str = "https://en.rf4-stat.ru/locations/",
|
||||
) -> list[RF4StatWaterbodyCandidate]:
|
||||
"""Parse public RF4-STAT location cards without treating metrics as canonical facts."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
result: list[RF4StatWaterbodyCandidate] = []
|
||||
seen: set[str] = set()
|
||||
for link in soup.select('a[href*="/locations/location/"]'):
|
||||
href = link.get("href")
|
||||
external_id = _key(href)
|
||||
if not isinstance(href, str) or not external_id or external_id in seen:
|
||||
continue
|
||||
container = link.find_parent(["article", "li"]) or link.parent
|
||||
text = _text(container)
|
||||
name = _text(link)
|
||||
metric_texts = [
|
||||
value for node in container.select("span, div, small, strong")
|
||||
if (value := _text(node)) and value != name
|
||||
]
|
||||
metric_texts.append(text)
|
||||
if not name:
|
||||
continue
|
||||
|
||||
def metric(label: str) -> int | None:
|
||||
patterns = (
|
||||
rf"(?:{label})\s*:\s*([0-9]+(?: [0-9]{{3}})*)",
|
||||
rf"([0-9]+(?: [0-9]{{3}})*)\s+(?:{label})",
|
||||
)
|
||||
for candidate in metric_texts:
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, candidate, flags=re.I)
|
||||
if match:
|
||||
return int(match.group(1).replace(" ", ""))
|
||||
return None
|
||||
|
||||
seen.add(external_id)
|
||||
result.append(RF4StatWaterbodyCandidate(
|
||||
source_system="rf4stat-locations",
|
||||
source_external_id=external_id,
|
||||
source_url=urljoin(base_url, href),
|
||||
name=name,
|
||||
catches_count=metric(r"catches|улов"),
|
||||
fish_species_count=metric(r"kinds of fish|вид(?:а|ов) рыб"),
|
||||
bait_count=metric(r"baits|нажив"),
|
||||
spot_count=metric(r"spots|точ(?:ек|ки)"),
|
||||
))
|
||||
if not result:
|
||||
raise CommunityParseError("RF4-STAT waterbody directory not found")
|
||||
return result
|
||||
|
||||
|
||||
def _gear_level(value: str | None) -> int | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Protocol
|
||||
|
||||
from .media_assets import normalize_entity_label
|
||||
|
||||
@@ -21,6 +22,14 @@ class WaterbodyIdentity:
|
||||
name: str
|
||||
|
||||
|
||||
class WaterbodyCandidate(Protocol):
|
||||
"""Minimum shape shared by parsed secondary-source candidates."""
|
||||
|
||||
source_system: str
|
||||
source_external_id: str
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CrosswalkSuggestion:
|
||||
source_system: str
|
||||
@@ -30,6 +39,24 @@ class CrosswalkSuggestion:
|
||||
canonical_keys: tuple[str, ...]
|
||||
|
||||
|
||||
def identities_from_candidates(
|
||||
candidates: Iterable[WaterbodyCandidate],
|
||||
) -> list[WaterbodyIdentity]:
|
||||
"""Convert parser rows without selecting a canonical key.
|
||||
|
||||
Metrics, URLs, and other source facts stay outside the crosswalk
|
||||
decision and must not influence automatic canonical matching.
|
||||
"""
|
||||
return [
|
||||
WaterbodyIdentity(
|
||||
source_system=row.source_system,
|
||||
external_id=row.source_external_id,
|
||||
name=row.name,
|
||||
)
|
||||
for row in candidates
|
||||
]
|
||||
|
||||
|
||||
def suggest_waterbody_crosswalk(
|
||||
canonical: list[CanonicalWaterbody], identities: list[WaterbodyIdentity],
|
||||
) -> list[CrosswalkSuggestion]:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<html><body>
|
||||
<a href="/lakes/12">+ 35 р. Нижняя Тунгуска 38 видов рыбы</a>
|
||||
<a href="/lakes/12">дубликат</a>
|
||||
<a href="/lakes/16">оз. Комариное</a>
|
||||
</body></html>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<html><body>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/locations/location/1">Mosquito Lake</a>
|
||||
<span>Catches: 254</span><span>42 baits</span><span>20 kinds of fish</span><span>9 spots</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/locations/location/19">Elk Lake</a>
|
||||
<span>Catches: 520</span><span>121 baits</span><span>19 kinds of fish</span><span>11 spots</span>
|
||||
</li>
|
||||
</ul>
|
||||
</body></html>
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
|
||||
from rf4_research import community_cli
|
||||
from rf4_research.community_cli import enforce_fetch_interval, fetch_site_key, mark_fetch
|
||||
from rf4_research.community_sources import RF4DBWaterbodyDetail
|
||||
from rf4_research.community_sources import RF4DBWaterbodyDetail, RF4MapWaterbodyCandidate, RF4StatWaterbodyCandidate
|
||||
|
||||
|
||||
def test_fetch_cooldown_is_persistent_per_source(tmp_path: Path) -> None:
|
||||
@@ -36,6 +36,7 @@ def test_fetch_site_key_normalizes_common_subdomains() -> None:
|
||||
assert fetch_site_key("https://cdn.rf4db.com/assets/img.jpg") == "rf4db.com"
|
||||
assert fetch_site_key("https://oss.rf4db.com/game/fish/pike.webp") == "rf4db.com"
|
||||
assert fetch_site_key("https://www.rf4db.com/") == "rf4db.com"
|
||||
assert fetch_site_key("https://en.rf4-stat.ru/locations/") == "rf4-stat.ru"
|
||||
# Base domain stays the same
|
||||
assert fetch_site_key("https://rf4db.com/") == "rf4db.com"
|
||||
assert fetch_site_key("https://gw.rf4map.ru/public/images/fish.webp") == "rf4map.ru"
|
||||
@@ -72,6 +73,68 @@ def test_reserve_only_does_not_make_http_request(
|
||||
assert payload == {"reserved": True, "source": "rf4db-waterbodies", "site_key": "rf4db.com"}
|
||||
|
||||
|
||||
def test_rf4map_waterbody_directory_is_a_manual_source(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state_file = tmp_path / "fetch-state.json"
|
||||
monkeypatch.setattr(community_cli, "fetch_html", lambda _url: "<html></html>")
|
||||
monkeypatch.setitem(
|
||||
community_cli.SOURCES,
|
||||
"rf4map-waterbodies",
|
||||
("https://rf4map.ru/lakes", lambda _html: [RF4MapWaterbodyCandidate(
|
||||
source_system="rf4map-waterbodies", source_external_id="fixture",
|
||||
source_url="https://rf4map.ru/lakes/fixture", name="fixture", fish_species_count=None,
|
||||
)]),
|
||||
)
|
||||
|
||||
assert community_cli.main([
|
||||
"rf4map-waterbodies", "--state-file", str(state_file),
|
||||
]) == 0
|
||||
assert json.loads(capsys.readouterr().out)[0]["source_external_id"] == "fixture"
|
||||
|
||||
|
||||
def test_rf4stat_location_directory_is_a_manual_source(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state_file = tmp_path / "fetch-state.json"
|
||||
monkeypatch.setattr(community_cli, "fetch_html", lambda _url: "<html></html>")
|
||||
monkeypatch.setitem(
|
||||
community_cli.SOURCES,
|
||||
"rf4stat-locations",
|
||||
("https://en.rf4-stat.ru/locations/", lambda _html: [RF4StatWaterbodyCandidate(
|
||||
source_system="rf4stat-locations", source_external_id="fixture",
|
||||
source_url="https://en.rf4-stat.ru/locations/location/fixture", name="fixture",
|
||||
catches_count=None, fish_species_count=None, bait_count=None, spot_count=None,
|
||||
)]),
|
||||
)
|
||||
|
||||
assert community_cli.main(["rf4stat-locations", "--state-file", str(state_file)]) == 0
|
||||
assert json.loads(capsys.readouterr().out)[0]["source_external_id"] == "fixture"
|
||||
|
||||
|
||||
def test_saved_html_source_does_not_reserve_or_make_http_request(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
state_file = tmp_path / "fetch-state.json"
|
||||
html_file = tmp_path / "source.html"
|
||||
html_file.write_text("<html></html>", encoding="utf-8")
|
||||
monkeypatch.setattr(community_cli, "fetch_html", lambda _url: (_ for _ in ()).throw(AssertionError("offline mode must not fetch")))
|
||||
monkeypatch.setitem(
|
||||
community_cli.SOURCES,
|
||||
"rf4map-waterbodies",
|
||||
("https://rf4map.ru/lakes", lambda _html: [RF4MapWaterbodyCandidate(
|
||||
source_system="rf4map-waterbodies", source_external_id="fixture",
|
||||
source_url="https://rf4map.ru/lakes/fixture", name="fixture", fish_species_count=None,
|
||||
)]),
|
||||
)
|
||||
|
||||
assert community_cli.main([
|
||||
"rf4map-waterbodies", "--html", str(html_file), "--state-file", str(state_file),
|
||||
]) == 0
|
||||
assert not state_file.exists()
|
||||
assert json.loads(capsys.readouterr().out)[0]["source_external_id"] == "fixture"
|
||||
|
||||
|
||||
def test_detail_fetch_serializes_single_record(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
|
||||
@@ -10,13 +10,15 @@ from rf4_research.community_sources import (
|
||||
parse_rf4db_waterbodies,
|
||||
parse_rf4db_waterbody_detail,
|
||||
parse_rf4map_point,
|
||||
parse_rf4map_waterbodies,
|
||||
parse_rf4posts_spot,
|
||||
parse_rf4stat_fishing,
|
||||
parse_rf4stat_posts,
|
||||
parse_rf4stat_waterbodies,
|
||||
)
|
||||
from rf4_research.waterbody_crosswalk import (
|
||||
CanonicalWaterbody, WaterbodyIdentity, missing_external_ids,
|
||||
suggest_waterbody_crosswalk,
|
||||
identities_from_candidates, suggest_waterbody_crosswalk,
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +71,48 @@ def test_parses_rf4db_waterbody_catalog_without_inventing_image_roles() -> None:
|
||||
assert rows[1].image_url == "https://rf4db.com/game/maps/level_000_cottage.webp"
|
||||
|
||||
|
||||
def test_parses_rf4map_waterbody_directory_as_secondary_candidates() -> None:
|
||||
rows = parse_rf4map_waterbodies(fixture("rf4map_waterbodies_sample.html"))
|
||||
|
||||
assert [(row.source_external_id, row.name, row.fish_species_count) for row in rows] == [
|
||||
("12", "р. Нижняя Тунгуска", 38),
|
||||
("16", "оз. Комариное", None),
|
||||
]
|
||||
assert rows[0].source_system == "rf4map-waterbodies"
|
||||
assert rows[0].source_url == "https://rf4map.ru/lakes/12"
|
||||
|
||||
|
||||
def test_rf4map_waterbody_directory_rejects_unrelated_html() -> None:
|
||||
with pytest.raises(CommunityParseError, match="RF4MAP waterbody directory not found"):
|
||||
parse_rf4map_waterbodies("<html></html>")
|
||||
|
||||
|
||||
def test_parses_rf4stat_waterbody_directory_metrics() -> None:
|
||||
rows = parse_rf4stat_waterbodies(fixture("rf4stat_waterbodies_sample.html"))
|
||||
|
||||
assert [(row.source_external_id, row.name, row.catches_count, row.fish_species_count, row.bait_count, row.spot_count) for row in rows] == [
|
||||
("1", "Mosquito Lake", 254, 20, 42, 9),
|
||||
("19", "Elk Lake", 520, 19, 121, 11),
|
||||
]
|
||||
assert rows[0].source_url == "https://en.rf4-stat.ru/locations/location/1"
|
||||
|
||||
|
||||
def test_rf4stat_waterbody_directory_rejects_unrelated_html() -> None:
|
||||
with pytest.raises(CommunityParseError, match="RF4-STAT waterbody directory not found"):
|
||||
parse_rf4stat_waterbodies("<html></html>")
|
||||
|
||||
|
||||
def test_secondary_candidates_convert_to_crosswalk_identities_only() -> None:
|
||||
candidates = parse_rf4map_waterbodies(fixture("rf4map_waterbodies_sample.html"))
|
||||
|
||||
identities = identities_from_candidates(candidates)
|
||||
|
||||
assert identities == [
|
||||
WaterbodyIdentity("rf4map-waterbodies", "12", "р. Нижняя Тунгуска"),
|
||||
WaterbodyIdentity("rf4map-waterbodies", "16", "оз. Комариное"),
|
||||
]
|
||||
|
||||
|
||||
def test_rf4db_waterbody_catalog_rejects_unrelated_html() -> None:
|
||||
with pytest.raises(CommunityParseError, match="waterbody cards not found"):
|
||||
parse_rf4db_waterbodies("<html></html>")
|
||||
|
||||
Reference in New Issue
Block a user