@heripo/research-radar 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,7 +23,7 @@ Previously reported service metrics were $0.2–1 per issue and 15% CTR. These a
23
23
 
24
24
  - Type-safe TypeScript with strict interfaces
25
25
  - Provider pattern for swapping components (Crawling/Analysis/Content/Email)
26
- - 73 active crawling targets across heritage agencies, museums, academic societies, filtered at runtime by robots.txt
26
+ - 74 active crawling targets across heritage agencies, museums, academic societies, filtered at runtime by robots.txt
27
27
  - Multi LLM providers: OpenAI GPT-5 (analysis) + selectable content generation (OpenAI / Anthropic / Google)
28
28
  - Built-in retries, chain options, preview emails
29
29
 
@@ -185,19 +185,19 @@ Select content generation with `contentGeneration: { provider, apiKey, model? }`
185
185
  | Group | Active | Commented out |
186
186
  | ---------- | -----: | ------------: |
187
187
  | News | 57 | 1 |
188
- | Business | 4 | 0 |
188
+ | Business | 5 | 0 |
189
189
  | Employment | 12 | 0 |
190
- | **Total** | **73** | **1** |
190
+ | **Total** | **74** | **1** |
191
191
 
192
- Only the excavation status board stays commented out, as low-value fragmented data. Boards that a site's robots.txt restricts are configured normally and refused at runtime by the robots.txt check, so the configuration does not have to track each site's policy by hand. With current policies 14 of the 73 targets are refused.
192
+ Only the excavation status board stays commented out, as low-value fragmented data. Boards that a site's robots.txt restricts are configured normally and refused at runtime by the robots.txt check, so the configuration does not have to track each site's policy by hand. With current policies 14 of the 74 targets are refused.
193
193
 
194
- Two Employment targets are read from data.go.kr open APIs instead of scraped: 나라일터 (`PblJobService`) and 알리오 (`recruitment`). Each is a single request, and `publicDataApiKey` supplies the service key — omit it and both answer with an empty list without making a request. `src/crawling/heritage-job-filter.ts` narrows them before analysis, since the boards carry every public-sector vacancy in the country; roughly 2% survive, about 2.6 postings a day.
194
+ Three targets are read from data.go.kr open APIs instead of scraped: 나라일터 (`PblJobService`) and 알리오 (`recruitment`) under Employment, and 나라장터 (`BidPublicInfoService`) under Business. Each is a single round of requests, and `publicDataApiKey` supplies the service key — omit it and they answer with an empty list without making a request. The boards carry every public-sector vacancy and procurement notice in the country, so both are narrowed before analysis. The job boards use `src/crawling/heritage-job-filter.ts`, whose exclusions are role nouns and hold up as patterns; roughly 2% of postings survive, about 2.6 a day. 나라장터 instead uses `src/crawling/heritage-triage.ts`, which asks a cheap model about 100 titles per request: heritage vocabulary cannot be settled by substring in Korean, and the deterministic filter remains behind it as the fallback when a batch fails.
195
195
 
196
196
  Crawling fetches pass through that check (`src/crawling/robots.ts`) before reaching the network: a disallowed request is refused rather than sent, and a missing or unreachable robots.txt allows it. `robotsExemptOrigins` in `src/config/index.ts` lists origins exempted from the check, each with its reason.
197
197
 
198
198
  Sources include the Korea Heritage Service, National Research Institute of Cultural Heritage, National Research Institute of Maritime Heritage, Korea Heritage Agency, Korea Association of Archaeological Heritage, archaeological societies, and national museums.
199
199
 
200
- [src/parsers/](./src/parsers/) contains 22 organization-specific parser modules plus shared date and URL utilities. List parsers return `ParsedTargetListItem[]` (title, date, detail URL, date type, and optional source ID); detail parsers return Markdown `detailContent` and attachment/image flags. Parsers can be synchronous or asynchronous. KRAS, Yeongnam Archaeological Society, and maritime heritage sources use additional API requests for client-rendered content.
200
+ [src/parsers/](./src/parsers/) contains 23 organization-specific parser modules plus shared date and URL utilities. List parsers return `ParsedTargetListItem[]` (title, date, detail URL, date type, and optional source ID); detail parsers return Markdown `detailContent` and attachment/image flags. Parsers can be synchronous or asynchronous. KRAS, Yeongnam Archaeological Society, and maritime heritage sources use additional API requests for client-rendered content.
201
201
 
