├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── Close_Stale_Issues_and_PRs.yaml │ ├── Manually_build_executable_programs.yml │ ├── Manually_docker_image.yml │ ├── Release_build_executable_program.yml │ └── Release_docker_image.yml ├── .gitignore ├── .python-version ├── Dockerfile ├── README.md ├── README_EN.md ├── docs ├── Cookie获取教程.md ├── DouK-Downloader文档.md ├── QQ群聊二维码.png ├── Release_Notes.md ├── screenshot │ ├── Cookie获取教程1.png │ ├── Cookie获取教程2.png │ ├── WebAPI模式截图CN1.png │ ├── WebAPI模式截图CN2.png │ ├── WebAPI模式截图EN1.png │ ├── WebAPI模式截图EN2.png │ ├── device_id获取示例图.png │ ├── 终端交互模式截图CN1.png │ ├── 终端交互模式截图CN2.png │ ├── 终端交互模式截图CN3.png │ ├── 终端交互模式截图EN1.png │ ├── 终端交互模式截图EN2.png │ └── 终端交互模式截图EN3.png ├── 微信赞助二维码.png ├── 支付宝赞助二维码.png └── 赞助商_TikHub_Logo.png ├── license ├── locale ├── README.md ├── en_US │ └── LC_MESSAGES │ │ ├── tk.mo │ │ └── tk.po ├── generate_path.py ├── po_to_mo.py ├── tk.pot └── zh_CN │ └── LC_MESSAGES │ ├── tk.mo │ └── tk.po ├── main.py ├── pyproject.toml ├── requirements.txt ├── src ├── application │ ├── TikTokDownloader.py │ ├── __init__.py │ ├── main_monitor.py │ ├── main_server.py │ └── main_terminal.py ├── cli_edition │ ├── __init__.py │ ├── main_cli.py │ └── write.py ├── config │ ├── __init__.py │ ├── parameter.py │ └── settings.py ├── custom │ ├── __init__.py │ ├── function.py │ ├── internal.py │ └── static.py ├── downloader │ ├── __init__.py │ └── download.py ├── encrypt │ ├── __init__.py │ ├── aBogus.py │ ├── device_id.py │ ├── msToken.py │ ├── ttWid.py │ ├── verifyFp.py │ ├── webID.py │ └── xBogus.py ├── extract │ ├── __init__.py │ └── extractor.py ├── gui_edition │ └── __init__.py ├── interface │ ├── __init__.py │ ├── account.py │ ├── account_tiktok.py │ ├── collection.py │ ├── collects.py │ ├── comment.py │ ├── comment_tiktok.py │ ├── detail.py │ ├── detail_tiktok.py │ ├── hashtag.py │ ├── hot.py │ ├── info.py │ ├── info_tiktok.py │ ├── live.py │ ├── live_tiktok.py │ ├── mix.py │ ├── mix_tiktok.py │ ├── search.py │ ├── slides.py │ ├── template.py │ └── user.py ├── link │ ├── __init__.py │ ├── extractor.py │ └── requester.py ├── manager │ ├── __init__.py │ ├── cache.py │ ├── database.py │ └── recorder.py ├── models │ ├── __init__.py │ ├── account.py │ ├── base.py │ ├── comment.py │ ├── detail.py │ ├── live.py │ ├── mix.py │ ├── reply.py │ ├── response.py │ ├── search.py │ ├── settings.py │ └── share.py ├── module │ ├── __init__.py │ ├── cookie.py │ ├── ffmpeg.py │ ├── register.py │ ├── tiktok_account_index.py │ └── tiktok_unofficial.py ├── record │ ├── __init__.py │ ├── base.py │ └── logger.py ├── storage │ ├── __init__.py │ ├── csv.py │ ├── manager.py │ ├── mysql.py │ ├── sql.py │ ├── sqlite.py │ ├── text.py │ └── xlsx.py ├── testers │ ├── __init__.py │ ├── logger.py │ ├── params.py │ ├── test_format.py │ └── translate.py ├── tools │ ├── __init__.py │ ├── browser.py │ ├── capture.py │ ├── choose.py │ ├── cleaner.py │ ├── console.py │ ├── error.py │ ├── file_folder.py │ ├── format.py │ ├── list_pop.py │ ├── rename_compatible.py │ ├── retry.py │ ├── session.py │ ├── temporary.py │ ├── timer.py │ └── truncate.py ├── translation │ ├── __init__.py │ ├── static.py │ └── translate.py └── tui_edition │ ├── __init__.py │ ├── app.py │ └── setting.py ├── static ├── images │ ├── DouK-Downloader.icns │ ├── DouK-Downloader.ico │ ├── DouK-Downloader.jpg │ ├── DouK-Downloader.png │ └── blank.png └── js │ ├── X-Bogus.js │ └── a_bogus.js └── uv.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: 报告项目问题 4 | title: '[功能异常] ' 5 | labels: '' 6 | assignees: JoeanAmier 7 | 8 | --- 9 | 10 | **问题描述** 11 | 12 | 清晰简洁地描述该错误是什么。 13 | 14 | A clear and concise description of what the bug is. 15 | 16 | **重现步骤** 17 | 18 | 重现该问题的步骤: 19 | 20 | Steps to reproduce the behavior: 21 | 22 | 1. ... 23 | 2. ... 24 | 3. ... 25 | 26 | **预期结果** 27 | 28 | 清晰简洁地描述您预期会发生的情况。 29 | 30 | A clear and concise description of what you expected to happen. 31 | 32 | **补充信息** 33 | 34 | 在此添加有关该问题的任何其他上下文信息,例如:操作系统、运行方式、配置文件、错误截图、运行日志等。 35 | 36 | 请注意:提供配置文件时,请删除 Cookie 内容,避免敏感数据泄露! 37 | 38 | Add any other contextual information about the issue here, such as operating system, runtime mode, configuration files, 39 | error screenshots, runtime logs, etc. 40 | 41 | Please note: When providing configuration files, please delete cookie content to avoid sensitive data leakage! 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: 功能优化建议 4 | title: '[优化建议] ' 5 | labels: '' 6 | assignees: JoeanAmier 7 | 8 | --- 9 | 10 | **功能请求** 11 | 12 | 清晰简洁地描述问题是什么。例如:当 [...] 时,我总是感到沮丧。 13 | 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **描述您希望的解决方案** 17 | 18 | 清晰简洁地描述您希望发生的情况。 19 | 20 | A clear and concise description of what you want to happen. 21 | 22 | **描述您考虑过的替代方案** 23 | 24 | 清晰简洁地描述您考虑过的任何替代解决方案或功能。 25 | 26 | A clear and concise description of any alternative solutions or features you've considered. 27 | 28 | **补充信息** 29 | 30 | 在此添加有关功能请求的任何其他上下文或截图。 31 | 32 | Add any other context or screenshots about the feature request here. 33 | -------------------------------------------------------------------------------- /.github/workflows/Close_Stale_Issues_and_PRs.yaml: -------------------------------------------------------------------------------- 1 | name: "自动管理过时的问题和PR" 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 5" 5 | 6 | permissions: 7 | issues: write 8 | pull-requests: write 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/stale@v9 15 | with: 16 | stale-issue-message: | 17 | ⚠️ 此 Issue 已超过一定时间未活动,如果没有进一步更新,将在 14 天后关闭。 18 | ⚠️ This issue has been inactive for a certain period of time. If there are no further updates, it will be closed in 14 days. 19 | close-issue-message: | 20 | 🔒 由于长时间未响应,此 Issue 已被自动关闭。如有需要,请重新打开或提交新 issue。 21 | 🔒 Due to prolonged inactivity, this issue has been automatically closed. If needed, please reopen it or submit a new issue. 22 | stale-pr-message: | 23 | ⚠️ 此 PR 已超过一定时间未更新,请更新,否则将在 14 天后关闭。 24 | ⚠️ This PR has not been updated for a certain period of time. Please update it, otherwise it will be closed in 14 days. 25 | close-pr-message: | 26 | 🔒 此 PR 已因无更新而自动关闭。如仍需合并,请重新打开或提交新 PR。 27 | 🔒 This PR has been automatically closed due to inactivity. If you still wish to merge it, please reopen it or submit a new PR. 28 | 29 | days-before-issue-stale: 28 30 | days-before-pr-stale: 28 31 | days-before-close: 14 32 | 33 | stale-issue-label: "未跟进问题(Stale)" 34 | close-issue-label: "自动关闭(Close)" 35 | stale-pr-label: "未跟进问题(Stale)" 36 | close-pr-label: "自动关闭(Close)" 37 | exempt-issue-labels: "新功能(feature),功能异常(bug),文档补充(docs),功能优化(enhancement),适合新手(good first issue)," 38 | exempt-pr-labels: "新功能(feature),功能异常(bug),文档补充(docs),功能优化(enhancement),适合新手(good first issue)," 39 | -------------------------------------------------------------------------------- /.github/workflows/Manually_build_executable_programs.yml: -------------------------------------------------------------------------------- 1 | name: 构建可执行文件 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | name: 构建于 ${{ matrix.os }} 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ macos-latest, windows-latest, macos-13 ] 13 | 14 | steps: 15 | - name: 签出存储库 16 | uses: actions/checkout@v4 17 | 18 | - name: 设置 Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.12" 22 | 23 | - name: 安装依赖项 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements.txt 27 | pip install pyinstaller 28 | 29 | - name: 构建 Win 可执行文件 30 | if: runner.os == 'Windows' 31 | run: | 32 | echo "DATE=$(Get-Date -Format 'yyyyMMdd')" >> $env:GITHUB_ENV 33 | pyinstaller --icon=./static/images/DouK-Downloader.ico --add-data "static:static" --add-data "locale:locale" --collect-all emoji main.py 34 | shell: pwsh 35 | 36 | - name: 构建 Mac 可执行文件 37 | if: runner.os == 'macOS' 38 | run: | 39 | echo "DATE=$(date +'%Y%m%d')" >> $GITHUB_ENV 40 | pyinstaller --icon=./static/images/DouK-Downloader.icns --add-data "static:static" --add-data "locale:locale" --collect-all emoji main.py 41 | 42 | - name: 上传文件 43 | uses: actions/upload-artifact@v4 44 | with: 45 | name: DouK-Downloader_${{ runner.os }}_${{ runner.arch }}_${{ env.DATE }} 46 | path: dist/main/ 47 | -------------------------------------------------------------------------------- /.github/workflows/Manually_docker_image.yml: -------------------------------------------------------------------------------- 1 | name: 构建并发布 Docker 镜像 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | is_beta: 7 | type: boolean 8 | required: true 9 | description: "开发版" 10 | default: true 11 | custom_version: 12 | type: string 13 | required: false 14 | description: "版本号" 15 | default: "" 16 | 17 | permissions: 18 | contents: read 19 | packages: write 20 | attestations: write 21 | id-token: write 22 | 23 | env: 24 | REGISTRY: ghcr.io 25 | DOCKER_REPO: ${{ secrets.DOCKERHUB_USERNAME }}/tiktok-downloader 26 | GHCR_REPO: ghcr.io/${{ secrets.DOCKERHUB_USERNAME }}/tiktok-downloader 27 | 28 | jobs: 29 | publish-docker: 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - name: 拉取源码 34 | uses: actions/checkout@v4 35 | with: 36 | fetch-depth: 1 37 | 38 | - name: 获取最新的发布标签 39 | id: get-latest-release 40 | run: | 41 | if [ -z "${{ github.event.inputs.custom_version }}" ]; then 42 | LATEST_TAG=$(curl -s \ 43 | -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ 44 | https://api.github.com/repos/${{ github.repository }}/releases/latest \ 45 | | jq -r '.tag_name') 46 | else 47 | LATEST_TAG=${{ github.event.inputs.custom_version }} 48 | fi 49 | if [ -z "$LATEST_TAG" ]; then 50 | exit 1 51 | fi 52 | echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV 53 | 54 | - name: 设置 QEMU 55 | uses: docker/setup-qemu-action@v3 56 | 57 | - name: 设置 Docker Buildx 58 | uses: docker/setup-buildx-action@v3 59 | 60 | - name: 生成标签 61 | id: generate-tags 62 | run: | 63 | if [ "${{ inputs.is_beta }}" == "true" ]; then 64 | LATEST_TAG="${LATEST_TAG%.*}.$(( ${LATEST_TAG##*.} + 1 ))" 65 | echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV 66 | TAGS="${{ env.DOCKER_REPO }}:${LATEST_TAG}-dev,${{ env.GHCR_REPO }}:${LATEST_TAG}-dev" 67 | else 68 | TAGS="${{ env.DOCKER_REPO }}:${LATEST_TAG},${{ env.DOCKER_REPO }}:latest,${{ env.GHCR_REPO }}:${LATEST_TAG},${{ env.GHCR_REPO }}:latest" 69 | fi 70 | echo "TAGS=$TAGS" >> $GITHUB_ENV 71 | 72 | - name: 登录到 Docker Hub 73 | uses: docker/login-action@v3 74 | with: 75 | username: ${{ secrets.DOCKERHUB_USERNAME }} 76 | password: ${{ secrets.DOCKERHUB_TOKEN }} 77 | 78 | - name: 登录到 GitHub Container Registry 79 | uses: docker/login-action@v3 80 | with: 81 | registry: ${{ env.REGISTRY }} 82 | username: ${{ github.actor }} 83 | password: ${{ secrets.GITHUB_TOKEN }} 84 | 85 | - name: 构建和推送 Docker 镜像到 Docker Hub 和 GHCR 86 | uses: docker/build-push-action@v6 87 | with: 88 | context: . 89 | platforms: linux/amd64,linux/arm64 90 | push: true 91 | tags: ${{ env.TAGS }} 92 | provenance: false 93 | sbom: false 94 | -------------------------------------------------------------------------------- /.github/workflows/Release_build_executable_program.yml: -------------------------------------------------------------------------------- 1 | name: 自动构建并发布可执行文件 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | permissions: 8 | contents: write 9 | discussions: write 10 | 11 | jobs: 12 | build: 13 | name: 构建于 ${{ matrix.os }} 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ macos-latest, windows-latest, macos-13 ] 18 | 19 | steps: 20 | - name: 签出存储库 21 | uses: actions/checkout@v4 22 | 23 | - name: 设置 Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: "3.12" 27 | 28 | - name: 安装依赖项 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r requirements.txt 32 | pip install pyinstaller 33 | 34 | - name: 构建 Win 可执行文件 35 | if: runner.os == 'Windows' 36 | run: | 37 | pyinstaller --icon=./static/images/DouK-Downloader.ico --add-data "static:static" --add-data "locale:locale" --collect-all emoji main.py 38 | shell: pwsh 39 | 40 | - name: 构建 Mac 可执行文件 41 | if: runner.os == 'macOS' 42 | run: | 43 | pyinstaller --icon=./static/images/DouK-Downloader.icns --add-data "static:static" --add-data "locale:locale" --collect-all emoji main.py 44 | 45 | - name: 创建压缩包 46 | run: | 47 | 7z a "DouK-Downloader_V${{ github.event.release.tag_name }}_${{ runner.os }}_${{ runner.arch }}.zip" ./dist/main/* 48 | shell: bash 49 | 50 | - name: 上传文件到 release 51 | uses: softprops/action-gh-release@v2 52 | with: 53 | files: | 54 | ./DouK-Downloader_V*.zip 55 | name: DouK-Downloader V${{ github.event.release.tag_name }} 56 | body_path: ./docs/Release_Notes.md 57 | draft: ${{ github.event.release.draft }} 58 | prerelease: ${{ github.event.release.prerelease }} 59 | -------------------------------------------------------------------------------- /.github/workflows/Release_docker_image.yml: -------------------------------------------------------------------------------- 1 | name: 自动构建并发布 Docker 镜像 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | permissions: 8 | contents: read 9 | packages: write 10 | attestations: write 11 | id-token: write 12 | 13 | env: 14 | REGISTRY: ghcr.io 15 | DOCKER_REPO: ${{ secrets.DOCKERHUB_USERNAME }}/tiktok-downloader 16 | GHCR_REPO: ghcr.io/${{ secrets.DOCKERHUB_USERNAME }}/tiktok-downloader 17 | 18 | jobs: 19 | publish-docker: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: 拉取源码 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 1 27 | 28 | - name: 设置 QEMU 29 | uses: docker/setup-qemu-action@v3 30 | 31 | - name: 设置 Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: 登录到 Docker Hub 35 | uses: docker/login-action@v3 36 | with: 37 | username: ${{ secrets.DOCKERHUB_USERNAME }} 38 | password: ${{ secrets.DOCKERHUB_TOKEN }} 39 | 40 | - name: 登录到 GitHub Container Registry 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ${{ env.REGISTRY }} 44 | username: ${{ github.actor }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: 构建和推送 Docker 镜像到 Docker Hub 和 GHCR 48 | uses: docker/build-push-action@v6 49 | with: 50 | context: . 51 | platforms: linux/amd64,linux/arm64 52 | push: true 53 | tags: | 54 | ${{ env.DOCKER_REPO }}:${{ github.event.release.tag_name }} 55 | ${{ env.DOCKER_REPO }}:latest 56 | ${{ env.GHCR_REPO }}:${{ github.event.release.tag_name }} 57 | ${{ env.GHCR_REPO }}:latest 58 | provenance: false 59 | sbom: false 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | /Download/ 4 | /.venv/ 5 | /.ruff_cache/ 6 | /.idea/ 7 | /Data/ 8 | /Temp/ 9 | /Log/ 10 | /Live/ 11 | *.db 12 | *.json 13 | *.log 14 | *.xlsx 15 | *.csv 16 | *.spec 17 | *.ini 18 | *.txt 19 | !.github/workflows/*.yaml 20 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | LABEL name="DouK-Downloader" authors="JoeanAmier" repository="https://github.com/JoeanAmier/TikTokDownloader" 4 | 5 | WORKDIR /TikTokDownloader 6 | 7 | COPY src /TikTokDownloader/src 8 | COPY locale /TikTokDownloader/locale 9 | COPY static /TikTokDownloader/static 10 | COPY license /TikTokDownloader/license 11 | COPY main.py /TikTokDownloader/main.py 12 | COPY requirements.txt /TikTokDownloader/requirements.txt 13 | 14 | RUN pip install --no-cache-dir -r /TikTokDownloader/requirements.txt 15 | 16 | EXPOSE 5555 17 | 18 | CMD ["python", "main.py"] 19 | -------------------------------------------------------------------------------- /docs/Cookie获取教程.md: -------------------------------------------------------------------------------- 1 | # Cookie 获取教程 2 | 3 | 本教程仅演示部分能够获取所需 `Cookie` 的方法,仍有其他方法能够获取所需 `Cookie`;本教程使用的浏览器为 `Microsoft Edge` 4 | ,部分浏览器的开发人员工具可能不支持中文语言。 5 | 6 | **方法一\(推荐\):** 7 | 8 | 1. 打开浏览器\(可选无痕模式启动\),访问`https://www.douyin.com/` 9 | 2. 登录抖音账号\(可跳过\) 10 | 3. 按 `F12` 打开开发人员工具 11 | 4. 选择 `网络` 选项卡 12 | 5. 勾选 `保留日志` 13 | 6. 在 `筛选器` 输入框输入 `cookie-name:odin_tt` 14 | 7. 点击加载任意一个作品的评论区 15 | 8. 在开发人员工具窗口选择任意一个数据包\(如果无数据包,重复步骤7\) 16 | 9. 全选并复制 `Cookie` 的值 17 | 10. 运行 `main.py` ,根据提示写入 `Cookie` 18 | 19 | **截图示例:** 20 | 21 | 开发人员工具 22 | 23 | **方法二\(不适用本项目\):** 24 | 25 | 1. 打开浏览器\(可选无痕模式启动\),访问`https://www.douyin.com/` 26 | 2. 登录抖音账号\(可跳过\) 27 | 3. 按 `F12` 打开开发人员工具 28 | 4. 选择 `控制台` 选项卡 29 | 5. 输入 `document.cookie` 后回车确认 30 | 6. 检查 `Cookie` 是否包含 `passport_csrf_token` 和 `odin_tt` 字段 31 | 7. 如果未包含所需字段,尝试刷新网页或者点击加载任意一个作品的评论区,回到步骤5 32 | 8. 全选并复制 `Cookie` 的值 33 | 9. 运行 `main.py` ,根据提示写入 `Cookie` 34 | 35 | **截图示例:** 36 | 37 | 开发人员工具 38 | 39 | # device_id 参数 40 | 41 | `device_id` 参数获取方法与 Cookie 类似。 42 | 43 | 开发人员工具 44 | -------------------------------------------------------------------------------- /docs/QQ群聊二维码.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/QQ群聊二维码.png -------------------------------------------------------------------------------- /docs/Release_Notes.md: -------------------------------------------------------------------------------- 1 | **更新内容:** 2 | 3 | 1. 优化 API 模式搜索接口的响应提示 4 | 2. 修复 MacOS 下载直播报错的问题 5 | 3. 搜索数据为空时不再保存至文件 6 | 4. 重构调用 ffmpeg 下载直播功能 7 | 8 |

更新公告:本项目名称由 TikTokDownloader 变更为 DouK-Downloader

