├── .env.example ├── .gitignore ├── BillNote_frontend ├── .env example ├── .gitignore ├── Dockerfile ├── README.md ├── components.json ├── deploy │ ├── default.conf.template │ └── start.sh ├── eslint.config.js ├── index.html ├── package.json ├── postcss.config.cjs ├── public │ ├── icon.svg │ ├── placeholder.png │ └── vite.svg ├── src │ ├── App.css │ ├── App.tsx │ ├── assets │ │ ├── Lottie │ │ │ ├── idle.json │ │ │ └── loading.json │ │ └── react.svg │ ├── components │ │ ├── Lottie │ │ │ ├── Idle.tsx │ │ │ └── Loading.tsx │ │ └── ui │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── form.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── sonner.tsx │ │ │ └── tooltip.tsx │ ├── hooks │ │ └── useTaskPolling.ts │ ├── index.css │ ├── indexa.css │ ├── layouts │ │ ├── HomeLayout.tsx │ │ └── RootLayout.tsx │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── pages │ │ ├── Home.tsx │ │ └── components │ │ │ ├── MarkdownViewer.tsx │ │ │ ├── NoteForm.tsx │ │ │ ├── NoteFormWrapper.tsx │ │ │ └── NoteHistory.tsx │ ├── services │ │ └── note.ts │ ├── store │ │ └── taskStore │ │ │ └── index.ts │ ├── utils │ │ └── request.ts │ └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── LICENSE ├── README.md ├── backend ├── .env.example ├── Dockerfile ├── __init__.py ├── app │ ├── __init__.py │ ├── db │ │ ├── __init__.py │ │ ├── sqlite_client.py │ │ └── video_task_dao.py │ ├── decorators │ │ ├── __init__.py │ │ └── timeit.py │ ├── downloaders │ │ ├── __init__.py │ │ ├── base.py │ │ ├── bilibili_downloader.py │ │ ├── common.py │ │ ├── douyin_downloader.py │ │ └── youtube_downloader.py │ ├── enmus │ │ └── note_enums.py │ ├── gpt │ │ ├── __init__.py │ │ ├── base.py │ │ ├── deepseek_gpt.py │ │ ├── openai_gpt.py │ │ ├── prompt.py │ │ ├── qwen_gpt.py │ │ ├── tools.py │ │ └── utils.py │ ├── models │ │ ├── __init__.py │ │ ├── audio_model.py │ │ ├── gpt_model.py │ │ ├── notes_model.py │ │ ├── transcriber_model.py │ │ └── video_record.py │ ├── routers │ │ ├── __init__.py │ │ └── note.py │ ├── services │ │ ├── __init__.py │ │ └── note.py │ ├── transcriber │ │ ├── __init__.py │ │ ├── base.py │ │ ├── bcut.py │ │ ├── kuaishou.py │ │ ├── mlx_whisper_transcriber.py │ │ ├── transcriber_provider.py │ │ └── whisper.py │ ├── utils │ │ ├── env_checker.py │ │ ├── logger.py │ │ ├── note_helper.py │ │ ├── path_helper.py │ │ ├── response.py │ │ ├── status_code.py │ │ ├── url_parser.py │ │ └── video_helper.py │ └── validators │ │ ├── __init__.py │ │ └── video_url_validator.py ├── events │ ├── __init__.py │ ├── handlers.py │ └── signals.py ├── ffmpeg_helper.py ├── main.py └── requirements.txt ├── doc ├── BiliNote.png ├── icon.svg ├── image1.png ├── image2.png ├── image3.png └── wechat.png └── docker-compose.yml /.env.example: -------------------------------------------------------------------------------- 1 | # 通用端口配置 2 | BACKEND_PORT=8001 3 | FRONTEND_PORT=3015 4 | BACKEND_HOST=0.0.0.0 # 默认为 0.0.0.0,表示监听所有 IP 地址 不建议动 5 | 6 | # 前端访问后端用(生产环境建议写公网或宿主机 IP) 7 | VITE_API_BASE_URL=http://127.0.0.1:8001 8 | VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8001/static/screenshots 9 | 10 | # 生产环境配置 11 | ENV=production 12 | STATIC=/static 13 | OUT_DIR=./static/screenshots 14 | IMAGE_BASE_URL=/static/screenshots 15 | DATA_DIR=data 16 | # AI 相关配置 17 | OPENAI_API_KEY= 18 | OPENAI_API_BASE_URL= 19 | OPENAI_MODEL= 20 | DEEP_SEEK_API_KEY= 21 | DEEP_SEEK_API_BASE_URL= 22 | DEEP_SEEK_MODEL= 23 | QWEN_API_KEY= 24 | QWEN_API_BASE_URL= 25 | QWEN_MODEL= 26 | MODEl_PROVIDER= #如果不是openai 请修改 deepseek/qwen 27 | # FFMPEG 配置 28 | FFMPEG_BIN_PATH= 29 | 30 | # transcriber 相关配置 31 | TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou/mlx-whisper(仅Apple平台) 32 | WHISPER_MODEL_SIZE=base -------------------------------------------------------------------------------- /.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 | .DS_Store 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | BiliNote/pnpm-lock.yaml 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 | .BiliNote-dev/* 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 | !.env.example 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 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | # Byte-compiled / optimized / DLL files 138 | __pycache__/ 139 | *.py[cod] 140 | *$py.class 141 | 142 | # C extensions 143 | *.so 144 | 145 | # Distribution / packaging 146 | .Python 147 | build/ 148 | develop-eggs/ 149 | dist/ 150 | downloads/ 151 | eggs/ 152 | .eggs/ 153 | 154 | lib64/ 155 | parts/ 156 | sdist/ 157 | var/ 158 | wheels/ 159 | share/python-wheels/ 160 | *.egg-info/ 161 | .installed.cfg 162 | *.egg 163 | MANIFEST 164 | 165 | # PyInstaller 166 | # Usually these files are written by a python script from a template 167 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 168 | *.manifest 169 | *.spec 170 | 171 | # Installer logs 172 | pip-log.txt 173 | pip-delete-this-directory.txt 174 | 175 | # Unit test / coverage reports 176 | htmlcov/ 177 | .tox/ 178 | .nox/ 179 | .coverage 180 | .coverage.* 181 | .cache 182 | nosetests.xml 183 | coverage.xml 184 | *.cover 185 | *.py,cover 186 | .hypothesis/ 187 | .pytest_cache/ 188 | cover/ 189 | 190 | # Translations 191 | *.mo 192 | *.pot 193 | 194 | # Django stuff: 195 | *.log 196 | local_settings.py 197 | db.sqlite3 198 | db.sqlite3-journal 199 | 200 | # Flask stuff: 201 | instance/ 202 | .webassets-cache 203 | 204 | # Scrapy stuff: 205 | .scrapy 206 | 207 | # Sphinx documentation 208 | docs/_build/ 209 | 210 | # PyBuilder 211 | .pybuilder/ 212 | target/ 213 | 214 | # Jupyter Notebook 215 | .ipynb_checkpoints 216 | 217 | # IPython 218 | profile_default/ 219 | ipython_config.py 220 | 221 | # pyenv 222 | # For a library or package, you might want to ignore these files since the code is 223 | # intended to run in multiple environments; otherwise, check them in: 224 | # .python-version 225 | 226 | # pipenv 227 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 228 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 229 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 230 | # install all needed dependencies. 231 | #Pipfile.lock 232 | 233 | # UV 234 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 235 | # This is especially recommended for binary packages to ensure reproducibility, and is more 236 | # commonly ignored for libraries. 237 | #uv.lock 238 | 239 | # poetry 240 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 241 | # This is especially recommended for binary packages to ensure reproducibility, and is more 242 | # commonly ignored for libraries. 243 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 244 | #poetry.lock 245 | 246 | # pdm 247 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 248 | #pdm.lock 249 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 250 | # in version control. 251 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 252 | .pdm.toml 253 | .pdm-python 254 | .pdm-build/ 255 | 256 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 257 | __pypackages__/ 258 | 259 | # Celery stuff 260 | celerybeat-schedule 261 | celerybeat.pid 262 | 263 | # SageMath parsed files 264 | *.sage.py 265 | 266 | # Environments 267 | .env 268 | .venv 269 | env/ 270 | venv/ 271 | ENV/ 272 | env.bak/ 273 | venv.bak/ 274 | 275 | # Spyder project settings 276 | .spyderproject 277 | .spyproject 278 | 279 | # Rope project settings 280 | .ropeproject 281 | 282 | # mkdocs documentation 283 | /site 284 | 285 | # mypy 286 | .mypy_cache/ 287 | .dmypy.json 288 | dmypy.json 289 | 290 | # Pyre type checker 291 | .pyre/ 292 | 293 | # pytype static type analyzer 294 | .pytype/ 295 | 296 | # Cython debug symbols 297 | cython_debug/ 298 | 299 | # PyCharm 300 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 301 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 302 | # and can be added to the global gitignore or merged into this file. For a more nuclear 303 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 304 | #.idea/ 305 | 306 | # Ruff stuff: 307 | .ruff_cache/ 308 | 309 | # PyPI configuration file 310 | .pypirc 311 | /backend/data/* 312 | /backend/static/* 313 | /backend/note_tasks.db 314 | /backend/bin/ 315 | /backend/logs/ 316 | /backend/note_results 317 | /backend/models 318 | /backend/.idea -------------------------------------------------------------------------------- /BillNote_frontend/.env example: -------------------------------------------------------------------------------- 1 | # 前端专用 2 | VITE_API_BASE_URL=http://127.0.0.1:8000 3 | VITE_SCREENSHOT_BASE_URL=http://127.0.0.1:8000/static/screenshots -------------------------------------------------------------------------------- /BillNote_frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | /pnpm-lock.yaml 26 | -------------------------------------------------------------------------------- /BillNote_frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # === 前端构建阶段 === 2 | FROM node:18-alpine AS build 3 | 4 | # 安装 pnpm 5 | RUN npm install -g pnpm 6 | 7 | # 设置工作目录 8 | WORKDIR /app 9 | 10 | # 拷贝前端源码 11 | COPY ./BillNote_frontend /app 12 | 13 | # 安装依赖并构建 14 | RUN pnpm install && pnpm run build 15 | 16 | # === nginx 运行阶段 === 17 | FROM nginx:alpine 18 | 19 | # 拷贝模板配置 20 | COPY ./BillNote_frontend/deploy/default.conf.template /etc/nginx/templates/default.conf.template 21 | 22 | # 拷贝构建产物 23 | COPY --from=build /app/dist /usr/share/nginx/html 24 | 25 | # 拷贝启动脚本 26 | COPY ./BillNote_frontend/deploy/start.sh /start.sh 27 | RUN chmod +x /start.sh 28 | 29 | EXPOSE 80 30 | 31 | # 使用启动脚本启动容器 32 | CMD ["/start.sh"] -------------------------------------------------------------------------------- /BillNote_frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: 13 | 14 | ```js 15 | export default tseslint.config({ 16 | extends: [ 17 | // Remove ...tseslint.configs.recommended and replace with this 18 | ...tseslint.configs.recommendedTypeChecked, 19 | // Alternatively, use this for stricter rules 20 | ...tseslint.configs.strictTypeChecked, 21 | // Optionally, add this for stylistic rules 22 | ...tseslint.configs.stylisticTypeChecked, 23 | ], 24 | languageOptions: { 25 | // other options... 26 | parserOptions: { 27 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 28 | tsconfigRootDir: import.meta.dirname, 29 | }, 30 | }, 31 | }) 32 | ``` 33 | 34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: 35 | 36 | ```js 37 | // eslint.config.js 38 | import reactX from 'eslint-plugin-react-x' 39 | import reactDom from 'eslint-plugin-react-dom' 40 | 41 | export default tseslint.config({ 42 | plugins: { 43 | // Add the react-x and react-dom plugins 44 | 'react-x': reactX, 45 | 'react-dom': reactDom, 46 | }, 47 | rules: { 48 | // other rules... 49 | // Enable its recommended typescript rules 50 | ...reactX.configs['recommended-typescript'].rules, 51 | ...reactDom.configs.recommended.rules, 52 | }, 53 | }) 54 | ``` 55 | -------------------------------------------------------------------------------- /BillNote_frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /BillNote_frontend/deploy/default.conf.template: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | resolver 127.0.0.11 valid=10s; 4 | 5 | location / { 6 | root /usr/share/nginx/html; 7 | index index.html; 8 | try_files $uri $uri/ /index.html; 9 | } 10 | 11 | location /api/ { 12 | proxy_pass http://backend:${BACKEND_PORT}; 13 | proxy_set_header Host $host; 14 | proxy_set_header X-Real-IP $remote_addr; 15 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 16 | proxy_set_header X-Forwarded-Proto $scheme; 17 | } 18 | } -------------------------------------------------------------------------------- /BillNote_frontend/deploy/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ### 3 | # @Author: Jefferyhcool 1063474837@qq.com 4 | # @Date: 2025-04-16 01:57:05 5 | # @LastEditors: Jefferyhcool 1063474837@qq.com 6 | # @LastEditTime: 2025-04-16 01:59:37 7 | # @FilePath: /hotfix-dev/BillNote_frontend/deploy/start.sh 8 | # @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE 9 | ### 10 | # 等待后端健康检查通过 11 | until curl -s "http://backend:${BACKEND_PORT}/health" > /dev/null; do 12 | echo "等待后端服务就绪..." 13 | sleep 2 14 | done 15 | 16 | # 生成 nginx 配置文件(动态变量替换) 17 | envsubst '${BACKEND_HOST} ${BACKEND_PORT}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf 18 | 19 | # 启动 Nginx(在前台运行) 20 | exec nginx -g 'daemon off;' -------------------------------------------------------------------------------- /BillNote_frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /BillNote_frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | BiliNote - 强大的AI视频笔记神器 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /BillNote_frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bili_note", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^5.0.1", 14 | "@lottiefiles/dotlottie-react": "^0.13.3", 15 | "@radix-ui/react-checkbox": "^1.1.4", 16 | "@radix-ui/react-label": "^2.1.2", 17 | "@radix-ui/react-scroll-area": "^1.2.3", 18 | "@radix-ui/react-select": "^2.1.6", 19 | "@radix-ui/react-slot": "^1.1.2", 20 | "@radix-ui/react-tooltip": "^1.1.8", 21 | "@tailwindcss/vite": "^4.1.3", 22 | "@uiw/react-markdown-preview": "^5.1.3", 23 | "axios": "^1.8.4", 24 | "class-variance-authority": "^0.7.1", 25 | "clsx": "^2.1.1", 26 | "github-markdown-css": "^5.8.1", 27 | "katex": "^0.16.21", 28 | "lottie-react": "^2.4.1", 29 | "lucide-react": "^0.487.0", 30 | "next-themes": "^0.4.6", 31 | "react": "^19.0.0", 32 | "react-dom": "^19.0.0", 33 | "react-hook-form": "^7.55.0", 34 | "react-hot-toast": "^2.5.2", 35 | "react-markdown": "^10.1.0", 36 | "react-syntax-highlighter": "^15.6.1", 37 | "remark-gfm": "1.0.0", 38 | "sonner": "^2.0.3", 39 | "tailwind-merge": "^3.1.0", 40 | "tailwindcss": "^4.1.3", 41 | "tw-animate-css": "^1.2.5", 42 | "zod": "^3.24.2", 43 | "zustand": "^5.0.3" 44 | }, 45 | "devDependencies": { 46 | "@eslint/js": "^9.21.0", 47 | "@tailwindcss/postcss": "^4.1.3", 48 | "@types/node": "^22.14.0", 49 | "@types/react": "^19.0.10", 50 | "@types/react-dom": "^19.0.4", 51 | "@vitejs/plugin-react": "^4.3.4", 52 | "autoprefixer": "^10.4.21", 53 | "eslint": "^9.21.0", 54 | "eslint-plugin-react-hooks": "^5.1.0", 55 | "eslint-plugin-react-refresh": "^0.4.19", 56 | "globals": "^15.15.0", 57 | "typescript": "~5.7.2", 58 | "typescript-eslint": "^8.24.1", 59 | "vite": "^6.2.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /BillNote_frontend/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /BillNote_frontend/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /BillNote_frontend/public/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/BillNote_frontend/public/placeholder.png -------------------------------------------------------------------------------- /BillNote_frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /BillNote_frontend/src/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/BillNote_frontend/src/App.css -------------------------------------------------------------------------------- /BillNote_frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | 2 | import './App.css' 3 | import {HomePage} from "./pages/Home.tsx"; 4 | import {useTaskPolling} from "@/hooks/useTaskPolling.ts"; 5 | 6 | function App() { 7 | useTaskPolling(3000) // 每 3 秒轮询一次 8 | 9 | return ( 10 | <> 11 | 12 | 13 | ) 14 | } 15 | 16 | export default App 17 | -------------------------------------------------------------------------------- /BillNote_frontend/src/assets/Lottie/loading.json: -------------------------------------------------------------------------------- 1 | {"nm":"Loading 15","ddd":0,"h":1080,"w":1080,"meta":{"g":"@lottiefiles/toolkit-js 0.47.2"},"layers":[{"ty":4,"nm":"Sq 01","sr":1,"st":0,"op":91,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[90,90,100]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[390,390,0],"t":5},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[390,540,0],"t":23},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[390,690,0],"t":40},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[390,690,0],"t":45},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[390,690,0],"t":68},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[390,690,0],"t":73},{"s":[540,690,0],"t":90}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Rectangle 1","ix":1,"cix":2,"np":3,"it":[{"ty":"rc","bm":0,"hd":false,"mn":"ADBE Vector Shape - Rect","nm":"Rectangle Path 1","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":300},"s":{"a":1,"k":[{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":5},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,600],"t":23},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":40},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":45},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":68},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":73},{"s":[600,300],"t":90}]}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":10},"c":{"a":0,"k":[0.2353,0.4667,0.9843,1]}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0,0.3167,1,1]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":1},{"ty":4,"nm":"Sq 02","sr":1,"st":0,"op":91,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[90,90,100]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[690,390,0],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[690,390,0],"t":23},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[690,390,0],"t":28},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[540,390,0],"t":45},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[390,390,0],"t":63},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[390,390,0],"t":68},{"s":[390,390,0],"t":90}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Rectangle 1","ix":1,"cix":2,"np":3,"it":[{"ty":"rc","bm":0,"hd":false,"mn":"ADBE Vector Shape - Rect","nm":"Rectangle Path 1","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":300},"s":{"a":1,"k":[{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":0},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":23},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":28},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[600,300],"t":45},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":63},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":68},{"s":[300,300],"t":90}]}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":10},"c":{"a":0,"k":[0.2353,0.4667,0.9843,1]}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0,0.3167,1,1]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":2},{"ty":4,"nm":"Sq 03","sr":1,"st":0,"op":91,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[90,90,100]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[540,690,0],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[690,690,0],"t":18},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[690,690,0],"t":23},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[690,690,0],"t":45},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[690,690,0],"t":50},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[690,540,0],"t":68},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[690,390,0],"t":85},{"s":[690,390,0],"t":90}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Rectangle 1","ix":1,"cix":2,"np":3,"it":[{"ty":"rc","bm":0,"hd":false,"mn":"ADBE Vector Shape - Rect","nm":"Rectangle Path 1","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":300},"s":{"a":1,"k":[{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[600,300],"t":0},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":18},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":23},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":45},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":50},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,600],"t":68},{"o":{"x":0.5,"y":0},"i":{"x":0.5,"y":1},"s":[300,300],"t":85},{"s":[300,300],"t":90}]}},{"ty":"st","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Stroke","nm":"Stroke 1","lc":2,"lj":2,"ml":1,"o":{"a":0,"k":100},"w":{"a":0,"k":10},"c":{"a":0,"k":[0.2353,0.4667,0.9843,1]}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0,0.3167,1,1]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,1.1368683772161603e-13]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,1.1368683772161603e-13]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":3}],"v":"5.12.1","fr":30,"op":91,"ip":0,"assets":[]} -------------------------------------------------------------------------------- /BillNote_frontend/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /BillNote_frontend/src/components/Lottie/Idle.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import Lottie from 'lottie-react' 3 | import loadingJson from '@/assets/Lottie/idle.json' 4 | 5 | const Idle: FC = () => { 6 | return ( 7 |
8 | 14 |
15 | ) 16 | } 17 | 18 | export default Idle 19 | -------------------------------------------------------------------------------- /BillNote_frontend/src/components/Lottie/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import Lottie from 'lottie-react' 3 | import loadingJson from '@/assets/Lottie/loading.json' 4 | 5 | const Loading: FC = () => { 6 | return ( 7 |
8 | 14 |
15 | ) 16 | } 17 | 18 | export default Loading 19 | -------------------------------------------------------------------------------- /BillNote_frontend/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /BillNote_frontend/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /BillNote_frontend/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /BillNote_frontend/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { CheckIcon } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Checkbox({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 20 | 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /BillNote_frontend/src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | FormProvider, 7 | useFormContext, 8 | useFormState, 9 | type ControllerProps, 10 | type FieldPath, 11 | type FieldValues, 12 | } from "react-hook-form" 13 | 14 | import { cn } from "@/lib/utils" 15 | import { Label } from "@/components/ui/label" 16 | 17 | const Form = FormProvider 18 | 19 | type FormFieldContextValue< 20 | TFieldValues extends FieldValues = FieldValues, 21 | TName extends FieldPath = FieldPath, 22 | > = { 23 | name: TName 24 | } 25 | 26 | const FormFieldContext = React.createContext( 27 | {} as FormFieldContextValue 28 | ) 29 | 30 | const FormField = < 31 | TFieldValues extends FieldValues = FieldValues, 32 | TName extends FieldPath = FieldPath, 33 | >({ 34 | ...props 35 | }: ControllerProps) => { 36 | return ( 37 | 38 | 39 | 40 | ) 41 | } 42 | 43 | const useFormField = () => { 44 | const fieldContext = React.useContext(FormFieldContext) 45 | const itemContext = React.useContext(FormItemContext) 46 | const { getFieldState } = useFormContext() 47 | const formState = useFormState({ name: fieldContext.name }) 48 | const fieldState = getFieldState(fieldContext.name, formState) 49 | 50 | if (!fieldContext) { 51 | throw new Error("useFormField should be used within ") 52 | } 53 | 54 | const { id } = itemContext 55 | 56 | return { 57 | id, 58 | name: fieldContext.name, 59 | formItemId: `${id}-form-item`, 60 | formDescriptionId: `${id}-form-item-description`, 61 | formMessageId: `${id}-form-item-message`, 62 | ...fieldState, 63 | } 64 | } 65 | 66 | type FormItemContextValue = { 67 | id: string 68 | } 69 | 70 | const FormItemContext = React.createContext( 71 | {} as FormItemContextValue 72 | ) 73 | 74 | function FormItem({ className, ...props }: React.ComponentProps<"div">) { 75 | const id = React.useId() 76 | 77 | return ( 78 | 79 |
84 | 85 | ) 86 | } 87 | 88 | function FormLabel({ 89 | className, 90 | ...props 91 | }: React.ComponentProps) { 92 | const { error, formItemId } = useFormField() 93 | 94 | return ( 95 |
165 | ) 166 | } 167 | 168 | export default MarkdownViewer 169 | -------------------------------------------------------------------------------- /BillNote_frontend/src/pages/components/NoteForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Form, 3 | FormControl, 4 | FormDescription, 5 | FormField, 6 | FormItem, 7 | FormLabel, 8 | FormMessage, 9 | } from "@/components/ui/form" 10 | import { Input } from "@/components/ui/input" 11 | import { 12 | Select, 13 | SelectContent, 14 | SelectItem, 15 | SelectTrigger, 16 | SelectValue, 17 | } from "@/components/ui/select" 18 | import { Button } from "@/components/ui/button" 19 | import { Checkbox } from "@/components/ui/checkbox" 20 | import { useForm } from "react-hook-form" 21 | import { z } from "zod" 22 | import { zodResolver } from "@hookform/resolvers/zod" 23 | import { Info,Clock } from "lucide-react" 24 | 25 | import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx"; 26 | import {generateNote} from "@/services/note.ts"; 27 | import {useTaskStore} from "@/store/taskStore"; 28 | import { useState } from "react" 29 | import NoteHistory from "@/pages/components/NoteHistory.tsx"; 30 | 31 | // ✅ 定义表单 schema 32 | const formSchema = z.object({ 33 | video_url: z.string().url("请输入正确的视频链接"), 34 | platform: z.string().nonempty("请选择平台"), 35 | quality: z.enum(["fast", "medium", "slow"], { 36 | required_error: "请选择音频质量", 37 | }), 38 | screenshot: z.boolean().optional(), 39 | link:z.boolean().optional(), 40 | }) 41 | 42 | 43 | type NoteFormValues = z.infer 44 | 45 | const NoteForm = () => { 46 | const [selectedTaskId] = useState(null) 47 | 48 | const tasks = useTaskStore((state) => state.tasks) 49 | const setCurrentTask=useTaskStore((state)=>state.setCurrentTask) 50 | const currentTaskId=useTaskStore(state=>state.currentTaskId ) 51 | tasks.find((t) => t.id === selectedTaskId); 52 | const form = useForm({ 53 | resolver: zodResolver(formSchema), 54 | defaultValues: { 55 | video_url: "", 56 | platform: "bilibili", 57 | quality: "medium", // 默认中等质量 58 | screenshot: false, 59 | }, 60 | }) 61 | 62 | 63 | const isGenerating = false 64 | 65 | const onSubmit = async (data: NoteFormValues) => { 66 | console.log("🎯 提交内容:", data) 67 | await generateNote({ 68 | video_url: data.video_url, 69 | platform: data.platform, 70 | quality: data.quality, 71 | screenshot:data.screenshot, 72 | link:data.link 73 | }); 74 | } 75 | 76 | return ( 77 |
78 |
79 | 80 |
81 |
82 |

