mirror of
https://github.com/karust/openserp.git
synced 2026-08-06 01:03:56 +08:00
558 lines
18 KiB
Go
558 lines
18 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"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"`
|
|
Lang string `json:"lang"`
|
|
}
|
|
|
|
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")
|
|
if errors.Is(err, ErrTargetNotAllowed) {
|
|
return &APIError{HTTPStatus: fiber.StatusBadRequest, ErrorCode: "invalid_extract_url", Message: err.Error()}
|
|
}
|
|
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)
|
|
}
|
|
|
|
// baseExtractRequest builds the URL-independent part of an extract request
|
|
// from proxy headers, query params, and the parsed body. Shared by /extract
|
|
// and /extract/batch so both accept the same knobs.
|
|
func (s *Server) baseExtractRequest(c *fiber.Ctx, body extractPayload, cfg extractpkg.Config) (extractpkg.ExtractRequest, error) {
|
|
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 := extractpkg.Mode(strings.ToLower(firstNonEmpty(c.Query("mode"), body.Mode, cfg.DefaultMode)))
|
|
switch mode {
|
|
case extractpkg.ModeAuto, extractpkg.ModeFast, extractpkg.ModeRendered:
|
|
default:
|
|
return extractpkg.ExtractRequest{}, errInvalidParam("mode must be one of auto, fast, rendered")
|
|
}
|
|
// 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")
|
|
}
|
|
return extractpkg.ExtractRequest{
|
|
Mode: mode,
|
|
ProxyURL: proxyURL,
|
|
LangCode: firstNonEmpty(body.Lang, 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) extractRequestFromFiber(c *fiber.Ctx, cfg extractpkg.Config) (extractpkg.ExtractRequest, error) {
|
|
var body extractPayload
|
|
if len(c.Body()) > 0 {
|
|
_ = c.BodyParser(&body)
|
|
}
|
|
req, err := s.baseExtractRequest(c, body, cfg)
|
|
if err != nil {
|
|
return extractpkg.ExtractRequest{}, err
|
|
}
|
|
req.URL = extractpkg.NormalizeURL(strings.TrimSpace(firstNonEmpty(c.Query("url"), body.URL)))
|
|
if err := validateExtractTargetURL(c.UserContext(), req.URL, cfg.AllowPrivateNetworks); err != nil {
|
|
return extractpkg.ExtractRequest{}, errInvalidParam(err.Error())
|
|
}
|
|
return req, 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) {
|
|
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()
|
|
if err := validateExtractTargetURL(ctx, req.URL, cfg.AllowPrivateNetworks); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := RawSearchRequest(ctx, req.URL, Query{
|
|
ProxyURL: req.ProxyURL,
|
|
LangCode: req.LangCode,
|
|
Insecure: insecure,
|
|
GuardPrivateNetworks: !cfg.AllowPrivateNetworks,
|
|
})
|
|
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 {
|
|
limit = int64(cfg.MaxBytes)
|
|
}
|
|
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")
|
|
}
|
|
cfg := s.opts.Extract.Normalized()
|
|
if err := s.validateRenderedExtractNavigation(ctx, req, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
browser, err := s.opts.BrowserResolver(req.ProxyURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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)
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (s *Server) enrichEnvelopeWithExtraction(ctx context.Context, env *Envelope, q Query, format string) {
|
|
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()
|
|
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"
|
|
}
|
|
limit := clampExtractTop(q.ExtractTop)
|
|
if limit > len(env.Results) {
|
|
limit = len(env.Results)
|
|
}
|
|
candidateLimit := limit + 3
|
|
if candidateLimit > len(env.Results) {
|
|
candidateLimit = len(env.Results)
|
|
}
|
|
|
|
// Per-fetch timeouts bound a single URL; this aggregate deadline bounds the
|
|
// whole batch so a few slow/hanging targets can't stretch the request
|
|
// 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.
|
|
ctx, cancel := context.WithTimeout(ctx, cfg.BatchTimeout(candidateLimit))
|
|
defer cancel()
|
|
|
|
extractOne := func(idx int) {
|
|
// Skip the fetch entirely if the batch budget is already spent.
|
|
if err := ctx.Err(); err != nil {
|
|
env.Results[idx].Extracted = &ExtractedContent{Error: SanitizeExtractError(err)}
|
|
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 {
|
|
env.Results[idx].Extracted = &ExtractedContent{Error: SanitizeExtractError(err)}
|
|
return
|
|
}
|
|
content := result.Markdown
|
|
if contentFormat == "text" {
|
|
content = result.Text
|
|
}
|
|
if !ExtractedContentLooksUseful(content) {
|
|
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,
|
|
}
|
|
}
|
|
|
|
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 }()
|
|
extractOne(idx)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
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
|
|
|
|
// 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 {
|
|
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 == "" &&
|
|
ExtractedContentLooksUseful(result.Extracted.Content)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// 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 {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
msg := strings.TrimSpace(err.Error())
|
|
if msg == "" {
|
|
return "extract failed"
|
|
}
|
|
if len(msg) > 180 {
|
|
msg = msg[:180]
|
|
}
|
|
return msg
|
|
}
|
|
|
|
const maxBatchExtractURLs = 20
|
|
|
|
type batchExtractPayload struct {
|
|
extractPayload
|
|
URLs []string `json:"urls"`
|
|
}
|
|
|
|
// batchExtractItem is one entry of the bare-array /extract/batch response.
|
|
// The {page_content, metadata} shape is the Open WebUI ExternalWebLoader
|
|
// contract - do not wrap it in the Envelope.
|
|
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"}
|
|
}
|
|
|
|
if len(c.Body()) == 0 {
|
|
return errInvalidParam("request body is required")
|
|
}
|
|
var body batchExtractPayload
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return errInvalidParam("invalid JSON body")
|
|
}
|
|
urls := dedupeBatchURLs(body.URLs)
|
|
if len(urls) == 0 {
|
|
return errInvalidParam("urls array is required and must contain at least one valid URL")
|
|
}
|
|
if len(urls) > maxBatchExtractURLs {
|
|
return errInvalidParam(fmt.Sprintf("urls array exceeds maximum of %d", maxBatchExtractURLs))
|
|
}
|
|
baseReq, err := s.baseExtractRequest(c, body.extractPayload, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Target URLs are validated in the fetch path, inside the workers - a bad
|
|
// URL becomes an error item instead of failing the whole batch (Open WebUI
|
|
// drops every doc on a non-2xx). 400 is reserved for malformed requests.
|
|
extractor := s.newExtractor()
|
|
results := make([]batchExtractItem, len(urls))
|
|
|
|
// Bounded parallelism plus an aggregate deadline, same pattern as
|
|
// EnrichEnvelopeWithExtraction.
|
|
ctx, cancel := context.WithTimeout(requestCtx, 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 }()
|
|
results[idx] = batchExtractOne(ctx, extractor, baseReq, url)
|
|
}(i, u)
|
|
}
|
|
wg.Wait()
|
|
|
|
return c.JSON(results)
|
|
}
|
|
|
|
// dedupeBatchURLs normalizes, drops empties, and keeps first occurrence order.
|
|
func dedupeBatchURLs(raw []string) []string {
|
|
seen := make(map[string]struct{}, len(raw))
|
|
var urls []string
|
|
for _, r := range raw {
|
|
u := extractpkg.NormalizeURL(strings.TrimSpace(r))
|
|
if u == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[u]; dup {
|
|
continue
|
|
}
|
|
seen[u] = struct{}{}
|
|
urls = append(urls, u)
|
|
}
|
|
return urls
|
|
}
|
|
|
|
// batchExtractOne extracts a single URL, folding failures into the item.
|
|
func batchExtractOne(ctx context.Context, extractor extractpkg.Extractor, req extractpkg.ExtractRequest, url string) batchExtractItem {
|
|
fail := func(err error) batchExtractItem {
|
|
WithRequest(ctx).WithError(err).WithField("url", url).Warn("Batch extract failed")
|
|
return batchExtractItem{Metadata: map[string]string{"source": url, "error": SanitizeExtractError(err)}}
|
|
}
|
|
// Skip the fetch once the batch budget is spent.
|
|
if ctx.Err() != nil {
|
|
return fail(errors.New("batch timeout"))
|
|
}
|
|
req.URL = url
|
|
result, err := extractor.Extract(ctx, req)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
return 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,
|
|
"took_ms": strconv.FormatInt(result.Meta.TookMs, 10),
|
|
},
|
|
}
|
|
}
|