├── .dockerignore ├── .gitignore ├── .replit ├── Dockerfile ├── LICENSE ├── backend ├── .gitignore ├── .replit ├── api.js ├── app.js ├── bin │ └── www ├── netlify.toml ├── package-lock.json ├── package.json ├── public │ └── .gitignore ├── replit.nix ├── routes │ ├── api.js │ └── redbook.js └── utils │ ├── douyinService.js │ ├── redbookService.js │ ├── x-bogus.js │ └── xhs-sign.js ├── frontend ├── .cache │ ├── replit │ │ ├── __replit_disk_meta.json │ │ ├── modules.stamp │ │ └── nix │ │ │ └── env.json │ └── typescript │ │ └── 4.4 │ │ ├── package-lock.json │ │ └── package.json ├── .gitignore ├── .replit ├── .upm │ └── store.json ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── help.svg │ ├── logo.png │ └── logo.svg ├── replit.nix ├── replit_zip_error_log.txt ├── src │ ├── App.vue │ ├── assets │ │ └── main.css │ ├── components │ │ └── Home.vue │ ├── locales │ │ ├── en.json │ │ ├── i18n.js │ │ └── zh.json │ ├── main.js │ └── utils │ │ ├── config.js │ │ └── tableUtils.js └── vite.config.js ├── package-lock.json ├── package.json ├── readme.md └── use-doc ├── 使用截图.png ├── 使用示例.gif ├── 填写自定义插件.png ├── 新增自定义插件.png └── 获取cookie.png /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | backend/node_modules 3 | npm-debug.log -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js 2 | node_modules 3 | bower_components 4 | nodemon 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | modules = ["nodejs-20:v8-20230920-bd784b9"] 2 | hidden = [".config", "package-lock.json"] 3 | run = "npm run build && npm run start" 4 | 5 | [nix] 6 | channel = "stable-23_05" 7 | 8 | [unitTest] 9 | language = "nodejs" 10 | 11 | [deployment] 12 | run = ["sh", "-c", "npm run build && npm run start"] 13 | deploymentTarget = "cloudrun" 14 | ignorePorts = false 15 | build = ["sh", "-c", "npm install"] 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/feishudouyin 5 | 6 | # Install app dependencies 7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 8 | # where available (npm@5+) 9 | COPY package*.json ./ 10 | 11 | RUN npm install 12 | RUN npm build 13 | # If you are building your code for production 14 | # RUN npm ci --omit=dev 15 | 16 | # Bundle app source 17 | COPY ./backend . 18 | 19 | EXPOSE 4000 20 | 21 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js 2 | node_modules 3 | bower_components 4 | nodemon 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | -------------------------------------------------------------------------------- /backend/.replit: -------------------------------------------------------------------------------- 1 | modules = ["nodejs-20:v8-20230920-bd784b9"] 2 | hidden = [".config", "package-lock.json"] 3 | run = "npm run start" 4 | 5 | [nix] 6 | channel = "stable-23_05" 7 | 8 | [unitTest] 9 | language = "nodejs" 10 | 11 | [deployment] 12 | run = ["sh", "-c", "npm run start"] 13 | deploymentTarget = "cloudrun" 14 | ignorePorts = false 15 | -------------------------------------------------------------------------------- /backend/api.js: -------------------------------------------------------------------------------- 1 | import serverless from "serverless-http"; 2 | 3 | const app = require('./app'); 4 | 5 | export const handler = serverless(app); -------------------------------------------------------------------------------- /backend/app.js: -------------------------------------------------------------------------------- 1 | var createError = require('http-errors'); 2 | var express = require('express'); 3 | var path = require('path'); 4 | var cookieParser = require('cookie-parser'); 5 | var logger = require('morgan'); 6 | var favicon = require('serve-favicon') 7 | 8 | var apiRouter = require('./routes/api'); 9 | var redbookRouter = require('./routes/redbook'); 10 | 11 | var app = express(); 12 | 13 | 14 | // view engine setup 15 | app.engine('html', require('ejs').renderFile); 16 | 17 | app.set('view engine', 'html'); 18 | app.use(logger('dev')); 19 | app.use(express.json()); 20 | app.use(express.urlencoded({ extended: false })); 21 | app.use(cookieParser()); 22 | app.use(favicon(path.join(__dirname, 'public/favicon.ico'))); 23 | 24 | // CROS 25 | app.all('*', function (req, res, next) { 26 | res.header('Access-Control-Allow-Origin', '*') 27 | res.header('Access-Control-Allow-Headers', 'Content-Type,Content-Length, Authorization, Accept,X-Requested-With') 28 | res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS') 29 | res.header('X-Powered-By', ' 3.2.1') 30 | req.method == "OPTIONS" ? res.send(200) : next() 31 | }) 32 | 33 | // 主页 34 | app.use(express.static(path.join(__dirname, 'public'))); 35 | // 调用api 36 | app.use('/api', apiRouter); 37 | app.use('/redbook', redbookRouter); 38 | 39 | // catch 404 and forward to error handler 40 | app.use(function(req, res, next) { 41 | next(createError(404)); 42 | }); 43 | 44 | // error handler 45 | app.use(function(err, req, res, next) { 46 | // set locals, only providing error in development 47 | res.locals.message = err.message; 48 | res.locals.error = req.app.get('env') === 'development' ? err : {}; 49 | 50 | // render the error page 51 | console.log(err) 52 | res.status(err.status || 500); 53 | res.render('error'); 54 | }); 55 | 56 | 57 | module.exports = app; -------------------------------------------------------------------------------- /backend/bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('feishudouyin:server'); 9 | var http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || '4000'); 16 | app.set('port', port); 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | var server = http.createServer(app); 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address(); 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | debug('Listening on ' + bind); 90 | } 91 | -------------------------------------------------------------------------------- /backend/netlify.toml: -------------------------------------------------------------------------------- 1 | [functions] 2 | external_node_modules = ["express"] 3 | node_bundler = "esbuild" 4 | [[redirects]] 5 | force = true 6 | from = "/*" 7 | status = 200 8 | to = "/.netlify/functions/:splat" -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feishu-douyin-tool-be", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node ./bin/www" 7 | }, 8 | "dependencies": { 9 | "@netlify/functions": "^2.5.1", 10 | "axios": "^0.26.1", 11 | "cookie-parser": "~1.4.4", 12 | "debug": "~2.6.9", 13 | "ejs": "^3.1.6", 14 | "express": "^4.18.2", 15 | "express-handlebars": "^6.0.5", 16 | "file-saver": "^2.0.5", 17 | "http-errors": "~1.6.3", 18 | "jade": "^1.9.2", 19 | "jsdom": "^24.0.0", 20 | "jszip": "^3.10.1", 21 | "md5": "^2.3.0", 22 | "morgan": "~1.9.1", 23 | "nodemon": "^2.0.15", 24 | "serve-favicon": "^2.5.0", 25 | "serverless-http": "^3.2.0" 26 | }, 27 | "description": "\"image\"", 28 | "main": "app.js", 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/happyVee/feishu-douyin-tool.git" 32 | }, 33 | "keywords": [], 34 | "author": "", 35 | "license": "ISC", 36 | "bugs": { 37 | "url": "https://github.com/happyVee/feishu-douyin-tool/issues" 38 | }, 39 | "homepage": "https://github.com/happyVee/feishu-douyin-tool#readme" 40 | } 41 | -------------------------------------------------------------------------------- /backend/public/.gitignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /backend/replit.nix: -------------------------------------------------------------------------------- 1 | {pkgs}: { 2 | deps = [ ]; 3 | } 4 | -------------------------------------------------------------------------------- /backend/routes/api.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { GetID, GetInfo } = require('../utils/douyinService'); 3 | const getXB = require('../utils/x-bogus.js') 4 | 5 | let router = express.Router(); 6 | 7 | /* 获取API */ 8 | router.post('/', async function(req, res, next) { 9 | try { 10 | // 尝试从cookies中获取并解析dycookie 11 | let dyCookie; 12 | try { 13 | dyCookie = req.body.dyCookie; 14 | } catch { 15 | dyCookie = null; 16 | } 17 | 18 | 19 | // 如果dycookie不存在或无法解析rs 20 | const allEmpty = Object.values(dyCookie).every(value => value === ''); 21 | if (allEmpty) { 22 | res.render('index', { videoData: {work:false} }); 23 | return; 24 | } 25 | 26 | // 如果未提供URL,则使用默认视频 27 | let videoUrl = req.body.url; 28 | if (!videoUrl) { 29 | throw new Error('视频地址为空'); 30 | } 31 | 32 | // 检查URL的有效性(简单的) 33 | if (!videoUrl.startsWith('https://v.douyin.com/') && !videoUrl.startsWith('https://www.douyin.com/')) { 34 | throw new Error('无效的URL地址'); 35 | } 36 | 37 | // 根据提供的URL获取视频ID 38 | const videoId = await GetID(videoUrl); 39 | 40 | // 使用视频ID和cookie获取视频详情 41 | const videoData = await GetInfo(videoId, dyCookie, getXB); 42 | res.send({code:0, data: videoData, msg: '解析成功'}) 43 | } catch (error) { 44 | next(error); // 转发错误到错误处理中间件 45 | } 46 | }); 47 | 48 | // 错误处理中间件 49 | router.use((err, req, res, next) => { 50 | console.error(err.stack) 51 | // 不要尝试嗅探并覆盖响应的MIME类型 52 | res.setHeader('X-Content-Type-Options', 'nosniff'); 53 | // 根据错误类型或消息为用户提供不同的反馈 54 | if (err.message.includes('无效的URL地址')) { 55 | res.status(200).send({code:400, data: null, msg: '提供的URL无效'}) 56 | } else { 57 | res.status(200).send({code:400, data: null, msg: '服务器内部错误'}) 58 | } 59 | }); 60 | 61 | module.exports = router; -------------------------------------------------------------------------------- /backend/routes/redbook.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { getAllNoteList, getProfileInfo, getNoteInfo } = require('../utils/redbookService.js'); 3 | 4 | let router = express.Router(); 5 | 6 | /* 获取全部笔记API */ 7 | router.post('/getNoteList', async function(req, res, next) { 8 | try { 9 | // 尝试从cookies中获取并解析dycookie 10 | let xhsCookies; 11 | try { 12 | xhsCookies = req.body.xhsCookies; 13 | } catch { 14 | xhsCookies = null; 15 | } 16 | 17 | 18 | // 如果dycookie不存在或无法解析rs 19 | const allEmpty = Object.values(xhsCookies).every(value => value === ''); 20 | if (allEmpty) { 21 | res.send({code:400, data: null, msg: '提供cookies参数缺失'}) 22 | return; 23 | } 24 | 25 | // 如果未提供URL,则使用默认视频 26 | let userUrl = req.body.url; 27 | if (!userUrl) { 28 | throw new Error('用户主页地址为空'); 29 | } 30 | 31 | // 检查URL的有效性(简单的) 32 | if (!userUrl.startsWith('https://www.xiaohongshu.com/')) { 33 | throw new Error('无效的URL地址'); 34 | } 35 | 36 | // 根据提供的URL获取视频ID 37 | const parts = userUrl.split("/"); 38 | const userId = parts[parts.length - 1]; 39 | const regex = /^[0-9a-zA-Z]{24}$/; 40 | if(!regex.test(userId)) { 41 | throw new Error('用户主页地址错误'); 42 | } 43 | 44 | // 使用视频ID和cookie获取视频详情 45 | const noteList = await getAllNoteList(userId, xhsCookies); 46 | res.send({code:0, data: noteList, msg: '成功'}) 47 | } catch (error) { 48 | console.log("error==========>", error); 49 | next(error); // 转发错误到错误处理中间件 50 | } 51 | }); 52 | 53 | /* 获取用户信息API */ 54 | router.post('/getProfileInfo', async function(req, res, next) { 55 | try { 56 | // 尝试从cookies中获取并解析dycookie 57 | let xhsCookies; 58 | try { 59 | xhsCookies = req.body.xhsCookies; 60 | } catch { 61 | xhsCookies = null; 62 | } 63 | 64 | 65 | // 如果dycookie不存在或无法解析rs 66 | const allEmpty = Object.values(xhsCookies).every(value => value === ''); 67 | if (allEmpty) { 68 | res.send({code:400, data: null, msg: '提供cookies参数缺失'}) 69 | return; 70 | } 71 | 72 | // 如果未提供URL 73 | let userUrl = req.body.url; 74 | if (!userUrl) { 75 | throw new Error('用户主页地址为空'); 76 | } 77 | 78 | // 检查URL的有效性(简单的) 79 | if (!userUrl.startsWith('https://www.xiaohongshu.com/')) { 80 | throw new Error('无效的URL地址'); 81 | } 82 | 83 | // 根据提供的URL获取用户ID 84 | const parts = userUrl.split("/"); 85 | const userId = parts[parts.length - 1]; 86 | const regex = /^[0-9a-zA-Z]{24}$/; 87 | if(!regex.test(userId)) { 88 | throw new Error('用户主页地址错误'); 89 | } 90 | 91 | // 使用用户ID和cookie获取用户详情 92 | const profileInfo = await getProfileInfo(userId, xhsCookies); 93 | res.send({code:0, data: profileInfo, msg: '成功'}) 94 | } catch (error) { 95 | console.log("error==========>", error); 96 | next(error); // 转发错误到错误处理中间件 97 | } 98 | }); 99 | 100 | /* 获取笔记API */ 101 | router.post('/getNoteInfo', async function(req, res, next) { 102 | try { 103 | // 尝试从cookies中获取并解析dycookie 104 | let xhsCookies; 105 | try { 106 | xhsCookies = req.body.xhsCookies; 107 | } catch { 108 | xhsCookies = null; 109 | } 110 | 111 | 112 | // 如果dycookie不存在或无法解析rs 113 | const allEmpty = Object.values(xhsCookies).every(value => value === ''); 114 | if (allEmpty) { 115 | res.status(400).send({code:400, data: null, msg: '提供cookies参数缺失'}) 116 | return; 117 | } 118 | 119 | // 如果未提供URL 120 | let noteUrl = req.body.url; 121 | if (!noteUrl) { 122 | throw new Error('用户主页地址为空'); 123 | } 124 | 125 | // 检查URL的有效性(简单的) 126 | if (!noteUrl.startsWith('https://www.xiaohongshu.com/')) { 127 | throw new Error('无效的URL地址'); 128 | } 129 | 130 | // 根据提供的URL获取笔记ID 131 | const parts = noteUrl.split("/"); 132 | const noteId = parts[parts.length - 1]; 133 | const regex = /^[0-9a-zA-Z]{24}$/; 134 | if(!regex.test(noteId)) { 135 | throw new Error('用户主页地址错误'); 136 | } 137 | 138 | // 使用笔记ID和cookie获取笔记详情 139 | const noteInfo = await getNoteInfo(noteId, xhsCookies); 140 | res.send({code:0, data: noteInfo, msg: '成功'}) 141 | } catch (error) { 142 | console.log("error==========>", error); 143 | next(error); // 转发错误到错误处理中间件 144 | } 145 | }); 146 | 147 | // 错误处理中间件 148 | router.use((err, req, res, next) => { 149 | console.log("use error==========>", err); 150 | console.error(err.stack) 151 | // 不要尝试嗅探并覆盖响应的MIME类型 152 | res.setHeader('X-Content-Type-Options', 'nosniff'); 153 | // 根据错误类型或消息为用户提供不同的反馈 154 | if (err.message.includes('无效的URL地址')) { 155 | res.status(200).send({code:400, data: null, msg: '提供的URL无效'}) 156 | } else { 157 | res.status(200).send({code:400, data: null, msg: err.message ?? '服务器内部错误'}) 158 | } 159 | }); 160 | 161 | module.exports = router; -------------------------------------------------------------------------------- /backend/utils/douyinService.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | 3 | // 常量定义 4 | const invalid = /[\\\n\r/:*?\"<>|]/g; 5 | const repWith = ``; 6 | const AWE_URL_BASE = "http://aweme.snssdk.com/aweme/v1/play/?"; 7 | const DETAIL_URL_BASE = 'https://www.douyin.com/aweme/v1/web/aweme/detail/?' 8 | const USER_AGENT_MOBILE = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"; 9 | const USER_AGENT_DESKTOP = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"; 10 | const VIDEO_REGEX = /video\/(\d*)/; 11 | const NOTE_REGEX = /note\/(\d*)/; 12 | 13 | /** 14 | * 获取作品ID 15 | * @param {string} dyurl - 抖音短链接。 16 | * @returns {string} 作品ID 17 | * @throws {Error} 在请求失败或解析ID时可能会抛出错误。 18 | */ 19 | async function GetID(dyurl) { 20 | const response = await axios.get(dyurl, { 21 | headers: { "user-agent": USER_AGENT_DESKTOP }, 22 | }); 23 | 24 | if (response.request.res.responseUrl.includes('video')) { 25 | item_ids = VIDEO_REGEX.exec(response.request.res.responseUrl)[1]; 26 | } else if (response.request.res.responseUrl.includes('note')) { 27 | item_ids = NOTE_REGEX.exec(response.request.res.responseUrl)[1]; 28 | } else { 29 | console.error("URL格式不匹配任何已知模式"); 30 | return; 31 | } 32 | return item_ids; 33 | } 34 | 35 | /** 36 | * 根据作品ID和cookie获取作品详细信息 37 | * @param {string} item_ids - 作品ID。 38 | * @param {Object} dycookie - 抖音cookie对象。 39 | * @param {Function} getXB - 获取XB参数的函数。 40 | * @returns {Object} 作品的详细信息 41 | * @throws {Error} 在请求失败或解析数据时可能会抛出错误。 42 | */ 43 | async function GetInfo(item_ids, dycookie, getXB) { 44 | // 构造请求URL 45 | const params_url = `aweme_id=${item_ids}&aid=1128&version_name=23.5.0&device_platform=android&os_version=2333`; 46 | const xb = getXB(params_url); 47 | const url = `${DETAIL_URL_BASE}${params_url}&X-Bogus=${xb}`; 48 | 49 | const response = await axios.get(url, { 50 | headers: { 51 | 'cookie': `odin_tt=${dycookie["odin_tt"]};sessionid_ss=${dycookie["sessionid_ss"]};ttwid=${dycookie['ttwid']};passport_csrf_token=${dycookie['passport_csrf_token']};msToken=${dycookie['msToken']};`, 52 | 'referer': 'https://www.douyin.com/', 53 | 'user-agent': USER_AGENT_MOBILE 54 | } 55 | }); 56 | 57 | // 如果response.data为空或未定义 58 | if (!response.data) { 59 | return { work: false }; 60 | } 61 | 62 | // 校验响应状态 63 | if (response.data.status_code === 0) { 64 | // console.log(JSON.stringify(response.data)) 65 | const aweme_detail = response.data.aweme_detail; 66 | // 提取需要的数据 67 | const { video, music, author, desc, aweme_id, aweme_type, statistics, create_time} = aweme_detail; 68 | const uniqueId = author.unique_id || author.short_id; // 如果unique_id为空,则使用short_id 69 | const userhome = `https://www.douyin.com/user/${author.sec_uid}`; 70 | const type = Number(aweme_type) === 0 ? '视频' : '图集'; 71 | const images = Number(aweme_type) !== 0 ? aweme_detail.images.map(image => image.url_list[0]) : []; 72 | //const images = aweme_type !== 0 && response.data.aweme_detail.images ? response.data.aweme_detail.images.map(image => image.url_list[0]) : []; 73 | 74 | const url = video?.bit_rate?.[0]?.play_addr?.url_list?.[0] ?? ''; 75 | const videoCover = video?.cover?.url_list?.[0] ?? ''; 76 | const cleanedDesc = desc.replaceAll(invalid, repWith); 77 | 78 | const res = { 79 | url, 80 | type, 81 | title: cleanedDesc, 82 | videoUrl: url, 83 | videoCover, 84 | musicUrl: music.play_url.uri, 85 | musicTitle: music.title, 86 | nickname: author.nickname, 87 | signature: author.signature, 88 | userhome, 89 | uniqueId, 90 | videoId: aweme_id, 91 | images, 92 | statistics, 93 | releaseTime: aweme_detail.create_time * 1000, 94 | collectionCount: Number(statistics.collect_count) , 95 | likeCount: Number(statistics.digg_count), 96 | shareCount: Number(statistics.share_count), 97 | commentCount: Number(statistics.comment_count), 98 | }; 99 | // console.log(res) 100 | return res; 101 | } else { 102 | // 如果响应状态码不为0,抛出错误 103 | throw new Error(`Error with status code: ${response.data.status_code}`); 104 | } 105 | } 106 | 107 | module.exports = { GetID, GetInfo }; 108 | -------------------------------------------------------------------------------- /backend/utils/redbookService.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const getXs = require("./xhs-sign"); 3 | const { json } = require("express"); 4 | 5 | 6 | function getTypeWord(type) { 7 | if (type == 'normal') { 8 | return "图文" 9 | } else { 10 | return "视频" 11 | } 12 | } 13 | 14 | function getGenderWord(gender) { 15 | if (gender === 0) { 16 | return '男'; 17 | } else if (gender === 1) { 18 | return '女'; 19 | } else { 20 | return '未知'; 21 | } 22 | } 23 | 24 | function extractIdFromUrl(url) { 25 | const regex = /\/(\w+)!/; 26 | const match = url.match(regex); 27 | return match ? match[1] : null; 28 | } 29 | 30 | function processUrls(urlList) { 31 | return urlList.map(url => { 32 | const id = extractIdFromUrl(url); 33 | return id ? 'https://sns-img-bd.xhscdn.com/' + id : null; 34 | }).filter(url => url !== null); 35 | } 36 | 37 | function decodedUniChars(url) { 38 | let decodedUniChars = decodeURIComponent(escape(url)); 39 | return decodedUniChars; 40 | } 41 | 42 | function get_cookies() { 43 | return { 44 | "xsecappid": "", 45 | "a1": "", 46 | "webId": "", 47 | "gid": "", 48 | "webBuild": "3.3.4", 49 | "web_session": "", 50 | "websectiga": "", 51 | "sec_poison_id": "" 52 | } 53 | } 54 | 55 | function get_home_headers() { 56 | return { 57 | "authority": "www.xiaohongshu.com", 58 | "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", 59 | "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", 60 | "cache-control": "no-cache", 61 | "pragma": "no-cache", 62 | "sec-ch-ua": "\"Microsoft Edge\";v=\"117\", \"Not;A=Brand\";v=\"8\", \"Chromium\";v=\"117\"", 63 | "sec-ch-ua-mobile": "?0", 64 | "sec-ch-ua-platform": "\"Windows\"", 65 | "sec-fetch-dest": "document", 66 | "sec-fetch-mode": "navigate", 67 | "sec-fetch-site": "none", 68 | "sec-fetch-user": "?1", 69 | "upgrade-insecure-requests": "1", 70 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.47" 71 | } 72 | } 73 | 74 | function get_headers() { 75 | return { 76 | "authority": "edith.xiaohongshu.com", 77 | "accept": "application/json, text/plain, */*", 78 | "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", 79 | "content-type": "application/json;charset=UTF-8", 80 | "origin": "https://www.xiaohongshu.com", 81 | "referer": "https://www.xiaohongshu.com/", 82 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.188", 83 | "x-s": "", 84 | "x-t": "" 85 | } 86 | } 87 | 88 | function get_note_data(note_id) { 89 | return { 90 | "source_note_id": note_id, 91 | "image_scenes": [ 92 | "CRD_PRV_WEBP", 93 | "CRD_WM_WEBP" 94 | ] 95 | } 96 | } 97 | 98 | function get_search_data() { 99 | return { 100 | "image_scenes": "FD_PRV_WEBP,FD_WM_WEBP", 101 | "keyword": "", 102 | "note_type": "0", 103 | "page": "", 104 | "page_size": "20", 105 | "search_id": "2c7hu5b3kzoivkh848hp0", 106 | "sort": "general" 107 | } 108 | } 109 | 110 | function get_params() { 111 | return { 112 | "num": "30", 113 | "cursor": "", 114 | "user_id": "", 115 | "image_scenes": "" 116 | } 117 | } 118 | 119 | function formatNote(note) { 120 | if (!note) { 121 | return null; 122 | } 123 | return { 124 | "url" : "https://www.xiaohongshu.com/explore/" + note.note_id, 125 | "type" : getTypeWord(note.type), 126 | "title" : note.display_title, 127 | "likeCount" : note.interact_info?.liked_count, 128 | "nickname" : note.user?.nick_name, 129 | "userhome" : "https://www.xiaohongshu.com/user/profile/" + note.user?.user_id, 130 | "userAvatar" : note.user?.avatar, 131 | "noteCover" : note.cover?.url, 132 | "noteId" : note.note_id, 133 | } 134 | } 135 | 136 | function handleProfileInfo(userId, htmlText) { 137 | const userhome = "https://www.xiaohongshu.com/user/profile/" + userId 138 | const infoRegex = / 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feishu-douyin-tool-fe", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "preview": "vite preview --port 4173" 8 | }, 9 | "dependencies": { 10 | "@lark-base-open/js-sdk": "^0.3.5", 11 | "axios": "^1.6.3", 12 | "element-plus": "^2.3.6", 13 | "qs": "^6.11.2", 14 | "reset-css": "^5.0.1", 15 | "vue": "^3.2.37", 16 | "vue-i18n": "^9.4.1" 17 | }, 18 | "devDependencies": { 19 | "@vitejs/plugin-vue": "^3.0.1", 20 | "unplugin-auto-import": "^0.16.4", 21 | "unplugin-vue-components": "^0.25.1", 22 | "vite": "^3.0.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yibo365/feishu-wemedia-tool/cab2b56f500a00c932d1397dd2c8862485f28b91/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/help.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yibo365/feishu-wemedia-tool/cab2b56f500a00c932d1397dd2c8862485f28b91/frontend/public/logo.png -------------------------------------------------------------------------------- /frontend/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 88 | 93 | 99 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /frontend/replit.nix: -------------------------------------------------------------------------------- 1 | { pkgs }: { 2 | deps = [ 3 | pkgs.nodejs-16_x 4 | pkgs.nodePackages.typescript-language-server 5 | pkgs.yarn 6 | pkgs.replitPackages.jest 7 | ]; 8 | } -------------------------------------------------------------------------------- /frontend/replit_zip_error_log.txt: -------------------------------------------------------------------------------- 1 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file .cache/typescript/4.4/node_modules/.bin/acorn","time":"2023-11-30T12:49:53Z"} 2 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file .cache/typescript/4.4/node_modules/.bin/browserslist","time":"2023-11-30T12:49:53Z"} 3 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file .cache/typescript/4.4/node_modules/.bin/terser","time":"2023-11-30T12:49:53Z"} 4 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file .cache/typescript/4.4/node_modules/.bin/update-browserslist-db","time":"2023-11-30T12:49:53Z"} 5 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file .cache/typescript/4.4/node_modules/.bin/webpack","time":"2023-11-30T12:49:53Z"} 6 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/.bin/acorn","time":"2023-11-30T12:49:58Z"} 7 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/.bin/esbuild","time":"2023-11-30T12:49:58Z"} 8 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/.bin/nanoid","time":"2023-11-30T12:49:58Z"} 9 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/.bin/parser","time":"2023-11-30T12:49:58Z"} 10 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/.bin/resolve","time":"2023-11-30T12:49:58Z"} 11 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/.bin/rollup","time":"2023-11-30T12:49:58Z"} 12 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/.bin/vite","time":"2023-11-30T12:49:58Z"} 13 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/@vueuse/core/node_modules/.bin/vue-demi-fix","time":"2023-11-30T12:50:08Z"} 14 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/@vueuse/core/node_modules/.bin/vue-demi-switch","time":"2023-11-30T12:50:08Z"} 15 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/@vueuse/shared/node_modules/.bin/vue-demi-fix","time":"2023-11-30T12:50:08Z"} 16 | {"error":".zip archives do not support non-regular files","level":"error","msg":"unable to write file node_modules/@vueuse/shared/node_modules/.bin/vue-demi-switch","time":"2023-11-30T12:50:08Z"} 17 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 25 | -------------------------------------------------------------------------------- /frontend/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import 'reset-css'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/Home.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 272 | 273 | 274 | 275 | 288 | -------------------------------------------------------------------------------- /frontend/src/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Little Red Book Data Retrieval", 3 | "selectGroup": { 4 | "selectAll": "Select All", 5 | "videoInfo": { 6 | "type": "Type", 7 | "title": "Title", 8 | "uploader": "Uploader", 9 | "nickname": "Nickname", 10 | "userAvatar": "User Avatar", 11 | "releaseTime": "Release Time", 12 | "collectionCount": "Collection Count", 13 | "likeCount": "Like Count", 14 | "shareCount": "Share Count", 15 | "commentCount": "Comment Count", 16 | "videoUrl": "Video URL", 17 | "videoCover": "Video Cover", 18 | "musicUrl": "Music URL", 19 | "musicTitle": "Music Title", 20 | "signature": "Uploader Signature", 21 | "userhome": "User Home", 22 | "videoId": "Video ID", 23 | "images": "Image List", 24 | "fetchDataTime": "Data Fetch Time" 25 | } 26 | }, 27 | "alerts": { 28 | "selectNumberField": "Retrieve data such as title, like count, etc., based on Little Red Book article links", 29 | "selectGroupFieldTip": "The plugin can automatically create selected fields. For mapping, ensure the field names in the multi-dimensional table match the option names. 'Blogger' field type suggested as text, 'xx count' as number, and 'xx time' as date. 'Total Interaction Count' field can be calculated from multiple fields" 30 | }, 31 | "labels": { 32 | "link": "Link", 33 | "cookie": "Cookie", 34 | "xSCommon": "X-S-Common" 35 | }, 36 | "checks": { 37 | "2": "Type Check Failed: Type is not a number", 38 | "1": "Type Check Failed: Type is not text", 39 | "5": "Type Check Failed: Type is not a date", 40 | "17": "Type Check Failed: Type is not an attachment" 41 | }, 42 | "placeholder": { 43 | "link": "Please select the field column corresponding to the video link", 44 | "interCount": "Please select all the field columns that need to be calculated", 45 | "cookie": "Please enter the cookie as per the help guide", 46 | "xSCommon": "Please enter X-S-Common as per the help guide" 47 | }, 48 | "detailMode": "Enable Detailed Mode", 49 | "submit": "Retrieve Data", 50 | "helpTip": "For the help guide, please see here 👉️", 51 | "errorTip": { 52 | "emptyNoteLink": "Incorrect article link address", 53 | "errorLink": "Incorrect link format, please refer to the documentation", 54 | "errorLinkType": "Incorrect field type, please set as text type", 55 | "errorRequest": "Cookie error or request timed out" 56 | }, 57 | "finishTip": "Data retrieval complete, number of failed requests:" 58 | } 59 | -------------------------------------------------------------------------------- /frontend/src/locales/i18n.js: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n' 2 | import en from './en.json'; 3 | import zh from './zh.json'; 4 | import { bitable } from '@lark-base-open/js-sdk' 5 | 6 | 7 | 8 | export const i18n = createI18n({ 9 | locale: 'zh', 10 | allowComposition: true, // 占位符支持 11 | messages: { 12 | en: en, 13 | zh: zh 14 | } 15 | }) 16 | 17 | bitable.bridge.getLanguage().then((lang) => { 18 | i18n.global.locale = lang 19 | }) 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/locales/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "title":"抖音、小红书数据获取", 3 | "selectGroup": { 4 | "selectAll": "全选", 5 | "videoInfo": { 6 | "url": "链接", 7 | "type": "类型", 8 | "title": "标题", 9 | "desc": "描述内容", 10 | "uploader": "博主", 11 | "nickname": "博主", 12 | "userAvatar": "博主头像", 13 | "tags": "标签", 14 | "releaseTime": "发布时间", 15 | "collectionCount": "收藏量", 16 | "likeCount": "点赞量", 17 | "shareCount": "转发量", 18 | "commentCount": "评论量", 19 | "followsCount": "关注数", 20 | "fansCount": "粉丝数", 21 | "interactionCount": "互动数", 22 | "gender": "性别", 23 | "videoUrl": "视频地址", 24 | "videoCover": "视频封面", 25 | "musicUrl": "配乐地址", 26 | "musicTitle": "配乐标题", 27 | "signature": "博主签名", 28 | "userhome": "用户主页", 29 | "videoId": "视频id", 30 | "noteId": "笔记id", 31 | "images": "图片列表", 32 | "imageNoWater": "去水印图片", 33 | "noteCover": "笔记封面", 34 | "ipLocation": "归属地", 35 | "fetchDataTime": "数据获取时间", 36 | "msg" : "错误信息" 37 | } 38 | }, 39 | "alerts": { 40 | "selectNumberField": "依据短链接,获取标题、点赞量、视频链接等数据", 41 | "selectGroupFieldTip": "插件可自动创建所选字段。如需映射,请确保多维表格中的字段名称和选项名称一致,建议“博主”字段类型为文本,“xx量”字段类型为数字, “xx时间”字段为日期。" 42 | }, 43 | "labels": { 44 | "dataType": "获取数据类型", 45 | "link": "链接所在列", 46 | "cookie": "ttwid Cookie", 47 | "xhsCookie": "Cookie", 48 | "xSCommon": "X-S-Common" 49 | }, 50 | "checks": { 51 | "2": "类型校验失败:类型不是数字", 52 | "1": "类型校验失败:类型不是文本", 53 | "5": "类型校验失败:类型不是日期", 54 | "17": "类型校验失败:类型不是附件" 55 | }, 56 | "placeholder": { 57 | "dataType": "获取要获取的数据类型", 58 | "link": "请选择视频链接对应的字段列", 59 | "interCount": "请选择所有需要计算的字段列", 60 | "cookie": "请参照说明文档输入 cookie", 61 | "xhsCookie": "请参照说明文档输入 cookie", 62 | "xSCommon": "请参照说明文档输入 X-S-Common" 63 | }, 64 | "detailMode": "开启详细模式", 65 | "submit": "获取数据", 66 | "helpTip": "帮助指南请查看这里 👉️", 67 | "errorTip": { 68 | "emptyNoteLink": "文章链接地址错误", 69 | "errorLink": "链接格式错误,请查看说明文档", 70 | "errorLinkType": "字段类型错误,请设置为文本类型", 71 | "errorRequest": "Cookie 错误或请求超时" 72 | }, 73 | "finishTip": "数据获取完成, 请求失败数:" 74 | 75 | } -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import './assets/main.css' 4 | import {i18n} from './locales/i18n.js' 5 | createApp(App).use(i18n).mount('#app') // 注入国际化函数$t 6 | -------------------------------------------------------------------------------- /frontend/src/utils/config.js: -------------------------------------------------------------------------------- 1 | import { FieldType } from "@lark-base-open/js-sdk"; 2 | 3 | export const config = { 4 | serverHost: 'http://127.0.0.1:4000', 5 | feilds: { 6 | url: { key: "url", zh: "链接", en: "Link", type: FieldType.Text }, 7 | type: { key: "type", zh: "类型", en: "Type", type: FieldType.Text }, 8 | title: { key: "title", zh: "标题", en: "Title", type: FieldType.Text }, 9 | desc: { key: "desc", zh: "描述内容", en: "Description", type: FieldType.Text }, 10 | uploader: { key: "uploader", zh: "博主", en: "Uploader", type: FieldType.Text }, 11 | nickname: { key: "nickname", zh: "博主", en: "Nickname", type: FieldType.Text }, 12 | userAvatar: { key: "userAvatar", zh: "博主头像", en: "User Avatar", type: FieldType.Text }, 13 | tags: { key: "tags", zh: "标签", en: "Tags", type: FieldType.Text }, 14 | releaseTime: { key: "releaseTime", zh: "发布时间", en: "Release Time", type: FieldType.DateTime }, 15 | collectionCount: { key: "collectionCount", zh: "收藏量", en: "Collection Count", type: FieldType.Number }, 16 | likeCount: { key: "likeCount", zh: "点赞量", en: "Like Count", type: FieldType.Number }, 17 | shareCount: { key: "shareCount", zh: "转发量", en: "Share Count", type: FieldType.Number }, 18 | commentCount: { key: "commentCount", zh: "评论量", en: "Comment Count", type: FieldType.Number }, 19 | followsCount: { key: "followsCount", zh: "关注数", en: "Follows Count", type: FieldType.Number }, 20 | fansCount: { key: "fansCount", zh: "粉丝数", en: "Fans Count", type: FieldType.Number }, 21 | interactionCount: { key: "interactionCount", zh: "互动数", en: "Interaction Count", type: FieldType.Number }, 22 | gender: { key: "gender", zh: "性别", en: "Gender", type: FieldType.Text }, 23 | videoUrl: { key: "videoUrl", zh: "视频地址", en: "Video URL", type: FieldType.Text }, 24 | videoCover: { key: "videoCover", zh: "视频封面", en: "Video Cover", type: FieldType.Text }, 25 | musicUrl: { key: "musicUrl", zh: "配乐地址", en: "Music URL", type: FieldType.Text }, 26 | musicTitle: { key: "musicTitle", zh: "配乐标题", en: "Music Title", type: FieldType.Text }, 27 | signature: { key: "signature", zh: "博主签名", en: "Signature", type: FieldType.Text }, 28 | userhome: { key: "userhome", zh: "用户主页", en: "User Home", type: FieldType.Text }, 29 | videoId: { key: "videoId", zh: "视频id", en: "Video ID", type: FieldType.Text }, 30 | noteId: { key: "noteId", zh: "笔记id", en: "Note ID", type: FieldType.Text }, 31 | images: { key: "images", zh: "图片列表", en: "Images", type: FieldType.Text }, 32 | imageNoWater: { key: "imageNoWater", zh: "去水印图片", en: "Image No Water", type: FieldType.Text }, 33 | noteCover: { key: "noteCover", zh: "笔记封面", en: "Note Cover", type: FieldType.Text }, 34 | ipLocation: { key: "ipLocation", zh: "归属地", en: "IP Location", type: FieldType.Text }, 35 | fetchDataTime: { key: "fetchDataTime", zh: "数据获取时间", en: "Fetch Data Time", type: FieldType.DateTime }, 36 | msg: { key: "msg", zh: "错误信息", en: "Error Message", type: FieldType.Text } 37 | }, 38 | dataType: [ 39 | { 40 | label: '获取抖音作品数据', 41 | value: 'douyinDetail', 42 | path: '/api', 43 | canChooseField: [ 'type', 'title', 'nickname', 'releaseTime','collectionCount', 'likeCount', 'shareCount', 'commentCount', 44 | 'videoUrl', 'videoCover', 'musicUrl', 'musicTitle', 'signature', 'userhome', 'videoId', 'images', 'msg', 'fetchDataTime' 45 | ], 46 | }, 47 | { 48 | label: '获取小红书作品数据', 49 | value: 'redbookNoteInfo', 50 | path: '/redbook/getNoteInfo', 51 | canChooseField: [ "url", "type", "title", "userhome", "nickname", "userAvatar", "desc", "likeCount", "collectionCount", "commentCount", 52 | "shareCount", "videoUrl", "releaseTime", "noteId", "images", "imageNoWater", "noteCover", 'fetchDataTime' 53 | ], 54 | }, 55 | { 56 | label: '获取小红书作者数据', 57 | value: 'redbookProfileInfo', 58 | path: '/redbook/getProfileInfo', 59 | canChooseField: ['userhome', 'nickname', 'userAvatar', 'signature', 'followsCount', 'fansCount', 'interactionCount', 'ipLocation', 'gender', 'fetchDataTime'], 60 | }, 61 | { 62 | label: '获取小红书作者全部笔记', 63 | value: 'redbookNoteList', 64 | path: '/redbook/getNoteList', 65 | canChooseField: ["url", "type", "title", "likeCount", "nickname", "userhome", "userAvatar", "noteCover", "noteId", 'fetchDataTime'], 66 | } 67 | ], 68 | doc: "https://aigccamp.feishu.cn/wiki/LvQRwI1A4iYtnMkOBtZc2zfsnMd" 69 | } -------------------------------------------------------------------------------- /frontend/src/utils/tableUtils.js: -------------------------------------------------------------------------------- 1 | import { bitable, FieldType } from "@lark-base-open/js-sdk"; 2 | import { config } from './config'; 3 | import {i18n} from '../locales/i18n.js'; 4 | 5 | const lang = i18n.global.locale; 6 | 7 | // 获取所勾选字段的字段Id 8 | const getSelectedFieldsId = (fieldMetaList, checkedFields) => { 9 | const mappedFields = {}; 10 | for (let field of checkedFields) { 11 | // 查找与checkedFields相匹配的fieldListSeView项目 12 | const foundField = fieldMetaList.find(f => f.name === config.feilds[field][lang]); 13 | 14 | const feildType = getFieldTypeByKey(field); 15 | if (foundField && foundField.type !== feildType) { 16 | throw new Error(config.feilds[field][lang] + ' type is not right') 17 | } 18 | 19 | // 如果找到了相应的项目,就使用其id,否则设置为-1 20 | mappedFields[field] = foundField ? foundField.id : -1; 21 | } 22 | 23 | return mappedFields; 24 | } 25 | 26 | /** 27 | * 匹配已有的字段,创建缺少的字段 28 | */ 29 | export async function completeMappedFields(selection, checkedFieldsToMap) { 30 | const table = await bitable.base.getTableById(selection.tableId) 31 | const view = await table.getViewById(selection.viewId) 32 | const fieldMetaList = await view.getFieldMetaList() 33 | // 匹配已有的字段 34 | const mappedFields = getSelectedFieldsId(fieldMetaList, checkedFieldsToMap) 35 | console.log("writeData() >> original mappedFields", mappedFields) 36 | 37 | for (let key in mappedFields) { 38 | if (mappedFields[key] === -1) { 39 | let type = getFieldTypeByKey(key); 40 | mappedFields[key] = await table.addField({ 41 | type: type, 42 | name: config.feilds[key][lang], 43 | }) 44 | } 45 | } 46 | 47 | console.log("writeData() >> created mappedFields", mappedFields) 48 | return mappedFields; 49 | } 50 | 51 | 52 | 53 | /** 54 | * @param {object} table 数据表 55 | * @param {string} recordId 记录ID 56 | * @param {string} infoData 记录内容 57 | * @param {object} mappedFieldIdMap 映射字段Ids 58 | */ 59 | export async function setRecord(table, recordId, infoData, mappedFieldIdMap) { 60 | console.log("setRecord mappedFieldIdMap ====>", mappedFieldIdMap) 61 | const recordFields = getRecordFields(infoData, mappedFieldIdMap) 62 | console.log("setRecord ====>", recordFields) 63 | 64 | await table.setRecord(recordId, { 65 | fields: recordFields 66 | }) 67 | } 68 | 69 | export async function addRecords(table, recordList, mappedFieldIdMap) { 70 | const recordValues = recordList.map(record => { 71 | const fields = getRecordFields(record, mappedFieldIdMap); 72 | // 返回符合 IRecordValue 类型的对象 73 | return { fields }; 74 | }); 75 | 76 | // 打印转换后的记录列表 77 | console.log(recordValues); 78 | 79 | // 调用 addRecords 函数保存记录 80 | await table.addRecords(recordValues); 81 | } 82 | 83 | 84 | /** 85 | * 查询式,获取 recordFields 86 | * @param {object} infoData 数据 87 | * @param {object} mappedFieldIdMap 字段对应列id 88 | */ 89 | const getRecordFields = (infoData, mappedFields) => { 90 | let recordFields = {} 91 | let key = '' 92 | let value = '' 93 | let fetchDataTimeValue = Date.now() 94 | 95 | for (let field in mappedFields) { 96 | 97 | key = mappedFields[field] 98 | 99 | if (field === 'fetchDataTime') 100 | value = fetchDataTimeValue 101 | else 102 | value = infoData[field] 103 | 104 | if (value) { 105 | recordFields[key] = value 106 | } 107 | } 108 | return recordFields 109 | } 110 | 111 | // 依据 recordId & filedId 获取 cell 值 112 | export async function getCellValueByCell(table, recordId, fieldId) { 113 | const cellValue = await table.getCellValue(fieldId, recordId) 114 | 115 | if (typeof cellValue == 'object') 116 | return cellValue[0].text 117 | 118 | return cellValue 119 | } 120 | 121 | export function getFieldTypeByKey(key) { 122 | switch (key) { 123 | case "type": // 类型 124 | case "title": // 视频名称 125 | case "uploader": // 作者名 126 | case "videoUrl": 127 | case "videoCover": 128 | case "musicUrl": 129 | case "musicTitle": 130 | case "signature": 131 | case "userhome": 132 | case "videoId": 133 | case "images": 134 | return FieldType.Text; 135 | case "releaseTime": 136 | case "lastUpdateTime": 137 | case "fetchDataTime": 138 | return FieldType.DateTime; 139 | case "danmuCount": 140 | case "coinCount": 141 | case "viewCount": 142 | case "collectionCount": 143 | case "likeCount": 144 | case "commentCount": // 评论量 145 | case "totalInterCount": // 总互动量 146 | case "shareCount": 147 | return FieldType.Number; 148 | case "commentWc": 149 | case "danmuWc": 150 | return FieldType.Attachment; 151 | default: 152 | return FieldType.Text; 153 | } 154 | } 155 | 156 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import AutoImport from 'unplugin-auto-import/vite' 6 | import Components from 'unplugin-vue-components/vite' 7 | import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | server: { 12 | host: true 13 | }, 14 | plugins: [ 15 | vue(), 16 | AutoImport({ 17 | resolvers: [ElementPlusResolver()], 18 | }), 19 | Components({ 20 | resolvers: [ElementPlusResolver()], 21 | }),], 22 | resolve: { 23 | alias: { 24 | '@': fileURLToPath(new URL('./src', import.meta.url)) 25 | } 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feishu-douyin-tool", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "feishu-douyin-tool", 9 | "version": "1.0.0", 10 | "hasInstallScript": true, 11 | "devDependencies": {} 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feishu-douyin-tool", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "install": "npm --prefix ./frontend install && npm --prefix ./backend install", 6 | "build": "npm --prefix ./frontend run build && rm -rf ./backend/public/* && cp -r ./frontend/dist/* ./backend/public/", 7 | "start": "npm --prefix ./backend run start" 8 | }, 9 | "dependencies": {}, 10 | "devDependencies": {} 11 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

