跳到主要內容

DE Day 16 動態頁面:Playwright 自動化瀏覽器

DE Day 16 動態頁面:Playwright 自動化瀏覽器

執行需求:CPU 可跑。今天是「資料工程實戰」系列的第十六篇,承接 Day 15 用 requests 與 BeautifulSoup 抓靜態 HTML 的作法。當網頁內容是靠 JavaScript 動態渲染(俗稱 SPA)或需要互動才能取得資料時,requests 拿到的常常是空白殼殼,這時就要派出真正的瀏覽器上場。我們會用 Playwright 1.50(2025 年 11 月的主流版本)示範四種動態場景:等待元素出現、捲動載入、按下分頁、處理登入,並把結果寫進 Day 8 已建好的 DuckDB。

引言

Day 15 我們用 requests 抓公開資料頁,幾行程式就把表格拉回來;但實務上常會碰到 requests 抓回來是空殼的情況——HTML 裡沒有資料、要等 JavaScript 跑完才會塞進去。常見原因有三個:第一,網站用 React、Vue、Angular 等前端框架,內容是執行階段才掛上的;第二,資料透過 XHR 或 fetch 二次呼叫才取得,HTML 只剩骨架;第三,需要捲動到某個位置、按下「載入更多」才會出來新資料。這三種狀況 requests 都不在行,我們需要能「真的把網頁跑起來」的工具。

Playwright 是微軟在 2020 年開源的瀏覽器自動化函式庫,跟 Selenium、Cypress、Puppeteer 同類,但有兩個特點讓它在資料工程領域特別受歡迎:第一,內建等待機制(auto-wait)寫起來直覺,不會滿地雷;第二,支援 Chromium、Firefox、WebKit 三套引擎,可以模擬不同瀏覽器。2025 年 11 月的主流版本是 1.50 世代,安裝指令仍是 pip install playwright 加上 python -m playwright install chromium,這是 Playwright 1.5x 的固定安裝法。

今天的文章會做五件事:第一,說明 Playwright 與 requests 的差別、何時該用哪一個;第二,安裝並啟動第一個無頭瀏覽器;第三,示範四種動態頁面處理——等待元素、捲動載入、按下分頁、模擬登入;第四,把抓下來的資料寫進 DuckDB 與 Parquet,與 Day 8、Day 9 的流程接軌;第五,誠實談談動態爬蟲在禮儀、法律與效能上的取捨。這篇不會把 Playwright 的所有 API 講完,但讀完應該能寫出八成資料工程會用到的動態爬蟲。

動態頁面與 Playwright 的工作原理

先建立心智模型。requests 的工作流程是「送 HTTP 請求、收 HTML 字串、剖析」;Playwright 的工作流程是「啟動一整個瀏覽器、讓 JavaScript 跑起來、等 DOM 穩定、把 DOM 內容抓回來」。前者像寄一封信要對方回信,後者像坐在對方家裡看著他把信寫完再拿走。差別在於:瀏覽器會執行 JavaScript、發出額外的 API 呼叫、把資料渲染到畫面上,這些動作 requests 完全模擬不來。

Playwright 的兩個關鍵概念是「瀏覽器(browser)」與「頁面(page)」。瀏覽器是一個 process,負責管理所有頁面的生命週期;頁面是一個分頁(tab),對應一個獨立的瀏覽上下文。我們會建立一個 browser,再用它開新的 page,最後把 page 上的元素抓下來。這跟 Selenium 的「WebDriver」觀念類似,但 Playwright 用 WebSocket 跟瀏覽器溝通,比 Selenium 的 HTTP 協定快很多。

