本記事に出てくるファイル名・日付・IDはすべてダミーに置き換えている。 実際の書類には氏名・住所・口座番号・通院履歴などが含まれるため、 構造と手法が伝わる範囲で差し替えた。数値の集計だけは実際のものである。

ScanSnapで紙をスキャンしてGoogle Driveに放り込む生活を、かれこれ10年近く続けていた。ペーパーレス化そのものの話は前に書いた

便利なのだが、問題がひとつあった。溜める一方で、一度も整理していなかったのである。

気づけば ScanSnap フォルダの直下に497件のPDFが並んでいた。ファイル名はこんな調子だ。

20230512_002.pdf
20230512_003.pdf
20230617_叫ヘ、゙ツ○FUⅢ丿々.pdf
19730408_自動車保険証券.pdf

前半3つは論外として、最後のものが曲者だった。1973年? 生まれる前である。

これをClaude Codeに丸ごと投げて整理させた記録である。結論から言うと全497件が18カテゴリに分類され、判別不能で残ったのは17件(3.4%)だけになった。そして途中で、Google Drive側が保持しているOCRテキストが壊れているという予想外の問題にぶつかった。

前提: ファイル名の日付は信用できない

まず判明したのが、ScanSnapのファイル名に入っている日付がまったくアテにならないということ。

ScanSnapはOCRした紙面から日付らしき数字を拾ってファイル名にする。ところがこれが、証券番号だったり、生年月日だったり、電話番号の一部だったりする。

  • 19730408_自動車保険証券.pdf → 実際のスキャンは2025年
  • 20260801_軽自動車税納税証明書.pdf → 実際のスキャンは2024年(未来の日付)

つまりファイル名は何の手がかりにもならない。中身を見るしかない。

最初の関門: 497件の中身をどう読むか

素直にやるなら、1件ずつファイルを開いてテキストを取り出す。497回のAPIコールだ。遅いし、トークンも食う。

ここで効いたのが、Google Drive APIの files.list(MCP経由だと search_files)が返す contentSnippet というフィールドだった。

Driveは、PDFをアップロードした時点で自前でOCRしてテキストを保持している。検索機能のためのインデックスだ。そしてそのテキストの冒頭が、検索結果にそのまま含まれてくる。

search_files(query: "parentId = '...'", pageSize: 100)
  → 100件分のメタデータ + 本文冒頭

検索5回で全497件の中身が読めた。 ダウンロードは一度もしていない。

余談だが、1回のレスポンスが300KBほどになり会話に載らないので、Claude Codeが自動でJSONファイルとしてディスクに保存してくれた。それをjqとPythonでローカル処理する形になり、結果的に生データを全部コンテキストに載せずに済んでいる。これは想定外に良い挙動だった。

本題: Driveが持っているOCRテキストが壊れていた

さて中身が読める、と思ったら142件がこうなっていた。

•Û•Ø•‘

‚¨‹q—l‚É‚²‹L“ü’¸‚¢‚½•Û•Ø•‘‚Ì•T‚¦‚Í

古典的なやつである。Shift_JISのバイト列をcp1252として読んだ文字化けだ。

念のため個別に read_file_content でも読んでみたが、同じ化け方をした。つまり通信経路の問題ではなく、Drive側に保存されているテキストそのものが壊れている。おそらくアップロード時のOCR処理でエンコーディング判定をミスっている。

素直に戻すと惜しいところで間違う

理屈は簡単だ。cp1252でエンコードし直して、cp932でデコードすればいい。

s.encode('cp1252').decode('cp932')

やってみると、こうなった。

保娩舞

惜しい。保証書 のはずである。

原因はすぐ分かった。cp1252には未定義のバイトがある0x81 0x8D 0x8F 0x90 0x9D の5つだ。変換時にこれらが全部、0x95)と同じ記号に潰れてしまっている。

保証書  = 95DB 8FD8 8F91
         ↓ 0x8F が 0x95 に潰れる
保娩舞  = 95DB 95D8 9591

