Skip to content

Seed 엔진 상세 설명

This content is not available in your language yet.

“Seed Engine defines HOW to fetch.”

Seed 엔진은 Research 엔진이 발견한 URL들을 기반으로 **수집 계약(Contract)**을 생성하는 시스템입니다. 실제 콘텐츠를 수집하지는 않지만, 수집에 필요한 모든 기술적 메타데이터를 준비합니다.


seed-engine/
├── raw/ # Stage 1: 불변 원본 데이터
│ └── country=sg/category=news/date=2026-01-28/
│ ├── raw_0001.json # Research 엔진 출력 (입력)
│ ├── raw_0002.json
│ ├── raw_0003.json
│ ├── raw_metadata.json # 파티션 메타데이터
│ └── raw_NNNN.json.success # 처리 완료 체크포인트
└── prod/ # Stage 2: 파이프라인 산출물
└── country=sg/category=news/date=2026-01-28/
├── mom.gov.sg/ # 도메인별 디렉토리
│ ├── domain_metadata.json # 도메인 메타데이터
│ ├── robots.txt # 수집된 robots.txt
│ ├── sitemap.xml # 수집된 sitemap.xml
│ └── domain_metadata.json.success
├── moh.gov.sg/
│ ├── domain_metadata.json
│ ├── robots.txt
│ ├── sitemap.xml
│ └── domain_metadata.json.success
└── ica.gov.sg/
└── ... (동일 구조)
Research Engine → raw/ → Seed Engine → prod/ → Scraper Engine
(입력) (불변) (처리) (산출물) (최종 수집)
  • raw/: 완전 불변, 덮어쓰기 금지, Research 엔진 출력의 정확한 복사본
  • prod/: 갱신 가능, 파이프라인 재실행 시 새로운 메타데이터로 업데이트

// raw/country=sg/category=news/date=2026-01-28/raw_0001.json
[
{
"url": "https://mom.gov.sg/employment-practices/employment-act",
"title": "Employment Act - Ministry of Manpower",
"domain": "mom.gov.sg",
"registrable_domain": "mom.gov.sg",
"domain_id": "gov:sg:mom.gov.sg",
"source_type": "gov",
"content_type": "text/html",
"language": "en",
"country": "sg",
"discovered_at": "2026-01-28T10:00:00Z",
"discovery_method": "google_search"
},
{
"url": "https://mom.gov.sg/newsroom/press-releases",
"title": "Press Releases - Ministry of Manpower",
"domain": "mom.gov.sg",
"registrable_domain": "mom.gov.sg",
"domain_id": "gov:sg:mom.gov.sg",
"source_type": "gov",
"content_type": "text/html",
"language": "en",
"country": "sg",
"discovered_at": "2026-01-28T10:01:00Z",
"discovery_method": "google_search"
}
]
// prod/country=sg/category=news/date=2026-01-28/mom.gov.sg/domain_metadata.json
{
"domain": "mom.gov.sg",
"domain_id": "gov:sg:mom.gov.sg",
"registrable_domain": "mom.gov.sg",
"urls": [
{
"url": "https://mom.gov.sg/employment-practices/employment-act",
"title": "Employment Act - Ministry of Manpower",
"path": "/employment-practices/employment-act"
},
{
"url": "https://mom.gov.sg/newsroom/press-releases",
"title": "Press Releases - Ministry of Manpower",
"path": "/newsroom/press-releases"
}
],
"technical_metadata": {
"robots_txt": {
"exists": true,
"size": 1024,
"last_modified": "2026-01-15T09:00:00Z",
"allows_crawling": true,
"sitemap_urls": [
"https://mom.gov.sg/sitemap.xml"
]
},
"sitemap_xml": {
"exists": true,
"size": 2048,
"last_modified": "2026-01-20T14:30:00Z",
"url_count": 156,
"last_updated": "2026-01-20T14:30:00Z"
},
"server_info": {
"server": "nginx/1.18.0",
"content_encoding": "gzip",
"supports_https": true,
"response_time_avg_ms": 245
}
},
"processing_metadata": {
"processed_at": "2026-01-28T15:30:00Z",
"processing_duration_ms": 1250,
"total_urls": 2,
"partition_info": {
"country": "sg",
"category": "news",
"date": "2026-01-28"
}
}
}

