🛸

有価証券報告書の全文検索システム Yakumo

2024/12/19に公開

はじめに

有価証券報告書を全文検索したい。

EDINETの全文検索がある。
使いにくい。

有価証券報告書以外の文書もあるため、条件による絞り込みに手間がかかる。キーワードを入力する前に文書種別や書類提出者情報を指定しなければいけない。

手間がかかるだけではない。機能的な不満もある。

スペースで幅調整されて記載されている人名などは、キーワードで検索してもみつからない。例えば【役員の情報】の役員の名前欄に「冠 二 郎」と記載されていたりすることがよくある。全文検索でキーワード「冠二郎」と検索しても引っかからない。意図的にではないにしても検索除けになっているのだ。

目次の範囲指定機能も便利だが落とし穴がある。目次の範囲指定機能は、指定の目次の範囲において全文検索することができる。例えば、有価証券報告書に新しく書くようになった【サステナビリティに関する考え方及び取組】から全文検索することができる。

しかしこの機能ではの目次が少しでも異なると指定範囲から除外される。上記の例で言えば【サステナビリティに関する考え方及び取組み】(最後に送り仮名「み」がついてる)となっているだけで検索されない。このような目次の表記揺れは珍しいケースではない。

以上の理由により有価証券報告書の全文検索システム“yakumo”を自前で実装することにした。

目標

yakumoの目標は以下の4点。

  • 有価証券報告書の全文検索ができること
  • スペースで幅調整された人名等が検索できること
  • 目次でフィルタリングできること
  • レスポンスが早く使いやすいこと

成果物

有価証券報告書の全文検索システム[yakumo]。githubにて公開している。
名前の由来は、北海道八雲町。UFO(≒有報)がよく見つかると言われている町である。
https://github.com/manpukupanda/yakumo

システム全体設計

有価証券報告書データ

有価証券報告書のデータはEDINET APIを使って取得する。
https://disclosure2.edinet-fsa.go.jp/WEEK0010.aspx

アーキテクチャ設計

全文検索エンジン

全文検索エンジンにはPGroongaを採用した。
https://pgroonga.github.io/ja/

採用理由は、以下の通り。

  • 日本語がそのまま使える
  • 形態素解析の考慮が基本的に不要
  • RDBでありSQLで全文検索ができる

全文検索の代名詞ともいえるElasticSearchは、日本語を使うにはkuromojiのアナライザー等をインストールする必要があり、Indexに特別な設計が必要になる。ElasticSearchのクエリはSQLよりずっと煩雑だ。機能的にはElasticSearchのほうが豊富で柔軟性もあるように思えたが、今回は単純にgrep的な機能でよかったのでPGroongaで十分と判断した。

DockerでPostgreSQL/PGroongaのサーバを立てて利用する実装とした。

バックエンドアプリ

有価証券報告書のデータ取得から読込・加工・データベースへ投入処理を行うバッチ処理はGo言語で作成した。PythonでもC#でもよかったが、著者がGoの勉強中だったので習作としたためである。

なお、Goだから処理が早い、ということはない。処理時間の95%以上がデータのダウンロードで言語による違いはほとんどない。それ以外の5%以下の処理が速かろうが遅かろうが大した違いにはならないからである。同じ理由でゴルーチンは使用していない。

フロントエンド

検索画面にはPHPを採用。DockerでPHP動作用のサーバを立て、ブラウザで検索する実装とした。

検索キーワードを入力できて、結果が表示できて、目次でフィルタできるだけの簡単な画面なので、PHPで1ファイルの簡単な実装とした。PHPのフレームワークも不要。

デザインも単純なものとしたが、CSSフレームワークにはPrimerをCDN経由で使用した。
https://github.com/primer/css

使用技術まとめ

  • PostgreSQL/PGroonga
  • Go言語
  • PHP
  • Docker

データベース設計

