├── .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 | 
4 |
5 | Speechless 是一个帮助新浪微博用户,把微博内容导出成 PDF 进行本地备份的 Chrome Extension。
6 |
7 | 查看官网👉 [https://sppechless.fun](https://speechless.fun)
8 |
9 | ## 使用
10 |
11 | - 通过 Chrome Web Store 安装(推荐)
12 |
13 | [](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 | 
43 |
44 | 
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 |
2 |
7 |
8 | S
9 | P
10 | E
11 | E
12 | C
13 | H
14 | L
15 | E
16 | S
17 | S
18 |
19 |
20 |
21 |
24 |
正在初始化...
25 |
26 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | S
79 | P
80 | E
81 | E
82 | C
83 | H
84 | L
85 | E
86 | S
87 | S
88 |
89 |
V2.2
93 |
94 |
97 |
98 | 把
99 |
103 | 的记忆打包。
104 |
105 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
123 |
124 |
125 |
130 |
131 | 结束时间须晚于开始时间
132 |
133 |
134 |
135 |
140 |
141 |
142 |
149 |
150 |
151 |
152 |
153 |
154 |
155 | {{ pendingWording }}
159 |
160 |
161 |
162 | 拼命回忆中...
163 | {{ count }}/{{ total }}
164 |
165 |
171 |
172 |
173 |
174 |
175 |
176 |
177 | {{ pendingWording }}
181 |
182 |
183 |
188 |
189 |
190 |
191 |
192 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
![]()
210 |
211 |
212 | 有帮到你吗?
213 |
214 |
请我喝杯奶茶吧 :-)
215 |
216 |
235 |
236 |
237 |
238 |
239 |
240 |
245 |
252 |
253 |
254 |
255 |
256 |
257 |
258 | 请切到
用户主页,再刷新下页面试试
260 | 去
speechless.fun查看更多帮助
266 |
267 |
268 |
269 |
270 |
271 |
272 |
480 |
485 |
--------------------------------------------------------------------------------
/src/component/SelectMonth.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ title }}
4 |
5 |
14 |
23 |
24 |
25 |
26 |
27 |
72 |
--------------------------------------------------------------------------------
/src/component/SelectNative.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 | {{ description }}
9 |
10 |
11 |
24 |
25 |
26 |
27 |
28 |
44 |
--------------------------------------------------------------------------------
/src/component/SelectTimeRange.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
15 |
16 |
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 += ``
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 += ``
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 | }
--------------------------------------------------------------------------------