By the end of this tutorial, you will have a Python script that pulls Italian business leads from paginegialle.it into structured rows: name, category type, phone, public email, street address, city, postal code, region and coordinates.

PagineGialle's listing pages sit behind an AWS WAF challenge on CloudFront. In our tests on 8 November 2026, plain HTTP clients passed only 2 of 25 pages from a residential EU IP and 7 of 25 from a datacenter server before hitting challenges. PagineGialle Scraper (reventadata/paginegialle-scraper) handles that fetch layer and parses the structured Schema.org data on each page.

⏱️ This takes about 10 minutes. Every sample row below comes from a live run on 14 November 2026.

Prerequisites

You need three things before you run the script:

  1. Python 3.11 or newer (required by the Apify API client for Python 3.x).
  2. A free Apify account, which you can create with an email address or a GitHub login.
  3. Your API token, copied from Settings > API & Integrations in Apify Console.

Install or upgrade the client and export your token:

pip install -U "apify-client~=3.0" export APIFY_API_TOKEN="your_apify_token_here" 

You do not need a local browser, proxy credentials or scraping code on your machine.

What you'll achieve

You will run reventadata/paginegialle-scraper against Rome's restaurant directory (lazio/roma/ristoranti), read the returned dataset in Python, and print a two-line contact summary for each business.

Because everything lives inside the listing page's embedded JSON-LD ItemList, each page of up to 30 businesses requires only one fetch rather than 30 extra profile visits. You will also see why some rows have email: null or come back with a neighbouring town in locality, and how to handle those fields safely in Python.

The same workflow adapts to any Italian city and category — dentists in Milan, plumbers in Turin, hotels in Florence — simply by changing searchQueries.

Step 1: Open PagineGialle Scraper

Go to the PagineGialle Scraper page on Apify Store. The Try for free button opens the Actor in Apify Console if you want to inspect the form visually. Since we are driving it from Python, all you need is the Actor slug: reventadata/paginegialle-scraper.

Step 2: Configure your search queries

The Actor takes three input fields:

  • searchQueries (required): a list of category-and-location paths like "lazio/roma/ristoranti" or "roma/ristoranti", or full listing URLs like "https://www.paginegialle.it/lombardia/milano/dentisti.html". Avoid /ricerca/ search URLs: paginegialle.it/robots.txt disallows /ricerca/, so the Actor rejects them with an input error.
  • maxItems: maximum business rows to store across the whole run (default 100, minimum 1).
  • maxPages: maximum listing pages per query, from 1 to 100 (default 100; values above 100 are clamped to 100). A full page holds 30 businesses.

To combine a short path and a full URL in one run:

{ "searchQueries": [ "lazio/roma/ristoranti", "https://www.paginegialle.it/lombardia/milano/dentisti.html" ], "maxItems": 60, "maxPages": 2 } 

Businesses found across multiple pages or queries in the same run are deduplicated by their PagineGialle id.

Step 3: Run it from Python

Save this script and run it with your APIFY_API_TOKEN set. It runs lazio/roma/ristoranti on page 1, waits for the run to finish, checks run.status, and prints each business:

import os from apify_client import ApifyClient # 1. Initialise the client with your token from the environment client = ApifyClient(os.environ["APIFY_API_TOKEN"]) # 2. Describe the search: restaurants in Rome (Lazio), page 1, up to 20 rows run_input = { "searchQueries": ["lazio/roma/ristoranti"], "maxItems": 20, "maxPages": 1, } # 3. Start the Actor and wait for it to finish run = client.actor("reventadata/paginegialle-scraper").call(run_input=run_input) if run is None or run.status != "SUCCEEDED": status = run.status if run else "unknown" raise SystemExit(f"Run did not succeed (status: {status}).") # 4. Read the rows from the run's default dataset items = client.dataset(run.default_dataset_id).list_items().items print(f"Retrieved {len(items)} businesses.\n") # 5. Print two lines per business: contact details and street address for item in items: email = item.get("email") or "no email" print(f"{item['name']} ({item.get('type') or 'unknown'}) | {item.get('telephone') or 'no phone'} | {email}") print(f" {item.get('streetAddress')}, {item.get('postalCode')} {item.get('locality')} ({item.get('region')})") 

The Actor sends requests one page at a time through an unblocking service with Italian egress, so starting with maxPages: 1 lets you check field coverage before running larger sweeps.

Step 4: Read the output

When the example run executed on 14 November 2026 for lazio/roma/ristoranti, it finished in 23.8 seconds and returned 20 rows. Here are the first four lines of output produced by the loop above:

