From 97b848288482ab3c6dd277c3ccdcca3eced7d52e Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:04:15 +0900 Subject: sanpi.mdx ほか: 議案の賛否一覧の試作(下書き)を追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小平市議会の「議案に対する各議員の賛否一覧」PDFから、会派ごと・議員ごとの賛否を抽出するスクリプト(scripts/)と、試作ページを追加した。ページは draft: true のため本番ビルドには含まれない(開発サーバーでのみ表示)。 --- scripts/gen-sanpi-table.py | 52 +++++++++++++++++++++ scripts/parse-sanpi-hyo.py | 111 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 scripts/gen-sanpi-table.py create mode 100644 scripts/parse-sanpi-hyo.py (limited to 'scripts') diff --git a/scripts/gen-sanpi-table.py b/scripts/gen-sanpi-table.py new file mode 100644 index 0000000..4cd926d --- /dev/null +++ b/scripts/gen-sanpi-table.py @@ -0,0 +1,52 @@ +import sys +import json + +sys.stdout.reconfigure(encoding="utf-8") + +with open(".sanpi-parsed.json", encoding="utf-8") as f: + data = json.load(f) + +members = [(int(c), g, n) for c, g, n in data["members"]] +rows = data["rows"] +for r in rows: + r["votes"] = {int(k): v for k, v in r["votes"].items()} + +# 会派ごとにまとめる(全行で一致していれば1列、分かれていれば議員ごとの列) +groups = {} +for col, g, n in members: + groups.setdefault(g, []).append((col, n)) + +cols = [] # (見出し, [(col, name)]) +for g, ms in groups.items(): + if len(ms) == 1: + cols.append((f"{g} {ms[0][1]}", ms)) + continue + same = True + for r in rows: + vals = {r["votes"].get(c) for c, _ in ms} + vals = {v for v in vals if v in ("〇", "○", "×")} # 表決に加わらない議員は除外 + if len(vals) > 1: + same = False + break + if same: + cols.append((g, ms)) + else: + # 安竹の列は常に表示しつつ、分かれた会派は議員ごとに + for c, n in ms: + cols.append((f"{n}({g})", [(c, n)])) + +print("| 議案 | 議決結果 | " + " | ".join(h for h, _ in cols) + " |") +print("|---|---|" + "---|" * len(cols)) +for r in rows: + cells = [] + for _, ms in cols: + vals = [r["votes"].get(c, "") for c, _ in ms] + real = {v for v in vals if v in ("〇", "○", "×")} + if len(real) == 1: + cells.append(next(iter(real))) + elif not real: + cells.append("—") + else: + cells.append("/".join(v or "—" for v in vals)) + no = r["番号"] or "" + print(f"| {no} {r['件名'][:40]} | {r['議決結果']} | " + " | ".join(cells) + " |") diff --git a/scripts/parse-sanpi-hyo.py b/scripts/parse-sanpi-hyo.py new file mode 100644 index 0000000..2030549 --- /dev/null +++ b/scripts/parse-sanpi-hyo.py @@ -0,0 +1,111 @@ +import sys +import json +import re +import pdfplumber + +sys.stdout.reconfigure(encoding="utf-8") + +VOTE_CHARS = set("〇○×✕✖◯") # 賛成・反対の記号候補 + + +def norm(c): + """セル文字列を正規化(空白・改行を除去)""" + if c is None: + return "" + return "".join(str(c).split()) + + +HEADER_WORDS = {"区分", "番号", "件名", "議決結果", "賛成", "反対", "会派", "議員", "賛否"} + + +def is_vote(c): + s = norm(c) + return len(s) == 1 and s in VOTE_CHARS + + +def is_name(c): + s = norm(c) + if not (2 <= len(s) <= 8): + return False + if is_vote(c) or s in HEADER_WORDS: + return False + return not re.search(r"[0-90-9]", s) + + +def is_label(c): + s = norm(c) + if not (1 <= len(s) <= 5): + return False + if is_vote(c) or s in HEADER_WORDS: + return False + return not re.search(r"[0-90-9]", s) + + +def main(path): + members = [] # [(col, 会派, 議員名)] + rows = [] # [{区分, 番号, 件名, 議決結果, votes:{col: '〇'/'×'}}] + carries = {} # 区分の繰越 + + with pdfplumber.open(path) as pdf: + for page in pdf.pages: + for table in page.extract_tables(): + if not table: + continue + ncols = max(len(r) for r in table) + grid = [list(r) + [None] * (ncols - len(r)) for r in table] + + # 議員名の行=2〜8文字の名前らしいセルが最も多く並ぶ行 + best = None + for i, row in enumerate(grid[:6]): + n = sum(1 for c in row if is_name(c)) + if n >= 5 and (best is None or n > best[1]): + best = (i, n) + if best is None: + continue + member_row_idx = best[0] + + # 会派名は、その上の行(無ければ同じ行の短いラベル)から拾う + label_row = grid[member_row_idx - 1] if member_row_idx > 0 else grid[member_row_idx] + local = {} + group = "" + for c in range(ncols): + lab = norm(label_row[c]) + if lab and is_label(label_row[c]): + group = lab + if is_name(grid[member_row_idx][c]): + local[c] = (group, norm(grid[member_row_idx][c])) + if local and not members: + members = [(c, g, n) for c, (g, n) in sorted(local.items())] + + # データ行(〇×がある行) + for row in grid[member_row_idx + 1:]: + votes = {c: norm(row[c]) for c in local if is_vote(row[c])} + if len(votes) < 2: + continue + vals = [norm(x) for x in row] + disp = vals[0] if vals[0] else carries.get("区分", "") + if vals[0]: + carries["区分"] = vals[0] + rows.append({ + "区分": disp, + "番号": next((v for v in vals[1:4] if v and "第" in v), ""), + "件名": next((v for v in vals[1:6] if len(v) >= 8), ""), + "議決結果": next((v for v in vals if v in ("認 定", "認定", "原案可決", "可決", "不採択", "採択", "否決", "原案否決")), ""), + "votes": votes, + "raw": vals[:4], + }) + + print("議員(会派 / 名前):") + for c, g, n in members: + print(f" col{c:>3}: {g:<6} {n}") + print("\n議案行数:", len(rows)) + for r in rows[:6]: + vs = "".join(r["votes"].get(c, "・") for c, _, _ in members) + print(f" {r['区分'][:6]:<6} {r['番号']:<8} {r['議決結果']:<8} {r['件名'][:28]:<28} {vs}") + + with open(".sanpi-parsed.json", "w", encoding="utf-8") as f: + json.dump({"members": members, "rows": rows}, f, ensure_ascii=False, indent=1) + print("\n.sanpi-parsed.json に保存しました") + + +main(".tmp-hyo.pdf") -- cgit v1.3.1