Passo Scraper — Korean Used Motorcycle Listings (파쏘)
Pricing
$4.00 / 1,000 catalog listing scrapeds
Passo Scraper — Korean Used Motorcycle Listings (파쏘)
Scrape Passo (파쏘), Korea's long-running used-motorcycle marketplace (~11,000 live listings): brand, model, year, KRW price, mileage, region, primary photo and listing date as clean JSON. Full active catalogue in one pass. Public pages only, never authenticated, no private-seller contact data.
Pricing
$4.00 / 1,000 catalog listing scrapeds
Rating
0.0
(0)
Developer
Seok June Park
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
0
Monthly active users
a day ago
Last modified
Categories
Features
Share
Passo Scraper — Korean Used Motorcycles (파쏘) 🏍️
Scrape live used-motorcycle listings from Passo (파쏘) — Korea's long-running used-bike marketplace, ~11,000 active listings (11,013–11,016 over the course of 2026-10-22; the board moves during the day) — as clean, structured JSON. Brand, model, year, KRW price, mileage, region, listing date and the primary photo for every bike on the board.
This is catalog mode: the listing-row data plus the primary image, which is what you need to render a browsable inventory. The full per-bike record (engine capacity, type, colour, accident and tuning status, description, dealer, all photos) lives on Passo's detail page and is a separate, on-demand mode — see Field availability for exactly which field sits where.
What it does
Passo's used-bike catalogue is a 2012-era AJAX application. The page you see in a browser
is a 353 kB shell that contains zero listings; its own front end fetches them by
POSTing to index.php and parsing an XML document. This actor speaks that endpoint
directly — no headless browser, no HTML scraping, one request per 50 bikes.
- The whole active catalogue (~11,000 bikes), or
- One manufacturer (150 makes, Korean or English name), or
- A custom slice using Passo's own search syntax (year range, region, price band).
Sorted by newest, price, model year or mileage.
Input
| Field | Type | Description |
|---|---|---|
maxItems | integer | Rows to collect. Default 100, max 20000. Set 12000 for the whole active catalogue. |
maker | string | Optional brand filter — 혼다/Honda, BMW, 할리데이비슨/Harley-Davidson, 야마하/Yamaha, SYM, KTM, 존테스/Zontes… or a raw Passo maker_idx. Blank = all 150 makes. |
sort | string | newest (default), price_asc, price_desc, year_desc, year_asc, mileage_asc, mileage_desc. |
includeSoldAndReserved | boolean | Default false = active listings only (~11,000). true returns Passo's full historical archive (~863,000 rows), which is not live inventory. |
searchExpression | string | Advanced. Extra terms in Passo's own DSL, e.g. @out_year:2018~2024#, @area:?서울#, @sale_price:300~800#. |
requestDelaySecs | integer | Pause between sequential requests. Default 2, never below 1.5 whatever you set. |
proxyConfiguration | object | Defaults to Apify Proxy, RESIDENTIAL, country KR. See Proxy. |
{ "maxItems": 12000, "sort": "newest" }
{ "maker": "Harley-Davidson", "searchExpression": "@out_year:2018~2026#", "maxItems": 500 }
Run it locally
cd actors/passo-scrapernpm installmkdir -p storage/key_value_stores/defaultprintf '%s' '{"maxItems":100,"sort":"newest","includeSoldAndReserved":false,"requestDelaySecs":2}' \> storage/key_value_stores/default/INPUT.jsonnode src/main.js
Rows land in storage/datasets/default/. Omit proxyConfiguration from the input, as
above, when you are already on a Korean network — the actor logs a warning and goes
direct. storage/ is run state and is not committed.
Output
One dataset record per listing. price_krw is full Korean won (Passo publishes 만원 /
10,000-won units; this actor converts). mileage_km is kilometres. brand_en gives the
official English brand name (Honda, Harley-Davidson, Daelim, Hyosung…, null for small
makes with no recognised English name) and region_province_en the province in English.
{"source": "passo","listing_id": "1141679","scraped_at": "2026-10-22T10:33:54+09:00","url": "http://bike.passo.co.kr/bike/index.php?part=cybershop&path=cybershop&mode=home#HIS_ID:search=&page=0&order_by=&search_mode=country&idx=1141679&num=0","brand": "SYM","brand_en": "SYM","model": "조이맥스 Z 125 플러스","year": 2022,"mileage_km": 40437,"price_krw": 1490000,"region": "경남/창원시 성산구","primary_image_url": "http://file.passo.co.kr/dataimg/bike_images/11417/1141679/MOTOR_gid_1141679_s_1.img?0","published_date": "2026-10-22","engine_cc": null, "motorcycle_type": null, "color": null,"accident_free": null, "tuning": null, "description": null,"seller_name": null, "photo_urls": null,"title": "출퇴근,배달,가성비 모든면에서 우수한 조이맥스제트플러스125","price_man": 149,"price_negotiable": true,"mileage_value": 40437,"mileage_unit": "Km","region_province": "경남","region_province_en": "Gyeongnam","region_city": "창원시 성산구","listing_status": "on_sale","warranty": false,"view_count": 264,"badge_codes": [6, 7],"badges": ["A/S", "카드"],"product_state_code": "2","sponsored": false,"detail_url": "http://bike.passo.co.kr/bike/index.php?part=cybershop&path=cybershop&mode=process&process=view&idx=1141679&num=1"}
A real 100-listing run is in samples/client-sample.json, with the same rows as samples/client-sample.csv (UTF-8 BOM, opens straight in Excel).
The eight detail-only fields are emitted as explicit nulls on purpose, so your table
has one shape in both modes and a later detail pass is a plain UPDATE rather than a
migration.
Field availability: catalog row vs detail page
Measured on 2026-10-22 against the live list XML and a real detail page
(idx=1141679). "Catalog row" means the field arrives in the same response as everything
else, at no extra request and no extra cost.
| Field you asked for | Where it lives | Evidence |
|---|---|---|
| Passo ID | Catalog row — listing_id | <idx> in every <item>. Passo's own primary key; also the argument to the detail endpoint. |
| Original URL | Catalog row — url (derived) | Passo has no server-rendered detail page for used bikes. The catalogue is one AJAX page whose state lives in the location hash: lib/dhtmlHistory.js reads #HIS_ID:…&idx=<id>&num=0 on load and opens that listing. Passo generates exactly these links itself. Built from <idx>, so it costs nothing. |
| Brand | Catalog row — brand | <maker>. 100/100 populated in the sample. |
| Model | Catalog row — model | <model>. 100/100. |
| Year | Catalog row — year | <c_out_year>. 100/100. ⚠ The sibling <out_year> is a constant placeholder — it reads 2000 on every row of every page, including bikes whose real year is 2025. |
| Engine capacity (cc) | Detail page only | Not present in the list XML under any name. Detail page shows 배기량 125 cc. |
| Mileage | Catalog row — mileage_km | <distance> + <distance_unit>. 100/100. Unit is Km on 99/100 and ml (miles) on 1/100; the actor converts miles and also returns the raw value and unit. |
| Price (KRW) | Catalog row — price_krw | <c_sale_price>, in 만원, converted. 100/100. ⚠ The sibling <sale_price> is a placeholder reading 100 on every row. |
| Region | Catalog row — region | <area>, e.g. 경남/창원시 성산구. Also split into region_province / region_city. 100/100. |
| Motorcycle type | Detail page only | Detail page shows 유형 스쿠터 > 빅스쿠터. The list XML has no type field. (You can filter the catalogue by type with searchExpression @_type_idx:…#, but the chosen type is not echoed back on the row.) |
| Colour | Detail page only | Detail page 색상 흰색. |
| Accident status | Detail page only | Detail page 사고유무 무. Not derivable from the row: the hotmark badges are commerce flags, not condition flags — the three in use decode to A/S, 카드 (card accepted) and 할부 (instalments), read off the badge GIFs. |
| Tuning status | Detail page only | Detail page 튜닝체크 무, plus an 18-item breakdown (배기/흡기/엔진/새시/브레이크/서스펜션 튜닝, 크롬·카본 파츠, 커스텀 도색 …). |
| Description | Detail page only | Detail page 매물상세설명, free text. ⚠ The list XML has a <comment> element — it is not a description. It is the comment-count badge (" " or "[1]"). |
| Seller / dealer | Detail page only — and partly off-limits | Detail page 판매자 정보: 이름 (e.g. a shop name such as 모터링크), 핸드폰 010-…, 전화번호, 통화가능시간, 판매지역. Shop name is business identity and is in scope for detail mode. The two phone numbers are not emitted, ever — a 010- mobile does not reliably distinguish a dealer from a private seller. See Compliance. |
| Publication date | Catalog row — published_date | <update_date>, YY-MM-DD, normalised to ISO. 100/100. Note this is the listing update date, which is what Passo sorts "newest" by — a refreshed old listing resurfaces. |
| All photos | Detail page only | The row carries exactly one image, <thumb> → primary_image_url (100/100). The detail page carries the full set, labelled 우측면 / 좌측면 / 후면 / 핸들 … |
Summary for your schema: 9 of the 17 wished-for fields are on the catalog row (ID, URL, brand, model, year, mileage, price, region, publication date) plus the primary image. The remaining 7 — engine cc, type, colour, accident, tuning, description, seller — plus the full photo set are detail-page only. Nothing you asked for is unavailable anywhere, though the seller field will arrive as a business name without phone numbers.
Fields the row gives you for free and you did not ask for: title (the seller's own
headline), price_negotiable (가격절충), listing_status, warranty, view_count,
badges, sponsored.
Removal detection (the two-strike rule)
The actor is stateless — each run knows nothing of the last one — so the absence counter belongs on your side. What it gives you is the two things that rule needs:
listing_id— Passo's ownidx. Immutable: it is the row's primary key on Passo's side, it appears in the deep link and it is the detail endpoint's argument. It does not change when the seller edits the price, swaps photos or rewrites the text. Use it as your primary key; do not key on the URL, which embeds it anyway.scraped_at— ISO-8601 in KST (UTC+9), stamped per row.
Intended usage:
- Run a full pass (
maxItems: 12000, nomaker, nosearchExpression). Check the run succeeded and returned a plausible count before you trust its absences — a failed or truncated pass must not count as a strike. last_seen = scraped_atfor every id in the pass;absent_passes = 0.- For ids in your table but not in the pass,
absent_passes += 1. - Retire at
absent_passes >= 2. Reset to0the moment an id reappears — Passo listings do come back.
One pass is ~221 requests and takes about 8 minutes at the default delay, so daily is comfortable and a listing is retired after roughly 48 hours of absence.
Pagination: the whole catalogue is reachable in one pass
Deep-pagination caps are the usual reason a "full catalogue" promise fails (KB Chachacha, for instance, stops at 10,000 items per query). Passo has no such cap. Verified 2026-10-22:
| Probe | Result |
|---|---|
@state:0#, page 0 | <count>11015</count>, <last>220</last> — 221 pages of 50 |
@state:0#, page 150 | 50 rows, dated 26-06 |
@state:0#, page 220 (the last) | 15 rows → 220 × 50 + 15 = 11,015 = count exactly |
@state:0#, page 221 | 0 rows — clean end, no error |
| unfiltered, page 17255 of 17,256 | 35 rows → 17255 × 50 + 35 = 862,785 = count exactly, in 7.8 s |
The server serves its true last page even at offset 862,750. So no facet split is
needed: a single unfiltered pass with includeSoldAndReserved: false reaches all
~11,000 active listings in 221 sequential requests.
If Passo ever does impose a cap, the split that works is by manufacturer
(maker, 150 codes, largest bucket is a few thousand) with @out_year:a~b# as a second
axis inside any bucket that overflows. The facet machinery is already in the input.
One caveat, not a cap: the <plus> block of ~10 sponsored listings is injected into
every page and rotates. The actor de-duplicates by listing_id, flags them
sponsored: true and emits them after the page's real results, so a maxItems
cut-off never discards a genuine listing in favour of a promoted one. 10 of the 100 rows
in the sample are sponsored — they are real active listings, just paid-for placements, and
excluding them would under-report the catalogue.
Traffic and cost
All measured on 2026-10-22 with Accept-Encoding: gzip, deflate sent explicitly and curl
not decoding, so these are true on-the-wire bytes.
The origin does not compress. No Content-Encoding header is returned even when gzip
is offered — Server: Apache/2.4.54 (Unix) PHP/5.2.17, Transfer-Encoding: chunked. A
metered proxy therefore bills the full uncompressed size. There is no header, parameter or
endpoint variant that turns compression on.
| Response | Wire body | Rows | Content-Encoding |
|---|---|---|---|
Catalog page 0 (@state:0#) | 59,084 B | 50 goods + 10 sponsored | none |
| Catalog page 1 | 58,088 B / 57,866 B (two fetches) | 50 goods + 10 sponsored | none |
| Catalog page 150 | 58,027 B | 50 goods + 10 sponsored | none |
| Catalog page 220 (last, partial) | 24,663 B | 15 goods | none |
Detail page (process=view&idx=1089124) | 60,350 B | 1 listing | none |
Response headers are ~190 B; a request is ~525 B (headers + the 125-byte form body).
Catalog economics. Mean full page 58,587 B. A full active pass = 220 full pages + page 220 + overhead = 13.07 MB for ~11,015 listings = 1,187 bytes per listing.
| Per 1,000 catalog listings | 1.19 MB → $0.0095 at $8/GB |
| Full 12,000-listing pass | 14.24 MB → $0.114 |
| Revenue on that pass | $48.00 |
| Proxy as a share of revenue | 0.24 % |
That is below the optimistic end of the $0.96–$14.40 range the deal was being sized against — by roughly 8×. The catalog tier is not proxy-constrained.
Detail economics are a different story. 60,350 B per listing, versus 1,187 B for a catalog row — 51× more traffic per record for a tier priced at only 2× the catalog rate.
| Per detail record | 61,065 B → $0.000489 |
| Per 1,000 detail records | 61.1 MB → $0.489 |
| Revenue per 1,000 | $8.00 |
| Proxy as a share of revenue | 6.1 % |
Still healthy, but it is the real cost centre and it scales with visitor traffic rather than with a nightly job. Two notes for whoever prices detail mode: the 60 kB is mostly page furniture (card-instalment tables, a shopping-mall sidebar, safe-trade boilerplate) around a few hundred bytes of actual record, and there is no leaner variant — Passo's own front end fetches this same blob.
Is there a more compact endpoint? Checked deliberately, and the answer is: this already is the compact one.
- The XML POST endpoint is not one of several options — it is the only source. The
server-rendered page at
mode=homeis 352,877 B and contains zero listings; they are injected by JavaScript from this same XML. So HTML scraping would cost 353 kB plus the XML, not instead of it. Choosing the XML endpoint is already the ~6× win per page, and far more for a whole pass. - No JSON variant exists. The site's
lib/ajax.jssupports"xml"and"text"only, and the catalogue path (process_cybershop.js→get_list) uses"xml". - No page-size parameter is exposed anywhere in the front-end code (
list_count,page_size,limit,rows,per_page— none appear). 50 rows per page is fixed. - The one visible waste is the
<plus>sponsored block: 9,726 B of every 58 kB page, 16.6 %, or 2.15 MB across a full pass. Droppingmainck201508=homefrom the body was tested and returns a byte-identical response, so it cannot be suppressed.
This task's own footprint. 34 requests to passo.co.kr in total (recon + evidence
archive + the sample run), 1.29 MiB of response bodies, well inside the 120-request
budget, all sequential and ≥1.5 s apart. The sample run itself was 2 requests and
117,172 B for 100 listings — 1,172 B per listing, matching the projection.
Proxy: use Korea
Passo is reported to geo-restrict the catalogue to Korean IPs and to answer a non-Korean
IP with HTTP 200 and an empty result rather than an error — the same failure mode that
silently zeroed kbchachacha-scraper's platform runs on 2026-10-08. The default is
therefore Apify Proxy, RESIDENTIAL, apifyProxyCountry: "KR", and the actor forces KR
onto any Apify Proxy config that omits a country.
Honest caveat: we could not reproduce the geo-gate ourselves, because our own egress is
Korean. The default is a precaution taken on that report, not on a measurement of ours. It
costs nothing if the report turns out to be wrong, and the first-page diagnostic tells you
which way it went: a zero <count> means the server genuinely matched nothing, while a
large <count> with no rows means the parser drifted — neither of which a proxy fixes.
Note also that bike.passo.co.kr refuses TCP 443 (verified: connection refused). It
is plain HTTP only. https:// URLs for this host do not degrade, they fail to connect.
Compliance
Dated evidence is archived in compliance/ with a fetch log and checksums — Passo's 이용약관 carries no 시행일 and Article 3 ② lets the company amend it at will, so there is no other way to prove later what the terms said today. Read compliance/README.md before changing anything below.
bike.passo.co.kronly. It publishesAllow: /.m.passo.co.krpublishesDisallow: /and shares an IP with it (116.127.120.244), so the exclusion is a thrown error in code (FORBIDDEN_HOSTS,assertAllowedHost()), not a convention.- Never authenticates. No login, no cookie, no session, no credentials. Article 11's
duties — including ① 8, profit-making use without consent — are addressed to 회원,
defined in Article 2 ② as a party who has concluded a use agreement. An anonymous reader
of
Allow: /pages is not one. Logging in would make the actor a 회원 and destroy that position retroactively. - No private-seller personal data. The catalog row carries no seller field at all. The detail page carries 핸드폰 / 전화번호; when detail mode is built those must be dropped in the parser.
- Deliberately slow. Sequential only, ≥1.5 s between requests, no concurrency control. The origin runs PHP 5.2.17 (EOL 2011) and Article 11 ① 6 makes obstructing the company's operations a breach, so rate is a terms matter, not etiquette.
- No image binaries.
primary_image_urlis a link to Passo's CDN. Nothing is downloaded, re-hosted or re-served.
Known limitations
published_dateis the update date. Passo exposes<update_date>and sorts "newest" by it, so a refreshed old listing resurfaces at the top. There is no separate original-registration date on the row.sponsoredrows appear on every page. ~10 per page, rotating, de-duplicated by id. They are real active listings; they are just promoted.badgesdecodes three codes. Passo publishes itshotmarkbadges only as unlabelled GIFs with no alt text and no legend. Codes 6 (A/S), 7 (카드) and 12 (할부) were read off the images; every other code is returned as a number inbadge_codesand not guessed at.product_state_codeis passed through uninterpreted. The server emits it (values 1 and 2 observed) but Passo's own front end never reads it and no legend is published.mileage_unitcan beml. Rare (1 of 100) but real;mileage_kmis converted and the raw value and unit are kept alongside.- Truncated responses. The origin occasionally ends a chunked response without the
terminating zero-length chunk. The parser keeps the complete
<item>elements it received and logs a warning rather than discarding a usable page. - No detail mode yet. Everything in the "detail page only" column above is
null.
FAQ
Is it legal to scrape this? This actor reads only public, non-authenticated pages that
Passo's own robots.txt marks Allow: /, collects no personal data, and never logs in.
See Compliance for the full reasoning and the archived evidence. You remain
responsible for what you do with the data.
Why is the URL so ugly? Because Passo has no server-rendered detail page for used
bikes — the catalogue is a single AJAX page whose state lives in the location hash. The
#HIS_ID: form is Passo's own deep-link format, not one we invented; the site ships
anchors in exactly that shape.
How long does a full pass take? 221 requests at the 2-second default ≈ 8 minutes.
What does it cost? $4 per 1,000 catalog rows. A full ~11,000-listing pass is about $44. See PRICING.md.
⭐ Found it useful?
If this Actor saves you time, a short review on its Apify Store page helps other buyers find it. Missing a field or hit a bug? Open an issue on the Issues tab; reports are answered and fixed.