// Package ocr contains cloud OCR adapters. package ocr import ( "bytes" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" "github.com/mmmy/snapgo/internal/application" "github.com/mmmy/snapgo/internal/domain" ) // Client calls one configured OCR provider. type Client struct { providerID string cfg domain.OCRProviderConfig httpClient *http.Client now func() time.Time nonce func() string } var _ application.OCRRecognizer = (*Client)(nil) // NewClient validates cfg and returns a reusable OCR client. func NewClient(providerID string, cfg domain.OCRProviderConfig) (*Client, error) { if strings.TrimSpace(cfg.Endpoint) == "" { return nil, fmt.Errorf("ocr endpoint is required") } if strings.TrimSpace(cfg.AccessKeyID) == "" { return nil, fmt.Errorf("ocr access key id is required") } if strings.TrimSpace(cfg.AccessKeySecret) == "" { return nil, fmt.Errorf("ocr access key secret is required") } if strings.TrimSpace(cfg.Action) == "" { return nil, fmt.Errorf("ocr action is required") } if strings.TrimSpace(cfg.Version) == "" { return nil, fmt.Errorf("ocr version is required") } switch providerID { case domain.OCRProviderAliyun: case domain.OCRProviderVolcengine: if strings.TrimSpace(cfg.Region) == "" { return nil, fmt.Errorf("volcengine ocr region is required") } if strings.TrimSpace(cfg.Service) == "" { return nil, fmt.Errorf("volcengine ocr service is required") } default: return nil, fmt.Errorf("unsupported ocr provider %q", providerID) } timeout := cfg.TimeoutSecs if timeout <= 0 { timeout = 30 } return &Client{ providerID: providerID, cfg: cfg, httpClient: &http.Client{Timeout: time.Duration(timeout) * time.Second}, now: time.Now, nonce: randomNonce, }, nil } // RecognizeText extracts visible text from a PNG screenshot. func (c *Client) RecognizeText(ctx context.Context, pngBytes []byte) (string, error) { if len(pngBytes) == 0 { return "", fmt.Errorf("empty screenshot") } switch c.providerID { case domain.OCRProviderAliyun: return c.recognizeAliyun(ctx, pngBytes) case domain.OCRProviderVolcengine: return c.recognizeVolcengine(ctx, pngBytes) default: return "", fmt.Errorf("unsupported ocr provider %q", c.providerID) } } func (c *Client) recognizeAliyun(ctx context.Context, pngBytes []byte) (string, error) { endpoint, err := parseEndpoint(c.cfg.Endpoint) if err != nil { return "", err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(pngBytes)) if err != nil { return "", fmt.Errorf("build aliyun ocr request: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/octet-stream") c.signAliyun(req, pngBytes) return c.doOCRRequest(req, "aliyun ocr") } func (c *Client) recognizeVolcengine(ctx context.Context, pngBytes []byte) (string, error) { endpoint, err := parseEndpoint(c.cfg.Endpoint) if err != nil { return "", err } query := endpoint.Query() query.Set("Action", c.cfg.Action) query.Set("Version", c.cfg.Version) endpoint.RawQuery = query.Encode() body := map[string]string{ "image_base64": base64.StdEncoding.EncodeToString(pngBytes), } payload, err := json.Marshal(body) if err != nil { return "", fmt.Errorf("marshal volcengine ocr request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) if err != nil { return "", fmt.Errorf("build volcengine ocr request: %w", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") c.signVolcengine(req, payload) return c.doOCRRequest(req, "volcengine ocr") } func (c *Client) doOCRRequest(req *http.Request, label string) (string, error) { resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("%s request: %w", label, err) } defer resp.Body.Close() data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("%s status %d: %s", label, resp.StatusCode, compactBody(data)) } text, err := parseOCRText(data) if err != nil { return "", fmt.Errorf("%s response: %w", label, err) } return text, nil } func (c *Client) signAliyun(req *http.Request, payload []byte) { now := c.now().UTC() payloadHash := sha256Hex(payload) nonce := c.nonce() req.Header.Set("x-acs-action", c.cfg.Action) req.Header.Set("x-acs-version", c.cfg.Version) req.Header.Set("x-acs-date", now.Format("2006-01-02T15:04:05Z")) req.Header.Set("x-acs-signature-nonce", nonce) req.Header.Set("x-acs-content-sha256", payloadHash) signedHeaders := []string{ "content-type", "host", "x-acs-action", "x-acs-content-sha256", "x-acs-date", "x-acs-signature-nonce", "x-acs-version", } canonicalHeaders := canonicalHeaders(req, signedHeaders) canonicalRequest := strings.Join([]string{ req.Method, canonicalURI(req.URL), canonicalQuery(req.URL), canonicalHeaders, strings.Join(signedHeaders, ";"), payloadHash, }, "\n") stringToSign := "ACS3-HMAC-SHA256\n" + sha256Hex([]byte(canonicalRequest)) signature := hmacSHA256Hex([]byte(c.cfg.AccessKeySecret), []byte(stringToSign)) req.Header.Set( "Authorization", fmt.Sprintf("ACS3-HMAC-SHA256 Credential=%s,SignedHeaders=%s,Signature=%s", c.cfg.AccessKeyID, strings.Join(signedHeaders, ";"), signature, ), ) } func (c *Client) signVolcengine(req *http.Request, payload []byte) { now := c.now().UTC() xDate := now.Format("20060102T150405Z") shortDate := now.Format("20060102") payloadHash := sha256Hex(payload) req.Header.Set("X-Date", xDate) req.Header.Set("X-Content-Sha256", payloadHash) signedHeaders := []string{ "content-type", "host", "x-content-sha256", "x-date", } canonicalHeaders := canonicalHeaders(req, signedHeaders) canonicalRequest := strings.Join([]string{ req.Method, canonicalURI(req.URL), canonicalQuery(req.URL), canonicalHeaders, strings.Join(signedHeaders, ";"), payloadHash, }, "\n") scope := strings.Join([]string{shortDate, c.cfg.Region, c.cfg.Service, "request"}, "/") stringToSign := strings.Join([]string{ "HMAC-SHA256", xDate, scope, sha256Hex([]byte(canonicalRequest)), }, "\n") signingKey := volcengineSigningKey(c.cfg.AccessKeySecret, shortDate, c.cfg.Region, c.cfg.Service) signature := hmacSHA256Hex(signingKey, []byte(stringToSign)) req.Header.Set( "Authorization", fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", c.cfg.AccessKeyID, scope, strings.Join(signedHeaders, ";"), signature, ), ) } func parseEndpoint(raw string) (*url.URL, error) { endpoint := strings.TrimSpace(raw) if endpoint == "" { return nil, fmt.Errorf("ocr endpoint is required") } if !strings.Contains(endpoint, "://") { endpoint = "https://" + endpoint } parsed, err := url.Parse(endpoint) if err != nil { return nil, fmt.Errorf("parse ocr endpoint: %w", err) } if parsed.Scheme == "" || parsed.Host == "" { return nil, fmt.Errorf("invalid ocr endpoint %q", raw) } if parsed.Path == "" { parsed.Path = "/" } return parsed, nil } func parseOCRText(data []byte) (string, error) { var root any if err := json.Unmarshal(data, &root); err != nil { return "", fmt.Errorf("decode json: %w", err) } if msg := responseError(root); msg != "" { return "", errors.New(msg) } parts := collectOCRParts(root, "") if len(parts) == 0 { return "", fmt.Errorf("no text found") } return strings.Join(dedupeNonEmpty(parts), "\n"), nil } func responseError(v any) string { obj, ok := v.(map[string]any) if !ok { return "" } if meta, ok := obj["ResponseMetadata"].(map[string]any); ok { if errObj, ok := meta["Error"].(map[string]any); ok { if msg := stringValue(errObj["Message"]); msg != "" { return msg } if code := stringValue(errObj["Code"]); code != "" { return code } } } if errObj, ok := obj["Error"].(map[string]any); ok { if msg := stringValue(errObj["Message"]); msg != "" { return msg } if msg := stringValue(errObj["message"]); msg != "" { return msg } } if code, ok := numericCode(obj["code"]); ok && code != 0 && code != 10000 { if msg := stringValue(obj["message"]); msg != "" { return msg } if msg := stringValue(obj["msg"]); msg != "" { return msg } return fmt.Sprintf("provider code %.0f", code) } if code := stringValue(obj["Code"]); code != "" && !isSuccessCode(code) { if msg := stringValue(obj["Message"]); msg != "" { return msg } return code } return "" } func collectOCRParts(v any, parentKey string) []string { switch value := v.(type) { case string: text := strings.TrimSpace(value) if text == "" { return nil } if looksLikeJSON(text) { var nested any if err := json.Unmarshal([]byte(text), &nested); err == nil { return collectOCRParts(nested, parentKey) } } if isTextKey(parentKey) || isContainerTextKey(parentKey) { return []string{text} } return nil case []any: parts := make([]string, 0, len(value)) for _, item := range value { parts = append(parts, collectOCRParts(item, parentKey)...) } return parts case map[string]any: parts := make([]string, 0) for _, key := range priorityOCRKeys() { if child, ok := value[key]; ok { parts = append(parts, collectOCRParts(child, key)...) } } keys := make([]string, 0, len(value)) for key := range value { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { if isPriorityOCRKey(key) || isMetadataKey(key) { continue } parts = append(parts, collectOCRParts(value[key], key)...) } return parts default: return nil } } func priorityOCRKeys() []string { return []string{ "Data", "data", "Result", "result", "content", "Content", "text", "Text", "DetectedText", "detected_text", "line_text", "LineText", "line_texts", "LineTexts", "word", "Word", "words", "Words", "words_result", "WordsResult", "prism_wordsInfo", "prism_words_info", "ocr_infos", "OCRInfos", "items", "Items", "blocks", "Blocks", "regions", "Regions", } } func isPriorityOCRKey(key string) bool { for _, item := range priorityOCRKeys() { if item == key { return true } } return false } func isTextKey(key string) bool { switch normalizeKey(key) { case "content", "text", "detectedtext", "linetext", "word", "words", "value": return true default: return false } } func isContainerTextKey(key string) bool { switch normalizeKey(key) { case "data", "result", "linetexts", "texts": return true default: return false } } func isMetadataKey(key string) bool { switch normalizeKey(key) { case "requestid", "request", "code", "status", "statuscode", "success", "error", "errors", "message", "msg", "cost", "angle", "probability", "confidence", "height", "width", "left", "top", "right", "bottom", "x", "y", "responsemetadata": return true default: return false } } func normalizeKey(key string) string { key = strings.ToLower(key) key = strings.ReplaceAll(key, "_", "") key = strings.ReplaceAll(key, "-", "") return key } func dedupeNonEmpty(parts []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(parts)) for _, part := range parts { for _, line := range strings.Split(part, "\n") { line = strings.TrimSpace(line) if line == "" { continue } if _, ok := seen[line]; ok { continue } seen[line] = struct{}{} out = append(out, line) } } return out } func looksLikeJSON(text string) bool { return strings.HasPrefix(text, "{") || strings.HasPrefix(text, "[") } func stringValue(v any) string { if s, ok := v.(string); ok { return strings.TrimSpace(s) } return "" } func numericCode(v any) (float64, bool) { switch n := v.(type) { case float64: return n, true case int: return float64(n), true case json.Number: f, err := n.Float64() return f, err == nil default: return 0, false } } func isSuccessCode(code string) bool { switch strings.ToLower(strings.TrimSpace(code)) { case "", "ok", "success", "200", "10000": return true default: return false } } func canonicalURI(u *url.URL) string { if u == nil || u.EscapedPath() == "" { return "/" } return u.EscapedPath() } func canonicalQuery(u *url.URL) string { if u == nil || u.RawQuery == "" { return "" } values, _ := url.ParseQuery(u.RawQuery) return values.Encode() } func canonicalHeaders(req *http.Request, signedHeaders []string) string { lines := make([]string, 0, len(signedHeaders)) for _, key := range signedHeaders { var value string if key == "host" { value = req.URL.Host } else { value = req.Header.Get(key) } lines = append(lines, key+":"+normalizeHeaderValue(value)+"\n") } return strings.Join(lines, "") } func normalizeHeaderValue(value string) string { return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") } func volcengineSigningKey(secret, date, region, service string) []byte { kDate := hmacSHA256([]byte(secret), []byte(date)) kRegion := hmacSHA256(kDate, []byte(region)) kService := hmacSHA256(kRegion, []byte(service)) return hmacSHA256(kService, []byte("request")) } func hmacSHA256(key, data []byte) []byte { mac := hmac.New(sha256.New, key) mac.Write(data) return mac.Sum(nil) } func hmacSHA256Hex(key, data []byte) string { return hex.EncodeToString(hmacSHA256(key, data)) } func sha256Hex(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } func randomNonce() string { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(buf) } func compactBody(data []byte) string { text := strings.TrimSpace(string(data)) if text == "" { return "" } if len(text) > 800 { return text[:800] + "..." } return text }