Swiggy Tracking: Goal, Progress and Learnings

Last updated 2026-10-27. Source of truth for the project: this document. Project folder: swiggyResearch.

1. Goal

Collect restaurants, outlets, menu dishes and ratings from Swiggy delivery pages and store them in a SQLite database.

Scope so far: every Hyderabad delivery restaurant URL found in Swiggy's public sitemap (27,418). Only a few have been mined.

2. Status at a glance

PieceStatus
Menu data sourceFound. The /dapi/menu/pl JSON response contains the full menu, prices, ratings, descriptions, restaurant info, address and license.
Menu completenessMatches. Sum of section counts equals parsed items for every restaurant mined so far.
Capture and parseWorking. Mined end to end from the viewer (capture, then parse). Browser-side diagnostics are non-fatal.
Database11 restaurants, 11 outlets, 1,627 dishes, 1,171 dish ratings, all outlets have a license number.
Viewdish_ratings_named adds dish, restaurant and outlet names to dish_ratings.
ViewerWorking. All, Done and Not done filters. Mine button runs one restaurant.
Outlet switcherNot done. Swiggy marks restaurants as multiOutlet; only the URL's outlet is captured.
Address and license in the viewStored in tables, not yet in the view.

3. How the pipeline works

  1. List: hyderabad_restaurants.txt holds one URL per restaurant (from the sitemap, section 10).
  2. Capture (capture.js): opens one URL in a visible Chrome, clicks View full menu, saves the menu API response and some diagnostics to snapshots\rest<id>\<timestamp>\.
  3. Parse (parse.js, lib/menu-api.js): reads the saved menu API JSON and writes restaurant, outlet, dishes and ratings to swiggy.db.
  4. View: dish_ratings_named in the DB, or the local viewer.

The capture step never writes to the DB. Parsing reads only saved files, so it can be re-run without touching Swiggy again. That split is the most useful design decision so far.

The older mine.js (DOM-based, writes straight to the DB) is still present but no longer used by the viewer. Its failure path writes placeholder rows, so it is kept only for reference.

4. What the Swiggy data looks like

Where the menu comes from

The menu is not in the first page load. Clicking View full menu requests https://www.swiggy.com/dapi/menu/pl. The response is 230 KB to 430 KB and holds everything needed in one response. There was no pagination in any response seen so far (firstOffsetRequest: true is only a marker).

Fields used

DataWhere in the response
Restaurant name, city, cost for two, cuisines, average rating, rating countRestaurant.info: name, city, costForTwo (paise), cuisines, avgRating, totalRatingsString
Outlet name, area, addressRestaurantAddress: name, area, completeAddress
License numberRestaurantLicenseInfo.text: "License No. ..."
Dish sectionsItemCategory cards, and sub-categories inside NestedItemCategory, under groupedCard.cardGroupMap.REGULAR
Dish name, description, veg flag, priceitemCards[].card.info: name, description, itemAttribute.vegClassifier (VEG / NONVEG), price
Dish ratinginfo.ratings.aggregatedRating: rating (string), ratingCountV2. Missing or "--" means no rating.

Things that surprised us

Markup changes

The DOM dish card used to carry an aria-label with "Costs: N rupees". It is now a paragraph with the same text, and each card has data-testid="normal-dish-item". capture.js accepts both. This change was the cause of the earlier DOM timeouts (section 11).

5. Parser decisions

DecisionWhy
Use the menu API as the source of truth. The DOM is diagnostic only.The API has every dish in one response, with exact ratings and descriptions. The DOM depends on rendering and on markup that changes.
Store prices in rupees (paise divided by 100, rounded).Matches what the page shows ("Costs: 458 rupees").
Items with variants take the lowest variant price.The page shows a single price for these. Example: Murgh Musallam is stored as 664 (Half), not 825 (Full). Confirm this is the rule you want.
Category = the section title. Nested sub-categories are flattened to their own section.Matches how the page groups dishes. Section titles have whitespace collapsed.
Duplicate (category, name) pairs: keep the first, report the rest in the run note.The DB key is (outlet, category, name). A silent overwrite would lose data.
Outlet name from RestaurantAddress.name, falling back to the restaurant name.Outlet name can differ from the brand name (for example "Pista House Bakery"). This was a bug until it was fixed.
Address from completeAddress; license number from RestaurantLicenseInfo.Address is the best human key. License is a stable government ID per outlet, so it is the most reliable way to tell outlets apart.
Ratings: missing means null, not zero.A dish with no rating is different from a dish rated 0.
Rating count "1.8K+" is stored as 1800 (approximate). The original text is kept too.The API only gives a rounded count for large numbers.
Multiple menu responses in one snapshot: use the one with the most items.A snapshot can contain error bodies or other API responses.

Success conditions

6. Database

File: swiggy.db (SQLite, WAL mode). Schema is created by lib/db.js every time it opens. Tables:

