Merchant Center disapproves a product for a price mismatch, and the product page looks right: the price on it is the price in the feed. It looks right because your browser is in your market. Google's crawler compares the feed with the page it gets, and in Google's own words, "the default IP addresses of the Googlebot crawler appear to be based in the USA." A store that changes the price, the currency or the address with the visitor's country can hand the crawler a page your feed never described. Google's Merchant Center help says so directly: "Don't use IP geolocation to automatically change prices on your landing page, as Google crawlers may not find the correct regional price."
This guide checks each product page from two places: the US, where Googlebot crawls from by default, and the country your feed targets. A Python script reads your feed rows, captures every link from both countries, reads the price, currency and availability the page declares in its structured data, records where the browser landed, and writes a report with both screenshots side by side. You can check one page free in the browser first.
We ran it on 10 November 2026 on the public product pages of two Shopify stores that sell abroad, against feed rows we wrote from the prices those pages showed. We do not see these stores' feeds: the script reports what each page says, not what Merchant Center decided.
Check one product page in the browser, free
- Open site-shot.com and paste the product link from your feed. Capturing is free and needs no signup.
- In the Country dropdown, select United States. (The default is Direct connection, which renders from our data center without geo routing.)
- Press Shot and compare the price, the currency and the stock in the picture with the feed row.
- Paste the same link into Google's Rich Results Test. Google's help suggests it for this job: the Offer block it finds should carry the feed's price and currency.
US captures are free and require no account. Every other country in the dropdown unlocks with any paid plan, so the second view, the one a shopper in your feed's country gets, is where the browser tool stops on the free path. A feed of many products is a job for the API.
Put the feed rows in a file
You need Python 3.9 or newer, the Site-Shot SDK and an API key. Install the SDK:
pip install site-shot
For the key, a confirmed account's API key comes with the free plan: 100 API captures a month, no card needed; paid plans start at $5/mo for 2,000. The API quickstart gets you one. Free-plan captures come from the default location or the US only, so on the free plan put US in the country column: a capture from any other country is refused and still counts against the month, and that view needs any paid plan. Each row costs two captures, or one when its country is the US.
Put the products in feed.csv, one row per link in your feed:
link,price,availability,country
https://store.example/products/leather-case,39.00 EUR,in_stock,DE
https://store.example/eu/products/leather-case,39.00 EUR,in_stock,DE
Write the price as the feed does, a number and a currency code, and the availability in the feed's words (in_stock, out_of_stock, preorder, backorder). The country is the feed's target country as a two-letter ISO code, such as GB (not UK) or DE. The script reads the columns by name, so a Merchant Center product export or your own feed file works if it is comma-separated and has these columns. If it also has a sale_price column, the script uses it wherever it is filled.
Run the check
Save this as price_check.py next to feed.csv:
"""Check each product page on feed.csv from the US and from the row's country.
python price_check.py
Captures every link from the US, where Googlebot crawls from by default, and from the row's
country, compares the page's structured data with the feed and writes price-check/report.html.
"""
import base64
import csv
import html
import json
import re
import time
from decimal import Decimal, InvalidOperation
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlsplit
from site_shot import SiteShot, SiteShotError
client = SiteShot() # reads SITESHOT_API_KEY from the environment
OUT = Path("price-check")
SETTINGS = dict(
width=1280, height=1200,
no_cookie_popup=True, no_ads=True,
fresh_session=True, source_code=True,
strict_country=True,
)
# Writes the address the browser ended up on, after any redirect, into the page source.
FINAL_URL = "document.documentElement.setAttribute('data-final-url', location.href);"
CRAWLER = "US" # Googlebot's default IP addresses are in the US
STOCK = {"instock": "in_stock", "outofstock": "out_of_stock", "soldout": "out_of_stock",
"preorder": "preorder", "backorder": "backorder"}
def same(a, b):
"""Whether two addresses name the same page, ignoring the case of scheme and host."""
def parts(url):
p = urlsplit(url if "//" in url else "https://" + url)
return p.scheme.lower(), p.netloc.lower(), unquote(p.path or "/"), p.query
try:
return parts(a) == parts(b)
except ValueError: # a malformed address, such as "https://[bad"
return a == b
def amount(value):
"""A price as a Decimal with two places, or None."""
text = str(value).strip()
if re.fullmatch(r"\d{1,3}(\.\d{3})*,\d\d|\d+,\d\d", text): # 39,00 or 1.299,00
text = text.replace(".", "").replace(",", ".")
try:
return Decimal(text.replace(",", "")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
def stock(value):
"""A schema.org or feed availability value in the feed's words."""
if isinstance(value, list):
value = value[0] if value else None
if isinstance(value, dict): # {"@id": "https://schema.org/InStock"}
value = value.get("@id")
word = str(value or "").strip().rsplit("/", 1)[-1].lower().replace(" ", "_")
return STOCK.get(word, word) or None
class Page(HTMLParser):
"""The prices a product page declares, read from its rendered HTML."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.final_url, self.offers, self.meta, self.blocks = None, [], {}, []
self.inside = False
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
key = (attrs.get("property") or "").lower()
if tag == "html":
self.final_url = attrs.get("data-final-url")
elif tag == "meta" and key in ("og:price:amount", "og:price:currency",
"product:price:amount", "product:price:currency"):
self.meta.setdefault(key, (attrs.get("content") or "").strip())
elif tag == "script" and (attrs.get("type") or "").strip().lower() == "application/ld+json":
self.inside = True
self.blocks.append("")
def handle_data(self, data):
if self.inside:
self.blocks[-1] += data
def handle_endtag(self, tag):
if tag == "script" and self.inside:
self.inside = False
try:
self.walk(json.loads(self.blocks[-1]))
except ValueError:
pass # an unreadable block declares nothing Google can read either
def walk(self, data):
if isinstance(data, dict):
kinds = data.get("@type") or []
kinds = kinds if isinstance(kinds, list) else [kinds]
if {"Offer", "AggregateOffer"} & {str(kind) for kind in kinds}:
self.offers.append({
"price": amount(data.get("price", data.get("lowPrice"))),
"currency": str(data.get("priceCurrency") or "").upper() or None,
"availability": stock(data.get("availability")),
"url": str(data.get("url") or ""),
})
data = list(data.values())
if isinstance(data, list):
for item in data:
self.walk(item)
def variant(url):
"""The variant id a Shopify-style product link selects, if any."""
return parse_qs(urlsplit(url).query).get("variant", [None])[0]
def capture(link, country, image):
"""Capture link from country, save the screenshot as OUT/image and return what the page says."""
reply = client.capture_json(
link,
country=country,
javascript_code=FINAL_URL,
**SETTINGS,
)
(OUT / image).write_bytes(base64.b64decode(reply["image"].split(",", 1)[1]))
page = Page()
page.feed(reply.get("source_code") or "")
picked = [o for o in page.offers if variant(link) and variant(o["url"]) == variant(link)]
offer = (picked or page.offers or [None])[0]
if offer is None and page.meta.get("og:price:amount"):
offer = {"price": amount(page.meta["og:price:amount"]), "availability": None,
"currency": page.meta.get("og:price:currency", "").upper() or None}
currencies = {o["currency"] for o in page.offers if o["currency"]}
currencies |= {page.meta[k].upper() for k in ("og:price:currency", "product:price:currency")
if page.meta.get(k)}
return {
"status": reply.get("response", {}).get("status_code"),
"source": reply.get("source_code_status"), "landed": page.final_url or link,
"offer": offer, "currencies": sorted(currencies), "image": image,
"checked": time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime()),
}
def compare(row, view):
"""What to look at in one country's view of the page, against the feed row."""
notes = [] if view["status"] == 200 else ["status %s" % view["status"]]
if view["source"] != "ok":
return notes + ["no page source (%s)" % view["source"]]
if not same(view["landed"], row["link"]):
notes.append("lands on %s" % view["landed"])
if len(view["currencies"]) > 1:
notes.append("the page names %s" % " and ".join(view["currencies"]))
offer = view["offer"]
if not offer or offer["price"] is None:
return notes + ["no price in the structured data"]
if offer["currency"] != row["currency"]:
notes.append("currency %s, feed %s" % (offer["currency"] or "missing", row["currency"]))
elif offer["price"] != row["price"]:
notes.append("price %s, feed %s" % (offer["price"], row["price"]))
if row["availability"] and offer["availability"] not in (None, row["availability"]):
notes.append("availability %s, feed %s" % (offer["availability"], row["availability"]))
return notes
def rows():
"""One dict per product in feed.csv: link, price, currency, availability, country."""
with open("feed.csv", newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
reader.fieldnames = [(name or "").strip().lower().replace(" ", "_")
for name in reader.fieldnames or []]
if not {"link", "price", "country"} <= set(reader.fieldnames):
raise SystemExit("feed.csv needs the columns link, price and country")
found = []
for n, row in enumerate(reader, 1):
link = (row.get("link") or "").strip()
if not link:
continue # a blank line
price = (row.get("sale_price") or "").strip() or (row.get("price") or "").strip()
match = re.fullmatch(r"([\d.,]+)\s*([A-Za-z]{3})", price)
if not match:
raise SystemExit("row %d: write the price as in the feed, such as 39.00 EUR" % n)
country = (row.get("country") or "").strip().upper()
if not re.fullmatch(r"[A-Z]{2}", country):
raise SystemExit("row %d: write the country as a two-letter code, such as GB" % n)
found.append({"n": n, "link": link, "price": amount(match.group(1)),
"currency": match.group(2).upper(),
"availability": stock(row.get("availability")), "country": country})
return found
def card(view, notes):
"""One report cell: the check, the screenshot and what the page declared."""
if view is None:
return "<td>%s</td>" % html.escape("; ".join(notes))
offer = view["offer"] or {}
said = "%s %s · %s" % (offer.get("price") or "(no price)", offer.get("currency") or "",
offer.get("availability") or "(no availability)")
return ("<td><b>%s</b><br><a href='%s'><img src='%s' width='300'></a><br>%s<br>%s<br>"
"landed on %s</td>" % (html.escape("; ".join(notes) or "OK"), view["image"],
view["image"], view["checked"], html.escape(said),
html.escape(view["landed"])))
def main():
found = rows()
for country in {CRAWLER} | {row["country"] for row in found}:
(OUT / country.lower()).mkdir(parents=True, exist_ok=True)
cells = []
for row in found:
checks, cards = [], []
for country in dict.fromkeys([CRAWLER, row["country"]]):
try:
view = capture(row["link"], country, "%s/%02d.png" % (country.lower(), row["n"]))
notes = compare(row, view)
except SiteShotError as error:
view, notes = None, ["capture failed: %s" % error]
checks.append("%s: %s" % (country, "; ".join(notes)) if notes else "")
cards.append(card(view, notes))
if row["country"] == CRAWLER:
cards.append("<td>The feed's country is the US.</td>")
print(row["n"], " | ".join(check for check in checks if check) or "OK")
cells.append("<tr><td>%d</td><td><a href='%s'>%s</a><br>%s %s · %s · %s</td>%s</tr>" % (
row["n"], html.escape(row["link"]), html.escape(row["link"]), row["price"],
row["currency"], row["availability"] or "", row["country"], "".join(cards)))
(OUT / "report.html").write_text(
"<meta charset='utf-8'><table border='1' cellpadding='6'><tr><th>Row</th><th>Feed</th>"
"<th>From the US</th><th>From the feed's country</th></tr>" + "".join(cells) + "</table>",
encoding="utf-8",
)
main()
Run it with your key in the environment:
export SITESHOT_API_KEY=your_key
python price_check.py
It prints one line per row, with what to look at in each country's view, or OK:
1 US: currency USD, feed EUR | DE: lands on https://store.example/eu/products/leather-case
2 OK
price-check/report.html shows each feed row with both views: what the check found, the screenshot, when it was taken, the price, currency and availability the page declared, and the address it landed on. The screenshots sit in a folder per country, such as price-check/us/01.png and price-check/de/01.png.
What each setting does
countryroutes the capture through an IP address in that country and sets the browser's language, time zone and geolocation to match, as a shopper there has them.CRAWLER = "US"makes the US the first view of every row.strict_country=Truemakes a capture fail instead of falling back to a US address when the country has no capacity at that moment, so a row never shows the US page under another country's name. The JSON reply then carries the errorcountry_unavailable, and the row says the capture failed.javascript_codecopies the address the browser ended up on into the page before the HTML is read, because the API's JSON reply does not include the final address.source_code=Truereturns the rendered HTML in the same reply as the screenshot. The script reads everyOfferin the page's JSON-LD, plus theog:priceandproduct:pricemeta tags.width=1280, height=1200captures the first screen at desktop size, where a product page shows its price.no_cookie_popup=Trueandno_ads=Trueare the two blockers behind the browser tool's No ads / No cookie box;fresh_session=Truestarts each capture without the cookies earlier captures saved, so a currency one visit chose is not carried into the next.
Which rows to look at
- lands on an address: the store redirected the visitor, or a script on the page rewrote the address. Google's help asks for product URLs that "lead directly to the product page without redirects." When the page it lands on is the market's own address, such as
/eu/or/en-gb/, put that address in the feed and run the row again. - currency X, feed Y: the page's structured data names another currency in that view. When the US view says USD and the view from the feed's country shows the feed's currency or lands on another address, the page changes with the visitor's IP address, which is the case Google's rule is about. When both views say USD, as in row 3 below, neither visitor gets the feed's currency at that address.
- price X, feed Y: same currency, another number. A sale the feed does not have, a feed that has not refreshed, or a price shown with or without VAT in that country.
- availability X, feed Y: the page declares other stock in that view. Some stores keep stock per market.
- the page names two currencies: the JSON-LD blocks and the price meta tags disagree with each other. The crawler compares the feed with the page or its structured data, and of two currencies on one page, at least one disagrees with the feed.
- no price in the structured data: no JSON-LD
Offerwith a price and noog:pricemeta tag. The script does not read microdata (itempropattributes) or a price kept only inpriceSpecification, and a block page has neither, so look at the screenshot and run the link through the Rich Results Test before you conclude Google has no price to read.
A row whose two views differ in price, currency or landing address is a page that changes with the visitor's country. Fix the address before the price: a feed link that shows every visitor the same market makes both views agree.
What the run returned on 10 November 2026
Eight captures, two per row, through our own key on PyPI site-shot 0.3.3. The feed rows are ours, written from the prices each page showed in its own market:
| Row | Feed row (ours) | From the US | From the feed's country |
|---|---|---|---|
| 1 | Accessories store, product page; 39.00 EUR, in_stock, DE | currency USD, feed EUR | lands on its /eu/ address |
| 2 | Same store, its /eu/ address; 39.00 EUR, in_stock, DE |
OK | OK |
| 3 | Skincare store, product page; 20.00 GBP, in_stock, GB | currency USD, feed GBP | currency USD, feed GBP |
| 4 | Same store, link with ?currency=GBP; 20.00 GBP, in_stock, GB |
lands on its /en-gb/ address; the page names GBP and USD; currency USD, feed GBP |
lands on its /en-gb/ address; the page names GBP and USD; currency USD, feed GBP |
Rows 1 and 2 show the fix Google's rule points to. The product's own address showed a visitor from the US 45.00 USD and sent a visitor from Germany to the store's /eu/ address, at 39.00 EUR. The /eu/ address showed 39.00 EUR to both.
Row 3 is a store that greets a UK visitor with a popup offering to continue in GBP, over a page whose price and structured data stay in US dollars. A UK feed priced in pounds matches neither view until the visitor chooses.
Row 4 tries the fix that Shopify Community threads suggest, a ?currency=GBP parameter on the link. The page moved to the store's /en-gb/ address and its HTML priced the product at £20.00 (in the screenshot a newsletter popup covers that price; the products around it show pounds), but one Product block in its JSON-LD and its og:price tag still say USD, next to a second block that says GBP. The script picks the first Offer, so it reports USD and names both currencies. That is the page to fix before the feed.
Where the honest limits are
- The script reads the page after its scripts ran. Google says data passed with JavaScript after the page loads triggers an error, so a price that only JavaScript writes can read correctly here and still fail at Google.
curl -son the link shows the HTML the server sends, from wherever you run it. - Two views are a sample, not Google's crawl. Googlebot also crawls from addresses outside the US. A Site-Shot capture from the US is a visitor from the US, not Googlebot, and a site can answer Google's crawler differently. The check shows what visitors from each country get.
- A store's own popups can stay. The blockers clear cookie banners and ads, not a store's location or newsletter popup. The screenshot shows it, and the structured data underneath is read either way.
- One offer per row. The script takes the
Offerwhose URL carries the link'svariantparameter, otherwise the first one. A product whose variants cost different amounts needs each variant's link in the feed. - Sale dates and tax rules are not read. A filled
sale_priceis used without its effective dates, and a price shown with VAT in one country and without it in another shows up as a price difference for you to explain. - A check, not a feed tool or a monitor. It reads the rows you give it and does not run on a schedule. Feed tools keep prices in sync, and Merchant Center's diagnostics remain the record of what Google flagged; this shows you each page.
- The free plan sees the US view only. A capture from any other country needs any paid plan; on the free plan it is refused and still counts against the month.
FAQ
Why does Merchant Center see a different price than my browser? Because Google's crawler compares the feed with the landing page as the crawler gets it, and a store that changes the price, the currency or the address with the visitor's IP address shows the crawler the page for another market. Google says the default IP addresses of Googlebot appear to be based in the USA. Site-Shot can capture the page from the US and from the feed's country (country=US, country=GB) and return its rendered HTML, structured data included, with the screenshot, which shows what each visitor gets.
Does Google crawl product pages from the US? By default, yes: Google says the default IP addresses of the Googlebot crawler appear to be based in the USA, and that Googlebot also crawls from addresses outside the USA. Google's Merchant Center help says not to change prices with IP geolocation, and to use product URLs that lead directly to the product page without redirects.
Can I check a product page from another country for free? From the United States, yes: the free browser tool at site-shot.com captures from the US with no signup. Through the API, a confirmed account's API key comes with the free plan: 100 API captures a month, no card needed; paid plans start at $5/mo for 2,000. Free-plan captures come from the default location or the US only, and every other country needs any paid plan.
Does adding ?currency=GBP to the feed link fix a currency mismatch? Check the page it produces before relying on it. In a Site-Shot run on 10 November 2026, a Shopify store's link with ?currency=GBP moved to the store's /en-gb/ address and showed prices in pounds, while one Product block in its structured data and its og:price tag still said USD. On another store, the market's own /eu/ address showed the same euro price to visitors from the US and from Germany.
Related reading
- How to Take Website Screenshots from Another Country: the browser tool and the API from any country, with strict routing.
- How to Capture MAP Violation Evidence with Timestamped Screenshots: when the prices you check are on other sellers' pages.
- How to Compare Pages Before and After a Website Migration: the same structured-data reading, for a site that moved.
For one page, the browser tool is free and needs no signup. For a whole feed, the API runs the script above; see pricing.