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
|
---
// このページの構造化データ(JSON-LD)。ビルド日を更新日として出力する。
import { scheduleAbsoluteUrl } from '@/data/schedule/config';
import events from '@/data/schedule/events.json';
import { entryDates } from '@/lib/schedule-dates';
// URLは src/data/schedule/config.ts で一元管理している(ページ移転時はそこだけ変える)
const url = scheduleAbsoluteUrl();
const updated = new Date();
const iso = updated.toISOString().slice(0, 10);
const label = `${updated.getFullYear()}年${updated.getMonth() + 1}月${updated.getDate()}日`;
const pad = (n: number) => String(n).padStart(2, '0');
const todayKey = `${updated.getFullYear()}-${pad(updated.getMonth() + 1)}-${pad(updated.getDate())}`;
// 構造化データに入れるのは、ビルド日から1週間先までの予定(近い予定に絞る)
const until = new Date(updated.getFullYear(), updated.getMonth(), updated.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();
};
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: iso,
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],
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>
|