生活分享

實戰:製作資料整理工具

這個實戰製作 CSV 名單整理工具,保留原檔並產生新結果。規則固定為修剪空白、將 email 轉小寫、同 email 保留第一筆有效資料、略過缺姓名或格式不合的 email,最後回報保留、重複與無效筆數。這是練習用格式檢查,不保證信箱真實存在。

閱讀時間約 20 分鐘 · 操作 45 分鐘

實作順序示意圖,非產品介面截圖。
圖片:Mokaair (© Mokaair)
回總目錄:Codex 學習中心:完整教學目錄

進階 · Desktop / CLI / VS Code / JetBrains

本篇目錄
  1. 目標與準備
  2. 步驟 1:建立可以人工驗算的資料
  3. 步驟 2:請 Codex 先規劃整理契約
  4. 步驟 3:檢查參考程式的保護位置
  5. 步驟 4:執行、保存狀態與核對輸出
  6. 步驟 5:Unicode、引號與壞列
  7. 步驟 6:把驗收留成可重跑測試
  8. 交付、還原與限制

目標與準備

步驟 1:建立可以人工驗算的資料

在編輯器開啟 csv-lab,以 UTF-8 建立 contacts.csv,完整內容如下。第一列欄名必須正好是 name,email,順序也相同。保留 Alice 前後與第一筆 email 的空白,它們是測試條件。使用純文字編輯器檢查原文,避免試算表另存時換編碼或改值。Windows 執行 py -3 --version,macOS/Linux 執行 python3 --version,確認版本至少為 Python 3.9;本例不需要 pip、pandas 或資料庫。

contacts.csv(保留測試空白) · csv
name,email
 Alice , ALICE@EXAMPLE.TEST 
Alice duplicate,alice@example.test
Bob,bob@example.test
Missing,
輸入列判定輸出
Alice/ALICE@EXAMPLE.TEST有效,清除空白並轉小寫Alice/alice@example.test
Alice duplicate/alice@example.test與有效 email 重複不保留
Bob/bob@example.test有效Bob/bob@example.test
Missing/空 email無效不保留

因此預期 kept 2、duplicate 1、invalid 1。去重依標準化後 email,不依姓名;若第一筆同地址資料因姓名空白而無效,後來第一筆有效資料仍可保留。這些規則應在生成程式前說清楚。

步驟 2:請 Codex 先規劃整理契約

讓 Codex 開啟 csv-lab,先用 確認輸入、輸出、例外及驗收,再交付實作。每次只輸出新檔案;結構壞掉要整次失敗,不留下看似成功的半份資料。一般無效 email 是可計數的資料問題,但缺欄、多欄或錯誤表頭是契約錯誤。這個區分會直接影響下游是否敢使用結果,不能只寫一句幫我清理 CSV。

實作需求提示詞 · text
Create clean_contacts.py using Python's standard library only.
Expose clean(source: Path, target: Path) returning kept/duplicate/invalid counts.
Read UTF-8 with optional BOM using csv.DictReader, not string splitting.
Require exactly name,email. Trim both fields and lowercase email.
Keep the first valid row per email; reject empty names and invalid email shapes.
Treat missing/extra columns or malformed CSV as whole-run failures.
Read and validate before creating output. Never change input or overwrite output.
Write UTF-8 with name,email and a newline after each row.
CLI takes source and target paths, reports counts on stderr and exits 0 on success,
1 on data/file errors. Add tests for preservation, duplicates, Unicode, quoted
commas, malformed rows and overwrite refusal. Do not use real contact data.

步驟 3:檢查參考程式的保護位置

以下是完整參考程式,存為 clean_contacts.py 可獨立執行;若你已讓 Codex 生成版本,先另存備份,再比較差異。csv 模組負責引號、逗號與換行,不能用 split(',') 替代。程式在全部輸入讀完後才用 x 模式建立輸出;已有目標檔就失敗。這能避免輸入格式錯誤時留下輸出,但磁碟在寫到一半失敗仍可能留下不完整檔案,該次必須標記失敗並檢查產物,不能聲稱這是完整交易系統。

clean_contacts.py(完整參考) · python
"""Clean a practice CSV without overwriting either the input or an existing output."""
import argparse
import csv
import json
import re
import sys
from pathlib import Path


