348 lines
14 KiB
Bash
Executable File
348 lines
14 KiB
Bash
Executable File
#!/bin/bash
|
||
# ============================================================
|
||
# 百度网盘备份脚本
|
||
# 用法: ./baidu-backup.sh [目标目录]
|
||
# 示例: ./baidu-backup.sh /2026/0517
|
||
# ./baidu-backup.sh # 自动使用 /年份/月日
|
||
# ============================================================
|
||
|
||
# 不使用 set -e(与并行后台进程不兼容)
|
||
|
||
# ---- 配置 ----
|
||
APP_KEY="z3gemBZfg7KYj6U3eHNfIzTs7uYS9OMh"
|
||
SECRET_KEY="ptCKj2DfxL0KtGR1pM08c9KO2t2UC7SR"
|
||
TOKEN_FILE="$HOME/.baidu_pan_token.json"
|
||
BLOCK_SIZE=$((32 * 1024 * 1024)) # 32MB 大分片减少HTTP开销
|
||
PARALLEL=4 # 并行上传数
|
||
|
||
# ---- 自动识别项目 ----
|
||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||
PROJECT_NAME="$(basename "$SCRIPT_DIR")"
|
||
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
|
||
ZIP_FILE="/tmp/${PROJECT_NAME}.zip"
|
||
MD5_DIR="/tmp/baidu_md5"
|
||
|
||
# ---- 目标目录 ----
|
||
if [ -n "$1" ]; then
|
||
REMOTE_DIR="$1"
|
||
else
|
||
REMOTE_DIR="/$(date +%Y)/$(date +%m%d)"
|
||
fi
|
||
REMOTE_PATH="${REMOTE_DIR}/${PROJECT_NAME}.zip"
|
||
|
||
echo ""
|
||
echo "╔══════════════════════════════════════════╗"
|
||
echo "║ 📦 百度网盘备份工具 ║"
|
||
echo "╚══════════════════════════════════════════╝"
|
||
echo ""
|
||
echo " 项目: $PROJECT_NAME"
|
||
echo " 目标: $REMOTE_PATH"
|
||
echo ""
|
||
|
||
# ============================================================
|
||
# 步骤1: 打包
|
||
# ============================================================
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "[1/6] 📦 打包项目文件..."
|
||
rm -f "$ZIP_FILE"
|
||
cd "$PARENT_DIR"
|
||
|
||
# 先统计文件总数
|
||
TOTAL_FILES=$(find "$PROJECT_NAME" -not -path "${PROJECT_NAME}/.git/*" -type f | wc -l | tr -d ' ')
|
||
echo " 📊 共 ${TOTAL_FILES} 个文件"
|
||
|
||
# 打包并实时显示百分比
|
||
COUNTER=0
|
||
zip -r "$ZIP_FILE" "$PROJECT_NAME" -x "${PROJECT_NAME}/.git/*" 2>&1 | while IFS= read -r line; do
|
||
COUNTER=$((COUNTER + 1))
|
||
PCT=$((COUNTER * 100 / TOTAL_FILES))
|
||
if [ $PCT -gt 100 ]; then PCT=100; fi
|
||
# 进度条
|
||
BAR_FILLED=$((PCT * 30 / 100))
|
||
BAR_EMPTY=$((30 - BAR_FILLED))
|
||
BAR=""
|
||
for ((b=0; b<BAR_FILLED; b++)); do BAR="${BAR}█"; done
|
||
for ((b=0; b<BAR_EMPTY; b++)); do BAR="${BAR}░"; done
|
||
printf "\r %s %3d%% (%d/%d) " "$BAR" "$PCT" "$COUNTER" "$TOTAL_FILES"
|
||
done
|
||
echo ""
|
||
|
||
FILE_SIZE=$(stat -f%z "$ZIP_FILE" 2>/dev/null || stat -c%s "$ZIP_FILE" 2>/dev/null)
|
||
FILE_SIZE_MB=$((FILE_SIZE / 1024 / 1024))
|
||
echo " ✅ 打包完成: ${FILE_SIZE_MB}MB"
|
||
|
||
# ============================================================
|
||
# 步骤2: 获取 Token(缓存 / 刷新 / 设备授权)
|
||
# ============================================================
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "[2/6] 🔑 获取百度网盘授权..."
|
||
|
||
ACCESS_TOKEN=""
|
||
|
||
# 尝试读取缓存
|
||
if [ -f "$TOKEN_FILE" ]; then
|
||
CACHED=$(python3 -c "
|
||
import json, time, sys
|
||
with open('$TOKEN_FILE') as f:
|
||
d = json.load(f)
|
||
if time.time() < d.get('expires_at', 0):
|
||
print('VALID|' + d['access_token'])
|
||
else:
|
||
print('EXPIRED|' + d.get('refresh_token', ''))
|
||
" 2>/dev/null || echo "FAIL|")
|
||
|
||
STATUS="${CACHED%%|*}"
|
||
VALUE="${CACHED#*|}"
|
||
|
||
if [ "$STATUS" = "VALID" ]; then
|
||
ACCESS_TOKEN="$VALUE"
|
||
echo " ✅ 使用缓存 Token(有效)"
|
||
elif [ "$STATUS" = "EXPIRED" ] && [ -n "$VALUE" ]; then
|
||
echo " ⏳ Token 已过期,尝试刷新..."
|
||
RESP=$(curl -s -X POST "https://openapi.baidu.com/oauth/2.0/token" \
|
||
-d "grant_type=refresh_token&refresh_token=${VALUE}&client_id=${APP_KEY}&client_secret=${SECRET_KEY}")
|
||
NEW_TOKEN=$(echo "$RESP" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('access_token',''))" 2>/dev/null)
|
||
if [ -n "$NEW_TOKEN" ]; then
|
||
ACCESS_TOKEN="$NEW_TOKEN"
|
||
# 更新缓存
|
||
python3 -c "
|
||
import json, time
|
||
d = json.loads('$RESP'.replace(\"'\", '\"'))
|
||
token = {'access_token': d['access_token'], 'refresh_token': d['refresh_token'], 'expires_at': int(time.time()) + d['expires_in']}
|
||
with open('$TOKEN_FILE', 'w') as f: json.dump(token, f)
|
||
" 2>/dev/null
|
||
echo " ✅ Token 刷新成功"
|
||
else
|
||
echo " ⚠️ 刷新失败,需要重新授权"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# 如果没有有效 Token,走设备授权流程
|
||
if [ -z "$ACCESS_TOKEN" ]; then
|
||
echo " 🔐 需要设备授权..."
|
||
DEVICE_RESP=$(curl -s -X POST "https://openapi.baidu.com/oauth/2.0/device/code" \
|
||
-d "response_type=device_code&client_id=${APP_KEY}&scope=basic,netdisk")
|
||
|
||
DEVICE_CODE=$(echo "$DEVICE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['device_code'])")
|
||
USER_CODE=$(echo "$DEVICE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['user_code'])")
|
||
QRCODE_URL=$(echo "$DEVICE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin)['qrcode_url'])")
|
||
|
||
echo ""
|
||
echo " ┌─────────────────────────────────────┐"
|
||
echo " │ 请打开: https://openapi.baidu.com/device"
|
||
echo " │ 输入码: $USER_CODE"
|
||
echo " │ 或扫码: $QRCODE_URL"
|
||
echo " └─────────────────────────────────────┘"
|
||
echo ""
|
||
read -p " 授权完成后按回车继续..." _
|
||
|
||
TOKEN_RESP=$(curl -s -X POST "https://openapi.baidu.com/oauth/2.0/token" \
|
||
-d "grant_type=device_token&code=${DEVICE_CODE}&client_id=${APP_KEY}&client_secret=${SECRET_KEY}")
|
||
ACCESS_TOKEN=$(echo "$TOKEN_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null)
|
||
|
||
if [ -z "$ACCESS_TOKEN" ]; then
|
||
echo " ❌ 授权失败: $TOKEN_RESP"
|
||
rm -f "$ZIP_FILE"
|
||
exit 1
|
||
fi
|
||
|
||
# 保存缓存
|
||
echo "$TOKEN_RESP" | python3 -c "
|
||
import sys, json, time
|
||
d = json.load(sys.stdin)
|
||
token = {'access_token': d['access_token'], 'refresh_token': d['refresh_token'], 'expires_at': int(time.time()) + d['expires_in']}
|
||
with open('$TOKEN_FILE', 'w') as f: json.dump(token, f)
|
||
"
|
||
echo " ✅ 授权成功,Token 已缓存"
|
||
fi
|
||
|
||
# ============================================================
|
||
# 步骤3: 创建远程目录
|
||
# ============================================================
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "[3/6] 📁 创建远程目录 ${REMOTE_DIR} ..."
|
||
DIR_RESP=$(curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${ACCESS_TOKEN}" \
|
||
-d "path=${REMOTE_DIR}&size=0&isdir=1")
|
||
DIR_ERRNO=$(echo "$DIR_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('errno',99))" 2>/dev/null)
|
||
if [ "$DIR_ERRNO" = "0" ] || [ "$DIR_ERRNO" = "-8" ]; then
|
||
echo " ✅ 目录已就绪"
|
||
else
|
||
echo " ⚠️ 目录创建返回: errno=$DIR_ERRNO(继续尝试上传)"
|
||
fi
|
||
|
||
# ============================================================
|
||
# 步骤4: 预创建文件
|
||
# ============================================================
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
BLOCKS=$(( (FILE_SIZE + BLOCK_SIZE - 1) / BLOCK_SIZE ))
|
||
echo "[4/6] 📋 预创建文件(${BLOCKS}个分片)..."
|
||
BLOCK_LIST=$(python3 -c "import json; print(json.dumps(['0'*32]*$BLOCKS))")
|
||
PRE_RESP=$(curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=precreate&access_token=${ACCESS_TOKEN}" \
|
||
-d "path=${REMOTE_PATH}&size=${FILE_SIZE}&isdir=0&autoinit=1&block_list=${BLOCK_LIST}")
|
||
UPLOAD_ID=$(echo "$PRE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('uploadid',''))" 2>/dev/null)
|
||
if [ -z "$UPLOAD_ID" ]; then
|
||
echo " ❌ 预创建失败: $PRE_RESP"
|
||
rm -f "$ZIP_FILE"
|
||
exit 1
|
||
fi
|
||
echo " ✅ 预创建成功"
|
||
|
||
# ============================================================
|
||
# 步骤5: 分片上传(32MB大分片 + 并行上传)
|
||
# ============================================================
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "[5/6] 🚀 上传中(${PARALLEL}路并行, 每片$((BLOCK_SIZE/1024/1024))MB)..."
|
||
echo ""
|
||
|
||
# 预切割文件
|
||
SPLIT_DIR="/tmp/baidu_chunks"
|
||
rm -rf "$SPLIT_DIR"
|
||
mkdir -p "$SPLIT_DIR"
|
||
split -b ${BLOCK_SIZE} -a 4 "$ZIP_FILE" "${SPLIT_DIR}/chunk_"
|
||
CHUNK_FILES=($(ls "${SPLIT_DIR}/chunk_"* | sort))
|
||
BLOCKS=${#CHUNK_FILES[@]}
|
||
echo " 📊 已切割为 ${BLOCKS} 个分片"
|
||
echo ""
|
||
|
||
# 创建md5结果目录
|
||
MD5_DIR="/tmp/baidu_md5"
|
||
rm -rf "$MD5_DIR"
|
||
mkdir -p "$MD5_DIR"
|
||
|
||
START_TIME=$(date +%s)
|
||
DONE_COUNT=0
|
||
FAIL_FLAG="/tmp/baidu_upload_fail"
|
||
rm -f "$FAIL_FLAG"
|
||
|
||
# 单片上传函数(带重试)
|
||
upload_chunk() {
|
||
local SEQ=$1
|
||
local CHUNK_FILE=$2
|
||
local RETRY=0
|
||
local MAX_RETRY=3
|
||
while [ $RETRY -lt $MAX_RETRY ]; do
|
||
local RESP
|
||
RESP=$(curl -s --connect-timeout 30 --max-time 300 \
|
||
"https://d.pcs.baidu.com/rest/2.0/pcs/superfile2?method=upload&access_token=${ACCESS_TOKEN}&type=tmpfile&path=${REMOTE_PATH}&uploadid=${UPLOAD_ID}&partseq=${SEQ}" \
|
||
-F "file=@${CHUNK_FILE}" 2>&1)
|
||
local MD5
|
||
MD5=$(echo "$RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('md5',''))" 2>/dev/null)
|
||
if [ -n "$MD5" ]; then
|
||
echo "$MD5" > "${MD5_DIR}/$(printf '%04d' $SEQ).md5"
|
||
return 0
|
||
fi
|
||
RETRY=$((RETRY + 1))
|
||
if [ $RETRY -lt $MAX_RETRY ]; then
|
||
sleep 2
|
||
fi
|
||
done
|
||
echo "chunk $SEQ failed: $RESP" > "${FAIL_FLAG}_${SEQ}"
|
||
return 1
|
||
}
|
||
|
||
# 并行上传
|
||
for ((i=0; i<BLOCKS; i+=PARALLEL)); do
|
||
PIDS=()
|
||
for ((j=0; j<PARALLEL && i+j<BLOCKS; j++)); do
|
||
SEQ=$((i + j))
|
||
upload_chunk $SEQ "${CHUNK_FILES[$SEQ]}" &
|
||
PIDS+=($!)
|
||
done
|
||
|
||
# 等待本批完成
|
||
BATCH_FAIL=0
|
||
for PID in "${PIDS[@]}"; do
|
||
wait $PID || BATCH_FAIL=1
|
||
done
|
||
if [ $BATCH_FAIL -ne 0 ]; then
|
||
echo ""
|
||
echo " ❌ 上传失败:"
|
||
cat "${FAIL_FLAG}_"* 2>/dev/null
|
||
rm -rf "$SPLIT_DIR" "$MD5_DIR" "$ZIP_FILE" "${FAIL_FLAG}_"*
|
||
exit 1
|
||
fi
|
||
|
||
# 计算进度
|
||
DONE_COUNT=$((i + ${#PIDS[@]}))
|
||
if [ $DONE_COUNT -gt $BLOCKS ]; then DONE_COUNT=$BLOCKS; fi
|
||
PCT=$((DONE_COUNT * 100 / BLOCKS))
|
||
UPLOADED_MB=$((DONE_COUNT * BLOCK_SIZE / 1024 / 1024))
|
||
if [ $UPLOADED_MB -gt $FILE_SIZE_MB ]; then UPLOADED_MB=$FILE_SIZE_MB; fi
|
||
NOW=$(date +%s)
|
||
ELAPSED=$((NOW - START_TIME))
|
||
if [ $ELAPSED -gt 0 ]; then
|
||
SPEED_MB=$((UPLOADED_MB / ELAPSED))
|
||
REMAINING_MB=$((FILE_SIZE_MB - UPLOADED_MB))
|
||
if [ $SPEED_MB -gt 0 ]; then
|
||
ETA=$((REMAINING_MB / SPEED_MB))
|
||
ETA_STR="${ETA}s"
|
||
if [ $ETA -ge 60 ]; then ETA_STR="$((ETA/60))m$((ETA%60))s"; fi
|
||
else
|
||
ETA_STR="计算中"
|
||
fi
|
||
else
|
||
SPEED_MB=0
|
||
ETA_STR="计算中"
|
||
fi
|
||
|
||
BAR_FILLED=$((PCT * 30 / 100))
|
||
BAR_EMPTY=$((30 - BAR_FILLED))
|
||
BAR=""
|
||
for ((b=0; b<BAR_FILLED; b++)); do BAR="${BAR}█"; done
|
||
for ((b=0; b<BAR_EMPTY; b++)); do BAR="${BAR}░"; done
|
||
printf "\r %s %3d%% | %d/%dMB | %dMB/s | 剩余%s " "$BAR" "$PCT" "$UPLOADED_MB" "$FILE_SIZE_MB" "$SPEED_MB" "$ETA_STR"
|
||
done
|
||
|
||
echo ""
|
||
echo ""
|
||
echo " ✅ 所有分片上传完成"
|
||
|
||
# 删除临时分片
|
||
rm -rf "$SPLIT_DIR"
|
||
|
||
# ============================================================
|
||
# 步骤6: 合并文件
|
||
# ============================================================
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "[6/6] 🔗 合并文件..."
|
||
MD5_LIST=$(cat "${MD5_DIR}/"*.md5 | awk '{printf "\"%s\",", $0}' | sed 's/,$//')
|
||
CREATE_RESP=$(curl -s "https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${ACCESS_TOKEN}" \
|
||
-d "path=${REMOTE_PATH}&size=${FILE_SIZE}&isdir=0&uploadid=${UPLOAD_ID}&block_list=[${MD5_LIST}]")
|
||
CREATE_ERRNO=$(echo "$CREATE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('errno',99))" 2>/dev/null)
|
||
FINAL_PATH=$(echo "$CREATE_RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('path',''))" 2>/dev/null)
|
||
|
||
# 清理
|
||
rm -f "$ZIP_FILE" /tmp/chunk_part
|
||
rm -rf "$MD5_DIR" "$FAIL_FLAG"
|
||
|
||
# 计算耗时
|
||
END_TIME=$(date +%s)
|
||
TOTAL=$((END_TIME - START_TIME))
|
||
T_MIN=$((TOTAL / 60))
|
||
T_SEC=$((TOTAL % 60))
|
||
|
||
# 输出结果
|
||
echo ""
|
||
echo "╔══════════════════════════════════════════════╗"
|
||
if [ "$CREATE_ERRNO" = "0" ]; then
|
||
echo "║ 📦 百度网盘备份完成! ║"
|
||
echo "╠══════════════════════════════════════════════╣"
|
||
echo " 📁 路径: $FINAL_PATH"
|
||
echo " 📊 大小: ${FILE_SIZE_MB} MB"
|
||
echo " ⏱️ 耗时: ${T_MIN}分${T_SEC}秒"
|
||
echo " ✅ 状态: 上传成功"
|
||
else
|
||
echo "║ ❌ 合并失败 ║"
|
||
echo "╠══════════════════════════════════════════════╣"
|
||
echo " errno: $CREATE_ERRNO"
|
||
echo " 响应: $CREATE_RESP"
|
||
fi
|
||
echo "╚══════════════════════════════════════════════╝"
|
||
echo ""
|