From 44f9ef0d9caa0d9cce06635f6c0c7e045fef2796 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Fri, 3 Jul 2026 10:23:41 +0900 Subject: =?UTF-8?q?Add=20scrapers=20for=20=E6=96=B0=E5=AE=BFMARZ=20and=20?= =?UTF-8?q?=E6=9D=B1=E9=AB=98=E5=86=86=E5=AF=BAU.F.O.CLUB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/scrapers/ufoclub-higashikoenji.ts | 133 ++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 app/scrapers/ufoclub-higashikoenji.ts (limited to 'app/scrapers/ufoclub-higashikoenji.ts') 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構造: + *
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; + }); + }, +}; -- cgit v1.2.3