🔖

【Yozora Diff:決算版】#2 決算短信を扱いやすい形にしよう!XBRL/PDFをJSONへ

に公開

はじめに

こんにちは!Rayです。普段は自然言語処理(NLP)の研究をしています。
僕たちは、Yozora Financeという学生コミュニティで、誰もが自分だけの投資エージェントを開発できる世界を目指して活動しています。
その中の基盤モジュール群のひとつとして、開示文書の差分に特化したシリーズを開発しており、それが Yozora Diff です。完成後は誰でも使えるようオープンソースで公開予定です!
今回の記事では、決算短信をXBRL/PDFから後処理で扱いやすいJSON形式へ変換する過程を紹介します。

決算短信は2種類ある!

形式 特徴 苦労ポイント
XBRL 構造化されておりHTMLとして扱える 企業ごとにタグ付けが微妙に異なる
PDF よく配布される形式 構造情報が無い&ヘッダー/フッター除去が必要

XBRLは基本的にTDnetからしか入手できませんが、PDFは企業のHPから入手できます。
TDnetは無料では過去1ヶ月分しか取れず2期分用意するには課金が必要なので、PDF対応の需要が高いんじゃないかと思っています。

方針

今回の目的は「差分抽出の前準備として、自然言語パートを後続処理しやすい構造に整形すること」です。
そのために、以下の2点を行います。

  • 本文を抽出する
  • 章・段落構造を崩さないまま表を除去してテキスト化

差分要約は定性的データの第1章のみで十分なので、そこだけパースすればいいかと思いましたが、せっかくなので目次以降の本文を全部パースしました。
比較時に揺れが出ないように全てのテキストはNFKCで正規化しています。同じ文章内でも表記揺れがあるので忘れずに!

手法

それぞれの形式でパースした方法を書いていきます。太字はフォーマットの傾向からハードコードしているところです。
自分でもやってみようという人の一助になれば幸いです。例外処理が多すぎてコードが超長いので、この説明を見ないと自分でも分かりづらい気がします笑

共通フローチャート

目次取得      ・・・目次ページから構造化に必要な見出しの取得
             PDFではヘッダーとフッターも追加取得
   ↓ 
表セクションの取得 ・・・XBRLでは付属ファイルから取得可能
             PDFには含まれないのでスキップ
   ↓  
本文の取得     ・・・セクションごとに表を除去して取得
   ↓  
構造化       ・・・セクションの構造を崩さずに構造化

XBRL

XBRLはタグが付いているから一見扱いやすいですが、タグのつけ方に各社で揺れがあるので簡単にはいきませんでした。
ライブラリはBeautifulSoupを使用しています。

1.目次取得

「目 次」のようになっているケースがあるので、「目」と「次」が含まれるタグを抽出 します。

パターンは以下の2種類です。印刷会社によって結構処理が違うので面倒でした。ここ以外にも、それぞれ微妙な例外処理があって苦労しました...
levelは左端からのスペースで4.の構造化で使用します。levelは稀に含まれないことがあるので、その場合は冒頭にあるクラス定義から引っ張ってきます。
クラス定義にも含まれない場合は、インデントで無理やり調整しているので、スペースの数からlevelを計算します。

印刷会社 目次のパターン level
プロネクサス <table> margin-left
宝印刷 <p>で列挙 padding-left

ページ番号の点線(...) の前を抽出して見出しを取得します。

(1)当期の経営成績の概況 ……………………………………………………………………………………
2

2.表セクションの取得

manifest.xmlから表セクションの名前を取得し、スキップ対象にします。今回は自然言語の差分が対象なので本文には含めません。

3. 本文の取得

1.の目次に含まれるセクション名でマッチングしてセクションごとに取得します。タグごとに必要な処理が異なるので大変でした。表は除去するので<table>タグはスキップしましょう。
期中レビュー報告書のセクション名が<table>タグのせいで、2.で除去されてしまうことがあるので気を付けましょう。僕は気づくのにすごい時間がかかりました...

4. 構造化

決算短信のフォーマットはおおよそ固定されているので、同じ章を比較できるようにしたいです。そこで、1.で得たlevelからセクションの深さを特定して構造化します。
ただし、セクションの深さに関わらずlevelが同じケースがあるので、その場合はセクション名からも構造化を行います。

