From 23afa26eeedf271081b4f369893f785b41d2fa0a Mon Sep 17 00:00:00 2001 From: yyamashita Date: Tue, 19 May 2026 22:25:18 +0900 Subject: =?UTF-8?q?Add=20RIPS=20=E5=85=AB=E7=8E=8B=E5=AD=90=20and=20MatchV?= =?UTF-8?q?ox=20=E5=85=AB=E7=8E=8B=E5=AD=90=20scrapers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/scrapers/index.ts | 4 ++ app/scrapers/matchvox-hachioji.ts | 147 ++++++++++++++++++++++++++++++++++++++ app/scrapers/rips-hachioji.ts | 147 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 app/scrapers/matchvox-hachioji.ts create mode 100644 app/scrapers/rips-hachioji.ts (limited to 'app') 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 と同一構造)。 + * イベント構造: + * + * + * ?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 { + 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(); + 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 プラグイン。 + * イベント構造: + *
    + *
  • + * 5/1(金) + *
    + * + *

    タイトル

    + *
    + *

    アーティスト1
    アーティスト2

    + *

    OPEN HH:MM START HH:MM
    adv:XXXX door:XXXX

    + *
    + *
    + *
  • + *
+ * + * ?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 { + 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(); + return htmls + .flatMap(parseHtml) + .filter((e) => { + const key = `${e.date}|${e.title}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }, +}; -- cgit v1.2.3