Show

知乎备份脚本翻新

回顾

距离咱知乎脚本发布已经快一年了,在这期间,咱断断续续进行了非常多的改进和修补,添加了解析评论、存为单文件等重要功能,使用体验上也更加人性化。

更新日志
markdown
* 25.1.3(0.9.32):
    - 保存想法的标题
    - 移除更多的搜索推荐词
* 24.12.20(0.9.30):
    - 修复无法保存无字想法问题
    - 修复下载 zip 与油猴菜单的冲突
    - 现在提示保存失败后无需滚动即可重新保存
* 24.12.3(0.9.26):
    - 修复突然无法下载 zip 问题
    - 现在展开内容后无需滚动即可保存
    - 开启复制带 fm 时不再额外带标题
* 24.11.21(0.9.23):
    - 复制时可以包含 frontmatter 信息了(需通过油猴菜单手动打开)
    - 添加了油猴脚本选项**菜单**
* 24.11.13(0.9.22):
    - 修复两处截图样式异常问题
    - 修复浏览器窗口过窄时按钮溢出屏幕的问题
    - 修复按时间排序的问题被误判为回答的问题
* 24.10.24(0.9.18):
    - 修复保存分段引用内容未分段问题
    - 修复保存带标点加粗内容在阅读器中误加粗问题
    - 修复保存段首有空格内容在阅读器中误判为代码块问题
    - 修复收藏夹页无法保存部分图片问题
    - 保存图注(图片下方灰字)作为斜体的普通段落
    - 复制除想法外内容时添加标题
* 24.8.26(0.9.11):
    - 修复保存转发的想法异常
    - 修复新的样式异常
    - frontmatter 添加作者个性签名
* 24.7.10(0.9.7):
    - 修复搜索结果页保存报错
    - 修复获取评论数量不对
* 24.6.13(0.9.6):
    - 修复新的截图出错问题
* 24.6.12(0.9.5):
    - 文章页截图不会再截到按钮了
    - 移除没图片时多余的 assets 文件夹
    - **添加保存为单文件功能**
    - 支持保存评论中贴纸表情
    - 修复评论中图片重复的问题
    - 优化体验,写备注时可以把文本框拖大
* 24.3.29(0.8.25):
    - 移除没图片评论时多余的 assets 文件夹
    - 修复新的无法保存评论问题
    - 下载文章时包含头图
* 24.3.28(0.8.22):
    - 隐藏已折叠内容下的按钮
    - 修复保存无名用户内容出错
    - 修复按钮干扰选择文字的问题
    - 修复点击保存评论时奇怪的跳转问题
* 24.3.27(0.8.18):
    - 保存失败时给予补救机会
    - 修复按钮被目录遮挡无法点击
    - 修复无法保存机构号主页内容
    - 修复 url 获取错误
    - 内容子标题从 h2 开始
    - 解析参考文献
    - 解析目录
* 24.3.20(0.8.8):
    - 修复保存匿名用户内容出错
    - 增加保存失败原因提示
* 24.3.4(0.8.7):
    - 更方便的测试
    - 解析评论为Markdown
    - 评论图片本地化
    - **完善解析评论**修复bug
    - 修复zip内文件日期错误问题
    - 修复无法下载视频问题
    - 适配推荐页、搜索结果页
    - info中添加ip属地(如果有)
    - 修复想法无法保存图片
* 24.2.29(0.7.10):
    - 备注改为最长60字
    - 修复个人页无法保存想法问题
    - 修复保存zip处理评论可能出错问题
* 24.2.4(0.7.7):
    - 为Markdown添加frontmatter
    - 修正下载md内的图片路径为本地路径
    - 对于有目录的内容,减轻按钮与目录的重叠

不过,一直以来,有个需求非常重要却难以实现,就是需要保存更多的评论,包括展开之后的,弹出框里面的。

过程

思路

仍然延续之前的技术路线,即解析 DOM,而非请求 api。这样实现起来更加简单,更直观,所见即所得。而且不易受到反爬、加密等机制限制,更可靠。

思路就是先获取当前评论容器(内容下方的或弹出框中的),再将其逐条解析,得到作者、内容、时间等数据,暂存起来。然后在需要时将其转为 Markdown,合入主工作流程保存下来。

那么它就主要包括 3 个部分,即添加按钮与事件、解析评论、渲染评论

解析评论

