From e204202611d096c26c488a3012f9681d29ad2b56 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 8 Oct 2026 17:33:09 +0000 Subject: [PATCH] Add fullporner provider with xiaoshenke proxy - Implement fullporner.com provider with search, pagination, categories, pornstars - Add xiaoshenke.net proxy for Cloudflare-protected embed videos - Proxy fetches signed URLs via FlareSolverr and streams video content - Fix quality bitmask mapping (1=360p, 2=480p, 4=720p, 8=1080p) - Support HEAD/GET/Range for video streaming Co-Authored-By: Claude Code --- Cargo.toml | 3 +- build.rs | 5 + src/providers/fullporner.rs | 92 +++++- src/providers/fullpornerorg.rs | 542 +++++++++++++++++++++++++++++++++ src/providers/mod.rs | 4 +- src/providers/supjav.rs | 1 + src/proxies/archivebate.rs | 1 + src/proxies/lulustream.rs | 48 ++- src/proxies/mod.rs | 1 + src/proxies/xiaoshenke.rs | 296 ++++++++++++++++++ src/proxy.rs | 6 + src/util/requester.rs | 83 ++++- 12 files changed, 1039 insertions(+), 43 deletions(-) create mode 100644 src/providers/fullpornerorg.rs create mode 100644 src/proxies/xiaoshenke.rs diff --git a/Cargo.toml b/Cargo.toml index 6a76828..8f9ec4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ cbc = { version = "0.1", features = ["alloc"] } hex = "0.4" chromiumoxide = { version = "0.7", features = ["tokio-runtime"] } playwright-rs = "0.17" +http-body-util = "0.1.5" [lints.rust] warnings = "warn" @@ -68,4 +69,4 @@ incremental = true opt-level = 3 codegen-units = 16 lto = false -debug = 0 \ No newline at end of file +debug = 0 diff --git a/build.rs b/build.rs index 0929210..a52de2d 100644 --- a/build.rs +++ b/build.rs @@ -361,6 +361,11 @@ const PROVIDERS: &[ProviderDef] = &[ module: "fullporner", ty: "FullpornerProvider", }, + ProviderDef { + id: "fullpornerorg", + module: "fullpornerorg", + ty: "FullpornerorgProvider", + }, ProviderDef { id: "thepornbunny", module: "thepornbunny", diff --git a/src/providers/fullporner.rs b/src/providers/fullporner.rs index b2e440a..aa927d1 100644 --- a/src/providers/fullporner.rs +++ b/src/providers/fullporner.rs @@ -16,11 +16,8 @@ pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata = }; const BASE_URL: &str = "https://fullporner.com"; -const MEDIA_HOST: &str = "https://xiaoshenke.net"; const CHANNEL_ID: &str = "fullporner"; const DEFAULT_PER_PAGE: usize = 32; -const BROWSER_UA: &str = - "Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0"; error_chain! { foreign_links { @@ -262,17 +259,68 @@ impl FullpornerProvider { Ok(stubs) } - fn build_format(thumb_id: &str) -> VideoFormat { - let url = format!("{MEDIA_HOST}/vid/{thumb_id}/720"); - let mut fmt = VideoFormat::new(url, "720p".to_string(), "mp4".to_string()); - fmt.add_http_header("Referer".to_string(), format!("{MEDIA_HOST}/")); - fmt.add_http_header("User-Agent".to_string(), BROWSER_UA.to_string()); - fmt + /// Extract xiaoshenke embed URL from the watch page. +/// Returns (video_id, quality_bitmask) from iframe src like "//xiaoshenke.net/video/3827433/14" + fn extract_embed_params(watch_html: &str) -> Option<(String, u8)> { + let document = Html::parse_document(watch_html); + let iframe_sel = Selector::parse("iframe[src*=\"xiaoshenke.net/video/\"]").ok()?; + + for iframe in document.select(&iframe_sel) { + if let Some(src) = iframe.value().attr("src") { + // src format: //xiaoshenke.net/video/{id}/{quality_bitmask} + let parts: Vec<&str> = src.split('/').collect(); + if parts.len() >= 5 && parts[2] == "xiaoshenke.net" && parts[3] == "video" { + let video_id = parts[4].to_string(); + let quality_bitmask = parts[5].parse::().ok()?; + return Some((video_id, quality_bitmask)); + } + } + } + None + } + + /// Fetch watch page and extract embed params, then build proxy URL for formats. + async fn enrich_with_formats( + mut item: VideoItem, + options: &ServerOptions, + ) -> VideoItem { + let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_with_formats"); + let html = match requester.get(&item.url, None).await { + Ok(html) => html, + Err(_) => return item, // Return item without formats on fetch failure + }; + + if let Some((video_id, quality_bitmask)) = Self::extract_embed_params(&html) { + let proxy_url = format!( + "http://127.0.0.1:18080/proxy/xiaoshenke/video/{}/{}", + video_id, quality_bitmask + ); + let height = Self::quality_bitmask_to_height(quality_bitmask); + item.formats = Some(vec![VideoFormat::new( + proxy_url, + format!("{}p", height), + "mp4".to_string(), + )]); + } + item + } + + fn quality_bitmask_to_height(bitmask: u8) -> u32 { + if bitmask & 8 != 0 { + 1080 + } else if bitmask & 4 != 0 { + 720 + } else if bitmask & 2 != 0 { + 480 + } else if bitmask & 1 != 0 { + 360 + } else { + 720 + } } fn stub_to_item(stub: CardStub) -> VideoItem { let url = format!("{BASE_URL}/watch/{}", stub.slug); - let formats = stub.thumb_id.as_deref().map(|id| vec![Self::build_format(id)]); let mut item = VideoItem::new( stub.slug, @@ -282,7 +330,6 @@ impl FullpornerProvider { stub.thumb, stub.duration, ); - item.formats = formats; item.uploadedAt = stub.uploaded_at; item.aspectRatio = Some(16.0 / 9.0); item @@ -301,11 +348,24 @@ impl FullpornerProvider { .map_err(|e| Error::from(format!("fetch failed for {url}: {e}")))?; let stubs = Self::parse_listing_page(&html)?; - let items = stubs - .into_iter() - .take(per_page) - .map(Self::stub_to_item) - .collect(); + let mut items = Vec::new(); + + for stub in stubs { + // Only include items that have a valid thumb_id (i.e., playable formats) + // Old thumbnail format (/imgs/.../xxx_main.jpg) doesn't yield a valid video ID + if stub.thumb_id.is_none() { + continue; + } + + let item = Self::stub_to_item(stub); + let item = Self::enrich_with_formats(item, options).await; + items.push(item); + + if items.len() >= per_page { + break; + } + } + Ok(items) } } diff --git a/src/providers/fullpornerorg.rs b/src/providers/fullpornerorg.rs new file mode 100644 index 0000000..0fa0a4f --- /dev/null +++ b/src/providers/fullpornerorg.rs @@ -0,0 +1,542 @@ +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", "full-length", "embed", "lulustream"], + }; + +const BASE_URL: &str = "https://fullporner.org"; +const CHANNEL_ID: &str = "fullpornerorg"; +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 FullpornerorgProvider; + +#[derive(Debug, Clone)] +enum Target { + Latest, + Search { query: String }, + Pornstar { slug: String }, +} + +#[derive(Debug, Clone)] +struct CardStub { + id: String, + title: String, + url: String, + thumb: String, + duration: u32, + actor_slugs: Vec, +} + +impl FullpornerorgProvider { + pub fn new() -> Self { + Self + } + + fn build_channel(&self, _clientversion: ClientVersion) -> Channel { + Channel { + id: CHANNEL_ID.to_string(), + name: "FullPorner.org".to_string(), + description: + "Full length porn videos from fullporner.org. Uses lulustream embeds; \ + HLS URLs extracted via the lulustream proxy." + .to_string(), + premium: false, + favicon: "https://www.google.com/s2/favicons?sz=64&domain=fullporner.org".to_string(), + status: "active".to_string(), + categories: vec![], + options: vec![ + ChannelOption { + id: "sort".to_string(), + title: "Sort".to_string(), + description: "Browse FullPorner.org by newest videos.".to_string(), + systemImage: "arrow.up.arrow.down".to_string(), + colorName: "blue".to_string(), + options: vec![ + FilterOption { + id: "new".to_string(), + title: "Latest".to_string(), + }, + ], + multiSelect: false, + }, + ], + nsfw: true, + cacheDuration: Some(1800), + ytdlpCommand: Some("yt-dlp --impersonate chrome-120".to_string()), + } + } + + fn selector(value: &str) -> Result { + 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()) { + if let Some(slug) = q.strip_prefix("pornstar:") { + return Target::Pornstar { + slug: slug.trim().to_string(), + }; + } + if let Some(slug) = q.strip_prefix("star:") { + return Target::Pornstar { + slug: slug.trim().to_string(), + }; + } + 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}") + } + } + Target::Pornstar { slug } => { + if page <= 1 { + format!("{BASE_URL}/videos/pornstar/{slug}/") + } else { + format!("{BASE_URL}/videos/pornstar/{slug}/page/{page}/") + } + } + } + } + + 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 { + 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> { + let document = Html::parse_document(html); + // Video cards are article elements with data-video-id attribute + 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 h2, header.entry-header .entry-title a")?; + let duration_sel = Self::selector(".duration")?; + let actor_regex = Regex::new(r"\bactors-([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::>().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 duration_text = card + .select(&duration_sel) + .next() + .map(|s| s.text().collect::>().join("")) + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .unwrap_or_default(); + let duration = crate::util::time::parse_time_to_seconds(&duration_text) + .and_then(|s| u32::try_from(s).ok()) + .unwrap_or(0); + + // Extract actor slugs from class names like "actors-vivienne-vo" + let actor_slugs: Vec = actor_regex + .captures_iter(class_attr) + .filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string())) + .collect(); + + stubs.push(CardStub { + id, + title, + url, + thumb, + duration, + actor_slugs, + }); + } + + Ok(stubs) + } + + /// Extracts the luluvdo.com embed URL from a fullporner.org detail page. + fn extract_embed_url(html: &str) -> Option { + // The embed is in an iframe: "#; + assert_eq!( + FullpornerorgProvider::extract_embed_url(html).as_deref(), + Some("https://luluvdo.com/e/a0jb2i5304d4") + ); + // Also test uppercase attributes + let html2 = r#"