Metadata-Version: 2.4
Name: zytools-fs
Version: 0.0.34
Summary: Personal Python utilities for FTP, video, and audio downloads
Author: zytools
License-Expression: MIT
Keywords: zytools,ftp,video,download
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE.txt
Requires-Dist: cryptography
Requires-Dist: gmssl
Requires-Dist: lmdb
Requires-Dist: loguru
Requires-Dist: lxml
Requires-Dist: openpyxl
Requires-Dist: paramiko
Requires-Dist: psutil
Requires-Dist: py7zr
Requires-Dist: PyYAML
Requires-Dist: rarfile
Requires-Dist: requests
Requires-Dist: tqdm
Requires-Dist: trafilatura
Dynamic: license-file

# zytools-fs

`zytools-fs` 是一个轻量的 Python 工具集合，包含 FTP/SFTP 文件传输、任务心跳、URL 持久化去重、文章抓取、哔哩哔哩视频下载和猫耳 FM 音频下载等功能。各模块均可直接在脚本中导入使用，不依赖额外框架。

## 文章爬虫（优先使用）

文章爬虫从入口 URL 按广度优先发现同域页面，只保存识别为正文的文章。图片、PDF、音视频、压缩包、JavaScript 和 CSS 等资源会被跳过。使用 `UrlFilter` 后，重复运行只会继续处理未保存的文章 URL。

```python
from zytools.artice import UrlCrawler
from zytools.utils import UrlFilter

START_URL = "https://www.hothk.com/"

with UrlFilter("article_urls.lmdb") as url_filter:
    crawler = UrlCrawler(
        start_url=START_URL,
        max_saved_urls=100,
        max_depth=3,
        same_domain=True,
        url_fp=url_filter,
    )

    for article in crawler.crawl():
        print(article["title"])
        print(article["url"])
        print(article["content"])

    crawler.save_url_filter()
```

单页测试：

```python
from zytools.artice import check_response

result = check_response("https://gongjyuhok.hk/a")
print(result["type"], result.get("title"), result.get("text_length"))
```

已验证可识别正文的站点包括：香港头条、香港24小时、HK Daily Post、港語學。默认 `same_domain=True`，不会抓取外部域名。

## 目录

