2026年8月31日 星期一

Python一下:自動備份水井村USR資料夾——日期、日誌與ZIP封存

《Python一下:從風土資料到智慧生活》第 20 篇

自動備份水井村USR資料夾——日期、日誌與ZIP封存

整合標準函式庫,建立可預覽、可驗證、可測試還原的地方資料備份流程。

CH11 標準函式庫ZIP備份還原演練
學習目標
完成本篇後,你能運用 datetimepathlibloggingzipfilehashlibjson,建立時間戳備份檔、排除敏感資料、輸出清冊、驗證ZIP與SHA-256,並安全測試還原。

一、壓縮檔不等於完整備份

把資料夾壓成ZIP只是備份流程的一步。真正可用的備份還要知道來源、建立時間、包含哪些檔案、是否完整、存放在哪裡,以及能否成功還原。

層次問題本篇做法
建立是否遺漏或覆蓋?先預覽、時間戳命名、排他建立
驗證ZIP是否損壞?testzip()與SHA-256
保存是否只有同一顆硬碟一份?另存獨立媒體或受控儲存
還原真的能取回資料嗎?定期還原到隔離測試目錄
安全界線:備份可能集中逐字稿、照片、錄音、養殖營運資料與設備設定,因此風險比單一檔案更高。不要把密碼、Token、私鑰、未授權原始媒體及個資一股腦放入ZIP;一般ZIP加密也不應視為敏感資料的充分保護。

二、使用時間戳建立唯一檔名

from datetime import datetime


def make_backup_name(project_name, now=None):
    now = now or datetime.now()
    timestamp = now.strftime("%Y%m%d_%H%M%S")
    return f"{project_name}_{timestamp}.zip"


print(make_backup_name("shuijing_usr"))

now 設成可傳入參數,測試時可固定時間,避免測試結果每秒不同。

三、先定義排除規則

from pathlib import Path

EXCLUDED_NAMES = {
    ".env",
    "secrets.json",
    "private_key.pem",
}
EXCLUDED_PARTS = {
    "__pycache__",
    ".git",
    "待授權原始媒體",
}


def should_exclude(path):
    path = Path(path)
    if path.name in EXCLUDED_NAMES:
        return True
    return any(part in EXCLUDED_PARTS for part in path.parts)

排除清單須依專案實際資料政策共同確認,不能假設所有叫「照片」或「逐字稿」的內容都有相同公開權限。

四、建立備份預覽清單

from pathlib import Path


def collect_backup_files(source):
    source = Path(source).resolve()
    if not source.is_dir():
        raise NotADirectoryError(f"來源不是資料夾:{source}")

    files = []
    for path in sorted(source.rglob("*")):
        relative = path.relative_to(source)
        if path.is_file() and not should_exclude(relative):
            files.append((path, relative))
    return files


def preview_backup(files):
    total_size = sum(path.stat().st_size for path, _ in files)
    print("預計檔案數:", len(files))
    print("預計位元組數:", total_size)
    for _, relative in files:
        print("-", relative)
先預覽的理由:若清單出現憑證、未授權錄音、資料庫備份或不必要的大型暫存檔,應先修正規則,不要先壓縮再處理。

五、建立SHA-256清冊

import hashlib


def sha256_file(path, chunk_size=1024 * 1024):
    digest = hashlib.sha256()
    with path.open("rb") as file:
        while chunk := file.read(chunk_size):
            digest.update(chunk)
    return digest.hexdigest()


def build_manifest(files):
    return [
        {
            "path": relative.as_posix(),
            "size": source.stat().st_size,
            "sha256": sha256_file(source),
        }
        for source, relative in files
    ]

六、建立不覆蓋的ZIP備份

import json
import zipfile
from pathlib import Path


def create_backup(source, destination, confirmed=False):
    if not confirmed:
        raise PermissionError("尚未確認預覽清單")

    source = Path(source).resolve()
    destination = Path(destination).resolve()
    files = collect_backup_files(source)
    manifest = build_manifest(files)

    destination.parent.mkdir(parents=True, exist_ok=True)
    with destination.open("xb") as raw_file:
        with zipfile.ZipFile(
            raw_file, "w", compression=zipfile.ZIP_DEFLATED
        ) as archive:
            for file_path, relative in files:
                archive.write(file_path, arcname=relative.as_posix())

            manifest_text = json.dumps(
                manifest, ensure_ascii=False, indent=2
            )
            archive.writestr("backup_manifest.json", manifest_text)

    return destination

xb 先取得排他建立的檔案物件,再交給 ZipFile,避免同名備份被覆蓋。ZIP內使用相對路徑,不保存來源電腦的絕對路徑。

七、記錄備份過程

import logging
from pathlib import Path


def configure_logging(log_path):
    log_path = Path(log_path)
    log_path.parent.mkdir(parents=True, exist_ok=True)
    logging.basicConfig(
        filename=log_path,
        level=logging.INFO,
        encoding="utf-8",
        format="%(asctime)s | %(levelname)s | %(message)s",
        force=True,
    )


configure_logging("備份日誌/backup.log")
logging.info("備份流程開始")
logging.info("來源已完成預覽")

日誌記錄時間、結果與錯誤即可,不應寫入Token、密碼、完整個資或不必要的敏感檔名。

八、驗證ZIP與備份檔雜湊

