Files
Train/baidu-backup.sh
2026-06-16 00:55:20 +08:00

350 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=$((4 * 1024 * 1024)) # 4MB
# ---- 自动识别项目 ----
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_NAME="$(basename "$SCRIPT_DIR")"
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
ZIP_FILE="/tmp/${PROJECT_NAME}.zip"
MD5_FILE="/tmp/baidu_md5_list.txt"
# ---- 目标目录 ----
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/5] 📦 打包项目文件..."
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/5] 🔑 获取百度网盘授权..."
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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
BLOCKS=$(( (FILE_SIZE + BLOCK_SIZE - 1) / BLOCK_SIZE ))
echo "[3/5] 📋 预创建文件(${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 " ✅ 预创建成功"
# ============================================================
# 步骤4: 分片上传(并发)
# ============================================================
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
PARALLEL="${BAIDU_PARALLEL:-8}"
if [ "$PARALLEL" -gt "$BLOCKS" ]; then PARALLEL=$BLOCKS; fi
echo "[4/5] 🚀 上传中(${PARALLEL} 路并发)..."
echo ""
UNSORTED_MD5="/tmp/baidu_md5_unsorted_${UPLOAD_ID}.txt"
PROGRESS_FILE="/tmp/baidu_progress_${UPLOAD_ID}.txt"
ERR_FILE="/tmp/baidu_err_${UPLOAD_ID}.txt"
DONE_FLAG="/tmp/baidu_done_${UPLOAD_ID}"
: > "$UNSORTED_MD5"
: > "$PROGRESS_FILE"
: > "$ERR_FILE"
rm -f "$DONE_FLAG"
: > "$MD5_FILE"
START_TIME=$(date +%s)
# 单分片上传 worker(并发安全:单行 echo < PIPE_BUF 是原子写)
upload_part() {
local i=$1
local chunk="/tmp/baidu_chunk_${UPLOAD_ID}_${i}"
dd if="$ZIP_FILE" bs="$BLOCK_SIZE" skip="$i" count=1 2>/dev/null > "$chunk"
local resp md5 attempt
md5=""
for attempt in 1 2 3; do
resp=$(curl -s --max-time 600 \
"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=${i}" \
-F "file=@${chunk}")
md5=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin).get('md5',''))" 2>/dev/null)
[ -n "$md5" ] && break
sleep $((attempt * 2))
done
rm -f "$chunk"
if [ -z "$md5" ]; then
echo "part ${i} failed after 3 attempts: ${resp}" >> "$ERR_FILE"
return 1
fi
echo "${i}|${md5}" >> "$UNSORTED_MD5"
echo "x" >> "$PROGRESS_FILE"
}
export -f upload_part
export ZIP_FILE BLOCK_SIZE ACCESS_TOKEN REMOTE_PATH UPLOAD_ID
export UNSORTED_MD5 PROGRESS_FILE ERR_FILE
# 后台进度刷新进程(每秒刷新一次)
(
while :; do
DONE=$(wc -l < "$PROGRESS_FILE" 2>/dev/null | tr -d ' ')
DONE=${DONE:-0}
if [ "$DONE" -gt "$BLOCKS" ]; then DONE=$BLOCKS; fi
PCT=$((DONE * 100 / BLOCKS))
UPLOADED_MB=$((DONE * 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 ] && [ $DONE -gt 0 ]; then
SPEED_KB=$((DONE * BLOCK_SIZE / 1024 / ELAPSED))
SPEED_INT=$((SPEED_KB / 1024))
SPEED_DEC=$(( (SPEED_KB % 1024) * 10 / 1024 ))
if [ "$SPEED_KB" -gt 0 ]; then
REMAINING_KB=$(( (FILE_SIZE - DONE * BLOCK_SIZE) / 1024 ))
if [ $REMAINING_KB -lt 0 ]; then REMAINING_KB=0; fi
ETA=$(( REMAINING_KB / SPEED_KB ))
ETA_MIN=$((ETA / 60))
ETA_SEC=$((ETA % 60))
ETA_STR="${ETA_MIN}m${ETA_SEC}s"
else
ETA_STR="计算中"
fi
else
SPEED_INT=0
SPEED_DEC=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 | %d.%dMB/s | 剩余%s " "$BAR" "$PCT" "$UPLOADED_MB" "$FILE_SIZE_MB" "$SPEED_INT" "$SPEED_DEC" "$ETA_STR"
if [ -f "$DONE_FLAG" ] || [ "$DONE" -ge "$BLOCKS" ]; then
break
fi
sleep 1
done
) &
PROGRESS_PID=$!
# 并发上传:xargs -P 同时跑 PARALLEL 个 worker(绕开百度 PCS 单连接限速)
XARGS_RC=0
seq 0 $((BLOCKS-1)) | xargs -P "$PARALLEL" -I % bash -c 'upload_part "$@"' _ % || XARGS_RC=$?
# 通知进度进程退出并等待,最后重画一行
touch "$DONE_FLAG"
wait "$PROGRESS_PID" 2>/dev/null || true
echo ""
if [ "$XARGS_RC" -ne 0 ]; then
echo ""
echo " ❌ 分片上传失败:"
if [ -s "$ERR_FILE" ]; then
head -n 5 "$ERR_FILE"
fi
rm -f "$ZIP_FILE" "$MD5_FILE" "$UNSORTED_MD5" "$PROGRESS_FILE" "$ERR_FILE" "$DONE_FLAG"
rm -f /tmp/baidu_chunk_${UPLOAD_ID}_*
exit 1
fi
# 校验分片数
DONE_COUNT=$(wc -l < "$UNSORTED_MD5" | tr -d ' ')
if [ "$DONE_COUNT" != "$BLOCKS" ]; then
echo ""
echo " ❌ 分片数不匹配: ${DONE_COUNT}/${BLOCKS}"
if [ -s "$ERR_FILE" ]; then
head -n 5 "$ERR_FILE"
fi
rm -f "$ZIP_FILE" "$MD5_FILE" "$UNSORTED_MD5" "$PROGRESS_FILE" "$ERR_FILE" "$DONE_FLAG"
rm -f /tmp/baidu_chunk_${UPLOAD_ID}_*
exit 1
fi
# 按 partseq 升序生成最终 MD5_FILE
sort -t'|' -k1n "$UNSORTED_MD5" | cut -d'|' -f2 > "$MD5_FILE"
rm -f "$UNSORTED_MD5" "$PROGRESS_FILE" "$ERR_FILE" "$DONE_FLAG"
echo ""
echo " ✅ 所有分片上传完成"
# ============================================================
# 步骤5: 合并文件
# ============================================================
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "[5/5] 🔗 合并文件..."
MD5_LIST=$(awk '{printf "\"%s\",", $0}' "$MD5_FILE" | 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" "$MD5_FILE"
rm -f /tmp/baidu_chunk_${UPLOAD_ID}_* 2>/dev/null || true
# 计算耗时
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 ""