├── .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 ├── LICENSE ├── README.md ├── README_EN.md ├── example.py ├── locale ├── README.md ├── en_US │ └── LC_MESSAGES │ │ ├── xhs.mo │ │ └── xhs.po ├── generate_path.py ├── po_to_mo.py ├── xhs.pot └── zh_CN │ └── LC_MESSAGES │ ├── xhs.mo │ └── xhs.po ├── main.py ├── pyproject.toml ├── requirements.txt ├── source ├── CLI │ ├── __init__.py │ └── main.py ├── TUI │ ├── __init__.py │ ├── about.py │ ├── app.py │ ├── index.py │ ├── loading.py │ ├── monitor.py │ ├── progress.py │ ├── record.py │ ├── setting.py │ └── update.py ├── __init__.py ├── application │ ├── __init__.py │ ├── app.py │ ├── download.py │ ├── explore.py │ ├── image.py │ ├── request.py │ └── video.py ├── expansion │ ├── __init__.py │ ├── browser.py │ ├── cleaner.py │ ├── converter.py │ ├── error.py │ ├── file_folder.py │ ├── namespace.py │ └── truncate.py ├── module │ ├── __init__.py │ ├── extend.py │ ├── manager.py │ ├── mapping.py │ ├── model.py │ ├── recorder.py │ ├── settings.py │ ├── static.py │ └── tools.py └── translation │ ├── __init__.py │ └── translate.py ├── static ├── QQ群聊二维码.png ├── Release_Notes.md ├── XHS-Downloader.icns ├── XHS-Downloader.ico ├── XHS-Downloader.jpg ├── XHS-Downloader.js ├── XHS-Downloader.png ├── XHS-Downloader.tcss ├── screenshot │ ├── 命令行模式截图CN1.png │ ├── 命令行模式截图CN2.png │ ├── 命令行模式截图EN1.png │ ├── 命令行模式截图EN2.png │ ├── 用户脚本截图1.png │ ├── 用户脚本截图2.png │ ├── 用户脚本截图3.png │ ├── 程序运行截图CN1.png │ ├── 程序运行截图CN2.png │ ├── 程序运行截图CN3.png │ ├── 程序运行截图EN1.png │ ├── 程序运行截图EN2.png │ ├── 程序运行截图EN3.png │ ├── 脚本安装教程.png │ ├── 获取Cookie示意图.png │ └── 请求头示例图.png ├── 微信赞助二维码.png ├── 支付宝赞助二维码.png ├── 自动滚动页面.js └── 赞助商_TikHub_Logo.png └── 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/XHS-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/XHS-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: XHS-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 }}/xhs-downloader 26 | GHCR_REPO: ghcr.io/${{ secrets.DOCKERHUB_USERNAME }}/xhs-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/XHS-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/XHS-Downloader.icns --add-data "static:static" --add-data "locale:locale" --collect-all emoji main.py 44 | 45 | - name: 创建压缩包 46 | run: | 47 | 7z a "XHS-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 | ./XHS-Downloader_V*.zip 55 | name: XHS-Downloader V${{ github.event.release.tag_name }} 56 | body_path: ./static/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 }}/xhs-downloader 16 | GHCR_REPO: ghcr.io/${{ secrets.DOCKERHUB_USERNAME }}/xhs-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 | *.db 7 | *.json 8 | /.idea/ 9 | /Temp/ 10 | !.github/workflows/*.yaml 11 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | LABEL name="XHS-Downloader" authors="JoeanAmier" repository="https://github.com/JoeanAmier/XHS-Downloader" 6 | 7 | COPY locale /app/locale 8 | COPY source /app/source 9 | COPY static/XHS-Downloader.tcss /app/static/XHS-Downloader.tcss 10 | COPY LICENSE /app/LICENSE 11 | COPY main.py /app/main.py 12 | COPY requirements.txt /app/requirements.txt 13 | 14 | RUN pip install --no-cache-dir -r /app/requirements.txt 15 | 16 | EXPOSE 6666 17 | 18 | CMD ["python", "main.py"] 19 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | from asyncio import run 2 | 3 | from httpx import post 4 | from rich import print 5 | 6 | from source import XHS 7 | 8 | 9 | async def example(): 10 | """通过代码设置参数,适合二次开发""" 11 | # 示例链接 12 | demo_link = "https://www.xiaohongshu.com/explore/XXX?xsec_token=XXX" 13 | 14 | # 实例对象 15 | work_path = "D:\\" # 作品数据/文件保存根路径,默认值:项目根路径 16 | folder_name = "Download" # 作品文件储存文件夹名称(自动创建),默认值:Download 17 | name_format = "作品标题 作品描述" 18 | user_agent = "" # User-Agent 19 | cookie = "" # 小红书网页版 Cookie,无需登录,可选参数,登录状态对数据采集有影响 20 | proxy = None # 网络代理 21 | timeout = 5 # 请求数据超时限制,单位:秒,默认值:10 22 | chunk = 1024 * 1024 * 10 # 下载文件时,每次从服务器获取的数据块大小,单位:字节 23 | max_retry = 2 # 请求数据失败时,重试的最大次数,单位:秒,默认值:5 24 | record_data = False # 是否保存作品数据至文件 25 | image_format = "WEBP" # 图文作品文件下载格式,支持:AUTO、PNG、WEBP、JPEG、HEIC 26 | folder_mode = False # 是否将每个作品的文件储存至单独的文件夹 27 | image_download = True # 图文作品文件下载开关 28 | video_download = True # 视频作品文件下载开关 29 | live_download = False # 图文动图文件下载开关 30 | download_record = True # 是否记录下载成功的作品 ID 31 | language = "zh_CN" # 设置程序提示语言 32 | author_archive = True # 是否将每个作者的作品存至单独的文件夹 33 | write_mtime = True # 是否将作品文件的 修改时间 修改为作品的发布时间 34 | read_cookie = None # 读取浏览器 Cookie,支持设置浏览器名称(字符串)或者浏览器序号(整数),设置为 None 代表不读取 35 | 36 | # async with XHS() as xhs: 37 | # pass # 使用默认参数 38 | 39 | async with XHS( 40 | work_path=work_path, 41 | folder_name=folder_name, 42 | name_format=name_format, 43 | user_agent=user_agent, 44 | cookie=cookie, 45 | proxy=proxy, 46 | timeout=timeout, 47 | chunk=chunk, 48 | max_retry=max_retry, 49 | record_data=record_data, 50 | image_format=image_format, 51 | folder_mode=folder_mode, 52 | image_download=image_download, 53 | video_download=video_download, 54 | live_download=live_download, 55 | download_record=download_record, 56 | language=language, 57 | read_cookie=read_cookie, 58 | author_archive=author_archive, 59 | write_mtime=write_mtime, 60 | ) as xhs: # 使用自定义参数 61 | download = True # 是否下载作品文件,默认值:False 62 | # 返回作品详细信息,包括下载地址 63 | # 获取数据失败时返回空字典 64 | print( 65 | await xhs.extract( 66 | demo_link, 67 | download, 68 | index=[ 69 | 1, 70 | 2, 71 | 5, 72 | ], 73 | ) 74 | ) 75 | 76 | 77 | async def example_api(): 78 | """通过 API 设置参数,适合二次开发""" 79 | server = "http://127.0.0.1:6666/xhs/" 80 | data = { 81 | "url": "", # 必需参数 82 | "download": True, 83 | "index": [ 84 | 3, 85 | 6, 86 | 9, 87 | ], 88 | "proxy": "http://127.0.0.1:10808", 89 | } 90 | response = post(server, json=data, timeout=10) 91 | print(response.json()) 92 | 93 | 94 | async def test(): 95 | url = "" 96 | async with XHS( 97 | download_record=False, 98 | # image_format="PNG", 99 | # image_format="WEBP", 100 | # image_format="JPEG", 101 | # image_format="HEIC", 102 | # image_format="AVIF", 103 | # image_format="AUTO", 104 | ) as xhs: 105 | print( 106 | await xhs.extract( 107 | url, 108 | # download=True, 109 | ) 110 | ) 111 | 112 | 113 | if __name__ == "__main__": 114 | # run(example()) 115 | # run(example_api()) 116 | run(test()) 117 | -------------------------------------------------------------------------------- /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 xhs -o xhs.pot` 9 | * `mkdir zh_CN\LC_MESSAGES` 10 | * `msginit -l zh_CN -o zh_CN/LC_MESSAGES/xhs.po -i xhs.pot` 11 | * `mkdir en_US\LC_MESSAGES` 12 | * `msginit -l en_US -o en_US/LC_MESSAGES/xhs.po -i xhs.pot` 13 | * `msgmerge -U zh_CN/LC_MESSAGES/xhs.po xhs.pot` 14 | * `msgmerge -U en_US/LC_MESSAGES/xhs.po xhs.pot` 15 | 16 | # 翻译贡献指南 17 | 18 | * 如果想要贡献支持更多语言,请在终端切换至 `locale` 文件夹,运行命令 19 | `msginit -l 语言代码 -o 语言代码/LC_MESSAGES/xhs.po -i xhs.pot` 20 | 生成 po 文件并编辑翻译。 21 | * 如果想要贡献改进翻译结果,请直接编辑 `xhs.po` 文件内容。 22 | * 仅需提交 `xhs.po` 文件,作者会转换格式并合并。 23 | 24 | # Translation Contribution Guide 25 | 26 | * If you want to contribute support for more languages, please switch to the `locale` folder in the terminal and run the 27 | command `msginit -l language_code -o language_code/LC_MESSAGES/xhs.po -i xhs.pot` to generate the po file and edit the 28 | translation. 29 | * If you want to contribute to improving the translation, please directly edit the content of the `xhs.po` file. 30 | * Only the `xhs.po` file needs to be submitted, and the author will convert the format and merge it. 31 | -------------------------------------------------------------------------------- /locale/en_US/LC_MESSAGES/xhs.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/locale/en_US/LC_MESSAGES/xhs.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("source") # 源目录 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/xhs.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/xhs.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: XHS-Downloader 2.6\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-05-17 15:41+0800\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:178 21 | #, python-brace-format 22 | msgid "作品 {0} 存在下载记录,跳过下载" 23 | msgstr "" 24 | 25 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:195 26 | msgid "提取作品文件下载地址失败" 27 | msgstr "" 28 | 29 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:225 30 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:252 31 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:554 32 | msgid "提取小红书作品链接失败" 33 | msgstr "" 34 | 35 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:227 36 | #, python-brace-format 37 | msgid "共 {0} 个小红书作品待处理..." 38 | msgstr "" 39 | 40 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:297 41 | #, python-brace-format 42 | msgid "作品 {0} 存在下载记录,跳过处理" 43 | msgstr "" 44 | 45 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:300 46 | #, python-brace-format 47 | msgid "开始处理作品:{0}" 48 | msgstr "" 49 | 50 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:309 51 | #, python-brace-format 52 | msgid "{0} 获取数据失败" 53 | msgstr "" 54 | 55 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:314 56 | #, python-brace-format 57 | msgid "{0} 提取数据失败" 58 | msgstr "" 59 | 60 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:316 61 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:83 62 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:79 63 | msgid "视频" 64 | msgstr "" 65 | 66 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:319 67 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:90 68 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:80 69 | msgid "图文" 70 | msgstr "" 71 | 72 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:320 73 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:79 74 | msgid "图集" 75 | msgstr "" 76 | 77 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:324 78 | #, python-brace-format 79 | msgid "未知的作品类型:{0}" 80 | msgstr "" 81 | 82 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:328 83 | #, python-brace-format 84 | msgid "作品处理完成:{0}" 85 | msgstr "" 86 | 87 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:406 88 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\monitor.py:57 89 | msgid "" 90 | "程序会自动读取并提取剪贴板中的小红书作品链接,并自动下载链接对应的作品文件," 91 | "如需关闭,请点击关闭按钮,或者向剪贴板写入 “close” 文本!" 92 | msgstr "" 93 | 94 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:567 95 | msgid "获取小红书作品数据成功" 96 | msgstr "" 97 | 98 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:569 99 | msgid "获取小红书作品数据失败" 100 | msgstr "" 101 | 102 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:130 103 | msgid "视频作品下载功能已关闭,跳过下载" 104 | msgstr "" 105 | 106 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:147 107 | msgid "图文作品下载功能已关闭,跳过下载" 108 | msgstr "" 109 | 110 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:182 111 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:193 112 | #, python-brace-format 113 | msgid "{0} 文件已存在,跳过下载" 114 | msgstr "" 115 | 116 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:239 117 | #, python-brace-format 118 | msgid "文件 {0} 缓存异常,重新下载" 119 | msgstr "" 120 | 121 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:267 122 | #, python-brace-format 123 | msgid "文件 {0} 下载成功" 124 | msgstr "" 125 | 126 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:273 127 | #, python-brace-format 128 | msgid "网络异常,{0} 下载失败,错误信息: {1}" 129 | msgstr "" 130 | 131 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:350 132 | #, python-brace-format 133 | msgid "文件 {0} 格式判断失败,错误信息:{1}" 134 | msgstr "" 135 | 136 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:53 137 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:58 138 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:77 139 | msgid "未知" 140 | msgstr "" 141 | 142 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\request.py:80 143 | #, python-brace-format 144 | msgid "网络异常,{0} 请求失败: {1}" 145 | msgstr "" 146 | 147 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:124 148 | msgid "小红书作品链接" 149 | msgstr "" 150 | 151 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:131 152 | msgid "下载指定序号的图片文件,仅对图文作品生效;多个序号输入示例:\"1 3 5 7\"" 153 | msgstr "" 154 | 155 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:136 156 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:30 157 | msgid "作品数据 / 文件保存根路径" 158 | msgstr "" 159 | 160 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:137 161 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:40 162 | msgid "作品文件储存文件夹名称" 163 | msgstr "" 164 | 165 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:138 166 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:49 167 | msgid "作品文件名称格式" 168 | msgstr "" 169 | 170 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:140 171 | msgid "小红书网页版 Cookie,无需登录" 172 | msgstr "" 173 | 174 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:141 175 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:78 176 | msgid "网络代理" 177 | msgstr "" 178 | 179 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:142 180 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:88 181 | msgid "请求数据超时限制,单位:秒" 182 | msgstr "" 183 | 184 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:148 185 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:98 186 | msgid "下载文件时,每次从服务器获取的数据块大小,单位:字节" 187 | msgstr "" 188 | 189 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:151 190 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:108 191 | msgid "请求数据失败时,重试的最大次数" 192 | msgstr "" 193 | 194 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:152 195 | msgid "是否记录作品数据至文件" 196 | msgstr "" 197 | 198 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:157 199 | msgid "图文作品文件下载格式,支持:PNG、WEBP" 200 | msgstr "" 201 | 202 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:159 203 | msgid "动态图片下载开关" 204 | msgstr "" 205 | 206 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:160 207 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:149 208 | msgid "作品下载记录开关" 209 | msgstr "" 210 | 211 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:165 212 | msgid "是否将每个作品的文件储存至单独的文件夹" 213 | msgstr "" 214 | 215 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:171 216 | msgid "是否将每个作者的作品储存至单独的文件夹" 217 | msgstr "" 218 | 219 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:178 220 | msgid "是否将作品文件的修改时间属性修改为作品的发布时间" 221 | msgstr "" 222 | 223 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:182 224 | msgid "设置程序语言,目前支持:zh_CN、en_US" 225 | msgstr "" 226 | 227 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:183 228 | msgid "读取指定配置文件" 229 | msgstr "" 230 | 231 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:190 232 | #, python-brace-format 233 | msgid "从指定的浏览器读取小红书网页版 Cookie,支持:{0}; 输入浏览器名称或序号" 234 | msgstr "" 235 | 236 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:203 237 | msgid "是否更新配置文件" 238 | msgstr "" 239 | 240 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:204 241 | msgid "查看详细参数说明" 242 | msgstr "" 243 | 244 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:205 245 | msgid "查看 XHS-Downloader 版本" 246 | msgstr "" 247 | 248 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\browser.py:53 249 | #, python-brace-format 250 | msgid "" 251 | "读取指定浏览器的 Cookie 并写入配置文件\n" 252 | "Windows 系统需要以管理员身份运行程序才能读取 Chromium、Chrome、Edge 浏览器 " 253 | "Cookie!\n" 254 | "{options}\n" 255 | "请输入浏览器名称或序号:" 256 | msgstr "" 257 | 258 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\browser.py:63 259 | msgid "未选择浏览器!" 260 | msgstr "" 261 | 262 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\browser.py:74 263 | msgid "浏览器名称或序号输入错误!" 264 | msgstr "" 265 | 266 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\browser.py:80 267 | msgid "获取 Cookie 失败,未找到 Cookie 数据!" 268 | msgstr "" 269 | 270 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\browser.py:118 271 | msgid "从浏览器读取 Cookie 功能不支持当前平台!" 272 | msgstr "" 273 | 274 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\cleaner.py:45 275 | msgid "不受支持的操作系统类型,可能无法正常去除非法字符!" 276 | msgstr "" 277 | 278 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\manager.py:226 279 | #, python-brace-format 280 | msgid "代理 {0} 测试成功" 281 | msgstr "" 282 | 283 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\manager.py:230 284 | #, python-brace-format 285 | msgid "代理 {0} 测试超时" 286 | msgstr "" 287 | 288 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\manager.py:238 289 | #, python-brace-format 290 | msgid "代理 {0} 测试失败:{1}" 291 | msgstr "" 292 | 293 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:57 294 | #, python-brace-format 295 | msgid "{old_folder} 文件夹不存在,跳过处理" 296 | msgstr "" 297 | 298 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:86 299 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:108 300 | msgid "文件夹" 301 | msgstr "" 302 | 303 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:91 304 | #, python-brace-format 305 | msgid "文件夹 {old_folder} 已重命名为 {new_folder}" 306 | msgstr "" 307 | 308 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:113 309 | #, python-brace-format 310 | msgid "文件夹 {old_} 重命名为 {new_}" 311 | msgstr "" 312 | 313 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:186 314 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:201 315 | msgid "文件" 316 | msgstr "" 317 | 318 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:191 319 | #, python-brace-format 320 | msgid "文件 {old_file} 重命名为 {new_file}" 321 | msgstr "" 322 | 323 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:210 324 | #, python-brace-format 325 | msgid "{type} {old}被占用,重命名失败: {error}" 326 | msgstr "" 327 | 328 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:219 329 | #, python-brace-format 330 | msgid "{type} {new}名称重复,重命名失败: {error}" 331 | msgstr "" 332 | 333 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:228 334 | #, python-brace-format 335 | msgid "处理{type} {old}时发生预期之外的错误: {error}" 336 | msgstr "" 337 | 338 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\tools.py:31 339 | msgid "" 340 | "如需重新尝试处理该对象,请关闭所有正在访问该对象的窗口或程序,然后直接按下回" 341 | "车键!\n" 342 | "如需跳过处理该对象,请输入任意字符后按下回车键!" 343 | msgstr "" 344 | 345 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:20 346 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:29 347 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\monitor.py:21 348 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:15 349 | msgid "退出程序" 350 | msgstr "" 351 | 352 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:21 353 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:30 354 | msgid "检查更新" 355 | msgstr "" 356 | 357 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:22 358 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\record.py:35 359 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:16 360 | msgid "返回首页" 361 | msgstr "" 362 | 363 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:35 364 | msgid "如果 XHS-Downloader 对您有帮助,请考虑为它点个 Star,感谢您的支持!" 365 | msgstr "" 366 | 367 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:42 368 | msgid "Discord 社区" 369 | msgstr "" 370 | 371 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:46 372 | msgid "邀请链接:" 373 | msgstr "" 374 | 375 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:48 376 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:61 377 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:70 378 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:56 379 | msgid "点击访问" 380 | msgstr "" 381 | 382 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:51 383 | msgid "作者的其他开源项目" 384 | msgstr "" 385 | 386 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\app.py:74 387 | msgid "" 388 | "配置文件 settings.json 缺少必要的参数,请删除该文件,然后重新运行程序,自动生" 389 | "成默认配置文件!" 390 | msgstr "" 391 | 392 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:31 393 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:212 394 | msgid "程序设置" 395 | msgstr "" 396 | 397 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:32 398 | msgid "下载记录" 399 | msgstr "" 400 | 401 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:33 402 | msgid "开启监听" 403 | msgstr "" 404 | 405 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:34 406 | msgid "关于项目" 407 | msgstr "" 408 | 409 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:49 410 | msgid "开源协议: " 411 | msgstr "" 412 | 413 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:52 414 | msgid "项目地址: " 415 | msgstr "" 416 | 417 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:59 418 | msgid "请输入小红书图文/视频作品链接" 419 | msgstr "" 420 | 421 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:62 422 | msgid "多个链接之间使用空格分隔" 423 | msgstr "" 424 | 425 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:64 426 | msgid "下载无水印作品文件" 427 | msgstr "" 428 | 429 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:65 430 | msgid "读取剪贴板" 431 | msgstr "" 432 | 433 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:66 434 | msgid "清空输入框" 435 | msgstr "" 436 | 437 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:81 438 | msgid "免责声明\n" 439 | msgstr "" 440 | 441 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:94 442 | msgid "未输入任何小红书作品链接" 443 | msgstr "" 444 | 445 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:124 446 | msgid "下载小红书作品文件失败" 447 | msgstr "" 448 | 449 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\loading.py:19 450 | msgid "程序处理中..." 451 | msgstr "" 452 | 453 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\monitor.py:22 454 | msgid "关闭监听" 455 | msgstr "" 456 | 457 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\monitor.py:34 458 | msgid "已启动监听剪贴板模式" 459 | msgstr "" 460 | 461 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\monitor.py:36 462 | msgid "退出监听剪贴板模式" 463 | msgstr "" 464 | 465 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\record.py:23 466 | msgid "请输入待删除的小红书作品链接或作品 ID" 467 | msgstr "" 468 | 469 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\record.py:26 470 | msgid "" 471 | "支持输入作品 ID 或包含作品 ID 的作品链接,多个链接或 ID 之间使用空格分隔" 472 | msgstr "" 473 | 474 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\record.py:32 475 | msgid "删除指定作品 ID" 476 | msgstr "" 477 | 478 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\record.py:47 479 | msgid "删除下载记录成功" 480 | msgstr "" 481 | 482 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:35 483 | msgid "程序根路径" 484 | msgstr "" 485 | 486 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:64 487 | msgid "内置 Chrome User Agent" 488 | msgstr "" 489 | 490 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:69 491 | msgid "小红书网页版 Cookie" 492 | msgstr "" 493 | 494 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:83 495 | msgid "不使用代理" 496 | msgstr "" 497 | 498 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:120 499 | msgid "记录作品详细数据" 500 | msgstr "" 501 | 502 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:125 503 | msgid "作品归档保存模式" 504 | msgstr "" 505 | 506 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:130 507 | msgid "视频作品下载开关" 508 | msgstr "" 509 | 510 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:135 511 | msgid "图文作品下载开关" 512 | msgstr "" 513 | 514 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:144 515 | msgid "动图文件下载开关" 516 | msgstr "" 517 | 518 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:154 519 | msgid "作者归档保存模式" 520 | msgstr "" 521 | 522 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:159 523 | msgid "更新文件修改时间" 524 | msgstr "" 525 | 526 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:167 527 | msgid "图片下载格式" 528 | msgstr "" 529 | 530 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:171 531 | msgid "程序语言" 532 | msgstr "" 533 | 534 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:194 535 | msgid "保存配置" 536 | msgstr "" 537 | 538 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:198 539 | msgid "放弃更改" 540 | msgstr "" 541 | 542 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:208 543 | msgid "小红书网页版 Cookie,无需登录,参数已设置" 544 | msgstr "" 545 | 546 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:209 547 | msgid "小红书网页版 Cookie,无需登录,参数未设置" 548 | msgstr "" 549 | 550 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:26 551 | msgid "正在检查新版本,请稍等..." 552 | msgstr "" 553 | 554 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:46 555 | #, python-brace-format 556 | msgid "检测到新版本:{0}.{1}" 557 | msgstr "" 558 | 559 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:54 560 | msgid "当前版本为开发版, 可更新至正式版" 561 | msgstr "" 562 | 563 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:59 564 | msgid "当前已是最新开发版" 565 | msgstr "" 566 | 567 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:64 568 | msgid "当前已是最新正式版" 569 | msgstr "" 570 | 571 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:71 572 | msgid "检测新版本失败" 573 | msgstr "" 574 | -------------------------------------------------------------------------------- /locale/zh_CN/LC_MESSAGES/xhs.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/locale/zh_CN/LC_MESSAGES/xhs.mo -------------------------------------------------------------------------------- /locale/zh_CN/LC_MESSAGES/xhs.po: -------------------------------------------------------------------------------- 1 | # Chinese translations for XHS-Downloader package 2 | # Copyright (C) 2024 THE XHS-Downloader'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the XHS-Downloader package. 4 | # FIRST AUTHOR , 2024. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: XHS-Downloader 2.6\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2025-05-17 15:41+0800\n" 11 | "PO-Revision-Date: 2024-12-22 14:14+0800\n" 12 | "Last-Translator: \n" 13 | "Language-Team: Chinese (simplified)\n" 14 | "Language: zh_CN\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=1; plural=0;\n" 19 | 20 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:178 21 | #, python-brace-format 22 | msgid "作品 {0} 存在下载记录,跳过下载" 23 | msgstr "" 24 | 25 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:195 26 | msgid "提取作品文件下载地址失败" 27 | msgstr "" 28 | 29 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:225 30 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:252 31 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:554 32 | msgid "提取小红书作品链接失败" 33 | msgstr "" 34 | 35 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:227 36 | #, python-brace-format 37 | msgid "共 {0} 个小红书作品待处理..." 38 | msgstr "" 39 | 40 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:297 41 | #, python-brace-format 42 | msgid "作品 {0} 存在下载记录,跳过处理" 43 | msgstr "" 44 | 45 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:300 46 | #, python-brace-format 47 | msgid "开始处理作品:{0}" 48 | msgstr "" 49 | 50 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:309 51 | #, python-brace-format 52 | msgid "{0} 获取数据失败" 53 | msgstr "" 54 | 55 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:314 56 | #, python-brace-format 57 | msgid "{0} 提取数据失败" 58 | msgstr "" 59 | 60 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:316 61 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:83 62 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:79 63 | msgid "视频" 64 | msgstr "" 65 | 66 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:319 67 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:90 68 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:80 69 | msgid "图文" 70 | msgstr "" 71 | 72 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:320 73 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:79 74 | msgid "图集" 75 | msgstr "" 76 | 77 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:324 78 | #, python-brace-format 79 | msgid "未知的作品类型:{0}" 80 | msgstr "" 81 | 82 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:328 83 | #, python-brace-format 84 | msgid "作品处理完成:{0}" 85 | msgstr "" 86 | 87 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:406 88 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\monitor.py:57 89 | msgid "" 90 | "程序会自动读取并提取剪贴板中的小红书作品链接,并自动下载链接对应的作品文件," 91 | "如需关闭,请点击关闭按钮,或者向剪贴板写入 “close” 文本!" 92 | msgstr "" 93 | 94 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:567 95 | msgid "获取小红书作品数据成功" 96 | msgstr "" 97 | 98 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\app.py:569 99 | msgid "获取小红书作品数据失败" 100 | msgstr "" 101 | 102 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:130 103 | msgid "视频作品下载功能已关闭,跳过下载" 104 | msgstr "" 105 | 106 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:147 107 | msgid "图文作品下载功能已关闭,跳过下载" 108 | msgstr "" 109 | 110 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:182 111 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:193 112 | #, python-brace-format 113 | msgid "{0} 文件已存在,跳过下载" 114 | msgstr "" 115 | 116 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:239 117 | #, python-brace-format 118 | msgid "文件 {0} 缓存异常,重新下载" 119 | msgstr "" 120 | 121 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:267 122 | #, python-brace-format 123 | msgid "文件 {0} 下载成功" 124 | msgstr "" 125 | 126 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:273 127 | #, python-brace-format 128 | msgid "网络异常,{0} 下载失败,错误信息: {1}" 129 | msgstr "" 130 | 131 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\download.py:350 132 | #, python-brace-format 133 | msgid "文件 {0} 格式判断失败,错误信息:{1}" 134 | msgstr "" 135 | 136 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:53 137 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:58 138 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\explore.py:77 139 | msgid "未知" 140 | msgstr "" 141 | 142 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\application\request.py:80 143 | #, python-brace-format 144 | msgid "网络异常,{0} 请求失败: {1}" 145 | msgstr "" 146 | 147 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:124 148 | msgid "小红书作品链接" 149 | msgstr "" 150 | 151 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:131 152 | msgid "下载指定序号的图片文件,仅对图文作品生效;多个序号输入示例:\"1 3 5 7\"" 153 | msgstr "" 154 | 155 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:136 156 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:30 157 | msgid "作品数据 / 文件保存根路径" 158 | msgstr "" 159 | 160 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:137 161 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:40 162 | msgid "作品文件储存文件夹名称" 163 | msgstr "" 164 | 165 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:138 166 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:49 167 | msgid "作品文件名称格式" 168 | msgstr "" 169 | 170 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:140 171 | msgid "小红书网页版 Cookie,无需登录" 172 | msgstr "" 173 | 174 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:141 175 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:78 176 | msgid "网络代理" 177 | msgstr "" 178 | 179 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:142 180 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:88 181 | msgid "请求数据超时限制,单位:秒" 182 | msgstr "" 183 | 184 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:148 185 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:98 186 | msgid "下载文件时,每次从服务器获取的数据块大小,单位:字节" 187 | msgstr "" 188 | 189 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:151 190 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:108 191 | msgid "请求数据失败时,重试的最大次数" 192 | msgstr "" 193 | 194 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:152 195 | msgid "是否记录作品数据至文件" 196 | msgstr "" 197 | 198 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:157 199 | msgid "图文作品文件下载格式,支持:PNG、WEBP" 200 | msgstr "" 201 | 202 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:159 203 | msgid "动态图片下载开关" 204 | msgstr "" 205 | 206 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:160 207 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:149 208 | msgid "作品下载记录开关" 209 | msgstr "" 210 | 211 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:165 212 | msgid "是否将每个作品的文件储存至单独的文件夹" 213 | msgstr "" 214 | 215 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:171 216 | msgid "是否将每个作者的作品储存至单独的文件夹" 217 | msgstr "" 218 | 219 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:178 220 | msgid "是否将作品文件的修改时间属性修改为作品的发布时间" 221 | msgstr "" 222 | 223 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:182 224 | msgid "设置程序语言,目前支持:zh_CN、en_US" 225 | msgstr "" 226 | 227 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:183 228 | msgid "读取指定配置文件" 229 | msgstr "" 230 | 231 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:190 232 | #, python-brace-format 233 | msgid "从指定的浏览器读取小红书网页版 Cookie,支持:{0}; 输入浏览器名称或序号" 234 | msgstr "" 235 | 236 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:203 237 | msgid "是否更新配置文件" 238 | msgstr "" 239 | 240 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:204 241 | msgid "查看详细参数说明" 242 | msgstr "" 243 | 244 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\CLI\main.py:205 245 | msgid "查看 XHS-Downloader 版本" 246 | msgstr "" 247 | 248 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\browser.py:53 249 | #, python-brace-format 250 | msgid "" 251 | "读取指定浏览器的 Cookie 并写入配置文件\n" 252 | "Windows 系统需要以管理员身份运行程序才能读取 Chromium、Chrome、Edge 浏览器 " 253 | "Cookie!\n" 254 | "{options}\n" 255 | "请输入浏览器名称或序号:" 256 | msgstr "" 257 | 258 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\browser.py:63 259 | msgid "未选择浏览器!" 260 | msgstr "" 261 | 262 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\browser.py:74 263 | msgid "浏览器名称或序号输入错误!" 264 | msgstr "" 265 | 266 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\browser.py:80 267 | msgid "获取 Cookie 失败,未找到 Cookie 数据!" 268 | msgstr "" 269 | 270 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\browser.py:118 271 | msgid "从浏览器读取 Cookie 功能不支持当前平台!" 272 | msgstr "" 273 | 274 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\expansion\cleaner.py:45 275 | msgid "不受支持的操作系统类型,可能无法正常去除非法字符!" 276 | msgstr "" 277 | 278 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\manager.py:226 279 | #, python-brace-format 280 | msgid "代理 {0} 测试成功" 281 | msgstr "" 282 | 283 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\manager.py:230 284 | #, python-brace-format 285 | msgid "代理 {0} 测试超时" 286 | msgstr "" 287 | 288 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\manager.py:238 289 | #, python-brace-format 290 | msgid "代理 {0} 测试失败:{1}" 291 | msgstr "" 292 | 293 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:57 294 | #, python-brace-format 295 | msgid "{old_folder} 文件夹不存在,跳过处理" 296 | msgstr "" 297 | 298 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:86 299 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:108 300 | msgid "文件夹" 301 | msgstr "" 302 | 303 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:91 304 | #, python-brace-format 305 | msgid "文件夹 {old_folder} 已重命名为 {new_folder}" 306 | msgstr "" 307 | 308 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:113 309 | #, python-brace-format 310 | msgid "文件夹 {old_} 重命名为 {new_}" 311 | msgstr "" 312 | 313 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:186 314 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:201 315 | msgid "文件" 316 | msgstr "" 317 | 318 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:191 319 | #, python-brace-format 320 | msgid "文件 {old_file} 重命名为 {new_file}" 321 | msgstr "" 322 | 323 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:210 324 | #, python-brace-format 325 | msgid "{type} {old}被占用,重命名失败: {error}" 326 | msgstr "" 327 | 328 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:219 329 | #, python-brace-format 330 | msgid "{type} {new}名称重复,重命名失败: {error}" 331 | msgstr "" 332 | 333 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\mapping.py:228 334 | #, python-brace-format 335 | msgid "处理{type} {old}时发生预期之外的错误: {error}" 336 | msgstr "" 337 | 338 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\module\tools.py:31 339 | msgid "" 340 | "如需重新尝试处理该对象,请关闭所有正在访问该对象的窗口或程序,然后直接按下回" 341 | "车键!\n" 342 | "如需跳过处理该对象,请输入任意字符后按下回车键!" 343 | msgstr "" 344 | 345 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:20 346 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:29 347 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\monitor.py:21 348 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:15 349 | msgid "退出程序" 350 | msgstr "" 351 | 352 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:21 353 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:30 354 | msgid "检查更新" 355 | msgstr "" 356 | 357 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:22 358 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\record.py:35 359 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:16 360 | msgid "返回首页" 361 | msgstr "" 362 | 363 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:35 364 | msgid "如果 XHS-Downloader 对您有帮助,请考虑为它点个 Star,感谢您的支持!" 365 | msgstr "" 366 | 367 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:42 368 | msgid "Discord 社区" 369 | msgstr "" 370 | 371 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:46 372 | msgid "邀请链接:" 373 | msgstr "" 374 | 375 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:48 376 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:61 377 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:70 378 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:56 379 | msgid "点击访问" 380 | msgstr "" 381 | 382 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\about.py:51 383 | msgid "作者的其他开源项目" 384 | msgstr "" 385 | 386 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\app.py:74 387 | msgid "" 388 | "配置文件 settings.json 缺少必要的参数,请删除该文件,然后重新运行程序,自动生" 389 | "成默认配置文件!" 390 | msgstr "" 391 | 392 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:31 393 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:212 394 | msgid "程序设置" 395 | msgstr "" 396 | 397 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:32 398 | msgid "下载记录" 399 | msgstr "" 400 | 401 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:33 402 | msgid "开启监听" 403 | msgstr "" 404 | 405 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:34 406 | msgid "关于项目" 407 | msgstr "" 408 | 409 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:49 410 | msgid "开源协议: " 411 | msgstr "" 412 | 413 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:52 414 | msgid "项目地址: " 415 | msgstr "" 416 | 417 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:59 418 | msgid "请输入小红书图文/视频作品链接" 419 | msgstr "" 420 | 421 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:62 422 | msgid "多个链接之间使用空格分隔" 423 | msgstr "" 424 | 425 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:64 426 | msgid "下载无水印作品文件" 427 | msgstr "" 428 | 429 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:65 430 | msgid "读取剪贴板" 431 | msgstr "" 432 | 433 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:66 434 | msgid "清空输入框" 435 | msgstr "" 436 | 437 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:81 438 | msgid "免责声明\n" 439 | msgstr "" 440 | "关于 XHS-Downloader 的 免责声明:\n" 441 | "\n" 442 | "1. 使用者对本项目的使用由使用者自行决定,并自行承担风险。作者对使用者使用本项" 443 | "目所产生的任何损失、责任、或风险概不负责。\n" 444 | "2. 本项目的作者提供的代码和功能是基于现有知识和技术的开发成果。作者按现有技术" 445 | "水平努力确保代码的正确性和安全性,但不保证代码完全没有错误或缺陷。\n" 446 | "3. 本项目依赖的所有第三方库、插件或服务各自遵循其原始开源或商业许可,使用者需" 447 | "自行查阅并遵守相应协议,作者不对第三方组件的稳定性、安全性及合规性承担任何责" 448 | "任。\n" 449 | "4. 使用者在使用本项目时必须严格遵守 GNU General Public License v3.0 的要求," 450 | "并在适当的地方注明使用了 GNU General Public License v3.0 的代码。\n" 451 | "5. 使用者在使用本项目的代码和功能时,必须自行研究相关法律法规,并确保其使用行" 452 | "为合法合规。任何因违反法律法规而导致的法律责任和风险,均由使用者自行承担。\n" 453 | "6. 使用者不得使用本工具从事任何侵犯知识产权的行为,包括但不限于未经授权下载、" 454 | "传播受版权保护的内容,开发者不参与、不支持、不认可任何非法内容的获取或分" 455 | "发。\n" 456 | "7. 本项目不对使用者涉及的数据收集、存储、传输等处理活动的合规性承担责任。使用" 457 | "者应自行遵守相关法律法规,确保处理行为合法正当;因违规操作导致的法律责任由使" 458 | "用者自行承担。\n" 459 | "8. 使用者在任何情况下均不得将本项目的作者、贡献者或其他相关方与使用者的使用行" 460 | "为联系起来,或要求其对使用者使用本项目所产生的任何损失或损害负责。\n" 461 | "9. 本项目的作者不会提供 XHS-Downloader 项目的付费版本,也不会提供与 XHS-" 462 | "Downloader 项目相关的任何商业服务。\n" 463 | "10. 基于本项目进行的任何二次开发、修改或编译的程序与原创作者无关,原创作者不" 464 | "承担与二次开发行为或其结果相关的任何责任,使用者应自行对因二次开发可能带来的" 465 | "各种情况负全部责任。\n" 466 | "11. 本项目不授予使用者任何专利许可;若使用本项目导致专利纠纷或侵权,使用者自" 467 | "行承担全部风险和责任。未经作者或权利人书面授权,不得使用本项目进行任何商业宣" 468 | "传、推广或再授权。\n" 469 | "12. 作者保留随时终止向任何违反本声明的使用者提供服务的权利,并可能要求其销毁" 470 | "已获取的代码及衍生作品。\n" 471 | "13. 作者保留在不另行通知的情况下更新本声明的权利,使用者持续使用即视为接受修" 472 | "订后的条款。\n" 473 | "\n" 474 | "在使用本项目的代码和功能之前,请您认真考虑并接受以上免责声明。如果您对上述声" 475 | "明有任何疑问或不同意,请不要使用本项目的代码和功能。如果您使用了本项目的代码" 476 | "和功能,则视为您已完全理解并接受上述免责声明,并自愿承担使用本项目的一切风险" 477 | "和后果。\n" 478 | 479 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:94 480 | msgid "未输入任何小红书作品链接" 481 | msgstr "" 482 | 483 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\index.py:124 484 | msgid "下载小红书作品文件失败" 485 | msgstr "" 486 | 487 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\loading.py:19 488 | msgid "程序处理中..." 489 | msgstr "" 490 | 491 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\monitor.py:22 492 | msgid "关闭监听" 493 | msgstr "" 494 | 495 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\monitor.py:34 496 | msgid "已启动监听剪贴板模式" 497 | msgstr "" 498 | 499 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\monitor.py:36 500 | msgid "退出监听剪贴板模式" 501 | msgstr "" 502 | 503 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\record.py:23 504 | msgid "请输入待删除的小红书作品链接或作品 ID" 505 | msgstr "" 506 | 507 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\record.py:26 508 | msgid "" 509 | "支持输入作品 ID 或包含作品 ID 的作品链接,多个链接或 ID 之间使用空格分隔" 510 | msgstr "" 511 | 512 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\record.py:32 513 | msgid "删除指定作品 ID" 514 | msgstr "" 515 | 516 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\record.py:47 517 | msgid "删除下载记录成功" 518 | msgstr "" 519 | 520 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:35 521 | msgid "程序根路径" 522 | msgstr "" 523 | 524 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:64 525 | msgid "内置 Chrome User Agent" 526 | msgstr "" 527 | 528 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:69 529 | msgid "小红书网页版 Cookie" 530 | msgstr "" 531 | 532 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:83 533 | msgid "不使用代理" 534 | msgstr "" 535 | 536 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:120 537 | msgid "记录作品详细数据" 538 | msgstr "" 539 | 540 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:125 541 | msgid "作品归档保存模式" 542 | msgstr "" 543 | 544 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:130 545 | msgid "视频作品下载开关" 546 | msgstr "" 547 | 548 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:135 549 | msgid "图文作品下载开关" 550 | msgstr "" 551 | 552 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:144 553 | msgid "动图文件下载开关" 554 | msgstr "" 555 | 556 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:154 557 | msgid "作者归档保存模式" 558 | msgstr "" 559 | 560 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:159 561 | msgid "更新文件修改时间" 562 | msgstr "" 563 | 564 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:167 565 | msgid "图片下载格式" 566 | msgstr "" 567 | 568 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:171 569 | msgid "程序语言" 570 | msgstr "" 571 | 572 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:194 573 | msgid "保存配置" 574 | msgstr "" 575 | 576 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:198 577 | msgid "放弃更改" 578 | msgstr "" 579 | 580 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:208 581 | msgid "小红书网页版 Cookie,无需登录,参数已设置" 582 | msgstr "" 583 | 584 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\setting.py:209 585 | msgid "小红书网页版 Cookie,无需登录,参数未设置" 586 | msgstr "" 587 | 588 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:26 589 | msgid "正在检查新版本,请稍等..." 590 | msgstr "" 591 | 592 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:46 593 | #, python-brace-format 594 | msgid "检测到新版本:{0}.{1}" 595 | msgstr "" 596 | 597 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:54 598 | msgid "当前版本为开发版, 可更新至正式版" 599 | msgstr "" 600 | 601 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:59 602 | msgid "当前已是最新开发版" 603 | msgstr "" 604 | 605 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:64 606 | msgid "当前已是最新正式版" 607 | msgstr "" 608 | 609 | #: C:\Users\You\PycharmProjects\XHS-Downloader\source\TUI\update.py:71 610 | msgid "检测新版本失败" 611 | msgstr "" 612 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from asyncio import run 2 | from asyncio.exceptions import CancelledError 3 | from contextlib import suppress 4 | from sys import argv 5 | 6 | from source import Settings 7 | from source import XHS 8 | from source import XHSDownloader 9 | from source import cli 10 | 11 | 12 | async def app(): 13 | async with XHSDownloader() as xhs: 14 | await xhs.run_async() 15 | 16 | 17 | async def server( 18 | host="0.0.0.0", 19 | port=6666, 20 | log_level="info", 21 | ): 22 | async with XHS(**Settings().run()) as xhs: 23 | await xhs.run_server( 24 | host, 25 | port, 26 | log_level, 27 | ) 28 | 29 | 30 | if __name__ == "__main__": 31 | with suppress( 32 | KeyboardInterrupt, 33 | CancelledError, 34 | ): 35 | if len(argv) == 1: 36 | run(app()) 37 | elif argv[1] == "server": 38 | run(server()) 39 | else: 40 | cli() 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "XHS-Downloader" 3 | version = "2.6" 4 | description = "小红书(XiaoHongShu、RedNote)链接提取/作品采集工具:提取账号发布、收藏、点赞、专辑作品链接;提取搜索结果作品、用户链接;采集小红书作品信息;提取小红书作品下载地址;下载小红书无水印作品文件" 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 | "click>=8.1.8", 15 | "emoji>=2.14.1", 16 | "fastapi>=0.115.9", 17 | "httpx[socks]>=0.28.1", 18 | "lxml>=5.3.1", 19 | "pyperclip>=1.9.0", 20 | "pyyaml>=6.0.2", 21 | "rookiepy>=0.5.6", 22 | "textual>=3.1.0", 23 | "uvicorn>=0.34.0", 24 | ] 25 | 26 | [project.urls] 27 | Repository = "https://github.com/JoeanAmier/XHS-Downloader" 28 | 29 | [tool.uv.pip] 30 | index-url = "https://mirrors.ustc.edu.cn/pypi/simple" 31 | 32 | [tool.ruff] 33 | # Exclude a variety of commonly ignored directories. 34 | exclude = [ 35 | ".bzr", 36 | ".direnv", 37 | ".eggs", 38 | ".git", 39 | ".git-rewrite", 40 | ".hg", 41 | ".ipynb_checkpoints", 42 | ".mypy_cache", 43 | ".nox", 44 | ".pants.d", 45 | ".pyenv", 46 | ".pytest_cache", 47 | ".pytype", 48 | ".ruff_cache", 49 | ".svn", 50 | ".tox", 51 | ".venv", 52 | ".vscode", 53 | "__pypackages__", 54 | "_build", 55 | "buck-out", 56 | "build", 57 | "dist", 58 | "node_modules", 59 | "site-packages", 60 | "venv", 61 | ] 62 | 63 | # Same as Black. 64 | line-length = 88 65 | indent-width = 4 66 | 67 | # Assume Python 3.12 68 | target-version = "py312" 69 | 70 | [tool.ruff.lint] 71 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 72 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 73 | # McCabe complexity (`C901`) by default. 74 | select = ["E4", "E7", "E9", "F"] 75 | ignore = [] 76 | 77 | # Allow fix for all enabled rules (when `--fix`) is provided. 78 | fixable = ["ALL"] 79 | unfixable = [] 80 | 81 | # Allow unused variables when underscore-prefixed. 82 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 83 | 84 | [tool.ruff.format] 85 | # Like Black, use double quotes for strings. 86 | quote-style = "double" 87 | 88 | # Like Black, indent with spaces, rather than tabs. 89 | indent-style = "space" 90 | 91 | # Like Black, respect magic trailing commas. 92 | skip-magic-trailing-comma = false 93 | 94 | # Like Black, automatically detect the appropriate line ending. 95 | line-ending = "auto" 96 | 97 | # Enable auto-formatting of code examples in docstrings. Markdown, 98 | # reStructuredText code/literal blocks and doctests are all supported. 99 | # 100 | # This is currently disabled by default, but it is planned for this 101 | # to be opt-out in the future. 102 | docstring-code-format = false 103 | 104 | # Set the line length limit used when formatting code snippets in 105 | # docstrings. 106 | # 107 | # This only has an effect when the `docstring-code-format` setting is 108 | # enabled. 109 | docstring-code-line-length = "dynamic" 110 | 111 | [dependency-groups] 112 | dev = [ 113 | "textual-dev>=1.7.0", 114 | ] 115 | -------------------------------------------------------------------------------- /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 xhs-downloader (pyproject.toml) 5 | aiosqlite==0.21.0 6 | # via xhs-downloader (pyproject.toml) 7 | click==8.1.8 8 | # via xhs-downloader (pyproject.toml) 9 | emoji==2.14.1 10 | # via xhs-downloader (pyproject.toml) 11 | fastapi==0.115.12 12 | # via xhs-downloader (pyproject.toml) 13 | httpx[socks]==0.28.1 14 | # via xhs-downloader (pyproject.toml) 15 | lxml==5.3.2 16 | # via xhs-downloader (pyproject.toml) 17 | pyperclip==1.9.0 18 | # via xhs-downloader (pyproject.toml) 19 | pyyaml==6.0.2 20 | # via xhs-downloader (pyproject.toml) 21 | rookiepy==0.5.6 22 | # via xhs-downloader (pyproject.toml) 23 | textual==3.1.0 24 | # via xhs-downloader (pyproject.toml) 25 | uvicorn==0.34.2 26 | # via xhs-downloader (pyproject.toml) 27 | -------------------------------------------------------------------------------- /source/CLI/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import cli 2 | 3 | __all__ = ["cli"] 4 | -------------------------------------------------------------------------------- /source/CLI/main.py: -------------------------------------------------------------------------------- 1 | from asyncio import run 2 | from contextlib import suppress 3 | from pathlib import Path as Root 4 | from textwrap import fill 5 | 6 | from click import Context 7 | from click import ( 8 | command, 9 | option, 10 | Path, 11 | Choice, 12 | pass_context, 13 | echo, 14 | ) 15 | from rich import print 16 | from rich.panel import Panel 17 | from rich.table import Table 18 | 19 | from source.application import XHS 20 | from source.expansion import BrowserCookie 21 | from source.module import ( 22 | ROOT, 23 | PROJECT, 24 | ) 25 | from source.module import Settings 26 | from source.translation import switch_language, _ 27 | 28 | __all__ = ["cli"] 29 | 30 | 31 | def check_value(function): 32 | def inner(ctx: Context, param, value): 33 | if not value: 34 | return 35 | return function(ctx, param, value) 36 | 37 | return inner 38 | 39 | 40 | class CLI: 41 | def __init__(self, ctx: Context, **kwargs): 42 | self.ctx = ctx 43 | self.url = ctx.params.pop("url") 44 | self.index = self.__format_index(ctx.params.pop("index")) 45 | self.path = ctx.params.pop("settings") 46 | self.update = ctx.params.pop("update_settings") 47 | self.settings = Settings(self.__check_settings_path()) 48 | self.parameter = self.settings.run() | self.__clean_params(ctx.params) 49 | self.APP = XHS(**self.parameter) 50 | 51 | async def __aenter__(self): 52 | await self.APP.__aenter__() 53 | return self 54 | 55 | async def __aexit__(self, exc_type, exc_value, traceback): 56 | await self.APP.__aexit__(exc_type, exc_value, traceback) 57 | 58 | async def run(self): 59 | if self.url: 60 | await self.APP.extract_cli(self.url, index=self.index) 61 | self.__update_settings() 62 | 63 | def __update_settings(self): 64 | if self.update: 65 | self.settings.update(self.parameter) 66 | 67 | def __check_settings_path(self) -> Path: 68 | if not self.path: 69 | return ROOT 70 | return s.parent if (s := Root(self.path)).is_file() else ROOT 71 | 72 | @staticmethod 73 | def __merge_cookie(data: dict) -> None: 74 | if not data["cookie"] and (bc := data["browser_cookie"]): 75 | data["cookie"] = bc 76 | data.pop("browser_cookie") 77 | 78 | def __clean_params(self, data: dict) -> dict: 79 | self.__merge_cookie(data) 80 | return {k: v for k, v in data.items() if v} 81 | 82 | @staticmethod 83 | def __format_index(index: str) -> list: 84 | if index: 85 | result = [] 86 | values = index.split() 87 | for i in values: 88 | with suppress(ValueError): 89 | result.append(int(i)) 90 | return result 91 | return [] 92 | 93 | @staticmethod 94 | @check_value 95 | def version(ctx: Context, param, value) -> None: 96 | echo(PROJECT) 97 | ctx.exit() 98 | 99 | @staticmethod 100 | @check_value 101 | def read_cookie(ctx: Context, param, value) -> str: 102 | return BrowserCookie.get( 103 | value, 104 | domains=[ 105 | "xiaohongshu.com", 106 | ], 107 | ) 108 | 109 | @staticmethod 110 | @check_value 111 | def help_(ctx: Context, param, value) -> None: 112 | table = Table(highlight=True, box=None, show_header=True) 113 | 114 | # 添加表格的列名 115 | table.add_column("parameter", no_wrap=True, style="bold") 116 | table.add_column("abbreviation", no_wrap=True, style="bold") 117 | table.add_column("type", no_wrap=True, style="bold") 118 | table.add_column( 119 | "description", 120 | no_wrap=True, 121 | ) 122 | 123 | options = ( 124 | ("--url", "-u", "str", _("小红书作品链接")), 125 | ( 126 | "--index", 127 | "-i", 128 | "str", 129 | fill( 130 | _( 131 | '下载指定序号的图片文件,仅对图文作品生效;多个序号输入示例:"1 3 5 7"' 132 | ), 133 | width=55, 134 | ), 135 | ), 136 | ("--work_path", "-wp", "str", _("作品数据 / 文件保存根路径")), 137 | ("--folder_name", "-fn", "str", _("作品文件储存文件夹名称")), 138 | ("--name_format", "-nf", "str", _("作品文件名称格式")), 139 | ("--user_agent", "-ua", "str", "User-Agent"), 140 | ("--cookie", "-ck", "str", _("小红书网页版 Cookie,无需登录")), 141 | ("--proxy", "-p", "str", _("网络代理")), 142 | ("--timeout", "-t", "int", _("请求数据超时限制,单位:秒")), 143 | ( 144 | "--chunk", 145 | "-c", 146 | "int", 147 | fill( 148 | _("下载文件时,每次从服务器获取的数据块大小,单位:字节"), width=55 149 | ), 150 | ), 151 | ("--max_retry", "-mr", "int", _("请求数据失败时,重试的最大次数")), 152 | ("--record_data", "-rd", "bool", _("是否记录作品数据至文件")), 153 | ( 154 | "--image_format", 155 | "-if", 156 | "choice", 157 | _("图文作品文件下载格式,支持:PNG、WEBP"), 158 | ), 159 | ("--live_download", "-ld", "bool", _("动态图片下载开关")), 160 | ("--download_record", "-dr", "bool", _("作品下载记录开关")), 161 | ( 162 | "--folder_mode", 163 | "-fm", 164 | "bool", 165 | _("是否将每个作品的文件储存至单独的文件夹"), 166 | ), 167 | ( 168 | "--author_archive", 169 | "-aa", 170 | "bool", 171 | _("是否将每个作者的作品储存至单独的文件夹"), 172 | ), 173 | ( 174 | "--write_mtime", 175 | "-wm", 176 | "bool", 177 | fill( 178 | _("是否将作品文件的修改时间属性修改为作品的发布时间"), 179 | width=55, 180 | ), 181 | ), 182 | ("--language", "-l", "choice", _("设置程序语言,目前支持:zh_CN、en_US")), 183 | ("--settings", "-s", "str", _("读取指定配置文件")), 184 | ( 185 | "--browser_cookie", 186 | "-bc", 187 | "choice", 188 | fill( 189 | _( 190 | "从指定的浏览器读取小红书网页版 Cookie,支持:{0}; 输入浏览器名称或序号" 191 | ).format( 192 | ", ".join( 193 | f"{i}: {j}" 194 | for i, j in enumerate( 195 | BrowserCookie.SUPPORT_BROWSER.keys(), 196 | start=1, 197 | ) 198 | ) 199 | ), 200 | width=55, 201 | ), 202 | ), 203 | ("--update_settings", "-us", "flag", _("是否更新配置文件")), 204 | ("--help", "-h", "flag", _("查看详细参数说明")), 205 | ("--version", "-v", "flag", _("查看 XHS-Downloader 版本")), 206 | ) 207 | 208 | for option in options: 209 | table.add_row(*option) 210 | 211 | print( 212 | Panel( 213 | table, 214 | border_style="bold", 215 | title="XHS-Downloader CLI Parameters", 216 | title_align="left", 217 | ) 218 | ) 219 | 220 | 221 | @command(name="XHS-Downloader", help=PROJECT) 222 | @option( 223 | "--url", 224 | "-u", 225 | ) 226 | @option( 227 | "--index", 228 | "-i", 229 | ) 230 | @option( 231 | "--work_path", 232 | "-wp", 233 | type=Path(file_okay=False), 234 | ) 235 | @option( 236 | "--folder_name", 237 | "-fn", 238 | ) 239 | @option( 240 | "--name_format", 241 | "-nf", 242 | ) 243 | @option( 244 | "--user_agent", 245 | "-ua", 246 | ) 247 | @option( 248 | "--cookie", 249 | "-ck", 250 | ) 251 | @option( 252 | "--proxy", 253 | "-p", 254 | ) 255 | @option( 256 | "--timeout", 257 | "-t", 258 | type=int, 259 | ) 260 | @option( 261 | "--chunk", 262 | "-c", 263 | type=int, 264 | ) 265 | @option( 266 | "--max_retry", 267 | "-mr", 268 | type=int, 269 | ) 270 | @option( 271 | "--record_data", 272 | "-rd", 273 | type=bool, 274 | ) 275 | @option( 276 | "--image_format", 277 | "-if", 278 | type=Choice(["png", "PNG", "webp", "WEBP"]), 279 | ) 280 | @option( 281 | "--live_download", 282 | "-ld", 283 | type=bool, 284 | ) 285 | @option( 286 | "--download_record", 287 | "-dr", 288 | type=bool, 289 | ) 290 | @option( 291 | "--folder_mode", 292 | "-fm", 293 | type=bool, 294 | ) 295 | @option( 296 | "--author_archive", 297 | "-aa", 298 | type=bool, 299 | ) 300 | @option( 301 | "--write_mtime", 302 | "-wm", 303 | type=bool, 304 | ) 305 | @option( 306 | "--language", 307 | "-l", 308 | type=Choice(["zh_CN", "en_US"]), 309 | ) 310 | @option( 311 | "--settings", 312 | "-s", 313 | type=Path(dir_okay=False), 314 | ) 315 | @option( 316 | "--browser_cookie", 317 | "-bc", 318 | type=Choice( 319 | list(BrowserCookie.SUPPORT_BROWSER.keys()) 320 | + [str(i) for i in range(1, len(BrowserCookie.SUPPORT_BROWSER) + 1)] 321 | ), 322 | callback=CLI.read_cookie, 323 | ) 324 | @option( 325 | "--update_settings", 326 | "-us", 327 | type=bool, 328 | is_flag=True, 329 | ) 330 | @option( 331 | "-h", 332 | "--help", 333 | is_flag=True, 334 | ) 335 | @option( 336 | "--version", 337 | "-v", 338 | is_flag=True, 339 | is_eager=True, 340 | expose_value=False, 341 | callback=CLI.version, 342 | ) 343 | @pass_context 344 | def cli(ctx, help, language, **kwargs): 345 | # Step 1: 切换语言 346 | if language: 347 | switch_language(language) 348 | 349 | # Step 2: 如果请求了帮助信息,则显示帮助并退出 350 | if help: 351 | ctx.obj = kwargs # 保留当前上下文的参数 352 | CLI.help_(ctx, None, help) 353 | return 354 | 355 | # Step 3: 主逻辑 356 | async def main(): 357 | async with CLI(ctx, **kwargs) as xhs: 358 | await xhs.run() 359 | 360 | run(main()) 361 | 362 | 363 | if __name__ == "__main__": 364 | from click.testing import CliRunner 365 | 366 | runner = CliRunner() 367 | result = runner.invoke(cli, ["-l", "en_US", "-u", ""]) 368 | -------------------------------------------------------------------------------- /source/TUI/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import XHSDownloader 2 | 3 | __all__ = ["XHSDownloader"] 4 | -------------------------------------------------------------------------------- /source/TUI/about.py: -------------------------------------------------------------------------------- 1 | from rich.text import Text 2 | from textual.app import ComposeResult 3 | from textual.binding import Binding 4 | from textual.screen import Screen 5 | from textual.widgets import Footer, Header, Label, Link 6 | 7 | from ..module import ( 8 | INFO, 9 | MASTER, 10 | PROJECT, 11 | PROMPT, 12 | ) 13 | from ..translation import _ 14 | 15 | __all__ = ["About"] 16 | 17 | 18 | class About(Screen): 19 | BINDINGS = [ 20 | Binding(key="Q", action="quit", description=_("退出程序")), 21 | Binding(key="U", action="update", description=_("检查更新")), 22 | Binding(key="B", action="back", description=_("返回首页")), 23 | ] 24 | 25 | def __init__( 26 | self, 27 | ): 28 | super().__init__() 29 | 30 | def compose(self) -> ComposeResult: 31 | yield Header() 32 | yield Label( 33 | Text( 34 | _( 35 | "如果 XHS-Downloader 对您有帮助,请考虑为它点个 Star,感谢您的支持!" 36 | ), 37 | style=INFO, 38 | ), 39 | classes="prompt", 40 | ) 41 | yield Label( 42 | Text(_("Discord 社区"), style=PROMPT), 43 | classes="prompt", 44 | ) 45 | yield Link( 46 | _("邀请链接:") + "https://discord.com/invite/ZYtmgKud9Y", 47 | url="https://discord.com/invite/ZYtmgKud9Y", 48 | tooltip=_("点击访问"), 49 | ) 50 | yield Label( 51 | Text(_("作者的其他开源项目"), style=PROMPT), 52 | classes="prompt", 53 | ) 54 | yield Label( 55 | Text("DouK-Downloader (抖音 / TikTok)", style=MASTER), 56 | classes="prompt", 57 | ) 58 | yield Link( 59 | "https://github.com/JoeanAmier/TikTokDownloader", 60 | url="https://github.com/JoeanAmier/TikTokDownloader", 61 | tooltip=_("点击访问"), 62 | ) 63 | yield Label( 64 | Text("KS-Downloader (快手)", style=MASTER), 65 | classes="prompt", 66 | ) 67 | yield Link( 68 | "https://github.com/JoeanAmier/KS-Downloader", 69 | url="https://github.com/JoeanAmier/KS-Downloader", 70 | tooltip=_("点击访问"), 71 | ) 72 | yield Footer() 73 | 74 | def on_mount(self) -> None: 75 | self.title = PROJECT 76 | 77 | async def action_quit(self) -> None: 78 | await self.app.action_quit() 79 | 80 | async def action_back(self) -> None: 81 | await self.app.action_back() 82 | 83 | async def action_update(self): 84 | await self.app.run_action("update") 85 | -------------------------------------------------------------------------------- /source/TUI/app.py: -------------------------------------------------------------------------------- 1 | from textual.app import App 2 | from textual.widgets import RichLog 3 | 4 | from ..application import XHS 5 | from ..module import ( 6 | ERROR, 7 | ROOT, 8 | Settings, 9 | logging, 10 | ) 11 | from ..translation import _ 12 | from .about import About 13 | from .index import Index 14 | from .loading import Loading 15 | from .record import Record 16 | from .setting import Setting 17 | from .update import Update 18 | 19 | __all__ = ["XHSDownloader"] 20 | 21 | 22 | class XHSDownloader(App): 23 | CSS_PATH = ROOT.joinpath("static/XHS-Downloader.tcss") 24 | SETTINGS = Settings(ROOT) 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self.parameter: dict 29 | self.APP: XHS 30 | self.__initialization() 31 | 32 | async def __aenter__(self): 33 | await self.APP.__aenter__() 34 | return self 35 | 36 | async def __aexit__(self, exc_type, exc_value, traceback): 37 | await self.APP.__aexit__(exc_type, exc_value, traceback) 38 | 39 | def __initialization(self) -> None: 40 | self.parameter = self.SETTINGS.run() 41 | self.APP = XHS( 42 | **self.parameter, 43 | _print=False, 44 | ) 45 | 46 | async def on_mount(self) -> None: 47 | self.theme = "nord" 48 | self.install_screen( 49 | Setting( 50 | self.parameter, 51 | ), 52 | name="setting", 53 | ) 54 | self.install_screen( 55 | Index( 56 | self.APP, 57 | ), 58 | name="index", 59 | ) 60 | self.install_screen(Loading(), name="loading") 61 | self.install_screen(About(), name="about") 62 | self.install_screen( 63 | Record( 64 | self.APP, 65 | ), 66 | name="record", 67 | ) 68 | await self.push_screen("index") 69 | self.SETTINGS.check_keys( 70 | self.parameter, 71 | logging, 72 | self.screen.query_one(RichLog), 73 | _( 74 | "配置文件 settings.json 缺少必要的参数,请删除该文件,然后重新运行程序,自动生成默认配置文件!" 75 | ) 76 | + f"\n{'>' * 50}", 77 | ERROR, 78 | ) 79 | 80 | async def action_settings(self): 81 | async def save_settings(data: dict) -> None: 82 | self.SETTINGS.update(data) 83 | await self.refresh_screen() 84 | 85 | await self.push_screen("setting", save_settings) 86 | 87 | async def refresh_screen(self): 88 | await self.action_back() 89 | await self.close_database() 90 | await self.APP.close() 91 | self.__initialization() 92 | await self.__aenter__() 93 | self.uninstall_screen("index") 94 | self.uninstall_screen("setting") 95 | self.uninstall_screen("loading") 96 | self.uninstall_screen("about") 97 | self.uninstall_screen("record") 98 | self.install_screen( 99 | Index( 100 | self.APP, 101 | ), 102 | name="index", 103 | ) 104 | self.install_screen( 105 | Setting( 106 | self.parameter, 107 | ), 108 | name="setting", 109 | ) 110 | self.install_screen(Loading(), name="loading") 111 | self.install_screen(About(), name="about") 112 | self.install_screen( 113 | Record( 114 | self.APP, 115 | ), 116 | name="record", 117 | ) 118 | await self.push_screen("index") 119 | 120 | def update_result(self, args: tuple[str, str]) -> None: 121 | self.notify( 122 | args[0], 123 | severity=args[1], 124 | ) 125 | 126 | async def action_update(self): 127 | await self.push_screen( 128 | Update( 129 | self.APP, 130 | ), 131 | callback=self.update_result, 132 | ) 133 | 134 | async def close_database(self): 135 | await self.APP.id_recorder.cursor.close() 136 | await self.APP.id_recorder.database.close() 137 | await self.APP.data_recorder.cursor.close() 138 | await self.APP.data_recorder.database.close() 139 | -------------------------------------------------------------------------------- /source/TUI/index.py: -------------------------------------------------------------------------------- 1 | from pyperclip import paste 2 | from rich.text import Text 3 | from textual import on, work 4 | from textual.app import ComposeResult 5 | from textual.binding import Binding 6 | from textual.containers import HorizontalScroll, ScrollableContainer 7 | from textual.screen import Screen 8 | from textual.widgets import Button, Footer, Header, Input, Label, Link, RichLog 9 | 10 | from ..application import XHS 11 | from ..module import ( 12 | ERROR, 13 | GENERAL, 14 | LICENCE, 15 | MASTER, 16 | PROJECT, 17 | PROMPT, 18 | REPOSITORY, 19 | WARNING, 20 | ) 21 | from ..translation import _ 22 | from .monitor import Monitor 23 | 24 | __all__ = ["Index"] 25 | 26 | 27 | class Index(Screen): 28 | BINDINGS = [ 29 | Binding(key="Q", action="quit", description=_("退出程序")), 30 | Binding(key="U", action="update", description=_("检查更新")), 31 | Binding(key="S", action="settings", description=_("程序设置")), 32 | Binding(key="R", action="record", description=_("下载记录")), 33 | Binding(key="M", action="monitor", description=_("开启监听")), 34 | Binding(key="A", action="about", description=_("关于项目")), 35 | ] 36 | 37 | def __init__( 38 | self, 39 | app: XHS, 40 | ): 41 | super().__init__() 42 | self.xhs = app 43 | self.url = None 44 | self.tip = None 45 | 46 | def compose(self) -> ComposeResult: 47 | yield Header() 48 | yield ScrollableContainer( 49 | Label(Text(_("开源协议: ") + LICENCE, style=MASTER)), 50 | Link( 51 | Text( 52 | _("项目地址: ") + REPOSITORY, 53 | style=MASTER, 54 | ), 55 | url=REPOSITORY, 56 | tooltip=_("点击访问"), 57 | ), 58 | Label( 59 | Text(_("请输入小红书图文/视频作品链接"), style=PROMPT), 60 | classes="prompt", 61 | ), 62 | Input(placeholder=_("多个链接之间使用空格分隔")), 63 | HorizontalScroll( 64 | Button(_("下载无水印作品文件"), id="deal"), 65 | Button(_("读取剪贴板"), id="paste"), 66 | Button(_("清空输入框"), id="reset"), 67 | ), 68 | ) 69 | yield RichLog( 70 | markup=True, 71 | wrap=True, 72 | auto_scroll=True, 73 | ) 74 | yield Footer() 75 | 76 | def on_mount(self) -> None: 77 | self.title = PROJECT 78 | self.url = self.query_one(Input) 79 | self.tip = self.query_one(RichLog) 80 | self.tip.write( 81 | Text(_("免责声明\n") + f"\n{'>' * 50}", style=MASTER), 82 | scroll_end=True, 83 | ) 84 | self.xhs.manager.print_proxy_tip( 85 | log=self.tip, 86 | ) 87 | 88 | @on(Button.Pressed, "#deal") 89 | async def deal_button(self): 90 | if self.url.value: 91 | self.deal() 92 | else: 93 | self.tip.write( 94 | Text(_("未输入任何小红书作品链接"), style=WARNING), 95 | scroll_end=True, 96 | ) 97 | self.tip.write( 98 | Text(">" * 50, style=GENERAL), 99 | scroll_end=True, 100 | ) 101 | 102 | @on(Button.Pressed, "#reset") 103 | def reset_button(self): 104 | self.query_one(Input).value = "" 105 | 106 | @on(Button.Pressed, "#paste") 107 | def paste_button(self): 108 | self.query_one(Input).value = paste() 109 | 110 | @work(exclusive=True) 111 | async def deal(self): 112 | await self.app.push_screen("loading") 113 | if any( 114 | await self.xhs.extract( 115 | self.url.value, 116 | True, 117 | log=self.tip, 118 | data=False, 119 | ) 120 | ): 121 | self.url.value = "" 122 | else: 123 | self.tip.write( 124 | Text(_("下载小红书作品文件失败"), style=ERROR), 125 | animate=True, 126 | scroll_end=True, 127 | ) 128 | self.tip.write( 129 | Text(">" * 50, style=GENERAL), 130 | scroll_end=True, 131 | ) 132 | await self.app.action_back() 133 | 134 | async def action_quit(self) -> None: 135 | await self.app.action_quit() 136 | 137 | async def action_update(self) -> None: 138 | await self.app.run_action("update") 139 | 140 | async def action_settings(self): 141 | await self.app.run_action("settings") 142 | 143 | async def action_monitor(self): 144 | await self.app.push_screen( 145 | Monitor( 146 | self.xhs, 147 | ) 148 | ) 149 | 150 | async def action_about(self): 151 | await self.app.push_screen("about") 152 | 153 | async def action_record(self): 154 | await self.app.push_screen("record") 155 | -------------------------------------------------------------------------------- /source/TUI/loading.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.containers import Grid 3 | from textual.screen import ModalScreen 4 | from textual.widgets import Label, LoadingIndicator 5 | 6 | from ..translation import _ 7 | 8 | __all__ = ["Loading"] 9 | 10 | 11 | class Loading(ModalScreen): 12 | def __init__( 13 | self, 14 | ): 15 | super().__init__() 16 | 17 | def compose(self) -> ComposeResult: 18 | yield Grid( 19 | Label(_("程序处理中...")), 20 | LoadingIndicator(), 21 | classes="loading", 22 | ) 23 | -------------------------------------------------------------------------------- /source/TUI/monitor.py: -------------------------------------------------------------------------------- 1 | from rich.text import Text 2 | from textual import on, work 3 | from textual.app import ComposeResult 4 | from textual.binding import Binding 5 | from textual.screen import Screen 6 | from textual.widgets import Button, Footer, Header, Label, RichLog 7 | 8 | from ..application import XHS 9 | from ..module import ( 10 | INFO, 11 | MASTER, 12 | PROJECT, 13 | ) 14 | from ..translation import _ 15 | 16 | __all__ = ["Monitor"] 17 | 18 | 19 | class Monitor(Screen): 20 | BINDINGS = [ 21 | Binding(key="Q", action="quit", description=_("退出程序")), 22 | Binding(key="C", action="close", description=_("关闭监听")), 23 | ] 24 | 25 | def __init__( 26 | self, 27 | app: XHS, 28 | ): 29 | super().__init__() 30 | self.xhs = app 31 | 32 | def compose(self) -> ComposeResult: 33 | yield Header() 34 | yield Label(Text(_("已启动监听剪贴板模式"), style=INFO), classes="prompt") 35 | yield RichLog(markup=True, wrap=True) 36 | yield Button(_("退出监听剪贴板模式"), id="close") 37 | yield Footer() 38 | 39 | @on(Button.Pressed, "#close") 40 | async def close_button(self): 41 | await self.action_close() 42 | 43 | @work(exclusive=True) 44 | async def run_monitor(self): 45 | await self.xhs.monitor( 46 | download=True, 47 | log=self.query_one(RichLog), 48 | data=False, 49 | ) 50 | await self.action_close() 51 | 52 | def on_mount(self) -> None: 53 | self.title = PROJECT 54 | self.query_one(RichLog).write( 55 | Text( 56 | _( 57 | "程序会自动读取并提取剪贴板中的小红书作品链接,并自动下载链接对应的作品文件,如需关闭,请点击关闭按钮,或者向剪贴板写入 “close” 文本!" 58 | ), 59 | style=MASTER, 60 | ), 61 | scroll_end=True, 62 | ) 63 | self.run_monitor() 64 | 65 | async def action_close(self): 66 | self.xhs.stop_monitor() 67 | await self.app.action_back() 68 | 69 | async def action_quit(self) -> None: 70 | await self.action_close() 71 | await self.app.action_quit() 72 | -------------------------------------------------------------------------------- /source/TUI/progress.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.screen import Screen 3 | 4 | __all__ = ["Progress"] 5 | 6 | 7 | class Progress(Screen): 8 | def compose(self) -> ComposeResult: 9 | pass 10 | -------------------------------------------------------------------------------- /source/TUI/record.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.app import ComposeResult 3 | from textual.containers import Grid, HorizontalScroll 4 | from textual.screen import ModalScreen 5 | from textual.widgets import Button, Input, Label 6 | 7 | from ..application import XHS 8 | from ..translation import _ 9 | 10 | __all__ = ["Record"] 11 | 12 | 13 | class Record(ModalScreen): 14 | def __init__( 15 | self, 16 | app: XHS, 17 | ): 18 | super().__init__() 19 | self.xhs = app 20 | 21 | def compose(self) -> ComposeResult: 22 | yield Grid( 23 | Label(_("请输入待删除的小红书作品链接或作品 ID"), classes="prompt"), 24 | Input( 25 | placeholder=_( 26 | "支持输入作品 ID 或包含作品 ID 的作品链接,多个链接或 ID 之间使用空格分隔" 27 | ), 28 | id="id", 29 | ), 30 | HorizontalScroll( 31 | Button( 32 | _("删除指定作品 ID"), 33 | id="enter", 34 | ), 35 | Button(_("返回首页"), id="close"), 36 | ), 37 | id="record", 38 | ) 39 | 40 | async def delete(self, text: str): 41 | text = await self.xhs.extract_links( 42 | text, 43 | None, 44 | ) 45 | text = self.xhs.extract_id(text) 46 | await self.xhs.id_recorder.delete(text) 47 | self.app.notify(_("删除下载记录成功")) 48 | 49 | @on(Button.Pressed, "#enter") 50 | async def save_settings(self): 51 | text = self.query_one(Input) 52 | await self.delete(text.value) 53 | text.value = "" 54 | 55 | @on(Button.Pressed, "#close") 56 | def reset(self): 57 | self.dismiss() 58 | -------------------------------------------------------------------------------- /source/TUI/setting.py: -------------------------------------------------------------------------------- 1 | from textual import on 2 | from textual.app import ComposeResult 3 | from textual.binding import Binding 4 | from textual.containers import Container, ScrollableContainer 5 | from textual.screen import Screen 6 | from textual.widgets import Button, Checkbox, Footer, Header, Input, Label, Select 7 | 8 | from ..translation import _ 9 | 10 | __all__ = ["Setting"] 11 | 12 | 13 | class Setting(Screen): 14 | BINDINGS = [ 15 | Binding(key="Q", action="quit", description=_("退出程序")), 16 | Binding(key="B", action="index", description=_("返回首页")), 17 | ] 18 | 19 | def __init__( 20 | self, 21 | data: dict, 22 | ): 23 | super().__init__() 24 | self.data = data 25 | 26 | def compose(self) -> ComposeResult: 27 | yield Header() 28 | yield ScrollableContainer( 29 | Label( 30 | _("作品数据 / 文件保存根路径"), 31 | classes="params", 32 | ), 33 | Input( 34 | self.data["work_path"], 35 | placeholder=_("程序根路径"), 36 | valid_empty=True, 37 | id="work_path", 38 | ), 39 | Label( 40 | _("作品文件储存文件夹名称"), 41 | classes="params", 42 | ), 43 | Input( 44 | self.data["folder_name"], 45 | placeholder="Download", 46 | id="folder_name", 47 | ), 48 | Label( 49 | _("作品文件名称格式"), 50 | classes="params", 51 | ), 52 | Input( 53 | self.data["name_format"], 54 | placeholder="发布时间 作者昵称 作品标题", 55 | valid_empty=True, 56 | id="name_format", 57 | ), 58 | Label( 59 | "User-Agent", 60 | classes="params", 61 | ), 62 | Input( 63 | self.data["user_agent"], 64 | placeholder=_("内置 Chrome User Agent"), 65 | valid_empty=True, 66 | id="user_agent", 67 | ), 68 | Label( 69 | _("小红书网页版 Cookie"), 70 | classes="params", 71 | ), 72 | Input( 73 | placeholder=self.__check_cookie(), 74 | valid_empty=True, 75 | id="cookie", 76 | ), 77 | Label( 78 | _("网络代理"), 79 | classes="params", 80 | ), 81 | Input( 82 | self.data["proxy"], 83 | placeholder=_("不使用代理"), 84 | valid_empty=True, 85 | id="proxy", 86 | ), 87 | Label( 88 | _("请求数据超时限制,单位:秒"), 89 | classes="params", 90 | ), 91 | Input( 92 | str(self.data["timeout"]), 93 | placeholder="10", 94 | type="integer", 95 | id="timeout", 96 | ), 97 | Label( 98 | _("下载文件时,每次从服务器获取的数据块大小,单位:字节"), 99 | classes="params", 100 | ), 101 | Input( 102 | str(self.data["chunk"]), 103 | placeholder="1048576", 104 | type="integer", 105 | id="chunk", 106 | ), 107 | Label( 108 | _("请求数据失败时,重试的最大次数"), 109 | classes="params", 110 | ), 111 | Input( 112 | str(self.data["max_retry"]), 113 | placeholder="5", 114 | type="integer", 115 | id="max_retry", 116 | ), 117 | Label(), 118 | Container( 119 | Checkbox( 120 | _("记录作品详细数据"), 121 | id="record_data", 122 | value=self.data["record_data"], 123 | ), 124 | Checkbox( 125 | _("作品归档保存模式"), 126 | id="folder_mode", 127 | value=self.data["folder_mode"], 128 | ), 129 | Checkbox( 130 | _("视频作品下载开关"), 131 | id="video_download", 132 | value=self.data["video_download"], 133 | ), 134 | Checkbox( 135 | _("图文作品下载开关"), 136 | id="image_download", 137 | value=self.data["image_download"], 138 | ), 139 | classes="horizontal-layout", 140 | ), 141 | Label(), 142 | Container( 143 | Checkbox( 144 | _("动图文件下载开关"), 145 | id="live_download", 146 | value=self.data["live_download"], 147 | ), 148 | Checkbox( 149 | _("作品下载记录开关"), 150 | id="download_record", 151 | value=self.data["download_record"], 152 | ), 153 | Checkbox( 154 | _("作者归档保存模式"), 155 | id="author_archive", 156 | value=self.data["author_archive"], 157 | ), 158 | Checkbox( 159 | _("更新文件修改时间"), 160 | id="write_mtime", 161 | value=self.data["write_mtime"], 162 | ), 163 | classes="horizontal-layout", 164 | ), 165 | Container( 166 | Label( 167 | _("图片下载格式"), 168 | classes="params", 169 | ), 170 | Label( 171 | _("程序语言"), 172 | classes="params", 173 | ), 174 | classes="horizontal-layout", 175 | ), 176 | Label(), 177 | Container( 178 | Select.from_values( 179 | ("AUTO", "PNG", "WEBP", "JPEG", "HEIC"), 180 | value=self.data["image_format"].upper(), 181 | allow_blank=False, 182 | id="image_format", 183 | ), 184 | Select.from_values( 185 | ["zh_CN", "en_US"], 186 | value=self.data["language"], 187 | allow_blank=False, 188 | id="language", 189 | ), 190 | classes="horizontal-layout", 191 | ), 192 | Container( 193 | Button( 194 | _("保存配置"), 195 | id="save", 196 | ), 197 | Button( 198 | _("放弃更改"), 199 | id="abandon", 200 | ), 201 | classes="settings_button", 202 | ), 203 | ) 204 | yield Footer() 205 | 206 | def __check_cookie(self) -> str: 207 | if self.data["cookie"]: 208 | return _("小红书网页版 Cookie,无需登录,参数已设置") 209 | return _("小红书网页版 Cookie,无需登录,参数未设置") 210 | 211 | def on_mount(self) -> None: 212 | self.title = _("程序设置") 213 | 214 | @on(Button.Pressed, "#save") 215 | def save_settings(self): 216 | self.dismiss( 217 | { 218 | "mapping_data": self.data.get("mapping_data", {}), 219 | "work_path": self.query_one("#work_path").value, 220 | "folder_name": self.query_one("#folder_name").value, 221 | "name_format": self.query_one("#name_format").value, 222 | "user_agent": self.query_one("#user_agent").value, 223 | "cookie": self.query_one("#cookie").value or self.data["cookie"], 224 | "proxy": self.query_one("#proxy").value or None, 225 | "timeout": int(self.query_one("#timeout").value), 226 | "chunk": int(self.query_one("#chunk").value), 227 | "max_retry": int(self.query_one("#max_retry").value), 228 | "record_data": self.query_one("#record_data").value, 229 | "image_format": self.query_one("#image_format").value, 230 | "folder_mode": self.query_one("#folder_mode").value, 231 | "language": self.query_one("#language").value, 232 | "image_download": self.query_one("#image_download").value, 233 | "video_download": self.query_one("#video_download").value, 234 | "live_download": self.query_one("#live_download").value, 235 | "download_record": self.query_one("#download_record").value, 236 | "author_archive": self.query_one("#author_archive").value, 237 | "write_mtime": self.query_one("#write_mtime").value, 238 | } 239 | ) 240 | 241 | @on(Button.Pressed, "#abandon") 242 | def reset(self): 243 | self.dismiss(self.data) 244 | 245 | async def action_quit(self) -> None: 246 | await self.app.action_quit() 247 | 248 | async def action_index(self): 249 | await self.app.action_back() 250 | -------------------------------------------------------------------------------- /source/TUI/update.py: -------------------------------------------------------------------------------- 1 | from textual import work 2 | from textual.app import ComposeResult 3 | from textual.containers import Grid 4 | from textual.screen import ModalScreen 5 | from textual.widgets import Label, LoadingIndicator 6 | 7 | from ..application import XHS 8 | from ..module import ( 9 | RELEASES, 10 | ) 11 | from ..translation import _ 12 | 13 | __all__ = ["Update"] 14 | 15 | 16 | class Update(ModalScreen): 17 | def __init__( 18 | self, 19 | app: XHS, 20 | ): 21 | super().__init__() 22 | self.xhs = app 23 | 24 | def compose(self) -> ComposeResult: 25 | yield Grid( 26 | Label(_("正在检查新版本,请稍等...")), 27 | LoadingIndicator(), 28 | classes="loading", 29 | ) 30 | 31 | @work(exclusive=True) 32 | async def check_update(self) -> None: 33 | try: 34 | url = await self.xhs.html.request_url( 35 | RELEASES, 36 | False, 37 | None, 38 | timeout=5, 39 | ) 40 | version = url.split("/")[-1] 41 | match self.compare_versions( 42 | f"{XHS.VERSION_MAJOR}.{XHS.VERSION_MINOR}", version, XHS.VERSION_BETA 43 | ): 44 | case 4: 45 | args = ( 46 | _("检测到新版本:{0}.{1}").format( 47 | XHS.VERSION_MAJOR, 48 | XHS.VERSION_MINOR, 49 | ), 50 | "warning", 51 | ) 52 | case 3: 53 | args = ( 54 | _("当前版本为开发版, 可更新至正式版"), 55 | "warning", 56 | ) 57 | case 2: 58 | args = ( 59 | _("当前已是最新开发版"), 60 | "warning", 61 | ) 62 | case 1: 63 | args = ( 64 | _("当前已是最新正式版"), 65 | "information", 66 | ) 67 | case _: 68 | raise ValueError 69 | except ValueError: 70 | args = ( 71 | _("检测新版本失败"), 72 | "error", 73 | ) 74 | self.dismiss(args) 75 | 76 | def on_mount(self) -> None: 77 | self.check_update() 78 | 79 | @staticmethod 80 | def compare_versions( 81 | current_version: str, target_version: str, is_development: bool 82 | ) -> int: 83 | current_major, current_minor = map(int, current_version.split(".")) 84 | target_major, target_minor = map(int, target_version.split(".")) 85 | 86 | if target_major > current_major: 87 | return 4 88 | if target_major == current_major: 89 | if target_minor > current_minor: 90 | return 4 91 | if target_minor == current_minor: 92 | return 3 if is_development else 1 93 | return 2 94 | -------------------------------------------------------------------------------- /source/__init__.py: -------------------------------------------------------------------------------- 1 | from .CLI import cli 2 | from .TUI import XHSDownloader 3 | from .application import XHS 4 | from .module import Settings 5 | 6 | __all__ = [ 7 | "XHS", 8 | "XHSDownloader", 9 | "cli", 10 | "Settings", 11 | ] 12 | -------------------------------------------------------------------------------- /source/application/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import XHS 2 | 3 | __all__ = ["XHS"] 4 | -------------------------------------------------------------------------------- /source/application/app.py: -------------------------------------------------------------------------------- 1 | from asyncio import Event, Queue, QueueEmpty, create_task, gather, sleep 2 | from contextlib import suppress 3 | from datetime import datetime 4 | from re import compile 5 | from urllib.parse import urlparse 6 | 7 | from fastapi import FastAPI 8 | from fastapi.responses import RedirectResponse 9 | 10 | # from aiohttp import web 11 | from pyperclip import copy, paste 12 | from uvicorn import Config, Server 13 | 14 | from source.expansion import ( 15 | BrowserCookie, 16 | Cleaner, 17 | Converter, 18 | Namespace, 19 | beautify_string, 20 | ) 21 | from source.module import ( 22 | __VERSION__, 23 | ERROR, 24 | MASTER, 25 | REPOSITORY, 26 | ROOT, 27 | VERSION_BETA, 28 | VERSION_MAJOR, 29 | VERSION_MINOR, 30 | WARNING, 31 | DataRecorder, 32 | ExtractData, 33 | ExtractParams, 34 | IDRecorder, 35 | Manager, 36 | MapRecorder, 37 | logging, 38 | sleep_time, 39 | ) 40 | from source.translation import _, switch_language 41 | 42 | from ..module import Mapping 43 | from .download import Download 44 | from .explore import Explore 45 | from .image import Image 46 | from .request import Html 47 | from .video import Video 48 | 49 | __all__ = ["XHS"] 50 | 51 | 52 | def data_cache(function): 53 | async def inner( 54 | self, 55 | data: dict, 56 | ): 57 | if self.manager.record_data: 58 | download = data["下载地址"] 59 | lives = data["动图地址"] 60 | await function( 61 | self, 62 | data, 63 | ) 64 | data["下载地址"] = download 65 | data["动图地址"] = lives 66 | 67 | return inner 68 | 69 | 70 | class XHS: 71 | VERSION_MAJOR = VERSION_MAJOR 72 | VERSION_MINOR = VERSION_MINOR 73 | VERSION_BETA = VERSION_BETA 74 | LINK = compile(r"https?://www\.xiaohongshu\.com/explore/\S+") 75 | SHARE = compile(r"https?://www\.xiaohongshu\.com/discovery/item/\S+") 76 | SHORT = compile(r"https?://xhslink\.com/\S+") 77 | ID = compile(r"(?:explore|item)/(\S+)?\?") 78 | __INSTANCE = None 79 | CLEANER = Cleaner() 80 | 81 | def __new__(cls, *args, **kwargs): 82 | if not cls.__INSTANCE: 83 | cls.__INSTANCE = super().__new__(cls) 84 | return cls.__INSTANCE 85 | 86 | def __init__( 87 | self, 88 | mapping_data: dict = None, 89 | work_path="", 90 | folder_name="Download", 91 | name_format="发布时间 作者昵称 作品标题", 92 | user_agent: str = None, 93 | cookie: str = "", 94 | proxy: str | dict = None, 95 | timeout=10, 96 | chunk=1024 * 1024, 97 | max_retry=5, 98 | record_data=False, 99 | image_format="PNG", 100 | image_download=True, 101 | video_download=True, 102 | live_download=False, 103 | folder_mode=False, 104 | download_record=True, 105 | author_archive=False, 106 | write_mtime=False, 107 | language="zh_CN", 108 | read_cookie: int | str = None, 109 | _print: bool = True, 110 | *args, 111 | **kwargs, 112 | ): 113 | switch_language(language) 114 | self.manager = Manager( 115 | ROOT, 116 | work_path, 117 | folder_name, 118 | name_format, 119 | chunk, 120 | user_agent, 121 | self.read_browser_cookie(read_cookie) or cookie, 122 | proxy, 123 | timeout, 124 | max_retry, 125 | record_data, 126 | image_format, 127 | image_download, 128 | video_download, 129 | live_download, 130 | download_record, 131 | folder_mode, 132 | author_archive, 133 | write_mtime, 134 | _print, 135 | ) 136 | self.mapping_data = mapping_data or {} 137 | self.map_recorder = MapRecorder( 138 | self.manager, 139 | ) 140 | self.mapping = Mapping(self.manager, self.map_recorder) 141 | self.html = Html(self.manager) 142 | self.image = Image() 143 | self.video = Video() 144 | self.explore = Explore() 145 | self.convert = Converter() 146 | self.download = Download(self.manager) 147 | self.id_recorder = IDRecorder(self.manager) 148 | self.data_recorder = DataRecorder(self.manager) 149 | self.clipboard_cache: str = "" 150 | self.queue = Queue() 151 | self.event = Event() 152 | # self.runner = self.init_server() 153 | # self.site = None 154 | self.server = None 155 | 156 | def __extract_image(self, container: dict, data: Namespace): 157 | container["下载地址"], container["动图地址"] = self.image.get_image_link( 158 | data, self.manager.image_format 159 | ) 160 | 161 | def __extract_video(self, container: dict, data: Namespace): 162 | container["下载地址"] = self.video.get_video_link(data) 163 | container["动图地址"] = [ 164 | None, 165 | ] 166 | 167 | async def __download_files( 168 | self, 169 | container: dict, 170 | download: bool, 171 | index, 172 | log, 173 | bar, 174 | ): 175 | name = self.__naming_rules(container) 176 | if (u := container["下载地址"]) and download: 177 | if await self.skip_download(i := container["作品ID"]): 178 | logging(log, _("作品 {0} 存在下载记录,跳过下载").format(i)) 179 | else: 180 | path, result = await self.download.run( 181 | u, 182 | container["动图地址"], 183 | index, 184 | container["作者ID"] 185 | + "_" 186 | + self.CLEANER.filter_name(container["作者昵称"]), 187 | name, 188 | container["作品类型"], 189 | container["时间戳"], 190 | log, 191 | bar, 192 | ) 193 | await self.__add_record(i, result) 194 | elif not u: 195 | logging(log, _("提取作品文件下载地址失败"), ERROR) 196 | await self.save_data(container) 197 | 198 | @data_cache 199 | async def save_data( 200 | self, 201 | data: dict, 202 | ): 203 | data["采集时间"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 204 | data["下载地址"] = " ".join(data["下载地址"]) 205 | data["动图地址"] = " ".join(i or "NaN" for i in data["动图地址"]) 206 | data.pop("时间戳", None) 207 | await self.data_recorder.add(**data) 208 | 209 | async def __add_record(self, id_: str, result: list) -> None: 210 | if all(result): 211 | await self.id_recorder.add(id_) 212 | 213 | async def extract( 214 | self, 215 | url: str, 216 | download=False, 217 | index: list | tuple = None, 218 | log=None, 219 | bar=None, 220 | data=True, 221 | ) -> list[dict]: 222 | # return # 调试代码 223 | urls = await self.extract_links(url, log) 224 | if not urls: 225 | logging(log, _("提取小红书作品链接失败"), WARNING) 226 | else: 227 | logging(log, _("共 {0} 个小红书作品待处理...").format(len(urls))) 228 | # return urls # 调试代码 229 | return [ 230 | await self.__deal_extract( 231 | i, 232 | download, 233 | index, 234 | log, 235 | bar, 236 | data, 237 | ) 238 | for i in urls 239 | ] 240 | 241 | async def extract_cli( 242 | self, 243 | url: str, 244 | download=True, 245 | index: list | tuple = None, 246 | log=None, 247 | bar=None, 248 | data=False, 249 | ) -> None: 250 | url = await self.extract_links(url, log) 251 | if not url: 252 | logging(log, _("提取小红书作品链接失败"), WARNING) 253 | else: 254 | await self.__deal_extract( 255 | url[0], 256 | download, 257 | index, 258 | log, 259 | bar, 260 | data, 261 | ) 262 | 263 | async def extract_links(self, url: str, log) -> list: 264 | urls = [] 265 | for i in url.split(): 266 | if u := self.SHORT.search(i): 267 | i = await self.html.request_url( 268 | u.group(), 269 | False, 270 | log, 271 | ) 272 | if u := self.SHARE.search(i): 273 | urls.append(u.group()) 274 | elif u := self.LINK.search(i): 275 | urls.append(u.group()) 276 | return urls 277 | 278 | def extract_id(self, links: list[str]) -> list[str]: 279 | ids = [] 280 | for i in links: 281 | if j := self.ID.search(i): 282 | ids.append(j.group(1)) 283 | return ids 284 | 285 | async def __deal_extract( 286 | self, 287 | url: str, 288 | download: bool, 289 | index: list | tuple | None, 290 | log, 291 | bar, 292 | data: bool, 293 | cookie: str = None, 294 | proxy: str = None, 295 | ): 296 | if await self.skip_download(i := self.__extract_link_id(url)) and not data: 297 | msg = _("作品 {0} 存在下载记录,跳过处理").format(i) 298 | logging(log, msg) 299 | return {"message": msg} 300 | logging(log, _("开始处理作品:{0}").format(i)) 301 | html = await self.html.request_url( 302 | url, 303 | log=log, 304 | cookie=cookie, 305 | proxy=proxy, 306 | ) 307 | namespace = self.__generate_data_object(html) 308 | if not namespace: 309 | logging(log, _("{0} 获取数据失败").format(i), ERROR) 310 | return {} 311 | data = self.explore.run(namespace) 312 | # logging(log, data) # 调试代码 313 | if not data: 314 | logging(log, _("{0} 提取数据失败").format(i), ERROR) 315 | return {} 316 | if data["作品类型"] == _("视频"): 317 | self.__extract_video(data, namespace) 318 | elif data["作品类型"] in { 319 | _("图文"), 320 | _("图集"), 321 | }: 322 | self.__extract_image(data, namespace) 323 | else: 324 | logging(log, _("未知的作品类型:{0}").format(i), WARNING) 325 | data["下载地址"] = [] 326 | await self.update_author_nickname(data, log) 327 | await self.__download_files(data, download, index, log, bar) 328 | logging(log, _("作品处理完成:{0}").format(i)) 329 | await sleep_time() 330 | return data 331 | 332 | async def update_author_nickname( 333 | self, 334 | container: dict, 335 | log, 336 | ): 337 | if a := self.CLEANER.filter_name( 338 | self.mapping_data.get(i := container["作者ID"], "") 339 | ): 340 | container["作者昵称"] = a 341 | else: 342 | container["作者昵称"] = self.manager.filter_name(container["作者昵称"]) or i 343 | await self.mapping.update_cache( 344 | i, 345 | container["作者昵称"], 346 | log, 347 | ) 348 | 349 | @staticmethod 350 | def __extract_link_id(url: str) -> str: 351 | link = urlparse(url) 352 | return link.path.split("/")[-1] 353 | 354 | def __generate_data_object(self, html: str) -> Namespace: 355 | data = self.convert.run(html) 356 | return Namespace(data) 357 | 358 | def __naming_rules(self, data: dict) -> str: 359 | keys = self.manager.name_format.split() 360 | values = [] 361 | for key in keys: 362 | match key: 363 | case "发布时间": 364 | values.append(self.__get_name_time(data)) 365 | case "作品标题": 366 | values.append(self.__get_name_title(data)) 367 | case _: 368 | values.append(data[key]) 369 | return beautify_string( 370 | self.CLEANER.filter_name( 371 | self.manager.SEPARATE.join(values), 372 | default=self.manager.SEPARATE.join( 373 | ( 374 | data["作者ID"], 375 | data["作品ID"], 376 | ) 377 | ), 378 | ), 379 | length=128, 380 | ) 381 | 382 | @staticmethod 383 | def __get_name_time(data: dict) -> str: 384 | return data["发布时间"].replace(":", ".") 385 | 386 | def __get_name_title(self, data: dict) -> str: 387 | return ( 388 | beautify_string( 389 | self.manager.filter_name(data["作品标题"]), 390 | 64, 391 | ) 392 | or data["作品ID"] 393 | ) 394 | 395 | async def monitor( 396 | self, 397 | delay=1, 398 | download=False, 399 | log=None, 400 | bar=None, 401 | data=True, 402 | ) -> None: 403 | logging( 404 | None, 405 | _( 406 | "程序会自动读取并提取剪贴板中的小红书作品链接,并自动下载链接对应的作品文件,如需关闭,请点击关闭按钮,或者向剪贴板写入 “close” 文本!" 407 | ), 408 | style=MASTER, 409 | ) 410 | self.event.clear() 411 | copy("") 412 | await gather( 413 | self.__get_link(delay), 414 | self.__receive_link(delay, download, None, log, bar, data), 415 | ) 416 | 417 | async def __get_link(self, delay: int): 418 | while not self.event.is_set(): 419 | if (t := paste()).lower() == "close": 420 | self.stop_monitor() 421 | elif t != self.clipboard_cache: 422 | self.clipboard_cache = t 423 | create_task(self.__push_link(t)) 424 | await sleep(delay) 425 | 426 | async def __push_link( 427 | self, 428 | content: str, 429 | ): 430 | await gather( 431 | *[self.queue.put(i) for i in await self.extract_links(content, None)] 432 | ) 433 | 434 | async def __receive_link(self, delay: int, *args, **kwargs): 435 | while not self.event.is_set() or self.queue.qsize() > 0: 436 | with suppress(QueueEmpty): 437 | await self.__deal_extract(self.queue.get_nowait(), *args, **kwargs) 438 | await sleep(delay) 439 | 440 | def stop_monitor(self): 441 | self.event.set() 442 | 443 | async def skip_download(self, id_: str) -> bool: 444 | return bool(await self.id_recorder.select(id_)) 445 | 446 | async def __aenter__(self): 447 | await self.id_recorder.__aenter__() 448 | await self.data_recorder.__aenter__() 449 | await self.map_recorder.__aenter__() 450 | return self 451 | 452 | async def __aexit__(self, exc_type, exc_value, traceback): 453 | await self.id_recorder.__aexit__(exc_type, exc_value, traceback) 454 | await self.data_recorder.__aexit__(exc_type, exc_value, traceback) 455 | await self.map_recorder.__aexit__(exc_type, exc_value, traceback) 456 | await self.close() 457 | 458 | async def close(self): 459 | await self.manager.close() 460 | 461 | @staticmethod 462 | def read_browser_cookie(value: str | int) -> str: 463 | return ( 464 | BrowserCookie.get( 465 | value, 466 | domains=[ 467 | "xiaohongshu.com", 468 | ], 469 | ) 470 | if value 471 | else "" 472 | ) 473 | 474 | # @staticmethod 475 | # async def index(request): 476 | # return web.HTTPFound(REPOSITORY) 477 | 478 | # async def handle(self, request): 479 | # data = await request.post() 480 | # url = data.get("url") 481 | # download = data.get("download", False) 482 | # index = data.get("index") 483 | # skip = data.get("skip", False) 484 | # url = await self.__extract_links(url, None) 485 | # if not url: 486 | # msg = _("提取小红书作品链接失败") 487 | # data = None 488 | # else: 489 | # if data := await self.__deal_extract(url[0], download, index, None, None, not skip, ): 490 | # msg = _("获取小红书作品数据成功") 491 | # else: 492 | # msg = _("获取小红书作品数据失败") 493 | # data = None 494 | # return web.json_response(dict(message=msg, url=url[0], data=data)) 495 | 496 | # def init_server(self, ): 497 | # app = web.Application(debug=True) 498 | # app.router.add_get('/', self.index) 499 | # app.router.add_post('/xhs/', self.handle) 500 | # return web.AppRunner(app) 501 | 502 | # async def run_server(self, log=None, ): 503 | # try: 504 | # await self.start_server(log) 505 | # while True: 506 | # await sleep(3600) # 保持服务器运行 507 | # except (CancelledError, KeyboardInterrupt): 508 | # await self.close_server(log) 509 | 510 | # async def start_server(self, log=None, ): 511 | # await self.runner.setup() 512 | # self.site = web.TCPSite(self.runner, "0.0.0.0") 513 | # await self.site.start() 514 | # logging(log, _("Web API 服务器已启动!")) 515 | # logging(log, _("服务器主机及端口: {0}".format(self.site.name, ))) 516 | 517 | # async def close_server(self, log=None, ): 518 | # await self.runner.cleanup() 519 | # logging(log, _("Web API 服务器已关闭!")) 520 | 521 | async def run_server( 522 | self, 523 | host="0.0.0.0", 524 | port=6666, 525 | log_level="info", 526 | ): 527 | self.server = FastAPI( 528 | debug=self.VERSION_BETA, 529 | title="XHS-Downloader", 530 | version=__VERSION__, 531 | ) 532 | self.setup_routes() 533 | config = Config( 534 | self.server, 535 | host=host, 536 | port=port, 537 | log_level=log_level, 538 | ) 539 | server = Server(config) 540 | await server.serve() 541 | 542 | def setup_routes(self): 543 | @self.server.get("/") 544 | async def index(): 545 | return RedirectResponse(url=REPOSITORY) 546 | 547 | @self.server.post( 548 | "/xhs/", 549 | response_model=ExtractData, 550 | ) 551 | async def handle(extract: ExtractParams): 552 | url = await self.extract_links(extract.url, None) 553 | if not url: 554 | msg = _("提取小红书作品链接失败") 555 | data = None 556 | else: 557 | if data := await self.__deal_extract( 558 | url[0], 559 | extract.download, 560 | extract.index, 561 | None, 562 | None, 563 | not extract.skip, 564 | extract.cookie, 565 | extract.proxy, 566 | ): 567 | msg = _("获取小红书作品数据成功") 568 | else: 569 | msg = _("获取小红书作品数据失败") 570 | data = None 571 | return ExtractData(message=msg, params=extract, data=data) 572 | -------------------------------------------------------------------------------- /source/application/download.py: -------------------------------------------------------------------------------- 1 | from asyncio import Semaphore, gather 2 | from pathlib import Path 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from aiofiles import open 6 | from httpx import HTTPError 7 | 8 | from ..expansion import CacheError 9 | 10 | # from ..module import WARNING 11 | from ..module import ( 12 | ERROR, 13 | FILE_SIGNATURES, 14 | FILE_SIGNATURES_LENGTH, 15 | MAX_WORKERS, 16 | logging, 17 | sleep_time, 18 | ) 19 | from ..module import retry as re_download 20 | from ..translation import _ 21 | 22 | if TYPE_CHECKING: 23 | from httpx import AsyncClient 24 | 25 | from ..module import Manager 26 | 27 | __all__ = ["Download"] 28 | 29 | 30 | class Download: 31 | SEMAPHORE = Semaphore(MAX_WORKERS) 32 | CONTENT_TYPE_MAP = { 33 | "image/png": "png", 34 | "image/jpeg": "jpeg", 35 | "image/webp": "webp", 36 | "video/mp4": "mp4", 37 | "video/quicktime": "mov", 38 | "audio/mp4": "m4a", 39 | "audio/mpeg": "mp3", 40 | } 41 | 42 | def __init__( 43 | self, 44 | manager: "Manager", 45 | ): 46 | self.manager = manager 47 | self.folder = manager.folder 48 | self.temp = manager.temp 49 | self.chunk = manager.chunk 50 | self.client: "AsyncClient" = manager.download_client 51 | self.headers = manager.blank_headers 52 | self.retry = manager.retry 53 | self.folder_mode = manager.folder_mode 54 | self.video_format = "mp4" 55 | self.live_format = "mp4" 56 | self.image_format = manager.image_format 57 | self.image_format_list = ( 58 | "jpeg", 59 | "png", 60 | "webp", 61 | "avif", 62 | "heic", 63 | ) 64 | self.image_download = manager.image_download 65 | self.video_download = manager.video_download 66 | self.live_download = manager.live_download 67 | self.author_archive = manager.author_archive 68 | self.write_mtime = manager.write_mtime 69 | 70 | async def run( 71 | self, 72 | urls: list, 73 | lives: list, 74 | index: list | tuple | None, 75 | nickname: str, 76 | filename: str, 77 | type_: str, 78 | mtime: int, 79 | log, 80 | bar, 81 | ) -> tuple[Path, list[Any]]: 82 | path = self.__generate_path(nickname, filename) 83 | if type_ == _("视频"): 84 | tasks = self.__ready_download_video( 85 | urls, 86 | path, 87 | filename, 88 | log, 89 | ) 90 | elif type_ == _("图文"): 91 | tasks = self.__ready_download_image( 92 | urls, 93 | lives, 94 | index, 95 | path, 96 | filename, 97 | log, 98 | ) 99 | else: 100 | raise ValueError 101 | tasks = [ 102 | self.__download( 103 | url, 104 | path, 105 | name, 106 | format_, 107 | mtime, 108 | log, 109 | bar, 110 | ) 111 | for url, name, format_ in tasks 112 | ] 113 | tasks = await gather(*tasks) 114 | return path, tasks 115 | 116 | def __generate_path(self, nickname: str, filename: str): 117 | if self.author_archive: 118 | folder = self.folder.joinpath(nickname) 119 | folder.mkdir(exist_ok=True) 120 | else: 121 | folder = self.folder 122 | path = self.manager.archive(folder, filename, self.folder_mode) 123 | path.mkdir(exist_ok=True) 124 | return path 125 | 126 | def __ready_download_video( 127 | self, urls: list[str], path: Path, name: str, log 128 | ) -> list: 129 | if not self.video_download: 130 | logging(log, _("视频作品下载功能已关闭,跳过下载")) 131 | return [] 132 | if self.__check_exists_path(path, f"{name}.{self.video_format}", log): 133 | return [] 134 | return [(urls[0], name, self.video_format)] 135 | 136 | def __ready_download_image( 137 | self, 138 | urls: list[str], 139 | lives: list[str], 140 | index: list | tuple | None, 141 | path: Path, 142 | name: str, 143 | log, 144 | ) -> list: 145 | tasks = [] 146 | if not self.image_download: 147 | logging(log, _("图文作品下载功能已关闭,跳过下载")) 148 | return tasks 149 | for i, j in enumerate(zip(urls, lives), start=1): 150 | if index and i not in index: 151 | continue 152 | file = f"{name}_{i}" 153 | if not any( 154 | self.__check_exists_path( 155 | path, 156 | f"{file}.{s}", 157 | log, 158 | ) 159 | for s in self.image_format_list 160 | ): 161 | tasks.append([j[0], file, self.image_format]) 162 | if ( 163 | not self.live_download 164 | or not j[1] 165 | or self.__check_exists_path( 166 | path, 167 | f"{file}.{self.live_format}", 168 | log, 169 | ) 170 | ): 171 | continue 172 | tasks.append([j[1], file, self.live_format]) 173 | return tasks 174 | 175 | def __check_exists_glob( 176 | self, 177 | path: Path, 178 | name: str, 179 | log, 180 | ) -> bool: 181 | if any(path.glob(name)): 182 | logging(log, _("{0} 文件已存在,跳过下载").format(name)) 183 | return True 184 | return False 185 | 186 | def __check_exists_path( 187 | self, 188 | path: Path, 189 | name: str, 190 | log, 191 | ) -> bool: 192 | if path.joinpath(name).exists(): 193 | logging(log, _("{0} 文件已存在,跳过下载").format(name)) 194 | return True 195 | return False 196 | 197 | @re_download 198 | async def __download( 199 | self, 200 | url: str, 201 | path: Path, 202 | name: str, 203 | format_: str, 204 | mtime: int, 205 | log, 206 | bar, 207 | ): 208 | async with self.SEMAPHORE: 209 | headers = self.headers.copy() 210 | # try: 211 | # length, suffix = await self.__head_file( 212 | # url, 213 | # headers, 214 | # format_, 215 | # ) 216 | # except HTTPError as error: 217 | # logging( 218 | # log, 219 | # _( 220 | # "网络异常,{0} 请求失败,错误信息: {1}").format(name, repr(error)), 221 | # ERROR, 222 | # ) 223 | # return False 224 | # temp = self.temp.joinpath(f"{name}.{suffix}") 225 | temp = self.temp.joinpath(f"{name}.{format_}") 226 | self.__update_headers_range( 227 | headers, 228 | temp, 229 | ) 230 | try: 231 | async with self.client.stream( 232 | "GET", 233 | url, 234 | headers=headers, 235 | ) as response: 236 | await sleep_time() 237 | if response.status_code == 416: 238 | raise CacheError( 239 | _("文件 {0} 缓存异常,重新下载").format(temp.name), 240 | ) 241 | response.raise_for_status() 242 | # self.__create_progress( 243 | # bar, 244 | # int( 245 | # response.headers.get( 246 | # 'content-length', 0)) or None, 247 | # ) 248 | async with open(temp, "ab") as f: 249 | async for chunk in response.aiter_bytes(self.chunk): 250 | await f.write(chunk) 251 | # self.__update_progress(bar, len(chunk)) 252 | real = await self.__suffix_with_file( 253 | temp, 254 | path, 255 | name, 256 | # suffix, 257 | format_, 258 | log, 259 | ) 260 | self.manager.move( 261 | temp, 262 | real, 263 | mtime, 264 | self.write_mtime, 265 | ) 266 | # self.__create_progress(bar, None) 267 | logging(log, _("文件 {0} 下载成功").format(real.name)) 268 | return True 269 | except HTTPError as error: 270 | # self.__create_progress(bar, None) 271 | logging( 272 | log, 273 | _("网络异常,{0} 下载失败,错误信息: {1}").format( 274 | name, repr(error) 275 | ), 276 | ERROR, 277 | ) 278 | return False 279 | except CacheError as error: 280 | self.manager.delete(temp) 281 | logging( 282 | log, 283 | str(error), 284 | ERROR, 285 | ) 286 | 287 | @staticmethod 288 | def __create_progress( 289 | bar, 290 | total: int | None, 291 | completed=0, 292 | ): 293 | if bar: 294 | bar.update(total=total, completed=completed) 295 | 296 | @staticmethod 297 | def __update_progress(bar, advance: int): 298 | if bar: 299 | bar.advance(advance) 300 | 301 | @classmethod 302 | def __extract_type(cls, content: str) -> str: 303 | return cls.CONTENT_TYPE_MAP.get(content, "") 304 | 305 | async def __head_file( 306 | self, 307 | url: str, 308 | headers: dict[str, str], 309 | suffix: str, 310 | ) -> tuple[int, str]: 311 | response = await self.client.head( 312 | url, 313 | headers=headers, 314 | ) 315 | await sleep_time() 316 | response.raise_for_status() 317 | suffix = self.__extract_type(response.headers.get("Content-Type")) or suffix 318 | length = response.headers.get("Content-Length", 0) 319 | return int(length), suffix 320 | 321 | @staticmethod 322 | def __get_resume_byte_position(file: Path) -> int: 323 | return file.stat().st_size if file.is_file() else 0 324 | 325 | def __update_headers_range( 326 | self, 327 | headers: dict[str, str], 328 | file: Path, 329 | ) -> int: 330 | headers["Range"] = f"bytes={(p := self.__get_resume_byte_position(file))}-" 331 | return p 332 | 333 | @staticmethod 334 | async def __suffix_with_file( 335 | temp: Path, 336 | path: Path, 337 | name: str, 338 | default_suffix: str, 339 | log, 340 | ) -> Path: 341 | try: 342 | async with open(temp, "rb") as f: 343 | file_start = await f.read(FILE_SIGNATURES_LENGTH) 344 | for offset, signature, suffix in FILE_SIGNATURES: 345 | if file_start[offset : offset + len(signature)] == signature: 346 | return path.joinpath(f"{name}.{suffix}") 347 | except Exception as error: 348 | logging( 349 | log, 350 | _("文件 {0} 格式判断失败,错误信息:{1}").format( 351 | temp.name, repr(error) 352 | ), 353 | ERROR, 354 | ) 355 | return path.joinpath(f"{name}.{default_suffix}") 356 | -------------------------------------------------------------------------------- /source/application/explore.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from ..expansion import Namespace 4 | from ..translation import _ 5 | 6 | __all__ = ["Explore"] 7 | 8 | 9 | class Explore: 10 | time_format = "%Y-%m-%d_%H:%M:%S" 11 | 12 | def run(self, data: Namespace) -> dict: 13 | return self.__extract_data(data) 14 | 15 | def __extract_data(self, data: Namespace) -> dict: 16 | result = {} 17 | if data: 18 | self.__extract_interact_info(result, data) 19 | self.__extract_tags(result, data) 20 | self.__extract_info(result, data) 21 | self.__extract_time(result, data) 22 | self.__extract_user(result, data) 23 | return result 24 | 25 | @staticmethod 26 | def __extract_interact_info(container: dict, data: Namespace) -> None: 27 | container["收藏数量"] = data.safe_extract("interactInfo.collectedCount", "-1") 28 | container["评论数量"] = data.safe_extract("interactInfo.commentCount", "-1") 29 | container["分享数量"] = data.safe_extract("interactInfo.shareCount", "-1") 30 | container["点赞数量"] = data.safe_extract("interactInfo.likedCount", "-1") 31 | 32 | @staticmethod 33 | def __extract_tags(container: dict, data: Namespace): 34 | tags = data.safe_extract("tagList", []) 35 | container["作品标签"] = " ".join( 36 | Namespace.object_extract(i, "name") for i in tags 37 | ) 38 | 39 | def __extract_info(self, container: dict, data: Namespace): 40 | container["作品ID"] = data.safe_extract("noteId") 41 | container["作品链接"] = ( 42 | f"https://www.xiaohongshu.com/explore/{container['作品ID']}" 43 | ) 44 | container["作品标题"] = data.safe_extract("title") 45 | container["作品描述"] = data.safe_extract("desc") 46 | container["作品类型"] = self.__classify_works(data) 47 | # container["IP归属地"] = data.safe_extract("ipLocation") 48 | 49 | def __extract_time(self, container: dict, data: Namespace): 50 | container["发布时间"] = ( 51 | datetime.fromtimestamp(time / 1000).strftime(self.time_format) 52 | if (time := data.safe_extract("time")) 53 | else _("未知") 54 | ) 55 | container["最后更新时间"] = ( 56 | datetime.fromtimestamp(last / 1000).strftime(self.time_format) 57 | if (last := data.safe_extract("lastUpdateTime")) 58 | else _("未知") 59 | ) 60 | container["时间戳"] = ( 61 | (time / 1000) if (time := data.safe_extract("time")) else None 62 | ) 63 | 64 | @staticmethod 65 | def __extract_user(container: dict, data: Namespace): 66 | container["作者昵称"] = data.safe_extract("user.nickname") 67 | container["作者ID"] = data.safe_extract("user.userId") 68 | container["作者链接"] = ( 69 | f"https://www.xiaohongshu.com/user/profile/{container['作者ID']}" 70 | ) 71 | 72 | @staticmethod 73 | def __classify_works(data: Namespace) -> str: 74 | type_ = data.safe_extract("type") 75 | list_ = data.safe_extract("imageList", []) 76 | if type_ not in {"video", "normal"} or len(list_) == 0: 77 | return _("未知") 78 | if type_ == "video": 79 | return _("视频") if len(list_) == 1 else _("图集") 80 | return _("图文") 81 | -------------------------------------------------------------------------------- /source/application/image.py: -------------------------------------------------------------------------------- 1 | from source.expansion import Namespace 2 | 3 | from .request import Html 4 | 5 | __all__ = ["Image"] 6 | 7 | 8 | class Image: 9 | @classmethod 10 | def get_image_link(cls, data: Namespace, format_: str) -> tuple[list, list]: 11 | images = data.safe_extract("imageList", []) 12 | live_link = cls.__get_live_link(images) 13 | token_list = [ 14 | cls.__extract_image_token(Namespace.object_extract(i, "urlDefault")) 15 | for i in images 16 | ] 17 | match format_: 18 | case "png" | "webp" | "jpeg" | "heic" | "avif": 19 | return [ 20 | Html.format_url( 21 | cls.__generate_fixed_link( 22 | i, 23 | format_, 24 | ) 25 | ) 26 | for i in token_list 27 | ], live_link 28 | case "auto": 29 | return [ 30 | Html.format_url(cls.__generate_auto_link(i)) for i in token_list 31 | ], live_link 32 | case _: 33 | raise ValueError 34 | 35 | @staticmethod 36 | def __generate_auto_link(token: str) -> str: 37 | return f"https://sns-img-bd.xhscdn.com/{token}" 38 | 39 | @staticmethod 40 | def __generate_fixed_link( 41 | token: str, 42 | format_: str, 43 | ) -> str: 44 | return f"https://ci.xiaohongshu.com/{token}?imageView2/format/{format_}" 45 | 46 | @staticmethod 47 | def __extract_image_token(url: str) -> str: 48 | return "/".join(url.split("/")[5:]).split("!")[0] 49 | 50 | @staticmethod 51 | def __get_live_link(items: list) -> list: 52 | return [ 53 | ( 54 | Html.format_url( 55 | Namespace.object_extract(item, "stream.h264[0].masterUrl") 56 | ) 57 | or None 58 | ) 59 | for item in items 60 | ] 61 | -------------------------------------------------------------------------------- /source/application/request.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from httpx import HTTPError 4 | from httpx import get 5 | 6 | from ..module import ERROR, Manager, logging, retry, sleep_time 7 | from ..translation import _ 8 | 9 | if TYPE_CHECKING: 10 | from ..module import Manager 11 | 12 | __all__ = ["Html"] 13 | 14 | 15 | class Html: 16 | def __init__( 17 | self, 18 | manager: "Manager", 19 | ): 20 | self.retry = manager.retry 21 | self.client = manager.request_client 22 | self.headers = manager.headers 23 | self.timeout = manager.timeout 24 | 25 | @retry 26 | async def request_url( 27 | self, 28 | url: str, 29 | content=True, 30 | log=None, 31 | cookie: str = None, 32 | proxy: str = None, 33 | **kwargs, 34 | ) -> str: 35 | headers = self.update_cookie( 36 | cookie, 37 | ) 38 | try: 39 | match (content, bool(proxy)): 40 | case (True, False): 41 | response = await self.__request_url_get( 42 | url, 43 | headers, 44 | **kwargs, 45 | ) 46 | await sleep_time() 47 | response.raise_for_status() 48 | return response.text 49 | case (True, True): 50 | response = await self.__request_url_get_proxy( 51 | url, 52 | headers, 53 | proxy, 54 | **kwargs, 55 | ) 56 | await sleep_time() 57 | response.raise_for_status() 58 | return response.text 59 | case (False, False): 60 | response = await self.__request_url_head( 61 | url, 62 | headers, 63 | **kwargs, 64 | ) 65 | await sleep_time() 66 | return str(response.url) 67 | case (False, True): 68 | response = await self.__request_url_head_proxy( 69 | url, 70 | headers, 71 | proxy, 72 | **kwargs, 73 | ) 74 | await sleep_time() 75 | return str(response.url) 76 | case _: 77 | raise ValueError 78 | except HTTPError as error: 79 | logging( 80 | log, _("网络异常,{0} 请求失败: {1}").format(url, repr(error)), ERROR 81 | ) 82 | return "" 83 | 84 | @staticmethod 85 | def format_url(url: str) -> str: 86 | return bytes(url, "utf-8").decode("unicode_escape") 87 | 88 | def update_cookie( 89 | self, 90 | cookie: str = None, 91 | ) -> dict: 92 | return self.headers | {"Cookie": cookie} if cookie else self.headers.copy() 93 | 94 | async def __request_url_head( 95 | self, 96 | url: str, 97 | headers: dict, 98 | **kwargs, 99 | ): 100 | return await self.client.head( 101 | url, 102 | headers=headers, 103 | **kwargs, 104 | ) 105 | 106 | async def __request_url_head_proxy( 107 | self, 108 | url: str, 109 | headers: dict, 110 | proxy: str, 111 | **kwargs, 112 | ): 113 | return await self.client.head( 114 | url, 115 | headers=headers, 116 | proxy=proxy, 117 | follow_redirects=True, 118 | verify=False, 119 | timeout=self.timeout, 120 | **kwargs, 121 | ) 122 | 123 | async def __request_url_get( 124 | self, 125 | url: str, 126 | headers: dict, 127 | **kwargs, 128 | ): 129 | return await self.client.get( 130 | url, 131 | headers=headers, 132 | **kwargs, 133 | ) 134 | 135 | async def __request_url_get_proxy( 136 | self, 137 | url: str, 138 | headers: dict, 139 | proxy: str, 140 | **kwargs, 141 | ): 142 | return get( 143 | url, 144 | headers=headers, 145 | proxy=proxy, 146 | follow_redirects=True, 147 | verify=False, 148 | timeout=self.timeout, 149 | **kwargs, 150 | ) 151 | -------------------------------------------------------------------------------- /source/application/video.py: -------------------------------------------------------------------------------- 1 | from source.expansion import Namespace 2 | from .request import Html 3 | 4 | __all__ = ["Video"] 5 | 6 | 7 | class Video: 8 | VIDEO_LINK = ( 9 | "video", 10 | "consumer", 11 | "originVideoKey", 12 | ) 13 | 14 | @classmethod 15 | def get_video_link(cls, data: Namespace) -> list: 16 | return ( 17 | [Html.format_url(f"https://sns-video-bd.xhscdn.com/{t}")] 18 | if (t := data.safe_extract(".".join(cls.VIDEO_LINK))) 19 | else [] 20 | ) 21 | -------------------------------------------------------------------------------- /source/expansion/__init__.py: -------------------------------------------------------------------------------- 1 | from .browser import BrowserCookie 2 | from .cleaner import Cleaner 3 | from .converter import Converter 4 | from .error import CacheError 5 | from .file_folder import file_switch 6 | from .file_folder import remove_empty_directories 7 | from .namespace import Namespace 8 | from .truncate import beautify_string 9 | from .truncate import trim_string 10 | from .truncate import truncate_string 11 | -------------------------------------------------------------------------------- /source/expansion/browser.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from sys import platform 3 | 4 | from rich.console import Console 5 | from rookiepy import ( 6 | arc, 7 | brave, 8 | chrome, 9 | chromium, 10 | edge, 11 | firefox, 12 | librewolf, 13 | opera, 14 | opera_gx, 15 | vivaldi, 16 | ) 17 | 18 | try: 19 | from source.translation import _ 20 | except ImportError: 21 | _ = lambda s: s 22 | 23 | __all__ = ["BrowserCookie"] 24 | 25 | 26 | class BrowserCookie: 27 | SUPPORT_BROWSER = { 28 | "Arc": (arc, "Linux, macOS, Windows"), 29 | "Chrome": (chrome, "Linux, macOS, Windows"), 30 | "Chromium": (chromium, "Linux, macOS, Windows"), 31 | "Opera": (opera, "Linux, macOS, Windows"), 32 | "OperaGX": (opera_gx, "macOS, Windows"), 33 | "Brave": (brave, "Linux, macOS, Windows"), 34 | "Edge": (edge, "Linux, macOS, Windows"), 35 | "Vivaldi": (vivaldi, "Linux, macOS, Windows"), 36 | "Firefox": (firefox, "Linux, macOS, Windows"), 37 | "LibreWolf": (librewolf, "Linux, macOS, Windows"), 38 | } 39 | 40 | @classmethod 41 | def run( 42 | cls, 43 | domains: list[str], 44 | console: Console = None, 45 | ) -> str: 46 | console = console or Console() 47 | options = "\n".join( 48 | f"{i}. {k}: {v[1]}" 49 | for i, (k, v) in enumerate(cls.SUPPORT_BROWSER.items(), start=1) 50 | ) 51 | if browser := console.input( 52 | _( 53 | "读取指定浏览器的 Cookie 并写入配置文件\n" 54 | "Windows 系统需要以管理员身份运行程序才能读取 Chromium、Chrome、Edge 浏览器 Cookie!\n" 55 | "{options}\n请输入浏览器名称或序号:" 56 | ).format(options=options), 57 | ): 58 | return cls.get( 59 | browser, 60 | domains, 61 | console, 62 | ) 63 | console.print(_("未选择浏览器!")) 64 | 65 | @classmethod 66 | def get( 67 | cls, 68 | browser: str | int, 69 | domains: list[str], 70 | console: Console = None, 71 | ) -> str: 72 | console = console or Console() 73 | if not (browser := cls.__browser_object(browser)): 74 | console.print(_("浏览器名称或序号输入错误!")) 75 | return "" 76 | try: 77 | cookies = browser(domains=domains) 78 | return "; ".join(f"{i['name']}={i['value']}" for i in cookies) 79 | except RuntimeError: 80 | console.print(_("获取 Cookie 失败,未找到 Cookie 数据!")) 81 | return "" 82 | 83 | @classmethod 84 | def __browser_object(cls, browser: str | int): 85 | with suppress(ValueError): 86 | browser = int(browser) - 1 87 | if isinstance(browser, int): 88 | try: 89 | return list(cls.SUPPORT_BROWSER.values())[browser][0] 90 | except IndexError: 91 | return None 92 | if isinstance(browser, str): 93 | try: 94 | return cls.__match_browser(browser) 95 | except KeyError: 96 | return None 97 | raise TypeError 98 | 99 | @classmethod 100 | def __match_browser(cls, browser: str): 101 | for i, j in cls.SUPPORT_BROWSER.items(): 102 | if i.lower() == browser.lower(): 103 | return j[0] 104 | 105 | 106 | match platform: 107 | case "darwin": 108 | from rookiepy import safari 109 | 110 | BrowserCookie.SUPPORT_BROWSER |= { 111 | "Safari": (safari, "macOS"), 112 | } 113 | case "linux": 114 | BrowserCookie.SUPPORT_BROWSER.pop("OperaGX") 115 | case "win32": 116 | pass 117 | case _: 118 | print(_("从浏览器读取 Cookie 功能不支持当前平台!")) 119 | -------------------------------------------------------------------------------- /source/expansion/cleaner.py: -------------------------------------------------------------------------------- 1 | from platform import system 2 | from re import compile 3 | from string import whitespace 4 | from warnings import warn 5 | 6 | from emoji import replace_emoji 7 | 8 | try: 9 | from source.translation import _ 10 | except ImportError: 11 | _ = lambda s: s 12 | 13 | 14 | class Cleaner: 15 | CONTROL_CHARACTERS = compile(r"[\x00-\x1F\x7F]") 16 | 17 | def __init__(self): 18 | """ 19 | 替换字符串中包含的非法字符,默认根据系统类型生成对应的非法字符字典,也可以自行设置非法字符字典 20 | """ 21 | self.rule = self.default_rule() # 默认非法字符字典 22 | 23 | @staticmethod 24 | def default_rule(): 25 | """根据系统类型生成默认非法字符字典""" 26 | if (s := system()) in ("Windows", "Darwin"): 27 | rule = { 28 | "/": "", 29 | "\\": "", 30 | "|": "", 31 | "<": "", 32 | ">": "", 33 | '"': "", 34 | "?": "", 35 | ":": "", 36 | "*": "", 37 | "\x00": "", 38 | } # Windows 系统和 Mac 系统 39 | elif s == "Linux": 40 | rule = { 41 | "/": "", 42 | "\x00": "", 43 | } # Linux 系统 44 | else: 45 | warn(_("不受支持的操作系统类型,可能无法正常去除非法字符!")) 46 | rule = {} 47 | cache = {i: "" for i in whitespace[1:]} # 补充换行符等非法字符 48 | return rule | cache 49 | 50 | def set_rule(self, rule: dict[str, str], update=True): 51 | """ 52 | 设置非法字符字典 53 | 54 | :param rule: 替换规则,字典格式,键为非法字符,值为替换后的内容 55 | :param update: 如果是 True,则与原有规则字典合并,否则替换原有规则字典 56 | """ 57 | self.rule = {**self.rule, **rule} if update else rule 58 | 59 | def filter(self, text: str) -> str: 60 | """ 61 | 去除非法字符 62 | 63 | :param text: 待处理的字符串 64 | :return: 替换后的字符串,如果替换后字符串为空,则返回 None 65 | """ 66 | for i in self.rule: 67 | text = text.replace(i, self.rule[i]) 68 | return text 69 | 70 | def filter_name( 71 | self, 72 | text: str, 73 | replace: 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( 84 | text, 85 | replace, 86 | ) 87 | 88 | text = self.clear_spaces(text) 89 | 90 | text = text.strip().strip(".").strip("_") 91 | 92 | return text or default 93 | 94 | @staticmethod 95 | def clear_spaces(string: str): 96 | """将连续的空格转换为单个空格""" 97 | return " ".join(string.split()) 98 | 99 | @classmethod 100 | def remove_control_characters( 101 | cls, 102 | text, 103 | replace="", 104 | ): 105 | # 使用正则表达式匹配所有控制字符 106 | return cls.CONTROL_CHARACTERS.sub( 107 | replace, 108 | text, 109 | ) 110 | 111 | 112 | if __name__ == "__main__": 113 | demo = Cleaner() 114 | print(demo.rule) 115 | print(demo.filter_name("")) 116 | print(demo.remove_control_characters("hello \x08world")) 117 | -------------------------------------------------------------------------------- /source/expansion/converter.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from lxml.etree import HTML 4 | from yaml import safe_load 5 | 6 | __all__ = ["Converter"] 7 | 8 | 9 | class Converter: 10 | INITIAL_STATE = "//script/text()" 11 | KEYS_LINK = ( 12 | "note", 13 | "noteDetailMap", 14 | "[-1]", 15 | "note", 16 | ) 17 | 18 | def run(self, content: str) -> dict: 19 | return self._filter_object(self._convert_object(self._extract_object(content))) 20 | 21 | def _extract_object(self, html: str) -> str: 22 | if not html: 23 | return "" 24 | html_tree = HTML(html) 25 | scripts = html_tree.xpath(self.INITIAL_STATE) 26 | return self.get_script(scripts) 27 | 28 | @staticmethod 29 | def _convert_object(text: str) -> dict: 30 | return safe_load(text.lstrip("window.__INITIAL_STATE__=")) 31 | 32 | @classmethod 33 | def _filter_object(cls, data: dict) -> dict: 34 | return cls.deep_get(data, cls.KEYS_LINK) or {} 35 | 36 | @classmethod 37 | def deep_get(cls, data: dict, keys: list | tuple, default=None): 38 | if not data: 39 | return default 40 | try: 41 | for key in keys: 42 | if key.startswith("[") and key.endswith("]"): 43 | data = cls.safe_get(data, int(key[1:-1])) 44 | else: 45 | data = data[key] 46 | return data 47 | except (KeyError, IndexError, ValueError, TypeError): 48 | return default 49 | 50 | @staticmethod 51 | def safe_get(data: Union[dict, list, tuple, set], index: int): 52 | if isinstance(data, dict): 53 | return list(data.values())[index] 54 | elif isinstance(data, list | tuple | set): 55 | return data[index] 56 | raise TypeError 57 | 58 | @staticmethod 59 | def get_script(scripts: list) -> str: 60 | scripts.reverse() 61 | for script in scripts: 62 | if script.startswith("window.__INITIAL_STATE__"): 63 | return script 64 | return "" 65 | -------------------------------------------------------------------------------- /source/expansion/error.py: -------------------------------------------------------------------------------- 1 | class CacheError(Exception): 2 | def __init__(self, message: str): 3 | super().__init__(message) 4 | self.message = message 5 | 6 | def __str__(self): 7 | return self.message 8 | -------------------------------------------------------------------------------- /source/expansion/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 | -------------------------------------------------------------------------------- /source/expansion/namespace.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from types import SimpleNamespace 3 | from typing import Union 4 | 5 | __all__ = ["Namespace"] 6 | 7 | 8 | class Namespace: 9 | def __init__(self, data: dict) -> None: 10 | self.data: SimpleNamespace = self.generate_data_object(data) 11 | 12 | @staticmethod 13 | def generate_data_object(data: dict) -> SimpleNamespace: 14 | def depth_conversion(element): 15 | if isinstance(element, dict): 16 | return SimpleNamespace( 17 | **{k: depth_conversion(v) for k, v in element.items()} 18 | ) 19 | elif isinstance(element, list): 20 | return [depth_conversion(item) for item in element] 21 | else: 22 | return element 23 | 24 | return depth_conversion(data) 25 | 26 | def safe_extract( 27 | self, 28 | attribute_chain: str, 29 | default: Union[str, int, list, dict, SimpleNamespace] = "", 30 | ): 31 | return self.__safe_extract(self.data, attribute_chain, default) 32 | 33 | @staticmethod 34 | def __safe_extract( 35 | data_object: SimpleNamespace, 36 | attribute_chain: str, 37 | default: Union[str, int, list, dict, SimpleNamespace] = "", 38 | ): 39 | data = deepcopy(data_object) 40 | attributes = attribute_chain.split(".") 41 | for attribute in attributes: 42 | if "[" in attribute: 43 | parts = attribute.split("[", 1) 44 | attribute = parts[0] 45 | index = parts[1][:-1] 46 | try: 47 | index = int(index) 48 | data = getattr(data, attribute, None)[index] 49 | except (IndexError, TypeError, ValueError): 50 | return default 51 | else: 52 | data = getattr(data, attribute, None) 53 | if not data: 54 | return default 55 | return data or default 56 | 57 | @classmethod 58 | def object_extract( 59 | cls, 60 | data_object: SimpleNamespace, 61 | attribute_chain: str, 62 | default: Union[str, int, list, dict, SimpleNamespace] = "", 63 | ): 64 | return cls.__safe_extract( 65 | data_object, 66 | attribute_chain, 67 | default, 68 | ) 69 | 70 | @property 71 | def __dict__(self): 72 | return self.convert_to_dict(self.data) 73 | 74 | @classmethod 75 | def convert_to_dict(cls, data) -> dict: 76 | return { 77 | key: cls.convert_to_dict(value) 78 | if isinstance(value, SimpleNamespace) 79 | else value 80 | for key, value in vars(data).items() 81 | } 82 | 83 | def __bool__(self): 84 | return bool(vars(self.data)) 85 | -------------------------------------------------------------------------------- /source/expansion/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 | -------------------------------------------------------------------------------- /source/module/__init__.py: -------------------------------------------------------------------------------- 1 | from .extend import Account 2 | from .manager import Manager 3 | from .model import ( 4 | ExtractData, 5 | ExtractParams, 6 | ) 7 | from .recorder import DataRecorder 8 | from .recorder import IDRecorder 9 | from .recorder import MapRecorder 10 | from .mapping import Mapping 11 | from .settings import Settings 12 | from .static import ( 13 | VERSION_MAJOR, 14 | VERSION_MINOR, 15 | VERSION_BETA, 16 | ROOT, 17 | REPOSITORY, 18 | LICENCE, 19 | RELEASES, 20 | MASTER, 21 | PROMPT, 22 | GENERAL, 23 | PROGRESS, 24 | ERROR, 25 | WARNING, 26 | INFO, 27 | USERSCRIPT, 28 | HEADERS, 29 | PROJECT, 30 | USERAGENT, 31 | FILE_SIGNATURES, 32 | FILE_SIGNATURES_LENGTH, 33 | MAX_WORKERS, 34 | __VERSION__, 35 | ) 36 | from .tools import ( 37 | retry, 38 | logging, 39 | sleep_time, 40 | retry_limited, 41 | ) 42 | -------------------------------------------------------------------------------- /source/module/extend.py: -------------------------------------------------------------------------------- 1 | __all__ = ["Account"] 2 | 3 | 4 | class Account: 5 | pass 6 | -------------------------------------------------------------------------------- /source/module/manager.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from re import compile, sub 3 | from shutil import move, rmtree 4 | from os import utime 5 | from httpx import ( 6 | AsyncClient, 7 | AsyncHTTPTransport, 8 | HTTPStatusError, 9 | RequestError, 10 | TimeoutException, 11 | get, 12 | ) 13 | 14 | from source.expansion import remove_empty_directories 15 | 16 | from ..translation import _ 17 | from .static import HEADERS, USERAGENT, WARNING 18 | from .tools import logging 19 | 20 | __all__ = ["Manager"] 21 | 22 | 23 | class Manager: 24 | NAME = compile(r"[^\u4e00-\u9fffa-zA-Z0-9-_!?,。;:“”()《》]") 25 | NAME_KEYS = ( 26 | "收藏数量", 27 | "评论数量", 28 | "分享数量", 29 | "点赞数量", 30 | "作品标签", 31 | "作品ID", 32 | "作品标题", 33 | "作品描述", 34 | "作品类型", 35 | "发布时间", 36 | "最后更新时间", 37 | "作者昵称", 38 | "作者ID", 39 | ) 40 | NO_PROXY = { 41 | "http://": None, 42 | "https://": None, 43 | } 44 | SEPARATE = "_" 45 | WEB_ID = r"(?:^|; )webId=[^;]+" 46 | WEB_SESSION = r"(?:^|; )web_session=[^;]+" 47 | 48 | def __init__( 49 | self, 50 | root: Path, 51 | path: str, 52 | folder: str, 53 | name_format: str, 54 | chunk: int, 55 | user_agent: str, 56 | cookie: str, 57 | proxy: str | dict, 58 | timeout: int, 59 | retry: int, 60 | record_data: bool, 61 | image_format: str, 62 | image_download: bool, 63 | video_download: bool, 64 | live_download: bool, 65 | download_record: bool, 66 | folder_mode: bool, 67 | author_archive: bool, 68 | write_mtime: bool, 69 | _print: bool, 70 | ): 71 | self.root = root 72 | self.temp = root.joinpath("./temp") 73 | self.path = self.__check_path(path) 74 | self.folder = self.__check_folder(folder) 75 | self.blank_headers = HEADERS | { 76 | "user-agent": user_agent or USERAGENT, 77 | } 78 | self.headers = self.blank_headers | { 79 | "cookie": cookie, 80 | } 81 | self.retry = retry 82 | self.chunk = chunk 83 | self.name_format = self.__check_name_format(name_format) 84 | self.record_data = self.check_bool(record_data, False) 85 | self.image_format = self.__check_image_format(image_format) 86 | self.folder_mode = self.check_bool(folder_mode, False) 87 | self.download_record = self.check_bool(download_record, True) 88 | self.proxy_tip = None 89 | self.proxy = self.__check_proxy(proxy) 90 | self.print_proxy_tip( 91 | _print, 92 | ) 93 | self.timeout = timeout 94 | self.request_client = AsyncClient( 95 | headers=self.headers 96 | | { 97 | "referer": "https://www.xiaohongshu.com/", 98 | }, 99 | timeout=timeout, 100 | verify=False, 101 | follow_redirects=True, 102 | mounts={ 103 | "http://": AsyncHTTPTransport(proxy=self.proxy), 104 | "https://": AsyncHTTPTransport(proxy=self.proxy), 105 | }, 106 | ) 107 | self.download_client = AsyncClient( 108 | headers=self.blank_headers, 109 | timeout=timeout, 110 | verify=False, 111 | follow_redirects=True, 112 | mounts={ 113 | "http://": AsyncHTTPTransport(proxy=self.proxy), 114 | "https://": AsyncHTTPTransport(proxy=self.proxy), 115 | }, 116 | ) 117 | self.image_download = self.check_bool(image_download, True) 118 | self.video_download = self.check_bool(video_download, True) 119 | self.live_download = self.check_bool(live_download, True) 120 | self.author_archive = self.check_bool(author_archive, False) 121 | self.write_mtime = self.check_bool(write_mtime, False) 122 | 123 | def __check_path(self, path: str) -> Path: 124 | if not path: 125 | return self.root 126 | if (r := Path(path)).is_dir(): 127 | return r 128 | return r if (r := self.__check_root_again(r)) else self.root 129 | 130 | def __check_folder(self, folder: str) -> Path: 131 | folder = self.path.joinpath(folder or "Download") 132 | folder.mkdir(exist_ok=True) 133 | self.temp.mkdir(exist_ok=True) 134 | return folder 135 | 136 | @staticmethod 137 | def __check_root_again(root: Path) -> bool | Path: 138 | if root.resolve().parent.is_dir(): 139 | root.mkdir() 140 | return root 141 | return False 142 | 143 | @staticmethod 144 | def __check_image_format(image_format) -> str: 145 | if (i := image_format.lower()) in { 146 | "auto", 147 | "png", 148 | "webp", 149 | "jpeg", 150 | "heic", 151 | "avif", 152 | }: 153 | return i 154 | return "png" 155 | 156 | @staticmethod 157 | def is_exists(path: Path) -> bool: 158 | return path.exists() 159 | 160 | @staticmethod 161 | def delete(path: Path): 162 | if path.exists(): 163 | path.unlink() 164 | 165 | @staticmethod 166 | def archive(root: Path, name: str, folder_mode: bool) -> Path: 167 | return root.joinpath(name) if folder_mode else root 168 | 169 | @classmethod 170 | def move( 171 | cls, 172 | temp: Path, 173 | path: Path, 174 | mtime: int = None, 175 | rewrite: bool = False, 176 | ): 177 | move(temp.resolve(), path.resolve()) 178 | if rewrite and mtime: 179 | cls.update_mtime(path.resolve(), mtime) 180 | 181 | @staticmethod 182 | def update_mtime(file: Path, mtime: int): 183 | utime(file, (mtime, mtime)) 184 | 185 | def __clean(self): 186 | rmtree(self.temp.resolve()) 187 | 188 | def filter_name(self, name: str) -> str: 189 | name = self.NAME.sub("_", name) 190 | return sub(r"_+", "_", name).strip("_") 191 | 192 | @staticmethod 193 | def check_bool(value: bool, default: bool) -> bool: 194 | return value if isinstance(value, bool) else default 195 | 196 | async def close(self): 197 | await self.request_client.aclose() 198 | await self.download_client.aclose() 199 | # self.__clean() 200 | remove_empty_directories(self.root) 201 | remove_empty_directories(self.folder) 202 | 203 | def __check_name_format(self, format_: str) -> str: 204 | keys = format_.split() 205 | return next( 206 | ("发布时间 作者昵称 作品标题" for key in keys if key not in self.NAME_KEYS), 207 | format_, 208 | ) 209 | 210 | def __check_proxy( 211 | self, 212 | proxy: str, 213 | url="https://www.xiaohongshu.com/explore", 214 | ) -> str | None: 215 | if proxy: 216 | try: 217 | response = get( 218 | url, 219 | proxy=proxy, 220 | timeout=10, 221 | headers={ 222 | "User-Agent": USERAGENT, 223 | }, 224 | ) 225 | response.raise_for_status() 226 | self.proxy_tip = (_("代理 {0} 测试成功").format(proxy),) 227 | return proxy 228 | except TimeoutException: 229 | self.proxy_tip = ( 230 | _("代理 {0} 测试超时").format(proxy), 231 | WARNING, 232 | ) 233 | except ( 234 | RequestError, 235 | HTTPStatusError, 236 | ) as e: 237 | self.proxy_tip = ( 238 | _("代理 {0} 测试失败:{1}").format( 239 | proxy, 240 | e, 241 | ), 242 | WARNING, 243 | ) 244 | 245 | def print_proxy_tip( 246 | self, 247 | _print: bool = True, 248 | log=None, 249 | ) -> None: 250 | if _print and self.proxy_tip: 251 | logging(log, *self.proxy_tip) 252 | 253 | @classmethod 254 | def clean_cookie(cls, cookie_string: str) -> str: 255 | return cls.delete_cookie( 256 | cookie_string, 257 | ( 258 | cls.WEB_ID, 259 | cls.WEB_SESSION, 260 | ), 261 | ) 262 | 263 | @classmethod 264 | def delete_cookie(cls, cookie_string: str, patterns: list | tuple) -> str: 265 | for pattern in patterns: 266 | # 使用空字符串替换匹配到的部分 267 | cookie_string = sub(pattern, "", cookie_string) 268 | # 去除多余的分号和空格 269 | cookie_string = sub(r";\s*$", "", cookie_string) # 删除末尾的分号和空格 270 | cookie_string = sub(r";\s*;", ";", cookie_string) # 删除中间多余分号后的空格 271 | return cookie_string.strip("; ") 272 | -------------------------------------------------------------------------------- /source/module/mapping.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import TYPE_CHECKING 3 | 4 | from ..translation import _ 5 | from .static import ERROR 6 | from .tools import logging 7 | 8 | if TYPE_CHECKING: 9 | from manager import Manager 10 | from recorder import MapRecorder 11 | 12 | 13 | __all__ = ["Mapping"] 14 | 15 | 16 | class Mapping: 17 | def __init__( 18 | self, 19 | manager: "Manager", 20 | mapping: "MapRecorder", 21 | ): 22 | self.root = manager.folder 23 | self.folder_mode = manager.folder_mode 24 | self.database = mapping 25 | self.switch = manager.author_archive 26 | 27 | async def update_cache( 28 | self, 29 | id_: str, 30 | alias: str, 31 | log=None, 32 | ): 33 | if not self.switch: 34 | return 35 | if (a := await self.has_mapping(id_)) and a != alias: 36 | self.__check_file( 37 | id_, 38 | alias, 39 | a, 40 | log, 41 | ) 42 | await self.database.add(id_, alias) 43 | 44 | async def has_mapping(self, id_: str) -> str: 45 | return d[0] if (d := await self.database.select(id_)) else "" 46 | 47 | def __check_file( 48 | self, 49 | id_: str, 50 | alias: str, 51 | old_alias: str, 52 | log, 53 | ): 54 | if not (old_folder := self.root.joinpath(f"{id_}_{old_alias}")).is_dir(): 55 | logging( 56 | log, 57 | _("{old_folder} 文件夹不存在,跳过处理").format( 58 | old_folder=old_folder.name 59 | ), 60 | ) 61 | return 62 | self.__rename_folder( 63 | old_folder, 64 | id_, 65 | alias, 66 | log, 67 | ) 68 | self.__scan_file( 69 | id_, 70 | alias, 71 | old_alias, 72 | log, 73 | ) 74 | 75 | def __rename_folder( 76 | self, 77 | old_folder: Path, 78 | id_: str, 79 | alias: str, 80 | log, 81 | ): 82 | new_folder = self.root.joinpath(f"{id_}_{alias}") 83 | self.__rename( 84 | old_folder, 85 | new_folder, 86 | _("文件夹"), 87 | log, 88 | ) 89 | logging( 90 | log, 91 | _("文件夹 {old_folder} 已重命名为 {new_folder}").format( 92 | old_folder=old_folder.name, new_folder=new_folder.name 93 | ), 94 | ) 95 | 96 | def __rename_works_folder( 97 | self, 98 | old_: Path, 99 | alias: str, 100 | old_alias: str, 101 | log, 102 | ) -> Path: 103 | if old_alias in old_.name: 104 | new_ = old_.parent / old_.name.replace(old_alias, alias, 1) 105 | self.__rename( 106 | old_, 107 | new_, 108 | _("文件夹"), 109 | log, 110 | ) 111 | logging( 112 | log, 113 | _("文件夹 {old_} 重命名为 {new_}").format( 114 | old_=old_.name, new_=new_.name 115 | ), 116 | ) 117 | return new_ 118 | return old_ 119 | 120 | def __scan_file( 121 | self, 122 | id_: str, 123 | alias: str, 124 | old_alias: str, 125 | log, 126 | ): 127 | root = self.root.joinpath(f"{id_}_{alias}") 128 | item_list = root.iterdir() 129 | if self.folder_mode: 130 | for f in item_list: 131 | if f.is_dir(): 132 | f = self.__rename_works_folder( 133 | f, 134 | alias, 135 | old_alias, 136 | log, 137 | ) 138 | files = f.iterdir() 139 | self.__batch_rename( 140 | f, 141 | files, 142 | alias, 143 | old_alias, 144 | log, 145 | ) 146 | else: 147 | self.__batch_rename( 148 | root, 149 | item_list, 150 | alias, 151 | old_alias, 152 | log, 153 | ) 154 | 155 | def __batch_rename( 156 | self, 157 | root: Path, 158 | files, 159 | alias: str, 160 | old_alias: str, 161 | log, 162 | ): 163 | for old_file in files: 164 | if old_alias not in old_file.name: 165 | break 166 | self.__rename_file( 167 | root, 168 | old_file, 169 | alias, 170 | old_alias, 171 | log, 172 | ) 173 | 174 | def __rename_file( 175 | self, 176 | root: Path, 177 | old_file: Path, 178 | alias: str, 179 | old_alias: str, 180 | log, 181 | ): 182 | new_file = root.joinpath(old_file.name.replace(old_alias, alias, 1)) 183 | self.__rename( 184 | old_file, 185 | new_file, 186 | _("文件"), 187 | log, 188 | ) 189 | logging( 190 | log, 191 | _("文件 {old_file} 重命名为 {new_file}").format( 192 | old_file=old_file.name, new_file=new_file.name 193 | ), 194 | ) 195 | return True 196 | 197 | @staticmethod 198 | def __rename( 199 | old_: Path, 200 | new_: Path, 201 | type_=_("文件"), 202 | log=None, 203 | ) -> bool: 204 | try: 205 | old_.rename(new_) 206 | return True 207 | except PermissionError as e: 208 | logging( 209 | log, 210 | _("{type} {old}被占用,重命名失败: {error}").format( 211 | type=type_, old=old_.name, error=e 212 | ), 213 | ERROR, 214 | ) 215 | return False 216 | except FileExistsError as e: 217 | logging( 218 | log, 219 | _("{type} {new}名称重复,重命名失败: {error}").format( 220 | type=type_, new=new_.name, error=e 221 | ), 222 | ERROR, 223 | ) 224 | return False 225 | except OSError as e: 226 | logging( 227 | log, 228 | _("处理{type} {old}时发生预期之外的错误: {error}").format( 229 | type=type_, old=old_.name, error=e 230 | ), 231 | ERROR, 232 | ) 233 | return True 234 | -------------------------------------------------------------------------------- /source/module/model.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class ExtractParams(BaseModel): 5 | url: str 6 | download: bool = False 7 | index: list = None 8 | cookie: str = None 9 | proxy: str = None 10 | skip: bool = False 11 | 12 | 13 | class ExtractData(BaseModel): 14 | message: str 15 | params: ExtractParams 16 | data: dict | None 17 | -------------------------------------------------------------------------------- /source/module/recorder.py: -------------------------------------------------------------------------------- 1 | from asyncio import CancelledError 2 | from contextlib import suppress 3 | from typing import TYPE_CHECKING 4 | 5 | from aiosqlite import connect 6 | 7 | if TYPE_CHECKING: 8 | from ..module import Manager 9 | 10 | __all__ = ["IDRecorder", "DataRecorder", "MapRecorder"] 11 | 12 | 13 | class IDRecorder: 14 | def __init__(self, manager: "Manager"): 15 | self.file = manager.root.joinpath("ExploreID.db") 16 | self.switch = manager.download_record 17 | self.database = None 18 | self.cursor = None 19 | 20 | async def _connect_database(self): 21 | self.database = await connect(self.file) 22 | self.cursor = await self.database.cursor() 23 | await self.database.execute( 24 | "CREATE TABLE IF NOT EXISTS explore_id (ID TEXT PRIMARY KEY);" 25 | ) 26 | await self.database.commit() 27 | 28 | async def select(self, id_: str): 29 | if self.switch: 30 | await self.cursor.execute("SELECT ID FROM explore_id WHERE ID=?", (id_,)) 31 | return await self.cursor.fetchone() 32 | 33 | async def add( 34 | self, 35 | id_: str, 36 | name: str = None, 37 | *args, 38 | **kwargs, 39 | ) -> None: 40 | if self.switch: 41 | await self.database.execute("REPLACE INTO explore_id VALUES (?);", (id_,)) 42 | await self.database.commit() 43 | 44 | async def __delete(self, id_: str) -> None: 45 | if id_: 46 | await self.database.execute("DELETE FROM explore_id WHERE ID=?", (id_,)) 47 | await self.database.commit() 48 | 49 | async def delete(self, ids: list[str]): 50 | if self.switch: 51 | [await self.__delete(i) for i in ids] 52 | 53 | async def all(self): 54 | if self.switch: 55 | await self.cursor.execute("SELECT ID FROM explore_id") 56 | return [i[0] for i in await self.cursor.fetchmany()] 57 | 58 | async def __aenter__(self): 59 | await self._connect_database() 60 | return self 61 | 62 | async def __aexit__(self, exc_type, exc_value, traceback): 63 | with suppress(CancelledError): 64 | await self.cursor.close() 65 | await self.database.close() 66 | 67 | 68 | class DataRecorder(IDRecorder): 69 | DATA_TABLE = ( 70 | ("采集时间", "TEXT"), 71 | ("作品ID", "TEXT PRIMARY KEY"), 72 | ("作品类型", "TEXT"), 73 | ("作品标题", "TEXT"), 74 | ("作品描述", "TEXT"), 75 | ("作品标签", "TEXT"), 76 | ("发布时间", "TEXT"), 77 | ("最后更新时间", "TEXT"), 78 | ("收藏数量", "TEXT"), 79 | ("评论数量", "TEXT"), 80 | ("分享数量", "TEXT"), 81 | ("点赞数量", "TEXT"), 82 | ("作者昵称", "TEXT"), 83 | ("作者ID", "TEXT"), 84 | ("作者链接", "TEXT"), 85 | ("作品链接", "TEXT"), 86 | ("下载地址", "TEXT"), 87 | ("动图地址", "TEXT"), 88 | ) 89 | 90 | def __init__(self, manager: "Manager"): 91 | super().__init__(manager) 92 | self.file = manager.folder.joinpath("ExploreData.db") 93 | self.switch = manager.record_data 94 | 95 | async def _connect_database(self): 96 | self.database = await connect(self.file) 97 | self.cursor = await self.database.cursor() 98 | await self.database.execute(f"""CREATE TABLE IF NOT EXISTS explore_data ( 99 | {",".join(" ".join(i) for i in self.DATA_TABLE)} 100 | );""") 101 | await self.database.commit() 102 | 103 | async def select(self, id_: str): 104 | pass 105 | 106 | async def add(self, **kwargs) -> None: 107 | if self.switch: 108 | await self.database.execute( 109 | f"""REPLACE INTO explore_data ( 110 | {", ".join(i[0] for i in self.DATA_TABLE)} 111 | ) VALUES ( 112 | {", ".join("?" for _ in kwargs)} 113 | );""", 114 | self.__generate_values(kwargs), 115 | ) 116 | await self.database.commit() 117 | 118 | async def __delete(self, id_: str) -> None: 119 | pass 120 | 121 | async def delete(self, ids: list | tuple): 122 | pass 123 | 124 | async def all(self): 125 | pass 126 | 127 | def __generate_values(self, data: dict) -> tuple: 128 | return tuple(data[i] for i, _ in self.DATA_TABLE) 129 | 130 | 131 | class MapRecorder(IDRecorder): 132 | def __init__(self, manager: "Manager"): 133 | super().__init__(manager) 134 | self.file = manager.root.joinpath("MappingData.db") 135 | self.switch = manager.author_archive 136 | 137 | async def _connect_database(self): 138 | self.database = await connect(self.file) 139 | self.cursor = await self.database.cursor() 140 | await self.database.execute( 141 | "CREATE TABLE IF NOT EXISTS mapping_data (" 142 | "ID TEXT PRIMARY KEY," 143 | "NAME TEXT NOT NULL" 144 | ");" 145 | ) 146 | await self.database.commit() 147 | 148 | async def select(self, id_: str): 149 | if self.switch: 150 | await self.cursor.execute( 151 | "SELECT NAME FROM mapping_data WHERE ID=?", (id_,) 152 | ) 153 | return await self.cursor.fetchone() 154 | 155 | async def add(self, id_: str, name: str, *args, **kwargs) -> None: 156 | if self.switch: 157 | await self.database.execute( 158 | "REPLACE INTO mapping_data VALUES (?, ?);", 159 | ( 160 | id_, 161 | name, 162 | ), 163 | ) 164 | await self.database.commit() 165 | 166 | async def __delete(self, id_: str) -> None: 167 | pass 168 | 169 | async def delete(self, ids: list[str]): 170 | pass 171 | 172 | async def all(self): 173 | if self.switch: 174 | await self.cursor.execute("SELECT ID, NAME FROM mapping_data") 175 | return [i[0] for i in await self.cursor.fetchmany()] 176 | -------------------------------------------------------------------------------- /source/module/settings.py: -------------------------------------------------------------------------------- 1 | from json import dump, load 2 | from pathlib import Path 3 | from platform import system 4 | 5 | from .static import ROOT, USERAGENT 6 | 7 | __all__ = ["Settings"] 8 | 9 | 10 | class Settings: 11 | default = { 12 | "mapping_data": {}, 13 | "work_path": "", 14 | "folder_name": "Download", 15 | "name_format": "发布时间 作者昵称 作品标题", 16 | "user_agent": USERAGENT, 17 | "cookie": "", 18 | "proxy": None, 19 | "timeout": 10, 20 | "chunk": 1024 * 1024 * 2, 21 | "max_retry": 5, 22 | "record_data": False, 23 | "image_format": "PNG", 24 | "image_download": True, 25 | "video_download": True, 26 | "live_download": False, 27 | "folder_mode": False, 28 | "download_record": True, 29 | "author_archive": False, 30 | "write_mtime": False, 31 | "language": "zh_CN", 32 | } 33 | encode = "UTF-8-SIG" if system() == "Windows" else "UTF-8" 34 | 35 | def __init__(self, root: Path = ROOT): 36 | self.file = root.joinpath("./settings.json") 37 | 38 | def run(self): 39 | return self.read() if self.file.is_file() else self.create() 40 | 41 | def read(self) -> dict: 42 | with self.file.open("r", encoding=self.encode) as f: 43 | return load(f) 44 | 45 | def create(self) -> dict: 46 | with self.file.open("w", encoding=self.encode) as f: 47 | dump(self.default, f, indent=4, ensure_ascii=False) 48 | return self.default 49 | 50 | def update(self, data: dict): 51 | with self.file.open("w", encoding=self.encode) as f: 52 | dump(data, f, indent=4, ensure_ascii=False) 53 | 54 | @classmethod 55 | def check_keys( 56 | cls, 57 | data: dict, 58 | callback: callable, 59 | *args, 60 | **kwargs, 61 | ) -> dict: 62 | needful_keys = set(cls.default.keys()) 63 | given_keys = set(data.keys()) 64 | if not needful_keys.issubset(given_keys): 65 | callback(*args, **kwargs) 66 | return cls.default 67 | return data 68 | -------------------------------------------------------------------------------- /source/module/static.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | VERSION_MAJOR = 2 4 | VERSION_MINOR = 6 5 | VERSION_BETA = True 6 | __VERSION__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{'beta' if VERSION_BETA else 'stable'}" 7 | ROOT = Path(__file__).resolve().parent.parent.parent 8 | PROJECT = f"XHS-Downloader V{VERSION_MAJOR}.{VERSION_MINOR} { 9 | 'Beta' if VERSION_BETA else 'Stable' 10 | }" 11 | 12 | REPOSITORY = "https://github.com/JoeanAmier/XHS-Downloader" 13 | LICENCE = "GNU General Public License v3.0" 14 | RELEASES = "https://github.com/JoeanAmier/XHS-Downloader/releases/latest" 15 | 16 | USERSCRIPT = "https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/master/static/XHS-Downloader.js" 17 | 18 | USERAGENT = ( 19 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 " 20 | "Safari/537.36" 21 | ) 22 | 23 | HEADERS = { 24 | "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8," 25 | "application/signed-exchange;v=b3;q=0.7", 26 | "referer": "https://www.xiaohongshu.com/explore", 27 | "user-agent": USERAGENT, 28 | } 29 | 30 | MASTER = "b #fff200" 31 | PROMPT = "b turquoise2" 32 | GENERAL = "b bright_white" 33 | PROGRESS = "b bright_magenta" 34 | ERROR = "b bright_red" 35 | WARNING = "b bright_yellow" 36 | INFO = "b bright_green" 37 | 38 | FILE_SIGNATURES: tuple[ 39 | tuple[ 40 | int, 41 | bytes, 42 | str, 43 | ], 44 | ..., 45 | ] = ( 46 | # 分别为偏移量(字节)、十六进制签名、后缀 47 | # 参考:https://en.wikipedia.org/wiki/List_of_file_signatures 48 | # 参考:https://www.garykessler.net/library/file_sigs.html 49 | (0, b"\xff\xd8\xff", "jpeg"), 50 | (0, b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a", "png"), 51 | (4, b"\x66\x74\x79\x70\x61\x76\x69\x66", "avif"), 52 | (4, b"\x66\x74\x79\x70\x68\x65\x69\x63", "heic"), 53 | (8, b"\x57\x45\x42\x50", "webp"), 54 | (4, b"\x66\x74\x79\x70\x4d\x53\x4e\x56", "mp4"), 55 | (4, b"\x66\x74\x79\x70\x69\x73\x6f\x6d", "mp4"), 56 | (4, b"\x66\x74\x79\x70\x6d\x70\x34\x32", "m4v"), 57 | (4, b"\x66\x74\x79\x70\x71\x74\x20\x20", "mov"), 58 | (0, b"\x1a\x45\xdf\xa3", "mkv"), 59 | (0, b"\x00\x00\x01\xb3", "mpg"), 60 | (0, b"\x00\x00\x01\xba", "mpg"), 61 | (0, b"\x46\x4c\x56\x01", "flv"), 62 | (8, b"\x41\x56\x49\x20", "avi"), 63 | ) 64 | FILE_SIGNATURES_LENGTH = max( 65 | offset + len(signature) for offset, signature, _ in FILE_SIGNATURES 66 | ) 67 | 68 | MAX_WORKERS: int = 4 69 | 70 | if __name__ == "__main__": 71 | print(__VERSION__) 72 | -------------------------------------------------------------------------------- /source/module/tools.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | from random import uniform 3 | 4 | from rich import print 5 | from rich.text import Text 6 | 7 | from ..translation import _ 8 | from .static import INFO 9 | 10 | 11 | def retry(function): 12 | async def inner(self, *args, **kwargs): 13 | if result := await function(self, *args, **kwargs): 14 | return result 15 | for __ in range(self.retry): 16 | if result := await function(self, *args, **kwargs): 17 | return result 18 | return result 19 | 20 | return inner 21 | 22 | 23 | def retry_limited(function): 24 | # TODO: 不支持 TUI 25 | def inner(self, *args, **kwargs): 26 | while True: 27 | if function(self, *args, **kwargs): 28 | return 29 | if self.console.input( 30 | _( 31 | "如需重新尝试处理该对象,请关闭所有正在访问该对象的窗口或程序,然后直接按下回车键!\n" 32 | "如需跳过处理该对象,请输入任意字符后按下回车键!" 33 | ), 34 | ): 35 | return 36 | 37 | return inner 38 | 39 | 40 | def logging(log, text, style=INFO): 41 | string = Text(text, style=style) 42 | if log: 43 | log.write( 44 | string, 45 | scroll_end=True, 46 | ) 47 | else: 48 | print(string) 49 | 50 | 51 | async def sleep_time( 52 | min_time: int | float = 1.0, 53 | max_time: int | float = 2.5, 54 | ): 55 | await sleep(uniform(min_time, max_time)) 56 | -------------------------------------------------------------------------------- /source/translation/__init__.py: -------------------------------------------------------------------------------- 1 | from .translate import switch_language, _ 2 | -------------------------------------------------------------------------------- /source/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="xhs", 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 | -------------------------------------------------------------------------------- /static/QQ群聊二维码.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/QQ群聊二维码.png -------------------------------------------------------------------------------- /static/Release_Notes.md: -------------------------------------------------------------------------------- 1 | **项目更新内容:** 2 | 3 | 1. 支持音乐图集作品下载 4 | 5 | ***** 6 | 7 | **用户脚本更新内容:** 8 | 9 | **版本号:2.0.1** 10 | 11 | 1. 修复单张图片的作品下载后文件损坏问题 12 | -------------------------------------------------------------------------------- /static/XHS-Downloader.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/XHS-Downloader.icns -------------------------------------------------------------------------------- /static/XHS-Downloader.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/XHS-Downloader.ico -------------------------------------------------------------------------------- /static/XHS-Downloader.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/XHS-Downloader.jpg -------------------------------------------------------------------------------- /static/XHS-Downloader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/XHS-Downloader.png -------------------------------------------------------------------------------- /static/XHS-Downloader.tcss: -------------------------------------------------------------------------------- 1 | Button { 2 | width: 1fr; 3 | margin: 1 1; 4 | } 5 | .vertical-layout { 6 | layout: vertical; 7 | height: auto; 8 | } 9 | .horizontal-layout, .settings_button { 10 | layout: horizontal; 11 | height: auto; 12 | } 13 | .horizontal-layout > * { 14 | width: 25vw; 15 | } 16 | Button#deal, Button#paste, Button#save, Button#enter { 17 | color: $success; 18 | } 19 | Button#reset, Button#abandon, Button#close { 20 | color: $error; 21 | } 22 | Label, Link { 23 | width: 100%; 24 | content-align-horizontal: center; 25 | content-align-vertical: middle; 26 | } 27 | Link { 28 | color: $accent; 29 | } 30 | Label.params { 31 | margin: 1 0 0 0; 32 | color: $primary; 33 | } 34 | Label.prompt { 35 | padding: 1; 36 | } 37 | .loading { 38 | grid-size: 1 2; 39 | grid-gutter: 1; 40 | width: 40vw; 41 | height: 5; 42 | border: double $primary; 43 | } 44 | #record { 45 | grid-size: 1 3; 46 | width: 80vw; 47 | height: 12; 48 | border: double $primary; 49 | } 50 | ModalScreen { 51 | align: center middle; 52 | } 53 | -------------------------------------------------------------------------------- /static/screenshot/命令行模式截图CN1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/命令行模式截图CN1.png -------------------------------------------------------------------------------- /static/screenshot/命令行模式截图CN2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/命令行模式截图CN2.png -------------------------------------------------------------------------------- /static/screenshot/命令行模式截图EN1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/命令行模式截图EN1.png -------------------------------------------------------------------------------- /static/screenshot/命令行模式截图EN2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/命令行模式截图EN2.png -------------------------------------------------------------------------------- /static/screenshot/用户脚本截图1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/用户脚本截图1.png -------------------------------------------------------------------------------- /static/screenshot/用户脚本截图2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/用户脚本截图2.png -------------------------------------------------------------------------------- /static/screenshot/用户脚本截图3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/用户脚本截图3.png -------------------------------------------------------------------------------- /static/screenshot/程序运行截图CN1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/程序运行截图CN1.png -------------------------------------------------------------------------------- /static/screenshot/程序运行截图CN2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/程序运行截图CN2.png -------------------------------------------------------------------------------- /static/screenshot/程序运行截图CN3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/程序运行截图CN3.png -------------------------------------------------------------------------------- /static/screenshot/程序运行截图EN1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/程序运行截图EN1.png -------------------------------------------------------------------------------- /static/screenshot/程序运行截图EN2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/程序运行截图EN2.png -------------------------------------------------------------------------------- /static/screenshot/程序运行截图EN3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/程序运行截图EN3.png -------------------------------------------------------------------------------- /static/screenshot/脚本安装教程.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/脚本安装教程.png -------------------------------------------------------------------------------- /static/screenshot/获取Cookie示意图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/获取Cookie示意图.png -------------------------------------------------------------------------------- /static/screenshot/请求头示例图.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/screenshot/请求头示例图.png -------------------------------------------------------------------------------- /static/微信赞助二维码.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/微信赞助二维码.png -------------------------------------------------------------------------------- /static/支付宝赞助二维码.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/支付宝赞助二维码.png -------------------------------------------------------------------------------- /static/自动滚动页面.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name 自动滚动页面 3 | // @namespace http://tampermonkey.net/ 4 | // @version 0.1 5 | // @description 模拟自然滚动,测试页面滚动效果 6 | // @author ChatGPT, JoeanAmier 7 | // @match *://*/* // 匹配所有页面,也可以指定具体网站 8 | // @grant none 9 | // @run-at document-end // 在页面加载完毕后运行脚本 10 | // ==/UserScript== 11 | 12 | (function () { 13 | 'use strict'; 14 | 15 | // 配置滚动模式 16 | const scrollMode = 'limited'; // 'none'、'endless' 或 'limited' 17 | const maxScrollCount = 10; // 最大滚动次数(如果模式是 'limited') 18 | 19 | // 随机整数生成函数 20 | const getRandomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; 21 | 22 | // 判断是否需要暂停,模拟用户的停顿行为 23 | const shouldPause = () => Math.random() < 0.2; // 20%几率停顿 24 | 25 | // 执行一次增量滚动 26 | const scrollOnce = () => { 27 | const scrollDistanceMin = 50; // 最小滚动距离 28 | const scrollDistanceMax = 200; // 最大滚动距离 29 | const scrollDistance = getRandomInt(scrollDistanceMin, scrollDistanceMax); 30 | console.log(`滚动距离: ${scrollDistance}px`); // 日志输出滚动距离 31 | window.scrollBy(0, scrollDistance); // 增量滚动 32 | }; 33 | 34 | // 检查是否已经滚动到底部 35 | const isAtBottom = () => { 36 | const docHeight = document.documentElement.scrollHeight; 37 | const winHeight = window.innerHeight; 38 | const scrollPos = window.scrollY; 39 | 40 | return (docHeight - winHeight - scrollPos <= 10); // 如果距离底部小于10px,认为滚动到底部 41 | }; 42 | 43 | // 自动滚动主函数 44 | const scrollScreen = (callback, scrollCount = 0,) => { 45 | const timeoutMin = 100; // 最小滚动间隔 46 | const timeoutMax = 300; // 最大滚动间隔 47 | 48 | console.log('开始滚动...'); 49 | 50 | const scrollInterval = setInterval(() => { 51 | if (shouldPause()) { 52 | // 停顿,模拟用户的休息 53 | clearInterval(scrollInterval); 54 | setTimeout(() => { 55 | scrollScreen(callback, scrollCount,); // 重新启动滚动 56 | }, getRandomInt(500, 1500)); // 随机停顿时间 57 | } else if (scrollMode === 'endless') { 58 | // 无限滚动至底部模式 59 | if (!isAtBottom()) { 60 | scrollOnce(); // 执行一次滚动 61 | } else { 62 | // 到达底部,停止滚动 63 | clearInterval(scrollInterval); 64 | callback(); // 调用回调函数 65 | console.log('已经到达页面底部,停止滚动'); 66 | } 67 | } else if (scrollMode === 'limited') { 68 | // 滚动指定次数模式 69 | if (scrollCount < maxScrollCount && !isAtBottom()) { 70 | scrollOnce(); // 执行一次滚动 71 | scrollCount++; 72 | } else { 73 | // 如果到达底部或滚动次数已满,停止滚动 74 | clearInterval(scrollInterval); 75 | callback(); // 调用回调函数 76 | console.log(`已经滚动${scrollCount}次,停止滚动`); 77 | } 78 | } else if (scrollMode === 'none') { 79 | // 关闭滚动功能 80 | clearInterval(scrollInterval); 81 | console.log('自动滚动已关闭'); 82 | } 83 | }, getRandomInt(timeoutMin, timeoutMax)); // 随机滚动间隔 84 | }; 85 | 86 | // 等待页面完全加载后执行滚动 87 | window.addEventListener('load', () => { 88 | console.log('页面加载完成'); 89 | 90 | // 检查页面是否足够长,以便滚动 91 | if (document.body.scrollHeight > window.innerHeight && scrollMode !== 'none') { 92 | // 执行自动滚动 93 | scrollScreen(() => { 94 | console.log('滚动完成'); 95 | }); 96 | } else { 97 | console.log('页面没有足够的内容进行滚动,或者自动滚动已关闭'); 98 | } 99 | }); 100 | 101 | })(); 102 | -------------------------------------------------------------------------------- /static/赞助商_TikHub_Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoeanAmier/XHS-Downloader/600f2fb60c60e1ada583b0f45bf6ced716f029ef/static/赞助商_TikHub_Logo.png --------------------------------------------------------------------------------