#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 釋字及憲法法庭資料庫 — 一鍵建置腳本 ===================================== 在 Windows 上直接執行: cd C:\\Users\\JudyLin\\Documents\\釋字及憲法法庭資料庫 python _pipeline\\建置資料庫.py 它會做完下列全部工作,中途可以隨時中斷,再跑一次會從斷點續做: 1. 從司法院憲法法庭網站抓取 813 則大法官解釋 + 57 則憲法法庭判決原始 HTML 2. 逐頁驗證「第N筆/共813筆」與頁內解釋字號,確認編號與內容相符 3. 抓取全部意見書 PDF(約 1,680 篇) 4. 逐篇抽出 PDF 文字,中文字比例未達門檻者標示為需 OCR,不留空白也不編造 5. 產出 Obsidian Markdown 筆記庫、法條反向索引、總索引、爭點速查、年表 6. 產出單一檔案的離線網頁版資料庫(可直接雙擊開啟) 7. 產出校驗報告 鐵律:原文區塊逐字照錄,不改標點不重排;每則保留官網網址與抓取時間戳。 """ import os, sys, re, json, time, html, zipfile, subprocess, collections from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed BASE = "https://cons.judicial.gov.tw" ROOT = Path(__file__).resolve().parent.parent # 資料庫根目錄 RAW = ROOT / "90_原始存檔" PDFD = ROOT / "90_原始存檔" / "意見書PDF" PIPE = ROOT / "_pipeline" WORKERS = 4 # 對官網併發數,請勿調高 DELAY = 0.25 # 每次請求後的禮貌延遲(秒) # 釋字第813號不符合 id = 310181 + N 的規則,實際 id 由官網「下一筆」連結取得 JIE_SPECIAL = {813: 325335} JUD_IDS = "111-01:310024,111-02:309998,111-03:340434,111-04:309908,111-05:309913,111-06:309577,111-07:309994,111-08:340546,111-09:339757,111-10:309974,111-11:309958,111-12:340615,111-13:309956,111-14:325412,111-15:310029,111-16:310030,111-17:310021,111-18:309960,111-19:341172,111-20:309995,112-01:309875,112-02:309945,112-03:340469,112-04:310013,112-05:344513,112-06:310005,112-07:345269,112-08:340775,112-09:345006,112-10:325406,112-11:309954,112-12:309916,112-13:325399,112-14:351424,112-15:309832,112-16:310022,112-17:310025,112-18:351004,112-19:309986,112-20:309944,113-01:346741,113-02:309854,113-03:340393,113-04:352146,113-05:339983,113-06:349469,113-07:346206,113-08:351689,113-09:352966,113-10:350658,113-11:349244,114-01:355485,115-01:350743,115-02:343660,115-03:358150,115-04:351300,115-05:343661" # ---------------------------------------------------------------- 相依套件 def need(mod, pip=None): try: return __import__(mod) except ImportError: print(f"[安裝] {pip or mod} …") subprocess.run([sys.executable, "-m", "pip", "install", "-q", pip or mod], check=True) return __import__(mod) requests = need("requests") need("bs4", "beautifulsoup4"); need("lxml") from bs4 import BeautifulSoup pypdf = need("pypdf") UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"} PROBE = f"{BASE}/docdata.aspx?fid=100&id=310182" MODE = None # 連線策略,由 build_session() 決定 SESSION = None def _plain(): s = requests.Session(); s.headers.update(UA); return s def _truststore(): """使用 Windows 憑證存放區,解決防毒/公司代理做 TLS 攔截時的憑證不受信任問題。""" need("truststore") import truststore truststore.inject_into_ssl() s = requests.Session(); s.headers.update(UA); return s def _legacy(): """放寬到舊版 TLS 重新協商與較低安全等級,解決舊式伺服器握手失敗。""" import ssl from requests.adapters import HTTPAdapter from urllib3.poolmanager import PoolManager ctx = ssl.create_default_context() ctx.options |= getattr(ssl, "OP_LEGACY_SERVER_CONNECT", 0x4) try: ctx.set_ciphers("DEFAULT@SECLEVEL=1") except Exception: pass class A(HTTPAdapter): def init_poolmanager(self, connections, maxsize, block=False, **kw): kw["ssl_context"] = ctx self.poolmanager = PoolManager(num_pools=connections, maxsize=maxsize, block=block, **kw) s = requests.Session(); s.headers.update(UA); s.mount("https://", A()); return s def _noverify(): """最後手段:不驗證憑證。僅用於抓取公開政府文件,且每則仍保留官網網址可自行核對。""" import urllib3 urllib3.disable_warnings() s = requests.Session(); s.headers.update(UA); s.verify = False; return s def _curl_available(): try: subprocess.run(["curl", "--version"], capture_output=True, timeout=10, check=True) return True except Exception: return False def build_session(): global SESSION, MODE errs = [] for name, fn in (("標準", _plain), ("Windows憑證存放區", _truststore), ("寬鬆TLS", _legacy), ("不驗證憑證", _noverify)): try: s = fn() r = s.get(PROBE, timeout=45) r.raise_for_status() if "解釋字號" not in r.text: raise ValueError("回應內容不含預期欄位") SESSION, MODE = s, name print(f" 連線策略:{name}") if name == "不驗證憑證": print(" ⚠ 已停用憑證驗證。原始出處網址仍逐則保留,可自行核對。") return except Exception as e: errs.append(f"{name} → {type(e).__name__}: {str(e)[:160]}") if _curl_available(): try: r = subprocess.run(["curl", "-sS", "-L", "--max-time", "60", "-A", UA["User-Agent"], PROBE], capture_output=True, check=True) if "解釋字號" in r.stdout.decode("utf-8", "replace"): SESSION, MODE = "curl", "curl" print(" 連線策略:改用系統 curl(走 Windows 憑證存放區)") return errs.append("curl → 回應內容不含預期欄位") except Exception as e: errs.append(f"curl → {type(e).__name__}: {str(e)[:160]}") else: errs.append("curl → 系統未安裝") print("\n[連線失敗] 四種策略都無法連上司法院憲法法庭網站:") for e in errs: print(" -", e) print("\n請把上面的錯誤訊息貼回對話,我依實際原因調整。") sys.exit(1) def get(url, binary=False, tries=3): for i in range(tries): try: if MODE == "curl": r = subprocess.run(["curl", "-sS", "-L", "--max-time", "90", "-A", UA["User-Agent"], url], capture_output=True, check=True) time.sleep(DELAY) return r.stdout if binary else r.stdout.decode("utf-8", "replace") r = SESSION.get(url, timeout=60) r.raise_for_status() time.sleep(DELAY) return r.content if binary else r.text except Exception: if i == tries - 1: raise time.sleep(2 * (i + 1)) # ---------------------------------------------------------------- 1. 抓取 def targets(): out = [] for n in range(1, 814): out.append((f"釋字{n:04d}", f"{BASE}/docdata.aspx?fid=100&id={JIE_SPECIAL.get(n, 310181 + n)}")) for part in JUD_IDS.split(","): k, i = part.split(":") out.append((f"憲判{k}", f"{BASE}/docdata.aspx?fid=38&id={i}")) return out def fetch_one(key, url): dst = RAW / f"{key}.html" if dst.exists() and dst.stat().st_size > 200: return key, "cached" soup = BeautifulSoup(get(url), "lxml") art = soup.select_one("#Print_area") if art is None: return key, "NO_CONTENT" dst.write_text(f"\n\n" + str(art), encoding="utf-8") return key, "ok" def step_fetch(): RAW.mkdir(parents=True, exist_ok=True) print("[0/6] 測試連線 …") build_session() tg = targets() todo = [(k, u) for k, u in tg if not (RAW / f"{k}.html").exists()] print(f"[1/6] 抓取本文:共 {len(tg)} 份,待抓 {len(todo)} 份") bad = [] with ThreadPoolExecutor(WORKERS) as ex: futs = {ex.submit(fetch_one, k, u): k for k, u in todo} for i, f in enumerate(as_completed(futs), 1): try: k, st = f.result() if st not in ("ok", "cached"): bad.append(f"{k}:{st}") except Exception as e: bad.append(f"{futs[f]}:{type(e).__name__}") if i % 50 == 0: print(f" {i}/{len(todo)}") if bad: print(" 失敗:", bad[:10]) return bad # ---------------------------------------------------------------- 2. 解析 SEC_ORDER = ["解釋文", "主文", "理由書", "理由", "判決理由", "主筆大法官記載", "大法官就主文所採立場表", "判決摘要", "相關文件"] ROC = re.compile(r"(?:中華民國)?\s*(\d{2,3})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日") def roc_iso(s): m = ROC.search(s or "") if not m: return "", "" return f"{int(m.group(1))+1911:04d}-{int(m.group(2)):02d}-{int(m.group(3)):02d}", m.group(0).replace("中華民國", "").strip() def blocks(node): lis = node.select("ul.paragraphs > li, ul.num-list > li") if lis: out = [] for li in lis: lab = li.find("label") pre = li.find("pre") body = (pre.get_text() if pre else li.get_text()).replace("\r\n", "\n").strip("\n").rstrip() out.append([lab.get_text(strip=True) if lab else "", body]) return out t = node.get_text("\n").strip() return [["", t]] if t else [] def parse_all(): recs, integrity = [], [] for p in sorted(RAW.glob("*.html")): key = p.stem txt = p.read_text(encoding="utf-8") src = (re.search(r"", txt) or [None, ""])[1] at = (re.search(r"", txt) or [None, ""])[1] soup = BeautifulSoup(txt, "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[t.get_text(strip=True)] = v is_jie = key.startswith("釋字") # 完整性驗證:頁面自稱的序號與字號 if is_jie: n = int(key[2:]) sp = soup.select_one(".btn_list span") cnt = re.sub(r"\s", "", sp.get_text()) if sp else "" m = re.search(r"第(\d+)筆/共(\d+)筆", cnt) zt = F.get("解釋字號").get_text(strip=True) if F.get("解釋字號") else "" zm = re.search(r"釋字第(\d+)號", zt) if not m or int(m.group(1)) != n: integrity.append(f"{key} 頁面序號不符:{cnt}") if not zm or int(zm.group(1)) != n: integrity.append(f"{key} 字號不符:{zt[:20]}") num_node = F.get("解釋字號") or F.get("判決字號") num_txt = re.sub(r"\s+", " ", num_node.get_text()).strip() if num_node else key mm = re.match(r"(釋字第\d+號|\d+年憲判字第\d+號)\s*(?:【(.*?)】)?", num_txt) pub_node = F.get("解釋公布院令") or F.get("判決日期") or F.get("公布日期") pub_txt = re.sub(r"\s+", " ", pub_node.get_text()).strip() if pub_node else "" iso, roc = roc_iso(pub_txt) z_node = F.get("解釋爭點") or F.get("案由") secs = [[L, blocks(F[L])] for L in SEC_ORDER if L in F and blocks(F[L])] fnode = F.get("意見書、抄本等文件") or F.get("意見書") ops = [] if fnode: for a in fnode.select("div.lawFileList a"): s = a.find("strong") title = (s.get_text() if s else a.get_text()).strip() if "意見書" not in title: continue href = a.get("href", "") dl = re.search(r"[?&]id=(\d+)", href) ops.append({"t": title, "h": BASE + href if href.startswith("/") else href, "id": dl.group(1) if dl else ""}) lnode = F.get("相關法令") laws = list(dict.fromkeys(p2.get_text(strip=True) for p2 in lnode.select("div.lawLinkList a p"))) if lnode else [] meta = [[L, re.sub(r"\s+", " ", F[L].get_text()).strip()] for L in ("原分案號", "聲請人") if L in F and F[L].get_text(strip=True)] recs.append({"k": key, "ty": "釋字" if is_jie else "憲判", "z": mm.group(1) if mm else num_txt, "n": (mm.group(2) if mm and mm.group(2) else ""), "dt": iso, "roc": roc, "decree": re.sub(r".*?日\s*", "", pub_txt).strip(), "m": meta, "q": re.sub(r"\s+", " ", z_node.get_text()).strip() if z_node else "", "s": secs, "o": ops, "l": laws, "u": src, "at": at}) recs.sort(key=lambda r: (r["ty"] != "釋字", r["k"])) return recs, integrity # ---------------------------------------------------------------- 3. 意見書 PDF def step_pdfs(recs): PDFD.mkdir(parents=True, exist_ok=True) jobs = [] for r in recs: for i, o in enumerate(r["o"], 1): safe = re.sub(r'[\\/:*?"<>|]', "_", o["t"])[:110] jobs.append((f"{r['k']}_{i:02d}_{safe}.pdf", o["h"])) todo = [(n, u) for n, u in jobs if not (PDFD / n).exists()] print(f"[3/6] 意見書 PDF:共 {len(jobs)} 篇,待抓 {len(todo)} 篇") bad = [] def one(name, url): data = get(url, binary=True) if len(data) < 500: raise ValueError("too small") (PDFD / name).write_bytes(data) with ThreadPoolExecutor(WORKERS) as ex: futs = {ex.submit(one, n, u): n for n, u in todo} for i, f in enumerate(as_completed(futs), 1): try: f.result() except Exception as e: bad.append(f"{futs[f]}:{type(e).__name__}") if i % 100 == 0: print(f" {i}/{len(todo)}") if bad: print(" 失敗:", bad[:10]) return bad CJK = re.compile(r"[\u4e00-\u9fff]") def pdf_text(path): try: rd = pypdf.PdfReader(str(path)) t = "\n".join((pg.extract_text() or "") for pg in rd.pages) except Exception: return "", 0.0 t = re.sub(r"\n{3,}", "\n\n", t.replace("\r\n", "\n")).strip() flat = re.sub(r"\s", "", t) q = (len(CJK.findall(flat)) / len(flat)) if len(flat) >= 80 else 0.0 return t, q def step_extract(recs): print("[4/6] 抽取意見書文字 …") store, stats = {}, {"ok": 0, "低品質": 0, "缺檔": 0} for r in recs: for i, o in enumerate(r["o"], 1): safe = re.sub(r'[\\/:*?"<>|]', "_", o["t"])[:110] p = PDFD / f"{r['k']}_{i:02d}_{safe}.pdf" if not p.exists(): stats["缺檔"] += 1 o["x"] = "" continue t, q = pdf_text(p) if q >= 0.25: o["x"] = t stats["ok"] += 1 else: o["x"] = "" o["low"] = True stats["低品質"] += 1 print(" ", stats) return stats # ---------------------------------------------------------------- 5. 產出 # ---------------------------------------------------------------- 大法官姓名 # 官網有兩種寫法:「林大法官俊益」(姓+大法官+名)與「詹森林大法官」(全名+大法官)。 # 先用寬鬆規則掃出候選,剔除碎片後建立名冊,再以名冊回頭比對,避免正則誤切。 _RAW = re.compile(r"([\u4e00-\u9fff]{1,2})大法官([\u4e00-\u9fff]{1,3}?)" r"(?=提出|提|加入|及|與|均|、|,|。|)|\)|之|等|參與|共同|協同|不同|意見|部分|一部|主筆|$)") _LEAD = "及與含和號決書判由第之((、," _TAIL = "提加均出入共之" def _clean_name(n): while n and n[0] in _LEAD: n = n[1:] if n.endswith("主筆"): n = n[:-2] while n and n[-1] in _TAIL: n = n[:-1] return n def _roster(recs): cand = collections.Counter() def scan(t): for m in _RAW.finditer(t or ""): n = _clean_name(m.group(1) + m.group(2)) if 2 <= len(n) <= 4: cand[n] += 1 for m in re.findall(r"([\u4e00-\u9fff]{2,4})大法官主筆", t or ""): n = _clean_name(m) if 2 <= len(n) <= 4: cand[n] += 1 for r in recs: for o in r["o"]: scan(o["t"]) for L, ps in r["s"]: if L == "主筆大法官記載": for _, tx in ps: scan(tx) # 剔除「是其他姓名之真子字串」的碎片,例如「太郎」之於「呂太郎」 return sorted({k for k in cand if not any(k != o and k in o for o in cand)}) def _sitting(main_text): """釋字第1至79號的解釋文末尾附有出席大法官名單,第80號以後官網不再附。 名單以全形空白對齊,單字姓名會被拆成兩段(如「林 彬」),需合併還原。""" i = main_text.find("大法官會議主席") if i < 0: return "", [] blk = re.split(r"\n\s*\n", main_text[i:])[0] m = re.search(r"大法官會議主席\s*院\s*長\s*([^\s ]+)", blk) chair = re.sub(r"大法官$", "", m.group(1)) if m else "" j = blk.find("大法官", 7) if j < 0: return chair, [] toks = [x for x in re.split(r"[\s ]+", blk[j + 3:]) if x] out, k = [], 0 while k < len(toks): if len(toks[k]) == 1 and k + 1 < len(toks) and len(toks[k + 1]) == 1: out.append(toks[k] + toks[k + 1]); k += 2 else: out.append(toks[k]); k += 1 return chair, [x for x in out if 2 <= len(x) <= 3 and x != "大法官"] def derive_judges(recs): """為每則補上 j 欄位:主筆、意見書提出者、加入者、意見書類型、出席名單。""" roster = _roster(recs) TYPES = ["部分協同部分不同意見書", "部分不同部分協同意見書", "部分協同意見書", "部分不同意見書", "一部協同一部不同意見書", "協同意見書", "不同意見書", "意見書"] def find_names(t): flat = re.sub(r"^憲法法庭\d+年憲判字第\d+號判決", "", t or "").replace("大法官", "") got = sorted((flat.index(n), n) for n in roster if n in flat) return flat, got for r in recs: pro, add, tys = [], [], [] for o in r["o"]: flat, got = find_names(o["t"]) ip = flat.find("提出") if got: if ip < 0: p2, a2 = [n for _, n in got], [] else: p2 = [n for i, n in got if i < ip] a2 = [n for i, n in got if i > ip] if not p2: p2, a2 = [got[0][1]], [n for n in a2 if n != got[0][1]] else: p2, a2 = [], [] o["p"], o["a"] = p2, a2 for x in p2: if x not in pro: pro.append(x) for x in a2: if x not in add: add.append(x) ty = next((t for t in TYPES if t in o["t"]), "") o["ty"] = ty if ty and ty not in tys: tys.append(ty) w = [] for L, ps in r["s"]: if L == "主筆大法官記載": for _, tx in ps: f = tx.replace("大法官", "") for n in roster: if n in f and n not in w: w.append(n) mt = "\n".join(px[1] for L, ps in r["s"] if L in ("解釋文", "主文") for px in ps) chair, sit = _sitting(mt) r["j"] = {"w": w, "p": pro, "a": add, "t": tys, "c": chair, "s": sit} return roster def wikilaw(raw): return re.sub(r"\s*[((][\d./]+[))]\s*$", "", raw).strip() def write_notes(recs): (ROOT / "10_釋字").mkdir(exist_ok=True) (ROOT / "20_憲判").mkdir(exist_ok=True) (ROOT / "30_法條").mkdir(exist_ok=True) for r in recs: sub = "10_釋字" if r["ty"] == "釋字" else "20_憲判" fn = f"釋字第{int(r['k'][2:]):04d}號" if r["ty"] == "釋字" else r["z"] y = ["---", f'類型: "{r["ty"]}"', f'字號: "{r["z"]}"', f'案名: "{r["n"]}"', f'公布日期: "{r["dt"]}"', f'公布日期_民國: "{r["roc"]}"', f'院令字號: "{r["decree"]}"', f'爭點: "{r["q"][:400].replace(chr(34), chr(39))}"', "相關法條: [" + ", ".join('"' + wikilaw(x).replace('"', "'") + '"' for x in r["l"]) + "]", f'意見書篇數: {len(r["o"])}', f'原始出處: "{r["u"]}"', f'抓取時間: "{r["at"]}"', "---", ""] md = y + [f'# {r["z"]}' + (f'【{r["n"]}】' if r["n"] else ""), "", "> [!info] 原始出處", f'> 司法院憲法法庭網站 · [官網原文]({r["u"]}) · 公布 {r["roc"] or r["dt"]} · 抓取於 {r["at"][:10]}', ""] for L, v in r["m"]: md.append(f"> **{L}**:{v}") if r["m"]: md.append("") if r["q"]: md += [f'## {"解釋爭點" if r["ty"]=="釋字" else "案由"}', "", r["q"], ""] for L, ps in r["s"]: md += [f"## {L}", ""] for lab, body in ps: md += ([f"**({lab})**"] if lab else []) + [body, ""] if r["o"]: md += [f'## 意見書({len(r["o"])} 篇)', ""] for i, o in enumerate(r["o"], 1): md += [f'### {i}. {o["t"]}', "", f'> 原始檔:[官網 PDF]({o["h"]})', ""] if o.get("ai"): a = o["ai"] md += ["> [!warning] 白話解說(AI 生成,非原文,不得引用)", f'> **立場**:{a["one"]}', f'> **核心主張**:{a["main"]}', f'> **與多數意見的差異**:{a["diff"]}'] if a.get("struct"): md.append(f'> **論證結構**:{a["struct"]}') if a.get("guide"): md.append(f'> **段落指引**:{a["guide"]}') md.append("") if o.get("x"): md += [o["x"], ""] else: md += ["> [!failure] 本篇 PDF 未能取得可用文字層,請點上方連結查閱官網原檔。", ""] if r["l"]: md += ["## 相關法條", ""] for x in r["l"]: v = re.search(r"[((]([\d./]+)[))]\s*$", x) md.append(f"- [[{wikilaw(x)}]]" + (f"(版本 {v.group(1)})" if v else "")) md.append("") ai = r.get("ai") if ai: md += ["---", "", "## 白話解說(AI 生成,非官方原文)", "", "> [!warning] 以下由 AI 依上方原文撰寫,僅供理解輔助。撰寫論文時請引用原文區塊,不得引用本區塊。", "", f'**一句話結論**:{ai["one"]}', "", "**爭點拆解**", "", ai["issue"], "", "**結論與理由**", "", ai["holding"], ""] if ai.get("place"): md += ["**在體系中的位置**", "", ai["place"], ""] (ROOT / sub / f"{fn}.md").write_text("\n".join(md).rstrip() + "\n", encoding="utf-8") # 法條反向索引 lawmap = collections.defaultdict(list) for r in recs: for x in r["l"]: lawmap[wikilaw(x)].append(r) used = {} for law, rs in lawmap.items(): safe = re.sub(r'[\\/:*?"<>|\r\n]', "_", law).strip() if not safe: continue # 檔名上限:Linux 為 255 bytes,中文一字 3 bytes,保守截到 70 字並去重 if len(safe) > 70: safe = safe[:70].rstrip() + "…" if used.get(safe) not in (None, law): safe = safe[:66] + "_%02d" % len(used) used[safe] = law body = ["---", '類型: "法條節點"', f'法條: "{law}"', f"被引次數: {len(rs)}", "---", "", f"# {law}", "", f"本庫中共 {len(rs)} 則解釋/判決將本條列為相關法令。", ""] for r in sorted(rs, key=lambda x: x["dt"]): fn = f"釋字第{int(r['k'][2:]):04d}號" if r["ty"] == "釋字" else r["z"] body.append(f'- [[{fn}\\|{r["z"]}]] {r["n"]} ({r["dt"]})') (ROOT / "30_法條" / f"{safe}.md").write_text("\n".join(body) + "\n", encoding="utf-8") return len(lawmap) def write_index(recs): (ROOT / "00_索引").mkdir(exist_ok=True) jie = [r for r in recs if r["ty"] == "釋字"] jud = [r for r in recs if r["ty"] == "憲判"] L = ["# 總索引", "", f"- 大法官解釋:{len(jie)} 則", f"- 憲法法庭判決:{len(jud)} 則", "", "## 大法官解釋", "", "| 字號 | 案名 | 公布日期 | 意見書 | 爭點 |", "|---|---|---|---|---|"] for r in jie: L.append(f'| [[釋字第{int(r["k"][2:]):04d}號\\|{r["z"]}]] | {r["n"]} | {r["dt"]} | {len(r["o"])} | {r["q"][:60]} |') L += ["", "## 憲法法庭判決", "", "| 字號 | 案名 | 公布日期 | 意見書 |", "|---|---|---|---|"] for r in jud: L.append(f'| [[{r["z"]}]] | {r["n"]} | {r["dt"]} | {len(r["o"])} |') (ROOT / "00_索引" / "總索引.md").write_text("\n".join(L) + "\n", encoding="utf-8") Q = ["# 爭點速查", ""] + [f'- **{r["z"]}** {r["q"]}' for r in recs] (ROOT / "00_索引" / "爭點速查.md").write_text("\n".join(Q) + "\n", encoding="utf-8") by = collections.defaultdict(list) for r in recs: by[r["dt"][:4] or "____"].append(r) Y = ["# 年表", ""] for y in sorted(by): Y.append(f"## {y}({len(by[y])} 則)") Y += [f'- {r["z"]} {r["n"]}' for r in sorted(by[y], key=lambda x: x["dt"])] Y.append("") (ROOT / "00_索引" / "年表.md").write_text("\n".join(Y) + "\n", encoding="utf-8") CSS = """*{box-sizing:border-box}body{margin:0;font:15px/1.9 "Noto Sans TC","Microsoft JhengHei",system-ui,sans-serif;color:#1a1a1a;background:#f6f5f3} header{background:#6b1220;color:#fff;padding:13px 22px;display:flex;align-items:baseline;gap:16px;flex-wrap:wrap} header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.5px}header .sub{font-size:12px;opacity:.82} header .dash{margin-left:auto;color:#ffd9a8;font-size:12.5px;text-decoration:none;border:1px solid #a34a54; border-radius:5px;padding:4px 12px;white-space:nowrap} header .dash:hover{background:#fff;color:#6b1220;border-color:#fff} .hx{border:1px solid #e2dad2;border-radius:6px;padding:10px 14px;margin:0 0 12px;background:#fbfaf9;font-size:13px} .hx b{color:#6b1220} .hx .ch{margin:8px 0 0;padding-left:14px;border-left:2px solid #ddd} .hx .ch div{margin:1px 0;color:#444} .hx .ch em{font-style:normal;color:#999;font-size:12px;margin-right:6px} .hx a{color:#6b1220} .wrap{display:flex;height:calc(100vh - 50px)} .side{width:346px;min-width:280px;border-right:1px solid #ddd;background:#fff;display:flex;flex-direction:column} .ctl{padding:12px;border-bottom:1px solid #eee;display:flex;flex-direction:column;gap:8px} #q{width:100%;padding:9px 11px;border:1px solid #ccc;border-radius:5px;font-size:14px;font-family:inherit} .row{display:flex;gap:9px;align-items:center;font-size:12px;color:#555;flex-wrap:wrap} select{padding:5px;border:1px solid #ccc;border-radius:4px;font-family:inherit;font-size:12px} #list{overflow:auto;flex:1} .it{padding:10px 13px;border-bottom:1px solid #f0f0f0;cursor:pointer;border-left:3px solid transparent} .it:hover{background:#faf7f5}.it.on{background:#f5ebeb;border-left-color:#6b1220} .it b{font-size:13.5px}.nm{font-size:12px;color:#6b1220;margin-left:6px} .mt{font-size:11px;color:#999}.qq{font-size:12px;color:#666;margin-top:3px;line-height:1.6} #doc{flex:1;overflow:auto;padding:32px 46px 80px;background:#fff} .hd{display:flex;align-items:flex-start;gap:16px;justify-content:space-between} #doc h1{font-size:23px;margin:0 0 8px;color:#6b1220;line-height:1.5} #cp{flex:none;background:#fff;border:1px solid #6b1220;color:#6b1220;border-radius:4px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit} #cp:hover{background:#6b1220;color:#fff} #doc h2{font-size:16px;margin:32px 0 10px;padding-left:10px;border-left:4px solid #6b1220} #doc h3{font-size:14px;margin:22px 0 6px;color:#444} .src{font-size:12px;color:#777;padding-bottom:10px;border-bottom:1px solid #eee}.src a{color:#6b1220} .src2{font-size:12px;color:#666;padding:8px 0;border-bottom:1px solid #f0f0f0} .bd{white-space:pre-wrap;margin:0 0 14px;text-align:justify;max-width:54em} .op{white-space:pre-wrap;margin:0 0 18px;max-width:54em;background:#fbfaf9;border-left:3px solid #ddd;padding:10px 14px;font-size:14px} .pn{display:inline-block;min-width:2em;color:#a05;font-size:12px;vertical-align:top} .warn{background:#fff4f4;border-left:3px solid #c33;padding:8px 12px;font-size:13px;margin-bottom:14px} .laws .lw{display:inline-block;background:#f1ede9;border:1px solid #e2dad2;border-radius:3px;padding:2px 9px;margin:0 6px 6px 0;font-size:13px} mark{background:#ffe9a8;padding:0 1px} .ai{background:#fffaf0;border:1px solid #f0dfc0;border-left:4px solid #d79a2b;border-radius:4px;padding:14px 18px;margin:18px 0;max-width:54em} .ai .cap{font-size:12px;color:#8a6114;font-weight:600;margin-bottom:8px} .ai h4{margin:14px 0 4px;font-size:13.5px;color:#7a5410} .ai p{margin:0 0 6px;white-space:pre-wrap} .ai .one{font-size:15px;font-weight:600;color:#5a3f0c} .chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:14px 0 4px;padding-bottom:12px;border-bottom:1px solid #eee} .chips .lb{font-size:12px;color:#888;margin-right:2px} .chip{font-size:12.5px;padding:4px 12px;border:1px solid #d6cec6;border-radius:14px;cursor:pointer;background:#fff;color:#888;user-select:none} .chip:hover{border-color:#6b1220} .chip.on{background:#6b1220;border-color:#6b1220;color:#fff} .chip.mini{padding:3px 9px;font-size:11.5px;border-radius:4px;color:#6b1220;border-color:#c9b3b3} .ck{margin-right:7px;vertical-align:-1px;cursor:pointer} .cmpbar{background:#f3e9e9;border-bottom:1px solid #e0cfcf;padding:8px 12px;font-size:12px;display:flex;gap:6px;align-items:center;flex-wrap:wrap} .cmpbar .names{width:100%;color:#7a5c5c;line-height:1.6;max-height:3.4em;overflow:auto} .cmpbar b{color:#6b1220} .cmpbar button{font-size:11.5px;padding:3px 10px;border:1px solid #6b1220;background:#fff;color:#6b1220;border-radius:4px;cursor:pointer;font-family:inherit} .cmpbar button:hover{background:#6b1220;color:#fff} .cmptbl{border-collapse:collapse;width:max-content;min-width:100%;margin-top:14px} .cmptbl th,.cmptbl td{border:1px solid #e0dad4;vertical-align:top;padding:9px 12px;text-align:left} .cmptbl th.rh{background:#f7f4f1;font-size:13px;width:110px;min-width:110px;position:sticky;left:0;z-index:2;color:#6b1220} .cmptbl thead th{background:#6b1220;color:#fff;font-size:13px;min-width:330px;max-width:430px} .cmptbl thead th.rh{background:#6b1220;color:#fff} .cmptbl td{min-width:330px;max-width:430px;font-size:13.5px;line-height:1.8} .cmpcell{max-height:340px;overflow:auto;white-space:pre-wrap} .cmpwrap{overflow:auto} #advBtn{width:100%;padding:6px;border:1px solid #6b1220;background:#fff;color:#6b1220;border-radius:4px;cursor:pointer;font-family:inherit;font-size:12.5px} #advBtn:hover{background:#6b1220;color:#fff} .mask{position:fixed;inset:0;background:rgba(0,0,0,.42);display:flex;align-items:flex-start;justify-content:center;z-index:99;padding:34px 16px;overflow:auto} .modal{background:#fff;border-radius:7px;width:min(880px,100%);box-shadow:0 12px 40px rgba(0,0,0,.3)} .modal h3{margin:0;padding:14px 20px;background:#6b1220;color:#fff;font-size:15px;border-radius:7px 7px 0 0} .grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 18px;padding:18px 20px} .grid .f{display:flex;flex-direction:column;gap:4px} .grid .f.wide{grid-column:1/-1} .grid label{font-size:12px;color:#6b1220;font-weight:600} .grid input,.grid select{padding:7px 9px;border:1px solid #ccc;border-radius:4px;font-family:inherit;font-size:13px} .grid .hint{font-size:11px;color:#999;font-weight:400} .mfoot{padding:12px 20px;border-top:1px solid #eee;display:flex;gap:8px;justify-content:flex-end;align-items:center} .mfoot button{padding:7px 18px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:13px;border:1px solid #6b1220} .mfoot .go{background:#6b1220;color:#fff}.mfoot .cl{background:#fff;color:#6b1220} .conds{padding:8px 12px;border-bottom:1px solid #eee;display:flex;flex-wrap:wrap;gap:5px;align-items:center;background:#fcfaf9} .conds .lb{font-size:11.5px;color:#888} .cond{font-size:11.5px;background:#6b1220;color:#fff;border-radius:12px;padding:3px 8px 3px 10px;display:inline-flex;gap:6px;align-items:center} .cond i{font-style:normal;cursor:pointer;opacity:.75}.cond i:hover{opacity:1} .cond b{font-weight:600;opacity:.8} .conds .clr{font-size:11.5px;color:#6b1220;cursor:pointer;text-decoration:underline;margin-left:auto} .rng{display:flex;gap:6px;align-items:center}.rng input{width:100%} .jump{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0 0 6px} .jump .lb{font-size:12px;color:#888;margin-right:2px} .jump a{font-size:12.5px;padding:4px 11px;border:1px solid #c9b3b3;border-radius:4px;color:#6b1220;text-decoration:none;background:#fff} .jump a:hover{background:#6b1220;color:#fff;border-color:#6b1220} .opbox{border:1px solid #e6dfd9;border-radius:5px;margin:0 0 8px;max-width:54em;overflow:hidden} .ophd{display:flex;gap:8px;align-items:flex-start;padding:9px 12px;background:#faf7f5;cursor:pointer;font-size:13.5px} .ophd:hover{background:#f3ebe8} .ophd .tw{flex:1} .ophd .ar{color:#6b1220;font-size:12px;flex:none;margin-top:2px} .ophd .tg{font-size:11px;color:#fff;background:#a08;border-radius:3px;padding:1px 6px;flex:none;margin-top:2px} .opbody{padding:10px 14px;border-top:1px solid #eee;white-space:pre-wrap;font-size:14px} .opbar{display:flex;gap:8px;align-items:center;margin:6px 0 12px;flex-wrap:wrap} .opbar button{font-size:11.5px;padding:4px 11px;border:1px solid #6b1220;background:#fff;color:#6b1220;border-radius:4px;cursor:pointer;font-family:inherit} .opbar button:hover{background:#6b1220;color:#fff} .opbar .n{font-size:11.5px;color:#888} @media(max-width:760px){.wrap{flex-direction:column;height:auto}.side{width:100%;height:46vh}#doc{padding:20px}.grid{grid-template-columns:1fr}}""" APP = r"""(function(){ const D=JSON.parse(document.getElementById('data').textContent); const $=s=>document.querySelector(s); let cur=null,hits=D; const esc=s=>(s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); const FIELDS=[['all','全部欄位'],['id','字號・案名'],['issue','爭點・案由'],['main','解釋文・主文'], ['reason','理由書・理由'],['op','意見書'],['law','相關法條'],['ai','白話解說'],['misc','其他區塊'],['judge','大法官']]; const SUB=['id','issue','main','reason','op','law','ai','misc','judge']; const SECS=[['issue','爭點'],['main','解釋文・主文'],['reason','理由書・理由'], ['op','意見書'],['law','相關法條'],['ai','白話解說'],['misc','其他區塊']]; const MAIN=['解釋文','主文'], REASON=['理由書','理由','判決理由']; const OPTYPES=['協同意見書','不同意見書','部分協同意見書','部分不同意見書','部分協同部分不同意見書','部分不同部分協同意見書','一部協同一部不同意見書']; let ON={issue:1,main:1,reason:1,op:1,law:1,ai:1,misc:1}; let CMP=[]; let ADV={}; // 進階條件 function secs(r,names){ return r.s.filter(x=>names.indexOf(x[0])>=0); } function fld(r,f){ if(!r._f) r._f={}; if(r._f[f]!==undefined) return r._f[f]; let v=''; if(f==='id') v=r.z+' '+r.n; else if(f==='issue') v=r.q; else if(f==='main') v=secs(r,MAIN).map(x=>x[1].map(p=>p[1]).join('\n')).join('\n'); else if(f==='reason') v=secs(r,REASON).map(x=>x[1].map(p=>p[1]).join('\n')).join('\n'); else if(f==='op') v=r.o.map(o=>o.t+'\n'+(o.x||'')+'\n'+(o.ai?o.ai.one+' '+o.ai.main+' '+o.ai.diff+' '+(o.ai.struct||'')+' '+(o.ai.guide||''):'')).join('\n'); else if(f==='law') v=r.l.join(' '); else if(f==='ai') v=r.ai?[r.ai.one,r.ai.issue,r.ai.holding,r.ai.place].join('\n'):''; else if(f==='misc') v=r.s.filter(x=>MAIN.indexOf(x[0])<0&&REASON.indexOf(x[0])<0).map(x=>x[0]+'\n'+x[1].map(p=>p[1]).join('\n')).join('\n'); else if(f==='judge') v=r.j?[].concat(r.j.w,r.j.p,r.j.a,r.j.s||[],r.j.c?[r.j.c]:[]).join(' '):''; if(f==='id'||f==='issue'||f==='op'){ if(v.indexOf('大法官')>=0) v=v+'\n'+v.split('大法官').join(''); } r._f[f]=v; return v; } function terms(q){ return (q||'').split(/\s+/).filter(Boolean).map(function(t){ if(t.indexOf('大法官')<0) return t; var v=t.split('大法官').join(''); return v.length>=2 ? v : t; }); } function hit(r,f,ts){ if(!ts.length) return true; if(f==='all') return ts.every(t=>SUB.some(x=>fld(r,x).indexOf(t)>=0)); const v=fld(r,f); return ts.every(t=>v.indexOf(t)>=0); } function judgeHit(r){ if(!ADV.judge) return true; const ts=terms(ADV.judge); if(!ts.length) return true; const j=r.j||{w:[],p:[],a:[]}; const pool = ADV.jrole==='w'?j.w : ADV.jrole==='p'?j.p : ADV.jrole==='a'?j.a : ADV.jrole==='s'?(j.s||[]) : [].concat(j.w,j.p,j.a,j.s||[],j.c?[j.c]:[]); const v=pool.join(' '); return ts.every(t=>v.indexOf(t)>=0); } function advHit(r){ for(const f of ['id','issue','main','reason','op','law','ai','misc']){ if(ADV[f] && !hit(r,f,terms(ADV[f]))) return false; if(ADV['not_'+f]){ const v=fld(r,f); if(terms(ADV['not_'+f]).some(t=>v.indexOf(t)>=0)) return false; } } if(!judgeHit(r)) return false; if(ADV.not_judge){ const v=fld(r,'judge'); if(terms(ADV.not_judge).some(t=>v.indexOf(t)>=0)) return false; } if(ADV.otype){ const ts=(r.j&&r.j.t)||[]; if(ts.indexOf(ADV.otype)<0) return false; } if(ADV.y1 && +r.dt.slice(0,4) < +ADV.y1) return false; if(ADV.y2 && +r.dt.slice(0,4) > +ADV.y2) return false; if(ADV.nop && r.o.length < +ADV.nop) return false; return true; } function mark(t,ts){ let h=esc(t); if(!ts.length)return h; const all=[]; ts.forEach(x=>{ all.push(x); if(x.length>=2) all.push(x.slice(0,1)+'大法官'+x.slice(1)); }); all.forEach(x=>{h=h.replace(new RegExp(x.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'g'),'$&');}); return h; } function allTerms(){ let ts=terms($('#q').value.trim()); ['id','issue','main','reason','op','law','ai','misc','judge'].forEach(f=>{ if(ADV[f]) ts=ts.concat(terms(ADV[f])); }); return ts; } function snip(r,f,ts){ if(!ts.length) return r.q.slice(0,88); const order = f==='all' ? ['issue','id','main','reason','ai','law','op','misc','judge'] : [f]; for(const x of order){ const v=fld(r,x); const i=v.indexOf(ts[0]); if(i>=0){ const a=Math.max(0,i-30); return (a?'…':'')+v.slice(a,a+96).replace(/\n/g,' ')+'…'; } } return r.q.slice(0,88); } function cite(r){ return (r.ty==='釋字'?'司法院'+r.z+'解釋':'憲法法庭'+r.z+'判決')+(r.n?'【'+r.n+'】':''); } // ---------------- 進階篩選面板 ---------------- const ADVF=[['id','字號・案名'],['issue','爭點・案由'],['main','解釋文・主文'],['reason','理由書・理由'], ['op','意見書內容'],['law','相關法條'],['ai','白話解說'],['misc','其他區塊']]; function openAdv(){ const m=document.createElement('div'); m.className='mask'; m.id='mask'; let g=ADVF.map(f=>'
' +'
' +'
').join(''); g+='
' +'
' +'
'; g+='
'; g+='
'; g+='
'; g+='
' +'
'; m.innerHTML=''; document.body.appendChild(m); m.addEventListener('click',e=>{ if(e.target===m) m.remove(); }); $('#advCancel').onclick=()=>m.remove(); $('#advClear').onclick=()=>{ ADV={}; m.remove(); search(); }; $('#advGo').onclick=function(){ const n={}; m.querySelectorAll('[data-a]').forEach(function(el){ const v=(el.value||'').trim(); if(v) n[el.dataset.a]=v; }); if(!n.judge) delete n.jrole; ADV=n; m.remove(); search(); }; m.querySelector('input').focus(); } function condLabel(k){ const f=ADVF.find(x=>x[0]===k); if(f) return f[1]; if(k.slice(0,4)==='not_'){ const b=condLabel(k.slice(4)); return b+'(不含)'; } return {judge:'大法官',jrole:'身分',otype:'意見書類型',nop:'意見書篇數≥',y1:'公布年自',y2:'公布年至'}[k]||k; } function condVal(k,v){ if(k==='jrole') return {p:'提出',a:'加入',w:'主筆',s:'出席'}[v]||v; return v; } function conds(){ const c=$('#conds'); const ks=Object.keys(ADV).filter(k=>ADV[k]!=='' && !(k==='jrole'&&!ADV.judge)); ks.sort(); if(!ks.length){ c.style.display='none'; return; } c.style.display='flex'; c.innerHTML='篩選條件' + ks.map(k=>''+condLabel(k)+''+esc(condVal(k,ADV[k]))+'×').join('') + '全部清除'; c.querySelectorAll('[data-x]').forEach(function(e){ e.onclick=function(){ delete ADV[e.dataset.x]; if(e.dataset.x==='judge') delete ADV.jrole; search(); }; }); $('#condClr').onclick=function(){ ADV={}; search(); }; } function search(){ const q=$('#q').value.trim(),ty=$('#ty').value,yr=$('#yr').value,op=$('#op').checked,f=$('#fd').value; const ts=terms(q); hits=D.filter(r=>{ if(ty&&r.ty!==ty)return false; if(op&&!r.o.length)return false; if(yr){const y=+r.dt.slice(0,4); if(yr==='a'&&y>=1990)return false; if(yr==='b'&&(y<1990||y>=2005))return false; if(yr==='c'&&(y<2005||y>=2015))return false; if(yr==='d'&&y<2015)return false;} if(!hit(r,f,ts)) return false; return advHit(r); }); $('#cnt').textContent=hits.length+' 筆'; const mts=allTerms(); $('#list').innerHTML=hits.slice(0,500).map(r=>'
' +'=0?' checked':'')+' title="加入比較">'+esc(r.z)+'' +(r.n?''+esc(r.n)+'':'') +'
'+esc(r.dt)+(r.o.length?' · 意見書 '+r.o.length:'')+'
' +'
'+mark(snip(r,f,mts),mts)+'
').join('') +(hits.length>500?'
僅列前 500 筆
':''); conds(); bar(); } function bar(){ const b=$('#cmpbar'); if(!CMP.length){ b.style.display='none'; return; } b.style.display='flex'; b.innerHTML='比較清單 '+CMP.length+' 案' +'' +'
'+CMP.map(k=>{const r=D.find(x=>x.k===k);return r?r.z:k;}).join('、')+'
'; $('#doCmp').onclick=showCompare; $('#clrCmp').onclick=function(){CMP=[];search(); if(cur)show(cur.k);}; $('#cpCmp').onclick=copyCompare; } function chips(){ return '
顯示區塊' + SECS.map(x=>''+x[1]+'').join('') + '全選全不選
'; } function bindChips(after){ document.querySelectorAll('.chips .chip').forEach(function(c){ c.onclick=function(){ if(c.dataset.all){ SECS.forEach(x=>ON[x[0]]=1); } else if(c.dataset.none){ SECS.forEach(x=>ON[x[0]]=0); } else { ON[c.dataset.s]=ON[c.dataset.s]?0:1; } after(); }; }); } function judgeLine(r){ const j=r.j; if(!j) return ''; const bits=[]; if(j.c) bits.push('主席 '+esc(j.c)); if(j.s&&j.s.length) bits.push('出席大法官 '+esc(j.s.join('、'))); if(j.w&&j.w.length) bits.push('主筆 '+esc(j.w.join('、'))); if(j.p&&j.p.length) bits.push('提出意見書 '+esc(j.p.join('、'))); if(j.a&&j.a.length) bits.push('加入 '+esc(j.a.join('、'))); return bits.length?'
'+bits.join(' | ')+'
':''; } function judgeText(r){ const j=r.j; if(!j) return ''; const b=[]; if(j.c) b.push('主席:'+j.c); if(j.s&&j.s.length) b.push('出席:'+j.s.join('、')); if(j.w&&j.w.length) b.push('主筆:'+j.w.join('、')); if(j.p&&j.p.length) b.push('提出意見書:'+j.p.join('、')); if(j.a&&j.a.length) b.push('加入:'+j.a.join('、')); return b.join('\n'); } function show(k){ const r=D.find(x=>x.k===k); if(!r)return; cur=r; history.replaceState(null,'','#'+k); const ts=allTerms(); let h='

