# Skyscanner Flight Scraper (`exotic_harmonium/skyscanner-flight`) Actor

Scrapes Skyscanner flights via the internal web-unified-search JSON API — no browser needed (pure HTTP over residential proxy). Mirrors the jupri/skyscanner-flight technique.

- **URL**: https://apify.com/exotic\_harmonium/skyscanner-flight.md
- **Developed by:** [deep sea](https://apify.com/exotic_harmonium) (community)
- **Categories:** Travel
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Skyscanner Flight Scraper (Apify Actor)

Scrapes Skyscanner flights via its **internal `web-unified-search` JSON API — no browser
in the hot path** — over Apify's built-in Residential Proxy. Same technique as
[`jupri/skyscanner-flight`](https://apify.com/jupri/skyscanner-flight).

```
input → resolve IATA→entityId → mint cookie → POST web-unified-search → poll until complete → parse → dataset
```

### Why no browser

Skyscanner's `web-unified-search` endpoint returns pure JSON. The only obstacle is the
**PerimeterX** cookie (`_px3`). A clean residential IP usually gets a usable cookie with a
plain HTTP GET (**Phase 1**). If that IP is challenged, the actor falls back to a headless
anti-detect browser (**Camoufox, Phase 2**) that loads one page from the **same sticky IP**
and hands back the cookie. Everything else is HTTP.

### Input

Mirrors jupri's fields. Minimum: `origin.0`, `target.0`, `depart.0`.

| Field | Meaning |
|---|---|
| `origin.0..5` / `target.0..5` / `depart.0..5` | Per-leg origin, destination, and `YYYY-MM-DD` date. For a round trip: leg 0 = outbound, leg 1 = `origin.1=target.0`, `target.1=origin.0`, `depart.1=`return. |
| `market` / `currency` | ISO country + currency (e.g. `IL` / `USD`). **currency is required internally.** |
| `sort` | `score` | `cheapest` | `fastest` | `departure`. |
| `cabin_class` | `economy` | `premiumeconomy` | `business` | `first`. |
| `adults` / `children` / `infants` | Passenger counts (max 8 each). |
| `non_stop` / `one_stop` / `two_stop` | Stop filters (any combination). |
| `limit` | Slice the result count. |
| `dev_proxy_config` | Proxy. Defaults to Apify **RESIDENTIAL** (needed to pass PerimeterX). |
| `dev_custom_headers` / `dev_custom_cookies` | Extra headers / cookies (inject a pre-minted `_px3` here). |
| `dev_transform_fields` / `dev_no_strip` / `dev_dataset_name` / `dev_dataset_clear` | Output shaping. |

Example:

```json
{
  "origin.0": "TLV", "target.0": "HER", "depart.0": "2026-08-02",
  "origin.1": "HER", "target.1": "TLV", "depart.1": "2026-08-05",
  "market": "IL", "currency": "USD",
  "cabin_class": "economy", "adults": 1, "non_stop": true,
  "sort": "cheapest"
}
```

### Output

One dataset record per itinerary:

```json
{
  "id": "16995-2608021920--30967-0-12130-2608022110|12130-2608051125--30967-0-16995-2608051305",
  "price": 229, "currency": "USD",
  "carriers": "Blue Bird Airways",
  "stops": 0,
  "origin": "TLV", "destination": "HER",
  "departure": "2026-08-02T19:20", "arrival": "2026-08-02T21:10",
  "durationMinutes": 110,
  "legs": [ … ], "agents": [ … ]
}
```

`id` is the `legOut|legIn` config id — the key for a per-agent price lookup
(`/g/sonar/v3/itinerary/details/poll`).

### Run

Locally (needs Apify CLI + an APIFY token for proxy):

```bash
apify run --purge
```

Deploy:

```bash
apify push
```

Call it over the API (this is how the .NET admin consumes it):

```bash
curl -X POST \
  "https://api.apify.com/v2/acts/<actor-id>/run-sync-get-dataset-items?token=<APIFY_TOKEN>" \
  -H 'Content-Type: application/json' \
  -d '{"origin.0":"TLV","target.0":"HER","depart.0":"2026-08-02","market":"IL","currency":"USD"}'
```

### Gotchas baked in

- `user-agent` on the search POST **must match** the UA the cookie was minted with (Phase 2 returns the browser's exact UA for this reason).
- `x-skyscanner-currency` is **required** (missing → `400`).
- Mint + every fire exit the **same sticky IP** (PerimeterX binds `_px3` to the IP).
- First response ≈ 10 results (`status: incomplete`); it grows toward ~860 as the actor polls to `complete`.

# Actor input Schema

## `origin.0` (type: `string`):

Origin city or airport (IATA code, e.g. TLV, or a city name).

## `target.0` (type: `string`):

Destination city or airport (IATA code, e.g. HER, or a city name).

## `depart.0` (type: `string`):

Departure date (YYYY-MM-DD).

## `origin.1` (type: `string`):

Second leg origin (for round trip, set = target.0).

## `target.1` (type: `string`):

Second leg destination (for round trip, set = origin.0).

## `depart.1` (type: `string`):

Second leg / return date (YYYY-MM-DD).

## `origin.2` (type: `string`):

Leg 2 origin.

## `target.2` (type: `string`):

Leg 2 destination.

## `depart.2` (type: `string`):

Leg 2 date (YYYY-MM-DD).

## `origin.3` (type: `string`):

Leg 3 origin.

## `target.3` (type: `string`):

Leg 3 destination.

## `depart.3` (type: `string`):

Leg 3 date (YYYY-MM-DD).

## `origin.4` (type: `string`):

Leg 4 origin.

## `target.4` (type: `string`):

Leg 4 destination.

## `depart.4` (type: `string`):

Leg 4 date (YYYY-MM-DD).

## `origin.5` (type: `string`):

Leg 5 origin.

## `target.5` (type: `string`):

Leg 5 destination.

## `depart.5` (type: `string`):

Leg 5 date (YYYY-MM-DD).

## `market` (type: `string`):

Country you're searching from — gives local deals. ISO country code.

## `currency` (type: `string`):

Pricing currency (ISO code, e.g. USD, ILS, EUR).

## `sort` (type: `string`):

How to sort results.

## `limit` (type: `integer`):

Max number of results to push per search (slice, after sort). Leave empty for all.

## `trips` (type: `array`):

Run MANY searches in ONE browser session — PerimeterX is passed once and amortized across all of them. Each item: {"depart":"YYYY-MM-DD","return":"YYYY-MM-DD"} (return optional; origin/target default to origin.0/target.0, or override per item with "origin"/"target"). When set, the single origin.0/depart.0 search is ignored. Records are tagged with search\_depart/search\_return.

## `poll_budget_s` (type: `integer`):

Max seconds to poll each search toward status:complete. Higher = more agents populate per itinerary; lower = faster but fewer agents on some itineraries. Default 120.

## `poll_stall_s` (type: `integer`):

Stop polling a search once its result count has not grown for this many seconds. Skyscanner currently freezes these searches (never reports 'complete'), so without this every search runs the full poll budget for nothing. 0 = disabled (poll the whole budget).

## `freeze_stall_s` (type: `integer`):

Shorter stall window applied only when the result count never grew past the first snapshot — the frozen shape. A search that grew at least once keeps the full poll\_stall\_s window. 0 = use poll\_stall\_s for both.

## `poll_interval_s` (type: `integer`):

Seconds between polls of a running search. Each poll replays the CREATE request, which starts a NEW search server-side, so polling fast can keep killing a search before it matures. Raise this if searches freeze at 10 results. Default 2.

## `passive_poll` (type: `boolean`):

Send no poll requests of our own — only capture the web-unified-search responses the site's own JS fires. Avoids the 'every poll starts a new search' problem entirely. Falls back to active polling if the site fires only once in 20s.

## `renav_on_freeze` (type: `boolean`):

When a search shows the freeze signature (count stuck at the first snapshot, no poll continuing the same session), reload the results page once instead of waiting out the stall timeout. Costs ~10s and lets the site create a fresh search, versus ~45s of waiting that ends with 10 records.

## `block_third_party` (type: `boolean`):

Abort requests to third-party ad and analytics domains (doubleclick, GTM, hotjar, ...). Nothing on a skyscanner domain and nothing PerimeterX-related is blocked. Cuts page load work; measure the 403 rate before leaving it on.

## `search_concurrency` (type: `integer`):

How many of the trips dates may run at once. MEASURED: leave this at 1. Skyscanner degrades concurrent searches from the same session — same 2 dates took 76s sequentially (1 snapshot each, 15 records each) versus 165s in parallel tabs, where every poll took 20-35s and searches stall-stopped at 10 results. 3 was worse still and lost a whole date. Raise only with fresh measurements.

## `early_stop` (type: `boolean`):

Stop polling once the cheapest `limit` itineraries have stopped changing for a few consecutive snapshots, instead of waiting for the full result set to complete. Much faster; small risk of missing a cheaper flight that surfaces late. Best combined with a set `limit`.

## `race_ips` (type: `integer`):

Launch a browser on this many residential IPs in parallel and take the first that passes PerimeterX; the rest are closed. 2 hides the ~20s otherwise wasted when the first IP hits an interstitial. 1 disables the race. Higher = more RAM (use 4GB if >2).

## `cabin_class` (type: `string`):

Cabin class.

## `alternate_origin` (type: `boolean`):

Include nearby origin airports.

## `alternate_target` (type: `boolean`):

Include nearby destination airports.

## `non_stop` (type: `boolean`):

Keep only direct (non-stop) flights.

## `one_stop` (type: `boolean`):

Keep 1-stop flights.

## `two_stop` (type: `boolean`):

Keep 2+ stop flights.

## `adults` (type: `integer`):

Number of adults (max 8).

## `children` (type: `integer`):

Number of children (max 8).

## `infants` (type: `integer`):

Number of infants (max 8).

## `dev_proxy_config` (type: `object`):

Proxy networking. Defaults to Apify Residential proxy (required to pass PerimeterX). Sticky sessions are used internally so the cookie mint and every fire exit the same IP.

## `dev_custom_headers` (type: `array`):

Additional HTTP headers as {name, value} objects, merged into every request.

## `dev_custom_cookies` (type: `array`):

Additional cookies as {name, value} objects, merged into the cookie jar. Use to inject a pre-minted \_px3 if you have one (see reuse\_session\_id).

## `reuse_session_id` (type: `string`):

Reuse a previously-minted PerimeterX session to SKIP the browser. Set this to the session\_id from a prior run's KV 'px\_session', and pass that run's cookies in dev\_custom\_cookies. Phase 1 (pure HTTP) then exits the same residential IP the \_px3 is bound to. Works only while the \_px3 + sticky IP are still alive (minutes). On failure it falls back to the browser.

## `dev_transform_fields` (type: `array`):

Restrict pushed output to these field names (in order). Empty = full record.

## `dev_dataset_name` (type: `string`):

Named dataset to push to. Supports {ACTOR}, {DATE}, {TIME} tokens. Empty = default dataset.

## `dev_dataset_clear` (type: `boolean`):

Wipe the target dataset before pushing.

## `dev_no_strip` (type: `boolean`):

Keep null/empty fields in output instead of stripping them.

## `dev_discover_config` (type: `boolean`):

One-off: navigate the /config/<id> page for the first itinerary and log every XHR that carries pricing\_options, so the details endpoint can be captured. Saves the raw hit to KV key 'config\_capture'.

## `fetch_details` (type: `boolean`):

LEGACY / optional. Every run now already returns the full agent list inline in each record's `agents` (via poll-to-complete + snapshot merge), so this is only needed for the extra jupri-shaped `pricing_options` with agent names/ratings from the /config details endpoint. Costs one browser session + one page visit per itinerary — leave OFF unless you need the richer per-agent metadata.

## `details_limit` (type: `integer`):

How many top itineraries (after sort) to enrich with pricing\_options when 'Fetch per-agent pricing options' is on.

## `details_only` (type: `boolean`):

With 'Fetch per-agent pricing options' on: output ONLY the top-N enriched itineraries and finish immediately, skipping all other itineraries. Fastest/cheapest when you just want the top results in full.

## `replay_dates` (type: `boolean`):

For multi-date (trips) runs, derive each later date's search by swapping the dates in the CREATE request captured on the first date, issued from the same PerimeterX-warm tab. Measured: the navigation itself is 1-2s but waiting for the site's own JS to fire its search costs 16-26s per extra date. Falls back to a normal navigation whenever the route differs or the replay does not return results. Only affects trips runs.

## `px_block_fire_s` (type: `integer`):

MEASURED CORRECTION: a PerimeterX block does NOT return 403 on the results document. The document loads normally and the site JS simply never fires a search, so both raced IPs sit out the full 30s deadline (~36s per failed attempt, up to 3 attempts per run). The actor therefore probes the PAGE for the PX challenge (captcha element / challenge copy) while waiting, and always LOGS when it sees one and whether that page went on to fire anyway. Set this to how many extra seconds to allow after the signature has been seen twice. 0 = off (keep the full wait) and is the default, because a page mid-challenge can still self-solve. Set it from the "after PX signature: fired=... at Xs" log lines rather than by guessing.

## `parallel_replays` (type: `boolean`):

EXPERIMENTAL, off by default. For trips runs, ask for every remaining date's search concurrently from the first date's PerimeterX-warm tab in a single in-page call, instead of one tab + navigation + blocking replay per date. Each date otherwise costs 12-27s of pure server latency and they add up; this makes the total roughly the slowest single call. Unlike search\_concurrency (measured worse) it opens no extra tabs and runs no competing site JS. Any date whose replay does not come back usable falls back to a normal navigation.

## `partial_enrich_top` (type: `integer`):

When a search does not reach "complete", its flights and prices are correct but each itinerary carries only ONE agent, because the inline agent list fills in over poll snapshots that never arrive. /itinerary/details does not depend on the search completing — measured on a frozen date it returned 18-19 priced agents with names, ratings and booking links for itineraries that had 1. This enriches the cheapest N of any PARTIAL date so the rows people actually read keep a full seller list. Costs a few seconds, and only on dates that came back partial. 0 = off. Dates that complete normally are unaffected; fetch\_details still overrides for all dates. Sizing note: the enrichment replays fire 4 per round, so 4 costs the same as 3 (one round) and 5-8 all cost one extra round. 4 is the free maximum.

## Actor input object example

```json
{
  "origin.0": "TLV",
  "target.0": "HER",
  "depart.0": "2026-08-02",
  "market": "US",
  "currency": "USD",
  "sort": "score",
  "trips": [
    {
      "depart": "2026-08-02",
      "return": "2026-08-05"
    },
    {
      "depart": "2026-08-03",
      "return": "2026-08-06"
    }
  ],
  "poll_budget_s": 120,
  "poll_stall_s": 45,
  "freeze_stall_s": 20,
  "poll_interval_s": 2,
  "passive_poll": false,
  "renav_on_freeze": false,
  "block_third_party": false,
  "search_concurrency": 1,
  "early_stop": false,
  "race_ips": 2,
  "cabin_class": "economy",
  "alternate_origin": false,
  "alternate_target": false,
  "non_stop": false,
  "one_stop": false,
  "two_stop": false,
  "adults": 1,
  "children": 0,
  "infants": 0,
  "dev_proxy_config": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "BUYPROXIES94952"
    ]
  },
  "dev_dataset_clear": false,
  "dev_no_strip": false,
  "dev_discover_config": false,
  "fetch_details": false,
  "details_limit": 10,
  "details_only": false,
  "replay_dates": true,
  "px_block_fire_s": 0,
  "parallel_replays": false,
  "partial_enrich_top": 4
}
```

# 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 = {
    "trips": [
        {
            "depart": "2026-08-02",
            "return": "2026-08-05"
        },
        {
            "depart": "2026-08-03",
            "return": "2026-08-06"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("exotic_harmonium/skyscanner-flight").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 = { "trips": [
        {
            "depart": "2026-08-02",
            "return": "2026-08-05",
        },
        {
            "depart": "2026-08-03",
            "return": "2026-08-06",
        },
    ] }

# Run the Actor and wait for it to finish
run = client.actor("exotic_harmonium/skyscanner-flight").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "trips": [
    {
      "depart": "2026-08-02",
      "return": "2026-08-05"
    },
    {
      "depart": "2026-08-03",
      "return": "2026-08-06"
    }
  ]
}' |
apify call exotic_harmonium/skyscanner-flight --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=exotic_harmonium/skyscanner-flight",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/XbV76JLNV6b37n1e6/builds/UvhgrLMaDYacY1B9B/openapi.json
