2026年8月31日 星期一

Python一下:用SQLite建立水井村場域、感測器與巡查資料庫

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

用SQLite建立水井村場域、感測器與巡查資料庫

把散落的CSV整理成有關聯、有約束、可查詢,也能安全備份的場域資料。

CH13 關聯式資料庫SQLite參數化SQL
學習目標
完成本篇後,你能解釋資料表、列、欄位、主鍵與外鍵,使用Python內建 sqlite3 建立資料庫及關聯表,以參數化SQL新增、查詢、更新與刪除資料,使用交易維持一致性,並建立安全備份。

一、CSV不夠用的時候

CSV適合交換資料,但多座場域、感測器與大量巡查紀錄之間具有關係。若每列重複場域名稱和設備資訊,容易出現拼字不一、重複資料及刪改不同步。關聯式資料庫以表格和鍵值建立一致關係。

概念本篇例子
資料表sites、sensors、readings
主鍵每個場域或設備的唯一id
外鍵感測器所屬場域、讀值所屬感測器
約束名稱不可空白、pH基本理論範圍
資料治理提醒:教材使用「示範池A~G」匿名名稱,不保存真實養殖戶姓名、電話、精確座標、帳密或設備Token。資料庫檔案本身沒有自動等同完善權限控制;真實系統仍需最小權限、加密、備份、存取紀錄與資料保存政策。

二、連線並啟用外鍵

import sqlite3
from pathlib import Path


def connect_database(path):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)

    connection = sqlite3.connect(path, timeout=10)
    connection.execute("PRAGMA foreign_keys = ON")
    connection.row_factory = sqlite3.Row
    return connection


connection = connect_database("data/shuijing_demo.db")
print(connection.execute(
    "PRAGMA foreign_keys"
).fetchone()[0])

SQLite外鍵檢查應在每個連線明確啟用。Row 讓查詢結果可用欄位名稱讀取。

三、設計三張關聯表

SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS sites (
    site_id INTEGER PRIMARY KEY,
    site_code TEXT NOT NULL UNIQUE,
    display_name TEXT NOT NULL,
    active INTEGER NOT NULL DEFAULT 1
        CHECK (active IN (0, 1))
);

CREATE TABLE IF NOT EXISTS sensors (
    sensor_id INTEGER PRIMARY KEY,
    sensor_code TEXT NOT NULL UNIQUE,
    site_id INTEGER NOT NULL,
    kind TEXT NOT NULL,
    unit TEXT NOT NULL,
    FOREIGN KEY (site_id) REFERENCES sites(site_id)
        ON UPDATE CASCADE ON DELETE RESTRICT
);

