Quick answer
Fashionphile's own /shop search page ships a JSON block with price and sku set to null — a placeholder, not the real listing data. The real, fully-resolved inventory lives one layer down, in the Algolia search index that actually powers Fashionphile's on-site search. The Fashionphile Handbag Listings Scraper queries that index directly and returns price, condition, SKU, images, and retail-savings percent per handbag, at $3.05 per 1,000 rows.
Why does the /shop page show null prices? 🕳️
Load fashionphile.com/shop, view source, and find the embedded product JSON. The fields are there — price, sku, compare_at_price — but they're null. Not missing, not malformed: explicitly null. If you parse that block expecting real numbers, you get a dataset full of empty prices and no error to tell you why.
That block isn't where Fashionphile's search actually gets its data from. The site's own search-as-you-type box calls Algolia — the hosted search service — directly from the browser, using a public, search-only application key that ships to every visitor. That call returns the real shopify_products index: full pricing, condition grade, SKU, images, category tags, description, and inventory count, all fully resolved. This Actor makes that same class of call server-side instead of relying on the page's own placeholder block.
What a search actually returns
{
"searchTerms": ["chanel", "hermes birkin"],
"maxItemsPerSearch": 5,
"proxyConfiguration": { "useApifyProxy": true }
}
from apify_client import ApifyClient
client = ApifyClient("APIFY_TOKEN")
run = client.actor("DevilScrapes/fashionphile-handbag-listings-scraper").call(
run_input={"searchTerms": ["chanel classic flap"], "maxItemsPerSearch": 100}
)
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
print(item["title"], item["price"], item["condition"])
And one resolved row:
{
"object_id": "shopify_products_7841234567890",
"sku": "FP-CHN-00042",
"title": "Chanel Classic Medium Double Flap Black Caviar",
"url": "https://www.fashionphile.com/products/chanel-classic-medium-double-flap-black-caviar-00042",
"price": 5095.0,
"compare_at_price": 6200.0,
"retail_savings_percent": 17.8,
"condition": "Excellent",
"images": ["https://cdn.shopify.com/s/files/1/fashionphile/00042-front.jpg"],
"categories": ["Handbags", "Chanel", "Flap Bags"],
"inventory_quantity": 1,
"search_term": "chanel",
"scraped_at": "2026-08-24T12:00:00.000Z"
}
retail_savings_percent and condition come from a nested meta.custom block on the hit, not a top-level field — the kind of detail that's easy to miss if you're mapping the response by hand, and easy to get silently wrong if you're not checking it against a live response.
What we handle 🛡️
-
Browser fingerprint rotation. Every request goes out through
curl-cffi, impersonating a real Chrome, Firefox, or Safari TLS handshake, rotated on every retry — not a bare Python client signature. -
Retries with backoff, on the right codes.
408 / 429 / 503and network errors trigger up to 5 attempts, 2 seconds doubling to a 30-second cap, honoringRetry-Afterwhen it's sent. Other 4xx responses are treated as final, not retried into a request loop. - Session rotation on pushback. A retry doesn't just wait — it also rotates to the next browser profile and, where available, a fresh proxy session, so a run doesn't keep hammering with the exact signature that got throttled.
- Per-search-term fault isolation. A malformed or missing field on one listing gets logged and skipped — it doesn't take the rest of that search term's results down with it. A search term that runs and genuinely matches nothing finishes as a clean zero-row success, not a failed run.
- Typed, validated rows. Every field is Pydantic-checked before it reaches your dataset — no float where a null belongs, no silently dropped keys.
Does this scrape each listing's detail page too?
No — and that's deliberate. The Algolia hit already carries every field this Actor emits (price, condition, SKU, images, description, inventory count), so there's no second per-item HTTP request. A 500-listing search is one search request plus pagination, not 500 follow-up fetches to individual product pages.
What people build with this 💡
- Price-arbitrage sourcing — scan brand and keyword queries for underpriced authenticated pieces before the general public finds them through Fashionphile's own storefront search.
- Authentication and resale comps — pull condition grade, SKU, and retail-savings percent to benchmark a specific bag's resale value against current live listings.
- Inventory monitoring — track how deep a brand or model's catalog runs on Fashionphile and at what price band, run over run.
- Luxury resale market research — build a keyword-level snapshot of supply, condition mix, and pricing across brands for a dataset or report.
Frequently asked questions
What does 1,000 rows cost?
$3.05 — a $0.05 run-start charge plus $0.003 per unique listing. No data, no charge beyond the run-start fee.
Do I need a Fashionphile account or API key?
No. This queries Fashionphile's own public, search-only index — the same one its on-site search box calls — no login required.
Why not just parse the /shop page's JSON block directly?
Because that block's price and sku fields are null placeholders on Fashionphile's own page, not real data. Reading it as-is gets you a dataset of empty prices.
What happens if a search term matches nothing?
That term finishes as a clean, zero-row success. The run only fails if every search term could not be reached at all — a genuine no-match isn't treated as a failure.
Does this filter to handbags only?
Not strictly — a broad brand query can return non-bag accessories from the wider catalog alongside handbags. Scope your searchTerms narrowly if you need a bags-only set.
Try it
Live on the Apify Store: Fashionphile Handbag Listings Scraper. Apify gives every new account $5 of free credit, no card required to try it.
Built by Devil Scrapes — we go looking for the real data behind the placeholder block, so your dataset doesn't ship full of nulls.
Top comments (0)