def clean(source: Path, target: Path) -> dict[str, int]:
    if source.resolve() == target.resolve():
        raise ValueError("Input and output must differ")
    rows = []
    seen = set()
    counts = {"kept": 0, "duplicate": 0, "invalid": 0}
    with source.open(encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream, strict=True)
        if reader.fieldnames != ["name", "email"]:
            raise ValueError("Expected exactly: name,email")
        for row in reader:
            if None in row or any(value is None for value in row.values()):
                raise ValueError("Malformed CSV row")
            name = row["name"].strip()
            email = row["email"].strip().lower()
            # An exercise-level shape check, not proof an address can receive mail.
            if not name or not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email):
                counts["invalid"] += 1
            elif email in seen:
                counts["duplicate"] += 1
            else:
                seen.add(email)
                rows.append({"name": name, "email": email})
                counts["kept"] += 1
    # Exclusive creation: a rerun never silently replaces an existing result.
    with target.open("x", encoding="utf-8", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=["name", "email"], lineterminator="\n")
        writer.writeheader()
        writer.writerows(rows)
    return counts


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("source", type=Path)
    parser.add_argument("target", type=Path)
    args = parser.parse_args()
    try:
        counts = clean(args.source, args.target)
    except (OSError, ValueError, csv.Error) as error:
        print(str(error), file=sys.stderr)
        return 1
    print(json.dumps(counts), file=sys.stderr)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

步驟 4:執行、保存狀態與核對輸出

執行前確認 cleaned-01.csv 不存在;macOS/Linux 的 contacts-before.csv 也須是未使用檔名,避免 cp 覆蓋舊備份。Windows PowerShell 記下輸入 SHA256;macOS/Linux 則複製自己這份虛構輸入供 cmp 比較。計數寫在 stderr,成功時仍會顯示 JSON,不能只因這個通道有文字就判定失敗。立即保存退出狀態,再核對輸出與原件。

Windows PowerShell · powershell
$csvBefore = (Get-FileHash -LiteralPath contacts.csv -Algorithm SHA256).Hash
py -3 clean_contacts.py contacts.csv cleaned-01.csv
$csvExit = $LASTEXITCODE
$csvExit
Get-Content -LiteralPath cleaned-01.csv
$csvBefore -eq (Get-FileHash -LiteralPath contacts.csv -Algorithm SHA256).Hash
macOS/Linux · sh
cp contacts.csv contacts-before.csv
python3 clean_contacts.py contacts.csv cleaned-01.csv
csv_exit=$?
echo "$csv_exit"
cat cleaned-01.csv
cmp contacts-before.csv contacts.csv
cleaned-01.csv 的預期內容 · csv
name,email
Alice,alice@example.test
Bob,bob@example.test

預期退出 0,計數為 kept 2、duplicate 1、invalid 1,輸出兩筆且順序保留,PowerShell 比對 True 或 cmp 無差異。接著原指令再執行一次,因 cleaned-01.csv 已存在應退出 1,既有輸出內容不變;不要先刪掉它來假裝通過覆寫防護。再把輸入與輸出都指定 contacts.csv,應拒絕且原件不變。這些失敗是驗收成功的一部分。

步驟 5:Unicode、引號與壞列

另存下方 contacts-unicode.csv,輸出用 cleaned-unicode.csv。預期 kept 1、duplicate 0、invalid 0,姓名中的逗號仍屬同一欄,Unicode 不變。若輸出重新打開變成亂碼,先確認編輯器以 UTF-8 解碼,不立刻改寫原始輸入。再建立 bad.csv,內容為 name,email 換行 Alice,也就是缺少 email 欄;指定全新的 cleaned-bad.csv 執行,應退出 1 且不建立輸出。把表頭改成 email,name 或多加第三欄也應拒絕。

contacts-unicode.csv · csv
name,email
"林, Alex",alex@example.test

延伸:保留第一筆有效資料

contacts-first-valid.csv · csv
name,email
 ,first@example.test
First valid,FIRST@example.test
Later duplicate,first@example.test

用這份新增輸入與尚不存在的 cleaned-first-valid.csv 執行同一程式,其他參數不變。預期 kept 1、duplicate 1、invalid 1:第一筆姓名空白而無效,不應先占住 email;第二筆才是第一筆有效資料;第三筆才算重複。若輸出沒有 First valid,檢查 seen 是在資料通過驗證後才加入,而不是先去重再驗證。原 contacts.csv 與 cleaned-01.csv 都保持原樣。

cleaned-first-valid.csv 的預期內容 · csv
name,email
First valid,first@example.test

步驟 6:把驗收留成可重跑測試