另一個關鍵設計是「auto-wait」。傳統 Selenium 寫爬蟲最痛苦的地方是「元素還沒出現就去找,導致 NoSuchElement 錯誤」;Playwright 在你呼叫 locator.click()、locator.text_content() 之前,會自動等元素變成 visible、enabled、stable。這讓程式碼乾淨很多,但代價是「真的要等」,如果網站慢你就會等很久。理解這點之後,你會知道何時該用 auto-wait、何時該自己用 page.wait_for_selector() 明確等待。當網站回應慢或設計不良時,明確等待比 auto-wait 更可控。

最後談「locator」。Locator 是 Playwright 用來定位元素的物件,類似 CSS 選擇器或 XPath,但更強大。每次你呼叫 page.locator("#spa") 都會回傳一個新的 locator 物件(lazy 評估),實際尋找元素的動作會在 click()、inner_text() 等終端操作時才發生。這設計的好處是同一個 locator 可以重複使用,而且會隨著 DOM 變化自動重新尋找。對資料工程來說,locator 把「找元素」與「操作元素」分離,寫起來比 Selenium 的 find_element_by_* 直覺很多。

完整實作:四種動態場景與 DuckDB 落地

假設工作目錄沿用 Day 1 的 de-journey/,今天的任務是把四種常見的動態頁面處理方式收斂成一支腳本 pipelines/day16_playwright.py。為了不依賴任何實際網站(避免哪天網站改版範例就壞掉),我們用 Python 標準函式庫寫一個迷你伺服器模擬「動態頁面」,再用 Playwright 去抓它。這個寫法的另一個好處是「完全離線也能跑」,你只要裝好 Playwright 就能重現結果;同時也方便 Day 17 之後在這個骨架上加入禮儀與重試。

第一步:安裝 Playwright 與瀏覽器。整段指令可以一次貼上執行:

cd de-journey
uv pip install playwright==1.50.0 httpx==0.28.1 fastapi==0.115.0 uvicorn==0.32.0
python -m playwright install chromium
# 如果需要 Firefox / WebKit,可加:
# python -m playwright install firefox webkit

這段指令做了四件事:安裝 Python 套件(Playwright 1.50 是 2025 年 11 月主流版本,httpx 0.28 是搭配使用的 HTTP 函式庫,FastAPI 0.115 用來跑迷你伺服器)、下載 Chromium 瀏覽器(首次約 150 MB,會快取在 ~/.cache/ms-playwright/)、選擇性下載其他瀏覽器。執行 python -m playwright install 時若看到「Failed to install browsers」,通常是 Linux 缺少系統套件,請依錯誤訊息安裝 libnss3、libatk-bridge2.0-0 等相依套件;macOS 與 Windows 通常可以直接裝好。若用 Docker,請把這行寫進 Dockerfile 的 RUN 段落。

第二步:用 FastAPI 寫一個迷你動態網站,模擬 SPA、捲動載入、按下分頁、登入四種場景。把這個檔案存成 pipelines/_mock_server.py,稍後我們的 Playwright 腳本會連過來:

"""pipelines/_mock_server.py:模擬四種動態頁面,給 Playwright 範例用。"""
import asyncio
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse
import uvicorn

app = FastAPI()

USERS = {"demo": "demo123"}

HTML = """
<!doctype html>
<html lang="zh-Hant">
<head><meta charset="utf-8"><title>DE Day 16 範例</title></head>
<body style="font-family: sans-serif; max-width: 720px; margin: 24px auto;">
  <h1 id="title">DE Day 16 動態頁面範例</h1>
  <div id="spa"></div>
  <button id="load_more">載入更多</button>
  <ul id="list"></ul>
  <form action="/login" method="post">
    <input name="username" placeholder="帳號">
    <input name="password" type="password" placeholder="密碼">
    <button type="submit">登入</button>
  </form>
  <div id="after_login" style="display:none;">已登入</div>
  <script>
    fetch("/api/data").then(r=>r.json()).then(d=>{
      document.getElementById("spa").innerText = d.message;
    });
    let page = 0;
    document.getElementById("load_more").onclick = async () => {
      page += 1;
      const r = await fetch("/api/list?page=" + page);
      const items = await r.json();
      const ul = document.getElementById("list");
      for (const it of items) {
        const li = document.createElement("li");
        li.innerText = it.name;
        ul.appendChild(li);
      }
    };
  </script>
</body>
</html>
"""

