/** * FLAT 西荻窪 — https://www.flat.rinky.info/schedule * * Wix イベントカレンダー。JS レンダリングが必要なため Playwright を使用。 * * DOM 構造: * [data-hook="calendar-cell-"] ← 各日付セル * .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", name: "FLAT 西荻窪", url: "https://www.flat.rinky.info", area: "西荻窪", capacity: 80, }; const SCHEDULE_URL = "https://www.flat.rinky.info/schedule"; async function extractMonthEvents(page: Page): Promise { 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 { 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 = { "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 { 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(); 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(); } }, };