diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-09-26 00:09:06 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-09-26 00:09:06 +0900 |
| commit | 999b7a3133fccc3f120ac5ae24c02bb38a4ecce9 (patch) | |
| tree | 79ee4380e8637b4702824c66e2c3c5f4e315beb3 /app | |
| parent | 7370e6c128b819bf51260783c6ffbe47be8e0b47 (diff) | |
Add scraper for 吉祥寺NEPO
Parses the monthly schedule pages (this month + next two) for date,
title, line-up, open/start times and flyer image. Price is only on
per-event detail pages, which are slow (~3s each), so it is skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app')
| -rw-r--r-- | app/scrapers/index.ts | 2 | ||||
| -rw-r--r-- | app/scrapers/nepo-kichijoji.ts | 105 |
2 files changed, 107 insertions, 0 deletions
diff --git a/app/scrapers/index.ts b/app/scrapers/index.ts index 79e8125..e6a364b 100644 --- a/app/scrapers/index.ts +++ b/app/scrapers/index.ts @@ -27,6 +27,7 @@ import { scraper as ripsHachioji } from "./rips-hachioji"; import { scraper as matchvoxHachioji } from "./matchvox-hachioji"; import { scraper as marzShinjuku } from "./marz-shinjuku"; import { scraper as ufoclubHigashikoenji } from "./ufoclub-higashikoenji"; +import { scraper as nepoKichijoji } from "./nepo-kichijoji"; export const ALL_SCRAPERS: Scraper[] = [ liquidRoom, @@ -53,6 +54,7 @@ export const ALL_SCRAPERS: Scraper[] = [ matchvoxHachioji, marzShinjuku, ufoclubHigashikoenji, + nepoKichijoji, ]; export type { Scraper } from "./base"; diff --git a/app/scrapers/nepo-kichijoji.ts b/app/scrapers/nepo-kichijoji.ts new file mode 100644 index 0000000..afab998 --- /dev/null +++ b/app/scrapers/nepo-kichijoji.ts @@ -0,0 +1,105 @@ +/** + * 吉祥寺 NEPO — https://nepo.co.jp/schedules/index/YYYY/MM/ + * + * 月別一覧の構造: + * <div class="grid-item sche-item get-h"> + * <a href="/schedules/view/ID"> + * <div class="sche-img-inner"><img src="/img/Schedule/ID/image/..."></div> + * <p class="sche-day">DD</p> + * <dl class="inline-list"><dt>Open</dt><dt>Start</dt><dd>HH:MM</dd><dd>HH:MM</dd></dl> + * <h4>タイトル</h4> + * <dl class="inline-list lineup"><dt>Line Up</dt><dd>出演者</dd>...</dl> + * </a> + * </div> + * + * 料金は詳細ページにしかないため取得しない(1ページ数秒かかり全体が遅くなる)。 + * チケット予約は /contacts/index/ID?tab=booking のフォーム。 + */ +import * as cheerio from "cheerio"; +import type { Scraper, VenueMeta } from "./base"; +import type { EventInput } from "~/lib/db.server"; + +const BASE = "https://nepo.co.jp"; + +export const venue: VenueMeta = { + id: "nepo-kichijoji", + name: "吉祥寺 NEPO", + url: BASE, + area: "吉祥寺", +}; + +async function scrapeMonth(year: number, month: number): Promise<EventInput[]> { + const mm = String(month).padStart(2, "0"); + const res = await fetch(`${BASE}/schedules/index/${year}/${mm}/`); + if (!res.ok) return []; + const $ = cheerio.load(await res.text()); + const events: EventInput[] = []; + + $(".grid-item.sche-item").each((_, el) => { + const $el = $(el); + + const day = $el.find("p.sche-day").first().text().trim(); + if (!/^\d{1,2}$/.test(day)) return; + const date = `${year}-${mm}-${day.padStart(2, "0")}`; + + const title = $el.find("h4").first().text().trim(); + if (!title) return; + + const href = $el.find("a[href]").first().attr("href"); + const source_url = href ? new URL(href, BASE).href : null; + const eventId = href?.match(/\/schedules\/view\/(\d+)/)?.[1]; + + const times = $el + .find("dl.inline-list:not(.lineup) dd") + .map((_, dd) => $(dd).text().trim()) + .get(); + + const artists = $el + .find("dl.lineup dd") + .map((_, dd) => $(dd).text().trim()) + .get() + .filter(Boolean); + + const img = $el.find(".sche-img-inner img").attr("src"); + + events.push({ + venue_id: venue.id, + title, + artist: artists.length > 0 ? artists.join(" / ") : null, + date, + open_time: isTime(times[0]) ? times[0] : null, + start_time: isTime(times[1]) ? times[1] : null, + price: null, + ticket_url: eventId ? `${BASE}/contacts/index/${eventId}?tab=booking` : null, + image_url: img ? new URL(img, BASE).href : null, + source_url, + }); + }); + + return events; +} + +export const scraper: Scraper = { + venue, + async scrape(): Promise<EventInput[]> { + // Scrape window is ~65 days, so cover this month + next two + const now = new Date(); + const months = [0, 1, 2].map((offset) => { + const d = new Date(now.getFullYear(), now.getMonth() + offset, 1); + return { year: d.getFullYear(), month: d.getMonth() + 1 }; + }); + const perMonth = await Promise.all(months.map(({ year, month }) => scrapeMonth(year, month))); + + const seen = new Set<string>(); + return perMonth.flat().filter((e) => { + const key = `${e.date}|${e.title}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }, +}; + +function isTime(s: string | undefined): s is string { + return !!s && /^\d{1,2}:\d{2}$/.test(s); +} |
