| package main |
|
|
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "log" |
| "net/http" |
| "os" |
| "path/filepath" |
| "runtime" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
|
|
| "github.com/go-rod/rod" |
| "github.com/go-rod/rod/lib/launcher" |
| "github.com/go-rod/rod/lib/proto" |
| "github.com/go-rod/stealth" |
| ) |
|
|
| var ( |
| globalBrowser *rod.Browser |
|
|
| statsMu sync.Mutex |
| totalRequests int64 |
| totalErrors int64 |
| activeTabs int64 |
| startTime time.Time |
|
|
| tmpDir string |
| ) |
|
|
| type AdvancedAction struct { |
| Type string `json:"type"` |
| Selector string `json:"selector,omitempty"` |
| Value string `json:"value,omitempty"` |
| FileName string `json:"fileName,omitempty"` |
| } |
|
|
| type Payload struct { |
| Timeout int `json:"timeout,omitempty"` |
| Width int `json:"width,omitempty"` |
| Height int `json:"height,omitempty"` |
| UserAgent string `json:"userAgent,omitempty"` |
| IgnoreHTTPSErrors bool `json:"ignoreHTTPSErrors,omitempty"` |
| DisableJS bool `json:"disableJS,omitempty"` |
| BlockImages bool `json:"blockImages,omitempty"` |
| SniffNetwork bool `json:"sniffNetwork,omitempty"` |
| MaxSniffSize int `json:"maxSniffSize,omitempty"` |
| Headers map[string]interface{} `json:"headers,omitempty"` |
| Actions []AdvancedAction `json:"actions"` |
| } |
|
|
| type StandardResponse struct { |
| Logs []string `json:"logs"` |
| Result map[string]interface{} `json:"result"` |
| Error string `json:"error,omitempty"` |
| } |
|
|
| type StatsResponse struct { |
| Uptime string `json:"uptime"` |
| TotalRequests int64 `json:"totalRequests"` |
| TotalErrors int64 `json:"totalErrors"` |
| ActiveTabs int64 `json:"activeTabs"` |
| OpenPagesCount int `json:"openPagesCount"` |
| GoVersion string `json:"goVersion"` |
| NumGoroutine int `json:"numGoroutine"` |
| AllocMemoryMB uint64 `json:"allocMemoryMB"` |
| TotalAllocMB uint64 `json:"totalAllocMB"` |
| SysMemoryMB uint64 `json:"sysMemoryMB"` |
| NumGC uint32 `json:"numGC"` |
| } |
|
|
| type SniffedNetworkActivity struct { |
| URL string `json:"url"` |
| Method string `json:"method"` |
| RequestHeaders map[string]string `json:"requestHeaders,omitempty"` |
| ResponseStatus int `json:"responseStatus,omitempty"` |
| ResponseHeaders map[string]string `json:"responseHeaders,omitempty"` |
| ResponseBody string `json:"responseBody,omitempty"` |
| } |
|
|
| func main() { |
| port := "7860" |
| startTime = time.Now() |
|
|
| tmpDir = os.TempDir() |
|
|
| initGlobalBrowser() |
| defer globalBrowser.MustClose() |
|
|
| http.HandleFunc("/", statsHandler) |
| http.HandleFunc("/execute", executeHandler) |
| |
|
|
| fs := http.FileServer(http.Dir(tmpDir)) |
| http.Handle("/tmp/", http.StripPrefix("/tmp/", fs)) |
|
|
| fmt.Printf("Running on port %s...\n", port) |
| if err := http.ListenAndServe(":"+port, nil); err != nil { |
| log.Fatal(err) |
| } |
| } |
|
|
| func initGlobalBrowser() { |
| path, _ := launcher.LookPath() |
| u := launcher.New(). |
| Bin(path). |
| NoSandbox(true). |
| Headless(true). |
| Devtools(false). |
| Set("disable-gpu", "true"). |
| Set("disable-dev-shm-usage", "true"). |
| Set("disable-blink-features", "AutomationControlled"). |
| Set("exclude-switches", "enable-automation"). |
| Set("disable-gl-drawing-for-tests", "true"). |
| Set("disable-software-rasterizer", "true"). |
| Set("disable-canvas-aa", "true"). |
| Set("disable-2d-canvas-clip-antialiasing", "true"). |
| Set("disable-gpu-rasterization", "true"). |
| Set("no-zygote", "true"). |
| Set("single-process", "true"). |
| Set("disable-infobars", "true"). |
| Set("disable-notifications", "true"). |
| Set("disable-popup-blocking", "true"). |
| Set("disable-background-networking", "true"). |
| Set("disable-renderer-backgrounding", "true"). |
| Set("no-first-run", "true"). |
| Set("no-default-browser-check", "true"). |
| Set("window-size", "1366,768"). |
| Set("lang", "en-US,en"). |
| Set("platform", "Win32"). |
| Set("blink-settings", "primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4"). |
| MustLaunch() |
|
|
| globalBrowser = rod.New().ControlURL(u).MustConnect() |
| } |
|
|
| func executeHandler(w http.ResponseWriter, r *http.Request) { |
| statsMu.Lock() |
| totalRequests++ |
| activeTabs++ |
| statsMu.Unlock() |
|
|
| defer func() { |
| statsMu.Lock() |
| activeTabs-- |
| statsMu.Unlock() |
| }() |
|
|
| if r.Method != http.MethodPost { |
| http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) |
| return |
| } |
|
|
| var req Payload |
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| http.Error(w, err.Error(), http.StatusBadRequest) |
| return |
| } |
|
|
| tabTimeout := 30 |
| if req.Timeout > 0 { |
| tabTimeout = req.Timeout |
| } |
|
|
| var logs []string |
| results := make(map[string]interface{}) |
| |
| var screenshots []string |
| var evals []interface{} |
|
|
| var networkMu sync.Mutex |
| var finalNetworkLogs []SniffedNetworkActivity |
|
|
| pageCtx, cancelPage := context.WithTimeout(context.Background(), time.Duration(tabTimeout)*time.Second) |
| defer cancelPage() |
|
|
| scopedBrowser := globalBrowser.Context(pageCtx) |
| p := stealth.MustPage(scopedBrowser) |
| defer p.MustClose() |
|
|
| _ = p.MustSetExtraHeaders("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") |
| _ = p.MustSetExtraHeaders("Accept-Language", "en-US,en;q=0.9") |
| _ = p.MustSetExtraHeaders("Sec-Ch-Ua", `"Chromium";v="124", "Not A(Brand";v="24", "Google Chrome";v="124"`) |
| _ = p.MustSetExtraHeaders("Sec-Ch-Ua-Mobile", "?0") |
| _ = p.MustSetExtraHeaders("Sec-Ch-Ua-Platform", `"Windows"`) |
|
|
| width := 1366 |
| height := 768 |
| if req.Width > 0 && req.Height > 0 { |
| width = req.Width |
| height = req.Height |
| } |
| _ = p.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ |
| Width: width, |
| Height: height, |
| }) |
|
|
| uaStr := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" |
| if req.UserAgent != "" { |
| uaStr = req.UserAgent |
| } |
| _ = p.SetUserAgent(&proto.NetworkSetUserAgentOverride{ |
| UserAgent: uaStr, |
| }) |
|
|
| if req.SniffNetwork { |
| maxSize := 1024 * 50 |
| if req.MaxSniffSize > 0 { |
| maxSize = req.MaxSniffSize |
| } |
|
|
| router := p.HijackRequests() |
| router.MustAdd("*", func(ctx *rod.Hijack) { |
| ctx.MustLoadResponse() |
| |
| res := ctx.Response |
| reqInfo := ctx.Request |
| |
| contentType := strings.ToLower(res.Headers().Get("Content-Type")) |
| isData := strings.Contains(contentType, "json") || |
| strings.Contains(contentType, "text/html") || |
| strings.Contains(contentType, "javascript") |
|
|
| activity := SniffedNetworkActivity{ |
| URL: reqInfo.URL().String(), |
| Method: reqInfo.Method(), |
| ResponseStatus: 200, |
| } |
|
|
| reqHeadersMentah, _ := json.Marshal(reqInfo.Headers()) |
| resHeadersMentah, _ := json.Marshal(res.Headers()) |
|
|
| reqHeadersMap := make(map[string]string) |
| resHeadersMap := make(map[string]string) |
|
|
| _ = json.Unmarshal(reqHeadersMentah, &reqHeadersMap) |
| _ = json.Unmarshal(resHeadersMentah, &resHeadersMap) |
|
|
| activity.RequestHeaders = reqHeadersMap |
| activity.ResponseHeaders = resHeadersMap |
|
|
| if isData { |
| body := ctx.Response.Body() |
| if len(body) > maxSize { |
| activity.ResponseBody = body[:maxSize] + " ... [TRUNCATED]" |
| } else { |
| activity.ResponseBody = body |
| } |
| } else { |
| activity.ResponseBody = "[SKIPPED: NON-DATA OR LARGE ASSET]" |
| } |
|
|
| networkMu.Lock() |
| finalNetworkLogs = append(finalNetworkLogs, activity) |
| networkMu.Unlock() |
| |
| ctx.ContinueRequest(&proto.FetchContinueRequest{}) |
| }) |
| go router.Run() |
| } |
|
|
| _, _ = p.Eval(` |
| (() => { |
| const tamperWebdriver = () => { |
| try { |
| Object.defineProperty(navigator, 'webdriver', { |
| get: () => false, |
| configurable: false, |
| enumerable: true |
| }); |
| } catch (e) {} |
| }; |
| |
| tamperWebdriver(); |
| |
| if (!window.chrome) { |
| Object.defineProperty(window, 'chrome', { |
| configurable: true, |
| enumerable: true, |
| value: { runtime: {} } |
| }); |
| } |
| |
| window.__turnstileToken = ''; |
| const hookTurnstile = () => { |
| if (!window.turnstile || window.turnstile.__hooked) return; |
| const origRender = window.turnstile.render.bind(window.turnstile); |
| window.turnstile.render = function(container, params) { |
| if (params && typeof params.callback === 'function') { |
| const origCb = params.callback; |
| params.callback = function(token) { |
| window.__turnstileToken = token; |
| return origCb(token); |
| }; |
| } |
| return origRender(container, params); |
| }; |
| window.turnstile.__hooked = true; |
| }; |
| Object.defineProperty(window, 'turnstile', { |
| configurable: true, |
| set(v) { this.__ts = v; setTimeout(hookTurnstile, 0); }, |
| get() { return this.__ts; } |
| }); |
| })(); |
| `) |
|
|
| for i, act := range req.Actions { |
| logMsg := fmt.Sprintf("Step %d: [%s]", i+1, act.Type) |
| if act.Selector != "" { |
| logMsg += fmt.Sprintf(" -> Target: %s", act.Selector) |
| } |
| logs = append(logs, logMsg) |
|
|
| var err error |
|
|
| switch act.Type { |
| case "navigate": |
| err = p.Navigate(act.Value) |
| if err == nil { |
| _ = p.WaitDOMStable(500*time.Millisecond, 0.1) |
| if req.BlockImages { |
| injectStyleScript := `const style = document.createElement('style'); style.innerText = 'img, svg, picture, [style*="background-image"] { display: none !important; }'; document.head.appendChild(style);` |
| _, _ = p.Eval(injectStyleScript) |
| } |
| } |
|
|
| case "wait": |
| _, err = p.Element(act.Selector) |
|
|
| case "click": |
| var el *rod.Element |
| el, err = p.Element(act.Selector) |
| if err == nil { |
| err = el.Click(proto.InputMouseButtonLeft, 1) |
| } |
|
|
| case "type": |
| var el *rod.Element |
| el, err = p.Element(act.Selector) |
| if err == nil { |
| err = el.Input(act.Value) |
| } |
|
|
| case "clear": |
| var el *rod.Element |
| el, err = p.Element(act.Selector) |
| if err == nil { |
| err = el.SelectAllText() |
| if err == nil { |
| err = el.Input("") |
| } |
| } |
|
|
| case "hover": |
| var el *rod.Element |
| el, err = p.Element(act.Selector) |
| if err == nil { |
| err = el.Hover() |
| } |
|
|
| case "select": |
| var el *rod.Element |
| el, err = p.Element(act.Selector) |
| if err == nil { |
| err = el.Select([]string{act.Value}, true, rod.SelectorTypeRegex) |
| } |
|
|
| case "key": |
| jsInputScript := fmt.Sprintf(`(() => { const activeEl = document.activeElement; if (activeEl) { activeEl.value += %q; activeEl.dispatchEvent(new Event('input', { bubbles: true })); activeEl.dispatchEvent(new Event('change', { bubbles: true })); } })()`, act.Value) |
| _, err = p.Eval(jsInputScript) |
|
|
| case "scroll": |
| if act.Selector != "" { |
| var el *rod.Element |
| el, err = p.Element(act.Selector) |
| if err == nil { |
| err = el.ScrollIntoView() |
| } |
| } else { |
| valInt, _ := strconv.Atoi(act.Value) |
| _, err = p.Eval(fmt.Sprintf("window.scrollTo(0, %d);", valInt)) |
| } |
|
|
| case "setLocation": |
| coords := strings.Split(act.Value, ",") |
| lat, _ := strconv.ParseFloat(coords[0], 64) |
| long, _ := strconv.ParseFloat(coords[1], 64) |
| accuracy := float64(100) |
| _ = p.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ |
| Width: width, |
| Height: height, |
| }) |
| err = proto.EmulationSetGeolocationOverride{ |
| Latitude: &lat, |
| Longitude: &long, |
| Accuracy: &accuracy, |
| }.Call(p) |
|
|
| case "evaluate": |
| var jsResult *proto.RuntimeRemoteObject |
| jsResult, err = p.Eval(act.Value) |
| if err == nil && jsResult != nil { |
| evals = append(evals, jsResult.Value.Val()) |
| } |
|
|
| case "extractTurnstile": |
| var iframe *rod.Element |
| for retry := 0; retry < 15; retry++ { |
| iframe, _ = p.Element(`iframe[src*="challenges.cloudflare.com"], iframe[title*="verification"], .cf-turnstile`) |
| if iframe != nil { |
| break |
| } |
| time.Sleep(500 * time.Millisecond) |
| } |
|
|
| if iframe != nil { |
| _ = iframe.ScrollIntoView() |
| err = iframe.Click(proto.InputMouseButtonLeft, 1) |
| } |
|
|
| var tokenVal *proto.RuntimeRemoteObject |
| harvestScript := ` |
| (() => { |
| if (window.__turnstileToken && window.__turnstileToken.length > 10) return window.__turnstileToken; |
| let input = document.querySelector('input[name="cf-turnstile-response"]') || |
| document.querySelector('input[name="g-recaptcha-response"]') || |
| document.getElementById('cf-turnstile-response'); |
| return input ? input.value : ""; |
| })() |
| ` |
| for retry := 0; retry < 30; retry++ { |
| tokenVal, err = p.Eval(harvestScript) |
| if err == nil && tokenVal != nil && tokenVal.Value.String() != "" { |
| break |
| } |
| time.Sleep(700 * time.Millisecond) |
| } |
|
|
| if tokenVal != nil && tokenVal.Value.String() != "" { |
| results["turnstileToken"] = tokenVal.Value.String() |
| } else { |
| results["turnstileToken"] = "FAILED_OR_TIMEOUT" |
| } |
|
|
| case "screenshot": |
| var buf []byte |
| if act.Selector != "" { |
| var el *rod.Element |
| el, err = p.Element(act.Selector) |
| if err == nil { |
| buf, err = el.Screenshot(proto.PageCaptureScreenshotFormatPng, 100) |
| } |
| } else { |
| buf = p.MustScreenshot("") |
| } |
|
|
| if err == nil && len(buf) > 0 { |
| name := act.FileName |
| if name == "" { |
| name = fmt.Sprintf("ss-%d.png", time.Now().UnixNano()) |
| } |
| name = strings.ReplaceAll(name, " ", "_") |
| filePath := filepath.Join(tmpDir, name) |
| err = os.WriteFile(filePath, buf, 0644) |
| if err == nil { |
| screenshots = append(screenshots, "/tmp/"+name) |
| } |
| } |
|
|
| case "screenshotFullPage": |
| buf := p.MustScreenshotFullPage() |
| if len(buf) > 0 { |
| name := act.FileName |
| if name == "" { |
| name = fmt.Sprintf("fullss-%d.png", time.Now().UnixNano()) |
| } |
| name = strings.ReplaceAll(name, " ", "_") |
| filePath := filepath.Join(tmpDir, name) |
| err = os.WriteFile(filePath, buf, 0644) |
| if err == nil { |
| screenshots = append(screenshots, "/tmp/"+name) |
| } |
| } |
| } |
|
|
| if err != nil { |
| statsMu.Lock() |
| totalErrors++ |
| statsMu.Unlock() |
| returnError(w, logs, fmt.Errorf("failed in step %d (%s): %w", i+1, act.Type, err)) |
| return |
| } |
| } |
|
|
| results["screenshots"] = screenshots |
| results["evals"] = evals |
| if req.SniffNetwork { |
| results["networkLogs"] = finalNetworkLogs |
| } |
|
|
| w.Header().Set("Content-Type", "application/json") |
| json.NewEncoder(w).Encode(StandardResponse{ |
| Logs: logs, |
| Result: results, |
| }) |
| } |
|
|
| func statsHandler(w http.ResponseWriter, r *http.Request) { |
| var m runtime.MemStats |
| runtime.ReadMemStats(&m) |
|
|
| pagesCount := 0 |
| if globalBrowser != nil { |
| pages, err := globalBrowser.Pages() |
| if err == nil { |
| pagesCount = len(pages) |
| } |
| } |
|
|
| statsMu.Lock() |
| res := StatsResponse{ |
| Uptime: time.Since(startTime).String(), |
| TotalRequests: totalRequests, |
| TotalErrors: totalErrors, |
| ActiveTabs: activeTabs, |
| OpenPagesCount: pagesCount, |
| GoVersion: runtime.Version(), |
| NumGoroutine: runtime.NumGoroutine(), |
| AllocMemoryMB: m.Alloc / 1024 / 1024, |
| TotalAllocMB: m.TotalAlloc / 1024 / 1024, |
| SysMemoryMB: m.Sys / 1024 / 1024, |
| NumGC: m.NumGC, |
| } |
| statsMu.Unlock() |
|
|
| w.Header().Set("Content-Type", "application/json") |
| json.NewEncoder(w).Encode(res) |
| } |
|
|
| func returnError(w http.ResponseWriter, logs []string, err error) { |
| w.Header().Set("Content-Type", "application/json") |
| w.WriteHeader(http.StatusInternalServerError) |
| json.NewEncoder(w).Encode(StandardResponse{ |
| Logs: logs, |
| Error: err.Error(), |
| }) |
| } |