๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Guides

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.

S SparkProxy 0 23 min read
Share

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.

ProbeResult
`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.

Free trial

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