202
202
  `CrawlingProvider` uses a maximum concurrency of 5 and wraps the supplied fetch to route KRAS public detail URLs to its detail API, while retaining public URLs in article metadata. When constructing your own pipeline, use the provider's fetch together with its target groups.
203
203
 
package/dist/index.d.ts CHANGED
@@ -490,6 +490,22 @@ declare class ContentGenerateProvider implements ContentGenerateProvider$1 {
490
490
  }>;
491
491
  }
492
492
 
493
+ type HeritageBidCandidate = {
494
+ /** Issuing and requesting institutions, joined. */
495
+ institution: string;
496
+ /** Notice title. */
497
+ title: string;
498
+ /** `pubPrcrmntMidClsfcNm`, when the notice carries one. */
499
+ classification?: string | null;
500
+ };
501
+
502
+ /**
503
+ * Decides which 나라장터 notices are worth scoring, one batch at a time.
504
+ *
505
+ * Returns one verdict per candidate, in the order they were given.
506
+ */
507
+ type HeritageBidTriage = (candidates: HeritageBidCandidate[], signal?: AbortSignal) => Promise<boolean[]>;
508
+
493
509
  /**
494
510
  * Crawling provider implementation
495
511
  * - Defines crawling targets
@@ -504,7 +520,7 @@ declare class CrawlingProvider implements CrawlingProvider$1 {
504
520
  customFetch?: typeof fetch;
505
521
  /** Crawling target groups configuration */
506
522
  crawlingTargetGroups: CrawlingTargetGroup[];
507
- constructor(articleRepository: ArticleRepository, customFetch?: typeof fetch, excavationReportSource?: ExcavationReportSource, logger?: AppLogger, publicDataApiKey?: string);
523
+ constructor(articleRepository: ArticleRepository, customFetch?: typeof fetch, excavationReportSource?: ExcavationReportSource, logger?: AppLogger, publicDataApiKey?: string, bidTriage?: HeritageBidTriage);
508
524
  /**
509
525
  * Fetch existing articles by URLs to avoid duplicate crawling
510
526
  * @param articleUrls - URLs to check
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';
@@ -61,20 +62,257 @@ function isHeritageJobCandidate({ institution, title, categoryCodes = [], }) {
61
62
  }
62
63
  return categoryCodes.some((code) => HERITAGE_NCS_CODES.includes(code));
63
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
+ };
64
302
 
65
- const API_BASE$1 = 'https://apis.data.go.kr/1051000/recruitment';
66
- const SITE_BASE$1 = 'https://job.alio.go.kr';
303
+ const API_BASE$2 = 'https://apis.data.go.kr/1051000/recruitment';
304
+ const SITE_BASE$2 = 'https://job.alio.go.kr';
67
305
  /**
68
306
  * Public 알리오 board URL used as this target's list page.
69
307
  *
70
308
  * As with 나라일터, core fetches this URL and `createAlioFetch` answers it from
71
309
  * the 재정경제부 open API, keeping the service key out of the configuration.
72
310
  */
73
- const ALIO_LIST_URL = `${SITE_BASE$1}/recruit.do`;
311
+ const ALIO_LIST_URL = `${SITE_BASE$2}/recruit.do`;
74
312
  /** Public posting URL. */
75
- const buildAlioDetailUrl = (serial) => `${SITE_BASE$1}/recruitview.do?idx=${serial}`;
313
+ const buildAlioDetailUrl = (serial) => `${SITE_BASE$2}/recruitview.do?idx=${serial}`;
76
314
  /** `yyyymmdd` to ISO `yyyy-mm-dd`. */
