web-corders-vrt 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +13 -0
- package/README.md +169 -0
- package/bin/vrt.ts +77 -0
- package/dist/bin/vrt.js +917 -0
- package/dist/bin/vrt.js.map +1 -0
- package/package.json +46 -0
- package/src/commands/init.ts +82 -0
- package/src/commands/run.ts +259 -0
- package/src/constants.ts +39 -0
- package/src/core/comparator.ts +95 -0
- package/src/core/region-detector.ts +277 -0
- package/src/core/screenshotter.ts +137 -0
- package/src/core/stabilizer.ts +59 -0
- package/src/reporters/html.ts +163 -0
- package/src/reporters/json.ts +15 -0
- package/src/reporters/terminal.ts +86 -0
- package/src/schemas.ts +16 -0
- package/src/types/pixelmatch.d.ts +22 -0
- package/src/types.ts +117 -0
- package/test/comparator.test.ts +153 -0
- package/test/region-detector.test.ts +147 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +15 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import pixelmatch from "pixelmatch";
|
|
2
|
+
import { PNG } from "pngjs";
|
|
3
|
+
import type { ComparisonResult } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 2つのPNG画像をピクセル単位で比較し、差分画像を生成する。
|
|
7
|
+
* サイズが異なる場合は大きい方に合わせて白パディングで拡張する。
|
|
8
|
+
*/
|
|
9
|
+
export function compareImages(
|
|
10
|
+
beforeBuffer: Buffer,
|
|
11
|
+
afterBuffer: Buffer,
|
|
12
|
+
threshold: number = 0.1,
|
|
13
|
+
): ComparisonResult {
|
|
14
|
+
const before = PNG.sync.read(beforeBuffer);
|
|
15
|
+
const after = PNG.sync.read(afterBuffer);
|
|
16
|
+
|
|
17
|
+
const width = Math.max(before.width, after.width);
|
|
18
|
+
const height = Math.max(before.height, after.height);
|
|
19
|
+
|
|
20
|
+
// サイズが異なる場合は正規化
|
|
21
|
+
const normalizedBefore = normalizeImage(before, width, height);
|
|
22
|
+
const normalizedAfter = normalizeImage(after, width, height);
|
|
23
|
+
|
|
24
|
+
const diff = new PNG({ width, height });
|
|
25
|
+
|
|
26
|
+
const diffCount = pixelmatch(
|
|
27
|
+
normalizedBefore.data,
|
|
28
|
+
normalizedAfter.data,
|
|
29
|
+
diff.data,
|
|
30
|
+
width,
|
|
31
|
+
height,
|
|
32
|
+
{
|
|
33
|
+
threshold: 0.1,
|
|
34
|
+
includeAA: false,
|
|
35
|
+
alpha: 0.1,
|
|
36
|
+
diffColor: [255, 0, 0],
|
|
37
|
+
diffColorAlt: [0, 200, 0],
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const totalPixels = width * height;
|
|
42
|
+
const diffPercentage = (diffCount / totalPixels) * 100;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
diffCount,
|
|
46
|
+
totalPixels,
|
|
47
|
+
diffPercentage,
|
|
48
|
+
diffImage: PNG.sync.write(diff),
|
|
49
|
+
passed: diffPercentage <= threshold,
|
|
50
|
+
dimensions: {
|
|
51
|
+
width,
|
|
52
|
+
height,
|
|
53
|
+
beforeHeight: before.height,
|
|
54
|
+
afterHeight: after.height,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 画像を指定サイズに正規化する。
|
|
61
|
+
* 小さい場合は白(#ffffff)でパディングする。
|
|
62
|
+
*/
|
|
63
|
+
function normalizeImage(
|
|
64
|
+
png: PNG,
|
|
65
|
+
targetWidth: number,
|
|
66
|
+
targetHeight: number,
|
|
67
|
+
): PNG {
|
|
68
|
+
if (png.width === targetWidth && png.height === targetHeight) {
|
|
69
|
+
return png;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const normalized = new PNG({ width: targetWidth, height: targetHeight });
|
|
73
|
+
|
|
74
|
+
// 白で埋める
|
|
75
|
+
for (let i = 0; i < normalized.data.length; i += 4) {
|
|
76
|
+
normalized.data[i] = 255; // R
|
|
77
|
+
normalized.data[i + 1] = 255; // G
|
|
78
|
+
normalized.data[i + 2] = 255; // B
|
|
79
|
+
normalized.data[i + 3] = 255; // A
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 元の画像をコピー
|
|
83
|
+
for (let y = 0; y < png.height; y++) {
|
|
84
|
+
for (let x = 0; x < png.width; x++) {
|
|
85
|
+
const srcIdx = (y * png.width + x) * 4;
|
|
86
|
+
const dstIdx = (y * targetWidth + x) * 4;
|
|
87
|
+
normalized.data[dstIdx] = png.data[srcIdx];
|
|
88
|
+
normalized.data[dstIdx + 1] = png.data[srcIdx + 1];
|
|
89
|
+
normalized.data[dstIdx + 2] = png.data[srcIdx + 2];
|
|
90
|
+
normalized.data[dstIdx + 3] = png.data[srcIdx + 3];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return normalized;
|
|
95
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { PNG } from "pngjs";
|
|
2
|
+
import type {
|
|
3
|
+
VrtDiffRegion,
|
|
4
|
+
VerticalPosition,
|
|
5
|
+
HorizontalPosition,
|
|
6
|
+
} from "../types.js";
|
|
7
|
+
|
|
8
|
+
export interface RegionDetectorOptions {
|
|
9
|
+
/** この面積(px)未満の領域は無視する */
|
|
10
|
+
minRegionSize?: number;
|
|
11
|
+
/** この距離(px)以下の差分ピクセルを同一領域としてマージする */
|
|
12
|
+
mergingDistance?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* diff画像から差分領域を検出し、座標・位置ヒントを返す。
|
|
17
|
+
* Connected Component Labeling + bounding box 方式。
|
|
18
|
+
*/
|
|
19
|
+
export function detectDiffRegions(
|
|
20
|
+
diffImageBuffer: Buffer,
|
|
21
|
+
options: RegionDetectorOptions = {},
|
|
22
|
+
): VrtDiffRegion[] {
|
|
23
|
+
const { minRegionSize = 10, mergingDistance = 50 } = options;
|
|
24
|
+
|
|
25
|
+
const png = PNG.sync.read(diffImageBuffer);
|
|
26
|
+
const { width, height, data } = png;
|
|
27
|
+
|
|
28
|
+
// 1. diff ピクセル(赤色)の座標を収集
|
|
29
|
+
const diffPixels: Array<{ x: number; y: number }> = [];
|
|
30
|
+
for (let y = 0; y < height; y++) {
|
|
31
|
+
for (let x = 0; x < width; x++) {
|
|
32
|
+
const idx = (y * width + x) * 4;
|
|
33
|
+
const r = data[idx];
|
|
34
|
+
const g = data[idx + 1];
|
|
35
|
+
const b = data[idx + 2];
|
|
36
|
+
const a = data[idx + 3];
|
|
37
|
+
|
|
38
|
+
// pixelmatch の diffColor [255, 0, 0] または diffColorAlt [0, 200, 0] を検出
|
|
39
|
+
if (
|
|
40
|
+
a > 100 &&
|
|
41
|
+
((r > 200 && g < 100 && b < 100) || (r < 100 && g > 150 && b < 100))
|
|
42
|
+
) {
|
|
43
|
+
diffPixels.push({ x, y });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (diffPixels.length === 0) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. グリッドベースのクラスタリング(高速化のため)
|
|
53
|
+
const clusters = clusterPixels(diffPixels, mergingDistance);
|
|
54
|
+
|
|
55
|
+
// 3. 各クラスタの bounding box を計算し、フィルタリング
|
|
56
|
+
const regions: VrtDiffRegion[] = clusters
|
|
57
|
+
.map((cluster, idx) => {
|
|
58
|
+
let minX = Infinity;
|
|
59
|
+
let maxX = -Infinity;
|
|
60
|
+
let minY = Infinity;
|
|
61
|
+
let maxY = -Infinity;
|
|
62
|
+
for (const p of cluster) {
|
|
63
|
+
if (p.x < minX) minX = p.x;
|
|
64
|
+
if (p.x > maxX) maxX = p.x;
|
|
65
|
+
if (p.y < minY) minY = p.y;
|
|
66
|
+
if (p.y > maxY) maxY = p.y;
|
|
67
|
+
}
|
|
68
|
+
const regionWidth = maxX - minX + 1;
|
|
69
|
+
const regionHeight = maxY - minY + 1;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id: idx + 1,
|
|
73
|
+
boundingBox: {
|
|
74
|
+
x: minX,
|
|
75
|
+
y: minY,
|
|
76
|
+
width: regionWidth,
|
|
77
|
+
height: regionHeight,
|
|
78
|
+
},
|
|
79
|
+
diffPixelCount: cluster.length,
|
|
80
|
+
diffPercentageInRegion:
|
|
81
|
+
(cluster.length / (regionWidth * regionHeight)) * 100,
|
|
82
|
+
locationHint: estimatePosition(
|
|
83
|
+
minX,
|
|
84
|
+
minY,
|
|
85
|
+
regionWidth,
|
|
86
|
+
regionHeight,
|
|
87
|
+
width,
|
|
88
|
+
height,
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
})
|
|
92
|
+
.filter((r) => r.diffPixelCount >= minRegionSize)
|
|
93
|
+
.sort((a, b) => b.diffPixelCount - a.diffPixelCount);
|
|
94
|
+
|
|
95
|
+
// IDを振り直す
|
|
96
|
+
return regions.map((r, i) => ({ ...r, id: i + 1 }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* ピクセルをグリッドベースでクラスタリングする。
|
|
101
|
+
* mergingDistance をセルサイズとしてグリッドに分割し、
|
|
102
|
+
* 隣接セルのピクセルを同一クラスタとしてマージする。
|
|
103
|
+
*/
|
|
104
|
+
function clusterPixels(
|
|
105
|
+
pixels: Array<{ x: number; y: number }>,
|
|
106
|
+
distance: number,
|
|
107
|
+
): Array<Array<{ x: number; y: number }>> {
|
|
108
|
+
const cellSize = Math.max(distance, 1);
|
|
109
|
+
const grid = new Map<string, Array<{ x: number; y: number }>>();
|
|
110
|
+
|
|
111
|
+
// グリッドに割り当て
|
|
112
|
+
for (const p of pixels) {
|
|
113
|
+
const cellX = Math.floor(p.x / cellSize);
|
|
114
|
+
const cellY = Math.floor(p.y / cellSize);
|
|
115
|
+
const key = `${cellX},${cellY}`;
|
|
116
|
+
if (!grid.has(key)) {
|
|
117
|
+
grid.set(key, []);
|
|
118
|
+
}
|
|
119
|
+
grid.get(key)!.push(p);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Union-Find でセルをクラスタリング
|
|
123
|
+
const cellKeys = Array.from(grid.keys());
|
|
124
|
+
const parent = new Map<string, string>();
|
|
125
|
+
|
|
126
|
+
function find(key: string): string {
|
|
127
|
+
if (!parent.has(key)) parent.set(key, key);
|
|
128
|
+
// 反復的にルートを探索
|
|
129
|
+
let root = key;
|
|
130
|
+
while (parent.get(root) !== root) {
|
|
131
|
+
root = parent.get(root)!;
|
|
132
|
+
}
|
|
133
|
+
// パス圧縮
|
|
134
|
+
let current = key;
|
|
135
|
+
while (current !== root) {
|
|
136
|
+
const next = parent.get(current)!;
|
|
137
|
+
parent.set(current, root);
|
|
138
|
+
current = next;
|
|
139
|
+
}
|
|
140
|
+
return root;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function union(a: string, b: string): void {
|
|
144
|
+
const ra = find(a);
|
|
145
|
+
const rb = find(b);
|
|
146
|
+
if (ra !== rb) parent.set(ra, rb);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 隣接セルをマージ
|
|
150
|
+
for (const key of cellKeys) {
|
|
151
|
+
const [cx, cy] = key.split(",").map(Number);
|
|
152
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
153
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
154
|
+
if (dx === 0 && dy === 0) continue;
|
|
155
|
+
const neighborKey = `${cx + dx},${cy + dy}`;
|
|
156
|
+
if (grid.has(neighborKey)) {
|
|
157
|
+
union(key, neighborKey);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// クラスタごとにピクセルを集約
|
|
164
|
+
const clusters = new Map<string, Array<{ x: number; y: number }>>();
|
|
165
|
+
for (const key of cellKeys) {
|
|
166
|
+
const root = find(key);
|
|
167
|
+
if (!clusters.has(root)) {
|
|
168
|
+
clusters.set(root, []);
|
|
169
|
+
}
|
|
170
|
+
clusters.get(root)!.push(...grid.get(key)!);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return Array.from(clusters.values());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 差分領域の位置から、CSSの修正箇所を推定するヒントを生成する。
|
|
178
|
+
*/
|
|
179
|
+
function estimatePosition(
|
|
180
|
+
x: number,
|
|
181
|
+
y: number,
|
|
182
|
+
regionWidth: number,
|
|
183
|
+
regionHeight: number,
|
|
184
|
+
pageWidth: number,
|
|
185
|
+
pageHeight: number,
|
|
186
|
+
): VrtDiffRegion["locationHint"] {
|
|
187
|
+
// 垂直位置の推定
|
|
188
|
+
const centerY = y + regionHeight / 2;
|
|
189
|
+
const yRatio = centerY / pageHeight;
|
|
190
|
+
let verticalPosition: VerticalPosition;
|
|
191
|
+
if (yRatio < 0.1) verticalPosition = "top";
|
|
192
|
+
else if (yRatio < 0.3) verticalPosition = "upper";
|
|
193
|
+
else if (yRatio < 0.7) verticalPosition = "middle";
|
|
194
|
+
else if (yRatio < 0.9) verticalPosition = "lower";
|
|
195
|
+
else verticalPosition = "bottom";
|
|
196
|
+
|
|
197
|
+
// 水平位置の推定
|
|
198
|
+
const centerX = x + regionWidth / 2;
|
|
199
|
+
const widthRatio = regionWidth / pageWidth;
|
|
200
|
+
let horizontalPosition: HorizontalPosition;
|
|
201
|
+
if (widthRatio > 0.8) {
|
|
202
|
+
horizontalPosition = "full-width";
|
|
203
|
+
} else if (centerX < pageWidth * 0.33) {
|
|
204
|
+
horizontalPosition = "left";
|
|
205
|
+
} else if (centerX > pageWidth * 0.67) {
|
|
206
|
+
horizontalPosition = "right";
|
|
207
|
+
} else {
|
|
208
|
+
horizontalPosition = "center";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 要素の推定
|
|
212
|
+
const estimatedElement = guessElement(
|
|
213
|
+
verticalPosition,
|
|
214
|
+
horizontalPosition,
|
|
215
|
+
regionWidth,
|
|
216
|
+
regionHeight,
|
|
217
|
+
pageWidth,
|
|
218
|
+
y,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
verticalPosition,
|
|
223
|
+
horizontalPosition,
|
|
224
|
+
fromTopPx: y,
|
|
225
|
+
fromLeftPx: x,
|
|
226
|
+
estimatedElement,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 位置とサイズから、可能性のあるUI要素を推測する。
|
|
232
|
+
*/
|
|
233
|
+
function guessElement(
|
|
234
|
+
vPos: VerticalPosition,
|
|
235
|
+
hPos: HorizontalPosition,
|
|
236
|
+
width: number,
|
|
237
|
+
height: number,
|
|
238
|
+
pageWidth: number,
|
|
239
|
+
fromTop: number,
|
|
240
|
+
): string {
|
|
241
|
+
// ページ最上部の全幅要素 → ヘッダー/ナビゲーション
|
|
242
|
+
if (vPos === "top" && hPos === "full-width" && fromTop < 100) {
|
|
243
|
+
return "Likely a header or navigation bar";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ページ最下部の全幅要素 → フッター
|
|
247
|
+
if (vPos === "bottom" && hPos === "full-width") {
|
|
248
|
+
return "Likely a footer";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 上部の大きな要素 → ヒーローセクション
|
|
252
|
+
if (vPos === "upper" && hPos === "full-width" && height > 200) {
|
|
253
|
+
return "Likely a hero section or banner";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 小さなボタンサイズ
|
|
257
|
+
if (width < 200 && height < 60) {
|
|
258
|
+
return "Likely a button or small UI element";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 横長の細い要素 → テキスト行
|
|
262
|
+
if (width > pageWidth * 0.5 && height < 40) {
|
|
263
|
+
return "Likely a text line or heading";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// カードサイズ
|
|
267
|
+
if (width > 200 && width < 500 && height > 100 && height < 400) {
|
|
268
|
+
return "Likely a card or content block";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 画像サイズ
|
|
272
|
+
if (width > 100 && height > 100 && width < pageWidth * 0.8) {
|
|
273
|
+
return "Likely an image or media element";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return `UI element at ~${fromTop}px from top`;
|
|
277
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { chromium, type Browser, type BrowserContext } from "playwright";
|
|
2
|
+
import type { ScreenshotResult, ViewportType } from "../types.js";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_SP_VIEWPORT,
|
|
5
|
+
DEFAULT_PC_VIEWPORT,
|
|
6
|
+
DEFAULT_DELAY,
|
|
7
|
+
DEFAULT_CONCURRENCY,
|
|
8
|
+
SP_USER_AGENT,
|
|
9
|
+
BLOCKED_DOMAINS,
|
|
10
|
+
} from "../constants.js";
|
|
11
|
+
import { stabilizePage, getDateMockScript } from "./stabilizer.js";
|
|
12
|
+
|
|
13
|
+
export class Screenshotter {
|
|
14
|
+
private browser: Browser | null = null;
|
|
15
|
+
|
|
16
|
+
async initialize(): Promise<void> {
|
|
17
|
+
this.browser = await chromium.launch();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 指定URLのスクリーンショットを取得する。
|
|
22
|
+
*/
|
|
23
|
+
async capture(
|
|
24
|
+
url: string,
|
|
25
|
+
pagePath: string,
|
|
26
|
+
viewportType: ViewportType,
|
|
27
|
+
hideSelectors: string[],
|
|
28
|
+
): Promise<ScreenshotResult> {
|
|
29
|
+
if (!this.browser) {
|
|
30
|
+
throw new Error("Browser not initialized. Call initialize() first.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const viewport =
|
|
34
|
+
viewportType === "sp"
|
|
35
|
+
? { ...DEFAULT_SP_VIEWPORT }
|
|
36
|
+
: { ...DEFAULT_PC_VIEWPORT };
|
|
37
|
+
|
|
38
|
+
const context: BrowserContext = await this.browser.newContext({
|
|
39
|
+
viewport,
|
|
40
|
+
deviceScaleFactor: viewportType === "sp" ? 2 : 1,
|
|
41
|
+
userAgent: viewportType === "sp" ? SP_USER_AGENT : undefined,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const page = await context.newPage();
|
|
45
|
+
|
|
46
|
+
// 日付をモック
|
|
47
|
+
await page.addInitScript(getDateMockScript());
|
|
48
|
+
|
|
49
|
+
// 広告・計測系リクエストをブロック
|
|
50
|
+
await page.route("**/*", (route) => {
|
|
51
|
+
const reqUrl = route.request().url();
|
|
52
|
+
const shouldBlock = BLOCKED_DOMAINS.some((domain) =>
|
|
53
|
+
reqUrl.includes(domain),
|
|
54
|
+
);
|
|
55
|
+
if (shouldBlock) {
|
|
56
|
+
return route.abort();
|
|
57
|
+
}
|
|
58
|
+
return route.continue();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ページにアクセス
|
|
62
|
+
await page.goto(url, { waitUntil: "load", timeout: 60000 });
|
|
63
|
+
|
|
64
|
+
// ページの安定化
|
|
65
|
+
await stabilizePage(page, {
|
|
66
|
+
hideSelectors,
|
|
67
|
+
delay: DEFAULT_DELAY,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// スクリーンショット取得
|
|
71
|
+
const buffer = await page.screenshot({
|
|
72
|
+
fullPage: true,
|
|
73
|
+
type: "png",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 画像サイズを取得(PNGヘッダから読む)
|
|
77
|
+
const width = buffer.readUInt32BE(16);
|
|
78
|
+
const height = buffer.readUInt32BE(20);
|
|
79
|
+
|
|
80
|
+
await context.close();
|
|
81
|
+
|
|
82
|
+
// パスからページ名を生成
|
|
83
|
+
const pageName =
|
|
84
|
+
pagePath === "/"
|
|
85
|
+
? "top"
|
|
86
|
+
: pagePath.replace(/^\//, "").replace(/\//g, "-");
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
pagePath,
|
|
90
|
+
pageName,
|
|
91
|
+
viewportType,
|
|
92
|
+
buffer: Buffer.from(buffer),
|
|
93
|
+
width,
|
|
94
|
+
height,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 複数のページ・ビューポートのスクリーンショットを並行で取得する。
|
|
100
|
+
*/
|
|
101
|
+
async captureAll(
|
|
102
|
+
baseUrl: string,
|
|
103
|
+
paths: string[],
|
|
104
|
+
viewports: ViewportType[],
|
|
105
|
+
hideSelectors: string[],
|
|
106
|
+
): Promise<ScreenshotResult[]> {
|
|
107
|
+
const tasks: Array<{ url: string; path: string; viewport: ViewportType }> =
|
|
108
|
+
[];
|
|
109
|
+
|
|
110
|
+
for (const pagePath of paths) {
|
|
111
|
+
const url = new URL(pagePath, baseUrl).toString();
|
|
112
|
+
for (const viewport of viewports) {
|
|
113
|
+
tasks.push({ url, path: pagePath, viewport });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 並行実行制御
|
|
118
|
+
const results: ScreenshotResult[] = [];
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < tasks.length; i += DEFAULT_CONCURRENCY) {
|
|
121
|
+
const batch = tasks.slice(i, i + DEFAULT_CONCURRENCY);
|
|
122
|
+
const batchResults = await Promise.all(
|
|
123
|
+
batch.map((task) =>
|
|
124
|
+
this.capture(task.url, task.path, task.viewport, hideSelectors),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
results.push(...batchResults);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async cleanup(): Promise<void> {
|
|
134
|
+
await this.browser?.close();
|
|
135
|
+
this.browser = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import { DISABLE_ANIMATIONS_CSS } from "../constants.js";
|
|
3
|
+
|
|
4
|
+
export interface StabilizeOptions {
|
|
5
|
+
disableAnimations?: boolean;
|
|
6
|
+
hideSelectors?: string[];
|
|
7
|
+
delay?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ページの動的コンテンツを安定化させる。
|
|
12
|
+
* アニメーション無効化、要素の非表示、日付のモックなどを行う。
|
|
13
|
+
*/
|
|
14
|
+
export async function stabilizePage(
|
|
15
|
+
page: Page,
|
|
16
|
+
options: StabilizeOptions,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
// アニメーション・トランジションを無効化
|
|
19
|
+
if (options.disableAnimations !== false) {
|
|
20
|
+
await page.addStyleTag({ content: DISABLE_ANIMATIONS_CSS });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 指定要素を非表示にする(visibility: hidden でレイアウトを崩さない)
|
|
24
|
+
if (options.hideSelectors && options.hideSelectors.length > 0) {
|
|
25
|
+
const hideCSS = options.hideSelectors
|
|
26
|
+
.map((s) => `${s} { visibility: hidden !important; }`)
|
|
27
|
+
.join("\n");
|
|
28
|
+
await page.addStyleTag({ content: hideCSS });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 追加の待機時間
|
|
32
|
+
if (options.delay && options.delay > 0) {
|
|
33
|
+
await page.waitForTimeout(options.delay);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 日付をモックして固定値にする初期化スクリプト。
|
|
39
|
+
* page.goto() の前に page.addInitScript() で使う。
|
|
40
|
+
*/
|
|
41
|
+
export function getDateMockScript(): string {
|
|
42
|
+
return `
|
|
43
|
+
(() => {
|
|
44
|
+
const fixedDate = new Date('2025-01-01T00:00:00Z');
|
|
45
|
+
const OrigDate = Date;
|
|
46
|
+
const MockDate = class extends OrigDate {
|
|
47
|
+
constructor(...args) {
|
|
48
|
+
if (args.length === 0) {
|
|
49
|
+
super(fixedDate.getTime());
|
|
50
|
+
} else {
|
|
51
|
+
super(...args);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
static now() { return fixedDate.getTime(); }
|
|
55
|
+
};
|
|
56
|
+
globalThis.Date = MockDate;
|
|
57
|
+
})();
|
|
58
|
+
`;
|
|
59
|
+
}
|