/** * 東高円寺 U.F.O.CLUB — https://www.ufoclub.jp/schedule/ * * WordPress テーマ。fetch に Mozilla UA が必要(curl は 2 バイトを返す)。 * * DOM構造: *
2026年6月
← 年月の取得元 * * * ← 日 * ← 曜日(スキップ) * * * * *
1MON * 【タイトル】
* /LIVE; artist1, artist2 * /DJ; dj1 *
OPEN 18:30~
START 19:00~
ADV.¥2800(+1D)
DOOR.¥3300(+1D)
* * 月ナビ: /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 { 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
2026年6月
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 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 (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 , 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 { const [thisMonth, nextMonth] = await Promise.all([ scrapePage("https://www.ufoclub.jp/schedule/"), scrapePage("https://www.ufoclub.jp/schedule/next-month"), ]); const seen = new Set(); return [...thisMonth, ...nextMonth].filter((e) => { const key = `${e.date}|${e.title}`; if (seen.has(key)) return false; seen.add(key); return true; }); }, };