summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-07-03 10:23:41 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-07-03 10:23:41 +0900
commit44f9ef0d9caa0d9cce06635f6c0c7e045fef2796 (patch)
treeae0f5c8ee341bfdd0430ad806ba8e6604636a9ef /app
parent272f7d079cbac81c9af86b9f210ace8cabc0e98c (diff)
Add scrapers for 新宿MARZ and 東高円寺U.F.O.CLUB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app')
-rw-r--r--app/scrapers/index.ts4
-rw-r--r--app/scrapers/marz-shinjuku.ts117
-rw-r--r--app/scrapers/ufoclub-higashikoenji.ts133
3 files changed, 254 insertions, 0 deletions
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構造:
+ * <article id="eXXXX">
+ * <a href="detail-url"><div class="img"><img src="..."><time datetime="YYYY-MM-DD"></div></a>
+ * <h1><a href="...">タイトル</a></h1>
+ * <div class="desc">
+ * <div class="entrybody"><p>【LIVE】<br/>アーティスト名</p> ...</div>
+ * <div class="entryex"><p>open HH:MM / start HH:MM<br/>adv¥XXX / door¥XXX<br/>...</p></div>
+ * </div>
+ * </article>
+ */
+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<EventInput[]> {
+ 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 <br> 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<EventInput[]> {
+ 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<string>();
+ 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構造:
+ * <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;
+ });
+ },
+};