mirror of
https://github.com/lukaszraczylo/mcp-captcharesolver.git
synced 2026-10-12 00:45:28 +00:00
feat(imageutil): input decoding and grid centroid geometry
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
// Package imageutil decodes image/audio inputs (base64, data-URI, file path,
|
||||||
|
// http(s) URL) and computes grid geometry for tile-based captcha solving.
|
||||||
|
package imageutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var dataURIRe = regexp.MustCompile(`^data:([^;,]+)(;base64)?,(.*)$`)
|
||||||
|
|
||||||
|
// Point is a pixel coordinate.
|
||||||
|
type Point struct {
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode resolves input (data-URI | bare base64 | file path | http(s) URL) to
|
||||||
|
// raw bytes plus a best-effort media type sniffed from the content.
|
||||||
|
func Decode(input string) ([]byte, string, error) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(input, "data:"):
|
||||||
|
m := dataURIRe.FindStringSubmatch(input)
|
||||||
|
if m == nil {
|
||||||
|
return nil, "", fmt.Errorf("malformed data URI")
|
||||||
|
}
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
if m[2] == ";base64" {
|
||||||
|
data, err = base64.StdEncoding.DecodeString(m[3])
|
||||||
|
} else {
|
||||||
|
data = []byte(m[3])
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return data, m[1], nil
|
||||||
|
case strings.HasPrefix(input, "http://"), strings.HasPrefix(input, "https://"):
|
||||||
|
return fetchURL(input)
|
||||||
|
default:
|
||||||
|
// File path if it exists; otherwise treat as bare base64.
|
||||||
|
if data, err := os.ReadFile(input); err == nil { //nolint:gosec // path is caller-provided automation input
|
||||||
|
return data, sniff(data), nil
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("input is not a data-URI, URL, readable file, or base64: %w", err)
|
||||||
|
}
|
||||||
|
return data, sniff(data), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchURL(url string) ([]byte, string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, "", fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
mt := resp.Header.Get("Content-Type")
|
||||||
|
if mt == "" {
|
||||||
|
mt = sniff(data)
|
||||||
|
}
|
||||||
|
return data, mt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sniff returns a media type from magic bytes, defaulting to image/png.
|
||||||
|
func sniff(b []byte) string {
|
||||||
|
switch {
|
||||||
|
case len(b) >= 4 && b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4e && b[3] == 0x47:
|
||||||
|
return "image/png"
|
||||||
|
case len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff:
|
||||||
|
return "image/jpeg"
|
||||||
|
case len(b) >= 4 && b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F':
|
||||||
|
return "audio/wav"
|
||||||
|
case len(b) >= 6 && string(b[:3]) == "ID3":
|
||||||
|
return "audio/mpeg"
|
||||||
|
default:
|
||||||
|
return "image/png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GridCentroids returns the pixel centre of each tile for a rows x cols grid
|
||||||
|
// laid over a width x height image, in row-major order (index 0 = top-left).
|
||||||
|
func GridCentroids(width, height, rows, cols int) []Point {
|
||||||
|
pts := make([]Point, 0, rows*cols)
|
||||||
|
cw := float64(width) / float64(cols)
|
||||||
|
ch := float64(height) / float64(rows)
|
||||||
|
for r := 0; r < rows; r++ {
|
||||||
|
for c := 0; c < cols; c++ {
|
||||||
|
pts = append(pts, Point{
|
||||||
|
X: int(cw*float64(c) + cw/2),
|
||||||
|
Y: int(ch*float64(r) + ch/2),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pts
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package imageutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecode(t *testing.T) {
|
||||||
|
raw := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic prefix
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||||
|
|
||||||
|
t.Run("data uri", func(t *testing.T) {
|
||||||
|
data, mt, err := Decode("data:image/png;base64," + b64)
|
||||||
|
if err != nil || mt != "image/png" || string(data) != string(raw) {
|
||||||
|
t.Fatalf("data=%v mt=%q err=%v", data, mt, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("bare base64 sniffs png", func(t *testing.T) {
|
||||||
|
data, mt, err := Decode(b64)
|
||||||
|
if err != nil || mt != "image/png" || string(data) != string(raw) {
|
||||||
|
t.Fatalf("data=%v mt=%q err=%v", data, mt, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("file path", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "x.png")
|
||||||
|
if err := os.WriteFile(p, raw, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, mt, err := Decode(p)
|
||||||
|
if err != nil || mt != "image/png" || string(data) != string(raw) {
|
||||||
|
t.Fatalf("data=%v mt=%q err=%v", data, mt, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGridCentroids(t *testing.T) {
|
||||||
|
pts := GridCentroids(300, 300, 3, 3)
|
||||||
|
if len(pts) != 9 {
|
||||||
|
t.Fatalf("want 9 centroids, got %d", len(pts))
|
||||||
|
}
|
||||||
|
// index 0 = top-left tile centre at (50,50)
|
||||||
|
if pts[0].X != 50 || pts[0].Y != 50 {
|
||||||
|
t.Fatalf("centroid[0]=%+v", pts[0])
|
||||||
|
}
|
||||||
|
// index 4 = centre tile at (150,150)
|
||||||
|
if pts[4].X != 150 || pts[4].Y != 150 {
|
||||||
|
t.Fatalf("centroid[4]=%+v", pts[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user