UNPKG

rsshub

Version:
306 lines (304 loc) 12.2 kB
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 };