├── .gitattributes ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── build.crx ├── build.pem ├── build.zip ├── build ├── icons │ ├── Speechless128.png │ ├── Speechless16.png │ ├── Speechless32.png │ └── Speechless48.png ├── index.html ├── index.js ├── manifest.json └── style │ └── speechless.css ├── medias └── Small promo tile.png ├── package.json ├── postcss.config.js ├── src ├── App.vue ├── component │ ├── SelectMonth.vue │ ├── SelectNative.vue │ └── SelectTimeRange.vue ├── index.html ├── main.css ├── main.js └── module │ ├── blogPost.js │ ├── longText.js │ ├── pageHandle.js │ ├── range.js │ ├── test.js │ └── userInfo.js ├── tailwind.config.js ├── webpack.config.js └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Serverless directories 108 | .serverless/ 109 | 110 | # FuseBox cache 111 | .fusebox/ 112 | 113 | # DynamoDB Local files 114 | .dynamodb/ 115 | 116 | # TernJS port file 117 | .tern-port 118 | 119 | # Stores VSCode versions used for testing VSCode extensions 120 | .vscode-test 121 | 122 | # yarn v2 123 | .yarn/cache 124 | .yarn/unplugged 125 | .yarn/build-state.yml 126 | .yarn/install-state.gz 127 | .pnp.* 128 | *.DS_Store 129 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "prettier.semi": false 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 meterscao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Speechless 2 | 3 | ![WechatIMG160 copy](https://user-images.githubusercontent.com/1685497/234859432-04ab3f05-82ae-4a2f-9b51-265c4998b38d.jpg) 4 | 5 | Speechless 是一个帮助新浪微博用户,把微博内容导出成 PDF 进行本地备份的 Chrome Extension。 6 | 7 | 查看官网👉 [https://sppechless.fun](https://speechless.fun) 8 | 9 | ## 使用 10 | 11 | - 通过 Chrome Web Store 安装(推荐) 12 | 13 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/v/igilfpckopigflpafgoajlljpdhmoall?color=green&label=Chrome%20Web%20Store&logo=google%20chrome&logoColor=white)](https://chrome.google.com/webstore/detail/speechless-%E5%BE%AE%E5%8D%9A%E5%A4%87%E4%BB%BD/igilfpckopigflpafgoajlljpdhmoall) 14 | 15 | 16 | ## 简介 17 | 18 | ✅ 一键导出 PDF 19 | 20 | 将微博的文字、图片、表情轻松备份成高质量PDF文件,方便阅读、浏览和储存。 21 | 22 | ✅ 支持时间范围筛选 23 | 24 | 可以根据时间范围进行筛选和导出,确保只备份你关心的特定时期的微博内容。 25 | 26 | ✅ 备份任何公开的微博 27 | 28 | 不仅支持备份你自己的微博,还支持其他任何公共公开的微博。 29 | 30 | ✅ 安全可靠 31 | 32 | 无需额外登录和输入密码,直接通过插件形式使用,确保你的账户安全和隐私保护。 33 | 34 | ✅ 支持原创与转载 35 | 36 | 可选择仅备份原创微博或全部微博(含转发内容),灵活满足不同需求。 37 | 38 | ✅ 精美的照片排版 39 | 40 | 支持选择图片备份的大小和清晰度,为你提供最佳的阅读和存储体验。 41 | 42 | ![WechatIMG161 copy](https://user-images.githubusercontent.com/1685497/234859469-62b64b5a-728d-48e2-ac24-45d68266f751.jpg) 43 | 44 | ![WechatIMG162 copy](https://user-images.githubusercontent.com/1685497/234859495-970397e5-1cbd-4272-868d-74ab1a6dac20.jpg) 45 | 46 | ## 原理 47 | 48 | Chrome 并没有为 Extension 提供直接导出 PDF 的 Api,但是可以借助 Chrome 的 **打印预览/另存为 PDF** 功能,将网页的内容直接另存为 PDF。 49 | 50 | 所以 Speechless 做了以下几件事情: 51 | 52 | 1. 在页面上找到需要备份用户的 UID,这通常可以通过 URL 直接获得 53 | 2. 通过 Ajax 不断去拉取该用户可见的微博内容,当内容中有长文时,额外通过接口获取长文信息 54 | 3. 将拉取到的微博内容,添加到页面的节点上,并且设置基本的样式和布局 55 | 4. 直到所有内容都拉取完毕之后,通过点击事件触发 `window.print()` ,唤起 Chrome 自带的打印预览界面 56 | 5. 在打印预览界面 **目标打印机** 选择 **另存为 PDF**,导出即可 57 | 58 | ## 依赖 59 | 60 | - Vue3 61 | - TailwindCSS 62 | 63 | ## 其他 64 | 65 | - 愿人人都有自由表达的权利。 66 | 67 | ## 更新 68 | 69 | ### version 2.2 70 | - 修复了长文无法展开,只显示“展开”两个字的问题 71 | - 保留了文本内容内的换行,使内容阅读体验更佳 72 | - 优化了导出文件的标题 73 | 74 | ### version 2.0 75 | - 支持选择时间范围 76 | - 支持选择图片大小 77 | - 支持仅备份原创微博 78 | - 支持最小化窗口 79 | - 支持多种url形式的微博主页 80 | 81 | ### version 1.2 82 | - 增加了默认的拉取时间间隔,以避免拉取过于频繁被微博限制的问题。但目前的方法仍不是最优解法,尚有较大优化空间 83 | - 增加了拉取完成后,手动选择图片裁切样式的操作 84 | - 增加了拉取完成后,手动选择是否展示 转、赞、评 信息的操作 [@rickypeng99](https://github.com/rickypeng99) 85 | - 增加了拉取过程中暂停的操作 86 | ### version 1.1 87 | - 使用 Weibo API 获取用户 UID 和用户名 [@jingfelix](https://github.com/jingfelix) 88 | - 修复了 icon name 错误的大小写问题 [@jjhhms](https://github.com/jjhhms) 89 | 90 | 91 | -------------------------------------------------------------------------------- /build.crx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meterscao/Speechless/21e4aabdedc3509755754dd9e5880e50d647f215/build.crx -------------------------------------------------------------------------------- /build.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpuPrO8uCP/N1j 3 | kVNiB1yoDfs7InZNgHtU53goTw1eWgwsR0e1/9pjBA8MpxHx49dRb+SXoSD3Gljs 4 | DFFHWlYO6Y1XtAhaRekcZvjWBo6I+/0ccMvbg9g0aGDCBnXrpef2g05cJxz1G2EW 5 | CjFue6NtHcnYQQ2SjEmUi93B3U+KtzebGEsZM3u+mRfdQ/astNDmYOe0lXum/J5o 6 | Wf9UUYr6zjK56mdlg3e39sWcItKL+/3ntqtfMDbbBLCNJDkbRv6KM02Jp6LfhThJ 7 | afl3nm2hh95J6iBnsmF5L5wbJNNXOWhgFUn5Ye8GlyDlSbqLVfLlH2uQILchzltF 8 | lJpCbnjdAgMBAAECggEAFWCyNWXx84D/ZnQJeNlXij10mG65+v9vdB18YjYO3Nm3 9 | xc4BUi3Uv/sPZYxtCLb7nzYvqLUIIMfQooQsqrGjZwPI+gk4XMfd2bt9+qtfFxGy 10 | ayYpWfrXPUarxVSoqWgUzyqb209RPkMBbWlsRNp4QR1q7VJzAgip7fbYwFCpcl3M 11 | CTs6BQrmWiXWDjh1W+iOcUXcmCiznfbU6zrn7bPMWiCx216OYNwTlvTkIUMehAkB 12 | JX5d1h2qU/JynZWzV6A8QKzuOn3ZUvfmbepXeLMGAoLtZLbDg+3HoXOYHb60DoJa 13 | 1jyp520QioYYPB4nfztlXbsI7pQUuZfOMDOcmmZK6wKBgQDT10jKLSMrF3o8MyPk 14 | MTelCvB60IhSlMe2yEDkGacCMscQX+jt5jp6j1Dg7O21Zynv1chqhaXD+wc02DQ7 15 | cS7APOZijqMLXe+/QMGP6gfi8tTnFYpSBYBg/ygr++kqBJXlJ05KQrImNbcILnsM 16 | KGABbUdjPj5UEA9sfq1+dFO3GwKBgQDNGhVVy+wR/xnuYO0GFOdHXsgYeaO8iKoN 17 | NHsmxx+X1GfKBZk9hGX5+qBpbAMP3ahKnw0Nu2L6KXMJtyu+QVKt3Z69wHL5kn0m 18 | 8KlcmAoPhELMNlQLpF8I6PPWEa0O6sl4lkIbhgeTXR+gkQXCpr8IgEWb3Js7Bvp/ 19 | kTYiMss3ZwKBgEoz4r9OyD7T3nEvyKapxr/88PdUDCxBCSOxSWmcwq82chv+PGz4 20 | dAtyH/Zph2o/VKU50RXe8o2PZJrSEJmxr5qOunRTWjElGlF3sVVjuJd6T7ESDn1k 21 | h+9x8PRXLPkW2hqGhhnk+tn/frlS7q73hYl6acMNjm+LoJt0U/kzbg95AoGAegvp 22 | YjKEeXkJnzYCFTpfHbMi8p1/d3MDbfQqzDp1bE2yrR6Dv32HiSdASTtC3zARMsZO 23 | aDt3fFEp6UAcQYxxfJCCI3vfbu9ilcdjoHvAkNctQIObo4neG1kzr9C44EWKOb1/ 24 | /QzWeTRS26MwgFHlH1PlGAhAPvG6IirwwYzkLEUCgYB1APwU35sLZg/I3K+mXrm+ 25 | RYY5BEu8lidq3Gq2P32bIAohJrsQRJ7jjlFbiNiSmXmwhMw1uiaF084k53XeVdf8 26 | Q/rpZHywbsydx6dQSe1ghYDRY3bL2IMhccdki177r6hi6/rODt9IDQrTGT6nKnXk 27 | hg62laZNJxOZP/MgWGTe1g== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /build.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meterscao/Speechless/21e4aabdedc3509755754dd9e5880e50d647f215/build.zip -------------------------------------------------------------------------------- /build/icons/Speechless128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meterscao/Speechless/21e4aabdedc3509755754dd9e5880e50d647f215/build/icons/Speechless128.png -------------------------------------------------------------------------------- /build/icons/Speechless16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meterscao/Speechless/21e4aabdedc3509755754dd9e5880e50d647f215/build/icons/Speechless16.png -------------------------------------------------------------------------------- /build/icons/Speechless32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meterscao/Speechless/21e4aabdedc3509755754dd9e5880e50d647f215/build/icons/Speechless32.png -------------------------------------------------------------------------------- /build/icons/Speechless48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meterscao/Speechless/21e4aabdedc3509755754dd9e5880e50d647f215/build/icons/Speechless48.png -------------------------------------------------------------------------------- /build/index.html: -------------------------------------------------------------------------------- 1 | Hello World
-------------------------------------------------------------------------------- /build/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Speechless 微博备份", 4 | "version": "2.2", 5 | "description": "把新浪微博的内容,导出成 PDF 文件进行备份", 6 | "icons": { 7 | "16": "icons/Speechless16.png", 8 | "32": "icons/Speechless32.png", 9 | "48": "icons/Speechless48.png", 10 | "128": "icons/Speechless128.png" 11 | }, 12 | "content_scripts": [ 13 | { 14 | "js": ["index.js"], 15 | "css": ["style/speechless.css"], 16 | "matches": ["https://weibo.com/u/*", "https://weibo.com/*"], 17 | "run_at": "document_end" 18 | } 19 | ], 20 | "content_security_policy": { 21 | "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /build/style/speechless.css: -------------------------------------------------------------------------------- 1 | /* Reset */ 2 | 3 | html { 4 | background-color: #fff !important; 5 | } 6 | body { 7 | background-color: #fff !important; 8 | height: auto !important; 9 | font-size: 14px !important; 10 | line-height: 24px !important; 11 | } 12 | #WB_webchat { 13 | display: none !important; 14 | } 15 | 16 | .icon_setskin { 17 | display: none !important; 18 | } 19 | 20 | div[i-am-music-player] { 21 | display: none !important; 22 | } 23 | 24 | /* Post Styles */ 25 | .speechless-list { 26 | padding: 20px; 27 | color: #000 !important; 28 | background: #fff !important; 29 | } 30 | 31 | .speechless-post { 32 | font-size: 14px; 33 | line-height: 24px; 34 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 35 | padding-bottom: 10px; 36 | margin-bottom: 10px; 37 | overflow: hidden; 38 | display: flex; 39 | width: 100%; 40 | } 41 | 42 | .speechless-post .text { 43 | } 44 | .speechless-post .text img, 45 | .speechless-post .retweet img { 46 | display: inline-block; 47 | width: 16px; 48 | height: 16px; 49 | vertical-align: -3px; 50 | margin: 0 2px; 51 | } 52 | 53 | .speechless-post .meta { 54 | width: 150px; 55 | color: #666; 56 | display: flex; 57 | flex-direction: column; 58 | justify-content: space-between; 59 | } 60 | 61 | .speechless-post .main { 62 | flex: 1; 63 | width: 1px; 64 | } 65 | 66 | .speechless-post .retweet { 67 | background: #f1f1f1; 68 | padding: 8px; 69 | margin-top: 5px; 70 | margin-bottom: 5px; 71 | } 72 | .speechless-post .media { 73 | display: flex; 74 | flex-wrap: wrap; 75 | gap: 5px; 76 | margin-top: 5px; 77 | } 78 | .speechless-post .media::after { 79 | content: ""; 80 | flex-grow: 999999999; 81 | } 82 | 83 | .speechless-post .media .image-container { 84 | position: relative; 85 | } 86 | .speechless-post .media .image-placeholder { 87 | display: block; 88 | } 89 | 90 | .speechless-post .media .image-new { 91 | position: absolute; 92 | top: 0; 93 | width: 100%; 94 | vertical-align: bottom; 95 | } 96 | .speechless-list-small .media-medium, 97 | .speechless-list-small .media-large { 98 | display: none; 99 | } 100 | .speechless-list-small .media-small { 101 | display: flex; 102 | } 103 | 104 | .speechless-list-medium .media-small, 105 | .speechless-list-medium .media-large { 106 | display: none; 107 | } 108 | .speechless-list-medium .media-medium { 109 | display: flex; 110 | } 111 | 112 | .speechless-list-large .media-small, 113 | .speechless-list-large .media-medium { 114 | display: none; 115 | } 116 | .speechless-list-large .media-large { 117 | display: flex; 118 | } 119 | 120 | .speechless-list.showinteraction .interactionStats { 121 | display: flex; 122 | gap: 5px; 123 | color: #999; 124 | } 125 | .speechless-corpyright { 126 | text-align: center; 127 | } 128 | .speechless-corpyright a { 129 | color: rgb(239, 68, 68); 130 | font-weight: 500; 131 | } 132 | 133 | /* Print */ 134 | @media print { 135 | html, 136 | body { 137 | background: #fff !important; 138 | } 139 | 140 | .speechless-post, 141 | .speechless-post .image-container, 142 | .speechless-post .image { 143 | page-break-inside: avoid; 144 | } 145 | .speechless-post .media { 146 | page-break-inside: auto; 147 | } 148 | .speech-less-thumbnail .image { 149 | page-break-inside: auto; 150 | } 151 | #speechless { 152 | display: none; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /medias/Small promo tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meterscao/Speechless/21e4aabdedc3509755754dd9e5880e50d647f215/medias/Small promo tile.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ExtentsionWithVue", 3 | "version": "2.2.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "webpack serve --mode development", 8 | "build": "webpack --mode production", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "autoprefixer": "^10.4.13", 16 | "axios": "^1.2.1", 17 | "css-loader": "^6.7.2", 18 | "html-webpack-plugin": "^5.5.0", 19 | "postcss-loader": "^7.0.2", 20 | "tailwindcss": "^3.2.4", 21 | "vue-style-loader": "^4.1.3", 22 | "webpack": "^5.75.0", 23 | "webpack-cli": "^5.0.1", 24 | "webpack-dev-server": "^4.11.1" 25 | }, 26 | "dependencies": { 27 | "@tailwindcss/forms": "^0.5.3", 28 | "dayjs": "^1.11.7", 29 | "vue": "^3.2.47", 30 | "vue-loader": "^17.0.1", 31 | "vue-template-compiler": "^2.7.14" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 271 | 272 | 480 | 485 | -------------------------------------------------------------------------------- /src/component/SelectMonth.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 72 | -------------------------------------------------------------------------------- /src/component/SelectNative.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 44 | -------------------------------------------------------------------------------- /src/component/SelectTimeRange.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 86 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hello World 7 | 8 | 9 | 10 |
11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-size: 14px; 7 | line-height: 1.5; 8 | } 9 | 10 | #app svg, 11 | #app img { 12 | display: inline-block; 13 | } 14 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | // 从一个单文件组件中导入根组件 3 | import App from './App.vue' 4 | import './main.css' 5 | 6 | const createRootNode = function(){ 7 | let rootNode = document.createElement('div') 8 | let attrID = document.createAttribute('id') 9 | attrID.value = 'speechless' 10 | rootNode.setAttributeNode(attrID) 11 | document.body.append(rootNode) 12 | } 13 | 14 | window.donateImageURL = chrome.runtime.getURL("medias/donate_code.png"); 15 | 16 | createRootNode() 17 | 18 | const app = createApp(App) 19 | app.mount('#speechless') -------------------------------------------------------------------------------- /src/module/blogPost.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | 3 | const GetPostsByRangeApiURL = `https://weibo.com/ajax/statuses/searchProfile` 4 | const GetLongTextURL = `https://weibo.com/ajax/statuses/longtext` 5 | 6 | let page = 1 7 | let total = 0 8 | let count = 0 9 | let loadMore = true 10 | let _uid 11 | let _sourceType = 1 12 | let speechlessListEL 13 | 14 | let _callback 15 | 16 | // 拉取间隔时间 17 | let interval = 1000 18 | 19 | // 上一次拉取时间 20 | let lastFetchTimeStamp = 0 21 | 22 | const delay = function (timeout) { 23 | return new Promise((resolve, reject) => { 24 | setTimeout(resolve, timeout) 25 | }) 26 | } 27 | 28 | // 每添加一个卡片,就要更新一次页面的状态 29 | const updateWholePageState = function () { 30 | window.scrollTo(0, document.body.scrollHeight) 31 | count++ 32 | _callback({ 33 | type: "count", 34 | value: count, 35 | }) 36 | } 37 | 38 | // 把页面上的其他元素移除,并且初始化挂载节点 39 | const generateHTML = function () { 40 | document.getElementById("app").remove() 41 | speechlessListEL = document.createElement("div") 42 | speechlessListEL.classList = "speechless-list speechless-list-small" 43 | document.body.append(speechlessListEL) 44 | } 45 | 46 | const appendSpeechless = function () { 47 | let dateString = getDate(new Date()) 48 | let speechlessHtml = ` 49 | ${dateString} 使用 ♥ Speechless 导出 50 | ` 51 | let node = document.createElement("div") 52 | node.className = "speechless-corpyright" 53 | node.innerHTML = speechlessHtml 54 | speechlessListEL.appendChild(node) 55 | } 56 | 57 | // 格式化时间 58 | const getDate = function (dateString, showSecond) { 59 | let date = new Date(dateString) 60 | let hour = date.getHours() 61 | let minute = date.getMinutes() 62 | let second = date.getSeconds() 63 | let year = date.getFullYear() 64 | let month = date.getMonth() + 1 65 | let day = date.getDate() 66 | 67 | let fillWithZero = function (num) { 68 | if (parseInt(num) < 10) { 69 | return "0" + num.toString() 70 | } else return num.toString() 71 | } 72 | return ( 73 | year + 74 | "/" + 75 | fillWithZero(month) + 76 | "/" + 77 | fillWithZero(day) + 78 | " " + 79 | fillWithZero(hour) + 80 | ":" + 81 | fillWithZero(minute) + 82 | (showSecond ? ":" + fillWithZero(second) : "") 83 | ) 84 | } 85 | 86 | // 过滤多余的换行 87 | const clearLineBreak = function (text) { 88 | let textClear = text.replace(/\n/g, "
") 89 | textClear = textClear.replace(/(){3,}/g, "