✨ 获取抖音、小红书数据保存到飞书多维表格 ✨

2 | 3 | # 使用效果 4 | ![使用效果](/use-doc/使用示例.gif) 5 | 6 | # 使用教程 7 | ### 1. 新增一个多维表格,将需要采集的链接贴入 8 | 每行一个,支持的链接形式有: 9 | 10 | ``` 11 | https://www.douyin.com/video/7304875720877034803 12 | https://v.douyin.com/iL7oNdRv/ 13 | ``` 14 | 15 | ### 2. 运行程序 16 | 17 | 可使用Replit 一键部署:https://replit.com/@yib360/feishu-douyin-tool 18 | 19 | 如果没有,可以参考下方本地运行方式,运行成功后 20 | 添加自定义插件,地址填写:http://localhost:4000/ 21 | ![新增自定义插件](/use-doc/新增自定义插件.png) 22 | ![填写自定义插件](/use-doc/填写自定义插件.png) 23 | 24 | ### 3. 获取cookie 25 | 登陆 https://www.douyin.com/ 26 | 27 | ![获取cookie](/use-doc/获取cookie.png) 28 | 29 | ### 4. 填入cookie 并 选择需要的字段 30 | ![使用截图](/use-doc/使用截图.png) 31 | 32 | ### 5. 点击获取数据 33 | 34 | # 本地运行 35 | ## Install 36 | ``` 37 | git clone https://github.com/happyVee/feishu-douyin-tool.git 38 | cd feishu-douyin-tool 39 | npm install 40 | ``` 41 | 42 | ## Build 43 | ``` 44 | npm build 45 | ``` 46 | 47 | ## Run 48 | ``` 49 | npm start 50 | ``` 51 | 52 | ## view 53 | 访问: http://localhost:4000/ -------------------------------------------------------------------------------- /use-doc/使用截图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yibo365/feishu-wemedia-tool/cab2b56f500a00c932d1397dd2c8862485f28b91/use-doc/使用截图.png -------------------------------------------------------------------------------- /use-doc/使用示例.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yibo365/feishu-wemedia-tool/cab2b56f500a00c932d1397dd2c8862485f28b91/use-doc/使用示例.gif -------------------------------------------------------------------------------- /use-doc/填写自定义插件.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yibo365/feishu-wemedia-tool/cab2b56f500a00c932d1397dd2c8862485f28b91/use-doc/填写自定义插件.png -------------------------------------------------------------------------------- /use-doc/新增自定义插件.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yibo365/feishu-wemedia-tool/cab2b56f500a00c932d1397dd2c8862485f28b91/use-doc/新增自定义插件.png -------------------------------------------------------------------------------- /use-doc/获取cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yibo365/feishu-wemedia-tool/cab2b56f500a00c932d1397dd2c8862485f28b91/use-doc/获取cookie.png --------------------------------------------------------------------------------