更新runway 2api风控

This commit is contained in:
2026-07-10 13:25:42 +08:00
parent 8d792e66e0
commit 3a2c9710c3
4 changed files with 112 additions and 44 deletions
+7
View File
@@ -65,6 +65,13 @@ func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio,
return nil, nil, err 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. // Image-to-video: upload each reference frame and collect its asset URL.
var imageRefs []string var imageRefs []string
for _, f := range frames { for _, f := range frames {
+76 -16
View File
@@ -6,7 +6,9 @@ package runway
import ( import (
"context" "context"
crand "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -22,8 +24,81 @@ import (
const ( const (
apiBase = "https://api.runwayml.com" apiBase = "https://api.runwayml.com"
origin = "https://app.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 ( var (
ErrAuth = errors.New("runway auth failed") ErrAuth = errors.New("runway auth failed")
ErrQuotaExhausted = errors.New("runway quota exhausted") ErrQuotaExhausted = errors.New("runway quota exhausted")
@@ -112,22 +187,7 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
return nil, err return nil, err
} }
req = req.WithContext(ctx) req = req.WithContext(ctx)
req.Header = http.Header{ req.Header = browserHeaders(token, teamID)
"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",
},
}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
+10 -2
View File
@@ -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 { if err != nil {
return nil, nil, err 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, // createImageTask creates a gemini image task (workflow_gemini_image for Pro,
// gemini_3_1_flash_image for Nano Banana 2) and returns its id. // 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" taskType := "workflow_gemini_image"
opts := map[string]any{ opts := map[string]any{
"name": "Nano Banana Pro - " + prompt, "name": "Nano Banana Pro - " + prompt,
@@ -131,6 +133,12 @@ func (c *Client) createImageTask(ctx context.Context, client tlsclient.HttpClien
} }
opts["aspect_ratio"] = aspectRatio 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 { if len(refImages) > 0 {
opts["reference_images"] = refImages opts["reference_images"] = refImages
} }
+19 -26
View File
@@ -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") return nil, nil, errors.New("runway: failed to decode first-frame image")
} }
// Only the task-create (generate submit) egresses via the proxy; first-frame // Use a single client (and therefore a single exit IP) for the whole flow.
// upload, polling and download run on the local IP. // The web app runs upload, task-create, polling and download from one IP; a
submitClient, err := c.newTLSClient() // mid-flow IP switch (proxy for submit, local for the rest) is a strong
if err != nil { // bot/risk signal, so we route everything through the proxy-aware client.
return nil, nil, err apiClient, err := c.newTLSClient()
}
directClient, err := c.newDirectTLSClient()
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
filename := "frame_" + time.Now().UTC().Format("20060102_150405") + ".png" 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 { if err != nil {
return nil, nil, err 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 { if err != nil {
return nil, nil, err 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 { if err != nil {
return nil, nil, err 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 { if err != nil {
return nil, nil, err 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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -115,7 +113,7 @@ func (c *Client) GenerateVideo(ctx context.Context, token, teamID, prompt, aspec
if !downloadResult { if !downloadResult {
return nil, meta, nil return nil, meta, nil
} }
data, err := c.download(ctx, directClient, artifactURL) data, err := c.download(ctx, apiClient, artifactURL)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -291,17 +289,7 @@ func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token
return nil, err return nil, err
} }
req = req.WithContext(ctx) req = req.WithContext(ctx)
req.Header = http.Header{ req.Header = browserHeaders(token, teamID)
"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",
},
}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) 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 return "", err
} }
req = req.WithContext(ctx) 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) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) 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 return nil, err
} }
req = req.WithContext(ctx) 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) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err) return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)