Compare commits

..

3 commits

Author SHA1 Message Date
goon-foss
0da36d5c1e perf(tags): warianty pisowni tagow jednym zapytaniem
Some checks are pending
Backend tests / test (push) Waiting to run
enrich-tags wolal resolve_tag dla kazdej nazwy spoza slugow, a ten szukal po
alnum-kluczu i liczbie mnogiej osobnymi zapytaniami (N+1, GOON-5F). Nowe
resolve_tags_by_altkey robi to jednym IN po indeksie ix_tags_name_altkey;
_resolve_by_altkey dla jednej nazwy korzysta z niego (1 zapytanie zamiast 2).
Na 603 nazwach z produkcji wyniki identyczne, 0,77 s -> 0,03 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:24:18 +02:00
goon-foss
cb9e4ab035 fix(siska): przeniesienie na siska.tv po wygasnieciu siska.video
siska.video od 2026-09-22 nie istnieje (NXDOMAIN), ten sam serwis dziala pod
siska.tv z tymi samymi videoID i sciezkami miniatur. Scraper bierze kafelki z obu
domen i z www, zapisuje jeden kanoniczny adres. Lista najnowszych jest pod
best_xvideos.php?page=N, bo /page/N/ na siska.tv zawsze oddaje strone 1.

Na produkcji adresy stron, miniatur i external_id przepisane na siska.tv
(16 007 zrodel, 16 265 powiazan scen); 103 duplikaty www.siska.video oznaczone
jako martwe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:18:22 +02:00
goon-foss
ed7c1cd3ed fix(movies): yourdailypornmovies gubil nowe filmy przez nowy format okladki
Od ~15.09 nowe karty maja okladke imgs4cdn.adultempire.com/movie/<id>?w=... (czesc
i.ibb.co albo bez okladki), a regex karty wymagal starego products/NN/<id>h.jpg.
33 z 40 filmow strony 1 odpadalo po cichu, ingest przez tydzien: 0 nowych.
Okladka teraz opcjonalna i w dowolnym formacie, id Adult Empire z obu wariantow.
Nadrobione: 26 nowych filmow, 6 dopietych do istniejacych.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:18:22 +02:00
6 changed files with 113 additions and 39 deletions

View file

