UNPKG

rsshub

Version:
215 lines (213 loc) 8.63 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 { a as parseCondition, d as parseWard, f as parseYmd, h as tsuboUnit, i as parseArea, m as summarize, n as clean, o as parseHeavyFood, 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/abc-tenpo/property.ts const HOST = "https://www.abc-tenpo.com"; const DETAIL_CONCURRENCY = 2; const PAGE_SIZE = 20; /** `pref[]` values of the search form (Kanto edition). */ const PREFECTURES = [ { slug: "tokyo", code: "13", label: "東京都" }, { slug: "kanagawa", code: "14", label: "神奈川県" }, { slug: "saitama", code: "11", label: "埼玉県" }, { slug: "chiba", code: "12", label: "千葉県" } ]; /** * List page (`/property/search?sort=1`, 20 per page, server-rendered): card article.c-property-box, * headline .c-property-box-footer__lead (fallback .c-property-box-header__title = station), link a.c-property-box__anchor, * 所在地 .c-property-box-header__area '神奈川県鎌倉市 / 駅から徒歩4分', dl rows dt.c-property-box-table__title → dd: * 賃料 '23.2万円', 初期費用 '268.0万円', 面積 '6.71坪 / 22.18㎡', 階層 '1階' | '地下1階' | '1階~2階一括', 最寄り駅 'JR横須賀線 鎌倉駅 徒歩4分', * 引渡状態 'スケルトン' | '居抜き', 現業態 'ダイニングバー'; tags .c-tag-property__item (狭小, 居抜き, 重飲食可 …), .c-property-box-main__tag--new * 保証金 / 礼金 / 造作譲渡料 are members-only on the site. */ const parseList = (html) => { const $ = load(html); return $("article.c-property-box").toArray().map((el) => { const $el = $(el); const href = $el.find("a.c-property-box__anchor").attr("href"); const id = href?.match(/\/view\/(\d+)/)?.[1]; const title = clean($el.find(".c-property-box-footer__lead").text()) ?? clean($el.find(".c-property-box-header__title").text()); if (!href || !id || !title) return null; const cell = (label) => { const dt = $el.find("dt.c-property-box-table__title").toArray().find((d) => clean($(d).text()) === label); return dt ? clean($(dt).next("dd").text()) : null; }; const raw = { station_title: clean($el.find(".c-property-box-header__title").text()), area_header: clean($el.find(".c-property-box-header__area").text()), rent: cell("賃料"), initial_cost: cell("初期費用"), area: cell("面積"), floor: cell("階層"), station: cell("最寄り駅"), condition: cell("引渡状態"), prev_business: cell("現業態") }; const tags = [...$el.find(".c-tag-property__item").toArray().map((t) => clean($(t).text())), $el.find(".c-property-box-main__tag--new").length > 0 ? "NEW" : null].filter((t) => t !== null); const [line, station] = (raw.station ?? "").split(" ", 2); const address = clean(raw.area_header?.split("/", 1)[0])?.replaceAll(" ", "") ?? null; const rentJpy = parseJpy(raw.rent); const { tsubo, area_m2 } = parseArea(raw.area); const image = $el.find(".c-property-box-main__img img[src^=\"/img/bukken/\"]").first().attr("src"); return { title, link: new URL(href, HOST).href, image: image ? new URL(image, HOST).href : void 0, extra: { source: "abc-tenpo", listing_id: id, rent_jpy: rentJpy, tsubo, area_m2, tsubo_unit_jpy: tsuboUnit(rentJpy, tsubo), floor: normalizeFloor(raw.floor?.replaceAll("地下", "B") ?? null), station: clean(station)?.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(raw.condition), prev_business: raw.prev_business === "その他" ? null : raw.prev_business, heavy_food_ok: tags.includes("重飲食可") ? true : null, business_limit: null, listed_at: null, ward: parseWard(address), address_hint: address, tags, raw } }; }).filter((c) => c !== null); }; /** Detail page (`/property/view/{id}`): 物件情報 dl.c-table-a__def dt → dd; 情報更新日 in .p-property-updated-date__def. */ const parseDetail = (html) => { const $ = load(html); const cell = (label) => { const dt = $("dl.c-table-a__def dt").toArray().find((d) => clean($(d).text()) === label); return dt ? clean($(dt).next("dd").text()) : null; }; return { updated_at: clean($(".p-property-updated-date__def dd").first().text()), business_limit: cell("業種制限"), food_condition: cell("飲食条件"), structure: cell("構造"), note: cell("備考") }; }; const mergeDetail = (base, d) => { const limitParts = [d.business_limit === null || /^(?:無し|なし|-)$/.test(d.business_limit) ? null : d.business_limit, d.food_condition === null ? null : `飲食条件: ${d.food_condition}`].filter((p) => p !== null); return { ...base, heavy_food_ok: base.heavy_food_ok ?? parseHeavyFood(d.food_condition), business_limit: limitParts.length > 0 ? limitParts.join(" / ") : null, listed_at: parseYmd(d.updated_at), raw: { ...base.raw, updated_at: d.updated_at, business_limit: d.business_limit, food_condition: d.food_condition, structure: d.structure, 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(`abc-tenpo: 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 = `${HOST}/property/search?sort=1${prefecture ? `&pref%5B%5D=${prefecture.code}` : ""}`; 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, pubDate: extra.listed_at === null ? void 0 : timezone(parseDate(extra.listed_at, "YYYY-MM-DD"), 9), description: summarize(extra), image: card.image, _extra: extra }; }), { concurrency: DETAIL_CONCURRENCY }); return { title: `ABC店舗 新着物件${prefecture ? ` (${prefecture.label})` : ""}`, link: listUrl, language: "ja", item: items }; }; const route = { path: "/property/:pref?", name: "新着物件", url: "www.abc-tenpo.com", maintainers: ["pseudoyu"], handler, example: "/abc-tenpo/property/tokyo", parameters: { pref: { description: "Prefecture slug or JIS X 0401 code; omit for all of 東京・神奈川・千葉・埼玉", options: PREFECTURES.map((p) => ({ value: p.slug, label: `${p.label} (${p.code})` })) } }, description: `Listings on ABC 店舗 sorted by 新着順 (first page, 20 listings). Each item's \`_extra\` carries the structured listing fields (賃料,坪,階,最寄駅,引渡状態,現業態,業種制限,飲食条件,情報更新日,…) parsed from the list and detail pages; unknown values are \`null\`. 保証金,礼金 and 造作譲渡料 are members-only on the site and therefore always \`null\`; the item date is the site's 情報更新日. | 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: [ "www.abc-tenpo.com/property/search", "www.abc-tenpo.com/feature/new_arrival", "www.abc-tenpo.com/" ], target: "/property" }] }; //#endregion export { handler, route };