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

更新日志
* 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代码。这个代码会实现增量解析并避免重复添加评论。以下是详细实现:
// 在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
。(咱只是调试时用到它,实际代码并未用到)