77
- function toIsoDate$1(compact) {
315
+ function toIsoDate$2(compact) {
78
316
  return /^\d{8}$/.test(compact)
79
317
  ? `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`
80
318
  : '';
@@ -104,7 +342,7 @@ const createAlioFetch = (baseFetch = fetch, options) => {
104
342
  catch {
105
343
  return baseFetch(input, init);
106
344
  }
107
- if (url.origin !== SITE_BASE$1) {
345
+ if (url.origin !== SITE_BASE$2) {
108
346
  return baseFetch(input, init);
109
347
  }
110
348
  if (!apiKey) {
@@ -112,19 +350,19 @@ const createAlioFetch = (baseFetch = fetch, options) => {
112
350
  return Response.json({ result: [] });
113
351
  }
114
352
  if (url.pathname === '/recruit.do') {
115
- return baseFetch(`${API_BASE$1}/list?serviceKey=${apiKey}&resultType=json` +
353
+ return baseFetch(`${API_BASE$2}/list?serviceKey=${apiKey}&resultType=json` +
116
354
  `&numOfRows=${rowsPerPage}&pageNo=1&ongoingYn=Y`, init);
117
355
  }
118
356
  if (url.pathname === '/recruitview.do') {
119
357
  const serial = url.searchParams.get('idx');
120
358
  if (serial) {
121
- return baseFetch(`${API_BASE$1}/detail?serviceKey=${apiKey}&resultType=json&sn=${serial}`, init);
359
+ return baseFetch(`${API_BASE$2}/detail?serviceKey=${apiKey}&resultType=json&sn=${serial}`, init);
122
360
  }
123
361
  }
124
362
  return baseFetch(input, init);
125
363
  };
126
364
  };
127
- function parseJson$1(body) {
365
+ function parseJson$2(body) {
128
366
  try {
129
367
  return JSON.parse(body);
130
368
  }
@@ -144,7 +382,7 @@ function renderAlioContent(record) {
144
382
  [
145
383
  '공고기간',
146
384
  [record.pbancBgngYmd, record.pbancEndYmd]
147
- .map((value) => toIsoDate$1(value ?? ''))
385
+ .map((value) => toIsoDate$2(value ?? ''))
148
386
  .filter(Boolean)
149
387
  .join(' - '),
150
388
  ],
@@ -170,7 +408,7 @@ function renderAlioContent(record) {
170
408
  }
171
409
  /** Parses the aggregated list response, keeping only heritage-related postings. */
172
410
  const parseAlioList = (body) => {
173
- const parsed = parseJson$1(body);
411
+ const parsed = parseJson$2(body);
174
412
  const posts = [];
175
413
  for (const record of parsed?.result ?? []) {
176
414
  const title = record.recrutPbancTtl?.trim();
@@ -188,7 +426,7 @@ const parseAlioList = (body) => {
188
426
  posts.push({
189
427
  uniqId: String(record.recrutPblntSn),
190
428
  title,
191
- date: toIsoDate$1(record.pbancBgngYmd ?? ''),
429
+ date: toIsoDate$2(record.pbancBgngYmd ?? ''),
192
430
  detailUrl: buildAlioDetailUrl(record.recrutPblntSn),
193
431
  dateType: DateType.REGISTERED,
194
432
  });
@@ -197,7 +435,7 @@ const parseAlioList = (body) => {
197
435
  };
198
436
  /** Parses a `/detail` response into the article body. */
199
437
  const parseAlioDetail = (body) => {
200
- const parsed = parseJson$1(body);
438
+ const parsed = parseJson$2(body);
201
439
  const record = parsed?.result;
202
440
  return {
203
441
  detailContent: record ? renderAlioContent(record) : '',
@@ -373,7 +611,7 @@ function parseInjectedPayload(body) {
373
611
  return null;
374
612
  }
375
613
  }
376
- function jsonResponse(payload) {
614
+ function jsonResponse$1(payload) {
377
615
  return new Response(JSON.stringify(payload), {
378
616
  status: 200,
379
617
  headers: { 'content-type': 'application/json' },
@@ -406,7 +644,7 @@ const createExcavationReportFetch = (baseFetch = fetch, source) => {
406
644
  }
407
645
  if (url.pathname === new URL(EXCAVATION_REPORT_LIST_URL).pathname) {
408
646
  const reports = await loadReports();
409
- return jsonResponse({
647
+ return jsonResponse$1({
410
648
  marker: INJECTED_MARKER,
411
649
  reports,
412
650
  });
@@ -415,7 +653,7 @@ const createExcavationReportFetch = (baseFetch = fetch, source) => {
415
653
  const externalId = url.searchParams.get('ecexmRcno');
416
654
  const report = (await loadReports()).find((candidate) => candidate.externalId === externalId);
417
655
  if (report) {
418
- return jsonResponse({
656
+ return jsonResponse$1({
419
657
  marker: INJECTED_MARKER,
420
658
  report,
421
659
  });
@@ -539,6 +777,281 @@ function getUniqIdFromExcavationItem(element) {
539
777
  return ((element.attr('onclick') ?? '').match(/dataSelected\('(.*)'\)/)?.[1] ?? '');
540
778
  }
541
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
+
542
1055
  const parseGimhaeMuseumList = (html, hrefPrefix) => {
543
1056
  const $ = cheerio.load(html);
544
1057
  const posts = [];
@@ -2374,6 +2887,20 @@ function createCrawlingTargetGroups(customFetch) {
2374
2887
  parseList: parseKaahList,
2375
2888
  parseDetail: parseKaahDetail,
2376
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
+ },
2377
2904
  ],
2378
2905
  },
2379
2906
  {
@@ -2655,13 +3182,36 @@ const DOMAIN_PRIORITY = `## 유산 영역 우선순위
2655
3182
  판단이 애매할 때 고고학 기사는 고려 중인 점수대의 위쪽을, 자연유산·무형유산 기사는 아래쪽을 택한다.
2656
3183
  영역은 tag1에 표기되어 있다. 단, 영역만으로 점수를 정하지는 않는다 — 사안 자체가 미미한 고고학 기사보다
2657
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
+ "소장유물 복제", "소장품도록 발간"은 유물을 직접 다루므로 정상 채점한다.`;
2658
3205
  const EMPLOYMENT_FILTER = `## 채용 공고 판별
2659
3206
 
2660
- 채용 공고는 직무가 유산 분야인지 먼저 확인한다.
3207
+ 채용 공고는 **직무가 아래 지원 직무 목록에 해당하는지**를 먼저 본다.
2661
3208
 
2662
- - 유산 분야: 학예연구, 발굴조사, 보존처리, 유산 행정·연구직 등 → 아래 점수 기준을 정상 적용
2663
- - **비유산 분야: 조리, 방호·경비, 시설관리, 청소, 운전, 일반 사무보조 등 → 점수 1**
2664
- 기관이 박물관·연구소라는 이유만으로 유산 분야로 보지 않는다. 직무 내용으로 판단한다.
3209
+ - **비유산 지원 직무: 조리·급식, 방호·경비, 청소·미화, 시설관리, 운전, 당직 → 점수 1**
3210
+ 이 목록에 해당하는 직무만 1점이다. 목록에 없는 직무를 유추해서 넣지 않는다.
3211
+ - **그 밖의 직무는 모두 아래 점수 기준을 정상 적용한다.** 특히 박물관·미술관·연구소가 내는
3212
+ 공고는 직무명이 학예직이 아니어도 — 행정, 연구, 교육, 전시, 기록물, 일반직 — 유산 분야로 본다.
3213
+ 기관명에 '문화유산'이나 '역사'가 들어가지 않아도 마찬가지다. 국립농업박물관의 행정직 공고는
3214
+ 박물관의 일자리이고, 그 자체가 독자에게 실무 정보다.
2665
3215
  - 한 공고에 두 분야가 섞여 있으면 유산 분야 직무를 기준으로 평가한다.`;
2666
3216
  function scoreScale(minimumScore) {
2667
3217
  const tiers = [
@@ -2673,7 +3223,7 @@ function scoreScale(minimumScore) {
2673
3223
  '2-3: 단순 정보 공유, 반복적인 일상 소식',
2674
3224
  ];
2675
3225
  if (minimumScore === 1) {
2676
- tiers.push('1: **현재 실무 가치가 없는 정보** — 종료된 지원사업, 지난 행사, 만료된 입찰·채용 공고, 회비 납부 현황·회의록·내부 일정 같은 단순 행정 공지, 그리고 위 채용 판별에서 걸러진 비유산 분야 채용');
3226
+ tiers.push('1: **현재 실무 가치가 없는 정보** — 종료된 지원사업, 지난 행사, 만료된 입찰·채용 공고, 회비 납부 현황·회의록·내부 일정 같은 단순 행정 공지, 그리고 위 채용 판별에서 걸러진 비유산 지원 직무 채용');
2677
3227
  }
2678
3228
  return `## 점수 기준 (${minimumScore}-10)\n\n${tiers.join('\n')}`;
2679
3229
  }
@@ -2697,6 +3247,11 @@ function temporalRule$1(minimumScore) {
2697
3247
  - 예외 ②: **이미 일어난 일을 전하는 보도·발표 기사**는 깎지 않는다. 지정·선정 결과, 조사 성과, 협약·기증·개최 소식처럼
2698
3248
  독자가 알아두면 되는 내용이면 행사일이 지났더라도 사안의 무게대로 평가한다.
2699
3249
  (판단 기준: 기사가 독자에게 무엇을 하라고 요구하는가? 아무 행동도 요구하지 않는다면 이 규칙의 대상이 아니다.)
3250
+ - 예외 ③: **발굴조사·시굴조사·매장유산 조사 입찰 공고**는 입찰 마감이 지났어도 깎지 않는다.
3251
+ 독자는 입찰에 참여하려고 이 소식을 보는 것이 아니라 **어디서 어떤 조사가 시작되는지** 알려고 본다.
3252
+ "○○ 유적 정밀발굴조사 용역 발주"는 그 자체로 고고학계 소식이다.
3253
+ 이런 공고는 수의계약으로 나와 마감이 공고 다음 날인 경우가 많아, 마감으로 거르면 정작 알려야 할 조사가 사라진다.
3254
+ 문화재 수리·보수정비·보존처리 발주도 같게 본다. 다만 입찰 참여 조건이나 마감을 강조해 쓰지는 않는다.
2700
3255
  - 마감·일정 언급이 전혀 없으면 이 규칙은 적용하지 않는다.
2701
3256
  `;
2702
3257
  }
@@ -2718,6 +3273,8 @@ const determineImportancePrompt = {
2718
3273
 
2719
3274
  ${DOMAIN_PRIORITY}
2720
3275
 
3276
+ ${SUBJECT_OVER_INSTITUTION}
3277
+
2721
3278
  ${EMPLOYMENT_FILTER}
2722
3279
 
2723
3280
  ${scoreScale(minimumScore)}
@@ -2761,6 +3318,34 @@ const LEADING_CATEGORIES = [
2761
3318
  '⛏️ 발굴현장 공개',
2762
3319
  '💼 채용·공고',
2763
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
+ 본문에는 싣되 제목과 브리핑에는 올리지 않는다.`;
2764
3349
  /**
2765
3350
  * Per-category table columns.
2766
3351
  *
@@ -2786,6 +3371,14 @@ const TABLE_SPECS = `### 표 형식 (구조적 목록)
2786
3371
  - 공고: 공고명을 [원제목](URL) 링크로
2787
3372
  - 기관: 채용 기관명만 (예: 국립중앙박물관)
2788
3373
  - 접수 마감: 날짜와 시각. 합격자 발표처럼 마감이 없는 공고는 "—"
3374
+ - **사업·입찰**: \`사업 | 발주기관 | 마감\`
3375
+ - 사업: 공고명을 [원제목](URL) 링크로
3376
+ - 발주기관: 발주처 이름만 (예: 안동시). 수요기관이 따로 있으면 수요기관을 쓴다
3377
+ - 마감: 입찰 마감 일시. 원문에 없으면 "—"
3378
+ - **아래 시간 유효성 예외 ③으로 살린 만료 공고는 마감 칸을 "—"로 둔다.**
3379
+ 이미 지난 마감 일시를 적으면 독자가 지원할 수 있는 것으로 읽는다.
3380
+ 그 공고를 싣는 이유는 어디서 어떤 조사가 시작되는지 알리기 위해서다.
3381
+ - 사업금액, 계약방법, 공고번호, 업무구분은 **싣지 않는다**
2789
3382
 
2790
3383
  공통 규칙:
2791
3384
 
@@ -2812,7 +3405,8 @@ const EDITORIAL_RULES = `## 편집 규칙
2812
3405
  한 호 안에서 같은 이모지를 두 번 쓰지 않는다.
2813
3406
  - 통계·비중 분석을 만들어내지 않는다. "오늘 소식의 00%가 ~" 같은 문장은 쓰지 않는다.
2814
3407
  - 소식을 언급할 때마다 [원제목](URL) 형식으로 링크한다. "자세히 보기", "기사", "[3번 글]" 같은 표기는 쓰지 않는다.
2815
- - 날짜 범위는 물결표(~)가 아니라 붙임표(-)로 쓴다. 물결표는 마크다운에서 취소선이 된다.`;
3408
+ - 날짜 범위는 물결표(~)가 아니라 붙임표(-)로 쓴다. 물결표는 마크다운에서 취소선이 된다.
3409
+ ${SECTION_PLACEMENT}`;
2816
3410
  const LENGTH_CONTROL = `## 분량 (중요도 점수 기준, 점수 자체는 출력하지 않는다)
2817
3411
 
2818
3412
  - **9-10점**: 핵심 사실을 **굵게** + 링크. 대상·범위, 일정·절차, 예산·규모를 필요한 만큼 풀어쓴다.
@@ -2838,6 +3432,10 @@ function temporalRule(publicationDate) {
2838
3432
  - 예외 ①: 학술 성과(발간된 학술지, 공개된 연구, 종료된 학술대회 자료)는 참조 가치가 있으므로 남긴다.
2839
3433
  - 예외 ②: **이미 일어난 일을 전하는 보도·발표 기사**는 남긴다. 지정·선정 결과, 조사 성과, 협약·기증·개최 소식처럼
2840
3434
  독자에게 아무 행동도 요구하지 않는 기사는 행사일이 지났더라도 그대로 싣는다.
3435
+ - 예외 ③: **발굴조사·시굴조사·매장유산 조사 입찰 공고**는 입찰 마감이 지났어도 싣는다.
3436
+ 독자는 입찰에 참여하려고 보는 것이 아니라 어디서 어떤 조사가 시작되는지 알려고 본다.
3437
+ 문화재 수리·보수정비·보존처리 발주도 같다. 다만 이런 항목은 **무엇을 조사·수리하는지** 중심으로 쓰고,
3438
+ 마감이 지난 입찰 일정은 적지 않는다.
2841
3439
  - 기사 게시일이 발행일보다 30일 이상 앞서면 신선도를 의심하고, 마감이 남은 진행 중 사업처럼 여전히 앞을 내다보는 가치가 있을 때만 싣는다.
2842
3440
  - 제외한 기사는 "그 밖에 주목할 만한 소식"이나 표에도 언급하지 않는다.`;
2843
3441
  }
@@ -2853,11 +3451,18 @@ ${opening}
2853
3451
 
2854
3452
  브리핑은 **3-4문장, 짧고 강하게** 쓴다. 오늘 가장 무게 있는 소식 한두 건을 이름을 들어 짚고, 독자가 왜 지금 이것을 봐야 하는지 한 문장으로 말한다.
2855
3453
  통계, 비중, 항목 수 세기는 쓰지 않는다. 글머리 목록도 만들지 않는다. 구독 링크는 여기에 넣지 않는다.
3454
+ **\`${TRAILING_CATEGORY}\` 섹션의 소식은 브리핑에 올리지 않는다.** 단, 발굴조사·시굴조사
3455
+ 입찰은 고고학 소식이므로 무게가 있다면 올릴 수 있다.
2856
3456
 
2857
3457
  2. **분류**: 아래 순서를 지킨다. 해당 소식이 없는 분류는 건너뛴다.
2858
3458
 
2859
3459
  ${LEADING_CATEGORIES.map((c, i) => ` ${i + 1}) ${c}`).join('\n')}
2860
- ${LEADING_CATEGORIES.length + 1}) 그 외 (정책·제도, 지정·보존, 전시·행사, 입찰·공고 등 내용에 맞게 묶는다)
3460
+ ${LEADING_CATEGORIES.length + 1}) 그 외 (정책·제도, 지정·보존, 전시·행사 등 내용에 맞게 묶는다)
3461
+ ${LEADING_CATEGORIES.length + 2}) ${TRAILING_CATEGORY} — **반드시 마지막 분류**로, \`## 📌 마무리\` 바로 앞에 둔다
3462
+
3463
+ \`${TRAILING_CATEGORY}\`에는 입찰 공고와 용역·공사 발주 소식을 모은다. 위의 다른
3464
+ 분류에 섞어 넣지 않는다. 건수가 많아도 표로 한 행씩 전부 싣고, 줄이거나 생략하지 않는다.
3465
+ 독자가 놓치면 일감을 놓치는 정보다.
2861
3466
 
2862
3467
  각 분류는 Heading 2(\`##\`)를 쓴다. 분류 안에서는 중요한 것부터 배치한다.
2863
3468
  같은 내용이 여러 출처에서 왔다면 가장 자세한 것을 기준으로 한 번만 쓴다.
@@ -2872,6 +3477,9 @@ ${LEADING_CATEGORIES.map((c, i) => ` ${i + 1}) ${c}`).join('\n')}
2872
3477
  형식: \`- **2026년 9월 18일(금):** 국립김해박물관 특별전 개막 / 백제학회 학술대회\`
2873
3478
  마감 시각이 있으면 날짜 뒤에 붙인다. (예: \`- **2026년 9월 21일(월) 18:00:** …\`)
2874
3479
  본문에서 다룬 일정만 넣는다. 날짜가 없는 소식은 넣지 않는다.
3480
+ **\`${TRAILING_CATEGORY}\` 섹션의 입찰 마감은 이 목록에 넣지 않는다.** 마감 일시는
3481
+ 그 섹션의 표에 이미 있고, 목록까지 입찰로 채우면 학술대회·채용·전시 일정이 묻힌다.
3482
+ 학술대회, 채용 접수, 전시 개막·종료, 신청 마감 같은 나머지 일정만 넣는다.
2875
3483
 
2876
3484
  다음 호 예고나 문의처는 쓰지 않는다.`;
2877
3485
  }
@@ -2880,7 +3488,8 @@ function titleRules(context) {
2880
3488
  const common = `- 길이는 20-70자를 지킨다.
2881
3489
  - **이모지를 넣지 않는다.**
2882
3490
  - "뉴스레터" 같은 일반 명사 대신 구체적인 사실, 수치, 일정을 담는다.
2883
- - '발표', '시행', '마감 임박'처럼 중립적이고 객관적인 표현을 쓴다.`;
3491
+ - '발표', '시행', '마감 임박'처럼 중립적이고 객관적인 표현을 쓴다.
3492
+ ${SECTION_PLACEMENT}`;
2884
3493
  if (titleContext) {
2885
3494
  return `## 제목
2886
3495
 
@@ -4065,7 +4674,7 @@ class CrawlingProvider {
4065
4674
  customFetch;
4066
4675
  /** Crawling target groups configuration */
4067
4676
  crawlingTargetGroups;
4068
- constructor(articleRepository, customFetch, excavationReportSource, logger, publicDataApiKey) {
4677
+ constructor(articleRepository, customFetch, excavationReportSource, logger, publicDataApiKey, bidTriage) {
4069
4678
  this.articleRepository = articleRepository;
4070
4679
  // robots.txt is checked first, so a disallowed request is never sent — not
4071
4680
  // even through a proxy. The injected and KRAS adapters sit inside it: their
@@ -4084,7 +4693,7 @@ class CrawlingProvider {
4084
4693
  // The two public job boards are read from data.go.kr open APIs. Without a
4085
4694
  // key they answer with an empty list and make no request, so the targets
4086
4695
  // stay configured and simply collect nothing.
4087
- const withPublicJobs = createAlioFetch(createGojobsFetch(withKras, { apiKey: publicDataApiKey ?? '' }), { apiKey: publicDataApiKey ?? '' });
4696
+ const withPublicJobs = createG2bFetch(createAlioFetch(createGojobsFetch(withKras, { apiKey: publicDataApiKey ?? '' }), { apiKey: publicDataApiKey ?? '' }), { apiKey: publicDataApiKey ?? '', triage: bidTriage });
4088
4697
  // When the application supplies excavation reports, that board is served
4089
4698
  // from the injected source and never requested over the network. Every
4090
4699
  // other target keeps going through the same fetch as before.
@@ -4269,7 +4878,19 @@ function createNewsletterGenerator(dependencies) {
4269
4878
  });
4270
4879
  const dateService = new DateService(dependencies.publishDate);
4271
4880
  const taskService = new TaskService(dependencies.taskRepository);
4272
- const crawlingProvider = new CrawlingProvider(dependencies.articleRepository, dependencies.customFetch, dependencies.excavationReportSource, dependencies.logger, dependencies.publicDataApiKey);
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
+ }));
4273
4894
  const analysisProvider = new AnalysisProvider(openai, dependencies.articleRepository, dependencies.tagRepository);
4274
4895
  // Inject display date from DateService into template options
4275
4896
  const templateOptions = dependencies.templateOptions
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@heripo/research-radar",
3
3
  "private": false,
4
4
  "type": "module",
5
- "version": "5.1.0",
5
+ "version": "5.2.0",
6
6
  "description": "AI-driven intelligence for Korean cultural heritage. This package serves as both a ready-to-use newsletter service and a practical implementation example for the LLM-Newsletter-Kit.",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",