视频链接

83 | 84 | 85 | 86 | 87 | 88 | 89 |

输入视频链接,支持哔哩哔哩、YouTube等平台

90 |
91 |
92 |
93 |
94 | 95 |
96 | {/* 平台选择 */} 97 | ( 101 | 102 | 117 | 118 | 119 | )} 120 | /> 121 | 122 | {/* 视频地址 */} 123 | ( 127 | 128 | 129 | 133 | 134 | 135 | 136 | )} 137 | /> 138 | 139 |
140 | {/*

*/} 141 | {/* 支持哔哩哔哩视频链接,例如:*/} 142 | {/* https://www.bilibili.com/video/BV1vc25YQE9X/*/} 143 | {/*

*/} 144 | ( 148 | 149 |
150 |

音频质量

151 | 152 | 153 | 154 | 155 | 156 | 157 |

质量越高,下载体积越大,速度越慢

158 |
159 |
160 |
161 |
162 | 177 | {/**/} 178 | {/* 质量越高,下载体积越大,速度越慢*/} 179 | {/**/} 180 | 181 |
182 | )} 183 | /> 184 | 185 |
186 | 187 | {/* 是否需要原片位置 */} 188 | ( 192 | 193 | {/* Tooltip 部分 */} 194 | 195 | 196 | 197 | 202 | 203 | 204 | 208 | 是否插入内容跳转链接 209 | 210 | 211 | )} 212 | /> 213 | {/* 是否需要下载 */} 214 | ( 218 | 219 | {/* Tooltip 部分 */} 220 | 221 | 222 | 223 | 228 | 229 | 230 | 234 | 是否插入视频截图 235 | 236 | 237 | )} 238 | /> 239 | 240 | {/* 提交按钮 */} 241 | 247 | 248 | 249 | 250 | 251 | {/*生成历史 */} 252 |
253 | 254 |

生成历史

255 |
256 |
257 | 258 | 259 |
260 | 261 | {/* 添加一些额外的说明或功能介绍 */} 262 |
263 |

功能介绍

264 |
    265 |
  • 266 | 267 | 自动提取视频内容,生成结构化笔记 268 |
  • 269 |
  • 270 | 271 | 支持多个视频平台,包括哔哩哔哩、YouTube等 272 |
  • 273 |
  • 274 | 275 | 一键复制笔记,支持Markdown格式 276 |
  • 277 |
  • 278 | 279 | 可选择是否插入图片 280 |
  • 281 |
282 |
283 |
284 | ) 285 | } 286 | 287 | export default NoteForm 288 | -------------------------------------------------------------------------------- /BillNote_frontend/src/pages/components/NoteFormWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form" 2 | import { Form } from "@/components/ui/form" 3 | import NoteForm from "./NoteForm" 4 | 5 | const NoteFormWrapper = () => { 6 | const form = useForm() 7 | 8 | return ( 9 |
10 | 11 | 12 | ) 13 | } 14 | 15 | export default NoteFormWrapper 16 | -------------------------------------------------------------------------------- /BillNote_frontend/src/pages/components/NoteHistory.tsx: -------------------------------------------------------------------------------- 1 | import { useTaskStore } from "@/store/taskStore" 2 | import { FC } from "react" 3 | import { ScrollArea } from "@/components/ui/scroll-area" 4 | import { Badge } from "@/components/ui/badge" 5 | import { cn } from "@/lib/utils" 6 | import { Trash ,Clock} from "lucide-react" 7 | import { Button } from "@/components/ui/button" 8 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" 9 | 10 | interface NoteHistoryProps { 11 | onSelect: (taskId: string) => void 12 | selectedId: string | null 13 | } 14 | 15 | const NoteHistory: FC = ({ onSelect, selectedId }) => { 16 | const tasks = useTaskStore((state) => state.tasks) 17 | const removeTask = useTaskStore((state) => state.removeTask) 18 | 19 | if (tasks.length === 0) { 20 | return ( 21 |
22 |

暂无历史记录