階層 正規表現 形式
1階層目 r"^\d+\." 番号とピリオドからスタート   例: 1.経営成績等の概況
2階層目 r"^\(\d+\)" 括弧で囲まれた番号からスタート 例: (1)当期の経営成績の概況

PDF

PDFは構造化されていないので処理が大変かと思っていたんですが、XBRLと違ってタグの揺れがないので処理はシンプルに書けました。ライブラリはPyMuPDFを使用しました。
ただ、タグが存在しないので、自力で表を取得する必要があります。
HTMLと違って本文を取得するとヘッダーとフッターが入ってくるのでそれらの除去も必要です。

1.目次とヘッダー・フッターの取得

PDFで厄介なのは本文を取得しても、PDFの見た目と順番が異なることです。例えば、目次のページでは目次の見出し、フッター、ヘッダーが同じ位置にあることがあります。
また、目\n次のように改行がされていることがあるので、「目」と「次」を両方含むページを探検索して特定する必要があります。

○添付資料の目次                  ← 目次の見出し
- 1 -                       ← フッター
株式会社○○(0000) 2025年10月期 第3四半期決算短信 ← ヘッダー
  • 章タイトルとページ番号
    ...で区切られているので正規表現r"(.+?)\s*\.+[\s\n]*([0-9]+)"で取得しました。稀に見出しと本文で半角、全角が異なることがあるので、章番号は除去することをオススメします。
  • ヘッダー
    会社コードと「決算短信」が含まれるので、\nで分割して正規表現r"\([0-9]{3}[0-9A-Z]\).*決算短信"を含む要素を探して取得しました。基本文頭か末尾にあります。
  • フッター
    フッターは基本ヘッダーの前後にあるので1(ページ番号)を含むならフッターとしました。

2.表セクションの取得(スキップ)

PDFにはmanifest.xmlと対応するiXBRLファイルが含まれないのでスキップ。

3.本文の取得と表・ヘッダー・フッターの除去

XBRLと同様にセクション名でマッチングしてセクションごとに取得します。
次に表の除去ですが、これは少し手間でした。PyMuPDFには全体(get_text())と表(find_tables())を取得する関数がありますが、表を除いた本文を取得する関数が無いみたいなんですよね。そこで、表部分をリダクションしてテキストのみを取得しました。
一言でいうと「PDF上の表領域を一度塗りつぶしてからテキスト抽出している」イメージです。
find_tables()の精度がだいぶ良いみたいで、上手く表を除去できていて良かったです。
最後にヘッダーとフッターの除去も忘れずに行いましょう。

4.構造化

XBRLと違ってPDFにはタグがないので、名前から決め打ちで構造化します。

階層 正規表現 形式
1階層目 r"^\d+\." 番号とピリオドからスタート   例: 1.経営成績等の概況
2階層目 r"^\(\d+\)" 括弧で囲まれた番号からスタート 例: (1)当期の経営成績の概況

結果

それぞれJSONにした結果がこんな感じです。
PDFは意味ではなくページ幅で改行されていることもあるので直しましょう。XBRLも稀に数字で改行されている謎の仕様がありますので、改行処理を後処理として行います。

XBRLのパース結果
XBRL_parser

PDFのパース結果
PDF_parser

まとめ

手間は掛かりましたが、うまく動いてくれたので最終的にはよかったです。
もっともサンプルデータで動くようにしただけなので、汎用性を上げるために様々なデータでテストする必要がありそうです。全部自力でやるのは面倒なので、データを用意したらエージェントが自動実行+修正までやってくれるループを組めたら嬉しいです。
テストデータでも例外をつぶすだけでも苦労したので、汎用性をあげるのはもっと大変だろうし、ぜひ統一規格を用意して欲しいと思いました(泣)
次回の記事では、今回整形したJSONをもとに、「新旧の文をどのように対応付けるか(アライメント)」 の実装に入ります。ここを越えるといよいよ「意味のある差分」が見えてきます。

気になる点や改善案などコメントいただけるととても助かります。次回もぜひお楽しみに!

GitHubで編集を提案

Discussion