Suumo.jp is one website with one URL scheme, but rental listings (chintai) and sale listings (bukken) are rendered by what are effectively two different applications glued together under the same domain. Same brand, same header, completely different card markup, price units, and even where the agency's name lives in the page.
Quick answer
A Suumo search-result URL containing /jj/bukken/ichiran/ is a sale (buy-mode) listing page; anything else is treated as rent-mode. The two modes need separate parsers because the card selectors, price formatting, and station-line text format are all different β and because on buy-mode pages the listing agency's name isn't in the DOM at all, it's inside an inline JavaScript variable you have to regex out of the raw HTML. A parser written against one mode and pointed at the other returns wrong data or nothing, not an error.
def parse_search_page(html: str, source_url: str) -> list[ResultRow]:
sel = Selector(text=html)
scraped_at = datetime.now(UTC).isoformat()
if BUY_PATH_SEGMENT in source_url:
return _parse_buy_page(sel, scraped_at)
return _parse_rent_page(sel, scraped_at)
Why does the agency name disappear on sale listings?
Because on a buy-mode detail page, it was never rendered as visible text β it's a JavaScript variable assignment sitting in a <script> block: kaisha_nm : "...". A selector-based parser (CSS or XPath) will never find it, because there's no element to select. It has to be pulled with a regex against the raw HTML response body, not the parsed DOM tree:
_RE_KAISHA_NM = re.compile(r'kaisha_nm\s*:\s*"([^"]*)"')
def extract_buy_detail_page(html: str) -> BuyDetailInfo:
name_match = _RE_KAISHA_NM.search(html)
agency_name = (name_match.group(1).strip() or None) if name_match else None
if agency_name is None:
logger.warning("kaisha_nm not found on buy detail page.")
...
Rent-mode detail pages are the opposite case β the agency name is a normal DOM node (.advance_actioncard_reserve-sales-title), no JS-parsing needed. Two listing types on one site, two entirely different extraction strategies for the same field.
Why does one price format use "δΈε" and another just "ε"?
Japanese real-estate listings customarily quote larger figures in units of δΈε (10,000 yen) β "6.5δΈε" means Β₯65,000 β while smaller fees are sometimes quoted in plain ε. A parser that regexes for digits and ignores the unit will be off by a factor of 10,000 on rent figures. We match both patterns explicitly and normalize everything to integer yen:
MAN_YEN = 10_000
_RE_MAN_YEN = re.compile(r"([\d,]+(?:\.\d+)?)δΈε")
_RE_YEN = re.compile(r"([\d,]+)ε")
def _parse_yen(raw: str) -> int | None:
raw = raw.strip()
if not raw or raw in ("-", "γͺγ"):
return None
m = _RE_MAN_YEN.search(raw)
if m:
return round(float(m.group(1).replace(",", "")) * MAN_YEN)
m = _RE_YEN.search(raw)
if m:
return int(m.group(1).replace(",", ""))
return None
There's a second wrinkle on buy-mode detail pages: some fees appear as compound values like 1δΈ6000ε β one man-yen digit and a yen remainder in the same string β which needs its own regex (_RE_COMPOUND_YEN) before falling back to the standard parser.
Why do deposit and key money come back null on sale listings?
Because they don't apply to a purchase and the buy-mode list page genuinely doesn't render those fields β they're rent-market concepts (shikikin/reikin). Rather than guess or leave the fields out of the schema inconsistently, buy-mode rows set them explicitly to None so the output schema stays identical across both listing modes:
return ResultRow(
listing_id=id_match.group(1),
listing_mode="buy",
management_fee_yen=None,
deposit_yen=None,
key_money_yen=None,
floor=None,
...
)
Why does the nearest-station text need two regex patterns?
Because rent and buy cards format the same information β line, station, walk time β with different punctuation. Rent cards write <line>/<station>ι§
ζ©4ε; buy cards write <line>γ<station>γεΎζ©4ε and never append the ι§
(station) suffix, so it has to be added back on for consistent output:
_RE_STATION_SLASH = re.compile(r"/(.+?ι§
)")
_RE_STATION_BRACKET = re.compile(r"(.+?)γ(.+?)γ")
def _parse_station_line(text: str) -> StationInfo | None:
slash_match = _RE_STATION_SLASH.search(text)
if slash_match:
return StationInfo(line=..., station=slash_match.group(1), walk_minutes=...)
bracket_match = _RE_STATION_BRACKET.search(text)
if bracket_match:
return StationInfo(line=..., station=f"{bracket_match.group(2)}ι§
", walk_minutes=...)
return None
If neither pattern matches, the line is dropped rather than emitted with a blank station name β StationInfo.station isn't nullable, so a row we can't parse cleanly doesn't get emitted with garbage in it.
Is scraping Suumo.jp legal?
Suumo publishes these listings on its public search pages with no login required β the same pages a browser visitor sees. We still treat the target as one that can rate-limit: curl-cffi impersonates real Chrome and Firefox TLS sessions, retries 408 / 429 / 5xx with exponential backoff up to 5 attempts honoring Retry-After, and rotates residential proxy sessions on every block, with optional JP-region targeting for geo-gated pages.
FAQ
How do I point the Actor at buy listings instead of rentals?
Pass a Suumo search-result URL from the buy (bukken) search UI β the Actor detects /jj/bukken/ichiran/ in the URL and switches parsers automatically.
What's the fixed "δΈε€γγ³γ·γ§γ³" (used condo) property type on buy rows?
The buy-mode search endpoint this Actor targets returns only used condominium listings β there's no per-card type label to parse, so it's set as a constant rather than guessed.
Why would a single listing row get skipped?
Any row a card-level parser can't resolve a listing ID or URL for is dropped and logged; a single bad row never fails the whole page β every other listing keeps parsing.
Does the Actor fetch listing detail pages by default?
No β fetchListingDetails is opt-in. Enable it to enrich rows with agency name (and, for buy-mode, management fee and floor) at an additional per-row cost; leave it off for search-page-only speed.
Packaged and ready to run: Suumo.jp Japan Real Estate Scraper β pass any Suumo search URL, get one typed row per listing with price, deposit, key money, layout, area, station, and walk time normalized to integer yen and consistent fields across rent and buy. $0.002 per result row (about $2 for 1,000 listings), plus $0.005 per row only if you opt into detail-page enrichment.
We do the dirty work so your dataset stays clean. π













