UNPKG

rsshub

Version:
364 lines (362 loc) 11.3 kB
import { t as rofetch } from "./ofetch-U0CdpE2g.mjs"; import { t as logger } from "./logger-DlhSwgkg.mjs"; import { t as parseDate } from "./parse-date-3kcy2Eau.mjs"; import { t as cache_default } from "./cache-C1Y-9qDV.mjs"; import { t as timezone } from "./timezone-UiMFOuvL.mjs"; import { c as parseMonths, d as parseWard, f as parseYmd, h as tsuboUnit, i as parseArea, m as summarize, n as clean, p as sumKnown, 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/tenant-shop/chintai.ts const DETAIL_CONCURRENCY = 2; const PAGE_SIZE = 30; /** `pa` codes of the site; East Japan is served by tenant-shop.com, 愛知 and westward by tenant-shop.jp (data is not mirrored). */ const PREFECTURES = [ { slug: "tokyo", code: "14", label: "東京都", host: "https://www.tenant-shop.com" }, { slug: "kanagawa", code: "15", label: "神奈川県", host: "https://www.tenant-shop.com" }, { slug: "saitama", code: "16", label: "埼玉県", host: "https://www.tenant-shop.com" }, { slug: "chiba", code: "17", label: "千葉県", host: "https://www.tenant-shop.com" }, { slug: "ibaraki", code: "18", label: "茨城県", host: "https://www.tenant-shop.com" }, { slug: "tochigi", code: "19", label: "栃木県", host: "https://www.tenant-shop.com" }, { slug: "gunma", code: "20", label: "群馬県", host: "https://www.tenant-shop.com" }, { slug: "hokkaido", code: "7", label: "北海道", host: "https://www.tenant-shop.com" }, { slug: "miyagi", code: "10", label: "宮城県", host: "https://www.tenant-shop.com" }, { slug: "niigata", code: "21", label: "新潟県", host: "https://www.tenant-shop.com" }, { slug: "nagano", code: "28", label: "長野県", host: "https://www.tenant-shop.com" }, { slug: "aichi", code: "25", label: "愛知県", host: "https://www.tenant-shop.jp" }, { slug: "gifu", code: "26", label: "岐阜県", host: "https://www.tenant-shop.jp" }, { slug: "shizuoka", code: "27", label: "静岡県", host: "https://www.tenant-shop.jp" }, { slug: "osaka", code: "1", label: "大阪府", host: "https://www.tenant-shop.jp" }, { slug: "kyoto", code: "31", label: "京都府", host: "https://www.tenant-shop.jp" }, { slug: "hyogo", code: "32", label: "兵庫県", host: "https://www.tenant-shop.jp" }, { slug: "shiga", code: "5", label: "滋賀県", host: "https://www.tenant-shop.jp" }, { slug: "nara", code: "34", label: "奈良県", host: "https://www.tenant-shop.jp" } ]; /** `f{n}-1` path filters of the site. */ const TYPES = [ { slug: "inuki", code: "f1", label: "居抜き" }, { slug: "food", code: "f2", label: "飲食" }, { slug: "office", code: "f3", label: "オフィス" }, { slug: "retail", code: "f4", label: "物販・サービス" }, { slug: "warehouse", code: "f5", label: "倉庫・工場" }, { slug: "beauty", code: "f6", label: "美容・エステ・医療" }, { slug: "roadside", code: "f7", label: "沿道サービス・借地" }, { slug: "mall", code: "f8", label: "商業施設" } ]; /** * List page (`/chintai_biz/pa-14/shin-1/`, 30 per page, newest first, server-rendered): one `table.result` per card. * title/link div.estatename a ('/detail/e-169996/'), 種別 div.esttype, 賃料 div.price '37.40万(税込)', 坪単価 .smallText '(坪単価:5.80万/坪)', * 面積 div.area_wrap '建物21.33㎡(6.45坪)', icon rows div.add img[src$="/{icon}.png"] with no text label: * kanri 管理費, rei 礼金 '2ヶ月', hoken 保証金 '10ヶ月', shiki 敷金 '5ヶ月', kaiyaku 償却, addr 所在地 '文京区湯島3', * station 最寄駅 '湯島駅(徒歩1分)', chikunen 築年月, floor 階 '1階/9階建'; PR div.cmnt1; NEW img.new_label */ const parseList = (html, host) => { const $ = load(html); return $("table.result").toArray().map((el) => { const $el = $(el); const a = $el.find("div.estatename a").first(); const href = a.attr("href"); const title = clean(a.text()); const id = href?.match(/\/detail\/e-(\d+)/)?.[1]; if (!href || !title || !id) return null; const iconRow = (icon) => clean($el.find(`div.add img[src$="/${icon}.png"]`).first().parent().text()); const raw = { type: clean($el.find("div.esttype").text()), rent: clean($el.find("div.price").text()), tsubo_unit: clean($el.find("div.price").next(".smallText").text()), area: clean($el.find("div.area_wrap").text()), management_fee: iconRow("kanri"), key_money: iconRow("rei"), guarantee: iconRow("hoken"), security: iconRow("shiki"), amortization: iconRow("kaiyaku"), address: iconRow("addr"), station: iconRow("station"), built: iconRow("chikunen"), floor: iconRow("floor"), comment: clean($el.find("div.cmnt1").text()) }; const rentJpy = parseJpy(raw.rent); const { tsubo, area_m2 } = parseArea(raw.area); const stationParts = clean(raw.station?.replace(/[((].*$/, ""))?.split(" ") ?? []; const station = stationParts.length > 0 ? stationParts.at(-1).replace(/駅$/, "") : null; const line = stationParts.length > 1 ? stationParts.slice(0, -1).join(" ") : null; const deposits = [raw.guarantee, raw.security]; const depositMonths = deposits.every((d) => d === null) ? null : sumKnown(parseMonths(raw.guarantee), parseMonths(raw.security)); const depositYen = deposits.filter((d) => d !== null && /[万円]/.test(d)); const image = $el.find("div.photo img").first().attr("src"); return { title, link: new URL(href, host).href, image: image ? new URL(image, host).href : void 0, extra: { source: "tenant-shop", listing_id: id, rent_jpy: rentJpy, tsubo, area_m2, tsubo_unit_jpy: parseJpy(raw.tsubo_unit) ?? tsuboUnit(rentJpy, tsubo), floor: normalizeFloor(clean(raw.floor?.split("/", 1)[0])), station, line, walk_min: parseWalkMin(raw.station), deposit_months: depositMonths, deposit_jpy: depositYen.length === 0 ? null : sumKnown(parseJpy(depositYen[0]), parseJpy(depositYen[1] ?? null)), key_money_months: parseMonths(raw.key_money), fixtures_transfer_jpy: null, condition: null, prev_business: null, heavy_food_ok: null, business_limit: null, listed_at: null, ward: parseWard(raw.address), address_hint: raw.address, tags: $el.find("img.new_label").length > 0 ? ["NEW"] : [], raw } }; }).filter((c) => c !== null); }; /** Detail page (`/detail/e-{id}/`): div.info-block4 table th → td (現況, 物件登録日, 情報更新日); category tags div.facility_list li a. */ const parseDetail = (html) => { const $ = load(html); const cell = (label) => { const th = $("div.info-block4 th").toArray().find((t) => clean($(t).text()) === label); return th ? clean($(th).next("td").text()) : null; }; return { status: cell("現況"), registered_at: cell("物件登録日"), updated_at: cell("情報更新日"), facilities: $("div.facility_list li a").toArray().map((t) => clean($(t).text())).filter((t) => t !== null), remarks: clean($("div.pr_cmnt3").text()) }; }; const mergeDetail = (base, d) => ({ ...base, condition: d.facilities.includes("居抜き") ? "inuki" : base.condition, listed_at: parseYmd(d.registered_at), tags: [...base.tags, ...d.facilities], raw: { ...base.raw, status: d.status, registered_at: d.registered_at, updated_at: d.updated_at, remarks: d.remarks } }); /** 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(`tenant-shop: 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") ?? "tokyo"; const prefecture = PREFECTURES.find((p) => p.slug === pref || p.code === pref); if (!prefecture) throw new Error(`Unknown prefecture "${pref}", expected one of ${PREFECTURES.map((p) => p.slug).join(", ")}`); const typeSlug = ctx.req.param("type"); const type = typeSlug === void 0 ? void 0 : TYPES.find((t) => t.slug === typeSlug); if (typeSlug !== void 0 && !type) throw new Error(`Unknown type "${typeSlug}", expected one of ${TYPES.map((t) => t.slug).join(", ")}`); const limit = Math.min(ctx.req.query("limit") ? Number(ctx.req.query("limit")) : PAGE_SIZE, PAGE_SIZE); const listUrl = `${prefecture.host}/chintai_biz/pa-${prefecture.code}/${type ? `${type.code}-1/` : ""}shin-1/`; const cards = parseList(await rofetch(listUrl), prefecture.host).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, pubDate: extra.listed_at === null ? void 0 : timezone(parseDate(extra.listed_at, "YYYY-MM-DD"), 9), description: [extra.raw.comment, summarize(extra)].filter(Boolean).join(" / "), image: card.image, _extra: extra }; }), { concurrency: DETAIL_CONCURRENCY }); return { title: `テナントショップ 新着物件 (${prefecture.label}${type ? ` ${type.label}` : ""})`, link: listUrl, language: "ja", item: items }; }; const route = { path: "/chintai/:pref?/:type?", name: "新着物件", url: "www.tenant-shop.com", maintainers: ["pseudoyu"], handler, example: "/tenant-shop/chintai/tokyo/food", parameters: { pref: { description: "Prefecture slug or the site's `pa` code", default: "tokyo", options: PREFECTURES.map((p) => ({ value: p.slug, label: `${p.label} (${p.code})` })) }, type: { description: "Property type filter; omit for all types", options: TYPES.map((t) => ({ value: t.slug, label: t.label })) } }, description: `New listings (新着物件) on テナントショップネットワーク for one prefecture, newest first (first page, 30 listings). Each item's \`_extra\` carries the structured listing fields (賃料,坪,坪単価,階,最寄駅,保証金・敷金,礼金,物件登録日,…) parsed from the list and detail pages; unknown values are \`null\`. East Japan prefectures are served by tenant-shop.com and 愛知 and westward by tenant-shop.jp. | Query | Description | Default | | ------- | ---------------------------------------------------------------------------- | ------- | | \`limit\` | Number of listings to process (detail pages are fetched per listing), max 30 | 30 |`, categories: ["other"], features: { requireConfig: false, requirePuppeteer: false, antiCrawler: false, supportRadar: true }, radar: [{ source: ["www.tenant-shop.com/chintai_biz/pa-:pa/shin-1", "www.tenant-shop.com/chintai_biz/pa-:pa"], target: "/chintai/:pa" }, { source: ["www.tenant-shop.jp/chintai_biz/pa-:pa/shin-1", "www.tenant-shop.jp/chintai_biz/pa-:pa"], target: "/chintai/:pa" }] }; //#endregion export { handler, route };