情報が落ちているので、単純な逆変換では戻らない。0x95 で始まる2バイトを見たとき、元が6候補(0x81/0x8D/0x8F/0x90/0x95/0x9D)のどれだったか判断がつかない。

コーパス自身に判定させる

ここで使ったのが、**同じコーパスの中の「化けていないスニペット」**だった。

497件のうち、化けているのは142件。裏を返せば355件は正常な日本語である。そこから漢字の出現頻度表を作れば、「どちらの候補がこの文書群でありそうか」を判定できる。

def fix_leads(s, freq):
    """先頭バイト0x95で読まれた漢字を、頻度表を使って正しい候補に置き換える"""
    for ch in s:
        b = ch.encode("cp932")
        if len(b) != 2 or b[0] != 0x95:
            continue
        # 潰れた候補を総当たりし、コーパス内で最も高頻度の字を選ぶ
        for lead in (0x8F, 0x81, 0x90, 0x8D, 0x9D, 0x95):
            cand = bytes([lead, b[1]]).decode("cp932")
            if freq.get(cand, 0) > best_n:
                best_ch, best_n = cand, freq[cand]

なら のほうが圧倒的に多い。 なら 。書類の山を相手にしているのだから、当然そうなる。

結果、こうなった。

保娩舞        →  保証書
売買契約舞    →  売買契約書
ペーパーレス保険娩券  →  ペーパーレス保険証券

分類には十分な精度だ。外部の辞書もモデルも使っていない。壊れたデータの隣にあった無事なデータで、壊れたほうを直したことになる。

直せなかったもの

もう1種類、別の化け方をしたものが15件ほどあった。

ň͋ͬͨΒ͍͍ͳʼnΛ͍ͪ͹Μʹɻ

調べると、かなは一定量(+0x2CF7)ずれているだけだった。

あ = U+3042, 化けた文字 = U+034B → 差 0x2CF7
っ = U+3063, 化けた文字 = U+036C → 差 0x2CF7

かなだけ戻すと ňあったらいいなʼnをいちばんに と読めた。生命保険のパンフレットである。

ところが漢字はズレ幅が字ごとに違う。PDFに埋め込まれたフォントのグリフ番号がそのまま漏れているパターンで、フォントごとにマッピングが違うため機械的には戻せない。ここは諦めて、かなだけ復元して分類の手がかりにした。

分類: 機械にやらせる部分と、自分で読む部分

汎用語の罠

キーワード辞書でカテゴリを判定させたのだが、最初の実装で「金融・証券」に71件も入って明らかにおかしかった。

原因は 株式 である。すべての企業のレターヘッドに「株式会社」と書いてある。同様に 口座 銀行 振込 カード も、支払いが絡む書類ならほぼ全部にヒットする。

対策として、汎用語を辞書から外したうえで、スコアをキーワードの長さの2乗にした。

scores[cat] += len(kw) ** 2

特定口座年間取引報告書(11文字→121点)は 株式(2文字→4点)に負けない。長いキーワードほど具体的、という素朴な仮定だが、これでバランスが取れた。

日付と発行元の推定を「疑う」

自動抽出は、放っておくと堂々と間違える。

日付: 本文から日付を拾うと、生年月日・保険期間・電話番号を掴んでくる。2024年の健診結果に、まったく無関係な 1973-04-08 が付いた。対策として「スキャン日より後ではない」「スキャン日から5年以内」の条件を満たすものだけ採用し、通らなければスキャン日を使って末尾に ~ を付けた。推定値であることを名前に残すのが大事だと思う。

発行元: 本文のどこかにある社名を拾うと、まったく無関係な会社になる。差出人はレターヘッド(冒頭)に来るので、先頭250文字に現れたものだけ採用するようにした。住所の一部(市区町村名)を発行元と誤認する問題もあったので、単独の市区町村名は不採用にした。

あと地味にハマったのが正規表現の文字クラス。

# 長音符「ー」(U+30FC) は「ァ-ヶ」(U+30A1-U+30F6) に含まれない
r'([一-龥ぁ-んァ-ヶ]{2,12}株式会社)'

