aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-09-23 09:11:52 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-09-23 09:11:52 +0900
commit2e9dae139d7bcb00d94aa31a71e9206d0f454d8d (patch)
tree4009c3c38b3153815766321bd4abf2932358d2a3 /scripts
parentfcd023276d57c378487f1e9f538e229953fe0d27 (diff)
parse-sanpi-hyo.py: 罫線で列を区切り、合計欄で検算する方式に変更
従来は表のセル抽出と会派の区切りの推測に頼っていて、会派や議員ごとの列がずれることがあった。上部の「会派名(議員数)」を読み、縦罫線で列を厳密に区切って議員名と賛否記号を割り当て、PDFに載っている議案ごとの賛成・反対の合計と突き合わせて検算する方式にした。令和7年12月定例会で、会派・議員27人が市の会派名簿と一致し、4議案の賛成・反対数もPDFの合計とすべて一致することを確認。(表の生成側 gen-sanpi-table.py は、この出力に合わせて後日更新が必要)
Diffstat (limited to 'scripts')
-rw-r--r--scripts/parse-sanpi-hyo.py196
1 files changed, 106 insertions, 90 deletions
diff --git a/scripts/parse-sanpi-hyo.py b/scripts/parse-sanpi-hyo.py
index 2030549..457cac0 100644
--- a/scripts/parse-sanpi-hyo.py
+++ b/scripts/parse-sanpi-hyo.py
@@ -1,111 +1,127 @@
-import sys
-import json
-import re
-import pdfplumber
-
-sys.stdout.reconfigure(encoding="utf-8")
+"""賛否一覧PDF(新レイアウト)を読み、Markdown表を出力する。合計欄で検算する。
-VOTE_CHARS = set("〇○×✕✖◯") # 賛成・反対の記号候補
+使い方: python .extract-final.py <pdf> "<定例会名>"
+"""
+import re
+import sys
+import pymupdf
-def norm(c):
- """セル文字列を正規化(空白・改行を除去)"""
- if c is None:
- return ""
- return "".join(str(c).split())
+sys.stdout.reconfigure(encoding="utf-8")
+YES, NO, ABS = set("〇○◯"), set("×✕✖"), set("-—−-・")
+VOTE = YES | NO | ABS
+DIGITS = set("01234567890123456789")
-HEADER_WORDS = {"区分", "番号", "件名", "議決結果", "賛成", "反対", "会派", "議員", "賛否"}
+def col_of(x, bounds):
+ for i in range(len(bounds) - 1):
+ if bounds[i] <= x < bounds[i + 1]:
+ return i
+ return None
-def is_vote(c):
- s = norm(c)
- return len(s) == 1 and s in VOTE_CHARS
+def chars(pg):
+ out = []
+ for block in pg.get_text("rawdict")["blocks"]:
+ for line in block.get("lines", []):
+ for span in line["spans"]:
+ for ch in span["chars"]:
+ if ch["c"].strip():
+ x0, y0, x1, y1 = ch["bbox"]
+ out.append({"c": ch["c"], "x": (x0 + x1) / 2, "y": (y0 + y1) / 2})
+ return out
-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 main(path, label):
+ doc = pymupdf.open(path)
+ pg = doc[0]
+ cs = chars(pg)
-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)
+ # 会派(上部の2行、y 45〜70。文字は y,x の順に並べる)
+ topcs = sorted([c for c in cs if 45 <= c["y"] <= 70], key=lambda c: (round(c["y"] / 6), c["x"]))
+ top = "".join(c["c"] for c in topcs)
+ parties = [(s, f.strip(), int(n)) for s, f, n in
+ re.findall(r"([ぁ-んァ-ヶ一-龥A-Za-z]{2})[::]([^((]+?)[((](\d+)", top)]
+ total = sum(n for _, _, n in parties)
+ # 縦罫線グリッド
+ xs = {}
+ for d in pg.get_drawings():
+ for it in d["items"]:
+ if it[0] == "l":
+ a, b = it[1], it[2]
+ if abs(a.x - b.x) < 0.8:
+ k = round(a.x, 1)
+ info = xs.setdefault(k, {"maxy": 0})
+ info["maxy"] = max(info["maxy"], a.y, b.y)
+ grid = sorted(x for x, i in xs.items() if i["maxy"] > 200 and 250 < x < 770)
-def main(path):
- members = [] # [(col, 会派, 議員名)]
- rows = [] # [{区分, 番号, 件名, 議決結果, votes:{col: '〇'/'×'}}]
- carries = {} # 区分の繰越
+ # 議員名の帯(y 88〜140、左の区分・番号・件名・議決結果を除く)
+ namechars = [c for c in cs if 88 <= c["y"] <= 140 and c["x"] > 308]
+ nx0, nx1 = min(c["x"] for c in namechars), max(c["x"] for c in namechars)
+ left = [x for x in grid if x <= nx0]
+ bounds = ([left[-1]] if left else [nx0 - 8]) + [x for x in grid if nx0 < x <= nx1 + 15]
- 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]
+ cols = {}
+ for c in namechars:
+ i = col_of(c["x"], bounds)
+ if i is not None:
+ cols.setdefault(i, []).append(c)
+ members = [(i, "".join(x["c"] for x in sorted(cols[i], key=lambda c: c["y"]))) for i in sorted(cols)]
- # 議員名の行=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]
+ # 会派ごとに区切る
+ groups, k = [], 0
+ for s, f, n in parties:
+ groups.append((s, members[k:k + n]))
+ k += n
+ ngroups = len(members) - sum(n for _, _, n in parties) # 末尾の合計欄(賛成/反対)
+ member_only = members[:sum(n for _, _, n in parties)]
- # 会派名は、その上の行(無ければ同じ行の短いラベル)から拾う
- 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())]
+ # 賛否
+ marks = [c for c in cs if c["c"] in VOTE and c["y"] > 145]
+ rows = {}
+ for c in marks:
+ rows.setdefault(round(c["y"] / 10) * 10, []).append(c)
- # データ行(〇×がある行)
- 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],
- })
+ # 合計欄(末尾の列)の数字
+ nums = {}
+ for c in cs:
+ if c["c"] in DIGITS:
+ i = col_of(c["x"], bounds)
+ if i is not None and i >= len(member_only):
+ nums.setdefault(round(c["y"] / 10) * 10, []).append((c["y"], c["x"], c["c"]))
- 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}")
+ # 議案名(左側)
+ left_txt = {}
+ for c in cs:
+ if c["x"] < 300 and c["y"] > 120:
+ left_txt.setdefault(round(c["y"] / 10) * 10, []).append((c["x"], c["c"]))
- 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 に保存しました")
+ print(f"### {label}")
+ print(f"会派: " + " / ".join(f"{s}({n})" for s, _, n in parties) + f" = {total}人")
+ print(f"議員抽出数: {len(member_only)}(一致: {len(member_only) == total})")
+ for s, ms in groups:
+ print(f" {s}: {', '.join(m[1] for m in ms)}")
+ print("\n議案行の検算:")
+ ok = True
+ for y in sorted(rows):
+ cells = {}
+ for c in rows[y]:
+ i = col_of(c["x"], bounds)
+ if i is not None:
+ cells.setdefault(i, []).append(c["c"])
+ yes = sum(1 for i, _ in member_only if any(v in YES for v in cells.get(i, [])))
+ no = sum(1 for i, _ in member_only if any(v in NO for v in cells.get(i, [])))
+ if yes + no == 0:
+ continue # 賛否の記号がない行(全会一致など)
+ pdfs = "".join(c for _, _, c in sorted(nums.get(y, [])))
+ norm = pdfs.translate(str.maketrans("0123456789", "0123456789"))
+ match = (str(yes) in norm and str(no) in norm)
+ ok = ok and match
+ print(f" y={y}: 賛成{yes} 反対{no} / PDF={pdfs} {'✓' if match else '✗ 要確認'}")
+ print(" ⇒ 検算:", "全行一致" if ok else "不一致あり")
+ doc.close()
-main(".tmp-hyo.pdf")
+main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "")