summaryrefslogtreecommitdiff
path: root/app/scrapers/ufoclub-higashikoenji.ts
blob: 85749f914e915ab7420bd6befa6a6f7a450cc153 (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
/**
 * 東高円寺 U.F.O.CLUB — https://www.ufoclub.jp/schedule/
 *
 * WordPress テーマ。fetch に Mozilla UA が必要(curl は 2 バイトを返す)。
 *
 * DOM構造:
 *   <div class="month">2026年6月</div>   ← 年月の取得元
 *   <table class="schedule-table"><tbody>
 *     <tr>
 *       <th>1</th>            ← 日
 *       <td>MON</td>          ← 曜日(スキップ)
 *       <td>
 *         <strong>【タイトル】</strong><br/>
 *         /LIVE; artist1, artist2
 *         /DJ; dj1
 *       </td>
 *       <td class="td_open">OPEN 18:30~<br/>START 19:00~</td>
 *       <td class="td_charge">ADV.¥2800(+1D)<br/>DOOR.¥3300(+1D)</td>
 *     </tr>
 *   </tbody></table>
 *
 * 月ナビ: /schedule/ (当月) / /schedule/next-month (翌月)
 */
import * as cheerio from "cheerio";
import type { Scraper, VenueMeta } from "./base";
import type { EventInput } from "~/lib/db.server";

export const venue: VenueMeta = {
  id: "ufoclub-higashikoenji",
  name: "東高円寺 U.F.O.CLUB",
  url: "https://www.ufoclub.jp",
  area: "東高円寺",
  capacity: 120,
};

const UA =
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";

async function scrapePage(url: string): Promise<EventInput[]> {
  const res = await fetch(url, { headers: { "User-Agent": UA } });
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
  const $ = cheerio.load(await res.text());
  const events: EventInput[] = [];

  // Parse year and month from <div class="month">2026年6月</div>
  const monthText = $("div.month").first().text().trim();
  const monthMatch = monthText.match(/(\d{4})年(\d{1,2})月/);
  if (!monthMatch) return events;
  const year = parseInt(monthMatch[1], 10);
  const month = parseInt(monthMatch[2], 10);

  $("table.schedule-table tbody tr").each((_, row) => {
    const $row = $(row);

    // Day from <th>
    const day = parseInt($row.find("th").text().trim(), 10);
    if (isNaN(day)) return;
    const date = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;

    // Event td is the 2nd <td> (index 1; 0 = day-of-week)
    const $eventTd = $row.find("td").eq(1);
    const title = $eventTd.find("strong").text().replace(/^【|】$/g, "").trim();
    if (!title) return;

    // Artists: text after </strong>, lines starting with /LIVE; /DJ; /MC;
    const $tdClone = $eventTd.clone();
    $tdClone.find("strong").remove();
    $tdClone.find("br").replaceWith("\n");
    const afterTitle = $tdClone.text();
    const artistParts: string[] = [];
    for (const line of afterTitle.split("\n")) {
      const t = line.replace(/\s+/g, " ").trim();
      const liveM = t.match(/^\/LIVE;\s*(.+)/i);
      const djM = t.match(/^\/DJ;\s*(.+)/i);
      const mcM = t.match(/^\/MC;\s*(.+)/i);
      if (liveM) artistParts.push(liveM[1].trim());
      else if (djM) artistParts.push(`DJ: ${djM[1].trim()}`);
      else if (mcM) artistParts.push(`MC: ${mcM[1].trim()}`);
    }
    const artist = artistParts.length > 0 ? artistParts.join(" / ") : null;

    // Open / Start time from td.td_open
    const $openTd = $row.find("td.td_open").clone();
    $openTd.find("br").replaceWith("\n");
    const openText = $openTd.text();
    const openMatch = openText.match(/OPEN\s+(\d{1,2}:\d{2})/i);
    const startMatch = openText.match(/START\s+(\d{1,2}:\d{2})/i);

    // Price from td.td_charge (first two lines: ADV / DOOR)
    const $chargeTd = $row.find("td.td_charge").clone();
    $chargeTd.find("br").replaceWith("\n");
    const chargeLines = $chargeTd
      .text()
      .split("\n")
      .map((l) => l.trim())
      .filter(Boolean);
    const price =
      chargeLines.length >= 2
        ? `${chargeLines[0]} / ${chargeLines[1]}`
        : (chargeLines[0] ?? null);

    events.push({
      venue_id: venue.id,
      title,
      artist,
      date,
      open_time: openMatch?.[1] ?? null,
      start_time: startMatch?.[1] ?? null,
      price,
      source_url: url,
    });
  });

  return events;
}

export const scraper: Scraper = {
  venue,
  async scrape(): Promise<EventInput[]> {
    const [thisMonth, nextMonth] = await Promise.all([
      scrapePage("https://www.ufoclub.jp/schedule/"),
      scrapePage("https://www.ufoclub.jp/schedule/next-month"),
    ]);

    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;
    });
  },
};