Nyaa SI Slim

Show descriptions of torrents in the search page.

  1. "use strict";
  2. // ==UserScript==
  3. // @name Nyaa SI Slim
  4. // @namespace Original by Vietconnect & Simon1, updated by minori_aoi then me.
  5. // @require https://cdnjs.cloudflare.com/ajax/libs/markdown-it/8.3.1/markdown-it.min.js
  6. // @match https://sukebei.nyaa.si/*
  7. // @grant GM.xmlHttpRequest
  8. // @grant GM_xmlhttpRequest
  9. // @version 1.2.2
  10. // @description Show descriptions of torrents in the search page.
  11. // @homepageURL https://sleazyfork.org/en/scripts/420886-nyaa-si-slim
  12. // @supportURL https://sleazyfork.org/en/scripts/420886-nyaa-si-slim/feedback
  13. // ==/UserScript==
  14. /* jshint esversion:8 */
  15. /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
  16. function log(...msg) {
  17. console.log(`[Nyaa SI Slim] ${msg}`);
  18. }
  19. function getXmlHttpRequest() {
  20. return (typeof GM !== "undefined" && GM !== null ? GM.xmlHttpRequest : GM_xmlhttpRequest);
  21. }
  22. const URLCreator = URL || webkitURL;
  23. /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
  24. function crossOriginRequest(details) {
  25. return new Promise((resolve, reject) => {
  26. getXmlHttpRequest()({
  27. timeout: 10000,
  28. onload: resolve,
  29. onerror: reject,
  30. ontimeout: err => {
  31. log(`${details.url} timed out`);
  32. reject(err);
  33. },
  34. ...details,
  35. });
  36. });
  37. }
  38. /** The maximum concurrent connection executed by the script. Default is 4.*/
  39. const CONCURRENCY_LIMIT = 4;
  40. /** The delay before spawning a new connection. Default is 1000ms.*/
  41. const DELAY = 1000;
  42. /**
  43. * After TIMEOUT ms, a connection still not resolved is considered inactive, the
  44. * script will move on to spawn the next connection as if it no longer exists.
  45. * Default value is null (never).
  46. **/
  47. const TIMEOUT = null;
  48. const ROW_SELECTOR = "table.torrent-list > tbody > tr";
  49. const DESC_SELECTOR = "#torrent-description";
  50. const LINK_SELECTOR = "td:nth-child(2) a";
  51. const COMMENT_SELECTOR = "#comments .comment-content";
  52. /**
  53. * a Promise that resolves after a period of time.
  54. * @param {number|null} ms - ms milliseconds, or never if ms == null
  55. * @returns Promise<void>
  56. */
  57. function timeout(ms) {
  58. if (ms === null) {
  59. return new Promise(() => { });
  60. }
  61. else {
  62. return new Promise(resolve => setTimeout(resolve, ms));
  63. }
  64. }
  65. /** A simple task pool limiting the number of concurrently running tasks. */
  66. class Pool {
  67. /**
  68. * Create a task pool.
  69. * @constructor
  70. * @param {number} limit - the maximum number of concurrent tasks.
  71. * @param {number|null=null} timeout - when timeout != null, tasks are considered complete after timeout ms.
  72. */
  73. constructor(limit, timeout = null) {
  74. this.limit = limit;
  75. this.timeout = timeout;
  76. this.running = 0;
  77. this.tasks = [];
  78. }
  79. /**
  80. * Push a task to the end of the task queue.
  81. * @param {Task} task
  82. */
  83. push(task) {
  84. this.tasks.push(task);
  85. this.on_release();
  86. }
  87. /**
  88. * Insert a task at the start of the task queue.
  89. * @param {Task} task
  90. */
  91. insert(task) {
  92. this.tasks.unshift(task);
  93. this.on_release();
  94. }
  95. spawn() {
  96. const task = this.tasks.shift();
  97. if (task === undefined) {
  98. return;
  99. }
  100. this.running += 1;
  101. Promise.race([task(), timeout(this.timeout)])
  102. .catch(console.error)
  103. .finally(() => {
  104. this.running -= 1;
  105. this.on_release();
  106. });
  107. }
  108. on_release() {
  109. while (this.running < this.limit && this.tasks.length > 0) {
  110. this.spawn();
  111. }
  112. }
  113. }
  114. const POOL = new Pool(CONCURRENCY_LIMIT, TIMEOUT);
  115. function set_placeholder(element, text) {
  116. const LOADING_LINE = `<tr><td colspan=9>${text}</td></tr>`;
  117. element.innerHTML = LOADING_LINE;
  118. }
  119. /**
  120. * Collect urls to individual pages of torrents on current page, insert a placeholder after each row.
  121. * @returns Array - pairs of (url, DOM element to inject description)
  122. */
  123. function collect_rows() {
  124. const rows = Array.from(document.querySelectorAll(ROW_SELECTOR));
  125. return rows.map(row => {
  126. const link = row.querySelector(LINK_SELECTOR);
  127. if (link === null) {
  128. throw new Error(`No link found in row ${row.innerHTML}`);
  129. }
  130. const url = link.href;
  131. const loading = document.createElement("tr");
  132. set_placeholder(loading, 'queued');
  133. row.insertAdjacentElement('afterend', loading);
  134. return [url, loading.firstElementChild];
  135. });
  136. }
  137. class TorrentPage {
  138. constructor(desc, comments) {
  139. this.desc = desc;
  140. this.comments = comments;
  141. }
  142. static parse(dom, nComments) {
  143. var _a;
  144. const desc_elem = dom.querySelector(DESC_SELECTOR);
  145. if (desc_elem === null) {
  146. throw new Error(`No ${DESC_SELECTOR} on DOM`);
  147. }
  148. const desc = (_a = desc_elem.textContent) !== null && _a !== void 0 ? _a : "";
  149. const comments = Array.from(dom.querySelectorAll(COMMENT_SELECTOR))
  150. .slice(0, nComments)
  151. .map(elem => { var _a; return (_a = elem.textContent) !== null && _a !== void 0 ? _a : ""; });
  152. return new TorrentPage(desc, comments);
  153. }
  154. first_nonempty() {
  155. var _a;
  156. if (this.desc !== "" && this.desc !== "#### No description.") {
  157. return this.desc;
  158. }
  159. else {
  160. return (_a = this.comments.find(md => md != "")) !== null && _a !== void 0 ? _a : null;
  161. }
  162. }
  163. }
  164. /**
  165. * Fetch and render descriptions on the current page.
  166. * @param {string} url - url pointing to the description of a torrent.
  167. * @param {Element} loading - the element on the current page where the description should be injected.
  168. * @returns Promise
  169. */
  170. async function fetch_render_description(url, loading) {
  171. var _a;
  172. set_placeholder(loading, 'loading...');
  173. let page;
  174. try {
  175. page = await fetch_description(url);
  176. }
  177. catch (e) {
  178. render_error(url, loading, e);
  179. return;
  180. }
  181. render_description(loading, (_a = page.first_nonempty()) !== null && _a !== void 0 ? _a : "");
  182. }
  183. async function fetch_description(url) {
  184. const res = await fetch(url);
  185. if (res.status >= 400) {
  186. throw new Error(`${res.url} returned ${res.status}`);
  187. }
  188. const dom = new DOMParser().parseFromString(await res.text(), "text/html");
  189. return TorrentPage.parse(dom, 1);
  190. }
  191. /**
  192. * @param {string} url
  193. * @param {Element} loading
  194. * @param {Error} error
  195. */
  196. function render_error(url, loading, error) {
  197. var _a;
  198. loading.innerHTML = '';
  199. const a = document.createElement('a');
  200. a.text = `${error.message}, click to retry`;
  201. a.setAttribute('href', '#');
  202. a.setAttribute('style', 'color: red;');
  203. a.setAttribute('title', (_a = error.stack) !== null && _a !== void 0 ? _a : 'empty stack');
  204. a.addEventListener('click', e => {
  205. e.preventDefault();
  206. e.stopPropagation();
  207. set_placeholder(loading, 'queued');
  208. POOL.insert(() => fetch_render_description(url, loading));
  209. });
  210. loading.appendChild(a);
  211. }
  212. /**
  213. * Render and inject the description.
  214. * @param {Element} loading
  215. * @param {Element} desc - a div element, the innerHTML of which is torrent description in markdown format.
  216. */
  217. async function render_description(loading, desc) {
  218. const MARKDOWN_OPTIONS = {
  219. html: true,
  220. breaks: true,
  221. linkify: true,
  222. typographer: true,
  223. };
  224. const md = markdownit(MARKDOWN_OPTIONS);
  225. const rendered_desc = md.render(desc);
  226. loading.innerHTML = rendered_desc;
  227. await expandHostedImage(loading);
  228. limitImageSize(loading);
  229. }
  230. const XpicImageHost = {
  231. match(url) {
  232. return url.startsWith("https://xpic.org");
  233. },
  234. async extract(url) {
  235. const IMG_SELECTOR = "img.attachment-original";
  236. const res = await crossOriginRequest({
  237. method: "GET",
  238. url,
  239. });
  240. const dom = new DOMParser().parseFromString(res.responseText, "text/html");
  241. const img = dom.querySelector(IMG_SELECTOR);
  242. if (img === null) {
  243. throw new Error(`no ${IMG_SELECTOR} in page, check HTML`);
  244. }
  245. return img.src;
  246. }
  247. };
  248. const HentaiCoverHost = {
  249. match(url) {
  250. return url.startsWith("https://hentai-covers.site");
  251. },
  252. async extract(url) {
  253. const IMG_SELECTOR = "img#image-main";
  254. const res = await crossOriginRequest({
  255. method: "GET",
  256. url,
  257. });
  258. const dom = new DOMParser().parseFromString(res.responseText, "text/html");
  259. const img = dom.querySelector(IMG_SELECTOR);
  260. if (img === null) {
  261. throw new Error(`no ${IMG_SELECTOR} in page, check HTML`);
  262. }
  263. return img.src;
  264. }
  265. };
  266. const DlsiteImageHost = {
  267. match(url) {
  268. return /https:\/\/www\.dlsite\.com\/(maniax|pro)\/work\/=\/product_id\//.test(url);
  269. },
  270. async extract(url) {
  271. const DATA_SELECTOR = ".product-slider-data > :first-child";
  272. const res = await crossOriginRequest({
  273. method: "GET",
  274. url,
  275. });
  276. const dom = new DOMParser().parseFromString(res.responseText, "text/html");
  277. const div = dom.querySelector(DATA_SELECTOR);
  278. if (div === null) {
  279. throw new Error(`no ${DATA_SELECTOR} in page, check HTML`);
  280. }
  281. const dataSrc = div.dataset["src"];
  282. if (dataSrc == null) {
  283. throw new Error(`no data-src in ${DATA_SELECTOR}, check HTML`);
  284. }
  285. return dataSrc;
  286. }
  287. };
  288. // 403 forbidden
  289. // const GetchuImageHost: ImageHost = {
  290. // match(url: string): boolean {
  291. // return /https:\/\/(www\.)?getchu\.com\/soft\.phtml\?id=(\d+)/.test(url)
  292. // },
  293. // async extract(url: string): Promise<string> {
  294. // const A_SELECTOR = ".highslide";
  295. // const pageRes = await crossOriginRequest({
  296. // method: "GET",
  297. // url,
  298. // });
  299. // const dom = new DOMParser().parseFromString(pageRes.responseText, "text/html");
  300. // const a = <HTMLAnchorElement>dom.querySelector(A_SELECTOR);
  301. // let src = a.href;
  302. // if (src.startsWith("https://sukebei.nyaa.si")) {
  303. // src = src.replace("sukebei.nyaa.si", "www.getchu.com");
  304. // }
  305. // const imgRes = await crossOriginRequest({
  306. // method: "GET",
  307. // url: src,
  308. // binary: true,
  309. // responseType: "blob",
  310. // })
  311. // log(imgRes.responseHeaders);
  312. // return URLCreator.createObjectURL(imgRes.response);
  313. // }
  314. // }
  315. const IMAGE_HOSTS = [XpicImageHost, DlsiteImageHost, HentaiCoverHost];
  316. async function expandHostedImage(desc) {
  317. // skip if there's already an image
  318. if (desc.querySelector("img") !== null) {
  319. return;
  320. }
  321. for (const a of desc.querySelectorAll('a')) {
  322. const host = IMAGE_HOSTS.find(h => h.match(a.href));
  323. if (host == null) {
  324. continue;
  325. }
  326. const img = document.createElement("img");
  327. img.src = await host.extract(a.href);
  328. img.style.display = "block";
  329. a.appendChild(img);
  330. return;
  331. }
  332. }
  333. function limitImageSize(desc) {
  334. for (const img of desc.querySelectorAll("img")) {
  335. // what if the load event didn't fire, e.g. the image is already loaded?
  336. img.addEventListener("load", () => {
  337. if (img.width > 600) {
  338. img.width = 600;
  339. }
  340. });
  341. }
  342. }
  343. async function main() {
  344. for (const [url, loading] of collect_rows()) {
  345. POOL.push(() => fetch_render_description(url, loading));
  346. await timeout(DELAY);
  347. }
  348. }
  349. main().catch(console.error);