From 3a2c9710c3c1327059f0fd3a299d9bd636161ef9 Mon Sep 17 00:00:00 2001 From: chiyi Date: Fri, 10 Jul 2026 13:25:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0runway=202api=E9=A3=8E?= =?UTF-8?q?=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/provider/grok/video.go | 7 ++ backend/internal/provider/runway/client.go | 92 ++++++++++++++++++---- backend/internal/provider/runway/image.go | 12 ++- backend/internal/provider/runway/video.go | 45 +++++------ 4 files changed, 112 insertions(+), 44 deletions(-) diff --git a/backend/internal/provider/grok/video.go b/backend/internal/provider/grok/video.go index 8af0fc7..a8da0c8 100644 --- a/backend/internal/provider/grok/video.go +++ b/backend/internal/provider/grok/video.go @@ -65,6 +65,13 @@ func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio, return nil, nil, err } + // Self-heal the x-statsig-id anti-bot challenge for THIS session before the + // gated submit. Without it the header falls back to the static defaults, + // which go stale on a new grok web build and make conversations/new answer + // 403 (anti-bot). Fetch via the proxy client so the derived seed matches the + // submit's egress IP. Failure is non-fatal (statsigID then uses defaults). + c.ensureChallenge(ctx, submitClient, token) + // Image-to-video: upload each reference frame and collect its asset URL. var imageRefs []string for _, f := range frames { diff --git a/backend/internal/provider/runway/client.go b/backend/internal/provider/runway/client.go index 2f4bfd8..b0dac27 100644 --- a/backend/internal/provider/runway/client.go +++ b/backend/internal/provider/runway/client.go @@ -6,7 +6,9 @@ package runway import ( "context" + crand "crypto/rand" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -22,8 +24,81 @@ import ( const ( apiBase = "https://api.runwayml.com" origin = "https://app.runwayml.com" + + // userAgent / secChUA mirror the real browser that produced a successful + // registration + generation in the reference HAR (Edge 150 on Windows). + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0" + secChUA = `"Not;A=Brand";v="8", "Chromium";v="150", "Microsoft Edge";v="150"` ) +// randHex returns n random bytes hex-encoded (2n chars), for sentry trace ids. +func randHex(n int) string { + b := make([]byte, n) + if _, err := crand.Read(b); err != nil { + return strings.Repeat("0", n*2) + } + return hex.EncodeToString(b) +} + +// browserHeaders builds the full Chrome/Edge header set the Runway web app sends +// on its JSON API calls. tls-client only spoofs the TLS/JA3 fingerprint — it does +// NOT inject a User-Agent or the client-hint / fetch-metadata headers. Without +// them a request looks like a headless bot and Runway's anti-abuse revokes the +// account's free credits within minutes ("秒死"). teamID is optional. +func browserHeaders(token, teamID string) http.Header { + traceID := randHex(16) + spanID := randHex(8) + h := http.Header{ + "accept": {"application/json"}, + "accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"}, + "authorization": {"Bearer " + strings.TrimPrefix(token, "Bearer ")}, + "baggage": {"sentry-environment=production,sentry-public_key=8ea832c064ed4bbcb4b8952c02ba119a,sentry-trace_id=" + traceID}, + "content-type": {"application/json"}, + "origin": {origin}, + "priority": {"u=1, i"}, + "referer": {origin + "/"}, + "sec-ch-ua": {secChUA}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-platform": {`"Windows"`}, + "sec-fetch-dest": {"empty"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-site": {"same-site"}, + "sentry-trace": {traceID + "-" + spanID}, + "user-agent": {userAgent}, + "x-runway-workspace": {teamID}, + http.HeaderOrderKey: { + "accept", "accept-language", "authorization", "baggage", + "content-type", "origin", "priority", "referer", + "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", + "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", + "sentry-trace", "user-agent", "x-runway-workspace", + }, + } + if strings.TrimSpace(teamID) == "" { + h.Del("x-runway-workspace") + } + return h +} + +// browserAssetHeaders is the lighter header set the browser sends on cross-site +// asset transfers (presigned S3 upload / CloudFront artifact download): a +// User-Agent and client hints, but no Runway authorization. +func browserAssetHeaders() http.Header { + return http.Header{ + "accept": {"*/*"}, + "accept-language": {"zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"}, + "origin": {origin}, + "referer": {origin + "/"}, + "sec-ch-ua": {secChUA}, + "sec-ch-ua-mobile": {"?0"}, + "sec-ch-ua-platform": {`"Windows"`}, + "sec-fetch-dest": {"empty"}, + "sec-fetch-mode": {"cors"}, + "sec-fetch-site": {"cross-site"}, + "user-agent": {userAgent}, + } +} + var ( ErrAuth = errors.New("runway auth failed") ErrQuotaExhausted = errors.New("runway quota exhausted") @@ -112,22 +187,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str return nil, err } req = req.WithContext(ctx) - req.Header = http.Header{ - "accept": {"application/json"}, - "content-type": {"application/json"}, - "origin": {origin}, - "referer": {origin + "/"}, - "authorization": {"Bearer " + token}, - "x-runway-workspace": {teamID}, - http.HeaderOrderKey: { - "accept", - "content-type", - "origin", - "referer", - "authorization", - "x-runway-workspace", - }, - } + req.Header = browserHeaders(token, teamID) resp, err := client.Do(req) if err != nil { diff --git a/backend/internal/provider/runway/image.go b/backend/internal/provider/runway/image.go index 63f51fa..4a24863 100644 --- a/backend/internal/provider/runway/image.go +++ b/backend/internal/provider/runway/image.go @@ -64,7 +64,9 @@ func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prom }) } - taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, refImages) + assetGroupID, _ := c.assetGroupID(ctx, directClient, token, teamID) // best-effort + + taskID, err := c.createImageTask(ctx, submitClient, token, teamID, modelID, prompt, aspectRatio, imageSize, assetGroupID, refImages) if err != nil { return nil, nil, err } @@ -111,7 +113,7 @@ func (c *Client) uploadReference(ctx context.Context, client tlsclient.HttpClien // createImageTask creates a gemini image task (workflow_gemini_image for Pro, // gemini_3_1_flash_image for Nano Banana 2) and returns its id. -func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize string, refImages []map[string]any) (string, error) { +func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, modelID, prompt, aspectRatio, imageSize, assetGroupID string, refImages []map[string]any) (string, error) { taskType := "workflow_gemini_image" opts := map[string]any{ "name": "Nano Banana Pro - " + prompt, @@ -131,6 +133,12 @@ func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClien } opts["aspect_ratio"] = aspectRatio } + // assetGroupId is present on every real browser task submit; omitting it is a + // bot tell. Best-effort — only attach when we resolved the "Generations" + // group for this workspace. + if assetGroupID != "" { + opts["assetGroupId"] = assetGroupID + } if len(refImages) > 0 { opts["reference_images"] = refImages } diff --git a/backend/internal/provider/runway/video.go b/backend/internal/provider/runway/video.go index 033de8e..a9ac2e5 100644 --- a/backend/internal/provider/runway/video.go +++ b/backend/internal/provider/runway/video.go @@ -70,39 +70,37 @@ func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspec return nil, nil, errors.New("runway: failed to decode first-frame image") } - // Only the task-create (generate submit) egresses via the proxy; first-frame - // upload, polling and download run on the local IP. - submitClient, err := c.newTLSClient() - if err != nil { - return nil, nil, err - } - directClient, err := c.newDirectTLSClient() + // Use a single client (and therefore a single exit IP) for the whole flow. + // The web app runs upload, task-create, polling and download from one IP; a + // mid-flow IP switch (proxy for submit, local for the rest) is a strong + // bot/risk signal, so we route everything through the proxy-aware client. + apiClient, err := c.newTLSClient() if err != nil { return nil, nil, err } filename := "frame_" + time.Now().UTC().Format("20060102_150405") + ".png" - previewUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET_PREVIEW", frame) + previewUploadID, _, err := c.uploadFile(ctx, apiClient, token, teamID, filename, "DATASET_PREVIEW", frame) if err != nil { return nil, nil, err } - datasetUploadID, _, err := c.uploadFile(ctx, directClient, token, teamID, filename, "DATASET", frame) + datasetUploadID, _, err := c.uploadFile(ctx, apiClient, token, teamID, filename, "DATASET", frame) if err != nil { return nil, nil, err } - assetID, imageURL, err := c.createDataset(ctx, directClient, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height) + assetID, imageURL, err := c.createDataset(ctx, apiClient, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height) if err != nil { return nil, nil, err } - assetGroupID, _ := c.assetGroupID(ctx, directClient, token, teamID) // best-effort + assetGroupID, _ := c.assetGroupID(ctx, apiClient, token, teamID) // best-effort - taskID, err := c.createTask(ctx, submitClient, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio, seconds) + taskID, err := c.createTask(ctx, apiClient, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio, seconds) if err != nil { return nil, nil, err } - artifactURL, err := c.pollTask(ctx, directClient, token, teamID, taskID) + artifactURL, err := c.pollTask(ctx, apiClient, token, teamID, taskID) if err != nil { return nil, nil, err } @@ -115,7 +113,7 @@ func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspec if !downloadResult { return nil, meta, nil } - data, err := c.download(ctx, directClient, artifactURL) + data, err := c.download(ctx, apiClient, artifactURL) if err != nil { return nil, nil, err } @@ -291,17 +289,7 @@ func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token return nil, err } req = req.WithContext(ctx) - req.Header = http.Header{ - "accept": {"application/json"}, - "content-type": {"application/json"}, - "origin": {origin}, - "referer": {origin + "/"}, - "authorization": {"Bearer " + token}, - "x-runway-workspace": {teamID}, - http.HeaderOrderKey: { - "accept", "content-type", "origin", "referer", "authorization", "x-runway-workspace", - }, - } + req.Header = browserHeaders(token, teamID) resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) @@ -343,7 +331,8 @@ func (c *Client) putBytes(ctx context.Context, client tlsclient.HttpClient, url, return "", err } req = req.WithContext(ctx) - req.Header = http.Header{"content-type": {contentType}} + req.Header = browserAssetHeaders() + req.Header.Set("content-type", contentType) resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) @@ -362,6 +351,10 @@ func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url return nil, err } req = req.WithContext(ctx) + req.Header = browserAssetHeaders() + req.Header.Set("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + req.Header.Set("sec-fetch-dest", "image") + req.Header.Set("sec-fetch-mode", "no-cors") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)