#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 把瀏覽器採集到的 #Print_area HTML 轉成 Obsidian Markdown 筆記。 鐵律:解釋爭點/解釋文/理由書/主文/判決理由 一律逐字照錄,不改字、不改標點、不重排段落。 """ import json, re, sys, os, glob, html from pathlib import Path from bs4 import BeautifulSoup ROC_RE = re.compile(r'中華民國\s*(\d+)\s*年\s*(\d+)\s*月\s*(\d+)\s*日') def roc_to_iso(s): m = ROC_RE.search(s or '') if not m: return None y, mo, d = int(m.group(1)) + 1911, int(m.group(2)), int(m.group(3)) return f"{y:04d}-{mo:02d}-{d:02d}" def block_text(li_text): """把 li.text 轉成逐字文字,保留官網段次編號。""" paras = li_text.select('ul.paragraphs > li, ul.num-list > li') if paras: out = [] for li in paras: lab = li.find('label') lab = lab.get_text(strip=True) if lab else '' pre = li.find('pre') body = pre.get_text() if pre else li.get_text() #
內的空白即官網原文排版,只去掉頭尾多餘換行,不動行內空白
body = body.replace('\r\n', '\n').strip('\n').rstrip()
out.append((lab, body))
return out
txt = li_text.get_text('\n').replace('\r\n', '\n').strip()
return [('', txt)] if txt else []
def render_block(pairs):
"""有段號者以官網段次呈現;無段號者直接輸出。"""
lines = []
for lab, body in pairs:
if lab:
lines.append(f"**({lab})**")
lines.append(body)
lines.append('')
return '\n'.join(lines).strip()
def parse_doc(key, rec):
soup = BeautifulSoup(rec['html'], 'lxml')
fields, files, laws = {}, [], []
for ul in soup.select('ul.flex'):
t = ul.find('li', class_='title')
v = ul.find('li', class_='text')
if not t or not v:
continue
name = t.get_text(strip=True)
fields[name] = v
# 檔案清單(意見書、抄本等)
fl = fields.get('意見書、抄本等文件') or fields.get('意見書')
if fl is not None:
for a in fl.select('div.lawFileList a'):
title = a.find('strong')
title = title.get_text(strip=True) if title else a.get_text(strip=True)
href = a.get('href', '')
m = re.search(r'[?&]id=(\d+)', href)
files.append({'標題': title, '下載id': m.group(1) if m else None,
'連結': ('https://cons.judicial.gov.tw' + href) if href.startswith('/') else href})
# 相關法令
ll = fields.get('相關法令')
if ll is not None:
seen = set()
for a in ll.select('div.lawLinkList a'):
p = a.find('p')
name = (p.get_text(strip=True) if p else a.get_text(strip=True))
if name and name not in seen:
seen.add(name)
laws.append(name)
return fields, files, laws
def law_wikilink(raw):
"""「憲法第75條(36.01.01)」→ 顯示原文,連結去掉版本日期。"""
base = re.sub(r'\s*[((][\d./]+[))]\s*$', '', raw).strip()
return f"[[{base}]]" if base else raw
def build_note(key, rec, fields, files, laws, ai_block=None):
is_jie = key.startswith('釋字')
get = lambda *names: next((fields[n] for n in names if n in fields), None)
num_txt = get('解釋字號', '判決字號')
num_txt = num_txt.get_text(' ', strip=True) if num_txt is not None else key
m = re.match(r'(釋字第\d+號|\d+年憲判字第\d+號)\s*(?:【(.*?)】)?', num_txt)
zihao = m.group(1) if m else num_txt
casename = (m.group(2) if m and m.group(2) else '')
pub = get('解釋公布院令', '判決日期', '判決公布日期', '公布日期', '宣示日期')
pub_txt = pub.get_text(' ', strip=True) if pub is not None else ''
iso = roc_to_iso(pub_txt)
roc = ROC_RE.search(pub_txt).group(0).replace('中華民國', '').strip() if ROC_RE.search(pub_txt) else ''
decree = re.sub(r'.*?日\s*', '', pub_txt).strip() if pub_txt else ''
争 = get('解釋爭點', '案由')
争_txt = render_block(block_text(争)) if 争 is not None else ''
sections = []
for label in ['解釋文', '主文', '理由書', '理由', '判決理由',
'主筆大法官記載', '大法官就主文所採立場表', '判決摘要', '相關文件']:
node = fields.get(label)
if node is not None:
body = render_block(block_text(node))
if body:
sections.append((label, body))
op_files = [f for f in files if '意見書' in f['標題']]
fm = {
'類型': '釋字' if is_jie else '憲judgment',
'字號': zihao,
'案名': casename,
'公布日期': iso or '',
'公布日期_民國': roc,
'院令字號': decree,
'爭點': re.sub(r'\s+', ' ', 争_txt)[:400],
'相關法條': [re.sub(r'\s*[((][\d./]+[))]\s*$', '', l).strip() for l in laws],
'聲請人': (get('聲請人').get_text(' ', strip=True)[:200] if get('聲請人') is not None else ''),
'原分案號': (get('原分案號').get_text(' ', strip=True)[:120] if get('原分案號') is not None else ''),
'意見書篇數': len(op_files),
'原始出處': rec['url'] if rec['url'].startswith('http') else 'https://cons.judicial.gov.tw' + rec['url'].lstrip('.'),
'抓取時間': rec['at'],
}
if not is_jie:
fm['類型'] = '憲判'
y = ['---']
for k, v in fm.items():
if isinstance(v, list):
y.append(f"{k}: [" + ', '.join('"' + x.replace('"', "'") + '"' for x in v) + "]")
elif isinstance(v, int):
y.append(f'{k}: {v}')
else:
s = str(v).replace('\n', ' ').replace('"', "'")
y.append(f'{k}: "{s}"')
y.append('---')
title = f"{zihao}" + (f"【{casename}】" if casename else '')
md = ['\n'.join(y), '', f'# {title}', '',
'> [!info] 原始出處',
f"> 司法院憲法法庭網站 · [官網原文]({fm['原始出處']}) · 抓取於 {rec['at'][:10]}",
'']
if 争_txt:
md += ['## ' + ('解釋爭點' if is_jie else '案由'), '', 争_txt, '']
for label, body in sections:
md += [f'## {label}', '', body, '']
if op_files:
md += ['## 意見書', '']
for i, f in enumerate(op_files, 1):
md += [f"### {i}. {f['標題']}", '', f"> 原始檔:[{f['標題']}.pdf]({f['連結']})", '',
'', '']
if laws:
md += ['## 相關法條', '']
for l in laws:
ver = re.search(r'[((]([\d./]+)[))]\s*$', l)
md.append('- ' + law_wikilink(l) + (f'(版本 {ver.group(1)})' if ver else ''))
md += ['']
if ai_block:
md += ['---', '', '## 白話解說(AI 生成,非官方原文)', '',
'> [!warning] 以下由 AI 撰寫,僅供理解輔助。撰寫論文時請引用上方原文,不得引用本區塊。', '',
ai_block, '']
return md_join(md), fm
def md_join(parts):
return '\n'.join(parts).replace('\n\n\n\n', '\n\n').rstrip() + '\n'
def main(indir, outdir):
outdir = Path(outdir)
(outdir / '10_釋字').mkdir(parents=True, exist_ok=True)
(outdir / '20_憲判').mkdir(parents=True, exist_ok=True)
index = []
for path in sorted(glob.glob(os.path.join(indir, '*.json'))):
bundle = json.load(open(path, encoding='utf-8'))
for key, rec in bundle.get('docs', bundle).items():
fields, files, laws = parse_doc(key, rec)
note, fm = build_note(key, rec, fields, files, laws)
sub = '10_釋字' if key.startswith('釋字') else '20_憲判'
fn = fm['字號'] if not key.startswith('釋字') else '釋字第%04d號' % int(re.search(r'\d+', key).group())
(outdir / sub / f'{fn}.md').write_text(note, encoding='utf-8')
index.append({'key': key, 'file': f'{sub}/{fn}.md', **{k: fm[k] for k in ('字號','案名','公布日期','意見書篇數','原始出處')},
'相關法條': fm['相關法條']})
json.dump(index, open(outdir / '_pipeline' / '索引.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
print(f'notes={len(index)}')
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])