#!/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" # 需要排除的目录:依赖、编译产物、缓存(减小备份体积) ZIP_EXCLUDES=( "${PROJECT_NAME}/.git/*" "*/node_modules/*" "*/.next/*" "*/dist/*" "*/.cache/*" "*/.turbo/*" "*/coverage/*" "*.DS_Store" ) # 先统计文件总数(与上面的排除规则保持一致,保证进度条准确) TOTAL_FILES=$(find "$PROJECT_NAME" \ -type d \( -name node_modules -o -name .git -o -name .next -o -name dist -o -name .cache -o -name .turbo -o -name coverage \) -prune \ -o -type f -print | wc -l | tr -d ' ') echo " 📊 共 ${TOTAL_FILES} 个文件(已排除依赖/编译/缓存)" # 打包并实时显示百分比 COUNTER=0 zip -r "$ZIP_FILE" "$PROJECT_NAME" -x "${ZIP_EXCLUDES[@]}" 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/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/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 ""