'+esc(r.z)+(r.n?'【'+esc(r.n)+'】':'')+'

'; h+='
司法院憲法法庭網站 · 官網原文 · 公布 '+esc(r.roc||r.dt)+(r.decree?' · '+esc(r.decree):'')+' · 抓取於 '+esc((r.at||'').slice(0,10))+'
'; if(r.m&&r.m.length) h+='
'+r.m.map(x=>''+esc(x[0])+' '+esc(x[1])).join(' | ')+'
'; h+=judgeLine(r); h+=chips(); const jump=[]; let body=''; if(ON.issue&&r.q){ const t1=(r.ty==='釋字'?'解釋爭點':'案由'); jump.push(['s_issue',t1]); body+='

'+t1+'

'+mark(r.q,ts)+'
'; } r.s.forEach(function(p,ix){ const key = MAIN.indexOf(p[0])>=0?'main':(REASON.indexOf(p[0])>=0?'reason':'misc'); if(!ON[key]) return; const id='s_sec'+ix; jump.push([id,p[0]]); body+='

'+esc(p[0])+'

'; p[1].forEach(function(b){ body+='
'+(b[0]?''+esc(b[0])+'':'')+mark(b[1],ts)+'
'; }); }); if(ON.op&&r.o.length){ jump.push(['s_op','意見書']); body+='

