Files
openserp/extract/extractor.go
Rustem Kamalov 04db5347a8 fix(core): harden extract and stabilize request protection
- breaker: stop counting client cancellations/deadlines (incl. bare
  rate-limiter wait errors) and circuit-open as engine failures
- rate limiting: cache limiters in SearchEngineOptions and rawEngine so
  pacing applies on raw/library paths; pool wrapper delegates
- /extract SSRF guard: public-IP-only policy with dial-time IP pinning,
  redirect re-validation, rendered-mode preflight, http/https allow-list,
  ErrTargetNotAllowed -> HTTP 400, extract.allow_private_networks
  escape hatch (default off)
- browser: no rod Must* on the request path; CLI parses block_resources
  without panicking
2026-06-12 18:09:40 +03:00

209 lines
6.1 KiB
Go

package extract
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
)
type Extractor struct {
RawFetch RawFetcher
RenderedFetch RenderedFetcher
Cfg Config
}
func (e *Extractor) Extract(ctx context.Context, req ExtractRequest) (*ExtractResult, error) {
startedAt := time.Now()
cfg := e.Cfg.Normalized()
req = normalizeRequest(req, cfg)
if req.URL == "" {
return nil, errors.New("url is required")
}
if _, err := url.ParseRequestURI(req.URL); err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
if req.Mode == "" {
req.Mode = Mode(cfg.DefaultMode)
}
if req.Mode != ModeAuto && req.Mode != ModeFast && req.Mode != ModeRendered {
return nil, fmt.Errorf("invalid mode %q", req.Mode)
}
// A site's llms.txt is purpose-built markdown — when the caller opts in and
// the URL is a root, it beats anything we can scrape. Falls through silently
// to normal extraction when absent.
if req.UseLLMSTxt {
if result, ok := e.tryLLMSTxt(ctx, req, startedAt); ok {
return result, nil
}
}
var rawResult *ExtractResult
var rawErr error
if req.Mode != ModeRendered && e.RawFetch != nil {
rawResult, rawErr = e.extractFast(ctx, req, startedAt)
if req.Mode == ModeFast || goodEnough(rawResult, rawErr, req.MinRunes) {
return rawResult, rawErr
}
}
if e.RenderedFetch == nil {
return rawResult, rawErr
}
renderedResult, renderedErr := e.extractRendered(ctx, req, startedAt)
if renderedErr == nil && renderedResult != nil {
// In auto mode the raw pass already produced something usable but below
// the quality threshold. Only prefer the rendered pass when it actually
// recovered more content — a bot wall or consent page can render shorter
// than the raw HTML, and falling back to it would be a regression.
if rawErr == nil && rawResult != nil && textLength(rawResult) > textLength(renderedResult) {
return rawResult, nil
}
return renderedResult, nil
}
if rawResult != nil || rawErr != nil {
return rawResult, rawErr
}
return nil, renderedErr
}
func (e *Extractor) extractFast(ctx context.Context, req ExtractRequest, startedAt time.Time) (*ExtractResult, error) {
fetchCtx, cancel := context.WithTimeout(ctx, req.Timeout)
defer cancel()
resp, err := e.RawFetch(fetchCtx, req)
if err != nil {
return nil, err
}
if resp == nil {
return nil, errors.New("empty raw response")
}
return buildResult(req, resp, string(ModeFast), startedAt)
}
func (e *Extractor) extractRendered(ctx context.Context, req ExtractRequest, startedAt time.Time) (*ExtractResult, error) {
fetchCtx, cancel := context.WithTimeout(ctx, req.Timeout)
defer cancel()
resp, err := e.RenderedFetch(fetchCtx, req)
if err != nil {
return nil, err
}
if resp == nil {
return nil, errors.New("empty rendered response")
}
return buildResult(req, resp, string(ModeRendered), startedAt)
}
func normalizeRequest(req ExtractRequest, cfg Config) ExtractRequest {
req.URL = NormalizeURL(strings.TrimSpace(req.URL))
if req.Timeout <= 0 {
req.Timeout = cfg.Timeout
}
if req.MaxBytes <= 0 {
req.MaxBytes = cfg.MaxBytes
}
if req.MaxBytes > 0 && req.MaxBytes < 64*1024 {
req.MaxBytes = 64 * 1024
}
req.Mode = Mode(strings.ToLower(strings.TrimSpace(string(req.Mode))))
return req
}
// NormalizeURL defaults a missing scheme to https so callers can pass a bare host
func NormalizeURL(raw string) string {
if raw == "" || strings.Contains(raw, "://") || strings.HasPrefix(raw, "//") {
return raw
}
return "https://" + raw
}
func buildResult(req ExtractRequest, resp *FetchResponse, mode string, startedAt time.Time) (*ExtractResult, error) {
if err := classifyStatus(resp.StatusCode); err != nil {
return nil, err
}
body := resp.Body
if req.MaxBytes > 0 && len(body) > req.MaxBytes {
body = body[:req.MaxBytes]
}
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
metadata := parseMetadata(doc, req.URL)
content, contentErr := extractContent(body, req.URL, !req.FullPage)
result := &ExtractResult{
URL: req.URL,
Title: firstNonEmpty(content.Title, metadata.Title),
Description: firstNonEmpty(content.Description, metadata.Description),
Markdown: content.Markdown,
Text: content.Text,
Headings: metadata.Headings,
Links: metadata.Links,
Canonical: metadata.Canonical,
Lang: firstNonEmpty(content.Lang, metadata.Lang),
SchemaOrg: metadata.SchemaOrg,
OGTags: metadata.OGTags,
Meta: ExtractMeta{
ModeUsed: mode,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
Bytes: len(body),
TookMs: time.Since(startedAt).Milliseconds(),
},
}
if contentErr != nil && strings.TrimSpace(result.Text) == "" {
return result, contentErr
}
return result, nil
}
func classifyStatus(status int) error {
if status == 0 {
return nil
}
if status == http.StatusForbidden || status == http.StatusUnauthorized {
return fmt.Errorf("blocked: HTTP %d", status)
}
if status == http.StatusTooManyRequests {
return fmt.Errorf("rate limited: HTTP %d", status)
}
if status < 200 || status >= 300 {
return fmt.Errorf("unexpected HTTP status %d", status)
}
return nil
}
// defaultMinRunes is the built-in auto-mode escalation floor.
const defaultMinRunes = 200
// goodEnough reports whether the raw pass is worth keeping instead of escalating
// to a render. minRunes overrides the floor (<= 0 uses defaultMinRunes); the
// headings shortcut accepts shorter structured pages, never below 120 runes.
func goodEnough(result *ExtractResult, err error, minRunes int) bool {
if err != nil || result == nil {
return false
}
if minRunes <= 0 {
minRunes = defaultMinRunes
}
textLen := textLength(result)
if textLen >= minRunes {
return true
}
return textLen >= 120 && textLen >= minRunes-80 && len(result.Headings) > 0
}
// textLength returns the rune count of the trimmed extracted text, used both to
// gate auto-mode escalation and to compare raw vs rendered output quality.
func textLength(result *ExtractResult) int {
if result == nil {
return 0
}
return len([]rune(strings.TrimSpace(result.Text)))
}