feat(fullporner): browse scraper pelnych scen + extractor bez tokenu
Pelne sceny, nie klipy: mediana ~33 min, zero ponizej 10 min, ~140-240 dziennie, listing chronologiczny mimo naglowka Featured. Extractor sklada URL sam, bez tokenu i bez wygasania: id w iframe jest zapisane wspak, a koncowka to maska bitowa jakosci (1/2/4/8 = 360/480/720/1080). Id bywa numeryczne albo szesnastkowe, a to drugie to ~25% katalogu. Blokada CDN jest na naglowku (bez UA 403, bez Referera 404), NIE na fingerprincie TLS, wiec telefon przechodzi -> mobile_direct_ok. Zweryfikowane 206 + faststart do 1080p. Metadane cienkie: brak studia, mediana 3 tagi, tytuly przepisane pod SEO. Za to data jako unix timestamp i dlugosc co do sekundy. Bramka na obsade, bo sceny bez performera scalily sie 0/23 w dry-runie. Phash miniatury liczony celowo i to on robi robote: dry-run resolvera bez niego dawal 31% scalen, pilot z nim dal 65% (seen 62, new 22, updated 40, errors 0). 85 z 89 performerow ma ref tpdb/stashdb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ea489454c0
commit
b485511f3f
4 changed files with 260 additions and 0 deletions
|
|
@ -48,6 +48,7 @@ from app.connectors.direct_scrapers.galaxyporn import GalaxyPornScraper
|
|||
from app.connectors.direct_scrapers.pornbusy import PornBusyScraper
|
||||
from app.connectors.direct_scrapers.pornmike import PornMikeScraper
|
||||
from app.connectors.direct_scrapers.kporner8 import KPorner8Scraper
|
||||
from app.connectors.direct_scrapers.fullporner import FullPornerScraper
|
||||
from app.connectors.direct_scrapers.watchporn import WatchPornScraper
|
||||
from app.connectors.direct_scrapers.youperv import YoupervScraper
|
||||
from app.connectors.direct_scrapers.xhamster import XHamsterScraper
|
||||
|
|
@ -189,6 +190,16 @@ ALL_BROWSE_SCRAPERS: list[type[BaseBrowseScraper]] = [
|
|||
# na obsadę przepuszcza 62/115, a z nich 97% performerów ma ref tpdb/stashdb.
|
||||
# Pilot: seen 62, new 25, updated 37 — 60% dopina się do scen, które już mamy.
|
||||
KPorner8Scraper,
|
||||
# FullPornerScraper — dodany 2026-07-27. PEŁNE sceny (mediana ~33 min, zero poniżej
|
||||
# 10 min), ~140-240 dziennie, listing chronologiczny mimo nagłówka „Featured".
|
||||
# Playback najtrwalszy w portfolio: mp4 do 1080p bez tokenu i bez wygasania.
|
||||
# Metadane cienkie: BRAK studia, mediana 3 tagi, tytuły przepisane pod SEO — za to
|
||||
# data jako unix timestamp i długość co do sekundy. Dry-run resolvera na 120
|
||||
# scenach: 31% dopina się do tego, co mamy (composite waży performera + długość),
|
||||
# przeciek duplikatów ~15% po odjęciu tła kontrolnego. Bramka na obsadę, bo sceny
|
||||
# bez performera scaliły się 0/23. phash liczony celowo — to jedyny sygnał dla
|
||||
# bulk_dedup na te duplikaty, po tytule nie pójdzie.
|
||||
FullPornerScraper,
|
||||
# SexuScraper (`sexu.py`) — ZBUDOWANY 2026-07-27, ale NIE ingestujemy. Metadane ma
|
||||
# najlepsze z całej serii (studio 97%, 96% performerów z refem tpdb/stashdb, dokładna
|
||||
# długość, świeżość w godzinach), tylko jednostka treści jest zła: to **klipy 5-10
|
||||
|
|
|
|||
167
app/connectors/direct_scrapers/fullporner.py
Normal file
167
app/connectors/direct_scrapers/fullporner.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""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,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
@ -35,6 +35,7 @@ from app.extractors.tubes import (
|
|||
hdporngg,
|
||||
hqfap,
|
||||
hqporner,
|
||||
fullporner,
|
||||
kporner8,
|
||||
sexu,
|
||||
javflix,
|
||||
|
|
@ -128,6 +129,10 @@ _REGISTRY: dict[str, Callable[[str], list[StreamSource] | None]] = {
|
|||
# okcdn/vkuser jak 4k69: srcIp nieegzekwowane → mobile direct. Cap 720p, bo CDN
|
||||
# dławi do ~2,1 Mbps, a 1080p+ potrzebuje więcej → "loading forever".
|
||||
"8kpornercom": kporner8.extract,
|
||||
# fullporner — iframe xiaoshenke: id mediów zapisane WSPAK + maska bitowa jakości.
|
||||
# Zero tokenu i zero wygasania, URL składamy sami. Blokada jest na nagłówku (bez
|
||||
# UA → 403, bez Referera → 404), NIE na fingerprincie TLS, więc telefon przechodzi.
|
||||
"fullporner": fullporner.extract,
|
||||
# sexu — POST /api/video-info przez Bright Data (CF blokuje sam POST z VPS).
|
||||
# Bierzemy HLS `playerData.src`, NIE mp4 `sources` — te mają w tokenie `ip=`
|
||||
# requestera, więc na telefonie dałyby 403. Master kończy się `.mp4` → wchodzi
|
||||
|
|
|
|||
77
app/extractors/tubes/fullporner.py
Normal file
77
app/extractors/tubes/fullporner.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""fullporner.com — bezpośredni mp4 z xiaoshenke.net. Dodany 2026-07-27.
|
||||
|
||||
Najprostszy i najtrwalszy stream w całym portfolio: URL buduje się deterministycznie,
|
||||
**bez tokenu, bez podpisu i bez wygasania**. Nie ma tu nic do resolvowania po stronie
|
||||
hostera, wystarczy przeczytać dwie liczby z iframe'a.
|
||||
|
||||
Strona sceny osadza `<iframe src="//xiaoshenke.net/video/4458723/14">`, a player robi
|
||||
z tego:
|
||||
|
||||
var id = "4458723".split('').reverse().join(''); // id mediów zapisane WSPAK
|
||||
function btq(f) { 1→360, 2→480, 4→720, 8→1080 } // /14 = 8+4+2 = 1080,720,480
|
||||
v.media = `//${location.hostname}/vid/${id}/${quality}`
|
||||
|
||||
czyli `https://xiaoshenke.net/vid/3278544/1080`. Że id jest odwrócone, potwierdza
|
||||
miniaturka z listingu: `imgs.xiaoshenke.net/thumb/3278544.jpg`.
|
||||
|
||||
**Warunek dostępu to Referer + jakikolwiek realistyczny User-Agent.** Sprawdzone
|
||||
osobno, bo to przesądza o `mobile_direct_ok`: zwykły httpx (a więc NIE fingerprint
|
||||
TLS Chrome'a) z Refererem i UA ExoPlayera dostaje 206, a ten sam request bez UA
|
||||
dostaje 403. Blokada jest więc na nagłówku, nie na fingerprincie, i telefon ją
|
||||
przechodzi — `playback.py` wysyła oba nagłówki.
|
||||
|
||||
Zweryfikowane: 1080p = 1,51 GB, 720p = 754 MB, 206 na Range, `ftypisom` w pierwszym
|
||||
KB (faststart, seek działa od razu). Przy błędzie player dokleja `/b` (backup CDN) —
|
||||
nie używamy tego, bo podstawowy URL działa, ale gdyby zaczęło sypać 404, to jest
|
||||
pierwsza rzecz do sprawdzenia.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.extractors._fetch import fetch_tube_html
|
||||
from app.extractors._models import StreamSource
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_BASE = "https://fullporner.com"
|
||||
# Id bywa numeryczne (`4458723`) ALBO szesnastkowe (`a6487a97766a6`) — to drugie to
|
||||
# ~25% katalogu i przy `\d+` wypadało z ingestu. Odwracanie działa dla obu, bo player
|
||||
# robi zwykły reverse stringa.
|
||||
_IFRAME_RE = re.compile(r'src="(?://)?[^"]*xiaoshenke\.net/video/([0-9a-f]+)/(\d+)"')
|
||||
# Maska bitowa z URL-a iframe'a, 1:1 z `btq()` w playerze.
|
||||
_QUALITY_BITS = ((1, 360), (2, 480), (4, 720), (8, 1080))
|
||||
|
||||
|
||||
def extract(page_url: str, *, timeout: float = 60.0) -> list[StreamSource] | None:
|
||||
html_text = fetch_tube_html(page_url, timeout=timeout)
|
||||
|
||||
m = _IFRAME_RE.search(html_text)
|
||||
if not m:
|
||||
log.info("fullporner: brak iframe xiaoshenke na %s", page_url)
|
||||
return None
|
||||
|
||||
media_id = m.group(1)[::-1] # id w iframe jest wspak
|
||||
mask = int(m.group(2))
|
||||
|
||||
out: list[StreamSource] = []
|
||||
for bit, height in _QUALITY_BITS:
|
||||
if not mask & bit:
|
||||
continue
|
||||
out.append(
|
||||
StreamSource(
|
||||
link=f"https://xiaoshenke.net/vid/{media_id}/{height}",
|
||||
type="mp4",
|
||||
quality=f"{height}p",
|
||||
# Bez Referera CDN oddaje 404, bez UA 403 — oba dokłada playback.py.
|
||||
referer=_BASE + "/",
|
||||
raw={"mobile_direct_ok": True},
|
||||
)
|
||||
)
|
||||
|
||||
if not out:
|
||||
log.info("fullporner: maska jakości %s nie dała żadnej rendycji na %s", mask, page_url)
|
||||
return None
|
||||
out.sort(key=lambda s: int((s.quality or "0p")[:-1]), reverse=True)
|
||||
return out
|
||||
Loading…
Add table
Reference in a new issue