CREATE TABLE IF NOT EXISTS documents (
    date char(10) NOT NULL,       -- 日付
    seqNumber int NOT NULL,       -- 連番
    docID char(8) NOT NULL,       -- 書類管理番号
    submitDateTime char(16) NULL, -- 提出日時
    edinetCode char(6) NULL,      -- 提出者EDINETコード
    secCode char(5) NULL,         -- 提出者証券コード
    filerName text NULL,          -- 提出者名
    periodStart  char(10) NULL,   -- 期間(自)
    periodEnd char(10) NULL,      -- 期間(至)
    docDescription text NULL,     -- 提出書類概要
    PRIMARY KEY (date, seqNumber)
);

CREATE TABLE IF NOT EXISTS document_texts (
    docID char(8) NOT NULL,     -- 書類管理番号
    seq int NOT NULL,           -- 連番
    title text NOT NULL,        -- 目次
    breadcrumb text NOT NULL,   -- 目次パンくずリスト
    content text NOT NULL,      -- テキスト
    PRIMARY KEY (docID, seq)
);

CREATE EXTENSION IF NOT EXISTS pgroonga;
CREATE INDEX IF NOT EXISTS pgroonga_content_index ON document_texts USING pgroonga (breadcrumb, content);

テーブルは、書類のメタ情報を保持するdocumentsと、全文テキストを目次ごとに保持するdocument_textsの2つ。

documentsの項目はEDINET APIの書類一覧APIが返す提出書類(results)の項目から必要項目を選別した。書類一覧APIによると、同じ日の連番は一度付与されてから変わることがない番号、と定められているので、日付(date)と連番(seqNumber)が主キーとなる。

document_textsは、書類を目次ごとに区切ったテキストとして保持する。書類を識別する書類管理番号(docID)と連番(seq)が主キーとなる。titleは目次を、breadcrumbは目次の階層をパンくずリスト形式にしたものとして保持する。contentは目次のテキストとなる。

カラム データ例
docID S100UY5A
seq 4
title 1 【主要な経営指標等の推移】
breadcrumb 本文 > 企業情報 > 企業の概況 > 主要な経営指標等の推移
content 1 【主要な経営指標等の推移】 (1) 提出会社の経営指標等 ...

ソースの最後の2行はPGroongaで全文検索するための設定。document_textsbreadcrumb(目次パンくずリスト)とcontent(テキスト)に対して全文検索クエリが実行できるようにした。これにより、本文テキストに対して全文検索でき、目次でのフィルタリングも全文検索クエリにて実行できる。目次のフィルタリングは単純なLIKE演算子を使ってもよいが、パフォーマンスを考慮して全文検索で対応することとした。

有価証券報告書データの取得

EDINET APIの「書類一覧取得API」で1日分のメタデータを取得し、必要な書類(有価証券報告書
)について「書類取得API」でZIPファイルを取得する。

そのために必要となるEDINET AIPの「書類一覧取得API」と「書類取得API」を呼び出す処理を実装する。

EDINET APIにはAPIキーを事前に申請して取得する。
APIキーは環境変数に保持する。

// EDINET APIの利用にはAPIキーを取得する必要があります。
// EDINET操作ガイド(下のURL) >  EDINET API利用規約 に記載の方法にてAPIキーを取得できます。
//
//	https://disclosure2dl.edinet-fsa.go.jp/guide/static/disclosure/WZEK0110.html
//
// EDINET API のキー:環境変数 YAKUMO_EDINET_API_KEY より取得
var apiKey string = os.Getenv("YAKUMO_EDINET_API_KEY")

// APIキー未設定エラー
var ErrApikey error = errors.New("ApiKey is empty")

書類一覧取得APIはURLに取得する日付を指定する。

// 書類一覧取得APIのURLの書式
var apiDocumentsUrlFormat string = "https://api.edinet-fsa.go.jp/api/v2/documents.json?date=%s&type=2&Subscription-Key=%s"

// 日付を指定して書類一覧取得APIのURL
func urlOfDocuments(date string) string {
	return fmt.Sprintf(apiDocumentsUrlFormat, date, apiKey)
}

書類一覧取得APIのレスポンスはJSON形式なので、構造体して返す。

// 書類一覧JSONの構造体
type Documents struct {
	Metadata `json:"metadata"`
	Results  []Result `json:"results"`
}

