rsshub
Version:
Make RSS Great Again!
306 lines (304 loc) • 12.2 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 { load } from "cheerio";
import pMap from "p-map";
//#region lib/routes/inuki-ichiba/utils.ts
const TSUBO_M2 = 3.30579;
/** Collapse whitespace from cheerio `.text()`; empty → null. */
const clean = (text) => {
const s = (text ?? "").replaceAll(/\s+/g, " ").trim();
return s === "" ? null : s;
};
const isUnknown = (text) => /^(?:[--―—]|ご?相談|要相談|応相談|未定|非公開)?$/.test(text);
/** '352,000円(税込)' → 352000;'160万円' → 1600000;'無償譲渡' → 0;'相談' / '' → null。 */
const parseJpy = (text) => {
if (text === null) return null;
const s = text.replaceAll(/[,,\s]/g, "");
if (/無償|なし|無し/.test(s)) return 0;
if (isUnknown(s)) return null;
if (!/[円万億]/.test(s) && !/^\d+(?:\.\d+)?$/.test(s)) return null;
const oku = s.match(/^(\d+(?:\.\d+)?)億(?:(\d+(?:\.\d+)?)万)?/);
if (oku) return Math.round(Number(oku[1]) * 1e8 + (oku[2] ? Number(oku[2]) * 1e4 : 0));
const m = s.match(/\d+(?:\.\d+)?/);
if (!m) return null;
const n = Number(m[0]);
return Math.round(s.includes("万") ? n * 1e4 : n);
};
const round2 = (n) => Math.round(n * 100) / 100;
/** '16.55坪 (54.74㎡)' / '70㎡' → { tsubo, area_m2 }, the missing side converted at 3.30579. */
const parseArea = (text) => {
if (text === null) return {
tsubo: null,
area_m2: null
};
const s = text.replaceAll(/[,,]/g, "");
const tsubo = s.match(/(\d+(?:\.\d+)?)\s*坪/)?.[1];
const m2 = s.match(/(\d+(?:\.\d+)?)\s*(?:㎡|m²|m2|平米)/)?.[1];
return {
tsubo: tsubo ? Number(tsubo) : m2 ? round2(Number(m2) / TSUBO_M2) : null,
area_m2: m2 ? Number(m2) : tsubo ? round2(Number(tsubo) * TSUBO_M2) : null
};
};
/** 'N ヶ月 / ヵ月 / か月 / カ月' → N;'なし' → 0;'相談' / '' → null。 */
const parseMonths = (text) => {
if (text === null) return null;
const s = text.replaceAll(/\s/g, "");
if (/なし|無し/.test(s)) return 0;
const m = s.match(/(\d+(?:\.\d+)?)[ヶヵかカケ]月/);
return m?.[1] ? Number(m[1]) : null;
};
/** '徒歩6分' → 6。 */
const parseWalkMin = (text) => {
const m = text?.match(/(\d+)\s*分/);
return m?.[1] ? Number(m[1]) : null;
};
/** '1F' → '1F','-1F' → 'B1F','1F~2F' → '1F〜2F'。 */
const normalizeFloor = (text) => {
const tokens = text?.match(/(?:B|-)?\d+/gi);
if (!tokens) return null;
return tokens.map((t) => /^(?:B|-)/i.test(t) ? `B${t.slice(1)}F` : `${t}F`).join("〜");
};
/** '台東区浅草橋5丁目' → '台東区';'藤沢市鵠沼石上1丁目' → '藤沢市'。 */
const parseWard = (address) => {
if (address === null) return null;
const s = address.replace(/^(?:東京都|北海道|(?:京都|大阪)府|\S{2,3}県)/, "");
for (const re of [
/^(.+?区)/,
/^(.+?市)/,
/^(.+?郡)/,
/^(.+?(?:町|村))/
]) {
const m = s.match(re);
if (m?.[1]) return m[1];
}
return null;
};
/** 物件タイプ:居抜き → inuki;スケルトン → skeleton;otherwise null. */
const parseCondition = (text) => {
if (text === null) return null;
if (/居抜き|居抜/.test(text)) return "inuki";
if (text.includes("スケルトン")) return "skeleton";
return null;
};
/** 飲食条件:'重飲食可' → true;'重飲食不可' / '飲食不可' → false;plain '飲食可' → null. */
const parseHeavyFood = (text) => {
if (text === null) return null;
if (/重飲食(?:・[^\s/]+)?\s*(?:不可|NG|×)|飲食\s*不可/.test(text)) return false;
if (/重飲食\s*(?:可|OK|○)/.test(text)) return true;
return null;
};
/** 賃料 ÷ 坪 (the site does not give 坪単価). */
const tsuboUnit = (rentJpy, tsubo) => rentJpy !== null && tsubo !== null && tsubo > 0 ? Math.round(rentJpy / tsubo) : null;
/** Structured one-line summary used as the item description. */
const summarize = (x) => [
x.rent_jpy === null ? null : `賃料 ${x.rent_jpy.toLocaleString("ja-JP")}円`,
x.tsubo === null ? null : `${x.tsubo}坪`,
x.floor,
x.station === null ? null : `${x.station}駅 徒歩${x.walk_min ?? "?"}分`,
x.condition === "inuki" ? "居抜き" : x.condition === "skeleton" ? "スケルトン" : null,
x.prev_business === null ? null : `現況 ${x.prev_business}`,
x.fixtures_transfer_jpy === null ? null : `造作 ${x.fixtures_transfer_jpy === 0 ? "無償" : `${x.fixtures_transfer_jpy.toLocaleString("ja-JP")}円`}`,
x.ward
].filter((p) => p !== null).join(" / ");
//#endregion
//#region lib/routes/inuki-ichiba/rent.ts
const HOST = "https://inuki-ichiba.jp";
const DETAIL_CONCURRENCY = 2;
const PAGE_SIZE = 20;
/** 一都三県 — the only prefectures in the site's search form (`location[<code>][prefecture]`). */
const PREFECTURES = [
{
slug: "tokyo",
code: "13",
label: "東京都"
},
{
slug: "kanagawa",
code: "14",
label: "神奈川県"
},
{
slug: "saitama",
code: "11",
label: "埼玉県"
},
{
slug: "chiba",
code: "12",
label: "千葉県"
}
];
/**
* List page (`/sp_rent/1`, `/rent/search-results`) renders a PC and an SP copy; only `.property_list.pc_only` is read.
* card .property_box, title/link h3.title a ('浅草橋駅 | 徒歩6分 | 台東区浅草橋5丁目', href '/rent/21712'),
* rows .detail_box .detail → .head label + .item value:
* 駅 a(路線) a(駅) '徒歩6分' | 賃料 '352,000円(税込)' | 造作価格 '無償譲渡' | '相談' (row absent when none)
* 物件タイプ/現業態 '居抜き / 居酒屋' | 'スケルトン' | エリア a(区/市) a(町) | 階数/面積 '1F / 16.55坪 (54.74㎡)'
* tags .tag_list li (NEW, 値下げ), .picto_list .picto (路面店)
*/
const parseList = (html) => {
const $ = load(html);
return $(".property_list.pc_only .property_box").toArray().map((el) => {
const $el = $(el);
const a = $el.find("h3.title a").first();
const href = a.attr("href");
const title = clean(a.text());
const id = href?.match(/\/rent\/(\d+)/)?.[1];
if (!href || !title || !id) return null;
const rows = /* @__PURE__ */ new Map();
for (const row of $el.find(".detail_box .detail").toArray()) {
const head = clean($(row).find(".head").first().text());
if (head) rows.set(head, $(row).find(".item").first());
}
const text = (label) => clean(rows.get(label)?.text());
const links = (label) => (rows.get(label)?.find("a").toArray() ?? []).map((x) => clean($(x).text())).filter((x) => x !== null);
const [line, station] = links("駅");
const [ward, town] = links("エリア");
const [floorText, areaText] = (text("階数/面積") ?? "").split(/\s*\/\s*/, 2);
const typeText = text("物件タイプ/現業態");
const [typeKind, typeBusiness] = (typeText ?? "").split(/\s*\/\s*/, 2);
const raw = {
station: text("駅"),
rent: text("賃料"),
fixtures: text("造作価格"),
type: typeText,
area_row: text("エリア"),
floor_area: text("階数/面積")
};
const tags = [...$el.find(".tag_list li").toArray(), ...$el.find(".picto_list .picto").toArray()].map((t) => clean($(t).text())).filter((t) => t !== null);
const rentJpy = parseJpy(raw.rent);
const { tsubo, area_m2 } = parseArea(clean(areaText));
const extra = {
source: "inuki-ichiba",
listing_id: id,
rent_jpy: rentJpy,
tsubo,
area_m2,
tsubo_unit_jpy: tsuboUnit(rentJpy, tsubo),
floor: normalizeFloor(clean(floorText)),
station: station?.replace(/駅$/, "") ?? null,
line: line ?? null,
walk_min: parseWalkMin(raw.station),
deposit_months: null,
deposit_jpy: null,
key_money_months: null,
fixtures_transfer_jpy: parseJpy(raw.fixtures),
condition: parseCondition(typeKind ?? null),
prev_business: clean(typeBusiness),
heavy_food_ok: null,
business_limit: null,
listed_at: null,
ward: ward ?? parseWard(title.split("|").pop()?.trim() ?? null),
address_hint: ward && town ? `${ward}${town}` : ward ?? null,
tags,
raw
};
return {
title,
link: new URL(href, HOST).href,
extra
};
}).filter((c) => c !== null);
};
/** Detail page (non-member view): `.detail .head` label → sibling `.item`. 物件概要 and the map are members-only. */
const parseDetail = (html) => {
const $ = load(html);
const item = (label) => {
const head = $(".detail .head").toArray().find((el) => clean($(el).text()) === label);
return head ? clean($(head).siblings(".item").first().text()) : null;
};
return {
deposit: item("敷金・保証金"),
fixtures: item("造作代金"),
food_condition: item("飲食条件"),
type: item("物件タイプ/現業態")
};
};
/** 敷金・保証金 is either an amount ('160万円') or months ('3ヶ月'); they land in deposit_jpy / deposit_months respectively. */
const mergeDetail = (base, d) => ({
...base,
deposit_months: parseMonths(d.deposit),
deposit_jpy: parseJpy(d.deposit),
fixtures_transfer_jpy: base.fixtures_transfer_jpy ?? parseJpy(d.fixtures),
heavy_food_ok: parseHeavyFood(d.food_condition),
business_limit: d.food_condition,
raw: {
...base.raw,
deposit: d.deposit,
fixtures_detail: d.fixtures,
food_condition: d.food_condition,
type_detail: d.type
}
});
/** A failed detail page (e.g. delisted 404) 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(`inuki-ichiba: detail fetch failed for ${card.link}: ${String(error)}`);
return {
...card.extra,
raw: {
...card.extra.raw,
detail_error: String(error)
}
};
}
};
const handler = async (ctx) => {
const pref = ctx.req.param("pref");
const prefecture = pref === void 0 ? void 0 : PREFECTURES.find((p) => p.slug === pref || p.code === pref);
if (pref !== void 0 && !prefecture) throw new Error(`Unknown prefecture "${pref}", expected one of ${PREFECTURES.map((p) => p.slug).join(", ")}`);
const limit = Math.min(ctx.req.query("limit") ? Number(ctx.req.query("limit")) : PAGE_SIZE, PAGE_SIZE);
const listUrl = prefecture ? `${HOST}/rent/search-results?location%5B${prefecture.code}%5D%5Bprefecture%5D=${prefecture.code}&sort=id` : `${HOST}/sp_rent/1`;
const cards = parseList(await rofetch(listUrl)).slice(0, limit);
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: summarize(extra),
_extra: extra
};
}), { concurrency: DETAIL_CONCURRENCY });
return {
title: `居抜き市場 新着物件${prefecture ? ` (${prefecture.label})` : ""}`,
link: listUrl,
language: "ja",
item: items
};
};
const route = {
path: "/rent/:pref?",
name: "新着物件",
url: "inuki-ichiba.jp",
maintainers: ["pseudoyu"],
handler,
example: "/inuki-ichiba/rent/tokyo",
parameters: { pref: {
description: "都道府県 slug or JIS X 0401 code; omit for the site-wide 新着物件 page (一都三県 mixed)",
options: PREFECTURES.map((p) => ({
value: p.slug,
label: `${p.label} (${p.code})`
}))
} },
description: `New listings on 居抜き市場,20 per page (first page only). With a prefecture the search results are sorted by 新着順;without one the site's 新着物件 page is used. 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\`.
| Query | Description | Default |
| ------- | ---------------------------------------------------------------------------- | ------- |
| \`limit\` | Number of listings to process (detail pages are fetched per listing), max 20 | 20 |`,
categories: ["other"],
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportRadar: true
},
radar: [{
source: ["inuki-ichiba.jp/sp_rent/1", "inuki-ichiba.jp/"],
target: "/rent"
}]
};
//#endregion
export { handler, route };