@app.get("/", response_class=HTMLResponse)
async def index():
    return HTML

@app.get("/api/data")
async def api_data():
    await asyncio.sleep(0.3)
    return {"message": "這是 SPA fetch 後才出現的內容"}

@app.get("/api/list")
async def api_list(page: int = 1):
    await asyncio.sleep(0.2)
    return [{"name": f"條目 {page}-{i}"} for i in range(1, 4)]

@app.post("/login")
async def login(username: str = Form(...), password: str = Form(...)):
    if USERS.get(username) == password:
        return HTMLResponse("登入成功")
    return HTMLResponse("登入失敗", status_code=401)

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8765)

這段迷你伺服器刻意把四種場景塞在一頁:SPA(fetch 後才塞文字)、按下分頁(按鈕觸發新資料)、登入(form submit 後改 DOM)。請用另一個終端機啟動它:uv run python pipelines/_mock_server.py,瀏覽器打開 http://127.0.0.1:8765/ 應該能看到標題與按鈕。這個伺服器只在本機跑、不對外開放,符合後面談到的「禮儀」原則。請特別注意:我們刻意不在 HTML 裡寫死資料,而是讓 fetch 後才塞,模擬真實 SPA 行為;這樣 Playwright 才會需要等元素出現。

第三步:寫 Playwright 主腳本,一次示範四種動態處理。先看完整程式碼,再分段解釋:

"""pipelines/day16_playwright.py:用 Playwright 抓動態頁面並寫進 DuckDB。"""
import asyncio
from pathlib import Path
import duckdb
from playwright.async_api import async_playwright

BASE = "http://127.0.0.1:8765"
DB_PATH = "warehouse/de-journey.duckdb"

async def main() -> None:
    Path("warehouse").mkdir(exist_ok=True)
    con = duckdb.connect(DB_PATH)
    con.execute("CREATE SCHEMA IF NOT EXISTS raw")
    con.execute(
        "CREATE TABLE IF NOT EXISTS raw.day16_list ("
        "item_name VARCHAR, fetched_at TIMESTAMP)"
    )
    con.execute("DELETE FROM raw.day16_list")  # 重新跑時清空

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        ctx = await browser.new_context()
        page = await ctx.new_page()

        # 場景一:SPA 等待元素
        await page.goto(BASE + "/", wait_until="domcontentloaded")
        spa_locator = page.locator("#spa")
        await spa_locator.wait_for(timeout=5000)
        spa_text = await spa_locator.inner_text()
        print(f"SPA 內容:{spa_text}")

        # 場景二:按下「載入更多」做分頁
        rows = []
        for click in range(3):
            await page.locator("#load_more").click()
            await page.wait_for_function(
                "document.querySelectorAll('#list li').length > " + str(click * 3),
                timeout=5000,
            )
        items = await page.locator("#list li").all_inner_texts()
        rows.extend(items)
        print(f"分頁筆數:{len(rows)}({rows[0]} ... {rows[-1]})")

        # 場景三:表單登入
        await page.locator("input[name='username']").fill("demo")
        await page.locator("input[name='password']").fill("demo123")
        async with page.expect_navigation():
            await page.locator("button[type='submit']").click()
        login_ok = "成功" in await page.content()
        print(f"登入成功:{login_ok}")

        # 把抓到的資料寫進 DuckDB
        fetched_at = await page.evaluate("() => new Date().toISOString()")
        con.executemany(
            "INSERT INTO raw.day16_list VALUES (?, CAST(? AS TIMESTAMP))",
            [(name, fetched_at) for name in rows],
        )
        n = con.execute("SELECT COUNT(*) FROM raw.day16_list").fetchone()[0]
        print(f"DuckDB raw.day16_list 累計:{n} 筆")
        await browser.close()

