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
|
/**
* 新代田 FEVER — https://www.fever-popo.com
*
* Movable Type CMS。月別 URL: /schedule/YYYY/MM/
* DOM 構造:
* <div class="entry-asset">
* <h2 class="eventtitle">26.05.01 (Fri) タイトル</h2>
* <meta property="og:url" content="https://www.fever-popo.com/schedule/.../MMDD.html">
* <h3><p>アーティスト1<br/>アーティスト2</p></h3>
* <div>OPEN HH:MM / START HH:MM</div>
* <div><p>ADV ¥XXXX (+1drink) / DOOR ¥XXXX (+1drink)</p></div>
* <img class="scpickup" src="..."> ← フライヤー画像
*/
import * as cheerio from "cheerio";
import type { Scraper, VenueMeta } from "./base";
import type { EventInput } from "~/lib/db.server";
export const venue: VenueMeta = {
id: "fever-shindaita",
name: "新代田 FEVER",
url: "https://www.fever-popo.com",
area: "新代田",
capacity: 300,
};
async function scrapeMonth(yyyymm: string): Promise<EventInput[]> {
const [year, month] = yyyymm.split("-");
const url = `${venue.url}/schedule/${year}/${month}/`;
const res = await fetch(url);
if (!res.ok) return [];
const $ = cheerio.load(await res.text());
const events: EventInput[] = [];
$("div.entry-asset").each((_, el) => {
const $el = $(el);
// Title: "26.05.01 (Fri) タイトル"
const h2Text = $el.find("h2.eventtitle").first().text();
const titleMatch = h2Text.match(/^\d{2}\.\d{2}\.\d{2}\s+\([A-Za-z]+\)\s*(.+)$/);
if (!titleMatch) return;
const title = titleMatch[1].trim();
if (!title) return;
// Date from title prefix: "26.05.01"
const dateMatch = h2Text.match(/^(\d{2})\.(\d{2})\.(\d{2})/);
if (!dateMatch) return;
const date = `20${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
// Source URL from og:url meta inside the entry
const sourceUrl = $el.find("meta[property='og:url']").attr("content") ?? null;
// Artists: first <h3><p> in body
const $h3 = $el.find("div.asset-body h3").first();
const artist = $h3.find("p").text()
.split(/\n|<br\s*\/?>/i)
.map((s) => s.replace(/<[^>]+>/g, "").trim())
.filter(Boolean)
.join("、") || null;
// Time: div containing "OPEN" / "START"
let openTime: string | null = null;
let startTime: string | null = null;
$el.find("div.asset-body div").each((_, d) => {
const text = $(d).text();
if (/OPEN/i.test(text) && /START/i.test(text)) {
const om = text.match(/OPEN\s*(\d{1,2}:\d{2})/i);
const sm = text.match(/START\s*(\d{1,2}:\d{2})/i);
if (om) openTime = om[1];
if (sm) startTime = sm[1];
}
});
// Price: div after the time div
let price: string | null = null;
$el.find("div.asset-body div").each((_, d) => {
const text = $(d).text().trim();
if (/[¥¥]/.test(text) && /(ADV|DOOR|前売|当日)/i.test(text)) {
price = text.replace(/\s+/g, " ").split("\n")[0].trim() || null;
}
});
// Image
const imageUrl = $el.find("img.scpickup").first().attr("src") ?? null;
// Ticket URL
const ticketUrl =
$el.find("a[href*='eplus'], a[href*='pia'], a[href*='tiget'], a[href*='livepocket'], a[href*='t-dv.com']")
.first().attr("href") ?? null;
events.push({
venue_id: venue.id,
title,
artist,
date,
open_time: openTime,
start_time: startTime,
price,
ticket_url: ticketUrl,
image_url: imageUrl,
source_url: sourceUrl,
});
});
return events;
}
export const scraper: Scraper = {
venue,
async scrape(): Promise<EventInput[]> {
const now = new Date();
const months = [0, 1, 2].map((offset) => {
const d = new Date(now.getFullYear(), now.getMonth() + offset, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
});
const results = await Promise.all(months.map(scrapeMonth));
return results.flat();
},
};
|