TableOne row perNotes
restaurantsSwiggy restaurant ID (one outlet each)Name, city, cuisines, cost for two, rating, rating count
outletsOutlet (one per restaurant ID here)outlet_name, area, address, license_no (added later)
dishesDish on an outlet's menuUnique on (outlet, category, name). Veg flag, price, description
dish_ratingsDish, per scrape runStars and rating count at that time. Keeps history.
scrape_runsParse runStatus, dishes found, dishes expected, note. Used for viewer status.
dish_ratings_named (view)Same as dish_ratingsAdds dish_name, restaurant_name, outlet_name.

Migrations and backfills

Known data quirks

7. Capture decisions (browser)

DecisionWhy
Visible browser, not headless.Headless runs were refused by Swiggy with an HTTP error on the navigation. A visible window worked.
Persistent profile in browser-profile\, not your personal Chrome.Keeps cookies and location between runs without touching your own profile or sign-ins. Override with the PROFILE environment variable.
Wait for the restaurant header, then up to 15 s for the View full menu button to be visible, then click.A fixed sleep made the button check run before the button rendered.
Wait for dish cards to be attached (in the DOM), not visible.A late or off-screen render should not end the scroll. The API response is what the parser uses.
Card selector: [data-testid="normal-dish-item"], with the old aria-label as fallback.The label moved into the text of a paragraph (section 4).
DOM result recorded as dom_status in meta.json: cards_seen, no_cards, no_button, error.Diagnostic only. It explains a failure but never decides success.
HTTP 202 on navigation is logged as a note, not an error.The page still loaded and the menu API returned 200.
Only HTTP 400 and above stops a capture.Matches the observed behaviour of the site.
One browser context for the whole run; one page per URL.Keeps the profile shared while avoiding leftover tabs.

Rate and blocking

Repeated requests to Swiggy in a short time start failing with ERR_HTTP_RESPONSE_CODE_FAILURE, and sometimes HTTP 403. Pause between runs, and do not loop over many restaurants at once. The viewer runs one job at a time for this reason.

8. Learnings: Claude in Chrome

How the tools behaved

Lessons for using it

9. Viewer

Run npm run viewer, then open http://127.0.0.1:3000. It listens on localhost only.

The viewer server does not reload when its code changes. After any change to viewer/server.js, restart it. A stale server returns old fields, which caused the Done and Not done filters to show 0 restaurants. The page now computes Done from the dish count, so it works with either version.

10. Restaurant list (27,418 URLs)

11. Issues found and how they were fixed

SymptomCauseFix
Capture did not click View full menuButton checked once after a fixed 3 s sleep, before it rendered.Wait for the header, then for the button to be visible, then click.
Menu API captured but the run timed out and was logged as failedThe selector [aria-label*="Costs:"] no longer matched. The label moved into paragraph text.Selector switched to data-testid="normal-dish-item", with the old label as fallback. DOM result made non-fatal.
Placeholder restaurant rows with the URL as the nameThe failure path of mine.js wrote placeholder restaurants and failed@url outlets.Parse upserts overwrote the names. Placeholder outlets deleted. Viewer no longer uses mine.js.
Viewer showed "undefined" statuses, then 0 for Done and Not doneServer process started before a code change, so it returned old fields.Restart the viewer. Page derives Done from the dish count.
Outlet name was always the brand nameParser used the restaurant name, not RestaurantAddress.name.Parser uses the outlet name, falling back to the restaurant name.
DB would not open in the VS Code SQLite extensionSuspected: WAL mode side files, and a project folder synced by OneDrive. Not confirmed; you reported it worked afterwards, which you put down to a memory issue.No code change. Note this if it returns.
Test of a recent parse showed "0 with ratings"Correct. The restaurant had dishes marked --. The two ratings found belonged to other outlets in a grid.None needed. Recorded here so it isn't mistaken for a bug.

12. Files

FilePurpose
capture.jsOpens one URL, clicks the menu, saves the API response and diagnostics.
parse.jsParses saved snapshots into the DB. npm run parse.
lib/menu-api.jsMenu API parser: restaurant, outlet, license, dishes, ratings.
lib/db.jsSchema, migrations, view, saves one scrape in one transaction.
lib/extract.jsDOM extractor and shared helpers. Used by the old mine.js.
mine.jsOlder DOM-based miner. Not used by the viewer.
viewer/server.js, viewer/index.htmlLocal viewer and mining job runner.
test/menu-api.test.jsParser tests against real snapshots. npm test.
hyderabad_restaurants.txt27,418 Hyderabad restaurant URLs.
ref.txtThe two original test URLs.
swiggy.dbThe database.
snapshots\Raw captures. Large, not in the repo.
browser-profile\Browser profile used by capture. Not in the repo.

13. Open items

14. Corrections to earlier notes