package research import ( "context" "errors" "fmt" "html" "io" "net" "net/http" "net/url" "regexp" "strings" "time" "unicode" "unicode/utf8" ) const ( defaultFetchBytes = int64(2 << 20) defaultFetchChars = 14000 ) type FetchOptions struct { MaxBytes int64 MaxChars int Timeout time.Duration AllowPrivate bool } type FetchDiagnostic struct { URL string `json:"url"` FinalURL string `json:"final_url,omitempty"` HTTPStatus int `json:"http_status,omitempty"` ContentType string `json:"content_type,omitempty"` Bytes int `json:"bytes,omitempty"` Characters int `json:"characters,omitempty"` DurationMS int64 `json:"duration_ms,omitempty"` ErrorKind string `json:"error_kind,omitempty"` } type FetchedPage struct { URL string Title string Content string ContentType string } func (c *Client) FetchPage(ctx context.Context, rawURL string, options FetchOptions) (FetchedPage, FetchDiagnostic, error) { started := time.Now() diagnostic := FetchDiagnostic{URL: strings.TrimSpace(rawURL)} finish := func(page FetchedPage, err error) (FetchedPage, FetchDiagnostic, error) { diagnostic.DurationMS = time.Since(started).Milliseconds() if page.URL != "" { diagnostic.FinalURL = page.URL } if page.ContentType != "" { diagnostic.ContentType = page.ContentType } diagnostic.Characters = len([]rune(page.Content)) if err != nil && diagnostic.ErrorKind == "" { diagnostic.ErrorKind = classifyFetchError(err) } return page, diagnostic, err } if options.MaxBytes <= 0 { options.MaxBytes = defaultFetchBytes } if options.MaxChars <= 0 { options.MaxChars = defaultFetchChars } if options.Timeout <= 0 { options.Timeout = 20 * time.Second } parsed, err := validateFetchURL(ctx, rawURL, options.AllowPrivate) if err != nil { diagnostic.ErrorKind = "unsafe_url" return finish(FetchedPage{}, err) } transport := http.DefaultTransport.(*http.Transport).Clone() transport.Proxy = nil transport.DialContext = safeDialContext(options.AllowPrivate) transport.MaxIdleConns = 4 transport.MaxIdleConnsPerHost = 2 transport.IdleConnTimeout = 20 * time.Second client := &http.Client{ Transport: transport, Timeout: options.Timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return errors.New("zu viele Weiterleitungen") } _, err := validateFetchURL(req.Context(), req.URL.String(), options.AllowPrivate) return err }, } req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) if err != nil { diagnostic.ErrorKind = "request_build" return finish(FetchedPage{}, err) } req.Header.Set("Accept", "text/html, text/plain;q=0.9, application/xhtml+xml;q=0.8, application/pdf;q=0.2") req.Header.Set("Accept-Language", "de-DE,de;q=0.9,en;q=0.8") req.Header.Set("User-Agent", "glpi-neural-brain-research/1.0") resp, err := client.Do(req) if err != nil { return finish(FetchedPage{}, fmt.Errorf("Webquelle %s konnte nicht geladen werden: %w", parsed.Hostname(), err)) } defer resp.Body.Close() diagnostic.HTTPStatus = resp.StatusCode diagnostic.ContentType = strings.TrimSpace(resp.Header.Get("Content-Type")) if resp.StatusCode/100 != 2 { diagnostic.ErrorKind = "http_status" return finish(FetchedPage{}, fmt.Errorf("Webquelle lieferte HTTP %d (%s)", resp.StatusCode, http.StatusText(resp.StatusCode))) } body, err := io.ReadAll(io.LimitReader(resp.Body, options.MaxBytes+1)) if err != nil { diagnostic.ErrorKind = "response_read" return finish(FetchedPage{}, fmt.Errorf("Webquelle konnte nicht gelesen werden: %w", err)) } if int64(len(body)) > options.MaxBytes { diagnostic.ErrorKind = "response_too_large" return finish(FetchedPage{}, fmt.Errorf("Webquelle überschreitet das Größenlimit von %d Bytes", options.MaxBytes)) } diagnostic.Bytes = len(body) contentType := strings.ToLower(strings.TrimSpace(strings.Split(diagnostic.ContentType, ";")[0])) finalURL := resp.Request.URL.String() page := FetchedPage{URL: finalURL, ContentType: contentType} switch { case contentType == "application/pdf" || strings.HasSuffix(strings.ToLower(resp.Request.URL.Path), ".pdf"): diagnostic.ErrorKind = "pdf_unsupported" return finish(FetchedPage{}, errors.New("PDF-Quelle erkannt; PDF-Textextraktion ist in diesem Build nicht aktiviert")) case contentType == "text/plain" || contentType == "text/markdown" || contentType == "application/json": page.Content = normalizeExtractedText(string(body), options.MaxChars) case contentType == "" || contentType == "text/html" || contentType == "application/xhtml+xml": page.Title, page.Content = extractHTMLText(string(body), options.MaxChars) default: diagnostic.ErrorKind = "unsupported_content_type" return finish(FetchedPage{}, fmt.Errorf("nicht unterstützter Content-Type %q", diagnostic.ContentType)) } if page.Title == "" { page.Title = strings.TrimSpace(resp.Request.URL.Hostname()) } if len([]rune(page.Content)) < 160 { diagnostic.ErrorKind = "content_too_short" return finish(FetchedPage{}, errors.New("Webquelle enthält nach der Textextraktion zu wenig verwertbaren Inhalt")) } return finish(page, nil) } func validateFetchURL(ctx context.Context, rawURL string, allowPrivate bool) (*url.URL, error) { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return nil, fmt.Errorf("ungültige URL: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return nil, errors.New("Webquellen müssen HTTP oder HTTPS verwenden") } if u.Hostname() == "" { return nil, errors.New("Webquelle enthält keinen Host") } if u.User != nil { return nil, errors.New("Webquellen mit Zugangsdaten in der URL sind nicht erlaubt") } if allowPrivate { return u, nil } host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) if host == "localhost" || strings.HasSuffix(host, ".localhost") { return nil, errors.New("lokale Webadressen sind als Recherchequelle nicht erlaubt") } ips, err := resolveHost(ctx, host) if err != nil { return nil, fmt.Errorf("Host %q konnte nicht aufgelöst werden: %w", host, err) } for _, ip := range ips { if !isPublicIP(ip) { return nil, fmt.Errorf("Host %q verweist auf eine private oder lokale Adresse", host) } } return u, nil } func safeDialContext(allowPrivate bool) func(context.Context, string, string) (net.Conn, error) { dialer := &net.Dialer{Timeout: 12 * time.Second, KeepAlive: 20 * time.Second} return func(ctx context.Context, network, address string) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, err } if allowPrivate { return dialer.DialContext(ctx, network, address) } ips, err := resolveHost(ctx, host) if err != nil { return nil, err } var lastErr error for _, ip := range ips { if !isPublicIP(ip) { continue } conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) if err == nil { return conn, nil } lastErr = err } if lastErr != nil { return nil, lastErr } return nil, errors.New("Host besitzt keine zulässige öffentliche IP-Adresse") } } func resolveHost(ctx context.Context, host string) ([]net.IP, error) { if ip := net.ParseIP(host); ip != nil { return []net.IP{ip}, nil } values, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, err } out := make([]net.IP, 0, len(values)) for _, value := range values { out = append(out, value.IP) } if len(out) == 0 { return nil, errors.New("keine IP-Adresse gefunden") } return out, nil } func isPublicIP(ip net.IP) bool { return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsUnspecified() } var ( titlePattern = regexp.MustCompile(`(?is)]*>(.*?)`) mainPattern = regexp.MustCompile(`(?is)]*>(.*?)|]*>(.*?)`) discardBlockPattern = regexp.MustCompile(`(?is)]*>.*?|]*>.*?|]*>.*?|]*>.*?|]*>.*?|]*>.*?|]*>.*?|]*>.*?|]*>.*?|]*>.*?`) commentPattern = regexp.MustCompile(`(?is)`) blockTagPattern = regexp.MustCompile(`(?is)]*>`) tagPattern = regexp.MustCompile(`(?is)<[^>]+>`) spacePattern = regexp.MustCompile(`[\t\f\v ]+`) newlinePattern = regexp.MustCompile(`\n{3,}`) ) func extractHTMLText(raw string, maxChars int) (string, string) { title := "" if match := titlePattern.FindStringSubmatch(raw); len(match) == 2 { title = normalizeExtractedText(tagPattern.ReplaceAllString(html.UnescapeString(match[1]), " "), 300) } text := raw if match := mainPattern.FindStringSubmatch(raw); len(match) >= 3 { if strings.TrimSpace(match[1]) != "" { text = match[1] } else if strings.TrimSpace(match[2]) != "" { text = match[2] } } text = commentPattern.ReplaceAllString(text, " ") text = discardBlockPattern.ReplaceAllString(text, " ") text = blockTagPattern.ReplaceAllString(text, "\n") text = tagPattern.ReplaceAllString(text, " ") text = html.UnescapeString(text) return title, normalizeExtractedText(text, maxChars) } func normalizeExtractedText(value string, maxChars int) string { if !utf8.ValidString(value) { value = strings.ToValidUTF8(value, " ") } value = strings.ReplaceAll(value, "\r\n", "\n") value = strings.ReplaceAll(value, "\r", "\n") lines := strings.Split(value, "\n") out := make([]string, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(spacePattern.ReplaceAllString(line, " ")) if line == "" { if len(out) > 0 && out[len(out)-1] != "" { out = append(out, "") } continue } printable := 0 for _, r := range line { if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) { printable++ } } if printable < 2 { continue } out = append(out, line) } text := strings.TrimSpace(newlinePattern.ReplaceAllString(strings.Join(out, "\n"), "\n\n")) if maxChars > 0 { runes := []rune(text) if len(runes) > maxChars { text = strings.TrimSpace(string(runes[:maxChars])) } } return text } func classifyFetchError(err error) string { if err == nil { return "" } if errors.Is(err, context.DeadlineExceeded) { return "timeout" } return classifyError(err) }