{"id":678,"date":"2026-07-17T02:09:04","date_gmt":"2026-07-16T23:09:04","guid":{"rendered":"https:\/\/mexela.com\/blog\/monitor-google-shopping-prices-across-countries\/"},"modified":"2026-07-22T00:20:43","modified_gmt":"2026-07-21T21:20:43","slug":"monitor-google-shopping-prices-across-countries","status":"publish","type":"post","link":"https:\/\/mexela.com\/blog\/monitor-google-shopping-prices-across-countries\/","title":{"rendered":"How to Monitor Google Shopping Prices Across Countries"},"content":{"rendered":"<p><!-- mexela-platform-guide:start --><\/p>\n<p class='mexela-answer'>To monitor Google Shopping prices across countries, first capture each visible offer exactly as shown in a controlled market session, including product variant, seller, price text, currency, shipping text, availability, promotion text, market, and UTC time. Normalize those raw observations in a separate step, then compare only records with the same product, variant, seller, market, and currency. A Shopping card price is not automatically the checkout total because coupons, delivery, tax, membership, stock, and variant choices can change the payable amount.<\/p>\n<p>This guide uses a fictional product called the Marlin Desk Lamp. It shows a small manual capture design and a runnable Node.js transformation; it does not provide a collector or imply that a browser should poll Google at high frequency. Keep acquisition within the project&#8217;s permissions and applicable Google terms. If a licensed commerce feed or supported provider already supplies the required market fields, prefer its stable schema.<\/p>\n<h2>Lock product identity and market rows first<\/h2>\n<p>Do not begin with a search box. Begin with a canonical product and variant table. Product titles alone are unreliable because merchants shorten model names, translate colors, combine accessories, and advertise bundles. Prefer GTIN or manufacturer part number. When those identifiers are unavailable, require an exact reviewed combination such as brand, model, shade, plug, pack size, condition, and included accessories.<\/p>\n<p>The matrix below changes market inputs while keeping the requested item precise. The countries are real proxy targets, but every product, seller, query, and scheduled time is fictional. The delivery assumption is recorded because a seller may calculate shipping only after receiving a postal code.<\/p>\n<table>\n<thead>\n<tr>\n<th>Run<\/th>\n<th>Market and verified exit<\/th>\n<th>Interface<\/th>\n<th>Query<\/th>\n<th>Required variant<\/th>\n<th>Delivery assumption<\/th>\n<th>Account and device<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>DE-01<\/td>\n<td>Germany<\/td>\n<td>German, EUR<\/td>\n<td>Marlin Schreibtischlampe M7<\/td>\n<td>M7, black, EU plug, new, single pack<\/td>\n<td>No postal code supplied<\/td>\n<td>Signed out, clean desktop, 1365\u00d7900<\/td>\n<\/tr>\n<tr>\n<td>CA-01<\/td>\n<td>Canada<\/td>\n<td>English, CAD<\/td>\n<td>Marlin desk lamp M7<\/td>\n<td>M7, black, North American plug, new, single pack<\/td>\n<td>No postal code supplied<\/td>\n<td>Signed out, clean desktop, 1365\u00d7900<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>EU and North American plugs are different variants, so this matrix supports within-market tracking, not a claim that the physical offers are interchangeable. For a direct cross-country comparison, select a truly identical variant or declare the transformation that makes them comparable. The <a href='\/blog\/choose-best-proxy-location\/'>proxy-location guide<\/a> helps align the exit claim with the market row.<\/p>\n<h2>Capture the offer without cleaning it<\/h2>\n<p>Open one clean, signed-out session through the verified country exit. Keep the address stable while loading and recording that row. Save the final Google URL, screenshot identifier, observed exit country, interface language, account state, and device with the offer. The <a href='\/blog\/test-if-your-proxy-is-working\/'>proxy verification guide<\/a> explains the route check that should happen before Google.<\/p>\n<p>Copy visible strings rather than turning absent text into assumptions. Preserve the card&#8217;s title, seller, price, currency label, shipping wording, promotion, and availability. If the seller or shipping is missing, store <code>null<\/code>, not \u201cunknown seller\u201d or zero delivery. A raw record can look like this:<\/p>\n<pre data-check='json'><code class='language-json'>{\n  \"captureId\": \"DE-01-20260717-1015\",\n  \"canonicalProductId\": \"marlin-m7\",\n  \"rawTitle\": \"Marlin M7 Schreibtischlampe - Schwarz\",\n  \"rawPrice\": \"1.299,00 \u20ac\",\n  \"currency\": \"EUR\",\n  \"decimalSeparator\": \",\",\n  \"rawSeller\": \" Nordlicht Handel \",\n  \"rawShipping\": \"Versand 6,90 \u20ac\",\n  \"rawPromotion\": \"20 \u20ac Gutschein im Warenkorb\",\n  \"rawAvailability\": \"Auf Lager\",\n  \"variant\": \"black \/ EU plug \/ new \/ single pack\",\n  \"market\": \"DE\",\n  \"capturedAt\": \"2026-07-17T10:15:00.000Z\",\n  \"evidenceFile\": \"DE-01-20260717-1015.png\"\n}<\/code><\/pre>\n<p>This is an observation record, not executable collection instructions. The explicit decimal separator comes from the row&#8217;s documented number convention; it is not guessed from the digits. Collection and normalization remain separate so a parsing rule can be corrected without rewriting what the card actually displayed. Preserve the raw record immutably and add a parser version to production outputs.<\/p>\n<h2>Keep card price, adjustments, and checkout total distinct<\/h2>\n<p>Google&#8217;s <a href='https:\/\/support.google.com\/merchants\/answer\/7052112?hl=en' data-source='primary'>Merchant Center product specification<\/a> separates price, sale price, availability, shipping, and ISO 4217 currency. Use that vocabulary without assuming a rendered card exposes the merchant&#8217;s full record.<\/p>\n<p>Coupons can require a code, account, payment method, or cart action. Keep coupon text separate unless eligibility is confirmed. Google&#8217;s <a href='https:\/\/support.google.com\/merchants\/answer\/16527720?hl=en' data-source='primary'>pricing overview<\/a> also distinguishes standard, sale, promotional, and loyalty prices.<\/p>\n<p>Shipping may be free, fixed, calculated later, or absent. Normalize a paid amount only when the number appears near the record&#8217;s currency code or a supported symbol. Thus \u201cShipping $6.90\u201d can be monetary, while \u201cDelivery in 3 days\u201d retains its text with a null amount. Only an explicit free label becomes zero.<\/p>\n<p>Tax treatment varies. Google&#8217;s specification says US and Canadian price attributes exclude sales tax, GST, VAT, and import tax, while other countries include VAT or GST. Record the market and visible tax note rather than inventing tax or calling equal-looking cards equal checkout totals.<\/p>\n<p>Google&#8217;s <a href='https:\/\/support.google.com\/merchants\/answer\/6324448' data-source='primary'>availability requirements<\/a> distinguish in stock, out of stock, preorder, and backorder. A \u201cfrom\u201d price, different capacity, or unavailable shade is not evidence for the requested variant.<\/p>\n<h2>Normalize and detect a comparable change in Node.js<\/h2>\n<p>The complete program declares each row&#8217;s decimal separator, validates grouping, and rejects conflicting formats. It handles currency, seller, shipping, variant, market, and capture time. Its comparison key prevents mismatched variants, sellers, markets, or currencies.<\/p>\n<pre data-check='node'><code class='language-javascript'>const rawOffers = [\n  {\n    captureId: 'DE-01-20260716-1015',\n    canonicalProductId: 'marlin-m7',\n    rawPrice: '1.349,00 \u20ac',\n    currency: 'EUR',\n    decimalSeparator: ',',\n    rawSeller: ' Nordlicht Handel ',\n    rawShipping: 'Versand 6,90 \u20ac',\n    variant: 'black \/ EU plug \/ new \/ single pack',\n    market: 'DE',\n    capturedAt: '2026-07-16T10:15:00.000Z',\n  },\n  {\n    captureId: 'DE-01-20260717-1015',\n    canonicalProductId: 'marlin-m7',\n    rawPrice: '1.299,00 \u20ac',\n    currency: 'EUR',\n    decimalSeparator: ',',\n    rawSeller: 'Nordlicht Handel',\n    rawShipping: 'Versand 6,90 \u20ac',\n    variant: 'black \/ EU plug \/ new \/ single pack',\n    market: 'DE',\n    capturedAt: '2026-07-17T10:15:00.000Z',\n  },\n];\nfunction cleanText(value, fieldName) {\n  if (typeof value !== 'string' || !value.trim()) {\n    throw new Error(`${fieldName} must be a non-empty string`);\n  }\n  return value.trim().replace(\/\\s+\/g, ' ');\n}\nfunction resolveCurrency(rawAmount, explicitCurrency) {\n  const explicit = explicitCurrency?.trim().toUpperCase();\n  if (explicit) return explicit;\n  if (rawAmount.includes('\u20ac')) return 'EUR';\n  if (rawAmount.includes('C$')) return 'CAD';\n  if (rawAmount.includes('US$')) return 'USD';\n  if (rawAmount.includes('\u00a3')) return 'GBP';\n  throw new Error(`Currency is missing from: ${rawAmount}`);\n}\nfunction normalizeDecimalSeparator(value) {\n  if (value !== '.' &amp;&amp; value !== ',') {\n    throw new Error('decimalSeparator must be \".\" or \",\"');\n  }\n  return value;\n}\nfunction parseLocalizedAmount(rawAmount, declaredDecimalSeparator) {\n  const decimalSeparator = normalizeDecimalSeparator(declaredDecimalSeparator);\n  const text = cleanText(rawAmount, 'amount');\n  const numericTokens = text.match(\/[+-]?\\d(?:[\\d.,\\s\\u00a0]*\\d)?\/g) ?? [];\n  if (numericTokens.length !== 1) {\n    throw new Error(`Expected one numeric amount: ${rawAmount}`);\n  }\n  const compact = numericTokens[0].replace(\/[\\s\\u00a0]\/g, '');\n  const groupingSeparator = decimalSeparator === ',' ? '.' : ',';\n  const escapedDecimal = decimalSeparator === '.' ? '\\\\.' : ',';\n  const escapedGrouping = groupingSeparator === '.' ? '\\\\.' : ',';\n  const pattern = new RegExp(\n    `^(?:\\\\d{1,3}(?:${escapedGrouping}\\\\d{3})+|\\\\d+)(?:${escapedDecimal}\\\\d+)?$`,\n  );\n  if (!pattern.test(compact)) {\n    throw new Error(`Amount conflicts with declared decimal separator: ${rawAmount}`);\n  }\n  const normalized = compact\n    .replaceAll(groupingSeparator, '')\n    .replace(decimalSeparator, '.');\n  const amount = Number(normalized);\n  if (!Number.isFinite(amount) || amount &lt; 0) {\n    throw new Error(`Cannot parse amount: ${rawAmount}`);\n  }\n  return amount;\n}\nfunction hasMonetaryContext(text, currency) {\n  const normalizedCurrency = cleanText(String(currency), 'currency').toUpperCase();\n  const symbols = {\n    EUR: ['\u20ac'],\n    USD: ['$', 'US$'],\n    CAD: ['C$', '$'],\n    GBP: ['\u00a3'],\n  }[normalizedCurrency] ?? [];\n  const markers = [normalizedCurrency, ...symbols];\n  const numberPattern = '[+-]?\\\\d(?:[\\\\d.,\\\\s\\\\u00a0]*\\\\d)?';\n  return markers.some((marker) =&gt; {\n    const escaped = marker.replace(\/[.*+?^${}()|[\\]\\\\]\/g, '\\\\$&amp;');\n    return new RegExp(\n      `(?:${escaped}\\\\s*.{0,8}${numberPattern}|${numberPattern}.{0,8}\\\\s*${escaped})`,\n      'i',\n    ).test(text);\n  });\n}\nfunction parseShipping(rawShipping, decimalSeparator, currency) {\n  if (rawShipping == null || !String(rawShipping).trim()) {\n    return { shippingAmount: null, shippingText: null };\n  }\n  const shippingText = cleanText(String(rawShipping), 'shipping');\n  if (\/\\b(free|kostenlos|gratuit)\\b\/i.test(shippingText)) {\n    return { shippingAmount: 0, shippingText };\n  }\n  if (!\/\\d\/.test(shippingText) || !hasMonetaryContext(shippingText, currency)) {\n    return { shippingAmount: null, shippingText };\n  }\n  return {\n    shippingAmount: parseLocalizedAmount(shippingText, decimalSeparator),\n    shippingText,\n  };\n}\nfunction normalizeOffer(raw) {\n  const capturedAt = new Date(raw.capturedAt);\n  if (Number.isNaN(capturedAt.getTime())) {\n    throw new Error(`Invalid capture time: ${raw.capturedAt}`);\n  }\n  const decimalSeparator = normalizeDecimalSeparator(raw.decimalSeparator);\n  const currency = resolveCurrency(raw.rawPrice, raw.currency);\n  const shipping = parseShipping(raw.rawShipping, decimalSeparator, currency);\n  return {\n    captureId: cleanText(raw.captureId, 'captureId'),\n    productId: cleanText(raw.canonicalProductId, 'canonicalProductId'),\n    market: cleanText(raw.market, 'market').toUpperCase(),\n    variant: cleanText(raw.variant, 'variant').toLowerCase(),\n    seller: cleanText(raw.rawSeller, 'seller'),\n    amount: parseLocalizedAmount(raw.rawPrice, decimalSeparator),\n    currency,\n    decimalSeparator,\n    shippingAmount: shipping.shippingAmount,\n    shippingText: shipping.shippingText,\n    capturedAt: capturedAt.toISOString(),\n  };\n}\nfunction comparisonKey(offer) {\n  return [offer.productId, offer.market, offer.variant, offer.seller, offer.currency].join('|');\n}\nfunction detectChange(previous, current) {\n  if (comparisonKey(previous) !== comparisonKey(current)) {\n    throw new Error('Offers are not comparable');\n  }\n  const amountDelta = Number((current.amount - previous.amount).toFixed(2));\n  const shippingChanged = current.shippingAmount !== previous.shippingAmount;\n  if (amountDelta === 0 &amp;&amp; !shippingChanged) return null;\n  return {\n    type: 'offer_change',\n    key: comparisonKey(current),\n    previousCaptureId: previous.captureId,\n    currentCaptureId: current.captureId,\n    previousAmount: previous.amount,\n    currentAmount: current.amount,\n    amountDelta,\n    currency: current.currency,\n    previousShippingAmount: previous.shippingAmount,\n    currentShippingAmount: current.shippingAmount,\n    detectedAt: current.capturedAt,\n  };\n}\nconst normalizedRecords = rawOffers.map(normalizeOffer);\nconst change = detectChange(normalizedRecords[0], normalizedRecords[1]);\nconsole.log(JSON.stringify({ normalizedRecords, change }, null, 2));<\/code><\/pre>\n<p>Raw strings stay immutable. <code>12.5<\/code> and <code>12,5<\/code> parse only under their declared separators; malformed or conflicting formats are rejected. Shipping needs a nearby currency code or supported symbol, so calculated delivery and digit-bearing timing remain text with a null amount. Cross-currency analysis still needs a named rate source and timestamp.<\/p>\n<h2>Check the structured result before alerting<\/h2>\n<p>Running the example prints two comparable normalized records and one price change. The strict JSON below is the expected contract. A production job can alert on <code>amountDelta<\/code>, but it should keep shipping changes separate and retain links to both raw observations.<\/p>\n<pre data-check='json'><code class='language-json'>{\n  \"normalizedRecords\": [\n    {\n      \"captureId\": \"DE-01-20260716-1015\",\n      \"productId\": \"marlin-m7\",\n      \"market\": \"DE\",\n      \"variant\": \"black \/ eu plug \/ new \/ single pack\",\n      \"seller\": \"Nordlicht Handel\",\n      \"amount\": 1349,\n      \"currency\": \"EUR\",\n      \"decimalSeparator\": \",\",\n      \"shippingAmount\": 6.9,\n      \"shippingText\": \"Versand 6,90 \u20ac\",\n      \"capturedAt\": \"2026-07-16T10:15:00.000Z\"\n    },\n    {\n      \"captureId\": \"DE-01-20260717-1015\",\n      \"productId\": \"marlin-m7\",\n      \"market\": \"DE\",\n      \"variant\": \"black \/ eu plug \/ new \/ single pack\",\n      \"seller\": \"Nordlicht Handel\",\n      \"amount\": 1299,\n      \"currency\": \"EUR\",\n      \"decimalSeparator\": \",\",\n      \"shippingAmount\": 6.9,\n      \"shippingText\": \"Versand 6,90 \u20ac\",\n      \"capturedAt\": \"2026-07-17T10:15:00.000Z\"\n    }\n  ],\n  \"change\": {\n    \"type\": \"offer_change\",\n    \"key\": \"marlin-m7|DE|black \/ eu plug \/ new \/ single pack|Nordlicht Handel|EUR\",\n    \"previousCaptureId\": \"DE-01-20260716-1015\",\n    \"currentCaptureId\": \"DE-01-20260717-1015\",\n    \"previousAmount\": 1349,\n    \"currentAmount\": 1299,\n    \"amountDelta\": -50,\n    \"currency\": \"EUR\",\n    \"previousShippingAmount\": 6.9,\n    \"currentShippingAmount\": 6.9,\n    \"detectedAt\": \"2026-07-17T10:15:00.000Z\"\n  }\n}<\/code><\/pre>\n<p>An alert still needs human-readable context. State that the displayed item amount changed by EUR 50 for one seller, market, and variant while observed shipping stayed EUR 6.90. Do not call the sum a checkout total without verifying tax, delivery address, coupon eligibility, and availability.<\/p>\n<h2>Choose a cadence that preserves comparability<\/h2>\n<p>Run paired observations close enough that market comparisons are not dominated by different sale windows. Use a control offer to detect parser or layout failures. A sudden disappearance across every product is more likely a consent, routing, or capture problem than simultaneous global stock loss. Stop and inspect evidence rather than converting absent cards into zero prices.<\/p>\n<p>A stable country exit is more useful here than rapid rotation because verification, page load, and screenshot belong to one market observation. Mexela&#8217;s <a href='https:\/\/mexela.com\/proxy-pricing\/'>proxy options<\/a> can be assessed for country coverage and session stability. Close the session before changing markets.<\/p>\n<h2>Questions about Shopping comparisons<\/h2>\n<div class='mexela-faq'>\n<h3>Can the displayed card amount be called the local retail price?<\/h3>\n<p>Call it the displayed offer amount for the captured seller, variant, market, and time. Checkout may add shipping or tax, and a coupon or membership condition may change eligibility.<\/p>\n<h3>Should missing shipping become zero?<\/h3>\n<p>No. Zero means the card explicitly said free shipping or displayed a zero charge. Missing text remains <code>null<\/code> and makes a delivered-total comparison incomplete.<\/p>\n<h3>How should currencies be compared?<\/h3>\n<p>Keep the original amount and currency. If cross-currency analysis is required, add a separate converted value with the exchange-rate source, rate, and timestamp; never overwrite the observed amount.<\/p>\n<h3>What if the same model name has different plugs or capacities?<\/h3>\n<p>Treat each plug, capacity, color, condition, pack size, and bundle as a distinct variant. Only compare records whose declared comparison key matches.<\/p>\n<\/div>\n<p><!-- mexela-platform-guide:end --><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Capture controlled Google Shopping observations by market, then normalize product variants, seller, price, currency, shipping, and time separately.<\/p>\n","protected":false},"author":1,"featured_media":679,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[47],"tags":[499,497,495,498,482,496,174,500],"_links":{"self":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/678"}],"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=678"}],"version-history":[{"count":2,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/678\/revisions"}],"predecessor-version":[{"id":860,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/678\/revisions\/860"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/media\/679"}],"wp:attachment":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/media?parent=678"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/categories?post=678"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/tags?post=678"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}