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 @@ + +