diff options
Diffstat (limited to 'app/scrapers/marz-shinjuku.ts')
| -rw-r--r-- | app/scrapers/marz-shinjuku.ts | 117 |
1 files changed, 117 insertions, 0 deletions
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; + }); + }, +}; |