[Step 1] Orchestrator: 파일 목록 수집 및 Queue 발송
├── R2에서 raw/ 파일들 스캔
├── 미처리 파일 필터링 (.success 확인)
└── SEED_QUEUE로 파일 경로 전송
[Step 2] File Processor: Raw 파일 읽기 및 도메인 추출
├── Raw 파일 다운로드 및 JSON 파싱
├── URL들을 registrable_domain별로 그룹화
└── DOMAIN_QUEUE로 도메인 그룹 전송
[Step 3] Domain Collector: 기술 메타데이터 수집
├── robots.txt 다운로드 및 분석
├── sitemap.xml 수집 및 파싱
├── 서버 응답 헤더 분석
└── prod/에 도메인 메타데이터 저장

🚀 Step 1: Orchestrator (파일 목록 수집)

섹션 제목: “🚀 Step 1: Orchestrator (파일 목록 수집)”
  • R2에서 raw/ prefix 파일들을 스캔
  • 미처리 파일 식별 (.success 파일 확인)
  • SEED_QUEUE로 파일 경로 전송
Terminal window
POST /api/v1/seeds/orchestrate
// Request
interface OrchestratorRequest {
country?: string; // 선택적 필터
category?: string; // 선택적 필터
date?: string; // 선택적 필터 (YYYY-MM-DD)
force?: boolean; // .success 파일 무시하고 재처리
}
// Response
interface OrchestratorResponse {
success: boolean;
data: {
files_found: number; // 발견된 총 파일 수
sent: number; // Queue로 전송된 파일 수
skipped: number; // 이미 처리된 파일 수 (건너뛴)
};
}
export async function orchestrateSeeds(
request: OrchestratorRequest,
env: Env
): Promise<OrchestratorResponse> {
// 1. R2 List API로 raw/ 파일들 스캔
const prefix = buildRawDatasetPrefix(
request.country || '',
request.category || '',
request.date || ''
);
const files = await listRawFiles(env.DATASETS_BUCKET, prefix);
let sentCount = 0;
let skippedCount = 0;
// 2. 각 파일에 대해 처리 상태 확인 및 Queue 전송
for (const file of files) {
const partitionInfo = extractPartitionInfo(file.key);
if (!partitionInfo) continue;
// 3. .success 파일 존재 확인 (force 옵션이 아닌 경우)
if (!request.force) {
const successPath = `${file.key}.success`;
const exists = await env.DATASETS_BUCKET.head(successPath);
if (exists) {
skippedCount++;
continue;
}
}
// 4. SEED_QUEUE로 메시지 전송
try {
await env.SEED_QUEUE.send({
file_path: file.key,
partition_info: partitionInfo
});
sentCount++;
} catch (error) {
console.error(`Queue send failed: ${file.key}`, error);
}
}
return {
success: true,
data: {
files_found: files.length,
sent: sentCount,
skipped: skippedCount
}
};
}

  • Raw 파일 다운로드 및 JSON 파싱
  • URL들을 registrable_domain별로 그룹화
  • DOMAIN_QUEUE로 도메인 그룹 전송
  • 처리 완료 시 .success 체크포인트 생성
