@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/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
- // NOTE: Parsing logic is implemented, but crawling is restricted by robots.txt policy
1631
- // {
1632
- // id: '국립경주박물관_새소식',
1633
- // name: '국립경주박물관 새소식',
1634
- // url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0701.html',
1635
- // parseList: (html) =>
1636
- // parseGyeongjuMuseumList(html, '/kor/html/sub07/0701.html'),
1637
- // parseDetail: parseGyeongjuMuseumDetail,
1638
- // },
1639
- // {
1640
- // id: '국립경주박물관_고시공고',
1641
- // name: '국립경주박물관 고시/공고',
1642
- // url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0703.html',
1643
- // parseList: parseGyeongjuMuseumNoticeList,
1644
- // parseDetail: parseGyeongjuMuseumDetail,
1645
- // },
1646
- // {
1647
- // id: '국립경주박물관_보도자료',
1648
- // name: '국립경주박물관 보도자료',
1649
- // url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0705.html',
1650
- // parseList: (html) =>
1651
- // parseGyeongjuMuseumList(html, '/kor/html/sub07/0705.html'),
1652
- // parseDetail: parseGyeongjuMuseumDetail,
1653
- // },
1654
- // {
1655
- // id: '국립청주박물관_새소식',
1656
- // name: '국립청주박물관 새소식',
1657
- // url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=1&key=482&nbar=s',
1658
- // parseList: parseCheongjuMuseumList,
1659
- // parseDetail: parseCheongjuMuseumDetail,
1660
- // },
1661
- // {
1662
- // id: '국립청주박물관_언론보도자료',
1663
- // name: '국립청주박물관 언론보도자료',
1664
- // url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=20&key=31&nbar=s',
1665
- // parseList: parseCheongjuMuseumList,
1666
- // parseDetail: parseCheongjuMuseumDetail,
1667
- // },
1668
- // {
1669
- // id: '국립김해박물관_새소식',
1670
- // name: '국립김해박물관 새소식',
1671
- // url: 'https://gimhae.museum.go.kr/kr/html/sub04/0401.html',
1672
- // parseList: (html) =>
1673
- // parseGimhaeMuseumList(html, '/kr/html/sub04/0401.html'),
1674
- // parseDetail: parseGimhaeMuseumDetail,
1675
- // },
1676
- // {
1677
- // id: '국립김해박물관_보도자료',
1678
- // name: '국립김해박물관 언론보도자료',
1679
- // url: 'https://gimhae.museum.go.kr/kr/html/sub04/0402.html',
1680
- // parseList: (html) =>
1681
- // parseGimhaeMuseumList(html, '/kr/html/sub04/0402.html'),
1682
- // parseDetail: parseGimhaeMuseumDetail,
1683
- // },
1684
- // {
1685
- // id: '국립제주박물관_새소식',
1686
- // name: '국립제주박물관 새소식',
1687
- // url: 'https://jeju.museum.go.kr/_prog/_board/?code=sub02_0201&site_dvs_cd=kr&menu_dvs_cd=050101&ntt_tag=1',
1688
- // parseList: parseJejuMuseumList,
1689
- // parseDetail: parseJejuMuseumDetail,
1690
- // },
1691
- // {
1692
- // id: '국립익산박물관_공지사항',
1693
- // name: '국립익산박물관 공지사항',
1694
- // url: 'https://iksan.museum.go.kr/kor/html/sub05/0501.html',
1695
- // parseList: parseIksanMuseumList,
1696
- // parseDetail: parseIksanMuseumDetail,
1697
- // },
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
- // NOTE: Parsing logic is implemented, but crawling is restricted by robots.txt policy
1788
- // {
1789
- // id: '국립경주박물관_채용안내',
1790
- // name: '국립경주박물관 채용안내',
1791
- // url: 'https://gyeongju.museum.go.kr/kor/html/sub07/0704.html',
1792
- // parseList: (html) =>
1793
- // parseGyeongjuMuseumList(html, '/kor/html/sub07/0704.html'),
1794
- // parseDetail: parseGyeongjuMuseumDetail,
1795
- // },
1796
- // {
1797
- // id: '국립청주박물관_채용및공고',
1798
- // name: '국립청주박물관 채용 및 공고',
1799
- // url: 'https://cheongju.museum.go.kr/www/selectBbsNttList.do?bbsNo=29&key=476&nbar=s',
1800
- // parseList: parseCheongjuMuseumList,
1801
- // parseDetail: parseCheongjuMuseumDetail,
1802
- // },
1803
- // {
1804
- // id: '국립제주박물관_채용정보',
1805
- // name: '국립제주박물관 채용정보',
1806
- // url: 'https://jeju.museum.go.kr/_prog/_board/?code=sub02_0201&site_dvs_cd=kr&menu_dvs_cd=050102&ntt_tag=2',
1807
- // parseList: parseJejuMuseumList,
1808
- // parseDetail: parseJejuMuseumDetail,
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-mini'),
3653
+ model: this.openai('gpt-5.6-luna'),
1873
3654
  };
1874
3655
  this.analyzeImagesOptions = {
1875
- model: this.openai('gpt-5.1'),
3656
+ model: this.openai('gpt-5.6-terra'),
1876
3657
  };
1877
3658
  this.determineScoreOptions = {
1878
- model: this.openai('gpt-5.1'),
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(article);
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
- this.customFetch = createKrasFetch(customFetch ?? fetch);
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.4');
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