콘텐츠로 이동

Research 엔진 상세 설명

“Research Engine discovers WHERE to look.”

Research 엔진은 뉴스 소스 발견에 특화된 시스템으로, URL 발견에만 집중합니다. 콘텐츠의 품질이나 유효성을 판단하지 않고, 단순히 “관련성이 있을 수 있는” URL들을 발견하여 불변 데이터셋으로 저장합니다.


research/
├── datasets/ # 발견된 URL 데이터셋 (불변 스냅샷)
│ └── country=sg/
│ └── category=news/
│ ├── 2026-01-24_0001.json
│ ├── 2026-01-25_0001.json
│ └── 2026-01-25_summary.json
├── liveness/ # 도메인 생존 확인 결과
│ └── country=sg/
│ ├── 2026-01-24.json
│ └── 2026-01-25.json
├── blocked/ # 차단된 도메인 (403, captcha, rate limit)
│ └── country=sg/
│ └── 2026-01-24.json
├── dead/ # 죽은 도메인 (DNS 실패, 연결 불가)
│ └── country=sg/
│ └── 2026-01-24.json
└── processing/ # 체크포인트 및 중간 처리 데이터
└── checkpoints/
└── country=sg/category=news/
└── research/checkpoint.json
  • Primary Storage: Cloudflare R2 (DATASETS_BUCKET)
  • Metadata Storage: Cloudflare D1 (배치 상태, 통계)
  • Audit Trail: GitHub (메타데이터 동기화)

📊 경로 규칙 (Hive-style Partitioning)

섹션 제목: “📊 경로 규칙 (Hive-style Partitioning)”
research/datasets/country={cc}/category={cat}/{date}_{chunk}.json
research/liveness/country={cc}/{date}.json
research/blocked/country={cc}/{date}.json
research/dead/country={cc}/{date}.json

이 형식은 다음 도구들과 호환됩니다:

  • BigQuery
  • Delta Lake
  • AWS Athena
  • Cloudflare R2

{
"meta": {
"dataset_id": "sg-news-2026-01-25-0001",
"country": "SG",
"category": "news",
"discovered_at": "2026-01-25T03:12:00Z",
"research_methods": ["google_search", "crtsh", "wayback_machine"],
"queries": [
"Singapore government news site:.gov.sg",
"Singapore ministry news 2026"
],
"engine": {
"name": "research-engine",
"version": "1.0.0"
},
"record_count": 8,
"chunk_info": {
"chunk_index": 1,
"total_chunks": 3,
"chunk_size": 100
}
},
"records": [
{
"raw_url": "https://www.mom.gov.sg/newsroom",
"normalized_domain": "mom.gov.sg",
"domain_id": "gov:sg:mom.gov.sg",
"registrable_domain": "mom.gov.sg",
"subdomain": "www",
"source_type": "gov",
"discovery_method": "google_search",
"discovery_query": "Singapore government news site:.gov.sg",
"confidence": 0.95,
"content_hints": ["news", "government_content"],
"discovered_at": "2026-01-25T03:12:15Z",
"metadata": {
"title": "Newsroom - Ministry of Manpower",
"description": "Latest news and updates from MOM",
"language": "en"
}
}
]
}
{
"meta": {
"country": "SG",
"check_date": "2026-01-25",
"total_domains": 150,
"alive_count": 142,
"dead_count": 5,
"blocked_count": 3
},
"results": [
{
"domain_id": "gov:sg:mom.gov.sg",
"domain": "mom.gov.sg",
"status": "alive",
"http_status": 200,
"response_time_ms": 245,
"last_check": "2026-01-25T10:30:00Z",
"ssl_valid": true,
"redirect_chain": [
"https://mom.gov.sg",
"https://www.mom.gov.sg"
]
}
]
}

  • Google Search API: 구조화된 검색 쿼리 실행
  • Certificate Transparency (crt.sh): SSL 인증서 기반 도메인 발견
  • Wayback Machine: 과거 스냅샷에서 URL 추출
  • DNS 열거: 서브도메인 브루트포스 (선택적)