9 | -------------------------------------------------------------------------------- /docs/screenshot/Cookie获取教程1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/Cookie获取教程1.png -------------------------------------------------------------------------------- /docs/screenshot/Cookie获取教程2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/Cookie获取教程2.png -------------------------------------------------------------------------------- /docs/screenshot/WebAPI模式截图CN1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/WebAPI模式截图CN1.png -------------------------------------------------------------------------------- /docs/screenshot/WebAPI模式截图CN2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/WebAPI模式截图CN2.png -------------------------------------------------------------------------------- /docs/screenshot/WebAPI模式截图EN1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/WebAPI模式截图EN1.png -------------------------------------------------------------------------------- /docs/screenshot/WebAPI模式截图EN2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/WebAPI模式截图EN2.png -------------------------------------------------------------------------------- /docs/screenshot/device_id获取示例图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/device_id获取示例图.png -------------------------------------------------------------------------------- /docs/screenshot/终端交互模式截图CN1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/终端交互模式截图CN1.png -------------------------------------------------------------------------------- /docs/screenshot/终端交互模式截图CN2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/终端交互模式截图CN2.png -------------------------------------------------------------------------------- /docs/screenshot/终端交互模式截图CN3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/终端交互模式截图CN3.png -------------------------------------------------------------------------------- /docs/screenshot/终端交互模式截图EN1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/终端交互模式截图EN1.png -------------------------------------------------------------------------------- /docs/screenshot/终端交互模式截图EN2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/终端交互模式截图EN2.png -------------------------------------------------------------------------------- /docs/screenshot/终端交互模式截图EN3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/screenshot/终端交互模式截图EN3.png -------------------------------------------------------------------------------- /docs/微信赞助二维码.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/微信赞助二维码.png -------------------------------------------------------------------------------- /docs/支付宝赞助二维码.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/支付宝赞助二维码.png -------------------------------------------------------------------------------- /docs/赞助商_TikHub_Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/docs/赞助商_TikHub_Logo.png -------------------------------------------------------------------------------- /locale/README.md: -------------------------------------------------------------------------------- 1 | # 命令参考 2 | 3 | **运行命令前,确保已经安装了 `gettext` 软件包,并配置好环境变量。** 4 | 5 | **Before running the command, ensure that the `gettext` package is installed and the environment variables are properly 6 | configured.** 7 | 8 | * `xgettext --files-from=py_files.txt -d tk -o tk.pot` 9 | * `mkdir zh_CN\LC_MESSAGES` 10 | * `msginit -l zh_CN -o zh_CN/LC_MESSAGES/tk.po -i tk.pot` 11 | * `mkdir en_US\LC_MESSAGES` 12 | * `msginit -l en_US -o en_US/LC_MESSAGES/tk.po -i tk.pot` 13 | * `msgmerge -U zh_CN/LC_MESSAGES/tk.po tk.pot` 14 | * `msgmerge -U en_US/LC_MESSAGES/tk.po tk.pot` 15 | 16 | # 翻译贡献指南 17 | 18 | * 如果想要贡献支持更多语言,请在终端切换至 `locale` 文件夹,运行命令 `msginit -l 语言代码 -o 语言代码/LC_MESSAGES/tk.po -i tk.pot` 19 | 生成 po 文件并编辑翻译。 20 | * 如果想要贡献改进翻译结果,请直接编辑 `tk.po` 文件内容。 21 | * 仅需提交 `tk.po` 文件,作者会转换格式并合并。 22 | 23 | # Translation Contribution Guide 24 | 25 | * If you want to contribute support for more languages, please switch to the `locale` folder in the terminal and run the 26 | command `msginit -l language_code -o language_code/LC_MESSAGES/tk.po -i tk.pot` to generate the po file and edit the 27 | translation. 28 | * If you want to contribute to improving the translation, please directly edit the content of the `tk.po` file. 29 | * Only the `tk.po` file needs to be submitted, and the author will convert the format and merge it. 30 | -------------------------------------------------------------------------------- /locale/en_US/LC_MESSAGES/tk.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/locale/en_US/LC_MESSAGES/tk.mo -------------------------------------------------------------------------------- /locale/generate_path.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | ROOT = Path(__file__).resolve().parent.parent 4 | 5 | 6 | def find_python_files(dir_, file): 7 | with open(file, "w", encoding="utf-8") as f: 8 | for py_file in dir_.rglob("*.py"): # 递归查找所有 .py 文件 9 | f.write(str(py_file) + "\n") # 写入文件路径 10 | 11 | 12 | # 设置源目录和输出文件 13 | source_directory = ROOT.joinpath("src") # 源目录 14 | output_file = "py_files.txt" # 输出文件名 15 | 16 | find_python_files(source_directory, output_file) 17 | print(f"所有 .py 文件路径已保存到 {output_file}") 18 | -------------------------------------------------------------------------------- /locale/po_to_mo.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from subprocess import run 3 | 4 | ROOT = Path(__file__).resolve().parent 5 | 6 | 7 | def scan_directory(): 8 | return [ 9 | item.joinpath("LC_MESSAGES/tk.po") for item in ROOT.iterdir() if item.is_dir() 10 | ] 11 | 12 | 13 | def generate_map(files: list[Path]): 14 | return [(i, i.with_suffix(".mo")) for i in files] 15 | 16 | 17 | def generate_mo(maps: list[tuple[Path, Path]]): 18 | for i, j in maps: 19 | command = f'msgfmt --check -o "{j}" "{i}"' 20 | print(run(command, shell=True, text=True)) 21 | 22 | 23 | if __name__ == "__main__": 24 | generate_mo(generate_map(scan_directory())) 25 | -------------------------------------------------------------------------------- /locale/zh_CN/LC_MESSAGES/tk.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/locale/zh_CN/LC_MESSAGES/tk.mo -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from asyncio import CancelledError 2 | from asyncio import run 3 | 4 | from src.application import TikTokDownloader 5 | 6 | 7 | async def main(): 8 | async with TikTokDownloader() as downloader: 9 | try: 10 | await downloader.run() 11 | except ( 12 | KeyboardInterrupt, 13 | CancelledError, 14 | ): 15 | return 16 | 17 | 18 | if __name__ == "__main__": 19 | run(main()) 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "DouK-Downloader" 3 | version = "5.7" 4 | description = "TikTok 发布/喜欢/合辑/直播/视频/图集/音乐;抖音发布/喜欢/收藏/收藏夹/视频/图集/实况/直播/音乐/合集/评论/账号/搜索/热榜数据采集工具" 5 | authors = [ 6 | { name = "JoeanAmier", email = "yonglelolu@foxmail.com" }, 7 | ] 8 | readme = "README.md" 9 | license = "GPL-3.0" 10 | requires-python = ">=3.12,<3.13" 11 | dependencies = [ 12 | "aiofiles>=24.1.0", 13 | "aiosqlite>=0.21.0", 14 | "emoji>=2.14.1", 15 | "fastapi>=0.115.9", 16 | "gmssl>=3.2.2", 17 | "httpx[socks]>=0.28.1", 18 | "lxml>=5.3.1", 19 | "openpyxl>=3.1.5", 20 | "pydantic>=2.10.6", 21 | "qrcode>=8.0", 22 | "rich>=13.9.4", 23 | "rookiepy>=0.5.6", 24 | "uvicorn>=0.34.0", 25 | ] 26 | 27 | [project.urls] 28 | Repository = "https://github.com/JoeanAmier/KS-Downloader" 29 | 30 | [tool.uv.pip] 31 | index-url = "https://pypi.org/simple" 32 | 33 | [tool.ruff] 34 | # Exclude a variety of commonly ignored directories. 35 | exclude = [ 36 | ".bzr", 37 | ".direnv", 38 | ".eggs", 39 | ".git", 40 | ".git-rewrite", 41 | ".hg", 42 | ".ipynb_checkpoints", 43 | ".mypy_cache", 44 | ".nox", 45 | ".pants.d", 46 | ".pyenv", 47 | ".pytest_cache", 48 | ".pytype", 49 | ".ruff_cache", 50 | ".svn", 51 | ".tox", 52 | ".venv", 53 | ".vscode", 54 | "__pypackages__", 55 | "_build", 56 | "buck-out", 57 | "build", 58 | "dist", 59 | "node_modules", 60 | "site-packages", 61 | "venv", 62 | ] 63 | 64 | # Same as Black. 65 | line-length = 88 66 | indent-width = 4 67 | 68 | # Assume Python 3.12 69 | target-version = "py312" 70 | 71 | [tool.ruff.lint] 72 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 73 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 74 | # McCabe complexity (`C901`) by default. 75 | select = ["E4", "E7", "E9", "F"] 76 | ignore = [] 77 | 78 | # Allow fix for all enabled rules (when `--fix`) is provided. 79 | fixable = ["ALL"] 80 | unfixable = [] 81 | 82 | # Allow unused variables when underscore-prefixed. 83 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 84 | 85 | [tool.ruff.format] 86 | # Like Black, use double quotes for strings. 87 | quote-style = "double" 88 | 89 | # Like Black, indent with spaces, rather than tabs. 90 | indent-style = "space" 91 | 92 | # Like Black, respect magic trailing commas. 93 | skip-magic-trailing-comma = false 94 | 95 | # Like Black, automatically detect the appropriate line ending. 96 | line-ending = "auto" 97 | 98 | # Enable auto-formatting of code examples in docstrings. Markdown, 99 | # reStructuredText code/literal blocks and doctests are all supported. 100 | # 101 | # This is currently disabled by default, but it is planned for this 102 | # to be opt-out in the future. 103 | docstring-code-format = false 104 | 105 | # Set the line length limit used when formatting code snippets in 106 | # docstrings. 107 | # 108 | # This only has an effect when the `docstring-code-format` setting is 109 | # enabled. 110 | docstring-code-line-length = "dynamic" 111 | 112 | [dependency-groups] 113 | dev = [ 114 | "pytest>=8.3.5", 115 | ] 116 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile pyproject.toml --no-deps --no-strip-extras -o requirements.txt 3 | aiofiles==24.1.0 4 | # via tiktokdownloader (pyproject.toml) 5 | aiosqlite==0.21.0 6 | # via tiktokdownloader (pyproject.toml) 7 | emoji==2.14.1 8 | # via tiktokdownloader (pyproject.toml) 9 | fastapi==0.115.9 10 | # via tiktokdownloader (pyproject.toml) 11 | gmssl==3.2.2 12 | # via tiktokdownloader (pyproject.toml) 13 | httpx[socks]==0.28.1 14 | # via tiktokdownloader (pyproject.toml) 15 | lxml==5.3.1 16 | # via tiktokdownloader (pyproject.toml) 17 | openpyxl==3.1.5 18 | # via tiktokdownloader (pyproject.toml) 19 | pydantic==2.10.6 20 | # via tiktokdownloader (pyproject.toml) 21 | qrcode==8.0 22 | # via tiktokdownloader (pyproject.toml) 23 | rich==13.9.4 24 | # via tiktokdownloader (pyproject.toml) 25 | rookiepy==0.5.6 26 | # via tiktokdownloader (pyproject.toml) 27 | uvicorn==0.34.0 28 | # via tiktokdownloader (pyproject.toml) 29 | -------------------------------------------------------------------------------- /src/application/__init__.py: -------------------------------------------------------------------------------- 1 | from .TikTokDownloader import TikTokDownloader 2 | 3 | __all__ = ["TikTokDownloader"] 4 | -------------------------------------------------------------------------------- /src/application/main_monitor.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from .main_terminal import TikTok 4 | 5 | if TYPE_CHECKING: 6 | from ..config import Parameter 7 | from ..manager import Database 8 | 9 | __all__ = ["ClipboardMonitor", "PostMonitor"] 10 | 11 | 12 | class ClipboardMonitor(TikTok): 13 | def __init__( 14 | self, 15 | parameter: "Parameter", 16 | database: "Database", 17 | ): 18 | super().__init__( 19 | parameter, 20 | database, 21 | ) 22 | 23 | 24 | class PostMonitor(TikTok): 25 | def __init__( 26 | self, 27 | parameter: "Parameter", 28 | database: "Database", 29 | ): 30 | super().__init__( 31 | parameter, 32 | database, 33 | ) 34 | -------------------------------------------------------------------------------- /src/cli_edition/__init__.py: -------------------------------------------------------------------------------- 1 | from .main_cli import cli 2 | 3 | __all__ = ["cli"] 4 | -------------------------------------------------------------------------------- /src/cli_edition/main_cli.py: -------------------------------------------------------------------------------- 1 | __all__ = ["cli"] 2 | 3 | 4 | class Cli: 5 | pass 6 | 7 | 8 | def cli(): 9 | pass 10 | -------------------------------------------------------------------------------- /src/cli_edition/write.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from src.config import Settings 4 | from src.custom import PROJECT_ROOT 5 | from src.tools import ColorfulConsole 6 | from src.translation import _ 7 | 8 | 9 | class Write: 10 | def __init__( 11 | self, 12 | ): 13 | self.console = ColorfulConsole() 14 | self.settings = Settings(PROJECT_ROOT, self.console) 15 | self.data = self.settings.read() 16 | 17 | def run(self): 18 | data = self.txt_inquire() 19 | self.generate_data(data) 20 | self.settings.update(self.data) 21 | 22 | def generate_data(self, data: str): 23 | for i in data.split("\n"): 24 | if i.strip(): 25 | self.data["accounts_urls_tiktok"].append( 26 | { 27 | "mark": "", 28 | "url": i, 29 | "tab": "post", 30 | "earliest": "", 31 | "latest": "", 32 | "enable": True, 33 | } 34 | ) 35 | 36 | def txt_inquire(self) -> str: 37 | if path := self.console.input(_("请输入文本文档路径:")): 38 | if (t := Path(path.replace('"', ""))).is_file(): 39 | try: 40 | with t.open("r", encoding=self.settings.encode) as f: 41 | return f.read() 42 | except UnicodeEncodeError as e: 43 | self.console.warning( 44 | _("{path} 文件读取异常: {error}").format(path=path, error=e) 45 | ) 46 | else: 47 | self.console.print(_("{path} 文件不存在!").format(path=path)) 48 | return "" 49 | 50 | 51 | if __name__ == "__main__": 52 | Write().run() 53 | -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .parameter import Parameter 2 | from .settings import Settings 3 | 4 | __all__ = ["Parameter", "Settings"] 5 | -------------------------------------------------------------------------------- /src/config/settings.py: -------------------------------------------------------------------------------- 1 | from json import dump 2 | from json import load 3 | from json.decoder import JSONDecodeError 4 | from platform import system 5 | from types import SimpleNamespace 6 | from typing import TYPE_CHECKING 7 | 8 | from ..custom import ERROR 9 | from ..translation import _ 10 | 11 | if TYPE_CHECKING: 12 | from ..tools import ColorfulConsole 13 | from pathlib import Path 14 | 15 | __all__ = ["Settings"] 16 | 17 | 18 | class Settings: 19 | encode = "UTF-8-SIG" if system() == "Windows" else "UTF-8" 20 | default = { 21 | "accounts_urls": [ 22 | { 23 | "mark": "", 24 | "url": "", 25 | "tab": "", 26 | "earliest": "", 27 | "latest": "", 28 | "enable": True, 29 | }, 30 | ], 31 | "accounts_urls_tiktok": [ 32 | { 33 | "mark": "", 34 | "url": "", 35 | "tab": "", 36 | "earliest": "", 37 | "latest": "", 38 | "enable": True, 39 | }, 40 | ], 41 | "mix_urls": [ 42 | { 43 | "mark": "", 44 | "url": "", 45 | "enable": True, 46 | }, 47 | ], 48 | "mix_urls_tiktok": [ 49 | { 50 | "mark": "", 51 | "url": "", 52 | "enable": True, 53 | }, 54 | ], 55 | "owner_url": { 56 | "mark": "", 57 | "url": "", 58 | "uid": "", 59 | "sec_uid": "", 60 | "nickname": "", 61 | }, 62 | "owner_url_tiktok": None, 63 | "root": "", 64 | "folder_name": "Download", 65 | "name_format": "create_time type nickname desc", 66 | "date_format": "%Y-%m-%d %H:%M:%S", 67 | "split": "-", 68 | "folder_mode": False, 69 | "music": False, 70 | "truncate": 50, 71 | "storage_format": "", 72 | "cookie": "", 73 | "cookie_tiktok": "", 74 | "dynamic_cover": False, 75 | "static_cover": False, 76 | "proxy": "", 77 | "proxy_tiktok": "", 78 | "twc_tiktok": "", 79 | "download": True, 80 | "max_size": 0, 81 | "chunk": 1024 * 1024 * 2, # 每次从服务器接收的数据块大小 82 | "timeout": 10, 83 | "max_retry": 5, # 重试最大次数 84 | "max_pages": 0, 85 | "run_command": "", 86 | "ffmpeg": "", 87 | "douyin_platform": True, 88 | "tiktok_platform": True, 89 | "browser_info": { 90 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", 91 | "pc_libra_divert": "Windows", 92 | "browser_platform": "Win32", 93 | "browser_name": "Chrome", 94 | "browser_version": "136.0.0.0", 95 | "engine_name": "Blink", 96 | "engine_version": "136.0.0.0", 97 | "os_name": "Windows", 98 | "os_version": "10", 99 | "webid": "", 100 | }, 101 | "browser_info_tiktok": { 102 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", 103 | "app_language": "zh-Hans", 104 | "browser_language": "zh-SG", 105 | "browser_name": "Mozilla", 106 | "browser_platform": "Win32", 107 | "browser_version": "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", 108 | "language": "zh-Hans", 109 | "os": "windows", 110 | "priority_region": "CN", 111 | "region": "US", 112 | "tz_name": "Asia/Shanghai", 113 | "webcast_language": "zh-Hans", 114 | "device_id": "", 115 | }, 116 | } # 默认配置 117 | compatible = ( 118 | ( 119 | "default_mode", 120 | "run_command", 121 | "", 122 | ), 123 | ( 124 | "update_cookie", 125 | "douyin_platform", 126 | True, 127 | ), 128 | ( 129 | "update_cookie_tiktok", 130 | "tiktok_platform", 131 | True, 132 | ), 133 | ( 134 | "original_cover", 135 | "static_cover", 136 | False, 137 | ), 138 | ) # 兼容旧版本配置文件 139 | 140 | def __init__(self, root: "Path", console: "ColorfulConsole"): 141 | self.file = root.joinpath("./settings.json") # 配置文件 142 | self.console = console 143 | 144 | def __create(self) -> dict: 145 | """创建默认配置文件""" 146 | with self.file.open("w", encoding=self.encode) as f: 147 | dump(self.default, f, indent=4, ensure_ascii=False) 148 | self.console.info( 149 | _( 150 | "创建默认配置文件 settings.json 成功!\n" 151 | "请参考项目文档的快速入门部分,设置 Cookie 后重新运行程序!\n" 152 | "建议根据实际使用需求修改配置文件 settings.json!\n" 153 | ), 154 | ) 155 | return self.default 156 | 157 | def read(self) -> dict: 158 | """读取配置文件,如果没有配置文件,则生成配置文件""" 159 | try: 160 | if self.file.exists(): 161 | with self.file.open("r", encoding=self.encode) as f: 162 | return self.__check(load(f)) 163 | return self.__create() # 生成的默认配置文件必须设置 cookie 才可以正常运行 164 | except JSONDecodeError: 165 | self.console.error( 166 | _("配置文件 settings.json 格式错误,请检查 JSON 格式!"), 167 | ) 168 | return self.default # 读取配置文件发生错误时返回空配置 169 | 170 | def __check(self, data: dict) -> dict: 171 | default_keys = self.default.keys() 172 | data = self.__compatible_with_old_settings(data) 173 | data_keys = set(data.keys()) 174 | if not (miss := default_keys - data_keys): 175 | return data 176 | if ( 177 | self.console.input( 178 | _( 179 | "配置文件 settings.json 缺少 {missing_params} 参数,是否需要生成默认配置文件(YES/NO): " 180 | ).format(missing_params=", ".join(miss)), 181 | style=ERROR, 182 | ).upper() 183 | == "YES" 184 | ): 185 | self.__create() 186 | self.console.warning( 187 | _("本次运行将会使用各项参数默认值,程序功能可能无法正常使用!"), 188 | ) 189 | return self.default 190 | 191 | def update(self, settings: dict | SimpleNamespace): 192 | """更新配置文件""" 193 | with self.file.open("w", encoding=self.encode) as f: 194 | dump( 195 | settings if isinstance(settings, dict) else vars(settings), 196 | f, 197 | indent=4, 198 | ensure_ascii=False, 199 | ) 200 | self.console.info( 201 | _("保存配置成功!"), 202 | ) 203 | 204 | def __compatible_with_old_settings( 205 | self, 206 | data: dict, 207 | ) -> dict: 208 | """兼容旧版本配置文件""" 209 | for old, new_, default in self.compatible: 210 | if old in data: 211 | self.console.info( 212 | _( 213 | "配置文件 {old} 参数已变更为 {new} 参数,请注意修改配置文件!" 214 | ).format(old=old, new=new_), 215 | ) 216 | data[new_] = data.get( 217 | new_, 218 | data.get( 219 | old, 220 | default, 221 | ), 222 | ) 223 | return data 224 | -------------------------------------------------------------------------------- /src/custom/__init__.py: -------------------------------------------------------------------------------- 1 | from .function import ( 2 | wait, 3 | failure_handling, 4 | condition_filter, 5 | suspend, 6 | is_valid_token, 7 | ) 8 | from .internal import ( 9 | DISCLAIMER_TEXT, 10 | PROJECT_ROOT, 11 | VERSION_MAJOR, 12 | VERSION_MINOR, 13 | VERSION_BETA, 14 | RELEASES, 15 | REPOSITORY, 16 | LICENCE, 17 | DOCUMENTATION_URL, 18 | USERAGENT, 19 | RETRY, 20 | BLANK_PREVIEW, 21 | TIMEOUT, 22 | PROJECT_NAME, 23 | DATA_HEADERS, 24 | PARAMS_HEADERS, 25 | DOWNLOAD_HEADERS, 26 | QRCODE_HEADERS, 27 | DOWNLOAD_HEADERS_TIKTOK, 28 | PHONE_HEADERS, 29 | PARAMS_HEADERS_TIKTOK, 30 | DATA_HEADERS_TIKTOK, 31 | VIDEO_INDEX, 32 | VIDEO_TIKTOK_INDEX, 33 | IMAGE_INDEX, 34 | IMAGE_TIKTOK_INDEX, 35 | VIDEOS_INDEX, 36 | DYNAMIC_COVER_INDEX, 37 | STATIC_COVER_INDEX, 38 | MUSIC_INDEX, 39 | COMMENT_IMAGE_INDEX, 40 | COMMENT_STICKER_INDEX, 41 | LIVE_COVER_INDEX, 42 | AUTHOR_COVER_INDEX, 43 | HOT_WORD_COVER_INDEX, 44 | COMMENT_IMAGE_LIST_INDEX, 45 | BITRATE_INFO_TIKTOK_INDEX, 46 | LIVE_DATA_INDEX, 47 | AVATAR_LARGER_INDEX, 48 | AUTHOR_COVER_URL_INDEX, 49 | SEARCH_USER_INDEX, 50 | SEARCH_AVATAR_INDEX, 51 | MUSIC_COLLECTION_COVER_INDEX, 52 | MUSIC_COLLECTION_DOWNLOAD_INDEX, 53 | __VERSION__, 54 | BLANK_HEADERS, 55 | ) 56 | from .static import ( 57 | MAX_WORKERS, 58 | DESCRIPTION_LENGTH, 59 | TEXT_REPLACEMENT, 60 | SERVER_HOST, 61 | SERVER_PORT, 62 | MASTER, 63 | PROMPT, 64 | WARNING, 65 | ERROR, 66 | INFO, 67 | GENERAL, 68 | PROGRESS, 69 | DEBUG, 70 | COOKIE_UPDATE_INTERVAL, 71 | MAX_FILENAME_LENGTH, 72 | FILE_SIGNATURES, 73 | FILE_SIGNATURES_LENGTH, 74 | ) 75 | -------------------------------------------------------------------------------- /src/custom/function.py: -------------------------------------------------------------------------------- 1 | # from asyncio import sleep 2 | # from random import randint 3 | from typing import TYPE_CHECKING 4 | 5 | # from src.translation import _ 6 | 7 | if TYPE_CHECKING: 8 | from src.tools import ColorfulConsole 9 | 10 | 11 | async def wait() -> None: 12 | """ 13 | 设置网络请求间隔时间,仅对获取数据生效,不影响下载文件 14 | """ 15 | # 随机延时 16 | # await sleep(randint(10, 25) * 0.1) 17 | # 固定延时 18 | # await sleep(2) 19 | # 取消延时 20 | pass 21 | 22 | 23 | def failure_handling() -> bool: 24 | """批量下载账号作品模式 和 批量下载合集作品模式 获取数据失败时,是否继续执行""" 25 | # 询问用户 26 | # return bool(input(_("输入任意字符继续处理账号/合集,直接回车停止处理账号/合集: "))) 27 | # 继续执行 28 | return True 29 | # 结束执行 30 | # return False 31 | 32 | 33 | def condition_filter(data: dict) -> bool: 34 | """ 35 | 自定义作品筛选规则,例如:筛选作品点赞数、作品类型、视频分辨率等 36 | 需要排除的作品返回 False,否则返回 True 37 | """ 38 | # if data["ratio"] in ("720p", "540p"): 39 | # return False # 过滤低分辨率的视频作品 40 | return True 41 | 42 | 43 | async def suspend(count: int, console: "ColorfulConsole") -> None: 44 | """ 45 | 如需采集大量数据,请启用该函数,可以在处理指定数量的数据后,暂停一段时间,然后继续运行 46 | batches: 每次处理的数据数量上限,比如:每次处理 10 个数据,就会暂停程序 47 | rest_time: 程序暂停的时间,单位:秒;比如:每处理 10 个数据,就暂停 5 分钟 48 | 仅对 批量下载账号作品模式 和 批量下载合集作品模式 生效 49 | 说明: 此处的一个数据代表一个账号或者一个合集,并非代表一个数据包 50 | """ 51 | # 启用该函数 52 | # batches = 10 # 根据实际需求修改 53 | # if not count % batches: 54 | # rest_time = 60 * 5 # 根据实际需求修改 55 | # console.print( 56 | # _( 57 | # "程序连续处理了 {batches} 个数据,为了避免请求频率过高导致账号或 IP 被风控," 58 | # "程序已经暂停运行,将在 {rest_time} 秒后恢复运行!" 59 | # ).format(batches=batches, rest_time=rest_time), 60 | # ) 61 | # await sleep(rest_time) 62 | # 禁用该函数 63 | pass 64 | 65 | 66 | def is_valid_token(token: str) -> bool: 67 | """Web API 接口模式 和 Web UI 交互模式 token 参数验证""" 68 | return True 69 | -------------------------------------------------------------------------------- /src/custom/internal.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent 4 | VERSION_MAJOR = 5 5 | VERSION_MINOR = 7 6 | VERSION_BETA = True 7 | __VERSION__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{'beta' if VERSION_BETA else 'stable'}" 8 | PROJECT_NAME = f"DouK-Downloader V{VERSION_MAJOR}.{VERSION_MINOR} { 9 | 'Beta' if VERSION_BETA else 'Stable' 10 | }" 11 | 12 | REPOSITORY = "https://github.com/JoeanAmier/TikTokDownloader" 13 | LICENCE = "GNU General Public License v3.0" 14 | DOCUMENTATION_URL = "https://github.com/JoeanAmier/TikTokDownloader/wiki/Documentation" 15 | RELEASES = "https://github.com/JoeanAmier/TikTokDownloader/releases/latest" 16 | 17 | DISCLAIMER_TEXT = ( 18 | "关于 DouK-Downloader 的 免责声明:\n" 19 | "\n" 20 | "1. 使用者对本项目的使用由使用者自行决定,并自行承担风险。作者对使用者使用本项目所产生的任何损失、责任、或风险概不负责。\n" 21 | "2. 本项目的作者提供的代码和功能是基于现有知识和技术的开发成果。作者按现有技术水平努力确保代码的正确性和安全性,但不保证代码完全没有错误或缺陷。\n" 22 | "3. 本项目依赖的所有第三方库、插件或服务各自遵循其原始开源或商业许可,使用者需自行查阅并遵守相应协议,作者不对第三方组件的稳定性、安全性及合规性承担任何责任。\n" 23 | "4. 使用者在使用本项目时必须严格遵守 GNU General Public License v3.0 的要求,并在适当的地方注明使用了 GNU General Public License v3.0 的代码。\n" 24 | "5. 使用者在使用本项目的代码和功能时,必须自行研究相关法律法规,并确保其使用行为合法合规。任何因违反法律法规而导致的法律责任和风险,均由使用者自行承担。\n" 25 | "6. 使用者不得使用本工具从事任何侵犯知识产权的行为,包括但不限于未经授权下载、传播受版权保护的内容,开发者不参与、不支持、不认可任何非法内容的获取或分发。\n" 26 | "7. 本项目不对使用者涉及的数据收集、存储、传输等处理活动的合规性承担责任。使用者应自行遵守相关法律法规,确保处理行为合法正当;因违规操作导致的法律责任由使用者自行承担。\n" 27 | "8. 使用者在任何情况下均不得将本项目的作者、贡献者或其他相关方与使用者的使用行为联系起来,或要求其对使用者使用本项目所产生的任何损失或损害负责。\n" 28 | "9. 本项目的作者不会提供 DouK-Downloader 项目的付费版本,也不会提供与 DouK-Downloader 项目相关的任何商业服务。\n" 29 | "10. 基于本项目进行的任何二次开发、修改或编译的程序与原创作者无关,原创作者不承担与二次开发行为或其结果相关的任何责任,使用者应自行对因二次开发可能带来的各种情况负全部责任。\n" 30 | "11. 本项目不授予使用者任何专利许可;若使用本项目导致专利纠纷或侵权,使用者自行承担全部风险和责任。未经作者或权利人书面授权,不得使用本项目进行任何商业宣传、推广或再授权。\n" 31 | "12. 作者保留随时终止向任何违反本声明的使用者提供服务的权利,并可能要求其销毁已获取的代码及衍生作品。\n" 32 | "13. 作者保留在不另行通知的情况下更新本声明的权利,使用者持续使用即视为接受修订后的条款。\n" 33 | "\n" 34 | "在使用本项目的代码和功能之前,请您认真考虑并接受以上免责声明。如果您对上述声" 35 | "明有任何疑问或不同意,请不要使用本项目的代码和功能。如果您使用了本项目的代码" 36 | "和功能,则视为您已完全理解并接受上述免责声明,并自愿承担使用本项目的一切风险" 37 | "和后果。\n" 38 | ) 39 | 40 | RETRY = 5 41 | TIMEOUT = 10 42 | 43 | PHONE_HEADERS = { 44 | "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) " 45 | "CriOS/125.0.6422.51 Mobile/15E148 Safari/604.1", 46 | } 47 | USERAGENT = ( 48 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 " 49 | "Safari/537.36" 50 | ) 51 | BLANK_HEADERS = { 52 | "User-Agent": USERAGENT, 53 | } 54 | REFERER = "https://www.douyin.com/?recommend=1" 55 | REFERER_TIKTOK = "https://www.tiktok.com/explore" 56 | PARAMS_HEADERS = { 57 | "Accept": "*/*", 58 | "Accept-Encoding": "*/*", 59 | "Content-Type": "text/plain;charset=UTF-8", 60 | "Referer": REFERER, 61 | "User-Agent": USERAGENT, 62 | } 63 | PARAMS_HEADERS_TIKTOK = PARAMS_HEADERS | { 64 | "Referer": REFERER_TIKTOK, 65 | } 66 | DATA_HEADERS = { 67 | "Accept": "*/*", 68 | "Accept-Encoding": "*/*", 69 | "Referer": REFERER, 70 | "User-Agent": USERAGENT, 71 | } 72 | DATA_HEADERS_TIKTOK = DATA_HEADERS | { 73 | "Referer": REFERER_TIKTOK, 74 | } 75 | DOWNLOAD_HEADERS = { 76 | "Accept": "*/*", 77 | "Range": "bytes=0-", 78 | "Referer": REFERER, 79 | "User-Agent": USERAGENT, 80 | } 81 | DOWNLOAD_HEADERS_TIKTOK = DOWNLOAD_HEADERS | { 82 | "Referer": REFERER_TIKTOK, 83 | } 84 | QRCODE_HEADERS = { 85 | "Accept": "*/*", 86 | "Accept-Encoding": "*/*", 87 | "Referer": REFERER, 88 | "User-Agent": USERAGENT, 89 | } 90 | 91 | BLANK_PREVIEW = "static/images/blank.png" 92 | 93 | VIDEO_INDEX: int = -1 94 | VIDEO_TIKTOK_INDEX: int = 0 95 | IMAGE_INDEX: int = -1 96 | IMAGE_TIKTOK_INDEX: int = -1 97 | VIDEOS_INDEX: int = -1 98 | DYNAMIC_COVER_INDEX: int = -1 99 | STATIC_COVER_INDEX: int = -1 100 | MUSIC_INDEX: int = -1 101 | COMMENT_IMAGE_INDEX: int = -1 102 | COMMENT_STICKER_INDEX: int = -1 103 | LIVE_COVER_INDEX: int = -1 104 | AUTHOR_COVER_INDEX: int = -1 105 | HOT_WORD_COVER_INDEX: int = -1 106 | COMMENT_IMAGE_LIST_INDEX: int = 0 107 | BITRATE_INFO_TIKTOK_INDEX: int = 0 108 | LIVE_DATA_INDEX: int = 0 109 | AVATAR_LARGER_INDEX: int = 0 110 | AUTHOR_COVER_URL_INDEX: int = 0 111 | SEARCH_USER_INDEX: int = 0 112 | SEARCH_AVATAR_INDEX: int = 0 113 | MUSIC_COLLECTION_COVER_INDEX: int = 0 114 | MUSIC_COLLECTION_DOWNLOAD_INDEX: int = 0 115 | 116 | if __name__ == "__main__": 117 | print(__VERSION__) 118 | -------------------------------------------------------------------------------- /src/custom/static.py: -------------------------------------------------------------------------------- 1 | # 同时下载作品文件的最大任务数,对直播无效 2 | MAX_WORKERS = 4 3 | 4 | # 作品描述最大长度限制,仅对作品文件名称生效,不影响数据储存,设置时需要考虑系统文件名称最大长度限制 5 | DESCRIPTION_LENGTH = 64 6 | 7 | # 文件名称最大长度限制 8 | MAX_FILENAME_LENGTH = 128 9 | 10 | # 非法字符替换规则,key 为替换前的文本,value 为替换后的文本 11 | TEXT_REPLACEMENT = { 12 | " ": " ", 13 | } 14 | 15 | # 服务器模式主机,对 Web API 接口模式、Web UI 交互模式 生效,设置为 "0.0.0.0" 可以启用局域网访问(外部可用) 16 | SERVER_HOST = "127.0.0.1" 17 | 18 | # 服务器模式端口,对 Web API 接口模式、Web UI 交互模式 生效 19 | SERVER_PORT = 5555 20 | 21 | # Cookie 更新间隔,单位:秒 22 | COOKIE_UPDATE_INTERVAL = 20 * 60 23 | 24 | # 彩色交互提示颜色设置,支持标准颜色名称、Hex、RGB 格式 25 | MASTER = "b #fff200" 26 | PROMPT = "b turquoise2" 27 | GENERAL = "b bright_white" 28 | PROGRESS = "b bright_magenta" 29 | ERROR = "b bright_red" 30 | WARNING = "b bright_yellow" 31 | INFO = "b bright_green" 32 | DEBUG = "b dark_orange" 33 | 34 | # 文件类型签名 35 | FILE_SIGNATURES: tuple[ 36 | tuple[ 37 | int, 38 | bytes, 39 | str, 40 | ], 41 | ..., 42 | ] = ( 43 | # 分别为偏移量(字节)、十六进制签名、后缀 44 | # 参考:https://en.wikipedia.org/wiki/List_of_file_signatures 45 | # 参考:https://www.garykessler.net/library/file_sigs.html 46 | (0, b"\xff\xd8\xff", "jpg"), 47 | (0, b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a", "png"), 48 | (4, b"\x66\x74\x79\x70\x61\x76\x69\x66", "avif"), 49 | (4, b"\x66\x74\x79\x70\x68\x65\x69\x63", "heic"), 50 | (8, b"\x57\x45\x42\x50", "webp"), 51 | (4, b"\x66\x74\x79\x70\x4d\x53\x4e\x56", "mp4"), 52 | (4, b"\x66\x74\x79\x70\x69\x73\x6f\x6d", "mp4"), 53 | (4, b"\x66\x74\x79\x70\x6d\x70\x34\x32", "m4v"), 54 | (4, b"\x66\x74\x79\x70\x71\x74\x20\x20", "mov"), 55 | (0, b"\x1a\x45\xdf\xa3", "mkv"), 56 | (0, b"\x00\x00\x01\xb3", "mpg"), 57 | (0, b"\x00\x00\x01\xba", "mpg"), 58 | (0, b"\x46\x4c\x56\x01", "flv"), 59 | (8, b"\x41\x56\x49\x20", "avi"), 60 | ) 61 | FILE_SIGNATURES_LENGTH = max( 62 | offset + len(signature) for offset, signature, _ in FILE_SIGNATURES 63 | ) 64 | -------------------------------------------------------------------------------- /src/downloader/__init__.py: -------------------------------------------------------------------------------- 1 | from .download import Downloader 2 | 3 | __all__ = ["Downloader"] 4 | -------------------------------------------------------------------------------- /src/encrypt/__init__.py: -------------------------------------------------------------------------------- 1 | from .aBogus import ABogus 2 | from .device_id import DeviceId 3 | from .msToken import MsToken, MsTokenTikTok 4 | from .ttWid import TtWid, TtWidTikTok 5 | from .verifyFp import VerifyFp 6 | from .webID import WebId 7 | from .xBogus import XBogus, XBogusTikTok 8 | -------------------------------------------------------------------------------- /src/encrypt/device_id.py: -------------------------------------------------------------------------------- 1 | from asyncio import run 2 | from re import compile 3 | from typing import TYPE_CHECKING, Union 4 | 5 | from src.custom import PARAMS_HEADERS_TIKTOK 6 | from src.tools import request_params 7 | 8 | if TYPE_CHECKING: 9 | from src.record import BaseLogger, LoggerManager 10 | from src.testers import Logger 11 | 12 | 13 | class DeviceId: 14 | NAME = "device_id" 15 | URL = "https://www.tiktok.com/explore" 16 | DEVICE_ID = compile(r'"wid":"(\d{19})"') 17 | 18 | @classmethod 19 | async def get_device_id( 20 | cls, 21 | logger: Union["BaseLogger", "LoggerManager", "Logger"], 22 | headers: dict, 23 | **kwargs, 24 | ) -> [str, str]: 25 | response = await request_params( 26 | logger, 27 | cls.URL, 28 | "GET", 29 | headers=headers, 30 | resp="response", 31 | **kwargs, 32 | ) 33 | response.raise_for_status() 34 | device_id = d.group(1) if (d := cls.DEVICE_ID.search(response.text)) else "" 35 | cookie = "; ".join( 36 | [f"{key}={value}" for key, value in response.cookies.items()] 37 | ) 38 | return device_id, cookie 39 | 40 | @classmethod 41 | async def get_device_ids( 42 | cls, 43 | logger: Union["BaseLogger", "LoggerManager", "Logger"], 44 | headers: dict, 45 | number: int, 46 | **kwargs, 47 | ) -> [[str, str]]: 48 | return [ 49 | await cls.get_device_id( 50 | logger, 51 | headers, 52 | **kwargs, 53 | ) 54 | for _ in range(number) 55 | ] 56 | 57 | 58 | async def test(): 59 | from src.testers import Logger 60 | 61 | print( 62 | await DeviceId.get_device_id( 63 | Logger(), 64 | PARAMS_HEADERS_TIKTOK, 65 | proxy="http://127.0.0.1:10809", 66 | ) 67 | ) 68 | # print(await DeviceId.get_device_ids( 69 | # Logger(), 70 | # PARAMS_HEADERS_TIKTOK, 71 | # 5, 72 | # proxy="http://127.0.0.1:10809", 73 | # )) 74 | 75 | 76 | if __name__ == "__main__": 77 | run(test()) 78 | -------------------------------------------------------------------------------- /src/encrypt/ttWid.py: -------------------------------------------------------------------------------- 1 | from asyncio import run 2 | from http import cookies 3 | from json import dumps 4 | from typing import TYPE_CHECKING, Union 5 | 6 | from src.custom import PARAMS_HEADERS, PARAMS_HEADERS_TIKTOK 7 | from src.tools import request_params 8 | from src.translation import _ 9 | 10 | if TYPE_CHECKING: 11 | from src.record import BaseLogger, LoggerManager 12 | from src.testers import Logger 13 | 14 | __all__ = ["TtWid", "TtWidTikTok"] 15 | 16 | 17 | class TtWid: 18 | NAME = "ttwid" 19 | API = "https://ttwid.bytedance.com/ttwid/union/register/" 20 | DATA = ( 21 | '{"region":"cn","aid":1768,"needFid":false,"service":"www.ixigua.com","migrate_info":{"ticket":"",' 22 | '"source":"node"},"cbUrlProtocol":"https","union":true}' 23 | ) 24 | 25 | @classmethod 26 | async def get_tt_wid( 27 | cls, 28 | logger: Union["BaseLogger", "LoggerManager", "Logger"], 29 | headers: dict, 30 | proxy: str = None, 31 | **kwargs, 32 | ) -> dict | None: 33 | if response := await request_params( 34 | logger, 35 | cls.API, 36 | data=cls.DATA, 37 | headers=headers, 38 | proxy=proxy, 39 | **kwargs, 40 | ): 41 | return cls.extract(logger, response, cls.NAME) 42 | logger.error(_("获取 {name} 参数失败!").format(name=cls.NAME)) 43 | 44 | @staticmethod 45 | def extract( 46 | logger: Union["BaseLogger", "LoggerManager", "Logger"], headers, key: str 47 | ) -> dict | None: 48 | if c := headers.get("Set-Cookie"): 49 | cookie_jar = cookies.SimpleCookie() 50 | cookie_jar.load(c) 51 | if v := cookie_jar.get(key): 52 | return {key: v.value} 53 | logger.error(f"获取 {key} 参数失败!") 54 | 55 | 56 | class TtWidTikTok(TtWid): 57 | API = "https://www.tiktok.com/ttwid/check/" 58 | DATA = dumps( 59 | { 60 | "aid": 1988, 61 | "service": "www.tiktok.com", 62 | "union": False, 63 | "unionHost": "", 64 | "needFid": False, 65 | "fid": "", 66 | "migrate_priority": 0, 67 | }, 68 | separators=(",", ":"), 69 | ) 70 | 71 | @classmethod 72 | async def get_tt_wid( 73 | cls, 74 | logger: Union["BaseLogger", "LoggerManager", "Logger"], 75 | headers: dict, 76 | cookie: str = "", 77 | proxy: str = None, 78 | **kwargs, 79 | ) -> dict | None: 80 | if response := await request_params( 81 | logger, 82 | cls.API, 83 | data=cls.DATA, 84 | headers=headers 85 | | { 86 | "Cookie": cookie, 87 | "Content-Type": "application/x-www-form-urlencoded", 88 | }, 89 | proxy=proxy, 90 | **kwargs, 91 | ): 92 | return cls.extract(logger, response, cls.NAME) 93 | logger.error(_("获取 {name} 参数失败!").format(name=cls.NAME)) 94 | 95 | 96 | async def test(): 97 | from src.testers import Logger 98 | 99 | print("抖音", await TtWid.get_tt_wid(Logger(), PARAMS_HEADERS, proxy=None)) 100 | print( 101 | "TikTok", 102 | await TtWidTikTok.get_tt_wid( 103 | Logger(), 104 | PARAMS_HEADERS_TIKTOK, 105 | cookie="ttwid=", 106 | proxy="http://localhost:10809", 107 | ), 108 | ) 109 | 110 | 111 | if __name__ == "__main__": 112 | run(test()) 113 | -------------------------------------------------------------------------------- /src/encrypt/verifyFp.py: -------------------------------------------------------------------------------- 1 | from random import random 2 | from string import ascii_lowercase 3 | from string import ascii_uppercase 4 | from string import digits 5 | from time import time 6 | 7 | from rich import print 8 | 9 | __all__ = [ 10 | "VerifyFp", 11 | ] 12 | 13 | 14 | class VerifyFp: 15 | """ 16 | var xi = function() { 17 | return Pi.get(Si) || (null === localStorage || void 0 === localStorage ? void 0 : localStorage.getItem(Si)) || function() { 18 | var e = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".split("") 19 | , t = e.length 20 | , n = Date.now().toString(36) 21 | , r = []; 22 | r[8] = r[13] = r[18] = r[23] = "_", 23 | r[14] = "4"; 24 | for (var o = 0, i = void 0; o < 36; o++) 25 | r[o] || (i = 0 | Math.random() * t, 26 | r[o] = e[19 == o ? 3 & i | 8 : i]); 27 | return "verify_" + n + "_" + r.join("") 28 | }() 29 | } 30 | """ 31 | 32 | @staticmethod 33 | def get_verify_fp(timestamp: int = None): 34 | base_str = digits + ascii_uppercase + ascii_lowercase 35 | t = len(base_str) 36 | milliseconds = timestamp or int(round(time() * 1000)) 37 | base36 = "" 38 | 39 | # 转换为 base36 40 | while milliseconds > 0: 41 | milliseconds, remainder = divmod(milliseconds, 36) 42 | if remainder < 10: 43 | base36 = str(remainder) + base36 44 | else: 45 | base36 = chr(ord("a") + remainder - 10) + base36 46 | 47 | # 设置固定字符 48 | o = [""] * 36 49 | o[8] = o[13] = o[18] = o[23] = "_" 50 | o[14] = "4" 51 | 52 | # 随机填充缺失的字符 53 | for i in range(36): 54 | if not o[i]: 55 | n = int(random() * t) # 优化随机数生成方式 56 | if i == 19: 57 | n = 3 & n | 8 58 | o[i] = base_str[n] 59 | 60 | # 组合最终字符串 61 | return f"verify_{base36}_" + "".join(o) 62 | 63 | 64 | if __name__ == "__main__": 65 | params = 1710413848097 66 | print(VerifyFp.get_verify_fp(params)) 67 | -------------------------------------------------------------------------------- /src/encrypt/webID.py: -------------------------------------------------------------------------------- 1 | from asyncio import run 2 | from typing import TYPE_CHECKING, Union 3 | 4 | from src.custom import PARAMS_HEADERS 5 | from src.tools import request_params 6 | from src.translation import _ 7 | 8 | if TYPE_CHECKING: 9 | from src.record import BaseLogger, LoggerManager 10 | from src.testers import Logger 11 | 12 | __all__ = ["WebId"] 13 | 14 | 15 | class WebId: 16 | NAME = "webid" 17 | API = "https://mcs.zijieapi.com/webid" 18 | PARAMS = {"aid": "6383", "sdk_version": "5.1.18_zip", "device_platform": "web"} 19 | 20 | @classmethod 21 | async def get_web_id( 22 | cls, 23 | logger: Union["BaseLogger", "LoggerManager", "Logger"], 24 | headers: dict, 25 | proxy: str = None, 26 | **kwargs, 27 | ) -> str | None: 28 | user_agent = headers.get("User-Agent") 29 | data = ( 30 | f'{{"app_id":6383,"url":"https://www.douyin.com/","user_agent":"{user_agent}","referer":"https://www' 31 | f'.douyin.com/","user_unique_id":""}}' 32 | ) 33 | if response := await request_params( 34 | logger, 35 | cls.API, 36 | params=cls.PARAMS, 37 | data=data, 38 | headers=headers, 39 | resp="json", 40 | proxy=proxy, 41 | **kwargs, 42 | ): 43 | return response.get("web_id") 44 | logger.error(_("获取 {name} 参数失败!").format(name=cls.NAME)) 45 | 46 | 47 | async def test(): 48 | from src.testers import Logger 49 | 50 | print(await WebId.get_web_id(Logger(), PARAMS_HEADERS, proxy=None)) 51 | 52 | 53 | if __name__ == "__main__": 54 | run(test()) 55 | -------------------------------------------------------------------------------- /src/encrypt/xBogus.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | from hashlib import md5 3 | from time import time 4 | from urllib.parse import quote, urlencode 5 | 6 | from ..custom import USERAGENT 7 | 8 | __all__ = ["XBogus", "XBogusTikTok"] 9 | 10 | 11 | class XBogus: 12 | __string = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=" 13 | __array = ( 14 | [None for _ in range(48)] 15 | + list(range(10)) 16 | + [None for _ in range(39)] 17 | + list(range(10, 16)) 18 | ) 19 | __canvas = 3873194319 20 | 21 | @staticmethod 22 | def disturb_array(a, b, e, d, c, f, t, n, o, i, r, _, x, u, s, l, v, h, g): 23 | array = [0] * 19 24 | array[0] = a 25 | array[10] = b 26 | array[1] = e 27 | array[11] = d 28 | array[2] = c 29 | array[12] = f 30 | array[3] = t 31 | array[13] = n 32 | array[4] = o 33 | array[14] = i 34 | array[5] = r 35 | array[15] = _ 36 | array[6] = x 37 | array[16] = u 38 | array[7] = s 39 | array[17] = l 40 | array[8] = v 41 | array[18] = h 42 | array[9] = g 43 | return array 44 | 45 | @staticmethod 46 | def generate_garbled_1(a, b, e, d, c, f, t, n, o, i, r, _, x, u, s, l, v, h, g): 47 | array = [0] * 19 48 | array[0] = a 49 | array[1] = r 50 | array[2] = b 51 | array[3] = _ 52 | array[4] = e 53 | array[5] = x 54 | array[6] = d 55 | array[7] = u 56 | array[8] = c 57 | array[9] = s 58 | array[10] = f 59 | array[11] = l 60 | array[12] = t 61 | array[13] = v 62 | array[14] = n 63 | array[15] = h 64 | array[16] = o 65 | array[17] = g 66 | array[18] = i 67 | return "".join(map(chr, map(int, array))) 68 | 69 | @staticmethod 70 | def generate_num(text): 71 | return [ 72 | ord(text[i]) << 16 | ord(text[i + 1]) << 8 | ord(text[i + 2]) << 0 73 | for i in range(0, 21, 3) 74 | ] 75 | 76 | @staticmethod 77 | def generate_garbled_2(a, b, c): 78 | return chr(a) + chr(b) + c 79 | 80 | @staticmethod 81 | def generate_garbled_3(a, b): 82 | d = list(range(256)) 83 | c = 0 84 | f = "" 85 | for a_idx in range(256): 86 | d[a_idx] = a_idx 87 | for b_idx in range(256): 88 | c = (c + d[b_idx] + ord(a[b_idx % len(a)])) % 256 89 | e = d[b_idx] 90 | d[b_idx] = d[c] 91 | d[c] = e 92 | t = 0 93 | c = 0 94 | for b_idx in range(len(b)): 95 | t = (t + 1) % 256 96 | c = (c + d[t]) % 256 97 | e = d[t] 98 | d[t] = d[c] 99 | d[c] = e 100 | f += chr(ord(b[b_idx]) ^ d[(d[t] + d[c]) % 256]) 101 | return f 102 | 103 | def calculate_md5(self, input_string): 104 | if isinstance(input_string, str): 105 | array = self.md5_to_array(input_string) 106 | elif isinstance(input_string, list): 107 | array = input_string 108 | else: 109 | raise TypeError 110 | 111 | md5_hash = md5() 112 | md5_hash.update(bytes(array)) 113 | return md5_hash.hexdigest() 114 | 115 | def md5_to_array(self, md5_str): 116 | if isinstance(md5_str, str) and len(md5_str) > 32: 117 | return [ord(char) for char in md5_str] 118 | else: 119 | return [ 120 | (self.__array[ord(md5_str[index])] << 4) 121 | | self.__array[ord(md5_str[index + 1])] 122 | for index in range(0, len(md5_str), 2) 123 | ] 124 | 125 | def process_url_path(self, url_path): 126 | return self.md5_to_array( 127 | self.calculate_md5(self.md5_to_array(self.calculate_md5(url_path))) 128 | ) 129 | 130 | def generate_str(self, num): 131 | string = [num & 16515072, num & 258048, num & 4032, num & 63] 132 | string = [i >> j for i, j in zip(string, range(18, -1, -6))] 133 | return "".join([self.__string[i] for i in string]) 134 | 135 | @staticmethod 136 | def handle_ua(a, b): 137 | d = list(range(256)) 138 | c = 0 139 | result = bytearray(len(b)) 140 | 141 | for i in range(256): 142 | c = (c + d[i] + ord(a[i % len(a)])) % 256 143 | d[i], d[c] = d[c], d[i] 144 | 145 | t = 0 146 | c = 0 147 | 148 | for i in range(len(b)): 149 | t = (t + 1) % 256 150 | c = (c + d[t]) % 256 151 | d[t], d[c] = d[c], d[t] 152 | result[i] = b[i] ^ d[(d[t] + d[c]) % 256] 153 | 154 | return result 155 | 156 | def generate_ua_array(self, user_agent: str, params: int) -> list: 157 | ua_key = ["\u0000", "\u0001", chr(params)] 158 | value = self.handle_ua(ua_key, user_agent.encode("utf-8")) 159 | value = b64encode(value) 160 | return list(md5(value).digest()) 161 | 162 | def generate_x_bogus( 163 | self, query: list, params: int, user_agent: str, timestamp: int 164 | ): 165 | ua_array = self.generate_ua_array(user_agent, params) 166 | array = [ 167 | 64, 168 | 0.00390625, 169 | 1, 170 | params, 171 | query[-2], 172 | query[-1], 173 | 69, 174 | 63, 175 | ua_array[-2], 176 | ua_array[-1], 177 | timestamp >> 24 & 255, 178 | timestamp >> 16 & 255, 179 | timestamp >> 8 & 255, 180 | timestamp >> 0 & 255, 181 | self.__canvas >> 24 & 255, 182 | self.__canvas >> 16 & 255, 183 | self.__canvas >> 8 & 255, 184 | self.__canvas >> 0 & 255, 185 | None, 186 | ] 187 | zero = 0 188 | for i in array[:-1]: 189 | if isinstance(i, float): 190 | i = int(i) 191 | zero ^= i 192 | array[-1] = zero 193 | garbled = self.generate_garbled_1(*self.disturb_array(*array)) 194 | garbled = self.generate_garbled_2(2, 255, self.generate_garbled_3("ÿ", garbled)) 195 | return "".join(self.generate_str(i) for i in self.generate_num(garbled)) 196 | 197 | def get_x_bogus( 198 | self, query: dict | str, params=8, user_agent=USERAGENT, test_time=None 199 | ): 200 | timestamp = int(test_time or time()) 201 | query = self.process_url_path( 202 | urlencode(query, quote_via=quote) if isinstance(query, dict) else query 203 | ) 204 | return self.generate_x_bogus(query, params, user_agent, timestamp) 205 | 206 | 207 | class XBogusTikTok(XBogus): 208 | pass 209 | -------------------------------------------------------------------------------- /src/extract/__init__.py: -------------------------------------------------------------------------------- 1 | from .extractor import Extractor 2 | 3 | __all__ = ["Extractor"] 4 | -------------------------------------------------------------------------------- /src/gui_edition/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/src/gui_edition/__init__.py -------------------------------------------------------------------------------- /src/interface/__init__.py: -------------------------------------------------------------------------------- 1 | from ..interface.account import Account 2 | from ..interface.account_tiktok import AccountTikTok 3 | from ..interface.collection import Collection 4 | from ..interface.collects import ( 5 | Collects, 6 | CollectsDetail, 7 | CollectsMix, 8 | CollectsMusic, 9 | CollectsSeries, 10 | ) 11 | from ..interface.comment import Comment, Reply 12 | from ..interface.comment_tiktok import CommentTikTok, ReplyTikTok 13 | from ..interface.detail import Detail 14 | from ..interface.detail_tiktok import DetailTikTok 15 | from ..interface.hashtag import HashTag 16 | from ..interface.hot import Hot 17 | from ..interface.info import Info 18 | from ..interface.info_tiktok import InfoTikTok 19 | from ..interface.live import Live 20 | from ..interface.live_tiktok import LiveTikTok 21 | from ..interface.mix import Mix 22 | from ..interface.mix_tiktok import MixListTikTok 23 | from ..interface.mix_tiktok import MixTikTok 24 | from ..interface.search import Search 25 | from ..interface.template import API 26 | from ..interface.template import APITikTok 27 | from ..interface.user import User 28 | -------------------------------------------------------------------------------- /src/interface/account_tiktok.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Callable, Coroutine, Type, Union 2 | 3 | from src.interface.account import Account 4 | from src.interface.template import APITikTok 5 | 6 | if TYPE_CHECKING: 7 | from src.config import Parameter 8 | from src.testers import Params 9 | 10 | 11 | class AccountTikTok( 12 | Account, 13 | APITikTok, 14 | ): 15 | post_api = f"{APITikTok.domain}api/post/item_list/" 16 | favorite_api = f"{APITikTok.domain}api/favorite/item_list/" 17 | 18 | def __init__( 19 | self, 20 | params: Union["Parameter", "Params"], 21 | cookie: str = "", 22 | proxy: str = None, 23 | sec_user_id: str = ..., 24 | tab="post", 25 | earliest: str | float | int = "", 26 | latest: str | float | int = "", 27 | pages: int = None, 28 | cursor=0, 29 | count=35, 30 | *args, 31 | **kwargs, 32 | ): 33 | super().__init__( 34 | params, 35 | cookie, 36 | proxy, 37 | sec_user_id, 38 | tab, 39 | earliest, 40 | latest, 41 | pages, 42 | cursor, 43 | count, 44 | *args, 45 | **kwargs, 46 | ) 47 | 48 | async def run( 49 | self, 50 | referer: str = None, 51 | single_page=False, 52 | data_key: str = "itemList", 53 | error_text="", 54 | cursor="cursor", 55 | has_more="hasMore", 56 | params: Callable = lambda: {}, 57 | data: Callable = lambda: {}, 58 | method="GET", 59 | headers: dict = None, 60 | *args, 61 | **kwargs, 62 | ): 63 | self.set_referer(referer) 64 | match single_page: 65 | case True: 66 | await self.run_single( 67 | data_key, 68 | error_text=error_text, 69 | cursor=cursor, 70 | has_more=has_more, 71 | params=params, 72 | data=data, 73 | method=method, 74 | headers=headers, 75 | *args, 76 | **kwargs, 77 | ) 78 | return self.response 79 | case False: 80 | await self.run_batch( 81 | data_key, 82 | error_text=error_text, 83 | cursor=cursor, 84 | has_more=has_more, 85 | params=params, 86 | data=data, 87 | method=method, 88 | headers=headers, 89 | *args, 90 | **kwargs, 91 | ) 92 | return self.response, self.earliest, self.latest 93 | raise ValueError 94 | 95 | async def run_batch( 96 | self, 97 | data_key: str = "itemList", 98 | error_text="", 99 | cursor="cursor", 100 | has_more="hasMore", 101 | params: Callable = lambda: {}, 102 | data: Callable = lambda: {}, 103 | method="GET", 104 | headers: dict = None, 105 | callback: Type[Coroutine] = None, 106 | *args, 107 | **kwargs, 108 | ): 109 | await super().run_batch( 110 | data_key=data_key, 111 | error_text=error_text, 112 | cursor=cursor, 113 | has_more=has_more, 114 | params=params, 115 | data=data, 116 | method=method, 117 | headers=headers, 118 | callback=callback, 119 | *args, 120 | **kwargs, 121 | ) 122 | 123 | def generate_favorite_params(self) -> dict: 124 | return self.generate_post_params() 125 | 126 | def generate_post_params(self) -> dict: 127 | return self.params | { 128 | "secUid": self.sec_user_id, 129 | "count": self.count, 130 | "cursor": self.cursor, 131 | "coverFormat": "2", 132 | "post_item_list_request_type": "0", 133 | } 134 | 135 | 136 | async def test(): 137 | from src.testers import Params 138 | 139 | async with Params() as params: 140 | i = AccountTikTok( 141 | params, 142 | sec_user_id="", 143 | earliest=15, 144 | ) 145 | print(await i.run()) 146 | 147 | 148 | if __name__ == "__main__": 149 | from asyncio import run 150 | 151 | run(test()) 152 | -------------------------------------------------------------------------------- /src/interface/collection.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Callable, Union 2 | 3 | from src.interface.template import API 4 | from src.translation import _ 5 | 6 | if TYPE_CHECKING: 7 | from src.config import Parameter 8 | from src.testers import Params 9 | 10 | 11 | class Collection(API): 12 | def __init__( 13 | self, 14 | params: Union["Parameter", "Params"], 15 | cookie: str = "", 16 | proxy: str = None, 17 | sec_user_id: str = "", 18 | count=10, 19 | cursor=0, 20 | pages: int = None, 21 | *args, 22 | **kwargs, 23 | ): 24 | super().__init__(params, cookie, proxy, *args, **kwargs) 25 | self.api = f"{self.domain}aweme/v1/web/aweme/listcollection/" 26 | self.text = _("账号收藏作品") 27 | self.count = count 28 | self.cursor = cursor 29 | self.pages = pages or params.max_pages 30 | self.sec_user_id = sec_user_id 31 | 32 | async def run( 33 | self, 34 | referer: str = "", 35 | single_page=False, 36 | data_key: str = "aweme_list", 37 | error_text="", 38 | cursor="cursor", 39 | has_more="has_more", 40 | params: Callable = lambda: {}, 41 | data: Callable = lambda: {}, 42 | method="POST", 43 | headers: dict = None, 44 | *args, 45 | **kwargs, 46 | ): 47 | await super().run( 48 | referer or f"{self.domain}user/self?showTab=favorite_collection", 49 | single_page, 50 | data_key, 51 | error_text, 52 | cursor, 53 | has_more, 54 | params, 55 | data, 56 | method, 57 | headers, 58 | *args, 59 | **kwargs, 60 | ) 61 | # await self.get_owner_data() 62 | return self.response 63 | 64 | def generate_params( 65 | self, 66 | ) -> dict: 67 | return self.params | { 68 | "publish_video_strategy_type": "2", 69 | "version_code": "170400", 70 | "version_name": "17.4.0", 71 | } 72 | 73 | def generate_data( 74 | self, 75 | ) -> dict: 76 | return { 77 | "count": self.count, 78 | "cursor": self.cursor, 79 | } 80 | 81 | async def request_data( 82 | self, 83 | url: str, 84 | params: dict = None, 85 | data: dict = None, 86 | method="GET", 87 | headers: dict = None, 88 | encryption="GET", 89 | finished=False, 90 | *args, 91 | **kwargs, 92 | ): 93 | return await super().request_data( 94 | url, 95 | params, 96 | data, 97 | method, 98 | headers, 99 | encryption, 100 | finished, 101 | *args, 102 | **kwargs, 103 | ) 104 | 105 | 106 | async def test(): 107 | from src.testers import Params 108 | 109 | async with Params() as params: 110 | c = Collection( 111 | params, 112 | pages=1, 113 | ) 114 | print(await c.run()) 115 | 116 | 117 | if __name__ == "__main__": 118 | from asyncio import run 119 | 120 | run(test()) 121 | -------------------------------------------------------------------------------- /src/interface/comment_tiktok.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from typing import Union 3 | 4 | from src.interface.comment import Comment, Reply 5 | from src.interface.template import APITikTok 6 | from src.translation import _ 7 | 8 | if TYPE_CHECKING: 9 | from src.config import Parameter 10 | from src.testers import Params 11 | 12 | 13 | class CommentTikTok(Comment, APITikTok): 14 | def __init__( 15 | self, 16 | params: Union["Parameter", "Params"], 17 | cookie: str = "", 18 | proxy: str = None, 19 | detail_id: str = ..., 20 | pages: int = None, 21 | cursor=0, 22 | count=20, 23 | count_reply=3, 24 | ): 25 | super().__init__( 26 | params, cookie, proxy, detail_id, pages, cursor, count, count_reply 27 | ) 28 | self.api = f"{self.domain}api/comment/list/" 29 | self.text = _("作品评论") 30 | 31 | def generate_params( 32 | self, 33 | ) -> dict: 34 | return self.params | { 35 | "aweme_id": self.item_id, 36 | "count": self.count, 37 | "cursor": self.cursor, 38 | "enter_from": "tiktok_web", 39 | "is_non_personalized": "false", 40 | "fromWeb": "1", 41 | "from_page": "video", 42 | } 43 | 44 | 45 | class ReplyTikTok(Reply, CommentTikTok, APITikTok): 46 | def __init__( 47 | self, 48 | params: Union["Parameter", "Params"], 49 | cookie: str = "", 50 | proxy: str = None, 51 | detail_id: str = "", 52 | comment_id: str = "", 53 | pages: int = None, 54 | cursor=0, 55 | count=3, 56 | progress=None, 57 | task_id=None, 58 | ): 59 | super().__init__( 60 | params, 61 | cookie, 62 | proxy, 63 | detail_id, 64 | comment_id, 65 | pages, 66 | cursor, 67 | count, 68 | progress, 69 | task_id, 70 | ) 71 | self.api = f"{self.domain}api/comment/list/reply/" 72 | 73 | def generate_params( 74 | self, 75 | ) -> dict: 76 | return self.params | { 77 | "comment_id": self.comment_id, 78 | "count": self.count, 79 | "cursor": self.cursor, 80 | "fromWeb": "1", 81 | "from_page": "video", 82 | "item_id": self.item_id, 83 | } 84 | 85 | 86 | async def test(): 87 | from src.testers import Params 88 | 89 | async with Params() as params: 90 | i = CommentTikTok( 91 | params, 92 | detail_id="", 93 | ) 94 | print(await i.run()) 95 | i = ReplyTikTok( 96 | params, 97 | detail_id="", 98 | comment_id="", 99 | ) 100 | print(await i.run()) 101 | 102 | 103 | if __name__ == "__main__": 104 | from asyncio import run 105 | 106 | run(test()) 107 | -------------------------------------------------------------------------------- /src/interface/detail.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from typing import TYPE_CHECKING 3 | from typing import Union 4 | 5 | from src.interface.template import API 6 | from src.translation import _ 7 | 8 | if TYPE_CHECKING: 9 | from src.config import Parameter 10 | from src.testers import Params 11 | 12 | 13 | class Detail(API): 14 | def __init__( 15 | self, 16 | params: Union["Parameter", "Params"], 17 | cookie: str = "", 18 | proxy: str = None, 19 | detail_id: str = ..., 20 | ): 21 | super().__init__(params, cookie, proxy) 22 | self.detail_id = detail_id 23 | self.api = f"{self.domain}aweme/v1/web/aweme/detail/" 24 | self.text = _("作品") 25 | 26 | def generate_params( 27 | self, 28 | ) -> dict: 29 | return self.params | { 30 | "aweme_id": self.detail_id, 31 | "version_code": "190500", 32 | "version_name": "19.5.0", 33 | } 34 | 35 | async def run( 36 | self, 37 | referer: str = None, 38 | single_page=True, 39 | data_key: str = "aweme_detail", 40 | error_text="", 41 | cursor="cursor", 42 | has_more="has_more", 43 | params: Callable = lambda: {}, 44 | data: Callable = lambda: {}, 45 | method="GET", 46 | headers: dict = None, 47 | *args, 48 | **kwargs, 49 | ): 50 | return await super().run( 51 | referer, 52 | single_page, 53 | data_key, 54 | error_text, 55 | cursor, 56 | has_more, 57 | params, 58 | data, 59 | method, 60 | headers, 61 | *args, 62 | **kwargs, 63 | ) 64 | 65 | def check_response( 66 | self, 67 | data_dict: dict, 68 | data_key: str, 69 | error_text="", 70 | cursor="cursor", 71 | has_more="has_more", 72 | *args, 73 | **kwargs, 74 | ): 75 | try: 76 | if not (d := data_dict[data_key]): 77 | self.log.warning(error_text) 78 | else: 79 | self.response = d 80 | except KeyError: 81 | self.log.error( 82 | _("数据解析失败,请告知作者处理: {data}").format(data=data_dict) 83 | ) 84 | 85 | 86 | async def test(): 87 | from src.testers import Params 88 | 89 | async with Params() as params: 90 | i = Detail( 91 | params, 92 | detail_id="", 93 | ) 94 | print(await i.run()) 95 | 96 | 97 | if __name__ == "__main__": 98 | from asyncio import run 99 | 100 | run(test()) 101 | -------------------------------------------------------------------------------- /src/interface/detail_tiktok.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from typing import TYPE_CHECKING 3 | from typing import Union 4 | 5 | from src.interface.template import APITikTok 6 | from src.translation import _ 7 | 8 | if TYPE_CHECKING: 9 | from src.config import Parameter 10 | from src.testers import Params 11 | 12 | 13 | class DetailTikTok(APITikTok): 14 | def __init__( 15 | self, 16 | params: Union["Parameter", "Params"], 17 | cookie: str = "", 18 | proxy: str = None, 19 | detail_id: str = ..., 20 | ): 21 | super().__init__(params, cookie, proxy) 22 | self.detail_id = detail_id 23 | self.api = f"{self.domain}/api/item/detail/" 24 | self.text = _("作品") 25 | 26 | def generate_params( 27 | self, 28 | ) -> dict: 29 | return self.params | { 30 | "itemId": self.detail_id, 31 | } 32 | 33 | async def run( 34 | self, 35 | referer: str = None, 36 | single_page=True, 37 | data_key: str = None, 38 | error_text="", 39 | cursor=None, 40 | has_more=None, 41 | params: Callable = lambda: {}, 42 | data: Callable = lambda: {}, 43 | method="GET", 44 | headers: dict = None, 45 | *args, 46 | **kwargs, 47 | ): 48 | return await super().run( 49 | referer, 50 | single_page, 51 | data_key, 52 | error_text, 53 | cursor, 54 | has_more, 55 | params, 56 | data, 57 | method, 58 | headers, 59 | *args, 60 | **kwargs, 61 | ) 62 | 63 | def check_response( 64 | self, 65 | data_dict: dict, 66 | data_key: str = None, 67 | error_text="", 68 | cursor=None, 69 | has_more=None, 70 | *args, 71 | **kwargs, 72 | ): 73 | try: 74 | if not (d := data_dict["itemInfo"]["itemStruct"]): 75 | self.log.info(error_text) 76 | else: 77 | self.response = d 78 | except KeyError: 79 | self.log.error( 80 | _("数据解析失败,请告知作者处理: {data}").format(data=data_dict) 81 | ) 82 | 83 | 84 | async def test(): 85 | from src.testers import Params 86 | 87 | async with Params() as params: 88 | i = DetailTikTok( 89 | params, 90 | detail_id="", 91 | ) 92 | print(await i.run()) 93 | 94 | 95 | if __name__ == "__main__": 96 | from asyncio import run 97 | 98 | run(test()) 99 | -------------------------------------------------------------------------------- /src/interface/hashtag.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from typing import Union 3 | 4 | from src.interface.template import API 5 | 6 | # from src.translation import _ 7 | 8 | if TYPE_CHECKING: 9 | from src.config import Parameter 10 | from src.testers import Params 11 | 12 | 13 | class HashTag(API): 14 | def __init__( 15 | self, 16 | params: Union["Parameter", "Params"], 17 | cookie: str = "", 18 | proxy: str = None, 19 | *args, 20 | **kwargs, 21 | ): 22 | super().__init__(params, cookie, proxy, *args, **kwargs) 23 | 24 | async def run(self, *args, **kwargs): 25 | pass 26 | 27 | 28 | async def test(): 29 | from src.testers import Params 30 | 31 | async with Params() as params: 32 | i = HashTag( 33 | params, 34 | ) 35 | print(await i.run()) 36 | 37 | 38 | if __name__ == "__main__": 39 | from asyncio import run 40 | 41 | run(test()) 42 | -------------------------------------------------------------------------------- /src/interface/hot.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from types import SimpleNamespace 3 | from typing import Callable 4 | from typing import TYPE_CHECKING 5 | from typing import Union 6 | 7 | from src.interface.template import API 8 | from src.translation import _ 9 | 10 | if TYPE_CHECKING: 11 | from src.config import Parameter 12 | from src.testers import Params 13 | 14 | 15 | class Hot(API): 16 | board_params = ( 17 | SimpleNamespace( 18 | name=_("抖音热榜"), 19 | type=0, 20 | sub_type="", 21 | ), 22 | SimpleNamespace( 23 | name=_("娱乐榜"), 24 | type=2, 25 | sub_type=2, 26 | ), 27 | SimpleNamespace( 28 | name=_("社会榜"), 29 | type=2, 30 | sub_type=4, 31 | ), 32 | SimpleNamespace( 33 | name=_("挑战榜"), 34 | type=2, 35 | sub_type="hotspot_challenge", 36 | ), 37 | ) 38 | 39 | def __init__( 40 | self, 41 | params: Union["Parameter", "Params"], 42 | cookie: str = "", 43 | proxy: str = None, 44 | *args, 45 | **kwargs, 46 | ): 47 | super().__init__(params, cookie, proxy, *args, **kwargs) 48 | self.headers = self.headers | { 49 | "Cookie": "", 50 | } 51 | self.api = f"{self.domain}aweme/v1/web/hot/search/list/" 52 | self.text = _("热榜") 53 | self.index = None 54 | self.time = None 55 | 56 | def generate_params( 57 | self, 58 | ) -> dict: 59 | return self.params | { 60 | "detail_list": "1", 61 | "source": "6", 62 | "board_type": self.board_params[self.index].type, 63 | "board_sub_type": self.board_params[self.index].sub_type, 64 | "version_code": "170400", 65 | "version_name": "17.4.0", 66 | } 67 | 68 | async def run( 69 | self, 70 | referer: str = "https://www.douyin.com/discover", 71 | single_page=True, 72 | data_key: str = None, 73 | error_text=None, 74 | cursor=None, 75 | has_more=None, 76 | params: Callable = lambda: {}, 77 | data: Callable = lambda: {}, 78 | method="GET", 79 | headers: dict = None, 80 | *args, 81 | **kwargs, 82 | ): 83 | self.time = f"{datetime.now():%Y_%m_%d_%H_%M_%S}" 84 | self.set_referer(referer) 85 | for index, space in enumerate(self.board_params): 86 | self.index = index 87 | self.text = _("{space_name}数据").format(space_name=space.name) 88 | await self.run_single( 89 | data_key, 90 | "", 91 | cursor, 92 | has_more, 93 | params=self.generate_params, 94 | data=data, 95 | method=method, 96 | headers=headers, 97 | index=index, 98 | *args, 99 | **kwargs, 100 | ) 101 | return self.time, self.response 102 | 103 | def check_response( 104 | self, 105 | data_dict: dict, 106 | data_key: str = None, 107 | error_text=None, 108 | cursor=None, 109 | has_more=None, 110 | index: int = None, 111 | *args, 112 | **kwargs, 113 | ): 114 | try: 115 | if not (d := data_dict["data"]["word_list"]): 116 | self.log.info(error_text) 117 | else: 118 | self.response.append((index, d)) 119 | except KeyError: 120 | self.log.error( 121 | _("数据解析失败,请告知作者处理: {data}").format(data=data_dict) 122 | ) 123 | 124 | 125 | async def test(): 126 | from src.testers import Params 127 | 128 | async with Params() as params: 129 | i = Hot( 130 | params, 131 | ) 132 | print(await i.run()) 133 | 134 | 135 | if __name__ == "__main__": 136 | from asyncio import run 137 | 138 | run(test()) 139 | -------------------------------------------------------------------------------- /src/interface/info.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from typing import Union 3 | 4 | from src.interface.template import API 5 | from src.translation import _ 6 | 7 | if TYPE_CHECKING: 8 | from src.config import Parameter 9 | from src.testers import Params 10 | 11 | 12 | class Info(API): 13 | def __init__( 14 | self, 15 | params: Union["Parameter", "Params"], 16 | cookie: str = "", 17 | proxy: str = None, 18 | sec_user_id: Union[str, list[str], tuple[str]] = ..., 19 | *args, 20 | **kwargs, 21 | ): 22 | super().__init__(params, cookie, proxy, *args, **kwargs) 23 | self.api = f"{self.domain}aweme/v1/web/im/user/info/" 24 | self.sec_user_id = sec_user_id 25 | self.static_params = self.params | { 26 | "version_code": "170400", 27 | "version_name": "17.4.0", 28 | } 29 | self.text = _("账号简略") 30 | 31 | async def run( 32 | self, 33 | first=True, 34 | *args, 35 | **kwargs, 36 | ) -> dict | list[dict]: 37 | self.set_referer() 38 | await self.run_single() 39 | if first: 40 | return self.response[0] if self.response else {} 41 | return self.response 42 | 43 | async def run_single( 44 | self, 45 | *args, 46 | **kwargs, 47 | ): 48 | await super().run_single( 49 | "", 50 | params=lambda: self.static_params, 51 | data=self.__generate_data, 52 | method="POST", 53 | ) 54 | 55 | def check_response( 56 | self, 57 | data_dict: dict, 58 | *args, 59 | **kwargs, 60 | ): 61 | if d := data_dict.get("data"): 62 | self.append_response(d) 63 | else: 64 | self.log.warning(_("获取{text}失败").format(text=self.text)) 65 | 66 | def __generate_data( 67 | self, 68 | ) -> dict: 69 | if isinstance(self.sec_user_id, str): 70 | self.sec_user_id = [self.sec_user_id] 71 | value = f"[{','.join(f'"{i}"' for i in self.sec_user_id)}]" 72 | return { 73 | "sec_user_ids": value, 74 | } 75 | 76 | 77 | async def test(): 78 | from src.testers import Params 79 | 80 | async with Params() as params: 81 | i = Info( 82 | params, 83 | sec_user_id="", 84 | ) 85 | print(await i.run()) 86 | 87 | 88 | if __name__ == "__main__": 89 | from asyncio import run 90 | 91 | run(test()) 92 | -------------------------------------------------------------------------------- /src/interface/info_tiktok.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from typing import Union 3 | 4 | from src.interface.template import APITikTok 5 | from src.translation import _ 6 | 7 | if TYPE_CHECKING: 8 | from src.config import Parameter 9 | from src.testers import Params 10 | 11 | 12 | class InfoTikTok(APITikTok): 13 | def __init__( 14 | self, 15 | params: Union["Parameter", "Params"], 16 | cookie: str = "", 17 | proxy: str = None, 18 | unique_id: Union[str] = "", 19 | sec_user_id: Union[str] = "", 20 | *args, 21 | **kwargs, 22 | ): 23 | super().__init__(params, cookie, proxy, *args, **kwargs) 24 | self.api = f"{self.domain}api/user/detail/" 25 | self.unique_id = unique_id 26 | self.sec_user_id = sec_user_id 27 | self.text = _("账号简略") 28 | 29 | async def run( 30 | self, 31 | # first=True, 32 | *args, 33 | **kwargs, 34 | ) -> dict | list[dict]: 35 | self.set_referer() 36 | await self.run_single() 37 | return self.response[0] if self.response else {} 38 | 39 | async def run_single( 40 | self, 41 | *args, 42 | **kwargs, 43 | ): 44 | await super().run_single( 45 | "", 46 | ) 47 | 48 | def check_response( 49 | self, 50 | data_dict: dict, 51 | *args, 52 | **kwargs, 53 | ): 54 | if d := data_dict.get("userInfo"): 55 | self.append_response(d) 56 | else: 57 | self.log.warning(_("获取{text}失败").format(text=self.text)) 58 | 59 | def append_response( 60 | self, 61 | data: dict, 62 | *args, 63 | **kwargs, 64 | ) -> None: 65 | self.response.append(data) 66 | 67 | def generate_params( 68 | self, 69 | ) -> dict: 70 | return self.params | { 71 | "abTestVersion": "[object Object]", 72 | "appType": "t", 73 | "secUid": self.sec_user_id, 74 | "uniqueId": self.unique_id, 75 | "user": "[object Object]", 76 | } 77 | 78 | 79 | async def test(): 80 | from src.testers import Params 81 | 82 | async with Params() as params: 83 | i = InfoTikTok( 84 | params, 85 | unique_id="", 86 | sec_user_id="", 87 | ) 88 | print(await i.run()) 89 | 90 | 91 | if __name__ == "__main__": 92 | from asyncio import run 93 | 94 | run(test()) 95 | -------------------------------------------------------------------------------- /src/interface/live.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from typing import Union 3 | 4 | from src.interface.template import API 5 | from src.tools import DownloaderError 6 | 7 | if TYPE_CHECKING: 8 | from src.config import Parameter 9 | from src.testers import Params 10 | 11 | 12 | class Live(API): 13 | live_api = "https://live.douyin.com/webcast/room/web/enter/" 14 | live_api_share = "https://webcast.amemv.com/webcast/room/reflow/info/" 15 | 16 | def __init__( 17 | self, 18 | params: Union["Parameter", "Params"], 19 | cookie: str = "", 20 | proxy: str = None, 21 | web_rid=None, 22 | room_id=None, 23 | sec_user_id=None, 24 | ): 25 | super().__init__(params, cookie, proxy) 26 | self.black_headers = params.headers_download 27 | self.web_rid = web_rid 28 | self.room_id = room_id 29 | self.sec_user_id = sec_user_id 30 | 31 | async def run( 32 | self, 33 | *args, 34 | **kwargs, 35 | ) -> dict: 36 | if self.web_rid: 37 | return await self.with_web_rid() 38 | elif self.room_id and self.sec_user_id: 39 | return await self.with_room_id() 40 | else: 41 | raise DownloaderError 42 | 43 | async def with_web_rid(self) -> dict: 44 | self.set_referer("https://live.douyin.com/") 45 | params = { 46 | "aid": "6383", 47 | "app_name": "douyin_web", 48 | "live_id": "1", 49 | "device_platform": "web", 50 | "language": "zh-CN", 51 | "enter_from": "link_share", 52 | "cookie_enabled": "true", 53 | "screen_width": "1536", 54 | "screen_height": "864", 55 | "browser_language": "zh-CN", 56 | "browser_platform": "Win32", 57 | "browser_name": "Edge", 58 | "browser_version": "125.0.0.0", 59 | "web_rid": self.web_rid, 60 | # "room_id_str": "", 61 | "enter_source": "", 62 | "is_need_double_stream": "false", 63 | "insert_task_id": "", 64 | "live_reason": "", 65 | } 66 | return await self.request_data( 67 | self.live_api, 68 | params, 69 | ) 70 | 71 | async def with_room_id(self) -> dict: 72 | params = { 73 | "type_id": "0", 74 | "live_id": "1", 75 | "room_id": self.room_id, 76 | "sec_user_id": self.sec_user_id, 77 | "version_code": "99.99.99", 78 | "app_id": "1128", 79 | } 80 | return await self.request_data( 81 | self.live_api_share, 82 | params, 83 | headers=self.black_headers, 84 | ) 85 | 86 | 87 | async def test(): 88 | from src.testers import Params 89 | 90 | async with Params() as params: 91 | i = Live( 92 | params, 93 | web_rid="", 94 | room_id="", 95 | ) 96 | print(await i.run()) 97 | 98 | 99 | if __name__ == "__main__": 100 | from asyncio import run 101 | 102 | run(test()) 103 | -------------------------------------------------------------------------------- /src/interface/live_tiktok.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | from typing import Union 3 | 4 | from src.interface.template import APITikTok 5 | from src.translation import _ 6 | 7 | if TYPE_CHECKING: 8 | from ..config import Parameter 9 | from src.testers import Params 10 | 11 | 12 | class LiveTikTok(APITikTok): 13 | live_api = "https://webcast.us.tiktok.com/webcast/room/enter/" 14 | 15 | def __init__( 16 | self, 17 | params: Union["Parameter", "Params"], 18 | cookie: str = "", 19 | proxy: str = None, 20 | room_id: str = ..., 21 | ): 22 | super().__init__(params, cookie, proxy) 23 | self.black_headers = params.headers_download 24 | self.room_id = room_id 25 | 26 | async def run( 27 | self, 28 | *args, 29 | **kwargs, 30 | ) -> dict: 31 | response = await self.with_room_id() 32 | return self.check_response(response) 33 | 34 | async def with_room_id(self) -> dict: 35 | return await self.request_data( 36 | self.live_api, 37 | self.params, 38 | method="POST", 39 | data=self.__generate_room_id_data(), 40 | ) 41 | 42 | def __generate_room_id_data( 43 | self, 44 | ) -> dict: 45 | return { 46 | "enter_source": "others-others", 47 | "room_id": self.room_id, 48 | } 49 | 50 | def check_response( 51 | self, 52 | data_dict: dict, 53 | *args, 54 | **kwargs, 55 | ): 56 | if data_dict and "prompt" in data_dict["data"]: 57 | self.console.warning(_("此直播可能会令部分观众感到不适,请登录后重试!")) 58 | return {} 59 | return data_dict 60 | 61 | 62 | async def test(): 63 | from src.testers import Params 64 | 65 | async with Params() as params: 66 | i = LiveTikTok( 67 | params, 68 | room_id="", 69 | ) 70 | print(await i.run()) 71 | 72 | 73 | if __name__ == "__main__": 74 | from asyncio import run 75 | 76 | run(test()) 77 | -------------------------------------------------------------------------------- /src/interface/mix.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from typing import TYPE_CHECKING 3 | from typing import Union 4 | 5 | from src.extract import Extractor 6 | from src.interface.detail import Detail 7 | from src.interface.template import API 8 | from src.translation import _ 9 | 10 | if TYPE_CHECKING: 11 | from src.config import Parameter 12 | from src.testers import Params 13 | 14 | 15 | class Mix(API): 16 | def __init__( 17 | self, 18 | params: Union["Parameter", "Params"], 19 | cookie: str = "", 20 | proxy: str = None, 21 | mix_id: str = None, 22 | detail_id: str = None, 23 | cursor=0, 24 | count=12, 25 | *args, 26 | **kwargs, 27 | ): 28 | super().__init__(params, cookie, proxy, *args, **kwargs) 29 | self.mix_title = None 30 | self.mix_id = mix_id 31 | self.detail_id = detail_id 32 | self.count = count 33 | self.cursor = cursor 34 | self.api = f"{self.domain}aweme/v1/web/mix/aweme/" 35 | self.text = _("合集作品") 36 | self.detail = Detail( 37 | params, 38 | cookie, 39 | proxy, 40 | self.detail_id, 41 | ) 42 | 43 | def generate_params( 44 | self, 45 | ) -> dict: 46 | return self.params | { 47 | "mix_id": self.mix_id, 48 | "cursor": self.cursor, 49 | "count": self.count, 50 | "version_code": "170400", 51 | "version_name": "17.4.0", 52 | } 53 | 54 | async def run( 55 | self, 56 | referer: str = None, 57 | single_page=False, 58 | data_key: str = "aweme_list", 59 | error_text="", 60 | cursor="cursor", 61 | has_more="has_more", 62 | params: Callable = lambda: {}, 63 | data: Callable = lambda: {}, 64 | method="GET", 65 | headers: dict = None, 66 | *args, 67 | **kwargs, 68 | ): 69 | await self.__get_mix_id() 70 | if not self.mix_id: 71 | self.log.warning(_("获取合集 ID 失败")) 72 | return self.response 73 | return await super().run( 74 | referer, 75 | single_page, 76 | data_key, 77 | error_text, 78 | cursor, 79 | has_more, 80 | params, 81 | data, 82 | method, 83 | headers, 84 | *args, 85 | **kwargs, 86 | ) 87 | 88 | async def __get_mix_id(self): 89 | if not self.mix_id: 90 | self.mix_id = Extractor.extract_mix_id(await self.detail.run()) 91 | 92 | 93 | async def test(): 94 | from src.testers import Params 95 | 96 | async with Params() as params: 97 | i = Mix( 98 | params, 99 | mix_id="", 100 | detail_id="", 101 | ) 102 | print(await i.run()) 103 | 104 | 105 | if __name__ == "__main__": 106 | from asyncio import run 107 | 108 | run(test()) 109 | -------------------------------------------------------------------------------- /src/interface/mix_tiktok.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | from typing import TYPE_CHECKING 3 | from typing import Union 4 | 5 | from src.interface.template import APITikTok 6 | from src.translation import _ 7 | 8 | if TYPE_CHECKING: 9 | from src.config import Parameter 10 | from src.testers import Params 11 | 12 | 13 | class MixTikTok(APITikTok): 14 | def __init__( 15 | self, 16 | params: Union["Parameter", "Params"], 17 | cookie: str = "", 18 | proxy: str = None, 19 | mix_title: str = ..., 20 | mix_id: str = ..., 21 | # detail_id: str = None, 22 | cursor=0, 23 | count=30, 24 | *args, 25 | **kwargs, 26 | ): 27 | super().__init__(params, cookie, proxy, *args, **kwargs) 28 | self.mix_title = mix_title 29 | self.mix_id = mix_id 30 | # self.detail_id = detail_id # 未使用 31 | self.cursor = cursor 32 | self.count = count 33 | self.api = f"{self.domain}api/mix/item_list/" 34 | self.text = _("合辑作品") 35 | 36 | def generate_params( 37 | self, 38 | ) -> dict: 39 | return self.params | { 40 | "count": self.count, 41 | "cursor": self.cursor, 42 | "mixId": self.mix_id, 43 | } 44 | 45 | async def run( 46 | self, 47 | referer: str = None, 48 | single_page=False, 49 | data_key: str = "itemList", 50 | error_text="", 51 | cursor="cursor", 52 | has_more="hasMore", 53 | params: Callable = lambda: {}, 54 | data: Callable = lambda: {}, 55 | method="GET", 56 | headers: dict = None, 57 | *args, 58 | **kwargs, 59 | ): 60 | return await super().run( 61 | referer, 62 | single_page, 63 | data_key, 64 | error_text, 65 | cursor, 66 | has_more, 67 | params, 68 | data, 69 | method, 70 | headers, 71 | *args, 72 | **kwargs, 73 | ) 74 | 75 | 76 | class MixListTikTok(APITikTok): 77 | def __init__( 78 | self, 79 | params: Union["Parameter", "Params"], 80 | cookie: str = "", 81 | proxy: str = None, 82 | sec_user_id: str = "", 83 | cursor=0, 84 | count=20, 85 | *args, 86 | **kwargs, 87 | ): 88 | super().__init__(params, cookie, proxy, *args, **kwargs) 89 | self.sec_user_id = sec_user_id 90 | self.cursor = cursor 91 | self.count = count 92 | self.api = f"{self.domain}api/user/playlist/" 93 | self.text = _("账号合辑数据") 94 | 95 | def generate_params( 96 | self, 97 | ) -> dict: 98 | return self.params | { 99 | "count": self.count, 100 | "cursor": self.cursor, 101 | "secUid": self.sec_user_id, 102 | } 103 | 104 | async def run( 105 | self, 106 | referer: str = None, 107 | single_page=False, 108 | data_key: str = "playList", 109 | error_text="", 110 | cursor="cursor", 111 | has_more="hasMore", 112 | params: Callable = lambda: {}, 113 | data: Callable = lambda: {}, 114 | method="GET", 115 | headers: dict = None, 116 | *args, 117 | **kwargs, 118 | ): 119 | return await super().run( 120 | referer, 121 | single_page, 122 | data_key, 123 | error_text, 124 | cursor, 125 | has_more, 126 | params, 127 | data, 128 | method, 129 | headers, 130 | *args, 131 | **kwargs, 132 | ) 133 | 134 | 135 | async def test(): 136 | from src.testers import Params 137 | 138 | async with Params() as params: 139 | i = MixTikTok( 140 | params, 141 | mix_id="", 142 | ) 143 | print(await i.run()) 144 | 145 | 146 | if __name__ == "__main__": 147 | from asyncio import run 148 | 149 | run(test()) 150 | -------------------------------------------------------------------------------- /src/interface/slides.py: -------------------------------------------------------------------------------- 1 | # from typing import Callable 2 | from typing import TYPE_CHECKING 3 | from typing import Union 4 | 5 | from src.interface.template import API 6 | from src.translation import _ 7 | 8 | if TYPE_CHECKING: 9 | from src.config import Parameter 10 | from src.testers import Params 11 | 12 | __all__ = ["Slides"] 13 | 14 | 15 | class Slides(API): 16 | def __init__( 17 | self, 18 | params: Union["Parameter", "Params"], 19 | cookie: str = "", 20 | proxy: str = None, 21 | slides_id: str | list | tuple = ..., 22 | ): 23 | super().__init__(params, cookie, proxy) 24 | self.slides_id = slides_id 25 | self.api = f"{self.short_domain}web/api/v2/aweme/slidesinfo/" 26 | self.text = _("作品") 27 | 28 | async def run(self, *args, **kwargs): 29 | pass 30 | 31 | 32 | async def test(): 33 | from src.testers import Params 34 | 35 | async with Params() as params: 36 | i = Slides( 37 | params, 38 | slides_id="", 39 | ) 40 | print(await i.run()) 41 | 42 | 43 | if __name__ == "__main__": 44 | from asyncio import run 45 | 46 | run(test()) 47 | -------------------------------------------------------------------------------- /src/interface/user.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Callable, Type, Coroutine 2 | from typing import Union 3 | 4 | from src.interface.template import API 5 | from src.translation import _ 6 | 7 | if TYPE_CHECKING: 8 | from src.config import Parameter 9 | from src.testers import Params 10 | 11 | 12 | class User(API): 13 | def __init__( 14 | self, 15 | params: Union["Parameter", "Params"], 16 | cookie: str = "", 17 | proxy: str = None, 18 | sec_user_id: str = ..., 19 | *args, 20 | **kwargs, 21 | ): 22 | super().__init__(params, cookie, proxy, *args, **kwargs) 23 | self.sec_user_id = sec_user_id 24 | self.api = f"{self.domain}aweme/v1/web/user/profile/other/" 25 | self.text = _("账号") 26 | 27 | async def run(self, *args, **kwargs): 28 | return await super().run( 29 | single_page=True, 30 | data_key="user", 31 | ) 32 | 33 | async def run_batch( 34 | self, 35 | data_key: str, 36 | error_text="", 37 | cursor="cursor", 38 | has_more="has_more", 39 | params: Callable = lambda: {}, 40 | data: Callable = lambda: {}, 41 | method="GET", 42 | headers: dict = None, 43 | callback: Type[Coroutine] = None, 44 | *args, 45 | **kwargs, 46 | ): 47 | pass 48 | 49 | def check_response( 50 | self, 51 | data_dict: dict, 52 | data_key: str, 53 | error_text="", 54 | *args, 55 | **kwargs, 56 | ): 57 | try: 58 | if not (d := data_dict[data_key]): 59 | self.log.warning(error_text) 60 | else: 61 | self.response = d 62 | except KeyError: 63 | self.log.error( 64 | _("数据解析失败,请告知作者处理: {data}").format(data=data_dict) 65 | ) 66 | self.finished = True 67 | 68 | def generate_params( 69 | self, 70 | ) -> dict: 71 | return self.params | { 72 | "publish_video_strategy_type": "2", 73 | "sec_user_id": self.sec_user_id, 74 | "personal_center_strategy": "1", 75 | "profile_other_record_enable": "1", 76 | "land_to": "1", 77 | "version_code": "170400", 78 | "version_name": "17.4.0", 79 | } 80 | 81 | 82 | async def test(): 83 | from src.testers import Params 84 | 85 | async with Params() as params: 86 | i = User( 87 | params, 88 | sec_user_id="", 89 | ) 90 | print(await i.run()) 91 | 92 | 93 | if __name__ == "__main__": 94 | from asyncio import run 95 | 96 | run(test()) 97 | -------------------------------------------------------------------------------- /src/link/__init__.py: -------------------------------------------------------------------------------- 1 | from .extractor import Extractor, ExtractorTikTok 2 | 3 | __all__ = [ 4 | "Extractor", 5 | "ExtractorTikTok", 6 | ] 7 | -------------------------------------------------------------------------------- /src/link/extractor.py: -------------------------------------------------------------------------------- 1 | from re import compile 2 | from typing import TYPE_CHECKING, Union 3 | from urllib.parse import parse_qs, unquote, urlparse 4 | 5 | from .requester import Requester 6 | 7 | if TYPE_CHECKING: 8 | from src.config import Parameter 9 | 10 | __all__ = ["Extractor", "ExtractorTikTok"] 11 | 12 | 13 | class Extractor: 14 | account_link = compile( 15 | r"\S*?https://www\.douyin\.com/user/([A-Za-z0-9_-]+)(?:\S*?\bmodal_id=(\d{19}))?" 16 | ) # 账号主页链接 17 | account_share = compile( 18 | r"\S*?https://www\.iesdouyin\.com/share/user/(\S*?)\?\S*?" # 账号主页分享链接 19 | ) 20 | 21 | detail_id = compile(r"\b(\d{19})\b") # 作品 ID 22 | detail_link = compile( 23 | r"\S*?https://www\.douyin\.com/(?:video|note|slides)/([0-9]{19})\S*?" 24 | ) # 作品链接 25 | detail_share = compile( 26 | r"\S*?https://www\.iesdouyin\.com/share/(?:video|note|slides)/([0-9]{19})/\S*?" 27 | ) # 作品分享链接 28 | detail_search = compile( 29 | r"\S*?https://www\.douyin\.com/search/\S+?modal_id=(\d{19})\S*?" 30 | ) # 搜索作品链接 31 | detail_discover = compile( 32 | r"\S*?https://www\.douyin\.com/discover\S*?modal_id=(\d{19})\S*?" 33 | ) # 首页作品链接 34 | 35 | mix_link = compile( 36 | r"\S*?https://www\.douyin\.com/collection/(\d{19})\S*?" 37 | ) # 合集链接 38 | mix_share = compile( 39 | r"\S*?https://www\.iesdouyin\.com/share/mix/detail/(\d{19})/\S*?" 40 | ) # 合集分享链接 41 | 42 | live_link = compile(r"\S*?https://live\.douyin\.com/([0-9]+)\S*?") # 直播链接 43 | live_link_self = compile(r"\S*?https://www\.douyin\.com/follow\?webRid=(\d+)\S*?") 44 | live_link_share = compile( 45 | r"\S*?https://webcast\.amemv\.com/douyin/webcast/reflow/\S+" 46 | ) 47 | 48 | channel_link = compile( 49 | r"\S*?https://www\.douyin\.com/channel/\d+?\?modal_id=(\d{19})\S*?" 50 | ) 51 | 52 | def __init__( 53 | self, 54 | params: "Parameter", 55 | tiktok=False, 56 | ): 57 | self.client = params.client_tiktok if tiktok else params.client 58 | self.requester = Requester( 59 | params, 60 | self.client, 61 | ) 62 | 63 | async def run( 64 | self, 65 | text: str, 66 | type_="detail", 67 | proxy: str = None, 68 | ) -> Union[list[str], tuple[bool, list[str]], str]: 69 | text = await self.requester.run( 70 | text, 71 | proxy, 72 | ) 73 | match type_: 74 | case "detail": 75 | return self.detail(text) 76 | case "user": 77 | return self.user(text) 78 | case "mix": 79 | return self.mix(text) 80 | case "live": 81 | return self.live(text) 82 | case "": 83 | return text 84 | raise ValueError 85 | 86 | def detail( 87 | self, 88 | urls: str, 89 | ) -> list[str]: 90 | return self.__extract_detail(urls) 91 | 92 | def user( 93 | self, 94 | urls: str, 95 | ) -> list[str]: 96 | link = self.extract_info(self.account_link, urls, 1) 97 | share = self.extract_info(self.account_share, urls, 1) 98 | return link + share 99 | 100 | def mix( 101 | self, 102 | urls: str, 103 | ) -> [bool, list[str]]: 104 | if detail := self.__extract_detail(urls): 105 | return False, detail 106 | link = self.extract_info(self.mix_link, urls, 1) 107 | share = self.extract_info(self.mix_share, urls, 1) 108 | return (True, m) if (m := link + share) else (None, []) 109 | 110 | def live( 111 | self, 112 | urls: str, 113 | ) -> [bool, list]: 114 | live_link = self.extract_info(self.live_link, urls, 1) 115 | live_link_self = self.extract_info(self.live_link_self, urls, 1) 116 | if live := live_link + live_link_self: 117 | return True, live 118 | live_link_share = self.extract_info(self.live_link_share, urls, 0) 119 | return False, self.extract_sec_user_id(live_link_share) 120 | 121 | def __extract_detail( 122 | self, 123 | urls: str, 124 | ) -> list[str]: 125 | link = self.extract_info(self.detail_link, urls, 1) 126 | share = self.extract_info(self.detail_share, urls, 1) 127 | account = self.extract_info(self.account_link, urls, 2) 128 | search = self.extract_info(self.detail_search, urls, 1) 129 | discover = self.extract_info(self.detail_discover, urls, 1) 130 | channel = self.extract_info(self.channel_link, urls, 1) 131 | return link + share + account + search + discover + channel 132 | 133 | @staticmethod 134 | def extract_sec_user_id(urls: list[str]) -> list[list]: 135 | data = [] 136 | for url in urls: 137 | url = urlparse(url) 138 | query_params = parse_qs(url.query) 139 | data.append( 140 | [url.path.split("/")[-1], query_params.get("sec_user_id", [""])[0]] 141 | ) 142 | return data 143 | 144 | @staticmethod 145 | def extract_info(pattern, urls: str, index=1) -> list[str]: 146 | result = pattern.finditer(urls) 147 | return [i for i in (i.group(index) for i in result) if i] if result else [] 148 | 149 | 150 | class ExtractorTikTok(Extractor): 151 | SEC_UID = compile(r'"secUid":"([a-zA-Z0-9_-]+)"') 152 | ROOD_ID = compile(r'"roomId":"(\d+)"') 153 | MIX_ID = compile(r'"canonical":"\S+?(\d{19})"') 154 | 155 | account_link = compile(r"\S*?(https://www\.tiktok\.com/@[^\s/]+)\S*?") 156 | 157 | detail_link = compile( 158 | r"\S*?https://www\.tiktok\.com/@[^\s/]+(?:/(?:video|photo)/(\d{19}))?\S*?" 159 | ) # 作品链接 160 | 161 | mix_link = compile( 162 | r"\S*?https://www\.tiktok\.com/@\S+/(?:playlist|collection)/(.+?)-(\d{19})\S*?" 163 | ) # 合集链接 164 | 165 | live_link = compile(r"\S*?https://www\.tiktok\.com/@[^\s/]+/live\S*?") # 直播链接 166 | 167 | def __init__(self, params: "Parameter"): 168 | super().__init__( 169 | params, 170 | True, 171 | ) 172 | 173 | async def run( 174 | self, 175 | text: str, 176 | type_="detail", 177 | proxy: str = None, 178 | ) -> Union[ 179 | list[str], 180 | tuple[bool, list[str]], 181 | str, 182 | ]: 183 | text = await self.requester.run( 184 | text, 185 | proxy, 186 | ) 187 | match type_: 188 | case "detail": 189 | return await self.detail(text) 190 | case "user": 191 | return await self.user(text) 192 | case "mix": 193 | return await self.mix(text) 194 | case "live": 195 | return await self.live(text) 196 | case "": 197 | return text 198 | raise ValueError 199 | 200 | async def detail( 201 | self, 202 | urls: str, 203 | ) -> list[str]: 204 | return self.__extract_detail(urls) 205 | 206 | async def user( 207 | self, 208 | urls: str, 209 | ) -> list[str]: 210 | link = self.extract_info(self.account_link, urls, 1) 211 | link = [await self.__get_html_data(i, self.SEC_UID) for i in link] 212 | return [i for i in link if i] 213 | 214 | def __extract_detail( 215 | self, 216 | urls: str, 217 | index=1, 218 | ) -> list[str]: 219 | link = self.extract_info(self.detail_link, urls, index) 220 | return link 221 | 222 | async def __get_html_data( 223 | self, 224 | url: str, 225 | pattern, 226 | index=1, 227 | ) -> str: 228 | html = await self.requester.request_url( 229 | url, 230 | "text", 231 | ) 232 | return m.group(index) if (m := pattern.search(html or "")) else "" 233 | 234 | async def mix( 235 | self, 236 | urls: str, 237 | ) -> [bool, list[str]]: 238 | detail = self.__extract_detail(urls, index=0) 239 | detail = [await self.__get_html_data(i, self.MIX_ID) for i in detail] 240 | detail = [i for i in detail if i] 241 | mix = self.extract_info(self.mix_link, urls, 2) 242 | title = [unquote(i) for i in self.extract_info(self.mix_link, urls, 1)] 243 | return True, detail + mix, [None for _ in detail] + title 244 | 245 | async def live( 246 | self, 247 | urls: str, 248 | ) -> [bool, list[str]]: 249 | link = self.extract_info(self.live_link, urls, 0) 250 | link = [await self.__get_html_data(i, self.ROOD_ID) for i in link] 251 | return True, [i for i in link if i] 252 | -------------------------------------------------------------------------------- /src/link/requester.py: -------------------------------------------------------------------------------- 1 | from re import compile 2 | from typing import TYPE_CHECKING 3 | 4 | from ..custom import BLANK_HEADERS 5 | from ..custom import wait 6 | from ..tools import Retry, DownloaderError, capture_error_request 7 | 8 | if TYPE_CHECKING: 9 | from httpx import AsyncClient, get, head 10 | 11 | from ..config import Parameter 12 | 13 | __all__ = ["Requester"] 14 | 15 | 16 | class Requester: 17 | URL = compile(r"(https?://\S+)") 18 | HEADERS = BLANK_HEADERS 19 | 20 | def __init__( 21 | self, 22 | params: "Parameter", 23 | client: "AsyncClient", 24 | ): 25 | self.client = client 26 | self.log = params.logger 27 | self.max_retry = params.max_retry 28 | self.timeout = params.timeout 29 | 30 | async def run( 31 | self, 32 | text: str, 33 | proxy: str = None, 34 | ) -> str: 35 | urls = self.URL.finditer(text) 36 | if not urls: 37 | return "" 38 | result = [] 39 | for i in urls: 40 | result.append( 41 | await self.request_url( 42 | u := i.group(), 43 | proxy=proxy, 44 | ) 45 | or u 46 | ) 47 | await wait() 48 | return " ".join(i for i in result if i) 49 | 50 | @Retry.retry 51 | @capture_error_request 52 | async def request_url( 53 | self, 54 | url: str, 55 | content="url", 56 | proxy: str = None, 57 | ): 58 | self.log.info(f"URL: {url}", False) 59 | match (content in {"url", "headers"}, bool(proxy)): 60 | case True, True: 61 | response = self.request_url_head_proxy( 62 | url, 63 | proxy, 64 | ) 65 | case True, False: 66 | response = await self.request_url_head(url) 67 | case False, True: 68 | response = self.request_url_get_proxy( 69 | url, 70 | proxy, 71 | ) 72 | case False, False: 73 | response = await self.request_url_get(url) 74 | case _: 75 | raise DownloaderError 76 | self.log.info(f"Response URL: {response.url}", False) 77 | self.log.info(f"Response Code: {response.status_code}", False) 78 | # 记录请求体数据会导致日志文件体积过大,仅在必要时记录 79 | # self.log.info(f"Response Content: {response.content}", False) 80 | self.log.info(f"Response Headers: {dict(response.headers)}", False) 81 | match content: 82 | case "text": 83 | return response.text 84 | case "content": 85 | return response.content 86 | case "json": 87 | return response.json() 88 | case "headers": 89 | return response.headers 90 | case "url": 91 | return str(response.url) 92 | case _: 93 | raise DownloaderError 94 | 95 | async def request_url_head( 96 | self, 97 | url: str, 98 | ): 99 | return await self.client.head( 100 | url, 101 | ) 102 | 103 | def request_url_head_proxy( 104 | self, 105 | url: str, 106 | proxy: str, 107 | ): 108 | return head( 109 | url, 110 | headers=self.HEADERS, 111 | proxy=proxy, 112 | follow_redirects=True, 113 | verify=False, 114 | timeout=self.timeout, 115 | ) 116 | 117 | async def request_url_get( 118 | self, 119 | url: str, 120 | ): 121 | response = await self.client.get( 122 | url, 123 | ) 124 | response.raise_for_status() 125 | return response 126 | 127 | def request_url_get_proxy( 128 | self, 129 | url: str, 130 | proxy: str, 131 | ): 132 | response = get( 133 | url, 134 | headers=self.HEADERS, 135 | proxy=proxy, 136 | follow_redirects=True, 137 | verify=False, 138 | timeout=self.timeout, 139 | ) 140 | response.raise_for_status() 141 | return response 142 | -------------------------------------------------------------------------------- /src/manager/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache import Cache 2 | from .database import Database 3 | from .recorder import DownloadRecorder 4 | 5 | __all__ = [ 6 | "Cache", 7 | "DownloadRecorder", 8 | "Database", 9 | ] 10 | -------------------------------------------------------------------------------- /src/manager/cache.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING 3 | 4 | from ..tools import Retry 5 | from ..translation import _ 6 | 7 | if TYPE_CHECKING: 8 | from ..config import Parameter 9 | from .database import Database 10 | 11 | __all__ = ["Cache"] 12 | 13 | 14 | class Cache: 15 | def __init__( 16 | self, 17 | parameter: "Parameter", 18 | database: "Database", 19 | mark: bool, 20 | name: bool, 21 | ): 22 | self.console = parameter.console 23 | self.log = parameter.logger # 日志记录对象 24 | self.database = database 25 | self.root = parameter.root # 作品文件保存根目录 26 | self.mark = mark 27 | self.name = name 28 | 29 | async def update_cache( 30 | self, 31 | solo_mode: bool, 32 | prefix: str, 33 | suffix: str, 34 | id_: str, 35 | name: str, 36 | mark: str, 37 | ): 38 | if d := await self.has_cache(id_): 39 | self.__check_file( 40 | solo_mode, 41 | prefix, 42 | suffix, 43 | id_, 44 | name, 45 | mark, 46 | d, 47 | ) 48 | data = ( 49 | id_, 50 | name, 51 | mark, 52 | ) 53 | await self.database.update_mapping_data(*data) 54 | self.log.info(f"更新缓存数据: {', '.join(data)}", False) 55 | 56 | async def has_cache(self, id_: str) -> dict: 57 | return await self.database.read_mapping_data(id_) 58 | 59 | def __check_file( 60 | self, 61 | solo_mode: bool, 62 | prefix: str, 63 | suffix: str, 64 | id_: str, 65 | name: str, 66 | mark: str, 67 | data: dict, 68 | ): 69 | if not ( 70 | old_folder := self.root.joinpath( 71 | f"{prefix}{id_}_{data['mark'] or data['name']}_{suffix}" 72 | ) 73 | ).is_dir(): 74 | self.log.info(f"{old_folder} 文件夹不存在,自动跳过", False) 75 | return 76 | if data["mark"] != mark: 77 | self.__rename_folder(old_folder, prefix, suffix, id_, mark) 78 | if self.mark: 79 | self.__scan_file( 80 | solo_mode, 81 | prefix, 82 | suffix, 83 | id_, 84 | name, 85 | mark, 86 | key="mark", 87 | data=data, 88 | ) 89 | if data["name"] != name and self.name: 90 | self.__scan_file( 91 | solo_mode, 92 | prefix, 93 | suffix, 94 | id_, 95 | name, 96 | mark, 97 | data=data, 98 | ) 99 | 100 | def __rename_folder( 101 | self, 102 | old_folder: Path, 103 | prefix: str, 104 | suffix: str, 105 | id_: str, 106 | mark: str, 107 | ): 108 | new_folder = self.root.joinpath(f"{prefix}{id_}_{mark}_{suffix}") 109 | self.__rename( 110 | old_folder, 111 | new_folder, 112 | _("文件夹"), 113 | ) 114 | self.log.info(f"文件夹 {old_folder} 已重命名为 {new_folder}", False) 115 | 116 | def __rename_works_folder( 117 | self, 118 | old_: Path, 119 | mark: str, 120 | name: str, 121 | key: str, 122 | data: dict, 123 | ) -> Path: 124 | if (s := data[key]) in old_.name: 125 | new_ = old_.parent / old_.name.replace( 126 | s, {"name": name, "mark": mark}[key], 1 127 | ) 128 | self.__rename( 129 | old_, 130 | new_, 131 | _("文件夹"), 132 | ) 133 | self.log.info(f"文件夹 {old_} 重命名为 {new_}", False) 134 | return new_ 135 | return old_ 136 | 137 | def __scan_file( 138 | self, 139 | solo_mode: bool, 140 | prefix: str, 141 | suffix: str, 142 | id_: str, 143 | name: str, 144 | mark: str, 145 | data: dict, 146 | key="name", 147 | ): 148 | root = self.root.joinpath(f"{prefix}{id_}_{mark}_{suffix}") 149 | item_list = root.iterdir() 150 | if solo_mode: 151 | for f in item_list: 152 | if f.is_dir(): 153 | f = self.__rename_works_folder( 154 | f, 155 | mark, 156 | name, 157 | key, 158 | data, 159 | ) 160 | files = f.iterdir() 161 | self.__batch_rename( 162 | f, 163 | files, 164 | mark, 165 | name, 166 | key, 167 | data, 168 | ) 169 | else: 170 | self.__batch_rename( 171 | root, 172 | item_list, 173 | mark, 174 | name, 175 | key, 176 | data, 177 | ) 178 | 179 | def __batch_rename( 180 | self, 181 | root: Path, 182 | files, 183 | mark: str, 184 | name: str, 185 | key: str, 186 | data: dict, 187 | ): 188 | for old_file in files: 189 | if (s := data[key]) not in old_file.name: 190 | break 191 | self.__rename_file(root, old_file, s, mark, name, key) 192 | 193 | def __rename_file( 194 | self, 195 | root: Path, 196 | old_file: Path, 197 | keywords: str, 198 | mark: str, 199 | name: str, 200 | field: str, 201 | ): 202 | new_file = root.joinpath( 203 | old_file.name.replace(keywords, {"name": name, "mark": mark}[field], 1) 204 | ) 205 | self.__rename( 206 | old_file, 207 | new_file, 208 | _("文件"), 209 | ) 210 | self.log.info(f"文件 {old_file} 重命名为 {new_file}", False) 211 | return True 212 | 213 | @Retry.retry_limited 214 | def __rename( 215 | self, 216 | old_: Path, 217 | new_: Path, 218 | type_=_("文件"), 219 | ) -> bool: 220 | try: 221 | old_.rename(new_) 222 | return True 223 | except PermissionError as e: 224 | self.console.error( 225 | _("{type} {old}被占用,重命名失败: {error}").format( 226 | type=type_, old=old_, error=e 227 | ), 228 | ) 229 | return False 230 | except FileExistsError as e: 231 | self.console.error( 232 | _("{type} {new}名称重复,重命名失败: {error}").format( 233 | type=type_, new=new_, error=e 234 | ), 235 | ) 236 | return False 237 | except OSError as e: 238 | self.console.error( 239 | _("处理{type} {old}时发生预期之外的错误: {error}").format( 240 | type=type_, old=old_, error=e 241 | ), 242 | ) 243 | return True 244 | -------------------------------------------------------------------------------- /src/manager/database.py: -------------------------------------------------------------------------------- 1 | from asyncio import CancelledError 2 | from contextlib import suppress 3 | 4 | from aiosqlite import Row, connect 5 | 6 | from ..custom import PROJECT_ROOT 7 | 8 | __all__ = ["Database"] 9 | 10 | 11 | class Database: 12 | __FILE = "DouK-Downloader.db" 13 | 14 | def __init__( 15 | self, 16 | ): 17 | self.file = PROJECT_ROOT.joinpath(self.__FILE) 18 | self.database = None 19 | self.cursor = None 20 | 21 | async def __connect_database(self): 22 | self.database = await connect(self.file) 23 | self.database.row_factory = Row 24 | self.cursor = await self.database.cursor() 25 | await self.__create_table() 26 | await self.__write_default_config() 27 | await self.__write_default_option() 28 | await self.database.commit() 29 | 30 | async def __create_table(self): 31 | await self.database.execute( 32 | """CREATE TABLE IF NOT EXISTS config_data ( 33 | NAME TEXT PRIMARY KEY, 34 | VALUE INTEGER NOT NULL CHECK(VALUE IN (0, 1)) 35 | );""" 36 | ) 37 | await self.database.execute( 38 | "CREATE TABLE IF NOT EXISTS download_data (ID TEXT PRIMARY KEY);" 39 | ) 40 | await self.database.execute("""CREATE TABLE IF NOT EXISTS mapping_data ( 41 | ID TEXT PRIMARY KEY, 42 | NAME TEXT NOT NULL, 43 | MARK TEXT NOT NULL 44 | );""") 45 | await self.database.execute("""CREATE TABLE IF NOT EXISTS option_data ( 46 | NAME TEXT PRIMARY KEY, 47 | VALUE TEXT NOT NULL 48 | );""") 49 | 50 | async def __write_default_config(self): 51 | await self.database.execute("""INSERT OR IGNORE INTO config_data (NAME, VALUE) 52 | VALUES ('Record', 1), 53 | ('Logger', 0), 54 | ('Disclaimer', 0);""") 55 | 56 | async def __write_default_option(self): 57 | await self.database.execute("""INSERT OR IGNORE INTO option_data (NAME, VALUE) 58 | VALUES ('Language', 'zh_CN');""") 59 | 60 | async def read_config_data(self): 61 | await self.cursor.execute("SELECT * FROM config_data") 62 | return await self.cursor.fetchall() 63 | 64 | async def read_option_data(self): 65 | await self.cursor.execute("SELECT * FROM option_data") 66 | return await self.cursor.fetchall() 67 | 68 | async def update_config_data( 69 | self, 70 | name: str, 71 | value: int, 72 | ): 73 | await self.database.execute( 74 | "REPLACE INTO config_data (NAME, VALUE) VALUES (?,?)", (name, value) 75 | ) 76 | await self.database.commit() 77 | 78 | async def update_option_data( 79 | self, 80 | name: str, 81 | value: str, 82 | ): 83 | await self.database.execute( 84 | "REPLACE INTO option_data (NAME, VALUE) VALUES (?,?)", (name, value) 85 | ) 86 | await self.database.commit() 87 | 88 | async def update_mapping_data(self, id_: str, name: str, mark: str): 89 | await self.database.execute( 90 | "REPLACE INTO mapping_data (ID, NAME, MARK) VALUES (?,?,?)", 91 | (id_, name, mark), 92 | ) 93 | await self.database.commit() 94 | 95 | async def read_mapping_data(self, id_: str): 96 | await self.cursor.execute( 97 | "SELECT NAME, MARK FROM mapping_data WHERE ID=?", (id_,) 98 | ) 99 | return await self.cursor.fetchone() 100 | 101 | async def has_download_data(self, id_: str) -> bool: 102 | await self.cursor.execute("SELECT ID FROM download_data WHERE ID=?", (id_,)) 103 | return bool(await self.cursor.fetchone()) 104 | 105 | async def write_download_data(self, id_: str): 106 | await self.database.execute( 107 | "INSERT OR IGNORE INTO download_data (ID) VALUES (?);", (id_,) 108 | ) 109 | await self.database.commit() 110 | 111 | async def delete_download_data(self, ids: list | tuple | str): 112 | if not ids: 113 | return 114 | if isinstance(ids, str): 115 | ids = [ids] 116 | [await self.__delete_download_data(i) for i in ids] 117 | await self.database.commit() 118 | 119 | async def __delete_download_data(self, id_: str): 120 | await self.database.execute("DELETE FROM download_data WHERE ID=?", (id_,)) 121 | 122 | async def delete_all_download_data(self): 123 | await self.database.execute("DELETE FROM download_data") 124 | await self.database.commit() 125 | 126 | async def __aenter__(self): 127 | await self.__connect_database() 128 | return self 129 | 130 | async def close(self): 131 | with suppress(CancelledError): 132 | await self.cursor.close() 133 | await self.database.close() 134 | 135 | async def __aexit__(self, exc_type, exc_value, traceback): 136 | await self.close() 137 | -------------------------------------------------------------------------------- /src/manager/recorder.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from platform import system 3 | from re import compile 4 | from typing import TYPE_CHECKING 5 | 6 | from ..custom import ( 7 | ERROR, 8 | INFO, 9 | WARNING, 10 | ) 11 | 12 | if TYPE_CHECKING: 13 | from ..tools import ColorfulConsole 14 | from .database import Database 15 | 16 | __all__ = [ 17 | "DownloadRecorder", 18 | ] 19 | 20 | 21 | class __DownloadRecorder: 22 | encode = "UTF-8-SIG" if system() == "Windows" else "UTF-8" 23 | works_id = compile(r"\d{19}") 24 | 25 | def __init__( 26 | self, switch: bool, folder: Path, state: bool, console: "ColorfulConsole" 27 | ): 28 | self.switch = switch 29 | self.state = state 30 | self.backup = folder.joinpath("IDRecorder_backup.txt") 31 | self.path = folder.joinpath("IDRecorder.txt") 32 | self.file = None 33 | self.console = console 34 | self.record = self.__get_set() 35 | 36 | def __get_set(self) -> set: 37 | return self.__read_file() if self.switch else set() 38 | 39 | def __read_file(self): 40 | if not self.path.is_file(): 41 | blacklist = set() 42 | else: 43 | with self.path.open("r", encoding=self.encode) as f: 44 | blacklist = self.__restore_data({line.strip() for line in f}) 45 | self.file = self.path.open("w", encoding=self.encode) 46 | return blacklist 47 | 48 | def __save_file(self, file): 49 | file.write("\n".join(f"{i}" for i in self.record)) 50 | 51 | def update_id(self, id_): 52 | if self.switch: 53 | self.record.add(id_) 54 | 55 | def __extract_ids(self, ids: str) -> list[str]: 56 | ids = ids.split() 57 | result = [] 58 | for i in ids: 59 | if id_ := self.works_id.search(i): 60 | result.append(id_.group()) 61 | return result 62 | 63 | def delete_ids(self, ids: str) -> None: 64 | if ids.upper() == "ALL": 65 | self.record.clear() 66 | else: 67 | ids = self.__extract_ids(ids) 68 | [self.record.remove(i) for i in ids if i in self.record] 69 | 70 | def backup_file(self): 71 | if self.file and self.record: 72 | # print("Backup IDRecorder") # 调试代码 73 | with self.backup.open("w", encoding=self.encode) as f: 74 | self.__save_file(f) 75 | 76 | def close(self): 77 | if self.file: 78 | self.__save_file(self.file) 79 | self.file.close() 80 | self.file = None 81 | # print("Close IDRecorder") # 调试代码 82 | 83 | def __restore_data(self, ids: set) -> set: 84 | if self.state: 85 | return ids 86 | self.console.print( 87 | f"程序检测到上次运行可能没有正常结束,您的作品下载记录数据可能已经丢失!\n数据文件路径:{ 88 | self.path.resolve() 89 | }", 90 | style=ERROR, 91 | ) 92 | if self.backup.exists(): 93 | if ( 94 | self.console.input( 95 | "检测到 IDRecorder 备份文件,是否恢复最后一次备份的数据(YES/NO): ", 96 | style=WARNING, 97 | ).upper() 98 | == "YES" 99 | ): 100 | self.path.write_text(self.backup.read_text(encoding=self.encode)) 101 | self.console.print( 102 | "IDRecorder 已恢复最后一次备份的数据,请重新运行程序!", style=INFO 103 | ) 104 | return set(self.backup.read_text(encoding=self.encode).split()) 105 | else: 106 | self.console.print( 107 | "IDRecorder 数据未恢复,下载任意作品之后,备份数据会被覆盖导致无法恢复!", 108 | style=ERROR, 109 | ) 110 | else: 111 | self.console.print( 112 | "未检测到 IDRecorder 备份文件,您的作品下载记录数据无法恢复!", 113 | style=ERROR, 114 | ) 115 | return set() 116 | 117 | 118 | class DownloadRecorder: 119 | detail = compile(r"\d{19}") 120 | 121 | def __init__(self, database: "Database", switch: bool, console: "ColorfulConsole"): 122 | self.switch = switch 123 | self.console = console 124 | self.database = database 125 | 126 | async def has_id(self, id_: str) -> bool: 127 | return ( 128 | await self.database.has_download_data(id_) if self.switch and id_ else False 129 | ) 130 | 131 | async def update_id(self, id_: str): 132 | if self.switch and id_: 133 | await self.database.write_download_data(id_) 134 | 135 | async def delete_id(self, id_: str) -> None: 136 | if self.switch and id_: 137 | await self.database.delete_download_data(id_) 138 | 139 | async def delete_ids(self, ids: str) -> None: 140 | if ids.upper() == "ALL": 141 | await self.database.delete_all_download_data() 142 | else: 143 | ids = self.__extract_ids(ids) 144 | await self.database.delete_download_data(ids) 145 | 146 | def __extract_ids(self, ids: str) -> list[str]: 147 | ids = ids.split() 148 | result = [] 149 | for i in ids: 150 | if id_ := self.detail.search(i): 151 | result.append(id_.group()) 152 | return result 153 | -------------------------------------------------------------------------------- /src/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .response import DataResponse, UrlResponse 2 | from .search import ( 3 | GeneralSearch, 4 | VideoSearch, 5 | UserSearch, 6 | LiveSearch, 7 | ) 8 | from .settings import Settings 9 | from .share import ShortUrl 10 | from .detail import Detail, DetailTikTok 11 | from .account import Account, AccountTiktok 12 | from .comment import Comment 13 | from .reply import Reply 14 | from .mix import Mix, MixTikTok 15 | from .live import Live, LiveTikTok 16 | 17 | __all__ = ( 18 | "GeneralSearch", 19 | "VideoSearch", 20 | "UserSearch", 21 | "LiveSearch", 22 | "DataResponse", 23 | "Settings", 24 | "UrlResponse", 25 | "ShortUrl", 26 | "Detail", 27 | "DetailTikTok", 28 | "Account", 29 | "AccountTiktok", 30 | "Comment", 31 | "Reply", 32 | "Mix", 33 | "MixTikTok", 34 | "Live", 35 | "LiveTikTok", 36 | ) 37 | -------------------------------------------------------------------------------- /src/models/account.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from .base import APIModel 4 | 5 | 6 | class Account(APIModel): 7 | sec_user_id: str 8 | tab: str = "post" 9 | earliest: str | float | int | None = None 10 | latest: str | float | int | None = None 11 | pages: int | None = None 12 | cursor: int = 0 13 | count: int = Field( 14 | 18, 15 | gt=0, 16 | ) 17 | 18 | 19 | class AccountTiktok(Account): 20 | pass 21 | -------------------------------------------------------------------------------- /src/models/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class APIModel(BaseModel): 5 | cookie: str = "" 6 | proxy: str = "" 7 | source: bool = False 8 | -------------------------------------------------------------------------------- /src/models/comment.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from .base import APIModel 4 | 5 | 6 | class Comment(APIModel): 7 | detail_id: str 8 | pages: int = Field( 9 | 1, 10 | gt=0, 11 | ) 12 | cursor: int = 0 13 | count: int = Field( 14 | 20, 15 | gt=0, 16 | ) 17 | count_reply: int = Field( 18 | 3, 19 | gt=0, 20 | ) 21 | reply: bool = False 22 | -------------------------------------------------------------------------------- /src/models/detail.py: -------------------------------------------------------------------------------- 1 | from .base import APIModel 2 | 3 | 4 | class Detail(APIModel): 5 | detail_id: str 6 | 7 | 8 | class DetailTikTok(Detail): 9 | pass 10 | -------------------------------------------------------------------------------- /src/models/live.py: -------------------------------------------------------------------------------- 1 | from .base import APIModel 2 | 3 | 4 | class Live(APIModel): 5 | web_rid: str | None = None 6 | room_id: str | None = None 7 | sec_user_id: str | None = None 8 | 9 | 10 | class LiveTikTok(APIModel): 11 | room_id: str | None = None 12 | -------------------------------------------------------------------------------- /src/models/mix.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from .base import APIModel 4 | 5 | 6 | class Mix(APIModel): 7 | mix_id: str | None = None 8 | detail_id: str | None = None 9 | cursor: int = 0 10 | count: int = Field( 11 | 12, 12 | gt=0, 13 | ) 14 | 15 | 16 | class MixTikTok(APIModel): 17 | mix_id: str | None = None 18 | cursor: int = 0 19 | count: int = Field( 20 | 30, 21 | gt=0, 22 | ) 23 | -------------------------------------------------------------------------------- /src/models/reply.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from .base import APIModel 4 | 5 | 6 | class Reply(APIModel): 7 | detail_id: str 8 | comment_id: str 9 | pages: int = Field( 10 | 1, 11 | gt=0, 12 | ) 13 | cursor: int = 0 14 | count: int = Field( 15 | 3, 16 | gt=0, 17 | ) 18 | -------------------------------------------------------------------------------- /src/models/response.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel, computed_field 4 | 5 | 6 | class DataResponse(BaseModel): 7 | message: str 8 | data: dict | list[dict] | None = None 9 | params: dict | None 10 | 11 | @computed_field 12 | @property 13 | def time(self) -> str: 14 | """格式化后的时间字符串""" 15 | return datetime.now().strftime("%Y-%m-%d %H:%M:%S") 16 | 17 | 18 | class UrlResponse(BaseModel): 19 | message: str 20 | url: str | None = None 21 | params: dict | None 22 | 23 | @computed_field 24 | @property 25 | def time(self) -> str: 26 | """格式化后的时间字符串""" 27 | return datetime.now().strftime("%Y-%m-%d %H:%M:%S") 28 | -------------------------------------------------------------------------------- /src/models/search.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | from pydantic import field_validator 5 | 6 | from src.models.base import APIModel 7 | 8 | try: 9 | from src.translation import _ 10 | except ImportError: 11 | 12 | def _(x): 13 | return x 14 | 15 | 16 | class BaseSearch(APIModel): 17 | keyword: str 18 | pages: int = Field( 19 | 1, 20 | gt=0, 21 | ) 22 | 23 | @field_validator("keyword", mode="before") 24 | @classmethod 25 | def keyword_validator(cls, v): 26 | if not v: 27 | raise ValueError(_("keyword 参数无效")) 28 | return v 29 | 30 | 31 | class GeneralSearch(BaseSearch): 32 | channel: Literal[0,] = 0 33 | sort_type: Literal[ 34 | 0, 35 | 1, 36 | 2, 37 | ] = 0 38 | publish_time: Literal[ 39 | 0, 40 | 1, 41 | 7, 42 | 180, 43 | ] = 0 44 | duration: Literal[ 45 | 0, 46 | 1, 47 | 2, 48 | 3, 49 | ] = 0 50 | search_range: Literal[ 51 | 0, 52 | 1, 53 | 2, 54 | 3, 55 | ] = 0 56 | content_type: Literal[ 57 | 0, 58 | 1, 59 | 2, 60 | ] = 0 61 | 62 | @field_validator( 63 | "sort_type", 64 | "publish_time", 65 | "duration", 66 | "search_range", 67 | "content_type", 68 | mode="before", 69 | ) 70 | @classmethod 71 | def val_number(cls, value: str | int) -> int: 72 | return int(value) if isinstance(value, str) else value 73 | 74 | 75 | class VideoSearch(BaseSearch): 76 | channel: Literal[1,] = 1 77 | sort_type: Literal[ 78 | 0, 79 | 1, 80 | 2, 81 | ] = 0 82 | publish_time: Literal[ 83 | 0, 84 | 1, 85 | 7, 86 | 180, 87 | ] = 0 88 | duration: Literal[ 89 | 0, 90 | 1, 91 | 2, 92 | 3, 93 | ] = 0 94 | search_range: Literal[ 95 | 0, 96 | 1, 97 | 2, 98 | 3, 99 | ] = 0 100 | 101 | @field_validator( 102 | "sort_type", "publish_time", "duration", "search_range", mode="before" 103 | ) 104 | @classmethod 105 | def val_number(cls, value: str | int) -> int: 106 | return int(value) if isinstance(value, str) else value 107 | 108 | 109 | class UserSearch(BaseSearch): 110 | channel: Literal[2,] = 2 111 | douyin_user_fans: Literal[ 112 | 0, 113 | 1, 114 | 2, 115 | 3, 116 | 4, 117 | 5, 118 | ] = 0 119 | douyin_user_type: Literal[ 120 | 0, 121 | 1, 122 | 2, 123 | 3, 124 | ] = 0 125 | 126 | @field_validator("douyin_user_fans", "douyin_user_type", mode="before") 127 | @classmethod 128 | def val_number(cls, value: str | int) -> int: 129 | return int(value) if isinstance(value, str) else value 130 | 131 | 132 | class LiveSearch(BaseSearch): 133 | channel: Literal[3,] = 3 134 | -------------------------------------------------------------------------------- /src/models/settings.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | from typing import List 3 | 4 | 5 | class AccountUrl(BaseModel): 6 | mark: str = "" 7 | url: str 8 | tab: str = "post" 9 | earliest: str | int | float = "" 10 | latest: str | int | float = "" 11 | enable: bool = True 12 | 13 | 14 | class MixUrl(BaseModel): 15 | mark: str = "" 16 | url: str 17 | enable: bool = True 18 | 19 | 20 | class OwnerUrl(BaseModel): 21 | mark: str = "" 22 | url: str 23 | uid: str = "" 24 | sec_uid: str = "" 25 | nickname: str = "" 26 | 27 | 28 | class BrowserInfo(BaseModel): 29 | User_Agent: str = Field( 30 | default="", 31 | alias="User-Agent", 32 | ) 33 | pc_libra_divert: str = "" 34 | browser_platform: str = "" 35 | browser_name: str = "" 36 | browser_version: str = "" 37 | engine_name: str = "" 38 | engine_version: str = "" 39 | os_name: str = "" 40 | os_version: str = "" 41 | webid: str = "" 42 | 43 | 44 | class TikTokBrowserInfo(BaseModel): 45 | User_Agent: str = Field( 46 | "", 47 | alias="User-Agent", 48 | ) 49 | app_language: str = "" 50 | browser_language: str = "" 51 | browser_name: str = "" 52 | browser_platform: str = "" 53 | browser_version: str = "" 54 | language: str = "" 55 | os: str = "" 56 | priority_region: str = "" 57 | region: str = "" 58 | tz_name: str = "" 59 | webcast_language: str = "" 60 | device_id: str = "" 61 | 62 | 63 | class Settings(BaseModel): 64 | accounts_urls: List[AccountUrl] = [] 65 | accounts_urls_tiktok: List[AccountUrl] = [] 66 | mix_urls: List[MixUrl] = [] 67 | mix_urls_tiktok: List[MixUrl] = [] 68 | owner_url: OwnerUrl | dict[str, str] = {} 69 | owner_url_tiktok: None = None 70 | root: str | None = None 71 | folder_name: str | None = None 72 | name_format: str | None = None 73 | date_format: str | None = None 74 | split: str | None = None 75 | folder_mode: bool | None = None 76 | music: bool | None = None 77 | truncate: int | None = None 78 | storage_format: str | None = None 79 | cookie: str | dict = "" 80 | cookie_tiktok: str | dict = "" 81 | dynamic_cover: bool | None = None 82 | static_cover: bool | None = None 83 | proxy: str | None = None 84 | proxy_tiktok: str | None = None 85 | twc_tiktok: str | None = None 86 | download: bool | None = None 87 | max_size: int | None = None 88 | chunk: int | None = None 89 | timeout: int | None = None 90 | max_retry: int | None = None 91 | max_pages: int | None = None 92 | run_command: str | None = None 93 | ffmpeg: str | None = None 94 | # douyin_platform: bool | None = None 95 | # tiktok_platform: bool | None = None 96 | browser_info: BrowserInfo | None = None 97 | browser_info_tiktok: TikTokBrowserInfo | None = None 98 | 99 | class Config: 100 | populate_by_name = True 101 | arbitrary_types_allowed = True 102 | json_encoders = { 103 | AccountUrl: lambda v: v.dict(), 104 | MixUrl: lambda v: v.dict(), 105 | OwnerUrl: lambda v: v.dict(), 106 | BrowserInfo: lambda v: v.dict(), 107 | TikTokBrowserInfo: lambda v: v.dict(), 108 | } 109 | -------------------------------------------------------------------------------- /src/models/share.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class ShortUrl(BaseModel): 5 | text: str 6 | proxy: str = "" 7 | -------------------------------------------------------------------------------- /src/module/__init__.py: -------------------------------------------------------------------------------- 1 | from .cookie import Cookie 2 | from .ffmpeg import FFMPEG 3 | from .register import Register 4 | from .tiktok_unofficial import DetailTikTokExtractor, DetailTikTokUnofficial 5 | 6 | __all__ = [ 7 | "Cookie", 8 | "FFMPEG", 9 | "Register", 10 | "DetailTikTokExtractor", 11 | "DetailTikTokUnofficial", 12 | ] 13 | -------------------------------------------------------------------------------- /src/module/cookie.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from ..tools import cookie_str_to_dict 4 | from ..translation import _ 5 | 6 | if TYPE_CHECKING: 7 | from ..config import Settings 8 | from ..tools import ColorfulConsole 9 | 10 | __all__ = ["Cookie"] 11 | 12 | 13 | class Cookie: 14 | STATE_KEY = "sessionid_ss" 15 | PLATFORM = ( 16 | _("抖音"), 17 | "TikTok", 18 | ) 19 | 20 | def __init__(self, settings: "Settings", console: "ColorfulConsole"): 21 | self.settings = settings 22 | self.console = console 23 | 24 | def run( 25 | self, 26 | key="cookie", 27 | tiktok=0, 28 | ): 29 | """提取 Cookie 并写入配置文件""" 30 | if not ( 31 | cookie := self.console.input( 32 | _("请粘贴 {platform} Cookie 内容: ").format( 33 | platform=self.PLATFORM[tiktok] 34 | ) 35 | ) 36 | ): 37 | return False 38 | self.extract(cookie, key=key) 39 | return True 40 | 41 | def extract( 42 | self, 43 | cookie: str, 44 | write=True, 45 | key="cookie", 46 | ) -> dict: 47 | cookie_dict = cookie_str_to_dict(cookie) 48 | self.__check_state(cookie_dict) 49 | if write: 50 | self.save_cookie(cookie_dict, key) 51 | self.console.print(_("写入 Cookie 成功!")) 52 | return cookie_dict 53 | 54 | def __check_state(self, items: dict) -> None: 55 | if items.get(self.STATE_KEY): 56 | self.console.print(_("当前 Cookie 已登录")) 57 | else: 58 | self.console.print(_("当前 Cookie 未登录")) 59 | 60 | def save_cookie(self, cookie: dict, key="cookie") -> None: 61 | data = self.settings.read() 62 | data[key] = cookie 63 | self.settings.update(data) 64 | -------------------------------------------------------------------------------- /src/module/ffmpeg.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from shutil import which 3 | from platform import system 4 | from subprocess import Popen, run 5 | from textwrap import dedent 6 | 7 | __all__ = ["FFMPEG"] 8 | 9 | 10 | class FFMPEG: 11 | SYSTEM = system() 12 | 13 | # 常见终端及其执行模板 14 | linux_terminal_templates = { 15 | # GNOME Terminal (Ubuntu) 16 | "gnome-terminal": ["gnome-terminal", "--", "bash", "-c", "{cmd}; exec bash"], 17 | # Deepin Terminal 18 | "deepin-terminal": ["deepin-terminal", "--", "bash", "-c", "{cmd}; exec bash"], 19 | # XFCE4 Terminal (MX Linux 默认) 20 | "xfce4-terminal": [ 21 | "xfce4-terminal", 22 | "--hold", 23 | "-e", 24 | 'bash -c "{cmd}; exec bash"', 25 | ], 26 | # Konsole (KDE) 27 | "konsole": ["konsole", "-e", "bash", "-i", "-c", "{cmd}; bash"], 28 | # Terminator 29 | "terminator": ["terminator", "-x", "bash", "-c", "{cmd}; exec bash"], 30 | } 31 | 32 | def __init__(self, path: str): 33 | self.path = self.__check_ffmpeg_path(Path(path)) 34 | self.support = { 35 | "Darwin": self.generate_command_darwin, 36 | "Linux": self.generate_command_linux, 37 | "Windows": self.generate_command_windows, 38 | } 39 | self.run_command = self.support.get(self.SYSTEM, None) 40 | self.state = bool(self.path) if self.run_command else False 41 | 42 | @staticmethod 43 | def generate_command_darwin(command: list) -> None: 44 | script = dedent(f""" 45 | tell application "Terminal" 46 | do script "{" ".join(command).replace('"', '\\"')}" 47 | activate 48 | end tell 49 | """) 50 | Popen(["osascript", "-e", script]) 51 | 52 | @staticmethod 53 | def generate_command_windows(command: list) -> None: 54 | Popen( 55 | " ".join( 56 | [ 57 | "start", 58 | "cmd", 59 | "/k", 60 | ] 61 | + command 62 | ), 63 | shell=True, 64 | ) 65 | 66 | @classmethod 67 | def generate_command_linux(cls, command: list) -> None: 68 | # TODO: Linux 系统尚未测试 69 | command = " ".join(command) 70 | print("ffmpeg command:", command) 71 | for term, template in cls.linux_terminal_templates.items(): 72 | if which(term): 73 | # 填充命令并执行 74 | filled = [ 75 | part.format(cmd=command) if "{cmd}" in part else part 76 | for part in template 77 | ] 78 | run( 79 | filled, 80 | ) 81 | 82 | def __check_ffmpeg_path(self, path: Path): 83 | return self.__check_system_ffmpeg() or self.__check_system_ffmpeg(path) 84 | 85 | def download(self, data: list[tuple], proxy, user_agent): 86 | for u, p in data: 87 | command = self.__generate_command( 88 | u, 89 | p, 90 | proxy, 91 | user_agent, 92 | ) 93 | self.run_command(command) 94 | 95 | def __generate_command( 96 | self, 97 | url, 98 | file, 99 | proxy, 100 | user_agent, 101 | ) -> list: 102 | command = [ 103 | self.path, 104 | "-hide_banner", 105 | "-rw_timeout", 106 | f"{30 * 1000 * 1000}", 107 | "-loglevel", 108 | "info", 109 | "-protocol_whitelist", 110 | "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy", 111 | "-analyzeduration", 112 | f"{10 * 1000 * 1000}", 113 | "-probesize", 114 | f"{10 * 1000 * 1000}", 115 | "-fflags", 116 | "+discardcorrupt", 117 | "-user_agent", 118 | f'"{user_agent}"', 119 | "-i", 120 | f'"{url}"', 121 | "-bufsize", 122 | "10240k", 123 | "-map", 124 | "0", 125 | "-c:v", 126 | "copy", 127 | "-c:a", 128 | "copy", 129 | "-sn", 130 | "-dn", 131 | "-reconnect_delay_max", 132 | "60", 133 | "-reconnect_streamed", 134 | "-reconnect_at_eof", 135 | "-max_muxing_queue_size", 136 | "128", 137 | "-correct_ts_overflow", 138 | "1", 139 | "-f", 140 | "mp4", 141 | ] 142 | if proxy: 143 | for insert_index, item in enumerate(("-http_proxy", proxy), start=2): 144 | command.insert(insert_index, item) 145 | command.append(f'"{file}"') 146 | return command 147 | 148 | @staticmethod 149 | def __check_system_ffmpeg(path: Path = None): 150 | return which(path or "ffmpeg") 151 | -------------------------------------------------------------------------------- /src/module/tiktok_account_index.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from re import compile 3 | 4 | from lxml.etree import HTML 5 | 6 | from src.tools import timestamp 7 | 8 | __all__ = [] 9 | 10 | 11 | class __TikTokAccount: 12 | urls = '//*[@id="main-content-others_homepage"]/div/div[2]/div[last()]/div/div/div/div/div/a/@href' 13 | uid = '//*[@id="main-content-others_homepage"]/div/div[1]/div[1]/div[2]/div/div[2]/a/@href' 14 | uid_re = compile(r".*?u=(\d+).*?") 15 | nickname = ( 16 | '//*[@id="main-content-others_homepage"]/div/div[1]/div[1]/div[2]/h2/text()' 17 | ) 18 | works_link_tiktok = compile( 19 | r"\S*?https://www\.tiktok\.com/@\S+?/video/(\d{19})\S*?" 20 | ) 21 | 22 | def __init__(self, path: str): 23 | self.path = Path(path.replace('"', "")) 24 | 25 | def run(self) -> list: 26 | if self.path.is_file() and self.path.suffix == ".html": 27 | return self.__read_html_file([self.path]) 28 | elif self.path.is_dir(): 29 | return self.__read_html_file(self.path.glob("*.html")) 30 | return [] 31 | 32 | def __read_html_file(self, items) -> list: 33 | ids = [] 34 | for i in items: 35 | with i.open("r", encoding="utf-8") as f: 36 | data = f.read() 37 | ids.append(self.__extract_id_data(data)) 38 | return [i for i in ids if all(i)] 39 | 40 | def __extract_id_data(self, html: str) -> (str, str, list[str]): 41 | html_tree = HTML(html) 42 | urls = html_tree.xpath(self.urls) 43 | uid = self.__extract_uid(html_tree.xpath(self.uid)) 44 | nickname = self.__extract_nickname(html_tree.xpath(self.nickname)) 45 | return uid, nickname, self.works_link_tiktok.findall(" ".join(urls)) 46 | 47 | def __extract_uid(self, text: list): 48 | if len(text) == 1: 49 | return u.group(1) if (u := self.uid_re.search(text[0])) else timestamp() 50 | return timestamp() 51 | 52 | @staticmethod 53 | def __extract_nickname(text: list): 54 | return text[0].strip() or timestamp() if len(text) == 1 else timestamp() 55 | -------------------------------------------------------------------------------- /src/module/tiktok_unofficial.py: -------------------------------------------------------------------------------- 1 | from time import strftime, localtime 2 | from types import SimpleNamespace 3 | from typing import TYPE_CHECKING 4 | from typing import Union 5 | 6 | from httpx import get 7 | 8 | from src.custom import BLANK_HEADERS 9 | from src.custom import wait 10 | from src.extract import Extractor 11 | from src.testers import Params 12 | from src.tools import Retry 13 | from src.tools import capture_error_request 14 | from src.translation import _ 15 | 16 | if TYPE_CHECKING: 17 | from src.config import Parameter 18 | from src.testers import Params 19 | 20 | 21 | class DetailTikTokUnofficial: 22 | def __init__( 23 | self, 24 | params: Union["Parameter", "Params"], 25 | proxy: str = None, 26 | detail_id: str = ..., 27 | *args, 28 | **kwargs, 29 | ): 30 | self.headers = BLANK_HEADERS 31 | self.log = params.logger 32 | self.console = params.console 33 | self.api = "https://www.tikwm.com/api/" 34 | self.proxy = proxy or params.proxy_tiktok 35 | self.max_retry = params.max_retry 36 | self.timeout = params.timeout 37 | self.detail_id = detail_id 38 | self.text = _("作品") 39 | 40 | async def run( 41 | self, 42 | ) -> dict: 43 | data = await self.request_data_get() 44 | data = self.check_response(data) 45 | return data 46 | 47 | @Retry.retry 48 | @capture_error_request 49 | async def request_data_get( 50 | self, 51 | ): 52 | response = get( 53 | self.api, 54 | params={"url": self.detail_id, "hd": "1"}, 55 | headers=self.headers, 56 | timeout=self.timeout, 57 | follow_redirects=True, 58 | verify=False, 59 | proxy=self.proxy, 60 | ) 61 | response.raise_for_status() 62 | await wait() 63 | return response.json() 64 | 65 | def check_response( 66 | self, 67 | data: dict, 68 | ): 69 | try: 70 | if data["msg"] == "success" and data["data"]: 71 | return data["data"] 72 | raise KeyError 73 | except KeyError: 74 | self.log.error(_("数据解析失败,请告知作者处理: {data}").format(data=data)) 75 | 76 | 77 | class DetailTikTokExtractor: 78 | def __init__(self, params: "Parameter"): 79 | self.date_format = params.date_format 80 | self.cleaner = params.CLEANER 81 | 82 | def __clean_description(self, desc: str) -> str: 83 | return self.cleaner.clear_spaces(self.cleaner.filter(desc)) 84 | 85 | def __format_date( 86 | self, 87 | data: int, 88 | ) -> str: 89 | return strftime( 90 | self.date_format, 91 | localtime(data or None), 92 | ) 93 | 94 | def run(self, data: dict) -> dict: 95 | item = {} 96 | data = Extractor.generate_data_object(data) 97 | self.extract_detail_tiktok(item, data) 98 | self.extract_music_tiktok(item, data) 99 | self.extract_author_tiktok(item, data) 100 | self.extract_statistics_tiktok(item, data) 101 | return item 102 | 103 | def extract_detail_tiktok( 104 | self, 105 | item: dict, 106 | data: SimpleNamespace, 107 | ) -> None: 108 | item["id"] = Extractor.safe_extract(data, "id") 109 | item["desc"] = ( 110 | self.__clean_description(Extractor.safe_extract(data, "title")) 111 | or item["id"] 112 | ) 113 | item["create_time"] = self.__format_date( 114 | Extractor.safe_extract(data, "create_time") 115 | ) 116 | item["type"] = _("视频") 117 | item["downloads"] = Extractor.safe_extract(data, "hdplay") 118 | item["dynamic_cover"] = Extractor.safe_extract(data, "ai_dynamic_cover") 119 | item["static_cover"] = Extractor.safe_extract(data, "origin_cover") 120 | 121 | def extract_author_tiktok( 122 | self, 123 | item: dict, 124 | data: SimpleNamespace, 125 | ) -> None: 126 | item["uid"] = Extractor.safe_extract(data, "author.id") 127 | item["nickname"] = Extractor.safe_extract(data, "author.nickname") 128 | item["unique_id"] = Extractor.safe_extract(data, "author.unique_id") 129 | 130 | def extract_music_tiktok( 131 | self, 132 | item: dict, 133 | data: SimpleNamespace, 134 | ) -> None: 135 | item["music_author"] = Extractor.safe_extract(data, "music_info.author") 136 | item["music_title"] = Extractor.safe_extract(data, "music_info.title") 137 | item["music_url"] = Extractor.safe_extract(data, "music") 138 | 139 | @staticmethod 140 | def extract_statistics_tiktok( 141 | item: dict, 142 | data: SimpleNamespace, 143 | ) -> None: 144 | for i in Extractor.statistics_keys: 145 | item[i] = Extractor.safe_extract( 146 | data, 147 | i, 148 | -1, 149 | ) 150 | 151 | 152 | async def test(): 153 | async with Params() as params: 154 | i = DetailTikTokUnofficial( 155 | params, 156 | detail_id="", 157 | ) 158 | if data := await i.run(): 159 | print(DetailTikTokExtractor(params).run(data)) 160 | 161 | 162 | if __name__ == "__main__": 163 | from asyncio import run 164 | 165 | run(test()) 166 | -------------------------------------------------------------------------------- /src/record/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseLogger 2 | from .logger import LoggerManager 3 | 4 | __all__ = ["LoggerManager", "BaseLogger"] 5 | -------------------------------------------------------------------------------- /src/record/base.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from time import localtime, strftime 3 | from typing import TYPE_CHECKING 4 | 5 | from ..custom import ( 6 | DEBUG, 7 | ERROR, 8 | GENERAL, 9 | INFO, 10 | VERSION_BETA, 11 | WARNING, 12 | ) 13 | from ..tools import Cleaner 14 | 15 | if TYPE_CHECKING: 16 | from ..tools import ColorfulConsole 17 | 18 | 19 | class BaseLogger: 20 | """不记录日志,空白日志记录器""" 21 | 22 | DEBUG = VERSION_BETA 23 | 24 | def __init__( 25 | self, 26 | main_path: Path, 27 | console: "ColorfulConsole", 28 | root="", 29 | folder="", 30 | name="", 31 | ): 32 | self.log = None # 记录器主体 33 | self.console = console 34 | self._root, self._folder, self._name = self.init_check( 35 | main_path=main_path, 36 | root=root, 37 | folder=folder, 38 | name=name, 39 | ) 40 | 41 | def init_check( 42 | self, 43 | main_path: Path, 44 | root=None, 45 | folder=None, 46 | name=None, 47 | ) -> tuple: 48 | root = self.check_root(root, main_path) 49 | folder = self.check_folder(folder) 50 | name = self.check_name(name) 51 | return root, folder, name 52 | 53 | def check_root(self, root: str, default: Path) -> Path: 54 | if not root: 55 | return default 56 | if (r := Path(root)).is_dir(): 57 | return r 58 | self.console.print( 59 | f"日志储存路径 {root} 无效,程序将使用项目根路径作为储存路径" 60 | ) 61 | return default 62 | 63 | def check_name(self, name: str) -> str: 64 | if not name: 65 | return "%Y-%m-%d %H.%M.%S" 66 | try: 67 | _ = strftime(name, localtime()) 68 | return name 69 | except ValueError: 70 | self.console.print( 71 | f"日志名称格式 {name} 无效,程序将使用默认时间格式:年-月-日 时.分.秒" 72 | ) 73 | return "%Y-%m-%d %H.%M.%S" 74 | 75 | @staticmethod 76 | def check_folder(folder: str) -> str: 77 | return Cleaner().filter_name(folder, "Log") 78 | 79 | def run(self, *args, **kwargs): 80 | pass 81 | 82 | def info(self, text: str, output=True, **kwargs): 83 | if output: 84 | self.console.print(text, style=INFO, **kwargs) 85 | 86 | def warning(self, text: str, output=True, **kwargs): 87 | if output: 88 | self.console.print(text, style=WARNING, **kwargs) 89 | 90 | def error(self, text: str, output=True, **kwargs): 91 | if output: 92 | self.console.print(text, style=ERROR, **kwargs) 93 | 94 | def debug(self, text: str, **kwargs): 95 | if self.DEBUG: 96 | self.console.print(text, style=DEBUG, **kwargs) 97 | 98 | def print(self, text: str, style=GENERAL, **kwargs) -> None: 99 | self.console.print(text, style=style, **kwargs) 100 | -------------------------------------------------------------------------------- /src/record/logger.py: -------------------------------------------------------------------------------- 1 | from logging import INFO as INFO_LEVEL 2 | from logging import FileHandler, Formatter, getLogger 3 | from pathlib import Path 4 | from platform import system 5 | from time import localtime, strftime 6 | from typing import TYPE_CHECKING 7 | 8 | from ..custom import ( 9 | DEBUG, 10 | ERROR, 11 | INFO, 12 | WARNING, 13 | ) 14 | from .base import BaseLogger 15 | 16 | if TYPE_CHECKING: 17 | from ..tools import ColorfulConsole 18 | 19 | 20 | class LoggerManager(BaseLogger): 21 | """日志记录""" 22 | 23 | encode = "UTF-8-SIG" if system() == "Windows" else "UTF-8" 24 | 25 | def __init__( 26 | self, main_path: Path, console: "ColorfulConsole", root="", folder="", name="" 27 | ): 28 | super().__init__(main_path, console, root, folder, name) 29 | 30 | def run( 31 | self, 32 | format_="%(asctime)s[%(levelname)s]: %(message)s", 33 | filename=None, 34 | ): 35 | if not (dir_ := self._root.joinpath(self._folder)).exists(): 36 | dir_.mkdir() 37 | file_handler = FileHandler( 38 | dir_.joinpath( 39 | f"{filename}.log" 40 | if filename 41 | else f"{strftime(self._name, localtime())}.log" 42 | ), 43 | encoding=self.encode, 44 | ) 45 | formatter = Formatter(format_, datefmt="%Y-%m-%d %H:%M:%S") 46 | file_handler.setFormatter(formatter) 47 | self.log = getLogger(__name__) 48 | self.log.addHandler(file_handler) 49 | self.log.setLevel(INFO_LEVEL) 50 | 51 | def info(self, text: str, output=True, **kwargs): 52 | if output: 53 | self.console.print(text, style=INFO, **kwargs) 54 | self.log.info(text.strip()) 55 | 56 | def warning(self, text: str, output=True, **kwargs): 57 | if output: 58 | self.console.print(text, style=WARNING, **kwargs) 59 | self.log.warning(text.strip()) 60 | 61 | def error(self, text: str, output=True, **kwargs): 62 | if output: 63 | self.console.print(text, style=ERROR, **kwargs) 64 | self.log.error(text.strip()) 65 | 66 | def debug(self, text: str, **kwargs): 67 | if self.DEBUG: 68 | self.console.print(text, style=DEBUG, **kwargs) 69 | self.log.debug(text.strip()) 70 | -------------------------------------------------------------------------------- /src/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from .manager import RecordManager 2 | 3 | __all__ = ["RecordManager"] 4 | -------------------------------------------------------------------------------- /src/storage/csv.py: -------------------------------------------------------------------------------- 1 | from csv import writer 2 | from os.path import getsize 3 | from pathlib import Path 4 | from platform import system 5 | from typing import TYPE_CHECKING 6 | 7 | from .text import BaseTextLogger 8 | 9 | if TYPE_CHECKING: 10 | from ..tools import ColorfulConsole 11 | 12 | __all__ = ["CSVLogger"] 13 | 14 | 15 | class CSVLogger(BaseTextLogger): 16 | """CSV 格式保存数据""" 17 | 18 | __type = "csv" 19 | encode = "UTF-8-SIG" if system() == "Windows" else "UTF-8" 20 | 21 | def __init__( 22 | self, 23 | root: Path, 24 | title_line: tuple, 25 | field_keys: tuple, 26 | console: "ColorfulConsole", 27 | old=None, 28 | name="Download", 29 | *args, 30 | **kwargs, 31 | ): 32 | super().__init__(*args, **kwargs) 33 | self.console = console 34 | self.file = None # 文件对象 35 | self.writer = None # CSV对象 36 | self.name = self._rename(root, self.__type, old, name) # 文件名称 37 | self.path = root.joinpath(f"{self.name}.{self.__type}") # 文件路径 38 | self.title_line = title_line # 标题行 39 | self.field_keys = field_keys 40 | 41 | async def __aenter__(self): 42 | self.file = self.path.open("a", encoding=self.encode, newline="") 43 | self.writer = writer(self.file) 44 | await self.title() 45 | return self 46 | 47 | async def __aexit__(self, exc_type, exc_val, exc_tb): 48 | self.file.close() 49 | 50 | async def title(self): 51 | if getsize(self.path) == 0: 52 | # 如果文件没有任何数据,则写入标题行 53 | await self.save(self.title_line) 54 | 55 | async def _save(self, data, *args, **kwargs): 56 | self.writer.writerow(data) 57 | -------------------------------------------------------------------------------- /src/storage/mysql.py: -------------------------------------------------------------------------------- 1 | from .sql import BaseSQLLogger 2 | 3 | __all__ = ["MySQLLogger"] 4 | 5 | 6 | class MySQLLogger(BaseSQLLogger): 7 | pass 8 | -------------------------------------------------------------------------------- /src/storage/sql.py: -------------------------------------------------------------------------------- 1 | from re import Pattern 2 | from re import compile 3 | 4 | from .text import BaseTextLogger 5 | 6 | __all__ = ["BaseSQLLogger"] 7 | 8 | 9 | class BaseSQLLogger(BaseTextLogger): 10 | SHEET_NAME: Pattern = compile(r"[^\u4e00-\u9fffa-zA-Z0-9_]") 11 | CHECK_SQL = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?;" 12 | UPDATE_SQL = "ALTER TABLE ? RENAME TO ?;" 13 | -------------------------------------------------------------------------------- /src/storage/sqlite.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from re import sub 3 | 4 | from aiosqlite import connect 5 | 6 | from .sql import BaseSQLLogger 7 | 8 | __all__ = ["SQLLogger"] 9 | 10 | 11 | class SQLLogger(BaseSQLLogger): 12 | """SQLite 数据库保存数据""" 13 | 14 | def __init__( 15 | self, 16 | root: Path, 17 | db_name: str, 18 | title_line: tuple, 19 | title_type: tuple, 20 | field_keys: tuple, 21 | old=None, 22 | name="Download", 23 | *args, 24 | **kwargs, 25 | ): 26 | super().__init__(*args, **kwargs) 27 | self.db = None # 数据库 28 | self.cursor = None # 游标对象 29 | self.name = (old, name) # 数据表名称 30 | self.file = db_name # 数据库文件名称 31 | self.path = root.joinpath(self.file) 32 | self.title_line = title_line # 数据表列名 33 | self.title_type = title_type # 数据表数据类型 34 | self.field_keys = field_keys 35 | 36 | async def __aenter__(self): 37 | self.db = await connect(self.path) 38 | self.cursor = await self.db.cursor() 39 | await self.update_sheet() 40 | await self.create() 41 | return self 42 | 43 | async def __aexit__(self, exc_type, exc_val, exc_tb): 44 | await self.db.close() 45 | 46 | async def create(self): 47 | create_sql = f"""CREATE TABLE IF NOT EXISTS {self.name} ({ 48 | ", ".join([f"{i} {j}" for i, j in zip(self.title_line, self.title_type)]) 49 | });""" 50 | await self.cursor.execute(create_sql) 51 | await self.db.commit() 52 | 53 | async def _save(self, data, *args, **kwargs): 54 | insert_sql = f"""REPLACE INTO {self.name} ({ 55 | ", ".join(self.title_line) 56 | }) VALUES ({", ".join(["?" for _ in self.title_line])});""" 57 | await self.cursor.execute(insert_sql, data) 58 | await self.db.commit() 59 | 60 | async def update_sheet(self): 61 | old_sheet, new_sheet = self.__clean_sheet_name(self.name) 62 | mark = new_sheet.split("_", 1) 63 | if not old_sheet or mark[-1] == old_sheet: 64 | self.name = new_sheet 65 | return 66 | mark[-1] = old_sheet 67 | old_sheet = "_".join(mark) 68 | if await self.__check_sheet_exists(old_sheet): 69 | await self.cursor.execute(self.UPDATE_SQL, (old_sheet, new_sheet)) 70 | await self.db.commit() 71 | self.name = new_sheet 72 | 73 | async def __check_sheet_exists(self, sheet: str) -> bool: 74 | await self.cursor.execute(self.CHECK_SQL, (sheet,)) 75 | exists = await self.cursor.fetchone() 76 | return exists[0] > 0 77 | 78 | def __clean_sheet_name(self, name: tuple) -> tuple: 79 | return self.__clean_characters(name[0]), self.__clean_characters(name[1]) 80 | 81 | def __clean_characters(self, text: str | None) -> str | None: 82 | if isinstance(text, str): 83 | text = self.SHEET_NAME.sub("_", text) 84 | text = sub(r"_+", "_", text) 85 | return text 86 | -------------------------------------------------------------------------------- /src/storage/text.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING 3 | from typing import Union 4 | 5 | from ..tools import Retry 6 | 7 | if TYPE_CHECKING: 8 | from typing import Iterable 9 | 10 | 11 | def convert_to_string(function): 12 | async def _convert_to_string(self, data: Union["Iterable", list], *args, **kwargs): 13 | for index, value in enumerate(data): 14 | if isinstance(value, (int, float)): # 如果值是数字(整型或浮点型) 15 | data[index] = str(value) # 转换为字符串 16 | elif isinstance(value, list): # 如果值是列表 17 | data[index] = " ".join(value) # 将列表元素转换为字符串并连接 18 | return await function(self, data, *args, **kwargs) 19 | 20 | return _convert_to_string 21 | 22 | 23 | class BaseTextLogger: 24 | def __init__(self, *args, **kwargs): 25 | self.field_keys = [] 26 | 27 | async def __aenter__(self): 28 | return self 29 | 30 | async def __aexit__(self, exc_type, exc_val, exc_tb): 31 | pass 32 | 33 | @convert_to_string 34 | async def save(self, data: "Iterable", *args, **kwargs): 35 | # 数据保存方法入口 36 | return await self._save(data, *args, **kwargs) 37 | 38 | async def _save(self, data: "Iterable", *args, **kwargs): 39 | # 实际数据保存逻辑 40 | pass 41 | 42 | @classmethod 43 | def _rename(cls, root: Path, type_: str, old: str, new_: str) -> str: 44 | mark = new_.split("_", 1) 45 | if not old or mark[-1] == old: 46 | return new_ 47 | mark[-1] = old 48 | old_file = root.joinpath(f"{'_'.join(mark)}.{type_}") 49 | cls.__rename_file(old_file, root.joinpath(f"{new_}.{type_}")) 50 | return new_ 51 | 52 | @staticmethod 53 | @Retry.retry_infinite 54 | def __rename_file(old_file: Path, new_file: Path) -> bool: 55 | if old_file.exists() and not new_file.exists(): 56 | try: 57 | old_file.rename(new_file) 58 | return True 59 | except PermissionError: 60 | return False 61 | return True 62 | -------------------------------------------------------------------------------- /src/storage/xlsx.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING 3 | 4 | from openpyxl import Workbook, load_workbook 5 | from openpyxl.utils.exceptions import IllegalCharacterError 6 | 7 | from ..translation import _ 8 | from .text import BaseTextLogger 9 | 10 | if TYPE_CHECKING: 11 | from ..tools import ColorfulConsole 12 | 13 | __all__ = ["XLSXLogger"] 14 | 15 | 16 | class XLSXLogger(BaseTextLogger): 17 | """XLSX 格式保存数据""" 18 | 19 | __type = "xlsx" 20 | 21 | def __init__( 22 | self, 23 | root: Path, 24 | title_line: tuple, 25 | field_keys: tuple, 26 | console: "ColorfulConsole", 27 | old=None, 28 | name="Download", 29 | *args, 30 | **kwargs, 31 | ): 32 | super().__init__(*args, **kwargs) 33 | self.console = console 34 | self.book = None # XLSX数据簿 35 | self.sheet = None # XLSX数据表 36 | self.name = self._rename(root, self.__type, old, name) # 文件名称 37 | self.path = root.joinpath(f"{self.name}.{self.__type}") 38 | self.title_line = title_line # 标题行 39 | self.field_keys = field_keys 40 | 41 | async def __aenter__(self): 42 | self.book = load_workbook(self.path) if self.path.exists() else Workbook() 43 | self.sheet = self.book.active 44 | self.title() 45 | return self 46 | 47 | async def __aexit__(self, exc_type, exc_val, exc_tb): 48 | self.book.save(self.path) 49 | self.book.close() 50 | 51 | def title(self): 52 | if not self.sheet["A1"].value: 53 | # 如果文件没有任何数据,则写入标题行 54 | for col, value in enumerate(self.title_line, start=1): 55 | self.sheet.cell(row=1, column=col, value=value) 56 | 57 | async def _save(self, data, *args, **kwargs): 58 | try: 59 | self.sheet.append(data) 60 | except IllegalCharacterError as e: 61 | self.console.warning( 62 | _("数据包含非法字符,保存数据失败:{error}").format(error=e) 63 | ) 64 | -------------------------------------------------------------------------------- /src/testers/__init__.py: -------------------------------------------------------------------------------- 1 | from .logger import Logger 2 | from .params import Params 3 | -------------------------------------------------------------------------------- /src/testers/logger.py: -------------------------------------------------------------------------------- 1 | class Logger: 2 | @staticmethod 3 | def info( 4 | *args, 5 | ): 6 | print( 7 | *args, 8 | ) 9 | 10 | @staticmethod 11 | def warning( 12 | *args, 13 | ): 14 | print( 15 | *args, 16 | ) 17 | 18 | @staticmethod 19 | def error( 20 | *args, 21 | ): 22 | print( 23 | *args, 24 | ) 25 | -------------------------------------------------------------------------------- /src/testers/params.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser, NoSectionError, NoOptionError 2 | 3 | from rich.console import Console 4 | 5 | from src.custom import ( 6 | DATA_HEADERS, 7 | DATA_HEADERS_TIKTOK, 8 | DOWNLOAD_HEADERS_TIKTOK, 9 | ) 10 | from src.custom import PROJECT_ROOT 11 | from src.encrypt import ABogus 12 | from src.encrypt import XBogus 13 | from src.testers.logger import Logger 14 | from src.tools import Cleaner 15 | from src.tools import create_client 16 | 17 | 18 | class Params: 19 | CONFIG = PROJECT_ROOT.joinpath("test_cookie.ini") 20 | CLEANER = Cleaner() 21 | 22 | def __init__(self): 23 | self.cookie_str = "" 24 | self.cookie_str_tiktok = "" 25 | self.config = ConfigParser( 26 | interpolation=None, 27 | ) 28 | self.read_ini() 29 | self.headers = DATA_HEADERS | {"Cookie": self.cookie_str} 30 | self.headers_tiktok = DATA_HEADERS_TIKTOK | { 31 | "Cookie": self.cookie_str_tiktok, 32 | } 33 | self.headers_download = DOWNLOAD_HEADERS_TIKTOK 34 | self.logger = Logger() 35 | self.ab = ABogus() 36 | self.xb = XBogus() 37 | self.console = Console() 38 | self.max_retry = 0 39 | self.timeout = 5 40 | self.max_pages = 2 41 | self.date_format = "%Y-%m-%d %H:%M:%S" 42 | self.client = create_client( 43 | timeout=self.timeout, 44 | ) 45 | self.client_tiktok = create_client( 46 | timeout=self.timeout, 47 | proxy="http://127.0.0.1:10809", 48 | ) 49 | 50 | def create_ini(self): 51 | self.config["dy"] = { 52 | "cookie": "", 53 | } 54 | self.config["tk"] = { 55 | "cookie": "", 56 | } 57 | with self.CONFIG.open("w", encoding="utf-8") as configfile: 58 | self.config.write(configfile) 59 | 60 | def read_ini(self): 61 | if not self.config.read(self.CONFIG): 62 | self.create_ini() 63 | return 64 | try: 65 | self.cookie_str = self.config.get( 66 | "dy", 67 | "cookie", 68 | ) 69 | self.cookie_str_tiktok = self.config.get( 70 | "tk", 71 | "cookie", 72 | ) 73 | except (NoSectionError, NoOptionError) as e: 74 | print(f"读取 Cookie 错误: {e}") 75 | 76 | async def __aenter__(self): 77 | return self 78 | 79 | async def __aexit__(self, exc_type, exc_val, exc_tb): 80 | await self.client.aclose() 81 | await self.client_tiktok.aclose() 82 | 83 | 84 | async def test(): 85 | async with Params() as params: 86 | print(params.cookie_str) 87 | print(params.cookie_str_tiktok) 88 | 89 | 90 | if __name__ == "__main__": 91 | from asyncio import run 92 | 93 | run(test()) 94 | -------------------------------------------------------------------------------- /src/testers/test_format.py: -------------------------------------------------------------------------------- 1 | from http.cookiejar import Cookie, CookieJar 2 | 3 | from pytest import mark 4 | 5 | from src.tools import ( 6 | cookie_dict_to_str, 7 | cookie_jar_to_dict, 8 | cookie_str_to_dict, 9 | cookie_str_to_str, 10 | format_size, 11 | ) 12 | 13 | 14 | @mark.parametrize( 15 | "x, y", 16 | [ 17 | ( 18 | "UIFID_V=2; UIFID_TEMP=aaa; fpk1=aaa; fpk2=aaa; tiktok", 19 | {"UIFID_V": "2", "UIFID_TEMP": "aaa", "fpk1": "aaa", "fpk2": "aaa"}, 20 | ), 21 | ], 22 | ) 23 | def test_cookie_str_to_dict(x, y): 24 | assert cookie_str_to_dict(x) == y 25 | 26 | 27 | @mark.parametrize( 28 | "x, y", 29 | [ 30 | ( 31 | "ixigua-a-s=1; path=/; secure; httponly", 32 | "ixigua-a-s=1", 33 | ), 34 | ], 35 | ) 36 | def test_cookie_str_to_str(x, y): 37 | assert cookie_str_to_str(x) == y 38 | 39 | 40 | @mark.parametrize( 41 | "x, y", 42 | [ 43 | ( 44 | {"UIFID_V": "2", "UIFID_TEMP": "aaa", "fpk1": "aaa", "fpk2": "aaa"}, 45 | "UIFID_V=2; UIFID_TEMP=aaa; fpk1=aaa; fpk2=aaa", 46 | ), 47 | ({"name": "value"}, "name=value"), 48 | ], 49 | ) 50 | def test_cookie_dict_to_str(x, y): 51 | assert cookie_dict_to_str(x) == y 52 | 53 | 54 | def create_test_cookie_jar(): 55 | jar = CookieJar() 56 | jar.set_cookie( 57 | Cookie( 58 | version=0, 59 | name="cookie_name", 60 | value="cookie_value", 61 | port=None, 62 | port_specified=False, 63 | domain="example.com", 64 | domain_specified=True, 65 | domain_initial_dot=False, 66 | path="/", 67 | path_specified=True, 68 | secure=False, 69 | expires=None, 70 | discard=False, 71 | comment=None, 72 | comment_url=None, 73 | rest={}, 74 | ) 75 | ) 76 | return jar 77 | 78 | 79 | @mark.parametrize( 80 | "x, y", 81 | [ 82 | ( 83 | create_test_cookie_jar(), 84 | {"cookie_name": "cookie_value"}, 85 | ), 86 | ], 87 | ) 88 | def test_cookie_jar_to_dict(x, y): 89 | assert cookie_jar_to_dict(x) == y 90 | 91 | 92 | @mark.parametrize( 93 | "x, y", 94 | [ 95 | (1024 * 1024, "1.00 MB"), 96 | (1024 * 512, "512.00 KB"), 97 | (1024 * 1024 * 2.25, "2.25 MB"), 98 | ], 99 | ) 100 | def test_format_size(x, y): 101 | assert format_size(x) == y 102 | -------------------------------------------------------------------------------- /src/testers/translate.py: -------------------------------------------------------------------------------- 1 | from src.translation import _, switch_language 2 | from src.custom import DISCLAIMER_TEXT 3 | 4 | if __name__ == "__main__": 5 | print(_(DISCLAIMER_TEXT)) 6 | 7 | # 切换到英文并打印翻译 8 | switch_language("en_US") 9 | print(_(DISCLAIMER_TEXT)) 10 | 11 | # 切换回中文并打印翻译 12 | switch_language("zh_CN") 13 | print(_(DISCLAIMER_TEXT)) 14 | -------------------------------------------------------------------------------- /src/tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .browser import Browser 2 | from .capture import capture_error_params 3 | from .capture import capture_error_request 4 | from .choose import choose 5 | from .cleaner import Cleaner 6 | from .console import ColorfulConsole 7 | from .error import CacheError 8 | from .error import DownloaderError 9 | from .file_folder import file_switch 10 | from .file_folder import remove_empty_directories 11 | from .format import ( 12 | cookie_dict_to_str, 13 | cookie_str_to_dict, 14 | cookie_jar_to_dict, 15 | cookie_str_to_str, 16 | format_size, 17 | ) 18 | from .list_pop import safe_pop 19 | from .retry import Retry 20 | from .session import ( 21 | request_params, 22 | create_client, 23 | ) 24 | from .temporary import random_string 25 | from .temporary import timestamp 26 | from .timer import run_time 27 | from .truncate import beautify_string 28 | from .truncate import trim_string 29 | from .truncate import truncate_string 30 | from .rename_compatible import RenameCompatible 31 | -------------------------------------------------------------------------------- /src/tools/browser.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from sys import platform 3 | from types import SimpleNamespace 4 | from typing import TYPE_CHECKING 5 | 6 | from rookiepy import ( 7 | arc, 8 | brave, 9 | chrome, 10 | chromium, 11 | edge, 12 | firefox, 13 | librewolf, 14 | opera, 15 | opera_gx, 16 | vivaldi, 17 | ) 18 | 19 | from ..translation import _ 20 | 21 | if TYPE_CHECKING: 22 | from ..config import Parameter 23 | from ..module import Cookie 24 | 25 | __all__ = ["Browser"] 26 | 27 | 28 | class Browser: 29 | SUPPORT_BROWSER = { 30 | "Arc": (arc, "Linux, macOS, Windows"), 31 | "Chrome": (chrome, "Linux, macOS, Windows"), 32 | "Chromium": (chromium, "Linux, macOS, Windows"), 33 | "Opera": (opera, "Linux, macOS, Windows"), 34 | "OperaGX": (opera_gx, "macOS, Windows"), 35 | "Brave": (brave, "Linux, macOS, Windows"), 36 | "Edge": (edge, "Linux, macOS, Windows"), 37 | "Vivaldi": (vivaldi, "Linux, macOS, Windows"), 38 | "Firefox": (firefox, "Linux, macOS, Windows"), 39 | "LibreWolf": (librewolf, "Linux, macOS, Windows"), 40 | } 41 | PLATFORM = { 42 | False: SimpleNamespace( 43 | name=_("抖音"), 44 | domain=[ 45 | "douyin.com", 46 | ], 47 | key="cookie", 48 | ), 49 | True: SimpleNamespace( 50 | name="TikTok", 51 | domain=[ 52 | "tiktok.com", 53 | ], 54 | key="cookie_tiktok", 55 | ), 56 | } 57 | 58 | def __init__(self, parameters: "Parameter", cookie_object: "Cookie"): 59 | self.console = parameters.console 60 | self.cookie_object = cookie_object 61 | self.options = "\n".join( 62 | ( 63 | f"{i}. {k}: {v[1]}" 64 | for i, (k, v) in enumerate( 65 | self.SUPPORT_BROWSER.items(), 66 | start=1, 67 | ) 68 | ) 69 | ) 70 | 71 | def run( 72 | self, 73 | tiktok=False, 74 | select: str = None, 75 | ): 76 | if browser := select or self.console.input( 77 | _( 78 | "读取指定浏览器的 {platform_name} Cookie 并写入配置文件;\n" 79 | "注意:Windows 系统需要以管理员身份运行程序才能读取 Chromium、Chrome、Edge 浏览器 Cookie!\n" 80 | "{options}\n" 81 | "请输入浏览器名称或序号:" 82 | ).format(platform_name=self.PLATFORM[tiktok].name, options=self.options), 83 | ): 84 | if cookie := self.get( 85 | browser, 86 | self.PLATFORM[tiktok].domain, 87 | ): 88 | self.console.info( 89 | _("读取 Cookie 成功!"), 90 | ) 91 | else: 92 | self.console.warning( 93 | _("Cookie 数据为空!"), 94 | ) 95 | self.__save_cookie( 96 | cookie, 97 | tiktok, 98 | ) 99 | else: 100 | self.console.print(_("未选择浏览器!")) 101 | 102 | def __save_cookie(self, cookie: dict, tiktok: bool): 103 | self.cookie_object.save_cookie(cookie, self.PLATFORM[tiktok].key) 104 | 105 | def get( 106 | self, 107 | browser: str | int, 108 | domains: list[str], 109 | ) -> dict[str, str]: 110 | if not (browser := self.__browser_object(browser)): 111 | self.console.warning( 112 | _("浏览器名称或序号输入错误!"), 113 | ) 114 | return {} 115 | try: 116 | cookies = browser(domains=domains) 117 | return {i["name"]: i["value"] for i in cookies} 118 | except RuntimeError: 119 | self.console.warning( 120 | _("读取 Cookie 失败,未找到 Cookie 数据!"), 121 | ) 122 | return {} 123 | 124 | @classmethod 125 | def __browser_object(cls, browser: str | int): 126 | with suppress(ValueError): 127 | browser = int(browser) - 1 128 | if isinstance(browser, int): 129 | try: 130 | return list(cls.SUPPORT_BROWSER.values())[browser][0] 131 | except IndexError: 132 | return None 133 | if isinstance(browser, str): 134 | try: 135 | return cls.__match_browser(browser) 136 | except KeyError: 137 | return None 138 | raise TypeError 139 | 140 | @classmethod 141 | def __match_browser(cls, browser: str): 142 | for i, j in cls.SUPPORT_BROWSER.items(): 143 | if i.lower() == browser.lower(): 144 | return j[0] 145 | 146 | 147 | match platform: 148 | case "darwin": 149 | from rookiepy import safari 150 | 151 | Browser.SUPPORT_BROWSER |= { 152 | "Safari": (safari, "macOS"), 153 | } 154 | case "linux": 155 | Browser.SUPPORT_BROWSER.pop("OperaGX") 156 | case "win32": 157 | pass 158 | case _: 159 | print(_("从浏览器读取 Cookie 功能不支持当前平台!")) 160 | -------------------------------------------------------------------------------- /src/tools/capture.py: -------------------------------------------------------------------------------- 1 | from json.decoder import JSONDecodeError 2 | from ssl import SSLError 3 | from typing import TYPE_CHECKING, Union 4 | 5 | from httpx import HTTPStatusError, NetworkError, RequestError, TimeoutException 6 | 7 | from ..translation import _ 8 | 9 | if TYPE_CHECKING: 10 | from ..record import BaseLogger, LoggerManager 11 | 12 | __all__ = [ 13 | "capture_error_params", 14 | "capture_error_request", 15 | ] 16 | 17 | 18 | def capture_error_params(function): 19 | async def inner(logger: Union["BaseLogger", "LoggerManager"], *args, **kwargs): 20 | try: 21 | return await function(logger, *args, **kwargs) 22 | except ( 23 | JSONDecodeError, 24 | UnicodeDecodeError, 25 | ): 26 | logger.error(_("响应内容不是有效的 JSON 数据")) 27 | except HTTPStatusError as e: 28 | logger.error(_("响应码异常:{error}").format(error=e)) 29 | except NetworkError as e: 30 | logger.error(_("网络异常:{error}").format(error=e)) 31 | except TimeoutException as e: 32 | logger.error(_("请求超时:{error}").format(error=e)) 33 | except ( 34 | RequestError, 35 | SSLError, 36 | ) as e: 37 | logger.error(_("网络异常:{error}").format(error=e)) 38 | return None 39 | 40 | return inner 41 | 42 | 43 | def capture_error_request(function): 44 | async def inner(self, *args, **kwargs): 45 | try: 46 | return await function(self, *args, **kwargs) 47 | except (JSONDecodeError, UnicodeDecodeError): 48 | self.log.error(_("响应内容不是有效的 JSON 数据,请尝试更新 Cookie!")) 49 | except HTTPStatusError as e: 50 | self.log.error(_("响应码异常:{error}").format(error=e)) 51 | except NetworkError as e: 52 | self.log.error(_("网络异常:{error}").format(error=e)) 53 | except TimeoutException as e: 54 | self.log.error(_("请求超时:{error}").format(error=e)) 55 | except ( 56 | RequestError, 57 | SSLError, 58 | ) as e: 59 | self.log.error(_("网络异常:{error}").format(error=e)) 60 | return None 61 | 62 | return inner 63 | -------------------------------------------------------------------------------- /src/tools/choose.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | if TYPE_CHECKING: 4 | from rich.console import Console 5 | 6 | from src.tools import ColorfulConsole 7 | 8 | __all__ = ["choose"] 9 | 10 | 11 | def choose( 12 | title: str, 13 | options: tuple | list, 14 | console: Union["ColorfulConsole", "Console"], 15 | separate=None, 16 | ) -> str: 17 | screen = f"{title}:\n" 18 | for i, j in enumerate(options, start=1): 19 | screen += f"{i: >2d}. {j}\n" 20 | if separate and i in separate: 21 | screen += f"{'=' * 32}\n" 22 | return console.input(screen) 23 | -------------------------------------------------------------------------------- /src/tools/cleaner.py: -------------------------------------------------------------------------------- 1 | from platform import system 2 | from re import compile 3 | from string import whitespace 4 | 5 | from emoji import replace_emoji 6 | 7 | try: 8 | from ..translation import _ 9 | except ImportError: 10 | _ = lambda x: x 11 | 12 | __all__ = ["Cleaner"] 13 | 14 | 15 | class Cleaner: 16 | CONTROL_CHARACTERS = compile(r"[\x00-\x1F\x7F]") 17 | 18 | def __init__(self): 19 | """ 20 | 替换字符串中包含的非法字符,默认根据系统类型生成对应的非法字符字典,也可以自行设置非法字符字典 21 | """ 22 | self.rule = self.default_rule() # 默认非法字符字典 23 | 24 | @staticmethod 25 | def default_rule(): 26 | """根据系统类型生成默认非法字符字典""" 27 | if (s := system()) in ("Windows", "Darwin"): 28 | rule = { 29 | "/": "", 30 | "\\": "", 31 | "|": "", 32 | "<": "", 33 | ">": "", 34 | '"': "", 35 | "?": "", 36 | ":": "", 37 | "*": "", 38 | "\x00": "", 39 | } # Windows 系统和 Mac 系统 40 | elif s == "Linux": 41 | rule = { 42 | "/": "", 43 | "\x00": "", 44 | } # Linux 系统 45 | else: 46 | print(_("不受支持的操作系统类型,可能无法正常去除非法字符!")) 47 | rule = {} 48 | cache = {i: "" for i in whitespace[1:]} # 补充换行符等非法字符 49 | return rule | cache 50 | 51 | def set_rule(self, rule: dict[str, str], update=False): 52 | """ 53 | 设置非法字符字典 54 | 55 | :param rule: 替换规则,字典格式,键为非法字符,值为替换后的内容 56 | :param update: 如果是 True,则与原有规则字典合并,否则替换原有规则字典 57 | """ 58 | self.rule = {**self.rule, **rule} if update else rule 59 | 60 | def filter(self, text: str) -> str: 61 | """ 62 | 去除非法字符 63 | 64 | :param text: 待处理的字符串 65 | :return: 替换后的字符串,如果替换后字符串为空,则返回 None 66 | """ 67 | for i in self.rule: 68 | text = text.replace(i, self.rule[i]) 69 | return text 70 | 71 | def filter_name( 72 | self, 73 | text: str, 74 | default: str = "", 75 | ) -> str: 76 | """过滤文件夹名称中的非法字符""" 77 | text = text.replace(":", ".") 78 | 79 | text = self.remove_control_characters(text) 80 | 81 | text = self.filter(text) 82 | 83 | text = replace_emoji(text) 84 | 85 | text = self.clear_spaces(text) 86 | 87 | text = text.strip().strip(".") 88 | 89 | return text or default 90 | 91 | @staticmethod 92 | def clear_spaces(string: str): 93 | """将连续的空格转换为单个空格""" 94 | return " ".join(string.split()) 95 | 96 | @classmethod 97 | def remove_control_characters( 98 | cls, 99 | text, 100 | replace="", 101 | ): 102 | # 使用正则表达式匹配所有控制字符 103 | return cls.CONTROL_CHARACTERS.sub( 104 | replace, 105 | text, 106 | ) 107 | 108 | 109 | if __name__ == "__main__": 110 | demo = Cleaner() 111 | print(demo.rule) 112 | print(demo.filter_name("")) 113 | print(demo.remove_control_characters("hello \x08world")) 114 | -------------------------------------------------------------------------------- /src/tools/console.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | from rich.text import Text 3 | 4 | from src.custom import ( 5 | PROMPT, 6 | GENERAL, 7 | INFO, 8 | WARNING, 9 | ERROR, 10 | DEBUG, 11 | ) 12 | 13 | __all__ = ["ColorfulConsole"] 14 | 15 | 16 | class ColorfulConsole(Console): 17 | def print(self, *args, style=GENERAL, highlight=False, **kwargs): 18 | super().print(*args, style=style, highlight=highlight, **kwargs) 19 | 20 | def info(self, *args, highlight=False, **kwargs): 21 | self.print(*args, style=INFO, highlight=highlight, **kwargs) 22 | 23 | def warning(self, *args, highlight=False, **kwargs): 24 | self.print(*args, style=WARNING, highlight=highlight, **kwargs) 25 | 26 | def error(self, *args, highlight=False, **kwargs): 27 | self.print(*args, style=ERROR, highlight=highlight, **kwargs) 28 | 29 | def debug(self, *args, highlight=False, **kwargs): 30 | self.print(*args, style=DEBUG, highlight=highlight, **kwargs) 31 | 32 | def input(self, prompt="", style=PROMPT, *args, **kwargs): 33 | try: 34 | return super().input(Text(prompt, style=style), *args, **kwargs) 35 | except EOFError as e: 36 | raise KeyboardInterrupt from e 37 | -------------------------------------------------------------------------------- /src/tools/error.py: -------------------------------------------------------------------------------- 1 | from ..translation import _ 2 | 3 | 4 | class DownloaderError(Exception): 5 | def __init__( 6 | self, 7 | message: str = "", 8 | ): 9 | self.message = message or _("项目代码错误") 10 | super().__init__(self.message) 11 | 12 | def __str__(self): 13 | return f"DownloaderError: {self.message}" 14 | 15 | 16 | class CacheError(Exception): 17 | def __init__(self, message: str): 18 | super().__init__(message) 19 | self.message = message 20 | 21 | def __str__(self): 22 | return self.message 23 | -------------------------------------------------------------------------------- /src/tools/file_folder.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from pathlib import Path 3 | 4 | 5 | def file_switch(path: Path) -> None: 6 | if path.exists(): 7 | path.unlink() 8 | else: 9 | path.touch() 10 | 11 | 12 | def remove_empty_directories(path: Path) -> None: 13 | exclude = { 14 | "\\.", 15 | "\\_", 16 | "\\__", 17 | } 18 | for dir_path, dir_names, file_names in path.walk( 19 | top_down=False, 20 | ): 21 | if any(i in str(dir_path) for i in exclude): 22 | continue 23 | if not dir_names and not file_names: 24 | with suppress(OSError): 25 | dir_path.rmdir() 26 | -------------------------------------------------------------------------------- /src/tools/format.py: -------------------------------------------------------------------------------- 1 | from http.cookiejar import CookieJar 2 | from re import compile 3 | 4 | 5 | def cookie_str_to_dict(cookie_str: str) -> dict: 6 | if not cookie_str: 7 | return {} 8 | cookie = {} 9 | pattern = compile(r"(?P[^=;,]+)=(?P[^;,]+)") 10 | matches = pattern.finditer(cookie_str) 11 | for match in matches: 12 | key = match.group("key").strip() 13 | value = match.group("value").strip() 14 | cookie[key] = value 15 | return cookie 16 | 17 | 18 | def cookie_str_to_str(cookie_str: str) -> str: 19 | if not cookie_str: 20 | return "" 21 | pattern = compile(r", (?=\D)") 22 | return "; ".join(cookie.split("; ")[0] for cookie in pattern.split(cookie_str)) 23 | 24 | 25 | def cookie_dict_to_str(cookie_dict: dict | CookieJar) -> str: 26 | if not cookie_dict: 27 | return "" 28 | cookie_pairs = [f"{key}={value}" for key, value in cookie_dict.items()] 29 | return "; ".join(cookie_pairs) 30 | 31 | 32 | def cookie_jar_to_dict(cookie_jar: CookieJar) -> dict: 33 | return {i.name: i.value for i in cookie_jar} 34 | 35 | 36 | def format_size(size_in_bytes: int) -> str: 37 | units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] 38 | index = 0 39 | while size_in_bytes >= 1024 and index < len(units) - 1: 40 | size_in_bytes /= 1024 41 | index += 1 42 | return f"{size_in_bytes:.2f} {units[index]}" 43 | 44 | 45 | if __name__ == "__main__": 46 | print(format_size(0)) 47 | -------------------------------------------------------------------------------- /src/tools/list_pop.py: -------------------------------------------------------------------------------- 1 | __all__ = ["safe_pop"] 2 | 3 | 4 | def safe_pop(data: list): 5 | return data.pop() if data else None 6 | -------------------------------------------------------------------------------- /src/tools/rename_compatible.py: -------------------------------------------------------------------------------- 1 | from ..custom import PROJECT_ROOT 2 | from shutil import copy2 3 | 4 | 5 | class RenameCompatible: 6 | OLD_DB_FILE = PROJECT_ROOT.joinpath("TikTokDownloader.db") 7 | NEW_DB_FILE = PROJECT_ROOT.joinpath("DouK-Downloader.db") 8 | 9 | @classmethod 10 | def migration_file( 11 | cls, 12 | ): 13 | if cls.OLD_DB_FILE.exists() and not cls.NEW_DB_FILE.exists(): 14 | copy2(cls.OLD_DB_FILE.resolve(), cls.NEW_DB_FILE.resolve()) 15 | -------------------------------------------------------------------------------- /src/tools/retry.py: -------------------------------------------------------------------------------- 1 | from ..custom import RETRY, wait 2 | from ..translation import _ 3 | 4 | __all__ = ["Retry"] 5 | 6 | 7 | class Retry: 8 | """重试器,仅适用于本项目!""" 9 | 10 | @staticmethod 11 | def retry(function): 12 | """发生错误时尝试重新执行,装饰的函数需要返回布尔值""" 13 | 14 | async def inner(self, *args, **kwargs): 15 | finished = kwargs.pop("finished", False) 16 | for i in range(self.max_retry): 17 | if result := await function(self, *args, **kwargs): 18 | return result 19 | self.log.warning(_("正在进行第 {index} 次重试").format(index=i + 1)) 20 | await wait() 21 | if not (result := await function(self, *args, **kwargs)) and finished: 22 | self.finished = True 23 | return result 24 | 25 | return inner 26 | 27 | @staticmethod 28 | def retry_lite(function): 29 | async def inner(*args, **kwargs): 30 | if r := await function(*args, **kwargs): 31 | return r 32 | for _ in range(RETRY): 33 | if r := await function(*args, **kwargs): 34 | return r 35 | await wait() 36 | return r 37 | 38 | return inner 39 | 40 | @staticmethod 41 | def retry_limited(function): 42 | def inner(self, *args, **kwargs): 43 | while True: 44 | if function(self, *args, **kwargs): 45 | return 46 | if self.console.input( 47 | _( 48 | "如需重新尝试处理该对象,请关闭所有正在访问该对象的窗口或程序,然后直接按下回车键!\n" 49 | "如需跳过处理该对象,请输入任意字符后按下回车键!" 50 | ), 51 | ): 52 | return 53 | 54 | return inner 55 | 56 | @staticmethod 57 | def retry_infinite(function): 58 | def inner(self, *args, **kwargs): 59 | while True: 60 | if function(self, *args, **kwargs): 61 | return 62 | self.console.input( 63 | _("请关闭所有正在访问该对象的窗口或程序,然后按下回车键继续处理!") 64 | ) 65 | 66 | return inner 67 | -------------------------------------------------------------------------------- /src/tools/session.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union 2 | 3 | from httpx import AsyncClient, AsyncHTTPTransport, Client, HTTPTransport, Limits 4 | 5 | from ..custom import MAX_WORKERS, TIMEOUT, USERAGENT 6 | from ..tools import DownloaderError 7 | from .capture import capture_error_params 8 | from .retry import Retry 9 | 10 | if TYPE_CHECKING: 11 | from ..record import BaseLogger, LoggerManager 12 | from ..testers import Logger 13 | 14 | __all__ = ["request_params", "create_client"] 15 | 16 | 17 | def create_client( 18 | user_agent=USERAGENT, 19 | timeout=TIMEOUT, 20 | headers: dict = None, 21 | max_connections=MAX_WORKERS, 22 | proxy: str = None, 23 | *args, 24 | **kwargs, 25 | ) -> AsyncClient: 26 | return AsyncClient( 27 | headers=headers 28 | or { 29 | "User-Agent": user_agent, 30 | }, 31 | timeout=timeout, 32 | follow_redirects=True, 33 | verify=False, 34 | limits=Limits(max_connections=max_connections), 35 | mounts={ 36 | "http://": AsyncHTTPTransport(proxy=proxy), 37 | "https://": AsyncHTTPTransport(proxy=proxy), 38 | }, 39 | *args, 40 | **kwargs, 41 | ) 42 | 43 | 44 | async def request_params( 45 | logger: Union[ 46 | "BaseLogger", 47 | "LoggerManager", 48 | "Logger", 49 | ], 50 | url: str, 51 | method: str = "POST", 52 | params: dict | str = None, 53 | data: dict | str = None, 54 | useragent=USERAGENT, 55 | timeout=TIMEOUT, 56 | headers: dict = None, 57 | resp="headers", 58 | proxy: str = None, 59 | **kwargs, 60 | ): 61 | with Client( 62 | headers=headers 63 | or { 64 | "User-Agent": useragent, 65 | "Content-Type": "application/json; charset=utf-8", 66 | # "Referer": "https://www.douyin.com/" 67 | }, 68 | follow_redirects=True, 69 | timeout=timeout, 70 | verify=False, 71 | mounts={ 72 | "http://": HTTPTransport(proxy=proxy), 73 | "https://": HTTPTransport(proxy=proxy), 74 | }, 75 | ) as client: 76 | return await request( 77 | logger, 78 | client, 79 | method, 80 | url, 81 | resp, 82 | params=params, 83 | data=data, 84 | **kwargs, 85 | ) 86 | 87 | 88 | @Retry.retry_lite 89 | @capture_error_params 90 | async def request( 91 | logger: Union[ 92 | "BaseLogger", 93 | "LoggerManager", 94 | "Logger", 95 | ], 96 | client: Client, 97 | method: str, 98 | url: str, 99 | resp="json", 100 | **kwargs, 101 | ): 102 | response = client.request(method, url, **kwargs) 103 | response.raise_for_status() 104 | match resp: 105 | case "headers": 106 | return response.headers 107 | case "text": 108 | return response.text 109 | case "content": 110 | return response.content 111 | case "json": 112 | return response.json() 113 | case "url": 114 | return str(response.url) 115 | case "response": 116 | return response 117 | case _: 118 | raise DownloaderError 119 | -------------------------------------------------------------------------------- /src/tools/temporary.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | from string import ( 3 | ascii_lowercase, 4 | ascii_uppercase, 5 | digits, 6 | ) 7 | from time import time 8 | 9 | CHARACTER = ascii_lowercase + ascii_uppercase + digits 10 | 11 | 12 | def timestamp() -> str: 13 | return str(time())[:10] 14 | 15 | 16 | def random_string(length: int = 10) -> str: 17 | return "".join(choice(CHARACTER) for _ in range(length)) 18 | 19 | 20 | if __name__ == "__main__": 21 | print(timestamp()) 22 | print(random_string()) 23 | -------------------------------------------------------------------------------- /src/tools/timer.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | 3 | __all__ = ["run_time"] 4 | 5 | 6 | def run_time(function): 7 | def inner(self, *args, **kwargs): 8 | start = time() 9 | result = function(self, *args, **kwargs) 10 | print(f"{function.__name__}运行耗时: {time() - start}s") 11 | return result 12 | 13 | return inner 14 | -------------------------------------------------------------------------------- /src/tools/truncate.py: -------------------------------------------------------------------------------- 1 | from unicodedata import name 2 | 3 | 4 | def is_chinese_char(char: str) -> bool: 5 | return "CJK" in name(char, "") 6 | 7 | 8 | def truncate_string(s: str, length: int = 64) -> str: 9 | count = 0 10 | result = "" 11 | for char in s: 12 | count += 2 if is_chinese_char(char) else 1 13 | if count > length: 14 | break 15 | result += char 16 | return result 17 | 18 | 19 | def trim_string(s: str, length: int = 64) -> str: 20 | length = length // 2 - 2 21 | return f"{s[:length]}...{s[-length:]}" if len(s) > length else s 22 | 23 | 24 | def beautify_string(s: str, length: int = 64) -> str: 25 | count = 0 26 | for char in s: 27 | count += 2 if is_chinese_char(char) else 1 28 | if count > length: 29 | break 30 | else: 31 | return s 32 | length //= 2 33 | start = truncate_string(s, length) 34 | end = truncate_string(s[::-1], length)[::-1] 35 | return f"{start}...{end}" 36 | -------------------------------------------------------------------------------- /src/translation/__init__.py: -------------------------------------------------------------------------------- 1 | from .translate import switch_language, _ 2 | -------------------------------------------------------------------------------- /src/translation/static.py: -------------------------------------------------------------------------------- 1 | TRANSLATE_MAP = { 2 | "发布作品": "Posts", 3 | "喜欢作品": "Liked", 4 | "收藏作品": "Favorites", 5 | "收藏夹": "Collections", 6 | "收藏夹作品": "Collections Works", 7 | "收藏音乐": "Collections Music", 8 | "收藏合集": "Collections Mix", 9 | "收藏短剧": "Collections Series", 10 | "作品": "Works", 11 | "合集": "Mix", 12 | "合辑": "Mix", 13 | "热榜": "HotBoard", 14 | "实况": "LivePhoto", 15 | } 16 | -------------------------------------------------------------------------------- /src/translation/translate.py: -------------------------------------------------------------------------------- 1 | from gettext import translation 2 | from locale import getlocale 3 | from pathlib import Path 4 | 5 | ROOT = Path(__file__).resolve().parent.parent.parent 6 | 7 | 8 | class TranslationManager: 9 | """管理gettext翻译的类""" 10 | 11 | _instance = None # 单例实例 12 | 13 | def __new__(cls, *args, **kwargs): 14 | if not cls._instance: 15 | cls._instance = super(TranslationManager, cls).__new__(cls) 16 | return cls._instance 17 | 18 | def __init__(self, domain="tk", localedir=None): 19 | self.domain = domain 20 | if not localedir: 21 | localedir = ROOT.joinpath("locale") 22 | self.localedir = Path(localedir) 23 | self.current_translator = self.setup_translation( 24 | self.get_language_code(), 25 | ) 26 | 27 | @staticmethod 28 | def get_language_code() -> str: 29 | # 获取当前系统的语言和区域设置 30 | language_code, __ = getlocale() 31 | if not language_code: 32 | return "en_US" 33 | return ( 34 | "zh_CN" 35 | if any( 36 | s in language_code.upper() 37 | for s in ( 38 | "CHINESE", 39 | "ZH", 40 | "CHINA", 41 | ) 42 | ) 43 | else "en_US" 44 | ) 45 | 46 | def setup_translation(self, language: str = "zh_CN"): 47 | """设置gettext翻译环境""" 48 | try: 49 | return translation( 50 | self.domain, 51 | localedir=self.localedir, 52 | languages=[language], 53 | fallback=True, 54 | ) 55 | except FileNotFoundError as e: 56 | print( 57 | f"Warning: Translation files for '{self.domain}' not found. Error: {e}" 58 | ) 59 | return translation(self.domain, fallback=True) 60 | 61 | def switch_language(self, language: str = "en_US"): 62 | """切换当前使用的语言""" 63 | self.current_translator = self.setup_translation(language) 64 | 65 | def gettext(self, message): 66 | """提供gettext方法""" 67 | return self.current_translator.gettext(message) 68 | 69 | 70 | # 初始化TranslationManager单例实例 71 | translation_manager = TranslationManager() 72 | 73 | 74 | def _translate(message): 75 | """辅助函数来简化翻译调用""" 76 | return translation_manager.gettext(message) 77 | 78 | 79 | def switch_language(language: str = "en_US"): 80 | """切换语言并刷新翻译函数""" 81 | global _ 82 | translation_manager.switch_language(language) 83 | _ = translation_manager.gettext 84 | 85 | 86 | # 设置默认翻译函数 87 | _ = _translate 88 | -------------------------------------------------------------------------------- /src/tui_edition/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import App 2 | 3 | __all__ = ["App"] 4 | -------------------------------------------------------------------------------- /src/tui_edition/app.py: -------------------------------------------------------------------------------- 1 | __all__ = ["App"] 2 | 3 | 4 | class App: 5 | pass 6 | -------------------------------------------------------------------------------- /src/tui_edition/setting.py: -------------------------------------------------------------------------------- 1 | __all__ = ["Setting"] 2 | 3 | 4 | class Setting: 5 | pass 6 | -------------------------------------------------------------------------------- /static/images/DouK-Downloader.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/static/images/DouK-Downloader.icns -------------------------------------------------------------------------------- /static/images/DouK-Downloader.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/static/images/DouK-Downloader.ico -------------------------------------------------------------------------------- /static/images/DouK-Downloader.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/static/images/DouK-Downloader.jpg -------------------------------------------------------------------------------- /static/images/DouK-Downloader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/static/images/DouK-Downloader.png -------------------------------------------------------------------------------- /static/images/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/TikTokDownloader/5c3a6fe1fbd7c37784c9790456bf194a29475b38/static/images/blank.png --------------------------------------------------------------------------------