SaveNest Crawler
Real-time product price scraping across Pakistan's top grocery stores. Sitemap enumeration, BFS fallback, JSON-LD parsing, and optional Playwright JS rendering — all in one FastAPI backend.
What is this API?
The SaveNest Crawler API is a stateless scraping backend. It enumerates product URLs via sitemaps (Shopify / WooCommerce / generic), falls back to bounded BFS crawling when no sitemap is available, and scrapes product details using JSON-LD structured data with CSS selector fallbacks.
The API is designed to be consumed by Flutter apps, web dashboards, or Firestore pipelines. All state lives on the client — use cursor to paginate and persist results in your own database.
Architecture
- FastAPI — async Python backend on Hugging Face Spaces (free tier)
- Sitemap discovery — robots.txt parsing + store-specific hints + default paths
- BFS fallback — bounded breadth-first crawl (max 50 pages) when no sitemap exists
- JSON-LD first — parses
application/ld+jsonProduct schema; CSS selectors as fallback - Playwright (Firefox) — optional JS rendering for dynamic sites like Daraz
- In-memory TTL cache — 3-minute page cache per URL, per-request isolation for /search
- Rate limiter — per-host politeness gap (default 2s) to avoid bans
- CORS enabled — open to all origins, ready for any client
https://frnklnwrld-savenest-api.hf.spaceAll endpoints return JSON. No auth required from clients.
Quick Start
Search for prices or crawl a full store in under 2 minutes.
Search across all stores
Hit /search?q=milk — the API queries all 5 stores simultaneously and returns deduplicated, price-sorted results.
Crawl a specific store
Hit /crawl?store=alfatah.pk&mode=urls to enumerate all product URLs. Add mode=full to also scrape prices.
Paginate large stores
Use cursor=next_cursor from each response to fetch the next batch. Repeat until next_cursor is null.
cURL — Search
curl "https://frnklnwrld-savenest-api.hf.space/search?q=milk&limit=10"
cURL — Crawl (URL mode)
curl "https://frnklnwrld-savenest-api.hf.space/crawl?store=alfatah.pk&mode=urls&limit=50"
JavaScript
// Search across all stores
const res = await fetch(
'https://frnklnwrld-savenest-api.hf.space/search?q=olpers+milk&limit=20'
);
const data = await res.json();
data.offers.forEach(item => {
console.log(`${item.source}: ${item.title} — PKR ${item.price}`);
// Al-Fatah: Olpers Full Cream Milk 1L — PKR 285
});
Sample Search Response
{
"query": "milk",
"count": 4,
"offers": [
{
"source": "Al-Fatah",
"title": "Olpers Full Cream Milk 1L",
"price": 285.0,
"currency": "PKR",
"url": "https://alfatah.pk/products/olpers-milk-1l",
"image": "https://cdn.shopify.com/...",
"in_stock": true
},
{
"source": "Springs Store",
"title": "Nestle Milkpak UHT Milk 1L",
"price": 290.0,
"currency": "PKR",
"url": "https://springs.com.pk/product/...",
"image": null,
"in_stock": null
}
]
}
Supported Stores
Platform Details
/search/suggest.json API + /products/{handle} URLs. Full JSON-LD support./product/slug). Sitemap-based enumeration./products/slug-id pattern.STORES list in app.py with base, platform, and optional product_link_patterns, sitemap_hints, and product_fallback CSS selectors./search
Live product search across all registered stores simultaneously.
Query Parameters
| Param | Type | Description |
|---|---|---|
| qrequired | string | Product keyword. E.g. "milk", "bread", "rice 5kg". |
| limitoptional | integer | Max results to return (1–100, default: 40). |
How it works
For each store the API uses the most appropriate search strategy: Shopify stores use /search/suggest.json, WooCommerce uses /?s=query&post_type=product, and custom stores use their configured search_url_template. Product page URLs are collected, scraped in parallel (max 5 concurrent), and deduplicated by title+source keeping the cheapest price.
Response Fields
"Al-Fatah")."PKR"./crawl
Enumerate all product URLs for a store and optionally scrape full details. Supports pagination via cursor.
Query Parameters
| Param | Type | Description |
|---|---|---|
| storerequired | string | Domain key, e.g. "alfatah.pk". Must match a registered store. |
| limitoptional | integer | Items per call (1–500, default: 100). |
| cursoroptional | integer | Resume offset. Pass next_cursor from previous response. Default: 0. |
| modeoptional | string | "urls" — enumerate only (fast). "full" — enumerate + scrape prices. Default: "full". |
| use_jsoptional | 0 | 1 | Override Playwright JS rendering for this request. |
Response Fields
urls mode: [{url: string}].cursor in next call to get next batch. null when all items fetched."sitemap_or_bfs" or "none".Paginating a full store
let cursor = 0;
const allProducts = [];
while (cursor !== null) {
const res = await fetch(
`/crawl?store=alfatah.pk&mode=full&limit=100&cursor=${cursor}`
);
const data = await res.json();
allProducts.push(...data.items);
cursor = data.next_cursor; // null when done
console.log(`Fetched ${allProducts.length} / ${data.total_urls}`);
}
// Save allProducts to Firestore
/health
Simple liveness check. Returns immediately with no external calls.
{ "ok": true }
Sitemap & BFS
How the API discovers every product URL in a store.
Discovery Order
robots.txt
Fetches /robots.txt and parses any Sitemap: directives.
Store-specific hints
Each store entry can declare sitemap_hints — custom sitemap URLs tested before defaults.
Default paths
Tries /sitemap.xml, /sitemap_index.xml, /sitemap-index.xml in order.
BFS fallback
If no sitemap yields product URLs, a bounded BFS crawl (max 50 pages, max 8000 URLs) follows category/listing links to find products.
.xml URLs) are followed recursively up to 5000 nodes. All discovered URLs are filtered to the store's domain and matched against product_link_patterns.Product Parsing
How price, title and image are extracted from each product page.
Extraction Order
JSON-LD structured data
Parses all <script type="application/ld+json"> blocks looking for @type: "Product". Handles nested offers, aggregateOffer, and priceSpecification.
CSS selector fallback
If no JSON-LD Product found, uses platform-specific CSS selectors for title, price, and image. Custom stores can define their own product_fallback selectors.
OG image fallback
If no product image found via CSS, checks <meta property="og:image"> and <link rel="image_src">.
Service filter
Titles containing service keywords (installation, repair, cleaning, etc.) are discarded — only physical products are returned.
Price Cleaning
Raw price strings like "Rs. 1,285.00" or "PKR 285" are normalized using regex extraction of the first numeric value, stripping commas and currency symbols.
Pagination
The /crawl endpoint is designed for paginated calls to avoid HuggingFace Spaces timeouts.
limit at 50–100 for mode=full (scraping) and up to 500 for mode=urls (enumeration only).Pagination Flow
import requests, time
BASE = "https://frnklnwrld-savenest-api.hf.space"
cursor = 0
all_items = []
while cursor is not None:
res = requests.get(f"{BASE}/crawl", params={
"store": "alfatah.pk",
"mode": "full",
"limit": 50,
"cursor": cursor
})
data = res.json()
all_items.extend(data["items"])
cursor = data["next_cursor"]
print(f"Got {len(all_items)} / {data['total_urls']}")
time.sleep(1) # be polite
print(f"Done. Total: {len(all_items)} products")
JS Rendering
Optional Playwright (Firefox) rendering for JavaScript-heavy sites.
Some stores (particularly Daraz) render product data entirely via JavaScript — a plain HTTP fetch returns an empty shell. The API includes optional Playwright Firefox rendering to handle these cases.
How to enable
- Set
USE_PLAYWRIGHT=1in your HF Space environment variables (enabled by default) - Per-request override: add
use_js=1oruse_js=0to any/crawlrequest - Daraz.pk is automatically detected and uses JS rendering when Playwright is enabled
Playwright install (for local dev)
pip install playwright playwright install firefox
Flutter Guide
Complete Dart service class and widget patterns for the SaveNest API.
Service Class
import 'dart:convert';
import 'package:http/http.dart' as http;
class SaveNestService {
static const String base = 'https://frnklnwrld-savenest-api.hf.space';
/// Search for a product across all stores
Future<List<Map>> search(String query, {int limit = 20}) async {
final uri = Uri.parse('$base/search')
.replace(queryParameters: {'q': query, 'limit': '$limit'});
final res = await http.get(uri);
if (res.statusCode != 200) throw Exception('Search failed: ${res.statusCode}');
final data = jsonDecode(res.body);
return List<Map>.from(data['offers']);
}
/// Get product URLs from a store (paginated)
Future<Map> crawlUrls(String store, {int limit = 100, int cursor = 0}) async {
final uri = Uri.parse('$base/crawl').replace(queryParameters: {
'store': store, 'mode': 'urls',
'limit': '$limit', 'cursor': '$cursor'
});
final res = await http.get(uri);
if (res.statusCode != 200) throw Exception('Crawl failed');
return jsonDecode(res.body) as Map;
}
}
Price Comparison Widget
final service = SaveNestService();
final results = await service.search('olpers milk');
// Sort by price (already sorted, but just in case)
results.sort((a, b) => (a['price'] ?? 999999).compareTo(b['price'] ?? 999999));
// Render
ListView.builder(
itemCount: results.length,
itemBuilder: (ctx, i) {
final item = results[i];
return ListTile(
leading: item['image'] != null
? Image.network(item['image'], width: 48, errorBuilder: (_,__,___) => Icon(Icons.shopping_bag))
: Icon(Icons.shopping_bag),
title: Text(item['title']),
subtitle: Text(item['source']),
trailing: Text(
'PKR ${item['price']}',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green),
),
onTap: () => launchUrl(Uri.parse(item['url'])),
);
}
)
Error Handling
Unknown store key in
/crawl, or missing q param in /search. Check the detail field in the response.Invalid parameter type or out-of-range value (e.g.
limit=600). FastAPI returns detailed validation errors.Unexpected scraping error. Common causes: store changed HTML structure, Playwright unavailable, network timeout. Check HF Space logs.
Recommended retry pattern
async function safeSearch(query, retries = 2) {
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}&limit=20`);
if (res.ok) return await res.json();
if (res.status === 400) throw new Error('Bad query');
// 500 — retry
if (i < retries) await new Promise(r => setTimeout(r, 2000 * (i + 1)));
} catch(e) {
if (i === retries) throw e;
}
}
}
Proposal Kit
Ready-to-copy templates for hiring on Upwork — Flutter developers, backend engineers, and data engineers.
Flutter Developer — Price Comparison App
Complete job post for a Flutter dev to build the mobile UI.
Title: Flutter Developer — Grocery Price Comparison App (Pakistan) We're building SaveNest, a Flutter app that shows real-time grocery prices across Pakistan's top stores. The backend API is live and production-ready. We need an experienced Flutter developer to build the mobile UI. === LIVE BACKEND === API: https://frnklnwrld-savenest-api.hf.space Swagger: https://frnklnwrld-savenest-api.hf.space/docs Docs: https://frnklnwrld-savenest-api.hf.space/documentation === BACKEND CAPABILITIES === • GET /search?q=milk — search all 5 stores, returns price-sorted results • GET /crawl?store=alfatah.pk — enumerate + scrape entire store (paginated) • Stores: Al-Fatah, QnE, Springs, Vmart, GrocerApp • Returns: title, price (PKR), image URL, product URL, in_stock status === YOUR RESPONSIBILITIES === 1. Home screen: search bar + recent searches 2. Results screen: price comparison cards sorted cheapest first - Show: store name, product title, price, image, in-stock badge - "View Product" button opens product URL in browser 3. Store browser: crawl mode screen with infinite scroll (paginated cursor) 4. Price history chart (local storage, compare prices over time) 5. Loading/error states with retry UX 6. State management: Riverpod or Provider 7. Offline cache: last search results === API INTEGRATION NOTES === • /search returns immediately, ~5-15s depending on store response times • /crawl supports cursor pagination: pass next_cursor back as cursor param • next_cursor is null when all products fetched • Images may be null — handle gracefully with placeholder === REQUIREMENTS === • 3+ years Flutter experience • REST API integration (Dio or http package) • Experience with pagination / infinite scroll • Price/comparison app UI portfolio preferred Please share 2-3 examples of e-commerce or comparison apps you've built. Budget: [your budget] | Timeline: 2-3 weeks
Backend / Scraping Engineer
Title: Python/FastAPI Developer — Improve Grocery Scraping API
We have a working FastAPI web scraper for Pakistan grocery stores.
Core is functional. Need improvements and more store coverage.
=== CURRENT STACK ===
• FastAPI + Python 3.10, hosted on HuggingFace Spaces
• Live: https://frnklnwrld-savenest-api.hf.space
• Docs: https://frnklnwrld-savenest-api.hf.space/documentation
=== CURRENT FEATURES ===
• Sitemap enumeration (Shopify, WooCommerce, custom)
• BFS fallback crawler (bounded, 50-page cap)
• JSON-LD product parsing + CSS selector fallback
• Playwright (Firefox) for JS-heavy stores
• In-memory TTL cache with per-request isolation
• 5 stores: alfatah.pk, qne.com.pk, springs.com.pk, vmart.pk, grocerapp.pk
=== TASKS ===
1. Add 5 more Pakistani grocery stores (daraz.pk groceries, imtiaz.pk,
metro.pk, carrefour.pk, naheed.pk) with correct platform configs
2. Improve Daraz scraping — currently blocked, needs better JS/header handling
3. Add Redis caching (replace in-memory) for multi-worker deployment
4. Add /products/{store} endpoint to list cached products without re-scraping
5. Improve price parsing for edge cases (sale prices, per-kg pricing)
6. Add async Playwright support (currently sync fallback, blocks event loop)
7. Write pytest suite for parsing functions
=== REQUIREMENTS ===
• Strong Python async (asyncio, httpx)
• Web scraping experience (BeautifulSoup, Playwright, Selenium)
• Understanding of JSON-LD / schema.org product markup
• Experience scraping Pakistani/South Asian e-commerce sites preferred
GitHub or scraping portfolio required.
Budget: [your budget] | Timeline: 1-2 weeks
Technical Spec Sheet
Share with any developer you're interviewing.
=== SAVENEST CRAWLER API — TECHNICAL SPEC ===
LIVE
API: https://frnklnwrld-savenest-api.hf.space
Docs: https://frnklnwrld-savenest-api.hf.space/documentation
Swagger: https://frnklnwrld-savenest-api.hf.space/docs
ENDPOINTS
GET /search?q={query}&limit={n}
→ searches all stores, returns deduplicated price-sorted offers
GET /crawl?store={domain}&mode={urls|full}&limit={n}&cursor={n}
→ enumerates entire store, paginates via cursor
GET /health
→ {"ok": true}
RESPONSE STRUCTURE (offers array)
source: string — store display name
title: string — product name
price: float — numeric price
currency: string — "PKR"
url: string — product page URL
image: string? — image URL (may be null)
in_stock: bool? — availability (may be null)
CRAWL PAGINATION
Pass next_cursor from response as cursor in next call.
next_cursor is null when all products fetched.
SUPPORTED STORES
alfatah.pk → Shopify (suggest.json + JSON-LD)
qne.com.pk → Custom (HTML search + CSS fallback)
springs.com.pk → WooCommerce (sitemap + JSON-LD)
vmart.pk → Custom (search template + CSS)
grocerapp.pk → Custom (sitemap hints + CSS)
ENV VARS (HF Space Settings)
USE_PLAYWRIGHT=1 enable Firefox JS rendering
PER_HOST_GAP=2 politeness delay seconds
REQUEST_TIMEOUT=30 HTTP timeout
MAX_CONCURRENCY=5 parallel fetches
CRAWL_LIMIT_CAP=500 max items per /crawl call
TECH STACK
Python 3.10, FastAPI, httpx, BeautifulSoup4
rapidfuzz, playwright (optional), uvicorn
CORS: open (*)