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
155
156
157
158
159
160
161
|
#!/usr/bin/env node
// 行事予定ページの「カテゴリで絞り込む」から「会場の地図」までを1枚の画像にする。
// X(旧Twitter)への投稿用。ビルド済みの dist/ をローカルで配信して撮影する。
//
// 使い方:
// node scripts/capture-schedule-shot.mjs
// node scripts/capture-schedule-shot.mjs --category 図書館 --width 900 --out drafts/schedule-card.png
// node scripts/capture-schedule-shot.mjs --zoom 16 … 地図の拡大(ピンが小さいとき)
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { chromium } from 'playwright';
const args = process.argv.slice(2);
const opt = (name, fallback) => {
const i = args.indexOf(`--${name}`);
return i >= 0 ? args[i + 1] : fallback;
};
const category = opt('category', '図書館');
// 1120px 付近だとサイドバーが出ず、本文が最も広くなる(X で見やすい縦横比になる)
const width = Number(opt('width', 1120));
// 本文の最大幅。広げると縦長になりすぎない
const contentWidth = opt('contentWidth', '56rem');
// カレンダーで選ぶ日付(YYYY-MM-DD)。未指定なら今日
const date = opt('date', '');
// 地図でクリックしてフキダシを開くピン(会場名の一部)。未指定ならクリックしない
const pin = opt('pin', '');
const out = opt('out', 'drafts/schedule-card.png');
const dpr = Number(opt('dpr', 2));
const root = path.resolve('dist');
if (!fs.existsSync(root)) {
console.error('dist がありません。先に npm run build を実行してください。');
process.exit(1);
}
const types = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.css': 'text/css',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.xml': 'application/xml',
'.json': 'application/json',
};
const server = http.createServer((req, res) => {
const urlPath = decodeURIComponent(req.url.split('?')[0]);
let file = path.join(root, urlPath);
if (path.extname(file) === '') file = path.join(file, 'index.html');
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404);
res.end('not found');
return;
}
res.writeHead(200, { 'content-type': types[path.extname(file)] || 'application/octet-stream' });
res.end(data);
});
});
await new Promise((r) => server.listen(0, r));
const port = server.address().port;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width, height: 2600 }, deviceScaleFactor: dpr });
await page.goto(`http://localhost:${port}/kodaira-data/hp-archive/schedule/`, {
waitUntil: 'networkidle',
});
await page.waitForSelector('#schedule-map.leaflet-container');
// 本文を広めにして、縦長になりすぎないようにする(X での見え方を考慮)
if (contentWidth) {
await page.addStyleTag({
content: `:root { --sl-content-width: ${contentWidth} !important; }`,
});
}
// カテゴリを選ぶ(地図・カレンダーに反映される)
if (category && category !== 'すべて') {
await page.evaluate((label) => {
const btn = [...document.querySelectorAll('.schedule-filter-btn')].find((b) =>
b.textContent.trim().replace(/^[^\s]+\s/, '').startsWith(label)
);
if (btn) btn.click();
}, category);
await page.waitForTimeout(400);
}
// 日付を選ぶ(見つからなければ月を送って探す)
if (date) {
let found = false;
for (let i = 0; i < 15 && !found; i += 1) {
found = await page.evaluate((key) => {
const btn = document.querySelector(`.schedule-cal-day[data-date="${key}"]`);
if (!btn) return false;
btn.click();
return true;
}, date);
if (found) break;
await page.evaluate(() => document.querySelector('.schedule-cal-nav[data-dir="1"]')?.click());
await page.waitForTimeout(200);
}
if (!found) throw new Error(`日付 ${date} のセルが見つかりませんでした`);
await page.waitForTimeout(600);
}
// 地図のピンをクリックしてフキダシを開く
if (pin) {
const opened = await page.evaluate((name) => {
const marker = [...document.querySelectorAll('.schedule-map-marker')].find((el) =>
(el.getAttribute('title') ?? '').includes(name)
);
if (!marker) return false;
marker.click();
return true;
}, pin);
if (!opened) throw new Error(`ピン「${pin}」が見つかりませんでした`);
await page.waitForSelector('.leaflet-popup', { timeout: 5000 });
}
// タイルの読み込みとピンの描画を待つ
await page.waitForTimeout(2500);
// 絞り込みの直前の説明文(または絞り込み欄)から、地図ブロックの下端までを切り抜く
const clip = await page.evaluate(() => {
const filters = document.getElementById('event-filters');
const mapBlock = document.querySelector('.schedule-map-block');
// 直前の段落(「カテゴリと日付を選ぶと…」)があれば上端に含める
let start = filters;
const prev = filters.previousElementSibling;
if (prev && prev.tagName === 'P' && prev.textContent.trim().length > 0) start = prev;
start.scrollIntoView({ block: 'start' });
window.scrollBy(0, -12);
const s = start.getBoundingClientRect();
const m = mapBlock.getBoundingClientRect();
const left = Math.max(0, Math.min(s.left, m.left) - 8);
const top = Math.max(0, s.top - 8);
const right = Math.min(window.innerWidth, Math.max(s.right, m.right) + 8);
return {
x: Math.round(left),
y: Math.round(top),
width: Math.round(right - left),
height: Math.round(m.bottom + 10 - top),
};
});
fs.mkdirSync(path.dirname(out), { recursive: true });
await page.screenshot({ path: out, clip });
console.log(`保存: ${out}(${clip.width}x${clip.height} CSS px × ${dpr}倍 = ${clip.width * dpr}x${clip.height * dpr}px)`);
await browser.close();
server.close();
|