First paragraph of the body.
+Second paragraph with more detail.
+diff --git a/.gitignore b/.gitignore index 7095842d..31cc72d2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/go.mod b/go.mod index c54f32d7..6d8e00ec 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 10b32a13..7cf5fd18 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/crawler/parser/parser.go b/internal/crawler/parser/parser.go new file mode 100644 index 00000000..f96bdf12 --- /dev/null +++ b/internal/crawler/parser/parser.go @@ -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) +} diff --git a/internal/crawler/parser/rule/errors.go b/internal/crawler/parser/rule/errors.go new file mode 100644 index 00000000..e541d547 --- /dev/null +++ b/internal/crawler/parser/rule/errors.go @@ -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 +} diff --git a/internal/crawler/parser/rule/parser.go b/internal/crawler/parser/rule/parser.go new file mode 100644 index 00000000..85b563f5 --- /dev/null +++ b/internal/crawler/parser/rule/parser.go @@ -0,0 +1,330 @@ +package rule + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + + "issuetracker/internal/crawler/core" + "issuetracker/internal/crawler/parser" + "issuetracker/internal/storage" +) + +// resolveTimeout 은 호출자 ctx 가 deadline 없을 때 추가 안전망입니다. +// Resolver 의 Redis/cache 핫패스가 막혀도 호출 worker 가 영원히 block 되지 않도록 5초. +// +// ctx 에 이미 더 짧은 deadline 이 있으면 그것이 우선 (context.WithTimeout 이 합성). +const resolveTimeout = 5 * time.Second + +// Parser 는 DB 기반 파싱 규칙으로 동작하는 단일 page parser engine 입니다 (이슈 #100). +// +// Parser implements both parser.ContentParser and parser.LinkListParser, driven by +// storage.ParsingRuleRecord resolved per request via Resolver. 사이트별 hardcode 파서 +// (NaverParser/DaumParser/...) 를 대체 — 새 사이트 지원 = parsing_rules row 추가. +// +// 도메인 중립 — 뉴스 / 블로그 / 제품 페이지 / 일반 문서 모두 동일 engine 으로 처리. +// 호출자가 도메인-specific 모델로 변환 (예: Page → news.NewsArticle) 하면 됨. +// +// stateless / goroutine-safe — 모든 worker 가 단일 인스턴스 공유 가능. +type Parser struct { + resolver *Resolver + dateLayouts []string // PublishedAt try-list (앞쪽 우선) +} + +// NewParser 는 Resolver 를 사용하는 Parser 를 생성합니다. +// resolver 가 nil 이면 panic — wire 누락 즉시 가시화. +func NewParser(resolver *Resolver) *Parser { + if resolver == nil { + panic("rule: NewParser requires non-nil resolver") + } + return &Parser{ + resolver: resolver, + dateLayouts: defaultDateLayouts(), + } +} + +// defaultDateLayouts 는 PublishedAt 추출 시 시도할 layout 목록입니다. +// 사이트별 차이 (RFC3339 / Korean / ISO 8601 etc) 를 일반화. 운영 중 새 형식 발견 시 확장. +func defaultDateLayouts() []string { + return []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05Z07:00", + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006.01.02 15:04", + "2006.01.02. 15:04", + "2006.01.02", + "2006/01/02 15:04:05", + } +} + +// ParsePage 는 RawContent 를 DB rule 기반으로 Page 로 파싱합니다 (parser.ContentParser 구현). +// +// 흐름: +// 1. raw.URL 의 host 로 active page rule lookup (Resolver — cache hit 핫패스) +// 2. rule.Selectors 에 따라 각 필드 (Title/MainContent/Author/PublishedAt/...) 추출 +// 3. Title selector 누락 → ErrEmptySelector (필수 필드) +// 4. MainContent 매칭 0건 → ErrParseFailure (selector 는 있지만 매칭 0건 = stale rule 진단) +func (p *Parser) ParsePage(ctx context.Context, raw *core.RawContent) (*parser.Page, error) { + if err := validateRaw(raw); err != nil { + return nil, err + } + + // 호출자 ctx 의 cancel/trace metadata 를 보존하면서 추가 timeout 안전망 적용 (Gemini #3). + resolveCtx, cancel := context.WithTimeout(ctx, resolveTimeout) + defer cancel() + rule, err := p.resolver.ResolveByURL(resolveCtx, raw.URL, storage.TargetTypePage) + if err != nil { + return nil, err + } + + // Title + MainContent 둘 다 필수 — nil 또는 CSS 빈 문자열 모두 ErrEmptySelector 로 분류 + // (Coderabbit 피드백: nil 만 검사하면 zero-value selector 가 ErrParseFailure 로 잘못 분류됨) + if !hasRequiredSelector(rule.Selectors.Title) || !hasRequiredSelector(rule.Selectors.MainContent) { + return nil, &Error{ + Code: ErrEmptySelector, + Message: "page rule missing required Title or MainContent selector", + URL: raw.URL, + TargetType: string(storage.TargetTypePage), + } + } + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(raw.HTML)) + if err != nil { + return nil, &Error{Code: ErrParseFailure, Message: "goquery parse failed", URL: raw.URL, Err: err} + } + + page := &parser.Page{ + URL: raw.URL, + Title: extractField(doc, rule.Selectors.Title), + MainContent: extractField(doc, rule.Selectors.MainContent), + Summary: extractField(doc, rule.Selectors.Summary), + Author: extractField(doc, rule.Selectors.Author), + Category: extractField(doc, rule.Selectors.Category), + Tags: extractFieldMulti(doc, rule.Selectors.Tags), + Images: extractFieldMulti(doc, rule.Selectors.Images), + PublishedAt: p.extractDate(doc, rule.Selectors.PublishedAt), + } + + // Title 도 MainContent 와 동등한 필수 — selector 는 있지만 추출 결과 빈 경우도 stale 진단 (Gemini #5). + if page.Title == "" || page.MainContent == "" { + return nil, &Error{ + Code: ErrParseFailure, + Message: "Title or MainContent selector matched 0 elements (rule may be stale)", + URL: raw.URL, + TargetType: string(storage.TargetTypePage), + } + } + return page, nil +} + +// ParseLinks 는 RawContent 의 링크-허브 페이지를 LinkItem 슬라이스로 파싱합니다 +// (parser.LinkListParser 구현). +// +// 흐름: +// 1. raw.URL 의 host 로 active list rule lookup +// 2. rule.ItemContainer selector 로 각 item element 순회 +// 3. 각 item 안에서 ItemLink (href) / ItemTitle / ItemSnippet 추출 +// 4. 상대 URL 은 raw.URL base 로 절대 URL 화 +func (p *Parser) ParseLinks(ctx context.Context, raw *core.RawContent) ([]parser.LinkItem, error) { + if err := validateRaw(raw); err != nil { + return nil, err + } + + // 호출자 ctx 의 cancel/trace metadata 를 보존하면서 추가 timeout 안전망 적용. + resolveCtx, cancel := context.WithTimeout(ctx, resolveTimeout) + defer cancel() + rule, err := p.resolver.ResolveByURL(resolveCtx, raw.URL, storage.TargetTypeList) + if err != nil { + return nil, err + } + + // ItemContainer + ItemLink 둘 다 필수 — nil 또는 CSS 빈 문자열 모두 ErrEmptySelector + if !hasRequiredSelector(rule.Selectors.ItemContainer) || !hasRequiredSelector(rule.Selectors.ItemLink) { + return nil, &Error{ + Code: ErrEmptySelector, + Message: "list rule missing required ItemContainer or ItemLink selector", + URL: raw.URL, + TargetType: string(storage.TargetTypeList), + } + } + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(raw.HTML)) + if err != nil { + return nil, &Error{Code: ErrParseFailure, Message: "goquery parse failed", URL: raw.URL, Err: err} + } + + base, baseErr := url.Parse(raw.URL) + if baseErr != nil { + // raw.URL 이 잘못된 경우 — 상대 URL 절대화 못 하면 link 그대로 유지 + base = nil + } + + containers := doc.Find(rule.Selectors.ItemContainer.CSS) + // ItemContainer 자체가 매칭 0건 — 사이트 구조 변경으로 selector stale. + // (Coderabbit 피드백: ItemLink 모두 빈 case 와 분리하여 정확한 진단 메시지 제공) + if containers.Length() == 0 { + return nil, &Error{ + Code: ErrParseFailure, + Message: "ItemContainer selector matched 0 elements (rule may be stale)", + URL: raw.URL, + TargetType: string(storage.TargetTypeList), + } + } + + var items []parser.LinkItem + containers.Each(func(_ int, container *goquery.Selection) { + link := extractFieldFromSelection(container, rule.Selectors.ItemLink) + if link == "" { + return + } + absURL := link + if base != nil { + if abs, err := absoluteURL(base, link); err == nil { + absURL = abs + } + } + items = append(items, parser.LinkItem{ + URL: absURL, + Title: extractFieldFromSelection(container, rule.Selectors.ItemTitle), + Snippet: extractFieldFromSelection(container, rule.Selectors.ItemSnippet), + }) + }) + + // ItemContainer 는 매칭됐지만 모든 ItemLink 가 빈 결과 — ItemLink selector stale. + if len(items) == 0 { + return nil, &Error{ + Code: ErrParseFailure, + Message: "ItemContainer matched but no valid ItemLink found (ItemLink selector may be stale)", + URL: raw.URL, + TargetType: string(storage.TargetTypeList), + } + } + return items, nil +} + +// hasRequiredSelector 는 selector 가 lookup 가능한지 검사합니다 (Coderabbit 피드백). +// nil 이거나 CSS 가 trim 후 빈 문자열이면 false — DB 의 zero-value row 도 명확히 reject. +func hasRequiredSelector(fs *storage.FieldSelector) bool { + return fs != nil && strings.TrimSpace(fs.CSS) != "" +} + +// validateRaw 는 ParsePage / ParseLinks 의 공통 raw 검증입니다. +// raw 가 nil 이거나 HTML 이 비어있으면 (whitespace-only 도 빈 것으로 간주) raw.URL 진단 정보 포함 Error 반환. +// (Coderabbit 피드백: " \n" 같은 whitespace-only 가 통과해 stale-rule 오인 회피) +func validateRaw(raw *core.RawContent) error { + if raw != nil && strings.TrimSpace(raw.HTML) != "" { + return nil + } + var u string + if raw != nil { + u = raw.URL + } + return &Error{Code: ErrParseFailure, Message: "raw content empty", URL: u} +} + +// extractField 는 단일 필드를 추출합니다 (selector 가 nil 이면 빈 문자열). +// +// Multi=false (기본): 첫 매칭 element 의 값 반환. +// Multi=true: 모든 매칭 element 의 값을 줄바꿈으로 합쳐 반환 (MainContent 다중 단락 등). +func extractField(doc *goquery.Document, fs *storage.FieldSelector) string { + if fs == nil || fs.CSS == "" { + return "" + } + return extractFromSelection(doc.Selection, fs) +} + +// extractFieldFromSelection 은 sub-selection 안에서 필드를 추출합니다 (list item 내부 lookup). +func extractFieldFromSelection(s *goquery.Selection, fs *storage.FieldSelector) string { + if fs == nil || fs.CSS == "" { + return "" + } + return extractFromSelection(s, fs) +} + +// extractFromSelection 은 selection 범위에서 selector 적용 결과를 반환합니다. +func extractFromSelection(scope *goquery.Selection, fs *storage.FieldSelector) string { + matched := scope.Find(fs.CSS) + if matched.Length() == 0 { + return "" + } + if !fs.Multi { + return strings.TrimSpace(extractValue(matched.First(), fs.Attribute)) + } + var parts []string + matched.Each(func(_ int, sel *goquery.Selection) { + v := strings.TrimSpace(extractValue(sel, fs.Attribute)) + if v != "" { + parts = append(parts, v) + } + }) + return strings.Join(parts, "\n") +} + +// extractFieldMulti 는 multi 결과를 string 슬라이스로 반환합니다 (Tags / Images 용). +// +// extractField 의 multi 모드는 줄바꿈으로 합치지만, 본 함수는 각 element 를 별도 항목으로 보존. +func extractFieldMulti(doc *goquery.Document, fs *storage.FieldSelector) []string { + if fs == nil || fs.CSS == "" { + return nil + } + matched := doc.Find(fs.CSS) + if matched.Length() == 0 { + return nil + } + out := make([]string, 0, matched.Length()) + matched.Each(func(_ int, sel *goquery.Selection) { + v := strings.TrimSpace(extractValue(sel, fs.Attribute)) + if v != "" { + out = append(out, v) + } + }) + if len(out) == 0 { + return nil + } + return out +} + +// extractValue 는 element 의 text (Attribute=="") 또는 attribute 값을 반환합니다. +func extractValue(sel *goquery.Selection, attribute string) string { + if attribute == "" { + return sel.Text() + } + v, _ := sel.Attr(attribute) + return v +} + +// extractDate 는 PublishedAt 필드를 추출하고 dateLayouts 를 순회 시도합니다. +// 추출 실패 시 zero time 반환 — 호출자 (validator 등) 가 zero 검사로 분기. +func (p *Parser) extractDate(doc *goquery.Document, fs *storage.FieldSelector) time.Time { + raw := extractField(doc, fs) + if raw == "" { + return time.Time{} + } + for _, layout := range p.dateLayouts { + if t, err := time.Parse(layout, raw); err == nil { + return t + } + } + return time.Time{} +} + +// absoluteURL 은 link 를 base 기준 절대 URL 로 변환합니다. +func absoluteURL(base *url.URL, link string) (string, error) { + ref, err := url.Parse(link) + if err != nil { + return "", fmt.Errorf("parse link: %w", err) + } + return base.ResolveReference(ref).String(), nil +} + +// 컴파일 시 인터페이스 구현 검증 — 두 인터페이스 모두 만족해야 함. +var ( + _ parser.ContentParser = (*Parser)(nil) + _ parser.LinkListParser = (*Parser)(nil) +) diff --git a/internal/crawler/parser/rule/resolver.go b/internal/crawler/parser/rule/resolver.go new file mode 100644 index 00000000..bc8317e4 --- /dev/null +++ b/internal/crawler/parser/rule/resolver.go @@ -0,0 +1,223 @@ +// Package rule 은 DB 기반 파싱 규칙 (이슈 #100) 의 resolver 와 단일 parser engine 을 제공합니다. +// +// Package rule provides the URL → Rule resolver and a single rule-driven parser engine +// that implements both NewsArticleParser and NewsListParser. 사이트별 hardcode 파서를 +// 대체하여 새 사이트 지원을 코드 변경 없이 DB rule 추가만으로 가능하게 합니다. +package rule + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "sync" + "time" + + "issuetracker/internal/storage" +) + +// DefaultCacheTTL 은 Resolver 의 기본 양성 캐시 TTL 입니다. +// 너무 길면 운영자가 새 rule enabled 후에도 한참 미반영, 너무 짧으면 DB 부하. +const DefaultCacheTTL = 5 * time.Minute + +// DefaultNegativeCacheTTL 은 미매칭 (ErrNotFound) 결과의 캐시 TTL 입니다. +// 양성보다 짧게 — 새 rule 등록 시 빠르게 반영되도록. +const DefaultNegativeCacheTTL = 30 * time.Second + +// DefaultMaxCacheEntries 는 cache 의 최대 entry 수입니다 (이슈 #100, Gemini #6). +// +// 무제한 map 은 호스트 수 폭증 시 OOM 위험. 단순 정책 — 가득 차면 가장 오래된 entry +// (만료 임박 순) 를 evict. LRU 가 아니라 expiry-order eviction 이지만 본 패키지의 +// 부하 패턴 (소수의 동일 host 반복 lookup) 에는 충분. +const DefaultMaxCacheEntries = 10_000 + +// Resolver 는 URL 에서 host 를 추출해 storage.ParsingRuleRecord 를 조회합니다 (이슈 #100). +// +// Resolver maps a URL to its active ParsingRule via host_pattern + target_type. +// In-memory cache (TTL based) 로 DB roundtrip 을 줄입니다 — 운영자는 새 rule enabled 후 +// 최대 DefaultCacheTTL 만큼 지연을 감수합니다. +// +// goroutine-safe — sync.RWMutex 로 cache 보호. +type Resolver struct { + repo storage.ParsingRuleRepository + cacheTTL time.Duration + negativeCacheTTL time.Duration + maxEntries int + + mu sync.RWMutex + cache map[cacheKey]cacheEntry + now func() time.Time // 테스트 주입 (실시각 → fake clock) +} + +// cacheKey 는 (host, target_type) 튜플입니다 — DB FindActive 인자와 1:1 매칭. +type cacheKey struct { + host string + targetType storage.TargetType +} + +// cacheEntry 는 lookup 결과를 캐싱합니다. rule==nil 이면 negative cache. +type cacheEntry struct { + rule *storage.ParsingRuleRecord + expiresAt time.Time +} + +// Option 은 Resolver 생성 옵션입니다. +type Option func(*Resolver) + +// WithCacheTTL 은 양성 lookup 결과의 cache TTL 을 override 합니다. +func WithCacheTTL(d time.Duration) Option { + return func(r *Resolver) { r.cacheTTL = d } +} + +// WithNegativeCacheTTL 은 미매칭 결과의 cache TTL 을 override 합니다. +func WithNegativeCacheTTL(d time.Duration) Option { + return func(r *Resolver) { r.negativeCacheTTL = d } +} + +// WithMaxCacheEntries 는 cache 의 최대 entry 수를 override 합니다 (default: 10_000). +// 0 이하 값은 무시되고 default 유지. +func WithMaxCacheEntries(n int) Option { + return func(r *Resolver) { + if n > 0 { + r.maxEntries = n + } + } +} + +// NewResolver 는 ParsingRuleRepository 를 사용하는 Resolver 를 생성합니다. +// repo 가 nil 이면 panic — application 시작 시점 wire 누락 즉시 가시화. +func NewResolver(repo storage.ParsingRuleRepository, opts ...Option) *Resolver { + if repo == nil { + panic("rule: NewResolver requires non-nil repo") + } + r := &Resolver{ + repo: repo, + cacheTTL: DefaultCacheTTL, + negativeCacheTTL: DefaultNegativeCacheTTL, + maxEntries: DefaultMaxCacheEntries, + cache: make(map[cacheKey]cacheEntry), + now: time.Now, + } + for _, o := range opts { + o(r) + } + return r +} + +// ResolveByURL 은 URL 에서 host 를 추출해 매칭 활성 규칙을 반환합니다. +// +// 흐름: +// 1. URL parse 실패 → ErrInvalidURL +// 2. cache hit (양성) → 즉시 반환 +// 3. cache hit (negative, 미만료) → ErrNoRule (DB roundtrip 회피) +// 4. cache miss → repo.FindActive → 결과를 cache 후 반환 +// +// 매칭 없음은 storage.ErrNotFound 가 아닌 rule.ErrNoRule 로 정규화 — 호출자가 errors.Is +// 로 분기 가능 (예: LLM 자동 생성 fallback 트리거). +func (r *Resolver) ResolveByURL(ctx context.Context, rawURL string, targetType storage.TargetType) (*storage.ParsingRuleRecord, error) { + host, err := extractHost(rawURL) + if err != nil { + return nil, &Error{Code: ErrInvalidURL, Message: err.Error(), URL: rawURL} + } + return r.Resolve(ctx, host, targetType) +} + +// Resolve 는 host 를 직접 받아 매칭 활성 규칙을 반환합니다 (host 가 이미 추출된 경우). +func (r *Resolver) Resolve(ctx context.Context, host string, targetType storage.TargetType) (*storage.ParsingRuleRecord, error) { + host = strings.ToLower(host) + key := cacheKey{host: host, targetType: targetType} + + if rule, hit, negative := r.lookupCache(key); hit { + if negative { + return nil, &Error{Code: ErrNoRule, Message: "no active rule (cached)", Host: host, TargetType: string(targetType)} + } + return rule, nil + } + + rule, err := r.repo.FindActive(ctx, host, targetType) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + r.storeCache(key, nil, r.negativeCacheTTL) + return nil, &Error{Code: ErrNoRule, Message: "no active rule", Host: host, TargetType: string(targetType)} + } + return nil, fmt.Errorf("find active rule (%s, %s): %w", host, targetType, err) + } + r.storeCache(key, rule, r.cacheTTL) + return rule, nil +} + +// Invalidate 는 (host, type) 의 cache entry 를 즉시 제거합니다 — 운영자가 rule 변경 직후 호출. +func (r *Resolver) Invalidate(host string, targetType storage.TargetType) { + host = strings.ToLower(host) + r.mu.Lock() + delete(r.cache, cacheKey{host: host, targetType: targetType}) + r.mu.Unlock() +} + +// InvalidateAll 은 모든 cache 를 비웁니다. +func (r *Resolver) InvalidateAll() { + r.mu.Lock() + r.cache = make(map[cacheKey]cacheEntry) + r.mu.Unlock() +} + +// lookupCache 는 캐시 조회 결과를 반환합니다. +// 반환값: (rule, hit, negative) — hit=true 이면 캐시 적용, negative=true 이면 미매칭 캐시. +func (r *Resolver) lookupCache(key cacheKey) (*storage.ParsingRuleRecord, bool, bool) { + r.mu.RLock() + entry, ok := r.cache[key] + r.mu.RUnlock() + if !ok || r.now().After(entry.expiresAt) { + return nil, false, false + } + return entry.rule, true, entry.rule == nil +} + +// evictExpiringSoon 은 cache 가 maxEntries 초과 시 가장 만료 임박한 entry 를 제거합니다. +// 호출자는 이미 r.mu 를 hold 하고 있어야 합니다 (storeCache 안에서 호출). +// +// 단순 정책 — LRU 가 아니라 expiry-order eviction. 본 패키지의 부하 패턴 (소수의 동일 +// host 반복 lookup) 에는 충분. 호스트 폭증 시 OOM 방어가 핵심 목적. +func (r *Resolver) evictExpiringSoon() { + if len(r.cache) < r.maxEntries { + return + } + var oldestKey cacheKey + var oldestExpiry time.Time + first := true + for k, e := range r.cache { + if first || e.expiresAt.Before(oldestExpiry) { + oldestKey = k + oldestExpiry = e.expiresAt + first = false + } + } + if !first { + delete(r.cache, oldestKey) + } +} + +// storeCache 는 entry 를 저장합니다 (rule==nil 이면 negative cache). +// maxEntries 초과 시 evictExpiringSoon 으로 가장 만료 임박 entry 제거 후 저장. +func (r *Resolver) storeCache(key cacheKey, rule *storage.ParsingRuleRecord, ttl time.Duration) { + r.mu.Lock() + r.evictExpiringSoon() + r.cache[key] = cacheEntry{rule: rule, expiresAt: r.now().Add(ttl)} + r.mu.Unlock() +} + +// extractHost 는 URL 문자열에서 host (소문자) 를 추출합니다. +func extractHost(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("parse url: %w", err) + } + // u.Hostname() — 포트 부분 ":8080" 을 제거 (Gemini code review 피드백). + // DB 의 host_pattern 이 순수 호스트네임이라 포트 포함 매칭 실패 회피. + host := u.Hostname() + if host == "" { + return "", fmt.Errorf("empty host in url %q", rawURL) + } + return strings.ToLower(host), nil +} diff --git a/internal/storage/parsing_rule.go b/internal/storage/parsing_rule.go new file mode 100644 index 00000000..a9af5262 --- /dev/null +++ b/internal/storage/parsing_rule.go @@ -0,0 +1,128 @@ +package storage + +import ( + "context" + "time" +) + +// TargetType 은 파싱 규칙이 적용될 페이지 종류입니다 (이슈 #100). +// +// TargetType discriminates rules between article pages and list/category pages. +type TargetType string + +const ( + // TargetTypePage: 단일 컨텐츠 페이지 — Title/MainContent/Author 등 추출. + // 뉴스 기사 / 블로그 포스트 / 제품 페이지 / 일반 문서 모두 포함. + TargetTypePage TargetType = "page" + // TargetTypeList: 링크-허브 페이지 — 카테고리/목록/sitemap 등 LinkItem 들 추출. + TargetTypeList TargetType = "list" +) + +// 호환성 — 기존 TargetTypeArticle 명칭이 코드/DB 에 잔존할 수 있어 별칭 유지. +// Deprecated: TargetTypePage 사용 권장 (도메인 일반화). +const TargetTypeArticle = TargetTypePage + +// FieldSelector 는 단일 필드의 추출 규칙입니다. +// +// FieldSelector defines how to extract one field from HTML. +// +// - CSS: goquery selector (예: "h1.article-title", "div.author > a") +// - Attribute: 빈 문자열이면 element 의 .Text(), 그 외엔 attribute 값 +// (예: "href" / "src" / "datetime" / "content") +// - Multi: 여러 element 매칭 시 동작 +// - false: 첫 element 만 반환 (Title 등 단일 값) +// - true: 모든 element 의 결과를 합침/배열 반환 (Tags / ImageURLs / Body 의 다중 단락 등) +type FieldSelector struct { + CSS string `json:"css"` + Attribute string `json:"attribute,omitempty"` + Multi bool `json:"multi,omitempty"` +} + +// SelectorMap 은 page/list 페이지에서 추출할 모든 필드의 selector 모음입니다. +// +// SelectorMap holds selectors for every extractable field. Nil entries mean +// "field not configured" — parser 는 해당 필드를 빈 값으로 두고 계속 진행합니다. +// +// page (단일 컨텐츠 페이지) 용 필드와 list (링크-허브 페이지) 용 필드가 한 struct 에 +// 함께 정의되지만, target_type 에 따라 사용되는 부분만 채워집니다 (JSONB nil 친화). +// +// 뉴스 / 블로그 / 제품 페이지 등 임의 웹페이지의 핵심 내용 추출에 일반화 (이슈 #100): +// - Title : 페이지 제목 (h1 등) +// - MainContent : 핵심 본문 (article body / blog post / product description ...) +// - Summary : meta description 또는 별도 요약 영역 +// - Author : 게시자/저자 (있을 때) +// - PublishedAt : 게시 시각 selector (datetime attribute 권장) +// - Category : 섹션/카테고리 (뉴스 섹션 / 블로그 카테고리 / 제품 카테고리 등) +// - Tags : 태그 슬라이스 +// - Images : 핵심 이미지 URL 슬라이스 (이전 ImageURLs) +type SelectorMap struct { + // page (단일 컨텐츠 페이지) 용 + Title *FieldSelector `json:"title,omitempty"` + MainContent *FieldSelector `json:"main_content,omitempty"` // 핵심 본문 (article body / post / description 등) + Summary *FieldSelector `json:"summary,omitempty"` + Author *FieldSelector `json:"author,omitempty"` + PublishedAt *FieldSelector `json:"published_at,omitempty"` + Category *FieldSelector `json:"category,omitempty"` + Tags *FieldSelector `json:"tags,omitempty"` + Images *FieldSelector `json:"images,omitempty"` + + // list (링크-허브 페이지) 용 — 각 item 의 link/title/snippet 추출 + ItemContainer *FieldSelector `json:"item_container,omitempty"` // 각 item 의 root element selector + ItemLink *FieldSelector `json:"item_link,omitempty"` // ItemContainer 내 link selector (attribute=href 권장) + ItemTitle *FieldSelector `json:"item_title,omitempty"` + ItemSnippet *FieldSelector `json:"item_snippet,omitempty"` // 짧은 요약/설명 (있을 때) +} + +// ParsingRuleRecord 는 parsing_rules 테이블의 단일 행입니다. +// +// ParsingRuleRecord represents a single row of the parsing_rules table. +type ParsingRuleRecord struct { + ID int64 + SourceName string // "naver" / "cnn" + HostPattern string // URL host 매칭 (예: "n.news.naver.com") + TargetType TargetType // "page" | "list" + Version int // 활성 row 안에서 같은 (source, host, type) 의 최신 버전 + Enabled bool + Selectors SelectorMap // JSONB — application 측 struct 로 직렬화 + Description string + CreatedAt time.Time + UpdatedAt time.Time +} + +// ParsingRuleFilter 는 List 조회 시 필터 조건입니다. +type ParsingRuleFilter struct { + SourceName string // 빈 문자열이면 전체 + HostPattern string // 빈 문자열이면 전체 + TargetType TargetType // 빈 문자열이면 전체 + OnlyEnabled bool // true 면 enabled=true 만 + Limit int // 0 이면 기본값 (50) + Offset int +} + +// ParsingRuleRepository 는 parsing_rules 테이블에 대한 데이터 접근 인터페이스입니다. +// +// ParsingRuleRepository is the data access interface for parsing_rules. +// All implementations must be goroutine-safe. +type ParsingRuleRepository interface { + // Insert 는 새 규칙을 저장합니다. 자연키 충돌 시 ErrDuplicate 반환. + // 성공 시 r.ID 가 채워집니다. + Insert(ctx context.Context, r *ParsingRuleRecord) error + + // Update 는 ID 로 규칙을 갱신합니다. 존재하지 않으면 ErrNotFound 반환. + // 갱신 가능 필드: Selectors, Enabled, Description (자연키는 변경 불가). + Update(ctx context.Context, r *ParsingRuleRecord) error + + // GetByID 는 ID 로 규칙을 조회합니다. + GetByID(ctx context.Context, id int64) (*ParsingRuleRecord, error) + + // FindActive 는 host + target_type 에 매칭되는 활성 규칙을 반환합니다 (RuleResolver 핫패스). + // 같은 (host, type) 에 여러 활성 row 가 있다면 version DESC 순으로 첫 항목 반환. + // 매칭 없으면 ErrNotFound. + FindActive(ctx context.Context, host string, targetType TargetType) (*ParsingRuleRecord, error) + + // List 는 필터 조건에 맞는 규칙들을 반환합니다 (운영 대시보드용). + List(ctx context.Context, filter ParsingRuleFilter) ([]*ParsingRuleRecord, error) + + // Delete 는 ID 로 규칙을 삭제합니다. 존재하지 않아도 nil 반환 (idempotent). + Delete(ctx context.Context, id int64) error +} diff --git a/internal/storage/postgres/parsing_rule.go b/internal/storage/postgres/parsing_rule.go new file mode 100644 index 00000000..fff6ed7d --- /dev/null +++ b/internal/storage/postgres/parsing_rule.go @@ -0,0 +1,218 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "issuetracker/internal/storage" + "issuetracker/pkg/logger" +) + +// pgParsingRuleRepository 는 pgx/v5 기반 ParsingRuleRepository 구현체입니다 (이슈 #100). +type pgParsingRuleRepository struct { + pool *pgxpool.Pool +} + +// NewParsingRuleRepository 는 pgxpool 을 사용하는 ParsingRuleRepository 를 생성합니다. +// +// log 인자는 향후 query latency / error log 등 운영 가시성 추가를 대비해 시그니처에 유지하되, +// 현재 구현에서는 사용하지 않습니다 (Gemini code review #8 — 미사용 필드 정리). +// 다른 Repository 들 (NewContentRepository 등) 의 시그니처와 일관성을 위해 인자는 보존. +func NewParsingRuleRepository(pool *pgxpool.Pool, log *logger.Logger) storage.ParsingRuleRepository { + _ = log + return &pgParsingRuleRepository{pool: pool} +} + +const sqlInsertParsingRule = ` +INSERT INTO parsing_rules ( + source_name, host_pattern, target_type, version, enabled, selectors, description +) VALUES ( + $1, $2, $3, $4, $5, $6, $7 +) +RETURNING id, created_at, updated_at +` + +// Insert 는 새 규칙을 저장합니다. 자연키 (source_name, host_pattern, target_type, version) 충돌 시 +// storage.ErrDuplicate 를 반환합니다. 성공 시 r.ID / CreatedAt / UpdatedAt 가 채워집니다. +func (r *pgParsingRuleRepository) Insert(ctx context.Context, rec *storage.ParsingRuleRecord) error { + selectors, err := json.Marshal(rec.Selectors) + if err != nil { + return fmt.Errorf("marshal selectors: %w", err) + } + if rec.Version == 0 { + rec.Version = 1 + } + row := r.pool.QueryRow(ctx, sqlInsertParsingRule, + rec.SourceName, rec.HostPattern, string(rec.TargetType), rec.Version, + rec.Enabled, selectors, rec.Description, + ) + if err := row.Scan(&rec.ID, &rec.CreatedAt, &rec.UpdatedAt); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return storage.ErrDuplicate + } + return fmt.Errorf("insert parsing rule: %w", err) + } + return nil +} + +const sqlUpdateParsingRule = ` +UPDATE parsing_rules +SET selectors = $2, enabled = $3, description = $4 +WHERE id = $1 +RETURNING updated_at +` + +// Update 는 ID 로 규칙을 갱신합니다. 자연키 (source/host/type/version) 는 변경 불가 — +// 규칙 진화는 새 row 를 INSERT 후 enabled flip 으로 표현 권장. +func (r *pgParsingRuleRepository) Update(ctx context.Context, rec *storage.ParsingRuleRecord) error { + selectors, err := json.Marshal(rec.Selectors) + if err != nil { + return fmt.Errorf("marshal selectors: %w", err) + } + row := r.pool.QueryRow(ctx, sqlUpdateParsingRule, rec.ID, selectors, rec.Enabled, rec.Description) + if err := row.Scan(&rec.UpdatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return storage.ErrNotFound + } + return fmt.Errorf("update parsing rule %d: %w", rec.ID, err) + } + return nil +} + +const sqlGetParsingRuleByID = ` +SELECT id, source_name, host_pattern, target_type, version, enabled, selectors, description, created_at, updated_at +FROM parsing_rules +WHERE id = $1 +` + +// GetByID 는 ID 로 규칙을 조회합니다. +func (r *pgParsingRuleRepository) GetByID(ctx context.Context, id int64) (*storage.ParsingRuleRecord, error) { + row := r.pool.QueryRow(ctx, sqlGetParsingRuleByID, id) + rec, err := scanParsingRule(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, storage.ErrNotFound + } + return nil, fmt.Errorf("get parsing rule %d: %w", id, err) + } + return rec, nil +} + +const sqlFindActiveParsingRule = ` +SELECT id, source_name, host_pattern, target_type, version, enabled, selectors, description, created_at, updated_at +FROM parsing_rules +WHERE host_pattern = $1 + AND target_type = $2 + AND enabled = TRUE +ORDER BY version DESC +LIMIT 1 +` + +// FindActive 는 host + target_type 매칭 활성 규칙을 반환합니다 (RuleResolver 핫패스). +// 같은 (host, type) 에 여러 활성 row 가 있다면 version DESC 순으로 첫 항목. +func (r *pgParsingRuleRepository) FindActive(ctx context.Context, host string, targetType storage.TargetType) (*storage.ParsingRuleRecord, error) { + row := r.pool.QueryRow(ctx, sqlFindActiveParsingRule, host, string(targetType)) + rec, err := scanParsingRule(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, storage.ErrNotFound + } + return nil, fmt.Errorf("find active parsing rule (%s, %s): %w", host, targetType, err) + } + return rec, nil +} + +// List 는 필터 조건에 맞는 규칙들을 반환합니다. +func (r *pgParsingRuleRepository) List(ctx context.Context, f storage.ParsingRuleFilter) ([]*storage.ParsingRuleRecord, error) { + limit := f.Limit + if limit <= 0 { + limit = 50 + } + + query := ` +SELECT id, source_name, host_pattern, target_type, version, enabled, selectors, description, created_at, updated_at +FROM parsing_rules +WHERE 1=1` + args := make([]any, 0, 4) + idx := 1 + + if f.SourceName != "" { + query += fmt.Sprintf(" AND source_name = $%d", idx) + args = append(args, f.SourceName) + idx++ + } + if f.HostPattern != "" { + query += fmt.Sprintf(" AND host_pattern = $%d", idx) + args = append(args, f.HostPattern) + idx++ + } + if f.TargetType != "" { + query += fmt.Sprintf(" AND target_type = $%d", idx) + args = append(args, string(f.TargetType)) + idx++ + } + if f.OnlyEnabled { + query += " AND enabled = TRUE" + } + + query += fmt.Sprintf(" ORDER BY source_name, host_pattern, target_type, version DESC LIMIT $%d OFFSET $%d", idx, idx+1) + args = append(args, limit, f.Offset) + + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list parsing rules: %w", err) + } + defer rows.Close() + + var out []*storage.ParsingRuleRecord + for rows.Next() { + rec, err := scanParsingRule(rows) + if err != nil { + return nil, fmt.Errorf("scan parsing rule row: %w", err) + } + out = append(out, rec) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate parsing rule rows: %w", err) + } + return out, nil +} + +const sqlDeleteParsingRule = `DELETE FROM parsing_rules WHERE id = $1` + +// Delete 는 ID 로 규칙을 삭제합니다 (idempotent — 미존재여도 nil). +func (r *pgParsingRuleRepository) Delete(ctx context.Context, id int64) error { + if _, err := r.pool.Exec(ctx, sqlDeleteParsingRule, id); err != nil { + return fmt.Errorf("delete parsing rule %d: %w", id, err) + } + return nil +} + +// scanParsingRule 은 Row/Rows 에서 ParsingRuleRecord 를 스캔합니다. +// selectors 는 raw JSONB → SelectorMap 으로 unmarshal. +func scanParsingRule(s scanner) (*storage.ParsingRuleRecord, error) { + rec := &storage.ParsingRuleRecord{} + var selectorsRaw []byte + var targetType string + if err := s.Scan( + &rec.ID, &rec.SourceName, &rec.HostPattern, &targetType, &rec.Version, + &rec.Enabled, &selectorsRaw, &rec.Description, &rec.CreatedAt, &rec.UpdatedAt, + ); err != nil { + return nil, err + } + rec.TargetType = storage.TargetType(targetType) + if len(selectorsRaw) > 0 { + if err := json.Unmarshal(selectorsRaw, &rec.Selectors); err != nil { + return nil, fmt.Errorf("unmarshal selectors for rule %d: %w", rec.ID, err) + } + } + return rec, nil +} diff --git a/migrations/down/006_create_parsing_rules.sql b/migrations/down/006_create_parsing_rules.sql new file mode 100644 index 00000000..26ca5a1d --- /dev/null +++ b/migrations/down/006_create_parsing_rules.sql @@ -0,0 +1,9 @@ +-- 006_create_parsing_rules 롤백 — trigger / function / 인덱스 / 테이블 역순 제거 + +DROP TRIGGER IF EXISTS parsing_rules_touch_updated_at ON parsing_rules; +DROP FUNCTION IF EXISTS parsing_rules_touch_updated_at(); + +DROP INDEX IF EXISTS idx_parsing_rules_source_enabled; +DROP INDEX IF EXISTS idx_parsing_rules_lookup; + +DROP TABLE IF EXISTS parsing_rules; diff --git a/migrations/up/006_create_parsing_rules.sql b/migrations/up/006_create_parsing_rules.sql new file mode 100644 index 00000000..5761c40a --- /dev/null +++ b/migrations/up/006_create_parsing_rules.sql @@ -0,0 +1,71 @@ +-- 006_create_parsing_rules: 사이트별 파싱 규칙을 DB로 일원화 (이슈 #100) +-- +-- 배경: +-- 기존에는 naver/daum/yonhap/cnn 각각에 parser.go 가 있고 selector 가 코드에 +-- hardcode 되어 있어 새 사이트 지원 시 코드 추가 + 재배포가 필요했다. 본 테이블은 +-- 사이트별 파싱 규칙을 단일 source 로 관리하여 단일 rule-based parser engine 이 +-- 런타임에 규칙을 조회해 동작하도록 한다. +-- +-- 스키마: +-- - source_name + host_pattern + target_type + version 의 자연키 +-- - selectors: JSONB — 필드별 CSS selector / attribute / multi 등 선택값 보관 +-- (top-level 컬럼으로 빼면 새 필드 추가 시 migration 필요 → JSONB 가 진화 친화적) +-- - enabled: 동일 (source_name, target_type) 안에서 어떤 version 이 활성인지 표시 +-- - 활성 규칙 1건 보장은 application 레벨 책임 (DB unique 로 강제하지 않음 — +-- 운영자가 새 version 을 enabled=true 로 바꾼 직후 잠시 두 row 활성 가능) + +CREATE TABLE IF NOT EXISTS parsing_rules ( + id BIGSERIAL PRIMARY KEY, + + -- 자연키 — application 에서 (source_name, host_pattern, target_type, version) 4-tuple 로 lookup + source_name VARCHAR(100) NOT NULL, -- "naver" / "cnn" / "yonhap" / "blog.example.com" 등 + host_pattern VARCHAR(255) NOT NULL, -- "n.news.naver.com" / "edition.cnn.com" — URL host 매칭 + target_type VARCHAR(20) NOT NULL, -- "page" (단일 컨텐츠) / "list" (링크-허브) + version INT NOT NULL DEFAULT 1, + + -- 활성화 플래그 + enabled BOOLEAN NOT NULL DEFAULT TRUE, + + -- 필드별 CSS selector / attribute / multi 등 — JSONB 로 유연성 확보 + selectors JSONB NOT NULL DEFAULT '{}'::jsonb, + + -- 메타데이터 + description TEXT, -- 운영자 메모 (LLM 생성 / 휴먼 review 결과 등) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT parsing_rules_target_type_check + CHECK (target_type IN ('page', 'list')), + CONSTRAINT parsing_rules_version_positive + CHECK (version > 0), + -- Lookup 키 (host_pattern, target_type, version) 와 동일하게 UNIQUE. + -- source_name 은 metadata (어느 source 가 등록했는지) 로만 보존 — 동일 host 에는 + -- 한 source 만 매핑되는 가정. 동일 (host, type, version) 으로 두 row 를 의도적으로 + -- 두는 시나리오는 nondeterministic FindActive 를 유발하므로 schema 단계에서 차단. + -- (Coderabbit code review 피드백 — natural key ↔ lookup key 정렬) + CONSTRAINT parsing_rules_lookup_key_unique + UNIQUE (host_pattern, target_type, version) +); + +-- URL host 기반 lookup — host_pattern + target_type + enabled 가 핫패스 +CREATE INDEX IF NOT EXISTS idx_parsing_rules_lookup + ON parsing_rules (host_pattern, target_type, enabled) + WHERE enabled = TRUE; + +-- 운영 대시보드 — source 별 활성 rule 조회 +CREATE INDEX IF NOT EXISTS idx_parsing_rules_source_enabled + ON parsing_rules (source_name, enabled, target_type); + +-- updated_at auto-touch trigger +CREATE OR REPLACE FUNCTION parsing_rules_touch_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS parsing_rules_touch_updated_at ON parsing_rules; +CREATE TRIGGER parsing_rules_touch_updated_at + BEFORE UPDATE ON parsing_rules + FOR EACH ROW EXECUTE FUNCTION parsing_rules_touch_updated_at(); diff --git a/test/internal/parser/rule/parser_test.go b/test/internal/parser/rule/parser_test.go new file mode 100644 index 00000000..0e0bfa35 --- /dev/null +++ b/test/internal/parser/rule/parser_test.go @@ -0,0 +1,224 @@ +package rule_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "issuetracker/internal/crawler/core" + "issuetracker/internal/crawler/parser/rule" + "issuetracker/internal/storage" +) + +const articleHTML = ` +
+First paragraph of the body.
+Second paragraph with more detail.
+
+
+ Snippet 1
+Snippet 2
+