解析评论就是将评论的 DOM 节点转为 JS 对象,保留元数据。这个工作非常复杂,交给 AI 来做最好了。你需要做的,就只是清晰描述需求,检查代码。

By Claude 3.5 Sonnet

我来帮你编写一个解析评论区的JavaScript代码。这个代码会实现增量解析并避免重复添加评论。以下是详细实现:

JavaScript
// 在window对象上创建存储空间
window.ArticleComments = window.ArticleComments || {};

class CommentParser {
    constructor(articleKey) {
        this.articleKey = articleKey;
        // 确保文章的评论存储空间存在
        window.ArticleComments[articleKey] = window.ArticleComments[articleKey] || {
            comments: new Map(), // 使用Map存储评论,key为评论ID
            lastUpdateTime: null
        };
    }
    /**
     * 解析单条评论
     * @param {Element} commentElement - 评论元素
     * @returns {Object} 解析后的评论对象
     */
    parseComment(commentElement) {
        const commentId = commentElement.getAttribute('data-id');
        (略)
        return {
            id: commentId,
            author,
            content,
            time,
            location,
            likes,
            isAuthor,
            parentId: null, // 将在后续处理中设置
            replies: [], // 子评论ID列表
            updateTime: new Date().getTime()
        };
    }
    /**
     * 构建评论层级关系
     * @param {Element} container - 评论容器元素
     * @param {Array} comments - 解析出的评论数组
     */
    buildCommentHierarchy(container) {
        const commentElements = Array.from(container.querySelectorAll('[data-id]'));
        const commentsData = window.ArticleComments[this.articleKey].comments;
        commentElements.forEach(element => {
            const commentId = element.getAttribute('data-id');
            const comment = this.parseComment(element);
            // 判断是否为回复评论
            (略)
            // 更新或添加评论
            if (commentsData.has(commentId)) {
                // 合并新数据,保留原有的replies
                const oldComment = commentsData.get(commentId);
                comment.replies = oldComment.replies;
                commentsData.set(commentId, {...oldComment, ...comment});
            } else {
                commentsData.set(commentId, comment);
            }
        });
        (略)
    }
    /**
     * 解析评论区
     * @param {string} containerSelector - 评论容器的选择器
     */
    parseComments(containerSelector = '.Comments-container') {
        const container = document.querySelector(containerSelector);
        if (!container) {
            console.error('找不到评论容器');
            return;
        }
        this.buildCommentHierarchy(container);
    }
    /**
     * 获取评论数据
     * @returns {Object} 评论数据
     */
    getComments() {
        return window.ArticleComments[this.articleKey];
    }
}

// 使用示例:
// const parser = new CommentParser('article-unique-key');
// parser.parseComments();
// const comments = parser.getComments();
// console.log(comments);
// 使用方法:
// 1. 创建解析器实例,传入文章的唯一标识
const parser = new CommentParser('article-123');
// 2. 初次解析评论区
parser.parseComments();
// 3. 当加载更多评论或展开回复时,再次解析
parser.parseComments();
// 4. 获取解析后的评论数据
const commentsData = parser.getComments();

这个实现有以下特点:

  • 使用Map存储评论数据,确保评论ID的唯一性
  • 支持评论的层级关系(父评论和子评论)
  • 支持增量更新,不会重复添加已存在的评论
  • 保存评论的所有必要信息(ID、作者、内容、时间、地理位置、点赞数等)
  • 正确处理评论中的图片,保存其alt文本
  • 标记作者评论
  • 存储在window.ArticleComments对象中,按文章key区分不同文章的评论
  • 记录最后更新时间,方便后续功能扩展
  • 你可以通过window.ArticleComments[articleKey]访问特定文章的评论数据。数据结构如下:(略)

AI 的实现果然很好,就是判断是否为子评论处理错了,不过很好修复。

添加按钮与事件

提供用户触发保存的接口,并且传递一些必要信息(内容 ID)

这个事情也非常麻烦,麻烦在知乎页面结构的多样性。内容下方的评论好说,关键是弹出框。就咱分析,弹出框的触发方式有好几种,比如点击“查看全部回复”“查看全部评论”,有时单纯展开评论区也会触发。如果在弹出框内点击“查看全部回复”,则会更新弹出框。

一开始,咱选择监听页面滚动事件,遍历所有评论容器,然后挂按钮。但是,出现弹出框时无法滚动页面,也就无法挂载。更重要的是,你无法知道当前弹出框到底是对应的哪一个内容!也就无法将评论数据和关联起来。

