├── .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
--------------------------------------------------------------------------------