意見書('+r.o.length+' 篇)

'; body+='
' + '已選 0 篇
'; r.o.forEach(function(o,i){ body+='
' +'
' +'' +(o.ty?''+esc(o.ty)+'':'') +''+(i+1)+'. '+mark(o.t,ts)+'' +'官網PDF
' +'
'; }); } if(ON.law&&r.l.length){ jump.push(['s_law','相關法條']); body+='

相關法條

'+r.l.map(x=>''+mark(x,ts)+'').join('')+'
'; } if(ON.ai&&r.ai){ jump.push(['s_ai','白話解說']); body+='

白話解說

⚠ 本區塊由 AI 依上方原文撰寫,非官方原文。撰寫論文請引用原文,不得引用本區塊。
' +'

'+mark(r.ai.one,ts)+'

' +'

爭點拆解

'+mark(r.ai.issue,ts)+'

' +'

結論與理由

'+mark(r.ai.holding,ts)+'

' +(r.ai.place?'

在體系中的位置

'+mark(r.ai.place,ts)+'

':'')+'
'; } if(r.hx){ jump.push(['s_hx','案件歷程']); const dash='研究儀表板.html#his/'+encodeURIComponent(r.k); body+='

案件歷程

' +''+r.hx.n+' 筆確定終局裁判與前審紀錄,構成 '+r.hx.nc+' 條獨立審級鏈' +(r.hx.m?',另有 '+r.hx.m+' 筆未命中':'')+'。' +' 在研究儀表板展開 →' +r.hx.c.map(function(c){ return '
'+c.map(function(s){ return '' +''+esc(s[0])+''+esc(s[1])+'
'; }).join('')+'
'; }).join('') +'
' +'串接自司法院終結案件資料(DisposedCase 多年期),非官網解釋文內容。' +'該資料集不含當事人姓名,故無法以個人為單位追蹤。
'; } if(jump.length>1) h+='
跳至' + jump.map(x=>''+esc(x[1])+'').join('')+'
'; h+=body; const dv=$('#doc'); dv.innerHTML=h; dv.scrollTop=0; dv.querySelectorAll('[data-j]').forEach(function(a){ a.onclick=function(e){ e.preventDefault(); const el=document.getElementById(a.dataset.j); if(el) dv.scrollTop = el.offsetTop - dv.offsetTop - 8; }; }); bindOpinions(r,ts); bindChips(function(){show(k);}); $('#cp').onclick=function(){navigator.clipboard.writeText(cite(r)+','+r.u).then(function(){$('#cp').textContent='已複製';setTimeout(function(){$('#cp').textContent='複製引用';},1500);});}; search(); } function bindOpinions(r,ts){ const dv=$('#doc'); const sel=new Set(); function setN(){ const e=document.getElementById('opN'); if(e) e.textContent='已選 '+sel.size+' 篇'; } function toggle(i,force){ const b=dv.querySelector('[data-b="'+i+'"]'); const a=dv.querySelector('[data-ar="'+i+'"]'); if(!b) return; const openIt = force===undefined ? b.style.display==='none' : force; b.style.display = openIt?'block':'none'; if(a) a.textContent = openIt?'▾':'▸'; } dv.querySelectorAll('.ophd').forEach(function(hd){ hd.onclick=function(){ toggle(hd.parentNode.dataset.i); }; }); dv.querySelectorAll('.opck').forEach(function(c){ c.onchange=function(){ if(c.checked) sel.add(+c.dataset.i); else sel.delete(+c.dataset.i); setN(); }; }); const all=document.getElementById('opAll'), none=document.getElementById('opNone'), cmp=document.getElementById('opCmp'); if(all) all.onclick=function(){ r.o.forEach(function(_,i){ toggle(i,true); }); }; if(none) none.onclick=function(){ r.o.forEach(function(_,i){ toggle(i,false); }); }; if(cmp) cmp.onclick=function(){ const idx=[...sel].sort((a,b)=>a-b); if(idx.length<1){ alert('請先勾選要比較的意見書'); return; } showOpCompare(r,idx,ts); }; } function opCell(o,key){ if(key==='ty') return o.ty||'(未標類型)'; if(key==='who') return ((o.p&&o.p.length)?'提出:'+o.p.join('、'):'(未標作者)')+((o.a&&o.a.length)?'\n加入:'+o.a.join('、'):''); if(key==='ai') return o.ai?('【立場】'+o.ai.one+'\n\n【核心主張】'+o.ai.main+'\n\n【與多數意見的差異】'+o.ai.diff +(o.ai.struct?'\n\n【論證結構】'+o.ai.struct:'')+(o.ai.guide?'\n\n【段落指引】'+o.ai.guide:'')):'(尚未產生白話解說)'; if(key==='x') return o.x||'(本篇無可用文字層)'; return ''; } function showOpCompare(r,idx,ts){ const OPROWS=[['ty','意見書類型'],['who','提出/加入'],['ai','白話解說'],['x','全文']]; const os=idx.map(i=>r.o[i]); let h='

