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.
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’s permissions and applicable Google terms. If a licensed commerce feed or supported provider already supplies the required market fields, prefer its stable schema.
Lock product identity and market rows first
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.
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.
| Run | Market and verified exit | Interface | Query | Required variant | Delivery assumption | Account and device |
|---|---|---|---|---|---|---|
| DE-01 | Germany | German, EUR | Marlin Schreibtischlampe M7 | M7, black, EU plug, new, single pack | No postal code supplied | Signed out, clean desktop, 1365×900 |
| CA-01 | Canada | English, CAD | Marlin desk lamp M7 | M7, black, North American plug, new, single pack | No postal code supplied | Signed out, clean desktop, 1365×900 |
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 proxy-location guide helps align the exit claim with the market row.
Capture the offer without cleaning it
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 proxy verification guide explains the route check that should happen before Google.
Copy visible strings rather than turning absent text into assumptions. Preserve the card’s title, seller, price, currency label, shipping wording, promotion, and availability. If the seller or shipping is missing, store null, not “unknown seller” or zero delivery. A raw record can look like this:
{
"captureId": "DE-01-20260717-1015",
"canonicalProductId": "marlin-m7",
"rawTitle": "Marlin M7 Schreibtischlampe - Schwarz",
"rawPrice": "1.299,00 €",
"currency": "EUR",
"decimalSeparator": ",",
"rawSeller": " Nordlicht Handel ",
"rawShipping": "Versand 6,90 €",
"rawPromotion": "20 € Gutschein im Warenkorb",
"rawAvailability": "Auf Lager",
"variant": "black / EU plug / new / single pack",
"market": "DE",
"capturedAt": "2026-07-17T10:15:00.000Z",
"evidenceFile": "DE-01-20260717-1015.png"
}
This is an observation record, not executable collection instructions. The explicit decimal separator comes from the row’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.
Keep card price, adjustments, and checkout total distinct
Google’s Merchant Center product specification separates price, sale price, availability, shipping, and ISO 4217 currency. Use that vocabulary without assuming a rendered card exposes the merchant’s full record.
Coupons can require a code, account, payment method, or cart action. Keep coupon text separate unless eligibility is confirmed. Google’s pricing overview also distinguishes standard, sale, promotional, and loyalty prices.
Shipping may be free, fixed, calculated later, or absent. Normalize a paid amount only when the number appears near the record’s currency code or a supported symbol. Thus “Shipping $6.90” can be monetary, while “Delivery in 3 days” retains its text with a null amount. Only an explicit free label becomes zero.
Tax treatment varies. Google’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.
Google’s availability requirements distinguish in stock, out of stock, preorder, and backorder. A “from” price, different capacity, or unavailable shade is not evidence for the requested variant.
Normalize and detect a comparable change in Node.js
The complete program declares each row’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.
const rawOffers = [
{
captureId: 'DE-01-20260716-1015',
canonicalProductId: 'marlin-m7',
rawPrice: '1.349,00 €',
currency: 'EUR',
decimalSeparator: ',',
rawSeller: ' Nordlicht Handel ',
rawShipping: 'Versand 6,90 €',
variant: 'black / EU plug / new / single pack',
market: 'DE',
capturedAt: '2026-07-16T10:15:00.000Z',
},
{
captureId: 'DE-01-20260717-1015',
canonicalProductId: 'marlin-m7',
rawPrice: '1.299,00 €',
currency: 'EUR',
decimalSeparator: ',',
rawSeller: 'Nordlicht Handel',
rawShipping: 'Versand 6,90 €',
variant: 'black / EU plug / new / single pack',
market: 'DE',
capturedAt: '2026-07-17T10:15:00.000Z',
},
];
function cleanText(value, fieldName) {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`${fieldName} must be a non-empty string`);
}
return value.trim().replace(/\s+/g, ' ');
}
function resolveCurrency(rawAmount, explicitCurrency) {
const explicit = explicitCurrency?.trim().toUpperCase();
if (explicit) return explicit;
if (rawAmount.includes('€')) return 'EUR';
if (rawAmount.includes('C$')) return 'CAD';
if (rawAmount.includes('US$')) return 'USD';
if (rawAmount.includes('£')) return 'GBP';
throw new Error(`Currency is missing from: ${rawAmount}`);
}
function normalizeDecimalSeparator(value) {
if (value !== '.' && value !== ',') {
throw new Error('decimalSeparator must be "." or ","');
}
return value;
}
function parseLocalizedAmount(rawAmount, declaredDecimalSeparator) {
const decimalSeparator = normalizeDecimalSeparator(declaredDecimalSeparator);
const text = cleanText(rawAmount, 'amount');
const numericTokens = text.match(/[+-]?\d(?:[\d.,\s\u00a0]*\d)?/g) ?? [];
if (numericTokens.length !== 1) {
throw new Error(`Expected one numeric amount: ${rawAmount}`);
}
const compact = numericTokens[0].replace(/[\s\u00a0]/g, '');
const groupingSeparator = decimalSeparator === ',' ? '.' : ',';
const escapedDecimal = decimalSeparator === '.' ? '\\.' : ',';
const escapedGrouping = groupingSeparator === '.' ? '\\.' : ',';
const pattern = new RegExp(
`^(?:\\d{1,3}(?:${escapedGrouping}\\d{3})+|\\d+)(?:${escapedDecimal}\\d+)?$`,
);
if (!pattern.test(compact)) {
throw new Error(`Amount conflicts with declared decimal separator: ${rawAmount}`);
}
const normalized = compact
.replaceAll(groupingSeparator, '')
.replace(decimalSeparator, '.');
const amount = Number(normalized);
if (!Number.isFinite(amount) || amount < 0) {
throw new Error(`Cannot parse amount: ${rawAmount}`);
}
return amount;
}
function hasMonetaryContext(text, currency) {
const normalizedCurrency = cleanText(String(currency), 'currency').toUpperCase();
const symbols = {
EUR: ['€'],
USD: ['$', 'US$'],
CAD: ['C$', '$'],
GBP: ['£'],
}[normalizedCurrency] ?? [];
const markers = [normalizedCurrency, ...symbols];
const numberPattern = '[+-]?\\d(?:[\\d.,\\s\\u00a0]*\\d)?';
return markers.some((marker) => {
const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(
`(?:${escaped}\\s*.{0,8}${numberPattern}|${numberPattern}.{0,8}\\s*${escaped})`,
'i',
).test(text);
});
}
function parseShipping(rawShipping, decimalSeparator, currency) {
if (rawShipping == null || !String(rawShipping).trim()) {
return { shippingAmount: null, shippingText: null };
}
const shippingText = cleanText(String(rawShipping), 'shipping');
if (/\b(free|kostenlos|gratuit)\b/i.test(shippingText)) {
return { shippingAmount: 0, shippingText };
}
if (!/\d/.test(shippingText) || !hasMonetaryContext(shippingText, currency)) {
return { shippingAmount: null, shippingText };
}
return {
shippingAmount: parseLocalizedAmount(shippingText, decimalSeparator),
shippingText,
};
}
function normalizeOffer(raw) {
const capturedAt = new Date(raw.capturedAt);
if (Number.isNaN(capturedAt.getTime())) {
throw new Error(`Invalid capture time: ${raw.capturedAt}`);
}
const decimalSeparator = normalizeDecimalSeparator(raw.decimalSeparator);
const currency = resolveCurrency(raw.rawPrice, raw.currency);
const shipping = parseShipping(raw.rawShipping, decimalSeparator, currency);
return {
captureId: cleanText(raw.captureId, 'captureId'),
productId: cleanText(raw.canonicalProductId, 'canonicalProductId'),
market: cleanText(raw.market, 'market').toUpperCase(),
variant: cleanText(raw.variant, 'variant').toLowerCase(),
seller: cleanText(raw.rawSeller, 'seller'),
amount: parseLocalizedAmount(raw.rawPrice, decimalSeparator),
currency,
decimalSeparator,
shippingAmount: shipping.shippingAmount,
shippingText: shipping.shippingText,
capturedAt: capturedAt.toISOString(),
};
}
function comparisonKey(offer) {
return [offer.productId, offer.market, offer.variant, offer.seller, offer.currency].join('|');
}
function detectChange(previous, current) {
if (comparisonKey(previous) !== comparisonKey(current)) {
throw new Error('Offers are not comparable');
}
const amountDelta = Number((current.amount - previous.amount).toFixed(2));
const shippingChanged = current.shippingAmount !== previous.shippingAmount;
if (amountDelta === 0 && !shippingChanged) return null;
return {
type: 'offer_change',
key: comparisonKey(current),
previousCaptureId: previous.captureId,
currentCaptureId: current.captureId,
previousAmount: previous.amount,
currentAmount: current.amount,
amountDelta,
currency: current.currency,
previousShippingAmount: previous.shippingAmount,
currentShippingAmount: current.shippingAmount,
detectedAt: current.capturedAt,
};
}
const normalizedRecords = rawOffers.map(normalizeOffer);
const change = detectChange(normalizedRecords[0], normalizedRecords[1]);
console.log(JSON.stringify({ normalizedRecords, change }, null, 2));
Raw strings stay immutable. 12.5 and 12,5 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.
Check the structured result before alerting
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 amountDelta, but it should keep shipping changes separate and retain links to both raw observations.
{
"normalizedRecords": [
{
"captureId": "DE-01-20260716-1015",
"productId": "marlin-m7",
"market": "DE",
"variant": "black / eu plug / new / single pack",
"seller": "Nordlicht Handel",
"amount": 1349,
"currency": "EUR",
"decimalSeparator": ",",
"shippingAmount": 6.9,
"shippingText": "Versand 6,90 €",
"capturedAt": "2026-07-16T10:15:00.000Z"
},
{
"captureId": "DE-01-20260717-1015",
"productId": "marlin-m7",
"market": "DE",
"variant": "black / eu plug / new / single pack",
"seller": "Nordlicht Handel",
"amount": 1299,
"currency": "EUR",
"decimalSeparator": ",",
"shippingAmount": 6.9,
"shippingText": "Versand 6,90 €",
"capturedAt": "2026-07-17T10:15:00.000Z"
}
],
"change": {
"type": "offer_change",
"key": "marlin-m7|DE|black / eu plug / new / single pack|Nordlicht Handel|EUR",
"previousCaptureId": "DE-01-20260716-1015",
"currentCaptureId": "DE-01-20260717-1015",
"previousAmount": 1349,
"currentAmount": 1299,
"amountDelta": -50,
"currency": "EUR",
"previousShippingAmount": 6.9,
"currentShippingAmount": 6.9,
"detectedAt": "2026-07-17T10:15:00.000Z"
}
}
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.
Choose a cadence that preserves comparability
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.
A stable country exit is more useful here than rapid rotation because verification, page load, and screenshot belong to one market observation. Mexela’s proxy options can be assessed for country coverage and session stability. Close the session before changing markets.
Questions about Shopping comparisons
Can the displayed card amount be called the local retail price?
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.
Should missing shipping become zero?
No. Zero means the card explicitly said free shipping or displayed a zero charge. Missing text remains null and makes a delivered-total comparison incomplete.
How should currencies be compared?
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.
What if the same model name has different plugs or capacities?
Treat each plug, capacity, color, condition, pack size, and bundle as a distinct variant. Only compare records whose declared comparison key matches.

