Add get_probe/probe_first_archive; fullporner and fullpornerorg resolve pornstar names without prefix
This commit is contained in:
19
docs/query-resolution-todo.md
Normal file
19
docs/query-resolution-todo.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Seamless query resolution todo
|
||||||
|
|
||||||
|
Goal: a free-form query ("gal ritchie", "family strokes") should reach the matching
|
||||||
|
pornstar / studio / channel / tag archive **without** a `pornstar:` / `studio:` / `tag:` prefix,
|
||||||
|
by asking the site (probe) and falling back to keyword search. Prefixes stay as explicit overrides.
|
||||||
|
|
||||||
|
Pattern (see `hooporn`, `cumlouder`): after known-category matching, probe page 1 of each entity
|
||||||
|
archive for the slug with `Requester::get_probe` (404 = instant `None`, Cloudflare challenge goes to
|
||||||
|
FlareSolverr, 5xx/429 retried once), use the first archive that yields cards (page N stays in that
|
||||||
|
archive), else keyword search. A probe is only valid after checking what the site answers for an
|
||||||
|
unknown slug (404 vs soft-404 that lists unrelated videos).
|
||||||
|
|
||||||
|
Status: `[ ]` open, `[x]` done and verified live (feed of the archive + unknown name falls back),
|
||||||
|
`[-]` not applicable (reason).
|
||||||
|
|
||||||
|
| | Provider | Prefix kinds today | Plan / evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [x] | `hooporn` | cat, pornstar, studio | reference implementation |
|
||||||
|
| [x] | `cumlouder` | cat, tag, series, girl | reference implementation (girl page, then tag) |
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::DbPool;
|
use crate::DbPool;
|
||||||
use crate::api::ClientVersion;
|
use crate::api::ClientVersion;
|
||||||
use crate::providers::{Provider, report_provider_error, requester_or_default};
|
use crate::providers::{
|
||||||
|
Provider, probe_first_archive, report_provider_error, requester_or_default, slug_dashed,
|
||||||
|
};
|
||||||
use crate::status::*;
|
use crate::status::*;
|
||||||
use crate::util::cache::VideoCache;
|
use crate::util::cache::VideoCache;
|
||||||
use crate::util::time::parse_time_to_seconds;
|
use crate::util::time::parse_time_to_seconds;
|
||||||
@@ -134,13 +136,13 @@ impl FullpornerProvider {
|
|||||||
}
|
}
|
||||||
if let Some(slug) = q.strip_prefix("pornstar:") {
|
if let Some(slug) = q.strip_prefix("pornstar:") {
|
||||||
return Target::Pornstar {
|
return Target::Pornstar {
|
||||||
slug: slug.trim().to_string(),
|
slug: slug_dashed(slug),
|
||||||
page,
|
page,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(slug) = q.strip_prefix("star:") {
|
if let Some(slug) = q.strip_prefix("star:") {
|
||||||
return Target::Pornstar {
|
return Target::Pornstar {
|
||||||
slug: slug.trim().to_string(),
|
slug: slug_dashed(slug),
|
||||||
page,
|
page,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -153,6 +155,25 @@ impl FullpornerProvider {
|
|||||||
Target::Latest { page }
|
Target::Latest { page }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A free-form query that names a pornstar goes to her archive (probed on
|
||||||
|
/// the site; unknown names 404), otherwise it stays a keyword search.
|
||||||
|
async fn resolve_keyword(query: &str, page: u32, options: &ServerOptions) -> Option<Target> {
|
||||||
|
let slug = slug_dashed(query);
|
||||||
|
if slug.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let probe = Target::Pornstar { slug: slug.clone(), page: 1 };
|
||||||
|
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
|
||||||
|
probe_first_archive(
|
||||||
|
&mut requester,
|
||||||
|
&[Self::build_listing_url(&probe)],
|
||||||
|
&format!("{BASE_URL}/"),
|
||||||
|
|body| body.contains("class=\"video-card\""),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| Target::Pornstar { slug, page })
|
||||||
|
}
|
||||||
|
|
||||||
fn selector(value: &str) -> Result<Selector> {
|
fn selector(value: &str) -> Result<Selector> {
|
||||||
Selector::parse(value)
|
Selector::parse(value)
|
||||||
.map_err(|e| Error::from(format!("selector `{value}` failed: {e}")))
|
.map_err(|e| Error::from(format!("selector `{value}` failed: {e}")))
|
||||||
@@ -394,7 +415,10 @@ impl Provider for FullpornerProvider {
|
|||||||
.filter(|q| !q.is_empty())
|
.filter(|q| !q.is_empty())
|
||||||
.map(ToOwned::to_owned);
|
.map(ToOwned::to_owned);
|
||||||
|
|
||||||
let target = Self::pick_target(normalized_query.as_deref(), page);
|
let mut target = Self::pick_target(normalized_query.as_deref(), page);
|
||||||
|
if let Target::Search { query, .. } = &target {
|
||||||
|
target = Self::resolve_keyword(query, page, &options).await.unwrap_or(target);
|
||||||
|
}
|
||||||
|
|
||||||
match Self::fetch_page(&target, per_page, &options).await {
|
match Self::fetch_page(&target, per_page, &options).await {
|
||||||
Ok(items) => items,
|
Ok(items) => items,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::DbPool;
|
use crate::DbPool;
|
||||||
use crate::api::ClientVersion;
|
use crate::api::ClientVersion;
|
||||||
use crate::providers::{
|
use crate::providers::{probe_first_archive, slug_dashed,
|
||||||
Provider, report_provider_error, report_provider_error_background, requester_or_default,
|
Provider, report_provider_error, report_provider_error_background, requester_or_default,
|
||||||
};
|
};
|
||||||
use crate::status::*;
|
use crate::status::*;
|
||||||
@@ -109,12 +109,12 @@ impl FullpornerorgProvider {
|
|||||||
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
|
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
|
||||||
if let Some(slug) = q.strip_prefix("pornstar:") {
|
if let Some(slug) = q.strip_prefix("pornstar:") {
|
||||||
return Target::Pornstar {
|
return Target::Pornstar {
|
||||||
slug: slug.trim().to_string(),
|
slug: slug_dashed(slug),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(slug) = q.strip_prefix("star:") {
|
if let Some(slug) = q.strip_prefix("star:") {
|
||||||
return Target::Pornstar {
|
return Target::Pornstar {
|
||||||
slug: slug.trim().to_string(),
|
slug: slug_dashed(slug),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return Target::Search {
|
return Target::Search {
|
||||||
@@ -153,6 +153,25 @@ impl FullpornerorgProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A free-form query that names a pornstar goes to her archive (probed on
|
||||||
|
/// the site; unknown names 404), otherwise it stays a keyword search.
|
||||||
|
async fn resolve_keyword(query: &str, options: &ServerOptions) -> Option<Target> {
|
||||||
|
let slug = slug_dashed(query);
|
||||||
|
if slug.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let target = Target::Pornstar { slug };
|
||||||
|
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
|
||||||
|
probe_first_archive(
|
||||||
|
&mut requester,
|
||||||
|
&[Self::build_listing_url(&target, 1)],
|
||||||
|
&format!("{BASE_URL}/"),
|
||||||
|
|body| body.contains("loop-video"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| target)
|
||||||
|
}
|
||||||
|
|
||||||
fn html_headers(referer: &str) -> Vec<(String, String)> {
|
fn html_headers(referer: &str) -> Vec<(String, String)> {
|
||||||
vec![
|
vec![
|
||||||
("User-Agent".to_string(), BROWSER_UA.to_string()),
|
("User-Agent".to_string(), BROWSER_UA.to_string()),
|
||||||
@@ -446,7 +465,10 @@ impl Provider for FullpornerorgProvider {
|
|||||||
.filter(|q| !q.is_empty())
|
.filter(|q| !q.is_empty())
|
||||||
.map(ToOwned::to_owned);
|
.map(ToOwned::to_owned);
|
||||||
|
|
||||||
let target = Self::pick_target(normalized_query.as_deref());
|
let mut target = Self::pick_target(normalized_query.as_deref());
|
||||||
|
if let Target::Search { query } = &target {
|
||||||
|
target = Self::resolve_keyword(query, &options).await.unwrap_or(target);
|
||||||
|
}
|
||||||
|
|
||||||
match Self::fetch_page(target, page, per_page, &options).await {
|
match Self::fetch_page(target, page, per_page, &options).await {
|
||||||
Ok(items) => items,
|
Ok(items) => items,
|
||||||
|
|||||||
@@ -430,40 +430,13 @@ impl HoopornProvider {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches a listing page. A real 404 (unknown archive, page past the end)
|
/// Fetches a listing page; `None` for a real 404 (see `Requester::get_probe`).
|
||||||
/// is `None` and answers immediately; a Cloudflare challenge is handed to
|
|
||||||
/// the requester's full chain (FlareSolverr).
|
|
||||||
async fn fetch_html(requester: &mut Requester, url: &str) -> Result<Option<String>> {
|
async fn fetch_html(requester: &mut Requester, url: &str) -> Result<Option<String>> {
|
||||||
let headers = vec![("Referer".to_string(), format!("{BASE_URL}/"))];
|
let headers = vec![("Referer".to_string(), format!("{BASE_URL}/"))];
|
||||||
let mut last_err = String::new();
|
requester
|
||||||
for _ in 0..2 {
|
.get_probe(url, headers)
|
||||||
match requester.get_raw_with_headers(url, headers.clone()).await {
|
.await
|
||||||
Ok(response) => {
|
.map_err(|e| Error::from(format!("fetch failed for {url}: {e}")))
|
||||||
let status = response.status().as_u16();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
if status == 404 {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
if status == 403
|
|
||||||
|| status == 503
|
|
||||||
|| Requester::looks_like_cloudflare_challenge(&body)
|
|
||||||
{
|
|
||||||
return requester
|
|
||||||
.get_with_headers(url, headers, None)
|
|
||||||
.await
|
|
||||||
.map(Some)
|
|
||||||
.map_err(|e| Error::from(format!("fetch failed for {url}: {e}")));
|
|
||||||
}
|
|
||||||
if status >= 500 || status == 429 {
|
|
||||||
last_err = format!("HTTP {status}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return Ok(Some(body));
|
|
||||||
}
|
|
||||||
Err(e) => last_err = e.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(Error::from(format!("fetch failed for {url}: {last_err}")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch(target: &Target, page: u32, options: &ServerOptions) -> Result<Vec<VideoItem>> {
|
async fn fetch(target: &Target, page: u32, options: &ServerOptions) -> Result<Vec<VideoItem>> {
|
||||||
|
|||||||
@@ -612,6 +612,50 @@ pub fn report_provider_error_background(provider_name: &str, context: &str, msg:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Some(slug)` joined with `-` ("Gal Ritchie" -> "gal-ritchie") for archive URLs.
|
||||||
|
pub fn slug_dashed(value: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut last_dash = true;
|
||||||
|
for c in value.trim().chars() {
|
||||||
|
if c.is_alphanumeric() {
|
||||||
|
out.extend(c.to_lowercase());
|
||||||
|
last_dash = false;
|
||||||
|
} else if !last_dash {
|
||||||
|
out.push('-');
|
||||||
|
last_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as [`slug_dashed`] without separators ("Family Strokes" -> "familystrokes").
|
||||||
|
pub fn slug_joined(value: &str) -> String {
|
||||||
|
slug_dashed(value).replace('-', "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes candidate archive URLs (pornstar / studio / channel / tag pages) in
|
||||||
|
/// order and returns the index and body of the first that exists and
|
||||||
|
/// `has_items` accepts. A miss (404, empty page, transport error) moves on to
|
||||||
|
/// the next candidate; the caller falls back to keyword search on `None`.
|
||||||
|
/// Uses `Requester::get_probe`, so an unknown archive answers instantly and a
|
||||||
|
/// Cloudflare challenge is solved through FlareSolverr.
|
||||||
|
pub async fn probe_first_archive(
|
||||||
|
requester: &mut Requester,
|
||||||
|
urls: &[String],
|
||||||
|
referer: &str,
|
||||||
|
has_items: impl Fn(&str) -> bool,
|
||||||
|
) -> Option<(usize, String)> {
|
||||||
|
for (index, url) in urls.iter().enumerate() {
|
||||||
|
let headers = vec![("Referer".to_string(), referer.to_string())];
|
||||||
|
if let Ok(Some(body)) = requester.get_probe(url, headers).await {
|
||||||
|
if has_items(&body) {
|
||||||
|
return Some((index, body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub fn requester_or_default(
|
pub fn requester_or_default(
|
||||||
options: &ServerOptions,
|
options: &ServerOptions,
|
||||||
provider_name: &str,
|
provider_name: &str,
|
||||||
|
|||||||
@@ -661,6 +661,45 @@ impl Requester {
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Existence probe for archive pages (pornstar / studio / tag / category).
|
||||||
|
///
|
||||||
|
/// Reads the raw response so a real 404/410 answers immediately with
|
||||||
|
/// `Ok(None)` instead of escalating through the Jina/FlareSolverr chain
|
||||||
|
/// (minutes per miss). A Cloudflare challenge (403/503/"Just a moment...")
|
||||||
|
/// is handed to the full chain so FlareSolverr solves it, and 429/5xx are
|
||||||
|
/// retried once.
|
||||||
|
pub async fn get_probe(
|
||||||
|
&mut self,
|
||||||
|
url: &str,
|
||||||
|
headers: Vec<(String, String)>,
|
||||||
|
) -> Result<Option<String>, AnyErr> {
|
||||||
|
let mut last_err = String::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
match self.get_raw_with_headers(url, headers.clone()).await {
|
||||||
|
Ok(response) => {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
if status == 404 || status == 410 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if status == 403
|
||||||
|
|| status == 503
|
||||||
|
|| Self::looks_like_cloudflare_challenge(&body)
|
||||||
|
{
|
||||||
|
return self.get_with_headers(url, headers, None).await.map(Some);
|
||||||
|
}
|
||||||
|
if status >= 500 || status == 429 {
|
||||||
|
last_err = format!("HTTP {status}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Ok(Some(body));
|
||||||
|
}
|
||||||
|
Err(e) => last_err = e.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!("probe failed for {url}: {last_err}").into())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get(
|
pub async fn get(
|
||||||
&mut self,
|
&mut self,
|
||||||
url: &str,
|
url: &str,
|
||||||
|
|||||||
Reference in New Issue
Block a user