@heripo/research-radar 5.0.5 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +216 -90
- package/dist/index.d.ts +138 -5
- package/dist/index.js +1564 -141
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
package/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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
214
|
+
## Email templates
|
|
142
215
|
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 #
|
|
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
|
-
|
|
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
|
-
|
|
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 #
|
|
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
|
-
|
|
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**:
|
|
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.
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
+
const apiKey = process.env.GOOGLE_API_KEY;
|
|
390
|
+
if (!apiKey) throw new Error('GOOGLE_API_KEY is required');
|
|
261
391
|
|
|
262
|
-
|
|
392
|
+
const contentGeneration: ContentGenerationConfig = {
|
|
393
|
+
provider: 'google',
|
|
394
|
+
apiKey,
|
|
395
|
+
model: 'gemini-3.1-pro-preview',
|
|
396
|
+
};
|
|
397
|
+
```
|
|
263
398
|
|
|
264
|
-
|
|
399
|
+
Default models: openai=`gpt-5.6-sol`, anthropic=`claude-sonnet-4-6`, google=`gemini-3.1-pro-preview`
|
|
265
400
|
|
|
266
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
504
|
-
|
|
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 };
|