Pixiv novel to Epub

Download pixiv novels in Epub format

  1. /* eslint-disable no-multi-spaces */
  2. /* eslint-disable no-return-assign */
  3.  
  4. // ==UserScript==
  5. // @name Pixiv novel to Epub
  6. // @name:zh-CN Pixiv小说Epub合成器
  7. // @name:en Pixiv novel to Epub
  8. // @namespace PY-DNG userscripts
  9. // @version 0.1.8
  10. // @description Download pixiv novels in Epub format
  11. // @description:zh-CN 以Epub格式下载Pixiv小说
  12. // @description:en Download pixiv novels in Epub format
  13. // @author PY-DNG
  14. // @license GPL-3.0-or-later
  15. // @match *://www.pixiv.net/*
  16. // @match *://pixiv.net/*
  17. // @connect pximg.net
  18. // @require https://update.greasyfork.org/scripts/456034/1348286/Basic%20Functions%20%28For%20userscripts%29.js
  19. // @require data:application/javascript,window.setImmediate%20%3D%20window.setImmediate%20%7C%7C%20((f%2C%20...args)%20%3D%3E%20window.setTimeout(()%20%3D%3E%20f(args)%2C%200))%3B
  20. // @require https://fastly.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js
  21. // @require https://fastly.jsdelivr.net/npm/ejs@3.1.9/ejs.min.js
  22. // @require https://fastly.jsdelivr.net/npm/jepub@2.1.4/dist/jepub.min.js
  23. // @icon data:image/png;base64,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
  24. // @grant GM_xmlhttpRequest
  25. // @grant GM_registerMenuCommand
  26. // @run-at document-start
  27. // ==/UserScript==
  28.  
  29. // @require https://fastly.jsdelivr.net/npm/setimmediate@1.0.5/setImmediate.min.js
  30. // @require https://fastly.jsdelivr.net/npm/jepub@2.1.4/dist/jepub.min.js
  31. // @require https://fastly.jsdelivr.net/npm/ejs@3.1.9/ejs.min.js
  32. // @require https://fastly.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js
  33.  
  34. /* global LogLevel DoLog Err $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager queueTask testChecker registerChecker loadFuncs */
  35. /* global jEpub, JSZip, ejs */
  36.  
  37. let PixivAPI = (function() {
  38. queueTask.GM_xmlhttpRequest = {
  39. sleep: 200,
  40. max: 10
  41. };
  42.  
  43. return {
  44. get, safeGet, utils: { toAbsURL, toSearch, queueTask },
  45.  
  46. // https://www.pixiv.net/ajax/novel/18673574
  47. novel: id => safeGet(`/ajax/novel/${id}`),
  48.  
  49. // https://www.pixiv.net/ajax/novel/7522350/insert_illusts?id%5B%5D=60139778-1&lang=zh&version=1efff679631a40a674235820806f7431d67065d9
  50. insert_illusts: (novel_id, illust_ids, lang='zh') => {
  51. const url = `/ajax/novel/${novel_id}/insert_illusts`;
  52. const query = { lang };
  53. if (Array.isArray(illust_ids)) {
  54. for (let i = 0; i < illust_ids.length; i++) {
  55. const id = illust_ids[i];
  56. query[`id[${i}]`] = id;
  57. }
  58. } else {
  59. query[`id[]`] = illust_ids;
  60. }
  61. return safeGet(url, query);
  62. },
  63.  
  64. // https://www.pixiv.net/ajax/novel/series/9649276?lang=zh&version=a48f2f681629909b885608393916b81989accf5b
  65. // 'version' removed due to unspecified meaning
  66. series: (id, lang='zh') => safeGet(`/ajax/novel/series/${id}`, { id, lang }),
  67.  
  68. // https://www.pixiv.net/ajax/novel/series_content/9649276?limit=30&last_order=0&order_by=asc
  69. series_content: (id, limit=30, last_order=0, order_by='asc') => safeGet(`/ajax/novel/series_content/${id}`, { limit, last_order, order_by }),
  70. };
  71.  
  72. function safeGet() {
  73. return queueTask(() => get.call(this, ...arguments), 'GM_xmlhttpRequest');
  74. }
  75.  
  76. function get(url, params, responseType='json', retry=2) {
  77. return new Promise((resolve, reject) => {
  78. GM_xmlhttpRequest({
  79. method: 'GET', responseType,
  80. headers: {
  81. Referer: /^(www\.)?pixiv\.net$/.test(location.host) ? location.href : 'https://www.pixiv.net/'
  82. },
  83. url: toAbsURL(url, params),
  84. onload: async res => res.status === 200 && (responseType !== 'json' || res.response?.error === false) ? resolve(res.response) : checkRetry(res),
  85. onerror: checkRetry
  86. });
  87.  
  88. async function checkRetry(err) {
  89. retry-- > 0 ? resolve(await get(url, params, responseType, retry)) : reject(err);
  90. }
  91. });
  92. }
  93.  
  94. function toAbsURL(pathname, searchOptions) {
  95. return new URL(pathname, `https://www.pixiv.net/`).href + (searchOptions ? `?${toSearch(searchOptions)}` : '');
  96. }
  97.  
  98. function toSearch(options) {
  99. return new URLSearchParams(options).toString()
  100. }
  101. }) ();
  102.  
  103. (async function __MAIN__() {
  104. 'use strict';
  105.  
  106. const CONST = {
  107. TextAllLang: {
  108. DEFAULT: 'zh-CN',
  109. 'zh-CN': {
  110. DownloadEpub: '下载当前小说Epub',
  111. DownloadEpub_Short: '下载Epub',
  112. DownloadEpub_Progress: 'Epub (C/A)',
  113. DownloadComplete: 'Epub下载完成',
  114. RestrictData: {"0":"Enable","1":"NotFound","2":"Mypixiv","3":"R18","4":"R18G","Enable":0,"NotFound":1,"Mypixiv":2,"R18":3,"R18G":4},
  115. RestrictInfo: {
  116. NotFound: "#%(order)は非公開作品です", // No translation provided by pixiv yet
  117. Mypixiv: '#%(order)是好P友限定作品',
  118. R18: '#%(order)是R-18作品',
  119. R18G: '#%(order)是R-18G作品'
  120. },
  121. UnvieableTitle: '该章节无法查看', // unused constance, deletable
  122. UnvieableContent: '此章节Pixiv并未开放查看,请到Pixiv网站或app检查该章节是否设置了阅读限制\n如果是R18/R18G阅读限制,可到Pixiv网站打开R18/R18G开关'
  123. }
  124. },
  125. GFURL: 'https://greasyfork.org/scripts/483999',
  126. GFAuthorURL: 'https://greasyfork.org/users/667968',
  127. Symbol: {
  128. CHAPTER_NOT_VIEWABLE: Symbol('CHAPTER_NOT_VIEWABLE')
  129. }
  130. };
  131.  
  132. // Init language
  133. const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT;
  134. CONST.Text = CONST.TextAllLang[i18n];
  135.  
  136. // @require fallbacks
  137. await Promise.all([
  138. { missing: typeof setImmediate === 'undefined', src: 'https://fastly.jsdelivr.net/npm/setimmediate@1.0.5/setImmediate.min.js' },
  139. { missing: typeof JSZip === 'undefined', src: 'https://fastly.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js' },
  140. { missing: typeof ejs === 'undefined', src: 'https://fastly.jsdelivr.net/npm/ejs@3.1.9/ejs.min.js' },
  141. { missing: typeof jEpub === 'undefined', src: 'https://fastly.jsdelivr.net/npm/jepub@2.1.4/dist/jepub.min.js' }
  142. ].filter(script => script.missing).map(src => new Promise((resolve, reject) => document.head.appendChild($$CrE({
  143. tagName: 'script',
  144. props: { src },
  145. listeners: [
  146. ['load', resolve],
  147. ['error', reject]
  148. ]
  149. })))));
  150.  
  151. // Progress
  152. const progress = {
  153. __finished: true, // {boolean} All tasks finished
  154. __cur: 0, // {number} current task number
  155. __all: 0, // {number} all tasks count
  156. __listeners: {},
  157. __id: 0,
  158.  
  159. start() {
  160. this.__finished = false;
  161. this.__cur = 0;
  162. this.__all = 0;
  163. Object.values(this.__listeners).forEach(l => l(this.__cur, this.__all, this.__finished));
  164. },
  165.  
  166. finish() {
  167. this.__finished = true;
  168. Object.values(this.__listeners).forEach(l => l(this.__cur, this.__all, this.__finished));
  169. },
  170.  
  171. update(cur, all=false) {
  172. this.__cur = cur;
  173. all !== false && (this.__all = all);
  174. Object.values(this.__listeners).forEach(l => l(cur, all, this.__finished));
  175. },
  176.  
  177. listen(l) {
  178. const id = this.__id++;
  179. this.__listeners[id] = l;
  180. return id;
  181. },
  182.  
  183. remove(id) {
  184. delete this.__listeners[id]
  185. },
  186.  
  187. get finished() {
  188. return this.__finished;
  189. },
  190. get cur() {
  191. return this.__cur;
  192. },
  193. get all() {
  194. return this.__all;
  195. },
  196. get listeners() {
  197. return this.__listeners;
  198. }
  199. };
  200.  
  201. // User Interface
  202. GM_registerMenuCommand(CONST.Text.DownloadEpub, downloadEpub);
  203. loadFuncs([{
  204. func: () => {
  205. detectDom({
  206. selector: 'main>section section',
  207. callback: section => {
  208. if (!testChecker({
  209. type: 'regpath',
  210. value: [
  211. /^\/novel\/show\.php$/,
  212. /^\/novel\/series\/\d+$/
  213. ]
  214. })) { return; }
  215.  
  216. const toolbar = section;
  217. const dlDiv = makeDownloadButton();
  218. toolbar.appendChild(dlDiv);
  219. }
  220. });
  221.  
  222. function makeDownloadButton() {
  223. const DOWNLOAD = '<svg class="epub-download-svg" viewBox="0 0 32 32" width="32" height="32">\n <mask id="mask">\n <rect x="0" y="0" width="32" height="32" fill="white"></rect>\n <path d="M21.358 6.7v6.39H27L16 25.7 5 13.09h5.642V6.7z"></path>\n </mask>\n <path d="M10.64 5.1c-1.104 0-2 .716-2 1.6v4.8H5c-.745 0-1.428.332-1.773.86s-.294 1.167.133 1.656l11 12.61c.374.43.987.685 1.64.685s1.266-.256 1.64-.685l11-12.61c.426-.49.477-1.127.133-1.656S27.745 11.5 27 11.5h-3.644V6.7c-.001-.883-.895-1.6-2-1.6z" mask="url(#mask)"></path>\n </svg>';
  224. const CANCEL = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"/></svg>'
  225. const STYLE = '.epub-download { margin-right: 20px; line-height: 32px; font-weight: 700; cursor: pointer; padding: 0px; background: none; border: none; } .epub-download-button { display: inline-block; padding: 0; color: inherit; background: none; height: 32px; line-height: 32px; border: none; font-weight: 700; cursor: pointer; } .epub-download-svg { vertical-align: middle; overflow: visible !important; margin-right: 4px; width: 12px; font-size: 0; -webkit-transition: fill .2s; transition: fill .2s; fill: currentColor; } .epub-download-span { vertical-align: middle; }';
  226. addStyle(STYLE, 'novel-epub-download');
  227.  
  228. const div = $$CrE({
  229. tagName: 'div',
  230. classes: 'epub-download',
  231. //listeners: [['click', e => download]]
  232. });
  233. const button = $$CrE({
  234. tagName: 'button',
  235. classes: 'epub-download-button',
  236. props: { innerHTML: DOWNLOAD }
  237. });
  238. const span = $$CrE({
  239. tagName: 'span',
  240. classes: 'epub-download-span',
  241. props: { innerText: CONST.Text.DownloadEpub_Short }
  242. });
  243. $AEL(div, 'click', download);
  244. button.appendChild(span);
  245. div.appendChild(button);
  246. return div;
  247.  
  248. function download() {
  249. if (!progress.finished) { return; }
  250. const listernerID = progress.listen((cur, all, finished) => {
  251. if (finished) {
  252. span.innerText = CONST.Text.DownloadComplete;
  253. progress.remove(listernerID);
  254. } else {
  255. const text = replaceText(CONST.Text.DownloadEpub_Progress, { C: cur, A: all });
  256. span.innerText = text;
  257. }
  258. })
  259. downloadEpub();
  260. }
  261. }
  262. }
  263. }])
  264.  
  265. function downloadEpub() {
  266. const pathname = location.pathname;
  267.  
  268. // Novel series
  269. // https://www.pixiv.net/novel/series/9649276
  270. /^\/novel\/series\/\d+$/.test(pathname) && downloadSeries();
  271.  
  272. // Novel
  273. // https://www.pixiv.net/novel/show.php?id=18673574
  274. /^\/novel\/show\.php$/.test(pathname) && downloadNovel();
  275. }
  276.  
  277. async function downloadSeries() {
  278. DoLog('Start download series');
  279. progress.start();
  280.  
  281. progress.update(1, 5);
  282. const id = location.pathname.split('/').pop();
  283. const epub = new jEpub();
  284.  
  285. // Get series data
  286. const series = (await PixivAPI.series(id)).body;
  287. await initEpub(epub, series);
  288.  
  289. // List all novels
  290. progress.update(2, 5);
  291. const promises = [];
  292. for (let index = 0; index < series.total; index += 30) {
  293. const promise = PixivAPI.series_content(id, 30, index);
  294. promises.push(promise);
  295. }
  296. const list = (await Promise.all(promises)).reduce((l, json) => ((l.push(...json.body.page.seriesContents), l)), []);
  297. DoLog(list);
  298.  
  299. progress.update(3, 5);
  300. const novel_datas = await Promise.all(list.map(
  301. async novel => (
  302. novel.series.viewableType == 0 ? (
  303. (await PixivAPI.novel(novel.id)).body
  304. ) : {
  305. unviewable: CONST.Symbol.CHAPTER_NOT_VIEWABLE,
  306. series: novel.series
  307. }
  308. )
  309. ));
  310. DoLog(novel_datas);
  311.  
  312. /* Now loading chapter and adding loaded chapter has been separated, use Promise.all to speed up the process
  313. // Add chapters one by one
  314. // Do not use promise.all, because that will break the order
  315. for (const data of novel_datas) {
  316. await addChapter(epub, data);
  317. }
  318. //await Promise.all(novel_datas.map(async data => await addChapter(epub, data)));
  319. */
  320. // Load all chapters asynchronously and Add them to epub at once
  321. progress.update(4, 5);
  322. const chapters = await Promise.all(novel_datas.map(data => loadChapter(epub, data)));
  323. chapters.forEach(chapter => addLoaded(epub, chapter));
  324. DoLog(chapters);
  325. DoLog('Saving Epub');
  326.  
  327. progress.update(5, 5);
  328. saveEpub(epub, series.title + '.epub', () => progress.finish());
  329. }
  330.  
  331. async function downloadNovel() {
  332. DoLog('Start download novel');
  333. progress.start();
  334. progress.update(1, 2);
  335.  
  336. const id = getUrlArgv('id');
  337. const json = await PixivAPI.novel(id);
  338. const data = json.body;
  339.  
  340. const epub = new jEpub();
  341. await Promise.all([initEpub(epub, data), addChapter(epub, data)]);
  342. progress.update(2, 2);
  343.  
  344. saveEpub(epub, data.title + '.epub', () => progress.finish());
  345. }
  346.  
  347. // Compatible with PixivAPI.novel / PixivAPI.series
  348. async function initEpub(epub, data) {
  349. const html_link = `<a href="${htmlEncode(location.href)}" title="${htmlEncode(data.extraData.meta.title)}">${htmlEncode(location.href)}</a>`;
  350. const html_desc = `<div content-role="source">Pixiv link: ${html_link}</div><div content-role="description">${data.description || data.caption || ''}</div>`;
  351. const html_note = `EPUB generated from: ${html_link}</br>By <a href="${htmlEncode(CONST.GFURL)}">${htmlEncode(GM_info.script.name)}</a> author <a href="${htmlEncode(CONST.GFAuthorURL)}">${htmlEncode(GM_info.script.author)}</a></br></br>Copyright belongs to the article author. Please comply with relevant legal requirements while reading and distributing this file.`;
  352. epub.init({
  353. i18n: 'en',
  354. title: data.title,
  355. author: data.userName,
  356. publisher: '',
  357. description: html_desc,
  358. tags: Array.isArray(data.tags) ? data.tags : data.tags.tags.map(tag => tag.tag)
  359. });
  360. epub.date(new Date(data.uploadDate || data.lastPublishedContentTimestamp));
  361. epub.notes(html_note);
  362.  
  363. const coverUrl = data.coverUrl || data.cover.urls.original;
  364. const cover = await PixivAPI.safeGet(coverUrl, null, 'blob');
  365. epub.cover(cover);
  366.  
  367. return epub;
  368. }
  369.  
  370. // Load chapter assets and generate { title, content } (that ready to epub.add) which is called a 'chapter'
  371. async function loadChapter(epub, data) {
  372. if (data?.unviewable === CONST.Symbol.CHAPTER_NOT_VIEWABLE) {
  373. const texthint = replaceText(CONST.Text.RestrictInfo[CONST.Text.RestrictData[data.series.viewableType]], { '%(order)': data.series.contentOrder });
  374. return {
  375. title: texthint,
  376. content: `<p>${htmlEncode(CONST.Text.UnvieableContent).replace('\n', '<br>')}</p>`
  377. };
  378. }
  379.  
  380. let content = data.content;
  381.  
  382. // Load images
  383. const imagePromises = [];
  384. content = content.replace(/\[uploadedimage:([\d\-]+)\]/g, (match_str, id) => {
  385. const url = data.textEmbeddedImages[id].urls.original;
  386. const promise = PixivAPI.safeGet(url, null, 'blob').then(blob => epub.image(blob, id));//.catch(err => );
  387. imagePromises.push(promise);
  388. return `\n<%= image[${id}] %>\n`;
  389. });
  390. const illusts = Array.from(new Set( [...content.matchAll(/\[pixivimage:([\d\-]+)\]/g)] ));
  391. if (illusts.length) {
  392. const illustsJson = await PixivAPI.insert_illusts(data.id, illusts.map(match => match[1]));
  393. illusts.forEach(illust => {
  394. const id = illust[1];
  395. if (illustsJson.body[id].visible) {
  396. const url = illustsJson.body[id].illust.images.original;
  397. const promise = PixivAPI.safeGet(url, null, 'blob').then(blob => epub.image(blob, id));//.catch(err => );
  398. imagePromises.push(promise);
  399. content = content.replaceAll(illust[0], `\n<%= image[${escJsStr(id)}] %>\n`);
  400. }
  401. });
  402. }
  403. await Promise.all(imagePromises);
  404.  
  405. // Parse '[[rb:久世彩葉 > くぜ いろは]]' // 10618179
  406. content = content.replace(/\[\[rb:([^\[\]]+) *> *([^\[\]]+)\]\]/g, (match_str, main, desc) => {
  407. return `<ruby>${htmlEncode(main)}<rp>(</rp><rt>${htmlEncode(desc)}</rt><rp>)</rp></ruby>`;
  408. });
  409.  
  410. // Parse '[chapter:【プロローグ】]' // 21893883
  411. content = content.replace(/\[chapter: *([^\]]+)\]/g, (match_str, chapterName) => {
  412. return `<h2>${chapterName}</h2>`;
  413. });
  414.  
  415. // Parse '[[jumpuri:捕虜の待遇に関する千九百四十九年八月十二日のジュネーヴ条約(第三条約)【日本国防衛省ホームページより】 > https://www.mod.go.jp/j/presiding/treaty/geneva/geneva3.html]]' // 19912145#12
  416. content = content.replace(/\[\[jumpuri:([^\[\]]+) *> *([^\[\]]+)\]\]/g, (match_str, text, url) => {
  417. return `<a href=${escJsStr(url)}>${htmlEncode(text)}</a>`;
  418. });
  419.  
  420. // Parse '[jump:2]' // 22003928
  421. content = content.replace(/\[jump:(\d+)\]/g, (match_str, page) => {
  422. return `<a href=${escJsStr(`#ChapterPage-${page}`)}>Jump to page ${htmlEncode(page)}</a>`;
  423. });
  424.  
  425. // Check undealed markers
  426. let markers = Array.from(content.matchAll(/\[+[^\[\]]+\]+/g));
  427. markers = markers.filter(match => {
  428. // remove dealed images
  429. const pattern = match.input.substring(match.index-9, match.index + match[0].length+3);
  430. const isImagePattern = pattern.startsWith('<%= image[') && pattern.endsWith('] %>');
  431.  
  432. // remove [newpage]s
  433. const isNewpagePattern = match[0].includes('[newpage]'); // Why .include: for matches like '[xxx[[newpage]]]blabla]]'
  434. return !isImagePattern && !isNewpagePattern;
  435. });
  436. markers.length && DoLog(LogLevel.Warning, {
  437. message: 'Undealed markers found',
  438. chapter: data,
  439. markers
  440. });
  441.  
  442. // Up to 4 connected newlines (3 empty lines between paragraphs) at once
  443. content = content.replaceAll(/\n{4,}/g, '\n'.repeat(4));
  444.  
  445. // Parse '[newpage]' & Covert into html
  446. const pageCounter = (start => {
  447. let num = start;
  448. return () => start++;
  449. }) (1);
  450.  
  451. content = content.split('[newpage]').map(subContent => {
  452. // Split content into pages and wrap each page's lines into <p>s
  453. return subContent.split('\n').map(line => line.trim() ? `<p>${line}</p>` : '<br>').join('\n');
  454. }).map(pageHTML => {
  455. const page = pageCounter();
  456. const page_id = `ChapterPage-${page}`;
  457.  
  458. // Remove <br>s at beggining and ending of each page
  459. pageHTML = pageHTML.replaceAll(/^(<br>|\s)+/g, '').replaceAll(/(<br>|\s)+$/g, '');
  460.  
  461. // Add page number to start and end of each page
  462. const pageNum = `<div class="ChapterBlockMarker">Page ${page}</div>`;
  463. pageHTML = `${pageNum}\n${pageHTML}\n${pageNum}`;
  464.  
  465. // Wrap each page's html in <div id=pageID>
  466. return `<div id=${escJsStr(page_id)} class="ChapterContentBlock">\n${pageHTML}\n</div>`;
  467. }).join('\n');
  468.  
  469. // Add description to chapter beginning
  470. let description = data.description;
  471. description = description
  472. .replace(/(<br \/>)+/g, '<br>').split('<br>')
  473. .filter(line => line.trim().length)
  474. .map(line => `<p>${line}</p>`)
  475. .join('\n');
  476. description = `<div id="ChapterDescription" class="ChapterContentBlock">${description}</div>\n`;
  477. content = description + content;
  478.  
  479. // Add cover image to chapter beginning
  480. const cover = await PixivAPI.safeGet(data.coverUrl, null, 'blob');
  481. const coverId = `ChapterCover-${data.id}`;
  482. epub.image(cover, coverId);
  483. content = `\n<%= image[${escJsStr(coverId)}] %>\n` + content;
  484.  
  485. // Add style
  486. content = '<style>.ChapterContentBlock { border-bottom: solid; padding: 1em 0; } .ChapterBlockMarker { font-size: 1em; text-align: right; }</style>' + content;
  487.  
  488. return {
  489. title: data.title,
  490. content
  491. };
  492. }
  493.  
  494. function addLoaded(epub, chapter) {
  495. epub.add(chapter.title, chapter.content);
  496. }
  497.  
  498. async function addChapter(epub, data) {
  499. const chapter = await loadChapter(epub, data);
  500. addLoaded(epub, chapter);
  501. }
  502.  
  503. async function saveEpub(epub, filename, callback=function() {}) {
  504. const blob = await epub.generate('blob');
  505. const url = URL.createObjectURL(blob);
  506. dl_browser(url, filename);
  507. setTimeout(() => {
  508. URL.revokeObjectURL(url);
  509. callback();
  510. });
  511. }
  512.  
  513. function htmlEncode(text, encodes = '<>\'";&#') {
  514. return Array.from(text).map(char => !encodes || encodes.includes(char) ? `&#${char.charCodeAt(0)};` : char).join('');
  515. }
  516.  
  517. // Pixiv's js hooked original EventTarget.prototype.addEventListener, using this function to bypass
  518. function $AEL(elm, ...args) {
  519. if (!$AEL.addEventListener) {
  520. const ifr = $$CrE({
  521. tagName: 'iframe',
  522. styles: {
  523. border: 'none',
  524. padding: 'none',
  525. width: '0',
  526. height: '0',
  527. 'z-index': '-9999999',
  528. },
  529. props: {
  530. 'srcdoc': '<html></html>'
  531. }
  532. });
  533. document.body.appendChild(ifr);
  534. $AEL.addEventListener = ifr.contentWindow.EventTarget.prototype.addEventListener;
  535. }
  536. return $AEL.addEventListener.apply(elm, args);
  537. }
  538. })();