// SEED_QUEUE 입력 메시지
interface SeedQueueMessage {
file_path: string; // "raw/country=sg/category=news/date=2026-01-28/raw_0001.json"
partition_info: {
country: string; // "sg"
category: string; // "news"
date: string; // "2026-01-28"
};
}
// DOMAIN_QUEUE 출력 메시지
interface DomainQueueMessage {
domain: string; // "mom.gov.sg"
domain_id: string; // "gov:sg:mom.gov.sg"
urls: Array<{
url: string;
title: string;
path: string;
}>;
partition_info: {
country: string;
category: string;
date: string;
};
}
export async function handleSeedQueue(
batch: MessageBatch,
env: Env
): Promise<void> {
for (const message of batch.messages) {
const { file_path, partition_info } = message.body;
try {
// 1. 성공 체크포인트 확인 (idempotency)
const successPath = `${file_path}.success`;
const exists = await env.DATASETS_BUCKET.head(successPath);
if (exists) {
console.log('Already processed, skipping:', file_path);
continue;
}
// 2. Raw 파일 다운로드 및 파싱
const rawObject = await env.DATASETS_BUCKET.get(file_path);
if (!rawObject) {
throw new Error(`File not found: ${file_path}`);
}
const rawData = await rawObject.json() as RawRecord[];
// 3. 도메인별 그룹화
const domainGroups = groupByDomain(rawData);
// 4. 각 도메인 그룹을 DOMAIN_QUEUE로 전송
for (const [domain, urls] of domainGroups) {
const domainMessage: DomainQueueMessage = {
domain,
domain_id: urls[0].domain_id,
urls: urls.map(record => ({
url: record.url,
title: record.title,
path: new URL(record.url).pathname
})),
partition_info
};
await env.DOMAIN_QUEUE.send(domainMessage);
}
// 5. 성공 체크포인트 생성
await env.DATASETS_BUCKET.put(successPath, new Uint8Array(0));
console.log(`Processed ${file_path}: ${domainGroups.size} domains`);
} catch (error) {
console.error(`Seed processing failed: ${file_path}`, error);
throw error; // Queue 재시도/DLQ 처리
}
}
}
function groupByDomain(records: RawRecord[]): Map<string, RawRecord[]> {
const groups = new Map<string, RawRecord[]>();
for (const record of records) {
const domain = record.registrable_domain;
if (!groups.has(domain)) {
groups.set(domain, []);
}
groups.get(domain)!.push(record);
}
return groups;
}

🌐 Step 3: Domain Collector (DOMAIN_QUEUE)

섹션 제목: “🌐 Step 3: Domain Collector (DOMAIN_QUEUE)”
  • robots.txt 다운로드 및 분석
  • sitemap.xml 수집 및 파싱
  • 서버 응답 헤더 분석
  • prod/에 도메인 메타데이터 저장
  • 성공 체크포인트 생성