CREATE TABLE IF NOT EXISTS readings (
    reading_id INTEGER PRIMARY KEY,
    sensor_id INTEGER NOT NULL,
    observed_at TEXT NOT NULL,
    value REAL,
    quality TEXT NOT NULL
        CHECK (quality IN ('valid', 'unknown', 'invalid')),
    note TEXT NOT NULL DEFAULT '',
    FOREIGN KEY (sensor_id) REFERENCES sensors(sensor_id)
        ON DELETE RESTRICT,
    UNIQUE (sensor_id, observed_at)
);
"""

with connection:
    connection.executescript(SCHEMA_SQL)

value 允許NULL,因為設備離線時「未知」不等於0;同時用quality標記資料品質。真實門檻不寫死在這個通用資料表。

四、新增七座匿名場域

sites = [
    (f"SITE-{number:02d}", f"示範池{letter}")
    for number, letter in enumerate("ABCDEFG", start=1)
]

with connection:
    connection.executemany(
        """
        INSERT INTO sites (site_code, display_name)
        VALUES (?, ?)
        ON CONFLICT(site_code) DO NOTHING
        """,
        sites,
    )

print("場域筆數:", connection.execute(
    "SELECT COUNT(*) FROM sites"
).fetchone()[0])

問號是參數佔位符。資料值永遠透過第二個參數傳入,不用f-string或字串拼接產生SQL。

五、新增感測器並取得場域主鍵

def find_site_id(connection, site_code):
    row = connection.execute(
        "SELECT site_id FROM sites WHERE site_code = ?",
        (site_code,),
    ).fetchone()
    if row is None:
        raise ValueError(f"找不到場域:{site_code}")
    return row["site_id"]


site_id = find_site_id(connection, "SITE-01")

with connection:
    connection.execute(
        """
        INSERT INTO sensors
            (sensor_code, site_id, kind, unit)
        VALUES (?, ?, ?, ?)
        ON CONFLICT(sensor_code) DO NOTHING
        """,
        ("TEMP-01", site_id, "temperature", "°C"),
    )

六、為何不能拼接SQL?

# 錯誤示範:不要把外部文字拼進SQL
unsafe_name = "示範池A"
unsafe_sql = (
    "SELECT * FROM sites WHERE display_name = '"
    + unsafe_name
    + "'"
)

# 正確方式:SQL與資料值分開
safe_row = connection.execute(
    "SELECT * FROM sites WHERE display_name = ?",
    (unsafe_name,),
).fetchone()

print(dict(safe_row) if safe_row else "找不到")

參數化SQL可防止資料被解讀成SQL語法,也能正確處理引號。表名、欄名不能用一般值參數代替;若需要動態欄位,必須從程式內固定允許清單選擇。

七、驗證後新增讀值

from datetime import datetime


def insert_reading(
    connection, sensor_code, observed_at,
    value, quality, note=""
):
    datetime.fromisoformat(observed_at)
    if quality not in {"valid", "unknown", "invalid"}:
        raise ValueError("不支援的quality")
    if quality == "unknown":
        value = None
    elif value is None:
        raise ValueError("valid或invalid必須保留原始數值")
    else:
        value = float(value)

    with connection:
        connection.execute(
            """
            INSERT INTO readings
                (sensor_id, observed_at, value, quality, note)
            SELECT sensor_id, ?, ?, ?, ?
            FROM sensors
            WHERE sensor_code = ?
            """,
            (
                observed_at, value, quality,
                str(note), sensor_code,
            ),
        )


insert_reading(
    connection, "TEMP-01",
    "2026-08-31T08:00:00+08:00",
    27.2, "valid",
)

八、確認INSERT真的找到感測器

前一個 INSERT...SELECT 若找不到sensor_code,可能插入0列而不報錯。可檢查游標的 rowcount:

def insert_unknown(connection, sensor_code, observed_at):
    with connection:
        cursor = connection.execute(
            """
            INSERT INTO readings
                (sensor_id, observed_at, value, quality, note)
            SELECT sensor_id, ?, NULL, 'unknown', ?
            FROM sensors
            WHERE sensor_code = ?
            """,
            (observed_at, "設備離線", sensor_code),
        )
        if cursor.rowcount != 1:
            raise ValueError(f"找不到感測器:{sensor_code}")

九、JOIN查詢巡查紀錄

rows = connection.execute(
    """
    SELECT
        s.site_code,
        s.display_name,
        e.sensor_code,
        e.kind,
        r.observed_at,
        r.value,
        r.quality
    FROM readings AS r
    JOIN sensors AS e ON e.sensor_id = r.sensor_id
    JOIN sites AS s ON s.site_id = e.site_id
    ORDER BY r.observed_at DESC, s.site_code
    """
).fetchall()

for row in rows:
    print(dict(row))

十、彙整有效資料

summary = connection.execute(
    """
    SELECT
        s.site_code,
        COUNT(r.reading_id) AS valid_count,
        ROUND(AVG(r.value), 2) AS average_value
    FROM sites AS s
    LEFT JOIN sensors AS e ON e.site_id = s.site_id
    LEFT JOIN readings AS r
        ON r.sensor_id = e.sensor_id
        AND r.quality = 'valid'
    GROUP BY s.site_id, s.site_code
    ORDER BY s.site_code
    """
).fetchall()

for row in summary:
    print(dict(row))

只對quality為valid的資料取平均,並同時呈現有效筆數。沒有有效資料時AVG為NULL,不應自動改成0。

十一、交易:全部成功或全部取消

def rename_site_and_sensor(
    connection, site_code, new_name,
    sensor_code, new_unit
):
    try:
        with connection:
            connection.execute(
                """
                UPDATE sites SET display_name = ?
                WHERE site_code = ?
                """,
                (new_name, site_code),
            )
            connection.execute(
                """
                UPDATE sensors SET unit = ?
                WHERE sensor_code = ?
                """,
                (new_unit, sensor_code),
            )
    except sqlite3.DatabaseError:
        print("交易失敗,已回復本次變更。")
        raise

with connection 正常結束時提交,出現例外時回復。批次匯入應以合理大小分批,不要每一列各自提交。

十二、更新與刪除要先預覽

def preview_inactive_sites(connection):
    return connection.execute(
        """
        SELECT site_id, site_code, display_name
        FROM sites
        WHERE active = 0
        ORDER BY site_code
        """
    ).fetchall()


for row in preview_inactive_sites(connection):
    print("待確認:", dict(row))

# 教材預設不執行DELETE
# 刪除前還要檢查外鍵、備份、權限與保存政策

十三、索引與查詢計畫

with connection:
    connection.execute(
        """
        CREATE INDEX IF NOT EXISTS
            idx_readings_sensor_time
        ON readings(sensor_id, observed_at)
        """
    )

plan = connection.execute(
    """
    EXPLAIN QUERY PLAN
    SELECT * FROM readings
    WHERE sensor_id = ? AND observed_at >= ?
    """,
    (1, "2026-08-01"),
).fetchall()

for row in plan:
    print(tuple(row))

索引可加速特定查詢,但會增加儲存與寫入成本。應依實際查詢及資料量建立,不是每個欄位都加索引。

十四、使用SQLite備份API

import sqlite3
from pathlib import Path


def backup_database(source_connection, target_path):
    target_path = Path(target_path)
    if target_path.exists():
        raise FileExistsError(f"備份已存在:{target_path}")
    target_path.parent.mkdir(parents=True, exist_ok=True)

    target = sqlite3.connect(target_path)
    try:
        source_connection.backup(target)
    finally:
        target.close()
    return target_path


# backup_database(connection, "backup/shuijing_demo_v1.db")

不要在資料庫正在寫入時直接複製檔案當作唯一備份流程。SQLite備份API可在連線層建立一致副本;備份後仍要驗證並做還原演練。

十五、完整性檢查與關閉連線

foreign_key_errors = connection.execute(
    "PRAGMA foreign_key_check"
).fetchall()
integrity = connection.execute(
    "PRAGMA integrity_check"
).fetchone()[0]

print("外鍵錯誤:", len(foreign_key_errors))
print("完整性:", integrity)

connection.close()

十六、常見錯誤檢查表

現象原因修正
刪除場域後留下孤兒設備未啟用外鍵每個連線PRAGMA foreign_keys=ON
含引號資料造成錯誤或注入拼接SQL所有值使用參數化SQL
設備離線被記為0混淆未知與零值value=NULL並記錄quality
批次中途留下半套資料未使用交易用with connection包住整批
備份檔無法還原未驗證與演練完整性檢查並測試還原

十七、USR實作挑戰

挑戰A|七座場域模型
為七座匿名教學場域各建立水溫與pH設備,加入正常、未知及無效讀值,查詢各場域的資料品質數量。
挑戰B|CSV匯入交易
讀取上一章CSV,整批驗證後寫入;故意加入重複時間,確認交易能回復且錯誤可追查。
挑戰C|權限與公開版
設計內部資料庫與公開摘要的欄位差異,公開版不得包含養殖戶、精確位置與設備識別資訊。

十八、與生成式AI協作

請擔任Python sqlite3與USR資料治理助教。
請檢查我的場域資料庫程式:
1. 啟用外鍵並設計主鍵、唯一鍵與CHECK;
2. 所有資料值必須參數化,不可拼接SQL;
3. 明確區分0、NULL、unknown與invalid;
4. 批次寫入使用交易,失敗時完整回復;
5. UPDATE與DELETE先預覽,教材預設不刪除;
6. 不保存個資、精確位置、密碼或Token;
7. 使用SQLite backup API並驗證還原。
請提供測試資料與失敗情境。
本篇小結
關聯式資料庫不只是把CSV搬進表格,而是透過主鍵、外鍵、約束、交易與參數化查詢維持資料品質。未知值不造假、敏感資料最小化、備份可還原,才是USR場域資料庫的底線。

Python一下:用Beautiful Soup整理地方活動網頁——負責任的HTML擷取

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

用Beautiful Soup整理地方活動網頁——負責任的HTML擷取

先用自建教學頁學會解析,再把robots、條款、頻率、個資與著作權放進真正的擷取流程。

CH12 HTMLBeautiful Soup負責任擷取
學習目標
完成本篇後,你能使用Beautiful Soup解析HTML,透過標籤、屬性與CSS選擇器擷取文字和連結,處理缺漏節點與相對網址,建立結構驗證及離線測試,並在擷取前檢查robots.txt、使用條款、授權與網站負載。

一、API、HTML與瀏覽器畫面不同

若網站提供正式API或資料下載,應優先使用;HTML主要為人閱讀,版面一改,選擇器就可能失效。有些頁面內容由JavaScript執行後才出現,requests取得的初始HTML未必包含瀏覽器畫面上的全部內容。

來源優點限制
官方API/下載檔欄位較明確、適合程式仍可能改版或限制頻率
靜態HTMLBeautiful Soup容易解析版面變動會影響選擇器
JavaScript動態內容互動豐富初始HTML可能沒有資料
負責任擷取原則:公開可瀏覽不等於可任意大量蒐集或再利用。先確認網站條款、robots.txt、資料授權與個資風險;使用可識別的User-Agent、低頻率、快取與有限重試。robots.txt是自動客戶端應尊重的規則,但不是存取授權,也不取代著作權、個資與契約判斷。

二、安裝Beautiful Soup

python -m pip install beautifulsoup4 requests

Beautiful Soup套件名稱是 beautifulsoup4,程式匯入名稱則是 bs4。本篇使用Python內建的 html.parser,減少額外環境需求。

三、先用自建HTML練習

from bs4 import BeautifulSoup

html_text = """
<section id="events">
  <article class="event-card" data-id="E001">
    <h2 class="title">水井三寶導覽體驗</h2>
    <time datetime="2026-10-13">2026年10月13日</time>
    <p class="place">教學示範場域</p>
    <a class="detail" href="/events/E001">活動資訊</a>
  </article>
