rsshub
Version:
Make RSS Great Again!
211 lines (210 loc) • 7.6 kB
JavaScript
import { t as rofetch } from "./ofetch-U0CdpE2g.mjs";
import { t as logger } from "./logger-DlhSwgkg.mjs";
import { t as cache_default } from "./cache-C1Y-9qDV.mjs";
import { a as parseCondition, d as parseWard, h as tsuboUnit, i as parseArea, m as summarize, n as clean, r as normalizeFloor, s as parseJpy, u as parseWalkMin } from "./utils--J7EU6Hb.mjs";
import { load } from "cheerio";
import pMap from "p-map";
//#region lib/routes/bukenavi/object.ts
const HOST = "https://bukenavi.jp";
const DETAIL_CONCURRENCY = 2;
const REGIONS = [
{
slug: "kanto",
label: "関東"
},
{
slug: "kansai",
label: "関西"
},
{
slug: "tokai",
label: "東海"
}
];
/** JIS X 0401 codes accepted as `prefecture[]`; any two-digit code is passed through. */
const PREFECTURES = {
tokyo: "13",
kanagawa: "14",
saitama: "11",
chiba: "12",
osaka: "27",
kyoto: "26",
hyogo: "28",
aichi: "23"
};
/**
* List page (`/{region}/object/list?wanted=1`, 10 per page, 新着順 by default, server-rendered):
* card .box__property, title .box__property__title, link .box__property__info__to-detail a,
* 路線/駅/徒歩 .box__property__info__station 'JR山手線/目黒駅 (徒歩約 4分)', 階 + 坪 .rent__size '1階、2階 25.7 坪',
* 賃料 .rent__price em '105万円', 坪単価 .rent__price .unit-price '@4.09万円', PR .box__property__info__lead
*/
const parseList = (html, region) => {
const $ = load(html);
return $(".box__property").toArray().map((el) => {
const $el = $(el);
const href = $el.find(".box__property__info__to-detail a").attr("href");
const title = clean($el.find(".box__property__title").text());
const id = href?.match(/\/(\d+)$/)?.[1];
if (!href || !title || !id) return null;
const raw = {
station: clean($el.find(".box__property__info__station").text()),
size: clean($el.find(".rent__size").text()),
rent: clean($el.find(".rent__price em").text()),
tsubo_unit: clean($el.find(".rent__price .unit-price").text()),
lead: clean($el.find(".box__property__info__lead").text())
};
const [line, stationPart] = (raw.station ?? "").split("/", 2);
const tsuboText = raw.size?.match(/\d+(?:\.\d+)?\s*坪/)?.[0] ?? null;
const floorText = tsuboText === null ? null : clean(raw.size?.slice(0, raw.size.indexOf(tsuboText)));
const rentJpy = parseJpy(raw.rent);
const { tsubo, area_m2 } = parseArea(tsuboText);
const image = $el.find(".box__property__left__image img").attr("src");
return {
title,
link: `${HOST}/${region}/object/${id}`,
image: image && !image.includes("/img/") ? image.replace("//storage", "/storage") : void 0,
extra: {
source: "bukenavi",
listing_id: id,
rent_jpy: rentJpy,
tsubo,
area_m2,
tsubo_unit_jpy: parseJpy(raw.tsubo_unit) ?? tsuboUnit(rentJpy, tsubo),
floor: normalizeFloor(floorText),
station: clean(stationPart?.replace(/\(.*$/, ""))?.replace(/駅$/, "") ?? null,
line: clean(line),
walk_min: parseWalkMin(raw.station),
deposit_months: null,
deposit_jpy: null,
key_money_months: null,
fixtures_transfer_jpy: null,
condition: parseCondition(title),
prev_business: null,
heavy_food_ok: null,
business_limit: null,
listed_at: null,
ward: null,
address_hint: null,
tags: [],
raw
}
};
}).filter((c) => c !== null);
};
/** Detail page: `table.box__property__table tr > th + td`; 住所 is town-level for guests. */
const parseDetail = (html) => {
const $ = load(html);
const cell = (label) => {
const th = $("table.box__property__table th").toArray().find((el) => clean($(el).text()) === label);
return th ? clean($(th).next("td").text()) : null;
};
return {
address: cell("住所"),
line: cell("沿線"),
area: cell("面積"),
prev_business: cell("前の業態"),
business_types: cell("業種"),
ng_business: cell("不可業態"),
note: cell("特記事項")
};
};
const mergeDetail = (base, d) => {
const address = clean(d.address?.replace(/※.*$/, "")) ?? null;
const { area_m2 } = parseArea(d.area);
const limitParts = [d.business_types === null ? null : `可: ${d.business_types}`, d.ng_business === null ? null : `不可: ${d.ng_business}`].filter((p) => p !== null);
return {
...base,
area_m2: area_m2 ?? base.area_m2,
line: base.line ?? d.line,
prev_business: d.prev_business,
heavy_food_ok: d.ng_business === null ? null : !/飲食/.test(d.ng_business),
business_limit: limitParts.length > 0 ? limitParts.join(" / ") : null,
ward: parseWard(address),
address_hint: address,
raw: {
...base.raw,
address: d.address,
line: d.line,
area: d.area,
prev_business: d.prev_business,
business_types: d.business_types,
ng_business: d.ng_business,
note: d.note
}
};
};
/** A failed detail page keeps the list fields instead of breaking the feed. */
const enrich = async (card) => {
try {
return mergeDetail(card.extra, parseDetail(await rofetch(card.link)));
} catch (error) {
logger.warn(`bukenavi: detail fetch failed for ${card.link}: ${String(error)}`);
return {
...card.extra,
raw: {
...card.extra.raw,
detail_error: String(error)
}
};
}
};
const handler = async (ctx) => {
const regionSlug = ctx.req.param("region") ?? "kanto";
const region = REGIONS.find((r) => r.slug === regionSlug);
if (!region) throw new Error(`Unknown region "${regionSlug}", expected one of ${REGIONS.map((r) => r.slug).join(", ")}`);
const pref = ctx.req.param("pref");
const prefCode = pref === void 0 ? void 0 : PREFECTURES[pref] ?? pref;
if (prefCode !== void 0 && !/^\d{2}$/.test(prefCode)) throw new Error(`Unknown prefecture "${pref}", expected a slug (${Object.keys(PREFECTURES).join(", ")}) or a two-digit JIS code`);
const listUrl = `${HOST}/${region.slug}/object/list?wanted=1${prefCode === void 0 ? "" : `&prefecture%5B%5D=${prefCode}`}`;
const cards = parseList(await rofetch(listUrl), region.slug);
const items = await pMap(cards, (card) => cache_default.tryGet(card.link, async () => {
const extra = await enrich(card);
return {
title: card.title,
link: card.link,
guid: card.link,
description: [extra.raw.lead, summarize(extra)].filter(Boolean).join(" / "),
image: card.image,
_extra: extra
};
}), { concurrency: DETAIL_CONCURRENCY });
return {
title: `ぶけなび 新着物件 (${region.label}${prefCode === void 0 ? "" : ` ${pref}`})`,
link: listUrl,
language: "ja",
item: items
};
};
const route = {
path: "/object/:region?/:pref?",
name: "新着物件",
url: "bukenavi.jp",
maintainers: ["pseudoyu"],
handler,
example: "/bukenavi/object/kanto/tokyo",
parameters: {
region: {
description: "Region",
default: "kanto",
options: REGIONS.map((r) => ({
value: r.slug,
label: r.label
}))
},
pref: "Prefecture slug (tokyo, kanagawa, saitama, chiba, osaka, kyoto, hyogo, aichi) or two-digit JIS X 0401 code; omit for the whole region"
},
description: `New 居抜き listings on ぶけなび that are currently 募集中,newest first (first page, 10 listings). Each item's \`_extra\` carries the structured listing fields (賃料,坪,坪単価,階,最寄駅,前業態,業種制限,…) parsed from the list and detail pages; unknown values are \`null\`. The site does not publish listing dates, so items have no \`pubDate\`.`,
categories: ["other"],
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportRadar: true
},
radar: [{
source: ["bukenavi.jp/:region/object/list", "bukenavi.jp/:region"],
target: "/object/:region"
}]
};
//#endregion
export { handler, route };