Compare commits

...

10 commits

Author SHA1 Message Date
goon-foss
426b5e99a2 fix(player): lulust.com do resolvera JWPlayera na telefonie
Some checks failed
Backend tests / test (push) Has been cancelled
latestpornvideo osadza nowsze filmy na lulust.com, nowej domenie rodziny
lulu (luluvid, luluvdo, lulustream). Bez niej na liscie isPackerHoster
zwracal false, szlo przez WebView i konczylo sie 403 albo "Source not
loading". Domena dopisana plus regula na przedrostek lulu z sciezka /e/,
bo rodzina rotuje domeny. Emulator: przed zmiana "Source not loading",
po zmianie gra.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:16:13 +02:00
goon-foss
edf9e8a39f fix(proxy): okcdn odrzuca obcy Origin, ponowienie bez niego
_build_headers dokleja Origin zawsze, gdy znamy Referer. okcdn (OK.ru,
porndupe) na ten sam adres z Refererem daje 206, a z Refererem i Originem
400, wiec kazdy zapasowy proxy porndupe konczyl sie 502 (120 bledow 502
w tydzien). Przy 400 jedno ponowienie bez Origin; zwykly <video> w
przegladarce i tak go nie wysyla.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:04:14 +02:00
goon-foss
6c5d409fcc fix(porndoe): link do pliku pobierany na telefonie, nie przez WebView
Linki porndoe (mp4 z ip= i HLS z tokenem w sciezce) sa podpisane pod adres,
z ktorego przyszlo /service/index.json: token z IPv4 gra tylko przez IPv4,
z IPv6 tylko przez IPv6, z VPS 403. Token pobieral WebView (Chromium),
a plik ExoPlayer (OkHttp), wiec na sieciach dual-stack i za CGNAT wychodzily
innymi adresami: 164 bledy 403 na 278 udanych w tydzien.

Nowy resolver po stronie telefonu robi jedno zapytanie (film rozpoznawany po
Refererze) zwyklym fetch, ta sama droga co odtwarzacz; mp4 przed HLS, ponowne
rozwiazanie przy bledzie jak eporner/sxyprn. Pusto = dotychczasowy WebView.
Emulator: gra bez WebView.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:58:19 +02:00
goon-foss
3b127bff3d fix(player): vikingfile dostawal adres reklamy zamiast filmu
Wyjscie z wstrzykiwanego skryptu dla stron z Turnstile siedzialo w tym samym
try co oswajanie strony vikingfile. Skrypt startuje przed powstaniem <html>,
appendChild na null rzucal, a catch omijal return. Na vikingfile szedl caly
skrypt ekstrakcji i podsluch fetch bral zapytanie Adcash
(adexchangerapid.com/...&cbtitle=<plik>.mp4) za film: "None of the available
extractors" albo brak polaczenia z adexchangerapid przy blokadzie reklam,
polowa odtworzen galaxyporn.

Host liczony na poczatku, wyjscie poza try, styl dokladany po zaladowaniu
DOM, podsluch tylko na POST do wlasnej domeny, zapas z <video>.src.
Domeny Adcash na liscie reklam. Sprawdzone na emulatorze przez DevTools
WebView: stara paczka 3x blad, nowa gra (link z POST vikingfile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:43:28 +02:00
goon-foss
ba53f872d2 fix(proxy): hdporn.gg i fullmovies, swiezy adres zamiast 410 w zapasowym proxy
get_file jest przypiety do IP i sesji ladujacej strone i bywa jednorazowy.
Gdy resolver na telefonie zawiodl, proxy dostawalo ten sam zuzyty adres i
oddawalo 410/503: 14 urzadzen w tydzien mialo na tych serwisach same porazki
(inne serwisy graly im bez problemu). Teraz po 403/404/410 albo trwalym 503
proxy pobiera strone na nowo (try_extract) i bierze adres w tej samej jakosci.
Test: adres pobrany z innego IP przez proxy 410 -> z odswiezaniem 206 video/mp4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:16:14 +02:00
goon-foss
0f128d2e17 feat(playback): zgloszenie 404/410 z telefonu sprawdzane z serwera
Zrodlo znikalo tylko po dlugim przytrzymaniu przez uzytkownika. Teraz
zgloszenie "Response code: 404/410" uruchamia w tle ponowny resolve z serwera;
martwe dopiero, gdy ekstraktor mowi "usuniete" albo plik z serwera tez daje
404/410 (dwie kontrole). Na sucho na 84 parach z 14 dni: 0 martwych, 83 zywe,
czyli 410 z telefonu prawie zawsze znaczy zly adres, nie skasowany plik.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:16:14 +02:00
goon-foss
b0645f625a fix(movies): streamporn.li, nowy uklad mangoporn i yourdailypornmovies
Trzy zrodla filmow od tygodnia nie dawaly nic, a watchdog widzial tylko
"brak nowych":
- streamporn.vip -> 302 streamporn.li; konektor odrzuca linki spoza swojej
  domeny, wiec lista byla pusta. Id filmu to slug, domena nie robi duplikatow.
- mangoporn: /movies/ daje 404, lista z /page/N/; link ma class przed href,
  tytul w golym <h1> (zapas po itemprop i class="data"); limit 75 s, strona
  odpowiada z VPS po 30-60 s.
- yourdailypornmovies: nowe linie miedzy znacznikami karty, wzorzec bez \s*
  nie lapal nic przy dzialajacej stronie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:50:34 +02:00
goon-foss
251032a13b chore(edge): skrypty odtworzenia i oceny punktacji feedu
edge_replay_features.py liczy na prodzie cechy znane w chwili kazdego
wyswietlenia, edge_tuning_analysis.py ocenia formuly (AUC w obrebie
urzadzenia, podzial w czasie). Na nich dobrana punktacja z 4666fcb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:48:17 +02:00
goon-foss
6c2ebeecf5 chore(edge): raport porownujacy nowa punktacje feedu z grupa kontrolna
Grupy po hashu urzadzenia, tylko slot affinity, srednie po urzadzeniach.
Test A/A na tygodniu przed wdrozeniem: dokonczenia 13,0% vs 13,1%, lajki
3,4% vs 3,1%, ucieczki 49,1% vs 49,6% (357 i 111 urzadzen), czyli szum ~0,5 pp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:47:49 +02:00
goon-foss
4666fcb04a feat(edge): nowa punktacja feedu z historii klipu u nas, 25% w grupie kontrolnej
Odtworzenie uczenia na 189 tys. zdarzen (wagi zdarzenie po zdarzeniu, bez
zagladania w przyszlosc, test na 30% najnowszych): stara punktacja porzadkowala
klipy w obrebie urzadzenia z AUC 0,576 (lajk albo >= 10 s vs ucieczka < 2 s).
Tagi RedGifs sa liczne i ogolne, wiec ich suma dominowala, a mowila malo; nisze
prawie nic, popularnosc z RedGifs slabo, tworca i przyjecie klipu u nas najwiecej.

Nowa: log(waga tagu x rzadkosc tagu) + 0,25 log(nisze) + 2,5 log(tworca)
+ 0,8 ln(1+lajki RedGifs) + 120 (odsetek lajkow u nas - 3%) - 15 (odsetek
ucieczek - 50%) + 0,3 za swiezosc. Na tescie 0,652, regresja na tych samych
cechach 0,654. Liczniki clips.feed_views/skips/likes (migracja 0052, wypelnione
z historii), bumpowane w POST /clips/events. 25% urzadzen (hash id) zostaje na
starej punktacji do porownania na zywym ruchu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:47:02 +02:00
24 changed files with 959 additions and 45 deletions

View file

@ -0,0 +1,55 @@
"""Liczniki klipu z feedu Edge: wyswietlenia, ucieczki (< 2 s) i lajki w samym Goonie.
Odtworzenie uczenia feedu na 189 tys. zdarzen (2026-10-02) pokazalo, ze najmocniejszym
sygnalem tego, czy klip sie spodoba, jest to, jak przyjeli go inni u nas: odsetek lajkow
(AUC w obrebie urzadzenia 0,600) bije wagi tagow (0,571), a popularnosc z RedGifs prawie
nic nie mowi (0,532). Punktacja (app/clips_feed.py) potrzebuje tych liczb w chwili
ukladania strony, stad kolumny przy klipie zamiast agregatu po clip_feed_events.
ADD COLUMN ze stalym DEFAULT nie przepisuje tabeli (Postgres 11+). Wypelnienie z
dotychczasowych zdarzen jednym UPDATE (~70 tys. klipow).
Revision ID: 0052_clips_feed_counters
Revises: 0051_movies_tpdb_checked_at
Create Date: 2026-10-02
"""
from __future__ import annotations
from alembic import op
revision = "0052_clips_feed_counters"
down_revision: str | None = "0051_movies_tpdb_checked_at"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("""
ALTER TABLE clips
ADD COLUMN IF NOT EXISTS feed_views integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS feed_skips integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS feed_likes integer NOT NULL DEFAULT 0
""")
op.execute("""
UPDATE clips c SET feed_views = a.v, feed_skips = a.s, feed_likes = greatest(a.l, 0)
FROM (
SELECT clip_id,
count(*) FILTER (WHERE kind IN ('complete', 'skip', 'impression', 'autoswipe')) AS v,
count(*) FILTER (WHERE kind = 'skip') AS s,
count(*) FILTER (WHERE kind = 'like') - count(*) FILTER (WHERE kind = 'unlike') AS l
FROM clip_feed_events WHERE clip_id IS NOT NULL
GROUP BY clip_id
) a
WHERE a.clip_id = c.id
""")
def downgrade() -> None:
op.execute("SET LOCAL lock_timeout = '5s'")
op.execute("""
ALTER TABLE clips
DROP COLUMN IF EXISTS feed_views,
DROP COLUMN IF EXISTS feed_skips,
DROP COLUMN IF EXISTS feed_likes
""")

View file

@ -17,6 +17,7 @@ bez image proxy: RedGifs nie sprawdza Referera, a proxy tylko dodałoby VPS do t
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import threading import threading
import time import time
import uuid import uuid
@ -44,6 +45,8 @@ _AFFINITY_TAGS, _AFFINITY_NICHES, _AFFINITY_CREATORS = 6, 3, 3
_PER_TAG, _PER_NICHE, _PER_CREATOR = 60, 60, 40 _PER_TAG, _PER_NICHE, _PER_CREATOR = 60, 60, 40
_UUID_ZERO = uuid.UUID(int=0) _UUID_ZERO = uuid.UUID(int=0)
_BLACKLIST_KINDS = frozenset({"tag", "niche", "creator"}) _BLACKLIST_KINDS = frozenset({"tag", "niche", "creator"})
#: Zdarzenia oznaczajace, ze klip zostal obejrzany (zejscie ze strony), do clips.feed_views.
_VIEW_KINDS = frozenset({"complete", "skip", "impression", "autoswipe"})
# ---- schematy --------------------------------------------------------------------------- # ---- schematy ---------------------------------------------------------------------------
@ -251,7 +254,10 @@ def _visible_clauses(device_id: str, bl: dict[str, set[str]]) -> list:
return clauses return clauses
_CAND_COLS = (Clip.id, Clip.creator, Clip.likes, Clip.published_at, Clip.niches) _CAND_COLS = (
Clip.id, Clip.creator, Clip.likes, Clip.published_at, Clip.niches,
Clip.feed_views, Clip.feed_skips, Clip.feed_likes,
)
def _rows_to_candidates(session: Session, rows, *, with_tags: bool) -> list[cf.Candidate]: def _rows_to_candidates(session: Session, rows, *, with_tags: bool) -> list[cf.Candidate]:
@ -259,7 +265,10 @@ def _rows_to_candidates(session: Session, rows, *, with_tags: bool) -> list[cf.C
wyłącznie lajkami, więc ciągnięcie tagów dla 300 kandydatów było czystym kosztem.""" wyłącznie lajkami, więc ciągnięcie tagów dla 300 kandydatów było czystym kosztem."""
tags = _tags_for(session, [r[0] for r in rows]) if with_tags else {} tags = _tags_for(session, [r[0] for r in rows]) if with_tags else {}
return [ return [
cf.Candidate(id=r[0], creator=r[1], likes=r[2], published_at=r[3], tags=tags.get(r[0], []), niches=list(r[4] or [])) cf.Candidate(
id=r[0], creator=r[1], likes=r[2], published_at=r[3], tags=tags.get(r[0], []), niches=list(r[4] or []),
feed_views=r[5] or 0, feed_skips=r[6] or 0, feed_likes=r[7] or 0,
)
for r in rows for r in rows
] ]
@ -294,6 +303,29 @@ def _explore_candidates(
return _rows_to_candidates(session, rows, with_tags=False), (end_id if not last else _UUID_ZERO), last return _rows_to_candidates(session, rows, with_tags=False), (end_id if not last else _UUID_ZERO), last
# Rzadkosc tagow (IDF) do punktacji: ln(liczba klipow / klipy z tagiem). Liczenie to jedno
# GROUP BY po ~1,7 mln clip_tags (~1 s), slownik zmienia sie wolno, wiec cache 6 h w procesie.
_IDF_TTL_SEC = 6 * 3600
_idf_cache: tuple[float, dict[str, float]] | None = None
_idf_lock = threading.Lock()
def _tag_idf(session: Session) -> dict[str, float]:
global _idf_cache
hit = _idf_cache
if hit is not None and time.monotonic() - hit[0] < _IDF_TTL_SEC:
return hit[1]
with _idf_lock:
hit = _idf_cache
if hit is not None and time.monotonic() - hit[0] < _IDF_TTL_SEC:
return hit[1]
n_all = session.execute(select(func.count()).select_from(Clip)).scalar_one()
rows = session.execute(text("SELECT tag, count(*) FROM clip_tags GROUP BY tag")).all()
idf = {t: math.log((n_all + 1) / (n + 1)) for t, n in rows}
_idf_cache = (time.monotonic(), idf)
return idf
def _affinity_candidate_ids(session: Session, aff: cf.Affinity, rng) -> set[uuid.UUID]: def _affinity_candidate_ids(session: Session, aff: cf.Affinity, rng) -> set[uuid.UUID]:
ids: set[uuid.UUID] = set() ids: set[uuid.UUID] = set()
for tag in aff.top("tags", _AFFINITY_TAGS): for tag in aff.top("tags", _AFFINITY_TAGS):
@ -352,7 +384,13 @@ def clip_feed(
picked_aff: list[cf.Candidate] = [] picked_aff: list[cf.Candidate] = []
if n_aff: if n_aff:
cand_ids = _affinity_candidate_ids(session, aff, rng) cand_ids = _affinity_candidate_ids(session, aff, rng)
picked_aff = cf.pick_affinity(_candidates(session, list(cand_ids), clauses), aff, n_aff) picked_aff = cf.pick_affinity(
_candidates(session, list(cand_ids), clauses),
aff,
n_aff,
idf=_tag_idf(session),
v1=cf.uses_v1_scoring(device_id),
)
taken = {c.id for c in picked_aff} taken = {c.id for c in picked_aff}
# Explore: okno po PK; gdy w oknie brak widocznych, przesuwamy sie dalej (max 3 okna). # Explore: okno po PK; gdy w oknie brak widocznych, przesuwamy sie dalej (max 3 okna).
@ -432,6 +470,27 @@ def clip_events(
} }
row, aff = _load_affinity(session, device_id) row, aff = _load_affinity(session, device_id)
changed = 0 changed = 0
# Liczniki przyjecia klipu u nas (clips.feed_*), z nich korzysta punktacja feedu.
counters: dict[uuid.UUID, list[int]] = {}
for e in events:
d = counters.setdefault(e.clip_id, [0, 0, 0])
if e.kind in _VIEW_KINDS:
d[0] += 1
if e.kind == "skip":
d[1] += 1
if e.kind == "like":
d[2] += 1
elif e.kind == "unlike":
d[2] -= 1
for cid, (dv, ds, dl) in counters.items():
if dv or ds or dl:
session.execute(
text(
"UPDATE clips SET feed_views = feed_views + :v, feed_skips = feed_skips + :s, "
"feed_likes = greatest(feed_likes + :l, 0) WHERE id = :id"
),
{"v": dv, "s": ds, "l": dl, "id": cid},
)
for e in events: for e in events:
session.add( session.add(
ClipFeedEvent( ClipFeedEvent(

View file

@ -17,6 +17,7 @@ from app.api.device import get_device_id
from app.auth import require_api_key from app.auth import require_api_key
from app.db import get_session from app.db import get_session
from app.models.playback_event import PlaybackEvent from app.models.playback_event import PlaybackEvent
from app.playback_gone import is_gone_report, verify_gone_async
router = APIRouter(prefix="/playback-events", tags=["telemetry"], dependencies=[Depends(require_api_key)]) router = APIRouter(prefix="/playback-events", tags=["telemetry"], dependencies=[Depends(require_api_key)])
@ -51,3 +52,7 @@ def post_playback_event(
) )
) )
session.commit() session.commit()
# Plik zniknal (404/410 w odtwarzaczu): serwer sprawdza zrodlo sam i oznacza martwe
# dopiero po wlasnym potwierdzeniu (app/playback_gone.py).
if is_gone_report(body.status, body.error_kind, body.error_detail):
verify_gone_async(body.scene_id, body.origin)

View file

@ -585,6 +585,40 @@ async def sxyprn_thumb(post_id: str) -> Response:
) )
#: Po tych statusach proxy pobiera strone tube na nowo (gdy token ma rf + rh "tube:...").
#: 503 dopiero po nieudanym ponowieniu: nowy token bywa kierowany na inny wezel CDN.
_TUBE_REFRESH_STATUSES = frozenset({403, 404, 410, 503})
_QUALITY_IN_URL_RE = re.compile(r"_(\d{3,4})[a-z]?\.mp4", re.IGNORECASE)
async def _fresh_tube_link(sitetag: str, page_url: str, old_target: str) -> str | None:
"""Swiezy adres pliku z ekstraktora tube, w tej samej jakosci co `old_target`.
hdporn.gg / fullmovies.xxx: get_file jest przypiety do IP ladujacego strone i bywa
jednorazowy. Telefon, ktoremu nie wyszedl wlasny resolver, dostawal od proxy ten sam
zuzyty adres i konczyl na 410/503 (14 urzadzen w tydzien z samymi porazkami, 2026-10-02).
"""
import anyio
from app.extractors import try_extract
try:
sources = await anyio.to_thread.run_sync(lambda: try_extract(sitetag, page_url))
except Exception as e: # noqa: BLE001 (HosterDead/TubePageError: zostaje stary blad)
log.info("proxy: odswiezenie %s nie wyszlo: %s", page_url, e)
return None
links = [s.link for s in (sources or []) if s.link]
if not links:
return None
want = _QUALITY_IN_URL_RE.search(old_target)
if want:
for link in links:
m = _QUALITY_IN_URL_RE.search(link)
if m and m.group(1) == want.group(1):
return link
return links[0]
async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None: async def _refetch_mixdrop_url(session: "AsyncSession", embed_url: str) -> str | None:
"""Re-fetch mixdrop embed, decode P.A.C.K.E.R., extract fresh MDCore.wurl. """Re-fetch mixdrop embed, decode P.A.C.K.E.R., extract fresh MDCore.wurl.
Cookies persist w session, użytkowane potem do mp4 GET (same-session bind). Cookies persist w session, użytkowane potem do mp4 GET (same-session bind).
@ -870,6 +904,36 @@ async def proxy_stream(
stream=True, stream=True,
follow_redirects=True, follow_redirects=True,
) )
# okcdn (OK.ru, porndupe) odrzuca obcy naglowek Origin kodem 400, a _build_headers
# dokleja go zawsze, gdy znamy Referer. Przegladarka przy zwyklym <video> Origin
# nie wysyla. Bez tego kazdy zapasowy proxy porndupe konczyl sie 502 (2026-10-02:
# ten sam adres z Refererem 206, z Refererem i Originem 400).
if upstream.status_code == 400 and "Origin" in headers and method.upper() in ("GET", "HEAD"):
await upstream.aclose()
headers.pop("Origin", None)
log.info("proxy: upstream 400 z Origin, ponawiam bez niego %s", target[:70])
upstream = await client.send(
client.build_request(method, target, headers=headers),
stream=True,
follow_redirects=True,
)
if (
upstream.status_code in _TUBE_REFRESH_STATUSES
and refetch_url
and (refetch_hoster or "").startswith("tube:")
and method.upper() in ("GET", "HEAD")
):
fresh = await _fresh_tube_link(refetch_hoster[5:], refetch_url, target)
if fresh:
log.info("proxy: upstream %d, swiezy adres z %s", upstream.status_code, refetch_url[:70])
await upstream.aclose()
target = fresh
cached_target = None # nowy cel: po sukcesie cache dostanie jego koncowy adres
upstream = await client.send(
client.build_request(method, target, headers=headers),
stream=True,
follow_redirects=True,
)
if upstream.status_code >= 400: if upstream.status_code >= 400:
status = upstream.status_code status = upstream.status_code
ups_headers = dict(upstream.headers) ups_headers = dict(upstream.headers)

View file

@ -14,8 +14,10 @@ zapomina stare gusta, cap trzyma słowniki małe (60/40/40). Wszystko celowo pro
przejrzyste: da się wytłumaczyć użytkownikowi, dlaczego coś widzi, i skasować jednym przejrzyste: da się wytłumaczyć użytkownikowi, dlaczego coś widzi, i skasować jednym
wywołaniem. wywołaniem.
**Punktacja kandydata** = Σ wag tagów + 2·waga niszy + 3·waga twórcy + 0,3·ln(1+lajki) **Punktacja kandydata** (od 2026-10-02, uzasadnienie przy `score_candidate`) = log(Σ waga
+ 0,5 gdy klip z ostatnich 7 dni. Strona: 80 % z afiniczności (max 2 klipy jednego twórcy), tagu × rzadkość tagu) + 0,25·log(nisze) + 2,5·log(twórca) + 0,8·ln(1+lajki RedGifs)
+ 120·(odsetek lajków u nas − 3 %) − 15·(odsetek ucieczek u nas − 50 %) + 0,3 za świeżość.
25 % urządzeń zostaje na starej punktacji jako grupa kontrolna. Strona: 80 % z afiniczności (max 2 klipy jednego twórcy),
20 % explore, przemieszane deterministycznie z seeda. 20 % explore, przemieszane deterministycznie z seeda.
""" """
from __future__ import annotations from __future__ import annotations
@ -186,9 +188,60 @@ class Candidate:
published_at: datetime | None published_at: datetime | None
tags: list[str] tags: list[str]
niches: list[str] niches: list[str]
#: Przyjecie klipu u nas (kolumny clips.feed_*, migracja 0052).
feed_views: int = 0
feed_skips: int = 0
feed_likes: int = 0
def score_candidate(c: Candidate, aff: Affinity, now: datetime | None = None) -> float: # Punktacja od 2026-10-02, dobrana na odtworzeniu 189 tys. zdarzen (wagi urzadzenia
# liczone zdarzenie po zdarzeniu, bez zagladania w przyszlosc; trening na 70 % najstarszych,
# test na reszcie). Miara: czy wyzszy wynik u TEGO urzadzenia znaczy lajk albo >= 10 s
# ogladania, a nie ucieczke < 2 s (AUC w obrebie urzadzenia). Stara punktacja: 0,576,
# ta: 0,652, regresja logistyczna na tych samych cechach: 0,654. Wnioski:
# * suma wag tagow niewiele mowi, bo tagi RedGifs sa liczne i ogolne, a lajk dokladal
# +5 kazdemu z ~10 tagow klipu; waga razy rzadkosc tagu (IDF) i log daje wiecej,
# * nisze prawie nic (0,54), stad 0,25 zamiast 2,
# * tworca to najlepszy sygnal z wag (0,58),
# * najmocniejszy jest odsetek lajkow klipu u nas (0,60), wygladzony, zeby klip bez
# historii nie dostawal skrajnych ocen; popularnosc z RedGifs slaba, ale nie zerowa.
W_NICHE = 0.25
W_CREATOR = 2.5
W_POP = 0.8
W_FEED_LIKE = 120.0
W_FEED_SKIP = 15.0
W_FRESH = 0.3
#: Wygladzanie odsetkow: klip zaczyna od 10 „wirtualnych" wyswietlen ze srednimi wartosciami.
PRIOR_VIEWS = 10.0
PRIOR_LIKE_RATE = 0.03
PRIOR_SKIP_RATE = 0.5
def _slog(x: float) -> float:
return math.copysign(math.log1p(abs(x)), x)
def score_candidate(
c: Candidate, aff: Affinity, now: datetime | None = None, idf: dict[str, float] | None = None
) -> float:
"""`idf`: tag -> ln(N/df) po calym katalogu klipow; bez niego kazdy tag waży 1."""
now = now or datetime.now(UTC)
default_idf = max(idf.values()) if idf else 1.0
tag = sum(aff.tags.get(t, 0.0) * (idf.get(t, default_idf) if idf else 1.0) for t in c.tags)
s = _slog(tag)
s += W_NICHE * _slog(sum(aff.niches.get(n, 0.0) for n in c.niches))
s += W_CREATOR * _slog(aff.creators.get(c.creator or "", 0.0))
s += W_POP * math.log1p(max(c.likes, 0))
like_rate = (c.feed_likes + PRIOR_VIEWS * PRIOR_LIKE_RATE) / (c.feed_views + PRIOR_VIEWS)
skip_rate = (c.feed_skips + PRIOR_VIEWS * PRIOR_SKIP_RATE) / (c.feed_views + PRIOR_VIEWS)
s += W_FEED_LIKE * (like_rate - PRIOR_LIKE_RATE) - W_FEED_SKIP * (skip_rate - PRIOR_SKIP_RATE)
if c.published_at and c.published_at >= now - timedelta(days=FRESH_DAYS):
s += W_FRESH
return s
def score_candidate_v1(c: Candidate, aff: Affinity, now: datetime | None = None) -> float:
"""Punktacja sprzed 2026-10-02, zostaje dla grupy kontrolnej (patrz `uses_v1_scoring`)."""
now = now or datetime.now(UTC) now = now or datetime.now(UTC)
s = sum(aff.tags.get(t, 0.0) for t in c.tags) s = sum(aff.tags.get(t, 0.0) for t in c.tags)
s += 2.0 * sum(aff.niches.get(n, 0.0) for n in c.niches) s += 2.0 * sum(aff.niches.get(n, 0.0) for n in c.niches)
@ -199,9 +252,30 @@ def score_candidate(c: Candidate, aff: Affinity, now: datetime | None = None) ->
return s return s
def pick_affinity(cands: list[Candidate], aff: Affinity, n: int, *, max_per_creator: int = MAX_PER_CREATOR) -> list[Candidate]: #: Odsetek urzadzen na starej punktacji (grupa kontrolna). Przydzial staly po hashu id
#: urzadzenia, wiec do porownania wystarczy ten sam hash przy analizie zdarzen.
V1_CONTROL_PERCENT = 25
def uses_v1_scoring(device_id: str) -> bool:
h = int(hashlib.sha256(device_id.encode("utf-8")).hexdigest()[:8], 16)
return h % 100 < V1_CONTROL_PERCENT
def pick_affinity(
cands: list[Candidate],
aff: Affinity,
n: int,
*,
max_per_creator: int = MAX_PER_CREATOR,
idf: dict[str, float] | None = None,
v1: bool = False,
) -> list[Candidate]:
"""Najlepsze `n` po punktach, max `max_per_creator` klipów jednego twórcy na stronę.""" """Najlepsze `n` po punktach, max `max_per_creator` klipów jednego twórcy na stronę."""
ranked = sorted(cands, key=lambda c: -score_candidate(c, aff)) if v1:
ranked = sorted(cands, key=lambda c: -score_candidate_v1(c, aff))
else:
ranked = sorted(cands, key=lambda c: -score_candidate(c, aff, idf=idf))
out: list[Candidate] = [] out: list[Candidate] = []
per: dict[str, int] = {} per: dict[str, int] = {}
for c in ranked: for c in ranked:

View file

@ -56,10 +56,13 @@ USER_AGENT = (
# 2. streamporn/pandamovies: <a class="ml-mask jt" href="<base>/<slug>/"> # 2. streamporn/pandamovies: <a class="ml-mask jt" href="<base>/<slug>/">
# (slug bez /movies/ prefix, np. /watch-xxx-...-adult-movie-online-free/) # (slug bez /movies/ prefix, np. /watch-xxx-...-adult-movie-online-free/)
# Łapiemy oba przez alternatywę. # Łapiemy oba przez alternatywę.
# 2026-10-02 mangoporn przeszedl na `<a class="thumb" href=...>` (klasa PRZED href) i
# wzorzec z href na poczatku nie lapal juz nic: 0 filmow od 2026-09-23. Teraz href moze
# stac w dowolnym miejscu znacznika; linki menu do /movies/category/ odpadaja.
_LIST_ITEM_RE = re.compile( _LIST_ITEM_RE = re.compile(
r'<a\s+href="(?P<url>https?://[^"]+)"[^>]*\bclass="ml-mask\b[^"]*"' r'<a\s+href="(?P<url>https?://[^"]+)"[^>]*\bclass="ml-mask\b[^"]*"'
r"|" r"|"
r'<a\s+href="(?P<url2>https?://[^"]+/movies/[a-z0-9-]+/)"', r'<a\s+(?:[^>]*?\s)?href="(?P<url2>https?://[^"]+/movies/(?!category/)[a-z0-9-]+/)"',
re.IGNORECASE, re.IGNORECASE,
) )
# Tolerantny title, mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies # Tolerantny title, mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies
@ -69,6 +72,9 @@ _TITLE_RE = re.compile(
r'|class="data"[^>]*>\s*<h[1-6][^>]*>([^<]+)</h[1-6]>', r'|class="data"[^>]*>\s*<h[1-6][^>]*>([^<]+)</h[1-6]>',
re.IGNORECASE | re.DOTALL, re.IGNORECASE | re.DOTALL,
) )
# Zapas: mangoporn od 2026-10 ma tytul w golym <h1>, bez itemprop i bez class="data".
# Tylko gdy dwa wzorce wyzej nic nie znajda, zeby inne motywy nie braly logo z <h1>.
_H1_TITLE_RE = re.compile(r"<h1[^>]*>\s*([^<]{2,300}?)\s*</h1>", re.IGNORECASE)
# dooplay uses /year/, raw PsyPlay uses /release-year/. Same dla pozostałych slugów, # dooplay uses /year/, raw PsyPlay uses /release-year/. Same dla pozostałych slugów,
# różne thema dziedziczą podstawowy markup ale customizują URL słowniki. # różne thema dziedziczą podstawowy markup ale customizują URL słowniki.
_YEAR_RE = re.compile( _YEAR_RE = re.compile(
@ -272,11 +278,11 @@ class DooplayConnector(BaseMovieConnector):
def _parse_dooplay_detail( def _parse_dooplay_detail(
*, slug: str, html: str, source_name: str, base_url: str, page_url: str | None = None *, slug: str, html: str, source_name: str, base_url: str, page_url: str | None = None
) -> RawMovie | None: ) -> RawMovie | None:
m_title = _TITLE_RE.search(html) m_title = _TITLE_RE.search(html) or _H1_TITLE_RE.search(html)
if not m_title: if not m_title:
log.warning("%s: no title in %s", source_name, slug) log.warning("%s: no title in %s", source_name, slug)
return None return None
title = _decode_html((m_title.group(1) or m_title.group(2)).strip()) title = _decode_html(next(g for g in m_title.groups() if g).strip())
m_year = _YEAR_RE.search(html) m_year = _YEAR_RE.search(html)
release_year = int(m_year.group(1)) if m_year else None release_year = int(m_year.group(1)) if m_year else None
@ -452,7 +458,10 @@ class StreampornVipConnector(DooplayConnector):
# samym PsyPlay theme i świeższym katalogiem (ocena 2026-07-02). Osobny source_name # samym PsyPlay theme i świeższym katalogiem (ocena 2026-07-02). Osobny source_name
# → nowe origins `streampornvip:<host>`, nie miesza z martwym .nl. # → nowe origins `streampornvip:<host>`, nie miesza z martwym .nl.
name = "streampornvip" name = "streampornvip"
base_url = "https://streamporn.vip" # 2026-10-02 streamporn.vip -> 302 streamporn.li. Konektor odrzuca linki spoza swojej
# domeny, wiec po przekierowaniu lista byla pusta (0 filmow od 2026-09-23). Id filmu to
# slug, bez domeny, wiec przeprowadzka nie robi duplikatow.
base_url = "https://streamporn.li"
def _listing_path(self, page: int) -> str: def _listing_path(self, page: int) -> str:
"""Strona główna, NIE `/movies/`. Diagnoza 2026-08-03: źródło stało od """Strona główna, NIE `/movies/`. Diagnoza 2026-08-03: źródło stało od
@ -480,6 +489,15 @@ class MangopornConnector(DooplayConnector):
name = "mangoporn" name = "mangoporn"
base_url = "https://mangoporn.net" base_url = "https://mangoporn.net"
def __init__(self, *, timeout: float = 75.0):
# Strona odpowiada z VPS po 30-60 s (2026-10-02), przy 30 s ingest konczyl sie
# na timeoutcie listy.
super().__init__(timeout=timeout)
def _listing_path(self, page: int) -> str:
"""Od 2026-10 `/movies/` daje 404, najnowsze sa na stronie glownej (`/page/N/`)."""
return "/" if page == 1 else f"/page/{page}/"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers (zduplikowane z paradisehill.py, celowo, żeby connectory były niezależne) # Helpers (zduplikowane z paradisehill.py, celowo, żeby connectory były niezależne)

View file

@ -55,11 +55,13 @@ _BASE = "https://yourdailypornmovies.ws"
# formacie: od ~2026-09-15 nowe filmy mają `imgs4cdn.adultempire.com/movie/<id>?w=...` # 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 # 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ń). # starego formatu po cichu gubił 33 z 40 filmów strony 1 (ingest: 0 nowych przez tydzień).
# 2026-10 strona wstawila nowe linie miedzy znacznikami karty i wzorzec bez \s* nie
# lapal juz nic (0 filmow od 2026-09-29, przy dzialajacej stronie).
_CARD_RE = re.compile( _CARD_RE = re.compile(
r'<div class="movie"><div class="imagen">\s*' r'<div class="movie">\s*<div class="imagen">\s*'
r'(?:<img src="(?P<poster>[^"]*)"[^>]*>\s*)?' r'(?:<img src="(?P<poster>[^"]*)"[^>]*>\s*)?'
r'<a href="(?P<url>https://yourdailypornmovies\.ws/[^"]+)">.*?' r'<a href="(?P<url>https://yourdailypornmovies\.ws/[^"]+)">.*?'
r'</div><h2>(?P<title>[^<]+)</h2>\s*<span class="year">(?P<year>\d{4})</span>', r'</div>\s*<h2>(?P<title>[^<]+)</h2>\s*<span class="year">(?P<year>\d{4})</span>',
re.DOTALL, re.DOTALL,
) )
# Id produktu Adult Empire z adresu okładki, w obu formatach (to ta sama numeracja). # Id produktu Adult Empire z adresu okładki, w obu formatach (to ta sama numeracja).

View file

@ -31,7 +31,9 @@ _SOURCE_RE = re.compile(
_SKIP_QUALITY_RE = re.compile(r"2160|1440|4k", re.IGNORECASE) _SKIP_QUALITY_RE = re.compile(r"2160|1440|4k", re.IGNORECASE)
def resolve(page_url: str, base_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: def resolve(
page_url: str, base_url: str, *, timeout: float = 30.0, sitetag: str | None = None
) -> list[StreamSource] | None:
if not _HAS_CURL_CFFI: if not _HAS_CURL_CFFI:
log.info("source_getfile: curl_cffi unavailable — %s", page_url) log.info("source_getfile: curl_cffi unavailable — %s", page_url)
return None return None
@ -59,9 +61,16 @@ def resolve(page_url: str, base_url: str, *, timeout: float = 30.0) -> list[Stre
if _SKIP_QUALITY_RE.search(quality): if _SKIP_QUALITY_RE.search(quality):
log.info("source_getfile: skip broken-CDN quality %r on %s", quality, page_url) log.info("source_getfile: skip broken-CDN quality %r on %s", quality, page_url)
continue continue
raw: dict = {"mobile_direct_ok": True}
if sitetag:
# get_file jest przypiety do IP tego, kto zaladowal strone, i bywa jednorazowy,
# wiec proxy (zapas, gdy resolver na telefonie zawiedzie) po 403/410 pobiera
# strone na nowo zamiast oddac blad (stream_proxy._fresh_tube_link, 2026-10-02).
raw["refetch_url"] = page_url
raw["refetch_hoster"] = f"tube:{sitetag}"
out.append(StreamSource( out.append(StreamSource(
link=url, type="mp4", quality=quality or None, link=url, type="mp4", quality=quality or None,
referer=base_url + "/", raw={"mobile_direct_ok": True}, referer=base_url + "/", raw=raw,
)) ))
if not out: if not out:

View file

@ -16,4 +16,4 @@ _BASE = "https://www.fullmovies.xxx"
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
return _source_getfile.resolve(page_url, _BASE, timeout=timeout) return _source_getfile.resolve(page_url, _BASE, timeout=timeout, sitetag="fullmoviesxxx")

View file

@ -14,4 +14,4 @@ _BASE = "https://www.hdporn.gg"
def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None: def extract(page_url: str, *, timeout: float = 30.0) -> list[StreamSource] | None:
return _source_getfile.resolve(page_url, _BASE, timeout=timeout) return _source_getfile.resolve(page_url, _BASE, timeout=timeout, sitetag="hdporngg")

View file

@ -68,6 +68,11 @@ class Clip(UUIDPKMixin, TimestampMixin, Base):
hls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) hls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
views: Mapped[int] = mapped_column(Integer, nullable=False, default=0) views: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
#: Przyjecie klipu w samym Goonie (feed Edge): wyswietlenia, ucieczki < 2 s, lajki.
#: Licznik bumpuje POST /clips/events, punktacja feedu z tego korzysta (migracja 0052).
feed_views: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
feed_skips: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
feed_likes: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
#: Data publikacji u źródła (RedGifs `createDate`), do odznaki „nowe" i sortu. #: Data publikacji u źródła (RedGifs `createDate`), do odznaki „nowe" i sortu.
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
dead_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) dead_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

136
app/playback_gone.py Normal file
View file

@ -0,0 +1,136 @@
"""Zgloszenie „plik zniknal" z telefonu -> sprawdzenie z serwera -> zrodlo martwe.
Skad: telemetria 7 dni (2026-10-02) miala ~230 bledow „Response code: 410" z fullmovies.xxx
i hdporn.gg, a zrodlo znikalo tylko po dlugim przytrzymaniu przez uzytkownika. Kolejne osoby
trafialy w ten sam skasowany plik.
Dwie kontrole, jak przy kazdym przemiale zrodel (patrz notatka o reaperach): telefon dostal
404/410 na pliku, a serwer rozwiazuje strone tube na nowo i sam dostaje 404/410 na pliku albo
ekstraktor mowi wprost, ze film usuniety (HosterDead, TubePageError 404/410). 503, bledy sieci,
pusty ekstraktor i hostery rozwiazywane na telefonie niczego nie oznaczaja.
"""
from __future__ import annotations
import logging
import re
import threading
import time
import uuid
from datetime import UTC, datetime
import httpx
from sqlalchemy import select
from app.db import session_scope
from app.models.playback_source import PlaybackSource
log = logging.getLogger(__name__)
#: Komunikat ExoPlayera o pliku, ktorego nie ma (expo-video: "... Source error Response code: 410").
_GONE_RE = re.compile(r"response code: (404|410)\b", re.IGNORECASE)
_UA = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36"
#: Ta sama para (scena, origin) sprawdzana najwyzej raz na tyle sekund (wiele telefonow naraz).
_RECHECK_SEC = 3600
_checked: dict[tuple, float] = {}
_lock = threading.Lock()
def is_gone_report(status: str, error_kind: str | None, error_detail: str | None) -> bool:
return status == "error" and bool(_GONE_RE.search(error_detail or ""))
def _file_status(url: str, referer: str | None) -> int | None:
headers = {"User-Agent": _UA, "Range": "bytes=0-1023"}
if referer:
headers["Referer"] = referer
try:
with httpx.Client(timeout=20, follow_redirects=True) as http:
return http.get(url, headers=headers).status_code
except httpx.HTTPError:
return None
def _mark_dead(pb_id: uuid.UUID, reason: str) -> None:
with session_scope() as s:
pb = s.get(PlaybackSource, pb_id)
if pb is not None and pb.dead_at is None:
pb.dead_at = datetime.now(UTC)
pb.dead_reason = reason[:512]
s.commit()
log.info("gone: playback %s (%s) martwy: %s", pb_id, pb.origin, reason)
def verify_gone(scene_id: uuid.UUID, origin: str, *, dry_run: bool = False) -> dict[str, int]:
"""Sprawdza zywe zrodla (scena, origin) zgloszone jako 404/410. Zwraca liczniki.
`dry_run`: tylko liczy, nic nie oznacza (do sprawdzenia na historii przed backfillem)."""
mark = (lambda *_a: None) if dry_run else _mark_dead
from app.extractors import HosterDead, TubePageError, is_vps_blocked_fallback, try_extract
out = {"checked": 0, "dead": 0, "alive": 0, "unknown": 0}
if not origin.startswith("tube:"):
return out
sitetag = origin.split(":", 1)[1]
if is_vps_blocked_fallback(sitetag):
return out # rozwiazywane tylko na telefonie, serwer nie ma jak sprawdzic
with session_scope() as s:
rows = s.execute(
select(PlaybackSource.id, PlaybackSource.page_url).where(
PlaybackSource.scene_id == scene_id,
PlaybackSource.origin == origin,
PlaybackSource.dead_at.is_(None),
)
).all()
for pb_id, page_url in rows:
out["checked"] += 1
try:
sources = try_extract(sitetag, page_url)
except HosterDead as e:
mark(pb_id, f"{e} (zgloszenie 410 z telefonu)")
out["dead"] += 1
continue
except TubePageError as e:
if e.status_code in (404, 410):
mark(pb_id, f"tube page {e.status_code} (zgloszenie 410 z telefonu)")
out["dead"] += 1
else:
out["unknown"] += 1
continue
except Exception: # noqa: BLE001
out["unknown"] += 1
continue
direct = [x for x in (sources or []) if (x.type or "mp4") in ("mp4", "m3u8")]
if not direct:
out["unknown"] += 1
continue
codes = [_file_status(x.link, x.referer or page_url) for x in direct[:3]]
if codes and all(c in (404, 410) for c in codes):
mark(pb_id, f"plik {codes[0]} z telefonu i z serwera")
out["dead"] += 1
elif any(c in (200, 206) for c in codes):
out["alive"] += 1
else:
out["unknown"] += 1
return out
def verify_gone_async(scene_id: uuid.UUID | None, origin: str) -> None:
"""Fire-and-forget z endpointu telemetrii; ta sama para najwyzej raz na godzine."""
if scene_id is None:
return
key = (scene_id, origin)
now = time.monotonic()
with _lock:
if now - _checked.get(key, -1e9) < _RECHECK_SEC:
return
_checked[key] = now
if len(_checked) > 20000:
for k in [k for k, t in _checked.items() if now - t > _RECHECK_SEC]:
_checked.pop(k, None)
def run() -> None:
try:
verify_gone(scene_id, origin)
except Exception: # noqa: BLE001
log.exception("gone: sprawdzenie %s %s nie wyszlo", scene_id, origin)
threading.Thread(target=run, name="gone-check", daemon=True).start()

View file

@ -35,6 +35,27 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-10-02d',
date: 'October 2026',
items: [
'Latestpornvideo: newer videos sit on a new lulu domain the app did not know, so they opened the host page and failed. They now play in the app player like the older ones.',
],
},
{
id: '2026-10-02c',
date: 'October 2026',
items: [
'Porndoe: many plays ended with "Response code: 403". The video link is tied to the address that asked for it, and the page and the player could reach the site through different addresses. The app now asks for the link itself, the same way the player connects, and skips the web page.',
],
},
{
id: '2026-10-02b',
date: 'October 2026',
items: [
'Galaxyporn: about half of the plays ended with "Playback failed". The app grabbed the address of an ad on the vikingfile page and tried to play it as the video. It now waits for the real file.',
],
},
{ {
id: '2026-10-02a', id: '2026-10-02a',
date: 'October 2026', date: 'October 2026',

View file

@ -18,6 +18,10 @@
const PACKER_HOSTS = new Set([ const PACKER_HOSTS = new Set([
'luluvid.com', 'luluvdo.com', 'lulustream.com', 'lulu.st', 'luluvid.com', 'luluvdo.com', 'lulustream.com', 'lulu.st',
// lulust.com: nowa domena tej samej rodziny (latestpornvideo 2026-10-02). Bez niej szla
// przez WebView, a w probce wszystkie sceny na lulust konczyly sie 403, na luluvdo gra.
// Rodzina rotuje domeny, dlatego nizej jest tez regula na przedrostek lulu.
'lulust.com',
// luluvid/streamwish forki rotują TLD, `luluvids.top` + cdnstream/cdnvids.top // luluvid/streamwish forki rotują TLD, `luluvids.top` + cdnstream/cdnvids.top
// potwierdzone P.A.C.K.E.R.-JWPlayer (mypornerleak embeds, bug-report 2026-06-07: // potwierdzone P.A.C.K.E.R.-JWPlayer (mypornerleak embeds, bug-report 2026-06-07:
// bez tego `isPackerHoster` zwracał false → WebView na luluvids.top → "disable // bez tego `isPackerHoster` zwracał false → WebView na luluvids.top → "disable
@ -45,6 +49,9 @@ export function isPackerHoster(url: string): boolean {
for (const h of PACKER_HOSTS) { for (const h of PACKER_HOSTS) {
if (host.endsWith('.' + h)) return true; if (host.endsWith('.' + h)) return true;
} }
// Kolejne domeny rodziny lulu (luluvid, luluvdo, lulustream, lulust): osadzenie ma
// zawsze sciezke /e/<id>. Gdy resolver i tak nie znajdzie skryptu, Player spada na WebView.
if (/^lulu[a-z]*\.[a-z]{2,}$/.test(host) && /^\/e\//.test(new URL(url).pathname)) return true;
} catch {} } catch {}
return false; return false;
} }

View file

@ -0,0 +1,60 @@
/**
* Mobile-side porndoe.com resolver.
*
* Linki do pliku (mp4 `?ip=<adres>&token=` i HLS z tokenem w sciezce) sa podpisane pod
* adres, z ktorego przyszlo zapytanie `/service/index.json`. Sprawdzone 2026-10-02: token
* pobrany przez IPv4 gra tylko przez IPv4, przez IPv6 tylko przez IPv6, z VPS 403.
*
* Wczesniej porndoe szedl przez WebView: token pobieral Chromium, a plik ExoPlayer
* (OkHttp). Na sieciach z IPv4 i IPv6 albo za CGNAT z pula adresow te dwa stosy potrafia
* wyjsc innymi adresami i plik dostawal 403 (164 bledy na 278 udanych w tydzien).
* Tu token pobiera zwykly `fetch` (tez OkHttp), wiec idzie ta sama droga co odtwarzacz.
*
* Jedno zapytanie wystarcza: serwis rozpoznaje film po Refererze, ciasteczka niepotrzebne.
*/
import type { StreamLink } from '../types';
const UA =
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36';
type PorndoeSource = {
name?: string;
height?: string | number;
link?: string;
type?: string;
};
export async function resolvePorndoePage(pageUrl: string): Promise<StreamLink[]> {
let origin: string;
try {
origin = new URL(pageUrl).origin;
} catch {
return [];
}
let data: { payload?: { player?: { sources?: { mp4?: unknown; hls?: unknown } } } } | null;
try {
const r = await fetch(`${origin}/service/index.json?device=mobile`, {
headers: { 'User-Agent': UA, Referer: pageUrl, Accept: 'application/json' },
});
if (!r.ok) return [];
data = await r.json();
} catch {
return [];
}
const sources = data?.payload?.player?.sources;
// mp4 przed HLS: jeden plik z przewijaniem. 1080p ma `type: 'link'` (reklama konta
// premium zamiast adresu), dlatego bierzemy tylko `type: 'video'`.
const pick = (list: unknown, kind: 'mp4' | 'm3u8'): StreamLink[] =>
(Array.isArray(list) ? (list as PorndoeSource[]) : [])
.filter((s) => s.type === 'video' && /^https?:\/\//.test(s.link || ''))
.sort((a, b) => Number(b.height || 0) - Number(a.height || 0))
.map((s) => ({
stream_url: s.link!,
direct_url: s.link!,
headers: { Referer: `${origin}/`, 'User-Agent': UA },
quality: s.name || (s.height ? `${s.height}p` : null),
type: kind,
}));
const mp4 = pick(sources?.mp4, 'mp4');
return mp4.length > 0 ? mp4 : pick(sources?.hls, 'm3u8');
}

View file

@ -505,6 +505,8 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
links = await (await import('../lib/epornerResolver')).resolveEpornerPage(resolvePageUrl); links = await (await import('../lib/epornerResolver')).resolveEpornerPage(resolvePageUrl);
} else if (resolvePageUrl && playOrigin === 'tube:fpoxxx') { } else if (resolvePageUrl && playOrigin === 'tube:fpoxxx') {
links = await (await import('../lib/fpoxxxResolver')).resolveFpoxxxPage(resolvePageUrl); links = await (await import('../lib/fpoxxxResolver')).resolveFpoxxxPage(resolvePageUrl);
} else if (resolvePageUrl && playOrigin === 'tube:porndoecom') {
links = await (await import('../lib/porndoeResolver')).resolvePorndoePage(resolvePageUrl);
} else if (canBackendReresolve && sceneId && params.playbackId) { } else if (canBackendReresolve && sceneId && params.playbackId) {
// Świeży resolve przez backend (VPS re-runuje extractor KVS → nowy nod + token). // Świeży resolve przez backend (VPS re-runuje extractor KVS → nowy nod + token).
const res = await client.resolvePlayback(sceneId, params.playbackId); const res = await client.resolvePlayback(sceneId, params.playbackId);
@ -1281,6 +1283,18 @@ const INJECTED_JS = `
// podsluchujemy POST zwracajacy bezposredni link. NICZEGO nie chowamy: pierwsza // podsluchujemy POST zwracajacy bezposredni link. NICZEGO nie chowamy: pierwsza
// wersja chowala oprawe strony i przy nierenderujacym sie widgecie zostawal // wersja chowala oprawe strony i przy nierenderujacym sie widgecie zostawal
// czarny ekran, gorzej niz przed zmiana. // czarny ekran, gorzej niz przed zmiana.
//
// Host sprawdzamy NA POCZATKU i wychodzimy POZA blokiem try. Wczesniej wyjscie
// siedzialo w tym samym try co oswajanie strony, a skrypt rusza, zanim powstanie
// <html>: appendChild na null rzucal, catch polykal blad i omijal return. Na
// vikingfile szedl wiec caly skrypt ponizej, a podsluch fetch bral zapytanie
// reklamy Adcash (adexchangerapid.com/...&cbtitle=<nazwa pliku>.mp4) za film.
// Telemetria: ~155 bledow "None of the available extractors" i ~50 "adexchangerapid"
// tygodniowo, polowa odtworzen galaxyporn. Dowod z emulatora 2026-10-02.
var goonTurnstileHost = false;
try {
goonTurnstileHost = /vik[i1]ngfile\\.|playmogo\\.|dood|d0000d|dooood|do0od|do7go|ds2play|doply\\.|myvidplay\\./i.test(location.hostname);
} catch (e) {}
try { try {
if (/vik[i1]ngfile\\./i.test(location.hostname)) { if (/vik[i1]ngfile\\./i.test(location.hostname)) {
// UWAGA: NIE chowamy tu reszty strony. Pierwsza wersja robila dokladnie to i user // UWAGA: NIE chowamy tu reszty strony. Pierwsza wersja robila dokladnie to i user
@ -1288,6 +1302,12 @@ const INJECTED_JS = `
// wyrenderuje, na czarnym tle nie zostaje juz nic. Gorsze niz stan wyjsciowy. Teraz // wyrenderuje, na czarnym tle nie zostaje juz nic. Gorsze niz stan wyjsciowy. Teraz
// tylko podswietlamy wlasciwe miejsce i przewijamy do niego, wiec najgorszy przypadek // tylko podswietlamy wlasciwe miejsce i przewijamy do niego, wiec najgorszy przypadek
// to normalna strona hostera, czyli to, co user widzial przed ta zmiana. // to normalna strona hostera, czyli to, co user widzial przed ta zmiana.
var vkSent = false;
var vkSendUrl = function(u) {
if (vkSent || !/^https?:/i.test(u || '') || !window.ReactNativeWebView) return;
vkSent = true;
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'video_url', url: u }));
};
var vkCss = document.createElement('style'); var vkCss = document.createElement('style');
vkCss.textContent = [ vkCss.textContent = [
'#captcha-download{outline:2px solid #e05a6d!important;border-radius:10px!important;', '#captcha-download{outline:2px solid #e05a6d!important;border-radius:10px!important;',
@ -1295,9 +1315,17 @@ const INJECTED_JS = `
'#goon-vk-hint{color:#e05a6d;font:600 15px system-ui,sans-serif;text-align:center;', '#goon-vk-hint{color:#e05a6d;font:600 15px system-ui,sans-serif;text-align:center;',
'padding:8px 0}', 'padding:8px 0}',
].join(''); ].join('');
(document.head || document.documentElement).appendChild(vkCss);
var vkScrolled = false; var vkScrolled = false;
var vkReady = function() { var vkReady = function() {
// Styl dokladamy dopiero tutaj: na starcie skryptu nie ma jeszcze ani <head>,
// ani <html> (patrz komentarz przy goonTurnstileHost).
if (!vkCss.parentNode) {
var vkRoot = document.head || document.documentElement;
if (vkRoot) vkRoot.appendChild(vkCss);
}
// Zapas dla podsluchu nizej: strona sama wpisuje link do <video>, gdy to plik wideo.
var vkVideo = document.querySelector('video');
if (vkVideo && /^https?:/i.test(vkVideo.src || '')) vkSendUrl(vkVideo.src);
var box = document.getElementById('captcha-download'); var box = document.getElementById('captcha-download');
if (box && !document.getElementById('goon-vk-hint')) { if (box && !document.getElementById('goon-vk-hint')) {
var hint = document.createElement('div'); var hint = document.createElement('div');
@ -1338,41 +1366,50 @@ const INJECTED_JS = `
// gdy tylko link sie pojawi, oddajemy go aplikacji, ktora przechodzi na natywny // gdy tylko link sie pojawi, oddajemy go aplikacji, ktora przechodzi na natywny
// odtwarzacz. Oswojony wyglad strony zostaje jako zabezpieczenie na wypadek, gdyby // odtwarzacz. Oswojony wyglad strony zostaje jako zabezpieczenie na wypadek, gdyby
// zabezpieczenie jednak zazadalo kliniecia. // zabezpieczenie jednak zazadalo kliniecia.
//
// Bierzemy tylko odpowiedz na POST do wlasnej domeny strony: skrypty reklam tez
// robia zapytania i ich JSON-y bywaja podobne.
var vkOpen = XMLHttpRequest.prototype.open; var vkOpen = XMLHttpRequest.prototype.open;
var vkSend = XMLHttpRequest.prototype.send; var vkSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function() { return vkOpen.apply(this, arguments); }; XMLHttpRequest.prototype.open = function(method, reqUrl) {
try {
this.__goonVkOwn = String(method).toUpperCase() === 'POST' &&
new URL(String(reqUrl), location.href).origin === location.origin;
} catch (e) {
this.__goonVkOwn = false;
}
return vkOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function() { XMLHttpRequest.prototype.send = function() {
var self = this; var self = this;
try { try {
self.addEventListener('load', function() { if (self.__goonVkOwn) {
try { self.addEventListener('load', function() {
var j = JSON.parse(self.responseText); try {
if (j && j.link && window.ReactNativeWebView) { var j = JSON.parse(self.responseText);
window.ReactNativeWebView.postMessage( if (j && typeof j.link === 'string') vkSendUrl(j.link);
JSON.stringify({ type: 'video_url', url: j.link }) } catch (e) {}
); });
} }
} catch (e) {}
});
} catch (e) {} } catch (e) {}
return vkSend.apply(this, arguments); return vkSend.apply(this, arguments);
}; };
} }
// Na KAZDYM hosterze z brama Cloudflare KONCZYMY tutaj. Ponizej patchujemy prototypy
// HTMLMediaElement, klikamy w plakat i usuwamy elementy uznane za reklamy, a Turnstile
// potrafi uznac tak zmodyfikowane srodowisko za podejrzane i odrzucic weryfikacje
// (zgloszenie: "wywolanie cloudflare dziala, ale weryfikacja jest negatywna").
//
// Rodzina dooda doszla 2026-08-26. Playmogo (tam laduje myvidplay i doply) renderuje
// widget przez turnstile.render z size flexible, DOPIERO po kliknieciu w play,
// i trzyma go w kontenerze o klasie captcha-player z z-index 100000, czyli w tym,
// co nasz usuwacz nakladek traktuje jak reklame. Do tego nasze auto-klikniecie w plakat
// odpalalo ta brame, zanim widget zdazyl sie wyrenderowac.
//
// Na stronie pliku i tak nie potrzebujemy ani wyciszania autoplay, ani ad-blocka,
// a bezposredni link przechwytujemy podsluchem powyzej.
if (/vik[i1]ngfile\\.|playmogo\\.|dood|d0000d|dooood|do0od|do7go|ds2play|doply\\.|myvidplay\\./i.test(location.hostname)) return;
} catch (e) {} } catch (e) {}
// Na KAZDYM hosterze z brama Cloudflare KONCZYMY tutaj. Ponizej patchujemy prototypy
// HTMLMediaElement, klikamy w plakat i usuwamy elementy uznane za reklamy, a Turnstile
// potrafi uznac tak zmodyfikowane srodowisko za podejrzane i odrzucic weryfikacje
// (zgloszenie: "wywolanie cloudflare dziala, ale weryfikacja jest negatywna").
//
// Rodzina dooda doszla 2026-08-26. Playmogo (tam laduje myvidplay i doply) renderuje
// widget przez turnstile.render z size flexible, DOPIERO po kliknieciu w play,
// i trzyma go w kontenerze o klasie captcha-player z z-index 100000, czyli w tym,
// co nasz usuwacz nakladek traktuje jak reklame. Do tego nasze auto-klikniecie w plakat
// odpalalo ta brame, zanim widget zdazyl sie wyrenderowac.
//
// Na stronie pliku i tak nie potrzebujemy ani wyciszania autoplay, ani ad-blocka,
// a bezposredni link przechwytujemy podsluchem powyzej.
if (goonTurnstileHost) return;
// -- 0a. Mute autoplay until the FIRST user gesture (kills the unmuted-audio flash // -- 0a. Mute autoplay until the FIRST user gesture (kills the unmuted-audio flash
// gdy WebView otwiera stronę żeby przechwycić stream, report dccc05e4). // gdy WebView otwiera stronę żeby przechwycić stream, report dccc05e4).
@ -1495,6 +1532,10 @@ const INJECTED_JS = `
// ("zacina się na pierwszych sekundach i robi loop", bug-reports 40ec88cc / 92e9133e). // ("zacina się na pierwszych sekundach i robi loop", bug-reports 40ec88cc / 92e9133e).
// isLikelyAd odsiewał je już przy auto-extrakcji, ale sama strona i tak je ładowała. // isLikelyAd odsiewał je już przy auto-extrakcji, ale sama strona i tak je ładowała.
'opencdn.b-cdn.net', 'opencdn.b-cdn.net',
// Adcash: zapytanie o reklame niesie tytul strony w parametrze (cbtitle=<plik>.mp4),
// a VIDEO_RE widzi ".mp4" takze w parametrach. Tak reklama trafiala do odtwarzacza
// na vikingfile (2026-10-02).
'adexchangerapid.com','usrpubtrk.com',
]; ];
const isAdHost = function(url) { const isAdHost = function(url) {
if (!url) return false; if (!url) return false;

View file

@ -25,6 +25,7 @@ import { resolvePornxpPage } from '../lib/pornxpResolver';
import { resolveSxyprnPage } from '../lib/sxyprnResolver'; import { resolveSxyprnPage } from '../lib/sxyprnResolver';
import { resolveEpornerPage } from '../lib/epornerResolver'; import { resolveEpornerPage } from '../lib/epornerResolver';
import { resolveFpoxxxPage } from '../lib/fpoxxxResolver'; import { resolveFpoxxxPage } from '../lib/fpoxxxResolver';
import { resolvePorndoePage } from '../lib/porndoeResolver';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { fonts, theme } from '../theme'; import { fonts, theme } from '../theme';
import { HeaderIconButton } from '../ui'; import { HeaderIconButton } from '../ui';
@ -643,7 +644,7 @@ function PlaybackButton({
// IP-bound tuby resolwowane phone-side: przekaż page_url, by Player mógł // IP-bound tuby resolwowane phone-side: przekaż page_url, by Player mógł
// re-resolve'ować świeży token gdy native padnie na initial-load (zmiana IP / TTL). // re-resolve'ować świeży token gdy native padnie na initial-load (zmiana IP / TTL).
const PHONE_RESOLVE_ORIGINS = ['tube:sxyprncom', 'tube:epornercom', 'tube:fpoxxx']; const PHONE_RESOLVE_ORIGINS = ['tube:sxyprncom', 'tube:epornercom', 'tube:fpoxxx', 'tube:porndoecom'];
nav.navigate('Player', { nav.navigate('Player', {
url: initialUrl, url: initialUrl,
sceneId, sceneId,
@ -696,6 +697,8 @@ function PlaybackButton({
? resolveFpoxxxPage ? resolveFpoxxxPage
: source.origin === 'tube:pornxpph' : source.origin === 'tube:pornxpph'
? resolvePornxpPage ? resolvePornxpPage
: source.origin === 'tube:porndoecom'
? resolvePorndoePage
: // hqfap zdjęty 2026-07-28: strona wdrożyła Cloudflare z JS-challenge na : // hqfap zdjęty 2026-07-28: strona wdrożyła Cloudflare z JS-challenge na
// /watch/ (403 dla każdego zwykłego fetcha, także z pełnym kompletem // /watch/ (403 dla każdego zwykłego fetcha, także z pełnym kompletem
// nagłówków przeglądarki), więc phone-resolve tylko padał i zrzucał usera // nagłówków przeglądarki), więc phone-resolve tylko padał i zrzucał usera

62
scripts/edge_ab_report.py Normal file
View file

@ -0,0 +1,62 @@
"""Porownanie punktacji feedu Edge: grupa nowa vs kontrolna (stara punktacja).
Przydzial do grupy jest staly po hashu id urzadzenia (`app.clips_feed.uses_v1_scoring`),
wiec wystarczy pogrupowac zdarzenia z okresu po wdrozeniu. Liczymy tylko slot 'affinity',
bo tylko tam punktacja dziala (explore losuje tak samo w obu grupach). Srednie sa po
urzadzeniach (kazde urzadzenie z >= MIN_VIEWS obejrzeniami waży tyle samo), zeby kilku
najaktywniejszych nie przesadzalo o wyniku.
docker compose exec -T worker python3 /srv/scripts/edge_ab_report.py [od] [do]
Domyslnie od wdrozenia (2026-10-02 10:00 UTC) do teraz. Okno sprzed wdrozenia to test A/A:
obie grupy mialy wtedy te sama punktacje, wiec roznice pokazuja sam szum.
"""
from __future__ import annotations
import statistics
import sys
from collections import defaultdict
from sqlalchemy import text
from app.clips_feed import uses_v1_scoring
from app.db import session_scope
SINCE = sys.argv[1] if len(sys.argv) > 1 else "2026-10-02T10:00:00+00:00"
UNTIL = sys.argv[2] if len(sys.argv) > 2 else "2100-01-01T00:00:00+00:00"
MIN_VIEWS = 20
VIEW_KINDS = ("complete", "skip", "impression", "autoswipe")
with session_scope() as s:
rows = s.execute(text("""
SELECT device_id, kind, dwell_ms FROM clip_feed_events
WHERE created_at >= CAST(:since AS timestamptz) AND created_at < CAST(:until AS timestamptz)
AND slot = 'affinity'
"""), {"since": SINCE, "until": UNTIL}).all()
per = defaultdict(lambda: {"views": 0, "complete": 0, "skip": 0, "like": 0, "not_interested": 0, "dwell": []})
for dev, kind, dwell in rows:
d = per[dev]
if kind in VIEW_KINDS:
d["views"] += 1
if dwell:
d["dwell"].append(dwell)
if kind in d and kind != "views":
d[kind] += 1
groups: dict[str, list[dict]] = {"nowa": [], "kontrolna": []}
for dev, d in per.items():
if d["views"] >= MIN_VIEWS:
groups["kontrolna" if uses_v1_scoring(dev) else "nowa"].append(d)
print(f"od {SINCE} do {UNTIL}, zdarzen {len(rows)}, urzadzen z >= {MIN_VIEWS} obejrzeniami: "
+ ", ".join(f"{g} {len(v)}" for g, v in groups.items()))
for g, devs in groups.items():
if not devs:
continue
def avg(f):
return 100 * statistics.mean(f(d) for d in devs)
print(f" {g:9s} dokonczone {avg(lambda d: d['complete'] / d['views']):5.1f}%"
f" lajki {avg(lambda d: d['like'] / d['views']):4.1f}%"
f" ucieczki {avg(lambda d: d['skip'] / d['views']):5.1f}%"
f" nie-interesuje {avg(lambda d: d['not_interested'] / d['views']):4.1f}%"
f" mediana dwell {statistics.median(statistics.median(d['dwell']) for d in devs if d['dwell']) / 1000:.1f} s")

View file

@ -0,0 +1,95 @@
"""Odtworzenie uczenia feedu Edge zdarzenie po zdarzeniu (bez zagladania w przyszlosc).
Dla kazdego wyswietlenia (complete/skip/impression/autoswipe) zapisuje cechy znane W TEJ
CHWILI: skladniki obecnej punktacji z wag urzadzenia sprzed zdarzenia oraz liczniki klipu
z wczesniejszych zdarzen wszystkich urzadzen. Wynik CSV na stdout.
Uzyte do przestrojenia punktacji 2026-10-02 (patrz app/clips_feed.py, score_candidate).
Wybor tagow na starcie (onboarding) nie jest zdarzeniem, wiec tu go brakuje.
docker compose exec -T worker sh -c "cd /srv && python3 scripts/edge_replay_features.py | gzip -c" > edge.csv.gz
Analiza lokalnie: python scripts/edge_tuning_analysis.py edge.csv (pandas + scikit-learn).
"""
import csv
import math
import sys
from collections import defaultdict
from datetime import timedelta
from sqlalchemy import text
from app import clips_feed as cf
from app.db import session_scope
with session_scope() as s:
ev = s.execute(text("""
select device_id, clip_id, kind, dwell_ms, reason, slot, created_at
from clip_feed_events where clip_id is not null order by created_at""")).all()
ids = list({e.clip_id for e in ev})
clips = {}
for i in range(0, len(ids), 5000):
for r in s.execute(text("""select id, creator, likes, published_at, niches, duration_sec, width, height,
has_audio, creator_verified
from clips where id = any(:ids)"""), {"ids": ids[i:i + 5000]}).all():
clips[r.id] = r
tags = defaultdict(list)
for i in range(0, len(ids), 5000):
for cid, t in s.execute(text("select clip_id, tag from clip_tags where clip_id = any(:ids)"), {"ids": ids[i:i + 5000]}).all():
tags[cid].append(t)
n_all = s.execute(text("select count(*) from clips")).scalar()
tag_df = dict(s.execute(text("select tag, count(*) from clip_tags group by tag")).all())
idf = {t: math.log((n_all + 1) / (n + 1)) for t, n in tag_df.items()}
print(f"zdarzen {len(ev)}, klipow {len(clips)}, tagow {len(idf)}", file=sys.stderr)
aff = defaultdict(cf.Affinity)
last_day = {}
dev_n = defaultdict(int)
# liczniki klipu z wczesniejszych zdarzen (wszystkie urzadzenia)
c_seen = defaultdict(int)
c_comp = defaultdict(int)
c_skip = defaultdict(int)
c_like = defaultdict(int)
liked = set((e.device_id, e.clip_id) for e in ev if e.kind == "like")
w = csv.writer(sys.stdout)
w.writerow(["dev", "ts", "slot", "kind", "dwell", "liked", "tag_sum", "tag_pos", "tag_n", "niche_sum", "creator_w",
"tag_idf_sum", "tag_idf_pos", "pop", "fresh", "dur", "vertical", "audio", "verified", "c_seen", "c_comp", "c_skip", "c_like", "dev_n"])
OUTCOME = {"complete", "skip", "impression", "autoswipe"}
for e in ev:
c = clips.get(e.clip_id)
if c is None:
continue
a = aff[e.device_id]
day = e.created_at.date()
prev = last_day.get(e.device_id)
if prev is not None and day > prev:
for _ in range((day - prev).days):
a.tags = cf.decay_weights(a.tags)
a.niches = cf.decay_weights(a.niches)
a.creators = cf.decay_weights(a.creators)
last_day[e.device_id] = day
t = tags.get(e.clip_id, [])
niches = list(c.niches or [])
if e.kind in OUTCOME:
ts_vals = [a.tags.get(x, 0.0) for x in t]
fresh = int(bool(c.published_at and c.published_at >= e.created_at - timedelta(days=cf.FRESH_DAYS)))
vert = int(bool(c.width and c.height and c.height > c.width))
w.writerow([e.device_id[:12], e.created_at.isoformat(), e.slot or "", e.kind, e.dwell_ms or 0,
int((e.device_id, e.clip_id) in liked),
round(sum(ts_vals), 3), sum(1 for v in ts_vals if v > 0), len(t),
round(sum(a.niches.get(n, 0.0) for n in niches), 3),
round(a.creators.get(c.creator or "", 0.0), 3),
round(sum(a.tags.get(x, 0.0) * idf.get(x, 0.0) for x in t), 3),
round(sum(idf.get(x, 0.0) for x in t if a.tags.get(x, 0.0) > 0), 3),
round(math.log1p(max(c.likes or 0, 0)), 3), fresh, round(c.duration_sec or 0, 1), vert,
int(bool(c.has_audio)), int(bool(c.creator_verified)),
c_seen[e.clip_id], c_comp[e.clip_id], c_skip[e.clip_id], c_like[e.clip_id], dev_n[e.device_id]])
c_seen[e.clip_id] += 1
if e.kind == "complete":
c_comp[e.clip_id] += 1
elif e.kind == "skip":
c_skip[e.clip_id] += 1
dev_n[e.device_id] += 1
elif e.kind == "like":
c_like[e.clip_id] += 1
cf.apply_event(a, kind=e.kind, dwell_ms=e.dwell_ms, reason=e.reason, tags=t, niches=niches, creator=c.creator)

View file

@ -0,0 +1,82 @@
"""Ocena punktacji feedu Edge na cechach z scripts/edge_replay_features.py.
Trening na 70% najstarszych wyswietlen, test na reszcie. Glowna miara: AUC W OBREBIE
URZADZENIA (czy wyzszy wynik u tego samego urzadzenia znaczy lepsza reakcje), bo globalne
AUC miesza aktywnych z nowymi. Dwie etykiety: lajk albo >= 10 s ogladania (odporna na
dlugosc klipu) oraz dokonczenie albo lajk. F5 to formula wdrozona 2026-10-02.
Wymaga pandas i scikit-learn (lokalnie, nie w kontenerze).
"""
import sys
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
df = pd.read_csv(sys.argv[1], parse_dates=["ts"])
A = 10.0
P_LIKE, P_SKIP = 0.03, 0.5
df["like_rate"] = (df.c_like + A * P_LIKE) / (df.c_seen + A)
df["skip_rate"] = (df.c_skip + A * P_SKIP) / (df.c_seen + A)
df["popul"] = df["pop"]
slog = lambda x: np.sign(x) * np.log1p(np.abs(x)) # noqa: E731
for c in ["tag_sum", "tag_idf_sum", "creator_w", "niche_sum"]:
df["s_" + c] = slog(df[c])
def within(d, col):
num = den = 0.0
for _, g in d.groupby("dev"):
if len(g) < 30 or g.y.nunique() < 2 or g[col].nunique() < 2:
continue
num += roc_auc_score(g.y, g[col]) * len(g)
den += len(g)
return num / den if den else float("nan")
def labelled(kind):
if kind == "dwell":
good = (df.liked == 1) | (df.dwell >= 10000)
else:
good = (df.kind == "complete") | (df.liked == 1)
bad = df.kind == "skip"
d = df[good | bad].copy()
d["y"] = good[good | bad].astype(int)
return d.sort_values("ts")
def cur(d):
return d.tag_sum + 2 * d.niche_sum + 3 * d.creator_w + 0.3 * d.popul + 0.5 * d.fresh
def formula(d, w):
return (w["tag"] * d.s_tag_idf_sum + w["creator"] * d.s_creator_w + w["pop"] * d.popul
+ w["like"] * (d.like_rate - P_LIKE) - w["skip"] * (d.skip_rate - P_SKIP))
for kind in ("dwell", "complete"):
d = labelled(kind)
cut = int(len(d) * 0.7)
tr, te = d.iloc[:cut].copy(), d.iloc[cut:].copy()
print(f"\n===== etykieta {kind}: trening {len(tr)}, test {len(te)}")
print(" AUC w obrebie urzadzenia (test):")
for f in ["s_tag_sum", "s_tag_idf_sum", "tag_idf_pos", "s_creator_w", "s_niche_sum", "popul", "like_rate"]:
print(f" {f:14s} {within(te, f):.3f}")
cols = ["s_tag_idf_sum", "s_creator_w", "s_niche_sum", "popul", "like_rate", "skip_rate"]
m = LogisticRegression(max_iter=3000).fit(tr[cols], tr.y)
coef = dict(zip(cols, m.coef_[0]))
base = coef["s_tag_idf_sum"]
print(" wspolczynniki w jednostkach tagu:", {c: round(v / base, 2) for c, v in coef.items()})
te["cur"] = cur(te)
te["model"] = m.decision_function(te[cols])
for name, w in {
"F1": {"tag": 1.0, "creator": 1.0, "pop": 0.3, "like": 20.0, "skip": 2.0},
"F2": {"tag": 1.0, "creator": 1.5, "pop": 0.5, "like": 30.0, "skip": 3.0},
"F3": {"tag": 1.0, "creator": 1.0, "pop": 0.5, "like": 40.0, "skip": 4.0},
"F4": {"tag": 1.0, "creator": 2.5, "pop": 0.8, "like": 120.0, "skip": 15.0},
}.items():
te[name] = formula(te, w)
print(" porownanie (test): AUC globalnie / w obrebie urzadzenia")
te["F5"] = formula(te, {"tag": 1.0, "creator": 2.5, "pop": 0.8, "like": 120.0, "skip": 15.0}) + 0.25 * te.s_niche_sum + 0.3 * te.fresh
for f in ["cur", "model", "F1", "F2", "F3", "F4", "F5"]:
print(f" {f:6s} {roc_auc_score(te.y, te[f]):.3f} / {within(te, f):.3f}")

View file

@ -0,0 +1,38 @@
"""Jednorazowo: zrodla, ktore telefony zglosily jako 404/410, sprawdzone z serwera.
Ta sama logika co na zywo (app/playback_gone.py), dla zgloszen sprzed wdrozenia.
docker compose exec -T worker python3 /srv/scripts/verify_gone_backfill.py [dni] [--commit] [--limit N]
Bez --commit tylko liczy.
"""
from __future__ import annotations
import sys
from collections import Counter
from sqlalchemy import text
from app.db import session_scope
from app.playback_gone import verify_gone
days = int(next((a for a in sys.argv[1:] if a.isdigit()), "14"))
commit = "--commit" in sys.argv
limit = int(sys.argv[sys.argv.index("--limit") + 1]) if "--limit" in sys.argv else 100000
with session_scope() as s:
pairs = s.execute(text("""
SELECT DISTINCT e.scene_id, e.origin FROM playback_events e
WHERE e.created_at > now() - make_interval(days => :d) AND e.status = 'error'
AND e.error_detail ~* 'response code: (404|410)' AND e.scene_id IS NOT NULL
AND EXISTS (SELECT 1 FROM playback_sources p WHERE p.scene_id = e.scene_id
AND p.origin = e.origin AND p.dead_at IS NULL)
LIMIT :lim"""), {"d": days, "lim": limit}).all()
print(f"par (scena, serwis) do sprawdzenia: {len(pairs)}, {'NA SERIO' if commit else 'na sucho'}", flush=True)
total: Counter = Counter()
per_origin: dict[str, Counter] = {}
for scene_id, origin in pairs:
r = verify_gone(scene_id, origin, dry_run=not commit)
total.update(r)
per_origin.setdefault(origin, Counter()).update(r)
for o, c in sorted(per_origin.items(), key=lambda kv: -kv[1]["checked"]):
print(f" {o:24s} {dict(c)}")
print("razem:", dict(total))

View file

@ -15,7 +15,9 @@ from app.clips_feed import (
pick_affinity, pick_affinity,
pick_explore, pick_explore,
score_candidate, score_candidate,
score_candidate_v1,
split_counts, split_counts,
uses_v1_scoring,
) )
@ -95,3 +97,41 @@ def test_pick_explore_weighted_but_complete():
cands = [_cand("a", likes=0), _cand("b", likes=10**6)] cands = [_cand("a", likes=0), _cand("b", likes=10**6)]
out = pick_explore(cands, 2, random.Random(1)) out = pick_explore(cands, 2, random.Random(1))
assert {c.creator for c in out} == {"a", "b"} assert {c.creator for c in out} == {"a", "b"}
def test_feed_history_dominates_ranking():
# Klip, ktory u innych zbiera lajki, idzie przed takim samym z ucieczkami (2026-10-02).
aff = Affinity(tags={"x": 5.0})
liked = Candidate(id=uuid.uuid4(), creator="a", likes=0, published_at=None, tags=["x"], niches=[],
feed_views=50, feed_skips=10, feed_likes=8)
skipped = Candidate(id=uuid.uuid4(), creator="a", likes=0, published_at=None, tags=["x"], niches=[],
feed_views=50, feed_skips=45, feed_likes=0)
fresh_clip = Candidate(id=uuid.uuid4(), creator="a", likes=0, published_at=None, tags=["x"], niches=[])
assert score_candidate(liked, aff) > score_candidate(fresh_clip, aff) > score_candidate(skipped, aff)
def test_rare_tag_counts_more_than_common():
aff = Affinity(tags={"rare": 5.0, "common": 5.0})
idf = {"rare": 8.0, "common": 0.5}
rare = _cand("a", tags=["rare"])
common = _cand("a", tags=["common"])
assert score_candidate(rare, aff, idf=idf) > score_candidate(common, aff, idf=idf)
# stara punktacja ich nie rozroznia
assert score_candidate_v1(rare, aff) == score_candidate_v1(common, aff)
def test_many_generic_tags_do_not_swamp_creator():
# Stara punktacja: 12 ogolnych tagow po 5 pkt bilo ulubionego tworce (60 > 3*10).
aff = Affinity(tags={f"t{i}": 5.0 for i in range(12)}, creators={"fav": 10.0})
idf = {f"t{i}": 0.3 for i in range(12)}
generic = _cand("other", tags=[f"t{i}" for i in range(12)])
favourite = _cand("fav")
assert score_candidate(favourite, aff, idf=idf) > score_candidate(generic, aff, idf=idf)
assert score_candidate_v1(generic, aff) > score_candidate_v1(favourite, aff)
def test_v1_control_group_is_stable_and_about_a_quarter():
ids = [uuid.uuid4().hex for _ in range(4000)]
share = sum(uses_v1_scoring(d) for d in ids) / len(ids)
assert 0.21 < share < 0.29
assert all(uses_v1_scoring(d) == uses_v1_scoring(d) for d in ids[:50])

View file

@ -0,0 +1,23 @@
from app.connectors.dooplay import _LIST_ITEM_RE
def _urls(html):
return [m.group("url") or m.group("url2") for m in _LIST_ITEM_RE.finditer(html)]
def test_mangoporn_class_before_href():
# mangoporn od 2026-10: klasa przed href, wczesniej wzorzec nie lapal nic.
html = (
'<a class="thumb" href="https://mangoporn.net/movies/major-creampie/"><div></div></a>'
'<a class="infos" href="https://mangoporn.net/movies/major-creampie/" title="Major Creampie">'
'<a title="Porn Movies" href="https://mangoporn.net/movies/category/porn-movies-xxx/" class="nav-link">'
)
assert _urls(html) == [
"https://mangoporn.net/movies/major-creampie/",
"https://mangoporn.net/movies/major-creampie/",
]
def test_old_layouts_still_match():
assert _urls('<a href="https://mangoporn.net/movies/old-one/">x</a>') == ["https://mangoporn.net/movies/old-one/"]
assert _urls('<a href="https://streamporn.li/watch-xxx-a/" class="ml-mask jt">') == ["https://streamporn.li/watch-xxx-a/"]

View file

@ -36,3 +36,18 @@ def test_adultempire_id_from_both_poster_formats():
assert (old.group(1) or old.group(2)) == "5128705" assert (old.group(1) or old.group(2)) == "5128705"
assert (new.group(1) or new.group(2)) == "5136742" assert (new.group(1) or new.group(2)) == "5136742"
assert _AE_ID_RE.search("https://i.ibb.co/DHDd9pm9/Private-POV.jpg") is None assert _AE_ID_RE.search("https://i.ibb.co/DHDd9pm9/Private-POV.jpg") is None
_MULTILINE = (
'<div class="movie">\n<div class="imagen">\n'
'<img src="https://imgs4cdn.adultempire.com/movie/2755934?w=500&#038;h=700&#038;fit=cover" alt="Busty Treats Vol. 2" '
'width="100%" height="100%" />\n<a href="https://yourdailypornmovies.ws/busty-treats-vol-2/"><span class="player"></span></a>\n'
'</div>\n<h2>Busty Treats Vol. 2</h2>\n<span class="year">2026</span></div>'
)
def test_card_with_newlines_between_tags():
# Uklad strony od 2026-10: nowe linie miedzy znacznikami (0 filmow do poprawki).
cards = list(_CARD_RE.finditer(_MULTILINE + _MULTILINE.replace("busty-treats-vol-2", "b2")))
assert [c.group("title") for c in cards] == ["Busty Treats Vol. 2", "Busty Treats Vol. 2"]
assert cards[0].group("url").endswith("/busty-treats-vol-2/")