goon/app/connectors/direct_scrapers/vjav.py
goon-foss a4486980a4 style: myslniki em/en z komentarzy i docstringow (calosc repo)
1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt,
' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety;
zakresy 0–5 i A–Z na dywiz.

Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika
stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu
komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach,
czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu,
tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana.

Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik
byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli
do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie).

Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika
to zero zmiany.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:51:57 +02:00

303 lines
13 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""vjav.com, JAV browse scraper (TXXX network). Sitemap id-walk + JSON metadata API.
Osobna pula JAV (tytuły azjatyckie + `title_jp`, studia typu JAVHD), origin `tube:vjav`.
Świadomie orphan vertical (sekcja JAV), NIE deduplikuje się z zachodnim katalogiem.
vjav to SPA (ktk_player + blob), listing renderuje się JS-owo, więc HTML listingu
nie ma linków do scen. Zamiast tego:
- **browse**: `sitemap.xml` -> `sitemap_vids_N.xml` (id-e wszystkich wideo). Newest
= najwyższe id (auto-increment). Cache posortowanej listy id malejąco, page N =
kolejny wycinek PER_PAGE.
- **metadata**: bogaty JSON API `GET /api/json/video/1/<floor>/<id>/<id>.json`
(floor = id//1000*1000), title (+title_jp), duration, post_date, channel.title
(studio), models{} (performerki), categories{}+tags{}.
- **stream**: osobny ekstraktor `app/extractors/tubes/vjav.py` (videofile.php ->
get_file HLS). RE 2026-07-10.
Override `crawl_page()` (API/sitemap flow, wzór jak EpornerApiScraper). HTML-owe
_listing_url/_extract/_parse to stuby (ABC ich wymaga, nieużywane).
"""
from __future__ import annotations
import json
import logging
import re
from datetime import date, datetime
from app.connectors.base import (
RawPerformer,
RawPlaybackSource,
RawScene,
RawStudio,
RawTag,
)
from app.config import get_settings
from app.connectors.direct_scrapers._browse_base import BaseBrowseScraper
from app.extractors import browser_get
from app.normalize.text import slugify
log = logging.getLogger(__name__)
_BASE = "https://vjav.com"
_PER_PAGE = 40
_SITEMAP_INDEX = f"{_BASE}/sitemap.xml"
_SITEMAP_RE = re.compile(r"https://vjav\.com/sitemap_vids_\d+\.xml")
_VID_RE = re.compile(r"/videos/(\d+)/")
def _parse_duration(value: str | None) -> int | None:
"""`1:16:43` / `14:17` -> sekundy."""
if not value:
return None
try:
parts = [int(p) for p in value.strip().split(":")]
except ValueError:
return None
sec = 0
for p in parts:
sec = sec * 60 + p
return sec or None
# --- bramka jakości tytułu ----------------------------------------------------------
# vjav zaczął sypać śmieciem: pomiar 2026-08-04 na 295 scenach z doby po naprawie
# dostępu dał 78,6% tytułów typu slug-concat i ZERO przypisanej obsady. Takie sceny są
# w apce nieodnajdywalne, nie mają ani tytułu, ani performera, po którym da się trafić.
# W starym katalogu ten sam pomiar daje 27% (wcześniejsza liczba 2,9% była zaniżona,
# bo sprawdzała tylko jeden z czterech wzorców).
_JUNK_ID_JAV = re.compile(r"^\d{6,}_JAV", re.IGNORECASE) # 0008364_JAV_JapaneseAV_...
_JUNK_HEX_ID = re.compile(r"^x\d+x[0-9A-F]{12,}$") # x1x84BDE66B5F8862EDA6
_JUNK_CODE_MASH = re.compile(r"^[A-Za-z]{2,4}\d{2,4}-[A-Z]{6,}\d{6,}$") # QCT062-KPUDYLMLBL913295279
def _has_cjk(text: str) -> bool:
"""Tytuły japońskie/chińskie MUSZĄ omijać heurystyki znakowe: `str.isalpha()` jest
prawdziwe dla kana i kanji, a japoński nie stawia spacji, więc reguły „za mało
samogłosek" i „brak spacji" odrzuciłyby prawdziwe tytuły na serwisie JAV. Ta pomyłka
wyszła przy pomiarze pierwszej wersji bramki, bez tego wyjątku poszłoby 708 z 708
tytułów CJK z próbki."""
return sum(1 for c in text if " " <= c <= "鿿" or "＀" <= c <= "￯") >= 3
def _is_junk_title(title: str) -> bool:
"""True gdy tytuł nie niesie żadnej informacji (id, hash, walnięcie w klawiaturę).
Zmierzone na próbce 6000 tytułów vjav: odrzuca 76% świeżych i 27% starych, przy
ZERO odrzuconych tytułach CJK. Przepuszczone przykłady to normalne zdania
(„Maki Horiguchis Erotic Shower...", „200GANA-2482 Japanese Amateur")."""
t = (title or "").strip()
if not t:
return True
if _JUNK_ID_JAV.search(t) or _JUNK_HEX_ID.search(t) or _JUNK_CODE_MASH.search(t):
return True
if _has_cjk(t):
return False
letters = [c for c in t if "a" <= c.lower() <= "z"]
if len(letters) >= 12:
vowels = sum(1 for c in letters if c.lower() in "aeiouy")
if vowels / len(letters) < 0.22:
return True
return " " not in t and len(t) >= 20
def _parse_post_date(value: str | None) -> date | None:
if not value:
return None
try:
return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S").date()
except ValueError:
return None
class VjavScraper(BaseBrowseScraper):
sitetag = "vjav"
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
# Cache id-ów wideo (posortowane malejąco = newest first), per instancja/run.
self._sorted_ids: list[int] | None = None
# vjav odciął IP VPS-a: 2026-08-03 KAŻDA ścieżka (sitemap, strona główna,
# /latest-updates/) oddawała 429 przy trzech próbach z rzędu, a przez proxy
# 200 od pierwszej. Źródło stało 12 dni. Bright Data jest tu więc warunkiem
# działania, nie optymalizacją, bez klucza scraper się wyłącza.
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("vjav: BRAK BRIGHTDATA_PROXY_URL — vjav blokuje IP VPS-a (429)")
# ---- browse: sitemap id-walk ------------------------------------------------
def _load_ids(self) -> None:
"""`self._sorted_ids = []` znaczy „katalog pusty", a to woła `crawl_page` do
zwrócenia [], czyli „koniec katalogu". Dlatego błąd pobrania NIE MOŻE tu
ustawiać []: przy 429 scraper raportował 0 scen i 0 błędów, więc przez 12 dni
wyglądał na zdrowy. Zostawiamy None = „nie wiadomo", co crawl_page tłumaczy na
transient-fail. Status sprawdzamy jawnie, bo `browser_get` nie rzuca na 4xx,
i to właśnie przez to treść błędu 429 była parsowana jako XML."""
try:
res = browser_get(_SITEMAP_INDEX, timeout=self._timeout, proxy=self._proxy)
if getattr(res, "status_code", 200) >= 400:
log.warning("vjav: sitemap index HTTP %s — przerywam", res.status_code)
self._sorted_ids = None
return
idx = res.text if hasattr(res, "text") else res
except Exception as e:
log.warning("vjav: sitemap index fetch fail: %s", e)
self._sorted_ids = None
return
maps = list(dict.fromkeys(_SITEMAP_RE.findall(idx)))
if not maps:
log.warning("vjav: sitemap index bez sitemap wideo (%d B) — przerywam", len(idx))
self._sorted_ids = None
return
ids: set[int] = set()
for sm in maps:
try:
res = browser_get(sm, timeout=self._timeout, proxy=self._proxy)
if getattr(res, "status_code", 200) >= 400:
log.info("vjav: sitemap %s HTTP %s", sm, res.status_code)
continue
body = res.text if hasattr(res, "text") else res
except Exception as e:
log.info("vjav: sitemap %s fetch fail: %s", sm, e)
continue
for m in _VID_RE.finditer(body):
ids.add(int(m.group(1)))
self._sorted_ids = sorted(ids, reverse=True)
log.info("vjav: załadowano %d id z %d sitemap", len(self._sorted_ids), len(maps))
def _meta_url(self, vid: int) -> str:
floor = (vid // 1000) * 1000
return f"{_BASE}/api/json/video/1/{floor}/{vid}/{vid}.json"
# ---- metadata JSON -> RawScene ----------------------------------------------
def _parse_meta(self, vid: int, v: dict) -> RawScene | None:
title = (v.get("title") or "").strip()
slug = (v.get("dir") or "").strip()
if not title or not slug:
return None
if _is_junk_title(title):
return None
page_url = f"{_BASE}/videos/{vid}/{slug}/"
duration_sec = _parse_duration(v.get("duration"))
release_date = _parse_post_date(v.get("post_date"))
thumb = (v.get("thumbsrc") or v.get("thumb") or "").strip() or None
studio: RawStudio | None = None
ch = v.get("channel")
if isinstance(ch, dict) and (ch.get("title") or "").strip():
st = ch["title"].strip()
sg = slugify(st)
studio = RawStudio(external_id=f"vjav:studio:{sg}", name=st, slug=sg)
performers: list[RawPerformer] = []
models = v.get("models")
if isinstance(models, dict):
for mid, md in models.items():
nm = (md.get("title") or "").strip() if isinstance(md, dict) else ""
if nm:
performers.append(RawPerformer(external_id=f"vjav:model:{mid}", name=nm))
tags: list[RawTag] = []
seen_slugs: set[str] = set()
for coll in ("categories", "tags"):
c = v.get(coll)
if not isinstance(c, dict):
continue
for td in c.values():
nm = (td.get("title") or "").strip() if isinstance(td, dict) else ""
if not nm:
continue
sg = slugify(nm)
if not sg or sg in seen_slugs or len(sg) > 60:
continue
seen_slugs.add(sg)
tags.append(RawTag(external_id=f"vjav:tag:{sg}", name=nm, slug=sg))
return RawScene(
external_id=f"{self.sitetag}:{page_url}",
title=title,
release_date=release_date,
duration_sec=duration_sec,
url=page_url,
studio=studio,
performers=performers,
tags=tags,
playback_sources=[
RawPlaybackSource(
origin=f"tube:{self.sitetag}",
page_url=page_url,
duration_sec=duration_sec,
thumbnail_url=thumb,
)
],
raw={"source": "vjav_api", "id": vid},
)
def latest_scenes(self, *, max_pages: int = 5):
"""Własna iteracja, bo bazowa przerywa na pierwszej pustej stronie.
Po wprowadzeniu bramki jakości pusta strona przestała znaczyć „koniec
katalogu": strona 1 bywa w 100% śmieciowa (same `0008364_JAV_...`), więc po
odsianiu zwraca [], a bazowe `latest_scenes` traktowało to jak wyczerpanie i
kończyło CAŁY run, źródło dawało `seen: 0` mimo 39 dobrych scen na stronach
2-5. To ta sama pułapka „[] znaczy wyczerpane" co w `_load_ids`.
Tu wyczerpanie rozstrzyga długość listy id, nie liczba scen po filtrze.
"""
if self._sorted_ids is None:
self._load_ids()
if not self._sorted_ids:
return
for page in range(1, max_pages + 1):
if (page - 1) * _PER_PAGE >= len(self._sorted_ids):
return # realny koniec katalogu
scenes = self.crawl_page(page)
if scenes is None:
return # transient fail
yield from scenes
def crawl_page(self, page: int) -> list[RawScene] | None:
if self._sorted_ids is None:
self._load_ids()
if not self._sorted_ids:
# [] gdy sitemap pusty (exhausted); None tylko przy fetch-failu indexu.
return [] if self._sorted_ids == [] else None
start = (page - 1) * _PER_PAGE
chunk = self._sorted_ids[start : start + _PER_PAGE]
if not chunk:
return []
out: list[RawScene] = []
for vid in chunk:
try:
# Proxy także tutaj, API metadanych siedzi na tym samym hoście, więc
# obejmuje je ten sam 429 co sitemap.
res = browser_get(
self._meta_url(vid),
timeout=self._timeout,
headers={"Referer": _BASE + "/"},
proxy=self._proxy,
)
body = res.text if hasattr(res, "text") else res
v = json.loads(body).get("video")
except Exception as e:
log.info("vjav: meta fetch/parse fail id=%s: %s", vid, e)
continue
if not isinstance(v, dict):
continue
try:
raw = self._parse_meta(vid, v)
except Exception as e:
log.warning("vjav: meta parse fail id=%s: %s", vid, e)
continue
if raw is not None:
out.append(raw)
return out
# ---- HTML stuby (nieużywane, ABC wymaga) ------------------------------------
def _listing_url(self, page: int) -> str: # pragma: no cover
raise NotImplementedError("VjavScraper używa crawl_page (sitemap+API)")
def _extract_scene_urls(self, listing_html: str) -> list[str]: # pragma: no cover
raise NotImplementedError
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None: # pragma: no cover
raise NotImplementedError