summaryrefslogtreecommitdiff
path: root/app/scrapers/marz-shinjuku.ts
blob: b03f23ca24855397db9199238f259604cfd13ca1 (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
106
107
108
109
110
111
112
113
114
115
116
117
118
/**
 * 新宿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);
  // 404/500 = schedule not yet published for that month
  if (!res.ok) return [];
  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;
    });
  },
};