more providers
This commit is contained in:
25
build.rs
25
build.rs
@@ -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() {
|
||||||
|
|||||||
8
check.py
8
check.py
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
1146
src/providers/borntobefuck.rs
Normal file
1146
src/providers/borntobefuck.rs
Normal file
File diff suppressed because it is too large
Load Diff
709
src/providers/fapello.rs
Normal file
709
src/providers/fapello.rs
Normal 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
620
src/providers/hdporn92.rs
Normal 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
999
src/providers/hornyleak.rs
Normal 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
886
src/providers/notfans.rs
Normal 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user