</section>
"""

soup = BeautifulSoup(html_text, "html.parser")
print(soup.title)
print(soup.find("h2").get_text(strip=True))

這是自建教學資料,不代表真實活動公告。先在固定HTML上開發與測試,避免學習階段反覆請求外部網站。

四、find與find_all

first_card = soup.find("article", class_="event-card")
all_cards = soup.find_all("article", class_="event-card")

print(first_card.get("data-id"))
print("活動數:", len(all_cards))

Python的 class 是保留字,所以依CSS class搜尋時使用 class_。屬性不存在時,tag.get("name") 回傳 None,比直接索引更適合可缺欄資料。

五、CSS選擇器

Beautiful Soup官方文件說明,可使用CSS選擇器搜尋解析樹。select_one() 取第一個結果,select() 取全部。

card = soup.select_one("#events .event-card")
title = card.select_one(".title")
date_tag = card.select_one("time[datetime]")
detail = card.select_one("a.detail[href]")

print(title.get_text(" ", strip=True))
print(date_tag.get("datetime"))
print(detail.get("href"))

選擇器應鎖定有語意且相對穩定的id、class或 data-* 屬性;避免依賴「第3個div裡第2個span」等脆弱位置。

六、把卡片轉成字典

def required_text(card, selector, field_name):
    tag = card.select_one(selector)
    if tag is None:
        raise ValueError(f"缺少欄位:{field_name}")
    text = tag.get_text(" ", strip=True)
    if not text:
        raise ValueError(f"欄位空白:{field_name}")
    return text


def parse_event_card(card):
    event_id = (card.get("data-id") or "").strip()
    date_tag = card.select_one("time[datetime]")
    link_tag = card.select_one("a.detail[href]")

    if not event_id:
        raise ValueError("缺少活動id")
    if date_tag is None or link_tag is None:
        raise ValueError("缺少日期或詳情連結")

    return {
        "id": event_id,
        "title": required_text(card, ".title", "title"),
        "date": date_tag.get("datetime", "").strip(),
        "place": required_text(card, ".place", "place"),
        "href": link_tag.get("href", "").strip(),
    }

七、把相對網址轉成完整網址

from urllib.parse import urljoin, urlparse


def normalize_link(base_url, href):
    absolute = urljoin(base_url, href)
    parsed = urlparse(absolute)
    if parsed.scheme != "https":
        raise ValueError("只接受HTTPS連結")
    if parsed.hostname != "example.edu":
        raise ValueError("連結超出允許網域")
    return absolute


print(normalize_link(
    "https://example.edu/activities/",
    "/events/E001",
))

範例網域只供教學。若擷取結果含連結,應限制允許的協定與網域;不要直接把不可信連結交給後續下載程式。

八、批次解析與錯誤隔離

def parse_event_page(html_text, base_url):
    soup = BeautifulSoup(html_text, "html.parser")
    records = []
    errors = []
    used_ids = set()

    for index, card in enumerate(
        soup.select("#events article.event-card"),
        start=1,
    ):
        try:
            record = parse_event_card(card)
            if record["id"] in used_ids:
                raise ValueError(f"重複id:{record['id']}")
            record["url"] = normalize_link(
                base_url, record.pop("href")
            )
            used_ids.add(record["id"])
            records.append(record)
        except ValueError as error:
            errors.append({"序號": index, "原因": str(error)})

    return records, errors

九、先檢查robots.txt

Python標準函式庫 urllib.robotparser 可讀取robots.txt並詢問特定User-Agent是否可擷取網址。

from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser


def robots_allows(url, user_agent):
    robots_url = urljoin(url, "/robots.txt")
    parser = RobotFileParser()
    parser.set_url(robots_url)
    parser.read()
    return parser.can_fetch(user_agent, url)


# 真實使用前,另確認網站條款與資料授權
# allowed = robots_allows(
#     "https://example.edu/activities/",
#     "NFU-Python-USR-Education/1.0",
# )

robots.txt的錯誤回應與快取有標準化處理細節;正式爬蟲應依RFC 9309實作或使用成熟工具。即使允許擷取,也不代表取得內容再出版、商用或訓練AI的權利。

十、負責任地下載一個頁面

import requests


def fetch_html(url):
    headers = {
        "User-Agent": "NFU-Python-USR-Education/1.0"
    }
    response = requests.get(
        url,
        headers=headers,
        timeout=(3.05, 15),
    )
    response.raise_for_status()

    content_type = response.headers.get(
        "Content-Type", ""
    ).lower()
    if "text/html" not in content_type:
        raise ValueError("回應不是HTML")
    if len(response.content) > 2_000_000:
        raise ValueError("頁面超過教學程式允許大小")

    return response.text
不要在迴圈中無節制請求:抓取多頁前先取得網站許可與規則,加入間隔、快取、最大頁數、有限重試及停止機制。遇到429或Retry-After要尊重服務端要求。

十一、先保存快照,再離線解析

from datetime import datetime, timezone
from pathlib import Path


def save_html_snapshot(folder, html_text):
    timestamp = datetime.now(timezone.utc).strftime(
        "%Y%m%dT%H%M%SZ"
    )
    path = Path(folder) / f"activity_{timestamp}.html"
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("x", encoding="utf-8") as file:
        file.write(html_text)
    return path

保存快照可減少重複請求,也能重現當時的解析結果。快照若含個資或受著作權保護內容,必須限制保存範圍、期限與存取權限。

十二、HTML改版要被測試發現

def validate_page_structure(html_text):
    soup = BeautifulSoup(html_text, "html.parser")
    container = soup.select_one("#events")
    cards = soup.select("#events article.event-card")

    if container is None:
        raise ValueError("找不到活動容器,網頁可能改版")
    if not cards:
        raise ValueError("找不到活動卡片,請人工檢查")
    return len(cards)

不要把「擷取到0筆」自動解讀成「今天沒有活動」。這也可能是網站改版、權限變化或動態內容造成。

十三、動態網頁怎麼辦?

先使用瀏覽器開發工具確認資料是否來自官方JSON API;若有,依規範使用API。若網站明確禁止自動化或需登入,不要繞過。只有在取得許可且確有必要時,才考慮瀏覽器自動化;不要破解驗證碼、存取控制或反爬蟲機制。

十四、不要大量重製原文

擷取活動標題、日期、地點與來源連結,通常比複製整篇文章更符合資料最小化。摘要與再利用仍須依網站授權及著作權判斷;人物照片、電話、報名名單與訪談內容更需要個資及同意檢查。

十五、常見錯誤檢查表

現象原因修正
select_one回傳None選擇器錯誤或網頁改版先檢查節點,不直接.get_text()
瀏覽器看得到,requests卻沒有內容由JavaScript載入尋找官方API或取得許可後調整方案
相對連結無法開啟未與基底網址合併使用urljoin並驗證網域
網站回傳429請求過於頻繁停止、尊重Retry-After並降低頻率
擷取0筆就說沒有活動忽略結構變動結構驗證失敗時人工檢查

十六、USR實作挑戰

挑戰A|離線活動頁
自行建立含三張活動卡的HTML,其中一張缺日期、一張重複ID,輸出有效清單與錯誤報告。
挑戰B|擷取前檢核表
為預計使用的公開網站記錄API、robots.txt、條款、授權、個資、頻率、快取、停止條件與聯絡方式。
挑戰C|改版測試
修改教學HTML的class名稱,確認測試會明確失敗,而不是輸出空資料假裝成功。

十七、與生成式AI協作

請擔任Beautiful Soup與負責任網頁擷取助教。
請檢查我的地方活動擷取程式:
1. 有官方API時優先使用API;
2. 擷取前檢查robots.txt、條款與授權;
3. 使用可識別User-Agent、timeout、快取及低頻率;
4. 驗證Content-Type、大小與HTML結構;
5. 節點缺漏不可直接當成沒有活動;
6. 相對連結須urljoin並限制HTTPS允許網域;
7. 不蒐集不必要個資,不大量重製原文;
8. 不繞過登入、驗證碼、存取控制或反爬機制。
請先列風險,再提供離線測試HTML與測試案例。
本篇小結
Beautiful Soup能把HTML轉成可搜尋的樹,但負責任的擷取還包括來源選擇、網站規則、頻率、資料最小化、授權與改版偵測。技術上抓得到,不等於應該抓、可以公開。

Python一下:從XML讀懂智慧設備設定與地方導覽資料

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

從XML讀懂智慧設備設定與地方導覽資料

從樹狀標記中找出水井三寶多語內容與設備參數,同時守住格式、來源及操作安全。

CH12 XMLElementTree設定驗證
學習目標
完成本篇後,你能辨認XML元素、屬性、文字與階層,使用 ElementTree 解析、搜尋、建立及輸出XML,處理命名空間與解析錯誤,並把設備設定轉成「待人工確認」的安全資料。

一、XML與JSON有何不同?

兩者都能表示結構化資料。JSON常見於網頁API;XML在設備設定、標準文件、RSS及舊系統交換格式中仍很常見。

特性JSONXML
主要結構物件、陣列、基本型別元素樹、屬性、文字
資料型別數字、布林、null等讀出時多半先是字串
重複節點通常使用陣列重複同名元素
命名空間沒有內建機制可避免不同詞彙名稱衝突
設備安全原則:XML格式正確不代表參數安全。教學程式只讀取、驗證並產生預覽;不可把外部XML直接送往水泵、曝氣機或其他設備。實際套用須確認來源、版本、簽章或完整性、場域規則及人工授權。

二、看懂XML結構

<guide version="1.0">
  <item id="white-horse" language="zh-Hant">
    <title>白馬</title>
    <status>待社區確認</status>
  </item>
  <item id="turtle" language="zh-Hant">
    <title>烏龜</title>
    <status>待社區確認</status>
  </item>
</guide>

guide 是根元素;item 是子元素;id 與 language 是屬性;title 元素內的「白馬」是文字內容。XML必須只有一個根元素,開始與結束標籤要正確巢狀。

三、從字串解析XML

import xml.etree.ElementTree as ET

xml_text = """
<guide version="1.0">
  <item id="marriage-flower" language="zh-Hant">
    <title>姻緣花</title>
    <status>待社區確認</status>
  </item>
