> ## Content Index
> Fetch the complete content index at: https://roundproxies.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# 7 ways to scrape Home Depot in 2026
- URL: https://roundproxies.com/blog/scrape-home-depot/
- Published: 2026-11-05T21:19:30.000Z
- Updated: 2026-11-05T22:04:39.000Z
- Description: Scrape Home Depot in Python: product URLs, GraphQL replay, per-store stock, and an Akamai cookie fix.
- Author: Emilie Beher
- Tags: Web Scraping, How to scrape a website, PerimeterX, #dated-6aebb78856ce24000126922a

This guide shows how to scrape Home Depot product data (names, prices, availability, review scores) with your own Python code, without a paid scraping API. The obstacle is Akamai Bot Manager, which answers a plain `requests` call with a 403 before any HTML arrives.

Nearly every guide on page one for this query ends the same way: sign up for the author's scraping API and let it handle Akamai.

That gets you data, but you pay for usage, and when requests start failing you can't see why or fix it yourself.

The seven methods here all run on your own machine with [curl\_cffi](https://roundproxies.com/blog/curl%5Fcffi/) and Playwright. They are ordered from "no anti-bot at all" to "needs a warmed session".

Start with the first method and move down the list only when it stops returning data. The earlier methods are lighter to run and have fewer parts to break.

Akamai gets its own section near the end. That section covers the cookie handoff the later methods depend on: a real browser session obtains the cookies, and the lighter HTTP client reuses them.

## How do you scrape Home Depot?

To scrape Home Depot, list product URLs from its public XML sitemaps, then fetch each product with a Chrome TLS fingerprint from a US residential IP.

Read the JSON-LD block for basics, or replay the site's own federation-gateway GraphQL calls for specs, per-store prices, stock, and reviews. When Akamai blocks you, warm cookies in Playwright and reuse them.

| Method               | What you get                             | Akamai session needed | Breaks when                   |
| -------------------- | ---------------------------------------- | --------------------- | ----------------------------- |
| 1\. Sitemaps         | Every product URL and item ID            | No                    | Sitemap paths move            |
| 2\. JSON-LD          | Name, price, SKU, brand, rating          | Yes                   | Schema fields get dropped     |
| 3\. Apollo cache     | IDs and store-tied pricing from the HTML | Yes                   | Cache key names change        |
| 4\. GraphQL product  | Specs, UPC, images, pricing              | Yes                   | The captured query goes stale |
| 5\. GraphQL category | 24 products per call, paged              | Yes                   | The opname is renamed         |
| 6\. Store stock      | Price and inventory per store            | Yes                   | Fulfillment fields move       |
| 7\. Reviews          | Review text, ratings, dates              | Yes                   | Review variables change       |

## Prerequisites

- Python 3.10 or newer (curl\_cffi's minimum since v0.14)
- `pip install curl_cffi parsel playwright`, then `playwright install chromium`
- A US residential IP with sticky sessions for anything past Method 1
- Chrome DevTools, for capturing the GraphQL payloads in Methods 4 to 7

Every method shares one session and one fetch function. The fetch raises `Blocked` on Akamai's block page so the scraper stops instead of parsing an error page as a product.

```python
import os
from curl_cffi import requests as cffi

PROXY = os.environ.get("HD_PROXY")  # e.g. http://user:pass@host:port, sticky US residential
HEADERS = {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
}
session = cffi.Session(impersonate="chrome",  # Chrome TLS + HTTP/2 fingerprint
                       proxies={"https": PROXY, "http": PROXY} if PROXY else None)

class Blocked(Exception):
    """Akamai answered with its block page."""

def fetch(url: str) -> str:
    resp = session.get(url, headers=HEADERS, timeout=30)
    if resp.status_code == 403 or "errors.edgesuite.net" in resp.text[:5000]:
        raise Blocked(f"{resp.status_code} on {url}")
    resp.raise_for_status()
    return resp.text

```

Akamai's block page links to `errors.edgesuite.net` and shows a reference number, so the check reads the body as well as the status code.

`impersonate="chrome"` tracks the newest Chrome profile your curl\_cffi version ships. You'll pin a specific version once you pair it with a browser's User-Agent in the Akamai section.

## Method 1: Find every product URL in Home Depot's sitemaps

Home Depot's `robots.txt` points to `sitemap/main.xml`, and its `P/PIPs.xml` child indexes 96 product files (`PIP-0.xml` to `PIP-95.xml`), each listing `/p/` URLs with a `lastmod` date.

When I pulled those files on 4 November 2026, they loaded without any Akamai challenge. That makes the sitemaps the cheapest way to build a product list.

```python
import random
import re
import time

PIP_INDEX = "https://www.homedepot.com/sitemap/P/PIPs.xml"
LOC = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>")

def product_urls(max_files: int = 2):
    for child in LOC.findall(fetch(PIP_INDEX))[:max_files]:  # PIP-0.xml, PIP-1.xml, ...
        yield from LOC.findall(fetch(child))
        time.sleep(1)

def item_id(url: str) -> str:
    # https://www.homedepot.com/p/<slug>/<itemId>  ->  "<itemId>"
    return url.split("?")[0].rstrip("/").rsplit("/", 1)[-1]

```

The trailing number in a `/p/` URL is the item ID (the "Internet #" on the page). Every GraphQL call later in this guide keys on it, so store it as your primary key.

The same index lists `B/PLPs.xml` for category pages. Their URLs end in `/N-5yc1v...`, and that `N-` value is the `navParam` Method 5 needs.

## Method 2: Read product data from the JSON-LD block

Home Depot product pages ship a schema.org `Product` object in a `<script type="application/ld+json">` tag, rendered on the server, so you can read it without running JavaScript.

That block carries the name, brand, SKU, model number, price, and rating summary, which covers most price-monitoring jobs.

```python
import json
from parsel import Selector

def json_ld_product(html: str) -> dict:
    for raw in Selector(text=html).css('script[type="application/ld+json"]::text').getall():
        try:
            data = json.loads(raw)
        except json.JSONDecodeError:
            continue  # some pages ship more than one block, not all of it valid
        for node in data if isinstance(data, list) else [data]:
            if isinstance(node, dict) and node.get("@type") == "Product":
                return node
    return {}

```

The parser flattens the parts you'll store. Offers can arrive as an object or a list, so it handles both:

```python
def parse_ld(node: dict) -> dict:
    offers = node.get("offers") or {}
    offers = offers[0] if isinstance(offers, list) and offers else offers
    brand = node.get("brand") or {}
    rating = node.get("aggregateRating") or {}
    return {
        "name": node.get("name"),
        "brand": brand.get("name") if isinstance(brand, dict) else brand,
        "sku": node.get("sku"),
        "model": node.get("model") or node.get("mpn"),
        "gtin": node.get("gtin13") or node.get("gtin12"),
        "price": offers.get("price"),
        "availability": offers.get("availability"),
        "rating": rating.get("ratingValue"),
        "review_count": rating.get("reviewCount"),
    }

url = "https://www.homedepot.com/p/RIDGID-18V-SubCompact-Brushless-Cordless-6-1-2-in-Circular-Saw-Tool-Only-R8656B/315976374"
print(parse_ld(json_ld_product(fetch(url))))

```

The JSON-LD price belongs to whichever store the session is localized to, and a fresh session gets a default store. If price per location matters, skip ahead to Method 6.

## Method 3: Parse the Apollo cache Home Depot embeds in the page

Home Depot pages embed their GraphQL client cache as `window.__APOLLO_STATE__`, a flat JSON object holding every product the page rendered, carousels included.

Reach for it when JSON-LD is missing a field, or when a category page's HTML is all you have.

```python
import re

APOLLO = re.compile(r"__APOLLO_STATE__\s*=\s*")

def apollo_state(html: str) -> dict:
    m = APOLLO.search(html)
    if not m:
        return {}
    # raw_decode stops at the closing brace and ignores the JavaScript after it
    state, _ = json.JSONDecoder().raw_decode(html, m.end())
    return state

```

Apollo normalizes its cache, which trips up most first attempts. Nested objects become `{"__ref": "..."}` pointers, and fields with arguments get keys like `pricing({"storeId":"121"})` instead of `pricing`.

Two helpers absorb both quirks:

```python
def deref(state: dict, value):
    return state.get(value["__ref"], {}) if isinstance(value, dict) and "__ref" in value else value

def field(state: dict, obj: dict, name: str):
    if name in obj:
        return deref(state, obj[name])
    for key, value in obj.items():  # pricing({"storeId":"121"}) and friends
        if key.startswith(name + "("):
            return deref(state, value)
    return None

```

With those, pulling products out of the cache is a filter over its values:

```python
def apollo_products(state: dict) -> list:
    rows = []
    for obj in state.values():
        if not isinstance(obj, dict) or "itemId" not in obj:
            continue
        ids = field(state, obj, "identifiers") or {}
        price = field(state, obj, "pricing") or {}
        rows.append({
            "item_id": obj["itemId"],
            "title": ids.get("productLabel"),
            "brand": ids.get("brandName"),
            "model": ids.get("modelNumber"),
            "price": price.get("value"),
            "was_price": price.get("original"),
        })
    return rows

```

Dump one product object with `json.dumps(obj, indent=2)` before you trust the column names. The field names above match public write-ups of Home Depot's schema, and that schema drifts.

## Method 4: Replay the productClientOnlyProduct GraphQL call

Home Depot's product page fetches its full data by POSTing to `/federation-gateway/graphql?opname=productClientOnlyProduct`, and replaying that request gets you specs, UPC, images, and pricing as clean JSON.

Don't hand-write the query. Capture it from your own browser so the field list matches what the gateway accepts today:

1. Open any product page in Chrome and press F12.
2. In the Network tab, filter by `federation-gateway`, then reload.
3. Click the request with `opname=productClientOnlyProduct`, open Payload, and click "view source".
4. Save that JSON (it has `operationName`, `variables`, and `query`) as `queries/productClientOnlyProduct.json`.

The replay function loads a saved payload and overrides only the variables you name. It refuses names the capture doesn't contain, which catches renamed variables early:

```python
from pathlib import Path

GATEWAY = "https://www.homedepot.com/federation-gateway/graphql"

def graphql(opname: str, overrides: dict, referer: str) -> dict:
    payload = json.loads(Path("queries", f"{opname}.json").read_text())
    for key, value in overrides.items():
        if key not in payload["variables"]:
            raise KeyError(f"{key!r} not in captured variables for {opname}")
        payload["variables"][key] = value
    resp = session.post(f"{GATEWAY}?opname={opname}", json=payload, timeout=30, headers={
        "Origin": "https://www.homedepot.com", "Referer": referer,
        "x-experience-name": "general-merchandise"})  # copy the value your capture shows
    if resp.status_code == 403:
        raise Blocked(f"403 on {opname}")
    resp.raise_for_status()
    body = resp.json()
    if body.get("errors") and not body.get("data"):
        raise ValueError(body["errors"][0].get("message"))
    return body["data"]

```

Check the request headers in your capture as well as the payload. The browser sends `x-experience-name`, and the gateway can answer differently without it.

The product call itself is short:

```python
def get_product(item: str, **store) -> dict:
    data = graphql("productClientOnlyProduct", {"itemId": item, **store},
                   referer=f"https://www.homedepot.com/p/{item}")
    return data.get("product") or {}

product = get_product("315976374")
ids = product.get("identifiers") or {}
specs = {s.get("specName"): s.get("specValue")
         for group in product.get("specificationGroup") or []
         for s in group.get("specifications") or []}
print(ids.get("productLabel"), ids.get("upcGtin13"), len(specs), "specs")

```

The spec groups are the reason to bother with GraphQL. JSON-LD has no dimensions, voltage, or warranty fields, and the GraphQL response nests all of them under `specificationGroup`.

## Method 5: Page through category listings with searchModel

Category and search pages load their product grid through a `searchModel` GraphQL operation that takes a `navParam` (the `N-` value from a `/b/` URL), a store ID, and `startIndex` / `pageSize` variables for paging.

Capture it the same way as Method 4, from a category page, and save it as `queries/searchModel.json`. Some captures name the operation `psSearchModel` instead; use whatever yours says.

```python
NAV = re.compile(r"/N-([0-9A-Za-z]+)")

def browse(category_url: str, store_id: str, pages: int = 5, page_size: int = 24):
    nav = NAV.search(category_url).group(1)  # /b/Tools-Power-Tools-Drills/N-5yc1vZc2bk
    for page in range(pages):
        data = graphql("searchModel", {"navParam": nav, "storeId": store_id,
                                       "startIndex": page * page_size, "pageSize": page_size},
                       referer=category_url)
        model = data.get("searchModel") or {}
        products = model.get("products") or []
        yield from products
        total = (model.get("searchReport") or {}).get("totalProducts") or 0
        if not products or (page + 1) * page_size >= total:
            break
        time.sleep(random.uniform(2, 4))

```

Each product in the grid carries an `itemId`, so a category crawl doubles as a discovery step for Method 4\. Stop at `totalProducts` rather than looping until a page comes back empty.

Prefer category pages over keyword search. Home Depot's `robots.txt` disallows `/s/` search URLs, while plain `/b/` category pages are open to crawlers.

It also blocks `/b/Featured-Products` and any URL carrying `NCNI-5` or `Ntt-`, so strip those parameters from your category list.

## Method 6: Scrape Home Depot prices and stock for a specific store

Home Depot prices and inventory are per store, and the product GraphQL call returns that store's price and fulfillment block once you pass `storeId` and `zipCode` variables.

A store's ID is the number at the end of its store page URL under `homedepot.com/l/`. You can also pick a store on the site and copy `storeId` from any gateway request in DevTools.

Capture `productClientOnlyProduct` from a product page with a store already selected. Then both store variables exist in the saved payload, and the replay function will accept them.

```python
from datetime import datetime, timezone

def find_key(obj, key):
    if isinstance(obj, dict):
        for k, v in obj.items():
            if k == key:
                yield v
            yield from find_key(v, key)
    elif isinstance(obj, list):
        for item in obj:
            yield from find_key(item, key)

```

`find_key` searches by name at any depth, which survives Home Depot reshuffling the wrapper objects. The snapshot function loops stores and records one row per fulfillment option:

```python
def store_snapshot(item: str, stores: dict) -> list:
    rows, now = [], datetime.now(timezone.utc).isoformat()
    for store_id, zip_code in stores.items():
        product = get_product(item, storeId=store_id, zipCode=zip_code)
        price = (product.get("pricing") or {}).get("value")
        for options in find_key(product.get("fulfillment") or {}, "fulfillmentOptions"):
            for opt in options or []:
                inv = next(find_key(opt, "inventory"), None) or {}
                rows.append({"item_id": item, "store_id": store_id, "price": price,
                             "option": opt.get("type"), "in_stock": inv.get("isInStock"),
                             "quantity": inv.get("quantity"), "checked_at": now})
        time.sleep(random.uniform(2, 4))
    return rows

```

Pickup and delivery are separate options, each with its own inventory. A drill can be out of stock for pickup at one store and still ship, so keep the `option` column.

If your capture returns no `fulfillment` block, the page loaded it through a separate `fulfillment` operation. Capture that one too and call it with the same two variables.

Append these rows instead of overwriting them. A price history needs one row per check, and [our guide to storing scraped data](https://roundproxies.com/blog/store-scraped-data/) compares SQLite and JSON Lines for that.

## Method 7: Collect product reviews page by page

Home Depot loads reviews through a `reviews` GraphQL operation that takes the item ID plus a start index and page size, so paging means bumping the start index until a page comes back short.

Capture it by opening a product's reviews and clicking to page 2\. Save the payload as `queries/reviews.json` and check which variable names hold the offset and the page size.

```python
REVIEW_START, REVIEW_SIZE = "startIndex", "pagesize"  # rename to match your capture

def reviews(item: str, max_reviews: int = 300):
    captured = json.loads(Path("queries/reviews.json").read_text())["variables"]
    start, size = captured.get(REVIEW_START, 1), captured.get(REVIEW_SIZE, 10)
    fetched = 0
    while fetched < max_reviews:
        data = graphql("reviews", {"itemId": item, REVIEW_START: start},
                       referer=f"https://www.homedepot.com/p/{item}")
        page = next(find_key(data, "Results"), None) or next(find_key(data, "results"), [])
        yield from page
        fetched += len(page)
        if len(page) < size:
            break
        start += len(page)
        time.sleep(random.uniform(2, 4))

```

The code yields raw review objects on purpose. I wouldn't hard-code review field names from any guide, this one included; print one review, then map the fields you need (rating, title, text, date, verified flag).

Write them to JSON Lines as you go. Popular tools have thousands of reviews, and a crash at review 2,400 shouldn't cost you the first 2,399.

## How to scrape Home Depot past Akamai without a paid API

Home Depot sits behind Akamai Bot Manager, which scores each visitor at the edge on IP reputation, TLS fingerprint, and a JavaScript sensor that sets the `_abck` cookie.

Fail the score and you get a 403 "Access Denied" page with an Akamai reference number.

[Our Akamai bypass guide](https://roundproxies.com/blog/bypass-akamai/) covers those checks in general. On Home Depot, three things decide whether Methods 2 to 7 work.

### Check which vendor you're dealing with

At least one 2026 Home Depot scraping write-up blames HUMAN (PerimeterX). The cookies tell you which vendor answered, so look before you tune anything:

| Cookie         | Vendor             | What it does                                  |
| -------------- | ------------------ | --------------------------------------------- |
| \_abck         | Akamai Bot Manager | Holds the sensor validation state             |
| bm\_sz         | Akamai Bot Manager | Short-lived session cookie set on first visit |
| ak\_bmsc       | Akamai Bot Manager | Bot Manager session cookie                    |
| akavpau\_\*    | Akamai             | Visitor prioritization (a waiting room)       |
| \_px3, \_pxvid | HUMAN              | Would mean PerimeterX, not Akamai             |

Home Depot's responses set `_abck`, `bm_sz`, and an `akavpau_prod` waiting-room cookie, and its block pages come from `errors.edgesuite.net`, Akamai's error host. If you ever see `_px3` instead, the vendor changed and this section needs a different playbook.

### Warm the session in a real browser, then hand off the cookies

The pattern I'd start with is to let a real Chromium pass the sensor check once, then reuse its cookies and User-Agent in curl\_cffi for the fast GraphQL calls.

```python
from playwright.sync_api import sync_playwright

def warm_session(proxy: dict | None = None) -> tuple[list, str]:
    # proxy = {"server": "http://host:port", "username": "...", "password": "..."}
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False, proxy=proxy)  # headless is easier to flag
        page = browser.new_page(locale="en-US", timezone_id="America/New_York")
        page.goto("https://www.homedepot.com/", wait_until="domcontentloaded")
        page.mouse.move(420, 310)
        page.mouse.wheel(0, 1400)  # some interaction for the sensor script to report
        page.wait_for_timeout(6000)
        page.goto("https://www.homedepot.com/b/Tools/N-5yc1vZc1xy", wait_until="domcontentloaded")
        page.wait_for_timeout(4000)
        cookies, ua = page.context.cookies(), page.evaluate("navigator.userAgent")
        browser.close()
    return cookies, ua

```

On a Linux server, run it under `xvfb-run` so the headed browser has a display. The handoff copies each cookie with its domain, so curl\_cffi sends it back to the right host:

```python
def handoff(cookies: list, ua: str) -> None:
    for c in cookies:
        session.cookies.set(c["name"], c["value"], domain=c["domain"], path=c["path"])
    session.headers.update({"User-Agent": ua})  # must match the browser that earned the cookies

cookies, ua = warm_session()
handoff(cookies, ua)

```

Match the curl\_cffi fingerprint to that browser, too. Read the Chrome major version from `ua` and pin the closest target curl\_cffi offers, so the TLS handshake and the User-Agent tell the same story.

Re-warm when `Blocked` starts firing again. A wrapper that catches it once, calls `warm_session()`, and retries the request keeps long runs going without a timer.

### Keep the IP and the pace boring

Datacenter IPs fail Akamai's reputation check before the sensor matters, so use US residential addresses. Keep one sticky IP for the warm-up and every request that reuses its cookies.

A cookie earned on one IP and replayed from another is a mismatch Akamai can see. [Residential vs datacenter proxies](https://roundproxies.com/blog/residential-vs-datacenter-proxies/) explains why the ASN carries that much weight.

Roundproxies sells sticky US residential sessions; any provider that pins an IP for 10 minutes or more works the same way.

Pace each worker at one request every two to four seconds, which is what the `random.uniform(2, 4)` sleeps above do. Spread volume across more sessions rather than pushing one session faster.

### Fallback: capture Home Depot's GraphQL responses in Playwright

If the warm-up passes but your replayed GraphQL calls still fail, stop replaying and record. Let Playwright browse the pages and collect the gateway's responses as the browser receives them.

```python
from contextlib import suppress

def capture_gateway(urls: list) -> list:
    payloads = []
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        page = browser.new_page(locale="en-US")
        def on_response(resp):
            if "/federation-gateway/graphql" in resp.url and resp.ok:
                with suppress(Exception):  # a few gateway replies aren't JSON
                    payloads.append(resp.json())
        page.on("response", on_response)
        for url in urls:
            page.goto(url, wait_until="networkidle")
            page.wait_for_timeout(random.uniform(2000, 4000))
        browser.close()
    return payloads

```

Feed the payloads to `find_key` and the parsers above unchanged.

It's slower per product, so route only the URLs that fail the fast path here ([Playwright's network docs](https://playwright.dev/python/docs/network) and [our guide to intercepting network traffic](https://roundproxies.com/blog/intercept-network-playwright/) cover the event API).

### What none of these methods get you

Account-specific prices are out of reach on purpose, Pro Xtra pricing included, because they need a login and this guide doesn't touch accounts.

Items whose price shows only in the cart won't expose it on the product page either.

And there's no national price to scrape: every number is tied to a store, so record the store ID next to every price.

## Troubleshooting

### "Access Denied" (403) with an Akamai reference number

**Why:** Akamai rejected the request at the edge, usually because of a datacenter IP, a non-browser TLS fingerprint, or a missing `_abck` cookie.

**Fix:** Confirm `impersonate="chrome"` is set and the IP is residential, then run `warm_session()` and `handoff()` through that same IP.

### "Oops!! Something went wrong. Please refresh page."

**Why:** A Home Depot-branded error page came back with a 200, so `fetch()` didn't raise and the parsers returned empty dicts.

**Fix:** Treat an empty `json_ld_product()` result as a block, not a missing product. Re-warm the session and retry once before marking the URL as failed.

### GraphQL returns 200 with `"errors"` and `"data": null`

**Why:** The captured query references a field the gateway no longer serves, or you passed a variable the operation doesn't declare.

**Fix:** Re-capture the payload from DevTools on a live page. The `KeyError` in `graphql()` catches renamed variables before the request goes out.

### Prices don't match what you see in the browser

**Why:** Your browser has a store selected and your scraper uses the session's default store.

**Fix:** Pass `storeId` and `zipCode` on every call (Method 6) and save the store ID with each price.

## FAQ

### Is it legal to scrape Home Depot?

Scraping public product pages is lawful in many jurisdictions, but Home Depot's Terms of Use still govern how you use its site.

Stick to public catalog data, stay out of accounts and checkout, and keep request rates low. Get legal advice before any commercial use; this isn't legal advice.

### Does Home Depot have a public API for product data?

No public product API is open for anyone to sign up for and query. The website itself runs on an internal federation-gateway GraphQL endpoint, which Methods 4 to 7 replay. It's undocumented and changes without notice.

### Can you scrape Home Depot without a headless browser?

Yes, for most requests. Sitemaps, JSON-LD, the Apollo cache, and the GraphQL calls are all plain HTTP once your session carries valid Akamai cookies.

You need a real browser for the cookie warm-up, and as a fallback for URLs that keep failing.

### Do you need proxies to scrape Home Depot?

Yes, past a handful of pages. Akamai scores IP reputation first, so datacenter and VPN addresses tend to get a 403 before anything else is checked.

Use sticky US residential IPs and keep each warmed session on one address.

### How do I find a Home Depot store ID?

A store ID is the number at the end of the store's page URL under homedepot.com/l/. You can also pick a store on the site and copy the storeId value from any federation-gateway request in DevTools.

Pass it with the store's ZIP code to get that store's prices and stock.

### What does Home Depot's robots.txt allow?

Home Depot's robots.txt allows product pages under /p/ and category pages under /b/, and it disallows search pages under /s/, cart, checkout, and account paths.

It publishes its sitemap index at /sitemap/main.xml. robots.txt is a crawling convention, not a license, so read the Terms of Use too.

## Wrapping up

Start with Method 1 and a few hundred URLs from one PIP file. It's the only step that works without an Akamai session, and it tells you which item IDs to feed everything else.

Then capture `productClientOnlyProduct` in DevTools, run `warm_session()` once, and replay Method 4 for ten items before you scale anything.

The parts that rot are the captured payloads and the cookie names. When runs start failing, re-capture first and re-check [Home Depot's robots.txt](https://www.homedepot.com/robots.txt) and the [curl\_cffi releases](https://github.com/lexiforest/curl%5Fcffi) second.