diff options
Diffstat (limited to 'app/scrapers/ufoclub-higashikoenji.ts')
| -rw-r--r-- | app/scrapers/ufoclub-higashikoenji.ts | 133 |
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; + }); + }, +}; |
