浏览器功能定制2:显示网页正文的阅读模式

在《浏览器功能定制1:将网页内容保存为 Markdown》中,为了保证 HTML 转 Markdown 的效果,其中有一步,通过提取网页正文来简化 HTML 结构。

这让我意识到,这一步应当提取出来,作为一个单独的步骤,实现一种类似『阅读模式』。

之所以说“类似”的阅读模式,与浏览器所自带的阅读模式不同。浏览器自带的阅读模式,会滤出掉文章的所有样式,转为一种统一的“文章”样式进行展示。

而我的实现方案更加简单一些:识别出文章所在的 div,将这个元素充满屏幕展示即可,换句话说,隐藏掉出文章 div 外的其它内容。

在本文中,记录了实现这个方案进行的探索。

识别网页正文

在《浏览器功能定制1:将网页内容保存为 Markdown》中,我调研了许多网页正文提取库。这些网页正文提取库,能够根据一定的算法,将 HTML 的正文提取出来。

但是不太符合我的需求,我需要的不是纯文字,而是代表文章正文的 div。goose3 和 gne 都具备文本提取,以及对应的 div 的 HTML 提取,非常方便。

2023-11-08:在《浏览器功能定制4:qutebrowser userscipts机制及自带readability》中,我了解到了 qutebrowser 内置的阅读模式实现方案,其中同时提供了 readability 库的 Python 和 JavaScript 实现。而 JavaScript 实现由 mozilla 官方实现,看起来要更好一些。

调研

TODO:将《浏览器功能定制1:将网页内容保存为 Markdown》中的调研搬至这里。

使用 readability.js 提取正文

经过一番调研,mozilla/readability 是最香的,能获得 Firefox 同款。其他 Python 的移植版本都没有 JavaScript 版本香。

我打算用它作为网络正文提取器。在《浏览器功能定制4:qutebrowser userscipts机制及自带readability》中提到,qutebrowser 通过 userscripts 机制是提供网络正文提取器(阅读模式)的,并且可以选择基于 JavaScript 版本的 readability。

但是我打算重新实现,原因是 userscripts 跟我的需求不太吻合。它默认行为会触发打开一个新 Tab。而我需要的是一个命令行工具,通过 Python 脚本调用,并返回给我处理后的结果。

于是寻找 readability.js 命令行版的封装,找到一个 phxql/readability-cli。实际使用中,我发现它在处理管道输入时有点问题,会报错 EAGAIN。需要用如下代码替换:

const run = (url) => {
  let data = '';
  process.stdin.setEncoding('utf-8');

  process.stdin.on('readable', () => {
    let chunk;
    while ((chunk = process.stdin.read()) !== null) {
      data += chunk;
    }
  });

  process.stdin.on('end', () => {
    const sanitizedDom = DOMPurify.sanitize(data, purifyOptions);
    if (debug) {
      console.error('url: ', url)
    }
    const options = {
      features: {
        FetchExternalResources: false,
        ProcessExternalresources: false,
      },
      virtualConsole: jsdomConsole,
      url: url,
    };

    const article = readability(new JSDOM(sanitizedDom, options), url);
    if (article == null) {
      process.exit(1);
    } else {
      console.log(JSON.stringify(article));
    }
  });
};

Python 侧的调用代码如下:

def readability_js(html: str, url: str):
    print("=======readability_js=======")
    # print(html)
    # 指定index.js脚本的完整路径
    script_path = "/home/maxiee/Code/readability-cli/index.js"

    # 构建命令,确保使用绝对路径调用node脚本
    command = ["node", script_path, url]
    # 开启进程
    with subprocess.Popen(
        command,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True
    ) as process:
        # 发送输入数据并获取输出
        stdout, stderr = process.communicate(input=html)

        # 打印标准输出和错误(如果有)
        print(stdout)
        if stderr:
            print("Error:", stderr)

        # 打印返回码
        print("Return code:", process.returncode)
        ret = json.loads(stdout)
        return ret['content']

实际使用下来,效果令人满意!

正文提取库的实际效果

我发现,正文提取不是每个站都能成功。比如 goose3,在有的站点能够成功,有的站点则解析失败。

好在我发现,有时 goose3 解不出来的站点,用 gne 能解出来。(也许 gne 更加强大,我应该调换下顺序,先 gne,后用 goose3 做兜底尝试)。

因此,我选择同时采用两个提取器,以加大提取成功率。

代码实现如下:

@cmdutils.register(instance='command-dispatcher', scope='window')
def main_content(self):
    cur_tab: browsertab.AbstractTab = self._cntwidget()
    url = self._current_url()

    def on_html(html: str):
        from goose3 import Goose
        # 首先尝试使用 Goose 解析
        g = Goose()
        article = g.extract(raw_html=html)
        content_html = article.top_node_raw_html
        # 如果没解出来,再尝试使用 GNE 解析
        if content_html is None:
            from gne import GeneralNewsExtractor
            extractor = GeneralNewsExtractor()
            result = extractor.extract(
			            html=html, 
			            with_body_html=True)
            content_html = result['body_html']

        print(content_html)

    cur_tab.dump_async(on_html)

这会自动在 qutebrowser 中创建一条指令 main-content。通过键盘即可触发,非常 Geek style。

更新页面

找到正文后,接下来该是更新页面内容,忽略掉其他元素,只展示正文部分。

我最直接的想法,是拿到 content_html 后直接 set_html。尝试了一下,怎么说呢,正文是展示出来了,但是比较错乱,可读性比较差。

此时应该有一个 html simplify 的过程。去掉各种样式,恢复为最朴素的 HTML(对的,我希望的就是最朴素的 HTML)。

html simplify

借助于 GPT 和 BeautifulSoup,我实现了一个 html 简化算法:

def html_simplify(html: str):
    # 使用 BeautifulSoup 解析 HTML
    soup = BeautifulSoup(html, 'html.parser')

    # 遍历所有元素并删除 'style' 属性,限制图片大小
    for element in soup.recursiveChildGenerator():
        if isinstance(element, Tag):
            # 删除样式属性
            if 'style' in element.attrs:
                del element.attrs['style']

            # 如果是 img 标签,则设置宽度属性
            if element.name == 'img':
                element.attrs['style'] = 'max-width: 80%; height: auto;'

    cleaned_html = soup.prettify()
    print(cleaned_html)
    # 输出清理后的 HTML
    return cleaned_html

main_content 的回调中,再加一行 set_html

cur_tab.set_html(html_simplify(content_html))

效果

我本担心 set_html 会不会过于粗暴。

实际验证下来,效果非常不错!

以本数字花园的文章为例,正文阅读模式的效果:

Pasted image 20231105000602.png|600

是不是很不错!

有了如此干净的 HTML,后续《浏览器功能定制1:将网页内容保存为 Markdown》转 Markdown 时也会简单很多。

对于日常阅读,我喜欢这样的极简样式,能够让我更加专注、严肃地阅读。

今天收获满满,希望我的探索也对你有所启发、帮助。


本文作者:Maeiee

本文链接:浏览器功能定制2:显示网页正文的阅读模式

版权声明:如无特别声明,本文即为原创文章,版权归 Maeiee 所有,未经允许不得转载!


喜欢我文章的朋友请随缘打赏,鼓励我创作更多更好的作品!