├── src ├── entry.ts ├── core │ ├── types.ts │ ├── download2zip.ts │ ├── savelex.ts │ ├── toast.ts │ ├── renderComments.js │ ├── tokenTypes.ts │ ├── parser.ts │ ├── utils.ts │ ├── parseComments.js │ ├── lexer.ts │ └── obsidianSaver.ts ├── dealItem.ts └── index.ts ├── .gitignore ├── tsconfig.json ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── .eslintrc.json ├── scripts ├── dev.js └── build-tampermonkey.js ├── package.json ├── TampermonkeyConfig.js ├── ReleaseNotes-0110.md ├── webpack.config.js └── README.md /src/entry.ts: -------------------------------------------------------------------------------- 1 | import "./index"; -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | export type AuthorType = { 2 | name: string, 3 | url: string, 4 | badge?: string, 5 | icon?: string, 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | #dist/ 3 | test/ 4 | 5 | 6 | pnpm-lock.yaml 7 | dist/bundle.js 8 | dist/bundle.min.js 9 | dist/tampermonkey.md 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": true, 5 | "module": "es2020", 6 | "target": "es2020", 7 | "allowJs": true, 8 | "lib": ["DOM", "ES6", "ESNext"], 9 | "moduleResolution": "node" 10 | } 11 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **问题描述** 13 | 描述您遇到的问题 14 | 15 | **如何复现** 16 | 提供相关内容的链接,以及场景(回答页/推荐页) 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "sourceType": "module" 5 | }, 6 | "rules": { 7 | "indent": [0, "space"] 8 | //"quotes": ["error", "double"] 9 | }, 10 | "extends": [ 11 | // "eslint:recommended" 12 | ], 13 | "ignorePatterns": [ 14 | "node_modules/", 15 | "dist/", 16 | "build/" 17 | ] 18 | } -------------------------------------------------------------------------------- /src/core/download2zip.ts: -------------------------------------------------------------------------------- 1 | import * as JSZip from "jszip" 2 | 3 | /** 4 | * 下载文件并将其添加到zip文件中 5 | * @param url 下载文件的URL 6 | * @param zip JSZip对象,用于创建zip文件 7 | * @returns 添加了下载文件的zip文件 8 | */ 9 | export async function downloadAndZip(url: string, zip: JSZip): Promise<{ zip: JSZip, file_name: string }> { 10 | 11 | const response = await fetch(url) 12 | const arrayBuffer = await response.arrayBuffer() 13 | let fileName = url.replace(/\?.*?$/g, "").split("/").pop() 14 | fileName.endsWith('.image') ? fileName += '.jpg' : 0 15 | 16 | // 添加到zip文件 17 | zip.file(fileName, arrayBuffer) 18 | return { zip, file_name: fileName } 19 | } 20 | 21 | /** 22 | * 下载一系列文件并将其添加到zip文件中 23 | * @param urls 下载文件的URL 24 | * @param zip JSZip对象,用于创建zip文件 25 | * @returns 添加了下载文件的zip文件 26 | */ 27 | export async function downloadAndZipAll(urls: string[], zip: JSZip): Promise { 28 | for (let url of urls) zip = (await downloadAndZip(url, zip)).zip 29 | return zip 30 | } -------------------------------------------------------------------------------- /scripts/dev.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name 知乎备份剪藏-本地测试 3 | // @namespace qtqz 4 | // @source https://github.com/qtqz/zhihu-backup-collect 5 | // @version 0.9.22 6 | // @description 将你喜欢的知乎回答/文章/想法保存为 markdown / zip / png 7 | // @author qtqz 8 | // @match https://www.zhihu.com/follow 9 | // @match https://www.zhihu.com/pin/* 10 | // @match https://www.zhihu.com/people/* 11 | // @match https://www.zhihu.com/org/* 12 | // @match https://www.zhihu.com/question/* 13 | // @match https://www.zhihu.com/answer/* 14 | // @match https://www.zhihu.com/collection/* 15 | // @match https://zhuanlan.zhihu.com/p/* 16 | // @match https://www.zhihu.com/ 17 | // @match https://www.zhihu.com/search*content* 18 | // @require file://C:/code/zhihu/zhihu-backup-collect/dist/bundle.js 19 | // @license MIT 20 | // @icon https://static.zhihu.com/heifetz/favicon.ico 21 | // @grant GM_setValue 22 | // @grant GM_getValue 23 | // @grant GM_registerMenuCommand 24 | // @grant GM_unregisterMenuCommand 25 | // ==/UserScript== 26 | 27 | ;; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zhihu-backup-collect", 3 | "version": "0.11.3", 4 | "description": "将你喜欢的知乎回答/文章/想法保存为 markdown / zip / png", 5 | "scripts": { 6 | "dev": "run-s watch", 7 | "watch": "webpack --mode development --watch", 8 | "build:production": "webpack --mode production", 9 | "build:tampermonkey": "node ./scripts/build-tampermonkey.js", 10 | "build": "run-s build:production build:tampermonkey", 11 | "lint": "eslint --fix --ext .js,.ts ./src ./scripts webpack.config.js" 12 | }, 13 | "devDependencies": { 14 | "html-webpack-plugin": "^5.6.0", 15 | "npm-run-all": "^4.1.5", 16 | "ts-loader": "^9.5.1", 17 | "typescript": "^5.3.3", 18 | "uglifyjs-webpack-plugin": "^2.2.0", 19 | "webpack": "^5.89.0", 20 | "webpack-cli": "^5.1.4" 21 | }, 22 | "dependencies": { 23 | "@babel/core": "^7.23.7", 24 | "@babel/preset-env": "^7.23.7", 25 | "@babel/preset-typescript": "^7.23.3", 26 | "@types/file-saver": "^2.0.7", 27 | "@types/md5": "^2.3.5", 28 | "@types/node": "^20.10.6", 29 | "@typescript-eslint/parser": "^6.17.0", 30 | "babel-loader": "^9.1.3", 31 | "eslint": "^8.56.0", 32 | "file-saver": "^2.0.5", 33 | "jszip": "^3.9.1", 34 | "modern-screenshot": "^4.4.37" 35 | }, 36 | "type": "module", 37 | "author": "qtqz", 38 | "license": "MIT" 39 | } -------------------------------------------------------------------------------- /TampermonkeyConfig.js: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | 3 | const packageInfo = JSON.parse(fs.readFileSync("./package.json", "utf-8").toString()) 4 | 5 | export const UserScriptContent = fs.readFileSync("./dist/bundle.min.js", "utf-8").toString().trim() 6 | //.replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, "")会误伤base64 7 | 8 | export const UserScript = { 9 | "name": "知乎备份剪藏", 10 | "namespace": "qtqz", 11 | "source": "https://github.com/qtqz/zhihu-backup-collect", 12 | "version": packageInfo.version, 13 | "description": "将你喜欢的知乎回答/文章/想法保存为 markdown / zip / png", 14 | "author": packageInfo.author, 15 | "match": [ 16 | "https:\/\/www.zhihu.com/follow", 17 | "https:\/\/www.zhihu.com/pin/*", 18 | "https:\/\/www.zhihu.com/people/*", 19 | "https:\/\/www.zhihu.com/org/*", 20 | "https:\/\/www.zhihu.com/question/*", 21 | "https:\/\/www.zhihu.com/answer/*", 22 | "https:\/\/www.zhihu.com/collection/*", 23 | "https:\/\/zhuanlan.zhihu.com/p/*", 24 | "https:\/\/www.zhihu.com/search*content*", 25 | "https:\/\/www.zhihu.com/" 26 | ], 27 | "license": packageInfo.license, 28 | "icon": "https://static.zhihu.com/heifetz/favicon.ico", 29 | "grant": [ 30 | "GM_setValue", 31 | "GM_getValue", 32 | "GM_registerMenuCommand", 33 | "GM_unregisterMenuCommand" 34 | ] 35 | } -------------------------------------------------------------------------------- /scripts/build-tampermonkey.js: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import { UserScript, UserScriptContent } from "../TampermonkeyConfig.js" 3 | 4 | 5 | // Padding 长度 6 | const paddingLength = Object.entries(UserScript).reduce((maxLength, [key]) => { 7 | return Math.max(maxLength, key.length) 8 | }, 0) + 1 9 | 10 | // Tampermonkey UserScript Config 11 | const TampermonkeyConfig = Object.entries(UserScript).map(([key, value]) => { 12 | if (!value) return 13 | 14 | if (typeof value == "object") 15 | return Object.entries(value).map(([_key, value]) => { 16 | return `// @${key.padEnd(paddingLength, " ")} ${value}` 17 | }).join("\n") 18 | 19 | return `// @${key.padEnd(paddingLength, " ")} ${value}` 20 | 21 | }).filter((val) => val).join("\n") 22 | 23 | //readme 24 | const readme = fs.readFileSync("./README.md", "utf-8").toString().replace(/# .*?\n/s,'').replace(/## Dev.*?(?=##)/s,'').replace(//gs,'') 25 | 26 | fs.writeFileSync("./dist/tampermonkey.md", ` 27 | 项目主页:[github:qtqz/zhihu-backup-collect](https://github.com/qtqz/zhihu-backup-collect) 28 | 部分代码来自:[知乎下载器](https://greasyfork.org/zh-CN/scripts/478608-%E7%9F%A5%E4%B9%8E%E4%B8%8B%E8%BD%BD%E5%99%A8) 29 | 30 | ${readme} 31 | 32 | `, "utf-8") 33 | 34 | //?(?=(##)?) 35 | fs.writeFileSync("./dist/tampermonkey-script.js", `// ==UserScript== 36 | ${TampermonkeyConfig} 37 | // ==/UserScript== 38 | 39 | /** 40 | ${readme.match(/## Changelog.*$/s)[0].slice(0,450).trim() + '...\n...\n'} 41 | */ 42 | 43 | ${UserScriptContent}`, "utf-8") -------------------------------------------------------------------------------- /src/core/savelex.ts: -------------------------------------------------------------------------------- 1 | import * as JSZip from "jszip" 2 | import { TokenType, type LexType } from "./tokenTypes" 3 | import { downloadAndZip } from "./download2zip" 4 | import { parser } from "./parser" 5 | 6 | export default async ( 7 | lex: LexType[], 8 | assetsPath: string = "assets" 9 | ): Promise<{ zip: JSZip, localLex: LexType[] }> => { 10 | 11 | const zip = new JSZip() 12 | let FigureFlag = false 13 | 14 | for (let token of lex) { 15 | if (token.type == TokenType.Figure || token.type == TokenType.Video || token.type == TokenType.Gif) { 16 | FigureFlag = true 17 | break 18 | } 19 | } 20 | if (FigureFlag) { 21 | const assetsFolder = zip.folder(assetsPath) 22 | 23 | for (let token of lex) { 24 | try { 25 | switch (token.type) { 26 | case TokenType.Figure: 27 | case TokenType.Video: 28 | case TokenType.Gif: { 29 | const { file_name } = await downloadAndZip(token.src, assetsFolder) 30 | token.localSrc = `./${assetsPath}/${file_name}` 31 | token.local = true 32 | break 33 | } 34 | } 35 | } catch (e) { 36 | console.error('下载', token, e) 37 | alert('下载失败' + token.type + e) 38 | } 39 | } 40 | } 41 | 42 | /*const markdown = parser(lex).join("\n\n") 43 | zip.file("index.md", markdown)*/ 44 | 45 | return { zip: zip, localLex: lex } 46 | } -------------------------------------------------------------------------------- /ReleaseNotes-0110.md: -------------------------------------------------------------------------------- 1 | 2 | ## 🎉 重大更新:支持保存内容到指定文件夹,兼容 Obsidian 3 | 4 | 现在可以直接将知乎内容保存到本地存储库(如 Obsidian vault),无需手动下载和解压 ZIP 文件! 5 | 6 | ### ✨ 新功能 7 | 8 | #### 1. **支持自定义保存位置** 9 | - 🆕 选择您的个人知识库文件夹,将知乎内容直接保存到位,无需手动整理 10 | - 🆕 一目了然地展示您本地的文件夹结构,可以选择保存到子文件夹,便于分类 11 | - 🆕 授权后,保存位置长期可用,无需每次都手动寻找保存位置 12 | - 🆕 内容格式与 Obsidian 兼容,可在其中直接阅读 13 | - 🆕 支持直接保存解压后的 ZIP,无需手动解压 14 | 15 | #### 2. **基于原程序扩展而来,充分保留原有功能** 16 | - 📝 保存流程调用旧代码,保持对五花八门的知乎内容的良好适配 17 | - 📁 可选 5 种保存方式:ZIP单独解包、ZIP共同解包、ZIP不解包、纯文本、图片 18 | - 🎨 保持原来的界面与交互设计,自然、优雅、不突兀 19 | - 💬 仍然支持保存评论 20 | 21 | #### 3. **新增消息弹窗提示系统** 22 | - ℹ 给予人性化的操作提示 23 | - 🔮 样式简洁、动效流畅 24 | 25 | 26 | ### 🐛 其他修改 27 | 28 | - 修复此问题:对于同一个内容,保存过 ZIP 后,评论中图片链接就永久变为本地链接,影响再次保存 29 | - 样式和体验优化 30 | - 删除无用的 info.json 31 | - 为自定义保存文件名添加了默认值,方便修改 32 | 33 | ### 📋 浏览器要求 34 | 35 | - ✅ Chrome 86+ 36 | - ✅ Edge 86+ 37 | - ❌ Firefox(不支持 File System Access API) 38 | - ❌ Safari(不支持 File System Access API) 39 | 40 | ### 📖 新功能使用方法 41 | 42 | 1. 点击侧栏的 **保存到指定文件夹** 按钮 43 | 2. 首次使用会提示选择**存储库**目录 44 | 3. 选择一种保存方式,鼠标移到按钮上可以看到具体说明 45 | 46 | 47 | ### 📁 文件结构示例 48 | 49 | **分类保存:** 50 | ``` 51 | Obsidian Vault/ 52 | ├── 历史/ 53 | │ ├── 内容-123.md 54 | │ └── 内容-456.md 55 | ├── 生活/ 56 | │ ├── 内容-666.md 57 | │ └── 内容-999.md 58 | ``` 59 | 60 | **ZIP单独解包:** 61 | ``` 62 | Obsidian Vault/ 63 | ├── 标题123/ 64 | │ ├── assets/ 65 | │ │ ├── image1.jpg 66 | │ │ └── image2.png 67 | │ └── index.md 68 | └── 标题456/ 69 | ├── assets/ 70 | │ ├── image1.jpg 71 | │ └── image2.png 72 | └── index.md 73 | ``` 74 | 75 | **共享解包:** 76 | ``` 77 | Obsidian Vault/ 78 | ├── assets/ 79 | │ ├── image1.jpg 80 | │ ├── image2.jpg 81 | │ └── image3.png 82 | ├── 标题87654321.md 83 | └── 标题12345678.md 84 | ``` 85 | 86 | ### 🔒 隐私与安全 87 | 88 | - ✅ 所有操作都在本地完成 89 | - ✅ 不上传数据到服务器 90 | - ✅ 用户完全掌控文件访问权限 91 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import webpack from "webpack"; 3 | import fs from "fs"; 4 | import HtmlWebpackPlugin from "html-webpack-plugin"; 5 | import UglifyJsPlugin from "uglifyjs-webpack-plugin"; 6 | 7 | const __dirname = path.resolve(); 8 | 9 | const devConfig = { 10 | entry: "./src/entry.ts", 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.ts$/, 15 | use: "ts-loader", 16 | exclude: /node_modules|test|dist/g, 17 | } 18 | ], 19 | }, 20 | resolve: { 21 | extensions: [".ts", ".js"], 22 | }, 23 | output: { 24 | filename: "bundle.js", 25 | path: path.resolve(__dirname, "dist"), 26 | }, 27 | plugins: [ 28 | /*...fs.readdirSync("./test").map((file) => { 29 | console.log(file) 30 | if (file.endsWith(".html")) { 31 | return new HtmlWebpackPlugin({ 32 | filename: file, 33 | template: `./test/${file}`, 34 | }); 35 | } 36 | }),*/ 37 | ], 38 | optimization: { 39 | minimize: false 40 | }, 41 | devtool: "eval-source-map", 42 | }; 43 | 44 | export default (env, argv) => { 45 | if (argv.mode === "production") { 46 | return { 47 | entry: "./src/entry.ts", 48 | module: { 49 | rules: [{ 50 | test: /\.ts$/, 51 | exclude: /node_modules|test/g, 52 | use: [{ 53 | loader: "ts-loader", 54 | options: { 55 | transpileOnly: true 56 | } 57 | }] 58 | }] 59 | }, 60 | resolve: { 61 | extensions: [".ts", ".js"], 62 | }, 63 | output: { 64 | filename: "bundle.min.js", 65 | path: path.resolve(__dirname, "dist"), 66 | }, 67 | plugins: [ 68 | /*new UglifyJsPlugin({ 69 | uglifyOptions: { 70 | compress: false{ 71 | drop_console: false, 72 | drop_debugger: true, 73 | //pure_funcs: ["console.log"], 74 | }, 75 | }, 76 | }),*/ 77 | ], 78 | optimization: { 79 | minimize: false 80 | } 81 | }; 82 | } else return devConfig; 83 | }; -------------------------------------------------------------------------------- /src/core/toast.ts: -------------------------------------------------------------------------------- 1 | // 维护活动的 toast 列表 2 | const activeToasts: HTMLElement[] = []; 3 | const TOAST_HEIGHT = 60; // 每个 toast 的高度间隔 4 | 5 | // 初始化样式 6 | const initStyles = () => { 7 | if (document.getElementById('toast-styles')) return; 8 | 9 | const style = document.createElement('style'); 10 | style.id = 'toast-styles'; 11 | style.textContent = ` 12 | .toast-message { 13 | position: fixed; 14 | top: 30px; 15 | left: 50%; 16 | background: white; 17 | color: #333; 18 | padding: 12px 24px; 19 | border-radius: 4px; 20 | font-size: 14px; 21 | box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); 22 | z-index: 1000; 23 | pointer-events: none; 24 | opacity: 0; 25 | transition: transform 0.3s ease; 26 | } 27 | 28 | .toast-message.show { 29 | opacity: 1; 30 | animation: toast-float-in 0.5s ease forwards; 31 | } 32 | 33 | .toast-message.hide { 34 | animation: toast-float-out 0.5s ease forwards; 35 | } 36 | 37 | @keyframes toast-float-in { 38 | from { 39 | opacity: 0; 40 | } 41 | to { 42 | opacity: 1; 43 | } 44 | } 45 | 46 | @keyframes toast-float-out { 47 | from { 48 | opacity: 1; 49 | } 50 | to { 51 | opacity: 0; 52 | } 53 | } 54 | `; 55 | document.head.appendChild(style); 56 | }; 57 | 58 | // 显示消息 59 | export const showToast = (message: string, duration: number = 4000) => { 60 | initStyles(); 61 | 62 | const toast = document.createElement('div'); 63 | toast.className = 'toast-message'; 64 | toast.textContent = message; 65 | 66 | // 计算当前 toast 应该显示的位置(根据已有 toast 数量) 67 | const offset = activeToasts.length * TOAST_HEIGHT; 68 | toast.style.transform = `translate(-50%, ${offset}px)`; 69 | 70 | document.body.appendChild(toast); 71 | activeToasts.push(toast); 72 | 73 | // 淡入显示 74 | setTimeout(() => toast.classList.add('show'), 10); 75 | 76 | // 淡出消失 77 | setTimeout(() => { 78 | toast.classList.remove('show'); 79 | toast.classList.add('hide'); 80 | 81 | setTimeout(() => { 82 | toast.remove(); 83 | // 从列表中移除 84 | const index = activeToasts.indexOf(toast); 85 | if (index > -1) { 86 | activeToasts.splice(index, 1); 87 | // 更新剩余 toast 的位置 88 | updateToastPositions(); 89 | } 90 | }, 500); 91 | }, duration); 92 | }; 93 | 94 | // 更新所有 toast 的位置 95 | const updateToastPositions = () => { 96 | activeToasts.forEach((toast, index) => { 97 | toast.style.transform = `translate(-50%, ${index * TOAST_HEIGHT}px)`; 98 | }); 99 | }; 100 | -------------------------------------------------------------------------------- /src/core/renderComments.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 渲染单条评论 3 | * @param {Object} comment 4 | * @param {Map} comments 5 | * @param {number} level 6 | * @param {Boolean} isLocalImg 7 | */ 8 | function renderCommentToMarkdown(comment, comments, level = 0, isLocalImg) { 9 | //console.log(comment); 10 | // 基础模板 11 | const prefix = level ? '> '.repeat(level) : ''; 12 | const titleLevel = level ? '####' : '###'; 13 | 14 | // 处理评论内容中的换行符,确保在markdown中正确换行 15 | const formattedContent = comment.content.replace('\n', '\n\n').split('\n') 16 | .map(line => `${prefix}${line}`) 17 | .join('\n'); 18 | 19 | // 构建基本评论模板 20 | let markdown = [ 21 | `${prefix}${titleLevel} ${comment.author}${comment.beReplied ? ` › ${comment.beReplied}` : ''}`, 22 | prefix, 23 | formattedContent, 24 | prefix 25 | ]; 26 | 27 | if (comment.img) { 28 | let img = comment.img 29 | if (isLocalImg) { 30 | commentsImgs.push(comment.img) 31 | img = './assets/' + comment.img.replace(/\?.*?$/, "").split("/").pop() 32 | } 33 | // @ts-ignore 34 | window.no_save_img && !isLocalImg ? 35 | markdown.push(`${prefix}[图片]`, prefix) : 36 | markdown.push(`${prefix}![](${img})`, prefix) 37 | // @ts-ignore 38 | console.log('comment.img', window.no_save_img); 39 | } 40 | 41 | markdown.push( 42 | `${prefix}${comment.time} ${comment.location} ${comment.likes} 赞`, 43 | prefix 44 | ); 45 | 46 | // 递归处理回复 47 | if (comment.replies && comment.replies.length) { 48 | const repliesMarkdown = comment.replies 49 | .map(replyId => comments.get(replyId)) 50 | //.filter(reply => reply) // 过滤掉可能的无效回复 51 | .map(reply => renderCommentToMarkdown(reply, comments, level + 1, isLocalImg)) 52 | .join('\n'); 53 | 54 | markdown.push(repliesMarkdown.replace(/> $/, '')); 55 | } 56 | 57 | return markdown.join('\n'); 58 | } 59 | 60 | let commentsImgs = [] 61 | /** 62 | * 渲染所有评论 63 | * @param {Map} commentsMap 64 | * @param {Boolean} isLocalImg 65 | * @returns {[String,String[]]} 66 | */ 67 | export function renderAllComments(commentsMap, isLocalImg) { 68 | // 找出所有顶级评论(没有parentId的评论) 69 | //console.log(commentsMap) 70 | const topLevelComments = Array.from(commentsMap.values()) 71 | .filter(comment => !comment.parentId) 72 | commentsImgs = [] 73 | // 解析所有顶级评论及其回复 74 | return [ 75 | topLevelComments 76 | .map(comment => renderCommentToMarkdown(comment, commentsMap, 0, isLocalImg)) 77 | .join('\n'), 78 | commentsImgs 79 | ]; 80 | } 81 | 82 | /* 使用示例: 83 | const [markdown, imgs] = renderAllComments(comments, true); 84 | console.log(markdown, imgs);*/ -------------------------------------------------------------------------------- /src/core/tokenTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enum representing the different types of tokens in the parsed markdown. 3 | */ 4 | export enum TokenType { 5 | H2, H3, 6 | Text, 7 | Figure, 8 | Gif, 9 | InlineLink, 10 | InlineCode, 11 | Math, 12 | Italic, 13 | Bold, 14 | PlainText, 15 | UList, 16 | Olist, 17 | BR, 18 | HR, 19 | Blockquote, 20 | Code, 21 | Link, 22 | Table, 23 | Video, 24 | FootnoteList, 25 | } 26 | 27 | 28 | 29 | /** 30 | * Represents a token of italic text. 31 | */ 32 | export type TokenTextItalic = { 33 | type: TokenType.Italic; 34 | content: TokenTextType[]; 35 | dom?: HTMLElement; 36 | }; 37 | 38 | /** 39 | * Represents a bold token with its content and corresponding DOM element. 40 | */ 41 | export type TokenTextBold = { 42 | type: TokenType.Bold; 43 | content: TokenTextType[]; 44 | dom?: HTMLElement; 45 | }; 46 | 47 | /** 48 | * Represents a token that contains a link. 49 | */ 50 | export type TokenTextLink = { 51 | type: TokenType.InlineLink; 52 | text: string; 53 | href: string; 54 | dom?: HTMLAnchorElement; 55 | }; 56 | 57 | /** 58 | * Represents a token of plain text. 59 | */ 60 | export type TokenTextPlain = { 61 | type: TokenType.PlainText; 62 | text: string; 63 | dom?: ChildNode 64 | }; 65 | 66 | /** 67 | * Represents a token of
. 68 | */ 69 | export type TokenTextBr = { 70 | type: TokenType.BR; 71 | dom?: HTMLBRElement 72 | }; 73 | 74 | /** 75 | * Represents a token of inline code. 76 | */ 77 | export type TokenTextCode = { 78 | type: TokenType.InlineCode; 79 | content: string; 80 | dom?: HTMLElement; 81 | }; 82 | 83 | /** 84 | * Represents a token of inline math. 85 | */ 86 | export type TokenTextInlineMath = { 87 | type: TokenType.Math; 88 | content: string; 89 | display?: boolean; // true 表示块级公式,false 或 undefined 表示行内公式 90 | dom?: HTMLElement; 91 | }; 92 | 93 | /** 94 | * Represents a token of all kinds of text. 95 | */ 96 | export type TokenTextType = 97 | TokenTextPlain | 98 | TokenTextLink | 99 | TokenTextBold | 100 | TokenTextItalic | 101 | TokenTextBr | 102 | TokenTextCode | 103 | TokenTextInlineMath; 104 | 105 | /** 106 | * Represents a token of text. 107 | */ 108 | export type TokenText = { 109 | type: TokenType.Text; 110 | content: TokenTextType[]; 111 | dom?: HTMLParagraphElement 112 | }; 113 | 114 | 115 | /** 116 | * Represents a token of blockquote. 117 | */ 118 | export type TokenBlockquote = { 119 | type: TokenType.Blockquote; 120 | content: TokenTextType[]; 121 | dom?: HTMLQuoteElement 122 | }; 123 | 124 | 125 | /** 126 | * Represents a token of unordered list. 127 | */ 128 | export type TokenUList = { 129 | type: TokenType.UList; 130 | content: TokenTextType[][]; // 谢天谢地,知乎List不会嵌套 131 | dom?: HTMLUListElement; 132 | }; 133 | 134 | 135 | /** 136 | * Represents a token of ordered list. 137 | */ 138 | export type TokenOList = { 139 | type: TokenType.Olist; 140 | content: TokenTextType[][]; 141 | dom?: HTMLOListElement; 142 | }; 143 | 144 | 145 | /** 146 | * Represents a token of code block. 147 | */ 148 | export type TokenCode = { 149 | type: TokenType.Code; 150 | content: string; 151 | language?: string; 152 | dom?: HTMLDivElement; 153 | }; 154 | 155 | 156 | /** 157 | * Represents a token of horizontal rule. 158 | */ 159 | export type TokenHR = { 160 | type: TokenType.HR; 161 | dom?: HTMLHRElement; 162 | }; 163 | 164 | 165 | /** 166 | * Represents a token of link. 167 | */ 168 | export type TokenLink = { 169 | type: TokenType.Link; 170 | text: string; 171 | href: string; 172 | dom?: HTMLDivElement; 173 | }; 174 | 175 | 176 | /** 177 | * Represents a token of type H1. 178 | */ 179 | export type TokenH2 = { 180 | type: TokenType.H2; 181 | text: string; 182 | dom?: HTMLHeadingElement; 183 | }; 184 | 185 | 186 | /** 187 | * Represents a token of type H2. 188 | */ 189 | export type TokenH3 = { 190 | type: TokenType.H3; 191 | text: string; 192 | dom?: HTMLHeadingElement; 193 | }; 194 | 195 | 196 | /** 197 | * Represents a token figure. 198 | */ 199 | export type TokenFigure = { 200 | type: TokenType.Figure; 201 | src: string; 202 | local: boolean; // 文件是否已经下载 203 | localSrc?: string; 204 | dom?: HTMLElement; 205 | }; 206 | 207 | 208 | /** 209 | * Represents a token gif. 210 | */ 211 | export type TokenGif = { 212 | type: TokenType.Gif; 213 | src: string; 214 | local: boolean; // 文件是否已经下载 215 | localSrc?: string; 216 | dom?: HTMLElement; 217 | }; 218 | 219 | 220 | /** 221 | * Represents a token table. 222 | */ 223 | export type TokenTable = { 224 | type: TokenType.Table; 225 | content: string[][]; 226 | dom?: HTMLTableElement; 227 | }; 228 | 229 | 230 | /** 231 | * Represents a token video. 232 | */ 233 | export type TokenVideo = { 234 | type: TokenType.Video; 235 | src: string; 236 | local: boolean; // 文件是否已经下载 237 | localSrc: string; 238 | dom?: HTMLDivElement; 239 | } 240 | 241 | 242 | /** 243 | * Represents a footnote/reference item. 244 | */ 245 | export type FootnoteItem = { 246 | id: string; // 如 "1", "2" 247 | content: string; // 脚注内容 248 | }; 249 | 250 | /** 251 | * Represents a token footnote list. 252 | */ 253 | export type TokenFootnoteList = { 254 | type: TokenType.FootnoteList; 255 | items: FootnoteItem[]; 256 | dom?: HTMLOListElement; 257 | } 258 | 259 | 260 | /** 261 | * Represents a token of all kinds of lex. 262 | */ 263 | export type LexType = 264 | TokenH2 | 265 | TokenH3 | 266 | TokenCode | 267 | TokenText | 268 | TokenUList | 269 | TokenOList | 270 | TokenFigure | 271 | TokenBlockquote | 272 | TokenHR | 273 | TokenLink | 274 | TokenTable | 275 | TokenVideo | 276 | TokenGif | 277 | TokenFootnoteList -------------------------------------------------------------------------------- /src/core/parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type LexType, TokenType, TokenText, TokenTextType, TokenTextCode, TokenTextInlineMath, TokenFootnoteList, 3 | } from "./tokenTypes" 4 | 5 | 6 | 7 | /** 8 | * Parses an array of LexType objects and returns an array of strings representing the parsed output. 9 | * @param input An array of LexType objects to be parsed. 10 | * @returns An array of strings representing the parsed output. 11 | */ 12 | export const parser = (input: LexType[]): string[] => { 13 | const output: string[] = [] 14 | 15 | for (let i = 0; i < input.length; i++) { 16 | const token = input[i] 17 | 18 | switch (token.type) { 19 | case TokenType.Code: { 20 | output.push(`\`\`\`${token.language ? token.language : ""}\n${token.content}${token.content.endsWith("\n") ? "" : "\n" 21 | }\`\`\``) 22 | break 23 | } 24 | 25 | case TokenType.UList: { 26 | output.push(token.content.map((item) => `- ${renderRich(item)}`).join("\n")) 27 | break 28 | } 29 | 30 | case TokenType.Olist: { 31 | output.push(token.content.map((item, index) => `${index + 1}. ${renderRich(item)}`).join("\n")) 32 | break 33 | } 34 | 35 | case TokenType.H2: { 36 | output.push(`## ${token.text}`) 37 | break 38 | } 39 | 40 | case TokenType.H3: { 41 | output.push(`### ${token.text}`) 42 | break 43 | } 44 | 45 | case TokenType.Blockquote: { 46 | output.push(renderRich(token.content, "> ").replace(/\n/g, '\n> \n')) // 修复部分引用不分段问题 47 | break 48 | } 49 | 50 | case TokenType.Text: { 51 | output.push(renderRich(token.content)) 52 | break 53 | } 54 | 55 | case TokenType.HR: { 56 | output.push("\n---\n") 57 | break 58 | } 59 | 60 | case TokenType.Link: { 61 | output.push(`[${token.text}](${token.href})`) 62 | break 63 | } 64 | 65 | case TokenType.Figure: 66 | 67 | case TokenType.Gif: { 68 | // @ts-ignore 69 | window.no_save_img && !token.local ? 70 | output.push(`[图片]`) : 71 | output.push(`![](${token.local ? token.localSrc : token.src})`) 72 | break 73 | } 74 | 75 | case TokenType.Video: { 76 | // 创建一个虚拟的 DOM 节点 77 | const dom = document.createElement("video") 78 | dom.setAttribute("src", token.local ? token.localSrc : token.src) 79 | if (!token.local) dom.setAttribute("data-info", "文件还未下载,随时可能失效,请使用`下载全文为Zip`将视频一同下载下来") 80 | 81 | output.push(dom.outerHTML) 82 | break 83 | } 84 | 85 | case TokenType.Table: { 86 | //console.log(token) 87 | 88 | const rows = token.content 89 | const cols = rows[0].length 90 | const widths = new Array(cols).fill(0) 91 | const res = [] 92 | 93 | for (let i in rows) { 94 | for (let j in rows[i]) { 95 | widths[j] = Math.max(widths[j], rows[i][j].length) 96 | } 97 | } 98 | 99 | const renderRow = (row: string[]): string => { 100 | let res = "" 101 | for (let i = 0; i < cols; i++) { 102 | res += `| ${row[i].padEnd(widths[i])} ` 103 | } 104 | res += "|" 105 | return res 106 | } 107 | 108 | const renderSep = (): string => { 109 | let res = "" 110 | for (let i = 0; i < cols; i++) { 111 | res += `| ${"-".repeat(widths[i])} ` 112 | } 113 | res += "|" 114 | return res 115 | } 116 | 117 | res.push(renderRow(rows[0])) 118 | res.push(renderSep()) 119 | 120 | for (let i = 1; i < rows.length; i++) { 121 | res.push(renderRow(rows[i])) 122 | } 123 | output.push(res.join("\n")) 124 | 125 | break 126 | } 127 | 128 | case TokenType.FootnoteList: { 129 | // 渲染脚注定义列表 130 | const footnotes = (token as TokenFootnoteList).items.map(item => 131 | `[^${item.id}]: ${item.content}` 132 | ) 133 | output.push(footnotes.join("\n")) 134 | break 135 | } 136 | } 137 | } 138 | 139 | return output 140 | } 141 | 142 | 143 | /** 144 | * Renders rich text based on an array of tokens. 145 | * @param input An array of TokenTextType objects representing the rich text to render. 146 | * @param joint An optional string to join the rendered text with. 147 | * @returns A string representing the rendered rich text. 148 | */ 149 | const renderRich = (input: TokenTextType[], joint: string = ""): string => { 150 | let res = "" 151 | 152 | for (let el of input) { 153 | switch (el.type) { 154 | case TokenType.Bold: { 155 | res += `**${renderRich(el.content)}** ` // 修复md阅读器识别带标点符号的加粗内容有误问题 156 | break 157 | } 158 | 159 | case TokenType.Italic: { 160 | res += `*${renderRich(el.content)}*` 161 | break 162 | } 163 | 164 | case TokenType.InlineLink: { 165 | res += `[${el.text}](${el.href})` 166 | break 167 | } 168 | 169 | case TokenType.PlainText: { 170 | res += el.text 171 | break 172 | } 173 | 174 | case TokenType.BR: { 175 | res += "\n" + joint 176 | break 177 | } 178 | 179 | case TokenType.InlineCode: { 180 | res += `\`${(el as TokenTextCode).content}\`` 181 | break 182 | } 183 | 184 | case TokenType.Math: { 185 | const mathToken = el as TokenTextInlineMath; 186 | if (mathToken.display) { 187 | res += `\n\n$$\n${mathToken.content}\n$$\n\n` 188 | } else { 189 | res += `$${mathToken.content}$` 190 | } 191 | break 192 | } 193 | } 194 | } 195 | //console.log(joint +res) 196 | return joint + res 197 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 知乎备份剪藏 2 | 3 | 在这个信息纷繁复杂、稍纵即逝的时代,帮你保存知乎上珍贵的内容,进而打造个人知识库,方便日后查阅,高效检索。 4 | 5 | * 📑复制知乎文章/回答/想法为 Markdown 6 | * 📁下载文章/回答/想法为 zip(包含图片与文本,以及赞数时间等信息) 7 | * 📝下载文章/回答/想法为纯文本 8 | * 🖼剪藏文章/回答/想法为图片 9 | * ✏支持添加备注 10 | * 💬支持保存评论 11 | * 🎨丰富的可配置项 12 | * 📁【最近更新】可以保存内容到指定文件夹,兼容 Obsidian 13 | 14 | 注:(未全面测试,可能存在bug)此项目**非**爬蟲🐛,仅用于用户**日常保存喜欢的内容**。我们因热爱知乎而走到一起,请尊重内容作者权利,切勿用于抄袭与盈利。被保存的内容亦不能作为证据使用。使用此脚本即表示**您同意此页内容**,若引发任何纠纷后果自行承担。 15 | 16 | 此脚本目前已断断续续进行了一年多的开发维护,期间适配了各种场景和内容类型,添加了存图、备注和评论解析功能。基本的 Markdown 解析和 zip 下载使用了[这里的代码](https://github.com/Howardzhangdqs/zhihu-copy-as-markdown)(MIT),感谢他的探索。 17 | 18 | 如果你喜欢此项目,想要**赞赏支持**💰,可扫描[赞赏码](https://qtqz.github.io/img/sponsor.png)。 19 | 20 | ## 使用 21 | 22 | 安装油猴脚本:[greasyfork - 知乎备份剪藏](https://greasyfork.org/zh-CN/scripts/486538-%E7%9F%A5%E4%B9%8E%E5%A4%87%E4%BB%BD%E5%89%AA%E8%97%8F)。或者在这里将`dist/tampermonkey-script.js`复制粘贴进脚本管理器中。在此之前,你需要有一个脚本管理器,如 Tampermonkey(油猴,篡改猴)。⚠ 2024-10-29 起,新版油猴中(5.3.2),您必须至浏览器 - 扩展 - **打开开发者选项**后才能使用用户脚本。[如何](https://www.tampermonkey.net/faq.php#Q209)。如果点击安装没反应,请至浏览器 - 下载,查看脚本是否被拦截。 23 | 24 | **鼠标移到知乎内容上,会出现保存按钮,点击即可保存**(到下载目录)。已支持的页面有关注页、个人主页、回答页、问题页、文章页、想法页、收藏夹页、推荐页、搜索结果页;已支持的内容有文章、回答、想法。具体体功能解释: 25 | 26 | * 复制 Markdown:复制到剪贴板,语法见[Markdown Reference](https://commonmark.org/help/),可选是否保存元信息和评论 27 | * 下载 zip:将内容的图片、Markdown 文本、信息(赞数、时间等)、当前页评论(如果启用)保存为 zip,文件名格式`标题_作者_日期_备注.zip`。`.md`文件请用文本方式打开(如使用 Notepad3),语法同上。 28 | * 下载纯文本:将内容转为 Markdown 文本,并添加信息与评论(如果启用),保存为 `.md`单文件。 29 | * 剪藏图片:将当前内容(和评论)截为 PNG 图片,会自动隐藏你的头像以保护隐私。**请先滚动到底确保所有图片都加载**,否则图片会是空白(太长的截图建议用下面推荐软件保存) 30 | * 备注:备注会保存在文件名末尾,最长60字符,空格会转义为“-”,不能包含` \ / : * ? " < > |`。提示:可以选中拖动内容中的字到备注栏。 31 | * 保存评论:按需保存,需要先**手动逐页暂存**,详情按评论区的按钮操作。 32 | * 保存指定文件夹:**支持保存内容到指定文件夹,兼容 Obsidian**,详见[🎉 重大更新:支持保存内容到指定文件夹,兼容 Obsidian](./ReleaseNotes-0110.md) 33 | 34 | 选项开关:在目标页点击油猴,再点击脚本下方的开关以调整 35 | 36 | 可能的问题: 37 | 38 | * 如何保存PDF?选中内容右键-->打印-->打印为PDF 39 | * 能否批量保存某答主/问题/收藏夹?**不打算**做这样的功能,请找爬蟲,或使用下面推荐 40 | * 已知问题:保存为图片时部分样式轻微异常;未适配视频页和部分视频;如果**想法**有大于 4 张图,则只能保存前 4 张 41 | * 在关注页等页面,无法获取作者个性签名 42 | * 提示:个人页的**想法**,点击发布时间,即可进到想法页,截图评论 43 | * 如果脚本安装后无法运行(管理器中显示已启用未执行),请至浏览器 - 扩展 - 打开开发者选项,这是[油猴的要求](https://www.tampermonkey.net/faq.php#Q209) 44 | * 未确认与 *知乎增强 知乎美化* 脚本的兼容性,谨慎使用哦~ 45 | 46 | 其他推荐: 47 | 48 | - **SingleFile**,浏览器扩展,扩展商店自寻,可以将网页的全部或部分(比如个人主页的一串回答、弹出窗口中的一串评论)保存为`html`单文件。 49 | - **FSCapture**,网页长截图工具。可以先将网页缩放以节省空间。 50 | - **系统自带画图**,截图后拼长图保存评论 51 | - [chenluda/zhihu-download: 将知乎专栏文章转换为 Markdown 文件保存到本地](https://github.com/chenluda/zhihu-download) 52 | 53 | ## 开发 54 | 55 | ```bash 56 | pnpm i 57 | ``` 58 | 59 | `0.7.11+`已启用更方便的测试: 60 | 61 | 1. 允许脚本管理器 Tampermonkey 访问文件网址 右键插件图标-插件管理页面-访问文件网址 或者参照官方 [faq](https://tampermonkey.net/faq.php?ext=dhdg#Q204) 62 | 2. 在脚本管理器中安装`scripts/dev.js`,并且修改其`@require `为正确的路径,以调用本地的`dist/bundle.js`。 63 | 3. `pnpm dev` 64 | 4. 刷新目标网页 65 | 66 | ```bash 67 | pnpm build 68 | ``` 69 | 70 | > 需修改以解决[压缩包时间错误问题](https://github.com/Stuk/jszip/pull/735/files),并且,[不可安装 3.10.1](https://github.com/Stuk/jszip/issues/814#issuecomment-1139378561) 71 | 72 | ## 原理 73 | 74 | 技术路线为**解析 DOM**,而非**请求 api**,所以不易受限,更直观,更可靠 75 | 76 | 1. 获取页面中的所有 RichText 区块 77 | 2. 检测当前页面类型(文章/问答/想法等) 78 | 3. 根据需要将评论暂存,以备保存 79 | 4. 根据 DOM 节点生成 Lex(词法单元序列) 80 | 5. 用 Parser 解析 Lex,生成 Markdown 内容 81 | 6. 根据每个 `DOM` 获取标题等信息,生成 frontmatter 82 | 7. 获取标题、作者等元信息 83 | 8. 合成最终 Markdown 内容 84 | 85 | 86 | ## Changelog 87 | 88 | * 0.11.3(2025-12-04): 89 | - 修复一大堆关于公式的问题 90 | - 修复脚注不能用的问题 91 | - 跳过更多空白段落 92 | * 0.11.0(2025-12-03): 93 | - **可以保存内容到本地的指定目录**,便于分类,支持以多种格式保存 94 | - 添加消息提示系统 95 | - 样式和体验优化 96 | - 删除无用的 info.json 97 | - 为自定义保存文件名添加了默认值,方便修改 98 | - 修复此问题:对于同一个内容,保存过 ZIP 后,评论中图片链接就永久变为本地链接,影响再次保存 99 | * 0.10.52(2025-10-08): 100 | - 修复知乎更新后无法保存想法中图片的问题 101 | * 0.10.51(2025-09-29): 102 | - 修复知乎更新后保存失败的问题 103 | * 0.10.48(2025-07-28): 104 | - 修复**想法中很短的段落可能会缺失**的问题(从第二段起,短段落变为空白行),请大家自查之前保存的想法 105 | - 允许自定义保存后的文件名格式(通过油猴菜单输入) 106 | - 使评论区的换行与原来的一致 107 | - 存 zip 且无评论时,不再生成空的评论文件 108 | 109 |
110 | 更多更新日志 111 | 112 | * 0.10.44(2025-06-19): 113 | - 兼容知乎美化脚本的暗黑模式 114 | * 0.10.43(2025-06-14): 115 | - 修复搜索结果页新的保存出错问题 116 | - 优化按钮显示体验,避免按钮消失 117 | * 0.10.41(2025-06-01): 118 | - 修复有时不显示按钮的问题 119 | * 0.10.40(2025-05-30): 120 | - 修复最近保存专栏文章出错问题 121 | - 修复保存赞过的评论赞数量错误问题 122 | - 支持保存被折叠的评论 123 | - 支持保存评论中的@ 124 | - 补充多行评论中缺少的换行 125 | - 关注页关注问题的动态现在不会出按钮了 126 | * 0.10.32(2025-05-10): 127 | - 修复有时候油猴菜单会消失的问题 128 | - 修复在个人主页搜索后无法保存想法的问题 129 | - 修复某些页面下无法保存文章的评论的问题 130 | - 试图避免无法保存需重新保存的问题 131 | - 修复无法保存带下划线文字的问题(非链接) 132 | - 允许**删除文中多余的换行**(需通过油猴菜单手动开启) 133 | * 0.10.25(2025-03-07): 134 | - 点评论区提示按钮可以显示当前油猴选项了,避免忘记当前选的是什么 135 | - 下载 zip 按钮添加下载提示语 136 | - 修复未存评弹框点确定太快会无效的问题 137 | - 修复保存不了有且仅有多个小表情的评论的问题 138 | - 元信息中添加 IP属地(如果有) 139 | - **修复评论时间错误问题**,并且更精确一点了 140 | * 0.10.19(2025-02-25): 141 | - 下载 zip 时允许合并正文和评论(需通过油猴菜单手动开启) 142 | - 修复未存评弹框点确定后无法自动复制的问题 143 | - 修复在个人页搜索内容后按钮被隐藏的问题 144 | - 修复文章页有时不出现评论按钮的问题 145 | - 存长图时,若有图片未加载,给出提示 146 | - 修复不存图选项影响 zip 内文本的问题 147 | * 0.10.13(2025-02-18): 148 | - **重构主线程**,整理代码 149 | - 复制时支持复制评论了(需通过油猴菜单手动开启) 150 | - 支持复制或存文本时不保存图片(改为“[图片]”,需通过油猴菜单手动开启) 151 | - 修复在搜索结果页和文章页不能存评论的问题 152 | - 修复评论按钮显示突变 153 | - 点击存评论按钮后有了反馈 154 | * 0.10.2(2025-02-15): 155 | - 修复想法页可能不显示存评论按钮的问题 156 | - 修复存 zip 可能无法存评论的问题 157 | * 0.10.0(2025-01-13): 158 | - **全新的评论解析器**,可以解析弹出框中的评论 159 | - 优化代码与性能 160 | - 评论相对时间转绝对时间 161 | - 补充转发想法中缺少的换行 162 | * 25.1.3(0.9.32): 163 | - 保存想法的标题 164 | - 移除更多的搜索推荐词 165 | * 24.12.20(0.9.30): 166 | - 修复无法保存无字想法问题 167 | - 修复下载 zip 与油猴菜单的冲突 168 | - 现在提示保存失败后无需滚动即可重新保存 169 | * 24.12.3(0.9.26): 170 | - 修复突然无法下载 zip 问题 171 | - 现在展开内容后无需滚动即可保存 172 | - 开启复制带 fm 时不再额外带标题 173 | * 24.11.21(0.9.23): 174 | - 复制时可以包含 frontmatter 信息了(需通过油猴菜单手动打开) 175 | - 添加了油猴脚本选项**菜单** 176 | * 24.11.13(0.9.22): 177 | - 修复两处截图样式异常问题 178 | - 修复浏览器窗口过窄时按钮溢出屏幕的问题 179 | - 修复按时间排序的问题被误判为回答的问题 180 | * 24.10.24(0.9.18): 181 | - 修复保存分段引用内容未分段问题 182 | - 修复保存带标点加粗内容在阅读器中误加粗问题 183 | - 修复保存段首有空格内容在阅读器中误判为代码块问题 184 | - 修复收藏夹页无法保存部分图片问题 185 | - 保存图注(图片下方灰字)作为斜体的普通段落 186 | - 复制除想法外内容时添加标题 187 | * 24.8.26(0.9.11): 188 | - 修复保存转发的想法异常 189 | - 修复新的样式异常 190 | - frontmatter 添加作者个性签名 191 | * 24.7.10(0.9.7): 192 | - 修复搜索结果页保存报错 193 | - 修复获取评论数量不对 194 | * 24.6.13(0.9.6): 195 | - 修复新的截图出错问题 196 | * 24.6.12(0.9.5): 197 | - 文章页截图不会再截到按钮了 198 | - 移除没图片时多余的 assets 文件夹 199 | - **添加保存为单文件功能** 200 | - 支持保存评论中贴纸表情 201 | - 修复评论中图片重复的问题 202 | - 优化体验,写备注时可以把文本框拖大 203 | * 24.3.29(0.8.25): 204 | - 移除没图片评论时多余的 assets 文件夹 205 | - 修复新的无法保存评论问题 206 | - 下载文章时包含头图 207 | * 24.3.28(0.8.22): 208 | - 隐藏已折叠内容下的按钮 209 | - 修复保存无名用户内容出错 210 | - 修复按钮干扰选择文字的问题 211 | - 修复点击保存评论时奇怪的跳转问题 212 | * 24.3.27(0.8.18): 213 | - 保存失败时给予补救机会 214 | - 修复按钮被目录遮挡无法点击 215 | - 修复无法保存机构号主页内容 216 | - 修复 url 获取错误 217 | - 内容子标题从 h2 开始 218 | - 解析参考文献 219 | - 解析目录 220 | * 24.3.20(0.8.8): 221 | - 修复保存匿名用户内容出错 222 | - 增加保存失败原因提示 223 | * 24.3.4(0.8.7): 224 | - 更方便的测试 225 | - 解析评论为Markdown 226 | - 评论图片本地化 227 | - **完善解析评论**修复bug 228 | - 修复zip内文件日期错误问题 229 | - 修复无法下载视频问题 230 | - 适配推荐页、搜索结果页 231 | - info中添加ip属地(如果有) 232 | - 修复想法无法保存图片 233 | * 24.2.29(0.7.10): 234 | - 备注改为最长60字 235 | - 修复个人页无法保存想法问题 236 | - 修复保存zip处理评论可能出错问题 237 | * 24.2.4(0.7.7): 238 | - 为Markdown添加frontmatter 239 | - 修正下载md内的图片路径为本地路径 240 | - 对于有目录的内容,减轻按钮与目录的重叠 241 | * 24.1.19(0.7.4): 242 | - 截图适配专栏文章 243 | * 24.1.13(0.7.x): 244 | - 粗略**解析评论**并添加到zip 245 | - 修复大量bug 246 | - 准备发布 247 | * 24.1.13(0.6.x): 248 | - **适配想法**中的复杂情形 249 | * 24.1.11(0.5.x): 250 | - **添加截图功能** 251 | - 初步适配想法 252 | * 24.1.2(0.4.x): 253 | - 初步重制 254 | * 23.12.29: 255 | - 立项 256 | 257 |
-------------------------------------------------------------------------------- /src/core/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AuthorType } from "./types" 2 | 3 | /** 4 | * Converts a Zhihu link to a normal link. 5 | * @param link - The Zhihu link to convert. 6 | * @returns The converted normal link. 7 | */ 8 | export const ZhihuLink2NormalLink = (link: string): string => { 9 | const url = new URL(link) 10 | if (url.hostname == "link.zhihu.com") { 11 | const target = new URLSearchParams(url.search).get("target") 12 | return decodeURIComponent(target) 13 | } 14 | else { 15 | if (link.match(/#/)) return '#' + link.split('#')[1] 16 | else return link 17 | } 18 | } 19 | 20 | 21 | /** 22 | * Get the title of the dom. 23 | * @param dom - The dom to get title. 24 | * @returns The title of the dom. 25 | */ 26 | export const getTitle = (dom: HTMLElement, scene: string, type: string) => { 27 | let t 28 | if (scene == "follow" || scene == "people" || scene == "collection" || scene == "pin") { 29 | let title_dom = dom.closest('.ContentItem').querySelector("h2.ContentItem-title a") 30 | if (type == "answer" || type == "article") { 31 | //搜索结果页最新讨论 32 | !title_dom ? title_dom = dom.closest('.HotLanding-contentItem').querySelector("h2.ContentItem-title a") : 0 33 | t = title_dom.textContent 34 | 35 | } 36 | else {//想法 37 | if (title_dom) { 38 | t = "想法:" + title_dom.textContent + '-' + dom.innerText.slice(0, 16).trim().replace(/\s/g, "") 39 | } else t = "想法:" + dom.innerText.slice(0, 24).trim().replace(/\s/g, "") 40 | } 41 | } 42 | //问题/回答 43 | else if (scene == "question" || scene == "answer") { 44 | t = (dom.closest('.QuestionPage').querySelector("meta[itemprop=name]") as HTMLMetaElement).content 45 | } 46 | //文章 47 | else if (scene == "article") { 48 | t = dom.closest('.Post-Main').querySelector("h1.Post-Title").textContent 49 | } 50 | else t = "无标题" 51 | //替换英文问号为中文问号,因标题中间也可能有问号所以不去掉 52 | return t.replace(/\?/g, "?").replace(/\/|\\|<|>|"|\*|\?|\||\:/g, "-") 53 | } 54 | 55 | /** 56 | * Get the author of the dom. 57 | * @param dom - The dom to get author. 58 | * @returns The author of the dom. 59 | */ 60 | export const getAuthor = (dom: HTMLElement, scene: string, type: string): AuthorType => { 61 | let author_dom 62 | //寻找包含昵称+链接+签名的节点 63 | 64 | if (scene == "follow") { 65 | let p = dom.closest('.ContentItem') 66 | //唯独关注页作者在ContentItem外面,原创内容没有作者栏 67 | author_dom = p.querySelector(".AuthorInfo-content") || 68 | dom.closest('.Feed').querySelector(".FeedSource .AuthorInfo-content") || 69 | dom.closest('.Feed').querySelector(".FeedSource-firstline") 70 | } 71 | ///个人/问题/回答/想法/收藏夹 72 | else if (scene == "people" || scene == "question" || scene == "answer" || scene == "pin" || scene == "collection") { 73 | let p = dom.closest('.ContentItem') 74 | author_dom = p.querySelector(".AuthorInfo-content") 75 | // 个人页的搜索结果的想法没有作者栏 76 | if (!author_dom && location.href.includes('search')) { 77 | author_dom = document.querySelector('.ProfileHeader-title') 78 | return { 79 | name: author_dom.children[0].textContent, 80 | url: location.href.match(/(https.*)\/search/)[1], 81 | badge: author_dom.children[1].textContent 82 | } 83 | } 84 | } 85 | //文章 86 | else if (scene == "article") { 87 | author_dom = dom.closest('.Post-Main').querySelector(".Post-Author") 88 | } 89 | 90 | if (author_dom) { 91 | let authorName_dom = author_dom.querySelector(".AuthorInfo-name .UserLink-link") as HTMLAnchorElement || 92 | author_dom.querySelector(".UserLink-link") as HTMLAnchorElement || 93 | author_dom.querySelector(".UserLink.AuthorInfo-name")//匿名用户 94 | let authorBadge_dom = author_dom.querySelector(".AuthorInfo-badge") as HTMLDivElement 95 | //console.log("authorName_dom", authorName_dom) 96 | return { 97 | name: authorName_dom.innerText || (authorName_dom.children[0] ? authorName_dom.children[0].getAttribute("alt") : ''),//???//没有名字的用户https://www.zhihu.com/people/8-90-74/answers 98 | url: authorName_dom.href, 99 | badge: authorBadge_dom ? authorBadge_dom.innerText : "" 100 | } 101 | } 102 | else console.error("未找到author_dom") 103 | } 104 | 105 | /** 106 | * Get the URL of the dom. 107 | * 应该按每个内容获取URL,而非目前网址 108 | * @param dom - The dom to get URL. 109 | * @returns The URL of the dom. 110 | */ 111 | export const getURL = (dom: HTMLElement, scene: string, type: string): string => { 112 | let url 113 | //文章/想法/回答 114 | if (scene == "article" || scene == "pin" || scene == "answer") { 115 | url = window.location.href 116 | let q = url.match(/\?/) ? url.match(/\?/).index : 0 117 | if (q) url = url.slice(0, q) 118 | return url 119 | } 120 | //关注/个人/问题/等 121 | // if (scene == "follow" || scene == "people" || scene == "question") 122 | else { 123 | if (type == "answer" || type == "article") { 124 | //普通 125 | let p = dom.closest('.ContentItem') 126 | let url_dom = (p.querySelector(".ContentItem>meta[itemprop=url]") as any) 127 | //搜索结果页 128 | if (!url_dom) { 129 | url_dom = p.querySelector(".ContentItem h2 a") 130 | } 131 | //搜索结果页最新讨论 132 | if (!url_dom) { 133 | p = dom.closest('.HotLanding-contentItem') 134 | url_dom = p.querySelector(".ContentItem h2 a") 135 | } 136 | url = url_dom.content || (url_dom.href) 137 | if (url.slice(0, 5) != "https") url = "https:" + url 138 | return url 139 | } 140 | //pin 141 | else { 142 | let zopdata = dom.closest('.ContentItem').getAttribute("data-zop") 143 | return "https://www.zhihu.com/pin/" + JSON.parse(zopdata).itemId 144 | } 145 | } 146 | } 147 | 148 | /** 149 | * 150 | * 时间: 151 | * 使用内容下显示的时间 152 | * 153 | */ 154 | export const getTime = async (dom: HTMLElement, scene: string, type?: string): Promise<{ 155 | created: string, 156 | modified: string 157 | }> => { 158 | //关注/个人/问题/回答页 159 | //if (scene == "follow" || scene == "people" || scene == "question" || scene == "answer") {//收藏夹 160 | // if (type != "" || type == "article") { 161 | let created, modified, time_dom 162 | if (scene != "article") { 163 | time_dom = dom.closest('.ContentItem').querySelector(".ContentItem-time") 164 | created = time_dom.querySelector("a").getAttribute("data-tooltip").slice(4)//2023-12-30 16:12 165 | modified = time_dom.querySelector("a").innerText.slice(4) 166 | return { created, modified } 167 | } 168 | else {//文章 169 | time_dom = dom.closest('.Post-content').querySelector(".ContentItem-time") as HTMLElement 170 | modified = time_dom.childNodes[0].textContent.slice(4) 171 | time_dom.click() 172 | await new Promise((resolve) => { 173 | setTimeout(() => { 174 | resolve() 175 | }, 1000) 176 | }) 177 | created = time_dom.childNodes[0].textContent.slice(4) 178 | time_dom.click() 179 | return { created, modified } 180 | } 181 | // } 182 | //} 183 | } 184 | 185 | export const getUpvote = (dom: HTMLElement, scene: string | null, type: string): number => { 186 | //关注/个人/问题/回答页 187 | //if (scene == "follow" || scene == "people" || scene == "question" || scene == "answer") {//收藏夹 188 | //up_dom = (getParent(dom, "ContentItem") as HTMLElement).querySelector(".VoteButton--up") as HTMLElement//\n赞同 5.6 万 189 | let upvote, up_dom 190 | if (type == "pin") { 191 | //个人页的想法有2层ContentItem-actions,想法页有1层 192 | up_dom = dom.closest('.ContentItem').querySelector(".ContentItem-actions>.ContentItem-actions") || 193 | dom.closest('.ContentItem').querySelector(".ContentItem-actions") 194 | up_dom = up_dom.childNodes[0] 195 | upvote = up_dom.textContent.replace(/,|\u200B/g, '').slice(3)//0, -4 196 | upvote ? 0 : upvote = 0 197 | } 198 | else if (scene == "article") { 199 | up_dom = dom.closest('.Post-content').querySelector(".ContentItem-actions .VoteButton") 200 | upvote = up_dom.textContent.replace(/,|\u200B/g, '').slice(3) 201 | upvote ? 0 : upvote = 0 202 | } 203 | else { 204 | let zaedata = dom.closest('.ContentItem').getAttribute("data-za-extra-module") 205 | //搜索结果页 206 | if (window.location.href.includes('/search?')) { 207 | upvote = dom.closest('.RichContent').querySelector(".ContentItem-actions .VoteButton").getAttribute('aria-label').slice(3) || 0 208 | } 209 | else upvote = JSON.parse(zaedata).card.content.upvote_num 210 | } 211 | return parseInt(upvote) 212 | // } 213 | //} 214 | } 215 | 216 | export const getCommentNum = (dom: HTMLElement, scene: string, type: string): number => { 217 | //关注/个人/问题/回答页 218 | //if (scene == "follow" || scene == "people" || scene == "question" || scene == "answer") {//收藏夹 219 | let cm, cm_dom, p 220 | //被展开的评论区 221 | p = dom.closest('.ContentItem') 222 | p ? cm_dom = p.querySelector(".css-1k10w8f") : 0 223 | if (cm_dom) { 224 | cm = cm_dom.textContent.replace(/,|\u200B/g, "").slice(0, -4) 225 | } 226 | else if (type == "pin") { 227 | cm_dom = dom.closest('.ContentItem').querySelector(".ContentItem-actions>.ContentItem-actions") || 228 | dom.closest('.ContentItem').querySelector(".ContentItem-actions") 229 | cm_dom = cm_dom.childNodes[1] 230 | cm = cm_dom.textContent.replace(/,|\u200B/g, "").slice(0, -4) 231 | cm ? 0 : cm = 0 232 | } 233 | else if (scene == "article") { 234 | cm_dom = dom.closest('.Post-content').querySelector(".BottomActions-CommentBtn") 235 | cm = cm_dom.textContent.replace(/,|\u200B/g, '').slice(0, -4) 236 | cm ? 0 : cm = 0 237 | } 238 | else { 239 | let zaedata = dom.closest('.ContentItem').getAttribute("data-za-extra-module") 240 | //搜索结果页 241 | if (window.location.href.includes('/search?')) { 242 | cm_dom = dom.closest('.RichContent').querySelector("button.ContentItem-action") 243 | cm = cm_dom.textContent.replace(/,|\u200B/g, "").slice(0, -4) 244 | } 245 | else cm = JSON.parse(zaedata).card.content.comment_num 246 | } 247 | return parseInt(cm) 248 | // } 249 | //} 250 | } 251 | 252 | export const getRemark = (dom: HTMLElement): string => { 253 | let remark, p = dom.closest('.ContentItem')//文章页没有,remark = remark.replace(/\/|\\|<|>|"|\*|\?|\||\:/g, "-") 254 | if (!p) p = dom.closest('.PinItem') 255 | if (!p) p = dom.closest('.Post-content') 256 | if (p) remark = (p.querySelector("textarea.to-remark") as HTMLInputElement).value.replace(/\s/g, "-") 257 | if (remark.match(/\/|\\|<|>|"|\*|\?|\||\:/g)) return "非法备注" 258 | return remark 259 | } 260 | 261 | /** 262 | * 获取是否需要保存评论,用于截图,zip 263 | */ 264 | export const getCommentSwitch = (dom: HTMLElement): boolean => { 265 | let s, p = dom.closest('.ContentItem') 266 | if (!p) p = dom.closest('.PinItem') 267 | if (!p) p = dom.closest('.Post-content') 268 | if (p) s = (p.querySelector("input.to-cm") as HTMLInputElement).checked 269 | return s 270 | } 271 | 272 | /** 273 | * Get the Location of the dom. 274 | * @param dom - The dom. 275 | * @returns string | null 276 | */ 277 | export const getLocation = (dom: HTMLElement, scene: string, type: string): string | null => { 278 | let location, el = dom.closest('.ContentItem')//想法类型、文章页没有 279 | if (!el) el = dom.closest('.PinItem') 280 | if (!el) el = dom.closest('.Post-content') 281 | try { 282 | if (el) { 283 | location = el.querySelector('.ContentItem-time').childNodes[1]?.textContent.slice(6) 284 | } 285 | if (!location && scene == "people") { 286 | let name = document.querySelector('.ProfileHeader-name').childNodes[0].textContent 287 | if (name == getAuthor(dom, scene, type).name) { 288 | location = document.querySelector('.css-1xfvezd').textContent.slice(5) 289 | } 290 | } 291 | } catch (e) { 292 | console.error('保存location出错', e) 293 | } 294 | return location 295 | } 296 | -------------------------------------------------------------------------------- /src/dealItem.ts: -------------------------------------------------------------------------------- 1 | import * as JSZip from "jszip" 2 | import { lexer } from "./core/lexer" 3 | import { TokenType, TokenFigure } from "./core/tokenTypes" 4 | import { parser } from "./core/parser" 5 | import { getAuthor, getTitle, getURL, getTime, getUpvote, getCommentNum, getRemark, getCommentSwitch, getLocation } from "./core/utils" 6 | import savelex from "./core/savelex" 7 | import { renderAllComments } from "./core/renderComments" 8 | import { showToast } from "./core/toast" 9 | import { hideObsidianModal } from "./core/obsidianSaver" 10 | 11 | interface DealItemResult { 12 | zip?: JSZip; 13 | textString?: string; 14 | title?: string; 15 | } 16 | 17 | function detectScene(): string { 18 | const pathname = location.pathname 19 | let scene 20 | if (pathname == "/follow") scene = "follow" 21 | else if (pathname.includes("/people") || pathname.includes("/org")) scene = "people" 22 | else if (pathname.includes("/question") && !pathname.includes('answer')) scene = "question" 23 | else if (pathname.includes("/question") && pathname.includes('answer')) scene = "answer" 24 | else if (pathname.includes("/pin")) scene = "pin" 25 | else if (location.hostname == "zhuanlan.zhihu.com") scene = "article" 26 | else if (pathname.includes("/collection")) scene = "collection" 27 | else if (pathname.includes("/search")) scene = "collection" 28 | else if (location.href == "https://www.zhihu.com/") scene = "collection"//搜索、推荐、收藏夹似乎一样 29 | else console.log("未知场景") 30 | //https://www.zhihu.com/question/2377606804/answers/updated 按时间排序的问题 31 | if (pathname.slice(0, 9) == "/question" && !pathname.includes('updated')) scene = "question" 32 | return scene 33 | } 34 | 35 | function detectType(dom: HTMLElement, bt: string, ev?: Event): string | null { 36 | //ContentItem 37 | let type 38 | if (dom.closest('.AnswerItem')) type = "answer" 39 | else if (dom.closest('.ArticleItem')) type = "article" 40 | else if (dom.closest('.Post-content')) type = "article" 41 | else if (dom.closest('.PinItem')) type = "pin" 42 | else { 43 | console.log("未知内容") 44 | 45 | if (!ev) { 46 | alert('请勿收起又展开内容,否则会保存失败。请手动重新保存。') 47 | } 48 | else { 49 | let zhw = (ev.target as HTMLElement).closest('.zhihubackup-wrap'), 50 | bz = zhw.querySelector('textarea').value, 51 | fa = zhw.closest('.ContentItem') || zhw.closest('.Post-content') || zhw.closest('.HotLanding-contentItem') 52 | !fa ? alert('请勿收起又展开内容,否则会保存失败。请重新保存。') : 0 53 | setTimeout(() => { 54 | fa.querySelector('textarea').value = bz 55 | }, 200) 56 | setTimeout(() => { 57 | (fa.querySelector(`.to-${bt}`) as HTMLElement).click() 58 | }, 250) 59 | } 60 | document.querySelectorAll('.zhihubackup-wrap').forEach((w) => w.remove()) 61 | // @ts-ignore 62 | setTimeout(window.zhbf, 100) 63 | return; 64 | } 65 | return type 66 | } 67 | 68 | export default async (dom: HTMLElement, button?: string, event?: Event): Promise => { 69 | //console.log(dom) 70 | //确认场景 71 | let scene = detectScene() 72 | let type = detectType(dom, button, event) 73 | if (!type) { 74 | return 75 | } 76 | //console.log(scene + type) 77 | 78 | if (!scene || !type) return; 79 | /* try { 80 | // @ts-ignore 仅供调试 81 | var gminfo = GM_info 82 | console.log(gminfo) 83 | script.name 84 | } catch (e) { 85 | } */ 86 | 87 | showToast('✅ 开始保存'); 88 | const title = getTitle(dom, scene, type), 89 | author = getAuthor(dom, scene, type), 90 | time = await getTime(dom, scene),//????????? 91 | url = getURL(dom, scene, type), 92 | upvote_num = getUpvote(dom, scene, type), 93 | comment_num = getCommentNum(dom, scene, type), 94 | Location = getLocation(dom, scene, type) 95 | let remark = getRemark(dom) 96 | 97 | if (remark === "非法备注") { 98 | alert(decodeURIComponent("备注不可包含%20%20%2F%20%3A%20*%20%3F%20%22%20%3C%20%3E%20%7C")) 99 | return; 100 | } 101 | remark ? remark = "_" + remark : 0 102 | 103 | if (button == 'png') { 104 | const imgs = dom.querySelectorAll('figure img') 105 | let noload 106 | imgs.forEach((i: HTMLImageElement) => { 107 | if (i.src.match(/data\:image\/svg\+xml;.*><\/svg>/)) noload = 1 108 | }) 109 | if (noload) { 110 | alert('内容中还有未加载的图片,请滚动到底,使图都加载后再保存\n若效果不好,可使用其他软件保存') 111 | return; 112 | } 113 | return { 114 | title: getFilename() 115 | } 116 | } 117 | 118 | // 复制与下载纯文本时不保存图片,影响所有parser(),还有评论的图片,暂存到window 119 | var no_save_img = false, 120 | skip_empty_p = false 121 | try { 122 | // @ts-ignore 123 | no_save_img = GM_getValue("no_save_img") 124 | // @ts-ignore 125 | window.no_save_img = no_save_img 126 | // @ts-ignore 127 | skip_empty_p = GM_getValue("skip_empty_p") 128 | // @ts-ignore 129 | window.skip_empty_p = skip_empty_p 130 | 131 | } catch (e) { 132 | console.warn(e) 133 | } 134 | 135 | /** 136 | * 生成frontmatter 137 | * 标题,链接,作者名,赞数,评论数,创建时间,修改时间 138 | * (author.badge ? ('\nauthor_badge: ' + author.badge) : '') 139 | */ 140 | const getFrontmatter = (): string => { 141 | let fm = '---' 142 | + '\ntitle: ' + title 143 | + '\nurl: ' + url 144 | + '\nauthor: ' + author.name 145 | + '\nauthor_badge: ' + author.badge 146 | + `${Location ? '\nlocation: ' + Location : ''}` 147 | + '\ncreated: ' + time.created 148 | + '\nmodified: ' + time.modified 149 | + '\nupvote_num: ' + upvote_num 150 | + '\ncomment_num: ' + comment_num 151 | + '\n---\n' 152 | return fm 153 | } 154 | 155 | /** 156 | * 生成文件名 157 | */ 158 | function getFilename(): string { 159 | let fm = title + "_" + author.name + "_" + time.modified.slice(0, 10) + remark 160 | try { 161 | // @ts-ignore 162 | var efm = GM_getValue("edit_Filename") 163 | if (efm) { 164 | fm = eval(efm) 165 | } 166 | } catch (e) { 167 | console.warn(e) 168 | } 169 | return fm 170 | } 171 | 172 | /** 173 | * 生成目录 174 | */ 175 | const TOC = ((): string[] | null => { 176 | let toc = (dom.closest('.ContentItem') || dom.closest('.Post-content') as HTMLElement).querySelector(".Catalog-content") 177 | let items: string[] = [] 178 | if (toc) { 179 | let i = 1, j = 1 180 | toc.childNodes.forEach((e) => { 181 | if ((e as HTMLElement).classList.contains('Catalog-FirstLevelTitle')) { 182 | items.push(i++ + '. ' + e.textContent) 183 | j = 1 184 | } 185 | else { 186 | items.push(' ' + j++ + '. ' + e.textContent) 187 | } 188 | }) 189 | return ['## 目录', items.join('\n')] 190 | } 191 | else return null 192 | })() 193 | 194 | const lex = lexer(dom.childNodes as NodeListOf, type) 195 | var md: string[] = [], originPinMD = [] 196 | 197 | //console.log("lex", lex) 198 | //保存文章头图 199 | let headImg = document.querySelector('span>picture>img') 200 | if (scene == 'article' && headImg) { 201 | const src = headImg.getAttribute("src") 202 | if (src) lex.unshift({ 203 | type: TokenType.Figure, 204 | src, 205 | local: false, 206 | dom: headImg 207 | } as TokenFigure) 208 | } 209 | 210 | //是转发的想法,对源想法解析,并准备附加到新想法下面 211 | if (type == "pin" && dom.closest('.PinItem').querySelector(".PinItem-content-originpin")) { 212 | const dom2 = dom.closest('.PinItem').querySelector(".PinItem-content-originpin .RichText") 213 | const lex2 = lexer(dom2.childNodes as NodeListOf, type) 214 | //markdown = markdown.concat(parser(lex2).map((l) => "> " + l)) 215 | originPinMD.push('\n\n' + parser(lex2).map((l) => "> " + l).join("\n> \n")) 216 | } 217 | 218 | // 获取想法图片/标题 219 | if (type == "pin") { 220 | const pinItem = dom.closest('.PinItem') 221 | if (pinItem.querySelector(".ContentItem-title")) 222 | lex.unshift({ 223 | type: TokenType.Text, 224 | content: [{ 225 | type: TokenType.PlainText, 226 | text: '**' + pinItem.querySelector(".ContentItem-title").textContent + '**' 227 | }] 228 | }) 229 | if (pinItem.querySelector(".PinItem-remainContentRichText")) { 230 | const imgs = pinItem.querySelectorAll(".PinItem-remainContentRichText img") 231 | imgs.forEach((img) => { 232 | lex.push({ 233 | type: TokenType.Figure, 234 | src: img.getAttribute("data-original") || img.getAttribute("data-actualsrc"), 235 | } as TokenFigure) 236 | }) 237 | } 238 | } 239 | 240 | 241 | //解析评论 242 | let commentText = '', commentsImgs: string[] = [] 243 | const dealComments = async () => { 244 | try { 245 | if (getCommentSwitch(dom)) { 246 | let p = dom.closest('.ContentItem') || dom.closest('.Post-content') 247 | let openComment = p.querySelector(".Comments-container") 248 | let itemId = type + url.split('/').pop() 249 | let tip = '' 250 | 251 | if (openComment && openComment.querySelector('.css-189h5o3')) { 252 | let t = '**' + openComment.querySelector('.css-189h5o3').textContent + '**' //评论区已关闭|暂无评论 253 | if (button == 'text') commentText = t 254 | else zip.file("comments.md", t) 255 | } 256 | else { 257 | if (openComment && openComment.querySelector('.css-1tdhe7b')) tip = '**评论内容由作者筛选后展示**\n\n' 258 | 259 | // @ts-ignore 260 | let commentsData = window.ArticleComments[itemId]?.comments as Map 261 | if (!commentsData) { 262 | if (!openComment) return;//既没评论数据也没展开评论区 263 | let s = confirm('您还未暂存任何评论,却展开了评论区,是否立即【暂存此页评论并保存】?【否】则什么也不做\n(若不想存评,请收起评论区或取消勾选框)') 264 | let obsidian = document.querySelector("#zhihu-obsidian-modal") as HTMLElement 265 | let display = obsidian?.style.display 266 | if (!s) { 267 | showToast('❎ 取消保存'); 268 | if (display == "block") { 269 | hideObsidianModal() 270 | } 271 | return 'return' 272 | } 273 | else { 274 | (openComment.querySelector('.save') as HTMLElement).click() 275 | setTimeout(() => { 276 | if (display == "block") { 277 | showToast('❎ 取消保存'); 278 | hideObsidianModal() 279 | return alert('已【暂存此页评论】,由于这次是自定义文件夹保存,请再次手动保存文件。') 280 | } 281 | (p.querySelector(`.zhihubackup-wrap .to-${button}`) as HTMLElement).click() 282 | }, 1900) 283 | return 'return' 284 | } 285 | } 286 | let num_text = tip + '共 ' + comment_num + ' 条评论,已存 ' + commentsData.size + ' 条' + '\n\n' 287 | if (button == 'text' || button == 'copy') { 288 | // 准备添加第三种图片归宿,完全舍弃 289 | [commentText, commentsImgs] = renderAllComments(commentsData, false) 290 | commentText = num_text + commentText 291 | } 292 | else if (button == 'zip') { 293 | [commentText, commentsImgs] = renderAllComments(commentsData, true) 294 | commentText = num_text + commentText 295 | if (commentsImgs.length) { 296 | const assetsFolder = zip.folder('assets') 297 | for (let i = 0; i < commentsImgs.length; i++) { 298 | const response = await fetch(commentsImgs[i]) 299 | const arrayBuffer = await response.arrayBuffer() 300 | const fileName = commentsImgs[i].replace(/\?.*?$/, "").split("/").pop() 301 | assetsFolder.file(fileName, arrayBuffer) 302 | } 303 | } 304 | } 305 | } 306 | } 307 | } catch (e) { 308 | console.warn("评论:", e) 309 | alert('主要工作已完成,但是评论保存出错了') 310 | } 311 | } 312 | 313 | 314 | if (button == 'copy') { 315 | try { 316 | // @ts-ignore 317 | var copy_save_fm = GM_getValue("copy_save_fm"), 318 | // @ts-ignore 319 | copy_save_cm = GM_getValue("copy_save_cm") 320 | } catch (e) { 321 | console.warn(e) 322 | } 323 | md = TOC ? TOC.concat(parser(lex)) : parser(lex) 324 | if (type == "pin" && dom.closest('.PinItem').querySelector(".PinItem-content-originpin")) { 325 | md = md.concat(originPinMD) //解决保存转发的想法异常 326 | } 327 | if (copy_save_fm) { 328 | md = [getFrontmatter()].concat(md)//放到剪贴板,string[] 329 | } 330 | if (copy_save_cm) { 331 | if (await dealComments() == 'return') return; 332 | commentText ? commentText = '\n\n---\n\n## 评论\n\n' + commentText : 0 333 | md.push(commentText) 334 | } 335 | if (type != 'pin' && !copy_save_fm) 336 | return { textString: [title].concat(md).join('\n\n') }//复制内容增加标题 337 | else 338 | return { textString: md.join('\n\n') } 339 | } 340 | // ============================以下只有 text 或 zip 2种情况=========================== 341 | 342 | if (button == 'text') { 343 | if (await dealComments() == 'return') return; 344 | commentText ? commentText = '\n\n---\n\n## 评论\n\n' + commentText : 0 345 | let md2: string[] = [] 346 | if (type == "pin" && dom.closest('.PinItem').querySelector(".PinItem-content-originpin")) { 347 | md2 = originPinMD 348 | } 349 | return { 350 | textString: getFrontmatter() + (TOC ? TOC.join("\n\n") + '\n\n' : '') + parser(lex).join("\n\n") + md2.join("\n\n") + commentText, 351 | title: getFilename() 352 | } 353 | } 354 | 355 | if (button == 'zip') { 356 | //对lex的再处理,保存资产,并将lex中链接改为本地 357 | var { zip, localLex } = await savelex(lex) 358 | if (await dealComments() == 'return') return; 359 | if (type == "pin" && dom.closest('.PinItem').querySelector(".PinItem-content-originpin")) { 360 | md = parser(localLex).concat(md) 361 | } 362 | else md = parser(localLex) 363 | 364 | try { 365 | // @ts-ignore 366 | var zip_merge_cm = GM_getValue("zip_merge_cm") 367 | 368 | // ZIP共同解包模式下,强制合并文本和评论 369 | let obsidian = document.querySelector("#zhihu-obsidian-modal") as HTMLElement 370 | let display = obsidian?.style.display 371 | let btn2 = document.querySelector("#zhihu-obsidian-modal #btn-2") as HTMLElement 372 | if (display == "block" && btn2?.classList.contains("selected")) { 373 | zip_merge_cm = true 374 | } 375 | } catch (e) { 376 | console.warn(e) 377 | } 378 | if (zip_merge_cm) { 379 | commentText ? commentText = '\n\n---\n\n## 评论\n\n' + commentText : 0 380 | md.push(commentText) 381 | } 382 | else if (commentText) 383 | zip.file("comments.md", commentText) 384 | 385 | zip.file("index.md", getFrontmatter() + (TOC ? TOC.join("\n\n") + '\n\n' : '') + md.join("\n\n")) 386 | } 387 | 388 | return { 389 | zip, 390 | title: getFilename() 391 | } 392 | } -------------------------------------------------------------------------------- /src/core/parseComments.js: -------------------------------------------------------------------------------- 1 | // 在window对象上创建存储空间 2 | //window.ArticleComments = window.ArticleComments || {}; 3 | 4 | class CommentParser { 5 | constructor(articleKey) { 6 | this.articleKey = articleKey; 7 | // 确保文章的评论存储空间存在 8 | window.ArticleComments[articleKey] = window.ArticleComments[articleKey] || { 9 | comments: new Map(), // 使用Map存储评论,key为评论ID 10 | lastUpdateTime: null 11 | }; 12 | } 13 | 14 | /** 15 | * 解析单条评论 16 | * @param {Element} commentElement - 评论元素 17 | * @returns {Object} 解析后的评论对象 18 | */ 19 | parseComment(commentElement) { 20 | const commentId = commentElement.getAttribute('data-id'); 21 | 22 | // 查找评论作者与被回复者 23 | const authorElement = commentElement.children[0].children[1].children[0].querySelectorAll('a'); 24 | const author = authorElement[0].textContent 25 | let author2 26 | if (authorElement[1]) { 27 | author2 = authorElement[1].textContent 28 | } 29 | 30 | // 查找评论内容 31 | const contentElement = commentElement.querySelector('.CommentContent'); 32 | let textContentPlain = '' // string | string[] 33 | let img = '' 34 | Array.from(contentElement.childNodes).map(node => { 35 | //评论内容最小元素 36 | if (node.nodeName == 'DIV') { 37 | if (node.classList.contains('comment_img') || node.classList.contains('comment_sticker')) { 38 | img = node.querySelector('img').getAttribute('data-original') 39 | } 40 | else if (node.classList.contains('css-1gomreu')) {//评论中的@ answer/105002650041 41 | let link = node.querySelector('a').href 42 | textContentPlain += '[' + node.textContent + '](' + link + ')' 43 | } 44 | } 45 | else if (node.nodeName == 'IMG') textContentPlain += node.alt//小表情 46 | else if (node.nodeName == 'A') { 47 | let link = ZhihuLink2NormalLink(node.href) 48 | textContentPlain += '[' + node.textContent + '](' + link + ')' 49 | } 50 | else if (node.nodeName == 'BR') textContentPlain += '\n' 51 | else if (node.nodeName == 'P') {//如果一条评论有且仅有多个小表情,会用P包裹,有时分段内容也会 52 | node.childNodes.forEach(c => { 53 | textContentPlain += c.alt || c.textContent 54 | if (c.nodeName == 'BR') textContentPlain += '\n' 55 | }) 56 | } 57 | else textContentPlain += node.textContent 58 | //暂不处理图片,因为图片只会存在于文末。每条评论最多只有一张图片应该 59 | }); 60 | 61 | let content = textContentPlain 62 | 63 | const timeElement = commentElement.querySelector('.css-12cl38p'); 64 | const time = timeElement ? relativeToAbsoluteDate(timeElement.textContent) : ''; 65 | 66 | const locationElement = commentElement.querySelector('.css-ntkn7q'); 67 | const location = locationElement ? locationElement.textContent : ''; 68 | 69 | const likeBox = commentElement.querySelector('.css-140jo2'), 70 | likeButton = likeBox.querySelector('.css-1vd72tl') || likeBox.querySelector('.css-1staphk') //赞过的 71 | const likes = likeButton?.textContent.match(/\d+/) ? parseInt(likeButton.textContent.match(/\d+/)[0]) : 0 72 | 73 | //const isAuthor = !!commentElement.querySelector('.css-8v0dsd'); 74 | 75 | return { 76 | id: commentId, 77 | author, 78 | content, 79 | time, 80 | location, 81 | likes, 82 | //isAuthor, 83 | img, 84 | beReplied: author2, 85 | parentId: null, // 将在后续处理中设置 86 | replies: [], // 子评论ID列表 87 | //updateTime: new Date().getTime() 88 | }; 89 | } 90 | 91 | /** 92 | * 构建评论层级关系 93 | * @param {Element} container - 评论容器元素 94 | */ 95 | buildCommentHierarchy(container) { 96 | const commentElements = Array.from(container.querySelectorAll('[data-id]')); 97 | const commentsData = window.ArticleComments[this.articleKey].comments; 98 | 99 | commentElements.forEach(element => { 100 | //console.log(element) 101 | const commentId = element.getAttribute('data-id'); 102 | const comment = this.parseComment(element); 103 | 104 | // 判断是否为回复评论 105 | // 如果当前评论元素的子元素有css-1kwt8l8类名(一个缩进),说明这是一条回复评论 106 | let parentElement, isReplyComment = element.firstElementChild.classList.contains('css-1kwt8l8'); 107 | // 另一种情况,弹出框的回复评论(因回复太多而弹出的,和弹出框子页面的,非单纯弹出框) 108 | if (!isReplyComment) { 109 | isReplyComment = element.closest('.css-16zdamy') 110 | parentElement = container.querySelector('.css-tpyajk [data-id]') 111 | } else parentElement = element.parentElement; 112 | if (isReplyComment) { 113 | // 向上或向里查找最近的不是回复评论的data-id元素 114 | const parentCommentElement = parentElement.closest('[data-id]'); 115 | 116 | const parentId = parentCommentElement.getAttribute('data-id'); 117 | comment.parentId = parentId; 118 | 119 | // 更新父评论的replies 120 | const parentComment = commentsData.get(parentId); 121 | if (parentComment && !parentComment.replies.includes(commentId)) { 122 | parentComment.replies.push(commentId); 123 | } 124 | } 125 | 126 | // 更新或添加评论 127 | if (commentsData.has(commentId)) { 128 | // 合并新数据,保留原有的replies 129 | const oldComment = commentsData.get(commentId); 130 | comment.replies = oldComment.replies; 131 | commentsData.set(commentId, { ...oldComment, ...comment }); 132 | } else { 133 | commentsData.set(commentId, comment); 134 | } 135 | }); 136 | 137 | // 更新最后更新时间 138 | window.ArticleComments[this.articleKey].lastUpdateTime = new Date().getTime(); 139 | } 140 | 141 | /** 142 | * 解析评论区 143 | * @param {string} selector - 评论容器的选择器 144 | * @param {HtmlElement} c - 评论容器 .Comments-container 145 | */ 146 | parseComments(c) { 147 | const container = c || document.querySelector('.Comments-container'); 148 | if (!container) { 149 | console.error('找不到评论容器'); 150 | return; 151 | } 152 | this.buildCommentHierarchy(container); 153 | } 154 | 155 | /** 156 | * 获取评论数据 157 | * @returns {Object} 评论数据 158 | */ 159 | getComments() { 160 | return window.ArticleComments[this.articleKey]; 161 | } 162 | } 163 | 164 | const buttonContainer = document.createElement("div") 165 | buttonContainer.innerHTML = `
166 | 167 |   168 |   169 |  
` 170 | buttonContainer.classList.add("comment-parser-container-wrap") 171 | buttonContainer.style.position = "absolute" 172 | buttonContainer.style.right = "20%" 173 | 174 | const HINT = '此为评论解析器,用于暂存评论,以便后续保存\n每次点击会暂存当前页评论,支持弹出框,支持增量保存(自动去重),评论顺序取决于暂存顺序\n暂存的评论仅当前页可用,在页面刷新后会消失' 175 | /** 176 | * 1 获取评论容器 177 | * 4 获取回答唯一KEY 178 | * 3 添加按钮 179 | * 5 绑定点击事件 180 | * 保存在window对象上, 181 | */ 182 | /** 183 | * ContentItem下有本次要添加按钮的评论区的位置 184 | * @param {HtmlElement} ContentItem .ContentItem 或 .Modal-content,作为评论区容器 185 | */ 186 | function addParseButton(ContentItem, itemId) { 187 | 188 | if (!ContentItem) return; 189 | //cc下有所有本次要处理的评论,层级不限 190 | let cc = ContentItem.querySelector('.Comments-container') 191 | let toolbar//功能栏,css-1onritu 192 | 193 | // 另一种情况,此时ContentItem为Modal-content,触发来源为点击查看按钮后延时cc = ContentItem 194 | let modal = document.querySelector('.Modal-content') 195 | if (modal) { 196 | itemId = modal.getAttribute('itemId') 197 | cc = ContentItem.querySelector('.css-tpyajk') 198 | toolbar = cc?.querySelector('.css-1onritu') 199 | cc.querySelector('.comment-parser-container-wrap')?.remove()// 避免重复添加 200 | } 201 | else if (cc) { 202 | toolbar = cc.querySelector('.css-1onritu') 203 | cc.querySelector('.comment-parser-container-wrap')?.remove()// 避免重复添加 204 | } 205 | 206 | if (!cc || cc.querySelector('.css-189h5o3')?.textContent.match('还没有')) return; 207 | 208 | if (!toolbar) { 209 | toolbar = cc.querySelector('.css-14eeh9e')// 去兼容知乎美化 暗黑模式 210 | cc.querySelector('.comment-parser-container-wrap')?.remove() 211 | } 212 | toolbar.appendChild(buttonContainer.cloneNode(true)) 213 | 214 | cc.querySelector(".save").addEventListener('click', (e) => { 215 | e.target.textContent = ' 暂存中……… ' 216 | setTimeout(() => { 217 | e.target.textContent = '暂存此页评论' 218 | }, 700) 219 | const parser = new CommentParser(itemId); 220 | parser.parseComments(cc); 221 | //const comments = parser.getComments(); 222 | //console.log(cc, comments); 223 | }) 224 | cc.querySelector(".unsave").addEventListener('click', (e) => { 225 | e.target.textContent = ' 清空中……… ' 226 | setTimeout(() => { 227 | e.target.textContent = '清空暂存区' 228 | }, 700) 229 | window.ArticleComments[itemId] = undefined 230 | }) 231 | cc.querySelector(".sum").addEventListener('click', () => { 232 | try { 233 | alert('已存 ' + window.ArticleComments[itemId].comments.size + ' 条') 234 | } catch (e) { 235 | alert('已存 0 条') 236 | } 237 | }) 238 | } 239 | /** 240 | * Modal评论处理方案 241 | * 添加按钮并正确传入主人ID 242 | * 来源: 243 | * 1 点击底栏按钮(弹出Modal) 244 | * 2 点击评论区查看子评论 245 | * 3 点击评论区查看全部评论(div.css-wu78cf)(折叠评论css-1r40vb1) 246 | * 4 打开Modal后,点击Modal内查看子评论(css-tpyajk下才是真的评论区)不可能在点击时直接获取ID 247 | * 248 | * 计划: 249 | * 1 250 | * 点击时查找主人ID,延时触发添加按钮(同时会添加事件) 251 | * 问题是在 4 时仍然找不到 252 | * 253 | * 2 254 | * 点击 123 时查找主人ID并存入window,延时触发添加按钮,不传ID 255 | * 点击 4 时只延时触发添加按钮,不传ID 256 | * 使用按钮时如果没有主人ID(发生在由 1234 创造的 Modal 内按钮),使用window中的 257 | * 延时后只在Modal内添加( 1 有时并不会创造Modal,此时由滚动添加) 258 | * 259 | * 3 260 | * (可替代非Modal场景) 261 | * 点击 123 时查找主人ID,延时添加到 Modal DOM 上,延时触发添加按钮,不传ID 262 | * 点击 1 时额外判断如果延时后没有Modal,就传ID添加按钮 263 | * 23时没有可以再试一次 264 | * 使用按钮时如果没有主人ID(发生在由 1234 创造的 Modal 内按钮),使用 Modal DOM 中的 265 | * 266 | * 267 | * 路线 268 | * 269 | * 基本解析功能 270 | * 挂载按钮与事件 271 | * 合并入主程序 272 | * 基本渲染功能 273 | * 渲染合并入主程序 274 | * 处理图片(下载和文本链接) 275 | * 细节处理(筛选后显示、已关闭、待展开子项) 276 | * 人性化提示(保存正文前、暂存反馈) 277 | * 专栏与搜索结果页 278 | * 279 | */ 280 | /** 281 | * 调用后挂载document点击事件 282 | */ 283 | export const mountParseComments = () => { 284 | const autoAdd = () => setTimeout(() => { 285 | let c = document.querySelector('.Post-content') || document.querySelector('.ContentItem') 286 | let itemId = getItemId(c, c) 287 | addParseButton(c, itemId) 288 | }, 2000) 289 | if (location.href.match(/\/pin\/|\/p\//)) { 290 | // 想法页文章页直接呈现评论 291 | autoAdd() 292 | } 293 | document.addEventListener("click", (e) => { 294 | let itemId 295 | const btn = e.target.closest('button') 296 | // 1 297 | if (btn?.closest('.ContentItem-actions') && /评论/.test(btn.textContent)) { 298 | let father = e.target.closest(".ContentItem") || e.target.closest(".Post-content") 299 | //注意文章页,搜索结果页 300 | itemId = getItemId(father, e.target) 301 | setTimeout(() => { 302 | let modal = document.querySelector('.Modal-content') 303 | if (modal) { 304 | modal.setAttribute('itemId', itemId) 305 | addParseButton(modal, itemId) 306 | } 307 | else addParseButton(father, itemId) 308 | }, 1200); 309 | return; 310 | } 311 | // 23 4 312 | else if (btn || e.target.closest('.css-wu78cf') || e.target.closest('.css-tpyajk .css-1jm49l2') || e.target.closest('.css-1r40vb1')) { 313 | let click = btn || e.target.closest('.css-wu78cf') || e.target.closest('.css-tpyajk .css-1jm49l2') || e.target.closest('.css-1r40vb1') 314 | if (click.textContent.match(/(查看.*(评论|回复))|评论回复/)) { 315 | 316 | let father = e.target.closest(".ContentItem") || e.target.closest(".Post-content") 317 | //注意文章页,搜索结果页 318 | setTimeout(() => { 319 | let modal = document.querySelector('.Modal-content') 320 | if (father) {// 4:false,不需要获取 321 | //非Modal内 23 322 | //console.log(2233) 323 | itemId = getItemId(father, e.target) 324 | modal.setAttribute('itemId', itemId) 325 | } 326 | addParseButton(modal, itemId)// 最终都是给Modal挂 327 | }, 1200); 328 | } 329 | } 330 | if (e.target.closest('button.hint')) { 331 | try { 332 | var skip_empty_p = GM_getValue("skip_empty_p"), 333 | zip_merge_cm = GM_getValue("zip_merge_cm"), 334 | copy_save_fm = GM_getValue("copy_save_fm"), 335 | copy_save_cm = GM_getValue("copy_save_cm"), 336 | no_save_img = GM_getValue("no_save_img"), 337 | edit_Filename = GM_getValue("edit_Filename") || '未启用' 338 | if (edit_Filename == 'title + "_" + author.name + "_" + time.modified.slice(0, 10) + remark') { 339 | edit_Filename = '与默认值相同' 340 | } 341 | var HINT2 = `\n当前设置:\n跳过空白段落:${skip_empty_p}\n复制保存评论:${copy_save_cm}\n复制保存FM:${copy_save_fm}\nzip合并评论:${zip_merge_cm}\n复制与纯文本不存图片:${no_save_img}\n自定义文件名:${edit_Filename}` 342 | } catch (e) { 343 | } 344 | alert(HINT + HINT2) 345 | } 346 | else if (btn?.getAttribute('aria-label') == "关闭") { 347 | autoAdd()// 文章页关闭弹出框后按钮消失 348 | } 349 | if (e.target.closest('.ContentItem-more')) { 350 | setTimeout(window.zhbf, 200)// 评论无关功能,展开后无需滚动即可保存 351 | } 352 | }) 353 | } 354 | 355 | /** 356 | * 357 | * @param {HtmlElement} father 含有itemId zop 358 | * @param {HtmlElement} etg e.target 359 | * @returns {String} 360 | */ 361 | const getItemId = (father, etg) => { 362 | let zopdata = JSON.parse(father.getAttribute("data-zop") || '{}') 363 | if (!zopdata.itemId) { 364 | // 搜索结果页 365 | father = etg.closest(".Card") 366 | let zem = JSON.parse(father.getAttribute("data-za-extra-module")).card.content 367 | zopdata.type = zem.type 368 | if (zopdata.type == 'Post') zopdata.type = 'article' 369 | zopdata.itemId = zem.token 370 | } 371 | return zopdata.type.toLowerCase() + zopdata.itemId 372 | } 373 | 374 | const ZhihuLink2NormalLink = (link) => { 375 | const url = new URL(link) 376 | if (url.hostname == "link.zhihu.com") { 377 | const target = new URLSearchParams(url.search).get("target") 378 | return decodeURIComponent(target) 379 | } 380 | else { 381 | if (link.match(/#/)) return '#' + link.split('#')[1] 382 | else return link 383 | } 384 | } 385 | 386 | /** 387 | * 相对时间转绝对时间 388 | * @param {String} relativeTime 389 | * @returns {String} 390 | */ 391 | function relativeToAbsoluteDate(relativeTime) { 392 | //const now = new Date(); 393 | //更精确一点了:推算日内可知部分并将不可知部分置为0 394 | let result = new Date(); 395 | 396 | if (relativeTime.includes('分钟前')) { 397 | const minutes = parseInt(relativeTime); 398 | result.setMinutes(result.getMinutes() - minutes); 399 | result.setSeconds(0); 400 | } 401 | else if (relativeTime.includes('小时前')) { 402 | const hours = parseInt(relativeTime); 403 | result.setHours(result.getHours() - hours); 404 | result.setMinutes(0, 0); 405 | } 406 | else if (relativeTime.includes('昨天')) { 407 | result.setDate(result.getDate() - 1); 408 | result.setSeconds(0); 409 | } 410 | /* else if (relativeTime.includes('天前')) { 411 | result.setDate(result.getDate() - relativeTime.match(/\d+/)[0]); 412 | result.setSeconds(0); 413 | } */ 414 | // 处理 "MM-DD" 格式 415 | else if (/^\d{2}-\d{2}$/.test(relativeTime)) { 416 | const [month, day] = relativeTime.split('-').map(num => parseInt(num)); 417 | result.setMonth(month - 1); 418 | result.setDate(day); 419 | result.setHours(0, 0, 0); 420 | } 421 | // 处理 "YYYY-MM-DD" 格式 422 | else if (/^\d{4}-\d{2}-\d{2}$/.test(relativeTime)) return relativeTime 423 | // "刚刚" 无需处理 424 | // 返回 YYYY-MM-DD 格式的字符串2025-02-28 (14:41:32) 425 | return formatDate(result); 426 | } 427 | 428 | function formatDate(date) { 429 | const year = date.getFullYear(); 430 | const month = String(date.getMonth() + 1).padStart(2, '0'); 431 | const day = String(date.getDate()).padStart(2, '0'); 432 | const hours = String(date.getHours()).padStart(2, '0'); 433 | const minutes = String(date.getMinutes()).padStart(2, '0'); 434 | const seconds = String(date.getSeconds()).padStart(2, '0'); 435 | 436 | if (parseInt(hours + minutes + seconds)) 437 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; 438 | return `${year}-${month}-${day}`; 439 | } 440 | -------------------------------------------------------------------------------- /src/core/lexer.ts: -------------------------------------------------------------------------------- 1 | import { log } from "console" 2 | import type { 3 | TokenH2, 4 | TokenH3, 5 | TokenCode, 6 | TokenText, 7 | TokenUList, 8 | TokenOList, 9 | TokenFigure, 10 | TokenBlockquote, 11 | TokenTextPlain, 12 | LexType, 13 | TokenTextType, 14 | TokenTextBr, 15 | TokenTextBold, 16 | TokenTextLink, 17 | TokenTextItalic, 18 | TokenTextCode, 19 | TokenTextInlineMath, 20 | TokenHR, 21 | TokenLink, 22 | TokenTable, 23 | TokenVideo, 24 | TokenGif, 25 | TokenFootnoteList, 26 | FootnoteItem, 27 | } from "./tokenTypes" 28 | 29 | import { TokenType } from "./tokenTypes" 30 | import { ZhihuLink2NormalLink } from "./utils" 31 | 32 | 33 | /** 34 | * Tokenizes a NodeListOf and returns an array of LexType tokens. 35 | * @param input - The NodeListOf to tokenize. 36 | * @returns An array of LexType tokens. 37 | */ 38 | export const lexer = (input: NodeListOf | Element[], type?: string): LexType[] => { 39 | 40 | /** 41 | * 想法,文字没有节点,非#标签和非@链接被
隔开,是单独的一行 42 | * 将每一段转为p段落处理 43 | */ 44 | if (type == "pin") { 45 | //console.log(input) 46 | if (input.length == 0) { 47 | return [] as LexType[] 48 | } 49 | let pinParagraphs: LexType[] = []//二级包-一级 50 | let dom = input[0].parentNode as HTMLElement//RichText 51 | 52 | //被转发的想法,首行添加主人 53 | if (dom.closest('.PinItem-content-originpin')) { 54 | let p = document.createElement("p") 55 | p.innerHTML = (dom.closest('.PinItem-content-originpin') as HTMLElement).firstElementChild.textContent 56 | pinParagraphs.push({ 57 | type: TokenType.Text, 58 | content: Tokenize(p), 59 | }) 60 | } 61 | // 在这里修复了保存想法时,很短的段落可能会消失的问题(从第二段起,变为空白行),请大家自查之前保存的想法 07.28 62 | // 例 pin/1862470667586396160 pin/1845764257590939648 pin/1930759768441591386 63 | let blocks = dom.innerHTML.replace(/\n\s*/g, "").split(/

|

/) 64 | for (let block of blocks) { 65 | let p = document.createElement("p") 66 | p.innerHTML = block 67 | pinParagraphs.push({ 68 | type: TokenType.Text, 69 | content: Tokenize(p), 70 | }) 71 | } 72 | 73 | //检查想法有无引用回答,仅检查当前层级 74 | if (dom.closest('.PinItem-content-originpin')) { 75 | let a = (dom.closest('.PinItem-content-originpin') as HTMLElement).querySelector("a.LinkCard") as HTMLAnchorElement 76 | if (a) { 77 | let p = document.createElement("p") 78 | let a2 = document.createElement("a") 79 | a2.href = a.href 80 | a2.innerHTML = a.innerText.replace(/\n\s*/g, " ") 81 | p.innerHTML = a2.outerHTML 82 | pinParagraphs.push({ 83 | type: TokenType.Text, 84 | content: Tokenize(p), 85 | }) 86 | } 87 | } else { 88 | //此时dom不在源想法内 89 | let parent = dom.closest('.PinItem') as HTMLElement 90 | if (!parent.querySelector(".PinItem-content-originpin") && parent.querySelector("a.LinkCard")) { 91 | let a = parent.querySelector("a.LinkCard") as HTMLAnchorElement 92 | let p = document.createElement("p") 93 | let a2 = document.createElement("a") 94 | a2.href = a.href 95 | a2.innerHTML = a.innerText.replace(/\n\s*/g, " ") 96 | p.innerHTML = a2.outerHTML 97 | pinParagraphs.push({ 98 | type: TokenType.Text, 99 | content: Tokenize(p), 100 | }) 101 | } 102 | } 103 | 104 | //console.log('pinParagraphs', pinParagraphs) 105 | return pinParagraphs 106 | } 107 | 108 | 109 | const tokens: LexType[] = [] 110 | 111 | // @ts-ignore 112 | let skipEmpty = window.skip_empty_p 113 | 114 | for (let i = 0; i < input.length; i++) { 115 | const node = input[i] 116 | //console.log(node) 117 | const tagName = node.nodeName.toLowerCase() 118 | 119 | switch (tagName) { 120 | case "h2": { 121 | tokens.push({ 122 | type: TokenType.H2, 123 | text: node.textContent, 124 | dom: node 125 | } as TokenH2) 126 | break 127 | } 128 | 129 | case "h3": { 130 | tokens.push({ 131 | type: TokenType.H3, 132 | text: node.textContent, 133 | dom: node 134 | } as TokenH3) 135 | break 136 | } 137 | 138 | case "div": { 139 | if (node.classList.contains("highlight")) { 140 | tokens.push({ 141 | type: TokenType.Code, 142 | content: node.textContent, 143 | language: node.querySelector("pre > code").classList.value.slice(9), 144 | dom: node 145 | } as TokenCode) 146 | } else if (node.classList.contains("RichText-LinkCardContainer")) { 147 | const link = node.firstChild as HTMLAnchorElement 148 | tokens.push({ 149 | type: TokenType.Link, 150 | text: link.getAttribute("data-text"), 151 | href: ZhihuLink2NormalLink(link.href), 152 | dom: node as HTMLDivElement 153 | } as TokenLink) 154 | } else if (node.querySelector("video")) { 155 | tokens.push({ 156 | type: TokenType.Video, 157 | src: node.querySelector("video").getAttribute("src"), 158 | local: false, 159 | dom: node 160 | } as TokenVideo) 161 | } else if (node.classList.contains("RichText-ADLinkCardContainer")) { 162 | tokens.push({ 163 | type: TokenType.Text, 164 | content: [{ 165 | type: TokenType.PlainText, 166 | text: node.textContent 167 | }], 168 | dom: node 169 | } as TokenText) 170 | } 171 | break 172 | } 173 | 174 | case "blockquote": { 175 | tokens.push({ 176 | type: TokenType.Blockquote, 177 | content: Tokenize(node), 178 | dom: node as HTMLQuoteElement 179 | } as TokenBlockquote) 180 | break 181 | } 182 | 183 | case "figure": { 184 | const img = node.querySelector("img") 185 | if (img.classList.contains("ztext-gif")) { 186 | const guessSrc = (src: string): string => { 187 | return src.replace(/\..{3,4}$/g, ".gif") 188 | } 189 | const src = guessSrc(img.getAttribute("src") || img.getAttribute("data-thumbnail")) 190 | if (src) { 191 | tokens.push({ 192 | type: TokenType.Gif, 193 | src, 194 | local: false, 195 | dom: node 196 | } as TokenGif) 197 | } 198 | } 199 | else if (img.getAttribute('data-actualsrc')?.includes('/equation?tex=')) { 200 | // 图片格式的公式 201 | const altText = img.getAttribute('alt') || ''; 202 | if (altText) { 203 | tokens.push({ 204 | type: TokenType.Text, 205 | content: [{ 206 | type: TokenType.Math, 207 | content: altText.trim(), 208 | display: true, 209 | dom: img 210 | } as TokenTextInlineMath], 211 | dom: node 212 | } as TokenText) 213 | } 214 | } 215 | else { 216 | const src = img.getAttribute("data-actualsrc") || img.getAttribute("data-original") || img.src 217 | if (src) { 218 | tokens.push({ 219 | type: TokenType.Figure, 220 | src, 221 | local: false, 222 | dom: node as HTMLElement 223 | } as TokenFigure) 224 | } 225 | } 226 | //保存图片题注Tokenize(text), 227 | const text = node.querySelector("figcaption") 228 | if (text) { 229 | tokens.push({ 230 | type: TokenType.Text, 231 | content: [{ 232 | type: TokenType.Italic, 233 | content: Tokenize(text), 234 | dom: text, 235 | }], 236 | dom: text 237 | } as TokenText) 238 | } 239 | break 240 | } 241 | 242 | case "ul": { 243 | const childNodes = Array.from(node.querySelectorAll("li")) 244 | tokens.push({ 245 | type: TokenType.UList, 246 | content: childNodes.map((el) => Tokenize(el)), 247 | dom: node, 248 | } as TokenUList) 249 | 250 | break 251 | } 252 | 253 | case "ol": { 254 | // 检查是否为脚注/参考文献列表 255 | if (node.classList.contains('ReferenceList')) { 256 | const childNodes = Array.from(node.querySelectorAll("li")) 257 | const items: FootnoteItem[] = childNodes.map((li) => { 258 | // 提取脚注编号,从 id="ref_1" 中提取 "1" 259 | const id = li.id.replace('ref_', '') 260 | // 提取脚注内容,跳过返回链接,只取 span 中的文本 261 | const span = li.querySelector('span') 262 | const content = span ? span.textContent || '' : li.textContent || '' 263 | return { id, content: content.trim() } 264 | }) 265 | tokens.push({ 266 | type: TokenType.FootnoteList, 267 | items, 268 | dom: node, 269 | } as TokenFootnoteList) 270 | } else { 271 | // 普通有序列表 272 | const childNodes = Array.from(node.querySelectorAll("li")) 273 | tokens.push({ 274 | type: TokenType.Olist, 275 | content: childNodes.map((el) => Tokenize(el)), 276 | dom: node, 277 | } as TokenOList) 278 | } 279 | 280 | break 281 | } 282 | 283 | case "p": { 284 | if (skipEmpty && (node.classList.contains('ztext-empty-paragraph') || node.textContent.length == 0)) 285 | break 286 | 287 | tokens.push({ 288 | type: TokenType.Text, 289 | content: Tokenize(node), 290 | dom: node as HTMLParagraphElement 291 | } as TokenText) 292 | 293 | break 294 | } 295 | 296 | case "hr": { 297 | 298 | tokens.push({ 299 | type: TokenType.HR, 300 | dom: node 301 | } as TokenHR) 302 | 303 | break 304 | } 305 | 306 | case "table": { 307 | 308 | const el = node as HTMLTableElement 309 | 310 | const table2array = (table: HTMLTableElement): string[][] => { 311 | const res: string[][] = [] 312 | const rows = Array.from(table.rows) 313 | 314 | for (let row of rows) { 315 | const cells = Array.from(row.cells) 316 | res.push(cells.map((cell) => cell.innerHTML.replace( 317 | /(.*?).*?<\/svg><\/a>/gms, 318 | "$1" 319 | ).replace( 320 | /(.*?)<\/span>/gms, 321 | "$1" 322 | ))) 323 | } 324 | 325 | return res 326 | } 327 | const table = table2array(el) 328 | 329 | tokens.push({ 330 | type: TokenType.Table, 331 | content: table, 332 | dom: node, 333 | } as TokenTable) 334 | 335 | break 336 | } 337 | } 338 | } 339 | //console.log(tokens) 340 | 341 | return tokens 342 | } 343 | 344 | 345 | /** 346 | * Tokenizes an HTML element or string into an array of TokenTextType objects. 347 | * 处理行内内容 348 | * @param node The HTML element or string to tokenize. 349 | * @returns An array of TokenTextType objects representing the tokenized input. 350 | */ 351 | const Tokenize = (node: Element | string): TokenTextType[] => { 352 | 353 | if (typeof node == "string") { 354 | return [{ 355 | type: TokenType.PlainText, 356 | text: node.replace('\t', '').replace(/^\s{2,}/, ''), // 修复被误识别为代码块,修复公式后面缺少空格的问题 357 | } as TokenTextPlain] 358 | } 359 | 360 | let childs = Array.from(node.childNodes) 361 | const res: TokenTextType[] = [] 362 | 363 | // 处理

的奇观 364 | try { 365 | if (childs.length == 1 && (childs[0] as HTMLElement).tagName.toLowerCase() == "p") { 366 | childs = Array.from((childs[0] as HTMLElement).childNodes) 367 | } 368 | } catch { } 369 | 370 | for (let child of childs) { 371 | 372 | if (child.nodeType == child.TEXT_NODE) { 373 | res.push({ 374 | type: TokenType.PlainText, 375 | text: child.textContent.replace(/\u200B/g, '').replace('\t', '').replace(/^\s{2,}/, ''), // 修复被误识别为代码块,修复公式后面缺少空格的问题 376 | dom: child, 377 | } as TokenTextPlain) 378 | } else { 379 | let el = child as HTMLElement 380 | 381 | switch (el.tagName.toLowerCase()) { 382 | case "b": { 383 | res.push({ 384 | type: TokenType.Bold, 385 | content: Tokenize(el), 386 | dom: el, 387 | } as TokenTextBold) 388 | break 389 | } 390 | 391 | case "i": { 392 | res.push({ 393 | type: TokenType.Italic, 394 | content: Tokenize(el), 395 | dom: el, 396 | } as TokenTextItalic) 397 | break 398 | } 399 | 400 | case "br": { 401 | res.push({ 402 | type: TokenType.BR, 403 | dom: el, 404 | } as TokenTextBr) 405 | break 406 | } 407 | 408 | case "code": { 409 | res.push({ 410 | type: TokenType.InlineCode, 411 | content: el.innerText, 412 | dom: el, 413 | } as TokenTextCode) 414 | break 415 | } 416 | 417 | case "span": { 418 | try { 419 | if (el.classList.contains("ztext-math")) { 420 | // 根据是否存在 MathJax_SVG_Display 类来判断是否为块级公式 421 | const hasDisplayClass = el.querySelector(".MathJax_SVG_Display") !== null; 422 | const content = el.getAttribute("data-tex").trim(); 423 | // 如果公式包含 \tag 命令,也应该是块级公式(\tag 只能在 display mode 中使用) 424 | const hasTag = content.includes('\\tag'); 425 | const isDisplayMath = hasDisplayClass || hasTag; 426 | res.push({ 427 | type: TokenType.Math, 428 | content: content, 429 | display: isDisplayMath, 430 | dom: el, 431 | } as TokenTextInlineMath) 432 | } else if (el.children[0].classList.contains("RichContent-EntityWord")) {//搜索词 433 | res.push({ 434 | type: TokenType.PlainText, 435 | text: el.innerText, 436 | dom: el, 437 | } as TokenTextPlain) 438 | } 439 | else if (el.children[0].classList.contains("UserLink")) {//想法中的@ 440 | res.push({ 441 | type: TokenType.InlineLink, 442 | text: el.innerText, 443 | href: ZhihuLink2NormalLink((el.querySelector("a") as HTMLAnchorElement).href), 444 | dom: el, 445 | } as TokenTextLink) 446 | } 447 | } catch (e) { 448 | res.push({ 449 | type: TokenType.PlainText, 450 | text: el.innerText, 451 | dom: el, 452 | } as TokenTextPlain) 453 | //console.error(el, el.innerText) 454 | } 455 | break 456 | } 457 | 458 | case "a": { 459 | //console.log(el) 460 | // 移除另一种搜索推荐 461 | if ((el as HTMLAnchorElement).href.startsWith('https://zhida.zhihu.com/search')) { 462 | res.push({ 463 | type: TokenType.PlainText, 464 | text: el.innerText, 465 | dom: el, 466 | } as TokenTextPlain) 467 | } else 468 | res.push({ 469 | type: TokenType.InlineLink, 470 | text: el.textContent, 471 | href: ZhihuLink2NormalLink((el as HTMLAnchorElement).href), 472 | dom: el, 473 | } as TokenTextLink) 474 | break 475 | } 476 | 477 | case "sup": { 478 | const link = el.firstElementChild as HTMLAnchorElement 479 | // 提取脚注编号,如 [1] -> 1 480 | const footnoteText = link.textContent.replace(/[\[\]]/g, '') 481 | res.push({ 482 | type: TokenType.PlainText, 483 | text: `[^${footnoteText}]`, 484 | dom: el, 485 | } as TokenTextPlain) 486 | break 487 | } 488 | 489 | default: { 490 | //下划线内容等question/478154391/answer/121816724037 491 | res.push({ 492 | type: TokenType.PlainText, 493 | text: child.textContent.replace(/\u200B/g, '').replace('\t', '').replace(/^\s{2,}/, ''), 494 | dom: child, 495 | } as TokenTextPlain) 496 | } 497 | } 498 | } 499 | } 500 | //console.log(res) 501 | return res 502 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { saveAs } from "file-saver" 2 | import dealItem from "./dealItem" 3 | import * as JSZip from "jszip" 4 | import { domToPng } from "modern-screenshot" 5 | import { getCommentSwitch } from "./core/utils" 6 | import { mountParseComments } from "./core/parseComments" 7 | import { selectObsidianVault, saveFile } from "./core/obsidianSaver"; 8 | import { showToast } from './core/toast'; 9 | /** 10 | * 适配复杂的想法:转发、带卡片链接、带@ 11 | * 页:推送页,个人/机构主页,回答页,问题页,文章页,想法页,收藏夹页,搜索结果页 12 | */ 13 | 14 | // @grant GM_setValue 15 | // @grant GM_getValue 16 | // @grant GM_registerMenuCommand 17 | // @grant GM_unregisterMenuCommand 18 | 19 | /** 20 | * 油猴按钮 21 | */ 22 | function registerBtn() { 23 | try { 24 | // @ts-ignore 25 | let skipEmpty = GM_registerMenuCommand( 26 | "(推荐)解析时跳过空白段落", 27 | function () { 28 | // @ts-ignore 29 | let ac = GM_getValue("skip_empty_p"), c 30 | !ac ? c = confirm("解析时跳过空白段落,避免产生大量多余的换行,你是否继续?") : alert('已取消跳过空白段落') 31 | if (c) { 32 | // @ts-ignore 33 | GM_setValue("skip_empty_p", true) 34 | // @ts-ignore 35 | } else GM_setValue("skip_empty_p", false) 36 | } 37 | ) 38 | // @ts-ignore 39 | let menuFM = GM_registerMenuCommand( 40 | "复制内容时添加fm元信息", 41 | function () { 42 | // @ts-ignore 43 | let ac = GM_getValue("copy_save_fm"), c 44 | !ac ? c = confirm("复制内容时,添加 frontmatter 信息,就像下载为纯文本的时候一样。你是否继续?") : alert('已取消复制添加fm') 45 | // @ts-ignore 46 | c ? GM_setValue("copy_save_fm", true) : GM_setValue("copy_save_fm", false) 47 | //alert(GM_getValue("copy_save_fm")) 48 | } 49 | ) 50 | // @ts-ignore 51 | let menuSaveCM = GM_registerMenuCommand( 52 | "复制内容时同时复制评论", 53 | function () { 54 | // @ts-ignore 55 | let ns = GM_getValue("copy_save_cm"), c 56 | !ns ? c = confirm("启用后,复制时也会复制评论,就像直接复制了下载的纯文本。你是否继续?") : alert('已取消复制评论') 57 | // @ts-ignore 58 | c ? GM_setValue("copy_save_cm", true) : GM_setValue("copy_save_cm", false) 59 | //alert(GM_getValue("copy_save_cm")) 60 | } 61 | ) 62 | // @ts-ignore 63 | let menuMergeCM = GM_registerMenuCommand( 64 | "下载zip时合并正文与评论", 65 | function () { 66 | // @ts-ignore 67 | let ns = GM_getValue("zip_merge_cm"), c 68 | !ns ? c = confirm("启用后,下载zip时会合并正文与评论到一个文件中。你是否继续?") : alert('已取消合并') 69 | // @ts-ignore 70 | c ? GM_setValue("zip_merge_cm", true) : GM_setValue("zip_merge_cm", false) 71 | //alert(GM_getValue("zip_merge_cm")) 72 | } 73 | ) 74 | // @ts-ignore 75 | let menuSaveImg = GM_registerMenuCommand( 76 | "复制与下载纯文本时不保存图片", 77 | function () { 78 | // @ts-ignore 79 | let ns = GM_getValue("no_save_img"), c 80 | !ns ? c = confirm("启用后,复制、存文本时将所有图片替换为“[图片]”,不影响存zip。你是否继续?") : alert('已取消不存图') 81 | // @ts-ignore 82 | c ? GM_setValue("no_save_img", true) : GM_setValue("no_save_img", false) 83 | //alert(GM_getValue("no_save_img")) 84 | } 85 | ) 86 | // @ts-ignore 87 | let menuFilename = GM_registerMenuCommand( 88 | "自定义保存后的文件名格式", 89 | function () { 90 | // @ts-ignore 91 | let efm = GM_getValue("edit_Filename") 92 | let fm = prompt(`是否自定义保存后的文件名格式?留空或填错恢复默认\n默认为title + "_" + author.name + "_" + time.modified.slice(0, 10) + remark,你可以调整它的顺序,使用其他属性需要阅读源码\n(new Date).toLocaleDateString().replaceAll('/','-')添加保存日期`, 93 | efm ? efm : 'title + "_" + author.name + "_" + time.modified.slice(0, 10) + remark' 94 | ) 95 | // @ts-ignore 96 | GM_setValue("edit_Filename", fm) 97 | //alert(GM_getValue("edit_Filename")) 98 | } 99 | ) 100 | } catch (e) { 101 | console.warn(e) 102 | } 103 | } 104 | registerBtn() 105 | 106 | const ButtonContainer = document.createElement("div") 107 | ButtonContainer.classList.add("zhihubackup-wrap") 108 | ButtonContainer.innerHTML = `
109 | 110 | 111 | 112 | 113 | 116 | 119 |
` 122 | 123 | const main = async () => { 124 | 125 | //console.log("Starting…") 126 | const RichTexts = Array.from(document.querySelectorAll(".RichText")) as HTMLElement[] 127 | for (let RichText of RichTexts) { 128 | try { 129 | let result: { 130 | zip?: JSZip, 131 | textString?: string, 132 | title: string, 133 | } 134 | //console.log(RichText) 135 | if (RichText.parentElement.classList.contains("Editable")) continue 136 | if (window.location.hostname.includes('zhuanlan')) { 137 | if (RichText.closest('.Post-Main').querySelector(".zhihubackup-container")) continue 138 | } 139 | else { 140 | if (RichText.closest('.PinItem')) { 141 | if (!RichText.closest('.RichContent-inner')) continue//每个带图想法有3个RichText,除掉图、假转发 142 | //if (RichText.children[0].classList.contains("Image-Wrapper-Preview")) continue 143 | if (RichText.closest('.PinItem-content-originpin')) continue//被转发想法 144 | } 145 | if (RichText.closest('.RichContent').querySelector(".zhihubackup-container")) continue 146 | const richInner = RichText.closest('.RichContent-inner') 147 | if (richInner && richInner.querySelector(".ContentItem-more")) continue//未展开 148 | if (RichText.closest('.RichContent').querySelector(".ContentItem-expandButton")) continue 149 | //if (RichText.textContent.length == 0) continue 150 | } 151 | const aButtonContainer = ButtonContainer.cloneNode(true) as HTMLDivElement 152 | 153 | //父级 154 | let parent_dom = RichText.closest('.List-item') || 155 | RichText.closest('.Post-content') || 156 | RichText.closest('.PinItem') || 157 | RichText.closest('.CollectionDetailPageItem') || 158 | RichText.closest('.Card') as HTMLElement 159 | if (parent_dom.querySelector('.Catalog')) { 160 | (aButtonContainer.firstElementChild as HTMLElement).style.position = 'fixed'; 161 | (aButtonContainer.firstElementChild as HTMLElement).style.top = 'unset'; 162 | (aButtonContainer.firstElementChild as HTMLElement).style.bottom = '60px' 163 | } 164 | let p = RichText.closest('.RichContent') || RichText.closest('.Post-RichTextContainer') as HTMLElement 165 | p.prepend(aButtonContainer) 166 | 167 | const ButtonMarkdown = parent_dom.querySelector(".to-copy") 168 | ButtonMarkdown.addEventListener("click", throttle(async (event: Event) => { 169 | try { 170 | const res = await dealItem(RichText, 'copy', event) 171 | if (!res) return;// 取消保存 172 | result = { 173 | textString: res.textString, 174 | title: res.title, 175 | } 176 | /*console.log(result.markdown.join("\n\n"))*/ 177 | navigator.clipboard.writeText(result.textString) 178 | ButtonMarkdown.innerHTML = "复制成功✅" 179 | setTimeout(() => { 180 | ButtonMarkdown.innerHTML = "复制为Markdown" 181 | }, 3000) 182 | } catch (e) { 183 | console.log(e) 184 | ButtonMarkdown.innerHTML = "发生错误❌
请打开控制台查看" 185 | setTimeout(() => { 186 | ButtonMarkdown.innerHTML = "复制为Markdown" 187 | }, 3000) 188 | } 189 | })) 190 | 191 | const ButtonZip = parent_dom.querySelector(".to-zip") 192 | ButtonZip.addEventListener("click", throttle(async (event: Event) => { 193 | try { 194 | ButtonZip.innerHTML = "下载中……" 195 | const res = await dealItem(RichText, 'zip', event) 196 | if (!res) 197 | return ButtonZip.innerHTML = "下载为 ZIP";// 取消保存 198 | result = { 199 | zip: res.zip, 200 | title: res.title, 201 | } 202 | const blob = await result.zip.generateAsync({ type: "blob" }) 203 | saveAs(blob, result.title + ".zip") 204 | ButtonZip.innerHTML = "下载成功✅
请看下载记录" 205 | setTimeout(() => { 206 | ButtonZip.innerHTML = "下载为 ZIP" 207 | }, 5000) 208 | } catch (e) { 209 | console.log(e) 210 | ButtonZip.innerHTML = "发生错误❌
请打开控制台查看" 211 | setTimeout(() => { 212 | ButtonZip.innerHTML = "下载为 ZIP" 213 | }, 5000) 214 | } 215 | },)) 216 | 217 | const ButtonPNG = parent_dom.querySelector(".to-png") 218 | ButtonPNG.addEventListener("click", throttle(async (event: Event) => { 219 | try { 220 | const res = await dealItem(RichText, 'png', event) 221 | if (!res) return;// 取消保存 222 | result = { 223 | title: res.title, 224 | } 225 | 226 | let clip = parent_dom 227 | clip.classList.add("to-screenshot") 228 | let saveCM = getCommentSwitch(RichText) 229 | !saveCM ? clip.classList.add("no-cm") : 0 230 | let svgDefs = document.querySelector("#MathJax_SVG_glyphs") as HTMLElement 231 | svgDefs ? svgDefs.style.visibility = "visible" : 0 232 | 233 | domToPng(clip, { 234 | backgroundColor: "#fff", 235 | filter(el) { 236 | if ((el as HTMLElement).tagName == 'DIV' && (el as HTMLElement).classList.contains('zhihubackup-wrap')) return false 237 | else return true 238 | }, 239 | }).then((dataUrl: any) => { 240 | const link = document.createElement('a') 241 | link.download = result.title + ".png" 242 | link.href = dataUrl 243 | link.click() 244 | setTimeout(() => { 245 | clip.classList.remove("to-screenshot") 246 | !saveCM ? clip.classList.remove("no-cm") : 0 247 | //svgDefs2.remove() 248 | ButtonPNG.innerHTML = "剪藏为 PNG" 249 | }, 5000) 250 | }) 251 | ButtonPNG.innerHTML = "请稍待片刻✅
查看下载记录" 252 | } catch (e) { 253 | console.log(e) 254 | ButtonPNG.innerHTML = "发生错误❌
请打开控制台查看" 255 | setTimeout(() => { 256 | ButtonPNG.innerHTML = "剪藏为 PNG" 257 | }, 5000) 258 | } 259 | })) 260 | 261 | const ButtonText = parent_dom.querySelector(".to-text") 262 | ButtonText.addEventListener("click", throttle(async (event: Event) => { 263 | try { 264 | const res = await dealItem(RichText, 'text', event) 265 | if (!res) return;// 取消保存 266 | result = { 267 | textString: res.textString, 268 | title: res.title, 269 | } 270 | const blob = new Blob([result.textString], { type: 'text/plain' }) 271 | saveAs(blob, result.title + ".md") 272 | ButtonText.innerHTML = "下载成功✅
请看下载记录,以文本方式打开" 273 | setTimeout(() => { 274 | ButtonText.innerHTML = "下载为纯文本" 275 | }, 5000) 276 | } catch (e) { 277 | console.log(e) 278 | ButtonText.innerHTML = "发生错误❌
请打开控制台查看" 279 | setTimeout(() => { 280 | ButtonText.innerHTML = "下载为纯文本" 281 | }, 5000) 282 | } 283 | })) 284 | 285 | const ButtonObsidian = parent_dom.querySelector(".to-obsidian") 286 | ButtonObsidian.addEventListener("click", throttle(async (event: Event) => { 287 | try { 288 | let saveType = await selectObsidianVault() 289 | if (!saveType) return;// 取消保存 290 | 291 | if (saveType == 'text') { 292 | const res = await dealItem(RichText, 'text') 293 | if (!res) return;// 取消保存 294 | result = { 295 | textString: res.textString, 296 | title: res.title, 297 | } 298 | await saveFile(result, saveType as any) 299 | } 300 | else if (saveType.slice(0, 3) == 'zip') { 301 | const res = await dealItem(RichText, 'zip') 302 | if (!res) return;// 取消保存 303 | result = { 304 | zip: res.zip, 305 | title: res.title, 306 | } 307 | await saveFile(result, saveType as any) 308 | } 309 | else if (saveType == 'png') { 310 | const res = await dealItem(RichText, 'png') 311 | if (!res) return;// 取消保存 312 | 313 | let clip = parent_dom 314 | clip.classList.add("to-screenshot") 315 | let saveCM = getCommentSwitch(RichText) 316 | !saveCM ? clip.classList.add("no-cm") : 0 317 | let svgDefs = document.querySelector("#MathJax_SVG_glyphs") as HTMLElement 318 | svgDefs ? svgDefs.style.visibility = "visible" : 0 319 | 320 | domToPng(clip, { 321 | backgroundColor: "#fff", 322 | filter(el) { 323 | if ((el as HTMLElement).tagName == 'DIV' && (el as HTMLElement).classList.contains('zhihubackup-wrap')) return false 324 | else return true 325 | }, 326 | }).then(async (dataUrl: any) => { 327 | result = { 328 | textString: dataUrl, 329 | title: res.title, 330 | } 331 | clip.classList.remove("to-screenshot") 332 | !saveCM ? clip.classList.remove("no-cm") : 0 333 | await saveFile(result, saveType as any) 334 | }) 335 | } 336 | } catch (e) { 337 | console.log(e) 338 | alert('发生错误❌请打开控制台查看\n你可以关闭窗口后再试一次!') 339 | } 340 | })) 341 | 342 | } catch (e) { 343 | console.log(e) 344 | } 345 | } 346 | } 347 | 348 | function throttle(fn: Function, delay: number = 2000) { 349 | let flag = true 350 | return function (this: any, ...args: any[]) { // 使用剩余参数接收所有参数 351 | if (flag) { 352 | flag = false 353 | setTimeout(() => { 354 | flag = true 355 | }, delay) 356 | return fn.apply(this, args); // 通过 apply 传递参数和 this 357 | } 358 | } 359 | } 360 | 361 | setTimeout(() => { 362 | let node = document.createElement("style")//!important 363 | node.appendChild(document.createTextNode(` 364 | .RichContent { 365 | position: relative; 366 | } 367 | .zhihubackup-wrap { 368 | opacity: 0; 369 | pointer-events: none; 370 | transition: opacity 0.5s; 371 | position: absolute; 372 | left: -10em; 373 | top: -100px; 374 | height: 100%; 375 | min-height: 200px; 376 | user-select: none; 377 | width: 12em; 378 | } 379 | .RichContent:hover .zhihubackup-wrap, 380 | .ContentItem:hover .zhihubackup-wrap, 381 | .Post-content:hover .zhihubackup-wrap, 382 | .Post-RichTextContainer:hover .zhihubackup-wrap, 383 | .zhihubackup-wrap:hover { 384 | opacity: 1; 385 | pointer-events: initial; 386 | } 387 | .zhihubackup-container { 388 | position: sticky; 389 | top: 120px; 390 | /*display: flex; 391 | flex-direction: column; 392 | justify-content: space-around; 393 | height: 22em;*/ 394 | width: min-content; 395 | max-width: 8em; 396 | z-index: 2; 397 | } 398 | .zhihubackup-container button { 399 | width: 8em; 400 | margin-bottom: 8px; 401 | line-height: 24px !important; 402 | padding: 4px 10px!important; 403 | } 404 | .zhihubackup-container input, 405 | .zhihubackup-container textarea { 406 | /*border: 1px solid #777;*/ 407 | background-color: #0000; 408 | font-size: 14px; 409 | color: #1772f6; 410 | border: unset; 411 | text-align: center; 412 | outline: unset; 413 | height: 100%; 414 | resize: none; 415 | overflow: hidden; 416 | line-height: 1.5em; 417 | vertical-align: middle; 418 | } 419 | button.Button.VoteButton:has(input:focus), 420 | button.Button.VoteButton:has(textarea:focus), 421 | button.Button.VoteButton:has(textarea:hover) { 422 | resize: both; 423 | overflow: hidden; 424 | } 425 | .to-screenshot .ContentItem-actions { 426 | position: initial!important; 427 | box-shadow: unset!important; 428 | margin: 0 -20px -10px!important; 429 | } 430 | .to-screenshot.Post-content .RichContent-actions { 431 | position: initial!important;/*专栏*/ 432 | box-shadow: unset!important; 433 | } 434 | .to-screenshot.Post-content { 435 | width: 780px; 436 | margin: 0 auto; 437 | min-width: unset!important; 438 | } 439 | .to-screenshot .Post-Main { 440 | display: flex; 441 | flex-direction: column; 442 | align-items: center; 443 | } 444 | .to-screenshot.PinItem .RichText>.RichText:has(a[data-first-child]) { 445 | display: flex;/*想法-卡片链接*/ 446 | flex-direction: column; 447 | align-items: center; 448 | } 449 | .to-screenshot .ContentItem-actions>.ContentItem-actions { 450 | margin-top: -10px!important;/*想法*/ 451 | } 452 | .to-screenshot .css-m4psdq{ 453 | opacity: 0; 454 | } 455 | .to-screenshot .AppHeader-profileAvatar{ 456 | opacity: 0; 457 | } 458 | .to-screenshot.no-cm .Comments-container{ 459 | display: none; 460 | } 461 | .to-screenshot noscript{ 462 | display: none; 463 | } 464 | .to-screenshot .RichText-LinkCardContainer{ 465 | display: flex; 466 | justify-content: center; 467 | } 468 | .to-screenshot .LinkCard.new{ 469 | margin: 0!important; 470 | } 471 | .to-screenshot .FeedSource{ 472 | margin-bottom: 14px !important; 473 | } 474 | .to-screenshot .Comments-container>div>div{ 475 | margin-bottom: 10px !important; 476 | } 477 | .to-screenshot .Comments-container{ 478 | margin: 0 !important; 479 | } 480 | .to-screenshot.PinItem{ 481 | margin: 16px 0;/*想法增加留白*/ 482 | padding: 0 16px; 483 | width: 690px; 484 | } 485 | .PinDetail:has(.to-screenshot){ 486 | max-width: 706px!important; 487 | } 488 | .to-screenshot .Recommendations-Main{ 489 | display: none;/*文章推荐阅读*/ 490 | } 491 | .to-screenshot .css-kt4t4n{ 492 | display: none;/*下方黏性评论栏*/ 493 | } 494 | .to-screenshot .zhihubackup-container{ 495 | /*display: none;*/ 496 | } 497 | .RichContent:has(.ContentItem-more) .zhihubackup-wrap, 498 | .Post-RichTextContainer:has(.ContentItem-more) .zhihubackup-wrap{ 499 | display:none; 500 | } 501 | .comment-parser-container{ 502 | opacity: 0; 503 | pointer-events: none; 504 | transition: opacity 0.5s; 505 | } 506 | .Comments-container:hover .comment-parser-container, 507 | .Modal-content:hover .comment-parser-container{ 508 | opacity: 1; 509 | pointer-events: initial; 510 | } 511 | .Card:has(.zhihubackup-wrap){ 512 | overflow: visible!important; 513 | } 514 | `)) 515 | let head = document.querySelector("head") 516 | head.appendChild(node) 517 | 518 | if (window.innerWidth < 1275) { 519 | let node2 = document.createElement("style") 520 | node2.appendChild(document.createTextNode(` 521 | .zhihubackup-wrap { 522 | left: unset; 523 | right: -10em; 524 | z-index: 2; 525 | } 526 | .zhihubackup-container { 527 | float: right; 528 | background-color: rgb(244, 246, 249); 529 | } 530 | .RichContent { 531 | z-index: 2; 532 | } 533 | `)) 534 | head.appendChild(node2) 535 | } 536 | }, 30) 537 | 538 | setTimeout(() => { 539 | main() 540 | mountParseComments() 541 | // @ts-ignore 542 | window.zhbf = main 543 | // 在window对象上创建存储空间 544 | // @ts-ignore 545 | window.ArticleComments = window.ArticleComments || {}; 546 | document.querySelector('.Topstory-tabs')?.addEventListener('click', () => { 547 | setTimeout(registerBtn, 100); 548 | }) 549 | }, 300) 550 | 551 | let timer: any = null 552 | window.addEventListener("scroll", () => { 553 | //debounce 554 | if (timer) { 555 | clearTimeout(timer) 556 | } 557 | timer = setTimeout(main, 1000) 558 | }) 559 | -------------------------------------------------------------------------------- /src/core/obsidianSaver.ts: -------------------------------------------------------------------------------- 1 | import * as JSZip from "jszip"; 2 | import { showToast } from './toast'; 3 | 4 | // showToast('欢迎使用知乎助手-备份到obsidian插件'); 5 | /** 6 | * 下一步 7 | * 解决再次打开时,文件夹列表闪动的问题,尽量不重新读取文件夹 8 | * 9 | */ 10 | 11 | // ============= 1. 全局状态管理 ============= 12 | 13 | // 全局变量存储弹框元素和当前选择的文件夹 14 | let obsidianModal: HTMLElement | null = null; 15 | let selectedVaultHandle: FileSystemDirectoryHandle | null = null; // 存储选择的文件夹 16 | let rootVaultHandle: FileSystemDirectoryHandle | null = null; // 存储最初选择的根文件夹 17 | let currentSelectedPath: string = ''; // 存储当前选择的相对路径 18 | 19 | // 扩展 FileSystemDirectoryHandle 类型以包含必要的方法 20 | declare global { 21 | interface FileSystemDirectoryHandle { 22 | entries(): AsyncIterableIterator<[string, FileSystemHandle]>; 23 | queryPermission(descriptor?: { mode?: 'read' | 'readwrite' }): Promise; 24 | requestPermission(descriptor?: { mode?: 'read' | 'readwrite' }): Promise; 25 | } 26 | } 27 | 28 | // ============= 2. 弹框生命周期管理 ============= 29 | 30 | /** 31 | * 注入 Obsidian 选择弹框到页面 32 | */ 33 | function injectObsidianModal(): void { 34 | if (obsidianModal) { 35 | return; // 已经注入过了 36 | } 37 | 38 | // 创建弹框容器 39 | obsidianModal = document.createElement('div'); 40 | obsidianModal.id = 'zhihu-obsidian-modal'; 41 | obsidianModal.innerHTML = ` 42 | 98 | `; 99 | 100 | // 添加CSS样式 101 | const style = document.createElement('style'); 102 | style.textContent = ` 103 | #zhihu-obsidian-modal { 104 | position: fixed; 105 | top: 0; 106 | left: 0; 107 | width: 100%; 108 | height: 100%; 109 | z-index: 100; 110 | display: none; 111 | opacity: 1; 112 | transition: opacity 0.3s ease-in-out; 113 | } 114 | 115 | #zhihu-obsidian-modal .modal-overlay { 116 | position: absolute; 117 | top: 0; 118 | left: 0; 119 | width: 100%; 120 | height: 100%; 121 | background-color: rgba(0, 0, 0, 0.5); 122 | display: flex; 123 | align-items: center; 124 | justify-content: center; 125 | } 126 | 127 | #zhihu-obsidian-modal .modal-content { 128 | background-color: white; 129 | border-radius: 8px; 130 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 131 | width: 90%; 132 | max-width: 600px; 133 | max-height: 85vh; 134 | overflow: hidden; 135 | display: flex; 136 | flex-direction: column; 137 | } 138 | 139 | #zhihu-obsidian-modal .modal-header { 140 | background-color: rgb(221, 232, 249); 141 | padding: 16px 20px; 142 | border-bottom: 1px solid rgb(23, 114, 246); 143 | display: flex; 144 | justify-content: space-between; 145 | align-items: center; 146 | } 147 | 148 | #zhihu-obsidian-modal .modal-header h3 { 149 | margin: 0; 150 | color: black; 151 | font-size: 18px; 152 | font-weight: 600; 153 | display: flex; 154 | align-items: center; 155 | gap: 0.5em; 156 | } 157 | 158 | #zhihu-obsidian-modal .close-btn { 159 | background: none; 160 | border: none; 161 | font-size: 24px; 162 | cursor: pointer; 163 | color: black; 164 | padding: 0; 165 | width: 30px; 166 | height: 30px; 167 | display: flex; 168 | align-items: center; 169 | justify-content: center; 170 | } 171 | 172 | #zhihu-obsidian-modal .close-btn:hover { 173 | background-color: rgba(23, 114, 246, 0.1); 174 | border-radius: 4px; 175 | } 176 | 177 | #zhihu-obsidian-modal .modal-body { 178 | padding: 20px; 179 | flex: 1; 180 | overflow-y: auto; 181 | } 182 | 183 | #zhihu-obsidian-modal .folder-selection { 184 | margin-bottom: 20px; 185 | display: flex; 186 | align-items: center; 187 | gap: 12px; 188 | } 189 | 190 | #zhihu-obsidian-modal .selected-folder-info { 191 | background-color: rgb(221, 232, 249); 192 | border: 1px solid rgb(23, 114, 246); 193 | border-radius: 4px; 194 | padding: 12px; 195 | color: black; 196 | font-size: 14px; 197 | min-height: 20px; 198 | flex: 1; 199 | } 200 | 201 | #zhihu-obsidian-modal .select-folder-btn { 202 | background-color: rgb(23, 114, 246); 203 | color: white; 204 | border: none; 205 | padding: 12px 20px; 206 | border-radius: 6px; 207 | cursor: pointer; 208 | font-size: 14px; 209 | font-weight: 500; 210 | transition: background-color 0.2s; 211 | white-space: nowrap; 212 | } 213 | 214 | #zhihu-obsidian-modal .select-folder-btn:hover { 215 | background-color: rgb(21, 101, 217); 216 | } 217 | 218 | #zhihu-obsidian-modal .select-folder-btn:disabled { 219 | background-color: #ccc; 220 | cursor: not-allowed; 221 | } 222 | 223 | #zhihu-obsidian-modal .folder-structure { 224 | background-color: #f8f9fa; 225 | border: 1px solid #dee2e6; 226 | border-radius: 4px; 227 | padding: 16px; 228 | min-height: 180px; 229 | max-height: 260px; 230 | overflow-y: auto; 231 | font-family: monospace; 232 | font-size: 14px; 233 | color: black; 234 | white-space: pre-wrap; 235 | line-height: 1.4; 236 | } 237 | 238 | #zhihu-obsidian-modal .folder-item { 239 | cursor: pointer; 240 | padding: 2px 4px; 241 | border-radius: 3px; 242 | transition: background-color 0.2s; 243 | } 244 | 245 | #zhihu-obsidian-modal .folder-item:hover { 246 | background-color: rgba(23, 114, 246, 0.1); 247 | } 248 | 249 | #zhihu-obsidian-modal .folder-item.selected { 250 | background-color: rgb(221, 232, 249); 251 | font-weight: bold; 252 | } 253 | 254 | #zhihu-obsidian-modal .modal-footer { 255 | background-color: rgb(221, 232, 249); 256 | padding: 16px 20px; 257 | border-top: 1px solid rgb(23, 114, 246); 258 | display: flex; 259 | gap: 12px; 260 | justify-content: center; 261 | } 262 | 263 | #zhihu-obsidian-modal .confirm-btn { 264 | background-color: rgb(23, 114, 246); 265 | color: white; 266 | border: none; 267 | padding: 10px 20px; 268 | border-radius: 6px; 269 | cursor: pointer; 270 | font-size: 14px; 271 | font-weight: 500; 272 | } 273 | 274 | #zhihu-obsidian-modal .confirm-btn:hover:not(:disabled) { 275 | background-color: rgb(21, 101, 217); 276 | } 277 | 278 | #zhihu-obsidian-modal .confirm-btn:disabled { 279 | background-color: #ccc; 280 | cursor: not-allowed; 281 | } 282 | 283 | #zhihu-obsidian-modal .cancel-btn { 284 | background-color: white; 285 | color: black; 286 | border: 1px solid rgb(23, 114, 246); 287 | padding: 10px 20px; 288 | border-radius: 6px; 289 | cursor: pointer; 290 | font-size: 14px; 291 | font-weight: 500; 292 | } 293 | 294 | #zhihu-obsidian-modal .cancel-btn:hover { 295 | background-color: rgb(221, 232, 249); 296 | } 297 | 298 | #zhihu-obsidian-modal .button-group { 299 | display: flex; 300 | gap: 8px; 301 | margin-bottom: 20px; 302 | } 303 | 304 | #zhihu-obsidian-modal .option-btn { 305 | flex: 1; 306 | padding: 10px; 307 | border: 1px solid rgb(23, 114, 246); 308 | border-radius: 6px; 309 | background-color: white; 310 | color: rgb(23, 114, 246); 311 | font-size: 14px; 312 | font-weight: 500; 313 | cursor: pointer; 314 | transition: all 0.2s; 315 | } 316 | 317 | #zhihu-obsidian-modal .option-btn:hover { 318 | background-color: rgba(23, 114, 246, 0.1); 319 | } 320 | 321 | #zhihu-obsidian-modal .option-btn.selected { 322 | background-color: rgb(23, 114, 246); 323 | color: white; 324 | } 325 | 326 | #zhihu-obsidian-modal .user-notes { 327 | margin-top: 20px; 328 | padding: 16px; 329 | background-color: #f8f9fa; 330 | border: 1px solid #dee2e6; 331 | border-radius: 4px; 332 | font-size: 12px; 333 | color: #666; 334 | } 335 | 336 | #zhihu-obsidian-modal .user-notes ul { 337 | margin: 0; 338 | padding-left: 16px; 339 | } 340 | 341 | #zhihu-obsidian-modal .user-notes li { 342 | margin-bottom: 4px; 343 | line-height: 1.4; 344 | } 345 | `; 346 | 347 | // 将样式和弹框添加到页面 348 | document.head.appendChild(style); 349 | document.body.appendChild(obsidianModal); 350 | 351 | // 绑定事件监听器 352 | bindModalEvents(); 353 | } 354 | 355 | /** 356 | * 绑定选项按钮事件 357 | */ 358 | function bindOptionButtons(): void { 359 | const optionButtons = obsidianModal?.querySelectorAll('.option-btn'); 360 | optionButtons?.forEach(button => { 361 | button.addEventListener('click', () => { 362 | // 移除所有按钮的选中状态 363 | optionButtons.forEach(btn => btn.classList.remove('selected')); 364 | 365 | // 添加当前按钮的选中状态 366 | button.classList.add('selected'); 367 | 368 | // 获取按钮文字并保存到localStorage 369 | const buttonText = button.getAttribute('data-text'); 370 | if (buttonText) { 371 | saveSelectedOption(buttonText); 372 | console.log('选中选项:', buttonText); 373 | } 374 | }); 375 | }); 376 | } 377 | 378 | /** 379 | * 保存选中的选项到localStorage 380 | */ 381 | function saveSelectedOption(optionText: string): void { 382 | localStorage.setItem('zhihu-obsidian-selected-option', optionText); 383 | } 384 | 385 | /** 386 | * 从localStorage加载选中的选项 387 | */ 388 | function loadSelectedOption(): string | null { 389 | return localStorage.getItem('zhihu-obsidian-selected-option'); 390 | } 391 | 392 | /** 393 | * 恢复按钮选中状态 394 | */ 395 | function restoreButtonSelection(): void { 396 | const selectedOption = loadSelectedOption(); 397 | let targetButton; 398 | 399 | if (selectedOption) { 400 | targetButton = obsidianModal?.querySelector(`[data-text="${selectedOption}"]`); 401 | } 402 | 403 | // 如果没有保存的选项,默认选中第4个按钮 404 | if (!targetButton) { 405 | targetButton = obsidianModal?.querySelector('#btn-4'); 406 | if (targetButton) { 407 | // 保存默认选择到localStorage 408 | const defaultText = targetButton.getAttribute('data-text'); 409 | if (defaultText) { 410 | saveSelectedOption(defaultText); 411 | } 412 | } 413 | } 414 | 415 | if (targetButton) { 416 | // 移除所有按钮的选中状态 417 | const optionButtons = obsidianModal?.querySelectorAll('.option-btn'); 418 | optionButtons?.forEach(btn => btn.classList.remove('selected')); 419 | 420 | // 添加目标按钮的选中状态 421 | targetButton.classList.add('selected'); 422 | const buttonText = targetButton.getAttribute('data-text'); 423 | console.log('恢复选中状态:', buttonText); 424 | } 425 | } 426 | 427 | /** 428 | * 绑定弹框事件监听器 429 | */ 430 | function bindModalEvents(): void { 431 | if (!obsidianModal) return; 432 | 433 | // 关闭按钮 434 | const closeBtn = obsidianModal.querySelector('.close-btn'); 435 | closeBtn?.addEventListener('click', hideObsidianModal); 436 | 437 | // 取消按钮 438 | const cancelBtn = obsidianModal.querySelector('#cancel-btn'); 439 | cancelBtn?.addEventListener('click', hideObsidianModal); 440 | 441 | // 绑定选项按钮事件 442 | bindOptionButtons(); 443 | 444 | // 选择文件夹按钮 445 | const selectFolderBtn = obsidianModal.querySelector('#select-folder-btn'); 446 | selectFolderBtn?.addEventListener('click', async () => { 447 | try { 448 | selectedVaultHandle = await selectObsidianVaultInternal(); 449 | if (selectedVaultHandle) { 450 | rootVaultHandle = selectedVaultHandle; // 保存根路径 451 | currentSelectedPath = ''; // 重置为根路径 452 | 453 | // 保存到IndexedDB 454 | await fileHandleManager.saveRootFolderHandle(selectedVaultHandle); 455 | await fileHandleManager.saveCurrentSelectedHandle(selectedVaultHandle); 456 | fileHandleManager.setRootFolder(selectedVaultHandle); 457 | fileHandleManager.setCurrentSelected(selectedVaultHandle); 458 | 459 | updateSelectedFolderInfo(); 460 | await updateFolderStructure(); 461 | enableConfirmButton(); 462 | 463 | console.log('新文件夹选择完成:', selectedVaultHandle.name); 464 | } 465 | } catch (error) { 466 | console.error('选择文件夹失败:', error); 467 | } 468 | }); 469 | 470 | // 确认保存按钮的事件监听器将在 selectObsidianVault 函数中动态添加 471 | 472 | // 点击遮罩层关闭弹框 473 | const overlay = obsidianModal.querySelector('.modal-overlay'); 474 | overlay?.addEventListener('click', (e) => { 475 | if (e.target === overlay) { 476 | hideObsidianModal(); 477 | } 478 | }); 479 | } 480 | 481 | /** 482 | * 显示 Obsidian 选择弹框 483 | */ 484 | export function showObsidianModal(): void { 485 | if (!obsidianModal) { 486 | injectObsidianModal(); 487 | } 488 | if (obsidianModal) { 489 | obsidianModal.style.display = 'block'; 490 | 491 | // 恢复按钮选中状态 492 | restoreButtonSelection(); 493 | 494 | // 加载上次的选择状态 495 | loadLastSelection(); 496 | } 497 | } 498 | 499 | /** 500 | * 隐藏 Obsidian 选择弹框 501 | */ 502 | export function hideObsidianModal(): void { 503 | if (obsidianModal) { 504 | // 先设置透明度过渡 505 | obsidianModal.style.opacity = '0'; 506 | 507 | // 等待过渡完成后再彻底隐藏 508 | setTimeout(() => { 509 | if (obsidianModal) { 510 | obsidianModal.style.display = 'none'; 511 | // 重置透明度,为下次显示做准备 512 | obsidianModal.style.opacity = '1'; 513 | } 514 | }, 300); 515 | } 516 | } 517 | 518 | /** 519 | * 加载上次的选择状态 520 | */ 521 | async function loadLastSelection(): Promise { 522 | console.log('尝试恢复文件夹访问权限...'); 523 | 524 | // 显示加载状态 525 | const structureElement = obsidianModal?.querySelector('#folder-structure') as HTMLElement; 526 | if (structureElement) { 527 | structureElement.innerHTML = ` 528 |
529 |

正在恢复文件夹访问权限...

530 |
531 | `; 532 | } 533 | 534 | // 尝试从IndexedDB恢复根文件夹句柄 535 | const rootHandle = await fileHandleManager.loadAndVerifyRootFolderHandle(); 536 | 537 | if (rootHandle) { 538 | console.log('成功恢复根文件夹访问权限:', rootHandle.name); 539 | 540 | // 设置根目录状态 541 | rootVaultHandle = rootHandle; 542 | fileHandleManager.setRootFolder(rootHandle); 543 | 544 | // 尝试恢复当前选择的文件夹句柄 545 | const currentSelectedHandle = await fileHandleManager.loadAndVerifyCurrentSelectedHandle(); 546 | 547 | if (currentSelectedHandle) { 548 | console.log('成功恢复当前选择文件夹访问权限:', currentSelectedHandle.name); 549 | selectedVaultHandle = currentSelectedHandle; 550 | fileHandleManager.setCurrentSelected(currentSelectedHandle); 551 | } else { 552 | console.log('未找到当前选择的文件夹,使用根目录'); 553 | selectedVaultHandle = rootHandle; 554 | fileHandleManager.setCurrentSelected(null); 555 | } 556 | 557 | // 加载保存的路径配置 558 | const saved = loadDirectorySelection(); 559 | currentSelectedPath = saved.selectedPath || ''; 560 | /* if (currentSelectedPath != rootHandle.name) { */ 561 | // 更新UI显示,高亮显示 562 | updateSelectedFolderInfo(currentSelectedPath); 563 | updateFolderHighlight(currentSelectedPath); 564 | /* } 565 | else { 566 | updateSelectedFolderInfo(''); 567 | updateFolderHighlight(''); 568 | } */ 569 | 570 | // 打开一次后,可能不需要更新了 571 | await updateFolderStructure(); 572 | 573 | // 启用确认按钮 574 | enableConfirmButton(); 575 | 576 | console.log('文件夹结构已恢复,当前路径:', currentSelectedPath); 577 | console.log('当前选择的句柄:', selectedVaultHandle.name); 578 | } else { 579 | console.log('需要重新选择文件夹'); 580 | 581 | // 显示默认状态 582 | const infoElement = obsidianModal?.querySelector('#selected-folder-info'); 583 | if (infoElement) { 584 | infoElement.textContent = '未选择文件夹'; 585 | } 586 | 587 | const structureElement = obsidianModal?.querySelector('#folder-structure') as HTMLElement; 588 | if (structureElement) { 589 | structureElement.innerHTML = ` 590 |
591 |

点击"选择文件夹"开始选择您的存储仓库

592 |
593 | `; 594 | } 595 | } 596 | } 597 | 598 | // ============= 3. 辅助函数 ============= 599 | 600 | /** 601 | * 更新选中的文件夹信息显示 602 | */ 603 | function updateSelectedFolderInfo(customPath?: string): void { 604 | const infoElement = obsidianModal?.querySelector('#selected-folder-info'); 605 | if (infoElement && selectedVaultHandle && rootVaultHandle) { 606 | let displayPath; 607 | if (customPath) { 608 | // 如果提供了自定义路径,从根路径开始显示 609 | displayPath = `${rootVaultHandle.name}/${customPath}`; 610 | } else { 611 | // 显示当前选择的文件夹名称 612 | displayPath = selectedVaultHandle.name; 613 | } 614 | infoElement.textContent = `已选择: ${displayPath}`; 615 | } 616 | } 617 | 618 | /** 619 | * 更新文件夹高亮显示 620 | */ 621 | function updateFolderHighlight(selectedPath: string): void { 622 | const structureElement = obsidianModal?.querySelector('#folder-structure') as HTMLElement; 623 | if (!structureElement) return; 624 | 625 | // 移除所有高亮 626 | const allItems = structureElement.querySelectorAll('.folder-item'); 627 | allItems.forEach(item => { 628 | item.classList.remove('selected'); 629 | }); 630 | 631 | // 高亮选中的文件夹 632 | setTimeout(() => { 633 | const selectedItem = structureElement.querySelector(`[data-path="${selectedPath}"]`); 634 | if (selectedItem) { 635 | selectedItem.classList.add('selected'); 636 | } 637 | }, 100); 638 | 639 | } 640 | 641 | /** 642 | * 启用确认按钮 643 | */ 644 | function enableConfirmButton(): void { 645 | (obsidianModal?.querySelector('#confirm-save-btn') as HTMLButtonElement).disabled = false; 646 | } 647 | 648 | // ============= 4. 文件夹管理 ============= 649 | 650 | /** 651 | * 更新文件夹结构显示 652 | */ 653 | async function updateFolderStructure(): Promise { 654 | const structureElement = obsidianModal?.querySelector('#folder-structure') as HTMLElement; 655 | if (!structureElement || !rootVaultHandle) return; 656 | 657 | try { 658 | // 清除之前的事件监听器 659 | structureElement.innerHTML = ''; 660 | 661 | // 创建根文件夹显示(始终从根文件夹开始) 662 | const rootElement = createFolderElement(rootVaultHandle.name, rootVaultHandle, ''); 663 | structureElement.appendChild(rootElement); 664 | 665 | // 添加子文件夹(从根文件夹开始展开) 666 | await addSubFolders(structureElement, rootVaultHandle, '', 4); 667 | 668 | } catch (error) { 669 | structureElement.innerHTML = '
无法读取文件夹结构
'; 670 | } 671 | } 672 | 673 | /** 674 | * 创建文件夹元素 675 | */ 676 | function createFolderElement(name: string, handle: FileSystemDirectoryHandle, path: string): HTMLElement { 677 | const element = document.createElement('div'); 678 | element.className = 'folder-item'; 679 | element.textContent = '📁 ' + name; 680 | element.dataset.path = path; 681 | element.dataset.name = name; 682 | element.dataset.handle = JSON.stringify({ name: handle.name }); // 存储句柄信息 683 | 684 | element.addEventListener('click', async () => { 685 | await selectFolder(handle, path); 686 | }); 687 | 688 | return element; 689 | } 690 | 691 | /** 692 | * 添加子文件夹 693 | */ 694 | async function addSubFolders( 695 | container: HTMLElement, 696 | dirHandle: FileSystemDirectoryHandle, 697 | currentPath: string, 698 | maxDepth: number, 699 | indent: string = '' 700 | ): Promise { 701 | if (maxDepth <= 0) return; 702 | 703 | try { 704 | const entries: Array<{ name: string, handle: FileSystemHandle }> = []; 705 | 706 | for await (const [name, handle] of dirHandle.entries()) { 707 | entries.push({ name, handle }); 708 | } 709 | 710 | // 只筛选出文件夹,并过滤掉名称长度超过25字符的文件夹 711 | const folders = entries.filter(entry => 712 | entry.handle.kind === 'directory' && 713 | entry.name.length <= 25 && 714 | entry.name !== 'assets' 715 | ); 716 | 717 | // 限制显示条目数量 718 | const limitedFolders = folders.slice(0, 20); 719 | 720 | for (const { name, handle } of limitedFolders) { 721 | const fullPath = currentPath ? `${currentPath}/${name}` : name; 722 | const folderElement = createFolderElement(name, handle as FileSystemDirectoryHandle, fullPath); 723 | 724 | // 添加缩进(增加每一级的缩进量) 725 | folderElement.style.paddingLeft = `${indent.length * 20 + 20}px`; 726 | 727 | container.appendChild(folderElement); 728 | 729 | // 递归添加子文件夹 730 | if (maxDepth > 1) { 731 | await addSubFolders(container, handle as FileSystemDirectoryHandle, fullPath, maxDepth - 1, indent + ' '); 732 | } 733 | } 734 | 735 | if (folders.length > 20) { 736 | const moreElement = document.createElement('div'); 737 | moreElement.textContent = indent + `... 还有 ${folders.length - 20} 个文件夹`; 738 | moreElement.style.paddingLeft = `${indent.length * 20 + 20}px`; 739 | moreElement.style.color = '#666'; 740 | container.appendChild(moreElement); 741 | } 742 | 743 | } catch (error) { 744 | console.error('读取子文件夹失败:', error); 745 | } 746 | } 747 | 748 | /** 749 | * 点击后选择文件夹 750 | */ 751 | async function selectFolder(handle: FileSystemDirectoryHandle, path: string): Promise { 752 | // 更新全局变量 753 | selectedVaultHandle = handle; 754 | currentSelectedPath = path; 755 | 756 | // 保存当前选择的句柄到IndexedDB 757 | await fileHandleManager.saveCurrentSelectedHandle(handle); 758 | fileHandleManager.setCurrentSelected(handle); 759 | 760 | // 保存到localStorage 761 | if (rootVaultHandle) { 762 | saveDirectorySelection(rootVaultHandle.name, path); 763 | } 764 | 765 | // 更新显示路径 766 | updateSelectedFolderInfo(path); 767 | 768 | // 更新高亮显示 769 | updateFolderHighlight(path); 770 | 771 | // 启用确认按钮 772 | enableConfirmButton(); 773 | 774 | console.log('点击选择子文件夹:', path, '句柄:', handle.name); 775 | } 776 | 777 | 778 | /** 779 | * 内部的选择文件夹函数(实际执行选择操作) 780 | */ 781 | async function selectObsidianVaultInternal(): Promise { 782 | try { 783 | const dirHandle = await (window as any).showDirectoryPicker({ 784 | mode: "readwrite", 785 | }); 786 | return dirHandle; 787 | } catch (err) { 788 | if (err.name !== "AbortError") { 789 | console.error("选择目录失败:", err); 790 | } 791 | return null; 792 | } 793 | } 794 | 795 | // ============= 5. IndexedDB 持久化 ============= 796 | 797 | /** 798 | * 简化的 IndexedDB 操作类 799 | */ 800 | class SimpleDB { 801 | private dbName: string; 802 | private version: number; 803 | 804 | constructor(dbName: string, version: number = 1) { 805 | this.dbName = dbName; 806 | this.version = version; 807 | } 808 | 809 | async open(): Promise { 810 | return new Promise((resolve, reject) => { 811 | const request = indexedDB.open(this.dbName, this.version); 812 | 813 | request.onerror = () => reject(request.error); 814 | request.onsuccess = () => resolve(request.result); 815 | 816 | request.onupgradeneeded = (event) => { 817 | const db = (event.target as IDBOpenDBRequest).result; 818 | if (!db.objectStoreNames.contains('handles')) { 819 | db.createObjectStore('handles'); 820 | } 821 | }; 822 | }); 823 | } 824 | 825 | async put(storeName: string, value: any, key: string): Promise { 826 | const db = await this.open(); 827 | const transaction = db.transaction([storeName], 'readwrite'); 828 | const store = transaction.objectStore(storeName); 829 | return new Promise((resolve, reject) => { 830 | const request = store.put(value, key); 831 | request.onsuccess = () => resolve(); 832 | request.onerror = () => reject(request.error); 833 | }); 834 | } 835 | 836 | async get(storeName: string, key: string): Promise { 837 | const db = await this.open(); 838 | const transaction = db.transaction([storeName], 'readonly'); 839 | const store = transaction.objectStore(storeName); 840 | return new Promise((resolve, reject) => { 841 | const request = store.get(key); 842 | request.onsuccess = () => resolve(request.result); 843 | request.onerror = () => reject(request.error); 844 | }); 845 | } 846 | 847 | async delete(storeName: string, key: string): Promise { 848 | const db = await this.open(); 849 | const transaction = db.transaction([storeName], 'readwrite'); 850 | const store = transaction.objectStore(storeName); 851 | return new Promise((resolve, reject) => { 852 | const request = store.delete(key); 853 | request.onsuccess = () => resolve(); 854 | request.onerror = () => reject(request.error); 855 | }); 856 | } 857 | } 858 | 859 | /** 860 | * FileSystemDirectoryHandle 管理器 861 | */ 862 | class FileHandleManager { 863 | private db: SimpleDB; 864 | private storeName: string = 'handles'; 865 | private rootFolderHandle: FileSystemDirectoryHandle | null = null; 866 | private currentSelectedHandle: FileSystemDirectoryHandle | null = null; 867 | 868 | constructor() { 869 | this.db = new SimpleDB('zhihu-obsidian-handles'); 870 | } 871 | 872 | // 保存根文件夹句柄 873 | async saveRootFolderHandle(folderHandle: FileSystemDirectoryHandle): Promise { 874 | try { 875 | await this.db.put(this.storeName, folderHandle, 'rootFolder'); 876 | console.log('根文件夹句柄已保存到 IndexedDB'); 877 | return true; 878 | } catch (error) { 879 | console.error('保存根文件夹句柄失败:', error); 880 | return false; 881 | } 882 | } 883 | 884 | // 保存当前选择的文件夹句柄 885 | async saveCurrentSelectedHandle(folderHandle: FileSystemDirectoryHandle): Promise { 886 | try { 887 | await this.db.put(this.storeName, folderHandle, 'currentSelected'); 888 | console.log('当前选择文件夹句柄已保存到 IndexedDB'); 889 | return true; 890 | } catch (error) { 891 | console.error('保存当前选择文件夹句柄失败:', error); 892 | return false; 893 | } 894 | } 895 | 896 | // 加载并验证根文件夹句柄 897 | async loadAndVerifyRootFolderHandle(): Promise { 898 | try { 899 | const folderHandle = await this.db.get(this.storeName, 'rootFolder'); 900 | 901 | if (!folderHandle) { 902 | console.log('未找到保存的根文件夹句柄'); 903 | return null; 904 | } 905 | 906 | return await this.verifyFolderHandle(folderHandle, 'rootFolder'); 907 | } catch (error) { 908 | console.error('加载根文件夹句柄失败:', error); 909 | return null; 910 | } 911 | } 912 | 913 | // 加载并验证当前选择的文件夹句柄 914 | async loadAndVerifyCurrentSelectedHandle(): Promise { 915 | try { 916 | const folderHandle = await this.db.get(this.storeName, 'currentSelected'); 917 | 918 | if (!folderHandle) { 919 | console.log('未找到保存的当前选择文件夹句柄'); 920 | return null; 921 | } 922 | 923 | return await this.verifyFolderHandle(folderHandle, 'currentSelected'); 924 | } catch (error) { 925 | console.error('加载当前选择文件夹句柄失败:', error); 926 | return null; 927 | } 928 | } 929 | 930 | // 验证文件夹句柄权限 931 | private async verifyFolderHandle(folderHandle: FileSystemDirectoryHandle, key: string): Promise { 932 | try { 933 | // 检查权限 934 | const permissionStatus = await folderHandle.queryPermission(); 935 | console.log(`${key} 权限状态: ${permissionStatus}`); 936 | 937 | if (permissionStatus === 'granted') { 938 | console.log(`${key} 文件夹权限仍然有效`); 939 | return folderHandle; 940 | } 941 | 942 | // 尝试重新请求权限 943 | console.log(`尝试重新请求 ${key} 文件夹权限...`); 944 | const newPermissionStatus = await folderHandle.requestPermission(); 945 | 946 | if (newPermissionStatus === 'granted') { 947 | console.log(`重新获得 ${key} 文件夹权限`); 948 | return folderHandle; 949 | } 950 | 951 | // 权限被拒绝,从存储中移除 952 | console.log(`${key} 权限被拒绝,清除保存的句柄`); 953 | await this.db.delete(this.storeName, key); 954 | return null; 955 | } catch (error) { 956 | console.error(`验证 ${key} 文件夹句柄失败:`, error); 957 | return null; 958 | } 959 | } 960 | 961 | // 设置根文件夹句柄 962 | setRootFolder(folderHandle: FileSystemDirectoryHandle | null): void { 963 | this.rootFolderHandle = folderHandle; 964 | } 965 | 966 | // 设置当前选择的文件夹句柄 967 | setCurrentSelected(folderHandle: FileSystemDirectoryHandle | null): void { 968 | this.currentSelectedHandle = folderHandle; 969 | } 970 | 971 | // 获取根文件夹句柄 972 | getRootFolder(): FileSystemDirectoryHandle | null { 973 | return this.rootFolderHandle; 974 | } 975 | 976 | // 获取当前选择的文件夹句柄 977 | getCurrentSelected(): FileSystemDirectoryHandle | null { 978 | return this.currentSelectedHandle; 979 | } 980 | 981 | // 兼容性方法:获取当前文件夹句柄(返回当前选择的,如果没有则返回根目录) 982 | getCurrentFolder(): FileSystemDirectoryHandle | null { 983 | return this.currentSelectedHandle || this.rootFolderHandle; 984 | } 985 | } 986 | 987 | // 全局文件句柄管理器实例 988 | const fileHandleManager = new FileHandleManager(); 989 | 990 | // ============= 6. 配置管理 ============= 991 | 992 | /** 993 | * Obsidian 保存器配置 994 | */ 995 | export interface ObsidianConfig { 996 | /** Obsidian vault 根目录句柄 */ 997 | vaultHandle?: FileSystemDirectoryHandle; 998 | /** 附件文件夹名称 */ 999 | attachmentFolder: string; 1000 | /** 上次选择的根目录名称 */ 1001 | lastRootName?: string; 1002 | /** 上次选择的相对路径 */ 1003 | lastSelectedPath?: string; 1004 | } 1005 | 1006 | /** 1007 | * 从 localStorage 加载 Obsidian 配置 1008 | */ 1009 | function loadObsidianConfig(): ObsidianConfig { 1010 | const config = localStorage.getItem("zhihu-obsidian-config"); 1011 | if (config) { 1012 | const parsed = JSON.parse(config); 1013 | return { 1014 | attachmentFolder: parsed.attachmentFolder || "assets", 1015 | lastRootName: parsed.lastRootName, 1016 | lastSelectedPath: parsed.lastSelectedPath, 1017 | }; 1018 | } 1019 | return { 1020 | attachmentFolder: "assets", 1021 | }; 1022 | } 1023 | 1024 | /** 1025 | * 保存 Obsidian 配置到 localStorage 1026 | */ 1027 | export function saveObsidianConfig(config: Partial): void { 1028 | const current = loadObsidianConfig(); 1029 | const updated = { ...current, ...config }; 1030 | localStorage.setItem("zhihu-obsidian-config", JSON.stringify(updated)); 1031 | } 1032 | 1033 | /** 1034 | * 保存目录选择状态到 localStorage 1035 | */ 1036 | function saveDirectorySelection(rootName: string, selectedPath: string): void { 1037 | saveObsidianConfig({ 1038 | lastRootName: rootName, 1039 | lastSelectedPath: selectedPath, 1040 | }); 1041 | } 1042 | 1043 | /** 1044 | * 从 localStorage 加载目录选择状态 1045 | */ 1046 | function loadDirectorySelection(): { rootName?: string; selectedPath?: string } { 1047 | const config = loadObsidianConfig(); 1048 | return { 1049 | rootName: config.lastRootName, 1050 | selectedPath: config.lastSelectedPath, 1051 | }; 1052 | } 1053 | 1054 | /** 1055 | * 清理文件名,移除所有不允许的字符 1056 | * Windows/macOS/Linux 文件系统禁止的字符:< > : " / \ | ? * 以及控制字符 1057 | */ 1058 | function sanitizeFilename(filename: string): string { 1059 | if (!filename || typeof filename !== 'string') { 1060 | return 'untitled'; 1061 | } 1062 | 1063 | return filename 1064 | // 移除所有控制字符(包括换行、回车、制表符等) 1065 | .replace(/[\x00-\x1f\x7f-\x9f]/g, '') 1066 | // 移除或替换文件系统非法字符 1067 | .replace(/[<>:"/\\|?*]/g, '-') 1068 | // 移除 Unicode 零宽字符和其他不可见字符 1069 | .replace(/[\u200B-\u200D\uFEFF]/g, '') 1070 | // 替换连续空白字符为单个空格 1071 | .replace(/\s+/g, ' ') 1072 | // 移除前后空格 1073 | .trim() 1074 | // 移除连续的点(避免 .. 等) 1075 | .replace(/\.{2,}/g, '.') 1076 | // 移除文件名开头和结尾的点和空格 1077 | .replace(/^[.\s]+|[.\s]+$/g, '') 1078 | // 限制长度(Windows 文件名最大255字节,保守起见限制200字符) 1079 | .substring(0, 200) 1080 | // 再次移除末尾的空格和点 1081 | .replace(/[.\s]+$/, '') 1082 | // 如果清理后为空,使用默认名称 1083 | || 'untitled'; 1084 | } 1085 | 1086 | // ============= 7. 主函数 ============= 1087 | 1088 | /** 1089 | * 请求选择 Obsidian vault 目录 1090 | * 现在打开弹框,通过弹框界面进行选择 1091 | * Promise 1092 | */ 1093 | export async function selectObsidianVault(): Promise { 1094 | // 检查浏览器是否支持 File System Access API 1095 | if (!(window as any).showDirectoryPicker) { 1096 | alert("您的浏览器不支持文件系统访问功能,请使用 Chrome 或 Edge 浏览器"); 1097 | return null; 1098 | } 1099 | 1100 | return new Promise((resolve) => { 1101 | // 显示弹框 1102 | showObsidianModal(); 1103 | 1104 | // 监听确认按钮点击事件 1105 | const confirmBtn = obsidianModal?.querySelector('#confirm-save-btn'); 1106 | const cancelBtn = obsidianModal?.querySelector('#cancel-btn'); 1107 | const closeBtn = obsidianModal?.querySelector('.close-btn'); 1108 | 1109 | const cleanup = () => { 1110 | confirmBtn?.removeEventListener('click', onConfirm); 1111 | cancelBtn?.removeEventListener('click', onCancel); 1112 | closeBtn?.removeEventListener('click', onCancel); 1113 | }; 1114 | 1115 | const onConfirm = async () => { 1116 | cleanup(); 1117 | // hideObsidianModal(); 1118 | 1119 | // 获取当前选中的按钮内容 1120 | const selectedButton = obsidianModal?.querySelector('.option-btn.selected'); 1121 | const selectedOption = selectedButton?.getAttribute('data-text'); 1122 | 1123 | console.log('确认保存 - selectedVaultHandle:', selectedVaultHandle?.name); 1124 | console.log('确认保存 - currentSelectedPath:', currentSelectedPath); 1125 | console.log('确认保存 - 选择的按钮内容:', selectedOption); 1126 | 1127 | if (selectedOption) { 1128 | // 优先使用当前选择的句柄(可能是子文件夹) 1129 | let finalHandle = selectedVaultHandle; 1130 | 1131 | // 如果当前没有选择句柄,则使用IndexedDB中保存的当前选择句柄 1132 | if (!finalHandle) { 1133 | finalHandle = fileHandleManager.getCurrentSelected(); 1134 | } 1135 | 1136 | // 如果还是没有,使用根目录句柄 1137 | if (!finalHandle) { 1138 | finalHandle = fileHandleManager.getRootFolder(); 1139 | } 1140 | 1141 | if (finalHandle && rootVaultHandle) { 1142 | const currentPath = currentSelectedPath 1143 | ? `${rootVaultHandle.name}/${currentSelectedPath}` 1144 | : rootVaultHandle.name; 1145 | 1146 | console.log('当前保存的路径:', currentPath); 1147 | } 1148 | } 1149 | resolve(selectedOption || null); 1150 | }; 1151 | 1152 | const onCancel = () => { 1153 | cleanup(); 1154 | hideObsidianModal(); 1155 | resolve(null); 1156 | }; 1157 | 1158 | confirmBtn?.addEventListener('click', onConfirm); 1159 | cancelBtn?.addEventListener('click', onCancel); 1160 | closeBtn?.addEventListener('click', onCancel); 1161 | }); 1162 | } 1163 | 1164 | // ============= 8. 文件处理与保存 ============= 1165 | 1166 | /** 1167 | * 保存结果接口 1168 | */ 1169 | export interface SaveResult { 1170 | zip?: JSZip; 1171 | textString?: string; 1172 | title: string; 1173 | } 1174 | 1175 | /** 1176 | * 保存类型 1177 | */ 1178 | export type SaveType = 'zip-single' | 'zip-common' | 'zip-none' | 'png' | 'text'; 1179 | 1180 | /** 1181 | * 将dataUrl转换为Blob 1182 | * @param dataUrl 图片的data URL 1183 | * @returns Blob对象 1184 | */ 1185 | function dataUrlToBlob(dataUrl: string): Blob { 1186 | // 分离dataUrl的元数据和数据部分 1187 | const parts = dataUrl.split(','); 1188 | const mime = parts[0].match(/:(.*?);/)?.[1] || 'image/png'; 1189 | const bstr = atob(parts[1]); // base64解码 1190 | 1191 | // 将字符串转换为Uint8Array 1192 | const n = bstr.length; 1193 | const u8arr = new Uint8Array(n); 1194 | for (let i = 0; i < n; i++) { 1195 | u8arr[i] = bstr.charCodeAt(i); 1196 | } 1197 | 1198 | // 创建Blob 1199 | return new Blob([u8arr], { type: mime }); 1200 | } 1201 | 1202 | /** 1203 | * 解包ZIP文件到指定文件夹 1204 | * @param zip JSZip对象 1205 | * @param targetFolder 目标文件夹句柄 1206 | */ 1207 | async function unpackZipToFolder(zip: JSZip, targetFolder: FileSystemDirectoryHandle): Promise { 1208 | const files = Object.keys(zip.files); 1209 | 1210 | for (const filepath of files) { 1211 | const file = zip.files[filepath]; 1212 | 1213 | // 跳过文件夹条目 1214 | if (file.dir) { 1215 | continue; 1216 | } 1217 | 1218 | try { 1219 | // 分割路径,处理嵌套文件夹 1220 | const pathParts = filepath.split('/'); 1221 | const filename = pathParts.pop(); // 最后一部分是文件名 1222 | 1223 | if (!filename) { 1224 | continue; 1225 | } 1226 | 1227 | // 如果有子文件夹,先创建子文件夹 1228 | let currentFolder = targetFolder; 1229 | for (const folderName of pathParts) { 1230 | if (folderName) { 1231 | const safeFolderName = sanitizeFilename(folderName); 1232 | currentFolder = await currentFolder.getDirectoryHandle(safeFolderName, { create: true }); 1233 | } 1234 | } 1235 | 1236 | // 获取文件内容 1237 | const content = await file.async('uint8array'); 1238 | const safeFilename = sanitizeFilename(filename); 1239 | 1240 | // 创建并写入文件 1241 | const fileHandle = await currentFolder.getFileHandle(safeFilename, { create: true }); 1242 | const writable = await fileHandle.createWritable(); 1243 | await writable.write(content as FileSystemWriteChunkType); 1244 | await writable.close(); 1245 | console.log(`已保存文件: ${filepath} -> ${safeFilename}`); 1246 | } catch (error) { 1247 | console.error(`保存文件失败 ${filepath}:`, error); 1248 | // 继续处理其他文件 1249 | } 1250 | } 1251 | } 1252 | 1253 | /** 1254 | * 保存文件到本地文件系统 1255 | * @param result 保存结果数据 1256 | * @param saveType 保存类型 1257 | */ 1258 | export async function saveFile(result: SaveResult, saveType: SaveType): Promise { 1259 | 1260 | const finalHandle = selectedVaultHandle || fileHandleManager.getCurrentSelected() || fileHandleManager.getRootFolder(); 1261 | 1262 | if (!finalHandle) { 1263 | throw new Error('未选择保存文件夹'); 1264 | } 1265 | 1266 | if (saveType === 'zip-single') { 1267 | if (!result.zip) { 1268 | throw new Error('ZIP数据不存在'); 1269 | } 1270 | 1271 | const folderName = sanitizeFilename(result.title); 1272 | try { 1273 | const targetFolder = await finalHandle.getDirectoryHandle(folderName, { create: true }); 1274 | await unpackZipToFolder(result.zip, targetFolder); 1275 | console.log(`成功解包ZIP文件到文件夹: ${folderName}`); 1276 | showToast('✅ 保存成功'); 1277 | } catch (error) { 1278 | console.error('解包ZIP文件失败:', error); 1279 | showToast('❌ 保存失败'); 1280 | throw error; 1281 | } 1282 | } 1283 | else if (saveType === 'zip-common') { 1284 | if (!result.zip) { 1285 | throw new Error('ZIP数据不存在'); 1286 | } 1287 | 1288 | try { 1289 | await unpackZipCommon(result.zip, result.title, finalHandle); 1290 | console.log(`成功共同解包ZIP文件: ${result.title}`); 1291 | showToast('✅ 保存成功'); 1292 | } catch (error) { 1293 | console.error('共同解包ZIP文件失败:', error); 1294 | showToast('❌ 保存失败'); 1295 | throw error; 1296 | } 1297 | } 1298 | else if (saveType === 'zip-none') { 1299 | if (!result.zip) { 1300 | throw new Error('ZIP数据不存在'); 1301 | } 1302 | 1303 | const filename = result.title + '.zip'; 1304 | try { 1305 | const zipBlob = await result.zip.generateAsync({ type: 'blob' }); 1306 | const fileHandle = await finalHandle.getFileHandle(filename, { create: true }); 1307 | const writable = await fileHandle.createWritable(); 1308 | await writable.write(zipBlob); 1309 | await writable.close(); 1310 | console.log(`成功保存ZIP文件: ${filename}`); 1311 | showToast('✅ 保存成功'); 1312 | } catch (error) { 1313 | console.error('保存ZIP文件失败:', error); 1314 | showToast('❌ 保存失败'); 1315 | throw error; 1316 | } 1317 | } 1318 | else if (saveType === 'png') { 1319 | if (!result.textString) { 1320 | throw new Error('图片数据不存在'); 1321 | } 1322 | 1323 | const filename = result.title + '.png'; 1324 | try { 1325 | const blob = dataUrlToBlob(result.textString); 1326 | const fileHandle = await finalHandle.getFileHandle(filename, { create: true }); 1327 | const writable = await fileHandle.createWritable(); 1328 | await writable.write(blob); 1329 | await writable.close(); 1330 | console.log(`成功保存图片文件: ${filename}`); 1331 | showToast('✅ 保存成功'); 1332 | } catch (error) { 1333 | console.error('保存图片文件失败:', error); 1334 | showToast('❌ 保存失败'); 1335 | throw error; 1336 | } 1337 | } 1338 | else if (saveType === 'text') { 1339 | if (!result.textString) { 1340 | throw new Error('文本内容不存在'); 1341 | } 1342 | 1343 | const filename = result.title + '.md'; 1344 | try { 1345 | const fileHandle = await finalHandle.getFileHandle(filename, { create: true }); 1346 | const writable = await fileHandle.createWritable(); 1347 | await writable.write(result.textString); 1348 | await writable.close(); 1349 | console.log(`成功保存MD文件: ${filename}`); 1350 | showToast('✅ 保存成功'); 1351 | } catch (error) { 1352 | console.error('保存MD文件失败:', error); 1353 | showToast('❌ 保存失败'); 1354 | throw error; 1355 | } 1356 | } 1357 | // 等保存成功后再隐藏弹框,不然共同解包ZIP会出问题,不能正常合并评论 1358 | hideObsidianModal(); 1359 | } 1360 | 1361 | 1362 | /** 1363 | * 共同解包ZIP文件 1364 | * @param zip JSZip对象 1365 | * @param title 文件标题 1366 | * @param targetFolder 目标文件夹句柄 1367 | * @param assetsFolder assets文件夹名称,默认为'assets' 1368 | */ 1369 | async function unpackZipCommon( 1370 | zip: JSZip, 1371 | title: string, 1372 | targetFolder: FileSystemDirectoryHandle, 1373 | assetsFolder: string = 'assets' 1374 | ): Promise { 1375 | const safeAssetsFolder = sanitizeFilename(assetsFolder); 1376 | 1377 | // 1. 创建或获取assets文件夹 1378 | const assetsDirHandle = await targetFolder.getDirectoryHandle( 1379 | safeAssetsFolder, 1380 | { create: true } 1381 | ); 1382 | 1383 | // 2. 遍历ZIP中的所有文件 1384 | const files = Object.keys(zip.files); 1385 | for (const filepath of files) { 1386 | const file = zip.files[filepath]; 1387 | 1388 | // 跳过文件夹条目 1389 | if (file.dir) { 1390 | continue; 1391 | } 1392 | 1393 | try { 1394 | const content = await file.async('uint8array'); 1395 | const pureFilename = filepath.split('/').pop(); 1396 | 1397 | if (!pureFilename) { 1398 | continue; 1399 | } 1400 | 1401 | const safeFilename = sanitizeFilename(pureFilename); 1402 | // 判断是md文件还是资源文件 1403 | if (pureFilename.endsWith('.md')) { 1404 | // MD文件保存到目标文件夹根目录 1405 | const safeTitle = sanitizeFilename(title); 1406 | const mdFilename = `${safeTitle}.md`; 1407 | 1408 | const mdFileHandle = await targetFolder.getFileHandle(mdFilename, { 1409 | create: true, 1410 | }); 1411 | const writable = await mdFileHandle.createWritable(); 1412 | await writable.write(content as FileSystemWriteChunkType); 1413 | await writable.close(); 1414 | 1415 | console.log(`已保存MD文件: ${mdFilename}`); 1416 | } else { 1417 | // 其他文件(图片等)保存到assets文件夹 1418 | const fileHandle = await assetsDirHandle.getFileHandle(safeFilename, { 1419 | create: true, 1420 | }); 1421 | const writable = await fileHandle.createWritable(); 1422 | await writable.write(content as FileSystemWriteChunkType); 1423 | await writable.close(); 1424 | 1425 | console.log(`已保存资源文件: ${safeAssetsFolder}/${safeFilename}`); 1426 | } 1427 | } catch (error) { 1428 | console.error(`保存文件失败 ${filepath}:`, error); 1429 | // 继续处理其他文件 1430 | } 1431 | } 1432 | } 1433 | --------------------------------------------------------------------------------