add hotbunny provider + fix domain cookie scoping in requester

New channel: hotbunny (AI-generated hentai, hotbunny.ai JSON API).
Includes thumbnail proxy (/proxy/hotbunny-thumb/) since assets.hotbunny.ai
is CF bot-managed. check.py updated to treat CF-protected format URLs as
warnings rather than errors.

requester.rs: store_response_cookies now honours the Domain attribute in
Set-Cookie headers — cookies scoped to .domain.com are registered against
the parent domain so the wreq Jar returns them for sub.domain.com requests
automatically, without per-provider workarounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon
2026-06-24 18:40:57 +00:00
parent 8373d49593
commit 6d5771a038
9 changed files with 464 additions and 10 deletions

View File

@@ -391,6 +391,11 @@ const PROVIDERS: &[ProviderDef] = &[
module: "kwiky",
ty: "KwikyProvider",
},
ProviderDef {
id: "hotbunny",
module: "hotbunny",
ty: "HotbunnyProvider",
},
];
fn main() {

View File

@@ -69,6 +69,7 @@ _BROWSER_UA = "Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/
_CF_PROTECTED_HOSTS = {
"www.camsoda.com",
"camsoda.com",
"assets.hotbunny.ai",
}
@@ -277,12 +278,20 @@ def check_video(video: dict, channel_id: str, results: Results, run_ytdlp: bool)
continue
ok, code = http_ok(furl, headers=fheaders)
if not ok:
results.err(
channel_id,
f"{label} format[{j}]: unreachable HTTP={code}"
+ (f" (headers={list(fheaders.keys())})" if fheaders else "")
+ f": {furl}",
)
if _is_cf_protected(furl):
results.warn(
channel_id,
f"{label} format[{j}]: unreachable HTTP={code} (CF-protected host, expected)"
+ (f" (headers={list(fheaders.keys())})" if fheaders else "")
+ f": {furl}",
)
else:
results.err(
channel_id,
f"{label} format[{j}]: unreachable HTTP={code}"
+ (f" (headers={list(fheaders.keys())})" if fheaders else "")
+ f": {furl}",
)
else:
results.info(channel_id, f"{label} format[{j}]: OK (HTTP {code})")

View File

@@ -24,6 +24,7 @@ This is the current implementation inventory as of this snapshot of the repo. Us
| `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). |
| `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). |
| `hqporner` | `studio-network` | no | yes | Uses thumb and redirect proxy helpers. |
| `hsex` | `chinese` | yes | no | Strong template for tags, uploaders, and direct HLS formats. |
| `hypnotube` | `fetish-kink` | no | no | Fetish/tube hybrid. |
@@ -117,6 +118,7 @@ These return binary media or images, sometimes rewriting manifests or forwarding
- `/proxy/fikfap-thumb/{endpoint}*`
- `/proxy/hqporner-thumb/{endpoint}*`
- `/proxy/porndish-thumb/{endpoint}*`
- `/proxy/hotbunny-thumb/{endpoint}*`
- `/proxy/pornhub-thumb/{endpoint}*`
## Best Copy Sources By Problem

355
src/providers/hotbunny.rs Normal file
View File