意見書比較 '+esc(r.z)+'('+os.length+' 篇)

'; h+='
同一則之意見書並排對照。全文逐字照錄,白話解說列為 AI 撰寫,不得引用。' +' ← 回到本則內文
'; h+='
' + os.map((o,n)=>'').join('')+''; OPROWS.forEach(function(f){ h+=''+os.map(function(o){ const t=opCell(o,f[0]); return ''; }).join('')+''; }); h+=''+os.map(o=>'').join('')+''; h+='
欄位'+(idx[n]+1)+'. '+esc(o.t.slice(0,40))+(o.t.length>40?'…':'')+'
'+f[1]+'
'+mark(t,ts)+'
官網PDF開啟原檔
'; const dv=$('#doc'); dv.innerHTML=h; dv.scrollTop=0; document.getElementById('backCase').onclick=function(e){ e.preventDefault(); show(r.k); }; document.getElementById('cp').onclick=function(){ const e2=t=>(t||'').replace(/\|/g,'\\|').replace(/\n/g,'
'); let md='| 欄位 | '+os.map((o,n)=>(idx[n]+1)+'. '+o.t).join(' | ')+' |\n|---|'+os.map(()=>'---').join('|')+'|\n'; OPROWS.forEach(function(f){ md+='| '+f[1]+' | '+os.map(o=>e2(opCell(o,f[0]))).join(' | ')+' |\n'; }); md+='| 官網PDF | '+os.map(o=>o.h).join(' | ')+' |\n'; md='### '+cite(r)+' 意見書比較\n\n'+md; navigator.clipboard.writeText(md).then(function(){ const b=document.getElementById('cp'); b.textContent='已複製'; setTimeout(function(){b.textContent='複製為表格';},1500); }); }; } function cellText(r,key){ if(key==='issue') return r.q||''; if(key==='law') return r.l.join('\n'); if(key==='ai') return r.ai?('【一句話】'+r.ai.one+'\n\n【爭點拆解】'+r.ai.issue+'\n\n【結論與理由】'+r.ai.holding+(r.ai.place?'\n\n【體系定位】'+r.ai.place:'')):''; if(key==='op') return r.o.length?r.o.map((o,i)=>(i+1)+'. '+o.t).join('\n'):'(無意見書)'; const names = key==='main'?MAIN:(key==='reason'?REASON:null); const list = names? secs(r,names) : r.s.filter(x=>MAIN.indexOf(x[0])<0&&REASON.indexOf(x[0])<0); return list.map(x=>x[0]+'\n'+x[1].map(p=>(p[0]?'('+p[0]+')':'')+p[1]).join('\n')).join('\n\n'); } function cmpRows(){ return SECS.filter(x=>ON[x[0]]); } function showCompare(){ const rs=CMP.map(k=>D.find(x=>x.k===k)).filter(Boolean); if(!rs.length) return; const ts=allTerms(); let h='