") 90 | return textClear 91 | } 92 | 93 | const combineImageHtml = function (image, size) { 94 | let str 95 | if (!size) size = 120 96 | 97 | if (image.width > 0 && image.height > 0) { 98 | str = `
` 105 | } else { 106 | str = `` 107 | } 108 | 109 | return str 110 | } 111 | 112 | // 把卡片添加到页面中 113 | const appendPostToBody = function (post) { 114 | if (_sourceType == 1 && (post.retweeted_status || post.user.id != _uid)) { 115 | } else { 116 | let metaHTML = "" 117 | 118 | metaHTML += `
119 |
120 | ${getDate(post.created_at)}` 121 | if (post.region_name) { 122 | metaHTML += `
${post.region_name.replace( 123 | "发布于 ", 124 | "" 125 | )}
` 126 | } 127 | metaHTML += `
` 128 | 129 | let textHTML = `
${clearLineBreak( 130 | post.long_text_source || post.text || post.page_info?.page_title 131 | )}
` 132 | 133 | let retweetHTML = "" 134 | if (post.retweeted_status && post.retweeted_status.user) { 135 | retweetHTML += `
` 136 | retweetHTML += `${ 137 | post.retweeted_status.user.screen_name 138 | ? post.retweeted_status.user.screen_name 139 | : "" 140 | }:${clearLineBreak( 141 | post.retweeted_status.long_text_source || post.retweeted_status.text 142 | )}` 143 | retweetHTML += `
` 144 | } 145 | 146 | let mediaHTML = "" 147 | 148 | if (post.pic_infos) { 149 | mediaHTML += '
' 150 | for (let key in post.pic_infos) { 151 | mediaHTML += combineImageHtml(post.pic_infos[key].large, 160) 152 | } 153 | mediaHTML += "
" 154 | 155 | mediaHTML += '
' 156 | for (let key in post.pic_infos) { 157 | mediaHTML += combineImageHtml(post.pic_infos[key].large, 320) 158 | } 159 | mediaHTML += "
" 160 | 161 | mediaHTML += '
' 162 | for (let key in post.pic_infos) { 163 | mediaHTML += combineImageHtml(post.pic_infos[key].large, 500) 164 | } 165 | mediaHTML += "
" 166 | } 167 | 168 | let postHTML = ` 169 | ${metaHTML} 170 |
171 | ${textHTML} 172 | ${retweetHTML} 173 | ${mediaHTML} 174 |
` 175 | 176 | let node = document.createElement("div") 177 | node.className = "speechless-post" 178 | node.innerHTML = postHTML 179 | 180 | speechlessListEL.appendChild(node) 181 | } 182 | 183 | updateWholePageState() 184 | } 185 | const fetchWithRetry = async function ( 186 | GetPostsByRangeApiURL, 187 | parameters, 188 | retries = 3 189 | ) { 190 | while (retries > 0) { 191 | try { 192 | const response = await axios.get(GetPostsByRangeApiURL, parameters) 193 | return response 194 | } catch (error) { 195 | console.error(`Fetch failed, ${retries - 1} retries left: `, error) 196 | retries-- 197 | } 198 | } 199 | throw new Error("Maximum retries reached, request failed") 200 | } 201 | 202 | // 拉取数据,并且格式化 203 | const doFetch = async function (parameters) { 204 | if (!parameters) parameters = {} 205 | 206 | let offset = parseInt(new Date().valueOf()) - lastFetchTimeStamp 207 | if (offset < interval) { 208 | let delayMS = interval - offset 209 | console.log(`Delay of ${delayMS} milliseconds`) 210 | await delay(delayMS) 211 | } 212 | 213 | lastFetchTimeStamp = parseInt(new Date().getTime()) 214 | const fetchResp = await fetchWithRetry(GetPostsByRangeApiURL, { 215 | params: parameters, 216 | }) 217 | 218 | try { 219 | let resp = fetchResp.data.data 220 | let list = resp.list 221 | _callback({ 222 | type: "total", 223 | value: resp.total, 224 | }) 225 | await formatPosts(list, parameters.uid) 226 | return resp 227 | } catch (err) { 228 | console.error(err) 229 | return 230 | } 231 | } 232 | 233 | // 处理每一批的列表 234 | const formatPosts = async function (posts, uid) { 235 | let _list = [] 236 | 237 | for (let post of posts) { 238 | if (post.user.id != uid) continue 239 | if (!!post.isLongText) { 240 | try { 241 | let offset = parseInt(new Date().valueOf()) - lastFetchTimeStamp 242 | if (offset < interval) { 243 | let delayMS = interval - offset 244 | console.log(`Delay of ${delayMS} milliseconds`) 245 | await delay(delayMS) 246 | } 247 | lastFetchTimeStamp = parseInt(new Date().getTime()) 248 | let longtextData = await fetchLongText(post.mblogid) 249 | post.long_text_source = longtextData.longTextContent || "" 250 | console.log(post) 251 | } catch (err) { 252 | console.error(err) 253 | } 254 | } 255 | if (post.retweeted_status && post.retweeted_status.isLongText) { 256 | try { 257 | let offset = parseInt(new Date().valueOf()) - lastFetchTimeStamp 258 | if (offset < interval) { 259 | let delayMS = interval - offset 260 | console.log(`Delay of ${delayMS} milliseconds`) 261 | await delay(delayMS) 262 | } 263 | let longtextData = await fetchLongText(post.retweeted_status.mblogid) 264 | post.retweeted_status.long_text_source = 265 | longtextData.longTextContent || "" 266 | } catch (err) { 267 | console.error(err) 268 | } 269 | } 270 | appendPostToBody(post) 271 | _list.push(post) 272 | } 273 | 274 | return _list 275 | } 276 | 277 | function getLastDayTimestamp(obj) { 278 | let { year, month } = obj 279 | const nextMonth = parseInt(month) + 1 280 | const nextMonthFirstDay = new Date(year, nextMonth - 1, 1) 281 | nextMonthFirstDay.setHours(0, 0, 0, 0) 282 | const lastDayTimestamp = nextMonthFirstDay.getTime() - 1 283 | const stamp = Math.floor(lastDayTimestamp / 1000) 284 | return stamp 285 | } 286 | 287 | function getFirstDayTimestamp(obj) { 288 | let { year, month } = obj 289 | const firstDay = new Date(year, parseInt(month) - 1, 1) 290 | firstDay.setHours(0, 0, 0, 0) 291 | const firstDayTimestamp = firstDay.getTime() 292 | let stamp = Math.floor(firstDayTimestamp / 1000) 293 | return stamp 294 | } 295 | const fetchLongText = async function (postid) { 296 | let longTextResp = await axios.get(GetLongTextURL, { 297 | params: { 298 | id: postid, 299 | }, 300 | }) 301 | 302 | try { 303 | return longTextResp?.data?.data || "" 304 | } catch (error) { 305 | return 306 | } 307 | } 308 | 309 | // 拉取主要函数 310 | export const fetchPost = async function (parameters, callback) { 311 | _callback = callback 312 | 313 | console.log(parameters) 314 | generateHTML() 315 | 316 | let { uid, sourceType, rangeType, range } = parameters 317 | 318 | _uid = uid 319 | _sourceType = sourceType 320 | 321 | let requestParam = { 322 | uid, 323 | page, 324 | feature: 4, 325 | } 326 | if (rangeType == 1) { 327 | requestParam = { 328 | ...requestParam, 329 | starttime: getFirstDayTimestamp(range.start), 330 | endtime: getLastDayTimestamp(range.end), 331 | } 332 | } 333 | 334 | while (loadMore) { 335 | requestParam.page = page 336 | let respData = await doFetch(requestParam) 337 | console.log(respData) 338 | if (!respData) { 339 | // 如果是接口报错了,什么都不干,继续 page ++ 340 | console.log("接口报错了") 341 | } else { 342 | if (respData?.list?.length > 0) { 343 | total = respData.total 344 | console.log("继续拉") 345 | } else { 346 | loadMore = false 347 | console.log("数据拉完了") 348 | } 349 | } 350 | page++ 351 | } 352 | 353 | appendSpeechless() 354 | } 355 | -------------------------------------------------------------------------------- /src/module/longText.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | 3 | const GetLongTextURL = `https://weibo.com/ajax/statuses/longtext` 4 | 5 | export const fetchLongText = async function (postid) { 6 | let longTextResp = await axios.get(GetLongTextURL, { 7 | params: { 8 | id: postid, 9 | }, 10 | }) 11 | 12 | try { 13 | return longTextResp.data 14 | } catch (error) { 15 | return 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/module/pageHandle.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meterscao/Speechless/21e4aabdedc3509755754dd9e5880e50d647f215/src/module/pageHandle.js -------------------------------------------------------------------------------- /src/module/range.js: -------------------------------------------------------------------------------- 1 | // 根据选择的月份区间 和 用户的微博月份区间 得到一个有效的月份区间 2 | export const getRangeMonths = function (yearMap, range) { 3 | if (!yearMap) return [] 4 | if (!range) return [] 5 | 6 | let historyMonths = [] 7 | for (let y = range.start.year; y <= range.end.year; y++) { 8 | for (let m = 1; m <= 12; m++) { 9 | if (y == range.start.year && m < range.start.month) continue 10 | if (y == range.end.year && m > range.end.month) break 11 | historyMonths.push(`${y}|${m}`) 12 | } 13 | } 14 | 15 | let mapMonths = [] 16 | for (const year in yearMap) { 17 | const monthsInYear = yearMap[year]; 18 | mapMonths = mapMonths.concat([], monthsInYear.map(month => { 19 | return `${year}|${month}` 20 | })) 21 | } 22 | 23 | let rangeMonths = mapMonths.filter(function (m) { return historyMonths.indexOf(m) > -1 }) 24 | console.log('rangeMonths :', rangeMonths) 25 | if (rangeMonths.length > 0) { 26 | rangeMonths = rangeMonths.reverse() 27 | } 28 | return rangeMonths 29 | 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/module/test.js: -------------------------------------------------------------------------------- 1 | let map = { 2 | 2016: [6, 7, 8], 3 | 2018: [9], 4 | } 5 | 6 | let range = { 7 | start: { 8 | year: 2014, 9 | month: 9, 10 | }, 11 | end: { 12 | year: 2018, 13 | month: 10, 14 | }, 15 | } 16 | 17 | const getMonthParameters = function (years) {} 18 | 19 | const convertDateToNumber = function (y, m) { 20 | let n = "" + y + (m < 10 ? "0" + m : "" + m) 21 | console.log(n) 22 | return parseInt(`${y}` + m < 10 ? `0${m}` : `${m}`) 23 | } 24 | 25 | const getValidMonths = function (map, range) { 26 | let historyMonths = [] 27 | for (let y = range.start.year; y <= range.end.year; y++) { 28 | for (let m = 1; m <= 12; m++) { 29 | let num = convertDateToNumber(y, m) 30 | if ( 31 | num >= convertDateToNumber(range.start.year, range.start.month) && 32 | num <= convertDateToNumber(range.end.year, range.end.month) 33 | ) { 34 | historyMonths.push(`${y}|${m}`) 35 | } 36 | } 37 | } 38 | console.log(historyMonths) 39 | let rangeMonths = [] 40 | } 41 | 42 | getValidMonths(map, range) 43 | -------------------------------------------------------------------------------- /src/module/userInfo.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | 3 | const UserInfoApiPath = `https://weibo.com/ajax/profile/info` 4 | const UserBlogHistoryApiPath = `https://weibo.com/ajax/profile/mbloghistory` 5 | 6 | const _getIDFromURL = function () { 7 | let id 8 | let idfrom 9 | let url = decodeURIComponent(location.href) 10 | 11 | if (!id) { 12 | // https://weibo.com/u/1738498871 13 | let regRes = url.match(/weibo.com\/u\/*(\w+)/) 14 | if (regRes && regRes.length > 1) { 15 | id = regRes.pop() 16 | idfrom = "uid" 17 | } 18 | } 19 | 20 | if (!id) { 21 | // https://weibo.com/n/%E6%83%A0%E8%8B%B1%E7%B4%85kara 22 | let regRes = url.match(/https:\/\/weibo\.com\/n\/([\w一-龥]+)/) 23 | if (regRes && regRes.length > 1) { 24 | id = regRes.pop() 25 | idfrom = "screen_name" 26 | } 27 | } 28 | 29 | if (!id) { 30 | // https://weibo.com/sandra0314 31 | let regRes = url.match(/weibo.com\/(\w+)/) 32 | if (regRes && regRes.length > 1) { 33 | id = regRes.pop() 34 | idfrom = "custom" 35 | } 36 | } 37 | console.log("id from url is: ", id) 38 | return { 39 | idfrom, 40 | id, 41 | } 42 | } 43 | 44 | const _fetchBlogHistory = async function (uid) { 45 | if (uid) { 46 | let historyResp = await axios.get(UserBlogHistoryApiPath, { 47 | params: { 48 | uid: uid, 49 | }, 50 | }) 51 | try { 52 | let yearMap = historyResp.data.data 53 | console.log("yearMap", yearMap) 54 | return yearMap 55 | } catch (error) { 56 | console.error(error) 57 | return 58 | } 59 | } else { 60 | return 61 | } 62 | } 63 | 64 | export const fetchUserInfo = async function () { 65 | let { id, idfrom } = _getIDFromURL() 66 | 67 | if (id) { 68 | let parm = {} 69 | parm[idfrom] = id 70 | let userResp = await axios.get(UserInfoApiPath, { 71 | params: parm, 72 | }) 73 | 74 | try { 75 | let uid = userResp.data.data.user.id 76 | let username = userResp.data.data.user.screen_name 77 | console.log("uid", uid) 78 | console.log("username", username) 79 | let history = await _fetchBlogHistory(uid) 80 | 81 | return { 82 | id, 83 | uid, 84 | username, 85 | history, 86 | } 87 | } catch (error) { 88 | console.error(error) 89 | return 90 | } 91 | } else { 92 | return 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | // tailwind.config.js 2 | module.exports = { 3 | content:['./src/**/*.{vue,js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | extend: {}, 9 | }, 10 | plugins: [ 11 | require('@tailwindcss/forms'), 12 | ], 13 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const { VueLoaderPlugin } = require('vue-loader') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | 5 | module.exports = { 6 | watch:true, 7 | entry: { 8 | index: path.resolve(__dirname, "src", "main.js") 9 | }, 10 | output: { 11 | path: path.resolve(__dirname, "build") 12 | }, 13 | module: { 14 | 15 | rules: [ 16 | // ... 其它规则 17 | { 18 | test: /\.vue$/, 19 | loader: 'vue-loader' 20 | }, 21 | { 22 | test: /\.css$/, 23 | use: [ 24 | 'vue-style-loader', 25 | 'css-loader', 26 | 'postcss-loader' 27 | ] 28 | } 29 | ] 30 | }, 31 | plugins: [ 32 | new HtmlWebpackPlugin({ 33 | template: path.resolve(__dirname, "src", "index.html") 34 | }), 35 | // 请确保引入这个插件! 36 | new VueLoaderPlugin() 37 | ] 38 | } --------------------------------------------------------------------------------