更新runway 2api风控
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user