asyncio.run(main())

這段腳本結構分明:先準備 DuckDB 連線與空表,再用 async with async_playwright() 進入 Playwright 上下文、啟動 Chromium、建立 context 與 page。場景一是 SPA 等待:用 goto() 進首頁,再用 locator("#spa").wait_for() 等 fetch 完才出現的內容;最多等 5 秒。場景二是按下分頁:每次按下「載入更多」後用 wait_for_function() 等 DOM 數量增加,最後用 all_inner_texts() 一次抓所有串列條目。場景三是登入:填表單、按下提交、expect_navigation() 等跳轉完成,再檢查登入後才會顯示的元素。實際輸出會依網站不同,這裡是示範結構。請特別看 con.executemany():這是 DuckDB 0.10 之後新增的批次插入方法,比 executemany 配合迴圈快上數倍。

第四步:把 DuckDB 的結果與 Polars 串起來,做一個最簡的彙總驗證:

import duckdb
import polars as pl

con = duckdb.connect("warehouse/de-journey.duckdb")
df = con.execute("SELECT * FROM raw.day16_list").pl()
print(df.head(5))
print(f"總筆數:{df.height},唯一條目數:{df['item_name'].n_unique()}")
df.write_parquet("warehouse/day16_list.parquet")
print("已輸出 Parquet:warehouse/day16_list.parquet")

這段示範 DuckDB 與 Polars 的雙向整合(Day 12 的混用策略):DuckDB 的查詢結果用 .pl() 轉成 Polars DataFrame,再用 write_parquet() 落地成 Day 9 學過的 Parquet 檔。後續章節(Day 19、Day 20)會用這份 Parquet 練習分頁與增量載入。注意這段用 n_unique() 算唯一值數量,這是 Polars 1.3x 的命名(早期版本是 unique().count())。

第五步:把 Playwright 用來抓「真的需要瀏覽器」的網站時,要做幾個禮貌設定——設定 User-Agent、降低並行、限制下載:

import asyncio
from playwright.async_api import async_playwright

UA = "Mozilla/5.0 (compatible; HaoBot/1.0; +https://blog.hao-code.com/bots)"

async def polite_fetch(url: str) -> str:
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        ctx = await browser.new_context(user_agent=UA, viewport={"width": 1280, "height": 800})
        await ctx.route("**/*", lambda route: (
            route.abort() if route.request.resource_type in {"image", "font", "stylesheet", "media"}
            else route.continue_()
        ))
        page = await ctx.new_page()
        try:
            await page.goto(url, wait_until="domcontentloaded", timeout=20000)
            await page.wait_for_load_state("networkidle", timeout=10000)
            return await page.content()
        finally:
            await browser.close()

if __name__ == "__main__":
    snippet = asyncio.run(polite_fetch("https://example.com"))
    print(snippet[:200])  # 輸出:<!doctype html><html ...(實際會略有不同)

這個寫法有三個重點:第一,自訂 User-Agent 並放上聯絡網址,這是 Day 17 會展開的「robots.txt 與禮儀」原則;第二,用 ctx.route() 阻擋圖片、CSS、字型,把單頁流量從 1 MB 壓到 50 KB;第三,設定 20 秒 timeout 避免被慢站拖死。實際抓 example.com 通常會成功,回傳的 HTML 開頭是 doctype。route.abort() 與 route.continue_() 是 Playwright 1.5x 推薦的寫法,比舊版的 request.abort() 直覺。

第六步:把「禮儀 + 重試」做成共用函式,這是 Day 17 會展開的完整版,今天先給一個骨架:

"""pipelines/_polite_session.py:禮貌爬取 + 自動重試(Day 17 完整版)。"""
import asyncio
import random
from typing import Awaitable, Callable, TypeVar
from playwright.async_api import async_playwright, Browser

