Tools: Ultimate Guide: Building a Google Ads Click Automation Platform
Building a Google Ads Click Automation Platform
Table of Contents
Introduction
What You'll Build
Architecture Overview
Prerequisites
Directory Structure
Step 1: Docker Infrastructure
Step 2: Database Schema
Schema Design Notes
Step 3: Environment Configuration
Step 4: Browser Utilities
Why These Functions Matter
Step 5: Stealth and Anti-Detection
The Detection Surface
Choosing Patchright Over Playwright
The Stealth Configuration
Mobile User Agents
Country-Aware Google Domains
Language Arrays Per Country
The Core Stealth Script
Why Each Patch Exists
Testing Your Stealth Setup
Step 6: Click Worker
Key Design Decisions in the Click Worker
Step 7: API Server
API Endpoint Summary
Step 8: Management Panel
Step 9: Proxy Integration
Choosing a Provider
Adding Proxies via API
AnyIP Session Rotation
Verifying Proxy Connectivity
Step 10: Caddy Reverse Proxy
Generating the Password Hash
IP-Based Access (No Domain)
Step 11: Systemd Service and xvfb
Node.js Dependencies
Wrapper Script
Systemd Unit
Running Your First Campaign
1. Add a Proxy
2. Create a Click Target
3. Launch a Campaign
4. Monitor Execution
5. Scale Up
Monitoring and Debugging
Checking Service Health
Common Failure Patterns
Log Monitoring
Database Queries for Debugging
Gotchas and Hard-Won Lessons
1. HeadlessChrome in User-Agent = Instant CAPTCHA
2. window.chrome = false is a Bot Signal
3. navigator.plugins = 0 is a Bot Signal
4. --disable-blink-features=AutomationControlled BREAKS Patchright
5. Do NOT Add &hl=en&gl=uk to Search URLs
6. Set Proxy at Context Level, Not Browser Level
7. AnyIP Commas in Username Break curl
8. Google Consent Dialog Blocks Everything in the EU
9. Scrolling Matters for Ad Rendering
10. One Browser at a Time
Security Considerations
Protect Your Panel
Protect Proxy Credentials
Protect the .env File
Rotate Proxy Credentials Regularly
Monitor Costs
Log Retention
What's Next Patchright + BullMQ + Residential Proxies + Stealth Anti-Detection Google Ads dominates search advertising. When someone searches for "emergency plumber London" and clicks the top ad, the advertiser pays anywhere from two to fifty dollars for that single click. That economic reality creates two legitimate use cases for click automation: Both modes require solving the same hard technical problem: making automated browser sessions look indistinguishable from a real human using a real phone on a real mobile network. Google's fraud detection is among the most sophisticated bot-detection systems ever built. It fingerprints the browser engine, checks JavaScript execution environments, analyzes mouse movement patterns, correlates IP reputation databases, and flags anything that smells like automation. This tutorial builds a complete platform for managing click campaigns at scale. It is based on a production system that ran campaigns across multiple European countries using residential mobile proxies, with a centralized management panel and job queue. Every piece of code comes from that deployment, sanitized and annotated. The hard part is not clicking links. The hard part is not getting caught. The stealth and anti-detection section of this tutorial is where the real value lives -- it documents weeks of debugging browser fingerprints, analyzing CAPTCHA triggers, and iterating on evasion techniques until the detection rate dropped to near zero. When you finish this tutorial, you will have: Why Patchright outside Docker? Patchright (a Playwright fork with anti-detection patches) needs a real display server for headless: false mode. Running it under xvfb-run on the host gives it a virtual framebuffer that satisfies this requirement. Running browsers inside Docker containers adds layers of complexity with display forwarding, shared memory, and font rendering -- all of which can introduce detectable fingerprint anomalies. Why BullMQ? Click jobs need retry logic, concurrency control, rate limiting, and persistence across restarts. BullMQ backed by Redis gives all of this out of the box. A single worker processes one click at a time (concurrency: 1) because running parallel browser sessions from the same IP is an obvious bot signal. Install system dependencies: Create the directory tree: The Docker stack runs three services: PostgreSQL for persistent data, Redis for the job queue, and Caddy as a reverse proxy with basic auth. Create /opt/automation/docker-compose.yml: Key design decisions: Start the Docker stack: Verify all three containers are running: The schema tracks five core entities: cities (for geolocation spoofing), accounts (Google accounts for authenticated sessions), click targets (keywords + URLs to click), clicks (execution log), and proxies (residential IP endpoints). Create /opt/automation/db-init/01-schema.sql: If you already started PostgreSQL before creating this file, load the schema manually: Why a separate cities table? Every click must appear to come from a specific geographic location. The browser context needs a timezone, locale, language, and GPS coordinates that all match. Storing this in a lookup table means you can add cities without touching code, and the click worker can resolve coordinates by city name at runtime. Why blacklisted_ips? When a proxy IP triggers a Google CAPTCHA, that IP is burned. The system records it and never uses it again. Over time, this table becomes a valuable dataset of known-bad proxy IPs. The getCleanIP() function checks every proxy IP against this blacklist before using it. Why TEXT[] for links_clicked? PostgreSQL native arrays let you store the list of URLs clicked in a single row without a join table. For analytics queries, you can use array_length(links_clicked, 1) to count clicks per session or unnest(links_clicked) to expand them into rows. Create /opt/automation/.env: Replace all YOUR_* placeholders with real values. Generate strong passwords: This module contains the shared functions used by both the API server and the click worker: database connections, proxy resolution, IP blacklisting, Google consent handling, and navigation helpers. Create /opt/automation/service/browser-utils.js: handleGoogleConsent() is critical. In the EU, Google shows a full-page cookie consent dialog before any search results. If your automation does not dismiss it, every click session fails silently. The function tries seven different selectors covering English, Italian, French, and German consent buttons, plus fallback CSS selectors. Order matters -- the localized text buttons are tried first because they are more reliable than generic CSS selectors. getCleanIP() implements the retry loop that keeps your operation running. When a proxy IP gets CAPTCHAed, it gets blacklisted and the function rotates to a new session. With residential proxies charging per-GB, you want to avoid burning bandwidth on IPs that will just hit CAPTCHAs. navigateWithConsent() wraps every Google navigation with timeout retry and automatic consent handling. The two-attempt retry with a random delay between 2-4 seconds mimics a human refreshing a slow page. This is the core of the platform. Google runs sophisticated bot detection on every search page. Getting this wrong means every click triggers a CAPTCHA, which means every click fails and your proxy IPs get burned. This section documents exactly what Google checks and how to defeat each check. When a browser loads a Google search page, JavaScript probes the execution environment for signs of automation. Here is what Google (and similar anti-bot systems) look for: Patchright is a fork of Playwright that patches Chromium to remove automation signals at the browser level. Regular Playwright sets navigator.webdriver = true and leaves Chrome DevTools Protocol (CDP) markers in the JavaScript global scope. Patchright patches these out before the browser starts. However, Patchright alone is not enough. Even with Patchright: You need additional addInitScript patches on top of Patchright to fully pass detection. Here is the complete stealth implementation. Every line exists because removing it caused CAPTCHAs in testing. Why mobile? Mobile browsers have a simpler fingerprint surface than desktop. There is no window.speechSynthesis, fewer plugin expectations, and Google's mobile search page runs lighter anti-bot checks. Mobile viewports also look more natural with residential mobile proxy IPs. This is the addInitScript payload that patches the browser environment before any page JavaScript runs. It executes in every frame and every navigation within the browser context. window.chrome: This was the number one CAPTCHA trigger during development. Without it, Google's anti-bot script detects a headless environment within milliseconds. The object does not need to be functionally complete -- it just needs to exist with the expected shape. Google's detection checks typeof window.chrome === 'object' and probes for chrome.runtime. navigator.plugins: The second most common trigger. A real Chrome install always reports at least the PDF plugins and Native Client. Length zero is a dead giveaway. The item() and namedItem() methods are required because some detection scripts call them instead of indexing the array directly. navigator.languages: A browser on a UK mobile IP reporting ["en-US"] is suspicious. The language array must be consistent with the proxy's geolocation. This is a correlation check -- individually these signals are weak, but combined with other mismatches they push the bot-detection score over the threshold. CDP markers: Chrome DevTools Protocol injects global variables into the page context for communication with the automation framework. These variables have distinctive prefixes (cdc_) that are well-documented in bot detection literature. Patchright removes some but not all of these markers. WebGL renderer: Google's fingerprinting reads the GPU information via WebGL. Software renderers like SwiftShader (used in headless Chrome) are uncommon on real devices. Reporting a Qualcomm Adreno GPU is consistent with the Android mobile user-agents we use. Before running real campaigns, verify your stealth patches work. Create a quick test script: Check the screenshot. You should see green checkmarks for all major detection vectors. Red flags on webdriver, chrome, or plugins mean your patches are not applying correctly. The click worker is the engine of the platform. It receives jobs from the BullMQ queue, launches a stealth browser session through a residential proxy, searches Google, identifies sponsored results, clicks the right ones based on the campaign mode, and logs everything to the database. Create /opt/automation/service/click-worker.js: Why concurrency: 1? Running two browser instances simultaneously from the same server means two Google searches from the same origin within seconds. Even with different proxy IPs, the timing correlation is detectable. One at a time is safer and simpler to debug. Why headless: false instead of headless: true? Headless mode in Chromium, even with Patchright, leaves subtle fingerprint differences. The headless: false flag launches a real Chrome window that needs a display server -- that is what xvfb provides. The overhead is minimal and the stealth improvement is significant. Why proxy at context level, not browser level? Patchright (like Playwright) lets you set proxy at browser launch or at context creation. Setting it at the context level means you can create a new context with a different proxy without restarting the entire browser. It also ensures the proxy is applied to all requests within that context, including service workers and iframes. The CAPTCHA retry loop: When a CAPTCHA is detected, the current proxy IP is blacklisted and a new one is acquired. The browser is fully closed and relaunched -- not just navigated to a new page. This ensures no session state from the CAPTCHAed attempt leaks into the retry. The Express API server serves the management panel, provides CRUD endpoints for campaigns and proxies, and queues click jobs into BullMQ. Create /opt/automation/service/server.js: The management panel is a single-page application served as a static HTML file. It uses a dark theme, vanilla JavaScript (no framework dependencies), and communicates with the API endpoints defined above. Create /opt/automation/service/panel/index.html: The panel gives you single-pane visibility into the entire platform: create targets, launch campaigns, monitor click execution, manage proxies, and track blacklisted IPs. The 15-second auto-refresh means you can watch campaigns execute in near-real-time. Residential proxies are the backbone of the platform. Without them, every click comes from your server's IP, which Google will flag after the first few searches. You need residential mobile proxies with: Providers that work well for this use case: AnyIP uses a unique session rotation mechanism: you append a session identifier to the username with a comma separator. Each unique session ID routes through a different exit IP. The getProxy() function in browser-utils.js handles this automatically. Each click gets a unique session ID, which means a fresh exit IP. Important: The comma in the username breaks curl testing (curl interprets commas in URLs) but Patchright handles it correctly through its proxy configuration. Do not waste time debugging proxy auth failures in curl -- test directly through the browser. Test a proxy endpoint from your server: If the returned IP is different from your server IP and geolocates to the target country, the proxy is working. Caddy provides automatic HTTPS and basic authentication in front of the Node.js service. This keeps the management panel and API protected without implementing auth in the application layer. Create /opt/automation/caddy/Caddyfile: If you have a domain name pointed at your server, replace YOUR_SERVER_IP with your domain (e.g., automation.example.com) and Caddy will automatically provision a Let's Encrypt certificate. Caddy requires bcrypt-hashed passwords. Generate one inside the Caddy container: Copy the output and replace YOUR_HASHED_PASSWORD in the Caddyfile. If you do not have a domain and want to access via IP with HTTP: Restart Caddy after editing: The Node.js service must run under xvfb-run so that Patchright's headless: false mode has a virtual display to render into. We run the API server and click worker as separate processes under one systemd unit using a wrapper script. Create /opt/automation/service/start.sh: Create /etc/systemd/system/automation.service: With everything running, here is the end-to-end workflow for executing a click campaign. Navigate to the panel (via Caddy URL or http://YOUR_SERVER_IP/panel/) and go to the Proxies tab. Add your residential proxy credentials. In the panel Targets tab, fill in: Click the Run button next to your target. Enter the number of clicks (start with 1 for testing). The click is queued into BullMQ. Watch the Click Log tab. Within 30-60 seconds, you should see the click appear with status completed and the list of URLs that were clicked. Check the system logs for detailed output: You will see output like: Once a single click works, increase the count. The worker processes one at a time with random delays between jobs. For sustained campaigns, set up recurring jobs using a cron or the BullMQ repeatable jobs feature. All clicks show status error with "CAPTCHA on all IPs":
Your proxy IPs are burned, or your stealth patches are not applying correctly. Clear the blacklist, check your user-agent strings, and verify stealth with the test-stealth.js script. Clicks show no_ads:No sponsored results matched your target link. Check the keyword -- some searches have no ads. Also verify the target_link value is a substring of the actual ad URL that Google shows. Browser crashes immediately:Check that xvfb is running (ps aux | grep Xvfb). Ensure you installed Patchright's Chromium (npx patchright install chromium). Check available RAM -- each browser session uses 200-400 MB. "IP PROTECTION FAILED" errors:
No active proxies in the database for the target country. Add proxies via the Proxies tab or API. The country string must match exactly (the query uses UPPER() comparison). These are problems that cost hours of debugging. Learn from them. Patchright 1.57.0 in headless mode injects HeadlessChrome into the default user-agent string. This is the single biggest bot signal. Google's front-line bot detection checks the UA string before running any JavaScript fingerprinting. If it sees HeadlessChrome, the response is an immediate CAPTCHA page. Fix: Always set an explicit userAgent in the browser context. Never rely on the default. If your stealth script sets window.chrome to a falsy value (or fails to set it at all), detection scripts see this as confirmation of a headless environment. Real Chrome always has window.chrome as a truthy object. Fix: Use Object.defineProperty with a getter that returns a proper object, not a direct assignment. An empty plugins array (length 0) is the default in headless Chrome and is checked by virtually every bot detection library. Real Chrome always has at least 3 plugins (Chrome PDF Plugin, Chrome PDF Viewer, Native Client). Fix: Override navigator.plugins with a proper array that includes item() and namedItem() methods. This Chromium flag is commonly recommended for bypassing bot detection with standard Playwright/Puppeteer. However, Patchright already patches the automation signals at a lower level. Adding this flag on top of Patchright's patches causes conflicts that actually re-enable some detection vectors. Fix: Do not pass this flag when using Patchright. Let Patchright handle it. Real humans do not manually append language and geolocation parameters to Google search URLs. They just go to google.co.uk and search. Adding &hl= and &gl= parameters is a behavioral fingerprint that marks the request as programmatic. Fix: Use the correct country-specific Google domain (google.co.uk, google.it, etc.) and let the browser's language settings and proxy geolocation handle localization naturally. Setting the proxy at chromium.launch() applies it to the entire browser process, including internal Chrome traffic. Setting it at browser.newContext() applies it only to page navigation, which is cleaner and allows per-context proxy rotation without restarting the browser. Fix: Always pass proxy configuration in browser.newContext(). AnyIP's session rotation uses commas in the username (e.g., user,session_s12345). When testing with curl -x, the comma gets misinterpreted as a URL delimiter, causing authentication failures. You might spend an hour thinking the proxy credentials are wrong. Fix: Patchright handles commas in proxy usernames correctly. Test proxy connectivity directly through the browser, not through curl. If you must use curl, URL-encode the comma as %2C. If your proxy exits through an EU IP, Google shows a full-page GDPR cookie consent dialog. If you do not dismiss it, the search results never load. Your automation sees an empty page with no ads. Fix: The handleGoogleConsent() function tries multiple localized button texts. Run it after every Google navigation. Google lazy-loads some sponsored results. If you parse the DOM immediately after page load, you may miss ads that appear below the fold. The scrollAndExpand() function scrolls down gradually and clicks "Show more" buttons. Fix: Always scroll the page before parsing sponsored results. Running multiple browser instances in parallel seems like an obvious way to increase throughput. In practice, it increases detection risk dramatically. Two searches from the same server within seconds, even through different proxies, creates timing correlations that Google can detect. Fix: Keep BullMQ concurrency at 1. Scale by adding more servers, not more concurrent browsers per server. The management panel exposes full control over campaigns, proxies, and accounts. At minimum: Proxy credentials in the database are stored in plain text. This is a tradeoff for simplicity -- the click worker needs the raw password to authenticate. Mitigate the risk: If you suspect your proxy credentials are compromised, rotate them immediately. Compromised proxy credentials mean someone else can burn through your paid bandwidth. Residential proxy bandwidth is metered. A single click session uses 2-10 MB depending on the landing page. At $2/GB, that is $0.004-$0.02 per click. Set up alerts with your proxy provider for unusual bandwidth spikes. Click logs grow over time. Add a cleanup cron: Once the basic platform is running, here are natural extensions: This tutorial documents a production system built through extensive trial and error with browser fingerprinting and anti-detection techniques. The stealth configuration represents the state of the art as of early 2026 -- Google continuously updates its detection, so individual techniques may need refreshing over time. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse
Internet | [ Caddy ] :80/:443 basic auth | +----------+----------+ | | /panel/* (SPA) /api/* (proxy) | | +----------+----------+ | [ Node.js Service ] :3500 Express API + BullMQ | +-------------+-------------+ | | | [ PostgreSQL ] [ Redis ] [ Patchright ] :5432 :6379 via xvfb (Docker) (Docker) (host process) | [ Residential Proxy ] | [ Google Search ]
Internet | [ Caddy ] :80/:443 basic auth | +----------+----------+ | | /panel/* (SPA) /api/* (proxy) | | +----------+----------+ | [ Node.js Service ] :3500 Express API + BullMQ | +-------------+-------------+ | | | [ PostgreSQL ] [ Redis ] [ Patchright ] :5432 :6379 via xvfb (Docker) (Docker) (host process) | [ Residential Proxy ] | [ Google Search ]
Internet | [ Caddy ] :80/:443 basic auth | +----------+----------+ | | /panel/* (SPA) /api/* (proxy) | | +----------+----------+ | [ Node.js Service ] :3500 Express API + BullMQ | +-------------+-------------+ | | | [ PostgreSQL ] [ Redis ] [ Patchright ] :5432 :6379 via xvfb (Docker) (Docker) (host process) | [ Residential Proxy ] | [ Google Search ]
# Update and install essentials
apt update && apt install -y xvfb curl git # Install Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs # Install Docker
curl -fsSL https://get.docker.com | bash
# Update and install essentials
apt update && apt install -y xvfb curl git # Install Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs # Install Docker
curl -fsSL https://get.docker.com | bash
# Update and install essentials
apt update && apt install -y xvfb curl git # Install Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs # Install Docker
curl -fsSL https://get.docker.com | bash
/opt/automation/
├── docker-compose.yml
├── .env
├── caddy/
│ └── Caddyfile
├── db-init/
│ └── 01-schema.sql
├── service/
│ ├── package.json
│ ├── server.js
│ ├── click-worker.js
│ ├── browser-utils.js
│ └── panel/
│ └── index.html
└── data/ ├── profiles/ └── screenshots/
/opt/automation/
├── docker-compose.yml
├── .env
├── caddy/
│ └── Caddyfile
├── db-init/
│ └── 01-schema.sql
├── service/
│ ├── package.json
│ ├── server.js
│ ├── click-worker.js
│ ├── browser-utils.js
│ └── panel/
│ └── index.html
└── data/ ├── profiles/ └── screenshots/
/opt/automation/
├── docker-compose.yml
├── .env
├── caddy/
│ └── Caddyfile
├── db-init/
│ └── 01-schema.sql
├── service/
│ ├── package.json
│ ├── server.js
│ ├── click-worker.js
│ ├── browser-utils.js
│ └── panel/
│ └── index.html
└── data/ ├── profiles/ └── screenshots/
mkdir -p /opt/automation/{caddy,db-init,service/panel,data/{profiles,screenshots}}
cd /opt/automation
mkdir -p /opt/automation/{caddy,db-init,service/panel,data/{profiles,screenshots}}
cd /opt/automation
mkdir -p /opt/automation/{caddy,db-init,service/panel,data/{profiles,screenshots}}
cd /opt/automation
services: caddy: image: caddy:2-alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./caddy/Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - caddy_config:/config networks: - automation postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: automation POSTGRES_PASSWORD: YOUR_DB_PASSWORD POSTGRES_DB: automation ports: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./db-init:/docker-entrypoint-initdb.d networks: - automation redis: image: redis:7-alpine restart: unless-stopped command: > redis-server --requirepass YOUR_REDIS_PASSWORD --maxmemory-policy noeviction --appendonly yes ports: - "127.0.0.1:6379:6379" volumes: - redis_data:/data networks: - automation volumes: postgres_data: redis_data: caddy_data: caddy_config: networks: automation: driver: bridge
services: caddy: image: caddy:2-alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./caddy/Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - caddy_config:/config networks: - automation postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: automation POSTGRES_PASSWORD: YOUR_DB_PASSWORD POSTGRES_DB: automation ports: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./db-init:/docker-entrypoint-initdb.d networks: - automation redis: image: redis:7-alpine restart: unless-stopped command: > redis-server --requirepass YOUR_REDIS_PASSWORD --maxmemory-policy noeviction --appendonly yes ports: - "127.0.0.1:6379:6379" volumes: - redis_data:/data networks: - automation volumes: postgres_data: redis_data: caddy_data: caddy_config: networks: automation: driver: bridge
services: caddy: image: caddy:2-alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./caddy/Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data - caddy_config:/config networks: - automation postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: automation POSTGRES_PASSWORD: YOUR_DB_PASSWORD POSTGRES_DB: automation ports: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./db-init:/docker-entrypoint-initdb.d networks: - automation redis: image: redis:7-alpine restart: unless-stopped command: > redis-server --requirepass YOUR_REDIS_PASSWORD --maxmemory-policy noeviction --appendonly yes ports: - "127.0.0.1:6379:6379" volumes: - redis_data:/data networks: - automation volumes: postgres_data: redis_data: caddy_data: caddy_config: networks: automation: driver: bridge
cd /opt/automation
docker compose up -d
cd /opt/automation
docker compose up -d
cd /opt/automation
docker compose up -d
docker compose ps
docker compose ps
docker compose ps
-- Cities: used for timezone, locale, and geolocation spoofing
CREATE TABLE cities ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, country VARCHAR(50) NOT NULL, latitude NUMERIC(9,6) NOT NULL, longitude NUMERIC(9,6) NOT NULL, timezone_id VARCHAR(50) NOT NULL, locale VARCHAR(10) NOT NULL, language VARCHAR(5) NOT NULL, UNIQUE(name, country)
); -- Seed essential cities
INSERT INTO cities (name, country, latitude, longitude, timezone_id, locale, language) VALUES ('London', 'UK', 51.507351, -0.127758, 'Europe/London', 'en-GB', 'en'), ('Manchester', 'UK', 53.483959, -2.244644, 'Europe/London', 'en-GB', 'en'), ('Birmingham', 'UK', 52.486243, -1.890401, 'Europe/London', 'en-GB', 'en'), ('Leeds', 'UK', 53.800755, -1.549077, 'Europe/London', 'en-GB', 'en'), ('Glasgow', 'UK', 55.864237, -4.251806, 'Europe/London', 'en-GB', 'en'), ('Rome', 'Italy', 41.902782, 12.496366, 'Europe/Rome', 'it-IT', 'it'), ('Milan', 'Italy', 45.464203, 9.189982, 'Europe/Rome', 'it-IT', 'it'), ('Naples', 'Italy', 40.851775, 14.268124, 'Europe/Rome', 'it-IT', 'it'), ('Paris', 'France', 48.856614, 2.352222, 'Europe/Paris', 'fr-FR', 'fr'), ('Lyon', 'France', 45.764043, 4.835659, 'Europe/Paris', 'fr-FR', 'fr'), ('Berlin', 'Germany', 52.520007, 13.404954, 'Europe/Berlin', 'de-DE', 'de'), ('Munich', 'Germany', 48.135125, 11.581981, 'Europe/Berlin', 'de-DE', 'de'); -- Google accounts for authenticated sessions
CREATE TABLE accounts ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255), purpose VARCHAR(20) NOT NULL DEFAULT 'click', country VARCHAR(50), city VARCHAR(100), session_valid BOOLEAN DEFAULT false, session_verified_at TIMESTAMPTZ, last_click_at TIMESTAMPTZ, total_clicks INTEGER DEFAULT 0, status VARCHAR(20) DEFAULT 'active', totp_secret VARCHAR(255), backup_codes TEXT[], created_at TIMESTAMPTZ DEFAULT now()
); -- Click targets: what to search for and what to click
CREATE TABLE click_targets ( id SERIAL PRIMARY KEY, keyword VARCHAR(255) NOT NULL, country VARCHAR(50) NOT NULL, city VARCHAR(100) NOT NULL, coordinates_lat NUMERIC(9,6), coordinates_lng NUMERIC(9,6), target_link TEXT NOT NULL, mode VARCHAR(10) NOT NULL CHECK (mode IN ('boost', 'drain')), current_position INTEGER, total_clicks INTEGER DEFAULT 0, status VARCHAR(20) DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT now()
); -- Click execution log
CREATE TABLE clicks ( id SERIAL PRIMARY KEY, click_target_id INTEGER REFERENCES click_targets(id) ON DELETE SET NULL, campaign_id INTEGER, account_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL, keyword VARCHAR(255), mode VARCHAR(10), links_clicked TEXT[], dwell_time_seconds INTEGER, phone_clicked BOOLEAN DEFAULT false, vpn_ip VARCHAR(45), vpn_country VARCHAR(50), status VARCHAR(20) DEFAULT 'pending', screenshot_path VARCHAR(255), error_message TEXT, created_at TIMESTAMPTZ DEFAULT now()
); -- Residential proxy endpoints
CREATE TABLE proxies ( id SERIAL PRIMARY KEY, provider VARCHAR(50) NOT NULL, geo VARCHAR(50), city VARCHAR(100), endpoint VARCHAR(255) NOT NULL, username VARCHAR(100), password VARCHAR(100), active BOOLEAN DEFAULT true
); -- IPs that triggered CAPTCHAs -- never use again
CREATE TABLE blacklisted_ips ( id SERIAL PRIMARY KEY, ip VARCHAR(45) UNIQUE NOT NULL, reason TEXT NOT NULL, country VARCHAR(50), blocked_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_blacklist_ip ON blacklisted_ips(ip); -- Audit trail of all IPs used
CREATE TABLE vpn_ips_used ( id SERIAL PRIMARY KEY, ip VARCHAR(45) NOT NULL, country VARCHAR(50), action_type VARCHAR(20), used_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_vpn_ip_country ON vpn_ips_used(ip, country);
-- Cities: used for timezone, locale, and geolocation spoofing
CREATE TABLE cities ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, country VARCHAR(50) NOT NULL, latitude NUMERIC(9,6) NOT NULL, longitude NUMERIC(9,6) NOT NULL, timezone_id VARCHAR(50) NOT NULL, locale VARCHAR(10) NOT NULL, language VARCHAR(5) NOT NULL, UNIQUE(name, country)
); -- Seed essential cities
INSERT INTO cities (name, country, latitude, longitude, timezone_id, locale, language) VALUES ('London', 'UK', 51.507351, -0.127758, 'Europe/London', 'en-GB', 'en'), ('Manchester', 'UK', 53.483959, -2.244644, 'Europe/London', 'en-GB', 'en'), ('Birmingham', 'UK', 52.486243, -1.890401, 'Europe/London', 'en-GB', 'en'), ('Leeds', 'UK', 53.800755, -1.549077, 'Europe/London', 'en-GB', 'en'), ('Glasgow', 'UK', 55.864237, -4.251806, 'Europe/London', 'en-GB', 'en'), ('Rome', 'Italy', 41.902782, 12.496366, 'Europe/Rome', 'it-IT', 'it'), ('Milan', 'Italy', 45.464203, 9.189982, 'Europe/Rome', 'it-IT', 'it'), ('Naples', 'Italy', 40.851775, 14.268124, 'Europe/Rome', 'it-IT', 'it'), ('Paris', 'France', 48.856614, 2.352222, 'Europe/Paris', 'fr-FR', 'fr'), ('Lyon', 'France', 45.764043, 4.835659, 'Europe/Paris', 'fr-FR', 'fr'), ('Berlin', 'Germany', 52.520007, 13.404954, 'Europe/Berlin', 'de-DE', 'de'), ('Munich', 'Germany', 48.135125, 11.581981, 'Europe/Berlin', 'de-DE', 'de'); -- Google accounts for authenticated sessions
CREATE TABLE accounts ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255), purpose VARCHAR(20) NOT NULL DEFAULT 'click', country VARCHAR(50), city VARCHAR(100), session_valid BOOLEAN DEFAULT false, session_verified_at TIMESTAMPTZ, last_click_at TIMESTAMPTZ, total_clicks INTEGER DEFAULT 0, status VARCHAR(20) DEFAULT 'active', totp_secret VARCHAR(255), backup_codes TEXT[], created_at TIMESTAMPTZ DEFAULT now()
); -- Click targets: what to search for and what to click
CREATE TABLE click_targets ( id SERIAL PRIMARY KEY, keyword VARCHAR(255) NOT NULL, country VARCHAR(50) NOT NULL, city VARCHAR(100) NOT NULL, coordinates_lat NUMERIC(9,6), coordinates_lng NUMERIC(9,6), target_link TEXT NOT NULL, mode VARCHAR(10) NOT NULL CHECK (mode IN ('boost', 'drain')), current_position INTEGER, total_clicks INTEGER DEFAULT 0, status VARCHAR(20) DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT now()
); -- Click execution log
CREATE TABLE clicks ( id SERIAL PRIMARY KEY, click_target_id INTEGER REFERENCES click_targets(id) ON DELETE SET NULL, campaign_id INTEGER, account_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL, keyword VARCHAR(255), mode VARCHAR(10), links_clicked TEXT[], dwell_time_seconds INTEGER, phone_clicked BOOLEAN DEFAULT false, vpn_ip VARCHAR(45), vpn_country VARCHAR(50), status VARCHAR(20) DEFAULT 'pending', screenshot_path VARCHAR(255), error_message TEXT, created_at TIMESTAMPTZ DEFAULT now()
); -- Residential proxy endpoints
CREATE TABLE proxies ( id SERIAL PRIMARY KEY, provider VARCHAR(50) NOT NULL, geo VARCHAR(50), city VARCHAR(100), endpoint VARCHAR(255) NOT NULL, username VARCHAR(100), password VARCHAR(100), active BOOLEAN DEFAULT true
); -- IPs that triggered CAPTCHAs -- never use again
CREATE TABLE blacklisted_ips ( id SERIAL PRIMARY KEY, ip VARCHAR(45) UNIQUE NOT NULL, reason TEXT NOT NULL, country VARCHAR(50), blocked_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_blacklist_ip ON blacklisted_ips(ip); -- Audit trail of all IPs used
CREATE TABLE vpn_ips_used ( id SERIAL PRIMARY KEY, ip VARCHAR(45) NOT NULL, country VARCHAR(50), action_type VARCHAR(20), used_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_vpn_ip_country ON vpn_ips_used(ip, country);
-- Cities: used for timezone, locale, and geolocation spoofing
CREATE TABLE cities ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, country VARCHAR(50) NOT NULL, latitude NUMERIC(9,6) NOT NULL, longitude NUMERIC(9,6) NOT NULL, timezone_id VARCHAR(50) NOT NULL, locale VARCHAR(10) NOT NULL, language VARCHAR(5) NOT NULL, UNIQUE(name, country)
); -- Seed essential cities
INSERT INTO cities (name, country, latitude, longitude, timezone_id, locale, language) VALUES ('London', 'UK', 51.507351, -0.127758, 'Europe/London', 'en-GB', 'en'), ('Manchester', 'UK', 53.483959, -2.244644, 'Europe/London', 'en-GB', 'en'), ('Birmingham', 'UK', 52.486243, -1.890401, 'Europe/London', 'en-GB', 'en'), ('Leeds', 'UK', 53.800755, -1.549077, 'Europe/London', 'en-GB', 'en'), ('Glasgow', 'UK', 55.864237, -4.251806, 'Europe/London', 'en-GB', 'en'), ('Rome', 'Italy', 41.902782, 12.496366, 'Europe/Rome', 'it-IT', 'it'), ('Milan', 'Italy', 45.464203, 9.189982, 'Europe/Rome', 'it-IT', 'it'), ('Naples', 'Italy', 40.851775, 14.268124, 'Europe/Rome', 'it-IT', 'it'), ('Paris', 'France', 48.856614, 2.352222, 'Europe/Paris', 'fr-FR', 'fr'), ('Lyon', 'France', 45.764043, 4.835659, 'Europe/Paris', 'fr-FR', 'fr'), ('Berlin', 'Germany', 52.520007, 13.404954, 'Europe/Berlin', 'de-DE', 'de'), ('Munich', 'Germany', 48.135125, 11.581981, 'Europe/Berlin', 'de-DE', 'de'); -- Google accounts for authenticated sessions
CREATE TABLE accounts ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255), purpose VARCHAR(20) NOT NULL DEFAULT 'click', country VARCHAR(50), city VARCHAR(100), session_valid BOOLEAN DEFAULT false, session_verified_at TIMESTAMPTZ, last_click_at TIMESTAMPTZ, total_clicks INTEGER DEFAULT 0, status VARCHAR(20) DEFAULT 'active', totp_secret VARCHAR(255), backup_codes TEXT[], created_at TIMESTAMPTZ DEFAULT now()
); -- Click targets: what to search for and what to click
CREATE TABLE click_targets ( id SERIAL PRIMARY KEY, keyword VARCHAR(255) NOT NULL, country VARCHAR(50) NOT NULL, city VARCHAR(100) NOT NULL, coordinates_lat NUMERIC(9,6), coordinates_lng NUMERIC(9,6), target_link TEXT NOT NULL, mode VARCHAR(10) NOT NULL CHECK (mode IN ('boost', 'drain')), current_position INTEGER, total_clicks INTEGER DEFAULT 0, status VARCHAR(20) DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT now()
); -- Click execution log
CREATE TABLE clicks ( id SERIAL PRIMARY KEY, click_target_id INTEGER REFERENCES click_targets(id) ON DELETE SET NULL, campaign_id INTEGER, account_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL, keyword VARCHAR(255), mode VARCHAR(10), links_clicked TEXT[], dwell_time_seconds INTEGER, phone_clicked BOOLEAN DEFAULT false, vpn_ip VARCHAR(45), vpn_country VARCHAR(50), status VARCHAR(20) DEFAULT 'pending', screenshot_path VARCHAR(255), error_message TEXT, created_at TIMESTAMPTZ DEFAULT now()
); -- Residential proxy endpoints
CREATE TABLE proxies ( id SERIAL PRIMARY KEY, provider VARCHAR(50) NOT NULL, geo VARCHAR(50), city VARCHAR(100), endpoint VARCHAR(255) NOT NULL, username VARCHAR(100), password VARCHAR(100), active BOOLEAN DEFAULT true
); -- IPs that triggered CAPTCHAs -- never use again
CREATE TABLE blacklisted_ips ( id SERIAL PRIMARY KEY, ip VARCHAR(45) UNIQUE NOT NULL, reason TEXT NOT NULL, country VARCHAR(50), blocked_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_blacklist_ip ON blacklisted_ips(ip); -- Audit trail of all IPs used
CREATE TABLE vpn_ips_used ( id SERIAL PRIMARY KEY, ip VARCHAR(45) NOT NULL, country VARCHAR(50), action_type VARCHAR(20), used_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_vpn_ip_country ON vpn_ips_used(ip, country);
docker compose exec -T postgres psql -U automation -d automation < db-init/01-schema.sql
docker compose exec -T postgres psql -U automation -d automation < db-init/01-schema.sql
docker compose exec -T postgres psql -U automation -d automation < db-init/01-schema.sql
# Service
PORT=3500 # Database
DATABASE_URL=postgresql://automation:[email protected]:5432/automation # Redis (BullMQ job queue)
REDIS_URL=redis://:[email protected]:6379 # Data directories
PROFILES_DIR=/opt/automation/data/profiles
SCREENSHOTS_DIR=/opt/automation/data/screenshots # IP protection mode: "proxy" or "vpn"
PROXY_MODE=proxy
VPN_ENABLED=false # Telegram alerts (optional)
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= # Panel credentials (used by Caddy basic auth)
PANEL_USER=admin
PANEL_PASS=YOUR_PANEL_PASSWORD
# Service
PORT=3500 # Database
DATABASE_URL=postgresql://automation:[email protected]:5432/automation # Redis (BullMQ job queue)
REDIS_URL=redis://:[email protected]:6379 # Data directories
PROFILES_DIR=/opt/automation/data/profiles
SCREENSHOTS_DIR=/opt/automation/data/screenshots # IP protection mode: "proxy" or "vpn"
PROXY_MODE=proxy
VPN_ENABLED=false # Telegram alerts (optional)
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= # Panel credentials (used by Caddy basic auth)
PANEL_USER=admin
PANEL_PASS=YOUR_PANEL_PASSWORD
# Service
PORT=3500 # Database
DATABASE_URL=postgresql://automation:[email protected]:5432/automation # Redis (BullMQ job queue)
REDIS_URL=redis://:[email protected]:6379 # Data directories
PROFILES_DIR=/opt/automation/data/profiles
SCREENSHOTS_DIR=/opt/automation/data/screenshots # IP protection mode: "proxy" or "vpn"
PROXY_MODE=proxy
VPN_ENABLED=false # Telegram alerts (optional)
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID= # Panel credentials (used by Caddy basic auth)
PANEL_USER=admin
PANEL_PASS=YOUR_PANEL_PASSWORD
openssl rand -hex 24 # For DB password
openssl rand -hex 24 # For Redis password
openssl rand -hex 16 # For panel password
openssl rand -hex 24 # For DB password
openssl rand -hex 24 # For Redis password
openssl rand -hex 16 # For panel password
openssl rand -hex 24 # For DB password
openssl rand -hex 24 # For Redis password
openssl rand -hex 16 # For panel password
const { execSync } = require("child_process");
const { Pool } = require("pg");
const fs = require("fs"); // ─── Database ──────────────────────────────────────────────────────── let _db = null;
function getDB() { if (!_db) { _db = new Pool({ connectionString: process.env.DATABASE_URL }); } return _db;
} // ─── Utilities ─────────────────────────────────────────────────────── function rand(min, max) { return Math.floor(min + Math.random() * (max - min));
} function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms));
} // ─── Google Consent Popup ────────────────────────────────────────────
// Google shows a cookie consent dialog in the EU. If you don't dismiss
// it, the search results page never fully loads. This function tries
// multiple localized button texts. async function handleGoogleConsent(page) { const selectors = [ "button:has-text('Accept all')", "button:has-text('Accetta tutto')", "button:has-text('Tout accepter')", "button:has-text('Alle akzeptieren')", "#L2AGLb", "[aria-label='Accept all']", "form[action*='consent'] button", ]; for (const sel of selectors) { try { const btn = page.locator(sel).first(); if (await btn.isVisible({ timeout: 2000 })) { await btn.click(); await sleep(rand(1000, 2000)); return true; } } catch { // Selector not found, try next } } return false;
} // ─── Navigation with Consent Handling ────────────────────────────────
// Wraps page.goto() with retry logic and automatic consent dismissal.
// Google sometimes redirects to consent.google.com before showing
// search results -- this handles that redirect transparently. async function navigateWithConsent(page, url, options = {}) { for (let attempt = 0; attempt < 2; attempt++) { try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeout || 60000, }); await sleep(rand(1500, 3000)); // Check if we were redirected to consent page if (page.url().includes("consent.google")) { await handleGoogleConsent(page); } // Also try dismissing inline consent overlays await handleGoogleConsent(page); return; } catch (e) { if (attempt === 0 && e.message?.includes("Timeout")) { // First timeout: wait and retry await sleep(rand(2000, 4000)); continue; } throw e; } }
} // ─── Public IP Detection ───────────────────────────────────────────── function getPublicIP() { try { return execSync("curl -s --max-time 5 ifconfig.me", { timeout: 10000, }) .toString() .trim(); } catch { return "unknown"; }
} // ─── Proxy Selection ─────────────────────────────────────────────────
// Selects a random active proxy for the given country. For AnyIP
// (a residential proxy provider), appends a random session ID to the
// username so each connection gets a different exit IP. async function getProxy(country) { const db = getDB(); const { rows } = await db.query( `SELECT * FROM proxies WHERE active = true AND UPPER(geo) = UPPER($1) ORDER BY RANDOM() LIMIT 1`, [country] ); if (rows.length > 0) { const p = rows[0]; let username = p.username; // AnyIP session rotation: append random session ID if (p.provider === "anyip") { const sessionId = Math.random().toString(36).substring(2, 10) + Date.now().toString(36); username = username + ",session_s" + sessionId; } return { server: p.endpoint, username, password: p.password, }; } return null;
} // ─── IP Protection Layer ─────────────────────────────────────────────
// Ensures every click goes through a geo-targeted proxy (or VPN).
// Never clicks with your server's real IP. async function ensureIPProtection(country) { const proxy = await getProxy(country); if (proxy) { return { mode: "proxy", proxy }; } // VPN fallback (if configured) if (process.env.VPN_ENABLED === "true") { // VPN connection logic would go here: // connect to country-specific VPN server, verify IP, return throw new Error("VPN support not yet implemented"); } throw new Error( `IP PROTECTION FAILED: No proxy for "${country}" and VPN unavailable.` );
} // ─── Clean IP Acquisition ────────────────────────────────────────────
// Gets a proxy IP that has not been blacklisted. Retries up to 10
// times, rotating to a new proxy session each time. async function getCleanIP(country) { const db = getDB(); const MAX_ATTEMPTS = 10; for (let i = 0; i < MAX_ATTEMPTS; i++) { const protection = await ensureIPProtection(country); if (protection.mode === "proxy" && protection.proxy) { // Resolve the actual exit IP of this proxy // (In production, you would make a request through the proxy // to an IP-echo service. Here we use a simplified check.) const ip = protection.proxy.server.split(":")[0] || "unknown"; const { rows } = await db.query( "SELECT id FROM blacklisted_ips WHERE ip = $1", [ip] ); if (rows.length === 0) { protection.ip = ip; return protection; } console.log(`[IP] Skipping blacklisted IP ${ip}, rotating...`); await sleep(rand(1000, 3000)); } else { protection.ip = getPublicIP(); return protection; } } throw new Error( "Could not get clean IP after " + MAX_ATTEMPTS + " attempts" );
} // ─── IP Blacklisting ──────────────────────────────────────────────── async function blacklistIP(ip, reason, country) { const db = getDB(); await db.query( `INSERT INTO blacklisted_ips (ip, reason, country) VALUES ($1, $2, $3) ON CONFLICT (ip) DO NOTHING`, [ip, reason, country] ); console.log(`[IP] Blacklisted ${ip}: ${reason}`);
} // ─── City Coordinates Lookup ───────────────────────────────────────── async function getCityCoords(city, country) { const db = getDB(); const { rows } = await db.query( `SELECT latitude, longitude, timezone_id, locale, language FROM cities WHERE LOWER(name) = LOWER($1) AND LOWER(country) = LOWER($2)`, [city, country] ); if (rows.length > 0) return rows[0]; // Fallback: return London defaults return { latitude: 51.507351, longitude: -0.127758, timezone_id: "Europe/London", locale: "en-GB", language: "en", };
} module.exports = { getDB, rand, sleep, handleGoogleConsent, navigateWithConsent, getPublicIP, getProxy, ensureIPProtection, getCleanIP, blacklistIP, getCityCoords,
};
const { execSync } = require("child_process");
const { Pool } = require("pg");
const fs = require("fs"); // ─── Database ──────────────────────────────────────────────────────── let _db = null;
function getDB() { if (!_db) { _db = new Pool({ connectionString: process.env.DATABASE_URL }); } return _db;
} // ─── Utilities ─────────────────────────────────────────────────────── function rand(min, max) { return Math.floor(min + Math.random() * (max - min));
} function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms));
} // ─── Google Consent Popup ────────────────────────────────────────────
// Google shows a cookie consent dialog in the EU. If you don't dismiss
// it, the search results page never fully loads. This function tries
// multiple localized button texts. async function handleGoogleConsent(page) { const selectors = [ "button:has-text('Accept all')", "button:has-text('Accetta tutto')", "button:has-text('Tout accepter')", "button:has-text('Alle akzeptieren')", "#L2AGLb", "[aria-label='Accept all']", "form[action*='consent'] button", ]; for (const sel of selectors) { try { const btn = page.locator(sel).first(); if (await btn.isVisible({ timeout: 2000 })) { await btn.click(); await sleep(rand(1000, 2000)); return true; } } catch { // Selector not found, try next } } return false;
} // ─── Navigation with Consent Handling ────────────────────────────────
// Wraps page.goto() with retry logic and automatic consent dismissal.
// Google sometimes redirects to consent.google.com before showing
// search results -- this handles that redirect transparently. async function navigateWithConsent(page, url, options = {}) { for (let attempt = 0; attempt < 2; attempt++) { try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeout || 60000, }); await sleep(rand(1500, 3000)); // Check if we were redirected to consent page if (page.url().includes("consent.google")) { await handleGoogleConsent(page); } // Also try dismissing inline consent overlays await handleGoogleConsent(page); return; } catch (e) { if (attempt === 0 && e.message?.includes("Timeout")) { // First timeout: wait and retry await sleep(rand(2000, 4000)); continue; } throw e; } }
} // ─── Public IP Detection ───────────────────────────────────────────── function getPublicIP() { try { return execSync("curl -s --max-time 5 ifconfig.me", { timeout: 10000, }) .toString() .trim(); } catch { return "unknown"; }
} // ─── Proxy Selection ─────────────────────────────────────────────────
// Selects a random active proxy for the given country. For AnyIP
// (a residential proxy provider), appends a random session ID to the
// username so each connection gets a different exit IP. async function getProxy(country) { const db = getDB(); const { rows } = await db.query( `SELECT * FROM proxies WHERE active = true AND UPPER(geo) = UPPER($1) ORDER BY RANDOM() LIMIT 1`, [country] ); if (rows.length > 0) { const p = rows[0]; let username = p.username; // AnyIP session rotation: append random session ID if (p.provider === "anyip") { const sessionId = Math.random().toString(36).substring(2, 10) + Date.now().toString(36); username = username + ",session_s" + sessionId; } return { server: p.endpoint, username, password: p.password, }; } return null;
} // ─── IP Protection Layer ─────────────────────────────────────────────
// Ensures every click goes through a geo-targeted proxy (or VPN).
// Never clicks with your server's real IP. async function ensureIPProtection(country) { const proxy = await getProxy(country); if (proxy) { return { mode: "proxy", proxy }; } // VPN fallback (if configured) if (process.env.VPN_ENABLED === "true") { // VPN connection logic would go here: // connect to country-specific VPN server, verify IP, return throw new Error("VPN support not yet implemented"); } throw new Error( `IP PROTECTION FAILED: No proxy for "${country}" and VPN unavailable.` );
} // ─── Clean IP Acquisition ────────────────────────────────────────────
// Gets a proxy IP that has not been blacklisted. Retries up to 10
// times, rotating to a new proxy session each time. async function getCleanIP(country) { const db = getDB(); const MAX_ATTEMPTS = 10; for (let i = 0; i < MAX_ATTEMPTS; i++) { const protection = await ensureIPProtection(country); if (protection.mode === "proxy" && protection.proxy) { // Resolve the actual exit IP of this proxy // (In production, you would make a request through the proxy // to an IP-echo service. Here we use a simplified check.) const ip = protection.proxy.server.split(":")[0] || "unknown"; const { rows } = await db.query( "SELECT id FROM blacklisted_ips WHERE ip = $1", [ip] ); if (rows.length === 0) { protection.ip = ip; return protection; } console.log(`[IP] Skipping blacklisted IP ${ip}, rotating...`); await sleep(rand(1000, 3000)); } else { protection.ip = getPublicIP(); return protection; } } throw new Error( "Could not get clean IP after " + MAX_ATTEMPTS + " attempts" );
} // ─── IP Blacklisting ──────────────────────────────────────────────── async function blacklistIP(ip, reason, country) { const db = getDB(); await db.query( `INSERT INTO blacklisted_ips (ip, reason, country) VALUES ($1, $2, $3) ON CONFLICT (ip) DO NOTHING`, [ip, reason, country] ); console.log(`[IP] Blacklisted ${ip}: ${reason}`);
} // ─── City Coordinates Lookup ───────────────────────────────────────── async function getCityCoords(city, country) { const db = getDB(); const { rows } = await db.query( `SELECT latitude, longitude, timezone_id, locale, language FROM cities WHERE LOWER(name) = LOWER($1) AND LOWER(country) = LOWER($2)`, [city, country] ); if (rows.length > 0) return rows[0]; // Fallback: return London defaults return { latitude: 51.507351, longitude: -0.127758, timezone_id: "Europe/London", locale: "en-GB", language: "en", };
} module.exports = { getDB, rand, sleep, handleGoogleConsent, navigateWithConsent, getPublicIP, getProxy, ensureIPProtection, getCleanIP, blacklistIP, getCityCoords,
};
const { execSync } = require("child_process");
const { Pool } = require("pg");
const fs = require("fs"); // ─── Database ──────────────────────────────────────────────────────── let _db = null;
function getDB() { if (!_db) { _db = new Pool({ connectionString: process.env.DATABASE_URL }); } return _db;
} // ─── Utilities ─────────────────────────────────────────────────────── function rand(min, max) { return Math.floor(min + Math.random() * (max - min));
} function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms));
} // ─── Google Consent Popup ────────────────────────────────────────────
// Google shows a cookie consent dialog in the EU. If you don't dismiss
// it, the search results page never fully loads. This function tries
// multiple localized button texts. async function handleGoogleConsent(page) { const selectors = [ "button:has-text('Accept all')", "button:has-text('Accetta tutto')", "button:has-text('Tout accepter')", "button:has-text('Alle akzeptieren')", "#L2AGLb", "[aria-label='Accept all']", "form[action*='consent'] button", ]; for (const sel of selectors) { try { const btn = page.locator(sel).first(); if (await btn.isVisible({ timeout: 2000 })) { await btn.click(); await sleep(rand(1000, 2000)); return true; } } catch { // Selector not found, try next } } return false;
} // ─── Navigation with Consent Handling ────────────────────────────────
// Wraps page.goto() with retry logic and automatic consent dismissal.
// Google sometimes redirects to consent.google.com before showing
// search results -- this handles that redirect transparently. async function navigateWithConsent(page, url, options = {}) { for (let attempt = 0; attempt < 2; attempt++) { try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeout || 60000, }); await sleep(rand(1500, 3000)); // Check if we were redirected to consent page if (page.url().includes("consent.google")) { await handleGoogleConsent(page); } // Also try dismissing inline consent overlays await handleGoogleConsent(page); return; } catch (e) { if (attempt === 0 && e.message?.includes("Timeout")) { // First timeout: wait and retry await sleep(rand(2000, 4000)); continue; } throw e; } }
} // ─── Public IP Detection ───────────────────────────────────────────── function getPublicIP() { try { return execSync("curl -s --max-time 5 ifconfig.me", { timeout: 10000, }) .toString() .trim(); } catch { return "unknown"; }
} // ─── Proxy Selection ─────────────────────────────────────────────────
// Selects a random active proxy for the given country. For AnyIP
// (a residential proxy provider), appends a random session ID to the
// username so each connection gets a different exit IP. async function getProxy(country) { const db = getDB(); const { rows } = await db.query( `SELECT * FROM proxies WHERE active = true AND UPPER(geo) = UPPER($1) ORDER BY RANDOM() LIMIT 1`, [country] ); if (rows.length > 0) { const p = rows[0]; let username = p.username; // AnyIP session rotation: append random session ID if (p.provider === "anyip") { const sessionId = Math.random().toString(36).substring(2, 10) + Date.now().toString(36); username = username + ",session_s" + sessionId; } return { server: p.endpoint, username, password: p.password, }; } return null;
} // ─── IP Protection Layer ─────────────────────────────────────────────
// Ensures every click goes through a geo-targeted proxy (or VPN).
// Never clicks with your server's real IP. async function ensureIPProtection(country) { const proxy = await getProxy(country); if (proxy) { return { mode: "proxy", proxy }; } // VPN fallback (if configured) if (process.env.VPN_ENABLED === "true") { // VPN connection logic would go here: // connect to country-specific VPN server, verify IP, return throw new Error("VPN support not yet implemented"); } throw new Error( `IP PROTECTION FAILED: No proxy for "${country}" and VPN unavailable.` );
} // ─── Clean IP Acquisition ────────────────────────────────────────────
// Gets a proxy IP that has not been blacklisted. Retries up to 10
// times, rotating to a new proxy session each time. async function getCleanIP(country) { const db = getDB(); const MAX_ATTEMPTS = 10; for (let i = 0; i < MAX_ATTEMPTS; i++) { const protection = await ensureIPProtection(country); if (protection.mode === "proxy" && protection.proxy) { // Resolve the actual exit IP of this proxy // (In production, you would make a request through the proxy // to an IP-echo service. Here we use a simplified check.) const ip = protection.proxy.server.split(":")[0] || "unknown"; const { rows } = await db.query( "SELECT id FROM blacklisted_ips WHERE ip = $1", [ip] ); if (rows.length === 0) { protection.ip = ip; return protection; } console.log(`[IP] Skipping blacklisted IP ${ip}, rotating...`); await sleep(rand(1000, 3000)); } else { protection.ip = getPublicIP(); return protection; } } throw new Error( "Could not get clean IP after " + MAX_ATTEMPTS + " attempts" );
} // ─── IP Blacklisting ──────────────────────────────────────────────── async function blacklistIP(ip, reason, country) { const db = getDB(); await db.query( `INSERT INTO blacklisted_ips (ip, reason, country) VALUES ($1, $2, $3) ON CONFLICT (ip) DO NOTHING`, [ip, reason, country] ); console.log(`[IP] Blacklisted ${ip}: ${reason}`);
} // ─── City Coordinates Lookup ───────────────────────────────────────── async function getCityCoords(city, country) { const db = getDB(); const { rows } = await db.query( `SELECT latitude, longitude, timezone_id, locale, language FROM cities WHERE LOWER(name) = LOWER($1) AND LOWER(country) = LOWER($2)`, [city, country] ); if (rows.length > 0) return rows[0]; // Fallback: return London defaults return { latitude: 51.507351, longitude: -0.127758, timezone_id: "Europe/London", locale: "en-GB", language: "en", };
} module.exports = { getDB, rand, sleep, handleGoogleConsent, navigateWithConsent, getPublicIP, getProxy, ensureIPProtection, getCleanIP, blacklistIP, getCityCoords,
};
cd /opt/automation/service
npm init -y
npm install patchright pg bullmq express dotenv
npx patchright install chromium
cd /opt/automation/service
npm init -y
npm install patchright pg bullmq express dotenv
npx patchright install chromium
cd /opt/automation/service
npm init -y
npm install patchright pg bullmq express dotenv
npx patchright install chromium
// Real Chrome mobile user-agents, updated regularly.
// CRITICAL: These must NOT contain "HeadlessChrome".
// Patchright 1.57.0 in headless mode injects "HeadlessChrome" into
// the default UA. We override it completely.
const MOBILE_USER_AGENTS = [ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) " + "AppleWebKit/605.1.15 CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1",
]; function getRandomUA() { return MOBILE_USER_AGENTS[Math.floor(Math.random() * MOBILE_USER_AGENTS.length)];
}
// Real Chrome mobile user-agents, updated regularly.
// CRITICAL: These must NOT contain "HeadlessChrome".
// Patchright 1.57.0 in headless mode injects "HeadlessChrome" into
// the default UA. We override it completely.
const MOBILE_USER_AGENTS = [ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) " + "AppleWebKit/605.1.15 CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1",
]; function getRandomUA() { return MOBILE_USER_AGENTS[Math.floor(Math.random() * MOBILE_USER_AGENTS.length)];
}
// Real Chrome mobile user-agents, updated regularly.
// CRITICAL: These must NOT contain "HeadlessChrome".
// Patchright 1.57.0 in headless mode injects "HeadlessChrome" into
// the default UA. We override it completely.
const MOBILE_USER_AGENTS = [ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) " + "AppleWebKit/605.1.15 CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1",
]; function getRandomUA() { return MOBILE_USER_AGENTS[Math.floor(Math.random() * MOBILE_USER_AGENTS.length)];
}
// Use the correct country-specific Google domain.
// NEVER append &hl= or &gl= parameters to the URL -- that is a
// bot fingerprint. Real users just go to google.co.uk, not
// google.com?hl=en&gl=uk.
function getGoogleDomain(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return "www.google.it"; if (c === "uk" || c === "gb") return "www.google.co.uk"; if (c === "france" || c === "fr") return "www.google.fr"; if (c === "germany" || c === "de") return "www.google.de"; return "www.google.com";
}
// Use the correct country-specific Google domain.
// NEVER append &hl= or &gl= parameters to the URL -- that is a
// bot fingerprint. Real users just go to google.co.uk, not
// google.com?hl=en&gl=uk.
function getGoogleDomain(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return "www.google.it"; if (c === "uk" || c === "gb") return "www.google.co.uk"; if (c === "france" || c === "fr") return "www.google.fr"; if (c === "germany" || c === "de") return "www.google.de"; return "www.google.com";
}
// Use the correct country-specific Google domain.
// NEVER append &hl= or &gl= parameters to the URL -- that is a
// bot fingerprint. Real users just go to google.co.uk, not
// google.com?hl=en&gl=uk.
function getGoogleDomain(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return "www.google.it"; if (c === "uk" || c === "gb") return "www.google.co.uk"; if (c === "france" || c === "fr") return "www.google.fr"; if (c === "germany" || c === "de") return "www.google.de"; return "www.google.com";
}
// The languages array must match the proxy's geo-IP location.
// A browser reporting ["en-US"] on a UK mobile IP is suspicious.
function getLanguages(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return ["it-IT", "it", "en"]; if (c === "france" || c === "fr") return ["fr-FR", "fr", "en"]; if (c === "germany" || c === "de") return ["de-DE", "de", "en"]; return ["en-GB", "en-US", "en"];
}
// The languages array must match the proxy's geo-IP location.
// A browser reporting ["en-US"] on a UK mobile IP is suspicious.
function getLanguages(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return ["it-IT", "it", "en"]; if (c === "france" || c === "fr") return ["fr-FR", "fr", "en"]; if (c === "germany" || c === "de") return ["de-DE", "de", "en"]; return ["en-GB", "en-US", "en"];
}
// The languages array must match the proxy's geo-IP location.
// A browser reporting ["en-US"] on a UK mobile IP is suspicious.
function getLanguages(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return ["it-IT", "it", "en"]; if (c === "france" || c === "fr") return ["fr-FR", "fr", "en"]; if (c === "germany" || c === "de") return ["de-DE", "de", "en"]; return ["en-GB", "en-US", "en"];
}
async function applyStealthScripts(context, country) { const langs = getLanguages(country); await context.addInitScript((langsArg) => { // ── 1. Fake window.chrome object ────────────────────────────── // Headless Chrome does not have this object. Its absence is the // single most common bot detection signal. We create a minimal // but convincing replica. Object.defineProperty(window, "chrome", { get: () => ({ runtime: {}, app: { isInstalled: false }, csi: () => {}, loadTimes: () => {}, }), }); // ── 2. Fake navigator.plugins ───────────────────────────────── // Real Chrome always has at least 3 plugins. Headless has 0. // We create a PluginArray-like object with the standard three. Object.defineProperty(navigator, "plugins", { get: () => { const arr = [ { name: "Chrome PDF Plugin", filename: "internal-pdf-viewer", }, { name: "Chrome PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", }, { name: "Native Client", filename: "internal-nacl-plugin", }, ]; arr.item = (i) => arr[i]; arr.namedItem = (n) => arr.find((p) => p.name === n); arr.refresh = () => {}; return arr; }, }); // ── 3. Override navigator.languages ─────────────────────────── // Must match the geographic location of the proxy IP. Object.defineProperty(navigator, "languages", { get: () => langsArg, }); // ── 4. Remove CDP (Chrome DevTools Protocol) markers ───────── // Playwright/Patchright injects globals prefixed with "cdc_" // that bot detectors scan for. We hide them. Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Array", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Promise", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Symbol", { get: () => undefined, }); // ── 5. WebGL renderer spoofing ─────────────────────────────── // Headless Chrome reports "SwiftShader" or "Google SwiftShader" // as the GPU renderer, which is an instant bot flag. We override // the WebGL debug info to report a real mobile GPU. const getParameter = WebGLRenderingContext.prototype.getParameter; WebGLRenderingContext.prototype.getParameter = function (param) { // UNMASKED_VENDOR_WEBGL if (param === 37445) return "Google Inc. (Qualcomm)"; // UNMASKED_RENDERER_WEBGL if (param === 37446) return "ANGLE (Qualcomm, Adreno (TM) 740, OpenGL ES 3.2)"; return getParameter.call(this, param); }; }, langs);
}
async function applyStealthScripts(context, country) { const langs = getLanguages(country); await context.addInitScript((langsArg) => { // ── 1. Fake window.chrome object ────────────────────────────── // Headless Chrome does not have this object. Its absence is the // single most common bot detection signal. We create a minimal // but convincing replica. Object.defineProperty(window, "chrome", { get: () => ({ runtime: {}, app: { isInstalled: false }, csi: () => {}, loadTimes: () => {}, }), }); // ── 2. Fake navigator.plugins ───────────────────────────────── // Real Chrome always has at least 3 plugins. Headless has 0. // We create a PluginArray-like object with the standard three. Object.defineProperty(navigator, "plugins", { get: () => { const arr = [ { name: "Chrome PDF Plugin", filename: "internal-pdf-viewer", }, { name: "Chrome PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", }, { name: "Native Client", filename: "internal-nacl-plugin", }, ]; arr.item = (i) => arr[i]; arr.namedItem = (n) => arr.find((p) => p.name === n); arr.refresh = () => {}; return arr; }, }); // ── 3. Override navigator.languages ─────────────────────────── // Must match the geographic location of the proxy IP. Object.defineProperty(navigator, "languages", { get: () => langsArg, }); // ── 4. Remove CDP (Chrome DevTools Protocol) markers ───────── // Playwright/Patchright injects globals prefixed with "cdc_" // that bot detectors scan for. We hide them. Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Array", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Promise", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Symbol", { get: () => undefined, }); // ── 5. WebGL renderer spoofing ─────────────────────────────── // Headless Chrome reports "SwiftShader" or "Google SwiftShader" // as the GPU renderer, which is an instant bot flag. We override // the WebGL debug info to report a real mobile GPU. const getParameter = WebGLRenderingContext.prototype.getParameter; WebGLRenderingContext.prototype.getParameter = function (param) { // UNMASKED_VENDOR_WEBGL if (param === 37445) return "Google Inc. (Qualcomm)"; // UNMASKED_RENDERER_WEBGL if (param === 37446) return "ANGLE (Qualcomm, Adreno (TM) 740, OpenGL ES 3.2)"; return getParameter.call(this, param); }; }, langs);
}
async function applyStealthScripts(context, country) { const langs = getLanguages(country); await context.addInitScript((langsArg) => { // ── 1. Fake window.chrome object ────────────────────────────── // Headless Chrome does not have this object. Its absence is the // single most common bot detection signal. We create a minimal // but convincing replica. Object.defineProperty(window, "chrome", { get: () => ({ runtime: {}, app: { isInstalled: false }, csi: () => {}, loadTimes: () => {}, }), }); // ── 2. Fake navigator.plugins ───────────────────────────────── // Real Chrome always has at least 3 plugins. Headless has 0. // We create a PluginArray-like object with the standard three. Object.defineProperty(navigator, "plugins", { get: () => { const arr = [ { name: "Chrome PDF Plugin", filename: "internal-pdf-viewer", }, { name: "Chrome PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", }, { name: "Native Client", filename: "internal-nacl-plugin", }, ]; arr.item = (i) => arr[i]; arr.namedItem = (n) => arr.find((p) => p.name === n); arr.refresh = () => {}; return arr; }, }); // ── 3. Override navigator.languages ─────────────────────────── // Must match the geographic location of the proxy IP. Object.defineProperty(navigator, "languages", { get: () => langsArg, }); // ── 4. Remove CDP (Chrome DevTools Protocol) markers ───────── // Playwright/Patchright injects globals prefixed with "cdc_" // that bot detectors scan for. We hide them. Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Array", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Promise", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Symbol", { get: () => undefined, }); // ── 5. WebGL renderer spoofing ─────────────────────────────── // Headless Chrome reports "SwiftShader" or "Google SwiftShader" // as the GPU renderer, which is an instant bot flag. We override // the WebGL debug info to report a real mobile GPU. const getParameter = WebGLRenderingContext.prototype.getParameter; WebGLRenderingContext.prototype.getParameter = function (param) { // UNMASKED_VENDOR_WEBGL if (param === 37445) return "Google Inc. (Qualcomm)"; // UNMASKED_RENDERER_WEBGL if (param === 37446) return "ANGLE (Qualcomm, Adreno (TM) 740, OpenGL ES 3.2)"; return getParameter.call(this, param); }; }, langs);
}
// test-stealth.js
require("dotenv").config();
const { chromium } = require("patchright"); (async () => { const browser = await chromium.launch({ headless: false }); const context = await browser.newContext({ userAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", viewport: { width: 351, height: 878 }, isMobile: true, hasTouch: true, }); // Apply stealth await context.addInitScript(() => { Object.defineProperty(window, "chrome", { get: () => ({ runtime: {}, app: { isInstalled: false } }), }); Object.defineProperty(navigator, "plugins", { get: () => { const a = [ { name: "Chrome PDF Plugin", filename: "internal-pdf-viewer" }, ]; a.item = (i) => a[i]; return a; }, }); }); const page = await context.newPage(); await page.goto("https://bot.sannysoft.com/"); await page.screenshot({ path: "stealth-test.png", fullPage: true }); console.log("Screenshot saved to stealth-test.png"); await browser.close();
})();
// test-stealth.js
require("dotenv").config();
const { chromium } = require("patchright"); (async () => { const browser = await chromium.launch({ headless: false }); const context = await browser.newContext({ userAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", viewport: { width: 351, height: 878 }, isMobile: true, hasTouch: true, }); // Apply stealth await context.addInitScript(() => { Object.defineProperty(window, "chrome", { get: () => ({ runtime: {}, app: { isInstalled: false } }), }); Object.defineProperty(navigator, "plugins", { get: () => { const a = [ { name: "Chrome PDF Plugin", filename: "internal-pdf-viewer" }, ]; a.item = (i) => a[i]; return a; }, }); }); const page = await context.newPage(); await page.goto("https://bot.sannysoft.com/"); await page.screenshot({ path: "stealth-test.png", fullPage: true }); console.log("Screenshot saved to stealth-test.png"); await browser.close();
})();
// test-stealth.js
require("dotenv").config();
const { chromium } = require("patchright"); (async () => { const browser = await chromium.launch({ headless: false }); const context = await browser.newContext({ userAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", viewport: { width: 351, height: 878 }, isMobile: true, hasTouch: true, }); // Apply stealth await context.addInitScript(() => { Object.defineProperty(window, "chrome", { get: () => ({ runtime: {}, app: { isInstalled: false } }), }); Object.defineProperty(navigator, "plugins", { get: () => { const a = [ { name: "Chrome PDF Plugin", filename: "internal-pdf-viewer" }, ]; a.item = (i) => a[i]; return a; }, }); }); const page = await context.newPage(); await page.goto("https://bot.sannysoft.com/"); await page.screenshot({ path: "stealth-test.png", fullPage: true }); console.log("Screenshot saved to stealth-test.png"); await browser.close();
})();
cd /opt/automation/service
xvfb-run --auto-servernum node test-stealth.js
cd /opt/automation/service
xvfb-run --auto-servernum node test-stealth.js
cd /opt/automation/service
xvfb-run --auto-servernum node test-stealth.js
require("dotenv").config();
const { chromium } = require("patchright");
const { Worker } = require("bullmq");
const { getDB, rand, sleep, navigateWithConsent, getCleanIP, blacklistIP, getCityCoords,
} = require("./browser-utils"); // ─── Stealth Configuration ─────────────────────────────────────────── const MOBILE_USER_AGENTS = [ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) " + "AppleWebKit/605.1.15 CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1",
]; function getRandomUA() { return MOBILE_USER_AGENTS[ Math.floor(Math.random() * MOBILE_USER_AGENTS.length) ];
} function getGoogleDomain(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return "www.google.it"; if (c === "uk" || c === "gb") return "www.google.co.uk"; if (c === "france" || c === "fr") return "www.google.fr"; if (c === "germany" || c === "de") return "www.google.de"; return "www.google.com";
} function getLanguages(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return ["it-IT", "it", "en"]; if (c === "france" || c === "fr") return ["fr-FR", "fr", "en"]; if (c === "germany" || c === "de") return ["de-DE", "de", "en"]; return ["en-GB", "en-US", "en"];
} // ─── Stealth Script Injection ──────────────────────────────────────── async function applyStealthScripts(context, country) { const langs = getLanguages(country); await context.addInitScript((langsArg) => { // Fake window.chrome Object.defineProperty(window, "chrome", { get: () => ({ runtime: {}, app: { isInstalled: false }, csi: () => {}, loadTimes: () => {}, }), }); // Fake navigator.plugins Object.defineProperty(navigator, "plugins", { get: () => { const arr = [ { name: "Chrome PDF Plugin", filename: "internal-pdf-viewer" }, { name: "Chrome PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", }, { name: "Native Client", filename: "internal-nacl-plugin", }, ]; arr.item = (i) => arr[i]; arr.namedItem = (n) => arr.find((p) => p.name === n); arr.refresh = () => {}; return arr; }, }); // Geo-matched languages Object.defineProperty(navigator, "languages", { get: () => langsArg, }); // Remove CDP markers Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Array", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Promise", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Symbol", { get: () => undefined, }); // WebGL GPU spoofing const getParameter = WebGLRenderingContext.prototype.getParameter; WebGLRenderingContext.prototype.getParameter = function (param) { if (param === 37445) return "Google Inc. (Qualcomm)"; if (param === 37446) return "ANGLE (Qualcomm, Adreno (TM) 740, OpenGL ES 3.2)"; return getParameter.call(this, param); }; }, langs);
} // ─── Scroll and Expand Results ───────────────────────────────────────
// Mimics a human scrolling through search results. Also clicks
// "Show more" buttons that Google hides additional ad results behind. async function scrollAndExpand(page) { for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, rand(300, 600)); await sleep(rand(600, 1500)); } // Try expanding hidden results (localized button texts) const expandButtons = [ "text=Mostra altro", "text=Show more", "text=More businesses", ]; for (const sel of expandButtons) { try { const btn = page.locator(sel).first(); if (await btn.isVisible({ timeout: 1500 })) { await btn.click(); await sleep(rand(2000, 3000)); } } catch { // Button not present } } // Scroll back to top await page.evaluate(() => window.scrollTo(0, 0));
} // ─── Parse Sponsored Results ─────────────────────────────────────────
// Extracts all sponsored/ad links from the Google search results page.
// Covers four types of sponsored content:
// 1. Local Services Ads (LSA) -- "/localservices/profile" links
// 2. Traditional Google Ads -- "googleadservices.com" or "/aclk" links
// 3. Sponsored containers -- elements with [data-rw], [data-text-ad]
// 4. Local Pack website links -- "Website"/"Sito web" links in map cards async function parseSponsoredResults(page) { return page.evaluate(() => { const results = []; const seen = new Set(); function addResult(text, href, displayUrl, type) { if (seen.has(href)) return; if (!text || text.trim().length < 3) return; seen.add(href); results.push({ text: text.trim().slice(0, 200), href, displayUrl: displayUrl || href, type, position: results.length + 1, }); } // 1. Local Services Ads (LSA) document .querySelectorAll('a[href*="/localservices/profile"]') .forEach((link) => { addResult(link.innerText, link.href, link.href, "lsa"); }); // 2. Traditional Google Ads (via googleadservices.com) document.querySelectorAll("a[href]").forEach((link) => { const href = link.href || ""; if ( href.includes("googleadservices.com") || href.includes("/aclk") ) { let displayUrl = href; try { displayUrl = new URL(href).searchParams.get("adurl") || href; } catch {} addResult(link.innerText, href, displayUrl, "ad"); } }); // 3. Sponsored containers (data attributes) document .querySelectorAll("[data-rw], [data-text-ad], .uEierd") .forEach((el) => { const link = el.querySelector("a[href]"); if (link) { addResult(link.innerText, link.href, link.href, "ad-container"); } }); // 4. Local Pack website links document.querySelectorAll("a[href]").forEach((link) => { const text = (link.innerText || "").trim().toLowerCase(); if (text === "sito web" || text === "website") { let bizName = ""; const card = link.closest("[data-cid]") || link.closest("[jscontroller]"); if (card) { const h = card.querySelector("[role='heading'], h3"); if (h) bizName = h.textContent.trim(); } addResult( bizName || "Local Business", link.href, link.href, "local-web" ); } }); return results; });
} // ─── Main Click Logic ──────────────────────────────────────────────── async function singleClick(job) { const { keyword, country, city, target_link, mode, account_email, click_target_id, campaign_id, } = job.data; const db = getDB(); let browser = null; let context = null; let page = null; let protection = null; try { // ── Phase 1: Get clean IP and launch browser ───────────────── const MAX_IP_RETRIES = 5; for (let ipAttempt = 1; ipAttempt <= MAX_IP_RETRIES; ipAttempt++) { // Close previous browser if retrying if (browser) { await browser.close().catch(() => {}); browser = null; } protection = await getCleanIP(country); console.log( `[Click] Attempt ${ipAttempt}/${MAX_IP_RETRIES}: ` + `IP ${protection.ip}, keyword "${keyword}"` ); // Launch browser with stealth config browser = await chromium.launch({ headless: false, args: ["--no-sandbox"], // DO NOT add --disable-blink-features=AutomationControlled // It BREAKS Patchright's built-in patches! }); const coords = await getCityCoords(city, country); context = await browser.newContext({ userAgent: getRandomUA(), viewport: { width: 351, height: 878 }, locale: coords.locale, timezoneId: coords.timezone_id, geolocation: { latitude: parseFloat(coords.latitude), longitude: parseFloat(coords.longitude), }, permissions: ["geolocation"], isMobile: true, hasTouch: true, // Proxy at CONTEXT level, not browser level proxy: protection.proxy ? { server: protection.proxy.server, username: protection.proxy.username, password: protection.proxy.password, } : undefined, }); await applyStealthScripts(context, country); page = await context.newPage(); // ── Phase 2: Navigate to Google and search ───────────────── const searchUrl = `https://${getGoogleDomain(country)}` + `/search?q=${encodeURIComponent(keyword)}`; await navigateWithConsent(page, searchUrl); await scrollAndExpand(page); // ── Phase 3: CAPTCHA detection ───────────────────────────── const content = await page.content(); const isCaptcha = content.includes("unusual traffic") || content.includes("CAPTCHA") || page.url().includes("/sorry/"); if (isCaptcha) { console.log( `[Click] CAPTCHA detected on IP ${protection.ip}, blacklisting` ); await blacklistIP(protection.ip, "CAPTCHA detected", country); if (ipAttempt < MAX_IP_RETRIES) continue; throw new Error("CAPTCHA on all IPs after " + MAX_IP_RETRIES + " attempts"); } // Clean page -- break out of retry loop break; } // ── Phase 4: Parse and filter sponsored results ────────────── const sponsored = await parseSponsoredResults(page); console.log( `[Click] Found ${sponsored.length} sponsored results for "${keyword}"` ); let linksToClick = []; for (const result of sponsored) { const isTarget = result.href .toLowerCase() .includes(target_link.toLowerCase()); if (mode === "boost" && isTarget) { linksToClick.push(result); } else if (mode === "drain" && !isTarget) { linksToClick.push(result); } } if (linksToClick.length === 0) { console.log(`[Click] No matching ads found for mode "${mode}"`); await db.query( `INSERT INTO clicks (click_target_id, campaign_id, keyword, mode, links_clicked, vpn_ip, vpn_country, status, error_message) VALUES ($1,$2,$3,$4,$5,$6,$7,'no_ads',$8)`, [ click_target_id, campaign_id, keyword, mode, [], protection?.ip, country, "No matching ads found on page", ] ); return { success: false, reason: "no_matching_ads" }; } // ── Phase 5: Click ads and dwell ───────────────────────────── const clickedUrls = []; for (const linkData of linksToClick) { try { const link = page.locator(`a[href="${linkData.href}"]`).first(); if (await link.isVisible({ timeout: 3000 })) { await link.click(); clickedUrls.push(linkData.displayUrl); console.log( `[Click] Clicked: ${linkData.type} - ${linkData.displayUrl.slice(0, 80)}` ); // Simulate human browsing behavior on landing page await sleep(rand(2000, 4000)); // Scroll around the landing page (dwell time signal) for (let s = 0; s < 5; s++) { await page.mouse.wheel(0, rand(150, 400)); await sleep(rand(2000, 4000)); } // Go back to search results await page .goBack({ waitUntil: "domcontentloaded", timeout: 10000 }) .catch(() => {}); await sleep(rand(2000, 4000)); } } catch (e) { console.log(`[Click] Failed to click ${linkData.href}: ${e.message}`); } } // ── Phase 6: Save results to database ──────────────────────── const dwellTime = 35; // approximate total dwell seconds await db.query( `INSERT INTO clicks (click_target_id, campaign_id, keyword, mode, links_clicked, dwell_time_seconds, vpn_ip, vpn_country, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'completed')`, [ click_target_id, campaign_id, keyword, mode, clickedUrls, dwellTime, protection?.ip, country, ] ); // Update total clicks on target if (click_target_id) { await db.query( `UPDATE click_targets SET total_clicks = total_clicks + $1 WHERE id = $2`, [clickedUrls.length, click_target_id] ); } console.log( `[Click] Completed: ${clickedUrls.length} clicks for "${keyword}"` ); return { success: true, clicked: clickedUrls.length }; } catch (error) { console.error(`[Click] Error: ${error.message}`); // Log the failure await db .query( `INSERT INTO clicks (click_target_id, campaign_id, keyword, mode, vpn_ip, vpn_country, status, error_message) VALUES ($1,$2,$3,$4,$5,$6,'error',$7)`, [ click_target_id, campaign_id, keyword, mode, protection?.ip, country, error.message, ] ) .catch(() => {}); throw error; } finally { // Always close browser if (browser) { await browser.close().catch(() => {}); } }
} // ─── BullMQ Worker ───────────────────────────────────────────────────
// Concurrency: 1 -- only one browser session at a time.
// Multiple parallel sessions from the same server IP would be
// an obvious automation signal. const redisOpts = { connection: { host: "127.0.0.1", port: 6379, password: process.env.REDIS_URL?.match(/:([^@]+)@/)?.[1] || "", },
}; const worker = new Worker("clicks", async (job) => singleClick(job), { ...redisOpts, concurrency: 1, limiter: { max: 1, duration: 5000, // At most 1 job per 5 seconds },
}); worker.on("completed", (job, result) => { console.log( `[Worker] Job ${job.id} completed: ${JSON.stringify(result)}` );
}); worker.on("failed", (job, err) => { console.error(`[Worker] Job ${job?.id} failed: ${err.message}`);
}); worker.on("error", (err) => { console.error(`[Worker] Error: ${err.message}`);
}); console.log("[Worker] Click worker started, waiting for jobs...");
require("dotenv").config();
const { chromium } = require("patchright");
const { Worker } = require("bullmq");
const { getDB, rand, sleep, navigateWithConsent, getCleanIP, blacklistIP, getCityCoords,
} = require("./browser-utils"); // ─── Stealth Configuration ─────────────────────────────────────────── const MOBILE_USER_AGENTS = [ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) " + "AppleWebKit/605.1.15 CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1",
]; function getRandomUA() { return MOBILE_USER_AGENTS[ Math.floor(Math.random() * MOBILE_USER_AGENTS.length) ];
} function getGoogleDomain(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return "www.google.it"; if (c === "uk" || c === "gb") return "www.google.co.uk"; if (c === "france" || c === "fr") return "www.google.fr"; if (c === "germany" || c === "de") return "www.google.de"; return "www.google.com";
} function getLanguages(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return ["it-IT", "it", "en"]; if (c === "france" || c === "fr") return ["fr-FR", "fr", "en"]; if (c === "germany" || c === "de") return ["de-DE", "de", "en"]; return ["en-GB", "en-US", "en"];
} // ─── Stealth Script Injection ──────────────────────────────────────── async function applyStealthScripts(context, country) { const langs = getLanguages(country); await context.addInitScript((langsArg) => { // Fake window.chrome Object.defineProperty(window, "chrome", { get: () => ({ runtime: {}, app: { isInstalled: false }, csi: () => {}, loadTimes: () => {}, }), }); // Fake navigator.plugins Object.defineProperty(navigator, "plugins", { get: () => { const arr = [ { name: "Chrome PDF Plugin", filename: "internal-pdf-viewer" }, { name: "Chrome PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", }, { name: "Native Client", filename: "internal-nacl-plugin", }, ]; arr.item = (i) => arr[i]; arr.namedItem = (n) => arr.find((p) => p.name === n); arr.refresh = () => {}; return arr; }, }); // Geo-matched languages Object.defineProperty(navigator, "languages", { get: () => langsArg, }); // Remove CDP markers Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Array", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Promise", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Symbol", { get: () => undefined, }); // WebGL GPU spoofing const getParameter = WebGLRenderingContext.prototype.getParameter; WebGLRenderingContext.prototype.getParameter = function (param) { if (param === 37445) return "Google Inc. (Qualcomm)"; if (param === 37446) return "ANGLE (Qualcomm, Adreno (TM) 740, OpenGL ES 3.2)"; return getParameter.call(this, param); }; }, langs);
} // ─── Scroll and Expand Results ───────────────────────────────────────
// Mimics a human scrolling through search results. Also clicks
// "Show more" buttons that Google hides additional ad results behind. async function scrollAndExpand(page) { for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, rand(300, 600)); await sleep(rand(600, 1500)); } // Try expanding hidden results (localized button texts) const expandButtons = [ "text=Mostra altro", "text=Show more", "text=More businesses", ]; for (const sel of expandButtons) { try { const btn = page.locator(sel).first(); if (await btn.isVisible({ timeout: 1500 })) { await btn.click(); await sleep(rand(2000, 3000)); } } catch { // Button not present } } // Scroll back to top await page.evaluate(() => window.scrollTo(0, 0));
} // ─── Parse Sponsored Results ─────────────────────────────────────────
// Extracts all sponsored/ad links from the Google search results page.
// Covers four types of sponsored content:
// 1. Local Services Ads (LSA) -- "/localservices/profile" links
// 2. Traditional Google Ads -- "googleadservices.com" or "/aclk" links
// 3. Sponsored containers -- elements with [data-rw], [data-text-ad]
// 4. Local Pack website links -- "Website"/"Sito web" links in map cards async function parseSponsoredResults(page) { return page.evaluate(() => { const results = []; const seen = new Set(); function addResult(text, href, displayUrl, type) { if (seen.has(href)) return; if (!text || text.trim().length < 3) return; seen.add(href); results.push({ text: text.trim().slice(0, 200), href, displayUrl: displayUrl || href, type, position: results.length + 1, }); } // 1. Local Services Ads (LSA) document .querySelectorAll('a[href*="/localservices/profile"]') .forEach((link) => { addResult(link.innerText, link.href, link.href, "lsa"); }); // 2. Traditional Google Ads (via googleadservices.com) document.querySelectorAll("a[href]").forEach((link) => { const href = link.href || ""; if ( href.includes("googleadservices.com") || href.includes("/aclk") ) { let displayUrl = href; try { displayUrl = new URL(href).searchParams.get("adurl") || href; } catch {} addResult(link.innerText, href, displayUrl, "ad"); } }); // 3. Sponsored containers (data attributes) document .querySelectorAll("[data-rw], [data-text-ad], .uEierd") .forEach((el) => { const link = el.querySelector("a[href]"); if (link) { addResult(link.innerText, link.href, link.href, "ad-container"); } }); // 4. Local Pack website links document.querySelectorAll("a[href]").forEach((link) => { const text = (link.innerText || "").trim().toLowerCase(); if (text === "sito web" || text === "website") { let bizName = ""; const card = link.closest("[data-cid]") || link.closest("[jscontroller]"); if (card) { const h = card.querySelector("[role='heading'], h3"); if (h) bizName = h.textContent.trim(); } addResult( bizName || "Local Business", link.href, link.href, "local-web" ); } }); return results; });
} // ─── Main Click Logic ──────────────────────────────────────────────── async function singleClick(job) { const { keyword, country, city, target_link, mode, account_email, click_target_id, campaign_id, } = job.data; const db = getDB(); let browser = null; let context = null; let page = null; let protection = null; try { // ── Phase 1: Get clean IP and launch browser ───────────────── const MAX_IP_RETRIES = 5; for (let ipAttempt = 1; ipAttempt <= MAX_IP_RETRIES; ipAttempt++) { // Close previous browser if retrying if (browser) { await browser.close().catch(() => {}); browser = null; } protection = await getCleanIP(country); console.log( `[Click] Attempt ${ipAttempt}/${MAX_IP_RETRIES}: ` + `IP ${protection.ip}, keyword "${keyword}"` ); // Launch browser with stealth config browser = await chromium.launch({ headless: false, args: ["--no-sandbox"], // DO NOT add --disable-blink-features=AutomationControlled // It BREAKS Patchright's built-in patches! }); const coords = await getCityCoords(city, country); context = await browser.newContext({ userAgent: getRandomUA(), viewport: { width: 351, height: 878 }, locale: coords.locale, timezoneId: coords.timezone_id, geolocation: { latitude: parseFloat(coords.latitude), longitude: parseFloat(coords.longitude), }, permissions: ["geolocation"], isMobile: true, hasTouch: true, // Proxy at CONTEXT level, not browser level proxy: protection.proxy ? { server: protection.proxy.server, username: protection.proxy.username, password: protection.proxy.password, } : undefined, }); await applyStealthScripts(context, country); page = await context.newPage(); // ── Phase 2: Navigate to Google and search ───────────────── const searchUrl = `https://${getGoogleDomain(country)}` + `/search?q=${encodeURIComponent(keyword)}`; await navigateWithConsent(page, searchUrl); await scrollAndExpand(page); // ── Phase 3: CAPTCHA detection ───────────────────────────── const content = await page.content(); const isCaptcha = content.includes("unusual traffic") || content.includes("CAPTCHA") || page.url().includes("/sorry/"); if (isCaptcha) { console.log( `[Click] CAPTCHA detected on IP ${protection.ip}, blacklisting` ); await blacklistIP(protection.ip, "CAPTCHA detected", country); if (ipAttempt < MAX_IP_RETRIES) continue; throw new Error("CAPTCHA on all IPs after " + MAX_IP_RETRIES + " attempts"); } // Clean page -- break out of retry loop break; } // ── Phase 4: Parse and filter sponsored results ────────────── const sponsored = await parseSponsoredResults(page); console.log( `[Click] Found ${sponsored.length} sponsored results for "${keyword}"` ); let linksToClick = []; for (const result of sponsored) { const isTarget = result.href .toLowerCase() .includes(target_link.toLowerCase()); if (mode === "boost" && isTarget) { linksToClick.push(result); } else if (mode === "drain" && !isTarget) { linksToClick.push(result); } } if (linksToClick.length === 0) { console.log(`[Click] No matching ads found for mode "${mode}"`); await db.query( `INSERT INTO clicks (click_target_id, campaign_id, keyword, mode, links_clicked, vpn_ip, vpn_country, status, error_message) VALUES ($1,$2,$3,$4,$5,$6,$7,'no_ads',$8)`, [ click_target_id, campaign_id, keyword, mode, [], protection?.ip, country, "No matching ads found on page", ] ); return { success: false, reason: "no_matching_ads" }; } // ── Phase 5: Click ads and dwell ───────────────────────────── const clickedUrls = []; for (const linkData of linksToClick) { try { const link = page.locator(`a[href="${linkData.href}"]`).first(); if (await link.isVisible({ timeout: 3000 })) { await link.click(); clickedUrls.push(linkData.displayUrl); console.log( `[Click] Clicked: ${linkData.type} - ${linkData.displayUrl.slice(0, 80)}` ); // Simulate human browsing behavior on landing page await sleep(rand(2000, 4000)); // Scroll around the landing page (dwell time signal) for (let s = 0; s < 5; s++) { await page.mouse.wheel(0, rand(150, 400)); await sleep(rand(2000, 4000)); } // Go back to search results await page .goBack({ waitUntil: "domcontentloaded", timeout: 10000 }) .catch(() => {}); await sleep(rand(2000, 4000)); } } catch (e) { console.log(`[Click] Failed to click ${linkData.href}: ${e.message}`); } } // ── Phase 6: Save results to database ──────────────────────── const dwellTime = 35; // approximate total dwell seconds await db.query( `INSERT INTO clicks (click_target_id, campaign_id, keyword, mode, links_clicked, dwell_time_seconds, vpn_ip, vpn_country, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'completed')`, [ click_target_id, campaign_id, keyword, mode, clickedUrls, dwellTime, protection?.ip, country, ] ); // Update total clicks on target if (click_target_id) { await db.query( `UPDATE click_targets SET total_clicks = total_clicks + $1 WHERE id = $2`, [clickedUrls.length, click_target_id] ); } console.log( `[Click] Completed: ${clickedUrls.length} clicks for "${keyword}"` ); return { success: true, clicked: clickedUrls.length }; } catch (error) { console.error(`[Click] Error: ${error.message}`); // Log the failure await db .query( `INSERT INTO clicks (click_target_id, campaign_id, keyword, mode, vpn_ip, vpn_country, status, error_message) VALUES ($1,$2,$3,$4,$5,$6,'error',$7)`, [ click_target_id, campaign_id, keyword, mode, protection?.ip, country, error.message, ] ) .catch(() => {}); throw error; } finally { // Always close browser if (browser) { await browser.close().catch(() => {}); } }
} // ─── BullMQ Worker ───────────────────────────────────────────────────
// Concurrency: 1 -- only one browser session at a time.
// Multiple parallel sessions from the same server IP would be
// an obvious automation signal. const redisOpts = { connection: { host: "127.0.0.1", port: 6379, password: process.env.REDIS_URL?.match(/:([^@]+)@/)?.[1] || "", },
}; const worker = new Worker("clicks", async (job) => singleClick(job), { ...redisOpts, concurrency: 1, limiter: { max: 1, duration: 5000, // At most 1 job per 5 seconds },
}); worker.on("completed", (job, result) => { console.log( `[Worker] Job ${job.id} completed: ${JSON.stringify(result)}` );
}); worker.on("failed", (job, err) => { console.error(`[Worker] Job ${job?.id} failed: ${err.message}`);
}); worker.on("error", (err) => { console.error(`[Worker] Error: ${err.message}`);
}); console.log("[Worker] Click worker started, waiting for jobs...");
require("dotenv").config();
const { chromium } = require("patchright");
const { Worker } = require("bullmq");
const { getDB, rand, sleep, navigateWithConsent, getCleanIP, blacklistIP, getCityCoords,
} = require("./browser-utils"); // ─── Stealth Configuration ─────────────────────────────────────────── const MOBILE_USER_AGENTS = [ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 " + "Chrome/131.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) " + "AppleWebKit/605.1.15 CriOS/131.0.6778.73 Mobile/15E148 Safari/604.1",
]; function getRandomUA() { return MOBILE_USER_AGENTS[ Math.floor(Math.random() * MOBILE_USER_AGENTS.length) ];
} function getGoogleDomain(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return "www.google.it"; if (c === "uk" || c === "gb") return "www.google.co.uk"; if (c === "france" || c === "fr") return "www.google.fr"; if (c === "germany" || c === "de") return "www.google.de"; return "www.google.com";
} function getLanguages(country) { const c = (country || "").toLowerCase(); if (c === "italy" || c === "it") return ["it-IT", "it", "en"]; if (c === "france" || c === "fr") return ["fr-FR", "fr", "en"]; if (c === "germany" || c === "de") return ["de-DE", "de", "en"]; return ["en-GB", "en-US", "en"];
} // ─── Stealth Script Injection ──────────────────────────────────────── async function applyStealthScripts(context, country) { const langs = getLanguages(country); await context.addInitScript((langsArg) => { // Fake window.chrome Object.defineProperty(window, "chrome", { get: () => ({ runtime: {}, app: { isInstalled: false }, csi: () => {}, loadTimes: () => {}, }), }); // Fake navigator.plugins Object.defineProperty(navigator, "plugins", { get: () => { const arr = [ { name: "Chrome PDF Plugin", filename: "internal-pdf-viewer" }, { name: "Chrome PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", }, { name: "Native Client", filename: "internal-nacl-plugin", }, ]; arr.item = (i) => arr[i]; arr.namedItem = (n) => arr.find((p) => p.name === n); arr.refresh = () => {}; return arr; }, }); // Geo-matched languages Object.defineProperty(navigator, "languages", { get: () => langsArg, }); // Remove CDP markers Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Array", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Promise", { get: () => undefined, }); Object.defineProperty(window, "cdc_adoQpoasnfa76pfcZLmcfl_Symbol", { get: () => undefined, }); // WebGL GPU spoofing const getParameter = WebGLRenderingContext.prototype.getParameter; WebGLRenderingContext.prototype.getParameter = function (param) { if (param === 37445) return "Google Inc. (Qualcomm)"; if (param === 37446) return "ANGLE (Qualcomm, Adreno (TM) 740, OpenGL ES 3.2)"; return getParameter.call(this, param); }; }, langs);
} // ─── Scroll and Expand Results ───────────────────────────────────────
// Mimics a human scrolling through search results. Also clicks
// "Show more" buttons that Google hides additional ad results behind. async function scrollAndExpand(page) { for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, rand(300, 600)); await sleep(rand(600, 1500)); } // Try expanding hidden results (localized button texts) const expandButtons = [ "text=Mostra altro", "text=Show more", "text=More businesses", ]; for (const sel of expandButtons) { try { const btn = page.locator(sel).first(); if (await btn.isVisible({ timeout: 1500 })) { await btn.click(); await sleep(rand(2000, 3000)); } } catch { // Button not present } } // Scroll back to top await page.evaluate(() => window.scrollTo(0, 0));
} // ─── Parse Sponsored Results ─────────────────────────────────────────
// Extracts all sponsored/ad links from the Google search results page.
// Covers four types of sponsored content:
// 1. Local Services Ads (LSA) -- "/localservices/profile" links
// 2. Traditional Google Ads -- "googleadservices.com" or "/aclk" links
// 3. Sponsored containers -- elements with [data-rw], [data-text-ad]
// 4. Local Pack website links -- "Website"/"Sito web" links in map cards async function parseSponsoredResults(page) { return page.evaluate(() => { const results = []; const seen = new Set(); function addResult(text, href, displayUrl, type) { if (seen.has(href)) return; if (!text || text.trim().length < 3) return; seen.add(href); results.push({ text: text.trim().slice(0, 200), href, displayUrl: displayUrl || href, type, position: results.length + 1, }); } // 1. Local Services Ads (LSA) document .querySelectorAll('a[href*="/localservices/profile"]') .forEach((link) => { addResult(link.innerText, link.href, link.href, "lsa"); }); // 2. Traditional Google Ads (via googleadservices.com) document.querySelectorAll("a[href]").forEach((link) => { const href = link.href || ""; if ( href.includes("googleadservices.com") || href.includes("/aclk") ) { let displayUrl = href; try { displayUrl = new URL(href).searchParams.get("adurl") || href; } catch {} addResult(link.innerText, href, displayUrl, "ad"); } }); // 3. Sponsored containers (data attributes) document .querySelectorAll("[data-rw], [data-text-ad], .uEierd") .forEach((el) => { const link = el.querySelector("a[href]"); if (link) { addResult(link.innerText, link.href, link.href, "ad-container"); } }); // 4. Local Pack website links document.querySelectorAll("a[href]").forEach((link) => { const text = (link.innerText || "").trim().toLowerCase(); if (text === "sito web" || text === "website") { let bizName = ""; const card = link.closest("[data-cid]") || link.closest("[jscontroller]"); if (card) { const h = card.querySelector("[role='heading'], h3"); if (h) bizName = h.textContent.trim(); } addResult( bizName || "Local Business", link.href, link.href, "local-web" ); } }); return results; });
} // ─── Main Click Logic ──────────────────────────────────────────────── async function singleClick(job) { const { keyword, country, city, target_link, mode, account_email, click_target_id, campaign_id, } = job.data; const db = getDB(); let browser = null; let context = null; let page = null; let protection = null; try { // ── Phase 1: Get clean IP and launch browser ───────────────── const MAX_IP_RETRIES = 5; for (let ipAttempt = 1; ipAttempt <= MAX_IP_RETRIES; ipAttempt++) { // Close previous browser if retrying if (browser) { await browser.close().catch(() => {}); browser = null; } protection = await getCleanIP(country); console.log( `[Click] Attempt ${ipAttempt}/${MAX_IP_RETRIES}: ` + `IP ${protection.ip}, keyword "${keyword}"` ); // Launch browser with stealth config browser = await chromium.launch({ headless: false, args: ["--no-sandbox"], // DO NOT add --disable-blink-features=AutomationControlled // It BREAKS Patchright's built-in patches! }); const coords = await getCityCoords(city, country); context = await browser.newContext({ userAgent: getRandomUA(), viewport: { width: 351, height: 878 }, locale: coords.locale, timezoneId: coords.timezone_id, geolocation: { latitude: parseFloat(coords.latitude), longitude: parseFloat(coords.longitude), }, permissions: ["geolocation"], isMobile: true, hasTouch: true, // Proxy at CONTEXT level, not browser level proxy: protection.proxy ? { server: protection.proxy.server, username: protection.proxy.username, password: protection.proxy.password, } : undefined, }); await applyStealthScripts(context, country); page = await context.newPage(); // ── Phase 2: Navigate to Google and search ───────────────── const searchUrl = `https://${getGoogleDomain(country)}` + `/search?q=${encodeURIComponent(keyword)}`; await navigateWithConsent(page, searchUrl); await scrollAndExpand(page); // ── Phase 3: CAPTCHA detection ───────────────────────────── const content = await page.content(); const isCaptcha = content.includes("unusual traffic") || content.includes("CAPTCHA") || page.url().includes("/sorry/"); if (isCaptcha) { console.log( `[Click] CAPTCHA detected on IP ${protection.ip}, blacklisting` ); await blacklistIP(protection.ip, "CAPTCHA detected", country); if (ipAttempt < MAX_IP_RETRIES) continue; throw new Error("CAPTCHA on all IPs after " + MAX_IP_RETRIES + " attempts"); } // Clean page -- break out of retry loop break; } // ── Phase 4: Parse and filter sponsored results ────────────── const sponsored = await parseSponsoredResults(page); console.log( `[Click] Found ${sponsored.length} sponsored results for "${keyword}"` ); let linksToClick = []; for (const result of sponsored) { const isTarget = result.href .toLowerCase() .includes(target_link.toLowerCase()); if (mode === "boost" && isTarget) { linksToClick.push(result); } else if (mode === "drain" && !isTarget) { linksToClick.push(result); } } if (linksToClick.length === 0) { console.log(`[Click] No matching ads found for mode "${mode}"`); await db.query( `INSERT INTO clicks (click_target_id, campaign_id, keyword, mode, links_clicked, vpn_ip, vpn_country, status, error_message) VALUES ($1,$2,$3,$4,$5,$6,$7,'no_ads',$8)`, [ click_target_id, campaign_id, keyword, mode, [], protection?.ip, country, "No matching ads found on page", ] ); return { success: false, reason: "no_matching_ads" }; } // ── Phase 5: Click ads and dwell ───────────────────────────── const clickedUrls = []; for (const linkData of linksToClick) { try { const link = page.locator(`a[href="${linkData.href}"]`).first(); if (await link.isVisible({ timeout: 3000 })) { await link.click(); clickedUrls.push(linkData.displayUrl); console.log( `[Click] Clicked: ${linkData.type} - ${linkData.displayUrl.slice(0, 80)}` ); // Simulate human browsing behavior on landing page await sleep(rand(2000, 4000)); // Scroll around the landing page (dwell time signal) for (let s = 0; s < 5; s++) { await page.mouse.wheel(0, rand(150, 400)); await sleep(rand(2000, 4000)); } // Go back to search results await page .goBack({ waitUntil: "domcontentloaded", timeout: 10000 }) .catch(() => {}); await sleep(rand(2000, 4000)); } } catch (e) { console.log(`[Click] Failed to click ${linkData.href}: ${e.message}`); } } // ── Phase 6: Save results to database ──────────────────────── const dwellTime = 35; // approximate total dwell seconds await db.query( `INSERT INTO clicks (click_target_id, campaign_id, keyword, mode, links_clicked, dwell_time_seconds, vpn_ip, vpn_country, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'completed')`, [ click_target_id, campaign_id, keyword, mode, clickedUrls, dwellTime, protection?.ip, country, ] ); // Update total clicks on target if (click_target_id) { await db.query( `UPDATE click_targets SET total_clicks = total_clicks + $1 WHERE id = $2`, [clickedUrls.length, click_target_id] ); } console.log( `[Click] Completed: ${clickedUrls.length} clicks for "${keyword}"` ); return { success: true, clicked: clickedUrls.length }; } catch (error) { console.error(`[Click] Error: ${error.message}`); // Log the failure await db .query( `INSERT INTO clicks (click_target_id, campaign_id, keyword, mode, vpn_ip, vpn_country, status, error_message) VALUES ($1,$2,$3,$4,$5,$6,'error',$7)`, [ click_target_id, campaign_id, keyword, mode, protection?.ip, country, error.message, ] ) .catch(() => {}); throw error; } finally { // Always close browser if (browser) { await browser.close().catch(() => {}); } }
} // ─── BullMQ Worker ───────────────────────────────────────────────────
// Concurrency: 1 -- only one browser session at a time.
// Multiple parallel sessions from the same server IP would be
// an obvious automation signal. const redisOpts = { connection: { host: "127.0.0.1", port: 6379, password: process.env.REDIS_URL?.match(/:([^@]+)@/)?.[1] || "", },
}; const worker = new Worker("clicks", async (job) => singleClick(job), { ...redisOpts, concurrency: 1, limiter: { max: 1, duration: 5000, // At most 1 job per 5 seconds },
}); worker.on("completed", (job, result) => { console.log( `[Worker] Job ${job.id} completed: ${JSON.stringify(result)}` );
}); worker.on("failed", (job, err) => { console.error(`[Worker] Job ${job?.id} failed: ${err.message}`);
}); worker.on("error", (err) => { console.error(`[Worker] Error: ${err.message}`);
}); console.log("[Worker] Click worker started, waiting for jobs...");
require("dotenv").config();
const express = require("express");
const path = require("path");
const { Pool } = require("pg");
const { Queue } = require("bullmq"); const app = express();
app.use(express.json()); // ─── Static Panel ──────────────────────────────────────────────────── app.use("/panel", express.static(path.join(__dirname, "panel"))); // ─── Database ──────────────────────────────────────────────────────── const db = new Pool({ connectionString: process.env.DATABASE_URL }); // ─── Redis / BullMQ ────────────────────────────────────────────────── const redisOpts = { connection: { host: "127.0.0.1", port: 6379, password: process.env.REDIS_URL?.match(/:([^@]+)@/)?.[1] || "", },
}; const clickQueue = new Queue("clicks", redisOpts); // ─── Health Check ──────────────────────────────────────────────────── app.get("/api/health", async (req, res) => { const checks = {}; // Database try { await db.query("SELECT 1"); checks.database = "ok"; } catch (e) { checks.database = `error: ${e.message}`; } // Redis try { const waiting = await clickQueue.getWaitingCount(); const active = await clickQueue.getActiveCount(); checks.redis = "ok"; checks.queue = { waiting, active }; } catch (e) { checks.redis = `error: ${e.message}`; } const allOk = checks.database === "ok" && checks.redis === "ok"; res.status(allOk ? 200 : 503).json({ status: allOk ? "healthy" : "degraded", checks, uptime: process.uptime(), });
}); // ─── Click Targets CRUD ────────────────────────────────────────────── app.get("/api/click-targets", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM click_targets ORDER BY id DESC" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.post("/api/click-targets", async (req, res) => { try { const { keyword, country, city, target_link, mode } = req.body; if (!keyword || !country || !city || !target_link || !mode) { return res.status(400).json({ error: "Missing required fields" }); } // Look up city coordinates const { rows: cityRows } = await db.query( `SELECT latitude, longitude FROM cities WHERE LOWER(name) = LOWER($1) AND LOWER(country) = LOWER($2)`, [city, country] ); const lat = cityRows[0]?.latitude || null; const lng = cityRows[0]?.longitude || null; const { rows } = await db.query( `INSERT INTO click_targets (keyword, country, city, coordinates_lat, coordinates_lng, target_link, mode) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [keyword, country, city, lat, lng, target_link, mode] ); res.status(201).json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.put("/api/click-targets/:id", async (req, res) => { try { const { status } = req.body; const { rows } = await db.query( "UPDATE click_targets SET status = $1 WHERE id = $2 RETURNING *", [status, req.params.id] ); res.json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.delete("/api/click-targets/:id", async (req, res) => { try { await db.query("DELETE FROM click_targets WHERE id = $1", [ req.params.id, ]); res.json({ deleted: true }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Click Campaigns (Queue Jobs) ──────────────────────────────────── app.post("/api/click-campaigns", async (req, res) => { try { const { click_target_id, count } = req.body; const clickCount = count || 1; // Load target details const { rows: targets } = await db.query( "SELECT * FROM click_targets WHERE id = $1", [click_target_id] ); if (targets.length === 0) { return res.status(404).json({ error: "Click target not found" }); } const target = targets[0]; const jobs = []; for (let i = 0; i < clickCount; i++) { const job = await clickQueue.add("click", { keyword: target.keyword, country: target.country, city: target.city, target_link: target.target_link, mode: target.mode, click_target_id: target.id, campaign_id: Date.now(), // Simple campaign grouping }); jobs.push({ jobId: job.id, position: i + 1 }); } res.json({ queued: clickCount, jobs, target: { keyword: target.keyword, mode: target.mode, }, }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Click History ─────────────────────────────────────────────────── app.get("/api/clicks", async (req, res) => { try { const limit = parseInt(req.query.limit) || 50; const { rows } = await db.query( `SELECT c.*, ct.keyword as target_keyword, ct.mode as target_mode FROM clicks c LEFT JOIN click_targets ct ON c.click_target_id = ct.id ORDER BY c.created_at DESC LIMIT $1`, [limit] ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Proxies CRUD ──────────────────────────────────────────────────── app.get("/api/proxies", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM proxies ORDER BY id DESC" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.post("/api/proxies", async (req, res) => { try { const { provider, geo, city, endpoint, username, password } = req.body; const { rows } = await db.query( `INSERT INTO proxies (provider, geo, city, endpoint, username, password) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [provider, geo, city, endpoint, username, password] ); res.status(201).json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.put("/api/proxies/:id", async (req, res) => { try { const { active } = req.body; const { rows } = await db.query( "UPDATE proxies SET active = $1 WHERE id = $2 RETURNING *", [active, req.params.id] ); res.json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.delete("/api/proxies/:id", async (req, res) => { try { await db.query("DELETE FROM proxies WHERE id = $1", [req.params.id]); res.json({ deleted: true }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Accounts CRUD ─────────────────────────────────────────────────── app.get("/api/accounts", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM accounts ORDER BY id DESC" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.post("/api/accounts", async (req, res) => { try { const { email, password, purpose, country, city } = req.body; const { rows } = await db.query( `INSERT INTO accounts (email, password, purpose, country, city) VALUES ($1,$2,$3,$4,$5) RETURNING *`, [email, password, purpose || "click", country, city] ); res.status(201).json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── IP Blacklist ──────────────────────────────────────────────────── app.get("/api/blacklist", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM blacklisted_ips ORDER BY blocked_at DESC LIMIT 100" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.delete("/api/blacklist", async (req, res) => { try { const result = await db.query("DELETE FROM blacklisted_ips"); res.json({ cleared: result.rowCount }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Queue Status ──────────────────────────────────────────────────── app.get("/api/queue/status", async (req, res) => { try { const [waiting, active, completed, failed] = await Promise.all([ clickQueue.getWaitingCount(), clickQueue.getActiveCount(), clickQueue.getCompletedCount(), clickQueue.getFailedCount(), ]); res.json({ waiting, active, completed, failed }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Statistics ────────────────────────────────────────────────────── app.get("/api/stats", async (req, res) => { try { const [clickStats, targetStats, proxyStats, blacklistStats] = await Promise.all([ db.query(` SELECT COUNT(*) as total_clicks, COUNT(*) FILTER (WHERE status = 'completed') as successful, COUNT(*) FILTER (WHERE status = 'error') as failed, COUNT(*) FILTER (WHERE status = 'no_ads') as no_ads, COUNT(*) FILTER (WHERE created_at > now() - interval '24 hours') as last_24h FROM clicks `), db.query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'active') as active FROM click_targets `), db.query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE active = true) as active FROM proxies `), db.query("SELECT COUNT(*) as total FROM blacklisted_ips"), ]); res.json({ clicks: clickStats.rows[0], targets: targetStats.rows[0], proxies: proxyStats.rows[0], blacklisted_ips: blacklistStats.rows[0], }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Start Server ──────────────────────────────────────────────────── const PORT = process.env.PORT || 3500;
app.listen(PORT, () => { console.log(`[Server] API listening on port ${PORT}`); console.log(`[Server] Panel: http://localhost:${PORT}/panel/`);
});
require("dotenv").config();
const express = require("express");
const path = require("path");
const { Pool } = require("pg");
const { Queue } = require("bullmq"); const app = express();
app.use(express.json()); // ─── Static Panel ──────────────────────────────────────────────────── app.use("/panel", express.static(path.join(__dirname, "panel"))); // ─── Database ──────────────────────────────────────────────────────── const db = new Pool({ connectionString: process.env.DATABASE_URL }); // ─── Redis / BullMQ ────────────────────────────────────────────────── const redisOpts = { connection: { host: "127.0.0.1", port: 6379, password: process.env.REDIS_URL?.match(/:([^@]+)@/)?.[1] || "", },
}; const clickQueue = new Queue("clicks", redisOpts); // ─── Health Check ──────────────────────────────────────────────────── app.get("/api/health", async (req, res) => { const checks = {}; // Database try { await db.query("SELECT 1"); checks.database = "ok"; } catch (e) { checks.database = `error: ${e.message}`; } // Redis try { const waiting = await clickQueue.getWaitingCount(); const active = await clickQueue.getActiveCount(); checks.redis = "ok"; checks.queue = { waiting, active }; } catch (e) { checks.redis = `error: ${e.message}`; } const allOk = checks.database === "ok" && checks.redis === "ok"; res.status(allOk ? 200 : 503).json({ status: allOk ? "healthy" : "degraded", checks, uptime: process.uptime(), });
}); // ─── Click Targets CRUD ────────────────────────────────────────────── app.get("/api/click-targets", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM click_targets ORDER BY id DESC" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.post("/api/click-targets", async (req, res) => { try { const { keyword, country, city, target_link, mode } = req.body; if (!keyword || !country || !city || !target_link || !mode) { return res.status(400).json({ error: "Missing required fields" }); } // Look up city coordinates const { rows: cityRows } = await db.query( `SELECT latitude, longitude FROM cities WHERE LOWER(name) = LOWER($1) AND LOWER(country) = LOWER($2)`, [city, country] ); const lat = cityRows[0]?.latitude || null; const lng = cityRows[0]?.longitude || null; const { rows } = await db.query( `INSERT INTO click_targets (keyword, country, city, coordinates_lat, coordinates_lng, target_link, mode) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [keyword, country, city, lat, lng, target_link, mode] ); res.status(201).json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.put("/api/click-targets/:id", async (req, res) => { try { const { status } = req.body; const { rows } = await db.query( "UPDATE click_targets SET status = $1 WHERE id = $2 RETURNING *", [status, req.params.id] ); res.json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.delete("/api/click-targets/:id", async (req, res) => { try { await db.query("DELETE FROM click_targets WHERE id = $1", [ req.params.id, ]); res.json({ deleted: true }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Click Campaigns (Queue Jobs) ──────────────────────────────────── app.post("/api/click-campaigns", async (req, res) => { try { const { click_target_id, count } = req.body; const clickCount = count || 1; // Load target details const { rows: targets } = await db.query( "SELECT * FROM click_targets WHERE id = $1", [click_target_id] ); if (targets.length === 0) { return res.status(404).json({ error: "Click target not found" }); } const target = targets[0]; const jobs = []; for (let i = 0; i < clickCount; i++) { const job = await clickQueue.add("click", { keyword: target.keyword, country: target.country, city: target.city, target_link: target.target_link, mode: target.mode, click_target_id: target.id, campaign_id: Date.now(), // Simple campaign grouping }); jobs.push({ jobId: job.id, position: i + 1 }); } res.json({ queued: clickCount, jobs, target: { keyword: target.keyword, mode: target.mode, }, }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Click History ─────────────────────────────────────────────────── app.get("/api/clicks", async (req, res) => { try { const limit = parseInt(req.query.limit) || 50; const { rows } = await db.query( `SELECT c.*, ct.keyword as target_keyword, ct.mode as target_mode FROM clicks c LEFT JOIN click_targets ct ON c.click_target_id = ct.id ORDER BY c.created_at DESC LIMIT $1`, [limit] ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Proxies CRUD ──────────────────────────────────────────────────── app.get("/api/proxies", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM proxies ORDER BY id DESC" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.post("/api/proxies", async (req, res) => { try { const { provider, geo, city, endpoint, username, password } = req.body; const { rows } = await db.query( `INSERT INTO proxies (provider, geo, city, endpoint, username, password) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [provider, geo, city, endpoint, username, password] ); res.status(201).json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.put("/api/proxies/:id", async (req, res) => { try { const { active } = req.body; const { rows } = await db.query( "UPDATE proxies SET active = $1 WHERE id = $2 RETURNING *", [active, req.params.id] ); res.json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.delete("/api/proxies/:id", async (req, res) => { try { await db.query("DELETE FROM proxies WHERE id = $1", [req.params.id]); res.json({ deleted: true }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Accounts CRUD ─────────────────────────────────────────────────── app.get("/api/accounts", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM accounts ORDER BY id DESC" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.post("/api/accounts", async (req, res) => { try { const { email, password, purpose, country, city } = req.body; const { rows } = await db.query( `INSERT INTO accounts (email, password, purpose, country, city) VALUES ($1,$2,$3,$4,$5) RETURNING *`, [email, password, purpose || "click", country, city] ); res.status(201).json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── IP Blacklist ──────────────────────────────────────────────────── app.get("/api/blacklist", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM blacklisted_ips ORDER BY blocked_at DESC LIMIT 100" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.delete("/api/blacklist", async (req, res) => { try { const result = await db.query("DELETE FROM blacklisted_ips"); res.json({ cleared: result.rowCount }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Queue Status ──────────────────────────────────────────────────── app.get("/api/queue/status", async (req, res) => { try { const [waiting, active, completed, failed] = await Promise.all([ clickQueue.getWaitingCount(), clickQueue.getActiveCount(), clickQueue.getCompletedCount(), clickQueue.getFailedCount(), ]); res.json({ waiting, active, completed, failed }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Statistics ────────────────────────────────────────────────────── app.get("/api/stats", async (req, res) => { try { const [clickStats, targetStats, proxyStats, blacklistStats] = await Promise.all([ db.query(` SELECT COUNT(*) as total_clicks, COUNT(*) FILTER (WHERE status = 'completed') as successful, COUNT(*) FILTER (WHERE status = 'error') as failed, COUNT(*) FILTER (WHERE status = 'no_ads') as no_ads, COUNT(*) FILTER (WHERE created_at > now() - interval '24 hours') as last_24h FROM clicks `), db.query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'active') as active FROM click_targets `), db.query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE active = true) as active FROM proxies `), db.query("SELECT COUNT(*) as total FROM blacklisted_ips"), ]); res.json({ clicks: clickStats.rows[0], targets: targetStats.rows[0], proxies: proxyStats.rows[0], blacklisted_ips: blacklistStats.rows[0], }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Start Server ──────────────────────────────────────────────────── const PORT = process.env.PORT || 3500;
app.listen(PORT, () => { console.log(`[Server] API listening on port ${PORT}`); console.log(`[Server] Panel: http://localhost:${PORT}/panel/`);
});
require("dotenv").config();
const express = require("express");
const path = require("path");
const { Pool } = require("pg");
const { Queue } = require("bullmq"); const app = express();
app.use(express.json()); // ─── Static Panel ──────────────────────────────────────────────────── app.use("/panel", express.static(path.join(__dirname, "panel"))); // ─── Database ──────────────────────────────────────────────────────── const db = new Pool({ connectionString: process.env.DATABASE_URL }); // ─── Redis / BullMQ ────────────────────────────────────────────────── const redisOpts = { connection: { host: "127.0.0.1", port: 6379, password: process.env.REDIS_URL?.match(/:([^@]+)@/)?.[1] || "", },
}; const clickQueue = new Queue("clicks", redisOpts); // ─── Health Check ──────────────────────────────────────────────────── app.get("/api/health", async (req, res) => { const checks = {}; // Database try { await db.query("SELECT 1"); checks.database = "ok"; } catch (e) { checks.database = `error: ${e.message}`; } // Redis try { const waiting = await clickQueue.getWaitingCount(); const active = await clickQueue.getActiveCount(); checks.redis = "ok"; checks.queue = { waiting, active }; } catch (e) { checks.redis = `error: ${e.message}`; } const allOk = checks.database === "ok" && checks.redis === "ok"; res.status(allOk ? 200 : 503).json({ status: allOk ? "healthy" : "degraded", checks, uptime: process.uptime(), });
}); // ─── Click Targets CRUD ────────────────────────────────────────────── app.get("/api/click-targets", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM click_targets ORDER BY id DESC" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.post("/api/click-targets", async (req, res) => { try { const { keyword, country, city, target_link, mode } = req.body; if (!keyword || !country || !city || !target_link || !mode) { return res.status(400).json({ error: "Missing required fields" }); } // Look up city coordinates const { rows: cityRows } = await db.query( `SELECT latitude, longitude FROM cities WHERE LOWER(name) = LOWER($1) AND LOWER(country) = LOWER($2)`, [city, country] ); const lat = cityRows[0]?.latitude || null; const lng = cityRows[0]?.longitude || null; const { rows } = await db.query( `INSERT INTO click_targets (keyword, country, city, coordinates_lat, coordinates_lng, target_link, mode) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [keyword, country, city, lat, lng, target_link, mode] ); res.status(201).json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.put("/api/click-targets/:id", async (req, res) => { try { const { status } = req.body; const { rows } = await db.query( "UPDATE click_targets SET status = $1 WHERE id = $2 RETURNING *", [status, req.params.id] ); res.json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.delete("/api/click-targets/:id", async (req, res) => { try { await db.query("DELETE FROM click_targets WHERE id = $1", [ req.params.id, ]); res.json({ deleted: true }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Click Campaigns (Queue Jobs) ──────────────────────────────────── app.post("/api/click-campaigns", async (req, res) => { try { const { click_target_id, count } = req.body; const clickCount = count || 1; // Load target details const { rows: targets } = await db.query( "SELECT * FROM click_targets WHERE id = $1", [click_target_id] ); if (targets.length === 0) { return res.status(404).json({ error: "Click target not found" }); } const target = targets[0]; const jobs = []; for (let i = 0; i < clickCount; i++) { const job = await clickQueue.add("click", { keyword: target.keyword, country: target.country, city: target.city, target_link: target.target_link, mode: target.mode, click_target_id: target.id, campaign_id: Date.now(), // Simple campaign grouping }); jobs.push({ jobId: job.id, position: i + 1 }); } res.json({ queued: clickCount, jobs, target: { keyword: target.keyword, mode: target.mode, }, }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Click History ─────────────────────────────────────────────────── app.get("/api/clicks", async (req, res) => { try { const limit = parseInt(req.query.limit) || 50; const { rows } = await db.query( `SELECT c.*, ct.keyword as target_keyword, ct.mode as target_mode FROM clicks c LEFT JOIN click_targets ct ON c.click_target_id = ct.id ORDER BY c.created_at DESC LIMIT $1`, [limit] ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Proxies CRUD ──────────────────────────────────────────────────── app.get("/api/proxies", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM proxies ORDER BY id DESC" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.post("/api/proxies", async (req, res) => { try { const { provider, geo, city, endpoint, username, password } = req.body; const { rows } = await db.query( `INSERT INTO proxies (provider, geo, city, endpoint, username, password) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, [provider, geo, city, endpoint, username, password] ); res.status(201).json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.put("/api/proxies/:id", async (req, res) => { try { const { active } = req.body; const { rows } = await db.query( "UPDATE proxies SET active = $1 WHERE id = $2 RETURNING *", [active, req.params.id] ); res.json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.delete("/api/proxies/:id", async (req, res) => { try { await db.query("DELETE FROM proxies WHERE id = $1", [req.params.id]); res.json({ deleted: true }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Accounts CRUD ─────────────────────────────────────────────────── app.get("/api/accounts", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM accounts ORDER BY id DESC" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.post("/api/accounts", async (req, res) => { try { const { email, password, purpose, country, city } = req.body; const { rows } = await db.query( `INSERT INTO accounts (email, password, purpose, country, city) VALUES ($1,$2,$3,$4,$5) RETURNING *`, [email, password, purpose || "click", country, city] ); res.status(201).json(rows[0]); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── IP Blacklist ──────────────────────────────────────────────────── app.get("/api/blacklist", async (req, res) => { try { const { rows } = await db.query( "SELECT * FROM blacklisted_ips ORDER BY blocked_at DESC LIMIT 100" ); res.json(rows); } catch (e) { res.status(500).json({ error: e.message }); }
}); app.delete("/api/blacklist", async (req, res) => { try { const result = await db.query("DELETE FROM blacklisted_ips"); res.json({ cleared: result.rowCount }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Queue Status ──────────────────────────────────────────────────── app.get("/api/queue/status", async (req, res) => { try { const [waiting, active, completed, failed] = await Promise.all([ clickQueue.getWaitingCount(), clickQueue.getActiveCount(), clickQueue.getCompletedCount(), clickQueue.getFailedCount(), ]); res.json({ waiting, active, completed, failed }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Statistics ────────────────────────────────────────────────────── app.get("/api/stats", async (req, res) => { try { const [clickStats, targetStats, proxyStats, blacklistStats] = await Promise.all([ db.query(` SELECT COUNT(*) as total_clicks, COUNT(*) FILTER (WHERE status = 'completed') as successful, COUNT(*) FILTER (WHERE status = 'error') as failed, COUNT(*) FILTER (WHERE status = 'no_ads') as no_ads, COUNT(*) FILTER (WHERE created_at > now() - interval '24 hours') as last_24h FROM clicks `), db.query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'active') as active FROM click_targets `), db.query(` SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE active = true) as active FROM proxies `), db.query("SELECT COUNT(*) as total FROM blacklisted_ips"), ]); res.json({ clicks: clickStats.rows[0], targets: targetStats.rows[0], proxies: proxyStats.rows[0], blacklisted_ips: blacklistStats.rows[0], }); } catch (e) { res.status(500).json({ error: e.message }); }
}); // ─── Start Server ──────────────────────────────────────────────────── const PORT = process.env.PORT || 3500;
app.listen(PORT, () => { console.log(`[Server] API listening on port ${PORT}`); console.log(`[Server] Panel: http://localhost:${PORT}/panel/`);
});
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Click Automation</title> <style> :root { --bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a; --text: #e1e4ed; --text-dim: #8b8fa3; --accent: #5b8def; --accent-hover: #7ba4f7; --success: #4ade80; --warning: #fbbf24; --danger: #f87171; --radius: 8px; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; } /* ── Layout ──────────────────────────────────────────────── */ .header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; } .header h1 { font-size: 20px; font-weight: 600; } .header .status { display: flex; gap: 16px; font-size: 13px; color: var(--text-dim); } .header .status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } .dot.green { background: var(--success); } .dot.red { background: var(--danger); } .dot.yellow { background: var(--warning); } .container { max-width: 1400px; margin: 0 auto; padding: 24px; } /* ── Tabs ─────────────────────────────────────────────────── */ .tabs { display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 0; } .tab { padding: 10px 20px; cursor: pointer; border: none; background: none; color: var(--text-dim); font-size: 14px; border-bottom: 2px solid transparent; transition: all 0.2s; } .tab:hover { color: var(--text); } .tab.active { color: var(--accent); border-bottom-color: var(--accent); } .tab-content { display: none; } .tab-content.active { display: block; } /* ── Stats Cards ──────────────────────────────────────────── */ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; } .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; } .stat-card .label { font-size: 12px; text-transform: uppercase; color: var(--text-dim); letter-spacing: 0.5px; } .stat-card .value { font-size: 28px; font-weight: 700; margin-top: 4px; } /* ── Tables ────────────────────────────────────────────────── */ table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } th { text-align: left; padding: 12px 16px; font-size: 12px; text-transform: uppercase; color: var(--text-dim); background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--border); } td { padding: 12px 16px; font-size: 14px; border-bottom: 1px solid var(--border); } tr:last-child td { border-bottom: none; } tr:hover td { background: rgba(255,255,255,0.02); } /* ── Forms ─────────────────────────────────────────────────── */ .form-row { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } input, select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px; color: var(--text); font-size: 14px; outline: none; } input:focus, select:focus { border-color: var(--accent); } input::placeholder { color: var(--text-dim); } /* ── Buttons ───────────────────────────────────────────────── */ .btn { padding: 8px 16px; border: none; border-radius: var(--radius); cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; } .btn-primary { background: var(--accent); color: white; } .btn-primary:hover { background: var(--accent-hover); } .btn-danger { background: var(--danger); color: white; } .btn-danger:hover { opacity: 0.8; } .btn-sm { padding: 4px 10px; font-size: 12px; } .btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text-dim); } .btn-ghost:hover { border-color: var(--text-dim); color: var(--text); } /* ── Badges ────────────────────────────────────────────────── */ .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; } .badge-success { background: rgba(74,222,128,0.15); color: var(--success); } .badge-danger { background: rgba(248,113,113,0.15); color: var(--danger); } .badge-warning { background: rgba(251,191,36,0.15); color: var(--warning); } .badge-info { background: rgba(91,141,239,0.15); color: var(--accent); } .badge-dim { background: rgba(139,143,163,0.15); color: var(--text-dim); } </style>
</head>
<body> <!-- Header --> <div class="header"> <h1>Click Automation</h1> <div class="status"> <span><span class="dot green" id="healthDot"></span> <span id="healthText">Checking...</span></span> <span>Queue: <strong id="queueCount">-</strong></span> </div> </div> <div class="container"> <!-- Stats --> <div class="stats-grid" id="statsGrid"> <div class="stat-card"> <div class="label">Total Clicks</div> <div class="value" id="statTotal">-</div> </div> <div class="stat-card"> <div class="label">Successful</div> <div class="value" style="color:var(--success)" id="statSuccess">-</div> </div> <div class="stat-card"> <div class="label">Failed</div> <div class="value" style="color:var(--danger)" id="statFailed">-</div> </div> <div class="stat-card"> <div class="label">Last 24h</div> <div class="value" style="color:var(--accent)" id="stat24h">-</div> </div> <div class="stat-card"> <div class="label">Blacklisted IPs</div> <div class="value" style="color:var(--warning)" id="statBlacklist">-</div> </div> </div> <!-- Tabs --> <div class="tabs"> <button class="tab active" onclick="switchTab('targets')">Targets</button> <button class="tab" onclick="switchTab('clicks')">Click Log</button> <button class="tab" onclick="switchTab('proxies')">Proxies</button> <button class="tab" onclick="switchTab('accounts')">Accounts</button> <button class="tab" onclick="switchTab('blacklist')">Blacklist</button> </div> <!-- Targets Tab --> <div class="tab-content active" id="tab-targets"> <div class="form-row"> <input id="tKeyword" placeholder="Keyword (e.g. plumber london)" style="flex:2" /> <input id="tCountry" placeholder="Country (e.g. UK)" style="width:100px" /> <input id="tCity" placeholder="City (e.g. London)" style="width:140px" /> <input id="tLink" placeholder="Target link domain" style="flex:2" /> <select id="tMode" style="width:100px"> <option value="boost">Boost</option> <option value="drain">Drain</option> </select> <button class="btn btn-primary" onclick="addTarget()">Add Target</button> </div> <table> <thead> <tr> <th>ID</th><th>Keyword</th><th>Country</th><th>City</th> <th>Target</th><th>Mode</th><th>Clicks</th><th>Status</th> <th>Actions</th> </tr> </thead> <tbody id="targetsBody"></tbody> </table> </div> <!-- Click Log Tab --> <div class="tab-content" id="tab-clicks"> <table> <thead> <tr> <th>Time</th><th>Keyword</th><th>Mode</th><th>Links Clicked</th> <th>IP</th><th>Status</th><th>Error</th> </tr> </thead> <tbody id="clicksBody"></tbody> </table> </div> <!-- Proxies Tab --> <div class="tab-content" id="tab-proxies"> <div class="form-row"> <input id="pProvider" placeholder="Provider (e.g. anyip)" style="width:120px" /> <input id="pGeo" placeholder="Geo (e.g. UK)" style="width:80px" /> <input id="pCity" placeholder="City" style="width:120px" /> <input id="pEndpoint" placeholder="host:port" style="flex:1" /> <input id="pUser" placeholder="Username" style="width:150px" /> <input id="pPass" placeholder="Password" style="width:150px" type="password" /> <button class="btn btn-primary" onclick="addProxy()">Add Proxy</button> </div> <table> <thead> <tr> <th>ID</th><th>Provider</th><th>Geo</th><th>City</th> <th>Endpoint</th><th>Active</th><th>Actions</th> </tr> </thead> <tbody id="proxiesBody"></tbody> </table> </div> <!-- Accounts Tab --> <div class="tab-content" id="tab-accounts"> <div class="form-row"> <input id="aEmail" placeholder="Email" style="flex:1" /> <input id="aPass" placeholder="Password" style="width:150px" type="password" /> <input id="aCountry" placeholder="Country" style="width:100px" /> <input id="aCity" placeholder="City" style="width:120px" /> <button class="btn btn-primary" onclick="addAccount()">Add Account</button> </div> <table> <thead> <tr> <th>ID</th><th>Email</th><th>Country</th><th>City</th> <th>Clicks</th><th>Last Click</th><th>Status</th> </tr> </thead> <tbody id="accountsBody"></tbody> </table> </div> <!-- Blacklist Tab --> <div class="tab-content" id="tab-blacklist"> <div class="form-row"> <button class="btn btn-danger" onclick="clearBlacklist()">Clear All Blacklisted IPs</button> </div> <table> <thead> <tr><th>IP</th><th>Reason</th><th>Country</th><th>Blocked At</th></tr> </thead> <tbody id="blacklistBody"></tbody> </table> </div> </div> <script> const API = '/api'; // ── Tab Switching ───────────────────────────────────────── function switchTab(name) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); event.target.classList.add('active'); document.getElementById('tab-' + name).classList.add('active'); } // ── Data Loading ────────────────────────────────────────── async function loadStats() { try { const res = await fetch(API + '/stats'); const data = await res.json(); document.getElementById('statTotal').textContent = data.clicks.total_clicks; document.getElementById('statSuccess').textContent = data.clicks.successful; document.getElementById('statFailed').textContent = data.clicks.failed; document.getElementById('stat24h').textContent = data.clicks.last_24h; document.getElementById('statBlacklist').textContent = data.blacklisted_ips.total; } catch {} } async function loadHealth() { try { const res = await fetch(API + '/health'); const data = await res.json(); const dot = document.getElementById('healthDot'); const text = document.getElementById('healthText'); dot.className = 'dot ' + (data.status === 'healthy' ? 'green' : 'red'); text.textContent = data.status === 'healthy' ? 'Healthy' : 'Degraded'; } catch { document.getElementById('healthDot').className = 'dot red'; document.getElementById('healthText').textContent = 'Offline'; } } async function loadQueue() { try { const res = await fetch(API + '/queue/status'); const data = await res.json(); document.getElementById('queueCount').textContent = `${data.active} active, ${data.waiting} waiting`; } catch {} } async function loadTargets() { const res = await fetch(API + '/click-targets'); const data = await res.json(); const tbody = document.getElementById('targetsBody'); tbody.innerHTML = data.map(t => ` <tr> <td>${t.id}</td> <td>${t.keyword}</td> <td>${t.country}</td> <td>${t.city}</td> <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis">${t.target_link}</td> <td><span class="badge ${t.mode === 'boost' ? 'badge-success' : 'badge-danger'}">${t.mode}</span></td> <td>${t.total_clicks}</td> <td><span class="badge ${t.status === 'active' ? 'badge-success' : 'badge-dim'}">${t.status}</span></td> <td> <button class="btn btn-sm btn-primary" onclick="runCampaign(${t.id})">Run</button> <button class="btn btn-sm btn-danger" onclick="deleteTarget(${t.id})">Del</button> </td> </tr> `).join(''); } async function loadClicks() { const res = await fetch(API + '/clicks?limit=50'); const data = await res.json(); const tbody = document.getElementById('clicksBody'); tbody.innerHTML = data.map(c => { const time = new Date(c.created_at).toLocaleString(); const links = (c.links_clicked || []).length; const statusClass = c.status === 'completed' ? 'badge-success' : c.status === 'error' ? 'badge-danger' : 'badge-warning'; return ` <tr> <td style="white-space:nowrap">${time}</td> <td>${c.keyword || '-'}</td> <td><span class="badge ${c.mode === 'boost' ? 'badge-success' : 'badge-danger'}">${c.mode || '-'}</span></td> <td>${links}</td> <td>${c.vpn_ip || '-'}</td> <td><span class="badge ${statusClass}">${c.status}</span></td> <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;font-size:12px;color:var(--text-dim)">${c.error_message || ''}</td> </tr> `; }).join(''); } async function loadProxies() { const res = await fetch(API + '/proxies'); const data = await res.json(); const tbody = document.getElementById('proxiesBody'); tbody.innerHTML = data.map(p => ` <tr> <td>${p.id}</td> <td>${p.provider}</td> <td>${p.geo || '-'}</td> <td>${p.city || '-'}</td> <td>${p.endpoint}</td> <td> <span class="badge ${p.active ? 'badge-success' : 'badge-dim'}" style="cursor:pointer" onclick="toggleProxy(${p.id}, ${!p.active})"> ${p.active ? 'Active' : 'Inactive'} </span> </td> <td><button class="btn btn-sm btn-danger" onclick="deleteProxy(${p.id})">Del</button></td> </tr> `).join(''); } async function loadAccounts() { const res = await fetch(API + '/accounts'); const data = await res.json(); const tbody = document.getElementById('accountsBody'); tbody.innerHTML = data.map(a => { const lastClick = a.last_click_at ? new Date(a.last_click_at).toLocaleString() : 'Never'; return ` <tr> <td>${a.id}</td> <td>${a.email}</td> <td>${a.country || '-'}</td> <td>${a.city || '-'}</td> <td>${a.total_clicks}</td> <td>${lastClick}</td> <td><span class="badge ${a.status === 'active' ? 'badge-success' : 'badge-dim'}">${a.status}</span></td> </tr> `; }).join(''); } async function loadBlacklist() { const res = await fetch(API + '/blacklist'); const data = await res.json(); const tbody = document.getElementById('blacklistBody'); tbody.innerHTML = data.map(b => ` <tr> <td><code>${b.ip}</code></td> <td>${b.reason}</td> <td>${b.country || '-'}</td> <td>${new Date(b.blocked_at).toLocaleString()}</td> </tr> `).join(''); } // ── Actions ─────────────────────────────────────────────── async function addTarget() { await fetch(API + '/click-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyword: document.getElementById('tKeyword').value, country: document.getElementById('tCountry').value, city: document.getElementById('tCity').value, target_link: document.getElementById('tLink').value, mode: document.getElementById('tMode').value, }), }); loadTargets(); } async function deleteTarget(id) { if (!confirm('Delete this target?')) return; await fetch(API + '/click-targets/' + id, { method: 'DELETE' }); loadTargets(); } async function runCampaign(targetId) { const count = prompt('How many clicks?', '1'); if (!count) return; const res = await fetch(API + '/click-campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ click_target_id: targetId, count: parseInt(count) }), }); const data = await res.json(); alert(`Queued ${data.queued} click(s)`); loadQueue(); } async function addProxy() { await fetch(API + '/proxies', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: document.getElementById('pProvider').value, geo: document.getElementById('pGeo').value, city: document.getElementById('pCity').value, endpoint: document.getElementById('pEndpoint').value, username: document.getElementById('pUser').value, password: document.getElementById('pPass').value, }), }); loadProxies(); } async function toggleProxy(id, active) { await fetch(API + '/proxies/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ active }), }); loadProxies(); } async function deleteProxy(id) { if (!confirm('Delete this proxy?')) return; await fetch(API + '/proxies/' + id, { method: 'DELETE' }); loadProxies(); } async function addAccount() { await fetch(API + '/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: document.getElementById('aEmail').value, password: document.getElementById('aPass').value, country: document.getElementById('aCountry').value, city: document.getElementById('aCity').value, }), }); loadAccounts(); } async function clearBlacklist() { if (!confirm('Clear ALL blacklisted IPs? They may trigger CAPTCHAs again.')) return; await fetch(API + '/blacklist', { method: 'DELETE' }); loadBlacklist(); loadStats(); } // ── Auto-refresh ────────────────────────────────────────── function loadAll() { loadStats(); loadHealth(); loadQueue(); loadTargets(); loadClicks(); loadProxies(); loadAccounts(); loadBlacklist(); } loadAll(); setInterval(loadAll, 15000); // Refresh every 15 seconds </script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Click Automation</title> <style> :root { --bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a; --text: #e1e4ed; --text-dim: #8b8fa3; --accent: #5b8def; --accent-hover: #7ba4f7; --success: #4ade80; --warning: #fbbf24; --danger: #f87171; --radius: 8px; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; } /* ── Layout ──────────────────────────────────────────────── */ .header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; } .header h1 { font-size: 20px; font-weight: 600; } .header .status { display: flex; gap: 16px; font-size: 13px; color: var(--text-dim); } .header .status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } .dot.green { background: var(--success); } .dot.red { background: var(--danger); } .dot.yellow { background: var(--warning); } .container { max-width: 1400px; margin: 0 auto; padding: 24px; } /* ── Tabs ─────────────────────────────────────────────────── */ .tabs { display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 0; } .tab { padding: 10px 20px; cursor: pointer; border: none; background: none; color: var(--text-dim); font-size: 14px; border-bottom: 2px solid transparent; transition: all 0.2s; } .tab:hover { color: var(--text); } .tab.active { color: var(--accent); border-bottom-color: var(--accent); } .tab-content { display: none; } .tab-content.active { display: block; } /* ── Stats Cards ──────────────────────────────────────────── */ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; } .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; } .stat-card .label { font-size: 12px; text-transform: uppercase; color: var(--text-dim); letter-spacing: 0.5px; } .stat-card .value { font-size: 28px; font-weight: 700; margin-top: 4px; } /* ── Tables ────────────────────────────────────────────────── */ table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } th { text-align: left; padding: 12px 16px; font-size: 12px; text-transform: uppercase; color: var(--text-dim); background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--border); } td { padding: 12px 16px; font-size: 14px; border-bottom: 1px solid var(--border); } tr:last-child td { border-bottom: none; } tr:hover td { background: rgba(255,255,255,0.02); } /* ── Forms ─────────────────────────────────────────────────── */ .form-row { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } input, select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px; color: var(--text); font-size: 14px; outline: none; } input:focus, select:focus { border-color: var(--accent); } input::placeholder { color: var(--text-dim); } /* ── Buttons ───────────────────────────────────────────────── */ .btn { padding: 8px 16px; border: none; border-radius: var(--radius); cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; } .btn-primary { background: var(--accent); color: white; } .btn-primary:hover { background: var(--accent-hover); } .btn-danger { background: var(--danger); color: white; } .btn-danger:hover { opacity: 0.8; } .btn-sm { padding: 4px 10px; font-size: 12px; } .btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text-dim); } .btn-ghost:hover { border-color: var(--text-dim); color: var(--text); } /* ── Badges ────────────────────────────────────────────────── */ .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; } .badge-success { background: rgba(74,222,128,0.15); color: var(--success); } .badge-danger { background: rgba(248,113,113,0.15); color: var(--danger); } .badge-warning { background: rgba(251,191,36,0.15); color: var(--warning); } .badge-info { background: rgba(91,141,239,0.15); color: var(--accent); } .badge-dim { background: rgba(139,143,163,0.15); color: var(--text-dim); } </style>
</head>
<body> <!-- Header --> <div class="header"> <h1>Click Automation</h1> <div class="status"> <span><span class="dot green" id="healthDot"></span> <span id="healthText">Checking...</span></span> <span>Queue: <strong id="queueCount">-</strong></span> </div> </div> <div class="container"> <!-- Stats --> <div class="stats-grid" id="statsGrid"> <div class="stat-card"> <div class="label">Total Clicks</div> <div class="value" id="statTotal">-</div> </div> <div class="stat-card"> <div class="label">Successful</div> <div class="value" style="color:var(--success)" id="statSuccess">-</div> </div> <div class="stat-card"> <div class="label">Failed</div> <div class="value" style="color:var(--danger)" id="statFailed">-</div> </div> <div class="stat-card"> <div class="label">Last 24h</div> <div class="value" style="color:var(--accent)" id="stat24h">-</div> </div> <div class="stat-card"> <div class="label">Blacklisted IPs</div> <div class="value" style="color:var(--warning)" id="statBlacklist">-</div> </div> </div> <!-- Tabs --> <div class="tabs"> <button class="tab active" onclick="switchTab('targets')">Targets</button> <button class="tab" onclick="switchTab('clicks')">Click Log</button> <button class="tab" onclick="switchTab('proxies')">Proxies</button> <button class="tab" onclick="switchTab('accounts')">Accounts</button> <button class="tab" onclick="switchTab('blacklist')">Blacklist</button> </div> <!-- Targets Tab --> <div class="tab-content active" id="tab-targets"> <div class="form-row"> <input id="tKeyword" placeholder="Keyword (e.g. plumber london)" style="flex:2" /> <input id="tCountry" placeholder="Country (e.g. UK)" style="width:100px" /> <input id="tCity" placeholder="City (e.g. London)" style="width:140px" /> <input id="tLink" placeholder="Target link domain" style="flex:2" /> <select id="tMode" style="width:100px"> <option value="boost">Boost</option> <option value="drain">Drain</option> </select> <button class="btn btn-primary" onclick="addTarget()">Add Target</button> </div> <table> <thead> <tr> <th>ID</th><th>Keyword</th><th>Country</th><th>City</th> <th>Target</th><th>Mode</th><th>Clicks</th><th>Status</th> <th>Actions</th> </tr> </thead> <tbody id="targetsBody"></tbody> </table> </div> <!-- Click Log Tab --> <div class="tab-content" id="tab-clicks"> <table> <thead> <tr> <th>Time</th><th>Keyword</th><th>Mode</th><th>Links Clicked</th> <th>IP</th><th>Status</th><th>Error</th> </tr> </thead> <tbody id="clicksBody"></tbody> </table> </div> <!-- Proxies Tab --> <div class="tab-content" id="tab-proxies"> <div class="form-row"> <input id="pProvider" placeholder="Provider (e.g. anyip)" style="width:120px" /> <input id="pGeo" placeholder="Geo (e.g. UK)" style="width:80px" /> <input id="pCity" placeholder="City" style="width:120px" /> <input id="pEndpoint" placeholder="host:port" style="flex:1" /> <input id="pUser" placeholder="Username" style="width:150px" /> <input id="pPass" placeholder="Password" style="width:150px" type="password" /> <button class="btn btn-primary" onclick="addProxy()">Add Proxy</button> </div> <table> <thead> <tr> <th>ID</th><th>Provider</th><th>Geo</th><th>City</th> <th>Endpoint</th><th>Active</th><th>Actions</th> </tr> </thead> <tbody id="proxiesBody"></tbody> </table> </div> <!-- Accounts Tab --> <div class="tab-content" id="tab-accounts"> <div class="form-row"> <input id="aEmail" placeholder="Email" style="flex:1" /> <input id="aPass" placeholder="Password" style="width:150px" type="password" /> <input id="aCountry" placeholder="Country" style="width:100px" /> <input id="aCity" placeholder="City" style="width:120px" /> <button class="btn btn-primary" onclick="addAccount()">Add Account</button> </div> <table> <thead> <tr> <th>ID</th><th>Email</th><th>Country</th><th>City</th> <th>Clicks</th><th>Last Click</th><th>Status</th> </tr> </thead> <tbody id="accountsBody"></tbody> </table> </div> <!-- Blacklist Tab --> <div class="tab-content" id="tab-blacklist"> <div class="form-row"> <button class="btn btn-danger" onclick="clearBlacklist()">Clear All Blacklisted IPs</button> </div> <table> <thead> <tr><th>IP</th><th>Reason</th><th>Country</th><th>Blocked At</th></tr> </thead> <tbody id="blacklistBody"></tbody> </table> </div> </div> <script> const API = '/api'; // ── Tab Switching ───────────────────────────────────────── function switchTab(name) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); event.target.classList.add('active'); document.getElementById('tab-' + name).classList.add('active'); } // ── Data Loading ────────────────────────────────────────── async function loadStats() { try { const res = await fetch(API + '/stats'); const data = await res.json(); document.getElementById('statTotal').textContent = data.clicks.total_clicks; document.getElementById('statSuccess').textContent = data.clicks.successful; document.getElementById('statFailed').textContent = data.clicks.failed; document.getElementById('stat24h').textContent = data.clicks.last_24h; document.getElementById('statBlacklist').textContent = data.blacklisted_ips.total; } catch {} } async function loadHealth() { try { const res = await fetch(API + '/health'); const data = await res.json(); const dot = document.getElementById('healthDot'); const text = document.getElementById('healthText'); dot.className = 'dot ' + (data.status === 'healthy' ? 'green' : 'red'); text.textContent = data.status === 'healthy' ? 'Healthy' : 'Degraded'; } catch { document.getElementById('healthDot').className = 'dot red'; document.getElementById('healthText').textContent = 'Offline'; } } async function loadQueue() { try { const res = await fetch(API + '/queue/status'); const data = await res.json(); document.getElementById('queueCount').textContent = `${data.active} active, ${data.waiting} waiting`; } catch {} } async function loadTargets() { const res = await fetch(API + '/click-targets'); const data = await res.json(); const tbody = document.getElementById('targetsBody'); tbody.innerHTML = data.map(t => ` <tr> <td>${t.id}</td> <td>${t.keyword}</td> <td>${t.country}</td> <td>${t.city}</td> <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis">${t.target_link}</td> <td><span class="badge ${t.mode === 'boost' ? 'badge-success' : 'badge-danger'}">${t.mode}</span></td> <td>${t.total_clicks}</td> <td><span class="badge ${t.status === 'active' ? 'badge-success' : 'badge-dim'}">${t.status}</span></td> <td> <button class="btn btn-sm btn-primary" onclick="runCampaign(${t.id})">Run</button> <button class="btn btn-sm btn-danger" onclick="deleteTarget(${t.id})">Del</button> </td> </tr> `).join(''); } async function loadClicks() { const res = await fetch(API + '/clicks?limit=50'); const data = await res.json(); const tbody = document.getElementById('clicksBody'); tbody.innerHTML = data.map(c => { const time = new Date(c.created_at).toLocaleString(); const links = (c.links_clicked || []).length; const statusClass = c.status === 'completed' ? 'badge-success' : c.status === 'error' ? 'badge-danger' : 'badge-warning'; return ` <tr> <td style="white-space:nowrap">${time}</td> <td>${c.keyword || '-'}</td> <td><span class="badge ${c.mode === 'boost' ? 'badge-success' : 'badge-danger'}">${c.mode || '-'}</span></td> <td>${links}</td> <td>${c.vpn_ip || '-'}</td> <td><span class="badge ${statusClass}">${c.status}</span></td> <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;font-size:12px;color:var(--text-dim)">${c.error_message || ''}</td> </tr> `; }).join(''); } async function loadProxies() { const res = await fetch(API + '/proxies'); const data = await res.json(); const tbody = document.getElementById('proxiesBody'); tbody.innerHTML = data.map(p => ` <tr> <td>${p.id}</td> <td>${p.provider}</td> <td>${p.geo || '-'}</td> <td>${p.city || '-'}</td> <td>${p.endpoint}</td> <td> <span class="badge ${p.active ? 'badge-success' : 'badge-dim'}" style="cursor:pointer" onclick="toggleProxy(${p.id}, ${!p.active})"> ${p.active ? 'Active' : 'Inactive'} </span> </td> <td><button class="btn btn-sm btn-danger" onclick="deleteProxy(${p.id})">Del</button></td> </tr> `).join(''); } async function loadAccounts() { const res = await fetch(API + '/accounts'); const data = await res.json(); const tbody = document.getElementById('accountsBody'); tbody.innerHTML = data.map(a => { const lastClick = a.last_click_at ? new Date(a.last_click_at).toLocaleString() : 'Never'; return ` <tr> <td>${a.id}</td> <td>${a.email}</td> <td>${a.country || '-'}</td> <td>${a.city || '-'}</td> <td>${a.total_clicks}</td> <td>${lastClick}</td> <td><span class="badge ${a.status === 'active' ? 'badge-success' : 'badge-dim'}">${a.status}</span></td> </tr> `; }).join(''); } async function loadBlacklist() { const res = await fetch(API + '/blacklist'); const data = await res.json(); const tbody = document.getElementById('blacklistBody'); tbody.innerHTML = data.map(b => ` <tr> <td><code>${b.ip}</code></td> <td>${b.reason}</td> <td>${b.country || '-'}</td> <td>${new Date(b.blocked_at).toLocaleString()}</td> </tr> `).join(''); } // ── Actions ─────────────────────────────────────────────── async function addTarget() { await fetch(API + '/click-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyword: document.getElementById('tKeyword').value, country: document.getElementById('tCountry').value, city: document.getElementById('tCity').value, target_link: document.getElementById('tLink').value, mode: document.getElementById('tMode').value, }), }); loadTargets(); } async function deleteTarget(id) { if (!confirm('Delete this target?')) return; await fetch(API + '/click-targets/' + id, { method: 'DELETE' }); loadTargets(); } async function runCampaign(targetId) { const count = prompt('How many clicks?', '1'); if (!count) return; const res = await fetch(API + '/click-campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ click_target_id: targetId, count: parseInt(count) }), }); const data = await res.json(); alert(`Queued ${data.queued} click(s)`); loadQueue(); } async function addProxy() { await fetch(API + '/proxies', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: document.getElementById('pProvider').value, geo: document.getElementById('pGeo').value, city: document.getElementById('pCity').value, endpoint: document.getElementById('pEndpoint').value, username: document.getElementById('pUser').value, password: document.getElementById('pPass').value, }), }); loadProxies(); } async function toggleProxy(id, active) { await fetch(API + '/proxies/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ active }), }); loadProxies(); } async function deleteProxy(id) { if (!confirm('Delete this proxy?')) return; await fetch(API + '/proxies/' + id, { method: 'DELETE' }); loadProxies(); } async function addAccount() { await fetch(API + '/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: document.getElementById('aEmail').value, password: document.getElementById('aPass').value, country: document.getElementById('aCountry').value, city: document.getElementById('aCity').value, }), }); loadAccounts(); } async function clearBlacklist() { if (!confirm('Clear ALL blacklisted IPs? They may trigger CAPTCHAs again.')) return; await fetch(API + '/blacklist', { method: 'DELETE' }); loadBlacklist(); loadStats(); } // ── Auto-refresh ────────────────────────────────────────── function loadAll() { loadStats(); loadHealth(); loadQueue(); loadTargets(); loadClicks(); loadProxies(); loadAccounts(); loadBlacklist(); } loadAll(); setInterval(loadAll, 15000); // Refresh every 15 seconds </script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Click Automation</title> <style> :root { --bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a; --text: #e1e4ed; --text-dim: #8b8fa3; --accent: #5b8def; --accent-hover: #7ba4f7; --success: #4ade80; --warning: #fbbf24; --danger: #f87171; --radius: 8px; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; } /* ── Layout ──────────────────────────────────────────────── */ .header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; } .header h1 { font-size: 20px; font-weight: 600; } .header .status { display: flex; gap: 16px; font-size: 13px; color: var(--text-dim); } .header .status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } .dot.green { background: var(--success); } .dot.red { background: var(--danger); } .dot.yellow { background: var(--warning); } .container { max-width: 1400px; margin: 0 auto; padding: 24px; } /* ── Tabs ─────────────────────────────────────────────────── */ .tabs { display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 0; } .tab { padding: 10px 20px; cursor: pointer; border: none; background: none; color: var(--text-dim); font-size: 14px; border-bottom: 2px solid transparent; transition: all 0.2s; } .tab:hover { color: var(--text); } .tab.active { color: var(--accent); border-bottom-color: var(--accent); } .tab-content { display: none; } .tab-content.active { display: block; } /* ── Stats Cards ──────────────────────────────────────────── */ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; } .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; } .stat-card .label { font-size: 12px; text-transform: uppercase; color: var(--text-dim); letter-spacing: 0.5px; } .stat-card .value { font-size: 28px; font-weight: 700; margin-top: 4px; } /* ── Tables ────────────────────────────────────────────────── */ table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } th { text-align: left; padding: 12px 16px; font-size: 12px; text-transform: uppercase; color: var(--text-dim); background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--border); } td { padding: 12px 16px; font-size: 14px; border-bottom: 1px solid var(--border); } tr:last-child td { border-bottom: none; } tr:hover td { background: rgba(255,255,255,0.02); } /* ── Forms ─────────────────────────────────────────────────── */ .form-row { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } input, select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px; color: var(--text); font-size: 14px; outline: none; } input:focus, select:focus { border-color: var(--accent); } input::placeholder { color: var(--text-dim); } /* ── Buttons ───────────────────────────────────────────────── */ .btn { padding: 8px 16px; border: none; border-radius: var(--radius); cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; } .btn-primary { background: var(--accent); color: white; } .btn-primary:hover { background: var(--accent-hover); } .btn-danger { background: var(--danger); color: white; } .btn-danger:hover { opacity: 0.8; } .btn-sm { padding: 4px 10px; font-size: 12px; } .btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text-dim); } .btn-ghost:hover { border-color: var(--text-dim); color: var(--text); } /* ── Badges ────────────────────────────────────────────────── */ .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; } .badge-success { background: rgba(74,222,128,0.15); color: var(--success); } .badge-danger { background: rgba(248,113,113,0.15); color: var(--danger); } .badge-warning { background: rgba(251,191,36,0.15); color: var(--warning); } .badge-info { background: rgba(91,141,239,0.15); color: var(--accent); } .badge-dim { background: rgba(139,143,163,0.15); color: var(--text-dim); } </style>
</head>
<body> <!-- Header --> <div class="header"> <h1>Click Automation</h1> <div class="status"> <span><span class="dot green" id="healthDot"></span> <span id="healthText">Checking...</span></span> <span>Queue: <strong id="queueCount">-</strong></span> </div> </div> <div class="container"> <!-- Stats --> <div class="stats-grid" id="statsGrid"> <div class="stat-card"> <div class="label">Total Clicks</div> <div class="value" id="statTotal">-</div> </div> <div class="stat-card"> <div class="label">Successful</div> <div class="value" style="color:var(--success)" id="statSuccess">-</div> </div> <div class="stat-card"> <div class="label">Failed</div> <div class="value" style="color:var(--danger)" id="statFailed">-</div> </div> <div class="stat-card"> <div class="label">Last 24h</div> <div class="value" style="color:var(--accent)" id="stat24h">-</div> </div> <div class="stat-card"> <div class="label">Blacklisted IPs</div> <div class="value" style="color:var(--warning)" id="statBlacklist">-</div> </div> </div> <!-- Tabs --> <div class="tabs"> <button class="tab active" onclick="switchTab('targets')">Targets</button> <button class="tab" onclick="switchTab('clicks')">Click Log</button> <button class="tab" onclick="switchTab('proxies')">Proxies</button> <button class="tab" onclick="switchTab('accounts')">Accounts</button> <button class="tab" onclick="switchTab('blacklist')">Blacklist</button> </div> <!-- Targets Tab --> <div class="tab-content active" id="tab-targets"> <div class="form-row"> <input id="tKeyword" placeholder="Keyword (e.g. plumber london)" style="flex:2" /> <input id="tCountry" placeholder="Country (e.g. UK)" style="width:100px" /> <input id="tCity" placeholder="City (e.g. London)" style="width:140px" /> <input id="tLink" placeholder="Target link domain" style="flex:2" /> <select id="tMode" style="width:100px"> <option value="boost">Boost</option> <option value="drain">Drain</option> </select> <button class="btn btn-primary" onclick="addTarget()">Add Target</button> </div> <table> <thead> <tr> <th>ID</th><th>Keyword</th><th>Country</th><th>City</th> <th>Target</th><th>Mode</th><th>Clicks</th><th>Status</th> <th>Actions</th> </tr> </thead> <tbody id="targetsBody"></tbody> </table> </div> <!-- Click Log Tab --> <div class="tab-content" id="tab-clicks"> <table> <thead> <tr> <th>Time</th><th>Keyword</th><th>Mode</th><th>Links Clicked</th> <th>IP</th><th>Status</th><th>Error</th> </tr> </thead> <tbody id="clicksBody"></tbody> </table> </div> <!-- Proxies Tab --> <div class="tab-content" id="tab-proxies"> <div class="form-row"> <input id="pProvider" placeholder="Provider (e.g. anyip)" style="width:120px" /> <input id="pGeo" placeholder="Geo (e.g. UK)" style="width:80px" /> <input id="pCity" placeholder="City" style="width:120px" /> <input id="pEndpoint" placeholder="host:port" style="flex:1" /> <input id="pUser" placeholder="Username" style="width:150px" /> <input id="pPass" placeholder="Password" style="width:150px" type="password" /> <button class="btn btn-primary" onclick="addProxy()">Add Proxy</button> </div> <table> <thead> <tr> <th>ID</th><th>Provider</th><th>Geo</th><th>City</th> <th>Endpoint</th><th>Active</th><th>Actions</th> </tr> </thead> <tbody id="proxiesBody"></tbody> </table> </div> <!-- Accounts Tab --> <div class="tab-content" id="tab-accounts"> <div class="form-row"> <input id="aEmail" placeholder="Email" style="flex:1" /> <input id="aPass" placeholder="Password" style="width:150px" type="password" /> <input id="aCountry" placeholder="Country" style="width:100px" /> <input id="aCity" placeholder="City" style="width:120px" /> <button class="btn btn-primary" onclick="addAccount()">Add Account</button> </div> <table> <thead> <tr> <th>ID</th><th>Email</th><th>Country</th><th>City</th> <th>Clicks</th><th>Last Click</th><th>Status</th> </tr> </thead> <tbody id="accountsBody"></tbody> </table> </div> <!-- Blacklist Tab --> <div class="tab-content" id="tab-blacklist"> <div class="form-row"> <button class="btn btn-danger" onclick="clearBlacklist()">Clear All Blacklisted IPs</button> </div> <table> <thead> <tr><th>IP</th><th>Reason</th><th>Country</th><th>Blocked At</th></tr> </thead> <tbody id="blacklistBody"></tbody> </table> </div> </div> <script> const API = '/api'; // ── Tab Switching ───────────────────────────────────────── function switchTab(name) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); event.target.classList.add('active'); document.getElementById('tab-' + name).classList.add('active'); } // ── Data Loading ────────────────────────────────────────── async function loadStats() { try { const res = await fetch(API + '/stats'); const data = await res.json(); document.getElementById('statTotal').textContent = data.clicks.total_clicks; document.getElementById('statSuccess').textContent = data.clicks.successful; document.getElementById('statFailed').textContent = data.clicks.failed; document.getElementById('stat24h').textContent = data.clicks.last_24h; document.getElementById('statBlacklist').textContent = data.blacklisted_ips.total; } catch {} } async function loadHealth() { try { const res = await fetch(API + '/health'); const data = await res.json(); const dot = document.getElementById('healthDot'); const text = document.getElementById('healthText'); dot.className = 'dot ' + (data.status === 'healthy' ? 'green' : 'red'); text.textContent = data.status === 'healthy' ? 'Healthy' : 'Degraded'; } catch { document.getElementById('healthDot').className = 'dot red'; document.getElementById('healthText').textContent = 'Offline'; } } async function loadQueue() { try { const res = await fetch(API + '/queue/status'); const data = await res.json(); document.getElementById('queueCount').textContent = `${data.active} active, ${data.waiting} waiting`; } catch {} } async function loadTargets() { const res = await fetch(API + '/click-targets'); const data = await res.json(); const tbody = document.getElementById('targetsBody'); tbody.innerHTML = data.map(t => ` <tr> <td>${t.id}</td> <td>${t.keyword}</td> <td>${t.country}</td> <td>${t.city}</td> <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis">${t.target_link}</td> <td><span class="badge ${t.mode === 'boost' ? 'badge-success' : 'badge-danger'}">${t.mode}</span></td> <td>${t.total_clicks}</td> <td><span class="badge ${t.status === 'active' ? 'badge-success' : 'badge-dim'}">${t.status}</span></td> <td> <button class="btn btn-sm btn-primary" onclick="runCampaign(${t.id})">Run</button> <button class="btn btn-sm btn-danger" onclick="deleteTarget(${t.id})">Del</button> </td> </tr> `).join(''); } async function loadClicks() { const res = await fetch(API + '/clicks?limit=50'); const data = await res.json(); const tbody = document.getElementById('clicksBody'); tbody.innerHTML = data.map(c => { const time = new Date(c.created_at).toLocaleString(); const links = (c.links_clicked || []).length; const statusClass = c.status === 'completed' ? 'badge-success' : c.status === 'error' ? 'badge-danger' : 'badge-warning'; return ` <tr> <td style="white-space:nowrap">${time}</td> <td>${c.keyword || '-'}</td> <td><span class="badge ${c.mode === 'boost' ? 'badge-success' : 'badge-danger'}">${c.mode || '-'}</span></td> <td>${links}</td> <td>${c.vpn_ip || '-'}</td> <td><span class="badge ${statusClass}">${c.status}</span></td> <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;font-size:12px;color:var(--text-dim)">${c.error_message || ''}</td> </tr> `; }).join(''); } async function loadProxies() { const res = await fetch(API + '/proxies'); const data = await res.json(); const tbody = document.getElementById('proxiesBody'); tbody.innerHTML = data.map(p => ` <tr> <td>${p.id}</td> <td>${p.provider}</td> <td>${p.geo || '-'}</td> <td>${p.city || '-'}</td> <td>${p.endpoint}</td> <td> <span class="badge ${p.active ? 'badge-success' : 'badge-dim'}" style="cursor:pointer" onclick="toggleProxy(${p.id}, ${!p.active})"> ${p.active ? 'Active' : 'Inactive'} </span> </td> <td><button class="btn btn-sm btn-danger" onclick="deleteProxy(${p.id})">Del</button></td> </tr> `).join(''); } async function loadAccounts() { const res = await fetch(API + '/accounts'); const data = await res.json(); const tbody = document.getElementById('accountsBody'); tbody.innerHTML = data.map(a => { const lastClick = a.last_click_at ? new Date(a.last_click_at).toLocaleString() : 'Never'; return ` <tr> <td>${a.id}</td> <td>${a.email}</td> <td>${a.country || '-'}</td> <td>${a.city || '-'}</td> <td>${a.total_clicks}</td> <td>${lastClick}</td> <td><span class="badge ${a.status === 'active' ? 'badge-success' : 'badge-dim'}">${a.status}</span></td> </tr> `; }).join(''); } async function loadBlacklist() { const res = await fetch(API + '/blacklist'); const data = await res.json(); const tbody = document.getElementById('blacklistBody'); tbody.innerHTML = data.map(b => ` <tr> <td><code>${b.ip}</code></td> <td>${b.reason}</td> <td>${b.country || '-'}</td> <td>${new Date(b.blocked_at).toLocaleString()}</td> </tr> `).join(''); } // ── Actions ─────────────────────────────────────────────── async function addTarget() { await fetch(API + '/click-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyword: document.getElementById('tKeyword').value, country: document.getElementById('tCountry').value, city: document.getElementById('tCity').value, target_link: document.getElementById('tLink').value, mode: document.getElementById('tMode').value, }), }); loadTargets(); } async function deleteTarget(id) { if (!confirm('Delete this target?')) return; await fetch(API + '/click-targets/' + id, { method: 'DELETE' }); loadTargets(); } async function runCampaign(targetId) { const count = prompt('How many clicks?', '1'); if (!count) return; const res = await fetch(API + '/click-campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ click_target_id: targetId, count: parseInt(count) }), }); const data = await res.json(); alert(`Queued ${data.queued} click(s)`); loadQueue(); } async function addProxy() { await fetch(API + '/proxies', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: document.getElementById('pProvider').value, geo: document.getElementById('pGeo').value, city: document.getElementById('pCity').value, endpoint: document.getElementById('pEndpoint').value, username: document.getElementById('pUser').value, password: document.getElementById('pPass').value, }), }); loadProxies(); } async function toggleProxy(id, active) { await fetch(API + '/proxies/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ active }), }); loadProxies(); } async function deleteProxy(id) { if (!confirm('Delete this proxy?')) return; await fetch(API + '/proxies/' + id, { method: 'DELETE' }); loadProxies(); } async function addAccount() { await fetch(API + '/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: document.getElementById('aEmail').value, password: document.getElementById('aPass').value, country: document.getElementById('aCountry').value, city: document.getElementById('aCity').value, }), }); loadAccounts(); } async function clearBlacklist() { if (!confirm('Clear ALL blacklisted IPs? They may trigger CAPTCHAs again.')) return; await fetch(API + '/blacklist', { method: 'DELETE' }); loadBlacklist(); loadStats(); } // ── Auto-refresh ────────────────────────────────────────── function loadAll() { loadStats(); loadHealth(); loadQueue(); loadTargets(); loadClicks(); loadProxies(); loadAccounts(); loadBlacklist(); } loadAll(); setInterval(loadAll, 15000); // Refresh every 15 seconds </script>
</body>
</html>
# Add a UK residential proxy (AnyIP example)
curl -X POST http://localhost:3500/api/proxies \ -H "Content-Type: application/json" \ -d '{ "provider": "anyip", "geo": "UK", "city": "London", "endpoint": "http://YOUR_PROXY_HOST:YOUR_PROXY_PORT", "username": "YOUR_PROXY_USERNAME", "password": "YOUR_PROXY_PASSWORD" }' # Add an Italian proxy
curl -X POST http://localhost:3500/api/proxies \ -H "Content-Type: application/json" \ -d '{ "provider": "anyip", "geo": "Italy", "city": "Rome", "endpoint": "http://YOUR_PROXY_HOST:YOUR_PROXY_PORT", "username": "YOUR_PROXY_USERNAME", "password": "YOUR_PROXY_PASSWORD" }'
# Add a UK residential proxy (AnyIP example)
curl -X POST http://localhost:3500/api/proxies \ -H "Content-Type: application/json" \ -d '{ "provider": "anyip", "geo": "UK", "city": "London", "endpoint": "http://YOUR_PROXY_HOST:YOUR_PROXY_PORT", "username": "YOUR_PROXY_USERNAME", "password": "YOUR_PROXY_PASSWORD" }' # Add an Italian proxy
curl -X POST http://localhost:3500/api/proxies \ -H "Content-Type: application/json" \ -d '{ "provider": "anyip", "geo": "Italy", "city": "Rome", "endpoint": "http://YOUR_PROXY_HOST:YOUR_PROXY_PORT", "username": "YOUR_PROXY_USERNAME", "password": "YOUR_PROXY_PASSWORD" }'
# Add a UK residential proxy (AnyIP example)
curl -X POST http://localhost:3500/api/proxies \ -H "Content-Type: application/json" \ -d '{ "provider": "anyip", "geo": "UK", "city": "London", "endpoint": "http://YOUR_PROXY_HOST:YOUR_PROXY_PORT", "username": "YOUR_PROXY_USERNAME", "password": "YOUR_PROXY_PASSWORD" }' # Add an Italian proxy
curl -X POST http://localhost:3500/api/proxies \ -H "Content-Type: application/json" \ -d '{ "provider": "anyip", "geo": "Italy", "city": "Rome", "endpoint": "http://YOUR_PROXY_HOST:YOUR_PROXY_PORT", "username": "YOUR_PROXY_USERNAME", "password": "YOUR_PROXY_PASSWORD" }'
# Base username:
YOUR_PROXY_USERNAME # With session rotation:
YOUR_PROXY_USERNAME,session_s<random_string>
# Base username:
YOUR_PROXY_USERNAME # With session rotation:
YOUR_PROXY_USERNAME,session_s<random_string>
# Base username:
YOUR_PROXY_USERNAME # With session rotation:
YOUR_PROXY_USERNAME,session_s<random_string>
# Basic connectivity test (will show proxy IP)
curl -x http://YOUR_PROXY_USERNAME:YOUR_PROXY_PASSWORD@YOUR_PROXY_HOST:YOUR_PROXY_PORT \ -s https://ifconfig.me # Test with geo-specific session
curl -x http://YOUR_PROXY_USERNAME,session_stest123:YOUR_PROXY_PASSWORD@YOUR_PROXY_HOST:YOUR_PROXY_PORT \ -s https://ifconfig.me
# Basic connectivity test (will show proxy IP)
curl -x http://YOUR_PROXY_USERNAME:YOUR_PROXY_PASSWORD@YOUR_PROXY_HOST:YOUR_PROXY_PORT \ -s https://ifconfig.me # Test with geo-specific session
curl -x http://YOUR_PROXY_USERNAME,session_stest123:YOUR_PROXY_PASSWORD@YOUR_PROXY_HOST:YOUR_PROXY_PORT \ -s https://ifconfig.me
# Basic connectivity test (will show proxy IP)
curl -x http://YOUR_PROXY_USERNAME:YOUR_PROXY_PASSWORD@YOUR_PROXY_HOST:YOUR_PROXY_PORT \ -s https://ifconfig.me # Test with geo-specific session
curl -x http://YOUR_PROXY_USERNAME,session_stest123:YOUR_PROXY_PASSWORD@YOUR_PROXY_HOST:YOUR_PROXY_PORT \ -s https://ifconfig.me
YOUR_SERVER_IP { # Basic auth for the entire site basicauth { admin YOUR_HASHED_PASSWORD } # Proxy everything to the Node.js service reverse_proxy localhost:3500
}
YOUR_SERVER_IP { # Basic auth for the entire site basicauth { admin YOUR_HASHED_PASSWORD } # Proxy everything to the Node.js service reverse_proxy localhost:3500
}
YOUR_SERVER_IP { # Basic auth for the entire site basicauth { admin YOUR_HASHED_PASSWORD } # Proxy everything to the Node.js service reverse_proxy localhost:3500
}
docker compose exec caddy caddy hash-password --plaintext YOUR_PANEL_PASSWORD
docker compose exec caddy caddy hash-password --plaintext YOUR_PANEL_PASSWORD
docker compose exec caddy caddy hash-password --plaintext YOUR_PANEL_PASSWORD
:80 { basicauth { admin YOUR_HASHED_PASSWORD } reverse_proxy localhost:3500
}
:80 { basicauth { admin YOUR_HASHED_PASSWORD } reverse_proxy localhost:3500
}
:80 { basicauth { admin YOUR_HASHED_PASSWORD } reverse_proxy localhost:3500
}
docker compose restart caddy
docker compose restart caddy
docker compose restart caddy
cd /opt/automation/service
npm install patchright pg bullmq express dotenv
npx patchright install chromium
cd /opt/automation/service
npm install patchright pg bullmq express dotenv
npx patchright install chromium
cd /opt/automation/service
npm install patchright pg bullmq express dotenv
npx patchright install chromium
#!/bin/bash
set -e
cd /opt/automation/service # Load environment
set -a
source /opt/automation/.env
set +a # Start the API server in background
node server.js &
SERVER_PID=$! # Start the click worker
node click-worker.js &
WORKER_PID=$! echo "Server PID: $SERVER_PID, Worker PID: $WORKER_PID" # Wait for either process to exit
wait -n
exit $?
#!/bin/bash
set -e
cd /opt/automation/service # Load environment
set -a
source /opt/automation/.env
set +a # Start the API server in background
node server.js &
SERVER_PID=$! # Start the click worker
node click-worker.js &
WORKER_PID=$! echo "Server PID: $SERVER_PID, Worker PID: $WORKER_PID" # Wait for either process to exit
wait -n
exit $?
#!/bin/bash
set -e
cd /opt/automation/service # Load environment
set -a
source /opt/automation/.env
set +a # Start the API server in background
node server.js &
SERVER_PID=$! # Start the click worker
node click-worker.js &
WORKER_PID=$! echo "Server PID: $SERVER_PID, Worker PID: $WORKER_PID" # Wait for either process to exit
wait -n
exit $?
chmod +x /opt/automation/service/start.sh
chmod +x /opt/automation/service/start.sh
chmod +x /opt/automation/service/start.sh
[Unit]
Description=Click Automation Service
After=docker.service
Requires=docker.service [Service]
Type=simple
WorkingDirectory=/opt/automation/service
ExecStart=/usr/bin/xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" /opt/automation/service/start.sh
Restart=always
RestartSec=10
Environment=NODE_ENV=production
StandardOutput=journal
StandardError=journal [Install]
WantedBy=multi-user.target
[Unit]
Description=Click Automation Service
After=docker.service
Requires=docker.service [Service]
Type=simple
WorkingDirectory=/opt/automation/service
ExecStart=/usr/bin/xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" /opt/automation/service/start.sh
Restart=always
RestartSec=10
Environment=NODE_ENV=production
StandardOutput=journal
StandardError=journal [Install]
WantedBy=multi-user.target
[Unit]
Description=Click Automation Service
After=docker.service
Requires=docker.service [Service]
Type=simple
WorkingDirectory=/opt/automation/service
ExecStart=/usr/bin/xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" /opt/automation/service/start.sh
Restart=always
RestartSec=10
Environment=NODE_ENV=production
StandardOutput=journal
StandardError=journal [Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable automation
systemctl start automation
systemctl daemon-reload
systemctl enable automation
systemctl start automation
systemctl daemon-reload
systemctl enable automation
systemctl start automation
systemctl status automation
journalctl -u automation -f
systemctl status automation
journalctl -u automation -f
systemctl status automation
journalctl -u automation -f
[Server] API listening on port 3500
[Server] Panel: http://localhost:3500/panel/
[Worker] Click worker started, waiting for jobs...
[Server] API listening on port 3500
[Server] Panel: http://localhost:3500/panel/
[Worker] Click worker started, waiting for jobs...
[Server] API listening on port 3500
[Server] Panel: http://localhost:3500/panel/
[Worker] Click worker started, waiting for jobs...
curl -u admin:YOUR_PANEL_PASSWORD \ -X POST http://YOUR_SERVER_IP/api/proxies \ -H "Content-Type: application/json" \ -d '{ "provider": "anyip", "geo": "UK", "endpoint": "http://YOUR_PROXY_HOST:YOUR_PROXY_PORT", "username": "YOUR_PROXY_USERNAME", "password": "YOUR_PROXY_PASSWORD" }'
curl -u admin:YOUR_PANEL_PASSWORD \ -X POST http://YOUR_SERVER_IP/api/proxies \ -H "Content-Type: application/json" \ -d '{ "provider": "anyip", "geo": "UK", "endpoint": "http://YOUR_PROXY_HOST:YOUR_PROXY_PORT", "username": "YOUR_PROXY_USERNAME", "password": "YOUR_PROXY_PASSWORD" }'
curl -u admin:YOUR_PANEL_PASSWORD \ -X POST http://YOUR_SERVER_IP/api/proxies \ -H "Content-Type: application/json" \ -d '{ "provider": "anyip", "geo": "UK", "endpoint": "http://YOUR_PROXY_HOST:YOUR_PROXY_PORT", "username": "YOUR_PROXY_USERNAME", "password": "YOUR_PROXY_PASSWORD" }'
journalctl -u automation -f
journalctl -u automation -f
journalctl -u automation -f
[Click] Attempt 1/5: IP 82.xx.xx.xx, keyword "emergency plumber london"
[Click] Found 6 sponsored results for "emergency plumber london"
[Click] Clicked: ad - https://myplumbing.co.uk/emergency
[Click] Completed: 1 clicks for "emergency plumber london"
[Worker] Job abc123 completed: {"success":true,"clicked":1}
[Click] Attempt 1/5: IP 82.xx.xx.xx, keyword "emergency plumber london"
[Click] Found 6 sponsored results for "emergency plumber london"
[Click] Clicked: ad - https://myplumbing.co.uk/emergency
[Click] Completed: 1 clicks for "emergency plumber london"
[Worker] Job abc123 completed: {"success":true,"clicked":1}
[Click] Attempt 1/5: IP 82.xx.xx.xx, keyword "emergency plumber london"
[Click] Found 6 sponsored results for "emergency plumber london"
[Click] Clicked: ad - https://myplumbing.co.uk/emergency
[Click] Completed: 1 clicks for "emergency plumber london"
[Worker] Job abc123 completed: {"success":true,"clicked":1}
# API health endpoint
curl -s http://localhost:3500/api/health | jq . # Queue status
curl -s http://localhost:3500/api/queue/status | jq . # Recent clicks
curl -s http://localhost:3500/api/clicks?limit=5 | jq .
# API health endpoint
curl -s http://localhost:3500/api/health | jq . # Queue status
curl -s http://localhost:3500/api/queue/status | jq . # Recent clicks
curl -s http://localhost:3500/api/clicks?limit=5 | jq .
# API health endpoint
curl -s http://localhost:3500/api/health | jq . # Queue status
curl -s http://localhost:3500/api/queue/status | jq . # Recent clicks
curl -s http://localhost:3500/api/clicks?limit=5 | jq .
# Follow service logs
journalctl -u automation -f # Search for errors
journalctl -u automation --since "1 hour ago" | grep -i error # Check Docker container logs
docker compose logs --tail 50 postgres
docker compose logs --tail 50 redis
# Follow service logs
journalctl -u automation -f # Search for errors
journalctl -u automation --since "1 hour ago" | grep -i error # Check Docker container logs
docker compose logs --tail 50 postgres
docker compose logs --tail 50 redis
# Follow service logs
journalctl -u automation -f # Search for errors
journalctl -u automation --since "1 hour ago" | grep -i error # Check Docker container logs
docker compose logs --tail 50 postgres
docker compose logs --tail 50 redis
# Connect to PostgreSQL
docker compose exec postgres psql -U automation -d automation # Click success rate by country
SELECT vpn_country, status, COUNT(*)
FROM clicks
WHERE created_at > now() - interval '24 hours'
GROUP BY vpn_country, status
ORDER BY vpn_country, status; # Most blacklisted proxy IPs
SELECT ip, reason, COUNT(*) as times_seen
FROM blacklisted_ips
GROUP BY ip, reason
ORDER BY times_seen DESC
LIMIT 20; # Average dwell time by mode
SELECT mode, AVG(dwell_time_seconds), COUNT(*)
FROM clicks
WHERE status = 'completed'
GROUP BY mode;
# Connect to PostgreSQL
docker compose exec postgres psql -U automation -d automation # Click success rate by country
SELECT vpn_country, status, COUNT(*)
FROM clicks
WHERE created_at > now() - interval '24 hours'
GROUP BY vpn_country, status
ORDER BY vpn_country, status; # Most blacklisted proxy IPs
SELECT ip, reason, COUNT(*) as times_seen
FROM blacklisted_ips
GROUP BY ip, reason
ORDER BY times_seen DESC
LIMIT 20; # Average dwell time by mode
SELECT mode, AVG(dwell_time_seconds), COUNT(*)
FROM clicks
WHERE status = 'completed'
GROUP BY mode;
# Connect to PostgreSQL
docker compose exec postgres psql -U automation -d automation # Click success rate by country
SELECT vpn_country, status, COUNT(*)
FROM clicks
WHERE created_at > now() - interval '24 hours'
GROUP BY vpn_country, status
ORDER BY vpn_country, status; # Most blacklisted proxy IPs
SELECT ip, reason, COUNT(*) as times_seen
FROM blacklisted_ips
GROUP BY ip, reason
ORDER BY times_seen DESC
LIMIT 20; # Average dwell time by mode
SELECT mode, AVG(dwell_time_seconds), COUNT(*)
FROM clicks
WHERE status = 'completed'
GROUP BY mode;
ufw allow from YOUR_OFFICE_IP to any port 80
ufw allow from YOUR_OFFICE_IP to any port 443
ufw deny 80
ufw deny 443
ufw enable
ufw allow from YOUR_OFFICE_IP to any port 80
ufw allow from YOUR_OFFICE_IP to any port 443
ufw deny 80
ufw deny 443
ufw enable
ufw allow from YOUR_OFFICE_IP to any port 80
ufw allow from YOUR_OFFICE_IP to any port 443
ufw deny 80
ufw deny 443
ufw enable
chmod 600 /opt/automation/.env
chown root:root /opt/automation/.env
chmod 600 /opt/automation/.env
chown root:root /opt/automation/.env
chmod 600 /opt/automation/.env
chown root:root /opt/automation/.env
# Clean clicks older than 90 days
0 3 * * * docker compose -f /opt/automation/docker-compose.yml exec -T postgres \ psql -U automation -d automation -c "DELETE FROM clicks WHERE created_at < now() - interval '90 days';"
# Clean clicks older than 90 days
0 3 * * * docker compose -f /opt/automation/docker-compose.yml exec -T postgres \ psql -U automation -d automation -c "DELETE FROM clicks WHERE created_at < now() - interval '90 days';"
# Clean clicks older than 90 days
0 3 * * * docker compose -f /opt/automation/docker-compose.yml exec -T postgres \ psql -U automation -d automation -c "DELETE FROM clicks WHERE created_at < now() - interval '90 days';" - Introduction
- What You'll Build
- Architecture Overview
- Prerequisites
- Directory Structure
- Step 1: Docker Infrastructure
- Step 2: Database Schema
- Step 3: Environment Configuration
- Step 4: Browser Utilities
- Step 5: Stealth and Anti-Detection
- Step 6: Click Worker
- Step 7: API Server
- Step 8: Management Panel
- Step 9: Proxy Integration
- Step 10: Caddy Reverse Proxy
- Step 11: Systemd Service and xvfb
- Running Your First Campaign
- Monitoring and Debugging
- Gotchas and Hard-Won Lessons
- Security Considerations
- What's Next - Boost mode: Click your own ads to increase CTR signals, push your ad position higher, and generate landing page engagement metrics that feed back into Quality Score.
- Drain mode: Click competitor ads to exhaust their daily budget, removing them from the auction for the rest of the day. - Multi-country targeting with per-country Google domains, languages, locales, and timezones
- Residential proxy rotation with automatic IP blacklisting on CAPTCHA detection
- Two click modes: boost (click your own ads) and drain (click competitor ads)
- City-level geolocation spoofing with real coordinates from a cities database
- Mobile device emulation with touch events, viewport sizing, and proper UA strings
- Full stealth stack: patched window.chrome, fake plugins array, WebGL spoofing, language headers, and more - Ubuntu 24.04 VPS with at least 4 CPU cores and 8 GB RAM (browsers are hungry)
- Docker and Docker Compose installed
- Node.js 20+ installed on the host (not in Docker)
- A residential proxy provider that offers geo-targeted mobile IPs (AnyIP, Bright Data, Oxylabs, etc.)
- A domain name pointed at your server (for Caddy HTTPS) -- or use IP-based access with HTTP - PostgreSQL and Redis bind to 127.0.0.1 only. The Node.js service runs on the host and connects via localhost. No need to expose these ports to the internet.
- Redis uses noeviction memory policy. BullMQ jobs must not be silently dropped. If Redis runs out of memory, it should return errors rather than evict queue data.
- Redis has AOF persistence enabled. Queued click jobs survive Redis restarts.
- Caddy handles TLS automatically via Let's Encrypt if you point a domain at the server. - The default Chromium user-agent still contains "HeadlessChrome" in headless mode
- window.chrome is still not properly populated
- navigator.plugins returns an empty array
- WebGL reports a software renderer - Geo-targeting at the country level (city-level is better but rarer)
- Session rotation -- each new session gets a different exit IP
- Pay-per-GB pricing (not per-IP) for cost efficiency
- No KYC if possible (simplifies onboarding) - Keyword: emergency plumber london
- Country: UK
- City: London
- Target link: myplumbing.co.uk (domain fragment to match)
- Mode: boost (click your own ad) or drain (click competitors) - Caddy basic auth: Required. Without it, anyone can queue clicks and read your proxy credentials.
- Firewall: Restrict port 80/443 to your IP addresses. Use ufw: - SSH access: Use key-based auth only. Disable password authentication. - Restrict PostgreSQL to 127.0.0.1 (already done in docker-compose.yml)
- Use a strong, unique database password
- Do not expose the API without authentication - Telegram notifications: Send real-time alerts when campaigns complete or when CAPTCHA rates spike. The .env already has TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID placeholders.
- Screenshot capture: Save a screenshot of the search results page before and after clicking. Useful for verifying ad positions and debugging ad matching failures.
- Scheduling: Use BullMQ's repeatable jobs to run campaigns on a schedule (e.g., 5 clicks every hour during business hours).
- Multi-server deployment: Run click workers on multiple VPS servers in different data centers to increase throughput while keeping one-browser-per-server.
- Account rotation: Use the accounts table to rotate through Google accounts for authenticated sessions, which can bypass some rate limits.
- Position tracking: Before clicking, record the target ad's position on the page. Track position changes over time to measure campaign effectiveness.
- Cost tracking: Calculate per-click cost based on proxy bandwidth usage and display it in the panel dashboard.