起因

在我的日常工作流中,read-later-list 是必备项,一直以来 我都是用 Notion Page 来记录和同步每天待看的文章

image-20230828215240130

(有很多的未读)(但是不重要)

由于标记了日期,也在回溯文章时有迹可循

可能就会有人问:那你为什么不用收藏夹 or 集锦?答案是删除时费工夫、不能根据时间检索、不能多端同步,同时 过于独立的页面也很难让人有想打开看的欲望,久而久之积攒的更多了

错误范例:

image-20230828215845182

(更多 更多的未读)

所以 Notion Page 姑且成为了最佳选择,它被我固定到标签栏最上边(我是用竖栏标签页的),闲着没事就会进去消灭几个未读

然而时间长了另一个痛点逐渐涌现:把文章作为超链接加到 Page 中真的好麻烦!我需要复制标题、复制网页链接、进入 Notion Page、添加 To do list block、添加超链接内容,在我经历这一过程 1 年多以后心态逐渐崩溃(不是),我开始寻找有没有更简单的方式 —— 哪怕,哪怕只有一个前端简陋的 dashboard page,只要能够显示类似 Notion Page 的内容就可以 —— 于是有了下面的探索

前置

read-later 扩展的痛点

无意中搜到了 read-later 这个浏览器扩展,安装后可以在当前页面右键(或超链接右键)将网页加入待读,在扩展的 popup 页面即可显示

image-20230828220655472

对一个网页可以显示 favicon、标题、阅读进度(部分),还可以方便的删除 & 同步(利用扩展的同步性),看似已经是完美符合我的需求,但实际体验一段时间后我发现了这样几个通点

  • 无法在更多端进行同步:比如我的 ipad mini6,比如我的 firefox
  • popup 页面和收藏夹、集锦一样,侵入性太强
  • 不能根据时间做分类
  • 不能自定义添加网页的标题

于是开始了改造之路

read-later 的程序逻辑

让我们先对这个扩展进行简易的代码分析,核心的代码其实只有 background 文件夹中的 4 个 js

image-20230828221530088

下面简单说一下每个 js 的主要功能:

  1. actions.js

包含添加页面、删除页面、更新 storage 的函数

  1. background.js

注册 contextMenus,监听鼠标右键操作 并把需要的信息交给 action.js 中处理,也监听 runtime 的消息(更新、安装)

  1. pageInfo.js

定义了 PageInfo, PositionInfo 和 SelectionInfo(后两个均基于 PageInfo),分别表示页面信息、当前浏览的位置信息、超链接的选择信息,向外导出 initPageInfo 和 completeInfo 用于添加页面到 storage

  1. request.js

主要是针对超链接文章,用于获取未实际访问的超链接的标题

改造

自定义添加网页的标题

* 私信认为这是个相当必需的需求,但不知为何作者并未做到这一点?

background.js 原本的监听是这样做的

contextMenus.onClicked(async (selection, tab) => {
    selection.linkUrl
        ? await action.saveSelection(tab, selection)
        : await action.savePage()
})

对点击区域按是否存在 selection.linkUrl 来区分即将添加的是超链接还是当前网页,如果是超链接 会默认不存在标题、交给 request.js 获取 <title> 中的内容作为标题;如果是网页,直接从 tab.title 获取

—— 怎么看都没有我们插手的地方,我选择首先把 saveXXX 的判断改掉

contextMenus.onClicked(async (selection, tab) => {
	if (typeof selection.linkUrl === "undefined" && typeof selection.selectionText === "undefined"){
        await action.savePage()
    } else {
        await action.saveSelection(tab, selection)  // use selectionText as page.title
    }
})

当右键划动选中文字区域时,虽然没有 selection.linkUrl,但是 selection.selectionText 仍然存在,依靠二者共同判断是添加 Page 还是 Selection

之后修改 SelectionInfo 类的方法

image-20230828223713035

我们把 “在选中内容处右键” 作为添加 Page 的一种方式 但仍然归类到 SelectionInfo 中,最小限度的修改代码、实现需求

修改 "done" 浮现的条件

