@mseep/korean-dart-mcp 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +660 -0
- package/build/cli.d.ts +2 -0
- package/build/cli.js +22 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +36 -0
- package/build/lib/corp-code.d.ts +53 -0
- package/build/lib/corp-code.js +235 -0
- package/build/lib/dart-client.d.ts +25 -0
- package/build/lib/dart-client.js +71 -0
- package/build/lib/dart-xml.d.ts +26 -0
- package/build/lib/dart-xml.js +187 -0
- package/build/lib/xbrl-parser.d.ts +145 -0
- package/build/lib/xbrl-parser.js +673 -0
- package/build/server/mcp-server.d.ts +7 -0
- package/build/server/mcp-server.js +40 -0
- package/build/setup.d.ts +8 -0
- package/build/setup.js +264 -0
- package/build/tools/_helpers.d.ts +28 -0
- package/build/tools/_helpers.js +35 -0
- package/build/tools/buffett-quality-snapshot.d.ts +10 -0
- package/build/tools/buffett-quality-snapshot.js +261 -0
- package/build/tools/disclosure-anomaly.d.ts +14 -0
- package/build/tools/disclosure-anomaly.js +231 -0
- package/build/tools/download-document.d.ts +14 -0
- package/build/tools/download-document.js +89 -0
- package/build/tools/get-attachments.d.ts +15 -0
- package/build/tools/get-attachments.js +339 -0
- package/build/tools/get-company.d.ts +7 -0
- package/build/tools/get-company.js +32 -0
- package/build/tools/get-corporate-event.d.ts +14 -0
- package/build/tools/get-corporate-event.js +180 -0
- package/build/tools/get-executive-compensation.d.ts +13 -0
- package/build/tools/get-executive-compensation.js +89 -0
- package/build/tools/get-financials.d.ts +15 -0
- package/build/tools/get-financials.js +127 -0
- package/build/tools/get-major-holdings.d.ts +10 -0
- package/build/tools/get-major-holdings.js +117 -0
- package/build/tools/get-periodic-report.d.ts +7 -0
- package/build/tools/get-periodic-report.js +100 -0
- package/build/tools/get-shareholders.d.ts +13 -0
- package/build/tools/get-shareholders.js +87 -0
- package/build/tools/get-xbrl.d.ts +12 -0
- package/build/tools/get-xbrl.js +96 -0
- package/build/tools/index.d.ts +38 -0
- package/build/tools/index.js +66 -0
- package/build/tools/insider-signal.d.ts +15 -0
- package/build/tools/insider-signal.js +208 -0
- package/build/tools/resolve-corp-code.d.ts +7 -0
- package/build/tools/resolve-corp-code.js +40 -0
- package/build/tools/search-disclosures.d.ts +14 -0
- package/build/tools/search-disclosures.js +300 -0
- package/build/utils/safe-zip.d.ts +47 -0
- package/build/utils/safe-zip.js +215 -0
- package/build/version.d.ts +2 -0
- package/build/version.js +2 -0
- package/package.json +67 -0
package/build/cli.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { VERSION } from "./version.js";
|
|
5
|
+
const program = new Command();
|
|
6
|
+
program
|
|
7
|
+
.name("korean-dart")
|
|
8
|
+
.description("OpenDART CLI — 공시/재무/지분 조회")
|
|
9
|
+
.version(VERSION);
|
|
10
|
+
program
|
|
11
|
+
.command("resolve <keyword>")
|
|
12
|
+
.description("회사명 → corp_code 조회")
|
|
13
|
+
.action(async (_keyword) => {
|
|
14
|
+
console.log("TODO: corp_code resolver 구현 예정");
|
|
15
|
+
});
|
|
16
|
+
program
|
|
17
|
+
.command("search <keyword>")
|
|
18
|
+
.description("공시 검색")
|
|
19
|
+
.action(async (_keyword) => {
|
|
20
|
+
console.log("TODO: search_disclosures 구현 예정");
|
|
21
|
+
});
|
|
22
|
+
program.parseAsync(process.argv);
|
package/build/index.d.ts
ADDED
package/build/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { createServer } from "./server/mcp-server.js";
|
|
5
|
+
import { SERVER_NAME, VERSION } from "./version.js";
|
|
6
|
+
async function main() {
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
// setup 서브커맨드: npx korean-dart-mcp setup
|
|
9
|
+
if (args[0] === "setup") {
|
|
10
|
+
const { runSetup } = await import("./setup.js");
|
|
11
|
+
try {
|
|
12
|
+
await runSetup();
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
if (err?.code === "ERR_USE_AFTER_CLOSE")
|
|
16
|
+
return;
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const apiKey = process.env.DART_API_KEY;
|
|
22
|
+
if (!apiKey) {
|
|
23
|
+
console.error(`[${SERVER_NAME}] DART_API_KEY 가 설정되지 않았습니다.\n` +
|
|
24
|
+
`발급: https://opendart.fss.or.kr/ (가입 → 인증키 신청)\n` +
|
|
25
|
+
`쉽게 설치: npx -y korean-dart-mcp setup`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const server = createServer({ apiKey });
|
|
29
|
+
const transport = new StdioServerTransport();
|
|
30
|
+
await server.connect(transport);
|
|
31
|
+
console.error(`[${SERVER_NAME}] v${VERSION} stdio 서버 시작`);
|
|
32
|
+
}
|
|
33
|
+
main().catch((err) => {
|
|
34
|
+
console.error(`[${SERVER_NAME}] fatal:`, err);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* corp_code 리졸버
|
|
3
|
+
*
|
|
4
|
+
* 문제: LLM은 "삼성전자" 만 아는데, 모든 OpenDART 엔드포인트는 8자리 corp_code 필수.
|
|
5
|
+
* 해결: 서버 기동 시 `corpCode.xml` 전체 덤프를 내려받아 SQLite 로 인덱싱.
|
|
6
|
+
* 첫 호출 시 한 번만 받고, 이후는 캐시 디렉터리에서 재사용 (24h TTL).
|
|
7
|
+
*
|
|
8
|
+
* 엔드포인트: /api/corpCode.xml (ZIP → CORPCODE.xml)
|
|
9
|
+
* 레코드 형식: <list><corp_code>...</corp_code><corp_name>...</corp_name>
|
|
10
|
+
* <corp_eng_name>...</corp_eng_name><stock_code>...</stock_code>
|
|
11
|
+
* <modify_date>...</modify_date></list>
|
|
12
|
+
*/
|
|
13
|
+
import type { DartClient } from "./dart-client.js";
|
|
14
|
+
export interface CorpRecord {
|
|
15
|
+
corp_code: string;
|
|
16
|
+
corp_name: string;
|
|
17
|
+
corp_eng_name?: string;
|
|
18
|
+
stock_code?: string;
|
|
19
|
+
modify_date?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CorpCodeResolverOptions {
|
|
22
|
+
/** 캐시 디렉터리 (기본: ~/.korean-dart-mcp) */
|
|
23
|
+
cacheDir?: string;
|
|
24
|
+
/** 디스크 캐시를 무시하고 재다운로드 */
|
|
25
|
+
forceRefresh?: boolean;
|
|
26
|
+
/** 캐시 TTL (ms, 기본 24h) */
|
|
27
|
+
ttlMs?: number;
|
|
28
|
+
}
|
|
29
|
+
export declare class CorpCodeResolver {
|
|
30
|
+
private readonly cacheDir;
|
|
31
|
+
private readonly dbPath;
|
|
32
|
+
private readonly forceRefresh;
|
|
33
|
+
private readonly ttlMs;
|
|
34
|
+
private db;
|
|
35
|
+
private initPromise;
|
|
36
|
+
constructor(opts?: CorpCodeResolverOptions);
|
|
37
|
+
/** 서버 기동 시 1회 호출. 캐시 유효하면 DB만 열고 끝. */
|
|
38
|
+
init(client: DartClient): Promise<void>;
|
|
39
|
+
private doInit;
|
|
40
|
+
private isCacheFresh;
|
|
41
|
+
/** 키워드로 회사 검색. alias → 상장사 → 완전일치 → 접두사 → 짧은 이름 → 낮은 종목코드 순. */
|
|
42
|
+
search(keyword: string, limit?: number): CorpRecord[];
|
|
43
|
+
byStockCode(code: string): CorpRecord | undefined;
|
|
44
|
+
byCorpCode(code: string): CorpRecord | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* 입력 문자열을 단일 corp_code 로 해석.
|
|
47
|
+
* - 8자리 숫자 → byCorpCode
|
|
48
|
+
* - 6자리 숫자 → byStockCode
|
|
49
|
+
* - 그 외 → search() 상위 1건
|
|
50
|
+
*/
|
|
51
|
+
resolve(input: string): CorpRecord | undefined;
|
|
52
|
+
private requireDb;
|
|
53
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* corp_code 리졸버
|
|
3
|
+
*
|
|
4
|
+
* 문제: LLM은 "삼성전자" 만 아는데, 모든 OpenDART 엔드포인트는 8자리 corp_code 필수.
|
|
5
|
+
* 해결: 서버 기동 시 `corpCode.xml` 전체 덤프를 내려받아 SQLite 로 인덱싱.
|
|
6
|
+
* 첫 호출 시 한 번만 받고, 이후는 캐시 디렉터리에서 재사용 (24h TTL).
|
|
7
|
+
*
|
|
8
|
+
* 엔드포인트: /api/corpCode.xml (ZIP → CORPCODE.xml)
|
|
9
|
+
* 레코드 형식: <list><corp_code>...</corp_code><corp_name>...</corp_name>
|
|
10
|
+
* <corp_eng_name>...</corp_eng_name><stock_code>...</stock_code>
|
|
11
|
+
* <modify_date>...</modify_date></list>
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, existsSync, unlinkSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import Database from "better-sqlite3";
|
|
17
|
+
import { DOMParser } from "@xmldom/xmldom";
|
|
18
|
+
import { safeUnzipToMemory } from "../utils/safe-zip.js";
|
|
19
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
const CORP_CODE_RE = /^\d{8}$/;
|
|
21
|
+
const STOCK_CODE_RE = /^\d{6}$/;
|
|
22
|
+
// 영문 corp_name 으로 등록된 한국 상장사 — 한글 query 로 LIKE 매칭이 안 되어
|
|
23
|
+
// 자회사로 잘못 resolve 되는 케이스 방어. corp_code 는 OpenDART corpCode.xml 검증 필요.
|
|
24
|
+
// "현대차" 같은 약어도 자회사 우선 정렬되는 케이스라 alias 에 포함.
|
|
25
|
+
const KOREAN_ALIAS = {
|
|
26
|
+
네이버: "00266961", // NAVER
|
|
27
|
+
현대차: "00164742", // 현대자동차
|
|
28
|
+
};
|
|
29
|
+
export class CorpCodeResolver {
|
|
30
|
+
constructor(opts = {}) {
|
|
31
|
+
this.db = null;
|
|
32
|
+
this.initPromise = null;
|
|
33
|
+
this.cacheDir = opts.cacheDir ?? join(homedir(), ".korean-dart-mcp");
|
|
34
|
+
this.dbPath = join(this.cacheDir, "corp_code.sqlite");
|
|
35
|
+
this.forceRefresh = opts.forceRefresh ?? false;
|
|
36
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
37
|
+
}
|
|
38
|
+
/** 서버 기동 시 1회 호출. 캐시 유효하면 DB만 열고 끝. */
|
|
39
|
+
async init(client) {
|
|
40
|
+
if (this.initPromise)
|
|
41
|
+
return this.initPromise;
|
|
42
|
+
this.initPromise = this.doInit(client);
|
|
43
|
+
return this.initPromise;
|
|
44
|
+
}
|
|
45
|
+
async doInit(client) {
|
|
46
|
+
if (!existsSync(this.cacheDir)) {
|
|
47
|
+
mkdirSync(this.cacheDir, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
const cacheFresh = !this.forceRefresh && this.isCacheFresh();
|
|
50
|
+
if (cacheFresh) {
|
|
51
|
+
this.db = new Database(this.dbPath, { readonly: false });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.error("[corp-code] 덤프 다운로드 중... (수 초 소요)");
|
|
55
|
+
const zipBuf = await client.getZip("corpCode.xml");
|
|
56
|
+
const xml = await extractCorpCodeXml(zipBuf);
|
|
57
|
+
const records = parseCorpCodeXml(xml);
|
|
58
|
+
console.error(`[corp-code] ${records.length}개 회사 적재 중...`);
|
|
59
|
+
this.db = buildDatabase(this.dbPath, records);
|
|
60
|
+
console.error("[corp-code] 준비 완료");
|
|
61
|
+
}
|
|
62
|
+
isCacheFresh() {
|
|
63
|
+
if (!existsSync(this.dbPath))
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
const db = new Database(this.dbPath, { readonly: true });
|
|
67
|
+
const row = db
|
|
68
|
+
.prepare("SELECT value FROM meta WHERE key = 'updated_at'")
|
|
69
|
+
.get();
|
|
70
|
+
db.close();
|
|
71
|
+
if (!row)
|
|
72
|
+
return false;
|
|
73
|
+
const age = Date.now() - Number(row.value);
|
|
74
|
+
return age < this.ttlMs;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** 키워드로 회사 검색. alias → 상장사 → 완전일치 → 접두사 → 짧은 이름 → 낮은 종목코드 순. */
|
|
81
|
+
search(keyword, limit = 10) {
|
|
82
|
+
const db = this.requireDb();
|
|
83
|
+
const k = keyword.trim();
|
|
84
|
+
if (!k)
|
|
85
|
+
return [];
|
|
86
|
+
// 1. 한글 alias 우선: 영문등록 한국 상장사가 LIKE 매칭에서 누락되는 케이스 방어
|
|
87
|
+
const aliasCode = KOREAN_ALIAS[k];
|
|
88
|
+
let aliased;
|
|
89
|
+
if (aliasCode)
|
|
90
|
+
aliased = this.byCorpCode(aliasCode);
|
|
91
|
+
// 2. 일반 LIKE 검색. 동률 정렬 시 stock_code ASC 추가 (낮은 종목코드 = 오래된 대형사 휴리스틱).
|
|
92
|
+
const like = `%${k}%`;
|
|
93
|
+
const rows = db
|
|
94
|
+
.prepare(`SELECT corp_code, corp_name, corp_eng_name, stock_code, modify_date
|
|
95
|
+
FROM corps
|
|
96
|
+
WHERE corp_name LIKE ?
|
|
97
|
+
OR corp_eng_name LIKE ?
|
|
98
|
+
ORDER BY
|
|
99
|
+
(stock_code IS NULL OR stock_code = '') ASC,
|
|
100
|
+
CASE WHEN corp_name = ? THEN 0
|
|
101
|
+
WHEN corp_name LIKE ? THEN 1
|
|
102
|
+
ELSE 2 END,
|
|
103
|
+
length(corp_name) ASC,
|
|
104
|
+
CASE WHEN stock_code IS NULL OR stock_code = '' THEN 1 ELSE 0 END,
|
|
105
|
+
stock_code ASC
|
|
106
|
+
LIMIT ?`)
|
|
107
|
+
.all(like, like, k, `${k}%`, limit);
|
|
108
|
+
const normalized = rows.map(normalize);
|
|
109
|
+
// alias 결과를 1위로 prepend (중복 제거)
|
|
110
|
+
if (aliased) {
|
|
111
|
+
const others = normalized.filter((r) => r.corp_code !== aliased.corp_code);
|
|
112
|
+
return [aliased, ...others].slice(0, limit);
|
|
113
|
+
}
|
|
114
|
+
return normalized;
|
|
115
|
+
}
|
|
116
|
+
byStockCode(code) {
|
|
117
|
+
const db = this.requireDb();
|
|
118
|
+
const row = db
|
|
119
|
+
.prepare(`SELECT corp_code, corp_name, corp_eng_name, stock_code, modify_date
|
|
120
|
+
FROM corps WHERE stock_code = ? LIMIT 1`)
|
|
121
|
+
.get(code);
|
|
122
|
+
return row ? normalize(row) : undefined;
|
|
123
|
+
}
|
|
124
|
+
byCorpCode(code) {
|
|
125
|
+
const db = this.requireDb();
|
|
126
|
+
const row = db
|
|
127
|
+
.prepare(`SELECT corp_code, corp_name, corp_eng_name, stock_code, modify_date
|
|
128
|
+
FROM corps WHERE corp_code = ? LIMIT 1`)
|
|
129
|
+
.get(code);
|
|
130
|
+
return row ? normalize(row) : undefined;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 입력 문자열을 단일 corp_code 로 해석.
|
|
134
|
+
* - 8자리 숫자 → byCorpCode
|
|
135
|
+
* - 6자리 숫자 → byStockCode
|
|
136
|
+
* - 그 외 → search() 상위 1건
|
|
137
|
+
*/
|
|
138
|
+
resolve(input) {
|
|
139
|
+
const s = input.trim();
|
|
140
|
+
if (CORP_CODE_RE.test(s))
|
|
141
|
+
return this.byCorpCode(s);
|
|
142
|
+
if (STOCK_CODE_RE.test(s))
|
|
143
|
+
return this.byStockCode(s);
|
|
144
|
+
return this.search(s, 1)[0];
|
|
145
|
+
}
|
|
146
|
+
requireDb() {
|
|
147
|
+
if (!this.db) {
|
|
148
|
+
throw new Error("CorpCodeResolver.init() 을 먼저 호출하세요.");
|
|
149
|
+
}
|
|
150
|
+
return this.db;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function normalize(row) {
|
|
154
|
+
return {
|
|
155
|
+
corp_code: row.corp_code,
|
|
156
|
+
corp_name: row.corp_name,
|
|
157
|
+
corp_eng_name: row.corp_eng_name || undefined,
|
|
158
|
+
stock_code: row.stock_code && row.stock_code.trim() !== "" ? row.stock_code : undefined,
|
|
159
|
+
modify_date: row.modify_date || undefined,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async function extractCorpCodeXml(zipBuf) {
|
|
163
|
+
// corp_code 전량 덤프는 현재 ~30MB 수준. 장기 성장 대비 총 300MB · 단일 300MB 허용.
|
|
164
|
+
const entries = await safeUnzipToMemory(zipBuf, {
|
|
165
|
+
maxTotalBytes: 300 * 1024 * 1024,
|
|
166
|
+
maxEntryBytes: 300 * 1024 * 1024,
|
|
167
|
+
maxEntries: 16,
|
|
168
|
+
filter: (name) => /CORPCODE\.xml$/i.test(name),
|
|
169
|
+
});
|
|
170
|
+
const hit = entries[0];
|
|
171
|
+
if (!hit)
|
|
172
|
+
throw new Error("CORPCODE.xml not found in zip");
|
|
173
|
+
return hit.data.toString("utf8");
|
|
174
|
+
}
|
|
175
|
+
function parseCorpCodeXml(xml) {
|
|
176
|
+
const doc = new DOMParser().parseFromString(xml, "text/xml");
|
|
177
|
+
const listEls = doc.getElementsByTagName("list");
|
|
178
|
+
const out = [];
|
|
179
|
+
for (let i = 0; i < listEls.length; i++) {
|
|
180
|
+
const el = listEls[i];
|
|
181
|
+
const code = text(el, "corp_code");
|
|
182
|
+
const name = text(el, "corp_name");
|
|
183
|
+
if (!code || !name)
|
|
184
|
+
continue;
|
|
185
|
+
out.push({
|
|
186
|
+
corp_code: code,
|
|
187
|
+
corp_name: name,
|
|
188
|
+
corp_eng_name: text(el, "corp_eng_name") || undefined,
|
|
189
|
+
stock_code: text(el, "stock_code") || undefined,
|
|
190
|
+
modify_date: text(el, "modify_date") || undefined,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
// xmldom 의 Element 타입을 직접 쓰지 않고 duck-typing 으로 접근.
|
|
196
|
+
// DOM lib 을 tsconfig 에 포함시키지 않아 전역 Element 가 없음.
|
|
197
|
+
function text(parent, tag) {
|
|
198
|
+
const t = parent.getElementsByTagName(tag)[0]?.textContent ?? "";
|
|
199
|
+
return t.trim();
|
|
200
|
+
}
|
|
201
|
+
function buildDatabase(path, records) {
|
|
202
|
+
if (existsSync(path)) {
|
|
203
|
+
try {
|
|
204
|
+
unlinkSync(path);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* ignore — 새 DB 생성 시 덮어써짐 */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const db = new Database(path);
|
|
211
|
+
db.pragma("journal_mode = WAL");
|
|
212
|
+
db.exec(`
|
|
213
|
+
CREATE TABLE corps (
|
|
214
|
+
corp_code TEXT PRIMARY KEY,
|
|
215
|
+
corp_name TEXT NOT NULL,
|
|
216
|
+
corp_eng_name TEXT,
|
|
217
|
+
stock_code TEXT,
|
|
218
|
+
modify_date TEXT
|
|
219
|
+
);
|
|
220
|
+
CREATE INDEX idx_corps_name ON corps(corp_name);
|
|
221
|
+
CREATE INDEX idx_corps_stock ON corps(stock_code) WHERE stock_code IS NOT NULL AND stock_code != '';
|
|
222
|
+
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
223
|
+
`);
|
|
224
|
+
const insert = db.prepare(`INSERT OR REPLACE INTO corps (corp_code, corp_name, corp_eng_name, stock_code, modify_date)
|
|
225
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
226
|
+
const tx = db.transaction((items) => {
|
|
227
|
+
for (const r of items) {
|
|
228
|
+
insert.run(r.corp_code, r.corp_name, r.corp_eng_name ?? null, r.stock_code ?? null, r.modify_date ?? null);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
tx(records);
|
|
232
|
+
db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('updated_at', ?)").run(String(Date.now()));
|
|
233
|
+
db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('count', ?)").run(String(records.length));
|
|
234
|
+
return db;
|
|
235
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenDART HTTP 클라이언트
|
|
3
|
+
*
|
|
4
|
+
* Base: https://opendart.fss.or.kr/api/
|
|
5
|
+
* 인증: 모든 요청에 `crtfc_key` 쿼리파라미터 필수
|
|
6
|
+
* 응답: JSON (대부분) / ZIP (원문·corp_code·XBRL)
|
|
7
|
+
* 요율: 일 20,000건 (키 단위 합산)
|
|
8
|
+
*/
|
|
9
|
+
export interface DartClientOptions {
|
|
10
|
+
apiKey: string;
|
|
11
|
+
/** 요청 타임아웃 (ms), 기본 30s */
|
|
12
|
+
timeout?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare class DartClient {
|
|
15
|
+
private readonly apiKey;
|
|
16
|
+
private readonly timeout;
|
|
17
|
+
constructor(opts: DartClientOptions);
|
|
18
|
+
/** JSON 엔드포인트 호출 */
|
|
19
|
+
getJson<T = unknown>(path: string, params?: Record<string, string | number | undefined>): Promise<T>;
|
|
20
|
+
/** ZIP 엔드포인트 호출 (corp_code 덤프, 원문 XML, XBRL 등).
|
|
21
|
+
* DART 는 에러 시 Content-Type 은 zip 이지만 바디는 {"status":"013",...} JSON 을 돌려준다. */
|
|
22
|
+
getZip(path: string, params?: Record<string, string | number | undefined>): Promise<Buffer>;
|
|
23
|
+
private buildUrl;
|
|
24
|
+
private fetch;
|
|
25
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenDART HTTP 클라이언트
|
|
3
|
+
*
|
|
4
|
+
* Base: https://opendart.fss.or.kr/api/
|
|
5
|
+
* 인증: 모든 요청에 `crtfc_key` 쿼리파라미터 필수
|
|
6
|
+
* 응답: JSON (대부분) / ZIP (원문·corp_code·XBRL)
|
|
7
|
+
* 요율: 일 20,000건 (키 단위 합산)
|
|
8
|
+
*/
|
|
9
|
+
const DART_BASE_URL = "https://opendart.fss.or.kr/api";
|
|
10
|
+
export class DartClient {
|
|
11
|
+
constructor(opts) {
|
|
12
|
+
this.apiKey = opts.apiKey;
|
|
13
|
+
this.timeout = opts.timeout ?? 30000;
|
|
14
|
+
}
|
|
15
|
+
/** JSON 엔드포인트 호출 */
|
|
16
|
+
async getJson(path, params = {}) {
|
|
17
|
+
const url = this.buildUrl(path, params);
|
|
18
|
+
const res = await this.fetch(url);
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
throw new Error(`DART ${path} → HTTP ${res.status}`);
|
|
21
|
+
}
|
|
22
|
+
return (await res.json());
|
|
23
|
+
}
|
|
24
|
+
/** ZIP 엔드포인트 호출 (corp_code 덤프, 원문 XML, XBRL 등).
|
|
25
|
+
* DART 는 에러 시 Content-Type 은 zip 이지만 바디는 {"status":"013",...} JSON 을 돌려준다. */
|
|
26
|
+
async getZip(path, params = {}) {
|
|
27
|
+
const url = this.buildUrl(path, params);
|
|
28
|
+
const res = await this.fetch(url);
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
throw new Error(`DART ${path} → HTTP ${res.status}`);
|
|
31
|
+
}
|
|
32
|
+
const ab = await res.arrayBuffer();
|
|
33
|
+
const buf = Buffer.from(ab);
|
|
34
|
+
// PK\x03\x04 (zip local file header) 또는 PK\x05\x06 (empty zip) 으로 시작해야 정상
|
|
35
|
+
if (buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b)
|
|
36
|
+
return buf;
|
|
37
|
+
// JSON 에러 응답 감지
|
|
38
|
+
const head = buf.subarray(0, Math.min(512, buf.length)).toString("utf8");
|
|
39
|
+
if (head.trimStart().startsWith("{")) {
|
|
40
|
+
try {
|
|
41
|
+
const err = JSON.parse(head);
|
|
42
|
+
throw new Error(`DART ${path} → [${err.status ?? "?"}] ${err.message ?? head}`);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (e instanceof Error && e.message.startsWith("DART "))
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`DART ${path} → 비-ZIP 응답 (${buf.length}B): ${head.slice(0, 200)}`);
|
|
50
|
+
}
|
|
51
|
+
buildUrl(path, params) {
|
|
52
|
+
const u = new URL(`${DART_BASE_URL}/${path}`);
|
|
53
|
+
u.searchParams.set("crtfc_key", this.apiKey);
|
|
54
|
+
for (const [k, v] of Object.entries(params)) {
|
|
55
|
+
if (v === undefined || v === "")
|
|
56
|
+
continue;
|
|
57
|
+
u.searchParams.set(k, String(v));
|
|
58
|
+
}
|
|
59
|
+
return u.toString();
|
|
60
|
+
}
|
|
61
|
+
async fetch(url) {
|
|
62
|
+
const ctrl = new AbortController();
|
|
63
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeout);
|
|
64
|
+
try {
|
|
65
|
+
return await fetch(url, { signal: ctrl.signal });
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DART 원문 XML → 마크다운 변환기
|
|
3
|
+
*
|
|
4
|
+
* DART 전용 마크업(`dart4.xsd`) 은 공개 표준이 아니지만 구조가 단순해서
|
|
5
|
+
* 주요 태그만 대응해도 LLM 이 읽을 수 있는 수준의 마크다운을 만들 수 있다.
|
|
6
|
+
*
|
|
7
|
+
* 지원 태그:
|
|
8
|
+
* DOCUMENT / BODY → 루트
|
|
9
|
+
* COVER-TITLE / DOCUMENT-NAME → # 제목
|
|
10
|
+
* SECTION-1/2/3 → 하위 TITLE 의 heading level 제어
|
|
11
|
+
* TITLE → ##/###/#### (상위 SECTION 깊이에 따라)
|
|
12
|
+
* TABLE, THEAD, TBODY, TR, TH, TD, TU → 마크다운 테이블
|
|
13
|
+
* P → 단락
|
|
14
|
+
* PGBRK → 수평선
|
|
15
|
+
* A → 인라인 텍스트 (href 없음)
|
|
16
|
+
* IMAGE/IMG/LIBRARY → 제거
|
|
17
|
+
* SUMMARY/EXTRACTION → 메타 제거
|
|
18
|
+
* COLGROUP/COL → 제거 (스타일 전용)
|
|
19
|
+
* 나머지 (SPAN, COMPANY-NAME 등) → 텍스트만 유지
|
|
20
|
+
*
|
|
21
|
+
* 병합 셀(COLSPAN/ROWSPAN) 은 마크다운 자체가 지원하지 않으므로 무시.
|
|
22
|
+
*
|
|
23
|
+
* 타입 주석: 본 프로젝트 tsconfig 에 DOM lib 이 없어 xmldom 의 Node/Element 전역이 없다.
|
|
24
|
+
* duck-typing 으로 any 처리.
|
|
25
|
+
*/
|
|
26
|
+
export declare function dartXmlToMarkdown(xml: string): string;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DART 원문 XML → 마크다운 변환기
|
|
3
|
+
*
|
|
4
|
+
* DART 전용 마크업(`dart4.xsd`) 은 공개 표준이 아니지만 구조가 단순해서
|
|
5
|
+
* 주요 태그만 대응해도 LLM 이 읽을 수 있는 수준의 마크다운을 만들 수 있다.
|
|
6
|
+
*
|
|
7
|
+
* 지원 태그:
|
|
8
|
+
* DOCUMENT / BODY → 루트
|
|
9
|
+
* COVER-TITLE / DOCUMENT-NAME → # 제목
|
|
10
|
+
* SECTION-1/2/3 → 하위 TITLE 의 heading level 제어
|
|
11
|
+
* TITLE → ##/###/#### (상위 SECTION 깊이에 따라)
|
|
12
|
+
* TABLE, THEAD, TBODY, TR, TH, TD, TU → 마크다운 테이블
|
|
13
|
+
* P → 단락
|
|
14
|
+
* PGBRK → 수평선
|
|
15
|
+
* A → 인라인 텍스트 (href 없음)
|
|
16
|
+
* IMAGE/IMG/LIBRARY → 제거
|
|
17
|
+
* SUMMARY/EXTRACTION → 메타 제거
|
|
18
|
+
* COLGROUP/COL → 제거 (스타일 전용)
|
|
19
|
+
* 나머지 (SPAN, COMPANY-NAME 등) → 텍스트만 유지
|
|
20
|
+
*
|
|
21
|
+
* 병합 셀(COLSPAN/ROWSPAN) 은 마크다운 자체가 지원하지 않으므로 무시.
|
|
22
|
+
*
|
|
23
|
+
* 타입 주석: 본 프로젝트 tsconfig 에 DOM lib 이 없어 xmldom 의 Node/Element 전역이 없다.
|
|
24
|
+
* duck-typing 으로 any 처리.
|
|
25
|
+
*/
|
|
26
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
27
|
+
import { DOMParser } from "@xmldom/xmldom";
|
|
28
|
+
const SKIP_TAGS = new Set([
|
|
29
|
+
"SUMMARY",
|
|
30
|
+
"EXTRACTION",
|
|
31
|
+
"COLGROUP",
|
|
32
|
+
"COL",
|
|
33
|
+
"IMAGE",
|
|
34
|
+
"IMG",
|
|
35
|
+
"IMG-CAPTION",
|
|
36
|
+
"LIBRARY",
|
|
37
|
+
"FORMULA-VERSION",
|
|
38
|
+
]);
|
|
39
|
+
const SECTION_DEPTH = {
|
|
40
|
+
"SECTION-1": 2,
|
|
41
|
+
"SECTION-2": 3,
|
|
42
|
+
"SECTION-3": 4,
|
|
43
|
+
"SECTION-4": 5,
|
|
44
|
+
};
|
|
45
|
+
export function dartXmlToMarkdown(xml) {
|
|
46
|
+
// DART XML 은 일부 엔트리에 한글 태그(`<이사ㆍ감사>`) 가 텍스트가 아닌 마크업으로
|
|
47
|
+
// 들어가 있어 xmldom 의 well-formedness 검사가 ParseError 로 fatal-throw 한다.
|
|
48
|
+
// xmldom errorHandler 옵션은 warning/error 만 silence 가능하고 fatalError 는 throw 됨.
|
|
49
|
+
// → 비-ASCII 로 시작하는 태그는 모두 entity escape 해서 텍스트로 강등시킨다.
|
|
50
|
+
const sanitized = sanitizeNonAsciiTags(xml);
|
|
51
|
+
const silentHandler = () => { };
|
|
52
|
+
let doc;
|
|
53
|
+
try {
|
|
54
|
+
doc = new DOMParser({ errorHandler: silentHandler }).parseFromString(sanitized, "text/xml");
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// 그래도 실패 시 text/html 모드 (xmldom 이 더 관대)
|
|
58
|
+
try {
|
|
59
|
+
doc = new DOMParser({ errorHandler: silentHandler }).parseFromString(sanitized, "text/html");
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const root = doc?.documentElement;
|
|
66
|
+
if (!root)
|
|
67
|
+
return "";
|
|
68
|
+
return convertNode(root, 1).replace(/\n{3,}/g, "\n\n").trim();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `<한글태그>...</한글태그>` 같은 비-ASCII 태그를 entity escape 해서 텍스트로 만든다.
|
|
72
|
+
* XML 표준 NameStartChar 는 유니코드를 허용하지만 DART 의 한글 태그는 닫는 태그가
|
|
73
|
+
* 누락된 경우가 있어 well-formedness 가 깨진다.
|
|
74
|
+
*/
|
|
75
|
+
function sanitizeNonAsciiTags(xml) {
|
|
76
|
+
// 1. CDATA 섹션은 보호 (변환 대상 아님)
|
|
77
|
+
// 2. `<` 다음 첫 문자가 ASCII letter / `?` / `!` / `/` 가 아니면 entity escape
|
|
78
|
+
return xml.replace(/<\/?[^\x00-\x7F][^<>]*>/g, (m) => m.replace(/</g, "<").replace(/>/g, ">"));
|
|
79
|
+
}
|
|
80
|
+
function convertNode(node, titleDepth) {
|
|
81
|
+
if (!node)
|
|
82
|
+
return "";
|
|
83
|
+
if (node.nodeType === 3) {
|
|
84
|
+
// Text node
|
|
85
|
+
return (node.nodeValue ?? "").replace(/[ \t]+/g, " ").replace(/\n+/g, " ");
|
|
86
|
+
}
|
|
87
|
+
if (node.nodeType !== 1)
|
|
88
|
+
return ""; // Element 아니면 스킵 (comment 등)
|
|
89
|
+
const tag = String(node.nodeName ?? "").toUpperCase();
|
|
90
|
+
if (SKIP_TAGS.has(tag))
|
|
91
|
+
return "";
|
|
92
|
+
if (tag === "COVER-TITLE" || tag === "DOCUMENT-NAME") {
|
|
93
|
+
const t = inlineText(node);
|
|
94
|
+
return t ? `\n# ${t}\n\n` : "";
|
|
95
|
+
}
|
|
96
|
+
if (tag === "TITLE") {
|
|
97
|
+
const level = Math.min(6, Math.max(2, titleDepth));
|
|
98
|
+
const t = inlineText(node);
|
|
99
|
+
return t ? `\n${"#".repeat(level)} ${t}\n\n` : "";
|
|
100
|
+
}
|
|
101
|
+
if (SECTION_DEPTH[tag] !== undefined) {
|
|
102
|
+
return renderChildren(node, SECTION_DEPTH[tag]);
|
|
103
|
+
}
|
|
104
|
+
if (tag === "P") {
|
|
105
|
+
const t = inlineText(node);
|
|
106
|
+
return t ? `${t}\n\n` : "";
|
|
107
|
+
}
|
|
108
|
+
if (tag === "PGBRK") {
|
|
109
|
+
return "\n---\n\n";
|
|
110
|
+
}
|
|
111
|
+
if (tag === "TABLE") {
|
|
112
|
+
return renderTable(node) + "\n";
|
|
113
|
+
}
|
|
114
|
+
if (tag === "TABLE-GROUP") {
|
|
115
|
+
return renderChildren(node, titleDepth);
|
|
116
|
+
}
|
|
117
|
+
if (tag === "BR") {
|
|
118
|
+
return "\n";
|
|
119
|
+
}
|
|
120
|
+
return renderChildren(node, titleDepth);
|
|
121
|
+
}
|
|
122
|
+
function renderChildren(el, titleDepth) {
|
|
123
|
+
let out = "";
|
|
124
|
+
const children = el.childNodes;
|
|
125
|
+
if (!children)
|
|
126
|
+
return "";
|
|
127
|
+
for (let i = 0; i < children.length; i++) {
|
|
128
|
+
out += convertNode(children[i], titleDepth);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function inlineText(el) {
|
|
133
|
+
return String(el?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
134
|
+
}
|
|
135
|
+
function renderTable(table) {
|
|
136
|
+
const trs = collectByTag(table, "TR");
|
|
137
|
+
if (!trs.length)
|
|
138
|
+
return "";
|
|
139
|
+
const rowTexts = [];
|
|
140
|
+
let headerSize = 0;
|
|
141
|
+
for (const tr of trs) {
|
|
142
|
+
const kids = tr.childNodes;
|
|
143
|
+
if (!kids)
|
|
144
|
+
continue;
|
|
145
|
+
const cells = [];
|
|
146
|
+
for (let i = 0; i < kids.length; i++) {
|
|
147
|
+
const c = kids[i];
|
|
148
|
+
if (c.nodeType === 1 && /^(TD|TH|TU)$/i.test(String(c.nodeName)))
|
|
149
|
+
cells.push(c);
|
|
150
|
+
}
|
|
151
|
+
if (!cells.length)
|
|
152
|
+
continue;
|
|
153
|
+
const row = cells.map((c) => escapeCell(inlineText(c))).join(" | ");
|
|
154
|
+
rowTexts.push(row);
|
|
155
|
+
if (headerSize === 0)
|
|
156
|
+
headerSize = cells.length;
|
|
157
|
+
}
|
|
158
|
+
if (!rowTexts.length)
|
|
159
|
+
return "";
|
|
160
|
+
const separator = Array(headerSize).fill("---").join(" | ");
|
|
161
|
+
return [
|
|
162
|
+
`\n| ${rowTexts[0]} |`,
|
|
163
|
+
`| ${separator} |`,
|
|
164
|
+
...rowTexts.slice(1).map((r) => `| ${r} |`),
|
|
165
|
+
].join("\n");
|
|
166
|
+
}
|
|
167
|
+
function collectByTag(parent, tag) {
|
|
168
|
+
const out = [];
|
|
169
|
+
const walker = (el) => {
|
|
170
|
+
const kids = el.childNodes;
|
|
171
|
+
if (!kids)
|
|
172
|
+
return;
|
|
173
|
+
for (let i = 0; i < kids.length; i++) {
|
|
174
|
+
const c = kids[i];
|
|
175
|
+
if (c.nodeType === 1) {
|
|
176
|
+
if (String(c.nodeName).toUpperCase() === tag)
|
|
177
|
+
out.push(c);
|
|
178
|
+
walker(c);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
walker(parent);
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
function escapeCell(s) {
|
|
186
|
+
return s.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
187
|
+
}
|