Seed 엔진 상세 설명
This content is not available in your language yet.
🌱 Seed 엔진 상세 설명
섹션 제목: “🌱 Seed 엔진 상세 설명”🎯 핵심 철학
섹션 제목: “🎯 핵심 철학”“Seed Engine defines HOW to fetch.”
Seed 엔진은 Research 엔진이 발견한 URL들을 기반으로 **수집 계약(Contract)**을 생성하는 시스템입니다. 실제 콘텐츠를 수집하지는 않지만, 수집에 필요한 모든 기술적 메타데이터를 준비합니다.
📁 Raw/Prod 2단계 데이터 모델
섹션 제목: “📁 Raw/Prod 2단계 데이터 모델”🗂️ 디렉토리 구조
섹션 제목: “🗂️ 디렉토리 구조”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 Stage 데이터 (입력)
섹션 제목: “🎯 Raw Stage 데이터 (입력)”// 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 Stage 데이터 (출력)
섹션 제목: “🎯 Prod Stage 데이터 (출력)”// 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" } }}🔧 3단계 워크플로우
섹션 제목: “🔧 3단계 워크플로우”📊 전체 처리 흐름
섹션 제목: “📊 전체 처리 흐름”[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로 파일 경로 전송
🔌 API 엔드포인트
섹션 제목: “🔌 API 엔드포인트”POST /api/v1/seeds/orchestrate📊 요청/응답 스키마
섹션 제목: “📊 요청/응답 스키마”// Requestinterface OrchestratorRequest { country?: string; // 선택적 필터 category?: string; // 선택적 필터 date?: string; // 선택적 필터 (YYYY-MM-DD) force?: boolean; // .success 파일 무시하고 재처리}
// Responseinterface 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 } };}🔄 Step 2: File Processor (SEED_QUEUE)
섹션 제목: “🔄 Step 2: File Processor (SEED_QUEUE)”🎯 역할 및 책임
섹션 제목: “🎯 역할 및 책임”- Raw 파일 다운로드 및 JSON 파싱
- URL들을 registrable_domain별로 그룹화
- DOMAIN_QUEUE로 도메인 그룹 전송
- 처리 완료 시 .success 체크포인트 생성
📊 Queue 메시지 스키마
섹션 제목: “📊 Queue 메시지 스키마”// 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);}⚙️ 설정 및 환경
섹션 제목: “⚙️ 설정 및 환경”🔧 Queue 설정 (wrangler.jsonc)
섹션 제목: “🔧 Queue 설정 (wrangler.jsonc)”{ "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초)
🌍 환경 변수
섹션 제목: “🌍 환경 변수”# R2 스토리지DATASETS_BUCKET=newsfork-datasets-staging
# Queue 바인딩SEED_QUEUE=newsfork-seed-stagingDOMAIN_QUEUE=newsfork-domain-staging
# 메타데이터 DBMETADATA_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 Stage 경로
섹션 제목: “📁 Raw Stage 경로”// 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}/`;}📁 Prod Stage 경로
섹션 제목: “📁 Prod Stage 경로”// 도메인 결과 디렉토리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 처리
섹션 제목: “🔔 DLQ 처리”// 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/Prod 모델의 장점”- 명확한 분리: 입력(raw)과 출력(prod)의 명확한 구분
- 재처리 용이성: Raw 데이터 보존으로 언제든 재처리 가능
- 점진적 처리: 단계별 처리로 디버깅과 모니터링 용이
이를 통해 Scraper 엔진이 안정적이고 효율적으로 실제 콘텐츠를 수집할 수 있는 완벽한 기반을 제공합니다.