@heripo/research-radar 5.0.6 → 5.2.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 +29 -23
- package/dist/index.d.ts +154 -5
- package/dist/index.js +2162 -100
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createAnthropic } from '@ai-sdk/anthropic';
|
|
|
2
2
|
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
|
3
3
|
import { createOpenAI } from '@ai-sdk/openai';
|
|
4
4
|
import { DateType, GenerateNewsletter } from '@llm-newsletter-kit/core';
|
|
5
|
+
import { jsonSchema, generateText, Output } from 'ai';
|
|
5
6
|
import * as cheerio from 'cheerio';
|
|
6
7
|
import TurndownService from 'turndown';
|
|
7
8
|
import { JSDOM } from 'jsdom';
|
|
@@ -9,6 +10,440 @@ import safeMarkdown2Html from 'safe-markdown2html';
|
|
|
9
10
|
import DOMPurify from 'dompurify';
|
|
10
11
|
import juice from 'juice';
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Deterministic pre-filter for public job-posting APIs.
|
|
15
|
+
*
|
|
16
|
+
* Both sources return every public-sector vacancy in the country — roughly
|
|
17
|
+
* 1,300 postings per fortnight from 나라일터 alone — so the newsletter pipeline
|
|
18
|
+
* must not see them all. Scoring is an LLM call per article, and asking it to
|
|
19
|
+
* reject thousands of unrelated postings would be slow, expensive, and noisy.
|
|
20
|
+
*
|
|
21
|
+
* This cuts by rule first, and deliberately errs toward keeping things: a
|
|
22
|
+
* posting survives if its institution is a heritage body **or** its title names
|
|
23
|
+
* a heritage job. The remaining judgement — a heritage institution hiring for a
|
|
24
|
+
* non-heritage role, or a borderline field — is left to the importance prompt,
|
|
25
|
+
* which reads the full posting.
|
|
26
|
+
*/
|
|
27
|
+
/** Institutions whose name alone settles the domain. */
|
|
28
|
+
const HERITAGE_INSTITUTION = /국가유산청|국립문화유산연구원|국립해양유산연구소|한국전통문화대학교|국가유산진흥원|국립고궁박물관|궁능유적본부|국립[가-힣]*박물관|[가-힣]{2,}시립박물관|[가-힣]{2,}군립박물관|역사박물관|민속박물관|문화유산|국가유산|문화재단|[가-힣]{2,}유적|[가-힣]{2,}유물|고고|발굴/;
|
|
29
|
+
/** Job titles that settle the domain regardless of the institution. */
|
|
30
|
+
const HERITAGE_JOB = /학예|고고|발굴|매장유산|보존처리|보존과학|문화재|국가유산|문화유산|유물|고건축|전통건축|수리기술|전통문화|무형유산|천연기념물|전시기획|큐레이터|학예연구/;
|
|
31
|
+
/**
|
|
32
|
+
* Roles excluded even at a heritage institution.
|
|
33
|
+
*
|
|
34
|
+
* A museum hiring a cleaner or a security guard is not heritage news. This is
|
|
35
|
+
* the same distinction the importance prompt makes, applied here so the obvious
|
|
36
|
+
* cases never cost an LLM call.
|
|
37
|
+
*/
|
|
38
|
+
const NON_HERITAGE_ROLE = /미화|청소|방호|경비|청원경찰|조리|취사|영양사|시설관리|시설물|기계설비|전기설비|조경|운전|당직|소방|보건|간호|집배|매점|카페|주차|경리|경호/;
|
|
39
|
+
/** NCS job categories that can plausibly carry heritage work (재정경제부 API). */
|
|
40
|
+
const HERITAGE_NCS_CODES = [
|
|
41
|
+
'R600004', // 교육.자연.사회과학 — 고고학·역사학 연구직
|
|
42
|
+
'R600008', // 문화.예술.디자인.방송 — 학예·전시
|
|
43
|
+
'R600022', // 인쇄.목재.가구.공예 — 보존처리·전통공예
|
|
44
|
+
'R600025', // 연구
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* Whether a posting should reach the analysis stage.
|
|
48
|
+
*
|
|
49
|
+
* @returns true when the posting looks heritage-related and is not one of the
|
|
50
|
+
* excluded support roles
|
|
51
|
+
*/
|
|
52
|
+
function isHeritageJobCandidate({ institution, title, categoryCodes = [], }) {
|
|
53
|
+
if (NON_HERITAGE_ROLE.test(title)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const named = HERITAGE_INSTITUTION.test(institution) || HERITAGE_JOB.test(title);
|
|
57
|
+
if (!named) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (categoryCodes.length === 0) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return categoryCodes.some((code) => HERITAGE_NCS_CODES.includes(code));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Heritage subjects as they appear in a 나라장터 notice title.
|
|
67
|
+
*
|
|
68
|
+
* Wider than {@link HERITAGE_JOB} because a bid names the work rather than a
|
|
69
|
+
* role: 발굴조사, 보수정비, 기록화 and so on.
|
|
70
|
+
*/
|
|
71
|
+
const HERITAGE_BID_SUBJECT = /발굴조사|시굴조사|표본조사|입회조사|지표조사|매장유산|매장문화재|고분군|폐사지|문화재\s*수리|국가유산\s*수리|보존처리|보존과학|학예|고고|고건축|단청|석조문화재|목조문화재|전통건축|유적\s*정비|유물|기록화/;
|
|
72
|
+
/**
|
|
73
|
+
* Work excluded from a 나라장터 notice regardless of who issued it.
|
|
74
|
+
*
|
|
75
|
+
* 나라장터 carries every public procurement notice in the country, and heritage
|
|
76
|
+
* vocabulary collides badly with civil engineering and event support: 급경사지
|
|
77
|
+
* 정밀조사 is landslide risk, not archaeology, and a museum's 건설폐기물 처리 or
|
|
78
|
+
* a 문화재단's 축제 셔틀버스 is not heritage work either. These are the cases
|
|
79
|
+
* that need no judgement — anything arguable is left to the importance prompt.
|
|
80
|
+
*
|
|
81
|
+
* A word whose meaning turns on how it is used does not belong here, because
|
|
82
|
+
* this check runs before every inclusion signal and cannot be argued back.
|
|
83
|
+
* `드론` is narrowed to `드론쇼` for that reason: 국가유산 방재드론 스테이션 운영
|
|
84
|
+
* is heritage disaster response. Dropped outright for the same reason:
|
|
85
|
+
* `재선충` (남양주 홍릉과 유릉 소나무재선충병 긴급 예방사업 is care of a
|
|
86
|
+
* 조선왕릉's historic landscape), `산불` (안동 산불피해 국가유산 복원(안동
|
|
87
|
+
* 국탄댁), 청송 만세루 산불피해 복원사업, and 국가유산 재난방지시설
|
|
88
|
+
* 구축(산불소화시설) are all heritage restoration or disaster prevention),
|
|
89
|
+
* `숲가꾸기` (the same 역사경관림 care as 재선충), and `소독`/`방역` (의림지
|
|
90
|
+
* 역사박물관 수장고 및 유물 소독 is collection conservation). All of them now
|
|
91
|
+
* reach the importance prompt, which reads the whole notice.
|
|
92
|
+
*
|
|
93
|
+
* Dropping them does not widen intake on its own: this list only decides notices
|
|
94
|
+
* that already carry an inclusion signal, and the 산불·숲가꾸기 forestry notices
|
|
95
|
+
* it used to catch carry none — over a two-week sample of 10,199 notices it
|
|
96
|
+
* admitted no new ones. What it changes is that those titles can now be argued
|
|
97
|
+
* for at all: 의림지 역사박물관 수장고 및 유물 소독 passes on `유물`, and
|
|
98
|
+
* 국가유산 재난방지시설 구축(산불소화시설) passes when 국가유산청 issues it.
|
|
99
|
+
* Where the issuer is a 지자체 and the title carries no subject term — 안동
|
|
100
|
+
* 산불피해 국가유산 복원(안동 국탄댁) — the notice still fails, on the
|
|
101
|
+
* inclusion side rather than here.
|
|
102
|
+
*/
|
|
103
|
+
const NON_HERITAGE_BID = /급경사지|사방댐|관정|제설|건설폐기물|생활폐기물|폐아스콘|석면|청소|경비|방호|급식|조리|셔틀|현수막|드론쇼|키오스크|주차/;
|
|
104
|
+
/**
|
|
105
|
+
* 나라장터 procurement classifications that settle the domain on their own.
|
|
106
|
+
*
|
|
107
|
+
* The taxonomy carries one heritage category, and it catches notices no
|
|
108
|
+
* vocabulary would: 팔만대장경 P-XRF 분석, issued by 해인사 장경도량, matches
|
|
109
|
+
* neither the institution nor the title patterns. It is only a supplement —
|
|
110
|
+
* 공사 notices carry no classification at all, so it cannot replace them.
|
|
111
|
+
*/
|
|
112
|
+
const HERITAGE_PROCUREMENT_CLASSIFICATIONS = ['문화재 조사/발굴 및 수리'];
|
|
113
|
+
/**
|
|
114
|
+
* Whether a 나라장터 notice should reach the analysis stage.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately loose. Of roughly 1,000 notices a day this keeps about ten, and
|
|
117
|
+
* the remaining judgement — a heritage institution procuring fire alarms, a
|
|
118
|
+
* 문화재단 running a festival — is left to the importance prompt, which reads
|
|
119
|
+
* the whole notice. Tightening it here cost real articles: an earlier attempt
|
|
120
|
+
* to exclude 보수정비 also dropped 영양 하담고택 보수정비사업 and 보길도
|
|
121
|
+
* 윤선도원림 판석보 보수정비공사.
|
|
122
|
+
*/
|
|
123
|
+
function isHeritageBidCandidate({ institution, title, classification, }) {
|
|
124
|
+
if (NON_HERITAGE_BID.test(title)) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (classification &&
|
|
128
|
+
HERITAGE_PROCUREMENT_CLASSIFICATIONS.includes(classification.trim())) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return (HERITAGE_INSTITUTION.test(institution) || HERITAGE_BID_SUBJECT.test(title));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const DEFAULT_BATCH_SIZE = 100;
|
|
135
|
+
const DEFAULT_CONCURRENCY = 24;
|
|
136
|
+
/**
|
|
137
|
+
* Asks for the indices to keep rather than a verdict per row.
|
|
138
|
+
*
|
|
139
|
+
* A boolean array has to line up with the input to mean anything, and a model
|
|
140
|
+
* that returns 99 or 101 entries for 100 rows shifts every verdict after the
|
|
141
|
+
* mistake without failing. Indices carry their own alignment, and anything out
|
|
142
|
+
* of range or repeated is discarded rather than trusted.
|
|
143
|
+
*/
|
|
144
|
+
const KEEP_SCHEMA = jsonSchema({
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
keep: {
|
|
148
|
+
type: 'array',
|
|
149
|
+
items: { type: 'integer' },
|
|
150
|
+
description: '국가유산 업무와 관련될 가능성이 있는 공고의 번호',
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
required: ['keep'],
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
});
|
|
156
|
+
const SYSTEM_PROMPT = `너는 대한민국 나라장터 입찰공고를 국가유산 뉴스레터용으로 선별한다.
|
|
157
|
+
|
|
158
|
+
이것은 **선별** 단계이지 평가 단계가 아니다. 중요도와 기사 가치는 뒤의 채점
|
|
159
|
+
단계가 공고 전문을 읽고 따로 판단한다. 따라서 여기서는 재현율을 우선하여
|
|
160
|
+
**조금이라도 국가유산 업무일 가능성이 있으면 포함**한다. 애매하면 포함한다.
|
|
161
|
+
|
|
162
|
+
포함할 것:
|
|
163
|
+
- 발굴조사, 시굴조사, 지표조사, 입회조사, 매장유산 관련 사업
|
|
164
|
+
- 국가유산·문화재의 수리, 보수, 정비, 복원, 이전, 해체
|
|
165
|
+
- 보존처리, 보존과학, 기록화, 정밀실측, 학술연구, 종합정비계획
|
|
166
|
+
- 고택, 종택, 재사, 서원, 향교, 사찰, 읍성, 산성, 고분, 왕릉, 원림, 명승 등
|
|
167
|
+
지정·비지정 유산을 대상으로 하는 사업
|
|
168
|
+
- 박물관·미술관의 전시, 소장품·유물 관리, 수장고 업무
|
|
169
|
+
- 국가유산을 대상으로 한 재난방지·방재·소방 시설
|
|
170
|
+
|
|
171
|
+
제외할 것:
|
|
172
|
+
- 유산과 무관한 토목, 임업, 도로, 상하수도, 조경 사업
|
|
173
|
+
- 청소, 경비, 급식, 셔틀, 현수막 등 일반 지원 용역
|
|
174
|
+
- 유산과 무관한 건물의 일반 시설 유지관리
|
|
175
|
+
- 축제·행사의 운영 대행, 무대·음향·홍보물 등 행사 지원 그 자체
|
|
176
|
+
|
|
177
|
+
단, 국가유산의 지정·승격·등록처럼 유산 자체에 일어난 일을 기념하거나
|
|
178
|
+
알리는 사업은 행사 형식이더라도 포함한다.
|
|
179
|
+
|
|
180
|
+
중요: **발주 기관이 국가유산 기관이 아니어도 사업 내용이 유산이면 포함한다.**
|
|
181
|
+
지자체가 발주하는 고택 보수, 읍성 정비, 산불 피해 국가유산 복원이 여기 해당한다.
|
|
182
|
+
반대로 발주 기관이 유산 기관이어도 내용이 무관하면 제외한다.
|
|
183
|
+
|
|
184
|
+
주의: 한국어 부분문자열에 속지 마라. '생산성'은 산성이 아니고, '재사용'은
|
|
185
|
+
재사가 아니며, '서원구'는 서원이 아니다.`;
|
|
186
|
+
/**
|
|
187
|
+
* The three fields the model is shown, normalized once.
|
|
188
|
+
*
|
|
189
|
+
* The verdict cache keys on exactly this, so a candidate that reads identically
|
|
190
|
+
* to the model is the same candidate to the cache. Deriving both from here is
|
|
191
|
+
* what keeps them from drifting: keying on less than the model sees merges two
|
|
192
|
+
* notices that it judged separately, and the second verdict silently overwrites
|
|
193
|
+
* the first — within a single run, not only across retries.
|
|
194
|
+
*/
|
|
195
|
+
function identityOf(candidate) {
|
|
196
|
+
return [
|
|
197
|
+
candidate.institution.trim(),
|
|
198
|
+
candidate.title.trim(),
|
|
199
|
+
candidate.classification?.trim() ?? '',
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
function buildUserPrompt(candidates) {
|
|
203
|
+
const lines = candidates.map((candidate, index) => {
|
|
204
|
+
const [institution, title, classification] = identityOf(candidate);
|
|
205
|
+
return (`${index}. [${institution}] ${title}` +
|
|
206
|
+
(classification ? ` <${classification}>` : ''));
|
|
207
|
+
});
|
|
208
|
+
return `다음 ${candidates.length}건 중 국가유산 업무와 관련될 가능성이 있는 공고의 번호만 골라라.\n\n${lines.join('\n')}`;
|
|
209
|
+
}
|
|
210
|
+
/** Applies the deterministic filter, which is what triage falls back to. */
|
|
211
|
+
function deterministicVerdicts(candidates) {
|
|
212
|
+
return candidates.map((candidate) => isHeritageBidCandidate(candidate));
|
|
213
|
+
}
|
|
214
|
+
async function triageBatch(candidates, { model, onFallback }, signal) {
|
|
215
|
+
try {
|
|
216
|
+
const { output } = await generateText({
|
|
217
|
+
model,
|
|
218
|
+
output: Output.object({ schema: KEEP_SCHEMA }),
|
|
219
|
+
system: SYSTEM_PROMPT,
|
|
220
|
+
prompt: buildUserPrompt(candidates),
|
|
221
|
+
abortSignal: signal,
|
|
222
|
+
});
|
|
223
|
+
const verdicts = new Array(candidates.length).fill(false);
|
|
224
|
+
let usable = false;
|
|
225
|
+
for (const index of output.keep) {
|
|
226
|
+
if (Number.isInteger(index) && index >= 0 && index < candidates.length) {
|
|
227
|
+
verdicts[index] = true;
|
|
228
|
+
usable = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// An empty selection is a legitimate answer for a batch of road works, but
|
|
232
|
+
// a selection that is entirely out of range is a malformed response wearing
|
|
233
|
+
// the right shape, and it must not read as "reject everything".
|
|
234
|
+
if (!usable && output.keep.length > 0) {
|
|
235
|
+
onFallback?.('every returned index was out of range', candidates.length);
|
|
236
|
+
return deterministicVerdicts(candidates);
|
|
237
|
+
}
|
|
238
|
+
return verdicts;
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
// A cancelled crawl must fail the fetch, not quietly answer from the regex:
|
|
242
|
+
// core has already given up on this attempt, and returning a list now would
|
|
243
|
+
// hand it a result it is no longer waiting for. The signal is the ground
|
|
244
|
+
// truth rather than the error's shape, which the AI SDK may have wrapped.
|
|
245
|
+
if (signal?.aborted) {
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
onFallback?.(error instanceof Error ? error.message : String(error), candidates.length);
|
|
249
|
+
return deterministicVerdicts(candidates);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Builds the LLM triage that replaces the regex as 나라장터's gate.
|
|
254
|
+
*
|
|
255
|
+
* 나라장터 carries about 1,000 용역 and 공사 notices a day, so a 48-hour window
|
|
256
|
+
* holds roughly 1,500 — far too many to score one by one, which is why a
|
|
257
|
+
* deterministic filter stood here first. But heritage vocabulary cannot be
|
|
258
|
+
* settled by substring: `산불` is forestry in 산불예방 숲가꾸기 and heritage work
|
|
259
|
+
* in 안동 산불피해 국가유산 복원, and widening the other way collides just as
|
|
260
|
+
* badly (`산성` matches 생산성, `재사` matches 재사용). Judging 100 titles per
|
|
261
|
+
* request costs about 15 calls a day, so the judgement can be made by something
|
|
262
|
+
* that reads them.
|
|
263
|
+
*
|
|
264
|
+
* It never fails open: an error, or a response whose indices are unusable, falls
|
|
265
|
+
* back to {@link isHeritageBidCandidate} for that batch rather than admitting
|
|
266
|
+
* every notice into per-article scoring. Omitting triage entirely leaves the
|
|
267
|
+
* deterministic filter in charge, which is how the health-check runs without an
|
|
268
|
+
* LLM key.
|
|
269
|
+
*/
|
|
270
|
+
const createHeritageBidTriage = (options) => {
|
|
271
|
+
const { batchSize = DEFAULT_BATCH_SIZE, concurrency = DEFAULT_CONCURRENCY } = options;
|
|
272
|
+
// Verdicts survive across calls so core's retry resumes rather than restarts.
|
|
273
|
+
// Core aborts the first attempt at 10 seconds and tries again with a longer
|
|
274
|
+
// budget; without this, every attempt would pay for the whole window again.
|
|
275
|
+
const verdicts = new Map();
|
|
276
|
+
const keyOf = (candidate) => identityOf(candidate).join('\u0000');
|
|
277
|
+
return async (candidates, signal) => {
|
|
278
|
+
if (candidates.length === 0) {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
const pending = candidates.filter((c) => !verdicts.has(keyOf(c)));
|
|
282
|
+
const batches = [];
|
|
283
|
+
for (let index = 0; index < pending.length; index += batchSize) {
|
|
284
|
+
batches.push(pending.slice(index, index + batchSize));
|
|
285
|
+
}
|
|
286
|
+
let next = 0;
|
|
287
|
+
const workers = Array.from({ length: Math.min(concurrency, batches.length) }, async () => {
|
|
288
|
+
while (next < batches.length) {
|
|
289
|
+
const batch = batches[next++];
|
|
290
|
+
const decided = await triageBatch(batch, options, signal);
|
|
291
|
+
batch.forEach((candidate, index) => {
|
|
292
|
+
verdicts.set(keyOf(candidate), decided[index]);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
// A rejection here propagates out of the fetch, which is what an aborted
|
|
297
|
+
// crawl needs; completed batches stay cached for the next attempt.
|
|
298
|
+
await Promise.all(workers);
|
|
299
|
+
return candidates.map((c) => verdicts.get(keyOf(c)) ?? false);
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const API_BASE$2 = 'https://apis.data.go.kr/1051000/recruitment';
|
|
304
|
+
const SITE_BASE$2 = 'https://job.alio.go.kr';
|
|
305
|
+
/**
|
|
306
|
+
* Public 알리오 board URL used as this target's list page.
|
|
307
|
+
*
|
|
308
|
+
* As with 나라일터, core fetches this URL and `createAlioFetch` answers it from
|
|
309
|
+
* the 재정경제부 open API, keeping the service key out of the configuration.
|
|
310
|
+
*/
|
|
311
|
+
const ALIO_LIST_URL = `${SITE_BASE$2}/recruit.do`;
|
|
312
|
+
/** Public posting URL. */
|
|
313
|
+
const buildAlioDetailUrl = (serial) => `${SITE_BASE$2}/recruitview.do?idx=${serial}`;
|
|
314
|
+
/** `yyyymmdd` to ISO `yyyy-mm-dd`. */
|
|
315
|
+
function toIsoDate$2(compact) {
|
|
316
|
+
return /^\d{8}$/.test(compact)
|
|
317
|
+
? `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`
|
|
318
|
+
: '';
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Serves the 알리오 target from the 재정경제부 open API.
|
|
322
|
+
*
|
|
323
|
+
* This board only covers 공기업 and 준정부기관, so heritage postings are rare —
|
|
324
|
+
* 국가유산청 and the national museums publish through 나라일터 instead. It is
|
|
325
|
+
* collected anyway because a body like 국립농업박물관 does appear here, and the
|
|
326
|
+
* NCS job codes make filtering cheap and precise.
|
|
327
|
+
*
|
|
328
|
+
* Only postings still open (`ongoingYn=Y`) are requested.
|
|
329
|
+
*/
|
|
330
|
+
const createAlioFetch = (baseFetch = fetch, options) => {
|
|
331
|
+
const { apiKey, rowsPerPage = 3000 } = options;
|
|
332
|
+
return async (input, init) => {
|
|
333
|
+
const requestUrl = typeof input === 'string'
|
|
334
|
+
? input
|
|
335
|
+
: input instanceof URL
|
|
336
|
+
? input.href
|
|
337
|
+
: input.url;
|
|
338
|
+
let url;
|
|
339
|
+
try {
|
|
340
|
+
url = new URL(requestUrl);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return baseFetch(input, init);
|
|
344
|
+
}
|
|
345
|
+
if (url.origin !== SITE_BASE$2) {
|
|
346
|
+
return baseFetch(input, init);
|
|
347
|
+
}
|
|
348
|
+
if (!apiKey) {
|
|
349
|
+
// Without a key the board is simply not collected. See gojobs.parser.ts.
|
|
350
|
+
return Response.json({ result: [] });
|
|
351
|
+
}
|
|
352
|
+
if (url.pathname === '/recruit.do') {
|
|
353
|
+
return baseFetch(`${API_BASE$2}/list?serviceKey=${apiKey}&resultType=json` +
|
|
354
|
+
`&numOfRows=${rowsPerPage}&pageNo=1&ongoingYn=Y`, init);
|
|
355
|
+
}
|
|
356
|
+
if (url.pathname === '/recruitview.do') {
|
|
357
|
+
const serial = url.searchParams.get('idx');
|
|
358
|
+
if (serial) {
|
|
359
|
+
return baseFetch(`${API_BASE$2}/detail?serviceKey=${apiKey}&resultType=json&sn=${serial}`, init);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return baseFetch(input, init);
|
|
363
|
+
};
|
|
364
|
+
};
|
|
365
|
+
function parseJson$2(body) {
|
|
366
|
+
try {
|
|
367
|
+
return JSON.parse(body);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** Renders a posting's fields as the article body. */
|
|
374
|
+
function renderAlioContent(record) {
|
|
375
|
+
const lines = [
|
|
376
|
+
['기관', record.instNm],
|
|
377
|
+
['직무분야', record.ncsCdNmLst],
|
|
378
|
+
['고용형태', record.hireTypeNmLst],
|
|
379
|
+
['채용구분', record.recrutSeNm],
|
|
380
|
+
['근무지역', record.workRgnNmLst],
|
|
381
|
+
['모집인원', record.recrutNope ? `${record.recrutNope}명` : ''],
|
|
382
|
+
[
|
|
383
|
+
'공고기간',
|
|
384
|
+
[record.pbancBgngYmd, record.pbancEndYmd]
|
|
385
|
+
.map((value) => toIsoDate$2(value ?? ''))
|
|
386
|
+
.filter(Boolean)
|
|
387
|
+
.join(' - '),
|
|
388
|
+
],
|
|
389
|
+
['원문', record.srcUrl],
|
|
390
|
+
]
|
|
391
|
+
.filter(([, value]) => value)
|
|
392
|
+
.map(([label, value]) => `- **${label}**: ${String(value).trim()}`);
|
|
393
|
+
const sections = [
|
|
394
|
+
['지원자격', record.aplyQlfcCn],
|
|
395
|
+
['우대사항', record.prefCn],
|
|
396
|
+
['전형방법', record.scrnprcdrMthdExpln],
|
|
397
|
+
]
|
|
398
|
+
.filter(([, value]) => value?.trim())
|
|
399
|
+
.flatMap(([label, value]) => [
|
|
400
|
+
'',
|
|
401
|
+
`### ${label}`,
|
|
402
|
+
'',
|
|
403
|
+
String(value).trim(),
|
|
404
|
+
]);
|
|
405
|
+
return [`## ${record.recrutPbancTtl ?? ''}`, '', ...lines, ...sections]
|
|
406
|
+
.join('\n')
|
|
407
|
+
.trim();
|
|
408
|
+
}
|
|
409
|
+
/** Parses the aggregated list response, keeping only heritage-related postings. */
|
|
410
|
+
const parseAlioList = (body) => {
|
|
411
|
+
const parsed = parseJson$2(body);
|
|
412
|
+
const posts = [];
|
|
413
|
+
for (const record of parsed?.result ?? []) {
|
|
414
|
+
const title = record.recrutPbancTtl?.trim();
|
|
415
|
+
if (!title || record.recrutPblntSn == null) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const heritage = isHeritageJobCandidate({
|
|
419
|
+
institution: record.instNm ?? '',
|
|
420
|
+
title,
|
|
421
|
+
categoryCodes: (record.ncsCdLst ?? '').split(',').filter(Boolean),
|
|
422
|
+
});
|
|
423
|
+
if (!heritage) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
posts.push({
|
|
427
|
+
uniqId: String(record.recrutPblntSn),
|
|
428
|
+
title,
|
|
429
|
+
date: toIsoDate$2(record.pbancBgngYmd ?? ''),
|
|
430
|
+
detailUrl: buildAlioDetailUrl(record.recrutPblntSn),
|
|
431
|
+
dateType: DateType.REGISTERED,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return posts;
|
|
435
|
+
};
|
|
436
|
+
/** Parses a `/detail` response into the article body. */
|
|
437
|
+
const parseAlioDetail = (body) => {
|
|
438
|
+
const parsed = parseJson$2(body);
|
|
439
|
+
const record = parsed?.result;
|
|
440
|
+
return {
|
|
441
|
+
detailContent: record ? renderAlioContent(record) : '',
|
|
442
|
+
hasAttachedFile: true,
|
|
443
|
+
hasAttachedImage: false,
|
|
444
|
+
};
|
|
445
|
+
};
|
|
446
|
+
|
|
12
447
|
/**
|
|
13
448
|
* Formats a date string by replacing dots with dashes.
|
|
14
449
|
* If the string contains a newline (indicating a date range),
|
|
@@ -115,7 +550,136 @@ function getUniqIdFromBuyeoMuseum(element) {
|
|
|
115
550
|
return (element.attr('onclick') ?? '').match(/goView\('(.*)'\)/)?.[1] ?? '';
|
|
116
551
|
}
|
|
117
552
|
|
|
553
|
+
const parseCheongjuMuseumList = (html) => {
|
|
554
|
+
const $ = cheerio.load(html);
|
|
555
|
+
const posts = [];
|
|
556
|
+
const baseUrl = 'https://cheongju.museum.go.kr';
|
|
557
|
+
$('table.bbs_default_list tbody tr').each((index, element) => {
|
|
558
|
+
const columns = $(element).find('td');
|
|
559
|
+
if (columns.length === 0) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const titleElement = columns.eq(1).find('a');
|
|
563
|
+
const relativeHref = titleElement.attr('href');
|
|
564
|
+
if (!relativeHref) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const fullUrl = new URL(relativeHref.replace('./', '/www/'), baseUrl);
|
|
568
|
+
const detailUrl = fullUrl.href;
|
|
569
|
+
const uniqId = fullUrl.searchParams.get('nttNo') ?? undefined;
|
|
570
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
571
|
+
const date = getDate(columns.eq(4).text().trim());
|
|
572
|
+
posts.push({
|
|
573
|
+
uniqId,
|
|
574
|
+
title,
|
|
575
|
+
date,
|
|
576
|
+
detailUrl: cleanUrl(detailUrl),
|
|
577
|
+
dateType: DateType.REGISTERED,
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
return posts;
|
|
581
|
+
};
|
|
582
|
+
const parseCheongjuMuseumDetail = (html) => {
|
|
583
|
+
const $ = cheerio.load(html);
|
|
584
|
+
const content = $('td.content');
|
|
585
|
+
return {
|
|
586
|
+
detailContent: new TurndownService().turndown(content.html() ?? ''),
|
|
587
|
+
hasAttachedFile: $('tr.FILE td p').length > 0,
|
|
588
|
+
hasAttachedImage: content.find('img').length > 0,
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const EXCAVATION_BASE_URL = 'https://www.e-minwon.go.kr';
|
|
593
|
+
/** List page of the 국가유산청 발굴조사 보고서 board. */
|
|
594
|
+
const EXCAVATION_REPORT_LIST_URL = `${EXCAVATION_BASE_URL}/ge/ee/getListEcexmRptp.do`;
|
|
595
|
+
/** Public detail URL for one report, keyed by its board id (`ecexmRcno`). */
|
|
596
|
+
const buildExcavationReportDetailUrl = (externalId) => `${EXCAVATION_BASE_URL}/ge/ee/getEcexmRptp.do?ecexmRcno=${externalId}`;
|
|
597
|
+
/**
|
|
598
|
+
* Marks a response body as injected report data rather than scraped HTML, so
|
|
599
|
+
* the parsers below can tell the two apart without guessing.
|
|
600
|
+
*/
|
|
601
|
+
const INJECTED_MARKER = '@heripo/research-radar:excavation-reports';
|
|
602
|
+
function parseInjectedPayload(body) {
|
|
603
|
+
if (!body.includes(INJECTED_MARKER)) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
const parsed = JSON.parse(body);
|
|
608
|
+
return parsed.marker === INJECTED_MARKER ? parsed : null;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function jsonResponse$1(payload) {
|
|
615
|
+
return new Response(JSON.stringify(payload), {
|
|
616
|
+
status: 200,
|
|
617
|
+
headers: { 'content-type': 'application/json' },
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Serves the 발굴조사 보고서 board from an injected source instead of the live
|
|
622
|
+
* site, leaving every other request untouched.
|
|
623
|
+
*
|
|
624
|
+
* Core's crawling chain always fetches a target's list URL and then each detail
|
|
625
|
+
* URL, so the substitution happens at the fetch layer — the same seam
|
|
626
|
+
* `createKrasFetch` uses. `source` is called once and its result reused for the
|
|
627
|
+
* detail requests that follow.
|
|
628
|
+
*
|
|
629
|
+
* @param baseFetch - Fetch to delegate every other request to
|
|
630
|
+
* @param source - Supplies the reports for this run
|
|
631
|
+
*/
|
|
632
|
+
const createExcavationReportFetch = (baseFetch = fetch, source) => {
|
|
633
|
+
let pending = null;
|
|
634
|
+
const loadReports = () => (pending ??= source());
|
|
635
|
+
return async (input, init) => {
|
|
636
|
+
const requestUrl = typeof input === 'string'
|
|
637
|
+
? input
|
|
638
|
+
: input instanceof URL
|
|
639
|
+
? input.href
|
|
640
|
+
: input.url;
|
|
641
|
+
const url = new URL(requestUrl, EXCAVATION_BASE_URL);
|
|
642
|
+
if (url.origin !== new URL(EXCAVATION_BASE_URL).origin) {
|
|
643
|
+
return baseFetch(input, init);
|
|
644
|
+
}
|
|
645
|
+
if (url.pathname === new URL(EXCAVATION_REPORT_LIST_URL).pathname) {
|
|
646
|
+
const reports = await loadReports();
|
|
647
|
+
return jsonResponse$1({
|
|
648
|
+
marker: INJECTED_MARKER,
|
|
649
|
+
reports,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
if (url.pathname === '/ge/ee/getEcexmRptp.do') {
|
|
653
|
+
const externalId = url.searchParams.get('ecexmRcno');
|
|
654
|
+
const report = (await loadReports()).find((candidate) => candidate.externalId === externalId);
|
|
655
|
+
if (report) {
|
|
656
|
+
return jsonResponse$1({
|
|
657
|
+
marker: INJECTED_MARKER,
|
|
658
|
+
report,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return baseFetch(input, init);
|
|
663
|
+
};
|
|
664
|
+
};
|
|
665
|
+
/** Renders a report's fields as the article body. */
|
|
666
|
+
function renderExcavationReportContent(report) {
|
|
667
|
+
const lines = Object.entries(report.fields)
|
|
668
|
+
.filter(([, value]) => value != null && String(value).trim() !== '')
|
|
669
|
+
.map(([label, value]) => `- **${label}**: ${String(value).trim()}`);
|
|
670
|
+
return ['## 발굴조사 보고서 정보', '', ...lines].join('\n');
|
|
671
|
+
}
|
|
118
672
|
const parseExcavationReportList = (html) => {
|
|
673
|
+
const injected = parseInjectedPayload(html);
|
|
674
|
+
if (injected) {
|
|
675
|
+
return injected.reports.map((report) => ({
|
|
676
|
+
uniqId: report.externalId,
|
|
677
|
+
title: report.title,
|
|
678
|
+
date: report.submittedDate,
|
|
679
|
+
detailUrl: buildExcavationReportDetailUrl(report.externalId),
|
|
680
|
+
dateType: DateType.REGISTERED,
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
119
683
|
const $ = cheerio.load(html);
|
|
120
684
|
const posts = [];
|
|
121
685
|
const baseUrl = 'https://www.e-minwon.go.kr';
|
|
@@ -172,6 +736,14 @@ const parseExcavationSiteList = (html) => {
|
|
|
172
736
|
return posts;
|
|
173
737
|
};
|
|
174
738
|
const parseExcavationReportDetail = (html) => {
|
|
739
|
+
const injected = parseInjectedPayload(html);
|
|
740
|
+
if (injected) {
|
|
741
|
+
return {
|
|
742
|
+
detailContent: renderExcavationReportContent(injected.report),
|
|
743
|
+
hasAttachedFile: injected.report.hasAttachedFile ?? true,
|
|
744
|
+
hasAttachedImage: false,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
175
747
|
const $ = cheerio.load(html);
|
|
176
748
|
const content = $('table.td_left').parent();
|
|
177
749
|
const trList = content.find('table tbody tr');
|
|
@@ -205,6 +777,320 @@ function getUniqIdFromExcavationItem(element) {
|
|
|
205
777
|
return ((element.attr('onclick') ?? '').match(/dataSelected\('(.*)'\)/)?.[1] ?? '');
|
|
206
778
|
}
|
|
207
779
|
|
|
780
|
+
const API_BASE$1 = 'https://apis.data.go.kr/1230000/ad/BidPublicInfoService';
|
|
781
|
+
const SITE_BASE$1 = 'https://www.g2b.go.kr';
|
|
782
|
+
/**
|
|
783
|
+
* Public 나라장터 board URL used as this target's list page.
|
|
784
|
+
*
|
|
785
|
+
* Core fetches a target's `url` directly, so this stands in for the API calls:
|
|
786
|
+
* `createG2bFetch` recognises it and queries the 조달청 open API instead, which
|
|
787
|
+
* keeps the service key and the time window out of the configuration.
|
|
788
|
+
*/
|
|
789
|
+
const G2B_LIST_URL = `${SITE_BASE$1}/co/co/ococ/CoOcOc.do`;
|
|
790
|
+
/** Public notice URL, which is also what readers of the newsletter follow. */
|
|
791
|
+
const buildG2bDetailUrl = (notice, order) => `${SITE_BASE$1}/link/PNPE027_01/single/?bidPbancNo=${notice}&bidPbancOrd=${order}`;
|
|
792
|
+
/**
|
|
793
|
+
* Business categories collected.
|
|
794
|
+
*
|
|
795
|
+
* 나라장터 splits its operations by 업무구분 and a notice only answers on the
|
|
796
|
+
* matching one. Heritage work appears as 용역 (발굴조사, 학술연구, 보존처리) and
|
|
797
|
+
* 공사 (수리, 정비); 물품 and 외자 carry none, so they are not requested.
|
|
798
|
+
*/
|
|
799
|
+
const OPERATIONS = ['Servc', 'Cnstwk'];
|
|
800
|
+
/** `resultCode` the service returns on success. */
|
|
801
|
+
const SUCCESS_RESULT_CODE = '00';
|
|
802
|
+
/** Offset the service's wall clock runs on. */
|
|
803
|
+
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
|
804
|
+
/**
|
|
805
|
+
* `YYYYMMDDHHMM` in KST, the format `inqryBgnDt` and `inqryEndDt` expect.
|
|
806
|
+
*
|
|
807
|
+
* The parameters carry no timezone and the service reads them as Korean local
|
|
808
|
+
* time, the same clock `bidNtceDt` is published on. Serialising UTC instead
|
|
809
|
+
* shifts the whole window back nine hours: on a live seven-day query that was
|
|
810
|
+
* 2,595 notices against 2,975, with the most recent 380 falling outside it.
|
|
811
|
+
*/
|
|
812
|
+
function toApiDateTime(date) {
|
|
813
|
+
return new Date(date.getTime() + KST_OFFSET_MS)
|
|
814
|
+
.toISOString()
|
|
815
|
+
.replace(/[-:T]/g, '')
|
|
816
|
+
.slice(0, 12);
|
|
817
|
+
}
|
|
818
|
+
/** `2026-09-15 13:42:20` to ISO `2026-09-15`. */
|
|
819
|
+
function toIsoDate$1(value) {
|
|
820
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value ?? '');
|
|
821
|
+
return match ? `${match[1]}-${match[2]}-${match[3]}` : '';
|
|
822
|
+
}
|
|
823
|
+
function jsonResponse(payload) {
|
|
824
|
+
return new Response(JSON.stringify(payload), {
|
|
825
|
+
status: 200,
|
|
826
|
+
headers: { 'content-type': 'application/json' },
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Serves the 나라장터 target from the 조달청 open API.
|
|
831
|
+
*
|
|
832
|
+
* The list page is answered by querying both business categories and merging
|
|
833
|
+
* them. Detail requests are answered from that same response — the list carries
|
|
834
|
+
* every field the article body needs, so no second round trip is made and the
|
|
835
|
+
* daily quota of 1,000 calls is barely touched.
|
|
836
|
+
*/
|
|
837
|
+
const createG2bFetch = (baseFetch = fetch, options) => {
|
|
838
|
+
const { apiKey, windowHours = 48, rowsPerPage = 999, maxPages = 4, triage, } = options;
|
|
839
|
+
/** Notices from the most recent list call, keyed by `<bidNtceNo>:<bidNtceOrd>`. */
|
|
840
|
+
const noticeCache = new Map();
|
|
841
|
+
return async (input, init) => {
|
|
842
|
+
const requestUrl = typeof input === 'string'
|
|
843
|
+
? input
|
|
844
|
+
: input instanceof URL
|
|
845
|
+
? input.href
|
|
846
|
+
: input.url;
|
|
847
|
+
let url;
|
|
848
|
+
try {
|
|
849
|
+
url = new URL(requestUrl);
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
return baseFetch(input, init);
|
|
853
|
+
}
|
|
854
|
+
if (url.origin !== SITE_BASE$1) {
|
|
855
|
+
return baseFetch(input, init);
|
|
856
|
+
}
|
|
857
|
+
if (url.pathname === new URL(G2B_LIST_URL).pathname) {
|
|
858
|
+
if (!apiKey) {
|
|
859
|
+
// Without a key the board is simply not collected: answer with an empty
|
|
860
|
+
// payload so the parsers yield nothing and no request is made.
|
|
861
|
+
return jsonResponse({ items: [] });
|
|
862
|
+
}
|
|
863
|
+
const end = new Date();
|
|
864
|
+
const begin = new Date(end.getTime() - windowHours * 60 * 60 * 1000);
|
|
865
|
+
// The two 업무구분 are independent queries, so they run together: walked
|
|
866
|
+
// one after the other they take about 12 seconds, which already exceeds
|
|
867
|
+
// the 10 seconds core allows a crawl fetch on its first attempt, before
|
|
868
|
+
// triage adds anything. Pages within one operation stay sequential
|
|
869
|
+
// because each page's size decides whether another is needed.
|
|
870
|
+
//
|
|
871
|
+
// A partial result is worse than none here: if 공사 fails while 용역
|
|
872
|
+
// succeeds, the run looks healthy — the target still yields articles and
|
|
873
|
+
// the health-check still passes — while every construction tender is
|
|
874
|
+
// missing. Fail the whole list instead, which core logs as
|
|
875
|
+
// `crawl.list.fetch.failed` and the health-check reports as a failure.
|
|
876
|
+
const pages = await Promise.all(OPERATIONS.map(async (operation) => {
|
|
877
|
+
const notices = [];
|
|
878
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
879
|
+
const response = await baseFetch(`${API_BASE$1}/getBidPblancListInfo${operation}?serviceKey=${apiKey}` +
|
|
880
|
+
`&numOfRows=${rowsPerPage}&pageNo=${page}&type=json&inqryDiv=1` +
|
|
881
|
+
`&inqryBgnDt=${toApiDateTime(begin)}&inqryEndDt=${toApiDateTime(end)}`, init);
|
|
882
|
+
if (!response.ok) {
|
|
883
|
+
return `나라장터 ${operation} list failed with HTTP ${response.status}`;
|
|
884
|
+
}
|
|
885
|
+
const body = (await response.json());
|
|
886
|
+
const resultCode = body.response?.header?.resultCode;
|
|
887
|
+
// data.go.kr answers its own errors with HTTP 200 and a result code.
|
|
888
|
+
if (resultCode !== SUCCESS_RESULT_CODE) {
|
|
889
|
+
return `나라장터 ${operation} list returned resultCode ${resultCode ?? 'none'}`;
|
|
890
|
+
}
|
|
891
|
+
const batch = body.response?.body?.items ?? [];
|
|
892
|
+
for (const notice of batch) {
|
|
893
|
+
notices.push({
|
|
894
|
+
...notice,
|
|
895
|
+
businessDivision: operation === 'Servc' ? '용역' : '공사',
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
if (batch.length < rowsPerPage) {
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
// Same reasoning as the partial-failure check above: a list that is
|
|
902
|
+
// silently short looks healthy. If the window no longer fits inside
|
|
903
|
+
// `maxPages`, the notices past the cap are never judged and never
|
|
904
|
+
// missed by anything downstream, so fail instead of truncating.
|
|
905
|
+
if (page === maxPages) {
|
|
906
|
+
const totalCount = body.response?.body?.totalCount ?? 0;
|
|
907
|
+
if (totalCount > maxPages * rowsPerPage) {
|
|
908
|
+
return (`나라장터 ${operation} window holds ${totalCount} notices, ` +
|
|
909
|
+
`beyond the ${maxPages * rowsPerPage} that ${maxPages} pages ` +
|
|
910
|
+
`can retrieve — raise maxPages or narrow windowHours`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return notices;
|
|
915
|
+
}));
|
|
916
|
+
const failure = pages.find((result) => typeof result === 'string');
|
|
917
|
+
if (typeof failure === 'string') {
|
|
918
|
+
return new Response(failure, {
|
|
919
|
+
status: 502,
|
|
920
|
+
statusText: 'Bad Gateway',
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
const collected = pages.flat();
|
|
924
|
+
// Deduplicate before judging: the same notice arrives on more than one
|
|
925
|
+
// page, and triage is billed per row.
|
|
926
|
+
const unique = [];
|
|
927
|
+
const seenKeys = new Set();
|
|
928
|
+
for (const notice of collected) {
|
|
929
|
+
const key = `${notice.bidNtceNo}:${notice.bidNtceOrd}`;
|
|
930
|
+
if (!notice.bidNtceNm || !notice.bidNtceNo || seenKeys.has(key)) {
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
seenKeys.add(key);
|
|
934
|
+
unique.push(notice);
|
|
935
|
+
}
|
|
936
|
+
const candidates = unique.map((notice) => ({
|
|
937
|
+
institution: `${notice.ntceInsttNm ?? ''} ${notice.dminsttNm ?? ''}`,
|
|
938
|
+
title: notice.bidNtceNm ?? '',
|
|
939
|
+
classification: notice.pubPrcrmntMidClsfcNm,
|
|
940
|
+
}));
|
|
941
|
+
// `init.signal` is core's crawl timeout. It reaches the data.go.kr calls
|
|
942
|
+
// above through `init`; triage has to be given it explicitly, or an
|
|
943
|
+
// aborted crawl would leave 22 LLM requests running and bill them again
|
|
944
|
+
// on the next attempt.
|
|
945
|
+
const verdicts = triage
|
|
946
|
+
? await triage(candidates, init?.signal ?? undefined)
|
|
947
|
+
: candidates.map((candidate) => isHeritageBidCandidate(candidate));
|
|
948
|
+
const heritage = unique.filter((_, index) => verdicts[index]);
|
|
949
|
+
// The cache backs the detail path, so it holds exactly what the list
|
|
950
|
+
// serves — dropping a notice from one and not the other either breaks its
|
|
951
|
+
// detail request or retains 1,500 entries to serve a few dozen.
|
|
952
|
+
noticeCache.clear();
|
|
953
|
+
for (const notice of heritage) {
|
|
954
|
+
noticeCache.set(`${notice.bidNtceNo}:${notice.bidNtceOrd}`, notice);
|
|
955
|
+
}
|
|
956
|
+
return jsonResponse({ items: heritage });
|
|
957
|
+
}
|
|
958
|
+
if (url.pathname === '/link/PNPE027_01/single/') {
|
|
959
|
+
const key = `${url.searchParams.get('bidPbancNo')}:${url.searchParams.get('bidPbancOrd')}`;
|
|
960
|
+
const notice = noticeCache.get(key);
|
|
961
|
+
if (notice) {
|
|
962
|
+
return jsonResponse({ item: notice });
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return baseFetch(input, init);
|
|
966
|
+
};
|
|
967
|
+
};
|
|
968
|
+
function parseJson$1(body) {
|
|
969
|
+
try {
|
|
970
|
+
return JSON.parse(body);
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
/** Renders a notice's fields as the article body. */
|
|
977
|
+
function renderG2bContent(notice) {
|
|
978
|
+
const won = (value) => {
|
|
979
|
+
const amount = Number(value);
|
|
980
|
+
return Number.isFinite(amount) && amount > 0
|
|
981
|
+
? `${amount.toLocaleString('ko-KR')}원`
|
|
982
|
+
: '';
|
|
983
|
+
};
|
|
984
|
+
const lines = [
|
|
985
|
+
['업무구분', notice.businessDivision],
|
|
986
|
+
['공고종류', notice.ntceKindNm],
|
|
987
|
+
['용역구분', notice.srvceDivNm],
|
|
988
|
+
['공고기관', notice.ntceInsttNm],
|
|
989
|
+
['수요기관', notice.dminsttNm],
|
|
990
|
+
[
|
|
991
|
+
'분류',
|
|
992
|
+
[notice.pubPrcrmntLrgClsfcNm, notice.pubPrcrmntMidClsfcNm]
|
|
993
|
+
.filter(Boolean)
|
|
994
|
+
.join(' > '),
|
|
995
|
+
],
|
|
996
|
+
['추정가격', won(notice.presmptPrce)],
|
|
997
|
+
['배정예산', won(notice.asignBdgtAmt)],
|
|
998
|
+
['계약방법', notice.cntrctCnclsMthdNm],
|
|
999
|
+
['낙찰방법', notice.sucsfbidMthdNm],
|
|
1000
|
+
['공고일시', notice.bidNtceDt],
|
|
1001
|
+
['입찰마감', notice.bidClseDt],
|
|
1002
|
+
['개찰일시', notice.opengDt],
|
|
1003
|
+
[
|
|
1004
|
+
'공고번호',
|
|
1005
|
+
[notice.bidNtceNo, notice.bidNtceOrd].filter(Boolean).join('-'),
|
|
1006
|
+
],
|
|
1007
|
+
[
|
|
1008
|
+
'담당자',
|
|
1009
|
+
[notice.ntceInsttOfclNm, notice.ntceInsttOfclTelNo]
|
|
1010
|
+
.filter(Boolean)
|
|
1011
|
+
.join(' '),
|
|
1012
|
+
],
|
|
1013
|
+
]
|
|
1014
|
+
.filter(([, value]) => value)
|
|
1015
|
+
.map(([label, value]) => `- **${label}**: ${String(value).trim()}`);
|
|
1016
|
+
return [`## ${notice.bidNtceNm ?? ''}`, '', ...lines].join('\n').trim();
|
|
1017
|
+
}
|
|
1018
|
+
/** Parses the merged list response, keeping only heritage-related notices. */
|
|
1019
|
+
const parseG2bList = (body) => {
|
|
1020
|
+
const parsed = parseJson$1(body);
|
|
1021
|
+
const posts = [];
|
|
1022
|
+
const seen = new Set();
|
|
1023
|
+
for (const notice of parsed?.items ?? []) {
|
|
1024
|
+
const title = notice.bidNtceNm?.trim();
|
|
1025
|
+
const number = notice.bidNtceNo;
|
|
1026
|
+
const order = notice.bidNtceOrd ?? '000';
|
|
1027
|
+
if (!title || !number) {
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
const key = `${number}:${order}`;
|
|
1031
|
+
if (seen.has(key)) {
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
seen.add(key);
|
|
1035
|
+
posts.push({
|
|
1036
|
+
uniqId: key,
|
|
1037
|
+
title,
|
|
1038
|
+
date: toIsoDate$1(notice.bidNtceDt),
|
|
1039
|
+
detailUrl: notice.bidNtceDtlUrl || buildG2bDetailUrl(number, order),
|
|
1040
|
+
dateType: DateType.REGISTERED,
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
return posts;
|
|
1044
|
+
};
|
|
1045
|
+
/** Parses a cached notice into the article body. */
|
|
1046
|
+
const parseG2bDetail = (body) => {
|
|
1047
|
+
const parsed = parseJson$1(body);
|
|
1048
|
+
return {
|
|
1049
|
+
detailContent: parsed?.item ? renderG2bContent(parsed.item) : '',
|
|
1050
|
+
hasAttachedFile: true,
|
|
1051
|
+
hasAttachedImage: false,
|
|
1052
|
+
};
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
const parseGimhaeMuseumList = (html, hrefPrefix) => {
|
|
1056
|
+
const $ = cheerio.load(html);
|
|
1057
|
+
const posts = [];
|
|
1058
|
+
const baseUrl = 'https://gimhae.museum.go.kr';
|
|
1059
|
+
$('table.board_list tbody tr').each((index, element) => {
|
|
1060
|
+
const columns = $(element).find('td');
|
|
1061
|
+
if (columns.length === 0) {
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const titleElement = columns.eq(1).find('a');
|
|
1065
|
+
const relativeHref = titleElement.attr('href');
|
|
1066
|
+
if (!relativeHref) {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const fullUrl = new URL(`${hrefPrefix}${relativeHref}`, baseUrl);
|
|
1070
|
+
const detailUrl = fullUrl.href;
|
|
1071
|
+
const uniqId = fullUrl.searchParams.get('no') ?? undefined;
|
|
1072
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
1073
|
+
const date = getDate(columns.eq(5).text().trim());
|
|
1074
|
+
posts.push({
|
|
1075
|
+
uniqId,
|
|
1076
|
+
title,
|
|
1077
|
+
date,
|
|
1078
|
+
detailUrl: cleanUrl(detailUrl),
|
|
1079
|
+
dateType: DateType.REGISTERED,
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
return posts;
|
|
1083
|
+
};
|
|
1084
|
+
const parseGimhaeMuseumDetail = (html) => {
|
|
1085
|
+
const $ = cheerio.load(html);
|
|
1086
|
+
const content = $('div.bbs--view--content');
|
|
1087
|
+
return {
|
|
1088
|
+
detailContent: new TurndownService().turndown(content.html() ?? ''),
|
|
1089
|
+
hasAttachedFile: $('div.bbs--view--file').length > 0,
|
|
1090
|
+
hasAttachedImage: content.find('img').length > 0,
|
|
1091
|
+
};
|
|
1092
|
+
};
|
|
1093
|
+
|
|
208
1094
|
const parseGogungList = (html) => {
|
|
209
1095
|
const $ = cheerio.load(html);
|
|
210
1096
|
const posts = [];
|
|
@@ -244,6 +1130,205 @@ const parseGogungDetail = (html) => {
|
|
|
244
1130
|
};
|
|
245
1131
|
};
|
|
246
1132
|
|
|
1133
|
+
const API_BASE = 'https://apis.data.go.kr/1760000/PblJobService';
|
|
1134
|
+
const SITE_BASE = 'https://www.gojobs.go.kr';
|
|
1135
|
+
/**
|
|
1136
|
+
* Public 나라일터 board URL used as this target's list page.
|
|
1137
|
+
*
|
|
1138
|
+
* Core fetches a target's `url` directly, so this stands in for the API call:
|
|
1139
|
+
* `createGojobsFetch` recognises it and queries `/getList` instead, which keeps
|
|
1140
|
+
* the service key and the date window out of the checked-in configuration.
|
|
1141
|
+
*/
|
|
1142
|
+
const GOJOBS_LIST_URL = `${SITE_BASE}/apmList.do`;
|
|
1143
|
+
/** Public posting URL, which is also what readers of the newsletter follow. */
|
|
1144
|
+
const buildGojobsDetailUrl = (idx) => `${SITE_BASE}/apmView.do?empmnsn=${idx}`;
|
|
1145
|
+
/** `yyyy-mm-dd`, the format `Begin_de` and `End_de` expect. */
|
|
1146
|
+
function toApiDate(date) {
|
|
1147
|
+
return date.toISOString().slice(0, 10);
|
|
1148
|
+
}
|
|
1149
|
+
/** `yyyymmdd` (as the API returns dates) to ISO `yyyy-mm-dd`. */
|
|
1150
|
+
function toIsoDate(compact) {
|
|
1151
|
+
return /^\d{8}$/.test(compact)
|
|
1152
|
+
? `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`
|
|
1153
|
+
: '';
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Serves the 나라일터 target from the 인사혁신처 open API.
|
|
1157
|
+
*
|
|
1158
|
+
* The board carries every public-sector vacancy in the country, and its
|
|
1159
|
+
* documented `Kwrd` search parameter is ignored by the service, so the whole
|
|
1160
|
+
* date window is requested and narrowed in `parseGojobsList`. `Begin_de` and
|
|
1161
|
+
* `End_de` do work and keep that window bounded.
|
|
1162
|
+
*
|
|
1163
|
+
* Detail requests to the public posting URL are answered from `/getItem`, which
|
|
1164
|
+
* returns the announcement body directly — no HTML page is fetched.
|
|
1165
|
+
*/
|
|
1166
|
+
const createGojobsFetch = (baseFetch = fetch, options) => {
|
|
1167
|
+
const { apiKey, windowDays = 7, rowsPerPage = 3000 } = options;
|
|
1168
|
+
return async (input, init) => {
|
|
1169
|
+
const requestUrl = typeof input === 'string'
|
|
1170
|
+
? input
|
|
1171
|
+
: input instanceof URL
|
|
1172
|
+
? input.href
|
|
1173
|
+
: input.url;
|
|
1174
|
+
let url;
|
|
1175
|
+
try {
|
|
1176
|
+
url = new URL(requestUrl);
|
|
1177
|
+
}
|
|
1178
|
+
catch {
|
|
1179
|
+
return baseFetch(input, init);
|
|
1180
|
+
}
|
|
1181
|
+
if (url.origin !== SITE_BASE) {
|
|
1182
|
+
return baseFetch(input, init);
|
|
1183
|
+
}
|
|
1184
|
+
if (!apiKey) {
|
|
1185
|
+
// Without a key the board is simply not collected: answer with an empty
|
|
1186
|
+
// payload so the parsers yield nothing and no request is made.
|
|
1187
|
+
return new Response('<response><body><items/></body></response>', {
|
|
1188
|
+
status: 200,
|
|
1189
|
+
headers: { 'content-type': 'application/xml' },
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
if (url.pathname === '/apmList.do') {
|
|
1193
|
+
const end = new Date();
|
|
1194
|
+
const begin = new Date(end.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
|
1195
|
+
return baseFetch(`${API_BASE}/getList?serviceKey=${apiKey}&numOfRows=${rowsPerPage}` +
|
|
1196
|
+
`&pageNo=1&Begin_de=${toApiDate(begin)}&End_de=${toApiDate(end)}`, init);
|
|
1197
|
+
}
|
|
1198
|
+
if (url.pathname === '/apmView.do') {
|
|
1199
|
+
const idx = url.searchParams.get('empmnsn');
|
|
1200
|
+
if (idx) {
|
|
1201
|
+
return baseFetch(`${API_BASE}/getItem?serviceKey=${apiKey}&idx=${idx}`, init);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return baseFetch(input, init);
|
|
1205
|
+
};
|
|
1206
|
+
};
|
|
1207
|
+
function textOf($, item, tag) {
|
|
1208
|
+
return $(item).find(tag).first().text().trim();
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Parses `/getList` responses, keeping only heritage-related postings.
|
|
1212
|
+
*
|
|
1213
|
+
* Several concatenated pages may arrive in one body; Cheerio's XML mode reads
|
|
1214
|
+
* them as a single document, which is what the fetch above relies on.
|
|
1215
|
+
*/
|
|
1216
|
+
const parseGojobsList = (xml) => {
|
|
1217
|
+
const $ = cheerio.load(xml, { xml: true });
|
|
1218
|
+
const posts = [];
|
|
1219
|
+
const seen = new Set();
|
|
1220
|
+
$('item').each((_, element) => {
|
|
1221
|
+
const idx = textOf($, element, 'idx');
|
|
1222
|
+
const title = textOf($, element, 'title');
|
|
1223
|
+
const institution = textOf($, element, 'insttname');
|
|
1224
|
+
if (!idx || !title || seen.has(idx)) {
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
if (!isHeritageJobCandidate({ institution, title })) {
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
seen.add(idx);
|
|
1231
|
+
posts.push({
|
|
1232
|
+
uniqId: idx,
|
|
1233
|
+
title,
|
|
1234
|
+
date: toIsoDate(textOf($, element, 'regdate')),
|
|
1235
|
+
detailUrl: buildGojobsDetailUrl(idx),
|
|
1236
|
+
dateType: DateType.REGISTERED,
|
|
1237
|
+
});
|
|
1238
|
+
});
|
|
1239
|
+
return posts;
|
|
1240
|
+
};
|
|
1241
|
+
/** Parses a `/getItem` response into the article body. */
|
|
1242
|
+
const parseGojobsDetail = (xml) => {
|
|
1243
|
+
const $ = cheerio.load(xml, { xml: true });
|
|
1244
|
+
const item = $('item').first();
|
|
1245
|
+
const field = (tag) => item.find(tag).first().text().trim();
|
|
1246
|
+
const lines = [
|
|
1247
|
+
['기관', field('insttname')],
|
|
1248
|
+
['지역', field('areaname')],
|
|
1249
|
+
['공고일', toIsoDate(field('regdate'))],
|
|
1250
|
+
['마감일', toIsoDate(field('enddate'))],
|
|
1251
|
+
]
|
|
1252
|
+
.filter(([, value]) => value)
|
|
1253
|
+
.map(([label, value]) => `- **${label}**: ${value}`);
|
|
1254
|
+
const contents = field('contents');
|
|
1255
|
+
return {
|
|
1256
|
+
detailContent: [`## ${field('title')}`, '', ...lines, '', contents]
|
|
1257
|
+
.join('\n')
|
|
1258
|
+
.trim(),
|
|
1259
|
+
hasAttachedFile: true,
|
|
1260
|
+
hasAttachedImage: false,
|
|
1261
|
+
};
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
const parseGyeongjuMuseumList = (html, hrefPrefix) => {
|
|
1265
|
+
const $ = cheerio.load(html);
|
|
1266
|
+
const posts = [];
|
|
1267
|
+
const baseUrl = 'https://gyeongju.museum.go.kr';
|
|
1268
|
+
$('table tbody tr').each((index, element) => {
|
|
1269
|
+
const columns = $(element).find('td');
|
|
1270
|
+
if (columns.length === 0) {
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
const titleElement = columns.eq(1).find('a');
|
|
1274
|
+
const relativeHref = titleElement.attr('href');
|
|
1275
|
+
if (!relativeHref) {
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
const fullUrl = new URL(`${hrefPrefix}${relativeHref}`, baseUrl);
|
|
1279
|
+
const detailUrl = fullUrl.href;
|
|
1280
|
+
const uniqId = fullUrl.searchParams.get('no') ?? undefined;
|
|
1281
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
1282
|
+
const date = getDate(columns.eq(4).text().trim());
|
|
1283
|
+
posts.push({
|
|
1284
|
+
uniqId,
|
|
1285
|
+
title,
|
|
1286
|
+
date,
|
|
1287
|
+
detailUrl: cleanUrl(detailUrl),
|
|
1288
|
+
dateType: DateType.REGISTERED,
|
|
1289
|
+
});
|
|
1290
|
+
});
|
|
1291
|
+
return posts;
|
|
1292
|
+
};
|
|
1293
|
+
const parseGyeongjuMuseumNoticeList = (html) => {
|
|
1294
|
+
const $ = cheerio.load(html);
|
|
1295
|
+
const posts = [];
|
|
1296
|
+
const baseUrl = 'https://gyeongju.museum.go.kr';
|
|
1297
|
+
$('table tbody tr').each((index, element) => {
|
|
1298
|
+
const columns = $(element).find('td');
|
|
1299
|
+
if (columns.length === 0) {
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const titleElement = columns.eq(3).find('a');
|
|
1303
|
+
const relativeHref = titleElement.attr('href');
|
|
1304
|
+
if (!relativeHref) {
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const fullUrl = new URL(`/kor/html/sub07/0703.html${relativeHref}`, baseUrl);
|
|
1308
|
+
const detailUrl = fullUrl.href;
|
|
1309
|
+
const uniqId = fullUrl.searchParams.get('no') ?? undefined;
|
|
1310
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
1311
|
+
const date = getDate(columns.eq(5).text().trim());
|
|
1312
|
+
posts.push({
|
|
1313
|
+
uniqId,
|
|
1314
|
+
title,
|
|
1315
|
+
date,
|
|
1316
|
+
detailUrl: cleanUrl(detailUrl),
|
|
1317
|
+
dateType: DateType.REGISTERED,
|
|
1318
|
+
});
|
|
1319
|
+
});
|
|
1320
|
+
return posts;
|
|
1321
|
+
};
|
|
1322
|
+
const parseGyeongjuMuseumDetail = (html) => {
|
|
1323
|
+
const $ = cheerio.load(html);
|
|
1324
|
+
const content = $('div.bd_detail_cont');
|
|
1325
|
+
return {
|
|
1326
|
+
detailContent: new TurndownService().turndown(content.html() ?? ''),
|
|
1327
|
+
hasAttachedFile: $('div.bd_detail_file ul.file').length > 0,
|
|
1328
|
+
hasAttachedImage: content.find('img').length > 0,
|
|
1329
|
+
};
|
|
1330
|
+
};
|
|
1331
|
+
|
|
247
1332
|
const parseHeritageAgencyList = (html) => {
|
|
248
1333
|
const $ = cheerio.load(html);
|
|
249
1334
|
const posts = [];
|
|
@@ -324,6 +1409,45 @@ const parseHsasDetail = (html) => {
|
|
|
324
1409
|
};
|
|
325
1410
|
};
|
|
326
1411
|
|
|
1412
|
+
const parseIksanMuseumList = (html) => {
|
|
1413
|
+
const $ = cheerio.load(html);
|
|
1414
|
+
const posts = [];
|
|
1415
|
+
const baseUrl = 'https://iksan.museum.go.kr';
|
|
1416
|
+
$('div.bd_list_wrap table tbody tr').each((index, element) => {
|
|
1417
|
+
const columns = $(element).find('td');
|
|
1418
|
+
if (columns.length === 0) {
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
const titleElement = columns.eq(1).find('a');
|
|
1422
|
+
const relativeHref = titleElement.attr('href');
|
|
1423
|
+
if (!relativeHref) {
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const fullUrl = new URL(`/kor/html/sub05/0501.html${relativeHref}`, baseUrl);
|
|
1427
|
+
const detailUrl = fullUrl.href;
|
|
1428
|
+
const uniqId = fullUrl.searchParams.get('no') ?? undefined;
|
|
1429
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
1430
|
+
const date = getDate(columns.eq(5).text().trim());
|
|
1431
|
+
posts.push({
|
|
1432
|
+
uniqId,
|
|
1433
|
+
title,
|
|
1434
|
+
date,
|
|
1435
|
+
detailUrl: cleanUrl(detailUrl),
|
|
1436
|
+
dateType: DateType.REGISTERED,
|
|
1437
|
+
});
|
|
1438
|
+
});
|
|
1439
|
+
return posts;
|
|
1440
|
+
};
|
|
1441
|
+
const parseIksanMuseumDetail = (html) => {
|
|
1442
|
+
const $ = cheerio.load(html);
|
|
1443
|
+
const content = $('div.bd_detail_cont');
|
|
1444
|
+
return {
|
|
1445
|
+
detailContent: new TurndownService().turndown(content.html() ?? ''),
|
|
1446
|
+
hasAttachedFile: $('ul.file li').length > 0,
|
|
1447
|
+
hasAttachedImage: content.find('img').length > 0,
|
|
1448
|
+
};
|
|
1449
|
+
};
|
|
1450
|
+
|
|
327
1451
|
const parseJbgogoList = (html) => {
|
|
328
1452
|
const $ = cheerio.load(html);
|
|
329
1453
|
const posts = [];
|
|
@@ -364,6 +1488,45 @@ const parseJbgogoDetail = (html) => {
|
|
|
364
1488
|
};
|
|
365
1489
|
};
|
|
366
1490
|
|
|
1491
|
+
const parseJejuMuseumList = (html) => {
|
|
1492
|
+
const $ = cheerio.load(html);
|
|
1493
|
+
const posts = [];
|
|
1494
|
+
const baseUrl = 'https://jeju.museum.go.kr';
|
|
1495
|
+
$('div.board_list table tbody tr').each((index, element) => {
|
|
1496
|
+
const columns = $(element).find('td');
|
|
1497
|
+
if (columns.length === 0) {
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
const titleElement = columns.eq(1).find('a');
|
|
1501
|
+
const relativeHref = titleElement.attr('href');
|
|
1502
|
+
if (!relativeHref) {
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
const fullUrl = new URL(relativeHref.replace('./', '/_prog/_board/'), baseUrl);
|
|
1506
|
+
const detailUrl = fullUrl.href;
|
|
1507
|
+
const uniqId = fullUrl.searchParams.get('no') ?? undefined;
|
|
1508
|
+
const title = titleElement.text()?.trim() ?? '';
|
|
1509
|
+
const date = getDate(columns.eq(3).text().trim());
|
|
1510
|
+
posts.push({
|
|
1511
|
+
uniqId,
|
|
1512
|
+
title,
|
|
1513
|
+
date,
|
|
1514
|
+
detailUrl: cleanUrl(detailUrl),
|
|
1515
|
+
dateType: DateType.REGISTERED,
|
|
1516
|
+
});
|
|
1517
|
+
});
|
|
1518
|
+
return posts;
|
|
1519
|
+
};
|
|
1520
|
+
const parseJejuMuseumDetail = (html) => {
|
|
1521
|
+
const $ = cheerio.load(html);
|
|
1522
|
+
const content = $('div.board_viewDetail');
|
|
1523
|
+
return {
|
|
1524
|
+
detailContent: new TurndownService().turndown(content.html() ?? ''),
|
|
1525
|
+
hasAttachedFile: $('li.file div a').length > 0,
|
|
1526
|
+
hasAttachedImage: content.find('img').length > 0,
|
|
1527
|
+
};
|
|
1528
|
+
};
|
|
1529
|
+
|
|
367
1530
|
const parseJeonjuMuseumList = (html) => {
|
|
368
1531
|
const $ = cheerio.load(html);
|
|
369
1532
|
const posts = [];
|
|
@@ -1627,74 +2790,69 @@ function createCrawlingTargetGroups(customFetch) {
|
|
|
1627
2790
|
parseList: parseJinjuMuseumList,
|
|
1628
2791
|
parseDetail: parseJinjuMuseumDetail,
|
|
1629
2792
|
},
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
// name: '국립익산박물관 공지사항',
|
|
1694
|
-
// url: 'https://iksan.museum.go.kr/kor/html/sub05/0501.html',
|
|
1695
|
-
// parseList: parseIksanMuseumList,
|
|
1696
|
-
// parseDetail: parseIksanMuseumDetail,
|
|
1697
|
-
// },
|
|
2793
|
+
{
|
|
2794
|
+
id: '국립경주박물관_새소식',
|
|
2795
|
+
name: '국립경주박물관 새소식',
|
|
2796
|
+
url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0701.html',
|
|
2797
|
+
parseList: (html) => parseGyeongjuMuseumList(html, '/kor/html/sub07/0701.html'),
|
|
2798
|
+
parseDetail: parseGyeongjuMuseumDetail,
|
|
2799
|
+
},
|
|
2800
|
+
{
|
|
2801
|
+
id: '국립경주박물관_고시공고',
|
|
2802
|
+
name: '국립경주박물관 고시/공고',
|
|
2803
|
+
url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0703.html',
|
|
2804
|
+
parseList: parseGyeongjuMuseumNoticeList,
|
|
2805
|
+
parseDetail: parseGyeongjuMuseumDetail,
|
|
2806
|
+
},
|
|
2807
|
+
{
|
|
2808
|
+
id: '국립경주박물관_보도자료',
|
|
2809
|
+
name: '국립경주박물관 보도자료',
|
|
2810
|
+
url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0705.html',
|
|
2811
|
+
parseList: (html) => parseGyeongjuMuseumList(html, '/kor/html/sub07/0705.html'),
|
|
2812
|
+
parseDetail: parseGyeongjuMuseumDetail,
|
|
2813
|
+
},
|
|
2814
|
+
{
|
|
2815
|
+
id: '국립청주박물관_새소식',
|
|
2816
|
+
name: '국립청주박물관 새소식',
|
|
2817
|
+
url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=1&key=482&nbar=s',
|
|
2818
|
+
parseList: parseCheongjuMuseumList,
|
|
2819
|
+
parseDetail: parseCheongjuMuseumDetail,
|
|
2820
|
+
},
|
|
2821
|
+
{
|
|
2822
|
+
id: '국립청주박물관_언론보도자료',
|
|
2823
|
+
name: '국립청주박물관 언론보도자료',
|
|
2824
|
+
url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=20&key=31&nbar=s',
|
|
2825
|
+
parseList: parseCheongjuMuseumList,
|
|
2826
|
+
parseDetail: parseCheongjuMuseumDetail,
|
|
2827
|
+
},
|
|
2828
|
+
{
|
|
2829
|
+
id: '국립김해박물관_새소식',
|
|
2830
|
+
name: '국립김해박물관 새소식',
|
|
2831
|
+
url: 'https://gimhae.museum.go.kr/kr/html/sub04/0401.html',
|
|
2832
|
+
parseList: (html) => parseGimhaeMuseumList(html, '/kr/html/sub04/0401.html'),
|
|
2833
|
+
parseDetail: parseGimhaeMuseumDetail,
|
|
2834
|
+
},
|
|
2835
|
+
{
|
|
2836
|
+
id: '국립김해박물관_보도자료',
|
|
2837
|
+
name: '국립김해박물관 언론보도자료',
|
|
2838
|
+
url: 'https://gimhae.museum.go.kr/kr/html/sub04/0402.html',
|
|
2839
|
+
parseList: (html) => parseGimhaeMuseumList(html, '/kr/html/sub04/0402.html'),
|
|
2840
|
+
parseDetail: parseGimhaeMuseumDetail,
|
|
2841
|
+
},
|
|
2842
|
+
{
|
|
2843
|
+
id: '국립제주박물관_새소식',
|
|
2844
|
+
name: '국립제주박물관 새소식',
|
|
2845
|
+
url: 'https://jeju.museum.go.kr/_prog/_board/?code=sub02_0201&site_dvs_cd=kr&menu_dvs_cd=050101&ntt_tag=1',
|
|
2846
|
+
parseList: parseJejuMuseumList,
|
|
2847
|
+
parseDetail: parseJejuMuseumDetail,
|
|
2848
|
+
},
|
|
2849
|
+
{
|
|
2850
|
+
id: '국립익산박물관_공지사항',
|
|
2851
|
+
name: '국립익산박물관 공지사항',
|
|
2852
|
+
url: 'https://iksan.museum.go.kr/kor/html/sub05/0501.html',
|
|
2853
|
+
parseList: parseIksanMuseumList,
|
|
2854
|
+
parseDetail: parseIksanMuseumDetail,
|
|
2855
|
+
},
|
|
1698
2856
|
],
|
|
1699
2857
|
},
|
|
1700
2858
|
{
|
|
@@ -1729,6 +2887,20 @@ function createCrawlingTargetGroups(customFetch) {
|
|
|
1729
2887
|
parseList: parseKaahList,
|
|
1730
2888
|
parseDetail: parseKaahDetail,
|
|
1731
2889
|
},
|
|
2890
|
+
// Served from the 조달청 open API rather than scraped. 나라장터 carries
|
|
2891
|
+
// every public procurement notice in the country — about 2,100 in a
|
|
2892
|
+
// 48-hour window — so `createG2bFetch` narrows the list before it is
|
|
2893
|
+
// served: LLM triage when the generator supplies it, and the
|
|
2894
|
+
// deterministic filter in `src/crawling/heritage-job-filter.ts`
|
|
2895
|
+
// otherwise. 용역 and 공사 are merged into this one target. Without an
|
|
2896
|
+
// API key it yields nothing and makes no request.
|
|
2897
|
+
{
|
|
2898
|
+
id: '나라장터_입찰공고',
|
|
2899
|
+
name: '나라장터 입찰공고',
|
|
2900
|
+
url: G2B_LIST_URL,
|
|
2901
|
+
parseList: parseG2bList,
|
|
2902
|
+
parseDetail: parseG2bDetail,
|
|
2903
|
+
},
|
|
1732
2904
|
],
|
|
1733
2905
|
},
|
|
1734
2906
|
{
|
|
@@ -1784,29 +2956,46 @@ function createCrawlingTargetGroups(customFetch) {
|
|
|
1784
2956
|
parseList: (html) => parseBuyeoMuseumList(html, '2301270001'),
|
|
1785
2957
|
parseDetail: parseBuyeoMuseumDetail,
|
|
1786
2958
|
},
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
2959
|
+
{
|
|
2960
|
+
id: '국립경주박물관_채용안내',
|
|
2961
|
+
name: '국립경주박물관 채용안내',
|
|
2962
|
+
url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0704.html',
|
|
2963
|
+
parseList: (html) => parseGyeongjuMuseumList(html, '/kor/html/sub07/0704.html'),
|
|
2964
|
+
parseDetail: parseGyeongjuMuseumDetail,
|
|
2965
|
+
},
|
|
2966
|
+
{
|
|
2967
|
+
id: '국립청주박물관_채용및공고',
|
|
2968
|
+
name: '국립청주박물관 채용 및 공고',
|
|
2969
|
+
url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=29&key=476&nbar=s',
|
|
2970
|
+
parseList: parseCheongjuMuseumList,
|
|
2971
|
+
parseDetail: parseCheongjuMuseumDetail,
|
|
2972
|
+
},
|
|
2973
|
+
{
|
|
2974
|
+
id: '국립제주박물관_채용정보',
|
|
2975
|
+
name: '국립제주박물관 채용정보',
|
|
2976
|
+
url: 'https://jeju.museum.go.kr/_prog/_board/?code=sub02_0201&site_dvs_cd=kr&menu_dvs_cd=050102&ntt_tag=2',
|
|
2977
|
+
parseList: parseJejuMuseumList,
|
|
2978
|
+
parseDetail: parseJejuMuseumDetail,
|
|
2979
|
+
},
|
|
2980
|
+
// Served from data.go.kr open APIs rather than scraped. Both boards
|
|
2981
|
+
// carry every public-sector vacancy in the country, so their parsers
|
|
2982
|
+
// apply the heritage filter in `src/crawling/heritage-job-filter.ts`
|
|
2983
|
+
// before anything reaches analysis. Without an API key they yield
|
|
2984
|
+
// nothing and make no request.
|
|
2985
|
+
{
|
|
2986
|
+
id: '나라일터_채용공고',
|
|
2987
|
+
name: '나라일터 채용공고',
|
|
2988
|
+
url: GOJOBS_LIST_URL,
|
|
2989
|
+
parseList: parseGojobsList,
|
|
2990
|
+
parseDetail: parseGojobsDetail,
|
|
2991
|
+
},
|
|
2992
|
+
{
|
|
2993
|
+
id: '알리오_공공기관_채용공고',
|
|
2994
|
+
name: '알리오 공공기관 채용공고',
|
|
2995
|
+
url: ALIO_LIST_URL,
|
|
2996
|
+
parseList: parseAlioList,
|
|
2997
|
+
parseDetail: parseAlioDetail,
|
|
2998
|
+
},
|
|
1810
2999
|
],
|
|
1811
3000
|
},
|
|
1812
3001
|
];
|
|
@@ -1841,6 +3030,44 @@ const newsletterConfig = {
|
|
|
1841
3030
|
priorityArticleScoreThreshold: 8,
|
|
1842
3031
|
},
|
|
1843
3032
|
};
|
|
3033
|
+
/**
|
|
3034
|
+
* Maximum importance score per heritage domain (`tag1`).
|
|
3035
|
+
*
|
|
3036
|
+
* The newsletter is archaeology-first: archaeology and cultural heritage keep
|
|
3037
|
+
* the full 1-10 range, while natural and intangible heritage are capped so they
|
|
3038
|
+
* cannot crowd out archaeological coverage. `기타` — material that is not
|
|
3039
|
+
* heritage at all — is capped lowest, because the scoring prompt's academic-value
|
|
3040
|
+
* floor is domain-blind and would otherwise lift things like a general journal's
|
|
3041
|
+
* call for papers into the top half of the scale.
|
|
3042
|
+
*
|
|
3043
|
+
* The cap is applied deterministically after scoring, in
|
|
3044
|
+
* `AnalysisProvider.update()`, rather than asked for in the prompt, so the
|
|
3045
|
+
* ceiling always holds. It never reaches 1: a score of 1 means "exclude from the
|
|
3046
|
+
* newsletter" in the consuming application's candidate query, so capping to 1
|
|
3047
|
+
* would delete these articles instead of demoting them.
|
|
3048
|
+
*
|
|
3049
|
+
* Domains absent from this map are not capped.
|
|
3050
|
+
*/
|
|
3051
|
+
const maximumImportanceScoreByDomain = {
|
|
3052
|
+
자연유산: 6,
|
|
3053
|
+
무형유산: 6,
|
|
3054
|
+
기타: 5,
|
|
3055
|
+
};
|
|
3056
|
+
/**
|
|
3057
|
+
* Origins exempted from the robots.txt check.
|
|
3058
|
+
*
|
|
3059
|
+
* Each entry deliberately overrides what the site publishes, so it needs a
|
|
3060
|
+
* reason and should be revisited when that site's robots.txt changes.
|
|
3061
|
+
*
|
|
3062
|
+
* - `http://www.yngogo.or.kr` (영남고고학회): its board is rendered from
|
|
3063
|
+
* `/module/ntt/unity/selectNttListAjax.ink`, and robots.txt carries a blanket
|
|
3064
|
+
* `Disallow: /module`. The rule reads as protecting an internal path rather
|
|
3065
|
+
* than the public board it happens to serve, and there is no other route to
|
|
3066
|
+
* the listing, so the society's boards are collected under this exemption.
|
|
3067
|
+
*/
|
|
3068
|
+
const robotsExemptOrigins = [
|
|
3069
|
+
'http://www.yngogo.or.kr',
|
|
3070
|
+
];
|
|
1844
3071
|
/**
|
|
1845
3072
|
* LLM configuration
|
|
1846
3073
|
*/
|
|
@@ -1852,6 +3079,560 @@ const llmConfig = {
|
|
|
1852
3079
|
},
|
|
1853
3080
|
};
|
|
1854
3081
|
|
|
3082
|
+
/**
|
|
3083
|
+
* Fixed vocabulary for `tag1`, the heritage domain of an article.
|
|
3084
|
+
*
|
|
3085
|
+
* Downstream scoring depends on this being a closed set: importance weighting
|
|
3086
|
+
* ranks archaeology first and cultural heritage second, and natural/intangible
|
|
3087
|
+
* heritage are capped. A free-form tag cannot drive either rule reliably, so
|
|
3088
|
+
* `tag1` is constrained here while `tag2`/`tag3` stay open.
|
|
3089
|
+
*
|
|
3090
|
+
* `기타` exists so that non-heritage material — catering or security job
|
|
3091
|
+
* postings, general administration — is labelled honestly instead of being
|
|
3092
|
+
* forced into a heritage domain it does not belong to.
|
|
3093
|
+
*/
|
|
3094
|
+
const HERITAGE_DOMAIN_TAGS = [
|
|
3095
|
+
'고고학',
|
|
3096
|
+
'문화유산',
|
|
3097
|
+
'자연유산',
|
|
3098
|
+
'무형유산',
|
|
3099
|
+
'기타',
|
|
3100
|
+
];
|
|
3101
|
+
/** Narrows an arbitrary tag value to the fixed vocabulary. */
|
|
3102
|
+
function toHeritageDomainTag(tag) {
|
|
3103
|
+
const normalized = tag?.trim();
|
|
3104
|
+
return HERITAGE_DOMAIN_TAGS.includes(normalized)
|
|
3105
|
+
? normalized
|
|
3106
|
+
: null;
|
|
3107
|
+
}
|
|
3108
|
+
const DOMAIN_RULES = `## tag1 — 유산 영역 (고정 어휘, 아래 5개 중 정확히 하나)
|
|
3109
|
+
|
|
3110
|
+
- **고고학**: 발굴조사, 매장유산, 유적·유물 조사, 발굴현장 공개, 발굴조사보고서, 고고학 학술대회·학회 소식
|
|
3111
|
+
- **문화유산**: 건조물·미술·기록 등 유형유산, 국보·보물 지정과 보존처리, 박물관 전시·소장품, 유산 정책·제도
|
|
3112
|
+
- **자연유산**: 천연기념물, 명승, 지질유산, 유산으로서의 동식물
|
|
3113
|
+
- **무형유산**: 전통 기예·의례·공연, 보유자·전승자, 전승 교육
|
|
3114
|
+
- **기타**: 위 넷 중 어디에도 해당하지 않는 내용. 유산과 무관한 채용(조리·방호·시설관리 등), 일반 행정·회계 공고가 여기 해당한다.
|
|
3115
|
+
|
|
3116
|
+
여러 영역에 걸치면 **고고학 > 문화유산 > 자연유산 > 무형유산** 순으로 앞선 것을 고른다.
|
|
3117
|
+
예: 매장유산 발굴 성과를 다루는 국립박물관 전시 기사는 \`고고학\`.
|
|
3118
|
+
|
|
3119
|
+
tag1은 반드시 이 5개 문자열 중 하나여야 한다. 변형·수식·조합은 허용하지 않는다.`;
|
|
3120
|
+
/**
|
|
3121
|
+
* Replaces core's default tag prompt.
|
|
3122
|
+
*
|
|
3123
|
+
* Core's default asks for three free-form tags. This version pins `tag1` to the
|
|
3124
|
+
* heritage domain vocabulary and keeps the default's reuse behaviour for the
|
|
3125
|
+
* remaining two, so the existing tag pool stays consistent.
|
|
3126
|
+
*/
|
|
3127
|
+
const classifyTagsPrompt = {
|
|
3128
|
+
system: ({ outputLanguage }) => `당신은 한국 문화유산 분야 기사를 분류하는 전문가다. 기사마다 태그 3개를 매긴다.
|
|
3129
|
+
|
|
3130
|
+
${DOMAIN_RULES}
|
|
3131
|
+
|
|
3132
|
+
## tag2, tag3 — 주제 태그 (자유 어휘)
|
|
3133
|
+
|
|
3134
|
+
- 기사의 구체적인 주제를 나타낸다. 예: 발굴현장공개, 학술대회, 채용, 보존처리, 특별전
|
|
3135
|
+
- 제공된 기존 태그 목록과 80% 이상 들어맞으면 그 태그를 그대로 재사용한다. 분류 체계가 흩어지지 않게 하는 것이 새 태그를 만드는 것보다 중요하다.
|
|
3136
|
+
- 80%에 못 미칠 때만 새로 만든다. 새 태그는 비슷한 기사 여러 건에 두루 쓰일 수 있어야 한다.
|
|
3137
|
+
- 길이 3~15자, ${outputLanguage}로 작성한다.
|
|
3138
|
+
- tag1의 5개 어휘(고고학, 문화유산, 자연유산, 무형유산, 기타)는 너무 포괄적이므로 tag2, tag3에 쓰지 않는다.
|
|
3139
|
+
- tag2와 tag3은 서로 달라야 한다.`,
|
|
3140
|
+
user: ({ targetArticle, existTags }) => `아래 기사를 분류하라.
|
|
3141
|
+
|
|
3142
|
+
## 기사
|
|
3143
|
+
|
|
3144
|
+
- 제목: ${targetArticle.title}
|
|
3145
|
+
- 본문:
|
|
3146
|
+
${targetArticle.detailContent}
|
|
3147
|
+
|
|
3148
|
+
## 기존 태그 목록 (tag2, tag3 재사용 후보)
|
|
3149
|
+
|
|
3150
|
+
\`\`\`json
|
|
3151
|
+
${JSON.stringify(existTags, null, 2)}
|
|
3152
|
+
\`\`\`
|
|
3153
|
+
|
|
3154
|
+
## 출력
|
|
3155
|
+
|
|
3156
|
+
- tag1: 유산 영역. 고고학, 문화유산, 자연유산, 무형유산, 기타 중 정확히 하나.
|
|
3157
|
+
- tag2: 주제 태그.
|
|
3158
|
+
- tag3: 주제 태그. tag2와 다른 것.`,
|
|
3159
|
+
};
|
|
3160
|
+
|
|
3161
|
+
/**
|
|
3162
|
+
* Resolves the score floor core would apply for this article's board.
|
|
3163
|
+
*
|
|
3164
|
+
* `minimumImportanceScoreRules` is matched by `targetUrl` and raises the bottom
|
|
3165
|
+
* of the scale for boards that only ever carry relevant material. Core's default
|
|
3166
|
+
* prompt also drops the "score 1 = no practical value" tier and the temporal
|
|
3167
|
+
* expiry rule whenever a floor is in effect, since neither can be expressed
|
|
3168
|
+
* below the floor. The replacement below reproduces that.
|
|
3169
|
+
*/
|
|
3170
|
+
function resolveMinimumScore({ targetArticle, minimumImportanceScoreRules, }) {
|
|
3171
|
+
const rule = minimumImportanceScoreRules.find(({ targetUrl }) => targetUrl === targetArticle.targetUrl);
|
|
3172
|
+
return rule?.minScore ?? 1;
|
|
3173
|
+
}
|
|
3174
|
+
const DOMAIN_PRIORITY = `## 유산 영역 우선순위
|
|
3175
|
+
|
|
3176
|
+
이 뉴스레터는 고고학을 중심에 둔다. 사안의 객관적 무게가 비슷하다면 아래 순서로 점수를 준다.
|
|
3177
|
+
|
|
3178
|
+
1. **고고학** — 발굴조사, 매장유산, 유적·유물, 발굴현장 공개, 고고학 학술 활동
|
|
3179
|
+
2. **문화유산** — 유형유산 지정·보존, 박물관 활동, 유산 정책
|
|
3180
|
+
3. **자연유산 / 무형유산**
|
|
3181
|
+
|
|
3182
|
+
판단이 애매할 때 고고학 기사는 고려 중인 점수대의 위쪽을, 자연유산·무형유산 기사는 아래쪽을 택한다.
|
|
3183
|
+
영역은 tag1에 표기되어 있다. 단, 영역만으로 점수를 정하지는 않는다 — 사안 자체가 미미한 고고학 기사보다
|
|
3184
|
+
중대한 문화유산 기사가 높은 점수를 받는 것이 옳다.`;
|
|
3185
|
+
const SUBJECT_OVER_INSTITUTION = `## 발주·공고 기관이 아니라 사업 내용으로 판단
|
|
3186
|
+
|
|
3187
|
+
유산 기관이 낸 공고라도 **내용이 유산 업무가 아니면 낮게 준다.** 기관명은 단서일 뿐 근거가 아니다.
|
|
3188
|
+
|
|
3189
|
+
- 유산 업무: 발굴·시굴조사, 매장유산, 문화재 수리·보수정비, 보존처리, 기록화, 유적 정비,
|
|
3190
|
+
학술연구, 유물 조사·분석, 전시 기획
|
|
3191
|
+
- 유산 업무가 아닌 것: 소방·전기·냉난방·창호·정보통신 같은 건물 설비와 그 유지보수,
|
|
3192
|
+
청사 환경개선, 홈페이지·전산, 차량 임차, 홍보물·영상 제작,
|
|
3193
|
+
축제·행사의 운영과 그 부대 용역(무대, 홍보, 교통·안전관리 포함)
|
|
3194
|
+
→ 국가유산청·국립박물관이 발주했더라도 **1점**. 발주처가 유산 기관이라는 이유로
|
|
3195
|
+
2-3점으로 올리지 않는다. 유산 업무가 아니면 1점이다.
|
|
3196
|
+
|
|
3197
|
+
예: "창덕궁 미분무 소화설비 성능개선 공사"는 궁궐 소재지만 소방 설비 공사다.
|
|
3198
|
+
"대전선사박물관 창호 긴급교체 공사"도 박물관 건물의 창호 교체다.
|
|
3199
|
+
"제72회 백제문화제 혼잡·교통유도 및 차량동선 안전관리 용역", "2026 경북종가음식문화대전
|
|
3200
|
+
사업 운영 용역", "청춘양구 펀치볼 사과시래기축제 홍보물 제작"은 모두 행사 운영·지원이다.
|
|
3201
|
+
"국립항공박물관 정보통신설비 유지보수" 역시 건물 설비 유지보수다.
|
|
3202
|
+
반대로 "팔만대장경 P-XRF 분석"은 발주처가 사찰이어도 명백한 유산 조사이고,
|
|
3203
|
+
"안양암 아미타괘불도 보존처리", "중요동산문화유산 기록화 3D 스캐닝",
|
|
3204
|
+
"소장유물 복제", "소장품도록 발간"은 유물을 직접 다루므로 정상 채점한다.`;
|
|
3205
|
+
const EMPLOYMENT_FILTER = `## 채용 공고 판별
|
|
3206
|
+
|
|
3207
|
+
채용 공고는 **직무가 아래 지원 직무 목록에 해당하는지**를 먼저 본다.
|
|
3208
|
+
|
|
3209
|
+
- **비유산 지원 직무: 조리·급식, 방호·경비, 청소·미화, 시설관리, 운전, 당직 → 점수 1**
|
|
3210
|
+
이 목록에 해당하는 직무만 1점이다. 목록에 없는 직무를 유추해서 넣지 않는다.
|
|
3211
|
+
- **그 밖의 직무는 모두 아래 점수 기준을 정상 적용한다.** 특히 박물관·미술관·연구소가 내는
|
|
3212
|
+
공고는 직무명이 학예직이 아니어도 — 행정, 연구, 교육, 전시, 기록물, 일반직 — 유산 분야로 본다.
|
|
3213
|
+
기관명에 '문화유산'이나 '역사'가 들어가지 않아도 마찬가지다. 국립농업박물관의 행정직 공고는
|
|
3214
|
+
박물관의 일자리이고, 그 자체가 독자에게 실무 정보다.
|
|
3215
|
+
- 한 공고에 두 분야가 섞여 있으면 유산 분야 직무를 기준으로 평가한다.`;
|
|
3216
|
+
function scoreScale(minimumScore) {
|
|
3217
|
+
const tiers = [
|
|
3218
|
+
'10: 분야 전체에 즉각적이고 중대한 영향 — 주요 법령 통과, 대규모 예산 배정, 학계를 바꾸는 발견',
|
|
3219
|
+
'8-9: 다수 이해관계자에게 중요한 영향 — 주요 정책 변화, 중요 성과 공개, 대형 사업 발표',
|
|
3220
|
+
'7-8: 특정 분야의 중요한 학술·실무 성과 — 학술지 발간, 연구 성과 발표, 보고서 간행, 주요 학술행사, 중요 자원 지정, 중규모 입찰',
|
|
3221
|
+
'5-6: 특정 분야·지역에 한정된 일반적 중요 정보 — 소규모 사업 허가, 일반 행사 공지, 소규모 입찰',
|
|
3222
|
+
'4-5: 일반적인 분야 소식, 중소 규모 행사',
|
|
3223
|
+
'2-3: 단순 정보 공유, 반복적인 일상 소식',
|
|
3224
|
+
];
|
|
3225
|
+
if (minimumScore === 1) {
|
|
3226
|
+
tiers.push('1: **현재 실무 가치가 없는 정보** — 종료된 지원사업, 지난 행사, 만료된 입찰·채용 공고, 회비 납부 현황·회의록·내부 일정 같은 단순 행정 공지, 그리고 위 채용 판별에서 걸러진 비유산 지원 직무 채용');
|
|
3227
|
+
}
|
|
3228
|
+
return `## 점수 기준 (${minimumScore}-10)\n\n${tiers.join('\n')}`;
|
|
3229
|
+
}
|
|
3230
|
+
const EVALUATION_AXES = `## 평가 축
|
|
3231
|
+
|
|
3232
|
+
- **학술 가치**: 학술지 발간, 연구보고서, 학술대회·심포지엄, 연구 성과 발표는 최소 7점 (지식 기반 확장과 장기 참조 가치)
|
|
3233
|
+
- **실무 영향**: 정책, 규정, 입찰, 채용처럼 즉각 대응이 필요한 정보
|
|
3234
|
+
- **영향 범위**: 영향받는 이해관계자의 수
|
|
3235
|
+
- **희소성**: 정보의 희귀성과 독점성`;
|
|
3236
|
+
function temporalRule$1(minimumScore) {
|
|
3237
|
+
if (minimumScore > 1) {
|
|
3238
|
+
return '';
|
|
3239
|
+
}
|
|
3240
|
+
return `
|
|
3241
|
+
## 시간 유효성 (HARD RULE)
|
|
3242
|
+
|
|
3243
|
+
- 기사에 적힌 마감일, 접수 기간, 행사일, 입찰 마감, 채용 기간, 유효 기간을 뉴스레터 발행일과 비교한다.
|
|
3244
|
+
- 이미 지났다면 다른 기준과 무관하게 **1점**. 이 규칙이 다른 모든 고려사항에 우선한다.
|
|
3245
|
+
- 이 규칙은 **독자가 기한 안에 행동해야 하는 안내**에만 적용한다. 신청·접수·응모·입찰·참가 모집이 그렇다.
|
|
3246
|
+
- 예외 ①: 학술 성과(발간된 학술지, 공개된 연구, 종료된 학술대회 자료)는 참조 가치로 평가하며 깎지 않는다.
|
|
3247
|
+
- 예외 ②: **이미 일어난 일을 전하는 보도·발표 기사**는 깎지 않는다. 지정·선정 결과, 조사 성과, 협약·기증·개최 소식처럼
|
|
3248
|
+
독자가 알아두면 되는 내용이면 행사일이 지났더라도 사안의 무게대로 평가한다.
|
|
3249
|
+
(판단 기준: 기사가 독자에게 무엇을 하라고 요구하는가? 아무 행동도 요구하지 않는다면 이 규칙의 대상이 아니다.)
|
|
3250
|
+
- 예외 ③: **발굴조사·시굴조사·매장유산 조사 입찰 공고**는 입찰 마감이 지났어도 깎지 않는다.
|
|
3251
|
+
독자는 입찰에 참여하려고 이 소식을 보는 것이 아니라 **어디서 어떤 조사가 시작되는지** 알려고 본다.
|
|
3252
|
+
"○○ 유적 정밀발굴조사 용역 발주"는 그 자체로 고고학계 소식이다.
|
|
3253
|
+
이런 공고는 수의계약으로 나와 마감이 공고 다음 날인 경우가 많아, 마감으로 거르면 정작 알려야 할 조사가 사라진다.
|
|
3254
|
+
문화재 수리·보수정비·보존처리 발주도 같게 본다. 다만 입찰 참여 조건이나 마감을 강조해 쓰지는 않는다.
|
|
3255
|
+
- 마감·일정 언급이 전혀 없으면 이 규칙은 적용하지 않는다.
|
|
3256
|
+
`;
|
|
3257
|
+
}
|
|
3258
|
+
/**
|
|
3259
|
+
* Replaces core's default importance prompt.
|
|
3260
|
+
*
|
|
3261
|
+
* Adds the archaeology-first weighting and the non-heritage employment filter,
|
|
3262
|
+
* and drops core's star-rating vocabulary, which the newsletter no longer
|
|
3263
|
+
* renders. The hard ceiling on natural and intangible heritage is **not** here:
|
|
3264
|
+
* it is applied deterministically after scoring, in `AnalysisProvider.update()`,
|
|
3265
|
+
* so the two never disagree.
|
|
3266
|
+
*/
|
|
3267
|
+
const determineImportancePrompt = {
|
|
3268
|
+
system: (context) => {
|
|
3269
|
+
const minimumScore = resolveMinimumScore(context);
|
|
3270
|
+
return `당신은 한국 문화유산 분야의 중요도 평가 전문가다. 기사의 제목과 본문을 분석해 중요도를 점수로 매긴다.
|
|
3271
|
+
|
|
3272
|
+
주 독자는 연구기관 연구원, 지자체·공공기관 담당자, 대학원생, 현장 전문가다. 긴급성·영향력·희소성을 기준으로 평가한다.
|
|
3273
|
+
|
|
3274
|
+
${DOMAIN_PRIORITY}
|
|
3275
|
+
|
|
3276
|
+
${SUBJECT_OVER_INSTITUTION}
|
|
3277
|
+
|
|
3278
|
+
${EMPLOYMENT_FILTER}
|
|
3279
|
+
|
|
3280
|
+
${scoreScale(minimumScore)}
|
|
3281
|
+
|
|
3282
|
+
${EVALUATION_AXES}
|
|
3283
|
+
${temporalRule$1(minimumScore)}`;
|
|
3284
|
+
},
|
|
3285
|
+
user: (context) => {
|
|
3286
|
+
const minimumScore = resolveMinimumScore(context);
|
|
3287
|
+
const { targetArticle, dateService } = context;
|
|
3288
|
+
const publishedDate = targetArticle.publishedDate
|
|
3289
|
+
? `\n**기사 게시일:** ${targetArticle.publishedDate}`
|
|
3290
|
+
: '';
|
|
3291
|
+
const imageContext = targetArticle.imageContextByLlm
|
|
3292
|
+
? `\n\n**이미지 분석:** ${targetArticle.imageContextByLlm}`
|
|
3293
|
+
: '';
|
|
3294
|
+
const temporalCheck = minimumScore > 1
|
|
3295
|
+
? ''
|
|
3296
|
+
: '\n\n점수를 매기기 전에, 이 기사가 독자에게 기한 안에 행동할 것을 요구하는지 확인하라. 요구한다면 그 기한이 위 발행일 기준으로 지났는지 보고, 지났다면 1점이다. 이미 일어난 일을 전하는 보도라면 이 규칙을 적용하지 않는다.';
|
|
3297
|
+
return `아래 기사의 중요도를 ${minimumScore}부터 10까지로 평가하라.
|
|
3298
|
+
|
|
3299
|
+
**뉴스레터 발행일:** ${dateService.getPublicationISODateString()}${publishedDate}
|
|
3300
|
+
|
|
3301
|
+
**제목:** ${targetArticle.title || '제목 없음'}
|
|
3302
|
+
|
|
3303
|
+
**유산 영역(tag1):** ${targetArticle.tag1 || '미분류'}
|
|
3304
|
+
**주제 태그:** ${[targetArticle.tag2, targetArticle.tag3].filter(Boolean).join(', ') || '없음'}
|
|
3305
|
+
|
|
3306
|
+
**본문:**
|
|
3307
|
+
${targetArticle.detailContent || '내용 없음'}${imageContext}${temporalCheck}`;
|
|
3308
|
+
},
|
|
3309
|
+
};
|
|
3310
|
+
|
|
3311
|
+
/**
|
|
3312
|
+
* Category order. Categories listed here are emitted first, in this order;
|
|
3313
|
+
* anything else follows, ordered by how consequential it is.
|
|
3314
|
+
*/
|
|
3315
|
+
const LEADING_CATEGORIES = [
|
|
3316
|
+
'🎓 학술대회·학술행사',
|
|
3317
|
+
'📋 발굴조사보고서 (신규 제출)',
|
|
3318
|
+
'⛏️ 발굴현장 공개',
|
|
3319
|
+
'💼 채용·공고',
|
|
3320
|
+
];
|
|
3321
|
+
/**
|
|
3322
|
+
* The one category that runs last, immediately before 마무리.
|
|
3323
|
+
*
|
|
3324
|
+
* 나라장터 alone contributes about 49 notices a day — more than every other
|
|
3325
|
+
* source combined — so leading with them would bury the 학술대회 and 발굴 news
|
|
3326
|
+
* the newsletter exists for. They are not dropped either: procurement is how
|
|
3327
|
+
* many readers earn a living, and a reader who misses a 공고 misses money.
|
|
3328
|
+
* Last position keeps both true.
|
|
3329
|
+
*
|
|
3330
|
+
* Most of these notices concern non-archaeological heritage — 고택 보수, 향교
|
|
3331
|
+
* 지붕, 사찰 방염 — and they stay for the same reason: archaeologists take that
|
|
3332
|
+
* work. But they are the tail of the issue, not its headline, which is why
|
|
3333
|
+
* {@link SECTION_PLACEMENT} keeps them out of the title and the briefing.
|
|
3334
|
+
*/
|
|
3335
|
+
const TRAILING_CATEGORY = '📑 사업·입찰 공고';
|
|
3336
|
+
/**
|
|
3337
|
+
* Where 사업·입찰 may and may not appear.
|
|
3338
|
+
*
|
|
3339
|
+
* Stated in both the 제목 and 브리핑 rules as well, because fixing only one
|
|
3340
|
+
* place in this prompt has no visible effect — the model satisfies whichever
|
|
3341
|
+
* instruction it reads last.
|
|
3342
|
+
*/
|
|
3343
|
+
const SECTION_PLACEMENT = `- **\`${TRAILING_CATEGORY}\` 섹션의 소식은 제목(title)과 브리핑에 올리지 않는다.**
|
|
3344
|
+
이 섹션은 본문 맨 끝에서 따로 정리하는 참고 정보다.
|
|
3345
|
+
예외는 **발굴조사·시굴조사·매장유산 조사 입찰**뿐이다. 이것은 어디서 어떤 조사가
|
|
3346
|
+
시작되는지 알리는 고고학 소식이므로, 무게가 있다면 제목과 브리핑에 올릴 수 있다.
|
|
3347
|
+
고택 보수, 향교 정비, 사찰 방염, 박물관 시설 공사 같은 비고고학 유산 사업은
|
|
3348
|
+
본문에는 싣되 제목과 브리핑에는 올리지 않는다.`;
|
|
3349
|
+
/**
|
|
3350
|
+
* Per-category table columns.
|
|
3351
|
+
*
|
|
3352
|
+
* Source pages carry far more fields than a newsletter table can hold — an
|
|
3353
|
+
* excavation report listing alone exposes a dozen (허가번호, 조사면적, 조사기간,
|
|
3354
|
+
* 주소 …) — and core's default prompt asked for a generic four-column layout
|
|
3355
|
+
* that overflows at email width. Naming the columns per category keeps the
|
|
3356
|
+
* tables narrow and stops the model from inventing extra ones.
|
|
3357
|
+
*/
|
|
3358
|
+
const TABLE_SPECS = `### 표 형식 (구조적 목록)
|
|
3359
|
+
|
|
3360
|
+
발굴조사보고서, 발굴현장 공개, 채용처럼 같은 형태가 반복되는 항목은 표로 낸다.
|
|
3361
|
+
열은 아래에 지정된 것만 쓴다. **원문에 다른 필드가 있어도 열을 추가하지 않는다.**
|
|
3362
|
+
|
|
3363
|
+
- **발굴조사보고서**: \`유적 | 소재지 | 성격·시대\`
|
|
3364
|
+
- 유적: 보고서명을 [원제목](URL) 링크로
|
|
3365
|
+
- 소재지: 시도 + 시군구까지만 (예: 경남 합천군). 상세 주소·지번은 쓰지 않는다
|
|
3366
|
+
- 성격·시대: 유적성격/시대구분 (예: 생활유적, 조선시대)
|
|
3367
|
+
- 허가번호, 조사면적, 조사기간, 제출일, 도로명·지번 주소는 **싣지 않는다**
|
|
3368
|
+
- **발굴현장 공개**: \`유적 | 소재지 | 공개일시\`
|
|
3369
|
+
- 유적: 공개 대상 유적명을 [원제목](URL) 링크로
|
|
3370
|
+
- **채용**: \`공고 | 기관 | 접수 마감\`
|
|
3371
|
+
- 공고: 공고명을 [원제목](URL) 링크로
|
|
3372
|
+
- 기관: 채용 기관명만 (예: 국립중앙박물관)
|
|
3373
|
+
- 접수 마감: 날짜와 시각. 합격자 발표처럼 마감이 없는 공고는 "—"
|
|
3374
|
+
- **사업·입찰**: \`사업 | 발주기관 | 마감\`
|
|
3375
|
+
- 사업: 공고명을 [원제목](URL) 링크로
|
|
3376
|
+
- 발주기관: 발주처 이름만 (예: 안동시). 수요기관이 따로 있으면 수요기관을 쓴다
|
|
3377
|
+
- 마감: 입찰 마감 일시. 원문에 없으면 "—"
|
|
3378
|
+
- **아래 시간 유효성 예외 ③으로 살린 만료 공고는 마감 칸을 "—"로 둔다.**
|
|
3379
|
+
이미 지난 마감 일시를 적으면 독자가 지원할 수 있는 것으로 읽는다.
|
|
3380
|
+
그 공고를 싣는 이유는 어디서 어떤 조사가 시작되는지 알리기 위해서다.
|
|
3381
|
+
- 사업금액, 계약방법, 공고번호, 업무구분은 **싣지 않는다**
|
|
3382
|
+
|
|
3383
|
+
공통 규칙:
|
|
3384
|
+
|
|
3385
|
+
- **첫 열에만 [원제목](URL) 링크를 넣는다.** 나머지 열에는 링크나 긴 제목을 넣지 않고,
|
|
3386
|
+
지정된 값만 짧게 적는다.
|
|
3387
|
+
- 항목을 묶거나 "외 n건" 같은 표현으로 줄이지 않는다. 길어도 한 항목당 한 행으로 전부 싣는다.
|
|
3388
|
+
- 원문에 값이 없는 칸은 "—"로 채운다. 열 자체를 빼지 않는다.
|
|
3389
|
+
- 표 대신 글머리 기호나 번호 목록을 쓰지 않는다.`;
|
|
3390
|
+
const VOICE_RULES = `## 어투
|
|
3391
|
+
|
|
3392
|
+
- **모든 문장을 '습니다'체로 쓴다.** "~했다", "~이다" 같은 평서형 종결어미를 쓰지 않는다.
|
|
3393
|
+
("공고했다" → "공고했습니다", "예정이다" → "예정입니다", "선정됐다" → "선정됐습니다")
|
|
3394
|
+
표 안의 짧은 명사구는 예외로 둔다.
|
|
3395
|
+
- 신뢰할 만한 동료가 건네는 말투로 쓴다. 딱딱한 공문이나 보도자료 낭독이 아니다.
|
|
3396
|
+
독자가 왜 이 소식을 봐야 하는지, 무엇을 챙기면 되는지를 한 마디로 짚어준다.
|
|
3397
|
+
- 다만 과장하거나 들뜨지 않는다. 감탄사, 홍보성 수식어, 억지 친근함은 쓰지 않는다.
|
|
3398
|
+
- 단정보다 권유에 가깝게 쓴다. ("확인해야 한다" → "확인해두시면 좋겠습니다")`;
|
|
3399
|
+
const EDITORIAL_RULES = `## 편집 규칙
|
|
3400
|
+
|
|
3401
|
+
- **중요도 표시를 출력하지 않는다.** 별(★), 점수, "매우 중요" 같은 등급 표기를 본문 어디에도 쓰지 않는다. 중요도는 순서와 분량으로만 드러낸다.
|
|
3402
|
+
- **뉴스레터 제목(title)에는 이모지를 쓰지 않는다.** 본문 섹션 헤딩에는 쓴다(아래 참고).
|
|
3403
|
+
- **모든 \`##\` 섹션 헤딩은 이모지 하나로 시작한다.** 지정된 분류는 지정된 이모지를 그대로 쓰고,
|
|
3404
|
+
그 외 분류는 내용에 맞는 이모지를 하나 고른다. (예: 🏺 전시·국제교류, 📣 기타 공지, 🌿 보존·정비)
|
|
3405
|
+
한 호 안에서 같은 이모지를 두 번 쓰지 않는다.
|
|
3406
|
+
- 통계·비중 분석을 만들어내지 않는다. "오늘 소식의 00%가 ~" 같은 문장은 쓰지 않는다.
|
|
3407
|
+
- 소식을 언급할 때마다 [원제목](URL) 형식으로 링크한다. "자세히 보기", "기사", "[3번 글]" 같은 표기는 쓰지 않는다.
|
|
3408
|
+
- 날짜 범위는 물결표(~)가 아니라 붙임표(-)로 쓴다. 물결표는 마크다운에서 취소선이 된다.
|
|
3409
|
+
${SECTION_PLACEMENT}`;
|
|
3410
|
+
const LENGTH_CONTROL = `## 분량 (중요도 점수 기준, 점수 자체는 출력하지 않는다)
|
|
3411
|
+
|
|
3412
|
+
- **9-10점**: 핵심 사실을 **굵게** + 링크. 대상·범위, 일정·절차, 예산·규모를 필요한 만큼 풀어쓴다.
|
|
3413
|
+
- **6-8점**: **최대 3문장.** 핵심 사실 한 문장(굵게 + 링크) + 필요하면 마감·예산 같은 결정적 정보 한두 문장. 하위 항목이나 글머리 목록을 만들지 않는다.
|
|
3414
|
+
- **1-5점**: **한 문장.** 핵심 사실 + 링크. 여러 건은 한 목록으로 묶어도 된다.
|
|
3415
|
+
|
|
3416
|
+
구조적 목록(표)은 이 분량 제한과 무관하게 모든 항목을 싣는다.`;
|
|
3417
|
+
const FACT_RULES = `## 사실성
|
|
3418
|
+
|
|
3419
|
+
- 제공된 자료에 명시된 내용만 쓴다. 추론이나 추측으로 확장하지 않는다.
|
|
3420
|
+
- "~로 보인다", "~할 전망이다" 같은 추측 표현을 쓰지 않는다.
|
|
3421
|
+
- 자료에 없는 기관·정책·계획을 사실처럼 쓰지 않는다.
|
|
3422
|
+
- 이미지 분석 결과는 기사의 시각적 맥락을 파악하는 용도로만 쓴다. 거기 담긴 이름·수치·세부 사실을 본문에 인용하지 않는다.
|
|
3423
|
+
- 원문 표현을 그대로 옮기지 않는다. 사실만 추출해 새 문장으로 쓴다.`;
|
|
3424
|
+
function temporalRule(publicationDate) {
|
|
3425
|
+
return `## 시간 유효성 (HARD RULE)
|
|
3426
|
+
|
|
3427
|
+
발행일은 ${publicationDate}이다. 모든 기사에 대해:
|
|
3428
|
+
|
|
3429
|
+
- 이 규칙은 **독자가 기한 안에 행동해야 하는 안내**(신청·접수·응모·입찰·참가 모집)에만 적용한다.
|
|
3430
|
+
마감일, 접수 기간, 행사 참가 기한, 입찰 마감, 채용 기간이 발행일 기준으로 이미 지났다면
|
|
3431
|
+
중요도와 무관하게 본문에서 **완전히 제외**한다.
|
|
3432
|
+
- 예외 ①: 학술 성과(발간된 학술지, 공개된 연구, 종료된 학술대회 자료)는 참조 가치가 있으므로 남긴다.
|
|
3433
|
+
- 예외 ②: **이미 일어난 일을 전하는 보도·발표 기사**는 남긴다. 지정·선정 결과, 조사 성과, 협약·기증·개최 소식처럼
|
|
3434
|
+
독자에게 아무 행동도 요구하지 않는 기사는 행사일이 지났더라도 그대로 싣는다.
|
|
3435
|
+
- 예외 ③: **발굴조사·시굴조사·매장유산 조사 입찰 공고**는 입찰 마감이 지났어도 싣는다.
|
|
3436
|
+
독자는 입찰에 참여하려고 보는 것이 아니라 어디서 어떤 조사가 시작되는지 알려고 본다.
|
|
3437
|
+
문화재 수리·보수정비·보존처리 발주도 같다. 다만 이런 항목은 **무엇을 조사·수리하는지** 중심으로 쓰고,
|
|
3438
|
+
마감이 지난 입찰 일정은 적지 않는다.
|
|
3439
|
+
- 기사 게시일이 발행일보다 30일 이상 앞서면 신선도를 의심하고, 마감이 남은 진행 중 사업처럼 여전히 앞을 내다보는 가치가 있을 때만 싣는다.
|
|
3440
|
+
- 제외한 기사는 "그 밖에 주목할 만한 소식"이나 표에도 언급하지 않는다.`;
|
|
3441
|
+
}
|
|
3442
|
+
function structure(context) {
|
|
3443
|
+
const { freeFormIntro, dateService } = context;
|
|
3444
|
+
const displayDate = dateService.getPublicationDisplayDateString();
|
|
3445
|
+
const opening = freeFormIntro
|
|
3446
|
+
? `1. **브리핑**: \`## 📮 ${displayDate} 브리핑\` 형식의 Heading 2로 시작한다. 분야 이름은 헤딩에 넣지 않는다.`
|
|
3447
|
+
: `1. **머리말**: \`# ${displayDate} 문화유산 소식\` 형식의 Heading 1으로 시작하고, 이어서 \`## 📮 ${displayDate} 브리핑\` 문단을 쓴다.`;
|
|
3448
|
+
return `## 구성
|
|
3449
|
+
|
|
3450
|
+
${opening}
|
|
3451
|
+
|
|
3452
|
+
브리핑은 **3-4문장, 짧고 강하게** 쓴다. 오늘 가장 무게 있는 소식 한두 건을 이름을 들어 짚고, 독자가 왜 지금 이것을 봐야 하는지 한 문장으로 말한다.
|
|
3453
|
+
통계, 비중, 항목 수 세기는 쓰지 않는다. 글머리 목록도 만들지 않는다. 구독 링크는 여기에 넣지 않는다.
|
|
3454
|
+
**\`${TRAILING_CATEGORY}\` 섹션의 소식은 브리핑에 올리지 않는다.** 단, 발굴조사·시굴조사
|
|
3455
|
+
입찰은 고고학 소식이므로 무게가 있다면 올릴 수 있다.
|
|
3456
|
+
|
|
3457
|
+
2. **분류**: 아래 순서를 지킨다. 해당 소식이 없는 분류는 건너뛴다.
|
|
3458
|
+
|
|
3459
|
+
${LEADING_CATEGORIES.map((c, i) => ` ${i + 1}) ${c}`).join('\n')}
|
|
3460
|
+
${LEADING_CATEGORIES.length + 1}) 그 외 (정책·제도, 지정·보존, 전시·행사 등 내용에 맞게 묶는다)
|
|
3461
|
+
${LEADING_CATEGORIES.length + 2}) ${TRAILING_CATEGORY} — **반드시 마지막 분류**로, \`## 📌 마무리\` 바로 앞에 둔다
|
|
3462
|
+
|
|
3463
|
+
\`${TRAILING_CATEGORY}\`에는 입찰 공고와 용역·공사 발주 소식을 모은다. 위의 다른
|
|
3464
|
+
분류에 섞어 넣지 않는다. 건수가 많아도 표로 한 행씩 전부 싣고, 줄이거나 생략하지 않는다.
|
|
3465
|
+
독자가 놓치면 일감을 놓치는 정보다.
|
|
3466
|
+
|
|
3467
|
+
각 분류는 Heading 2(\`##\`)를 쓴다. 분류 안에서는 중요한 것부터 배치한다.
|
|
3468
|
+
같은 내용이 여러 출처에서 왔다면 가장 자세한 것을 기준으로 한 번만 쓴다.
|
|
3469
|
+
|
|
3470
|
+
3. **마무리**: 마지막 섹션은 반드시 \`## 📌 마무리\`로 쓴다. 두 부분으로 구성한다.
|
|
3471
|
+
|
|
3472
|
+
가. 오늘 다룬 주요 소식을 한 문단으로 정리한다. 행사명 뒤에 날짜를 괄호로 덧붙인다.
|
|
3473
|
+
(예: "국립김해박물관 가야 특별전 개막(9.18.), 백제학회 학술대회(9.18.) 등을 다루었습니다.")
|
|
3474
|
+
|
|
3475
|
+
나. 이어서 \`**임박한 주요 일정 및 마감:**\` 줄을 넣고, 날짜순 글머리 목록을 만든다.
|
|
3476
|
+
한 줄에 한 날짜씩 묶고, 같은 날 여러 건이면 \` / \`로 잇는다.
|
|
3477
|
+
형식: \`- **2026년 9월 18일(금):** 국립김해박물관 특별전 개막 / 백제학회 학술대회\`
|
|
3478
|
+
마감 시각이 있으면 날짜 뒤에 붙인다. (예: \`- **2026년 9월 21일(월) 18:00:** …\`)
|
|
3479
|
+
본문에서 다룬 일정만 넣는다. 날짜가 없는 소식은 넣지 않는다.
|
|
3480
|
+
**\`${TRAILING_CATEGORY}\` 섹션의 입찰 마감은 이 목록에 넣지 않는다.** 마감 일시는
|
|
3481
|
+
그 섹션의 표에 이미 있고, 목록까지 입찰로 채우면 학술대회·채용·전시 일정이 묻힌다.
|
|
3482
|
+
학술대회, 채용 접수, 전시 개막·종료, 신청 마감 같은 나머지 일정만 넣는다.
|
|
3483
|
+
|
|
3484
|
+
다음 호 예고나 문의처는 쓰지 않는다.`;
|
|
3485
|
+
}
|
|
3486
|
+
function titleRules(context) {
|
|
3487
|
+
const { titleContext } = context;
|
|
3488
|
+
const common = `- 길이는 20-70자를 지킨다.
|
|
3489
|
+
- **이모지를 넣지 않는다.**
|
|
3490
|
+
- "뉴스레터" 같은 일반 명사 대신 구체적인 사실, 수치, 일정을 담는다.
|
|
3491
|
+
- '발표', '시행', '마감 임박'처럼 중립적이고 객관적인 표현을 쓴다.
|
|
3492
|
+
${SECTION_PLACEMENT}`;
|
|
3493
|
+
if (titleContext) {
|
|
3494
|
+
return `## 제목
|
|
3495
|
+
|
|
3496
|
+
- **"${titleContext}"가 제목에 반드시 그대로 들어가야 한다.** 오늘 본문의 핵심 맥락과 자연스럽게 결합해 완성된 제목을 만든다.
|
|
3497
|
+
${common}`;
|
|
3498
|
+
}
|
|
3499
|
+
return `## 제목
|
|
3500
|
+
|
|
3501
|
+
- 오늘 가장 중요한 소식 한두 건의 핵심 사실을 객관적으로 전달한다.
|
|
3502
|
+
- 가장 중요한 사실을 앞에 둔다.
|
|
3503
|
+
${common}`;
|
|
3504
|
+
}
|
|
3505
|
+
/**
|
|
3506
|
+
* Replaces core's default newsletter prompt.
|
|
3507
|
+
*
|
|
3508
|
+
* Core's default mandates emoticons in the title and section headings, renders
|
|
3509
|
+
* importance as star ratings, and asks for share-of-coverage statistics — all of
|
|
3510
|
+
* which this newsletter removes. Those instructions cannot be cancelled by
|
|
3511
|
+
* appending contradicting rules, so the prompt is written from scratch.
|
|
3512
|
+
*
|
|
3513
|
+
* It still has to satisfy core's fixed output schema, which drives a full
|
|
3514
|
+
* regeneration (capped at 5 attempts) when the model reports a failure:
|
|
3515
|
+
* a 20-70 character title, `isWrittenInOutputLanguage`, `copyrightVerified`,
|
|
3516
|
+
* `factAccuracy`, and the `titleContext` phrase when one is supplied.
|
|
3517
|
+
*/
|
|
3518
|
+
const generateNewsletterPrompt = {
|
|
3519
|
+
system: (context) => {
|
|
3520
|
+
const { newsletterBrandName, expertFields, outputLanguage, dateService, subscribePageUrl, } = context;
|
|
3521
|
+
const subscribeRule = subscribePageUrl
|
|
3522
|
+
? `\n\n## 공유 안내\n\n\`## 📌 마무리\` 섹션 **뒤에**, 본문 맨 마지막 줄로 링크를 한 번만 넣는다. 브리핑이나 본문 중간에는 넣지 않는다.
|
|
3523
|
+
|
|
3524
|
+
이 글을 읽는 사람은 **이미 구독자**다. 구독을 권하지 말고, 동료에게 **소개·공유**해달라고 청한다.
|
|
3525
|
+
("구독해보세요", "정기 수신을 신청하세요" 같은 표현은 쓰지 않는다.)
|
|
3526
|
+
|
|
3527
|
+
형식: 공유를 청하는 한 문장 + \`[${newsletterBrandName} 구독하기](${subscribePageUrl})\` 링크.
|
|
3528
|
+
(예: "이 소식이 도움이 되셨다면 동료 연구자에게도 소개해주시면 좋겠습니다. [${newsletterBrandName} 구독하기](${subscribePageUrl})")`
|
|
3529
|
+
: '';
|
|
3530
|
+
return `당신은 "${newsletterBrandName}"의 뉴스레터 편집자다. 독자는 ${expertFields.join(', ')} 분야의 연구자, 기관 담당자, 현장 전문가다.
|
|
3531
|
+
|
|
3532
|
+
바쁜 전문가가 2-3분 안에 핵심을 파악할 수 있도록, 사실 중심으로 간결하게 씁니다.
|
|
3533
|
+
|
|
3534
|
+
모든 내용은 ${outputLanguage}로 쓴다.
|
|
3535
|
+
|
|
3536
|
+
${VOICE_RULES}
|
|
3537
|
+
|
|
3538
|
+
${structure(context)}
|
|
3539
|
+
|
|
3540
|
+
${EDITORIAL_RULES}${subscribeRule}
|
|
3541
|
+
|
|
3542
|
+
${LENGTH_CONTROL}
|
|
3543
|
+
|
|
3544
|
+
${TABLE_SPECS}
|
|
3545
|
+
|
|
3546
|
+
${FACT_RULES}
|
|
3547
|
+
|
|
3548
|
+
${temporalRule(dateService.getPublicationDisplayDateString())}
|
|
3549
|
+
|
|
3550
|
+
${titleRules(context)}
|
|
3551
|
+
|
|
3552
|
+
## 출력 형식
|
|
3553
|
+
|
|
3554
|
+
본문은 마크다운으로 쓴다. 제목(#, ##), 굵게(**), 목록(-), 표를 활용한다.
|
|
3555
|
+
\`isWrittenInOutputLanguage\`, \`copyrightVerified\`, \`factAccuracy\`는 위 규칙을 모두 지켰을 때 true로 보고한다.`;
|
|
3556
|
+
},
|
|
3557
|
+
user: (context) => {
|
|
3558
|
+
const { targetArticles, dateService, expertFields } = context;
|
|
3559
|
+
const articles = targetArticles
|
|
3560
|
+
.map((article, index) => {
|
|
3561
|
+
const tags = [article.tag1, article.tag2, article.tag3]
|
|
3562
|
+
.filter(Boolean)
|
|
3563
|
+
.join(', ');
|
|
3564
|
+
const image = article.imageContextByLlm
|
|
3565
|
+
? `\n**이미지 분석(맥락 파악용, 세부 사실 인용 금지):** ${article.imageContextByLlm}`
|
|
3566
|
+
: '';
|
|
3567
|
+
const published = article.publishedDate
|
|
3568
|
+
? `\n**게시일:** ${article.publishedDate}`
|
|
3569
|
+
: '';
|
|
3570
|
+
return `## 소식 ${index + 1}
|
|
3571
|
+
**제목:** ${article.title}
|
|
3572
|
+
**URL:** ${article.url}
|
|
3573
|
+
**중요도:** ${article.importanceScore}/10
|
|
3574
|
+
**태그:** ${tags}
|
|
3575
|
+
**구분:** ${article.contentType}${published}${image}
|
|
3576
|
+
**본문:**
|
|
3577
|
+
${article.detailContent}`;
|
|
3578
|
+
})
|
|
3579
|
+
.join('\n\n');
|
|
3580
|
+
return `아래는 새로 수집된 ${expertFields.join(', ')} 관련 소식 전체다.
|
|
3581
|
+
|
|
3582
|
+
${articles}
|
|
3583
|
+
|
|
3584
|
+
---
|
|
3585
|
+
|
|
3586
|
+
**발행일:** ${dateService.getPublicationISODateString()} (${dateService.getPublicationDisplayDateString()})
|
|
3587
|
+
|
|
3588
|
+
위 소식으로 ${dateService.getPublicationDisplayDateString()} 자 뉴스레터를 작성하라.
|
|
3589
|
+
|
|
3590
|
+
먼저 시간 유효성 HARD RULE을 적용해 **신청·접수 기한이 지난 안내**를 제외하고, 남은 소식을 지정된 분류 순서대로 배치한다.
|
|
3591
|
+
이미 일어난 일을 전하는 보도 기사는 행사일이 지났더라도 제외하지 않는다.
|
|
3592
|
+
중요도 점수는 분량을 정하는 데만 쓰고 본문에 출력하지 않는다.
|
|
3593
|
+
본문은 '습니다'체로 쓰고, 모든 섹션 헤딩은 이모지로 시작하며, 공유 링크는 \`## 📌 마무리\` 뒤 맨 마지막 줄에 한 번만 넣는다.
|
|
3594
|
+
독자는 이미 구독자이므로 구독 권유가 아니라 동료에게 소개해달라는 문장으로 쓴다.`;
|
|
3595
|
+
},
|
|
3596
|
+
};
|
|
3597
|
+
|
|
3598
|
+
/**
|
|
3599
|
+
* Research Radar's LLM prompt overrides.
|
|
3600
|
+
*
|
|
3601
|
+
* Each builder here **replaces** core's built-in prompt for that stage rather
|
|
3602
|
+
* than extending it — a `PromptBuilder` returns the whole prompt string. That is
|
|
3603
|
+
* deliberate: core's defaults instruct the model to use emoticons, render
|
|
3604
|
+
* importance as star ratings, and add share-of-coverage statistics, all of which
|
|
3605
|
+
* this newsletter removes. Appending contradicting rules to those defaults
|
|
3606
|
+
* degrades the output, so a replacement is written from scratch instead.
|
|
3607
|
+
*
|
|
3608
|
+
* **Contract a replacement must still satisfy.** Core's output schema is fixed,
|
|
3609
|
+
* and `generateNewsletter` regenerates the whole newsletter whenever the model
|
|
3610
|
+
* reports a failure. A replacement prompt has to steer the model toward:
|
|
3611
|
+
*
|
|
3612
|
+
* - `title`: 20–70 characters
|
|
3613
|
+
* - `isWrittenInOutputLanguage`, `copyrightVerified`, `factAccuracy`: true
|
|
3614
|
+
* - `titleContext` (KRAS mode): the phrase must appear in the title
|
|
3615
|
+
*
|
|
3616
|
+
* Core caps that loop at 5 attempts, so a prompt that ignores the contract costs
|
|
3617
|
+
* up to 5 full generations on the most expensive model in the pipeline.
|
|
3618
|
+
*
|
|
3619
|
+
* Tune these against real articles with core's playground
|
|
3620
|
+
* (`npm run playground:generate-newsletter` in ../llm-newsletter-kit-core) and
|
|
3621
|
+
* diff them against the defaults for free with `playground:verify-prompts`.
|
|
3622
|
+
* The playground loads this module from `dist`, so run `npm run build` here
|
|
3623
|
+
* after every edit — its source cannot be imported directly across repos
|
|
3624
|
+
* because of the `~/*` path alias.
|
|
3625
|
+
*/
|
|
3626
|
+
const researchRadarPromptProvider = {
|
|
3627
|
+
analysis: {
|
|
3628
|
+
classifyTags: classifyTagsPrompt,
|
|
3629
|
+
determineImportance: determineImportancePrompt,
|
|
3630
|
+
},
|
|
3631
|
+
contentGenerate: {
|
|
3632
|
+
generateNewsletter: generateNewsletterPrompt,
|
|
3633
|
+
},
|
|
3634
|
+
};
|
|
3635
|
+
|
|
1855
3636
|
/**
|
|
1856
3637
|
* Analysis provider implementation
|
|
1857
3638
|
* - LLM-based article analysis
|
|
@@ -1869,13 +3650,13 @@ class AnalysisProvider {
|
|
|
1869
3650
|
this.articleRepository = articleRepository;
|
|
1870
3651
|
this.tagRepository = tagRepository;
|
|
1871
3652
|
this.classifyTagOptions = {
|
|
1872
|
-
model: this.openai('gpt-5-
|
|
3653
|
+
model: this.openai('gpt-5.6-luna'),
|
|
1873
3654
|
};
|
|
1874
3655
|
this.analyzeImagesOptions = {
|
|
1875
|
-
model: this.openai('gpt-5.
|
|
3656
|
+
model: this.openai('gpt-5.6-terra'),
|
|
1876
3657
|
};
|
|
1877
3658
|
this.determineScoreOptions = {
|
|
1878
|
-
model: this.openai('gpt-5.
|
|
3659
|
+
model: this.openai('gpt-5.6-terra'),
|
|
1879
3660
|
minimumImportanceScoreRules: [
|
|
1880
3661
|
// Korean Archaeological Society news: minimum score 6
|
|
1881
3662
|
{
|
|
@@ -1981,9 +3762,29 @@ class AnalysisProvider {
|
|
|
1981
3762
|
* @param article - Article with analysis data
|
|
1982
3763
|
*/
|
|
1983
3764
|
async update(article) {
|
|
1984
|
-
await this.articleRepository.updateAnalysis(
|
|
3765
|
+
await this.articleRepository.updateAnalysis({
|
|
3766
|
+
...article,
|
|
3767
|
+
importanceScore: capScoreByHeritageDomain(article),
|
|
3768
|
+
});
|
|
1985
3769
|
}
|
|
1986
3770
|
}
|
|
3771
|
+
/**
|
|
3772
|
+
* Applies the per-domain score ceiling from `maximumImportanceScoreByDomain`.
|
|
3773
|
+
*
|
|
3774
|
+
* The domain comes from `tag1`, which the tag classification prompt pins to a
|
|
3775
|
+
* fixed vocabulary. An off-vocabulary value means the classification did not
|
|
3776
|
+
* hold, so the article is left uncapped rather than capped on a guess.
|
|
3777
|
+
*/
|
|
3778
|
+
function capScoreByHeritageDomain(article) {
|
|
3779
|
+
const domain = toHeritageDomainTag(article.tag1);
|
|
3780
|
+
if (!domain) {
|
|
3781
|
+
return article.importanceScore;
|
|
3782
|
+
}
|
|
3783
|
+
const maximumScore = maximumImportanceScoreByDomain[domain];
|
|
3784
|
+
return maximumScore === undefined
|
|
3785
|
+
? article.importanceScore
|
|
3786
|
+
: Math.min(article.importanceScore, maximumScore);
|
|
3787
|
+
}
|
|
1987
3788
|
|
|
1988
3789
|
/**
|
|
1989
3790
|
* Shared HTML fragments used by both newsletter and welcome email templates.
|
|
@@ -2634,6 +4435,231 @@ class ContentGenerateProvider {
|
|
|
2634
4435
|
}
|
|
2635
4436
|
}
|
|
2636
4437
|
|
|
4438
|
+
/**
|
|
4439
|
+
* robots.txt enforcement for crawling.
|
|
4440
|
+
*
|
|
4441
|
+
* Applied at the fetch layer, the same seam the KRAS and excavation-report
|
|
4442
|
+
* adapters use, so core's crawling pipeline is untouched: a disallowed request
|
|
4443
|
+
* is answered with 403 instead of being sent. Core treats a 4xx list response as
|
|
4444
|
+
* a failed fetch, logs `crawl.list.fetch.failed`, and continues with an empty
|
|
4445
|
+
* page, so a blocked target yields no articles rather than breaking the run.
|
|
4446
|
+
*/
|
|
4447
|
+
/**
|
|
4448
|
+
* Parses robots.txt into user-agent groups.
|
|
4449
|
+
*
|
|
4450
|
+
* Unknown fields (Sitemap, Crawl-delay, Host) and malformed lines are skipped —
|
|
4451
|
+
* 국가유산청 serves a `Disaloow:` typo, which must not be read as a rule.
|
|
4452
|
+
* Consecutive `User-agent:` lines share one rule block, per the spec.
|
|
4453
|
+
*/
|
|
4454
|
+
function parseRobotsTxt(text) {
|
|
4455
|
+
const groups = [];
|
|
4456
|
+
let current = null;
|
|
4457
|
+
let previousLineWasAgent = false;
|
|
4458
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
4459
|
+
const line = rawLine.split('#')[0].trim();
|
|
4460
|
+
if (!line) {
|
|
4461
|
+
continue;
|
|
4462
|
+
}
|
|
4463
|
+
const separator = line.indexOf(':');
|
|
4464
|
+
if (separator === -1) {
|
|
4465
|
+
continue;
|
|
4466
|
+
}
|
|
4467
|
+
const field = line.slice(0, separator).trim().toLowerCase();
|
|
4468
|
+
const value = line.slice(separator + 1).trim();
|
|
4469
|
+
if (field === 'user-agent') {
|
|
4470
|
+
if (!previousLineWasAgent || !current) {
|
|
4471
|
+
current = { agents: [], rules: [] };
|
|
4472
|
+
groups.push(current);
|
|
4473
|
+
}
|
|
4474
|
+
current.agents.push(value.toLowerCase());
|
|
4475
|
+
previousLineWasAgent = true;
|
|
4476
|
+
continue;
|
|
4477
|
+
}
|
|
4478
|
+
if (field === 'allow' || field === 'disallow') {
|
|
4479
|
+
current?.rules.push({ allow: field === 'allow', path: value });
|
|
4480
|
+
}
|
|
4481
|
+
previousLineWasAgent = false;
|
|
4482
|
+
}
|
|
4483
|
+
return groups;
|
|
4484
|
+
}
|
|
4485
|
+
/** Matches a robots path pattern, supporting `*` wildcards and a `$` anchor. */
|
|
4486
|
+
function matchesPattern(pattern, path) {
|
|
4487
|
+
if (pattern === '') {
|
|
4488
|
+
return false;
|
|
4489
|
+
}
|
|
4490
|
+
const anchored = pattern.endsWith('$');
|
|
4491
|
+
const body = anchored ? pattern.slice(0, -1) : pattern;
|
|
4492
|
+
const segments = body.split('*');
|
|
4493
|
+
let position = 0;
|
|
4494
|
+
for (const [index, segment] of segments.entries()) {
|
|
4495
|
+
if (index === 0) {
|
|
4496
|
+
if (!path.startsWith(segment)) {
|
|
4497
|
+
return false;
|
|
4498
|
+
}
|
|
4499
|
+
position = segment.length;
|
|
4500
|
+
continue;
|
|
4501
|
+
}
|
|
4502
|
+
if (segment === '') {
|
|
4503
|
+
// Trailing wildcard: anything left over matches, unless `$` demands the end.
|
|
4504
|
+
if (index === segments.length - 1) {
|
|
4505
|
+
return !anchored;
|
|
4506
|
+
}
|
|
4507
|
+
continue;
|
|
4508
|
+
}
|
|
4509
|
+
const found = path.indexOf(segment, position);
|
|
4510
|
+
if (found === -1) {
|
|
4511
|
+
return false;
|
|
4512
|
+
}
|
|
4513
|
+
position = found + segment.length;
|
|
4514
|
+
}
|
|
4515
|
+
return anchored ? position === path.length : true;
|
|
4516
|
+
}
|
|
4517
|
+
/** Picks the group for this user agent: a named match first, then `*`. */
|
|
4518
|
+
function selectGroup(groups, userAgent) {
|
|
4519
|
+
const normalized = userAgent.toLowerCase();
|
|
4520
|
+
const named = groups.find((group) => group.agents.some((agent) => agent !== '*' && normalized.includes(agent)));
|
|
4521
|
+
return named ?? groups.find((group) => group.agents.includes('*')) ?? null;
|
|
4522
|
+
}
|
|
4523
|
+
/**
|
|
4524
|
+
* Decides whether a path may be requested.
|
|
4525
|
+
*
|
|
4526
|
+
* The most specific matching rule wins; `Allow` wins a tie. A user agent with no
|
|
4527
|
+
* matching group, and a group with no matching rule, are both allowed.
|
|
4528
|
+
*
|
|
4529
|
+
* @param pathWithQuery - Path including the query string, e.g. `/bbs/list.do?key=1`
|
|
4530
|
+
*/
|
|
4531
|
+
function isPathAllowed(groups, userAgent, pathWithQuery) {
|
|
4532
|
+
const group = selectGroup(groups, userAgent);
|
|
4533
|
+
if (!group) {
|
|
4534
|
+
return { allowed: true };
|
|
4535
|
+
}
|
|
4536
|
+
let best = null;
|
|
4537
|
+
for (const rule of group.rules) {
|
|
4538
|
+
if (!matchesPattern(rule.path, pathWithQuery)) {
|
|
4539
|
+
continue;
|
|
4540
|
+
}
|
|
4541
|
+
if (!best ||
|
|
4542
|
+
rule.path.length > best.path.length ||
|
|
4543
|
+
(rule.path.length === best.path.length && rule.allow)) {
|
|
4544
|
+
best = rule;
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
4547
|
+
if (!best || best.allow) {
|
|
4548
|
+
return { allowed: true };
|
|
4549
|
+
}
|
|
4550
|
+
return { allowed: false, rule: `Disallow: ${best.path}` };
|
|
4551
|
+
}
|
|
4552
|
+
function resolveRequestUrl(input) {
|
|
4553
|
+
if (typeof input === 'string') {
|
|
4554
|
+
return input;
|
|
4555
|
+
}
|
|
4556
|
+
return input instanceof URL ? input.href : input.url;
|
|
4557
|
+
}
|
|
4558
|
+
/** Reads the User-Agent from an outgoing request, whatever shape the headers take. */
|
|
4559
|
+
function resolveUserAgent(input, init) {
|
|
4560
|
+
const headers = init?.headers ?? (input instanceof Request ? input.headers : undefined);
|
|
4561
|
+
if (!headers) {
|
|
4562
|
+
return '*';
|
|
4563
|
+
}
|
|
4564
|
+
if (headers instanceof Headers) {
|
|
4565
|
+
return headers.get('user-agent') ?? '*';
|
|
4566
|
+
}
|
|
4567
|
+
if (Array.isArray(headers)) {
|
|
4568
|
+
const found = headers.find(([key]) => key.toLowerCase() === 'user-agent');
|
|
4569
|
+
return found?.[1] ?? '*';
|
|
4570
|
+
}
|
|
4571
|
+
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === 'user-agent');
|
|
4572
|
+
return entry?.[1] ?? '*';
|
|
4573
|
+
}
|
|
4574
|
+
/**
|
|
4575
|
+
* Builds a robots.txt gate: a verdict function and a fetch that enforces it,
|
|
4576
|
+
* sharing one per-origin cache.
|
|
4577
|
+
*
|
|
4578
|
+
* robots.txt is fetched once per origin and the in-flight promise is shared, so
|
|
4579
|
+
* concurrent requests to the same site cause a single lookup. The lookup itself
|
|
4580
|
+
* bypasses the check.
|
|
4581
|
+
*
|
|
4582
|
+
* **Fails open.** A missing (4xx), unreachable, or unparseable robots.txt allows
|
|
4583
|
+
* the request. A transient outage should not silently empty the newsletter, and
|
|
4584
|
+
* 404 already means "no restrictions" under the standard. Blocked requests are
|
|
4585
|
+
* reported through `onBlocked` rather than logged here.
|
|
4586
|
+
*/
|
|
4587
|
+
function createRobotsGate(baseFetch = fetch, options = {}) {
|
|
4588
|
+
const { onBlocked, timeoutMs = 10_000, exemptOrigins = [] } = options;
|
|
4589
|
+
const cache = new Map();
|
|
4590
|
+
const exempt = new Set(exemptOrigins.map((origin) => {
|
|
4591
|
+
try {
|
|
4592
|
+
return new URL(origin).origin;
|
|
4593
|
+
}
|
|
4594
|
+
catch {
|
|
4595
|
+
return origin;
|
|
4596
|
+
}
|
|
4597
|
+
}));
|
|
4598
|
+
const loadRobots = (origin) => {
|
|
4599
|
+
const cached = cache.get(origin);
|
|
4600
|
+
if (cached) {
|
|
4601
|
+
return cached;
|
|
4602
|
+
}
|
|
4603
|
+
const pending = (async () => {
|
|
4604
|
+
try {
|
|
4605
|
+
const response = await baseFetch(`${origin}/robots.txt`, {
|
|
4606
|
+
redirect: 'follow',
|
|
4607
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
4608
|
+
});
|
|
4609
|
+
if (!response.ok) {
|
|
4610
|
+
return [];
|
|
4611
|
+
}
|
|
4612
|
+
const body = await response.text();
|
|
4613
|
+
// Some sites answer robots.txt with their HTML 404 page.
|
|
4614
|
+
return body.trimStart().startsWith('<') ? [] : parseRobotsTxt(body);
|
|
4615
|
+
}
|
|
4616
|
+
catch {
|
|
4617
|
+
return [];
|
|
4618
|
+
}
|
|
4619
|
+
})();
|
|
4620
|
+
cache.set(origin, pending);
|
|
4621
|
+
return pending;
|
|
4622
|
+
};
|
|
4623
|
+
const isAllowed = async (requestUrl, userAgent = '*') => {
|
|
4624
|
+
let url;
|
|
4625
|
+
try {
|
|
4626
|
+
url = new URL(requestUrl);
|
|
4627
|
+
}
|
|
4628
|
+
catch {
|
|
4629
|
+
return { allowed: true };
|
|
4630
|
+
}
|
|
4631
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
4632
|
+
return { allowed: true };
|
|
4633
|
+
}
|
|
4634
|
+
if (url.pathname === '/robots.txt') {
|
|
4635
|
+
return { allowed: true };
|
|
4636
|
+
}
|
|
4637
|
+
if (exempt.has(url.origin)) {
|
|
4638
|
+
return { allowed: true };
|
|
4639
|
+
}
|
|
4640
|
+
const groups = await loadRobots(url.origin);
|
|
4641
|
+
return isPathAllowed(groups, userAgent, url.pathname + url.search);
|
|
4642
|
+
};
|
|
4643
|
+
const gatedFetch = async (input, init) => {
|
|
4644
|
+
const requestUrl = resolveRequestUrl(input);
|
|
4645
|
+
const userAgent = resolveUserAgent(input, init);
|
|
4646
|
+
const verdict = await isAllowed(requestUrl, userAgent);
|
|
4647
|
+
if (verdict.allowed) {
|
|
4648
|
+
return baseFetch(input, init);
|
|
4649
|
+
}
|
|
4650
|
+
onBlocked?.({ url: requestUrl, userAgent, rule: verdict.rule });
|
|
4651
|
+
return new Response(`Blocked by robots.txt (${verdict.rule}) - ${new URL(requestUrl).origin}/robots.txt`, { status: 403, statusText: 'Blocked by robots.txt' });
|
|
4652
|
+
};
|
|
4653
|
+
return { isAllowed, fetch: gatedFetch };
|
|
4654
|
+
}
|
|
4655
|
+
/**
|
|
4656
|
+
* Wraps a fetch so that requests disallowed by the origin's robots.txt are
|
|
4657
|
+
* refused with 403 instead of sent. See {@link createRobotsGate}.
|
|
4658
|
+
*/
|
|
4659
|
+
function createRobotsAwareFetch(baseFetch = fetch, options = {}) {
|
|
4660
|
+
return createRobotsGate(baseFetch, options).fetch;
|
|
4661
|
+
}
|
|
4662
|
+
|
|
2637
4663
|
/**
|
|
2638
4664
|
* Crawling provider implementation
|
|
2639
4665
|
* - Defines crawling targets
|
|
@@ -2648,9 +4674,32 @@ class CrawlingProvider {
|
|
|
2648
4674
|
customFetch;
|
|
2649
4675
|
/** Crawling target groups configuration */
|
|
2650
4676
|
crawlingTargetGroups;
|
|
2651
|
-
constructor(articleRepository, customFetch) {
|
|
4677
|
+
constructor(articleRepository, customFetch, excavationReportSource, logger, publicDataApiKey, bidTriage) {
|
|
2652
4678
|
this.articleRepository = articleRepository;
|
|
2653
|
-
|
|
4679
|
+
// robots.txt is checked first, so a disallowed request is never sent — not
|
|
4680
|
+
// even through a proxy. The injected and KRAS adapters sit inside it: their
|
|
4681
|
+
// requests either bypass the network entirely or are rewritten to a URL
|
|
4682
|
+
// that still gets checked.
|
|
4683
|
+
const withRobots = createRobotsAwareFetch(customFetch ?? fetch, {
|
|
4684
|
+
exemptOrigins: robotsExemptOrigins,
|
|
4685
|
+
onBlocked: ({ url, rule, userAgent }) => {
|
|
4686
|
+
logger?.info({
|
|
4687
|
+
event: 'crawl.robots.blocked',
|
|
4688
|
+
data: { url, rule, userAgent },
|
|
4689
|
+
});
|
|
4690
|
+
},
|
|
4691
|
+
});
|
|
4692
|
+
const withKras = createKrasFetch(withRobots);
|
|
4693
|
+
// The two public job boards are read from data.go.kr open APIs. Without a
|
|
4694
|
+
// key they answer with an empty list and make no request, so the targets
|
|
4695
|
+
// stay configured and simply collect nothing.
|
|
4696
|
+
const withPublicJobs = createG2bFetch(createAlioFetch(createGojobsFetch(withKras, { apiKey: publicDataApiKey ?? '' }), { apiKey: publicDataApiKey ?? '' }), { apiKey: publicDataApiKey ?? '', triage: bidTriage });
|
|
4697
|
+
// When the application supplies excavation reports, that board is served
|
|
4698
|
+
// from the injected source and never requested over the network. Every
|
|
4699
|
+
// other target keeps going through the same fetch as before.
|
|
4700
|
+
this.customFetch = excavationReportSource
|
|
4701
|
+
? createExcavationReportFetch(withPublicJobs, excavationReportSource)
|
|
4702
|
+
: withPublicJobs;
|
|
2654
4703
|
this.crawlingTargetGroups = createCrawlingTargetGroups(this.customFetch);
|
|
2655
4704
|
}
|
|
2656
4705
|
/**
|
|
@@ -2811,7 +4860,7 @@ function createContentGenerationModel(config) {
|
|
|
2811
4860
|
switch (config.provider) {
|
|
2812
4861
|
case 'openai': {
|
|
2813
4862
|
const provider = createOpenAI({ apiKey: config.apiKey });
|
|
2814
|
-
return provider(config.model ?? 'gpt-5.
|
|
4863
|
+
return provider(config.model ?? 'gpt-5.6-sol');
|
|
2815
4864
|
}
|
|
2816
4865
|
case 'anthropic': {
|
|
2817
4866
|
const provider = createAnthropic({ apiKey: config.apiKey });
|
|
@@ -2829,7 +4878,19 @@ function createNewsletterGenerator(dependencies) {
|
|
|
2829
4878
|
});
|
|
2830
4879
|
const dateService = new DateService(dependencies.publishDate);
|
|
2831
4880
|
const taskService = new TaskService(dependencies.taskRepository);
|
|
2832
|
-
const crawlingProvider = new CrawlingProvider(dependencies.articleRepository, dependencies.customFetch
|
|
4881
|
+
const crawlingProvider = new CrawlingProvider(dependencies.articleRepository, dependencies.customFetch, dependencies.excavationReportSource, dependencies.logger, dependencies.publicDataApiKey,
|
|
4882
|
+
// 나라장터 publishes about 1,500 notices per 48-hour window, so which ones
|
|
4883
|
+
// reach per-article scoring is decided here, 100 titles per request. The
|
|
4884
|
+
// deterministic filter stays behind it as the fallback.
|
|
4885
|
+
createHeritageBidTriage({
|
|
4886
|
+
model: openai('gpt-5.6-luna'),
|
|
4887
|
+
onFallback: (reason, batchSize) => {
|
|
4888
|
+
dependencies.logger?.info({
|
|
4889
|
+
event: 'crawl.g2b.triage.fallback',
|
|
4890
|
+
data: { reason, batchSize },
|
|
4891
|
+
});
|
|
4892
|
+
},
|
|
4893
|
+
}));
|
|
2833
4894
|
const analysisProvider = new AnalysisProvider(openai, dependencies.articleRepository, dependencies.tagRepository);
|
|
2834
4895
|
// Inject display date from DateService into template options
|
|
2835
4896
|
const templateOptions = dependencies.templateOptions
|
|
@@ -2853,6 +4914,7 @@ function createNewsletterGenerator(dependencies) {
|
|
|
2853
4914
|
const contentGenerateProvider = new ContentGenerateProvider(contentModel, dependencies.articleRepository, dependencies.newsletterRepository, templateOptions, resolvedBrandName);
|
|
2854
4915
|
return new GenerateNewsletter({
|
|
2855
4916
|
contentOptions: resolvedContentOptions,
|
|
4917
|
+
promptProvider: dependencies.promptProvider ?? researchRadarPromptProvider,
|
|
2856
4918
|
dateService,
|
|
2857
4919
|
taskService,
|
|
2858
4920
|
crawlingProvider,
|
|
@@ -3152,5 +5214,5 @@ ${poweredByFooterHtml()}
|
|
|
3152
5214
|
</html>`;
|
|
3153
5215
|
}
|
|
3154
5216
|
|
|
3155
|
-
export { AnalysisProvider, ContentGenerateProvider, CrawlingProvider, DateService, TaskService, contentOptions, createCrawlingTargetGroups, generateNewsletter, generateWelcomeHTML, getSourceList, llmConfig, newsletterConfig };
|
|
5217
|
+
export { AnalysisProvider, ContentGenerateProvider, CrawlingProvider, DateService, TaskService, contentOptions, createCrawlingTargetGroups, generateNewsletter, generateWelcomeHTML, getSourceList, llmConfig, maximumImportanceScoreByDomain, newsletterConfig, researchRadarPromptProvider, robotsExemptOrigins };
|
|
3156
5218
|
//# sourceMappingURL=index.js.map
|