2026-06-04 02:22:38 +03:00
package core
import (
"context"
"encoding/json"
2026-06-12 18:09:40 +03:00
"errors"
2026-06-04 02:22:38 +03:00
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v2"
extractpkg "github.com/karust/openserp/extract"
)
type extractPayload struct {
URL string ` json:"url" `
Mode string ` json:"mode" `
// Clean defaults to true (article-only). Pointer so we can tell "omitted"
// (use default) from an explicit false (full-page extraction).
Clean * bool ` json:"clean" `
UseLLMSTxt bool ` json:"use_llms_txt" `
MinRunes int ` json:"min_runes" `
}
func ( s * Server ) handleExtract ( c * fiber . Ctx ) error {
startedAt := time . Now ( )
requestCtx := withRequestUsage ( c . UserContext ( ) , "extract" )
c . SetUserContext ( requestCtx )
defer setNetworkBytesHeader ( c , requestCtx )
defer setBrowserProfileHeader ( c , requestCtx )
cfg := s . opts . Extract . Normalized ( )
if ! cfg . Enabled {
return & APIError { HTTPStatus : fiber . StatusNotFound , ErrorCode : "not_found" , Message : "Extraction is disabled" }
}
format , err := resolveFormat ( c )
if err != nil {
return err
}
req , err := s . extractRequestFromFiber ( c , cfg )
if err != nil {
return err
}
extractor := s . newExtractor ( )
result , err := extractor . Extract ( requestCtx , req )
if err != nil {
WithRequest ( requestCtx ) . WithError ( err ) . Warn ( "Extract failed" )
2026-06-12 18:09:40 +03:00
if errors . Is ( err , ErrTargetNotAllowed ) {
return & APIError { HTTPStatus : fiber . StatusBadRequest , ErrorCode : "invalid_extract_url" , Message : err . Error ( ) }
}
2026-06-04 02:22:38 +03:00
return & APIError { HTTPStatus : fiber . StatusBadGateway , ErrorCode : "extract_failed" , Message : "Failed to extract URL content" }
}
result . Meta . TookMs = time . Since ( startedAt ) . Milliseconds ( )
return sendExtractResult ( c , format , result )
}
func ( s * Server ) extractRequestFromFiber ( c * fiber . Ctx , cfg extractpkg . Config ) ( extractpkg . ExtractRequest , error ) {
var body extractPayload
if len ( c . Body ( ) ) > 0 {
_ = c . BodyParser ( & body )
}
proxyOverride , err := NormalizeProxyRequestOverride ( c . Get ( "X-Use-Proxy" ) )
if err != nil {
return extractpkg . ExtractRequest { } , errInvalidParam ( fmt . Sprintf ( "X-Use-Proxy: %v" , err ) )
}
proxyURL := strings . TrimSpace ( c . Get ( "X-Proxy-URL" ) )
if proxyURL != "" {
normalized , err := NormalizeProxyURL ( proxyURL )
if err != nil {
return extractpkg . ExtractRequest { } , errInvalidParam ( fmt . Sprintf ( "X-Proxy-URL: %v" , err ) )
}
proxyURL = normalized
}
q := Query { ProxyURL : proxyURL , ProxyOverride : proxyOverride }
if err := s . validateRequestProxyURL ( & q ) ; err != nil {
return extractpkg . ExtractRequest { } , err
}
mode := firstNonEmpty ( c . Query ( "mode" ) , body . Mode , cfg . DefaultMode )
// Default clean=true (article-only). FullPage is the inverse: full-readable-body
// extraction, opted in via clean=false on the query string or body.
bodyClean := true
if body . Clean != nil {
bodyClean = * body . Clean
}
clean := parseBoolDefault ( c . Query ( "clean" ) , bodyClean )
minRunes , err := parseNonNegativeIntQuery ( c . Query ( "min_runes" ) , body . MinRunes )
if err != nil {
return extractpkg . ExtractRequest { } , errInvalidParam ( "min_runes must be a non-negative integer" )
}
2026-06-12 18:09:40 +03:00
targetURL := extractpkg . NormalizeURL ( strings . TrimSpace ( firstNonEmpty ( c . Query ( "url" ) , body . URL ) ) )
if err := validateExtractTargetURL ( c . UserContext ( ) , targetURL , cfg . AllowPrivateNetworks ) ; err != nil {
return extractpkg . ExtractRequest { } , errInvalidParam ( err . Error ( ) )
}
2026-06-04 02:22:38 +03:00
return extractpkg . ExtractRequest {
2026-06-12 18:09:40 +03:00
URL : targetURL ,
2026-06-04 02:22:38 +03:00
Mode : extractpkg . Mode ( mode ) ,
ProxyURL : proxyURL ,
LangCode : strings . TrimSpace ( c . Query ( "lang" ) ) ,
Timeout : cfg . Timeout ,
MaxBytes : cfg . MaxBytes ,
FullPage : ! clean ,
UseLLMSTxt : parseBoolDefault ( c . Query ( "use_llms_txt" ) , body . UseLLMSTxt ) ,
MinRunes : minRunes ,
} , nil
}
func ( s * Server ) newExtractor ( ) extractpkg . Extractor {
return extractpkg . Extractor {
RawFetch : s . rawExtractFetch ,
RenderedFetch : s . renderedExtractFetch ,
Cfg : s . opts . Extract ,
}
}
func ( s * Server ) rawExtractFetch ( ctx context . Context , req extractpkg . ExtractRequest ) ( * extractpkg . FetchResponse , error ) {
2026-06-16 03:51:37 +03:00
return RawExtractFetch ( ctx , req , s . opts . Extract , s . opts . FingerprintBrowserOpts . Insecure )
}
// RawExtractFetch performs the browserless extraction fetch: validate the
// target, issue a guarded HTTP GET, classify the status, and return the body
// capped to the byte budget. Shared by the HTTP server and the CLI.
func RawExtractFetch ( ctx context . Context , req extractpkg . ExtractRequest , cfg extractpkg . Config , insecure bool ) ( * extractpkg . FetchResponse , error ) {
cfg = cfg . Normalized ( )
2026-06-12 18:09:40 +03:00
if err := validateExtractTargetURL ( ctx , req . URL , cfg . AllowPrivateNetworks ) ; err != nil {
return nil , err
}
2026-06-04 02:22:38 +03:00
resp , err := RawSearchRequest ( ctx , req . URL , Query {
2026-06-12 18:09:40 +03:00
ProxyURL : req . ProxyURL ,
LangCode : req . LangCode ,
2026-06-16 03:51:37 +03:00
Insecure : insecure ,
2026-06-12 18:09:40 +03:00
GuardPrivateNetworks : ! cfg . AllowPrivateNetworks ,
2026-06-04 02:22:38 +03:00
} )
if err != nil {
return nil , err
}
defer DrainAndCloseResponse ( resp )
if err := ClassifySearchHTTPStatus ( resp . StatusCode ) ; err != nil {
return nil , err
}
limit := int64 ( req . MaxBytes )
if limit <= 0 {
2026-06-16 03:51:37 +03:00
limit = int64 ( cfg . MaxBytes )
2026-06-04 02:22:38 +03:00
}
body , err := io . ReadAll ( io . LimitReader ( resp . Body , limit + 1 ) )
if err != nil {
return nil , err
}
if int64 ( len ( body ) ) > limit {
body = body [ : limit ]
}
return & extractpkg . FetchResponse { StatusCode : resp . StatusCode , Body : body } , nil
}
func ( s * Server ) renderedExtractFetch ( ctx context . Context , req extractpkg . ExtractRequest ) ( * extractpkg . FetchResponse , error ) {
if s . opts . BrowserResolver == nil {
return nil , fmt . Errorf ( "rendered extraction is unavailable" )
}
2026-06-12 18:09:40 +03:00
cfg := s . opts . Extract . Normalized ( )
if err := s . validateRenderedExtractNavigation ( ctx , req , cfg ) ; err != nil {
return nil , err
}
2026-06-04 02:22:38 +03:00
browser , err := s . opts . BrowserResolver ( req . ProxyURL )
if err != nil {
return nil , err
}
2026-06-16 03:51:37 +03:00
return RenderExtractHTML ( WithRequestProxyURL ( ctx , req . ProxyURL ) , browser , req )
}
// RenderExtractHTML navigates an already-resolved browser to the target,
// returns its rendered HTML capped to the byte budget, and always closes the
// page. Shared by the HTTP server's BrowserResolver path and the CLI's
// one-shot browser. Callers own target validation and proxy gating.
func RenderExtractHTML ( ctx context . Context , browser * Browser , req extractpkg . ExtractRequest ) ( * extractpkg . FetchResponse , error ) {
page , err := browser . Navigate ( ctx , req . URL )
2026-06-04 02:22:38 +03:00
if err != nil {
return nil , err
}
defer func ( ) {
_ = browser . ClosePage ( ctx , page , time . Second )
} ( )
html , err := page . HTML ( )
if err != nil {
return nil , err
}
body := [ ] byte ( html )
if req . MaxBytes > 0 && len ( body ) > req . MaxBytes {
body = body [ : req . MaxBytes ]
}
return & extractpkg . FetchResponse { StatusCode : http . StatusOK , Body : body } , nil
}
2026-06-12 18:09:40 +03:00
func ( s * Server ) validateRenderedExtractNavigation ( ctx context . Context , req extractpkg . ExtractRequest , cfg extractpkg . Config ) error {
if err := validateExtractTargetURL ( ctx , req . URL , cfg . AllowPrivateNetworks ) ; err != nil {
return err
}
if cfg . AllowPrivateNetworks {
return nil
}
client , err := NewRawHTTPClient ( Query {
ProxyURL : req . ProxyURL ,
LangCode : req . LangCode ,
Insecure : s . opts . FingerprintBrowserOpts . Insecure ,
GuardPrivateNetworks : true ,
} )
if err != nil {
return err
}
preflight , err := http . NewRequestWithContext ( ctx , http . MethodHead , req . URL , nil )
if err != nil {
return err
}
resp , err := client . Do ( preflight )
if err != nil {
if errors . Is ( err , ErrTargetNotAllowed ) {
return err
}
WithRequest ( ctx ) . WithError ( err ) . Debug ( "Rendered extract redirect preflight failed; continuing after initial target validation" )
return nil
}
_ = resp . Body . Close ( )
return nil
}
func validateExtractTargetURL ( ctx context . Context , rawURL string , allowPrivateNetworks bool ) error {
rawURL = extractpkg . NormalizeURL ( strings . TrimSpace ( rawURL ) )
if allowPrivateNetworks {
_ , err := validateHTTPURL ( rawURL )
return err
}
return ValidatePublicHTTPURL ( ctx , rawURL )
}
2026-06-04 02:22:38 +03:00
func ( s * Server ) enrichEnvelopeWithExtraction ( ctx context . Context , env * Envelope , q Query , format string ) {
2026-06-16 03:51:37 +03:00
EnrichEnvelopeWithExtraction ( ctx , env , q , format , s . newExtractor ( ) , s . opts . Extract )
}
// EnrichEnvelopeWithExtraction fills env.Results[*].Extracted by running the
// extractor over the top organic results, with candidate fill-in when a top
// result fails. It is shared by the HTTP search handler and the CLI so both
// apply the same depth bounds, batch deadline, and result selection. The
// extractor and cfg are supplied by the caller (the server reuses its
// long-lived browser pool; the CLI builds a one-shot browser).
func EnrichEnvelopeWithExtraction ( ctx context . Context , env * Envelope , q Query , format string , extractor extractpkg . Extractor , cfg extractpkg . Config ) {
cfg = cfg . Normalized ( )
2026-06-04 02:22:38 +03:00
if env == nil || ! q . Extract || ! cfg . Enabled {
return
}
// One representation per result, chosen by the response format: plain text for
// format=text, markdown for everything else (json/ndjson/markdown). This keeps
// the format-specific renderers fed without serializing two near-identical blobs.
contentFormat := "markdown"
if format == "text" {
contentFormat = "text"
}
2026-06-16 03:51:37 +03:00
limit := clampExtractTop ( q . ExtractTop )
2026-06-04 02:22:38 +03:00
if limit > len ( env . Results ) {
limit = len ( env . Results )
}
2026-06-09 02:28:54 +03:00
candidateLimit := limit + 3
if candidateLimit > len ( env . Results ) {
candidateLimit = len ( env . Results )
}
2026-06-04 02:22:38 +03:00
// Per-fetch timeouts bound a single URL; this aggregate deadline bounds the
2026-06-16 03:51:37 +03:00
// whole batch so a few slow/hanging targets can't stretch the request
2026-06-04 02:22:38 +03:00
// open-endedly. The ceiling is derived from the per-URL budget (see
// Config.BatchTimeout) rather than a separate knob. When it fires, in-flight
// fetches are cancelled and any not yet started record a timeout error instead
// of a result — never a 500.
2026-06-09 02:28:54 +03:00
ctx , cancel := context . WithTimeout ( ctx , cfg . BatchTimeout ( candidateLimit ) )
2026-06-04 02:22:38 +03:00
defer cancel ( )
2026-06-09 02:28:54 +03:00
extractOne := func ( idx int ) {
// Skip the fetch entirely if the batch budget is already spent.
if err := ctx . Err ( ) ; err != nil {
2026-06-16 03:51:37 +03:00
env . Results [ idx ] . Extracted = & ExtractedContent { Error : SanitizeExtractError ( err ) }
2026-06-09 02:28:54 +03:00
return
}
req := extractpkg . ExtractRequest {
URL : env . Results [ idx ] . URL ,
Mode : extractpkg . Mode ( q . ExtractMode ) ,
ProxyURL : q . ProxyURL ,
LangCode : q . LangCode ,
Timeout : cfg . Timeout ,
MaxBytes : cfg . MaxBytes ,
MinRunes : q . ExtractMinRunes ,
}
result , err := extractor . Extract ( ctx , req )
if err != nil {
2026-06-16 03:51:37 +03:00
env . Results [ idx ] . Extracted = & ExtractedContent { Error : SanitizeExtractError ( err ) }
2026-06-09 02:28:54 +03:00
return
}
content := result . Markdown
if contentFormat == "text" {
content = result . Text
}
2026-06-16 03:51:37 +03:00
if ! ExtractedContentLooksUseful ( content ) {
2026-06-09 02:28:54 +03:00
env . Results [ idx ] . Extracted = & ExtractedContent { Error : "empty extracted content" }
return
}
env . Results [ idx ] . Extracted = & ExtractedContent {
Title : result . Title ,
Format : contentFormat ,
Content : content ,
ModeUsed : result . Meta . ModeUsed ,
FetchedAt : result . Meta . FetchedAt ,
}
}
2026-06-04 02:22:38 +03:00
sem := make ( chan struct { } , cfg . MaxConcurrent )
var wg sync . WaitGroup
for i := 0 ; i < limit ; i ++ {
if strings . TrimSpace ( env . Results [ i ] . URL ) == "" {
continue
}
wg . Add ( 1 )
sem <- struct { } { }
go func ( idx int ) {
defer wg . Done ( )
defer func ( ) { <- sem } ( )
2026-06-09 02:28:54 +03:00
extractOne ( idx )
2026-06-04 02:22:38 +03:00
} ( i )
}
wg . Wait ( )
2026-06-09 02:28:54 +03:00
successes := extractedSuccessCount ( env . Results [ : limit ] )
for i := limit ; successes < limit && i < candidateLimit ; i ++ {
if strings . TrimSpace ( env . Results [ i ] . URL ) == "" {
continue
}
extractOne ( i )
if extractedResultSucceeded ( env . Results [ i ] ) {
successes ++
}
}
}
const minUsefulExtractRunes = 80
2026-06-16 03:51:37 +03:00
// ExtractedContentLooksUseful reports whether extracted page content is long
// enough to keep, rather than an empty/boilerplate shell. Shared by the HTTP
// server and the CLI so both apply the same threshold.
func ExtractedContentLooksUseful ( content string ) bool {
2026-06-09 02:28:54 +03:00
return len ( [ ] rune ( strings . TrimSpace ( content ) ) ) >= minUsefulExtractRunes
}
func extractedSuccessCount ( results [ ] Result ) int {
count := 0
for _ , result := range results {
if extractedResultSucceeded ( result ) {
count ++
}
}
return count
}
func extractedResultSucceeded ( result Result ) bool {
return result . Extracted != nil &&
result . Extracted . Error == "" &&
2026-06-16 03:51:37 +03:00
ExtractedContentLooksUseful ( result . Extracted . Content )
2026-06-04 02:22:38 +03:00
}
func sendExtractResult ( c * fiber . Ctx , format string , result * extractpkg . ExtractResult ) error {
switch format {
case "json" :
return c . JSON ( result )
case "markdown" :
c . Set ( "Content-Type" , "text/markdown; charset=utf-8" )
var b strings . Builder
if result . Title != "" {
fmt . Fprintf ( & b , "# %s\n\n" , result . Title )
}
if result . URL != "" {
fmt . Fprintf ( & b , "<%s>\n\n" , result . URL )
}
b . WriteString ( result . Markdown )
b . WriteString ( "\n" )
return c . SendString ( b . String ( ) )
case "text" :
c . Set ( "Content-Type" , "text/plain; charset=utf-8" )
return c . SendString ( result . Text + "\n" )
case "ndjson" :
c . Set ( "Content-Type" , "application/x-ndjson; charset=utf-8" )
data , err := json . Marshal ( map [ string ] any { "kind" : "extract" , "result" : result } )
if err != nil {
return err
}
return c . Send ( append ( data , '\n' ) )
default :
return errInvalidParam ( "format must be one of json, markdown, text, ndjson" )
}
}
func parseBoolDefault ( raw string , fallback bool ) bool {
raw = strings . TrimSpace ( raw )
if raw == "" {
return fallback
}
return raw == "1" || strings . EqualFold ( raw , "true" ) || strings . EqualFold ( raw , "yes" )
}
2026-06-16 03:51:37 +03:00
// SanitizeExtractError trims and length-bounds an extraction error for safe
// inclusion in a response payload. Shared by the HTTP server and the CLI.
func SanitizeExtractError ( err error ) string {
2026-06-04 02:22:38 +03:00
if err == nil {
return ""
}
msg := strings . TrimSpace ( err . Error ( ) )
if msg == "" {
return "extract failed"
}
if len ( msg ) > 180 {
msg = msg [ : 180 ]
}
return msg
}
2026-07-18 00:18:05 +02:00
const maxBatchExtractURLs = 20
type batchExtractPayload struct {
URLs [ ] string ` json:"urls" `
Mode string ` json:"mode" `
}
2026-07-18 09:49:38 +02:00
// batchExtractItem is a response item for a single extracted URL, using
// page_content/metadata keys
2026-07-18 00:18:05 +02:00
type batchExtractItem struct {
PageContent string ` json:"page_content" `
Metadata map [ string ] string ` json:"metadata" `
}
func ( s * Server ) handleBatchExtract ( c * fiber . Ctx ) error {
requestCtx := withRequestUsage ( c . UserContext ( ) , "extract-batch" )
c . SetUserContext ( requestCtx )
defer setNetworkBytesHeader ( c , requestCtx )
defer setBrowserProfileHeader ( c , requestCtx )
cfg := s . opts . Extract . Normalized ( )
if ! cfg . Enabled {
return & APIError { HTTPStatus : fiber . StatusNotFound , ErrorCode : "not_found" , Message : "Extraction is disabled" }
}
var body batchExtractPayload
if len ( c . Body ( ) ) == 0 {
return & APIError { HTTPStatus : fiber . StatusBadRequest , ErrorCode : "invalid_request" , Message : "request body is required" }
}
if err := c . BodyParser ( & body ) ; err != nil {
return & APIError { HTTPStatus : fiber . StatusBadRequest , ErrorCode : "invalid_request" , Message : "invalid JSON body" }
}
// Deduplicate and normalize URLs.
seen := make ( map [ string ] struct { } , len ( body . URLs ) )
var urls [ ] string
for _ , raw := range body . URLs {
u := extractpkg . NormalizeURL ( strings . TrimSpace ( raw ) )
if u == "" {
continue
}
if _ , dup := seen [ u ] ; dup {
continue
}
seen [ u ] = struct { } { }
urls = append ( urls , u )
}
if len ( urls ) == 0 {
return & APIError { HTTPStatus : fiber . StatusBadRequest , ErrorCode : "invalid_request" , Message : "urls array is required and must contain at least one valid URL" }
}
if len ( urls ) > maxBatchExtractURLs {
return & APIError {
HTTPStatus : fiber . StatusBadRequest ,
ErrorCode : "invalid_request" ,
Message : fmt . Sprintf ( "urls array exceeds maximum of %d" , maxBatchExtractURLs ) ,
}
}
// Validate all URLs upfront.
for _ , u := range urls {
if err := validateExtractTargetURL ( c . UserContext ( ) , u , cfg . AllowPrivateNetworks ) ; err != nil {
return & APIError { HTTPStatus : fiber . StatusBadRequest , ErrorCode : "invalid_extract_url" , Message : err . Error ( ) }
}
}
mode := firstNonEmpty ( body . Mode , cfg . DefaultMode )
extractor := s . newExtractor ( )
results := make ( [ ] batchExtractItem , len ( urls ) )
// Concurrent extraction with bounded parallelism (same pattern as
// EnrichEnvelopeWithExtraction).
ctx , cancel := context . WithTimeout ( c . UserContext ( ) , cfg . BatchTimeout ( len ( urls ) ) )
defer cancel ( )
sem := make ( chan struct { } , cfg . MaxConcurrent )
var wg sync . WaitGroup
for i , u := range urls {
wg . Add ( 1 )
sem <- struct { } { }
go func ( idx int , url string ) {
defer wg . Done ( )
defer func ( ) { <- sem } ( )
if err := ctx . Err ( ) ; err != nil {
results [ idx ] = batchExtractItem {
PageContent : "" ,
Metadata : map [ string ] string { "source" : url , "error" : "batch timeout" } ,
}
return
}
req := extractpkg . ExtractRequest {
URL : url ,
Mode : extractpkg . Mode ( mode ) ,
ProxyURL : "" ,
LangCode : strings . TrimSpace ( c . Query ( "lang" ) ) ,
Timeout : cfg . Timeout ,
MaxBytes : cfg . MaxBytes ,
}
result , err := extractor . Extract ( ctx , req )
if err != nil {
results [ idx ] = batchExtractItem {
PageContent : "" ,
Metadata : map [ string ] string {
"source" : url ,
"error" : SanitizeExtractError ( err ) ,
} ,
}
return
}
results [ idx ] = batchExtractItem {
PageContent : result . Markdown ,
Metadata : map [ string ] string {
"source" : url ,
"title" : result . Title ,
"description" : result . Description ,
"lang" : result . Lang ,
"canonical" : result . Canonical ,
"mode_used" : result . Meta . ModeUsed ,
"fetched_at" : result . Meta . FetchedAt ,
} ,
}
} ( i , u )
}
wg . Wait ( )
return c . JSON ( results )
}