分离出core和cli,更新文档,打包脚本

This commit is contained in:
保清 2026-02-08 17:34:55 +08:00
parent 18fcfc2c01
commit cb737c0d95
6 changed files with 1077 additions and 1525 deletions

View File

@ -1,86 +0,0 @@
// ==UserScript==
// @name 123云盘下载辅助
// @namespace https://github.com/Bao-qing/123pan
// @version 0.3
// @description 123 Cloud Drive Unlimited Flow
// @match https://www.123pan.com/*
// @match https://www.123pan.cn/*
// @match https://www.123865.com/*
// @match https://www.123684.com/*
// @grant none
// @author Qing
// ==/UserScript==
(function () {
// 重写 XMLHttpRequest
const originalXHR = window.XMLHttpRequest;
function newXHR() {
const realXHR = new originalXHR();
realXHR.open = function (method, url, async, user, password) {
this._url = url; // 记录请求的 URL
return originalXHR.prototype.open.apply(this, arguments);
};
realXHR.setRequestHeader = function (header, value) {
let headers = {
"user-agent": "123pan/v2.4.0(Android_7.1.2;Xiaomi)",
//"loginuuid": generateUUIDHex(),
"platform": "android",
"app-version": "61",
"x-app-version": "2.4.0"
}
// 如果header在列表中则修改
if (header.toLowerCase() in headers) {
value = headers[header.toLowerCase()];
} else {
console.log('header:', header);
}
return originalXHR.prototype.setRequestHeader.apply(this, arguments);
};
// 拦截响应内容,修改 DownloadUrl以适应网页端下载
realXHR.send = function () {
const xhrInstance = this;
this.addEventListener('readystatechange', function () {
let origin_url;
let new_url_no_redirect;
let base64data;
if (xhrInstance.readyState === 4 && xhrInstance.status === 200) {
// 解析响应的 JSON
let responseText = xhrInstance.responseText;
let responseJSON = JSON.parse(responseText);
console.log('Original Response:', responseJSON);
// 修改 DownloadUrl
if (responseJSON.data && responseJSON.data.DownloadUrl) {
origin_url = responseJSON.data.DownloadUrl;
new_url_no_redirect = origin_url + "&auto_redirect=0";
base64data = btoa(new_url_no_redirect);
responseJSON.data.DownloadUrl = "https://web-pro2.123952.com/download-v2/?params=" + base64data + "&is_s3=0";
console.log('Modified DownloadUrl:', responseJSON.data.DownloadUrl);
}
// 将修改后的 JSON 转为字符串
let modifiedResponseText = JSON.stringify(responseJSON);
// 使用 defineProperty 重写 responseText
Object.defineProperty(xhrInstance, 'responseText', {
get: function () {
return modifiedResponseText;
}
});
console.log('Modified Response:', modifiedResponseText);
}
});
return originalXHR.prototype.send.apply(this, arguments);
};
return realXHR;
}
window.XMLHttpRequest = newXHR;
})();

1031
123pan.py

File diff suppressed because it is too large Load Diff

371
README.md
View File

