mirror of
https://github.com/karust/openserp.git
synced 2026-08-05 16:53:54 +08:00
- 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
209 lines
6.1 KiB
Go
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)))
|
|
}
|