summaryrefslogtreecommitdiff
path: root/app/scrapers
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-05-19 22:25:18 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-05-19 22:25:18 +0900
commit23afa26eeedf271081b4f369893f785b41d2fa0a (patch)
tree355f375aec213d535b0d07dc8362e4ddc5feb3d0 /app/scrapers
parentfd4ea38bd52a8aa8a31dbb3fd9c7fdfe5d960ec4 (diff)
Add RIPS 八王子 and MatchVox 八王子 scrapers
Both venues are RinkyDink-affiliated live houses in Hachioji. WordPress + My Calendar plugin; parses mc-list HTML for date, title, artist, OPEN/START times, and price across 3 months. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app/scrapers')
-rw-r--r--app/scrapers/index.ts4
-rw-r--r--app/scrapers/matchvox-hachioji.ts147
-rw-r--r--app/scrapers/rips-hachioji.ts147
3 files changed, 298 insertions, 0 deletions
diff --git a/app/scrapers/index.ts b/app/scrapers/index.ts
index fa0df9b..21e6847 100644
--- a/app/scrapers/index.ts
+++ b/app/scrapers/index.ts
@@ -23,6 +23,8 @@ import { scraper as shimokitazawaEra } from "./shimokitazawa-era";
import { scraper as duoMusicExchange } from "./duo-music-exchange";
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";
export const ALL_SCRAPERS: Scraper[] = [
liquidRoom,
@@ -45,6 +47,8 @@ export const ALL_SCRAPERS: Scraper[] = [
duoMusicExchange,
denAtsu,
buzzfrontYokohama,
+ ripsHachioji,
+ matchvoxHachioji,
];
export type { Scraper } from "./base";
diff --git a/app/scrapers/matchvox-hachioji.ts b/app/scrapers/matchvox-hachioji.ts
new file mode 100644
index 0000000..ff31788
--- /dev/null
+++ b/app/scrapers/matchvox-hachioji.ts
@@ -0,0 +1,147 @@
+/**
+ * MatchVox 八王子 — http://matchvox.rinkydink.info/matchvox/schedule
+ *
+ * WordPress + My Calendar プラグイン(RIPS と同一構造)。
+ * イベント構造:
+ * <ul class='mc-list'>
+ * <li id='list-YYYY-MM-DD' class='mc-events ...'>
+ * <strong class="event-date">5/1(金)</strong>
+ * <div class='list-event ...'>
+ * <meta itemprop='startDate' content='YYYY-MM-DDTHH:MM:SS' />
+ * <h3 class='event-title summary'>タイトル</h3>
+ * <div class='longdesc description'>
+ * <p>アーティスト1<br/>アーティスト2</p>
+ * <p>OPEN HH:MM START HH:MM<br/>adv:XXXX door:XXXX</p>
+ * </div>
+ * </div>
+ * </li>
+ * </ul>
+ *
+ * ?yr=YYYY&month=M&dy&cid=... でページネーション
+ */
+import * as cheerio from "cheerio";
+import type { Scraper, VenueMeta } from "./base";
+import type { EventInput } from "~/lib/db.server";
+
+export const venue: VenueMeta = {
+ id: "matchvox-hachioji",
+ name: "MatchVox 八王子",
+ url: "http://matchvox.rinkydink.info/matchvox",
+ area: "八王子",
+};
+
+const SCHEDULE_BASE = "http://matchvox.rinkydink.info/matchvox/schedule";
+const CID = "mc-35df894f2fa16157a96dd95b6cded5c1";
+
+function parseHtml(html: string): EventInput[] {
+ const $ = cheerio.load(html);
+ const events: EventInput[] = [];
+
+ $("ul.mc-list > li[id^='list-']").each((_, li) => {
+ const $li = $(li);
+ const dateMatch = $li.attr("id")?.match(/^list-(\d{4}-\d{2}-\d{2})$/);
+ if (!dateMatch) return;
+ const date = dateMatch[1];
+
+ $li.find("div.list-event").each((_, eventEl) => {
+ const $event = $(eventEl);
+
+ const title = $event.find("h3.event-title").text().trim();
+ if (!title) return;
+
+ let artist: string | null = null;
+ let open_time: string | null = null;
+ let start_time: string | null = null;
+ let price: string | null = null;
+ let image_url: string | null = null;
+
+ const $desc = $event.find("div.longdesc.description");
+ if ($desc.length) {
+ image_url = $desc.find("img").first().attr("src") ?? null;
+
+ const paragraphs: string[] = [];
+ $desc.find("p").each((_, p) => {
+ const $p = $(p).clone();
+ $p.find("br").replaceWith("\n");
+ const text = $p.text().trim();
+ if (text) paragraphs.push(text);
+ });
+
+ const timeParaIdx = paragraphs.findIndex((p) =>
+ /(?:open|pen)\s+\d{1,2}:\d{2}/i.test(p)
+ );
+
+ if (timeParaIdx !== -1) {
+ const timePara = paragraphs[timeParaIdx];
+ const openMatch = timePara.match(/(?:open|pen)\s+(\d{1,2}:\d{2})/i);
+ const startMatch = timePara.match(/start\s+(\d{1,2}:\d{2})/i);
+ if (openMatch) open_time = openMatch[1];
+ if (startMatch) start_time = startMatch[1];
+
+ const priceLines = timePara
+ .split("\n")
+ .map((l) => l.trim())
+ .filter((l) => /adv|door/i.test(l));
+ if (priceLines.length) price = priceLines.join(" / ");
+
+ const artistLines = paragraphs
+ .filter((_, i) => i !== timeParaIdx)
+ .flatMap((p) => p.split("\n").map((l) => l.trim()))
+ .filter((l) => l && !/^出演$/.test(l));
+ if (artistLines.length) artist = artistLines.join("、");
+ } else if (paragraphs.length) {
+ const artistLines = paragraphs
+ .flatMap((p) => p.split("\n").map((l) => l.trim()))
+ .filter((l) => l && !/^出演$/.test(l));
+ if (artistLines.length) artist = artistLines.join("、");
+ }
+ }
+
+ events.push({
+ venue_id: venue.id,
+ title,
+ artist,
+ date,
+ open_time,
+ start_time,
+ price,
+ image_url,
+ ticket_url:
+ $event
+ .find(
+ "a[href*='livepocket'], a[href*='eplus'], a[href*='pia'], a[href*='l-tike'], a[href*='tiget']"
+ )
+ .first()
+ .attr("href") ?? null,
+ source_url: SCHEDULE_BASE,
+ });
+ });
+ });
+
+ return events;
+}
+
+export const scraper: Scraper = {
+ venue,
+ async scrape(): Promise<EventInput[]> {
+ const now = new Date();
+ const urls = [0, 1, 2].map((offset) => {
+ const d = new Date(now.getFullYear(), now.getMonth() + offset, 1);
+ return `${SCHEDULE_BASE}?yr=${d.getFullYear()}&month=${d.getMonth() + 1}&dy&cid=${CID}`;
+ });
+
+ const htmls = await Promise.all(
+ urls.map((url) => fetch(url).then((r) => (r.ok ? r.text() : "")))
+ );
+
+ const seen = new Set<string>();
+ return htmls
+ .flatMap(parseHtml)
+ .filter((e) => {
+ const key = `${e.date}|${e.title}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ },
+};
diff --git a/app/scrapers/rips-hachioji.ts b/app/scrapers/rips-hachioji.ts
new file mode 100644
index 0000000..bfed498
--- /dev/null
+++ b/app/scrapers/rips-hachioji.ts
@@ -0,0 +1,147 @@
+/**
+ * RIPS 八王子 — http://rips.rinkydink.info/rips/schedule
+ *
+ * WordPress + My Calendar プラグイン。
+ * イベント構造:
+ * <ul class='mc-list'>
+ * <li id='list-YYYY-MM-DD' class='mc-events ...'>
+ * <strong class="event-date">5/1(金)</strong>
+ * <div class='list-event ...'>
+ * <meta itemprop='startDate' content='YYYY-MM-DDTHH:MM:SS' />
+ * <h3 class='event-title summary'>タイトル</h3>
+ * <div class='longdesc description'>
+ * <p>アーティスト1<br/>アーティスト2</p>
+ * <p>OPEN HH:MM START HH:MM<br/>adv:XXXX door:XXXX</p>
+ * </div>
+ * </div>
+ * </li>
+ * </ul>
+ *
+ * ?yr=YYYY&month=M&dy&cid=... でページネーション
+ */
+import * as cheerio from "cheerio";
+import type { Scraper, VenueMeta } from "./base";
+import type { EventInput } from "~/lib/db.server";
+
+export const venue: VenueMeta = {
+ id: "rips-hachioji",
+ name: "RIPS 八王子",
+ url: "http://rips.rinkydink.info/rips",
+ area: "八王子",
+};
+
+const SCHEDULE_BASE = "http://rips.rinkydink.info/rips/schedule";
+const CID = "mc-35df894f2fa16157a96dd95b6cded5c1";
+
+function parseHtml(html: string): EventInput[] {
+ const $ = cheerio.load(html);
+ const events: EventInput[] = [];
+
+ $("ul.mc-list > li[id^='list-']").each((_, li) => {
+ const $li = $(li);
+ const dateMatch = $li.attr("id")?.match(/^list-(\d{4}-\d{2}-\d{2})$/);
+ if (!dateMatch) return;
+ const date = dateMatch[1];
+
+ $li.find("div.list-event").each((_, eventEl) => {
+ const $event = $(eventEl);
+
+ const title = $event.find("h3.event-title").text().trim();
+ if (!title) return;
+
+ let artist: string | null = null;
+ let open_time: string | null = null;
+ let start_time: string | null = null;
+ let price: string | null = null;
+ let image_url: string | null = null;
+
+ const $desc = $event.find("div.longdesc.description");
+ if ($desc.length) {
+ image_url = $desc.find("img").first().attr("src") ?? null;
+
+ const paragraphs: string[] = [];
+ $desc.find("p").each((_, p) => {
+ const $p = $(p).clone();
+ $p.find("br").replaceWith("\n");
+ const text = $p.text().trim();
+ if (text) paragraphs.push(text);
+ });
+
+ const timeParaIdx = paragraphs.findIndex((p) =>
+ /(?:open|pen)\s+\d{1,2}:\d{2}/i.test(p)
+ );
+
+ if (timeParaIdx !== -1) {
+ const timePara = paragraphs[timeParaIdx];
+ const openMatch = timePara.match(/(?:open|pen)\s+(\d{1,2}:\d{2})/i);
+ const startMatch = timePara.match(/start\s+(\d{1,2}:\d{2})/i);
+ if (openMatch) open_time = openMatch[1];
+ if (startMatch) start_time = startMatch[1];
+
+ const priceLines = timePara
+ .split("\n")
+ .map((l) => l.trim())
+ .filter((l) => /adv|door/i.test(l));
+ if (priceLines.length) price = priceLines.join(" / ");
+
+ const artistLines = paragraphs
+ .filter((_, i) => i !== timeParaIdx)
+ .flatMap((p) => p.split("\n").map((l) => l.trim()))
+ .filter((l) => l && !/^出演$/.test(l));
+ if (artistLines.length) artist = artistLines.join("、");
+ } else if (paragraphs.length) {
+ const artistLines = paragraphs
+ .flatMap((p) => p.split("\n").map((l) => l.trim()))
+ .filter((l) => l && !/^出演$/.test(l));
+ if (artistLines.length) artist = artistLines.join("、");
+ }
+ }
+
+ events.push({
+ venue_id: venue.id,
+ title,
+ artist,
+ date,
+ open_time,
+ start_time,
+ price,
+ image_url,
+ ticket_url:
+ $event
+ .find(
+ "a[href*='livepocket'], a[href*='eplus'], a[href*='pia'], a[href*='l-tike'], a[href*='tiget']"
+ )
+ .first()
+ .attr("href") ?? null,
+ source_url: SCHEDULE_BASE,
+ });
+ });
+ });
+
+ return events;
+}
+
+export const scraper: Scraper = {
+ venue,
+ async scrape(): Promise<EventInput[]> {
+ const now = new Date();
+ const urls = [0, 1, 2].map((offset) => {
+ const d = new Date(now.getFullYear(), now.getMonth() + offset, 1);
+ return `${SCHEDULE_BASE}?yr=${d.getFullYear()}&month=${d.getMonth() + 1}&dy&cid=${CID}`;
+ });
+
+ const htmls = await Promise.all(
+ urls.map((url) => fetch(url).then((r) => (r.ok ? r.text() : "")))
+ );
+
+ const seen = new Set<string>();
+ return htmls
+ .flatMap(parseHtml)
+ .filter((e) => {
+ const key = `${e.date}|${e.title}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ },
+};