@ -1,18 +1,43 @@
# 123Pan 下载工具 # 1、123Pan Cli工具
123 网盘命令行工具,支持列出文件、下载、上传、分享、删除、创建目录及回收站管理。Android 客户端或 Web 协议,便于在本地批量管理与下载文件。 123 网盘命令行工具,支持列出文件、下载、上传、分享、删除、创建目录及回收站管理。
安卓客户端协议不受下载流量限制(推荐使用)。 <!-- TOC -->
* [1、123Pan Cli工具](#1123pan-cli工具)
* [1.1 特性](#11-特性)
* [1.2 脚本环境要求](#12-脚本环境要求)
* [1.3 安装与运行](#13-安装与运行)
* [1.3.1 脚本运行](#131-脚本运行)
* [1.3.2 下载release版](#132-下载release版)
* [1.4 配置文件JSON](#14-配置文件json)
* [1.5 常用命令(交互式)](#15-常用命令交互式)
* [2、123Pan接口模块pan123_core.py](#2123pan接口模块pan123_corepy)
* [2.1 核心类:`Pan123Core`](#21-核心类pan123core)
* [2.1.1 属性说明](#211-属性说明)
* [2.1.2 方法清单](#212-方法清单)
* [2.1.2.1 1登录操作](#2121-1登录操作)
* [2.1.2.2 2配置管理](#2122-2配置管理)
* [2.1.2.3 3目录操作](#2123-3目录操作)
* [2.1.2.4 4文件操作](#2124-4文件操作)
* [2.1.2.5 5用户信息](#2125-5用户信息)
* [2.2 工具类:`Pan123Tool`](#22-工具类pan123tool)
* [2.2.1 属性说明](#221-属性说明)
* [2.2.2 方法清单](#222-方法清单)
* [2.2.2.1 1配置管理](#2221-1配置管理)
* [2.2.2.2 2文件下载](#2222-2文件下载)
* [2.2.2.3 3文件上传](#2223-3文件上传)
* [2.3 全局配置参数](#23-全局配置参数)
* [2.3.1 协议相关](#231-协议相关)
* [2.3.2 设备伪装](#232-设备伪装)
* [2.4 错误码说明](#24-错误码说明)
* [2.5 典型使用示例](#25-典型使用示例)
* [3、下载说明](#3下载说明)
* [4、注意事项](#4注意事项)
* [5、免责声明](#5免责声明)
<!-- TOC -->
123download.js 是网页端下载油猴脚本最初版本,仍然可以使用,仅保留最基本的解锁下载功能,不再更新。 ## 1.1 特性
可以参考其他项目:
[123云盘解锁 (@QingJ)](https://greasyfork.org/zh-CN/scripts/519353-123%E4%BA%91%E7%9B%98%E8%A7%A3%E9%94%81)
[123 云盘会员青春版 (@hmjz100)](https://greasyfork.org/zh-CN/scripts/513528-123-%E4%BA%91%E7%9B%98%E4%BC%9A%E5%91%98%E9%9D%92%E6%98%A5%E7%89%88)
## 特性
- 登录 / 登出 - 登录 / 登出
- 列出当前目录文件ls - 列出当前目录文件ls
- 切换目录cd与刷新refresh / re - 切换目录cd与刷新refresh / re
@ -26,27 +51,35 @@
- 协议切换protocol android|web - 协议切换protocol android|web
- 支持保存配置到 JSON 文件authorization、device/os、protocol 等) - 支持保存配置到 JSON 文件authorization、device/os、protocol 等)
## 脚本环境要求 ## 1.2 脚本环境要求
- Python 3.7+
- Python 3
- 依赖库requests - 依赖库requests
安装: 安装:
```bash ```bash
pip install requests pip install requests
``` ```
## 安装与运行 ## 1.3 安装与运行
### 脚本运行
### 1.3.1 脚本运行
1. 克隆或下载本仓库到本地。 1. 克隆或下载本仓库到本地。
2. 进入项目目录。 2. 进入项目目录。
3. 运行脚本: 3. 运行脚本:
```bash ```bash
python 123pan.py python pan123_cli.py
``` ```
启动后会提示输入用户名 / 密码,或自动读取配置文件(默认 `123pan_config.json``123pan.txt`,脚本内部根据传入参数使用该文件)。 启动后会提示输入用户名 / 密码,或自动读取配置文件(默认 `123pan_config.json`,脚本内部根据传入参数使用该文件)。
### 下载release版
根据系统下载对应的 release 版本,解压后运行 `123pan.exe`Windows`123pan`Linux ### 1.3.2 下载release版
## 配置文件JSON
根据系统下载对应的 release 版本,解压后运行 `123pan.exe`Windows`123pan`Linux
## 1.4 配置文件JSON
脚本会读取并保存一个配置文件(示例 `123pan_config.json`),保存登录状态与偏好,格式示例: 脚本会读取并保存一个配置文件(示例 `123pan_config.json`),保存登录状态与偏好,格式示例:
```json ```json
{ {
"userName": "your_username", "userName": "your_username",
@ -57,72 +90,37 @@
"protocol": "android" "protocol": "android"
} }
``` ```
注意:保存密码或 token 到本地会有安全风险,请在可信环境下使用并妥善保护该文件。 注意:保存密码或 token 到本地会有安全风险,请在可信环境下使用并妥善保护该文件。
## 常用命令(交互式) ## 1.5 常用命令(交互式)
在脚本交互提示符中输入命令,部分带参数: 在脚本交互提示符中输入命令,部分带参数:
- 直接输入编号 | 指令 | 用法示例 | 功能说明 |
- 若编号对应文件夹 → 进入该文件夹 |-----------------------------|----------------------------------------|----------------------------------|
- 若编号对应文件 → 直接下载该文件 | 直接输入编号 | `3` | 若编号对应文件夹 → 进入该文件夹;若为文件 → 直接下载该文件 |
| ls | `ls` | 显示当前目录的文件与文件夹列表 |
- ls | cd [编号&#124;..&#124;/] | `cd 3`、`cd ..`、`cd /` | 切换目录:进入指定编号的文件夹、返回上级、返回根目录 |
- 显示当前目录的文件与文件夹列表。 | mkdir [名称] | `mkdir test` | 在当前目录创建文件夹 |
| upload [路径] | `upload C:\Users\you\Desktop\file.txt` | 上传文件到当前目录(仅支持单个文件) |
- cd [编号|..|/] | rm [编号] | `rm 2` | 删除当前列表中指定编号的文件/文件夹(移入回收站) |
- 切换目录: | share [编号 ...] | `share 2 4` | 为指定文件创建一个或多个分享链接,可设置提取码(可为空) |
- cd 3 —— 进入当前列表第3项如果是文件夹 | link [编号] | `link 3` | 获取指定文件的直链地址 |
- cd .. —— 返回上级。 | download / d [编号] | `download 5``d 5` | 下载指定编号的文件或文件夹(文件夹将递归下载) |
- cd / —— 返回根目录。 | recycle | `recycle` | 查看回收站内容,可恢复指定编号项或输入 clear 清空回收站 |
| refresh / re | `refresh``re` | 刷新当前目录列表 |
- mkdir [名称] | reload | `reload` | 重新加载配置文件并刷新目录 |
- 在当前目录创建文件夹。例如mkdir test | login / logout | `login`、`logout` | 手动登录或登出(清除授权信息) |
| clearaccount | clearaccount | 清除已登录账号(包括用户名和密码) |
- upload [路径] | more | `more` | 当目录分页未加载完时,继续加载更多内容 |
- 上传文件到当前目录。例如upload C:\Users\you\Desktop\file.txt | protocol [android&#124;web] | `protocol web` | 切换通信协议(如 android/web并可选择保存配置 |
- 仅支持文件,暂不支持目录批量上传。 | exit | `exit` | 退出程序 |
- rm [编号]
- 删除当前列表中的文件/文件夹会移动到回收站。例如rm 2
- share [编号 ...]
- 为一个或多个文件创建分享链接例如share 2 4
- 程序会提示输入提取码(可留空)。
- link [编号]
- 获取文件直链。例如link 3
- download / d [编号]
- 下载指定编号的文件或文件夹(如果是文件夹,会递归下载)。
- 例如download 5
- recycle
- 显示回收站内容,并可恢复或清空。
- 可输入编号恢复,或输入 clear 清空回收站。
- refresh / re
- 刷新当前目录列表。
- reload
- 重新加载配置文件并刷新目录。
- login / logout
- login手动登录使用当前配置或提示输入
- logout登出并清除授权信息保存配置时会写入空 token
- more
- 如果当前目录分页未加载完,输入 more 继续加载更多文件。
- protocol [android|web]
- 切换协议。例protocol web
- 切换后会重新初始化请求头,并可选择保存到配置文件。
- exit
- 退出程序。
--- ---
交互示例: 交互示例:
``` ```
/> cd demo /> cd demo
无效命令,使用 '..' 返回上级,'/' 返回根目录,或输入文件夹编号 无效命令,使用 '..' 返回上级,'/' 返回根目录,或输入文件夹编号
@ -152,15 +150,226 @@
/demo1/test> /demo1/test>
``` ```
## 下载说明 # 2、123Pan接口模块pan123_core.py
以下是基于代码实现的 **123pan 网盘 API**,按类结构分类说明:
---
### 2.1 核心类:`Pan123Core`
负责与 123pan 服务器的通信,管理认证状态、目录浏览、文件操作等核心逻辑。
#### 2.1.1 属性说明
| 属性名 | 类型 | 描述 |
|-----------------|------------|-----------------------------|
| `user_name` | str | 登录账号(手机号/用户名) |
| `password` | str | 登录密码 |
| `authorization` | str | 认证 Token登录后自动填充 |
| `protocol` | str | 请求协议(`"android"` 或 `"web"` |
| `cwd_id` | int | 当前工作目录 ID0 表示根目录) |
| `file_list` | List[dict] | 当前目录文件列表 |
| `nick_name` | str | 当前用户昵称 |
| `uid` | int | 当前用户 UID |
---
#### 2.1.2 方法清单
##### 2.1.2.1 1登录操作
| 方法名 | 参数说明 | 返回值类型 | 功能描述 |
|-------------------|------|--------|----------------------------|
| `login()` | 无 | Result | 使用 `user_name/password` 登录 |
| `logout()` | 无 | Result | 登出并清除 Token |
| `check_login()` | 无 | Result | 检查当前 Token 是否有效 |
| `clear_account()` | 无 | Result | 清除账号信息(不保存配置) |
##### 2.1.2.2 2配置管理
| 方法名 | 参数说明 | 返回值类型 | 功能描述 |
|--------------------------|-----------------------------------|--------|----------------------|
| `load_config(cfg)` | `cfg`: 包含账号信息的字典(见下方配置参数) | Result | 加载配置并更新实例状态 |
| `get_current_config()` | 无 | dict | 获取当前配置账号、Token、协议等 |
| `set_protocol(protocol)` | `protocol`: `"android"``"web"` | Result | 切换请求协议 |
##### 2.1.2.3 3目录操作
| 方法名 | 参数说明 | 返回值类型 | 功能描述 |
|-----------------------------------------------|---------------------------------------------------------|--------|--------------|
| `list_dir(parent_id=None, page=1, limit=100)` | `parent_id`: 父目录 ID<br>`page`: 页码<br>`limit`: 单页数量 | Result | 获取单页文件列表 |
| `list_dir_all(parent_id=None, limit=100)` | 同上 | Result | 获取全部文件(自动翻页) |
| `mkdir(name)` | `name`: 目录名 | Result | 在当前目录创建子目录 |
| `cd(folder_index)` | `folder_index`: `file_list` 中的目标文件夹下标 | Result | 进入目标文件夹 |
| `cd_up()` | 无 | Result | 返回上级目录 |
| `cd_root()` | 无 | Result | 返回根目录 |
| `trash(file_data, delete=True)` | `file_data`: 文件信息字典<br>`delete`: 是否删除True=删除False=恢复) | Result | 删除或恢复文件 |
| `list_recycle()` | 无 | Result | 获取回收站文件列表 |
##### 2.1.2.4 4文件操作
| 方法名 | 参数说明 | 返回值类型 | 功能描述 |
|-------------------------------------------------------------------------|---------------------------------------------------------------------------------|--------|-----------------|
| `upload_file(file_path, duplicate=0, on_progress=None)` | `file_path`: 本地文件路径<br>`duplicate`: 冲突策略0=报错1=覆盖2=保留)<br>`on_progress`: 进度回调 | Result | 上传文件(支持秒传和分块上传) |
| `get_download_url(index)` | `index`: `file_list` 中的目标文件下标 | Result | 获取文件直链(自动处理重定向) |
| `share(file_ids, share_pwd="", expiration="2099-12-12T08:00:00+08:00")` | `file_ids`: 文件 ID 列表<br>`share_pwd`: 提取码<br>`expiration`: 过期时间 | Result | 创建分享链接 |
##### 2.1.2.5 5用户信息
| 方法名 | 参数说明 | 返回值类型 | 功能描述 |
|-------------------|------|--------|------------------------|
| `get_user_info()` | 无 | Result | 获取当前用户信息UID、昵称、空间用量等 |
---
### 2.2 工具类:`Pan123Tool`
基于 `Pan123Core` 提供文件交互功能(依赖文件系统操作)。
#### 2.2.1 属性说明
| 属性名 | 类型 | 描述 |
|---------------|------------|-----------------------------------|
| `core` | Pan123Core | 关联的核心实例 |
| `config_file` | str | 配置文件路径(默认 `"123pan_config.json"` |
---
#### 2.2.2 方法清单
##### 2.2.2.1 1配置管理
| 方法名 | 参数说明 | 返回值类型 | 功能描述 |
|---------------------------|------|--------|------------|
| `load_config_from_file()` | 无 | Result | 从文件加载配置 |
| `save_config_to_file()` | 无 | Result | 将当前配置保存到文件 |
##### 2.2.2.2 2文件下载
| 方法名 | 参数说明 | 返回值类型 | 功能描述 |
|--------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|--------|--------|
| `download_file(index, save_dir="download", on_progress=None, overwrite=False, skip_existing=False)` | `index`: 文件列表下标<br>`save_dir`: 保存路径<br>`on_progress`: 进度回调<br>`overwrite`: 是否覆盖<br>`skip_existing`: 是否跳过已存在文件 | Result | 下载单个文件 |
| `download_directory(directory, save_dir="download", on_progress=None, overwrite=False, skip_existing=False)` | `directory`: 目录信息字典<br>其他参数同上 | Result | 递归下载目录 |
##### 2.2.2.3 3文件上传
| 方法名 | 参数说明 | 返回值类型 | 功能描述 |
|---------------------------------------------------------|----------------------------|--------|-------------------|
| `upload_file(file_path, duplicate=0, on_progress=None)` | 同 `Pan123Core.upload_file` | Result | 上传文件(与 Core 方法一致) |
---
### 2.3 全局配置参数
#### 2.3.1 协议相关
| 参数名 | 默认值 | 描述 |
|-----------------------|----------------------------|-----------------|
| `API_BASE_URL` | `"https://www.123pan.com"` | API 根地址 |
| `TIMEOUT_DEFAULT` | `15` | 默认请求超时时间(秒) |
| `UPLOAD_CHUNK_SIZE` | `5*1024*1024` | 分块上传单块大小5MB |
| `DOWNLOAD_CHUNK_SIZE` | `8192` | 下载流式读取单块大小8KB |
#### 2.3.2 设备伪装
| 参数名 | 默认值 | 描述 |
|----------------|-----|-------------------|
| `DEVICE_TYPES` | 见代码 | 可选 Android 设备型号列表 |
| `OS_VERSIONS` | 见代码 | 可选 Android 系统版本列表 |
---
### 2.4 错误码说明
| 错误码 | 含义 | 可能触发场景 |
|------|--------|----------------------------|
| 0 | 操作成功 | 所有接口成功时返回 |
| -1 | 网络请求失败 | 连接超时、SSL 错误等 |
| 5060 | 文件名冲突 | 上传时 `duplicate=0` 且目标文件已存在 |
| 1 | 本地文件冲突 | 下载时目标文件已存在 |
---
### 2.5 典型使用示例
```python
import json
from pan123_core import Pan123Core, Pan123Tool, Pan123EventType, format_size
# 初始化核心对象Android 协议)
core = Pan123Core(
user_name="13800138000",
password="your_password",
protocol=Pan123Core.PROTOCOL_ANDROID
)
# 登录
result = core.login()
if result["code"] != 0:
raise Exception("登录失败")
# 创建工具类实例
tool = Pan123Tool(core)
# 下载文件
def _download_progress(data) -> None:
if data.get("type") == Pan123EventType.DOWNLOAD_PROGRESS:
downloaded = data.get("downloaded", 0)
total = data.get("total", 0)
speed = data.get("speed", 0)
if total > 0:
pct = downloaded / total * 100
print(
f"\r进度: {pct:.1f}% | {format_size(downloaded)}/{format_size(total)} | {format_size(int(speed))}/s",
end=" ",
flush=True,
)
elif data.get("type") == Pan123EventType.DOWNLOAD_START_FILE:
print(f"开始下载: {data.get('file_name', '未知文件')} ({format_size(data.get('file_size', 0))})")
elif data.get("type") == Pan123EventType.DOWNLOAD_START_DIRECTORY:
print(f"开始下载目录: {data.get('dir_name', '未知目录')}")
else:
print(json.dumps(data, indent=2))
result = tool.download_file(
index=0,
save_dir="downloads",
on_progress=lambda e: print(f"下载进度: {e['percent']:.2f}%")
)
# 上传文件
result = core.upload_file(
file_path="local_file.txt",
duplicate=1, # 覆盖已有文件
on_progress=None # ...
)
# 创建分享链接
result = core.share(
file_ids=[12345],
share_pwd="123456",
expiration="2026-12-31T23:59:59+08:00"
)
```
---
# 3、下载说明
- 下载到脚本所在目录的 `download` 文件夹,下载过程中使用临时后缀 `.123pan`,下载完成后会重命名为原文件名。 - 下载到脚本所在目录的 `download` 文件夹,下载过程中使用临时后缀 `.123pan`,下载完成后会重命名为原文件名。
- 如果文件已存在,会提示覆盖 / 跳过 / 全部覆盖 / 全部跳过等选项。 - 如果文件已存在,会提示覆盖 / 跳过 / 全部覆盖 / 全部跳过等选项。
## 注意事项 # 4、注意事项
- 本工具用于学习与自用场景,请勿用于违法用途。对任何滥用造成的后果,本人概不负责。 - 本工具用于学习与自用场景,请勿用于违法用途。对任何滥用造成的后果,本人概不负责。
- 模拟客户端协议可能存在账号或服务端策略风险,请谨慎使用。 - 模拟客户端协议可能存在账号或服务端策略风险,请谨慎使用。
- 建议不要在公用或不受信任的机器上保存明文密码或授权信息。 - 建议不要在公用或不受信任的机器上保存明文密码或授权信息。
## 免责声明 # 5、免责声明
本工具用于学习场景,请勿用于违法用途。对任何滥用造成的后果,作者概不负责。 本工具用于学习场景,请勿用于违法用途。对任何滥用造成的后果,作者概不负责。
任何未经允许的api调用都是不被官方允许的对于因此产生的账号风险、数据损失等后果自负。

View File

@ -1 +1 @@
pyinstaller -F 123pan.py --icon favicon.ico --clean --noconfirm pyinstaller -F pan123_cli.py --icon favicon.ico --clean --noconfirm

View File

@ -2,11 +2,12 @@
123pan 控制台交互界面 仅负责用户 IO所有业务调用 Pan123Core 123pan 控制台交互界面 仅负责用户 IO所有业务调用 Pan123Core
""" """
import json
import os import os
import sys import sys
from typing import List from typing import Dict
from pan123_core import Pan123Core, format_size, make_result from pan123_core import Pan123Core, Pan123Tool, Pan123EventType, format_size
# ──────────────── 颜色工具 ──────────────── # ──────────────── 颜色工具 ────────────────
@ -50,7 +51,9 @@ class Pan123CLI:
exit - 退出程序""" exit - 退出程序"""
def __init__(self, config_file: str = "123pan_config.json"): def __init__(self, config_file: str = "123pan_config.json"):
self.core = Pan123Core(config_file=config_file) self.config_file: str = config_file
self.core = Pan123Core()
self.tool = Pan123Tool(self.core)
self._download_mode: int = 0 # 0=询问, 3=全部覆盖, 4=全部跳过 self._download_mode: int = 0 # 0=询问, 3=全部覆盖, 4=全部跳过
# ──────────────── 启动 ──────────────── # ──────────────── 启动 ────────────────
@ -62,11 +65,36 @@ class Pan123CLI:
os.system("") os.system("")
self._print_banner() self._print_banner()
self._init_login() if not self._init_login():
print(colored("无法登录", Color.RED))
a = input("输入1重新输入账号和密码输入2清除登录信息其他键退出: ")
if a == "1":
user_name = input("请输入用户名: ")
password = input("请输入密码: ")
if not user_name or not password:
print("用户名和密码不能为空,程序退出")
return
self.core.load_config({
"userName": user_name,
"passWord": password,
"authorization": ""
})
self.save_config()
return self.run()
if a == "2":
self._do_clear_account()
return self.run()
return
self.save_config()
self.core.refresh() # 加载文件列表
self.core.get_user_info()
self._show_files()
while True: while True:
try: try:
prompt = colored(f"{self.core.cwd_path}>", Color.RED) + " " prompt = colored(f"{self.core.cwd_path}>", Color.RED) + " "
print(colored(f'用户:{self.core.nick_name}', Color.GREEN))
command = input(prompt).strip() command = input(prompt).strip()
if not command: if not command:
continue continue
@ -82,26 +110,39 @@ class Pan123CLI:
def _print_banner(self) -> None: def _print_banner(self) -> None:
print("=" * 60) print("=" * 60)
print("123网盘客户端".center(56)) print("123网盘CLI客户端".center(56))
print("=" * 60) print("=" * 60)
def _init_login(self) -> None: def _init_login(self) -> bool:
"""尝试加载配置 -> 尝试访问目录 -> 必要时登录""" """尝试加载配置 -> 尝试访问目录 -> 必要时登录"""
self.core.load_config() res = self.load_config()
r = self.core.init_login_state()
if r["code"] < 0:
print(colored("登录失败", Color.YELLOW))
print(r["message"])
return False
return True
r = self.core.refresh() def load_config(self) -> Dict:
if r["code"] != 0: """加载配置"""
# 需要登录 try:
if not self.core.user_name: with open(self.config_file, "r", encoding="utf-8") as f:
self.core.user_name = input("请输入用户名: ") cfg = json.load(f)
if not self.core.password: except FileNotFoundError:
self.core.password = input("请输入密码: ") user_name = input("请输入用户名: ")
lr = self.core.login() password = input("请输入密码: ")
self._print_result(lr) cfg = {
if lr["code"] == 0: "userName": user_name,
self.core.refresh() "passWord": password,
"authorization": ""
}
return self.core.load_config(cfg)
self._show_files() def save_config(self) -> None:
"""保存配置"""
cfg = self.core.get_current_config()
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
# ──────────────── 命令分发 ──────────────── # ──────────────── 命令分发 ────────────────
@ -185,6 +226,7 @@ class Pan123CLI:
def _do_logout(self) -> None: def _do_logout(self) -> None:
r = self.core.logout() r = self.core.logout()
self.save_config()
self._print_result(r) self._print_result(r)
def _do_clear_account(self) -> None: def _do_clear_account(self) -> None:
@ -192,6 +234,7 @@ class Pan123CLI:
confirm = input("确定要清除已登录账号信息吗?(y/N): ").strip().lower() confirm = input("确定要清除已登录账号信息吗?(y/N): ").strip().lower()
if confirm == 'y': if confirm == 'y':
r = self.core.clear_account() r = self.core.clear_account()
self.save_config()
self._print_result(r) self._print_result(r)
else: else:
print("操作已取消") print("操作已取消")
@ -275,7 +318,7 @@ class Pan123CLI:
return return
r = self.core.get_download_url(int(arg) - 1) r = self.core.get_download_url(int(arg) - 1)
if r["code"] == 0: if r["code"] == 0:
print(f"文件直链: {r['data']['url']}") print(f"文件直链: \n{r['data']['url']}")
else: else:
self._print_result(r) self._print_result(r)
@ -292,7 +335,7 @@ class Pan123CLI:
overwrite = self._download_mode == 3 overwrite = self._download_mode == 3
skip = self._download_mode == 4 skip = self._download_mode == 4
r = self.core.download_file( r = self.tool.download_file(
idx, idx,
on_progress=self._download_progress, on_progress=self._download_progress,
overwrite=overwrite, overwrite=overwrite,
@ -311,7 +354,7 @@ class Pan123CLI:
return return
elif choice == "3": elif choice == "3":
self._download_mode = 3 self._download_mode = 3
r = self.core.download_file(idx, on_progress=self._download_progress, overwrite=True) r = self.tool.download_file(idx, on_progress=self._download_progress, overwrite=True)
print() # 换行 print() # 换行
self._print_result(r) self._print_result(r)
@ -378,7 +421,11 @@ class Pan123CLI:
# ──────────────── 进度回调 ──────────────── # ──────────────── 进度回调 ────────────────
@staticmethod @staticmethod
def _download_progress(downloaded: int, total: int, speed: float) -> None: def _download_progress(data) -> None:
if data.get("type") == Pan123EventType.DOWNLOAD_PROGRESS:
downloaded = data.get("downloaded", 0)
total = data.get("total", 0)
speed = data.get("speed", 0)
if total > 0: if total > 0:
pct = downloaded / total * 100 pct = downloaded / total * 100
print( print(
@ -386,9 +433,17 @@ class Pan123CLI:
end=" ", end=" ",
flush=True, flush=True,
) )
elif data.get("type") == Pan123EventType.DOWNLOAD_START_FILE:
print(f"开始下载: {data.get('file_name', '未知文件')} ({format_size(data.get('file_size', 0))})")
elif data.get("type") == Pan123EventType.DOWNLOAD_START_DIRECTORY:
print(f"开始下载目录: {data.get('dir_name', '未知目录')}")
else:
print(json.dumps(data, indent=2))
@staticmethod @staticmethod
def _upload_progress(uploaded: int, total: int) -> None: def _upload_progress(data) -> None:
uploaded = data.get("uploaded", 0)
total = data.get("total", 0)
if total > 0: if total > 0:
pct = uploaded / total * 100 pct = uploaded / total * 100
print(f"\r上传进度: {pct:.1f}%", end="", flush=True) print(f"\r上传进度: {pct:.1f}%", end="", flush=True)

View File

@ -1,12 +1,9 @@
""" """
123pan 网盘内核模块 纯业务逻辑无任何 IOprint / input 123pan 网盘内核模块
可直接移植到 GUI / Web / API 等上层应用
所有公开方法统一返回 Result 字典:: 所有公开方法统一返回 Result 字典::
{ {
"code": int, # 0 = 成功,非 0 = 失败(含 API 原始错误码) "code": int, # 0 = 成功,小于 0 = 失败 大于 0 = 警告
"message": str, # 人类可读的结果描述 "message": str, # 结果描述
"data": Any # 业务数据,失败时为 None "data": Any # 业务数据,失败时为 None
} }
""" """
@ -18,11 +15,11 @@ import random
import re import re
import time import time
import uuid import uuid
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
import requests import requests
# ════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════
# 全局常量 —— URL / 端点 / 超时 / 分块 / 设备信息 # 全局常量 —— URL / 端点 / 超时 / 分块 / 设备信息
# ════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════
@ -48,7 +45,7 @@ URL_DOWNLOAD_INFO = "/a/api/file/download_info"
"""单文件下载信息接口""" """单文件下载信息接口"""
URL_BATCH_DOWNLOAD = "/a/api/file/batch_download_info" URL_BATCH_DOWNLOAD = "/a/api/file/batch_download_info"
"""批量(文件夹)下载信息接口""" """批量(文件夹)下载信息接口 服务端会进行打包下载"""
URL_UPLOAD_REQUEST = "/b/api/file/upload_request" URL_UPLOAD_REQUEST = "/b/api/file/upload_request"
"""上传请求接口(含创建目录)""" """上传请求接口(含创建目录)"""
@ -65,6 +62,12 @@ URL_UPLOAD_COMPLETE = "/b/api/file/upload_complete"
URL_MKDIR = "/a/api/file/upload_request" URL_MKDIR = "/a/api/file/upload_request"
"""创建目录接口(复用 upload_requesttype=1""" """创建目录接口(复用 upload_requesttype=1"""
URL_USER_INFO = "/b/api/user/info"
"""获取用户信息接口"""
URL_DETAILS = "/b/api/restful/goapi/v1/file/details"
"""获取文件夹详情接口"""
SHARE_URL_TEMPLATE = "{base}/s/{key}" SHARE_URL_TEMPLATE = "{base}/s/{key}"
"""分享链接模板,{base} = API_BASE_URL{key} = ShareKey""" """分享链接模板,{base} = API_BASE_URL{key} = ShareKey"""
@ -155,6 +158,15 @@ WEB_USER_AGENT = (
WEB_APP_VERSION = "3" WEB_APP_VERSION = "3"
# ─── 事件类型 ────────────────────────────────────────────────
@dataclass
class Pan123EventType:
DOWNLOAD_START_FILE = "download_start_file"
DOWNLOAD_START_DIRECTORY = "download_start_directory"
DOWNLOAD_PROGRESS: str = "download_progress"
UPLOAD_PROGRESS: str = "upload_progress"
# ════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════
# 工具函数 # 工具函数
# ════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════
@ -163,7 +175,7 @@ def make_result(code: int = CODE_OK, message: str = "ok", data: Any = None) -> D
"""构造统一返回结构。 """构造统一返回结构。
Args: Args:
code: 状态码0 表示成功0 <EFBFBD><EFBFBD>失败 code: 状态码0 表示成功小于 0 表示失败大于 0 表示成功但有警告信息
message: 人类可读的结果描述 message: 人类可读的结果描述
data: 业务数据失败时通常为 None data: 业务数据失败时通常为 None
@ -226,9 +238,8 @@ ProgressCallback = Optional[Callable[..., None]]
class Pan123Core: class Pan123Core:
"""123 网盘内核类。 """123 网盘内核类。
提供登录目录浏览上传下载分享删除回收站等纯逻辑接口 提供登录目录浏览上传下载链接分享删除回收站等纯逻辑接口
不做任何 print / input所有结果通过 ``make_result`` 统一返回 所有结果通过 ``make_result`` 统一返回
方便上层CLI / GUI / Web自行处理展示与交互
Attributes: Attributes:
user_name (str): 登录用户名 / 手机号 user_name (str): 登录用户名 / 手机号
@ -236,8 +247,8 @@ class Pan123Core:
authorization (str): Bearer Token登录后自动填充 authorization (str): Bearer Token登录后自动填充
protocol (str): 请求协议"android" "web" protocol (str): 请求协议"android" "web"
config_file (str): 配置文件路径 config_file (str): 配置文件路径
device_type (str): Android 设备型号 device_type (str): Android 设备型号 留空则随机选取 DEVICE_TYPES 中的一个
os_version (str): Android 系统版本 os_version (str): Android 系统版本 留空则随机选取 OS_VERSIONS 中的一个
cwd_id (int): 当前工作目录 FileId0 = 根目录 cwd_id (int): 当前工作目录 FileId0 = 根目录
cwd_stack (List[int]): 目录 ID 导航栈 cwd_stack (List[int]): 目录 ID 导航栈
cwd_name_stack (List[str]): 目录名称导航栈 cwd_name_stack (List[str]): 目录名称导航栈
@ -246,6 +257,13 @@ class Pan123Core:
all_loaded (bool): 当前目录是否已全部加载 all_loaded (bool): 当前目录是否已全部加载
cookies (Optional[Dict]): 登录后保存的 Cookie cookies (Optional[Dict]): 登录后保存的 Cookie
headers (Dict[str, str]): 当前使用的请求头 headers (Dict[str, str]): 当前使用的请求头
nick_name (str): 当前用户昵称获取用户信息时填充
uid (int): 当前用户 UID获取用户信息时填充
:note:
流程初始化内核实例 -> 加载配置可以初始化时提供-> 初始化登录状态self.init_login_state() -> 进行目录浏览 / 上传 / 下载等操作 -> 需要时保存配置
配置说明如果传入了authorization会先使用它尝试获取用户信息来验证登录状态如果无效则根据提供的用户名和密码重新登录如果未传入authorization则直接根据用户名和密码登录登录成功后会更新authorization属性
""" """
# ── 协议常量 ────────────────────────────────────────────── # ── 协议常量 ──────────────────────────────────────────────
@ -258,9 +276,9 @@ class Pan123Core:
password: str = "", password: str = "",
authorization: str = "", authorization: str = "",
protocol: str = PROTOCOL_ANDROID, protocol: str = PROTOCOL_ANDROID,
config_file: str = "123pan.txt",
device_type: str = "", device_type: str = "",
os_version: str = "", os_version: str = "",
# config_file: str = "123pan_config.json",
): ):
"""初始化内核实例。 """初始化内核实例。
@ -272,6 +290,7 @@ class Pan123Core:
config_file: 配置文件路径用于持久化账号和 Token config_file: 配置文件路径用于持久化账号和 Token
device_type: 指定 Android 设备型号为空则随机选取 device_type: 指定 Android 设备型号为空则随机选取
os_version: 指定 Android 系统版本为空则随机选取 os_version: 指定 Android 系统版本为空则随机选取
use_config_file: 是否在初始化时自动从配置文件加载账号信息和 Token默认为 False以避免内核直接依赖文件系统
""" """
# 账号信息 # 账号信息
self.user_name: str = user_name self.user_name: str = user_name
@ -285,7 +304,7 @@ class Pan123Core:
self.login_uuid: str = uuid.uuid4().hex self.login_uuid: str = uuid.uuid4().hex
# 配置文件 # 配置文件
self.config_file: str = config_file # self.config_file: str = config_file
# 目录导航状态 # 目录导航状态
self.cwd_id: int = 0 self.cwd_id: int = 0
@ -305,6 +324,10 @@ class Pan123Core:
self.headers: Dict[str, str] = {} self.headers: Dict[str, str] = {}
self._build_headers() self._build_headers()
# 运行参数
self.nick_name = None
self.uid = None
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 请求头构建 # 请求头构建
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@ -364,10 +387,13 @@ class Pan123Core:
# 配置持久化 # 配置持久化
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
def load_config(self) -> Dict[str, Any]: def load_config(self, cfg: Dict) -> Dict[str, Any]:
"""从配置文件加载账号信息、Token 及协议设置。 """从配置加载账号信息、Token 及协议设置。仅更新cfg中存在的字段
会自<EFBFBD><EFBFBD><EFBFBD>重建 headers 并同步 authorization 会自动重建 headers 并同步 authorization
Args:
cfg: { userName: str, passWord: str, authorization: str, deviceType: str, osVersion: str, protocol: str }
Returns: Returns:
Result 字典:: Result 字典::
@ -375,11 +401,7 @@ class Pan123Core:
成功: {"code": 0, "message": "配置加载成功", "data": {配置内容 dict}} 成功: {"code": 0, "message": "配置加载成功", "data": {配置内容 dict}}
失败: {"code": -1, "message": "错误描述", "data": None} 失败: {"code": -1, "message": "错误描述", "data": None}
""" """
if not os.path.exists(self.config_file):
return make_result(-1, "配置文件不存在")
try: try:
with open(self.config_file, "r", encoding="utf-8") as f:
cfg = json.load(f)
self.user_name = cfg.get("userName", self.user_name) self.user_name = cfg.get("userName", self.user_name)
self.password = cfg.get("passWord", self.password) self.password = cfg.get("passWord", self.password)
self.authorization = cfg.get("authorization", self.authorization) self.authorization = cfg.get("authorization", self.authorization)
@ -392,16 +414,22 @@ class Pan123Core:
except Exception as e: except Exception as e:
return make_result(-1, f"加载配置失败: {e}") return make_result(-1, f"加载配置失败: {e}")
def save_config(self) -> Dict[str, Any]: def get_current_config(self) -> Dict[str, Any]:
"""将当前账号信息、Token 及协议设置保存到配置文件 """获取当前账号信息、Token 及协议设置的字典表示
Returns: Returns:
Result 字典:: 当前配置的字典例如::
成功: {"code": 0, "message": "配置已保存", "data": {配置内容 dict}} {
失败: {"code": -1, "message": "错误描述", "data": None} "userName": str,
"passWord": str,
"authorization": str,
"deviceType": str,
"osVersion": str,
"protocol": str,
}
""" """
cfg = { return {
"userName": self.user_name, "userName": self.user_name,
"passWord": self.password, "passWord": self.password,
"authorization": self.authorization, "authorization": self.authorization,
@ -409,12 +437,6 @@ class Pan123Core:
"osVersion": self.os_version, "osVersion": self.os_version,
"protocol": self.protocol, "protocol": self.protocol,
} }
try:
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
return make_result(CODE_OK, "配置已保存", cfg)
except Exception as e:
return make_result(-1, f"保存配置失败: {e}")
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 统一网络请求 # 统一网络请求
@ -445,7 +467,7 @@ class Pan123Core:
Result 字典:: Result 字典::
成功: {"code": 0, "message": "ok", "data": {API 原始响应 JSON}} 成功: {"code": 0, "message": "ok", "data": {API 原始响应 JSON}}
失败: {"code": <API错误码或-1>, "message": "错误描述", "data": {API响应} | None} 失败: {"code": <0, "message": "错误描述", "data": {API响应} | None}
""" """
url = f"{API_BASE_URL}{path}" if path.startswith("/") else path url = f"{API_BASE_URL}{path}" if path.startswith("/") else path
try: try:
@ -458,14 +480,46 @@ class Pan123Core:
) )
data = resp.json() data = resp.json()
api_code = data.get("code", -1) api_code = data.get("code", -1)
# 123pan 登录成功返回 200其余接口成功返回 0 # 123pan 登录成功/退出登录 成功返回 code 200其余接口成功返回 0
if api_code not in (CODE_OK, CODE_LOGIN_OK): if api_code not in (CODE_OK, CODE_LOGIN_OK):
return make_result(api_code, data.get("message", "未知错误"), data) return make_result(-3, data.get("message", "未知错误"), data)
return make_result(CODE_OK, "ok", data) return make_result(CODE_OK, "ok", data)
except requests.RequestException as e: except requests.RequestException as e:
return make_result(-1, f"请求失败: {e}") return make_result(-1, f"请求失败: {e}")
except json.JSONDecodeError: except json.JSONDecodeError:
return make_result(-1, "响应 JSON 解析错误") return make_result(-2, "响应 JSON 解析错误")
# ════════════════════════════════════════════════════════════
# 用户信息
# ════════════════════════════════════════════════════════════
def get_user_info(self) -> Dict[str, Any]:
"""获取当前登录用户的信息。
Returns:
Result:
成功: {"code": 0, "message": "ok", "data": {用户信息 dict}}
失败: {"code": <错误码>, "message": "错误描述", "data": None}
data: {
"UID": ,
"Nickname": "",
"SpaceUsed": ,
"SpacePermanent": ,
"SpaceTemp": 0,
"FileCount": ,
"SpaceTempExpr": "",
"Mail": "",
"Passport": ,
"HeadImage": "",
......
}
"""
user_info_res = self._request("GET", URL_USER_INFO)
if user_info_res["code"] != CODE_OK:
return make_result(user_info_res["code"], f"获取用户信息失败: {user_info_res['message']}")
self.nick_name = user_info_res["data"]["data"].get("Nickname", "")
self.uid = user_info_res["data"]["data"].get("UID", None)
return make_result(CODE_OK, "ok", user_info_res["data"]["data"])
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 登录 / 登出 # 登录 / 登出
@ -478,7 +532,7 @@ class Pan123Core:
Result 字典:: Result 字典::
成功: {"code": 0, "message": "登录成功", "data": None} 成功: {"code": 0, "message": "登录成功", "data": None}
失败: {"code": -1|<API码>, "message": "错误描述", "data": None} 失败: {"code": -1, "message": "错误描述", "data": None}
""" """
if not self.user_name or not self.password: if not self.user_name or not self.password:
return make_result(-1, "用户名和密码不能为空") return make_result(-1, "用户名和密码不能为空")
@ -494,7 +548,8 @@ class Pan123Core:
self.authorization = f"Bearer {token}" self.authorization = f"Bearer {token}"
self._build_headers() self._build_headers()
self._sync_authorization() self._sync_authorization()
self.save_config() # 为避免内核依赖文件系统,登录成功后不自动保存配置到文件,由上层调用者决定何时保存。
# self.save_config_to_file()
return make_result(CODE_OK, "登录成功") return make_result(CODE_OK, "登录成功")
def logout(self) -> Dict[str, Any]: def logout(self) -> Dict[str, Any]:
@ -508,11 +563,11 @@ class Pan123Core:
self.authorization = "" self.authorization = ""
self._sync_authorization() self._sync_authorization()
self.cookies = None self.cookies = None
self.save_config() # self.save_config_to_file()
return make_result(CODE_OK, "已登出") return make_result(CODE_OK, "已登出")
def clear_account(self) -> Dict[str, Any]: def clear_account(self) -> Dict[str, Any]:
"""清除已登录账号清除用户名、密码、authorization 和 cookies并保存配置 """清除已登录账号清除用户名、密码、authorization 和 cookies不保存配置,但重建请求头
Returns: Returns:
Result 字典:: Result 字典::
@ -524,13 +579,76 @@ class Pan123Core:
self.authorization = "" self.authorization = ""
self._sync_authorization() self._sync_authorization()
self.cookies = None self.cookies = None
self.save_config() # self.save_config_to_file()
return make_result(CODE_OK, "账号信息已清除") return make_result(CODE_OK, "账号信息已清除")
def check_login(self) -> Dict[str, Any]:
"""检查当前登录状态是否有效。
通过尝试获取根目录列表来验证 Token 是否有效
Returns:
Result 字典::
成功: {"code": 0, "message": "登录状态有效", "data": None}
失败: {"code": -1, "message": "登录状态无效: 错误描述", "data": None}
"""
result = self.get_user_info()
if result["code"] == CODE_OK:
return make_result(CODE_OK, "登录状态有效")
return make_result(-1, f"登录状态无效: {result['message']}")
def init_login_state(self) -> Dict[str, Any]:
"""根据提供的配置初始化登录状态。
Args:
cfg: 包含账号信息和 Token 的配置字典结构同 get_current_config() 的返回值
Returns:
Result:
{"code": Num, "message": "..."}
"""
# 直接获取目录列表来验证登录状态和 Token 是否有效
is_valid = self.check_login()
if is_valid["code"] == CODE_OK:
return make_result(CODE_OK, "登录状态初始化成功")
else:
# 登录状态无效,重新登录
if not self.user_name or not self.password:
return make_result(-1, "登录状态无效,且用户名或密码缺失,无法重新登录")
login_result = self.login()
if login_result["code"] == CODE_OK:
return make_result(CODE_OK, "登录状态无效,重新登录成功")
else:
return make_result(-2, f"登录状态无效,重新登录失败: {login_result['message']}")
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 目录浏览 # 目录浏览
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
def get_folder_details(self, folder_id: int) -> Dict[str, Any]:
"""获取指定文件夹的详情信息。
Args:
folder_id: 目标文件夹的 FileId
Returns:
Result 字典::
成功: {"code": 0, "message": "ok", "data": {文件夹详情 dict}}
失败: {"code": <错误码>, "message": "错误描述", "data": None}
"""
# 要传递一个包含 folder_id 的列表,但接口只返回第一个文件夹的详情
data = {"file_ids": [folder_id]}
res = self._request("POST", URL_DETAILS, json_data=data)
if res["code"] != CODE_OK:
return make_result(-1, f"获取文件夹详情失败: {res['message']}", res["data"])
details = res["data"]["data"]
if not details:
return make_result(-2, "文件夹详情数据为空", res["data"])
return make_result(CODE_OK, "ok", details)
def list_dir( def list_dir(
self, self,
parent_id: Optional[int] = None, parent_id: Optional[int] = None,
@ -735,7 +853,7 @@ class Pan123Core:
Result 字典:: Result 字典::
成功: {"code": 0, "message": "ok", "data": {API 响应}} 成功: {"code": 0, "message": "ok", "data": {API 响应}}
失败: {"code": -1|<API码>, "message": "...", "data": ...} 失败: {"code": -1, "message": "...", "data": ...}
""" """
if not name: if not name:
return make_result(-1, "目录名不能为空") return make_result(-1, "目录名不能为空")
@ -914,14 +1032,26 @@ class Pan123Core:
Returns: Returns:
Result 字典:: Result 字典::
来自 self.get_item_download_url() 的结果
成功: {"code": 0, "message": "ok", "data": {"url": "https://..."}} 成功: {"code": 0, "message": "ok", "data": {"url": "https://..."}}
失败: {"code": -1, "message": "...", "data": None} 失败: {"code": -1, "message": "...", "data": None}
""" """
if not (0 <= index < len(self.file_list)): if not (0 <= index < len(self.file_list)):
return make_result(-1, "无效的文件编号") return make_result(-1, "无效的文件编号")
item = self.file_list[index] item = self.file_list[index]
return self.get_item_download_url(item)
def get_item_download_url(self, item: Dict) -> Dict[str, Any]:
"""获取单个文件或文件夹的真实下载链接。
Args:
item: 文件信息字典文件夹Type = 1需包含 "FileId"
文件Type = 0需包含 "FileId", "Etag", "S3KeyFlag", "Type", "FileName", "Size"可以来自 file_list 中的条目或手动构造的 dict
Returns:
Result 字典::
成功: {"code": 0, "message": "ok", "data": {"url": "https://..."}}
失败: {"code": -1, "message": "...", "data": None}
"""
# 文件夹走批量下载接口,文件走单文件接口 # 文件夹走批量下载接口,文件走单文件接口
if item["Type"] == 1: if item["Type"] == 1:
api_path = URL_BATCH_DOWNLOAD api_path = URL_BATCH_DOWNLOAD
@ -946,7 +1076,12 @@ class Pan123Core:
# 跟随重定向获取真实下载链接 # 跟随重定向获取真实下载链接
try: try:
resp = requests.get(download_url, allow_redirects=False, timeout=TIMEOUT_DEFAULT) # 直接请求会报错证书错误
# 此服务器无法证明它是 user-app-free-download-cdn.123295.com它的安全证书来自 *.123pan.cn。这可能是由错误配置或者有攻击者截获你的连接而导致的。
# 关闭 SSL 验证以避免下载链接获取失败
# 仅在获取下载链接时关闭验证
requests.packages.urllib3.disable_warnings()
resp = requests.get(download_url, allow_redirects=False, timeout=TIMEOUT_DEFAULT, verify=False)
if resp.status_code == 302: if resp.status_code == 302:
location = resp.headers.get("Location") location = resp.headers.get("Location")
if location: if location:
@ -959,138 +1094,142 @@ class Pan123Core:
except requests.RequestException as e: except requests.RequestException as e:
return make_result(-1, f"获取真实下载链接失败: {e}") return make_result(-1, f"获取真实下载链接失败: {e}")
def download_file( # 文件交互,方法已移至 Pan123Tool
self, # def download_file(
index: int, # self,
save_dir: str = "download", # index: int,
on_progress: ProgressCallback = None, # save_dir: str = "download",
overwrite: bool = False, # on_progress: ProgressCallback = None,
skip_existing: bool = False, # overwrite: bool = False,
) -> Dict[str, Any]: # skip_existing: bool = False,
"""下载 file_list 中指定下标的文件到本地。 # ) -> Dict[str, Any]:
# """下载 file_list 中指定下标的文件到本地。
#
# 如果目标是文件夹,则自动递归调用 download_directory()。
# 下载过程中使用 ".123pan" 临时文件,完成后重命名。
#
# Args:
# index: file_list 中的 0-based 下标。
# save_dir: 本地保存目录路径,不存在会自动创建。
# on_progress: 下载进度回调函数,签名:
# (downloaded_bytes: int, total_bytes: int, speed_bps: float) -> None
# overwrite: True = 覆盖已存在的同名文件。
# skip_existing: True = 跳过已存在的同名文件。
#
# Returns:
# Result 字典::
#
# 成功: {"code": 0, "message": "下载完成", "data": {"path": "本地文件路径"}}
# 冲突: {"code": 1, "message": "文件已存在", "data": {"path": "...", "conflict": True}}
# 跳过: {"code": 0, "message": "文件已存在,已跳过", "data": {"path": "..."}}
# 失败: {"code": -1, "message": "...", "data": None}
# """
# if not (0 <= index < len(self.file_list)):
# return make_result(-1, "无效的文件编号")
# item = self.file_list[index]
#
# # 文件夹递归下载
# if item["Type"] == 1:
# return self.download_directory(item, save_dir, on_progress, overwrite, skip_existing)
#
# # 获取下载链接
# r = self.get_download_url(index)
# if r["code"] != CODE_OK:
# return r
# url = r["data"]["url"]
#
# file_name = item["FileName"]
# os.makedirs(save_dir, exist_ok=True)
# full_path = os.path.join(save_dir, file_name)
#
# # 文件冲突处理
# if os.path.exists(full_path):
# if skip_existing:
# return make_result(CODE_OK, "文件已存在,已跳过", {"path": full_path})
# if not overwrite:
# return make_result(CODE_CONFLICT, "文件已存在", {"path": full_path, "conflict": True})
# os.remove(full_path)
#
# # 使用临时文件下载
# temp_path = full_path + ".123pan"
# try:
# resp = requests.get(url, stream=True, timeout=TIMEOUT_DOWNLOAD)
# total = int(resp.headers.get("Content-Length", 0))
# downloaded = 0
# start = time.time()
# with open(temp_path, "wb") as f:
# for chunk in resp.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE):
# if chunk:
# f.write(chunk)
# downloaded += len(chunk)
# if on_progress:
# elapsed = time.time() - start
# speed = downloaded / elapsed if elapsed > 0 else 0.0
# # on_progress(
#
# )
# os.rename(temp_path, full_path)
# return make_result(CODE_OK, "下载完成", {"path": full_path})
# except Exception as e:
# if os.path.exists(temp_path):
# os.remove(temp_path)
# return make_result(-1, f"下载失败: {e}")
如果目标是文件夹则自动递归调用 download_directory() # 文件交互,方法已移至 Pan123ToolPan123Core 仅保留获取下载链接的功能,目录下载逻辑也移至工具类以避免内核依赖文件系统。
下载过程中使用 ".123pan" 临时文件完成后重命名 # def download_directory(
# self,
Args: # directory: Dict,
index: file_list 中的 0-based 下标 # save_dir: str = "download",
save_dir: 本地保存目录路径不存在会自动创建 # on_progress: ProgressCallback = None,
on_progress: 下载进度回调函数签名: # overwrite: bool = False,
(downloaded_bytes: int, total_bytes: int, speed_bps: float) -> None # skip_existing: bool = False,
overwrite: True = 覆盖已存在的同名文件 # ) -> Dict[str, Any]:
skip_existing: True = 跳过已存在的同名文件 # """递归下载整个目录到本地。
#
Returns: # Args:
Result 字典:: # directory: 文件夹信息字典(需包含 "FileId"、"FileName"、"Type" 字段)。
# save_dir: 本地保存根目录路径。
成功: {"code": 0, "message": "下载完成", "data": {"path": "本地文件路径"}} # on_progress: 下载进度回调函数(同 download_file
冲突: {"code": 1, "message": "文件已存在", "data": {"path": "...", "conflict": True}} # overwrite: True = 覆盖已存在文件。
跳过: {"code": 0, "message": "文件已存在,已跳过", "data": {"path": "..."}} # skip_existing: True = 跳过已存在文件。
失败: {"code": -1, "message": "...", "data": None} #
""" # Returns:
if not (0 <= index < len(self.file_list)): # Result 字典::
return make_result(-1, "无效的文件编号") #
item = self.file_list[index] # 成功: {"code": 0, "message": "文件夹下载完成", "data": {"path": "本地目录路径"}}
# 部分失败: {"code": -1, "message": "部分文件下载失败: ...", "data": {"path": "..."}}
# 文件夹递归下载 # 失败: {"code": <错误码>, "message": "...", "data": None}
if item["Type"] == 1: # """
return self.download_directory(item, save_dir, on_progress, overwrite, skip_existing) # if directory["Type"] != 1:
# return make_result(-1, "不是文件夹")
# 获取下载链接 #
r = self.get_download_url(index) # target_dir = os.path.join(save_dir, directory["FileName"])
if r["code"] != CODE_OK: # os.makedirs(target_dir, exist_ok=True)
return r #
url = r["data"]["url"] # r = self.list_dir_all(parent_id=directory["FileId"])
# if r["code"] != CODE_OK:
file_name = item["FileName"] # return r
os.makedirs(save_dir, exist_ok=True) #
full_path = os.path.join(save_dir, file_name) # items = r["data"]["items"]
# if not items:
# 文件冲突处理 # return make_result(CODE_OK, "文件夹为空", {"path": target_dir})
if os.path.exists(full_path): #
if skip_existing: # errors: List[str] = []
return make_result(CODE_OK, "文件已存在,已跳过", {"path": full_path}) # for item in items:
if not overwrite: # if item["Type"] == 1:
return make_result(CODE_CONFLICT, "文件已存在", {"path": full_path, "conflict": True}) # sub = self.download_directory(item, target_dir, on_progress, overwrite, skip_existing)
os.remove(full_path) # else:
# # 临时替换 file_list 以复用 download_file 逻辑
# 使用临时文件下载 # orig_list = self.file_list
temp_path = full_path + ".123pan" # self.file_list = [item]
try: # sub = self.download_file(0, target_dir, on_progress, overwrite, skip_existing)
resp = requests.get(url, stream=True, timeout=TIMEOUT_DOWNLOAD) # self.file_list = orig_list
total = int(resp.headers.get("Content-Length", 0)) # if sub["code"] != CODE_OK:
downloaded = 0 # errors.append(f"{item['FileName']}: {sub['message']}")
start = time.time() #
with open(temp_path, "wb") as f: # if errors:
for chunk in resp.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE): # return make_result(-1, f"部分文件下载失败: {'; '.join(errors)}", {"path": target_dir})
if chunk: # return make_result(CODE_OK, "文件夹下载完成", {"path": target_dir})
f.write(chunk)
downloaded += len(chunk)
if on_progress:
elapsed = time.time() - start
speed = downloaded / elapsed if elapsed > 0 else 0.0
on_progress(downloaded, total, speed)
os.rename(temp_path, full_path)
return make_result(CODE_OK, "下载完成", {"path": full_path})
except Exception as e:
if os.path.exists(temp_path):
os.remove(temp_path)
return make_result(-1, f"下载失败: {e}")
def download_directory(
self,
directory: Dict,
save_dir: str = "download",
on_progress: ProgressCallback = None,
overwrite: bool = False,
skip_existing: bool = False,
) -> Dict[str, Any]:
"""递归下载整个目录到本地。
Args:
directory: 文件夹信息字典需包含 "FileId""FileName""Type" 字段
save_dir: 本地保存根目录路径
on_progress: 下载进度回调函数 download_file
overwrite: True = 覆盖已存在文件
skip_existing: True = 跳过已存在文件
Returns:
Result 字典::
成功: {"code": 0, "message": "文件夹下载完成", "data": {"path": "本地目录路径"}}
部分失败: {"code": -1, "message": "部分文件下载失败: ...", "data": {"path": "..."}}
失败: {"code": <错误码>, "message": "...", "data": None}
"""
if directory["Type"] != 1:
return make_result(-1, "不是文件夹")
target_dir = os.path.join(save_dir, directory["FileName"])
os.makedirs(target_dir, exist_ok=True)
r = self.list_dir_all(parent_id=directory["FileId"])
if r["code"] != CODE_OK:
return r
items = r["data"]["items"]
if not items:
return make_result(CODE_OK, "文件夹为空", {"path": target_dir})
errors: List[str] = []
for item in items:
if item["Type"] == 1:
sub = self.download_directory(item, target_dir, on_progress, overwrite, skip_existing)
else:
# 临时替换 file_list 以复用 download_file 逻辑
orig_list = self.file_list
self.file_list = [item]
sub = self.download_file(0, target_dir, on_progress, overwrite, skip_existing)
self.file_list = orig_list
if sub["code"] != CODE_OK:
errors.append(f"{item['FileName']}: {sub['message']}")
if errors:
return make_result(-1, f"部分文件下载失败: {'; '.join(errors)}", {"path": target_dir})
return make_result(CODE_OK, "文件夹下载完成", {"path": target_dir})
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
# 上传 # 上传
@ -1235,7 +1374,12 @@ class Pan123Core:
uploaded += len(chunk) uploaded += len(chunk)
if on_progress: if on_progress:
on_progress(uploaded, total_size) on_progress({
"type": Pan123EventType.UPLOAD_PROGRESS,
"uploaded": uploaded,
"total": total_size,
"percent": uploaded / total_size * 100,
})
part_number += 1 part_number += 1
# 步骤 3: 通知服务端合并所有分块 # 步骤 3: 通知服务端合并所有分块
@ -1281,5 +1425,266 @@ class Pan123Core:
self.protocol = protocol self.protocol = protocol
self._build_headers() self._build_headers()
self._sync_authorization() self._sync_authorization()
self.save_config() # self.save_config_to_file()
return make_result(CODE_OK, f"已切换到 {protocol} 协议") return make_result(CODE_OK, f"已切换到 {protocol} 协议")
class Pan123Tool:
"""123pan 工具类,提供更高层次的文件交互方法,依赖 Pan123Core 实现具体 API 调用。
Args:
core: Pan123Core 实例负责 API 请求和状态管理
config_file: 配置文件路径默认为 "123pan_config.json"用于保存和加载账号信息Token 及协议设置
:note
Pan123Tool 主要负责文件下载上传目录操作等依赖文件系统的功能 Pan123Core 负责 API 请求认证和状态管理
"""
def __init__(self, core: Pan123Core, config_file: str = "123pan_config.json"):
self.core = core
self.config_file = config_file
def load_config_from_file(self) -> Dict[str, Any]:
"""从配置文件加载账号信息、Token 及协议设置。
会自动重建 headers 并同步 authorization
Returns:
Result 字典::
成功: {"code": 0, "message": "配置加载成功", "data": {配置内容 dict}}
失败: {"code": -1, "message": "错误描述", "data": None}
"""
if not os.path.exists(self.config_file):
return make_result(-1, "配置文件不存在")
try:
with open(self.config_file, "r", encoding="utf-8") as f:
cfg = json.load(f)
return self.core.load_config(cfg)
except Exception as e:
return make_result(-1, f"加载配置失败: {e}")
def save_config_to_file(self) -> Dict[str, Any]:
"""将当前账号信息、Token 及协议设置保存到配置文件。
Returns:
Result 字典::
成功: {"code": 0, "message": "配置已保存", "data": {配置内容 dict}}
失败: {"code": -1, "message": "错误描述", "data": None}
"""
cfg = {
"userName": self.core.user_name,
"passWord": self.core.password,
"authorization": self.core.authorization,
"deviceType": self.core.device_type,
"osVersion": self.core.os_version,
"protocol": self.core.protocol,
}
try:
with open(self.config_file, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
return make_result(CODE_OK, "配置已保存", cfg)
except Exception as e:
return make_result(-1, f"保存配置失败: {e}")
def download_file(
self,
index: int,
save_dir: str = "download",
on_progress: ProgressCallback = None,
overwrite: bool = False,
skip_existing: bool = False,
) -> Dict[str, Any]:
"""下载 file_list 中指定下标的文件到本地。
如果目标是文件夹则自动递归调用 download_directory()
下载过程中使用 ".123pan" 临时文件完成后重命名
Args:
index: file_list 中的 0-based 下标
save_dir: 本地保存目录路径不存在会自动创建
on_progress: 下载进度回调函数签名:
(downloaded_bytes: int, total_bytes: int, speed_bps: float) -> None
overwrite: True = 覆盖已存在的同名文件
skip_existing: True = 跳过已存在的同名文件
Returns:
Result 字典:: 来自 download_url() download_directory() 的结果
成功: {"code": 0, "message": "下载完成", "data": {"path": "本地文件路径"}}
冲突: {"code": 1, "message": "文件已存在", "data": {"path": "...", "conflict": True}}
跳过: {"code": 0, "message": "文件已存在,已跳过", "data": {"path": "..."}}
失败: {"code": -1, "message": "...", "data": None}
"""
if not (0 <= index < len(self.core.file_list)):
return make_result(-1, "无效的文件编号")
item = self.core.file_list[index]
return self.download_item(item, save_dir, on_progress, overwrite, skip_existing)
def download_item(
self,
item: Dict,
save_dir: str = "download",
on_progress: ProgressCallback = None,
overwrite: bool = False,
skip_existing: bool = False,
):
"""下载单个文件或文件夹项,自动区分类型并处理。
Args:
item: 文件信息字典文件夹Type = 1需包含 "FileId"
文件Type = 0需包含 "FileId", "Etag", "S3KeyFlag", "Type", "FileName", "Size"
save_dir: 本地保存目录路径不存在会自动创建
on_progress: 下载进度回调函数签名:
(downloaded_bytes: int, total_bytes: int, speed_bps: float) -> None
overwrite: True = 覆盖已存在的同名文件
skip_existing: True = 跳过已存在的同名文件
Returns:
Result 字典:: 来自 download_url() download_directory() 的结果
成功: {"code": 0, "message": "下载完成", "data": {"path": "本地文件路径"}}
冲突: {"code": 1, "message": "文件已存在", "data": {"path": "...", "conflict": True}}
跳过: {"code": 0, "message": "文件已存在,已跳过", "data": {"path": "..."}}
失败: {"code": -1, "message": "...", "data": None}
"""
# 文件夹递归下载
if item["Type"] == 1:
return self.download_directory(item, save_dir, on_progress, overwrite, skip_existing)
# 获取下载链接
r = self.core.get_item_download_url(item)
if r["code"] != CODE_OK:
return r
url = r["data"]["url"]
file_name = item["FileName"]
return self.download_url(url, file_name, save_dir, on_progress, overwrite, skip_existing)
def download_url(
self,
url: str,
file_name: str,
save_dir: str = "download",
on_progress: ProgressCallback = None,
overwrite: bool = False,
skip_existing: bool = False,
) -> Dict[str, Any]:
"""根据下载链接下载文件到本地,支持进度回调和冲突处理。
Args:
url: 真实下载链接
file_name: 保存的文件名不含路径
save_dir: 本地保存目录路径不存在会自动创建
on_progress: 下载进度回调函数签名:
(downloaded_bytes: int, total_bytes: int, speed_bps: float) -> None
overwrite: True = 覆盖已存在的同名文件
skip_existing: True = 跳过已存在的同名文件
Returns:
Result 字典::
成功: {"code": 0, "message": "下载完成", "data": {"path": "本地文件路径"}}
冲突: {"code": 1, "message": "文件已存在", "data": {"path": "...", "conflict": True}}
跳过: {"code": 0, "message": "文件已存在,已跳过", "data": {"path": "..."}}
失败: {"code": -1, "message": "...", "data": None}
"""
os.makedirs(save_dir, exist_ok=True)
full_path = os.path.join(save_dir, file_name)
# 文件冲突处理
if os.path.exists(full_path):
if skip_existing:
return make_result(CODE_OK, "文件已存在,已跳过", {"path": full_path})
if not overwrite:
return make_result(CODE_CONFLICT, "文件已存在", {"path": full_path, "conflict": True})
os.remove(full_path)
# TODO: 可以考虑断点续传
# 使用临时文件下载
temp_path = full_path + ".123pan"
try:
resp = requests.get(url, stream=True, timeout=TIMEOUT_DOWNLOAD)
total = int(resp.headers.get("Content-Length", 0))
downloaded = 0
start = time.time()
with open(temp_path, "wb") as f:
for chunk in resp.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if on_progress:
elapsed = time.time() - start
speed = downloaded / elapsed if elapsed > 0 else 0.0
on_progress({
"type": Pan123EventType.DOWNLOAD_PROGRESS,
"downloaded": downloaded,
"total": total,
"speed": speed,
})
os.rename(temp_path, full_path)
return make_result(CODE_OK, "下载完成", {"path": full_path})
except Exception as e:
if os.path.exists(temp_path):
os.remove(temp_path)
return make_result(-1, f"下载失败: {e}")
def download_directory(
self,
directory: Dict,
save_dir: str = "download",
on_progress: ProgressCallback = None,
overwrite: bool = False,
skip_existing: bool = False,
) -> Dict[str, Any]:
"""递归下载整个目录到本地。
Args:
directory: 文件夹信息字典需包含 "FileId""FileName""Type" 字段
save_dir: 本地保存根目录路径
on_progress: 下载进度回调函数 download_file
overwrite: True = 覆盖已存在文件
skip_existing: True = 跳过已存在文件
Returns:
成功: {"code": 0, "message": "文件夹下载完成", "data": {"path": "本地目录路径"}}
部分失败: {"code": -1, "message": "部分文件下载失败: ...", "data": {"path": "..."}}
失败: {"code": <错误码>, "message": "...", "data": None}
"""
if directory["Type"] != 1:
return make_result(-1, "不是文件夹")
target_dir = os.path.join(save_dir, directory["FileName"])
os.makedirs(target_dir, exist_ok=True)
r = self.core.list_dir_all(parent_id=directory["FileId"])
if r["code"] != CODE_OK:
return r
items = r["data"]["items"]
if not items:
return make_result(CODE_OK, "文件夹为空", {"path": target_dir})
errors: List[str] = []
for item in items:
if item["Type"] == 1:
# 递归下载子目录
if on_progress:
on_progress({
"type": Pan123EventType.DOWNLOAD_START_DIRECTORY,
"file_name": item["FileName"],
"dir_name": item["FileName"],
"message": f"正在下载目录: {item['FileName']}",
})
sub = self.download_directory(item, target_dir, on_progress, overwrite, skip_existing)
else:
if on_progress:
on_progress({
"type": Pan123EventType.DOWNLOAD_START_FILE,
"file_name": item["FileName"],
"file_size": item["Size"],
"message": f"正在下载文件: {item['FileName']}",
})
sub = self.download_item(item, target_dir, on_progress, overwrite, skip_existing)
if sub["code"] != CODE_OK:
errors.append(f"{item['FileName']}: {sub['message']}")
if errors:
return make_result(-1, f"部分文件下载失败: {'; '.join(errors)}", {"path": target_dir})
return make_result(CODE_OK, "文件夹下载完成", {"path": target_dir})