summaryrefslogtreecommitdiff
path: root/app/scrapers/ufoclub-higashikoenji.ts
diff options
context:
space:
mode:
Diffstat (limited to 'app/scrapers/ufoclub-higashikoenji.ts')
-rw-r--r--app/scrapers/ufoclub-higashikoenji.ts133
1 files changed, 133 insertions, 0 deletions
diff --git a/app/scrapers/ufoclub-higashikoenji.ts b/app/scrapers/ufoclub-higashikoenji.ts
new file mode 100644
index 0000000..85749f9
--- /dev/null
+++ b/app/scrapers/ufoclub-higashikoenji.ts
@@ -0,0 +1,133 @@
+/**
+ * 東高円寺 U.F.O.CLUB — https://www.ufoclub.jp/schedule/
+ *
+ * WordPress テーマ。fetch に Mozilla UA が必要(curl は 2 バイトを返す)。
+ *
+ * DOM構造:
+ * <div class="month">2026年6月</div> ← 年月の取得元
+ * <table class="schedule-table"><tbody>
+ * <tr>
+ * <th>1</th> ← 日
+ * <td>MON</td> ← 曜日(スキップ)
+ * <td>
+ * <strong>【タイトル】</strong><br/>
+ * /LIVE; artist1, artist2
+ * /DJ; dj1
+ * </td>
+ * <td class="td_open">OPEN 18:30~<br/>START 19:00~</td>
+ * <td class="td_charge">ADV.¥2800(+1D)<br/>DOOR.¥3300(+1D)</td>
+ * </tr>
+ * </tbody></table>
+ *
+ * 月ナビ: /schedule/ (当月) / /schedule/next-month (翌月)
+ */
+import * as cheerio from "cheerio";
+import type { Scraper, VenueMeta } from "./base";
+import type { EventInput } from "~/lib/db.server";
+
+export const venue: VenueMeta = {
+ id: "ufoclub-higashikoenji",
+ name: "東高円寺 U.F.O.CLUB",
+ url: "https://www.ufoclub.jp",
+ area: "東高円寺",
+ capacity: 120,
+};
+
+const UA =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
+
+async function scrapePage(url: string): Promise<EventInput[]> {
+ const res = await fetch(url, { headers: { "User-Agent": UA } });
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
+ const $ = cheerio.load(await res.text());
+ const events: EventInput[] = [];
+
+ // Parse year and month from <div class="month">2026年6月</div>
+ const monthText = $("div.month").first().text().trim();
+ const monthMatch = monthText.match(/(\d{4})年(\d{1,2})月/);
+ if (!monthMatch) return events;
+ const year = parseInt(monthMatch[1], 10);
+ const month = parseInt(monthMatch[2], 10);
+
+ $("table.schedule-table tbody tr").each((_, row) => {
+ const $row = $(row);
+
+ // Day from <th>
+ const day = parseInt($row.find("th").text().trim(), 10);
+ if (isNaN(day)) return;
+ const date = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
+
+ // Event td is the 2nd <td> (index 1; 0 = day-of-week)
+ const $eventTd = $row.find("td").eq(1);
+ const title = $eventTd.find("strong").text().replace(/^【|】$/g, "").trim();
+ if (!title) return;
+
+ // Artists: text after </strong>, lines starting with /LIVE; /DJ; /MC;
+ const $tdClone = $eventTd.clone();
+ $tdClone.find("strong").remove();
+ $tdClone.find("br").replaceWith("\n");
+ const afterTitle = $tdClone.text();
+ const artistParts: string[] = [];
+ for (const line of afterTitle.split("\n")) {
+ const t = line.replace(/\s+/g, " ").trim();
+ const liveM = t.match(/^\/LIVE;\s*(.+)/i);
+ const djM = t.match(/^\/DJ;\s*(.+)/i);
+ const mcM = t.match(/^\/MC;\s*(.+)/i);
+ if (liveM) artistParts.push(liveM[1].trim());
+ else if (djM) artistParts.push(`DJ: ${djM[1].trim()}`);
+ else if (mcM) artistParts.push(`MC: ${mcM[1].trim()}`);
+ }
+ const artist = artistParts.length > 0 ? artistParts.join(" / ") : null;
+
+ // Open / Start time from td.td_open
+ const $openTd = $row.find("td.td_open").clone();
+ $openTd.find("br").replaceWith("\n");
+ const openText = $openTd.text();
+ const openMatch = openText.match(/OPEN\s+(\d{1,2}:\d{2})/i);
+ const startMatch = openText.match(/START\s+(\d{1,2}:\d{2})/i);
+
+ // Price from td.td_charge (first two lines: ADV / DOOR)
+ const $chargeTd = $row.find("td.td_charge").clone();
+ $chargeTd.find("br").replaceWith("\n");
+ const chargeLines = $chargeTd
+ .text()
+ .split("\n")
+ .map((l) => l.trim())
+ .filter(Boolean);
+ const price =
+ chargeLines.length >= 2
+ ? `${chargeLines[0]} / ${chargeLines[1]}`
+ : (chargeLines[0] ?? null);
+
+ events.push({
+ venue_id: venue.id,
+ title,
+ artist,
+ date,
+ open_time: openMatch?.[1] ?? null,
+ start_time: startMatch?.[1] ?? null,
+ price,
+ source_url: url,
+ });
+ });
+
+ return events;
+}
+
+export const scraper: Scraper = {
+ venue,
+ async scrape(): Promise<EventInput[]> {
+ const [thisMonth, nextMonth] = await Promise.all([
+ scrapePage("https://www.ufoclub.jp/schedule/"),
+ scrapePage("https://www.ufoclub.jp/schedule/next-month"),
+ ]);
+
+ const seen = new Set<string>();
+ return [...thisMonth, ...nextMonth].filter((e) => {
+ const key = `${e.date}|${e.title}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ },
+};