// 예시 정규화 과정
"https://www.mom.gov.sg/newsroom/press-releases"
{
raw_url: "https://www.mom.gov.sg/newsroom/press-releases",
normalized_domain: "mom.gov.sg",
registrable_domain: "mom.gov.sg",
subdomain: "www",
domain_id: "gov:sg:mom.gov.sg"
}
// 정부 도메인: gov:{country}:{domain}
"mom.gov.sg""gov:sg:mom.gov.sg"
// 일반 조직: org:{country}:{domain}
"redcross.org.sg""org:sg:redcross.org.sg"
// 기업: com:{country}:{domain}
"dbs.com.sg""com:sg:dbs.com.sg"
  • Phase 1-A: 기본 HTTP 응답 확인
  • SSL 인증서 검증: 유효한 HTTPS 설정 확인
  • 리다이렉트 체인 추적: 최종 도착 URL 기록
  • 응답 시간 측정: 성능 메트릭 수집
  • 청킹: 대용량 결과를 100-1000개 단위로 분할
  • 메타데이터 생성: 각 청크별 메타데이터 첨부
  • 체크섬: 데이터 무결성 보장
  • 타임스탬프: 정확한 발견 시점 기록
  • 콘텐츠 타입 분석: RSS/HTML/API 구분하지 않음
  • 콘텐츠 품질 평가: 뉴스 품질이나 신뢰도 판단 안함
  • 메타데이터 추출: 상세한 페이지 메타데이터 수집 안함
  • Seed 계약 생성: 수집 방법 정의하지 않음
  • 실제 콘텐츠 수집: 페이지 내용 다운로드 안함

[API Request] POST /api/v1/queues/research
├── Request 검증 (country, category, urls)
├── 배치 ID 생성 (batch_uuid)
└── D1에 배치 메타데이터 저장
[Queue Message Creation]
├── URL 그룹을 청크 단위로 분할
├── 각 청크별 Queue 메시지 생성
└── RESEARCH_QUEUE로 메시지 전송
[Queue Consumer Processing]
├── 메시지 배치 처리 (max_batch_size: 10)
├── 각 URL에 대해 발견 로직 실행
└── 병렬 처리 (동시성 제어)
[Domain Functions Execution]
├── discoverUrlsFromSource(input)
├── normalizeDiscoveredUrls(urls)
├── generateDomainIds(domains)
└── createResearchOutput(results)
[Storage Operations]
├── R2에 데이터셋 저장
├── D1에 메타데이터 업데이트
├── 배치 상태 갱신
└── GitHub 동기화 (선택적)
// POST /api/v1/queues/research
{
"country": "SG",
"category": "news",
"urls": ["https://example.com", "https://test.com"],
"chunk_size": 100,
"research_methods": ["google_search", "crtsh"]
}
// 생성되는 배치 메타데이터
{
batch_id: "batch_2026-01-25_sg-news_001",
country: "SG",
category: "news",
total_urls: 250,
chunk_size: 100,
total_chunks: 3,
status: "queued",
created_at: "2026-01-25T10:00:00Z"
}
// RESEARCH_QUEUE 메시지
{
batch_id: "batch_2026-01-25_sg-news_001",
chunk_index: 1,
urls: ["url1", "url2", ...], // 최대 100개
research_config: {
methods: ["google_search", "crtsh"],
country: "SG",
category: "news"
}
}
export async function handleResearchQueue(
batch: MessageBatch,
env: Env
): Promise<void> {
for (const message of batch.messages) {
const { batch_id, chunk_index, urls, research_config } = message.body;
try {
// 1. URL 발견 실행
const discoveredUrls = await discoverUrlsFromSource({
urls,
methods: research_config.methods,
country: research_config.country
});
// 2. 도메인 정규화
const normalizedResults = await normalizeDiscoveredUrls(discoveredUrls);
// 3. Research 출력 생성
const researchOutput = await createResearchOutput({
batch_id,
chunk_index,
results: normalizedResults,
config: research_config
});
// 4. R2에 저장
const datasetPath = buildR2DatasetPath(
research_config.country,
research_config.category,
getCurrentDate(),
chunk_index
);
await env.DATASETS_BUCKET.put(
datasetPath,
JSON.stringify(researchOutput)
);
// 5. D1 메타데이터 업데이트
await updateBatchProgress(env.METADATA_DB, batch_id, chunk_index);
} catch (error) {
// 에러 처리 및 DLQ 전송
console.error(`Research processing failed: ${error.message}`);
throw error; // Queue가 자동으로 재시도/DLQ 처리
}
}
}

MethodEndpointDescriptionParameters
GET/api/v1/researchResearch 출력 목록 조회country, category, limit, offset
GET/api/v1/research/indexResearch 인덱스 조회-
GET/api/v1/research/:country/:category/:date특정 Research 조회country, category, date
GET/api/v1/research/:country/:category/today오늘의 Research 조회country, category
POST/api/v1/researchResearch 출력 생성Request Body
MethodEndpointDescriptionParameters
POST/api/v1/queues/researchResearch 배치 생성Request Body
GET/api/v1/queues/batch/:batchId배치 상태 조회batchId
POST/api/v1/queues/liveness생존성 확인 배치 생성Request Body
Terminal window
curl -X POST https://api.newsfork.com/api/v1/queues/research \
-H "Content-Type: application/json" \
-d '{
"country": "SG",
"category": "news",
"urls": [
"https://www.gov.sg",
"https://www.moh.gov.sg"
],
"chunk_size": 100,
"research_methods": ["google_search", "crtsh"]
}'
# Response
{
"success": true,
"data": {
"batch_id": "batch_2026-01-25_sg-news_001",
"total_chunks": 1,
"estimated_completion": "2026-01-25T10:15:00Z"
}
}
Terminal window
curl "https://api.newsfork.com/api/v1/research/SG/news/2026-01-25"
# Response
{
"success": true,
"data": {
"datasets": [
{
"dataset_id": "sg-news-2026-01-25-0001",
"path": "research/datasets/country=sg/category=news/2026-01-25_0001.json",
"record_count": 85,
"size_bytes": 45120,
"created_at": "2026-01-25T10:12:00Z"
}
],
"summary": {
"total_records": 85,
"unique_domains": 23,
"discovery_methods": ["google_search", "crtsh"]
}
}
}

