zepto-scraper 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Buğrahan Saka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include README.md LICENSE
2
+ recursive-include tests *.py *.html
3
+ recursive-include examples *.py
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: zepto-scraper
3
+ Version: 0.1.0
4
+ Summary: Scrape Zepto category listings (price, MRP, discount, stock) and product details (seller, shelf life, FSSAI licence) with Python.
5
+ Author: Buğrahan Saka
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bugrahansss/zepto-scraper
8
+ Project-URL: Source, https://github.com/bugrahansss/zepto-scraper
9
+ Project-URL: Issues, https://github.com/bugrahansss/zepto-scraper/issues
10
+ Project-URL: Scrape.do, https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=zepto-scraper
11
+ Keywords: zepto,web scraping,quick commerce,grocery,scraper,nextjs,india
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: requests>=2.25
21
+ Dynamic: license-file
22
+
23
+ # zepto-scraper
24
+
25
+ [![PyPI](https://img.shields.io/pypi/v/zepto-scraper)](https://pypi.org/project/zepto-scraper/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/zepto-scraper)](https://pypi.org/project/zepto-scraper/)
27
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
28
+
29
+ A Python scraper for [Zepto](https://www.zepto.com), India's quick-commerce grocery app. Two jobs:
30
+
31
+ - **Category listings:** start from one subcategory URL, walk its sibling subcategories and get every product card with price, MRP, discount, in-stock flag, **units in stock**, rating and the dark store it came from.
32
+ - **Product details:** turn product pages into a catalog sheet with seller entity, shelf life, FSSAI licence number, country of origin, category path, order limit and the full description.
33
+
34
+ Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
35
+
36
+ ```bash
37
+ pip install zepto-scraper
38
+ ```
39
+
40
+ ## Why a plain request fails
41
+
42
+ Zepto sits behind AWS WAF on CloudFront:
43
+
44
+ | Request | Status | Body |
45
+ | --- | --- | --- |
46
+ | `requests`, default user agent | 429 | 0 bytes |
47
+ | `requests`, Chrome user agent | 202, `x-amzn-waf-action: challenge` | 0 bytes |
48
+ | Through Scrape.do, no parameters | 200 | full page |
49
+
50
+ **Watch the 202.** It means "Accepted", so `raise_for_status()` treats it as success and a scraper that only checks for errors writes empty pages without a single failure. This package treats anything but a 200 as an error.
51
+
52
+ Pages are fetched through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper) API with a plain request: no `render`, no `super`, no `geoCode`, 1 credit per page. You need a Scrape.do API token; the free plan includes 1,000 credits a month.
53
+
54
+ ## Quick start
55
+
56
+ ```python
57
+ from zepto_scraper import ZeptoScraper
58
+
59
+ scraper = ZeptoScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
60
+
61
+ # Any subcategory URL; the scraper walks its siblings in the same category
62
+ rows = scraper.get_category(
63
+ "https://www.zepto.com/cn/munchies/chips-crisps/cid/d2c2a144-43cd-43e5-b308-92628fa68596/scid/df4f5100-c02f-4906-83b8-ddb744081a7a"
64
+ )
65
+ for row in rows[:3]:
66
+ print(row["name"], row["price_inr"], row["mrp_inr"], row["in_stock"], row["available_qty"])
67
+
68
+ product = scraper.get_product(rows[0]["url"])
69
+ print(product["seller"], product["shelf_life"], product["fssai_licence"])
70
+ ```
71
+
72
+ Start from a **subcategory** URL (`/cn/<category>/<subcategory>/cid/.../scid/...`). The category link on Zepto's homepage is not a listing.
73
+
74
+ ### Command line
75
+
76
+ ```bash
77
+ export SCRAPEDO_TOKEN=your_token
78
+
79
+ # every product in a category, CSV
80
+ zepto-scraper category "<subcategory-url>" -f csv -o zepto-category.csv
81
+
82
+ # catalog sheet for the first 10 products from that file
83
+ zepto-scraper product -i zepto-category.csv --limit 10 -f csv -o zepto-products.csv
84
+ ```
85
+
86
+ The two commands chain: `product` reads the `url` column of the listing CSV.
87
+
88
+ ### Output
89
+
90
+ Listing row (from the fictional test page in [`tests/fixtures`](tests/fixtures/subcategory_page.html)):
91
+
92
+ ```json
93
+ {
94
+ "variant_id": "44444444-4444-4444-8444-444444444440",
95
+ "name": "Example Chilli Chips",
96
+ "brand": "Examplo",
97
+ "pack_size": "1 pack (41 g)",
98
+ "price_inr": 19.0,
99
+ "mrp_inr": 20.0,
100
+ "discount_pct": 5,
101
+ "in_stock": true,
102
+ "available_qty": 30,
103
+ "rating": 4.9,
104
+ "rating_count": 401062,
105
+ "subcategory": "Chips & Crisps",
106
+ "store_id": "33333333-3333-4333-8333-333333333333",
107
+ "url": "https://www.zepto.com/pn/example-snack-0/pvid/44444444-4444-4444-8444-444444444440"
108
+ }
109
+ ```
110
+
111
+ Product sheet columns: `variant_id, name, brand, category, pack_size, mrp_inr, max_per_order, rating, rating_count, shelf_life, country_of_origin, seller, fssai_licence, image_count, description, url`.
112
+
113
+ ## How it works
114
+
115
+ **The Next.js stream.** Zepto runs on the Next.js App Router. A subcategory page makes no separate product API call; every product card is streamed into the HTML as `self.__next_f.push([1, "..."])` chunks. Each chunk is a JavaScript string literal, and JSON strings use the same escaping, so `json.loads('"' + chunk + '"')` decodes it. Joined, the chunks are one text with one JSON object per product card.
116
+
117
+ **Out-of-stock cards.** Card keys are sorted alphabetically. In-stock cards start with `availableQuantity`; out-of-stock cards drop that key and start with `baseProductId`. A parser that anchors only on `availableQuantity` silently skips every out-of-stock product. This one anchors on both, reads each object with `json.JSONDecoder.raw_decode`, and dedupes on the variant id.
118
+
119
+ **Prices are in paise.** `"discountedSellingPrice": 1900` is ₹19. The scraper divides by 100.
120
+
121
+ **Siblings.** Every subcategory page links its siblings, all sharing the category `cid`. The same subcategory can appear under a second category slug that renders no products, so only paths under the start URL's own slug are kept.
122
+
123
+ **Product pages.** The catalog fields come from the schema.org `Product` JSON-LD block (Zepto writes `id` before `type` on the script tag, so the regex does not depend on attribute order). MRP and the order limit come from the stream's `productVariant` object for the exact variant in the URL.
124
+
125
+ ## Limits
126
+
127
+ - **32 products per subcategory.** Zepto's web pages stop at 32 cards (`endOfPage: true`) and ignore `?page=2`. For wider coverage, walk more subcategories and categories.
128
+ - **One dark store.** Each price and stock count belongs to one dark store. With no location, Zepto serves its default store (east Bengaluru); the `store_id` column records which one. Changing the location through Scrape.do did not move the store in our tests.
129
+ - **Use the listing for price and stock.** Product pages cap the stock count and can lag the listing, so `get_product()` leaves price and stock out on purpose.
130
+ - **No search.** Search results need a browser-computed `request-signature` header, and robots.txt disallows `/search`. Walk categories instead.
131
+ - **Errors.** Non-200 responses (including the 202 challenge) raise `ScrapeError` with the status code. Server errors and 429s are retried twice; client errors are not.
132
+
133
+ ## Responsible use
134
+
135
+ The scraper reads category (`/cn/`) and product (`/pn/`) pages, which Zepto's robots.txt allows; it does not touch `/search`, `/api/`, cart or account paths. Product data is not personal data. Read Zepto's terms for your use case and keep request rates modest. This is not legal advice.
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ git clone https://github.com/bugrahansss/zepto-scraper
141
+ cd zepto-scraper
142
+ pip install -e .
143
+ python -m unittest discover -s tests -v
144
+ ```
145
+
146
+ The tests run offline against fictional fixture pages.
147
+
148
+ ## License
149
+
150
+ MIT
151
+
152
+ ---
153
+
154
+ Built and tested with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper), October 2026.
@@ -0,0 +1,132 @@
1
+ # zepto-scraper
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/zepto-scraper)](https://pypi.org/project/zepto-scraper/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/zepto-scraper)](https://pypi.org/project/zepto-scraper/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ A Python scraper for [Zepto](https://www.zepto.com), India's quick-commerce grocery app. Two jobs:
8
+
9
+ - **Category listings:** start from one subcategory URL, walk its sibling subcategories and get every product card with price, MRP, discount, in-stock flag, **units in stock**, rating and the dark store it came from.
10
+ - **Product details:** turn product pages into a catalog sheet with seller entity, shelf life, FSSAI licence number, country of origin, category path, order limit and the full description.
11
+
12
+ Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
13
+
14
+ ```bash
15
+ pip install zepto-scraper
16
+ ```
17
+
18
+ ## Why a plain request fails
19
+
20
+ Zepto sits behind AWS WAF on CloudFront:
21
+
22
+ | Request | Status | Body |
23
+ | --- | --- | --- |
24
+ | `requests`, default user agent | 429 | 0 bytes |
25
+ | `requests`, Chrome user agent | 202, `x-amzn-waf-action: challenge` | 0 bytes |
26
+ | Through Scrape.do, no parameters | 200 | full page |
27
+
28
+ **Watch the 202.** It means "Accepted", so `raise_for_status()` treats it as success and a scraper that only checks for errors writes empty pages without a single failure. This package treats anything but a 200 as an error.
29
+
30
+ Pages are fetched through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper) API with a plain request: no `render`, no `super`, no `geoCode`, 1 credit per page. You need a Scrape.do API token; the free plan includes 1,000 credits a month.
31
+
32
+ ## Quick start
33
+
34
+ ```python
35
+ from zepto_scraper import ZeptoScraper
36
+
37
+ scraper = ZeptoScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
38
+
39
+ # Any subcategory URL; the scraper walks its siblings in the same category
40
+ rows = scraper.get_category(
41
+ "https://www.zepto.com/cn/munchies/chips-crisps/cid/d2c2a144-43cd-43e5-b308-92628fa68596/scid/df4f5100-c02f-4906-83b8-ddb744081a7a"
42
+ )
43
+ for row in rows[:3]:
44
+ print(row["name"], row["price_inr"], row["mrp_inr"], row["in_stock"], row["available_qty"])
45
+
46
+ product = scraper.get_product(rows[0]["url"])
47
+ print(product["seller"], product["shelf_life"], product["fssai_licence"])
48
+ ```
49
+
50
+ Start from a **subcategory** URL (`/cn/<category>/<subcategory>/cid/.../scid/...`). The category link on Zepto's homepage is not a listing.
51
+
52
+ ### Command line
53
+
54
+ ```bash
55
+ export SCRAPEDO_TOKEN=your_token
56
+
57
+ # every product in a category, CSV
58
+ zepto-scraper category "<subcategory-url>" -f csv -o zepto-category.csv
59
+
60
+ # catalog sheet for the first 10 products from that file
61
+ zepto-scraper product -i zepto-category.csv --limit 10 -f csv -o zepto-products.csv
62
+ ```
63
+
64
+ The two commands chain: `product` reads the `url` column of the listing CSV.
65
+
66
+ ### Output
67
+
68
+ Listing row (from the fictional test page in [`tests/fixtures`](tests/fixtures/subcategory_page.html)):
69
+
70
+ ```json
71
+ {
72
+ "variant_id": "44444444-4444-4444-8444-444444444440",
73
+ "name": "Example Chilli Chips",
74
+ "brand": "Examplo",
75
+ "pack_size": "1 pack (41 g)",
76
+ "price_inr": 19.0,
77
+ "mrp_inr": 20.0,
78
+ "discount_pct": 5,
79
+ "in_stock": true,
80
+ "available_qty": 30,
81
+ "rating": 4.9,
82
+ "rating_count": 401062,
83
+ "subcategory": "Chips & Crisps",
84
+ "store_id": "33333333-3333-4333-8333-333333333333",
85
+ "url": "https://www.zepto.com/pn/example-snack-0/pvid/44444444-4444-4444-8444-444444444440"
86
+ }
87
+ ```
88
+
89
+ Product sheet columns: `variant_id, name, brand, category, pack_size, mrp_inr, max_per_order, rating, rating_count, shelf_life, country_of_origin, seller, fssai_licence, image_count, description, url`.
90
+
91
+ ## How it works
92
+
93
+ **The Next.js stream.** Zepto runs on the Next.js App Router. A subcategory page makes no separate product API call; every product card is streamed into the HTML as `self.__next_f.push([1, "..."])` chunks. Each chunk is a JavaScript string literal, and JSON strings use the same escaping, so `json.loads('"' + chunk + '"')` decodes it. Joined, the chunks are one text with one JSON object per product card.
94
+
95
+ **Out-of-stock cards.** Card keys are sorted alphabetically. In-stock cards start with `availableQuantity`; out-of-stock cards drop that key and start with `baseProductId`. A parser that anchors only on `availableQuantity` silently skips every out-of-stock product. This one anchors on both, reads each object with `json.JSONDecoder.raw_decode`, and dedupes on the variant id.
96
+
97
+ **Prices are in paise.** `"discountedSellingPrice": 1900` is ₹19. The scraper divides by 100.
98
+
99
+ **Siblings.** Every subcategory page links its siblings, all sharing the category `cid`. The same subcategory can appear under a second category slug that renders no products, so only paths under the start URL's own slug are kept.
100
+
101
+ **Product pages.** The catalog fields come from the schema.org `Product` JSON-LD block (Zepto writes `id` before `type` on the script tag, so the regex does not depend on attribute order). MRP and the order limit come from the stream's `productVariant` object for the exact variant in the URL.
102
+
103
+ ## Limits
104
+
105
+ - **32 products per subcategory.** Zepto's web pages stop at 32 cards (`endOfPage: true`) and ignore `?page=2`. For wider coverage, walk more subcategories and categories.
106
+ - **One dark store.** Each price and stock count belongs to one dark store. With no location, Zepto serves its default store (east Bengaluru); the `store_id` column records which one. Changing the location through Scrape.do did not move the store in our tests.
107
+ - **Use the listing for price and stock.** Product pages cap the stock count and can lag the listing, so `get_product()` leaves price and stock out on purpose.
108
+ - **No search.** Search results need a browser-computed `request-signature` header, and robots.txt disallows `/search`. Walk categories instead.
109
+ - **Errors.** Non-200 responses (including the 202 challenge) raise `ScrapeError` with the status code. Server errors and 429s are retried twice; client errors are not.
110
+
111
+ ## Responsible use
112
+
113
+ The scraper reads category (`/cn/`) and product (`/pn/`) pages, which Zepto's robots.txt allows; it does not touch `/search`, `/api/`, cart or account paths. Product data is not personal data. Read Zepto's terms for your use case and keep request rates modest. This is not legal advice.
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ git clone https://github.com/bugrahansss/zepto-scraper
119
+ cd zepto-scraper
120
+ pip install -e .
121
+ python -m unittest discover -s tests -v
122
+ ```
123
+
124
+ The tests run offline against fictional fixture pages.
125
+
126
+ ## License
127
+
128
+ MIT
129
+
130
+ ---
131
+
132
+ Built and tested with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper), October 2026.
@@ -0,0 +1,44 @@
1
+ """Walk a Zepto category, then build a catalog sheet for its products.
2
+
3
+ Usage:
4
+ export SCRAPEDO_TOKEN=your_token
5
+ python category_to_products.py "<subcategory-url>" 10
6
+
7
+ Writes zepto-category.csv (price, MRP, discount, stock per product) and
8
+ zepto-products.csv (seller, shelf life, FSSAI licence for the first N products).
9
+ """
10
+
11
+ import csv
12
+ import sys
13
+
14
+ from zepto_scraper import LISTING_FIELDS, PRODUCT_FIELDS, ScrapeError, ZeptoScraper
15
+
16
+
17
+ def write(path, fields, rows):
18
+ with open(path, "w", newline="", encoding="utf-8") as handle:
19
+ writer = csv.DictWriter(handle, fieldnames=fields)
20
+ writer.writeheader()
21
+ writer.writerows(rows)
22
+
23
+
24
+ def main(start_url: str, max_products: int = 10) -> None:
25
+ scraper = ZeptoScraper() # reads SCRAPEDO_TOKEN
26
+ listing = scraper.get_category(start_url)
27
+ write("zepto-category.csv", LISTING_FIELDS, listing)
28
+ out_of_stock = sum(1 for row in listing if not row["in_stock"])
29
+ print(f"Saved {len(listing)} products ({out_of_stock} out of stock) to zepto-category.csv")
30
+
31
+ products = []
32
+ for row in [r for r in listing if r["url"]][:max_products]:
33
+ try:
34
+ products.append(scraper.get_product(row["url"]))
35
+ except (ScrapeError, ValueError) as exc:
36
+ print(f"skipped {row['url']}: {exc}")
37
+ continue
38
+ print(f"{products[-1]['name']} | {products[-1]['seller']} | shelf life {products[-1]['shelf_life']}")
39
+ write("zepto-products.csv", PRODUCT_FIELDS, products)
40
+ print(f"Saved {len(products)} products to zepto-products.csv")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 10)
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zepto-scraper"
7
+ version = "0.1.0"
8
+ description = "Scrape Zepto category listings (price, MRP, discount, stock) and product details (seller, shelf life, FSSAI licence) with Python."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Buğrahan Saka" }]
14
+ keywords = ["zepto", "web scraping", "quick commerce", "grocery", "scraper", "nextjs", "india"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
21
+ ]
22
+ dependencies = ["requests>=2.25"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/bugrahansss/zepto-scraper"
26
+ Source = "https://github.com/bugrahansss/zepto-scraper"
27
+ Issues = "https://github.com/bugrahansss/zepto-scraper/issues"
28
+ "Scrape.do" = "https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=zepto-scraper"
29
+
30
+ [project.scripts]
31
+ zepto-scraper = "zepto_scraper.cli:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ """Scrape Zepto category listings and product details with Python."""
2
+
3
+ from .client import ScrapeError, ZeptoScraper
4
+ from .parser import (
5
+ LISTING_FIELDS,
6
+ PRODUCT_FIELDS,
7
+ decode_rsc,
8
+ extract_items,
9
+ parse_listing,
10
+ parse_product,
11
+ subcategory_paths,
12
+ )
13
+
14
+ __all__ = [
15
+ "LISTING_FIELDS",
16
+ "PRODUCT_FIELDS",
17
+ "ScrapeError",
18
+ "ZeptoScraper",
19
+ "decode_rsc",
20
+ "extract_items",
21
+ "parse_listing",
22
+ "parse_product",
23
+ "subcategory_paths",
24
+ ]
25
+
26
+ __version__ = "0.1.0"
@@ -0,0 +1,104 @@
1
+ """Command line entry point: ``zepto-scraper category URL`` / ``zepto-scraper product URL ...``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ import sys
9
+ from typing import List, Optional
10
+
11
+ from . import __version__
12
+ from .client import TOKEN_ENV, ScrapeError, ZeptoScraper
13
+ from .parser import LISTING_FIELDS, PRODUCT_FIELDS
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="zepto-scraper",
19
+ description="Scrape Zepto category listings and product details into JSON or CSV.",
20
+ )
21
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
22
+ sub = parser.add_subparsers(dest="command", required=True)
23
+
24
+ cat = sub.add_parser("category", help="Every product in a category, starting from one subcategory URL")
25
+ cat.add_argument("url", help="A Zepto subcategory URL (.../cn/<category>/<subcategory>/cid/.../scid/...)")
26
+ cat.add_argument("--max-subcategories", type=int, default=20)
27
+
28
+ prod = sub.add_parser("product", help="Catalog sheet for product pages")
29
+ prod.add_argument("urls", nargs="*", help="Zepto product URLs (.../pn/<slug>/pvid/<id>)")
30
+ prod.add_argument("-i", "--input", help="Text file with one URL per line, or a CSV with a 'url' column")
31
+ prod.add_argument("--limit", type=int, default=0, help="Stop after this many products (0 = all)")
32
+
33
+ for p in (cat, prod):
34
+ p.add_argument("-t", "--token", help=f"Scrape.do API token (default: ${TOKEN_ENV})")
35
+ p.add_argument("-f", "--format", choices=("json", "csv"), default="json")
36
+ p.add_argument("-o", "--output", help="Write to this file instead of stdout")
37
+ return parser
38
+
39
+
40
+ def _read_urls(args: argparse.Namespace) -> List[str]:
41
+ urls = list(args.urls)
42
+ if args.input:
43
+ with open(args.input, encoding="utf-8", newline="") as handle:
44
+ if args.input.endswith(".csv"):
45
+ urls.extend(row["url"] for row in csv.DictReader(handle) if row.get("url"))
46
+ else:
47
+ urls.extend(line.strip() for line in handle if line.strip() and not line.startswith("#"))
48
+ return urls[: args.limit] if args.limit else urls
49
+
50
+
51
+ def _write(rows: List[dict], fields: List[str], fmt: str, stream) -> None:
52
+ if fmt == "csv":
53
+ writer = csv.DictWriter(stream, fieldnames=fields)
54
+ writer.writeheader()
55
+ writer.writerows(rows)
56
+ else:
57
+ json.dump(rows, stream, indent=2, ensure_ascii=False)
58
+ stream.write("\n")
59
+
60
+
61
+ def main(argv: Optional[List[str]] = None) -> int:
62
+ args = build_parser().parse_args(argv)
63
+ try:
64
+ scraper = ZeptoScraper(token=args.token)
65
+ except ValueError as exc:
66
+ print(exc, file=sys.stderr)
67
+ return 2
68
+
69
+ failures = 0
70
+ if args.command == "category":
71
+ fields = LISTING_FIELDS
72
+ try:
73
+ rows = scraper.get_category(args.url, args.max_subcategories)
74
+ except (ScrapeError, ValueError) as exc:
75
+ print(f"fail {args.url}: {exc}", file=sys.stderr)
76
+ return 1
77
+ out_of_stock = sum(1 for r in rows if not r["in_stock"])
78
+ print(f"{len(rows)} products, {out_of_stock} out of stock", file=sys.stderr)
79
+ else:
80
+ fields = PRODUCT_FIELDS
81
+ urls = _read_urls(args)
82
+ if not urls:
83
+ print("No URLs given. Pass product URLs or --input FILE.", file=sys.stderr)
84
+ return 2
85
+ rows = []
86
+ for url in urls:
87
+ try:
88
+ rows.append(scraper.get_product(url))
89
+ print(f"ok {url}", file=sys.stderr)
90
+ except (ScrapeError, ValueError) as exc:
91
+ failures += 1
92
+ print(f"fail {url}: {exc}", file=sys.stderr)
93
+
94
+ if args.output:
95
+ with open(args.output, "w", encoding="utf-8", newline="" if args.format == "csv" else None) as handle:
96
+ _write(rows, fields, args.format, handle)
97
+ print(f"Saved {len(rows)} rows to {args.output}", file=sys.stderr)
98
+ else:
99
+ _write(rows, fields, args.format, sys.stdout)
100
+ return 1 if failures and not rows else 0
101
+
102
+
103
+ if __name__ == "__main__":
104
+ raise SystemExit(main())
@@ -0,0 +1,94 @@
1
+ """Fetch Zepto pages through the Scrape.do API.
2
+
3
+ Zepto sits behind AWS WAF on CloudFront. A plain ``requests`` call gets HTTP 429;
4
+ with a browser user agent it gets HTTP 202, an empty body and
5
+ ``x-amzn-waf-action: challenge``. 202 counts as success to
6
+ ``raise_for_status()``, so this client checks for ``status_code != 200``.
7
+
8
+ Through Scrape.do a plain request (no parameters, 1 credit) returns the full page.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import time
15
+ from typing import List, Optional
16
+
17
+ import requests
18
+
19
+ from .parser import ZEPTO, parse_listing, parse_product, subcategory_paths
20
+
21
+ API_URL = "https://api.scrape.do/"
22
+ TOKEN_ENV = "SCRAPEDO_TOKEN"
23
+
24
+
25
+ class ScrapeError(RuntimeError):
26
+ """Raised when a page cannot be fetched."""
27
+
28
+ def __init__(self, message: str, status_code: Optional[int] = None):
29
+ super().__init__(message)
30
+ self.status_code = status_code
31
+
32
+
33
+ class ZeptoScraper:
34
+ """Fetch and parse Zepto category and product pages.
35
+
36
+ >>> scraper = ZeptoScraper(token="YOUR_SCRAPEDO_TOKEN")
37
+ >>> rows = scraper.get_category("https://www.zepto.com/cn/munchies/chips-crisps/cid/.../scid/...")
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ token: Optional[str] = None,
43
+ timeout: float = 120.0,
44
+ retries: int = 2,
45
+ session: Optional[requests.Session] = None,
46
+ ):
47
+ self.token = token or os.environ.get(TOKEN_ENV, "")
48
+ if not self.token:
49
+ raise ValueError(f"A Scrape.do API token is required. Pass token=... or set {TOKEN_ENV}.")
50
+ self.timeout = timeout
51
+ self.retries = max(0, retries)
52
+ self.session = session or requests.Session()
53
+
54
+ def fetch(self, url: str) -> str:
55
+ """Return the raw HTML of ``url``. Anything but a 200 is an error."""
56
+ params = {"token": self.token, "url": url}
57
+ last_error: Optional[ScrapeError] = None
58
+ for attempt in range(self.retries + 1):
59
+ try:
60
+ response = self.session.get(API_URL, params=params, timeout=self.timeout)
61
+ except requests.RequestException as exc:
62
+ last_error = ScrapeError(f"Request failed: {exc}")
63
+ else:
64
+ if response.status_code == 200 and response.text:
65
+ return response.text
66
+ last_error = ScrapeError(
67
+ f"Request for {url} failed with status {response.status_code}",
68
+ status_code=response.status_code,
69
+ )
70
+ # Client errors (bad token, bad URL) will not fix themselves
71
+ if 400 <= response.status_code < 500 and response.status_code != 429:
72
+ break
73
+ if attempt < self.retries:
74
+ time.sleep(2 * (attempt + 1))
75
+ assert last_error is not None
76
+ raise last_error
77
+
78
+ def get_subcategory(self, url: str) -> List[dict]:
79
+ """Every product card on one subcategory page (at most 32 on Zepto's web pages)."""
80
+ return parse_listing(self.fetch(url))
81
+
82
+ def get_category(self, start_url: str, max_subcategories: int = 20) -> List[dict]:
83
+ """Walk the start subcategory and its siblings; one row per unique product."""
84
+ first = self.fetch(start_url)
85
+ rows = {}
86
+ for path in subcategory_paths(first, start_url)[:max_subcategories]:
87
+ html = first if path in start_url else self.fetch(ZEPTO + path)
88
+ for row in parse_listing(html):
89
+ rows.setdefault(row["variant_id"], row)
90
+ return list(rows.values())
91
+
92
+ def get_product(self, url: str) -> dict:
93
+ """Catalog sheet for one product page (seller, shelf life, FSSAI licence...)."""
94
+ return parse_product(self.fetch(url), url)
@@ -0,0 +1,182 @@
1
+ """Parse Zepto category and product pages.
2
+
3
+ Zepto runs on the Next.js App Router. Every product card on a subcategory page
4
+ is streamed into the HTML as React Server Components payload, a series of
5
+ ``self.__next_f.push([1, "..."])`` script chunks. Product pages carry the same
6
+ stream plus a schema.org ``Product`` block in JSON-LD.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from typing import Dict, List, Optional
14
+
15
+ ZEPTO = "https://www.zepto.com"
16
+
17
+ PUSH_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)')
18
+ # Card keys are sorted alphabetically. In-stock cards start with availableQuantity;
19
+ # out-of-stock cards drop that key and start with baseProductId.
20
+ ITEM_START_RE = re.compile(r'\{"(?:availableQuantity|baseProductId)":')
21
+ PRODUCT_URL_RE = re.compile(r"https://www\.zepto\.com/pn/[a-z0-9-]+/pvid/[0-9a-f-]{36}")
22
+ STORE_RE = re.compile(r'"storeServiceableResponse":\{[^}]*?"storeId":"([0-9a-f-]{36})"')
23
+ LD_RE = re.compile(r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', re.S)
24
+
25
+ LISTING_FIELDS = [
26
+ "variant_id", "name", "brand", "pack_size", "price_inr", "mrp_inr", "discount_pct",
27
+ "in_stock", "available_qty", "rating", "rating_count", "subcategory", "store_id", "url",
28
+ ]
29
+ PRODUCT_FIELDS = [
30
+ "variant_id", "name", "brand", "category", "pack_size", "mrp_inr", "max_per_order",
31
+ "rating", "rating_count", "shelf_life", "country_of_origin", "seller", "fssai_licence",
32
+ "image_count", "description", "url",
33
+ ]
34
+
35
+ _decoder = json.JSONDecoder()
36
+
37
+
38
+ def decode_rsc(html: str) -> str:
39
+ """Join the Next.js stream chunks back into one text.
40
+
41
+ Each chunk is a JavaScript string literal; JSON strings use the same escaping,
42
+ so ``json.loads('"' + chunk + '"')`` gives back the original text.
43
+ """
44
+ return "".join(json.loads('"' + chunk + '"') for chunk in PUSH_RE.findall(html))
45
+
46
+
47
+ def rupees(paise) -> object:
48
+ """Zepto stores every price in paise: 1900 means Rs 19."""
49
+ return paise / 100 if isinstance(paise, (int, float)) and not isinstance(paise, bool) else ""
50
+
51
+
52
+ def extract_items(flat: str) -> List[dict]:
53
+ """Return every product card in a decoded stream, in-stock and out-of-stock."""
54
+ items: Dict[str, dict] = {}
55
+ for match in ITEM_START_RE.finditer(flat):
56
+ try:
57
+ obj, _ = _decoder.raw_decode(flat, match.start())
58
+ except json.JSONDecodeError:
59
+ continue
60
+ variant = obj.get("productVariant") or {}
61
+ # Look-alike objects elsewhere in the stream have no product block
62
+ if variant.get("id") and obj.get("product"):
63
+ items.setdefault(variant["id"], obj)
64
+ return list(items.values())
65
+
66
+
67
+ def product_urls(flat: str) -> Dict[str, str]:
68
+ """The page's own product links, keyed by variant id."""
69
+ return {link.rsplit("/", 1)[1]: link for link in PRODUCT_URL_RE.findall(flat)}
70
+
71
+
72
+ def store_id(flat: str) -> str:
73
+ match = STORE_RE.search(flat)
74
+ return match.group(1) if match else ""
75
+
76
+
77
+ def to_row(item: dict, store: str, urls: Dict[str, str]) -> dict:
78
+ product = item.get("product") or {}
79
+ variant = item.get("productVariant") or {}
80
+ rating = variant.get("ratingSummary") or {}
81
+ return {
82
+ "variant_id": variant.get("id", ""),
83
+ "name": product.get("name", ""),
84
+ "brand": product.get("brand", ""),
85
+ "pack_size": variant.get("formattedPacksize", ""),
86
+ "price_inr": rupees(item.get("discountedSellingPrice")),
87
+ "mrp_inr": rupees(item.get("mrp")),
88
+ "discount_pct": item.get("discountPercent", 0),
89
+ "in_stock": not item.get("outOfStock", False),
90
+ "available_qty": item.get("availableQuantity", 0),
91
+ "rating": rating.get("averageRating", ""),
92
+ "rating_count": rating.get("totalRatings", ""),
93
+ "subcategory": item.get("primarySubcategoryName", ""),
94
+ "store_id": store,
95
+ "url": urls.get(variant.get("id", ""), ""),
96
+ }
97
+
98
+
99
+ def parse_listing(html: str) -> List[dict]:
100
+ """Every product card on one subcategory page, as flat rows."""
101
+ flat = decode_rsc(html)
102
+ store = store_id(flat)
103
+ urls = product_urls(flat)
104
+ return [to_row(item, store, urls) for item in extract_items(flat)]
105
+
106
+
107
+ def subcategory_paths(html: str, start_url: str) -> List[str]:
108
+ """The start subcategory plus its siblings, as site paths, deduped on scid.
109
+
110
+ The same subcategory can appear under a second category slug that renders no
111
+ products, so only paths under the start URL's own category slug are kept.
112
+ """
113
+ cid_match = re.search(r"/cid/([0-9a-f-]{36})", start_url)
114
+ if not cid_match or "/cn/" not in start_url:
115
+ raise ValueError("Start from a Zepto subcategory URL (.../cn/<category>/<subcategory>/cid/.../scid/...)")
116
+ cid = cid_match.group(1)
117
+ sub_re = re.compile(r"/cn/[a-z0-9-]+/[a-z0-9-]+/cid/" + cid + r"/scid/[0-9a-f-]{36}")
118
+ category_slug = start_url.split("/cn/")[1].split("/")[0]
119
+ start_path = "/cn/" + start_url.split("/cn/", 1)[1].split("?")[0]
120
+ found: Dict[str, str] = {}
121
+ for path in [start_path] + sub_re.findall(decode_rsc(html)):
122
+ if path.split("/")[2] == category_slug:
123
+ found.setdefault(path.rsplit("/", 1)[1], path)
124
+ return list(found.values())
125
+
126
+
127
+ def product_jsonld(html: str) -> dict:
128
+ """The schema.org Product block (the page has several JSON-LD blocks)."""
129
+ for block in LD_RE.findall(html):
130
+ try:
131
+ data = json.loads(block)
132
+ except json.JSONDecodeError:
133
+ continue
134
+ if isinstance(data, dict) and data.get("@type") == "Product":
135
+ return data
136
+ return {}
137
+
138
+
139
+ def variant_data(flat: str, variant_id: str) -> dict:
140
+ """productVariant carries the printed MRP and order limit for this pack size."""
141
+ for match in re.finditer(r'"productVariant":\{', flat):
142
+ try:
143
+ obj, _ = _decoder.raw_decode(flat, match.end() - 1)
144
+ except json.JSONDecodeError:
145
+ continue
146
+ if obj.get("id") == variant_id:
147
+ return obj
148
+ return {}
149
+
150
+
151
+ def parse_product(html: str, url: str) -> dict:
152
+ """Catalog sheet for one product page.
153
+
154
+ Price and stock are left out on purpose: the product page caps the stock
155
+ count and can lag the listing. Use the listing for those.
156
+ """
157
+ ld = product_jsonld(html)
158
+ if not ld:
159
+ raise ValueError("No Product JSON-LD on the page; it is probably not a Zepto product page")
160
+ variant_id = url.rstrip("/").rsplit("/", 1)[1]
161
+ variant = variant_data(decode_rsc(html), variant_id)
162
+ rating = ld.get("aggregateRating") or {}
163
+ props = {p.get("name"): p.get("value") for p in ld.get("additionalProperty", []) if isinstance(p, dict)}
164
+ images = ld.get("image") or []
165
+ return {
166
+ "variant_id": variant_id,
167
+ "name": ld.get("name", ""),
168
+ "brand": (ld.get("brand") or {}).get("name", "") if isinstance(ld.get("brand"), dict) else ld.get("brand", ""),
169
+ "category": ld.get("category", ""),
170
+ "pack_size": ld.get("size", ""),
171
+ "mrp_inr": rupees(variant.get("mrp")),
172
+ "max_per_order": variant.get("maxAllowedQuantity", ""),
173
+ "rating": rating.get("ratingValue", ""),
174
+ "rating_count": rating.get("ratingCount", ""),
175
+ "shelf_life": props.get("Shelf life", ""),
176
+ "country_of_origin": props.get("Country of origin", ""),
177
+ "seller": props.get("Seller", ""),
178
+ "fssai_licence": props.get("FSSAI licence number", ""),
179
+ "image_count": len(images) if isinstance(images, list) else 1,
180
+ "description": ld.get("description", ""),
181
+ "url": url,
182
+ }
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: zepto-scraper
3
+ Version: 0.1.0
4
+ Summary: Scrape Zepto category listings (price, MRP, discount, stock) and product details (seller, shelf life, FSSAI licence) with Python.
5
+ Author: Buğrahan Saka
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bugrahansss/zepto-scraper
8
+ Project-URL: Source, https://github.com/bugrahansss/zepto-scraper
9
+ Project-URL: Issues, https://github.com/bugrahansss/zepto-scraper/issues
10
+ Project-URL: Scrape.do, https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=zepto-scraper
11
+ Keywords: zepto,web scraping,quick commerce,grocery,scraper,nextjs,india
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: requests>=2.25
21
+ Dynamic: license-file
22
+
23
+ # zepto-scraper
24
+
25
+ [![PyPI](https://img.shields.io/pypi/v/zepto-scraper)](https://pypi.org/project/zepto-scraper/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/zepto-scraper)](https://pypi.org/project/zepto-scraper/)
27
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
28
+
29
+ A Python scraper for [Zepto](https://www.zepto.com), India's quick-commerce grocery app. Two jobs:
30
+
31
+ - **Category listings:** start from one subcategory URL, walk its sibling subcategories and get every product card with price, MRP, discount, in-stock flag, **units in stock**, rating and the dark store it came from.
32
+ - **Product details:** turn product pages into a catalog sheet with seller entity, shelf life, FSSAI licence number, country of origin, category path, order limit and the full description.
33
+
34
+ Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
35
+
36
+ ```bash
37
+ pip install zepto-scraper
38
+ ```
39
+
40
+ ## Why a plain request fails
41
+
42
+ Zepto sits behind AWS WAF on CloudFront:
43
+
44
+ | Request | Status | Body |
45
+ | --- | --- | --- |
46
+ | `requests`, default user agent | 429 | 0 bytes |
47
+ | `requests`, Chrome user agent | 202, `x-amzn-waf-action: challenge` | 0 bytes |
48
+ | Through Scrape.do, no parameters | 200 | full page |
49
+
50
+ **Watch the 202.** It means "Accepted", so `raise_for_status()` treats it as success and a scraper that only checks for errors writes empty pages without a single failure. This package treats anything but a 200 as an error.
51
+
52
+ Pages are fetched through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper) API with a plain request: no `render`, no `super`, no `geoCode`, 1 credit per page. You need a Scrape.do API token; the free plan includes 1,000 credits a month.
53
+
54
+ ## Quick start
55
+
56
+ ```python
57
+ from zepto_scraper import ZeptoScraper
58
+
59
+ scraper = ZeptoScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
60
+
61
+ # Any subcategory URL; the scraper walks its siblings in the same category
62
+ rows = scraper.get_category(
63
+ "https://www.zepto.com/cn/munchies/chips-crisps/cid/d2c2a144-43cd-43e5-b308-92628fa68596/scid/df4f5100-c02f-4906-83b8-ddb744081a7a"
64
+ )
65
+ for row in rows[:3]:
66
+ print(row["name"], row["price_inr"], row["mrp_inr"], row["in_stock"], row["available_qty"])
67
+
68
+ product = scraper.get_product(rows[0]["url"])
69
+ print(product["seller"], product["shelf_life"], product["fssai_licence"])
70
+ ```
71
+
72
+ Start from a **subcategory** URL (`/cn/<category>/<subcategory>/cid/.../scid/...`). The category link on Zepto's homepage is not a listing.
73
+
74
+ ### Command line
75
+
76
+ ```bash
77
+ export SCRAPEDO_TOKEN=your_token
78
+
79
+ # every product in a category, CSV
80
+ zepto-scraper category "<subcategory-url>" -f csv -o zepto-category.csv
81
+
82
+ # catalog sheet for the first 10 products from that file
83
+ zepto-scraper product -i zepto-category.csv --limit 10 -f csv -o zepto-products.csv
84
+ ```
85
+
86
+ The two commands chain: `product` reads the `url` column of the listing CSV.
87
+
88
+ ### Output
89
+
90
+ Listing row (from the fictional test page in [`tests/fixtures`](tests/fixtures/subcategory_page.html)):
91
+
92
+ ```json
93
+ {
94
+ "variant_id": "44444444-4444-4444-8444-444444444440",
95
+ "name": "Example Chilli Chips",
96
+ "brand": "Examplo",
97
+ "pack_size": "1 pack (41 g)",
98
+ "price_inr": 19.0,
99
+ "mrp_inr": 20.0,
100
+ "discount_pct": 5,
101
+ "in_stock": true,
102
+ "available_qty": 30,
103
+ "rating": 4.9,
104
+ "rating_count": 401062,
105
+ "subcategory": "Chips & Crisps",
106
+ "store_id": "33333333-3333-4333-8333-333333333333",
107
+ "url": "https://www.zepto.com/pn/example-snack-0/pvid/44444444-4444-4444-8444-444444444440"
108
+ }
109
+ ```
110
+
111
+ Product sheet columns: `variant_id, name, brand, category, pack_size, mrp_inr, max_per_order, rating, rating_count, shelf_life, country_of_origin, seller, fssai_licence, image_count, description, url`.
112
+
113
+ ## How it works
114
+
115
+ **The Next.js stream.** Zepto runs on the Next.js App Router. A subcategory page makes no separate product API call; every product card is streamed into the HTML as `self.__next_f.push([1, "..."])` chunks. Each chunk is a JavaScript string literal, and JSON strings use the same escaping, so `json.loads('"' + chunk + '"')` decodes it. Joined, the chunks are one text with one JSON object per product card.
116
+
117
+ **Out-of-stock cards.** Card keys are sorted alphabetically. In-stock cards start with `availableQuantity`; out-of-stock cards drop that key and start with `baseProductId`. A parser that anchors only on `availableQuantity` silently skips every out-of-stock product. This one anchors on both, reads each object with `json.JSONDecoder.raw_decode`, and dedupes on the variant id.
118
+
119
+ **Prices are in paise.** `"discountedSellingPrice": 1900` is ₹19. The scraper divides by 100.
120
+
121
+ **Siblings.** Every subcategory page links its siblings, all sharing the category `cid`. The same subcategory can appear under a second category slug that renders no products, so only paths under the start URL's own slug are kept.
122
+
123
+ **Product pages.** The catalog fields come from the schema.org `Product` JSON-LD block (Zepto writes `id` before `type` on the script tag, so the regex does not depend on attribute order). MRP and the order limit come from the stream's `productVariant` object for the exact variant in the URL.
124
+
125
+ ## Limits
126
+
127
+ - **32 products per subcategory.** Zepto's web pages stop at 32 cards (`endOfPage: true`) and ignore `?page=2`. For wider coverage, walk more subcategories and categories.
128
+ - **One dark store.** Each price and stock count belongs to one dark store. With no location, Zepto serves its default store (east Bengaluru); the `store_id` column records which one. Changing the location through Scrape.do did not move the store in our tests.
129
+ - **Use the listing for price and stock.** Product pages cap the stock count and can lag the listing, so `get_product()` leaves price and stock out on purpose.
130
+ - **No search.** Search results need a browser-computed `request-signature` header, and robots.txt disallows `/search`. Walk categories instead.
131
+ - **Errors.** Non-200 responses (including the 202 challenge) raise `ScrapeError` with the status code. Server errors and 429s are retried twice; client errors are not.
132
+
133
+ ## Responsible use
134
+
135
+ The scraper reads category (`/cn/`) and product (`/pn/`) pages, which Zepto's robots.txt allows; it does not touch `/search`, `/api/`, cart or account paths. Product data is not personal data. Read Zepto's terms for your use case and keep request rates modest. This is not legal advice.
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ git clone https://github.com/bugrahansss/zepto-scraper
141
+ cd zepto-scraper
142
+ pip install -e .
143
+ python -m unittest discover -s tests -v
144
+ ```
145
+
146
+ The tests run offline against fictional fixture pages.
147
+
148
+ ## License
149
+
150
+ MIT
151
+
152
+ ---
153
+
154
+ Built and tested with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=zepto-scraper), October 2026.
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ examples/category_to_products.py
6
+ src/zepto_scraper/__init__.py
7
+ src/zepto_scraper/cli.py
8
+ src/zepto_scraper/client.py
9
+ src/zepto_scraper/parser.py
10
+ src/zepto_scraper.egg-info/PKG-INFO
11
+ src/zepto_scraper.egg-info/SOURCES.txt
12
+ src/zepto_scraper.egg-info/dependency_links.txt
13
+ src/zepto_scraper.egg-info/entry_points.txt
14
+ src/zepto_scraper.egg-info/requires.txt
15
+ src/zepto_scraper.egg-info/top_level.txt
16
+ tests/test_parser.py
17
+ tests/fixtures/product_page.html
18
+ tests/fixtures/subcategory_page.html
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ zepto-scraper = zepto_scraper.cli:main
@@ -0,0 +1 @@
1
+ requests>=2.25
@@ -0,0 +1 @@
1
+ zepto_scraper
@@ -0,0 +1 @@
1
+ <!doctype html><html><head><title>Example</title><script type="application/ld+json">{"@type":"Organization","name":"Example"}</script><script id="productSchema" type="application/ld+json">{"@context": "https://schema.org", "@type": "Product", "name": "Example Chilli Chips", "brand": {"@type": "Brand", "name": "Examplo"}, "category": "Munchies > Chips & Crisps", "size": "1 pack (41 g)", "description": "Fictional chips for tests. Ingredients: potato, oil, salt.", "image": ["https://example.com/1.jpg", "https://example.com/2.jpg", "https://example.com/3.jpg"], "aggregateRating": {"@type": "AggregateRating", "ratingValue": 4.9, "ratingCount": 401062}, "additionalProperty": [{"@type": "PropertyValue", "name": "Shelf life", "value": "180 days"}, {"@type": "PropertyValue", "name": "Country of origin", "value": "India"}, {"@type": "PropertyValue", "name": "Seller", "value": "Example Groceries Private Limited"}, {"@type": "PropertyValue", "name": "FSSAI licence number", "value": "10000000000001"}]}</script></head><body><script>self.__next_f.push([1,"1:{\"productVariant\":{\"id\":\"44444444-4444-4444-8444-444444444441\",\"mrp\":2500,\"maxAllowedQuantity\":5}}\n"])</script><script>self.__next_f.push([1,"2:{\"storeProduct\":{\"availableQuantity\":3,\"productVariant\":{\"formattedPacksize\":\"1 pack (41 g)\",\"id\":\"44444444-4444-4444-8444-444444444440\",\"maxAllowedQuantity\":30,\"mrp\":2000}}}\n"])</script></body></html>
@@ -0,0 +1 @@
1
+ <!doctype html><html><head><title>Example</title></head><body><script>self.__next_f.push([1,"1:[\"$\",\"div\",null,{\"children\":\"layout\"}]\n"])</script><script>self.__next_f.push([1,"2:{\"storeServiceableResponse\":{\"serviceable\":true,\"storeId\":\"33333333-3333-4333-8333-333333333333\"}}\n\"/cn/munchies/chips-crisps/cid/11111111-1111-4111-8111-111111111111/scid/22222222-2222-4222-8222-222222222220\" \"/cn/munchies/namkeens/cid/11111111-1111-4111-8111-111111111111/scid/22222222-2222-4222-8222-222222222221\" \"/cn/snacks/top-picks/cid/11111111-1111-4111-8111-111111111111/scid/22222222-2222-4222-8222-222222222222\" \"/cn/munchies/top-picks/cid/11111111-1111-4111-8111-111111111111/scid/22222222-2222-4222-8222-222222222222\"\n"])</script><script>self.__next_f.push([1,"3:[{\"availableQuantity\":30,\"baseProductId\":\"b-4440\",\"discountPercent\":5,\"discountedSellingPrice\":1900,\"mrp\":2000,\"primarySubcategoryName\":\"Chips & Crisps\",\"product\":{\"brand\":\"Examplo\",\"name\":\"Example Chilli Chips\"},\"productVariant\":{\"formattedPacksize\":\"1 pack (41 g)\",\"id\":\"44444444-4444-4444-8444-444444444440\",\"ratingSummary\":{\"averageRating\":4.9,\"totalRatings\":401062}}},"])</script><script>self.__next_f.push([1,"{\"availableQuantity\":115,\"baseProductId\":\"b-4441\",\"discountPercent\":16,\"discountedSellingPrice\":2100,\"mrp\":2500,\"primarySubcategoryName\":\"Chips & Crisps\",\"product\":{\"brand\":\"Examplo\",\"name\":\"Example Cream & Onion Chips\"},\"productVariant\":{\"formattedPacksize\":\"1 pack (50 g)\",\"id\":\"44444444-4444-4444-8444-444444444441\",\"ratingSummary\":{\"averageRating\":4.5,\"totalRatings\":1200}}},{\"baseProductId\":\"b-4442\",\"discountPercent\":10,\"discountedSellingPrice\":4500,\"mrp\":5000,\"outOfStock\":true,\"primarySubcategoryName\":\"Chips & Crisps\",\"product\":{\"brand\":\"Snackly\",\"name\":\"Example Salted Chips\"},\"productVariant\":{\"formattedPacksize\":\"1 pack (90 g)\",\"id\":\"44444444-4444-4444-8444-444444444442\",\"ratingSummary\":{\"averageRating\":4.5,\"totalRatings\":1200}}},{\"availableQuantity\":9,\"productVariant\":{\"id\":\"44444444-4444-4444-8444-444444444443\"}}]\n\"https://www.zepto.com/pn/example-snack-0/pvid/44444444-4444-4444-8444-444444444440\" \"https://www.zepto.com/pn/example-snack-1/pvid/44444444-4444-4444-8444-444444444441\" \"https://www.zepto.com/pn/example-snack-2/pvid/44444444-4444-4444-8444-444444444442\"\n4:{\"endOfPage\":true}\n"])</script></body></html>
@@ -0,0 +1,136 @@
1
+ import unittest
2
+ from pathlib import Path
3
+ from unittest import mock
4
+
5
+ from zepto_scraper import (
6
+ LISTING_FIELDS, PRODUCT_FIELDS, ScrapeError, ZeptoScraper,
7
+ decode_rsc, extract_items, parse_listing, parse_product, subcategory_paths,
8
+ )
9
+ from zepto_scraper import cli
10
+
11
+ FIX = Path(__file__).parent / "fixtures"
12
+ LISTING = (FIX / "subcategory_page.html").read_text(encoding="utf-8")
13
+ PRODUCT = (FIX / "product_page.html").read_text(encoding="utf-8")
14
+ CID = "11111111-1111-4111-8111-111111111111"
15
+ START = f"https://www.zepto.com/cn/munchies/chips-crisps/cid/{CID}/scid/22222222-2222-4222-8222-222222222220"
16
+ V0 = "44444444-4444-4444-8444-444444444440"
17
+ PRODUCT_URL = f"https://www.zepto.com/pn/example-snack-0/pvid/{V0}"
18
+
19
+
20
+ class FakeResponse:
21
+ def __init__(self, status, text=""):
22
+ self.status_code, self.text = status, text
23
+
24
+
25
+ class ListingTests(unittest.TestCase):
26
+ def test_decode_joins_chunks(self):
27
+ flat = decode_rsc(LISTING)
28
+ self.assertIn('"endOfPage":true', flat)
29
+ self.assertIn("storeServiceableResponse", flat)
30
+
31
+ def test_out_of_stock_cards_are_kept(self):
32
+ items = extract_items(decode_rsc(LISTING))
33
+ self.assertEqual(len(items), 3) # 2 in stock + 1 out of stock, look-alike skipped
34
+ self.assertEqual(sum(1 for i in items if i.get("outOfStock")), 1)
35
+
36
+ def test_prices_are_converted_from_paise(self):
37
+ row = next(r for r in parse_listing(LISTING) if r["variant_id"] == V0)
38
+ self.assertEqual((row["price_inr"], row["mrp_inr"], row["discount_pct"]), (19.0, 20.0, 5))
39
+
40
+ def test_row_fields(self):
41
+ row = next(r for r in parse_listing(LISTING) if r["variant_id"] == V0)
42
+ self.assertEqual(list(row), LISTING_FIELDS)
43
+ self.assertEqual(row["available_qty"], 30)
44
+ self.assertEqual((row["rating"], row["rating_count"]), (4.9, 401062))
45
+ self.assertEqual(row["store_id"], "33333333-3333-4333-8333-333333333333")
46
+ self.assertEqual(row["url"], PRODUCT_URL)
47
+
48
+ def test_out_of_stock_row(self):
49
+ row = next(r for r in parse_listing(LISTING) if r["name"] == "Example Salted Chips")
50
+ self.assertFalse(row["in_stock"])
51
+ self.assertEqual(row["available_qty"], 0)
52
+
53
+ def test_subcategories_dedupe_and_skip_second_slug(self):
54
+ paths = subcategory_paths(LISTING, START)
55
+ self.assertEqual(len(paths), 3)
56
+ self.assertTrue(all(p.startswith("/cn/munchies/") for p in paths))
57
+ self.assertEqual(paths[0], START.split("zepto.com")[1])
58
+
59
+ def test_subcategories_need_a_subcategory_url(self):
60
+ with self.assertRaises(ValueError):
61
+ subcategory_paths(LISTING, "https://www.zepto.com/pn/x/pvid/y")
62
+
63
+
64
+ class ProductTests(unittest.TestCase):
65
+ def test_product_sheet(self):
66
+ row = parse_product(PRODUCT, PRODUCT_URL)
67
+ self.assertEqual(list(row), PRODUCT_FIELDS)
68
+ self.assertEqual(row["name"], "Example Chilli Chips")
69
+ self.assertEqual(row["brand"], "Examplo")
70
+ self.assertEqual(row["seller"], "Example Groceries Private Limited")
71
+ self.assertEqual(row["shelf_life"], "180 days")
72
+ self.assertEqual(row["fssai_licence"], "10000000000001")
73
+ self.assertEqual(row["image_count"], 3)
74
+
75
+ def test_mrp_and_limit_come_from_the_matching_variant(self):
76
+ row = parse_product(PRODUCT, PRODUCT_URL)
77
+ self.assertEqual((row["mrp_inr"], row["max_per_order"]), (20.0, 30))
78
+
79
+ def test_jsonld_with_id_before_type_is_found(self):
80
+ self.assertIn('id="productSchema" type="application/ld+json"', PRODUCT)
81
+ self.assertEqual(parse_product(PRODUCT, PRODUCT_URL)["category"], "Munchies > Chips & Crisps")
82
+
83
+ def test_non_product_page_raises(self):
84
+ with self.assertRaises(ValueError):
85
+ parse_product(LISTING, PRODUCT_URL)
86
+
87
+
88
+ class ClientTests(unittest.TestCase):
89
+ def scraper(self, *responses):
90
+ session = mock.Mock()
91
+ session.get.side_effect = list(responses)
92
+ return ZeptoScraper(token="t", session=session, retries=1), session
93
+
94
+ def test_202_challenge_is_an_error(self):
95
+ s, _ = self.scraper(FakeResponse(202), FakeResponse(202))
96
+ with mock.patch("time.sleep"), self.assertRaises(ScrapeError) as ctx:
97
+ s.fetch(START)
98
+ self.assertEqual(ctx.exception.status_code, 202)
99
+
100
+ def test_client_error_is_not_retried(self):
101
+ s, session = self.scraper(FakeResponse(401))
102
+ with self.assertRaises(ScrapeError):
103
+ s.fetch(START)
104
+ self.assertEqual(session.get.call_count, 1)
105
+
106
+ def test_plain_request_no_extra_parameters(self):
107
+ s, session = self.scraper(FakeResponse(200, LISTING))
108
+ s.fetch(START)
109
+ self.assertEqual(session.get.call_args.kwargs["params"], {"token": "t", "url": START})
110
+
111
+ def test_category_walk_dedupes(self):
112
+ s, session = self.scraper(*[FakeResponse(200, LISTING)] * 3)
113
+ rows = s.get_category(START)
114
+ self.assertEqual(len(rows), 3)
115
+ self.assertEqual(session.get.call_count, 3) # start page reused, two siblings fetched
116
+
117
+ def test_token_required(self):
118
+ with mock.patch.dict("os.environ", {}, clear=True), self.assertRaises(ValueError):
119
+ ZeptoScraper()
120
+
121
+
122
+ class CliTests(unittest.TestCase):
123
+ def test_product_csv_input(self):
124
+ import tempfile, os, csv
125
+ with tempfile.TemporaryDirectory() as d:
126
+ inp, out = os.path.join(d, "in.csv"), os.path.join(d, "out.csv")
127
+ with open(inp, "w", newline="") as f:
128
+ w = csv.DictWriter(f, fieldnames=["url"]); w.writeheader(); w.writerow({"url": PRODUCT_URL})
129
+ with mock.patch.object(ZeptoScraper, "fetch", return_value=PRODUCT):
130
+ code = cli.main(["product", "-t", "x", "-i", inp, "-f", "csv", "-o", out])
131
+ self.assertEqual(code, 0)
132
+ self.assertIn("Example Groceries", open(out).read())
133
+
134
+
135
+ if __name__ == "__main__":
136
+ unittest.main()