/** * StrawVerse Extension - Anikoto 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 crypto = require("crypto"); const cheerio = require("cheerio"); const baseUrl = "https://anikototv.to"; const megaPlayEncKey = "i?LMTAx0Q6,:}50U"; const megaPlayEncIv = "W0;27ToaUpl_P%'c"; function decodeMegaPlayEnc(value) { if (!value || typeof value !== "string") return null; try { const key = Buffer.alloc(32); Buffer.from(megaPlayEncKey).copy(key); const input = Buffer.from( value.replace(/-/g, "+").replace(/_/g, "/"), "base64", ); const decipher = crypto.createDecipheriv( "aes-256-cbc", key, Buffer.from(megaPlayEncIv), ); const decoded = Buffer.concat([ decipher.update(input), decipher.final(), ]).toString("utf8"); return JSON.parse(decoded); } catch (e) { return null; } } function parsePagination($, defaultPage) { let totalPages = 1; $(".pagination a").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 a").each((i, el) => { const rel = $(el).attr("rel"); if (rel === "next") { hasNextPage = true; } }); const activePageText = $( ".pagination li.active, .pagination li.page-item.active", ) .text() .trim(); const currentPage = parseInt(activePageText) || defaultPage || 1; return { currentPage, hasNextPage, totalPages, }; } async function SearchAnime(query, filters = {}) { try { const page = filters?.page || 1; const { data: html } = await global.axios.get( `${baseUrl}/search?keyword=${encodeURIComponent(query)}&page=${page}`, ); const $ = cheerio.load(html); const results = []; $("div.item").each((i, el) => { const aTag = $(el).find(".name.d-title"); const title = aTag.text().trim() || aTag.attr("data-jp") || aTag.attr("title") || $(el).find(".name.d-title").text().trim() || $(el).find(".title").text().trim(); let href = aTag.attr("href"); if (!href) return; const match = href.match(/\/watch\/([^\/]+)/); if (!match) return; const id = match[1]; const image = $(el).find(".ani.poster img").attr("src") || $(el).find("img").attr("src"); results.push({ id: id, title: title, image: image || null, }); }); const pagination = parsePagination($, page); return { currentPage: pagination.currentPage, hasNextPage: pagination.hasNextPage, totalPages: pagination.totalPages, results: results, }; } catch (err) { throw new Error(err.message); } } async function fetchRecentEpisodes(filters = {}) { try { const page = filters?.page || 1; const { data: html } = await global.axios.get( `${baseUrl}/latest-updated?page=${page}`, ); const $ = cheerio.load(html); const results = []; $(".item").each((i, el) => { const aTag = $(el).find(".name.d-title").length ? $(el).find(".name.d-title").first() : $(el).find("a").last(); const imgTag = $(el).find("img"); const title = $(el).find(".name.d-title").text().trim() || $(el).find(".title").text().trim() || imgTag.attr("title") || imgTag.attr("alt") || aTag.attr("title") || aTag.attr("data-jp") || aTag .text() .trim() .replace(/TV\s*Sub\s*Dub/i, "") .trim(); let href = $(el).find("a").first().attr("href") || aTag.attr("href"); if (!href) return; const match = href.match(/\/watch\/([^\/]+)/); if (!match) return; const id = match[1]; const image = $(el).find(".ani.poster img").attr("src") || $(el).find("img").attr("src"); results.push({ id: id, title: title, image: image || null, }); }); const pagination = parsePagination($, page); return { currentPage: pagination.currentPage, hasNextPage: pagination.hasNextPage, totalPages: pagination.totalPages, results: results, }; } catch (err) { throw new Error(err.message); } } async function AnimeInfo(id) { const animeInfo = { id: id, title: "", }; try { const { data: html } = await global.axios.get(`${baseUrl}/watch/${id}`); const $ = cheerio.load(html); const dataId = $("#watch-main").attr("data-id"); animeInfo.title = $('h1[itemprop="name"]').text().trim() || $(".title").first().text().trim() || id; animeInfo.image = $('img[itemprop="image"]').attr("src") || $(".ani.poster img").attr("src") || null; animeInfo.description = $(".synopsis").text().trim() || $(".description").text().trim() || ""; const genres = []; $(".genre a").each((i, el) => { genres.push($(el).text().trim()); }); animeInfo.genres = genres; animeInfo.status = "Unknown"; $(".info .item").each((i, el) => { const text = $(el).text(); if (text.includes("Status:")) { const status = $(el).find(".name").text().trim(); if (status.includes("Currently Airing")) animeInfo.status = "Ongoing"; else if (status.includes("Finished Airing")) animeInfo.status = "Completed"; } }); animeInfo.dataId = dataId; return animeInfo; } catch (error) { console.error("Error fetching data from AnikotoTV:", error); throw error; } } async function fetchServerTypes(dataIds) { if (!dataIds) return new Set(); try { const serverUrl = `${baseUrl}/ajax/server/list?servers=${dataIds}`; const serverRes = await global.axios.get(serverUrl, { headers: { "X-Requested-With": "XMLHttpRequest", }, timeout: 7000, }); const $ = cheerio.load(serverRes.data?.result || ""); const types = new Set(); const serverLinkSelector = "[data-link-id], li[data-id], a[data-id], button[data-id], .server-item[data-id], .item[data-id]"; $(".servers .type[data-type], [data-type]").each((i, el) => { const type = String($(el).attr("data-type") || "") .trim() .toLowerCase(); const hasOwnServerLink = Boolean($(el).attr("data-link-id")) || ($(el).is("li,a,button,.server-item,.item") && Boolean($(el).attr("data-id"))); const hasNestedServerLink = $(el).find(serverLinkSelector).length > 0; if (type && (hasOwnServerLink || hasNestedServerLink)) types.add(type); }); return types; } catch (e) { return new Set(); } } async function mapWithConcurrency(items, limit, mapper) { const results = new Array(items.length); let nextIndex = 0; const workerCount = Math.min(Math.max(Number(limit) || 1, 1), items.length); await Promise.all( Array.from({ length: workerCount }, async () => { while (nextIndex < items.length) { const index = nextIndex; nextIndex += 1; results[index] = await mapper(items[index], index); } }), ); return results; } async function fetchEpisode(dataId, page = 1) { try { let numericId = dataId; if (!/^\d+$/.test(String(dataId))) { try { const { data: watchHtml } = await global.axios.get( `${baseUrl}/watch/${dataId}`, ); const $w = cheerio.load(watchHtml); numericId = $w("#watch-main").attr("data-id") || $w("[data-id]").attr("data-id") || $w("#wrapper").attr("data-id"); } catch (e) {} } if (!numericId) { return { episodes: [], totalPages: 0, total: 0, currentPage: page }; } const url = `${baseUrl}/ajax/episode/list/${numericId}`; const { data } = await global.axios.get(url, { headers: { "X-Requested-With": "XMLHttpRequest", }, }); const $ = cheerio.load(data.result || ""); let episodes = []; const hasDubFilter = $('.filter.type [data-value="dub"], [data-value="dub"]').length > 0; $("a[data-id][data-ids], .ep-item, li a, .ssl-item, a.item").each( (i, el) => { const epNum = $(el).attr("data-num") || $(el).attr("data-number") || String(i + 1); const epId = $(el).attr("data-id"); const dataIds = $(el).attr("data-ids"); const title = $(el).attr("title") || $(el).find(".d-title").text().trim() || `Episode ${epNum}`; if (epId && dataIds) { const langs = []; if ($(el).attr("data-sub") === "1") langs.push("sub"); if ($(el).attr("data-dub") === "1") langs.push("dub"); episodes.push({ id: `${epId}|${dataIds}`, number: parseFloat(epNum), title: title, duration: "Unknown", langs, }); } }, ); const hasDubFlag = episodes.some((episode) => (episode.langs || []).includes("dub"), ); if (hasDubFilter && episodes.length > 0 && !hasDubFlag) { episodes = await mapWithConcurrency(episodes, 4, async (episode) => { const dataIds = String(episode.id || "").split("|")[1] || ""; const serverTypes = await fetchServerTypes(dataIds); const langs = new Set(episode.langs || []); if (serverTypes.has("hsub")) langs.add("sub"); if (serverTypes.has("sub")) langs.add("sub"); if (serverTypes.has("dub")) langs.add("dub"); return { ...episode, langs: Array.from(langs) }; }); } return { episodes: episodes, totalPages: 1, total: episodes.length, currentPage: 1, }; } catch (err) { return { episodes: [], totalPages: 0, total: 0, currentPage: page }; } } async function processServer(server) { if (!server || !server.linkId) return null; try { const linkRes = await global.axios.get( `${baseUrl}/ajax/server?get=${server.linkId}`, { headers: { "X-Requested-With": "XMLHttpRequest", }, timeout: 7000, }, ); let iframeUrl = linkRes.data?.result?.url || linkRes.data?.url || (typeof linkRes.data?.result === "string" ? linkRes.data.result : null); if (!iframeUrl && typeof linkRes.data?.result === "string") { const match = linkRes.data.result.match(/src=["']([^"']+)["']/); if (match) iframeUrl = match[1]; } if (!iframeUrl || typeof iframeUrl !== "string") return null; if (iframeUrl.startsWith("//")) iframeUrl = "https:" + iframeUrl; const iframeRes = await global.axios.get(iframeUrl, { headers: { "X-Requested-With": "XMLHttpRequest", Referer: baseUrl, }, timeout: 7000, }); const $iframe = cheerio.load(iframeRes.data); let playerDbId = $iframe("#megaplay-player").attr("data-id") || $iframe("[data-id]").attr("data-id"); if (!playerDbId) { const match = iframeRes.data.match(/data-id=["']([^"']+)["']/); if (match) playerDbId = match[1]; } if (!playerDbId) return null; const typeMatch = iframeRes.data.match(/type\s*:\s*'([^']+)'/) || iframeUrl.match(/\/stream\/[^\/]+\/([^\/\?]+)/); const type = typeMatch ? typeMatch[1] : ""; const ciduMatch = iframeRes.data.match(/cidu\s*:\s*'([^']+)'/); const cidu = ciduMatch ? ciduMatch[1] : ""; const domainName = new URL(iframeUrl).origin; const playerReferer = domainName + "/"; const sourcesRes = await global.axios.get( `${domainName}/stream/getSources?id=${playerDbId}${type ? `&type=${encodeURIComponent(type)}` : ""}${cidu ? `&cidu=${encodeURIComponent(cidu)}` : ""}`, { headers: { "X-Requested-With": "XMLHttpRequest", Referer: playerReferer, }, timeout: 7000, }, ); const decodedSourcesData = sourcesRes.data?.sources || sourcesRes.data?.file ? sourcesRes.data : decodeMegaPlayEnc(sourcesRes.data?.enc); if (decodedSourcesData && (decodedSourcesData.sources || decodedSourcesData.file)) { const rawSrc = decodedSourcesData.sources || decodedSourcesData.file; const m3u8Url = typeof rawSrc === "string" ? rawSrc : rawSrc.file || rawSrc.url || (Array.isArray(rawSrc) ? rawSrc[0]?.file || rawSrc[0]?.url || (typeof rawSrc[0] === "string" ? rawSrc[0] : null) : null); if (m3u8Url) { try { const cdnDomain = new URL(m3u8Url).hostname; global.setDynamicReferer(cdnDomain, playerReferer); global.setFallbackReferer(playerReferer); } catch (e) {} const subtitles = (sourcesRes.data.tracks || decodedSourcesData.tracks || []) .filter( (t) => t.file && (!t.kind || t.kind.toLowerCase() !== "thumbnails"), ) .map((t) => { let sUrl = t.file; if (sUrl.startsWith("//")) { sUrl = "https:" + sUrl; } else if ( !sUrl.startsWith("http://") && !sUrl.startsWith("https://") ) { try { sUrl = new URL(sUrl, iframeUrl).href; } catch (e) {} } return { url: sUrl, lang: t.label || t.language || "English", }; }); return { url: m3u8Url, isM3U8: true, quality: server.name || "auto", isDub: server.type === "dub", isHsub: server.type === "hsub", type: server.type, headers: { Referer: playerReferer }, subtitles: subtitles, }; } } } catch (err) { console.error(`Failed to process server ${server.name}:`, err.message); } return null; } async function fetchEpisodeSources(episodeIdStr, category = null) { try { const parts = episodeIdStr.split("|"); const epId = parts[0]; const dataIds = parts[1]; const serverUrl = `${baseUrl}/ajax/server/list?servers=${dataIds}`; const serverRes = await global.axios.get(serverUrl, { headers: { "X-Requested-With": "XMLHttpRequest", }, timeout: 7000, }); const $ = cheerio.load(serverRes.data.result); const servers = []; $( ".psps-links .item, .psps-links li, .type-server li, [data-type] li, li[data-link-id], li[data-id], .server-item", ).each((i, el) => { const parentWithType = $(el).closest("[data-type]"); let type = $(el).attr("data-type") || (parentWithType.length ? parentWithType.attr("data-type") : ""); if (!type) { const parentClass = ($(el).attr("class") || "") + " " + ($(el).parent().attr("class") || "") + " " + ($(el).closest("[class]").attr("class") || ""); if (parentClass.includes("dub")) type = "dub"; else if (parentClass.includes("hsub")) type = "hsub"; else if (parentClass.includes("sub")) type = "sub"; } const linkId = $(el).attr("data-link-id") || $(el).attr("data-id"); const name = $(el).text().trim() || $(el).find("a").text().trim(); if (linkId) { servers.push({ type: (type || "sub").toLowerCase(), linkId: linkId, name: name || "Server", }); } }); 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; }); targetServers.sort((a, b) => { const aName = (a.name || "").toLowerCase(); const bName = (b.name || "").toLowerCase(); const getServerScore = (n) => { if ( n.includes("hd-1") || n.includes("megacloud") || n.includes("hd-2") || n.includes("megaplay") || n.includes("hd 1") || n.includes("hd 2") ) return 10; if (n.includes("vidplay")) return 8; if ( n.includes("vidstream-1") || n.includes("vidstream 1") || n === "vidstream" ) return 5; if (n.includes("vidstream-2") || n.includes("vidstream 2")) return 1; if (n.includes("vidstream")) return 2; return 3; }; return getServerScore(bName) - getServerScore(aName); }); } const requestedCategory = (category || "sub").toLowerCase(); if (targetServers.length === 0) { return { sources: [], subtitles: [] }; } const sources = targetServers.map((s) => ({ quality: s.name, name: s.name, linkId: s.linkId, lang: s.type || requestedCategory, type: s.type || requestedCategory, isDub: (s.type || requestedCategory) === "dub", isSub: (s.type || requestedCategory) === "sub", isHsub: (s.type || requestedCategory) === "hsub", isUnresolved: true, rawServer: s, })); return { sources, subtitles: [], }; } catch (err) { console.error("Error fetching data from AnikotoTV:", err); return { sources: [], subtitles: [] }; } } module.exports = { name: "anikoto", version: "5.0.4", SearchAnime, AnimeInfo, fetchEpisodeSources, processServer, fetchRecentEpisodes, fetchEpisode, };