image-20230828224113911

添加成功就会浮现这个 Badge,但并不是所有添加都会浮现!强迫症不服

image-20230828224231991

同时我也删去了很奇葩的一个设置:将当前页面添加 read-list 后自动关闭当前页面,很智障,很让人窒息

对接后端 server

因为涉及到了数据展示和更新,本想用我最喜欢的 streamlit,但 streamlit 不能像正常的后端一样接收 api 请求,无奈选用了 Flask;因为之前 CTF 接触了太多的 Flask,代码部分倒是不难(还有 chatgpt 强力驱动)

import sqlite3

from flask import Flask, render_template, request, redirect, jsonify, abort
from datetime import datetime
import os

app = Flask(__name__)


@app.before_request
def auth():
    if request.headers.get('Authorization') == os.getenv('seckey'):
        return None
    else:
        abort(403)


@app.route('/api/add', methods=['POST'])
def add():
    data = request.get_json()
    # print(data)
    conn = sqlite3.connect('data.db')
    c = conn.cursor()
    c.execute("INSERT INTO reading_list (title, url) VALUES (?, ?)",
              (data['title'], data['url']))
    conn.commit()
    conn.close()
    return jsonify({'message': 'Data added successfully'})


@app.route('/dashboard')
def dashboard():
    conn = sqlite3.connect('data.db')
    cursor = conn.cursor()
    cursor.execute(
        "SELECT id, title, url, strftime('%Y-%m-%d %H:%M:%S', create_time, '+8 hour'), status FROM reading_list ORDER BY create_time DESC")
    rows = cursor.fetchall()
    conn.close()

    data = {}
    for row in rows:
        record = {
            'id': row[0],
            'status': row[4],
            'title': row[1],
            'url': row[2],
            'create_time': datetime.strptime(row[3], "%Y-%m-%d %H:%M:%S")
        }
        create_time = record['create_time'].strftime("%m%d")
        if create_time not in data:
            data[create_time] = []
        data[create_time].append(record)
        # print(data)

    return render_template('dashboard.html', data=data, seckey=request.headers.get('Authorization'))


@app.route('/sync/<int:id>/<int:status>')
def sync_status(id, status):
    conn = sqlite3.connect('data.db')
    cursor = conn.cursor()
    cursor.execute("UPDATE reading_list SET status=? WHERE id=?", (status, id))
    conn.commit()
    conn.close()

    return redirect('/dashboard')


@app.route('/delete/<int:id>')
def delete_record(id):
    conn = sqlite3.connect('data.db')
    cursor = conn.cursor()
    cursor.execute("DELETE FROM reading_list WHERE id=?", (id,))
    conn.commit()
    conn.close()

    return redirect('/dashboard')


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=10393)

模板渲染部分,用 jinja2 真的非常丝滑

image-20230828224746021

image-20230828224829221

image-20230828224841198

经过前面的修改,现在页面右键、对超链接右键、选择内容右键都可以在原扩展的基础上请求自己搭建的 server,并存到 data.db 数据库中,访问 /dashboard 会有类似这样的显示效果

image-20230828225025036

checkbox 和代表 delete 的 × 都可以点击,联动后端的存储

虽然一看是让前端 er 闻者落泪的显示效果…… 但这已经是我和 chatgpt 大战了 500 个回合 + 自己修改了 N 次的结果了()能用就行嗯嗯嗯

可能细心的师傅已经注意到代码中频繁出现的 seckey 了,惭愧的承认 这时另一个较为失败的地方,在脑内思考了多种鉴权、认证方式后,选择了代码最少的方式 ——Flask 自带的装饰器 @app.before_request,服务端通过设置环境变量 seckey,整个 app 靠 Authorization 头来做一刀切的鉴权;不过纵使有种种缺点,它最大的有点还是代码少,之后会进行修改的(迫真)

仍存在的问题

  • 过于简陋的前端
  • 需要在前端加入随便写的便签功能
  • 修改认证 / 鉴权逻辑,做到多端丝滑访问
  • 适当修改 readme (虽然根本不会有第二个人用就是了)