From f02cb247a3d0b967a2831b747d5c932b18e6ec56 Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 20 Sep 2026 19:16:46 +0700 Subject: [PATCH] feat: add offline waterbody source crosswalk --- apps/web/tests/waterbodies.spec.ts | 19 ++++ docs/ROADMAP.md | 4 +- docs/community-source-pilot.md | 6 +- docs/data-source-audit.md | 6 + rf4_research/community_cli.py | 28 +++-- rf4_research/community_sources.py | 107 ++++++++++++++++++ rf4_research/waterbody_crosswalk.py | 27 +++++ tests/fixtures/rf4map_waterbodies_sample.html | 5 + .../fixtures/rf4stat_waterbodies_sample.html | 12 ++ tests/test_community_cli.py | 65 ++++++++++- tests/test_community_sources.py | 46 +++++++- 11 files changed, 311 insertions(+), 14 deletions(-) create mode 100644 apps/web/tests/waterbodies.spec.ts create mode 100644 tests/fixtures/rf4map_waterbodies_sample.html create mode 100644 tests/fixtures/rf4stat_waterbodies_sample.html diff --git a/apps/web/tests/waterbodies.spec.ts b/apps/web/tests/waterbodies.spec.ts new file mode 100644 index 0000000..d5368f2 --- /dev/null +++ b/apps/web/tests/waterbodies.spec.ts @@ -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"); +}); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a32c1c3..ed3dbfd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. ### Каталог снастей, наживок и прочей оснастки diff --git a/docs/community-source-pilot.md b/docs/community-source-pilot.md index 6329ed4..6da6c54 100644 --- a/docs/community-source-pilot.md +++ b/docs/community-source-pilot.md @@ -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 трактуется как агрегированная точка, а не индивидуальный улов. ## Перед продуктивным импортом diff --git a/docs/data-source-audit.md b/docs/data-source-audit.md index 6fa559b..121592d 100644 --- a/docs/data-source-audit.md +++ b/docs/data-source-audit.md @@ -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 считается агрегированной точкой, а не набором взвешенных уловов. diff --git a/rf4_research/community_cli.py b/rf4_research/community_cli.py index e2124b6..2719dfb 100644 --- a/rf4_research/community_cli.py +++ b/rf4_research/community_cli.py @@ -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" diff --git a/rf4_research/community_sources.py b/rf4_research/community_sources.py index a3e4490..662a361 100644 --- a/rf4_research/community_sources.py +++ b/rf4_research/community_sources.py @@ -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: diff --git a/rf4_research/waterbody_crosswalk.py b/rf4_research/waterbody_crosswalk.py index 0f7b305..f1637df 100644 --- a/rf4_research/waterbody_crosswalk.py +++ b/rf4_research/waterbody_crosswalk.py @@ -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]: diff --git a/tests/fixtures/rf4map_waterbodies_sample.html b/tests/fixtures/rf4map_waterbodies_sample.html new file mode 100644 index 0000000..14cde44 --- /dev/null +++ b/tests/fixtures/rf4map_waterbodies_sample.html @@ -0,0 +1,5 @@ + + + 35 р. Нижняя Тунгуска 38 видов рыбы + дубликат + оз. Комариное + diff --git a/tests/fixtures/rf4stat_waterbodies_sample.html b/tests/fixtures/rf4stat_waterbodies_sample.html new file mode 100644 index 0000000..bd301cf --- /dev/null +++ b/tests/fixtures/rf4stat_waterbodies_sample.html @@ -0,0 +1,12 @@ + + + diff --git a/tests/test_community_cli.py b/tests/test_community_cli.py index 5803e30..067ed15 100644 --- a/tests/test_community_cli.py +++ b/tests/test_community_cli.py @@ -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: "") + 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: "") + 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("", 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: diff --git a/tests/test_community_sources.py b/tests/test_community_sources.py index a4159c0..0176ec9 100644 --- a/tests/test_community_sources.py +++ b/tests/test_community_sources.py @@ -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("") + + +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("") + + +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("")