Po naprawie dostepu vjav ruszyl, ale zaczal sypac smieciem: pomiar na 295 scenach z doby po fixie dal 78,6 proc. tytulow slug-concat i ZERO przypisanej obsady. Takie sceny sa w apce nieodnajdywalne - nie maja ani tytulu, ani performera. Bramka odrzuca cztery wzorce: prefiks id_JAV, hash x1x..., kod typu QCT062-KPUDYLMLBL913295279 oraz ciagi bez samoglosek/spacji (walniecie w klawiature). Zmierzone na probce 6000 tytulow: odrzuca 80 proc. swiezych i 27 proc. starych, przy 39 przepuszczonych na 200 obejrzanych - zrodlo nie jest wyzerowane. WAZNE, znalezione przy pomiarze pierwszej wersji: str.isalpha() jest prawdziwe dla kana i kanji, a japonski nie stawia spacji, wiec reguly "za malo samoglosek" i "brak spacji" odrzucalyby PRAWDZIWE tytuly na serwisie JAV - 708 z 708 tytulow CJK z probki. Stad jawny wyjatek _has_cjk. Kontrola jednostkowa: 8/8 przypadkow, w tym dwa CJK. Drugi blad, wprowadzony przez sama bramke i zlapany dopiero pilotem: strona 1 jest w 100 proc. smieciowa, wiec po odsianiu zwracala [], a bazowe latest_scenes traktuje pusta strone jako koniec katalogu i konczylo caly run. Zrodlo dawalo seen: 0 mimo 39 dobrych scen na stronach 2-5. Wlasna iteracja rozstrzyga wyczerpanie po dlugosci listy id, nie po liczbie scen po filtrze. To ta sama pulapka co w _load_ids. Pilot po obu poprawkach: seen 39 (wszystkie skipped - weszly przed bramka). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""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
|