export async function handleDomainQueue(
batch: MessageBatch,
env: Env
): Promise<void> {
for (const message of batch.messages) {
const { domain, domain_id, urls, partition_info } = message.body;
try {
// 1. 성공 체크포인트 확인
const metadataPath = buildDomainMetadataPath(
partition_info.country,
partition_info.category,
partition_info.date,
domain
);
const successPath = `${metadataPath}.success`;
const exists = await env.DATASETS_BUCKET.head(successPath);
if (exists) {
console.log('Already processed, skipping:', domain);
continue;
}
// 2. 기술 메타데이터 수집
const technicalMetadata = await collectTechnicalMetadata(domain);
// 3. 도메인 메타데이터 생성
const domainMetadata = {
domain,
domain_id,
registrable_domain: domain,
urls,
technical_metadata: technicalMetadata,
processing_metadata: {
processed_at: new Date().toISOString(),
processing_duration_ms: Date.now() - startTime,
total_urls: urls.length,
partition_info
}
};
// 4. prod/에 메타데이터 저장
await env.DATASETS_BUCKET.put(
metadataPath,
JSON.stringify(domainMetadata, null, 2)
);
// 5. robots.txt 저장 (존재하는 경우)
if (technicalMetadata.robots_txt.exists) {
const robotsPath = buildRobotsTxtPath(
partition_info.country,
partition_info.category,
partition_info.date,
domain
);
await env.DATASETS_BUCKET.put(robotsPath, technicalMetadata.robots_txt.content);
}
// 6. sitemap.xml 저장 (존재하는 경우)
if (technicalMetadata.sitemap_xml.exists) {
const sitemapPath = buildSitemapXmlPath(
partition_info.country,
partition_info.category,
partition_info.date,
domain
);
await env.DATASETS_BUCKET.put(sitemapPath, technicalMetadata.sitemap_xml.content);
}
// 7. 성공 체크포인트 생성
await env.DATASETS_BUCKET.put(successPath, new Uint8Array(0));
console.log(`Domain processed: ${domain} (${urls.length} URLs)`);
} catch (error) {
console.error(`Domain processing failed: ${domain}`, error);
throw error; // Queue 재시도/DLQ 처리
}
}
}
async function collectTechnicalMetadata(domain: string): Promise<TechnicalMetadata> {
const startTime = Date.now();
// 1. robots.txt 수집
const robotsResult = await fetchRobotsTxt(domain);
// 2. sitemap.xml 수집
const sitemapResult = await fetchSitemap(domain);
// 3. 서버 정보 수집
const serverInfo = await collectServerInfo(domain);
return {
robots_txt: robotsResult,
sitemap_xml: sitemapResult,
server_info: serverInfo
};
}
async function fetchRobotsTxt(domain: string): Promise<RobotsMetadata> {
try {
const response = await fetch(`https://${domain}/robots.txt`, {
timeout: 10000,
headers: {
'User-Agent': 'NewsforkBot/1.0 (+https://newsfork.com/bot)'
}
});
if (!response.ok) {
return { exists: false, error: `HTTP ${response.status}` };
}
const content = await response.text();
const sitemapUrls = extractSitemapUrls(content);
return {
exists: true,
size: content.length,
last_modified: response.headers.get('last-modified') || null,
allows_crawling: !content.includes('Disallow: /'),
sitemap_urls: sitemapUrls,
content
};
} catch (error) {
return {
exists: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
async function fetchSitemap(domain: string): Promise<SitemapMetadata> {
try {
const response = await fetch(`https://${domain}/sitemap.xml`, {
timeout: 15000,
headers: {
'User-Agent': 'NewsforkBot/1.0 (+https://newsfork.com/bot)'
}
});
if (!response.ok) {
return { exists: false, error: `HTTP ${response.status}` };
}
const content = await response.text();
const urlCount = (content.match(/<url>/g) || []).length;
return {
exists: true,
size: content.length,
last_modified: response.headers.get('last-modified') || null,
url_count: urlCount,
last_updated: new Date().toISOString(),
content
};
} catch (error) {
return {
exists: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}

🔄 체크포인트 시스템 (Idempotency)

섹션 제목: “🔄 체크포인트 시스템 (Idempotency)”
  • 파일 레벨 체크포인트: raw_NNNN.json.success
  • 도메인 레벨 체크포인트: domain_metadata.json.success
  • 원자적 연산: 성공 시에만 체크포인트 생성
  • 재시작 안전성: 중간 실패 시 안전한 재시작
// 처리 전 체크포인트 확인
async function checkProcessed(filePath: string, bucket: R2Bucket): Promise<boolean> {
const successPath = `${filePath}.success`;
const exists = await bucket.head(successPath);
return exists !== null;
}
// 처리 완료 후 체크포인트 생성
async function markProcessed(filePath: string, bucket: R2Bucket): Promise<void> {
const successPath = `${filePath}.success`;
await bucket.put(successPath, new Uint8Array(0), {
customMetadata: {
processed_at: new Date().toISOString(),
processor: 'seed-engine'
}
});
}
// force=true 옵션으로 재처리 가능
if (force || !await checkProcessed(filePath, bucket)) {
await processFile(filePath);
await markProcessed(filePath, bucket);
}

{
"queues": {
"consumers": [
{
"queue": "newsfork-seed-staging",
"max_batch_size": 1,
"max_batch_timeout": 30,
"max_retries": 3,
"dead_letter_queue": "newsfork-dlq-staging"
},
{
"queue": "newsfork-domain-staging",
"max_batch_size": 5,
"max_batch_timeout": 60,
"max_retries": 3,
"dead_letter_queue": "newsfork-dlq-staging"
}
]
}
}
  • SEED_QUEUE: max_batch_size: 1 (파일당 1개 Worker)
  • DOMAIN_QUEUE: max_batch_size: 5 (도메인당 병렬 처리)
  • 타임아웃: 도메인 수집은 더 긴 타임아웃 (60초)
Terminal window
# R2 스토리지
DATASETS_BUCKET=newsfork-datasets-staging
# Queue 바인딩
SEED_QUEUE=newsfork-seed-staging
DOMAIN_QUEUE=newsfork-domain-staging
# 메타데이터 DB
METADATA_DB=newsfork-metadata-staging

  • 파일 처리율: 처리된 raw 파일 / 전체 raw 파일
  • 도메인 성공률: 성공한 도메인 수집 / 전체 도메인
  • 평균 처리 시간: 파일당/도메인당 평균 처리 시간
  • robots.txt 수집률: robots.txt 존재 도메인 비율
  • sitemap.xml 수집률: sitemap.xml 존재 도메인 비율
// Seed Engine 처리 상태
{
"processing_stats": {
"files_total": 150,
"files_processed": 142,
"files_failed": 3,
"files_pending": 5,
"domains_total": 1250,
"domains_processed": 1180,
"domains_failed": 25,
"domains_pending": 45
},
"collection_stats": {
"robots_txt_found": 1050,
"robots_txt_rate": 0.84,
"sitemap_xml_found": 890,
"sitemap_xml_rate": 0.71,
"avg_processing_time_ms": 1250
}
}
  • 파일 처리 실패율 > 5%
  • 도메인 수집 실패율 > 10%
  • 평균 처리 시간 > 5초
  • DLQ 메시지 누적 > 100개

// Raw 데이터셋 파일 경로
export function buildRawDatasetPath(
country: CountryCode,
category: ContentCategory,
date: string,
chunkIndex: number
): string {
const chunkPadded = String(chunkIndex).padStart(4, '0');
return `raw/country=${country.toLowerCase()}/category=${category}/date=${date}/raw_${chunkPadded}.json`;
}
// Raw 데이터셋 prefix (목록 조회용)
export function buildRawDatasetPrefix(
country: string,
category: string,
date: string
): string {
return `raw/country=${country.toLowerCase()}/category=${category}/date=${date}/`;
}
// 도메인 결과 디렉토리
export function buildDomainResultPath(
country: string,
category: string,
date: string,
registrableDomain: string
): string {
const safeDomain = sanitizeDomain(registrableDomain);
return `prod/country=${country.toLowerCase()}/category=${category}/date=${date}/${safeDomain}`;
}
// 도메인 메타데이터 파일
export function buildDomainMetadataPath(
country: string,
category: string,
date: string,
registrableDomain: string
): string {
const basePath = buildDomainResultPath(country, category, date, registrableDomain);
return `${basePath}/domain_metadata.json`;
}
// robots.txt 파일
export function buildRobotsTxtPath(
country: string,
category: string,
date: string,
registrableDomain: string
): string {
const basePath = buildDomainResultPath(country, category, date, registrableDomain);
return `${basePath}/robots.txt`;
}
// sitemap.xml 파일
export function buildSitemapXmlPath(
country: string,
category: string,
date: string,
registrableDomain: string
): string {
const basePath = buildDomainResultPath(country, category, date, registrableDomain);
return `${basePath}/sitemap.xml`;
}

enum ProcessingErrorType {
NETWORK_ERROR = 'network_error', // 네트워크 연결 실패
TIMEOUT_ERROR = 'timeout_error', // 요청 타임아웃
PARSE_ERROR = 'parse_error', // JSON/XML 파싱 실패
STORAGE_ERROR = 'storage_error', // R2 스토리지 에러
VALIDATION_ERROR = 'validation_error' // 데이터 검증 실패
}
// 지수 백오프 재시도
async function retryWithBackoff<T>(
operation: () => Promise<T>,
maxRetries: number = 3
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries) break;
// 재시도 가능한 에러인지 확인
if (!isRetryableError(error)) {
throw error;
}
// 지수 백오프: 1s, 2s, 4s
const delay = Math.pow(2, attempt) * 1000;
await sleep(delay);
}
}
throw lastError;
}
function isRetryableError(error: any): boolean {
// 네트워크 에러, 타임아웃, 5xx 서버 에러는 재시도
return error.name === 'NetworkError' ||
error.name === 'TimeoutError' ||
(error.status >= 500 && error.status < 600);
}
// DLQ 메시지 구조
interface DLQMessage {
original_message: any;
error_info: {
error_type: ProcessingErrorType;
error_message: string;
retry_count: number;
failed_at: string;
};
context: {
queue_name: string;
worker_version: string;
processing_duration_ms: number;
};
}

Seed 엔진은 Research 엔진과 Scraper 엔진 사이의 핵심 브리지 역할을 수행합니다.

  • 기술적 준비: 실제 수집에 필요한 모든 메타데이터 준비
  • 도메인 중심: URL이 아닌 도메인 단위로 처리하여 효율성 극대화
  • 안정성: 체크포인트 시스템을 통한 완전한 idempotency 보장
  • 확장성: 파일/도메인별 독립적 병렬 처리
  • 명확한 분리: 입력(raw)과 출력(prod)의 명확한 구분
  • 재처리 용이성: Raw 데이터 보존으로 언제든 재처리 가능
  • 점진적 처리: 단계별 처리로 디버깅과 모니터링 용이

이를 통해 Scraper 엔진이 안정적이고 효율적으로 실제 콘텐츠를 수집할 수 있는 완벽한 기반을 제공합니다.