</guide>
"""

root = ET.fromstring(xml_text)
print("根元素:", root.tag)
print("版本:", root.get("version"))

fromstring() 回傳根元素。XML屬性可用 get() 取得,找不到時回傳 None。

四、搜尋元素與讀取文字

for item in root.findall("item"):
    item_id = item.get("id")
    language = item.get("language")
    title = item.findtext("title", default="").strip()
    status = item.findtext("status", default="").strip()

    print(item_id, language, title, status)
方法用途
find()尋找第一個符合的子元素
findall()取得所有符合元素
findtext()直接取得第一個符合元素的文字
iter()走訪目前元素下所有指定節點

五、從檔案安全解析

import xml.etree.ElementTree as ET
from pathlib import Path


def load_xml(path, max_bytes=2_000_000):
    path = Path(path)
    if not path.is_file():
        raise FileNotFoundError(f"找不到XML:{path}")
    if path.stat().st_size > max_bytes:
        raise ValueError("XML超過教學程式允許大小")

    try:
        tree = ET.parse(path)
    except ET.ParseError as error:
        raise ValueError(f"XML格式錯誤:{error}") from error
    return tree

檔案大小限制可降低意外資源消耗,但不是完整的XML安全防護。若要解析來源不可信的XML,應採用專為防禦XML攻擊設計的函式庫(如defusedxml)及環境資源限制。

六、把導覽元素轉成字典

def parse_guide_item(item):
    item_id = (item.get("id") or "").strip()
    language = (item.get("language") or "").strip()
    title = item.findtext("title", default="").strip()
    status = item.findtext("status", default="").strip()

    missing = [
        name
        for name, value in {
            "id": item_id,
            "language": language,
            "title": title,
            "status": status,
        }.items()
        if not value
    ]
    if missing:
        raise ValueError("缺少內容:" + "、".join(missing))

    return {
        "id": item_id,
        "language": language,
        "title": title,
        "status": status,
    }

XML沒有自動幫你驗證業務規則。讀取後仍須確認必要屬性、空白文字、重複ID及可接受的語言代碼。

七、批次處理並隔離錯誤

def parse_guide(root):
    records = []
    errors = []
    used_ids = set()

    for index, item in enumerate(root.findall("item"), start=1):
        try:
            record = parse_guide_item(item)
            if record["id"] in used_ids:
                raise ValueError(f"重複id:{record['id']}")
            used_ids.add(record["id"])
            records.append(record)
        except ValueError as error:
            errors.append({"序號": index, "原因": str(error)})

    return records, errors

八、解析智慧設備設定

device_xml = """
<device-config version="1.0" apply="preview-only">
  <device id="TEMP-01" type="temperature">
    <enabled>true</enabled>
    <sample-seconds>60</sample-seconds>
  </device>
</device-config>
"""

config_root = ET.fromstring(device_xml)
device = config_root.find("device")

device_id = device.get("id")
device_type = device.get("type")
enabled_text = device.findtext("enabled", default="").strip().lower()
sample_seconds = int(
    device.findtext("sample-seconds", default="")
)

print(device_id, device_type, enabled_text, sample_seconds)

XML文字須自行轉成 int 或布林值。不要使用 bool("false"),因為非空字串會得到 True。

九、嚴格轉換布林與整數

def parse_boolean(text, field_name):
    normalized = str(text).strip().lower()
    if normalized == "true":
        return True
    if normalized == "false":
        return False
    raise ValueError(f"{field_name}只能是true或false")


def parse_positive_int(text, field_name, maximum):
    value = int(str(text).strip())
    if not 1 <= value <= maximum:
        raise ValueError(
            f"{field_name}必須介於1到{maximum}"
        )
    return value


enabled = parse_boolean(enabled_text, "enabled")
interval = parse_positive_int(
    sample_seconds, "sample-seconds", 86400
)
print(enabled, interval)
上限只是教學防線:86400秒並非真實設備管理建議。實際採樣週期與控制參數要由設備規格、場域人員與專業需求共同決定。

十、命名空間不是多餘的括號

XML命名空間避免不同標準使用相同標籤時衝突。ElementTree搜尋時要提供前綴對照。

import xml.etree.ElementTree as ET

xml_text = """
<g:guide xmlns:g="https://example.edu/guide">
  <g:item id="white-horse">
    <g:title>白馬</g:title>
  </g:item>
