@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
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XBRL 파서 — DART instance document 에서 주요 재무제표(BS/IS/CF) 추출.
|
|
3
|
+
*
|
|
4
|
+
* 설계:
|
|
5
|
+
* - v0.8: whitelist 기반 (핵심 50 태그 내외). presentation/calculation linkbase 무시.
|
|
6
|
+
* - v0.9: "full" 모드 추가 — presentation linkbase 기반 계층/순서, calculation
|
|
7
|
+
* linkbase 기반 합산 검증. 업종별 택소노미에 자동 대응 (taxonomy에서 직접 추출).
|
|
8
|
+
* - 라벨: 한국어(lab-ko.xml) primary role 만 사용, 없으면 tag 로 폴백.
|
|
9
|
+
* - 재무제표 본체 facts: segment 가 ConsolidatedMember/SeparateMember "만" 있는
|
|
10
|
+
* 단순 context (추가 axis 를 가진 주석 facts 는 제외).
|
|
11
|
+
* - 기간: context id prefix 로 판별 (CFY=current, PFY=prior, BPFY=before-prior).
|
|
12
|
+
* - 단위: 원화(KRW) 그대로. decimals 는 표시 지침이라 별도 스케일 변환 없음.
|
|
13
|
+
*
|
|
14
|
+
* DART role 코드:
|
|
15
|
+
* D210=BS, D310=IS, D410=CI, D520=SE(자본변동), D610=CF.
|
|
16
|
+
* 접미 00=연결, 05=별도. D8xxxxx 는 주석/공시.
|
|
17
|
+
*/
|
|
18
|
+
import { DOMParser } from "@xmldom/xmldom";
|
|
19
|
+
import { safeUnzipToMemory } from "../utils/safe-zip.js";
|
|
20
|
+
// 한국 상장사 XBRL 주요 태그 whitelist — 재무제표 본체만. 순서가 표 출력 순서.
|
|
21
|
+
// IFRS-Full 과 K-IFRS(dart) 태그를 둘 다 등록해 엔티티별 사용 차이 흡수.
|
|
22
|
+
export const BS_TAGS = [
|
|
23
|
+
"ifrs-full:Assets",
|
|
24
|
+
"ifrs-full:CurrentAssets",
|
|
25
|
+
"ifrs-full:CashAndCashEquivalents",
|
|
26
|
+
"ifrs-full:TradeAndOtherCurrentReceivables",
|
|
27
|
+
"ifrs-full:Inventories",
|
|
28
|
+
"ifrs-full:NoncurrentAssets",
|
|
29
|
+
"ifrs-full:PropertyPlantAndEquipment",
|
|
30
|
+
"ifrs-full:IntangibleAssetsOtherThanGoodwill",
|
|
31
|
+
"ifrs-full:Goodwill",
|
|
32
|
+
"ifrs-full:Liabilities",
|
|
33
|
+
"ifrs-full:CurrentLiabilities",
|
|
34
|
+
"ifrs-full:TradeAndOtherCurrentPayables",
|
|
35
|
+
"ifrs-full:NoncurrentLiabilities",
|
|
36
|
+
"ifrs-full:LongtermBorrowings",
|
|
37
|
+
"ifrs-full:Equity",
|
|
38
|
+
"ifrs-full:EquityAttributableToOwnersOfParent",
|
|
39
|
+
"ifrs-full:IssuedCapital",
|
|
40
|
+
"dart:IssuedCapitalOfCommonStock",
|
|
41
|
+
"dart:IssuedCapitalOfPreferredStock",
|
|
42
|
+
"ifrs-full:RetainedEarnings",
|
|
43
|
+
"ifrs-full:NoncontrollingInterests",
|
|
44
|
+
];
|
|
45
|
+
export const IS_TAGS = [
|
|
46
|
+
"ifrs-full:Revenue",
|
|
47
|
+
"ifrs-full:CostOfSales",
|
|
48
|
+
"ifrs-full:GrossProfit",
|
|
49
|
+
"ifrs-full:DistributionCosts",
|
|
50
|
+
"ifrs-full:AdministrativeExpense",
|
|
51
|
+
"dart:OperatingIncomeLoss",
|
|
52
|
+
"ifrs-full:ProfitLossFromOperatingActivities",
|
|
53
|
+
"ifrs-full:FinanceIncome",
|
|
54
|
+
"ifrs-full:FinanceCosts",
|
|
55
|
+
"ifrs-full:ProfitLossBeforeTax",
|
|
56
|
+
"ifrs-full:IncomeTaxExpenseContinuingOperations",
|
|
57
|
+
"ifrs-full:ProfitLoss",
|
|
58
|
+
"ifrs-full:ProfitLossAttributableToOwnersOfParent",
|
|
59
|
+
"ifrs-full:ProfitLossAttributableToNoncontrollingInterests",
|
|
60
|
+
"ifrs-full:BasicEarningsLossPerShare",
|
|
61
|
+
"ifrs-full:DilutedEarningsLossPerShare",
|
|
62
|
+
];
|
|
63
|
+
export const CF_TAGS = [
|
|
64
|
+
"ifrs-full:CashFlowsFromUsedInOperatingActivities",
|
|
65
|
+
"ifrs-full:CashFlowsFromUsedInInvestingActivities",
|
|
66
|
+
"ifrs-full:CashFlowsFromUsedInFinancingActivities",
|
|
67
|
+
"ifrs-full:IncreaseDecreaseInCashAndCashEquivalents",
|
|
68
|
+
"ifrs-full:CashAndCashEquivalents",
|
|
69
|
+
"dart:CashAndCashEquivalentsAtBeginningOfPeriodCf",
|
|
70
|
+
"dart:CashAndCashEquivalentsAtEndOfPeriodCf",
|
|
71
|
+
];
|
|
72
|
+
// K-IFRS 태그 한국어 대안 라벨 (instance 라벨이 영문이거나 없을 때 폴백)
|
|
73
|
+
const KO_FALLBACK = {
|
|
74
|
+
"ifrs-full:Assets": "자산총계",
|
|
75
|
+
"ifrs-full:CurrentAssets": "유동자산",
|
|
76
|
+
"ifrs-full:NoncurrentAssets": "비유동자산",
|
|
77
|
+
"ifrs-full:CashAndCashEquivalents": "현금및현금성자산",
|
|
78
|
+
"ifrs-full:Inventories": "재고자산",
|
|
79
|
+
"ifrs-full:PropertyPlantAndEquipment": "유형자산",
|
|
80
|
+
"ifrs-full:Goodwill": "영업권",
|
|
81
|
+
"ifrs-full:Liabilities": "부채총계",
|
|
82
|
+
"ifrs-full:CurrentLiabilities": "유동부채",
|
|
83
|
+
"ifrs-full:NoncurrentLiabilities": "비유동부채",
|
|
84
|
+
"ifrs-full:LongtermBorrowings": "장기차입금",
|
|
85
|
+
"ifrs-full:Equity": "자본총계",
|
|
86
|
+
"ifrs-full:EquityAttributableToOwnersOfParent": "지배기업 소유주 지분",
|
|
87
|
+
"ifrs-full:IssuedCapital": "자본금",
|
|
88
|
+
"ifrs-full:RetainedEarnings": "이익잉여금",
|
|
89
|
+
"ifrs-full:NoncontrollingInterests": "비지배지분",
|
|
90
|
+
"ifrs-full:Revenue": "매출액",
|
|
91
|
+
"ifrs-full:CostOfSales": "매출원가",
|
|
92
|
+
"ifrs-full:GrossProfit": "매출총이익",
|
|
93
|
+
"ifrs-full:ProfitLossFromOperatingActivities": "영업이익",
|
|
94
|
+
"dart:OperatingIncomeLoss": "영업이익",
|
|
95
|
+
"ifrs-full:ProfitLossBeforeTax": "법인세차감전이익",
|
|
96
|
+
"ifrs-full:IncomeTaxExpenseContinuingOperations": "법인세비용",
|
|
97
|
+
"ifrs-full:ProfitLoss": "당기순이익",
|
|
98
|
+
"ifrs-full:ProfitLossAttributableToOwnersOfParent": "지배기업 소유주 귀속 순이익",
|
|
99
|
+
"ifrs-full:BasicEarningsLossPerShare": "기본주당이익",
|
|
100
|
+
"ifrs-full:CashFlowsFromUsedInOperatingActivities": "영업활동 현금흐름",
|
|
101
|
+
"ifrs-full:CashFlowsFromUsedInInvestingActivities": "투자활동 현금흐름",
|
|
102
|
+
"ifrs-full:CashFlowsFromUsedInFinancingActivities": "재무활동 현금흐름",
|
|
103
|
+
"ifrs-full:IncreaseDecreaseInCashAndCashEquivalents": "현금 증감",
|
|
104
|
+
};
|
|
105
|
+
// ── ZIP extraction ─────────────────────────────────────
|
|
106
|
+
export async function extractXbrlFilesFromZip(zipBuf) {
|
|
107
|
+
const entries = await safeUnzipToMemory(zipBuf, {
|
|
108
|
+
filter: (name) => /\.(xbrl|xml|xsd)$/i.test(name),
|
|
109
|
+
});
|
|
110
|
+
const out = new Map();
|
|
111
|
+
for (const e of entries)
|
|
112
|
+
out.set(e.name, e.data.toString("utf8"));
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
// ── Instance parsing ───────────────────────────────────
|
|
116
|
+
const CONTEXT_ID_PREFIX_RE = /^(BPFY|PFY|CFY)\d+([ed])FY(?:_(.+))?$/;
|
|
117
|
+
/** context id 에서 period bucket + pointType 추정. */
|
|
118
|
+
function classifyContextId(id) {
|
|
119
|
+
const m = CONTEXT_ID_PREFIX_RE.exec(id);
|
|
120
|
+
if (!m)
|
|
121
|
+
return { periodBucket: null, pointType: "duration", axisTail: null };
|
|
122
|
+
const [, prefix, type, tail] = m;
|
|
123
|
+
const periodBucket = prefix === "CFY" ? "current" : prefix === "PFY" ? "prior" : "priorPrior";
|
|
124
|
+
return {
|
|
125
|
+
periodBucket,
|
|
126
|
+
pointType: type === "e" ? "instant" : "duration",
|
|
127
|
+
axisTail: tail ?? null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** axisTail 이 ConsolidatedMember/SeparateMember "만" 있으면 재무제표 본체 context. */
|
|
131
|
+
function classifyConsolidation(axisTail) {
|
|
132
|
+
if (!axisTail)
|
|
133
|
+
return { consolidated: null, dimensionCount: 0 };
|
|
134
|
+
const parts = axisTail.split("_");
|
|
135
|
+
const joined = axisTail;
|
|
136
|
+
const hasConsolidated = /ifrs-full_ConsolidatedMember/.test(joined);
|
|
137
|
+
const hasSeparate = /ifrs-full_SeparateMember/.test(joined);
|
|
138
|
+
const axisMatches = axisTail.match(/Axis/g) ?? [];
|
|
139
|
+
const dimensionCount = axisMatches.length;
|
|
140
|
+
if (hasConsolidated)
|
|
141
|
+
return { consolidated: true, dimensionCount };
|
|
142
|
+
if (hasSeparate)
|
|
143
|
+
return { consolidated: false, dimensionCount };
|
|
144
|
+
return { consolidated: null, dimensionCount };
|
|
145
|
+
}
|
|
146
|
+
export function parseInstance(xml) {
|
|
147
|
+
const errors = [];
|
|
148
|
+
const doc = new DOMParser({
|
|
149
|
+
onError: (_level, msg) => errors.push(msg),
|
|
150
|
+
}).parseFromString(xml, "text/xml");
|
|
151
|
+
const contexts = new Map();
|
|
152
|
+
let entityId = null;
|
|
153
|
+
// context 추출
|
|
154
|
+
const ctxEls = doc.getElementsByTagName("xbrli:context");
|
|
155
|
+
for (let i = 0; i < ctxEls.length; i++) {
|
|
156
|
+
const el = ctxEls[i];
|
|
157
|
+
const id = el.getAttribute("id");
|
|
158
|
+
if (!id)
|
|
159
|
+
continue;
|
|
160
|
+
if (!entityId) {
|
|
161
|
+
const idf = el.getElementsByTagName("xbrli:identifier")[0];
|
|
162
|
+
if (idf)
|
|
163
|
+
entityId = (idf.textContent ?? "").trim();
|
|
164
|
+
}
|
|
165
|
+
const periodEl = el.getElementsByTagName("xbrli:period")[0];
|
|
166
|
+
if (!periodEl)
|
|
167
|
+
continue;
|
|
168
|
+
const instantEl = periodEl.getElementsByTagName("xbrli:instant")[0];
|
|
169
|
+
const startEl = periodEl.getElementsByTagName("xbrli:startDate")[0];
|
|
170
|
+
const endEl = periodEl.getElementsByTagName("xbrli:endDate")[0];
|
|
171
|
+
const periodType = instantEl ? "instant" : "duration";
|
|
172
|
+
const classified = classifyContextId(id);
|
|
173
|
+
const cons = classifyConsolidation(classified.axisTail);
|
|
174
|
+
contexts.set(id, {
|
|
175
|
+
id,
|
|
176
|
+
consolidated: cons.consolidated,
|
|
177
|
+
dimensionCount: cons.dimensionCount,
|
|
178
|
+
periodType,
|
|
179
|
+
instant: instantEl?.textContent?.trim(),
|
|
180
|
+
startDate: startEl?.textContent?.trim(),
|
|
181
|
+
endDate: endEl?.textContent?.trim(),
|
|
182
|
+
periodBucket: classified.periodBucket,
|
|
183
|
+
pointType: classified.pointType,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// fact 추출 — xbrli:xbrl 루트 직접 자식 중 context/unit 이 아닌 것들
|
|
187
|
+
const facts = [];
|
|
188
|
+
const root = doc.documentElement;
|
|
189
|
+
const children = root?.childNodes;
|
|
190
|
+
if (children) {
|
|
191
|
+
for (let i = 0; i < children.length; i++) {
|
|
192
|
+
const node = children[i];
|
|
193
|
+
if (!node || node.nodeType !== 1)
|
|
194
|
+
continue; // ELEMENT_NODE
|
|
195
|
+
const el = node;
|
|
196
|
+
const name = el.nodeName ?? el.tagName ?? "";
|
|
197
|
+
if (!name ||
|
|
198
|
+
name.startsWith("xbrli:") ||
|
|
199
|
+
name.startsWith("link:") ||
|
|
200
|
+
name === "xbrli:unit" ||
|
|
201
|
+
name === "xbrli:context") {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const contextRef = el.getAttribute("contextRef");
|
|
205
|
+
if (!contextRef)
|
|
206
|
+
continue;
|
|
207
|
+
const value = (el.textContent ?? "").trim();
|
|
208
|
+
facts.push({
|
|
209
|
+
tag: name,
|
|
210
|
+
contextRef,
|
|
211
|
+
unitRef: el.getAttribute("unitRef") ?? undefined,
|
|
212
|
+
decimals: el.getAttribute("decimals") ?? undefined,
|
|
213
|
+
value,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { facts, contexts, entityId, errors };
|
|
218
|
+
}
|
|
219
|
+
// ── Label parsing ──────────────────────────────────────
|
|
220
|
+
const LABEL_ID_RE = /^Label_label_(.+?)(?:_ko|_en)?(?:_\d+)?$/;
|
|
221
|
+
export function parseLabels(xml) {
|
|
222
|
+
const errors = [];
|
|
223
|
+
const doc = new DOMParser({
|
|
224
|
+
onError: (_level, msg) => errors.push(msg),
|
|
225
|
+
}).parseFromString(xml, "text/xml");
|
|
226
|
+
const labels = new Map();
|
|
227
|
+
const labelEls = doc.getElementsByTagName("link:label");
|
|
228
|
+
for (let i = 0; i < labelEls.length; i++) {
|
|
229
|
+
const el = labelEls[i];
|
|
230
|
+
const role = el.getAttribute("xlink:role");
|
|
231
|
+
if (role && role !== "http://www.xbrl.org/2003/role/label")
|
|
232
|
+
continue;
|
|
233
|
+
const id = el.getAttribute("id") ?? el.getAttribute("xlink:label");
|
|
234
|
+
if (!id)
|
|
235
|
+
continue;
|
|
236
|
+
const m = LABEL_ID_RE.exec(id);
|
|
237
|
+
if (!m)
|
|
238
|
+
continue;
|
|
239
|
+
// m[1] 예: "ifrs-full_Assets" → "ifrs-full:Assets"
|
|
240
|
+
const rawTag = m[1];
|
|
241
|
+
const idx = rawTag.indexOf("_");
|
|
242
|
+
if (idx < 0)
|
|
243
|
+
continue;
|
|
244
|
+
const tag = `${rawTag.substring(0, idx)}:${rawTag.substring(idx + 1)}`;
|
|
245
|
+
const text = (el.textContent ?? "").trim();
|
|
246
|
+
if (!text)
|
|
247
|
+
continue;
|
|
248
|
+
if (!labels.has(tag))
|
|
249
|
+
labels.set(tag, text); // 첫 primary 만 채택
|
|
250
|
+
}
|
|
251
|
+
return labels;
|
|
252
|
+
}
|
|
253
|
+
// ── Linkbase (presentation / calculation) ──────────────
|
|
254
|
+
// DART role URI 패턴:
|
|
255
|
+
// 일반: role-D{major}{3digits}{suffix2} 예: role-D210000 → major=2(BS), suffix=00(연결)
|
|
256
|
+
// 금융/보험: role-DX{major}{3digits}{suffix2} 예: role-DX220000 → major=2(BS)
|
|
257
|
+
// major: 2=BS, 3=IS, 4=CI, 5=SE, 6=CF. suffix: 00=consolidated, 05=separate.
|
|
258
|
+
const ROLE_RE = /role-DX?(\d)\d{3}(\d\d)/;
|
|
259
|
+
/** role URI → {statementType, fs_div}. 주석(D8xx)·자본변동(D52)은 제외 시 null 반환됨. */
|
|
260
|
+
export function classifyRole(roleUri) {
|
|
261
|
+
const m = ROLE_RE.exec(roleUri);
|
|
262
|
+
if (!m)
|
|
263
|
+
return { roleUri, statementType: null, fs_div: null };
|
|
264
|
+
const [, major, suffix] = m;
|
|
265
|
+
const statementType = major === "2" ? "BS" :
|
|
266
|
+
major === "3" ? "IS" :
|
|
267
|
+
major === "4" ? "CI" :
|
|
268
|
+
major === "5" ? "SE" :
|
|
269
|
+
major === "6" ? "CF" : null;
|
|
270
|
+
const fs_div = suffix === "00" ? "consolidated" :
|
|
271
|
+
suffix === "05" ? "separate" : null;
|
|
272
|
+
return { roleUri, statementType, fs_div };
|
|
273
|
+
}
|
|
274
|
+
/** link:loc 엘리먼트들에서 xlink:label → tag 매핑. href="...#ifrs-full_Assets" → "ifrs-full:Assets". */
|
|
275
|
+
function parseLocsFromLink(linkEl) {
|
|
276
|
+
const out = new Map();
|
|
277
|
+
const locs = linkEl.getElementsByTagName("link:loc");
|
|
278
|
+
for (let i = 0; i < locs.length; i++) {
|
|
279
|
+
const loc = locs[i];
|
|
280
|
+
const label = loc.getAttribute("xlink:label");
|
|
281
|
+
const href = loc.getAttribute("xlink:href") ?? "";
|
|
282
|
+
if (!label)
|
|
283
|
+
continue;
|
|
284
|
+
const hashIdx = href.lastIndexOf("#");
|
|
285
|
+
if (hashIdx < 0)
|
|
286
|
+
continue;
|
|
287
|
+
const frag = href.substring(hashIdx + 1);
|
|
288
|
+
const us = frag.indexOf("_");
|
|
289
|
+
if (us < 0)
|
|
290
|
+
continue;
|
|
291
|
+
const tag = `${frag.substring(0, us)}:${frag.substring(us + 1)}`;
|
|
292
|
+
out.set(label, tag);
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
export function parsePresentationLinkbase(xml) {
|
|
297
|
+
const errors = [];
|
|
298
|
+
const doc = new DOMParser({
|
|
299
|
+
onError: (_l, m) => errors.push(m),
|
|
300
|
+
}).parseFromString(xml, "text/xml");
|
|
301
|
+
const out = [];
|
|
302
|
+
const links = doc.getElementsByTagName("link:presentationLink");
|
|
303
|
+
for (let i = 0; i < links.length; i++) {
|
|
304
|
+
const link = links[i];
|
|
305
|
+
const role = link.getAttribute("xlink:role");
|
|
306
|
+
if (!role)
|
|
307
|
+
continue;
|
|
308
|
+
const info = classifyRole(role);
|
|
309
|
+
if (!info.statementType || !info.fs_div)
|
|
310
|
+
continue;
|
|
311
|
+
const locs = parseLocsFromLink(link);
|
|
312
|
+
const arcs = link.getElementsByTagName("link:presentationArc");
|
|
313
|
+
const children = new Map();
|
|
314
|
+
const isChild = new Set();
|
|
315
|
+
for (let j = 0; j < arcs.length; j++) {
|
|
316
|
+
const arc = arcs[j];
|
|
317
|
+
const arcrole = arc.getAttribute("xlink:arcrole") ?? "";
|
|
318
|
+
if (!/parent-child$/.test(arcrole))
|
|
319
|
+
continue;
|
|
320
|
+
const from = arc.getAttribute("xlink:from");
|
|
321
|
+
const to = arc.getAttribute("xlink:to");
|
|
322
|
+
const order = parseFloat(arc.getAttribute("order") ?? "1");
|
|
323
|
+
if (!from || !to)
|
|
324
|
+
continue;
|
|
325
|
+
let list = children.get(from);
|
|
326
|
+
if (!list) {
|
|
327
|
+
list = [];
|
|
328
|
+
children.set(from, list);
|
|
329
|
+
}
|
|
330
|
+
list.push({ label: to, order });
|
|
331
|
+
isChild.add(to);
|
|
332
|
+
}
|
|
333
|
+
const allFroms = new Set(children.keys());
|
|
334
|
+
const roots = [...allFroms].filter((f) => !isChild.has(f));
|
|
335
|
+
const nodes = [];
|
|
336
|
+
const visited = new Set();
|
|
337
|
+
const MAX_DEPTH = 100; // 비정상 taxonomy 재귀 방어 (정상 트리는 ≤10)
|
|
338
|
+
const visit = (label, parentTag, depth, order) => {
|
|
339
|
+
if (visited.has(label) || depth > MAX_DEPTH)
|
|
340
|
+
return;
|
|
341
|
+
visited.add(label);
|
|
342
|
+
const tag = locs.get(label);
|
|
343
|
+
if (tag)
|
|
344
|
+
nodes.push({ tag, depth, order, parent: parentTag });
|
|
345
|
+
const ch = children.get(label);
|
|
346
|
+
if (!ch)
|
|
347
|
+
return;
|
|
348
|
+
ch.sort((a, b) => a.order - b.order);
|
|
349
|
+
for (const c of ch)
|
|
350
|
+
visit(c.label, tag ?? parentTag, depth + (tag ? 1 : 0), c.order);
|
|
351
|
+
};
|
|
352
|
+
for (const r of roots)
|
|
353
|
+
visit(r, null, 0, 0);
|
|
354
|
+
out.push({ info, nodes });
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
export function parseCalculationLinkbase(xml) {
|
|
359
|
+
const errors = [];
|
|
360
|
+
const doc = new DOMParser({
|
|
361
|
+
onError: (_l, m) => errors.push(m),
|
|
362
|
+
}).parseFromString(xml, "text/xml");
|
|
363
|
+
const out = [];
|
|
364
|
+
const links = doc.getElementsByTagName("link:calculationLink");
|
|
365
|
+
for (let i = 0; i < links.length; i++) {
|
|
366
|
+
const link = links[i];
|
|
367
|
+
const role = link.getAttribute("xlink:role");
|
|
368
|
+
if (!role)
|
|
369
|
+
continue;
|
|
370
|
+
const info = classifyRole(role);
|
|
371
|
+
if (!info.statementType || !info.fs_div)
|
|
372
|
+
continue;
|
|
373
|
+
const locs = parseLocsFromLink(link);
|
|
374
|
+
const arcs = link.getElementsByTagName("link:calculationArc");
|
|
375
|
+
const relations = [];
|
|
376
|
+
for (let j = 0; j < arcs.length; j++) {
|
|
377
|
+
const arc = arcs[j];
|
|
378
|
+
const arcrole = arc.getAttribute("xlink:arcrole") ?? "";
|
|
379
|
+
if (!/summation-item$/.test(arcrole))
|
|
380
|
+
continue;
|
|
381
|
+
const from = arc.getAttribute("xlink:from");
|
|
382
|
+
const to = arc.getAttribute("xlink:to");
|
|
383
|
+
const weight = parseFloat(arc.getAttribute("weight") ?? "1");
|
|
384
|
+
const order = parseFloat(arc.getAttribute("order") ?? "1");
|
|
385
|
+
const parent = locs.get(from);
|
|
386
|
+
const child = locs.get(to);
|
|
387
|
+
if (!parent || !child)
|
|
388
|
+
continue;
|
|
389
|
+
relations.push({ parent, child, weight, order });
|
|
390
|
+
}
|
|
391
|
+
if (relations.length > 0)
|
|
392
|
+
out.push({ info, relations });
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
// ── Orchestration ──────────────────────────────────────
|
|
397
|
+
export async function parseXbrlZip(zipBuf, opts = {}) {
|
|
398
|
+
const files = await extractXbrlFilesFromZip(zipBuf);
|
|
399
|
+
let instanceXml = null;
|
|
400
|
+
let labelXml = null;
|
|
401
|
+
let preXml = null;
|
|
402
|
+
let calXml = null;
|
|
403
|
+
for (const [name, content] of files) {
|
|
404
|
+
if (name.endsWith(".xbrl"))
|
|
405
|
+
instanceXml = content;
|
|
406
|
+
else if (/_lab-ko\.xml$/i.test(name))
|
|
407
|
+
labelXml = content;
|
|
408
|
+
else if (opts.loadTaxonomy && /_pre\.xml$/i.test(name))
|
|
409
|
+
preXml = content;
|
|
410
|
+
else if (opts.loadTaxonomy && /_cal\.xml$/i.test(name))
|
|
411
|
+
calXml = content;
|
|
412
|
+
}
|
|
413
|
+
if (!instanceXml)
|
|
414
|
+
throw new Error("XBRL instance document (.xbrl) not found in ZIP");
|
|
415
|
+
const { facts, contexts, entityId, errors } = parseInstance(instanceXml);
|
|
416
|
+
const labels = labelXml ? parseLabels(labelXml) : new Map();
|
|
417
|
+
let taxonomy;
|
|
418
|
+
if (opts.loadTaxonomy) {
|
|
419
|
+
taxonomy = {
|
|
420
|
+
presentations: preXml ? parsePresentationLinkbase(preXml) : [],
|
|
421
|
+
calculations: calXml ? parseCalculationLinkbase(calXml) : [],
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
// instance 파싱에서 DOM 에러가 많은데 facts 가 비어있으면 parseWarnings 노출
|
|
425
|
+
// (빈 결과 = 실제 데이터 없음 vs 파싱 실패 구분용)
|
|
426
|
+
const parseWarnings = errors.length > 0 && facts.length === 0
|
|
427
|
+
? [`XBRL instance 파싱 중 ${errors.length}건 에러 발생, fact 0건 추출됨. 샘플: ${errors.slice(0, 3).join(" | ")}`]
|
|
428
|
+
: undefined;
|
|
429
|
+
return { facts, contexts, labels, entityId, taxonomy, parseWarnings };
|
|
430
|
+
}
|
|
431
|
+
// ── Statement building ─────────────────────────────────
|
|
432
|
+
function resolveLabel(tag, labels) {
|
|
433
|
+
const fromFile = labels.get(tag);
|
|
434
|
+
if (fromFile)
|
|
435
|
+
return fromFile;
|
|
436
|
+
const fallback = KO_FALLBACK[tag];
|
|
437
|
+
if (fallback)
|
|
438
|
+
return fallback;
|
|
439
|
+
return tag;
|
|
440
|
+
}
|
|
441
|
+
/** 재무제표 본체 fact 만 골라 bucket 별로 인덱싱. */
|
|
442
|
+
function indexCoreFacts(data, fs_div) {
|
|
443
|
+
const want = fs_div === "consolidated";
|
|
444
|
+
const idx = new Map();
|
|
445
|
+
for (const fact of data.facts) {
|
|
446
|
+
const ctx = data.contexts.get(fact.contextRef);
|
|
447
|
+
if (!ctx)
|
|
448
|
+
continue;
|
|
449
|
+
if (ctx.dimensionCount > 1)
|
|
450
|
+
continue; // 주석 제외
|
|
451
|
+
if (ctx.consolidated === null)
|
|
452
|
+
continue; // segment 없는 것 제외
|
|
453
|
+
if (ctx.consolidated !== want)
|
|
454
|
+
continue;
|
|
455
|
+
if (!ctx.periodBucket)
|
|
456
|
+
continue;
|
|
457
|
+
const num = Number(fact.value.replace(/,/g, ""));
|
|
458
|
+
if (!Number.isFinite(num))
|
|
459
|
+
continue;
|
|
460
|
+
const row = idx.get(fact.tag) ?? { current: null, prior: null, priorPrior: null };
|
|
461
|
+
row[ctx.periodBucket] = num;
|
|
462
|
+
idx.set(fact.tag, row);
|
|
463
|
+
}
|
|
464
|
+
return idx;
|
|
465
|
+
}
|
|
466
|
+
function findPeriods(data, fs_div) {
|
|
467
|
+
const want = fs_div === "consolidated";
|
|
468
|
+
const byBucket = { current: null, prior: null, priorPrior: null };
|
|
469
|
+
for (const ctx of data.contexts.values()) {
|
|
470
|
+
if (ctx.dimensionCount > 1)
|
|
471
|
+
continue;
|
|
472
|
+
if (ctx.consolidated === null || ctx.consolidated !== want)
|
|
473
|
+
continue;
|
|
474
|
+
if (!ctx.periodBucket)
|
|
475
|
+
continue;
|
|
476
|
+
if (byBucket[ctx.periodBucket])
|
|
477
|
+
continue;
|
|
478
|
+
byBucket[ctx.periodBucket] = {
|
|
479
|
+
end: ctx.endDate ?? ctx.instant,
|
|
480
|
+
start: ctx.startDate,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return byBucket;
|
|
484
|
+
}
|
|
485
|
+
export function buildStatements(data, opts) {
|
|
486
|
+
const idx = indexCoreFacts(data, opts.fs_div);
|
|
487
|
+
const periods = findPeriods(data, opts.fs_div);
|
|
488
|
+
function makeTable(tags) {
|
|
489
|
+
const rows = [];
|
|
490
|
+
for (const tag of tags) {
|
|
491
|
+
const v = idx.get(tag);
|
|
492
|
+
if (!v)
|
|
493
|
+
continue;
|
|
494
|
+
if (v.current == null && v.prior == null && v.priorPrior == null)
|
|
495
|
+
continue;
|
|
496
|
+
rows.push({
|
|
497
|
+
tag,
|
|
498
|
+
label: resolveLabel(tag, data.labels),
|
|
499
|
+
current: v.current,
|
|
500
|
+
prior: v.prior,
|
|
501
|
+
priorPrior: v.priorPrior,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return { rows };
|
|
505
|
+
}
|
|
506
|
+
const statements = {};
|
|
507
|
+
if (opts.sections.includes("BS"))
|
|
508
|
+
statements.BS = makeTable(BS_TAGS);
|
|
509
|
+
if (opts.sections.includes("IS"))
|
|
510
|
+
statements.IS = makeTable(IS_TAGS);
|
|
511
|
+
if (opts.sections.includes("CF"))
|
|
512
|
+
statements.CF = makeTable(CF_TAGS);
|
|
513
|
+
return { periods, fs_div: opts.fs_div, mode: "whitelist", statements };
|
|
514
|
+
}
|
|
515
|
+
// ── Full (taxonomy-driven) statement builder ───────────
|
|
516
|
+
const SECTION_ROLE = {
|
|
517
|
+
BS: ["BS"],
|
|
518
|
+
IS: ["IS", "CI"], // IS 없으면 CI(포괄손익) 로 폴백
|
|
519
|
+
CF: ["CF"],
|
|
520
|
+
};
|
|
521
|
+
/** presentation linkbase 트리를 기반으로 full 재무제표 구성. 원본 항목 순서/계층 보존. */
|
|
522
|
+
export function buildStatementsFull(data, opts) {
|
|
523
|
+
if (!data.taxonomy) {
|
|
524
|
+
throw new Error("buildStatementsFull requires taxonomy — parse with loadTaxonomy:true");
|
|
525
|
+
}
|
|
526
|
+
const idx = indexCoreFacts(data, opts.fs_div);
|
|
527
|
+
const periods = findPeriods(data, opts.fs_div);
|
|
528
|
+
function pickPresentation(sec) {
|
|
529
|
+
const wantTypes = SECTION_ROLE[sec];
|
|
530
|
+
// 우선 동일 fs_div & 첫 매칭 타입 — 여러 role 있을 수 있으니 첫 매치만.
|
|
531
|
+
for (const t of wantTypes) {
|
|
532
|
+
const hit = data.taxonomy.presentations.find((p) => p.info.statementType === t && p.info.fs_div === opts.fs_div);
|
|
533
|
+
if (hit)
|
|
534
|
+
return hit;
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
function makeTable(pres) {
|
|
539
|
+
const rows = [];
|
|
540
|
+
for (const node of pres.nodes) {
|
|
541
|
+
const v = idx.get(node.tag);
|
|
542
|
+
if (!v)
|
|
543
|
+
continue;
|
|
544
|
+
if (v.current == null && v.prior == null && v.priorPrior == null)
|
|
545
|
+
continue;
|
|
546
|
+
rows.push({
|
|
547
|
+
tag: node.tag,
|
|
548
|
+
label: resolveLabel(node.tag, data.labels),
|
|
549
|
+
depth: node.depth,
|
|
550
|
+
current: v.current,
|
|
551
|
+
prior: v.prior,
|
|
552
|
+
priorPrior: v.priorPrior,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return { rows };
|
|
556
|
+
}
|
|
557
|
+
const statements = {};
|
|
558
|
+
for (const sec of opts.sections) {
|
|
559
|
+
const pres = pickPresentation(sec);
|
|
560
|
+
if (pres)
|
|
561
|
+
statements[sec] = makeTable(pres);
|
|
562
|
+
}
|
|
563
|
+
// calculation linkbase 검증 — 5원 이상 && 0.1% 이상 차이만 보고 (부동소수 잡음 제거)
|
|
564
|
+
const validations = validateCalculations(data, idx, opts.fs_div);
|
|
565
|
+
return { periods, fs_div: opts.fs_div, mode: "full", statements, validations };
|
|
566
|
+
}
|
|
567
|
+
function validateCalculations(data, idx, fs_div) {
|
|
568
|
+
if (!data.taxonomy)
|
|
569
|
+
return [];
|
|
570
|
+
const out = [];
|
|
571
|
+
const byParent = new Map();
|
|
572
|
+
for (const rc of data.taxonomy.calculations) {
|
|
573
|
+
if (rc.info.fs_div !== fs_div)
|
|
574
|
+
continue;
|
|
575
|
+
for (const rel of rc.relations) {
|
|
576
|
+
let list = byParent.get(rel.parent);
|
|
577
|
+
if (!list) {
|
|
578
|
+
list = [];
|
|
579
|
+
byParent.set(rel.parent, list);
|
|
580
|
+
}
|
|
581
|
+
list.push(rel);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const periods = ["current", "prior", "priorPrior"];
|
|
585
|
+
for (const [parent, rels] of byParent) {
|
|
586
|
+
const pv = idx.get(parent);
|
|
587
|
+
if (!pv)
|
|
588
|
+
continue;
|
|
589
|
+
for (const period of periods) {
|
|
590
|
+
const actual = pv[period];
|
|
591
|
+
if (actual == null)
|
|
592
|
+
continue;
|
|
593
|
+
let expected = 0;
|
|
594
|
+
let missing = 0;
|
|
595
|
+
for (const r of rels) {
|
|
596
|
+
const cv = idx.get(r.child);
|
|
597
|
+
const v = cv?.[period];
|
|
598
|
+
if (v == null) {
|
|
599
|
+
missing++;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
expected += v * r.weight;
|
|
603
|
+
}
|
|
604
|
+
if (missing > 0)
|
|
605
|
+
continue; // 하위 중 일부 결측 → 검증 스킵
|
|
606
|
+
const diff = actual - expected;
|
|
607
|
+
const absActual = Math.abs(actual);
|
|
608
|
+
if (Math.abs(diff) < 5)
|
|
609
|
+
continue;
|
|
610
|
+
if (absActual > 0 && Math.abs(diff) / absActual < 0.001)
|
|
611
|
+
continue;
|
|
612
|
+
out.push({
|
|
613
|
+
parent,
|
|
614
|
+
parent_label: resolveLabel(parent, data.labels),
|
|
615
|
+
expected,
|
|
616
|
+
actual,
|
|
617
|
+
diff,
|
|
618
|
+
ratio: absActual > 0 ? diff / absActual : 0,
|
|
619
|
+
period,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
624
|
+
}
|
|
625
|
+
// ── Markdown rendering ─────────────────────────────────
|
|
626
|
+
function fmt(n) {
|
|
627
|
+
if (n == null)
|
|
628
|
+
return "-";
|
|
629
|
+
return n.toLocaleString("ko-KR");
|
|
630
|
+
}
|
|
631
|
+
function renderTable(title, table, periods) {
|
|
632
|
+
const head1 = periods.current?.end ?? "당기";
|
|
633
|
+
const head2 = periods.prior?.end ?? "전기";
|
|
634
|
+
const head3 = periods.priorPrior?.end ?? "전전기";
|
|
635
|
+
const lines = [
|
|
636
|
+
`## ${title}`,
|
|
637
|
+
"",
|
|
638
|
+
`| 계정과목 | ${head1} | ${head2} | ${head3} |`,
|
|
639
|
+
"| --- | ---: | ---: | ---: |",
|
|
640
|
+
];
|
|
641
|
+
for (const r of table.rows) {
|
|
642
|
+
const indent = r.depth && r.depth > 0 ? " ".repeat(r.depth * 2) + " " : "";
|
|
643
|
+
lines.push(`| ${indent}${r.label} | ${fmt(r.current)} | ${fmt(r.prior)} | ${fmt(r.priorPrior)} |`);
|
|
644
|
+
}
|
|
645
|
+
return lines.join("\n");
|
|
646
|
+
}
|
|
647
|
+
function renderValidations(vs) {
|
|
648
|
+
if (vs.length === 0)
|
|
649
|
+
return "## 계산 검증\n\n✅ 모든 합계 일치 (calculation linkbase 기준).";
|
|
650
|
+
const lines = ["## 계산 검증", "", `⚠️ ${vs.length}건 불일치 (0.1% 또는 5원 초과).`, ""];
|
|
651
|
+
lines.push("| 계정 | 기간 | 기재값 | 합산기대값 | 차이 | 비율 |");
|
|
652
|
+
lines.push("| --- | --- | ---: | ---: | ---: | ---: |");
|
|
653
|
+
for (const v of vs.slice(0, 20)) {
|
|
654
|
+
lines.push(`| ${v.parent_label} | ${v.period} | ${fmt(v.actual)} | ${fmt(v.expected)} | ${fmt(v.diff)} | ${(v.ratio * 100).toFixed(2)}% |`);
|
|
655
|
+
}
|
|
656
|
+
if (vs.length > 20)
|
|
657
|
+
lines.push(`| ...(+${vs.length - 20}건) | | | | | |`);
|
|
658
|
+
return lines.join("\n");
|
|
659
|
+
}
|
|
660
|
+
export function renderMarkdown(st) {
|
|
661
|
+
const parts = [];
|
|
662
|
+
const modeLabel = st.mode === "full" ? "full" : "whitelist";
|
|
663
|
+
parts.push(`# 재무제표 (${st.fs_div === "consolidated" ? "연결" : "별도"}, ${modeLabel})`, "", "※ 값 단위: 원. 표시되지 않은 계정과 모든 기간이 공시에 기재되지 않은 경우 생략.", "");
|
|
664
|
+
if (st.statements.BS)
|
|
665
|
+
parts.push(renderTable("재무상태표 (BS)", st.statements.BS, st.periods), "");
|
|
666
|
+
if (st.statements.IS)
|
|
667
|
+
parts.push(renderTable("손익계산서 (IS)", st.statements.IS, st.periods), "");
|
|
668
|
+
if (st.statements.CF)
|
|
669
|
+
parts.push(renderTable("현금흐름표 (CF)", st.statements.CF, st.periods), "");
|
|
670
|
+
if (st.validations)
|
|
671
|
+
parts.push(renderValidations(st.validations), "");
|
|
672
|
+
return parts.join("\n");
|
|
673
|
+
}
|