diff options
Diffstat (limited to 'app/scrapers/flat-nishiogikubo.ts')
| -rw-r--r-- | app/scrapers/flat-nishiogikubo.ts | 142 |
1 files changed, 130 insertions, 12 deletions
diff --git a/app/scrapers/flat-nishiogikubo.ts b/app/scrapers/flat-nishiogikubo.ts index 03cc70c..da6752f 100644 --- a/app/scrapers/flat-nishiogikubo.ts +++ b/app/scrapers/flat-nishiogikubo.ts @@ -1,17 +1,20 @@ /** * FLAT 西荻窪 — https://www.flat.rinky.info/schedule * - * ⚠️ Wix サイトのためクライアントサイド JS レンダリング。 - * 静的 fetch ではイベントデータを取得できない。 + * Wix イベントカレンダー。JS レンダリングが必要なため Playwright を使用。 * - * 代替案: - * - Playwright/Puppeteer でヘッドレスブラウザを使用 - * - Wix Events API (要サイトオーナーによる API キー発行) + * DOM 構造: + * [data-hook="calendar-cell-<UTC ISO>"] ← 各日付セル + * .WPczEB → 開始時刻 + * .ExCBIq → イベントタイトル + * aria-label が "イベントなし" のセルはスキップ * - * 現在は空配列を返す(エラーにはしない)。 + * 月ナビ: calendar-date-picker-button を開いて datepicker-right-arrow で翌月へ。 */ +import type { Page } from "playwright"; import type { Scraper, VenueMeta } from "./base"; import type { EventInput } from "~/lib/db.server"; +import { getBrowser } from "~/lib/playwright.server"; export const venue: VenueMeta = { id: "flat-nishiogikubo", @@ -20,14 +23,129 @@ export const venue: VenueMeta = { area: "西荻窪", }; +const SCHEDULE_URL = "https://www.flat.rinky.info/schedule"; + +async function extractMonthEvents(page: Page): Promise<EventInput[]> { + const events: EventInput[] = []; + const cells = await page.locator('[data-hook^="calendar-cell-"]').all(); + + for (const cell of cells) { + const ariaLabel = (await cell.getAttribute("aria-label")) ?? ""; + if (ariaLabel.includes("イベントなし")) continue; + + const dataHook = (await cell.getAttribute("data-hook")) ?? ""; + const isoStr = dataHook.replace("calendar-cell-", ""); + if (!isoStr) continue; + + // UTC timestamp → JST date (UTC+9) + const utcMs = new Date(isoStr).getTime(); + if (isNaN(utcMs)) continue; + const jstDate = new Date(utcMs + 9 * 3_600_000).toISOString().slice(0, 10); + + const timeLocs = cell.locator(".WPczEB"); + const titleLocs = cell.locator(".ExCBIq"); + const titleCount = await titleLocs.count(); + const timeCount = await timeLocs.count(); + + for (let i = 0; i < titleCount; i++) { + const title = (await titleLocs.nth(i).innerText()).trim(); + if (!title) continue; + const time = i < timeCount + ? (await timeLocs.nth(i).innerText()).trim() + : null; + + events.push({ + venue_id: venue.id, + title, + date: jstDate, + start_time: time || null, + source_url: SCHEDULE_URL, + }); + } + } + + return events; +} + +async function navigateToMonth(page: Page, targetYYYYMM: string): Promise<void> { + const [targetYear, targetMonth] = targetYYYYMM.split("-").map(Number); + + // Open the date picker + await page.click('[data-hook="calendar-date-picker-button"]'); + await page.waitForTimeout(500); + + // Click next-month arrow until we reach the target month + for (let attempt = 0; attempt < 6; attempt++) { + const monthText = await page.locator('[data-hook="datepicker-month-dropdown-button"]').innerText(); + const yearText = await page.locator('[data-hook="datepicker-year-dropdown-button"]').innerText(); + + const currentYear = parseInt(yearText); + const months: Record<string, number> = { + "1月": 1, "2月": 2, "3月": 3, "4月": 4, "5月": 5, "6月": 6, + "7月": 7, "8月": 8, "9月": 9, "10月": 10, "11月": 11, "12月": 12, + }; + const currentMonth = months[monthText.trim()] ?? 0; + + if (currentYear === targetYear && currentMonth === targetMonth) break; + + const diff = (targetYear * 12 + targetMonth) - (currentYear * 12 + currentMonth); + if (diff > 0) { + await page.click('[data-hook="datepicker-right-arrow"]'); + } else { + await page.click('[data-hook="datepicker-left-arrow"]'); + } + await page.waitForTimeout(300); + } + + // Click any date in the mini-calendar that belongs to the target month + const allDays = await page.locator('[role="dialog"] button, [data-hook="datepicker-right-arrow"] ~ * button').all(); + // Simpler: find a button with aria-label matching target year/month + const targetPrefix = `${targetYear}年${targetMonth}月`; + const dayBtns = await page.locator(`button[aria-label*="${targetPrefix}"]`).all(); + if (dayBtns.length > 0) { + await dayBtns[0].click(); + } else { + // Fallback: press Escape to close picker + await page.keyboard.press("Escape"); + } + await page.waitForTimeout(2000); +} + export const scraper: Scraper = { venue, async scrape(): Promise<EventInput[]> { - // Wix renders events with JavaScript; static fetch returns an empty calendar. - // TODO: Replace with a headless browser implementation (e.g. Playwright). - throw new Error( - "FLAT 西荻窪 は Wix サイトのため JS レンダリングが必要です。" + - "ヘッドレスブラウザ(Playwright 等)への移行が必要です。" - ); + const browser = await getBrowser(); + const page = await browser.newPage(); + + try { + await page.goto(SCHEDULE_URL, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + await page.waitForTimeout(5_000); + + const events: EventInput[] = []; + + // Current month events + events.push(...(await extractMonthEvents(page))); + + // Navigate to next month for 35-day window coverage + const now = new Date(); + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const nextYYYYMM = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth() + 1).padStart(2, "0")}`; + await navigateToMonth(page, nextYYYYMM); + events.push(...(await extractMonthEvents(page))); + + // Deduplicate by date + title + const seen = new Set<string>(); + return events.filter((e) => { + const key = `${e.date}|${e.title}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } finally { + await page.close(); + } }, }; |
