@clazic/urban 0.2.8 → 0.2.10
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/package.json +1 -1
- package/scripts/postinstall.js +11 -5
- package/src/agent/skills/_dbpia-access-filter.js +109 -0
- package/src/agent/skills/_normalize.js +228 -0
- package/src/agent/skills/_registry.js +79 -0
- package/src/agent/skills/base.js +90 -0
- package/src/agent/skills/dbpia.js +678 -0
- package/src/agent/skills/nanet.js +485 -0
- package/src/agent/skills/prism.js +150 -0
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -39,14 +39,20 @@ try {
|
|
|
39
39
|
if (installResult.ok) {
|
|
40
40
|
console.log(`[urban] 데몬 등록 완료: ${installResult.message}`);
|
|
41
41
|
const startResult = await startDaemon();
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
if (startResult.ok) {
|
|
43
|
+
console.log(`[urban] 데몬 시작 완료`);
|
|
44
|
+
console.log(`\n✅ 설치 완료 → http://localhost:${URBAN_PORT}\n`);
|
|
45
|
+
} else {
|
|
46
|
+
console.warn(`[urban] 데몬 등록은 완료됐지만 시작 실패: ${startResult.message}`);
|
|
47
|
+
console.warn(`\n👉 시작하려면: urban start\n`);
|
|
48
|
+
}
|
|
44
49
|
} else {
|
|
45
|
-
console.warn(
|
|
46
|
-
console.warn(
|
|
50
|
+
console.warn(`\n⚠️ 데몬 자동 등록 실패 (원인: ${installResult.message})`);
|
|
51
|
+
console.warn(`👉 설치 후 수동으로 실행하세요: urban start\n`);
|
|
47
52
|
}
|
|
48
53
|
} catch (err) {
|
|
49
|
-
console.warn(
|
|
54
|
+
console.warn(`\n⚠️ 데몬 자동 시작 건너뜀 (원인: ${err.message})`);
|
|
55
|
+
console.warn(`👉 설치 후 수동으로 실행하세요: urban start\n`);
|
|
50
56
|
}
|
|
51
57
|
} catch (err) {
|
|
52
58
|
// postinstall 전체 실패해도 npm install은 성공으로 처리
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DBpia 검색 결과 ↔ access cache 기반 필터/업서트 공용 모듈
|
|
3
|
+
*
|
|
4
|
+
* 정책:
|
|
5
|
+
* - strict=true : cache status='ok' 인 항목만 통과. miss(unknown) 및 부정 상태 숨김
|
|
6
|
+
* - strict=false : cache status='ok' 또는 miss(unknown) 통과. 부정 상태만 숨김
|
|
7
|
+
* - HIDDEN_STATUSES 의 부정 상태(license_out / external_link / thesis_unsupported /
|
|
8
|
+
* publisher_disallow / link_missing)는 **토글 상태와 무관하게 항상 숨김**
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 검색 결과에서 항상 숨겨야 할 상태 */
|
|
12
|
+
export const HIDDEN_STATUSES = new Set([
|
|
13
|
+
'license_out',
|
|
14
|
+
'external_link',
|
|
15
|
+
'thesis_unsupported',
|
|
16
|
+
'publisher_disallow',
|
|
17
|
+
'link_missing',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** error.reason enum → cache status enum 매핑 */
|
|
21
|
+
export function reasonToStatus(reason) {
|
|
22
|
+
switch (reason) {
|
|
23
|
+
case 'license_out_of_scope': return 'license_out';
|
|
24
|
+
case 'external_link': return 'external_link';
|
|
25
|
+
case 'thesis_unsupported': return 'thesis_unsupported';
|
|
26
|
+
case 'publisher_disallow': return 'publisher_disallow';
|
|
27
|
+
case 'link_missing': return 'link_missing';
|
|
28
|
+
case 'session_expired': return null; // 일시적 오류 → cache 적재 안 함
|
|
29
|
+
case 'unknown_error': return null; // 네트워크 등 일시 오류
|
|
30
|
+
default: return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 검색 결과 필터링 — cache 조회 후 strict 모드에 따라 통과/차단 결정
|
|
36
|
+
*
|
|
37
|
+
* @param {Array} items normalizeDbpiaItem 결과 배열 (각 항목에 nodeId 필요)
|
|
38
|
+
* @param {{db, strict, b2bId}} ctx
|
|
39
|
+
* @returns {Array} accessStatus 필드가 추가된 필터링 결과
|
|
40
|
+
*/
|
|
41
|
+
export function filterByAccessCache(items, { db, strict = true, b2bId = 'ICST00001014' }) {
|
|
42
|
+
if (!db || !Array.isArray(items) || items.length === 0) return items;
|
|
43
|
+
|
|
44
|
+
const nodeIds = items.map(it => it.nodeId).filter(Boolean);
|
|
45
|
+
if (nodeIds.length === 0) return items;
|
|
46
|
+
|
|
47
|
+
const placeholders = nodeIds.map(() => '?').join(',');
|
|
48
|
+
let rows = [];
|
|
49
|
+
try {
|
|
50
|
+
rows = db.prepare(
|
|
51
|
+
`SELECT node_id, status FROM dbpia_access_cache
|
|
52
|
+
WHERE b2b_id = ? AND node_id IN (${placeholders})`
|
|
53
|
+
).all(b2bId, ...nodeIds);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.warn('[dbpia-access-filter] cache 조회 실패:', e.message);
|
|
56
|
+
return items; // cache 미초기화 등 오류 시 pass-through
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const statusByNode = new Map(rows.map(r => [r.node_id, r.status]));
|
|
60
|
+
|
|
61
|
+
return items
|
|
62
|
+
.map(it => ({ ...it, accessStatus: statusByNode.get(it.nodeId) || 'unknown' }))
|
|
63
|
+
.filter(it => {
|
|
64
|
+
// 항상 숨김: 부정 상태
|
|
65
|
+
if (HIDDEN_STATUSES.has(it.accessStatus)) return false;
|
|
66
|
+
// strict 모드: ok 만 통과 (unknown 도 숨김)
|
|
67
|
+
if (strict && it.accessStatus !== 'ok') return false;
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* access_cache 에 판정 결과 upsert
|
|
74
|
+
*
|
|
75
|
+
* @param {Database} db
|
|
76
|
+
* @param {{nodeId, b2bId?, status, reason?, contentType?, bytes?}} entry
|
|
77
|
+
*/
|
|
78
|
+
export function upsertAccessCache(db, { nodeId, b2bId = 'ICST00001014', status, reason, contentType, bytes }) {
|
|
79
|
+
if (!db || !nodeId || !status) return;
|
|
80
|
+
try {
|
|
81
|
+
db.prepare(`
|
|
82
|
+
INSERT INTO dbpia_access_cache (node_id, b2b_id, status, reason, content_type, bytes, checked_at, retries)
|
|
83
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
84
|
+
ON CONFLICT(node_id, b2b_id) DO UPDATE SET
|
|
85
|
+
status = excluded.status,
|
|
86
|
+
reason = excluded.reason,
|
|
87
|
+
content_type = excluded.content_type,
|
|
88
|
+
bytes = excluded.bytes,
|
|
89
|
+
checked_at = excluded.checked_at,
|
|
90
|
+
retries = dbpia_access_cache.retries + 1
|
|
91
|
+
`).run(nodeId, b2bId, status, reason ?? null, contentType ?? null, bytes ?? null, Date.now());
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.warn('[dbpia-access-filter] upsert 실패:', e.message);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* cache row 수 조회 — warmupMode 결정용
|
|
99
|
+
* @returns {number}
|
|
100
|
+
*/
|
|
101
|
+
export function countAccessCacheRows(db, b2bId = 'ICST00001014') {
|
|
102
|
+
if (!db) return 0;
|
|
103
|
+
try {
|
|
104
|
+
const row = db.prepare('SELECT COUNT(*) AS c FROM dbpia_access_cache WHERE b2b_id = ?').get(b2bId);
|
|
105
|
+
return row?.c || 0;
|
|
106
|
+
} catch {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 공통 텍스트 정규화 유틸 (prism-dl normalizer.js ESM 포팅)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** 파이프라인에서 지원하는 파일 확장자 목록 */
|
|
6
|
+
export const SUPPORTED_EXTS = new Set(['.pdf', '.hwp', '.hwpx', '.docx', '.xlsx', '.jpg', '.jpeg', '.png', '.zip']);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 파일명 기준 지원 여부 확인
|
|
10
|
+
* @param {string} [fileName]
|
|
11
|
+
* @returns {boolean} - 파일명이 없거나 알 수 없으면 true (허용), 명확히 미지원 확장자면 false
|
|
12
|
+
*/
|
|
13
|
+
export function isSupportedFile(fileName) {
|
|
14
|
+
if (!fileName) return true;
|
|
15
|
+
const dot = fileName.lastIndexOf('.');
|
|
16
|
+
if (dot < 0) return true; // 확장자 없으면 일단 허용 (다운로드 후 판단)
|
|
17
|
+
const ext = fileName.slice(dot).toLowerCase();
|
|
18
|
+
return SUPPORTED_EXTS.has(ext);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 외부 검색 결과 UI에 표시 허용되는 확장자 (파이프라인 지원보다 엄격) */
|
|
22
|
+
export const EXTERNAL_VISIBLE_EXTS = new Set(['.pdf', '.hwp', '.hwpx', '.docx', '.doc', '.zip']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 외부 검색 결과 표시 가능 여부. fileName 불명 시 false (엄격).
|
|
26
|
+
*/
|
|
27
|
+
export function isExternalVisibleFile(fileName) {
|
|
28
|
+
if (!fileName) return false;
|
|
29
|
+
const dot = fileName.lastIndexOf('.');
|
|
30
|
+
if (dot < 0) return false;
|
|
31
|
+
return EXTERNAL_VISIBLE_EXTS.has(fileName.slice(dot).toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function stripHighlightTags(value) {
|
|
35
|
+
return String(value || '').replace(/<!HS>|<!HE>/g, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function decodeHtmlEntities(value) {
|
|
39
|
+
let output = String(value || '');
|
|
40
|
+
const named = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'" };
|
|
41
|
+
output = output.replace(/&([a-zA-Z0-9#]+);/g, (match, entity) => {
|
|
42
|
+
if (entity.startsWith('#x')) {
|
|
43
|
+
const n = parseInt(entity.slice(2), 16);
|
|
44
|
+
return isNaN(n) ? match : String.fromCodePoint(n);
|
|
45
|
+
}
|
|
46
|
+
if (entity.startsWith('#')) {
|
|
47
|
+
const n = parseInt(entity.slice(1), 10);
|
|
48
|
+
return isNaN(n) ? match : String.fromCodePoint(n);
|
|
49
|
+
}
|
|
50
|
+
return Object.prototype.hasOwnProperty.call(named, entity) ? named[entity] : match;
|
|
51
|
+
});
|
|
52
|
+
return output;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function htmlToText(value) {
|
|
56
|
+
return String(value || '')
|
|
57
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
58
|
+
.replace(/<\/p>/gi, '\n')
|
|
59
|
+
.replace(/<!--[\s\S]*?-->/g, ' ') // HTML 주석 제거
|
|
60
|
+
.replace(/<[^>]*>/g, ' ') // 정상 태그 제거
|
|
61
|
+
.replace(/<\/?[a-zA-Z][^<>]*$/g, ' '); // 끝이 잘린 태그 꼬리 제거
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function cleanWhitespace(value) {
|
|
65
|
+
return String(value || '')
|
|
66
|
+
.replace(/\r/g, '\n')
|
|
67
|
+
.replace(/\t/g, ' ')
|
|
68
|
+
.replace(/[ \u00A0]+/g, ' ')
|
|
69
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
70
|
+
.replace(/[ \n]+$/g, '')
|
|
71
|
+
.replace(/^[ \n]+/g, '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function toPlainText(value) {
|
|
75
|
+
return cleanWhitespace(decodeHtmlEntities(htmlToText(stripHighlightTags(value))));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function inferYear(value) {
|
|
79
|
+
const candidates = [
|
|
80
|
+
value?.PBLCN_YR, value?.FRST_REG_DT, value?.DATE,
|
|
81
|
+
value?.RSCH_END_YMD, value?.RSCH_BGNG_YMD,
|
|
82
|
+
].filter(Boolean).map(String);
|
|
83
|
+
|
|
84
|
+
for (const c of candidates) {
|
|
85
|
+
const m = c.match(/(19|20)\d{2}/);
|
|
86
|
+
if (m) return m[0];
|
|
87
|
+
}
|
|
88
|
+
return '미상';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const PARENT_INSTITUTIONS = [
|
|
92
|
+
'서울특별시', '부산광역시', '대구광역시', '인천광역시', '광주광역시',
|
|
93
|
+
'대전광역시', '울산광역시', '세종특별자치시', '경기도', '강원특별자치도',
|
|
94
|
+
'충청북도', '충청남도', '전북특별자치도', '전라남도', '경상북도', '경상남도', '제주특별자치도',
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
export function normalizeInstitution(name) {
|
|
98
|
+
const stripped = String(name || '').replace(/\s+/g, '');
|
|
99
|
+
for (const p of PARENT_INSTITUTIONS) {
|
|
100
|
+
if (stripped.startsWith(p)) return p;
|
|
101
|
+
}
|
|
102
|
+
return name;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** PRISM API 응답 항목 → NormalizedItem */
|
|
106
|
+
export function normalizePrismItem(item, query) {
|
|
107
|
+
return {
|
|
108
|
+
docid: `prism-${item.DOCID}`,
|
|
109
|
+
source: 'prism',
|
|
110
|
+
query,
|
|
111
|
+
title: toPlainText(item.ASMT_NM) || '제목없음',
|
|
112
|
+
institution: toPlainText(item.INST_NM) || '기관미상',
|
|
113
|
+
institutionId: String(item.JRSD_INST_GRNT_NO || ''),
|
|
114
|
+
year: inferYear(item),
|
|
115
|
+
fileName: toPlainText(item.FILE_NM) || '파일명없음',
|
|
116
|
+
fileSize: Number(item.FILE_SZ || 0),
|
|
117
|
+
keywords: toPlainText(item.KYWD_CN),
|
|
118
|
+
outline: toPlainText(item.ASMT_OTLN),
|
|
119
|
+
summary: toPlainText(item.THSS_SMRY_CN),
|
|
120
|
+
contentPreview: toPlainText(item.FILE_CONTENT).slice(0, 800),
|
|
121
|
+
// 다운로드 시 필요한 PRISM 전용 필드
|
|
122
|
+
asmtId: String(item.ASMT_ID || ''),
|
|
123
|
+
fileTypeCd: String(item.FILE_TYPE_CD || ''),
|
|
124
|
+
fileSn: String(item.FILE_SN || ''),
|
|
125
|
+
fileWkky: String(item.FILE_WKKY || ''),
|
|
126
|
+
pdfTrsfYn: String(item.PDF_TRSF_YN || 'Y'),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** DBpia 검색 결과 → NormalizedItem
|
|
131
|
+
* 실제 응답 필드 (2026-04-19 확인):
|
|
132
|
+
* nodeId, nodeTitle (<!HS>/<!HE> 마커 포함), abstractText,
|
|
133
|
+
* publishYymm("YYYY.MM"), authors[]{autrNm}, iprdNm, plctNm,
|
|
134
|
+
* labels.nodeType, externalLink, searchKeywords[]
|
|
135
|
+
*/
|
|
136
|
+
export function normalizeDbpiaItem(item, query) {
|
|
137
|
+
const nodeId = String(item.nodeId || '').trim();
|
|
138
|
+
const docid = nodeId ? `dbpia-${nodeId}` : `dbpia-unknown-${Date.now()}`;
|
|
139
|
+
|
|
140
|
+
const title = toPlainText(item.nodeTitle || '') || '제목없음';
|
|
141
|
+
|
|
142
|
+
const authors = Array.isArray(item.authors)
|
|
143
|
+
? item.authors.map(a => toPlainText(a.autrNm || '')).filter(Boolean)
|
|
144
|
+
: [];
|
|
145
|
+
|
|
146
|
+
const rawOrg = item.iprdNm || item.plctNm || '';
|
|
147
|
+
const institution = normalizeInstitution(toPlainText(rawOrg)) || '학술DB';
|
|
148
|
+
|
|
149
|
+
// publishYymm: "2016.01" → year "2016"
|
|
150
|
+
const year = String(item.publishYymm || '').slice(0, 4) || '미상';
|
|
151
|
+
|
|
152
|
+
const keywords = Array.isArray(item.searchKeywords)
|
|
153
|
+
? item.searchKeywords.map(k => toPlainText(k)).filter(Boolean).join(', ')
|
|
154
|
+
: '';
|
|
155
|
+
|
|
156
|
+
const abstract = toPlainText(item.abstractText || '');
|
|
157
|
+
const nodeType = item.labels?.nodeType ?? '';
|
|
158
|
+
const fileName = `${year !== '미상' ? `(${year}) ` : ''}${title}.pdf`;
|
|
159
|
+
const url = nodeId
|
|
160
|
+
? `https://www.dbpia.co.kr/journal/articleDetail?nodeId=${nodeId}`
|
|
161
|
+
: '';
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
docid,
|
|
165
|
+
source: 'dbpia',
|
|
166
|
+
query,
|
|
167
|
+
title,
|
|
168
|
+
institution,
|
|
169
|
+
institutionId: '',
|
|
170
|
+
year,
|
|
171
|
+
fileName,
|
|
172
|
+
fileSize: 0,
|
|
173
|
+
keywords,
|
|
174
|
+
outline: abstract,
|
|
175
|
+
summary: abstract.slice(0, 800),
|
|
176
|
+
contentPreview: abstract.slice(0, 800),
|
|
177
|
+
// DBpia 전용 필드
|
|
178
|
+
nodeId,
|
|
179
|
+
nodeType,
|
|
180
|
+
journalName: toPlainText(item.plctNm || ''),
|
|
181
|
+
authors,
|
|
182
|
+
externalLink: item.externalLink === true,
|
|
183
|
+
url,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** NANET 검색 결과 + PDF 파일 정보 → NormalizedItem */
|
|
188
|
+
export function normalizeNanetItem(searchItem, pdfFile, query) {
|
|
189
|
+
const controlNo = String(searchItem.controlNo || '');
|
|
190
|
+
const itemNo = pdfFile ? String(pdfFile.itemNo || '') : '';
|
|
191
|
+
const docid = controlNo
|
|
192
|
+
? `nanet-${controlNo}${(itemNo && itemNo !== '1') ? `-${itemNo}` : ''}`
|
|
193
|
+
: `nanet-unknown-${Date.now()}`;
|
|
194
|
+
|
|
195
|
+
const title = toPlainText(searchItem.title || '') || '제목없음';
|
|
196
|
+
const origExt = pdfFile?.fileName?.includes('.')
|
|
197
|
+
? pdfFile.fileName.slice(pdfFile.fileName.lastIndexOf('.')).toLowerCase()
|
|
198
|
+
: '.pdf';
|
|
199
|
+
const year = String(searchItem.publishYear || '');
|
|
200
|
+
const fileName = year ? `(${year}) ${title}${itemNo && itemNo !== '1' ? `_${itemNo}` : ''}${origExt}`
|
|
201
|
+
: `${title}${origExt}`;
|
|
202
|
+
|
|
203
|
+
// pdfCount: 검색 결과 HTML의 downloadDoc(this, controlNo, count)에서 파싱한 PDF 파일 수
|
|
204
|
+
// count==1 → itemNo 없이 직접 다운로드 (NANET JS downloadBySingleCount 동작 일치)
|
|
205
|
+
// count>1 → fetchPDFInfoList로 itemNo 획득 후 다운로드
|
|
206
|
+
const pdfCount = Number(searchItem.pdfCount || 1);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
docid,
|
|
210
|
+
source: 'nanet',
|
|
211
|
+
query,
|
|
212
|
+
title,
|
|
213
|
+
institution: normalizeInstitution(toPlainText(searchItem.publisher || '') || '국회도서관'),
|
|
214
|
+
institutionId: '',
|
|
215
|
+
year,
|
|
216
|
+
fileName,
|
|
217
|
+
fileSize: pdfFile ? Number(pdfFile.fileSize || 0) : 0,
|
|
218
|
+
keywords: '',
|
|
219
|
+
outline: toPlainText(searchItem.abstract || searchItem.description || ''),
|
|
220
|
+
summary: toPlainText(searchItem.abstract || searchItem.description || '').slice(0, 800),
|
|
221
|
+
contentPreview: toPlainText(searchItem.abstract || searchItem.description || '').slice(0, 800),
|
|
222
|
+
// NANET 전용 (다운로드 시 필요)
|
|
223
|
+
controlNo,
|
|
224
|
+
itemNo,
|
|
225
|
+
pdfCount,
|
|
226
|
+
linkSystemId: 'NADL',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillRegistry
|
|
3
|
+
*
|
|
4
|
+
* Skill 플러그인을 로드하고 관리합니다.
|
|
5
|
+
* - skillsDir의 .js 파일을 순회하여 Skill 인스턴스를 로드
|
|
6
|
+
* - base.js, _로 시작하는 파일 제외
|
|
7
|
+
* - 로드 실패한 스킬은 건너뜸 (에러 격리)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readdir } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
13
|
+
|
|
14
|
+
export class SkillRegistry {
|
|
15
|
+
#skills = new Map();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* skillsDir의 .js 파일을 순회하여 Skill 인스턴스를 로드합니다.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} skillsDir - 절대 경로
|
|
21
|
+
*/
|
|
22
|
+
async load(skillsDir) {
|
|
23
|
+
let files;
|
|
24
|
+
try {
|
|
25
|
+
files = await readdir(skillsDir);
|
|
26
|
+
} catch {
|
|
27
|
+
// 디렉토리 없으면 graceful degradation
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const skillFiles = files.filter(
|
|
32
|
+
f => f.endsWith('.js') && !f.startsWith('_') && f !== 'base.js'
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
for (const file of skillFiles) {
|
|
36
|
+
try {
|
|
37
|
+
const mod = await import(pathToFileURL(join(skillsDir, file)).href);
|
|
38
|
+
const SkillClass = mod.default;
|
|
39
|
+
if (!SkillClass || !SkillClass.manifest?.id) {
|
|
40
|
+
console.warn(`[skill] ${file}: default export 없거나 manifest.id 없음`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const instance = new SkillClass();
|
|
44
|
+
await instance.onRegister?.();
|
|
45
|
+
this.#skills.set(SkillClass.manifest.id, instance);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.warn(`[skill] ${file} 로드 실패: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ID로 스킬을 조회합니다.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} id
|
|
56
|
+
* @returns {Skill|undefined}
|
|
57
|
+
*/
|
|
58
|
+
get(id) {
|
|
59
|
+
return this.#skills.get(id);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 모든 로드된 스킬을 배열로 반환합니다.
|
|
64
|
+
*
|
|
65
|
+
* @returns {Skill[]}
|
|
66
|
+
*/
|
|
67
|
+
list() {
|
|
68
|
+
return [...this.#skills.values()];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 등록된 스킬 수를 반환합니다.
|
|
73
|
+
*
|
|
74
|
+
* @returns {number}
|
|
75
|
+
*/
|
|
76
|
+
get size() {
|
|
77
|
+
return this.#skills.size;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill 추상 클래스
|
|
3
|
+
*
|
|
4
|
+
* 모든 수집 플러그인은 이 클래스를 상속한다.
|
|
5
|
+
* discover() / openDownloadResponse() / normalize() 세 메서드를 반드시 구현해야 한다.
|
|
6
|
+
*/
|
|
7
|
+
export class Skill {
|
|
8
|
+
/**
|
|
9
|
+
* 플러그인 정적 매니페스트 — 서브클래스에서 오버라이드 필수
|
|
10
|
+
* @type {{ id: string, name: string, version: string, defaultCron: string|null, rateLimit: { rps: number, burst: number }, requiredEnv: string[], capabilities: string[] }}
|
|
11
|
+
*/
|
|
12
|
+
static manifest = {
|
|
13
|
+
id: 'base',
|
|
14
|
+
name: 'Base Skill',
|
|
15
|
+
version: '0.0.0',
|
|
16
|
+
defaultCron: null,
|
|
17
|
+
rateLimit: { rps: 1, burst: 1 },
|
|
18
|
+
requiredEnv: [],
|
|
19
|
+
capabilities: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 소스별 동작 메타 — 서브클래스에서 오버라이드 가능
|
|
24
|
+
*/
|
|
25
|
+
get meta() {
|
|
26
|
+
return {
|
|
27
|
+
sessionTtlMs: null,
|
|
28
|
+
minRequestIntervalMs: 0,
|
|
29
|
+
earlyStopConsecutiveEmpty: 5,
|
|
30
|
+
supportsRangeRequest: false,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 신규 항목 발견
|
|
36
|
+
* @param {{ since: Date|null, keywords: string[], limit: number }} options
|
|
37
|
+
* @returns {Promise<object[]>} NormalizedItem 배열
|
|
38
|
+
*/
|
|
39
|
+
async discover(_options) {
|
|
40
|
+
throw new Error(`${this.constructor.manifest.id}: discover() 미구현`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 단일 쿼리로 실시간 검색 (UI 외부검색용)
|
|
45
|
+
* @param {string} _query
|
|
46
|
+
* @param {{ limit?: number }} [_options]
|
|
47
|
+
* @returns {Promise<object[]>} NormalizedItem 배열
|
|
48
|
+
*/
|
|
49
|
+
async search(_query, _options) {
|
|
50
|
+
throw new Error(`${this.constructor.manifest.id}: search() 미구현`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 다운로드 스트림 오픈
|
|
55
|
+
* @param {object} item NormalizedItem
|
|
56
|
+
* @param {object} [options]
|
|
57
|
+
* @returns {Promise<Response>}
|
|
58
|
+
*/
|
|
59
|
+
async openDownloadResponse(_item, _options) {
|
|
60
|
+
throw new Error(`${this.constructor.manifest.id}: openDownloadResponse() 미구현`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 원본 항목 → NormalizedItem 변환
|
|
65
|
+
* @param {object} rawItem
|
|
66
|
+
* @returns {object}
|
|
67
|
+
*/
|
|
68
|
+
normalize(_rawItem) {
|
|
69
|
+
throw new Error(`${this.constructor.manifest.id}: normalize() 미구현`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 쿼리 가공 — 필요 시 서브클래스에서 오버라이드 */
|
|
73
|
+
buildQuery(options) {
|
|
74
|
+
return options.query ?? '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 세션 보장 — 로그인 필요 소스에서 오버라이드 */
|
|
78
|
+
async ensureSession() {}
|
|
79
|
+
|
|
80
|
+
/** 최초 등록 시 1회 호출 */
|
|
81
|
+
async onRegister() {}
|
|
82
|
+
|
|
83
|
+
/** 주기적 상태 확인 */
|
|
84
|
+
async healthCheck() {}
|
|
85
|
+
|
|
86
|
+
/** 추가 실행 조건 — false 반환 시 tick() 건너뜀 */
|
|
87
|
+
async shouldRun(_context) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|