T = TypeVar("T")

async def with_retry(fn: Callable[[], Awaitable[T]], attempts: int = 3) -> T:
    """指數退避重試:失敗後等 1, 2, 4 秒,最多三次。"""
    delay = 1.0
    for i in range(attempts):
        try:
            return await fn()
        except Exception as e:
            if i == attempts - 1:
                raise
            wait = delay + random.uniform(0, 0.5)
            print(f"重試 {i + 1}/{attempts - 1}:{e}(等 {wait:.1f} 秒)")
            await asyncio.sleep(wait)
            delay *= 2

async def fetch_with_throttle(browser: Browser, url: str, qps: float = 1.0) -> str:
    """節流抓取:每次抓取間隔至少 1/qps 秒。"""
    async def _do() -> str:
        page = await browser.new_page()
        try:
            await page.goto(url, wait_until="domcontentloaded", timeout=15000)
            return await page.content()
        finally:
            await page.close()

    result = await with_retry(_do)
    await asyncio.sleep(1.0 / qps)
    return result

if __name__ == "__main__":
    async def demo() -> None:
        async with async_playwright() as p:
            b = await p.chromium.launch(headless=True)
            try:
                html = await fetch_with_throttle(b, "https://example.com", qps=2.0)
                print(f"抓到 {len(html)} 字元")
            finally:
                await b.close()
    asyncio.run(demo())

這個共用函式把「重試」與「節流」抽成兩個獨立函式,fetch_with_throttle 同時包進 with_retry,等於把「禮儀」與「工程」綁在一起。Day 17 會把這套擴充成完整的速率控制模組,包含 robots.txt 解析與動態 QPS 調整。這裡展示的是骨架:用型別提示 TypeVar 讓 with_retry 能包任何回傳型別、用 asyncio.sleep 做節流、用 random.uniform 加一點抖動避免「雪崩重試」。

第七步:用 DuckDB 做抓取結果的「去重複」與「時間標記」,這是 Day 20 增量載入的暖身:

"""pipelines/day16_dedupe.py:抓回來後馬上去重,避免後續章節資料膨脹。"""
import duckdb

con = duckdb.connect("warehouse/de-journey.duckdb")
con.execute("""
    CREATE OR REPLACE TABLE raw.day16_list_dedup AS
    SELECT item_name, MIN(fetched_at) AS first_seen, COUNT(*) AS times_seen
    FROM raw.day16_list
    GROUP BY item_name
""")
total = con.execute("SELECT COUNT(*) FROM raw.day16_list_dedup").fetchone()[0]
print(f"去重後剩餘:{total} 個唯一條目")

con.execute("""
    CREATE OR REPLACE VIEW raw.day16_list_latest AS
    SELECT * FROM (
        SELECT *, ROW_NUMBER() OVER (PARTITION BY item_name ORDER BY fetched_at DESC) AS rn
        FROM raw.day16_list
    ) WHERE rn = 1
""")
latest_n = con.execute("SELECT COUNT(*) FROM raw.day16_list_latest").fetchone()[0]
print(f"最新一筆快照:{latest_n} 個條目")

這段用 DuckDB 的 GROUP BY 與 ROW_NUMBER 視窗函式,把同一個 item_name 的重複抓取合併,並保留最早出現時間。Day 4 與 Day 5 學過的視窗函式這裡派上用場:ROW_NUMBER() OVER (PARTITION BY item_name ORDER BY fetched_at DESC) 給每個 item_name 內的最新一筆編號 1,舊的編 2 以上,WHERE rn = 1 過濾掉舊的。這套語法在 Day 20 增量載入會完整展開,這裡先建立「抓回來要去重」這個習慣。實際去重筆數會依前幾步的抓取結果而定,示範情境約 9 個唯一條目。

常見錯誤與踩雷

