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/index.ts | 4 + app/scrapers/marz-shinjuku.ts | 117 ++++++++++++++++++++++++++++++ app/scrapers/ufoclub-higashikoenji.ts | 133 ++++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 app/scrapers/marz-shinjuku.ts create mode 100644 app/scrapers/ufoclub-higashikoenji.ts (limited to 'app') diff --git a/app/scrapers/index.ts b/app/scrapers/index.ts index 21e6847..79e8125 100644 --- a/app/scrapers/index.ts +++ b/app/scrapers/index.ts @@ -25,6 +25,8 @@ import { scraper as denAtsu } from "./den-atsu"; import { scraper as buzzfrontYokohama } from "./buzzfront-yokohama"; import { scraper as ripsHachioji } from "./rips-hachioji"; import { scraper as matchvoxHachioji } from "./matchvox-hachioji"; +import { scraper as marzShinjuku } from "./marz-shinjuku"; +import { scraper as ufoclubHigashikoenji } from "./ufoclub-higashikoenji"; export const ALL_SCRAPERS: Scraper[] = [ liquidRoom, @@ -49,6 +51,8 @@ export const ALL_SCRAPERS: Scraper[] = [ buzzfrontYokohama, ripsHachioji, matchvoxHachioji, + marzShinjuku, + ufoclubHigashikoenji, ]; export type { Scraper } from "./base"; diff --git a/app/scrapers/marz-shinjuku.ts b/app/scrapers/marz-shinjuku.ts new file mode 100644 index 0000000..06c0bcc --- /dev/null +++ b/app/scrapers/marz-shinjuku.ts @@ -0,0 +1,117 @@ +/** + * 新宿MARZ — http://www.marz.jp/schedule/YYYY/MM/ + * + * DOM構造: + *
+ *
+ *

タイトル

+ *
+ *

【LIVE】
アーティスト名

...
+ *

open HH:MM / start HH:MM
adv¥XXX / door¥XXX
...

+ *
+ *
+ */ +import * as cheerio from "cheerio"; +import type { Scraper, VenueMeta } from "./base"; +import type { EventInput } from "~/lib/db.server"; + +export const venue: VenueMeta = { + id: "marz-shinjuku", + name: "新宿MARZ", + url: "http://www.marz.jp", + area: "新宿", + capacity: 200, +}; + +async function scrapeMonth(year: number, month: number): Promise { + const url = `http://www.marz.jp/schedule/${year}/${String(month).padStart(2, "0")}/`; + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + const $ = cheerio.load(await res.text()); + const events: EventInput[] = []; + + $("article").each((_, el) => { + const $el = $(el); + + const date = $el.find("time[datetime]").attr("datetime"); + if (!date) return; + + const title = $el.find("h1 a").first().text().trim(); + if (!title) return; + + const source_url = $el.find("a[href]").first().attr("href") ?? null; + const image_url = $el.find(".img img").attr("src") ?? null; + const ticket_url = + $el + .find(".entrybody a[href]") + .filter((_, a) => { + const h = $(a).attr("href") ?? ""; + return h.startsWith("http") && !h.includes("marz.jp"); + }) + .first() + .attr("href") ?? null; + + // Artist lines: clone entrybody, replace
with \n, filter out labels/ticket lines + const $body = $el.find(".entrybody").clone(); + $body.find("br").replaceWith("\n"); + const artistLines = $body + .text() + .split("\n") + .map((l) => l.trim()) + .filter( + (l) => l && !l.includes("【") && !l.startsWith("■") && !l.startsWith("※") + ); + const artist = artistLines.length > 0 ? artistLines.join(" / ") : null; + + // Time and price from entryex + const $ex = $el.find(".entryex").clone(); + $ex.find("br").replaceWith("\n"); + const exLines = $ex + .text() + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + const timeLine = exLines[0] ?? ""; + const openMatch = timeLine.match(/open\s+(\d{1,2}:\d{2})/i); + const startMatch = timeLine.match(/start\s+(\d{1,2}:\d{2})/i); + const priceLine = + exLines.find((l, i) => i > 0 && /[¥¥]|adv|door/i.test(l)) ?? null; + + events.push({ + venue_id: venue.id, + title, + artist, + date, + open_time: openMatch?.[1] ?? null, + start_time: startMatch?.[1] ?? null, + price: priceLine, + ticket_url, + image_url, + source_url, + }); + }); + + return events; +} + +export const scraper: Scraper = { + venue, + async scrape(): Promise { + const now = new Date(); + const next = new Date(now.getFullYear(), now.getMonth() + 1, 1); + + const [thisMonth, nextMonth] = await Promise.all([ + scrapeMonth(now.getFullYear(), now.getMonth() + 1), + scrapeMonth(next.getFullYear(), next.getMonth() + 1), + ]); + + 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; + }); + }, +}; 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