23 |
24 | ) 25 | } 26 | 27 | return ( 28 | 29 | 30 |
31 | {tasks.map((task) => ( 32 |
onSelect(task.id)} 39 | > 40 | {/* 封面图 */} 41 | 封面 48 | 49 | {/* 标题 + 状态 */} 50 | 51 |
52 | 53 | 54 | 55 |
{task.audioMeta.title || "未命名笔记"}
56 |
57 | 58 |

{task.audioMeta.title || "未命名笔记"}

59 |
60 |
61 |
62 |
63 | {task.status === "SUCCESS" && 已完成} 64 | {task.status === "PENDING" && 等待中} 65 | {task.status === "FAILED" && 失败} 66 |
67 |
68 | 69 | 70 | {/* 删除按钮 */} 71 | 72 | 73 | 74 | 86 | 87 | 88 |

删除

89 |
90 |
91 |
92 |
93 | 94 | ))} 95 |
96 |
97 | ) 98 | } 99 | 100 | export default NoteHistory 101 | -------------------------------------------------------------------------------- /BillNote_frontend/src/services/note.ts: -------------------------------------------------------------------------------- 1 | import request from "@/utils/request" 2 | import toast from 'react-hot-toast' 3 | import {useTaskStore} from "@/store/taskStore"; 4 | import request from "@/utils/request" 5 | interface GenerateNotePayload { 6 | video_url: string 7 | platform: "bilibili" | "youtube" 8 | quality: "fast" | "medium" | "slow" 9 | } 10 | 11 | export const generateNote = async (data: { 12 | video_url: string; 13 | link: undefined | boolean; 14 | screenshot: undefined | boolean; 15 | platform: string; 16 | quality: string 17 | }) => { 18 | try { 19 | const response = await request.post("/generate_note", data) 20 | 21 | if (response.data.code!=0){ 22 | if (response.data.msg){ 23 | toast.error(response.data.msg) 24 | 25 | } 26 | return null 27 | } 28 | toast.success("笔记生成任务已提交!") 29 | 30 | const taskId = response.data.data.task_id 31 | 32 | console.log('res',response) 33 | // 成功提示 34 | useTaskStore.getState().addPendingTask(taskId, data.platform) 35 | 36 | return response.data 37 | } catch (e: any) { 38 | console.error("❌ 请求出错", e) 39 | 40 | // 错误提示 41 | toast.error( 42 | "笔记生成失败,请稍后重试" 43 | ) 44 | 45 | throw e // 抛出错误以便调用方处理 46 | } 47 | } 48 | 49 | 50 | 51 | export const delete_task = async ({video_id, platform}) => { 52 | try { 53 | const data={ 54 | video_id,platform 55 | } 56 | const res = await request.post("/delete_task", 57 | data 58 | ) 59 | 60 | if (res.data.code === 0) { 61 | toast.success("任务已成功删除") 62 | return res.data 63 | } else { 64 | toast.error(res.data.message || "删除失败") 65 | throw new Error(res.data.message || "删除失败") 66 | } 67 | } catch (e) { 68 | toast.error("请求异常,删除任务失败") 69 | console.error("❌ 删除任务失败:", e) 70 | throw e 71 | } 72 | } 73 | 74 | 75 | export const get_task_status=async (task_id:string)=>{ 76 | try { 77 | const response = await request.get("/task_status/"+task_id) 78 | 79 | if (response.data.code==0 && response.data.status=='SUCCESS') { 80 | // toast.success("笔记生成成功") 81 | } 82 | console.log('res',response) 83 | // 成功提示 84 | 85 | return response.data 86 | } 87 | catch (e){ 88 | console.error("❌ 请求出错", e) 89 | 90 | // 错误提示 91 | toast.error( 92 | "笔记生成失败,请稍后重试" 93 | ) 94 | 95 | throw e // 抛出错误以便调用方处理 96 | } 97 | } -------------------------------------------------------------------------------- /BillNote_frontend/src/store/taskStore/index.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { persist } from 'zustand/middleware' 3 | import {delete_task} from "@/services/note.ts"; 4 | 5 | export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD' 6 | 7 | export interface AudioMeta { 8 | cover_url: string 9 | duration: number 10 | file_path: string 11 | platform: string 12 | raw_info: any 13 | title: string 14 | video_id: string 15 | } 16 | 17 | export interface Segment { 18 | start: number 19 | end: number 20 | text: string 21 | } 22 | 23 | export interface Transcript { 24 | full_text: string 25 | language: string 26 | raw: any 27 | segments: Segment[] 28 | } 29 | 30 | export interface Task { 31 | id: string 32 | markdown: string 33 | transcript: Transcript 34 | status: TaskStatus 35 | audioMeta: AudioMeta 36 | createdAt: string 37 | } 38 | 39 | interface TaskStore { 40 | tasks: Task[] 41 | currentTaskId: string | null 42 | platform:string|null 43 | addPendingTask: (taskId: string, platform: string) => void 44 | updateTaskContent: (id: string, data: Partial>) => void 45 | removeTask: (id: string) => void 46 | clearTasks: () => void 47 | setCurrentTask: (taskId: string | null) => void 48 | getCurrentTask: () => Task | null 49 | } 50 | 51 | export const useTaskStore = create()( 52 | persist( 53 | (set,get) => ({ 54 | tasks: [], 55 | currentTaskId: null, 56 | 57 | addPendingTask: (taskId: string,platform: string) => 58 | set((state) => ({ 59 | tasks: [ 60 | { 61 | id: taskId, 62 | status: "PENDING", 63 | markdown: "", 64 | platform:platform, 65 | transcript: { 66 | full_text: "", 67 | language: "", 68 | raw: null, 69 | segments: [], 70 | }, 71 | createdAt: new Date().toISOString(), 72 | audioMeta: { 73 | cover_url: "", 74 | duration: 0, 75 | file_path: "", 76 | platform: '', 77 | raw_info: null, 78 | title: "", 79 | video_id: "", 80 | }, 81 | }, 82 | ...state.tasks, 83 | ], 84 | currentTaskId: taskId, // 默认设置为当前任务 85 | })), 86 | 87 | updateTaskContent: (id, data) => 88 | set((state) => ({ 89 | tasks: state.tasks.map((task) => 90 | task.id === id ? { ...task, ...data } : task 91 | ), 92 | })), 93 | getCurrentTask: () => { 94 | const currentTaskId = get().currentTaskId 95 | return get().tasks.find((task) => task.id === currentTaskId) || null 96 | }, 97 | removeTask: async (id) => { 98 | const task = get().tasks.find((t) => t.id === id) 99 | 100 | // 更新 Zustand 状态 101 | set((state) => ({ 102 | tasks: state.tasks.filter((task) => task.id !== id), 103 | currentTaskId: state.currentTaskId === id ? null : state.currentTaskId, 104 | })) 105 | 106 | // 调用后端删除接口(如果找到了任务) 107 | if (task) { 108 | await delete_task({ 109 | video_id: task.audioMeta.video_id, 110 | platform: task.platform, 111 | }) 112 | } 113 | }, 114 | 115 | 116 | clearTasks: () => set({ tasks: [], currentTaskId: null }), 117 | 118 | setCurrentTask: (taskId) => set({ currentTaskId: taskId }), 119 | }), 120 | { 121 | name: 'task-storage', 122 | } 123 | ) 124 | ) 125 | -------------------------------------------------------------------------------- /BillNote_frontend/src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | 3 | const request = axios.create({ 4 | baseURL: "/api", // 默认请求路径前缀 5 | timeout: 10000, 6 | }) 7 | 8 | export default request -------------------------------------------------------------------------------- /BillNote_frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /BillNote_frontend/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './index.html', 5 | './src/**/*.{vue,js,ts,js,jsx,tsx}' 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | }; -------------------------------------------------------------------------------- /BillNote_frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | } 31 | }, 32 | "include": ["src"] 33 | } 34 | -------------------------------------------------------------------------------- /BillNote_frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /BillNote_frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /BillNote_frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from "path" 4 | import tailwindcss from "@tailwindcss/vite" 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig(({ mode }) => { 8 | const env = loadEnv(mode, process.cwd() + '/../') 9 | 10 | const apiBaseUrl = env.VITE_API_BASE_URL 11 | 12 | return { 13 | plugins: [react(), tailwindcss()], 14 | resolve: { 15 | alias: { 16 | "@": path.resolve(__dirname, "./src"), 17 | }, 18 | }, 19 | server: { 20 | host: '0.0.0.0', 21 | port: 5173, 22 | proxy: { 23 | '/api': { 24 | target: apiBaseUrl, 25 | changeOrigin: true, 26 | rewrite: path => path.replace(/^\/api/, '/api'), 27 | }, 28 | }, 29 | }, 30 | } 31 | }) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jeffery Huang 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 |
3 |

4 | BiliNote Banner 5 |

6 |

BiliNote v1.0.1

7 |
8 | 9 |

AI 视频笔记生成工具 让 AI 为你的视频做笔记

10 | 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |

20 | 21 | 22 | 23 | ## ✨ 项目简介 24 | 25 | BiliNote 是一个开源的 AI 视频笔记助手,支持通过哔哩哔哩、YouTube 等视频链接,自动提取内容并生成结构清晰、重点明确的 Markdown 格式笔记。支持插入截图、原片跳转等功能。 26 | 27 | ## 🚀 体验地址 28 | [https://www.bilinote.app](https://www.bilinote.app) 29 | 注意:由于 项目部署在 Cloudflare Pages,访问速度可能存在一些问题,请耐心等待。 30 | 31 | ## 📦 Windows 打包版 32 | 本项目提供了 Windows 系统的 exe 文件,可在[release](https://github.com/JefferyHcool/BiliNote/releases/tag/v1.0.1) 进行下载。 33 | 34 | 35 | ## 🔧 功能特性 36 | 37 | - 支持多平台:Bilibili、YouTube(后续会加入更多平台) 38 | - 本地模型音频转写(支持 Fast-Whisper) 39 | - GPT 大模型总结视频内容(支持 OpenAI、DeepSeek、Qwen) 40 | - 自动生成结构化 Markdown 笔记 41 | - 可选插入截图(自动截取) 42 | - 可选内容跳转链接(关联原视频) 43 | - 任务记录与历史回看 44 | 45 | ## 📸 截图预览 46 | ![screenshot](./doc/image1.png) 47 | ![screenshot](./doc/image2.png) 48 | ![screenshot](./doc/image3.png) 49 | 50 | ## 🚀 快速开始 51 | 52 | ### 1. 克隆仓库 53 | 54 | ```bash 55 | git clone https://github.com/JefferyHcool/BiliNote.git 56 | cd BiliNote 57 | mv .env.example .env 58 | ``` 59 | 60 | ### 2. 启动后端(FastAPI) 61 | 62 | ```bash 63 | cd backend 64 | pip install -r requirements.txt 65 | python main.py 66 | ``` 67 | 68 | ### 3. 启动前端(Vite + React) 69 | 70 | ```bash 71 | cd BiliNote_frontend 72 | pnpm install 73 | pnpm dev 74 | ``` 75 | 76 | 访问:`http://localhost:5173` 77 | 78 | ## ⚙️ 依赖说明 79 | ### 🎬 FFmpeg 80 | 本项目依赖 ffmpeg 用于音频处理与转码,必须安装: 81 | ```bash 82 | # Mac (brew) 83 | brew install ffmpeg 84 | 85 | # Ubuntu / Debian 86 | sudo apt install ffmpeg 87 | 88 | # Windows 89 | # 请从官网下载安装:https://ffmpeg.org/download.html 90 | ``` 91 | > ⚠️ 若系统无法识别 ffmpeg,请将其加入系统环境变量 PATH 92 | 93 | ### 🚀 CUDA 加速(可选) 94 | 若你希望更快地执行音频转写任务,可使用具备 NVIDIA GPU 的机器,并启用 fast-whisper + CUDA 加速版本: 95 | 96 | 具体 `fast-whisper` 配置方法,请参考:[fast-whisper 项目地址](http://github.com/SYSTRAN/faster-whisper#requirements) 97 | 98 | ### 🐳 使用 Docker 一键部署 99 | 100 | 确保你已安装 Docker 和 Docker Compose: 101 | 102 | #### 1. 克隆本项目 103 | ```bash 104 | git clone https://github.com/JefferyHcool/BiliNote.git 105 | cd BiliNote 106 | mv .env.example .env 107 | ``` 108 | #### 2. 启动 Docker Compose 109 | ``` bash 110 | docker compose up --build 111 | ``` 112 | 默认端口: 113 | 114 | 前端:http://localhost:${FRONTEND_PORT} 115 | 116 | 后端:http://localhost:${BACKEND_PORT} 117 | 118 | .env 文件中可自定义端口与环境配置。 119 | 120 | 121 | ## ⚙️ 环境变量配置 122 | 123 | 后端 `.env` 示例: 124 | 125 | ```ini 126 | API_BASE_URL=http://localhost:8000 127 | OUT_DIR=note_results 128 | IMAGE_BASE_URL=/static/screenshots 129 | MODEl_PROVIDER=openai 130 | OPENAI_API_KEY=sk-xxxxxx 131 | DEEP_SEEK_API_KEY=xxx 132 | QWEN_API_KEY=xxx 133 | ``` 134 | 135 | ## 🧠 TODO 136 | 137 | - [ ] 支持抖音及快手等视频平台 138 | - [ ] 支持前端设置切换 AI 模型切换、语音转文字模型 139 | - [ ] AI 摘要风格自定义(学术风、口语风、重点提取等) 140 | - [ ] 笔记导出为 PDF / Word / Notion 141 | - [ ] 加入更多模型支持 142 | - [ ] 加入更多音频转文本模型支持 143 | 144 | ### Contact and Join-联系和加入社区 145 | - BiliNote 交流QQ群:785367111 146 | - BiliNote 交流微信群: 147 | 148 | wechat 149 | 150 | ## 📜 License 151 | 152 | MIT License 153 | 154 | --- 155 | 156 | 💬 你的支持与反馈是我持续优化的动力!欢迎 PR、提 issue、Star ⭐️ 157 | 158 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | 2 | # 通用 3 | ENV=production 4 | API_BASE_URL=http://127.0.0.1:8000 5 | SCREENSHOT_BASE_URL=http://127.0.0.1:8000/static/screenshots 6 | STATIC=/static # 外部访问路径(URL 前缀) 7 | OUT_DIR=./static/screenshots # 本地输出目录 8 | IMAGE_BASE_URL=/static/screenshots # 图片访问 URL 9 | DATA_DIR=data 10 | 11 | # 后端专用 12 | 13 | # AI 相关配置 14 | OPENAI_API_KEY= 15 | OPENAI_API_BASE_URL= 16 | OPENAI_MODEL= 17 | DEEP_SEEK_API_KEY= 18 | DEEP_SEEK_API_BASE_URL= 19 | DEEP_SEEK_MODEL 20 | QWEN_API_KEY= 21 | QWEN_API_BASE_URL= 22 | QWEN_MODEL= 23 | MODEl_PROVIDER= #如果不是openai 请修改 deepseek/qwen 24 | 25 | # transcriber 相关配置 26 | TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou 27 | WHISPER_MODEL_SIZE=base -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | 4 | RUN rm -f /etc/apt/sources.list && \ 5 | rm -rf /etc/apt/sources.list.d/* && \ 6 | echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main contrib non-free non-free-firmware" > /etc/apt/sources.list && \ 7 | echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \ 8 | echo "deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main contrib non-free non-free-firmware" >> /etc/apt/sources.list && \ 9 | apt-get update && \ 10 | apt-get install -y ffmpeg && \ 11 | rm -rf /var/lib/apt/lists/* 12 | 13 | # 确保 PATH 中包含 ffmpeg 路径(可选) 14 | ENV PATH="/usr/bin:${PATH}" 15 | 16 | WORKDIR /app 17 | COPY ./backend /app 18 | RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt 19 | 20 | CMD ["python", "main.py"] 21 | -------------------------------------------------------------------------------- /backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/__init__.py -------------------------------------------------------------------------------- /backend/app/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from .routers import note 3 | 4 | 5 | def create_app() -> FastAPI: 6 | app = FastAPI(title="BiliNote") 7 | app.include_router(note.router, prefix="/api") 8 | return app 9 | -------------------------------------------------------------------------------- /backend/app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/db/__init__.py -------------------------------------------------------------------------------- /backend/app/db/sqlite_client.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | def get_connection(): 4 | return sqlite3.connect("note_tasks.db") 5 | -------------------------------------------------------------------------------- /backend/app/db/video_task_dao.py: -------------------------------------------------------------------------------- 1 | from .sqlite_client import get_connection 2 | from app.utils.logger import get_logger 3 | logger = get_logger(__name__) 4 | def init_video_task_table(): 5 | conn = get_connection() 6 | if conn is None: 7 | logger.error("Failed to connect to the database.") 8 | return 9 | cursor = conn.cursor() 10 | 11 | cursor.execute(""" 12 | CREATE TABLE IF NOT EXISTS video_tasks ( 13 | id INTEGER PRIMARY KEY AUTOINCREMENT, 14 | video_id TEXT NOT NULL, 15 | platform TEXT NOT NULL, 16 | task_id TEXT NOT NULL UNIQUE, 17 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 18 | ) 19 | """) 20 | try: 21 | conn.commit() 22 | conn.close() 23 | logger.info("video_tasks table created successfully.") 24 | except Exception as e: 25 | logger.error(f"Failed to create video_tasks table: {e}") 26 | 27 | def insert_video_task(video_id: str, platform: str, task_id: str): 28 | try: 29 | conn = get_connection() 30 | cursor = conn.cursor() 31 | cursor.execute(""" 32 | INSERT INTO video_tasks (video_id, platform, task_id) 33 | VALUES (?, ?, ?) 34 | """, (video_id, platform, task_id)) 35 | conn.commit() 36 | conn.close() 37 | logger.info(f"Video task inserted successfully." 38 | f"video_id: {video_id}" 39 | f"platform: {platform}" 40 | f"task_id: {task_id}") 41 | except Exception as e: 42 | logger.error(f"Failed to insert video task: {e}") 43 | 44 | 45 | def get_task_by_video(video_id: str, platform: str): 46 | try: 47 | conn = get_connection() 48 | cursor = conn.cursor() 49 | cursor.execute(""" 50 | SELECT task_id FROM video_tasks 51 | WHERE video_id = ? AND platform = ? 52 | ORDER BY created_at DESC 53 | LIMIT 1 54 | """, (video_id, platform)) 55 | result = cursor.fetchone() 56 | conn.close() 57 | if result is None: 58 | logger.info(f"No task found for video_id: {video_id} and platform: {platform}") 59 | logger.info(f"Task found for video_id: {video_id} and platform: {platform}") 60 | return result[0] if result else None 61 | except Exception as e: 62 | logger.error(f"Failed to get task by video: {e}") 63 | 64 | 65 | def delete_task_by_video(video_id: str, platform: str): 66 | try: 67 | conn = get_connection() 68 | cursor = conn.cursor() 69 | cursor.execute(""" 70 | DELETE FROM video_tasks 71 | WHERE video_id = ? AND platform = ? 72 | """, (video_id, platform)) 73 | 74 | conn.commit() 75 | conn.close() 76 | logger.info(f"Task deleted for video_id: {video_id} and platform: {platform}") 77 | except Exception as e: 78 | logger.error(f"Failed to delete task by video: {e}") -------------------------------------------------------------------------------- /backend/app/decorators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/decorators/__init__.py -------------------------------------------------------------------------------- /backend/app/decorators/timeit.py: -------------------------------------------------------------------------------- 1 | import time 2 | import functools 3 | 4 | def timeit(func): 5 | @functools.wraps(func) 6 | def wrapper(*args, **kwargs): 7 | start = time.perf_counter() 8 | result = func(*args, **kwargs) 9 | end = time.perf_counter() 10 | duration = end - start 11 | print(f"⏱️ {func.__name__} executed in {duration:.4f} seconds") 12 | return result 13 | return wrapper 14 | -------------------------------------------------------------------------------- /backend/app/downloaders/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/downloaders/__init__.py -------------------------------------------------------------------------------- /backend/app/downloaders/base.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import Optional, Union 5 | 6 | from app.enmus.note_enums import DownloadQuality 7 | from app.models.notes_model import AudioDownloadResult 8 | from os import getenv 9 | QUALITY_MAP = { 10 | "fast": "32", 11 | "medium": "64", 12 | "slow": "128" 13 | } 14 | 15 | 16 | class Downloader(ABC): 17 | def __init__(self): 18 | #TODO 需要修改为可配置 19 | self.quality = QUALITY_MAP.get('fast') 20 | self.cache_data=getenv('DATA_DIR') 21 | 22 | @abstractmethod 23 | def download(self, video_url: str, output_dir: str = None, 24 | quality: DownloadQuality = "fast", need_video: Optional[bool] = False) -> AudioDownloadResult: 25 | ''' 26 | 27 | :param need_video: 28 | :param video_url: 资源链接 29 | :param output_dir: 输出路径 默认根目录data 30 | :param quality: 音频质量 fast | medium | slow 31 | :return:返回一个 AudioDownloadResult 类 32 | ''' 33 | pass 34 | 35 | @staticmethod 36 | def download_video(self, video_url: str, 37 | output_dir: Union[str, None] = None) -> str: 38 | pass 39 | -------------------------------------------------------------------------------- /backend/app/downloaders/bilibili_downloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABC 3 | from typing import Union, Optional 4 | 5 | import yt_dlp 6 | 7 | from app.downloaders.base import Downloader, DownloadQuality, QUALITY_MAP 8 | from app.models.notes_model import AudioDownloadResult 9 | from app.utils.path_helper import get_data_dir 10 | 11 | 12 | class BilibiliDownloader(Downloader, ABC): 13 | def __init__(self): 14 | super().__init__() 15 | 16 | def download( 17 | self, 18 | video_url: str, 19 | output_dir: Union[str, None] = None, 20 | quality: DownloadQuality = "fast", 21 | need_video:Optional[bool]=False 22 | ) -> AudioDownloadResult: 23 | if output_dir is None: 24 | output_dir = get_data_dir() 25 | if not output_dir: 26 | output_dir=self.cache_data 27 | os.makedirs(output_dir, exist_ok=True) 28 | 29 | output_path = os.path.join(output_dir, "%(id)s.%(ext)s") 30 | 31 | ydl_opts = { 32 | 'format': 'bestaudio[ext=m4a]/bestaudio/best', 33 | 'outtmpl': output_path, 34 | 'noplaylist': True, 35 | 'quiet': False, 36 | } 37 | 38 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 39 | info = ydl.extract_info(video_url, download=True) 40 | video_id = info.get("id") 41 | title = info.get("title") 42 | duration = info.get("duration", 0) 43 | cover_url = info.get("thumbnail") 44 | audio_path = os.path.join(output_dir, f"{video_id}.m4a") 45 | 46 | return AudioDownloadResult( 47 | file_path=audio_path, 48 | title=title, 49 | duration=duration, 50 | cover_url=cover_url, 51 | platform="bilibili", 52 | video_id=video_id, 53 | raw_info=info, 54 | video_path=None # ❗音频下载不包含视频路径 55 | ) 56 | 57 | def download_video( 58 | self, 59 | video_url: str, 60 | output_dir: Union[str, None] = None, 61 | ) -> str: 62 | """ 63 | 下载视频,返回视频文件路径 64 | """ 65 | if output_dir is None: 66 | output_dir = get_data_dir() 67 | 68 | os.makedirs(output_dir, exist_ok=True) 69 | output_path = os.path.join(output_dir, "%(id)s.%(ext)s") 70 | 71 | ydl_opts = { 72 | 'format': 'bv*+ba/bestvideo+bestaudio/best', 73 | 'outtmpl': output_path, 74 | 'noplaylist': True, 75 | 'quiet': False, 76 | 'merge_output_format': 'mp4', # 确保合并成 mp4 77 | } 78 | 79 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 80 | info = ydl.extract_info(video_url, download=True) 81 | video_id = info.get("id") 82 | video_path = os.path.join(output_dir, f"{video_id}.mp4") 83 | 84 | if not os.path.exists(video_path): 85 | raise FileNotFoundError(f"视频文件未找到: {video_path}") 86 | 87 | return video_path 88 | 89 | def delete_video(self, video_path: str) -> str: 90 | """ 91 | 删除视频文件 92 | """ 93 | if os.path.exists(video_path): 94 | os.remove(video_path) 95 | return f"视频文件已删除: {video_path}" 96 | else: 97 | return f"视频文件未找到: {video_path}" -------------------------------------------------------------------------------- /backend/app/downloaders/common.py: -------------------------------------------------------------------------------- 1 | # def download(): 2 | -------------------------------------------------------------------------------- /backend/app/downloaders/douyin_downloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABC 3 | from typing import Union, Optional 4 | 5 | import yt_dlp 6 | 7 | from app.downloaders.base import Downloader, DownloadQuality 8 | from app.models.notes_model import AudioDownloadResult 9 | from app.utils.path_helper import get_data_dir 10 | 11 | 12 | class DouyinDownloader(Downloader, ABC): 13 | def download( 14 | self, 15 | video_url: str, 16 | output_dir: Union[str, None] = None, 17 | quality: DownloadQuality = "fast", 18 | need_video:Optional[bool]=False 19 | ) -> AudioDownloadResult: 20 | if output_dir is None: 21 | output_dir = get_data_dir() 22 | 23 | os.makedirs(output_dir, exist_ok=True) 24 | 25 | output_path = os.path.join(output_dir, "%(id)s.%(ext)s") 26 | 27 | ydl_opts = { 28 | 'format': 'bestaudio[ext=m4a]/bestaudio/best', 29 | 'outtmpl': output_path, 30 | 'noplaylist': True, 31 | 'quiet': False, 32 | } 33 | 34 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 35 | info = ydl.extract_info(video_url, download=True) 36 | video_id = info.get("id") 37 | title = info.get("title") 38 | duration = info.get("duration", 0) 39 | cover_url = info.get("thumbnail") 40 | audio_path = os.path.join(output_dir, f"{video_id}.m4a") 41 | 42 | return AudioDownloadResult( 43 | file_path=audio_path, 44 | title=title, 45 | duration=duration, 46 | cover_url=cover_url, 47 | platform="douyin", 48 | video_id=video_id, 49 | raw_info={'tags':info.get('tags')}, #全部返回会报错 50 | video_path=None # ❗音频下载不包含视频路径 51 | ) 52 | 53 | def download_video( 54 | self, 55 | video_url: str, 56 | output_dir: Union[str, None] = None, 57 | ) -> str: 58 | """ 59 | 下载视频,返回视频文件路径 60 | """ 61 | if output_dir is None: 62 | output_dir = get_data_dir() 63 | 64 | os.makedirs(output_dir, exist_ok=True) 65 | output_path = os.path.join(output_dir, "%(id)s.%(ext)s") 66 | 67 | ydl_opts = { 68 | 'format': 'worst[ext=mp4]/worst', 69 | 'outtmpl': output_path, 70 | 'noplaylist': True, 71 | 'quiet': False, 72 | 'merge_output_format': 'mp4', # 确保合并成 mp4 73 | } 74 | 75 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 76 | info = ydl.extract_info(video_url, download=True) 77 | video_id = info.get("id") 78 | video_path = os.path.join(output_dir, f"{video_id}.mp4") 79 | 80 | if not os.path.exists(video_path): 81 | raise FileNotFoundError(f"视频文件未找到: {video_path}") 82 | 83 | return video_path 84 | -------------------------------------------------------------------------------- /backend/app/downloaders/youtube_downloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABC 3 | from typing import Union, Optional 4 | 5 | import yt_dlp 6 | 7 | from app.downloaders.base import Downloader, DownloadQuality 8 | from app.models.notes_model import AudioDownloadResult 9 | from app.utils.path_helper import get_data_dir 10 | 11 | 12 | class YoutubeDownloader(Downloader, ABC): 13 | def __init__(self): 14 | 15 | super().__init__() 16 | 17 | def download( 18 | self, 19 | video_url: str, 20 | output_dir: Union[str, None] = None, 21 | quality: DownloadQuality = "fast", 22 | need_video:Optional[bool]=False 23 | ) -> AudioDownloadResult: 24 | if output_dir is None: 25 | output_dir = get_data_dir() 26 | if not output_dir: 27 | output_dir=self.cache_data 28 | os.makedirs(output_dir, exist_ok=True) 29 | 30 | output_path = os.path.join(output_dir, "%(id)s.%(ext)s") 31 | 32 | ydl_opts = { 33 | 'format': 'bestaudio[ext=m4a]/bestaudio/best', 34 | 'outtmpl': output_path, 35 | 'noplaylist': True, 36 | 'quiet': False, 37 | } 38 | 39 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 40 | info = ydl.extract_info(video_url, download=True) 41 | video_id = info.get("id") 42 | title = info.get("title") 43 | duration = info.get("duration", 0) 44 | cover_url = info.get("thumbnail") 45 | audio_path = os.path.join(output_dir, f"{video_id}.m4a") 46 | 47 | return AudioDownloadResult( 48 | file_path=audio_path, 49 | title=title, 50 | duration=duration, 51 | cover_url=cover_url, 52 | platform="youtube", 53 | video_id=video_id, 54 | raw_info={'tags':info.get('tags')}, #全部返回会报错 55 | video_path=None # ❗音频下载不包含视频路径 56 | ) 57 | 58 | def download_video( 59 | self, 60 | video_url: str, 61 | output_dir: Union[str, None] = None, 62 | ) -> str: 63 | """ 64 | 下载视频,返回视频文件路径 65 | """ 66 | if output_dir is None: 67 | output_dir = get_data_dir() 68 | 69 | os.makedirs(output_dir, exist_ok=True) 70 | output_path = os.path.join(output_dir, "%(id)s.%(ext)s") 71 | 72 | ydl_opts = { 73 | 'format': 'worst[ext=mp4]/worst', 74 | 'outtmpl': output_path, 75 | 'noplaylist': True, 76 | 'quiet': False, 77 | 'merge_output_format': 'mp4', # 确保合并成 mp4 78 | } 79 | 80 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 81 | info = ydl.extract_info(video_url, download=True) 82 | video_id = info.get("id") 83 | video_path = os.path.join(output_dir, f"{video_id}.mp4") 84 | 85 | if not os.path.exists(video_path): 86 | raise FileNotFoundError(f"视频文件未找到: {video_path}") 87 | 88 | return video_path 89 | -------------------------------------------------------------------------------- /backend/app/enmus/note_enums.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class DownloadQuality(str, enum.Enum): 5 | fast = "fast" 6 | medium = "medium" 7 | slow = "slow" 8 | -------------------------------------------------------------------------------- /backend/app/gpt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/gpt/__init__.py -------------------------------------------------------------------------------- /backend/app/gpt/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC,abstractmethod 2 | 3 | from app.models.gpt_model import GPTSource 4 | 5 | 6 | class GPT(ABC): 7 | def summarize(self, source:GPTSource )->str: 8 | ''' 9 | 10 | :param source: 11 | :return: 12 | ''' 13 | pass -------------------------------------------------------------------------------- /backend/app/gpt/deepseek_gpt.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from app.gpt.base import GPT 3 | from openai import OpenAI 4 | from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT 5 | from app.gpt.utils import fix_markdown 6 | from app.models.gpt_model import GPTSource 7 | from app.models.transcriber_model import TranscriptSegment 8 | from datetime import timedelta 9 | 10 | 11 | class DeepSeekGPT(GPT): 12 | def __init__(self): 13 | from os import getenv 14 | self.api_key = getenv("DEEP_SEEK_API_KEY") 15 | self.base_url = getenv("DEEP_SEEK_API_BASE_URL") 16 | self.model=getenv('DEEP_SEEK_MODEL') 17 | print(self.model) 18 | self.client = OpenAI(api_key=self.api_key, base_url=self.base_url) 19 | self.screenshot = False 20 | 21 | def _format_time(self, seconds: float) -> str: 22 | return str(timedelta(seconds=int(seconds)))[2:] # e.g., 03:15 23 | 24 | def _build_segment_text(self, segments: List[TranscriptSegment]) -> str: 25 | return "\n".join( 26 | f"{self._format_time(seg.start)} - {seg.text.strip()}" 27 | for seg in segments 28 | ) 29 | 30 | def ensure_segments_type(self, segments) -> List[TranscriptSegment]: 31 | return [ 32 | TranscriptSegment(**seg) if isinstance(seg, dict) else seg 33 | for seg in segments 34 | ] 35 | 36 | def create_messages(self, segments: List[TranscriptSegment], title: str,tags:str): 37 | content = BASE_PROMPT.format( 38 | video_title=title, 39 | segment_text=self._build_segment_text(segments), 40 | tags=tags 41 | ) 42 | if self.screenshot: 43 | print(":需要截图") 44 | content += SCREENSHOT 45 | print(content) 46 | return [{"role": "user", "content": content + AI_SUM}] 47 | 48 | def summarize(self, source: GPTSource) -> str: 49 | self.screenshot = source.screenshot 50 | source.segment = self.ensure_segments_type(source.segment) 51 | messages = self.create_messages(source.segment, source.title,source.tags) 52 | response = self.client.chat.completions.create( 53 | model=self.model, 54 | messages=messages, 55 | temperature=0.7 56 | ) 57 | return response.choices[0].message.content.strip() 58 | 59 | 60 | -------------------------------------------------------------------------------- /backend/app/gpt/openai_gpt.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from app.gpt.base import GPT 3 | from openai import OpenAI 4 | from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT, LINK 5 | from app.gpt.utils import fix_markdown 6 | from app.models.gpt_model import GPTSource 7 | from app.models.transcriber_model import TranscriptSegment 8 | from datetime import timedelta 9 | 10 | 11 | class OpenaiGPT(GPT): 12 | def __init__(self): 13 | from os import getenv 14 | self.api_key = getenv("OPENAI_API_KEY") 15 | self.base_url = getenv("OPENAI_API_BASE_URL") 16 | self.model=getenv('OPENAI_MODEL') 17 | print(self.model) 18 | self.client = OpenAI(api_key=self.api_key, base_url=self.base_url) 19 | self.screenshot = False 20 | self.link=False 21 | 22 | def _format_time(self, seconds: float) -> str: 23 | return str(timedelta(seconds=int(seconds)))[2:] # e.g., 03:15 24 | 25 | def _build_segment_text(self, segments: List[TranscriptSegment]) -> str: 26 | return "\n".join( 27 | f"{self._format_time(seg.start)} - {seg.text.strip()}" 28 | for seg in segments 29 | ) 30 | 31 | def ensure_segments_type(self, segments) -> List[TranscriptSegment]: 32 | return [ 33 | TranscriptSegment(**seg) if isinstance(seg, dict) else seg 34 | for seg in segments 35 | ] 36 | 37 | def create_messages(self, segments: List[TranscriptSegment], title: str,tags:str): 38 | content = BASE_PROMPT.format( 39 | video_title=title, 40 | segment_text=self._build_segment_text(segments), 41 | tags=tags 42 | ) 43 | if self.screenshot: 44 | print(":需要截图") 45 | content += SCREENSHOT 46 | if self.link: 47 | print(":需要链接") 48 | content += LINK 49 | 50 | print(content) 51 | return [{"role": "user", "content": content + AI_SUM}] 52 | 53 | def summarize(self, source: GPTSource) -> str: 54 | self.screenshot = source.screenshot 55 | self.link = source.link 56 | source.segment = self.ensure_segments_type(source.segment) 57 | messages = self.create_messages(source.segment, source.title,source.tags) 58 | response = self.client.chat.completions.create( 59 | model=self.model, 60 | messages=messages, 61 | temperature=0.7 62 | ) 63 | return response.choices[0].message.content.strip() 64 | 65 | 66 | -------------------------------------------------------------------------------- /backend/app/gpt/prompt.py: -------------------------------------------------------------------------------- 1 | BASE_PROMPT = ''' 2 | You are a professional note-taking assistant who excels at summarizing video transcripts into clear, structured, and information-rich notes. 3 | 4 | 🎯 Language Requirement: 5 | - The notes must be written in **Chinese**. 6 | - Proper nouns, technical terms, brand names, and personal names should remain in **English** where appropriate. 7 | 8 | 📌 Video Title: 9 | {video_title} 10 | 11 | 📎 Video Tags: 12 | {tags} 13 | 14 | 📝 Your Task: 15 | Based on the segmented transcript below, generate structured notes in standard **Markdown format**, and follow these principles: 16 | 17 | 1. **Complete information**: Record as much relevant detail as possible to ensure comprehensive coverage. 18 | 2. **Clear structure**: Organize content with logical sectioning. Use appropriate heading levels (`##`, `###`) to summarize key points in each section. 19 | 3. **Concise wording**: Use accurate, clear, and professional Chinese expressions. 20 | 4. **Remove irrelevant content**: Omit advertisements, filler words, casual greetings, and off-topic remarks. 21 | 5. **Keep critical details**: Preserve important facts, examples, conclusions, and recommendations. 22 | 6. **Readable layout**: Use bullet points where needed, and keep paragraphs reasonably short to enhance readability. 23 | 7. **Table of Contents**: Generate a table of contents at the top based on the `##` level headings. 24 | 25 | 26 | ⚠️ Output Instructions: 27 | - Only return the final **Markdown content**. 28 | - Do **not** wrap the output in code blocks like ```` ```markdown ```` or ```` ``` ````. 29 | 30 | 31 | 🎬 Transcript Segments (Format: Start Time - Text): 32 | 33 | --- 34 | {segment_text} 35 | --- 36 | ''' 37 | 38 | LINK=''' 39 | 9. **Add time markers**: THIS IS IMPORTANT For every main heading (`##`), append the starting time of that segment using the format ,start with *Content ,eg: `*Content-[mm:ss]`. 40 | 41 | 42 | ''' 43 | AI_SUM=''' 44 | 45 | 🧠 Final Touch: 46 | At the end of the notes, add a professional **AI Summary** in Chinese – a brief conclusion summarizing the whole video. 47 | 48 | 49 | 50 | ''' 51 | 52 | SCREENSHOT=''' 53 | 8. **Screenshot placeholders**: If a section involves **visual demonstrations, code walkthroughs, UI interactions**, or any content where visuals aid understanding, insert a screenshot cue at the end of that section: 54 | - Format: `*Screenshot-[mm:ss]` 55 | - Only use it when truly helpful. 56 | ''' -------------------------------------------------------------------------------- /backend/app/gpt/qwen_gpt.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from app.gpt.base import GPT 3 | from openai import OpenAI 4 | from app.gpt.prompt import BASE_PROMPT, AI_SUM, SCREENSHOT 5 | from app.gpt.utils import fix_markdown 6 | from app.models.gpt_model import GPTSource 7 | from app.models.transcriber_model import TranscriptSegment 8 | from datetime import timedelta 9 | 10 | 11 | class QwenGPT(GPT): 12 | def __init__(self): 13 | from os import getenv 14 | self.api_key = getenv("QWEN_API_KEY") 15 | self.base_url = getenv("QWEN_API_BASE_URL") 16 | self.model=getenv('QWEN_MODEL') 17 | print(self.model) 18 | self.client = OpenAI(api_key=self.api_key, base_url=self.base_url) 19 | self.screenshot = False 20 | 21 | def _format_time(self, seconds: float) -> str: 22 | return str(timedelta(seconds=int(seconds)))[2:] # e.g., 03:15 23 | 24 | def _build_segment_text(self, segments: List[TranscriptSegment]) -> str: 25 | return "\n".join( 26 | f"{self._format_time(seg.start)} - {seg.text.strip()}" 27 | for seg in segments 28 | ) 29 | 30 | def ensure_segments_type(self, segments) -> List[TranscriptSegment]: 31 | return [ 32 | TranscriptSegment(**seg) if isinstance(seg, dict) else seg 33 | for seg in segments 34 | ] 35 | 36 | def create_messages(self, segments: List[TranscriptSegment], title: str,tags:str): 37 | content = BASE_PROMPT.format( 38 | video_title=title, 39 | segment_text=self._build_segment_text(segments), 40 | tags=tags 41 | ) 42 | if self.screenshot: 43 | print(":需要截图") 44 | content += SCREENSHOT 45 | print(content) 46 | return [{"role": "user", "content": content + AI_SUM}] 47 | 48 | def summarize(self, source: GPTSource) -> str: 49 | self.screenshot = source.screenshot 50 | source.segment = self.ensure_segments_type(source.segment) 51 | messages = self.create_messages(source.segment, source.title,source.tags) 52 | response = self.client.chat.completions.create( 53 | model=self.model, 54 | messages=messages, 55 | temperature=0.7 56 | ) 57 | return response.choices[0].message.content.strip() 58 | 59 | 60 | -------------------------------------------------------------------------------- /backend/app/gpt/tools.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/gpt/tools.py -------------------------------------------------------------------------------- /backend/app/gpt/utils.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | 3 | def fix_markdown(markdown: str) -> str: 4 | return codecs.decode(markdown, 'unicode_escape') -------------------------------------------------------------------------------- /backend/app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/models/__init__.py -------------------------------------------------------------------------------- /backend/app/models/audio_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | 5 | @dataclass 6 | class AudioDownloadResult: 7 | file_path: str # 本地音频路径 8 | title: str # 视频标题 9 | duration: float # 视频时长(秒) 10 | cover_url: Optional[str] # 视频封面图 11 | platform: str # 平台,如 "bilibili" 12 | video_id: str # 唯一视频ID 13 | raw_info: dict # yt-dlp 的原始 info 字典 14 | video_path: Optional[str] = None # ✅ 新增字段:可选视频文件路径 15 | 16 | -------------------------------------------------------------------------------- /backend/app/models/gpt_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Union, Optional 3 | 4 | from app.models.transcriber_model import TranscriptSegment 5 | 6 | 7 | @dataclass 8 | class GPTSource: 9 | segment: Union[List[TranscriptSegment], List] 10 | title: str 11 | tags:str 12 | screenshot: Optional[bool] = False 13 | link: Optional[bool] = False 14 | 15 | -------------------------------------------------------------------------------- /backend/app/models/notes_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from app.models.audio_model import AudioDownloadResult 5 | from app.models.transcriber_model import TranscriptResult 6 | 7 | 8 | @dataclass 9 | class NoteResult: 10 | markdown: str # GPT 总结的 Markdown 内容 11 | transcript: TranscriptResult # Whisper 转写结果 12 | audio_meta: AudioDownloadResult # 音频下载的元信息(title、duration、封面等) -------------------------------------------------------------------------------- /backend/app/models/transcriber_model.py: -------------------------------------------------------------------------------- 1 | 2 | from dataclasses import dataclass 3 | from typing import List, Optional 4 | 5 | @dataclass 6 | class TranscriptSegment: 7 | start: float # 开始时间(秒) 8 | end: float # 结束时间(秒) 9 | text: str # 该段文字 10 | 11 | @dataclass 12 | class TranscriptResult: 13 | language: Optional[str] # 检测语言(如 "zh"、"en") 14 | full_text: str # 完整合并后的文本(用于摘要) 15 | segments: List[TranscriptSegment] # 分段结构,适合前端显示时间轴字幕等 16 | raw: Optional[dict] = None # 原始响应数据,便于调试或平台特性处理 -------------------------------------------------------------------------------- /backend/app/models/video_record.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/models/video_record.py -------------------------------------------------------------------------------- /backend/app/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/routers/__init__.py -------------------------------------------------------------------------------- /backend/app/routers/note.py: -------------------------------------------------------------------------------- 1 | # app/routers/note.py 2 | import json 3 | import os 4 | import uuid 5 | from typing import Optional 6 | 7 | from fastapi import APIRouter, HTTPException, BackgroundTasks 8 | from pydantic import BaseModel, validator 9 | from dataclasses import asdict 10 | 11 | from app.db.video_task_dao import get_task_by_video 12 | from app.enmus.note_enums import DownloadQuality 13 | from app.services.note import NoteGenerator 14 | from app.utils.response import ResponseWrapper as R 15 | from app.utils.url_parser import extract_video_id 16 | from app.validators.video_url_validator import is_supported_video_url 17 | from fastapi import APIRouter, Request, HTTPException 18 | from fastapi.responses import StreamingResponse 19 | import httpx 20 | 21 | # from app.services.downloader import download_raw_audio 22 | # from app.services.whisperer import transcribe_audio 23 | 24 | router = APIRouter() 25 | 26 | 27 | class RecordRequest(BaseModel): 28 | video_id: str 29 | platform: str 30 | 31 | 32 | class VideoRequest(BaseModel): 33 | video_url: str 34 | platform: str 35 | quality: DownloadQuality 36 | screenshot: Optional[bool] = False 37 | link: Optional[bool] = False 38 | 39 | @validator("video_url") 40 | def validate_supported_url(cls, v): 41 | url = str(v) 42 | # 支持平台校验 43 | if not is_supported_video_url(url): 44 | raise ValueError("暂不支持该视频平台或链接格式无效") 45 | return v 46 | 47 | 48 | NOTE_OUTPUT_DIR = "note_results" 49 | 50 | 51 | def save_note_to_file(task_id: str, note): 52 | os.makedirs(NOTE_OUTPUT_DIR, exist_ok=True) 53 | with open(os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json"), "w", encoding="utf-8") as f: 54 | json.dump(asdict(note), f, ensure_ascii=False, indent=2) 55 | 56 | 57 | def run_note_task(task_id: str, video_url: str, platform: str, quality: DownloadQuality, link: bool = False,screenshot: bool = False): 58 | try: 59 | note = NoteGenerator().generate( 60 | video_url=video_url, 61 | platform=platform, 62 | quality=quality, 63 | task_id=task_id, 64 | link=link, 65 | screenshot=screenshot 66 | ) 67 | print('Note 结果',note) 68 | save_note_to_file(task_id, note) 69 | except Exception as e: 70 | save_note_to_file(task_id, {"error": str(e)}) 71 | 72 | 73 | @router.post('/delete_task') 74 | def delete_task(data:RecordRequest): 75 | try: 76 | 77 | NoteGenerator().delete_note(video_id=data.video_id,platform=data.platform) 78 | return R.success(msg='删除成功') 79 | except Exception as e: 80 | return R.error(msg=e) 81 | 82 | 83 | @router.post("/generate_note") 84 | def generate_note(data: VideoRequest, background_tasks: BackgroundTasks): 85 | try: 86 | 87 | video_id = extract_video_id(data.video_url, data.platform) 88 | if not video_id: 89 | raise HTTPException(status_code=400, detail="无法提取视频 ID") 90 | existing = get_task_by_video(video_id, data.platform) 91 | if existing: 92 | return R.error( 93 | msg='笔记已生成,请勿重复发起', 94 | 95 | ) 96 | 97 | task_id = str(uuid.uuid4()) 98 | 99 | background_tasks.add_task(run_note_task, task_id, data.video_url, data.platform, data.quality,data.link ,data.screenshot) 100 | return R.success({"task_id": task_id}) 101 | except Exception as e: 102 | raise HTTPException(status_code=500, detail=str(e)) 103 | 104 | 105 | @router.get("/task_status/{task_id}") 106 | def get_task_status(task_id: str): 107 | path = os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json") 108 | if not os.path.exists(path): 109 | return R.success({"status": "PENDING"}) 110 | 111 | with open(path, "r", encoding="utf-8") as f: 112 | content = json.load(f) 113 | 114 | if "error" in content: 115 | return R.error(content["error"], code=500) 116 | content['id'] = task_id 117 | return R.success({ 118 | "status": "SUCCESS", 119 | "result": content 120 | }) 121 | 122 | 123 | @router.get("/image_proxy") 124 | async def image_proxy(request: Request, url: str): 125 | headers = { 126 | "Referer": "https://www.bilibili.com/", # 模拟B站来源 127 | "User-Agent": request.headers.get("User-Agent", ""), 128 | } 129 | 130 | try: 131 | async with httpx.AsyncClient(timeout=10.0) as client: 132 | resp = await client.get(url, headers=headers) 133 | if resp.status_code != 200: 134 | raise HTTPException(status_code=resp.status_code, detail="图片获取失败") 135 | 136 | content_type = resp.headers.get("Content-Type", "image/jpeg") 137 | return StreamingResponse(resp.aiter_bytes(), media_type=content_type) 138 | except Exception as e: 139 | raise HTTPException(status_code=500, detail=str(e)) 140 | -------------------------------------------------------------------------------- /backend/app/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/services/__init__.py -------------------------------------------------------------------------------- /backend/app/services/note.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Union 3 | 4 | from pydantic import HttpUrl 5 | 6 | from app.db.video_task_dao import insert_video_task, delete_task_by_video 7 | from app.downloaders.base import Downloader 8 | from app.downloaders.bilibili_downloader import BilibiliDownloader 9 | from app.downloaders.douyin_downloader import DouyinDownloader 10 | from app.downloaders.youtube_downloader import YoutubeDownloader 11 | from app.gpt.base import GPT 12 | from app.gpt.deepseek_gpt import DeepSeekGPT 13 | from app.gpt.openai_gpt import OpenaiGPT 14 | from app.gpt.qwen_gpt import QwenGPT 15 | from app.models.gpt_model import GPTSource 16 | from app.models.notes_model import NoteResult 17 | from app.models.notes_model import AudioDownloadResult 18 | from app.enmus.note_enums import DownloadQuality 19 | from app.models.transcriber_model import TranscriptResult 20 | from app.transcriber.base import Transcriber 21 | from app.transcriber.transcriber_provider import get_transcriber,_transcribers 22 | from app.transcriber.whisper import WhisperTranscriber 23 | import re 24 | 25 | from app.utils.note_helper import replace_content_markers 26 | from app.utils.video_helper import generate_screenshot 27 | 28 | # from app.services.whisperer import transcribe_audio 29 | # from app.services.gpt import summarize_text 30 | from dotenv import load_dotenv 31 | from app.utils.logger import get_logger 32 | logger = get_logger(__name__) 33 | load_dotenv() 34 | BACKEND_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:8000") 35 | 36 | output_dir = os.getenv('OUT_DIR') 37 | image_base_url = os.getenv('IMAGE_BASE_URL') 38 | logger.info("starting up") 39 | 40 | 41 | 42 | class NoteGenerator: 43 | def __init__(self): 44 | self.model_size: str = 'base' 45 | self.device: Union[str, None] = None 46 | self.transcriber_type = os.getenv('TRANSCRIBER_TYPE','fast-whisper') 47 | self.transcriber = self.get_transcriber() 48 | # TODO 需要更换为可调节 49 | 50 | self.provider = os.getenv('MODEl_PROVIDER','openai') 51 | self.video_path = None 52 | logger.info("初始化NoteGenerator") 53 | 54 | 55 | def get_gpt(self) -> GPT: 56 | self.provider = self.provider.lower() 57 | if self.provider == 'openai': 58 | logger.info("使用OpenAI") 59 | return OpenaiGPT() 60 | elif self.provider == 'deepseek': 61 | logger.info("使用DeepSeek") 62 | return DeepSeekGPT() 63 | elif self.provider == 'qwen': 64 | logger.info("使用Qwen") 65 | return QwenGPT() 66 | else: 67 | self.provider = 'openai' 68 | logger.warning("不支持的AI提供商,使用 OpenAI 做完GPT") 69 | return OpenaiGPT() 70 | 71 | 72 | def get_downloader(self, platform: str) -> Downloader: 73 | if platform == "bilibili": 74 | logger.info("下载 Bilibili 平台视频") 75 | return BilibiliDownloader() 76 | elif platform == "youtube": 77 | logger.info("下载 YouTube 平台视频") 78 | return YoutubeDownloader() 79 | elif platform == 'douyin': 80 | logger.info("下载 Douyin 平台视频") 81 | return DouyinDownloader() 82 | else: 83 | logger.warning("不支持的平台") 84 | raise ValueError(f"不支持的平台:{platform}") 85 | 86 | def get_transcriber(self) -> Transcriber: 87 | ''' 88 | 89 | :param transcriber: 选择的转义器 90 | :return: 91 | ''' 92 | if self.transcriber_type in _transcribers.keys(): 93 | logger.info(f"使用{self.transcriber_type}转义器") 94 | return get_transcriber(transcriber_type=self.transcriber_type) 95 | else: 96 | logger.warning("不支持的转义器") 97 | raise ValueError(f"不支持的转义器:{self.transcriber}") 98 | 99 | def save_meta(self, video_id, platform, task_id): 100 | logger.info(f"记录已经生成的数据信息") 101 | insert_video_task(video_id=video_id, platform=platform, task_id=task_id) 102 | 103 | def insert_screenshots_into_markdown(self, markdown: str, video_path: str, image_base_url: str, 104 | output_dir: str) -> str: 105 | """ 106 | 扫描 markdown 中的 *Screenshot-xx:xx,生成截图并插入 markdown 图片 107 | :param markdown: 108 | :param image_base_url: 最终返回给前端的路径前缀(如 /static/screenshots) 109 | """ 110 | matches = self.extract_screenshot_timestamps(markdown) 111 | new_markdown = markdown 112 | logger.info(f"开始为笔记生成截图") 113 | try: 114 | for idx, (marker, ts) in enumerate(matches): 115 | image_path = generate_screenshot(video_path, output_dir, ts, idx) 116 | image_relative_path = os.path.join(image_base_url, os.path.basename(image_path)).replace("\\", "/") 117 | image_url = f"{BACKEND_BASE_URL.rstrip('/')}/{image_relative_path.lstrip('/')}" 118 | replacement = f"![]({image_url})" 119 | new_markdown = new_markdown.replace(marker, replacement, 1) 120 | 121 | return new_markdown 122 | except Exception as e: 123 | logger.error(f"截图生成失败:{e}") 124 | raise e 125 | 126 | @staticmethod 127 | def delete_note(video_id: str, platform: str): 128 | logger.info(f"删除生成的笔记记录") 129 | return delete_task_by_video(video_id, platform) 130 | 131 | import re 132 | 133 | def extract_screenshot_timestamps(self, markdown: str) -> list[tuple[str, int]]: 134 | """ 135 | 从 Markdown 中提取 Screenshot 时间标记(如 *Screenshot-03:39 或 Screenshot-[03:39]), 136 | 并返回匹配文本和对应时间戳(秒) 137 | """ 138 | logger.info(f"开始提取截图时间标记") 139 | pattern = r"(?:\*Screenshot-(\d{2}):(\d{2})|Screenshot-\[(\d{2}):(\d{2})\])" 140 | matches = list(re.finditer(pattern, markdown)) 141 | results = [] 142 | for match in matches: 143 | mm = match.group(1) or match.group(3) 144 | ss = match.group(2) or match.group(4) 145 | total_seconds = int(mm) * 60 + int(ss) 146 | results.append((match.group(0), total_seconds)) 147 | return results 148 | 149 | def generate( 150 | self, 151 | 152 | video_url: Union[str, HttpUrl], 153 | platform: str, 154 | quality: DownloadQuality = DownloadQuality.medium, 155 | task_id: Union[str, None] = None, 156 | link: bool = False, 157 | screenshot: bool = False, 158 | path: Union[str, None] = None 159 | 160 | ) -> NoteResult: 161 | logger.info(f"开始解析并生成笔记") 162 | # 1. 选择下载器 163 | downloader = self.get_downloader(platform) 164 | gpt = self.get_gpt() 165 | logger.info(f'使用{downloader.__class__.__name__}下载器') 166 | logger.info(f'使用{gpt.__class__.__name__}GPT') 167 | logger.info(f'视频地址:{video_url}') 168 | if screenshot: 169 | 170 | video_path = downloader.download_video(video_url) 171 | self.video_path = video_path 172 | print(video_path) 173 | 174 | # 2. 下载音频 175 | audio: AudioDownloadResult = downloader.download( 176 | video_url=video_url, 177 | quality=quality, 178 | output_dir=path, 179 | need_video=screenshot 180 | 181 | ) 182 | logger.info(f"下载音频成功,文件路径:{audio.file_path}") 183 | # 3. Whisper 转写 184 | transcript: TranscriptResult = self.transcriber.transcript(file_path=audio.file_path) 185 | logger.info(f"Whisper 转写成功,转写结果:{transcript.full_text}") 186 | # 4. GPT 总结 187 | source = GPTSource( 188 | title=audio.title, 189 | segment=transcript.segments, 190 | tags=audio.raw_info.get('tags'), 191 | screenshot=screenshot, 192 | link=link 193 | ) 194 | logger.info(f"GPT 总结完成,总结结果:{source}") 195 | markdown: str = gpt.summarize(source) 196 | print("markdown结果", markdown) 197 | 198 | markdown = replace_content_markers(markdown=markdown, video_id=audio.video_id, platform=platform) 199 | if self.video_path: 200 | markdown = self.insert_screenshots_into_markdown(markdown, self.video_path, image_base_url, output_dir) 201 | self.save_meta(video_id=audio.video_id, platform=platform, task_id=task_id) 202 | # 5. 返回结构体 203 | return NoteResult( 204 | markdown=markdown, 205 | transcript=transcript, 206 | audio_meta=audio 207 | ) 208 | 209 | 210 | -------------------------------------------------------------------------------- /backend/app/transcriber/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/transcriber/__init__.py -------------------------------------------------------------------------------- /backend/app/transcriber/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from app.models.transcriber_model import TranscriptResult 4 | 5 | 6 | class Transcriber(ABC): 7 | @abstractmethod 8 | def transcript(self,file_path:str)->TranscriptResult: 9 | ''' 10 | 11 | :param file_path:音频路径 12 | :return: 返回一个 TranscriptResult 类 13 | ''' 14 | pass 15 | 16 | def on_finish(self,video_path:str,result: TranscriptResult)->None: 17 | ''' 18 | 当音频转录完成时调用 19 | :param video_path: 视频路径 20 | :param result: 识别结果 21 | :return: 22 | ''' 23 | pass -------------------------------------------------------------------------------- /backend/app/transcriber/bcut.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | from typing import Optional, List, Dict, Union 5 | 6 | import requests 7 | 8 | from app.decorators.timeit import timeit 9 | from app.models.transcriber_model import TranscriptSegment, TranscriptResult 10 | from app.transcriber.base import Transcriber 11 | from app.utils.logger import get_logger 12 | from events import transcription_finished 13 | 14 | __version__ = "0.0.3" 15 | 16 | API_BASE_URL = "https://member.bilibili.com/x/bcut/rubick-interface" 17 | 18 | # 申请上传 19 | API_REQ_UPLOAD = API_BASE_URL + "/resource/create" 20 | 21 | # 提交上传 22 | API_COMMIT_UPLOAD = API_BASE_URL + "/resource/create/complete" 23 | 24 | # 创建任务 25 | API_CREATE_TASK = API_BASE_URL + "/task" 26 | 27 | # 查询结果 28 | API_QUERY_RESULT = API_BASE_URL + "/task/result" 29 | 30 | logger = get_logger(__name__) 31 | 32 | class BcutTranscriber(Transcriber): 33 | """必剪 语音识别接口""" 34 | headers = { 35 | 'User-Agent': 'Bilibili/1.0.0 (https://www.bilibili.com)', 36 | 'Content-Type': 'application/json' 37 | } 38 | 39 | def __init__(self): 40 | self.session = requests.Session() 41 | self.task_id = None 42 | self.__etags = [] 43 | 44 | self.__in_boss_key: Optional[str] = None 45 | self.__resource_id: Optional[str] = None 46 | self.__upload_id: Optional[str] = None 47 | self.__upload_urls: List[str] = [] 48 | self.__per_size: Optional[int] = None 49 | self.__clips: Optional[int] = None 50 | 51 | self.__etags: List[str] = [] 52 | self.__download_url: Optional[str] = None 53 | self.task_id: Optional[str] = None 54 | 55 | def _load_file(self, file_path: str) -> bytes: 56 | """读取文件内容""" 57 | with open(file_path, 'rb') as f: 58 | return f.read() 59 | 60 | def _upload(self, file_path: str) -> None: 61 | """申请上传""" 62 | file_binary = self._load_file(file_path) 63 | if not file_binary: 64 | raise ValueError("无法读取文件数据") 65 | 66 | payload = json.dumps({ 67 | "type": 2, 68 | "name": "audio.mp3", 69 | "size": len(file_binary), 70 | "ResourceFileType": "mp3", 71 | "model_id": "8", 72 | }) 73 | 74 | resp = self.session.post( 75 | API_REQ_UPLOAD, 76 | data=payload, 77 | headers=self.headers 78 | ) 79 | resp.raise_for_status() 80 | resp = resp.json() 81 | resp_data = resp["data"] 82 | 83 | self.__in_boss_key = resp_data["in_boss_key"] 84 | self.__resource_id = resp_data["resource_id"] 85 | self.__upload_id = resp_data["upload_id"] 86 | self.__upload_urls = resp_data["upload_urls"] 87 | self.__per_size = resp_data["per_size"] 88 | self.__clips = len(resp_data["upload_urls"]) 89 | 90 | logger.info( 91 | f"申请上传成功, 总计大小{resp_data['size'] // 1024}KB, {self.__clips}分片, 分片大小{resp_data['per_size'] // 1024}KB: {self.__in_boss_key}" 92 | ) 93 | self.__upload_part(file_binary) 94 | self.__commit_upload() 95 | 96 | def __upload_part(self, file_binary: bytes) -> None: 97 | """上传音频数据""" 98 | for clip in range(self.__clips): 99 | start_range = clip * self.__per_size 100 | end_range = min((clip + 1) * self.__per_size, len(file_binary)) 101 | logger.info(f"开始上传分片{clip}: {start_range}-{end_range}") 102 | resp = self.session.put( 103 | self.__upload_urls[clip], 104 | data=file_binary[start_range:end_range], 105 | headers={'Content-Type': 'application/octet-stream'} 106 | ) 107 | resp.raise_for_status() 108 | etag = resp.headers.get("Etag", "").strip('"') 109 | self.__etags.append(etag) 110 | logger.info(f"分片{clip}上传成功: {etag}") 111 | 112 | def __commit_upload(self) -> None: 113 | """提交上传数据""" 114 | data = json.dumps({ 115 | "InBossKey": self.__in_boss_key, 116 | "ResourceId": self.__resource_id, 117 | "Etags": ",".join(self.__etags), 118 | "UploadId": self.__upload_id, 119 | "model_id": "8", 120 | }) 121 | resp = self.session.post( 122 | API_COMMIT_UPLOAD, 123 | data=data, 124 | headers=self.headers 125 | ) 126 | resp.raise_for_status() 127 | resp = resp.json() 128 | if resp.get("code") != 0: 129 | error_msg = f"上传提交失败: {resp.get('message', '未知错误')}" 130 | logger.error(error_msg) 131 | raise Exception(error_msg) 132 | 133 | self.__download_url = resp["data"]["download_url"] 134 | logger.info(f"提交成功,下载链接: {self.__download_url}") 135 | 136 | def _create_task(self) -> str: 137 | """开始创建转换任务""" 138 | resp = self.session.post( 139 | API_CREATE_TASK, json={"resource": self.__download_url, "model_id": "8"}, headers=self.headers 140 | ) 141 | resp.raise_for_status() 142 | resp = resp.json() 143 | if resp.get("code") != 0: 144 | error_msg = f"创建任务失败: {resp.get('message', '未知错误')}" 145 | logger.error(error_msg) 146 | raise Exception(error_msg) 147 | 148 | self.task_id = resp["data"]["task_id"] 149 | logger.info(f"任务已创建: {self.task_id}") 150 | return self.task_id 151 | 152 | def _query_result(self) -> dict: 153 | """查询转换结果""" 154 | resp = self.session.get( 155 | API_QUERY_RESULT, 156 | params={"model_id": 7, "task_id": self.task_id}, 157 | headers=self.headers 158 | ) 159 | resp.raise_for_status() 160 | resp = resp.json() 161 | if resp.get("code") != 0: 162 | error_msg = f"查询结果失败: {resp.get('message', '未知错误')}" 163 | logger.error(error_msg) 164 | raise Exception(error_msg) 165 | 166 | return resp["data"] 167 | 168 | @timeit 169 | def transcript(self, file_path: str) -> TranscriptResult: 170 | """执行识别过程,符合 Transcriber 接口""" 171 | try: 172 | logger.info(f"开始处理文件: {file_path}") 173 | 174 | # 上传文件 175 | logger.info("正在上传文件...") 176 | self._upload(file_path) 177 | 178 | # 创建任务 179 | logger.info("提交转录任务...") 180 | self._create_task() 181 | 182 | # 轮询检查任务状态 183 | logger.info("等待转录结果...") 184 | task_resp = None 185 | max_retries = 500 186 | for i in range(max_retries): 187 | task_resp = self._query_result() 188 | 189 | if task_resp["state"] == 4: # 完成状态 190 | break 191 | elif task_resp["state"] == 3: # 失败状态 192 | error_msg = f"B站ASR任务失败,状态码: {task_resp['state']}" 193 | logger.error(error_msg) 194 | raise Exception(error_msg) 195 | 196 | # 每隔一段时间打印进度 197 | if i % 10 == 0: 198 | logger.info(f"转录进行中... {i}/{max_retries}") 199 | 200 | time.sleep(1) 201 | 202 | if not task_resp or task_resp["state"] != 4: 203 | error_msg = f"B站ASR任务未能完成,状态: {task_resp.get('state') if task_resp else 'Unknown'}" 204 | logger.error(error_msg) 205 | raise Exception(error_msg) 206 | 207 | # 解析结果 208 | logger.info("转录成功,处理结果...") 209 | result_json = json.loads(task_resp["result"]) 210 | 211 | # 提取分段数据 212 | segments = [] 213 | full_text = "" 214 | 215 | for u in result_json.get("utterances", []): 216 | text = u.get("transcript", "").strip() 217 | # B站ASR返回的时间戳是毫秒,需要转换为秒 218 | start_time = float(u.get("start_time", 0)) / 1000.0 219 | end_time = float(u.get("end_time", 0)) / 1000.0 220 | 221 | full_text += text + " " 222 | segments.append(TranscriptSegment( 223 | start=start_time, 224 | end=end_time, 225 | text=text 226 | )) 227 | 228 | # 创建结果对象 229 | result = TranscriptResult( 230 | language=result_json.get("language", "zh"), 231 | full_text=full_text.strip(), 232 | segments=segments, 233 | raw=result_json 234 | ) 235 | 236 | # 触发完成事件 237 | self.on_finish(file_path, result) 238 | 239 | return result 240 | 241 | except Exception as e: 242 | logger.error(f"B站ASR处理失败: {str(e)}") 243 | raise 244 | 245 | def on_finish(self, video_path: str, result: TranscriptResult) -> None: 246 | """转录完成的回调""" 247 | logger.info(f"B站ASR转写完成: {video_path}") 248 | transcription_finished.send({ 249 | "file_path": video_path, 250 | }) -------------------------------------------------------------------------------- /backend/app/transcriber/kuaishou.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import logging 3 | import os 4 | from typing import Union, List, Dict, Optional 5 | 6 | from app.decorators.timeit import timeit 7 | from app.models.transcriber_model import TranscriptSegment, TranscriptResult 8 | from app.transcriber.base import Transcriber 9 | from app.utils.logger import get_logger 10 | from events import transcription_finished 11 | 12 | logger = get_logger(__name__) 13 | 14 | class KuaishouTranscriber(Transcriber): 15 | """快手语音识别实现""" 16 | 17 | API_URL = "https://ai.kuaishou.com/api/effects/subtitle_generate" 18 | 19 | def __init__(self): 20 | pass 21 | 22 | def _load_file(self, file_path: str) -> bytes: 23 | """读取文件内容""" 24 | with open(file_path, 'rb') as f: 25 | return f.read() 26 | 27 | def _submit(self, file_path: str) -> dict: 28 | """提交识别请求""" 29 | try: 30 | file_binary = self._load_file(file_path) 31 | 32 | payload = { 33 | "typeId": "1" 34 | } 35 | 36 | # 使用文件名作为上传文件名 37 | file_name = os.path.basename(file_path) 38 | files = [('file', (file_name, file_binary, 'audio/mpeg'))] 39 | 40 | logger.info(f"开始向快手API提交请求,文件: {file_name}") 41 | response = requests.post(self.API_URL, data=payload, files=files, timeout=300) 42 | response.raise_for_status() # 检查HTTP错误 43 | 44 | result = response.json() 45 | 46 | # 检查快手API返回是否包含错误 47 | if "data" not in result or result.get("code", 0) != 0: 48 | error_msg = f"快手API返回错误: {result.get('message', '未知错误')}" 49 | logger.error(error_msg) 50 | raise Exception(error_msg) 51 | 52 | return result 53 | 54 | except requests.exceptions.RequestException as e: 55 | error_msg = f"快手ASR请求网络错误: {str(e)}" 56 | logger.error(error_msg) 57 | raise 58 | except Exception as e: 59 | error_msg = f"快手ASR请求处理错误: {str(e)}" 60 | logger.error(error_msg) 61 | raise 62 | 63 | @timeit 64 | def transcript(self, file_path: str) -> TranscriptResult: 65 | """执行转录过程,符合 Transcriber 接口""" 66 | try: 67 | logger.info(f"开始处理文件: {file_path}") 68 | 69 | # 提交请求并获取结果 70 | logger.info("向快手API提交识别请求...") 71 | result_data = self._submit(file_path) 72 | 73 | logger.info("请求成功,处理结果...") 74 | 75 | # 提取分段数据 76 | segments = [] 77 | full_text = "" 78 | 79 | # 解析快手API返回的文本段 80 | texts = result_data.get('data', {}).get('text', []) 81 | for u in texts: 82 | text = u.get('text', '').strip() 83 | start_time = float(u.get('start_time', 0)) 84 | end_time = float(u.get('end_time', 0)) 85 | 86 | full_text += text + " " 87 | segments.append(TranscriptSegment( 88 | start=start_time, 89 | end=end_time, 90 | text=text 91 | )) 92 | 93 | # 创建结果对象 94 | result = TranscriptResult( 95 | language="zh", # 快手API可能不返回语言信息,默认为中文 96 | full_text=full_text.strip(), 97 | segments=segments, 98 | raw=result_data 99 | ) 100 | 101 | # 触发完成事件 102 | self.on_finish(file_path, result) 103 | 104 | return result 105 | 106 | except Exception as e: 107 | logger.error(f"快手ASR处理失败: {str(e)}") 108 | raise 109 | 110 | def on_finish(self, video_path: str, result: TranscriptResult) -> None: 111 | """转录完成的回调""" 112 | logger.info(f"快手ASR转写完成: {video_path}") 113 | transcription_finished.send({ 114 | "file_path": video_path, 115 | }) -------------------------------------------------------------------------------- /backend/app/transcriber/mlx_whisper_transcriber.py: -------------------------------------------------------------------------------- 1 | import mlx_whisper 2 | from pathlib import Path 3 | import os 4 | import platform 5 | from huggingface_hub import snapshot_download 6 | 7 | from app.decorators.timeit import timeit 8 | from app.models.transcriber_model import TranscriptSegment, TranscriptResult 9 | from app.transcriber.base import Transcriber 10 | from app.utils.logger import get_logger 11 | from app.utils.path_helper import get_model_dir 12 | from events import transcription_finished 13 | 14 | logger = get_logger(__name__) 15 | 16 | class MLXWhisperTranscriber(Transcriber): 17 | def __init__( 18 | self, 19 | model_size: str = "base" 20 | ): 21 | # 检查平台 22 | if platform.system() != "Darwin": 23 | raise RuntimeError("MLX Whisper 仅支持 Apple 平台") 24 | 25 | # 检查环境变量 26 | if os.environ.get("TRANSCRIBER_TYPE") != "mlx-whisper": 27 | raise RuntimeError("必须设置环境变量 TRANSCRIBER_TYPE=mlx-whisper 才能使用 MLX Whisper") 28 | 29 | self.model_size = model_size 30 | self.model_name = f"mlx-community/whisper-{model_size}" 31 | self.model_path = None 32 | 33 | # 设置模型路径 34 | model_dir = get_model_dir("mlx-whisper") 35 | self.model_path = os.path.join(model_dir, self.model_name) 36 | # 检查并下载模型 37 | if not Path(self.model_path).exists(): 38 | logger.info(f"模型 {self.model_name} 不存在,开始下载...") 39 | snapshot_download( 40 | self.model_name, 41 | local_dir=self.model_path, 42 | local_dir_use_symlinks=False, 43 | ) 44 | logger.info("模型下载完成") 45 | 46 | logger.info(f"初始化 MLX Whisper 转录器,模型:{self.model_name}") 47 | 48 | @timeit 49 | def transcript(self, file_path: str) -> TranscriptResult: 50 | try: 51 | # 使用 MLX Whisper 进行转录 52 | result = mlx_whisper.transcribe( 53 | file_path, 54 | path_or_hf_repo=f"{self.model_name}" 55 | ) 56 | 57 | # 转换为标准格式 58 | segments = [] 59 | full_text = "" 60 | 61 | for segment in result["segments"]: 62 | text = segment["text"].strip() 63 | full_text += text + " " 64 | segments.append(TranscriptSegment( 65 | start=segment["start"], 66 | end=segment["end"], 67 | text=text 68 | )) 69 | 70 | transcript_result = TranscriptResult( 71 | language=result.get("language", "unknown"), 72 | full_text=full_text.strip(), 73 | segments=segments, 74 | raw=result 75 | ) 76 | 77 | self.on_finish(file_path, transcript_result) 78 | return transcript_result 79 | 80 | except Exception as e: 81 | logger.error(f"MLX Whisper 转写失败:{e}") 82 | raise e 83 | 84 | def on_finish(self, video_path: str, result: TranscriptResult) -> None: 85 | logger.info("MLX Whisper 转写完成") 86 | transcription_finished.send({ 87 | "file_path": video_path, 88 | }) -------------------------------------------------------------------------------- /backend/app/transcriber/transcriber_provider.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | 4 | from app.transcriber.whisper import WhisperTranscriber 5 | from app.transcriber.bcut import BcutTranscriber 6 | from app.transcriber.kuaishou import KuaishouTranscriber 7 | from app.utils.logger import get_logger 8 | logger = get_logger(__name__) 9 | 10 | # 只在Apple平台且设置了环境变量时才导入MLX Whisper 11 | if platform.system() == "Darwin" and os.environ.get("TRANSCRIBER_TYPE") == "mlx-whisper": 12 | try: 13 | from app.transcriber.mlx_whisper_transcriber import MLXWhisperTranscriber 14 | MLX_WHISPER_AVAILABLE = True 15 | logger.info("MLX Whisper 可用,已导入") 16 | except ImportError: 17 | MLX_WHISPER_AVAILABLE = False 18 | logger.warning("MLX Whisper 导入失败,可能未安装或平台不支持") 19 | else: 20 | MLX_WHISPER_AVAILABLE = False 21 | 22 | logger.info('初始化转录服务提供器') 23 | 24 | # 维护各种转录器的单例实例 25 | _transcribers = { 26 | 'whisper': None, 27 | 'bcut': None, 28 | 'kuaishou': None, 29 | 'mlx-whisper': None 30 | } 31 | 32 | def get_whisper_transcriber(model_size="base", device="cuda"): 33 | """获取 Whisper 转录器实例""" 34 | if _transcribers['whisper'] is None: 35 | logger.info(f'创建 Whisper 转录器实例,参数:{model_size}, {device}') 36 | try: 37 | _transcribers['whisper'] = WhisperTranscriber(model_size=model_size, device=device) 38 | logger.info('Whisper 转录器创建成功') 39 | except Exception as e: 40 | logger.error(f"Whisper 转录器创建失败: {e}") 41 | raise 42 | return _transcribers['whisper'] 43 | 44 | def get_bcut_transcriber(): 45 | """获取 Bcut 转录器实例""" 46 | if _transcribers['bcut'] is None: 47 | logger.info('创建 Bcut 转录器实例') 48 | try: 49 | _transcribers['bcut'] = BcutTranscriber() 50 | logger.info('Bcut 转录器创建成功') 51 | except Exception as e: 52 | logger.error(f"Bcut 转录器创建失败: {e}") 53 | raise 54 | return _transcribers['bcut'] 55 | 56 | def get_kuaishou_transcriber(): 57 | """获取快手转录器实例""" 58 | if _transcribers['kuaishou'] is None: 59 | logger.info('创建快手转录器实例') 60 | try: 61 | _transcribers['kuaishou'] = KuaishouTranscriber() 62 | logger.info('快手转录器创建成功') 63 | except Exception as e: 64 | logger.error(f"快手转录器创建失败: {e}") 65 | raise 66 | return _transcribers['kuaishou'] 67 | 68 | def get_mlx_whisper_transcriber(model_size="base"): 69 | """获取 MLX Whisper 转录器实例""" 70 | if not MLX_WHISPER_AVAILABLE: 71 | logger.warning("MLX Whisper 不可用,请确保在Apple平台且已安装mlx_whisper") 72 | raise ImportError("MLX Whisper 不可用,请确保在Apple平台且已安装mlx_whisper") 73 | 74 | if _transcribers['mlx-whisper'] is None: 75 | logger.info(f'创建 MLX Whisper 转录器实例,参数:{model_size}') 76 | try: 77 | _transcribers['mlx-whisper'] = MLXWhisperTranscriber(model_size=model_size) 78 | logger.info('MLX Whisper 转录器创建成功') 79 | except Exception as e: 80 | logger.error(f"MLX Whisper 转录器创建失败: {e}") 81 | raise 82 | return _transcribers['mlx-whisper'] 83 | 84 | def get_transcriber(transcriber_type="fast-whisper", model_size="base", device="cuda"): 85 | """ 86 | 获取指定类型的转录器实例 87 | 88 | 参数: 89 | transcriber_type: 转录器类型,支持 "fast-whisper", "bcut", "kuaishou", "mlx-whisper"(仅Apple平台) 90 | model_size: 模型大小,whisper 和 mlx-whisper 特有参数 91 | device: 设备类型,whisper 特有参数 92 | 93 | 返回: 94 | 对应类型的转录器实例 95 | """ 96 | logger.info(f'获取转录器,类型: {transcriber_type}') 97 | if transcriber_type == "fast-whisper": 98 | whisper_model_size = os.environ.get("WHISPER_MODEL_SIZE",model_size) 99 | return get_whisper_transcriber(whisper_model_size, device=device) 100 | elif transcriber_type == "mlx-whisper": 101 | whisper_model_size = os.environ.get("WHISPER_MODEL_SIZE",model_size) 102 | if not MLX_WHISPER_AVAILABLE: 103 | logger.warning("MLX Whisper 不可用,回退到 fast-whisper") 104 | return get_whisper_transcriber(whisper_model_size, device=device) 105 | return get_mlx_whisper_transcriber(whisper_model_size) 106 | elif transcriber_type == "bcut": 107 | return get_bcut_transcriber() 108 | elif transcriber_type == "kuaishou": 109 | return get_kuaishou_transcriber() 110 | else: 111 | logger.warning(f'未知转录器类型 "{transcriber_type}",使用默认 whisper') 112 | whisper_model_size = os.environ.get("WHISPER_MODEL_SIZE",model_size) 113 | return get_whisper_transcriber(whisper_model_size, device) -------------------------------------------------------------------------------- /backend/app/transcriber/whisper.py: -------------------------------------------------------------------------------- 1 | from faster_whisper import WhisperModel 2 | 3 | from app.decorators.timeit import timeit 4 | from app.models.transcriber_model import TranscriptSegment, TranscriptResult 5 | from app.transcriber.base import Transcriber 6 | from app.utils.env_checker import is_cuda_available, is_torch_installed 7 | from app.utils.logger import get_logger 8 | from app.utils.path_helper import get_model_dir 9 | 10 | from events import transcription_finished 11 | from pathlib import Path 12 | import os 13 | from tqdm import tqdm 14 | from huggingface_hub import snapshot_download 15 | 16 | ''' 17 | Size of the model to use (tiny, tiny.en, base, base.en, small, small.en, distil-small.en, medium, medium.en, distil-medium.en, large-v1, large-v2, large-v3, large, distil-large-v2, distil-large-v3, large-v3-turbo, or turbo 18 | ''' 19 | logger=get_logger(__name__) 20 | 21 | class WhisperTranscriber(Transcriber): 22 | # TODO:修改为可配置 23 | def __init__( 24 | self, 25 | model_size: str = "base", 26 | device: str = 'cpu', 27 | compute_type: str = None, 28 | cpu_threads: int = 1, 29 | ): 30 | if device == 'cpu' or device is None: 31 | self.device = 'cpu' 32 | else: 33 | self.device = "cuda" if self.is_cuda() else "cpu" 34 | if device == 'cuda' and self.device == 'cpu': 35 | print('没有 cuda 使用 cpu进行计算') 36 | 37 | self.compute_type = compute_type or ("float16" if self.device == "cuda" else "int8") 38 | 39 | model_dir = get_model_dir("whisper") 40 | model_path = os.path.join(model_dir, f"whisper-{model_size}") 41 | if not Path(model_path).exists(): 42 | logger.info(f"模型 whisper-{model_size} 不存在,开始下载...") 43 | repo_id = f"guillaumekln/faster-whisper-{model_size}" 44 | snapshot_download( 45 | repo_id, 46 | local_dir=model_path, 47 | local_dir_use_symlinks=False, 48 | ) 49 | logger.info("模型下载完成") 50 | 51 | self.model = WhisperModel( 52 | model_size, 53 | device=self.device, 54 | compute_type=self.compute_type, 55 | cpu_threads=cpu_threads, 56 | download_root=model_dir 57 | ) 58 | @staticmethod 59 | def is_torch_installed() -> bool: 60 | try: 61 | import torch 62 | return True 63 | except ImportError: 64 | return False 65 | 66 | @staticmethod 67 | def is_cuda() -> bool: 68 | try: 69 | if is_cuda_available(): 70 | print("✅ CUDA 可用,使用 GPU") 71 | return True 72 | elif is_torch_installed(): 73 | print("⚠️ 只装了 torch,但没有 CUDA,用 CPU") 74 | return False 75 | else: 76 | print("❌ 还没有安装 torch,请先安装") 77 | return False 78 | 79 | except ImportError: 80 | return False 81 | 82 | @timeit 83 | def transcript(self, file_path: str) -> TranscriptResult: 84 | try: 85 | 86 | segments_raw, info = self.model.transcribe(file_path) 87 | 88 | segments = [] 89 | full_text = "" 90 | 91 | for seg in segments_raw: 92 | text = seg.text.strip() 93 | full_text += text + " " 94 | segments.append(TranscriptSegment( 95 | start=seg.start, 96 | end=seg.end, 97 | text=text 98 | )) 99 | 100 | result= TranscriptResult( 101 | language=info.language, 102 | full_text=full_text.strip(), 103 | segments=segments, 104 | raw=info 105 | ) 106 | self.on_finish(file_path, result) 107 | return result 108 | except Exception as e: 109 | print(f"转写失败:{e}") 110 | 111 | 112 | def on_finish(self,video_path:str,result: TranscriptResult)->None: 113 | print("转写完成") 114 | transcription_finished.send({ 115 | "file_path": video_path, 116 | }) 117 | 118 | -------------------------------------------------------------------------------- /backend/app/utils/env_checker.py: -------------------------------------------------------------------------------- 1 | def is_cuda_available() -> bool: 2 | try: 3 | import torch 4 | return torch.cuda.is_available() 5 | except ImportError: 6 | return False 7 | def is_torch_installed() -> bool: 8 | try: 9 | import torch 10 | return True 11 | except ImportError: 12 | return False 13 | -------------------------------------------------------------------------------- /backend/app/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from pathlib import Path 4 | 5 | # 日志目录 6 | LOG_DIR = Path("logs") 7 | LOG_DIR.mkdir(exist_ok=True) 8 | 9 | # 日志格式 10 | formatter = logging.Formatter( 11 | fmt="%(asctime)s [%(levelname)s] %(name)s - %(message)s", 12 | datefmt="%Y-%m-%d %H:%M:%S" 13 | ) 14 | 15 | # 控制台输出 16 | console_handler = logging.StreamHandler(sys.stdout) 17 | console_handler.setFormatter(formatter) 18 | 19 | # 文件输出 20 | file_handler = logging.FileHandler(LOG_DIR / "app.log", encoding="utf-8") 21 | file_handler.setFormatter(formatter) 22 | 23 | # 获取日志器 24 | 25 | def get_logger(name: str) -> logging.Logger: 26 | logger = logging.getLogger(name) 27 | if not logger.handlers: 28 | logger.setLevel(logging.INFO) 29 | logger.addHandler(console_handler) 30 | logger.addHandler(file_handler) 31 | logger.propagate = False 32 | return logger 33 | -------------------------------------------------------------------------------- /backend/app/utils/note_helper.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | import re 5 | 6 | import re 7 | 8 | def replace_content_markers(markdown: str, video_id: str, platform: str = 'bilibili') -> str: 9 | """ 10 | 替换 *Content-04:16*、Content-04:16 或 Content-[04:16] 为超链接,跳转到对应平台视频的时间位置 11 | """ 12 | # 匹配三种形式:*Content-04:16*、Content-04:16、Content-[04:16] 13 | pattern = r"(?:\*?)Content-(?:\[(\d{2}):(\d{2})\]|(\d{2}):(\d{2}))" 14 | 15 | def replacer(match): 16 | mm = match.group(1) or match.group(3) 17 | ss = match.group(2) or match.group(4) 18 | total_seconds = int(mm) * 60 + int(ss) 19 | 20 | if platform == 'bilibili': 21 | url = f"https://www.bilibili.com/video/{video_id}?t={total_seconds}" 22 | elif platform == 'youtube': 23 | url = f"https://www.youtube.com/watch?v={video_id}&t={total_seconds}s" 24 | elif platform == 'douyin': 25 | url = f"https://www.douyin.com/video/{video_id}" 26 | return f"[原片 @ {mm}:{ss}]({url})" 27 | else: 28 | return f"({mm}:{ss})" 29 | 30 | return f"[原片 @ {mm}:{ss}]({url})" 31 | 32 | return re.sub(pattern, replacer, markdown) 33 | -------------------------------------------------------------------------------- /backend/app/utils/path_helper.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")) 4 | 5 | def get_data_dir(): 6 | data_path = os.path.join(PROJECT_ROOT, "data") 7 | os.makedirs(data_path, exist_ok=True) 8 | return data_path 9 | 10 | def get_model_dir(subdir: str = "whisper") -> str: 11 | base = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../models")) 12 | path = os.path.join(base, subdir) 13 | os.makedirs(path, exist_ok=True) 14 | return path 15 | 16 | 17 | if __name__ == '__main__': 18 | print(get_data_dir()) -------------------------------------------------------------------------------- /backend/app/utils/response.py: -------------------------------------------------------------------------------- 1 | from app.utils.status_code import StatusCode 2 | 3 | class ResponseWrapper: 4 | @staticmethod 5 | def success(data=None, msg="success", code=StatusCode.SUCCESS): 6 | return { 7 | "code": int(code), 8 | "msg": msg, 9 | "data": data 10 | } 11 | 12 | @staticmethod 13 | def error(msg="error", code=StatusCode.FAIL, data=None): 14 | return { 15 | "code": int(code), 16 | "msg": msg, 17 | "data": data 18 | } -------------------------------------------------------------------------------- /backend/app/utils/status_code.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | class StatusCode(IntEnum): 4 | SUCCESS = 0 5 | FAIL = 1 6 | 7 | DOWNLOAD_ERROR = 1001 8 | TRANSCRIBE_ERROR = 1002 9 | GENERATE_ERROR = 1003 10 | 11 | INVALID_URL = 2001 12 | PARAM_ERROR = 2002 -------------------------------------------------------------------------------- /backend/app/utils/url_parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | 5 | def extract_video_id(url: str, platform: str) -> Optional[str]: 6 | """ 7 | 从视频链接中提取视频 ID 8 | 9 | :param url: 视频链接 10 | :param platform: 平台名(bilibili / youtube / douyin) 11 | :return: 提取到的视频 ID 或 None 12 | """ 13 | if platform == "bilibili": 14 | # 匹配 BV号(如 BV1vc411b7Wa) 15 | match = re.search(r"BV([0-9A-Za-z]+)", url) 16 | return f"BV{match.group(1)}" if match else None 17 | 18 | elif platform == "youtube": 19 | # 匹配 v=xxxxx 或 youtu.be/xxxxx,ID 长度通常为 11 20 | match = re.search(r"(?:v=|youtu\.be/)([0-9A-Za-z_-]{11})", url) 21 | return match.group(1) if match else None 22 | 23 | elif platform == "douyin": 24 | # 匹配 douyin.com/video/1234567890123456789 25 | match = re.search(r"/video/(\d+)", url) 26 | return match.group(1) if match else None 27 | 28 | return None 29 | -------------------------------------------------------------------------------- /backend/app/utils/video_helper.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | import uuid 4 | 5 | 6 | def generate_screenshot(video_path: str, output_dir: str, timestamp: int, index: int) -> str: 7 | """ 8 | 使用 ffmpeg 生成截图,返回生成图片路径 9 | """ 10 | os.makedirs(output_dir, exist_ok=True) 11 | ids=str(uuid.uuid4()) 12 | output_path = os.path.join(output_dir, f"screenshot_{str(index)+ids}.jpg") 13 | 14 | command = [ 15 | "ffmpeg", 16 | "-ss", str(timestamp), 17 | "-i", video_path, 18 | "-frames:v", "1", 19 | "-q:v", "2", # 图像质量 20 | output_path, 21 | "-y" # 覆盖 22 | ] 23 | 24 | subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 25 | return output_path 26 | 27 | -------------------------------------------------------------------------------- /backend/app/validators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/app/validators/__init__.py -------------------------------------------------------------------------------- /backend/app/validators/video_url_validator.py: -------------------------------------------------------------------------------- 1 | from pydantic import AnyUrl, validator, BaseModel 2 | import re 3 | 4 | SUPPORTED_PLATFORMS = { 5 | "bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+", 6 | "youtube": r"(https?://)?(www\.)?(youtube\.com/watch\?v=|youtu\.be/)[\w\-]+", 7 | "douyin": r"(https?://)?(www\.)?douyin\.com/video/\d+", 8 | } 9 | 10 | 11 | 12 | def is_supported_video_url(url: str) -> bool: 13 | return any(re.match(pattern, url) for pattern in SUPPORTED_PLATFORMS.values()) 14 | 15 | 16 | class VideoRequest(BaseModel): 17 | url: AnyUrl 18 | platform: str 19 | 20 | @validator("url") 21 | def validate_video_url(cls, v): 22 | if not is_supported_video_url(str(v)): 23 | raise ValueError("暂不支持该视频平台或链接格式无效") 24 | return v 25 | -------------------------------------------------------------------------------- /backend/events/__init__.py: -------------------------------------------------------------------------------- 1 | # 注册监听器 2 | from app.utils.logger import get_logger 3 | from events.handlers import cleanup_temp_files 4 | from events.signals import transcription_finished 5 | 6 | logger = get_logger(__name__) 7 | 8 | def register_handler(): 9 | try: 10 | transcription_finished.connect(cleanup_temp_files) 11 | logger.info("注册监听器成功") 12 | except Exception as e: 13 | logger.error(f"注册监听器失败:{e}") 14 | 15 | -------------------------------------------------------------------------------- /backend/events/handlers.py: -------------------------------------------------------------------------------- 1 | import os 2 | from app.utils.logger import get_logger 3 | logger = get_logger(__name__) 4 | 5 | def cleanup_temp_files(data): 6 | logger.info(f"starting cleanup temp files :{data['file_path']}") 7 | file_path = data['file_path'] 8 | if not os.path.exists(file_path): 9 | logger.warning(f"路径不存在:{file_path}") 10 | return 11 | 12 | dir_path = os.path.dirname(file_path) 13 | base_name = os.path.basename(file_path) 14 | video_id, _ = os.path.splitext(base_name) 15 | 16 | logger.info(f"开始清理 video_id={video_id} 所有相关文件") 17 | 18 | for file in os.listdir(dir_path): 19 | if file.startswith(video_id): 20 | full_path = os.path.join(dir_path, file) 21 | try: 22 | os.remove(full_path) 23 | logger.info(f"删除文件:{full_path}") 24 | except Exception as e: 25 | logger.error(f"删除失败:{full_path},原因:{e}") 26 | -------------------------------------------------------------------------------- /backend/events/signals.py: -------------------------------------------------------------------------------- 1 | from blinker import signal 2 | transcription_finished = signal("transcription_finished") -------------------------------------------------------------------------------- /backend/ffmpeg_helper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from dotenv import load_dotenv 4 | 5 | from app.utils.logger import get_logger 6 | logger = get_logger(__name__) 7 | 8 | load_dotenv() 9 | def check_ffmpeg_exists() -> bool: 10 | """ 11 | 检查 ffmpeg 是否可用。优先使用 FFMPEG_BIN_PATH 环境变量指定的路径。 12 | """ 13 | ffmpeg_bin_path = os.getenv("FFMPEG_BIN_PATH") 14 | logger.info(f"FFMPEG_BIN_PATH: {ffmpeg_bin_path}") 15 | if ffmpeg_bin_path and os.path.isdir(ffmpeg_bin_path): 16 | os.environ["PATH"] = ffmpeg_bin_path + os.pathsep + os.environ.get("PATH", "") 17 | logger.info(f"ffmpeg 未配置路径,尝试使用系统路径PATH: {os.environ.get('PATH')}") 18 | try: 19 | subprocess.run(["ffmpeg", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) 20 | logger.info("ffmpeg 已安装") 21 | return True 22 | except (FileNotFoundError, OSError, subprocess.CalledProcessError): 23 | logger.info("ffmpeg 未安装") 24 | return False 25 | 26 | 27 | def ensure_ffmpeg_or_raise(): 28 | """ 29 | 校验 ffmpeg 是否可用,否则抛出异常并提示安装方式。 30 | """ 31 | if not check_ffmpeg_exists(): 32 | logger.error("未检测到 ffmpeg,请先安装后再使用本功能。") 33 | raise EnvironmentError( 34 | "❌ 未检测到 ffmpeg,请先安装后再使用本功能。\n" 35 | "👉 下载地址:https://ffmpeg.org/download.html\n" 36 | "🪟 Windows 推荐:https://www.gyan.dev/ffmpeg/builds/\n" 37 | "💡 如果你已安装,请将其路径写入 `.env` 文件,例如:\n" 38 | "FFMPEG_BIN_PATH=/your/custom/ffmpeg/bin" 39 | ) -------------------------------------------------------------------------------- /backend/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import uvicorn 4 | from starlette.staticfiles import StaticFiles 5 | from dotenv import load_dotenv 6 | from app.utils.logger import get_logger 7 | from app import create_app 8 | from app.db.video_task_dao import init_video_task_table 9 | from app.transcriber.transcriber_provider import get_transcriber 10 | from events import register_handler 11 | from ffmpeg_helper import ensure_ffmpeg_or_raise 12 | 13 | logger = get_logger(__name__) 14 | load_dotenv() 15 | 16 | # 读取 .env 中的路径 17 | static_path = os.getenv('STATIC', '/static') 18 | out_dir = os.getenv('OUT_DIR', './static/screenshots') 19 | 20 | # 自动创建本地目录(static 和 static/screenshots) 21 | static_dir = "static" 22 | if not os.path.exists(static_dir): 23 | os.makedirs(static_dir) 24 | 25 | if not os.path.exists(out_dir): 26 | os.makedirs(out_dir) 27 | 28 | app = create_app() 29 | app.mount(static_path, StaticFiles(directory=static_dir), name="static") 30 | 31 | async def startup_event(): 32 | register_handler() 33 | @app.on_event("startup") 34 | async def startup_event(): 35 | register_handler() 36 | ensure_ffmpeg_or_raise() 37 | get_transcriber(transcriber_type=os.getenv("TRANSCRIBER_TYPE","fast-whisper")) 38 | init_video_task_table() 39 | 40 | if __name__ == "__main__": 41 | port = int(os.getenv("BACKEND_PORT", 8000)) 42 | host = os.getenv("BACKEND_HOST", "0.0.0.0") 43 | logger.info(f"Starting server on {host}:{port}") 44 | uvicorn.run("main:app", host=host, port=port, reload=True) -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/backend/requirements.txt -------------------------------------------------------------------------------- /doc/BiliNote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/doc/BiliNote.png -------------------------------------------------------------------------------- /doc/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /doc/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/doc/image1.png -------------------------------------------------------------------------------- /doc/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/doc/image2.png -------------------------------------------------------------------------------- /doc/image3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/doc/image3.png -------------------------------------------------------------------------------- /doc/wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JefferyHcool/BiliNote/76ce0f58ef81850cca38e93220f4290873937d05/doc/wechat.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | backend: 5 | container_name: bilinote-backend 6 | build: 7 | context: . 8 | dockerfile: backend/Dockerfile 9 | env_file: 10 | - .env 11 | environment: 12 | - BACKEND_PORT=${BACKEND_PORT} 13 | - BACKEND_HOST=${BACKEND_HOST} 14 | volumes: 15 | - ./backend:/app 16 | ports: 17 | - "${BACKEND_PORT}:${BACKEND_PORT}" 18 | 19 | depends_on: 20 | - frontend 21 | 22 | frontend: 23 | container_name: bilinote-frontend 24 | build: 25 | context: . 26 | dockerfile: BillNote_frontend/Dockerfile 27 | env_file: 28 | - .env 29 | ports: 30 | - "${FRONTEND_PORT}:80" 31 | 32 | 33 | --------------------------------------------------------------------------------