import zipfile
from pathlib import Path


def verify_backup(zip_path):
    zip_path = Path(zip_path)
    with zipfile.ZipFile(zip_path, "r") as archive:
        bad_file = archive.testzip()
        names = archive.namelist()

    return {
        "ZIP測試": "通過" if bad_file is None else f"損壞:{bad_file}",
        "項目數": len(names),
        "備份SHA256": sha256_file(zip_path),
    }

testzip() 會讀取ZIP成員並檢查CRC,但備份本身的SHA-256仍應另外保存,以便日後比對整個封存檔是否改變。

九、安全檢查ZIP成員路徑

還原外部或來源不明的ZIP前,須防範成員路徑逃出目標資料夾。以下先檢查,不直接解壓縮。

from pathlib import Path, PurePosixPath
import zipfile


def validate_archive_names(zip_path):
    with zipfile.ZipFile(zip_path, "r") as archive:
        for name in archive.namelist():
            member = PurePosixPath(name)
            if member.is_absolute() or ".." in member.parts:
                raise ValueError(f"不安全的ZIP路徑:{name}")
            if member.parts and ":" in member.parts[0]:
                raise ValueError(f"疑似磁碟路徑:{name}")
    return True

十、還原到全新的隔離資料夾

from pathlib import Path
import zipfile


def restore_for_test(zip_path, target):
    zip_path = Path(zip_path)
    target = Path(target)
    validate_archive_names(zip_path)

    target.mkdir(parents=True, exist_ok=False)
    target_root = target.resolve()

    with zipfile.ZipFile(zip_path, "r") as archive:
        for info in archive.infolist():
            output = (target_root / info.filename).resolve()
            if not output.is_relative_to(target_root):
                raise ValueError(f"還原路徑越界:{info.filename}")
        archive.extractall(target_root)

    return target_root
還原規則:只還原到全新的隔離目錄,不直接蓋回正式資料夾。完成後比較清冊、抽查檔案,再由負責人決定是否進行正式復原。

十一、比對清冊

import json
from pathlib import Path


def verify_restored_manifest(restored_folder):
    restored_folder = Path(restored_folder)
    manifest_path = restored_folder / "backup_manifest.json"
    manifest = json.loads(
        manifest_path.read_text(encoding="utf-8")
    )

    results = []
    for item in manifest:
        path = restored_folder / item["path"]
        valid = (
            path.is_file()
            and path.stat().st_size == item["size"]
            and sha256_file(path) == item["sha256"]
        )
        results.append({"path": item["path"], "valid": valid})
    return results

十二、整合主流程

from pathlib import Path


def main():
    source = Path("shuijing_usr_demo")
    backup_folder = Path("backup_output")
    backup_name = make_backup_name("shuijing_usr")
    destination = backup_folder / backup_name

    files = collect_backup_files(source)
    preview_backup(files)

    answer = input("確認以上清單後輸入 YES:").strip()
    if answer != "YES":
        print("已取消,沒有建立備份。")
        return

    created = create_backup(
        source, destination, confirmed=True
    )
    print("已建立:", created)
    print(verify_backup(created))


if __name__ == "__main__":
    main()

互動確認只是教學防線,正式排程不可假裝有人確認;自動化前應完成固定來源、權限、排除規則、容量監控、通知及還原演練。

十三、3-2-1備份觀念

可採「至少3份資料、2種不同媒體、1份異地」作為基本思考,但仍須依組織政策、資料敏感度與法規調整。同步不是完整備份:誤刪、勒索軟體或錯誤同步也可能一起傳播。

十四、常見錯誤檢查表

現象原因修正
ZIP建立了卻無法還原未做還原演練定期還原到隔離目錄並驗證清冊
備份含.env或私鑰沒有排除與預覽先建立規則並人工檢查清單
舊備份被蓋掉固定檔名與覆寫模式時間戳加xb排他建立
備份與原始資料一起故障放在同一裝置建立獨立媒體與異地副本
外部ZIP解壓後污染其他資料夾未檢查成員路徑驗證路徑並還原到新隔離目錄

十五、USR實作挑戰

挑戰A|安全預覽
建立含教材、CSV、照片及假密鑰檔的教學資料夾,確認預覽清單會排除敏感檔且不產生ZIP。
挑戰B|損壞偵測
建立小型備份後記錄SHA-256,修改ZIP副本的一個位元組,觀察摘要與ZIP測試結果。
挑戰C|還原演練
將備份還原到全新資料夾,逐筆比對清冊,寫出成功率、失敗項目及改善措施。

十六、與生成式AI協作

請擔任Python備份與USR資料治理助教。
請檢查我的資料夾ZIP備份程式:
1. 先預覽,確認後才建立;
2. 排除.env、密鑰、Token與未授權媒體;
3. 使用時間戳與排他建立,不覆蓋舊備份;
4. 建立檔案大小與SHA-256清冊;
5. 驗證ZIP並安全檢查成員路徑;
6. 只能還原到全新隔離資料夾;
7. 必須設計還原演練與結果日誌。
請先列出風險,再提供測試案例。
本篇小結
備份的完成條件不是「看到ZIP檔」,而是內容經過預覽、封存後可驗證、被保存於獨立位置,並且確實通過還原演練。這正是把Python標準函式庫轉化為USR資料責任的實作。

沒有留言:

張貼留言