{
"queues": {
"consumers": [
{
"queue": "newsfork-research-staging",
"max_batch_size": 10,
"max_batch_timeout": 30,
"max_retries": 3,
"dead_letter_queue": "newsfork-dlq-staging"
}
]
}
}
Development: dev/research/datasets/...
Staging: staging/research/datasets/...
Production: prod/research/datasets/...
  • 배치 성공률: 완료된 배치 / 전체 배치
  • 발견 URL 수: 시간당 발견된 고유 URL 수
  • 도메인 생존율: 살아있는 도메인 / 전체 도메인
  • 처리 지연시간: Queue 메시지 처리 평균 시간
  • 에러율: 실패한 메시지 / 전체 메시지

Research 엔진은 기본적인 생존성 확인만 수행합니다:

async function checkDomainLiveness(domain: string): Promise<LivenessResult> {
try {
const response = await fetch(`https://${domain}`, {
method: 'HEAD',
timeout: 10000,
redirect: 'follow'
});
return {
domain,
status: response.ok ? 'alive' : 'error',
http_status: response.status,
response_time_ms: Date.now() - startTime,
ssl_valid: response.url.startsWith('https://'),
redirect_chain: getRedirectChain(response),
last_check: new Date().toISOString()
};
} catch (error) {
return {
domain,
status: 'dead',
error: error.message,
last_check: new Date().toISOString()
};
}
}
  • alive: HTTP 200-299 응답
  • dead: DNS 실패, 연결 불가, 타임아웃
  • blocked: 403, 429, captcha 감지
  • redirect: 영구적 리다이렉트 (301, 308)

// Domain Layer (순수 비즈니스 로직)
export function discoverUrlsFromSource(input: DiscoverUrlsInput): DiscoverUrlsOutput
export function createResearchOutput(...): ResearchOutput
export function generateDatasetId(...): string
export function createDatasetPath(...): string
// Service Layer (도메인 + 인프라 오케스트레이션)
export class ResearchService {
async list(params: ResearchListParams): Promise<ResearchListResult>
async get(country: string, category: string, date: string): Promise<ResearchOutput>
async create(request: CreateResearchRequest): Promise<ResearchOutput>
async createBatch(request: CreateBatchRequest): Promise<BatchResult>
}
// Infrastructure Layer (Cloudflare 어댑터)
export class R2StorageAdapter {
async storeDataset(path: string, data: ResearchOutput): Promise<void>
async getDataset(path: string): Promise<ResearchOutput>
async listDatasets(prefix: string): Promise<DatasetInfo[]>
}
// 서비스 생성 시 인프라 어댑터 주입
const researchService = new ResearchService({
r2Storage: new R2StorageAdapter(env.DATASETS_BUCKET),
d1Database: new D1Adapter(env.METADATA_DB),
githubStorage: new GitHubStorageAdapter(env.GITHUB_TOKEN)
});

  • 배치 레벨: 여러 배치 동시 처리
  • 청크 레벨: 배치 내 청크 병렬 처리
  • URL 레벨: 청크 내 URL 동시 발견
// 적응형 동시성 제어
let concurrency = 10;
const errorRate = errors / totalRequests;
if (errorRate > 0.05) {
concurrency = Math.max(5, concurrency * 0.8);
} else if (errorRate < 0.01) {
concurrency = Math.min(50, concurrency * 1.2);
}
  • 지수 백오프: 1s → 2s → 4s → 8s
  • Circuit Breaker: 연속 실패 시 일시 중단
  • DLQ 처리: 최대 재시도 후 수동 검토 큐로 이동

Research 엔진은 Newsfork 파이프라인의 첫 번째 단계로서, URL 발견이라는 명확한 책임을 가집니다.

  • 단순성: 발견에만 집중, 판단하지 않음
  • 확장성: 국가/카테고리별 독립적 확장
  • 신뢰성: 불변 데이터셋과 체크포인트 시스템
  • 추적성: 완전한 audit trail과 메타데이터

이를 통해 Seed 엔진이 안정적인 입력 데이터를 받아 수집 계약을 생성할 수 있는 기반을 제공합니다.