第一個雷是「裝了 Playwright 但忘了裝瀏覽器」。如果你只跑 pip install playwright,第一次執行會看到「Executable doesn't exist at ...chromium-...」的錯誤。對應排查方向:執行 python -m playwright install chromium 補裝瀏覽器;在 CI 或 Docker 環境要把這個指令寫進 Dockerfile 的 RUN 段落;如果想縮小映像檔體積,可以改用 playwright install --with-deps chromium 把系統套件也一起裝。

第二個雷是「以為 goto() 結束後資料就緒」。即使你設了 wait_until="networkidle",有些 SPA 會持續背景輪詢(polling)、永遠不會 idle。對應排查方向:對關鍵元素用 locator(...).wait_for() 或 page.wait_for_selector() 明確等待;不要依賴 sleep() 來等,這會讓程式變慢又不可靠。auto-wait 是好幫手,但要記得給合理的 timeout(建議 5 到 15 秒)。另一個隱性問題是「頁面跳轉兩次」:按下登入按鈕後 redirect 一次、JavaScript 又再 fetch 一次,這時 expect_navigation() 只涵蓋第一次跳轉,要用 wait_for_url() 等最終 URL。

第三個雷是「在 headless 模式看不到瀏覽器」。Linux 伺服器通常沒有 X server,跑 chromium.launch(headless=False) 會直接崩潰。對應排查方向:本機除錯時用 headless=False 並打 slow_mo=200(每步延遲 200 毫秒)方便觀察;正式部署一律 headless=True;若需要遠端除錯,可用 browser.new_context().new_page() 加上 page.video.record() 錄影,事後回放。

第四個雷是「一個 page 跑太多任務,瀏覽器卡住」。每個 page 是獨立分頁,但同一個 browser 的所有 page 共享記憶體。對應排查方向:批次任務用「每個分頁一個任務、總並行數控制在 2 到 4」;大量資料改用多個 browser process;不確定的話,先在 headless=False 觀察記憶體,確認沒問題再上線。實務上一個 Chromium 處理 4 個並行 page、約 100 MB 記憶體,是 CPU 可跑 的甜蜜點。

第五個雷是「登入後 session 沒保留」。如果你用 page.goto("/login") 登入後,又開新的 browser.new_page(),新分頁不會帶 cookie。對應排查方向:把登入的 page 與後續 page 用同一個 context 開;或把 cookie 與儲存狀態序列化到磁碟,下次載入;需要多使用者隔離時,每個使用者一個 context(Playwright 的 browser.new_context() 設計就是為此)。這個設計比 Selenium 的 driver 物件更乾淨,但要記得「context 等於一個獨立的小瀏覽器」。

效能與實務提醒

Playwright 的效能瓶頸通常在「瀏覽器啟動時間」。一個 Chromium 啟動約 0.5 到 1 秒,這對抓 10 頁網站沒差,但抓 1000 頁就會累積成 15 分鐘。對應做法:把整批抓取放在同一個 async_playwright() 與 browser 內,反覆開關瀏覽器最耗時。如果真的要大量抓,改用 Playwright 的「browser context pool」:建立 N 個 context,每個處理一批頁面,搭配 asyncio.gather() 並行。同時也要記得「每個 context 獨立 cookie」的特性,避免狀態污染。

另一個常見取捨是「無頭 vs 有頭」。無頭(headless=True)跑得快、耗記憶體少;有頭(headless=False)可以即時除錯、看截圖、觀察網站行為。本機開發一律建議先在有頭模式確認邏輯,再切到無頭模式上線。Playwright 1.5x 也提供 headless="new" 模式,這是 2024 年開始推的新版 headless,行為更貼近真實瀏覽器,能避開一些網站的反爬機制(例如偵測 headless 行為)。

