|
| 1 | +<p align="center"><a href="https://apify.com/DevilScrapes/tori-finland-cars"><img src="assets/banner.svg" alt="Tori.fi Marketplace Scraper (Finland) — Python example" width="100%"></a></p> |
| 2 | + |
| 3 | +# Tori.fi Scraper in Python: Finnish marketplace ads to pandas, JSON and CSV |
| 4 | + |
| 5 | +<p align="center"> |
| 6 | +<a href="https://apify.com/DevilScrapes/tori-finland-cars"><img alt="Run on Apify" src="https://img.shields.io/badge/Run%20on-Apify-DC2626?logo=apify&logoColor=white"></a> |
| 7 | +<img alt="Python" src="https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white"> |
| 8 | +<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-0A0606"></a> |
| 9 | +<a href="https://github.com/devilscrapes/tori-fi-scraper-python/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/devilscrapes/tori-fi-scraper-python/actions/workflows/ci.yml/badge.svg"></a> |
| 10 | +</p> |
| 11 | + |
| 12 | +Here is a compact Python client for the **[Tori.fi Marketplace Scraper](https://apify.com/DevilScrapes/tori-finland-cars)** on Apify. You describe what to look for, a word like `polkupyörä`, a category code or a Tori search link, and you get back a tidy table of current Finnish for-sale ads: the heading, the asking price as an integer in euros, where the item sits, whether a private person or a dealer is selling it, plus photos and, if you ask, the full ad text. Apify does the collecting in the cloud; your side is a short script and a pair of files, `results.json` and `results.csv`, that load straight into a DataFrame. The repository name says Tori, the Actor's slug says cars, and the truth is in between: it covers the general for-sale marketplace, vehicle parts included, but not the separate whole-car site. We name the devil in the details up front. |
| 13 | + |
| 14 | +> **Actor:** [Tori.fi Marketplace Scraper (Finland)](https://apify.com/DevilScrapes/tori-finland-cars) · **Publisher:** [Devil Scrapes](https://apify.com/DevilScrapes) · **Also in JavaScript:** [tori-fi-scraper-javascript](https://github.com/devilscrapes/tori-fi-scraper-javascript) |
| 15 | +
|
| 16 | +## 🔥 What you get |
| 17 | + |
| 18 | +- **Three ways to aim a run**: a free-text `query`, a Tori `category` code such as `0.93`, or a `searchUrl` copied from a search you already filtered on the site. When the URL is set, it takes priority over the other two. |
| 19 | +- **Prices you can do maths on**: `price` is a whole number of euros and `currency` is `EUR`, so there is no string parsing before you sort or average. |
| 20 | +- **Coordinates as well as place names**: each ad can carry `latitude` and `longitude` next to the free-text `location` and its `region`, which makes radius filters and maps a few lines of code. |
| 21 | +- **Private or dealer, flagged per row**: `seller_type` reads `private` or `dealer`, inferred from the ad's own flags. |
| 22 | +- **Optional second pass per ad**: with `enrichDetails` on, the Actor also opens each listing page to add `description`, `item_condition` and `category_path`; switch it off for a quicker, lighter run. |
| 23 | +- **Every photo link**: `photo_urls` is a list holding all of the ad's images, not only the thumbnail. |
| 24 | +- **Retries are its problem, not yours**: when Tori answers with a timeout or a server error, the Actor backs off and tries again rather than handing you a hole in the data. |
| 25 | + |
| 26 | +## 🎯 Use cases |
| 27 | + |
| 28 | +### Measure asking prices by region |
| 29 | + |
| 30 | +Collect a few hundred ads for one product type, group by `region` and look at the median `price`. Comparing Uusimaa with the rest of the country is a single `groupby` once the rows are in pandas. |
| 31 | + |
| 32 | +### Keep a dated price history from cron |
| 33 | + |
| 34 | +Run `python main.py --out data/$(date +%F)` every morning and you build a folder of daily snapshots. Join them on `listing_id` later to see which ads vanished or got cheaper. |
| 35 | + |
| 36 | +### Study the dealer share of a category |
| 37 | + |
| 38 | +Point the Actor at a category code and count `seller_type` values. A notebook cell that splits private sellers from dealers shows how professionalised a corner of the Finnish second-hand market is, and whether dealers ask more for comparable items. |
| 39 | + |
| 40 | +### Map supply without a geocoder |
| 41 | + |
| 42 | +Because rows can include coordinates, you can drop them into a scatter plot or a GeoPandas frame directly. You see where supply concentrates without sending a single place name to a geocoding service. |
| 43 | + |
| 44 | +## ⚡ Quick start |
| 45 | + |
| 46 | +You need an Apify account and an API token from [Console → Settings → Integrations](https://console.apify.com/settings/integrations). Apify's free plan includes monthly usage credit; this Actor's event prices are listed under Pricing below. |
| 47 | + |
| 48 | +```bash |
| 49 | +git clone https://github.com/devilscrapes/tori-fi-scraper-python.git |
| 50 | +cd tori-fi-scraper-python |
| 51 | +python -m venv .venv && source .venv/bin/activate |
| 52 | +pip install -r requirements.txt |
| 53 | +export APIFY_TOKEN=your_token_here |
| 54 | +python main.py # uses input.json |
| 55 | +python main.py my-input.json --out data --max-charge 2 |
| 56 | +``` |
| 57 | + |
| 58 | +The script starts the [Tori.fi Marketplace Scraper (Finland)](https://apify.com/DevilScrapes/tori-finland-cars), waits for it to finish, prints a link to the run in Apify Console and saves every result to `results.json` and `results.csv`. It exits with code 1 and the run's status message if the run fails. |
| 59 | + |
| 60 | +## 🧩 The core call |
| 61 | + |
| 62 | +Stripped to the bone, a run is a handful of lines with the official `apify-client` package. `call()` blocks until the Actor finishes, and `iterate_items()` walks the dataset page by page so memory stays flat on bigger pulls. |
| 63 | + |
| 64 | +```python |
| 65 | +from apify_client import ApifyClient |
| 66 | + |
| 67 | +client = ApifyClient("YOUR_APIFY_TOKEN") |
| 68 | +run = client.actor("DevilScrapes/tori-finland-cars").call(run_input={'query': 'polkupyörä', 'maxResults': 10, 'enrichDetails': True, 'proxyConfiguration': {'useApifyProxy': True, 'apifyProxyGroups': ['RESIDENTIAL']}}) |
| 69 | +for item in client.dataset(run.default_dataset_id).iterate_items(): |
| 70 | + print(item) |
| 71 | +``` |
| 72 | + |
| 73 | +## 📥 Input |
| 74 | + |
| 75 | +`input.json` goes to the Actor exactly as written. This one searches for bicycles (`polkupyörä`), stops after 10 ads and keeps detail enrichment on, so a first run costs about $0.22 and still shows every field filled. Every input field is optional; replace `query` with `category` or `searchUrl` when you want a different slice, and raise `maxResults` (up to 1,000) for real analysis. |
| 76 | + |
| 77 | +```json |
| 78 | +{ |
| 79 | + "query": "polkupyörä", |
| 80 | + "maxResults": 10, |
| 81 | + "enrichDetails": true, |
| 82 | + "proxyConfiguration": { |
| 83 | + "useApifyProxy": true, |
| 84 | + "apifyProxyGroups": [ |
| 85 | + "RESIDENTIAL" |
| 86 | + ] |
| 87 | + } |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +| Field | Type | Required | Default | Allowed values | |
| 92 | +|---|---|---|---|---| |
| 93 | +| `searchUrl` | string | no | — | — | |
| 94 | +| `query` | string | no | — | — | |
| 95 | +| `category` | string | no | — | — | |
| 96 | +| `maxResults` | integer | no | `50` | — | |
| 97 | +| `enrichDetails` | boolean | no | `true` | — | |
| 98 | +| `proxyConfiguration` | object | no | `{"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]}` | — | |
| 99 | + |
| 100 | +## 📤 Output |
| 101 | + |
| 102 | +A row stands for one ad. Only the ID, the link, the heading and the scrape time are guaranteed; everything else can be null when the seller left it out. `description`, `item_condition` and `category_path` stay empty unless `enrichDetails` was on, and `trade_type` keeps Tori's Finnish wording, for example `Myydään`. |
| 103 | + |
| 104 | +| Field | Type | Always present | |
| 105 | +|---|---|---| |
| 106 | +| `listing_id` | string | yes | |
| 107 | +| `listing_url` | string | yes | |
| 108 | +| `title` | string | yes | |
| 109 | +| `price` | integer or null | no | |
| 110 | +| `currency` | string or null | no | |
| 111 | +| `brand` | string or null | no | |
| 112 | +| `trade_type` | string or null | no | |
| 113 | +| `item_condition` | string or null | no | |
| 114 | +| `category_path` | string or null | no | |
| 115 | +| `location` | string or null | no | |
| 116 | +| `region` | string or null | no | |
| 117 | +| `latitude` | number or null | no | |
| 118 | +| `longitude` | number or null | no | |
| 119 | +| `seller_type` | string or null | no | |
| 120 | +| `photo_urls` | array | no | |
| 121 | +| `description` | string or null | no | |
| 122 | +| `posted_date` | string or null | no | |
| 123 | +| `scraped_at` | string | yes | |
| 124 | + |
| 125 | +## 💰 Pricing |
| 126 | + |
| 127 | +| Event | Price (USD) | |
| 128 | +|---|---| |
| 129 | +| Actor start | $0.2 | |
| 130 | +| result-row | $0.002 | |
| 131 | + |
| 132 | +Prices as configured on 2026-10-16. The [Actor page](https://apify.com/DevilScrapes/tori-finland-cars) always shows the current price. Runs are billed to the Apify account that owns the token. Use `--max-charge` to cap what a single run can cost. |
| 133 | + |
| 134 | +## 🛠️ Working with the data in Python |
| 135 | + |
| 136 | +A first look at a pull, using pandas (`pip install pandas`): per region, count the ads, take the median euro price and work out what fraction comes from dealers. The table prints to the terminal and is also written to `regions.csv`. |
| 137 | + |
| 138 | +```python |
| 139 | +import json |
| 140 | + |
| 141 | +import pandas as pd |
| 142 | + |
| 143 | +ads = pd.DataFrame(json.load(open("results.json", encoding="utf-8"))) |
| 144 | +priced = ads.dropna(subset=["price", "region"]) |
| 145 | + |
| 146 | +summary = ( |
| 147 | + priced.groupby("region") |
| 148 | + .agg( |
| 149 | + ads=("listing_id", "count"), |
| 150 | + median_eur=("price", "median"), |
| 151 | + dealer_share=("seller_type", lambda s: (s == "dealer").mean()), |
| 152 | + ) |
| 153 | + .sort_values("ads", ascending=False) |
| 154 | +) |
| 155 | +summary.to_csv("regions.csv") |
| 156 | +print(summary.round(2).to_string()) |
| 157 | +``` |
| 158 | + |
| 159 | +## ❓ FAQ |
| 160 | + |
| 161 | +### Can I get whole used cars from autot.tori.fi? |
| 162 | + |
| 163 | +No. Passenger cars live on a separate Tori app that this Actor does not read. What you do get from the vehicle side is the parts and accessories category, `0.90`. If whole cars in Finland are the goal, this is the wrong tool, and it is better to know that before the first run. |
| 164 | + |
| 165 | +### Why is `description` empty in some rows? |
| 166 | + |
| 167 | +The full text, the condition and the category breadcrumb come from each ad's own page. They are filled only when `enrichDetails` is true. Turning it off roughly halves the work per run, which is a sensible trade when you only need prices and places. |
| 168 | + |
| 169 | +### How many ads can one run return? |
| 170 | + |
| 171 | +`maxResults` accepts up to 1,000. Tori itself pages results at about 53 ads per page and stops at 50 pages, so a very broad search can hit Tori's ceiling first. Split such searches by category or keyword. |
| 172 | + |
| 173 | +### Does this need a Tori account or an official API key? |
| 174 | + |
| 175 | +Neither. Tori.fi does not offer a public API, and the Actor needs no Tori login. The only credential in this repository is your Apify token. |
| 176 | + |
| 177 | +## 🧯 Troubleshooting |
| 178 | + |
| 179 | +- **`Set APIFY_TOKEN first`**: export the token in the same shell, or put it in a local `.env` file your shell loads. Never commit it. |
| 180 | +- **Run finished with `FAILED` or `ABORTED`**: open the printed Console link; the run log says why. Input validation errors name the field. |
| 181 | +- **Fewer rows than expected**: a `--max-charge` cap also limits how many results can be charged. Raise the cap or lower the limits in your input. |
| 182 | +- **Something looks wrong in the data**: open an issue on the [Actor page](https://apify.com/DevilScrapes/tori-finland-cars) so the maintainers see it. |
| 183 | + |
| 184 | +## 🔗 More from Devil Scrapes |
| 185 | + |
| 186 | +- [Tori.fi Marketplace Scraper (Finland) on Apify Store](https://apify.com/DevilScrapes/tori-finland-cars) |
| 187 | +- [JavaScript version of this example](https://github.com/devilscrapes/tori-fi-scraper-javascript) |
| 188 | +- [All Devil Scrapes Actors](https://apify.com/DevilScrapes) |
| 189 | +- [Apify web scraping cookbook](https://github.com/devilscrapes/apify-web-scraping-cookbook) |
| 190 | + |
| 191 | +Related Actors: |
| 192 | + |
| 193 | +- [Blocket Car Scraper (Sweden)](https://apify.com/DevilScrapes/blocket-sweden-cars) — the Swedish neighbour, focused on used cars with make, model, mileage and SEK prices. |
| 194 | +- [Kijiji Listings Scraper](https://apify.com/DevilScrapes/kijiji-listings-scraper) — general classifieds search for Canada, useful as a second recommerce market. |
| 195 | +- [Wallapop Scraper](https://apify.com/DevilScrapes/wallapop-scraper) — second-hand listings from Spain by keyword. |
| 196 | + |
| 197 | +--- |
| 198 | + |
| 199 | +MIT licensed example code. The Actor itself runs on [Apify](https://apify.com/DevilScrapes/tori-finland-cars) and is maintained by [Devil Scrapes](https://apify.com/DevilScrapes). Not affiliated with Tori.fi or Schibsted. |
0 commit comments