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
|
/**
* 新宿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);
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
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;
});
},
};
|