プロローグ
きっかけはこちらのEDINET APIに関する記事を読んだこと。内容としては、EDINET APIを活用して任意の期間にEDINETに提出された有価証券報告書をローカルに一括ダウンロードするというもので、書類一覧APIのレスポンスに含まれる提出者 EDINETコード
を使えば特定の業種とか特定の企業の提出書類とかに絞ってダウンロードする、とかも出来そうだし、何よりそうしてダウンロードしてきたフォルダのxbrlを解析して分析につなげる(エピローグも参照)ことも出来て面白そうだなといった感じだった。
けどワイ、pythonはちょっと触ったことあるだけで、全然詳しくない。イチから勉強する時間もないしめんどくさくもあるので、自分が使い慣れているNode.jsを使って、まずは記事で紹介されていることと同じことを実現してみることにした。元ネタの記事だと有価証券報告書のzipファイルをダウンロードしてくるとこまでだったんだけど、今回はそれを解凍してフォルダに配置して、zipファイルのほうは削除するというとこまで実装してみたンゴ。
コード
先に今回のコードをお見せするンゴね。今回使うモジュールの中でadm-zip
はサードパーティ製なので事前にnpm installしておく。
npm i adm-zip
今回は2019年11月1日~2019年11月30日の期間に提出された有価証券報告書を対象としてみた。
const https = require('https');
const fs = require('fs');
const admZip = require("adm-zip");
//パスの末尾にスラッシュ(/)をつける。(hogeフォルダにするなら{hogeの親フォルダまでのパス}/hoge/)
const targetFolderPath = '{ダウンロードした有価証券報告書を保存しておく任意のフォルダのパス}';
const startingDate = "2019-11-01";
const endingDate = "2019-11-30";
const EDINET_API_URL = 'https://disclosure.edinet-fsa.go.jp/api/v1';
//指定された期間内の日付を配列で返す
const makeDayList = (startString, endString) => {
const startDate = new Date(startString);
const endDate = new Date(endString);
const period = (endDate - startDate) / 86400000 + 1;
const dayList = [];
for (let i = 0; i < period; i++) {
const baseDate = startDate;
if (i !== 0) {
baseDate.setDate(baseDate.getDate() + 1);
}
const year = baseDate.getFullYear();
let month = baseDate.getMonth() + 1;
let date = baseDate.getDate();
if (month < 10) {
month = '0' + month;
}
if (date < 10) {
date = '0' + date;
}
const dateString = `${year}-${month}-${date}`;
dayList.push(dateString);
}
return dayList;
};
//指定されたurlにhttpsでリクエストを送信して、結果をPromiseインスタンスとして返す
const getAPI = (url, docId) => {
return new Promise((resolve, reject) => {
https.get(url, (httpsRes) => {
if (httpsRes.statusCode !== 200) {
console.log('statusCode:', httpsRes.statusCode);
console.log('headers:', httpsRes.headers);
}
const chunks = [];
httpsRes.on('data', (data) => {
chunks.push(data);
});
httpsRes.on('end', () => {
//EDINET APIのうち、どちらのAPI(書類一覧 API or 書類取得 API)が呼ばれたかによって、データの返し方を変えている
//書類一覧 APIの場合
if (!docId) {
const buffer = Buffer.concat(chunks);
resolve(buffer);
//書類取得 APIの場合
} else {
const resultSet = {};
resultSet.content = Buffer.concat(chunks);
resultSet.docId = docId;
resolve(resultSet);
}
});
}).on('error', (e) => {
reject(e);
})
});
};
/*指定された期間分だけ書類一覧取得のAPIを実行し、結果をPromiseインスタンスとして返す。
fullfilledの場合はAPIから取得した有価証券報告書のIDを配列にしたものを返り値として持つPromiseインスタンスを返す*/
const makeDocIdList = async(dayList) => {
const responses = [];
for (let i = 0;i < dayList.length;i++) {
//書類一覧 APIのurlを設定する
const url = EDINET_API_URL + `/documents.json?date=${dayList[i]}&type=2`;
//getAPI()が終わってから後続の処理を実行するようにする(同期処理にする)
const response = await getAPI(url);
console.log(`the response of ${dayList[i]} has been received`);
responses.push(response);
}
const docIdList = [];
responses.forEach((response) => {
response = JSON.parse(response);
response.results.forEach((eachResult) => {
//縦覧終了の場合、書類取り下げの場合、有価証券報告書以外の場合はリストに追加しない
if (!(eachResult.edinetCode === null && eachResult.withdrawalStatus === 0) && eachResult.withdrawalStatus !== 2 && eachResult.ordinanceCode === '010' && eachResult.formCode === '030000') {
docIdList.push(eachResult.docID);
}
});
});
return docIdList;
};
//始点日と終点日を入力
const dayList = makeDayList(startingDate, endingDate);
makeDocIdList(dayList).then(async(docIdList) => {
//有価証券報告書が1件もヒットしなかった場合は、後続のAPIでdocIDを指定することが出来ないためrejected状態にしてcatch文までスキップする
if (!docIdList) {
return Promise.reject(new Error("There appears to be no docId.\nThe program is ending..."));
}
const responses = [];
for (let i = 0;i < docIdList.length;i++) {
//書類取得 APIのurlを設定する
const url = EDINET_API_URL + `/documents/${docIdList[i]}?type=1`;
//getAPI()が終わってから後続の処理を実行するようにする(同期処理にする)
const response = await getAPI(url, docIdList[i]);
console.log(`the response of docID '${docIdList[i]}' has been received`);
responses.push(response);
}
return responses;
}).then((apiReponses) => {
const writeDocument = (content, docId, fileName) => {
return new Promise((resolve, reject) => {
//ファイルの書き込みを行う
fs.writeFile(fileName, content, (err) => {
if (err) {
console.error(err);
reject(err);
} else {
console.log(`the content of ${docId} has been written into the designated folder`);
resolve();
}
});
});
}
const writeDocments = [];
apiReponses.forEach((apiReponse) => {
const fileName = targetFolderPath + apiReponse.docId + '.zip';
writeDocments.push(writeDocument(apiReponse.content, apiReponse.docId, fileName));
});
//zipファイルの一括書き込み
return Promise.all(writeDocments).then(() => {
console.log('writing process has completely been finished');
});
}).then(() => {
//指定したパスのフォルダーの中身を配列として返す
const makefileList = (targetFolderPath) => {
return new Promise((resolve, reject) => {
fs.readdir(targetFolderPath, (err, files) => {
if (err) {
reject(err);
} else {
resolve(files);
}
});
});
}
return Promise.resolve().then(() => {
return makefileList(targetFolderPath);
}).then((files) => {
const decompressFileList = [];
//ファイルを解凍して指定したフォルダーに格納する
const decompressFile = (zip, folderPath) => {
return new Promise((resolve, reject) => {
fs.mkdir(folderPath, (err) => {
if (err) {
reject(err);
} else {
zip.extractAllTo(folderPath, true);
resolve();
}
});
});
}
const deleteFileList = [];
//ファイルを削除する
const deleteFile = (filePath) => {
return new Promise((resolve, reject) => {
fs.unlink(filePath, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
})
}
files.forEach((eachFile) => {
const filePath = targetFolderPath + eachFile;
const zip = new admZip(filePath);
//末尾の拡張子(.zip)を取り除く
const fileName = eachFile.slice(0, -4);
decompressFileList.push(decompressFile(zip, targetFolderPath + fileName));
deleteFileList.push(deleteFile(filePath));
});
//一括解凍
return Promise.all(decompressFileList).then(() => {
console.log('the process of decompression has finished');
//一括(ファイル)削除
return Promise.all(deleteFileList).then(() => {
console.log('the process of deleting files has finished');
});
});
});
}).catch((err) => {
console.log(err.message);
});
実行が成功すると、自分で設定したフォルダに76件の子フォルダが作成されているハズ(フォルダ名はそれぞれの書類IDになっている)。この子フォルダの階層だけど、
子フォルダ > XBRL > PublicDoc
と辿るとHTML形式の有価証券報告書とかxbrlファイルとかが見つかる。
ポイント
EDINET APIの仕様上、有価証券報告書の対象期間を増やすと、その分書類一覧APIを送信する回数が多くなり、取得する有価証券報告書が増えれば結果として書類取得 APIを送信する回数も多くなる。つまりそれだけ多量のリクエストを同一のIPアドレスからEDINET APIに集中させることになるので、EDINET API側にサイバー攻撃と認識されない程度のリクエスト回数/秒
であることを意識する必要がある。実は今回のコードの作成途中では、例えば書類取得 APIリクエストを繰り返し実行する部分のコードは以下のようにしていた。
const getAPIs = [];
docIdList.forEach((eachId) => {
const url = EDINET_API_URL + `/documents/${eachId}?type=1`;
getAPIs.push(getAPI(url));
});
return Promise.all(getAPIs).then((apiReponses) => {
//後続処理
});
つまり、
Promise.all([getAPI(url1), getAPI(url2), ...]).then((apiReponses) => {});
とすることで、書類取得 APIリクエストを必要な回数分非同期で実行し、全ての実行が終わってすべてfullfilled
状態がreturnされたらば、次の処理に移るというような実装にしていた。
これだと、有価証券報告書取得の対象期間を長くした場合、短い間に大量のリクエストが送信されて、ステータスコード403
でYou don't have permission to access
というレスポンスが返ってきてしまうようになって、その後単体のAPIリクエストを送信しても一定時間は403エラーとなってしまう状態が続いた(しばらくしてもう一度試すとステータスコード200で正常なレスポンスが返却されるようになった)ので、おそらくDoS攻撃的なものと認識されてIPアドレスごと一定時間お断りを食らってしまったのではと推測しているンゴ。
なのでここではawait文を使ってAPIリクエストの送信を同期的に実行する必要があることが分かり、完成したソースコードのような形になった。await文はasyncをつけた関数(Async Function
と呼ぶらしい)内でしか使えないために
then(async() => {})
となっている。ちなみにAsync Functionが呼び出し元に何を返すかはこちらの通り、
- 値をreturn → その返り値を持つFulfilledなPromise
- Promiseをreturn → そのPromiseそのもの
- Async Function内で例外が発生した場合 → そのエラーを持つRejectedなPromise
となるので、値をreturnした場合でも意識せずにfullfilled状態のPromiseインスタンスが(returnする値とともに)返される。
また完成形のコードの中で、配列の中身で繰り返し処理をする部分がforEach()
ではなく普通のfor文になっているのは、こちらにあるように、forEach()
のコールバック関数をasyncにすると、完成したソースコードの
await getAPI(url, docIdList[i]);
の部分を待ってくれるのはコールバック関数内だけになり、その外側では処理が進んでしまうという挙動になってしまうからという理由があるンゴよね。実は今回awaitを使う必要性に迫られるまで、asyncとawaitとかについて全然よくわかっていなかったので、いい勉強になった。
エピローグ~XBRLの分析のためには…~
これでNode.jsを使ってEDINET APIを活用することが出来そう。その先としては、そうして
取得してきた有価証券報告書のXBRLファイルを使ったデータ加工・分析とかがしたくて、そのやり方が別の記事でこれまたpythonを使って紹介されていた(こちら)。
実はこれについてもNode.jsで実現しようと思ってコーディングをしてみたんだけども、xbrlをプログラム上で扱いやすいデータ形式に変換してくれるパッケージをNode.js界隈で探してみたところ、pythonと違って1つしかなくて、しかもそれを使った変換が上手くいかなかった。仕方ないからxbrlの変換部分だけpythonでやろうってことにしてNode.jsからpythonプログラムを呼び出せるpython-shell
っていうパッケージを使ったんだけど、そこでも躓いてしまって、それ以降何も進捗がない…
こうなると正直、pythonを習得したほうが早いんじゃないかっていう気がしてきたンゴ(´・ω・)