UNPKG

@mohtasimalam/hentai.js

Version:

A library that fetches hentai data from different sources.

1 lines 74.8 kB
{"version":3,"sources":["../src/index.ts","../src/sources/video/hanime.ts","../src/sources/video/hentai-haven.ts","../src/utils/get-number-from-string.ts","../src/utils/rot13.ts","../src/sources/video/hentai-stream.ts","../src/utils/normalize.ts","../src/utils/remove-number-from-string.ts","../src/sources/gallery/r34.ts","../src/utils/Dimension.ts"],"sourcesContent":["export * from \"./sources\";\nexport * from \"./utils\";\n","import { load } from \"cheerio\";\nimport type { PaginatedResult } from \"../../types\";\n\nexport const HANIME_BASE_URL = \"https://hanime.tv\";\nexport const HANIME_SEARCH_URL = \"https://search.htv-services.com\";\nexport const HANIME_SIGNATURE_GENERATOR = () =>\n\tArray.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join(\"\");\n\nexport const HAnime = class {\n\tpublic BASE_URL = HANIME_BASE_URL;\n\tpublic SEARCH_URL = HANIME_BASE_URL;\n\tpublic generateSignature = HANIME_SIGNATURE_GENERATOR;\n\n\t/**\n\t * Creates a new instance of the HAnime client.\n\t *\n\t * @param {HAnimeOptions} [options] - Configuration options for the HAnime client.\n\t * @param {string} [options.baseUrl] - Custom base URL for the HAnime website.\n\t * @param {string} [options.searchUrl] - Custom search API URL.\n\t * @param {function(): string} [options.signatureGenerator] - Custom function to generate request signatures.\n\t */\n\tconstructor(options?: HAnimeOptions) {\n\t\tthis.BASE_URL = options?.baseUrl || HANIME_BASE_URL;\n\t\tthis.SEARCH_URL = options?.searchUrl || HANIME_SEARCH_URL;\n\t\tthis.generateSignature = options?.signatureGenerator || HANIME_SIGNATURE_GENERATOR;\n\t}\n\n\t/**\n\t * Searches for videos on Hanime.tv based on the provided query.\n\t *\n\t * @param {string} query - The search query string.\n\t * @param {number} [page=1] - The page number to retrieve (default is 1).\n\t * @param {number} [perPage=10] - The number of results per page (default is 10).\n\t * @returns {Promise<PaginatedResult<HAnimeSearchResult>>} A promise that resolves to a paginated result of search results.\n\t */\n\tpublic search = async (\n\t\tquery: string,\n\t\tpage = 1,\n\t\tperPage = 10,\n\t): Promise<PaginatedResult<HAnimeSearchResult>> => {\n\t\tif (!query) {\n\t\t\tthrow new Error(\"Search query cannot be empty\");\n\t\t}\n\n\t\tlet validPage = page;\n\t\tif (validPage < 1) {\n\t\t\tvalidPage = 1;\n\t\t}\n\n\t\tlet validPerPage = perPage;\n\t\tif (validPerPage < 1) {\n\t\t\tvalidPerPage = 10;\n\t\t}\n\n\t\ttry {\n\t\t\tconst response = await fetch(this.SEARCH_URL, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: {\n\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t\tAccept: \"application/json\",\n\t\t\t\t},\n\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\tblacklist: [],\n\t\t\t\t\tbrands: [],\n\t\t\t\t\torder_by: \"created_at_unix\",\n\t\t\t\t\tpage: validPage - 1,\n\t\t\t\t\ttags: [],\n\t\t\t\t\tsearch_text: query.trim(),\n\t\t\t\t\ttags_mode: \"AND\",\n\t\t\t\t}),\n\t\t\t});\n\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`Search request failed with status: ${response.status}`);\n\t\t\t}\n\n\t\t\tconst data = (await response.json()) as {\n\t\t\t\tpage: number;\n\t\t\t\tnbPages: number;\n\t\t\t\tnbHits: number;\n\t\t\t\thitsPerPage: number;\n\t\t\t\thits: HAnimeRawSearchResult[];\n\t\t\t};\n\n\t\t\tif (!data.hits) {\n\t\t\t\treturn {\n\t\t\t\t\tresults: [],\n\t\t\t\t\ttotal: 0,\n\t\t\t\t\tpage,\n\t\t\t\t\tpages: 0,\n\t\t\t\t\thasNextPage: false,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tlet allResults: HAnimeSearchResult[] = [];\n\t\t\ttry {\n\t\t\t\tallResults = data.hits.map((result) => this.mapToSearchResult(result));\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Failed to parse search results:\", error);\n\t\t\t\tthrow new Error(\"Failed to parse search results\");\n\t\t\t}\n\n\t\t\tconst totalResults = data.nbHits || 0;\n\t\t\tconst totalPages = Math.max(1, Math.ceil(totalResults / validPerPage));\n\n\t\t\tconst finalPage = Math.min(Math.max(1, validPage), totalPages);\n\n\t\t\tconst startIndex = (finalPage - 1) * validPerPage;\n\t\t\tconst endIndex = Math.min(startIndex + validPerPage, allResults.length);\n\t\t\tconst results = allResults.slice(startIndex, endIndex);\n\n\t\t\treturn {\n\t\t\t\tresults,\n\t\t\t\ttotal: totalResults,\n\t\t\t\tpage: finalPage,\n\t\t\t\tpages: totalPages,\n\t\t\t\tprevious: finalPage > 1 ? finalPage - 1 : undefined,\n\t\t\t\tnext: finalPage < totalPages ? finalPage + 1 : undefined,\n\t\t\t\thasNextPage: finalPage < totalPages,\n\t\t\t};\n\t\t} catch (error) {\n\t\t\tconsole.error(\"Search error:\", error);\n\t\t\tthrow new Error(\n\t\t\t\t`Failed to search HAnime: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t);\n\t\t}\n\t};\n\n\t/**\n\t * Retrieves detailed information about a specific video by its slug.\n\t *\n\t * @param {string} slug - The unique slug identifier for the video.\n\t * @returns {Promise<HAnimeVideoInfo>} A promise that resolves to detailed information about the video.\n\t */\n\tpublic getInfo = async (slug: string): Promise<HAnimeVideoInfo> => {\n\t\tconst path = `/videos/hentai/${slug}`;\n\t\tconst url = `${this.BASE_URL}${path}`;\n\n\t\tconst response = await fetch(url);\n\t\tconst html = await response.text();\n\n\t\tconst $ = load(html);\n\n\t\tconst script = $('script:contains(\"window.__NUXT__\")');\n\t\tconst scriptHtml = script.html();\n\t\tconst json = JSON.parse(\n\t\t\tscriptHtml?.replace(\"window.__NUXT__=\", \"\").replaceAll(\";\", \"\") || \"{}\",\n\t\t) as HanimeResponse;\n\n\t\tconst videoData = json.state.data.video;\n\n\t\treturn {\n\t\t\ttitle: json.state.data.video.hentai_franchise.name,\n\t\t\tslug: json.state.data.video.hentai_franchise.slug,\n\t\t\tid: videoData.hentai_video.id,\n\t\t\tdescription: videoData.hentai_video.description,\n\t\t\tviews: videoData.hentai_video.views,\n\t\t\tinterests: videoData.hentai_video.interests,\n\t\t\tposterUrl: videoData.hentai_video.poster_url,\n\t\t\tcoverUrl: videoData.hentai_video.cover_url,\n\t\t\tbrand: {\n\t\t\t\tname: videoData.hentai_video.brand,\n\t\t\t\tid: videoData.hentai_video.brand_id,\n\t\t\t},\n\t\t\tdurationMs: videoData.hentai_video.duration_in_ms,\n\t\t\tisCensored: videoData.hentai_video.is_censored,\n\t\t\tlikes: videoData.hentai_video.likes,\n\t\t\trating: videoData.hentai_video.rating,\n\t\t\tdislikes: videoData.hentai_video.dislikes,\n\t\t\tdownloads: videoData.hentai_video.downloads,\n\t\t\trankMonthly: videoData.hentai_video.monthly_rank,\n\t\t\ttags: videoData.hentai_tags,\n\t\t\tcreatedAt: videoData.hentai_video.created_at,\n\t\t\treleasedAt: videoData.hentai_video.released_at,\n\t\t\tepisodes: {\n\t\t\t\tnext: this.mapToEpisode(videoData.next_hentai_video),\n\t\t\t\tall: json.state.data.video.hentai_franchise_hentai_videos.map(this.mapToEpisode),\n\t\t\t\trandom: this.mapToEpisode(videoData.next_random_hentai_video),\n\t\t\t},\n\t\t};\n\t};\n\n\t/**\n\t * Retrieves stream information for a specific video episode by its slug.\n\t *\n\t * @param {string} slug - The unique slug identifier for the video episode.\n\t * @returns {Promise<HAnimeStream[]>} A promise that resolves to an array of available video streams.\n\t */\n\tpublic getEpisode = async (slug: string): Promise<HAnimeStream[]> => {\n\t\tconst apiUrl = `${this.BASE_URL}/rapi/v7/videos_manifests/${slug}`;\n\t\tconst signature = this.generateSignature();\n\n\t\tconst response = await fetch(apiUrl, {\n\t\t\theaders: {\n\t\t\t\t\"x-signature\": signature,\n\t\t\t\t\"x-time\": Math.floor(Date.now() / 1000).toString(),\n\t\t\t\t\"x-signature-version\": \"web2\",\n\t\t\t},\n\t\t});\n\n\t\tconst json = (await response.json()) as { videos_manifest: VideosManifest };\n\n\t\tconst data = json.videos_manifest;\n\t\tconst videos = data.servers.flatMap((server) => server.streams);\n\n\t\tconst streams = videos\n\t\t\t.map((video) => ({\n\t\t\t\tid: video.id,\n\t\t\t\tserverId: video.server_id,\n\t\t\t\tkind: video.kind,\n\t\t\t\textension: video.extension,\n\t\t\t\tmimeType: video.mime_type,\n\t\t\t\twidth: video.width,\n\t\t\t\theight: video.height,\n\t\t\t\tdurationInMs: video.duration_in_ms,\n\t\t\t\tfilesizeMbs: video.filesize_mbs,\n\t\t\t\tfilename: video.filename,\n\t\t\t\turl: video.url,\n\t\t\t}))\n\t\t\t.filter((video) => video.url && video.url !== \"\" && video.kind !== \"premium_alert\");\n\n\t\treturn streams;\n\t};\n\n\tpublic mapToSearchResult = (raw: HAnimeRawSearchResult): HAnimeSearchResult => {\n\t\treturn {\n\t\t\tid: raw.id,\n\t\t\tname: raw.name,\n\t\t\ttitles: raw.titles,\n\t\t\tslug: raw.slug,\n\t\t\tdescription: raw.description,\n\t\t\tviews: raw.views,\n\t\t\tinterests: raw.interests,\n\t\t\tbannerImage: raw.poster_url,\n\t\t\tcoverImage: raw.cover_url,\n\t\t\tbrand: {\n\t\t\t\tname: raw.brand,\n\t\t\t\tid: raw.brand_id,\n\t\t\t},\n\t\t\tdurationMs: raw.duration_in_ms,\n\t\t\tisCensored: raw.is_censored,\n\t\t\tlikes: raw.likes,\n\t\t\trating: raw.rating,\n\t\t\tdislikes: raw.dislikes,\n\t\t\tdownloads: raw.downloads,\n\t\t\trankMonthly: raw.monthly_rank,\n\t\t\ttags:\n\t\t\t\ttypeof raw.tags === \"object\" && Array.isArray(raw.tags) ? raw.tags : JSON.parse(raw.tags),\n\t\t\tcreatedAt: raw.created_at,\n\t\t\treleasedAt: raw.released_at,\n\t\t};\n\t};\n\n\tpublic mapToEpisode = (raw: {\n\t\tid: number;\n\t\tname: string;\n\t\tslug: string;\n\t\tcreated_at: string;\n\t\treleased_at: string;\n\t\tviews: number;\n\t\tinterests: number;\n\t\tposter_url: string;\n\t\tcover_url: string;\n\t\tis_hard_subtitled: boolean;\n\t\tbrand: string;\n\t\tduration_in_ms: number;\n\t\tis_censored: boolean;\n\t\trating: number;\n\t\tlikes: number;\n\t\tdislikes: number;\n\t\tdownloads: number;\n\t\tmonthly_rank: number;\n\t\tbrand_id: string;\n\t\tis_banned_in: string;\n\t\tpreview_url: null;\n\t\tprimary_color: null;\n\t\tcreated_at_unix: number;\n\t\treleased_at_unix: number;\n\t}) => {\n\t\treturn {\n\t\t\tid: raw.id,\n\t\t\tname: raw.name,\n\t\t\tslug: raw.slug,\n\t\t\tviews: raw.views,\n\t\t\tinterests: raw.interests,\n\t\t\tthumbnailUrl: raw.poster_url,\n\t\t\tcoverUrl: raw.cover_url,\n\t\t\tisHardSubtitled: raw.is_hard_subtitled,\n\t\t\tbrand: {\n\t\t\t\tname: raw.brand,\n\t\t\t\tid: raw.brand_id,\n\t\t\t},\n\t\t\tdurationMs: raw.duration_in_ms,\n\t\t\tisCensored: raw.is_censored,\n\t\t\tlikes: raw.likes,\n\t\t\trating: raw.rating,\n\t\t\tdislikes: raw.dislikes,\n\t\t\tdownloads: raw.downloads,\n\t\t\trankMonthly: raw.monthly_rank,\n\t\t\tbrandId: raw.brand_id,\n\t\t\tisBannedIn: raw.is_banned_in,\n\t\t\tpreviewUrl: raw.preview_url,\n\t\t\tcolor: raw.primary_color,\n\t\t\tcreatedAt: raw.created_at_unix,\n\t\t\treleasedAt: raw.released_at_unix,\n\t\t};\n\t};\n};\n\nexport interface HAnimeOptions {\n\tbaseUrl?: string;\n\tsearchUrl?: string;\n\tsignatureGenerator?: () => string;\n}\n\nexport interface HAnimeVideoInfo {\n\ttitle: string;\n\tslug: string;\n\tid: number;\n\tdescription?: string;\n\tviews: number;\n\tinterests: number;\n\tposterUrl: string;\n\tcoverUrl: string;\n\tbrand: {\n\t\tname: string;\n\t\tid: string | number;\n\t};\n\tdurationMs: number;\n\tisCensored: boolean;\n\tlikes: number;\n\trating: number;\n\tdislikes: number;\n\tdownloads: number;\n\trankMonthly: number;\n\ttags: HentaiTag[];\n\tcreatedAt: string;\n\treleasedAt: string;\n\tepisodes: {\n\t\tnext: HentaiVideoEpisode;\n\t\tall: HentaiVideoEpisode[];\n\t\trandom: HentaiVideoEpisode;\n\t};\n}\n\nexport interface HentaiVideoEpisode {\n\tid: number;\n\tname: string;\n\tslug: string;\n\tviews: number;\n\tinterests: number;\n\tthumbnailUrl: string;\n\tcoverUrl: string;\n\tisHardSubtitled: boolean;\n\tbrand: {\n\t\tname: string;\n\t\tid: string;\n\t};\n\tdurationMs: number;\n\tisCensored: boolean;\n\tlikes: number;\n\trating: number;\n\tdislikes: number;\n\tdownloads: number;\n\trankMonthly: number;\n\tbrandId: string;\n\tisBannedIn: string;\n\tpreviewUrl: null;\n\tcolor: null;\n\tcreatedAt: number;\n\treleasedAt: number;\n}\n\nexport interface HAnimeStream {\n\tid: number;\n\tserverId: number;\n\tkind: string;\n\textension: string;\n\tmimeType: string;\n\twidth: number;\n\theight: string | number;\n\tdurationInMs: number;\n\tfilesizeMbs: number;\n\tfilename: string;\n\turl: string;\n}\n\nexport interface HanimeResponse {\n\tlayout: string;\n\tdata: unknown[];\n\terror: null;\n\tserverRendered: boolean;\n\tstate: State;\n\tvideos_manifest?: VideosManifest;\n\tpr?: boolean;\n}\n\nexport interface State {\n\tscrollY: number;\n\tversion: number;\n\tis_new_version: boolean;\n\tr: null;\n\tcountry_code: null;\n\tpage_name: string;\n\tuser_agent: string;\n\tip: null;\n\treferrer: null;\n\tgeo: null;\n\tis_dev: boolean;\n\tis_wasm_supported: boolean;\n\tis_mounted: boolean;\n\tis_loading: boolean;\n\tis_searching: boolean;\n\tbrowser_width: number;\n\tbrowser_height: number;\n\tsystem_msg: string;\n\tdata: Data;\n\tauth_claim: null;\n\tsession_token: string;\n\tsession_token_expire_time_unix: number;\n\tenv: Env;\n\tuser: null;\n\tuser_setting: null;\n\tplaylists: null;\n\tshuffle: boolean;\n\taccount_dialog: AccountDialog;\n\tcontact_us_dialog: ContactUsDialog;\n\tgeneral_confirmation_dialog: GeneralConfirmationDialog;\n\tsnackbar: Snackbar;\n\tsearch: Search;\n}\n\nexport interface Data {\n\tvideo: Video;\n}\n\nexport interface Video {\n\tplayer_base_url: string;\n\thentai_video: HentaiVideo;\n\thentai_tags: HentaiTag[];\n\thentai_franchise: HentaiFranchise;\n\thentai_franchise_hentai_videos: HentaiVideo[];\n\thentai_video_storyboards: HentaiVideoStoryboard[];\n\tbrand: Brand;\n\twatch_later_playlist_hentai_videos: null;\n\tlike_dislike_playlist_hentai_videos: null;\n\tplaylist_hentai_videos: null;\n\tsimilar_playlists_data: null;\n\tnext_hentai_video: HentaiVideo;\n\tnext_random_hentai_video: HentaiVideo;\n\tvideos_manifest?: VideosManifest;\n\tuser_license: null;\n\tbs: Bs;\n\tap: number;\n\tpre: string;\n\tencrypted_user_license: null;\n\thost: string;\n}\n\nexport interface HentaiVideo {\n\tid: number;\n\tis_visible: boolean;\n\tname: string;\n\tslug: string;\n\tcreated_at: string;\n\treleased_at: string;\n\tdescription?: string;\n\tviews: number;\n\tinterests: number;\n\tposter_url: string;\n\tcover_url: string;\n\tis_hard_subtitled: boolean;\n\tbrand: string;\n\tduration_in_ms: number;\n\tis_censored: boolean;\n\trating: number;\n\tlikes: number;\n\tdislikes: number;\n\tdownloads: number;\n\tmonthly_rank: number;\n\tbrand_id: string;\n\tis_banned_in: string;\n\tpreview_url: null;\n\tprimary_color: null;\n\tcreated_at_unix: number;\n\treleased_at_unix: number;\n\thentai_tags?: HentaiTag[];\n\ttitles?: unknown[];\n}\n\nexport interface HentaiTag {\n\tid: number;\n\ttext: string;\n\tcount?: number;\n\tdescription?: string;\n\twide_image_url?: string;\n\ttall_image_url?: string;\n}\n\nexport interface HentaiFranchise {\n\tid: number;\n\tname: string;\n\tslug: string;\n\ttitle: string;\n}\n\nexport interface HentaiVideoStoryboard {\n\tid: number;\n\tnum_total_storyboards: number;\n\tsequence: number;\n\turl: string;\n\tframe_width: number;\n\tframe_height: number;\n\tnum_total_frames: number;\n\tnum_horizontal_frames: number;\n\tnum_vertical_frames: number;\n}\n\nexport interface Brand {\n\tid: number;\n\ttitle: string;\n\tslug: string;\n\twebsite_url: null;\n\tlogo_url: null;\n\temail: null;\n\tcount: number;\n}\n\nexport interface VideosManifest {\n\tservers: Server[];\n}\n\nexport interface Server {\n\tid: number;\n\tname: string;\n\tslug: string;\n\tna_rating: number;\n\teu_rating: number;\n\tasia_rating: number;\n\tsequence: number;\n\tis_permanent: boolean;\n\tstreams: Stream[];\n}\n\nexport interface Stream {\n\tid: number;\n\tserver_id: number;\n\tslug: string;\n\tkind: string;\n\textension: string;\n\tmime_type: string;\n\twidth: number;\n\theight: string;\n\tduration_in_ms: number;\n\tfilesize_mbs: number;\n\tfilename: string;\n\turl: string;\n\tis_guest_allowed: boolean;\n\tis_member_allowed: boolean;\n\tis_premium_allowed: boolean;\n\tis_downloadable: boolean;\n\tcompatibility: string;\n\thv_id: number;\n\tserver_sequence: number;\n\tvideo_stream_group_id: string;\n\textra2: null;\n}\n\nexport interface Bs {\n\tntv_1: Ntv1;\n\tntv_2: Ntv2;\n\tfooter_0: Footer0;\n\tnative_1: Native1;\n\tnative_0: Native0;\n\tntv_0: Ntv0;\n}\n\nexport interface Ntv1 {\n\tdesktop: DesktopAd;\n}\n\nexport interface Ntv2 {\n\tdesktop: DesktopAd;\n}\n\nexport interface Footer0 {\n\tmobile: MobileAd;\n\tdesktop: DesktopAd;\n}\n\nexport interface Native1 {\n\tmobile: NativeAd;\n}\n\nexport interface Native0 {\n\tmobile: NativeAd;\n}\n\nexport interface Ntv0 {\n\tdesktop: DesktopAd;\n}\n\nexport interface DesktopAd {\n\tid: number;\n\tad_id: string;\n\tad_type: string;\n\tplacement: string;\n\timage_url: null;\n\tiframe_url: string;\n\tclick_url: null | string;\n\twidth: number;\n\theight: number;\n\tpage: string;\n\tform_factor: string;\n\tvideo_url: null;\n\timpressions: number;\n\tclicks: number;\n\tseconds: number;\n\tplacement_x: null;\n}\n\nexport interface MobileAd {\n\tid: number;\n\tad_id: string;\n\tad_type: string;\n\tplacement: string;\n\timage_url: null;\n\tiframe_url: string;\n\tclick_url: null;\n\twidth: number;\n\theight: number;\n\tpage: string;\n\tform_factor: string;\n\tvideo_url: null;\n\timpressions: number;\n\tclicks: number;\n\tseconds: number;\n\tplacement_x: null;\n}\n\nexport interface NativeAd {\n\tid: number;\n\tad_id: string;\n\tad_type: string;\n\tplacement: string;\n\timage_url: string;\n\tiframe_url: null;\n\tclick_url: string;\n\twidth: number;\n\theight: number;\n\tpage: string;\n\tform_factor: string;\n\tvideo_url: null;\n\timpressions: number;\n\tclicks: number;\n\tseconds: number;\n\tplacement_x: string;\n}\n\nexport interface Env {\n\tvhtv_version: number;\n\tpremium_coin_cost: number;\n\tmobile_apps: MobileApps;\n}\n\nexport interface MobileApps {\n\tcode_name: string;\n\t_build_number: number;\n\t_semver: string;\n\t_md5: string;\n\t_url: string;\n}\n\nexport interface AccountDialog {\n\tis_visible: boolean;\n\tactive_tab_id: string;\n\ttabs: Tab[];\n}\n\nexport interface Tab {\n\tid: string;\n\ticon: string;\n\ttitle: string;\n}\n\nexport interface ContactUsDialog {\n\tis_visible: boolean;\n\tis_video_report: boolean;\n\tsubject: string;\n\temail: string;\n\tmessage: string;\n\tis_sent: boolean;\n}\n\nexport interface GeneralConfirmationDialog {\n\tis_visible: boolean;\n\tis_persistent: boolean;\n\tis_mini_close_button_visible: boolean;\n\tis_cancel_button_visible: boolean;\n\tcancel_button_text: string;\n\ttitle: string;\n\tbody: string;\n\tconfirm_button_text: string;\n\tconfirmation_callback: null;\n}\n\nexport interface Snackbar {\n\ttimeout: number;\n\tcontext: string;\n\tmode: string;\n\ty: string;\n\tx: string;\n\tis_visible: boolean;\n\ttext: string;\n}\n\nexport interface Search {\n\tcache_sorting_config: unknown[];\n\tcache_tags_filter: null;\n\tcache_active_brands: null;\n\tcache_blacklisted_tags_filter: null;\n\tsearch_text: string;\n\tsearch_response_payload: null;\n\ttotal_search_results_count: number;\n\torder_by: string;\n\tordering: string;\n\ttags_match: string;\n\tpage_size: number;\n\toffset: number;\n\tpage: number;\n\tnumber_of_pages: number;\n\ttags: unknown[];\n\tactive_tags_count: number;\n\tbrands: unknown[];\n\tactive_brands_count: number;\n\tblacklisted_tags: unknown[];\n\tactive_blacklisted_tags_count: number;\n\tis_using_preferences: boolean;\n}\n\nexport interface HAnimeSearchResult {\n\tid: number;\n\tname: string;\n\ttitles: string[];\n\tslug: string;\n\tdescription: string;\n\tviews: number;\n\tinterests: number;\n\tbannerImage: string;\n\tcoverImage: string;\n\tbrand: {\n\t\tname: string;\n\t\tid: number;\n\t};\n\tdurationMs: number;\n\tisCensored: boolean;\n\tlikes: number;\n\trating: number;\n\tdislikes: number;\n\tdownloads: number;\n\trankMonthly: number;\n\ttags: string[];\n\tcreatedAt: number;\n\treleasedAt: number;\n}\n\nexport interface HAnimeRawSearchResult {\n\tid: number;\n\tname: string;\n\ttitles: string[];\n\tslug: string;\n\tdescription: string;\n\tviews: number;\n\tinterests: number;\n\tposter_url: string;\n\tcover_url: string;\n\tbrand: string;\n\tbrand_id: number;\n\tduration_in_ms: number;\n\tis_censored: boolean;\n\tlikes: number;\n\trating: number;\n\tdislikes: number;\n\tdownloads: number;\n\tmonthly_rank: number;\n\ttags: string[] | string;\n\tcreated_at: number;\n\treleased_at: number;\n}\n","import { load } from \"cheerio\";\nimport { parse } from \"date-fns\";\nimport { getNumberFromString } from \"../../utils/get-number-from-string\";\nimport { rot13Cipher } from \"../../utils/rot13\";\n\n/**\n * Base URL for the Hentai Haven website.\n */\nexport const HENTAI_HAVEN_URL = \"http://hentaihaven.xxx\";\n\n/**\n * Client for interacting with the Hentai Haven website.\n */\nexport const HentaiHaven = class {\n\t/**\n\t * Base URL for API requests.\n\t */\n\tpublic BASE_URL = HENTAI_HAVEN_URL;\n\n\t/**\n\t * Creates a new instance of the HentaiHaven client.\n\t *\n\t * @param {HentaiHavenOptions} options - Configuration options for the HentaiHaven client.\n\t * @param {string} [options.baseUrl] - Custom base URL for the HentaiHaven website.\n\t */\n\tconstructor(options?: HentaiHavenOptions) {\n\t\tthis.BASE_URL = options?.baseUrl || HENTAI_HAVEN_URL;\n\t}\n\n\t/**\n\t * Searches for hentai videos on Hentai Haven based on the provided query.\n\t *\n\t * @param {string} query - The search query string.\n\t * @returns {Promise<HHSearchResult[]>} A promise that resolves to an array of search results.\n\t * @throws {TypeError} If the query is empty or not a string.\n\t */\n\tpublic search = async (query: string) => {\n\t\tif (!query || typeof query !== \"string\") {\n\t\t\tthrow new TypeError(\"Invalid query in search.\");\n\t\t}\n\n\t\tconst url = `${this.BASE_URL}/?s=${query}&post_type=wp-manga`;\n\n\t\tconst response = await fetch(url);\n\t\tconst data = await response.text();\n\n\t\tconst $ = load(data);\n\t\tconst results: HHSearchResult[] = [];\n\n\t\t$(\".c-tabs-item__content\").each((_i, el) => {\n\t\t\tconst cover = $(el).find(\".c-image-hover img\").attr(\"src\") || \"\";\n\t\t\tconst id = $(el).find(\".c-image-hover a\").attr(\"href\")?.split(\"/\")[4] || \"\";\n\t\t\tconst title = $(el).find(\".post-title h3\").text().trim();\n\t\t\tconst alternative = $(el).find(\".tab-summary .mg_alternative .summary-content\").text().trim();\n\t\t\tconst author = $(el).find(\".tab-summary .mg_author .summary-content\").text().trim();\n\t\t\tconst released = Number(\n\t\t\t\t$(el).find(\".tab-summary .mg_release .summary-content\").text().trim(),\n\t\t\t);\n\t\t\tconst totalEpisodes =\n\t\t\t\tgetNumberFromString($(el).find(\".tab-meta .latest-chap .chapter\").text().trim()) || 0;\n\t\t\tconst dateString = $(el).find(\".tab-meta .post-on\").text().trim();\n\t\t\tconst parsedDate = parse(dateString, \"MMM dd, yyyy\", new Date());\n\n\t\t\tconst rating = Number($(el).find(\".tab-meta .rating .total_votes\").text().trim());\n\n\t\t\tconst genres: HHGenre[] = [];\n\n\t\t\t$(\".tab-summary .mg_genres .summary-content a\").each((_, element) => {\n\t\t\t\tgenres.push({\n\t\t\t\t\tid: $(element).attr(\"href\")?.split(\"/\")[4] || \"\",\n\t\t\t\t\turl: $(element).attr(\"href\") || \"\",\n\t\t\t\t\tname: $(element).text().trim().replaceAll(\",\", \"\"),\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tresults.push({\n\t\t\t\tid,\n\t\t\t\ttitle,\n\t\t\t\tcover: cover.replaceAll(\" \", \"%20\"),\n\t\t\t\trating,\n\t\t\t\treleased,\n\t\t\t\tgenres,\n\t\t\t\ttotalEpisodes,\n\t\t\t\tdate: {\n\t\t\t\t\tunparsed: dateString,\n\t\t\t\t\tparsed: parsedDate,\n\t\t\t\t},\n\t\t\t\talternative,\n\t\t\t\tauthor,\n\t\t\t});\n\t\t});\n\n\t\treturn results;\n\t};\n\n\t/**\n\t * Retrieves detailed information about a hentai series by its ID.\n\t *\n\t * @param {string} id - The unique identifier of the hentai series.\n\t * @param {HHEpisodesSort} [episodeSort=\"ASC\"] - The sort order for episodes (ascending or descending).\n\t * @returns {Promise<HHHentaiInfo>} A promise that resolves to detailed information about the hentai series.\n\t * @throws {Error} If the ID is not provided or if there's an error fetching the data.\n\t */\n\tpublic getInfo = async (id: string, episodeSort: HHEpisodesSort = \"ASC\") => {\n\t\tif (!id) {\n\t\t\tthrow new Error(\"Id is required\");\n\t\t}\n\n\t\tconst url = `${this.BASE_URL}/watch/${id}`;\n\n\t\tconst response = await fetch(url);\n\t\tconst data = await response.text();\n\n\t\tif (data === \"\" || !data) {\n\t\t\tthrow new Error(\"Error fetching data\");\n\t\t}\n\n\t\tconst $ = load(data);\n\n\t\tif ($(\"body\").text().includes(\"webpage has been blocked\")) {\n\t\t\tthrow new Error(`The webpage is blocked. Consider using a CORS proxy. GET ${url}`);\n\t\t}\n\n\t\tconst title = $(\".post-title h1\").text().trim();\n\t\tconst cover = $(\".summary_image img\").attr(\"src\") || \"\";\n\t\tconst ratingCount = Number($('span[property=\"ratingCount\"]').text().trim());\n\t\tconst views = getNumberFromString(\n\t\t\t$(\".post-content_item:nth-child(4) .summary-content\").text(),\n\t\t) as number;\n\t\tconst released = Number($(\".post-status .summary-content a\").text().trim());\n\t\tconst summary = $(\".description-summary p\").text().trim();\n\n\t\tconst genres: HHGenre[] = [];\n\t\tconst episodes: HHHentaiEpisode[] = [];\n\n\t\t$(\".genres-content a\").each((_i, el) => {\n\t\t\tgenres.push({\n\t\t\t\tid: $(el).attr(\"href\")?.split(\"/\")[4] || \"\",\n\t\t\t\turl: $(el).attr(\"href\") || \"\",\n\t\t\t\tname: $(el).text().trim(),\n\t\t\t});\n\t\t});\n\n\t\tconst episodesLength = $(\"li.wp-manga-chapter\").length;\n\n\t\t$(\"li.wp-manga-chapter\").each((i, el) => {\n\t\t\tconst thumbnail = $(el).find(\"img\").attr(\"src\");\n\t\t\tconst id = `${$(el).find(\"a\").attr(\"href\")?.split(\"/\")[4]}/${\n\t\t\t\t$(el).find(\"a\").attr(\"href\")?.split(\"/\")[5]\n\t\t\t}`;\n\t\t\tconst title = $(el).find(\"a\").text().trim();\n\t\t\tconst number = episodesLength - i;\n\t\t\tconst released = $(el).find(\".chapter-release-date\").text().trim();\n\t\t\tconst releasedUTC = parse(released, \"MMMM dd, yyyy\", new Date());\n\n\t\t\tepisodes.push({\n\t\t\t\t// Episode id spoofing cause the API doesn't return the episode id, it returns a path.\n\t\t\t\tid: btoa(id),\n\t\t\t\ttitle,\n\t\t\t\tthumbnail,\n\t\t\t\tnumber,\n\t\t\t\treleasedUTC,\n\t\t\t\treleasedRelative: released,\n\t\t\t});\n\t\t});\n\n\t\tthis.sortEpisodes(episodes, episodeSort);\n\n\t\treturn {\n\t\t\tid,\n\t\t\ttitle,\n\t\t\tcover: cover ? cover.replaceAll(\" \", \"%20\") : \"\",\n\t\t\tsummary,\n\t\t\tviews,\n\t\t\tratingCount,\n\t\t\treleased,\n\t\t\tgenres,\n\t\t\ttotalEpisodes: episodesLength,\n\t\t\tepisodes,\n\t\t} as HHHentaiInfo;\n\t};\n\n\t/**\n\t * Retrieves streaming sources for a specific episode by its ID.\n\t *\n\t * @param {string} id - The encoded episode ID.\n\t * @returns {Promise<HHHentaiSources>} A promise that resolves to the streaming sources for the episode.\n\t * @throws {TypeError} If the ID is invalid or not provided.\n\t * @throws {Error} If the episode ID is not properly encoded.\n\t */\n\tpublic getEpisode = async (id: string) => {\n\t\tif (!id || typeof id !== \"string\") {\n\t\t\tthrow new TypeError(\"Invalid identifier\");\n\t\t}\n\n\t\tif (id?.includes(\"episode-\")) {\n\t\t\tthrow new Error(\"The Episode ID must be encoded.\");\n\t\t}\n\n\t\tconst pageUrl = `${this.BASE_URL}/watch/${atob(id)}`;\n\n\t\tconst pageResponse = await fetch(pageUrl);\n\t\tconst pageHtml = await pageResponse.text();\n\n\t\tconst $page = load(pageHtml);\n\t\tconst iframeSrc = $page(\".player_logic_item > iframe\").attr(\"src\");\n\n\t\tconst iframeResponse = await fetch(iframeSrc || \"\");\n\t\tconst iframeHtml = await iframeResponse.text();\n\n\t\tconst $iframe = load(iframeHtml);\n\t\tconst secureToken = $iframe('meta[name=\"x-secure-token\"]')\n\t\t\t.attr(\"content\")\n\t\t\t?.replace(\"sha512-\", \"\");\n\n\t\tconst rotatedSha = rot13Cipher(secureToken || \"\");\n\t\tconst firstDecode = atob(rotatedSha);\n\t\tconst secondRotate = rot13Cipher(firstDecode);\n\t\tconst secondDecode = atob(secondRotate);\n\t\tconst thirdRotate = rot13Cipher(secondDecode);\n\n\t\tconst decryptedData = JSON.parse(atob(thirdRotate)) as {\n\t\t\ten: string;\n\t\t\tiv: string;\n\t\t\turi: string;\n\t\t};\n\n\t\tconst formData = new FormData();\n\t\tformData.append(\"action\", \"zarat_get_data_player_ajax\");\n\t\tformData.append(\"a\", decryptedData.en);\n\t\tformData.append(\"b\", decryptedData.iv);\n\n\t\tconst apiUrl = `${\n\t\t\tdecryptedData.uri || \"https://hentaihaven.xxx/wp-content/plugins/player-logic/\"\n\t\t}api.php`;\n\t\tconst apiResponse = (await (\n\t\t\tawait fetch(apiUrl, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tbody: formData,\n\t\t\t\tmode: \"cors\",\n\t\t\t\tcache: \"default\",\n\t\t\t})\n\t\t).json()) as {\n\t\t\tstatus: boolean;\n\t\t\tdata: {\n\t\t\t\timage: string | null;\n\t\t\t\tsources: HHHentaiSource[];\n\t\t\t};\n\t\t\tauthorization: {\n\t\t\t\ttoken: string;\n\t\t\t\texpiration: number;\n\t\t\t\tip: string;\n\t\t\t};\n\t\t};\n\n\t\tconst sources = apiResponse.data.sources;\n\t\tconst thumbnail = apiResponse.data.image;\n\n\t\treturn {\n\t\t\tsources,\n\t\t\tthumbnail,\n\t\t} as HHHentaiSources;\n\t};\n\n\t/**\n\t * Sorts an array of episodes based on the specified sort order.\n\t *\n\t * @param {HHHentaiEpisode[]} episodes - The array of episodes to sort.\n\t * @param {HHEpisodesSort} sortOrder - The sort order to apply (\"ASC\" for ascending, \"DESC\" for descending).\n\t */\n\tpublic sortEpisodes(episodes: HHHentaiEpisode[], sortOrder: HHEpisodesSort) {\n\t\tepisodes.sort((a, b) => {\n\t\t\tif (sortOrder === \"ASC\") {\n\t\t\t\treturn a.number - b.number;\n\t\t\t}\n\t\t\treturn b.number - a.number;\n\t\t});\n\t}\n};\n\n/**\n * Configuration options for the HentaiHaven client.\n */\ninterface HentaiHavenOptions {\n\t/**\n\t * Custom base URL for the HentaiHaven website.\n\t */\n\tbaseUrl?: string;\n}\n\n/**\n * Sort order for episodes.\n */\nexport type HHEpisodesSort = \"ASC\" | \"DESC\";\n\n/**\n * Represents a genre in Hentai Haven.\n */\nexport interface HHGenre {\n\t/**\n\t * Unique identifier for the genre.\n\t */\n\tid: string;\n\t/**\n\t * URL to the genre page.\n\t */\n\turl: string;\n\t/**\n\t * Name of the genre.\n\t */\n\tname: string;\n}\n\n/**\n * Represents an episode of a hentai series.\n */\nexport interface HHHentaiEpisode {\n\t/**\n\t * Unique identifier for the episode.\n\t */\n\tid: string;\n\t/**\n\t * Title of the episode.\n\t */\n\ttitle: string;\n\t/**\n\t * URL to the episode thumbnail image.\n\t */\n\tthumbnail?: string;\n\t/**\n\t * Episode number.\n\t */\n\tnumber: number;\n\t/**\n\t * Release date in UTC.\n\t */\n\treleasedUTC: Date;\n\t/**\n\t * Relative time since release (e.g., \"2 days ago\").\n\t */\n\treleasedRelative: string;\n}\n\n/**\n * Detailed information about a hentai series.\n */\nexport interface HHHentaiInfo {\n\t/**\n\t * Unique identifier for the series.\n\t */\n\tid: string;\n\t/**\n\t * Title of the series.\n\t */\n\ttitle: string;\n\t/**\n\t * URL to the cover image.\n\t */\n\tcover: string;\n\t/**\n\t * Plot summary or description.\n\t */\n\tsummary: string;\n\t/**\n\t * Number of views.\n\t */\n\tviews: number;\n\t/**\n\t * Number of ratings received.\n\t */\n\tratingCount: number;\n\t/**\n\t * Year of release.\n\t */\n\treleased: number;\n\t/**\n\t * List of genres associated with the series.\n\t */\n\tgenres: HHGenre[];\n\t/**\n\t * Total number of episodes in the series.\n\t */\n\ttotalEpisodes: number;\n\t/**\n\t * List of episodes in the series.\n\t */\n\tepisodes: HHHentaiEpisode[];\n}\n\n/**\n * Represents a video source for streaming.\n */\nexport interface HHHentaiSource {\n\t/**\n\t * Label for the video quality or source.\n\t */\n\tlabel: string;\n\t/**\n\t * URL to the video source.\n\t */\n\tsrc: string;\n\t/**\n\t * MIME type of the video.\n\t */\n\ttype: string;\n}\n\n/**\n * Collection of video sources for a hentai episode.\n */\nexport interface HHHentaiSources {\n\t/**\n\t * List of available video sources.\n\t */\n\tsources: HHHentaiSource[];\n\t/**\n\t * URL to the video thumbnail.\n\t */\n\tthumbnail?: string;\n}\n\n/**\n * Represents a search result from Hentai Haven.\n */\nexport interface HHSearchResult {\n\t/**\n\t * Unique identifier for the series.\n\t */\n\tid: string;\n\t/**\n\t * Title of the series.\n\t */\n\ttitle: string;\n\t/**\n\t * URL to the cover image.\n\t */\n\tcover: string;\n\t/**\n\t * Average rating of the series.\n\t */\n\trating: number;\n\t/**\n\t * Year of release.\n\t */\n\treleased: number;\n\t/**\n\t * List of genres associated with the series.\n\t */\n\tgenres: HHGenre[];\n\t/**\n\t * Total number of episodes in the series.\n\t */\n\ttotalEpisodes: number;\n\t/**\n\t * Date information.\n\t */\n\tdate: {\n\t\t/**\n\t\t * Unparsed date string.\n\t\t */\n\t\tunparsed: string;\n\t\t/**\n\t\t * Parsed Date object.\n\t\t */\n\t\tparsed: Date;\n\t};\n\t/**\n\t * Alternative titles.\n\t */\n\talternative: string;\n\t/**\n\t * Author or creator of the series.\n\t */\n\tauthor: string;\n}\n","/**\n * Extracts the first number from a string.\n *\n * @param {string} str - The input string to extract a number from.\n * @returns {number | null} The first number found in the string, or null if no numbers are found.\n * @throws {TypeError} If the input is not a string.\n *\n * @example\n * // Returns 123\n * getNumberFromString(\"abc123def456\");\n *\n * @example\n * // Returns null\n * getNumberFromString(\"no numbers here\");\n */\nexport function getNumberFromString(str: string): number | null {\n\tif (typeof str !== \"string\") {\n\t\tthrow new TypeError(\"Input must be a string\");\n\t}\n\n\tif (str.trim() === \"\") {\n\t\treturn null;\n\t}\n\n\tconst numbers = str.match(/\\d+/g);\n\treturn numbers ? Number(numbers[0]) : null;\n}\n","/**\n * Applies the ROT13 cipher to a string, shifting each letter 13 positions in the alphabet.\n * Non-alphabetic characters remain unchanged.\n *\n * @param {string} str - The input string to be encoded or decoded with ROT13.\n * @returns {string} The ROT13 transformed string.\n * @throws {TypeError} If the input is not a string.\n *\n * @example\n * // Returns \"uryyb\"\n * rot13Cipher(\"hello\");\n *\n * @example\n * // Returns \"hello\"\n * rot13Cipher(\"uryyb\");\n *\n * @example\n * // Returns \"Uryyb, Jbeyq! 123\"\n * rot13Cipher(\"Hello, World! 123\");\n */\nexport const rot13Cipher = (str: string): string => {\n\tif (typeof str !== \"string\") {\n\t\tthrow new TypeError(\"Input must be a string\");\n\t}\n\n\tif (str.length === 0) {\n\t\treturn \"\";\n\t}\n\n\treturn str.replace(/[a-zA-Z]/g, (c) => {\n\t\tconst charCode = c.charCodeAt(0);\n\t\tconst isUpperCase = charCode >= 65 && charCode <= 90;\n\t\tconst shiftedCharCode = isUpperCase\n\t\t\t? ((charCode - 65 + 13) % 26) + 65\n\t\t\t: ((charCode - 97 + 13) % 26) + 97;\n\t\treturn String.fromCharCode(shiftedCharCode);\n\t});\n};\n","import { load } from \"cheerio\";\nimport { parseDate } from \"chrono-node\";\nimport { getNumberFromString } from \"../../utils/get-number-from-string\";\nimport { normalize } from \"../../utils/normalize\";\nimport { removeNumberFromString } from \"../../utils/remove-number-from-string\";\n\nexport const HENTAI_STREAM_BASE_URL = \"https://tube.hentaistream.com\";\n\n/**\n * HentaiStream class for interacting with the HentaiStream API\n */\nexport const HentaiStream = class {\n\tpublic BASE_URL = HENTAI_STREAM_BASE_URL;\n\n\t/**\n\t * Creates a new HentaiStream instance\n\t * @param options - Configuration options\n\t */\n\tconstructor(options?: HentaiStreamOptions) {\n\t\tthis.BASE_URL = options?.baseUrl || HENTAI_STREAM_BASE_URL;\n\t}\n\n\t/**\n\t * Search for anime on HentaiStream\n\t * @param query - The search query\n\t * @returns Promise resolving to an array of search results\n\t * @throws {TypeError} If query is invalid\n\t */\n\tpublic search = async (query: string): Promise<HStreamResult[]> => {\n\t\tif (!query || typeof query !== \"string\") {\n\t\t\tthrow new TypeError(\"Invalid Query\");\n\t\t}\n\n\t\tconst url = `${this.BASE_URL}/?s=${encodeURIComponent(query)}`;\n\n\t\tconst response = await fetch(url);\n\t\tconst data = await response.text();\n\n\t\tconst $ = load(data);\n\n\t\tconst results: HStreamResult[] = [];\n\n\t\t$(\".content .post\").each((_i, e) => {\n\t\t\tconst $e = $(e);\n\n\t\t\tconst id = ($e.find(\"div.postimg a\").attr(\"href\") || \"\").split(\"/\").pop() || \"\";\n\t\t\tconst title = $e.find(\"p.posttitle ins\").text().trim() || \"\";\n\t\t\tconst image = $e.find(\"div.postimg img\").attr(\"src\");\n\t\t\tconst views =\n\t\t\t\tNumber.parseInt($e.find(\".view\").text().trim().split(\" \")[0].replaceAll(\",\", \"\")) || 0;\n\t\t\tconst releaseDate = parseDate($e.find(\".dtcreated\").text().trim().split(\"Added: \")[1].trim());\n\n\t\t\tresults.push({\n\t\t\t\tid,\n\t\t\t\timage,\n\t\t\t\ttitle,\n\t\t\t\tviews,\n\t\t\t\treleaseDate,\n\t\t\t});\n\t\t});\n\n\t\treturn results;\n\t};\n\n\t/**\n\t * Get information about a specific episode\n\t * @param id - The episode ID\n\t * @returns Promise resolving to episode information\n\t * @throws {TypeError} If ID is invalid\n\t */\n\tpublic getInfoEpisode = async (id: string): Promise<HStreamEpisodeInfo> => {\n\t\tif (!id || typeof id !== \"string\") {\n\t\t\tthrow new TypeError(\"Invalid ID.\");\n\t\t}\n\n\t\tconst url = `${this.BASE_URL}/${id}`;\n\n\t\tconst response = await fetch(url);\n\t\tconst data = await response.text();\n\n\t\tconst $ = load(data);\n\n\t\tconst title = $(\".videotitle\").text().trim().replaceAll(\"¤\", \"\").trim();\n\t\tconst releasedDate = parseDate(\n\t\t\t$(\".threebox p:nth-child(1)\").text().trim().split(\"Added: \")[1].split(\" @\")[0].trim(),\n\t\t);\n\t\tconst views = Number.parseInt(\n\t\t\t$(\".threebox p:nth-child(2)\").text().trim().split(\"Views: \")[1].replaceAll(\",\", \"\"),\n\t\t);\n\t\tconst genres = $('div.videotags:contains(\"Genre(s)\") a')\n\t\t\t.map((_i, e) => $(e).text().trim())\n\t\t\t.get();\n\n\t\treturn {\n\t\t\ttitle,\n\t\t\treleasedDate,\n\t\t\tviews,\n\t\t\tgenres,\n\t\t};\n\t};\n\n\t/**\n\t * Get detailed information about an anime series\n\t * @param id - The anime ID or title\n\t * @returns Promise resolving to anime information or null if not found\n\t * @throws {TypeError} If ID is invalid\n\t */\n\tpublic getInfo = async (id: string): Promise<HStreamAnimeInfo | null> => {\n\t\tif (!id || typeof id !== \"string\") {\n\t\t\tthrow new TypeError(\"Invalid ID.\");\n\t\t}\n\n\t\tconst normalizedId = normalize(id);\n\n\t\tconst searchData = await this.search(normalizedId);\n\n\t\tconst matchingResults = searchData.filter((result) => {\n\t\t\tconst normalizedTitle = normalize(result.title || \"\");\n\t\t\treturn normalizedTitle.includes(normalizedId);\n\t\t});\n\n\t\tif (matchingResults.length === 0) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst firstResult = matchingResults[0];\n\n\t\tconst episodes = await Promise.all(\n\t\t\tmatchingResults.map(async (result) => {\n\t\t\t\tconst episodeInfo = await this.getInfoEpisode(result.id);\n\t\t\t\tconst episodeNumber = getNumberFromString(result.title || \"\");\n\n\t\t\t\treturn {\n\t\t\t\t\t...result,\n\t\t\t\t\t...episodeInfo,\n\t\t\t\t\tepisodeNumber,\n\t\t\t\t};\n\t\t\t}),\n\t\t);\n\n\t\tepisodes.sort((a, b) => {\n\t\t\tconst aNum = a.episodeNumber || 0;\n\t\t\tconst bNum = b.episodeNumber || 0;\n\t\t\treturn aNum - bNum;\n\t\t});\n\n\t\tconst averageViews = Math.ceil(\n\t\t\tepisodes.reduce((sum, episode) => sum + (episode.views || 0), 0) / episodes.length,\n\t\t);\n\t\tconst genres = new Set(episodes.flatMap((episode) => episode.genres || []));\n\n\t\treturn {\n\t\t\ttitle: (normalizedId.charAt(0).toUpperCase() + normalizedId.slice(1)).trim(),\n\t\t\timage: firstResult.image,\n\t\t\tgenres: [...genres.values()],\n\t\t\tviews: averageViews,\n\t\t\tepisodes: episodes.map((ep) => ({\n\t\t\t\tid: btoa(ep.id),\n\t\t\t\tnumber: ep.episodeNumber,\n\t\t\t\tviews: ep.views,\n\t\t\t\treleasedDate: ep.releaseDate,\n\t\t\t\ttitle: ep.title,\n\t\t\t\timage: ep.image,\n\t\t\t})),\n\t\t\treleasedDate: episodes[0].releaseDate,\n\t\t};\n\t};\n\n\t/**\n\t * Get streaming information for a specific episode\n\t * @param id - The encoded episode ID\n\t * @returns Promise resolving to episode streaming information\n\t * @throws {TypeError} If ID is invalid\n\t * @throws {Error} If streams cannot be fetched\n\t */\n\tpublic getEpisode = async (id: string): Promise<HStreamEpisodeStream> => {\n\t\tif (!id || typeof id !== \"string\") {\n\t\t\tthrow new TypeError(\"Invalid ID.\");\n\t\t}\n\n\t\tconst url = `${this.BASE_URL}/${atob(id)}`;\n\n\t\tconst response = await fetch(url);\n\t\tconst data = await response.text();\n\n\t\tconst $ = load(data);\n\n\t\tconst frameUrl = $(\"iframe\").attr(\"src\");\n\n\t\tconst title = $(\".videotitle\").text().trim().replaceAll(\"¤\", \"\").trim();\n\t\tconst releasedDate = parseDate(\n\t\t\t$(\".threebox p:nth-child(1)\").text().trim().split(\"Added: \")[1].split(\" @\")[0].trim(),\n\t\t);\n\t\tconst views = Number.parseInt(\n\t\t\t$(\".threebox p:nth-child(2)\").text().trim().split(\"Views: \")[1].replaceAll(\",\", \"\"),\n\t\t);\n\n\t\tif (!frameUrl) {\n\t\t\tthrow new Error(\"Failed to fetch streams\");\n\t\t}\n\n\t\tconst res = await fetch(frameUrl);\n\t\tconst frameData = await res.text();\n\n\t\tconst $$ = load(frameData);\n\n\t\tconst $video = $$(\"video\");\n\n\t\tconst source = $video.find(\"source\").attr(\"src\");\n\n\t\treturn {\n\t\t\ttitle,\n\t\t\treleasedDate,\n\t\t\tviews,\n\t\t\tsource,\n\t\t};\n\t};\n};\n\n/**\n * Configuration options for HentaiStream\n */\nexport interface HentaiStreamOptions {\n\tbaseUrl?: string;\n}\n\n/**\n * Search result from HentaiStream\n */\nexport interface HStreamResult {\n\tid: string;\n\ttitle?: string;\n\tviews?: number;\n\timage?: string;\n\treleaseDate?: Date | null;\n}\n\n/**\n * Episode information\n */\nexport interface HStreamEpisodeInfo {\n\ttitle?: string;\n\treleasedDate?: Date | null;\n\tviews?: number;\n\tgenres?: string[];\n}\n\n/**\n * Anime series information\n */\nexport interface HStreamAnimeInfo {\n\ttitle: string;\n\timage?: string;\n\tgenres: string[];\n\tviews: number;\n\tepisodes: HStreamEpisodeListItem[];\n\treleasedDate?: Date | null;\n}\n\n/**\n * Episode list item\n */\nexport interface HStreamEpisodeListItem {\n\tid: string;\n\tnumber?: number | null;\n\tviews?: number;\n\treleasedDate?: Date | null;\n\ttitle?: string;\n\timage?: string;\n}\n\n/**\n * Episode streaming information\n */\nexport interface HStreamEpisodeStream {\n\ttitle?: string;\n\treleasedDate?: Date | null;\n\tviews?: number;\n\tsource?: string;\n}\n","/**\n * Normalizes a string by removing all symbols, special characters, and numbers,\n * then converts it to lowercase.\n *\n * @param {string} str - The input string to normalize.\n * @returns {string} The normalized string.\n * @throws {TypeError} If the input is not a string.\n *\n * @example\n * // Returns \"hello world\"\n * normalize(\"Hello World! 123\");\n *\n * @example\n * // Returns \"just text\"\n * normalize(\"Just TEXT! @#$%^&*()\");\n */\nexport function normalize(str: string): string {\n\tif (typeof str !== \"string\") {\n\t\tthrow new TypeError(\"Input must be a string\");\n\t}\n\n\tif (str.trim() === \"\") {\n\t\treturn str;\n\t}\n\n\t// Remove all symbols, special characters, and numbers\n\tconst normalized = str.replace(/[^a-zA-Z\\s]/g, \" \");\n\n\t// Convert to lowercase and remove double or more spaces\n\treturn normalized\n\t\t.toLowerCase()\n\t\t.replace(/\\s{2,}/g, \" \")\n\t\t.replaceAll(\"episode\", \"\");\n}\n","/**\n * Removes all numbers from a string.\n *\n * @param {string} str - The input string to remove numbers from.\n * @returns {string} The string with all numbers removed.\n * @throws {TypeError} If the input is not a string.\n *\n * @example\n * // Returns \"abcdef\"\n * removeNumberFromString(\"abc123def456\");\n *\n * @example\n * // Returns \"no numbers here\"\n * removeNumberFromString(\"no numbers here\");\n */\nexport function removeNumberFromString(str: string): string {\n\tif (typeof str !== \"string\") {\n\t\tthrow new TypeError(\"Input must be a string\");\n\t}\n\n\tif (str.trim() === \"\") {\n\t\treturn str;\n\t}\n\n\treturn str.replace(/\\d+/g, \"\");\n}\n","import { load } from \"cheerio\";\nimport type { PaginatedResult } from \"../../types\";\nimport { Dimension } from \"../../utils/Dimension\";\n\n/**\n * Base URL for the Rule34 website.\n */\nexport const RULE34_BASE_URL = \"https://rule34.xxx\";\n\n/**\n * API URL for Rule34 autocomplete functionality.\n */\nexport const RULE34_API_URL = \"https://ac.rule34.xxx\";\n\n/**\n * Class representing a Rule34 client for interacting with the Rule34 website.\n */\nexport const Rule34 = class {\n\t/**\n\t * Base URL for the Rule34 website.\n\t */\n\tpublic BASE_URL = RULE34_BASE_URL;\n\n\t/**\n\t * API URL for Rule34 autocomplete functionality.\n\t */\n\tpublic API_URL = RULE34_API_URL;\n\n\t/**\n\t * Creates a new instance of the Rule34 client.\n\t *\n\t * @param {R34Options} [options] - Configuration options for the Rule34 client.\n\t * @param {string} [options.baseUrl] - Custom base URL for the Rule34 website.\n\t * @param {string} [options.apiUrl] - Custom API URL for Rule34 autocomplete functionality.\n\t */\n\tconstructor(options?: R34Options) {\n\t\tthis.BASE_URL = options?.baseUrl || RULE34_BASE_URL;\n\t\tthis.API_URL = options?.apiUrl || RULE34_API_URL;\n\t}\n\n\t/**\n\t * Searches for autocomplete suggestions based on the provided query.\n\t *\n\t * @param {string} query - The search query string.\n\t * @returns {Promise<Array<{completedQuery: string, label: string, type: string}>>} A promise that resolves to an array of autocomplete suggestions.\n\t * @throws {TypeError} If the query is empty or not a string.\n\t */\n\tsearchAutocomplete = async (query: string) => {\n\t\tif (!query || typeof query !== \"string\") {\n\t\t\tthrow new TypeError(\"Query invalid\");\n\t\t}\n\n\t\tconst url = `${this.API_URL}/autocomplete.php?q=${query}`;\n\n\t\tconst response = await fetch(url);\n\t\tconst data = (await response.json()) as { label: string; value: string; type: string }[];\n\n\t\treturn data.map((item) => ({\n\t\t\tcompletedQuery: item.value,\n\t\t\tlabel: item.label,\n\t\t\ttype: item.type,\n\t\t}));\n\t};\n\n\t/**\n\t * Searches for images on Rule34 based on the provided query.\n\t *\n\t * @param {string} query - The search query string.\n\t * @param {number} [page=1] - The page number to retrieve (default is 1).\n\t * @param {number} [perPage=10] - The number of results per page (default is 10).\n\t * @returns {Promise<R34SearchResult>} A promise that resolves to a paginated result of search results.\n\t * @throws {TypeError} If the query is empty or not a string.\n\t */\n\tsearch = async (query: string, page = 1, perPage = 10) => {\n\t\tif (!query || typeof query !== \"string\") {\n\t\t\tthrow new TypeError(\"Query invalid\");\n\t\t}\n\n\t\tconst url = `${this.BASE_URL}/index.php?page=post&s=list&tags=${query}&pid=${(page - 1) * perPage}`;\n\n\t\tconst response = await fetch(url);\n\t\tconst data = await response.text();\n\n\t\tconst $ = load(data);\n\n\t\tconst results: R3SearchResult[] = [];\n\n\t\t$(\".image-list span\").each((_i, e) => {\n\t\t\tconst $e = $(e);\n\n\t\t\tconst id = $e.attr(\"id\")?.replace(\"s\", \"\") || \"\";\n\t\t\tconst image = $e.find(\"img\").attr(\"src\") || \"\";\n\t\t\tconst tags =\n\t\t\t\t$e\n\t\t\t\t\t.find(\"img\")\n\t\t\t\t\t.attr(\"alt\")\n\t\t\t\t\t?.trim()\n\t\t\t\t\t?.split(\" \")\n\t\t\t\t\t.filter((tag) => tag !== \"\") || [];\n\n\t\t\tresults.push({\n\t\t\t\tid: id,\n\t\t\t\timage: image,\n\t\t\t\ttags: tags,\n\t\t\t\ttype: \"preview\",\n\t\t\t});\n\t\t});\n\n\t\tconst pagination = $(\"#paginator .pagination\");\n\t\tconst totalPages =\n\t\t\tNumber.parseInt(pagination.find(\"a:last\").attr(\"href\")?.split(\"pid=\")[1] || \"1\", 10) /\n\t\t\t\tperPage +\n\t\t\t1;\n\t\tconst currentPage = page;\n\t\tconst nextPage = currentPage < totalPages ? currentPage + 1 : null;\n\t\tconst previousPage = currentPage > 1 ? currentPage - 1 : null;\n\t\tconst hasNextPage = nextPage !== null;\n\t\tconst next = nextPage !== null ? nextPage * perPage : 0;\n\t\tconst previous = previousPage !== null ? previousPage * perPage : 0;\n\n\t\treturn {\n\t\t\ttotal: totalPages * perPage,\n\t\t\tnext: next,\n\t\t\tprevious: previous,\n\t\t\tpages: totalPages,\n\t\t\tpage: currentPage,\n\t\t\thasNextPage,\n\t\t\tresults,\n\t\t} as R34SearchResult;\n\t};\n\n\t/**\n\t * Gets detailed information about a specific image by its ID.\n\t *\n\t * @param {string} id - The ID of the image to retrieve information for.\n\t * @returns {Promise<R34ImageInfo>} A promise that resolves to an object containing detailed information about the image.\n\t */\n\tpublic getInfo = async (id: string): Promise<R34ImageInfo> => {\n\t\tconst url = `${this.BASE_URL}/index.php?page=post&s=view&id=${id}`;\n\n\t\tconst resizeCookies = {\n\t\t\t\"resize-notification\": 1,\n\t\t\t\"resize-original\": 1,\n\t\t};\n\n\t\tconst [resizedResponse, nonResizedResponse] = await Promise.all([\n\t\t\tfetch(url),\n\t\t\tfetch(url, {\n\t\t\t\theaders: {\n\t\t\t\t\tcookie: Object.entries(resizeCookies)\n\t\t\t\t\t\t.map(([key, value]) => `${key}=${value}`)\n\t\t\t\t\t\t.join(\"; \"),\n\t\t\t\t},\n\t\t\t}),\n\t\t]);\n\n\t\tconst [resized, original] = await Promise.all([\n\t\t\tresizedResponse.text(),\n\t\t\tnonResizedResponse.text(),\n\t\t]);\n\n\t\tconst $resized = load(resized);\n\n\t\tconst resizedImageUrl = $resized(\"#image\").attr(\"src\");\n\n\t\tconst $ = load(original);\n\t\tconst fullImage = $(\"#image\").attr(\"src\");\n\t\tconst tags = $(\"#image\")\n\t\t\t.attr(\"alt\")\n\t\t\t?.trim()\n\t\t\t?.split(\" \")\n\t\t\t.filter((tag) => tag !== \"\");\n\n\t\tconst stats = $(\"#stats ul\");\n\n\t\tconst postedData = stats.find(\"li:nth-child(2)\").text().trim();\n\t\tconst createdAt = new Date(postedData.split(\"Posted: \")[1].split(\"by\")[0]).getTime();\n\t\tconst publishedBy = postedData.split(\"by\")[1].trim();\n\t\tcons