diff --git a/eslint/.eslintrc.js b/eslint/.eslintrc.js index b90059399..05f0f6180 100644 --- a/eslint/.eslintrc.js +++ b/eslint/.eslintrc.js @@ -66,10 +66,11 @@ module.exports = { "EpubMetaInfo": "readonly", "EpubPacker": "readonly", "ErrorLog": "readonly", + "ExternalScriptLoader": "readonly", "FetchCache": "readonly", "FetchErrorHandler": "readonly", "FetchImageErrorHandler": "readonly", - "Firefox": "readonly", + "MV2Compat": "readonly", "FootnoteExtractor": "readonly", "HttpClient": "readonly", "ImageCollector": "readonly", diff --git a/eslint/pack.js b/eslint/pack.js index 53b6d8aa2..d2f5ca1df 100644 --- a/eslint/pack.js +++ b/eslint/pack.js @@ -141,14 +141,26 @@ var getLocaleFilesNames = function() { }); }; -var addPopupHtmlToZip = function(zip) { +var addPopupHtmlToZip = function(zip, stripOffstore) { return readFilePromise("../plugin/popup.html") .then(function(data) { - let htmlAsString = data.toString() - .split("\r") - .filter(s => !s.includes("/experimental/")) - .join("\r"); - zip.add("popup.html", new zipjs.TextReader(htmlAsString)); + let html = data.toString(); + // strip offstore UI block between BEGIN/END markers + if (stripOffstore) { + html = html.replace(/[\s\S]*?/g, ""); + } + // strip individual lines with /experimental/ or /offstore/ markers (for script tags) + let lines = html.split(/\r?\n/); + let filtered = lines.filter(function(s) { + if (s.includes("/experimental/")) { + return false; + } + if (stripOffstore && s.includes("/offstore/")) { + return false; + } + return true; + }); + zip.add("popup.html", new zipjs.TextReader(filtered.join("\n"))); }); }; @@ -169,7 +181,10 @@ var addCssFileToZip = function(zip, fileName) { return addBinaryFileToZip(zip, "../plugin/" + dest, dest); }; -var packNonManifestExtensionFiles = function(zip, packedFileName) { +var packNonManifestExtensionFiles = function(zip, packedFileName, stripOffstore) { + let fileFilterPattern = stripOffstore + ? /\/experimental\/|\/offstore\// + : /\/experimental\//; return addBinaryFileToZip(zip, "../plugin/book128.png", "book128.png") .then(function() { return addImageFileToZip(zip, "ChapterStateDownloading.svg"); @@ -194,12 +209,12 @@ var packNonManifestExtensionFiles = function(zip, packedFileName) { }).then(function(fileList) { return getLocaleFilesNames().then(function(localeNames) { return ["js/ContentScript.js"].concat(localeNames) - .concat(fileList.filter(n => !n.includes("/experimental/"))); + .concat(fileList.filter(n => !fileFilterPattern.test(n))); }); }).then(function(fileList) { return addFilesToZip(zip, fileList); }).then(function() { - return addPopupHtmlToZip(zip); + return addPopupHtmlToZip(zip, stripOffstore); }).then(function() { return writeZipToDisk(zip, packedFileName); }).then(function() { @@ -239,18 +254,53 @@ var makeManifestForChrome = function(data) { return manifest; }; -var packExtension = function(manifest, fileExtension) { +var makeManifestForChromeMV2 = function(data) { + let manifest = JSON.parse(data.toString()); + delete(manifest.incognito); + delete(manifest.browser_specific_settings); + delete(manifest.action.browser_style); + manifest.manifest_version = 2; + + // fix permissions/host_permissions + let permissions = manifest.permissions; + permissions = permissions.filter(p => p != "scripting"); + if (permissions.includes("webRequest") && !permissions.includes("webRequestBlocking")) { + permissions.push("webRequestBlocking"); + } + manifest.permissions = permissions.concat(manifest.host_permissions); + delete manifest.host_permissions; + + // rename action => browser_action + manifest.browser_action = manifest.action; + delete manifest.action; + + // allow eval for external script loading + manifest.content_security_policy = "script-src 'self' 'unsafe-eval'; object-src 'self'"; + return manifest; +}; + +var makeManifestForFirefoxOffstore = function(data) { + let manifest = makeManifestForFirefox(data); + manifest.content_security_policy = "script-src 'self' 'unsafe-eval'; object-src 'self'"; + return manifest; +}; + +var packExtension = function(manifest, fileExtension, stripOffstore) { let zipFileWriter = new zipjs.BlobWriter("application/epub+zip"); let zipWriter = new zipjs.ZipWriter(zipFileWriter, {useWebWorkers: false,compressionMethod: 8, extendedTimestamp: false}); zipWriter.add("manifest.json", new zipjs.TextReader(JSON.stringify(manifest))); - return packNonManifestExtensionFiles(zipWriter, "WebToEpub" + manifest.version + fileExtension); + return packNonManifestExtensionFiles(zipWriter, "WebToEpub" + manifest.version + fileExtension, stripOffstore); }; // pack the extensions for Chrome and firefox readFilePromise("../plugin/manifest.json") .then(function(data) { - packExtension(makeManifestForFirefox(data), ".xpi"); - packExtension(makeManifestForChrome(data), ".zip"); + // store builds (strip /offstore/ files and lines) + packExtension(makeManifestForFirefox(data), ".xpi", true); + packExtension(makeManifestForChrome(data), ".zip", true); + // off-store builds (keep /offstore/ files and lines, add unsafe-eval CSP) + packExtension(makeManifestForFirefoxOffstore(data), ".offstore.Firefox.xpi", false); + packExtension(makeManifestForChromeMV2(data), ".offstore.Chrome.zip", false); }).catch(function(err) { console.log(err); }); diff --git a/external-parsers/CiweimaoParser.js b/external-parsers/CiweimaoParser.js new file mode 100644 index 000000000..99b24b930 --- /dev/null +++ b/external-parsers/CiweimaoParser.js @@ -0,0 +1,317 @@ +parserFactory.register("www.ciweimao.com", () => new CiweimaoParser()); // wap.ciweimao.com has a different formating but has the same content as www.ciweimao.com + +class CiweimaoParser extends Parser { + static BASE_URL = "https://www.ciweimao.com"; + + constructor() { + super(); + this.minimumThrottle = 1500; + this.lockedChapterIds = new Set(); + } + + async getChapterUrls(dom) { + const payload = { + book_id: this.getBookId(dom), + chapter_id: "0", + orderby: "0", + }; + const options = { + method: "POST", + credentials: "include", + body: new URLSearchParams(payload), + }; + const newDom = ( + await HttpClient.wrapFetch( + `${CiweimaoParser.BASE_URL}/chapter/get_chapter_list_in_chapter_detail`, + { fetchOptions: options } + ) + ).responseXML; + + const menuWrapper = document.createElement("div"); + const chapterLists = newDom.querySelectorAll(".book-chapter-list"); + chapterLists.forEach((element) => + menuWrapper.appendChild(element.cloneNode(true)) + ); + + this.lockedChapterIds.clear(); + const chapterLinks = [ + ...menuWrapper.querySelectorAll("a[href*='/chapter/']"), + ]; + + const chapters = chapterLinks.map((link) => { + const sourceUrl = link.href; + const title = link.textContent.trim(); + + if (link.querySelector(".icon-lock")) { + // locked + return { + sourceUrl, + title, + isIncludeable: false, + }; + } else if (link.querySelector(".icon-unlock")) { + // accessable but img chapter + const chapterId = this.getChapterId(sourceUrl); + this.lockedChapterIds.add(chapterId); + return { + sourceUrl, + title, + }; + } else { + // free chatper + return { + sourceUrl, + title, + }; + } + }); + + return chapters; + } + + getBookId(dom) { + // book ID is the last part of the path in the base URI + return dom.baseURI.split("/").pop(); + } + + getChapterId(url) { + return url.split("/").pop(); + } + + extractTitleImpl(dom) { + // rm the author's name (in a span) from the main title + const title = dom.querySelector("h1.title"); + const clone = title.cloneNode(true); + clone.querySelector("span")?.remove(); + return clone; + } + + findContent(dom) { + return dom.querySelector("div"); + // We can also have images in the encrypted chapter_content. + // The content is in "#J_BookRead" + } + + async fetchChapter(url) { + const chapterId = this.getChapterId(url); + const rules = [ + { + id: 1, + priority: 1, + action: { + type: "modifyHeaders", + requestHeaders: [ + { + header: "referer", + operation: "set", + value: `${CiweimaoParser.BASE_URL}/chapter/${chapterId}`, + }, + { + header: "origin", + operation: "set", + value: CiweimaoParser.BASE_URL, + }, + ], + }, + condition: { + urlFilter: `*://${ + new URL(CiweimaoParser.BASE_URL).hostname + }/*`, + }, + }, + ]; + + await HttpClient.setDeclarativeNetRequestRules(rules); + + let chapterJson; + const payload = new URLSearchParams({ chapter_id: chapterId }); + const postOptions = { + method: "POST", + credentials: "include", + headers: { + Accept: "application/json, text/javascript, */*; q=0.01", + "X-Requested-With": "XMLHttpRequest", + }, + body: payload, + }; + + if (this.lockedChapterIds.has(chapterId)) { + // locked (image) + chapterJson = ( + await HttpClient.fetchJson( + `${CiweimaoParser.BASE_URL}/chapter/ajax_get_image_session_code`, + postOptions + ) + ).json; + } else { + // unlocked (text) + const { chapter_access_key } = ( + await HttpClient.fetchJson( + `${CiweimaoParser.BASE_URL}/chapter/ajax_get_session_code`, + postOptions + ) + ).json; + + payload.append("chapter_access_key", chapter_access_key); + const chapterDetailJson = ( + await HttpClient.fetchJson( + `${CiweimaoParser.BASE_URL}/chapter/get_book_chapter_detail_info`, + postOptions + ) + ).json; + + chapterJson = { ...chapterDetailJson, chapter_access_key }; + } + + return this.buildChapter(chapterJson, url); + } + + _base64ToArrayBuffer(base64) { + const binary_string = atob(base64); + const len = binary_string.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes.buffer; + } + + async _decryptChapterContentNative({ content, keys, accessKey }) { + const keysLength = keys.length; + const decryptionKeysB64 = []; + decryptionKeysB64.push( + keys[accessKey.charCodeAt(accessKey.length - 1) % keysLength] + ); + decryptionKeysB64.push(keys[accessKey.charCodeAt(0) % keysLength]); + + let currentContentB64 = content; + let finalDecryptedBuffer; + + for (const keyB64 of decryptionKeysB64) { + const keyData = this._base64ToArrayBuffer(keyB64); + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyData, + { name: "AES-CBC" }, + false, // not extractable + ["decrypt"] + ); + + const rawContentBuffer = + this._base64ToArrayBuffer(currentContentB64); + const iv = rawContentBuffer.slice(0, 16); + const ciphertext = rawContentBuffer.slice(16); + const decryptedBuffer = await crypto.subtle.decrypt( + { name: "AES-CBC", iv: iv }, + cryptoKey, + ciphertext + ); + + currentContentB64 = new TextDecoder("latin1").decode( + decryptedBuffer + ); + finalDecryptedBuffer = decryptedBuffer; + } + + return new TextDecoder("utf-8").decode(finalDecryptedBuffer); + } + + async buildChapter(json, url) { + const newDoc = Parser.makeEmptyDocForContent(url); + const chapterId = this.getChapterId(url); + + // locked image + if (this.lockedChapterIds.has(chapterId)) { + if (json.image_code && json.encryt_keys && json.access_key) { + // trigger server to gen full-height img + const heightUrl = new URL( + `${CiweimaoParser.BASE_URL}/chapter/get_book_chapter_image_height` + ); + // todo: tune + const imageOptions = { + chapter_id: chapterId, + area_width: 871, + font: "undefined", + font_size: 16, + bg_color_name: "white", + text_color_name: "white", + }; + heightUrl.search = new URLSearchParams(imageOptions).toString(); + await HttpClient.wrapFetch(heightUrl.href); // don't need the resp + + const decryptedImageCode = + await this._decryptChapterContentNative({ + content: json.image_code, + keys: json.encryt_keys, + accessKey: json.access_key, + }); + + const imageUrl = new URL( + `${CiweimaoParser.BASE_URL}/chapter/book_chapter_image` + ); + imageUrl.search = new URLSearchParams({ + ...imageOptions, + image_code: decryptedImageCode.trim(), + }).toString(); + + const img = newDoc.dom.createElement("img"); + img.src = imageUrl.href; + newDoc.content.appendChild(img); + } + // unlocked text + } else if ( + json.chapter_content && + json.encryt_keys && + json.chapter_access_key + ) { + const chapterText = await this._decryptChapterContentNative({ + content: json.chapter_content, + keys: json.encryt_keys, + accessKey: json.chapter_access_key, + }); + + const tmpDiv = newDoc.dom.createElement("div"); + tmpDiv.innerHTML = chapterText; + while (tmpDiv.firstChild) { + newDoc.content.appendChild(tmpDiv.firstChild); + } + } else { + const p = newDoc.dom.createElement("p"); + p.textContent = "Chapter content couldn't be loaded"; + newDoc.content.appendChild(p); + } + + return newDoc.dom; + } + + findCoverImageUrl(dom) { + return util.getFirstImgSrc(dom, "div.cover"); + } + + extractLanguage() { + return "zh-CN"; + } + + extractAuthor(dom) { + let authorLabel = dom.querySelector("h1.title > span"); + return authorLabel?.textContent ?? super.extractAuthor(dom); + } + + getInformationEpubItemChildNodes(dom) { + return [...dom.querySelectorAll(".book-bd")]; + } + + cleanInformationNode(node) { + const elementsToRemove = node.querySelectorAll( + ".book-tip, [style*=\"display:none\"]" + ); + elementsToRemove.forEach((el) => el.remove()); + return node; + } + + addTitleToContent(webPage, content) { + let h2 = webPage.rawDom.createElement("h2"); + h2.innerText = webPage.title.trim(); + content.prepend(h2); + } +} diff --git a/external-parsers/index.json b/external-parsers/index.json new file mode 100644 index 000000000..75e78b2bf --- /dev/null +++ b/external-parsers/index.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "updated": "2026-07-11", + "scripts": [ + { + "host": "www.ciweimao.com", + "url": "https://raw.githubusercontent.com/kuwoyuki/WebToEpub/refs/heads/feature/offstore-external-scripts/external-parsers/CiweimaoParser.js" + } + ] +} diff --git a/plugin/js/Firefox.js b/plugin/js/Firefox.js deleted file mode 100644 index 4f4874829..000000000 --- a/plugin/js/Firefox.js +++ /dev/null @@ -1,39 +0,0 @@ - -"use strict"; - - -/** Functions specific to Firefox version of plug-in */ -class Firefox { // eslint-disable-line no-unused-vars - constructor() { - } - - /** fetch() calls on Firefox include an origin header. - Which makes some sites fail with a CORS violation. - Need to use a webRequest to remove origin from header. - */ - static filterHeaders(e) { - return {requestHeaders: e.requestHeaders.filter( - h => ((h.name.toLowerCase() !== "origin") - || !h.value.startsWith("moz-extension://")) - )}; - } - - static startWebRequestListeners() { - browser.webRequest.onBeforeSendHeaders.addListener( - Firefox.filterHeaders, - {urls: [""]}, - ["blocking", "requestHeaders"] - ); - } - - static injectContentScript(tabId) { - chrome.tabs.executeScript(tabId, { file: "js/ContentScript.js", runAt: "document_end" }, - function(result) { // eslint-disable-line no-unused-vars - if (chrome.runtime.lastError) { - util.log(chrome.runtime.lastError.message); - } - } - ); - } -} - diff --git a/plugin/js/MV2Compat.js b/plugin/js/MV2Compat.js new file mode 100644 index 000000000..57b5ac9df --- /dev/null +++ b/plugin/js/MV2Compat.js @@ -0,0 +1,35 @@ +"use strict"; + +// Funcs for MV2 compat, used by both Firefox and Chrome MV2 builds +class MV2Compat { + constructor() {} + + static filterHeaders(e) { + return { + requestHeaders: e.requestHeaders.filter( + h => h.name.toLowerCase() !== "origin" || !h.value.startsWith("moz-extension://") + ) + }; + } + + static startWebRequestListeners() { + const api = (typeof browser !== "undefined") ? browser : chrome; + api.webRequest.onBeforeSendHeaders.addListener( + MV2Compat.filterHeaders, + { urls: [""] }, + ["blocking", "requestHeaders"] + ); + } + + static injectContentScript(tabId) { + chrome.tabs.executeScript( + tabId, + { file: "js/ContentScript.js", runAt: "document_end" }, + () => { + if (chrome.runtime.lastError) { + util.log(chrome.runtime.lastError.message); + } + } + ); + } +} diff --git a/plugin/js/UserPreferences.js b/plugin/js/UserPreferences.js index 1f1bd14d8..111f5a5e5 100644 --- a/plugin/js/UserPreferences.js +++ b/plugin/js/UserPreferences.js @@ -129,6 +129,14 @@ class UserPreferences { // eslint-disable-line no-unused-vars this.disableImageResError = this.addPreference("disableImageResError", "disableImageResErrorCheckbox", false); this.disableWebpImageFormatError = this.addPreference("disableWebpImageFormatError", "disableWebpImageFormatErrorCheckbox", false); + // External script preferences.. only available in off-store builds + // UI elements only exist when /offstore/ lines are present in popup.html + if (document.getElementById("externalScriptsEnabledCheckbox")) { + this.externalScriptsEnabled = this.addPreference("externalScriptsEnabled", "externalScriptsEnabledCheckbox", false); + this.externalScriptRepos = this.addPreference("externalScriptRepos", "externalScriptReposInput", ExternalScriptLoader.defaultRepoUrl); + this.externalScriptsAutoUpdate = this.addPreference("externalScriptsAutoUpdate", "externalScriptsAutoUpdateCheckbox", true); + } + document.getElementById("themeColorTag").addEventListener("change", UserPreferences.SetTheme); } diff --git a/plugin/js/Util.js b/plugin/js/Util.js index ae9414010..e049a3a9f 100644 --- a/plugin/js/Util.js +++ b/plugin/js/Util.js @@ -43,6 +43,15 @@ const util = (function() { } } + function isMV2() { + try { + const runtime = isFirefox() ? browser.runtime : chrome.runtime; + return runtime.getManifest().manifest_version === 2; + } catch (e) { + return false; + } + } + function extensionVersion() { let runtime = isFirefox() ? browser.runtime : chrome.runtime; // when running unit tests, runtime is not available @@ -1163,6 +1172,7 @@ const util = (function() { sleepController: sleepController, randomInteger: randomInteger, isFirefox: isFirefox, + isMV2: isMV2, extensionVersion: extensionVersion, createEmptyXhtmlDoc: createEmptyXhtmlDoc, createEmptyHtmlDoc: createEmptyHtmlDoc, diff --git a/plugin/js/main.js b/plugin/js/main.js index 09ee112e1..43b2cf08b 100644 --- a/plugin/js/main.js +++ b/plugin/js/main.js @@ -232,8 +232,8 @@ var main = (function() { } function injectContentScript(tabId) { - if (util.isFirefox()) { - Firefox.injectContentScript(tabId); + if (util.isMV2()) { + MV2Compat.injectContentScript(tabId); } else { chromeInjectContentScript(tabId); } @@ -637,10 +637,13 @@ var main = (function() { getAdvancedOptionsSection().hidden = !userPreferences.advancedOptionsVisibleByDefault.value; getAdditionalMetadataSection().hidden = !userPreferences.ShowMoreMetadataOptions.value; addEventHandlers(); - populateControls(); - if (util.isFirefox()) { - Firefox.startWebRequestListeners(); + if (util.isMV2()) { + MV2Compat.startWebRequestListeners(); + } + if (typeof ExternalScriptLoader !== "undefined") { + await ExternalScriptLoader.init(userPreferences); } + populateControls(); } else { await openTabWindow(); } diff --git a/plugin/js/offstore/ExternalScriptLoader.js b/plugin/js/offstore/ExternalScriptLoader.js new file mode 100644 index 000000000..00fc3f43b --- /dev/null +++ b/plugin/js/offstore/ExternalScriptLoader.js @@ -0,0 +1,188 @@ +"use strict"; + +/** + * Load external parser scripts from configurable remote repos + * + * File only included in off-store builds (path contains /offstore/) + * Store builds strip it out in pack.js + * + * Users can configure multiple repository index URLs in Options.json or via the UI. + * Each rpeo provides index.json listing parser script URLs + * Scripts are fetched as text and eval()'d in the popup context, giving them + * full access to parserFactory, HttpClient, util, and all other bundled APIs. + * + * External scripts can override bundled parsers if a script registers a + * parser for a host that already has a bundled parser, the external one wins + */ +class ExternalScriptLoader { // eslint-disable-line no-unused-vars + constructor() {} + + static get storageKey() { return "ExternalScriptCache"; } + + static get defaultRepoUrl() { + return "https://raw.githubusercontent.com/dteviot/WebToEpub/main/external-parsers/index.json"; + } + + static async init(userPreferences) { + if (!userPreferences?.externalScriptsEnabled?.value) { + console.debug("[ExternalScripts] Disabled"); + return; + } + + const repoUrls = ExternalScriptLoader.parseRepoUrls(userPreferences.externalScriptRepos?.value); + if (repoUrls.length === 0) { + console.warn("[ExternalScripts] No repository URLs configured"); + return; + } + + console.debug(`[ExternalScripts] Loading from ${repoUrls.length} repo(s)`, repoUrls); + + const statusEl = document.getElementById("externalScriptsStatus"); + let loadedCount = 0; + let errorCount = 0; + + for (const repoUrl of repoUrls) { + try { + const hosts = await ExternalScriptLoader.loadFromRepo(repoUrl, userPreferences); + loadedCount += hosts.length; + console.debug(`[ExternalScripts] Loaded ${hosts.length} parser(s) from ${repoUrl}`, hosts); + } catch (err) { + errorCount++; + console.error(`[ExternalScripts] Failed to load repo: ${repoUrl}`, err); + } + } + + const msg = `External scripts: ${loadedCount} parser(s) loaded${errorCount > 0 ? `, ${errorCount} repo(s) failed` : ""}`; + console.debug(`[ExternalScripts] ${msg}`); + if (statusEl) { + statusEl.textContent = msg; + statusEl.hidden = false; + } + } + + static parseRepoUrls(reposValue) { + if (!reposValue) return []; + return reposValue + .split(/[\n,]/) + .map(s => s.trim()) + .filter(s => s.length > 0 && !s.startsWith("#")); + } + + static async loadFromRepo(indexUrl, userPreferences) { + const index = await ExternalScriptLoader.fetchIndex(indexUrl); + const scripts = index.scripts ?? []; + console.debug(`[ExternalScripts] Index has ${scripts.length} script(s)`); + const loadedHosts = []; + const cache = await ExternalScriptLoader.loadCache(); + + for (const entry of scripts) { + const scriptUrl = entry?.url; + const host = entry?.host ?? scriptUrl; + if (!scriptUrl) { + console.warn("[ExternalScripts] Skipping entry with no \"url\" field", entry); + continue; + } + try { + console.debug(`[ExternalScripts] Loading: ${scriptUrl}`); + const scriptText = await ExternalScriptLoader.fetchScript(entry, cache, userPreferences); + if (!scriptText) { + console.warn(`[ExternalScripts] Empty script: ${scriptUrl}`); + continue; + } + ExternalScriptLoader.evalScript(scriptText, scriptUrl); + loadedHosts.push(host); + console.debug(`[ExternalScripts] Loaded parser for: ${host}`); + } catch (err) { + console.error(`[ExternalScripts] Failed: ${scriptUrl}`, err); + } + } + + await ExternalScriptLoader.saveCache(cache); + return loadedHosts; + } + + static async fetchIndex(indexUrl) { + const handler = await HttpClient.fetchJson(indexUrl); + const index = handler.json; + if (!index) { + throw new Error("Index response is null or invalid JSON"); + } + if (!index.version || index.version < 1) { + throw new Error(`Invalid index version: ${index.version}`); + } + return index; + } + + static async fetchScript(entry, cache, userPreferences) { + const scriptUrl = entry.url; + const cached = cache[scriptUrl]; + const shouldRefetch = !cached || userPreferences?.externalScriptsAutoUpdate?.value; + + if (shouldRefetch) { + console.debug(`[ExternalScripts] Fetching: ${scriptUrl}`); + try { + const scriptText = await HttpClient.fetchText(scriptUrl); + cache[scriptUrl] = { text: scriptText, fetchedAt: Date.now() }; + return scriptText; + } catch (err) { + if (cached) { + console.warn(`[ExternalScripts] Refetch failed, using cache: ${scriptUrl}`, err); + return cached.text; + } + throw err; + } + } + + console.debug(`[ExternalScripts] Cache hit: ${scriptUrl}`); + return cached.text; + } + + /** eval a fetched script, allowing it to override bundled parsers. */ + static evalScript(scriptText, sourceUrl) { + const originalRegister = parserFactory.register; + let overriddenHosts = []; + + parserFactory.register = (hostName, constructor) => { + try { + originalRegister.call(parserFactory, hostName, constructor); + } catch { + // duplicate, override the bundled parser + parserFactory.reregister(hostName, constructor); + overriddenHosts.push(hostName); + } + }; + + try { + // eslint-disable-next-line no-eval + eval(`${scriptText}\n//# sourceURL=${sourceUrl}`); + } catch (err) { + throw new Error(`Eval error in ${sourceUrl}: ${err.message}\nStack: ${err.stack}`); + } finally { + parserFactory.register = originalRegister; + } + + if (overriddenHosts.length > 0) { + console.warn(`[ExternalScripts] Overrode bundled parser(s) for: ${overriddenHosts.join(", ")}`); + } + } + + static loadCache() { + return new Promise((resolve) => { + chrome.storage.local.get(ExternalScriptLoader.storageKey, (result) => { + resolve(result[ExternalScriptLoader.storageKey] ?? {}); + }); + }); + } + + static saveCache(cache) { + return new Promise((resolve) => { + chrome.storage.local.set({ [ExternalScriptLoader.storageKey]: cache }, resolve); + }); + } + + static clearCache() { + return new Promise((resolve) => { + chrome.storage.local.remove(ExternalScriptLoader.storageKey, resolve); + }); + } +} diff --git a/plugin/popup.html b/plugin/popup.html index b7ebd89d1..e12d51a02 100644 --- a/plugin/popup.html +++ b/plugin/popup.html @@ -491,6 +491,26 @@

Instructions

+ + + + Load external parser scripts from remote repositories (off-store builds only) + + + External script repos (one URL per line): + + + + + + + Auto-update external scripts + + + + + + __MSG_label_Developer_Stuff__ @@ -599,7 +619,7 @@

Instructions

- + @@ -1023,6 +1043,7 @@

Instructions

+ diff --git a/unitTest/Tests.html b/unitTest/Tests.html index 4f6a2ff68..d20292b38 100644 --- a/unitTest/Tests.html +++ b/unitTest/Tests.html @@ -22,7 +22,7 @@ - + @@ -95,7 +95,7 @@ - + diff --git a/unitTest/UtestFirefox.js b/unitTest/UtestFirefox.js deleted file mode 100644 index 8c6d95de8..000000000 --- a/unitTest/UtestFirefox.js +++ /dev/null @@ -1,15 +0,0 @@ - -"use strict"; - -module("Firefox"); - - -test("filterHeaders", function (assert) { - let inData = [ - {name: "Host", value: "gravitytales.com" }, - {name: "origin", value: "moz-extension://580713a4-7df3-4412-8732-17dfef5a47bd" }, - {name: "origin", value: "http://gravitytales.com" } - ]; - let actual = Firefox.filterHeaders({requestHeaders: inData}); - assert.deepEqual(actual.requestHeaders, [inData[0], inData[2]]); -}); diff --git a/unitTest/UtestMV2Compat.js b/unitTest/UtestMV2Compat.js new file mode 100644 index 000000000..8f5722a32 --- /dev/null +++ b/unitTest/UtestMV2Compat.js @@ -0,0 +1,13 @@ +"use strict"; + +module("MV2Compat"); + +test("filterHeaders", (assert) => { + const inData = [ + { name: "Host", value: "gravitytales.com" }, + { name: "origin", value: "moz-extension://580713a4-7df3-4412-8732-17dfef5a47bd" }, + { name: "origin", value: "http://gravitytales.com" } + ]; + const actual = MV2Compat.filterHeaders({ requestHeaders: inData }); + assert.deepEqual(actual.requestHeaders, [inData[0], inData[2]]); +});