- "use strict";
- // ==UserScript==
- // @name Nyaa SI Slim
- // @namespace Original by Vietconnect & Simon1, updated by minori_aoi then me.
- // @require https://cdnjs.cloudflare.com/ajax/libs/markdown-it/8.3.1/markdown-it.min.js
- // @match https://sukebei.nyaa.si/*
- // @grant GM.xmlHttpRequest
- // @grant GM_xmlhttpRequest
- // @version 1.2.2
- // @description Show descriptions of torrents in the search page.
- // @homepageURL https://sleazyfork.org/en/scripts/420886-nyaa-si-slim
- // @supportURL https://sleazyfork.org/en/scripts/420886-nyaa-si-slim/feedback
- // ==/UserScript==
- /* jshint esversion:8 */
- /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
- function log(...msg) {
- console.log(`[Nyaa SI Slim] ${msg}`);
- }
- function getXmlHttpRequest() {
- return (typeof GM !== "undefined" && GM !== null ? GM.xmlHttpRequest : GM_xmlhttpRequest);
- }
- const URLCreator = URL || webkitURL;
- /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
- function crossOriginRequest(details) {
- return new Promise((resolve, reject) => {
- getXmlHttpRequest()({
- timeout: 10000,
- onload: resolve,
- onerror: reject,
- ontimeout: err => {
- log(`${details.url} timed out`);
- reject(err);
- },
- ...details,
- });
- });
- }
- /** The maximum concurrent connection executed by the script. Default is 4.*/
- const CONCURRENCY_LIMIT = 4;
- /** The delay before spawning a new connection. Default is 1000ms.*/
- const DELAY = 1000;
- /**
- * After TIMEOUT ms, a connection still not resolved is considered inactive, the
- * script will move on to spawn the next connection as if it no longer exists.
- * Default value is null (never).
- **/
- const TIMEOUT = null;
- const ROW_SELECTOR = "table.torrent-list > tbody > tr";
- const DESC_SELECTOR = "#torrent-description";
- const LINK_SELECTOR = "td:nth-child(2) a";
- const COMMENT_SELECTOR = "#comments .comment-content";
- /**
- * a Promise that resolves after a period of time.
- * @param {number|null} ms - ms milliseconds, or never if ms == null
- * @returns Promise<void>
- */
- function timeout(ms) {
- if (ms === null) {
- return new Promise(() => { });
- }
- else {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
- }
- /** A simple task pool limiting the number of concurrently running tasks. */
- class Pool {
- /**
- * Create a task pool.
- * @constructor
- * @param {number} limit - the maximum number of concurrent tasks.
- * @param {number|null=null} timeout - when timeout != null, tasks are considered complete after timeout ms.
- */
- constructor(limit, timeout = null) {
- this.limit = limit;
- this.timeout = timeout;
- this.running = 0;
- this.tasks = [];
- }
- /**
- * Push a task to the end of the task queue.
- * @param {Task} task
- */
- push(task) {
- this.tasks.push(task);
- this.on_release();
- }
- /**
- * Insert a task at the start of the task queue.
- * @param {Task} task
- */
- insert(task) {
- this.tasks.unshift(task);
- this.on_release();
- }
- spawn() {
- const task = this.tasks.shift();
- if (task === undefined) {
- return;
- }
- this.running += 1;
- Promise.race([task(), timeout(this.timeout)])
- .catch(console.error)
- .finally(() => {
- this.running -= 1;
- this.on_release();
- });
- }
- on_release() {
- while (this.running < this.limit && this.tasks.length > 0) {
- this.spawn();
- }
- }
- }
- const POOL = new Pool(CONCURRENCY_LIMIT, TIMEOUT);
- function set_placeholder(element, text) {
- const LOADING_LINE = `<tr><td colspan=9>${text}</td></tr>`;
- element.innerHTML = LOADING_LINE;
- }
- /**
- * Collect urls to individual pages of torrents on current page, insert a placeholder after each row.
- * @returns Array - pairs of (url, DOM element to inject description)
- */
- function collect_rows() {
- const rows = Array.from(document.querySelectorAll(ROW_SELECTOR));
- return rows.map(row => {
- const link = row.querySelector(LINK_SELECTOR);
- if (link === null) {
- throw new Error(`No link found in row ${row.innerHTML}`);
- }
- const url = link.href;
- const loading = document.createElement("tr");
- set_placeholder(loading, 'queued');
- row.insertAdjacentElement('afterend', loading);
- return [url, loading.firstElementChild];
- });
- }
- class TorrentPage {
- constructor(desc, comments) {
- this.desc = desc;
- this.comments = comments;
- }
- static parse(dom, nComments) {
- var _a;
- const desc_elem = dom.querySelector(DESC_SELECTOR);
- if (desc_elem === null) {
- throw new Error(`No ${DESC_SELECTOR} on DOM`);
- }
- const desc = (_a = desc_elem.textContent) !== null && _a !== void 0 ? _a : "";
- const comments = Array.from(dom.querySelectorAll(COMMENT_SELECTOR))
- .slice(0, nComments)
- .map(elem => { var _a; return (_a = elem.textContent) !== null && _a !== void 0 ? _a : ""; });
- return new TorrentPage(desc, comments);
- }
- first_nonempty() {
- var _a;
- if (this.desc !== "" && this.desc !== "#### No description.") {
- return this.desc;
- }
- else {
- return (_a = this.comments.find(md => md != "")) !== null && _a !== void 0 ? _a : null;
- }
- }
- }
- /**
- * Fetch and render descriptions on the current page.
- * @param {string} url - url pointing to the description of a torrent.
- * @param {Element} loading - the element on the current page where the description should be injected.
- * @returns Promise
- */
- async function fetch_render_description(url, loading) {
- var _a;
- set_placeholder(loading, 'loading...');
- let page;
- try {
- page = await fetch_description(url);
- }
- catch (e) {
- render_error(url, loading, e);
- return;
- }
- render_description(loading, (_a = page.first_nonempty()) !== null && _a !== void 0 ? _a : "");
- }
- async function fetch_description(url) {
- const res = await fetch(url);
- if (res.status >= 400) {
- throw new Error(`${res.url} returned ${res.status}`);
- }
- const dom = new DOMParser().parseFromString(await res.text(), "text/html");
- return TorrentPage.parse(dom, 1);
- }
- /**
- * @param {string} url
- * @param {Element} loading
- * @param {Error} error
- */
- function render_error(url, loading, error) {
- var _a;
- loading.innerHTML = '';
- const a = document.createElement('a');
- a.text = `${error.message}, click to retry`;
- a.setAttribute('href', '#');
- a.setAttribute('style', 'color: red;');
- a.setAttribute('title', (_a = error.stack) !== null && _a !== void 0 ? _a : 'empty stack');
- a.addEventListener('click', e => {
- e.preventDefault();
- e.stopPropagation();
- set_placeholder(loading, 'queued');
- POOL.insert(() => fetch_render_description(url, loading));
- });
- loading.appendChild(a);
- }
- /**
- * Render and inject the description.
- * @param {Element} loading
- * @param {Element} desc - a div element, the innerHTML of which is torrent description in markdown format.
- */
- async function render_description(loading, desc) {
- const MARKDOWN_OPTIONS = {
- html: true,
- breaks: true,
- linkify: true,
- typographer: true,
- };
- const md = markdownit(MARKDOWN_OPTIONS);
- const rendered_desc = md.render(desc);
- loading.innerHTML = rendered_desc;
- await expandHostedImage(loading);
- limitImageSize(loading);
- }
- const XpicImageHost = {
- match(url) {
- return url.startsWith("https://xpic.org");
- },
- async extract(url) {
- const IMG_SELECTOR = "img.attachment-original";
- const res = await crossOriginRequest({
- method: "GET",
- url,
- });
- const dom = new DOMParser().parseFromString(res.responseText, "text/html");
- const img = dom.querySelector(IMG_SELECTOR);
- if (img === null) {
- throw new Error(`no ${IMG_SELECTOR} in page, check HTML`);
- }
- return img.src;
- }
- };
- const HentaiCoverHost = {
- match(url) {
- return url.startsWith("https://hentai-covers.site");
- },
- async extract(url) {
- const IMG_SELECTOR = "img#image-main";
- const res = await crossOriginRequest({
- method: "GET",
- url,
- });
- const dom = new DOMParser().parseFromString(res.responseText, "text/html");
- const img = dom.querySelector(IMG_SELECTOR);
- if (img === null) {
- throw new Error(`no ${IMG_SELECTOR} in page, check HTML`);
- }
- return img.src;
- }
- };
- const DlsiteImageHost = {
- match(url) {
- return /https:\/\/www\.dlsite\.com\/(maniax|pro)\/work\/=\/product_id\//.test(url);
- },
- async extract(url) {
- const DATA_SELECTOR = ".product-slider-data > :first-child";
- const res = await crossOriginRequest({
- method: "GET",
- url,
- });
- const dom = new DOMParser().parseFromString(res.responseText, "text/html");
- const div = dom.querySelector(DATA_SELECTOR);
- if (div === null) {
- throw new Error(`no ${DATA_SELECTOR} in page, check HTML`);
- }
- const dataSrc = div.dataset["src"];
- if (dataSrc == null) {
- throw new Error(`no data-src in ${DATA_SELECTOR}, check HTML`);
- }
- return dataSrc;
- }
- };
- // 403 forbidden
- // const GetchuImageHost: ImageHost = {
- // match(url: string): boolean {
- // return /https:\/\/(www\.)?getchu\.com\/soft\.phtml\?id=(\d+)/.test(url)
- // },
- // async extract(url: string): Promise<string> {
- // const A_SELECTOR = ".highslide";
- // const pageRes = await crossOriginRequest({
- // method: "GET",
- // url,
- // });
- // const dom = new DOMParser().parseFromString(pageRes.responseText, "text/html");
- // const a = <HTMLAnchorElement>dom.querySelector(A_SELECTOR);
- // let src = a.href;
- // if (src.startsWith("https://sukebei.nyaa.si")) {
- // src = src.replace("sukebei.nyaa.si", "www.getchu.com");
- // }
- // const imgRes = await crossOriginRequest({
- // method: "GET",
- // url: src,
- // binary: true,
- // responseType: "blob",
- // })
- // log(imgRes.responseHeaders);
- // return URLCreator.createObjectURL(imgRes.response);
- // }
- // }
- const IMAGE_HOSTS = [XpicImageHost, DlsiteImageHost, HentaiCoverHost];
- async function expandHostedImage(desc) {
- // skip if there's already an image
- if (desc.querySelector("img") !== null) {
- return;
- }
- for (const a of desc.querySelectorAll('a')) {
- const host = IMAGE_HOSTS.find(h => h.match(a.href));
- if (host == null) {
- continue;
- }
- const img = document.createElement("img");
- img.src = await host.extract(a.href);
- img.style.display = "block";
- a.appendChild(img);
- return;
- }
- }
- function limitImageSize(desc) {
- for (const img of desc.querySelectorAll("img")) {
- // what if the load event didn't fire, e.g. the image is already loaded?
- img.addEventListener("load", () => {
- if (img.width > 600) {
- img.width = 600;
- }
- });
- }
- }
- async function main() {
- for (const [url, loading] of collect_rows()) {
- POOL.push(() => fetch_render_description(url, loading));
- await timeout(DELAY);
- }
- }
- main().catch(console.error);