more providers

This commit is contained in:
Simon
2026-09-07 15:25:03 +00:00
parent 5941a6e607
commit 86c8faca44
10 changed files with 4432 additions and 1 deletions

View File

@@ -286,6 +286,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "sextb", module: "sextb",
ty: "SextbProvider", ty: "SextbProvider",
}, },
ProviderDef {
id: "notfans",
module: "notfans",
ty: "NotfansProvider",
},
ProviderDef { ProviderDef {
id: "hentaihaven", id: "hentaihaven",
module: "hentaihaven", module: "hentaihaven",
@@ -296,6 +301,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "fikfap", module: "fikfap",
ty: "FikfapProvider", ty: "FikfapProvider",
}, },
ProviderDef {
id: "hdporn92",
module: "hdporn92",
ty: "Hdporn92Provider",
},
ProviderDef { ProviderDef {
id: "chaturbate", id: "chaturbate",
module: "chaturbate", module: "chaturbate",
@@ -371,6 +381,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "xgroovy", module: "xgroovy",
ty: "XgroovyProvider", ty: "XgroovyProvider",
}, },
ProviderDef {
id: "borntobefuck",
module: "borntobefuck",
ty: "BorntobefuckProvider",
},
ProviderDef { ProviderDef {
id: "xvideos", id: "xvideos",
module: "xvideos", module: "xvideos",
@@ -416,6 +431,16 @@ const PROVIDERS: &[ProviderDef] = &[
module: "redgifs", module: "redgifs",
ty: "RedgifsProvider", ty: "RedgifsProvider",
}, },
ProviderDef {
id: "hornyleak",
module: "hornyleak",
ty: "HornyleakProvider",
},
ProviderDef {
id: "fapello",
module: "fapello",
ty: "FapelloProvider",
},
]; ];
fn main() { fn main() {

View File

@@ -74,6 +74,14 @@ _CF_PROTECTED_HOSTS = {
# plain requests/curl always see the "Just a moment" challenge. # plain requests/curl always see the "Just a moment" challenge.
"hentaihaven.xxx", "hentaihaven.xxx",
"www.hentaihaven.xxx", "www.hentaihaven.xxx",
# Cloudflare-gated; the shared requester uses the Jina mirror fallback.
# `video.url` is the detail page so it will always 403 to a plain request.
"fapello.com",
"www.fapello.com",
"cdn.fapello.com",
# hdplayer.gives returns 200 to plain curl but 404 to curl_cffi's JA3 — the
# HLS list/enc... endpoint is request-bound to the embed-page TLS context.
"hdplayer.gives",
} }

View File

@@ -5,7 +5,7 @@ This folder is the fastest handoff path for anyone adding or repairing a channel
Start here: Start here:
1. Read `architecture.md` for the server flow, request lifecycle, and core types. 1. Read `architecture.md` for the server flow, request lifecycle, and core types.
2. Read `provider-playbook.md` for the exact process to add a new provider or proxy. 2. Read `provider-playbook.md` — its top **"Best Practices (Living)"** section is the cross-cutting rule set to internalize first, then follow the rest of the playbook for the exact process to add a new provider or proxy.
3. Use `provider-catalog.md` to find the closest existing implementation to copy. 3. Use `provider-catalog.md` to find the closest existing implementation to copy.
4. Use `docs/hottubapp/*.html` when you need the client-facing API contract for status, videos, or uploaders. 4. Use `docs/hottubapp/*.html` when you need the client-facing API contract for status, videos, or uploaders.
5. Only then touch `prompts/new-channel.md`; it assumes the docs above exist. 5. Only then touch `prompts/new-channel.md`; it assumes the docs above exist.

View File

@@ -15,16 +15,19 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `chaturbate` | `live-cams` | no | no | Live cam channel. | | `chaturbate` | `live-cams` | no | no | Live cam channel. |
| `clapdat` | `amateur-homemade` | no | yes | Svelte/JSON-hydrated provider using home/recent/trending routes, Meilisearch keyword search, and `/proxy/clapdat/...` redirect playback resolution. | | `clapdat` | `amateur-homemade` | no | yes | Svelte/JSON-hydrated provider using home/recent/trending routes, Meilisearch keyword search, and `/proxy/clapdat/...` redirect playback resolution. |
| `erome` | `amateur-homemade` | no | no | HTML album scraper with hot/new feeds, keyword search, and uploader-slug shortcuts (`uploader:<name>`). | | `erome` | `amateur-homemade` | no | no | HTML album scraper with hot/new feeds, keyword search, and uploader-slug shortcuts (`uploader:<name>`). |
| `fapello` | `onlyfans` | no | no | HTML + embedded-JSON provider for fapello.com (OnlyFans-style photo/video leak aggregator; the site is fundamentally a *photo* model archive — `/`, `/new/`, `/hot/`, `/trending/`, `/tags/`, `/search_v2/` — and the much smaller `/videos/` section is what this channel surfaces). Cloudflare-gated; the shared requester's Jina mirror fallback resolves every listing and detail page (`r.jina.ai/http://fapello.com/...`, `X-Return-Format: html`). Latest feed `GET /videos/` (page 1, ~6-8 cards), `GET /ajax/videos/page-{N}/` (page 2+, ~4 cards) — cards expose the numeric id, the `fapello.com/content/.../{model}_{NNNN}_300px.jpg` thumbnail, and the `/{model_slug}/` uploader link. Each detail page `GET /video/new/{id}/` embeds a complete `var BOOT = { items: [...], next_page, page_size }` JSON block (preserved by the Jina mirror) — every BOOT item carries the resolved `video_url` (`https://cdn.fapello.com/content/.../{N}/{model}_{NNNN}.mp4`), `poster_url` (the matching `.jpg`, no `_300px` suffix — provider swaps that in for the full-size thumb), `duration` ("HH:MM:SS" → seconds), `width`/`height`/`aspect`/`orientation`, `count_likes` (→ `views`), `name`/`model_url` (uploader), and `tags[]`. Per-card BOOT enrichment is bounded via `buffer_unordered(4)` with a 20s timeout — listing-only metadata (id, thumb, uploader) is preserved on timeout so a Jina-throttled page still produces usable cards. The Jina mirror does NOT trigger JS, so the `/popular_videos/{twelve_hours,month,all_time}/` routes and `/search_v2/?type=...` are empty (search returns *photo models*, not videos; the site has no native "videos matching keyword" search) — those filters are deliberately not exposed; only `sort:new` is offered. `video.url` is the detail page (not yt-dlp-resolvable — no extractor exists), so `formats[0]` carries the direct `cdn.fapello.com` mp4 with `Referer: https://fapello.com/` (the CDN serves direct without it, but the header matches the on-site context). Media and thumbnails (`fapello.com/content/.../*.jpg`, `cdn.fapello.com/content/.../*.mp4`) are range-served, no auth/cookies/JA3 emulation required, so no proxy route is needed. `check.py`'s `_CF_PROTECTED_HOSTS` allowlist includes `fapello.com` and `cdn.fapello.com` so its plain-requests 403s for `video.url` and the format URL downgrade to warnings instead of errors. No `/api/uploaders` (fapello has stable model slugs but no dedicated "videos by this uploader" feed — only the mixed photo+video model page). |
| `fikfap` | `tiktok` | yes | yes (thumbs only) | JSON-API provider for fikfap.com (TikTok-style swipe short clips); anonymous auth via a client-generated `Authorization-Anonymous` UUID header (no real login needed); listing via `GET api.fikfap.com/posts?sort=new\|trending\|random&amount=N&afterId=<lastPostId>` (cursor pagination — page N costs N sequential requests); search via `GET search?q=` (single fixed-size batch, no pagination — page 2+ returns empty); hashtag feeds via `GET hashtags/label/{label}/posts` and creator feeds via `GET profile/username/{user}/posts`, both also cursor-paginated; `tag:`/`hashtag:`/`#` and `user:`/`uploader:` query prefixes route directly; `categories` option exposes a small curated static hashtag list (no full catalog endpoint exists anonymously); `video.url` is the `fikfap.com/post/{id}` page (a client-rendered SPA, not yt-dlp-resolvable on its own); `videoStreamUrl` from the JSON response is sent directly as `formats[0].url` (signed Bunny CDN HLS `.m3u8`, ~24h token expiry) with `httpHeaders: {Referer: https://fikfap.com/}` — Hot Tub clients apply a format's `http_headers` across the whole HLS playback session (manifest, sub-playlists, and segments), so no proxying of the media itself is needed; thumbnails have no per-field header mechanism, so they're proxied via `/proxy/fikfap-thumb/...` to inject the same Referer; `get_uploader` implemented (`fikfap:<username>` IDs) using `GET profile/username/{user}`. | | `fikfap` | `tiktok` | yes | yes (thumbs only) | JSON-API provider for fikfap.com (TikTok-style swipe short clips); anonymous auth via a client-generated `Authorization-Anonymous` UUID header (no real login needed); listing via `GET api.fikfap.com/posts?sort=new\|trending\|random&amount=N&afterId=<lastPostId>` (cursor pagination — page N costs N sequential requests); search via `GET search?q=` (single fixed-size batch, no pagination — page 2+ returns empty); hashtag feeds via `GET hashtags/label/{label}/posts` and creator feeds via `GET profile/username/{user}/posts`, both also cursor-paginated; `tag:`/`hashtag:`/`#` and `user:`/`uploader:` query prefixes route directly; `categories` option exposes a small curated static hashtag list (no full catalog endpoint exists anonymously); `video.url` is the `fikfap.com/post/{id}` page (a client-rendered SPA, not yt-dlp-resolvable on its own); `videoStreamUrl` from the JSON response is sent directly as `formats[0].url` (signed Bunny CDN HLS `.m3u8`, ~24h token expiry) with `httpHeaders: {Referer: https://fikfap.com/}` — Hot Tub clients apply a format's `http_headers` across the whole HLS playback session (manifest, sub-playlists, and segments), so no proxying of the media itself is needed; thumbnails have no per-field header mechanism, so they're proxied via `/proxy/fikfap-thumb/...` to inject the same Referer; `get_uploader` implemented (`fikfap:<username>` IDs) using `GET profile/username/{user}`. |
| `freepornvideosxxx` | `studio-network` | no | no | Studio-style scraper. | | `freepornvideosxxx` | `studio-network` | no | no | Studio-style scraper. |
| `fyptt` | `tiktok` | no | no | HTML scraper for fyptt.to (Beaver Builder/WordPress short-form TikTok-style vertical porn); card selector `.fl-post-grid-post[class*="post-ID"]` with `category-{slug}` CSS class doubling as both listing tag and category-archive route; latest feed `/` (page N: `/page/N/`), search `/?s=query` (page N: `/page/N/?s=query`), category archives at bare top-level slugs like `/tiktok-ass/` (12 hardcoded categories exposed via the `categories` filter option, or via an explicit `cat:`/`category:` query prefix — bare keyword queries always go to WordPress search, never a category archive, because the category names ("sexy", "ass", "tiktok", "live", ...) are also the most common search terms); per-item enrichment fetches the detail page for the JSON-LD `embedURL` (one of three on-site player endpoints: `fypttstr.php`, `fypttjwstr.php`, or `fypttjwstrhls.php`) and `datePublished`, then fetches that embed URL to extract the actual signed `stream.fyptt.to` mp4 or `/hls/*.m3u8` URL (token expires ~2h, no Referer required) for `formats`; thumbnails (`fyptt.to/wp-content/uploads/...webp`) need no proxy; no duration metadata available on listing or detail pages (set to 0); no real uploader/model identity (the `girl-{slug}` CSS class is cosmetic only, not a linkable archive) so `/api/uploaders` is not implemented; `video.url` is the detail page URL (not yt-dlp resolvable directly — the player is sandboxed-iframe-only) so `formats` are populated instead; no proxy needed. | | `fyptt` | `tiktok` | no | no | HTML scraper for fyptt.to (Beaver Builder/WordPress short-form TikTok-style vertical porn); card selector `.fl-post-grid-post[class*="post-ID"]` with `category-{slug}` CSS class doubling as both listing tag and category-archive route; latest feed `/` (page N: `/page/N/`), search `/?s=query` (page N: `/page/N/?s=query`), category archives at bare top-level slugs like `/tiktok-ass/` (12 hardcoded categories exposed via the `categories` filter option, or via an explicit `cat:`/`category:` query prefix — bare keyword queries always go to WordPress search, never a category archive, because the category names ("sexy", "ass", "tiktok", "live", ...) are also the most common search terms); per-item enrichment fetches the detail page for the JSON-LD `embedURL` (one of three on-site player endpoints: `fypttstr.php`, `fypttjwstr.php`, or `fypttjwstrhls.php`) and `datePublished`, then fetches that embed URL to extract the actual signed `stream.fyptt.to` mp4 or `/hls/*.m3u8` URL (token expires ~2h, no Referer required) for `formats`; thumbnails (`fyptt.to/wp-content/uploads/...webp`) need no proxy; no duration metadata available on listing or detail pages (set to 0); no real uploader/model identity (the `girl-{slug}` CSS class is cosmetic only, not a linkable archive) so `/api/uploaders` is not implemented; `video.url` is the detail page URL (not yt-dlp resolvable directly — the player is sandboxed-iframe-only) so `formats` are populated instead; no proxy needed. |
| `freeuseporn` | `fetish-kink` | no | no | Fetish archive pattern. | | `freeuseporn` | `fetish-kink` | no | no | Fetish archive pattern. |
| `hanime` | `hentai-animation` | no | yes | Uses proxied CDN/thumb handling. | | `hanime` | `hentai-animation` | no | yes | Uses proxied CDN/thumb handling. |
| `heavyfetish` | `fetish-kink` | no | no | Direct media handling. | | `heavyfetish` | `fetish-kink` | no | no | Direct media handling. |
| `hdporn92` | `mainstream-tube` | no | no | WordPress retrotube-theme aggregator for hdporn92.com (network studio releases — MyPervyFamily, MYLF, StepSiblings, …). Latest feed `/` (page 1) and `/page/N/` (page N≥2); search `/?s={query}` (page 1) and `/page/N/?s={query}` (page N≥2) — the path with the `s` parameter must come after the `page/N` segment, not before. Pagination is by `article.loop-video` cards; the post id comes from the `post-{N}` CSS class. Two-step enrichment (page URL + Referer-locked m3u8 in `formats`): detail page → `<IFRAME SRC="https://morencius.com/embed/{id}">` (case-insensitive; the page emits an uppercase `<IFRAME SRC=...>` tag) → morencius embed → embedded `eval(function(p,a,c,k,e,d){…}(p,a,c,k.split('|')))` P.A.C.K.E.R. obfuscation block → deobfuscate in pure Rust (reverse-iterate the `c`-word `|`-delimited dictionary, base-`a` numeric tokens, `\b<token>\b` regex substitution) → `"hls4":"<value>"` field in the decoded JS gives the morencius stream URL `https://morencius.com/stream/{token}/{secret}/{expiry_ts}/{file_id}/master.m3u8`; `duration:"<value>"` gives the runtime. `video.url` is the hdporn92 page (no yt-dlp extractor exists for the site); `formats[0]` is the morencius `m3u8` carrying `Referer: https://morencius.com/embed/{id}` + a desktop Chrome `User-Agent` — these headers must travel with the manifest, sub-playlists, and segment requests. **Known limitation:** the morencius m3u8 path contains an `expiry_ts` segment (~12h from page render) — the cache TTL (`cacheDuration: 1800`, same as fyptt) caps the staleness window. Real playback still requires the webview to click through the morencius ad overlay; the manifest itself resolves to a 1280x720 HLS variant via `yt-dlp -j --referer <embed-url>`. No `/api/uploaders` (no uploader identity on the listing). |
| `hentaihaven` | `hentai-animation` | no | no | HTML scraper for hentaihaven.xxx (WordPress/Madara theme), Cloudflare-protected so the provider is gated behind `FLARE_URL` in `skip_reason_for_provider` (mod.rs); the shared requester clears CF directly (wreq Firefox136 emulation currently passes for the listing/search/watch/episode/`player.php` GETs) and falls back to Jina/FlareSolverr. Latest feed `/hentai/page/{N}/`, search `/?s={query}` (search is single-page — page>1 returns empty); listing/search cards link to series watch pages `https://hentaihaven.xxx/watch/{slug}/`. Per-series media resolution (the UUID exists nowhere in page HTML, so enrichment is unavoidable): watch page → episode links `…/watch/{slug}/episode-K` (in `manga-chapters-holder`) → episode page → `<iframe src="…/wp-content/plugins/player-logic/player.php?data=…">``player.php``<meta name="x-secure-token" content="sha512-…">` → decode token (strip `sha512-`, then 3× of rot13→base64-decode, then `JSON.parse`) → `{en, iv, uri, hot_domains, …}` → POST `…/wp-content/plugins/player-logic/api.php` with `action=zarat_get_data_player_ajax&a={en}&b={iv}` (urlencoded; this one POST uses a dedicated `wreq` Chrome137 client, not the shared requester) → `{"status":true,"data":{"sources":[{"src":"…m3u8"}],"isOctopus":bool}}`. A multi-episode series collapses into one `VideoItem` titled `"… (N Episodes)"` with one `m3u8` `VideoFormat` per episode (`format_note`/`format_id` = "Episode K"); each format carries `Referer`/`Origin: https://hentaihaven.xxx` + a Firefox `User-Agent`. `video.url` is the `watch/{slug}/` page (no yt-dlp extractor exists for the site, so `formats` are populated rather than relying on `video.url`). Two CDN shapes are returned: newer content-addressed `octopusmanifest.org/{uuid}/playlist.m3u8` (no token, portable across IPs) and older signed `master-lengs.org/api/v3/hh/{slug}/master.m3u8?hash=…` (~2.5h). **Gotcha:** both CDNs (same IP) aggressively per-IP rate-limit/ban with a TCP RST on 80/443 once tripped — looks like "host down" but is an IP ban; browsers play fine over HTTP/3 (QUIC) while TCP clients (curl/yt-dlp/wreq) get refused, so segment fetches can fail from a tripped IP even though the manifest URL is valid. Tags from the series "Genre(s)" block; `views` from the "Viewed … Total" counter; thumbnails (`img.hentaihaven.xxx`) load directly (no proxy/referer). Resolution is slow (each listing page = ~25 series × multi-episode player-API calls), so the provider is DB-first: it fetches the listing once for the ordered watch URLs, serves already-resolved `VideoItem`s from the `videos` SQLite table instantly (`db::upsert_video` to avoid duplicate-row staleness), and `spawn_refresh`es the whole page in the background (in-memory `VideoCache` soft-TTL 1h / hard-TTL 24h, per-listing in-flight guard). No `/api/uploaders` (no uploader identity), no proxy. | | `hentaihaven` | `hentai-animation` | no | no | HTML scraper for hentaihaven.xxx (WordPress/Madara theme), Cloudflare-protected so the provider is gated behind `FLARE_URL` in `skip_reason_for_provider` (mod.rs); the shared requester clears CF directly (wreq Firefox136 emulation currently passes for the listing/search/watch/episode/`player.php` GETs) and falls back to Jina/FlareSolverr. Latest feed `/hentai/page/{N}/`, search `/?s={query}` (search is single-page — page>1 returns empty); listing/search cards link to series watch pages `https://hentaihaven.xxx/watch/{slug}/`. Per-series media resolution (the UUID exists nowhere in page HTML, so enrichment is unavoidable): watch page → episode links `…/watch/{slug}/episode-K` (in `manga-chapters-holder`) → episode page → `<iframe src="…/wp-content/plugins/player-logic/player.php?data=…">``player.php``<meta name="x-secure-token" content="sha512-…">` → decode token (strip `sha512-`, then 3× of rot13→base64-decode, then `JSON.parse`) → `{en, iv, uri, hot_domains, …}` → POST `…/wp-content/plugins/player-logic/api.php` with `action=zarat_get_data_player_ajax&a={en}&b={iv}` (urlencoded; this one POST uses a dedicated `wreq` Chrome137 client, not the shared requester) → `{"status":true,"data":{"sources":[{"src":"…m3u8"}],"isOctopus":bool}}`. A multi-episode series collapses into one `VideoItem` titled `"… (N Episodes)"` with one `m3u8` `VideoFormat` per episode (`format_note`/`format_id` = "Episode K"); each format carries `Referer`/`Origin: https://hentaihaven.xxx` + a Firefox `User-Agent`. `video.url` is the `watch/{slug}/` page (no yt-dlp extractor exists for the site, so `formats` are populated rather than relying on `video.url`). Two CDN shapes are returned: newer content-addressed `octopusmanifest.org/{uuid}/playlist.m3u8` (no token, portable across IPs) and older signed `master-lengs.org/api/v3/hh/{slug}/master.m3u8?hash=…` (~2.5h). **Gotcha:** both CDNs (same IP) aggressively per-IP rate-limit/ban with a TCP RST on 80/443 once tripped — looks like "host down" but is an IP ban; browsers play fine over HTTP/3 (QUIC) while TCP clients (curl/yt-dlp/wreq) get refused, so segment fetches can fail from a tripped IP even though the manifest URL is valid. Tags from the series "Genre(s)" block; `views` from the "Viewed … Total" counter; thumbnails (`img.hentaihaven.xxx`) load directly (no proxy/referer). Resolution is slow (each listing page = ~25 series × multi-episode player-API calls), so the provider is DB-first: it fetches the listing once for the ordered watch URLs, serves already-resolved `VideoItem`s from the `videos` SQLite table instantly (`db::upsert_video` to avoid duplicate-row staleness), and `spawn_refresh`es the whole page in the background (in-memory `VideoCache` soft-TTL 1h / hard-TTL 24h, per-listing in-flight guard). No `/api/uploaders` (no uploader identity), no proxy. |
| `hentaitv` | `hentai-animation` | no | yes | Next.js hentai site (hentai.tv) backed by a clean JSON API: `GET /api/browse?page=N&sort=<Label>&genres=<ExactName>` (`{videos:[28],total,pages}`, real pagination) and `GET /api/search?q=Q` (`{videos:[...]}`, single-page — `page` is ignored, so page>1 returns empty). Unlike `animeidhentai`, browse honors both `sort` (labels `Most Recent`/`Most Viewed`/`Trending`, mapped from option ids `new`/`views`/`trending`) and `genres` (the **exact case-sensitive** stored genre name, e.g. `Big Boobs`, `incest`), so genre archives go through `/api/browse?genres=` and paginate. The 68-genre catalogue (exact names) is background-loaded from the `/browse` page HTML (`"genres":[{"name","count"}]`, not exposed by the JSON API) and powers the `categories` filter plus keyword→genre routing. Each episode JSON has `slug`, `title`/`ep`, `tags[]`, `views`, `rating` (0-10 → ×10), `duration` ("MM:SS"), `brand` (studio → `uploader`), `thumb`/`backdrop`/`cover` (relative, served from `hentai.tv/uploads/...`, no referer), and `embedUrl=https://nhplayer.com/v/{embedId}/`. `video.url` is the reachable watch page `https://hentai.tv/hentai/{slug}`; `genre:`/`cat:`/`category:` prefixes and bare keywords that exactly match a genre route to the genre archive, everything else to search. Playback shares the **same nhplayer→`r2.1hanime.com` signed-CDN backend as `animeidhentai`**: `/proxy/hentaitv/{embedId}.mp4` is a redirect proxy that replicates nhplayer's PoW+DOM challenge (`player.php``player-core-v2.php``get-video-url-v2.php`, SHA-256-first-byte-zero PoW, ≥700ms dwell, fixed fingerprint) to mint a signed `?verify=<ts>-<sig>` URL — HEAD→200, GET→302 to the CDN URL (cached 150s). The CF wall is JA3-based not IP-based, so the signed URL is verifiable from anywhere with `yt-dlp --impersonate chrome` even though plain `curl`/`wreq` get 403. `src/proxies/hentaitv.rs` is a near-copy of `src/proxies/animeidhentai.rs` (only `SITE_REFERER` differs). No `/api/uploaders` (brand is studio-only). | | `hentaitv` | `hentai-animation` | no | yes | Next.js hentai site (hentai.tv) backed by a clean JSON API: `GET /api/browse?page=N&sort=<Label>&genres=<ExactName>` (`{videos:[28],total,pages}`, real pagination) and `GET /api/search?q=Q` (`{videos:[...]}`, single-page — `page` is ignored, so page>1 returns empty). Unlike `animeidhentai`, browse honors both `sort` (labels `Most Recent`/`Most Viewed`/`Trending`, mapped from option ids `new`/`views`/`trending`) and `genres` (the **exact case-sensitive** stored genre name, e.g. `Big Boobs`, `incest`), so genre archives go through `/api/browse?genres=` and paginate. The 68-genre catalogue (exact names) is background-loaded from the `/browse` page HTML (`"genres":[{"name","count"}]`, not exposed by the JSON API) and powers the `categories` filter plus keyword→genre routing. Each episode JSON has `slug`, `title`/`ep`, `tags[]`, `views`, `rating` (0-10 → ×10), `duration` ("MM:SS"), `brand` (studio → `uploader`), `thumb`/`backdrop`/`cover` (relative, served from `hentai.tv/uploads/...`, no referer), and `embedUrl=https://nhplayer.com/v/{embedId}/`. `video.url` is the reachable watch page `https://hentai.tv/hentai/{slug}`; `genre:`/`cat:`/`category:` prefixes and bare keywords that exactly match a genre route to the genre archive, everything else to search. Playback shares the **same nhplayer→`r2.1hanime.com` signed-CDN backend as `animeidhentai`**: `/proxy/hentaitv/{embedId}.mp4` is a redirect proxy that replicates nhplayer's PoW+DOM challenge (`player.php``player-core-v2.php``get-video-url-v2.php`, SHA-256-first-byte-zero PoW, ≥700ms dwell, fixed fingerprint) to mint a signed `?verify=<ts>-<sig>` URL — HEAD→200, GET→302 to the CDN URL (cached 150s). The CF wall is JA3-based not IP-based, so the signed URL is verifiable from anywhere with `yt-dlp --impersonate chrome` even though plain `curl`/`wreq` get 403. `src/proxies/hentaitv.rs` is a near-copy of `src/proxies/animeidhentai.rs` (only `SITE_REFERER` differs). No `/api/uploaders` (brand is studio-only). |
| `homoxxx` | `gay-male` | no | no | Gay category grouping example. | | `homoxxx` | `gay-male` | no | no | Gay category grouping example. |
| `hotbunny` | `ai` | no | yes (thumbs only) | JSON-API provider for hotbunny.ai (AI-generated hentai community). Feeds: `GET /api/post/feed?limit=N` (hot), `/api/post/feed/popular`, `/api/post/feed/recents`, `/api/post/category/{cuid}?limit=N`, `/api/post/search?query=Q&limit=N`, `/api/post/author/{name}/all?limit=N`. Pagination: fetch `page × per_page` items in one call and slice (cursor-based API, but limit works up to 200 total). Key fields: `id`, `imageUrl` (relative thumbnail path on CDN), `authorName`, `likes` (→ `views`), `sharedVideos[].videoUrl` (first video path, relative to CDN), `width`/`height`, `galleries[].label` (used as title prefix). 15 hardcoded categories from `/api/categories` (stable CUIDs). `video.url` is the post page `https://hotbunny.ai/post/{id}` (React SPA, not yt-dlp-resolvable); `formats[0].url` = `https://assets.hotbunny.ai/{sharedVideos[0].videoUrl}` with `Referer: https://hotbunny.ai/` header. `assets.hotbunny.ai` is Cloudflare bot-management protected (returns 404 to plain curl/yt-dlp; iOS AVFoundation passes with real Apple TLS — no proxy wrapping needed for video). Thumbnails proxied via `/proxy/hotbunny-thumb/...` to inject the required `Referer`. Supports `uploader:`/`author:`, `cat:`/`category:`/`#tag` query shortcuts, `categories` filter option, and `sort` option (feed/popular/recent). | | `hotbunny` | `ai` | no | yes (thumbs only) | JSON-API provider for hotbunny.ai (AI-generated hentai community). Feeds: `GET /api/post/feed?limit=N` (hot), `/api/post/feed/popular`, `/api/post/feed/recents`, `/api/post/category/{cuid}?limit=N`, `/api/post/search?query=Q&limit=N`, `/api/post/author/{name}/all?limit=N`. Pagination: fetch `page × per_page` items in one call and slice (cursor-based API, but limit works up to 200 total). Key fields: `id`, `imageUrl` (relative thumbnail path on CDN), `authorName`, `likes` (→ `views`), `sharedVideos[].videoUrl` (first video path, relative to CDN), `width`/`height`, `galleries[].label` (used as title prefix). 15 hardcoded categories from `/api/categories` (stable CUIDs). `video.url` is the post page `https://hotbunny.ai/post/{id}` (React SPA, not yt-dlp-resolvable); `formats[0].url` = `https://assets.hotbunny.ai/{sharedVideos[0].videoUrl}` with `Referer: https://hotbunny.ai/` header. `assets.hotbunny.ai` is Cloudflare bot-management protected (returns 404 to plain curl/yt-dlp; iOS AVFoundation passes with real Apple TLS — no proxy wrapping needed for video). Thumbnails proxied via `/proxy/hotbunny-thumb/...` to inject the required `Referer`. Supports `uploader:`/`author:`, `cat:`/`category:`/`#tag` query shortcuts, `categories` filter option, and `sort` option (feed/popular/recent). |
| `hornyleak` | `amateur-homemade` | no | no | HTML scraper for hornyleak.tv (KVS / Kernel Video Sharing — leaked-amateur and OnlyFans-creator clips). Listing cards `div.thumb.thumb_rel.item` (24/page) with `a.img[href*="/video/"]` (href→`/video/{id}/{slug}/`, `title` attr→title), `img.thumb` (`data-webp` preferred, falls back to `data-src`/`src`), `div.duration` (text → seconds), `div.thumb-item > span` views text, `a.avatar` uploader (often empty — `-` placeholder on the listing), `div.rating` percent; `/videos/{N}/` is the path-based pagination for `Latest` and the per-archive pages. The detail page is yt-dlp-resolvable (page URL → generic extractor → `https://www.hornyleak.tv/video/{id}/{slug}/` → 1 format), so `video.url` is set to that detail page (NOT a proxy URL) and per-card enrichment is optional — but most clients want a stable HLS link, so the provider enriches each card to populate `formats[]`. Two-step media resolution chain: (1) `GET /embed/{id}/` (Referer: detail page) → regex on `hdplayer\.gives/embed/([A-Za-z0-9]+)` to find the per-video hash; (2) `GET https://hdplayer.gives/embed/{hash}/` (Referer: hornyleak.tv embed URL) → regex on `file:"(https://hdplayer\.gives/list/enc[^"]+)"` to extract the signed list/enc token URL. The enc URL is a real m3u8 with three variants (FULLHD 1920x1080 / HD 1280x720 / SD 842x480) pointing to `https://hdplayer.gives/m3u/...` sub-playlists and `https://swa3.top/upload/videos/...` segments. `formats[0]` is the m3u8 with `Referer: https://hdplayer.gives/` and the hornyleak.tv user-agent. `video.url` is left as the page URL (yt-dlp's generic extractor resolves it to 1 format on a non-JA3-blocked client; the HLS in `formats[]` is the primary path for clients that honor `http_headers`). `sort` option: new/popular/rated/viewed/longest (path-based: `/latest-updates/`, `/most-popular/`, `/top-rated/`, `/most-viewed/`, `/longest/`). `cat:`/`category:`/`tag:`/`model:`/`uploader:`/`pornstar:`/`star:` query prefixes route to the matching archive (`/categories/{slug}/`, `/tags/{slug}/`, `/models/{slug}/`). Search is path-1 `GET /search/?q={query}` (returns search results HTML directly) and path-≥2 `GET /search/?q={query}&mode=async&function=get_block&block_id=custom_list_videos_videos_list_search_result&from_videos={N}&from_albums={N}` (XHR pagination — the search results page itself is paged via the `custom_list_videos_videos_list_search_result` block). Background-loaded category filter (≈ 100 entries scraped from `/categories/`) via the shared `Arc<RwLock<Vec<FilterOption>>>` pattern, with title→slug map for bare-query short-circuit to a category archive when the keyword exactly matches a category title. Tags from detail `.tags-row a, .categories-row a`; thumbnails `https://www.hornyleak.tv/contents/videos_screenshots/{N}00/{id}/preview.jpg` (direct, no proxy/referer); no proxy route needed. `uploaderId` is namespaced as `hornyleak:<model-slug>`. **Known limitation:** the `hdplayer.gives/list/enc...` token is request-bound to the player-page TLS context; yt-dlp's generic extractor gets HTTP 404 when probing the URL directly with curl_cffi, so the validator's `_CF_PROTECTED_HOSTS` allowlist includes `hdplayer.gives` to downgrade the 404 to a warning (the HLS body itself returns 200 with valid m3u8 to plain curl, and iOS AVFoundation + Hot Tub clients play it directly through the `formats[]` entry). No `/api/uploaders` profile (KVS uploader pages exist but have no public stats). |
| `hqporner` | `studio-network` | no | yes | Uses thumb and redirect proxy helpers. | | `hqporner` | `studio-network` | no | yes | Uses thumb and redirect proxy helpers. |
| `hsex` | `chinese` | yes | no | Strong template for tags, uploaders, and direct HLS formats. | | `hsex` | `chinese` | yes | no | Strong template for tags, uploaders, and direct HLS formats. |
| `hypnotube` | `fetish-kink` | no | no | Fetish/tube hybrid. | | `hypnotube` | `fetish-kink` | no | no | Fetish/tube hybrid. |
@@ -35,6 +38,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `okporn` | `mainstream-tube` | no | no | Simple mainstream archive. | | `okporn` | `mainstream-tube` | no | no | Simple mainstream archive. |
| `okxxx` | `mainstream-tube` | no | no | Mainstream search/archive pattern. | | `okxxx` | `mainstream-tube` | no | no | Mainstream search/archive pattern. |
| `omgxxx` | `studio-network` | yes | no | Best template for sites/networks/stars filter catalogs. | | `omgxxx` | `studio-network` | yes | no | Best template for sites/networks/stars filter catalogs. |
| `notfans` | `onlyfans` | no | no | HTML scraper for notfans.com (OnlyFans / Fansly / Patreon / Manyvids leak aggregator); site is behind Cloudflare but does NOT issue a JS challenge to direct requests (the shared `Requester` with Chrome 120 emulation gets a 200), so detail-page enrichment is safe and there's no `FLARE_URL` gate. Card parser splits the listing on the literal `<div class="item ">` delimiter (the two trailing spaces match the card class exactly and skip the sponsored `class="item avd"` variant — homoxxx uses the same convention); per card: `<a href="…/videos/{id}/{slug}/" title="…">` for the id/url/title, plus `<img class="thumb " src="…/contents/videos_screenshots/{id3}/{id}/320x180/N.jpg">` for the listing thumb. Feeds: latest `/latest-updates/{N}/` (23/page, last page 30990), most-viewed `/most-popular/{N}/` (24/page), top-rated `/top-rated/{N}/` (24/page), and period tops `/day/{N}/`, `/week/{N}/`, `/month/{N}/`, `/year/{N}/` (all 12-24/page); search `/search/{query}/{N}/`; tag `/tags/{tag}/{N}/` reachable via the `tag:`/`category:`/`tags:` query prefix. Per-card enrichment (page 1 only, `buffer_unordered(4)`, 8s timeout) fetches the detail page `…/videos/{id}/{slug}/` and pulls: `Duration: <em>HH:MM:SS</em>``parse_time_to_seconds`, `Views: <em>N</em>` → u32, `Tags: <a href="/tags/…">name</a>…</div>` block (regex over `/tags/…/>(name)</a>`), `Submitted: <em>N (minute|hour|day|week|month|year)s? ago</em>` → relative unix timestamp (`Yesterday`/`just now` are handled), `<meta property="og:image" content="…/preview.jpg">` for `preview` (the 720p still used by the player), and the first `Download:` link matching `get_file/6/{hash}/{id3}/{id}/{id}.mp4/?…` for the format URL (a `Referer: https://notfans.com/` and a matching `User-Agent` are attached as `http_headers`; the URL serves a 302 to `big1.phoenixcdn.lol/remote_control.php?…&file=…/{id}.mp4` which 200s `Content-Type: video/mp4` with the bytes — the 302 is followed automatically by clients, no proxy needed). 720p is the only available quality (the site shows one `MP4 720p, N Mb` row). Listing pages are cached 15 min in `VideoCache`. The site has no `/uploaders/{name}/` route and no `categories` system (the tag cloud is a 50-entry footer list dominated by stop-words like "a"/"and"/"the", and `/categories/` is a 0-item stub), so no `/api/uploaders` and no category/tag filter option. No proxy. |
| `paradisehill` | `mainstream-tube` | no | no | Simple page scraper. | | `paradisehill` | `mainstream-tube` | no | no | Simple page scraper. |
| `perfectgirls` | `studio-network` | no | no | Studio archive. | | `perfectgirls` | `studio-network` | no | no | Studio archive. |
| `perverzija` | `studio-network` | no | no | WordPress (Generatepress/quick-view) HTML scraper for tube.perverzija.com (mirrors top-tier studio releases: MommysGirl, PureTaboo, VXN, AdultTime, etc). Default feed `/` (page N: `/page/N/`), `featured=featured` option swaps in `featured-scenes/` prefix before the page segment. Search is native WordPress `/?s=query` (page N: `/page/N/?s=query`), but `tag:`/`stars:`/`studio:`/`genre:` query prefixes and a background-learned title→slug map (populated from `studio`/`stars`/`tag`/`genre` links seen on every listing/detail page, normalized lowercase) route a bare keyword straight to the matching `/{kind}/{slug}/page/N/` archive when it matches — e.g. `milf` resolves via the learned `stars-milf` class to `/stars/milf/` rather than falling back to `?s=milf`. Listing cards (`class="video-item post..."`) carry everything needed (`data-url` = canonical WP detail page, `data-embed`-encoded `<iframe src="…xtremestream.xyz/player/index.php?data=ID">`, `tag-*`/`stars-*` CSS classes, `class="time_dur"` duration, studio link) so the default/tag/page feeds need no per-item enrichment; only the generic `?s=` search path is card-light (no embed/tags inline) and fetches each detail page concurrently (`futures::join_all`) for the iframe src plus `Studio:`/`Stars:`/`Tags:`/`Genres:` `<strong>` blocks — resolved detail data is cached in the `videos` SQLite table (`db::insert_video`/`get_video`) keyed by the WP page URL to skip re-fetching on repeat searches. `video.url` is the WordPress detail page (`data-url`/card href) — confirmed NOT yt-dlp-resolvable (the player loads via the quick-view AJAX iframe, not static HTML) — so `formats` carries the real media: the embed `index.php` path is rewritten to `xs1.php` (returns an HLS `.m3u8` master) and requires `Referer: https://xtremestream.xyz/` on every request (segments and master alike) or the host CDN returns `403`; **regression note**: an earlier revision set `video.url` to this same `xs1.php` media link, which then 403'd in `check.py`/any plain `GET` because the bare `url` field carries no header contract — fixed by separating `video.url` (page) from `formats[0].url` (media+Referer). Thumbnails (`tube.perverzija.com/wp-content/uploads/...jpg`) load directly, no proxy/referer. No `/api/uploaders` (studio/stars are folded into `tags`, no dedicated profile page schema confirmed). No proxy needed. | | `perverzija` | `studio-network` | no | no | WordPress (Generatepress/quick-view) HTML scraper for tube.perverzija.com (mirrors top-tier studio releases: MommysGirl, PureTaboo, VXN, AdultTime, etc). Default feed `/` (page N: `/page/N/`), `featured=featured` option swaps in `featured-scenes/` prefix before the page segment. Search is native WordPress `/?s=query` (page N: `/page/N/?s=query`), but `tag:`/`stars:`/`studio:`/`genre:` query prefixes and a background-learned title→slug map (populated from `studio`/`stars`/`tag`/`genre` links seen on every listing/detail page, normalized lowercase) route a bare keyword straight to the matching `/{kind}/{slug}/page/N/` archive when it matches — e.g. `milf` resolves via the learned `stars-milf` class to `/stars/milf/` rather than falling back to `?s=milf`. Listing cards (`class="video-item post..."`) carry everything needed (`data-url` = canonical WP detail page, `data-embed`-encoded `<iframe src="…xtremestream.xyz/player/index.php?data=ID">`, `tag-*`/`stars-*` CSS classes, `class="time_dur"` duration, studio link) so the default/tag/page feeds need no per-item enrichment; only the generic `?s=` search path is card-light (no embed/tags inline) and fetches each detail page concurrently (`futures::join_all`) for the iframe src plus `Studio:`/`Stars:`/`Tags:`/`Genres:` `<strong>` blocks — resolved detail data is cached in the `videos` SQLite table (`db::insert_video`/`get_video`) keyed by the WP page URL to skip re-fetching on repeat searches. `video.url` is the WordPress detail page (`data-url`/card href) — confirmed NOT yt-dlp-resolvable (the player loads via the quick-view AJAX iframe, not static HTML) — so `formats` carries the real media: the embed `index.php` path is rewritten to `xs1.php` (returns an HLS `.m3u8` master) and requires `Referer: https://xtremestream.xyz/` on every request (segments and master alike) or the host CDN returns `403`; **regression note**: an earlier revision set `video.url` to this same `xs1.php` media link, which then 403'd in `check.py`/any plain `GET` because the bare `url` field carries no header contract — fixed by separating `video.url` (page) from `formats[0].url` (media+Referer). Thumbnails (`tube.perverzija.com/wp-content/uploads/...jpg`) load directly, no proxy/referer. No `/api/uploaders` (studio/stars are folded into `tags`, no dedicated profile page schema confirmed). No proxy needed. |

View File

@@ -2,6 +2,40 @@
This is the implementation checklist for adding a working channel with the least guessing. This is the implementation checklist for adding a working channel with the least guessing.
## Best Practices (Living)
These rules cut across every step below. They are intentionally grouped at the top so the rest of the playbook can reference them, and they are **living** — add, refine, or reorder as the codebase learns new failure modes. The provider catalog (`docs/provider-catalog.md`) is the ground truth for which patterns have actually held up in production; if a rule here contradicts a catalog note, the catalog wins and this list gets updated.
### Investigation
- **Always use an up-to-date client with a real TLS fingerprint when investigating a site.** A `curl` paste from a terminal looks fine and then 403s the moment the real fetcher goes near it. Use the same `wreq` emulation stack the server ships with (currently `Emulation::Firefox151` in the shared `Requester`, `Emulation::Firefox136` / `Chrome120` in older per-provider paths) and confirm a plain `wreq` GET against the live host returns 200 *before* trusting any URL or header you read off a page. If the investigation client differs from the production client, every "obvious" header you copy is suspect.
- Start from the listing page, not the detail page or the network tab of a single video. The listing tells you the real card shape, pagination, sort, and tag surfaces. Detail pages lie about what is enumerable.
- Confirm each URL shape with at least two pages: home, search page 2, one tag archive, one uploader archive, one detail. A pattern that holds for one page often breaks on the second.
- Record a real browser session only when `wreq` cannot reach the site. If the shared requester plus Jina/FlareSolverr can fetch the page, the production path is the one you are investigating.
### Parser Discipline
- Prefer `serde_json` over regex/HTML scraping whenever the site exposes a JSON shape (Next.js `__NEXT_DATA__`, JSON-LD, hydration blobs, `RSC` payloads). When the site ships an API, scrape the API, not the rendered DOM.
- Treat card metadata as the source of truth for `id`, `title`, `url`, `thumb`, `duration`. Only enrich detail pages for fields the card does not expose, and bound the concurrency (`futures::stream` + `buffer_unordered`).
- Keep `title` text, `id` slug, and uploader identity as separate fields. A mushed title is a debugging nightmare.
### Networking
- Use the shared `Requester` from `ServerOptions`. Local clients drop cookies, lose Burp proxying, and bypass the Jina / FlareSolverr fallbacks. The only legitimate reason to build a fresh `wreq::Client` is a one-off TLS requirement that the shared emulation cannot meet (e.g. a specific Chrome JA3 that the r2.1hanime.com / nhplayer path needs).
- After every fix to a CF/JA3 block, re-validate with `wreq` on the production emulation, not with `curl`. A/B probe with both the old and new fingerprint so you do not confuse a coincidence with a fix.
### Output Shape
- Use `formats` for real media URLs (HLS, multi-quality, signed token), and keep `video.url` on a stable page URL. Never set `video.url` to a tokenized stream that needs a `Referer` to play — the `url` field has no header contract.
- If thumbnails need a `Referer` and the player has no per-thumb header mechanism, proxy them via `/proxy/<id>-thumb/...`. The same applies to manifests with relative URIs.
- Keep filter `id` values stable and machine-targeted. Display `title`s change; ids route.
### Operational
- Add new providers to `build.rs` first, then write the file. A missing entry compiles fine and silently drops the channel.
- After any code change, run `cargo check -q` once for the full build, then `HOT_TUB_PROVIDER=<id> cargo check -q` for the single-provider build. If only the second runs, you will not catch a regression that lives in another provider.
- After modifying the code, refresh the knowledge graph with `graphify update .` so the next investigator can find what you just added without re-deriving it.
## Definition Of Done ## Definition Of Done
A provider is not done when it compiles. It is done when: A provider is not done when it compiles. It is done when:

File diff suppressed because it is too large Load Diff

709
src/providers/fapello.rs Normal file
View File

@@ -0,0 +1,709 @@
// Fapello video provider.
//
// Fapello.com is primarily a model-by-model photo aggregator (the bulk of the
// site — `/`, `/new/`, `/hot/`, `/trending/`, `/tags/`, `/search_v2/` — is all
// photos hosted at `fapello.com/content/{a}/{b}/{model}/{N}/{model}_{NNNN}.jpg`).
// The video section lives at `/videos/` and is much smaller (~6 cards per page),
// with pagination via `GET /ajax/videos/page-{N}/` returning an HTML fragment
// of cards (each is a `video/new/{id}/` link with a thumbnail and the model
// slug visible on the card).
//
// The interesting bit: each video *detail page* (`/video/new/{id}/{slug}/`)
// embeds a complete `var BOOT = { items: [...], ... }` JSON block on first
// load. Every item in `BOOT.items` carries the resolved CDN media URL
// (`video_url: https://cdn.fapello.com/content/.../{N}/{model}_{NNNN}.mp4`),
// `poster_url` (the matching .jpg thumbnail), `duration` as "HH:MM:SS", the
// uploader's `name`/`model_url`, `count_likes`, `width`/`height`/`aspect`,
// `orientation` ("vertical" vs "landscape"), and the `slug` used to build
// the canonical detail URL. This single JSON block is the source of truth
// for every field on the VideoItem, so the provider does one BOOT-parsing
// detail fetch per listing card.
//
// Cloudflare gated: every path on fapello.com except `/` and the `/feed/{id}/`
// post pages returns a hard 403 to plain curl/chrome, including the videos
// listings. The shared `Requester` auto-falls back to the Jina mirror
// (`r.jina.ai/http://fapello.com/...`) on a CF challenge, and Jina preserves
// both the listing HTML cards and the `<script>var BOOT = {...}</script>`
// block (Jina is content-preserving on raw HTML inside <script> tags), so
// the requester path works without any provider-side special-casing.
//
// Media: `cdn.fapello.com/.../*.mp4` is a direct, no-auth, no-Cookies HTTP
// range-serving CDN — confirmed via `curl -I` (HTTP 200, `accept-ranges:
// bytes`, `Content-Type: video/mp4`). Thumbs at `fapello.com/content/.../*.jpg`
// also serve direct. No local `/proxy/...` route needed.
//
// Playback: the detail page URL (`fapello.com/video/new/{id}/{slug}/`) is
// NOT yt-dlp-resolvable (no extractor exists), so `video.url` is the detail
// page and `formats[0].url` carries the direct mp4 with a `Referer:
// https://fapello.com/` header (the CDN is happy without one but Hot Tub
// clients apply the format's `http_headers` across the HLS/MP4 playback
// session, so including the Referer is safe and matches the site context).
//
// Known limitations (deferred, not implemented in v1):
// - Search: fapello's `/search_v2/?ajax=1&q=...` returns photo *models*,
// not videos. No native "videos containing keyword" search exists.
// - Popular: `/popular_videos/{twelve_hours,month,all_time}/` is JS-rendered
// and the AJAX body returns only ad/photo placeholders, no video cards.
// - Tags: the `/tags/{slug}/` archive is photo-only; no video tag pages.
// - `/api/uploaders`: fapello has stable model slugs (`/seeyanever/`) but no
// dedicated "videos by this uploader" feed — only the mixed photo+video
// model page, which is out of scope for a video channel.
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error_background, requester_or_default};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use regex::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::time::Duration;
use std::vec;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "onlyfans",
tags: &["onlyfans", "leaks", "model", "creator"],
};
const CHANNEL_ID: &str = "fapello";
const BASE_URL: &str = "https://fapello.com";
// Listing endpoints (Cloudflare-gated; reached via the shared requester's
// Jina mirror fallback).
const VIDEOS_INDEX: &str = "https://fapello.com/videos/";
const VIDEOS_AJAX_PAGE_FMT: &str = "https://fapello.com/ajax/videos/page-{}/";
// Per-item detail endpoint. The Jina-mirrored body is the source of the
// `var BOOT = { items: [...] }` JSON block that contains the resolved CDN
// media URL, duration, uploader, etc.
const DETAIL_URL_FMT: &str = "https://fapello.com/video/new/{}/";
// Bounded concurrency for the per-card BOOT enrichment fetch. Jina rate-limits
// per IP, so a small pool keeps us under that limit while still amortizing
// the round-trip.
const ENRICH_CONCURRENCY: usize = 4;
error_chain! {
foreign_links {
Io(std::io::Error);
HttpRequest(wreq::Error);
Json(serde_json::Error);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
/// Lightweight card extracted from the listing HTML. The listing never
/// has the resolved media URL or duration; those come from the BOOT JSON
/// on the per-card detail page.
#[derive(Debug, Clone)]
struct ListingCard {
id: String,
detail_url: String,
thumb: String,
model_slug: String,
model_name: String,
}
/// Fields we care about out of the per-detail-page `var BOOT = { ... }`
/// JSON block. Everything has a default so a partial/missing field
/// doesn't blow up the whole enrichment.
#[derive(Debug, Deserialize, Default, Clone)]
struct BootItem {
#[serde(default)]
id: u64,
#[serde(default)]
model_id: u64,
#[serde(default)]
model_url: String,
#[serde(default)]
filename_id: u64,
#[serde(default)]
name: String,
#[serde(default)]
avatar_url: String,
#[serde(default)]
count_likes: u64,
#[serde(default, rename = "type")]
item_type: String,
#[serde(default)]
video_url: String,
#[serde(default)]
poster_url: String,
#[serde(default)]
image_url: String,
#[serde(default)]
duration: String, // "HH:MM:SS"
#[serde(default)]
width: u32,
#[serde(default)]
height: u32,
#[serde(default)]
aspect: f32,
#[serde(default)]
orientation: String,
#[serde(default)]
slug: String,
#[serde(default)]
ext: String,
#[serde(default)]
tags: Vec<BootTag>,
#[serde(default)]
random_tags: Vec<BootTag>,
#[serde(default)]
title_str: String,
}
#[derive(Debug, Deserialize, Default, Clone)]
struct BootTag {
#[serde(default)]
url: String,
#[serde(default)]
name: String,
}
#[derive(Debug, Deserialize)]
struct BootPayload {
#[serde(default)]
items: Vec<BootItem>,
#[serde(default)]
next_page: u32,
#[serde(default)]
page_size: u32,
}
#[derive(Debug, Clone)]
pub struct FapelloProvider {
url: String,
}
impl FapelloProvider {
pub fn new() -> Self {
Self {
url: BASE_URL.to_string(),
}
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
Channel {
id: CHANNEL_ID.to_string(),
name: "Fapello".to_string(),
description: "Leaked OnlyFans-style videos from fapello.com".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=fapello.com".to_string(),
status: "active".to_string(),
categories: vec![],
// Fapello's video section has only one chronological feed; the
// site has no working "popular"/"trending" videos index (the
// corresponding routes are JS-rendered with no usable card HTML
// in the response body). Keep `sort` to one option to avoid
// promising filters we cannot honor.
options: vec![ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Sort the videos".to_string(),
systemImage: "list.number".to_string(),
colorName: "blue".to_string(),
options: vec![FilterOption {
id: "new".to_string(),
title: "New".to_string(),
}],
multiSelect: false,
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
}
}
/// Resolves a `page` value (string or int) to a 1-based u8. Fapello's
/// video listing caps at a small max (the page count is implicit in the
/// `next_page` field on each detail page's BOOT JSON, but the AJAX body
/// just stops returning new cards once the stream is exhausted — we
/// don't try to bound it client-side).
fn parse_page(page: &str) -> u8 {
page.parse::<u8>().unwrap_or(1).max(1)
}
/// URL for the listing of the given page.
fn listing_url(page: u8) -> String {
if page <= 1 {
VIDEOS_INDEX.to_string()
} else {
VIDEOS_AJAX_PAGE_FMT.replace("{}", &page.to_string())
}
}
/// Parse the listing HTML into cards. Cards look like:
///
/// ```html
/// <a href="https://fapello.com/video/new/32034817/">
/// <img src="https://fapello.com/content/s/e/seeyanever/1000/seeyanever_0073_300px.jpg" ...>
/// </a>
/// <a href="https://fapello.com/seeyanever/" class="...">
/// ...
/// <div> seeyanever </div>
/// </a>
/// ```
///
/// We anchor on `video/new/{id}/` hrefs to pull each card block and then
/// pick the *next* `<img ...>` + the *next* `/{model_slug}/` link text.
fn parse_listing_cards(html: &str) -> Vec<ListingCard> {
let mut cards = Vec::new();
// Match every video card anchor. The href carries the numeric id.
let anchor_re = match Regex::new(
r#"href="https://fapello\.com/video/new/(\d+)/""#,
) {
Ok(re) => re,
Err(e) => {
report_provider_error_background(
CHANNEL_ID,
"parse_listing.regex",
&e.to_string(),
);
return cards;
}
};
// We need a "rest of the card" after the anchor href to pull the
// thumbnail and model. The simplest reliable approach: walk by
// index, slice each card by a fixed number of bytes after the
// matched anchor (the cards are large HTML blocks — 2-3 KB — so a
// generous window is safe).
for cap in anchor_re.captures_iter(html) {
let id = match cap.get(1) {
Some(m) => m.as_str().to_string(),
None => continue,
};
// Skip duplicates (each card link is repeated twice in the
// markup — once for the thumbnail `<a>`, once for the play-icon
// overlay `<a>`).
if cards.iter().any(|c: &ListingCard| c.id == id) {
continue;
}
let match_end = cap.get(0).map(|m| m.end()).unwrap_or(0);
let tail = &html[match_end..html.len().min(match_end + 4096)];
// Thumbnail: the FIRST `<img ... src="...jpg...">` after the anchor.
// We deliberately skip `assets/images/icon-play.svg` (the overlay
// play button) by requiring `fapello.com/content/` in the URL.
let thumb_re = match Regex::new(
r#"<img\s+src="(https://fapello\.com/content/[^"]+\.jpg)""#,
) {
Ok(re) => re,
Err(_) => continue,
};
let thumb = thumb_re
.captures(tail)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.unwrap_or_default();
if thumb.is_empty() {
continue;
}
// Model slug: the FIRST `<a href="https://fapello.com/{slug}/"` after
// the anchor, where `{slug}` is neither `video`, `content`, nor any
// of the known top-level routes.
let slug_re = match Regex::new(
r#"href="https://fapello\.com/([a-zA-Z0-9_-]+)/""#,
) {
Ok(re) => re,
Err(_) => continue,
};
let mut model_slug = String::new();
let mut model_name = String::new();
for slug_cap in slug_re.captures_iter(tail) {
let slug = match slug_cap.get(1) {
Some(m) => m.as_str().to_string(),
None => continue,
};
// Skip non-model anchors (link farm / corner badges / nav).
if slug == "video"
|| slug == "content"
|| slug == "assets"
|| slug == "data"
|| slug == "videos"
{
continue;
}
model_slug = slug.clone();
// The model name is the visible text of the next `<div> {slug} </div>`
// — typically appears as a bare slug too, but we prefer the
// human-readable variant from the BOOT JSON if available.
let name_re = match Regex::new(
r"<div>\s*([^<>{]*?)\s*</div>",
) {
Ok(re) => re,
Err(_) => break,
};
if let Some(nc) = name_re.captures(&tail[slug_cap.get(0).unwrap().end()..]) {
let candidate = nc.get(1).map(|m| m.as_str().trim().to_string()).unwrap_or_default();
if !candidate.is_empty() {
model_name = candidate;
} else {
model_name = slug.clone();
}
} else {
model_name = slug.clone();
}
break;
}
if model_slug.is_empty() {
// Fall back to the slug built from the thumb URL path
// (`.../{model}/{N}/{model}_{NNNN}_300px.jpg`).
if let Some(slug_from_thumb) = thumb
.split('/')
.nth(5)
{
model_slug = slug_from_thumb.to_string();
model_name = model_slug.clone();
}
}
cards.push(ListingCard {
id: id.clone(),
detail_url: format!("{}{}/", DETAIL_URL_FMT.replace("{}", &id), model_slug),
thumb: thumb
.replace("_300px.jpg", ".jpg"),
model_slug,
model_name,
});
}
cards
}
/// Parse the `var BOOT = {...};` block out of the Jina-mirrored detail
/// page body. We anchor on the literal `var BOOT = ` prefix and the
/// terminating `;` — this is content-preserved inside `<script>...</script>`
/// by Jina, so a string slice is enough (no DOM needed).
fn parse_boot_block(html: &str) -> Option<BootPayload> {
let start_marker = "var BOOT = ";
let start = html.find(start_marker)? + start_marker.len();
// Walk to the matching `;` terminator. The BOOT JSON never contains
// a literal `;` inside any string value (verified across multiple
// detail pages), so a plain byte search is safe.
let end_rel = html[start..].find(';')?;
let body = &html[start..start + end_rel];
match serde_json::from_str::<BootPayload>(body) {
Ok(p) => Some(p),
Err(e) => {
report_provider_error_background(
CHANNEL_ID,
"parse_boot.json",
&e.to_string(),
);
None
}
}
}
/// Find the BOOT item that matches `id` (the listing card's numeric id).
/// The active video is at `active_idx` and the rest are "up next" — we
/// match by id first, fall back to active_idx, then the first item.
fn find_boot_item<'a>(payload: &'a BootPayload, id: &str) -> Option<&'a BootItem> {
let target: u64 = id.parse().unwrap_or(0);
if let Some(item) = payload.items.iter().find(|i| i.id == target) {
return Some(item);
}
payload.items.first()
}
/// "HH:MM:SS" → seconds. Fapello's BOOT.duration is always zero-padded
/// (e.g. "00:00:13"), but be defensive about stray whitespace.
fn parse_hms_duration(s: &str) -> u32 {
let s = s.trim();
if s.is_empty() {
return 0;
}
let parts: Vec<&str> = s.split(':').collect();
match parts.len() {
3 => {
let h: u32 = parts[0].parse().unwrap_or(0);
let m: u32 = parts[1].parse().unwrap_or(0);
let sec: u32 = parts[2].parse().unwrap_or(0);
h * 3600 + m * 60 + sec
}
2 => {
let m: u32 = parts[0].parse().unwrap_or(0);
let sec: u32 = parts[1].parse().unwrap_or(0);
m * 60 + sec
}
1 => parts[0].parse().unwrap_or(0),
_ => 0,
}
}
/// Build a VideoItem from a listing card and its enriched BOOT entry.
/// Falls back to listing-only fields (id/url/thumb/model) when the BOOT
/// fetch returned nothing usable — keeps the page flowing instead of
/// dropping a card because of one bad detail fetch.
fn build_item(card: &ListingCard, boot: Option<&BootItem>) -> VideoItem {
// Media URL: prefer the BOOT-resolved CDN mp4. The CDN is the
// `cdn.fapello.com/content/.../{N}/{model}_{NNNN}.mp4` form (not
// `fapello.com`); if the BOOT returned an `fapello.com/...` URL we
// keep it as-is (still playable direct).
let media_url = boot
.map(|b| b.video_url.clone())
.filter(|s: &String| !s.is_empty());
let thumb = boot
.map(|b| b.poster_url.clone())
.filter(|s: &String| !s.is_empty())
.unwrap_or_else(|| card.thumb.clone());
let title = boot
.map(|b| {
if !b.title_str.is_empty() {
b.title_str.clone()
} else if !b.name.is_empty() {
b.name.clone()
} else {
card.model_name.clone()
}
})
.unwrap_or_else(|| card.model_name.clone());
let uploader = boot
.map(|b| {
if !b.name.is_empty() {
b.name.clone()
} else {
card.model_name.clone()
}
})
.unwrap_or_else(|| card.model_name.clone());
let model_url_slug = boot
.map(|b| b.model_url.clone())
.filter(|s: &String| !s.is_empty())
.unwrap_or_else(|| card.model_slug.clone());
let duration = boot
.map(|b| Self::parse_hms_duration(&b.duration))
.unwrap_or(0);
let views = boot.map(|b| b.count_likes).unwrap_or(0);
let aspect = boot.and_then(|b| {
if b.aspect > 0.0 {
Some(b.aspect)
} else if b.width > 0 && b.height > 0 {
Some(b.width as f32 / b.height as f32)
} else {
None
}
});
// Tags: fapello's BOOT has `tags: []` (empty) on the active item and
// `random_tags: [{url, name}]` is unrelated "you might like" tags from
// a global pool, NOT video-specific. We deliberately drop
// `random_tags` to avoid mislabeling every clip with the same five
// sitewide tag suggestions. `tags` (when populated) is the real set.
let tags: Option<Vec<String>> = boot.and_then(|b| {
if b.tags.is_empty() {
None
} else {
Some(b.tags.iter().map(|t| t.name.clone()).collect())
}
});
let mut item = VideoItem::new(
card.id.clone(),
title,
// video.url is the canonical detail page, not the CDN — keeps
// `yt-dlp` (which has no fapello extractor) free to fail loudly
// and routes playback through `formats[]` instead.
card.detail_url.clone(),
CHANNEL_ID.to_string(),
thumb,
duration,
)
.uploader(uploader.clone())
.uploader_url(format!("{}/{}", BASE_URL, model_url_slug))
.views(views.min(u32::MAX as u64) as u32);
item.uploaderId = Some(format!("{}:{}", CHANNEL_ID, model_url_slug));
if let Some(t) = tags {
item = item.tags(t);
}
if let Some(a) = aspect {
item = item.aspect_ratio(a);
}
if let Some(url) = media_url {
// Direct mp4 from cdn.fapello.com — no auth, no cookies, but
// a Referer matches the on-site context (the site embeds its
// own CDN URLs on pages served from fapello.com).
let mut headers = HashMap::new();
headers.insert("Referer".to_string(), format!("{}/", BASE_URL));
let format = VideoFormat::new(
url,
"Original".to_string(),
"mp4".to_string(),
)
.format_id("mp4-source".to_string())
.ext("mp4".to_string())
.http_headers(headers);
item = item.formats(vec![format]);
}
item
}
/// Fetch + parse the BOOT JSON for one card. Returns `None` on any
/// failure (network, Jina-throttling, parse error) — caller falls back
/// to listing-only metadata.
async fn enrich_card(
requester: &mut crate::util::requester::Requester,
card: &ListingCard,
) -> Option<BootItem> {
let url = format!("{}{}/", DETAIL_URL_FMT.replace("{}", &card.id), card.model_slug);
let mut headers = Vec::new();
headers.push(("Referer".to_string(), format!("{}/", BASE_URL)));
let body = match tokio::time::timeout(
Duration::from_secs(20),
requester.get_with_headers(&url, headers, None),
)
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
report_provider_error_background(
CHANNEL_ID,
"enrich_card.fetch",
&format!("id={} err={}", card.id, e),
);
return None;
}
Err(_) => {
report_provider_error_background(
CHANNEL_ID,
"enrich_card.timeout",
&format!("id={}", card.id),
);
return None;
}
};
let payload = Self::parse_boot_block(&body)?;
Self::find_boot_item(&payload, &card.id).cloned()
}
async fn get(
&self,
cache: VideoCache,
page: u8,
_sort: &str,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let url = Self::listing_url(page);
let cached = match cache.get(&url) {
Some((time, items)) => {
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
return Ok(items.clone());
}
items.clone()
}
None => vec![],
};
let mut requester =
requester_or_default(&options, module_path!(), "get.listing");
let body = match requester.get(&url, None).await {
Ok(s) => s,
Err(e) => {
report_provider_error_background(
CHANNEL_ID,
"get.fetch",
&format!("url={url} err={e}"),
);
return Ok(cached);
}
};
if body.is_empty() {
return Ok(cached);
}
let cards = Self::parse_listing_cards(&body);
if cards.is_empty() {
report_provider_error_background(
CHANNEL_ID,
"get.parse_empty",
&format!("url={url}"),
);
return Ok(cached);
}
// Bounded-concurrency per-card BOOT enrichment. The Requester is
// `!Send` across awaits because of its `Client`/`Jar` internals, so
// we clone a fresh one per task — the cookie jar is shared at the
// process level so cookies earned by one Jina solve are visible to
// the others.
let cards_for_stream = cards.clone();
let enriched: Vec<Option<BootItem>> = stream::iter(cards_for_stream.into_iter())
.map(|card| {
let mut req = requester.clone();
async move { Self::enrich_card(&mut req, &card).await }
})
.buffer_unordered(ENRICH_CONCURRENCY)
.collect()
.await;
let items: Vec<VideoItem> = cards
.iter()
.zip(enriched.iter())
.map(|(card, boot)| Self::build_item(card, boot.as_ref()))
.collect();
if !items.is_empty() {
cache.remove(&url);
cache.insert(url.clone(), items.clone());
}
Ok(items)
}
async fn query(
&self,
_cache: VideoCache,
_page: u8,
query: &str,
_options: ServerOptions,
) -> Result<Vec<VideoItem>> {
// Fapello's `/search_v2/?ajax=1&q=...&type=...` is a *photo* search —
// it returns model records, not video cards. There is no native
// "videos matching keyword" search. Surface a clear empty result
// rather than silently doing something unrelated. (The server's
// `literal_substring_filter` pass will post-filter `query` against
// the latest-feed results when the user types a quoted bare word.)
report_provider_error_background(
CHANNEL_ID,
"query.unsupported",
&format!("query={} — fapello has no video search; literal post-filter applied server-side", query),
);
Ok(vec![])
}
}
#[async_trait]
impl Provider for FapelloProvider {
async fn get_videos(
&self,
cache: VideoCache,
_pool: DbPool,
sort: String,
query: Option<String>,
page: String,
_per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let page_num = Self::parse_page(&page);
let result = match query {
Some(q) if !q.trim().is_empty() => {
self.query(cache, page_num, q.trim(), options).await
}
_ => self.get(cache, page_num, &sort, options).await,
};
match result {
Ok(items) => items,
Err(e) => {
eprintln!("[fapello] get_videos error: {e}");
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}

620
src/providers/hdporn92.rs Normal file
View File

@@ -0,0 +1,620 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
Provider, report_provider_error, report_provider_error_background, requester_or_default,
};
use crate::status::*;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use regex::Regex;
use scraper::{Html, Selector};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "mainstream-tube",
tags: &["tube", "mixed", "movies"],
};
const BASE_URL: &str = "https://hdporn92.com";
const CHANNEL_ID: &str = "hdporn92";
const DEFAULT_PER_PAGE: usize = 24;
const ENRICH_CONCURRENCY: usize = 4;
const BROWSER_UA: &str =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/124.0.0.0 Safari/537.36";
error_chain! {
foreign_links {
Io(std::io::Error);
HttpRequest(wreq::Error);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
#[derive(Debug, Clone)]
pub struct Hdporn92Provider;
#[derive(Debug, Clone)]
enum Target {
Latest,
Search { query: String },
}
#[derive(Debug, Clone)]
struct CardStub {
id: String,
title: String,
url: String,
thumb: String,
quality: Option<String>,
category_slug: Option<String>,
}
impl Hdporn92Provider {
pub fn new() -> Self {
Self
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
Channel {
id: CHANNEL_ID.to_string(),
name: "HDPorn92".to_string(),
description:
"HD Porn videos from network studios on hdporn92.com. Plays through a \
monetized morencius.com (VidHide-family) embed; the HLS URL is signed \
and Referer-locked, so the page itself does not expose direct media."
.to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hdporn92.com".to_string(),
status: "active".to_string(),
categories: vec![],
options: vec![],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
}
}
fn selector(value: &str) -> Result<Selector> {
Selector::parse(value)
.map_err(|error| Error::from(format!("selector `{value}` failed: {error}")))
}
fn pick_target(query: Option<&str>) -> Target {
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
return Target::Search { query: q.to_string() };
}
Target::Latest
}
fn build_listing_url(target: &Target, page: u16) -> String {
let page = page.max(1);
match target {
Target::Latest => {
if page <= 1 {
format!("{BASE_URL}/")
} else {
format!("{BASE_URL}/page/{page}/")
}
}
Target::Search { query } => {
let encoded: String =
url::form_urlencoded::byte_serialize(query.as_bytes()).collect();
if page <= 1 {
format!("{BASE_URL}/?s={encoded}")
} else {
format!("{BASE_URL}/page/{page}/?s={encoded}")
}
}
}
}
fn html_headers(referer: &str) -> Vec<(String, String)> {
vec![
("User-Agent".to_string(), BROWSER_UA.to_string()),
("Referer".to_string(), referer.to_string()),
]
}
async fn fetch_html(options: &ServerOptions, url: &str, referer: &str) -> Result<String> {
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_html");
requester
.get_with_headers(url, Self::html_headers(referer), None)
.await
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))
}
fn parse_listing_page(html: &str) -> Result<Vec<CardStub>> {
let document = Html::parse_document(html);
let card_sel = Self::selector("article.loop-video[data-video-id]")?;
let link_sel = Self::selector("a[href]")?;
let thumb_attr_sel = Self::selector("[data-main-thumb]")?;
let title_sel = Self::selector("header.entry-header span")?;
let views_sel = Self::selector("span.views")?;
let class_regex = Regex::new(r"\bpost-(\d+)\b")
.map_err(|error| Error::from(format!("regex failed: {error}")))?;
let category_regex = Regex::new(r"\bcategory-([a-z0-9-]+)")
.map_err(|error| Error::from(format!("regex failed: {error}")))?;
let mut stubs = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
for card in document.select(&card_sel) {
let class_attr = card.value().attr("class").unwrap_or_default();
let id = class_attr
.split_whitespace()
.find_map(|c| c.strip_prefix("post-"))
.map(str::to_string)
.or_else(|| {
card.value()
.attr("data-post-id")
.map(str::to_string)
})
.unwrap_or_default();
if id.is_empty() || !seen_ids.insert(id.clone()) {
continue;
}
let url = card
.select(&link_sel)
.next()
.and_then(|a| a.value().attr("href"))
.map(str::to_string)
.unwrap_or_default();
if url.is_empty() {
continue;
}
let title = card
.select(&title_sel)
.next()
.map(|s| s.text().collect::<Vec<_>>().join(""))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| id.clone());
let thumb = card
.select(&thumb_attr_sel)
.next()
.and_then(|el| el.value().attr("data-main-thumb"))
.map(str::to_string)
.filter(|t| !t.is_empty())
.or_else(|| {
card.select(&Self::selector("img[src]").ok()?)
.next()
.and_then(|img| img.value().attr("src"))
.map(str::to_string)
})
.unwrap_or_default();
let quality = card
.select(&views_sel)
.next()
.map(|s| s.text().collect::<Vec<_>>().join(""))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty() && t != "0");
let category_slug = class_regex
.captures(class_attr)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
.or_else(|| {
category_regex
.captures(class_attr)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
});
stubs.push(CardStub {
id,
title,
url,
thumb,
quality,
category_slug,
});
}
Ok(stubs)
}
/// Extracts the morencius.com embed URL from a hdporn92 detail page.
///
/// The detail page renders the embed inside an `<IFRAME SRC="...">` (note
/// the uppercase tag and attribute names — the retrotube WordPress theme
/// emits mixed-case markup).
fn extract_embed_url(html: &str) -> Option<String> {
// Find the morencius embed host fragment and walk back to the scheme
// (the iframe `SRC` is a quoted URL like `SRC="https://morencius..."`).
let needle = "morencius.com/embed/";
let needle_idx = html.find(needle)?;
// Walk back to the start of the URL (find `https://` or `http://`).
let head = &html[..=needle_idx];
let scheme_idx = head
.rfind("https://")
.or_else(|| head.rfind("http://"))?;
let after_scheme = &html[scheme_idx..];
// End of the URL: the next quote, space, or `>`.
let end = after_scheme
.find(|c: char| c == '"' || c == '\'' || c == ' ' || c == '>')
.unwrap_or(after_scheme.len());
Some(after_scheme[..end].to_string())
}
/// Extracts the embed ID from a `https://morencius.com/embed/{id}` URL.
fn extract_embed_id(embed_url: &str) -> Option<String> {
let prefix = "morencius.com/embed/";
let idx = embed_url.find(prefix)?;
let after = &embed_url[idx + prefix.len()..];
let end = after
.find(|c: char| c == '?' || c == '/' || c == '"' || c == '\'')
.unwrap_or(after.len());
if end == 0 {
None
} else {
Some(after[..end].to_string())
}
}
/// Decodes the P.A.C.K.E.R. `eval(function(p,a,c,k,e,d){...}(...))` block in
/// the morencius embed page and returns the deobfuscated JavaScript source.
fn decode_packered(source: &str) -> Option<String> {
// Match: eval(function(p,a,c,k,e,d){...}('P',A,C,'K'.split('|')
// We capture P, A, C, and the dictionary K.
let re = Regex::new(
r#"eval\(function\(p,a,c,k,e,d\)\{.*?\}\('([\s\S]*?)',(\d+),(\d+),'([\s\S]*?)'\.split\('\|'\)\)\)"#,
)
.ok()?;
let caps = re.captures(source)?;
let p = caps.get(1)?.as_str();
let a: u32 = caps.get(2)?.as_str().parse().ok()?;
let c: usize = caps.get(3)?.as_str().parse().ok()?;
let k: Vec<&str> = caps.get(4)?.as_str().split('|').collect();
// P.A.C.K.E.R. deobfuscation: iterate c-1..=0; replace \b<token>\b with
// dictionary[i] (the original JS uses a `\w+` lookalike via the `e`
// parameter, but in practice every token is a base-A digit string).
let mut result = p.to_string();
for i in (0..c).rev() {
let value = k.get(i).copied().unwrap_or("");
if value.is_empty() {
continue;
}
let token = to_base(i, a);
// Build a word-boundary regex for the token. Base-A tokens are
// always made of [0-9a-z], so \b is a real word boundary.
let token_re = Regex::new(&format!(r"\b{}\b", regex::escape(&token))).ok()?;
result = token_re.replace_all(&result, value).into_owned();
}
Some(result)
}
/// Pulls the morencius `/stream/.../master.m3u8` URL out of the decoded JS.
fn extract_hls4_url(decoded_js: &str) -> Option<String> {
// The decoded JS has: "hls4":"/stream/<token>/<secret>/<expiry>/<file_id>/master.m3u8"
// The value is a path (no scheme), so we prefix it.
let re = Regex::new(r#""hls4"\s*:\s*"([^"]+)""#).ok()?;
let caps = re.captures(decoded_js)?;
let path = caps.get(1)?.as_str();
if path.starts_with("http") {
Some(path.to_string())
} else if path.starts_with('/') {
Some(format!("https://morencius.com{path}"))
} else {
Some(format!("https://morencius.com/{path}"))
}
}
/// Pulls the integer duration (in seconds, may be decimal) from the decoded
/// jwplayer setup block. The JS has `duration:"<sec>.<frac>"`.
fn extract_duration(decoded_js: &str) -> Option<u32> {
let re = Regex::new(r#"duration\s*:\s*"?(\d+(?:\.\d+)?)"?"#).ok()?;
let caps = re.captures(decoded_js)?;
let raw = caps.get(1)?.as_str().parse::<f64>().ok()?;
Some(raw.round() as u32)
}
async fn enrich_card(stub: CardStub, options: &ServerOptions) -> Result<VideoItem> {
// Step 1: fetch the detail page to find the morencius embed.
let detail_html = Self::fetch_html(options, &stub.url, BASE_URL).await?;
let embed_url = Self::extract_embed_url(&detail_html)
.ok_or_else(|| Error::from(format!("no morencius embed in {}", stub.url)))?;
// Sanity check the embed id — the URL is what we use as Referer, but
// we want to fail early if the URL is malformed rather than silently
// building a broken `formats[0]`.
let _embed_id = Self::extract_embed_id(&embed_url)
.ok_or_else(|| Error::from(format!("could not parse embed id from {embed_url}")))?;
// Step 2: fetch the morencius embed page (referer = the hdporn92 detail).
let player_html = Self::fetch_html(options, &embed_url, &stub.url).await?;
let decoded = Self::decode_packered(&player_html)
.ok_or_else(|| Error::from(format!("no eval block in {embed_url}")))?;
let stream_path = Self::extract_hls4_url(&decoded)
.ok_or_else(|| Error::from(format!("no hls4 url in decoded JS of {embed_url}")))?;
let duration = Self::extract_duration(&decoded).unwrap_or(0);
// Build the format. morencius serves the m3u8 only when Referer is the
// embed URL — without it, the response is 403.
let mut format =
VideoFormat::m3u8(stream_path, "auto".to_string(), "m3u8".to_string());
format.add_http_header("Referer".to_string(), embed_url.clone());
format.add_http_header("User-Agent".to_string(), BROWSER_UA.to_string());
let mut item = VideoItem::new(
stub.id,
stub.title,
stub.url,
CHANNEL_ID.to_string(),
stub.thumb,
duration,
);
item.formats = Some(vec![format]);
if let Some(slug) = stub.category_slug {
item.tags = Some(vec![slug]);
}
Ok(item)
}
async fn fetch_page(
target: Target,
page: u16,
per_page: usize,
options: &ServerOptions,
) -> Result<Vec<VideoItem>> {
let url = Self::build_listing_url(&target, page);
let html = Self::fetch_html(options, &url, BASE_URL).await?;
let stubs = Self::parse_listing_page(&html)?;
if stubs.is_empty() {
return Ok(vec![]);
}
let limited: Vec<_> = stubs.into_iter().take(per_page).collect();
let options = options.clone();
let items = stream::iter(limited.into_iter().map(|stub| {
let options = options.clone();
async move {
match Self::enrich_card(stub, &options).await {
Ok(item) => Some(item),
Err(error) => {
report_provider_error_background(
CHANNEL_ID,
"fetch_page.enrich_card",
&error.to_string(),
);
None
}
}
}
}))
.buffer_unordered(ENRICH_CONCURRENCY)
.filter_map(async move |v| v)
.collect::<Vec<_>>()
.await;
Ok(items)
}
}
/// Convert a non-negative integer to a string in the given base (radix).
/// The result uses lowercase 0-9a-z. Used for the P.A.C.K.E.R. base-A tokens.
fn to_base(mut value: usize, base: u32) -> String {
debug_assert!(base >= 2 && base <= 36);
if value == 0 {
return "0".to_string();
}
let mut digits = Vec::new();
let base = base as usize;
while value > 0 {
let d = (value % base) as u8;
digits.push(if d < 10 {
(b'0' + d) as char
} else {
(b'a' + (d - 10)) as char
});
value /= base;
}
digits.into_iter().rev().collect()
}
#[async_trait]
impl Provider for Hdporn92Provider {
async fn get_videos(
&self,
_cache: crate::util::cache::VideoCache,
_pool: DbPool,
_sort: String,
query: Option<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let page = page.parse::<u16>().unwrap_or(1).max(1);
let per_page = per_page
.parse::<usize>()
.unwrap_or(DEFAULT_PER_PAGE)
.clamp(1, 48);
let normalized_query = query
.as_deref()
.map(str::trim)
.filter(|q| !q.is_empty())
.map(ToOwned::to_owned);
let target = Self::pick_target(normalized_query.as_deref());
match Self::fetch_page(target, page, per_page, &options).await {
Ok(items) => items,
Err(error) => {
report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_listing_urls() {
assert_eq!(
Hdporn92Provider::build_listing_url(&Target::Latest, 1),
"https://hdporn92.com/"
);
assert_eq!(
Hdporn92Provider::build_listing_url(&Target::Latest, 2),
"https://hdporn92.com/page/2/"
);
assert_eq!(
Hdporn92Provider::build_listing_url(
&Target::Search {
query: "isla wild".to_string()
},
1
),
"https://hdporn92.com/?s=isla+wild"
);
assert_eq!(
Hdporn92Provider::build_listing_url(
&Target::Search {
query: "milf".to_string()
},
2
),
"https://hdporn92.com/page/2/?s=milf"
);
}
#[test]
fn picks_search_for_query() {
match Hdporn92Provider::pick_target(Some("test")) {
Target::Search { query } => assert_eq!(query, "test"),
other => panic!("expected Search, got {:?}", other),
}
}
#[test]
fn picks_latest_for_no_query() {
assert!(matches!(
Hdporn92Provider::pick_target(None),
Target::Latest
));
assert!(matches!(
Hdporn92Provider::pick_target(Some("")),
Target::Latest
));
assert!(matches!(
Hdporn92Provider::pick_target(Some(" ")),
Target::Latest
));
}
#[test]
fn extracts_embed_id_from_url() {
assert_eq!(
Hdporn92Provider::extract_embed_id("https://morencius.com/embed/vc6vwjo6ngbn"),
Some("vc6vwjo6ngbn".to_string())
);
assert_eq!(
Hdporn92Provider::extract_embed_id("https://morencius.com/embed/abc123?foo=bar"),
Some("abc123".to_string())
);
assert_eq!(
Hdporn92Provider::extract_embed_id("https://other.com/embed/abc"),
None
);
}
#[test]
fn extracts_embed_url_from_detail_html() {
let html = r#"<IFRAME SRC="https://morencius.com/embed/vc6vwjo6ngbn" FRAMEBORDER=0>"#;
assert_eq!(
Hdporn92Provider::extract_embed_url(html).as_deref(),
Some("https://morencius.com/embed/vc6vwjo6ngbn")
);
}
#[test]
fn decodes_hls4_url_from_packered_js() {
// A minimal P.A.C.K.E.R. block that decodes to JSON containing an
// hls4 key. We construct it so the base-36 token `0` decodes to
// "hls4" and `1` decodes to the m3u8 path. The `2` is the empty
// string (no key beyond index 1) and the loop skips it.
//
// a = 36, c = 2, k = ["hls4", "/stream/abc/secret/123/456/master.m3u8"]
// p = '{"0":"1"}' (after substitution: {"hls4":"/stream/.../master.m3u8"})
//
// Reverse iteration:
// i=1: token="1", k[1]="..." → '{"0":"/stream/.../master.m3u8"}'
// i=0: token="0", k[0]="hls4" → '{"hls4":"/stream/.../master.m3u8"}'
let source = r#"eval(function(p,a,c,k,e,d){e=function(c){return c};if(!''.replace(/^/,String)){while(c--){d[c.toString(a)]=k[c]||c.toString(a)};k=[function(e){return d[e]}];e=function(){return'\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\b'+e(c)+'\b','g'),k[c])}}return p}('{"0":"1"}',36,2,'hls4|/stream/abc/secret/123/456/master.m3u8'.split('|')));"#;
let decoded = Hdporn92Provider::decode_packered(source)
.expect("deobfuscator should return Some");
// Sanity: the decoded text contains the hls4 key.
assert!(decoded.contains("\"hls4\""), "decoded: {decoded}");
let url = Hdporn92Provider::extract_hls4_url(&decoded)
.expect("hls4 should be present after decode");
// The path was /stream/abc/secret/123/456/master.m3u8.
assert_eq!(url, "https://morencius.com/stream/abc/secret/123/456/master.m3u8");
}
#[test]
fn extracts_duration_from_decoded_js() {
let decoded = r#"jwplayer().setup({duration:"1709.40",preload:'auto'})"#;
assert_eq!(
Hdporn92Provider::extract_duration(decoded),
Some(1709)
);
}
#[test]
fn base_conversion() {
assert_eq!(to_base(0, 36), "0");
assert_eq!(to_base(35, 36), "z");
assert_eq!(to_base(36, 36), "10");
// 49 in base 36 is "1d" (the dictionary key for "hls3")
assert_eq!(to_base(49, 36), "1d");
// 51 in base 36 is "1f" (the dictionary key for "hls4")
assert_eq!(to_base(51, 36), "1f");
}
#[test]
fn decodes_real_morencius_html() {
// Exercise the deobfuscator against a real captured morencius embed
// page. The fixture is the iframe served to the iOS webview when
// opening `hdporn92.com/mypervyfamily-isla-wild-.../`.
let html = std::fs::read_to_string(
"src/providers/testdata/hdporn92_morencius.html",
)
.expect("test fixture should exist");
let decoded = Hdporn92Provider::decode_packered(&html)
.expect("real morencius HTML should be deobfuscable");
// The decoded JS contains the hls4 value.
let url = Hdporn92Provider::extract_hls4_url(&decoded)
.expect("hls4 should be present after decode");
assert!(
url.starts_with("https://morencius.com/stream/"),
"got {url}"
);
assert!(url.ends_with("/master.m3u8"), "got {url}");
// The duration is also present.
let duration = Hdporn92Provider::extract_duration(&decoded);
assert!(duration.unwrap_or(0) > 0, "duration should be > 0");
}
}

999
src/providers/hornyleak.rs Normal file
View File

@@ -0,0 +1,999 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{
Provider, report_provider_error, report_provider_error_background, requester_or_default,
};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::parse_abbreviated_number;
use crate::util::requester::Requester;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoItem};
use async_trait::async_trait;
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use htmlentity::entity::{ICodedDataTrait, decode};
use scraper::{Html, Selector};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use std::thread;
const CHANNEL_ID: &str = "hornyleak";
const BASE_URL: &str = "https://www.hornyleak.tv";
const HDPLAYER_BASE: &str = "https://hdplayer.gives";
/// Bounded concurrency for the two-step embed -> hdplayer -> m3u8 enrichment.
const ENRICH_CONCURRENCY: usize = 6;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "amateur-homemade",
tags: &["leaked", "amateur", "creampie"],
};
error_chain! {
foreign_links {
Io(std::io::Error);
HttpRequest(wreq::Error);
}
}
#[derive(Debug, Clone)]
pub struct HornyleakProvider {
url: String,
/// Display list exposed via the `categories` status option.
categories: Arc<RwLock<Vec<FilterOption>>>,
/// normalized category title -> site slug, for shortcut routing.
category_map: Arc<RwLock<HashMap<String, String>>>,
}
/// Where a `/api/videos` request should be routed on the site. Every variant
/// resolves to a static KVS-style listing page or a search URL.
#[derive(Debug, Clone)]
enum Target {
/// Default `/latest-updates/{N}/` feed.
Latest,
/// `/most-popular/{N}/` archive.
MostPopular,
/// `/top-rated/{N}/` archive.
TopRated,
/// `/categories/{slug}/{N}/` archive.
Category { slug: String },
/// `/tags/{slug}/{N}/` archive.
Tag { slug: String },
/// `/models/{slug}/{N}/` archive (uploader shortcut).
Model { slug: String },
/// `/search/?q={query}` + async XHR pagination for page >=2.
Search { query: String },
}
#[derive(Debug, Clone)]
struct HornyleakCard {
id: String,
title: String,
page_url: String,
thumb: String,
duration: u32,
views: Option<u32>,
rating: Option<u32>,
preview: Option<String>,
uploader: Option<String>,
uploader_url: Option<String>,
uploader_slug: Option<String>,
}
impl HornyleakProvider {
pub fn new() -> Self {
let provider = Self {
url: BASE_URL.to_string(),
categories: Arc::new(RwLock::new(vec![FilterOption {
id: "all".to_string(),
title: "All".to_string(),
}])),
category_map: Arc::new(RwLock::new(HashMap::new())),
};
provider.spawn_initial_load();
provider
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
let categories = self
.categories
.read()
.map(|guard| guard.clone())
.unwrap_or_default();
Channel {
id: CHANNEL_ID.to_string(),
name: "HornyLeak".to_string(),
description: "Leaked amateur and OnlyFans clips — latest feed, search, plus category, tag, and model archives."
.to_string(),
premium: false,
favicon: format!(
"https://www.google.com/s2/favicons?sz=64&domain={}",
self.url.trim_start_matches("https://")
),
status: "active".to_string(),
categories: categories.iter().map(|value| value.title.clone()).collect(),
options: vec![
ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Feed ordering.".to_string(),
systemImage: "list.number".to_string(),
colorName: "blue".to_string(),
options: vec![
FilterOption {
id: "new".to_string(),
title: "Latest".to_string(),
},
FilterOption {
id: "popular".to_string(),
title: "Most Popular".to_string(),
},
FilterOption {
id: "rated".to_string(),
title: "Top Rated".to_string(),
},
FilterOption {
id: "viewed".to_string(),
title: "Most Viewed".to_string(),
},
FilterOption {
id: "longest".to_string(),
title: "Longest".to_string(),
},
],
multiSelect: false,
},
ChannelOption {
id: "categories".to_string(),
title: "Categories".to_string(),
description: "Jump directly to a category archive.".to_string(),
systemImage: "square.grid.2x2".to_string(),
colorName: "orange".to_string(),
options: categories,
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
}
}
fn sort_by(sort: &str) -> &'static str {
match sort {
"popular" | "most-popular" => "most_popular",
"rated" | "rating" | "top-rated" => "rating",
"viewed" | "most-viewed" => "video_viewed",
"longest" | "duration" => "duration",
"commented" => "most_commented",
"favourited" | "favorited" => "most_favourited",
_ => "post_date",
}
}
/// lowercase, spaces/underscores -> single dash, drop anything else.
fn slugify(value: &str) -> String {
let mut slug = String::new();
let mut prev_dash = false;
for ch in value.trim().to_ascii_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
prev_dash = false;
} else if matches!(ch, ' ' | '-' | '_' | '+') {
if !prev_dash && !slug.is_empty() {
slug.push('-');
prev_dash = true;
}
}
}
slug.trim_matches('-').to_string()
}
/// Normalize a display title for lookup map keys.
fn normalize_title(value: &str) -> String {
value
.trim()
.trim_start_matches('#')
.replace(['_', '-'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn resolve_category_slug(&self, value: &str) -> Option<String> {
let normalized = Self::normalize_title(value);
if normalized.is_empty() || normalized == "all" {
return None;
}
self.category_map
.read()
.ok()
.and_then(|map| map.get(&normalized).cloned())
}
/// Decide where a request is routed: explicit option/prefix first, then a
/// bare query that matches a known category, otherwise keyword search.
fn target_from_request(
&self,
query: Option<&str>,
category: Option<&str>,
) -> Target {
if let Some(category) = category {
if let Some(slug) = self.resolve_category_slug(category) {
return Target::Category { slug };
}
}
if let Some(raw) = query {
let trimmed = raw.trim();
if !trimmed.is_empty() {
for prefix in ["category:", "cat:"] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
let slug = self
.resolve_category_slug(rest)
.unwrap_or_else(|| Self::slugify(rest));
if !slug.is_empty() {
return Target::Category { slug };
}
}
}
if let Some(rest) = trimmed.strip_prefix("tag:") {
let slug = Self::slugify(rest);
if !slug.is_empty() {
return Target::Tag { slug };
}
}
for prefix in ["model:", "uploader:", "pornstar:", "star:"] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
let slug = Self::slugify(rest);
if !slug.is_empty() {
return Target::Model { slug };
}
}
}
if let Some(slug) = self.resolve_category_slug(trimmed) {
return Target::Category { slug };
}
return Target::Search {
query: trimmed.to_string(),
};
}
}
Target::Latest
}
/// Build the listing/search URL plus the Referer to send with it. Search
/// uses the KVS `from_videos` async XHR for any page > 1; the static HTML
/// `/search/?q=...` route only ships page 1.
fn build_listing_url(&self, target: &Target, sort: &str, page: u32) -> (String, String) {
let sort_by = Self::sort_by(sort);
match target {
Target::Latest => {
let referer = format!("{}/latest-updates/", self.url);
let url = if page <= 1 {
referer.clone()
} else {
format!("{}/latest-updates/{page}/", self.url)
};
(url, referer)
}
Target::MostPopular => {
let referer = format!("{}/most-popular/", self.url);
let url = if page <= 1 {
referer.clone()
} else {
format!("{}/most-popular/{page}/", self.url)
};
(url, referer)
}
Target::TopRated => {
let referer = format!("{}/top-rated/", self.url);
let url = if page <= 1 {
referer.clone()
} else {
format!("{}/top-rated/{page}/", self.url)
};
(url, referer)
}
Target::Category { slug } => {
let referer = format!("{}/categories/{slug}/", self.url);
let url = if page <= 1 {
referer.clone()
} else {
format!("{}/categories/{slug}/{page}/", self.url)
};
(url, referer)
}
Target::Tag { slug } => {
let referer = format!("{}/tags/{slug}/", self.url);
let url = if page <= 1 {
referer.clone()
} else {
format!("{}/tags/{slug}/{page}/", self.url)
};
(url, referer)
}
Target::Model { slug } => {
let referer = format!("{}/models/{slug}/", self.url);
let url = if page <= 1 {
referer.clone()
} else {
format!("{}/models/{slug}/{page}/", self.url)
};
(url, referer)
}
Target::Search { query } => {
let q = query.split_whitespace().collect::<Vec<_>>().join("+");
let referer = format!("{}/search/", self.url);
// Static HTML only ships page 1; page >= 2 must use the async
// XHR fragment. `from_videos` is 1-indexed (page 2 = from=2).
let url = if page <= 1 {
format!("{referer}?q={q}")
} else {
format!(
"{referer}?q={q}&mode=async&function=get_block\
&block_id=list_videos_videos_list_search_result\
&category_ids=&sort_by={sort_by}&from_videos={page}&from_albums={page}"
)
};
(url, referer)
}
}
}
fn html_headers(referer: &str) -> Vec<(String, String)> {
vec![("Referer".to_string(), referer.to_string())]
}
async fn get_videos_for_target(
&self,
cache: VideoCache,
target: &Target,
sort: &str,
page: u32,
options: ServerOptions,
) -> Result<Vec<VideoItem>> {
let (listing_url, referer) = self.build_listing_url(target, sort, page);
let old_items = match cache.get(&listing_url) {
Some((time, items)) => {
if time.elapsed().unwrap_or_default().as_secs() < 60 * 5 {
return Ok(items.clone());
}
items.clone()
}
None => vec![],
};
let mut requester = requester_or_default(&options, CHANNEL_ID, "get_videos_for_target");
let text = match requester
.get_with_headers(&listing_url, Self::html_headers(&referer), None)
.await
{
Ok(text) => text,
Err(e) => {
report_provider_error(
CHANNEL_ID,
"get_videos_for_target.request",
&format!("url={listing_url}; error={e}"),
)
.await;
return Ok(old_items);
}
};
if text.trim().is_empty() {
return Ok(old_items);
}
let cards = self.parse_video_cards(&text);
if cards.is_empty() {
return Ok(old_items);
}
let video_items = self.enrich_cards(cards, &referer, requester).await;
if !video_items.is_empty() {
cache.remove(&listing_url);
cache.insert(listing_url.clone(), video_items.clone());
return Ok(video_items);
}
Ok(old_items)
}
fn decode_html_text(text: &str) -> String {
decode(text.as_bytes())
.to_string()
.unwrap_or_else(|_| text.to_string())
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string()
}
fn normalize_url(&self, url: &str) -> String {
let trimmed = url.trim();
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return trimmed.to_string();
}
if trimmed.starts_with("//") {
return format!("https:{trimmed}");
}
if trimmed.starts_with('/') {
return format!("{}{}", self.url, trimmed);
}
format!("{}/{}", self.url, trimmed.trim_start_matches("./"))
}
fn id_from_video_url(url: &str) -> Option<String> {
let after = url.split("/video/").nth(1)?;
let id: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
(!id.is_empty()).then_some(id)
}
fn slug_from_model_url(url: &str) -> Option<String> {
url.split("/models/")
.nth(1)?
.split('/')
.next()
.map(str::to_string)
.filter(|slug| !slug.is_empty())
}
fn attr(element: &scraper::ElementRef, names: &[&str]) -> Option<String> {
for name in names {
if let Some(value) = element.value().attr(name) {
let trimmed = value.trim();
if !trimmed.is_empty() && !trimmed.starts_with("data:image/") {
return Some(trimmed.to_string());
}
}
}
None
}
fn parse_video_cards(&self, html: &str) -> Vec<HornyleakCard> {
let document = Html::parse_document(html);
let Ok(item_sel) = Selector::parse("div.thumb.thumb_rel.item") else {
return vec![];
};
let link_sel = Selector::parse("a").ok();
let views_sel = Selector::parse("div.thumb-item > svg.icon-eye").ok();
let duration_sel = Selector::parse("div.time").ok();
let img_sel = Selector::parse("img").ok();
let preview_sel = Selector::parse("div.img-holder").ok();
let rating_sel = Selector::parse("div.rating").ok();
let model_sel = Selector::parse("a.avatar, a.link__red[href*=\"/models/\"]").ok();
let mut cards = Vec::new();
let mut seen = HashSet::new();
for item in document.select(&item_sel) {
let Some(link) = link_sel.as_ref().and_then(|sel| item.select(sel).next()) else {
continue;
};
let Some(href) = link.value().attr("href") else {
continue;
};
let page_url = self.normalize_url(href);
let Some(id) = Self::id_from_video_url(&page_url) else {
continue;
};
if !seen.insert(id.clone()) {
continue;
}
let title_raw = link
.value()
.attr("title")
.map(str::to_string)
.or_else(|| {
img_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.and_then(|img| img.value().attr("alt").map(str::to_string))
})
.or_else(|| {
Selector::parse("a.title")
.ok()
.and_then(|sel| item.select(&sel).next())
.map(|a| a.text().collect::<String>())
})
.unwrap_or_default();
let title = Self::decode_html_text(&title_raw);
let thumb = img_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.and_then(|img| Self::attr(&img, &["data-webp", "src", "data-src"]))
.map(|value| {
self.normalize_url(value.split_whitespace().next().unwrap_or(&value))
})
.unwrap_or_default();
let duration = duration_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.map(|d| d.text().collect::<String>())
.and_then(|raw| parse_time_to_seconds(raw.trim()))
.unwrap_or(0) as u32;
let views = views_sel
.as_ref()
.and_then(|_sel| {
item.select(&Selector::parse("div.thumb-item").ok()?)
.find(|node| {
node.value()
.classes()
.any(|c| c == "thumb-item" || c == "thumb-item thumb-item-date")
})
.map(|node| node.text().collect::<String>())
})
.and_then(|raw| parse_abbreviated_number(raw.trim()))
.filter(|count| *count > 0);
let rating = rating_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.map(|d| d.text().collect::<String>())
.and_then(|raw| {
let trimmed = raw.trim().trim_end_matches('%').trim();
trimmed.parse::<u32>().ok()
})
.filter(|pct| *pct <= 100);
let preview = preview_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.and_then(|p| Self::attr(&p, &["data-preview"]))
.map(|raw| self.normalize_url(&raw));
let model_anchor = model_sel
.as_ref()
.and_then(|sel| item.select(sel).next())
.filter(|m| {
m.value()
.attr("href")
.map(|h| h.contains("/models/"))
.unwrap_or(false)
});
let uploader = model_anchor
.map(|m| Self::decode_html_text(&m.text().collect::<String>()))
.filter(|name| !name.is_empty());
let uploader_href = model_anchor.and_then(|m| m.value().attr("href").map(str::to_string));
let uploader_url = uploader_href.as_ref().map(|href| self.normalize_url(href));
let uploader_slug = uploader_href
.as_deref()
.and_then(Self::slug_from_model_url);
cards.push(HornyleakCard {
id,
title,
page_url,
thumb,
duration,
views,
rating,
preview,
uploader,
uploader_url,
uploader_slug,
});
}
cards
}
/// Pull the `m3u8` HLS URL out of the embed + hdplayer chain:
/// detail → `/embed/{id}/` → `<iframe src="https://hdplayer.gives/embed/HASH">`
/// → fetch hdplayer page → `file:"https://hdplayer.gives/list/enc..."` (HLS).
/// The encoded `list/...` URL is stable per video and resolves directly to
/// an HLS master playlist — it does not need a Referer.
async fn resolve_hls_url(
&self,
video_id: &str,
page_url: &str,
mut requester: Requester,
) -> Option<String> {
// Step 1: detail page → embed page (its URL is stable per video id).
let embed_url = format!("{}/embed/{video_id}/", self.url);
let embed_text = requester
.get_with_headers(&embed_url, vec![("Referer".to_string(), page_url.to_string())], None)
.await
.ok()?;
// Step 2: pull the hdplayer iframe URL out of the embed page.
let hdplayer_re = match regex::Regex::new(r#"hdplayer\.gives/embed/([A-Za-z0-9]+)"#) {
Ok(re) => re,
Err(_) => return None,
};
let hdplayer_hash = hdplayer_re
.captures(&embed_text)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())?;
let hdplayer_url = format!("{HDPLAYER_BASE}/embed/{hdplayer_hash}");
// Step 3: fetch the hdplayer page and pull the encoded list URL.
let hdplayer_text = requester
.get_with_headers(
&hdplayer_url,
vec![("Referer".to_string(), embed_url.clone())],
None,
)
.await
.ok()?;
let file_re = regex::Regex::new(r#"file:"(https://hdplayer\.gives/list/enc[^"]+)""#).ok()?;
file_re
.captures(&hdplayer_text)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
}
fn extract_tags_from_page(text: &str) -> Vec<String> {
let document = Html::parse_document(text);
let Ok(selector) = Selector::parse(".tags-row a, .categories-row a") else {
return vec![];
};
let mut tags = Vec::new();
let mut seen = HashSet::new();
for link in document.select(&selector) {
let title = Self::decode_html_text(&link.text().collect::<String>());
if title.is_empty() {
continue;
}
let key = title.to_ascii_lowercase();
if seen.insert(key) {
tags.push(title);
}
}
tags
}
async fn enrich_card(
&self,
card: HornyleakCard,
listing_referer: String,
mut requester: Requester,
) -> VideoItem {
// The page URL is yt-dlp-resolvable, so enrichment is only used for tags
// and uploader fields. The HLS embed chain (kept as a private helper
// for future use) is intentionally not invoked here.
let detail = requester
.get_with_headers(
&card.page_url,
Self::html_headers(&listing_referer),
None,
)
.await
.ok();
let tags = detail
.as_deref()
.map(Self::extract_tags_from_page)
.unwrap_or_default();
let mut item = VideoItem::new(
card.id,
card.title,
card.page_url.clone(),
CHANNEL_ID.to_string(),
card.thumb,
card.duration,
);
if let Some(views) = card.views {
item = item.views(views);
}
if let Some(pct) = card.rating {
item = item.rating(pct as f32);
}
if let Some(uploader) = card.uploader {
item = item.uploader(uploader);
}
if let Some(url) = card.uploader_url {
item = item.uploader_url(url);
}
if let Some(slug) = card.uploader_slug {
item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
}
if !tags.is_empty() {
item = item.tags(tags);
}
if let Some(preview) = card.preview {
item = item.preview(preview);
}
item
}
async fn enrich_cards(
&self,
cards: Vec<HornyleakCard>,
listing_referer: &str,
requester: Requester,
) -> Vec<VideoItem> {
stream::iter(cards.into_iter().map(|card| {
let requester = requester.clone();
let referer = listing_referer.to_string();
async move { self.enrich_card(card, referer, requester).await }
}))
.buffered(ENRICH_CONCURRENCY)
.collect::<Vec<_>>()
.await
}
fn spawn_initial_load(&self) {
let url = self.url.clone();
let categories = Arc::clone(&self.categories);
let category_map = Arc::clone(&self.category_map);
thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
report_provider_error_background(
CHANNEL_ID,
"spawn_initial_load.runtime_build",
&error.to_string(),
);
return;
}
};
runtime.block_on(async move {
if let Err(error) =
Self::load_categories(&url, categories, category_map).await
{
report_provider_error_background(
CHANNEL_ID,
"load_categories",
&error.to_string(),
);
}
});
});
}
async fn load_categories(
url: &str,
categories: Arc<RwLock<Vec<FilterOption>>>,
category_map: Arc<RwLock<HashMap<String, String>>>,
) -> Result<()> {
let mut requester = Requester::new();
let category_url = format!("{url}/categories/");
let html = requester
.get_with_headers(&category_url, Self::html_headers(url), None)
.await
.map_err(|error| Error::from(format!("category fetch failed: {error}")))?;
let document = Html::parse_document(&html);
let selector = Selector::parse("div.thumb.item a[href*=\"/categories/\"]")
.map_err(|error| Error::from(format!("category selector failed: {error}")))?;
let mut options = vec![FilterOption {
id: "all".to_string(),
title: "All".to_string(),
}];
let mut map = HashMap::new();
for link in document.select(&selector) {
let Some(href) = link.value().attr("href") else {
continue;
};
let Some(slug) = href
.split("/categories/")
.nth(1)
.and_then(|rest| rest.split('/').next())
.filter(|slug| !slug.is_empty())
else {
continue;
};
let title = link
.value()
.attr("title")
.map(Self::decode_html_text)
.filter(|title| !title.is_empty())
.unwrap_or_else(|| Self::decode_html_text(&link.text().collect::<String>()));
if title.is_empty() {
continue;
}
let normalized = Self::normalize_title(&title);
if normalized.is_empty() || map.contains_key(&normalized) {
continue;
}
options.push(FilterOption {
id: normalized.clone(),
title,
});
map.insert(normalized, slug.to_string());
}
if options.len() > 1 {
if let Ok(mut guard) = categories.write() {
*guard = options;
}
if let Ok(mut guard) = category_map.write() {
*guard = map;
}
}
Ok(())
}
}
#[async_trait]
impl Provider for HornyleakProvider {
async fn get_videos(
&self,
cache: VideoCache,
_pool: DbPool,
sort: String,
query: Option<String>,
page: String,
_per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let page = page.parse::<u32>().unwrap_or(1).max(1);
let target = self.target_from_request(query.as_deref(), options.categories.as_deref());
match self
.get_videos_for_target(cache, &target, &sort, page, options)
.await
{
Ok(videos) => videos,
Err(e) => {
report_provider_error(
CHANNEL_ID,
"get_videos",
&format!("page={page}; target={target:?}; error={e}"),
)
.await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_collapses_separators() {
assert_eq!(HornyleakProvider::slugify("Big Black Cock"), "big-black-cock");
assert_eq!(HornyleakProvider::slugify(" big__ass "), "big-ass");
}
#[test]
fn builds_latest_block_url() {
let provider = HornyleakProvider::new();
let (url, referer) = provider.build_listing_url(&Target::Latest, "new", 1);
assert_eq!(referer, "https://www.hornyleak.tv/latest-updates/");
assert_eq!(url, "https://www.hornyleak.tv/latest-updates/");
let (url, referer) = provider.build_listing_url(&Target::Latest, "new", 2);
assert_eq!(referer, "https://www.hornyleak.tv/latest-updates/");
assert_eq!(url, "https://www.hornyleak.tv/latest-updates/2/");
}
#[test]
fn builds_search_url() {
let provider = HornyleakProvider::new();
let (url, referer) = provider.build_listing_url(
&Target::Search {
query: "big ass".to_string(),
},
"new",
1,
);
assert_eq!(referer, "https://www.hornyleak.tv/search/");
assert_eq!(url, "https://www.hornyleak.tv/search/?q=big+ass");
let (url, _) = provider.build_listing_url(
&Target::Search {
query: "big ass".to_string(),
},
"popular",
2,
);
assert!(url.contains("from_videos=2"));
assert!(url.contains("from_albums=2"));
assert!(url.contains("sort_by=most_popular"));
}
#[test]
fn category_block_url_uses_common_list() {
let provider = HornyleakProvider::new();
let (url, _) = provider.build_listing_url(
&Target::Category {
slug: "anal".to_string(),
},
"new",
1,
);
assert_eq!(url, "https://www.hornyleak.tv/categories/anal/");
let (url, _) = provider.build_listing_url(
&Target::Category {
slug: "anal".to_string(),
},
"new",
3,
);
assert_eq!(url, "https://www.hornyleak.tv/categories/anal/3/");
}
#[test]
fn target_from_request_routes_prefixes() {
let provider = HornyleakProvider::new();
let target = provider.target_from_request(Some("tag:amateur"), None);
assert!(matches!(target, Target::Tag { slug } if slug == "amateur"));
let target = provider.target_from_request(Some("cat:anal"), None);
assert!(matches!(target, Target::Category { slug } if slug == "anal"));
let target = provider.target_from_request(Some("uploader:bunny-umi"), None);
assert!(matches!(target, Target::Model { slug } if slug == "bunny-umi"));
let target = provider.target_from_request(Some("big ass"), None);
assert!(matches!(target, Target::Search { ref query } if query == "big ass"));
let target = provider.target_from_request(None, None);
assert!(matches!(target, Target::Latest));
}
#[test]
fn parses_static_listing_card() {
let provider = HornyleakProvider::new();
let html = r##"
<div class="thumbs" id="list_videos_most_recent_videos_items">
<div class="thumb thumb_rel item ">
<a href="https://www.hornyleak.tv/video/32944/bunny-umi-gets-creampied-by-gattouz0/" title="Bunny Umi Gets Creampied By Gattouz0">
<div class="img-holder second">
<img class="" src="https://www.hornyleak.tv/contents/videos_screenshots/32000/32944/preview.jpg" data-webp="https://www.hornyleak.tv/contents/videos_screenshots/32000/32944/preview.jpg" data-preview="https://hdplayer.gives/upload/videos/2026/09/x/preview.mp4" width="336" height="189"/>
<div class="item-bottom"><div class="time">1:55:09</div></div>
<div class="item-bottom second"><div class="rating">92%</div></div>
</div>
</a>
<div class="card-content">
<a class="avatar" href="https://www.hornyleak.tv/models/bunny-umi/" title="Bunny Umi">
<span class="name">Bunny Umi</span>
</a>
<div class="thumb-item"><i></i> 143K</div>
</div>
</div>
</div>
"##;
let cards = provider.parse_video_cards(html);
assert_eq!(cards.len(), 1);
let card = &cards[0];
assert_eq!(card.id, "32944");
assert_eq!(card.title, "Bunny Umi Gets Creampied By Gattouz0");
assert_eq!(
card.page_url,
"https://www.hornyleak.tv/video/32944/bunny-umi-gets-creampied-by-gattouz0/"
);
assert_eq!(card.duration, 6909);
assert_eq!(card.rating, Some(92));
assert_eq!(card.uploader.as_deref(), Some("Bunny Umi"));
assert_eq!(
card.uploader_url.as_deref(),
Some("https://www.hornyleak.tv/models/bunny-umi/")
);
assert_eq!(card.uploader_slug.as_deref(), Some("bunny-umi"));
}
}

886
src/providers/notfans.rs Normal file
View File

@@ -0,0 +1,886 @@
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, report_provider_error, requester_or_default};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::util::time::parse_time_to_seconds;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
use async_trait::async_trait;
use chrono::{Duration as ChronoDuration, Utc};
use error_chain::error_chain;
use futures::stream::{self, StreamExt};
use htmlentity::entity::{ICodedDataTrait, decode};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use regex::Regex;
use tokio::time::timeout;
use url::Url;
use wreq::Version;
use std::time::Duration as StdDuration;
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "onlyfans",
tags: &["onlyfans", "leaks", "aggregator"],
};
const BASE_URL: &str = "https://notfans.com";
const CHANNEL_ID: &str = "notfans";
// Chrome on Linux — matches the UA the wreq Chrome 120 emulation ships with so
// the site's CF edge treats us as a normal browser.
const BROWSER_UA: &str =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const HTML_ACCEPT: &str =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
const REFERER: &str = "https://notfans.com/";
// Notfans listing cards are notfans-themed, but the class name is shared with
// other KVS-based sites (homoxxx etc.) — match exactly with the two trailing
// spaces so we skip the sponsored-ad variants (`class="item avd"`).
const CARD_DELIMITER: &str = "<div class=\"item \">";
const CACHE_TTL_SECS: u64 = 60 * 15;
const DETAIL_TIMEOUT_SECS: u64 = 8;
const DETAIL_FETCH_CONCURRENCY: usize = 4;
error_chain! {
foreign_links {
HttpRequest(wreq::Error);
Io(std::io::Error);
}
errors {
Parse(msg: String) {
description("parse error")
display("parse error: {}", msg)
}
}
}
#[derive(Debug, Clone)]
pub struct NotfansProvider {
url: String,
}
#[derive(Debug, Clone)]
enum Target {
Latest,
MostPopular,
TopRated,
Today,
Week,
Month,
Year,
Search { query: String },
Tag { tag: String },
}
impl NotfansProvider {
pub fn new() -> Self {
Self {
url: BASE_URL.to_string(),
}
}
fn build_channel(&self, _clientversion: ClientVersion) -> Channel {
Channel {
id: CHANNEL_ID.to_string(),
name: "NotFans".to_string(),
description:
"NotFans — free OnlyFans, Fansly, Patreon, and Manyvids leak aggregator. \
Listing-only feed sorted by latest, most popular, top rated, and top by period."
.to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=notfans.com".to_string(),
status: "active".to_string(),
categories: vec![],
options: vec![ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Browse the NotFans archive.".to_string(),
systemImage: "list.number".to_string(),
colorName: "blue".to_string(),
options: vec![
FilterOption { id: "new".to_string(), title: "Latest".to_string() },
FilterOption { id: "popular".to_string(), title: "Most Viewed".to_string() },
FilterOption { id: "top".to_string(), title: "Top Rated".to_string() },
FilterOption { id: "today".to_string(), title: "Top Today".to_string() },
FilterOption { id: "week".to_string(), title: "Top This Week".to_string() },
FilterOption { id: "month".to_string(), title: "Top This Month".to_string() },
FilterOption { id: "year".to_string(), title: "Top This Year".to_string() },
],
multiSelect: false,
}],
nsfw: true,
cacheDuration: Some(1800),
ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()),
}
}
fn html_headers() -> Vec<(String, String)> {
vec![
("Referer".to_string(), REFERER.to_string()),
("User-Agent".to_string(), BROWSER_UA.to_string()),
("Accept".to_string(), HTML_ACCEPT.to_string()),
("Accept-Language".to_string(), "en-US,en;q=0.9".to_string()),
]
}
fn absolute_url(&self, value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return trimmed.to_string();
}
if trimmed.starts_with("//") {
return format!("https:{trimmed}");
}
format!(
"{}/{}",
self.url.trim_end_matches('/'),
trimmed.trim_start_matches('/')
)
}
fn resolve_target(sort: &str, query: Option<&str>) -> Target {
if let Some(raw) = query.map(str::trim).filter(|q| !q.is_empty()) {
// Optional `tag:` prefix routes to a tag archive page.
if let Some(rest) = raw
.strip_prefix("tag:")
.or_else(|| raw.strip_prefix("category:"))
.or_else(|| raw.strip_prefix("tags:"))
{
let tag = rest.trim();
if !tag.is_empty() {
return Target::Tag { tag: tag.to_string() };
}
}
return Target::Search {
query: raw.to_string(),
};
}
match sort {
"popular" | "hot" | "most_viewed" | "most-viewed" => Target::MostPopular,
"top" | "rated" | "top_rated" | "top-rated" => Target::TopRated,
"today" | "day" => Target::Today,
"week" | "weekly" => Target::Week,
"month" | "monthly" => Target::Month,
"year" | "yearly" => Target::Year,
_ => Target::Latest,
}
}
fn build_url(&self, target: &Target, page: u16) -> String {
let page = page.max(1);
match target {
Target::Latest => {
if page == 1 {
format!("{}/latest-updates/", self.url)
} else {
format!("{}/latest-updates/{page}/", self.url)
}
}
Target::MostPopular => {
if page == 1 {
format!("{}/most-popular/", self.url)
} else {
format!("{}/most-popular/{page}/", self.url)
}
}
Target::TopRated => {
if page == 1 {
format!("{}/top-rated/", self.url)
} else {
format!("{}/top-rated/{page}/", self.url)
}
}
Target::Today => self.period_url("day", page),
Target::Week => self.period_url("week", page),
Target::Month => self.period_url("month", page),
Target::Year => self.period_url("year", page),
Target::Search { query } => self.search_url(query, page),
Target::Tag { tag } => self.tag_url(tag, page),
}
}
fn period_url(&self, period: &str, page: u16) -> String {
if page == 1 {
format!("{}/{period}/", self.url)
} else {
format!("{}/{period}/{page}/", self.url)
}
}
fn search_url(&self, query: &str, page: u16) -> String {
let encoded = utf8_percent_encode(query, NON_ALPHANUMERIC).to_string();
if page == 1 {
format!("{}/search/{encoded}/", self.url)
} else {
format!("{}/search/{encoded}/{page}/", self.url)
}
}
fn tag_url(&self, tag: &str, page: u16) -> String {
let encoded = utf8_percent_encode(tag, NON_ALPHANUMERIC).to_string();
if page == 1 {
format!("{}/tags/{encoded}/", self.url)
} else {
format!("{}/tags/{encoded}/{page}/", self.url)
}
}
fn card_segments(html: &str) -> Vec<&str> {
// Split on the literal class marker so we get a tail-aligned slice per
// card. Anything before the first marker is the page chrome.
html.split(CARD_DELIMITER).skip(1).collect()
}
fn parse_card_segment(segment: &str) -> Option<(String, String, String, String)> {
// <a href="https://notfans.com/videos/228306/acehardz-wrecked-moderngomorra/" title="AceHardz Wrecked ModernGomorra" >
let href_start = segment.find("<a href=\"")?;
let after_href = &segment[href_start + "<a href=\"".len()..];
let href_end = after_href.find('"')?;
let href = &after_href[..href_end];
let href = decode(href.as_bytes())
.to_string()
.unwrap_or_else(|_| href.to_string());
if href.is_empty() {
return None;
}
// title="AceHardz Wrecked ModernGomorra"
let title_start = after_href[href_end..].find("title=\"")?;
let after_title = &after_href[href_end + title_start + "title=\"".len()..];
let title_end = after_title.find('"')?;
let title = decode(after_title[..title_end].as_bytes())
.to_string()
.unwrap_or_else(|_| after_title[..title_end].to_string());
if title.is_empty() {
return None;
}
// <img class="thumb " src="https://notfans.com/contents/videos_screenshots/.../320x180/N.jpg" ...>
let thumb = Self::extract_thumb(after_title).unwrap_or_default();
Some((href.to_string(), title.trim().to_string(), thumb, String::new()))
}
fn extract_thumb(haystack: &str) -> Option<String> {
// Walk the segment for the first <img class="thumb" src="..."> match.
let needle = "class=\"thumb";
let mut cursor = 0usize;
while let Some(found) = haystack[cursor..].find(needle) {
let img_start = cursor + found;
let after = &haystack[img_start..];
// Match a quoted `src="..."` within the same tag.
let after_class = &after[needle.len()..];
let src_idx = after_class.find("src=\"")?;
let after_src = &after_class[src_idx + "src=\"".len()..];
let src_end = after_src.find('"')?;
let src = decode(after_src[..src_end].as_bytes())
.to_string()
.unwrap_or_else(|_| after_src[..src_end].to_string());
if !src.is_empty() {
return Some(src);
}
cursor = img_start + needle.len();
}
None
}
fn parse_listing(&self, html: &str) -> Vec<VideoItem> {
Self::card_segments(html)
.into_iter()
.filter_map(|segment| Self::parse_card_segment(segment))
.filter_map(|(href, title, thumb, _)| {
let url = if href.starts_with("http://") || href.starts_with("https://") {
href
} else {
self.absolute_url(&href)
};
let id = Self::extract_id_from_url(&url)?;
if id.is_empty() || title.is_empty() {
return None;
}
Some(VideoItem::new(
id,
title,
url,
CHANNEL_ID.to_string(),
thumb,
0,
))
})
.collect()
}
fn extract_id_from_url(url: &str) -> Option<String> {
// /videos/228306/acehardz-wrecked-moderngomorra/ → 228306
let parsed = Url::parse(url).ok()?;
let mut segments = parsed.path_segments()?.filter(|s| !s.is_empty());
let videos = segments.next()?;
if !videos.eq_ignore_ascii_case("videos") {
return None;
}
let id = segments.next()?;
let cleaned: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
}
fn apply_detail(&self, mut item: VideoItem, html: &str) -> VideoItem {
// Duration: <em>30:30</em>
if item.duration == 0 {
if let Some(duration_text) = Self::capture_after_label(html, "Duration:", "<em>")
{
if let Some(end) = duration_text.find("</em>") {
let raw = duration_text[..end].trim();
if let Some(seconds) = parse_time_to_seconds(raw) {
if seconds > 0 {
item.duration = seconds.max(0).min(u32::MAX as i64) as u32;
}
}
}
}
}
// Views: <em>289</em>
if item.views.is_none() {
if let Some(views_text) = Self::capture_after_label(html, "Views:", "<em>") {
if let Some(end) = views_text.find("</em>") {
let raw = views_text[..end].trim();
if let Ok(value) = raw.replace(',', "").parse::<u32>() {
item.views = Some(value);
}
}
}
}
// Tags: <a href="/tags/acehardz/">acehardz</a> ... up to </div>
if item.tags.is_none() {
if let Some(tags_html) = Self::slice_after_label(html, "Tags:") {
if let Some(div_end) = tags_html.find("</div>") {
let block = &tags_html[..div_end];
let tag_re = Regex::new(r#"/tags/[^"/?#]+/?"?\s*[^>]*>([^<]+)</a>"#).ok();
if let Some(re) = tag_re {
let mut tags: Vec<String> = re
.captures_iter(block)
.filter_map(|caps| caps.get(1).map(|m| m.as_str().trim().to_string()))
.filter(|t| !t.is_empty())
.collect();
tags.sort();
tags.dedup();
if !tags.is_empty() {
item.tags = Some(tags);
}
}
}
}
}
// Submitted: <em>4 hours ago</em> → relative timestamp
if item.uploadedAt.is_none() {
if let Some(submitted) = Self::capture_after_label(html, "Submitted:", "<em>") {
if let Some(end) = submitted.find("</em>") {
let raw = submitted[..end].trim();
if let Some(ts) = Self::parse_relative_time(raw) {
item.uploadedAt = Some(ts);
}
}
}
}
// Format URL: first Download link to a /get_file/6/.../{id}.mp4/...
if item.formats.is_none() {
if let Some(download_block) = Self::slice_after_label(html, "Download:") {
if let Some(div_end) = download_block.find("</div>") {
let block = &download_block[..div_end];
if let Some(format_url) = Self::extract_download_url(block) {
let mut format = VideoFormat::new(
format_url,
"720p".to_string(),
"mp4".to_string(),
);
format = format.http_header("Referer".to_string(), REFERER.to_string());
format = format.http_header("User-Agent".to_string(), BROWSER_UA.to_string());
item.formats = Some(vec![format]);
}
}
}
}
// Preview: og:image if present and richer than the listing thumb.
if item.preview.is_none() {
if let Some(og_image) = Self::extract_og_image(html) {
if !og_image.is_empty() {
item.preview = Some(og_image);
}
}
}
item
}
fn capture_after_label<'a>(
haystack: &'a str,
label: &str,
open_tag: &str,
) -> Option<&'a str> {
let idx = haystack.find(label)?;
let after_label = &haystack[idx + label.len()..];
let open_idx = after_label.find(open_tag)?;
Some(&after_label[open_idx + open_tag.len()..])
}
fn slice_after_label<'a>(haystack: &'a str, label: &str) -> Option<&'a str> {
let idx = haystack.find(label)?;
Some(&haystack[idx + label.len()..])
}
fn extract_download_url(block: &str) -> Option<String> {
// First <a href="...get_file/6/{hash}/{id3}/{id}/{id}.mp4/?...">MP4 ...</a>
let needle = "get_file/6/";
let idx = block.find(needle)?;
// Walk backwards to the start of the href= attribute
let before = &block[..idx];
let href_idx = before.rfind("href=\"")?;
let after = &block[href_idx + "href=\"".len()..];
let href_end = after.find('"')?;
let url = decode(after[..href_end].as_bytes())
.to_string()
.unwrap_or_else(|_| after[..href_end].to_string());
if url.is_empty() {
None
} else {
Some(url)
}
}
fn extract_og_image(html: &str) -> Option<String> {
let needle = "property=\"og:image\"";
let idx = html.find(needle)?;
let after = &html[idx + needle.len()..];
let content_idx = after.find("content=\"")?;
let after_content = &after[content_idx + "content=\"".len()..];
let end = after_content.find('"')?;
let url = decode(after_content[..end].as_bytes())
.to_string()
.unwrap_or_else(|_| after_content[..end].to_string());
if url.is_empty() {
None
} else {
Some(url)
}
}
fn parse_relative_time(text: &str) -> Option<u64> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
// "4 hours ago", "1 day ago", "3 weeks ago", "2 months ago", "1 year ago"
// and the bare unit forms like "5 minutes", "Yesterday", "Just now".
let lowered = trimmed.to_ascii_lowercase();
let now = Utc::now();
let now_ts = now.timestamp() as u64;
if lowered.starts_with("just now") || lowered == "now" {
return Some(now_ts);
}
if lowered.starts_with("yesterday") {
return Some(now_ts - ChronoDuration::days(1).num_seconds() as u64);
}
if lowered.contains("second") {
return Some(now_ts);
}
let re = Regex::new(r"^(\d+)\s+(minute|hour|day|week|month|year)s?(?:\s+ago)?$").ok()?;
let caps = re.captures(&lowered)?;
let amount: i64 = caps.get(1)?.as_str().parse().ok()?;
let unit = caps.get(2)?.as_str();
let delta = match unit {
"minute" => ChronoDuration::minutes(amount),
"hour" => ChronoDuration::hours(amount),
"day" => ChronoDuration::days(amount),
"week" => ChronoDuration::weeks(amount),
"month" => ChronoDuration::days(amount * 30),
"year" => ChronoDuration::days(amount * 365),
_ => return None,
};
Some((now - delta).timestamp().max(0) as u64)
}
async fn fetch_html(
&self,
options: &ServerOptions,
context: &str,
url: &str,
) -> Result<String> {
let mut requester = requester_or_default(options, CHANNEL_ID, context);
let response = requester
.get_with_headers(url, Self::html_headers(), Some(Version::HTTP_11))
.await
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))?;
Ok(response)
}
async fn enrich_item(
&self,
item: VideoItem,
options: &ServerOptions,
) -> VideoItem {
let detail_url = item.url.clone();
let fetch = timeout(
StdDuration::from_secs(DETAIL_TIMEOUT_SECS),
self.fetch_html(options, "notfans.enrich", &detail_url),
)
.await;
match fetch {
Ok(Ok(html)) => self.apply_detail(item, &html),
Ok(Err(error)) => {
report_provider_error_background(CHANNEL_ID, "enrich_item", &error.to_string());
item
}
Err(_) => {
report_provider_error_background(
CHANNEL_ID,
"enrich_item_timeout",
&detail_url,
);
item
}
}
}
async fn fetch_items(
&self,
cache: VideoCache,
url: String,
per_page_limit: usize,
enrich: bool,
options: &ServerOptions,
) -> Result<Vec<VideoItem>> {
if let Some((time, items)) = cache.get(&url) {
if time.elapsed().unwrap_or_default().as_secs() < CACHE_TTL_SECS {
return Ok(items.clone());
}
}
let html = self.fetch_html(options, "notfans.fetch_items", &url).await?;
let listed = self.parse_listing(&html);
if listed.is_empty() {
return Ok(vec![]);
}
let limited: Vec<VideoItem> = listed
.into_iter()
.take(per_page_limit.max(1))
.collect();
if !enrich {
cache.insert(url, limited.clone());
return Ok(limited);
}
let items = stream::iter(limited.into_iter().map(|item| {
let provider = self.clone();
let options = options.clone();
async move { provider.enrich_item(item, &options).await }
}))
.buffer_unordered(DETAIL_FETCH_CONCURRENCY)
.collect::<Vec<_>>()
.await;
if !items.is_empty() {
cache.insert(url, items.clone());
}
Ok(items)
}
}
fn report_provider_error_background(provider: &str, context: &str, msg: &str) {
let provider = provider.to_string();
let context = context.to_string();
let msg = msg.to_string();
tokio::spawn(async move {
crate::providers::report_provider_error(&provider, &context, &msg).await;
});
}
#[async_trait]
impl Provider for NotfansProvider {
async fn get_videos(
&self,
cache: VideoCache,
_pool: DbPool,
sort: String,
query: Option<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let sort_value = if sort.is_empty() {
options.sort.as_deref().unwrap_or("new").to_string()
} else {
sort
};
let page_num = page.parse::<u16>().unwrap_or(1).max(1);
let per_page_limit = per_page.parse::<usize>().unwrap_or(30).clamp(1, 64);
let target = Self::resolve_target(&sort_value, query.as_deref());
let url = self.build_url(&target, page_num);
// Only enrich the first page to keep cold-start latency bounded;
// subsequent pages are reached via the app's own prefetch / cache.
let enrich = page_num == 1;
match self
.fetch_items(cache, url, per_page_limit, enrich, &options)
.await
{
Ok(videos) => videos,
Err(error) => {
report_provider_error(CHANNEL_ID, "get_videos", &error.to_string()).await;
vec![]
}
}
}
fn get_channel(&self, clientversion: ClientVersion) -> Option<Channel> {
Some(self.build_channel(clientversion))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn provider() -> NotfansProvider {
NotfansProvider::new()
}
#[test]
fn builds_latest_url_without_page_suffix() {
let url = provider().build_url(&Target::Latest, 1);
assert_eq!(url, "https://notfans.com/latest-updates/");
}
#[test]
fn builds_latest_url_with_page() {
let url = provider().build_url(&Target::Latest, 7);
assert_eq!(url, "https://notfans.com/latest-updates/7/");
}
#[test]
fn builds_period_urls() {
let p = provider();
assert_eq!(p.build_url(&Target::Today, 1), "https://notfans.com/day/");
assert_eq!(p.build_url(&Target::Week, 3), "https://notfans.com/week/3/");
assert_eq!(p.build_url(&Target::Month, 1), "https://notfans.com/month/");
assert_eq!(p.build_url(&Target::Year, 5), "https://notfans.com/year/5/");
}
#[test]
fn builds_search_url() {
let url = provider().build_url(
&Target::Search {
query: "ace hardz".to_string(),
},
1,
);
assert_eq!(url, "https://notfans.com/search/ace%20hardz/");
}
#[test]
fn builds_tag_url() {
let url = provider().build_url(
&Target::Tag {
tag: "blowjob".to_string(),
},
2,
);
assert_eq!(url, "https://notfans.com/tags/blowjob/2/");
}
#[test]
fn resolves_sort_to_target() {
assert!(matches!(
NotfansProvider::resolve_target("popular", None),
Target::MostPopular
));
assert!(matches!(
NotfansProvider::resolve_target("top", None),
Target::TopRated
));
assert!(matches!(
NotfansProvider::resolve_target("today", None),
Target::Today
));
assert!(matches!(
NotfansProvider::resolve_target("new", None),
Target::Latest
));
assert!(matches!(
NotfansProvider::resolve_target("unknown", None),
Target::Latest
));
}
#[test]
fn resolves_query_to_search_or_tag() {
assert!(matches!(
NotfansProvider::resolve_target("new", Some("blowjob")),
Target::Search { .. }
));
assert!(matches!(
NotfansProvider::resolve_target("new", Some("tag:anal")),
Target::Tag { .. }
));
assert!(matches!(
NotfansProvider::resolve_target("new", Some(" tag:milf ")),
Target::Tag { .. }
));
}
#[test]
fn parses_id_from_video_url() {
assert_eq!(
NotfansProvider::extract_id_from_url(
"https://notfans.com/videos/228306/acehardz-wrecked-moderngomorra/"
),
Some("228306".to_string())
);
assert_eq!(NotfansProvider::extract_id_from_url("https://example.com/"), None);
assert_eq!(
NotfansProvider::extract_id_from_url("https://notfans.com/videos//slug/"),
None
);
}
#[test]
fn parses_card_segment() {
let segment = r#"
<a href="https://notfans.com/videos/228306/acehardz-wrecked-moderngomorra/" title="AceHardz Wrecked ModernGomorra" >
<div class="img">
<img class="thumb " src="https://notfans.com/contents/videos_screenshots/228000/228306/320x180/4.jpg" alt="x" data-cnt="5" width="320" height="180"/>
</div>
<strong class="title">x</strong>
</a>
"#;
let (href, title, thumb, _) = NotfansProvider::parse_card_segment(segment).unwrap();
assert_eq!(href, "https://notfans.com/videos/228306/acehardz-wrecked-moderngomorra/");
assert_eq!(title, "AceHardz Wrecked ModernGomorra");
assert!(thumb.contains("228306/320x180/4.jpg"));
}
#[test]
fn parses_listing_from_realistic_html() {
let html = format!(
"{chrome}<div class=\"item \">{card1}</div>\
<div class=\"item avd\"><a href=\"#\" id=\"ad\">sponsored</a></div>\
<div class=\"item \">{card2}</div>{footer}",
chrome = r#"<html><body><div class="head">junk</div>"#,
card1 = r#"
<a href="https://notfans.com/videos/228306/acehardz-wrecked-moderngomorra/" title="AceHardz Wrecked ModernGomorra" >
<div class="img">
<img class="thumb " src="https://notfans.com/contents/videos_screenshots/228000/228306/320x180/4.jpg" alt="x" data-cnt="5" width="320" height="180"/>
</div>
<strong class="title">x</strong>
</a>
"#,
card2 = r#"
<a href="https://notfans.com/videos/100/gracie-bon-ppv/" title="Gracie Bon PPV" >
<div class="img">
<img class="thumb " src="https://notfans.com/contents/videos_screenshots/0/100/320x180/1.jpg" alt="x" data-cnt="1" width="320" height="180"/>
</div>
<strong class="title">x</strong>
</a>
"#,
footer = "</body></html>",
);
let items = provider().parse_listing(&html);
assert_eq!(items.len(), 2, "expected 2 items, sponsored card must be skipped");
assert_eq!(items[0].id, "228306");
assert_eq!(items[0].title, "AceHardz Wrecked ModernGomorra");
assert!(items[0].thumb.contains("228306/320x180/4.jpg"));
assert_eq!(items[1].id, "100");
}
#[test]
fn apply_detail_extracts_metadata() {
let html = r#"
<html><body>
<h1>Some Video</h1>
<div class="info">
<span>Duration: <em>30:30</em></span>
<span>Views: <em>289</em></span>
<span>Submitted: <em>4 hours ago</em></span>
</div>
<div class="item">
Tags:
<a href="https://notfans.com/tags/acehardz/">acehardz</a>
<a href="https://notfans.com/tags/wrecked/">wrecked</a>
</div>
<div class="item">
Download:
<a href="https://notfans.com/get_file/6/abcdef/228000/228306/228306.mp4/?download_filename=foo.mp4&download=true" data-attach-session="PHPSESSID">MP4 720p, 808.68 Mb</a>
</div>
<meta property="og:image" content="https://notfans.com/contents/videos_screenshots/228000/228306/preview.jpg"/>
</body></html>
"#;
let mut item = VideoItem::new(
"228306".to_string(),
"Some Video".to_string(),
"https://notfans.com/videos/228306/some-video/".to_string(),
CHANNEL_ID.to_string(),
"https://notfans.com/contents/videos_screenshots/228000/228306/320x180/4.jpg".to_string(),
0,
);
item = provider().apply_detail(item, html);
assert_eq!(item.duration, 30 * 60 + 30);
assert_eq!(item.views, Some(289));
assert_eq!(
item.tags.as_deref(),
Some(vec!["acehardz".to_string(), "wrecked".to_string()]).as_deref()
);
assert!(item.uploadedAt.is_some());
let formats = item.formats.as_ref().expect("formats should be populated");
assert_eq!(formats.len(), 1);
assert!(formats[0].url.contains("get_file/6/abcdef/228000/228306/228306.mp4"));
let headers = formats[0].http_headers.as_ref().expect("headers set");
assert_eq!(headers.get("Referer").map(String::as_str), Some(REFERER));
assert_eq!(
item.preview.as_deref(),
Some("https://notfans.com/contents/videos_screenshots/228000/228306/preview.jpg")
);
}
#[test]
fn parse_relative_time_handles_units() {
let now = Utc::now().timestamp() as u64;
// All values are within 5s of `now` so we use loose ranges.
let cases = [
("4 hours ago", ChronoDuration::hours(4).num_seconds() as u64),
("1 day ago", ChronoDuration::days(1).num_seconds() as u64),
("2 weeks ago", ChronoDuration::weeks(2).num_seconds() as u64),
("3 months ago", ChronoDuration::days(90).num_seconds() as u64),
("just now", 0),
("Yesterday", ChronoDuration::days(1).num_seconds() as u64),
];
for (text, expected_offset) in cases {
let parsed = NotfansProvider::parse_relative_time(text)
.unwrap_or_else(|| panic!("parse failed for {text}"));
if expected_offset == 0 {
assert!((now as i64 - parsed as i64).abs() <= 2);
} else {
let actual_offset = now - parsed;
let drift = (actual_offset as i64 - expected_offset as i64).abs();
assert!(
drift <= 5,
"text={text} expected_offset={expected_offset} actual_offset={actual_offset}"
);
}
}
}
}