Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@ go.work.sum

# Editor/IDE
# .idea/
# .vscode/
.vscode/

# Claude session state (loop auto-stop counter — local only)
.claude/loop-state.json

# Debug logs (local-only)
debug.log
debug_ext.log
debug_*.log

# Claude scheduler lock (session-local)
.claude/scheduled_tasks.lock
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ require (
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
Expand Down
78 changes: 78 additions & 0 deletions internal/crawler/parser/parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Package parser 은 모든 웹페이지 (뉴스 / 블로그 / 일반 문서) 의 핵심 내용을
// 추출하기 위한 도메인 중립 인터페이스와 모델을 제공합니다 (이슈 #100).
//
// Package parser defines domain-agnostic interfaces and models for extracting
// the main content of any web page. 사이트별 hardcode 파서를 대체하여, DB 기반 rule
// (storage.ParsingRuleRecord) 만 다른 단일 engine 이 모든 웹페이지를 처리합니다.
//
// 두 핵심 인터페이스:
// - ContentParser : 단일 웹페이지 → Page (핵심 본문 + 메타데이터)
// - LinkListParser : 카테고리/목록/링크-허브 페이지 → []LinkItem
//
// 뉴스 도메인의 NewsArticleParser/NewsListParser 는 본 인터페이스의 도메인 어댑터로
// 표현 가능합니다 (Page → NewsArticle 변환은 호출자 책임).
package parser

import (
"context"
"time"

"issuetracker/internal/crawler/core"
)

// Page 는 임의 웹페이지에서 추출한 핵심 내용입니다.
//
// Page represents the extracted main content of a web page (article, blog post,
// product page, etc). 모든 필드는 optional 이며 (URL/Title/MainContent 외에는 빈 값
// 허용), 사이트의 rule selectors 가 비어있으면 그 필드는 zero 값으로 남습니다.
//
// 뉴스 도메인 사용 시 호출자가 NewsArticle 로 변환:
//
// news.NewsArticle{
// Title: page.Title,
// Body: page.MainContent,
// PublishedAt: page.PublishedAt,
// ...
// }
type Page struct {
URL string
Title string
MainContent string // 페이지 핵심 본문 (article body, blog post, product description 등)
Summary string // optional — meta description 또는 별도 요약 영역
Author string // optional — 게시자/저자 (기사 / 블로그 등)
PublishedAt time.Time // optional — zero 면 미추출
Language string // optional — html lang 또는 메타 (ISO 639-1)
Category string // optional — 카테고리/섹션 (블로그 카테고리, 제품 카테고리 등)
Tags []string
Images []string // optional — page 내 핵심 이미지 URL
Metadata map[string]string // 확장 — canonical_url / og:* / twitter:* 등 임의 메타
}

// LinkItem 은 목록/링크-허브 페이지에서 추출한 단일 링크입니다.
//
// LinkItem represents a single link extracted from a list/category/hub page.
// URL 은 항상 절대 URL 로 정규화되어야 합니다 (LinkListParser 구현체가 base URL 기준 변환).
type LinkItem struct {
URL string
Title string // anchor text 또는 추출한 제목
Snippet string // optional — 짧은 요약/설명 (있을 때)
}

// ContentParser 는 웹페이지의 RawContent 를 Page 로 파싱하는 인터페이스입니다.
//
// ContentParser parses a single web page's RawContent into a Page.
// 구현체는 goroutine-safe 해야 합니다.
//
// ctx 는 호출자의 cancellation / timeout / trace metadata 전파에 사용됩니다.
// rule resolver lookup 등 I/O 가 수반되므로 ctx 를 인터페이스에 명시 (Go 컨벤션).
type ContentParser interface {
ParsePage(ctx context.Context, raw *core.RawContent) (*Page, error)
}

// LinkListParser 는 목록/링크-허브 페이지에서 LinkItem 들을 추출하는 인터페이스입니다.
//
// LinkListParser extracts LinkItem entries from a list/category page.
// 구현체는 goroutine-safe 해야 합니다.
type LinkListParser interface {
ParseLinks(ctx context.Context, raw *core.RawContent) ([]LinkItem, error)
}
80 changes: 80 additions & 0 deletions internal/crawler/parser/rule/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package rule

import "fmt"

// ErrorCode 는 rule 패키지의 정규화된 에러 분류입니다.
//
// ErrorCode classifies failures from Resolver / Parser. 호출자는 errors.As 로 *Error 를
// 추출해 Code 로 분기합니다 (예: ErrNoRule → LLM 자동 생성 fallback).
type ErrorCode string

const (
// ErrInvalidURL: URL parse 실패 / host 미존재. 호출자가 입력 검증 책임.
ErrInvalidURL ErrorCode = "invalid_url"

// ErrNoRule: host + target_type 매칭 활성 rule 없음.
// 향후 LLM 자동 생성 fallback 진입점 — 호출자가 errors.Is 로 분기 가능.
ErrNoRule ErrorCode = "no_rule"

// ErrEmptySelector: rule 의 selector 가 핵심 필드에 대해 비어있음.
// 예: article 인데 Title selector 없음 → 무의미한 결과 회피 위해 명시 실패.
ErrEmptySelector ErrorCode = "empty_selector"

// ErrParseFailure: HTML 파싱 / selector 매칭 실패 (필드 0건 추출 등).
ErrParseFailure ErrorCode = "parse_failure"
)

// Error 는 rule 패키지의 공통 에러 타입입니다.
type Error struct {
Code ErrorCode
Message string
Host string // 진단용 (resolver) — 비어있을 수 있음
URL string // 진단용 (resolver) — 비어있을 수 있음
TargetType string // 진단용 — 비어있을 수 있음
Err error // wrap 된 원본
}

func (e *Error) Error() string {
parts := fmt.Sprintf("[rule:%s] %s", e.Code, e.Message)
if e.Host != "" {
parts += fmt.Sprintf(" (host=%s)", e.Host)
}
if e.URL != "" {
parts += fmt.Sprintf(" (url=%s)", e.URL)
}
if e.TargetType != "" {
parts += fmt.Sprintf(" (type=%s)", e.TargetType)
}
if e.Err != nil {
parts += fmt.Sprintf(": %v", e.Err)
}
return parts
}

// Unwrap 은 errors.As / errors.Is 가 wrap chain 을 따라가도록 합니다.
func (e *Error) Unwrap() error { return e.Err }

// Is 는 errors.Is 호환 비교입니다 (Gemini code review 피드백 반영).
//
// target 의 비어있지 않은 모든 필드에 대해 AND 비교를 수행 — 호출자가 부분 매칭으로
// 분기 가능 ("Code=='no_rule' 인 모든 Error" / "Host=='naver.com' 인 ErrParseFailure" 등).
// target 의 모든 식별 필드가 비어있으면 모든 Error 와 매칭됨 (errors.Is 의 일반적 의미).
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
if !ok {
return false
}
if t.Code != "" && e.Code != t.Code {
return false
}
if t.Host != "" && e.Host != t.Host {
return false
}
if t.URL != "" && e.URL != t.URL {
return false
}
if t.TargetType != "" && e.TargetType != t.TargetType {
return false
}
return true
}
Comment thread
juhy0987 marked this conversation as resolved.
Loading
Loading