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, category_slug: Option, } 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::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 { 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); 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::>().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::>().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 `