korea-dart-mcp 1.0.0

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 ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Ryu
4
+ Copyright (c) 2026 gwangjun
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # Korea DART MCP
2
+
3
+ OpenDART 공시 원문을 표 구조 보존 상태로 가져오는 5-tool MCP 서버입니다.
4
+
5
+ 이 서버는 데이터 파이프만 담당합니다. 분석, 가공, 비교, 투자 판단은 MCP가 하지 않고 LLM이 원문과 구조화 데이터를 직접 읽고 수행합니다.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Purpose |
10
+ |---|---|
11
+ | `find_company` | 회사명, 종목코드, corp_code로 회사를 찾고 상장 여부와 structured financial API 사용 가능 여부를 반환 |
12
+ | `search_disclosures` | 회사/기간/공시유형 A~J/프리셋 기준 공시 목록 검색. 프리셋이 없으면 지정 `kind` 전체 반환 |
13
+ | `read_disclosures` | DART `document.xml` 원문을 `markdown`, `raw`, `toc`로 읽기. Markdown은 표 구조를 보존 |
14
+ | `read_attachment` | DART 뷰어 첨부파일 목록 조회 및 HWP/PDF/DOCX/XLSX 첨부를 Markdown으로 추출 |
15
+ | `get_financials` | 상장사 structured 재무제표 조회. `detail=summary/full`, `fs_div=CFS/OFS` 지원 |
16
+
17
+ ## Design
18
+
19
+ - 회사명은 `CorpCodeResolver`가 OpenDART `corpCode.xml` 덤프를 SQLite 캐시에 적재해 자동 해결합니다.
20
+ - 공시 원문은 upstream의 DART XML parser를 사용해 heading과 table을 Markdown으로 변환합니다.
21
+ - 큰 사업보고서는 `read_disclosures(format="toc")`로 목차를 먼저 보고, `section_id`, `section_title`, `query`로 필요한 섹션/검색 excerpt만 가져오면 빠릅니다.
22
+ - 첨부파일은 DART viewer HTML에서 `dcm_no`와 download links를 찾아 `kordoc`으로 Markdown 추출합니다.
23
+ - 비상장 회사의 재무제표는 `get_financials`가 아니라 `search_disclosures`와 `read_disclosures`/`read_attachment`로 감사보고서 원문을 읽는 흐름입니다.
24
+ - XBRL/정기보고서/지분/이벤트/리스크/퀄리티 같은 합성·분석 wrapper는 노출하지 않습니다.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ npm install
30
+ npm run build
31
+ ```
32
+
33
+ ## Run
34
+
35
+ `DART_API_KEY`가 필요합니다.
36
+
37
+ ```bash
38
+ $env:DART_API_KEY="YOUR_OPEN_DART_KEY"
39
+ npm start
40
+ ```
41
+
42
+ MCP 클라이언트에서는 `build/index.js`를 stdio 서버로 등록하세요.
43
+
44
+ ## Notes
45
+
46
+ - OpenDART API key: https://opendart.fss.or.kr/
47
+ - OpenDART 일일 호출 한도는 키 기준 20,000건입니다.
48
+ - corp code 캐시 기본 경로는 `~/.korea-dart-mcp/corp_code.sqlite`입니다.
49
+
50
+ ## Credits
51
+
52
+ 원작자 [chrisryugj/korean-dart-mcp](https://github.com/chrisryugj/korean-dart-mcp)를 fork해 이어받았습니다. (MIT License)
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
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 korea-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 korea-dart-mcp setup`);
26
+ process.exit(1);
27
+ }
28
+ const server = createServer({ apiKey, cacheDir: process.env.DART_CACHE_DIR });
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
+ /** 캐시 디렉터리 (기본: ~/.korea-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,239 @@
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(), ".korea-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: true });
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
+ // 파일 삭제는 best-effort. 다른 프로세스가 파일을 잠그고 있어도(라이브 MCP + 스모크 동시 실행 등)
203
+ // 아래 CREATE TABLE IF NOT EXISTS + DELETE FROM 조합으로 재빌드가 안전하게 동작한다.
204
+ if (existsSync(path)) {
205
+ try {
206
+ unlinkSync(path);
207
+ }
208
+ catch {
209
+ /* ignore — 잠겨 삭제 못 해도 아래 DELETE FROM 으로 초기화됨 */
210
+ }
211
+ }
212
+ const db = new Database(path);
213
+ db.pragma("journal_mode = WAL");
214
+ db.exec(`
215
+ CREATE TABLE IF NOT EXISTS corps (
216
+ corp_code TEXT PRIMARY KEY,
217
+ corp_name TEXT NOT NULL,
218
+ corp_eng_name TEXT,
219
+ stock_code TEXT,
220
+ modify_date TEXT
221
+ );
222
+ CREATE INDEX IF NOT EXISTS idx_corps_name ON corps(corp_name);
223
+ CREATE INDEX IF NOT EXISTS idx_corps_stock ON corps(stock_code) WHERE stock_code IS NOT NULL AND stock_code != '';
224
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
225
+ `);
226
+ // 기존 파일이 살아남은 경우(삭제 실패 등) 이전 데이터를 비우고 새로 적재한다.
227
+ db.exec("DELETE FROM corps; DELETE FROM meta;");
228
+ const insert = db.prepare(`INSERT OR REPLACE INTO corps (corp_code, corp_name, corp_eng_name, stock_code, modify_date)
229
+ VALUES (?, ?, ?, ?, ?)`);
230
+ const tx = db.transaction((items) => {
231
+ for (const r of items) {
232
+ insert.run(r.corp_code, r.corp_name, r.corp_eng_name ?? null, r.stock_code ?? null, r.modify_date ?? null);
233
+ }
234
+ });
235
+ tx(records);
236
+ db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('updated_at', ?)").run(String(Date.now()));
237
+ db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('count', ?)").run(String(records.length));
238
+ return db;
239
+ }
@@ -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,28 @@
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, TE → 표 (단순=마크다운, 병합 셀=HTML)
13
+ * P → 단락
14
+ * PGBRK → 수평선
15
+ * A → 인라인 텍스트 (href 없음)
16
+ * IMAGE/IMG → 제거
17
+ * SUMMARY/EXTRACTION → 메타 제거
18
+ * COLGROUP/COL → 제거 (스타일 전용)
19
+ * 나머지 (SPAN, COMPANY-NAME 등) → 텍스트만 유지
20
+ *
21
+ * 표 하이브리드 렌더링: 병합 셀(COLSPAN>1 또는 ROWSPAN>1)이 하나라도 있는 표는
22
+ * 열 의미·세로 병합 구조가 마크다운에서 깨지므로 HTML(<table>, rowspan/colspan)로
23
+ * 출력한다. 병합 없는 단순 표는 기존 마크다운 표 그대로 유지한다.
24
+ *
25
+ * 타입 주석: 본 프로젝트 tsconfig 에 DOM lib 이 없어 xmldom 의 Node/Element 전역이 없다.
26
+ * duck-typing 으로 any 처리.
27
+ */
28
+ export declare function dartXmlToMarkdown(xml: string): string;