/**
* 東高円寺 U.F.O.CLUB — https://www.ufoclub.jp/schedule/
*
* WordPress テーマ。fetch に Mozilla UA が必要(curl は 2 バイトを返す)。
*
* DOM構造:
*
{
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;
});
},
};
|