├── .github └── workflows │ └── issue-limit.yml ├── .gitignore ├── .markdownlint.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── CHANGELOG_THIS.md ├── Q&A.md ├── README.md ├── README_LESS_IMAGE.md ├── build-config ├── auto-tag.js ├── esbuild.build.js ├── esbuild.plugins.js ├── public │ ├── CHANGELOG_LIST.md │ └── CHANGELOG_MENU.md └── server-tp.js ├── index.js ├── package.json ├── src ├── components │ ├── background │ │ ├── background.ts │ │ ├── create-html.ts │ │ ├── dark.ts │ │ ├── highlight.ts │ │ ├── index.ts │ │ └── types.ts │ ├── black-list │ │ ├── add-block-button.ts │ │ ├── create-html.ts │ │ ├── do-fetch.ts │ │ ├── index.ts │ │ ├── intercept-response.ts │ │ ├── operate.ts │ │ ├── types.ts │ │ ├── update.ts │ │ └── user-home-blocked-user.ts │ ├── blocked-words.ts │ ├── copy │ │ └── index.ts │ ├── ctz-dialog │ │ ├── index.ts │ │ ├── menu.ts │ │ └── open.ts │ ├── ctz-type-operate.ts │ ├── custom-style.ts │ ├── echo-data.ts │ ├── fetch-intercept-status-change.ts │ ├── fn-changer.ts │ ├── follow-remove.ts │ ├── hidden │ │ ├── append-style.ts │ │ ├── configs.ts │ │ ├── create-html.ts │ │ ├── index.ts │ │ └── types.ts │ ├── history.ts │ ├── image.ts │ ├── just-number │ │ └── index.ts │ ├── link.ts │ ├── list-position │ │ ├── index.ts │ │ └── recommend-close-position.ts │ ├── listen-answer │ │ ├── index.ts │ │ └── listen.ts │ ├── listen-comment.ts │ ├── listen-list │ │ ├── index.ts │ │ ├── listen.ts │ │ ├── processing-data.ts │ │ └── recommend-high-performance.ts │ ├── listen-search-list-item.ts │ ├── not-interested │ │ └── index.ts │ ├── one-click-invitation.ts │ ├── page-filter-setting.ts │ ├── page-title │ │ ├── buttons-operate.ts │ │ ├── cache.ts │ │ ├── change.ts │ │ ├── create-html.ts │ │ └── index.ts │ ├── preview.ts │ ├── print.ts │ ├── select │ │ ├── config.ts │ │ ├── create-html.ts │ │ ├── echo.ts │ │ └── index.ts │ ├── size │ │ ├── create-html.ts │ │ ├── index.ts │ │ ├── my-size.ts │ │ └── size-change-before-resize.ts │ ├── suspension │ │ ├── index.ts │ │ ├── init-cache-header.ts │ │ ├── move.ts │ │ ├── store.ts │ │ ├── suspension-header.ts │ │ └── suspension-pickup.ts │ ├── time │ │ └── index.ts │ ├── user-home │ │ ├── index.ts │ │ └── listen.ts │ ├── video │ │ ├── change-style.ts │ │ ├── download.ts │ │ ├── fix-auto-play.ts │ │ └── index.ts │ ├── vote │ │ └── index.ts │ └── zhida-to-search.ts ├── config │ ├── index.ts │ └── types.ts ├── index.html ├── index.ts ├── init │ ├── init-history-view.ts │ ├── init-html │ │ ├── common-html.ts │ │ ├── configs │ │ │ ├── basic-show.ts │ │ │ ├── default-function.ts │ │ │ ├── filter.ts │ │ │ ├── high-performance.ts │ │ │ └── index.ts │ │ ├── init-html.ts │ │ └── types.ts │ ├── init-image-preview.ts │ ├── init-observer-resize.ts │ ├── init-operate.ts │ ├── init-top-event-listener.ts │ └── redirect.ts ├── misc │ └── index.ts ├── store │ └── index.ts ├── styles │ ├── blocked-users.less │ ├── button.less │ ├── change-zhihu.less │ ├── checkbox.less │ ├── common.less │ ├── dialog.less │ ├── fetch-intercept.less │ ├── form.less │ ├── index.less │ ├── radio.less │ ├── range.less │ ├── select.less │ ├── setting-background.less │ ├── setting-block-words.less │ ├── setting-default.less │ ├── switch.less │ ├── theme.less │ ├── title.less │ └── tooltip.less ├── tools │ ├── browser.ts │ ├── do-window-resize.ts │ ├── dom.ts │ ├── fetch.ts │ ├── format-data-to-hump.ts │ ├── import-file.ts │ ├── index.ts │ ├── math-for-my-listens.ts │ ├── message.ts │ ├── mouse-event-click.ts │ ├── pathname-fn.ts │ ├── percent.ts │ ├── scroll-stop-on.ts │ ├── storage.ts │ ├── throttle.ts │ └── time.ts ├── types │ ├── common.type.ts │ ├── global.d.ts │ └── zhihu │ │ ├── index.ts │ │ ├── js-initialData.type.ts │ │ ├── zhihu-answer.type.ts │ │ ├── zhihu-articles.type.ts │ │ ├── zhihu-recommend.type.ts │ │ └── zhihu.type.ts └── web-resources.ts ├── static ├── background-dark.png ├── background-light.png ├── black.png ├── blocked-user-tag-edit.png ├── blocked-user-tag-input.png ├── cancel-comment-auto-focus-after.png ├── cancel-comment-auto-focus.png ├── change-web-title.png ├── comment-image-preview.png ├── copy-link.png ├── download-video.png ├── export-content.png ├── export-home.png ├── export-to-pdf.png ├── filter-title-word.png ├── font-color.png ├── font-size.png ├── hidden.png ├── history-recommend.png ├── history-view.png ├── home.png ├── image-size.png ├── invite.png ├── item-date.png ├── item-type.png ├── just-number.png ├── not-fetch.png ├── remove-filter-tag.png ├── remove-item.png ├── remove-message.png ├── replace-zhida.png ├── safari-use.png ├── setting-background.png ├── setting-filter.png ├── setting-replace-zhida.png ├── setting-size.png ├── suspension-pickup.png ├── video-hidden.png └── video-link.png ├── tsconfig.json └── yarn.lock /.github/workflows/issue-limit.yml: -------------------------------------------------------------------------------- 1 | name: Limit Issue Creation 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | limit-issues: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check Issue Count 12 | uses: actions/github-script@v6 13 | with: 14 | script: | 15 | const username = context.payload.issue.user.login; 16 | const { data: issues } = await github.rest.issues.listForRepo({ 17 | owner: context.repo.owner, 18 | repo: context.repo.repo, 19 | creator: username, 20 | }); 21 | 22 | if (issues.length > 5) { // 设置最大数量,例如5个 23 | await github.rest.issues.createComment({ 24 | owner: context.repo.owner, 25 | repo: context.repo.repo, 26 | issue_number: context.issue.number, 27 | body: `🚫 你已经达到了最多 5 个 Issue 的限制!请先关闭一些 Issue 后再尝试。`, 28 | }); 29 | 30 | await github.rest.issues.update({ 31 | owner: context.repo.owner, 32 | repo: context.repo.repo, 33 | issue_number: context.issue.number, 34 | state: "closed", 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules* 2 | dist* 3 | target -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "code-block-style": { 3 | "style": "fenced" 4 | }, 5 | "code-fence-style": { 6 | "style": "backtick" 7 | }, 8 | "emphasis-style": { 9 | "style": "asterisk" 10 | }, 11 | "fenced-code-language": { 12 | "allowed_languages": ["bash", "html", "javascript", "json", "markdown", "text"], 13 | "language_only": true 14 | }, 15 | "heading-style": { 16 | "style": "atx" 17 | }, 18 | "hr-style": { 19 | "style": "---" 20 | }, 21 | "line-length": { 22 | "strict": true, 23 | "code_blocks": false, 24 | "line_length": 99999 25 | }, 26 | "no-duplicate-heading": { 27 | "siblings_only": true 28 | }, 29 | "ol-prefix": { 30 | "style": "ordered" 31 | }, 32 | "proper-names": { 33 | "code_blocks": false, 34 | "names": ["Cake.Markdownlint", "CommonMark", "JavaScript", "Markdown", "markdown-it", "markdownlint", "Node.js"] 35 | }, 36 | "strong-style": { 37 | "style": "asterisk" 38 | }, 39 | "ul-style": { 40 | "style": "dash" 41 | }, 42 | "no-inline-html": false 43 | } 44 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "files.trimTrailingWhitespace": true, 4 | // vscode默认启用了根据文件类型自动设置tabsize的选项 5 | "prettier.singleQuote": true, 6 | "prettier.semi": true, 7 | "search.followSymlinks": false, 8 | "emmet.triggerExpansionOnTab": true, 9 | "cSpell.words": [ 10 | "liuyubing", 11 | "Tampermonkey", 12 | "Topstory", 13 | "Violentmonkey", 14 | "Zhida", 15 | "Zhihu", 16 | "Zhuanlan" 17 | ], 18 | "prettier.printWidth": 180, 19 | "editor.codeActionsOnSave": { 20 | "source.organizeImports": "explicit" 21 | }, 22 | "diffEditor.wordWrap": "inherit", 23 | "markdown.extension.toc.slugifyMode": "github", 24 | "[markdown]": { 25 | "editor.wordWrap": "off", 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /CHANGELOG_THIS.md: -------------------------------------------------------------------------------- 1 | - 🆕 更新外置`不感兴趣`按钮逻辑,点击后:1. 调用不感兴趣接口;2. 将当前内容从页面移除;3. 将当前内容标题添加到 `屏蔽内容 - 不感兴趣的内容` 中,后续页面将不会再出现次内容 2 | - 💄 合并 `屏蔽内容`、`屏蔽词`菜单为 `屏蔽内容` 3 | - 🐞 修复从列表跳转到回答详情页面尺寸错误的情况 4 | -------------------------------------------------------------------------------- /Q&A.md: -------------------------------------------------------------------------------- 1 | # 部分问题解答 2 | 3 | #### 知乎自己的样式和内容问题就找我反馈也用处不大 😇,看心情如果有必要优化的话... 4 | 5 | - [1. 极简模式打开后如何恢复默认?](#1-极简模式打开后如何恢复默认) 6 | - [2. 拉黑按钮无效:拉黑并同步,该用户和其文章,依然能被搜索到](#2-拉黑按钮无效拉黑并同步该用户和其文章依然能被搜索到) 7 | - [3. 搜索页面、列表页面内容闪烁](#3-搜索页面列表页面内容闪烁) 8 | - [4. 问题回答页面内容闪烁,有时候浏览时候回答消失](#4-问题回答页面内容闪烁有时候浏览时候回答消失) 9 | - [5. 启用插件后知乎页面无法显示数据](#5-启用插件后知乎页面无法显示数据) 10 | - [6. 点击 `不感兴趣` 后推荐列表仍会推荐相关内容](#6-点击-不感兴趣-后推荐列表仍会推荐相关内容) 11 | - [7. GIF 图片会默认挡在屏幕正中心,点掉立刻就会重新刷出来](#7-gif-图片会默认挡在屏幕正中心点掉立刻就会重新刷出来) 12 | - [8. Safari 浏览器无法运行](#8-safari-浏览器无法运行) 13 | 14 | ### 1. 极简模式打开后如何恢复默认? 15 | 16 | 在 `基础设置 -> 配置操作 -> 恢复默认设置` 按钮,恢复到初始状态。 17 | 18 | 或在使用极简模式前点击 `导出配置` 将配置导出备份,再将配置导入(将导出的 txt 文件内容复制进入输入框再点击右侧导入按钮)即可恢复到导入的配置内容 19 | 20 | ### 2. 拉黑按钮无效:拉黑并同步,该用户和其文章,依然能被搜索到 21 | 22 | 可以搜索到拉黑用户的内容是知乎本身的问题,同步后黑名单列表里存在拉黑的用户代表已经拉黑成功了(修改器黑名单跟知乎本身黑名单是一致的,只是为了快捷操作) 23 | 24 | ### 3. 搜索页面、列表页面内容闪烁 25 | 26 | 1. 检查在「首页列表」里列表类别屏蔽一栏是否设置了屏蔽相关内容。 27 | 2. 检查在「首页列表」是否设置了低赞内容屏蔽 28 | 3. 是否过滤了屏蔽词内容 29 | 4. 以上设置在首页列表跟搜索也列表均会生效 30 | [issue >>](https://github.com/liuyubing233/zhihu-custom/issues/65) 31 | 32 | ### 4. 问题回答页面内容闪烁,有时候浏览时候回答消失 33 | 34 | 1. 检查在「回答详情」里是否设置屏蔽了知乎官方账号相关的回答 35 | 2. 「回答详情」里是否设置了详情低赞回答屏蔽 36 | 37 | ### 5. 启用插件后知乎页面无法显示数据 38 | 39 | 遇到这种情况请尝试关闭接口拦截。[issue >>](https://github.com/liuyubing233/zhihu-custom/issues/82) 40 | 41 | ![接口拦截](https://raw.githubusercontent.com/liuyubing233/zhihu-custom/refs/heads/main/static/not-fetch.png) 42 | 43 | ### 6. 点击 `不感兴趣` 后推荐列表仍会推荐相关内容 44 | 45 | 这是知乎自身的推送逻辑问题,有时候就算调用了不感兴趣接口却仍然会推荐相关内容。 46 | 47 | ### 7. GIF 图片会默认挡在屏幕正中心,点掉立刻就会重新刷出来 48 | 49 | `使用弹窗打开动图` 设置与修改图片类型的其他脚本或插件冲突,如果有使用修改图片的脚本不要打开此项设置。 50 | 51 | ### 8. Safari 浏览器无法运行 52 | 53 | safari 浏览器(苹果浏览器)用户请手动删除代码头部的 `// @grant unsafeWindow` 一行,否则无法正常运行。 54 | ![safari浏览器用户删除内容](https://raw.githubusercontent.com/liuyubing233/zhihu-custom/refs/heads/main/static/safari-use.png) 55 | -------------------------------------------------------------------------------- /build-config/auto-tag.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const shell = require('shelljs'); 4 | const os = require('os'); 5 | const { exec, echo } = shell; 6 | 7 | const VERSION_TAG = '$version'; 8 | 9 | const status = process.argv[process.argv.length - 1]; 10 | const prevVersion = process.env.npm_package_version; 11 | const [vMajor, vMinor, vRevision] = prevVersion.split('.'); 12 | const changeVersion = { 13 | fix: `${vMajor}.${vMinor}.${+vRevision + 1}`, 14 | feature: `${vMajor}.${+vMinor + 1}.0`, 15 | release: `${+vMajor + 1}.0.0`, 16 | }; 17 | 18 | if (!changeVersion[status]) { 19 | echo(`自动打标参数必须为以下其中一种: 20 | fix 问题修改&功能优化(0.0.0 ---> 0.0.1), 21 | feature 功能添加(0.0.0 ---> 0.1.0), 22 | release 大版本更新(0.0.0 ---> 1.0.0)`); 23 | return; 24 | } 25 | 26 | /** 时间格式化 */ 27 | const timeFormatter = (formatter = 'YYYY-MM-DD') => { 28 | const date = new Date(); 29 | const year = date.getFullYear(); 30 | const month = date.getMonth() + 1; 31 | const day = date.getDate(); 32 | const hour = date.getHours(); 33 | const min = date.getMinutes(); 34 | const sec = date.getSeconds(); 35 | const preArr = (num) => (String(num).length !== 2 ? '0' + String(num) : String(num)); 36 | return formatter 37 | .replace(/YYYY/g, String(year)) 38 | .replace(/MM/g, preArr(month)) 39 | .replace(/DD/g, preArr(day)) 40 | .replace(/HH/g, preArr(hour)) 41 | .replace(/mm/g, preArr(min)) 42 | .replace(/ss/g, preArr(sec)); 43 | }; 44 | 45 | const nVersion = changeVersion[status]; 46 | const regExpVersion = new RegExp(`("version":\\s*")([\\d\\.]+)(")`); 47 | const pathPackageJson = path.join(__dirname, '../package.json'); 48 | const packageJson = fs.readFileSync(pathPackageJson).toString(); 49 | fs.writeFileSync(pathPackageJson, packageJson.replace(regExpVersion, `$1${nVersion}$3`)); 50 | echo(`package.json 文件版本号修改完成。\r\n原版本号: ${prevVersion},新版本号: ${nVersion}`); 51 | 52 | /************** 更新日志修改开始 ⬇ **************/ 53 | const strChangelogThis = fs.readFileSync(path.join(__dirname, '../CHANGELOG_THIS.md')).toString(); 54 | const pathChangelogList = path.join(__dirname, './public/CHANGELOG_LIST.md'); 55 | const strChangelogMenu = fs.readFileSync(path.join(__dirname, './public/CHANGELOG_MENU.md')).toString(); 56 | const strChangelogList = fs.readFileSync(pathChangelogList).toString(); 57 | const nStrChangelogList = `## ${nVersion}` + '\n\n`' + timeFormatter() + '`\n\n' + strChangelogThis + '\n' + strChangelogList; 58 | const nStrChangelog = strChangelogMenu + '\n' + nStrChangelogList; 59 | const pathChangelog = path.join(__dirname, '../CHANGELOG.md'); 60 | fs.writeFileSync(pathChangelogList, nStrChangelogList); 61 | fs.writeFileSync(pathChangelog, nStrChangelog); 62 | echo(`CHANGELOG 内容修改完成。`); 63 | /************** 更新日志修改完成 ⬆ **************/ 64 | 65 | const pathReadme = path.join(__dirname, '../README.md'); 66 | const readmeJson = fs.readFileSync(pathReadme).toString(); 67 | fs.writeFileSync(pathReadme, readmeJson.replace(/\.\/static\//g, 'https://raw.githubusercontent.com/liuyubing233/zhihu-custom/refs/heads/main/static/')); 68 | echo(`README 内容修改完成。`); 69 | 70 | const doExec = async (commit) => { 71 | const res = exec(commit); 72 | if (res.code !== 0) { 73 | echo(`ERROR: ${commit}: ${res.code.stderr}`); 74 | return Promise.reject(); 75 | } 76 | return Promise.resolve(); 77 | }; 78 | 79 | (async function () { 80 | await doExec(os.type() == 'Windows_NT' ? 'yarn build:win' : 'yarn build'); 81 | await doExec('git add .'); 82 | await doExec(`git commit -m "docs: v${nVersion}"`); 83 | await doExec(`git push`); 84 | await doExec(`git tag v${nVersion}`); 85 | await doExec(`git push --tag`); 86 | })(); 87 | -------------------------------------------------------------------------------- /build-config/esbuild.build.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const { envEnd, envLoad } = require('./esbuild.plugins'); 3 | 4 | const env = process.env; 5 | const isDev = env.APP_ENV === 'dev'; 6 | const info = 7 | `// ==UserScript==\n` + 8 | `// @name ${env.npm_package_displayName}\n` + 9 | `// @namespace http://tampermonkey.net/\n` + 10 | `// @version ${env.npm_package_version}\n` + 11 | `// @description ${env.npm_package_description}\n` + 12 | `// @compatible edge Violentmonkey\n` + 13 | `// @compatible edge Tampermonkey\n` + 14 | `// @compatible chrome Violentmonkey\n` + 15 | `// @compatible chrome Tampermonkey\n` + 16 | `// @compatible firefox Violentmonkey\n` + 17 | `// @compatible firefox Tampermonkey\n` + 18 | `// @compatible safari Violentmonkey\n` + 19 | `// @compatible safari Tampermonkey\n` + 20 | `// @author ${env.npm_package_author_name}\n` + 21 | `// @license ${env.npm_package_license}\n` + 22 | '// @match *://*.zhihu.com/*\n' + 23 | `// @grant unsafeWindow\n` + 24 | `// @grant GM_info\n` + 25 | `// @grant GM_setValue\n` + 26 | `// @grant GM_getValue\n` + 27 | `// @grant GM.getValue\n` + 28 | `// @grant GM.setValue\n` + 29 | `// @grant GM.deleteValue\n` + 30 | `// @grant GM_registerMenuCommand\n` + 31 | `// @run-at document-start\n` + 32 | `// ==/UserScript==\n`; 33 | 34 | const options = { 35 | entryPoints: ['./src/index.ts'], 36 | outdir: 'dist', 37 | // outfile: 'index.js', 38 | bundle: true, // 是否打包 39 | format: 'iife', // 打包输出格式设置为 iife,用立即执行函数包裹 40 | minify: false, 41 | charset: 'utf8', 42 | plugins: [envLoad, envEnd], 43 | banner: { 44 | js: info, 45 | }, 46 | }; 47 | 48 | const onWatch = async () => { 49 | const context = await esbuild.context(options); 50 | await context.watch(); 51 | const res = await context.serve({ 52 | port: 5555, 53 | servedir: '.', 54 | }); 55 | console.log('成功启动,端口号: ', res.port); 56 | }; 57 | 58 | const onBuild = () => { 59 | esbuild.build(options); 60 | }; 61 | 62 | isDev ? onWatch() : onBuild(); 63 | -------------------------------------------------------------------------------- /build-config/esbuild.plugins.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const less = require('less'); 4 | const htmlMinify = require('html-minifier').minify; 5 | 6 | const envLoad = { 7 | name: 'envLoad', 8 | setup(build) { 9 | build.onLoad({ filter: /.ts/ }, async (args) => { 10 | const prevContent = fs.readFileSync(args.path).toString(); 11 | 12 | if (args.path.includes('web-resources.ts')) { 13 | // 加载 HTML 和 CSS 资源 14 | const filenameLess = path.join(__dirname, '../src/styles/index.less'); 15 | const contentLess = fs.readFileSync(filenameLess, 'utf-8'); 16 | const res = await less.render(contentLess, { compress: true, filename: filenameLess }); 17 | if (!res.css) { 18 | throw Error('less转css失败'); 19 | } 20 | const NAME_HTML = 'INNER_HTML'; 21 | const NAME_CSS = 'INNER_CSS'; 22 | const pathHTML = path.join(__dirname, '../src/index.html'); 23 | const REGEXP_REPLACE = /\s*\n\s*/g; // 删除回车及前后空格 24 | const strHTML = fs.readFileSync(pathHTML).toString(); 25 | const nRegExp = (name) => new RegExp('(' + name + '\\s\\=\\s`)()(`)'); 26 | 27 | const regexpCSS = nRegExp(NAME_CSS); 28 | const innerCSS = res.css.replace(REGEXP_REPLACE, ''); 29 | return { 30 | contents: prevContent 31 | .replace( 32 | nRegExp(NAME_HTML), 33 | `$1${htmlMinify(strHTML, { 34 | removeComments: true, 35 | collapseWhitespace: true, 36 | })}$3` 37 | ) 38 | .replace(regexpCSS, `$1${innerCSS}$3`), 39 | loader: 'ts', 40 | }; 41 | } 42 | 43 | const REGEXP_ANNOTATE_1 = /\/\*\*[\s\S]*?\*\//g; // 匹配 /** */ 格式注释 44 | const REGEXP_ANNOTATE_2 = /\s\/\/.*/g; // 匹配 // xxx 格式注释 45 | return { contents: prevContent.replace(REGEXP_ANNOTATE_1, '').replace(REGEXP_ANNOTATE_2, ''), loader: 'ts' }; 46 | }); 47 | }, 48 | }; 49 | 50 | /** 打包完成后删除路径注释并生成 index.js*/ 51 | const envEnd = { 52 | name: 'envEnd', 53 | setup(build) { 54 | build.onEnd(() => { 55 | const pathIndex = path.join(__dirname, '../dist/index.js'); 56 | const strIndexDist = fs.readFileSync(pathIndex).toString(); 57 | if (!strIndexDist) return; 58 | const REGEXP_ANNOTATE_PATH = /\s*\/\/\ssrc[^\n]*\n/g; // 匹配路径注释 59 | fs.writeFileSync(path.join(__dirname, '../index.js'), strIndexDist.replace(REGEXP_ANNOTATE_PATH, '\n')); 60 | }); 61 | }, 62 | }; 63 | 64 | module.exports = { envEnd, envLoad }; 65 | -------------------------------------------------------------------------------- /build-config/public/CHANGELOG_MENU.md: -------------------------------------------------------------------------------- 1 | | 图标 | 含义 | 2 | | ---- | ------------------------------------------------ | 3 | | 🆕 | 功能添加 | 4 | | 🐞 | 问题修复 | 5 | | 💄 | 添加或更新 UI 和样式文件 | 6 | | 👽 | **由于知乎自身接口或结构修改而进行的兼容性变化** | 7 | | 🎨 | 改进代码的结构/格式 | 8 | | ⚡ | 提高性能 | 9 | | 🗑️ | 删除无用功能、代码等 | 10 | | 📝 | 添加或更新文档 | 11 | | 🔨 | 添加或更新开发脚本 | 12 | | ♻️ | 重构代码 | 13 | | ♿️ | 优化小部分内容一以便于使用 | 14 | -------------------------------------------------------------------------------- /build-config/server-tp.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name 本地调试5555 3 | // @namespace http://tampermonkey.net/ 4 | // @version 0.0.1 5 | // @description 写入tampermonkey,本地开发使用 6 | // @author liuyubing 7 | // @match *://*.zhihu.com/* 8 | // @grant GM_xmlhttpRequest 9 | // @grant GM_info 10 | // @grant GM_setValue 11 | // @grant GM_getValue 12 | // @grant GM.getValue 13 | // @grant GM.setValue 14 | // @grant GM.deleteValue 15 | // @grant GM_registerMenuCommand 16 | // @run-at document-start 17 | // @connect 127.0.0.1 18 | // ==/UserScript== 19 | (function () { 20 | const CODE_NAME = 'CODE_FOR_CUSTOM_ZHIHU'; 21 | 22 | const innerJS = () => { 23 | return new Promise((resolve) => { 24 | GM_xmlhttpRequest({ 25 | url: 'http://127.0.0.1:5555/index.js', 26 | onload: (e) => { 27 | if (e.status === 200) { 28 | resolve(e.responseText); 29 | } 30 | }, 31 | }); 32 | }); 33 | }; 34 | 35 | async function loop() { 36 | const js = await innerJS(); 37 | if (code === js) return; 38 | window.localStorage.setItem(CODE_NAME, js); 39 | location.reload(); 40 | } 41 | 42 | setTimeout(loop, 500); 43 | 44 | const code = window.localStorage.getItem(CODE_NAME); 45 | code && eval(code); 46 | })(); 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zhihu-custom", 3 | "displayName": "知乎修改器🤜持续更新🤛努力实现功能最全的知乎配置插件", 4 | "version": "5.12.0", 5 | "description": "知乎高性能模式,页面模块自定义隐藏,列表及回答内容过滤,保存浏览历史记录,推荐页内容缓存,一键邀请,复制代码块删除版权信息,列表种类和关键词强过滤并自动调用「不感兴趣」接口,屏蔽用户回答,视频下载,设置自动收起所有长回答或自动展开所有回答,移除登录提示弹窗,设置过滤故事档案局和盐选科普回答等知乎官方账号回答,手动调节文字大小,切换主题及深色模式调整,隐藏知乎热搜,列表添加标签种类,去除广告,设置购买链接显示方式,收藏夹内容、回答、文章导出为PDF,一键移除所有屏蔽选项,外链直接打开,键盘左右切换预览图片,快捷键收起时修正定位,更多功能请在插件里体验...", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "APP_ENV=dev node ./build-config/esbuild.build.js", 9 | "build": "APP_ENV=prod node ./build-config/esbuild.build.js", 10 | "start:win": "set APP_ENV=dev&&node ./build-config/esbuild.build.js", 11 | "build:win": "set APP_ENV=prod&&node ./build-config/esbuild.build.js", 12 | "tag:fix": "yarn tagAuto fix", 13 | "tag:feature": "yarn tagAuto feature", 14 | "tag:release": "yarn tagAuto release", 15 | "tagAuto": "node ./build-config/auto-tag.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/liuyubing233/zhihu-custom.git" 20 | }, 21 | "keywords": [ 22 | "zhihu", 23 | "tampermonkey", 24 | "javascript", 25 | "typescript" 26 | ], 27 | "author": "lyb233", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/liuyubing233/zhihu-custom/issues" 31 | }, 32 | "homepage": "https://github.com/liuyubing233/zhihu-custom#readme", 33 | "devDependencies": { 34 | "@types/node": "^20.5.9", 35 | "esbuild": "^0.24.2", 36 | "html-minifier": "^4.0.0", 37 | "less": "^4.2.0", 38 | "shelljs": "^0.8.5", 39 | "typescript": "^5.2.2" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/background/create-html.ts: -------------------------------------------------------------------------------- 1 | import { CLASS_INPUT_CLICK } from '../../misc'; 2 | import { dom } from '../../tools'; 3 | import { INPUT_NAME_THEME, INPUT_NAME_THEME_DARK, INPUT_NAME_ThEME_LIGHT, THEME_CONFIG_DARK, THEME_CONFIG_LIGHT, THEMES } from './types'; 4 | 5 | /** 添加背景色选择元素 */ 6 | export const createHTMLBackgroundSetting = (domMain: HTMLElement) => { 7 | const radioBackground = (name: string, value: string | number, background: string, color: string, label: string, primary: string) => 8 | ``; 14 | 15 | const themeToRadio = (o: Record, className: string, color: string) => 16 | Object.keys(o) 17 | .map((key) => radioBackground(className, key, o[key].background, color, o[key].name, o[key].primary)) 18 | .join(''); 19 | 20 | dom('.ctz-set-background', domMain)!.innerHTML = 21 | `
${ 22 | `
主题
` + `
${THEMES.map((i) => radioBackground(INPUT_NAME_THEME, i.value, i.background, i.color, i.label, i.background)).join('')}
` 23 | }
` + 24 | `
${`
浅色主题
` + `
${themeToRadio(THEME_CONFIG_LIGHT, INPUT_NAME_ThEME_LIGHT, '#000')}
`}
` + 25 | `
${`
深色主题
` + `
${themeToRadio(THEME_CONFIG_DARK, INPUT_NAME_THEME_DARK, '#f7f9f9')}
`}
`; 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/background/dark.ts: -------------------------------------------------------------------------------- 1 | import { dom, myStorage } from '../../tools'; 2 | import { ETheme } from './types'; 3 | 4 | /** 启用知乎默认的黑暗模式 */ 5 | export const onUseThemeDark = async () => { 6 | dom('html')!.setAttribute('data-theme', (await isDark()) ? 'dark' : 'light'); 7 | }; 8 | 9 | /** 10 | * 判断当前网站是否启用深色模式还是浅色模式 11 | * 用来启用知乎默认的黑暗模式 12 | */ 13 | export const checkThemeDarkOrLight = () => { 14 | // 开始进入先修改一次 15 | onUseThemeDark(); 16 | const elementHTML = dom('html'); 17 | const muConfig = { attribute: true, attributeFilter: ['data-theme'] }; 18 | if (!elementHTML) return; 19 | // 监听 html 元素属性变化 20 | const muCallback = async function () { 21 | const themeName = elementHTML.getAttribute('data-theme'); 22 | const dark = await isDark(); 23 | if ((themeName === 'dark' && !dark) || (themeName === 'light' && dark)) { 24 | onUseThemeDark(); 25 | } 26 | }; 27 | const muObserver = new MutationObserver(muCallback); 28 | muObserver.observe(elementHTML, muConfig); 29 | }; 30 | 31 | /** 是否使用深色模式 */ 32 | export const isDark = async () => { 33 | const { theme = ETheme.自动 } = await myStorage.getConfig(); 34 | if (+theme === ETheme.自动) { 35 | // 获取浏览器颜色 36 | return window.matchMedia('(prefers-color-scheme: dark)').matches; 37 | } 38 | return +theme === ETheme.深色; 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/background/highlight.ts: -------------------------------------------------------------------------------- 1 | import { isDark } from "./dark"; 2 | import { EThemeDark, EThemeLight, THEME_CONFIG_DARK, THEME_CONFIG_LIGHT } from "./types"; 3 | 4 | /** 原创内容颜色高亮设置 */ 5 | export const doHighlightOriginal = async (backgroundHighlightOriginal = '', themeDark: EThemeDark, themeLight: EThemeLight) => 6 | 'background: ' + 7 | (backgroundHighlightOriginal 8 | ? `${backgroundHighlightOriginal}!important;` 9 | : (await isDark()) 10 | ? `${THEME_CONFIG_DARK[themeDark].background2}!important;` 11 | : +themeLight === EThemeLight.默认 12 | ? 'rgb(251,248,241)!important;' 13 | : `${THEME_CONFIG_LIGHT[themeLight].background}!important;`); 14 | -------------------------------------------------------------------------------- /src/components/background/index.ts: -------------------------------------------------------------------------------- 1 | export * from './background'; 2 | export * from './create-html'; 3 | export * from './dark'; 4 | export * from './highlight'; 5 | export * from './types'; 6 | 7 | -------------------------------------------------------------------------------- /src/components/background/types.ts: -------------------------------------------------------------------------------- 1 | /** 主题风格 */ 2 | export enum ETheme { 3 | 浅色, 4 | 深色, 5 | 自动, 6 | } 7 | 8 | /** 主题风格 - 浅色 */ 9 | export enum EThemeLight { 10 | 默认 = 0, 11 | 红 = 1, 12 | 黄 = 2, 13 | 绿 = 3, 14 | 灰 = 4, 15 | 紫 = 5, 16 | 橙 = 6, 17 | 浅橙 = 7, 18 | } 19 | 20 | /** 主题风格 - 深色 */ 21 | export enum EThemeDark { 22 | 默认 = 0, 23 | 深色一 = 1, 24 | 深色二 = 2, 25 | 深色三 = 3, 26 | 高对比度蓝 = 4, 27 | 高对比度红 = 5, 28 | 高对比度绿 = 6, 29 | 纯黑 = 7, 30 | } 31 | 32 | /** 浅色主题色配置 */ 33 | export type IThemeConfigLight = Record; 34 | 35 | /** 深色主题色配置 */ 36 | export type IThemeConfigDark = Record; 37 | 38 | export interface IThemeValue { 39 | /** 名称 */ 40 | name: string; 41 | /** 背景色 */ 42 | background: string; 43 | /** 第二背景色 */ 44 | background2: string; 45 | /** 主题色 */ 46 | primary: string; 47 | } 48 | 49 | export const THEMES = [ 50 | { label: '浅色', value: ETheme.浅色, background: '#fff', color: '#69696e' }, 51 | { label: '深色', value: ETheme.深色, background: '#000', color: '#fff' }, 52 | { label: '自动', value: ETheme.自动, background: 'linear-gradient(to right, #fff, #000)', color: '#000' }, 53 | ]; 54 | 55 | export const THEME_CONFIG_LIGHT: IThemeConfigLight = { 56 | [EThemeLight.默认]: { name: '默认', background: '#ffffff', background2: '', primary: 'rgb(0, 122, 255)' }, 57 | [EThemeLight.黄]: { name: '黄', background: '#faf9de', background2: '#fdfdf2', primary: 'rgb(160, 90, 0)' }, 58 | [EThemeLight.绿]: { name: '绿', background: '#cce8cf', background2: '#e5f1e7', primary: 'rgb(0, 125, 27)' }, 59 | [EThemeLight.灰]: { name: '灰', background: '#eaeaef', background2: '#f3f3f5', primary: 'rgb(142, 142, 147)' }, 60 | [EThemeLight.紫]: { name: '紫', background: '#e9ebfe', background2: '#f2f3fb', primary: 'rgb(175, 82, 222)' }, 61 | [EThemeLight.橙]: { name: '橙', background: '#FFD39B', background2: '#ffe4c4', primary: 'rgb(201, 52, 0)' }, 62 | [EThemeLight.浅橙]: { name: '浅橙', background: '#ffe4c4', background2: '#fff4e7', primary: 'rgb(255, 159, 10)' }, 63 | [EThemeLight.红]: { name: '红', background: '#ffd6d4', background2: '#f8ebeb', primary: 'rgb(255, 59, 48)' }, 64 | }; 65 | 66 | export const THEME_CONFIG_DARK: IThemeConfigDark = { 67 | [EThemeDark.默认]: { name: '默认', background: '#121212', background2: '#333333', primary: '#121212' }, 68 | [EThemeDark.深色一]: { name: '深色一', background: '#15202b', background2: '#38444d', primary: '#15202b' }, 69 | [EThemeDark.深色二]: { name: '深色二', background: '#1f1f1f', background2: '#303030', primary: '#1f1f1f' }, 70 | [EThemeDark.深色三]: { name: '深色三', background: '#272822', background2: '#383932', primary: '#272822' }, 71 | [EThemeDark.高对比度蓝]: { name: '高对比度蓝', background: '#1c0c59', background2: '#191970', primary: '#1c0c59' }, 72 | [EThemeDark.高对比度红]: { name: '高对比度红', background: '#570D0D', background2: '#8B0000', primary: '#570D0D' }, 73 | [EThemeDark.高对比度绿]: { name: '高对比度绿', background: '#093333', background2: '#0c403f', primary: '#093333' }, 74 | [EThemeDark.纯黑]: { name: '纯黑', background: '#202123', background2: '#000000', primary: '#121212' }, 75 | }; 76 | 77 | export const INPUT_NAME_THEME = 'theme'; 78 | export const INPUT_NAME_THEME_DARK = 'themeDark'; 79 | export const INPUT_NAME_ThEME_LIGHT = 'themeLight'; 80 | -------------------------------------------------------------------------------- /src/components/black-list/add-block-button.ts: -------------------------------------------------------------------------------- 1 | import { domC, fnReturnStr, myStorage } from '../../tools'; 2 | import { IZhihuCardContent } from '../../types/zhihu/zhihu.type'; 3 | import { CLASS_BLACK_TAG } from './create-html'; 4 | import { addBlockUser, removeBlockUser } from './do-fetch'; 5 | import { IBlockedUser } from './types'; 6 | 7 | /** class:黑名单模块盒子 */ 8 | export const CLASS_BLOCK_USER_BOX = 'ctz-block-user-box'; 9 | /** class:屏蔽用户 */ 10 | export const CLASS_BTN_ADD_BLOCKED = 'ctz-block-add-blocked'; 11 | /** class:移除屏蔽 */ 12 | export const CLASS_BTN_REMOVE_BLOCKED = 'ctz-block-remove-blocked'; 13 | 14 | /** 添加「屏蔽用户」按钮*/ 15 | export const answerAddBlockButton = async (contentItem: HTMLElement) => { 16 | const nodeUser = contentItem.querySelector('.AnswerItem-authorInfo>.AuthorInfo') as HTMLElement; 17 | if (!nodeUser || !nodeUser.offsetHeight) return; 18 | if (nodeUser.querySelector(`.${CLASS_BLOCK_USER_BOX}`)) return; 19 | const userUrl = (nodeUser.querySelector('meta[itemprop="url"]') as HTMLMetaElement).content; 20 | const userName = (nodeUser.querySelector('meta[itemprop="name"]') as HTMLMetaElement).content; 21 | const mo = contentItem.getAttribute('data-za-extra-module') || '{}' 22 | if (!JSON.parse(mo).card) return; 23 | const aContent: IZhihuCardContent = JSON.parse(mo).card.content; 24 | const userId = aContent.author_member_hash_id || ''; 25 | if (!userUrl.replace(/https:\/\/www.zhihu.com\/people\//, '')) return; 26 | 27 | const { blockedUsers = [], showBlockUserTag, showBlockUser, showBlockUserTagType } = await myStorage.getConfig(); 28 | 29 | const blockedUserInfo = blockedUsers.find((i) => i.id === userId); 30 | const nBlackBox = domC('div', { 31 | className: CLASS_BLOCK_USER_BOX, 32 | innerHTML: changeBlockedUsersBox(!!blockedUserInfo, showBlockUser, showBlockUserTag, showBlockUserTagType, blockedUserInfo), 33 | }); 34 | nBlackBox.onclick = async function (ev) { 35 | const target = ev.target as HTMLElement; 36 | const matched = userUrl.match(/(?<=people\/)[\w\W]+/); 37 | const urlToken = matched ? matched[0] : ''; 38 | const me = this as HTMLElement; 39 | // 屏蔽用户 40 | if (target.classList.contains(CLASS_BTN_ADD_BLOCKED)) { 41 | await addBlockUser({ id: userId, name: userName, urlToken }); 42 | me.innerHTML = changeBlockedUsersBox(true, showBlockUser, showBlockUserTag, showBlockUserTagType); 43 | return; 44 | } 45 | // 解除屏蔽 46 | if (target.classList.contains(CLASS_BTN_REMOVE_BLOCKED)) { 47 | await removeBlockUser({ id: userId, name: userName, urlToken }); 48 | me.innerHTML = changeBlockedUsersBox(false, showBlockUser, showBlockUserTag, showBlockUserTagType); 49 | return; 50 | } 51 | }; 52 | nodeUser.appendChild(nBlackBox); 53 | }; 54 | 55 | /** 56 | * 修改黑名单盒子 57 | * @param isBlocked 是否是黑名单用户 58 | * @param showBlock 显示屏蔽用户按钮 59 | * @param showBlockTag 显示黑名单用户标签 60 | * @param showBlockTagType 黑名单用户标签显示类型 61 | * @param userInfo 黑名单用户信息 62 | */ 63 | export const changeBlockedUsersBox = (isBlocked: boolean, showBlock?: boolean, showBlockTag?: boolean, showBlockTagType?: boolean, userInfo?: IBlockedUser) => { 64 | if (isBlocked) { 65 | return ( 66 | fnReturnStr( 67 | `黑名单${showBlockTagType && userInfo && userInfo.tags && userInfo.tags.length ? ':' + userInfo.tags.join('、') : ''}`, 68 | showBlockTag 69 | ) + fnReturnStr(``, showBlock) 70 | ); 71 | } else { 72 | return fnReturnStr(``, showBlock); 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/components/black-list/do-fetch.ts: -------------------------------------------------------------------------------- 1 | import { store } from '../../store'; 2 | import { IBlockedUser } from './types'; 3 | import { removeItemAfterBlock, updateItemAfterBlock } from './update'; 4 | 5 | /** 拉黑用户(屏蔽用户)方法 */ 6 | export const addBlockUser = (userInfo: IBlockedUser) => { 7 | const { name, urlToken } = userInfo; 8 | const message = `是否要屏蔽${name}?\n屏蔽后,对方将不能关注你、向你发私信、评论你的实名回答、使用「@」提及你、邀请你回答问题,但仍然可以查看你的公开信息。`; 9 | if (!confirm(message)) return Promise.reject(); 10 | return new Promise((resolve) => { 11 | const headers = store.getFetchHeaders(); 12 | fetch(`https://www.zhihu.com/api/v4/members/${urlToken}/actions/block`, { 13 | method: 'POST', 14 | headers: new Headers({ 15 | ...headers, 16 | 'x-xsrftoken': document.cookie.match(/(?<=_xsrf=)[\w-]+(?=;)/)![0] || '', 17 | }), 18 | credentials: 'include', 19 | }).then(async () => { 20 | await updateItemAfterBlock(userInfo); 21 | resolve(); 22 | }); 23 | }); 24 | }; 25 | 26 | /** 解除拉黑用户 */ 27 | export const removeBlockUser = (info: IBlockedUser, needConfirm = true) => { 28 | if (needConfirm) { 29 | const message = '取消屏蔽之后,对方将可以:关注你、给你发私信、向你提问、评论你的答案、邀请你回答问题。'; 30 | if (!confirm(message)) return Promise.reject(); 31 | } 32 | return new Promise((resolve) => { 33 | const { urlToken, id } = info; 34 | const headers = store.getFetchHeaders(); 35 | fetch(`https://www.zhihu.com/api/v4/members/${urlToken}/actions/block`, { 36 | method: 'DELETE', 37 | headers: new Headers({ 38 | ...headers, 39 | 'x-xsrftoken': document.cookie.match(/(?<=_xsrf=)[\w-]+(?=;)/)![0] || '', 40 | }), 41 | credentials: 'include', 42 | }).then(async () => { 43 | await removeItemAfterBlock(info); 44 | resolve(); 45 | }); 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/black-list/index.ts: -------------------------------------------------------------------------------- 1 | export * from './add-block-button'; 2 | export * from './create-html'; 3 | export * from './do-fetch'; 4 | export * from './intercept-response'; 5 | export * from './operate'; 6 | export * from './types'; 7 | export * from './update'; 8 | export * from './user-home-blocked-user'; 9 | 10 | -------------------------------------------------------------------------------- /src/components/black-list/intercept-response.ts: -------------------------------------------------------------------------------- 1 | import { store } from '../../store'; 2 | import { dom } from '../../tools'; 3 | import { IJsInitialDataUsersAnSENI } from '../../types/zhihu'; 4 | import { IBlockedUser } from './types'; 5 | import { removeItemAfterBlock, updateItemAfterBlock } from './update'; 6 | 7 | /** 拦截屏蔽用户接口 */ 8 | export const interceptResponseForBlocked = async (res: Response, opt?: RequestInit) => { 9 | if (/\/api\/v4\/members\/[^/]+\/actions\/block/.test(res.url) && res.ok) { 10 | if (dom('.ProfileHeader-contentFooter .MemberButtonGroup')) { 11 | // 个人主页中 12 | const jsInitData = store.getJsInitialData(); 13 | let userInfo: IBlockedUser | undefined = undefined; 14 | try { 15 | const currentUserInfo = jsInitData!.initialState!.entities.users; 16 | Object.entries(currentUserInfo).forEach(([key, value]) => { 17 | if ((value as IJsInitialDataUsersAnSENI).name && location.pathname.includes(key)) { 18 | const { id, name, urlToken } = value as IJsInitialDataUsersAnSENI; 19 | userInfo = { id, name, urlToken }; 20 | } 21 | }); 22 | } catch {} 23 | if (opt && userInfo) { 24 | opt.method === 'POST' && updateItemAfterBlock(userInfo); 25 | opt.method === 'DELETE' && removeItemAfterBlock(userInfo); 26 | } 27 | } 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/black-list/operate.ts: -------------------------------------------------------------------------------- 1 | import { store } from '../../store'; 2 | import { dom, domA, domById, domC, fnDomReplace, formatTime, message, myStorage } from '../../tools'; 3 | import { ID_BLOCK_LIST, initHTMLBlockedUsers } from './create-html'; 4 | import { removeBlockUser } from './do-fetch'; 5 | import { BLACK_LIST_CONFIG_NAMES, IBlockedUser, IConfigBlackList } from './types'; 6 | 7 | /** 导出黑名单部分配置 */ 8 | export const onExportBlack = async () => { 9 | const config = await myStorage.getConfig(); 10 | const configBlackList: IConfigBlackList = {}; 11 | BLACK_LIST_CONFIG_NAMES.forEach((name) => { 12 | if (typeof config[name] !== 'undefined') { 13 | configBlackList[name] = config[name] as any; 14 | } 15 | }); 16 | const dateNumber = +new Date(); 17 | const link = domC('a', { 18 | href: 'data:text/csv;charset=utf-8,\ufeff' + encodeURIComponent(JSON.stringify(configBlackList)), 19 | download: `黑名单配置-${formatTime(dateNumber, 'YYYYMMDD-HHmmss')}-${dateNumber}.txt`, 20 | }); 21 | document.body.appendChild(link); 22 | link.click(); 23 | document.body.removeChild(link); 24 | }; 25 | 26 | /** 黑名单部分配置导入 */ 27 | export const onImportBlack = async (oFREvent: ProgressEvent) => { 28 | let configBlackJson = oFREvent.target ? oFREvent.target.result : ''; 29 | if (typeof configBlackJson !== 'string') return; 30 | const configBlack = JSON.parse(configBlackJson) as IConfigBlackList; 31 | const { blockedUsers = [], blockedUsersTags = [] } = configBlack; 32 | const prevConfig = await myStorage.getConfig(); 33 | const { blockedUsers: prevBlockUsers = [], blockedUsersTags: prevBlockedUsersTags = [] } = prevConfig; 34 | // 标签合并去重 35 | const nTags = [...new Set([...prevBlockedUsersTags, ...blockedUsersTags])]; 36 | // 原黑名单列表去重后剩余的用户 37 | const prevListLess = prevBlockUsers.filter((item) => !blockedUsers.findIndex((i) => i.id === item.id)); 38 | blockedUsers.forEach((item) => { 39 | const prevUser = prevBlockUsers.find((i) => i.id === item.id); 40 | if (prevUser) { 41 | item.tags = [...new Set([...(item.tags || []), ...(prevUser.tags || [])])]; 42 | } 43 | }); 44 | // 黑名单用户合并去重 45 | let nBlackList: IBlockedUser[] = [...blockedUsers, ...prevListLess]; 46 | await myStorage.updateConfig({ 47 | ...prevConfig, 48 | ...configBlack, 49 | blockedUsers: nBlackList, 50 | blockedUsersTags: nTags, 51 | }); 52 | message('导入完成,请等待黑名单同步...'); 53 | onSyncBlackList(0); 54 | }; 55 | 56 | /** 清空黑名单列表 */ 57 | export const onSyncRemoveBlockedUsers = () => { 58 | if (!confirm('您确定要取消屏蔽所有黑名单用户吗?')) return; 59 | if (!confirm('确定清空所有屏蔽用户?')) return; 60 | 61 | const buttonSync = dom('button[name="syncBlackRemove"]') as HTMLButtonElement; 62 | if (!buttonSync.querySelector('ctz-loading')) { 63 | fnDomReplace(buttonSync, { innerHTML: '', disabled: true }); 64 | } 65 | 66 | const removeButtons = domA('.ctz-remove-block'); 67 | const len = removeButtons.length; 68 | let finishNumber = 0; 69 | 70 | if (!removeButtons.length) return; 71 | for (let i = 0; i < len; i++) { 72 | const item = removeButtons[i] as HTMLElement; 73 | const itemParent = item.parentElement!; 74 | const info = itemParent.dataset.info ? JSON.parse(itemParent.dataset.info) : {}; 75 | if (info.id) { 76 | removeBlockUser(info, false).then(async () => { 77 | finishNumber++; 78 | itemParent.remove(); 79 | if (finishNumber === len) { 80 | fnDomReplace(buttonSync, { innerHTML: '清空黑名单列表', disabled: false }); 81 | await myStorage.updateConfigItem('blockedUsers', []); 82 | initHTMLBlockedUsers(document.body); 83 | } 84 | }); 85 | } 86 | } 87 | }; 88 | 89 | /** 同步黑名单 */ 90 | export function onSyncBlackList(offset = 0, l: IBlockedUser[] = []) { 91 | const nodeList = domById(ID_BLOCK_LIST); 92 | if (!l.length && nodeList) { 93 | nodeList.innerHTML = '黑名单列表加载中...'; 94 | } 95 | 96 | const buttonSync = dom('button[name="syncBlack"]') as HTMLButtonElement; 97 | if (!buttonSync.querySelector('ctz-loading')) { 98 | fnDomReplace(buttonSync, { innerHTML: '', disabled: true }); 99 | } 100 | 101 | const limit = 20; 102 | const headers = store.getFetchHeaders(); 103 | fetch(`https://www.zhihu.com/api/v3/settings/blocked_users?offset=${offset}&limit=${limit}`, { 104 | method: 'GET', 105 | headers: new Headers(headers), 106 | credentials: 'include', 107 | }) 108 | .then((response) => response.json()) 109 | .then(async ({ data, paging }: { data: any[]; paging: any }) => { 110 | const prevConfig = await myStorage.getConfig(); 111 | const { blockedUsers = [] } = prevConfig; 112 | 113 | data.forEach(({ id, name, url_token }) => { 114 | const findItem = blockedUsers.find((i) => i.id === id); 115 | l.push({ id, name, urlToken: url_token, tags: (findItem && findItem.tags) || [] }); 116 | }); 117 | if (!paging.is_end) { 118 | onSyncBlackList(offset + limit, l); 119 | if (nodeList) { 120 | nodeList.innerHTML = `黑名单列表加载中(${l.length} / ${paging.totals})...`; 121 | } 122 | } else { 123 | await myStorage.updateConfigItem('blockedUsers', l); 124 | initHTMLBlockedUsers(document.body); 125 | fnDomReplace(buttonSync, { innerHTML: '同步黑名单', disabled: false }); 126 | message('黑名单列表同步完成'); 127 | } 128 | }); 129 | } 130 | -------------------------------------------------------------------------------- /src/components/black-list/types.ts: -------------------------------------------------------------------------------- 1 | import { ICommonContent } from '../../init/init-html/types'; 2 | 3 | /** 黑名单 */ 4 | export interface IBlockedUser { 5 | id: string; 6 | name: string; 7 | urlToken: string; 8 | tags?: string[]; // 标签 9 | } 10 | 11 | export const BLOCKED_USER_COMMON: ICommonContent[][] = [ 12 | [ 13 | { label: '列表和回答 - 「屏蔽用户」按钮', value: 'showBlockUser' }, 14 | { label: '用户主页 - 置顶「屏蔽用户」按钮', value: 'userHomeTopBlockUser' }, 15 | { label: '评论区 - 「屏蔽用户」按钮', value: 'showBlockUserComment' }, 16 | { label: '屏蔽黑名单用户发布的内容(问题、回答、文章)', value: 'removeBlockUserContent' }, 17 | { label: '屏蔽黑名单用户发布的评论', value: 'removeBlockUserComment' }, 18 | { label: '列表和回答 - 黑名单用户标识
黑名单
', value: 'showBlockUserTag' }, 19 | { label: '评论区 - 黑名单用户标识
黑名单
', value: 'showBlockUserCommentTag' }, 20 | { label: '黑名单用户标识显示标签分类
黑名单:xx
', value: 'showBlockUserTagType' }, 21 | ], 22 | ]; 23 | 24 | /** 黑名单部分配置类型 */ 25 | export interface IConfigBlackList { 26 | /** 列表用户名后显示「屏蔽用户」按钮 */ 27 | showBlockUser?: boolean; 28 | /** 用户主页置顶「屏蔽用户」按钮 */ 29 | userHomeTopBlockUser?: boolean; 30 | /** 评论用户名后显示"屏蔽用户"按钮 */ 31 | showBlockUserComment?: boolean; 32 | /** 屏蔽黑名单用户发布的评论 */ 33 | removeBlockUserComment?: boolean; 34 | /** 黑名单用户发布的评论显示黑名单标识 */ 35 | showBlockUserCommentTag?: boolean; 36 | /** 列表和回答显示黑名单用户标识 */ 37 | showBlockUserTag?: boolean; 38 | /** 黑名单用户标识也显示标签分类 */ 39 | showBlockUserTagType?: boolean; 40 | /** 屏蔽用户后弹出标签选择 */ 41 | openTagChooseAfterBlockedUser?: boolean; 42 | /** 屏蔽不再显示黑名单用户发布的内容 */ 43 | removeBlockUserContent?: boolean; 44 | /** 黑名单列表 */ 45 | blockedUsers?: IBlockedUser[]; 46 | /** 黑名单标签 */ 47 | blockedUsersTags?: string[]; 48 | } 49 | 50 | export const BLACK_LIST_CONFIG_NAMES: (keyof IConfigBlackList)[] = [ 51 | 'showBlockUser', 52 | 'userHomeTopBlockUser', 53 | 'showBlockUserComment', 54 | 'removeBlockUserComment', 55 | 'showBlockUserCommentTag', 56 | 'showBlockUserTag', 57 | 'showBlockUserTagType', 58 | 'openTagChooseAfterBlockedUser', 59 | 'removeBlockUserContent', 60 | 'blockedUsers', 61 | 'blockedUsersTags', 62 | ]; 63 | -------------------------------------------------------------------------------- /src/components/black-list/update.ts: -------------------------------------------------------------------------------- 1 | import { dom, domById, domC, myStorage } from '../../tools'; 2 | import { blackItemContent, chooseBlockedUserTags, ID_BLOCK_LIST } from './create-html'; 3 | import { IBlockedUser } from './types'; 4 | 5 | /** 屏蔽用户后修改器上的操作 */ 6 | export const updateItemAfterBlock = async (userInfo: IBlockedUser) => { 7 | const { blockedUsers = [], openTagChooseAfterBlockedUser } = await myStorage.getConfig(); 8 | blockedUsers.unshift(userInfo); 9 | await myStorage.updateConfigItem('blockedUsers', blockedUsers); 10 | const nodeUserItem = domC('div', { 11 | className: `ctz-black-item ctz-black-id-${userInfo.id}`, 12 | innerHTML: blackItemContent(userInfo), 13 | }); 14 | nodeUserItem.dataset.info = JSON.stringify(userInfo); 15 | const nodeUsers = domById(ID_BLOCK_LIST)!; 16 | nodeUsers.insertBefore(nodeUserItem, nodeUsers.children[0]); 17 | if (openTagChooseAfterBlockedUser) { 18 | chooseBlockedUserTags(nodeUserItem, false); 19 | dom('#CTZ_BLOCKED_NUMBER', document.body)!.innerText = blockedUsers.length ? `黑名单数量:${blockedUsers.length}` : ''; 20 | } 21 | }; 22 | 23 | /** 解除屏蔽后修改器上的操作 */ 24 | export const removeItemAfterBlock = async (userInfo: IBlockedUser) => { 25 | const { blockedUsers = [] } = await myStorage.getConfig(); 26 | const itemIndex = blockedUsers.findIndex((i) => i.id === userInfo.id); 27 | if (itemIndex >= 0) { 28 | blockedUsers.splice(itemIndex, 1); 29 | const removeItem = dom(`.ctz-black-id-${userInfo.id}`); 30 | removeItem && removeItem.remove(); 31 | myStorage.updateConfigItem('blockedUsers', blockedUsers); 32 | } 33 | dom('#CTZ_BLOCKED_NUMBER', document.body)!.innerText = blockedUsers.length ? `黑名单数量:${blockedUsers.length}` : ''; 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/black-list/user-home-blocked-user.ts: -------------------------------------------------------------------------------- 1 | import { dom, domById, domC, myStorage } from '../../tools'; 2 | 3 | const CLASS_TOP_BLOCK = 'ctz-top-block-in-user-home'; 4 | let blockObserver: MutationObserver | undefined; 5 | 6 | let index = 0; 7 | /** 用户主页置顶「屏蔽用户」按钮 */ 8 | export const topBlockUser = async () => { 9 | const { userHomeTopBlockUser } = await myStorage.getConfig(); 10 | const nodeUserHeaderOperate = dom('.ProfileHeader-contentFooter .MemberButtonGroup'); 11 | const nodeFooterOperations = dom('.Profile-footerOperations'); 12 | if (!nodeUserHeaderOperate || !userHomeTopBlockUser || !nodeFooterOperations) return; 13 | const isMe = nodeUserHeaderOperate.innerText.includes('编辑个人资料'); 14 | if (isMe) return; 15 | 16 | const domProfileHeader = domById('ProfileHeader'); 17 | if (!domProfileHeader || !domProfileHeader.dataset.zaExtraModule) { 18 | // 解决用户主页重置的情况 19 | setTimeout(topBlockUser, 500); 20 | return; 21 | } 22 | 23 | /** 是否是已经屏蔽的用户 */ 24 | const isBlocked = nodeUserHeaderOperate.innerText.includes('已屏蔽'); 25 | const domFind = dom(`.${CLASS_TOP_BLOCK}`); 26 | domFind && domFind.remove(); 27 | const nDomButton = domC('button', { 28 | className: `Button Button--primary Button--red ${CLASS_TOP_BLOCK}`, 29 | innerText: isBlocked ? '解除屏蔽' : '屏蔽用户', 30 | }); 31 | const domUnblock = nodeUserHeaderOperate.firstChild as HTMLButtonElement; 32 | const domBlock = nodeFooterOperations.firstChild as HTMLButtonElement; 33 | nDomButton.onclick = function () { 34 | if (isBlocked) { 35 | // 解除屏蔽 36 | domUnblock.click(); 37 | } else { 38 | domBlock.click(); 39 | } 40 | }; 41 | nodeUserHeaderOperate.insertBefore(nDomButton, domUnblock); 42 | blockObserver && blockObserver.disconnect(); 43 | blockObserver = new MutationObserver(() => { 44 | topBlockUser(); 45 | }); 46 | blockObserver.observe(nodeFooterOperations, { 47 | attributes: false, 48 | childList: true, 49 | characterData: false, 50 | characterDataOldValue: false, 51 | subtree: true, 52 | }); 53 | 54 | if (index === 0) { 55 | index++ 56 | setTimeout(topBlockUser, 1000); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/blocked-words.ts: -------------------------------------------------------------------------------- 1 | import { dom, domC, message, myStorage } from "../tools"; 2 | 3 | interface IFindDomName { 4 | /** 列表标题屏蔽词 */ 5 | filterKeywords: string; 6 | /** 回答内容屏蔽词 */ 7 | blockWordsAnswer: string; 8 | } 9 | type IKeyofDomName = keyof IFindDomName; 10 | 11 | /** 屏蔽词 ID */ 12 | const BLOCK_WORDS_LIST = `#CTZ_FILTER_BLOCK_WORDS .ctz-block-words-content`; 13 | const BLOCK_WORDS_ANSWER = `#CTZ_FILTER_BLOCK_WORDS_CONTENT .ctz-block-words-content`; 14 | 15 | const NAME_BY_KEY: IFindDomName = { 16 | filterKeywords: BLOCK_WORDS_LIST, 17 | blockWordsAnswer: BLOCK_WORDS_ANSWER, 18 | }; 19 | 20 | const onRemove = async (e: MouseEvent, key: IKeyofDomName) => { 21 | const domItem = e.target as HTMLElement; 22 | if (!domItem.classList.contains('ctz-filter-word-remove')) return; 23 | const title = domItem.innerText; 24 | const config = await myStorage.getConfig(); 25 | domItem.remove(); 26 | myStorage.updateConfigItem( 27 | key, 28 | (config[key] || []).filter((i: string) => i !== title) 29 | ); 30 | }; 31 | 32 | const onAddWord = async (target: HTMLInputElement, key: IKeyofDomName) => { 33 | const word = target.value; 34 | const configChoose = (await myStorage.getConfig())[key]; 35 | if (!Array.isArray(configChoose)) return; 36 | if (configChoose.includes(word)) { 37 | message('屏蔽词已存在'); 38 | return; 39 | } 40 | configChoose.push(word); 41 | await myStorage.updateConfigItem(key, configChoose); 42 | const domItem = domC('span', { innerText: word }); 43 | domItem.classList.add('ctz-filter-word-remove'); 44 | const nodeFilterWords = dom(NAME_BY_KEY[key]); 45 | nodeFilterWords && nodeFilterWords.appendChild(domItem); 46 | target.value = ''; 47 | }; 48 | 49 | /** 初始化屏蔽词 */ 50 | export const initBlockedWords = async () => { 51 | const config = await myStorage.getConfig(); 52 | const arr = [ 53 | { domFind: dom(BLOCK_WORDS_LIST), name: 'filterKeywords', domInput: dom('[name="inputBlockedWord"]') }, 54 | { domFind: dom(BLOCK_WORDS_ANSWER), name: 'blockWordsAnswer', domInput: dom('[name="inputBlockedWordAnswer"]') }, 55 | ]; 56 | for (let i = 0, len = arr.length; i < len; i++) { 57 | const { domFind, name, domInput } = arr[i]; 58 | if (domFind) { 59 | const children = (config[name] || []).map((i: string) => `${i}`).join(''); 60 | domFind.innerHTML = children || ''; 61 | domFind.onclick = (e) => onRemove(e, name as IKeyofDomName); 62 | } 63 | domInput && (domInput.onchange = (e) => onAddWord(e.target as HTMLInputElement, name as IKeyofDomName)); 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/copy/index.ts: -------------------------------------------------------------------------------- 1 | import { domA, domC, message } from '../../tools'; 2 | 3 | const CLASS_CAN_COPY = 'ctz-can-copy'; 4 | 5 | /** 解除禁止转载的限制 */ 6 | export const canCopy = () => { 7 | domA(`.RichContent-inner:not(.${CLASS_CAN_COPY})`).forEach((item) => { 8 | item.classList.add(CLASS_CAN_COPY); 9 | item.oncopy = (event) => { 10 | eventCopy(event); 11 | message('已复制内容,若有禁止转载提示可无视'); 12 | return true; 13 | }; 14 | }); 15 | }; 16 | 17 | export const eventCopy = (event: ClipboardEvent) => { 18 | let clipboardData = event.clipboardData; 19 | if (!clipboardData) return; 20 | const selection = window.getSelection(); 21 | let text = selection ? selection.toString() : ''; 22 | if (text) { 23 | event.preventDefault(); 24 | clipboardData.setData('text/plain', text); 25 | } 26 | }; 27 | 28 | /** 复制文本 */ 29 | export const copy = async (value: string) => { 30 | if (navigator.clipboard && navigator.permissions) { 31 | await navigator.clipboard.writeText(value); 32 | } else { 33 | const domTextarea = domC('textArea', { 34 | value, 35 | style: 'width: 0px;position: fixed;left: -999px;top: 10px;', 36 | }) as HTMLInputElement; 37 | domTextarea.setAttribute('readonly', 'readonly'); 38 | document.body.appendChild(domTextarea); 39 | domTextarea.select(); 40 | document.execCommand('copy'); 41 | document.body.removeChild(domTextarea); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/ctz-dialog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './menu'; 2 | export * from './open'; 3 | 4 | -------------------------------------------------------------------------------- /src/components/ctz-dialog/menu.ts: -------------------------------------------------------------------------------- 1 | /** 页面操作 */ 2 | import { dom, domA } from '../../tools'; 3 | 4 | /** 菜单初始化 */ 5 | export const initMenu = (domMain: HTMLElement) => { 6 | const { hash } = location; 7 | const arrayHash = [...domA('#CTZ_DIALOG_MENU>div', domMain)].map((i: HTMLElement) => i.dataset.href || ''); 8 | const chooseId = arrayHash.find((i) => i === hash || hash.replace(i, '') !== hash); 9 | fnChangeMenu(dom(`#CTZ_DIALOG_MENU>div[data-href="${chooseId || arrayHash[0]}"]`, domMain) as HTMLElement, domMain); 10 | }; 11 | 12 | export const onChangeMenu = (event: MouseEvent) => { 13 | const target = event.target as HTMLElement; 14 | const dataHref = target.dataset.href || ''; 15 | if (dataHref) { 16 | location.hash = dataHref; 17 | fnChangeMenu(target, document.body); 18 | return; 19 | } 20 | }; 21 | 22 | const fnChangeMenu = (target: HTMLElement, domMain: HTMLElement) => { 23 | const currentHref = target.dataset.href || ''; 24 | const chooseId = currentHref.replace(/#/, ''); 25 | if (!chooseId) return; 26 | domA('#CTZ_DIALOG_MENU>div', domMain).forEach((item) => item.classList.remove('target')); 27 | domA('#CTZ_DIALOG_MAIN>div', domMain).forEach((item) => (item.style.display = chooseId === item.id ? 'block' : 'none')); 28 | domA('.ctz-right-title-content>div', domMain).forEach((item) => (item.style.display = currentHref === item.dataset.id ? 'block' : 'none')); 29 | target.classList.add('target'); 30 | }; 31 | 32 | /** 33 | * 通过初始创建标题,切换路由通过显示隐藏的方式显示 34 | * innerHTML 等设置内容的方式需要时间长会存在卡顿问题(估计是原网页冗余内容太多,导致数据泄露,时间长了之后会卡顿) 35 | */ 36 | export const createHTMLRightTitle = (domMain: HTMLElement = document.body) => { 37 | const { hash } = location; 38 | const arr = [...dom('#CTZ_DIALOG_MENU', domMain)!.childNodes].map((item) => { 39 | const itemDom = item as HTMLElement; 40 | return { 41 | name: itemDom.textContent, 42 | commit: itemDom.dataset.commit || '', 43 | href: itemDom.dataset.href || '', 44 | }; 45 | }); 46 | dom('.ctz-right-title-content', domMain)!.innerHTML = arr 47 | .map( 48 | ({ name, commit, href }, index) => `
${name}${commit}
` 49 | ) 50 | .join(''); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/ctz-dialog/open.ts: -------------------------------------------------------------------------------- 1 | import { ID_EXTRA_DIALOG } from '../../misc'; 2 | import { dom, domById, myScroll } from '../../tools'; 3 | import { echoData } from '../echo-data'; 4 | import { echoHistory } from '../history'; 5 | 6 | /** 修改器弹窗打开关闭 */ 7 | export const openChange = () => { 8 | const nodeButton = domById('CTZ_OPEN_CLOSE')!; 9 | if (nodeButton.dataset.close === '1') { 10 | // 开启 11 | echoData(); 12 | echoHistory(); 13 | domById('CTZ_DIALOG')!.style.display = 'flex'; 14 | nodeButton.dataset.close = '0'; 15 | myScroll.stop(); 16 | } else { 17 | // 关闭 18 | const nodeDialog = domById('CTZ_DIALOG')!; 19 | nodeDialog.style.display = 'none'; 20 | nodeDialog.style.height = ''; 21 | dom(`button[name="dialogBig"]`)!.innerText = '⇵'; 22 | nodeButton.dataset.close = '1'; 23 | myScroll.on(); 24 | } 25 | }; 26 | 27 | /** 打开额外的弹窗 */ 28 | export const openExtra = (type: string, needCover = true) => { 29 | const extra = domById(ID_EXTRA_DIALOG)!; 30 | const extraCover = domById('CTZ_EXTRA_OUTPUT_COVER')!; 31 | const elementsTypes = extra.children; 32 | for (let i = 0, len = elementsTypes.length; i < len; i++) { 33 | const item = elementsTypes[i] as HTMLElement; 34 | item.style.display = item.dataset.type === type ? 'block' : 'none'; 35 | } 36 | extra.style.display = 'block'; 37 | needCover && (extraCover.style.display = 'block'); 38 | extra.dataset.status = 'open'; 39 | }; 40 | 41 | /** 关闭额外的弹窗 */ 42 | export const closeExtra = () => { 43 | const extra = domById(ID_EXTRA_DIALOG)!; 44 | extra.dataset.status = 'close'; 45 | extra.style.display = 'none'; 46 | domById('CTZ_EXTRA_OUTPUT_COVER')!.style.display = 'none'; 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/ctz-type-operate.ts: -------------------------------------------------------------------------------- 1 | import { dom } from '../tools'; 2 | 3 | /** 路径上存在 ctzType的操作, 一键设置 */ 4 | export const myCtzTypeOperation = { 5 | init: function () { 6 | const params = new URLSearchParams(location.search); 7 | let ctzType = params.get('ctzType') as '1' | '2' | '3'; 8 | this[ctzType] && this[ctzType](); 9 | }, 10 | /** 移除、关注问题并关闭网页 */ 11 | '1': function () { 12 | const domQuestion = dom('.QuestionPage'); 13 | if (domQuestion && domQuestion.getAttribute('data-za-extra-module')) { 14 | this.clickAndClose('.QuestionButtonGroup button'); 15 | } else { 16 | setTimeout(() => { 17 | this['1'](); 18 | }, 500); 19 | } 20 | }, 21 | /** 移除、关注话题并关闭网页 */ 22 | '2': function () { 23 | this.clickAndClose('.TopicActions .FollowButton'); 24 | }, 25 | /** 移除、关注收藏夹并关闭网页 */ 26 | '3': function () { 27 | const domQuestion = dom('.CollectionsDetailPage'); 28 | if (domQuestion && domQuestion.getAttribute('data-za-extra-module')) { 29 | this.clickAndClose('.CollectionDetailPageHeader-actions .FollowButton'); 30 | } else { 31 | setTimeout(() => { 32 | this['3'](); 33 | }, 500); 34 | } 35 | }, 36 | clickAndClose: function (eventname: string) { 37 | const nodeItem = dom(eventname); 38 | if (nodeItem) { 39 | nodeItem.click(); 40 | setTimeout(() => { 41 | window.close(); 42 | }, 300); 43 | } 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/custom-style.ts: -------------------------------------------------------------------------------- 1 | import { dom, fnAppendStyle, myStorage } from '../tools'; 2 | 3 | /** 自定义样式方法 */ 4 | export const myCustomStyle = { 5 | init: async function () { 6 | const { customizeCss = '' } = await myStorage.getConfig(); 7 | (dom('[name="textStyleCustom"]') as HTMLTextAreaElement).value = customizeCss; 8 | this.change(customizeCss); 9 | }, 10 | change: (innerCus: string) => fnAppendStyle('CTZ_STYLE_CUSTOM', innerCus), 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/echo-data.ts: -------------------------------------------------------------------------------- 1 | import { CLASS_INPUT_CHANGE, CLASS_INPUT_CLICK } from '../misc'; 2 | import { dom, domA, domById, myStorage } from '../tools'; 3 | import { echoBlockedContent } from './black-list'; 4 | import { echoMySelect } from './select'; 5 | import { VERSION_RANGE_HAVE_PERCENT } from './size'; 6 | 7 | /** 回填数据,供每次打开使用 */ 8 | export const echoData = async () => { 9 | const config = await myStorage.getConfig(); 10 | const textSameName: Record = { 11 | globalTitle: (e: HTMLInputElement) => (e.value = config.globalTitle || document.title), 12 | customizeCss: (e: HTMLInputElement) => (e.value = config.customizeCss || ''), 13 | }; 14 | const echoText = (even: HTMLInputElement) => (textSameName[even.name] ? textSameName[even.name](even) : (even.value = config[even.name] || '')); 15 | const echo: Record = { 16 | radio: (even: HTMLInputElement) => config.hasOwnProperty(even.name) && String(even.value) === String(config[even.name]) && (even.checked = true), 17 | checkbox: (even: HTMLInputElement) => (even.checked = config[even.name] || false), 18 | text: echoText, 19 | number: echoText, 20 | range: (even: HTMLInputElement) => { 21 | const nValue = config[even.name]; 22 | const nodeRange = dom(`[name="${even.name}"]`) as HTMLInputElement; 23 | const min = nodeRange && nodeRange.min; 24 | const rangeNum = isNaN(+nValue) || !(+nValue > 0) ? min : nValue; 25 | even.value = rangeNum; 26 | const nodeNewOne = domById(even.name); 27 | nodeNewOne && (nodeNewOne.innerText = rangeNum); 28 | }, 29 | }; 30 | const doEcho = (item: HTMLInputElement) => { 31 | echo[item.type] && echo[item.type](item); 32 | }; 33 | const nodeArrInputClick = domA(`.${CLASS_INPUT_CLICK}`); 34 | for (let i = 0, len = nodeArrInputClick.length; i < len; i++) { 35 | doEcho(nodeArrInputClick[i] as HTMLInputElement); 36 | } 37 | const nodeArrInputChange = domA(`.${CLASS_INPUT_CHANGE}`); 38 | for (let i = 0, len = nodeArrInputChange.length; i < len; i++) { 39 | doEcho(nodeArrInputChange[i] as HTMLInputElement); 40 | } 41 | 42 | echo.text(dom('[name="globalTitle"]')); 43 | 44 | VERSION_RANGE_HAVE_PERCENT.forEach((item) => { 45 | const isPercent = config[`${item.value}IsPercent`]; 46 | const domRange = dom(`.ctz-range-${item.value}`); 47 | const domRangePercent = dom(`.ctz-range-${item.value}Percent`); 48 | if (domRange && domRangePercent) { 49 | domRange.style.display = isPercent ? 'none' : 'flex'; 50 | domRangePercent.style.display = !isPercent ? 'none' : 'flex'; 51 | } 52 | }); 53 | 54 | echoMySelect(); 55 | // 回填(渲染)黑名单内容应在 echoData 中设置,保证每次打开弹窗都是最新内容 56 | echoBlockedContent(document.body); 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/fetch-intercept-status-change.ts: -------------------------------------------------------------------------------- 1 | import { dom, domA, myStorage } from '../tools'; 2 | 3 | /** 接口拦截开启关闭 */ 4 | export const initFetchInterceptStatus = async (domMain: HTMLElement) => { 5 | const { fetchInterceptStatus } = await myStorage.getConfig(); 6 | dom('#CTZ_FETCH_STATUS', domMain)!.innerHTML = fetchInterceptStatus 7 | ? '已开启接口拦截,若页面无法显示数据请尝试关闭' 8 | : '已关闭接口拦截,部分功能不可用'; 9 | if (!fetchInterceptStatus) { 10 | domA('.ctz-fetch-intercept', domMain).forEach((item) => { 11 | item.classList.add('ctz-fetch-intercept-close'); 12 | item.querySelectorAll('input').forEach((it) => { 13 | it.disabled = true; 14 | }); 15 | item.querySelectorAll('button').forEach((it) => { 16 | it.disabled = true; 17 | }); 18 | }); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/fn-changer.ts: -------------------------------------------------------------------------------- 1 | import { initImagePreview } from '../init/init-image-preview'; 2 | import { dom, domById, myStorage } from '../tools'; 3 | import { INPUT_NAME_THEME, INPUT_NAME_THEME_DARK, INPUT_NAME_ThEME_LIGHT, myBackground, onUseThemeDark } from './background'; 4 | import { appendHiddenStyle } from './hidden'; 5 | import { previewGIF } from './image'; 6 | import { myListenAnswer } from './listen-answer'; 7 | import { myListenList } from './listen-list'; 8 | import { changeICO, changeTitle } from './page-title'; 9 | import { mySize } from './size'; 10 | import { changeSuspensionTab, suspensionHeader, suspensionPickupAttribute } from './suspension'; 11 | import { addArticleTime, addQuestionTime } from './time'; 12 | import { myListenUserHomeList } from './user-home'; 13 | import { changeVideoStyle } from './video'; 14 | 15 | /** 更改编辑器方法 */ 16 | export const fnChanger = async (ev: HTMLInputElement) => { 17 | // onchange 时只调用 mySize 的 name 18 | const doCssVersion = [ 19 | 'questionTitleTag', 20 | 'fixedListItemMore', 21 | 'linkShopping', 22 | 'highlightListItem', 23 | 'zoomImageSize', 24 | 'zoomImageHeight', 25 | 'zoomImageHeightSize', 26 | 'versionHome', 27 | 'versionAnswer', 28 | 'versionArticle', 29 | 'versionHomePercent', 30 | 'versionAnswerPercent', 31 | 'versionArticlePercent', 32 | 'versionUserHome', 33 | 'versionUserHomePercent', 34 | 'versionCollection', 35 | 'versionCollectionPercent', 36 | 'fontSizeForListTitle', 37 | 'fontSizeForAnswerTitle', 38 | 'fontSizeForArticleTitle', 39 | 'fontSizeForList', 40 | 'fontSizeForAnswer', 41 | 'fontSizeForArticle', 42 | 'contentLineHeight', 43 | 'zoomListVideoType', 44 | 'zoomListVideoSize', 45 | 'commitModalSizeSameVersion', 46 | ]; 47 | const { name, value, checked, type } = ev; 48 | const changeBackground = () => { 49 | mySize.change(); 50 | myBackground.init(); 51 | myListenList.restart(); 52 | onUseThemeDark(); 53 | }; 54 | 55 | const rangeChoosePercent = () => { 56 | const rangeName = name.replace('IsPercent', ''); 57 | const rangeNamePercent = `${rangeName}Percent`; 58 | const domRange = dom(`.ctz-range-${rangeName}`); 59 | const domRangePercent = dom(`.ctz-range-${rangeNamePercent}`); 60 | if (domRange && domRangePercent) { 61 | domRange.style.display = checked ? 'none' : 'flex'; 62 | domRangePercent.style.display = !checked ? 'none' : 'flex'; 63 | } 64 | mySize.change(); 65 | }; 66 | 67 | const ob: Record = { 68 | [INPUT_NAME_THEME]: changeBackground, 69 | [INPUT_NAME_ThEME_LIGHT]: changeBackground, 70 | [INPUT_NAME_THEME_DARK]: changeBackground, 71 | colorText1: changeBackground, 72 | backgroundHighlightOriginal: changeBackground, 73 | suspensionHomeTab: () => { 74 | mySize.change(); 75 | changeSuspensionTab(); 76 | }, 77 | suspensionFind: () => suspensionHeader('suspensionFind'), 78 | suspensionSearch: () => suspensionHeader('suspensionSearch'), 79 | suspensionUser: () => suspensionHeader('suspensionUser'), 80 | titleIco: changeICO, 81 | showGIFinDialog: previewGIF, 82 | questionCreatedAndModifiedTime: addQuestionTime, 83 | highlightOriginal: () => myListenList.restart(), 84 | listOutPutNotInterested: () => myListenList.restart(), 85 | articleCreateTimeToTop: addArticleTime, 86 | versionHomeIsPercent: rangeChoosePercent, 87 | versionAnswerIsPercent: rangeChoosePercent, 88 | versionArticleIsPercent: rangeChoosePercent, 89 | versionUserHomeIsPercent: rangeChoosePercent, 90 | versionCollectionIsPercent: rangeChoosePercent, 91 | zoomImageType: () => { 92 | mySize.change(); 93 | initImagePreview(); 94 | }, 95 | globalTitleRemoveMessage: changeTitle, 96 | suspensionPickUp: suspensionPickupAttribute, 97 | suspensionPickupRight: suspensionPickupAttribute, 98 | videoInAnswerArticle: () => { 99 | changeVideoStyle(); 100 | myListenList.restart(); 101 | myListenAnswer.restart(); 102 | }, 103 | homeContentOpen: () => { 104 | myListenUserHomeList.restart(); 105 | }, 106 | topVote: () => { 107 | appendHiddenStyle() 108 | } 109 | }; 110 | 111 | if (name === 'fetchInterceptStatus') { 112 | if ( 113 | confirm( 114 | !checked 115 | ? '关闭接口拦截,确认后将刷新页面。\n「黑名单设置;外置不感兴趣;快速屏蔽用户;回答、文章和收藏夹导出」功能将不可用。' 116 | : '开启接口拦截,确认后将刷新页面。\n如遇到知乎页面无法显示数据的情况请尝试关闭接口拦截。' 117 | ) 118 | ) { 119 | myStorage.updateConfigItem('fetchInterceptStatus', checked); 120 | window.location.reload(); 121 | } else { 122 | ev.checked = !checked; 123 | } 124 | return; 125 | } 126 | 127 | await myStorage.updateConfigItem(name, type === 'checkbox' ? checked : value); 128 | 129 | if (type === 'range') { 130 | const nodeName = domById(name); 131 | nodeName && (nodeName.innerText = value); 132 | } 133 | 134 | if (/^hidden/.test(name)) { 135 | appendHiddenStyle(); 136 | return; 137 | } 138 | if (doCssVersion.includes(name)) { 139 | mySize.change(); 140 | return; 141 | } 142 | ob[name] && ob[name](); 143 | }; 144 | -------------------------------------------------------------------------------- /src/components/follow-remove.ts: -------------------------------------------------------------------------------- 1 | import { dom, domA, domC, domP, pathnameHasFn } from '../tools'; 2 | 3 | /** 关注的内容一键移除 */ 4 | export const myFollowRemove = { 5 | init: function () { 6 | clearTimeout(this.timer); 7 | this.timer = setTimeout(() => { 8 | pathnameHasFn({ 9 | questions: () => this.addButtons(this.classOb.questions), 10 | // topics: () => this.addButtons(this.classOb.topics), // 话题跳转页面内会重定向,暂时隐藏 11 | collections: () => this.addButtons(this.classOb.collections), 12 | }); 13 | }, 500); 14 | }, 15 | addButtons: function (initTypeOb: IClassObEntries) { 16 | const me = this; 17 | const { classNameItem, classHref, ctzType } = initTypeOb; 18 | if (dom(`div.PlaceHolder.${classNameItem}`)) { 19 | this.init(); 20 | return; 21 | } 22 | 23 | domA(`.${classNameItem}`).forEach((item) => { 24 | const elementButton = domC('button', { 25 | className: `${me.className} ${me.classNameRemove} ctz-button-black ctz-button`, 26 | innerText: '移除关注', 27 | style: 'position: absolute;right: 16px;bottom: 16px;background: transparent;', 28 | }); 29 | elementButton.onclick = function () { 30 | const nodeThis = this as HTMLButtonElement; 31 | const nItem = domP(nodeThis, 'class', classNameItem); 32 | const nodeHref = nItem ? (nItem.querySelector(classHref) as HTMLAnchorElement) : undefined; 33 | const qHref = nodeHref ? nodeHref.href : ''; 34 | if (!qHref) return; 35 | const nHref = qHref + `?ctzType=${ctzType}`; 36 | window.open(nHref); 37 | if (nodeThis.classList.contains(me.classNameRemove)) { 38 | nodeThis.innerText = '添加关注'; 39 | nodeThis.classList.remove(me.classNameRemove); 40 | } else { 41 | nodeThis.innerText = '移除关注'; 42 | nodeThis.classList.add(me.classNameRemove); 43 | } 44 | }; 45 | const nodeClassName = item.querySelector(`.${me.className}`); 46 | nodeClassName && nodeClassName.remove(); 47 | item.appendChild(elementButton); 48 | }); 49 | }, 50 | className: 'ctz-remove-follow', 51 | classNameRemove: 'ctz-button-red', 52 | classOb: { 53 | questions: { 54 | // 关注的问题 55 | classNameItem: 'List-item', 56 | classHref: '.QuestionItem-title a', 57 | ctzType: 1, 58 | }, 59 | topics: { 60 | // 关注的话题 61 | classNameItem: 'List-item', 62 | classHref: '.ContentItem-title .TopicLink', 63 | ctzType: 2, 64 | }, 65 | collections: { 66 | // 关注的收藏夹 67 | classNameItem: 'List-item', 68 | classHref: '.ContentItem-title a', 69 | ctzType: 3, 70 | }, 71 | }, 72 | timer: undefined, 73 | }; 74 | 75 | interface IClassObEntries { 76 | classNameItem: string; 77 | classHref: string; 78 | ctzType: number; 79 | } 80 | -------------------------------------------------------------------------------- /src/components/hidden/append-style.ts: -------------------------------------------------------------------------------- 1 | import { fnAppendStyle, myStorage } from '../../tools'; 2 | import { HIDDEN_ARRAY, HIDDEN_ARRAY_MORE } from './configs'; 3 | 4 | /** 加载隐藏模块的样式 */ 5 | export const appendHiddenStyle = async () => { 6 | const config = await myStorage.getConfig(); 7 | 8 | let hiddenContent = ''; 9 | 10 | HIDDEN_ARRAY.forEach((item) => { 11 | item.content.forEach((content) => { 12 | content.forEach((hiddenItem) => { 13 | config[hiddenItem.value] && (hiddenContent += hiddenItem.css); 14 | }); 15 | }); 16 | }); 17 | 18 | HIDDEN_ARRAY_MORE.forEach(({ keys, value }) => { 19 | let trueNumber = 0; 20 | keys.forEach((key) => config[key] && trueNumber++); 21 | trueNumber === keys.length && (hiddenContent += value); 22 | }); 23 | 24 | if (config.topVote) { 25 | hiddenContent += `.css-dvccr2{display: none!important;}`; 26 | } 27 | 28 | fnAppendStyle('CTZ_STYLE_HIDDEN', hiddenContent); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/hidden/create-html.ts: -------------------------------------------------------------------------------- 1 | import { createHTMLFormBoxSwitch } from '../../init/init-html/common-html'; 2 | import { dom } from '../../tools'; 3 | import { HIDDEN_ARRAY } from './configs'; 4 | 5 | /** 初始化隐藏元素设置,添加修改器隐藏模块设置元素 */ 6 | export const createHTMLHiddenConfig = (domMain: HTMLElement) => { 7 | // 隐藏元素部分 8 | dom('#CTZ_HIDDEN', domMain)!.innerHTML = HIDDEN_ARRAY.map( 9 | (item, index) => (item.name ? `
${item.name}${item.desc}
` : '') + createHTMLFormBoxSwitch(item.content) 10 | ).join(''); 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/hidden/index.ts: -------------------------------------------------------------------------------- 1 | export * from './append-style'; 2 | export * from './configs'; 3 | export * from './create-html'; 4 | export * from './types'; 5 | 6 | -------------------------------------------------------------------------------- /src/components/hidden/types.ts: -------------------------------------------------------------------------------- 1 | import { IOptionItem } from "../../types/common.type"; 2 | 3 | export type IHiddenArray = IHiddenItem[]; 4 | 5 | export interface IHiddenContentItem extends IOptionItem { 6 | css: string; 7 | } 8 | export interface IHiddenItem { 9 | key: string; 10 | name: string; 11 | desc: string; 12 | content: IHiddenContentItem[][]; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/history.ts: -------------------------------------------------------------------------------- 1 | import { dom, myStorage } from '../tools'; 2 | 3 | /** 回填历史记录 */ 4 | export const echoHistory = async () => { 5 | const history = await myStorage.getHistory(); 6 | const { list, view } = history; 7 | const nodeList = dom('#CTZ_HISTORY_LIST .ctz-set-content'); 8 | const nodeView = dom('#CTZ_HISTORY_VIEW .ctz-set-content'); 9 | nodeList && (nodeList.innerHTML = list.join('')); 10 | nodeView && (nodeView.innerHTML = view.join('')); 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/just-number/index.ts: -------------------------------------------------------------------------------- 1 | import { domA, domP, myStorage } from '../../tools'; 2 | 3 | const CLASS_JUST_NUMBER = 'ctz-just-number'; 4 | let timestamp = 0; 5 | 6 | /** 操作栏仅显示数字和图标 */ 7 | export const fnJustNumberInAction = async () => { 8 | const { justNumberInAction } = await myStorage.getConfig(); 9 | if (!justNumberInAction) return; 10 | 11 | const nTimestamp = +new Date(); 12 | if (nTimestamp - timestamp < 500) { 13 | setTimeout(fnJustNumberInAction, 500); 14 | return; 15 | } 16 | 17 | timestamp = nTimestamp; 18 | 19 | const nodes = domA(`.ContentItem .ContentItem-actions:not(.${CLASS_JUST_NUMBER})`); 20 | nodes.forEach((item) => { 21 | item.classList.add(CLASS_JUST_NUMBER); 22 | /** 赞同按钮 */ 23 | const buttonVoteUp = item.querySelector('.VoteButton--up'); 24 | /** 不赞同按钮 */ 25 | const buttonVoteDown = item.querySelector('.VoteButton--down'); 26 | /** 评论按钮 */ 27 | const buttonComment = item.querySelector('.Zi--Comment') ? domP(item.querySelector('.Zi--Comment'), 'class', 'Button')! : undefined; 28 | /** 分享按钮 */ 29 | const buttonShare = item.querySelector('.Zi--Share') ? domP(item.querySelector('.Zi--Share'), 'class', 'Button')! : undefined; 30 | /** 收藏按钮 */ 31 | const buttonCollection = item.querySelector('.Zi--Star') ? domP(item.querySelector('.Zi--Star'), 'class', 'Button')! : undefined; 32 | /** 喜欢按钮 */ 33 | const buttonLike = item.querySelector('.Zi--Heart') ? domP(item.querySelector('.Zi--Heart'), 'class', 'Button')! : undefined; 34 | 35 | buttonVoteUp && (buttonVoteUp.innerHTML = (buttonVoteUp.innerHTML || '').replace(/(已)?赞同\s*/, '')); 36 | buttonComment && (buttonComment.innerHTML = (buttonComment.innerHTML || '').replace(/\s*(条|添加|收起)?评论/, '')); 37 | buttonShare && (buttonShare.innerHTML = (buttonShare.innerHTML || '').replace(/分享/, '')); 38 | buttonCollection && (buttonCollection.innerHTML = (buttonCollection.innerHTML || '').replace(/(取消)?收藏/, '')); 39 | buttonLike && (buttonLike.innerHTML = (buttonLike.innerHTML || '').replace(/喜欢/, '')); 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/link.ts: -------------------------------------------------------------------------------- 1 | import { createButtonFontSize12, domA, message, myStorage } from '../tools'; 2 | import { copy } from './copy'; 3 | 4 | /** 知乎外链直接打开(修改外链内容,去除知乎重定向) */ 5 | export const initLinkChanger = () => { 6 | const esName = ['a.external', 'a.LinkCard']; 7 | for (let i = 0, len = esName.length; i < len; i++) { 8 | const name = esName[i]; 9 | const links = domA(`${name}:not(.ctz-link-changed)`); 10 | for (let index = 0, linkLen = links.length; index < linkLen; index++) { 11 | const item = links[index] as HTMLAnchorElement; 12 | const hrefFormat = item.href.replace(/^(https|http):\/\/link\.zhihu\.com\/\?target\=/, '') || ''; 13 | let href = ''; 14 | // 解决 hrefFormat 格式已经是 decode 后的格式 15 | try { 16 | href = decodeURIComponent(hrefFormat); 17 | } catch { 18 | href = hrefFormat; 19 | } 20 | item.href = href; 21 | item.classList.add('ctz-link-changed'); 22 | } 23 | } 24 | }; 25 | 26 | /** 回答内容意见分享 */ 27 | export const addAnswerCopyLink = async (contentItem: HTMLElement) => { 28 | const { copyAnswerLink } = await myStorage.getConfig(); 29 | if (!copyAnswerLink) return; 30 | const prevButton = contentItem.querySelector(`.ctz-copy-answer-link`); 31 | prevButton && prevButton.remove(); 32 | const nodeUser = contentItem.querySelector('.AnswerItem-authorInfo>.AuthorInfo'); 33 | if (!nodeUser) return; 34 | const nDomButton = createButtonFontSize12('获取回答链接', 'ctz-copy-answer-link'); 35 | nDomButton.onclick = function () { 36 | const metaUrl = contentItem.querySelector('[itemprop="url"]'); 37 | if (!metaUrl) return; 38 | const link = metaUrl.getAttribute('content') || ''; 39 | if (link) { 40 | copy(link); 41 | message('链接复制成功'); 42 | return; 43 | } 44 | }; 45 | nodeUser.appendChild(nDomButton); 46 | }; 47 | -------------------------------------------------------------------------------- /src/components/list-position/index.ts: -------------------------------------------------------------------------------- 1 | /** 列表内容定位 */ 2 | export * from './recommend-close-position'; 3 | -------------------------------------------------------------------------------- /src/components/list-position/recommend-close-position.ts: -------------------------------------------------------------------------------- 1 | import { dom, domP } from '../../tools'; 2 | 3 | /** 列表内容收起后修改定位,解决部分情况下位置跳转到很下方的问题 */ 4 | export const myRecommendClosePosition = { 5 | prevY: 0, 6 | yDocument: 0, 7 | savePosition: function (currentItem: HTMLElement) { 8 | // 如果存在 .is-collapsed,则为展开操作,不进行保存 9 | if (!currentItem.querySelector('.is-collapsed')) return; 10 | // 不是推荐列表则跳出 11 | if (!dom('.Topstory-recommend')) return; 12 | const topstoryItem = currentItem.classList.contains('TopstoryItem') ? currentItem : domP(currentItem, 'class', 'TopstoryItem'); 13 | if (!topstoryItem || !topstoryItem.nextElementSibling) return; 14 | console.log('savePosition', currentItem) 15 | const nextDom = topstoryItem.nextElementSibling as HTMLElement; 16 | if (nextDom.getBoundingClientRect().y > 0 && nextDom.getBoundingClientRect().y - window.innerHeight < 0) { 17 | // 保证收起时的下一项在页面内 18 | this.prevY = nextDom.offsetTop; 19 | this.yDocument = document.documentElement.scrollTop; 20 | } else { 21 | this.prevY = 0; 22 | this.yDocument = 0; 23 | } 24 | }, 25 | doPosition: function (currentItem: HTMLElement) { 26 | if (this.prevY === 0 || this.yDocument === 0) return; 27 | // 如果不存在 .is-collapsed,则为展开后内容,不进行保存 28 | if (currentItem.querySelector('.is-collapsed')) return; 29 | // 不是推荐列表则跳出 30 | if (!dom('.Topstory-recommend')) return; 31 | console.log('doPosition', currentItem) 32 | const topstoryItem = currentItem.classList.contains('TopstoryItem') ? currentItem : domP(currentItem, 'class', 'TopstoryItem'); 33 | if (!topstoryItem || !topstoryItem.nextElementSibling) return; 34 | const nextDom = topstoryItem.nextElementSibling as HTMLElement; 35 | window.scrollTo({ top: this.yDocument - (this.prevY - nextDom.offsetTop) }); 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/listen-answer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './listen'; 2 | -------------------------------------------------------------------------------- /src/components/listen-list/index.ts: -------------------------------------------------------------------------------- 1 | export * from './listen'; 2 | -------------------------------------------------------------------------------- /src/components/listen-list/listen.ts: -------------------------------------------------------------------------------- 1 | import { CLASS_LISTENED } from '../../misc'; 2 | import { dom, domA } from '../../tools'; 3 | import { processingData } from './processing-data'; 4 | import { recommendHighPerformance } from './recommend-high-performance'; 5 | 6 | interface IMyListenList { 7 | initTimestamp: number; 8 | loaded: boolean; 9 | /** 列表内容监听加载 */ 10 | init: () => Promise; 11 | /** 重置列表监听 */ 12 | reset: () => void; 13 | /** 重新加载监听 */ 14 | restart: () => void; 15 | /** 加载了数据 */ 16 | dataLoad: () => void; 17 | } 18 | 19 | /** 监听列表内容 - 过滤 */ 20 | export const myListenList: IMyListenList = { 21 | initTimestamp: 0, 22 | loaded: true, 23 | init: async function () { 24 | if (!this.loaded) return; 25 | const nodeLoading = dom('.Topstory-recommend .List-item.List-item'); 26 | const currentTime = +new Date(); 27 | // 存在此元素时为加载数据状态,半秒钟后再次加载 28 | // 时间戳添加,解决重置问题 29 | if (nodeLoading || currentTime - this.initTimestamp < 500) { 30 | setTimeout(() => this.init(), 500); 31 | return; 32 | } 33 | if (this.initTimestamp !== 0) { 34 | this.loaded = false; 35 | } 36 | 37 | this.initTimestamp = currentTime; 38 | await processingData(domA(`.TopstoryItem:not(.${CLASS_LISTENED})`)); 39 | setTimeout(async () => { 40 | await processingData(domA(`.TopstoryItem:not(.${CLASS_LISTENED})`)); // 每次执行后检测未检测到的项,解决内容重载的问题 41 | }, 500); 42 | await recommendHighPerformance(); 43 | }, 44 | reset: function () { 45 | this.dataLoad(); 46 | domA(`.TopstoryItem.${CLASS_LISTENED}`).forEach((item) => { 47 | item.classList.remove(CLASS_LISTENED); 48 | }); 49 | }, 50 | restart: function () { 51 | this.reset(); 52 | this.init(); 53 | }, 54 | dataLoad: function () { 55 | this.loaded = true; 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/listen-list/recommend-high-performance.ts: -------------------------------------------------------------------------------- 1 | import { CLASS_LISTENED } from "../../misc"; 2 | import { dom, domA, fnLog, myStorage } from "../../tools"; 3 | 4 | /** 高性能模式处理推荐列表 */ 5 | export const recommendHighPerformance = async () => { 6 | const { highPerformanceRecommend } = await myStorage.getConfig(); 7 | if (!highPerformanceRecommend) return; 8 | setTimeout(() => { 9 | const nodes = domA(`.${CLASS_LISTENED}`); 10 | if (nodes.length > 50) { 11 | // 查找最后一个元素显示位置,并在删除最前方元素后将页面位置调整回删除前,解决闪烁问题 12 | const nodeLast = nodes[nodes.length - 1]; 13 | /** 删除前最后一个元素的位置 */ 14 | const yLastPrev = nodeLast.offsetTop; 15 | /** 当前页面滚动位置 */ 16 | const yDocument = document.documentElement.scrollTop; 17 | /** 获取定位元素的唯一标识,通过唯一标识在删除元素后重新获取,解决只获取最后一个元素时已经有新元素添加进来导致的位置错误的情况 */ 18 | const code = nodeLast.dataset.code; 19 | 20 | const nIndex = nodes.length - 50; 21 | nodes.forEach((item, index) => { 22 | index < nIndex && item.remove(); 23 | }); 24 | 25 | const nNodeLast = dom(`[data-code="${code}"]`); 26 | if (nNodeLast) { 27 | /** 删除元素后最后一个元素的位置 */ 28 | const nYLast = nNodeLast.offsetTop; 29 | // 原页面滚动位置减去最后一个元素位置的差值,得出新的位置,解决闪烁问题 30 | window.scrollTo({ top: yDocument - (yLastPrev - nYLast) }); 31 | } 32 | fnLog(`已开启高性能模式,删除${nIndex}条推荐内容`); 33 | } 34 | }, 100); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/listen-search-list-item.ts: -------------------------------------------------------------------------------- 1 | import { CLASS_LISTENED } from '../misc'; 2 | import { CTZ_HIDDEN_ITEM_CLASS, domA, fnHidden, myStorage } from '../tools'; 3 | 4 | /** 监听搜索列表 - 过滤 */ 5 | export const myListenSearchListItem = { 6 | initTimestamp: 0, 7 | init: async function () { 8 | const currentTime = +new Date(); 9 | if (currentTime - this.initTimestamp < 500) { 10 | setTimeout(() => this.init(), 500); 11 | return; 12 | } 13 | const nodes = domA(`.SearchResult-Card[role="listitem"]:not(.${CLASS_LISTENED})`); 14 | if (this.index + 1 === nodes.length) return; 15 | const { removeItemAboutVideo, removeItemAboutArticle, removeItemAboutAD, removeLessVote, lessVoteNumber = 0 } = await myStorage.getConfig(); 16 | for (let i = 0, len = nodes.length; i < len; i++) { 17 | let message = ''; // 屏蔽信息 18 | const nodeItem = nodes[i]; 19 | nodeItem.classList.add(CLASS_LISTENED); 20 | if (!nodeItem || nodeItem.classList.contains(CTZ_HIDDEN_ITEM_CLASS)) continue; 21 | // FIRST 22 | // 列表种类屏蔽 23 | const haveAD = removeItemAboutAD && nodeItem.querySelector('.KfeCollection-PcCollegeCard-root'); 24 | const haveArticle = removeItemAboutArticle && nodeItem.querySelector('.ArticleItem'); 25 | const haveVideo = removeItemAboutVideo && nodeItem.querySelector('.ZvideoItem'); 26 | (haveAD || haveArticle || haveVideo) && (message = '列表种类屏蔽'); 27 | 28 | // 低赞内容过滤 29 | if (removeLessVote && !message) { 30 | const elementUpvote = nodeItem.querySelector('.ContentItem-actions .VoteButton--up'); 31 | if (elementUpvote) { 32 | const ariaLabel = elementUpvote.getAttribute('aria-label'); 33 | if (ariaLabel) { 34 | const upvoteText = ariaLabel.trim().replace(/\W+/, ''); 35 | const upvote = upvoteText.includes('万') ? +upvoteText.replace('万', '').trim() * 10000 : +upvoteText; 36 | if (upvote > -1 && upvote < lessVoteNumber) { 37 | message = `屏蔽低赞内容: ${upvote || 0}赞`; 38 | } 39 | } 40 | } 41 | } 42 | // 最后信息 & 起点位置处理 43 | message && fnHidden(nodeItem, message); 44 | } 45 | }, 46 | reset: function () { 47 | domA(`.SearchResult-Card[role="listitem"].${CLASS_LISTENED}`).forEach((item) => { 48 | item.classList.remove(CLASS_LISTENED); 49 | }); 50 | }, 51 | restart: function () { 52 | this.reset(); 53 | this.init(); 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/not-interested/index.ts: -------------------------------------------------------------------------------- 1 | import { domById, domC, myStorage } from '../../tools'; 2 | 3 | const ID_LIST = 'CTZ_NOT_INTERESTED_LIST'; 4 | const CLASS_REMOVE = 'ctz-remove-not-interested-item'; 5 | 6 | /** 加载不感兴趣列表 */ 7 | export const createHTMLNotInterestedList = async () => { 8 | let { notInterestedList = [] } = await myStorage.getConfig(); 9 | const boxList = domById(ID_LIST)!; 10 | boxList.innerHTML = notInterestedList.map((i) => `
${i}
`).join(''); 11 | boxList.onclick = async (event) => { 12 | const target = event.target as HTMLElement; 13 | if (target.classList.contains(CLASS_REMOVE)) { 14 | const content = target.previousElementSibling!.textContent; 15 | notInterestedList = notInterestedList.filter((i) => i !== content); 16 | await myStorage.updateConfigItem('notInterestedList', notInterestedList); 17 | target.parentElement!.remove(); 18 | } 19 | }; 20 | }; 21 | 22 | /** 添加不感兴趣项 */ 23 | export const addNotInterestedItem = async (name: string) => { 24 | const item = domC('div', { 25 | innerHTML: `${name}`, 26 | className: 'ctz-form-box-item', 27 | }); 28 | let { notInterestedList = [] } = await myStorage.getConfig(); 29 | const boxList = domById(ID_LIST)!; 30 | boxList.insertBefore(item, boxList.childNodes[0]); 31 | notInterestedList.unshift(name); 32 | await myStorage.updateConfigItem('notInterestedList', notInterestedList); 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/one-click-invitation.ts: -------------------------------------------------------------------------------- 1 | import { dom, domA, domC } from '../tools'; 2 | 3 | /** 初始化一键邀请功能 */ 4 | export const initOneClickInvitation = () => { 5 | setTimeout(() => { 6 | const domInvitation = dom('.QuestionInvitation'); 7 | if (!domInvitation || dom('.ctz-invite-once')) return; 8 | const nButton = domC('button', { 9 | className: 'ctz-button ctz-invite-once', 10 | innerHTML: '一键邀请', 11 | style: 'margin-left: 12px;', 12 | }); 13 | nButton.onclick = () => { 14 | const fnToMore = () => { 15 | const moreAction = dom('.QuestionMainAction'); 16 | if (moreAction) { 17 | moreAction.click(); 18 | setTimeout(() => { 19 | fnToMore(); 20 | }, 50); 21 | } else { 22 | fnToInviteAll(); 23 | } 24 | }; 25 | 26 | const fnToInviteAll = () => { 27 | const nodeInvites = domA('.QuestionInvitation .ContentItem-extra button') as NodeListOf; 28 | nodeInvites.forEach((item) => { 29 | !item.disabled && !item.classList.contains('AutoInviteItem-button--closed') && item.click(); 30 | }); 31 | }; 32 | 33 | fnToMore(); 34 | }; 35 | 36 | const nodeTopBar = domInvitation.querySelector('.Topbar'); 37 | nodeTopBar && nodeTopBar.appendChild(nButton); 38 | }, 500); 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/page-filter-setting.ts: -------------------------------------------------------------------------------- 1 | import { domA, domC } from '../tools'; 2 | 3 | /** 屏蔽页面设置 */ 4 | export const myPageFilterSetting = { 5 | timeout: undefined, 6 | init: function () { 7 | clearTimeout(this.timeout); 8 | if (/\/settings\/filter/.test(location.pathname)) { 9 | this.timeout = setTimeout(() => { 10 | this.addHTML(); 11 | this.init(); 12 | }, 500); 13 | } 14 | }, 15 | addHTML: () => { 16 | const nButton = domC('button', { 17 | className: 'ctz-button', 18 | style: 'margin-left: 12px;', 19 | innerHTML: '移除当前页所有屏蔽话题', 20 | }); 21 | nButton.onclick = () => { 22 | domA('.Tag button').forEach((item) => item.click()); 23 | }; 24 | domA('.css-j2uawy').forEach((item) => { 25 | if (/已屏蔽话题/.test(item.innerText) && !item.querySelector('.ctz-button')) { 26 | item.appendChild(nButton); 27 | } 28 | }); 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/page-title/buttons-operate.ts: -------------------------------------------------------------------------------- 1 | import { dom, message, myStorage } from '../../tools'; 2 | import { myCachePageTitle } from './cache'; 3 | import { changeTitle } from './change'; 4 | 5 | /** 点击确认修改网页标题按钮 */ 6 | export const buttonConfirmPageTitle = async () => { 7 | const nodeTitle = dom('[name="globalTitle"]') as HTMLInputElement; 8 | await myStorage.updateConfigItem('globalTitle', nodeTitle ? nodeTitle.value : ''); 9 | changeTitle(); 10 | message('网页标题修改成功'); 11 | }; 12 | 13 | /** 点击按钮还原网页标题 */ 14 | export const buttonResetPageTitle = async () => { 15 | const domGlobalTitle = dom('[name="globalTitle"]') as HTMLInputElement; 16 | domGlobalTitle && (domGlobalTitle.value = myCachePageTitle.get()); 17 | await myStorage.updateConfigItem('globalTitle', ''); 18 | changeTitle(); 19 | message('网页标题已还原'); 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/page-title/cache.ts: -------------------------------------------------------------------------------- 1 | /** 缓存网页标题 */ 2 | export const myCachePageTitle = { 3 | value: '', 4 | set: function (v = '') { 5 | this.value = v; 6 | }, 7 | get: function (): string { 8 | return this.value; 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/page-title/change.ts: -------------------------------------------------------------------------------- 1 | import { dom, domById, domC, myStorage } from '../../tools'; 2 | import { myCachePageTitle } from './cache'; 3 | import { ICO_URL } from './create-html'; 4 | 5 | const REGEXP_MESSAGE = /^\([^()]+\)/; 6 | 7 | /** 修改网页标题 */ 8 | export const changeTitle = async () => { 9 | const { globalTitle, globalTitleRemoveMessage } = await myStorage.getConfig(); 10 | let prevTitle = globalTitle || myCachePageTitle.get(); 11 | if (globalTitleRemoveMessage) { 12 | if (REGEXP_MESSAGE.test(prevTitle)) { 13 | prevTitle = prevTitle.replace(REGEXP_MESSAGE, '').trim(); 14 | } 15 | } 16 | document.title = prevTitle; 17 | }; 18 | 19 | /** 修改网页标题图片 */ 20 | export const changeICO = async () => { 21 | const { titleIco = '' } = await myStorage.getConfig(); 22 | const nId = 'CTZ_ICO'; 23 | if (!ICO_URL[titleIco]) return; 24 | const nodeXIcon = dom('[type="image/x-icon"]'); 25 | const nodeId = domById(nId); 26 | nodeXIcon && nodeXIcon.remove(); 27 | nodeId && nodeId.remove(); 28 | dom('head')!.appendChild( 29 | domC('link', { 30 | type: 'image/x-icon', 31 | href: ICO_URL[titleIco], 32 | id: nId, 33 | rel: 'icon', 34 | }) 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/page-title/create-html.ts: -------------------------------------------------------------------------------- 1 | import { dom } from '../../tools'; 2 | 3 | /** 网页标题图片集合 */ 4 | export const ICO_URL: Record = { 5 | zhihu: 'https://static.zhihu.com/heifetz/favicon.ico', 6 | github: 'https://github.githubassets.com/pinned-octocat.svg', 7 | juejin: 'https://lf3-cdn-tos.bytescm.com/obj/static/xitu_juejin_web//static/favicons/favicon-32x32.png', 8 | csdn: 'https://g.csdnimg.cn/static/logo/favicon32.ico', 9 | bilibili: 'https://www.bilibili.com/favicon.ico', 10 | lanhu: 'https://sso-cdn.lanhuapp.com/ssoweb/favicon.ico', 11 | yuque: 'https://mdn.alipayobjects.com/huamei_0prmtq/afts/img/A*vMxOQIh4KBMAAAAAAAAAAAAADvuFAQ/original', 12 | mailQQ: 'https://mail.qq.com/zh_CN/htmledition/images/favicon/qqmail_favicon_96h.png', 13 | mail163: 'https://mail.163.com/favicon.ico', 14 | weibo: 'https://weibo.com/favicon.ico', 15 | qzone: 'https://qzonestyle.gtimg.cn/aoi/img/logo/favicon.ico?max_age=31536000', 16 | baidu: 'https://www.baidu.com/favicon.ico', 17 | }; 18 | 19 | /** 添加修改网页图标设置 */ 20 | export const createHTMLTitleICOChange = (nDomMain: HTMLElement) => { 21 | dom('#CTZ_TITLE_ICO', nDomMain)!.innerHTML = Object.entries(ICO_URL) 22 | .map(([key, value]) => ``) 23 | .join(''); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/page-title/index.ts: -------------------------------------------------------------------------------- 1 | export * from './buttons-operate'; 2 | export * from './cache'; 3 | export * from './change'; 4 | export * from './create-html'; 5 | 6 | -------------------------------------------------------------------------------- /src/components/preview.ts: -------------------------------------------------------------------------------- 1 | import { dom, domById, myScroll } from '../tools'; 2 | 3 | /** 自定义预览方法 */ 4 | export const myPreview = { 5 | // 开启预览弹窗 6 | open: function (src: string, even?: any, isVideo?: boolean) { 7 | const nameDom = isVideo ? this.evenPathVideo : this.evenPathImg; 8 | const idDom = isVideo ? this.idVideo : this.idImg; 9 | const nodeName = dom(nameDom) as HTMLImageElement; 10 | const nodeId = domById(idDom); 11 | nodeName && (nodeName.src = src); 12 | nodeId && (nodeId.style.display = 'block'); 13 | // 存在 even 则保存,关闭时候清除 14 | // 解决浏览 GIF 时的弹窗问题 15 | even && (this.even = even); 16 | myScroll.stop(); 17 | }, 18 | // 关闭预览弹窗 19 | hide: function (pEvent: any) { 20 | if (this.even) { 21 | (this.even as HTMLButtonElement).click(); 22 | this.even = null; 23 | } 24 | pEvent.style.display = 'none'; 25 | const nodeImg = dom(this.evenPathImg) as HTMLImageElement; 26 | const nodeVideo = dom(this.evenPathVideo) as HTMLVideoElement; 27 | nodeImg && (nodeImg.src = ''); 28 | nodeVideo && (nodeVideo.src = ''); 29 | myScroll.on(); 30 | }, 31 | even: null, 32 | evenPathImg: '#CTZ_PREVIEW_IMAGE img', 33 | evenPathVideo: '#CTZ_PREVIEW_VIDEO video', 34 | idImg: 'CTZ_PREVIEW_IMAGE', 35 | idVideo: 'CTZ_PREVIEW_VIDEO', 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/select/config.ts: -------------------------------------------------------------------------------- 1 | import { IOptionItem } from '../../types/common.type'; 2 | 3 | /** 枚举: 替换知乎直达为搜索 */ 4 | export enum EReplaceZhidaToSearch { 5 | 不替换 = 'default', 6 | 知乎 = 'zhihu', 7 | 百度 = 'baidu', 8 | 谷歌 = 'google', 9 | 必应 = 'bing', 10 | 去除知乎直达跳转 = 'removeLink', 11 | } 12 | 13 | /** 枚举: 购物链接显示 */ 14 | export enum ELinkShopping { 15 | 默认 = '0', 16 | 仅文字 = '1', 17 | 隐藏 = '2', 18 | } 19 | 20 | /** 枚举: 回答内容收起/展开状态 */ 21 | export enum EAnswerOpen { 22 | 默认 = 'default', 23 | 自动展开所有回答 = 'on', 24 | 收起长回答 = 'off', 25 | } 26 | 27 | /** 枚举: 修改器弹出图标 ⚙︎ 定位方式 */ 28 | export enum ESuspensionOpen { 29 | 左右 = '0', 30 | 上下 = '1', 31 | } 32 | 33 | /** 枚举: 回答和文章图片尺寸 */ 34 | export enum EZoomImageType { 35 | 默认尺寸 = '0', 36 | 原图尺寸 = '1', 37 | 自定义尺寸 = '2', 38 | } 39 | 40 | /** 枚举: 图片最大高度限制 */ 41 | export enum EZoomImageHeight { 42 | 关闭 = '0', 43 | 开启 = '1', 44 | } 45 | 46 | /** 枚举: 列表视频回答尺寸 */ 47 | export enum EZoomListVideoType { 48 | 默认尺寸 = '0', 49 | 自定义尺寸 = '2', 50 | } 51 | 52 | /** 回答和文章中的视频显示方式 */ 53 | export enum EVideoInAnswerArticle { 54 | 默认 = '0', 55 | 修改为链接 = '1', 56 | 隐藏视频 = '2', 57 | } 58 | 59 | export enum EHomeContentOpen { 60 | 默认 = '0', 61 | 自动展开内容 = '1', 62 | } 63 | 64 | /** select 选择框 */ 65 | export const OPTIONS_MAP: Record = { 66 | // 替换知乎直达为搜索 67 | replaceZhidaToSearch: [ 68 | { label: '不替换', value: EReplaceZhidaToSearch.不替换 }, 69 | { label: '去除知乎直达跳转', value: EReplaceZhidaToSearch.去除知乎直达跳转 }, 70 | { label: '知乎', value: EReplaceZhidaToSearch.知乎 }, 71 | { label: '必应', value: EReplaceZhidaToSearch.必应 }, 72 | { label: '百度', value: EReplaceZhidaToSearch.百度 }, 73 | { label: '谷歌', value: EReplaceZhidaToSearch.谷歌 }, 74 | ], 75 | // 购物链接显示 76 | linkShopping: [ 77 | { label: '默认', value: ELinkShopping.默认 }, 78 | { label: '仅文字', value: ELinkShopping.仅文字 }, 79 | { label: '隐藏', value: ELinkShopping.隐藏 }, 80 | ], 81 | // 回答内容收起/展开状态 82 | answerOpen: [ 83 | { label: '默认', value: EAnswerOpen.默认 }, 84 | { label: '收起长回答', value: EAnswerOpen.收起长回答 }, 85 | { label: '自动展开所有回答', value: EAnswerOpen.自动展开所有回答 }, 86 | ], 87 | // 修改器弹出图标 ⚙︎ 定位方式 88 | suspensionOpen: [ 89 | { label: '左右', value: ESuspensionOpen.左右 }, 90 | { label: '上下', value: ESuspensionOpen.上下 }, 91 | ], 92 | // 回答和文章图片尺寸 93 | zoomImageType: [ 94 | { label: '默认尺寸', value: EZoomImageType.默认尺寸 }, 95 | { label: '自定义尺寸', value: EZoomImageType.自定义尺寸 }, 96 | { label: '原图尺寸', value: EZoomImageType.原图尺寸 }, 97 | ], 98 | // 图片最大高度限制 99 | zoomImageHeight: [ 100 | { label: '关闭', value: EZoomImageHeight.关闭 }, 101 | { label: '开启', value: EZoomImageHeight.开启 }, 102 | ], 103 | // 列表视频回答尺寸 104 | zoomListVideoType: [ 105 | { label: '默认尺寸', value: EZoomListVideoType.默认尺寸 }, 106 | { label: '自定义尺寸', value: EZoomListVideoType.自定义尺寸 }, 107 | ], 108 | // 回答和文章中的视频显示方式 109 | videoInAnswerArticle: [ 110 | { label: '默认', value: EVideoInAnswerArticle.默认 }, 111 | { label: '修改为链接', value: EVideoInAnswerArticle.修改为链接 }, 112 | { label: '隐藏视频/过滤视频回答', value: EVideoInAnswerArticle.隐藏视频 }, 113 | ], 114 | // 用户主页 - 动态、回答、文章收起/展开状态 115 | homeContentOpen: [ 116 | { label: '默认', value: EHomeContentOpen.默认 }, 117 | { label: '自动展开内容', value: EHomeContentOpen.自动展开内容 }, 118 | ], 119 | }; 120 | 121 | /** 显示修改部分的 select 选择 */ 122 | export const SELECT_BASIS_SHOW: IOptionItem[] = [ 123 | { label: '购物链接显示方式', value: 'linkShopping' }, 124 | { label: '替换知乎直达为搜索', value: 'replaceZhidaToSearch' }, 125 | { label: '回答和文章中的视频显示方式', value: 'videoInAnswerArticle' }, 126 | { label: '问题页面 - 回答收起/展开状态', value: 'answerOpen' }, 127 | { label: '用户主页 - 内容收起/展开状态', value: 'homeContentOpen' }, 128 | ]; 129 | -------------------------------------------------------------------------------- /src/components/select/create-html.ts: -------------------------------------------------------------------------------- 1 | import { createHTMLFormItem } from '../../init/init-html/common-html'; 2 | import { dom, domA, myStorage } from '../../tools'; 3 | import { OPTIONS_MAP, SELECT_BASIS_SHOW } from './config'; 4 | 5 | /** 创建自定义选择框和添加监听方法 */ 6 | export const createHTMLMySelect = (domMain: HTMLElement) => { 7 | // 添加下拉选择 8 | dom('#CTZ_BASIC_SHOW_SELECT', domMain)!.innerHTML = SELECT_BASIS_SHOW.map(({ label, value }) => 9 | createHTMLFormItem({ label, value: `
` }) 10 | ).join(''); 11 | 12 | // 添加下拉选择内容 13 | domA('.ctz-select', domMain).forEach((item) => { 14 | const name = item.getAttribute('name') || ''; 15 | if (OPTIONS_MAP[name]) { 16 | item.innerHTML = 17 | `
${`` + ``}
` + 18 | ``; 21 | 22 | const itemInput = item.querySelector('.ctz-select-input') as HTMLElement; 23 | const itemValue = item.querySelector('.ctz-select-value') as HTMLElement; 24 | const itemOptionBox = item.querySelector('.ctz-option-box') as HTMLElement; 25 | 26 | const open = () => { 27 | if (item.dataset.open === 'true') { 28 | itemOptionBox.style.display = 'none'; 29 | item.dataset.open = 'false'; 30 | } else { 31 | itemOptionBox.style.display = 'block'; 32 | item.dataset.open = 'true'; 33 | } 34 | }; 35 | 36 | itemInput.onclick = () => { 37 | closeAllSelect(); 38 | open(); 39 | }; 40 | itemOptionBox.onclick = async function (ev) { 41 | const target = ev.target as HTMLElement; 42 | if (!target.classList.contains('ctz-option-item')) return; 43 | const value = target.dataset.value; 44 | const label = target.textContent; 45 | itemValue.textContent = label; 46 | itemValue.dataset.value = value; 47 | optionChoose(itemOptionBox, target); 48 | open(); 49 | await myStorage.updateConfigItem(name, value); 50 | }; 51 | } 52 | }); 53 | }; 54 | 55 | /** 关闭所有select */ 56 | export const closeAllSelect = () => { 57 | domA('.ctz-select').forEach((item) => { 58 | item.dataset.open = 'false'; 59 | (item.querySelector('.ctz-option-box') as HTMLElement).style.display = 'none'; 60 | }); 61 | }; 62 | 63 | /** option 选择 */ 64 | export const optionChoose = (itemOptionBox: HTMLElement, chooseOne?: HTMLElement) => { 65 | itemOptionBox.querySelectorAll('.ctz-option-item').forEach((item) => { 66 | (item as HTMLElement).dataset.choose = 'false'; 67 | }); 68 | chooseOne && (chooseOne.dataset.choose = 'true'); 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/select/echo.ts: -------------------------------------------------------------------------------- 1 | import { domA, myStorage } from '../../tools'; 2 | import { OPTIONS_MAP } from './config'; 3 | import { optionChoose } from './create-html'; 4 | 5 | /** 回填自定义 select */ 6 | export const echoMySelect = async () => { 7 | const config = await myStorage.getConfig(); 8 | domA('.ctz-select').forEach((item) => { 9 | const name = item.getAttribute('name'); 10 | if (!name) return; 11 | const domValue = item.querySelector('.ctz-select-value') as HTMLElement; 12 | const options = OPTIONS_MAP[name]; 13 | if (!options) return; 14 | const currentOption = options.find((i) => i.value === config[name]); 15 | if (!currentOption) return; 16 | domValue.dataset.value = currentOption.value; 17 | domValue.textContent = currentOption.label; 18 | const itemOptionBox = item.querySelector('.ctz-option-box') as HTMLElement; 19 | const itemChoose = itemOptionBox.querySelector(`.ctz-option-item[data-value="${currentOption.value}"]`); 20 | optionChoose(itemOptionBox, itemChoose as HTMLElement | undefined); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/select/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config'; 2 | export * from './create-html'; 3 | export * from './echo'; 4 | 5 | -------------------------------------------------------------------------------- /src/components/size/create-html.ts: -------------------------------------------------------------------------------- 1 | import { createHTMLRange, createHTMLTooltip } from '../../init/init-html/common-html'; 2 | import { dom } from '../../tools'; 3 | import { IOptionItem } from '../../types/common.type'; 4 | 5 | /** 6 | * 尺寸设置部分元素添加 7 | * 页面宽度、字体大小、图片高度 8 | */ 9 | export const createHTMLSizeSetting = (domMain: HTMLElement) => { 10 | // 滑动输入条部分 START 11 | dom('#CTZ_VERSION_RANGE_ZHIHU', domMain)!.innerHTML = VERSION_RANGE_HAVE_PERCENT.map( 12 | (item) => 13 | `
${ 14 | `
${item.label}${createHTMLTooltip('最小显示宽度为600像素,设置低于此值将按照600像素显示')}
` + 15 | `
${createHTMLRange(item.value, VERSION_MIN_WIDTH, 1500) + createHTMLRange(`${item.value}Percent`, 20, 100, '%')}
` 16 | }
` + 17 | `
${ 18 | `
${item.label}使用百分比设置
` + `
` 19 | }
` 20 | ).join(''); 21 | 22 | dom('#CTZ_IMAGE_SIZE_CUSTOM', domMain)!.innerHTML = `
回答和文章图片宽度
` + createHTMLRange('zoomImageSize', 0, 1000); 23 | dom('#CTZ_IMAGE_HEIGHT_CUSTOM', domMain)!.innerHTML = `
图片最大高度
` + createHTMLRange('zoomImageHeightSize', 0, 1000); 24 | dom('#CTZ_LIST_VIDEO_SIZE_CUSTOM', domMain)!.innerHTML = `
列表视频回答宽度
` + createHTMLRange('zoomListVideoSize', 0, 1000); 25 | // 滑动输入条部分 END 26 | 27 | // 文字大小调节 28 | dom('#CTZ_FONT_SIZE_IN_ZHIHU', domMain)!.innerHTML = FONT_SIZE_INPUT.map( 29 | (item) => 30 | `
${ 31 | `
${item.label}
` + 32 | `
${ 33 | `` + 34 | `` 35 | }
` 36 | }
` 37 | ).join(''); 38 | }; 39 | 40 | export const FONT_SIZE_INPUT: IOptionItem[] = [ 41 | { value: 'fontSizeForListTitle', label: '列表标题文字大小' }, 42 | { value: 'fontSizeForList', label: '列表内容文字大小' }, 43 | { value: 'fontSizeForAnswerTitle', label: '回答标题文字大小' }, 44 | { value: 'fontSizeForAnswer', label: '回答内容文字大小' }, 45 | { value: 'fontSizeForArticleTitle', label: '文章标题文字大小' }, 46 | { value: 'fontSizeForArticle', label: '文章内容文字大小' }, 47 | { value: 'contentLineHeight', label: '内容行高' }, 48 | ]; 49 | 50 | /** 版心最小宽度 */ 51 | export const VERSION_MIN_WIDTH = 600; 52 | /** 版心宽度 */ 53 | export const VERSION_RANGE_HAVE_PERCENT: IOptionItem[] = [ 54 | { label: '列表宽度', value: 'versionHome' }, 55 | { label: '回答宽度', value: 'versionAnswer' }, 56 | { label: '文章宽度', value: 'versionArticle' }, 57 | { label: '用户主页宽度', value: 'versionUserHome' }, 58 | { label: '收藏夹宽度', value: 'versionCollection' }, 59 | ]; 60 | -------------------------------------------------------------------------------- /src/components/size/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-html'; 2 | export * from './my-size'; 3 | export * from './size-change-before-resize'; 4 | 5 | -------------------------------------------------------------------------------- /src/components/size/size-change-before-resize.ts: -------------------------------------------------------------------------------- 1 | import { dom, domById, fnAppendStyle, fnReturnStr, myStorage } from '../../tools'; 2 | 3 | /** 在页面尺寸修改后更新的样式 */ 4 | export const changeSizeBeforeResize = async () => { 5 | const { suspensionPickupRight, suspensionPickUp } = await myStorage.getConfig(); 6 | 7 | const prevContentBox = 8 | domById('TopstoryContent') || dom('.Question-mainColumn') || domById('SearchMain') || dom('.Profile-mainColumn') || dom('.CollectionsDetailPage-mainColumn') || document.body; 9 | // 如果尺寸大于document.body 则使用 body 尺寸 10 | const nodeContentBox = prevContentBox.offsetWidth > document.body.offsetWidth ? document.body : prevContentBox; 11 | let suspensionRight = +(suspensionPickupRight || 0); 12 | if (nodeContentBox) { 13 | suspensionRight = window.innerWidth - nodeContentBox.getBoundingClientRect().width - nodeContentBox.getBoundingClientRect().left + +(suspensionPickupRight || 0); 14 | } 15 | fnAppendStyle( 16 | 'CTZ_STYLE_CHANGE_AFTER_RESIZE', 17 | // 收起按钮悬浮 18 | fnReturnStr(`.ContentItem-actions.Sticky.is-fixed button[data-zop-retract-question="true"]{right: ${suspensionRight}px;}`, suspensionPickUp) 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/suspension/index.ts: -------------------------------------------------------------------------------- 1 | export * from './init-cache-header'; 2 | export * from './suspension-header'; 3 | export * from './suspension-pickup'; 4 | 5 | -------------------------------------------------------------------------------- /src/components/suspension/init-cache-header.ts: -------------------------------------------------------------------------------- 1 | import { dom } from '../../tools'; 2 | import { IHeaderName, storeSuspension } from './store'; 3 | import { suspensionHeader } from './suspension-header'; 4 | 5 | /** 6 | * 初始化缓存顶部元素 7 | * 顶部发现模块、个人中心模块、搜索模块 8 | */ 9 | export const initCacheHeader = () => { 10 | cacheSuspension('suspensionFind', '.AppHeader-inner .AppHeader-Tabs'); 11 | cacheSuspension('suspensionSearch', '.AppHeader-inner .SearchBar'); 12 | cacheSuspension('suspensionUser', '.AppHeader-inner .AppHeader-userInfo'); 13 | }; 14 | 15 | /** 查找缓存顶部元素 */ 16 | const cacheSuspension = (name: IHeaderName, classname: string, index = 0) => { 17 | const { setHeaderCache, getHeaderCache, setHeaderFound } = storeSuspension; 18 | const prevDom = getHeaderCache(name); 19 | 20 | // 当前元素仍然存在于页面上,那么知乎自身已经重载完毕 21 | if (prevDom && document.body.contains(prevDom)) { 22 | setHeaderFound(name); 23 | suspensionHeader(name); 24 | return; 25 | } 26 | 27 | const nextDom = dom(classname); 28 | if (nextDom) { 29 | setHeaderCache(name, nextDom); 30 | setTimeout(() => cacheSuspension(name, classname, index), 500); 31 | return; 32 | } 33 | 34 | // 如果已经查找超过十次仍未查找到元素则不进行再次查找(页面上不存在此元素) 35 | if (index >= 10) { 36 | setHeaderFound(name); 37 | return; 38 | } 39 | setTimeout(() => cacheSuspension(name, classname, ++index), 500); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/suspension/store.ts: -------------------------------------------------------------------------------- 1 | // 'suspensionHomeTab' | 2 | 3 | export type IHeaderName = 'suspensionFind' | 'suspensionUser' | 'suspensionSearch'; 4 | 5 | type IHeaderCache = { 6 | [key in IHeaderName]?: HTMLElement; 7 | }; 8 | 9 | /** 已经查找完毕 */ 10 | type IHeaderFound = { 11 | [key in IHeaderName]?: boolean; 12 | }; 13 | 14 | class Store { 15 | cache: IHeaderCache = {}; 16 | /** 已经查找完毕(会存在未查找到元素的情况 cache 对应的 name 值为 undefined) */ 17 | found: IHeaderFound = {}; 18 | 19 | constructor() { 20 | this.setHeaderCache = this.setHeaderCache.bind(this); 21 | this.getHeaderCache = this.getHeaderCache.bind(this); 22 | this.setHeaderFound = this.setHeaderFound.bind(this); 23 | this.getHeaderFound = this.getHeaderFound.bind(this); 24 | } 25 | 26 | setHeaderCache(keyname: IHeaderName, content: HTMLElement) { 27 | this.cache[keyname] = content; 28 | } 29 | getHeaderCache(keyname: IHeaderName): HTMLElement | undefined { 30 | return this.cache[keyname]; 31 | } 32 | setHeaderFound(keyname: IHeaderName) { 33 | this.found[keyname] = true; 34 | } 35 | getHeaderFound(keyname: IHeaderName): boolean { 36 | return !!this.found[keyname]; 37 | } 38 | } 39 | 40 | export const storeSuspension = new Store(); 41 | -------------------------------------------------------------------------------- /src/components/suspension/suspension-header.ts: -------------------------------------------------------------------------------- 1 | import { dom, domC, myStorage } from '../../tools'; 2 | import { mySize } from '../size'; 3 | import { myMove } from './move'; 4 | import { IHeaderName, storeSuspension } from './store'; 5 | 6 | let timeoutChangeSuspensionTab: NodeJS.Timeout | undefined; 7 | /** 改变列表切换TAB悬浮 */ 8 | export const changeSuspensionTab = async (index = 0, prevDom?: HTMLElement) => { 9 | const name = 'suspensionHomeTab'; 10 | const { suspensionHomeTab } = await myStorage.getConfig(); 11 | // 当前元素仍然存在于页面上,那么知乎自身已经重载完毕 12 | if (prevDom && document.body.contains(prevDom)) { 13 | if (suspensionHomeTab) { 14 | myLock.append(prevDom, name); 15 | myMove.init(prevDom, `${name}Po`, name); 16 | } else { 17 | myLock.remove(prevDom); 18 | myMove.destroy(prevDom); 19 | } 20 | return; 21 | } 22 | if (index >= 5) return; 23 | timeoutChangeSuspensionTab && clearTimeout(timeoutChangeSuspensionTab); 24 | timeoutChangeSuspensionTab = setTimeout(() => changeSuspensionTab(++index, dom('.Topstory-container .TopstoryTabs')), 500); 25 | }; 26 | 27 | /** 改变顶部元素的模块悬浮 */ 28 | export const suspensionHeader = async (name: IHeaderName) => { 29 | const { getHeaderCache, getHeaderFound } = storeSuspension; 30 | // 如果没有 Found 到则1s后重新执行(2s内未查找到元素 Found 会为true) 31 | if (!getHeaderFound(name)) { 32 | setTimeout(() => suspensionHeader(name), 1000); 33 | return; 34 | } 35 | 36 | const domCached = getHeaderCache(name); 37 | if (!domCached) return; 38 | 39 | const config = await myStorage.getConfig(); 40 | if (config[name]) { 41 | // 悬浮模块 42 | if (name === 'suspensionSearch') { 43 | if (!domCached.querySelector('.ctz-search-icon')) { 44 | const nDomSearch = domC('i', { className: 'ctz-search-icon', innerHTML: '⚲' }); 45 | nDomSearch.onclick = () => domCached.classList.add('focus'); 46 | domCached.appendChild(nDomSearch); 47 | } 48 | 49 | if (!domCached.querySelector('.ctz-search-pickup')) { 50 | const nDomPickup = domC('i', { className: 'ctz-search-pickup', innerHTML: '⇤' }); 51 | nDomPickup.onclick = () => domCached.classList.remove('focus'); 52 | domCached.appendChild(nDomPickup); 53 | } 54 | } 55 | myLock.append(domCached, name); 56 | domCached.classList.add(`position-${name}`); 57 | const nodeRoot = dom('#root'); 58 | nodeRoot && nodeRoot.appendChild(domCached); 59 | myMove.init(domCached, `${name}Po`, name); 60 | } else { 61 | // 模块不悬浮 62 | if (name === 'suspensionSearch') { 63 | const nodeIcon = dom('.ctz-search-icon'); 64 | const nodePickup = dom('.ctz-search-pickup'); 65 | nodeIcon && nodeIcon.remove(); 66 | nodePickup && nodePickup.remove(); 67 | domCached.classList.remove('focus'); 68 | } 69 | myLock.remove(domCached); 70 | domCached.classList.remove(`position-${name}`); 71 | domCached.setAttribute('style', ''); 72 | const nodeHeaderInner = dom('.AppHeader-inner'); 73 | nodeHeaderInner && nodeHeaderInner.appendChild(domCached); 74 | myMove.destroy(domCached); 75 | } 76 | mySize.change(); 77 | }; 78 | 79 | /** 悬浮模块开关锁添加移除方法 */ 80 | const myLock = { 81 | append: async function (el: HTMLElement, name: string) { 82 | // 悬浮模块是否固定改为鼠标放置到模块上显示开锁图标 点击即可移动模块 83 | const config = await myStorage.getConfig(); 84 | 85 | const iLock = domC('i', { className: 'ctz-lock', innerHTML: '☑︎' }); 86 | const iUnlock = domC('i', { className: 'ctz-unlock', innerHTML: '☒' }); 87 | const dLockMask = domC('div', { className: 'ctz-lock-mask' }); 88 | 89 | iLock.onclick = async () => { 90 | await myStorage.updateConfigItem(name + 'Fixed', true); 91 | el.classList.remove('ctz-move-this'); 92 | }; 93 | iUnlock.onclick = async () => { 94 | await myStorage.updateConfigItem(name + 'Fixed', false); 95 | el.classList.add('ctz-move-this'); 96 | }; 97 | 98 | // 如果进入页面的时候该项的 FIXED 为 false 则添加 class 99 | if (config[name + 'Fixed'] === false) { 100 | el.classList.add('ctz-move-this'); 101 | } 102 | 103 | !el.querySelector('.ctz-lock') && el.appendChild(iLock); 104 | !el.querySelector('.ctz-unlock') && el.appendChild(iUnlock); 105 | !el.querySelector('.ctz-lock-mask') && el.appendChild(dLockMask); 106 | }, 107 | remove: function (el: HTMLElement) { 108 | const nodeLock = el.querySelector('.ctz-lock'); 109 | const nodeUnlock = el.querySelector('.ctz-unlock'); 110 | const nodeLockMask = el.querySelector('.ctz-lock-mask'); 111 | nodeLock && nodeLock.remove(); 112 | nodeUnlock && nodeUnlock.remove(); 113 | nodeLockMask && nodeLockMask.remove(); 114 | }, 115 | }; 116 | -------------------------------------------------------------------------------- /src/components/suspension/suspension-pickup.ts: -------------------------------------------------------------------------------- 1 | import { dom, myStorage } from '../../tools'; 2 | import { changeSizeBeforeResize } from '../size'; 3 | 4 | /** 长回答和列表收起按钮悬浮 */ 5 | export const suspensionPickupAttribute = async () => { 6 | const { suspensionPickUp } = await myStorage.getConfig(); 7 | if (suspensionPickUp) { 8 | dom('body')!.setAttribute('data-suspension-pickup', 'true'); 9 | } else { 10 | dom('body')!.removeAttribute('data-suspension-pickup'); 11 | } 12 | // mySize.change(); 13 | changeSizeBeforeResize(); 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/time/index.ts: -------------------------------------------------------------------------------- 1 | import { CLASS_TIME_ITEM } from '../../misc'; 2 | import { dom, domC, formatTime, myStorage } from '../../tools'; 3 | 4 | /** 问题添加时间 */ 5 | export const updateItemTime = (contentItem: HTMLElement) => { 6 | const nodeBox = contentItem.querySelector('.ContentItem-meta'); 7 | if (!nodeBox || contentItem.querySelector(`.${CLASS_TIME_ITEM}`)) return; 8 | const dateCreated = contentItem.querySelector('[itemprop="dateCreated"]') as HTMLMetaElement; 9 | const datePublished = contentItem.querySelector('[itemprop="datePublished"]') as HTMLMetaElement; 10 | const dateModified = contentItem.querySelector('[itemprop="dateModified"]') as HTMLMetaElement; 11 | let innerHTML = ''; 12 | const create = dateCreated ? dateCreated.content || '' : ''; 13 | const published = datePublished ? datePublished.content || '' : ''; 14 | const modified = dateModified ? dateModified.content || '' : ''; 15 | create && (innerHTML += `创建时间:${formatTime(create, 'YYYY-MM-DD HH:mm:ss', true)}`); 16 | published && (innerHTML += `发布时间:${formatTime(published, 'YYYY-MM-DD HH:mm:ss', true)}`); 17 | modified && modified !== published && modified !== create && (innerHTML += `|最后修改时间:${formatTime(modified, 'YYYY-MM-DD HH:mm:ss', true)}`); 18 | nodeBox.appendChild( 19 | domC('div', { 20 | className: CLASS_TIME_ITEM, 21 | innerHTML, 22 | style: 'line-height: 24px;padding-top: 2px;font-size: 13px;color: rgb(132, 145, 165);display:inline-block;', 23 | }) 24 | ); 25 | }; 26 | 27 | export const removeItemTime = (contentItem?: Element) => { 28 | if (!contentItem) return; 29 | const prevTime = contentItem.querySelector(`.${CLASS_TIME_ITEM}`); 30 | prevTime && prevTime.remove(); 31 | }; 32 | 33 | let questionTimeout: NodeJS.Timeout; 34 | let questionFindIndex = 0; 35 | const resetQuestionTime = () => { 36 | if (questionFindIndex > 5 || !dom('.ctz-question-time')) { 37 | return; 38 | } 39 | questionFindIndex++; 40 | clearTimeout(questionTimeout); 41 | questionTimeout = setTimeout(addQuestionTime, 500); 42 | }; 43 | 44 | /** 问题详情添加时间 */ 45 | export const addQuestionTime = async () => { 46 | const nodeTime = dom('.ctz-question-time'); 47 | nodeTime && nodeTime.remove(); 48 | const { questionCreatedAndModifiedTime } = await myStorage.getConfig(); 49 | const nodeCreated = dom('[itemprop="dateCreated"]') as HTMLMetaElement; 50 | const nodeModified = dom('[itemprop="dateModified"]') as HTMLMetaElement; 51 | const nodeBox = dom('.QuestionPage .QuestionHeader-title'); 52 | if (!questionCreatedAndModifiedTime || !nodeCreated || !nodeModified || !nodeBox) { 53 | resetQuestionTime(); 54 | return; 55 | } 56 | const create = nodeCreated.content || ''; 57 | const modified = nodeModified.content || ''; 58 | 59 | nodeBox && 60 | nodeBox.appendChild( 61 | domC('div', { 62 | className: 'ctz-question-time', 63 | innerHTML: 64 | `创建时间:${formatTime(create, 'YYYY-MM-DD HH:mm:ss', true)}` + 65 | (modified && modified !== create ? `|最后修改时间:${formatTime(modified, 'YYYY-MM-DD HH:mm:ss', true)}` : ''), 66 | style: 'color: rgb(132, 145, 165);', 67 | }) 68 | ); 69 | resetQuestionTime(); 70 | }; 71 | 72 | /** 文章发布时间置顶 */ 73 | export const addArticleTime = async () => { 74 | const { articleCreateTimeToTop } = await myStorage.getConfig(); 75 | const nodeT = dom('.ctz-article-time'); 76 | if (nodeT) return; 77 | const nodeContentTime = dom('.ContentItem-time'); 78 | const nodeBox = dom('.Post-Header'); 79 | if (!articleCreateTimeToTop || !nodeContentTime || !nodeBox) return; 80 | nodeBox.appendChild( 81 | domC('span', { 82 | className: 'ctz-article-time', 83 | style: 'line-height: 30px;color: rgb(132, 145, 165);', 84 | innerHTML: nodeContentTime.innerText || '', 85 | }) 86 | ); 87 | setTimeout(() => { 88 | // 解决页面重载问题 89 | addArticleTime(); 90 | }, 500); 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/user-home/index.ts: -------------------------------------------------------------------------------- 1 | export * from './listen'; 2 | -------------------------------------------------------------------------------- /src/components/user-home/listen.ts: -------------------------------------------------------------------------------- 1 | import { doContentItem } from '../../init/init-top-event-listener'; 2 | import { CLASS_LISTENED } from '../../misc'; 3 | import { domA, myStorage } from '../../tools'; 4 | import { EHomeContentOpen } from '../select'; 5 | 6 | export const myListenUserHomeList = { 7 | timestamp: 0, 8 | init: async function () { 9 | const nTimestamp = +new Date(); 10 | if (nTimestamp - this.timestamp < 500) { 11 | setTimeout(() => this.init(), 500); 12 | return; 13 | } 14 | this.timestamp = nTimestamp 15 | 16 | const { homeContentOpen } = await myStorage.getConfig(); 17 | const nodes = domA(`.Profile-main .ListShortcut .List-item .ContentItem:not(.${CLASS_LISTENED})`); 18 | for (let i = 0, len = nodes.length; i < len; i++) { 19 | const contentItem = nodes[i]; 20 | contentItem.classList.add(CLASS_LISTENED); 21 | const isAnswer = contentItem.classList.contains('AnswerItem'); 22 | const isVideo = contentItem.classList.contains('ZVideoItem'); 23 | const isArticle = contentItem.classList.contains('ArticleItem'); 24 | const isPin = contentItem.classList.contains('PinItem'); 25 | if (!isAnswer && !isVideo && !isArticle && !isPin) continue; 26 | 27 | if (homeContentOpen === EHomeContentOpen.自动展开内容) { 28 | const openBTN = contentItem.querySelector('button.ContentItem-more') as HTMLButtonElement; 29 | openBTN && openBTN.click(); 30 | } 31 | 32 | doContentItem('USER_HOME', contentItem); 33 | } 34 | }, 35 | reset: function () { 36 | domA(`.Profile-main .ListShortcut .List-item .ContentItem.${CLASS_LISTENED}`).forEach((item) => { 37 | item.classList.remove(CLASS_LISTENED); 38 | }); 39 | }, 40 | restart: function () { 41 | this.reset(); 42 | this.init(); 43 | }, 44 | }; 45 | 46 | // AnswerItem 回答 47 | // ZVideoItem 视频 48 | // ArticleItem 文章 49 | // PinItem 想法 50 | -------------------------------------------------------------------------------- /src/components/video/change-style.ts: -------------------------------------------------------------------------------- 1 | import { EVideoInAnswerArticle } from '../../components/select'; 2 | import { fnAppendStyle, myStorage } from '../../tools'; 3 | import { CLASS_VIDEO_ONE, CLASS_VIDEO_TWO } from './download'; 4 | 5 | /** 修改回答和列表中的视频样式 */ 6 | export const changeVideoStyle = async () => { 7 | const { videoInAnswerArticle } = await myStorage.getConfig(); 8 | fnAppendStyle('CTZ_STYLE_VIDEO', STYLE_VIDEO[videoInAnswerArticle || EVideoInAnswerArticle.默认]); 9 | }; 10 | 11 | const STYLE_VIDEO = { 12 | [EVideoInAnswerArticle.默认]: '', 13 | [EVideoInAnswerArticle.修改为链接]: 14 | `${CLASS_VIDEO_ONE}>div,${CLASS_VIDEO_ONE}>i{display: none;}` + 15 | `${CLASS_VIDEO_ONE}{padding: 0!important;height:24px!important;width: fit-content!important;}` + 16 | `${CLASS_VIDEO_ONE}::before{content: '视频链接,点击跳转';cursor:pointer;color: #1677ff;font-size:12px;font-weight:600;}` + 17 | `${CLASS_VIDEO_ONE}:hover::before{color: rgb(0, 64, 221)}` + 18 | `${CLASS_VIDEO_TWO}::before,${CLASS_VIDEO_TWO}>i{display: none;}` + 19 | `.VideoAnswerPlayer + div{display:none;}.VideoAnswerPlayer::before{content: '视频链接,点击跳转';cursor:pointer;color: #1677ff;font-size:12px;font-weight:600;}` + 20 | `.VideoAnswerPlayer:hover::before{color: rgb(0, 64, 221)}`, 21 | [EVideoInAnswerArticle.隐藏视频]: 22 | `${CLASS_VIDEO_ONE}>div,${CLASS_VIDEO_ONE}>i{display: none;}` + 23 | `${CLASS_VIDEO_ONE}{padding: 0!important;height:24px!important;width: fit-content!important;}` + 24 | `${CLASS_VIDEO_ONE}::before{content: '隐藏一条视频内容';cursor:pointer;color: rgb(142, 142, 147);font-size: 12px;}`, 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/video/download.ts: -------------------------------------------------------------------------------- 1 | import { domC, myStorage } from '../../tools'; 2 | import { EVideoInAnswerArticle } from '../select'; 3 | 4 | /** 视频回答的包裹元素1 */ 5 | export const CLASS_VIDEO_ONE = '.css-1h1xzpn'; 6 | /** 视频回答的包裹元素2 */ 7 | export const CLASS_VIDEO_TWO = '.VideoAnswerPlayer-video'; 8 | export const CLASS_VIDEO_TWO_BOX = '.VideoAnswerPlayer'; 9 | 10 | /** 需要转链接的视频元素类名 */ 11 | const NEED_LINK_CLASS = [CLASS_VIDEO_ONE, CLASS_VIDEO_TWO]; 12 | 13 | /** 加载视频下载方法 */ 14 | export const initVideoDownload = async (nodeFound?: HTMLElement) => { 15 | if (!nodeFound) return; 16 | const { videoInAnswerArticle } = await myStorage.getConfig(); 17 | const domVideos = findDoms( 18 | nodeFound, 19 | ['.ZVideo-player>div', CLASS_VIDEO_ONE, CLASS_VIDEO_TWO].filter((i) => { 20 | return videoInAnswerArticle === EVideoInAnswerArticle.修改为链接 ? !NEED_LINK_CLASS.includes(i) : true; 21 | }) 22 | ); 23 | for (let i = 0, len = domVideos.length; i < len; i++) { 24 | const domVideoBox = domVideos[i] as HTMLElement; 25 | const nDomDownload = domC('i', { className: 'ctz-video-download', innerHTML: '⤓' }); 26 | const nDomLoading = domC('i', { className: 'ctz-loading', innerHTML: '↻', style: 'color: #fff;position: absolute;top: 20px;left: 20px;' }); 27 | nDomDownload.onclick = function () { 28 | const me = this as HTMLElement; 29 | const srcVideo = domVideoBox.querySelector('video')!.src; 30 | if (srcVideo) { 31 | me.style.display = 'none'; 32 | domVideoBox.appendChild(nDomLoading); 33 | videoDownload(srcVideo, `video${+new Date()}`).then(() => { 34 | me.style.display = 'block'; 35 | nDomLoading.remove(); 36 | }); 37 | } 38 | }; 39 | const nodeDownload = domVideoBox.querySelector('.ctz-video-download'); 40 | nodeDownload && nodeDownload.remove(); 41 | domVideoBox.style.cssText += `position: relative;`; 42 | domVideoBox.appendChild(nDomDownload); 43 | } 44 | }; 45 | 46 | const findDoms = (nodeFound: HTMLElement, domNames: string[]): NodeListOf => { 47 | const doms = domNames.map((i) => nodeFound.querySelectorAll(i)); 48 | for (let i = 0, len = doms.length; i < len; i++) { 49 | if (doms[i].length) { 50 | return doms[i]; 51 | } 52 | } 53 | return doms[doms.length - 1]; 54 | }; 55 | 56 | /** 视频下载 */ 57 | const videoDownload = async (url: string, name: string) => { 58 | return fetch(url) 59 | .then((res) => res.blob()) 60 | .then((blob) => { 61 | const objectUrl = window.URL.createObjectURL(blob); 62 | const elementA = domC('a', { 63 | download: name, 64 | href: objectUrl, 65 | }); 66 | elementA.click(); 67 | window.URL.revokeObjectURL(objectUrl); 68 | elementA.remove(); 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /src/components/video/fix-auto-play.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 解决视频自动播放问题 */ 3 | export const fixVideoAutoPlay = () => { 4 | // 拦截 video.play() 指令 5 | var originalPlay = HTMLMediaElement.prototype.play; 6 | // @ts-ignore 7 | HTMLMediaElement.prototype.play = function () { 8 | // 如果视频隐藏则退出 9 | if (!this.offsetHeight) { 10 | return; 11 | } 12 | // @ts-ignore 13 | // 否则正常执行 video.play() 指令 14 | return originalPlay.apply(this, arguments); 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/video/index.ts: -------------------------------------------------------------------------------- 1 | export * from './change-style'; 2 | export * from './download'; 3 | export * from './fix-auto-play'; 4 | 5 | -------------------------------------------------------------------------------- /src/components/vote/index.ts: -------------------------------------------------------------------------------- 1 | import { domC, myStorage } from "../../tools"; 2 | 3 | /** 内容顶部显示赞同数 nodeItem className: ContentItem-meta */ 4 | export const updateTopVote = async (contentItem: HTMLElement) => { 5 | const nodeItemMeta = contentItem.querySelector('.ContentItem-meta'); 6 | const nodeVote = contentItem.querySelector('[itemprop="upvoteCount"]') as HTMLMetaElement; 7 | const { topVote } = await myStorage.getConfig() 8 | if (!nodeVote || !topVote || !nodeItemMeta) return; 9 | const vote = nodeVote.content; 10 | if (+vote === 0) return; 11 | const className = 'ctz-top-vote'; 12 | const domVotePrev = nodeItemMeta.querySelector(`.${className}`); 13 | const innerHTML = `${vote} 人赞同`; 14 | if (domVotePrev) { 15 | domVotePrev.innerHTML = innerHTML; 16 | } else { 17 | const domVote = domC('div', { 18 | className, 19 | innerHTML, 20 | style: 'font-size: 13px;padding-top: 2px;color: rgb(132, 145, 165);', 21 | }); 22 | nodeItemMeta.appendChild(domVote); 23 | const metaObserver = new MutationObserver(() => { 24 | updateTopVote(contentItem); 25 | }); 26 | metaObserver.observe(nodeVote, { 27 | attributes: true, 28 | childList: false, 29 | characterData: false, 30 | characterDataOldValue: false, 31 | subtree: false, 32 | }); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/zhida-to-search.ts: -------------------------------------------------------------------------------- 1 | import { CLASS_LISTENED } from '../misc'; 2 | import { myStorage } from '../tools'; 3 | import { EReplaceZhidaToSearch } from './select'; 4 | 5 | let timeout: NodeJS.Timeout | undefined; 6 | 7 | /** 替换知乎直达为搜索 */ 8 | export const fnReplaceZhidaToSearch = async (domFind: HTMLElement = document.body, index = 0) => { 9 | if (index === 5) return; 10 | const { replaceZhidaToSearch = EReplaceZhidaToSearch.不替换 } = await myStorage.getConfig(); 11 | if (replaceZhidaToSearch === EReplaceZhidaToSearch.不替换) return; 12 | 13 | const domsZhida = domFind.querySelectorAll('.RichContent-EntityWord'); 14 | if (!domsZhida.length) { 15 | timeout && clearTimeout(timeout); 16 | timeout = setTimeout(() => { 17 | fnReplaceZhidaToSearch(domFind, ++index); 18 | }, 500); 19 | return; 20 | } 21 | 22 | for (let i = 0, len = domsZhida.length; i < len; i++) { 23 | const domItem = domsZhida[i] as HTMLAnchorElement; 24 | if (domItem.classList.contains(CLASS_LISTENED)) continue; 25 | domItem.classList.add(CLASS_LISTENED); 26 | 27 | const domSvg = domItem.querySelector('svg'); 28 | if (domSvg) { 29 | domSvg.style.display = 'none'; 30 | } 31 | // 去除知乎直达跳转 32 | if (replaceZhidaToSearch === EReplaceZhidaToSearch.去除知乎直达跳转) { 33 | domItem.onclick = function (e) { 34 | e.preventDefault(); 35 | }; 36 | domItem.style.cssText = `color: inherit!important; cursor: text!important;background: transparent!important;`; 37 | continue; 38 | } 39 | 40 | const prevTextContent = domItem.textContent || ''; 41 | domItem.innerHTML = prevTextContent + ''; 42 | domItem.href = SEARCH_PATH[replaceZhidaToSearch] + encodeURIComponent(prevTextContent); 43 | } 44 | }; 45 | 46 | const SEARCH_PATH: Record = { 47 | [EReplaceZhidaToSearch.知乎]: 'https://www.zhihu.com/search?type=content&q=', 48 | [EReplaceZhidaToSearch.百度]: 'https://www.baidu.com/s?wd=', 49 | [EReplaceZhidaToSearch.谷歌]: 'https://www.google.com.hk/search?q=', 50 | [EReplaceZhidaToSearch.必应]: 'https://www.bing.com/search?q=', 51 | }; 52 | -------------------------------------------------------------------------------- /src/init/init-history-view.ts: -------------------------------------------------------------------------------- 1 | import { dom, myStorage } from '../tools'; 2 | 3 | const CONTENT_HREF = ['www.zhihu.com/question/', 'zhuanlan.zhihu.com/p/', 'www.zhihu.com/zvideo/']; 4 | 5 | /** 添加浏览历史 */ 6 | export const initHistoryView = async () => { 7 | const { href, origin, pathname } = location; 8 | // 判断是否在内容页面中(回答、文章、视频) 9 | let isContentHref = false; 10 | CONTENT_HREF.forEach((item) => href.includes(item) && (isContentHref = true)); 11 | if (!isContentHref) return; 12 | 13 | setTimeout(async () => { 14 | let name = ''; 15 | const isQuestion = href.includes('www.zhihu.com/question/'); 16 | isQuestion && 17 | dom('.QuestionPage [itemprop="name"]') && 18 | (name = `「问题」${(dom('.QuestionPage [itemprop="name"]') as HTMLMetaElement)!.content}`); 19 | href.includes('zhuanlan.zhihu.com/p/') && dom('.Post-Title') && (name = `「文章」${dom('.Post-Title')!.innerText}`); 20 | href.includes('www.zhihu.com/zvideo/') && dom('.ZVideo .ZVideo-title') && (name = `「视频」${dom('.ZVideo .ZVideo-title')!.innerText}`); 21 | 22 | if (!name) { 23 | initHistoryView(); 24 | return; 25 | } 26 | 27 | let extra = ''; 28 | const questionAnswerId = pathname.replace(/\/question\/\d+\/answer\//, ''); 29 | if (isQuestion && questionAnswerId) { 30 | extra = ` ---- 回答: ${questionAnswerId}`; 31 | } 32 | 33 | const nA = `${name + extra}`; 34 | const { view } = await myStorage.getHistory(); 35 | if (!view.includes(nA)) { 36 | view.unshift(nA); 37 | myStorage.updateHistoryItem('view', view); 38 | } 39 | }, 500); 40 | }; 41 | -------------------------------------------------------------------------------- /src/init/init-html/common-html.ts: -------------------------------------------------------------------------------- 1 | import { ICommonContent } from './types'; 2 | 3 | /** 提示HTML */ 4 | export const createHTMLTooltip = (value: string) => `?${value}`; 5 | 6 | /** 范围选择器HTML */ 7 | export const createHTMLRange = (v: string, min: number, max: number, unit = '') => 8 | `
${ 9 | `当前:0${unit}` + 10 | `${min}${unit}` + 11 | `` + 12 | `${max}${unit}` 13 | }
`; 14 | 15 | /** 16 | * form box switch 通用模块HTML 17 | * @param con ICommonContent[][] 18 | * @returns string 19 | */ 20 | export const createHTMLFormBoxSwitch = (con: ICommonContent[][]) => 21 | con 22 | .map( 23 | (item) => 24 | `
${item 25 | .map(({ label, value, needFetch, tooltip }) => 26 | createHTMLFormItem({ label, value: ``, needFetch, tooltip }) 27 | ) 28 | .join('')}
` 29 | ) 30 | .join(''); 31 | 32 | /** 创建 formItem */ 33 | export const createHTMLFormItem = ({ label, value, needFetch, tooltip, extraClass }: ICreateFormItem) => 34 | `
${ 35 | `
${label + (needFetch ? '(接口拦截已关闭,此功能无法使用)' : '') + (tooltip ? createHTMLTooltip(tooltip) : '')}
` + 36 | `
${value}
` 37 | }
`; 38 | 39 | interface ICreateFormItem { 40 | /** 名称 */ 41 | label: string; 42 | /** 内容 */ 43 | value: string; 44 | /** 是否需要接口支持 */ 45 | needFetch?: boolean; 46 | /** 提示 */ 47 | tooltip?: string; 48 | /** 额外的样式 */ 49 | extraClass?: string; 50 | } 51 | -------------------------------------------------------------------------------- /src/init/init-html/configs/basic-show.ts: -------------------------------------------------------------------------------- 1 | import { ICommonContent } from '../types'; 2 | 3 | /** 基础设置 - 显示设置部分 */ 4 | export const BASIC_SHOW: ICommonContent[][] = [ 5 | [ 6 | { 7 | label: 8 | `列表 - 标题类别显示` + 9 | `「问题」` + 10 | `「文章」` + 11 | `「视频」` + 12 | `「想法」`, 13 | value: 'questionTitleTag', 14 | }, 15 | { label: '列表和回答 - 点击高亮边框', value: 'highlightListItem' }, 16 | { label: '列表 - 「···」按钮移动到最右侧', value: 'fixedListItemMore' }, 17 | { label: '列表 - 显示「直达问题」按钮', value: 'listOutputToQuestion' }, 18 | ], 19 | [ 20 | { label: '操作栏仅显示数字和图标', value: 'justNumberInAction' }, 21 | ], 22 | [ 23 | { label: '问题详情 - 替换回答顶部赞同数显示(实时显示点赞数量)', value: 'topVote' }, 24 | { label: '问题详情 - 一键获取回答链接', value: 'copyAnswerLink' }, 25 | { label: '回答和文章顶部显示「导出当前内容/回答按钮」', value: 'topExportContent' }, 26 | ], 27 | [ 28 | { label: '用户主页 - 内容发布和修改时间', value: 'userHomeContentTimeTop' }, 29 | { label: '列表 - 发布和修改时间', value: 'listItemCreatedAndModifiedTime' }, 30 | { label: '问题详情 - 问题 - 发布和修改时间', value: 'questionCreatedAndModifiedTime' }, 31 | { label: '问题详情 - 回答 - 发布和修改时间', value: 'answerItemCreatedAndModifiedTime' }, 32 | { label: '文章 - 发布时间', value: 'articleCreateTimeToTop' }, 33 | ], 34 | [ 35 | { label: '取消评论输入框自动聚焦', value: 'cancelCommentAutoFocus' }, 36 | { label: '键盘ESC键关闭评论弹窗', value: 'keyEscCloseCommentDialog' }, 37 | { label: '点击空白处关闭评论弹窗', value: 'clickMarkCloseCommentDialog' }, 38 | ], 39 | ]; 40 | -------------------------------------------------------------------------------- /src/init/init-html/configs/default-function.ts: -------------------------------------------------------------------------------- 1 | /** 默认功能 */ 2 | export const DEFAULT_FUNCTION = [ 3 | { 4 | title: '外部链接直接跳转', 5 | commit: '知乎里所有外部链接的重定向页面去除,点击将直接跳转到外部链接,不再打开知乎外部链接提示页面', 6 | }, 7 | { 8 | title: '移除登录提示弹窗', 9 | }, 10 | { 11 | title: '一键移除所有屏蔽话题,点击「话题黑名单」编辑按钮出现按钮', 12 | commit: '知乎屏蔽页面每次只显示部分内容,建议解除屏蔽后刷新页面查看是否仍然存在新的屏蔽标签', 13 | }, 14 | { 15 | title: '视频下载', 16 | commit: '可下载视频内容左上角将会生成一个下载按钮,点击即可下载视频', 17 | }, 18 | { 19 | title: '收藏夹内容导出为 PDF(需开启接口拦截)', 20 | commit: '点击收藏夹名称上方「导出当前页内容」按钮,可导出当前页码的收藏夹详细内容', 21 | }, 22 | { 23 | title: '个人主页关注订阅快捷取消关注', 24 | commit: 25 | '由于知乎接口的限制,关注及移除只能在对应页面中进行操作,所以点击「移除关注」按钮将打开页面到对应页面,取消或关注后此页面自动关闭,如果脚本未加载请刷新页面
目前仅支持「我关注的问题」、「我关注的收藏」一键移除或添回关注', 26 | }, 27 | { 28 | title: '预览静态图片键盘快捷切换', 29 | commit: '静态图片点击查看大图时,如果当前回答或者文章中存在多个图片,可以使用键盘方向键左右切换图片显示', 30 | }, 31 | { 32 | title: '用户主页-回答-导出当前页回答的功能(需开启接口拦截)', 33 | }, 34 | { 35 | title: '用户主页-文章-导出当前页文章的功能(需开启接口拦截)', 36 | }, 37 | { 38 | title: '一键邀请', 39 | commit: '问题邀请用户添加一键邀请按钮,点击可邀请所有推荐用户', 40 | }, 41 | { 42 | title: '解除禁止转载的限制', 43 | commit: '无视禁止转载提示强行复制', 44 | }, 45 | // { 46 | // title: '快捷键收起时修正定位', 47 | // commit: '推荐列表,快捷键收起时修正定位,解决部分情况下收起的内容在页面很上方的问题,方便阅读', 48 | // }, 49 | ]; 50 | -------------------------------------------------------------------------------- /src/init/init-html/configs/filter.ts: -------------------------------------------------------------------------------- 1 | import { ICommonContent } from '../types'; 2 | 3 | /** 列表内容屏蔽 */ 4 | export const FILTER_LIST: ICommonContent[][] = [ 5 | [{ label: '屏蔽顶部活动推广', value: 'removeTopAD' }], 6 | [{ label: '屏蔽匿名用户提出的问题', value: 'removeAnonymousQuestion', needFetch: true }], 7 | [ 8 | { label: '关注列表屏蔽自己的操作', value: 'removeMyOperateAtFollow' }, 9 | { label: '关注列表过滤关注人赞同回答', value: 'removeFollowVoteAnswer' }, 10 | { label: '关注列表过滤关注人赞同文章', value: 'removeFollowVoteArticle' }, 11 | { label: '关注列表过滤关注人关注问题', value: 'removeFollowFQuestion' }, 12 | ], 13 | [ 14 | { label: '列表过滤邀请回答', value: 'removeItemQuestionAsk' }, 15 | { label: '列表过滤商业推广', value: 'removeItemAboutAD' }, 16 | { label: '列表过滤文章', value: 'removeItemAboutArticle' }, 17 | { label: '列表过滤视频', value: 'removeItemAboutVideo' }, 18 | { label: '列表过滤想法', value: 'removeItemAboutPin' }, 19 | ], 20 | ]; 21 | -------------------------------------------------------------------------------- /src/init/init-html/configs/high-performance.ts: -------------------------------------------------------------------------------- 1 | import { ICommonContent } from "../types"; 2 | 3 | 4 | export const HIGH_PERFORMANCE: ICommonContent[][] = [ 5 | [ 6 | { label: '推荐列表高性能模式', value: 'highPerformanceRecommend', tooltip: '推荐列表内容最多保留50条,超出则删除之前内容' }, 7 | { label: '回答页高性能模式', value: 'highPerformanceAnswer', tooltip: '回答列表最多保留30条回答,超出则删除之前回答' }, 8 | ], 9 | ]; 10 | 11 | -------------------------------------------------------------------------------- /src/init/init-html/configs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './basic-show'; 2 | export * from './default-function'; 3 | export * from './filter'; 4 | export * from './high-performance'; 5 | 6 | -------------------------------------------------------------------------------- /src/init/init-html/init-html.ts: -------------------------------------------------------------------------------- 1 | import { createHTMLBackgroundSetting } from '../../components/background'; 2 | import { BLOCKED_USER_COMMON } from '../../components/black-list'; 3 | import { createHTMLRightTitle, initMenu } from '../../components/ctz-dialog'; 4 | import { initFetchInterceptStatus } from '../../components/fetch-intercept-status-change'; 5 | import { createHTMLHiddenConfig } from '../../components/hidden'; 6 | import { createHTMLNotInterestedList } from '../../components/not-interested'; 7 | import { createHTMLTitleICOChange } from '../../components/page-title'; 8 | import { createHTMLMySelect } from '../../components/select'; 9 | import { createHTMLSizeSetting } from '../../components/size'; 10 | import { store } from '../../store'; 11 | import { dom, domC } from '../../tools'; 12 | import { INNER_HTML } from '../../web-resources'; 13 | import { createHTMLFormBoxSwitch, createHTMLFormItem } from './common-html'; 14 | import { BASIC_SHOW, DEFAULT_FUNCTION, FILTER_LIST, HIGH_PERFORMANCE } from './configs'; 15 | 16 | /** 添加修改器内元素 */ 17 | export const initHTML = () => { 18 | const nDomMain = domC('div', { id: 'CTZ_MAIN', innerHTML: INNER_HTML }); 19 | // 版本号 20 | dom('.ctz-version', nDomMain)!.innerText = 'version: ' + GM_info.script.version; 21 | 22 | // 添加更多默认设置 23 | dom('#CTZ_DEFAULT_SELF', nDomMain)!.innerHTML = DEFAULT_FUNCTION.map(({ title, commit }) => 24 | createHTMLFormItem({ label: title, value: commit || '', extraClass: 'ctz-form-box-item-vertical' }) 25 | ).join(''); 26 | 27 | // 添加基础设置显示修改 28 | dom('#CTZ_BASIS_SHOW_CONTENT', nDomMain)!.innerHTML = createHTMLFormBoxSwitch(BASIC_SHOW); 29 | // 高性能 30 | dom('#CTZ_HIGH_PERFORMANCE', nDomMain)!.innerHTML = createHTMLFormBoxSwitch(HIGH_PERFORMANCE); 31 | // 列表内容屏蔽 32 | dom('#CTZ_FILTER_LIST_CONTENT', nDomMain)!.innerHTML = createHTMLFormBoxSwitch(FILTER_LIST); 33 | 34 | initFetchInterceptStatus(nDomMain); 35 | initMenu(nDomMain); 36 | createHTMLTitleICOChange(nDomMain); 37 | createHTMLSizeSetting(nDomMain); 38 | createHTMLBackgroundSetting(nDomMain); 39 | createHTMLHiddenConfig(nDomMain); 40 | createHTMLMySelect(nDomMain); 41 | createHTMLRightTitle(nDomMain); 42 | createHTMLNotInterestedList() 43 | 44 | dom('#CTZ_BLACKLIST_COMMON', nDomMain)!.innerHTML += createHTMLFormBoxSwitch(BLOCKED_USER_COMMON); 45 | // echoBlockedContent(nDomMain); // 回填(渲染)黑名单内容应在 echoData 中设置,保证每次打开弹窗都是最新内容 46 | appendHomeLink(nDomMain); 47 | document.body.appendChild(nDomMain); 48 | }; 49 | 50 | /** 添加个人主页跳转 */ 51 | export const appendHomeLink = (domMain: HTMLElement = document.body) => { 52 | const userInfo = store.getUserInfo(); 53 | const boxToZhihu = dom('.ctz-to-zhihu', domMain); 54 | if (dom('.ctz-home-link') || !userInfo || !boxToZhihu) return; 55 | const hrefUser = userInfo.url ? userInfo.url.replace('/api/v4', '') : ''; 56 | if (!hrefUser) return; 57 | boxToZhihu.appendChild( 58 | domC('a', { 59 | href: hrefUser, 60 | target: '_blank', 61 | innerText: '前往个人主页', 62 | className: 'ctz-home-link ctz-button', 63 | style: 'width: 100px;', 64 | }) 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/init/init-html/types.ts: -------------------------------------------------------------------------------- 1 | export interface ICommonContent { 2 | /** 名称 */ 3 | label: string; 4 | /** config 值 */ 5 | value: string; 6 | /** 是否需要开启接口拦截才能生效 */ 7 | needFetch?: boolean; 8 | /** 提示 */ 9 | tooltip?: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/init/init-image-preview.ts: -------------------------------------------------------------------------------- 1 | import { myPreview } from '../components/preview'; 2 | import { EZoomImageType } from '../components/select'; 3 | import { domA, myStorage } from '../tools'; 4 | 5 | /** 加载预览图片方法,解决部分图片无法点击预览的问题 */ 6 | export const initImagePreview = async () => { 7 | const { zoomImageType } = await myStorage.getConfig(); 8 | const images = [domA('.TitleImage:not(.ctz-processed)'), domA('.ArticleItem-image:not(.ctz-processed)'), domA('.ztext figure .content_image:not(.ctz-processed)')]; 9 | for (let i = 0, imageLen = images.length; i < imageLen; i++) { 10 | const ev = images[i]; 11 | for (let index = 0, len = ev.length; index < len; index++) { 12 | const nodeItem = ev[index] as HTMLImageElement; 13 | nodeItem.classList.add('ctz-processed'); 14 | const src = nodeItem.src || (nodeItem.style.backgroundImage && nodeItem.style.backgroundImage.split('("')[1].split('")')[0]); 15 | nodeItem.onclick = () => myPreview.open(src); 16 | } 17 | } 18 | 19 | if (zoomImageType === EZoomImageType.自定义尺寸) { 20 | const originImages = domA('.origin_image:not(.ctz-processed)'); 21 | for (let i = 0, len = originImages.length; i < len; i++) { 22 | const nodeItem = originImages[i] as HTMLImageElement; 23 | nodeItem.src = nodeItem.getAttribute('data-original') || nodeItem.src; 24 | nodeItem.classList.add('ctz-processed'); 25 | nodeItem.style.cssText = 'max-width: 100%;'; 26 | } 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/init/init-observer-resize.ts: -------------------------------------------------------------------------------- 1 | import { canCopy } from '../components/copy'; 2 | import { previewGIF } from '../components/image'; 3 | import { fnJustNumberInAction } from '../components/just-number'; 4 | import { initLinkChanger } from '../components/link'; 5 | import { myListenAnswer } from '../components/listen-answer'; 6 | import { doListenComment } from '../components/listen-comment'; 7 | import { myListenList } from '../components/listen-list'; 8 | import { myListenSearchListItem } from '../components/listen-search-list-item'; 9 | import { changeTitle } from '../components/page-title'; 10 | import { myCollectionExport } from '../components/print'; 11 | import { changeSizeBeforeResize } from '../components/size'; 12 | import { myListenUserHomeList } from '../components/user-home'; 13 | import { HTML_HOOTS } from '../misc'; 14 | import { dom, domById, myStorage, pathnameHasFn, throttle, windowResize } from '../tools'; 15 | import { initImagePreview } from './init-image-preview'; 16 | 17 | /** 使用 ResizeObserver 监听body高度 */ 18 | export const initResizeObserver = () => { 19 | const resizeObserver = new ResizeObserver(throttle(resizeFun)); 20 | resizeObserver.observe(document.body); 21 | }; 22 | 23 | async function resizeFun() { 24 | if (!HTML_HOOTS.includes(location.hostname)) return; 25 | const { hiddenSearchBoxTopSearch, globalTitle } = await myStorage.getConfig(); 26 | // 比较列表缓存的高度是否大于当前高度,如果大于则是从 index = 0 遍历 27 | const nodeTopStoryC = domById('TopstoryContent'); 28 | if (nodeTopStoryC) { 29 | const heightTopStoryContent = nodeTopStoryC.offsetHeight; 30 | if (heightTopStoryContent < 200) { 31 | // 小于200为自动加载数据(其实初始值为141) 32 | myListenList.restart(); 33 | } else { 34 | myListenList.init(); 35 | } 36 | // 如果列表模块高度小于网页高度则手动触发 resize 使其加载数据 37 | heightTopStoryContent < window.innerHeight && windowResize(); 38 | } 39 | 40 | initLinkChanger(); 41 | previewGIF(); 42 | initImagePreview(); 43 | doListenComment(); 44 | fnJustNumberInAction(); 45 | myListenSearchListItem.init(); 46 | myListenAnswer.init(); 47 | myListenUserHomeList.init(); 48 | canCopy(); 49 | changeSizeBeforeResize(); 50 | pathnameHasFn({ 51 | collection: () => myCollectionExport.init(), 52 | }); 53 | globalTitle !== document.title && changeTitle(); 54 | const nodeSearchBarInput = dom('.SearchBar-input input') as HTMLInputElement; 55 | if (hiddenSearchBoxTopSearch && nodeSearchBarInput) { 56 | nodeSearchBarInput.placeholder = ''; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/init/init-top-event-listener.ts: -------------------------------------------------------------------------------- 1 | import { answerAddBlockButton } from '../components/black-list/add-block-button'; 2 | import { addAnswerCopyLink } from '../components/link'; 3 | import { addNotInterestedItem } from '../components/not-interested'; 4 | import { printAnswer, printArticle } from '../components/print'; 5 | import { EVideoInAnswerArticle } from '../components/select'; 6 | import { updateItemTime } from '../components/time'; 7 | import { CLASS_VIDEO_ONE, CLASS_VIDEO_TWO_BOX, initVideoDownload } from '../components/video'; 8 | import { updateTopVote } from '../components/vote'; 9 | import { fnReplaceZhidaToSearch } from '../components/zhida-to-search'; 10 | import { CLASS_NOT_INTERESTED, CLASS_TO_QUESTION } from '../misc'; 11 | import { doFetchNotInterested, dom, domP, myStorage } from '../tools'; 12 | 13 | /** 顶部ROOT元素点击事件 */ 14 | export const initRootEvent = async () => { 15 | const domRoot = dom('#root'); 16 | if (!domRoot) return; 17 | domRoot.addEventListener('click', async function (event) { 18 | const config = await myStorage.getConfig(); 19 | const { fetchInterceptStatus, videoInAnswerArticle } = config; 20 | const target = event.target as HTMLElement; 21 | if (videoInAnswerArticle === EVideoInAnswerArticle.修改为链接) { 22 | // 回答内容中的视频回答替换为视频链接 23 | if (target.classList.contains(CLASS_VIDEO_ONE.replace('.', '')) || target.classList.contains(CLASS_VIDEO_TWO_BOX.replace('.', ''))) { 24 | const domVideo = target.querySelector('video'); 25 | const videoSrc = domVideo ? domVideo.src : ''; 26 | if (!videoSrc) return; 27 | window.open(videoSrc, '_blank'); 28 | } 29 | } 30 | 31 | // 点击「直达问题」按钮 32 | if (target.classList.contains(CLASS_TO_QUESTION)) { 33 | // @ts-ignore 自添加属性 34 | const { path } = target._params; 35 | path && window.open(path); 36 | } 37 | 38 | // 点击外置「不感兴趣」按钮 39 | if (target.classList.contains(CLASS_NOT_INTERESTED) && fetchInterceptStatus) { 40 | // @ts-ignore 自添加属性 41 | const { id, type, title } = target._params; 42 | doFetchNotInterested({ id, type }); 43 | const nodeTopStoryItem = domP(target, 'class', 'TopstoryItem'); 44 | nodeTopStoryItem && (nodeTopStoryItem.style.display = 'none'); 45 | addNotInterestedItem(title) 46 | } 47 | 48 | // 点击阅读全文 49 | doReadMore(target); 50 | }); 51 | }; 52 | 53 | /** 点击阅读全文后的操作 */ 54 | export const doReadMore = (currentDom: HTMLElement) => { 55 | const contentItem = currentDom.classList.contains('ContentItem') ? currentDom : currentDom.querySelector('.ContentItem') || domP(currentDom, 'class', 'ContentItem'); 56 | if (!contentItem) return; 57 | // 展开 58 | let pageType: IPageType | undefined = undefined; 59 | 60 | const domPByClass = (name: string) => domP(currentDom, 'class', name); 61 | (domPByClass('Topstory-recommend') || domPByClass('Topstory-follow') || domPByClass('zhuanlan .css-1voxft1') || domPByClass('SearchMain')) && (pageType = 'LIST'); 62 | domPByClass('Question-main') && (pageType = 'QUESTION'); 63 | domPByClass('Profile-main') && (pageType = 'USER_HOME'); 64 | doContentItem(pageType, contentItem as HTMLElement, true); 65 | }; 66 | 67 | type IPageType = 'LIST' | 'QUESTION' | 'USER_HOME'; 68 | 69 | /** 70 | * 列表、回答模块内容添加对应内容或执行了阅读全文 71 | * 例如:内容顶部显示赞同数、问题添加时间、加载视频下载方法、回答内容意见分享、替换知乎直达为搜索、添加「屏蔽用户」按钮、导出当前回答、导出当前文章等 72 | * @param config 配置 73 | * @param isRecommend 是否是列表页面 74 | * @param contentItem ContentItem 75 | * @param needTimeout 是否需要延时500ms执行 76 | */ 77 | export const doContentItem = async (pageType?: IPageType, contentItem?: HTMLElement, needTimeout = false) => { 78 | if (!contentItem || !pageType) return; 79 | const { topExportContent, fetchInterceptStatus, listItemCreatedAndModifiedTime, answerItemCreatedAndModifiedTime, userHomeContentTimeTop } = await myStorage.getConfig(); 80 | const doFun = () => { 81 | const doByPageType: Record = { 82 | LIST: () => { 83 | listItemCreatedAndModifiedTime && updateItemTime(contentItem); 84 | if (fetchInterceptStatus) { 85 | answerAddBlockButton(contentItem); 86 | } 87 | }, 88 | QUESTION: () => { 89 | answerItemCreatedAndModifiedTime && updateItemTime(contentItem); 90 | if (fetchInterceptStatus) { 91 | answerAddBlockButton(contentItem); 92 | } 93 | }, 94 | USER_HOME: () => { 95 | userHomeContentTimeTop && updateItemTime(contentItem); 96 | }, 97 | }; 98 | 99 | doByPageType[pageType](); 100 | updateTopVote(contentItem); 101 | initVideoDownload(contentItem); 102 | addAnswerCopyLink(contentItem); 103 | fnReplaceZhidaToSearch(contentItem); 104 | if (fetchInterceptStatus) { 105 | if (topExportContent) { 106 | printAnswer(contentItem); 107 | printArticle(contentItem); 108 | } 109 | } 110 | }; 111 | 112 | // 如果是回答内容,则 parentItem 设置为 nodeItem 自身 113 | if (needTimeout) { 114 | setTimeout(doFun, 500); 115 | } else { 116 | doFun(); 117 | } 118 | }; 119 | -------------------------------------------------------------------------------- /src/init/redirect.ts: -------------------------------------------------------------------------------- 1 | /** 是否需要进入重定向 */ 2 | export const needRedirect = () => { 3 | const { pathname, origin } = location; 4 | const phoneQuestion = '/tardis/sogou/qus/'; 5 | const phoneArt = '/tardis/zm/art/'; 6 | 7 | if (pathname.includes(phoneQuestion)) { 8 | const questionId = pathname.replace(phoneQuestion, ''); 9 | location.href = origin + '/question/' + questionId; 10 | return true; 11 | } 12 | 13 | if (pathname.includes(phoneArt)) { 14 | const questionId = pathname.replace(phoneArt, ''); 15 | location.href = 'https://zhuanlan.zhihu.com/p/' + questionId; 16 | return true; 17 | } 18 | return false; 19 | }; 20 | -------------------------------------------------------------------------------- /src/misc/index.ts: -------------------------------------------------------------------------------- 1 | export const HTML_HOOTS = ['www.zhihu.com', 'zhuanlan.zhihu.com']; 2 | /** class: INPUT 点击元素类名 */ 3 | export const CLASS_INPUT_CLICK = 'ctz-i'; 4 | /** class: INPUT 修改操作元素类名 */ 5 | export const CLASS_INPUT_CHANGE = 'ctz-i-change'; 6 | /** class: 不感兴趣外置按钮 */ 7 | export const CLASS_NOT_INTERESTED = 'ctz-not-interested'; 8 | /** class: 推荐列表显示「直达问题」按钮 */ 9 | export const CLASS_TO_QUESTION = 'ctz-to-question'; 10 | /** class: 自定义的时间元素名称 */ 11 | export const CLASS_TIME_ITEM = 'ctz-list-item-time'; 12 | /** class: 列表、回答内容已经监听的类名 */ 13 | export const CLASS_LISTENED = 'ctz-listened'; 14 | /** ID: 额外的弹窗 */ 15 | export const ID_EXTRA_DIALOG = 'CTZ_EXTRA_OUTPUT_DIALOG'; 16 | /** class: 知乎评论弹窗 */ 17 | export const CLASS_ZHIHU_COMMENT_DIALOG = 'css-1aq8hf9'; 18 | /** html 添加额外的类名 */ 19 | export const EXTRA_CLASS_HTML: Record = { 20 | 'zhuanlan.zhihu.com': 'zhuanlan', 21 | 'www.zhihu.com': 'zhihu', 22 | }; 23 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { IBlockedUser } from '../components/black-list'; 2 | import { EVideoInAnswerArticle } from '../components/select'; 3 | import { myStorage } from '../tools'; 4 | import { IJsInitialData, IZhihuAnswerTarget, IZhihuRecommendItem, IZhihuUserInfo } from '../types/zhihu'; 5 | 6 | /** 回答需要移除的ID和移除信息 */ 7 | interface IRecommendRemoved { 8 | id: string; 9 | message: string; 10 | } 11 | 12 | class Store { 13 | /** 用户信息 更改prev: userInfo */ 14 | userInfo: IZhihuUserInfo | undefined = undefined; 15 | /** 上一个请求的 Headers */ 16 | prevFetchHeaders: HeadersInit = {}; 17 | /** 推荐类别过滤的内容 */ 18 | removeRecommends: IRecommendRemoved[] = []; 19 | /** 评论区用户信息集合 */ 20 | commendAuthors: IBlockedUser[] = []; 21 | /** 当前用户主页的回答内容 */ 22 | userAnswers: any[] = []; 23 | /** 当前用户主页的文章内容 */ 24 | userArticle: any[] = []; 25 | /** 回答内容过滤的项 */ 26 | removeAnswers: IRecommendRemoved[] = []; 27 | /** 页面初始化的数据,取自 document.getElementById('js-initialData') */ 28 | jsInitialData: IJsInitialData | undefined = undefined; 29 | 30 | constructor() { 31 | // fix this is undefined 32 | this.setUserInfo = this.setUserInfo.bind(this); 33 | this.getUserInfo = this.getUserInfo.bind(this); 34 | this.setFetchHeaders = this.setFetchHeaders.bind(this); 35 | this.getFetchHeaders = this.getFetchHeaders.bind(this); 36 | this.findRemoveRecommends = this.findRemoveRecommends.bind(this); 37 | this.getRemoveRecommends = this.getRemoveRecommends.bind(this); 38 | this.setUserAnswer = this.setUserAnswer.bind(this); 39 | this.getUserAnswer = this.getUserAnswer.bind(this); 40 | this.setUserArticle = this.setUserArticle.bind(this); 41 | this.getUserArticle = this.getUserArticle.bind(this); 42 | this.setCommentAuthors = this.setCommentAuthors.bind(this); 43 | this.getCommentAuthors = this.getCommentAuthors.bind(this); 44 | this.findRemoveAnswers = this.findRemoveAnswers.bind(this); 45 | this.getRemoveAnswers = this.getRemoveAnswers.bind(this); 46 | this.setJsInitialData = this.setJsInitialData.bind(this); 47 | this.getJsInitialData = this.getJsInitialData.bind(this); 48 | } 49 | 50 | setUserInfo(inner: IZhihuUserInfo) { 51 | this.userInfo = inner; 52 | } 53 | getUserInfo() { 54 | return this.userInfo; 55 | } 56 | 57 | setFetchHeaders(headers: HeadersInit) { 58 | this.prevFetchHeaders = headers; 59 | } 60 | getFetchHeaders() { 61 | return this.prevFetchHeaders; 62 | } 63 | 64 | async findRemoveRecommends(recommends: IZhihuRecommendItem[]) { 65 | const { removeAnonymousQuestion, removeFromYanxuan, videoInAnswerArticle } = await myStorage.getConfig(); 66 | recommends.forEach((item) => { 67 | const target = item.target; 68 | if (!target) return; 69 | let message = ''; 70 | // 盐选专栏回答 71 | if (removeFromYanxuan && target.paid_info) { 72 | message = '选自盐选专栏的回答'; 73 | } 74 | // 匿名用于的提问 75 | if (removeAnonymousQuestion && target.question && target.question.author && !target.question.author.id) { 76 | message = '匿名用户的提问'; 77 | } 78 | 79 | if (videoInAnswerArticle === EVideoInAnswerArticle.隐藏视频 && target.attachment && target.attachment.video) { 80 | message = '已删除一条视频回答'; 81 | } 82 | 83 | if (message) { 84 | this.removeRecommends.push({ 85 | id: String(item.target.id), 86 | message, 87 | }); 88 | } 89 | }); 90 | } 91 | getRemoveRecommends() { 92 | return this.removeRecommends; 93 | } 94 | 95 | setUserAnswer(data: any[]) { 96 | this.userAnswers = data; 97 | } 98 | getUserAnswer() { 99 | return this.userAnswers; 100 | } 101 | setUserArticle(data: any[]) { 102 | this.userArticle = data; 103 | } 104 | getUserArticle() { 105 | return this.userArticle; 106 | } 107 | async setCommentAuthors(authors: IBlockedUser[]) { 108 | this.commendAuthors = authors; 109 | } 110 | getCommentAuthors() { 111 | return this.commendAuthors; 112 | } 113 | 114 | async findRemoveAnswers(answers: IZhihuAnswerTarget[]) { 115 | const { removeFromYanxuan, videoInAnswerArticle } = await myStorage.getConfig(); 116 | answers.forEach((item) => { 117 | let message = ''; 118 | if (removeFromYanxuan && item.answerType === 'paid' && item.labelInfo) { 119 | message = '已删除一条选自盐选专栏的回答'; 120 | } 121 | 122 | if (videoInAnswerArticle === EVideoInAnswerArticle.隐藏视频 && item.attachment && item.attachment.video) { 123 | message = '已删除一条视频回答'; 124 | } 125 | 126 | if (message) { 127 | this.removeAnswers.push({ 128 | id: item.id, 129 | message, 130 | }); 131 | } 132 | }); 133 | } 134 | getRemoveAnswers() { 135 | return this.removeAnswers; 136 | } 137 | 138 | setJsInitialData(data: IJsInitialData) { 139 | this.jsInitialData = data; 140 | } 141 | getJsInitialData() { 142 | return this.jsInitialData; 143 | } 144 | } 145 | 146 | export const store = new Store(); 147 | -------------------------------------------------------------------------------- /src/styles/blocked-users.less: -------------------------------------------------------------------------------- 1 | @import './common.less'; 2 | 3 | #CTA_BLOCKED_USERS, 4 | #CTZ_BLOCKED_USERS_TAGS { 5 | display: flex; 6 | flex-wrap: wrap; 7 | margin: 0 -8px -8px 0; 8 | } 9 | 10 | .ctz-black-item { 11 | height: 24px; 12 | line-height: 24px; 13 | box-sizing: content-box; 14 | padding: 2px 6px; 15 | margin: 0 8px 8px 0; 16 | display: flex; 17 | align-items: center; 18 | border-radius: 4px; 19 | border: 1px solid @gray1; 20 | background: #fff; 21 | transition: all 0.2s; 22 | 23 | a:hover { 24 | color: @primary; 25 | } 26 | 27 | .ctz-remove-block { 28 | width: 24px; 29 | height: 24px; 30 | text-align: center; 31 | border-radius: 8px; 32 | cursor: pointer; 33 | font-style: normal; 34 | &:hover { 35 | background: @gray01; 36 | } 37 | } 38 | } 39 | 40 | .ctz-black-box > button, 41 | .ctz-button-black { 42 | margin-left: 8px; 43 | } 44 | 45 | .ctz-blocked-users-tag { 46 | height: 24px; 47 | line-height: 24px; 48 | box-sizing: content-box; 49 | padding: 0 6px; 50 | margin: 0 8px 8px 0; 51 | display: flex; 52 | align-items: center; 53 | border-radius: 6px; 54 | border: 1px solid @gray1; 55 | background: #fff; 56 | } 57 | 58 | .ctz-remove-blocked-tag { 59 | &:hover { 60 | color: @red1; 61 | font-weight: 600; 62 | } 63 | .Active(); 64 | } 65 | 66 | .ctz-black-tag { 67 | padding: 0 6px; 68 | background: #000; 69 | color: #fff; 70 | font-size: 12px; 71 | border-radius: 4px; 72 | margin-left: 8px; 73 | display: inline-block; 74 | line-height: 22px; 75 | } 76 | 77 | .ctz-in-blocked-user-tag { 78 | margin-left: 4px; 79 | border-radius: 4px; 80 | font-size: 12px; 81 | border: 1px solid @blue1; 82 | color: @blue1; 83 | background: @blue01; 84 | height: 16px; 85 | line-height: 16px; 86 | padding: 0 4px; 87 | } 88 | 89 | .ctz-edit-user-tag, 90 | .ctz-edit-blocked-tag { 91 | display: inline-block; 92 | cursor: pointer; 93 | font-size: @FontSize; 94 | margin-left: 4px; 95 | .HoverText(@blue1); 96 | .Active(); 97 | } 98 | 99 | .ctz-block-user-box { 100 | button { 101 | font-size: 12px; 102 | margin-left: 8px; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/styles/button.less: -------------------------------------------------------------------------------- 1 | @import './common.less'; 2 | 3 | .ctz-button { 4 | outline: none; 5 | position: relative; 6 | display: inline-flex; 7 | align-items: center; 8 | justify-content: center; 9 | cursor: pointer; 10 | transition: all 0.3s; 11 | user-select: none; 12 | touch-action: manipulation; 13 | font-size: @FontSize; 14 | height: 24px; 15 | padding: 0px 8px; 16 | // border-radius: 6px; 17 | border-radius: 4px; 18 | border: 1px solid transparent; 19 | background-color: #fff; 20 | border-color: @gray04; 21 | font-weight: 400; 22 | box-sizing: border-box; 23 | &:hover { 24 | font-weight: 600; 25 | background: #eeeeee; 26 | } 27 | &:active { 28 | background: #e0e0e0; 29 | font-weight: 400; 30 | } 31 | } 32 | 33 | .ctz-button.ctz-button-primary { 34 | background: @primary; 35 | color: #fff; 36 | border-color: transparent; 37 | &:hover { 38 | background: @blue3; 39 | } 40 | &:active { 41 | background: @blue1; 42 | } 43 | } 44 | 45 | .ctz-button-red { 46 | color: @red1 !important; 47 | border: 1px solid @red1 !important; 48 | &:hover { 49 | color: @red2 !important; 50 | border: 1px solid @red2 !important; 51 | } 52 | } 53 | 54 | .ctz-button:disabled { 55 | border-color: #d0d0d0; 56 | background-color: rgba(0, 0, 0, 0.08); 57 | color: #b0b0b0; 58 | cursor: not-allowed; 59 | } 60 | -------------------------------------------------------------------------------- /src/styles/change-zhihu.less: -------------------------------------------------------------------------------- 1 | /** 2 | * 修改器修改的知乎页面 3 | */ 4 | @import './common.less'; 5 | 6 | // 用户修改版心宽度 7 | .Profile-mainColumn, 8 | .Collections-mainColumn, 9 | .CollectionsDetailPage-mainColumn { 10 | flex: 1; 11 | } 12 | 13 | #root { 14 | // 主页私信、同志、创作中心按钮的 margin-right 设置 15 | .css-1liaddi { 16 | margin-right: 0; 17 | } 18 | } 19 | 20 | .ContentItem-title div { 21 | display: inline; 22 | } 23 | 24 | // 原包裹 SearchBar 的 div, :empty 为内部没有元素 25 | .css-1acwmmj:empty { 26 | display: none !important; 27 | } 28 | 29 | // css-hr0k1l 为图片弹窗背景 30 | .css-hr0k1l::after { 31 | content: '点击键盘左、右按键切换图片'; 32 | position: absolute; 33 | bottom: 20px; 34 | left: 50%; 35 | transform: translateX(-50%); 36 | color: #fff; 37 | } 38 | 39 | // 知乎搜索页标题下的 XXX回答·XXX浏览 40 | .HotLanding-contentItemCount.HotLanding-contentItemCountWithoutSub { 41 | margin-top: 12px; 42 | } 43 | 44 | // 悬浮收起按钮 45 | body[data-suspension-pickup='true'] { 46 | .ContentItem-actions.Sticky.is-fixed { 47 | button[data-zop-retract-question='true'] { 48 | .Hover(@hover, #fff); 49 | .Active(); 50 | position: fixed; 51 | bottom: 50px; 52 | background: #fff; 53 | padding: 6px 12px; 54 | box-shadow: 0px 2px 8px #c9c9c9, 0px -2px 8px #ffffff; 55 | border-radius: 8px; 56 | } 57 | } 58 | } 59 | 60 | // 首页列表宽度优化 61 | .Topstory-container, 62 | .css-knqde, 63 | .Search-container { 64 | width: fit-content !important; 65 | } 66 | 67 | // 回答详情页面内容宽度优化 68 | .Question-main .Question-mainColumn, 69 | .QuestionHeader-main { 70 | flex: 1; 71 | } 72 | 73 | .Question-main { 74 | .List-item { 75 | border-bottom: 1px dashed #ddd; 76 | } 77 | .Question-sideColumn { 78 | margin-left: 12px; 79 | } 80 | 81 | .ListShortcut { 82 | flex: 1; 83 | .Question-mainColumn { 84 | width: initial; 85 | } 86 | } 87 | } 88 | 89 | .QuestionHeader { 90 | min-width: auto; // 用来解决小屏幕下顶部偏移的问题 91 | .QuestionHeader-content { 92 | margin: 0 auto; 93 | padding: 0; 94 | max-width: initial !important; 95 | } 96 | } 97 | 98 | // // 文章页面内容宽度优化 99 | // .zhuanlan .AuthorInfo, 100 | // .zhuanlan .css-1xy3kyp { 101 | // max-width: initial; 102 | // } 103 | 104 | // 图片 105 | .GifPlayer.isPlaying img { 106 | cursor: pointer !important; 107 | } 108 | 109 | // 解决隐藏元素顶部导航栏不居中的问题 110 | .AppHeader-inner { 111 | margin: 0 auto !important; 112 | padding: 0 !important; 113 | min-width: min-content !important; 114 | width: fit-content !important; 115 | } 116 | 117 | // 专栏部分宽度 118 | .zhuanlan { 119 | .Post-Row-Content-left { 120 | flex: 1; 121 | } 122 | 123 | .Post-Row-Content-right { 124 | margin-left: 10px; 125 | } 126 | 127 | .css-1pariuy, 128 | .css-44kk6u { 129 | max-width: none; 130 | } 131 | .css-9w3zhd { 132 | width: auto; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/styles/checkbox.less: -------------------------------------------------------------------------------- 1 | @import './theme.less'; 2 | 3 | @checkboxSize: 22px; 4 | @checkboxMR: 8px; 5 | 6 | .ctz-i:not(.ctz-switch)[type='checkbox'] { 7 | appearance: none; 8 | -webkit-appearance: none; 9 | -moz-appearance: none; 10 | -ms-appearance: none; 11 | -o-appearance: none; 12 | transition: all 0.2s; 13 | width: @checkboxSize; 14 | height: @checkboxSize; 15 | margin: 0; 16 | position: relative; 17 | border-radius: 4px; 18 | box-sizing: border-box; 19 | border: none; 20 | cursor: pointer; 21 | 22 | &::after { 23 | cursor: pointer; 24 | transition: all 0.2s; 25 | content: ' '; 26 | width: @checkboxSize; 27 | height: @checkboxSize; 28 | border-radius: 4px; 29 | border: 1px solid @gray04; 30 | box-sizing: border-box; 31 | left: 0px; 32 | top: 0px; 33 | z-index: 1; 34 | position: absolute; 35 | font-weight: 600; 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | } 40 | 41 | &:hover::after { 42 | border-color: @hover; 43 | } 44 | 45 | &:checked::after { 46 | content: '✓'; 47 | font-size: 16px; 48 | font-weight: 600; 49 | color: #fff; 50 | background: @primary; 51 | border-color: @primary; 52 | } 53 | } 54 | 55 | .ctz-checkbox-group { 56 | label { 57 | display: inline-flex !important; 58 | padding-right: 12px; 59 | div { 60 | margin-right: 12px; 61 | } 62 | &::after { 63 | content: ''; 64 | height: 12px; 65 | width: 1px; 66 | background: @gray04; 67 | } 68 | 69 | &:last-of-type::after { 70 | display: none; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/styles/fetch-intercept.less: -------------------------------------------------------------------------------- 1 | .ctz-fetch-intercept .ctz-need-fetch { 2 | display: none; 3 | } 4 | 5 | .ctz-fetch-intercept.ctz-fetch-intercept-close { 6 | color: #b0b0b0 !important; 7 | cursor: not-allowed !important; 8 | text-decoration: line-through; 9 | span.ctz-need-fetch { 10 | display: inline; 11 | } 12 | div.ctz-need-fetch { 13 | display: block; 14 | } 15 | .ctz-remove-block { 16 | cursor: not-allowed !important; 17 | } 18 | .ctz-black-item .ctz-remove-block:hover, 19 | .ctz-black-item a:hover { 20 | background: transparent !important; 21 | color: #b0b0b0 !important; 22 | } 23 | &:hover { 24 | color: #b0b0b0 !important; 25 | } 26 | 27 | .ctz-switch { 28 | background-color: rgba(0, 0, 0, 0.08); 29 | cursor: not-allowed !important; 30 | &::before { 31 | background: #ffffff !important; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/styles/form.less: -------------------------------------------------------------------------------- 1 | .ctz-form-box { 2 | background: #e9e9e8; 3 | border: 1px solid #dfdfde; 4 | border-radius: 8px; 5 | margin-bottom: 14px; 6 | } 7 | 8 | .ctz-form-box-item { 9 | display: flex; 10 | padding: 8px 12px; 11 | min-height: 24px; 12 | position: relative; 13 | & > div:first-of-type { 14 | flex: 1; 15 | line-height: 24px; 16 | word-break: keep-all; 17 | padding-right: 12px; 18 | } 19 | & > div:nth-child(2) { 20 | display: flex; 21 | flex-wrap: wrap; 22 | align-items: center; 23 | } 24 | 25 | &::after { 26 | content: ''; 27 | position: absolute; 28 | background: #e0e0df; 29 | height: 1px; 30 | width: 96%; 31 | bottom: 0; 32 | left: 50%; 33 | transform: translateX(-50%); 34 | } 35 | 36 | &:last-of-type::after { 37 | display: none; 38 | } 39 | } 40 | 41 | .ctz-form-box-item-vertical { 42 | display: block; 43 | & > div:nth-child(2) { 44 | display: block; 45 | padding-top: 4px; 46 | font-size: 12px; 47 | color: #999; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/styles/radio.less: -------------------------------------------------------------------------------- 1 | @import './theme.less'; 2 | 3 | .ctz-radio-group { 4 | display: flex; 5 | label { 6 | cursor: pointer; 7 | position: relative; 8 | margin: 0 !important; 9 | div { 10 | box-sizing: border-box; 11 | padding: 0 8px; 12 | height: 24px; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | border-top: 1px solid @gray04; 17 | border-bottom: 1px solid @gray04; 18 | position: relative; 19 | &::after { 20 | content: ''; 21 | position: absolute; 22 | height: 100%; 23 | width: 1px; 24 | background: @gray04; 25 | right: -0; 26 | top: 0; 27 | } 28 | } 29 | &:first-of-type { 30 | div { 31 | border-radius: 8px 0 0 8px; 32 | border-left: 1px solid @gray04; 33 | &::before { 34 | display: none; 35 | } 36 | } 37 | } 38 | &:last-of-type { 39 | div { 40 | border-radius: 0 8px 8px 0; 41 | border-right: 1px solid @gray04; 42 | &::after { 43 | display: none; 44 | } 45 | } 46 | } 47 | &:hover { 48 | div { 49 | background: @blue01; 50 | } 51 | } 52 | } 53 | 54 | input { 55 | visibility: hidden; 56 | position: absolute; 57 | } 58 | 59 | input:checked + div { 60 | background: @primary; 61 | color: #fff; 62 | border-color: @primary; 63 | z-index: 1; 64 | &::after { 65 | background: @primary; 66 | z-index: 1; 67 | } 68 | &::before { 69 | content: ''; 70 | position: absolute; 71 | height: 100%; 72 | width: 1px; 73 | background: @primary; 74 | left: 0; 75 | top: 0; 76 | z-index: 1; 77 | } 78 | } 79 | } 80 | 81 | .ctz-radio { 82 | display: inline-block; 83 | padding-left: 24px; 84 | line-height: 24px; 85 | input[type='radio'] { 86 | display: none; 87 | 88 | & + div { 89 | position: relative; 90 | cursor: pointer; 91 | &::before { 92 | content: ''; 93 | position: absolute; 94 | left: -20px; 95 | top: 4px; 96 | border-radius: 50%; 97 | border: 1px solid #cecece; 98 | width: 14px; 99 | height: 14px; 100 | background: #fff; 101 | box-shadow: inset 5px 5px 5px #f0f0f0, inset -5px -5px 5px #ffffff; 102 | } 103 | &::after { 104 | content: ''; 105 | position: absolute; 106 | left: -16px; 107 | top: 8px; 108 | border-radius: 50%; 109 | width: 8px; 110 | height: 8px; 111 | } 112 | } 113 | &:checked { 114 | + div { 115 | &::before { 116 | background: @blue1; 117 | border-color: @blue1; 118 | box-shadow: none; 119 | } 120 | &::after { 121 | background: #fff; 122 | } 123 | } 124 | } 125 | &:focus { 126 | + div::before { 127 | box-shadow: 0 0px 8px @blue1; 128 | } 129 | } 130 | &:disabled { 131 | + div::before { 132 | border: 1px solid #cecece; 133 | box-shadow: 0 0px 4px #ddd; 134 | } 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/styles/range.less: -------------------------------------------------------------------------------- 1 | @import './common.less'; 2 | 3 | #CTZ_DIALOG { 4 | input[type='range'] { 5 | /* 设置滑块下面那条线的样式 */ 6 | outline: none; /* 去掉点击时出现的外边框 */ 7 | -webkit-appearance: none; 8 | -moz-appearance: none; 9 | appearance: none; /* 这三个是去掉那条线原有的默认样式 */ 10 | height: 6px; 11 | border-radius: 8px; 12 | background: #dddddc; 13 | position: relative; 14 | box-shadow: inset 1px 1px 2px #d4d4d3, inset -1px -1px 2px #d4d4d3; 15 | &::before, 16 | &::after { 17 | content: ''; 18 | background: #c6c6c5; 19 | position: absolute; 20 | height: 10px; 21 | width: 3px; 22 | border-radius: 4px; 23 | top: -2px; 24 | } 25 | 26 | &::before { 27 | left: -2px; 28 | } 29 | 30 | &::after { 31 | right: -2px; 32 | } 33 | 34 | &::-webkit-slider-thumb { 35 | /* ::-webkit-slider-thumb 是代表给滑块的样式进行变更*/ 36 | -webkit-appearance: none; 37 | -moz-appearance: none; 38 | appearance: none; /* 这三个是去掉滑块原有的默认样式 */ 39 | transition: all 0.2s; 40 | width: 10px; 41 | height: 25px; 42 | border-radius: 16px; 43 | background: #fff; 44 | border: 1px solid #c7c7c6; 45 | z-index: 5; 46 | // box-shadow: 3px 3px 6px #f5f5f5, -3px -3px 6px #f5f5f5; 47 | &:active { 48 | background: #f0f0f0; 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/styles/select.less: -------------------------------------------------------------------------------- 1 | @import './common.less'; 2 | 3 | .ctz-select { 4 | position: relative; 5 | width: fit-content; 6 | } 7 | 8 | .ctz-select-input { 9 | background: transparent; 10 | text-align: right; 11 | height: 22px; 12 | border-radius: 6px; 13 | border: 1px solid transparent; 14 | padding: 0 8px; 15 | line-height: 22px; 16 | cursor: pointer; 17 | &:hover { 18 | background: #ffffff; 19 | border: 1px solid #e0e0e0; 20 | } 21 | } 22 | 23 | .ctz-select-icon { 24 | margin-left: 4px; 25 | } 26 | 27 | .ctz-option-box { 28 | position: absolute; 29 | top: 24px; 30 | right: 0; 31 | background: #e9e9e8; 32 | z-index: 10; 33 | padding: 6px; 34 | border-radius: 6px; 35 | border: 1px solid #e0e0e0; 36 | box-shadow: 2px 2px 4px #dbdbdb, -2px -2px 4px #dbdbdb; 37 | } 38 | 39 | .ctz-option-item { 40 | white-space: pre; 41 | cursor: default; 42 | padding: 0 6px 0 24px; 43 | border-radius: 4px; 44 | height: 24px; 45 | line-height: 24px; 46 | position: relative; 47 | &:hover { 48 | color: #fff; 49 | background: @hover; 50 | } 51 | 52 | &[data-choose="true"] { 53 | &::before { 54 | content: '✓'; 55 | position: absolute; 56 | left: 6px; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/styles/setting-background.less: -------------------------------------------------------------------------------- 1 | @import './common.less'; 2 | 3 | @BorderWidth: 4px; 4 | 5 | #CTZ_BACKGROUND { 6 | gap: 12px; 7 | } 8 | 9 | .ctz-background-item { 10 | position: relative; 11 | @width: 68px; 12 | @height: 46px; 13 | 14 | input { 15 | position: absolute; 16 | visibility: hidden; 17 | &:checked + div + div { 18 | border-color: @blue1; 19 | } 20 | 21 | &:checked + div + div + div { 22 | color: #272726; 23 | } 24 | } 25 | 26 | .ctz-background-item-div { 27 | border-radius: 8px; 28 | height: @height; 29 | width: @width; 30 | margin: @BorderWidth; 31 | } 32 | 33 | .ctz-background-item-border { 34 | height: @height; 35 | width: @width; 36 | border-radius: 12px; 37 | position: absolute; 38 | top: 0; 39 | left: 0; 40 | border: @BorderWidth solid transparent; 41 | } 42 | } 43 | 44 | .ctz-background-item-name { 45 | font-size: 12px; 46 | text-align: center; 47 | padding-top: 8px; 48 | color: #777776; 49 | } 50 | 51 | #CTZ_BACKGROUND_LIGHT, 52 | #CTZ_BACKGROUND_DARK { 53 | gap: 10px; 54 | padding: 4px 4px 24px 0; 55 | 56 | .ctz-background-item { 57 | position: relative; 58 | @r: 18px; 59 | 60 | input { 61 | position: absolute; 62 | visibility: hidden; 63 | &:checked + div + div, 64 | &:checked + div + div + div { 65 | opacity: 1; 66 | } 67 | } 68 | &-div { 69 | height: @r; 70 | width: @r; 71 | border-radius: 50%; 72 | margin: 0; 73 | } 74 | 75 | &-border { 76 | height: calc(@r - (@BorderWidth * 2)); 77 | width: calc(@r - (@BorderWidth * 2)); 78 | border-radius: 50%; 79 | position: absolute; 80 | top: 0; 81 | left: 0; 82 | background: #fff; 83 | opacity: 0; 84 | } 85 | 86 | &-name { 87 | font-size: 12px; 88 | text-align: center; 89 | padding-top: 8px; 90 | color: #777776; 91 | opacity: 0; 92 | position: absolute; 93 | word-break: keep-all; 94 | left: 50%; 95 | transform: translateX(-50%); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/styles/setting-block-words.less: -------------------------------------------------------------------------------- 1 | #CTZ_BLOCK_WORDS { 2 | padding-top: 0 !important; 3 | } 4 | 5 | .ctz-block-words-content { 6 | display: flex; 7 | flex-wrap: wrap; 8 | cursor: default; 9 | margin-bottom: -4px; 10 | & > span { 11 | padding: 0px 6px; 12 | border-radius: 4px; 13 | font-size: @FontSize; 14 | margin: 0 4px 4px 0; 15 | border: 1px solid @gray04; 16 | cursor: pointer; 17 | background: #fff; 18 | &:hover { 19 | color: @waring; 20 | border-color: @waring; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/styles/setting-default.less: -------------------------------------------------------------------------------- 1 | // 默认功能 2 | #CTZ_DEFAULT_SELF { 3 | a { 4 | color: @blue1; 5 | &:hover { 6 | color: #bbb; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/styles/switch.less: -------------------------------------------------------------------------------- 1 | @import './common.less'; 2 | 3 | .ctz-switch { 4 | width: 40px; 5 | height: 24px; 6 | position: relative; 7 | background-color: #dcdfe6; 8 | // border-radius: 20px; 9 | border-radius: 6px; 10 | background-clip: content-box; 11 | display: inline-block; 12 | appearance: none; 13 | -webkit-appearance: none; 14 | -moz-appearance: none; 15 | user-select: none; 16 | outline: none; 17 | margin: 0; 18 | cursor: pointer; 19 | 20 | &::before { 21 | content: ''; 22 | position: absolute; 23 | width: 22px; 24 | height: 22px; 25 | background-color: #ffffff; 26 | // border-radius: 50%; 27 | border-radius: 5px; 28 | left: 2px; 29 | top: 0; 30 | bottom: 0; 31 | margin: auto; 32 | transition: 0.3s; 33 | } 34 | 35 | &:checked { 36 | background-color: @blue1; 37 | transition: 0.6s; 38 | } 39 | 40 | &:checked::before { 41 | left: 17px; 42 | transition: 0.3s; 43 | } 44 | 45 | &:hover::before { 46 | background: #f0f0f0; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/styles/theme.less: -------------------------------------------------------------------------------- 1 | /** 红色 **/ 2 | @red1: rgb(255, 59, 48); 3 | @red2: rgb(255, 69, 58); 4 | @red3: rgb(215, 0, 21); 5 | @red4: rgb(255, 105, 97); 6 | @red01: rgba(255, 59, 48, 0.1); 7 | 8 | /** 橙色 **/ 9 | @origin1: rgb(255, 149, 0); 10 | @origin2: rgb(255, 159, 10); 11 | @origin3: rgb(201, 52, 0); 12 | @origin4: rgb(255, 179, 64); 13 | @origin01: rgba(255, 179, 64, 0.1); 14 | 15 | /** 黄色 **/ 16 | @yellow1: rgb(160, 90, 0); 17 | @yellow2: rgb(255, 214, 10); 18 | @yellow3: rgb(255, 204, 0); 19 | @yellow4: rgb(255, 212, 38); 20 | @yellow01: rgba(160, 90, 0, 0.1); 21 | 22 | /** 绿色 **/ 23 | @green1: rgb(0, 125, 27); 24 | @green2: rgb(50, 215, 75); 25 | @green3: rgb(40, 205, 65); 26 | @green4: rgb(49, 222, 75); 27 | @green01: rgba(0, 125, 27, 0.1); 28 | 29 | /** 蓝色 **/ 30 | @blue1: rgb(0, 122, 255); 31 | @blue2: rgb(10, 132, 255); 32 | @blue3: rgb(0, 64, 221); 33 | @blue4: rgb(64, 156, 255); 34 | @blue01: rgba(0, 122, 255, 0.1); 35 | 36 | /** 紫色 **/ 37 | @purple1: rgb(175, 82, 222); 38 | @purple2: rgb(191, 90, 242); 39 | @purple3: rgb(173, 68, 171); 40 | @purple4: rgb(218, 143, 255); 41 | @purple01: rgba(175, 82, 222, 0.1); 42 | 43 | /** 灰色 **/ 44 | @gray1: rgb(142, 142, 147); 45 | @gray2: rgb(145, 145, 157); 46 | @gray3: rgb(105, 105, 110); 47 | @gray4: rgb(152, 152, 157); 48 | @gray01: rgba(142, 142, 147, 0.1); 49 | @gray02: rgba(150, 162, 170, 0.2); 50 | @gray04: rgba(150, 162, 170, 0.4); 51 | 52 | @hover: @blue1; 53 | @primary: @blue1; 54 | 55 | @waring: @red1; 56 | @waringBg: @red01; 57 | 58 | @FontSize: 13px; -------------------------------------------------------------------------------- /src/styles/title.less: -------------------------------------------------------------------------------- 1 | @import './common.less'; 2 | 3 | .ctz-title { 4 | font-weight: bold; 5 | font-size: @FontSize; 6 | display: flex; 7 | align-items: center; 8 | height: 42px; 9 | line-height: 42px; 10 | padding-left: 10px; 11 | & > span { 12 | font-size: 12px; 13 | color: #999; 14 | padding-left: 8px; 15 | b { 16 | color: @waring; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/styles/tooltip.less: -------------------------------------------------------------------------------- 1 | @import './common.less'; 2 | 3 | .ctz-tooltip { 4 | position: relative; 5 | display: inline-block; 6 | margin-left: 4px; 7 | & > span:first-child { 8 | display: inline-block; 9 | font-size: 12px; 10 | border-radius: 50%; 11 | border: 1px solid @gray4; 12 | color: @gray4; 13 | width: 12px; 14 | height: 12px; 15 | display: inline-flex; 16 | align-items: center; 17 | justify-content: center; 18 | cursor: pointer; 19 | } 20 | & > span:last-child { 21 | display: none; 22 | position: absolute; 23 | top: 30px; 24 | left: -50px; 25 | background-color: #515151; 26 | color: #fff; 27 | padding: 8px 12px; 28 | z-index: 10; 29 | border-radius: 6px; 30 | width: max-content; 31 | line-height: 24px; 32 | &::after { 33 | content: ''; 34 | width: 0; 35 | height: 0; 36 | position: absolute; 37 | border-bottom: 6px solid #515151; 38 | border-left: 8px solid transparent; 39 | border-right: 8px solid transparent; 40 | top: -6px; 41 | left: 50px; 42 | } 43 | } 44 | 45 | &:hover { 46 | & > span:first-child { 47 | border-color: @blue1; 48 | color: @blue1; 49 | } 50 | 51 | & > span:last-child { 52 | display: block; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/tools/browser.ts: -------------------------------------------------------------------------------- 1 | /** 判断浏览器环境 */ 2 | const judgeBrowserType = () => { 3 | const userAgent = navigator.userAgent; 4 | if (userAgent.includes('Firefox')) return 'Firefox'; 5 | if (userAgent.includes('Edg')) return 'Edge'; 6 | if (userAgent.includes('Chrome')) return 'Chrome'; 7 | return 'Safari'; 8 | }; 9 | 10 | /** 当前浏览器是否为 Safari */ 11 | export const isSafari = judgeBrowserType() === 'Safari'; 12 | -------------------------------------------------------------------------------- /src/tools/do-window-resize.ts: -------------------------------------------------------------------------------- 1 | /** 手动触发页面尺寸变更方法 */ 2 | export const windowResize = () => { 3 | window.dispatchEvent(new Event('resize')); 4 | }; 5 | -------------------------------------------------------------------------------- /src/tools/dom.ts: -------------------------------------------------------------------------------- 1 | /** 获取元素 */ 2 | export const dom = (n: string, find: HTMLElement | Document = document): HTMLElement | undefined => (find ? (find.querySelector(n) as HTMLElement) : undefined); 3 | /** 使用 Id 获取元素 */ 4 | export const domById = (id: string): HTMLElement | undefined => document.getElementById(id) as HTMLElement; 5 | /** 获取所有元素 */ 6 | export const domA = (n: string, find: HTMLElement | Document = document): NodeListOf => find.querySelectorAll(n); 7 | /** 8 | * 创建元素 9 | * attrObjs - className 10 | */ 11 | export const domC = (name: string, attrObjs: Record) => { 12 | const node = document.createElement(name); 13 | for (let key in attrObjs) { 14 | // @ts-ignore 15 | node[key] = attrObjs[key]; 16 | } 17 | return node; 18 | }; 19 | 20 | /** 21 | * 查找父级元素 22 | * @param node 元素 23 | * @param attrName 例如 'class' 24 | * @param attrValue 例如 class 名 25 | * @returns HTMLElement | undefined 26 | */ 27 | export const domP = (node: any, attrName: string, attrValue: string): HTMLElement | undefined => { 28 | const nodeP = node.parentElement as HTMLElement; 29 | if (!nodeP) return undefined; 30 | if (!attrName || !attrValue) return nodeP; 31 | if (nodeP === document.body) return undefined; 32 | const attrValueList = (nodeP.getAttribute(attrName) || '').split(' '); 33 | return attrValueList.includes(attrValue) ? nodeP : domP(nodeP, attrName, attrValue); 34 | }; 35 | 36 | export const insertAfter = (newElement: any, targetElement: any) => { 37 | const parent = targetElement.parentNode; 38 | if (parent.lastChild === targetElement) { 39 | parent.appendChild(newElement); 40 | } else { 41 | parent.insertBefore(newElement, targetElement.nextSibling); 42 | } 43 | }; 44 | 45 | /** 判断是否返回空字符串 */ 46 | export const fnReturnStr = (str: string, isHave = false, strFalse = '') => (isHave ? str : strFalse); 47 | 48 | /** 带前缀的 log */ 49 | export const fnLog = (...str: string[]) => console.log('%c「知乎修改器」', 'color: green;font-weight: bold;', ...str); 50 | 51 | /** 注入样式文件的方法 */ 52 | export const fnAppendStyle = (id: string, innerHTML: string) => { 53 | const element = domById(id); 54 | element ? (element.innerHTML = innerHTML) : document.head.appendChild(domC('style', { id, type: 'text/css', innerHTML })); 55 | }; 56 | 57 | /** 元素属性替换 */ 58 | export const fnDomReplace = (node: any, attrObjs: Record) => { 59 | if (!node) return; 60 | for (let key in attrObjs) { 61 | node[key] = attrObjs[key]; 62 | } 63 | }; 64 | 65 | /** 66 | * 创建按钮,font-size: 12px 67 | * @param {string} innerHTML 按钮内容 68 | * @param {string} extraCLass 按钮额外类名 69 | * @returns {HTMLElement} 元素 70 | */ 71 | export const createButtonFontSize12 = (innerHTML: string, extraCLass: string = '', extra: Record = {}): HTMLElement => 72 | domC('button', { 73 | innerHTML, 74 | className: `ctz-button ${extraCLass}`, 75 | style: 'margin-left: 8px;font-size: 12px;', 76 | ...extra, 77 | }); 78 | -------------------------------------------------------------------------------- /src/tools/fetch.ts: -------------------------------------------------------------------------------- 1 | import { store } from '../store'; 2 | import { fnLog } from './dom'; 3 | 4 | /** 调用「不感兴趣」接口 */ 5 | export const doFetchNotInterested = ({ id, type }: { id: string; type: string }) => { 6 | const nHeader = store.getFetchHeaders() as Record; 7 | delete nHeader['vod-authorization']; 8 | delete nHeader['content-encoding']; 9 | delete nHeader['Content-Type']; 10 | delete nHeader['content-type']; 11 | const idToNum = +id; 12 | if (String(idToNum) === 'NaN') { 13 | fnLog(`调用不感兴趣接口错误,id为NaN, 原ID:${id}`); 14 | return; 15 | } 16 | fetch('/api/v3/feed/topstory/uninterestv2', { 17 | body: `item_brief=${encodeURIComponent(JSON.stringify({ source: 'TS', type: type, id: idToNum }))}`, 18 | method: 'POST', 19 | headers: new Headers({ 20 | ...nHeader, 21 | 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 22 | }), 23 | }).then((res) => res.json()); 24 | }; 25 | 26 | /** 拦截请求 */ 27 | export const interceptionResponse = (res: Response, pathRegexp: RegExp, fn: (r: any) => void) => { 28 | if (pathRegexp.test(res.url)) { 29 | res 30 | .clone() 31 | .json() 32 | .then((r) => fn(r)); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/tools/format-data-to-hump.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 下划线转驼峰 3 | * if_answer_is_had ---> isAnswerIsHad 4 | */ 5 | export function formatDataToHump(data: any): any { 6 | if (!data) return data; 7 | if (Array.isArray(data)) { 8 | return data.map((item) => { 9 | return typeof item === 'object' ? formatDataToHump(item) : item; 10 | }); 11 | } else if (typeof data === 'object') { 12 | const nData: any = {}; 13 | Object.keys(data).forEach((prevKey) => { 14 | const nKey = prevKey.replace(/\_(\w)/g, (_, $1) => $1.toUpperCase()); 15 | nData[nKey] = formatDataToHump(data[prevKey]); 16 | }); 17 | return nData; 18 | } 19 | return data; 20 | } 21 | -------------------------------------------------------------------------------- /src/tools/import-file.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Input type="file" 类型的导入文件方法 3 | * @param domInput 元素 4 | * @param callBack 导入完成的回调函数 5 | */ 6 | export const inputImportFile = (domInput: HTMLInputElement, callBack: (ev: ProgressEvent) => void) => { 7 | domInput.onchange = (e: Event) => { 8 | const target = e.target as HTMLInputElement; 9 | const configFile = (target.files || [])[0]; 10 | if (!configFile) return; 11 | const reader = new FileReader(); 12 | reader.readAsText(configFile); 13 | reader.onload = callBack; 14 | target.value = ''; 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | export * from './browser'; 2 | export * from './do-window-resize'; 3 | export * from './dom'; 4 | export * from './fetch'; 5 | export * from './format-data-to-hump'; 6 | export * from './import-file'; 7 | export * from './math-for-my-listens'; 8 | export * from './message'; 9 | export * from './mouse-event-click'; 10 | export * from './pathname-fn'; 11 | export * from './percent'; 12 | export * from './scroll-stop-on'; 13 | export * from './storage'; 14 | export * from './throttle'; 15 | export * from './time'; 16 | 17 | -------------------------------------------------------------------------------- /src/tools/math-for-my-listens.ts: -------------------------------------------------------------------------------- 1 | import { fnLog } from './dom'; 2 | 3 | export const CTZ_HIDDEN_ITEM_CLASS = 'ctz-hidden-item'; 4 | 5 | export const fnHidden = (ev: HTMLElement, msg: string) => { 6 | ev.style.display = 'none'; 7 | ev.classList.add(CTZ_HIDDEN_ITEM_CLASS); 8 | fnLog(msg); 9 | }; 10 | -------------------------------------------------------------------------------- /src/tools/message.ts: -------------------------------------------------------------------------------- 1 | import { dom, domById, domC } from './dom'; 2 | 3 | /** class: 消息提示弹窗 */ 4 | export const CLASS_MESSAGE = 'ctz-message'; 5 | 6 | const messageDoms: HTMLElement[] = []; 7 | /** 8 | * 信息提示框 9 | * @param {string} value 信息内容 10 | * @param {number} t 存在时间 11 | */ 12 | export const message = (value: string, t: number = 3000) => { 13 | const time = +new Date(); 14 | const classTime = `ctz-message-${time}`; 15 | const nDom = domC('div', { 16 | innerHTML: value, 17 | className: `${CLASS_MESSAGE} ${classTime}`, 18 | }); 19 | const domBox = domById('CTZ_MESSAGE_BOX'); 20 | if (!domBox) return; 21 | domBox.appendChild(nDom); 22 | messageDoms.push(nDom); 23 | if (messageDoms.length > 3) { 24 | const prevDom = messageDoms.shift(); 25 | prevDom && domBox.removeChild(prevDom); 26 | } 27 | setTimeout(() => { 28 | const nPrevDom = dom(`.${classTime}`); 29 | if (nPrevDom) { 30 | domById('CTZ_MESSAGE_BOX')!.removeChild(nPrevDom); 31 | messageDoms.shift(); 32 | } 33 | }, t); 34 | }; 35 | -------------------------------------------------------------------------------- /src/tools/mouse-event-click.ts: -------------------------------------------------------------------------------- 1 | import { isSafari } from "./browser"; 2 | 3 | /** 4 | * 模拟鼠标点击 5 | * @param {HTMLElement} element 需要点击的元素 6 | */ 7 | export const mouseEventClick = (element?: HTMLElement) => { 8 | if (!element) return; 9 | const myWindow = isSafari ? window : unsafeWindow; 10 | const event = new MouseEvent('click', { 11 | view: myWindow, 12 | bubbles: true, 13 | cancelable: true, 14 | }); 15 | element.dispatchEvent(event); 16 | }; 17 | -------------------------------------------------------------------------------- /src/tools/pathname-fn.ts: -------------------------------------------------------------------------------- 1 | /** 判断 pathname 匹配的项并运行对应方法 */ 2 | export const pathnameHasFn = (obj: Record) => { 3 | const { pathname } = location; 4 | for (let name in obj) { 5 | pathname.includes(name) && obj[name](); 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /src/tools/percent.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Promise.all 百分比进度 3 | * @param requests 异步方法数据 4 | * @param callback 回调函数 5 | */ 6 | export const promisePercent = (requests: any[] = [], callback: (index: number) => void): Promise => { 7 | let index = 0; 8 | requests.forEach((item) => { 9 | item.then(() => { 10 | index++; 11 | callback(index); 12 | }); 13 | }); 14 | return Promise.all(requests); 15 | }; 16 | -------------------------------------------------------------------------------- /src/tools/scroll-stop-on.ts: -------------------------------------------------------------------------------- 1 | import { dom } from "./dom"; 2 | 3 | /** 在打开弹窗时候停止页面滚动,只允许弹窗滚动 */ 4 | export const myScroll = { 5 | stop: () => dom('body')!.classList.add('ctz-stop-scroll'), 6 | on: () => dom('body')!.classList.remove('ctz-stop-scroll'), 7 | }; -------------------------------------------------------------------------------- /src/tools/storage.ts: -------------------------------------------------------------------------------- 1 | import { SAVE_HISTORY_NUMBER } from '../config'; 2 | import { IPfConfig, IPfHistory } from '../config/types'; 3 | 4 | /** 使用 localStorage + GM 存储,解决跨域存储配置不同的问题 */ 5 | export const myStorage = { 6 | set: async function (name: string, value: Record) { 7 | value.t = +new Date(); 8 | const v = JSON.stringify(value); 9 | localStorage.setItem(name, v); 10 | await GM.setValue(name, v); 11 | }, 12 | get: async function (name: string) { 13 | const config = await GM.getValue(name); 14 | const configLocal = localStorage.getItem(name); 15 | const cParse = config ? JSON.parse(config) : null; 16 | const cLParse = configLocal ? JSON.parse(configLocal) : null; 17 | if (!cParse && !cLParse) return ''; 18 | if (!cParse) return configLocal; 19 | if (!cLParse) return config; 20 | if (cParse.t < cLParse.t) return configLocal; 21 | return config; 22 | }, 23 | getConfig: async function (): Promise { 24 | const nConfig = await this.get('pfConfig'); 25 | return Promise.resolve(nConfig ? JSON.parse(nConfig) : {}); 26 | }, 27 | getHistory: async function (): Promise { 28 | const nHistory = await myStorage.get('pfHistory'); 29 | const h = nHistory ? JSON.parse(nHistory) : { list: [], view: [] }; 30 | return Promise.resolve(h); 31 | }, 32 | /** 修改配置中的值 */ 33 | updateConfigItem: async function (key: string | Record, value?: any) { 34 | const config = await this.getConfig(); 35 | if (typeof key === 'string') { 36 | config[key] = value; 37 | } else { 38 | for (let itemKey in key) { 39 | config[itemKey] = key[itemKey]; 40 | } 41 | } 42 | await this.updateConfig(config); 43 | }, 44 | /** 更新配置 */ 45 | updateConfig: async function (params: IPfConfig) { 46 | await this.set('pfConfig', params); 47 | }, 48 | updateHistoryItem: async function (key: 'list' | 'view', params: string[]) { 49 | const pfHistory = await this.getHistory(); 50 | pfHistory[key] = params.slice(0, SAVE_HISTORY_NUMBER); 51 | await this.set('pfHistory', pfHistory); 52 | }, 53 | updateHistory: async function (value: IPfHistory) { 54 | await this.set('pfHistory', value); 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /src/tools/throttle.ts: -------------------------------------------------------------------------------- 1 | /** 节流, 使用时 fn 需要为 function () {} */ 2 | export function throttle(fn: Function, time = 300) { 3 | let tout: NodeJS.Timeout | undefined = undefined; 4 | return function () { 5 | clearTimeout(tout); 6 | tout = setTimeout(() => { 7 | // @ts-ignore 8 | fn.apply(this, arguments); 9 | }, time); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/tools/time.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 时间格式化 3 | * @param t 传入的时间 4 | * @param f 时间格式 5 | * @param showTimeFromNow 是否显示距离当前时间 6 | */ 7 | export const formatTime = (t: string | number, f = 'YYYY-MM-DD HH:mm:ss', showTimeFromNow = false): string => { 8 | if (!t) return ''; 9 | const d = new Date(t); 10 | const year = d.getFullYear(); 11 | const month = d.getMonth() + 1; 12 | const day = d.getDate(); 13 | const hour = d.getHours(); 14 | const min = d.getMinutes(); 15 | const sec = d.getSeconds(); 16 | const preArr = (num: number) => (String(num).length !== 2 ? '0' + String(num) : String(num)); 17 | 18 | const strDate = f 19 | .replace(/YYYY/g, String(year)) 20 | .replace(/MM/g, preArr(month)) 21 | .replace(/DD/g, preArr(day)) 22 | .replace(/HH/g, preArr(hour)) 23 | .replace(/mm/g, preArr(min)) 24 | .replace(/ss/g, preArr(sec)); 25 | 26 | if (showTimeFromNow) { 27 | return strDate + `(${timeFromNow(t)})`; 28 | } 29 | 30 | return strDate; 31 | }; 32 | 33 | export const timeFromNow = (t: string | number) => { 34 | if (!t) return ''; 35 | const d = new Date(t); 36 | const year = d.getFullYear(); 37 | 38 | const prevTimestamp = +new Date(t); 39 | const now = new Date(); 40 | const nowTimestamp = +now; 41 | const nowYear = now.getFullYear(); 42 | 43 | const fromNow = nowTimestamp - prevTimestamp; 44 | 45 | // 一分钟内 46 | if (fromNow <= 1000 * 60) { 47 | return '刚刚'; 48 | } 49 | 50 | // 一小时内 51 | if (fromNow <= 1000 * 60 * 60) { 52 | return `${Math.floor(fromNow / 1000 / 60)}分钟前`; 53 | } 54 | 55 | // 一天内 56 | if (fromNow <= 1000 * 60 * 60 * 24) { 57 | return `${Math.floor(fromNow / 1000 / 60 / 60)}小时前`; 58 | } 59 | 60 | // 一个月内 61 | if (fromNow <= 1000 * 60 * 60 * 24 * 31) { 62 | return `${Math.floor(fromNow / 1000 / 60 / 60 / 24)}天前`; 63 | } 64 | 65 | // 一年内 66 | if (fromNow <= 1000 * 60 * 60 * 24 * 365) { 67 | return `${Math.floor(fromNow / 1000 / 60 / 60 / 24 / 30)}个月前`; 68 | } 69 | 70 | return `${nowYear - year}年前`; 71 | }; 72 | -------------------------------------------------------------------------------- /src/types/common.type.ts: -------------------------------------------------------------------------------- 1 | export interface IOptionItem { 2 | label: string; 3 | value: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const GM: { 2 | setValue: (key: string, value: string) => Promise; 3 | getValue: (key: string) => Promise; 4 | deleteValue: (key: string) => Promise; 5 | }; 6 | 7 | // declare const window: Window & { 8 | // [key: string]: any; 9 | // }; 10 | declare const GM_info: ScriptGetInfo; 11 | declare const GM_registerMenuCommand: (menuName: string, callback?: () => void, options?: Record) => void; 12 | 13 | type ScriptGetInfo = { 14 | downloadMode: string; 15 | isFirstPartyIsolation?: boolean; 16 | isIncognito: boolean; 17 | sandboxMode: SandboxMode; 18 | scriptHandler: string; 19 | scriptMetaStr: string | null; 20 | scriptUpdateURL: string | null; 21 | scriptWillUpdate: boolean; 22 | version?: string; 23 | script: { 24 | antifeatures: { [antifeature: string]: { [locale: string]: string } }; 25 | author: string | null; 26 | blockers: string[]; 27 | connects: string[]; 28 | copyright: string | null; 29 | deleted?: number | undefined; 30 | description_i18n: { [locale: string]: string } | null; 31 | description: string; 32 | downloadURL: string | null; 33 | excludes: string[]; 34 | fileURL: string | null; 35 | grant: string[]; 36 | header: string | null; 37 | homepage: string | null; 38 | icon: string | null; 39 | icon64: string | null; 40 | includes: string[]; 41 | lastModified: number; 42 | matches: string[]; 43 | name_i18n: { [locale: string]: string } | null; 44 | name: string; 45 | namespace: string | null; 46 | position: number; 47 | resources: Resource[]; 48 | supportURL: string | null; 49 | system?: boolean | undefined; 50 | 'run-at': string | null; 51 | unwrap: boolean | null; 52 | updateURL: string | null; 53 | version: string; 54 | webRequest: WebRequestRule[] | null; 55 | options: { 56 | check_for_updates: boolean; 57 | comment: string | null; 58 | compatopts_for_requires: boolean; 59 | compat_wrappedjsobject: boolean; 60 | compat_metadata: boolean; 61 | compat_foreach: boolean; 62 | compat_powerful_this: boolean | null; 63 | sandbox: string | null; 64 | noframes: boolean | null; 65 | unwrap: boolean | null; 66 | run_at: string | null; 67 | tab_types: string | null; 68 | override: { 69 | use_includes: string[]; 70 | orig_includes: string[]; 71 | merge_includes: boolean; 72 | use_matches: string[]; 73 | orig_matches: string[]; 74 | merge_matches: boolean; 75 | use_excludes: string[]; 76 | orig_excludes: string[]; 77 | merge_excludes: boolean; 78 | use_connects: string[]; 79 | orig_connects: string[]; 80 | merge_connects: boolean; 81 | use_blockers: string[]; 82 | orig_run_at: string | null; 83 | orig_noframes: boolean | null; 84 | }; 85 | }; 86 | }; 87 | }; 88 | 89 | type SandboxMode = 'js' | 'raw' | 'dom'; 90 | 91 | type Resource = { 92 | name: string; 93 | url: string; 94 | error?: string; 95 | content?: string; 96 | meta?: string; 97 | }; 98 | 99 | type WebRequestRule = { 100 | selector: { include?: string | string[]; match?: string | string[]; exclude?: string | string[] } | string; 101 | action: 102 | | string 103 | | { 104 | cancel?: boolean; 105 | redirect?: 106 | | { 107 | url: string; 108 | from?: string; 109 | to?: string; 110 | } 111 | | string; 112 | }; 113 | }; 114 | 115 | declare module '*.js' 116 | declare const unsafeWindow: Window; -------------------------------------------------------------------------------- /src/types/zhihu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './js-initialData.type'; 2 | export * from './zhihu-answer.type'; 3 | export * from './zhihu-articles.type'; 4 | export * from './zhihu-recommend.type'; 5 | export * from './zhihu.type'; 6 | 7 | -------------------------------------------------------------------------------- /src/types/zhihu/zhihu-answer.type.ts: -------------------------------------------------------------------------------- 1 | export interface IZhihuAnswerTarget { 2 | adminClosedComment: boolean; 3 | annotationAction: null; 4 | answerType: string; 5 | attachedInfo: string; 6 | author: Author; 7 | canComment: CanComment; 8 | collapseReason: string; 9 | collapsedBy: string; 10 | commentCount: number; 11 | commentPermission: string; 12 | content: string; 13 | contentMark: ContentMark; 14 | contentNeedTruncated: boolean; 15 | createdTime: number; 16 | decorativeLabels: any[]; 17 | editableContent: string; 18 | excerpt: string; 19 | extras: string; 20 | favlistsCount: number; 21 | forceLoginWhenClickReadMore: boolean; 22 | id: string; 23 | isCollapsed: boolean; 24 | isCopyable: boolean; 25 | isJumpNative: boolean; 26 | isLabeled: boolean; 27 | isMine: boolean; 28 | isNormal: boolean; 29 | isSticky: boolean; 30 | isVisible: boolean; 31 | matrixTips: string; 32 | question: Question; 33 | reactionInstruction: ContentMark; 34 | relationship: Relationship; 35 | relevantInfo: RelevantInfo; 36 | reshipmentSettings: string; 37 | rewardInfo: RewardInfo; 38 | stickyInfo: string; 39 | suggestEdit: SuggestEdit; 40 | thanksCount: number; 41 | thumbnailInfo: ThumbnailInfo; 42 | type: string; 43 | updatedTime: number; 44 | url: string; 45 | visibleOnlyToAuthor: boolean; 46 | voteupCount: number; 47 | labelInfo?: LabelInfo; 48 | attachment?: IAttachment; 49 | } 50 | 51 | interface LabelInfo { 52 | foregroundColor: { 53 | alpha: number; 54 | group: string; 55 | }; 56 | iconUrl: string; 57 | text: string; 58 | type: string; 59 | } 60 | 61 | interface Author { 62 | avatarUrl: string; 63 | avatarUrlTemplate: string; 64 | badge: any[]; 65 | badgeV2: BadgeV2; 66 | exposedMedal: ExposedMedal; 67 | followerCount: number; 68 | gender: number; 69 | headline: string; 70 | id: string; 71 | isAdvertiser: boolean; 72 | isFollowed: boolean; 73 | isFollowing: boolean; 74 | isOrg: boolean; 75 | isPrivacy: boolean; 76 | name: string; 77 | type: string; 78 | url: string; 79 | urlToken: string; 80 | userType: string; 81 | } 82 | 83 | interface BadgeV2 { 84 | detailBadges: any[]; 85 | icon: string; 86 | mergedBadges: any[]; 87 | nightIcon: string; 88 | title: string; 89 | } 90 | 91 | interface ExposedMedal { 92 | avatarUrl: string; 93 | description: string; 94 | medalAvatarFrame: string; 95 | medalId: string; 96 | medalName: string; 97 | miniAvatarUrl: string; 98 | } 99 | 100 | interface CanComment { 101 | reason: string; 102 | status: boolean; 103 | } 104 | 105 | interface ContentMark {} 106 | 107 | interface Question { 108 | created: number; 109 | id: string; 110 | questionType: string; 111 | relationship: ContentMark; 112 | title: string; 113 | type: string; 114 | updatedTime: number; 115 | url: string; 116 | } 117 | 118 | interface Relationship { 119 | isAuthor: boolean; 120 | isAuthorized: boolean; 121 | isNothelp: boolean; 122 | isThanked: boolean; 123 | upvotedFollowees: any[]; 124 | voting: number; 125 | } 126 | 127 | interface RelevantInfo { 128 | isRelevant: boolean; 129 | relevantText: string; 130 | relevantType: string; 131 | } 132 | 133 | interface RewardInfo { 134 | canOpenReward: boolean; 135 | isRewardable: boolean; 136 | rewardMemberCount: number; 137 | rewardTotalMoney: number; 138 | tagline: string; 139 | } 140 | 141 | interface SuggestEdit { 142 | reason: string; 143 | status: boolean; 144 | tip: string; 145 | title: string; 146 | unnormalDetails: UnnormalDetails; 147 | url: string; 148 | } 149 | 150 | interface UnnormalDetails { 151 | description: string; 152 | note: string; 153 | reason: string; 154 | reasonId: number; 155 | status: string; 156 | } 157 | 158 | interface ThumbnailInfo { 159 | count: number; 160 | thumbnails: Thumbnail[]; 161 | type: string; 162 | } 163 | 164 | interface Thumbnail { 165 | height: number; 166 | token: string; 167 | type: string; 168 | url: string; 169 | width: number; 170 | } 171 | 172 | interface IAttachment { 173 | attachmentId: string; 174 | type: string; 175 | video: Video; 176 | } 177 | 178 | interface Video { 179 | endTime: number; 180 | parentVideoId: string; 181 | playCount: number; 182 | startTime: number; 183 | subVideoId: string; 184 | title: string; 185 | videoInfo: VideoInfo; 186 | voteupCount: number; 187 | zvideoId: string; 188 | } 189 | 190 | interface VideoInfo { 191 | duration: number; 192 | height: number; 193 | isPaid: boolean; 194 | isTrial: boolean; 195 | playCount: number; 196 | playlist: Playlist; 197 | thumbnail: string; 198 | type: string; 199 | videoId: number; 200 | width: number; 201 | } 202 | 203 | interface Playlist { 204 | fhd: Fhd; 205 | hd: Fhd; 206 | ld: Fhd; 207 | sd: Fhd; 208 | } 209 | 210 | interface Fhd { 211 | bitrate: number; 212 | height: number; 213 | url: string; 214 | width: number; 215 | } 216 | -------------------------------------------------------------------------------- /src/types/zhihu/zhihu-articles.type.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export interface IZhihuArticlesDataItem { 4 | updated: number; 5 | author: Author; 6 | is_labeled: boolean; 7 | vessay_info: VessayInfo; 8 | excerpt: string; 9 | admin_closed_comment: boolean; 10 | article_type: string; 11 | reaction_instruction: ReactionInstruction; 12 | id: number; 13 | voteup_count: number; 14 | upvoted_followees: any[]; 15 | can_comment: CanComment; 16 | title: string; 17 | url: string; 18 | comment_permission: string; 19 | copyright_permission: string; 20 | created: number; 21 | content: string; 22 | comment_count: number; 23 | image_url: string; 24 | excerpt_title: string; 25 | voting: number; 26 | type: string; 27 | suggest_edit: SuggestEdit; 28 | is_normal: boolean; 29 | } 30 | 31 | export interface Author { 32 | avatar_url_template: string; 33 | badge: Badge[]; 34 | badge_v2: BadgeV2; 35 | name: string; 36 | is_advertiser: boolean; 37 | url: string; 38 | gender: number; 39 | user_type: string; 40 | vip_info: VipInfo; 41 | headline: string; 42 | avatar_url: string; 43 | is_org: boolean; 44 | type: string; 45 | url_token: string; 46 | id: string; 47 | } 48 | 49 | export interface Badge { 50 | topics: Topic[]; 51 | type: string; 52 | description: string; 53 | } 54 | 55 | export interface Topic { 56 | name: string; 57 | introduction: string; 58 | excerpt: string; 59 | url: string; 60 | followers_count: number; 61 | avatar_url: string; 62 | type: string; 63 | id: string; 64 | questions_count: number; 65 | } 66 | 67 | export interface BadgeV2 { 68 | icon: string; 69 | detail_badges: DetailBadgeElement[]; 70 | night_icon: string; 71 | merged_badges: DetailBadgeElement[]; 72 | title: string; 73 | } 74 | 75 | export interface DetailBadgeElement { 76 | description: string; 77 | title: string; 78 | url: string; 79 | sources: Source[]; 80 | night_icon: string; 81 | detail_type: string; 82 | type: string; 83 | icon: string; 84 | } 85 | 86 | export interface Source { 87 | avatar_path: string; 88 | name: string; 89 | url: string; 90 | priority: number; 91 | token: string; 92 | avatar_url: string; 93 | type: string; 94 | id: string; 95 | description: string; 96 | } 97 | 98 | export interface VipInfo { 99 | is_vip: boolean; 100 | vip_icon: VipIcon; 101 | } 102 | 103 | export interface VipIcon { 104 | url: string; 105 | night_mode_url: string; 106 | } 107 | 108 | export interface CanComment { 109 | status: boolean; 110 | reason: string; 111 | } 112 | 113 | export interface ReactionInstruction {} 114 | 115 | export interface SuggestEdit { 116 | status: boolean; 117 | url: string; 118 | reason: string; 119 | tip: string; 120 | title: string; 121 | } 122 | 123 | export interface VessayInfo { 124 | enable_video_translate: boolean; 125 | } 126 | -------------------------------------------------------------------------------- /src/types/zhihu/zhihu-recommend.type.ts: -------------------------------------------------------------------------------- 1 | export interface IZhihuRecommendItem { 2 | id: string; 3 | type: string; 4 | offset: number; 5 | verb: string; 6 | created_time: number; 7 | updated_time: number; 8 | target: Target; 9 | brief: string; 10 | attached_info: string; 11 | action_card: boolean; 12 | } 13 | 14 | interface Target { 15 | id: number; 16 | type: string; 17 | url: string; 18 | author: Author; 19 | created_time: number; 20 | updated_time: number; 21 | voteup_count: number; 22 | thanks_count: number; 23 | comment_count: number; 24 | is_copyable: boolean; 25 | question: Question; 26 | thumbnail: string; 27 | excerpt: string; 28 | excerpt_new: string; 29 | preview_type: string; 30 | preview_text: string; 31 | reshipment_settings: string; 32 | content: string; 33 | relationship: TargetRelationship; 34 | is_labeled: boolean; 35 | visited_count: number; 36 | thumbnails: string[]; 37 | favorite_count: number; 38 | answer_type: string; 39 | paid_info?: PaidInfo; 40 | attachment?: IAttachment; 41 | } 42 | 43 | interface Author { 44 | id: string; 45 | url: string; 46 | user_type: string; 47 | url_token: string; 48 | name: string; 49 | headline: string; 50 | avatar_url: string; 51 | is_org: boolean; 52 | gender: number; 53 | followers_count: number; 54 | is_following: boolean; 55 | is_followed: boolean; 56 | } 57 | 58 | interface PaidInfo { 59 | type: string; 60 | content: string; 61 | has_purchased: boolean; 62 | } 63 | 64 | interface Question { 65 | id: number; 66 | type: string; 67 | url: string; 68 | author: Author; 69 | title: string; 70 | created: number; 71 | answer_count: number; 72 | follower_count: number; 73 | comment_count: number; 74 | bound_topic_ids: number[]; 75 | is_following: boolean; 76 | excerpt: string; 77 | relationship: QuestionRelationship; 78 | detail: string; 79 | question_type: string; 80 | } 81 | 82 | interface QuestionRelationship { 83 | is_author: boolean; 84 | } 85 | 86 | interface TargetRelationship { 87 | is_thanked: boolean; 88 | is_nothelp: boolean; 89 | voting: number; 90 | } 91 | 92 | interface IAttachment { 93 | video: Video; 94 | attachment_id: string; 95 | type: string; 96 | } 97 | 98 | interface Video { 99 | zvideo_id: string; 100 | title: string; 101 | start_time: number; 102 | play_count: number; 103 | video_info: VideoInfo; 104 | parent_video_id: string; 105 | end_time: number; 106 | sub_video_id: string; 107 | voteup_count: number; 108 | } 109 | 110 | interface VideoInfo { 111 | status: string; 112 | playlist: Playlist; 113 | is_deleted: boolean; 114 | created_at: number; 115 | updated_at: number; 116 | play_count: number; 117 | width: number; 118 | id: number; 119 | duration: number; 120 | height: number; 121 | thumbnail: string; 122 | } 123 | 124 | interface Playlist { 125 | ld: HD; 126 | hd: HD; 127 | sd: HD; 128 | } 129 | 130 | interface HD { 131 | width: number; 132 | format: string; 133 | play_url: string; 134 | duration: number; 135 | height: number; 136 | size: number; 137 | } 138 | -------------------------------------------------------------------------------- /src/types/zhihu/zhihu.type.ts: -------------------------------------------------------------------------------- 1 | export type IZhihuDataZaExtraModule = Record<'card', IZhihuDataZaExtraModuleCard>; 2 | 3 | export interface IZhihuDataZaExtraModuleCard { 4 | has_image?: boolean; 5 | has_video?: boolean; 6 | content?: IZhihuCardContent; 7 | } 8 | 9 | export interface IZhihuCardContent { 10 | type?: string; 11 | token?: string; 12 | upvote_num?: number; 13 | comment_num?: number; 14 | publish_timestamp?: any; 15 | parent_token?: string; 16 | author_member_hash_id?: string; 17 | } 18 | 19 | export interface IZhihuDataZop { 20 | authorName?: string; 21 | itemId?: number; 22 | title?: string; 23 | type?: string; 24 | } 25 | 26 | /** 用户信息 */ 27 | export interface IZhihuUserInfo { 28 | id: string; 29 | url_token: string; 30 | name: string; 31 | use_default_avatar: boolean; 32 | avatar_url: string; 33 | avatar_url_template: string; 34 | is_org: boolean; 35 | type: string; 36 | url: string; 37 | user_type: string; 38 | headline: string; 39 | headline_render: string; 40 | gender: number; 41 | is_advertiser: boolean; 42 | ad_type: string; 43 | ip_info: string; 44 | vip_info: IZhihuVipInfo; 45 | account_status: any[]; 46 | is_force_renamed: boolean; 47 | is_destroy_waiting: boolean; 48 | answer_count: number; 49 | question_count: number; 50 | articles_count: number; 51 | columns_count: number; 52 | zvideo_count: number; 53 | favorite_count: number; 54 | pins_count: number; 55 | voteup_count: number; 56 | thanked_count: number; 57 | following_question_count: number; 58 | available_medals_count: number; 59 | uid: string; 60 | email: string; 61 | renamed_fullname: string; 62 | default_notifications_count: number; 63 | follow_notifications_count: number; 64 | vote_thank_notifications_count: number; 65 | messages_count: number; 66 | creation_count: number; 67 | is_bind_phone: boolean; 68 | is_realname: boolean; 69 | has_applying_column: boolean; 70 | has_add_baike_summary_permission: boolean; 71 | editor_info: string[]; 72 | available_message_types: string[]; 73 | } 74 | 75 | export interface IZhihuVipInfo { 76 | is_vip: boolean; 77 | vip_type: number; 78 | rename_days: string[]; 79 | entrance_v2: any; 80 | rename_frequency: number; 81 | rename_await_days: number; 82 | } 83 | -------------------------------------------------------------------------------- /src/web-resources.ts: -------------------------------------------------------------------------------- 1 | export const INNER_HTML = ``; 2 | export const INNER_CSS = ``; 3 | -------------------------------------------------------------------------------- /static/background-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/background-dark.png -------------------------------------------------------------------------------- /static/background-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/background-light.png -------------------------------------------------------------------------------- /static/black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/black.png -------------------------------------------------------------------------------- /static/blocked-user-tag-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/blocked-user-tag-edit.png -------------------------------------------------------------------------------- /static/blocked-user-tag-input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/blocked-user-tag-input.png -------------------------------------------------------------------------------- /static/cancel-comment-auto-focus-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/cancel-comment-auto-focus-after.png -------------------------------------------------------------------------------- /static/cancel-comment-auto-focus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/cancel-comment-auto-focus.png -------------------------------------------------------------------------------- /static/change-web-title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/change-web-title.png -------------------------------------------------------------------------------- /static/comment-image-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/comment-image-preview.png -------------------------------------------------------------------------------- /static/copy-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/copy-link.png -------------------------------------------------------------------------------- /static/download-video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/download-video.png -------------------------------------------------------------------------------- /static/export-content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/export-content.png -------------------------------------------------------------------------------- /static/export-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/export-home.png -------------------------------------------------------------------------------- /static/export-to-pdf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/export-to-pdf.png -------------------------------------------------------------------------------- /static/filter-title-word.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/filter-title-word.png -------------------------------------------------------------------------------- /static/font-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/font-color.png -------------------------------------------------------------------------------- /static/font-size.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/font-size.png -------------------------------------------------------------------------------- /static/hidden.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/hidden.png -------------------------------------------------------------------------------- /static/history-recommend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/history-recommend.png -------------------------------------------------------------------------------- /static/history-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/history-view.png -------------------------------------------------------------------------------- /static/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/home.png -------------------------------------------------------------------------------- /static/image-size.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/image-size.png -------------------------------------------------------------------------------- /static/invite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/invite.png -------------------------------------------------------------------------------- /static/item-date.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/item-date.png -------------------------------------------------------------------------------- /static/item-type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/item-type.png -------------------------------------------------------------------------------- /static/just-number.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/just-number.png -------------------------------------------------------------------------------- /static/not-fetch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/not-fetch.png -------------------------------------------------------------------------------- /static/remove-filter-tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/remove-filter-tag.png -------------------------------------------------------------------------------- /static/remove-item.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/remove-item.png -------------------------------------------------------------------------------- /static/remove-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/remove-message.png -------------------------------------------------------------------------------- /static/replace-zhida.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/replace-zhida.png -------------------------------------------------------------------------------- /static/safari-use.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/safari-use.png -------------------------------------------------------------------------------- /static/setting-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/setting-background.png -------------------------------------------------------------------------------- /static/setting-filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/setting-filter.png -------------------------------------------------------------------------------- /static/setting-replace-zhida.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/setting-replace-zhida.png -------------------------------------------------------------------------------- /static/setting-size.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/setting-size.png -------------------------------------------------------------------------------- /static/suspension-pickup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/suspension-pickup.png -------------------------------------------------------------------------------- /static/video-hidden.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/video-hidden.png -------------------------------------------------------------------------------- /static/video-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuyubing233/zhihu-custom/44b9f24950f26318dc2e73a1c46815b845e6dd6f/static/video-link.png --------------------------------------------------------------------------------