Compare commits
3 commits
45ee7a81e1
...
0da36d5c1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da36d5c1e | ||
|
|
cb9e4ab035 | ||
|
|
ed7c1cd3ed |
6 changed files with 113 additions and 39 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
38
tests/test_yourdailypornmovies_cards.py
Normal file
38
tests/test_yourdailypornmovies_cards.py
Normal 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&h=700&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
|
||||
Loading…
Add table
Reference in a new issue