- 命令行工具：[安装](#安装) · [解压工具](#解压工具) · [文件统计](#文件统计)
- 文件传输：[FTP/SFTP](#ftp-和-sftp-文件传输)
- 媒体下载：[哔哩哔哩](#哔哩哔哩视频下载) · [抖音](#抖音视频下载) · [猫耳 FM](#猫耳-fm-音频下载) · [小宇宙](#小宇宙播客下载) · [喜马拉雅](#喜马拉雅下载)
- 数据处理：[URL 去重](#url-去重) · [文章识别](#文章页面识别) · [URL 爬虫](#简单-url-爬虫)
- 运行工具：[任务心跳](#任务心跳) · [Clash 代理](#clash-代理切换)
- [许可证](#许可证)

## 功能

- [压缩包解压](#解压工具)：递归解压常见格式，保留目录层级并记录失败任务。
- [文件统计](#文件统计)：统计视频文件夹或 TXT、JSONL、JSON 文件并生成汇总名称。
- [FTP 和 SFTP](#ftp-和-sftp-文件传输)：目录递归上传下载，支持重试、进度、跳过和并发。
- [哔哩哔哩](#哔哩哔哩视频下载)与[抖音](#抖音视频下载)：下载视频并控制画质、代理和文件名。
- [猫耳 FM](#猫耳-fm-音频下载)与[小宇宙](#小宇宙播客下载)：下载并转换音频或播客。
- [喜马拉雅](#喜马拉雅下载)：下载公开单集或整张专辑，支持断点续传和清单记录。

所有媒体下载器均按 `zytools.video.<平台>.downloader` 组织，并在 `zytools.video` 导出统一的 `download_<平台>_video` 入口；旧的音频函数名继续兼容。

所有平台下载过程中统一使用 `完整文件名.原后缀.part`，例如 `视频.mp4.part`、`音频.wav.part`；下载及处理成功后才自动重命名回原后缀。

所有平台的 `filename` 都支持 `{Id}_{Title}_{Date}` 模板；合集或播客还支持 `{order}`。默认模板均为 `{Id}_{Title}_{Date}`。

统一命令格式：

```powershell
zytools video bilibili "链接" -o .
zytools video douyin "链接" -o .\downloads
zytools video maoer "https://www.missevan.com/sound/player?id=13407119" -o .
zytools video xiaoyuzhou "链接" -o .\downloads
zytools video ximalaya "链接" -o .\downloads
```
- [URL 去重](#url-去重)、[文章识别](#文章页面识别)和[URL 爬虫](#简单-url-爬虫)：处理抓取任务和持久化状态。
- [任务心跳](#任务心跳)与[Clash 代理](#clash-代理切换)：上报脚本状态并管理代理节点。

## Python 进程白名单

以下命令会立即在后台启动守护进程，保留 `name.py`，并持续结束其他以
`python xxx.py` 形式运行的 Python 脚本进程：

```powershell
zytools kill add name.py
```

可以同时指定多个白名单脚本，并调整扫描间隔：

```powershell
zytools kill add name.py worker.py --interval 2
```

也可以多次执行命令，白名单会追加到共享列表，后台只保持一个守护进程：

```powershell
zytools kill add name.py
zytools kill add name1.py
zytools kill add name2.py
```

白名单按脚本文件名匹配，不要求传入完整路径。交互式 Python 和
`python -m module` 进程不属于 `python xxx.py`，不会被结束。
省略 `--interval` 时默认每 10 秒扫描一次。

## 安装

```bash
pip install zytools-fs
```

要求 Python 3.9 或更高版本。

哔哩哔哩 DASH 音视频合并和猫耳音频格式转换依赖 `ffmpeg`，请先安装并确保命令已加入 `PATH`。未安装 `ffmpeg` 时，猫耳下载器无法生成最终音频文件。

安装后可运行以下命令检查：

```bash
zytools
```

预期输出：

```text
zytools installed successfully.
```

## 解压工具

解压常见压缩包或批量处理文件夹：

```bash
zytools unzip archive.zip
zytools unzip archive.zip -C ./output
zytools unzip ./archives
zytools unzip ./archives -C ./extracted --log ./records.xlsx
```

支持 ZIP、7Z、RAR、TAR、TAR.GZ、TGZ、TAR.BZ2、TBZ2、TAR.XZ、TXZ，以及单文件 GZ、BZ2、XZ。RAR 解压依赖系统中可用的 `unrar`、`unar`、`7zip` 或 `bsdtar` 后端。

传入文件夹时，程序会递归扫描全部受支持的压缩包，并在命令执行的当前目录生成 `zytools.xlsx`。程序会先把全部任务写入表格，再开始逐个解压。使用 `-C` 指定目标根目录后，输出会保持原文件夹相对层级。例如 `archives/a/data.zip` 会解压到 `extracted/a/data/`，不会把所有内容堆放到同一级。扫描、任务写入、开始解压、完成、跳过和失败都会立即输出状态，避免处理大文件时长时间没有反馈。

`zytools.xlsx` 包含压缩包路径、输出目录、状态（`ok`、`error` 或 `pending`）、尝试次数和错误信息。每个任务开始和结束时都会更新对应行。再次运行同一命令时，程序会合并当前扫描结果和表内任务：已完成且输出目录存在的任务跳过，失败或未完成任务按照表中的压缩包路径和输出目录再次解压。可用 `--log` 指定其他任务表路径。

省略 `-C` 时，程序会在每个压缩包旁边创建同名目录。旧版的第二位置参数仍兼容。解压器会拒绝绝对路径、目录穿越路径和符号链接。

也可以在 Python 中复用：

```python
from zytools.utils import extract_archive, extract_archive_folder

extract_archive("archive.zip", "./output")
extract_archive_folder("./archives", "./extracted")
```

## 文件统计

统计文件夹内的全部视频：

```bash
zytools count "C:/a/b/c"
zytools count "C:/a/b/c" --workers 8
zytools count "C:/a/b/c" --no-rename
zytools count "C:/data/rows.jsonl"
zytools count "C:/data/items.json"
zytools count "C:/data/lines.txt"
```

存储单位默认固定为 MB，也可以指定 GB 或 TB，并设置小数位：

```powershell
zytools count "C:/Videos" --unit GB --precision 3
```

默认保留 3 位小数；例如 1MB 固定为 GB 时显示为 `0.001GB`。

程序会递归识别 MP4、MKV、MOV、AVI、TS、M2TS、FLV、WEBM、WMV 等常见视频，通过 `ffprobe` 汇总数量、文件大小和时长。输出最后一行为：

```text
C:\a\b\c C:\a\b\c_20251222_2477条_109.86GB_2191.73H
```

存储单位默认固定为 MB；可用 `--unit GB` 或 `--unit TB` 指定单位，默认保留三位小数，使用 `--precision` 可调整。统计无错误时，命令默认把原文件夹重命名为汇总名称；使用 `--no-rename` 可以只返回名称而不修改目录。再次统计已带汇总后缀的目录时会替换旧后缀。目标目录已存在或视频读取失败时不会覆盖或重命名。时长统计需要安装 `ffprobe`，它随 `ffmpeg` 一起提供。

统计发现无法读取的视频时，会在源目录生成 `error.txt`，记录失败原因。默认只记录、不删除文件；增加 `--del` 后才删除失败视频、零字节文件和清理后形成的空文件夹。只要存在读取错误，源目录不会重命名。

```powershell
zytools count "C:/Videos" --del
```

传入 `.txt`、`.jsonl` 或 `.json` 文件时会自动切换为文本统计。TXT 按物理行数统计，JSONL 按非空记录行统计并验证每行 JSON，JSON 顶层数组按元素数统计；单个 JSON 对象按 1 条统计。文本汇总名称不包含小时数，例如：

```text
C:\data\rows.jsonl C:\data\rows_20251222_2477条_1.20GB.jsonl
```

文本统计成功后默认重命名文件，使用 `--no-rename` 时仅返回新名称。无效 JSON 或 JSONL 存在错误行时不会重命名。

Python 调用统一从 `zytools.utils` 导入：

```python
from zytools.utils import count_path, count_text_file, count_videos

result = count_path("C:/data/rows.jsonl", rename=False)
print(result["count"], result["summary"])
```

旧版的 `zytools.count` 和 `zytools.unzip` 模块路径继续兼容。

## FTP 和 SFTP 文件传输

`FTPClient` 和 `SFTPClient` 使用相同的上传下载接口。两者均支持单文件传输、目录递归传输、失败重试、传输进度、按大小跳过已有文件、并发传输和结果汇总。

### FTP 示例

```python
from zytools.utils import FTPClient

with FTPClient(
    host="127.0.0.1",
    user="user",
    password="password",
    port=21,
    passive=True,
    workers=4,
) as ftp:
    download_result = ftp.download("/remote/path", "./downloads")
    upload_result = ftp.upload("./reports", "/remote/reports")
```

### SFTP 示例

```python
from zytools.utils import SFTPClient

with SFTPClient(
    host="127.0.0.1",
    user="user",
    password="password",
    port=22,
    # key_filename="~/.ssh/id_rsa",
    # allow_unknown_host=True,  # 仅建议在可信的私有主机上使用
    workers=4,
) as sftp:
    download_result = sftp.download("/remote/path", "./downloads")
    upload_result = sftp.upload("./reports", "/remote/reports")
```

### 传输规则

- 文件路径只传输一个文件，目录路径会递归处理全部子目录和文件。
- 目标文件已存在且大小一致时会跳过，并计为成功。
- `show_progress=True` 时显示固定位置的进度条和完成日志；并发模式最多复用 `workers` 行进度显示。
- `download()` 和 `upload()` 均返回包含 `total`、`success`、`error` 的字典。
- 目录传输时设置 `workers>1` 可启用并发，每个工作线程使用独立连接。

返回值示例：

```python
{"total": 10, "success": 9, "error": 1}
```

常用参数：

- `port`：FTP 默认端口为 `21`，SFTP 默认端口为 `22`。
- `encoding`：FTP 文件名编码，默认为 `utf-8`。
- `passive`：是否使用 FTP 被动模式，默认为 `True`，仅 FTP 可用。
- `key_filename`：SFTP 使用的 SSH 私钥路径，支持 `~`。
- `allow_unknown_host`：是否允许未知的 SFTP 主机密钥，默认为 `False`。
- `download_retries`、`upload_retries`：下载和上传的尝试次数。
- `retry_wait_seconds`：两次重试之间的等待秒数。
- `workers`：目录上传下载的并发数，默认为 `1`。
- `show_progress`：是否显示进度条和完成日志。

## 哔哩哔哩视频下载

```python
from zytools.video import download_bili_video

ok = download_bili_video(
    "https://www.bilibili.com/video/BVxxxx",
    output_dir=".",
    quality="max",
    filename="{Id}_{Title}_{Date}",
    cookies={
        "SESSDATA": "your_sessdata",
        "bili_jct": "your_bili_jct",
    },
    proxies={
        "http": "http://127.0.0.1:7890",
        "https": "http://127.0.0.1:7890",
    },
)

print(ok)
```

参数说明：

- `quality`：`"max"` 选择可用的最高画质，`"min"` 选择最低画质。实际画质取决于账号权限和接口返回的 DASH 流。
- `cookies`：可选的 Cookie 字典，用于需要登录权限的内容。
- `proxies`：页面和 API 请求使用的 `requests` 格式代理字典；媒体流下载不使用该代理。

哔哩哔哩和抖音生成的视频文件名主体最多保留 20 个字符，不包含 `.mp4` 扩展名。较长的哔哩哔哩文件名会附加短哈希，避免分 P 或相似标题截断后发生重名。

B站下载中的最终文件使用 `文件名.mp4.part`，下载、合并并通过完整性校验后自动重命名为 `文件名.mp4`。中断或校验失败时不会生成正式 `.mp4`。

文件名模板支持以下字段：

- `{Id}`：视频 BV 号，默认模板使用该字段。
- `{Title}`：视频标题。
- `{Date}`：`YYYYMMDD` 格式的发布日期。

当链接对应合集（多个分集）时，在模板中加入小写 `{order}` 会下载全部分集，并按 `1`、`2`、`3` 编号；不加入 `{order}` 时只下载第 1 集。

请仅下载自己拥有或已获得授权的内容。

## 抖音视频下载

```python
from zytools.video import download_douyin_video

output_file = download_douyin_video(
    "https://www.douyin.com/video/7530000000000000000",
    output_dir="./downloads",
    filename="{Id}_{Title}_{Date}",
    cookies={
        "msToken": "your-ms-token",
    },
    proxies={
        "http": "http://127.0.0.1:7890",
        "https": "http://127.0.0.1:7890",
    },
)

print(output_file)
```

参数说明：

- `output_dir`：保存目录，默认为当前目录，父目录会自动创建。
- `filename`：可选文件名，可带或不带 `.mp4` 扩展名；省略时使用作品标题。清理非法字符后，文件名的标题部分最多保留 20 个字符。
- `cookies`：可选 Cookie 字典。传入有效的 `msToken` 可减少临时 Token 获取失败的影响。
- `proxies`：可选的 `requests` 格式代理字典，用于详情接口和视频下载。

函数成功后返回最终 MP4 文件的绝对路径；链接无效、详情获取失败或没有可用播放地址时抛出 `ValueError` 或 `RuntimeError`。

当 Web 详情接口未返回作品数据时，下载器会自动回退到抖音移动分享页，并将分享页播放地址转换为无水印地址，无需调用方额外处理。

请仅下载自己拥有或已获得授权的内容。

## 猫耳 FM 音频下载

```python
from zytools.video import download_maoer_video

output_file = download_maoer_video(
    "https://www.missevan.com/sound/player?id=13407119",
    output_dir=".",
    filename="{Id}_{Title}_{Date}",
    cookies={},
    proxies={
        "http": "http://127.0.0.1:7890",
        "https": "http://127.0.0.1:7890",
    },
)

print(output_file)
```

也支持广播剧合集：`https://www.missevan.com/mdrama/94476`。单集链接直接下载一个音频；合集未使用 `{order}` 时下载第 1 集，使用 `filename="{order}_{Id}_{Title}_{Date}"` 时下载全部分集。

`filename` 不写扩展名时自动保存为 `.m4a`，与其他平台一样可以直接使用统一模板。只有需要转换格式时才显式添加 `.wav`、`.mp3` 或 `.flac`。

代理只用于页面、播放列表和 DRM API 请求；音频分片下载会绕过传入的代理及环境代理。函数成功后返回最终音频文件的绝对路径。

请仅下载自己拥有或已获得授权的内容。

## 小宇宙播客下载

下载单集：

```python
from zytools.video import download_xiaoyuzhou_audio

output_file = download_xiaoyuzhou_audio(
    "https://www.xiaoyuzhoufm.com/episode/xxxxxxxx",
    output_dir="./downloads",
    filename="{Id}_{Title}_{Date}",
    cookies={},
    proxies={
        "http": "http://127.0.0.1:7890",
        "https": "http://127.0.0.1:7890",
    },
)

print(output_file)
```

下载整档播客：

```python
from zytools.video import download_xiaoyuzhou_audio

output_dir = download_xiaoyuzhou_audio(
    "https://www.xiaoyuzhoufm.com/podcast/xxxxxxxx",
    output_dir="./podcast",
)

print(output_dir)
```

所有节目默认转换为 WAV，因此需要提前安装 `ffmpeg` 并加入 `PATH`。单集下载成功后返回 WAV 文件的绝对路径；整档播客下载成功后返回保存目录的绝对路径，并在目录内生成 `episodes.json` 元数据文件。

参数说明：

- `output_dir`：保存目录。单集默认使用当前目录，整档播客默认使用播客标题创建目录。
- `filename`：单集文件名或整档播客目录名。
- `cookies`：可选 Cookie 字典。
- `proxies`：可选的 `requests` 格式代理字典，用于页面、RSS 和音频请求。

请仅下载自己拥有或已获得授权的内容。

## 喜马拉雅下载

支持公开的喜马拉雅单集和专辑链接，音频默认保留服务端格式（通常为 `.m4a`），文件名最多 20 个字符。

命令行：

```powershell
zytools video ximalaya "https://www.ximalaya.com/sound/123456789" -o .
zytools video ximalaya "https://www.ximalaya.com/album/123456789" -o .
```

Python：

```python
from zytools.video import download_ximalaya

result = download_ximalaya(
    "https://www.ximalaya.com/album/123456789",
    output_dir=".",
    filename="{Id}_{order}_{Title}_{Date}",
    cookies={},
    proxies=None,
)
print(result["output_dir"])
```

专辑链接默认只下载第 1 集；`filename` 中包含 `{order}` 时才会按顺序下载全部分集。下载过程会显示进度，并在专辑目录写入 `manifest.json`；已存在且校验有效的文件会自动跳过，临时文件支持断点续传。

## URL 去重

`UrlFilter` 在 LMDB 中保存 URL 的 MD5 指纹。它不是布隆过滤器，不会主动引入假阳性。

```python
from zytools.utils import UrlFilter

with UrlFilter(file_path="url_seen.lmdb") as url_filter:
    url = "https://example.com/video?id=1"

    if url_filter.add(url):
        print("首次出现")
    else:
        print("已经存在")

    print(len(url_filter))
```

批量添加、导出和导入：

```python
from zytools.utils import UrlFilter

with UrlFilter("url_seen.lmdb") as url_filter:
    added = url_filter.add_many([
        "https://example.com/a",
        "https://example.com/b",
    ])
    url_filter.to_csv("url_seen.csv")

print(f"新增 {added} 个 URL")

UrlFilter.to_lmdb("url_seen.csv", file_path="url_seen_copy.lmdb")
```

## 文章页面识别

使用 `check_response` 请求一个 URL，并将结果分类为文章、其他 HTML 页面、二进制资源或请求失败。

```python
from zytools.artice import check_response

result = check_response("https://example.com/news/1.html")

if result["type"] == "article":
    print(result["title"])
    print(result["date"])
    print(result["text"][:300])
else:
    print(result["type"], result.get("reason"))
```

`type` 可能为：

- `article`：文章页面，包含 `title`、`date`、`author` 和 `text`。
- `other`：不像文章的普通 HTML 页面。
- `binary`：图片、PDF、JavaScript、CSS、视频、压缩包等非 HTML 资源。
- `fetch_error`：请求失败或 HTTP 状态异常。

> 注意：当前公开模块名为 `zytools.artice`，请按上述拼写导入。

## 简单 URL 爬虫

`UrlCrawler` 从一个 URL 开始广度优先遍历链接，并逐条返回识别到的文章。可配合 `UrlFilter` 避免跨多次运行重复保存文章 URL。

```python
from zytools.artice import UrlCrawler
from zytools.utils import UrlFilter

with UrlFilter("article_urls.lmdb") as url_filter:
    crawler = UrlCrawler(
        start_url="https://example.com/",
        max_saved_urls=20,
        same_domain=True,
        max_depth=5,
        url_fp=url_filter,
    )

    for item in crawler.crawl():
        print(item["title"], item["url"])

    crawler.save_url_filter()
```

每条结果包含：

- `title`：文章标题。
- `creat_date`：文章发布日期（字段名保持现有接口拼写）。
- `content`：文章正文。
- `url`：最终文章 URL。
- `get_date`：当前抓取批次日期。

## 任务心跳

`update_task` 用于发送一次心跳；需要限制连续上报频率时可使用 `TaskUpdater`。

```python
from zytools.utils import TaskUpdater, update_task

result = update_task(
    name="daily job",
    machine_id="machine-1",
    script_path="/path/to/script.py",
    server="http://127.0.0.1:8001",
)

print(result)

task = TaskUpdater(
    name="daily job",
    machine_id="machine-1",
    script_path="/path/to/script.py",
    server="http://127.0.0.1:8001",
    min_interval=60,
)

task.update()
task.update(force=True)
```

服务端需要接收 `POST /tasks` 请求，请求体为 JSON，并包含 `name`、`machine_id`、`script_path`、`enabled` 和 `timeout_seconds` 字段。

## Clash 代理切换

`ClashVerge` 通过 Clash/Mihomo 外部控制器 API 查看代理组、检测节点延迟，并切换到指定或随机可用节点。控制器需要开启外部连接（默认地址为 `http://127.0.0.1:9090`）。

主要功能：

- `get_group()`：获取全部代理组名称。
- `get_group_nodes()`：递归展开嵌套代理组，返回去重后的实际节点名称。
- `get_activate()`：查看指定代理组当前选中的节点。
- `check_group_delay()`：批量检测代理组成员延迟，超时节点的延迟记为 `0`。
- `check_node_delay()`：检测单个节点的延迟。
- `switch_node()`：将 Selector 类型的代理组切换到指定节点。
- `switch_random_node()`：随机检测候选节点，并切换到第一个延迟检查通过的节点。
- `del_node()`：暂时排除不可用节点，到达 `node_delay_reset` 设置的时间后自动恢复候选资格。

请求失败、代理组不存在、节点不属于代理组或代理组不支持手动切换时，会抛出 `ClashAPIError`。

```python
from zytools.utils import ClashAPIError, ClashVerge

clash = ClashVerge(
    url="http://127.0.0.1:9090",
    secret="your-secret",
    default_group="主代理",
)

try:
    print(clash.get_group())
    print(clash.get_group_nodes())
    print(clash.get_activate())

    # 手动切换到指定节点
    clash.switch_node(proxy_name="香港节点")

    # 测试延迟并切换到随机可用节点
    result = clash.switch_random_node()
    print(result)  # {"group": "主代理", "node": "香港节点", "delay": 123}
except ClashAPIError as exc:
    print(f"Clash 操作失败：{exc}")
```

`switch_node` 也支持传入 `group_name` 和 `proxy_name`，`switch_random_node` 会自动跳过检测失败的节点，并在延迟一段时间后重新允许尝试。不要在不可信网络中暴露 Clash 控制器端口。

## 许可证

MIT