La Favola Ristorante Pizzeria (Restaurant) | +39 06 9485068 | isabellacupellini@icloud.com Piazza Giuseppe Garibaldi, 18, 00077 Monte Compatri (Lazio) Goji (Restaurant) | +39 351 8729881 | gojiroma@gmail.com Via Dell' Aeroporto, 4, 00175 Roma (Lazio) Hostaria Tor Millina (Restaurant) | +39 392 6508703 | no email Via di Tor Millina, 17, 00186 Roma (Lazio) Il Ristorante Rinaldi Al Quirinale (Restaurant) | +39 06 47825171 | info@rinaldialquirinale.it Via Parma, 11/A, 00184 Roma (Lazio) 

Notice two real-world details right away: Hostaria Tor Millina has no public email (no email), and La Favola Ristorante Pizzeria has locality set to Monte Compatri even though the site returned it for lazio/roma/ristoranti.

Understanding your results

Each dataset item contains 18 fields: id, name, type, url, telephone, email, streetAddress, locality, region, postalCode, country, latitude, longitude, hasMap, logo, searchQuery, page, and scrapedAt. Missing values stay null rather than empty strings, and country is always "IT".

Fields map directly from the page's JSON-LD ItemList: telephone and email come from the business's contactPoint entries, latitude and longitude are parsed as floats, searchQuery records which input query produced the row, page gives the listing page number, and scrapedAt holds the run's UTC ISO 8601 timestamp.

In the 20-row Rome run, 20 of 20 rows included telephone, streetAddress, postalCode, latitude, longitude, hasMap and logo, while 16 included email and 4 had email: null. Of those 16 emails, 9 used a custom domain, 6 used @gmail.com, and 1 used @icloud.com. Here is one complete row copied verbatim:

{ "id": "https://www.paginegialle.it/goji-roma", "name": "Goji", "type": "Restaurant", "url": "https://www.paginegialle.it/goji-roma", "telephone": "+39 351 8729881", "email": "gojiroma@gmail.com", "streetAddress": "Via Dell' Aeroporto, 4", "locality": "Roma", "region": "Lazio", "postalCode": "00175", "country": "IT", "latitude": 41.86124, "longitude": 12.55525, "hasMap": "https://www.paginegialle.it/goji-roma/mappa", "logo": "https://wips.plug.it/scips/JPXyePCiruggGCN4icRAbg/img.italiaonline.it/0WOSX00000WDSgo4AH_goji-cinese-food.jpg?a=rc&w=150&h=150&ssl=1", "searchQuery": "lazio/roma/ristoranti", "page": 1, "scrapedAt": "2026-11-14T14:45:13.000Z" } 

Pro tips

  1. Copy paths straight from category pages. Browse paginegialle.it to a category-and-city page like /lombardia/milano/idraulici.html and paste either the URL or lombardia/milano/idraulici. Query parameters on full URLs are ignored, and .html or /p-N.html suffixes are normalized automatically so pagination always starts at page 1.
  2. Filter type and locality downstream. In the 20 Rome rows, type was Restaurant on 18 rows, Hotel on 1 row and FoodEstablishment on 1 row; locality was Roma on 19 rows and Monte Compatri on 1. Filter on those fields in Python if you want strict city or Schema.org category boundaries.
  3. Deduplicate across runs using id. Duplicates within a single run are skipped automatically, whereas separate runs need your own id set.
  4. Stay under the 100-page cap per query. Each query stops at maxPages (capped at 100), on a past-ceiling page, or when a page renders fewer than 30 cards. Split large categories across multiple cities or provinces in searchQueries.

Troubleshooting

  • Zero rows, status SUCCEEDED. The site answered with its no-results page ("non siamo riusciti a trovare"). Double-check the region, city and Italian category spelling against a working URL on paginegialle.it.
  • Zero rows, status FAILED. Input validation rejected an empty query, an external URL or a robots.txt-disallowed path (such as /ricerca/ or /mappa/), or the page could not be parsed or loaded. Read the status message and logs with client.log(run.id).get().
  • Fewer rows than maxItems. The category may have ended early, or duplicates across pages/queries were skipped (Skipped N: already seen).

Check that your collection and use of public directory data comply with PagineGialle's terms of use and applicable data-protection law.

Next steps

For current pricing and details, see the Store page.

To automate recurring lead collection, save your input as an Apify task and attach a schedule, export datasets in JSON, CSV or Excel (dataset docs), or trigger downstream pipelines via webhooks using defaultDatasetId from the run resource.

What categories or Italian cities are you planning to scrape? Drop a comment! 💬

About the author: reventadata builds and publishes data extraction Actors on the Apify Store.