{"id":676,"date":"2026-07-17T02:09:02","date_gmt":"2026-07-16T23:09:02","guid":{"rendered":"https:\/\/mexela.com\/blog\/google-serp-monitoring-nodejs-playwright-proxies\/"},"modified":"2026-07-22T00:20:42","modified_gmt":"2026-07-21T21:20:42","slug":"google-serp-monitoring-nodejs-playwright-proxies","status":"publish","type":"post","link":"https:\/\/mexela.com\/blog\/google-serp-monitoring-nodejs-playwright-proxies\/","title":{"rendered":"Google SERP Monitoring With Node.js, Playwright, and Proxies"},"content":{"rendered":"<p><!-- mexela-platform-guide:start --><\/p>\n<p class='mexela-answer'>A small Google SERP monitor can use Node.js and Playwright to launch Chromium through one authenticated proxy, create an isolated desktop context, request one signed-out query, classify the returned page, extract at most ten organic destination URLs, and emit structured JSON. Treat consent, unusual-traffic, HTTP, no-result, and parser outcomes as data rather than retry instructions. Google markup changes, so a supported API or search-data provider is usually more stable for recurring production monitoring.<\/p>\n<p>The example below is deliberately one query and one navigation. It does not click results, accept consent, rotate addresses, or retry a warning page. That boundary makes the output understandable: each run is either a usable observation or a named failure state. Review the applicable Google terms and obtain approval for any scheduled collection before increasing frequency.<\/p>\n<h2>Install one browser and supply four secrets or inputs<\/h2>\n<p>Use a maintained Node.js release in an empty project. Run <code>npm init -y<\/code>, then <code>npm install playwright<\/code>, followed by <code>npx playwright install chromium<\/code>. These commands follow the official <a href='https:\/\/playwright.dev\/docs\/library' data-source='primary'>Playwright library installation<\/a>, which distinguishes installing the package from installing browser binaries.<\/p>\n<p>Set all four required variables in the shell that will launch the script. In PowerShell, complete example assignments are <code>$Env:PROXY_SERVER='http:\/\/proxy.example:3128'<\/code>, <code>$Env:PROXY_USERNAME='account-user'<\/code>, <code>$Env:PROXY_PASSWORD='replace-with-secret'<\/code>, and <code>$Env:QUERY='marlin desk lamp warranty'<\/code>. Save the program as <code>serp-check.mjs<\/code>, then run <code>node .\\serp-check.mjs<\/code>. Replace the fictional endpoint and credentials; do not commit them.<\/p>\n<p>Playwright supports HTTP and SOCKS proxy servers at browser launch. Its <code>username<\/code> and <code>password<\/code> fields are documented for HTTP proxy authentication; do not assume those credentials work with a SOCKS endpoint. Structured fields avoid parsing HTTP credentials out of a URL. The current option is documented in the <a href='https:\/\/playwright.dev\/docs\/api\/class-browsertype#browser-type-launch-option-proxy' data-source='primary'>BrowserType proxy reference<\/a>. For route and authentication diagnosis outside this script, use the <a href='\/blog\/use-proxies-curl-python-nodejs\/'>Playwright proxy setup guide<\/a> and <a href='\/blog\/proxy-authentication-username-password-vs-ip-auth\/'>proxy authentication guide<\/a>.<\/p>\n<h2>Run a complete one-navigation monitor<\/h2>\n<p>The program validates configuration before launching. It explicitly creates <code>browser<\/code>, <code>context<\/code>, and <code>page<\/code>, fixes the locale and desktop viewport, and calls <code>page.goto()<\/code> exactly once. After navigation, it reads the HTTP status, final URL, and body, extracts a bounded set of candidate organic URLs, and then classifies the page from that evidence and the extracted count. Within classification, recognized interruption and no-result states take precedence over generic HTTP or parser states.<\/p>\n<pre data-check='node'><code class='language-javascript'>import { writeFile } from 'node:fs\/promises';\nimport { chromium } from 'playwright';\nconst requiredNames = [\n  'PROXY_SERVER',\n  'PROXY_USERNAME',\n  'PROXY_PASSWORD',\n  'QUERY',\n];\nconst missingNames = requiredNames.filter((name) =&gt; !process.env[name]?.trim());\nif (missingNames.length &gt; 0) {\n  throw new Error(`Missing required environment variables: ${missingNames.join(', ')}`);\n}\nconst config = {\n  proxyServer: process.env.PROXY_SERVER.trim(),\n  proxyUsername: process.env.PROXY_USERNAME.trim(),\n  proxyPassword: process.env.PROXY_PASSWORD,\n  query: process.env.QUERY.trim(),\n  outputFile: process.env.OUTPUT_FILE?.trim() || null,\n  maxOrganicUrls: 10,\n};\nlet browser;\nlet context;\nlet page;\nlet output;\nconst startedAt = new Date();\nfunction classifyPage({ status, finalUrl, bodyText, organicCount }) {\n  const text = bodyText.toLowerCase();\n  if (text.includes('unusual traffic from your computer network')) {\n    return { state: 'unusual_traffic', detail: 'Google returned a traffic warning.' };\n  }\n  if (\n    finalUrl.includes('consent.google.') ||\n    text.includes('before you continue to google')\n  ) {\n    return { state: 'consent', detail: 'A consent page interrupted the result.' };\n  }\n  if (\n    text.includes('did not match any documents') ||\n    text.includes('no results found for')\n  ) {\n    return { state: 'no_results', detail: 'Google displayed an explicit empty result.' };\n  }\n  if (status &gt;= 400) {\n    return { state: 'http_error', detail: `HTTP ${status}` };\n  }\n  if (organicCount === 0) {\n    return { state: 'parser_error', detail: 'No supported organic anchors were found.' };\n  }\n  return { state: 'ok', detail: null };\n}\nasync function extractOrganicUrls(currentPage, limit) {\n  const anchors = currentPage.locator('#search a:has(h3)');\n  const count = await anchors.count();\n  const urls = [];\n  for (let index = 0; index &lt; Math.min(count, limit * 3); index += 1) {\n    const href = await anchors.nth(index).getAttribute('href');\n    if (!href) continue;\n    try {\n      const parsed = new URL(href, currentPage.url());\n      const isGoogleHost = parsed.hostname === 'google.com' || parsed.hostname.endsWith('.google.com');\n      if (\n        (parsed.protocol === 'http:' || parsed.protocol === 'https:') &amp;&amp;\n        !isGoogleHost &amp;&amp;\n        !urls.includes(parsed.href)\n      ) {\n        urls.push(parsed.href);\n      }\n    } catch {\n      \/\/ Ignore malformed destinations and preserve the bounded result.\n    }\n    if (urls.length === limit) break;\n  }\n  return urls;\n}\ntry {\n  browser = await chromium.launch({\n    headless: true,\n    proxy: {\n      server: config.proxyServer,\n      username: config.proxyUsername,\n      password: config.proxyPassword,\n    },\n  });\n  context = await browser.newContext({\n    locale: 'en-US',\n    viewport: { width: 1365, height: 900 },\n    geolocation: undefined,\n  });\n  page = await context.newPage();\n  const searchUrl = new URL('https:\/\/www.google.com\/search');\n  searchUrl.searchParams.set('q', config.query);\n  searchUrl.searchParams.set('hl', 'en');\n  const response = await page.goto(searchUrl.href, {\n    waitUntil: 'domcontentloaded',\n    timeout: 30_000,\n  });\n  const status = response?.status() ?? 0;\n  const finalUrl = page.url();\n  const bodyText = await page.locator('body').innerText().catch(() =&gt; '');\n  const organicUrls = await extractOrganicUrls(page, config.maxOrganicUrls);\n  const classification = classifyPage({\n    status,\n    finalUrl,\n    bodyText,\n    organicCount: organicUrls.length,\n  });\n  output = {\n    query: config.query,\n    requestedAt: startedAt.toISOString(),\n    completedAt: new Date().toISOString(),\n    finalUrl,\n    httpStatus: status,\n    state: classification.state,\n    detail: classification.detail,\n    organicUrls: classification.state === 'ok' ? organicUrls : [],\n    organicUrlLimit: config.maxOrganicUrls,\n  };\n} catch (error) {\n  output = {\n    query: config.query,\n    requestedAt: startedAt.toISOString(),\n    completedAt: new Date().toISOString(),\n    finalUrl: page?.url() ?? null,\n    httpStatus: null,\n    state: 'navigation_error',\n    detail: error instanceof Error ? error.message : String(error),\n    organicUrls: [],\n    organicUrlLimit: config.maxOrganicUrls,\n  };\n  process.exitCode = 1;\n} finally {\n  await browser?.close();\n}\nconst serialized = JSON.stringify(output, null, 2);\nif (config.outputFile) {\n  await writeFile(config.outputFile, `${serialized}\\n`, 'utf8');\n}\nconsole.log(serialized);<\/code><\/pre>\n<p>The optional <code>OUTPUT_FILE<\/code> variable writes the same JSON printed to standard output. It is optional and therefore not part of the four-value validation gate. Closing the browser in <code>finally<\/code> covers successful extraction, navigation failure, and parser failure. Playwright&#8217;s <a href='https:\/\/playwright.dev\/docs\/api\/class-page' data-source='primary'>Page documentation<\/a> describes the page, context, navigation, and locator objects used here.<\/p>\n<h2>Interpret the state before reading URLs<\/h2>\n<p>An <code>ok<\/code> state means the supported selector found at least one non-Google HTTP destination. It does not certify that every organic result was captured. The list is deduplicated and capped at ten, preventing a layout bug from producing an unbounded file. Keep the cap and selector version beside historical measurements if the script evolves.<\/p>\n<p><code>consent<\/code> means the run stopped on a consent surface. <code>unusual_traffic<\/code> means Google displayed its traffic warning. Neither state should trigger a loop that accepts, rotates, or retries automatically. These semantic states and an explicit <code>no_results<\/code> message take precedence over a generic HTTP classification, so a 429 traffic warning remains <code>unusual_traffic<\/code>; the separate <code>httpStatus<\/code> field still preserves 429. <code>http_error<\/code> covers a response of 400 or higher only when no recognized semantic page is present. <code>parser_error<\/code> means the expected anchors were absent. That last distinction prevents a markup change from being recorded as zero rankings.<\/p>\n<p><code>navigation_error<\/code> covers timeouts, proxy authentication failure, TLS failure, and other thrown errors before classification completes. Diagnose the route on a permitted neutral endpoint outside the measurement run. Never log the password or embed it in the result. If sessions require a stable country exit for the entire navigation, Mexela&#8217;s <a href='https:\/\/mexela.com\/proxy-pricing\/'>proxy plans<\/a> can be compared on country coverage and session persistence.<\/p>\n<h2>Expected JSON is intentionally small<\/h2>\n<p>A successful run has the following shape. URLs and times are fictional; the strict JSON block exists to document the contract, not to claim a live Google result.<\/p>\n<pre data-check='json'><code class='language-json'>{\n  \"query\": \"marlin desk lamp warranty\",\n  \"requestedAt\": \"2026-07-17T09:00:00.000Z\",\n  \"completedAt\": \"2026-07-17T09:00:02.410Z\",\n  \"finalUrl\": \"https:\/\/www.google.com\/search?q=marlin+desk+lamp+warranty&amp;hl=en\",\n  \"httpStatus\": 200,\n  \"state\": \"ok\",\n  \"detail\": null,\n  \"organicUrls\": [\n    \"https:\/\/example.org\/marlin-lamp\/warranty\",\n    \"https:\/\/docs.example.net\/lighting\/marlin\"\n  ],\n  \"organicUrlLimit\": 10\n}<\/code><\/pre>\n<p>Store the entire run object, not just ranks. State, query, final URL, status, and timestamps are necessary to distinguish a real result change from a consent redirect or broken parser. Credentials, cookies, and full page HTML are not part of this schema.<\/p>\n<h2>Know when browser monitoring is the wrong dependency<\/h2>\n<p>Rendered Google markup and module composition change. A CSS selector that works today can fail without warning, and browser automation adds consent, proxy, browser-binary, and policy dependencies. Recurring production reporting usually benefits from a supported search API or data provider with a documented schema and service terms. Use this browser example for a bounded, approved QA need where rendered evidence is essential.<\/p>\n<p>Keep production frequency conservative, cache equivalent work, and stop on warnings. Google&#8217;s current <a href='https:\/\/policies.google.com\/terms' data-source='primary'>Terms of Service<\/a> remain authoritative for use of its services; a proxy is routing configuration, not permission to send automated queries.<\/p>\n<h2>Questions about the monitor<\/h2>\n<div class='mexela-faq'>\n<h3>Why does the script not retry a consent or traffic page?<\/h3>\n<p>Those are meaningful stop states. Automatic retries can hide the interruption, increase traffic, and mix several network identities into one observation.<\/p>\n<h3>Does zero extracted URLs mean zero organic results?<\/h3>\n<p>No. The script reports <code>parser_error<\/code> unless Google explicitly displayed a no-results message. Inspect the approved debug evidence and update the selector deliberately.<\/p>\n<h3>Can the same browser instance run several markets?<\/h3>\n<p>Use a separate stable session per independent market design. This example accepts one proxy at browser launch and one query so cookies and routes cannot leak across market rows.<\/p>\n<\/div>\n<p><!-- mexela-platform-guide:end --><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Run one bounded Google query through an authenticated Playwright proxy and classify consent, traffic, HTTP, empty, and parser outcomes.<\/p>\n","protected":false},"author":1,"featured_media":677,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[47],"tags":[494,475,487,477,482,493,81,98],"_links":{"self":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/676"}],"collection":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/comments?post=676"}],"version-history":[{"count":2,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/676\/revisions"}],"predecessor-version":[{"id":859,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/676\/revisions\/859"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/media\/677"}],"wp:attachment":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/media?parent=676"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/categories?post=676"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/tags?post=676"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}