/** * 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; }); }, };