'use strict'; const nodeFetch = require('node-fetch'); const node_process = require('node:process'); const node_buffer = require('node:buffer'); const node_stream = require('node:stream'); const node_fs = require('node:fs'); const FormData = require('form-data'); function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } function _interopNamespaceCompat(e) { if (e && typeof e === 'object' && 'default' in e) return e; const n = Object.create(null); if (e) { for (const k in e) { n[k] = e[k]; } } n.default = e; return n; } const nodeFetch__namespace = /*#__PURE__*/_interopNamespaceCompat(nodeFetch); const FormData__default = /*#__PURE__*/_interopDefaultCompat(FormData); class SagiriError extends Error { constructor(code, message) { super(`${message} (${code})`); this.name = "SagiriError"; } } class SagiriClientError extends SagiriError { constructor(code, message) { super(code, message); this.name = "SagiriClientError"; } } class SagiriServerError extends SagiriError { constructor(code, message) { super(code, message); this.name = "SagiriServerError"; } } const DoujinMangaLexicon = { name: "The Doujinshi & Manga Lexicon", index: 3, urlMatcher: /(?:http:\/\/)?doujinshi\.mugimugi\.org\/index\.php?p=book&id=\d+/i, backupUrl: ({ data: { ddb_id } }) => `http://doujinshi.mugimugi.org/index.php?P=BOOK&ID=${ddb_id}` }; const Pixiv = { name: "Pixiv", index: 5, urlMatcher: /(?:https?:\/\/)?(?:www\.)?pixiv\.net\/member_illust\.php\?mode=.+&illust_id=\d+/i, backupUrl: ({ data: { pixiv_id } }) => `https://www.pixiv.net/member_illust.php?mode=medium&illust_id=${pixiv_id}`, authorData: ({ member_id, member_name }) => ({ authorName: member_name, authorUrl: `https://www.pixiv.net/users/${member_id}` }) }; const NicoNicoSeiga = { name: "Nico Nico Seiga", index: 8, urlMatcher: /(?:http:\/\/)?seiga\.nicovideo\.jp\/seiga\/im\d+/i, backupUrl: ({ data: { seiga_id } }) => `http://seiga.nicovideo.jp/seiga/im${seiga_id}` }; const Danbooru = { name: "Danbooru", index: 9, urlMatcher: /(?:https?:\/\/)?danbooru\.donmai\.us\/(?:posts|post\/show)\/\d+/i, backupUrl: ({ data: { danbooru_id } }) => `https://danbooru.donmai.us/posts/${danbooru_id}` }; const Drawr = { name: "drawr", index: 10, urlMatcher: /(?:http:\/\/)?(?:www\.)?drawr\.net\/show\.php\?id=\d+/i, backupUrl: ({ data: { drawr_id } }) => `http://drawr.net/show.php?id=${drawr_id}` }; const Nijie = { name: "Nijie", index: 11, urlMatcher: /(?:http:\/\/)?nijie\.info\/view\.php\?id=\d+/i, backupUrl: (data) => `http://nijie.info/view.php?id=${data.data.nijie_id}` }; const Yandere = { name: "Yande.re", index: 12, urlMatcher: /(?:https?:\/\/)?yande\.re\/post\/show\/\d+/i, backupUrl: (data) => `https://yande.re/post/show/${data.data.yandere_id}` }; const OpeningsMoe = { name: "Openings.moe", index: 13, urlMatcher: /(?:https?:\/\/)?openings\.moe\/\?video=.*/, backupUrl: (data) => `https://openings.moe/?video=${data.data.file}` }; const Fakku = { name: "FAKKU", index: 16, urlMatcher: /(?:https?:\/\/)?(www\.)?fakku\.net\/hentai\/[a-z-]+\d+}/i, backupUrl: (data) => `https://www.fakku.net/hentai/${data.data.source?.toLowerCase().replace(" ", "-")}` }; const NHentai = { name: "H-Misc (nHentai)", index: 18, urlMatcher: /https?:\/\/nhentai.net\/g\/\d+/i, backupUrl: (data) => `https://nhentai.net/g/${data.header.thumbnail.match(/nhentai\/(\d+)/)?.[1]}` }; const TwoDMarket = { name: "2D-Market", index: 19, urlMatcher: /https?:\/\/2d-market\.com\/comic\/\d+/i, backupUrl: (data) => `http://2d-market.com/Comic/${data.header.thumbnail.match(/2d_market\/(\d+)/i)?.[1]}-${data.data.source?.replace( " ", "-" )}` }; const MediBang = { name: "MediBang", index: 20, urlMatcher: /(?:https?:\/\/)?medibang\.com\/picture\/[\da-z]+/i, backupUrl: (data) => data.data.url }; const AniDB = { name: "AniDB", index: 21, urlMatcher: /(?:https?:\/\/)?anidb\.net\/perl-bin\/animedb\.pl\?show=.+&aid=\d+/i, backupUrl: (data) => `https://anidb.net/perl-bin/animedb.pl?show=anime&aid=${data.data.anidb_aid}` }; const IMDb = { name: "IMDb", index: 23, urlMatcher: /(?:https?:\/\/)?(?:www\.)?imdb\.com\/title\/.+/i, backupUrl: (data) => `https://www.imdb.com/title/${data.data.imdb_id}` }; const Gelbooru = { name: "Gelbooru", index: 25, urlMatcher: /(?:https?:\/\/)gelbooru\.com\/index\.php\?page=post&s=view&id=\d+/i, backupUrl: (data) => `https://gelbooru.com/index.php?page=post&s=view&id=${data.data.gelbooru_id}` }; const Konachan = { name: "Konachan", index: 26, urlMatcher: /(?:http:\/\/)?konachan\.com\/post\/show\/\d+/i, backupUrl: (data) => `https://konachan.com/post/show/${data.data.konachan_id}` }; const SankakuChannel = { name: "Sankaku Channel", index: 27, urlMatcher: /(?:https?:\/\/)?chan\.sankakucomplex\.com\/post\/show\/\d+/i, backupUrl: (data) => `https://chan.sankakucomplex.com/post/show/${data.data.sankaku_id}` }; const AnimePictures = { name: "Anime-Pictures", index: 28, urlMatcher: /(?:https?:\/\/)?anime-pictures\.net\/pictures\/view_post\/\d+/i, backupUrl: (data) => `https://anime-pictures.net/pictures/view_post/${data.data["anime-pictures_id"]}` }; const E621 = { name: "e621", index: 29, urlMatcher: /(?:https?:\/\/)?e621\.net\/post\/show\/\d+/i, backupUrl: (data) => `https://e621.net/post/show/${data.data.e621_id}` }; const IdolComplex = { name: "Idol Complex", index: 30, urlMatcher: /(?:https?:\/\/)?idol\.sankakucomplex\.com\/post\/show\/\d+/i, backupUrl: (data) => `https://idol.sankakucomplex.com/post/show/${data.data.idol_id}` }; const bcyIllust = { name: "bcy.net Illust", index: 31, urlMatcher: /(?:http:\/\/)?bcy.net\/illust\/detail\/\d+/i, backupUrl: (data) => `https://bcy.net/${data.data.bcy_type}/detail/${data.data.member_link_id}/${data.data.bcy_id}`, authorData: ({ member_id, member_name }) => ({ authorName: member_name, authorUrl: `https://bcy.net/u/${member_id}` }) }; const bcyCosplay = { name: "bcy.net Cosplay", index: 32, urlMatcher: /(?:http:\/\/)?bcy.net\/coser\/detail\/\d{5}/i, backupUrl: (data) => `https://bcy.net/${data.data.bcy_type}/detail/${data.data.member_link_id}/${data.data.bcy_id}` }; const PortalGraphics = { name: "PortalGraphics", index: 33, urlMatcher: /(?:http:\/\/)?web\.archive\.org\/web\/http:\/\/www\.portalgraphics\.net\/pg\/illust\/\?image_id=\d+/i, backupUrl: (data) => `http://web.archive.org/web/http://www.portalgraphics.net/pg/illust/?image_id=${data.data.pg_id}` }; const DeviantArt = { name: "deviantArt", index: 34, urlMatcher: /(?:https:\/\/)?deviantart\.com\/view\/\d+/i, backupUrl: (data) => `https://deviantart.com/view/${data.data.da_id}`, authorData: ({ author_name: authorName, author_url: authorUrl }) => ({ authorName, authorUrl }) }; const Pawoo = { name: "Pawoo", index: 35, urlMatcher: /(?:https?:\/\/)?pawoo\.net\/@.+/i, backupUrl: (data) => `https://pawoo.net/@${data.data.user_acct}/${data.data.pawoo_id}` }; const MangaUpdates = { name: "Manga Updates", index: 36, urlMatcher: /(?:https:\/\/)?www\.mangaupdates\.com\/series\.html\?id=\d+/gi, backupUrl: (data) => `https://www.mangaupdates.com/series.html?id=${data.data.mu_id}` }; const MangaDex = { name: "MangaDex", index: 37, urlMatcher: /(?:https?:\/\/)?mangadex\.org\/chapter\/(\w|-)+\/(?:\d+)?/gi, backupUrl: (data) => `https://mangadex.org/chapter/${data.data.md_id}`, authorData: (data) => ({ authorName: data.author, authorUrl: null }) }; const Ehentai = { name: "H-Misc (eHentai)", index: 38, urlMatcher: /(?:https?:\/\/)?e-hentai\.org\/g\/\d+/i, backupUrl: (data) => `https://e-hentai.org/g/${data.header.thumbnail.match(/e-hentai\/(\d+)/)?.[1]}` }; const ArtStation = { name: "FurAffinity", index: 39, urlMatcher: /(?:https?:\/\/)?www\.artstation\.com\/artwork\/\w+/i, backupUrl: (data) => `https://www.artstation.com/artwork/${data.data.as_project}`, authorData: (data) => ({ authorName: data.author_name, authorUrl: data.author_url }) }; const FurAffinity = { name: "FurAffinity", index: 40, urlMatcher: /(?:https?:\/\/)?furaffinity\.net\/view\/\d+/i, backupUrl: (data) => `https://furaffinity.net/view/${data.data.fa_id}`, authorData: (data) => ({ authorName: data.author_name, authorUrl: data.author_url }) }; const Twitter = { name: "Twitter", index: 41, urlMatcher: /(?:https?:\/\/)?twitter\.com\/.+/i, backupUrl: (data) => `https://twitter.com/i/web/status/${data.data.tweet_id}`, authorData: (data) => ({ authorName: data.twitter_user_handle, authorUrl: `https://twitter.com/i/user/${data.twitter_user_id}` }) }; const FurryNetwork = { name: "Furry Network", index: 42, urlMatcher: /(?:https?:\/\/)?furrynetwork\.com\/artwork\/\d+/i, backupUrl: (data) => `https://furrynetwork.com/artwork/${data.data.fn_id}`, authorData: (data) => ({ authorName: data.author_name, authorUrl: data.author_url }) }; const Kemono = { name: "Kemono", index: 43, urlMatcher: /|(?:(?:https?:\/\/)?fantia\.jp\/posts\/\d+)|(?:(?:https?:\/\/)?subscribestar\.adult\/posts\/\d+)|(?:(?:https?:\/\/)?gumroad\.com\/l\/\w+)|(?:(?:https?:\/\/)?patreon\.com\/posts\/\d+)|(?:(?:https?:\/\/)?pixiv\.net\/fanbox\/creator\/\d+\/post\/\d+)|(?:(?:https?:\/\/)?dlsite\.com\/home\/work\/=\/product_id\/\w+\.\w+)/i, backupUrl: (data) => { switch (data.data.service) { case "fantia": return `https://fantia.jp/posts/${data.data.id}`; case "subscribestar": return `https://subscribestar.adult/posts/${data.data.id}`; case "gumroad": return `https://gumroad.com/l/${data.data.id}`; case "patreon": return `https://patreon.com/posts/${data.data.id}`; case "fanbox": return `https://pixiv.net/fanbox/creator/${data.data.user_id}/post/${data.data.id}`; case "dlsite": return `https://dlsite.com/home/work/=/${data.data.id}`; default: throw new SagiriClientError(999, `Unknown service type for Kemono: ${data.data.service}`); } }, authorData: (data) => { switch (data.service) { case "fantia": return { authorName: data.user_name, authorUrl: `https://fantia.jp/fanclubs/${data.user_id}` }; case "subscribestar": return { authorName: data.user_name, authorUrl: `https://subscribestar.adult/${data.user_id}` }; case "gumroad": return { authorName: data.user_name, authorUrl: `https://gumroad.com/${data.user_id}` }; case "patreon": return { authorName: data.user_name, authorUrl: `https://patreon.com/user?u=${data.user_id}` }; case "fanbox": return { authorName: data.user_name, authorUrl: `https://pixiv.net/fanbox/creator/${data.user_id}` }; case "dlsite": return { authorName: data.user_name, authorUrl: `https://dlsite.com/eng/cicrle/profile/=/marker_id/${data.user_id}` }; default: throw new SagiriClientError(999, `Unknown service type for Kemono: ${data.service}`); } } }; const Skeb = { name: "Skeb", index: 44, urlMatcher: /(?:(?:https?:\/\/)?skeb\.jp\/@\w+\/works\/\d+)/i, backupUrl: (data) => `https://skeb.jp${data.data.path}`, authorData: (data) => ({ authorName: data.creator_name, authorUrl: data.author_url }) }; const sites = { "3": DoujinMangaLexicon, "4": DoujinMangaLexicon, "5": Pixiv, "6": Pixiv, "8": NicoNicoSeiga, "9": Danbooru, "10": Drawr, "11": Nijie, "12": Yandere, "13": OpeningsMoe, "16": Fakku, "18": NHentai, "19": TwoDMarket, "20": MediBang, "21": AniDB, "22": AniDB, "23": IMDb, "24": IMDb, "25": Gelbooru, "26": Konachan, "27": SankakuChannel, "28": AnimePictures, "29": E621, "30": IdolComplex, "31": bcyIllust, "32": bcyCosplay, "33": PortalGraphics, "34": DeviantArt, "35": Pawoo, "36": MangaUpdates, "37": MangaDex, "371": MangaDex, "38": Ehentai, "39": ArtStation, "40": FurAffinity, "41": Twitter, "42": FurryNetwork, "43": Kemono, "44": Skeb }; const generateMask = (masks) => masks.reduce((prev, curr) => { if (curr > 16) { return prev + Math.pow(2, curr - 1); } else { return prev + Math.pow(2, curr); } }, 0); function resolveResult(result) { const { data, header } = result; const id = header.index_id; if (!sites[id]) throw new Error(`Cannot resolve data for unknown index ${id}`); const { name, urlMatcher, backupUrl, authorData } = sites[id]; let url; if (data.ext_urls && data.ext_urls.length > 1) [url] = data.ext_urls.filter((url2) => urlMatcher.test(url2)); else if (data.ext_urls) [url] = data.ext_urls; if (!url) url = backupUrl(result); return { id, url, name, ...authorData?.(result.data) ?? { authorName: null, authorUrl: null } }; } let fetchFn; if (globalThis.fetch === void 0) { fetchFn = nodeFetch__namespace.default; } else { fetchFn = globalThis.fetch; } const sagiri = (token, defaultOpts = { results: 5 }) => { console.debug(`Created sagiri instance with opts: ${JSON.stringify(defaultOpts)}`); if (node_process.env.NODE_ENV !== "test") { if (token.length < 40 || !/^[a-zA-Z0-9]+$/.test(token)) throw new Error("Malformed token. Get a token from https://saucenao.com/user.php"); } return async (file, opts = {}) => { if (!file) throw new Error("No file provided"); console.debug(`Searching for possible sources of image: ${typeof file === "string" ? file : "Buffer"}`); const form = new FormData__default(); const { results, mask, excludeMask, testMode } = { ...defaultOpts, ...opts }; form.append("api_key", token); form.append("output_type", 2); form.append("numres", results); if (testMode) { console.debug("Test mode enabled"); form.append("testmode", 1); } if (mask && excludeMask) { throw new Error("Cannot have both mask and excludeMask"); } else if (mask) { console.log(`Adding inclusive db mask ${generateMask(mask)} from ${mask.join(", ")}`); form.append("dbmask", generateMask(mask)); } else if (excludeMask) { console.log(`Adding exclusive db mask ${generateMask(excludeMask)} from ${excludeMask.join(", ")}`); form.append("dbmaski", generateMask(excludeMask)); } if (typeof file === "string") { if (/^https?:\/\//.test(file)) { form.append("url", file); } else { form.append("file", node_fs.createReadStream(file), { filename: "image.jpg" }); } } else if (file instanceof node_buffer.Buffer) { form.append("file", file, { filename: "image.jpg" }); } else if (file instanceof node_stream.Readable) { form.append("file", file, { filename: "image.jpg" }); } else if (file instanceof Blob) { form.append("file", file, { filename: "image.jpg" }); } else { throw new Error("Invalid file type"); } const response = await fetchFn("https://saucenao.com/search.php", { method: "POST", body: form.getBuffer(), headers: form.getHeaders() }); const res = await response.json(); const { header: { status, message, results_returned: resultsReturned } } = res; if (status > 0) throw new SagiriServerError(status, message); if (status < 0) throw new SagiriClientError(status, message); const unknownIds = new Set( res.results.filter((result) => !sites[result.header.index_id]).map((result) => result.header.index_id) ); if (unknownIds.size > 0) console.warn( `Same results were not resolved, because they were not found in the list of supported sites. Please report this IDs to the library maintainer: ${Array.from(unknownIds).join(", ")}` ); const srcResults = res.results.filter((res2) => !unknownIds.has(res2.header.index_id)).sort((a, b) => b.header.similarity - a.header.similarity); console.debug( `Exepcted ${results} results, got ${srcResults.length}, with saucenao reporting ${resultsReturned} results.` ); return srcResults.map((res2) => { const { url, name, id, authorName, authorUrl } = resolveResult(res2); const { header: { similarity, thumbnail } } = res2; return { url, site: name, index: parseInt(id), similarity: Number(similarity), thumbnail, authorName, authorUrl, raw: res2 }; }); }; }; module.exports = sagiri;