Skip to content

Commit e54c5c1

Browse files
committed
Tori.fi scraper for Python
0 parents  commit e54c5c1

12 files changed

Lines changed: 555 additions & 0 deletions

File tree

‎.env.example‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Your Apify API token: https://console.apify.com/settings/integrations
2+
APIFY_TOKEN=

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
python-version: ["3.11", "3.13"]
16+
steps:
17+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
19+
with:
20+
python-version: ${{ matrix.python-version }}
21+
- run: pip install -r requirements-dev.txt
22+
- run: ruff check .
23+
- run: ruff format --check .
24+
- run: pytest -q

‎.gitignore‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.env
2+
results.json
3+
results.csv
4+
__pycache__/
5+
.venv/
6+
.pytest_cache/
7+
.ruff_cache/

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Devil Scrapes
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md‎

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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.

‎assets/banner.svg‎

Lines changed: 32 additions & 0 deletions
Loading

‎input.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"query": "polkupyörä",
3+
"maxResults": 10,
4+
"enrichDetails": true,
5+
"proxyConfiguration": {
6+
"useApifyProxy": true,
7+
"apifyProxyGroups": [
8+
"RESIDENTIAL"
9+
]
10+
}
11+
}

‎main.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Run an Apify Actor and save its results as JSON and CSV.
2+
3+
Actor: Tori.fi Marketplace Scraper (Finland)
4+
5+
Actor page: https://apify.com/DevilScrapes/tori-finland-cars
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import json
12+
import os
13+
import sys
14+
from decimal import Decimal
15+
from pathlib import Path
16+
17+
from apify_client import ApifyClient
18+
19+
ACTOR_ID = "DevilScrapes/tori-finland-cars"
20+
ACTOR_TITLE = "Tori.fi Marketplace Scraper (Finland)"
21+
TERMINAL_OK = "SUCCEEDED"
22+
23+
24+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
25+
parser = argparse.ArgumentParser(description=f"Run the {ACTOR_TITLE} on Apify.")
26+
parser.add_argument("input", nargs="?", default="input.json", help="Actor input JSON file (default: input.json)")
27+
parser.add_argument("--out", default=".", help="Folder for results.json and results.csv (default: here)")
28+
parser.add_argument("--max-charge", type=Decimal, help="Stop the run once it has cost this many USD")
29+
parser.add_argument("--memory", type=int, help="Run memory in MB (default: the Actor's own setting)")
30+
return parser.parse_args(argv)
31+
32+
33+
def load_input(path: str) -> dict:
34+
try:
35+
data = json.loads(Path(path).read_text(encoding="utf-8"))
36+
except FileNotFoundError:
37+
sys.exit(f"Input file not found: {path}")
38+
except json.JSONDecodeError as error:
39+
sys.exit(f"Input file {path} is not valid JSON: {error}")
40+
if not isinstance(data, dict):
41+
sys.exit(f"Input file {path} must contain a JSON object")
42+
return data
43+
44+
45+
def main(argv: list[str] | None = None) -> int:
46+
args = parse_args(argv)
47+
token = os.environ.get("APIFY_TOKEN")
48+
if not token:
49+
sys.exit("Set APIFY_TOKEN first. Get a token at https://console.apify.com/settings/integrations")
50+
51+
run_input = load_input(args.input)
52+
client = ApifyClient(token, api_url=os.environ.get("APIFY_API_URL", "https://api.apify.com"))
53+
54+
print(f"Starting {ACTOR_ID} ...")
55+
run = client.actor(ACTOR_ID).call(
56+
run_input=run_input,
57+
max_total_charge_usd=args.max_charge,
58+
memory_mbytes=args.memory,
59+
)
60+
if run is None:
61+
sys.exit("The run could not be started.")
62+
63+
print(f"Run {run.id} finished: {run.status}")
64+
print(f"Details: https://console.apify.com/actors/runs/{run.id}")
65+
if run.status != TERMINAL_OK:
66+
print(run.status_message or "No status message.", file=sys.stderr)
67+
return 1
68+
69+
dataset = client.dataset(run.default_dataset_id)
70+
out = Path(args.out)
71+
out.mkdir(parents=True, exist_ok=True)
72+
items = json.loads(dataset.get_items_as_bytes(item_format="json", clean=True))
73+
(out / "results.json").write_text(json.dumps(items, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
74+
(out / "results.csv").write_bytes(dataset.get_items_as_bytes(item_format="csv", clean=True))
75+
76+
print(f"Saved {len(items)} items to {out / 'results.json'} and {out / 'results.csv'}")
77+
return 0
78+
79+
80+
if __name__ == "__main__":
81+
sys.exit(main())

0 commit comments

Comments
 (0)