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
148
149
150
151
152
153
154
|
---
// このページの構造化データ(JSON-LD)と「最終更新」。
// 更新日は、ページ本体と予定データのうち最後にコミットされた時刻を使う。
// (ビルド時刻を使うと、内容が変わっていなくても日付が動いてしまうため)
import { execSync } from 'node:child_process';
import { scheduleAbsoluteUrl } from '@/data/schedule/config';
import events from '@/data/schedule/events.json';
import { entryDates } from '@/lib/schedule-dates';
import { SCHEDULE_PAGE_FILES } from '@/data/schedule/content-files.mjs';
// URLは src/data/schedule/config.ts で一元管理している(ページ移転時はそこだけ変える)
const url = scheduleAbsoluteUrl();
function lastCommitDate(): Date {
try {
const out = execSync(`git log -1 --format=%cI -- ${SCHEDULE_PAGE_FILES.join(' ')}`, {
encoding: 'utf-8',
}).trim();
if (out) return new Date(out);
} catch {
// git が使えない環境ではビルド時刻にする
}
return new Date();
}
const updated = lastCommitDate();
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
// 表示は日本時間。dateModified はタイムゾーン付きで出す。
const year = updated.getFullYear();
const month = updated.getMonth() + 1;
const day = updated.getDate();
const iso = `${year}-${pad(month)}-${pad(day)}`;
const label = `${year}年${month}月${day}日 ${pad(updated.getHours())}:${pad(updated.getMinutes())}`;
const offsetMinutes = -updated.getTimezoneOffset();
const offsetSign = offsetMinutes >= 0 ? '+' : '-';
const offsetAbs = Math.abs(offsetMinutes);
const isoDateTime = `${iso}T${pad(updated.getHours())}:${pad(updated.getMinutes())}:${pad(updated.getSeconds())}${offsetSign}${pad(Math.floor(offsetAbs / 60))}:${pad(offsetAbs % 60)}`;
// 「これからの予定」は、ビルドした日を基準にする。
const todayKey = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
// 構造化データに入れるのは、ビルド日から1週間先までの予定(近い予定に絞る)
const until = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7);
const untilKey = `${until.getFullYear()}-${pad(until.getMonth() + 1)}-${pad(until.getDate())}`;
const stripTags = (value: string) =>
value
.replace(/<[^>]*>/g, '')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/\s+/g, ' ')
.trim();
// タイトル先頭のアイコン(絵文字)を落とす
const plainName = (title: string) => stripTags(title).replace(/^[^\p{L}\p{N}]+/u, '').trim();
// 「午前9時から/市役所2階」のようなセルから、時間と会場を取り出す。
// 「/」がない場合は、時刻らしい文字が入っているかを手がかりにする。
const timeOf = (place: string) => {
const first = stripTags(place).split('/')[0].trim();
return /時|正午|から/.test(first) ? first : '';
};
const venueOf = (place: string) => {
const text = stripTags(place);
const parts = text.split('/');
if (parts.length > 1) return parts[1].trim();
return /時|から|まで|予約|配布|配信|申込|[0-90-9]/.test(text) ? '' : text.trim();
};
// 構造化データ用の説明文。時刻と場所、申込みなどの記載を短くまとめる。
const describe = (place: string, note: string) => {
const text = [timeOf(place), stripTags(note)].filter(Boolean).join('/');
return text.length > 100 ? `${text.slice(0, 100)}…` : text;
};
type Row = { date: string; title: string; place: string; note: string };
// ビルド日以降に開催されるイベントを、開催日の早い順に出力する。
// (ブラウザで組み立てるとGoogleが拾いにくいため、HTMLに直接入れる)
const upcoming = (events.rows as Row[])
.map((row) => ({
row,
dates: entryDates('event', row.date, row.place).filter(
(date) => date >= todayKey && date <= untilKey,
),
}))
.filter(({ dates }) => dates.length > 0)
.sort((a, b) => a.dates[0].localeCompare(b.dates[0]))
.slice(0, 60);
const data = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: '小平市のイベント・行事予定カレンダー(審議会・市議会の日程も掲載)',
description:
'小平市のイベント・行事予定を月ごとのカレンダーで見られます。審議会・市議会の日程・会場・申込期限を一覧にし、カテゴリ別の絞り込みと地図で確認できます。',
url,
inLanguage: 'ja',
dateModified: isoDateTime,
about: [
{ '@type': 'Thing', name: '小平市のイベントカレンダー' },
{ '@type': 'Thing', name: '小平市の審議会・市議会の日程' },
{ '@type': 'Thing', name: '小平市議会の傍聴' },
],
isPartOf: {
'@type': 'WebSite',
name: '小平市議 安竹洋平 公式サイト',
url: 'https://yasutakeyohei.com/',
},
};
const eventList = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: '小平市のこれからのイベント',
numberOfItems: upcoming.length,
itemListElement: upcoming.map(({ row, dates }, index) => ({
'@type': 'ListItem',
position: index + 1,
item: {
'@type': 'Event',
name: plainName(row.title),
startDate: dates[0],
description: describe(row.place, row.note),
eventStatus: 'https://schema.org/EventScheduled',
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
location: {
'@type': 'Place',
name: venueOf(row.place) || '小平市',
address: {
'@type': 'PostalAddress',
addressLocality: '東京都小平市',
addressCountry: 'JP',
},
},
},
})),
};
---
<script type="application/ld+json" set:html={JSON.stringify(data)} />
<script type="application/ld+json" set:html={JSON.stringify(eventList)} />
<p class="schedule-updated">最終更新:{label}</p>
<p class="schedule-cache-note">
新しい情報が表示されないときは、ページが保存(キャッシュ)されている可能性があります。
ブラウザで再読み込み(Windowsは Ctrl+F5、Macは Command+Shift+R など)をお試しください。
</p>
|