</g:guide>
"""

root = ET.fromstring(xml_text)
ns = {"g": "https://example.edu/guide"}

for item in root.findall("g:item", ns):
    title = item.findtext("g:title", default="", namespaces=ns)
    print(item.get("id"), title)

範例網址只是命名空間識別字,不代表瀏覽器一定能開啟該網址。

十一、建立XML導覽檔

import xml.etree.ElementTree as ET
from pathlib import Path


def build_guide_xml(records):
    root = ET.Element("guide", {"version": "1.0"})
    for record in records:
        item = ET.SubElement(
            root,
            "item",
            {
                "id": record["id"],
                "language": record["language"],
            },
        )
        ET.SubElement(item, "title").text = record["title"]
        ET.SubElement(item, "status").text = record["status"]

    tree = ET.ElementTree(root)
    ET.indent(tree, space="  ")
    return tree


records = [
    {"id": "white-horse", "language": "zh-Hant",
     "title": "白馬", "status": "待社區確認"},
]
tree = build_guide_xml(records)

由ElementTree建立元素時,特殊字元會被正確跳脫;不要用字串拼接XML,否則文字中的 &、< 等符號容易破壞格式。

十二、不覆蓋地輸出XML

from pathlib import Path


def write_xml_exclusive(tree, path):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)

    xml_bytes = ET.tostring(
        tree.getroot(),
        encoding="utf-8",
        xml_declaration=True,
    )
    with path.open("xb") as file:
        file.write(xml_bytes)
    return path


# write_xml_exclusive(tree, "導覽資料/水井三寶_v1.xml")

十三、設定變更比較

def compare_device_config(old_config, new_config):
    changes = {}
    keys = sorted(old_config.keys() | new_config.keys())

    for key in keys:
        old_value = old_config.get(key)
        new_value = new_config.get(key)
        if old_value != new_value:
            changes[key] = {
                "舊值": old_value,
                "新值": new_value,
            }
    return changes

變更報告應先交由設備負責人確認,再經測試環境驗證;不可由XML解析程式直接套用到正式場域。

十四、常見錯誤檢查表

現象原因修正
ParseError標籤未關閉或巢狀錯誤檢查錯誤位置與原始來源
find找不到元素路徑錯誤或有命名空間確認階層並提供ns對照
false被轉成True使用bool("false")明確比對true/false
中文輸出成亂碼編碼不一致輸出UTF-8並寫入XML宣告
設定檔直接控制設備缺少驗證與核准層只產生預覽與變更報告

十五、USR實作挑戰

挑戰A|水井三寶多語XML
為白馬、烏龜與姻緣花建立華語、台語文字節點,加入確認狀態與授權欄位,找出缺漏及重複ID。
挑戰B|設備設定預覽
解析三個匿名設備設定,比較新舊版本,輸出變更報告,但不連接或操作任何真實設備。
挑戰C|錯誤XML測試
準備缺標籤、缺屬性、錯誤布林、過大整數與命名空間版本,確認程式能提供清楚錯誤。

十六、與生成式AI協作

請擔任Python XML與智慧設備設定安全助教。
請檢查我的ElementTree程式:
1. 驗證根元素、版本、必要屬性與文字;
2. 數字與布林必須嚴格轉型;
3. 支援XML命名空間;
4. 不以字串拼接XML;
5. 輸出UTF-8且不得覆蓋舊檔;
6. 不可信XML須限制大小並說明安全解析方案;
7. 設定只能產生預覽和變更報告,不可直接控制設備。
請提供正常與故障測試資料。
本篇小結
XML是一棵帶有元素、屬性與文字的樹。解析只是第一步;真正可靠的流程還要驗證結構、型別、命名空間、來源與版本,設備設定更必須經人工核准。

Python一下:把YouBike JSON變成可分析資料——欄位驗證與快照保存

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

把YouBike JSON變成可分析資料——欄位驗證與快照保存

JSON能解析只是起點;資料還要能被理解、驗證、追蹤版本,才適合統計與比較。

CH12 JSON資料品質快照比較
學習目標
完成本篇後,你能理解JSON與Python型別的對應,使用 json.loads()、dumps()、load() 與 dump(),驗證欄位與型別、處理欄位別名、建立資料品質報告,並比較兩個YouBike快照的站點變化。

一、JSON是資料交換格式

JSON常用於網頁API與裝置資料交換。它是文字格式,不是Python程式碼,也不應使用 eval() 解析。

JSONPython注意
objectdict鍵通常是字串
arraylist元素可有不同型別
stringstr數字也可能被來源寫成字串
numberint或float金融或高精度資料另有考量
true/falseTrue/False大小寫不同
nullNone缺值不等於0或空字串
資料語意原則:0可能表示真的沒有車,None可能表示未知,缺少欄位可能代表Schema改變;三者不能自動視為相同。若把未知補成0,統計與決策都會被扭曲。

二、字串與Python物件互轉

import json

json_text = """
{
  "站點": "示範站A",
  "可借車輛": 8,
  "營運中": true,
  "備註": null
}
"""

data = json.loads(json_text)
print(data)
print(type(data["可借車輛"]))
print(data["備註"] is None)

restored = json.dumps(data, ensure_ascii=False, indent=2)
print(restored)

loads 的s可理解為string;dumps 產生JSON字串。ensure_ascii=False 讓中文直接顯示,indent=2 方便人工閱讀。

三、檔案與Python物件互轉

import json
from pathlib import Path

path = Path("教學快照.json")
sample = [{"sno": "A001", "sna": "示範站A"}]

with path.open("x", encoding="utf-8") as file:
    json.dump(sample, file, ensure_ascii=False, indent=2)

with path.open("r", encoding="utf-8") as file:
    loaded = json.load(file)

print(loaded)

load、dump 直接處理檔案物件。重要快照使用 x 模式,避免覆蓋歷史資料。

四、不要假設所有來源欄位永遠相同

不同縣市或不同版本可能以 available_rent_bikes、sbi 等名稱表示可借車輛。別名可以相容已知版本,但若同時出現衝突值,應視為錯誤。

FIELD_ALIASES = {
    "站點編號": ("sno",),
    "站點名稱": ("sna",),
    "行政區": ("sarea",),
    "可借車輛": ("available_rent_bikes", "sbi"),
    "可還空位": ("available_return_bikes", "bemp"),
    "更新時間": ("updateTime", "mday"),
}


def read_alias(record, aliases):
    found = [
        (name, record[name])
        for name in aliases
        if name in record and record[name] is not None
    ]
    if not found:
        raise KeyError(f"找不到欄位:{aliases}")

    values = {str(value) for _, value in found}
    if len(values) > 1:
        raise ValueError(f"別名欄位值衝突:{found}")
    return found[0][1]

五、建立正規化函式

def parse_nonnegative_int(value, field_name):
    if value is None or str(value).strip() == "":
        raise ValueError(f"{field_name}是未知值")

    number = int(value)
    if number < 0:
        raise ValueError(f"{field_name}不可為負數")
    return number


def normalize_station(record):
    station = {
        name: read_alias(record, aliases)
        for name, aliases in FIELD_ALIASES.items()
    }

    station["站點編號"] = str(station["站點編號"]).strip()
    station["站點名稱"] = str(station["站點名稱"]).strip()
    station["行政區"] = str(station["行政區"]).strip()
    station["可借車輛"] = parse_nonnegative_int(
        station["可借車輛"], "可借車輛"
    )
    station["可還空位"] = parse_nonnegative_int(
        station["可還空位"], "可還空位"
    )
    station["更新時間"] = str(station["更新時間"]).strip()

    if not station["站點編號"] or not station["站點名稱"]:
        raise ValueError("站點編號與名稱不可空白")
    return station

六、Schema版本:告訴未來的程式如何解讀

官方資料有自己的結構,你的「正規化快照」也應明確記錄版本。版本改變時,可保留舊解析器或撰寫轉換程式。

SNAPSHOT_SCHEMA = "nfu-youbike-snapshot/1.0"


def validate_snapshot(snapshot):
    if snapshot.get("schema") != SNAPSHOT_SCHEMA:
        raise ValueError("不支援的快照Schema版本")
    if not isinstance(snapshot.get("stations"), list):
        raise TypeError("stations必須是串列")
    if not isinstance(snapshot.get("quality"), dict):
        raise TypeError("quality必須是字典")
    return snapshot

七、批次轉換與資料品質報告

from collections import Counter


def normalize_dataset(raw_records):
    valid = []
    errors = []
    station_ids = Counter()

    for index, record in enumerate(raw_records, start=1):
        try:
            if not isinstance(record, dict):
                raise TypeError("單筆資料必須是字典")
            station = normalize_station(record)
            station_ids[station["站點編號"]] += 1
            valid.append(station)
        except (KeyError, TypeError, ValueError) as error:
            errors.append({"序號": index, "原因": str(error)})

    duplicate_ids = sorted(
        key for key, count in station_ids.items() if count > 1
    )
    quality = {
        "原始筆數": len(raw_records),
        "有效筆數": len(valid),
        "錯誤筆數": len(errors),
        "重複站點編號": duplicate_ids,
    }
    return valid, errors, quality

重複編號不能隨便保留第一筆或最後一筆;應先檢查是否為來源重複、版本衝突或站點資料更新方式造成。

八、計算資料完整率

def completeness_report(records, fields):
    total = len(records)
    report = {}

    for field in fields:
        present = sum(
            1
            for record in records
            if field in record
            and record[field] is not None
            and str(record[field]).strip() != ""
        )
        report[field] = {
            "有值筆數": present,
            "完整率": round(present / total, 4) if total else None,
        }
    return report

完整率只表示欄位有值,不代表值正確、即時或合理。資料品質至少還包含唯一性、一致性、時效性與來源可信度。

九、建立可追溯的JSON快照

import json
from datetime import datetime, timezone
from pathlib import Path


def save_snapshot(path, source_url, stations, errors, quality):
    snapshot = {
        "schema": SNAPSHOT_SCHEMA,
        "source": {
            "url": source_url,
            "fetched_at_utc": datetime.now(
                timezone.utc
            ).isoformat(),
        },
        "quality": quality,
        "errors": errors,
        "stations": stations,
    }

    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("x", encoding="utf-8") as file:
        json.dump(snapshot, file, ensure_ascii=False, indent=2)
    return path
快照不是即時資料:檔案一旦保存就是某個時間點的歷史紀錄。教學或展示時必須呈現擷取時間,不可讓使用者誤以為快照仍是最新車位資訊。

十、讀取快照時再次驗證

import json
from pathlib import Path


def load_snapshot(path):
    path = Path(path)
    try:
        with path.open("r", encoding="utf-8") as file:
            snapshot = json.load(file)
    except FileNotFoundError:
        raise FileNotFoundError(f"找不到快照:{path}")
    except json.JSONDecodeError as error:
        raise ValueError(
            f"JSON格式錯誤,第{error.lineno}行:{error.msg}"
        ) from error

    if not isinstance(snapshot, dict):
        raise TypeError("快照最外層必須是字典")
    return validate_snapshot(snapshot)

十一、比較兩個快照

def index_by_station_id(stations):
    result = {}
    for station in stations:
        station_id = station["站點編號"]
        if station_id in result:
            raise ValueError(f"重複站點編號:{station_id}")
        result[station_id] = station
    return result


def compare_snapshots(old_stations, new_stations):
    old_map = index_by_station_id(old_stations)
    new_map = index_by_station_id(new_stations)

    old_ids = old_map.keys()
    new_ids = new_map.keys()
    added = sorted(new_ids - old_ids)
    removed = sorted(old_ids - new_ids)
    changed = []

    for station_id in sorted(old_ids & new_ids):
        old = old_map[station_id]
        new = new_map[station_id]
        if (
            old["可借車輛"] != new["可借車輛"]
            or old["可還空位"] != new["可還空位"]
        ):
            changed.append({
                "站點編號": station_id,
                "舊可借": old["可借車輛"],
                "新可借": new["可借車輛"],
                "舊可還": old["可還空位"],
                "新可還": new["可還空位"],
            })

    return {"新增": added, "移除": removed, "車位變化": changed}

站點「移除」也可能只是新快照缺資料,不一定代表實體站點撤除。解讀變化時要同時看品質報告與來源時間。

十二、產生行政區摘要

from collections import defaultdict


def summarize_by_area(stations):
    areas = defaultdict(
        lambda: {"站點數": 0, "可借車輛": 0, "可還空位": 0}
    )

    for station in stations:
        area = areas[station["行政區"]]
        area["站點數"] += 1
        area["可借車輛"] += station["可借車輛"]
        area["可還空位"] += station["可還空位"]

    return dict(sorted(areas.items()))

摘要反映快照內有效資料,不應直接推論長期交通需求。若要進行趨勢分析,必須有規律、合法且時間間隔一致的歷史資料。

十三、常見錯誤檢查表

現象原因修正
用eval解析JSON把資料誤當程式碼只使用json.loads或json.load
缺值被算成0混淆未知與真實零值保留None或列為錯誤
欄位改名後全數失敗未管理Schema與別名記錄版本,有限度支援已知別名
快照被當成即時資料未顯示擷取時間保存來源與UTC時間
站點消失就判斷撤站忽略資料缺漏先檢查品質報告及後續快照

十四、USR實作挑戰

挑戰A|故障資料集
建立包含null、空字串、負數、重複編號與衝突別名的資料,確認品質報告能逐項揭露。
挑戰B|兩個時間點
保存兩份教學快照,比較新增、缺少及車位變化;報告中同時顯示兩次擷取時間與錯誤筆數。
挑戰C|轉用養殖API
把Schema改為場域、感測器、數值、單位及時間,明確區分0、None、缺欄與過期資料。

十五、與生成式AI協作

請擔任Python JSON與資料品質助教。
請檢查我的YouBike快照程式:
1. 禁止eval,只使用json模組;
2. 明確區分0、None、空字串與缺少欄位;
3. 支援已知欄位別名,但衝突時必須報錯;
4. 保存Schema版本、來源URL與UTC擷取時間;
5. 產生有效、錯誤、重複與完整率報告;
6. 比較快照時先確認站點編號唯一;
7. 不把單次快照推論為長期趨勢。
請提供正常與故障測試資料。
本篇小結
JSON解析只回答「文字能否轉成物件」,資料驗證才回答「這些物件能否被正確使用」。加入Schema、來源、時間、品質與版本,才能讓開放資料成為可追溯的分析材料。

Python一下:從網址取得YouBike與開放資料——requests安全入門

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

從網址取得YouBike與開放資料——requests安全入門

學會向公開資料服務提出請求、看懂回應,並在網路、格式或欄位改變時安全停止。

CH12 網頁資料requestsYouBike JSON
學習目標
完成本篇後,你能使用 requests.get() 取得JSON,理解HTTP狀態碼、標頭與逾時,使用 raise_for_status() 和例外處理,驗證資料結構,建立節制的重試策略,並保存來源與擷取時間。

一、這次使用哪一份資料?

本篇採用臺北市政府交通局提供的「YouBike2.0臺北市公共自行車即時資訊」作為教學案例。政府資料開放平臺列出的資料格式為JSON,欄位包括站點名稱、行政區、更新時間、可借車輛數、可還空位數及經緯度等,授權方式為政府資料開放授權條款第1版。

即時資料會改變:資料值、端點、欄位名稱、更新頻率及服務條件都可能調整。正式系統應把網址設成設定值、定期查看官方說明頁,並為欄位缺漏與服務中斷準備安全流程。

二、requests不是標準函式庫

requests 是第三方套件。在Thonny或本機環境可安裝;Google Colab通常已具備,但仍應先確認。

python -m pip install requests

Python也提供標準函式庫 urllib.request;本篇先使用介面較直觀的requests,之後再比較兩者。

三、第一個GET請求

import requests

URL = (
    "https://tcgbusfs.blob.core.windows.net/"
    "dotapp/youbike/v2/youbike_immediate.json"
)

response = requests.get(URL, timeout=(3.05, 15))
response.raise_for_status()
data = response.json()

print(type(data))
print("資料筆數:", len(data))

timeout=(3.05, 15) 分別限制建立連線與等待資料的時間。依requests官方文件,若未明確設定timeout,請求預設不會自動逾時;timeout也不是整份下載的總秒數,而是網路操作等待限制。

四、回應物件裡有什麼?

print("狀態碼:", response.status_code)
print("內容類型:", response.headers.get("Content-Type"))
print("回應位元組:", len(response.content))
print("最終網址:", response.url)
狀態碼範圍一般意義程式行動
2xx請求成功仍要驗證格式與欄位
3xx重新導向確認最終網址是否符合預期
4xx請求、權限或資源問題不要盲目重試,先檢查設定
5xx伺服器暫時或內部問題可有限度重試並保留紀錄

五、完整處理網路與JSON錯誤

import requests


def fetch_json(url):
    try:
        response = requests.get(
            url,
            timeout=(3.05, 15),
            headers={"User-Agent": "NFU-Python-USR-Education/1.0"},
        )
        response.raise_for_status()
        return response.json()
    except requests.Timeout:
        print("資料服務逾時,請稍後再試。")
    except requests.HTTPError as error:
        print("HTTP狀態錯誤:", error)
    except requests.ConnectionError:
        print("無法連線,請檢查網路或資料來源。")
    except requests.JSONDecodeError:
        print("回應不是有效JSON,請確認服務格式。")
    except requests.RequestException as error:
        print("其他請求錯誤:", error)
    return None

不要用空白 except: 隱藏問題。錯誤發生時也不要把上一次資料假裝成最新資料;若使用快取,畫面須清楚標示擷取時間與資料可能過期。

六、先確認最外層資料型別

def validate_dataset(data):
    if not isinstance(data, list):
        raise TypeError("預期JSON最外層是串列")
    if not data:
        raise ValueError("資料集是空串列")
    if not all(isinstance(item, dict) for item in data):
        raise TypeError("每一筆站點資料必須是字典")
    return data

response.json() 成功只表示回應可解析為JSON,不代表內容一定符合教學程式預期。

七、欄位名稱也要驗證

REQUIRED_FIELDS = {
    "sno",
    "sna",
    "sarea",
    "available_rent_bikes",
    "available_return_bikes",
    "updateTime",
}


def parse_station(item):
    missing = REQUIRED_FIELDS - item.keys()
    if missing:
        names = "、".join(sorted(missing))
        raise KeyError(f"缺少欄位:{names}")

    return {
        "站點編號": str(item["sno"]),
        "站點名稱": str(item["sna"]).strip(),
        "行政區": str(item["sarea"]).strip(),
        "可借車輛": int(item["available_rent_bikes"]),
        "可還空位": int(item["available_return_bikes"]),
        "更新時間": str(item["updateTime"]),
    }

八、單筆錯誤不拖垮整批資料

def parse_stations(data):
    valid = []
    errors = []

    for index, item in enumerate(data, start=1):
        try:
            station = parse_station(item)
            if station["可借車輛"] < 0:
                raise ValueError("可借車輛不可為負數")
            if station["可還空位"] < 0:
                raise ValueError("可還空位不可為負數")
            valid.append(station)
        except (KeyError, TypeError, ValueError) as error:
            errors.append({"資料序號": index, "原因": str(error)})

    return valid, errors

九、篩選可借車站點

def find_available_stations(stations, area=None, minimum=1):
    result = []
    for station in stations:
        if area is not None and station["行政區"] != area:
            continue
        if station["可借車輛"] >= minimum:
            result.append(station)

    return sorted(
        result,
        key=lambda item: item["可借車輛"],
        reverse=True,
    )

這只反映資料擷取當下的回應,不保證使用者抵達時仍有車。導覽或交通建議應顯示更新時間,避免把即時資料誤寫成長期事實。

十、把來源與時間一起保存

import json
from datetime import datetime, timezone
from pathlib import Path


def save_snapshot(path, source_url, stations, errors):
    snapshot = {
        "source_url": source_url,
        "fetched_at_utc": datetime.now(
            timezone.utc
        ).isoformat(),
        "valid_count": len(stations),
        "error_count": len(errors),
        "stations": stations,
        "errors": errors,
    }

    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("x", encoding="utf-8") as file:
        json.dump(snapshot, file, ensure_ascii=False, indent=2)
    return path

使用 x 避免覆蓋快照。檔案名稱可加入時間戳;日後分析時才知道資料是哪一刻取得、有效與錯誤各多少筆。

十一、有限度重試GET請求

GET通常是冪等操作,可對連線錯誤、429或部分5xx狀態有限度重試;不要無限迴圈,也不要對所有4xx重試。

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


def build_session():
    retry = Retry(
        total=3,
        connect=3,
        read=2,
        backoff_factor=0.5,
        status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=frozenset({"GET"}),
        respect_retry_after_header=True,
    )
    adapter = HTTPAdapter(max_retries=retry)
    session = requests.Session()
    session.mount("https://", adapter)
    return session

urllib3官方文件說明,是否針對特定狀態碼重試由 status_forcelist 與允許方法共同決定。若服務提供 Retry-After,應尊重伺服器要求。

十二、限制下載大小

來源即使可信也可能異常回傳超大內容。串流下載時可設定上限:

import requests


def download_limited(url, max_bytes=10_000_000):
    chunks = []
    total = 0

    with requests.get(
        url, timeout=(3.05, 15), stream=True
    ) as response:
        response.raise_for_status()
        for chunk in response.iter_content(chunk_size=64 * 1024):
            if not chunk:
                continue
            total += len(chunk)
            if total > max_bytes:
                raise ValueError("回應超過允許大小")
            chunks.append(chunk)

    return b"".join(chunks)

十三、整合主程式

def main():
    data = fetch_json(URL)
    if data is None:
        return

    try:
        validate_dataset(data)
    except (TypeError, ValueError) as error:
        print("資料集結構錯誤:", error)
        return

    stations, errors = parse_stations(data)
    print("有效站點:", len(stations))
    print("錯誤資料:", len(errors))

    available = find_available_stations(
        stations, minimum=5
    )
    for station in available[:10]:
        print(
            station["站點名稱"],
            station["可借車輛"],
            station["更新時間"],
        )


if __name__ == "__main__":
    main()

十四、來源網址不可任由使用者輸入

伺服器端程式若直接下載使用者提供的任意網址,可能造成SSRF,連到內部管理服務或雲端中繼資料。教學程式應使用固定官方端點或嚴格允許清單;不要關閉TLS憑證驗證,也不要在網址或日誌中放API金鑰。

十五、常見錯誤檢查表

現象原因修正
程式一直等待沒有設定timeout明確設定連線與讀取逾時
404頁面被當JSON未先檢查HTTP狀態呼叫raise_for_status()
json()成功後仍KeyError欄位改變或缺漏驗證最外層及必要欄位
資料服務壓力過大頻繁輪詢或無限重試快取、退避、有限次數並遵守條款
舊資料看似即時未保存或顯示擷取時間一併保存來源與UTC時間

十六、USR實作挑戰

挑戰A|YouBike資料品質
下載一次官方快照,統計有效筆數、錯誤筆數、行政區數量與更新時間範圍,不進行高頻輪詢。
挑戰B|離線教學
保存一份符合授權的教學快照,讓沒有網路時仍可練習解析;畫面清楚標示「非即時資料」。
挑戰C|移植水井場域
把站點欄位驗證概念改寫為智慧養殖感測API,設計來源、時間、有效筆數、錯誤筆數與過期警示。

十七、與生成式AI協作

請擔任Python requests與開放資料安全助教。
請檢查我的JSON下載程式:
1. 使用固定HTTPS官方端點;
2. 必須設定連線與讀取timeout;
3. 先raise_for_status()再解析JSON;
4. 驗證最外層型別及必要欄位;
5. 單筆錯誤不能拖垮整批;
6. 重試只限GET及特定暫時性錯誤;
7. 保存來源、擷取時間與資料品質;
8. 不關閉TLS驗證,不接受任意使用者網址。
請提供測試案例與失敗時的安全行為。
本篇小結
下載JSON只需幾行,可靠使用卻需要逾時、狀態碼、格式、欄位、重試、大小與來源治理。網路資料永遠是不穩定的外部輸入,先驗證再使用。

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

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

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

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

CH11 標準函式庫ZIP備份還原演練
學習目標
完成本篇後,你能運用 datetime、pathlib、logging、zipfile、hashlib 與 json,建立時間戳備份檔、排除敏感資料、輸出清冊、驗證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資料責任的實作。

Python一下:照片與語音不是文字——認識二進位檔與雜湊驗證

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

照片與語音不是文字——認識二進位檔與雜湊驗證

安全保存水井三寶影像、長者口述錄音及設備原始檔,並確認備份前後一個位元組都沒有改變。

CH10 二進位檔bytesSHA-256
學習目標
完成本篇後,你能分辨文字與二進位資料,使用 rb、wb、xb 讀寫位元組,以分塊方式處理大型媒體檔,使用Base64進行文字化傳輸,並用SHA-256驗證檔案完整性。

一、為什麼照片不能用UTF-8讀?

文字檔需要編碼把位元組解讀成字元;JPEG、PNG、MP3、WAV等檔案則有自己的二進位格式。若以 encoding="utf-8" 開啟照片,Python會嘗試把影像位元組解碼為文字,通常產生 UnicodeDecodeError。

資料Python型別常用模式
逐字稿、CSV、JSONstrr、w,指定編碼
照片、錄音、PDF、韌體bytesrb、wb,不指定編碼
資料權利提醒:照片與錄音可能包含肖像、聲音、住家環境、位置及個人故事。能讀取或複製檔案不等於取得公開、訓練AI或再利用權利。應保存同意範圍、撤回方式及公開版本,原始媒體需限制存取。

二、認識bytes

text = "水井三寶"
data = text.encode("utf-8")

print(type(text), len(text))
print(type(data), len(data))
print(data)
print(data.decode("utf-8"))

bytes 是0到255整數組成的不可變序列。文字可經編碼變成位元組,但照片位元組本身不應任意 decode() 成文字。

三、讀取二進位檔的前幾個位元組

from pathlib import Path


def read_header(path, size=16):
    path = Path(path)
    with path.open("rb") as file:
        return file.read(size)


# 實際使用時換成資料副本
# header = read_header("照片/水井三寶.jpg")
# print(header)
# print(header.hex())

許多檔案格式有特徵位元組,但只檢查檔頭不能證明整份檔案安全或內容正確。副檔名與檔頭都只能作為初步判斷。

四、建立教學用二進位檔

from pathlib import Path

path = Path("教學資料.bin")
sample = bytes([0, 1, 2, 127, 128, 254, 255])

with path.open("xb") as file:
    file.write(sample)

with path.open("rb") as file:
    restored = file.read()

print(restored)
print(list(restored))

xb 表示排他建立二進位檔;目標已存在時會拒絕覆蓋,適合保護重要資料。

五、分塊讀取大型照片或錄音

使用 read() 一次讀完整段錄音可能占用大量記憶體。分塊處理能固定每次使用量。

from pathlib import Path


def count_bytes(path, chunk_size=64 * 1024):
    total = 0
    with Path(path).open("rb") as file:
        while chunk := file.read(chunk_size):
            total += len(chunk)
    return total


print(count_bytes("教學資料.bin"))

六、安全複製:目標存在就停止

from pathlib import Path


def copy_binary(source, target, chunk_size=1024 * 1024):
    source = Path(source)
    target = Path(target)
    if not source.is_file():
        raise FileNotFoundError(f"來源不存在:{source}")

    target.parent.mkdir(parents=True, exist_ok=True)
    with source.open("rb") as input_file:
        with target.open("xb") as output_file:
            while chunk := input_file.read(chunk_size):
                output_file.write(chunk)
    return target


# copy_binary("原始媒體/訪談.wav", "備份/訪談.wav")
避免半成品:若複製途中失敗,目標可能只寫入一部分。正式流程可先寫入暫存檔,驗證雜湊後再更名;失敗時保留錯誤紀錄並人工處理。

七、用SHA-256建立檔案指紋

import hashlib
from pathlib import Path


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


print(sha256_file("教學資料.bin"))

相同位元組會產生相同摘要;只要內容改變,摘要通常就不同。雜湊適合驗證完整性,但不能從摘要還原檔案。

八、複製後驗證是否一致

def verify_same_file(source, target):
    source_hash = sha256_file(source)
    target_hash = sha256_file(target)
    return {
        "來源SHA256": source_hash,
        "目標SHA256": target_hash,
        "內容一致": source_hash == target_hash,
    }


# result = verify_same_file("原始媒體/訪談.wav", "備份/訪談.wav")
# print(result)
雜湊不是備份:摘要只能幫助發現差異,不能救回遺失檔案。真正備份至少要有獨立副本、權限管理、定期驗證與還原演練。

九、Base64:把位元組轉成文字

import base64

original = b"USR binary demo"
encoded = base64.b64encode(original)
decoded = base64.b64decode(encoded, validate=True)

print(encoded.decode("ascii"))
print(decoded)
print(original == decoded)

Base64常用於JSON、電子郵件或文字通道傳輸二進位內容,但體積會增加,而且不是加密。任何拿到內容的人都能解碼,不能用來保護訪談錄音或設備憑證。

十、建立二進位檔清冊

from pathlib import Path


def binary_inventory(folder):
    allowed = {".jpg", ".jpeg", ".png", ".wav", ".mp3", ".pdf"}
    records = []

    for path in sorted(Path(folder).rglob("*")):
        if path.is_file() and path.suffix.lower() in allowed:
            records.append({
                "相對路徑": str(path.relative_to(folder)),
                "大小": path.stat().st_size,
                "SHA256": sha256_file(path),
            })
    return records


# for record in binary_inventory("教學媒體"):
#     print(record)

清冊公開前要檢查檔名是否含姓名、地址或活動參與者資訊。SHA-256摘要本身通常不透露內容,但可用於比對已知檔案,仍應依資料政策管理。

十一、驗證備份清冊

def compare_inventories(source_records, backup_records):
    source_map = {
        record["相對路徑"]: record["SHA256"]
        for record in source_records
    }
    backup_map = {
        record["相對路徑"]: record["SHA256"]
        for record in backup_records
    }

    all_paths = sorted(source_map.keys() | backup_map.keys())
    return [
        {
            "相對路徑": path,
            "狀態": (
                "一致"
                if source_map.get(path) == backup_map.get(path)
                else "缺少或內容不同"
            ),
        }
        for path in all_paths
    ]

十二、不要用雜湊保存密碼

本篇SHA-256用於檔案完整性,不適合直接保存使用者密碼。密碼需要專用、帶鹽且刻意緩慢的演算法與成熟框架;也不要自己發明加密方式。

十三、常見錯誤檢查表

現象原因修正
照片出現UnicodeDecodeError以文字模式讀取改用rb,不指定encoding
大型錄音占滿記憶體一次read全部內容使用固定大小分塊
備份覆蓋原檔使用wb且未檢查使用xb並分開來源與目標
檔案大小相同就判斷一致大小不能代表內容比較SHA-256
把Base64當加密誤解其用途視為編碼,不存敏感資料

十四、USR實作挑戰

挑戰A|媒體清冊
對已授權的教學副本建立相對路徑、大小與SHA-256清冊,檢查檔名是否洩露個資。
挑戰B|備份驗證
複製三個小型教學檔案到獨立資料夾,比對雜湊;再修改副本一個位元組,觀察結果。
挑戰C|授權分級
為原始錄音、內部研究版、講述者確認版與公開剪輯版設計不同資料夾與存取規則。

十五、與生成式AI協作

請擔任Python二進位檔與USR媒體保存助教。
請檢查我的照片與錄音備份程式:
1. 使用rb與xb,不得覆蓋原始媒體;
2. 大檔案必須分塊處理;
3. 複製後以SHA-256驗證;
4. Base64只能視為編碼,不是加密;
5. 清冊不得洩露不必要個資;
6. 能存取檔案不代表取得公開或AI訓練授權。
請先列出風險,再提供可測試函式。
本篇小結
二進位檔要用位元組模式處理,大檔案要分塊,重要副本要拒絕覆蓋並用雜湊驗證。技術確保內容完整,授權與權限制度則確保使用正當。