Nyaa SI Slim

Show descriptions of torrents in the search page.

"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);