@mohtasimalam/hentai.js
Version:
A library that fetches hentai data from different sources.
936 lines (923 loc) • 33.3 kB
JavaScript
;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
Dimension: () => Dimension,
HANIME_BASE_URL: () => HANIME_BASE_URL,
HANIME_SEARCH_URL: () => HANIME_SEARCH_URL,
HANIME_SIGNATURE_GENERATOR: () => HANIME_SIGNATURE_GENERATOR,
HAnime: () => HAnime,
HENTAI_HAVEN_URL: () => HENTAI_HAVEN_URL,
HENTAI_STREAM_BASE_URL: () => HENTAI_STREAM_BASE_URL,
HentaiHaven: () => HentaiHaven,
HentaiStream: () => HentaiStream,
RULE34_API_URL: () => RULE34_API_URL,
RULE34_BASE_URL: () => RULE34_BASE_URL,
Rule34: () => Rule34,
getNumberFromString: () => getNumberFromString,
normalize: () => normalize,
removeNumberFromString: () => removeNumberFromString,
rot13Cipher: () => rot13Cipher
});
module.exports = __toCommonJS(index_exports);
// src/sources/video/hanime.ts
var import_cheerio = require("cheerio");
var HANIME_BASE_URL = "https://hanime.tv";
var HANIME_SEARCH_URL = "https://search.htv-services.com";
var HANIME_SIGNATURE_GENERATOR = () => Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
var HAnime = class {
BASE_URL = HANIME_BASE_URL;
SEARCH_URL = HANIME_BASE_URL;
generateSignature = HANIME_SIGNATURE_GENERATOR;
/**
* Creates a new instance of the HAnime client.
*
* @param {HAnimeOptions} [options] - Configuration options for the HAnime client.
* @param {string} [options.baseUrl] - Custom base URL for the HAnime website.
* @param {string} [options.searchUrl] - Custom search API URL.
* @param {function(): string} [options.signatureGenerator] - Custom function to generate request signatures.
*/
constructor(options) {
this.BASE_URL = options?.baseUrl || HANIME_BASE_URL;
this.SEARCH_URL = options?.searchUrl || HANIME_SEARCH_URL;
this.generateSignature = options?.signatureGenerator || HANIME_SIGNATURE_GENERATOR;
}
/**
* Searches for videos on Hanime.tv based on the provided query.
*
* @param {string} query - The search query string.
* @param {number} [page=1] - The page number to retrieve (default is 1).
* @param {number} [perPage=10] - The number of results per page (default is 10).
* @returns {Promise<PaginatedResult<HAnimeSearchResult>>} A promise that resolves to a paginated result of search results.
*/
search = async (query, page = 1, perPage = 10) => {
if (!query) {
throw new Error("Search query cannot be empty");
}
let validPage = page;
if (validPage < 1) {
validPage = 1;
}
let validPerPage = perPage;
if (validPerPage < 1) {
validPerPage = 10;
}
try {
const response = await fetch(this.SEARCH_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json"
},
body: JSON.stringify({
blacklist: [],
brands: [],
order_by: "created_at_unix",
page: validPage - 1,
tags: [],
search_text: query.trim(),
tags_mode: "AND"
})
});
if (!response.ok) {
throw new Error(`Search request failed with status: ${response.status}`);
}
const data = await response.json();
if (!data.hits) {
return {
results: [],
total: 0,
page,
pages: 0,
hasNextPage: false
};
}
let allResults = [];
try {
allResults = data.hits.map((result) => this.mapToSearchResult(result));
} catch (error) {
console.error("Failed to parse search results:", error);
throw new Error("Failed to parse search results");
}
const totalResults = data.nbHits || 0;
const totalPages = Math.max(1, Math.ceil(totalResults / validPerPage));
const finalPage = Math.min(Math.max(1, validPage), totalPages);
const startIndex = (finalPage - 1) * validPerPage;
const endIndex = Math.min(startIndex + validPerPage, allResults.length);
const results = allResults.slice(startIndex, endIndex);
return {
results,
total: totalResults,
page: finalPage,
pages: totalPages,
previous: finalPage > 1 ? finalPage - 1 : void 0,
next: finalPage < totalPages ? finalPage + 1 : void 0,
hasNextPage: finalPage < totalPages
};
} catch (error) {
console.error("Search error:", error);
throw new Error(
`Failed to search HAnime: ${error instanceof Error ? error.message : String(error)}`
);
}
};
/**
* Retrieves detailed information about a specific video by its slug.
*
* @param {string} slug - The unique slug identifier for the video.
* @returns {Promise<HAnimeVideoInfo>} A promise that resolves to detailed information about the video.
*/
getInfo = async (slug) => {
const path = `/videos/hentai/${slug}`;
const url = `${this.BASE_URL}${path}`;
const response = await fetch(url);
const html = await response.text();
const $ = (0, import_cheerio.load)(html);
const script = $('script:contains("window.__NUXT__")');
const scriptHtml = script.html();
const json = JSON.parse(
scriptHtml?.replace("window.__NUXT__=", "").replaceAll(";", "") || "{}"
);
const videoData = json.state.data.video;
return {
title: json.state.data.video.hentai_franchise.name,
slug: json.state.data.video.hentai_franchise.slug,
id: videoData.hentai_video.id,
description: videoData.hentai_video.description,
views: videoData.hentai_video.views,
interests: videoData.hentai_video.interests,
posterUrl: videoData.hentai_video.poster_url,
coverUrl: videoData.hentai_video.cover_url,
brand: {
name: videoData.hentai_video.brand,
id: videoData.hentai_video.brand_id
},
durationMs: videoData.hentai_video.duration_in_ms,
isCensored: videoData.hentai_video.is_censored,
likes: videoData.hentai_video.likes,
rating: videoData.hentai_video.rating,
dislikes: videoData.hentai_video.dislikes,
downloads: videoData.hentai_video.downloads,
rankMonthly: videoData.hentai_video.monthly_rank,
tags: videoData.hentai_tags,
createdAt: videoData.hentai_video.created_at,
releasedAt: videoData.hentai_video.released_at,
episodes: {
next: this.mapToEpisode(videoData.next_hentai_video),
all: json.state.data.video.hentai_franchise_hentai_videos.map(this.mapToEpisode),
random: this.mapToEpisode(videoData.next_random_hentai_video)
}
};
};
/**
* Retrieves stream information for a specific video episode by its slug.
*
* @param {string} slug - The unique slug identifier for the video episode.
* @returns {Promise<HAnimeStream[]>} A promise that resolves to an array of available video streams.
*/
getEpisode = async (slug) => {
const apiUrl = `${this.BASE_URL}/rapi/v7/videos_manifests/${slug}`;
const signature = this.generateSignature();
const response = await fetch(apiUrl, {
headers: {
"x-signature": signature,
"x-time": Math.floor(Date.now() / 1e3).toString(),
"x-signature-version": "web2"
}
});
const json = await response.json();
const data = json.videos_manifest;
const videos = data.servers.flatMap((server) => server.streams);
const streams = videos.map((video) => ({
id: video.id,
serverId: video.server_id,
kind: video.kind,
extension: video.extension,
mimeType: video.mime_type,
width: video.width,
height: video.height,
durationInMs: video.duration_in_ms,
filesizeMbs: video.filesize_mbs,
filename: video.filename,
url: video.url
})).filter((video) => video.url && video.url !== "" && video.kind !== "premium_alert");
return streams;
};
mapToSearchResult = (raw) => {
return {
id: raw.id,
name: raw.name,
titles: raw.titles,
slug: raw.slug,
description: raw.description,
views: raw.views,
interests: raw.interests,
bannerImage: raw.poster_url,
coverImage: raw.cover_url,
brand: {
name: raw.brand,
id: raw.brand_id
},
durationMs: raw.duration_in_ms,
isCensored: raw.is_censored,
likes: raw.likes,
rating: raw.rating,
dislikes: raw.dislikes,
downloads: raw.downloads,
rankMonthly: raw.monthly_rank,
tags: typeof raw.tags === "object" && Array.isArray(raw.tags) ? raw.tags : JSON.parse(raw.tags),
createdAt: raw.created_at,
releasedAt: raw.released_at
};
};
mapToEpisode = (raw) => {
return {
id: raw.id,
name: raw.name,
slug: raw.slug,
views: raw.views,
interests: raw.interests,
thumbnailUrl: raw.poster_url,
coverUrl: raw.cover_url,
isHardSubtitled: raw.is_hard_subtitled,
brand: {
name: raw.brand,
id: raw.brand_id
},
durationMs: raw.duration_in_ms,
isCensored: raw.is_censored,
likes: raw.likes,
rating: raw.rating,
dislikes: raw.dislikes,
downloads: raw.downloads,
rankMonthly: raw.monthly_rank,
brandId: raw.brand_id,
isBannedIn: raw.is_banned_in,
previewUrl: raw.preview_url,
color: raw.primary_color,
createdAt: raw.created_at_unix,
releasedAt: raw.released_at_unix
};
};
};
// src/sources/video/hentai-haven.ts
var import_cheerio2 = require("cheerio");
var import_date_fns = require("date-fns");
// src/utils/get-number-from-string.ts
function getNumberFromString(str) {
if (typeof str !== "string") {
throw new TypeError("Input must be a string");
}
if (str.trim() === "") {
return null;
}
const numbers = str.match(/\d+/g);
return numbers ? Number(numbers[0]) : null;
}
// src/utils/rot13.ts
var rot13Cipher = (str) => {
if (typeof str !== "string") {
throw new TypeError("Input must be a string");
}
if (str.length === 0) {
return "";
}
return str.replace(/[a-zA-Z]/g, (c) => {
const charCode = c.charCodeAt(0);
const isUpperCase = charCode >= 65 && charCode <= 90;
const shiftedCharCode = isUpperCase ? (charCode - 65 + 13) % 26 + 65 : (charCode - 97 + 13) % 26 + 97;
return String.fromCharCode(shiftedCharCode);
});
};
// src/sources/video/hentai-haven.ts
var HENTAI_HAVEN_URL = "http://hentaihaven.xxx";
var HentaiHaven = class {
/**
* Base URL for API requests.
*/
BASE_URL = HENTAI_HAVEN_URL;
/**
* Creates a new instance of the HentaiHaven client.
*
* @param {HentaiHavenOptions} options - Configuration options for the HentaiHaven client.
* @param {string} [options.baseUrl] - Custom base URL for the HentaiHaven website.
*/
constructor(options) {
this.BASE_URL = options?.baseUrl || HENTAI_HAVEN_URL;
}
/**
* Searches for hentai videos on Hentai Haven based on the provided query.
*
* @param {string} query - The search query string.
* @returns {Promise<HHSearchResult[]>} A promise that resolves to an array of search results.
* @throws {TypeError} If the query is empty or not a string.
*/
search = async (query) => {
if (!query || typeof query !== "string") {
throw new TypeError("Invalid query in search.");
}
const url = `${this.BASE_URL}/?s=${query}&post_type=wp-manga`;
const response = await fetch(url);
const data = await response.text();
const $ = (0, import_cheerio2.load)(data);
const results = [];
$(".c-tabs-item__content").each((_i, el) => {
const cover = $(el).find(".c-image-hover img").attr("src") || "";
const id = $(el).find(".c-image-hover a").attr("href")?.split("/")[4] || "";
const title = $(el).find(".post-title h3").text().trim();
const alternative = $(el).find(".tab-summary .mg_alternative .summary-content").text().trim();
const author = $(el).find(".tab-summary .mg_author .summary-content").text().trim();
const released = Number(
$(el).find(".tab-summary .mg_release .summary-content").text().trim()
);
const totalEpisodes = getNumberFromString($(el).find(".tab-meta .latest-chap .chapter").text().trim()) || 0;
const dateString = $(el).find(".tab-meta .post-on").text().trim();
const parsedDate = (0, import_date_fns.parse)(dateString, "MMM dd, yyyy", /* @__PURE__ */ new Date());
const rating = Number($(el).find(".tab-meta .rating .total_votes").text().trim());
const genres = [];
$(".tab-summary .mg_genres .summary-content a").each((_, element) => {
genres.push({
id: $(element).attr("href")?.split("/")[4] || "",
url: $(element).attr("href") || "",
name: $(element).text().trim().replaceAll(",", "")
});
});
results.push({
id,
title,
cover: cover.replaceAll(" ", "%20"),
rating,
released,
genres,
totalEpisodes,
date: {
unparsed: dateString,
parsed: parsedDate
},
alternative,
author
});
});
return results;
};
/**
* Retrieves detailed information about a hentai series by its ID.
*
* @param {string} id - The unique identifier of the hentai series.
* @param {HHEpisodesSort} [episodeSort="ASC"] - The sort order for episodes (ascending or descending).
* @returns {Promise<HHHentaiInfo>} A promise that resolves to detailed information about the hentai series.
* @throws {Error} If the ID is not provided or if there's an error fetching the data.
*/
getInfo = async (id, episodeSort = "ASC") => {
if (!id) {
throw new Error("Id is required");
}
const url = `${this.BASE_URL}/watch/${id}`;
const response = await fetch(url);
const data = await response.text();
if (data === "" || !data) {
throw new Error("Error fetching data");
}
const $ = (0, import_cheerio2.load)(data);
if ($("body").text().includes("webpage has been blocked")) {
throw new Error(`The webpage is blocked. Consider using a CORS proxy. GET ${url}`);
}
const title = $(".post-title h1").text().trim();
const cover = $(".summary_image img").attr("src") || "";
const ratingCount = Number($('span[property="ratingCount"]').text().trim());
const views = getNumberFromString(
$(".post-content_item:nth-child(4) .summary-content").text()
);
const released = Number($(".post-status .summary-content a").text().trim());
const summary = $(".description-summary p").text().trim();
const genres = [];
const episodes = [];
$(".genres-content a").each((_i, el) => {
genres.push({
id: $(el).attr("href")?.split("/")[4] || "",
url: $(el).attr("href") || "",
name: $(el).text().trim()
});
});
const episodesLength = $("li.wp-manga-chapter").length;
$("li.wp-manga-chapter").each((i, el) => {
const thumbnail = $(el).find("img").attr("src");
const id2 = `${$(el).find("a").attr("href")?.split("/")[4]}/${$(el).find("a").attr("href")?.split("/")[5]}`;
const title2 = $(el).find("a").text().trim();
const number = episodesLength - i;
const released2 = $(el).find(".chapter-release-date").text().trim();
const releasedUTC = (0, import_date_fns.parse)(released2, "MMMM dd, yyyy", /* @__PURE__ */ new Date());
episodes.push({
// Episode id spoofing cause the API doesn't return the episode id, it returns a path.
id: btoa(id2),
title: title2,
thumbnail,
number,
releasedUTC,
releasedRelative: released2
});
});
this.sortEpisodes(episodes, episodeSort);
return {
id,
title,
cover: cover ? cover.replaceAll(" ", "%20") : "",
summary,
views,
ratingCount,
released,
genres,
totalEpisodes: episodesLength,
episodes
};
};
/**
* Retrieves streaming sources for a specific episode by its ID.
*
* @param {string} id - The encoded episode ID.
* @returns {Promise<HHHentaiSources>} A promise that resolves to the streaming sources for the episode.
* @throws {TypeError} If the ID is invalid or not provided.
* @throws {Error} If the episode ID is not properly encoded.
*/
getEpisode = async (id) => {
if (!id || typeof id !== "string") {
throw new TypeError("Invalid identifier");
}
if (id?.includes("episode-")) {
throw new Error("The Episode ID must be encoded.");
}
const pageUrl = `${this.BASE_URL}/watch/${atob(id)}`;
const pageResponse = await fetch(pageUrl);
const pageHtml = await pageResponse.text();
const $page = (0, import_cheerio2.load)(pageHtml);
const iframeSrc = $page(".player_logic_item > iframe").attr("src");
const iframeResponse = await fetch(iframeSrc || "");
const iframeHtml = await iframeResponse.text();
const $iframe = (0, import_cheerio2.load)(iframeHtml);
const secureToken = $iframe('meta[name="x-secure-token"]').attr("content")?.replace("sha512-", "");
const rotatedSha = rot13Cipher(secureToken || "");
const firstDecode = atob(rotatedSha);
const secondRotate = rot13Cipher(firstDecode);
const secondDecode = atob(secondRotate);
const thirdRotate = rot13Cipher(secondDecode);
const decryptedData = JSON.parse(atob(thirdRotate));
const formData = new FormData();
formData.append("action", "zarat_get_data_player_ajax");
formData.append("a", decryptedData.en);
formData.append("b", decryptedData.iv);
const apiUrl = `${decryptedData.uri || "https://hentaihaven.xxx/wp-content/plugins/player-logic/"}api.php`;
const apiResponse = await (await fetch(apiUrl, {
method: "POST",
body: formData,
mode: "cors",
cache: "default"
})).json();
const sources = apiResponse.data.sources;
const thumbnail = apiResponse.data.image;
return {
sources,
thumbnail
};
};
/**
* Sorts an array of episodes based on the specified sort order.
*
* @param {HHHentaiEpisode[]} episodes - The array of episodes to sort.
* @param {HHEpisodesSort} sortOrder - The sort order to apply ("ASC" for ascending, "DESC" for descending).
*/
sortEpisodes(episodes, sortOrder) {
episodes.sort((a, b) => {
if (sortOrder === "ASC") {
return a.number - b.number;
}
return b.number - a.number;
});
}
};
// src/sources/video/hentai-stream.ts
var import_cheerio3 = require("cheerio");
var import_chrono_node = require("chrono-node");
// src/utils/normalize.ts
function normalize(str) {
if (typeof str !== "string") {
throw new TypeError("Input must be a string");
}
if (str.trim() === "") {
return str;
}
const normalized = str.replace(/[^a-zA-Z\s]/g, " ");
return normalized.toLowerCase().replace(/\s{2,}/g, " ").replaceAll("episode", "");
}
// src/utils/remove-number-from-string.ts
function removeNumberFromString(str) {
if (typeof str !== "string") {
throw new TypeError("Input must be a string");
}
if (str.trim() === "") {
return str;
}
return str.replace(/\d+/g, "");
}
// src/sources/video/hentai-stream.ts
var HENTAI_STREAM_BASE_URL = "https://tube.hentaistream.com";
var HentaiStream = class {
BASE_URL = HENTAI_STREAM_BASE_URL;
/**
* Creates a new HentaiStream instance
* @param options - Configuration options
*/
constructor(options) {
this.BASE_URL = options?.baseUrl || HENTAI_STREAM_BASE_URL;
}
/**
* Search for anime on HentaiStream
* @param query - The search query
* @returns Promise resolving to an array of search results
* @throws {TypeError} If query is invalid
*/
search = async (query) => {
if (!query || typeof query !== "string") {
throw new TypeError("Invalid Query");
}
const url = `${this.BASE_URL}/?s=${encodeURIComponent(query)}`;
const response = await fetch(url);
const data = await response.text();
const $ = (0, import_cheerio3.load)(data);
const results = [];
$(".content .post").each((_i, e) => {
const $e = $(e);
const id = ($e.find("div.postimg a").attr("href") || "").split("/").pop() || "";
const title = $e.find("p.posttitle ins").text().trim() || "";
const image = $e.find("div.postimg img").attr("src");
const views = Number.parseInt($e.find(".view").text().trim().split(" ")[0].replaceAll(",", "")) || 0;
const releaseDate = (0, import_chrono_node.parseDate)($e.find(".dtcreated").text().trim().split("Added: ")[1].trim());
results.push({
id,
image,
title,
views,
releaseDate
});
});
return results;
};
/**
* Get information about a specific episode
* @param id - The episode ID
* @returns Promise resolving to episode information
* @throws {TypeError} If ID is invalid
*/
getInfoEpisode = async (id) => {
if (!id || typeof id !== "string") {
throw new TypeError("Invalid ID.");
}
const url = `${this.BASE_URL}/${id}`;
const response = await fetch(url);
const data = await response.text();
const $ = (0, import_cheerio3.load)(data);
const title = $(".videotitle").text().trim().replaceAll("\xA4", "").trim();
const releasedDate = (0, import_chrono_node.parseDate)(
$(".threebox p:nth-child(1)").text().trim().split("Added: ")[1].split(" @")[0].trim()
);
const views = Number.parseInt(
$(".threebox p:nth-child(2)").text().trim().split("Views: ")[1].replaceAll(",", "")
);
const genres = $('div.videotags:contains("Genre(s)") a').map((_i, e) => $(e).text().trim()).get();
return {
title,
releasedDate,
views,
genres
};
};
/**
* Get detailed information about an anime series
* @param id - The anime ID or title
* @returns Promise resolving to anime information or null if not found
* @throws {TypeError} If ID is invalid
*/
getInfo = async (id) => {
if (!id || typeof id !== "string") {
throw new TypeError("Invalid ID.");
}
const normalizedId = normalize(id);
const searchData = await this.search(normalizedId);
const matchingResults = searchData.filter((result) => {
const normalizedTitle = normalize(result.title || "");
return normalizedTitle.includes(normalizedId);
});
if (matchingResults.length === 0) {
return null;
}
const firstResult = matchingResults[0];
const episodes = await Promise.all(
matchingResults.map(async (result) => {
const episodeInfo = await this.getInfoEpisode(result.id);
const episodeNumber = getNumberFromString(result.title || "");
return {
...result,
...episodeInfo,
episodeNumber
};
})
);
episodes.sort((a, b) => {
const aNum = a.episodeNumber || 0;
const bNum = b.episodeNumber || 0;
return aNum - bNum;
});
const averageViews = Math.ceil(
episodes.reduce((sum, episode) => sum + (episode.views || 0), 0) / episodes.length
);
const genres = new Set(episodes.flatMap((episode) => episode.genres || []));
return {
title: (normalizedId.charAt(0).toUpperCase() + normalizedId.slice(1)).trim(),
image: firstResult.image,
genres: [...genres.values()],
views: averageViews,
episodes: episodes.map((ep) => ({
id: btoa(ep.id),
number: ep.episodeNumber,
views: ep.views,
releasedDate: ep.releaseDate,
title: ep.title,
image: ep.image
})),
releasedDate: episodes[0].releaseDate
};
};
/**
* Get streaming information for a specific episode
* @param id - The encoded episode ID
* @returns Promise resolving to episode streaming information
* @throws {TypeError} If ID is invalid
* @throws {Error} If streams cannot be fetched
*/
getEpisode = async (id) => {
if (!id || typeof id !== "string") {
throw new TypeError("Invalid ID.");
}
const url = `${this.BASE_URL}/${atob(id)}`;
const response = await fetch(url);
const data = await response.text();
const $ = (0, import_cheerio3.load)(data);
const frameUrl = $("iframe").attr("src");
const title = $(".videotitle").text().trim().replaceAll("\xA4", "").trim();
const releasedDate = (0, import_chrono_node.parseDate)(
$(".threebox p:nth-child(1)").text().trim().split("Added: ")[1].split(" @")[0].trim()
);
const views = Number.parseInt(
$(".threebox p:nth-child(2)").text().trim().split("Views: ")[1].replaceAll(",", "")
);
if (!frameUrl) {
throw new Error("Failed to fetch streams");
}
const res = await fetch(frameUrl);
const frameData = await res.text();
const $$ = (0, import_cheerio3.load)(frameData);
const $video = $$("video");
const source = $video.find("source").attr("src");
return {
title,
releasedDate,
views,
source
};
};
};
// src/sources/gallery/r34.ts
var import_cheerio4 = require("cheerio");
// src/utils/Dimension.ts
var Dimension = class _Dimension {
width;
height;
constructor(width, height) {
this.width = width;
this.height = height;
}
getAspectRatio() {
const gcd = this.gcd(this.width, this.height);
return `${this.width / gcd}:${this.height / gcd}`;
}
getWidthInPx() {
return this.width;
}
getHeightInPx() {
return this.height;
}
getWidthInRem(baseFontSize = 16) {
return this.width / baseFontSize;
}
getHeightInRem(baseFontSize = 16) {
return this.height / baseFontSize;
}
gcd(a, b) {
if (b === 0) {
return a;
}
return this.gcd(b, a % b);
}
static fromString(dimensionString) {
const [width, height] = dimensionString.split("x").map(Number);
if (Number.isNaN(width) || Number.isNaN(height) || width <= 0 || height <= 0) {
return null;
}
return new _Dimension(width, height);
}
};
// src/sources/gallery/r34.ts
var RULE34_BASE_URL = "https://rule34.xxx";
var RULE34_API_URL = "https://ac.rule34.xxx";
var Rule34 = class {
/**
* Base URL for the Rule34 website.
*/
BASE_URL = RULE34_BASE_URL;
/**
* API URL for Rule34 autocomplete functionality.
*/
API_URL = RULE34_API_URL;
/**
* Creates a new instance of the Rule34 client.
*
* @param {R34Options} [options] - Configuration options for the Rule34 client.
* @param {string} [options.baseUrl] - Custom base URL for the Rule34 website.
* @param {string} [options.apiUrl] - Custom API URL for Rule34 autocomplete functionality.
*/
constructor(options) {
this.BASE_URL = options?.baseUrl || RULE34_BASE_URL;
this.API_URL = options?.apiUrl || RULE34_API_URL;
}
/**
* Searches for autocomplete suggestions based on the provided query.
*
* @param {string} query - The search query string.
* @returns {Promise<Array<{completedQuery: string, label: string, type: string}>>} A promise that resolves to an array of autocomplete suggestions.
* @throws {TypeError} If the query is empty or not a string.
*/
searchAutocomplete = async (query) => {
if (!query || typeof query !== "string") {
throw new TypeError("Query invalid");
}
const url = `${this.API_URL}/autocomplete.php?q=${query}`;
const response = await fetch(url);
const data = await response.json();
return data.map((item) => ({
completedQuery: item.value,
label: item.label,
type: item.type
}));
};
/**
* Searches for images on Rule34 based on the provided query.
*
* @param {string} query - The search query string.
* @param {number} [page=1] - The page number to retrieve (default is 1).
* @param {number} [perPage=10] - The number of results per page (default is 10).
* @returns {Promise<R34SearchResult>} A promise that resolves to a paginated result of search results.
* @throws {TypeError} If the query is empty or not a string.
*/
search = async (query, page = 1, perPage = 10) => {
if (!query || typeof query !== "string") {
throw new TypeError("Query invalid");
}
const url = `${this.BASE_URL}/index.php?page=post&s=list&tags=${query}&pid=${(page - 1) * perPage}`;
const response = await fetch(url);
const data = await response.text();
const $ = (0, import_cheerio4.load)(data);
const results = [];
$(".image-list span").each((_i, e) => {
const $e = $(e);
const id = $e.attr("id")?.replace("s", "") || "";
const image = $e.find("img").attr("src") || "";
const tags = $e.find("img").attr("alt")?.trim()?.split(" ").filter((tag) => tag !== "") || [];
results.push({
id,
image,
tags,
type: "preview"
});
});
const pagination = $("#paginator .pagination");
const totalPages = Number.parseInt(pagination.find("a:last").attr("href")?.split("pid=")[1] || "1", 10) / perPage + 1;
const currentPage = page;
const nextPage = currentPage < totalPages ? currentPage + 1 : null;
const previousPage = currentPage > 1 ? currentPage - 1 : null;
const hasNextPage = nextPage !== null;
const next = nextPage !== null ? nextPage * perPage : 0;
const previous = previousPage !== null ? previousPage * perPage : 0;
return {
total: totalPages * perPage,
next,
previous,
pages: totalPages,
page: currentPage,
hasNextPage,
results
};
};
/**
* Gets detailed information about a specific image by its ID.
*
* @param {string} id - The ID of the image to retrieve information for.
* @returns {Promise<R34ImageInfo>} A promise that resolves to an object containing detailed information about the image.
*/
getInfo = async (id) => {
const url = `${this.BASE_URL}/index.php?page=post&s=view&id=${id}`;
const resizeCookies = {
"resize-notification": 1,
"resize-original": 1
};
const [resizedResponse, nonResizedResponse] = await Promise.all([
fetch(url),
fetch(url, {
headers: {
cookie: Object.entries(resizeCookies).map(([key, value]) => `${key}=${value}`).join("; ")
}
})
]);
const [resized, original] = await Promise.all([
resizedResponse.text(),
nonResizedResponse.text()
]);
const $resized = (0, import_cheerio4.load)(resized);
const resizedImageUrl = $resized("#image").attr("src");
const $ = (0, import_cheerio4.load)(original);
const fullImage = $("#image").attr("src");
const tags = $("#image").attr("alt")?.trim()?.split(" ").filter((tag) => tag !== "");
const stats = $("#stats ul");
const postedData = stats.find("li:nth-child(2)").text().trim();
const createdAt = new Date(postedData.split("Posted: ")[1].split("by")[0]).getTime();
const publishedBy = postedData.split("by")[1].trim();
const size = stats.find("li:nth-child(3)").text().trim().split("Size: ")[1];
const rating = stats.find("li:contains('Rating:')").text().trim().split("Rating: ")[1];
const dimension = Dimension.fromString(size);
const comments = $("#comment-list div").map((_i, el) => {
const $el = $(el);
const id2 = $el.attr("id")?.replace("c", "");
const user = $el.find(".col1").text().trim().split("\n")[0];
const comment = $el.find(".col2").text().trim();
return {
id: id2,
user,
comment
};
}).get().filter(Boolean).filter((comment) => comment.comment !== "");
return {
id,
fullImage,
resizedImageUrl,
tags,
createdAt,
publishedBy,
rating,
sizes: {
aspect: dimension?.getAspectRatio(),
width: dimension?.getWidthInPx(),
height: dimension?.getHeightInPx(),
widthRem: dimension?.getWidthInRem(),
heightRem: dimension?.getHeightInRem(),
fullSize: dimension ? dimension.getWidthInPx() * dimension.getHeightInPx() : void 0,
formatted: `${dimension?.getWidthInPx()}x${dimension?.getHeightInPx()}`
},
comments
};
};
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Dimension,
HANIME_BASE_URL,
HANIME_SEARCH_URL,
HANIME_SIGNATURE_GENERATOR,
HAnime,
HENTAI_HAVEN_URL,
HENTAI_STREAM_BASE_URL,
HentaiHaven,
HentaiStream,
RULE34_API_URL,
RULE34_BASE_URL,
Rule34,
getNumberFromString,
normalize,
removeNumberFromString,
rot13Cipher
});
//# sourceMappingURL=index.cjs.map