把下方完整測試存為 test_clean_contacts.py,放在程式與 contacts.csv 旁邊。它使用暫存資料夾,會核對輸入位元組未變、既有輸出拒絕覆寫、Unicode、引號與結構錯誤;不在你的練習目錄累積測試產物。Windows 執行 py -3 -m unittest -v test_clean_contacts.py;macOS/Linux 執行 python3 -m unittest -v test_clean_contacts.py。參考版本應有三項測試全部通過;若生成版本的介面不同,先對齊 clean 函式契約,不隨意刪除失敗案例。

test_clean_contacts.py · python
import csv
import tempfile
import unittest
from pathlib import Path

from clean_contacts import clean


class CleanupTests(unittest.TestCase):
    def test_contract_and_input_preservation(self):
        original = (Path(__file__).parent / "contacts.csv").read_bytes()
        with tempfile.TemporaryDirectory() as directory:
            source = Path(directory) / "input.csv"
            target = Path(directory) / "output.csv"
            source.write_bytes(original)
            self.assertEqual(clean(source, target), {"kept": 2, "duplicate": 1, "invalid": 1})
            self.assertEqual(source.read_bytes(), original)
            with target.open(encoding="utf-8", newline="") as stream:
                self.assertEqual(list(csv.DictReader(stream)), [{"name": "Alice", "email": "alice@example.test"}, {"name": "Bob", "email": "bob@example.test"}])
            with self.assertRaises(FileExistsError):
                clean(source, target)
            with self.assertRaises(ValueError):
                clean(source, source)

    def test_unicode_and_quoted_comma(self):
        with tempfile.TemporaryDirectory() as directory:
            source, target = Path(directory) / "in.csv", Path(directory) / "out.csv"
            source.write_text('name,email\n"林, Alex",alex@example.test\n', encoding="utf-8")
            self.assertEqual(clean(source, target)["kept"], 1)
            self.assertIn('"林, Alex"', target.read_text(encoding="utf-8"))

    def test_bad_header_and_malformed_rows_create_no_output(self):
        with tempfile.TemporaryDirectory() as directory:
            source, target = Path(directory) / "in.csv", Path(directory) / "out.csv"
            for value in ["email,name\na@b.test,A\n", "name,email\nAlice\n", "name,email\nA,a@b.test,extra\n"]:
                source.write_text(value, encoding="utf-8")
                with self.assertRaises(ValueError):
                    clean(source, target)
                self.assertFalse(target.exists())


if __name__ == "__main__":
    unittest.main()

交付、還原與限制

交付時提供原始虛構 CSV、程式、測試、執行指令、計數及未修改原件的證據。可以下載完整 CSV 練習檔比對;壓縮檔包含參考程式,不需要先完成其他專案。重新整理用新的輸出名稱,清理時只選自己已確認的產物。若要讀真正名單、處理超大檔案或匯入試算表,另設計權限、串流及公式文字的處理規則;本篇把資料當文字保存,不自動把外部內容當命令執行。下一篇練習,保留這裡建立的基準與驗證方法。

32. 實戰:製作資料整理工具 — 實作順序示意圖,非產品介面截圖。 contacts.csv → Cleanup → clean.csv
32. 實戰:製作資料整理工具 — 實作順序示意圖,非產品介面截圖。 contacts.csv → Cleanup → clean.csv · 圖片:Mokaair (© Mokaair)
閱讀完整文字說明

contacts.csv to Cleanup to clean.csv

回總目錄

  • 生活分享

    Codex 學習中心:完整教學目錄

    從安裝、第一個任務到 MD 規則與進階整合,規劃 60 篇 Codex 教學、十個單元。依程度、平台、需求或指令搜尋下一篇;尚未公開的教學會標示狀態,方便安排學習路線。

  • 生活分享

    Worktree 與多任務隔離

    Worktree 讓同一個 Git 程式庫有不同的工作目錄,各自承接不同分支。它適合讓兩項工作分開改檔,但資料庫、連接埠與外部服務仍可能共用,不能把檔案隔離當成所有資源隔離。

  • 生活分享

    實戰:製作小網站

    從 brief.md 規劃並製作 Small Steps 待辦網站,完成新增、完成、刪除、篩選與本機資料保存。將 HTML、CSS、資料函式、畫面事件與測試分開,以 Node 測試和瀏覽器操作驗收,並留下可重新啟動與還原的交接紀錄。

  • 生活分享

    用量與效率:減少重工

    記錄任務條件、模型選項、時間與成果,找出能減少無效重試和過多上下文的調整。

最新旅遊情報攻略

資料來源

生活分享