350 lines
14 KiB
Bash
Executable File
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 ""
|