@@ -0,0 +1,355 @@
use async_trait::async_trait;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde::Deserialize;
use crate::DbPool;
use crate::api::ClientVersion;
use crate::providers::{Provider, build_proxy_url, report_provider_error, requester_or_default, strip_url_scheme};
use crate::status::*;
use crate::util::cache::VideoCache;
use crate::videos::{ServerOptions, VideoFormat, VideoItem};
pub const CHANNEL_METADATA: crate::providers::ProviderChannelMetadata =
crate::providers::ProviderChannelMetadata {
group_id: "ai",
tags: &["ai", "hentai", "generated", "anime", "community"],
};
const CHANNEL_ID: &str = "hotbunny";
const BASE_URL: &str = "https://hotbunny.ai";
const CDN_BASE: &str = "https://assets.hotbunny.ai";
const REFERER: &str = "https://hotbunny.ai/";
// Fetch at most this many posts per call so pagination stays cheap.
const MAX_FETCH: usize = 200;
// Category label → API CUID, hardcoded from GET /api/categories (stable IDs).
const CATEGORIES: &[(&str, &str)] = &[
("cmiys6yak0b1dpb0q3a9ijs8z", "Anal"),
("cmir7ft9r00bonq0ryhbjj0zf", "BDSM"),
("cmiyslxmt0b1gpb0qjbjiu1my", "Big Breasts"),
("cmiyszx1l0b1kpb0qxk71aql9", "Blondes"),
("cmiysraox0b1ipb0q4plrax1b", "Exhibitionism"),
("cmiyt45ce0b1mpb0qgb2s3q4c", "Feet"),
("cmiysyf0q0b1jpb0q6f41m9l2", "Furry"),
("cmiysfqo10b1epb0qczzjshi7", "Futanari"),
("cmiydl9et0000om0q9768i2ta", "Gay"),
("cmiysoegl0b1hpb0qd9vzsn7q", "Group"),
("cmiys0rlx0b19pb0q2f7iis14", "Hardcore"),
("cmiys3wix0b1bpb0qsmiud4bu", "Lesbian"),
("cmiye8qa40001om0qasn0d2iu", "Monsters"),
("cmiys1rcy0b1apb0qfb4lx9ib", "Oral"),
("cmiyepzos0000pb0qx74evwyx", "Soft"),
];
// ---------------------------------------------------------------------------
// API response types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct FeedResponse {
posts: Vec<HotbunnyPost>,
#[serde(rename = "nextCursor", default)]
_next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
struct HotbunnyPost {
id: String,
#[serde(rename = "imageUrl", default)]
image_url: String,
#[serde(rename = "authorName", default)]
author_name: String,
#[serde(default)]
likes: u64,
#[serde(rename = "sharedVideos", default)]
shared_videos: Vec<SharedVideo>,
#[serde(default)]
width: u32,
#[serde(default)]
height: u32,
#[serde(default)]
galleries: Vec<Gallery>,
}
#[derive(Debug, Deserialize)]
struct SharedVideo {
#[serde(rename = "videoUrl", default)]
video_url: String,
}
#[derive(Debug, Deserialize)]
struct Gallery {
#[serde(default)]
label: String,
}
// ---------------------------------------------------------------------------
// Target routing
// ---------------------------------------------------------------------------
enum Target {
Hot,
Popular,
Recent,
Category(String),
Search(String),
Author(String),
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
pub struct HotbunnyProvider;
impl HotbunnyProvider {
pub fn new() -> Self {
HotbunnyProvider
}
fn resolve_target(sort: &str, query: Option<&str>, options: &ServerOptions) -> Target {
if let Some(q) = query.map(str::trim).filter(|q| !q.is_empty()) {
let lower = q.to_ascii_lowercase();
// uploader:/author: prefix
let suffix_after_colon = |prefix: &str| -> Option<&str> {
lower.strip_prefix(prefix).map(|_| {
let colon = q.find(':').unwrap_or(0);
q[colon + 1..].trim()
})
};
if suffix_after_colon("uploader:").is_some() || suffix_after_colon("author:").is_some() {
let colon = q.find(':').unwrap_or(0);
return Target::Author(q[colon + 1..].trim().to_string());
}
// cat:/category: prefix or #tag
let keyword = if lower.starts_with("cat:") || lower.starts_with("category:") {
let colon = q.find(':').unwrap_or(0);
q[colon + 1..].trim()
} else {
q.trim_start_matches('#')
};
// Match keyword against a known category label
if let Some((cat_id, _)) = CATEGORIES
.iter()
.find(|(_, label)| label.eq_ignore_ascii_case(keyword))
{
return Target::Category(cat_id.to_string());
}
return Target::Search(keyword.to_string());
}
// categories filter option
if let Some(cat) = options.categories.as_deref().filter(|c| *c != "all" && !c.trim().is_empty()) {
if let Some((cat_id, _)) = CATEGORIES
.iter()
.find(|(_, label)| label.eq_ignore_ascii_case(cat))
{
return Target::Category(cat_id.to_string());
}
}
match sort {
"popular" => Target::Popular,
"recent" | "new" => Target::Recent,
_ => Target::Hot,
}
}
fn api_url(target: &Target, limit: usize) -> String {
match target {
Target::Hot => format!("{BASE_URL}/api/post/feed?limit={limit}"),
Target::Popular => format!("{BASE_URL}/api/post/feed/popular?limit={limit}"),
Target::Recent => format!("{BASE_URL}/api/post/feed/recents?limit={limit}"),
Target::Category(id) => format!("{BASE_URL}/api/post/category/{id}?limit={limit}"),
Target::Search(q) => {
let encoded = utf8_percent_encode(q, NON_ALPHANUMERIC).to_string();
format!("{BASE_URL}/api/post/search?query={encoded}&limit={limit}")
}
Target::Author(name) => {
let encoded = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string();
format!("{BASE_URL}/api/post/author/{encoded}/all?limit={limit}")
}
}
}
// Returns (posts, cdn_cookie) — cdn_cookie is the img_auth JWT the API
// sets via Set-Cookie for Domain=.hotbunny.ai; clients need it in the
// Cookie header when fetching from assets.hotbunny.ai directly.
async fn fetch_posts(&self, url: &str, options: &ServerOptions) -> (Vec<HotbunnyPost>, Option<String>) {
let mut requester = requester_or_default(options, CHANNEL_ID, "fetch_posts");
let headers = vec![
("Referer".to_string(), REFERER.to_string()),
("Accept".to_string(), "application/json".to_string()),
];
let text = match requester.get_with_headers(url, headers, None).await {
Ok(t) => t,
Err(e) => {
report_provider_error(CHANNEL_ID, "fetch_posts", &format!("{url}: {e}")).await;
return (vec![], None);
}
};
// The domain cookie fix in store_response_cookies now scopes img_auth to
// .hotbunny.ai, so this lookup also covers assets.hotbunny.ai.
let cdn_cookie = requester.cookie_header_for_url(CDN_BASE);
match serde_json::from_str::<FeedResponse>(&text) {
Ok(resp) => (resp.posts, cdn_cookie),
Err(e) => {
report_provider_error(CHANNEL_ID, "fetch_posts", &format!("parse: {e}")).await;
(vec![], cdn_cookie)
}
}
}
fn build_item(post: HotbunnyPost, options: &ServerOptions, cdn_cookie: Option<&str>) -> VideoItem {
let title = match post.galleries.first().map(|g| g.label.trim()) {
Some(label) if !label.is_empty() => format!("{label} by {}", post.author_name),
_ => format!("by {}", post.author_name),
};
let page_url = format!("{BASE_URL}/post/{}", post.id);
let thumb = if !post.image_url.is_empty() {
let cdn_path = strip_url_scheme(&format!("{CDN_BASE}/{}", post.image_url));
build_proxy_url(options, "hotbunny-thumb", &cdn_path)
} else {
String::new()
};
let mut item = VideoItem::new(
post.id,
title,
page_url,
CHANNEL_ID.to_string(),
thumb,
0,
);
if post.likes > 0 {
item.views = Some(post.likes as u32);
}
if post.width > 0 && post.height > 0 {
item.aspectRatio = Some(post.width as f32 / post.height as f32);
}
if !post.author_name.is_empty() {
let slug = post.author_name.to_ascii_lowercase();
item.uploader = Some(post.author_name.clone());
item.uploaderUrl = Some(format!("{BASE_URL}/u/{slug}"));
item.uploaderId = Some(format!("{CHANNEL_ID}:{slug}"));
}
// First shared video becomes a playable format
if let Some(sv) = post.shared_videos.into_iter().find(|v| !v.video_url.is_empty()) {
let cdn_url = format!("{CDN_BASE}/{}", sv.video_url);
let mut fmt = VideoFormat::new(cdn_url, "auto".to_string(), "mp4".to_string());
fmt.add_http_header("Referer".to_string(), REFERER.to_string());
if let Some(cookie) = cdn_cookie {
fmt.add_http_header("Cookie".to_string(), cookie.to_string());
}
item.formats = Some(vec![fmt]);
}
item
}
fn build_channel(&self) -> Channel {
let sort_options = vec![
FilterOption { id: "feed".to_string(), title: "Hot".to_string() },
FilterOption { id: "popular".to_string(), title: "Most Liked".to_string() },
FilterOption { id: "recent".to_string(), title: "Recent".to_string() },
];
let category_options: Vec<FilterOption> = CATEGORIES
.iter()
.map(|(_, label)| FilterOption {
id: label.to_string(),
title: label.to_string(),
})
.collect();
Channel {
id: CHANNEL_ID.to_string(),
name: "HotBunny".to_string(),
description: "AI-generated hentai images and short clips from HotBunny.ai community creators.".to_string(),
premium: false,
favicon: "https://www.google.com/s2/favicons?sz=64&domain=hotbunny.ai".to_string(),
status: "active".to_string(),
categories: CATEGORIES.iter().map(|(_, l)| l.to_string()).collect(),
options: vec![
ChannelOption {
id: "sort".to_string(),
title: "Sort".to_string(),
description: "Feed ordering".to_string(),
systemImage: "arrow.up.arrow.down".to_string(),
colorName: "blue".to_string(),
options: sort_options,
multiSelect: false,
},
ChannelOption {
id: "categories".to_string(),
title: "Category".to_string(),
description: "Browse by content category".to_string(),
systemImage: "tag".to_string(),
colorName: "orange".to_string(),
options: category_options,
multiSelect: false,
},
],
nsfw: true,
cacheDuration: Some(1800),
}
}
}
#[async_trait]
impl Provider for HotbunnyProvider {
async fn get_videos(
&self,
cache: VideoCache,
pool: DbPool,
sort: String,
query: Option<String>,
page: String,
per_page: String,
options: ServerOptions,
) -> Vec<VideoItem> {
let _ = cache;
let _ = pool;
let page = page.parse::<usize>().unwrap_or(1).max(1);
let per_page = per_page.parse::<usize>().unwrap_or(20).clamp(1, 50);
let total_limit = (page * per_page).min(MAX_FETCH);
let normalized = query.as_deref().map(str::trim).filter(|q| !q.is_empty());
let target = Self::resolve_target(&sort, normalized, &options);
let url = Self::api_url(&target, total_limit);
let (posts, cdn_cookie) = self.fetch_posts(&url, &options).await;
if posts.is_empty() {
return vec![];
}
let start = (page - 1) * per_page;
if start >= posts.len() {
return vec![];
}
posts
.into_iter()
.skip(start)
.take(per_page)
.map(|p| Self::build_item(p, &options, cdn_cookie.as_deref()))
.collect()
}
fn get_channel(&self, _cv: ClientVersion) -> Option<Channel> {
Some(self.build_channel())
}
}

View File

@@ -0,0 +1,52 @@
use ntex::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
use ntex::{
http::Response,
web::{self, HttpRequest, error},
};
use crate::util::requester::Requester;
const REFERER: &str = "https://hotbunny.ai/";
pub async fn get_image(
req: HttpRequest,
requester: web::types::State<Requester>,
) -> Result<impl web::Responder, web::Error> {
let endpoint = req.match_info().query("endpoint").trim_start_matches('/');
let image_url = if endpoint.starts_with("https://") || endpoint.starts_with("http://") {
endpoint.to_string()
} else {
format!("https://{endpoint}")
};
let upstream = match requester
.get_ref()
.clone()
.get_raw_with_headers(
&image_url,
vec![("Referer".to_string(), REFERER.to_string())],
)
.await
{
Ok(r) => r,
Err(_) => return Ok(web::HttpResponse::NotFound().finish()),
};
let status = upstream.status();
let headers = upstream.headers().clone();
let bytes = upstream.bytes().await.map_err(error::ErrorBadGateway)?;
let mut resp = Response::build(status);
if let Some(ct) = headers.get(CONTENT_TYPE) {
if let Ok(s) = ct.to_str() {
resp.set_header(CONTENT_TYPE, s);
}
}
if let Some(cl) = headers.get(CONTENT_LENGTH) {
if let Ok(s) = cl.to_str() {
resp.set_header(CONTENT_LENGTH, s);
}
}
Ok(resp.body(bytes.to_vec()))
}

View File

@@ -42,6 +42,7 @@ pub mod spankbang;
pub mod supjav;
pub mod sxyprn;
pub mod thaiporntv;
pub mod hotbunnythumb;
pub mod jable;
pub mod tube8;
pub mod thepornbunny;

View File

@@ -142,6 +142,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
web::resource("/porndish-thumb/{endpoint}*")
.route(web::post().to(crate::proxies::porndishthumb::get_image))
.route(web::get().to(crate::proxies::porndishthumb::get_image)),
)
.service(
web::resource("/hotbunny-thumb/{endpoint}*")
.route(web::post().to(crate::proxies::hotbunnythumb::get_image))
.route(web::get().to(crate::proxies::hotbunnythumb::get_image)),
);
cfg.service(
web::resource("/proxy/pornhub-thumb/{endpoint}*")

View File

@@ -61,15 +61,40 @@ impl Requester {
}
fn store_response_cookies(&self, url: &str, response: &Response) {
let Some(origin) = Self::origin_url_for_cookie_scope(url) else {
let Some(default_origin) = Self::origin_url_for_cookie_scope(url) else {
return;
};
for value in response.headers().get_all(SET_COOKIE).iter() {
if let Ok(cookie) = value.to_str() {
self.cookie_jar.add_cookie_str(cookie, &origin);
let Ok(cookie) = value.to_str() else {
continue;
};
// Honour the Domain attribute: if Set-Cookie specifies Domain=.foo.com,
// register the cookie against https://foo.com/ so the Jar returns it for
// sub.foo.com requests as well.
let origin = Self::cookie_domain_origin(cookie, &default_origin);
self.cookie_jar.add_cookie_str(cookie, &origin);
}
}
/// Derive the registration origin for a cookie string.
/// If the string contains `Domain=<d>`, strips the leading dot and builds a
/// URL from `<d>` so the jar scopes the cookie to the whole domain tree.
/// Falls back to `default` when no Domain attribute is present.
fn cookie_domain_origin(cookie_str: &str, default: &url::Url) -> url::Url {
for attr in cookie_str.split(';').skip(1) {
let attr = attr.trim();
if attr.len() >= 7 && attr[..7].eq_ignore_ascii_case("domain=") {
let domain = attr[7..].trim().trim_start_matches('.');
if !domain.is_empty() {
let scheme = default.scheme();
if let Ok(u) = url::Url::parse(&format!("{scheme}://{domain}/")) {
return u;
}
}
}
}
default.clone()
}
fn store_flaresolverr_cookies(