swiggy-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 *.json
3
+ recursive-include examples *.py
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: swiggy-scraper
3
+ Version: 0.1.0
4
+ Summary: Scrape Swiggy dishes, restaurants and full menus (prices, variants, ratings, delivery time, FSSAI licence) with Python.
5
+ Author: Buğrahan Saka
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bugrahansss/swiggy-scraper
8
+ Project-URL: Source, https://github.com/bugrahansss/swiggy-scraper
9
+ Project-URL: Issues, https://github.com/bugrahansss/swiggy-scraper/issues
10
+ Project-URL: Scrape.do, https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=swiggy-scraper
11
+ Keywords: swiggy,web scraping,food delivery,restaurant menu,scraper,aws waf,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
+ # swiggy-scraper
24
+
25
+ [![PyPI](https://img.shields.io/pypi/v/swiggy-scraper)](https://pypi.org/project/swiggy-scraper/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/swiggy-scraper)](https://pypi.org/project/swiggy-scraper/)
27
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
28
+
29
+ A Python scraper for [Swiggy](https://www.swiggy.com), India's food delivery app. Three jobs:
30
+
31
+ - **Dish search:** search a dish ("biryani") and get every matching dish from the restaurants that deliver to your location: price, veg/non-veg, bestseller flag, rating, rating count, every variant price, and the restaurant with its delivery time. One request, ~50 restaurants.
32
+ - **Restaurant search and nearby list:** name, area, cuisines, rating, rating count, cost for two, delivery time, distance, open/serviceable, and whether the listing is a paid (promoted) placement.
33
+ - **Full menus:** every dish of a restaurant with price, variants, veg flag, stock and rating, plus the restaurant's **FSSAI licence** and full address.
34
+
35
+ Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
36
+
37
+ ```bash
38
+ pip install swiggy-scraper
39
+ ```
40
+
41
+ ## Why a plain request fails
42
+
43
+ Swiggy sits behind AWS WAF on CloudFront. A script gets no data:
44
+
45
+ | Request | Result |
46
+ | --- | --- |
47
+ | `requests` with a Chrome user agent | HTTP 202, empty body, `x-amzn-waf-action: challenge` |
48
+ | `requests` with its default user agent | HTTP 403, an error page |
49
+ | Through Scrape.do (`geoCode=in`) | 200 in ~3 s, the JSON, every try |
50
+
51
+ Requests go through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper) API from India. Each request costs 1 credit; the free plan includes 1,000 credits a month.
52
+
53
+ ## Quick start
54
+
55
+ ```python
56
+ from swiggy_scraper import SwiggyScraper
57
+
58
+ scraper = SwiggyScraper(token="YOUR_SCRAPEDO_TOKEN", city="mumbai") # or lat=..., lng=...
59
+
60
+ dishes = scraper.search_dishes("biryani") # 1 request
61
+ for d in sorted(dishes, key=lambda d: d["price_inr"])[:3]:
62
+ print(d["price_inr"], d["name"], d["restaurant_name"], d["delivery_time_min"])
63
+
64
+ menu = scraper.get_menu("https://www.swiggy.com/city/bangalore/starbucks-coffee-ashok-nagar-rest124178")
65
+ print(menu["restaurant"]["fssai_license"], len(menu["items"]))
66
+ ```
67
+
68
+ ### Command line
69
+
70
+ ```bash
71
+ export SCRAPEDO_TOKEN=your_token
72
+
73
+ # every biryani on Swiggy for a Mumbai address
74
+ swiggy-scraper dishes biryani --city mumbai -f csv -o swiggy-biryani.csv
75
+
76
+ # pizza places that deliver to central Bengaluru
77
+ swiggy-scraper restaurants pizza -f csv -o swiggy-pizza.csv
78
+
79
+ # restaurants on the home page for any address
80
+ swiggy-scraper nearby --lat 12.9352 --lng 77.6245 -f csv -o swiggy-nearby.csv
81
+
82
+ # full menus of those restaurants
83
+ swiggy-scraper menu -i swiggy-nearby.csv --limit 5 -f csv -o swiggy-menus.csv
84
+ ```
85
+
86
+ City presets: `bengaluru` (default), `chennai`, `delhi`, `gurugram`, `hyderabad`, `kolkata`, `mumbai`, `pune`.
87
+
88
+ ### Output
89
+
90
+ Dish row (from the fictional test data in [`tests/fixtures`](tests/fixtures/menu.json)):
91
+
92
+ ```json
93
+ {"item_id": "9001", "name": "Paneer Dum Biryani", "category": "Biryani", "price_inr": 249, "is_veg": true,
94
+ "in_stock": true, "bestseller": true, "rating": 4.4, "rating_count": 210,
95
+ "variant_prices_inr": "Half 249; Full 389", "restaurant_id": "5001", "restaurant_name": "Example Biryani House"}
96
+ ```
97
+
98
+ Restaurant columns: `restaurant_id, name, area, locality, cuisines, rating, rating_count, cost_for_two_inr, delivery_time_min, distance_km, serviceable, is_open, promoted, veg_only, url` (menus add `fssai_license` and `address`).
99
+
100
+ ## How it works
101
+
102
+ **The page is a shell.** A restaurant page (`/city/bangalore/…-rest124178`) is 78 KB of HTML with no menu in it. The browser fills it from JSON endpoints under `/dapi`, and this package calls those directly:
103
+
104
+ | Data | Endpoint |
105
+ | --- | --- |
106
+ | Dish / restaurant search | `/dapi/restaurants/search/v3?lat&lng&str=biryani&selectedPLTab=DISH` (or `RESTAURANT`) |
107
+ | Home page restaurants | `/dapi/restaurants/list/v5?lat&lng` |
108
+ | Full menu | `/dapi/menu/pl?page-type=REGULAR_MENU&complete-menu=true&lat&lng&restaurantId=…` |
109
+
110
+ Every response is a list of widget cards with an `@type`; the parsers keep the restaurant, dish and licence cards and skip banners, filters and SEO blocks.
111
+
112
+ **Money is in paise.** `"price": 42000` is Rs 420. Some dishes have no `price`, only `defaultPrice`. Variant prices come either as absolute prices (`variantsV2.pricingModels`) or as a surcharge on the base price (`variants`); both are turned into rupees.
113
+
114
+ **The menu repeats itself.** The "Recommended" section lists dishes that also appear in their own sections: Starbucks in Bengaluru has 168 dish cards and 148 dishes. Each dish is kept once.
115
+
116
+ **Location decides the restaurants.** `lat`/`lng` decide which restaurants deliver, how far they are and how long delivery takes. The same "biryani" search returned 50 restaurants in Bengaluru and 50 in Mumbai with none in common. A menu still loads from a far-away location, with `serviceable: false`.
117
+
118
+ **Some results are ads.** Search mixes in paid placements; they carry `promoted: true` so you can drop them or keep them.
119
+
120
+ ## Responsible use
121
+
122
+ The package calls the JSON endpoints Swiggy's own web pages use for search, the home page and menus. Swiggy's robots.txt allows these paths and disallows account, cart, checkout, order tracking and payment pages, which this package never touches. It collects restaurant and menu data, not personal data. Read Swiggy's terms for your use case and keep request rates modest.
123
+
124
+ ## License
125
+
126
+ MIT. Not affiliated with Swiggy.
127
+
128
+ ---
129
+
130
+ Built with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper). [Get 1000 free credits](https://dashboard.scrape.do/sign-up?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper).
@@ -0,0 +1,108 @@
1
+ # swiggy-scraper
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/swiggy-scraper)](https://pypi.org/project/swiggy-scraper/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/swiggy-scraper)](https://pypi.org/project/swiggy-scraper/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ A Python scraper for [Swiggy](https://www.swiggy.com), India's food delivery app. Three jobs:
8
+
9
+ - **Dish search:** search a dish ("biryani") and get every matching dish from the restaurants that deliver to your location: price, veg/non-veg, bestseller flag, rating, rating count, every variant price, and the restaurant with its delivery time. One request, ~50 restaurants.
10
+ - **Restaurant search and nearby list:** name, area, cuisines, rating, rating count, cost for two, delivery time, distance, open/serviceable, and whether the listing is a paid (promoted) placement.
11
+ - **Full menus:** every dish of a restaurant with price, variants, veg flag, stock and rating, plus the restaurant's **FSSAI licence** and full address.
12
+
13
+ Output as Python dicts, JSON or CSV. One dependency (`requests`), no browser.
14
+
15
+ ```bash
16
+ pip install swiggy-scraper
17
+ ```
18
+
19
+ ## Why a plain request fails
20
+
21
+ Swiggy sits behind AWS WAF on CloudFront. A script gets no data:
22
+
23
+ | Request | Result |
24
+ | --- | --- |
25
+ | `requests` with a Chrome user agent | HTTP 202, empty body, `x-amzn-waf-action: challenge` |
26
+ | `requests` with its default user agent | HTTP 403, an error page |
27
+ | Through Scrape.do (`geoCode=in`) | 200 in ~3 s, the JSON, every try |
28
+
29
+ Requests go through the [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper) API from India. Each request costs 1 credit; the free plan includes 1,000 credits a month.
30
+
31
+ ## Quick start
32
+
33
+ ```python
34
+ from swiggy_scraper import SwiggyScraper
35
+
36
+ scraper = SwiggyScraper(token="YOUR_SCRAPEDO_TOKEN", city="mumbai") # or lat=..., lng=...
37
+
38
+ dishes = scraper.search_dishes("biryani") # 1 request
39
+ for d in sorted(dishes, key=lambda d: d["price_inr"])[:3]:
40
+ print(d["price_inr"], d["name"], d["restaurant_name"], d["delivery_time_min"])
41
+
42
+ menu = scraper.get_menu("https://www.swiggy.com/city/bangalore/starbucks-coffee-ashok-nagar-rest124178")
43
+ print(menu["restaurant"]["fssai_license"], len(menu["items"]))
44
+ ```
45
+
46
+ ### Command line
47
+
48
+ ```bash
49
+ export SCRAPEDO_TOKEN=your_token
50
+
51
+ # every biryani on Swiggy for a Mumbai address
52
+ swiggy-scraper dishes biryani --city mumbai -f csv -o swiggy-biryani.csv
53
+
54
+ # pizza places that deliver to central Bengaluru
55
+ swiggy-scraper restaurants pizza -f csv -o swiggy-pizza.csv
56
+
57
+ # restaurants on the home page for any address
58
+ swiggy-scraper nearby --lat 12.9352 --lng 77.6245 -f csv -o swiggy-nearby.csv
59
+
60
+ # full menus of those restaurants
61
+ swiggy-scraper menu -i swiggy-nearby.csv --limit 5 -f csv -o swiggy-menus.csv
62
+ ```
63
+
64
+ City presets: `bengaluru` (default), `chennai`, `delhi`, `gurugram`, `hyderabad`, `kolkata`, `mumbai`, `pune`.
65
+
66
+ ### Output
67
+
68
+ Dish row (from the fictional test data in [`tests/fixtures`](tests/fixtures/menu.json)):
69
+
70
+ ```json
71
+ {"item_id": "9001", "name": "Paneer Dum Biryani", "category": "Biryani", "price_inr": 249, "is_veg": true,
72
+ "in_stock": true, "bestseller": true, "rating": 4.4, "rating_count": 210,
73
+ "variant_prices_inr": "Half 249; Full 389", "restaurant_id": "5001", "restaurant_name": "Example Biryani House"}
74
+ ```
75
+
76
+ Restaurant columns: `restaurant_id, name, area, locality, cuisines, rating, rating_count, cost_for_two_inr, delivery_time_min, distance_km, serviceable, is_open, promoted, veg_only, url` (menus add `fssai_license` and `address`).
77
+
78
+ ## How it works
79
+
80
+ **The page is a shell.** A restaurant page (`/city/bangalore/…-rest124178`) is 78 KB of HTML with no menu in it. The browser fills it from JSON endpoints under `/dapi`, and this package calls those directly:
81
+
82
+ | Data | Endpoint |
83
+ | --- | --- |
84
+ | Dish / restaurant search | `/dapi/restaurants/search/v3?lat&lng&str=biryani&selectedPLTab=DISH` (or `RESTAURANT`) |
85
+ | Home page restaurants | `/dapi/restaurants/list/v5?lat&lng` |
86
+ | Full menu | `/dapi/menu/pl?page-type=REGULAR_MENU&complete-menu=true&lat&lng&restaurantId=…` |
87
+
88
+ Every response is a list of widget cards with an `@type`; the parsers keep the restaurant, dish and licence cards and skip banners, filters and SEO blocks.
89
+
90
+ **Money is in paise.** `"price": 42000` is Rs 420. Some dishes have no `price`, only `defaultPrice`. Variant prices come either as absolute prices (`variantsV2.pricingModels`) or as a surcharge on the base price (`variants`); both are turned into rupees.
91
+
92
+ **The menu repeats itself.** The "Recommended" section lists dishes that also appear in their own sections: Starbucks in Bengaluru has 168 dish cards and 148 dishes. Each dish is kept once.
93
+
94
+ **Location decides the restaurants.** `lat`/`lng` decide which restaurants deliver, how far they are and how long delivery takes. The same "biryani" search returned 50 restaurants in Bengaluru and 50 in Mumbai with none in common. A menu still loads from a far-away location, with `serviceable: false`.
95
+
96
+ **Some results are ads.** Search mixes in paid placements; they carry `promoted: true` so you can drop them or keep them.
97
+
98
+ ## Responsible use
99
+
100
+ The package calls the JSON endpoints Swiggy's own web pages use for search, the home page and menus. Swiggy's robots.txt allows these paths and disallows account, cart, checkout, order tracking and payment pages, which this package never touches. It collects restaurant and menu data, not personal data. Read Swiggy's terms for your use case and keep request rates modest.
101
+
102
+ ## License
103
+
104
+ MIT. Not affiliated with Swiggy.
105
+
106
+ ---
107
+
108
+ Built with [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper). [Get 1000 free credits](https://dashboard.scrape.do/sign-up?utm_source=github&utm_medium=readme&utm_campaign=swiggy-scraper).
@@ -0,0 +1,19 @@
1
+ """Cheapest delivered biryani in two cities, from one search request each.
2
+
3
+ export SCRAPEDO_TOKEN=your_token
4
+ python examples/cheapest_dish.py biryani
5
+ """
6
+ import sys
7
+
8
+ from swiggy_scraper import SwiggyScraper
9
+
10
+ query = sys.argv[1] if len(sys.argv) > 1 else "biryani"
11
+ for city in ("bengaluru", "mumbai"):
12
+ scraper = SwiggyScraper(city=city)
13
+ # search also returns sides (raita, pappadam): keep dishes that carry the query in their name
14
+ dishes = [d for d in scraper.search_dishes(query) if d["in_stock"] and d["price_inr"] and query.lower() in d["name"].lower()]
15
+ restaurants = {d["restaurant_id"] for d in dishes}
16
+ cheapest = sorted(dishes, key=lambda d: d["price_inr"])[:3]
17
+ print(f"\n{city}: {len(dishes)} dishes from {len(restaurants)} restaurants")
18
+ for d in cheapest:
19
+ print(f" Rs {d['price_inr']:>6} {d['name']} ({d['restaurant_name']}, {d['delivery_time_min']} min)")
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "swiggy-scraper"
7
+ version = "0.1.0"
8
+ description = "Scrape Swiggy dishes, restaurants and full menus (prices, variants, ratings, delivery time, 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 = ["swiggy", "web scraping", "food delivery", "restaurant menu", "scraper", "aws waf", "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/swiggy-scraper"
26
+ Source = "https://github.com/bugrahansss/swiggy-scraper"
27
+ Issues = "https://github.com/bugrahansss/swiggy-scraper/issues"
28
+ "Scrape.do" = "https://scrape.do/?utm_source=pypi&utm_medium=package&utm_campaign=swiggy-scraper"
29
+
30
+ [project.scripts]
31
+ swiggy-scraper = "swiggy_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,36 @@
1
+ """Scrape Swiggy dishes, restaurants and full menus with Python, through the Scrape.do API."""
2
+
3
+ from .client import CITIES, ScrapeError, SwiggyScraper
4
+ from .parser import (
5
+ DISH_FIELDS,
6
+ RESTAURANT_FIELDS,
7
+ parse_count,
8
+ parse_item,
9
+ parse_listing,
10
+ parse_menu,
11
+ parse_restaurant,
12
+ parse_search_dishes,
13
+ parse_search_restaurants,
14
+ restaurant_id_from_url,
15
+ rupees,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "CITIES",
22
+ "DISH_FIELDS",
23
+ "RESTAURANT_FIELDS",
24
+ "ScrapeError",
25
+ "SwiggyScraper",
26
+ "parse_count",
27
+ "parse_item",
28
+ "parse_listing",
29
+ "parse_menu",
30
+ "parse_restaurant",
31
+ "parse_search_dishes",
32
+ "parse_search_restaurants",
33
+ "restaurant_id_from_url",
34
+ "rupees",
35
+ "__version__",
36
+ ]
@@ -0,0 +1,118 @@
1
+ """Command line entry point: ``swiggy-scraper dishes|restaurants|nearby|menu``."""
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 CITIES, TOKEN_ENV, ScrapeError, SwiggyScraper
13
+ from .parser import DISH_FIELDS, RESTAURANT_FIELDS
14
+
15
+ MENU_FIELDS = DISH_FIELDS
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="swiggy-scraper",
21
+ description="Scrape Swiggy dishes, restaurants and full menus (prices, ratings, delivery time) into JSON or CSV.",
22
+ )
23
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
24
+ sub = parser.add_subparsers(dest="command", required=True)
25
+
26
+ dishes = sub.add_parser("dishes", help="Search dishes: every matching dish with price and restaurant")
27
+ dishes.add_argument("query", help='What to search, e.g. "biryani"')
28
+ rest = sub.add_parser("restaurants", help="Search restaurants that deliver to the location")
29
+ rest.add_argument("query", help='What to search, e.g. "pizza"')
30
+ sub.add_parser("nearby", help="Restaurants on the Swiggy home page for the location")
31
+ menu = sub.add_parser("menu", help="Full menus: every dish with price, variants, veg, stock, rating")
32
+ menu.add_argument("urls", nargs="*", help="Swiggy restaurant URLs or ids")
33
+ menu.add_argument("-i", "--input", help="Text file with one URL per line, or a CSV with a 'url' column")
34
+ menu.add_argument("--limit", type=int, default=0, help="Stop after this many restaurants (0 = all)")
35
+
36
+ for p in (dishes, rest, sub.choices["nearby"], menu):
37
+ p.add_argument("-t", "--token", help=f"Scrape.do API token (default: ${TOKEN_ENV})")
38
+ p.add_argument("--city", choices=sorted(CITIES), help="Delivery location preset (default: bengaluru)")
39
+ p.add_argument("--lat", type=float, help="Delivery latitude (with --lng)")
40
+ p.add_argument("--lng", type=float, help="Delivery longitude (with --lat)")
41
+ p.add_argument("-f", "--format", choices=("json", "csv"), default="json")
42
+ p.add_argument("-o", "--output", help="Write to this file instead of stdout")
43
+ return parser
44
+
45
+
46
+ def _read_urls(args: argparse.Namespace) -> List[str]:
47
+ urls = list(args.urls)
48
+ if args.input:
49
+ with open(args.input, encoding="utf-8", newline="") as handle:
50
+ if args.input.endswith(".csv"):
51
+ urls.extend(row["url"] for row in csv.DictReader(handle) if row.get("url"))
52
+ else:
53
+ urls.extend(line.strip() for line in handle if line.strip() and not line.startswith("#"))
54
+ return urls[: args.limit] if args.limit else urls
55
+
56
+
57
+ def _write(rows: List[dict], fields: List[str], fmt: str, stream) -> None:
58
+ if fmt == "csv":
59
+ writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
60
+ writer.writeheader()
61
+ writer.writerows(rows)
62
+ else:
63
+ json.dump(rows, stream, indent=2, ensure_ascii=False)
64
+ stream.write("\n")
65
+
66
+
67
+ def main(argv: Optional[List[str]] = None) -> int:
68
+ args = build_parser().parse_args(argv)
69
+ if (args.lat is None) != (args.lng is None):
70
+ print("Pass both --lat and --lng.", file=sys.stderr)
71
+ return 2
72
+ try:
73
+ scraper = SwiggyScraper(token=args.token, lat=args.lat, lng=args.lng, city=args.city)
74
+ except ValueError as exc:
75
+ print(exc, file=sys.stderr)
76
+ return 2
77
+
78
+ failures = 0
79
+ rows: List[dict] = []
80
+ try:
81
+ if args.command == "dishes":
82
+ fields, rows = DISH_FIELDS + ["restaurant_rating", "delivery_time_min", "restaurant_promoted"], scraper.search_dishes(args.query)
83
+ elif args.command == "restaurants":
84
+ fields, rows = RESTAURANT_FIELDS, scraper.search_restaurants(args.query)
85
+ elif args.command == "nearby":
86
+ fields, rows = RESTAURANT_FIELDS, scraper.get_restaurants()
87
+ else:
88
+ fields = MENU_FIELDS
89
+ urls = _read_urls(args)
90
+ if not urls:
91
+ print("No URLs given. Pass restaurant URLs or --input FILE.", file=sys.stderr)
92
+ return 2
93
+ for url in urls:
94
+ try:
95
+ menu = scraper.get_menu(url)
96
+ except (ScrapeError, ValueError) as exc:
97
+ failures += 1
98
+ print(f"fail {url}: {exc}", file=sys.stderr)
99
+ continue
100
+ rows.extend(menu["items"])
101
+ r = menu["restaurant"]
102
+ print(f"ok {r['name']} ({r['area']}): {len(menu['items'])} dishes, FSSAI {r['fssai_license']}", file=sys.stderr)
103
+ except (ScrapeError, ValueError) as exc:
104
+ print(f"fail {exc}", file=sys.stderr)
105
+ return 1
106
+
107
+ print(f"{len(rows)} rows, {scraper.credits_used} credits", file=sys.stderr)
108
+ if args.output:
109
+ with open(args.output, "w", encoding="utf-8", newline="" if args.format == "csv" else None) as handle:
110
+ _write(rows, fields, args.format, handle)
111
+ print(f"Saved {len(rows)} rows to {args.output}", file=sys.stderr)
112
+ else:
113
+ _write(rows, fields, args.format, sys.stdout)
114
+ return 1 if failures and not rows else 0
115
+
116
+
117
+ if __name__ == "__main__":
118
+ raise SystemExit(main())
@@ -0,0 +1,164 @@
1
+ """Fetch Swiggy's JSON APIs through the Scrape.do API.
2
+
3
+ Swiggy sits behind AWS WAF. A script with a browser user agent gets HTTP 202
4
+ with an empty body and ``x-amzn-waf-action: challenge``; with the default
5
+ ``requests`` user agent it gets a 403 page. Through Scrape.do a plain request
6
+ from India (``geoCode=in``) returns the JSON, for 1 credit.
7
+
8
+ Everything depends on the delivery location (``lat``/``lng`` in the query):
9
+ which restaurants deliver, how far they are and how long delivery takes.
10
+ Without a location this package uses central Bengaluru.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import time
18
+ from typing import Any, Dict, List, Optional
19
+ from urllib.parse import urlencode
20
+
21
+ import requests
22
+
23
+ from .parser import (
24
+ SWIGGY, parse_listing, parse_menu, parse_search_dishes, parse_search_restaurants, restaurant_id_from_url,
25
+ )
26
+
27
+ API_URL = "https://api.scrape.do/"
28
+ TOKEN_ENV = "SCRAPEDO_TOKEN"
29
+
30
+ CITIES = {
31
+ "bengaluru": (12.9715987, 77.5945627),
32
+ "mumbai": (19.0760, 72.8777),
33
+ "delhi": (28.6315, 77.2167),
34
+ "gurugram": (28.4595, 77.0266),
35
+ "hyderabad": (17.3850, 78.4867),
36
+ "pune": (18.5204, 73.8567),
37
+ "chennai": (13.0827, 80.2707),
38
+ "kolkata": (22.5726, 88.3639),
39
+ }
40
+ DEFAULT_CITY = "bengaluru"
41
+
42
+
43
+ class ScrapeError(RuntimeError):
44
+ """A request that did not return usable data. ``from_target`` is True when the status came from Swiggy."""
45
+
46
+ def __init__(self, message: str, status_code: Optional[int] = None, from_target: bool = False) -> None:
47
+ super().__init__(message)
48
+ self.status_code = status_code
49
+ self.from_target = from_target
50
+
51
+
52
+ class SwiggyScraper:
53
+ """Swiggy search, restaurant lists and full menus, for one delivery location.
54
+
55
+ >>> scraper = SwiggyScraper(token="...", city="mumbai")
56
+ >>> dishes = scraper.search_dishes("biryani")
57
+ >>> menu = scraper.get_menu("https://www.swiggy.com/city/bangalore/starbucks-coffee-ashok-nagar-rest124178")
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ token: Optional[str] = None,
63
+ lat: Optional[float] = None,
64
+ lng: Optional[float] = None,
65
+ city: Optional[str] = None,
66
+ timeout: float = 60,
67
+ retries: int = 2,
68
+ session: Optional[requests.Session] = None,
69
+ ) -> None:
70
+ self.token = (token or os.getenv(TOKEN_ENV) or "").strip()
71
+ if not self.token:
72
+ raise ValueError(f"A Scrape.do API token is required (pass token= or set {TOKEN_ENV}).")
73
+ if lat is None or lng is None:
74
+ key = (city or DEFAULT_CITY).strip().lower()
75
+ if key in ("bangalore", "bengaluru"):
76
+ key = "bengaluru"
77
+ if key not in CITIES:
78
+ raise ValueError(f"Unknown city {city!r}. Known: {', '.join(sorted(CITIES))}. Or pass lat and lng.")
79
+ lat, lng = CITIES[key]
80
+ self.lat = float(lat)
81
+ self.lng = float(lng)
82
+ self.timeout = timeout
83
+ self.retries = max(0, retries)
84
+ self.session = session or requests.Session()
85
+ self.credits_used = 0
86
+
87
+ # ------------------------------------------------------------ transport
88
+
89
+ def fetch_json(self, path: str, **query: Any) -> Dict[str, Any]:
90
+ """GET a Swiggy ``/dapi`` endpoint through Scrape.do and return its ``data`` object."""
91
+ url = f"{SWIGGY}{path}?{urlencode(query)}"
92
+ params = {"token": self.token, "url": url, "geoCode": "in"}
93
+ last: Optional[ScrapeError] = None
94
+ for attempt in range(self.retries + 1):
95
+ if attempt:
96
+ time.sleep(1.5 * attempt)
97
+ try:
98
+ response = self.session.get(API_URL, params=params, timeout=self.timeout)
99
+ except requests.RequestException as exc:
100
+ last = ScrapeError(f"Request failed: {str(exc).replace(self.token, '***')}")
101
+ continue
102
+ from_target = "scrape.do-initial-status-code" in {k.lower() for k in response.headers}
103
+ try:
104
+ self.credits_used += int(response.headers.get("scrape.do-request-cost") or 0)
105
+ except ValueError:
106
+ pass
107
+ if response.status_code == 401 and not from_target:
108
+ raise ScrapeError("Scrape.do rejected the token (HTTP 401).", 401, False)
109
+ if response.status_code == 200:
110
+ try:
111
+ body = response.json()
112
+ except ValueError:
113
+ body = None
114
+ if isinstance(body, dict) and body.get("statusCode") == 0 and isinstance(body.get("data"), dict):
115
+ return body["data"]
116
+ message = (body or {}).get("statusMessage") if isinstance(body, dict) else None
117
+ last = ScrapeError(f"Swiggy returned no data for {url}" + (f": {message}" if message else ""), 200, True)
118
+ if isinstance(body, dict) and body.get("statusCode") not in (None, 0):
119
+ break
120
+ continue
121
+ last = ScrapeError(f"HTTP {response.status_code} for {url}", response.status_code, from_target)
122
+ if response.status_code not in (429, 500, 502, 503, 504):
123
+ break
124
+ raise last or ScrapeError(f"Request failed: {url}")
125
+
126
+ def _search(self, query: str, tab: str) -> Dict[str, Any]:
127
+ if not str(query or "").strip():
128
+ raise ValueError("Search query is empty.")
129
+ return self.fetch_json(
130
+ "/dapi/restaurants/search/v3", lat=self.lat, lng=self.lng, str=query.strip(),
131
+ trackingId="undefined", submitAction="ENTER", selectedPLTab=tab,
132
+ )
133
+
134
+ # ------------------------------------------------------------- scraping
135
+
136
+ def search_dishes(self, query: str) -> List[Dict[str, Any]]:
137
+ """Dishes matching ``query`` from restaurants that deliver here: price, veg, rating, variants,
138
+ restaurant. One request (about 50 restaurants, every matching dish of each)."""
139
+ return parse_search_dishes(self._search(query, "DISH"))
140
+
141
+ def search_restaurants(self, query: str) -> List[Dict[str, Any]]:
142
+ """Restaurants matching ``query`` that deliver here. One request, ~100 restaurants."""
143
+ return parse_search_restaurants(self._search(query, "RESTAURANT"))
144
+
145
+ def get_restaurants(self) -> List[Dict[str, Any]]:
146
+ """The restaurants on Swiggy's home page for this location (top brands and the main grid)."""
147
+ return parse_listing(self.fetch_json(
148
+ "/dapi/restaurants/list/v5", lat=self.lat, lng=self.lng,
149
+ **{"is-seo-homepage-enabled": "true", "page_type": "DESKTOP_WEB_LISTING"},
150
+ ))
151
+
152
+ def get_menu(self, url_or_id: Any) -> Dict[str, Any]:
153
+ """Full menu of a restaurant: ``{"restaurant": {...}, "items": [...]}``. The restaurant row includes
154
+ the FSSAI licence, address, and whether it delivers to this location."""
155
+ restaurant_id = restaurant_id_from_url(str(url_or_id))
156
+ if not restaurant_id:
157
+ raise ValueError("Expected a Swiggy restaurant URL (…-rest<ID> or /restaurants/…-<ID>) or a restaurant id.")
158
+ menu = parse_menu(self.fetch_json(
159
+ "/dapi/menu/pl", **{"page-type": "REGULAR_MENU", "complete-menu": "true"},
160
+ lat=self.lat, lng=self.lng, restaurantId=restaurant_id,
161
+ ))
162
+ if not menu["restaurant"].get("restaurant_id"):
163
+ raise ScrapeError(f"No restaurant {restaurant_id} on Swiggy.", 200, True)
164
+ return menu