summaryrefslogtreecommitdiff
path: root/app/scrapers/matchvox-hachioji.ts
blob: ff31788ee3fec090bdf5239b47e53c848ab26530 (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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
/**
 * MatchVox 八王子 — http://matchvox.rinkydink.info/matchvox/schedule
 *
 * WordPress + My Calendar プラグイン(RIPS と同一構造)。
 * イベント構造:
 *   <ul class='mc-list'>
 *     <li id='list-YYYY-MM-DD' class='mc-events ...'>
 *       <strong class="event-date">5/1(金)</strong>
 *       <div class='list-event ...'>
 *         <meta itemprop='startDate' content='YYYY-MM-DDTHH:MM:SS' />
 *         <h3 class='event-title summary'>タイトル</h3>
 *         <div class='longdesc description'>
 *           <p>アーティスト1<br/>アーティスト2</p>
 *           <p>OPEN HH:MM START HH:MM<br/>adv:XXXX door:XXXX</p>
 *         </div>
 *       </div>
 *     </li>
 *   </ul>
 *
 * ?yr=YYYY&month=M&dy&cid=... でページネーション
 */
import * as cheerio from "cheerio";
import type { Scraper, VenueMeta } from "./base";
import type { EventInput } from "~/lib/db.server";

export const venue: VenueMeta = {
  id: "matchvox-hachioji",
  name: "MatchVox 八王子",
  url: "http://matchvox.rinkydink.info/matchvox",
  area: "八王子",
};

const SCHEDULE_BASE = "http://matchvox.rinkydink.info/matchvox/schedule";
const CID = "mc-35df894f2fa16157a96dd95b6cded5c1";

function parseHtml(html: string): EventInput[] {
  const $ = cheerio.load(html);
  const events: EventInput[] = [];

  $("ul.mc-list > li[id^='list-']").each((_, li) => {
    const $li = $(li);
    const dateMatch = $li.attr("id")?.match(/^list-(\d{4}-\d{2}-\d{2})$/);
    if (!dateMatch) return;
    const date = dateMatch[1];

    $li.find("div.list-event").each((_, eventEl) => {
      const $event = $(eventEl);

      const title = $event.find("h3.event-title").text().trim();
      if (!title) return;

      let artist: string | null = null;
      let open_time: string | null = null;
      let start_time: string | null = null;
      let price: string | null = null;
      let image_url: string | null = null;

      const $desc = $event.find("div.longdesc.description");
      if ($desc.length) {
        image_url = $desc.find("img").first().attr("src") ?? null;

        const paragraphs: string[] = [];
        $desc.find("p").each((_, p) => {
          const $p = $(p).clone();
          $p.find("br").replaceWith("\n");
          const text = $p.text().trim();
          if (text) paragraphs.push(text);
        });

        const timeParaIdx = paragraphs.findIndex((p) =>
          /(?:open|pen)\s+\d{1,2}:\d{2}/i.test(p)
        );

        if (timeParaIdx !== -1) {
          const timePara = paragraphs[timeParaIdx];
          const openMatch = timePara.match(/(?:open|pen)\s+(\d{1,2}:\d{2})/i);
          const startMatch = timePara.match(/start\s+(\d{1,2}:\d{2})/i);
          if (openMatch) open_time = openMatch[1];
          if (startMatch) start_time = startMatch[1];

          const priceLines = timePara
            .split("\n")
            .map((l) => l.trim())
            .filter((l) => /adv|door/i.test(l));
          if (priceLines.length) price = priceLines.join(" / ");

          const artistLines = paragraphs
            .filter((_, i) => i !== timeParaIdx)
            .flatMap((p) => p.split("\n").map((l) => l.trim()))
            .filter((l) => l && !/^出演$/.test(l));
          if (artistLines.length) artist = artistLines.join("、");
        } else if (paragraphs.length) {
          const artistLines = paragraphs
            .flatMap((p) => p.split("\n").map((l) => l.trim()))
            .filter((l) => l && !/^出演$/.test(l));
          if (artistLines.length) artist = artistLines.join("、");
        }
      }

      events.push({
        venue_id: venue.id,
        title,
        artist,
        date,
        open_time,
        start_time,
        price,
        image_url,
        ticket_url:
          $event
            .find(
              "a[href*='livepocket'], a[href*='eplus'], a[href*='pia'], a[href*='l-tike'], a[href*='tiget']"
            )
            .first()
            .attr("href") ?? null,
        source_url: SCHEDULE_BASE,
      });
    });
  });

  return events;
}

export const scraper: Scraper = {
  venue,
  async scrape(): Promise<EventInput[]> {
    const now = new Date();
    const urls = [0, 1, 2].map((offset) => {
      const d = new Date(now.getFullYear(), now.getMonth() + offset, 1);
      return `${SCHEDULE_BASE}?yr=${d.getFullYear()}&month=${d.getMonth() + 1}&dy&cid=${CID}`;
    });

    const htmls = await Promise.all(
      urls.map((url) => fetch(url).then((r) => (r.ok ? r.text() : "")))
    );

    const seen = new Set<string>();
    return htmls
      .flatMap(parseHtml)
      .filter((e) => {
        const key = `${e.date}|${e.title}`;
        if (seen.has(key)) return false;
        seen.add(key);
        return true;
      });
  },
};