Web 到 EPUB 扩展(Chrome 版)
Web 到 EPUB 扩展(Chrome 版)
引言
如果您和我一样,您会发现互联网上有许多网站提供免费的虚构作品阅读。例如,http://archiveofourown.org。不幸的是,这些网站大多数有两个小缺点。首先,因为它们是在线网站,所以您需要联网才能查看它们。其次,每篇故事的单个章节(而精彩的故事几乎总是有多个章节)分布在多个网页上。因此,您需要来回切换网页来阅读故事。
因此,在阅读了这些故事的第一个章节后,我常常希望我能点击浏览器上的一个按钮,然后将整个故事保存到一个 EPUB 文件中,放到我的下载目录里,方便复制到我的 EPUB 查看器中。
事实证明,可以为 Google 的 Chrome 浏览器编写“扩展”,让您可以为 Chrome 添加额外的功能。所以,我利用这一点创建了一个“保存到 EPUB”插件,现在我将向您展示它是如何实现的,以便您可以自己使用它,并根据您喜欢的网站进行扩展。
涵盖的主题有:
- 构建 Chrome 扩展程序的基础知识。
- 如何让您的扩展程序读取 Chrome 标签页中的网页 HTML。
- 如何使用 Chrome 检查网页以找出故事的作者、标题和章节列表,然后编写 JavaScript 来提取信息。
- 如何让 Chrome 自动获取其他网页。
- 如何将网页打包成 EPUB 文件。
- 关于单元测试的一些说明。
- 如何为喜欢的网站添加解析器。
使用代码或“如何将扩展程序加载到 Chrome 中”
如果您只想使用代码,基本步骤是:
- 下载此扩展程序的代码。
- 将“plugin”文件夹中的扩展程序加载到 Chrome 中。(参见下文。)
- 访问您想要提取故事的网页。请注意,目前唯一支持的网站是 Archive of Our Own。所以,尝试从那里加载一个故事进行测试。
- 点击 Chrome 工具栏上现在出现的“Web To EPUB”操作按钮
。(
)
- 检查弹出窗口中的故事详情是否正确。
- 按“获取章节”按钮并等待它们加载。
- 按“打包 EPUB”按钮。
将扩展程序加载到 Chrome 中的步骤是:
- 打开 Chrome,然后在浏览器中输入“chrome://extensions”。
- 确保页面顶部的“开发者模式”已勾选。
- 按“加载未打包的扩展程序..”按钮,然后浏览到扩展程序文件的根文件夹。
构建 Chrome 扩展程序的基础知识
您可以在 http://developer.chrome.com/extensions/getstarted.htm 找到 Google 关于构建扩展程序的优秀文档。
但是,这里有一些关键点。
- 扩展程序是 HTML 和 JavaScript 的混合体。
- 扩展程序包含以下内容:
- 一个 manifest 文件,这是一个描述扩展程序的 JSON 文件。
- 一组构成扩展程序的 HTML、CSS 和 JavaScript 文件。
Web To EPUB 扩展程序的 manifest 文件如下所示:
{
"manifest_version": 2,
"name": "WebToEpub",
"version": "1",
"icons": { "128": "book128.png" },
"permissions": ["tabs", "<all_urls>" ],
"browser_action": {
"default_title": "",
"default_icon": "book128.png",
"default_popup": "popup.html"
},
"minimum_chrome_version": "46"
}
主要值得注意的点是:
- 该扩展程序在 Chrome 工具栏上放置了一个按钮,该按钮使用 book128.png
作为按钮上的图像。
- 按下按钮后,会启动“popup.html”网页。因此,popup.html 是此扩展程序的“主”页面。
- 扩展程序需要以下权限才能工作:
tabs
:可以操作 Chrome 显示的网页(即标签页)的内容。all_urls
:可以向任何 URL 发起 HTTP 请求。
Popup.html 内容如下:
<html lang="en">
<head>
<meta charset="utf-8">
<title>WebToEpub</title>
<base />
</head>
<body>
<style>
body {
}
.scrollingtable {
overflow: scroll;
height: 300px;
}
</style>
<section id="inputSection">
<table id="inputTable">
<tr>
<td>Starting URL</td>
<td><input id="startingUrlInput" type="url" name="startingUrlInput" size="80" /></td>
</tr>
<tr>
<td>Title</td>
<td><input id="titleInput" type="text" name="titleInput" size="80" /></td>
</tr>
<tr>
<td>Author</td>
<td><input id="authorInput" type="text" name="authorInput" size="80" /></td>
</tr>
<tr>
<td>Language</td>
<td><input id="languageInput" type="text" name="languageInput" size="80" /></td>
</tr>
</table>
<button id=fetchChaptersButton>Fetch Chapters</button>
<button id=packEpubButton>Pack EPUB</button>
</section>
<section id="testSection"></section>
<section id="outputSection">
<div class="scrollingtable">
<table id=chapterUrlsTable>
<tr>
<th align=left>Title</th>
<th align=left>Loaded?</th>
<th align=left>URL</th>
</tr>
</table>
</div>
</section>
<!-- scripts go here -->
<script src="js/EpubMetaInfo.js"></script>
<script src="js/Util.js"></script>
<script src="js/HttpClient.js"></script>
<script src="js/parsers/ArchiveOfOurOwnParser.js"></script>
<script src="js-lib/jszip.min.js"></script>
<script src="js/EpubPacker.js"></script>
<script src="js/testFunctions.js"></script>
<script src="js/main.js"></script>
</body>
</html>
正如您所见,这是一个普通的 HTML 文件,包含显示故事关键属性的字段:标题、作者、语言和章节。唯一稍微不寻常的地方是它没有嵌入 JavaScript。
如何读取 Chrome 标签页中的网页 HTML。
当此扩展程序激活时,它首先会查找 Chrome 中当前活动的标签页(网页),并搜索其中的故事信息。基本步骤是:
- 在 manifest.json 中添加“tabs”权限。(我们在上一步中已完成此操作。)
- 当 popup.html 启动时,会调用一个附加到
onload()
事件的函数。 - 此函数指示 Chrome 将“内容脚本”注入到活动的网页中。
- 内容脚本是一个简单的 JavaScript 文件,它在活动的服务器页面的上下文中运行,因此可以访问页面的文档对象模型 (DOM)。
- 脚本读取 DOM 并将其序列化副本作为消息发送回 popup.html。
- Popup.html 侦听消息,该消息将异步返回。消息到达时,DOM 会被反序列化并解析,以提取故事信息来填充 popup.html。
- 请注意,由于消息是异步返回的,因此
onload()
函数需要在注入内容脚本之前设置消息侦听器。
内容脚本的内容如下所示:
// pack the DOM of this page into a message var parseResults = { messageType: "ParseResults", document: document.all[0].outerHTML, }; // send message back to our extension chrome.runtime.sendMessage(parseResults);
而 popup.html 中的 onload()
函数如下所示:
// actions to do when window opens window.onload = function () { // register listener that is called when content script injected into HTML sends its results chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) { if (request.messageType == "ParseResults") { // convert the string returned from content script back into a DOM let dom = new DOMParser().parseFromString(message.document, "text/html"); // pass the DOM onto our function to extract the story info (more on this later) processHtmlFromTab(dom); } }); // inject the content script into the active tab. // in this case, the content script is in a file called "ContentScript.js" chrome.tabs.executeScript({file: "js/ContentScript.js"}); }
这就是扩展程序读取 Chrome 中当前标签页内容所需的一切。有关正在发生的事情的更多详细信息,请查看 Google 关于 内容脚本和发送消息的文档。
使用 Chrome 的“检查元素”来检查 DOM
本节假定您知道什么是 DOM。如果您不知道,我建议您阅读 Eloquent Javascript 的第 13 章。继续,我等您。
欢迎回来。既然您知道什么是 DOM,并且我们在上一节中已经获得了网页的 DOM,那么下一步就是从 DOM 中提取我们需要的信息。我们想要的信息是:
- 故事的标题
- 故事的作者
- 每个章节的标题
- 每个章节的 URL
- 故事的语言。(注意,如果您只对美国故事感兴趣,可以跳过提取此信息,直接将其硬编码为“en-us”。如果您只对其他语言感兴趣,可以硬编码它们的语言代码。)
提取信息的第一个步骤是弄清楚如何在 DOM 中找到它。幸运的是,Chrome 内置了易于使用的工具。您只需要:
- 在 Chrome 中打开网页
- 将鼠标悬停在您感兴趣的项目上。
- 单击鼠标右键
- 选择“检查元素”。
- Chrome 将打开一个显示网页 DOM 的窗口,并将光标设置在您单击的元素上。
- 检查 DOM 以查看如何识别元素。
我将举例说明。首先加载页面 http://archiveofourown.org/works/685590/chapters/1258295,然后滚动它,直到您看到故事的标题和作者。它看起来会像这样:
右键单击“Judgement Day”并选择“检查元素”。“元素”窗口出现在屏幕底部,看起来像这样:
注意高亮显示的元素,这就是包含标题的元素。
<h2 class="title heading">
Judgement Day
</h2>
这表明标题在 <h2>
标签中,类名为“title heading”。如果我们现在单击网页上的作者姓名,将高亮显示以下 DOM 元素:
<a href="http://archiveofourown.org/users/TheUnknownJohnSmith/pseuds/TheUnknownJohnSmith" class="login author" rel="author">TheUnknownJohnSmith</a>
所以作者在一个“a”标签中,类名为“login author”。
最后,右键单击显示章节列表的下拉框。DOM 的该部分看起来是这样的:
<select id="selected_id" name="selected_id"><option value="1258295" selected="selected">1. Chapter 1</option>
<option value="1258298">2. Judgement Day Part II</option>
<option value="1457060">3. The Chariot</option>
<option value="1457063">4. The World</option>
<option value="1663608">5. Judgment</option>
<option value="2342893">6. Temperance</option></select>
如您所见,每个章节都在一个“option”标签中,其“value”属性是章节的 URL(相对于网页)。
查找故事的语言有点困难,因为它没有在网页上明确显示。但是,如果我们查看网页的源代码,当我们在页面上右键单击并选择“查看页面源代码”时,Chrome 将会显示,我们发现以下内容:
<meta name="keywords" content="fanfiction, transformative works, otw, fair use, archive">
<meta name="language" content="en-US">
<meta name="subject" content="fandom">
所以语言是“meta”标签的“content”属性,该“meta”标签的“name”属性是“language”。
现在我们知道所需信息在 DOM 中的位置,下一步就是提取它。让我们从标题开始。如前所述,标题在 <h2>
标签中,类名为“title heading”。因此,给定内容脚本返回的 DOM 对象,以下函数将提取标题:
extractTitle: function(dom) {
// first, get a list of all "h2" elements in the document
let elements = dom.getElementsByTagName("h2");
// getElementsByTagName() returns a HTMLCollection, convert it into an array
elements = Array.prototype.slice.apply(elements);
// remove all elements that do not have a class name of "title heading"
elements = elements.filter(e => e.className === "title heading");
// if the list has an element, then we've found the element we're looking for, so return its innerText.
return (elements.length != 0) ? elements[0].innerText() : null;
}
两个有趣的部分是 getElementsByTagName()
和 filter()
函数。
getElementsByTagName()
的作用正如其名称所示,它返回 DOM 中具有指定标签的所有元素。实际上,它更强大。除了搜索整个 DOM,如果您给它一个 DOM 元素,它将搜索该元素的子节点。如果您想查找 DOM 的某个部分内的特定子节点,这将非常有用。
`.filter()` 函数会移除不具有“title heading”className 的 `
元素。`filter()` 的要点是它接受一个对象数组和一个返回 true 或 false 的函数。(这在技术上称为谓词。)给定这两个输入,`filter()` 会构建并返回一个新数组,其中包含谓词返回 true 的所有元素。在上面的函数中,`e => e.className === "title heading"` 是谓词,我将其写成了较新版本 Chrome 支持的箭头函数表达式(它是 ECMAScript 6 标准的一部分)。这只是一个简写方式,表示:`function(e) { return e.className === "title heading"; }`。恭喜!您刚刚第一次接触了函数式编程。
既然我们已经看到了如何获取标题,那么获取作者应该是一个几乎相同的过程。唯一的区别是我们查找一个类名为 **“login author”** 的 `` 元素。
查找语言也很简单。
- 使用
getElementsByTagName()
查找 ` - 谓词必须测试一个具有“language”名称属性的元素。即 `e => (e.getAttribute("name") === "language")`。
- 语言值不是元素的 innerText,而是“content”属性的值,因此请调用
getAttribute()
来获取值。
查找和提取章节信息只稍微复杂一点。新出现的挑战是:
- 对于每个章节,我们想要章节标题和章节的 URL。
- 有多个章节。
返回章节的标题和 URL 很简单。一旦我们有了一个 `
optionToChapterInfo: function(optionElement) {
return {
sourceUrl: optionElement.getAttribute("value"),
title: optionElement.innerText
};
}
处理多个章节也很简单。如果您查看我们之前编写的 `extractTitle()` 函数,您会发现它处理了多个元素(至少直到最后一行,它只取第一个元素)。因此,我们可以使用该代码来获取 `
return elements.map(optionToChapterInfo);
`map()` 函数的作用:如果您有一个可以将一种类型的对象转换为新类型的函数(我们有,那就是我们之前编写的 `optionToChapterInfo`)和一个您想要转换的对象数组,它将从旧类型创建新类型对象的新数组。函数式编程太棒了。
以编程方式获取网页
现在我们有了章节的 URL 列表,下一步是获取它们。这可以使用 XMLHttpRequest 来完成。如果您查看了上面的链接,那么您只需要知道的是 Chrome 需要在扩展程序的 manifest 中有“<all_urls>
”权限。对于那些没有查看链接的人,基本步骤是:
- 创建一个
XMLHttpObject
对象 - 设置一个
onerror()
处理程序,以处理任何网络错误。 - 设置一个
onload()
处理程序(稍后详细介绍)。 - 调用
XMLHttpObject.send()
向服务器请求页面。 - 如果服务器响应,onload() 处理程序将在稍后异步调用。
- 在您的
onload()
中,检查服务器是否发送了错误。如果不是错误,则处理响应。
XMLHttpObject
的主要复杂之处在于,响应是异步的1,我们需要获取多个页面。这需要在 onload() 处理程序中使用异步、递归逻辑,该逻辑会检查已接收到哪个章节,然后调用 XMLHttpObject
,并在结果到达时将 onload()
处理程序设置为自身。我在这里不再尝试详细解释,因为它让我头疼。如果您想了解它是如何完成的,请查看此项目中 main.js 中的 onFetchChapters()
和 onLoadChapter()
。
注 1在某些情况下,您可以使用 XMLHttpRequest
同步运行,但这样做不好。有关详细信息,请阅读 XMLHttpRequest 文档。
创建 EPUB
此时,您已经获得了构成故事的网页以及故事的元数据(标题、作者、源 URL 和语言)。这就是创建故事 EPUB 所需的一切。有关完整的 EPUB 规范(当前版本为 3.0.1),请参阅在线规范。如果您觉得这些文档太长,维基百科有一个精彩的摘要。或者,您可以阅读我之前的文章,介绍如何构建一个 EPUB 阅读器。如果这仍然太长,以下是简述:“EPUB 是一个 zip 文件,其中包含网页和一些描述如何查看网页的文件。”
因此,如果我们想要创建 EPUB,我们需要做的第一件事是找到一种在 JavaScript 中创建 zip 文件的方法。我为此使用了 jszip 库,它非常易于使用。只需要三个步骤:
- 创建一个 JSZip 对象
- 在 JSZip 对象上调用
file()
将每个文件添加到其中。 - 调用
generate()
获取 zip 文件作为 blob。(然后您可以保存它。)
回到“描述如何查看网页的一些文件”,我们需要 4 个文件:
- mimetype:一个未压缩的 ASCII 文件,包含字符串
application/epub+zip
。 - container.xml,位于 META-INF 目录中:一个 XML 文件,指定 zip 中的哪个文件是 OPF 文件。
- OPF:一个 XML 文件,描述 zip 中的所有其他文件及其阅读顺序。
- 目录:一个 XML 文件,说明 zip 中每个章节的位置。
将 mimetype 文件添加到 zip 中很简单,因为它只是一个字符串,以下代码可以完成此任务:
zipFile.file("mimetype", "application/epub+zip");
添加 container.xml 文件几乎同样简单。因为我们将始终使用相同的名称作为我们的 OPF 文件,所以 container.xml 也是一个常量字符串。因此,添加 container.xml 的代码与 mimetype 相同,只是我们将“mimetype”替换为“META-INF/container.xml”,并将“application/epub+zip”替换为 container.xml 的 XML。
添加 OPF 文件并不难,尽管我们需要在使用故事信息之前对其进行构建。也就是说,OPF 文件是 XML,结构简单。我之前的文章中关于解析 EPUB 文件描述了 OPF 的结构,所以在此不再赘述。组装 XML 来构建此类结构很容易。如果您愿意,可以通过将文本插入字符串来完成。我建议不要那样做,当您插入的文本包含需要转义的字符时,它就会失败。使用 DOM 并向其添加元素同样简单,更安全,而且更容易理解。要了解如何操作,请查看 EpubPacker.js 中的 buildContentOpf()
函数。
一旦我们有了 OPF 文件,我们希望将其作为压缩文件添加到 zip 文件中。这很简单:
zipFile.file("content.opf", buildContentOpf(), { compression: "DEFLATE" });
与 OPF 类似,“目录”(ToC)也是一个简单的 XML 文件,在保存之前需要构建它。创建 ToC 没有什么新东西是您在 OPF 中没有看到过的,如果您想查看详细信息,请查看 EpubPacker.js 中的 buildTableOfContents()
。
下一步是将网页添加到 zip 文件中。有一个小问题。EPUB 2 规范要求这些网页是 XHTML,也称为 HTML 4。但从互联网上获取的网页通常是 HTML。我通过从抓取的网页中剥离实际的故事内容,将其插入到空白的 XHTML 文档中来解决这个问题,然后将该文档添加到 zip 文件中。作为一项有益的副作用,此过程通常也会剥离 JavaScript 和 CSS 文件链接。所以您不需要抓取这些文件并将其打包到 EPUB 中。
一旦所有文件都已添加到 zip 文件中,最后一步是生成一个 blob 并将该 blob 保存到您的浏览器“下载”文件夹。理想情况下,我们只需要调用 saveAs() 来完成此操作,但 Chrome 不再支持它。因此,我们可以使用 polyfill 库,或使用类似此处的代码:
saveAs: function (blob, fileName) {
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName;
a.click();
}
单元测试说明
这是我尝试编写的第三个 JavaScript 程序。我很快学到的一件事是,为编写的代码提供单元测试至关重要。了解您的 JavaScript 是否有效(甚至是否语法有效)的唯一方法是运行它。拥有有效的单元测试意味着我可以(通过重新)加载 unitTest 文件夹中的 Tests.html 网页来验证我刚刚编写的代码。也就是说,只需在 Chrome 中点击刷新。我不需要手动驱动扩展程序的 UI。这在第 200 次之后变得非常乏味。
- 这里使用的是 qunit 框架进行测试。这是一个轻量级、易于使用的框架,由 jQuery 创建并使用。
- 由于该插件的主要任务是解析 HTML,因此大多数测试都涉及加载一个已知的 HTML 文件,调用函数来解析 HTML,并检查函数是否返回了正确的结果。
- 因此,为了测试您的解析器,您可能需要下载示例网页并将其保存到磁盘进行测试。
- 默认情况下,Chrome 不允许扩展程序访问磁盘上的文件,因此您需要使用“
--allow-file-access-from-files
”标志启动 Chrome。警告,当您尝试使用此标志启动 Chrome 时,您不能有任何 Chrome 实例正在运行。
如何为喜欢的网站添加解析器
如果您已读到这里,我假设您想修改此扩展程序以保存您喜欢的网站的文件。因此,我将为您提供一些提示。
- 文件 ArchiveOfOurOwnParser.js 负责所有解析工作。因此,要处理另一个网站,您只需修改此文件。或者,复制一份并修改副本。
- ArchiveOfOurOwnParser.js 有三个函数会被扩展程序的其余部分调用,类中的其余函数只是实现了三个“接口”函数。
- 因此,要创建您自己的解析器,您需要实现这三个函数。它们是:
getEpubMetaInfo()
。这会接收一个 DOM 并返回一个EpubMetaInfo
。基本上,就是故事的作者、标题、语言和 URL。getChapterUrls()
。接收一个 DOM 并返回一个 chapterInfo 对象数组。extractContent()
。接收一个 DOM 并返回包含章节文本的 DOM 元素。
杂项
- 我要感谢我的同事 Paul、SeanO 和 Ross 在准备本文时提供的帮助。
- 本文根据 The Code Project Open License (CPOL) 获得许可。
- 该插件本身的代码根据 GPL v3 许可。
- 该扩展程序的最新代码可以在 GitHub 上找到。