多案比較('+rs.length+' 案)

'; h+='
比較的欄位由下方「顯示區塊」控制,取消勾選即可把該列從表中拿掉。原文逐字照錄,白話解說列不得引用。
'; h+=chips(); h+='
' + rs.map(r=>'').join('')+''; h+=''+rs.map(r=>'').join('')+''; h+=''+rs.map(r=>'').join('')+''; cmpRows().forEach(function(f){ h+=''+rs.map(function(r){ const t=cellText(r,f[0]); return ''; }).join('')+''; }); h+='
欄位'+esc(r.z)+(r.n?'
【'+esc(r.n)+'】':'')+'
公布日期'+esc(r.roc||r.dt)+'
大法官
'+(judgeText(r)?mark(judgeText(r),ts):'(無)')+'
'+f[1]+'
'+(t?mark(t,ts):'(無)')+'
'; const dv=$('#doc'); dv.innerHTML=h; dv.scrollTop=0; bindChips(showCompare); $('#cp').onclick=copyCompare; } function copyCompare(){ const rs=CMP.map(k=>D.find(x=>x.k===k)).filter(Boolean); if(!rs.length) return; const esc2=t=>(t||'').replace(/\|/g,'\\|').replace(/\n/g,'
'); let md='| 欄位 | '+rs.map(r=>cite(r)).join(' | ')+' |\n|---|'+rs.map(()=>'---').join('|')+'|\n'; md+='| 公布日期 | '+rs.map(r=>r.roc||r.dt).join(' | ')+' |\n'; md+='| 大法官 | '+rs.map(r=>esc2(judgeText(r))).join(' | ')+' |\n'; cmpRows().forEach(function(f){ md+='| '+f[1]+' | '+rs.map(r=>esc2(cellText(r,f[0]))).join(' | ')+' |\n'; }); md+='| 原始出處 | '+rs.map(r=>r.u).join(' | ')+' |\n'; navigator.clipboard.writeText(md).then(function(){ const b=document.getElementById('cp')||document.getElementById('cpCmp'); if(b){const o=b.textContent;b.textContent='已複製';setTimeout(function(){b.textContent=o;},1500);} }); } $('#fd').innerHTML=FIELDS.map(f=>'').join(''); ['#q','#ty','#yr','#op','#fd'].forEach(function(s){$(s).addEventListener(s==='#q'?'input':'change',search);}); $('#advBtn').onclick=openAdv; $('#list').addEventListener('click',function(e){ const ck=e.target.closest('.ck'); if(ck){ const k=ck.dataset.k; const i=CMP.indexOf(k); if(i>=0)CMP.splice(i,1); else CMP.push(k); bar(); e.stopPropagation(); return; } const it=e.target.closest('.it'); if(it)show(it.dataset.k); }); search(); show(decodeURIComponent(location.hash.slice(1))||'釋字0001'); })();""" def write_html(recs, lite=False, filename=None): """建置並寫檔。只要 HTML 字串請用 build_html。""" doc = build_html(recs, lite=lite) out = ROOT / (filename or "釋字及憲法法庭資料庫.html") out.write_text(doc, encoding="utf-8") return out, len(doc) def build_html(recs, lite=False): if lite: import copy recs = [{**r, "o": [{k2: v2 for k2, v2 in o.items() if k2 != "x"} for o in r["o"]]} for r in recs] nj = sum(1 for r in recs if r["ty"] == "釋字") nd = len(recs) - nj no = sum(len(r["o"]) for r in recs) data = json.dumps(recs, ensure_ascii=False).replace("<", "\\u003c") doc = ("" "" "釋字及憲法法庭資料庫" "

釋字及憲法法庭資料庫

大法官解釋 " + str(nj) + " 則 · 憲法法庭判決 " + str(nd) + " 則 · 意見書 " + str(no) + " 篇 · 逐字取自司法院憲法法庭網站" + ("(輕量版:意見書僅列連結)" if lite else "") + "" "研究儀表板 →
" "
" "" "
" "
" "
" "
" "
" "
" "
" "