diff options
Diffstat (limited to 'app/scrapers/fever-shindaita.ts')
| -rw-r--r-- | app/scrapers/fever-shindaita.ts | 117 |
1 files changed, 117 insertions, 0 deletions
diff --git a/app/scrapers/fever-shindaita.ts b/app/scrapers/fever-shindaita.ts new file mode 100644 index 0000000..71c31f6 --- /dev/null +++ b/app/scrapers/fever-shindaita.ts @@ -0,0 +1,117 @@ +/** + * 新代田 FEVER — https://www.fever-popo.com + * + * Movable Type CMS。月別 URL: /schedule/YYYY/MM/ + * DOM 構造: + * <div class="entry-asset"> + * <h2 class="eventtitle">26.05.01 (Fri) タイトル</h2> + * <meta property="og:url" content="https://www.fever-popo.com/schedule/.../MMDD.html"> + * <h3><p>アーティスト1<br/>アーティスト2</p></h3> + * <div>OPEN HH:MM / START HH:MM</div> + * <div><p>ADV ¥XXXX (+1drink) / DOOR ¥XXXX (+1drink)</p></div> + * <img class="scpickup" src="..."> ← フライヤー画像 + */ +import * as cheerio from "cheerio"; +import type { Scraper, VenueMeta } from "./base"; +import type { EventInput } from "~/lib/db.server"; + +export const venue: VenueMeta = { + id: "fever-shindaita", + name: "新代田 FEVER", + url: "https://www.fever-popo.com", + area: "新代田", +}; + +async function scrapeMonth(yyyymm: string): Promise<EventInput[]> { + const [year, month] = yyyymm.split("-"); + const url = `${venue.url}/schedule/${year}/${month}/`; + const res = await fetch(url); + if (!res.ok) return []; + const $ = cheerio.load(await res.text()); + const events: EventInput[] = []; + + $("div.entry-asset").each((_, el) => { + const $el = $(el); + + // Title: "26.05.01 (Fri) タイトル" + const h2Text = $el.find("h2.eventtitle").first().text(); + const titleMatch = h2Text.match(/^\d{2}\.\d{2}\.\d{2}\s+\([A-Za-z]+\)\s*(.+)$/); + if (!titleMatch) return; + const title = titleMatch[1].trim(); + if (!title) return; + + // Date from title prefix: "26.05.01" + const dateMatch = h2Text.match(/^(\d{2})\.(\d{2})\.(\d{2})/); + if (!dateMatch) return; + const date = `20${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`; + + // Source URL from og:url meta inside the entry + const sourceUrl = $el.find("meta[property='og:url']").attr("content") ?? null; + + // Artists: first <h3><p> in body + const $h3 = $el.find("div.asset-body h3").first(); + const artist = $h3.find("p").text() + .split(/\n|<br\s*\/?>/i) + .map((s) => s.replace(/<[^>]+>/g, "").trim()) + .filter(Boolean) + .join("、") || null; + + // Time: div containing "OPEN" / "START" + let openTime: string | null = null; + let startTime: string | null = null; + $el.find("div.asset-body div").each((_, d) => { + const text = $(d).text(); + if (/OPEN/i.test(text) && /START/i.test(text)) { + const om = text.match(/OPEN\s*(\d{1,2}:\d{2})/i); + const sm = text.match(/START\s*(\d{1,2}:\d{2})/i); + if (om) openTime = om[1]; + if (sm) startTime = sm[1]; + } + }); + + // Price: div after the time div + let price: string | null = null; + $el.find("div.asset-body div").each((_, d) => { + const text = $(d).text().trim(); + if (/[¥¥]/.test(text) && /(ADV|DOOR|前売|当日)/i.test(text)) { + price = text.replace(/\s+/g, " ").split("\n")[0].trim() || null; + } + }); + + // Image + const imageUrl = $el.find("img.scpickup").first().attr("src") ?? null; + + // Ticket URL + const ticketUrl = + $el.find("a[href*='eplus'], a[href*='pia'], a[href*='tiget'], a[href*='livepocket'], a[href*='t-dv.com']") + .first().attr("href") ?? null; + + events.push({ + venue_id: venue.id, + title, + artist, + date, + open_time: openTime, + start_time: startTime, + price, + ticket_url: ticketUrl, + image_url: imageUrl, + source_url: sourceUrl, + }); + }); + + return events; +} + +export const scraper: Scraper = { + venue, + async scrape(): Promise<EventInput[]> { + const now = new Date(); + const thisMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + const next = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const nextMonth = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`; + + const [a, b] = await Promise.all([scrapeMonth(thisMonth), scrapeMonth(nextMonth)]); + return [...a, ...b]; + }, +}; |
