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>
167 lines
6.8 KiB
Python
167 lines
6.8 KiB
Python
"""fullporner.com, browse scraper. Dodany 2026-07-27.
|
|
|
|
Pełne sceny, nie klipy: mediana długości 2067 s na stronie 1, 1911 s na 40 i 1969 s
|
|
na 300, zero filmów poniżej 10 minut w próbce. To jest różnica, przez którą sexu
|
|
odpadł, a ten wchodzi.
|
|
|
|
Listing jest ściśle chronologiczny, mimo że nagłówek mówi „Featured Videos" (strona
|
|
1-3 = dziś, 10 = doba, 50 = 9 dni, 500 = 86 dni), 24 sceny na stronę. Daje to
|
|
~140-240 pełnych scen dziennie i katalog co najmniej 12 tys. scen wstecz.
|
|
|
|
**Metadane są cienkie i trzeba to wiedzieć wprost.** Nie ma studia. Tagów jest
|
|
mediana 3. Tytuły są w 100% przepisane pod SEO, więc nie niosą nic z tytułu
|
|
studyjnego („POV Blowjob Queen Takes It Hard and Deep" tam, gdzie kanon ma „Blacked
|
|
- Dylan Moore - Petite Blonde Squirt"). Za to data jest podana jako **unix timestamp**
|
|
(dokładna, nie względna) i długość co do sekundy.
|
|
|
|
Dry-run resolvera na 120 scenach (w rollbacku): 37 (31%) dopięło się do scen, które
|
|
już mamy, przez `composite_auto`, composite radzi sobie mimo złych tytułów, bo waży
|
|
performera i długość. Reszta to nowa podaż. Zmierzony przeciek duplikatów, po
|
|
odjęciu tła z kontroli na przesuniętej długości: ~15% katalogu. Liczymy phash
|
|
miniaturki właśnie po to, żeby późniejszy `bulk_dedup` miał czym te duplikaty łapać.
|
|
|
|
**Bramka na obsadę.** Sceny bez performera (19%) w dry-runie scaliły się w 0 na 23
|
|
przypadków, bez performera composite nie ma na czym pracować, a studia tu nie ma
|
|
i tytuł jest zmyślony, więc taka scena nie ma ŻADNEGO sygnału kanonicznego. Wchodzą
|
|
tylko sceny z obsadą.
|
|
|
|
Playback: `xiaoshenke.net` (extractor `fullporner`), bezpośredni mp4 do 1080p bez
|
|
tokenu i bez wygasania.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import html as html_mod
|
|
import logging
|
|
import re
|
|
from datetime import UTC, datetime
|
|
|
|
from app.connectors.base import (
|
|
RawFingerprint,
|
|
RawPerformer,
|
|
RawPlaybackSource,
|
|
RawScene,
|
|
RawTag,
|
|
)
|
|
from app.connectors.direct_scrapers._browse_base import (
|
|
BaseBrowseScraper,
|
|
compute_thumbnail_phash,
|
|
)
|
|
from app.normalize.text import slugify
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_BASE = "https://fullporner.com"
|
|
_THUMB = "https://imgs.xiaoshenke.net/thumb/{}.jpg"
|
|
|
|
_SCENE_HREF_RE = re.compile(r'href="(/watch/[a-f0-9]+)"')
|
|
_TITLE_RE = re.compile(r"<h2[^>]*>\s*([^<]+?)\s*</h2>")
|
|
# `<span class="create">1785081937</span></div><div>36:33</div>`, data i długość
|
|
# siedzą obok siebie w `video-info`, więc bierzemy je jednym wzorcem.
|
|
_INFO_RE = re.compile(
|
|
r'class="create">(\d+)</span>\s*</div>\s*<div>\s*([0-9:]+)\s*</div>'
|
|
)
|
|
_PERF_BLOCK_RE = re.compile(r"<b>\s*Pornstars?:\s*</b>(.*?)</p>", re.IGNORECASE | re.DOTALL)
|
|
_PERF_RE = re.compile(r'href="/pornstar/[^"]+"[^>]*>\s*([^<]+?)\s*</a>')
|
|
_TAG_BLOCK_RE = re.compile(r'class="[^"]*tag-link[^"]*"[^>]*>(.*?)</p>', re.DOTALL)
|
|
_TAG_RE = re.compile(r'href="/category/[^"]+"[^>]*>#\s*([^<]+?)\s*</a>')
|
|
# `<iframe src="//xiaoshenke.net/video/4458723/14">`, id wspak, /14 = maska jakości.
|
|
# Id bywa numeryczne albo szesnastkowe (`a6487a97766a6`, ~25% katalogu).
|
|
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
|
|
|
|
|
|
def _duration(text: str) -> int | None:
|
|
parts = [int(x) for x in text.split(":")]
|
|
if len(parts) == 2:
|
|
return parts[0] * 60 + parts[1]
|
|
if len(parts) == 3:
|
|
return parts[0] * 3600 + parts[1] * 60 + parts[2]
|
|
return None
|
|
|
|
|
|
class FullPornerScraper(BaseBrowseScraper):
|
|
sitetag = "fullporner"
|
|
|
|
def _listing_url(self, page: int) -> str:
|
|
return f"{_BASE}/" if page <= 1 else f"{_BASE}/home/{page}"
|
|
|
|
def _extract_scene_urls(self, listing_html: str) -> list[str]:
|
|
return [_BASE + p for p in dict.fromkeys(_SCENE_HREF_RE.findall(listing_html))]
|
|
|
|
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
|
|
tm = _TITLE_RE.search(detail_html)
|
|
if not tm:
|
|
return None
|
|
title = html_mod.unescape(tm.group(1)).strip()
|
|
if not title:
|
|
return None
|
|
|
|
# BRAMKA: bez obsady scena nie ma żadnego sygnału kanonicznego (patrz docstring).
|
|
performers: list[RawPerformer] = []
|
|
seen_perf: set[str] = set()
|
|
pb = _PERF_BLOCK_RE.search(detail_html)
|
|
if pb:
|
|
for name in _PERF_RE.findall(pb.group(1)):
|
|
# Strona pisze performerów z małej („melissa straton"), do wyświetlania
|
|
# podnosimy, dopasowanie i tak idzie po slugu.
|
|
name = html_mod.unescape(name).strip().title()
|
|
slug = slugify(name)
|
|
if slug and slug not in seen_perf:
|
|
seen_perf.add(slug)
|
|
performers.append(
|
|
RawPerformer(external_id=f"{self.sitetag}:performer:{slug}", name=name)
|
|
)
|
|
if not performers:
|
|
return None
|
|
|
|
im = _INFO_RE.search(detail_html)
|
|
release_date = duration_sec = None
|
|
if im:
|
|
release_date = datetime.fromtimestamp(int(im.group(1)), UTC).date()
|
|
duration_sec = _duration(im.group(2))
|
|
|
|
tags: list[RawTag] = []
|
|
seen_tag: set[str] = set()
|
|
tb = _TAG_BLOCK_RE.search(detail_html)
|
|
if tb:
|
|
for name in _TAG_RE.findall(tb.group(1)):
|
|
name = html_mod.unescape(name).strip()
|
|
slug = slugify(name)
|
|
if slug and slug not in seen_tag:
|
|
seen_tag.add(slug)
|
|
tags.append(
|
|
RawTag(external_id=f"{self.sitetag}:tag:{slug}", name=name, slug=slug)
|
|
)
|
|
|
|
# Miniaturka: id mediów to id z iframe zapisane WSPAK (patrz extractor).
|
|
thumbnail_url = None
|
|
fm = _IFRAME_RE.search(detail_html)
|
|
if fm:
|
|
thumbnail_url = _THUMB.format(fm.group(1)[::-1])
|
|
|
|
# phash liczymy świadomie, mimo kosztu jednego fetcha na scenę: to jedyny
|
|
# sygnał, którym bulk_dedup może potem łapać te ~15% duplikatów, których
|
|
# composite nie scalił (tytuły są przepisane, więc po nich nie pójdzie).
|
|
fingerprints: list[RawFingerprint] = []
|
|
if thumbnail_url:
|
|
ph = compute_thumbnail_phash(thumbnail_url, referer=_BASE + "/")
|
|
if ph:
|
|
fingerprints.append(RawFingerprint(kind="phash", value=ph))
|
|
|
|
return RawScene(
|
|
external_id=f"{self.sitetag}:{scene_url.rsplit('/', 1)[-1]}",
|
|
title=title,
|
|
release_date=release_date,
|
|
duration_sec=duration_sec,
|
|
url=scene_url,
|
|
performers=performers,
|
|
tags=tags,
|
|
fingerprints=fingerprints,
|
|
playback_sources=[
|
|
RawPlaybackSource(
|
|
origin=f"tube:{self.sitetag}",
|
|
page_url=scene_url,
|
|
duration_sec=duration_sec,
|
|
thumbnail_url=thumbnail_url,
|
|
)
|
|
],
|
|
)
|