# Google Maps Place Photos Scraper (`dami_studio/google-maps-place-photos-scraper`) Actor

Pull the photos and videos off a Google Maps place: a full-size image link, the caption someone typed, the pixel size, the upload date and where the camera stood, one row per picture. Paste a Maps link, a Share link or a place id. No API key, no Google account, no browser.

- **URL**: https://apify.com/dami\_studio/google-maps-place-photos-scraper.md
- **Developed by:** [Dami's Studio](https://apify.com/dami_studio) (community)
- **Categories:** Travel, Business, Other
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.23 / 1,000 photo scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Google Maps Place Photos Scraper

Point it at a Google Maps place and get back the pictures on it — one row per photo or video, with a full-size image link, the caption the person typed, the pixel dimensions, the date it went up and where the camera was standing. No API key, no Google account, no browser.

- A full-size image link, not a thumbnail. Ask for the width you want and the URL comes back at that width.
- Photos, videos and Street View entries are labelled, so you can keep only the kind you need.
- Every row carries the upload date, so a venue's pictures sort by year without any extra work.
- Runs with empty input return a labelled sample row, free, so you can see the shape before spending anything.

### Price

**$0.23 per 1,000 photos**, plus a **$0.0005 start fee per run**.

This is a flat rate on every plan, free or paid. There are no volume tiers, no minimum spend, no subscription and no add-on fees. What you read here is what you pay on day one and on day four hundred.

| Photos | Total cost |
|---|---|
| 100 | $0.0235 |
| 1,000 | $0.2305 |
| 10,000 | $2.3005 |
| 100,000 | $23.0005 |

#### What is actually charged

- **One `photo-scraped` event per photo row written to the dataset.** Nothing else is metered per row.
- **Free:** the sample row an empty run returns, and every diagnostic row — a blocked target, a dead URL, a search that matched nothing. Those rows all carry `"charged": false`.
- A place that shows no pictures at all costs nothing beyond the start fee.
- Videos and Street View entries you filtered out are dropped before they are charged.
- A run that finds nothing costs the start fee and nothing more.
- Rows never leave the dataset without a charge, and are never charged without a row. The billed event is a named one, so there is no price quietly attached to `apify-default-dataset-item` — the trick that makes some scrapers bill you for their own error messages.

### Input

```json
{
  "placeUrls": [
    "https://www.google.com/maps/place/Starbucks+Reserve+Roastery/data=!4m2!3m1!1s0x54906acdccf44db1:0x6da0c1f2d7a6736e",
    "https://maps.app.goo.gl/example"
  ],
  "maxPhotosPerPlace": 30,
  "maxPhotos": 60,
  "mediaType": "all",
  "imageWidth": 1600
}
```

| Field | What it does |
|---|---|
| `placeUrls` | One or more places, up to 50 per run. A full Google Maps link works, so does the short link from the Share button, a `?cid=` link, or the raw `0x...:0x...` id if you already have one. |
| `maxPhotosPerPlace` | Ceiling on rows from a single place. Default 30. |
| `maxPhotos` | Total rows for the whole run, shared evenly between your places. Default 200. Keep it low while you are testing — you pay per row. |
| `mediaType` | `all` (default), `photos` to drop videos and Street View, or `videos` to keep only clips. |
| `imageWidth` | Pixel width for the `photoUrl` link. Default 1600. The height follows the original aspect ratio. |
| `language` | Two-letter interface language, default `en`. It changes captions Google has translated, not the pictures. |
| `country` | Two-letter country for the request, default `us`. |
| `proxyUrls` | Leave empty. Fill it in only if you want the traffic to leave through proxy servers you already pay for, as `http://user:pass@host:port`. |

Run it with **empty input** and you get one clearly labelled sample row, free, so you can see the output shape before you spend anything.

### Output

One row per photo. A real row from a real run:

```json
{
  "ok": true,
  "charged": true,
  "recordType": "photo",
  "target": "https://www.google.com/maps?cid=7899526995152106350",
  "placeName": "Starbucks Reserve Roastery",
  "placeId": "0x54906acdccf44db1:0x6da0c1f2d7a6736e",
  "cid": "7899526995152106350",
  "placeUrl": "https://www.google.com/maps?cid=7899526995152106350",
  "photoId": "CIHM0ogKEICAgICxpfGj4wE",
  "mediaType": "photo",
  "photoUrl": "https://lh3.googleusercontent.com/gps-cs-s/AHRPTWlnERVZyB9D3zFYFg7wFEDmh5g=w1600-h900-k-no",
  "thumbnailUrl": "https://lh3.googleusercontent.com/gps-cs-s/AHRPTWlnERVZyB9D3zFYFg7wFEDmh5g=w203-h114-k-no",
  "caption": "Espresso flight of 2",
  "width": 4032,
  "height": 2268,
  "uploadedAt": "2022-11-10T20:00:00.000Z",
  "latitude": 47.6140399947503,
  "longitude": -122.328100047145,
  "videoDurationMs": null,
  "videoUrl": null,
  "scrapedAt": "2026-09-19T12:04:11.418Z"
}
```

#### Field notes

- `photoUrl` — the image at the width you asked for. Google serves these directly; no token, no expiry seen in testing.
- `thumbnailUrl` — the small version exactly as Google returned it, if you want to avoid re-requesting a big file.
- `caption` — what the uploader typed under the picture. Null when they typed nothing, and null when the only text was the place name repeated.
- `mediaType` — `photo`, `video` or `street_view`.
- `uploadedAt` — when the picture went up, to the hour, in UTC. Google does not publish the minute.
- `latitude` / `longitude` — where the camera was, not the centre of the business. On a large place these differ by tens of metres.
- `videoDurationMs` — clip length in milliseconds, on video rows only.
- `placeId` — Google's internal feature id for the place, stable across runs and the safest join key.
- `cid` — the same place as a decimal id, which is what `maps?cid=` links use.

Every real row carries `"charged": true`. Sample rows carry `"_sample": true` and diagnostic rows carry `"_diagnostic": true` with an `errorCode` you can filter on, and neither is ever billed.

### How it works

- The gallery is not in the page source — a logged-out fetch of a place page comes back with no image links in it at all. The pictures come from the JSON feed the Maps front end calls for itself, so that is what this reads.
- That feed only answers properly once it has handed the caller a guest session, so each run asks Google for its own front page first and keeps what it gets back. Nobody logs in and no account is involved.
- Requests are paced, because the feed limits how often it will answer.
- If an address stops serving the gallery, the run moves to another one rather than reporting the place as empty. That distinction matters: "no photos here" and "this run got throttled" look identical unless you check, and only one of them is true.

### What people use it for

- Filling a listing, directory or travel page with real photography of a venue instead of stock images.
- Watching what a location looks like over time — the upload dates let you sort a venue's pictures by year and see a refurbishment happen.
- Checking whether a business's own pictures are outnumbered by customer ones, which is usually the first sign a listing has been left to drift.
- Building an image set for a computer-vision job, where the caption and the pixel dimensions matter as much as the file.
- Auditing a chain: run every branch through in one go and compare how many pictures each location has.

### Reading the output

Every run writes three kinds of row, and they are easy to tell apart:

- **Real rows** carry `"charged": true` and `"recordType": "photo"`. One billed event each.
- **The sample row** carries `"_sample": true` and `"charged": false`. There is exactly one, it only appears when the input had no places in it, and it is there so you can look at the shape of the output before you spend anything.
- **Diagnostic rows** carry `"_diagnostic": true`, `"charged": false` and an `errorCode` you can switch on: `PLACE_NOT_RESOLVED` when the link could not be tied to a place, `NO_PHOTOS` when the place genuinely shows none, `BLOCKED` when every request the run made was refused the gallery, `PHOTOS_PARSE` when the feed answered with something else, `NETWORK` when it could not be reached, and `TIME_BUDGET` when the run ran out of time before reaching a place.

If you only want the data, filter on `charged == true`. The count of those rows equals the number of events you were billed for, so the dataset is its own invoice.

### Limitations

- This returns the pictures on a place, and nothing else about it. No rating, no address, no opening hours, no reviews.
- There is no uploader name in the feed and none is invented, so rows tell you what the picture is and when it went up, not who took it.
- Captions are rare. On a 150-picture venue measured here, 4 rows carried a caption and the other 146 came back with `caption: null`. Treat it as a bonus, not a field you can plan around.
- The upload time is only published to the hour, so two pictures added in the same hour cannot be ordered between themselves.
- Street View entries are labelled and returned, but they are panorama stills rather than someone's photograph, so most callers will want `mediaType: "photos"`.
- A place with no pictures returns an uncharged diagnostic row, not an empty dataset.
- Short links and `ChIJ` ids each cost one extra request to turn into a place before any picture is read.
- The hard ceiling is 50 places per run. For more than that, split the work across runs.

### Questions

**Do I need a Google API key?**

No. There is no key, no OAuth and no Google account anywhere in the run. It reads the same public feed the Maps website uses when you click the photo grid.

**How do I get the link for a place?**

Open the place on Google Maps and copy the address bar, or press Share and copy the short link. Both work. If you already store Google place ids, paste those instead.

**Can I get the original resolution?**

Ask for the width you want with `imageWidth`. Each row also reports the picture's native `width` and `height`, so you can tell whether you asked for more pixels than exist.

**Will the run fail if a place is blocked?**

No. You get an uncharged diagnostic row saying what happened and the run finishes as succeeded. A failed run would still bill the start fee, which would mean paying to be told something went wrong.

**Does it tell me who uploaded each photo?**

No. The feed this reads does not carry the uploader, and nothing is guessed or joined in from elsewhere. If you need attribution, the photo page on Google Maps shows it.

**Can I run this on a schedule?**

Yes. Nothing is held between runs. `photoId` is stable, so diff on it to see only what is new since last time.

# Actor input Schema

## `placeUrls` (type: `array`):

The places whose pictures you want, up to 50 per run. A full Google Maps link works, so does the short link from the Share button, a ?cid= link, or the raw 0x...:0x... place id if you already have one.

## `maxPhotosPerPlace` (type: `integer`):

Ceiling on how many rows a single place may produce.

## `maxPhotos` (type: `integer`):

Total rows for the whole run, shared evenly between your places. Keep it low while you are testing - you pay per row.

## `mediaType` (type: `string`):

Keep everything, drop the videos and Street View panoramas, or keep only video clips.

## `imageWidth` (type: `integer`):

Width for the photoUrl link on each row. The height follows the picture's own aspect ratio. Each row also reports the native size, so you can tell when you asked for more pixels than exist.

## `language` (type: `string`):

Two-letter interface language. It changes captions Google has translated, not the pictures themselves.

## `country` (type: `string`):

Two-letter country code used for the request.

## `proxyUrls` (type: `array`):

Leave this empty. By default the run rotates a large pool of addresses that cost you nothing per gigabyte. Fill it in only if you specifically want the traffic to leave through proxy servers you already pay for, in the form http://user:pass@host:port.

## Actor input object example

```json
{
  "placeUrls": [
    "https://www.google.com/maps/place/Starbucks+Reserve+Roastery/data=!4m2!3m1!1s0x54906acdccf44db1:0x6da0c1f2d7a6736e"
  ],
  "maxPhotosPerPlace": 30,
  "maxPhotos": 200,
  "mediaType": "all",
  "imageWidth": 1600,
  "language": "en",
  "country": "us"
}
```

# Actor output Schema

## `results` (type: `string`):

Every row in the default dataset: placeName, mediaType, photoUrl, caption, width, height, uploadedAt, photoId, placeUrl. An empty, blocked or unmatched run returns a single uncharged row explaining what happened instead.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "placeUrls": [
        "https://www.google.com/maps/place/Starbucks+Reserve+Roastery/data=!4m2!3m1!1s0x54906acdccf44db1:0x6da0c1f2d7a6736e"
    ],
    "maxPhotosPerPlace": 30,
    "maxPhotos": 200,
    "mediaType": "all",
    "imageWidth": 1600,
    "language": "en",
    "country": "us"
};

// Run the Actor and wait for it to finish
const run = await client.actor("dami_studio/google-maps-place-photos-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "placeUrls": ["https://www.google.com/maps/place/Starbucks+Reserve+Roastery/data=!4m2!3m1!1s0x54906acdccf44db1:0x6da0c1f2d7a6736e"],
    "maxPhotosPerPlace": 30,
    "maxPhotos": 200,
    "mediaType": "all",
    "imageWidth": 1600,
    "language": "en",
    "country": "us",
}

# Run the Actor and wait for it to finish
run = client.actor("dami_studio/google-maps-place-photos-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "placeUrls": [
    "https://www.google.com/maps/place/Starbucks+Reserve+Roastery/data=!4m2!3m1!1s0x54906acdccf44db1:0x6da0c1f2d7a6736e"
  ],
  "maxPhotosPerPlace": 30,
  "maxPhotos": 200,
  "mediaType": "all",
  "imageWidth": 1600,
  "language": "en",
  "country": "us"
}' |
apify call dami_studio/google-maps-place-photos-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,dami_studio/google-maps-place-photos-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/2MWvngP6pCHJmVgPs/builds/SxNcq2q66CRziUVhb/openapi.json