@ -1851,7 +1851,7 @@ def enrich_tags_from_tube(
from app.models.tag import Tag
from app.normalize.scenes import NormalizedTag
from app.normalize.text import slugify
from app.resolve.tag_resolver import resolve_tag
from app.resolve.tag_resolver import resolve_tag, resolve_tags_by_altkey
scene = session.get(Scene, scene_id)
if scene is None:
@ -1923,10 +1923,17 @@ def enrich_tags_from_tube(
{t.slug: t for t in session.execute(select(Tag).where(Tag.slug.in_(direct))).scalars()}
if direct else {}
)
# Nazwy spoza slugow: wariant pisowni istniejacego tagu (alnum-klucz, liczba mnoga)
# tez jednym zapytaniem. resolve_tag zostaje tylko dla naprawde nowych nazw.
direct_set = set(direct)
by_alt = resolve_tags_by_altkey(
session,
[n for n in tag_names if slugify(n) in direct_set and slugify(n) not in by_slug],
)
tag_ids: list = []
for name in tag_names:
slug = slugify(name)
tag = by_slug.get(slug)
tag = by_slug.get(slug) or by_alt.get(name)
if tag is None:
tag = resolve_tag(session, norm=NormalizedTag(name=name, slug=slug, external_id=None))
if tag is not None and tag.id not in tag_ids:

View file

@ -110,7 +110,7 @@ class SourceListOut(BaseModel):
# tubey mają nietypowe TLDs / brakujące kropki w sitetag.
_DISPLAY_OVERRIDES: dict[str, str] = {
"fpoxxx": "fpo.xxx",
"siskavideo": "siska.video",
"siskavideo": "siska.tv",
"porn4dayspw": "porn4days.pw",
"porn00org": "porn00.org",
"freshpornoorg": "freshporno.org",

View file

@ -1,8 +1,8 @@
"""siska.video, latest-vids browse scraper.
"""siska.tv (do 2026-09-22 siska.video), latest-vids browse scraper.
Historia: dawniej performer-driven search scraper (`?s=<q>`), ale siska zepsuła
wyszukiwarkę (zwraca latest niezależnie od query). Przerobione na BROWSE (latest
chronologicznie z `/page/<n>/`), re-enabled 2026-06-20 (user fa4083a2).
chronologicznie z listingu, patrz `_listing_url`), re-enabled 2026-06-20 (user fa4083a2).
Cały blok kafelka listingu ma komplet metadanych (zero detail-fetchy):
<a title='<Tytuł>' href='https://siska.video/video.php?videoID=<n>'>
@ -36,11 +36,18 @@ from app.extractors import browser_get
log = logging.getLogger(__name__)
_BASE = "https://siska.video"
# Domena siska.video wygasla 2026-09-22 (NXDOMAIN), ten sam serwis dziala dalej pod
# siska.tv: te same numery videoID i sciezki miniatur. Adresy w bazie przepisane na
# siska.tv tego samego dnia. Kafelki przyjmujemy z obu domen i z www, ale zapisujemy
# zawsze jeden kanoniczny adres (_canonical), bo external_id sceny i unikalny klucz
# zrodla to sam adres; www.siska.video i siska.video dawaly 103 zdublowane zrodla.
_BASE = "https://siska.tv"
_HOST_RE = re.compile(r"^https://(?:www\.)?siska\.(?:tv|video)/", re.IGNORECASE)
# Kafelek: <a title='..' href='..videoID=N'>. Reszta pól w oknie po tym matchu.
_A_RE = re.compile(
r"<a\s+title='(?P<title>[^']*)'\s+href='(?P<url>https://siska\.video/video\.php\?videoID=\d+)'",
r"<a\s+title='(?P<title>[^']*)'\s+"
r"href='(?P<url>https://(?:www\.)?siska\.(?:tv|video)/video\.php\?videoID=\d+)'",
re.IGNORECASE,
)
_DUR_RE = re.compile(r"th_video_duration'>\s*([\d]{1,2}(?:\s*:\s*[\d]{2}){1,2})\s*<")
@ -49,6 +56,10 @@ _ALT_RE = re.compile(r"alt='([^']*)'")
_CAT_RE = re.compile(r"/category/([^/]+)/", re.IGNORECASE)
def _canonical(url: str) -> str:
return _HOST_RE.sub(_BASE + "/", url)
def _parse_duration(text: str | None) -> int | None:
"""`40 : 27` → 2427 (MM:SS); `1 : 05 : 30` → H:MM:SS. None gdy brak."""
if not text:
@ -73,11 +84,13 @@ class SiskaScraper(BaseBrowseScraper):
sitetag = "siskavideo"
def _listing_url(self, page: int) -> str:
return f"{_BASE}/page/{page}/"
# Na siska.tv `/page/<n>/` oddaje zawsze strone 1. Najnowsze po kolei (18 na
# strone, videoID malejaco) sa pod `best_xvideos.php`, mimo nazwy.
return f"{_BASE}/best_xvideos.php?page={page}"
# crawl_page nadpisany → poniższe abstrakcje nieużywane, ale wymagane do instancji.
def _extract_scene_urls(self, listing_html: str) -> list[str]:
return [m.group("url") for m in _A_RE.finditer(listing_html)]
return [_canonical(m.group("url")) for m in _A_RE.finditer(listing_html)]
def _parse_detail(self, scene_url: str, detail_html: str) -> RawScene | None:
return None
@ -94,7 +107,7 @@ class SiskaScraper(BaseBrowseScraper):
out: list[RawScene] = []
seen: set[str] = set()
for m in _A_RE.finditer(html):
scene_url = m.group("url")
scene_url = _canonical(m.group("url"))
if scene_url in seen:
continue
seen.add(scene_url)
@ -106,7 +119,7 @@ class SiskaScraper(BaseBrowseScraper):
dm = _DUR_RE.search(window)
duration_sec = _parse_duration(dm.group(1) if dm else None)
tm = _THUMB_RE.search(window)
thumbnail_url = tm.group(1) if tm else None
thumbnail_url = _canonical(tm.group(1)) if tm else None
# alt='Performer - Tytuł - Studio' → performer (pierwszy), studio (ostatni).
performers: list[RawPerformer] = []

View file

@ -51,14 +51,19 @@ log = logging.getLogger(__name__)
_BASE = "https://yourdailypornmovies.ws"
# Karta listingu: id produktu Adult Empire w miniaturce + URL + tytuł + rok.
# Karta listingu: okładka + URL + tytuł + rok. Okładka jest opcjonalna i w dowolnym
# formacie: od ~2026-09-15 nowe filmy mają `imgs4cdn.adultempire.com/movie/<id>?w=...`
# zamiast `.../products/NN/<id>h.jpg`, część ma okładkę z i.ibb.co. Regex wymagający
# starego formatu po cichu gubił 33 z 40 filmów strony 1 (ingest: 0 nowych przez tydzień).
_CARD_RE = re.compile(
r'<div class="movie"><div class="imagen">\s*'
r'<img src="(?P<poster>https://imgs\d*cdn\.adultempire\.com/products/\d+/(?P<ae>\d+)h\.jpg)"'
r'[^>]*>\s*<a href="(?P<url>https://yourdailypornmovies\.ws/[^"]+)">.*?'
r'(?:<img src="(?P<poster>[^"]*)"[^>]*>\s*)?'
r'<a href="(?P<url>https://yourdailypornmovies\.ws/[^"]+)">.*?'
r'</div><h2>(?P<title>[^<]+)</h2>\s*<span class="year">(?P<year>\d{4})</span>',
re.DOTALL,
)
# Id produktu Adult Empire z adresu okładki, w obu formatach (to ta sama numeracja).
_AE_ID_RE = re.compile(r"adultempire\.com/(?:products/\d+/(\d+)h\.jpg|movie/(\d+))")
# Blok metadanych filmu. WSZYSTKO czytamy TYLKO stąd, patrz pułapka w docstringu.
_XMLL_RE = re.compile(r'<div class="xmll">.*?</div>', re.DOTALL)
@ -208,18 +213,21 @@ class YourDailyPornMoviesConnector(BaseMovieConnector):
desc = html_mod.unescape(om.group(1)).strip() or None
slug = urlparse(url).path.strip("/").split("/")[-1]
poster = html_mod.unescape(card.group("poster") or "").strip() or None
aem = _AE_ID_RE.search(poster or "")
ae_id = (aem.group(1) or aem.group(2)) if aem else None
return RawMovie(
external_id=slug,
title=title,
description=desc,
release_year=year,
url=url,
poster_url=card.group("poster"),
poster_url=poster,
studio=studio,
performers=performers,
tags=tags,
playback_sources=playback,
# id produktu Adult Empire, mocniejsza kotwica do dedupu niż tytuł
# (przy ocenie źródła złapało 9 filmów, których tytuł się nie zgadzał).
raw={"source": self.name, "url": url, "adultempire_id": card.group("ae")},
raw={"source": self.name, "url": url, "adultempire_id": ae_id},
)

View file

@ -29,35 +29,43 @@ def _resolve_by_altkey(session: Session, name: str) -> Tag | None:
scene_count wygrywa). Zapobiega regeneracji duplikatów: nowy wariant "Deep Throat"
trafia do istniejącego "deepthroat" zamiast tworzyć osobny tag. Używa indeksu
funkcyjnego ix_tags_name_altkey."""
key = _altkey(name)
if not key:
return None
expr = func.regexp_replace(func.lower(func.btrim(Tag.name)), "[^a-z0-9]", "", "g")
hit = (
session.execute(select(Tag).where(expr == key).order_by(Tag.scene_count.desc()))
.scalars()
.first()
)
if hit is not None:
return hit
return resolve_tags_by_altkey(session, [name]).get(name)
def resolve_tags_by_altkey(session: Session, names) -> dict[str, Tag]:
"""`_resolve_by_altkey` dla wielu nazw jednym zapytaniem: {nazwa: tag} dla trafień.
Po zapytaniu na nazwę enrich-tags robił N+1 przy kilku nowych nazwach ze strony
tuba (Sentry GOON-5F).
"""
# Liczba mnoga. Alnum-klucz z natury jej NIE łapie ("blowjob" != "blowjobs"), więc
# duplikaty odrastały mimo tamtej prewencji (audyt 2026-08-02: 1944 pary, m.in.
# Blowjob/Blowjobs, Teen/Teens, Cumshot/Cumshots). Próbujemy obu kierunków: nowe
# "Blowjobs" trafia w istniejące "Blowjob", a nowe "Blowjob" w istniejące "Blowjobs".
# Tylko proste `+s`, form nieregularnych (panty/panties) świadomie nie ruszamy, bo
# zbyt łatwo o fałszywy zlew (glass/glasses, bra/bras).
alts = [key + "s"] if not key.endswith("s") else [key[:-1]]
for alt in alts:
if len(alt) < 3:
# zbyt łatwo o fałszywy zlew (glass/glasses, bra/bras). Dokładny klucz ma pierwszeństwo.
keys: dict[str, tuple[str, str | None]] = {}
for name in names:
key = _altkey(name)
if not key:
continue
hit = (
session.execute(select(Tag).where(expr == alt).order_by(Tag.scene_count.desc()))
.scalars()
.first()
)
if hit is not None:
return hit
return None
alt = key + "s" if not key.endswith("s") else key[:-1]
keys[name] = (key, alt if len(alt) >= 3 else None)
wanted = {k for k, _ in keys.values()} | {a for _, a in keys.values() if a}
if not wanted:
return {}
expr = func.regexp_replace(func.lower(func.btrim(Tag.name)), "[^a-z0-9]", "", "g")
best: dict[str, Tag] = {}
for key, tag in session.execute(
select(expr, Tag).where(expr.in_(wanted)).order_by(Tag.scene_count.desc())
).all():
best.setdefault(key, tag)
out: dict[str, Tag] = {}
for name, (key, alt) in keys.items():
tag = best.get(key) or (best.get(alt) if alt else None)
if tag is not None:
out[name] = tag
return out
def _canonical_dup2_slug(session: Session, slug: str) -> str:

View file

@ -0,0 +1,38 @@
from app.connectors.yourdailypornmovies import _AE_ID_RE, _CARD_RE
_OLD = (
'<div class="movie"><div class="imagen"> <img src="https://imgs1cdn.adultempire.com/products/05/5128705h.jpg" '
'alt="Ramming Into Her" width="100%" height="100%" /> <a href="https://yourdailypornmovies.ws/ramming-into-her/">'
'<span class="player"></span></a></div><h2>Ramming Into Her</h2> <span class="year">2026</span></div>'
)
_NEW = (
'<div class="movie"><div class="imagen"> <img src="https://imgs4cdn.adultempire.com/movie/5136742?w=500&#038;h=700&#038;fit=cover" '
'alt="Big Butts Drive Me Nuts 8" width="100%" height="100%" /> <a href="https://yourdailypornmovies.ws/big-butts-drive-me-nuts-8/">'
'<span class="player"></span></a></div><h2>Big Butts Drive Me Nuts 8</h2> <span class="year">2026</span></div>'
)
_IBB = (
'<div class="movie"><div class="imagen"> <img src="https://i.ibb.co/DHDd9pm9/Private-POV.jpg" alt="Private POV" '
'width="100%" height="100%" /> <a href="https://yourdailypornmovies.ws/private-pov/"><span class="player"></span></a>'
'</div><h2>Private POV</h2> <span class="year">2026</span></div>'
)
_NO_IMG = (
'<div class="movie"><div class="imagen"> <a href="https://yourdailypornmovies.ws/no-cover/"><span class="player">'
'</span></a></div><h2>No Cover</h2> <span class="year">2025</span></div>'
)
def test_all_card_variants_parse():
cards = list(_CARD_RE.finditer(_OLD + _NEW + _IBB + _NO_IMG))
assert [c.group("title") for c in cards] == [
"Ramming Into Her", "Big Butts Drive Me Nuts 8", "Private POV", "No Cover"
]
assert cards[3].group("poster") is None
assert cards[3].group("url") == "https://yourdailypornmovies.ws/no-cover/"
def test_adultempire_id_from_both_poster_formats():
old = _AE_ID_RE.search("https://imgs1cdn.adultempire.com/products/05/5128705h.jpg")
new = _AE_ID_RE.search("https://imgs4cdn.adultempire.com/movie/5136742?w=500&h=700&fit=cover")
assert (old.group(1) or old.group(2)) == "5128705"
assert (new.group(1) or new.group(2)) == "5136742"
assert _AE_ID_RE.search("https://i.ibb.co/DHDd9pm9/Private-POV.jpg") is None