@heripo/research-radar 5.0.5 → 5.1.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/README.md +216 -90
- package/dist/index.d.ts +138 -5
- package/dist/index.js +1564 -141
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
package/dist/index.js
CHANGED
|
@@ -9,6 +9,203 @@ import safeMarkdown2Html from 'safe-markdown2html';
|
|
|
9
9
|
import DOMPurify from 'dompurify';
|
|
10
10
|
import juice from 'juice';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Deterministic pre-filter for public job-posting APIs.
|
|
14
|
+
*
|
|
15
|
+
* Both sources return every public-sector vacancy in the country — roughly
|
|
16
|
+
* 1,300 postings per fortnight from 나라일터 alone — so the newsletter pipeline
|
|
17
|
+
* must not see them all. Scoring is an LLM call per article, and asking it to
|
|
18
|
+
* reject thousands of unrelated postings would be slow, expensive, and noisy.
|
|
19
|
+
*
|
|
20
|
+
* This cuts by rule first, and deliberately errs toward keeping things: a
|
|
21
|
+
* posting survives if its institution is a heritage body **or** its title names
|
|
22
|
+
* a heritage job. The remaining judgement — a heritage institution hiring for a
|
|
23
|
+
* non-heritage role, or a borderline field — is left to the importance prompt,
|
|
24
|
+
* which reads the full posting.
|
|
25
|
+
*/
|
|
26
|
+
/** Institutions whose name alone settles the domain. */
|
|
27
|
+
const HERITAGE_INSTITUTION = /국가유산청|국립문화유산연구원|국립해양유산연구소|한국전통문화대학교|국가유산진흥원|국립고궁박물관|궁능유적본부|국립[가-힣]*박물관|[가-힣]{2,}시립박물관|[가-힣]{2,}군립박물관|역사박물관|민속박물관|문화유산|국가유산|문화재단|[가-힣]{2,}유적|[가-힣]{2,}유물|고고|발굴/;
|
|
28
|
+
/** Job titles that settle the domain regardless of the institution. */
|
|
29
|
+
const HERITAGE_JOB = /학예|고고|발굴|매장유산|보존처리|보존과학|문화재|국가유산|문화유산|유물|고건축|전통건축|수리기술|전통문화|무형유산|천연기념물|전시기획|큐레이터|학예연구/;
|
|
30
|
+
/**
|
|
31
|
+
* Roles excluded even at a heritage institution.
|
|
32
|
+
*
|
|
33
|
+
* A museum hiring a cleaner or a security guard is not heritage news. This is
|
|
34
|
+
* the same distinction the importance prompt makes, applied here so the obvious
|
|
35
|
+
* cases never cost an LLM call.
|
|
36
|
+
*/
|
|
37
|
+
const NON_HERITAGE_ROLE = /미화|청소|방호|경비|청원경찰|조리|취사|영양사|시설관리|시설물|기계설비|전기설비|조경|운전|당직|소방|보건|간호|집배|매점|카페|주차|경리|경호/;
|
|
38
|
+
/** NCS job categories that can plausibly carry heritage work (재정경제부 API). */
|
|
39
|
+
const HERITAGE_NCS_CODES = [
|
|
40
|
+
'R600004', // 교육.자연.사회과학 — 고고학·역사학 연구직
|
|
41
|
+
'R600008', // 문화.예술.디자인.방송 — 학예·전시
|
|
42
|
+
'R600022', // 인쇄.목재.가구.공예 — 보존처리·전통공예
|
|
43
|
+
'R600025', // 연구
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Whether a posting should reach the analysis stage.
|
|
47
|
+
*
|
|
48
|
+
* @returns true when the posting looks heritage-related and is not one of the
|
|
49
|
+
* excluded support roles
|
|
50
|
+
*/
|
|
51
|
+
function isHeritageJobCandidate({ institution, title, categoryCodes = [], }) {
|
|
52
|
+
if (NON_HERITAGE_ROLE.test(title)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const named = HERITAGE_INSTITUTION.test(institution) || HERITAGE_JOB.test(title);
|
|
56
|
+
if (!named) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (categoryCodes.length === 0) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return categoryCodes.some((code) => HERITAGE_NCS_CODES.includes(code));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const API_BASE$1 = 'https://apis.data.go.kr/1051000/recruitment';
|
|
66
|
+
const SITE_BASE$1 = 'https://job.alio.go.kr';
|
|
67
|
+
/**
|
|
68
|
+
* Public 알리오 board URL used as this target's list page.
|
|
69
|
+
*
|
|
70
|
+
* As with 나라일터, core fetches this URL and `createAlioFetch` answers it from
|
|
71
|
+
* the 재정경제부 open API, keeping the service key out of the configuration.
|
|
72
|
+
*/
|
|
73
|
+
const ALIO_LIST_URL = `${SITE_BASE$1}/recruit.do`;
|
|
74
|
+
/** Public posting URL. */
|
|
75
|
+
const buildAlioDetailUrl = (serial) => `${SITE_BASE$1}/recruitview.do?idx=${serial}`;
|
|
76
|
+
/** `yyyymmdd` to ISO `yyyy-mm-dd`. */
|
|
77
|
+
function toIsoDate$1(compact) {
|
|
78
|
+
return /^\d{8}$/.test(compact)
|
|
79
|
+
? `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`
|
|
80
|
+
: '';
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Serves the 알리오 target from the 재정경제부 open API.
|
|
84
|
+
*
|
|
85
|
+
* This board only covers 공기업 and 준정부기관, so heritage postings are rare —
|
|
86
|
+
* 국가유산청 and the national museums publish through 나라일터 instead. It is
|
|
87
|
+
* collected anyway because a body like 국립농업박물관 does appear here, and the
|
|
88
|
+
* NCS job codes make filtering cheap and precise.
|
|
89
|
+
*
|
|
90
|
+
* Only postings still open (`ongoingYn=Y`) are requested.
|
|
91
|
+
*/
|
|
92
|
+
const createAlioFetch = (baseFetch = fetch, options) => {
|
|
93
|
+
const { apiKey, rowsPerPage = 3000 } = options;
|
|
94
|
+
return async (input, init) => {
|
|
95
|
+
const requestUrl = typeof input === 'string'
|
|
96
|
+
? input
|
|
97
|
+
: input instanceof URL
|
|
98
|
+
? input.href
|
|
99
|
+
: input.url;
|
|
100
|
+
let url;
|
|
101
|
+
try {
|
|
102
|
+
url = new URL(requestUrl);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return baseFetch(input, init);
|
|
106
|
+
}
|
|
107
|
+
if (url.origin !== SITE_BASE$1) {
|
|
108
|
+
return baseFetch(input, init);
|
|
109
|
+
}
|
|
110
|
+
if (!apiKey) {
|
|
111
|
+
// Without a key the board is simply not collected. See gojobs.parser.ts.
|
|
112
|
+
return Response.json({ result: [] });
|
|
113
|
+
}
|
|
114
|
+
if (url.pathname === '/recruit.do') {
|
|
115
|
+
return baseFetch(`${API_BASE$1}/list?serviceKey=${apiKey}&resultType=json` +
|
|
116
|
+
`&numOfRows=${rowsPerPage}&pageNo=1&ongoingYn=Y`, init);
|
|
117
|
+
}
|
|
118
|
+
if (url.pathname === '/recruitview.do') {
|
|
119
|
+
const serial = url.searchParams.get('idx');
|
|
120
|
+
if (serial) {
|
|
121
|
+
return baseFetch(`${API_BASE$1}/detail?serviceKey=${apiKey}&resultType=json&sn=${serial}`, init);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return baseFetch(input, init);
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
function parseJson$1(body) {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(body);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Renders a posting's fields as the article body. */
|
|
136
|
+
function renderAlioContent(record) {
|
|
137
|
+
const lines = [
|
|
138
|
+
['기관', record.instNm],
|
|
139
|
+
['직무분야', record.ncsCdNmLst],
|
|
140
|
+
['고용형태', record.hireTypeNmLst],
|
|
141
|
+
['채용구분', record.recrutSeNm],
|
|
142
|
+
['근무지역', record.workRgnNmLst],
|
|
143
|
+
['모집인원', record.recrutNope ? `${record.recrutNope}명` : ''],
|
|
144
|
+
[
|
|
145
|
+
'공고기간',
|
|
146
|
+
[record.pbancBgngYmd, record.pbancEndYmd]
|
|
147
|
+
.map((value) => toIsoDate$1(value ?? ''))
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.join(' - '),
|
|
150
|
+
],
|
|
151
|
+
['원문', record.srcUrl],
|
|
152
|
+
]
|
|
153
|
+
.filter(([, value]) => value)
|
|
154
|
+
.map(([label, value]) => `- **${label}**: ${String(value).trim()}`);
|
|
155
|
+
const sections = [
|
|
156
|
+
['지원자격', record.aplyQlfcCn],
|
|
157
|
+
['우대사항', record.prefCn],
|
|
158
|
+
['전형방법', record.scrnprcdrMthdExpln],
|
|
159
|
+
]
|
|
160
|
+
.filter(([, value]) => value?.trim())
|
|
161
|
+
.flatMap(([label, value]) => [
|
|
162
|
+
'',
|
|
163
|
+
`### ${label}`,
|
|
164
|
+
'',
|
|
165
|
+
String(value).trim(),
|
|
166
|
+
]);
|
|
167
|
+
return [`## ${record.recrutPbancTtl ?? ''}`, '', ...lines, ...sections]
|
|
168
|
+
.join('\n')
|
|
169
|
+
.trim();
|
|
170
|
+
}
|
|
171
|
+
/** Parses the aggregated list response, keeping only heritage-related postings. */
|
|
172
|
+
const parseAlioList = (body) => {
|
|
173
|
+
const parsed = parseJson$1(body);
|
|
174
|
+
const posts = [];
|
|
175
|
+
for (const record of parsed?.result ?? []) {
|
|
176
|
+
const title = record.recrutPbancTtl?.trim();
|
|
177
|
+
if (!title || record.recrutPblntSn == null) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const heritage = isHeritageJobCandidate({
|
|
181
|
+
institution: record.instNm ?? '',
|
|
182
|
+
title,
|
|
183
|
+
categoryCodes: (record.ncsCdLst ?? '').split(',').filter(Boolean),
|
|
184
|
+
});
|
|
185
|
+
if (!heritage) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
posts.push({
|
|
189
|
+
uniqId: String(record.recrutPblntSn),
|
|
190
|
+
title,
|
|
191
|
+
date: toIsoDate$1(record.pbancBgngYmd ?? ''),
|
|
192
|
+
detailUrl: buildAlioDetailUrl(record.recrutPblntSn),
|
|
193
|
+
dateType: DateType.REGISTERED,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return posts;
|
|
197
|
+
};
|
|
198
|
+
/** Parses a `/detail` response into the article body. */
|
|
199
|
+
const parseAlioDetail = (body) => {
|
|
200
|
+
const parsed = parseJson$1(body);
|
|
201
|
+
const record = parsed?.result;
|
|
202
|
+
return {
|
|
203
|
+
detailContent: record ? renderAlioContent(record) : '',
|
|
204
|
+
hasAttachedFile: true,
|
|
205
|
+
hasAttachedImage: false,
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
12
209
|
/**
|
|
13
210
|
* Formats a date string by replacing dots with dashes.
|
|
14
211
|
* If the string contains a newline (indicating a date range),
|
|
@@ -115,7 +312,136 @@ function getUniqIdFromBuyeoMuseum(element) {
|
|
|
115
312
|
return (element.attr('onclick') ?? '').match(/goView\('(.*)'\)/)?.[1] ?? '';
|
|
116
313
|
}
|
|
117
314
|
|
|
315
|
+
const parseCheongjuMuseumList = (html) => {
|
|
316
|
+
const $ = cheerio.load(html);
|
|
317
|
+
const posts = [];
|
|
318
|
+
const baseUrl = 'https://cheongju.museum.go.kr';
|
|
319
|
+
$('table.bbs_default_list tbody tr').each((index, element) => {
|
|
320
|
+
const columns = $(element).find('td');
|
|
321
|
+
if (columns.length === 0) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const titleElement = columns.eq(1).find('a');
|
|
325
|
+
const relativeHref = titleElement.attr('href');
|
|
326
|
+
if (!relativeHref) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const fullUrl = new URL(relativeHref.replace('./', '/www/'), baseUrl);
|
|
330
|
+
const detailUrl = fullUrl.href;
|
|
331
|
+
const uniqId = fullUrl.searchParams.get('nttNo') ?? undefined;
|
|
332
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
333
|
+
const date = getDate(columns.eq(4).text().trim());
|
|
334
|
+
posts.push({
|
|
335
|
+
uniqId,
|
|
336
|
+
title,
|
|
337
|
+
date,
|
|
338
|
+
detailUrl: cleanUrl(detailUrl),
|
|
339
|
+
dateType: DateType.REGISTERED,
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
return posts;
|
|
343
|
+
};
|
|
344
|
+
const parseCheongjuMuseumDetail = (html) => {
|
|
345
|
+
const $ = cheerio.load(html);
|
|
346
|
+
const content = $('td.content');
|
|
347
|
+
return {
|
|
348
|
+
detailContent: new TurndownService().turndown(content.html() ?? ''),
|
|
349
|
+
hasAttachedFile: $('tr.FILE td p').length > 0,
|
|
350
|
+
hasAttachedImage: content.find('img').length > 0,
|
|
351
|
+
};
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const EXCAVATION_BASE_URL = 'https://www.e-minwon.go.kr';
|
|
355
|
+
/** List page of the 국가유산청 발굴조사 보고서 board. */
|
|
356
|
+
const EXCAVATION_REPORT_LIST_URL = `${EXCAVATION_BASE_URL}/ge/ee/getListEcexmRptp.do`;
|
|
357
|
+
/** Public detail URL for one report, keyed by its board id (`ecexmRcno`). */
|
|
358
|
+
const buildExcavationReportDetailUrl = (externalId) => `${EXCAVATION_BASE_URL}/ge/ee/getEcexmRptp.do?ecexmRcno=${externalId}`;
|
|
359
|
+
/**
|
|
360
|
+
* Marks a response body as injected report data rather than scraped HTML, so
|
|
361
|
+
* the parsers below can tell the two apart without guessing.
|
|
362
|
+
*/
|
|
363
|
+
const INJECTED_MARKER = '@heripo/research-radar:excavation-reports';
|
|
364
|
+
function parseInjectedPayload(body) {
|
|
365
|
+
if (!body.includes(INJECTED_MARKER)) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const parsed = JSON.parse(body);
|
|
370
|
+
return parsed.marker === INJECTED_MARKER ? parsed : null;
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function jsonResponse(payload) {
|
|
377
|
+
return new Response(JSON.stringify(payload), {
|
|
378
|
+
status: 200,
|
|
379
|
+
headers: { 'content-type': 'application/json' },
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Serves the 발굴조사 보고서 board from an injected source instead of the live
|
|
384
|
+
* site, leaving every other request untouched.
|
|
385
|
+
*
|
|
386
|
+
* Core's crawling chain always fetches a target's list URL and then each detail
|
|
387
|
+
* URL, so the substitution happens at the fetch layer — the same seam
|
|
388
|
+
* `createKrasFetch` uses. `source` is called once and its result reused for the
|
|
389
|
+
* detail requests that follow.
|
|
390
|
+
*
|
|
391
|
+
* @param baseFetch - Fetch to delegate every other request to
|
|
392
|
+
* @param source - Supplies the reports for this run
|
|
393
|
+
*/
|
|
394
|
+
const createExcavationReportFetch = (baseFetch = fetch, source) => {
|
|
395
|
+
let pending = null;
|
|
396
|
+
const loadReports = () => (pending ??= source());
|
|
397
|
+
return async (input, init) => {
|
|
398
|
+
const requestUrl = typeof input === 'string'
|
|
399
|
+
? input
|
|
400
|
+
: input instanceof URL
|
|
401
|
+
? input.href
|
|
402
|
+
: input.url;
|
|
403
|
+
const url = new URL(requestUrl, EXCAVATION_BASE_URL);
|
|
404
|
+
if (url.origin !== new URL(EXCAVATION_BASE_URL).origin) {
|
|
405
|
+
return baseFetch(input, init);
|
|
406
|
+
}
|
|
407
|
+
if (url.pathname === new URL(EXCAVATION_REPORT_LIST_URL).pathname) {
|
|
408
|
+
const reports = await loadReports();
|
|
409
|
+
return jsonResponse({
|
|
410
|
+
marker: INJECTED_MARKER,
|
|
411
|
+
reports,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (url.pathname === '/ge/ee/getEcexmRptp.do') {
|
|
415
|
+
const externalId = url.searchParams.get('ecexmRcno');
|
|
416
|
+
const report = (await loadReports()).find((candidate) => candidate.externalId === externalId);
|
|
417
|
+
if (report) {
|
|
418
|
+
return jsonResponse({
|
|
419
|
+
marker: INJECTED_MARKER,
|
|
420
|
+
report,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return baseFetch(input, init);
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
/** Renders a report's fields as the article body. */
|
|
428
|
+
function renderExcavationReportContent(report) {
|
|
429
|
+
const lines = Object.entries(report.fields)
|
|
430
|
+
.filter(([, value]) => value != null && String(value).trim() !== '')
|
|
431
|
+
.map(([label, value]) => `- **${label}**: ${String(value).trim()}`);
|
|
432
|
+
return ['## 발굴조사 보고서 정보', '', ...lines].join('\n');
|
|
433
|
+
}
|
|
118
434
|
const parseExcavationReportList = (html) => {
|
|
435
|
+
const injected = parseInjectedPayload(html);
|
|
436
|
+
if (injected) {
|
|
437
|
+
return injected.reports.map((report) => ({
|
|
438
|
+
uniqId: report.externalId,
|
|
439
|
+
title: report.title,
|
|
440
|
+
date: report.submittedDate,
|
|
441
|
+
detailUrl: buildExcavationReportDetailUrl(report.externalId),
|
|
442
|
+
dateType: DateType.REGISTERED,
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
119
445
|
const $ = cheerio.load(html);
|
|
120
446
|
const posts = [];
|
|
121
447
|
const baseUrl = 'https://www.e-minwon.go.kr';
|
|
@@ -172,6 +498,14 @@ const parseExcavationSiteList = (html) => {
|
|
|
172
498
|
return posts;
|
|
173
499
|
};
|
|
174
500
|
const parseExcavationReportDetail = (html) => {
|
|
501
|
+
const injected = parseInjectedPayload(html);
|
|
502
|
+
if (injected) {
|
|
503
|
+
return {
|
|
504
|
+
detailContent: renderExcavationReportContent(injected.report),
|
|
505
|
+
hasAttachedFile: injected.report.hasAttachedFile ?? true,
|
|
506
|
+
hasAttachedImage: false,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
175
509
|
const $ = cheerio.load(html);
|
|
176
510
|
const content = $('table.td_left').parent();
|
|
177
511
|
const trList = content.find('table tbody tr');
|
|
@@ -205,6 +539,45 @@ function getUniqIdFromExcavationItem(element) {
|
|
|
205
539
|
return ((element.attr('onclick') ?? '').match(/dataSelected\('(.*)'\)/)?.[1] ?? '');
|
|
206
540
|
}
|
|
207
541
|
|
|
542
|
+
const parseGimhaeMuseumList = (html, hrefPrefix) => {
|
|
543
|
+
const $ = cheerio.load(html);
|
|
544
|
+
const posts = [];
|
|
545
|
+
const baseUrl = 'https://gimhae.museum.go.kr';
|
|
546
|
+
$('table.board_list tbody tr').each((index, element) => {
|
|
547
|
+
const columns = $(element).find('td');
|
|
548
|
+
if (columns.length === 0) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const titleElement = columns.eq(1).find('a');
|
|
552
|
+
const relativeHref = titleElement.attr('href');
|
|
553
|
+
if (!relativeHref) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const fullUrl = new URL(`${hrefPrefix}${relativeHref}`, baseUrl);
|
|
557
|
+
const detailUrl = fullUrl.href;
|
|
558
|
+
const uniqId = fullUrl.searchParams.get('no') ?? undefined;
|
|
559
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
560
|
+
const date = getDate(columns.eq(5).text().trim());
|
|
561
|
+
posts.push({
|
|
562
|
+
uniqId,
|
|
563
|
+
title,
|
|
564
|
+
date,
|
|
565
|
+
detailUrl: cleanUrl(detailUrl),
|
|
566
|
+
dateType: DateType.REGISTERED,
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
return posts;
|
|
570
|
+
};
|
|
571
|
+
const parseGimhaeMuseumDetail = (html) => {
|
|
572
|
+
const $ = cheerio.load(html);
|
|
573
|
+
const content = $('div.bbs--view--content');
|
|
574
|
+
return {
|
|
575
|
+
detailContent: new TurndownService().turndown(content.html() ?? ''),
|
|
576
|
+
hasAttachedFile: $('div.bbs--view--file').length > 0,
|
|
577
|
+
hasAttachedImage: content.find('img').length > 0,
|
|
578
|
+
};
|
|
579
|
+
};
|
|
580
|
+
|
|
208
581
|
const parseGogungList = (html) => {
|
|
209
582
|
const $ = cheerio.load(html);
|
|
210
583
|
const posts = [];
|
|
@@ -244,6 +617,205 @@ const parseGogungDetail = (html) => {
|
|
|
244
617
|
};
|
|
245
618
|
};
|
|
246
619
|
|
|
620
|
+
const API_BASE = 'https://apis.data.go.kr/1760000/PblJobService';
|
|
621
|
+
const SITE_BASE = 'https://www.gojobs.go.kr';
|
|
622
|
+
/**
|
|
623
|
+
* Public 나라일터 board URL used as this target's list page.
|
|
624
|
+
*
|
|
625
|
+
* Core fetches a target's `url` directly, so this stands in for the API call:
|
|
626
|
+
* `createGojobsFetch` recognises it and queries `/getList` instead, which keeps
|
|
627
|
+
* the service key and the date window out of the checked-in configuration.
|
|
628
|
+
*/
|
|
629
|
+
const GOJOBS_LIST_URL = `${SITE_BASE}/apmList.do`;
|
|
630
|
+
/** Public posting URL, which is also what readers of the newsletter follow. */
|
|
631
|
+
const buildGojobsDetailUrl = (idx) => `${SITE_BASE}/apmView.do?empmnsn=${idx}`;
|
|
632
|
+
/** `yyyy-mm-dd`, the format `Begin_de` and `End_de` expect. */
|
|
633
|
+
function toApiDate(date) {
|
|
634
|
+
return date.toISOString().slice(0, 10);
|
|
635
|
+
}
|
|
636
|
+
/** `yyyymmdd` (as the API returns dates) to ISO `yyyy-mm-dd`. */
|
|
637
|
+
function toIsoDate(compact) {
|
|
638
|
+
return /^\d{8}$/.test(compact)
|
|
639
|
+
? `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`
|
|
640
|
+
: '';
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Serves the 나라일터 target from the 인사혁신처 open API.
|
|
644
|
+
*
|
|
645
|
+
* The board carries every public-sector vacancy in the country, and its
|
|
646
|
+
* documented `Kwrd` search parameter is ignored by the service, so the whole
|
|
647
|
+
* date window is requested and narrowed in `parseGojobsList`. `Begin_de` and
|
|
648
|
+
* `End_de` do work and keep that window bounded.
|
|
649
|
+
*
|
|
650
|
+
* Detail requests to the public posting URL are answered from `/getItem`, which
|
|
651
|
+
* returns the announcement body directly — no HTML page is fetched.
|
|
652
|
+
*/
|
|
653
|
+
const createGojobsFetch = (baseFetch = fetch, options) => {
|
|
654
|
+
const { apiKey, windowDays = 7, rowsPerPage = 3000 } = options;
|
|
655
|
+
return async (input, init) => {
|
|
656
|
+
const requestUrl = typeof input === 'string'
|
|
657
|
+
? input
|
|
658
|
+
: input instanceof URL
|
|
659
|
+
? input.href
|
|
660
|
+
: input.url;
|
|
661
|
+
let url;
|
|
662
|
+
try {
|
|
663
|
+
url = new URL(requestUrl);
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return baseFetch(input, init);
|
|
667
|
+
}
|
|
668
|
+
if (url.origin !== SITE_BASE) {
|
|
669
|
+
return baseFetch(input, init);
|
|
670
|
+
}
|
|
671
|
+
if (!apiKey) {
|
|
672
|
+
// Without a key the board is simply not collected: answer with an empty
|
|
673
|
+
// payload so the parsers yield nothing and no request is made.
|
|
674
|
+
return new Response('<response><body><items/></body></response>', {
|
|
675
|
+
status: 200,
|
|
676
|
+
headers: { 'content-type': 'application/xml' },
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
if (url.pathname === '/apmList.do') {
|
|
680
|
+
const end = new Date();
|
|
681
|
+
const begin = new Date(end.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
|
682
|
+
return baseFetch(`${API_BASE}/getList?serviceKey=${apiKey}&numOfRows=${rowsPerPage}` +
|
|
683
|
+
`&pageNo=1&Begin_de=${toApiDate(begin)}&End_de=${toApiDate(end)}`, init);
|
|
684
|
+
}
|
|
685
|
+
if (url.pathname === '/apmView.do') {
|
|
686
|
+
const idx = url.searchParams.get('empmnsn');
|
|
687
|
+
if (idx) {
|
|
688
|
+
return baseFetch(`${API_BASE}/getItem?serviceKey=${apiKey}&idx=${idx}`, init);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return baseFetch(input, init);
|
|
692
|
+
};
|
|
693
|
+
};
|
|
694
|
+
function textOf($, item, tag) {
|
|
695
|
+
return $(item).find(tag).first().text().trim();
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Parses `/getList` responses, keeping only heritage-related postings.
|
|
699
|
+
*
|
|
700
|
+
* Several concatenated pages may arrive in one body; Cheerio's XML mode reads
|
|
701
|
+
* them as a single document, which is what the fetch above relies on.
|
|
702
|
+
*/
|
|
703
|
+
const parseGojobsList = (xml) => {
|
|
704
|
+
const $ = cheerio.load(xml, { xml: true });
|
|
705
|
+
const posts = [];
|
|
706
|
+
const seen = new Set();
|
|
707
|
+
$('item').each((_, element) => {
|
|
708
|
+
const idx = textOf($, element, 'idx');
|
|
709
|
+
const title = textOf($, element, 'title');
|
|
710
|
+
const institution = textOf($, element, 'insttname');
|
|
711
|
+
if (!idx || !title || seen.has(idx)) {
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (!isHeritageJobCandidate({ institution, title })) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
seen.add(idx);
|
|
718
|
+
posts.push({
|
|
719
|
+
uniqId: idx,
|
|
720
|
+
title,
|
|
721
|
+
date: toIsoDate(textOf($, element, 'regdate')),
|
|
722
|
+
detailUrl: buildGojobsDetailUrl(idx),
|
|
723
|
+
dateType: DateType.REGISTERED,
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
return posts;
|
|
727
|
+
};
|
|
728
|
+
/** Parses a `/getItem` response into the article body. */
|
|
729
|
+
const parseGojobsDetail = (xml) => {
|
|
730
|
+
const $ = cheerio.load(xml, { xml: true });
|
|
731
|
+
const item = $('item').first();
|
|
732
|
+
const field = (tag) => item.find(tag).first().text().trim();
|
|
733
|
+
const lines = [
|
|
734
|
+
['기관', field('insttname')],
|
|
735
|
+
['지역', field('areaname')],
|
|
736
|
+
['공고일', toIsoDate(field('regdate'))],
|
|
737
|
+
['마감일', toIsoDate(field('enddate'))],
|
|
738
|
+
]
|
|
739
|
+
.filter(([, value]) => value)
|
|
740
|
+
.map(([label, value]) => `- **${label}**: ${value}`);
|
|
741
|
+
const contents = field('contents');
|
|
742
|
+
return {
|
|
743
|
+
detailContent: [`## ${field('title')}`, '', ...lines, '', contents]
|
|
744
|
+
.join('\n')
|
|
745
|
+
.trim(),
|
|
746
|
+
hasAttachedFile: true,
|
|
747
|
+
hasAttachedImage: false,
|
|
748
|
+
};
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const parseGyeongjuMuseumList = (html, hrefPrefix) => {
|
|
752
|
+
const $ = cheerio.load(html);
|
|
753
|
+
const posts = [];
|
|
754
|
+
const baseUrl = 'https://gyeongju.museum.go.kr';
|
|
755
|
+
$('table tbody tr').each((index, element) => {
|
|
756
|
+
const columns = $(element).find('td');
|
|
757
|
+
if (columns.length === 0) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const titleElement = columns.eq(1).find('a');
|
|
761
|
+
const relativeHref = titleElement.attr('href');
|
|
762
|
+
if (!relativeHref) {
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const fullUrl = new URL(`${hrefPrefix}${relativeHref}`, baseUrl);
|
|
766
|
+
const detailUrl = fullUrl.href;
|
|
767
|
+
const uniqId = fullUrl.searchParams.get('no') ?? undefined;
|
|
768
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
769
|
+
const date = getDate(columns.eq(4).text().trim());
|
|
770
|
+
posts.push({
|
|
771
|
+
uniqId,
|
|
772
|
+
title,
|
|
773
|
+
date,
|
|
774
|
+
detailUrl: cleanUrl(detailUrl),
|
|
775
|
+
dateType: DateType.REGISTERED,
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
return posts;
|
|
779
|
+
};
|
|
780
|
+
const parseGyeongjuMuseumNoticeList = (html) => {
|
|
781
|
+
const $ = cheerio.load(html);
|
|
782
|
+
const posts = [];
|
|
783
|
+
const baseUrl = 'https://gyeongju.museum.go.kr';
|
|
784
|
+
$('table tbody tr').each((index, element) => {
|
|
785
|
+
const columns = $(element).find('td');
|
|
786
|
+
if (columns.length === 0) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const titleElement = columns.eq(3).find('a');
|
|
790
|
+
const relativeHref = titleElement.attr('href');
|
|
791
|
+
if (!relativeHref) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const fullUrl = new URL(`/kor/html/sub07/0703.html${relativeHref}`, baseUrl);
|
|
795
|
+
const detailUrl = fullUrl.href;
|
|
796
|
+
const uniqId = fullUrl.searchParams.get('no') ?? undefined;
|
|
797
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
798
|
+
const date = getDate(columns.eq(5).text().trim());
|
|
799
|
+
posts.push({
|
|
800
|
+
uniqId,
|
|
801
|
+
title,
|
|
802
|
+
date,
|
|
803
|
+
detailUrl: cleanUrl(detailUrl),
|
|
804
|
+
dateType: DateType.REGISTERED,
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
return posts;
|
|
808
|
+
};
|
|
809
|
+
const parseGyeongjuMuseumDetail = (html) => {
|
|
810
|
+
const $ = cheerio.load(html);
|
|
811
|
+
const content = $('div.bd_detail_cont');
|
|
812
|
+
return {
|
|
813
|
+
detailContent: new TurndownService().turndown(content.html() ?? ''),
|
|
814
|
+
hasAttachedFile: $('div.bd_detail_file ul.file').length > 0,
|
|
815
|
+
hasAttachedImage: content.find('img').length > 0,
|
|
816
|
+
};
|
|
817
|
+
};
|
|
818
|
+
|
|
247
819
|
const parseHeritageAgencyList = (html) => {
|
|
248
820
|
const $ = cheerio.load(html);
|
|
249
821
|
const posts = [];
|
|
@@ -324,6 +896,45 @@ const parseHsasDetail = (html) => {
|
|
|
324
896
|
};
|
|
325
897
|
};
|
|
326
898
|
|
|
899
|
+
const parseIksanMuseumList = (html) => {
|
|
900
|
+
const $ = cheerio.load(html);
|
|
901
|
+
const posts = [];
|
|
902
|
+
const baseUrl = 'https://iksan.museum.go.kr';
|
|
903
|
+
$('div.bd_list_wrap table tbody tr').each((index, element) => {
|
|
904
|
+
const columns = $(element).find('td');
|
|
905
|
+
if (columns.length === 0) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const titleElement = columns.eq(1).find('a');
|
|
909
|
+
const relativeHref = titleElement.attr('href');
|
|
910
|
+
if (!relativeHref) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const fullUrl = new URL(`/kor/html/sub05/0501.html${relativeHref}`, baseUrl);
|
|
914
|
+
const detailUrl = fullUrl.href;
|
|
915
|
+
const uniqId = fullUrl.searchParams.get('no') ?? undefined;
|
|
916
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
917
|
+
const date = getDate(columns.eq(5).text().trim());
|
|
918
|
+
posts.push({
|
|
919
|
+
uniqId,
|
|
920
|
+
title,
|
|
921
|
+
date,
|
|
922
|
+
detailUrl: cleanUrl(detailUrl),
|
|
923
|
+
dateType: DateType.REGISTERED,
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
return posts;
|
|
927
|
+
};
|
|
928
|
+
const parseIksanMuseumDetail = (html) => {
|
|
929
|
+
const $ = cheerio.load(html);
|
|
930
|
+
const content = $('div.bd_detail_cont');
|
|
931
|
+
return {
|
|
932
|
+
detailContent: new TurndownService().turndown(content.html() ?? ''),
|
|
933
|
+
hasAttachedFile: $('ul.file li').length > 0,
|
|
934
|
+
hasAttachedImage: content.find('img').length > 0,
|
|
935
|
+
};
|
|
936
|
+
};
|
|
937
|
+
|
|
327
938
|
const parseJbgogoList = (html) => {
|
|
328
939
|
const $ = cheerio.load(html);
|
|
329
940
|
const posts = [];
|
|
@@ -364,6 +975,45 @@ const parseJbgogoDetail = (html) => {
|
|
|
364
975
|
};
|
|
365
976
|
};
|
|
366
977
|
|
|
978
|
+
const parseJejuMuseumList = (html) => {
|
|
979
|
+
const $ = cheerio.load(html);
|
|
980
|
+
const posts = [];
|
|
981
|
+
const baseUrl = 'https://jeju.museum.go.kr';
|
|
982
|
+
$('div.board_list table tbody tr').each((index, element) => {
|
|
983
|
+
const columns = $(element).find('td');
|
|
984
|
+
if (columns.length === 0) {
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
const titleElement = columns.eq(1).find('a');
|
|
988
|
+
const relativeHref = titleElement.attr('href');
|
|
989
|
+
if (!relativeHref) {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const fullUrl = new URL(relativeHref.replace('./', '/_prog/_board/'), baseUrl);
|
|
993
|
+
const detailUrl = fullUrl.href;
|
|
994
|
+
const uniqId = fullUrl.searchParams.get('no') ?? undefined;
|
|
995
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
996
|
+
const date = getDate(columns.eq(3).text().trim());
|
|
997
|
+
posts.push({
|
|
998
|
+
uniqId,
|
|
999
|
+
title,
|
|
1000
|
+
date,
|
|
1001
|
+
detailUrl: cleanUrl(detailUrl),
|
|
1002
|
+
dateType: DateType.REGISTERED,
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
return posts;
|
|
1006
|
+
};
|
|
1007
|
+
const parseJejuMuseumDetail = (html) => {
|
|
1008
|
+
const $ = cheerio.load(html);
|
|
1009
|
+
const content = $('div.board_viewDetail');
|
|
1010
|
+
return {
|
|
1011
|
+
detailContent: new TurndownService().turndown(content.html() ?? ''),
|
|
1012
|
+
hasAttachedFile: $('li.file div a').length > 0,
|
|
1013
|
+
hasAttachedImage: content.find('img').length > 0,
|
|
1014
|
+
};
|
|
1015
|
+
};
|
|
1016
|
+
|
|
367
1017
|
const parseJeonjuMuseumList = (html) => {
|
|
368
1018
|
const $ = cheerio.load(html);
|
|
369
1019
|
const posts = [];
|
|
@@ -1614,87 +2264,82 @@ function createCrawlingTargetGroups(customFetch) {
|
|
|
1614
2264
|
parseDetail: parseBuyeoMuseumDetail,
|
|
1615
2265
|
},
|
|
1616
2266
|
{
|
|
1617
|
-
id: '국립부여박물관_보도자료',
|
|
1618
|
-
name: '국립부여박물관 보도자료',
|
|
1619
|
-
url: 'https://buyeo.museum.go.kr/bbs/list.do?key=2302150024',
|
|
1620
|
-
parseList: (html) => parseBuyeoMuseumList(html, '2302150024'),
|
|
1621
|
-
parseDetail: parseBuyeoMuseumDetail,
|
|
2267
|
+
id: '국립부여박물관_보도자료',
|
|
2268
|
+
name: '국립부여박물관 보도자료',
|
|
2269
|
+
url: 'https://buyeo.museum.go.kr/bbs/list.do?key=2302150024',
|
|
2270
|
+
parseList: (html) => parseBuyeoMuseumList(html, '2302150024'),
|
|
2271
|
+
parseDetail: parseBuyeoMuseumDetail,
|
|
2272
|
+
},
|
|
2273
|
+
{
|
|
2274
|
+
id: '국립진주박물관_새소식',
|
|
2275
|
+
name: '국립진주박물관 새소식',
|
|
2276
|
+
url: 'https://jinju.museum.go.kr/kor/html/sub06/0601.html',
|
|
2277
|
+
parseList: parseJinjuMuseumList,
|
|
2278
|
+
parseDetail: parseJinjuMuseumDetail,
|
|
2279
|
+
},
|
|
2280
|
+
{
|
|
2281
|
+
id: '국립경주박물관_새소식',
|
|
2282
|
+
name: '국립경주박물관 새소식',
|
|
2283
|
+
url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0701.html',
|
|
2284
|
+
parseList: (html) => parseGyeongjuMuseumList(html, '/kor/html/sub07/0701.html'),
|
|
2285
|
+
parseDetail: parseGyeongjuMuseumDetail,
|
|
2286
|
+
},
|
|
2287
|
+
{
|
|
2288
|
+
id: '국립경주박물관_고시공고',
|
|
2289
|
+
name: '국립경주박물관 고시/공고',
|
|
2290
|
+
url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0703.html',
|
|
2291
|
+
parseList: parseGyeongjuMuseumNoticeList,
|
|
2292
|
+
parseDetail: parseGyeongjuMuseumDetail,
|
|
2293
|
+
},
|
|
2294
|
+
{
|
|
2295
|
+
id: '국립경주박물관_보도자료',
|
|
2296
|
+
name: '국립경주박물관 보도자료',
|
|
2297
|
+
url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0705.html',
|
|
2298
|
+
parseList: (html) => parseGyeongjuMuseumList(html, '/kor/html/sub07/0705.html'),
|
|
2299
|
+
parseDetail: parseGyeongjuMuseumDetail,
|
|
2300
|
+
},
|
|
2301
|
+
{
|
|
2302
|
+
id: '국립청주박물관_새소식',
|
|
2303
|
+
name: '국립청주박물관 새소식',
|
|
2304
|
+
url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=1&key=482&nbar=s',
|
|
2305
|
+
parseList: parseCheongjuMuseumList,
|
|
2306
|
+
parseDetail: parseCheongjuMuseumDetail,
|
|
2307
|
+
},
|
|
2308
|
+
{
|
|
2309
|
+
id: '국립청주박물관_언론보도자료',
|
|
2310
|
+
name: '국립청주박물관 언론보도자료',
|
|
2311
|
+
url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=20&key=31&nbar=s',
|
|
2312
|
+
parseList: parseCheongjuMuseumList,
|
|
2313
|
+
parseDetail: parseCheongjuMuseumDetail,
|
|
2314
|
+
},
|
|
2315
|
+
{
|
|
2316
|
+
id: '국립김해박물관_새소식',
|
|
2317
|
+
name: '국립김해박물관 새소식',
|
|
2318
|
+
url: 'https://gimhae.museum.go.kr/kr/html/sub04/0401.html',
|
|
2319
|
+
parseList: (html) => parseGimhaeMuseumList(html, '/kr/html/sub04/0401.html'),
|
|
2320
|
+
parseDetail: parseGimhaeMuseumDetail,
|
|
1622
2321
|
},
|
|
1623
2322
|
{
|
|
1624
|
-
id: '
|
|
1625
|
-
name: '
|
|
1626
|
-
url: 'https://
|
|
1627
|
-
parseList:
|
|
1628
|
-
parseDetail:
|
|
2323
|
+
id: '국립김해박물관_보도자료',
|
|
2324
|
+
name: '국립김해박물관 언론보도자료',
|
|
2325
|
+
url: 'https://gimhae.museum.go.kr/kr/html/sub04/0402.html',
|
|
2326
|
+
parseList: (html) => parseGimhaeMuseumList(html, '/kr/html/sub04/0402.html'),
|
|
2327
|
+
parseDetail: parseGimhaeMuseumDetail,
|
|
2328
|
+
},
|
|
2329
|
+
{
|
|
2330
|
+
id: '국립제주박물관_새소식',
|
|
2331
|
+
name: '국립제주박물관 새소식',
|
|
2332
|
+
url: 'https://jeju.museum.go.kr/_prog/_board/?code=sub02_0201&site_dvs_cd=kr&menu_dvs_cd=050101&ntt_tag=1',
|
|
2333
|
+
parseList: parseJejuMuseumList,
|
|
2334
|
+
parseDetail: parseJejuMuseumDetail,
|
|
2335
|
+
},
|
|
2336
|
+
{
|
|
2337
|
+
id: '국립익산박물관_공지사항',
|
|
2338
|
+
name: '국립익산박물관 공지사항',
|
|
2339
|
+
url: 'https://iksan.museum.go.kr/kor/html/sub05/0501.html',
|
|
2340
|
+
parseList: parseIksanMuseumList,
|
|
2341
|
+
parseDetail: parseIksanMuseumDetail,
|
|
1629
2342
|
},
|
|
1630
|
-
// NOTE: Parsing logic is implemented, but crawling is restricted by robots.txt policy
|
|
1631
|
-
// {
|
|
1632
|
-
// id: '국립경주박물관_새소식',
|
|
1633
|
-
// name: '국립경주박물관 새소식',
|
|
1634
|
-
// url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0701.html',
|
|
1635
|
-
// parseList: (html) =>
|
|
1636
|
-
// parseGyeongjuMuseumList(html, '/kor/html/sub07/0701.html'),
|
|
1637
|
-
// parseDetail: parseGyeongjuMuseumDetail,
|
|
1638
|
-
// },
|
|
1639
|
-
// {
|
|
1640
|
-
// id: '국립경주박물관_고시공고',
|
|
1641
|
-
// name: '국립경주박물관 고시/공고',
|
|
1642
|
-
// url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0703.html',
|
|
1643
|
-
// parseList: parseGyeongjuMuseumNoticeList,
|
|
1644
|
-
// parseDetail: parseGyeongjuMuseumDetail,
|
|
1645
|
-
// },
|
|
1646
|
-
// {
|
|
1647
|
-
// id: '국립경주박물관_보도자료',
|
|
1648
|
-
// name: '국립경주박물관 보도자료',
|
|
1649
|
-
// url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0705.html',
|
|
1650
|
-
// parseList: (html) =>
|
|
1651
|
-
// parseGyeongjuMuseumList(html, '/kor/html/sub07/0705.html'),
|
|
1652
|
-
// parseDetail: parseGyeongjuMuseumDetail,
|
|
1653
|
-
// },
|
|
1654
|
-
// {
|
|
1655
|
-
// id: '국립청주박물관_새소식',
|
|
1656
|
-
// name: '국립청주박물관 새소식',
|
|
1657
|
-
// url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=1&key=482&nbar=s',
|
|
1658
|
-
// parseList: parseCheongjuMuseumList,
|
|
1659
|
-
// parseDetail: parseCheongjuMuseumDetail,
|
|
1660
|
-
// },
|
|
1661
|
-
// {
|
|
1662
|
-
// id: '국립청주박물관_언론보도자료',
|
|
1663
|
-
// name: '국립청주박물관 언론보도자료',
|
|
1664
|
-
// url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=20&key=31&nbar=s',
|
|
1665
|
-
// parseList: parseCheongjuMuseumList,
|
|
1666
|
-
// parseDetail: parseCheongjuMuseumDetail,
|
|
1667
|
-
// },
|
|
1668
|
-
// {
|
|
1669
|
-
// id: '국립김해박물관_새소식',
|
|
1670
|
-
// name: '국립김해박물관 새소식',
|
|
1671
|
-
// url: 'https://gimhae.museum.go.kr/kr/html/sub04/0401.html',
|
|
1672
|
-
// parseList: (html) =>
|
|
1673
|
-
// parseGimhaeMuseumList(html, '/kr/html/sub04/0401.html'),
|
|
1674
|
-
// parseDetail: parseGimhaeMuseumDetail,
|
|
1675
|
-
// },
|
|
1676
|
-
// {
|
|
1677
|
-
// id: '국립김해박물관_보도자료',
|
|
1678
|
-
// name: '국립김해박물관 언론보도자료',
|
|
1679
|
-
// url: 'https://gimhae.museum.go.kr/kr/html/sub04/0402.html',
|
|
1680
|
-
// parseList: (html) =>
|
|
1681
|
-
// parseGimhaeMuseumList(html, '/kr/html/sub04/0402.html'),
|
|
1682
|
-
// parseDetail: parseGimhaeMuseumDetail,
|
|
1683
|
-
// },
|
|
1684
|
-
// {
|
|
1685
|
-
// id: '국립제주박물관_새소식',
|
|
1686
|
-
// name: '국립제주박물관 새소식',
|
|
1687
|
-
// url: 'https://jeju.museum.go.kr/_prog/_board/?code=sub02_0201&site_dvs_cd=kr&menu_dvs_cd=050101&ntt_tag=1',
|
|
1688
|
-
// parseList: parseJejuMuseumList,
|
|
1689
|
-
// parseDetail: parseJejuMuseumDetail,
|
|
1690
|
-
// },
|
|
1691
|
-
// {
|
|
1692
|
-
// id: '국립익산박물관_공지사항',
|
|
1693
|
-
// name: '국립익산박물관 공지사항',
|
|
1694
|
-
// url: 'https://iksan.museum.go.kr/kor/html/sub05/0501.html',
|
|
1695
|
-
// parseList: parseIksanMuseumList,
|
|
1696
|
-
// parseDetail: parseIksanMuseumDetail,
|
|
1697
|
-
// },
|
|
1698
2343
|
],
|
|
1699
2344
|
},
|
|
1700
2345
|
{
|
|
@@ -1784,29 +2429,46 @@ function createCrawlingTargetGroups(customFetch) {
|
|
|
1784
2429
|
parseList: (html) => parseBuyeoMuseumList(html, '2301270001'),
|
|
1785
2430
|
parseDetail: parseBuyeoMuseumDetail,
|
|
1786
2431
|
},
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
2432
|
+
{
|
|
2433
|
+
id: '국립경주박물관_채용안내',
|
|
2434
|
+
name: '국립경주박물관 채용안내',
|
|
2435
|
+
url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0704.html',
|
|
2436
|
+
parseList: (html) => parseGyeongjuMuseumList(html, '/kor/html/sub07/0704.html'),
|
|
2437
|
+
parseDetail: parseGyeongjuMuseumDetail,
|
|
2438
|
+
},
|
|
2439
|
+
{
|
|
2440
|
+
id: '국립청주박물관_채용및공고',
|
|
2441
|
+
name: '국립청주박물관 채용 및 공고',
|
|
2442
|
+
url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=29&key=476&nbar=s',
|
|
2443
|
+
parseList: parseCheongjuMuseumList,
|
|
2444
|
+
parseDetail: parseCheongjuMuseumDetail,
|
|
2445
|
+
},
|
|
2446
|
+
{
|
|
2447
|
+
id: '국립제주박물관_채용정보',
|
|
2448
|
+
name: '국립제주박물관 채용정보',
|
|
2449
|
+
url: 'https://jeju.museum.go.kr/_prog/_board/?code=sub02_0201&site_dvs_cd=kr&menu_dvs_cd=050102&ntt_tag=2',
|
|
2450
|
+
parseList: parseJejuMuseumList,
|
|
2451
|
+
parseDetail: parseJejuMuseumDetail,
|
|
2452
|
+
},
|
|
2453
|
+
// Served from data.go.kr open APIs rather than scraped. Both boards
|
|
2454
|
+
// carry every public-sector vacancy in the country, so their parsers
|
|
2455
|
+
// apply the heritage filter in `src/crawling/heritage-job-filter.ts`
|
|
2456
|
+
// before anything reaches analysis. Without an API key they yield
|
|
2457
|
+
// nothing and make no request.
|
|
2458
|
+
{
|
|
2459
|
+
id: '나라일터_채용공고',
|
|
2460
|
+
name: '나라일터 채용공고',
|
|
2461
|
+
url: GOJOBS_LIST_URL,
|
|
2462
|
+
parseList: parseGojobsList,
|
|
2463
|
+
parseDetail: parseGojobsDetail,
|
|
2464
|
+
},
|
|
2465
|
+
{
|
|
2466
|
+
id: '알리오_공공기관_채용공고',
|
|
2467
|
+
name: '알리오 공공기관 채용공고',
|
|
2468
|
+
url: ALIO_LIST_URL,
|
|
2469
|
+
parseList: parseAlioList,
|
|
2470
|
+
parseDetail: parseAlioDetail,
|
|
2471
|
+
},
|
|
1810
2472
|
],
|
|
1811
2473
|
},
|
|
1812
2474
|
];
|
|
@@ -1841,6 +2503,44 @@ const newsletterConfig = {
|
|
|
1841
2503
|
priorityArticleScoreThreshold: 8,
|
|
1842
2504
|
},
|
|
1843
2505
|
};
|
|
2506
|
+
/**
|
|
2507
|
+
* Maximum importance score per heritage domain (`tag1`).
|
|
2508
|
+
*
|
|
2509
|
+
* The newsletter is archaeology-first: archaeology and cultural heritage keep
|
|
2510
|
+
* the full 1-10 range, while natural and intangible heritage are capped so they
|
|
2511
|
+
* cannot crowd out archaeological coverage. `기타` — material that is not
|
|
2512
|
+
* heritage at all — is capped lowest, because the scoring prompt's academic-value
|
|
2513
|
+
* floor is domain-blind and would otherwise lift things like a general journal's
|
|
2514
|
+
* call for papers into the top half of the scale.
|
|
2515
|
+
*
|
|
2516
|
+
* The cap is applied deterministically after scoring, in
|
|
2517
|
+
* `AnalysisProvider.update()`, rather than asked for in the prompt, so the
|
|
2518
|
+
* ceiling always holds. It never reaches 1: a score of 1 means "exclude from the
|
|
2519
|
+
* newsletter" in the consuming application's candidate query, so capping to 1
|
|
2520
|
+
* would delete these articles instead of demoting them.
|
|
2521
|
+
*
|
|
2522
|
+
* Domains absent from this map are not capped.
|
|
2523
|
+
*/
|
|
2524
|
+
const maximumImportanceScoreByDomain = {
|
|
2525
|
+
자연유산: 6,
|
|
2526
|
+
무형유산: 6,
|
|
2527
|
+
기타: 5,
|
|
2528
|
+
};
|
|
2529
|
+
/**
|
|
2530
|
+
* Origins exempted from the robots.txt check.
|
|
2531
|
+
*
|
|
2532
|
+
* Each entry deliberately overrides what the site publishes, so it needs a
|
|
2533
|
+
* reason and should be revisited when that site's robots.txt changes.
|
|
2534
|
+
*
|
|
2535
|
+
* - `http://www.yngogo.or.kr` (영남고고학회): its board is rendered from
|
|
2536
|
+
* `/module/ntt/unity/selectNttListAjax.ink`, and robots.txt carries a blanket
|
|
2537
|
+
* `Disallow: /module`. The rule reads as protecting an internal path rather
|
|
2538
|
+
* than the public board it happens to serve, and there is no other route to
|
|
2539
|
+
* the listing, so the society's boards are collected under this exemption.
|
|
2540
|
+
*/
|
|
2541
|
+
const robotsExemptOrigins = [
|
|
2542
|
+
'http://www.yngogo.or.kr',
|
|
2543
|
+
];
|
|
1844
2544
|
/**
|
|
1845
2545
|
* LLM configuration
|
|
1846
2546
|
*/
|
|
@@ -1852,6 +2552,478 @@ const llmConfig = {
|
|
|
1852
2552
|
},
|
|
1853
2553
|
};
|
|
1854
2554
|
|
|
2555
|
+
/**
|
|
2556
|
+
* Fixed vocabulary for `tag1`, the heritage domain of an article.
|
|
2557
|
+
*
|
|
2558
|
+
* Downstream scoring depends on this being a closed set: importance weighting
|
|
2559
|
+
* ranks archaeology first and cultural heritage second, and natural/intangible
|
|
2560
|
+
* heritage are capped. A free-form tag cannot drive either rule reliably, so
|
|
2561
|
+
* `tag1` is constrained here while `tag2`/`tag3` stay open.
|
|
2562
|
+
*
|
|
2563
|
+
* `기타` exists so that non-heritage material — catering or security job
|
|
2564
|
+
* postings, general administration — is labelled honestly instead of being
|
|
2565
|
+
* forced into a heritage domain it does not belong to.
|
|
2566
|
+
*/
|
|
2567
|
+
const HERITAGE_DOMAIN_TAGS = [
|
|
2568
|
+
'고고학',
|
|
2569
|
+
'문화유산',
|
|
2570
|
+
'자연유산',
|
|
2571
|
+
'무형유산',
|
|
2572
|
+
'기타',
|
|
2573
|
+
];
|
|
2574
|
+
/** Narrows an arbitrary tag value to the fixed vocabulary. */
|
|
2575
|
+
function toHeritageDomainTag(tag) {
|
|
2576
|
+
const normalized = tag?.trim();
|
|
2577
|
+
return HERITAGE_DOMAIN_TAGS.includes(normalized)
|
|
2578
|
+
? normalized
|
|
2579
|
+
: null;
|
|
2580
|
+
}
|
|
2581
|
+
const DOMAIN_RULES = `## tag1 — 유산 영역 (고정 어휘, 아래 5개 중 정확히 하나)
|
|
2582
|
+
|
|
2583
|
+
- **고고학**: 발굴조사, 매장유산, 유적·유물 조사, 발굴현장 공개, 발굴조사보고서, 고고학 학술대회·학회 소식
|
|
2584
|
+
- **문화유산**: 건조물·미술·기록 등 유형유산, 국보·보물 지정과 보존처리, 박물관 전시·소장품, 유산 정책·제도
|
|
2585
|
+
- **자연유산**: 천연기념물, 명승, 지질유산, 유산으로서의 동식물
|
|
2586
|
+
- **무형유산**: 전통 기예·의례·공연, 보유자·전승자, 전승 교육
|
|
2587
|
+
- **기타**: 위 넷 중 어디에도 해당하지 않는 내용. 유산과 무관한 채용(조리·방호·시설관리 등), 일반 행정·회계 공고가 여기 해당한다.
|
|
2588
|
+
|
|
2589
|
+
여러 영역에 걸치면 **고고학 > 문화유산 > 자연유산 > 무형유산** 순으로 앞선 것을 고른다.
|
|
2590
|
+
예: 매장유산 발굴 성과를 다루는 국립박물관 전시 기사는 \`고고학\`.
|
|
2591
|
+
|
|
2592
|
+
tag1은 반드시 이 5개 문자열 중 하나여야 한다. 변형·수식·조합은 허용하지 않는다.`;
|
|
2593
|
+
/**
|
|
2594
|
+
* Replaces core's default tag prompt.
|
|
2595
|
+
*
|
|
2596
|
+
* Core's default asks for three free-form tags. This version pins `tag1` to the
|
|
2597
|
+
* heritage domain vocabulary and keeps the default's reuse behaviour for the
|
|
2598
|
+
* remaining two, so the existing tag pool stays consistent.
|
|
2599
|
+
*/
|
|
2600
|
+
const classifyTagsPrompt = {
|
|
2601
|
+
system: ({ outputLanguage }) => `당신은 한국 문화유산 분야 기사를 분류하는 전문가다. 기사마다 태그 3개를 매긴다.
|
|
2602
|
+
|
|
2603
|
+
${DOMAIN_RULES}
|
|
2604
|
+
|
|
2605
|
+
## tag2, tag3 — 주제 태그 (자유 어휘)
|
|
2606
|
+
|
|
2607
|
+
- 기사의 구체적인 주제를 나타낸다. 예: 발굴현장공개, 학술대회, 채용, 보존처리, 특별전
|
|
2608
|
+
- 제공된 기존 태그 목록과 80% 이상 들어맞으면 그 태그를 그대로 재사용한다. 분류 체계가 흩어지지 않게 하는 것이 새 태그를 만드는 것보다 중요하다.
|
|
2609
|
+
- 80%에 못 미칠 때만 새로 만든다. 새 태그는 비슷한 기사 여러 건에 두루 쓰일 수 있어야 한다.
|
|
2610
|
+
- 길이 3~15자, ${outputLanguage}로 작성한다.
|
|
2611
|
+
- tag1의 5개 어휘(고고학, 문화유산, 자연유산, 무형유산, 기타)는 너무 포괄적이므로 tag2, tag3에 쓰지 않는다.
|
|
2612
|
+
- tag2와 tag3은 서로 달라야 한다.`,
|
|
2613
|
+
user: ({ targetArticle, existTags }) => `아래 기사를 분류하라.
|
|
2614
|
+
|
|
2615
|
+
## 기사
|
|
2616
|
+
|
|
2617
|
+
- 제목: ${targetArticle.title}
|
|
2618
|
+
- 본문:
|
|
2619
|
+
${targetArticle.detailContent}
|
|
2620
|
+
|
|
2621
|
+
## 기존 태그 목록 (tag2, tag3 재사용 후보)
|
|
2622
|
+
|
|
2623
|
+
\`\`\`json
|
|
2624
|
+
${JSON.stringify(existTags, null, 2)}
|
|
2625
|
+
\`\`\`
|
|
2626
|
+
|
|
2627
|
+
## 출력
|
|
2628
|
+
|
|
2629
|
+
- tag1: 유산 영역. 고고학, 문화유산, 자연유산, 무형유산, 기타 중 정확히 하나.
|
|
2630
|
+
- tag2: 주제 태그.
|
|
2631
|
+
- tag3: 주제 태그. tag2와 다른 것.`,
|
|
2632
|
+
};
|
|
2633
|
+
|
|
2634
|
+
/**
|
|
2635
|
+
* Resolves the score floor core would apply for this article's board.
|
|
2636
|
+
*
|
|
2637
|
+
* `minimumImportanceScoreRules` is matched by `targetUrl` and raises the bottom
|
|
2638
|
+
* of the scale for boards that only ever carry relevant material. Core's default
|
|
2639
|
+
* prompt also drops the "score 1 = no practical value" tier and the temporal
|
|
2640
|
+
* expiry rule whenever a floor is in effect, since neither can be expressed
|
|
2641
|
+
* below the floor. The replacement below reproduces that.
|
|
2642
|
+
*/
|
|
2643
|
+
function resolveMinimumScore({ targetArticle, minimumImportanceScoreRules, }) {
|
|
2644
|
+
const rule = minimumImportanceScoreRules.find(({ targetUrl }) => targetUrl === targetArticle.targetUrl);
|
|
2645
|
+
return rule?.minScore ?? 1;
|
|
2646
|
+
}
|
|
2647
|
+
const DOMAIN_PRIORITY = `## 유산 영역 우선순위
|
|
2648
|
+
|
|
2649
|
+
이 뉴스레터는 고고학을 중심에 둔다. 사안의 객관적 무게가 비슷하다면 아래 순서로 점수를 준다.
|
|
2650
|
+
|
|
2651
|
+
1. **고고학** — 발굴조사, 매장유산, 유적·유물, 발굴현장 공개, 고고학 학술 활동
|
|
2652
|
+
2. **문화유산** — 유형유산 지정·보존, 박물관 활동, 유산 정책
|
|
2653
|
+
3. **자연유산 / 무형유산**
|
|
2654
|
+
|
|
2655
|
+
판단이 애매할 때 고고학 기사는 고려 중인 점수대의 위쪽을, 자연유산·무형유산 기사는 아래쪽을 택한다.
|
|
2656
|
+
영역은 tag1에 표기되어 있다. 단, 영역만으로 점수를 정하지는 않는다 — 사안 자체가 미미한 고고학 기사보다
|
|
2657
|
+
중대한 문화유산 기사가 높은 점수를 받는 것이 옳다.`;
|
|
2658
|
+
const EMPLOYMENT_FILTER = `## 채용 공고 판별
|
|
2659
|
+
|
|
2660
|
+
채용 공고는 직무가 유산 분야인지 먼저 확인한다.
|
|
2661
|
+
|
|
2662
|
+
- 유산 분야: 학예연구, 발굴조사, 보존처리, 유산 행정·연구직 등 → 아래 점수 기준을 정상 적용
|
|
2663
|
+
- **비유산 분야: 조리, 방호·경비, 시설관리, 청소, 운전, 일반 사무보조 등 → 점수 1**
|
|
2664
|
+
기관이 박물관·연구소라는 이유만으로 유산 분야로 보지 않는다. 직무 내용으로 판단한다.
|
|
2665
|
+
- 한 공고에 두 분야가 섞여 있으면 유산 분야 직무를 기준으로 평가한다.`;
|
|
2666
|
+
function scoreScale(minimumScore) {
|
|
2667
|
+
const tiers = [
|
|
2668
|
+
'10: 분야 전체에 즉각적이고 중대한 영향 — 주요 법령 통과, 대규모 예산 배정, 학계를 바꾸는 발견',
|
|
2669
|
+
'8-9: 다수 이해관계자에게 중요한 영향 — 주요 정책 변화, 중요 성과 공개, 대형 사업 발표',
|
|
2670
|
+
'7-8: 특정 분야의 중요한 학술·실무 성과 — 학술지 발간, 연구 성과 발표, 보고서 간행, 주요 학술행사, 중요 자원 지정, 중규모 입찰',
|
|
2671
|
+
'5-6: 특정 분야·지역에 한정된 일반적 중요 정보 — 소규모 사업 허가, 일반 행사 공지, 소규모 입찰',
|
|
2672
|
+
'4-5: 일반적인 분야 소식, 중소 규모 행사',
|
|
2673
|
+
'2-3: 단순 정보 공유, 반복적인 일상 소식',
|
|
2674
|
+
];
|
|
2675
|
+
if (minimumScore === 1) {
|
|
2676
|
+
tiers.push('1: **현재 실무 가치가 없는 정보** — 종료된 지원사업, 지난 행사, 만료된 입찰·채용 공고, 회비 납부 현황·회의록·내부 일정 같은 단순 행정 공지, 그리고 위 채용 판별에서 걸러진 비유산 분야 채용');
|
|
2677
|
+
}
|
|
2678
|
+
return `## 점수 기준 (${minimumScore}-10)\n\n${tiers.join('\n')}`;
|
|
2679
|
+
}
|
|
2680
|
+
const EVALUATION_AXES = `## 평가 축
|
|
2681
|
+
|
|
2682
|
+
- **학술 가치**: 학술지 발간, 연구보고서, 학술대회·심포지엄, 연구 성과 발표는 최소 7점 (지식 기반 확장과 장기 참조 가치)
|
|
2683
|
+
- **실무 영향**: 정책, 규정, 입찰, 채용처럼 즉각 대응이 필요한 정보
|
|
2684
|
+
- **영향 범위**: 영향받는 이해관계자의 수
|
|
2685
|
+
- **희소성**: 정보의 희귀성과 독점성`;
|
|
2686
|
+
function temporalRule$1(minimumScore) {
|
|
2687
|
+
if (minimumScore > 1) {
|
|
2688
|
+
return '';
|
|
2689
|
+
}
|
|
2690
|
+
return `
|
|
2691
|
+
## 시간 유효성 (HARD RULE)
|
|
2692
|
+
|
|
2693
|
+
- 기사에 적힌 마감일, 접수 기간, 행사일, 입찰 마감, 채용 기간, 유효 기간을 뉴스레터 발행일과 비교한다.
|
|
2694
|
+
- 이미 지났다면 다른 기준과 무관하게 **1점**. 이 규칙이 다른 모든 고려사항에 우선한다.
|
|
2695
|
+
- 이 규칙은 **독자가 기한 안에 행동해야 하는 안내**에만 적용한다. 신청·접수·응모·입찰·참가 모집이 그렇다.
|
|
2696
|
+
- 예외 ①: 학술 성과(발간된 학술지, 공개된 연구, 종료된 학술대회 자료)는 참조 가치로 평가하며 깎지 않는다.
|
|
2697
|
+
- 예외 ②: **이미 일어난 일을 전하는 보도·발표 기사**는 깎지 않는다. 지정·선정 결과, 조사 성과, 협약·기증·개최 소식처럼
|
|
2698
|
+
독자가 알아두면 되는 내용이면 행사일이 지났더라도 사안의 무게대로 평가한다.
|
|
2699
|
+
(판단 기준: 기사가 독자에게 무엇을 하라고 요구하는가? 아무 행동도 요구하지 않는다면 이 규칙의 대상이 아니다.)
|
|
2700
|
+
- 마감·일정 언급이 전혀 없으면 이 규칙은 적용하지 않는다.
|
|
2701
|
+
`;
|
|
2702
|
+
}
|
|
2703
|
+
/**
|
|
2704
|
+
* Replaces core's default importance prompt.
|
|
2705
|
+
*
|
|
2706
|
+
* Adds the archaeology-first weighting and the non-heritage employment filter,
|
|
2707
|
+
* and drops core's star-rating vocabulary, which the newsletter no longer
|
|
2708
|
+
* renders. The hard ceiling on natural and intangible heritage is **not** here:
|
|
2709
|
+
* it is applied deterministically after scoring, in `AnalysisProvider.update()`,
|
|
2710
|
+
* so the two never disagree.
|
|
2711
|
+
*/
|
|
2712
|
+
const determineImportancePrompt = {
|
|
2713
|
+
system: (context) => {
|
|
2714
|
+
const minimumScore = resolveMinimumScore(context);
|
|
2715
|
+
return `당신은 한국 문화유산 분야의 중요도 평가 전문가다. 기사의 제목과 본문을 분석해 중요도를 점수로 매긴다.
|
|
2716
|
+
|
|
2717
|
+
주 독자는 연구기관 연구원, 지자체·공공기관 담당자, 대학원생, 현장 전문가다. 긴급성·영향력·희소성을 기준으로 평가한다.
|
|
2718
|
+
|
|
2719
|
+
${DOMAIN_PRIORITY}
|
|
2720
|
+
|
|
2721
|
+
${EMPLOYMENT_FILTER}
|
|
2722
|
+
|
|
2723
|
+
${scoreScale(minimumScore)}
|
|
2724
|
+
|
|
2725
|
+
${EVALUATION_AXES}
|
|
2726
|
+
${temporalRule$1(minimumScore)}`;
|
|
2727
|
+
},
|
|
2728
|
+
user: (context) => {
|
|
2729
|
+
const minimumScore = resolveMinimumScore(context);
|
|
2730
|
+
const { targetArticle, dateService } = context;
|
|
2731
|
+
const publishedDate = targetArticle.publishedDate
|
|
2732
|
+
? `\n**기사 게시일:** ${targetArticle.publishedDate}`
|
|
2733
|
+
: '';
|
|
2734
|
+
const imageContext = targetArticle.imageContextByLlm
|
|
2735
|
+
? `\n\n**이미지 분석:** ${targetArticle.imageContextByLlm}`
|
|
2736
|
+
: '';
|
|
2737
|
+
const temporalCheck = minimumScore > 1
|
|
2738
|
+
? ''
|
|
2739
|
+
: '\n\n점수를 매기기 전에, 이 기사가 독자에게 기한 안에 행동할 것을 요구하는지 확인하라. 요구한다면 그 기한이 위 발행일 기준으로 지났는지 보고, 지났다면 1점이다. 이미 일어난 일을 전하는 보도라면 이 규칙을 적용하지 않는다.';
|
|
2740
|
+
return `아래 기사의 중요도를 ${minimumScore}부터 10까지로 평가하라.
|
|
2741
|
+
|
|
2742
|
+
**뉴스레터 발행일:** ${dateService.getPublicationISODateString()}${publishedDate}
|
|
2743
|
+
|
|
2744
|
+
**제목:** ${targetArticle.title || '제목 없음'}
|
|
2745
|
+
|
|
2746
|
+
**유산 영역(tag1):** ${targetArticle.tag1 || '미분류'}
|
|
2747
|
+
**주제 태그:** ${[targetArticle.tag2, targetArticle.tag3].filter(Boolean).join(', ') || '없음'}
|
|
2748
|
+
|
|
2749
|
+
**본문:**
|
|
2750
|
+
${targetArticle.detailContent || '내용 없음'}${imageContext}${temporalCheck}`;
|
|
2751
|
+
},
|
|
2752
|
+
};
|
|
2753
|
+
|
|
2754
|
+
/**
|
|
2755
|
+
* Category order. Categories listed here are emitted first, in this order;
|
|
2756
|
+
* anything else follows, ordered by how consequential it is.
|
|
2757
|
+
*/
|
|
2758
|
+
const LEADING_CATEGORIES = [
|
|
2759
|
+
'🎓 학술대회·학술행사',
|
|
2760
|
+
'📋 발굴조사보고서 (신규 제출)',
|
|
2761
|
+
'⛏️ 발굴현장 공개',
|
|
2762
|
+
'💼 채용·공고',
|
|
2763
|
+
];
|
|
2764
|
+
/**
|
|
2765
|
+
* Per-category table columns.
|
|
2766
|
+
*
|
|
2767
|
+
* Source pages carry far more fields than a newsletter table can hold — an
|
|
2768
|
+
* excavation report listing alone exposes a dozen (허가번호, 조사면적, 조사기간,
|
|
2769
|
+
* 주소 …) — and core's default prompt asked for a generic four-column layout
|
|
2770
|
+
* that overflows at email width. Naming the columns per category keeps the
|
|
2771
|
+
* tables narrow and stops the model from inventing extra ones.
|
|
2772
|
+
*/
|
|
2773
|
+
const TABLE_SPECS = `### 표 형식 (구조적 목록)
|
|
2774
|
+
|
|
2775
|
+
발굴조사보고서, 발굴현장 공개, 채용처럼 같은 형태가 반복되는 항목은 표로 낸다.
|
|
2776
|
+
열은 아래에 지정된 것만 쓴다. **원문에 다른 필드가 있어도 열을 추가하지 않는다.**
|
|
2777
|
+
|
|
2778
|
+
- **발굴조사보고서**: \`유적 | 소재지 | 성격·시대\`
|
|
2779
|
+
- 유적: 보고서명을 [원제목](URL) 링크로
|
|
2780
|
+
- 소재지: 시도 + 시군구까지만 (예: 경남 합천군). 상세 주소·지번은 쓰지 않는다
|
|
2781
|
+
- 성격·시대: 유적성격/시대구분 (예: 생활유적, 조선시대)
|
|
2782
|
+
- 허가번호, 조사면적, 조사기간, 제출일, 도로명·지번 주소는 **싣지 않는다**
|
|
2783
|
+
- **발굴현장 공개**: \`유적 | 소재지 | 공개일시\`
|
|
2784
|
+
- 유적: 공개 대상 유적명을 [원제목](URL) 링크로
|
|
2785
|
+
- **채용**: \`공고 | 기관 | 접수 마감\`
|
|
2786
|
+
- 공고: 공고명을 [원제목](URL) 링크로
|
|
2787
|
+
- 기관: 채용 기관명만 (예: 국립중앙박물관)
|
|
2788
|
+
- 접수 마감: 날짜와 시각. 합격자 발표처럼 마감이 없는 공고는 "—"
|
|
2789
|
+
|
|
2790
|
+
공통 규칙:
|
|
2791
|
+
|
|
2792
|
+
- **첫 열에만 [원제목](URL) 링크를 넣는다.** 나머지 열에는 링크나 긴 제목을 넣지 않고,
|
|
2793
|
+
지정된 값만 짧게 적는다.
|
|
2794
|
+
- 항목을 묶거나 "외 n건" 같은 표현으로 줄이지 않는다. 길어도 한 항목당 한 행으로 전부 싣는다.
|
|
2795
|
+
- 원문에 값이 없는 칸은 "—"로 채운다. 열 자체를 빼지 않는다.
|
|
2796
|
+
- 표 대신 글머리 기호나 번호 목록을 쓰지 않는다.`;
|
|
2797
|
+
const VOICE_RULES = `## 어투
|
|
2798
|
+
|
|
2799
|
+
- **모든 문장을 '습니다'체로 쓴다.** "~했다", "~이다" 같은 평서형 종결어미를 쓰지 않는다.
|
|
2800
|
+
("공고했다" → "공고했습니다", "예정이다" → "예정입니다", "선정됐다" → "선정됐습니다")
|
|
2801
|
+
표 안의 짧은 명사구는 예외로 둔다.
|
|
2802
|
+
- 신뢰할 만한 동료가 건네는 말투로 쓴다. 딱딱한 공문이나 보도자료 낭독이 아니다.
|
|
2803
|
+
독자가 왜 이 소식을 봐야 하는지, 무엇을 챙기면 되는지를 한 마디로 짚어준다.
|
|
2804
|
+
- 다만 과장하거나 들뜨지 않는다. 감탄사, 홍보성 수식어, 억지 친근함은 쓰지 않는다.
|
|
2805
|
+
- 단정보다 권유에 가깝게 쓴다. ("확인해야 한다" → "확인해두시면 좋겠습니다")`;
|
|
2806
|
+
const EDITORIAL_RULES = `## 편집 규칙
|
|
2807
|
+
|
|
2808
|
+
- **중요도 표시를 출력하지 않는다.** 별(★), 점수, "매우 중요" 같은 등급 표기를 본문 어디에도 쓰지 않는다. 중요도는 순서와 분량으로만 드러낸다.
|
|
2809
|
+
- **뉴스레터 제목(title)에는 이모지를 쓰지 않는다.** 본문 섹션 헤딩에는 쓴다(아래 참고).
|
|
2810
|
+
- **모든 \`##\` 섹션 헤딩은 이모지 하나로 시작한다.** 지정된 분류는 지정된 이모지를 그대로 쓰고,
|
|
2811
|
+
그 외 분류는 내용에 맞는 이모지를 하나 고른다. (예: 🏺 전시·국제교류, 📣 기타 공지, 🌿 보존·정비)
|
|
2812
|
+
한 호 안에서 같은 이모지를 두 번 쓰지 않는다.
|
|
2813
|
+
- 통계·비중 분석을 만들어내지 않는다. "오늘 소식의 00%가 ~" 같은 문장은 쓰지 않는다.
|
|
2814
|
+
- 소식을 언급할 때마다 [원제목](URL) 형식으로 링크한다. "자세히 보기", "기사", "[3번 글]" 같은 표기는 쓰지 않는다.
|
|
2815
|
+
- 날짜 범위는 물결표(~)가 아니라 붙임표(-)로 쓴다. 물결표는 마크다운에서 취소선이 된다.`;
|
|
2816
|
+
const LENGTH_CONTROL = `## 분량 (중요도 점수 기준, 점수 자체는 출력하지 않는다)
|
|
2817
|
+
|
|
2818
|
+
- **9-10점**: 핵심 사실을 **굵게** + 링크. 대상·범위, 일정·절차, 예산·규모를 필요한 만큼 풀어쓴다.
|
|
2819
|
+
- **6-8점**: **최대 3문장.** 핵심 사실 한 문장(굵게 + 링크) + 필요하면 마감·예산 같은 결정적 정보 한두 문장. 하위 항목이나 글머리 목록을 만들지 않는다.
|
|
2820
|
+
- **1-5점**: **한 문장.** 핵심 사실 + 링크. 여러 건은 한 목록으로 묶어도 된다.
|
|
2821
|
+
|
|
2822
|
+
구조적 목록(표)은 이 분량 제한과 무관하게 모든 항목을 싣는다.`;
|
|
2823
|
+
const FACT_RULES = `## 사실성
|
|
2824
|
+
|
|
2825
|
+
- 제공된 자료에 명시된 내용만 쓴다. 추론이나 추측으로 확장하지 않는다.
|
|
2826
|
+
- "~로 보인다", "~할 전망이다" 같은 추측 표현을 쓰지 않는다.
|
|
2827
|
+
- 자료에 없는 기관·정책·계획을 사실처럼 쓰지 않는다.
|
|
2828
|
+
- 이미지 분석 결과는 기사의 시각적 맥락을 파악하는 용도로만 쓴다. 거기 담긴 이름·수치·세부 사실을 본문에 인용하지 않는다.
|
|
2829
|
+
- 원문 표현을 그대로 옮기지 않는다. 사실만 추출해 새 문장으로 쓴다.`;
|
|
2830
|
+
function temporalRule(publicationDate) {
|
|
2831
|
+
return `## 시간 유효성 (HARD RULE)
|
|
2832
|
+
|
|
2833
|
+
발행일은 ${publicationDate}이다. 모든 기사에 대해:
|
|
2834
|
+
|
|
2835
|
+
- 이 규칙은 **독자가 기한 안에 행동해야 하는 안내**(신청·접수·응모·입찰·참가 모집)에만 적용한다.
|
|
2836
|
+
마감일, 접수 기간, 행사 참가 기한, 입찰 마감, 채용 기간이 발행일 기준으로 이미 지났다면
|
|
2837
|
+
중요도와 무관하게 본문에서 **완전히 제외**한다.
|
|
2838
|
+
- 예외 ①: 학술 성과(발간된 학술지, 공개된 연구, 종료된 학술대회 자료)는 참조 가치가 있으므로 남긴다.
|
|
2839
|
+
- 예외 ②: **이미 일어난 일을 전하는 보도·발표 기사**는 남긴다. 지정·선정 결과, 조사 성과, 협약·기증·개최 소식처럼
|
|
2840
|
+
독자에게 아무 행동도 요구하지 않는 기사는 행사일이 지났더라도 그대로 싣는다.
|
|
2841
|
+
- 기사 게시일이 발행일보다 30일 이상 앞서면 신선도를 의심하고, 마감이 남은 진행 중 사업처럼 여전히 앞을 내다보는 가치가 있을 때만 싣는다.
|
|
2842
|
+
- 제외한 기사는 "그 밖에 주목할 만한 소식"이나 표에도 언급하지 않는다.`;
|
|
2843
|
+
}
|
|
2844
|
+
function structure(context) {
|
|
2845
|
+
const { freeFormIntro, dateService } = context;
|
|
2846
|
+
const displayDate = dateService.getPublicationDisplayDateString();
|
|
2847
|
+
const opening = freeFormIntro
|
|
2848
|
+
? `1. **브리핑**: \`## 📮 ${displayDate} 브리핑\` 형식의 Heading 2로 시작한다. 분야 이름은 헤딩에 넣지 않는다.`
|
|
2849
|
+
: `1. **머리말**: \`# ${displayDate} 문화유산 소식\` 형식의 Heading 1으로 시작하고, 이어서 \`## 📮 ${displayDate} 브리핑\` 문단을 쓴다.`;
|
|
2850
|
+
return `## 구성
|
|
2851
|
+
|
|
2852
|
+
${opening}
|
|
2853
|
+
|
|
2854
|
+
브리핑은 **3-4문장, 짧고 강하게** 쓴다. 오늘 가장 무게 있는 소식 한두 건을 이름을 들어 짚고, 독자가 왜 지금 이것을 봐야 하는지 한 문장으로 말한다.
|
|
2855
|
+
통계, 비중, 항목 수 세기는 쓰지 않는다. 글머리 목록도 만들지 않는다. 구독 링크는 여기에 넣지 않는다.
|
|
2856
|
+
|
|
2857
|
+
2. **분류**: 아래 순서를 지킨다. 해당 소식이 없는 분류는 건너뛴다.
|
|
2858
|
+
|
|
2859
|
+
${LEADING_CATEGORIES.map((c, i) => ` ${i + 1}) ${c}`).join('\n')}
|
|
2860
|
+
${LEADING_CATEGORIES.length + 1}) 그 외 (정책·제도, 지정·보존, 전시·행사, 입찰·공고 등 내용에 맞게 묶는다)
|
|
2861
|
+
|
|
2862
|
+
각 분류는 Heading 2(\`##\`)를 쓴다. 분류 안에서는 중요한 것부터 배치한다.
|
|
2863
|
+
같은 내용이 여러 출처에서 왔다면 가장 자세한 것을 기준으로 한 번만 쓴다.
|
|
2864
|
+
|
|
2865
|
+
3. **마무리**: 마지막 섹션은 반드시 \`## 📌 마무리\`로 쓴다. 두 부분으로 구성한다.
|
|
2866
|
+
|
|
2867
|
+
가. 오늘 다룬 주요 소식을 한 문단으로 정리한다. 행사명 뒤에 날짜를 괄호로 덧붙인다.
|
|
2868
|
+
(예: "국립김해박물관 가야 특별전 개막(9.18.), 백제학회 학술대회(9.18.) 등을 다루었습니다.")
|
|
2869
|
+
|
|
2870
|
+
나. 이어서 \`**임박한 주요 일정 및 마감:**\` 줄을 넣고, 날짜순 글머리 목록을 만든다.
|
|
2871
|
+
한 줄에 한 날짜씩 묶고, 같은 날 여러 건이면 \` / \`로 잇는다.
|
|
2872
|
+
형식: \`- **2026년 9월 18일(금):** 국립김해박물관 특별전 개막 / 백제학회 학술대회\`
|
|
2873
|
+
마감 시각이 있으면 날짜 뒤에 붙인다. (예: \`- **2026년 9월 21일(월) 18:00:** …\`)
|
|
2874
|
+
본문에서 다룬 일정만 넣는다. 날짜가 없는 소식은 넣지 않는다.
|
|
2875
|
+
|
|
2876
|
+
다음 호 예고나 문의처는 쓰지 않는다.`;
|
|
2877
|
+
}
|
|
2878
|
+
function titleRules(context) {
|
|
2879
|
+
const { titleContext } = context;
|
|
2880
|
+
const common = `- 길이는 20-70자를 지킨다.
|
|
2881
|
+
- **이모지를 넣지 않는다.**
|
|
2882
|
+
- "뉴스레터" 같은 일반 명사 대신 구체적인 사실, 수치, 일정을 담는다.
|
|
2883
|
+
- '발표', '시행', '마감 임박'처럼 중립적이고 객관적인 표현을 쓴다.`;
|
|
2884
|
+
if (titleContext) {
|
|
2885
|
+
return `## 제목
|
|
2886
|
+
|
|
2887
|
+
- **"${titleContext}"가 제목에 반드시 그대로 들어가야 한다.** 오늘 본문의 핵심 맥락과 자연스럽게 결합해 완성된 제목을 만든다.
|
|
2888
|
+
${common}`;
|
|
2889
|
+
}
|
|
2890
|
+
return `## 제목
|
|
2891
|
+
|
|
2892
|
+
- 오늘 가장 중요한 소식 한두 건의 핵심 사실을 객관적으로 전달한다.
|
|
2893
|
+
- 가장 중요한 사실을 앞에 둔다.
|
|
2894
|
+
${common}`;
|
|
2895
|
+
}
|
|
2896
|
+
/**
|
|
2897
|
+
* Replaces core's default newsletter prompt.
|
|
2898
|
+
*
|
|
2899
|
+
* Core's default mandates emoticons in the title and section headings, renders
|
|
2900
|
+
* importance as star ratings, and asks for share-of-coverage statistics — all of
|
|
2901
|
+
* which this newsletter removes. Those instructions cannot be cancelled by
|
|
2902
|
+
* appending contradicting rules, so the prompt is written from scratch.
|
|
2903
|
+
*
|
|
2904
|
+
* It still has to satisfy core's fixed output schema, which drives a full
|
|
2905
|
+
* regeneration (capped at 5 attempts) when the model reports a failure:
|
|
2906
|
+
* a 20-70 character title, `isWrittenInOutputLanguage`, `copyrightVerified`,
|
|
2907
|
+
* `factAccuracy`, and the `titleContext` phrase when one is supplied.
|
|
2908
|
+
*/
|
|
2909
|
+
const generateNewsletterPrompt = {
|
|
2910
|
+
system: (context) => {
|
|
2911
|
+
const { newsletterBrandName, expertFields, outputLanguage, dateService, subscribePageUrl, } = context;
|
|
2912
|
+
const subscribeRule = subscribePageUrl
|
|
2913
|
+
? `\n\n## 공유 안내\n\n\`## 📌 마무리\` 섹션 **뒤에**, 본문 맨 마지막 줄로 링크를 한 번만 넣는다. 브리핑이나 본문 중간에는 넣지 않는다.
|
|
2914
|
+
|
|
2915
|
+
이 글을 읽는 사람은 **이미 구독자**다. 구독을 권하지 말고, 동료에게 **소개·공유**해달라고 청한다.
|
|
2916
|
+
("구독해보세요", "정기 수신을 신청하세요" 같은 표현은 쓰지 않는다.)
|
|
2917
|
+
|
|
2918
|
+
형식: 공유를 청하는 한 문장 + \`[${newsletterBrandName} 구독하기](${subscribePageUrl})\` 링크.
|
|
2919
|
+
(예: "이 소식이 도움이 되셨다면 동료 연구자에게도 소개해주시면 좋겠습니다. [${newsletterBrandName} 구독하기](${subscribePageUrl})")`
|
|
2920
|
+
: '';
|
|
2921
|
+
return `당신은 "${newsletterBrandName}"의 뉴스레터 편집자다. 독자는 ${expertFields.join(', ')} 분야의 연구자, 기관 담당자, 현장 전문가다.
|
|
2922
|
+
|
|
2923
|
+
바쁜 전문가가 2-3분 안에 핵심을 파악할 수 있도록, 사실 중심으로 간결하게 씁니다.
|
|
2924
|
+
|
|
2925
|
+
모든 내용은 ${outputLanguage}로 쓴다.
|
|
2926
|
+
|
|
2927
|
+
${VOICE_RULES}
|
|
2928
|
+
|
|
2929
|
+
${structure(context)}
|
|
2930
|
+
|
|
2931
|
+
${EDITORIAL_RULES}${subscribeRule}
|
|
2932
|
+
|
|
2933
|
+
${LENGTH_CONTROL}
|
|
2934
|
+
|
|
2935
|
+
${TABLE_SPECS}
|
|
2936
|
+
|
|
2937
|
+
${FACT_RULES}
|
|
2938
|
+
|
|
2939
|
+
${temporalRule(dateService.getPublicationDisplayDateString())}
|
|
2940
|
+
|
|
2941
|
+
${titleRules(context)}
|
|
2942
|
+
|
|
2943
|
+
## 출력 형식
|
|
2944
|
+
|
|
2945
|
+
본문은 마크다운으로 쓴다. 제목(#, ##), 굵게(**), 목록(-), 표를 활용한다.
|
|
2946
|
+
\`isWrittenInOutputLanguage\`, \`copyrightVerified\`, \`factAccuracy\`는 위 규칙을 모두 지켰을 때 true로 보고한다.`;
|
|
2947
|
+
},
|
|
2948
|
+
user: (context) => {
|
|
2949
|
+
const { targetArticles, dateService, expertFields } = context;
|
|
2950
|
+
const articles = targetArticles
|
|
2951
|
+
.map((article, index) => {
|
|
2952
|
+
const tags = [article.tag1, article.tag2, article.tag3]
|
|
2953
|
+
.filter(Boolean)
|
|
2954
|
+
.join(', ');
|
|
2955
|
+
const image = article.imageContextByLlm
|
|
2956
|
+
? `\n**이미지 분석(맥락 파악용, 세부 사실 인용 금지):** ${article.imageContextByLlm}`
|
|
2957
|
+
: '';
|
|
2958
|
+
const published = article.publishedDate
|
|
2959
|
+
? `\n**게시일:** ${article.publishedDate}`
|
|
2960
|
+
: '';
|
|
2961
|
+
return `## 소식 ${index + 1}
|
|
2962
|
+
**제목:** ${article.title}
|
|
2963
|
+
**URL:** ${article.url}
|
|
2964
|
+
**중요도:** ${article.importanceScore}/10
|
|
2965
|
+
**태그:** ${tags}
|
|
2966
|
+
**구분:** ${article.contentType}${published}${image}
|
|
2967
|
+
**본문:**
|
|
2968
|
+
${article.detailContent}`;
|
|
2969
|
+
})
|
|
2970
|
+
.join('\n\n');
|
|
2971
|
+
return `아래는 새로 수집된 ${expertFields.join(', ')} 관련 소식 전체다.
|
|
2972
|
+
|
|
2973
|
+
${articles}
|
|
2974
|
+
|
|
2975
|
+
---
|
|
2976
|
+
|
|
2977
|
+
**발행일:** ${dateService.getPublicationISODateString()} (${dateService.getPublicationDisplayDateString()})
|
|
2978
|
+
|
|
2979
|
+
위 소식으로 ${dateService.getPublicationDisplayDateString()} 자 뉴스레터를 작성하라.
|
|
2980
|
+
|
|
2981
|
+
먼저 시간 유효성 HARD RULE을 적용해 **신청·접수 기한이 지난 안내**를 제외하고, 남은 소식을 지정된 분류 순서대로 배치한다.
|
|
2982
|
+
이미 일어난 일을 전하는 보도 기사는 행사일이 지났더라도 제외하지 않는다.
|
|
2983
|
+
중요도 점수는 분량을 정하는 데만 쓰고 본문에 출력하지 않는다.
|
|
2984
|
+
본문은 '습니다'체로 쓰고, 모든 섹션 헤딩은 이모지로 시작하며, 공유 링크는 \`## 📌 마무리\` 뒤 맨 마지막 줄에 한 번만 넣는다.
|
|
2985
|
+
독자는 이미 구독자이므로 구독 권유가 아니라 동료에게 소개해달라는 문장으로 쓴다.`;
|
|
2986
|
+
},
|
|
2987
|
+
};
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* Research Radar's LLM prompt overrides.
|
|
2991
|
+
*
|
|
2992
|
+
* Each builder here **replaces** core's built-in prompt for that stage rather
|
|
2993
|
+
* than extending it — a `PromptBuilder` returns the whole prompt string. That is
|
|
2994
|
+
* deliberate: core's defaults instruct the model to use emoticons, render
|
|
2995
|
+
* importance as star ratings, and add share-of-coverage statistics, all of which
|
|
2996
|
+
* this newsletter removes. Appending contradicting rules to those defaults
|
|
2997
|
+
* degrades the output, so a replacement is written from scratch instead.
|
|
2998
|
+
*
|
|
2999
|
+
* **Contract a replacement must still satisfy.** Core's output schema is fixed,
|
|
3000
|
+
* and `generateNewsletter` regenerates the whole newsletter whenever the model
|
|
3001
|
+
* reports a failure. A replacement prompt has to steer the model toward:
|
|
3002
|
+
*
|
|
3003
|
+
* - `title`: 20–70 characters
|
|
3004
|
+
* - `isWrittenInOutputLanguage`, `copyrightVerified`, `factAccuracy`: true
|
|
3005
|
+
* - `titleContext` (KRAS mode): the phrase must appear in the title
|
|
3006
|
+
*
|
|
3007
|
+
* Core caps that loop at 5 attempts, so a prompt that ignores the contract costs
|
|
3008
|
+
* up to 5 full generations on the most expensive model in the pipeline.
|
|
3009
|
+
*
|
|
3010
|
+
* Tune these against real articles with core's playground
|
|
3011
|
+
* (`npm run playground:generate-newsletter` in ../llm-newsletter-kit-core) and
|
|
3012
|
+
* diff them against the defaults for free with `playground:verify-prompts`.
|
|
3013
|
+
* The playground loads this module from `dist`, so run `npm run build` here
|
|
3014
|
+
* after every edit — its source cannot be imported directly across repos
|
|
3015
|
+
* because of the `~/*` path alias.
|
|
3016
|
+
*/
|
|
3017
|
+
const researchRadarPromptProvider = {
|
|
3018
|
+
analysis: {
|
|
3019
|
+
classifyTags: classifyTagsPrompt,
|
|
3020
|
+
determineImportance: determineImportancePrompt,
|
|
3021
|
+
},
|
|
3022
|
+
contentGenerate: {
|
|
3023
|
+
generateNewsletter: generateNewsletterPrompt,
|
|
3024
|
+
},
|
|
3025
|
+
};
|
|
3026
|
+
|
|
1855
3027
|
/**
|
|
1856
3028
|
* Analysis provider implementation
|
|
1857
3029
|
* - LLM-based article analysis
|
|
@@ -1869,13 +3041,13 @@ class AnalysisProvider {
|
|
|
1869
3041
|
this.articleRepository = articleRepository;
|
|
1870
3042
|
this.tagRepository = tagRepository;
|
|
1871
3043
|
this.classifyTagOptions = {
|
|
1872
|
-
model: this.openai('gpt-5-
|
|
3044
|
+
model: this.openai('gpt-5.6-luna'),
|
|
1873
3045
|
};
|
|
1874
3046
|
this.analyzeImagesOptions = {
|
|
1875
|
-
model: this.openai('gpt-5.
|
|
3047
|
+
model: this.openai('gpt-5.6-terra'),
|
|
1876
3048
|
};
|
|
1877
3049
|
this.determineScoreOptions = {
|
|
1878
|
-
model: this.openai('gpt-5.
|
|
3050
|
+
model: this.openai('gpt-5.6-terra'),
|
|
1879
3051
|
minimumImportanceScoreRules: [
|
|
1880
3052
|
// Korean Archaeological Society news: minimum score 6
|
|
1881
3053
|
{
|
|
@@ -1981,8 +3153,28 @@ class AnalysisProvider {
|
|
|
1981
3153
|
* @param article - Article with analysis data
|
|
1982
3154
|
*/
|
|
1983
3155
|
async update(article) {
|
|
1984
|
-
await this.articleRepository.updateAnalysis(
|
|
3156
|
+
await this.articleRepository.updateAnalysis({
|
|
3157
|
+
...article,
|
|
3158
|
+
importanceScore: capScoreByHeritageDomain(article),
|
|
3159
|
+
});
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
/**
|
|
3163
|
+
* Applies the per-domain score ceiling from `maximumImportanceScoreByDomain`.
|
|
3164
|
+
*
|
|
3165
|
+
* The domain comes from `tag1`, which the tag classification prompt pins to a
|
|
3166
|
+
* fixed vocabulary. An off-vocabulary value means the classification did not
|
|
3167
|
+
* hold, so the article is left uncapped rather than capped on a guess.
|
|
3168
|
+
*/
|
|
3169
|
+
function capScoreByHeritageDomain(article) {
|
|
3170
|
+
const domain = toHeritageDomainTag(article.tag1);
|
|
3171
|
+
if (!domain) {
|
|
3172
|
+
return article.importanceScore;
|
|
1985
3173
|
}
|
|
3174
|
+
const maximumScore = maximumImportanceScoreByDomain[domain];
|
|
3175
|
+
return maximumScore === undefined
|
|
3176
|
+
? article.importanceScore
|
|
3177
|
+
: Math.min(article.importanceScore, maximumScore);
|
|
1986
3178
|
}
|
|
1987
3179
|
|
|
1988
3180
|
/**
|
|
@@ -2013,18 +3205,14 @@ const heripoLogoHtml = (imgMarginBottom) => `
|
|
|
2013
3205
|
<!--<![endif]-->
|
|
2014
3206
|
</div>`;
|
|
2015
3207
|
/**
|
|
2016
|
-
* Heripo
|
|
3208
|
+
* Heripo project introduction section.
|
|
2017
3209
|
* Shared between newsletter and welcome email templates.
|
|
2018
3210
|
*
|
|
2019
|
-
* Note: Each template may append its own additional paragraph after this block
|
|
2020
|
-
* (e.g., newsletter adds a line about source requests via GitHub Issues).
|
|
2021
3211
|
*/
|
|
2022
3212
|
const platformIntroHtml = () => `
|
|
2023
|
-
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">heripo는
|
|
2024
|
-
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;"
|
|
2025
|
-
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7;
|
|
2026
|
-
color: #444444; margin: 0 0 18px 0;">현재는 소프트웨어 엔지니어와 고고학 연구자가 함께하는 <strong><a href="https://github.com/heripo-lab" target="_blank">heripo lab</a></strong>으로 운영 중이며, 2026년 1월 28일 핵심 엔진을 <strong><a href="https://github.com/heripo-lab/heripo-engine" target="_blank">오픈소스로 공개</a></strong>했습니다.</p>
|
|
2027
|
-
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">오픈소스로 공개된 핵심 기능은 <strong><a href="https://engine-demo.heripo.org" target="_blank">데모 사이트</a></strong>에서 직접 체험해 보실 수 있으며, 플랫폼 프로토타입 출시 시 구독자분들께 우선 안내해 드리겠습니다.</p>`;
|
|
3213
|
+
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">heripo는 발굴조사보고서의 기록을 데이터로 전환하고 출처와 변환 경로를 함께 보존하기 위한 연구 인프라 프로젝트입니다. 지금 받아보시는 뉴스레터는 그 출발점이며, 관련 오픈소스 프로젝트는 <strong><a href="https://github.com/heripo-lab" target="_blank">heripo lab</a></strong>에서 공개하고 있습니다.</p>
|
|
3214
|
+
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">뉴스레터에 이어 오픈소스 <strong><a href="https://github.com/heripo-lab/heripo-engine" target="_blank">heripo engine</a></strong>을 바탕으로 발굴조사보고서의 기록을 데이터로 전환하고 이를 탐색하고 활용할 수 있는 연구 도구 <strong>heripo 베이스캠프</strong>를 준비 중입니다.</p>
|
|
3215
|
+
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">heripo 베이스캠프는 현재 초대 기반 비공개 알파 테스트 중입니다. 공개 테스터 모집 및 정식 출시 소식은 이 뉴스레터로 전해드리겠습니다.</p>`;
|
|
2028
3216
|
/**
|
|
2029
3217
|
* "Powered by LLM Newsletter Kit · View Source" footer line.
|
|
2030
3218
|
*/
|
|
@@ -2531,7 +3719,7 @@ ${options.heripolabNewsMarkdown}
|
|
|
2531
3719
|
: ''}
|
|
2532
3720
|
|
|
2533
3721
|
<h2 style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; font-weight: bold; line-height: 1.3; color: #D2691E; margin: 0 0 16px 0; letter-spacing: -0.2px; border-left: 5px solid #D2691E; padding-left: 12px; background: none;">🔍 뉴스레터 출처</h2>
|
|
2534
|
-
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;"
|
|
3722
|
+
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">이 뉴스레터가 수집하는 모든 소식의 출처는 <a href="https://heripo.app/research-radar/sources" target="_blank">이곳에서</a> 확인할 수 있습니다. 새롭게 포함했으면 하는 출처나 오류가 있다면 <a href="https://github.com/heripo-lab/heripo-research-radar/issues" target="_blank">GitHub 이슈</a>로 알려주세요.</p>
|
|
2535
3723
|
<hr style="border: 0; border-top: 2px solid #D2691E; margin: 32px 0;">
|
|
2536
3724
|
<h2 style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; font-weight: bold; line-height: 1.3; color: #D2691E; margin: 0 0 16px 0; letter-spacing: -0.2px; border-left: 5px solid #D2691E; padding-left: 12px; background: none;">📅 발행 정책</h2>
|
|
2537
3725
|
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;"><strong>${options?.isKrasNewsletter ? '한국고고학회 뉴스레터' : 'heripo 리서치 레이더'}</strong>는 매일 발행을 원칙으로 하되, 독자분들께 의미 있는 정보를 제공하기 위해 다음과 같은 발행 기준을 적용합니다:</p>
|
|
@@ -2542,10 +3730,8 @@ ${options.heripolabNewsMarkdown}
|
|
|
2542
3730
|
</ul>
|
|
2543
3731
|
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">이러한 정책을 통해 매일 의미 없는 소식으로 독자분들의 시간을 낭비하지 않고, 정말 중요한 정보를 적절한 타이밍에 제공하고자 합니다.</p>
|
|
2544
3732
|
<hr style="border: 0; border-top: 2px solid #D2691E; margin: 32px 0;">
|
|
2545
|
-
<h2 style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; font-weight: bold; line-height: 1.3; color: #D2691E; margin: 0 0 16px 0; letter-spacing: -0.2px; border-left: 5px solid #D2691E; padding-left: 12px; background: none;">🔍 heripo
|
|
3733
|
+
<h2 style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; font-weight: bold; line-height: 1.3; color: #D2691E; margin: 0 0 16px 0; letter-spacing: -0.2px; border-left: 5px solid #D2691E; padding-left: 12px; background: none;">🔍 heripo 프로젝트 소개</h2>
|
|
2546
3734
|
${platformIntroHtml()}
|
|
2547
|
-
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7;
|
|
2548
|
-
color: #444444; margin: 0 0 18px 0;">보고 계신 뉴스레터(리서치 레이더)는 heripo의 초기 선행 기능 중 하나입니다. 뉴스레터 소스 추가 요청은 <a href="https://github.com/heripo-lab/heripo-research-radar/issues" target="_blank">GitHub 이슈</a>를 통해 언제든 환영합니다.</p>
|
|
2549
3735
|
<hr style="border: 0; border-top: 2px solid #D2691E; margin: 32px 0;">
|
|
2550
3736
|
<h2 style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; font-weight: bold; line-height: 1.3; color: #D2691E; margin: 0 0 16px 0; letter-spacing: -0.2px; border-left: 5px solid #D2691E; padding-left: 12px; background: none;">⚠️ 중요 안내</h2>
|
|
2551
3737
|
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">본 뉴스레터는 국가유산청 공지사항, 관련 기관 입찰 정보 등 특정 웹 게시판의 모든 신규 소식을 빠짐없이 수집하여 제공합니다. 수집된 모든 정보는 정확한 크롤링 로직에 기반하므로 원본과 일치하여 신뢰할 수 있습니다.</p>
|
|
@@ -2640,6 +3826,231 @@ class ContentGenerateProvider {
|
|
|
2640
3826
|
}
|
|
2641
3827
|
}
|
|
2642
3828
|
|
|
3829
|
+
/**
|
|
3830
|
+
* robots.txt enforcement for crawling.
|
|
3831
|
+
*
|
|
3832
|
+
* Applied at the fetch layer, the same seam the KRAS and excavation-report
|
|
3833
|
+
* adapters use, so core's crawling pipeline is untouched: a disallowed request
|
|
3834
|
+
* is answered with 403 instead of being sent. Core treats a 4xx list response as
|
|
3835
|
+
* a failed fetch, logs `crawl.list.fetch.failed`, and continues with an empty
|
|
3836
|
+
* page, so a blocked target yields no articles rather than breaking the run.
|
|
3837
|
+
*/
|
|
3838
|
+
/**
|
|
3839
|
+
* Parses robots.txt into user-agent groups.
|
|
3840
|
+
*
|
|
3841
|
+
* Unknown fields (Sitemap, Crawl-delay, Host) and malformed lines are skipped —
|
|
3842
|
+
* 국가유산청 serves a `Disaloow:` typo, which must not be read as a rule.
|
|
3843
|
+
* Consecutive `User-agent:` lines share one rule block, per the spec.
|
|
3844
|
+
*/
|
|
3845
|
+
function parseRobotsTxt(text) {
|
|
3846
|
+
const groups = [];
|
|
3847
|
+
let current = null;
|
|
3848
|
+
let previousLineWasAgent = false;
|
|
3849
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
3850
|
+
const line = rawLine.split('#')[0].trim();
|
|
3851
|
+
if (!line) {
|
|
3852
|
+
continue;
|
|
3853
|
+
}
|
|
3854
|
+
const separator = line.indexOf(':');
|
|
3855
|
+
if (separator === -1) {
|
|
3856
|
+
continue;
|
|
3857
|
+
}
|
|
3858
|
+
const field = line.slice(0, separator).trim().toLowerCase();
|
|
3859
|
+
const value = line.slice(separator + 1).trim();
|
|
3860
|
+
if (field === 'user-agent') {
|
|
3861
|
+
if (!previousLineWasAgent || !current) {
|
|
3862
|
+
current = { agents: [], rules: [] };
|
|
3863
|
+
groups.push(current);
|
|
3864
|
+
}
|
|
3865
|
+
current.agents.push(value.toLowerCase());
|
|
3866
|
+
previousLineWasAgent = true;
|
|
3867
|
+
continue;
|
|
3868
|
+
}
|
|
3869
|
+
if (field === 'allow' || field === 'disallow') {
|
|
3870
|
+
current?.rules.push({ allow: field === 'allow', path: value });
|
|
3871
|
+
}
|
|
3872
|
+
previousLineWasAgent = false;
|
|
3873
|
+
}
|
|
3874
|
+
return groups;
|
|
3875
|
+
}
|
|
3876
|
+
/** Matches a robots path pattern, supporting `*` wildcards and a `$` anchor. */
|
|
3877
|
+
function matchesPattern(pattern, path) {
|
|
3878
|
+
if (pattern === '') {
|
|
3879
|
+
return false;
|
|
3880
|
+
}
|
|
3881
|
+
const anchored = pattern.endsWith('$');
|
|
3882
|
+
const body = anchored ? pattern.slice(0, -1) : pattern;
|
|
3883
|
+
const segments = body.split('*');
|
|
3884
|
+
let position = 0;
|
|
3885
|
+
for (const [index, segment] of segments.entries()) {
|
|
3886
|
+
if (index === 0) {
|
|
3887
|
+
if (!path.startsWith(segment)) {
|
|
3888
|
+
return false;
|
|
3889
|
+
}
|
|
3890
|
+
position = segment.length;
|
|
3891
|
+
continue;
|
|
3892
|
+
}
|
|
3893
|
+
if (segment === '') {
|
|
3894
|
+
// Trailing wildcard: anything left over matches, unless `$` demands the end.
|
|
3895
|
+
if (index === segments.length - 1) {
|
|
3896
|
+
return !anchored;
|
|
3897
|
+
}
|
|
3898
|
+
continue;
|
|
3899
|
+
}
|
|
3900
|
+
const found = path.indexOf(segment, position);
|
|
3901
|
+
if (found === -1) {
|
|
3902
|
+
return false;
|
|
3903
|
+
}
|
|
3904
|
+
position = found + segment.length;
|
|
3905
|
+
}
|
|
3906
|
+
return anchored ? position === path.length : true;
|
|
3907
|
+
}
|
|
3908
|
+
/** Picks the group for this user agent: a named match first, then `*`. */
|
|
3909
|
+
function selectGroup(groups, userAgent) {
|
|
3910
|
+
const normalized = userAgent.toLowerCase();
|
|
3911
|
+
const named = groups.find((group) => group.agents.some((agent) => agent !== '*' && normalized.includes(agent)));
|
|
3912
|
+
return named ?? groups.find((group) => group.agents.includes('*')) ?? null;
|
|
3913
|
+
}
|
|
3914
|
+
/**
|
|
3915
|
+
* Decides whether a path may be requested.
|
|
3916
|
+
*
|
|
3917
|
+
* The most specific matching rule wins; `Allow` wins a tie. A user agent with no
|
|
3918
|
+
* matching group, and a group with no matching rule, are both allowed.
|
|
3919
|
+
*
|
|
3920
|
+
* @param pathWithQuery - Path including the query string, e.g. `/bbs/list.do?key=1`
|
|
3921
|
+
*/
|
|
3922
|
+
function isPathAllowed(groups, userAgent, pathWithQuery) {
|
|
3923
|
+
const group = selectGroup(groups, userAgent);
|
|
3924
|
+
if (!group) {
|
|
3925
|
+
return { allowed: true };
|
|
3926
|
+
}
|
|
3927
|
+
let best = null;
|
|
3928
|
+
for (const rule of group.rules) {
|
|
3929
|
+
if (!matchesPattern(rule.path, pathWithQuery)) {
|
|
3930
|
+
continue;
|
|
3931
|
+
}
|
|
3932
|
+
if (!best ||
|
|
3933
|
+
rule.path.length > best.path.length ||
|
|
3934
|
+
(rule.path.length === best.path.length && rule.allow)) {
|
|
3935
|
+
best = rule;
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
if (!best || best.allow) {
|
|
3939
|
+
return { allowed: true };
|
|
3940
|
+
}
|
|
3941
|
+
return { allowed: false, rule: `Disallow: ${best.path}` };
|
|
3942
|
+
}
|
|
3943
|
+
function resolveRequestUrl(input) {
|
|
3944
|
+
if (typeof input === 'string') {
|
|
3945
|
+
return input;
|
|
3946
|
+
}
|
|
3947
|
+
return input instanceof URL ? input.href : input.url;
|
|
3948
|
+
}
|
|
3949
|
+
/** Reads the User-Agent from an outgoing request, whatever shape the headers take. */
|
|
3950
|
+
function resolveUserAgent(input, init) {
|
|
3951
|
+
const headers = init?.headers ?? (input instanceof Request ? input.headers : undefined);
|
|
3952
|
+
if (!headers) {
|
|
3953
|
+
return '*';
|
|
3954
|
+
}
|
|
3955
|
+
if (headers instanceof Headers) {
|
|
3956
|
+
return headers.get('user-agent') ?? '*';
|
|
3957
|
+
}
|
|
3958
|
+
if (Array.isArray(headers)) {
|
|
3959
|
+
const found = headers.find(([key]) => key.toLowerCase() === 'user-agent');
|
|
3960
|
+
return found?.[1] ?? '*';
|
|
3961
|
+
}
|
|
3962
|
+
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === 'user-agent');
|
|
3963
|
+
return entry?.[1] ?? '*';
|
|
3964
|
+
}
|
|
3965
|
+
/**
|
|
3966
|
+
* Builds a robots.txt gate: a verdict function and a fetch that enforces it,
|
|
3967
|
+
* sharing one per-origin cache.
|
|
3968
|
+
*
|
|
3969
|
+
* robots.txt is fetched once per origin and the in-flight promise is shared, so
|
|
3970
|
+
* concurrent requests to the same site cause a single lookup. The lookup itself
|
|
3971
|
+
* bypasses the check.
|
|
3972
|
+
*
|
|
3973
|
+
* **Fails open.** A missing (4xx), unreachable, or unparseable robots.txt allows
|
|
3974
|
+
* the request. A transient outage should not silently empty the newsletter, and
|
|
3975
|
+
* 404 already means "no restrictions" under the standard. Blocked requests are
|
|
3976
|
+
* reported through `onBlocked` rather than logged here.
|
|
3977
|
+
*/
|
|
3978
|
+
function createRobotsGate(baseFetch = fetch, options = {}) {
|
|
3979
|
+
const { onBlocked, timeoutMs = 10_000, exemptOrigins = [] } = options;
|
|
3980
|
+
const cache = new Map();
|
|
3981
|
+
const exempt = new Set(exemptOrigins.map((origin) => {
|
|
3982
|
+
try {
|
|
3983
|
+
return new URL(origin).origin;
|
|
3984
|
+
}
|
|
3985
|
+
catch {
|
|
3986
|
+
return origin;
|
|
3987
|
+
}
|
|
3988
|
+
}));
|
|
3989
|
+
const loadRobots = (origin) => {
|
|
3990
|
+
const cached = cache.get(origin);
|
|
3991
|
+
if (cached) {
|
|
3992
|
+
return cached;
|
|
3993
|
+
}
|
|
3994
|
+
const pending = (async () => {
|
|
3995
|
+
try {
|
|
3996
|
+
const response = await baseFetch(`${origin}/robots.txt`, {
|
|
3997
|
+
redirect: 'follow',
|
|
3998
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
3999
|
+
});
|
|
4000
|
+
if (!response.ok) {
|
|
4001
|
+
return [];
|
|
4002
|
+
}
|
|
4003
|
+
const body = await response.text();
|
|
4004
|
+
// Some sites answer robots.txt with their HTML 404 page.
|
|
4005
|
+
return body.trimStart().startsWith('<') ? [] : parseRobotsTxt(body);
|
|
4006
|
+
}
|
|
4007
|
+
catch {
|
|
4008
|
+
return [];
|
|
4009
|
+
}
|
|
4010
|
+
})();
|
|
4011
|
+
cache.set(origin, pending);
|
|
4012
|
+
return pending;
|
|
4013
|
+
};
|
|
4014
|
+
const isAllowed = async (requestUrl, userAgent = '*') => {
|
|
4015
|
+
let url;
|
|
4016
|
+
try {
|
|
4017
|
+
url = new URL(requestUrl);
|
|
4018
|
+
}
|
|
4019
|
+
catch {
|
|
4020
|
+
return { allowed: true };
|
|
4021
|
+
}
|
|
4022
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
4023
|
+
return { allowed: true };
|
|
4024
|
+
}
|
|
4025
|
+
if (url.pathname === '/robots.txt') {
|
|
4026
|
+
return { allowed: true };
|
|
4027
|
+
}
|
|
4028
|
+
if (exempt.has(url.origin)) {
|
|
4029
|
+
return { allowed: true };
|
|
4030
|
+
}
|
|
4031
|
+
const groups = await loadRobots(url.origin);
|
|
4032
|
+
return isPathAllowed(groups, userAgent, url.pathname + url.search);
|
|
4033
|
+
};
|
|
4034
|
+
const gatedFetch = async (input, init) => {
|
|
4035
|
+
const requestUrl = resolveRequestUrl(input);
|
|
4036
|
+
const userAgent = resolveUserAgent(input, init);
|
|
4037
|
+
const verdict = await isAllowed(requestUrl, userAgent);
|
|
4038
|
+
if (verdict.allowed) {
|
|
4039
|
+
return baseFetch(input, init);
|
|
4040
|
+
}
|
|
4041
|
+
onBlocked?.({ url: requestUrl, userAgent, rule: verdict.rule });
|
|
4042
|
+
return new Response(`Blocked by robots.txt (${verdict.rule}) - ${new URL(requestUrl).origin}/robots.txt`, { status: 403, statusText: 'Blocked by robots.txt' });
|
|
4043
|
+
};
|
|
4044
|
+
return { isAllowed, fetch: gatedFetch };
|
|
4045
|
+
}
|
|
4046
|
+
/**
|
|
4047
|
+
* Wraps a fetch so that requests disallowed by the origin's robots.txt are
|
|
4048
|
+
* refused with 403 instead of sent. See {@link createRobotsGate}.
|
|
4049
|
+
*/
|
|
4050
|
+
function createRobotsAwareFetch(baseFetch = fetch, options = {}) {
|
|
4051
|
+
return createRobotsGate(baseFetch, options).fetch;
|
|
4052
|
+
}
|
|
4053
|
+
|
|
2643
4054
|
/**
|
|
2644
4055
|
* Crawling provider implementation
|
|
2645
4056
|
* - Defines crawling targets
|
|
@@ -2654,9 +4065,32 @@ class CrawlingProvider {
|
|
|
2654
4065
|
customFetch;
|
|
2655
4066
|
/** Crawling target groups configuration */
|
|
2656
4067
|
crawlingTargetGroups;
|
|
2657
|
-
constructor(articleRepository, customFetch) {
|
|
4068
|
+
constructor(articleRepository, customFetch, excavationReportSource, logger, publicDataApiKey) {
|
|
2658
4069
|
this.articleRepository = articleRepository;
|
|
2659
|
-
|
|
4070
|
+
// robots.txt is checked first, so a disallowed request is never sent — not
|
|
4071
|
+
// even through a proxy. The injected and KRAS adapters sit inside it: their
|
|
4072
|
+
// requests either bypass the network entirely or are rewritten to a URL
|
|
4073
|
+
// that still gets checked.
|
|
4074
|
+
const withRobots = createRobotsAwareFetch(customFetch ?? fetch, {
|
|
4075
|
+
exemptOrigins: robotsExemptOrigins,
|
|
4076
|
+
onBlocked: ({ url, rule, userAgent }) => {
|
|
4077
|
+
logger?.info({
|
|
4078
|
+
event: 'crawl.robots.blocked',
|
|
4079
|
+
data: { url, rule, userAgent },
|
|
4080
|
+
});
|
|
4081
|
+
},
|
|
4082
|
+
});
|
|
4083
|
+
const withKras = createKrasFetch(withRobots);
|
|
4084
|
+
// The two public job boards are read from data.go.kr open APIs. Without a
|
|
4085
|
+
// key they answer with an empty list and make no request, so the targets
|
|
4086
|
+
// stay configured and simply collect nothing.
|
|
4087
|
+
const withPublicJobs = createAlioFetch(createGojobsFetch(withKras, { apiKey: publicDataApiKey ?? '' }), { apiKey: publicDataApiKey ?? '' });
|
|
4088
|
+
// When the application supplies excavation reports, that board is served
|
|
4089
|
+
// from the injected source and never requested over the network. Every
|
|
4090
|
+
// other target keeps going through the same fetch as before.
|
|
4091
|
+
this.customFetch = excavationReportSource
|
|
4092
|
+
? createExcavationReportFetch(withPublicJobs, excavationReportSource)
|
|
4093
|
+
: withPublicJobs;
|
|
2660
4094
|
this.crawlingTargetGroups = createCrawlingTargetGroups(this.customFetch);
|
|
2661
4095
|
}
|
|
2662
4096
|
/**
|
|
@@ -2817,7 +4251,7 @@ function createContentGenerationModel(config) {
|
|
|
2817
4251
|
switch (config.provider) {
|
|
2818
4252
|
case 'openai': {
|
|
2819
4253
|
const provider = createOpenAI({ apiKey: config.apiKey });
|
|
2820
|
-
return provider(config.model ?? 'gpt-5.
|
|
4254
|
+
return provider(config.model ?? 'gpt-5.6-sol');
|
|
2821
4255
|
}
|
|
2822
4256
|
case 'anthropic': {
|
|
2823
4257
|
const provider = createAnthropic({ apiKey: config.apiKey });
|
|
@@ -2835,7 +4269,7 @@ function createNewsletterGenerator(dependencies) {
|
|
|
2835
4269
|
});
|
|
2836
4270
|
const dateService = new DateService(dependencies.publishDate);
|
|
2837
4271
|
const taskService = new TaskService(dependencies.taskRepository);
|
|
2838
|
-
const crawlingProvider = new CrawlingProvider(dependencies.articleRepository, dependencies.customFetch);
|
|
4272
|
+
const crawlingProvider = new CrawlingProvider(dependencies.articleRepository, dependencies.customFetch, dependencies.excavationReportSource, dependencies.logger, dependencies.publicDataApiKey);
|
|
2839
4273
|
const analysisProvider = new AnalysisProvider(openai, dependencies.articleRepository, dependencies.tagRepository);
|
|
2840
4274
|
// Inject display date from DateService into template options
|
|
2841
4275
|
const templateOptions = dependencies.templateOptions
|
|
@@ -2859,6 +4293,7 @@ function createNewsletterGenerator(dependencies) {
|
|
|
2859
4293
|
const contentGenerateProvider = new ContentGenerateProvider(contentModel, dependencies.articleRepository, dependencies.newsletterRepository, templateOptions, resolvedBrandName);
|
|
2860
4294
|
return new GenerateNewsletter({
|
|
2861
4295
|
contentOptions: resolvedContentOptions,
|
|
4296
|
+
promptProvider: dependencies.promptProvider ?? researchRadarPromptProvider,
|
|
2862
4297
|
dateService,
|
|
2863
4298
|
taskService,
|
|
2864
4299
|
crawlingProvider,
|
|
@@ -2936,7 +4371,7 @@ async function generateWelcomeHTML(id, name, options) {
|
|
|
2936
4371
|
function createWelcomeHtmlRaw(name, isKras, siteUrl, unsubscribeUrl) {
|
|
2937
4372
|
const title = isKras
|
|
2938
4373
|
? '한국고고학회 뉴스레터 구독 완료'
|
|
2939
|
-
: 'heripo
|
|
4374
|
+
: 'heripo 뉴스레터 구독 완료';
|
|
2940
4375
|
const headerHtml = isKras
|
|
2941
4376
|
? `<!-- KRAS 50주년 헤더 -->
|
|
2942
4377
|
<div style="text-align: center; margin-bottom: 36px;">
|
|
@@ -2947,12 +4382,7 @@ function createWelcomeHtmlRaw(name, isKras, siteUrl, unsubscribeUrl) {
|
|
|
2947
4382
|
: `${heripoLogoHtml('8px')}
|
|
2948
4383
|
|
|
2949
4384
|
<h1 style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; line-height: 1.2; margin: 0 0
|
|
2950
|
-
18px 0; letter-spacing: -0.5px; margin-top: 0; font-size: 32px; font-weight: bold; color: #111111; border-bottom: 3px solid #D2691E; padding-bottom: 8px;">${name}님,
|
|
2951
|
-
const feedbackHeading = `${name}님의 목소리가 heripo의 미래를 만듭니다`;
|
|
2952
|
-
const feedbackText = 'heripo';
|
|
2953
|
-
const newsletterLine = isKras
|
|
2954
|
-
? `<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">뉴스레터(리서치 레이더)는 heripo의 초기 선행 기능 중 하나입니다. 뉴스레터 소스 추가 요청은 <a href="https://github.com/heripo-lab/heripo-research-radar/issues" target="_blank">GitHub 이슈</a>를 통해 언제든 환영합니다.</p>`
|
|
2955
|
-
: `<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">뉴스레터(리서치 레이더)는 heripo의 초기 선행 기능 중 하나입니다. 뉴스레터 소스 추가 요청은 <a href="https://github.com/heripo-lab/heripo-research-radar/issues" target="_blank">GitHub 이슈</a>를 통해 언제든 환영합니다.</p>`;
|
|
4385
|
+
18px 0; letter-spacing: -0.5px; margin-top: 0; font-size: 32px; font-weight: bold; color: #111111; border-bottom: 3px solid #D2691E; padding-bottom: 8px;">${name}님, 환영합니다.</h1>`;
|
|
2956
4386
|
const warningHtml = isKras
|
|
2957
4387
|
? `
|
|
2958
4388
|
<blockquote style="background-color: #fef2f2; border-left: 5px solid #dc2626; margin: 24px 0; padding: 20px; border-radius: 4px;">
|
|
@@ -2968,7 +4398,7 @@ function createWelcomeHtmlRaw(name, isKras, siteUrl, unsubscribeUrl) {
|
|
|
2968
4398
|
</blockquote>`;
|
|
2969
4399
|
const footerDisclaimerText = isKras
|
|
2970
4400
|
? '이 이메일은 heripo.app에서 한국고고학회 뉴스레터를 구독하신 분들에게 발송됩니다.'
|
|
2971
|
-
: '이 이메일은 heripo.app에서
|
|
4401
|
+
: '이 이메일은 heripo.app에서 heripo 뉴스레터를 구독하신 분들에게 발송됩니다.';
|
|
2972
4402
|
const footerUnsubscribeHtml = isKras
|
|
2973
4403
|
? `<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.7; color: #6b7280; margin: 0 0 18px 0; margin-bottom: 8px;">📱 구독 관리: <a href="${unsubscribeUrl}" class="footer-link">구독 해지</a></p>`
|
|
2974
4404
|
: `<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.7; color: #6b7280; margin: 0 0 18px 0; margin-bottom: 8px;">📱 구독 관리: <a href="${unsubscribeUrl}" class="footer-link">구독 해지</a></p>`;
|
|
@@ -3124,23 +4554,16 @@ function createWelcomeHtmlRaw(name, isKras, siteUrl, unsubscribeUrl) {
|
|
|
3124
4554
|
<td bgcolor="#ffffff" align="left" class="content-cell dark-mode-content-bg${isKras ? ' kras-newsletter' : ''}" style="-webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; mso-table-lspace: 0pt; mso-table-rspace: 0pt; padding: 48px 44px 44px 44px; border-radius: 12px; box-shadow: 0 4px 18px rgba(0,0,0,0.07);">
|
|
3125
4555
|
${headerHtml}
|
|
3126
4556
|
|
|
3127
|
-
<h2 style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; font-weight: bold; line-height: 1.3; color: #D2691E; margin: 0 0 15px 0; letter-spacing: -0.2px; border-left: 5px solid #D2691E; padding-left: 12px; background: #fff7f2;"
|
|
4557
|
+
<h2 style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; font-weight: bold; line-height: 1.3; color: #D2691E; margin: 0 0 15px 0; letter-spacing: -0.2px; border-left: 5px solid #D2691E; padding-left: 12px; background: #fff7f2;">📬 뉴스레터 구독이 완료되었습니다</h2>
|
|
3128
4558
|
|
|
3129
|
-
|
|
4559
|
+
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">${name}님, 구독해주셔서 감사합니다. 고고학과 문화유산의 중요한 소식을 뉴스레터로 전해드리겠습니다.</p>
|
|
3130
4560
|
|
|
3131
|
-
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;"
|
|
4561
|
+
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">놓치고 싶지 않은 소식이나 개선 의견이 있다면 언제든 <a href="${siteUrl}/contact">문의하기</a>로 알려주세요.</p>
|
|
3132
4562
|
|
|
3133
|
-
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;">여러분의 피드백 하나하나가 ${feedbackText}의 다음 발걸음을 결정합니다.</p>
|
|
3134
|
-
${isKras
|
|
3135
|
-
? `
|
|
3136
|
-
<p style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #444444; margin: 0 0 18px 0;"><strong><a href="https://github.com/heripo-lab" target="_blank">heripo lab</a></strong>은 한국고고학회와 함께 뉴스레터 발행 및 고고학의 디지털 전환을 추진하고 있습니다. 앞으로도 연구 현장에 실질적으로 도움이 되는 정보와 기술을 제공해 드리겠습니다.</p>
|
|
3137
|
-
`
|
|
3138
|
-
: ''}
|
|
3139
4563
|
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 28px 0 20px;">
|
|
3140
4564
|
|
|
3141
|
-
<h2 style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; font-weight: bold; line-height: 1.3; color: #D2691E; margin: 0 0 15px 0; letter-spacing: -0.2px; border-left: 5px solid #D2691E; padding-left: 12px; background: #fff7f2;">🔍 heripo
|
|
4565
|
+
<h2 style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; font-weight: bold; line-height: 1.3; color: #D2691E; margin: 0 0 15px 0; letter-spacing: -0.2px; border-left: 5px solid #D2691E; padding-left: 12px; background: #fff7f2;">🔍 heripo 프로젝트 소개</h2>
|
|
3142
4566
|
${platformIntroHtml()}
|
|
3143
|
-
${newsletterLine}
|
|
3144
4567
|
${warningHtml}
|
|
3145
4568
|
|
|
3146
4569
|
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 32px 0;">
|
|
@@ -3170,5 +4593,5 @@ ${poweredByFooterHtml()}
|
|
|
3170
4593
|
</html>`;
|
|
3171
4594
|
}
|
|
3172
4595
|
|
|
3173
|
-
export { AnalysisProvider, ContentGenerateProvider, CrawlingProvider, DateService, TaskService, contentOptions, createCrawlingTargetGroups, generateNewsletter, generateWelcomeHTML, getSourceList, llmConfig, newsletterConfig };
|
|
4596
|
+
export { AnalysisProvider, ContentGenerateProvider, CrawlingProvider, DateService, TaskService, contentOptions, createCrawlingTargetGroups, generateNewsletter, generateWelcomeHTML, getSourceList, llmConfig, maximumImportanceScoreByDomain, newsletterConfig, researchRadarPromptProvider, robotsExemptOrigins };
|
|
3174
4597
|
//# sourceMappingURL=index.js.map
|