@heripo/research-radar 5.0.5 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,15 +15,15 @@ English | [한국어](./README-ko.md)
15
15
 
16
16
  An AI-powered newsletter service for Korean cultural heritage. Built on [`@llm-newsletter-kit/core`](https://github.com/heripo-lab/llm-newsletter-kit-core), it's both a production service ([live at heripo.app](https://heripo.app/research-radar/subscribe)) and a reference implementation showing how to build automated newsletters with LLMs.
17
17
 
18
- **Production metrics**:
19
- - **Cost**: $0.2-1 USD per issue
20
- - **Operation**: Fully autonomous 24/7 (no human intervention)
21
- - **Engagement**: 15% CTR
18
+ This package provides crawling, analysis, content generation, and email templates. The hosted service supplies database repositories, scheduling, subscriber management, and email delivery separately.
19
+
20
+ Previously reported service metrics were $0.2–1 per issue and 15% CTR. These are historical figures, not benchmarks for the current models or deployment.
22
21
 
23
22
  **Technical highlights**:
23
+
24
24
  - Type-safe TypeScript with strict interfaces
25
25
  - Provider pattern for swapping components (Crawling/Analysis/Content/Email)
26
- - 66 crawling targets across heritage agencies, museums, academic societies
26
+ - 73 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
 
@@ -33,7 +33,7 @@ An AI-powered newsletter service for Korean cultural heritage. Built on [`@llm-n
33
33
 
34
34
  Created by archaeologist-turned-engineer Hongyeon Kim to answer: "Why must research rely on labor-intensive manual work?"
35
35
 
36
- A personal script evolved into a production service after completing research on [Archaeological Informatization Using LLMs](https://poc.heripo.org). This repository open-sources the running service so developers can build
36
+ A personal script evolved into a production service after completing research on [Archaeological Informatization Using LLMs](https://poc.heripo.org). This repository open-sources the running service so developers can build
37
37
  domain-specific newsletters without starting from scratch.
38
38
 
39
39
  ## License
@@ -67,63 +67,88 @@ For academic publications:
67
67
  ## Installation
68
68
 
69
69
  ```bash
70
- npm install @heripo/research-radar @llm-newsletter-kit/core
70
+ npm install @heripo/research-radar '@llm-newsletter-kit/core@~3.0.0'
71
71
  ```
72
72
 
73
- **Requirements**: Node.js >= 24, OpenAI API key, content generation API key (OpenAI / Anthropic / Google)
73
+ **Requirements**: Node.js >= 24 and an ESM application. The package exports `dist/index.js` and TypeScript declarations (`dist/index.d.ts`), with a JavaScript sourcemap. The core engine is a peer dependency; the current supported range is `~3.0.5`. 3.0.5 is the floor because the newsletter generation prompt this package ships relies on the self-verification retry cap added there.
74
74
 
75
- **Note**: `@llm-newsletter-kit/core` is a peer dependency and must be installed separately.
75
+ Article analysis requires an OpenAI API key. Content generation requires a key for the selected provider (OpenAI / Anthropic / Google); OpenAI can use the same key for both. Keys are passed explicitly to the library; load environment variables in your application.
76
76
 
77
77
  ## Quick Start
78
78
 
79
+ Implement the four repository interfaces for your storage layer, then call this application-level wrapper. The repositories in this example are supplied by the caller; no database adapter is bundled.
80
+
79
81
  ```typescript
80
- import { generateNewsletter } from '@heripo/research-radar';
81
-
82
- const newsletterId = await generateNewsletter({
83
- openAIApiKey: process.env.OPENAI_API_KEY,
84
- contentGeneration: {
85
- provider: 'anthropic', // 'openai' | 'anthropic' | 'google'
86
- apiKey: process.env.ANTHROPIC_API_KEY,
87
- // model: 'claude-sonnet-4-6', // optional, uses sensible default
88
- },
82
+ import {
83
+ type ArticleRepository,
84
+ type NewsletterRepository,
85
+ type TagRepository,
86
+ type TaskRepository,
87
+ generateNewsletter,
88
+ } from '@heripo/research-radar';
89
+
90
+ export async function runNewsletter(repositories: {
91
+ taskRepository: TaskRepository;
92
+ articleRepository: ArticleRepository;
93
+ tagRepository: TagRepository;
94
+ newsletterRepository: NewsletterRepository;
95
+ }) {
96
+ const apiKey = process.env.OPENAI_API_KEY;
97
+ if (!apiKey) throw new Error('OPENAI_API_KEY is required');
98
+
99
+ const newsletterId = await generateNewsletter({
100
+ ...repositories,
101
+ openAIApiKey: apiKey,
102
+ contentGeneration: { provider: 'openai', apiKey },
103
+ logger: console,
104
+ });
105
+
106
+ if (newsletterId === null) {
107
+ console.log('No newsletter was created for this run.');
108
+ return null;
109
+ }
110
+
111
+ console.log('Saved newsletter:', newsletterId);
112
+ return newsletterId;
113
+ }
114
+ ```
89
115
 
90
- // Implement these repository interfaces (see src/types/dependencies.ts)
91
- taskRepository: {
92
- createTask: async () => db.tasks.create({ status: 'running' }),
93
- completeTask: async (id) => db.tasks.update(id, { status: 'completed' }),
94
- },
116
+ `generateNewsletter()` returns `Promise<string | number | null>`: the saved newsletter ID, or `null` when no issue is created (for example, when publication criteria are not met). Generation errors can reject the promise. The internal `createNewsletterGenerator()` factory is not exported.
95
117
 
96
- articleRepository: {
97
- findByUrls: async (urls) => db.articles.findByUrls(urls),
98
- saveCrawledArticles: async (articles, ctx) => db.articles.save(articles, ctx),
99
- findUnscoredArticles: async () => db.articles.findUnscored(),
100
- updateAnalysis: async (article) => db.articles.updateAnalysis(article),
101
- findCandidatesForNewsletter: async () => db.articles.findCandidates(),
102
- },
118
+ ### Repository contracts
103
119
 
104
- tagRepository: {
105
- findAllTags: async () => db.tags.findAll(),
106
- },
120
+ All interfaces are exported from the package root and defined in [src/types/dependencies.ts](./src/types/dependencies.ts). Article and newsletter data types come from `@llm-newsletter-kit/core`.
107
121
 
108
- newsletterRepository: {
109
- getNextIssueOrder: async () => db.newsletters.getNextOrder(),
110
- saveNewsletter: async (data) => db.newsletters.save(data),
111
- },
122
+ | Repository | Method | Required result |
123
+ | ---------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------- |
124
+ | `TaskRepository` | `createTask()` | `Promise<number>` — task ID, not a task record |
125
+ | | `completeTask(taskId)` | `Promise<void>` |
126
+ | `ArticleRepository` | `findByUrls(urls)` | `Promise<ParsedTarget[]>` for deduplication |
127
+ | | `saveCrawledArticles(articles, context)` | `Promise<number>` — saved count; preserve task, target group, and target context |
128
+ | | `findUnscoredArticles()` | `Promise<UnscoredArticle[]>` |
129
+ | | `updateAnalysis(article)` | `Promise<void>` |
130
+ | | `findCandidatesForNewsletter()` | `Promise<ArticleForGenerateContent[]>` |
131
+ | `TagRepository` | `findAllTags()` | `Promise<string[]>` |
132
+ | `NewsletterRepository` | `getNextIssueOrder()` | `Promise<number>` |
133
+ | | `saveNewsletter({ newsletter, usedArticles })` | `Promise<{ id: string \| number }>` |
112
134
 
113
- // Optional parameters:
114
- logger: console,
115
- publishDate: '2026-02-20', // Override publication date (ISO format)
116
- templateOptions: { /* ... */ }, // Newsletter template customization
117
- customFetch: proxyFetch, // Custom fetch for proxy-based crawling
118
- previewNewsletter: {
119
- fetchNewsletterForPreview: async () => db.newsletters.latest(),
120
- emailService: resendEmailService,
121
- emailMessage: { from: 'news@example.com', to: 'preview@example.com' },
122
- },
123
- });
124
- ```
135
+ Candidate selection belongs to your repository. Persist `usedArticles` associations as needed to exclude previously published content. Issue numbering is initialized before the pipeline starts; coordinate concurrent runs in your application or database. `TaskService` tracks an active task within one service instance, not across processes or separate `generateNewsletter()` calls.
136
+
137
+ ### Optional generation settings
138
+
139
+ | Option | Behavior |
140
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
141
+ | `logger` | Core `AppLogger` implementation, such as `console` |
142
+ | `publishDate` | A real calendar date in `YYYY-MM-DD` format; invalid values throw. Defaults to the current date in `Asia/Seoul` (KST), regardless of server timezone |
143
+ | `customFetch` | A `typeof fetch` implementation for crawling and parser API requests, such as a proxy adapter; does not configure LLM requests |
144
+ | `templateOptions` | Default or KRAS newsletter branding and Markdown sections (see below) |
145
+ | `promptProvider` | Core `PromptProvider` overriding the package's own LLM prompts entirely; omit to use the package's prompts, which fall back to core's defaults |
146
+ | `excavationReportSource` | Supplies 국가유산청 발굴조사 보고서 entries from the application; that board is then never crawled. Omit to crawl it as before |
147
+ | `previewNewsletter` | Fetch a saved `Newsletter` and send it through a supplied core `EmailService` |
148
+
149
+ `previewNewsletter` requires `fetchNewsletterForPreview: () => Promise<Newsletter>`, `emailService` (with `send(message)`), and `emailMessage` (core `EmailMessage` without `subject`, `html`, or `text`). The core fills those three fields and skips preview delivery when no newsletter was created. Ensure the callback fetches the issue saved by this run.
125
150
 
126
- **Repository interfaces** are defined in `src/types/dependencies.ts`. Each method signature includes JSDoc with expected input/output types.
151
+ Production integration must also supply a scheduler, subscriber storage, bulk delivery, and unsubscribe handling. The daily parser health-check workflow does not generate or distribute newsletters.
127
152
 
128
153
  ## Architecture
129
154
 
@@ -136,41 +161,138 @@ const newsletterId = await generateNewsletter({
136
161
 
137
162
  Uses the **Provider-Service pattern** from `@llm-newsletter-kit/core`. See [core docs](https://github.com/heripo-lab/llm-newsletter-kit-core#architecture--flow) for flow diagrams.
138
163
 
139
- ## Components
164
+ ## Configuration and models
165
+
166
+ Defaults below describe the checked-in code, not provider recommendations. This package uses AI SDK 7 and the OpenAI, Anthropic, and Google SDK adapters.
167
+
168
+ | Stage | Provider | Default model |
169
+ | ------------------ | --------- | ------------------------ |
170
+ | Tag classification | OpenAI | `gpt-5.6-luna` |
171
+ | Image analysis | OpenAI | `gpt-5.6-terra` |
172
+ | Importance scoring | OpenAI | `gpt-5.6-terra` |
173
+ | Content generation | OpenAI | `gpt-5.6-sol` |
174
+ | Content generation | Anthropic | `claude-sonnet-4-6` |
175
+ | Content generation | Google | `gemini-3.1-pro-preview` |
176
+
177
+ Select content generation with `contentGeneration: { provider, apiKey, model? }`. `model` overrides that provider's default. Analysis models are configured in [analysis.provider.ts](./src/providers/analysis.provider.ts).
178
+
179
+ [src/config/index.ts](./src/config/index.ts) defines Korean output (`outputLanguage: '한국어'`), the cultural heritage domain (`expertField: ['문화유산']`), brand name, `subscribePageUrl`, LLM `maxRetries: 5`, chain `stopAfterAttempt: 3`, and generation `temperature: 0.3`. Publication settings are `minimumArticleCountForIssue: 5` and `priorityArticleScoreThreshold: 8`; the core engine evaluates them. In the locked core 3.0.5 implementation, the count check skips **5 or fewer** candidates unless at least one has importance score >= 8. An empty candidate list is always skipped.
180
+
181
+ ## Crawling targets and parsers
182
+
183
+ [src/config/crawling-targets.ts](./src/config/crawling-targets.ts) currently defines these board targets (multiple boards may belong to one organization):
184
+
185
+ | Group | Active | Commented out |
186
+ | ---------- | -----: | ------------: |
187
+ | News | 57 | 1 |
188
+ | Business | 4 | 0 |
189
+ | Employment | 12 | 0 |
190
+ | **Total** | **73** | **1** |
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.
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.
195
+
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
+
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
+
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.
201
+
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
+
204
+ To display the active sources without crawling:
205
+
206
+ ```typescript
207
+ import { getSourceList } from '@heripo/research-radar';
208
+
209
+ const groups = getSourceList(); // [{ id, name, sources: [{ id, name, url }] }]
210
+ ```
211
+
212
+ `createCrawlingTargetGroups(customFetch?)`, `getSourceList()`, `contentOptions`, `newsletterConfig`, `llmConfig`, and `researchRadarPromptProvider` are public exports. `ExcavationReport` and `ExcavationReportSource` are exported as types. The package also exports the three provider classes, `DateService`, `TaskService`, and their public configuration/dependency types through [src/index.ts](./src/index.ts).
140
213
 
141
- **Config** (`src/config/`): Brand, language, LLM settings
214
+ ## Email templates
142
215
 
143
- **Targets** (`src/config/crawling-targets.ts`): 66 sources (News 52, Business 4, Employment 10)
216
+ [src/templates/](./src/templates/) contains the responsive newsletter template, welcome email template, and shared logos, introduction, sanitization, and footer components. Templates include light/dark mode styles and heripo/KRAS variants. Markdown sections are converted with `safe-markdown2html`; welcome email names are sanitized with DOMPurify and CSS is inlined with `juice`.
144
217
 
145
- **Parsers** (`src/parsers/`): Custom extractors per organization
218
+ `NewsletterTemplateOptions` supports:
219
+
220
+ | Option | Purpose |
221
+ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
222
+ | `isKrasNewsletter` | Enable KRAS branding, the 50th-anniversary header, archaeology-first expertise, free-form introduction, and the brand name `한국고고학회 뉴스레터` |
223
+ | `krasNewsMarkdown` | Society news before generated content |
224
+ | `heripolabNewsMarkdown` | heripo lab news before generated content |
225
+ | `krasNoticeMarkdown` | Society notices after generated content |
226
+ | `titleContext` | Title-generation context, only when `isKrasNewsletter: true`; an empty string is ignored |
227
+ | `displayDate` | Header date; `generateNewsletter()` replaces it with the date resolved by `DateService` when template options are supplied |
228
+
229
+ The three Markdown sections are available in either branding mode.
230
+
231
+ ```typescript
232
+ import {
233
+ type NewsletterTemplateOptions,
234
+ generateWelcomeHTML,
235
+ } from '@heripo/research-radar';
236
+
237
+ const templateOptions: NewsletterTemplateOptions = {
238
+ isKrasNewsletter: true,
239
+ titleContext: '한국고고학전국대회',
240
+ krasNewsMarkdown: '학회 소식을 입력하세요.',
241
+ krasNoticeMarkdown: '학회 공지사항을 입력하세요.',
242
+ };
243
+
244
+ const welcomeHtml = await generateWelcomeHTML('subscriber-id', '홍길동', {
245
+ isKrasNewsletter: true,
246
+ siteUrl: 'https://heripo.app',
247
+ });
248
+ ```
146
249
 
147
- **Templates** (`src/templates/`): `newsletter-html.ts` (responsive email with light/dark mode), `welcome-html.ts` (`generateWelcomeHTML()` async), `shared.ts` (shared HTML components)
250
+ `generateWelcomeHTML(id, name, options?)` returns `Promise<string>` and only renders HTML. `siteUrl` defaults to `https://heripo.app`; unsubscribe links use `/research-radar/unsubscribe?id=...`. Newsletter templates retain Resend's `{{{RESEND_UNSUBSCRIBE_URL}}}` marker, which your delivery integration must resolve or replace. The raw newsletter template factory and shared HTML helpers are internal, not package-root exports.
148
251
 
149
252
  ## Development commands
150
253
 
151
254
  ```bash
255
+ # Install development dependencies from the lockfile
256
+ npm ci
257
+
152
258
  # build
259
+ npm run clean # remove dist/
153
260
  npm run build # clean dist/ and build with Rollup (ESM + types)
154
261
 
155
262
  # type-check & lint
156
263
  npm run lint # lint source files
157
264
  npm run lint:fix # lint with autofix
265
+ npm run lint:ci # quiet CI lint
158
266
  npm run typecheck # TypeScript type-check
159
267
 
160
268
  # formatting
161
- npm run format # Prettier formatting
269
+ npm run format # format src/ with Prettier
270
+ npm run format:check # check src/ formatting
162
271
  ```
163
272
 
273
+ The build emits ESM, declarations, and a JavaScript sourcemap; runtime dependencies remain external. CI runs `npm ci`, `format:check`, `lint:ci`, `typecheck`, and `build` on Node.js 24.x for pull requests and manual dispatch. Formatting scripts cover `src/`; to check the READMEs explicitly, run `npx prettier --check README.md README-ko.md`. There is no `npm test` script.
274
+
275
+ For maintainers, `release` publishes to npm, while `release:patch`, `release:minor`, and `release:major` bump the version and publish. The version hooks build first and push commits/tags; `prepublishOnly` builds before publishing.
276
+
164
277
  ### Crawler Debugger
165
278
 
166
279
  A web-based tool for testing crawling parsers during development. Built with Express.js and vanilla HTML/CSS/JS to minimize dependencies.
167
280
 
168
281
  ```bash
169
282
  npm run dev:crawler # Start at http://localhost:3333
170
- npm run dev:crawler:proxy # Start with proxy support (uses .env)
283
+ npm run dev:crawler:proxy # Load dev-tools/crawler-debugger/.env
284
+ ```
285
+
286
+ For proxy use, create the gitignored `dev-tools/crawler-debugger/.env` file with your proxy endpoint:
287
+
288
+ ```dotenv
289
+ PROXY_URL=http://127.0.0.1:8080
171
290
  ```
172
291
 
292
+ The library itself receives a `customFetch` function; it does not read `PROXY_URL`.
293
+
173
294
  **Features**:
295
+
174
296
  - Test `parseList()` and `parseDetail()` parsers via web UI
175
297
  - View raw HTML source for debugging
176
298
  - Copy parsed results as JSON
@@ -185,7 +307,7 @@ Preview rendered newsletter HTML with sample content.
185
307
  npm run dev:newsletter-preview # Start at http://localhost:3334
186
308
  ```
187
309
 
188
- Query params: `?kras=true` (KRAS mode), `?krasNews=true` (KRAS news section), `?heripolabNews=true` (heripo lab news section)
310
+ Use the UI controls, or open `/api/preview?kras=true&krasNews=true&krasNotice=true&heripolabNews=true` on port 3334. These query parameters belong to `/api/preview`, not the root UI URL. Each section switch includes sample Markdown; this preview does not call an LLM.
189
311
 
190
312
  ### Welcome Email Preview
191
313
 
@@ -195,7 +317,7 @@ Preview rendered welcome email HTML.
195
317
  npm run dev:welcome-preview # Start at http://localhost:3335
196
318
  ```
197
319
 
198
- Query params: `?kras=true` (KRAS mode), `?name=홍길동` (subscriber name)
320
+ Use the UI controls, or open `/api/preview?kras=true&name=홍길동` on port 3335. No API key is needed for either HTML preview tool.
199
321
 
200
322
  ### Parser Health-Check
201
323
 
@@ -203,19 +325,20 @@ CLI tool that validates all active crawling parsers against live websites. Detec
203
325
 
204
326
  ```bash
205
327
  npm run health-check # Run health-check
206
- npm run health-check:proxy # Run with proxy support (uses .env)
328
+ npm run health-check:proxy # Load the same .env and pass --proxy
207
329
  npm run health-check -- --skip-khs-excavation # Skip KHS excavation report/site-open targets
208
330
  ```
209
331
 
210
332
  **What it checks per target**:
211
- - `parseList()`: Returns non-empty array with valid title, date, and detailUrl
212
- - `parseDetail()`: Returns non-empty detailContent (20+ chars)
213
333
 
214
- Use `--skip-target=<id-or-name>` to exclude additional known-flaky targets from a run.
334
+ - `parseList()`: Non-empty array; checks the first item for non-empty title/date and a detailUrl starting with `http`
335
+ - `parseDetail()`: Fetches the first detail item and checks trimmed `detailContent` length >= 20
336
+
337
+ Use repeatable `--skip-target=<id-or-name>` or `--skip-target <id-or-name>` to exclude targets by exact ID or name. `npm run health-check -- --help` lists options without crawling. This is a live-site smoke check of the first item per target, not full article validation; it does not invoke LLM analysis. The script exits with code 1 if any checked target fails.
215
338
 
216
339
  **Output**: Console table summary + compact text summary for CI integrations.
217
340
 
218
- **CI**: Daily automated run via GitHub Actions (`.github/workflows/parser-health-check.yml`) with Slack notifications on pass/fail. CI skips the KHS excavation report/site-open targets because their fetches can fail in Actions even when normal crawling works.
341
+ **CI**: [.github/workflows/parser-health-check.yml](./.github/workflows/parser-health-check.yml) runs daily at 08:00 UTC (17:00 KST), or manually, on an `org-linux` runner with a 30-minute job timeout. It skips the two KHS excavation report/site-open targets. The health-check composes the same fetch as production — robots.txt gate, then the KRAS detail adapter — so disallowed boards are reported as skipped rather than failed: 16 skipped and 55 checked with the current configuration. The detail check tries up to three list items, so one unreadable post at the top of a board does not fail the target. Slack notifications require the `SLACK_BOT_TOKEN` secret and `SLACK_ALERT_DEV_CHANNEL` repository variable. Forks need a matching runner and notification configuration to use this workflow unchanged. The CLI also writes GitHub Actions outputs and a job summary when their environment variables are present.
219
342
 
220
343
  ## 🤝 Contributing
221
344
 
@@ -230,55 +353,58 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution workflow, dev setup, a
230
353
 
231
354
  To build your own newsletter, update these files:
232
355
 
233
- **1. Template** (`src/templates/newsletter-html.ts`):
356
+ **1. Templates** (`src/templates/newsletter-html.ts`, `welcome-html.ts`, `shared.ts`):
357
+
234
358
  - Logo URLs, brand colors (#D2691E, #E59866), contact info
235
359
  - Platform intro and footer text
236
360
  - Unsubscribe link format (currently Resend's `{{{RESEND_UNSUBSCRIBE_URL}}}`)
237
361
 
238
362
  **2. Config** (`src/config/index.ts`):
363
+
239
364
  ```typescript
240
- brandName: 'Your Newsletter Name'
241
- subscribeUrl: 'https://yourdomain.com/subscribe'
365
+ import type { NewsletterConfig } from '@heripo/research-radar';
366
+
367
+ export const newsletterConfig: NewsletterConfig = {
368
+ brandName: 'Your Newsletter Name',
369
+ subscribePageUrl: 'https://yourdomain.com/subscribe',
370
+ publicationCriteria: {
371
+ minimumArticleCountForIssue: 5,
372
+ priorityArticleScoreThreshold: 8,
373
+ },
374
+ };
242
375
  ```
243
376
 
244
377
  **3. Crawling targets** (`src/config/crawling-targets.ts`):
378
+
245
379
  - Replace Korean heritage sites with your domain sources
246
380
  - Implement parsers in `src/parsers/`
247
381
 
248
382
  **4. Switch content generation LLM provider** (optional):
249
383
 
250
384
  Content generation supports **3 built-in providers** — just change `contentGeneration.provider`:
385
+
251
386
  ```typescript
252
- contentGeneration: {
253
- provider: 'google', // 'openai' | 'anthropic' | 'google'
254
- apiKey: process.env.GOOGLE_API_KEY,
255
- model: 'gemini-3.1-pro-preview', // optional, each provider has a default
256
- }
257
- ```
258
- Default models: openai=`gpt-5.1`, anthropic=`claude-sonnet-4-6`, google=`gemini-3.1-pro-preview`
387
+ import type { ContentGenerationConfig } from '@heripo/research-radar';
259
388
 
260
- Analysis provider (OpenAI) can be changed by modifying `src/providers/analysis.provider.ts`. Any [Vercel AI SDK provider](https://sdk.vercel.ai/providers) works.
389
+ const apiKey = process.env.GOOGLE_API_KEY;
390
+ if (!apiKey) throw new Error('GOOGLE_API_KEY is required');
261
391
 
262
- **Search keywords**: `heripo`, `kimhongyeon`, `#D2691E`, `openai`, `gpt-5`, `contentGeneration`
392
+ const contentGeneration: ContentGenerationConfig = {
393
+ provider: 'google',
394
+ apiKey,
395
+ model: 'gemini-3.1-pro-preview',
396
+ };
397
+ ```
263
398
 
264
- ## Why Code-Based?
399
+ Default models: openai=`gpt-5.6-sol`, anthropic=`claude-sonnet-4-6`, google=`gemini-3.1-pro-preview`
265
400
 
266
- Code-based automation delivers **superior output quality** through advanced AI techniques:
401
+ To change analysis providers, update both `src/providers/analysis.provider.ts` (provider type and models) and `src/newsletter-generator.ts` (provider construction), using a compatible AI SDK provider. Also adapt domain-specific minimum-score rules in the analysis provider, output language and expert fields in config, package metadata, and the GitHub Actions runner/Slack settings for your fork.
267
402
 
268
- **No-code platforms**: Generic content, limited to built-in features
269
- **This kit**: Self-reflection, chain-of-thought, multi-step verification workflows
403
+ **Search keywords**: `heripo`, `kimhongyeon`, `#D2691E`, `openai`, `gpt-5`, `contentGeneration`
270
404
 
271
- **Key advantages**:
272
- - **Quality**: Sophisticated prompting strategies, custom validation pipelines
273
- - **Cost control**: Different models per step, token limits, retry logic
274
- - **Flexibility**: Swap any component (Crawling/Analysis/Content/Email) via Provider interfaces
275
- - **Operations**: Built-in retries, preview emails, integrates with CI/CD
276
- - **No lock-in**: OSS, self-hostable, any LLM provider
405
+ ## Why Code-Based?
277
406
 
278
- **Design philosophy**:
279
- - Logic in code (orchestration, deduplication)
280
- - Reasoning in AI (analysis, scoring, content generation)
281
- - Connections in architecture (swappable Providers)
407
+ The domain logic is inspectable in source: parser behavior, model selection, score rules, publication settings, and HTML templates can be versioned together. Repository and provider interfaces let applications integrate existing storage and delivery systems, while the core engine supplies orchestration and retry handling.
282
408
 
283
409
  ## Related Projects
284
410
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { UrlString, ParsedTarget, CrawlingTargetGroup, CrawlingTarget, UnscoredArticle, ArticleForUpdateByAnalysis, ArticleForGenerateContent, Newsletter, AppLogger, EmailService, EmailMessage, DateService as DateService$1, IsoDateString, TaskService as TaskService$1, AnalysisProvider as AnalysisProvider$1, ContentGenerateProvider as ContentGenerateProvider$1, HtmlTemplate, CrawlingProvider as CrawlingProvider$1, GenerateNewsletterConfig } from '@llm-newsletter-kit/core';
1
+ import { UrlString, ParsedTarget, CrawlingTargetGroup, CrawlingTarget, UnscoredArticle, ArticleForUpdateByAnalysis, ArticleForGenerateContent, Newsletter, IsoDateString, AppLogger, EmailService, EmailMessage, PromptProvider, DateService as DateService$1, TaskService as TaskService$1, AnalysisProvider as AnalysisProvider$1, ContentGenerateProvider as ContentGenerateProvider$1, HtmlTemplate, CrawlingProvider as CrawlingProvider$1, GenerateNewsletterConfig } from '@llm-newsletter-kit/core';
2
2
  import { OpenAIProvider } from '@ai-sdk/openai';
3
3
  import { LanguageModel } from 'ai';
4
4
 
@@ -54,6 +54,53 @@ interface ArticleRepository {
54
54
  */
55
55
  findCandidatesForNewsletter(): Promise<ArticleForGenerateContent[]>;
56
56
  }
57
+ /**
58
+ * A single excavation report supplied by the application instead of crawled.
59
+ *
60
+ * Field names mirror what the 국가유산청 report board exposes, so an application
61
+ * that already stores these rows can hand them over without reshaping them.
62
+ */
63
+ interface ExcavationReport {
64
+ /**
65
+ * The board's own identifier (`ecexmRcno`). Used both as the article's unique
66
+ * id and to build its public detail URL, so it must match the value the board
67
+ * uses — otherwise previously crawled reports are re-saved as duplicates.
68
+ * @example "202609157717"
69
+ */
70
+ externalId: string;
71
+ /**
72
+ * Report title (보고서명).
73
+ * @example "태안 태안읍성 -남문지 및 연지-"
74
+ */
75
+ title: string;
76
+ /**
77
+ * Submission date (제출일) in ISO format (YYYY-MM-DD).
78
+ * @example "2026-09-15"
79
+ */
80
+ submittedDate: IsoDateString;
81
+ /**
82
+ * Report detail fields as label/value pairs, rendered into the article body in
83
+ * insertion order. Supply whatever the source holds — 허가번호, 유적명,
84
+ * 발간기관, 조사시도시군구, 조사기간, 유적성격/시대구분 and so on.
85
+ * Empty values are skipped.
86
+ */
87
+ fields: Record<string, string | null | undefined>;
88
+ /**
89
+ * Whether the report has a downloadable file. Defaults to true, matching the
90
+ * crawled parser.
91
+ */
92
+ hasAttachedFile?: boolean;
93
+ }
94
+ /**
95
+ * Supplies excavation reports from the application instead of crawling them.
96
+ *
97
+ * When provided, the 국가유산청 발굴조사 보고서 board is served from this
98
+ * function and never requested over the network; the rest of the crawl is
99
+ * unaffected. When omitted, the board is crawled as before.
100
+ *
101
+ * Called at most once per generation run.
102
+ */
103
+ type ExcavationReportSource = () => Promise<ExcavationReport[]>;
57
104
  /**
58
105
  * Repository interface for tag management
59
106
  */
@@ -178,7 +225,7 @@ interface PreviewNewsletterOptions {
178
225
  * Each provider uses a sensible default model that can be overridden.
179
226
  *
180
227
  * Default models:
181
- * - openai: `gpt-5.4`
228
+ * - openai: `gpt-5.6-sol`
182
229
  * - anthropic: `claude-sonnet-4-6`
183
230
  * - google: `gemini-3.1-pro-preview`
184
231
  */
@@ -226,6 +273,30 @@ interface NewsletterGeneratorDependencies {
226
273
  templateOptions?: NewsletterTemplateOptions;
227
274
  /** Custom fetch function for crawling (e.g., proxy-based fetch). Optional. */
228
275
  customFetch?: typeof fetch;
276
+ /**
277
+ * Supplies 국가유산청 발굴조사 보고서 entries from the application (optional).
278
+ *
279
+ * When provided, that board is read from this function and never crawled,
280
+ * which lets an application that already stores the reports reuse them. Omit
281
+ * it to keep crawling the board as before. No other target is affected.
282
+ */
283
+ excavationReportSource?: ExcavationReportSource;
284
+ /**
285
+ * data.go.kr service key for the 나라일터 and 알리오 job boards (optional).
286
+ *
287
+ * Pass the encoded key exactly as the portal supplies it. Both boards are read
288
+ * through open APIs rather than scraped; omit the key and they collect
289
+ * nothing while every other target is unaffected.
290
+ */
291
+ publicDataApiKey?: string;
292
+ /**
293
+ * LLM prompt overrides (optional).
294
+ *
295
+ * When provided, this replaces Research Radar's own prompt provider entirely
296
+ * rather than merging with it. Omit it to use the package's tuned prompts,
297
+ * which in turn fall back to core's defaults for any stage they do not define.
298
+ */
299
+ promptProvider?: PromptProvider;
229
300
  }
230
301
  /**
231
302
  * Newsletter generation execution function
@@ -433,7 +504,7 @@ declare class CrawlingProvider implements CrawlingProvider$1 {
433
504
  customFetch?: typeof fetch;
434
505
  /** Crawling target groups configuration */
435
506
  crawlingTargetGroups: CrawlingTargetGroup[];
436
- constructor(articleRepository: ArticleRepository, customFetch?: typeof fetch);
507
+ constructor(articleRepository: ArticleRepository, customFetch?: typeof fetch, excavationReportSource?: ExcavationReportSource, logger?: AppLogger, publicDataApiKey?: string);
437
508
  /**
438
509
  * Fetch existing articles by URLs to avoid duplicate crawling
439
510
  * @param articleUrls - URLs to check
@@ -489,6 +560,38 @@ declare const contentOptions: ContentOptions;
489
560
  * Newsletter brand configuration
490
561
  */
491
562
  declare const newsletterConfig: NewsletterConfig;
563
+ /**
564
+ * Maximum importance score per heritage domain (`tag1`).
565
+ *
566
+ * The newsletter is archaeology-first: archaeology and cultural heritage keep
567
+ * the full 1-10 range, while natural and intangible heritage are capped so they
568
+ * cannot crowd out archaeological coverage. `기타` — material that is not
569
+ * heritage at all — is capped lowest, because the scoring prompt's academic-value
570
+ * floor is domain-blind and would otherwise lift things like a general journal's
571
+ * call for papers into the top half of the scale.
572
+ *
573
+ * The cap is applied deterministically after scoring, in
574
+ * `AnalysisProvider.update()`, rather than asked for in the prompt, so the
575
+ * ceiling always holds. It never reaches 1: a score of 1 means "exclude from the
576
+ * newsletter" in the consuming application's candidate query, so capping to 1
577
+ * would delete these articles instead of demoting them.
578
+ *
579
+ * Domains absent from this map are not capped.
580
+ */
581
+ declare const maximumImportanceScoreByDomain: Record<string, number>;
582
+ /**
583
+ * Origins exempted from the robots.txt check.
584
+ *
585
+ * Each entry deliberately overrides what the site publishes, so it needs a
586
+ * reason and should be revisited when that site's robots.txt changes.
587
+ *
588
+ * - `http://www.yngogo.or.kr` (영남고고학회): its board is rendered from
589
+ * `/module/ntt/unity/selectNttListAjax.ink`, and robots.txt carries a blanket
590
+ * `Disallow: /module`. The rule reads as protecting an internal path rather
591
+ * than the public board it happens to serve, and there is no other route to
592
+ * the listing, so the society's boards are collected under this exemption.
593
+ */
594
+ declare const robotsExemptOrigins: readonly string[];
492
595
  /**
493
596
  * LLM configuration
494
597
  */
@@ -500,5 +603,35 @@ declare const llmConfig: {
500
603
  };
501
604
  };
502
605
 
503
- export { AnalysisProvider, ContentGenerateProvider, CrawlingProvider, DateService, TaskService, contentOptions, createCrawlingTargetGroups, generateNewsletter, generateWelcomeHTML, getSourceList, llmConfig, newsletterConfig };
504
- export type { ArticleRepository, ContentGenerationConfig, ContentOptions, NewsletterConfig, NewsletterGeneratorDependencies, NewsletterRepository, NewsletterTemplateOptions, PreviewNewsletterOptions, SourceGroup, SourceItem, TagRepository, TaskRepository, WelcomeTemplateOptions };
606
+ /**
607
+ * Research Radar's LLM prompt overrides.
608
+ *
609
+ * Each builder here **replaces** core's built-in prompt for that stage rather
610
+ * than extending it — a `PromptBuilder` returns the whole prompt string. That is
611
+ * deliberate: core's defaults instruct the model to use emoticons, render
612
+ * importance as star ratings, and add share-of-coverage statistics, all of which
613
+ * this newsletter removes. Appending contradicting rules to those defaults
614
+ * degrades the output, so a replacement is written from scratch instead.
615
+ *
616
+ * **Contract a replacement must still satisfy.** Core's output schema is fixed,
617
+ * and `generateNewsletter` regenerates the whole newsletter whenever the model
618
+ * reports a failure. A replacement prompt has to steer the model toward:
619
+ *
620
+ * - `title`: 20–70 characters
621
+ * - `isWrittenInOutputLanguage`, `copyrightVerified`, `factAccuracy`: true
622
+ * - `titleContext` (KRAS mode): the phrase must appear in the title
623
+ *
624
+ * Core caps that loop at 5 attempts, so a prompt that ignores the contract costs
625
+ * up to 5 full generations on the most expensive model in the pipeline.
626
+ *
627
+ * Tune these against real articles with core's playground
628
+ * (`npm run playground:generate-newsletter` in ../llm-newsletter-kit-core) and
629
+ * diff them against the defaults for free with `playground:verify-prompts`.
630
+ * The playground loads this module from `dist`, so run `npm run build` here
631
+ * after every edit — its source cannot be imported directly across repos
632
+ * because of the `~/*` path alias.
633
+ */
634
+ declare const researchRadarPromptProvider: PromptProvider;
635
+
636
+ export { AnalysisProvider, ContentGenerateProvider, CrawlingProvider, DateService, TaskService, contentOptions, createCrawlingTargetGroups, generateNewsletter, generateWelcomeHTML, getSourceList, llmConfig, maximumImportanceScoreByDomain, newsletterConfig, researchRadarPromptProvider, robotsExemptOrigins };
637
+ export type { ArticleRepository, ContentGenerationConfig, ContentOptions, ExcavationReport, ExcavationReportSource, NewsletterConfig, NewsletterGeneratorDependencies, NewsletterRepository, NewsletterTemplateOptions, PreviewNewsletterOptions, SourceGroup, SourceItem, TagRepository, TaskRepository, WelcomeTemplateOptions };