Price Intelligence API

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.

5
Stores
500
Max / Call
3
Endpoints
PKR
Currency

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+json Product 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
ℹ️
Base URL: https://frnklnwrld-savenest-api.hf.space
All endpoints return JSON. No auth required from clients.
Getting Started

Quick Start

Search for prices or crawl a full store in under 2 minutes.

1

Search across all stores

Hit /search?q=milk — the API queries all 5 stores simultaneously and returns deduplicated, price-sorted results.

2

Crawl a specific store

Hit /crawl?store=alfatah.pk&mode=urls to enumerate all product URLs. Add mode=full to also scrape prices.

3

Paginate large stores

Use cursor=next_cursor from each response to fetch the next batch. Repeat until next_cursor is null.

cURL — Search

bash
curl "https://frnklnwrld-savenest-api.hf.space/search?q=milk&limit=10"

cURL — Crawl (URL mode)

bash
curl "https://frnklnwrld-savenest-api.hf.space/crawl?store=alfatah.pk&mode=urls&limit=50"

JavaScript

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

json
{
  "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
    }
  ]
}
Coverage

Supported Stores

Al-Fatahalfatah.pk
QnEqne.com.pk
Springs Storesprings.com.pk
Vmartvmart.pk
GrocerAppgrocerapp.pk

Platform Details

alfatah.pkShopifyUses /search/suggest.json API + /products/{handle} URLs. Full JSON-LD support.
qne.com.pkCustomWooCommerce-style. CSS fallback selectors configured. HTML search scraping.
springs.com.pkWooCommerceStandard WooCommerce product URLs (/product/slug). Sitemap-based enumeration.
vmart.pkCustomGeneric search URL template. CSS fallback for title/price/image extraction.
grocerapp.pkCustomCustom sitemap hints configured. Product URLs match /products/slug-id pattern.
💡
To add a new store, add an entry to the STORES list in app.py with base, platform, and optional product_link_patterns, sitemap_hints, and product_fallback CSS selectors.
API Reference

/crawl

Enumerate all product URLs for a store and optionally scrape full details. Supports pagination via cursor.

GET /crawl Full store enumeration

Query Parameters

ParamTypeDescription
storerequiredstringDomain key, e.g. "alfatah.pk". Must match a registered store.
limitoptionalintegerItems per call (1–500, default: 100).
cursoroptionalintegerResume offset. Pass next_cursor from previous response. Default: 0.
modeoptionalstring"urls" — enumerate only (fast). "full" — enumerate + scrape prices. Default: "full".
use_jsoptional0 | 1Override Playwright JS rendering for this request.

Response Fields

storestringStore domain key as provided.
countintegerNumber of items in this batch.
itemsarrayArray of product objects (same structure as /search offers). In urls mode: [{url: string}].
next_cursorint?Pass as cursor in next call to get next batch. null when all items fetched.
total_urlsintegerTotal product URLs found in store (before pagination).
enumerationstring"sitemap_or_bfs" or "none".
js_fallbackboolWhether Playwright JS rendering was active.

Paginating a full store

javascript
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
API Reference

/health

GET /health Status check

Simple liveness check. Returns immediately with no external calls.

json
{ "ok": true }
Concepts

Sitemap & BFS

How the API discovers every product URL in a store.

Discovery Order

1

robots.txt

Fetches /robots.txt and parses any Sitemap: directives.

2

Store-specific hints

Each store entry can declare sitemap_hints — custom sitemap URLs tested before defaults.

3

Default paths

Tries /sitemap.xml, /sitemap_index.xml, /sitemap-index.xml in order.

4

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.

ℹ️
Sitemap index files (containing child .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.
Concepts

Product Parsing

How price, title and image are extracted from each product page.

Extraction Order

1

JSON-LD structured data

Parses all <script type="application/ld+json"> blocks looking for @type: "Product". Handles nested offers, aggregateOffer, and priceSpecification.

2

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.

3

OG image fallback

If no product image found via CSS, checks <meta property="og:image"> and <link rel="image_src">.

4

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.

Concepts

Pagination

The /crawl endpoint is designed for paginated calls to avoid HuggingFace Spaces timeouts.

⚠️
HF Spaces timeout: Free tier Spaces have a 60s request timeout. Keep limit at 50–100 for mode=full (scraping) and up to 500 for mode=urls (enumeration only).

Pagination Flow

python
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")
Concepts

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=1 in your HF Space environment variables (enabled by default)
  • Per-request override: add use_js=1 or use_js=0 to any /crawl request
  • Daraz.pk is automatically detected and uses JS rendering when Playwright is enabled
⚠️
Performance: JS rendering adds ~3-5s per page. Use sparingly and only for stores that require it. Plain HTTP is always tried first; Playwright is only invoked as a fallback when the plain fetch returns no usable HTML.

Playwright install (for local dev)

bash
pip install playwright
playwright install firefox
Integration

Flutter Guide

Complete Dart service class and widget patterns for the SaveNest API.

Service Class

dart
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

dart
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'])),
    );
  }
)
Integration

Error Handling

400
Bad Request
Unknown store key in /crawl, or missing q param in /search. Check the detail field in the response.
422
Validation Error
Invalid parameter type or out-of-range value (e.g. limit=600). FastAPI returns detailed validation errors.
500
Server Error
Unexpected scraping error. Common causes: store changed HTML structure, Playwright unavailable, network timeout. Check HF Space logs.

Recommended retry pattern

javascript
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;
    }
  }
}
Upwork Proposal Materials

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.

Upwork Job Post — Flutter Developer
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

Upwork Job Post — Backend 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.

Technical Spec
=== 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 (*)
FastAPI Python Web Scraping Flutter Playwright BeautifulSoup JSON-LD Pakistan E-commerce Price Comparison HuggingFace