#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 釋憲案件的縱向歷程串接 ====================== 把「釋憲案 → 確定終局裁判 → 各審級」串成一條鏈,資料來源有二: A. 釋字及憲法法庭資料庫/90_原始存檔/*.html 官網快照裡的「確定終局裁判」欄 B. DisposedCase_多年期/03_enriched/*.csv 司法院終結案件資料(103–114年度) 執行(兩個路徑都要給對): python _pipeline\\串接案件歷程.py "C:\\Users\\JudyLin\\Documents\\DisposedCase_多年期" 產出於 40_案件歷程/: 確定終局裁判.csv 釋憲案 → 裁判字號(含解析失敗者,標明原因) 案件歷程.csv 逐案的審級鏈(三審/二審/一審) 辯護型態變化.csv 同一案在各審級的辯護/代理型態轉換 法官參與.csv 各審級承審法官 並更新 00_索引/串接報告.md 限制(已於報告中列明): * 終結案件資料為「無姓名版」,只有辯護型態類別,**沒有律師姓名**, 故無法追蹤律師更換或律師的其他案件。法官姓名則有。 * 終結案件資料涵蓋 103–114 年度,102 年以前之確定終局裁判無法串接。 """ import csv, glob, json, os, re, sys, collections from pathlib import Path MAX_DEPTH = 6 # 最多回溯幾層;每多一層就多掃一次全部 03_enriched def cellkey(k): return f"{k[0]} {k[1]}年度{k[2]}字第{k[3]}號" csv.field_size_limit(10 ** 9) ROOT = Path(__file__).resolve().parent.parent SNAP = ROOT / "90_原始存檔" OUT = ROOT / "40_案件歷程" # 釋憲書狀裡的裁判字號。法院名允許夾雜「臺灣」「台灣」與分院。 # 法院名與年度之間,官網有時夾庭別(例:臺灣高等法院少年法庭104年度少抗字第87號), # 亦有分院。庭別不入 key,僅供跳過。 CASE_RE = re.compile( r"((?:臺灣|台灣)?[\u4e00-\u9fff]{0,8}?(?:地方法院|高等法院|行政法院|智慧財產法院|" r"智慧財產及商業法院|少年及家事法院|懲戒法院|公務員懲戒委員會))" r"((?:[\u4e00-\u9fff]{0,4}分院)?)" r"(?:[\u4e00-\u9fff]{0,8}?(?:法庭|庭))?" r"\s*(\d{2,3})\s*年度?\s*([\u4e00-\u9fff0-9]{1,10}?)字第\s*(\d+)\s*號") def norm_court(name): """終結案件資料的地院寫「臺南地方法院」,釋憲書狀寫「臺灣臺南地方法院」,需對齊。""" n = name.strip().replace("台", "臺") n = re.sub(r"^臺灣(?=[\u4e00-\u9fff]*地方法院)", "", n) n = re.sub(r"^臺灣(?=[\u4e00-\u9fff]*少年及家事法院)", "", n) return n # ------------------------------------------------------------ A. 釋憲端 def parse_snapshots(): try: from bs4 import BeautifulSoup except ImportError: os.system(f'"{sys.executable}" -m pip install -q beautifulsoup4 lxml') from bs4 import BeautifulSoup rows, nohit = [], [] files = sorted(SNAP.glob("*.html")) print(f"[1/5] 解析釋憲快照 {len(files)} 份 …") for i, p in enumerate(files, 1): soup = BeautifulSoup(p.read_text(encoding="utf-8"), "lxml") F = {} for ul in soup.select("ul.flex"): t, v = ul.find("li", class_="title"), ul.find("li", class_="text") if t and v: F[re.sub(r"\s", "", t.get_text())] = v zh = "" for k in ("解釋字號", "判決字號"): if k in F: zh = re.sub(r"\s+", " ", F[k].get_text()).strip() break node = next((F[k] for k in F if "確定終局裁判" in k), None) if node is None: nohit.append((p.stem, zh, "官網無此欄")) else: txt = re.sub(r"\s+", " ", node.get_text(" ")) found = CASE_RE.findall(txt) if not found: nohit.append((p.stem, zh, "欄位存在但無可解析之字號")) seen = set() for court, branch, y, z, no in found: court = court + branch key = (norm_court(court), y, z, no) if key in seen: continue seen.add(key) rows.append({"key": p.stem, "字號": zh, "法院": norm_court(court), "原文法院": court, "年": y, "字別": z, "號": no}) if i % 200 == 0: print(f" {i}/{len(files)}") print(f" 取得裁判 {len(rows)} 筆;無法取得者 {len(nohit)} 件") return rows, nohit # ------------------------------------------------------------ B. 終結案件端 SELF = [("c0_案號-年", "c0_案號-字別", "c0_案號-號"), ("c0_結案案號-年", "c0_結案案號-字別", "c0_結案案號-號")] # 前審欄位在 387 個 enriched 檔中有四種命名,缺一種就會漏掉整批案類。 # c0_原審(前審)… 132 檔(地院刑訴、高院刑訴、行政訴訟…) # c0_原審(前審或確定)… 108 檔(地院民訴、高院民訴、地院刑事其他、高院刑事其他、地院家事訴訟…) # c0_一審 / c0_二審 最高法院刑訴、刑事其他、民訴 # c0_原審(前審)簡易庭別 地院社會秩序維護法(無法院欄,沿用本案法院) PRIOR = [("c0_原審(前審)法院", "c0_原審(前審)案號-年", "c0_原審(前審)案號-字別", "c0_原審(前審)案號-號"), ("c0_原審(前審或確定)法院", "c0_原審(前審或確定)案號-年", "c0_原審(前審或確定)案號-字別", "c0_原審(前審或確定)案號-號"), ("c0_二審法院", "c0_二審案號-年", "c0_二審案號-字別", "c0_二審案號-號"), ("c0_一審法院", "c0_一審案號-年", "c0_一審案號-字別", "c0_一審案號-號")] # 無法院欄者:法院沿用本案,只取年/字別/號 PRIOR_SELFCOURT = [("c0_原審(前審)案號-年", "c0_原審(前審)案號-字別", "c0_原審(前審)案號-號")] KEEP = ["c0_案由", "c0_全案終結情形", "c1_辯護及代理", "c0_自訴人是否有律師代理", "c0_原告/上訴人是否有律師訴訟代理", "c0_被告/被上訴人是否有律師訴訟代理", "c0_全案終結日期-年", "c0_全案終結日期-月"] # 法官欄不寫死。enriched 檔多數只有 c0_法官名1~3,但五人合議庭(最高行政法院為主) # 另有 c0_法官名4、c0_法官名5,寫死 1~3 會漏人。改成動態抓取所有 c0_法官名N, # 並且跨列取聯集(同一案號在來源檔中一名當事人一列,各列所載法官未必相同)。 JUDGE_RE = re.compile(r"^c0_法官名\d+$") def scan(dc_root, wanted, level_tag): """在 03_enriched 全部 CSV 中找出 key 落在 wanted 的案件。回傳 {key: record}。""" files = sorted(glob.glob(os.path.join(dc_root, "03_enriched", "*.csv"))) got = {} for n, f in enumerate(files, 1): with open(f, encoding="utf-8-sig", newline="") as fh: r = csv.reader(fh) try: hdr = next(r) except StopIteration: continue ix = {h: i for i, h in enumerate(hdr)} self_cols = next((c for c in SELF if all(x in ix for x in c)), None) if not self_cols or "c0_法院別" not in ix: continue ci = ix["c0_法院別"] yi, zi, ni = (ix[c] for c in self_cols) priors = [tuple(ix[x] for x in p) for p in PRIOR if all(x in ix for x in p)] # 只有年/字別/號、沒有法院欄的前審寫法(社會秩序維護法簡易庭) priors_sc = [tuple(ix[x] for x in p) for p in PRIOR_SELFCOURT if all(x in ix for x in p) and not priors] keeps = [(k, ix[k]) for k in KEEP if k in ix] jcols = [(h, i) for h, i in ix.items() if JUDGE_RE.match(h)] src = os.path.basename(f) for row in r: try: key = (norm_court(row[ci]), row[yi].strip(), row[zi].strip(), row[ni].strip()) except IndexError: continue if key not in wanted: continue rec = got.setdefault(key, {"法院": key[0], "年": key[1], "字別": key[2], "號": key[3], "來源檔": src, "審級": level_tag, "筆數": 0, "前審": [], "法官": set(), "辯護": set(), "值": {}, "原始": None, "當事人": []}) rec["筆數"] += 1 # 保留原始整列(第一列)供「來源檔結構化表格」使用 if rec["原始"] is None: rec["原始"] = {h: (row[i2].strip() if i2 < len(row) else "") for h, i2 in ix.items()} if len(rec["當事人"]) < 60: ent = {} for h in ("當事人序號", "身分類別", "身分大分類", "c6_當事人類別", "c1_當事人終結情形", "當事人終結情形大分類", "c0_原告身分", "c0_被告身分"): if h in ix and ix[h] < len(row) and row[ix[h]].strip(): ent[h] = row[ix[h]].strip() if ent: rec["當事人"].append(ent) for h, i2 in jcols: v = row[i2].strip() if i2 < len(row) else "" if v: rec["法官"].add(v) for k, i2 in keeps: v = row[i2].strip() if i2 < len(row) else "" if not v: continue if "辯護" in k or "律師" in k: rec["辯護"].add(f"{k.replace('c1_','').replace('c0_','')}={v}") else: rec["值"].setdefault(k, v) for p in priors: if max(p) >= len(row): continue pc, py, pz, pn = (row[p[0]], row[p[1]], row[p[2]], row[p[3]]) if pc.strip() and pn.strip(): pk = (norm_court(pc), py.strip(), pz.strip(), pn.strip()) if pk not in rec["前審"]: rec["前審"].append(pk) for p in priors_sc: if max(p) >= len(row): continue py, pz, pn = (row[p[0]], row[p[1]], row[p[2]]) if pz.strip() and pn.strip(): pk = (key[0], py.strip(), pz.strip(), pn.strip()) if pk != key and pk not in rec["前審"]: rec["前審"].append(pk) if n % 60 == 0: print(f" 掃描 {n}/{len(files)},已命中 {len(got)}") return got def main(): if len(sys.argv) < 2: print("用法:python _pipeline\\串接案件歷程.py ") sys.exit(1) dc = sys.argv[1] if not os.path.isdir(os.path.join(dc, "03_enriched")): print("找不到", os.path.join(dc, "03_enriched")) sys.exit(1) OUT.mkdir(exist_ok=True) rows, nohit = parse_snapshots() with open(OUT / "確定終局裁判.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.writer(fh) w.writerow(["釋憲案", "字號", "法院", "年", "字別", "號", "官網原文法院"]) for r in rows: w.writerow([r["key"], r["字號"], r["法院"], r["年"], r["字別"], r["號"], r["原文法院"]]) for k, z, why in nohit: w.writerow([k, z, "", "", "", "", f"(未取得:{why})"]) wanted = {(r["法院"], r["年"], r["字別"], r["號"]) for r in rows} print(f"[2/5] 第一輪比對:目標 {len(wanted)} 筆裁判") lv1 = scan(dc, wanted, "確定終局裁判") print(f" 命中 {len(lv1)} 筆") # 往下回溯到接不動為止,不預設只有三層。 LABEL = ["確定終局裁判", "前審", "再前審", "第四層", "第五層", "第六層"] layers = [lv1] seen = set(lv1) for depth in range(1, MAX_DEPTH): nxt = {pk for pk in (p for rec in layers[-1].values() for p in rec["前審"])} - seen if not nxt: print(f"[3/5] 第 {depth+1} 輪:無新目標,回溯結束(共 {len(layers)} 層)") break print(f"[3/5] 第 {depth+1} 輪({LABEL[depth]}):目標 {len(nxt)} 筆") lv = scan(dc, nxt, LABEL[depth]) print(f" 命中 {len(lv)} 筆") if not lv: break seen |= set(lv) layers.append(lv) else: print(f"[3/5] 已達深度上限 {MAX_DEPTH} 層,可能仍有更前審未串。") allrec = {} for lv in reversed(layers): allrec.update(lv) print("[5/5] 輸出 …") # 來源檔結構化明細:每個案號的原始整列與當事人組成 detail = {} for k, rec in allrec.items(): detail[cellkey(k)] = {"來源檔": rec["來源檔"], "審級": rec["審級"], "筆數": rec["筆數"], "原始": rec["原始"] or {}, "當事人": rec["當事人"], # 保留全部前審指標,之後重建樹狀歷程就不必再掃 DisposedCase "前審": [cellkey(p) for p in rec["前審"]], "法官": sorted(rec["法官"]), "辯護": sorted(rec["辯護"])} with open(OUT / "來源明細.json", "w", encoding="utf-8") as fh: json.dump(detail, fh, ensure_ascii=False) print(f" 來源明細 {len(detail)} 件 → 40_案件歷程/來源明細.json") cell = cellkey def tree(k): """由確定終局裁判往下回溯出整棵前審樹(不是單一路徑)。 一筆裁判可能同時指向多個前審(最高法院常同時列一審與二審; 合併上訴亦可能有數個原審),只跟第一個會漏掉其餘分支。 回傳 [(案號, 上層案號 or None), …],廣度優先。 """ out, seen, q = [], set(), [(k, None)] while q: cur, par = q.pop(0) if cur in seen or cur not in allrec: continue seen.add(cur) out.append((cur, par)) for p in allrec[cur]["前審"]: if p not in seen: q.append((p, cur)) return out def paths(k): """回傳所有 根→葉 路徑,供辯護型態序列使用。""" res = [] def rec(cur, acc): if cur in acc or cur not in allrec: return acc = acc + [cur] nx = [p for p in allrec[cur]["前審"] if p in allrec and p not in acc] if not nx: res.append(acc) return for p in nx: rec(p, acc) rec(k, []) return res with open(OUT / "案件歷程.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.writer(fh) w.writerow(["釋憲案", "釋憲字號", "審級", "法院", "案號", "上層案號", "案由", "全案終結情形", "終結年月", "法官", "辯護/代理型態", "當事人筆數", "來源檔", "前審案號"]) for r in rows: k = (r["法院"], r["年"], r["字別"], r["號"]) chain = tree(k) if not chain: w.writerow([r["key"], r["字號"], "未命中", r["法院"], cell(k), "", "", "", "", "", "", "", "", ""]) continue for kk, par in chain: rec = allrec[kk] v = rec["值"] ym = (v.get("c0_全案終結日期-年", "") + "/" + v.get("c0_全案終結日期-月", "")).strip("/") w.writerow([r["key"], r["字號"], rec["審級"], kk[0], cell(kk), cell(par) if par else "", v.get("c0_案由", ""), v.get("c0_全案終結情形", ""), ym, "、".join(sorted(rec["法官"])), ";".join(sorted(rec["辯護"])), rec["筆數"], rec["來源檔"], ";".join(cell(p) for p in rec["前審"])]) with open(OUT / "辯護型態變化.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.writer(fh) w.writerow(["釋憲案", "釋憲字號", "審級序(由下而上)", "辯護型態序列", "是否變更"]) for r in rows: for chain in paths((r["法院"], r["年"], r["字別"], r["號"])): if len(chain) < 2: continue seq = [(";".join(sorted(allrec[c]["辯護"])) or "(無記載)") for c in reversed(chain)] w.writerow([r["key"], r["字號"], " → ".join(cell(c) for c in reversed(chain)), " → ".join(seq), "是" if len(set(seq)) > 1 else "否"]) with open(OUT / "法官參與.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.writer(fh) w.writerow(["法官", "法院", "審級", "案號", "釋憲案", "釋憲字號"]) for r in rows: for cur, _par in tree((r["法院"], r["年"], r["字別"], r["號"])): rec = allrec[cur] for j in sorted(rec["法官"]): w.writerow([j, cur[0], rec["審級"], cell(cur), r["key"], r["字號"]]) linked = {r["key"] for r in rows if (r["法院"], r["年"], r["字別"], r["號"]) in allrec} allcase = {r["key"] for r in rows} | {k for k, _, _ in nohit} yr = collections.Counter(int(r["年"]) for r in rows if r["年"].isdigit()) out_of_range = sum(v for k, v in yr.items() if k < 103) rep = ["# 釋憲案件歷程串接報告", "", f"- 釋憲案總數:{len(allcase)}", f"- 官網可取得確定終局裁判字號者:{len(allcase) - len(nohit)}", f"- 解析出裁判字號:{len(rows)} 筆", f"- 其中年度早於 103 年(終結案件資料未涵蓋):{out_of_range} 筆", f"- 在終結案件資料中命中:{len(lv1)} 筆", "- 各層命中數:" + "、".join(f"{LABEL[i]} {len(lv)} 筆" for i, lv in enumerate(layers)), f"- 命中裁判合計:{len(allrec)} 筆", f"- **至少串到一層的釋憲案:{len(linked)} 件**", "", "## 未能取得裁判字號之原因", ""] why = collections.Counter(w for _, _, w in nohit) rep += [f"- {k}:{v} 件" for k, v in why.most_common()] rep += ["", "## 資料限制", "", "1. 終結案件資料為無姓名版,`c1_辯護及代理` 僅有型態類別" "(選任律師辯護/公設辯護人辯護/法律扶助/義務律師等),**無律師姓名**," "因此無法追蹤律師更換,也無法查該律師承辦之其他案件。", "2. 終結案件資料**不含當事人姓名**。當事人層級只有 `當事人序號`、`身分類別`、" "`當事人人數`,且序號僅在案內有效,跨案沒有任何個人識別碼。" "因此無法列出被告姓名,也無法以個人為單位追蹤其前案。" "一條審級鏈=一件確定終局裁判(一個案號),不等於一位被告。", "3. 法官姓名欄有值,法官的縱向與橫向追蹤可行。欄位數依案類而異" "(多數檔為 `c0_法官名1~3`,五人合議庭另有 `c0_法官名4`、`c0_法官名5`)," "本腳本動態抓取全部並跨列取聯集,不寫死欄位數。", "4. 終結案件資料涵蓋 103–114 年度,102 年以前之裁判無法串接。", "5. 串接以「法院+年+字別+號」精確比對,不做模糊比對;" "地方法院名稱之「臺灣」前綴已正規化對齊。", "6. 前審欄位在 387 個 enriched 檔中有四種命名,本腳本四種都認:" "`c0_原審(前審)…`(132 檔)、`c0_原審(前審或確定)…`(108 檔)、" "`c0_一審/二審…`(36 檔)、`c0_原審(前審)簡易庭別`(12 檔)。" "另有 90 檔完全沒有前審欄位(如最高法院民事其他、地院民事執行、懲戒法院)," "那批在資料上就接不了,不是比對失敗。", "7. 回溯深度不預設層數,接不動為止(上限 6 層);" "一筆同時列出一審與二審時,優先接已命中者。"] (ROOT / "00_索引").mkdir(exist_ok=True) (ROOT / "00_索引" / "串接報告.md").write_text("\n".join(rep) + "\n", encoding="utf-8") print("=" * 60) print("完成。輸出於", OUT) print(f" 至少串到一層的釋憲案:{len(linked)} 件") print(" 報告:", ROOT / "00_索引" / "串接報告.md") print("=" * 60) if __name__ == "__main__": try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass main()