How to Scrape Macy's Product Data
Scrape Macy's product data properly: decode the Nuxt index graph, capture the whole price object, and expand the sparse colour, size and cup variant matrix.
Most scrapers that scrape Macy's product data store a single number in a price column and quietly corrupt the dataset. I pulled one macys.com product page on 2026-10-09 and counted four different dollar figures attached to it, all correct for different purposes, plus 66 real SKUs behind a URL that looks like one product. This guide shows where each figure lives, how to expand the SKU matrix without inventing rows that do not exist, and what it costs to run at scale.
What macys.com returned today
Every number below came out of my own terminal on 2026-10-09, from a single datacenter IP. Macy's sits behind Akamai, and the edge answers differently depending on how you knock.
| Probe | Result |
|---|---|
| `curl` + Chrome User-Agent, `/robots.txt` | `403`, 9,901-byte "Access Denied - Macy's" page, `Server: AkamaiGHost` |
| Same request, response headers | `X-Reference-Error`, `Akamai-GRN`, `X-Macys-Device: PC` |
| `curl_cffi` with `impersonate="safari17_0"`, `/robots.txt` | `200`, 2,332 bytes, `Server: AkamaiNetStorage` |
| `curl_cffi` Safari fingerprint, product page | `200`, 1,339,170 bytes of HTML |
| Cookies set on the product response | `_abck`, `bm_s`, `bm_so`, `bm_sz`, `akavpau_www_www1_macys`, `GCLB`, `MISCGCs` |
Two things in that table matter more than the status codes. First, robots.txt is served from Akamai NetStorage while the HTML comes from AkamaiGHost, so a block on one does not predict the other. Second, _abck and the bm_* trio are Akamai Bot Manager cookies, and akavpau_ is Akamai's visitor-prioritisation cookie, the same family of mechanism behind virtual waiting rooms. Macy's has the queueing machinery armed even outside a sale event.
One more header worth keeping: the 403 response still handed me a MISCGCs cookie containing USERLL1_92_12.98%2C77.583 and USERST1_92_KA, a latitude/longitude pair and a state code derived from my IP. Macy's geolocates you at the edge before it decides what to show, which is why store-scoped fields change when your exit IP changes.
The payload is an index graph, not JSON
Macy's product pages are a Nuxt 3 application (the navigation bundle reported version 1.381.0). The server-rendered state sits in one tag:
<script type="application/json" data-nuxt-data="nuxt-app" data-ssr="true" id="__NUXT_DATA__">
That tag parses as JSON, and then it lies to you. Nuxt serialises with devalue, which flattens the whole object graph into a single array where every value is an integer index into that same array. The page I pulled had 5,388 nodes. A raw json.loads gives you objects like {"priceType": 329, "policy": 331, "tieredPrice": 334, "priceTypeId": 113}. Those are not prices. 334 is a pointer.
This is the step that sinks most Macy's scrapers. They either regex a $\d+\.\d\d out of the HTML and grab whichever one appears first, or they read the index literally and store 334 as a price. You need a resolver:
import json
import re
def nuxt_payload(html):
"""Return the flat devalue array from a Macy's product page."""
m = re.search(
r'<script[^>]*id="__NUXT_DATA__"[^>]*>(.*?)</script>', html, re.S
)
if not m:
raise ValueError("no __NUXT_DATA__ tag: page was probably a block page")
return json.loads(m.group(1))
def make_resolver(flat):
"""Walk the devalue index graph, memoised, cycle-safe."""
memo = {}
def resolve(i, depth=0):
if depth > 80:
return None
if isinstance(i, bool) or not isinstance(i, int):
return i
if i < 0 or i >= len(flat):
return i
if i in memo:
return memo[i]
node = flat[i]
memo[i] = None
if isinstance(node, list):
out = [resolve(x, depth + 1) for x in node]
elif isinstance(node, dict):
out = {k: resolve(v, depth + 1) for k, v in node.items()}
else:
out = node
memo[i] = out
return out
return resolve
The memo matters: the graph is heavily shared and cyclic in places, so a naive recursive walk either explodes or hangs. With the resolver in place you can find the nodes you want by shape rather than by path, which survives Macy's shuffling indices between deploys:
def find_nodes(flat, resolve, *required_keys):
"""Yield resolved nodes that are dicts containing all required_keys."""
for i, node in enumerate(flat):
if isinstance(node, dict) and all(k in node for k in required_keys):
yield resolve(i)
products = list(find_nodes(flat, resolve, "pricing", "traits", "identifier"))
prices = list(find_nodes(flat, resolve, "tieredPrice", "priceType"))
Index-based paths break on the next deploy. Shape-based lookup does not.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Macy's price is an object, not a number
The page also ships a block with a standard Product. Its offers array held exactly one Offer: price: "13.20", priceCurrency: "USD", no priceValidUntil, no list price. If you parse the JSON-LD and stop there, you have stored $13.20.
The resolved price object for the same master product says something else:
{
"priceType": {"text": "Clearance", "onSale": true, "onEdv": false,
"memberProductOnSale": false, "applicableToAllUpcs": true},
"policy": {"url": "/catalog/product/pricingpolicy.ognc?fpriceTypeId=7&..."},
"tieredPrice": [
{"label": "Orig. [PRICE]", "values": [{"value": 72, "formattedValue": "$72.00", "type": "regular"}]},
{"label": "Now [PRICE]", "values": [{"value": 43.19, "formattedValue": "$43.19",
"type": "discount", "percentOff": [40]}]}
],
"priceTypeId": 7
}
Product 21263039 on 2026-10-09, prices in USD. The displayed price was $43.19. The JSON-LD said $13.20. Both came from the same HTML document. The JSON-LD figure is the cheapest UPC anywhere under that URL, which is a reasonable thing for Google Shopping and a terrible thing for a price-tracking table.
Across that single page I counted 14 distinct price objects spread over 47 nodes, carrying three different priceType values:
| `priceTypeId` | `priceType.text` | Label template | Example |
|---|---|---|---|
| 7 | `Clearance` | `Orig. [PRICE]` / `Now [PRICE]` | $72.00 to $43.19, `percentOff: [40]` |
| 24 | `Limited-Time Special` | `Reg. [PRICE]` / `Sale [PRICE]` | $68.00 to $40.80, `percentOff: [40]` |
| 0 / 11 | `null` | `[PRICE]` | $22.00, single `regular` tier |
So the label template is a function of priceTypeId, not of the numbers. The same arithmetic shows up as "Orig./Now" under clearance and "Reg./Sale" under a limited-time special, and a plain [PRICE] when nothing is marked down. Normalise on type (regular versus discount) and keep the raw label as provenance.
A detail that will produce phantom rows if you ignore it: two nodes both derived $72.00 at 40% off, and one wrote 43.19 while the other wrote 43.20. Macy's own payload disagrees with itself by a cent. Dedupe variants on the UPC, never on the price.
Then there is pricing.traitsMaps.priceToColors, which maps a price to the colour IDs that carry it. On member product 21338919, colour 10004235 sat at $68.00 with onSale: false while colour 10438203 sat at $43.20 with onSale: true. The price is not single-valued per product. It is not even single-valued per colour group.
Promotions live in a different field than price
Nothing above is the amount a shopper pays. That lives in pricing.badgesMap, keyed by promotion ID, with pricing.badgeIds giving the active set. My page carried ["19948001", "19952961"]:
{
"19948001": {
"promoId": "19948001",
"header": "Mix & Match 3 for $54",
"promotionType": "Promotional Pricing",
"offer": "PROMO_TIERED_OFFER",
"thresholdTriggers": [{"threshold": 3, "triggerType": "QUANTITY", "tier": 1}],
"autoApplied": true,
"walletEligible": false,
"applicableToAllUpcs": false
},
"19952961": {
"promoId": "19952961",
"header": "Coupon excluded",
"description": "Last Act: Extra savings do not apply.",
"thresholdTriggers": [{"threshold": 1, "triggerType": "QUANTITY", "tier": 1}],
"autoApplied": false
}
}
Read those two together and the real answer for this item on 2026-10-09 was: $43.19 for one, effectively $18.00 each if you buy three, and no coupon will improve either figure because the item is Last Act. A column called price cannot hold that.
Three fields decide whether a promotion is real for your use case:
autoAppliedtells you whether the discount lands in the bag without a code.19948001istrue;19952961isfalse.thresholdTriggers[].thresholdwithtriggerType: "QUANTITY"is the quantity gate. A threshold of1is an always-on badge;3is a multi-buy.applicableToAllUpcswasfalseon both badges, so promotion eligibility is itself per-UPC. You cannot attach a promo to the master row and call it done.
Macy's also links its own disclaimer from price.policy.url, and the text is worth reading once: "Regular" and "Original" prices "are offering prices", meaning Macy's offered the item at that price but "may not have sold the item at that price", and some ORIG. prices "may not have been in effect during the past 90 days". If you are computing discount depth, that is the vendor telling you the denominator is a list price, not a historical transaction price.
The variant matrix: colour, size, cup, store
One ?ID= URL is not one product. Product 21263039 resolved to a master plus four member products (21338919, 1894748, 1894751, 28007178), discoverable through relationships.memberProductIds and relationships.memberProductMap. Tell them apart with detail.flags.masterProduct and detail.flags.memberProduct.
The master's own traits.traitsMaps.upcMap was {}. Empty. Every real SKU hangs off the members, and the keys are composite:
# upcMap keys are "<colorId>_<sizeId>_<typeId>" -> UPC
# e.g. "10953960_0_0" -> 57623406
def iter_skus(member):
traits = member.get("traits") or {}
upc_map = (traits.get("traitsMaps") or {}).get("upcMap") or {}
colors = (traits.get("colors") or {}).get("colorMap") or {}
sizes = (traits.get("sizes") or {}).get("sizeMap") or {}
for key, upc in upc_map.items():
parts = key.split("_")
if len(parts) != 3:
continue
color_id, size_id, type_id = parts
yield {
"upc": upc,
"member_product_id": member["identifier"]["productId"],
"color_id": color_id,
"color": (colors.get(color_id) or {}).get("name"),
"size_id": size_id,
"size": (sizes.get(size_id) or {}).get("name"),
"type_id": type_id,
}
Here is why the Cartesian product is the wrong expansion. traits.sizes.displayValue was "Band" and the third axis was named "Cup", so this item is a three-dimensional matrix, and it is sparse:
| Member product | Colours | Sizes | Types | Real UPCs | Full Cartesian |
|---|---|---|---|---|---|
| `1894748` | 1 | 3 | 2 | 5 | 6 |
| `1894751` | 9 | 7 | 1 | 43 | 63 |
| `21338919` | 3 | 4 | 4 | 14 | 48 |
| `28007178` | 1 | 3 | 3 | 4 | 9 |
| **Total** | **66** | **126** |
66 real SKUs out of 126 possible combinations: the matrix is 52.4% dense, and member 21338919 is only 29% dense. Expanding colours by sizes by types would have invented 60 SKUs that Macy's does not sell. Taking only the default selection would have stored 1 of 66.
The adjacency you need is already in the payload. Each entry in sizes.sizeMap carries colorToTypes and typeToColors, so band 34 listed cup types [0, 1] for colour 10953960 while band 38 listed only [0]. Walk upcMap and you get the sparse truth for free; build the matrix yourself and you get fiction.
The store axis is separate again. availability on each product exposes available, lowAvailability, bopsAvailability (pick up in store), bossAvailability (ship to store), storeAvailability and checkInStoreEligibility. All six were booleans on my pull, and all six are geo-sensitive because of the MISCGCs cookie described earlier. For a store-by-store inventory sweep the pattern is closer to the one in our Home Depot store-scoped guide than to anything on this page.
What robots.txt actually allows
Read with a Safari fingerprint, https://www.macys.com/robots.txt was 2,332 bytes on 2026-10-09. The relevant lines for a product scrape:
Disallow: /searchandDisallow: /shop/search. Search result pages are off the table.Disallow: /shop/all/,Disallow: ids=,Disallow: /Sortby/,Disallow: Productsperpage,Disallow: Custratings,Disallow: Price/. Faceted and sorted listing URLs are disallowed as a family.Allow: /shop//,,Pageindex/,followed byDisallow: /shop//,,,Pageindex/,*. Pagination is permitted at one facet depth and refused one comma deeper. That asymmetry is deliberate and it is the single most useful line in the file: paginate the shallow category URL, do not paginate a filtered one.Disallow: natuzziandDisallow: Natuzzi, a brand-level carve-out that almost certainly reflects a vendor agreement.User-agent: ClaudeBotwithCrawl-delay: 5. There is noCrawl-delayunderUser-agent: *, so Macy's has set an explicit pace for one named crawler and left the default unstated. Absence of a number is not permission to hammer; 5 seconds is the only rate Macy's has published, and it is a defensible default to adopt.
Two sitemap lines close the file, mcom_sitemapindex.xml and mcom_sitemapindex_labs.xml. Those are an invitation, and they make the whole disallowed-listing problem go away.
None of this is legal advice. Scrape public pages, obey the disallow list, stay off checkout and account paths (/buy/, /my/bag, /account-xapi/* are all disallowed), and keep copyrighted images and review text out of anything you republish.
Build the ID list from the sitemap shards
https://www.macys.com/dyn_img/sitemap/mcom_sitemapindex.xml returned 4,843 bytes listing 34 child sitemaps: 23 named mcom_sitemap_pdp_.xml.gz, 5 facets_, 2 registry_*, and one each of cat_, misc_, onsite_ and 1_homepg. I pulled mcom_sitemap_pdp_aa.xml.gz: 2,493,967 gzipped bytes, 50,000 entries, every one of them a /shop/product/ URL with , and .
23 shards at 50,000 URLs is the order of a million product pages, which means discovery is never your bottleneck and you should not crawl categories at all.
import gzip
import io
import re
import xml.etree.ElementTree as ET
NS = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
INDEX = "https://www.macys.com/dyn_img/sitemap/mcom_sitemapindex.xml"
def product_ids(fetch, index_url=INDEX, shard_prefix="mcom_sitemap_pdp_"):
"""fetch(url) -> bytes. Yields (product_id, url, lastmod)."""
root = ET.fromstring(fetch(index_url))
shards = [
loc.text
for loc in root.findall(".//s:sitemap/s:loc", NS)
if shard_prefix in (loc.text or "")
]
for shard in shards:
blob = fetch(shard)
if shard.endswith(".gz"):
blob = gzip.GzipFile(fileobj=io.BytesIO(blob)).read()
for url_el in ET.fromstring(blob).findall(".//s:url", NS):
loc = url_el.findtext("s:loc", default="", namespaces=NS)
m = re.search(r"[?&]ID=(\d+)", loc)
if m:
yield m.group(1), loc, url_el.findtext(
"s:lastmod", default="", namespaces=NS
)
Treat as a hint and verify it. The shard I read on 2026-10-09 carried lastmod values stamped 2025-10-08T00:43:46-04:00, so the field is not a reliable change signal on its own.
Step 1: a plain fetch with a browser TLS stack
Here is the measurement that decides your architecture. Six product URLs from the sitemap shard, one datacenter IP, two clients, 5 seconds between requests:
| Client | 200s | `__NUXT_DATA__` present |
|---|---|---|
| `requests` with a Chrome User-Agent | 0 / 6 | 0 / 6 |
| `curl_cffi`, `impersonate="safari17_0"` | 6 / 6 | 6 / 6 |
Same IP. Same headers in spirit. The only variable was the TLS and HTTP/2 fingerprint, and it flipped a total block into a total pass. That is a small sample (n=6, one IP, one session, 2026-10-09) and not a durable success rate, but the direction is unambiguous and it is cheap to re-measure yourself. Note also that impersonate="chrome124" returned a 403 on the homepage in the same run while safari17_0 returned 1.6 MB, so profile choice is not cosmetic. Pin the profile you tested and re-test after upgrading curl_cffi.
I used curl_cffi 0.16.3, installed today. Everything a Macy's product scrape needs, the price object, the promo badges, the full upcMap, arrives in that plain server-rendered response. No browser required.
import time
from curl_cffi import requests as cr
UPSTREAM = "safari17_0"
def fetch_pdp(url, session=None, delay=5.0):
"""Fetch one Macy's PDP. Returns (status, html). Honours a 5s pace."""
s = session or cr.Session(impersonate=UPSTREAM)
r = s.get(url, timeout=40)
time.sleep(delay)
return r.status_code, r.text
def looks_blocked(status, html):
if status in (403, 429):
return True
if "Access Denied" in html[:4000] and "X-Reference-Error" not in html:
return True
return 'id="__NUXT_DATA__"' not in html
That last check is the one that saves you. Macy's block page is a 9,901-byte HTML document that renders fine and contains no product data, so a pipeline keyed on status == 200 will happily write thousands of empty rows. Absence of the __NUXT_DATA__ tag is the signal, not the status code. Our blocked-scraper detection guide covers the general pattern; on macys.com this is the specific marker.
Step 2: a rendered session, and when you need one
You do not need a headless browser for prices, variants or promotions. You need one for three things: review pagination beyond the first page, the store-availability lookups that fire after you pick a ZIP, and any flow where Akamai hands you an interstitial and wants to see the challenge solved.
If you go there, the cookie set from Step 1 tells you what to preserve: _abck, bm_s, bm_so, bm_sz and akavpau_www_www1_macys are the ones Akamai scores, and MISCGCs, shippingCountry and currency are the ones that pin geography and money. Sharing an _abck across IPs is a reliable way to get the whole set invalidated, so keep one browser context per exit IP and let it age. The mechanics are the same as in our Akamai Bot Manager walkthrough, and a stale or mismatched fingerprint is still the usual cause of a sudden 403 wall.
For a sibling target where the hard part is JS rather than pricing, Target's RedSky API and Etsy's embedded JSON-LD are better reference points than Macy's.
Step 3: the managed path on SparkProxy's Scraping API
Once you are fetching a million PDPs the problem stops being the fingerprint and becomes IP supply, retries and cost accounting. SparkProxy's Scraping API lives at https://scrape.sparkproxy.io/api/v1 and authenticates with an X-API-Key header. Signing up gives you 1,000 free credits with no card.
Two parameters do almost all the work on this target, and the default is the expensive one:
import base64
import requests
API = "https://scrape.sparkproxy.io/api/v1"
KEY = "YOUR_API_KEY"
PDP = "https://www.macys.com/shop/product/x?ID=21263039"
r = requests.get(
API,
headers={"X-API-Key": KEY},
params={
"url": PDP,
"render_js": "false", # plain HTTP fetch, 1 credit instead of 5
"transparent_status_code": "true", # give me Macy's real 403
"json_response": "true", # envelope with credits_used and duration_ms
},
timeout=90,
)
env = r.json()
html = base64.b64decode(env["body"]).decode("utf-8", "replace")
print(env["status_code"], env["credits_used"], env["duration_ms"], len(html))
Three things in that snippet are easy to get wrong:
render_js defaults to true and costs 5 credits. Macy's ships its full state server-side, so render_js=false is correct here and bills 1 credit, a 5x saving on every request for identical data. Set it explicitly; do not rely on the default.
json_response=true is the only way to get the envelope (job_id, status_code, duration_ms, credits_used, body), and the body is base64. Decode before you look for __NUXT_DATA__, or your block detector will never match. format is a different parameter entirely: it converts content to html, md, mdx, json, screenshot or pdf. format=json does not give you the envelope.
transparent_status_code mirrors Macy's own status code into the API response, and it only applies when render_js=false. Headless mode always returns 200 on success or 530 on failure, which means in headless mode you must detect the block from the body. On this target you want the real 403.
For the sitemap shards and for sweeping many PDPs cheaply, batch mode is the lever: pass comma-separated URLs in url, require render_js=false, and the whole batch costs 1 credit regardless of URL count. You get back a results array with per-URL httpStatus, success and body.
def batch_pdps(urls, key=KEY, chunk=20):
"""Batch mode: render_js=false required, 1 credit per batch."""
out = []
for i in range(0, len(urls), chunk):
part = urls[i:i + chunk]
resp = requests.get(
API,
headers={"X-API-Key": key},
params={"url": ",".join(part), "render_js": "false"},
timeout=180,
)
data = resp.json()
for row in data.get("results", []):
ok = row.get("success") and 'id="__NUXT_DATA__"' in (row.get("body") or "")
out.append({"url": row["url"], "status": row.get("httpStatus"), "usable": ok})
return out
Add country_code=US (+5 credits) when you need a US exit for store-scoped fields, and proxy_type=premium to draw from the premium pool, which the credit table prices at 10 credits without JS. Skip extract_rules: it requires render_js=true, it replaces the response with an extracted object rather than adding one, and a CSS selector cannot reach inside a devalue index graph anyway. Parse the payload yourself.
Behind the API, the pool is 1M+ premium IPs across 80+ countries with random or 5-minute auto-rotation, HTTP/HTTPS/SOCKS5, IP-whitelist or user:pass auth and 1 Gbps ports. Proxy plans start from $75/month as packs with unlimited bandwidth, so the billing unit is the plan and concurrency rather than traffic consumed, which suits a one-million-URL sweep. There is a 24-hour free trial without a card, and term discounts of 5%, 10% and 15% on 3, 6 and 12 months.
Reconcile the price object before you store it
Flatten to one row per UPC, never one row per URL, and keep every price tier rather than a winner. A schema that survived this page:
| Column | Source | Note |
|---|---|---|
| `upc` | `upcMap` value | Primary key. Never dedupe on price. |
| `master_product_id` / `member_product_id` | `identifier.productId` | Keep both; `flags.masterProduct` distinguishes them. |
| `color_id`, `size_id`, `type_id` | `upcMap` key split on `_` | Resolve names via `colorMap` / `sizeMap`. |
| `price_regular`, `price_final` | `tieredPrice[].values[0].value` by `type` | Two columns minimum. |
| `percent_off` | `values[0].percentOff[0]` | Macy's own figure; do not recompute. |
| `price_type_id`, `price_type_text` | `priceTypeId`, `priceType.text` | `7` = Clearance, `24` = Limited-Time Special. |
| `promo_ids`, `promo_headers`, `promo_auto_applied` | `badgeIds`, `badgesMap` | Multi-valued. `applicableToAllUpcs` gates each. |
| `coupon_excluded` | badge header or `description` | Last Act items reject extra savings. |
| `avail_*` | `availability` booleans | Geo-sensitive via `MISCGCs`. |
| `scraped_at`, `currency` | Your clock, `USD` | Date every price. |
Four assertions worth running before a write, all of which my sample would have caught:
def validate_row(row):
problems = []
if row.get("price_final") is None:
problems.append("no final price: parsed a block page or the wrong node")
reg, fin = row.get("price_regular"), row.get("price_final")
if reg and fin and fin > reg:
problems.append("final above regular: tiers were swapped")
if row.get("percent_off") and reg and fin:
implied = round(100 * (1 - fin / reg))
if abs(implied - row["percent_off"]) > 1:
problems.append(f"percentOff {row['percent_off']} vs implied {implied}")
if not row.get("upc"):
problems.append("no UPC: master product rows have an empty upcMap")
return problems
The one-cent tolerance in that third check is not laziness. It is there because Macy's emitted both 43.19 and 43.20 for $72.00 at 40% off on the same page.
On scheduling: percentOff and priceTypeId are the fields that move, and they move on Macy's promotional calendar rather than on a clock. A sweep that re-reads badgeIds and the two price tiers daily, and the full upcMap weekly, catches markdown transitions at a fraction of a full re-crawl. Pipe the output through the normalisation habits in our scraped-data cleaning guide before it reaches a dashboard, because formattedValue strings and value floats will both show up in the same dataset otherwise.
Frequently asked questions
FAQ
Scraping publicly visible pages is generally treated differently from accessing gated data, but Macy's robots.txt disallows /search, /shop/all/, faceted listing URLs and all checkout and account paths, and its terms of use govern your access. Stay on /shop/product pages reached from the published sitemaps, and get your own legal advice before commercial use.
Almost certainly your TLS fingerprint, not your IP or User-Agent. In my test on 2026-10-09, stock Python requests with a Chrome User-Agent got 0 of 6 product pages while curl_cffi with impersonate="safari17_0" got 6 of 6 from the same IP.
Store tieredPrice entries with type: "discount" as the selling price and type: "regular" as the list price, in two separate columns. The single price in the JSON-LD block is the cheapest UPC under that URL, which on my sample was $13.20 against a displayed $43.19.
More than the page suggests. Product 21263039 resolved to four member products holding 66 distinct UPCs, while the master product's own upcMap was empty, and the colour-by-size-by-cup matrix was only 52.4% dense.
No, not for prices, variants or promotions: all of it ships in the server-rendered __NUXT_DATA__ payload, so render_js=false at 1 credit is the right setting on the Scraping API. Reserve a browser for review pagination and store-availability lookups.
The availability object exposes bopsAvailability, bossAvailability and storeAvailability as booleans, and those values follow the geolocation Macy's derives from your exit IP into the MISCGCs cookie. Pin a US exit with country_code=US and treat store inventory as a separate crawl keyed on ZIP.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles
How to Bypass Fastly Next-Gen WAF
Bypass Fastly Next-Gen WAF (Signal Sciences) the right way: how its agent decides per signal at the edge, the 406 block, and why clean IPs beat header tricks.
How to Bypass Vercel BotID When Web Scraping
Bypass Vercel BotID: confirm it in one request, read the Kasada engine underneath, see what a block really returns, and know where the ceiling sits.
Anubis Proof of Work Bypass: What Solving Really Costs
Anubis proof of work bypass read from the MIT-licensed source: the SHA-256 loop, bits versus nibbles difficulty, measured solve costs, and when to stop.