几经折腾,最终咱还是选择监听页面点击事件。利用事件委托机制,如果发现点击的是评论相关按钮,就尝试挂按钮,同时传递内容 ID。在搜索结果页,页面结构稍有不同,咱又花了一些时间适配。最终,所有场景下都能够挂上按钮了。

渲染评论

将评论对象转为 Markdown。

这种复杂的、较为独立的功能还是让 AI 来做

实际效果很好,只是多行引用的格式有误,需要修改。

其他工作与小改进

剩下的工作自然就是将评论解析相关与主程序组合起来,然后适配“评论区已关闭”等情形,并且在没保存评论时添加人性化的提示和处理。最后将旧代码统统删除!

其他:

  • 将初版就有的获取指定父元素的getParent()函数,替换为原生的.closest()方法,性能更好,更灵活
  • 简单场景下将.match()替换为.includes()
  • 修复一处setTimeout(function, 200)写成setTimeout(function(), 200)的问题

零碎记录

AI

不得不说,AI 编写的代码真的非常专业,简洁,可读性好,总是能使用合适的方法解决问题。有它帮忙咱节约了大约两三个整天的时间。

细节

Node.appendChild()

Node.appendChild() 方法将一个节点附加到指定父节点的子节点列表的末尾处。如果将被插入的节点已经存在于当前文档的文档树中,那么 appendChild() 只会将它从原先的位置移动到新的位置(不需要事先移除要移动的节点)。

这意味着,一个节点不可能同时出现在文档的不同位置。所以,如果某个节点已经拥有父节点,在被传递给此方法后,它首先会被移除,再被插入到新的位置。若要保留已在文档中的节点,可以先使用 Node.cloneNode() 方法来为它创建一个副本,再将副本附加到目标父节点下。请注意,用 cloneNode 制作的副本不会自动保持同步。

注意它只是移动节点,Element.append() 方法也是移动节点,如需复制,可使用Node.cloneNode()

Node.childNodes 与 Element.children 的区别

Node.childNodes 返回包含指定节点的子节点的集合,该集合为即时更新的集合(live collection)。

Element.children 是一个只读属性,返回 一个 Node 的子 elements ,是一个动态更新的 HTMLCollection。

也就是说,Element.children只包括其子元素节点,而Node.childNodes不仅包括其子元素节点,也包括文本节点、注释节点。

innerText 与 textContent 的区别

textContent 属性返回元素的所有文本内容,包括元素内部的所有文本和注释节点。而 innerText 属性仅返回元素内部可见的文本内容,忽略任何被 CSS 隐藏的文本和注释节点。

textContent 属性对性能要求相对较低,因为它只是简单地返回元素的文本内容,不需要进行任何样式和布局的计算。而 innerText 属性会涉及到样式和布局的计算,因此在某些情况下可能会对性能有一些影响。

如果 HTML 元素之间有换行,textContent 会返回包括所有文本内容和换行符的字符串,而 innerText 只返回显示在页面上的可见文本。

油猴

在调试代码时发现脚本里使用的window与浏览器控制台看到的window不一样,又想到自己以前曾浏览过下文内容,遂重新查阅资料。

是关于油猴的 unsafeWindow 问题,详见

简单来说,就是因为油猴作为一个浏览器扩展,拥有比网页更高的权限(通过GM_* 函数调用)。为了避免网站的页面脚本恶意利用这些权限,就需要把它们隔离开来,把用户脚本放到沙盒环境运行。这样脚本上下文(window)就与前端的上下文(window)不再是同一个了。不信你在脚本中修改window,然后在控制台中查看window,看看能否看到脚本的修改。

不过,在很多简单的场景下,你的用户脚本并不需要那么多的权限和功能,这时你只需要声明// @grant none,油猴就会将你的脚本直接放在网页的上下文中执行,此时两个window就是一样的。

如果你使用了一些特殊权限,又想在脚本中使用前端的上下文(window),可以使用unsafeWindow。记得声明// @grant unsafeWindow。(咱只是调试时用到它,实际代码并未用到)

  • 知乎备份脚本翻新
  • 作者:天雪酱  发布于:2025-01-06  更新于:2025-01-17  许可协议:若无特别说明,均为 CC BY-NC-SA
使用 AI 重构我的 JS 特效库
知乎备份脚本开发初探