diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-05-19 22:25:18 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-05-19 22:25:18 +0900 |
| commit | 23afa26eeedf271081b4f369893f785b41d2fa0a (patch) | |
| tree | 355f375aec213d535b0d07dc8362e4ddc5feb3d0 /app | |
| parent | fd4ea38bd52a8aa8a31dbb3fd9c7fdfe5d960ec4 (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')
| -rw-r--r-- | app/scrapers/index.ts | 4 | ||||
| -rw-r--r-- | app/scrapers/matchvox-hachioji.ts | 147 | ||||
| -rw-r--r-- | app/scrapers/rips-hachioji.ts | 147 |
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; + }); + }, +}; |
