import { StringEnum } from "@mariozechner/pi-ai"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, } from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; import { Type } from "typebox"; const BASE_URLS = { nyaa: "https://nyaa.si", sukebei: "https://sukebei.nyaa.si", } as const; const USER_AGENT = "pi-nyaa-extension/0.1 (+https://github.com/mariozechner/pi-coding-agent)"; const MAX_DESCRIPTION_BYTES = 12_000; const MAX_FILE_LIST_ITEMS = 100; type Site = keyof typeof BASE_URLS; interface SearchItem { id?: string; title: string; pageUrl?: string; category?: string; categoryId?: string; size?: string; pubDate?: string; seeders?: number; leechers?: number; downloads?: number; comments?: number; trusted?: boolean; remake?: boolean; } interface ViewDetails { site: Site; id: string; pageUrl: string; title?: string; category?: string; date?: string; submitter?: string; information?: string; size?: string; seeders?: number; leechers?: number; completed?: number; description?: string; descriptionTruncated?: boolean; files?: string[]; filesTruncated?: boolean; comments?: number; } const DEFAULT_SEARCH_SITE: Site = "sukebei"; const searchParams = Type.Object({ site: Type.Optional( StringEnum(["nyaa", "sukebei"] as const, { description: "Which Nyaa site to search. Defaults to 'sukebei'. Use 'nyaa' for the non-adult index.", default: DEFAULT_SEARCH_SITE, }), ), query: Type.Optional( Type.String({ description: "Search query. Omit or use an empty string for latest items.", }), ), category: Type.Optional( Type.String({ description: "Raw Nyaa category id, e.g. 0_0 for all, 1_2 for Anime - English-translated. Category ids differ by site.", }), ), filter: Type.Optional( StringEnum(["all", "no_remakes", "trusted_only"] as const, { description: "Nyaa filter: all, no_remakes, or trusted_only. Defaults to all.", }), ), sort: Type.Optional( StringEnum( ["id", "seeders", "leechers", "downloads", "comments", "size"] as const, { description: "Sort field. Defaults to seeders.", }, ), ), order: Type.Optional( StringEnum(["desc", "asc"] as const, { description: "Sort order. Defaults to desc.", }), ), page: Type.Optional( Type.Integer({ minimum: 1, maximum: 100, description: "Search result page. Defaults to 1.", }), ), limit: Type.Optional( Type.Integer({ minimum: 1, maximum: 50, description: "Maximum RSS items to return. Defaults to 20.", }), ), }); const viewParams = Type.Object({ site: StringEnum(["nyaa", "sukebei"] as const, { description: "Which Nyaa site to fetch from. Use 'sukebei' only when the user explicitly wants the adult Sukebei index.", }), idOrUrl: Type.String({ description: "Nyaa view id (e.g. 2097677) or a https://nyaa.si/view/... / https://sukebei.nyaa.si/view/... URL.", }), includeDescription: Type.Optional( Type.Boolean({ description: "Include the torrent description text. Defaults to true.", }), ), includeFiles: Type.Optional( Type.Boolean({ description: "Include the file list when present. Defaults to true.", }), ), }); export default function nyaaExtension(pi: ExtensionAPI) { pi.registerTool({ name: "nyaa_search", label: "Nyaa Search", description: `Search public metadata from nyaa.si or sukebei.nyaa.si via RSS. Returns titles, categories, sizes, dates, swarm counts, comments, and view-page URLs only; it intentionally does not return torrent download or magnet links. Output is capped at 50 items and truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}.`, promptSnippet: "Search public Nyaa/Sukebei torrent metadata without returning torrent or magnet download links", promptGuidelines: [ "Use nyaa_search when the user asks to search or inspect metadata on nyaa.si or sukebei.nyaa.si.", "nyaa_search intentionally returns view-page metadata only; do not present it as downloading or acquiring content.", ], parameters: searchParams, async execute(_toolCallId, params, signal, onUpdate) { const site = (params.site ?? DEFAULT_SEARCH_SITE) as Site; onUpdate?.({ content: [{ type: "text", text: `Searching ${site}...` }], }); const url = buildSearchUrl(site, { query: params.query, category: params.category, filter: params.filter, sort: params.sort, order: params.order, page: params.page, }); const xml = await fetchText(url, signal); const limit = clampInteger(params.limit ?? 20, 1, 50); const items = sortSearchItems( parseRssItems(xml, site), params.sort ?? "seeders", params.order ?? "desc", ).slice(0, limit); const output = formatSearchResults(site, url, items); const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES, }); let text = truncation.content; if (truncation.truncated) { text += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).]`; } return { content: [{ type: "text", text }], details: { site, query: params.query ?? "", url, count: items.length, items, truncation: truncation.truncated ? truncation : undefined, }, }; }, renderCall(args, theme) { const site = typeof args.site === "string" ? args.site : DEFAULT_SEARCH_SITE; const query = typeof args.query === "string" && args.query.trim() ? ` ${JSON.stringify(args.query)}` : " latest"; return new Text( `${theme.fg("toolTitle", theme.bold("nyaa_search"))} ${theme.fg("accent", site)}${theme.fg("muted", query)}`, 0, 0, ); }, renderResult(result, { expanded, isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "Searching Nyaa..."), 0, 0); const details = result.details as | { count?: number; site?: string; items?: SearchItem[] } | undefined; const count = details?.count ?? 0; let text = count === 0 ? theme.fg("dim", "No results") : theme.fg( "success", `${count} result${count === 1 ? "" : "s"} from ${details?.site ?? DEFAULT_SEARCH_SITE}`, ); if (expanded && details?.items?.length) { for (const item of details.items.slice(0, 10)) { text += `\n${theme.fg("dim", `${item.id ?? "?"} · S:${item.seeders ?? "?"} L:${item.leechers ?? "?"} · ${item.title}`)}`; } } return new Text(text, 0, 0); }, }); pi.registerTool({ name: "nyaa_view", label: "Nyaa View", description: "Fetch public metadata from a nyaa.si or sukebei.nyaa.si view page. Returns title, category, upload metadata, description, file list, and view URL only; it intentionally omits torrent download and magnet links.", promptSnippet: "Fetch details for one Nyaa/Sukebei view page without returning torrent or magnet download links", promptGuidelines: [ "Use nyaa_view when the user provides a Nyaa/Sukebei view id or view URL and asks for its public metadata.", "nyaa_view intentionally omits torrent download and magnet links; do not infer or reconstruct them.", ], parameters: viewParams, async execute(_toolCallId, params, signal, onUpdate) { onUpdate?.({ content: [ { type: "text", text: `Fetching ${params.site} view page...` }, ], }); const site = params.site as Site; const { id, url } = normalizeViewInput(site, params.idOrUrl); const html = await fetchText(url, signal); const details = parseViewHtml(html, site, id, url, { includeDescription: params.includeDescription !== false, includeFiles: params.includeFiles !== false, }); return { content: [{ type: "text", text: formatViewDetails(details) }], details, }; }, renderCall(args, theme) { return new Text( `${theme.fg("toolTitle", theme.bold("nyaa_view"))} ${theme.fg("accent", String(args.site ?? "nyaa"))} ${theme.fg("muted", String(args.idOrUrl ?? ""))}`, 0, 0, ); }, renderResult(result, { expanded, isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "Fetching Nyaa metadata..."), 0, 0); const details = result.details as ViewDetails | undefined; if (!details) return new Text(theme.fg("dim", "No details"), 0, 0); let text = theme.fg("success", `${details.site}/${details.id}`); if (details.title) text += ` ${theme.fg("muted", details.title)}`; if (expanded) { if (details.category) text += `\n${theme.fg("dim", `Category: ${details.category}`)}`; if (details.size) text += `\n${theme.fg("dim", `Size: ${details.size}`)}`; if (details.files?.length) text += `\n${theme.fg("dim", `Files: ${details.files.length}${details.filesTruncated ? "+" : ""}`)}`; } return new Text(text, 0, 0); }, }); } function buildSearchUrl( site: Site, input: { query?: string; category?: string; filter?: string; sort?: string; order?: string; page?: number; }, ): string { const url = new URL("/", BASE_URLS[site]); url.searchParams.set("page", "rss"); const query = input.query?.trim(); if (query) url.searchParams.set("q", query); url.searchParams.set("c", input.category?.trim() || "0_0"); url.searchParams.set("f", filterToCode(input.filter)); url.searchParams.set("s", input.sort || "seeders"); url.searchParams.set("o", input.order || "desc"); if (input.page && input.page > 1) url.searchParams.set("p", String(Math.floor(input.page))); return url.toString(); } function filterToCode(filter?: string): string { if (filter === "no_remakes") return "1"; if (filter === "trusted_only") return "2"; return "0"; } async function fetchText(url: string, signal?: AbortSignal): Promise { const controller = new AbortController(); const timeout = setTimeout( () => controller.abort(new Error("Timed out fetching Nyaa")), 30_000, ); const abortFromParent = () => controller.abort(signal?.reason); if (signal?.aborted) abortFromParent(); else signal?.addEventListener("abort", abortFromParent, { once: true }); try { const response = await fetch(url, { signal: controller.signal, headers: { "User-Agent": USER_AGENT, Accept: "application/rss+xml, application/xml, text/html;q=0.9, */*;q=0.1", }, }); if (!response.ok) throw new Error( `Nyaa request failed: HTTP ${response.status} ${response.statusText}`, ); return await response.text(); } finally { clearTimeout(timeout); signal?.removeEventListener("abort", abortFromParent); } } function parseRssItems(xml: string, site: Site): SearchItem[] { const items: SearchItem[] = []; const itemRegex = /([\s\S]*?)<\/item>/gi; for (;;) { const match = itemRegex.exec(xml); if (match === null) break; const block = match[1] ?? ""; const guid = getXmlTag(block, "guid"); const id = guid?.match(/\/view\/(\d+)/)?.[1]; const pageUrl = id ? `${BASE_URLS[site]}/view/${id}` : guid; items.push({ id, title: getXmlTag(block, "title") ?? "(untitled)", pageUrl, category: getXmlTag(block, "nyaa:category"), categoryId: getXmlTag(block, "nyaa:categoryId"), size: getXmlTag(block, "nyaa:size"), pubDate: getXmlTag(block, "pubDate"), seeders: parseOptionalInt(getXmlTag(block, "nyaa:seeders")), leechers: parseOptionalInt(getXmlTag(block, "nyaa:leechers")), downloads: parseOptionalInt(getXmlTag(block, "nyaa:downloads")), comments: parseOptionalInt(getXmlTag(block, "nyaa:comments")), trusted: getXmlTag(block, "nyaa:trusted") === "Yes", remake: getXmlTag(block, "nyaa:remake") === "Yes", }); } return items; } function sortSearchItems( items: SearchItem[], sort: string, order: string, ): SearchItem[] { const direction = order === "asc" ? 1 : -1; return [...items].sort((a, b) => { const comparison = compareSearchItems(a, b, sort); if (comparison !== 0) return comparison * direction; return compareSearchItems(a, b, "id") * -1; }); } function compareSearchItems( a: SearchItem, b: SearchItem, sort: string, ): number { if (sort === "size") return compareNumbers(parseSizeBytes(a.size), parseSizeBytes(b.size)); if (sort === "leechers") return compareNumbers(a.leechers, b.leechers); if (sort === "downloads") return compareNumbers(a.downloads, b.downloads); if (sort === "comments") return compareNumbers(a.comments, b.comments); if (sort === "id") return compareNumbers(parseOptionalInt(a.id), parseOptionalInt(b.id)); return compareNumbers(a.seeders, b.seeders); } function compareNumbers(a?: number, b?: number): number { return (a ?? Number.NEGATIVE_INFINITY) - (b ?? Number.NEGATIVE_INFINITY); } function parseSizeBytes(size?: string): number | undefined { const match = size?.trim().match(/^(\d+(?:\.\d+)?)\s*([KMGTPE]?i?B)$/i); if (!match) return undefined; const value = Number(match[1]); if (!Number.isFinite(value)) return undefined; const unit = match[2]?.toLowerCase(); const powers: Record = { b: 0, kb: 1, kib: 1, mb: 2, mib: 2, gb: 3, gib: 3, tb: 4, tib: 4, pb: 5, pib: 5, eb: 6, eib: 6, }; const power = unit ? powers[unit] : undefined; return power === undefined ? undefined : value * 1024 ** power; } function getXmlTag(xml: string, tagName: string): string | undefined { const escaped = escapeRegExp(tagName); const match = xml.match( new RegExp(`<${escaped}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${escaped}>`, "i"), ); if (!match) return undefined; return cleanText(match[1] ?? ""); } function normalizeViewInput( defaultSite: Site, idOrUrl: string, ): { id: string; url: string } { const trimmed = idOrUrl.trim(); const urlMatch = trimmed.match( /^https:\/\/(nyaa\.si|sukebei\.nyaa\.si)\/view\/(\d+)/i, ); if (urlMatch) { const hostSite: Site = urlMatch[1]?.toLowerCase() === "sukebei.nyaa.si" ? "sukebei" : "nyaa"; const id = urlMatch[2]; if (!id) throw new Error("Nyaa view URL did not contain an id"); return { id, url: `${BASE_URLS[hostSite]}/view/${id}` }; } const idMatch = trimmed.match(/^(?:#)?(\d+)$/); if (!idMatch) throw new Error( "idOrUrl must be a numeric Nyaa view id or a nyaa.si/sukebei.nyaa.si /view/ URL", ); const id = idMatch[1]; if (!id) throw new Error("Nyaa view id was empty"); return { id, url: `${BASE_URLS[defaultSite]}/view/${id}` }; } function parseViewHtml( html: string, site: Site, id: string, pageUrl: string, options: { includeDescription: boolean; includeFiles: boolean }, ): ViewDetails { const title = cleanText( matchFirst( html, /]*class=["'][^"']*panel-title[^"']*["'][^>]*>([\s\S]*?)<\/h3>/i, ) ?? matchFirst(html, /([\s\S]*?)<\/title>/i)?.replace( /\s+::\s+(Nyaa|Sukebei)\s*$/i, "", ), ); const details: ViewDetails = { site, id, pageUrl, title: title || undefined, category: extractLabelValue(html, "Category"), date: extractLabelValue(html, "Date"), submitter: extractLabelValue(html, "Submitter"), information: extractLabelValue(html, "Information"), size: extractLabelValue(html, "File size"), seeders: parseOptionalInt(extractLabelValue(html, "Seeders")), leechers: parseOptionalInt(extractLabelValue(html, "Leechers")), completed: parseOptionalInt(extractLabelValue(html, "Completed")), comments: parseOptionalInt(matchFirst(html, /Comments\s*-\s*(\d+)/i)), }; if (options.includeDescription) { const rawDescription = matchFirst( html, /<div[^>]*id=["']torrent-description["'][^>]*>([\s\S]*?)<\/div>/i, ); if (rawDescription !== undefined) { const description = cleanText(rawDescription); const truncation = truncateHead(description, { maxLines: 400, maxBytes: MAX_DESCRIPTION_BYTES, }); details.description = truncation.content; details.descriptionTruncated = truncation.truncated; } } if (options.includeFiles) { const fileListHtml = matchFirst( html, /<div[^>]*class=["'][^"']*torrent-file-list[^"']*["'][^>]*>([\s\S]*?)<\/div>/i, ); if (fileListHtml !== undefined) { const allFiles = Array.from( fileListHtml.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi), ) .map((m) => cleanText(m[1] ?? "")) .filter(Boolean); details.files = allFiles.slice(0, MAX_FILE_LIST_ITEMS); details.filesTruncated = allFiles.length > details.files.length; } } return details; } function extractLabelValue(html: string, label: string): string | undefined { const escaped = escapeRegExp(label); const pattern = new RegExp( `<div[^>]*>\\s*${escaped}:\\s*<\\/div>\\s*<div[^>]*>([\\s\\S]*?)<\\/div>`, "i", ); const value = matchFirst(html, pattern); const cleaned = value === undefined ? undefined : cleanText(value); return cleaned || undefined; } function formatSearchResults( site: Site, url: string, items: SearchItem[], ): string { const lines = [ `Nyaa search (${site})`, `RSS URL: ${url}`, `Results: ${items.length}`, "", ]; if (items.length === 0) { lines.push("No results found."); return lines.join("\n"); } items.forEach((item, index) => { lines.push(`${index + 1}. ${item.title}`); lines.push( ` id: ${item.id ?? "?"} | category: ${item.category ?? item.categoryId ?? "?"} | size: ${item.size ?? "?"}`, ); lines.push( ` seeders: ${item.seeders ?? "?"} | leechers: ${item.leechers ?? "?"} | downloads: ${item.downloads ?? "?"} | comments: ${item.comments ?? "?"}`, ); if (item.pubDate) lines.push(` date: ${item.pubDate}`); if (item.pageUrl) lines.push(` view: ${item.pageUrl}`); if (item.trusted || item.remake) lines.push( ` flags: ${[item.trusted ? "trusted" : "", item.remake ? "remake" : ""].filter(Boolean).join(", ")}`, ); }); return lines.join("\n"); } function formatViewDetails(details: ViewDetails): string { const lines = [ `Nyaa view (${details.site}/${details.id})`, `View URL: ${details.pageUrl}`, ]; if (details.title) lines.push(`Title: ${details.title}`); if (details.category) lines.push(`Category: ${details.category}`); if (details.date) lines.push(`Date: ${details.date}`); if (details.submitter) lines.push(`Submitter: ${details.submitter}`); if (details.information) lines.push(`Information: ${details.information}`); if (details.size) lines.push(`File size: ${details.size}`); if ( details.seeders !== undefined || details.leechers !== undefined || details.completed !== undefined ) { lines.push( `Swarm: seeders ${details.seeders ?? "?"}, leechers ${details.leechers ?? "?"}, completed ${details.completed ?? "?"}`, ); } if (details.comments !== undefined) lines.push(`Comments: ${details.comments}`); if (details.description) { lines.push("", "Description:", details.description); if (details.descriptionTruncated) lines.push( `[Description truncated to ${formatSize(MAX_DESCRIPTION_BYTES)}.]`, ); } if (details.files?.length) { lines.push( "", `Files (${details.files.length}${details.filesTruncated ? "+" : ""}):`, ); for (const file of details.files) lines.push(`- ${file}`); if (details.filesTruncated) lines.push(`[File list truncated to ${MAX_FILE_LIST_ITEMS} items.]`); } return lines.join("\n"); } function cleanText(value: string): string { return decodeHtml(stripCdata(value)) .replace(/<br\s*\/?\s*>/gi, "\n") .replace(/<\/?p[^>]*>/gi, "\n") .replace(/<[^>]+>/g, "") .replace(/\r\n/g, "\n") .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .replace(/[ \t]{2,}/g, " ") .trim(); } function stripCdata(value: string): string { return value.replace(/^\s*<!\[CDATA\[/, "").replace(/\]\]>\s*$/, ""); } function decodeHtml(value: string): string { const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", }; return value.replace( /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (_full, entity: string) => { if (entity.startsWith("#x") || entity.startsWith("#X")) return String.fromCodePoint(Number.parseInt(entity.slice(2), 16)); if (entity.startsWith("#")) return String.fromCodePoint(Number.parseInt(entity.slice(1), 10)); return named[entity] ?? `&${entity};`; }, ); } function matchFirst(value: string, regex: RegExp): string | undefined { return value.match(regex)?.[1]; } function parseOptionalInt(value?: string): number | undefined { if (!value) return undefined; const parsed = Number.parseInt(value.replace(/,/g, ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } function clampInteger(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, Math.floor(value))); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }