summaryrefslogtreecommitdiff
path: root/app/scrapers/nepo-kichijoji.ts
blob: afab99834c3612d2410a76a8508f697c0e13441b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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);
}