Fix sextb: fetch HTML directly instead of the dead Jina markdown mirror
r.jina.ai now answers 403/429, so every request returned nothing. The site serves plain HTML to the emulating client; parse cards, detail rows and option lists from HTML, and return empty for no-hit searches (the site renders a recommendation tray under the notice). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUYdCh2nCVsQYgJAT7gwSi
This commit is contained in:
@@ -12,7 +12,7 @@ Status: `[ ]` open, `[~]` in progress, `[x]` fixed and verified, `[-]` not repro
|
||||
| | Channel | Symptom | Notes |
|
||||
|---|---|---|---|
|
||||
| [x] | `javtiful` | feed times out (150s) then 0 items; search 0 | site redesign (`article.video-card`); rewrote parser, page URL as `video.url` (yt-dlp-native), no per-item detail fetches. 23 items, p2/search OK |
|
||||
| [ ] | `sextb` | 0 items on feed, search and page 2 | |
|
||||
| [x] | `sextb` | 0 items on feed, search and page 2 | Jina mirror now 403/429; site is plain HTML reachable with the emulating client. Rewrote listing/detail/option parsers for HTML, removed mirror. Remaining check.py errors are tooling items (proxy URL policy; imggle.net thumbs are TLS-gated for `requests`) |
|
||||
| [ ] | `camsoda` | 0 items on every request | live cams, CF + Jina-only fetch (see memory) |
|
||||
| [ ] | `hentaimama` | 0 items or 85-150s timeouts | |
|
||||
| [ ] | `hotbunny` | 0 items; first request 78s | |
|
||||
|
||||
@@ -13,6 +13,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration as ChronoDuration, NaiveDate, Utc};
|
||||
use error_chain::error_chain;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use htmlentity::entity::ICodedDataTrait;
|
||||
use regex::Regex;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration as StdDuration;
|
||||
@@ -40,7 +41,6 @@ error_chain! {
|
||||
}
|
||||
|
||||
const BASE_URL: &str = "https://sextb.net";
|
||||
const MIRROR_PREFIX: &str = "https://r.jina.ai/http://";
|
||||
const CHANNEL_ID: &str = "sextb";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -242,18 +242,6 @@ impl SextbProvider {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn mirror_url(&self, url: &str) -> String {
|
||||
// `url` is already an absolute `https://sextb.net/...` URL, and
|
||||
// MIRROR_PREFIX already supplies a scheme — concatenating them
|
||||
// directly produces a malformed `.../http://https://...` target
|
||||
// that Jina rejects, so strip the scheme first.
|
||||
let stripped = url
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| url.strip_prefix("http://"))
|
||||
.unwrap_or(url);
|
||||
format!("{MIRROR_PREFIX}{stripped}")
|
||||
}
|
||||
|
||||
fn slugify(value: &str) -> String {
|
||||
let mut output = String::new();
|
||||
let mut needs_dash = false;
|
||||
@@ -336,35 +324,36 @@ impl SextbProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_markdown(&self, requester: &mut Requester, url: &str) -> Result<String> {
|
||||
let mirror_url = self.mirror_url(url);
|
||||
/// The site serves plain HTML; fetch it directly. (It used to be read
|
||||
/// through the r.jina.ai markdown mirror, which now answers 403/429.)
|
||||
async fn fetch_html(&self, requester: &mut Requester, url: &str) -> Result<String> {
|
||||
requester
|
||||
.get(&mirror_url, Some(Version::HTTP_11))
|
||||
.get(url, Some(Version::HTTP_2))
|
||||
.await
|
||||
.map_err(|error| Error::from(format!("request failed for {url}: {error}")))
|
||||
}
|
||||
|
||||
fn pagination_last_page(markdown: &str) -> Option<u16> {
|
||||
let regex = Regex::new(r"\[(?P<page>\d+)\]\(https://sextb\.net/[^)]*pg-(?P=page)\)")
|
||||
.ok()?;
|
||||
fn pagination_last_page(html: &str) -> Option<u16> {
|
||||
let regex = Regex::new(r#"/pg-(?P<page>\d+)['"]"#).ok()?;
|
||||
regex
|
||||
.captures_iter(markdown)
|
||||
.captures_iter(html)
|
||||
.filter_map(|captures| captures.name("page")?.as_str().parse::<u16>().ok())
|
||||
.max()
|
||||
}
|
||||
|
||||
fn parse_genre_options(markdown: &str) -> Result<Vec<FilterOption>> {
|
||||
let regex = Self::regex(
|
||||
r#"(?m)^\*\s+\[(?P<title>[^\]]+)\]\(https://sextb\.net/genre/(?P<slug>[^)\s]+)[^)]*\)\((?P<count>\d+)\)$"#,
|
||||
)?;
|
||||
fn collect_options(html: &str, pattern: &str) -> Result<Vec<FilterOption>> {
|
||||
let regex = Self::regex(pattern)?;
|
||||
let mut items = Vec::new();
|
||||
for captures in regex.captures_iter(markdown) {
|
||||
let title = Self::normalize_title(captures.name("title").map(|value| value.as_str()).unwrap_or_default());
|
||||
for captures in regex.captures_iter(html) {
|
||||
let slug = captures
|
||||
.name("slug")
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
.map(|value| value.as_str().trim().to_string())
|
||||
.unwrap_or_default();
|
||||
let raw = captures.name("title").map(|value| value.as_str()).unwrap_or_default();
|
||||
let decoded = htmlentity::entity::decode(raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| raw.to_string());
|
||||
let title = Self::normalize_title(decoded.trim_start_matches('#'));
|
||||
if title.is_empty() || slug.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -373,46 +362,25 @@ impl SextbProvider {
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn parse_studio_options(markdown: &str) -> Result<Vec<FilterOption>> {
|
||||
let regex = Self::regex(
|
||||
r#"(?m)^\[(?P<title>[^\]]+)\]\(https://sextb\.net/studio/(?P<slug>[^)\s]+)[^)]*\)"#,
|
||||
)?;
|
||||
let mut items = Vec::new();
|
||||
for captures in regex.captures_iter(markdown) {
|
||||
let title =
|
||||
Self::normalize_title(captures.name("title").map(|value| value.as_str()).unwrap_or_default());
|
||||
let slug = captures
|
||||
.name("slug")
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if title.is_empty() || slug.is_empty() {
|
||||
continue;
|
||||
}
|
||||
items.push(FilterOption { id: slug, title });
|
||||
}
|
||||
Ok(items)
|
||||
fn parse_genre_options(html: &str) -> Result<Vec<FilterOption>> {
|
||||
Self::collect_options(
|
||||
html,
|
||||
r#"<li><i class="fas fa-folder"[^>]*></i>\s*<a href="/genre/(?P<slug>[^"?]+)"[^>]*>(?P<title>[^<]+)</a>"#,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_actress_options(markdown: &str) -> Result<Vec<FilterOption>> {
|
||||
let regex = Self::regex(
|
||||
r#"(?m)^\[\!\[Image \d+: (?P<title>[^\]]+)\]\(https://[^)]+\)\s+(?P=title)\s+[0-9.]+[KM]\s+\d{2}/\d{2}/\d{4}\]\(https://sextb\.net/actress/[^)\s]+\)$"#,
|
||||
)?;
|
||||
let mut items = Vec::new();
|
||||
for captures in regex.captures_iter(markdown) {
|
||||
let title =
|
||||
Self::normalize_title(captures.name("title").map(|value| value.as_str()).unwrap_or_default());
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let slug_source = title.split('(').next().unwrap_or(&title).trim();
|
||||
let slug = Self::slugify(slug_source);
|
||||
if slug.is_empty() {
|
||||
continue;
|
||||
}
|
||||
items.push(FilterOption { id: slug, title: slug_source.to_string() });
|
||||
}
|
||||
Ok(items)
|
||||
fn parse_studio_options(html: &str) -> Result<Vec<FilterOption>> {
|
||||
Self::collect_options(
|
||||
html,
|
||||
r#"<div class="tray-item tray-item-list">\s*<a href="/studio/(?P<slug>[^"?]+)"[^>]*>(?:<i[^>]*></i>)?\s*(?P<title>[^<]+)</a>"#,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_actress_options(html: &str) -> Result<Vec<FilterOption>> {
|
||||
Self::collect_options(
|
||||
html,
|
||||
r#"<div class="tray-item-actress">\s*<a href="/actress/(?P<slug>[^"?]+)">.*?<div class="tray-item-actress-title">(?P<title>[^<]+)</div>"#,
|
||||
)
|
||||
}
|
||||
|
||||
async fn load_genres(base_url: &str, genres: Arc<RwLock<Vec<FilterOption>>>) -> Result<()> {
|
||||
@@ -423,8 +391,8 @@ impl SextbProvider {
|
||||
actresses: Arc::new(RwLock::new(vec![])),
|
||||
};
|
||||
let mut requester = Requester::new();
|
||||
let markdown = provider.fetch_markdown(&mut requester, &format!("{base_url}/genres")).await?;
|
||||
for item in Self::parse_genre_options(&markdown)? {
|
||||
let html = provider.fetch_html(&mut requester, &format!("{base_url}/genres")).await?;
|
||||
for item in Self::parse_genre_options(&html)? {
|
||||
Self::push_unique(&genres, item);
|
||||
}
|
||||
Ok(())
|
||||
@@ -438,9 +406,9 @@ impl SextbProvider {
|
||||
actresses: Arc::new(RwLock::new(vec![])),
|
||||
};
|
||||
let mut requester = Requester::new();
|
||||
let markdown =
|
||||
provider.fetch_markdown(&mut requester, &format!("{base_url}/list-studios")).await?;
|
||||
for item in Self::parse_studio_options(&markdown)? {
|
||||
let html =
|
||||
provider.fetch_html(&mut requester, &format!("{base_url}/list-studios")).await?;
|
||||
for item in Self::parse_studio_options(&html)? {
|
||||
Self::push_unique(&studios, item);
|
||||
}
|
||||
Ok(())
|
||||
@@ -458,17 +426,17 @@ impl SextbProvider {
|
||||
};
|
||||
let mut requester = Requester::new();
|
||||
let first_page =
|
||||
provider.fetch_markdown(&mut requester, &format!("{base_url}/list-actress")).await?;
|
||||
provider.fetch_html(&mut requester, &format!("{base_url}/list-actress")).await?;
|
||||
for item in Self::parse_actress_options(&first_page)? {
|
||||
Self::push_unique(&actresses, item);
|
||||
}
|
||||
|
||||
let last_page = Self::pagination_last_page(&first_page).unwrap_or(1).min(80);
|
||||
for page in 2..=last_page {
|
||||
let markdown = provider
|
||||
.fetch_markdown(&mut requester, &format!("{base_url}/list-actress/pg-{page}"))
|
||||
let html = provider
|
||||
.fetch_html(&mut requester, &format!("{base_url}/list-actress/pg-{page}"))
|
||||
.await?;
|
||||
for item in Self::parse_actress_options(&markdown)? {
|
||||
for item in Self::parse_actress_options(&html)? {
|
||||
Self::push_unique(&actresses, item);
|
||||
}
|
||||
}
|
||||
@@ -600,120 +568,63 @@ impl SextbProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_list_prefix_tags(prefix: &str) -> Vec<String> {
|
||||
let cleaned = Self::collapse_whitespace(prefix);
|
||||
if cleaned.is_empty() {
|
||||
return vec![];
|
||||
fn parse_list_items(html: &str) -> Result<Vec<ListItemSeed>> {
|
||||
// A no-hit search still renders a "recommended" tray under the notice.
|
||||
if html.contains("No Video were found") {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let bracket_regex = Regex::new(r"\[(?P<tag>[^\]]+)\]").ok();
|
||||
let mut tags = Vec::new();
|
||||
if let Some(regex) = bracket_regex {
|
||||
for captures in regex.captures_iter(&cleaned) {
|
||||
if let Some(tag) = captures.name("tag").map(|value| value.as_str().trim()) {
|
||||
if !tag.is_empty() {
|
||||
tags.push(tag.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let without_brackets = Regex::new(r"\[[^\]]+\]")
|
||||
.ok()
|
||||
.map(|regex| regex.replace_all(&cleaned, " ").to_string())
|
||||
.unwrap_or(cleaned.clone());
|
||||
for token in without_brackets.split(" ") {
|
||||
let value = Self::collapse_whitespace(token);
|
||||
if !value.is_empty() {
|
||||
tags.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
tags
|
||||
}
|
||||
|
||||
fn parse_list_items(markdown: &str) -> Result<Vec<ListItemSeed>> {
|
||||
let regex = Self::regex(
|
||||
r#"(?s)\[\!\[Image \d+: (?P<alt>.*?)\]\((?P<thumb>https?://[^)]+)\)(?P<body>.*?)\]\((?P<url>https://sextb\.net/[^)]+)\)"#,
|
||||
)?;
|
||||
let meta_regex = Self::regex(
|
||||
r#"(?s)^(?P<head>.*?)\s+(?P<quality>HD|SD)\s+(?P<duration>\d+)\s+min\s+(?P<code>[A-Za-z0-9\-]+)$"#,
|
||||
)?;
|
||||
let mut items = Vec::new();
|
||||
|
||||
for captures in regex.captures_iter(markdown) {
|
||||
let url = captures
|
||||
.name("url")
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if !url.starts_with("https://sextb.net/") {
|
||||
// Cards are `<div class="tray-item">`, optionally with modifiers such
|
||||
// as `tray-item tray-item-uncensored`; `tray-item-title` etc. must not match.
|
||||
let card_start = Self::regex(r#"<div class="tray-item(?: [^"]*)?">"#)?;
|
||||
for card in card_start.split(html).skip(1) {
|
||||
|
||||
let Some(href) = Self::attr_between(card, "<a href=\"", "\"") else {
|
||||
continue;
|
||||
};
|
||||
let slug = href.trim_matches('/').to_string();
|
||||
if slug.is_empty() || slug.contains('/') {
|
||||
continue;
|
||||
}
|
||||
let url = format!("{BASE_URL}/{slug}");
|
||||
|
||||
let body = Self::collapse_whitespace(
|
||||
captures.name("body").map(|value| value.as_str()).unwrap_or_default(),
|
||||
let title_raw = Self::attr_between(card, "class=\"tray-item-title\">", "</div>")
|
||||
.or_else(|| Self::attr_between(card, "alt=\"", "\" "))
|
||||
.unwrap_or_default();
|
||||
let title = Self::normalize_title(
|
||||
&htmlentity::entity::decode(title_raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| title_raw.to_string()),
|
||||
);
|
||||
let meta = match meta_regex.captures(&body) {
|
||||
Some(meta) => meta,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let alt_title = Self::normalize_title(
|
||||
captures.name("alt").map(|value| value.as_str()).unwrap_or_default(),
|
||||
);
|
||||
let head = Self::normalize_title(meta.name("head").map(|value| value.as_str()).unwrap_or_default());
|
||||
let title = if !alt_title.is_empty() && head.contains(&alt_title) {
|
||||
alt_title.clone()
|
||||
} else if !alt_title.is_empty() && !alt_title.eq_ignore_ascii_case("Julia") {
|
||||
alt_title.clone()
|
||||
} else {
|
||||
head.clone()
|
||||
};
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let prefix = if !alt_title.is_empty() && head.contains(&alt_title) {
|
||||
let prefix = head.split(&alt_title).next().unwrap_or_default();
|
||||
prefix.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let slug = url
|
||||
.trim_start_matches("https://sextb.net/")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
if slug.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let thumb = captures
|
||||
.name("thumb")
|
||||
.map(|value| value.as_str())
|
||||
let thumb = Self::attr_between(card, "data-src=\"", "\"")
|
||||
.or_else(|| Self::attr_between(card, "data-poster=\"", "\""))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if thumb.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let quality = meta
|
||||
.name("quality")
|
||||
.map(|value| value.as_str().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let code = meta
|
||||
.name("code")
|
||||
.map(|value| value.as_str().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let duration = meta
|
||||
.name("duration")
|
||||
.map(|value| Self::parse_duration(value.as_str()))
|
||||
let quality = if card.contains("class=\"hd\"") {
|
||||
Some("HD".to_string())
|
||||
} else if card.contains("class=\"sd\"") {
|
||||
Some("SD".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let duration = Self::attr_between(card, "class=\"tray-item-runtime\">", "</div>")
|
||||
.map(|value| value.replace("min", ""))
|
||||
.map(|value| Self::parse_duration(&value))
|
||||
.unwrap_or(0);
|
||||
let code = Self::attr_between(card, "class=\"tray-item-code\">", "</div>")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let mut tags = Self::parse_list_prefix_tags(&prefix);
|
||||
let mut tags = Vec::new();
|
||||
if let Some(value) = quality.clone() {
|
||||
tags.push(value);
|
||||
}
|
||||
@@ -739,13 +650,30 @@ impl SextbProvider {
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn extract_link_texts(line: &str) -> Result<Vec<(String, String)>> {
|
||||
let regex = Self::regex(r#"\[(?P<title>[^\]]+)\]\((?P<url>https://sextb\.net/[^)\s]+)"#)?;
|
||||
fn attr_between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
||||
let from = text.find(start)? + start.len();
|
||||
let rest = &text[from..];
|
||||
Some(&rest[..rest.find(end)?])
|
||||
}
|
||||
|
||||
/// Returns `(title, absolute-url)` for every `<a ...><strong>Title</strong></a>`.
|
||||
fn extract_link_texts(fragment: &str) -> Result<Vec<(String, String)>> {
|
||||
let regex = Self::regex(
|
||||
r#"<a href="(?P<url>[^"]+)"[^>]*>\s*<strong>(?P<title>[^<]+)</strong>\s*</a>"#,
|
||||
)?;
|
||||
Ok(regex
|
||||
.captures_iter(line)
|
||||
.captures_iter(fragment)
|
||||
.filter_map(|captures| {
|
||||
let title = captures.name("title")?.as_str().trim().trim_matches('*').to_string();
|
||||
let url = captures.name("url")?.as_str().to_string();
|
||||
let raw = captures.name("title")?.as_str().trim();
|
||||
let title = htmlentity::entity::decode(raw.as_bytes())
|
||||
.to_string()
|
||||
.unwrap_or_else(|_| raw.to_string());
|
||||
let url = captures.name("url")?.as_str();
|
||||
let url = if url.starts_with('/') {
|
||||
format!("{BASE_URL}{url}")
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
Some((title, url))
|
||||
})
|
||||
.collect())
|
||||
@@ -757,27 +685,30 @@ impl SextbProvider {
|
||||
Some(path.trim_start_matches('/').trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn parse_detail(markdown: &str) -> Result<DetailMetadata> {
|
||||
fn strong_text(fragment: &str) -> String {
|
||||
Self::attr_between(fragment, "<strong>", "</strong>")
|
||||
.map(|value| value.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse_detail(html: &str) -> Result<DetailMetadata> {
|
||||
let mut metadata = DetailMetadata::default();
|
||||
|
||||
let aliases_regex = Self::regex(r#"(?m)^# \*\*(?P<body>.+?)\*\*$"#)?;
|
||||
if let Some(captures) = aliases_regex.captures(markdown) {
|
||||
let body = captures.name("body").map(|value| value.as_str()).unwrap_or_default();
|
||||
let link_regex = Self::regex(r#"\[(?P<alias>[^\]]+)\]\(https://sextb\.net/search/[^)]+\)"#)?;
|
||||
for alias_match in link_regex.captures_iter(body) {
|
||||
if let Some(alias) = alias_match.name("alias").map(|value| value.as_str().trim()) {
|
||||
if !alias.is_empty() {
|
||||
metadata.aliases.push(alias.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let tag_regex = Self::regex(r"<[^>]+>")?;
|
||||
|
||||
let line_regex = Self::regex(r#"(?m)^\s*(?P<key>Director|Label|Studio|Cast\(s\)|Genre\(s\)|Quality|Release Date|Runtimes|Added|Viewed|Description):\s*(?P<value>.+)$"#)?;
|
||||
for captures in line_regex.captures_iter(markdown) {
|
||||
let key = captures.name("key").map(|value| value.as_str()).unwrap_or_default();
|
||||
let value = captures.name("value").map(|value| value.as_str()).unwrap_or_default().trim();
|
||||
match key {
|
||||
// Info rows are `<div class="description"><i ..></i> Key: <strong>..</strong></div>`.
|
||||
for block in html.split("<div class=\"description").skip(1) {
|
||||
let block = block.split("</div>").next().unwrap_or(block);
|
||||
// Drop the remainder of the opening tag (extra classes, `>`).
|
||||
let Some((_, block)) = block.split_once('>') else {
|
||||
continue;
|
||||
};
|
||||
let Some((key_part, value)) = block.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let key = tag_regex.replace_all(key_part, " ").trim().to_string();
|
||||
let value = value.trim();
|
||||
match key.as_str() {
|
||||
"Director" => {
|
||||
metadata.director = Self::extract_link_texts(value)?
|
||||
.first()
|
||||
@@ -796,71 +727,48 @@ impl SextbProvider {
|
||||
}
|
||||
}
|
||||
"Cast(s)" => {
|
||||
let mut cast = Vec::new();
|
||||
for (title, url) in Self::extract_link_texts(value)? {
|
||||
let slug = Self::detail_link_slug(&url, "actress")
|
||||
.unwrap_or_else(|| Self::slugify(&title));
|
||||
cast.push((title, slug));
|
||||
metadata.cast.push((title, slug));
|
||||
}
|
||||
metadata.cast = cast;
|
||||
}
|
||||
"Genre(s)" => {
|
||||
let mut genres = Vec::new();
|
||||
for (title, url) in Self::extract_link_texts(value)? {
|
||||
let slug = Self::detail_link_slug(&url, "genre")
|
||||
.unwrap_or_else(|| Self::slugify(&title));
|
||||
genres.push((title, slug));
|
||||
metadata.genres.push((title, slug));
|
||||
}
|
||||
metadata.genres = genres;
|
||||
}
|
||||
"Quality" => {
|
||||
let quality = value.trim_matches('*').trim().to_string();
|
||||
let quality = Self::strong_text(value);
|
||||
if !quality.is_empty() {
|
||||
metadata.quality = Some(quality);
|
||||
}
|
||||
}
|
||||
"Release Date" => {
|
||||
let cleaned = value.trim_matches('*').trim();
|
||||
metadata.release_date = Self::parse_absolute_date(cleaned);
|
||||
metadata.release_date = Self::parse_absolute_date(&Self::strong_text(value));
|
||||
}
|
||||
"Runtimes" => {}
|
||||
"Added" => {
|
||||
let cleaned = value.trim_matches('*').trim();
|
||||
metadata.added_at = Self::parse_relative_or_date(cleaned);
|
||||
metadata.added_at = Self::parse_relative_or_date(&Self::strong_text(value));
|
||||
}
|
||||
"Viewed" => {
|
||||
metadata.views = Self::parse_abbrev(value.trim());
|
||||
let text = tag_regex.replace_all(value, " ").to_string();
|
||||
metadata.views = Self::parse_abbrev(text.trim());
|
||||
}
|
||||
"Description" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let stats_regex =
|
||||
Self::regex(r#"(?m)(?P<likes>[0-9.]+[KM]?)\s+Liked(?:\s+(?P<dislikes>[0-9.]+[KM]?)\s+Disliked)?(?:\s+(?P<favorites>[0-9.]+[KM]?)\s+Favorited)?\s+(?P<views>[0-9.]+[KM]?)\s+Views"#)?;
|
||||
if let Some(captures) = stats_regex.captures(markdown) {
|
||||
if metadata.views.is_none() {
|
||||
metadata.views = captures
|
||||
.name("views")
|
||||
.and_then(|value| Self::parse_abbrev(value.as_str()));
|
||||
}
|
||||
if let (Some(likes), Some(dislikes)) = (
|
||||
captures
|
||||
.name("likes")
|
||||
.and_then(|value| Self::parse_abbrev(value.as_str())),
|
||||
captures
|
||||
.name("dislikes")
|
||||
.and_then(|value| Self::parse_abbrev(value.as_str())),
|
||||
) {
|
||||
let total = likes + dislikes;
|
||||
if total > 0 {
|
||||
metadata.rating = Some((likes as f32 / total as f32) * 100.0);
|
||||
}
|
||||
}
|
||||
if metadata.views.is_none() {
|
||||
let views_regex = Self::regex(r"(?P<views>[0-9][0-9.,]*[KM]?)\s+Views")?;
|
||||
metadata.views = views_regex
|
||||
.captures(html)
|
||||
.and_then(|captures| captures.name("views"))
|
||||
.and_then(|value| Self::parse_abbrev(value.as_str()));
|
||||
}
|
||||
|
||||
metadata.aliases.sort();
|
||||
metadata.aliases.dedup();
|
||||
// Like/dislike counts are filled in client-side, so no rating here.
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
@@ -1078,12 +986,12 @@ impl SextbProvider {
|
||||
let mut requester = requester_or_default(options, CHANNEL_ID, "enrich_seed");
|
||||
let detail_fetch = timeout(
|
||||
StdDuration::from_secs(10),
|
||||
self.fetch_markdown(&mut requester, &seed.url),
|
||||
self.fetch_html(&mut requester, &seed.url),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut item = match detail_fetch {
|
||||
Ok(Ok(markdown)) => match Self::parse_detail(&markdown) {
|
||||
Ok(Ok(html)) => match Self::parse_detail(&html) {
|
||||
Ok(detail) => self.apply_detail_metadata(seed.clone(), detail),
|
||||
Err(error) => {
|
||||
report_provider_error_background(CHANNEL_ID, "parse_detail", &error.to_string());
|
||||
@@ -1130,14 +1038,14 @@ impl SextbProvider {
|
||||
}
|
||||
|
||||
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_items_for_url");
|
||||
let markdown = timeout(
|
||||
let html = timeout(
|
||||
StdDuration::from_secs(12),
|
||||
self.fetch_markdown(&mut requester, &url),
|
||||
self.fetch_html(&mut requester, &url),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::from(format!("list request timed out for {url}")))??;
|
||||
|
||||
let seeds = Self::parse_list_items(&markdown)?
|
||||
let seeds = Self::parse_list_items(&html)?
|
||||
.into_iter()
|
||||
.take(per_page_limit.max(1))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -1266,15 +1174,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_jina_mirror_url_without_double_scheme() {
|
||||
let url = provider().mirror_url("https://sextb.net/censored?genre=all");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://r.jina.ai/http://sextb.net/censored?genre=all"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_search_page_two_url() {
|
||||
let url = provider().build_url_for_target(
|
||||
@@ -1308,33 +1207,81 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_detail_metadata_block() {
|
||||
let markdown = r#"
|
||||
57 Liked 16 Disliked 88 Favorited 45.2K Views
|
||||
let html = r#"
|
||||
<div class="col-7">
|
||||
<div class="description"> <i class="fa fa-user"></i> Director: <a href="/director/himurokku" title="Himurokku"><strong>Himurokku</strong></a> <i class="fas fa-heart favorite-director" data-id="1"></i> </div>
|
||||
<div class="description"> <i class="fa fa-tag"></i> Label: <a href="/label/madonna-x1" title="Madonna"><strong>Madonna</strong></a> </div>
|
||||
<div class="description"> <i class="fa fa-camera"></i> Studio: <a href="/studio/madonna-abc123" title="MADONNA"><strong>Madonna</strong></a> </div>
|
||||
<div class="description"> <i class="fa fa-users"></i> Cast(s): <a href="/actress/miyuu-imai" title="Miyuu Imai"><strong>Miyuu Imai</strong></a> <i class="fas fa-heart"></i> </div>
|
||||
<div class="description"> <i class="fa fa-list"></i> Genre(s): <a href="/genre/big-tits-aa11" title="Big Tits"><strong>Big Tits</strong></a>, <a href="/genre/creampie-bb22" title="Creampie"><strong>Creampie</strong></a> </div>
|
||||
<div class="description"><i class="fa fa-film"></i> Quality: <strong>HD</strong></div>
|
||||
<div class="description"><i class="fa fa-calendar"></i> Release Date: <strong>Mar. 24, 2026</strong></div>
|
||||
<div class="description"><i class="fa fa-calendar"></i> Added: <strong>2 days ago</strong></div>
|
||||
<div class="description visible-sm"><i class="fa fa-eye"></i> Viewed: 45.2K</div>
|
||||
</div>"#;
|
||||
|
||||
Director: [**Himurokku**](https://sextb.net/director/himurokku "Himurokku")
|
||||
Label: [**Madonna**](https://sextb.net/label/madonna "Madonna")
|
||||
Studio: [**Madonna**](https://sextb.net/studio/madonna "MADONNA")
|
||||
Cast(s): [**Miyuu Imai**](https://sextb.net/actress/miyuu-imai "Miyuu Imai")
|
||||
Genre(s): [**Big Tits**](https://sextb.net/genre/big-tits "Big Tits"), [**Creampie**](https://sextb.net/genre/creampie "Creampie")
|
||||
Quality: **HD**
|
||||
Release Date: **Mar. 24, 2026**
|
||||
Added: **2 days ago**
|
||||
Viewed: 45.2K
|
||||
"#;
|
||||
|
||||
let detail = SextbProvider::parse_detail(markdown).expect("detail should parse");
|
||||
let detail = SextbProvider::parse_detail(html).expect("detail should parse");
|
||||
assert_eq!(detail.director.as_deref(), Some("Himurokku"));
|
||||
assert_eq!(detail.label.as_deref(), Some("Madonna"));
|
||||
assert_eq!(detail.studio.as_ref().map(|value| value.0.as_str()), Some("Madonna"));
|
||||
assert_eq!(detail.cast.len(), 1);
|
||||
assert_eq!(
|
||||
detail.studio,
|
||||
Some(("Madonna".to_string(), "madonna-abc123".to_string()))
|
||||
);
|
||||
assert_eq!(detail.cast, vec![("Miyuu Imai".to_string(), "miyuu-imai".to_string())]);
|
||||
assert_eq!(detail.genres.len(), 2);
|
||||
assert_eq!(detail.genres[0].1, "big-tits-aa11");
|
||||
assert_eq!(detail.views, Some(45200));
|
||||
assert!(detail.rating.unwrap_or_default() > 70.0);
|
||||
assert_eq!(detail.quality.as_deref(), Some("HD"));
|
||||
assert!(detail.release_date.is_some());
|
||||
assert!(detail.added_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_listing_cards() {
|
||||
let html = r#"<div class="tray-content"><div class="tray-item"> <a href="/beb-036"> <img class="tray-item-thumbnail lazy" src="/images/default-cover.jpg" data-src="https://cdn001.imggle.net/webp/poster/a.webp" alt="Alt"> <div class="tray-item-description"> <div class="tray-item-title">Hara & Museum</div> <div class="tray-item-meta-info"> <div class="tray-item-quality"><span class="hd">HD</span></div> <div class="tray-item-runtime">120 min</div> <div class="tray-item-code">BEB-036</div> </div> </div> </a> <div class="tray-item-trailer" data-url="x" data-link="/beb-036" data-poster="https://cdn001.imggle.net/thumb/a.jpg"></div> </div>
|
||||
<div class="tray-item tray-item-uncensored"> <a href="/akdl-234"> <img data-src="https://cdn001.imggle.net/webp/poster/b.webp" alt="B"> <div class="tray-item-title">Second</div><div class="tray-item-runtime">150 min</div><div class="tray-item-code">AKDL-234</div> </a> </div></div>"#;
|
||||
let items = SextbProvider::parse_list_items(html).expect("listing should parse");
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].id, "beb-036");
|
||||
assert_eq!(items[0].url, "https://sextb.net/beb-036");
|
||||
assert_eq!(items[0].title, "Hara & Museum");
|
||||
assert_eq!(items[0].duration, 7200);
|
||||
assert_eq!(items[0].quality.as_deref(), Some("HD"));
|
||||
assert_eq!(items[0].code.as_deref(), Some("BEB-036"));
|
||||
assert_eq!(items[0].thumb, "https://cdn001.imggle.net/webp/poster/a.webp");
|
||||
assert_eq!(items[1].duration, 9000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_search_result_ignores_recommendation_tray() {
|
||||
let html = r#"<div class="tray-content"><h3 class="notice msg-result">Sorry, No Video were found that matched your search query.</h3>
|
||||
<div class="tray-item"> <a href="/abc-001"> <img data-src="https://x/a.webp"> <div class="tray-item-title">Rec</div></a></div></div>"#;
|
||||
assert!(SextbProvider::parse_list_items(html).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_filter_option_lists() {
|
||||
let genres = r#"<li><i class="fas fa-folder" style="x"></i> <a href="/genre/30-years-old-7c1gtr1u" title="Genre 30 Years Old">30 Years Old</a> <span>(1138)</span></li>"#;
|
||||
let g = SextbProvider::parse_genre_options(genres).unwrap();
|
||||
assert_eq!(g[0].id, "30-years-old-7c1gtr1u");
|
||||
assert_eq!(g[0].title, "30 Years Old");
|
||||
|
||||
let studios = r##"<div class="tray-item tray-item-list"> <a href="/studio/only-amateurs-29p3k0ax" title="Label"><i class="fas fa-folder-open"></i> #only amateurs</a> <span class="total">13</span> </div>"##;
|
||||
let s = SextbProvider::parse_studio_options(studios).unwrap();
|
||||
assert_eq!(s[0].id, "only-amateurs-29p3k0ax");
|
||||
assert_eq!(s[0].title, "only amateurs");
|
||||
|
||||
let actresses = r#"<div class="tray-item-actress"> <a href="/actress/th1bdtq81"> <img alt="Yumi"> <div class="tray-item-actress-description"> <div class="tray-item-actress-title">Yumi Kazama</div> </div></a></div>"#;
|
||||
let a = SextbProvider::parse_actress_options(actresses).unwrap();
|
||||
assert_eq!(a[0].id, "th1bdtq81");
|
||||
assert_eq!(a[0].title, "Yumi Kazama");
|
||||
|
||||
assert_eq!(
|
||||
SextbProvider::pagination_last_page("<a href='/list-actress/pg-2'>2</a><a href='/list-actress/pg-73'>73</a>"),
|
||||
Some(73)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_dd_player_buttons_from_detail_html() {
|
||||
let html = r#"
|
||||
|
||||
Reference in New Issue
Block a user