/** * StrawVerse Extension - AniNeko Scraper * Copyright (C) 2026 TheYogMehta * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * DISCLAIMER: This extension is intended for research, educational, * and developer testing purposes only. It functions as a client-side parser * of publicly available web pages. The developers do not host or distribute * any copyrighted media. Users are responsible for compliance with the terms of * service of the target website. */ const cheerio = require("cheerio"); const baseUrl = "https://anineko.to"; function parsePagination($, defaultPage) { let totalPages = 1; $(".pagination a.page-link").each((i, el) => { const href = $(el).attr("href"); if (href) { const match = href.match(/page=(\d+)/); if (match) { const pageNum = parseInt(match[1]); if (pageNum > totalPages) { totalPages = pageNum; } } } }); let hasNextPage = false; $(".pagination li.next, .pagination li.page-item.next").each((i, el) => { hasNextPage = true; }); const activeText = $(".pagination li.active a.page-link").text().trim(); const currentPage = parseInt(activeText) || defaultPage || 1; return { currentPage, hasNextPage, totalPages, }; } // Anime Search async function SearchAnime(query, filters = {}) { try { const page = filters?.page || 1; const { data: html } = await global.axios.get( `${baseUrl}/browser?keyword=${encodeURIComponent(query)}&page=${page}`, ); const $ = cheerio.load(html); const results = []; $("article.nv-anime-card").each((i, el) => { const titleEl = $(el).find("h3.nv-anime-title a"); const title = titleEl.text().trim(); let href = titleEl.attr("href") || $(el).find("a").first().attr("href"); if (!href) return; const match = href.match(/\/watch\/([^\/]+)/); if (!match) return; const id = match[1]; const image = $(el).find(".nv-anime-thumb img").attr("src") || $(el).find("img").attr("src") || null; results.push({ id, title, image, }); }); const pagination = parsePagination($, page); return { currentPage: pagination.currentPage, hasNextPage: pagination.hasNextPage, totalPages: pagination.totalPages, results, }; } catch (err) { throw new Error(err.message); } } // Recent Episodes async function fetchRecentEpisodes(filters = {}) { try { const page = filters?.page || 1; const { data: html } = await global.axios.get( `${baseUrl}/updates?page=${page}`, ); const $ = cheerio.load(html); const results = []; $("article.nv-anime-card").each((i, el) => { const titleEl = $(el).find("h3.nv-anime-title a"); const title = titleEl.text().trim(); let href = titleEl.attr("href") || $(el).find("a").first().attr("href"); if (!href) return; const match = href.match(/\/watch\/([^\/]+)/); if (!match) return; const id = match[1]; const image = $(el).find(".nv-anime-thumb img").attr("src") || $(el).find("img").attr("src") || null; results.push({ id, title, image, }); }); const pagination = parsePagination($, page); return { currentPage: pagination.currentPage, hasNextPage: pagination.hasNextPage, totalPages: pagination.totalPages, results, }; } catch (err) { throw new Error(err.message); } } // Anime Info async function AnimeInfo(id) { const animeInfo = { id: id, title: "", }; try { const { data: html } = await global.axios.get(`${baseUrl}/watch/${id}`); const $ = cheerio.load(html); animeInfo.title = $("h1").first().text().trim() || id; animeInfo.image = $(".nv-info-poster img").attr("src") || null; animeInfo.description = $(".nv-info-desc").text().trim() || $(".nv-info-synopsis").text().trim() || ""; const genres = []; $(".nv-info-tags span").each((i, el) => { const text = $(el).text().trim(); if ( text && !["SUB", "DUB", "Hardsub", "HD", "HSUB"].includes(text) && !text.match(/^\d{4}$/) && ![ "Currently Airing", "Finished Airing", "Not yet aired", "TV", "Movie", "OVA", "ONA", "Special", "Music", "TV_SHORT", ].includes(text) ) { genres.push(text); } }); animeInfo.genres = genres; animeInfo.status = "Unknown"; const statusText = $(".nv-info-tags span, .nv-pill").text(); if (statusText.includes("Currently Airing")) { animeInfo.status = "Ongoing"; } else if (statusText.includes("Finished Airing")) { animeInfo.status = "Completed"; } const typeEl = $(".nv-info-stats div").first().find("strong").text().trim(); animeInfo.type = typeEl || "TV"; animeInfo.dataId = id; return animeInfo; } catch (error) { console.error("Error fetching data from AniNeko:", error); throw error; } } // Fetch Episodes async function fetchEpisode(id, page = 1) { try { const { data: html } = await global.axios.get(`${baseUrl}/watch/${id}`); const $ = cheerio.load(html); let episodes = []; $("article.nv-info-episode-item").each((i, el) => { const mainLink = $(el).find("a.nv-info-episode-main"); const href = mainLink.attr("href") || ""; const epMatch = href.match(/\/ep-(\d+)/); const epNum = epMatch ? parseInt(epMatch[1]) : i + 1; const titleStrong = mainLink.find("strong").text().trim(); const titleSpan = mainLink.find("span").text().trim(); const title = titleSpan || titleStrong || `Episode ${epNum}`; const badges = $(el) .find(".nv-info-episode-badges span") .map((j, badge) => $(badge).text().trim().toUpperCase()) .get(); const langs = []; if (badges.includes("SUB")) langs.push("sub"); if (badges.includes("HSUB") || badges.includes("HARDSUB")) langs.push("hsub"); if (badges.includes("DUB")) langs.push("dub"); if ( badges.includes("SOFTSUB") || badges.includes("SOFT SUB") || badges.includes("SOFT-SUB") ) langs.push("sub"); if ( badges.includes("SOFTDUB") || badges.includes("SOFT DUB") || badges.includes("SOFT-DUB") ) langs.push("dub"); const watchPathMatch = href.match(/\/watch\/(.+)$/); const epSlug = watchPathMatch ? watchPathMatch[1] : `${id}/ep-${epNum}`; episodes.push({ id: epSlug, number: epNum, title, duration: "Unknown", langs, }); }); return { episodes, totalPages: 1, total: episodes.length, currentPage: 1, }; } catch (err) { return { episodes: [], totalPages: 0, total: 0, currentPage: page }; } } // Fetch Episode Sources async function fetchEpisodeSources(episodeId, category = null) { try { const { data: html } = await global.axios.get( `${baseUrl}/watch/${episodeId}`, ); const $ = cheerio.load(html); const servers = []; $(".lang-group, .server-group, [data-lang]").each((i, panel) => { let rawType = ( $(panel).attr("data-lang") || $(panel).attr("data-type") || $(panel).attr("data-id") || "" ).toLowerCase(); let panelType = ""; if ( rawType.includes("hsub") || rawType.includes("hardsub") || rawType.includes("hard-sub") ) { panelType = "hsub"; } else if (rawType.includes("dub")) { panelType = "dub"; } else if (rawType.includes("sub")) { panelType = "sub"; } $(panel) .find("button.server-video, a.server-video, .server-item") .each((j, btn) => { const videoUrl = $(btn).attr("data-video") || $(btn).attr("data-url") || $(btn).attr("href"); if ( !videoUrl || videoUrl === "#" || videoUrl.startsWith("javascript:") ) return; const cloned = $(btn).clone(); cloned.find("span").remove(); const serverName = cloned.text().trim() || "Server"; let btnRaw = ( $(btn).attr("data-lang") || $(btn).attr("data-type") || "" ).toLowerCase(); let btnType = panelType; if (btnRaw.includes("hsub") || btnRaw.includes("hardsub")) { btnType = "hsub"; } else if (btnRaw.includes("dub")) { btnType = "dub"; } else if (btnRaw.includes("sub") && !btnRaw.includes("hsub")) { btnType = "sub"; } servers.push({ url: videoUrl, name: serverName, type: btnType || "sub", isDefault: $(btn).hasClass("default") || $(btn).hasClass("active"), }); }); }); let targetServers = servers; if (category) { const catLower = category.toLowerCase(); targetServers = servers.filter((s) => { const typeLower = (s.type || "").toLowerCase(); if (catLower === "hsub" || catLower === "hardsub") { return typeLower === "hsub" || typeLower === "hardsub"; } else if (catLower === "sub" || catLower === "softsub") { return typeLower === "sub" || typeLower === "softsub"; } else if (catLower === "dub") { return typeLower === "dub"; } return typeLower === catLower; }); } const requestedCategory = (category || "sub").toLowerCase(); if (targetServers.length === 0) { return { sources: [], subtitles: [] }; } const sources = targetServers.map((s) => ({ quality: s.name, name: s.name, url: s.url, lang: s.type || requestedCategory, type: s.type || requestedCategory, isUnresolved: true, rawServer: s, })); return { sources, subtitles: [], }; } catch (err) { console.error("Error fetching data from AniNeko:", err); return { sources: [], subtitles: [] }; } } // Process an embed server URL to extract the actual video source async function processEmbedServer(server) { try { const embedUrl = server.url; let typeLabel = server.type ? server.type.toUpperCase() : ""; if (server.type === "sub") typeLabel = "Sub"; if (server.type === "hsub") typeLabel = "HSub"; if (server.type === "dub") typeLabel = "Dub"; const qualityLabel = `${server.name} ${typeLabel}`; let subtitles = []; try { const urlObj = new URL(embedUrl); const subParam = urlObj.searchParams.get("sub"); const captionParam = urlObj.searchParams.get("caption_1"); const c1FileParam = urlObj.searchParams.get("c1_file"); let subUrl = subParam || captionParam || c1FileParam; if (subUrl) { if (subUrl.startsWith("//")) { subUrl = "https:" + subUrl; } else if ( !subUrl.startsWith("http://") && !subUrl.startsWith("https://") ) { try { subUrl = new URL(subUrl, embedUrl).href; } catch (e) {} } subtitles.push({ url: subUrl, lang: "English", }); } } catch (e) {} const { data: embedHtml } = await global.axios.get(embedUrl, { headers: { Referer: `${baseUrl}/`, }, }); try { const tracksRegex = /tracks\s*[:=]\s*(\[[^\]]+\])/i; const tracksMatch = embedHtml.match(tracksRegex); if (tracksMatch) { const arrayStr = tracksMatch[1]; const objRegex = /\{([^}]+)\}/g; let objMatch; while ((objMatch = objRegex.exec(arrayStr)) !== null) { const objContent = objMatch[1]; const fileM = objContent.match( /['"]?file['"]?\s*:\s*['"]([^'"]+)['"]/, ); if (fileM) { let sUrl = fileM[1]; const labelM = objContent.match( /['"]?label['"]?\s*:\s*['"]([^'"]+)['"]/, ); const kindM = objContent.match( /['"]?kind['"]?\s*:\s*['"]([^'"]+)['"]/, ); const langM = objContent.match( /['"]?(?:language|lang)['"]?\s*:\s*['"]([^'"]+)['"]/, ); const kind = kindM ? kindM[1].toLowerCase() : ""; if (!kind || kind !== "thumbnails") { if (sUrl.startsWith("//")) { sUrl = "https:" + sUrl; } else if ( !sUrl.startsWith("http://") && !sUrl.startsWith("https://") ) { try { sUrl = new URL(sUrl, embedUrl).href; } catch (e) {} } subtitles.push({ url: sUrl, lang: labelM ? labelM[1] : langM ? langM[1] : "English", }); } } } } if (subtitles.length === 0) { const subFileRegex = /["']?file["']?\s*:\s*["']([^"']+\.(?:vtt|srt)[^"']*)["']/gi; let subMatch; while ((subMatch = subFileRegex.exec(embedHtml)) !== null) { let sUrl = subMatch[1]; if (sUrl.startsWith("//")) { sUrl = "https:" + sUrl; } else if ( !sUrl.startsWith("http://") && !sUrl.startsWith("https://") ) { try { sUrl = new URL(sUrl, embedUrl).href; } catch (e) {} } subtitles.push({ url: sUrl, lang: "English" }); } } } catch (e) {} let m3u8Match = embedHtml.match(/https?:\/\/[^\s"']+\.m3u8[^\s"']*/); if (!m3u8Match) { const srcMatch = embedHtml.match( /src\s*=\s*["']([^"']*\.m3u8[^"']*)["']/, ); if (srcMatch) { m3u8Match = [srcMatch[1]]; } } if (!m3u8Match) { const evalMatch = /(eval)(\(f.*?)(<\/script>)/s.exec(embedHtml); if (evalMatch) { try { const unpacked = eval(evalMatch[2].replace("eval", "")); m3u8Match = unpacked.match(/https?:\/\/[^\s"']+\.m3u8[^\s"']*/); } catch (e) {} } } if (!m3u8Match) { const fileMatch = embedHtml.match( /["']?file["']?\s*:\s*["']([^"']+\.m3u8[^"']*)["']/, ); if (fileMatch) { m3u8Match = [fileMatch[1]]; } } if (!m3u8Match) { const sourcesMatch = embedHtml.match( /sources\s*[:=]\s*\[\s*\{[^}]*["']?file["']?\s*:\s*["']([^"']+)["']/, ); if (sourcesMatch) { m3u8Match = [sourcesMatch[1]]; } } if (m3u8Match) { let m3u8Url = m3u8Match[0].replace(/["'\\]/g, ""); if (m3u8Url.startsWith("//")) { m3u8Url = "https:" + m3u8Url; } else if ( !m3u8Url.startsWith("http://") && !m3u8Url.startsWith("https://") ) { try { m3u8Url = new URL(m3u8Url, embedUrl).href; } catch (e) {} } try { const cdnDomain = new URL(m3u8Url).hostname; const embedDomain = new URL(embedUrl).origin + "/"; if (global.setDynamicReferer) { global.setDynamicReferer(cdnDomain, embedDomain); global.setFallbackReferer(embedDomain); } for (const sub of subtitles) { try { const subDomain = new URL(sub.url).hostname; if (subDomain !== cdnDomain && global.setDynamicReferer) { global.setDynamicReferer(subDomain, embedDomain); } } catch (_) {} } } catch (e) {} return { url: m3u8Url, isM3U8: true, quality: qualityLabel, isDub: server.type === "dub", isHsub: server.type === "hsub", type: server.type, headers: { Referer: new URL(embedUrl).origin + "/" }, subtitles: subtitles.length > 0 ? subtitles : undefined, }; } const mp4Match = embedHtml.match( /["']?file["']?\s*:\s*["']([^"']+\.mp4[^"']*)["']/, ); if (mp4Match) { let mp4Url = mp4Match[1]; if (mp4Url.startsWith("//")) { mp4Url = "https:" + mp4Url; } else if ( !mp4Url.startsWith("http://") && !mp4Url.startsWith("https://") ) { try { mp4Url = new URL(mp4Url, embedUrl).href; } catch (e) {} } try { const cdnDomain = new URL(mp4Url).hostname; const embedDomain = new URL(embedUrl).origin + "/"; if (global.setDynamicReferer) { global.setDynamicReferer(cdnDomain, embedDomain); global.setFallbackReferer(embedDomain); } for (const sub of subtitles) { try { const subDomain = new URL(sub.url).hostname; if (subDomain !== cdnDomain && global.setDynamicReferer) { global.setDynamicReferer(subDomain, embedDomain); } } catch (_) {} } } catch (e) {} return { url: mp4Url, isM3U8: false, quality: qualityLabel, isDub: server.type === "dub", isHsub: server.type === "hsub", type: server.type, headers: { Referer: new URL(embedUrl).origin + "/" }, subtitles: subtitles.length > 0 ? subtitles : undefined, }; } return null; } catch (err) { console.error(`Failed to process embed ${server.url}:`, err.message); return null; } } module.exports = { name: "anineko", version: "3.0.2", SearchAnime, AnimeInfo, fetchEpisodeSources, processServer: processEmbedServer, fetchRecentEpisodes, fetchEpisode, };