1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
|
/**
* FLAT 西荻窪 — https://www.flat.rinky.info/schedule
*
* Wix イベントカレンダー。JS レンダリングが必要なため Playwright を使用。
*
* 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",
name: "FLAT 西荻窪",
url: "https://www.flat.rinky.info",
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[]> {
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();
}
},
};
|