type Metadata struct {
	Title     string `json:"title"`
	Parameter struct {
		Date string `json:"date"`
		Type string `json:"type"`
	} `json:"parameter"`
	Resultset struct {
		Count int `json:"count"`
	} `json:"resultset"`
	ProcessDateTime string `json:"processDateTime"`
	Status          string `json:"status"`
	Message         string `json:"message"`
}

type Result struct {
	SeqNumber            int    `json:"seqNumber"`
	DocID                string `json:"docID"`
	EdinetCode           string `json:"edinetCode"`
	SecCode              string `json:"secCode"`
	Jcn                  string `json:"JCN"`
	FilerName            string `json:"filerName"`
	FundCode             string `json:"fundCode"`
	OrdinanceCode        string `json:"ordinanceCode"`
	FormCode             string `json:"formCode"`
	DocTypeCode          string `json:"docTypeCode"`
	PeriodStart          string `json:"periodStart"`
	PeriodEnd            string `json:"periodEnd"`
	SubmitDateTime       string `json:"submitDateTime"`
	DocDescription       string `json:"docDescription"`
	IssuerEdinetCode     string `json:"issuerEdinetCode"`
	SubjectEdinetCode    string `json:"subjectEdinetCode"`
	SubsidiaryEdinetCode string `json:"subsidiaryEdinetCode"`
	CurrentReportReason  string `json:"currentReportReason"`
	ParentDocID          string `json:"parentDocID"`
	OpeDateTime          string `json:"opeDateTime"`
	WithdrawalStatus     string `json:"withdrawalStatus"`
	DocInfoEditStatus    string `json:"docInfoEditStatus"`
	DisclosureStatus     string `json:"disclosureStatus"`
	XbrlFlag             string `json:"xbrlFlag"`
	PdfFlag              string `json:"pdfFlag"`
	AttachDocFlag        string `json:"attachDocFlag"`
	EnglishDocFlag       string `json:"englishDocFlag"`
	CsvFlag              string `json:"csvFlag"`
	LegalStatus          string `json:"legalStatus"`
}

// 書類一覧取得
func GetDocuments(date string) (*Documents, error) {
	if apiKey == "" {
		return nil, ErrApikey
	}
	url := urlOfDocuments(date)
	resp, err := http.Get(url)
	if err != nil {
		return nil, err
	}

	defer resp.Body.Close()
	byteArray, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	data := new(Documents)
	if err := json.Unmarshal(byteArray, data); err != nil {
		return nil, err
	}

	return data, nil
}

次に書類取得API。

書類取得APIはURLに取得する書類管理番号(docID)を指定する。

// 書類取得APIのURLの書式
var apiDownloadZipUrlFormat string = "https://api.edinet-fsa.go.jp/api/v2/documents/%s?type=1&Subscription-Key=%s"

// 書類取得APIのURL
func urlOfTheZip(docID string) string {
	return fmt.Sprintf(apiDownloadZipUrlFormat, docID, apiKey)
}

書類取得APIでダウンロードしたファイルを所定の場所に書き込む。

// 本文ZIPを取得する
func DownloadZip(docID string, filepath string) error {
	if apiKey == "" {
		return ErrApikey
	}

	url := urlOfTheZip(docID)

	// 出力先のファイルを作成
	out, err := os.Create(filepath)
	if err != nil {
		return err
	}
	defer out.Close()

	// ファイルをGET
	resp, err := http.Get(url)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	// 出力先ファイルに書き込む
	_, err = io.Copy(out, resp.Body)
	if err != nil {
		return err
	}

	return nil
}

データの加工

取得したデータを加工する処理について説明する。
全体の流れは以下の通り。

  • 1日ずつ、365日分以下の処理をループ
    1. EDINET APIで書類一覧を取得
    2. 書類ごとに以下の処理をループ
      1. 有価証券報告書以外は処理対象外とする
      2. データベースに登録済みのものは処理対象外とする(再実行を想定)
      3. データの取得・加工
        1. EDINET APIで書類のzipファイルを取得
        2. zipファイルを解凍
        3. 解凍したファイルの中からhtmlをだけを選別して文書の表示順に並べる
        4. 表示順にhtmlを読込み、目次ごとにテキストを抜き出す
        5. 目次のパンくずリストを作成
      4. 加工したデータをデータベースに登録する