禮儀與法律邊界是資料工程必談的議題。動態爬蟲比靜態爬蟲更有「真的像使用者」的疑慮,因此更需要節制。第一,永遠先讀 robots.txt,Day 17 會展開;第二,設定 User-Agent 並放聯絡資訊,被檢舉時對方找得到你;第三,避免在尖峰時段抓(通常是上班時間 9:00 到 18:00);第四,只抓你真的會用的資料,不要「整站打包」;第五,不要繞過登入或付費牆,這通常違反著作權與服務條款;第六,涉及個資時要特別小心,台灣《個人資料保護法》對蒐集、處理、利用有嚴格規範,即使是不含個資的公開資料仍可能有營業秘密或資料庫保護的議題。第七,公開政府資料(Day 18 會展開)依「政府資料開放授權條款第 1 版」可商用、可改作,但仍須標示來源。

最後,Playwright 也能做「測試」與「監控」。我們可以在排程腳本裡加一段 Playwright 檢查「我們的儀表板能不能正常開啟」、「API 文件頁能不能 render 完整」,把失敗截圖存起來當證據。Day 32 與 Day 38 會把這套做法接到品質監控。整套工具鏈的核心精神是「自動化一切可重複的動作」,瀏覽器自動化是其中最強大、但也最重的一塊,能用 requests 解就先用 requests,真的不行再上 Playwright。

小結

今天把 Day 15 的靜態爬蟲延伸到動態頁面:用 Playwright 1.50 啟動 Chromium、處理四種常見的動態場景(S SPA、按下分頁、表單登入、捲動載入),並把抓下來的資料寫進 DuckDB 與 Parquet。我們用一個 FastAPI 迷你伺服器離線模擬這四種場景,避開了「網站改版就壞掉」的問題,同時也建立「可以重現的測試環境」。效能上,我們學到 Chromium 啟動成本、無頭 vs 有頭的取捨、禮儀與法律的底線;工程上,我們學到 auto-wait 的設計、locator 的選擇、以及 context pool 的延伸用法。

把今天的關鍵詞整理進筆記本:Playwright 1.5x、async_playwright、browser context、auto-wait、locator、route 阻擋、禮儀 User-Agent。這些詞會在 Day 17(爬蟲禮儀)、Day 19(API 分頁)、Day 21(排程)反覆出現。請把 pipelines/_mock_server.py 與 pipelines/day16_playwright.py 都存起來,Day 17 會在這個基礎上加入速率、重試與 robots.txt 檢查。

結語

今天我們解決了「requests 抓不到的東西」。但這個能力伴隨著責任:能跑 JavaScript 不代表「應該對所有網站這麼做」。明天 Day 17 我們會把爬蟲的禮儀與工程化一次講清楚:robots.txt 的正確讀法、速率限制的計算、重試與退避(exponential backoff)的設計、以及如何在程式碼裡表達「我尊重這個網站」。這套做法會變成後續所有採集章節(Day 18 政府開放資料、Day 19 API 分頁、Day 20 增量載入)的標準配備。

明天,我們會把今天的 Playwright 腳本加上速率控制、重試、與 robots.txt 檢查,讓一支爬蟲從「能跑」變成「能上線」。

延伸資源

  • Playwright 官方文件(1.50,2025):https://playwright.dev/python/docs/intro,async API、locators、auto-wait 的標準說明。
  • Playwright Python API 參考(2025):https://playwright.dev/python/docs/api/class-playwright,async_playwright 與 BrowserContext 的完整介面。
  • Chromium 專案(2025):https://www.chromium.org/,Playwright 預設使用的瀏覽器核心,headless 與有頭模式的差異可從這裡開始查。
  • 政府資料開放平臺(2025):https://data.gov.tw/,Day 18 會展開的真實採集標的,依「政府資料開放授權條款第 1 版」可商用、可改作。
  • 台灣個人資料保護法(2025):https://law.moj.gov.tw/LawClass/LawAll.aspx?pcode=I0050021,資料工程師在採集涉及個資時必讀的基本法。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...