これだと サービス株式会社ビス株式会社 になる。 を明示的に足して解決した。

残りは自分で読む

ここまでの自動処理で、480件中329件が片付いた。残り151件は本文から書類名が取れないものだ。

これは素直に自分で読んだ。復元済みの本文150字ずつをTSVに書き出して、目で見て判定表を作った。

aB3xK9pQ  14_名刺        名刺
c7YtR2mN  01_税金        国税還付金振込通知書  ◯◯税務署
dE5wF8vL  11_取扱説明書  ひとり鍋 電子レンジ調理説明

このとき初めて「名刺が28件もある」と分かった。ルールベースでは絶対に出てこない発見で、専用カテゴリを追加することになった。ルールを賢くしようと粘るより、150字を151回読むほうが速くて確実だった。

最終的に判別不能で残ったのは17件(3.4%)。OCRテキストが実質空白のもので、これは正直に 99_要確認 に置いた。

結果

カテゴリ件数カテゴリ件数
税金・確定申告47領収書・請求書21
保険30取扱説明書・保証書90
住宅・不動産66会員・サービス5
自動車・バイク33広告・DM38
医療・健康21名刺28
育児・子育て6資格・認定13
行政・年金11レシピ・料理8
町会・地域40個人メモ・記録10
金融・証券13要確認17

ファイル名は YYYY-MM-DD_発行元_書類名.pdf に統一した。

2024-09-30_税務署_令和5年分所得税及び復興特別所得税の更正通知書.pdf
2022-10-04_不動産会社_土地付区分建物売買契約書.pdf
2022-11-26~_名刺_株式会社◯◯.pdf

末尾の ~ は「日付はスキャン日で、書類の発行日ではない」という印。330件がこれに該当する。

年フォルダは件数の多いカテゴリ(20件以上)にだけ作った。6件しかないカテゴリを年で割ると1〜2件のフォルダが並んで逆に探しにくい。

個人情報の扱いで方針転換した話

作業の途中で「記録にセンシティブな情報を残さないように」と指示を出した。

これが効いたのは主に2箇所だ。

リポジトリ側: 作業ログのREADMEに具体的な組織名や書類名を書いていたのを、一般的な表現に置き換えた。本文抜粋を含む data/ ディレクトリは .gitignore に入れた。氏名・住所・口座番号がそのまま入っているので、間違ってもコミットしてはいけない。

Drive側のファイル名: これが盲点だった。医療機関名がファイル名に入っていると、フォルダ一覧を開いただけで通院履歴が読める。受診関係書類.pdf お薬の説明書.pdf のような一般名に変えた。名刺も個人名ではなく会社名・職種で命名した。

整理の目的は「探しやすくすること」であって、「見えやすくすること」ではない。ここは分けて考えたほうがいい。

正直に書いておく限界

PDFを画像として見ていない。 見たのはDriveのテキスト層だけである。だから:

  • OCRが空白だった17件は本当に中身が分からない
  • 日付は「妥当そうな範囲に収まった」だけで、紙面を目視確認したわけではない
  • 分類の多くはキーワードルールの結果であって、内容を理解した判断ではない

「97%が正しく分類された」ではなく「97%に妥当そうなラベルが付いた」が正確な表現だと思う。

まとめ

  • Google Driveはアップロード時に自前でOCRしているcontentSnippet でそれが取れるので、ダウンロードせずに中身が読める。100件/1コールは効率が段違い
  • ただしそのOCRテキストは壊れていることがある。今回は497件中142件
  • 壊れたデータは、同じコーパスの無事な部分を使って直せる場合がある。外部辞書は要らない
  • 自動抽出した日付や発行元は疑ってかかる。推定値であることを成果物に残す
  • ルールを賢くするより、残りを自分で読んだほうが速いことがある。151件×150字は30分もかからない

10年分の積み残しが片付いた。次にスキャンした書類は、同じスクリプトを回すだけで所定の場所に収まる。

本記事の作業はClaude Codeで実施した。分類スクリプトと文字化け復元ロジックは手元のリポジトリに残してある。