競合の動きを調べるとき、有価証券報告書の「事業等のリスク」や「対処すべき課題」を、1社ずつPDFで開いて読んでいないでしょうか。1社でも数千字から1万字あり、5社分を読み比べると半日がかりです。しかも会社ごとに書き方が違うため、「A社には為替の項目があるのに、B社にはない」のか「B社は別の項目の中で書いている」のかは、全文を読まないと分かりません。
前の連載「ZENITH Code分析」(全5回)では、売上高や利益率、人的資本など、数字の表を競合と比べました。新しい連載「ディープ解析」では、数字の表の外にある有報の奥(本文・株主・政策保有・監査・主要な販売先)を、自社と競合の数社で並べて読みます。分析⑤の最後で予告した「運用編」(定期の更新や通知)は、この連載の後に回します。
初回は、有報の本文を見出しで並べる「開示リーダー」の「本文の読み比べ」の画面を読みます。本文を全部読む前に、各社が「どのリスクを独立した見出しにしているか」「どう区分しているか」を一覧にするための画面です。
画面の内容は、2026年10月30日にZENITH Codeの実データを取得して保存したデモです。トヨタ自動車(7203)・デンソー(6902)・本田技研工業(7267)・マツダ(7261)・テイン(7217)の、2026年3月期の有価証券報告書を並べています。
私たち株式会社Crabgrassは、企業の財務データなどをAPIで提供するZENITH Codeを開発しています。本記事は自社サービスのデータを使うためのテンプレートを題材にした実装例です。
この記事のコードは、公開サンプル集のdisclosure-readerテンプレート(執筆時点の版)に対応しています。画面の仕組みの基本(ローカルのPythonサーバーとブラウザの役割分担)は、実践編で詳しく扱いました。
1. この回で読む画面と操作
| 操作 | 画面で変わるもの | 今回読むコード |
|---|---|---|
| 上の「比べる会社」で2〜5社を選ぶ | 会社ごとの列 |
server.py の clean_text
|
| 節(事業等のリスク、経営方針と対処すべき課題など)を選ぶ | 各社の見出しの一覧と、文字数・見出しの数 |
model.mjs の extractHeadings
|
| キーワードを入れる(例:為替) | キーワードが出る見出しに件数の印 |
keywordPattern・sectionOutline
|
| 見出しを押す | 右の欄にその見出しの本文 | headingText |
| 「AIに渡すプロンプトをコピー」を押す | 見出しと出典だけの指示文 | buildPrompt |
ほかの「株主」「政策保有」「監査と販売先」の画面は、次回以降で読みます。
2. 登録不要で試す
必要なものはPython 3.9以上とブラウザです。追加のパッケージはありません。
git clone https://github.com/Crab-grass/zenith-code-examples.git
cd zenith-code-examples
python3 templates/disclosure-reader/server.py
ブラウザで http://127.0.0.1:8790 を開くと、保存済みのデモが表示されます。デモではAPIを呼ばないので、会員登録もAPIキーも要りません。終了するときは端末で Ctrl+C を押します。
3. 本文は節ごとに1回で受け取る
有報の本文は、ZENITH Codeの /stable/text-blocks に tag を付けて、節ごとに取得します。開示リーダーが使うのは5つの節で、tag=risks が「事業等のリスク」、tag=policy が「経営方針、経営環境及び対処すべき課題等」です。
ローカルのサーバーは、受け取った本文を次の関数で整えてからブラウザに渡します。
def clean_text(rows, expected):
if rows is None:
return None
rows = _list(rows, 10)
for row in rows:
_check_symbol(row, expected)
row = _latest(rows)
if row is None:
return None
out = _document(row, TEXT)
content = row.get("content")
content = content if isinstance(content, str) else ""
# The template counts characters of the text it receives, not the API's `chars` (HTML size).
out["content"] = content[:MAX_CONTENT]
out["truncated"] = len(content) > MAX_CONTENT
return out
ポイントは3つです。
-
別の会社の行が混ざっていたら止めます(
_check_symbol)。 -
一番新しい決算期の書類を1件だけ使います(
_latest)。 -
長すぎる本文は12万字で切り、切ったことを印で残します(
truncated)。画面には「先頭の一部だけを受け取っています」と出ます。
ブラウザに渡す項目は、server.py の許可リスト(TEXT・PROFILE など)に書き出したものだけです。社名を取るための /stable/profile は時価総額などの株価から計算する値も返しますが、このテンプレートは渡しません。書類へのリンクも、EDINETの閲覧ページだけを残します。
列の下と「出典」の表には、書類IDと提出日を出します。
| 会社 | 書類ID | 提出日 |
|---|---|---|
| トヨタ自動車 | S100Y8NY | 2026/06/10 |
| デンソー | S100Y9T1 | 2026/06/11 |
| 本田技研工業 | S100YDD9 | 2026/06/18 |
| マツダ | S100YDZ1 | 2026/06/23 |
| テイン | S100YIDI | 2026/06/24 |
4. 見出しを行頭の記号から取り出す
有報の本文には、目次のような構造データは付いていません。そこで model.mjs の extractHeadings が、各行の先頭にある記号を見て見出しを取り出します。使う記号は「(1)」「(1)」「①」「1.」「ⅰ)」「ア.」「a.」「【…】」「<…>」「■」などで、MARKERS に正規表現で並べています。
記号で始まっていても、「。」で終わる行は見出しにしません(titleOk)。たとえばデンソーの本文には、「ⅰ)」〜「ⅵ)」で始まる行が12行あります。そのうち6行は「…保証はありません。」のように「。」で終わる文で、残りの6行が「予期しない法律又は規制の変更」のような見出しです。
階層は「記号の入れ子」で決める
見出しの階層(大見出しか小見出しか)は、記号の種類の順番で決めます。最初に出た種類が大見出し、その中で新しく出た種類が一段下です。
// Levels follow the nesting of marker families: a family not open yet starts a deeper level,
// a family already open closes everything below it ("(2)" after "①" goes back to level 1).
const open = [];
return { lines, headings: headings.map((h, index) => {
const at = open.indexOf(h.type);
if (at >= 0) open.length = at + 1;
else open.push(h.type);
return { ...h, index, level: open.length };
}) };
}
「(1)→①→②→(2)」と並んでいれば、(1)と(2)が大見出し、①②が小見出しです。
記号のない区分の行と、くり返しのラベル
記号の規則だけでは拾えない書き方も、2つ扱っています。
// Unmarked short lines directly above the first item of a numbered list
// ("市場及び事業に関するリスク" over "(1) …"). Lines above "(3)" or "b." are body text.
マツダは「市場及び事業に関するリスク」のような区分の行に記号がなく、その下に「(1)」から番号を振っています。番号の並びの「1番目」のすぐ上の短い行だけを、大見出しとして拾います。
// Repeated labels such as <リスク> <対応策> under every item are not headings.
const counts = new Map();
for (const h of found) counts.set(h.title, (counts.get(h.title) ?? 0) + 1);
let headings = found.filter((h) => counts.get(h.title) < 3);
本田技研工業は、各項目の中を「<リスク>」と「<対応策>」に分けて書いており、デモの本文ではそれぞれ11回出てきます。3回以上くり返す名前は見出しから外します。
5. 見出しを並べると、区分の立て方が見える
5社の「事業等のリスク」を、この規則で取り出した結果です。数字は、demo.json を model.mjs の sectionOutline に通して数えたものです。
| 会社 | 文字数 | 見出し | うち大見出し | 書き方 |
|---|---|---|---|---|
| トヨタ自動車 | 8,539 | 19 | 3 | 3つの区分の下に①〜を並べる |
| デンソー | 6,269 | 24 | 3 | 3つの区分の下に①〜、さらにⅰ)〜の3段 |
| 本田技研工業 | 10,079 | 18 | 13 | (1)〜(13)を平らに並べ、一部だけ①〜で分ける |
| マツダ | 5,981 | 20 | 3 | 記号のない3つの区分の下に(1)〜 |
| テイン | 2,575 | 19 | 9 | (1)〜(9)を並べ、(2)の中に①〜⑩ |
文字数は受け取った本文(表は文字に並べ直したもの)の文字数で、書類の分量そのものではありません。見出しの数も書き方の細かさの違いで、リスクの多さではありません。
表から読めるのは、区分の立て方の違いです。
- トヨタ自動車・マツダ・デンソーは、大見出しが3つです。トヨタとマツダは「市場及び事業」「金融・経済」「政治・規制・法的手続・災害等」という、ほぼ同じ3区分です。デンソーは「事業環境」「事業内容」「その他」で区分しています。
- 本田技研工業は、13の項目をそれぞれ大見出しにしています。「地政学的リスク」「情報セキュリティリスク」「知的財産リスク」などが、区分の下ではなく、同じ高さに並びます。
- テインは、9つの大見出しのうち(2)だけが10項目を抱えています。「事業活動に潜在するリスクについて」の下に、「為替レートの変動」「競合会社の存在」などがまとまっています。
同じ「情報セキュリティ」でも、トヨタ自動車では「市場および事業に関するリスク」の⑧、本田技研工業では大見出しの(3)です。区分のどこに置くかは各社の整理の仕方で、自社の書き方と見比べる手がかりになります。
6. キーワードで「どの見出しに出るか」を見る
キーワードを入れると、その語が出る見出しに件数の印が付き、列の上に節全体の件数が出ます。キーワードは全角と半角を区別せずに探します(keywordPattern)。見出しごとの件数は、その見出しの行から、次の見出し(階層を問わない)の手前までで数えます(sectionOutline)。
デモで4つの語を入れたときの、節全体の件数です。
| キーワード | トヨタ自動車 | デンソー | 本田技研工業 | マツダ | テイン |
|---|---|---|---|---|---|
| 為替 | 12 | 6 | 2 | 7 | 4 |
| サイバー | 1 | 3 | 9 | 2 | 0 |
| 関税 | 10 | 1 | 3 | 1 | 0 |
| 人材 | 4 | 1 | 2 | 4 | 3 |
件数そのものより、どの見出しに出るかを見ます。
- 為替は、5社とも「為替」を含む見出しを立てています。テインは、(2)の中の「④為替レートの変動」(1件)と、大見出しの「(3)為替レートの変動について」(3件)の2か所に出ます。トヨタ自動車の12件のうち10件は「為替および金利変動の影響」、2件は「自動車産業に適用される法律、規制および行政措置」の中です。
- サイバーは、本田技研工業の9件がすべて大見出しの「情報セキュリティリスク」の中です。テインの「事業等のリスク」には、この語は出ません。
- 関税は、5社とも「関税」という見出しを立てていません。トヨタ自動車の10件のうち9件は「自動車産業に適用される法律、規制および行政措置」、1件は「自動車市場の需要変動」の中です。本田技研工業の3件は「地政学的リスク」「経済安全保障」「市場環境変化リスク」に1件ずつ分かれています。
- 人材は、本田技研工業を除く4社が「人材」を含む見出しを持っています。本田技研工業の2件は「情報セキュリティリスク」と「市場環境変化リスク」の中です。
「独立した見出しがあるか」「どの見出しの中で触れているか」が、本文を読む前に分かります。気になった見出しを押すと、右の欄にその本文が出ます。
件数は語の出現回数です。別の言葉(「為替」でなく「円高」など)で書いていれば数えないので、0件でも本文で確かめてください。
7. 「対処すべき課題」も同じ画面で並べる
節を「経営方針と対処すべき課題」に切り替えると、同じ5社の見出しが並びます。
| 会社 | 文字数 | 見出し | うち大見出し | 課題を書いている大見出し |
|---|---|---|---|---|
| トヨタ自動車 | 5,789 | 14 | 3 | (3)会社の対処すべき課題 |
| デンソー | 1,470 | 7 | 3 | (3)対処すべき課題 |
| 本田技研工業 | 7,113 | 16 | 4 | (4)優先的に対処すべき課題 |
| マツダ | 7,358 | 17 | 2 | (2)経営環境及び対処すべき課題 |
| テイン | 2,690 | 18 | 5 | (5)事業上及び財務上の対処すべき課題 |
この節は書き方の自由度が大きく、見出しの取り出し方の限界も見えます。
- トヨタ自動車は、「会社の経営の基本方針」の下にある7つの項目(「1.」〜「7.」)を、それぞれ見出しとして取り出しています。課題の下には「[…]」で囲んだ取り組みの名前が並びます。
- マツダは、階層が5段まであります。「■」で始まる19行のうち、「。」で終わる文の16行は見出しから外れ、「第1フェーズ」〜「第3フェーズ」の3行だけが残ります。
- デンソーと本田技研工業は、課題の中を「<成長戦略の3つの柱>」「<5つの重要テーマ>」でまとめ、その下に①〜を並べています。
見出しは記号から機械的に取り出したものです。会社が意図した構成と完全に同じとは限りません。会議の資料に使うときは、EDINETの原文の目次と見比べてください。
研究開発活動は、見出しのない会社が多い
節を「研究開発活動」にすると、見出しが取れるのはトヨタ自動車(2)と本田技研工業(3)だけです。デンソー・マツダ・テインの列には「本文全体(見出しなし)」と1行だけ出て、押すと本文全体が右に出ます。
8. AIに渡すのは、見出しと出典だけ
「AIに渡すプロンプトをコピー」は、選んだ節の各社の見出しを、AIへの指示文と一緒にクリップボードへ写します。本文そのものは写しません。 指示文の決まりは i18n.mjs にあります。
promptRules: [
"各社に共通するテーマ、一部の会社だけにあるテーマ、書き方や順番の違いを表にまとめてください。",
"見出しだけで分からない点は推測せず、「本文で確認が必要」と書いてください。本文は出典のEDINETのページで読めます。",
"数値の予想や、株式の売買についての意見は書かないでください。",
],
コピーした文には、各社の名前と出典(決算期・書類ID・提出日・EDINETのURL)に続けて、見出しが階層の字下げ付きで並びます。キーワードが出る見出しには「★」が付きます。本文の中身は、AIに推測させず原文で確かめる分担です。
9. 自社のコードで動かす
会員登録をしてAPIキーを作成し、--live を付けて起動し直します。
python3 templates/disclosure-reader/server.py --live
キーは端末の非表示入力で渡します。キーはローカルのPythonプロセスだけが持ち、ブラウザには送りません。
画面で「ライブ取得」を選び、証券コードを2〜5社入れて「取得」を押します。本文の5つの節だけで1社あたり5リクエスト、ほかの画面の分も含めると1社あたり12〜13リクエストです。5社なら最大65リクエストです。
1社ずつ順番に取得し、並列には呼びません。404(その節がない)は「この節はありません」と表示し、それ以外の失敗が1件でもあれば、一部の会社だけ欠けた状態で並べないよう全体を中止します。節の切り替え・キーワード・コピーでは、APIを呼びません。
10. 数字の検算
記事の数字はテンプレートの関数で数えたものです。自社や競合の有報でも、次の手順で確かめられます。筆者はデモで1と2を行い、記事の数字と一致することを確かめました。
1. 文字数とキーワードの件数を、本文から直接数える。 テンプレートの関数を使わず、リポジトリの直下で実行します。
python3 - <<'EOF'
import json, re
d = json.load(open("templates/disclosure-reader/demo.json", encoding="utf-8"))
for c in d["companies"]:
text = c["texts"]["risks"]["content"]
lines = [l.strip() for l in text.splitlines() if l.strip()]
marked = [l for l in lines if re.match(r"^([((][0-90-9]{1,2}[))]|[①-⑳])", l) and not l.endswith("。")]
print(c["symbol"], len(text), len(marked), text.count("為替"), text.count("サイバー"), text.count("関税"))
EOF
7203 8539 19 12 1 10
6902 6269 18 6 3 1
7267 10079 18 2 9 3
7261 5981 17 7 2 1
7217 2575 19 4 0 0
文字数とキーワードの件数は、5社とも画面と一致します。
2. 見出しの数の差を説明できるか確かめる。 上の3列目は、「(1)」と「①」で始まり「。」で終わらない行の数です。トヨタ自動車・本田技研工業・テインは画面と同じです。差が出る2社は、理由を確かめます。
| 会社 | 行頭の記号で数えた数 | 画面の見出し | 差の理由 |
|---|---|---|---|
| デンソー | 18 | 24 | 「ⅰ)」〜「ⅵ)」の見出し6つ(上の式は数えない) |
| マツダ | 17 | 20 | 記号のない区分の行3つ |
3. 原文と目で照合する。 3節の表の書類IDでEDINETの原文を開き、見出しが画面の列と同じ順で並んでいるかを確かめます。記号の使い方が想定と違う会社は、MARKERS に足すか、その節は本文を直接読むと決めておきます。
11. 小さな変更:大見出しだけを並べる
区分の立て方は大見出しに出ますが、小見出しまで並ぶと列が縦に長くなり、見比べにくくなります。そこで、大見出しだけを並べる切り替えを足します。大見出しには、その下の小見出しのキーワードの件数も足して表示します。
計算(model.mjs)とサーバー、APIの呼び出し回数は変えません。変えるのは app.mjs・i18n.mjs・style.css の3つです。
1. 状態を1つ足す(app.mjs の state)。
keyword: params.get("q") ?? "", active: null, prompt: null, topOnly: false,
2. 大見出しだけを選ぶ関数を足す(app.mjs の textColumn の上)。
// The top-level headings only. Each one carries the keyword hits of the sub-headings under it.
function topLevel(headings) {
const out = [];
for (const h of headings) {
if (h.level === 1) out.push({ ...h });
else if (out.length) out[out.length - 1].hits += h.hits;
}
return out;
}
見出しは本文の順に並んでいるので、小見出しの件数は直前の大見出しに足せば済みます。元の配列は書き換えず、写しに足します。
3. 切り替えのボタンを足す(app.mjs の renderText())。 上の操作の行はすでに横幅いっぱいなので、ボタンはキーワードの結果の行の左に置きます。
const top = node("button", t("topOnly"), { type: "button", class: "chip toggle", "aria-pressed": String(state.topOnly) });
top.addEventListener("click", () => { state.topOnly = !state.topOnly; top.setAttribute("aria-pressed", String(state.topOnly)); refreshText(data); });
const summary = node("p", undefined, { class: "summary", "aria-live": "polite" });
const bar = node("div", undefined, { class: "text-bar" });
bar.append(top, summary);
host.append(...) の summary は bar に置き換えます。
4. 列の数と見出しの一覧を変える(app.mjs の textColumn())。 列の上に大見出しの数を足し、切り替えが入っているときは大見出しだけを並べます。
meta.append(node("span", fill("chars", { n: M.number(outline.chars, state.lang) })), node("span", fill("headingsCount", { n: outline.headings.length })),
node("span", fill("topCount", { n: topLevel(outline.headings).length })));
for (const h of state.topOnly ? topLevel(outline.headings) : outline.headings) list.append(item(h.index, h.title, h.level, h.hits));
5. 文言を足す(i18n.mjs の ja と en)。
topOnly: "大見出しだけ", topCount: "うち大見出し{n}",
topOnly: "Top level only", topCount: "{n} top-level",
6. 見た目を整える(style.css)。 押した状態は塗りつぶしで示します。スマホ幅では、ボタンと結果の文が上下に分かれます。
.text-bar { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: flex-start; margin-bottom: 10px; }
.text-bar .summary { flex: 1 1 280px; margin: 3px 0 0; }
.chip.toggle { flex: none; padding: 3px 12px; font-size: 12.5px; }
.chip.toggle[aria-pressed="true"] { background: var(--ink); border-color: var(--ink); color: var(--sheet); }
保存して再読み込みし、「為替」を入れて「大見出しだけ」を押します。トヨタ自動車の「金融・経済のリスク」に10、マツダの「金融・経済に係るリスク」に7、テインの「事業活動に潜在するリスクについて」に1が付けば完成です。大見出しを押すと、右の欄にはその下の小見出しを含めた本文が出ます。
テストはそのまま通ります(node --test tests/disclosure-reader.test.mjs)。テストは、画面で使う文言がすべて日本語と英語の両方にあるかも確かめるので、topOnly と topCount を片方だけに足すと失敗します。
12. 確認箇所
| 症状 | 確認箇所 |
|---|---|
| 見出しが「本文全体(見出しなし)」だけになる | 本文の行頭に記号がない。MARKERS にない記号を使っていないか |
| 本文の一文が見出しとして並ぶ | 記号で始まり、「。」で終わらない長い行。原文の目次と見比べる |
| 階層が原文と違う | 記号の種類が出た順番で階層を決めている。最初に出た種類が大見出し |
| キーワードが0件 | 別の言葉で書いていないか。見出しを押して本文で確かめる |
| 大見出しの件数が小見出しの合計と合わない | 大見出しの行から最初の小見出しまでの文の件数も含まれる |
表示を変える作業をAIに頼む場合も、計算やAPIの呼び出しに触れないことを明記すると、確認する範囲を絞れます。
disclosure-readerテンプレートの「本文の読み比べ」に、大見出し(level が 1 の見出し)だけを並べる切り替えのボタンを足してください。
大見出しだけのときは、その下の小見出しのキーワードの件数を、大見出しの件数に足して表示してください。
各社の列の上に「うち大見出し{n}」と大見出しの数を出してください。
ボタンはキーワードの結果の行の左に置き、押した状態は aria-pressed で示してください。
app.mjs の renderText と textColumn、i18n.mjs の ja と en、style.css だけを変更します。
model.mjs、server.py、APIの呼び出し回数、AIに渡すプロンプトの中身は変更しないでください。
変更箇所と、デモで確認する操作を説明してください。
13. 次回は株主の顔ぶれを読む
今回は、有報の本文を見出しで並べ、各社が「どのリスクを独立した見出しにしているか」「どう区分しているか」を一覧にしました。見出しは本文を読み始める入口で、判断は原文で行います。
次回のディープ解析②では、同じ開示リーダーの「株主」の画面で、大株主の上位10と所有者別の構成を並べ、比べる会社どうしに共通する大株主から資本関係を読みます。