全ての説明を記載するのは冗長になるので、ここでは、実装のポイントについて説明する。

htmlファイルの文字コード

有価証券報告書のhtmlファイルの文字コードはUTF-8(BOM付)である。
気づかずに単にhtml.ParseしてもエラーにならないDOMが崩れた状態で読み込めてしまう

以下の記事を参考にBOM付ファイルの処理を実装した。
https://qiita.com/ssc-ynakamura/items/e05dc9bfacee063f3471

htmlファイルの先頭にXML宣言がある

有価証券報告書のhtmlは、InlineXBRLで作成されているため、冒頭にXML宣言がついている。

<?xml version="1.0" encoding="utf-8"?>
<html version="-//XBRL International//DTD XHTML Inline XBRL 1.0//EN" xmlns="http://www.w3.org/1999/xhtml" ... >
<head>
  ...

html.Parse()でパースしてhtmlのルートになるNodeが返される。普通のhtmlファイルであればこのNodeの子要素はhtml要素だけだが、InlineXBRLだとXML宣言とhtml要素の2つ子要素があるので
ルートから探索処理をする際に複数の子要素があることを前提として実装した。

ix:headerタグ以下のテキストは抜かない

全文検索の対象は、ブラウザで有価証券報告書のコンテンツとして人間が普通に読めるテキストだけを対象にしたい。非表示の文字列がhtmlファイルに書かれていたとしてもそれは対象外である。よって、headタグ以下は対象外である。

また、有価証券報告書のInlineXBRLにはix:headerタグで囲まれた範囲に記載されている隠し情報がある。それも全文検索のテキストには含めないようにした。

目次は墨付き括弧【】

有価証券報告書(EDINETの書類全般に言えることだが)の書類は、墨付き括弧【】を目次とする、という仕様である。有価証券報告書だと以下のようになっている。

第一部【企業情報】
    第1【企業の概況】
        1【主要な経営指標等の推移】
        2【沿革】
        ...
    第2【事業の状況】

墨付き括弧【】が目次を表し、その前の文字列「第一部」「第1」「1」の形式によって階層を識別する。このルールに従ってテキスト中から目次を識別して区切ったり、目次の階層構造を認識してパンくずリストを作成する。

目次のルールの詳細はEDINETの提出書類ファイル仕様書に記載されている。

検索

PostgreSQL/PGroongaではキーワード1 OR キーワード2というようなクエリー構文を使って全文検索をする場合は&@~演算子を使う。

SELECT M.docid, M.filername, M.docdescription, M.submitdatetime, D.breadcrumb,
        (pgroonga_snippet_html(content,pgroonga_query_extract_keywords (:search_query), 400))[1] AS highlighted_content
FROM documents M, document_texts D
WHERE M.docid = D.docid
AND   D.content &@~ '本文に対するクエリ―'
AND   D.breadcrumb &@~ '目次に対するクエリ―'
ORDER BY M.submitdatetime DESC;

pgroonga_snippet_html関数は検索対象のテキストからキーワード周辺のテキストを抽出できる。

上記のようなクエリ―で検索した結果を画面表示する。
検索画面
本文クエリ―「コンビニ」で検索した結果

検索結果は、書類ごとに表示される。複数の目次項目で検索された場合には目次ごとに表示している。
2つ目のテキストボックスで目次の絞り込みも可能である。

終わりに

冒頭の目標は全て達成できた。
特にPGroongaが優秀で、処理は速いし、数値の全角半角の違いなど気にせずに検索できる。

Yakumoでは有価証券報告書1年分を処理の対象にしたが、条件を変更することで半期報告書を対象に加えたり、対象期間を延ばしたりすることも可能である。興味ある方はソースをクローンして、該当箇所の条件を変更するなど試してみてほしい。

最後に自己紹介

本稿の著者パンダはフリーのITエンジニアです。お仕事はいつでも募集してます。PythonとC#が得意です。次がCで、Goも一応可。デザインが苦手でバックエンド派。手離れの良い成果物に定評があります。よろしくお願いします。

この記事はコンテストに間に合わせるために急いで作って書きました。間違いや勘違いがあったら、コメントで教えていただけると喜びます。

Discussion