@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,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DART MCP 도구 레지스트리 — 15개
|
|
3
|
+
*
|
|
4
|
+
* v0.7.0 통폐합:
|
|
5
|
+
* - get_financials 가 get_full_financials 흡수 (scope: summary/full)
|
|
6
|
+
* - buffett_quality_snapshot 가 quality_compare 흡수 (corps 배열)
|
|
7
|
+
* - search_disclosures 가 list_recent_filings 흡수 (preset + 페이지 병렬화)
|
|
8
|
+
*/
|
|
9
|
+
import { resolveCorpCodeTool } from "./resolve-corp-code.js";
|
|
10
|
+
import { searchDisclosuresTool } from "./search-disclosures.js";
|
|
11
|
+
import { getCompanyTool } from "./get-company.js";
|
|
12
|
+
import { getFinancialsTool } from "./get-financials.js";
|
|
13
|
+
import { downloadDocumentTool } from "./download-document.js";
|
|
14
|
+
import { getXbrlTool } from "./get-xbrl.js";
|
|
15
|
+
import { getPeriodicReportTool } from "./get-periodic-report.js";
|
|
16
|
+
import { getShareholdersTool } from "./get-shareholders.js";
|
|
17
|
+
import { getExecutiveCompensationTool } from "./get-executive-compensation.js";
|
|
18
|
+
import { getMajorHoldingsTool } from "./get-major-holdings.js";
|
|
19
|
+
import { getCorporateEventTool } from "./get-corporate-event.js";
|
|
20
|
+
import { insiderSignalTool } from "./insider-signal.js";
|
|
21
|
+
import { disclosureAnomalyTool } from "./disclosure-anomaly.js";
|
|
22
|
+
import { buffettQualitySnapshotTool } from "./buffett-quality-snapshot.js";
|
|
23
|
+
import { getAttachmentsTool } from "./get-attachments.js";
|
|
24
|
+
/**
|
|
25
|
+
* 15개 도구.
|
|
26
|
+
*
|
|
27
|
+
* 기본 조회 (7):
|
|
28
|
+
* [x] 1. resolve_corp_code
|
|
29
|
+
* [x] 2. search_disclosures (preset + all_pages 병렬 통합)
|
|
30
|
+
* [x] 3. get_company
|
|
31
|
+
* [x] 4. get_financials (scope: summary/full 통합)
|
|
32
|
+
* [x] 5. download_document (format: markdown/raw/text)
|
|
33
|
+
* [x] 6. get_xbrl
|
|
34
|
+
* [x] 7. get_periodic_report (29 섹션 enum)
|
|
35
|
+
*
|
|
36
|
+
* 합성 래퍼 (4):
|
|
37
|
+
* [x] 8. get_shareholders
|
|
38
|
+
* [x] 9. get_executive_compensation
|
|
39
|
+
* [x] 10. get_major_holdings
|
|
40
|
+
* [x] 11. get_corporate_event (36 enum + timeline mode)
|
|
41
|
+
*
|
|
42
|
+
* 애널리스트 프레임 (3 · 킬러):
|
|
43
|
+
* [x] 12. insider_signal
|
|
44
|
+
* [x] 13. disclosure_anomaly
|
|
45
|
+
* [x] 14. buffett_quality_snapshot (corps 배열 → 1개=snapshot / 2+=compare)
|
|
46
|
+
*
|
|
47
|
+
* 원문 분석 (1):
|
|
48
|
+
* [x] 15. get_attachments (kordoc + ZIP 재귀)
|
|
49
|
+
*/
|
|
50
|
+
export const TOOL_REGISTRY = [
|
|
51
|
+
resolveCorpCodeTool,
|
|
52
|
+
searchDisclosuresTool,
|
|
53
|
+
getCompanyTool,
|
|
54
|
+
getFinancialsTool,
|
|
55
|
+
downloadDocumentTool,
|
|
56
|
+
getXbrlTool,
|
|
57
|
+
getPeriodicReportTool,
|
|
58
|
+
getShareholdersTool,
|
|
59
|
+
getExecutiveCompensationTool,
|
|
60
|
+
getMajorHoldingsTool,
|
|
61
|
+
getCorporateEventTool,
|
|
62
|
+
insiderSignalTool,
|
|
63
|
+
disclosureAnomalyTool,
|
|
64
|
+
buffettQualitySnapshotTool,
|
|
65
|
+
getAttachmentsTool,
|
|
66
|
+
];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* insider_signal — 임원·주요주주 거래 시그널 집계 (킬러 포인트)
|
|
3
|
+
*
|
|
4
|
+
* 기반 데이터: elestock.json (DS004 임원·주요주주 소유보고)
|
|
5
|
+
*
|
|
6
|
+
* 기존 Python 래퍼는 raw 테이블만 반환. 여기서는 버핏·피셔 식 "내부자가 자기 돈으로 사는가" 를
|
|
7
|
+
* 정량화해서 LLM 에 바로 해석 가능한 시그널 단위로 제공:
|
|
8
|
+
* - 매수자 / 매도자 수
|
|
9
|
+
* - 순증감 주식수
|
|
10
|
+
* - 분기 단위 "cluster": N명 이상 같은 방향 거래
|
|
11
|
+
* - 등기임원 vs 미등기, 임원 vs 주요주주 구분
|
|
12
|
+
*
|
|
13
|
+
* 주의: 이는 투자 권유가 아니라 LLM 이 경영진 시그널을 해석하는 데이터 프레임 제공.
|
|
14
|
+
*/
|
|
15
|
+
export declare const insiderSignalTool: import("./_helpers.js").ToolDef;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* insider_signal — 임원·주요주주 거래 시그널 집계 (킬러 포인트)
|
|
3
|
+
*
|
|
4
|
+
* 기반 데이터: elestock.json (DS004 임원·주요주주 소유보고)
|
|
5
|
+
*
|
|
6
|
+
* 기존 Python 래퍼는 raw 테이블만 반환. 여기서는 버핏·피셔 식 "내부자가 자기 돈으로 사는가" 를
|
|
7
|
+
* 정량화해서 LLM 에 바로 해석 가능한 시그널 단위로 제공:
|
|
8
|
+
* - 매수자 / 매도자 수
|
|
9
|
+
* - 순증감 주식수
|
|
10
|
+
* - 분기 단위 "cluster": N명 이상 같은 방향 거래
|
|
11
|
+
* - 등기임원 vs 미등기, 임원 vs 주요주주 구분
|
|
12
|
+
*
|
|
13
|
+
* 주의: 이는 투자 권유가 아니라 LLM 이 경영진 시그널을 해석하는 데이터 프레임 제공.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { defineTool, normalizeDate, resolveCorp } from "./_helpers.js";
|
|
17
|
+
const Input = z.object({
|
|
18
|
+
corp: z.string().min(1).describe("회사명/종목코드/corp_code"),
|
|
19
|
+
start: z.string().optional().describe("기간 시작 (YYYY-MM-DD / YYYYMMDD)"),
|
|
20
|
+
end: z.string().optional().describe("기간 종료"),
|
|
21
|
+
cluster_threshold: z
|
|
22
|
+
.number()
|
|
23
|
+
.int()
|
|
24
|
+
.min(2)
|
|
25
|
+
.default(3)
|
|
26
|
+
.describe("cluster 인정 최소 인원 (기본 3: 분기 내 같은 방향 거래 3명 이상)"),
|
|
27
|
+
reporters_topn: z
|
|
28
|
+
.number()
|
|
29
|
+
.int()
|
|
30
|
+
.min(0)
|
|
31
|
+
.max(50)
|
|
32
|
+
.default(5)
|
|
33
|
+
.describe("분기별 reporters 명단 상위 N (절대값 큰 순). 대형사는 분기당 수백명 → 디폴트 5. 0=빈 배열."),
|
|
34
|
+
});
|
|
35
|
+
/** "-1,234" 또는 "1234" 같은 문자열 → 숫자. 파싱 실패 시 0. */
|
|
36
|
+
function toInt(v) {
|
|
37
|
+
if (!v)
|
|
38
|
+
return 0;
|
|
39
|
+
const n = Number(v.replace(/[,\s]/g, ""));
|
|
40
|
+
return Number.isFinite(n) ? n : 0;
|
|
41
|
+
}
|
|
42
|
+
/** DART는 rcept_dt 를 "YYYY-MM-DD" 또는 "YYYYMMDD" 둘 다 쓴다. 8자리 숫자로 정규화. */
|
|
43
|
+
function normalizeRcept(s) {
|
|
44
|
+
if (!s)
|
|
45
|
+
return null;
|
|
46
|
+
const digits = s.replace(/\D/g, "");
|
|
47
|
+
return /^\d{8}$/.test(digits) ? digits : null;
|
|
48
|
+
}
|
|
49
|
+
function quarterOf(yyyymmdd) {
|
|
50
|
+
if (!/^\d{8}$/.test(yyyymmdd))
|
|
51
|
+
return "unknown";
|
|
52
|
+
const y = yyyymmdd.slice(0, 4);
|
|
53
|
+
const m = parseInt(yyyymmdd.slice(4, 6), 10);
|
|
54
|
+
const q = Math.ceil(m / 3);
|
|
55
|
+
return `${y}Q${q}`;
|
|
56
|
+
}
|
|
57
|
+
export const insiderSignalTool = defineTool({
|
|
58
|
+
name: "insider_signal",
|
|
59
|
+
description: "임원·주요주주 거래(DS004 elestock)를 매수·매도 클러스터로 집계. " +
|
|
60
|
+
"기간 내 순증감·매수자수·분기별 클러스터 여부 산출. " +
|
|
61
|
+
"버핏 철학의 '경영진 본인 돈으로 매수' 시그널을 LLM 해석 가능한 단위로 제공.",
|
|
62
|
+
input: Input,
|
|
63
|
+
handler: async (ctx, args) => {
|
|
64
|
+
const record = resolveCorp(ctx.resolver, args.corp);
|
|
65
|
+
const startYmd = args.start ? normalizeDate(args.start) : null;
|
|
66
|
+
const endYmd = args.end ? normalizeDate(args.end) : null;
|
|
67
|
+
const raw = await ctx.client.getJson("elestock.json", {
|
|
68
|
+
corp_code: record.corp_code,
|
|
69
|
+
});
|
|
70
|
+
if (raw.status !== "000" && raw.status !== "013") {
|
|
71
|
+
throw new Error(`DART elestock 오류 [${raw.status}]: ${raw.message}`);
|
|
72
|
+
}
|
|
73
|
+
const all = raw.list ?? [];
|
|
74
|
+
// 기간 필터 (YYYY-MM-DD / YYYYMMDD 모두 수용)
|
|
75
|
+
const filtered = all
|
|
76
|
+
.map((it) => ({ it, ymd: normalizeRcept(it.rcept_dt) }))
|
|
77
|
+
.filter(({ ymd }) => {
|
|
78
|
+
if (!ymd)
|
|
79
|
+
return false;
|
|
80
|
+
if (startYmd && ymd < startYmd)
|
|
81
|
+
return false;
|
|
82
|
+
if (endYmd && ymd > endYmd)
|
|
83
|
+
return false;
|
|
84
|
+
return true;
|
|
85
|
+
})
|
|
86
|
+
.map(({ it, ymd }) => ({ ...it, rcept_dt: ymd }));
|
|
87
|
+
// 집계
|
|
88
|
+
let buyCount = 0; // 매수(증가) 건수
|
|
89
|
+
let sellCount = 0; // 매도(감소) 건수
|
|
90
|
+
let netChange = 0; // 순증감 수량
|
|
91
|
+
const buyers = new Set();
|
|
92
|
+
const sellers = new Set();
|
|
93
|
+
const byQuarter = {};
|
|
94
|
+
for (const item of filtered) {
|
|
95
|
+
const delta = toInt(item.sp_stock_lmp_irds_cnt);
|
|
96
|
+
if (delta === 0)
|
|
97
|
+
continue;
|
|
98
|
+
const name = item.repror ?? "(unknown)";
|
|
99
|
+
const role = item.isu_exctv_ofcps ||
|
|
100
|
+
item.isu_main_shrholdr ||
|
|
101
|
+
(item.isu_exctv_rgist_at === "Y" ? "등기임원" : "관계자");
|
|
102
|
+
const q = quarterOf(item.rcept_dt ?? "");
|
|
103
|
+
byQuarter[q] ?? (byQuarter[q] = {
|
|
104
|
+
buyers: new Set(),
|
|
105
|
+
sellers: new Set(),
|
|
106
|
+
netChange: 0,
|
|
107
|
+
reporters: [],
|
|
108
|
+
});
|
|
109
|
+
byQuarter[q].netChange += delta;
|
|
110
|
+
byQuarter[q].reporters.push({ name, change: delta, role });
|
|
111
|
+
netChange += delta;
|
|
112
|
+
if (delta > 0) {
|
|
113
|
+
buyCount++;
|
|
114
|
+
buyers.add(name);
|
|
115
|
+
byQuarter[q].buyers.add(name);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
sellCount++;
|
|
119
|
+
sellers.add(name);
|
|
120
|
+
byQuarter[q].sellers.add(name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const clusters = Object.entries(byQuarter)
|
|
124
|
+
.map(([quarter, agg]) => {
|
|
125
|
+
const buyers_n = agg.buyers.size;
|
|
126
|
+
const sellers_n = agg.sellers.size;
|
|
127
|
+
const direction = buyers_n >= args.cluster_threshold && buyers_n > sellers_n
|
|
128
|
+
? "buy_cluster"
|
|
129
|
+
: sellers_n >= args.cluster_threshold && sellers_n > buyers_n
|
|
130
|
+
? "sell_cluster"
|
|
131
|
+
: "mixed_or_thin";
|
|
132
|
+
// reporters 는 |change| 큰 순 상위 N 만 (대형사 폭발 방지)
|
|
133
|
+
const sortedReporters = [...agg.reporters].sort((a, b) => Math.abs(b.change) - Math.abs(a.change));
|
|
134
|
+
const topReporters = sortedReporters.slice(0, args.reporters_topn);
|
|
135
|
+
return {
|
|
136
|
+
quarter,
|
|
137
|
+
buyers: buyers_n,
|
|
138
|
+
sellers: sellers_n,
|
|
139
|
+
net_change: agg.netChange,
|
|
140
|
+
cluster: direction,
|
|
141
|
+
reporters_total: agg.reporters.length,
|
|
142
|
+
reporters_truncated: agg.reporters.length > args.reporters_topn,
|
|
143
|
+
reporters: topReporters,
|
|
144
|
+
};
|
|
145
|
+
})
|
|
146
|
+
.sort((a, b) => b.quarter.localeCompare(a.quarter));
|
|
147
|
+
const strongestCluster = clusters.find((c) => c.cluster === "buy_cluster") ??
|
|
148
|
+
clusters.find((c) => c.cluster === "sell_cluster") ??
|
|
149
|
+
null;
|
|
150
|
+
const signal = buyers.size >= args.cluster_threshold && buyers.size > sellers.size * 2
|
|
151
|
+
? "strong_buy_cluster"
|
|
152
|
+
: sellers.size >= args.cluster_threshold && sellers.size > buyers.size * 2
|
|
153
|
+
? "strong_sell_cluster"
|
|
154
|
+
: "neutral_or_mixed";
|
|
155
|
+
const summary_text = buildInsiderSummary({
|
|
156
|
+
corpName: record.corp_name,
|
|
157
|
+
reports: filtered.length,
|
|
158
|
+
buyCount,
|
|
159
|
+
sellCount,
|
|
160
|
+
buyers: buyers.size,
|
|
161
|
+
sellers: sellers.size,
|
|
162
|
+
netChange,
|
|
163
|
+
signal,
|
|
164
|
+
strongestCluster,
|
|
165
|
+
startYmd,
|
|
166
|
+
endYmd,
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
resolved: record,
|
|
170
|
+
period: { start: startYmd, end: endYmd },
|
|
171
|
+
cluster_threshold: args.cluster_threshold,
|
|
172
|
+
summary_text,
|
|
173
|
+
summary: {
|
|
174
|
+
reports_total: filtered.length,
|
|
175
|
+
buy_events: buyCount,
|
|
176
|
+
sell_events: sellCount,
|
|
177
|
+
unique_buyers: buyers.size,
|
|
178
|
+
unique_sellers: sellers.size,
|
|
179
|
+
net_change_shares: netChange,
|
|
180
|
+
signal,
|
|
181
|
+
strongest_quarter: strongestCluster?.quarter ?? null,
|
|
182
|
+
},
|
|
183
|
+
quarterly_clusters: clusters,
|
|
184
|
+
note: "DART 는 변동사유(장내매수/증여/유상증자 등)를 구분하지 않음. 순수 자발적 매수/매도 해석 시 raw items 의 report_tp·chg_rsn 참조 권장.",
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
function buildInsiderSummary(p) {
|
|
189
|
+
const periodStr = p.startYmd || p.endYmd
|
|
190
|
+
? `${p.startYmd ?? "처음"}~${p.endYmd ?? "현재"}`
|
|
191
|
+
: "전체 보고 기간";
|
|
192
|
+
if (p.reports === 0)
|
|
193
|
+
return `${p.corpName}: ${periodStr} 임원·주요주주 변동 보고 없음.`;
|
|
194
|
+
const netStr = p.netChange === 0
|
|
195
|
+
? "순증감 0"
|
|
196
|
+
: p.netChange > 0
|
|
197
|
+
? `순매수 +${p.netChange.toLocaleString("ko-KR")}주`
|
|
198
|
+
: `순매도 ${p.netChange.toLocaleString("ko-KR")}주`;
|
|
199
|
+
const signalStr = p.signal === "strong_buy_cluster"
|
|
200
|
+
? "→ 내부자 매수 클러스터 시그널"
|
|
201
|
+
: p.signal === "strong_sell_cluster"
|
|
202
|
+
? "→ 내부자 매도 클러스터 시그널"
|
|
203
|
+
: "→ 중립/혼조";
|
|
204
|
+
const clusterStr = p.strongestCluster
|
|
205
|
+
? ` 최강 클러스터: ${p.strongestCluster.quarter} (매수 ${p.strongestCluster.buyers}명/매도 ${p.strongestCluster.sellers}명).`
|
|
206
|
+
: "";
|
|
207
|
+
return `${p.corpName} ${periodStr}: ${p.reports}건 보고 (매수 ${p.buyCount} / 매도 ${p.sellCount}). 고유 매수자 ${p.buyers}명 / 매도자 ${p.sellers}명, ${netStr}. ${signalStr}.${clusterStr}`;
|
|
208
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolve_corp_code — 회사명·종목코드 → corp_code 조회
|
|
3
|
+
*
|
|
4
|
+
* 모든 DART 엔드포인트는 8자리 corp_code 를 요구하지만, LLM 은 보통 회사명만 안다.
|
|
5
|
+
* 서버가 기동 시 덤프를 SQLite 에 선적재해두므로 LIKE 검색이 수 ms 이내.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { defineTool } from "./_helpers.js";
|
|
9
|
+
const Input = z.object({
|
|
10
|
+
query: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.describe("회사명(한/영), 6자리 종목코드, 또는 8자리 corp_code"),
|
|
14
|
+
limit: z.number().int().min(1).max(50).default(10).describe("최대 반환 개수"),
|
|
15
|
+
});
|
|
16
|
+
export const resolveCorpCodeTool = defineTool({
|
|
17
|
+
name: "resolve_corp_code",
|
|
18
|
+
description: "회사명 또는 종목코드로 OpenDART corp_code 를 조회합니다. " +
|
|
19
|
+
"상장사·정확일치·짧은 이름 순으로 정렬해 반환. " +
|
|
20
|
+
"모든 다른 도구에 회사명을 바로 넘겨도 내부에서 자동 해결되지만, " +
|
|
21
|
+
"결과가 모호할 때 후보를 확인하는 용도로 사용하세요.",
|
|
22
|
+
input: Input,
|
|
23
|
+
handler: async (ctx, args) => {
|
|
24
|
+
const q = args.query.trim();
|
|
25
|
+
const direct = /^\d{8}$/.test(q)
|
|
26
|
+
? ctx.resolver.byCorpCode(q)
|
|
27
|
+
: /^\d{6}$/.test(q)
|
|
28
|
+
? ctx.resolver.byStockCode(q)
|
|
29
|
+
: undefined;
|
|
30
|
+
if (direct) {
|
|
31
|
+
return { query: args.query, count: 1, results: [direct] };
|
|
32
|
+
}
|
|
33
|
+
const results = ctx.resolver.search(args.query, args.limit);
|
|
34
|
+
return {
|
|
35
|
+
query: args.query,
|
|
36
|
+
count: results.length,
|
|
37
|
+
results,
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search_disclosures — 공시 검색 + 프리셋 배치 + 페이지 병렬화
|
|
3
|
+
*
|
|
4
|
+
* 세 모드 지원:
|
|
5
|
+
* 1. 페이지 모드 (기본): 단일 페이지 (page+size)
|
|
6
|
+
* 2. 프리셋 모드: `preset` 지정 → DART pblntf_ty + report_nm 정규식 자동 + 전량 수집
|
|
7
|
+
* 3. 전량 모드: `all_pages: true` → 필터 없이 기간 전체 병렬 수집
|
|
8
|
+
*
|
|
9
|
+
* 페이지 병렬화: 1페이지 먼저 → total_page 확인 → 나머지 병렬 (동시 5개)
|
|
10
|
+
* 기존 list_recent_filings 는 이 도구의 preset 모드로 흡수.
|
|
11
|
+
*
|
|
12
|
+
* 참고: https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019018
|
|
13
|
+
*/
|
|
14
|
+
export declare const searchDisclosuresTool: import("./_helpers.js").ToolDef;
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search_disclosures — 공시 검색 + 프리셋 배치 + 페이지 병렬화
|
|
3
|
+
*
|
|
4
|
+
* 세 모드 지원:
|
|
5
|
+
* 1. 페이지 모드 (기본): 단일 페이지 (page+size)
|
|
6
|
+
* 2. 프리셋 모드: `preset` 지정 → DART pblntf_ty + report_nm 정규식 자동 + 전량 수집
|
|
7
|
+
* 3. 전량 모드: `all_pages: true` → 필터 없이 기간 전체 병렬 수집
|
|
8
|
+
*
|
|
9
|
+
* 페이지 병렬화: 1페이지 먼저 → total_page 확인 → 나머지 병렬 (동시 5개)
|
|
10
|
+
* 기존 list_recent_filings 는 이 도구의 preset 모드로 흡수.
|
|
11
|
+
*
|
|
12
|
+
* 참고: https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019018
|
|
13
|
+
*/
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { defineTool, normalizeDate, resolveCorp } from "./_helpers.js";
|
|
16
|
+
// OpenDART 공시유형 (pblntf_ty) — enum 노출용 별칭
|
|
17
|
+
const KIND_MAP = {
|
|
18
|
+
periodic: "A",
|
|
19
|
+
major: "B",
|
|
20
|
+
issuance: "C",
|
|
21
|
+
holdings: "D",
|
|
22
|
+
other: "E",
|
|
23
|
+
audit: "F",
|
|
24
|
+
fund: "G",
|
|
25
|
+
abs: "H",
|
|
26
|
+
exchange: "I",
|
|
27
|
+
ftc: "J",
|
|
28
|
+
};
|
|
29
|
+
const PRESETS = {
|
|
30
|
+
// 자기주식
|
|
31
|
+
treasury_buy: { kind: "B", keyword: /자기주식.*취득/, label: "자기주식 취득 결정" },
|
|
32
|
+
treasury_sell: { kind: "B", keyword: /자기주식.*처분/, label: "자기주식 처분 결정" },
|
|
33
|
+
treasury_trust: { kind: "B", keyword: /자기주식.*신탁/, label: "자기주식 신탁 계약" },
|
|
34
|
+
// 사채 발행
|
|
35
|
+
cb_issue: { kind: "B", keyword: /전환사채/, label: "전환사채(CB) 발행결정" },
|
|
36
|
+
bw_issue: { kind: "B", keyword: /신주인수권부사채/, label: "신주인수권부사채(BW) 발행결정" },
|
|
37
|
+
eb_issue: { kind: "B", keyword: /교환사채/, label: "교환사채(EB) 발행결정" },
|
|
38
|
+
// 자본 증감
|
|
39
|
+
rights_offering: { kind: "B", keyword: /유상증자/, label: "유상증자 결정" },
|
|
40
|
+
bonus_issue: { kind: "B", keyword: /무상증자/, label: "무상증자 결정" },
|
|
41
|
+
capital_reduction: { kind: "B", keyword: /감자/, label: "감자 결정" },
|
|
42
|
+
// 지배구조
|
|
43
|
+
merger: { kind: "B", keyword: /합병/, label: "합병 결정" },
|
|
44
|
+
split: { kind: "B", keyword: /분할/, label: "분할 결정" },
|
|
45
|
+
stock_exchange: { kind: "B", keyword: /주식교환|주식이전/, label: "주식교환·이전" },
|
|
46
|
+
// 양수도
|
|
47
|
+
business_transfer: { kind: "B", keyword: /영업양도/, label: "영업양도" },
|
|
48
|
+
business_acquisition: { kind: "B", keyword: /영업양수/, label: "영업양수" },
|
|
49
|
+
// 지분
|
|
50
|
+
large_holding_5pct: { kind: "D", keyword: null, label: "지분공시 전체(5%룰+임원지분)" },
|
|
51
|
+
// 정기공시
|
|
52
|
+
annual_report: { kind: "A", keyword: /사업보고서/, label: "사업보고서" },
|
|
53
|
+
half_report: { kind: "A", keyword: /반기보고서/, label: "반기보고서" },
|
|
54
|
+
quarterly_report: { kind: "A", keyword: /분기보고서/, label: "분기보고서" },
|
|
55
|
+
// 감사
|
|
56
|
+
audit_report: { kind: "F", keyword: null, label: "외부감사 관련 공시 전체" },
|
|
57
|
+
// 정정
|
|
58
|
+
correction_all: {
|
|
59
|
+
kind: null,
|
|
60
|
+
keyword: /\[기재정정\]|\[첨부정정\]|\[첨부추가\]/,
|
|
61
|
+
label: "정정공시 전체",
|
|
62
|
+
},
|
|
63
|
+
// 부실/소송
|
|
64
|
+
insolvency: {
|
|
65
|
+
kind: "B",
|
|
66
|
+
keyword: /부도발생|영업정지|회생절차|해산사유|채권은행/,
|
|
67
|
+
label: "부실·법적 리스크",
|
|
68
|
+
},
|
|
69
|
+
litigation: { kind: "B", keyword: /소송/, label: "소송 제기" },
|
|
70
|
+
};
|
|
71
|
+
const PRESET_KEYS = Object.keys(PRESETS);
|
|
72
|
+
const Input = z.object({
|
|
73
|
+
corp: z.string().optional().describe("회사명/종목코드/corp_code. 생략 시 전체"),
|
|
74
|
+
begin: z.string().optional().describe("시작일 YYYY-MM-DD (생략 시 기본값)"),
|
|
75
|
+
end: z.string().optional().describe("종료일 (생략 시 오늘)"),
|
|
76
|
+
days: z
|
|
77
|
+
.number()
|
|
78
|
+
.int()
|
|
79
|
+
.min(1)
|
|
80
|
+
.max(365)
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("begin 대신 오늘 기준 과거 N일 (preset 모드 기본 7, 일반 90)"),
|
|
83
|
+
kind: z
|
|
84
|
+
.enum(Object.keys(KIND_MAP))
|
|
85
|
+
.optional()
|
|
86
|
+
.describe("공시유형: periodic/major/issuance/holdings/audit/other/fund/abs/exchange/ftc"),
|
|
87
|
+
preset: z
|
|
88
|
+
.enum(PRESET_KEYS)
|
|
89
|
+
.optional()
|
|
90
|
+
.describe("프리셋 22종: treasury_buy/sell/trust · cb/bw/eb_issue · rights_offering/bonus_issue/capital_reduction · merger/split/stock_exchange · business_transfer/acquisition · large_holding_5pct · annual_report/half_report/quarterly_report · audit_report · correction_all · insolvency · litigation. 지정 시 kind·키워드 자동 + 전량 페이지 병렬 수집."),
|
|
91
|
+
final_only: z.boolean().default(false).describe("최종보고서만 (정정공시 제외)"),
|
|
92
|
+
include_corrections: z
|
|
93
|
+
.boolean()
|
|
94
|
+
.default(false)
|
|
95
|
+
.describe("정정공시 포함 (preset 모드 전용). correction_all 은 자동 true."),
|
|
96
|
+
all_pages: z
|
|
97
|
+
.boolean()
|
|
98
|
+
.default(false)
|
|
99
|
+
.describe("preset 없이도 기간 전체를 병렬 수집. true 시 page/size 대신 limit 적용."),
|
|
100
|
+
page: z.number().int().min(1).default(1).describe("페이지 모드 시 페이지 번호"),
|
|
101
|
+
size: z.number().int().min(1).max(100).default(20).describe("페이지 모드 시 페이지 크기"),
|
|
102
|
+
limit: z
|
|
103
|
+
.number()
|
|
104
|
+
.int()
|
|
105
|
+
.min(1)
|
|
106
|
+
.max(3000)
|
|
107
|
+
.default(500)
|
|
108
|
+
.describe("배치 모드 최종 반환 개수 상한"),
|
|
109
|
+
concurrency: z
|
|
110
|
+
.number()
|
|
111
|
+
.int()
|
|
112
|
+
.min(1)
|
|
113
|
+
.max(10)
|
|
114
|
+
.default(5)
|
|
115
|
+
.describe("배치 모드 페이지 병렬 동시성 (1~10, 기본 5). 높이면 빠르지만 DART 일일 20,000건 한도/분당 쿼터 근접 위험."),
|
|
116
|
+
});
|
|
117
|
+
function ymd(d) {
|
|
118
|
+
const y = d.getFullYear();
|
|
119
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
120
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
121
|
+
return `${y}${m}${day}`;
|
|
122
|
+
}
|
|
123
|
+
function daysAgo(n) {
|
|
124
|
+
const d = new Date();
|
|
125
|
+
d.setDate(d.getDate() - n);
|
|
126
|
+
return ymd(d);
|
|
127
|
+
}
|
|
128
|
+
function parseYmd(s) {
|
|
129
|
+
return new Date(parseInt(s.slice(0, 4), 10), parseInt(s.slice(4, 6), 10) - 1, parseInt(s.slice(6, 8), 10));
|
|
130
|
+
}
|
|
131
|
+
function daysBetween(a, b) {
|
|
132
|
+
const ms = parseYmd(b).getTime() - parseYmd(a).getTime();
|
|
133
|
+
return Math.floor(ms / (24 * 3600 * 1000));
|
|
134
|
+
}
|
|
135
|
+
/** [bgn, end] 구간을 chunkDays 이하로 분할. 마지막 청크는 end 고정. */
|
|
136
|
+
function splitDateRange(bgn, end, chunkDays) {
|
|
137
|
+
const out = [];
|
|
138
|
+
let cursor = parseYmd(bgn);
|
|
139
|
+
const last = parseYmd(end);
|
|
140
|
+
while (cursor.getTime() <= last.getTime()) {
|
|
141
|
+
const chunkEnd = new Date(cursor);
|
|
142
|
+
chunkEnd.setDate(chunkEnd.getDate() + chunkDays - 1);
|
|
143
|
+
if (chunkEnd.getTime() > last.getTime())
|
|
144
|
+
chunkEnd.setTime(last.getTime());
|
|
145
|
+
out.push({ bgn: ymd(cursor), end: ymd(chunkEnd) });
|
|
146
|
+
cursor = new Date(chunkEnd);
|
|
147
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/** total_page 파악 후 2..N 페이지 병렬 수집 (동시성 제한). */
|
|
152
|
+
async function fetchAllPages(client, baseParams, concurrency = 5) {
|
|
153
|
+
const first = await client.getJson("list.json", {
|
|
154
|
+
...baseParams,
|
|
155
|
+
page_no: 1,
|
|
156
|
+
page_count: 100,
|
|
157
|
+
});
|
|
158
|
+
if (first.status === "013")
|
|
159
|
+
return { items: [], totalPages: 0 };
|
|
160
|
+
if (first.status !== "000") {
|
|
161
|
+
throw new Error(`DART list 오류 [${first.status}]: ${first.message}`);
|
|
162
|
+
}
|
|
163
|
+
const items = [...(first.list ?? [])];
|
|
164
|
+
const totalPages = Math.min(first.total_page ?? 1, 30); // 상한 30 페이지(=3000건)
|
|
165
|
+
if (totalPages <= 1)
|
|
166
|
+
return { items, totalPages };
|
|
167
|
+
const pages = [];
|
|
168
|
+
for (let p = 2; p <= totalPages; p++)
|
|
169
|
+
pages.push(p);
|
|
170
|
+
for (let i = 0; i < pages.length; i += concurrency) {
|
|
171
|
+
const batch = pages.slice(i, i + concurrency);
|
|
172
|
+
const responses = await Promise.all(batch.map((p) => client.getJson("list.json", {
|
|
173
|
+
...baseParams,
|
|
174
|
+
page_no: p,
|
|
175
|
+
page_count: 100,
|
|
176
|
+
})));
|
|
177
|
+
for (const r of responses) {
|
|
178
|
+
if (r.status === "000")
|
|
179
|
+
items.push(...(r.list ?? []));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { items, totalPages };
|
|
183
|
+
}
|
|
184
|
+
export const searchDisclosuresTool = defineTool({
|
|
185
|
+
name: "search_disclosures",
|
|
186
|
+
description: "DART 공시 검색 (3 모드): " +
|
|
187
|
+
"기본(단일 페이지, page+size), preset(22개 프리셋 자동 필터+전량 병렬), all_pages(프리셋 없이 기간 전체 병렬). " +
|
|
188
|
+
"rcp_no(rcept_no) 로 download_document / get_attachments 연동.",
|
|
189
|
+
input: Input,
|
|
190
|
+
handler: async (ctx, args) => {
|
|
191
|
+
const isBatch = Boolean(args.preset) || args.all_pages;
|
|
192
|
+
// 기간 기본값
|
|
193
|
+
const defaultDays = args.preset ? 7 : 90;
|
|
194
|
+
const end_de = args.end ? normalizeDate(args.end) : ymd(new Date());
|
|
195
|
+
const bgn_de = args.begin
|
|
196
|
+
? normalizeDate(args.begin)
|
|
197
|
+
: daysAgo(args.days ?? defaultDays);
|
|
198
|
+
// corp 해석
|
|
199
|
+
let corp_code;
|
|
200
|
+
let resolved = null;
|
|
201
|
+
if (args.corp) {
|
|
202
|
+
const r = resolveCorp(ctx.resolver, args.corp);
|
|
203
|
+
corp_code = r.corp_code;
|
|
204
|
+
resolved = { corp_code: r.corp_code, corp_name: r.corp_name };
|
|
205
|
+
}
|
|
206
|
+
// 프리셋 적용 → kind 자동
|
|
207
|
+
const preset = args.preset ? PRESETS[args.preset] : null;
|
|
208
|
+
const pblntf_ty = preset?.kind ?? (args.kind ? KIND_MAP[args.kind] : undefined);
|
|
209
|
+
const baseParams = {
|
|
210
|
+
corp_code,
|
|
211
|
+
bgn_de,
|
|
212
|
+
end_de,
|
|
213
|
+
pblntf_ty,
|
|
214
|
+
last_reprt_at: args.final_only ? "Y" : undefined,
|
|
215
|
+
};
|
|
216
|
+
if (!isBatch) {
|
|
217
|
+
// === 단일 페이지 모드 ===
|
|
218
|
+
const raw = await ctx.client.getJson("list.json", {
|
|
219
|
+
...baseParams,
|
|
220
|
+
page_no: args.page,
|
|
221
|
+
page_count: args.size,
|
|
222
|
+
});
|
|
223
|
+
if (raw.status === "013") {
|
|
224
|
+
return {
|
|
225
|
+
mode: "page",
|
|
226
|
+
period: { start: bgn_de, end: end_de },
|
|
227
|
+
corp: resolved,
|
|
228
|
+
total_count: 0,
|
|
229
|
+
page: args.page,
|
|
230
|
+
total_pages: 0,
|
|
231
|
+
items: [],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (raw.status !== "000") {
|
|
235
|
+
throw new Error(`DART 응답 오류 [${raw.status}]: ${raw.message}`);
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
mode: "page",
|
|
239
|
+
period: { start: bgn_de, end: end_de },
|
|
240
|
+
corp: resolved,
|
|
241
|
+
total_count: raw.total_count ?? 0,
|
|
242
|
+
page: raw.page_no ?? args.page,
|
|
243
|
+
total_pages: raw.total_page ?? 1,
|
|
244
|
+
items: raw.list ?? [],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
// === 배치 모드 (preset 또는 all_pages) ===
|
|
248
|
+
// OpenDART 제약: corp_code 없이 list.json 호출 시 bgn_de~end_de 는 90일(3개월) 이내.
|
|
249
|
+
// 기간 > 90일 && 회사 미지정 시 90일 청크로 자동 분할 (v0.9.0+).
|
|
250
|
+
// 청크 상한: bgn_de=1900 같은 악의·실수 입력에서 수천 chunks 폭발 방지.
|
|
251
|
+
const MAX_CHUNKS = 40; // ≈10년 × 4 청크/년
|
|
252
|
+
const rangeDays = daysBetween(bgn_de, end_de);
|
|
253
|
+
const needsSplit = !corp_code && rangeDays > 90;
|
|
254
|
+
const chunks = needsSplit
|
|
255
|
+
? splitDateRange(bgn_de, end_de, 90)
|
|
256
|
+
: [{ bgn: bgn_de, end: end_de }];
|
|
257
|
+
if (chunks.length > MAX_CHUNKS) {
|
|
258
|
+
throw new Error(`기간이 너무 깁니다 (${chunks.length} chunks > 상한 ${MAX_CHUNKS}). corp 지정 또는 기간 축소 필요.`);
|
|
259
|
+
}
|
|
260
|
+
let collected = [];
|
|
261
|
+
let totalPages = 0;
|
|
262
|
+
for (const chunk of chunks) {
|
|
263
|
+
const { items, totalPages: tp } = await fetchAllPages(ctx.client, { ...baseParams, bgn_de: chunk.bgn, end_de: chunk.end }, args.concurrency);
|
|
264
|
+
collected.push(...items);
|
|
265
|
+
totalPages += tp;
|
|
266
|
+
}
|
|
267
|
+
const includeCorrections = args.include_corrections || args.preset === "correction_all";
|
|
268
|
+
const filtered = collected.filter((item) => {
|
|
269
|
+
if (!includeCorrections && /\[(기재정정|첨부정정|첨부추가)\]/.test(item.report_nm)) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
if (preset?.keyword && !preset.keyword.test(item.report_nm))
|
|
273
|
+
return false;
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
const limited = filtered.slice(0, args.limit);
|
|
277
|
+
return {
|
|
278
|
+
mode: "batch",
|
|
279
|
+
preset: args.preset ?? null,
|
|
280
|
+
preset_label: preset?.label ?? null,
|
|
281
|
+
period: { start: bgn_de, end: end_de },
|
|
282
|
+
corp: resolved,
|
|
283
|
+
include_corrections: includeCorrections,
|
|
284
|
+
chunks: chunks.length > 1 ? chunks.length : undefined,
|
|
285
|
+
pages_fetched: totalPages,
|
|
286
|
+
total_fetched: collected.length,
|
|
287
|
+
matched: filtered.length,
|
|
288
|
+
returned: limited.length,
|
|
289
|
+
items: limited.map((it) => ({
|
|
290
|
+
rcept_no: it.rcept_no,
|
|
291
|
+
rcept_dt: it.rcept_dt,
|
|
292
|
+
corp_name: it.corp_name,
|
|
293
|
+
corp_code: it.corp_code,
|
|
294
|
+
corp_cls: it.corp_cls,
|
|
295
|
+
report_nm: it.report_nm,
|
|
296
|
+
flr_nm: it.flr_nm ?? null,
|
|
297
|
+
})),
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
});
|