Support explicit Puppeteer and Cloudflare screenshot providers

This commit is contained in:
Matt Isenhower
2026-09-07 17:44:11 -07:00
parent 489e471e3c
commit bee6bda585
23 changed files with 799 additions and 211 deletions

376
package-lock.json generated
View File

@@ -23,6 +23,8 @@
"lodash": "^4.18.1",
"mkdirp": "^3.0.1",
"moment-timezone": "^0.6.3",
"puppeteer": "^25.10.0",
"serve-handler": "^6.1.7",
"sharp": "^0.35.4",
"v-click-outside": "^3.0.1",
"vue": "^2.7.16",
@@ -3877,6 +3879,186 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@puppeteer/browsers": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
"integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==",
"license": "Apache-2.0",
"dependencies": {
"modern-tar": "^0.8.4",
"yargs": "^18.0.0"
},
"bin": {
"browsers": "lib/main-cli.js"
},
"engines": {
"node": ">=22.12.0"
},
"peerDependencies": {
"proxy-agent": ">=8.0.1",
"yauzl": "^2.10.0 || ^3.4.0"
},
"peerDependenciesMeta": {
"proxy-agent": {
"optional": true
},
"yauzl": {
"optional": true
}
}
},
"node_modules/@puppeteer/browsers/node_modules/ansi-regex": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@puppeteer/browsers/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@puppeteer/browsers/node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@puppeteer/browsers/node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@puppeteer/browsers/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/@puppeteer/browsers/node_modules/string-width": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@puppeteer/browsers/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@puppeteer/browsers/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@puppeteer/browsers/node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@puppeteer/browsers/node_modules/yargs": {
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^8.2.1",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/@puppeteer/browsers/node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz",
@@ -5961,7 +6143,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
@@ -6118,7 +6299,6 @@
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -6420,6 +6600,22 @@
"node": ">=6.0"
}
},
"node_modules/chromium-bidi": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/cjs-module-lexer": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz",
@@ -6676,7 +6872,6 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/connect-history-api-fallback": {
@@ -7510,6 +7705,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/devtools-protocol": {
"version": "0.0.1666840",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz",
"integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==",
"license": "BSD-3-Clause"
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -7854,7 +8055,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -8767,12 +8967,23 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -10323,6 +10534,18 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -10806,6 +11029,27 @@
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz",
"integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==",
"license": "MIT",
"dependencies": {
"mime-db": "~1.33.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types/node_modules/mime-db": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
"integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mimic-fn": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
@@ -11520,7 +11764,6 @@
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -11677,6 +11920,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
@@ -11692,6 +11941,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/modern-tar": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/module-alias": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.3.4.tgz",
@@ -12416,6 +12674,12 @@
"node": ">=0.10.0"
}
},
"node_modules/path-is-inside": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
"integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==",
"license": "(WTFPL OR MIT)"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -13355,6 +13619,44 @@
"node": ">=6"
}
},
"node_modules/puppeteer": {
"version": "25.10.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.10.0.tgz",
"integrity": "sha512-9ZfkiaZDQWpGPJp9XTS+Bkn/D78hPvYmtjPfIBeybn05oeY6Jj7aiSbYdfcSQD2UMvC0vE7Yi9PSDo179euRzw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.2.2",
"chromium-bidi": "17.0.2",
"devtools-protocol": "0.0.1666840",
"lilconfig": "^3.1.3",
"puppeteer-core": "25.10.0",
"typed-query-selector": "^2.12.2"
},
"bin": {
"puppeteer": "lib/puppeteer/node/cli.js"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/puppeteer-core": {
"version": "25.10.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz",
"integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.2.2",
"chromium-bidi": "17.0.2",
"devtools-protocol": "0.0.1666840",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.3",
"ws": "^8.21.3"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/purgecss": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/purgecss/-/purgecss-8.0.0.tgz",
@@ -14137,6 +14439,54 @@
"randombytes": "^2.1.0"
}
},
"node_modules/serve-handler": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz",
"integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==",
"license": "MIT",
"dependencies": {
"bytes": "3.0.0",
"content-disposition": "0.5.2",
"mime-types": "2.1.18",
"minimatch": "3.1.5",
"path-is-inside": "1.0.2",
"path-to-regexp": "3.3.0",
"range-parser": "1.2.0"
}
},
"node_modules/serve-handler/node_modules/bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
"integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/serve-handler/node_modules/content-disposition": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
"integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/serve-handler/node_modules/path-to-regexp": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz",
"integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==",
"license": "MIT"
},
"node_modules/serve-handler/node_modules/range-parser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
"integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/serve-index": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz",
@@ -15256,6 +15606,12 @@
"node": ">= 0.6"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"license": "MIT"
},
"node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
@@ -15907,6 +16263,12 @@
"defaults": "^1.0.3"
}
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -16591,7 +16953,6 @@
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -16623,7 +16984,6 @@
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"

View File

@@ -39,7 +39,8 @@
"updater:tail": "wrangler tail --config workers/updater/wrangler.jsonc",
"social": "node src/app/index.js social",
"social:test": "node src/app/index.js socialTest",
"admin:preview": "node workers/updater/preview/server.mjs"
"admin:preview": "node workers/updater/preview/server.mjs",
"screenshot": "node src/app/screenshots/cli.js"
},
"dependencies": {
"@atproto/api": "^0.20.42",
@@ -59,6 +60,8 @@
"lodash": "^4.18.1",
"mkdirp": "^3.0.1",
"moment-timezone": "^0.6.3",
"puppeteer": "^25.10.0",
"serve-handler": "^6.1.7",
"sharp": "^0.35.4",
"v-click-outside": "^3.0.1",
"vue": "^2.7.16",

View File

@@ -4,6 +4,7 @@
import FilesystemStorage from '../common/storage/FilesystemStorage.js';
import * as updater from './updater/index.js';
import * as social from './social/index.js';
import { withScreenshots } from './screenshots/node.js';
export function filesystemStorage() {
return {
@@ -20,12 +21,13 @@ export async function updateAll() {
}
export async function sendStatuses() {
let result = await social.sendStatuses(filesystemStorage());
let clients = social.createClients();
let result = await withScreenshots(screenshots => social.sendStatuses(filesystemStorage(), clients, screenshots));
if (!result.ok)
throw new Error('One or more social posts failed.');
return result;
}
export function testScreenshots() {
return social.testScreenshots(filesystemStorage());
return withScreenshots(screenshots => social.testScreenshots(filesystemStorage(), [], screenshots));
}

View File

@@ -0,0 +1,79 @@
import { logMessage } from '../log.js';
import { fetchWithTimeout } from '../../common/fetch.js';
async function errorMessage(response) {
let body = await response.text();
try {
let messages = JSON.parse(body).errors?.map(error => error.message).filter(Boolean);
if (messages?.length)
return messages.join('; ');
} catch {
// Not JSON; use the body as-is
}
return body || response.statusText || 'Unknown error';
}
// Browser Run's REST client works in both Node and Workers.
export default class BrowserRunClient {
constructor({ accountId, apiToken }) {
this.accountId = accountId;
this.apiToken = apiToken;
}
async capture({ url, viewport, readySelector }) {
let missing = [];
if (!this.accountId)
missing.push('CLOUDFLARE_ACCOUNT_ID');
if (!this.apiToken)
missing.push('CLOUDFLARE_BROWSER_RUN_API_TOKEN');
if (missing.length)
throw new Error(`Missing screenshot configuration: ${missing.join(', ')}`);
let endpoint = new URL(`/client/v4/accounts/${this.accountId}/browser-rendering/screenshot`, 'https://api.cloudflare.com');
endpoint.searchParams.set('cacheTTL', '0');
let request = {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: url.toString(),
viewport,
gotoOptions: { waitUntil: 'domcontentloaded', timeout: 10_000 },
waitForSelector: { selector: readySelector, timeout: 10_000 },
actionTimeout: 10_000,
setExtraHTTPHeaders: { 'Cache-Control': 'no-cache' },
screenshotOptions: { type: 'png' },
}),
};
let image;
for (let attempt = 0; attempt <= 3; attempt++) {
try {
// Three 10s browser phases plus transport overhead; covers response body too.
let response = await fetchWithTimeout(endpoint, request, 40_000);
if (!response.ok) {
let message = await errorMessage(response);
let error = new Error(`Browser Rendering screenshot failed (${response.status}): ${message}`);
// Browser Run reports navigation/selector/action timeouts as 422 errors.
error.retryable = response.status === 408 || response.status >= 500
|| (response.status === 422 && /timeout|timed out/i.test(message));
throw error;
}
image = new Uint8Array(await response.arrayBuffer());
break;
} catch (error) {
let retryable = error.retryable ?? ['TimeoutError', 'TypeError'].includes(error.name);
if (!retryable || attempt === 3)
throw error;
let delayMs = 500 * 2 ** attempt;
logMessage('warn', 'Retrying Browser Run screenshot', { attempt: attempt + 1, delayMs, error: error.message });
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return image;
}
}

View File

@@ -0,0 +1,21 @@
import puppeteer from 'puppeteer';
// This module is imported only by Node entry points, never by the Worker.
export default class PuppeteerRenderer {
async capture({ url, viewport, readySelector }) {
let browser = await puppeteer.launch({ timeout: 10_000, protocolTimeout: 10_000 });
try {
let page = await browser.newPage();
page.setDefaultTimeout(10_000);
await page.setViewport(viewport);
await page.setCacheEnabled(false);
let response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10_000 });
if (!response?.ok())
throw new Error(`Screenshot page failed (${response?.status() ?? 'no response'}): ${url}`);
await page.waitForSelector(readySelector);
return new Uint8Array(await page.screenshot({ type: 'png' }));
} finally {
await browser.close();
}
}
}

View File

@@ -0,0 +1,84 @@
import { screenshotReadySelector } from '../../common/screenshot.js';
export const defaultViewport = {
// Use a 16:9 ratio for the public social image.
// 1216 was chosen as the width because of Bulma's "widescreen" breakpoint
width: 1216,
height: 684,
deviceScaleFactor: 2,
};
// Routes and image dimensions are shared regardless of where the browser runs.
export default class ScreenshotGenerator {
constructor(renderer, siteUrl) {
this.renderer = renderer;
this.siteUrl = siteUrl;
}
async capture({ hash, url, viewport = {} }) {
if (!url) {
if (!this.siteUrl)
throw new Error('SITE_URL is required to capture a screenshot route.');
url = new URL('/screenshots.html', this.siteUrl);
url.hash = hash;
}
let captureViewport = { ...defaultViewport, ...viewport };
let image = await this.renderer.capture({
url: new URL(url).toString(),
viewport: captureViewport,
readySelector: screenshotReadySelector,
});
return {
image,
type: 'image/png',
width: captureViewport.width * captureViewport.deviceScaleFactor,
height: captureViewport.height * captureViewport.deviceScaleFactor,
};
}
captureScheduleScreenshot(now) {
let hash = `/schedules/${now}`;
return this.capture({ hash });
}
captureGearScreenshot(now) {
let hash = `/splatNetGear/${now}`;
return this.capture({ hash });
}
captureSalmonRunScreenshot(now, mode) {
let hash = `/salmonRun/${now}?mode=${mode}`;
return this.capture({ hash });
}
captureSalmonRunGearScreenshot(now) {
let hash = `/salmonRunGear/${now}`;
return this.capture({ hash });
}
captureNewWeaponScreenshot(now, weaponCount) {
let hash = `/newWeapon/${now}`;
// There are a max of 4 weapons per row
const rows = Math.ceil(weaponCount / 4);
// Determine the image height based on the number of rows
let height = rows * 320;
// Add some extra height for the bottom banner
height += 60;
// Set a minimum overall image height
height = Math.max(height, 700);
return this.capture({ hash, viewport: { height } });
}
captureSplatfestScreenshot(region, now, regions) {
regions = regions.join(',');
let hash = `/splatfest/${region}/${now}?regions=${regions}`;
return this.capture({ hash });
}
}

View File

@@ -0,0 +1,33 @@
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parseArgs } from 'node:util';
import { withScreenshots } from './node.js';
// Capture-only: no social clients, checkpoints, or production-data comparisons.
if (existsSync('.env'))
process.loadEnvFile();
try {
let { values } = parseArgs({ options: {
provider: { type: 'string' },
url: { type: 'string' },
hash: { type: 'string' },
output: { type: 'string', default: 'dist/test-screenshots/capture.png' },
help: { type: 'boolean' },
} });
if (values.help) {
console.log('Usage: npm run screenshot -- [--provider puppeteer|cloudflare] (--url <page URL> | --hash <screenshot route>) [--output <file.png>]');
console.log('Uses SCREENSHOT_PROVIDER and SITE_URL from the environment or .env. Without SITE_URL, Puppeteer serves dist/ temporarily.');
} else {
if (!!values.url === !!values.hash)
throw new Error('Specify exactly one of --url or --hash. Use --help for examples of the options.');
let result = await withScreenshots(screenshots => screenshots.capture(values), values);
await mkdir(dirname(values.output), { recursive: true });
await writeFile(values.output, result.image);
console.log(`Saved ${result.width}×${result.height} PNG to ${values.output}`);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}

View File

@@ -1 +0,0 @@
export * from './screenshots.js';

View File

@@ -0,0 +1,54 @@
import { createServer } from 'node:http';
import { access } from 'node:fs/promises';
import handler from 'serve-handler';
import BrowserRunClient from './BrowserRunClient.js';
import PuppeteerRenderer from './PuppeteerRenderer.js';
import ScreenshotGenerator from './ScreenshotGenerator.js';
// Provider selection and the temporary file server belong to the Node command.
// The Worker constructs its BrowserRunClient directly.
export async function withScreenshots(callback, {
provider = process.env.SCREENSHOT_PROVIDER,
siteUrl = process.env.SITE_URL,
url,
} = {}) {
let renderer;
if (provider === 'cloudflare') {
renderer = new BrowserRunClient({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN,
});
if (!siteUrl && !url)
throw new Error('SITE_URL or --url is required for Cloudflare screenshots.');
} else if (provider === 'puppeteer') {
renderer = new PuppeteerRenderer;
} else {
throw new Error('SCREENSHOT_PROVIDER must be "puppeteer" or "cloudflare" (or pass --provider).');
}
let server;
try {
if (!siteUrl && !url) {
await access('dist/screenshots.html').catch(() => {
throw new Error('Missing dist/screenshots.html. Run npm run build, or set SITE_URL to your dev server.');
});
server = createServer((request, response) => {
handler(request, response, { public: 'dist', cleanUrls: false, directoryListing: false }).catch(error => {
console.error(error);
response.destroy();
});
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
siteUrl = `http://127.0.0.1:${server.address().port}`;
}
return await callback(new ScreenshotGenerator(renderer, siteUrl));
} finally {
if (server) {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}
}
}

View File

@@ -1,156 +0,0 @@
import { logMessage } from '../log.js';
import { fetchWithTimeout } from '../../common/fetch.js';
import { screenshotReadySelector } from '../../common/screenshot.js';
// Screenshots of the site's screenshot page, rendered by Cloudflare Browser Rendering's REST
// API. It is plain fetch, so the same code runs under Node and in a Worker. The page is the
// deployed one (SITE_URL), which reads the published data.
//
// Configuration (environment variables / Worker secrets):
// SITE_URL e.g. https://splatoon2.ink
// CLOUDFLARE_ACCOUNT_ID
// CLOUDFLARE_BROWSER_RUN_API_TOKEN an API token with Browser Rendering permission
const viewport = {
// Use a 16:9 ratio for the public social image.
// 1216 was chosen as the width because of Bulma's "widescreen" breakpoint
width: 1216,
height: 684,
deviceScaleFactor: 2,
};
function config() {
let names = ['SITE_URL', 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_BROWSER_RUN_API_TOKEN'];
let missing = names.filter(name => !process.env[name]);
if (missing.length)
throw new Error(`Missing screenshot configuration: ${missing.join(', ')}`);
return {
siteUrl: process.env.SITE_URL,
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN,
};
}
async function errorMessage(response) {
let body = await response.text();
try {
let messages = JSON.parse(body).errors?.map(error => error.message).filter(Boolean);
if (messages?.length)
return messages.join('; ');
} catch {
// Not JSON; use the body as-is
}
return body || response.statusText || 'Unknown error';
}
/**
* @param {{ hash: string, viewport?: object }} options
* @returns {Promise<{ image: Uint8Array, type: string, width: number, height: number }>}
*/
export async function captureScreenshot({ hash, viewport: viewportOverrides }) {
let { siteUrl, accountId, apiToken } = config();
let thisViewport = Object.assign({}, viewport, viewportOverrides);
let url = new URL('/screenshots.html', siteUrl);
url.hash = hash;
let endpoint = new URL(`/client/v4/accounts/${accountId}/browser-rendering/screenshot`, 'https://api.cloudflare.com');
endpoint.searchParams.set('cacheTTL', '0');
let request = {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: url.toString(),
viewport: thisViewport,
gotoOptions: { waitUntil: 'domcontentloaded', timeout: 10_000 },
waitForSelector: { selector: screenshotReadySelector, timeout: 10_000 },
actionTimeout: 10_000,
setExtraHTTPHeaders: { 'Cache-Control': 'no-cache' },
screenshotOptions: { type: 'png' },
}),
};
let image;
for (let attempt = 0; attempt <= 3; attempt++) {
try {
// Three 10s browser phases plus transport overhead; covers response body too.
let response = await fetchWithTimeout(endpoint, request, 40_000);
if (!response.ok) {
let message = await errorMessage(response);
let error = new Error(`Browser Rendering screenshot failed (${response.status}): ${message}`);
// Browser Run reports navigation/selector/action timeouts as 422 errors.
error.retryable = response.status === 408 || response.status >= 500
|| (response.status === 422 && /timeout|timed out/i.test(message));
throw error;
}
image = new Uint8Array(await response.arrayBuffer());
break;
} catch (error) {
let retryable = error.retryable ?? ['TimeoutError', 'TypeError'].includes(error.name);
if (!retryable || attempt === 3)
throw error;
let delayMs = 500 * 2 ** attempt;
logMessage('warn', 'Retrying Browser Run screenshot', { attempt: attempt + 1, delayMs, error: error.message });
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return {
image,
type: 'image/png',
width: thisViewport.width * thisViewport.deviceScaleFactor,
height: thisViewport.height * thisViewport.deviceScaleFactor,
};
}
export function captureScheduleScreenshot(now) {
let hash = `/schedules/${now}`;
return captureScreenshot({ hash });
}
export function captureGearScreenshot(now) {
let hash = `/splatNetGear/${now}`;
return captureScreenshot({ hash });
}
export function captureSalmonRunScreenshot(now, mode) {
let hash = `/salmonRun/${now}?mode=${mode}`;
return captureScreenshot({ hash });
}
export function captureSalmonRunGearScreenshot(now) {
let hash = `/salmonRunGear/${now}`;
return captureScreenshot({ hash });
}
export function captureNewWeaponScreenshot(now, weaponCount) {
let hash = `/newWeapon/${now}`;
// There are a max of 4 weapons per row
const rows = Math.ceil(weaponCount / 4);
// Determine the image height based on the number of rows
let height = rows * 320;
// Add some extra height for the bottom banner
height += 60;
// Set a minimum overall image height
height = Math.max(height, 700);
return captureScreenshot({ hash, viewport: { height } });
}
export function captureSplatfestScreenshot(region, now, regions) {
regions = regions.join(',');
let hash = `/splatfest/${region}/${now}?regions=${regions}`;
return captureScreenshot({ hash });
}

View File

@@ -1,5 +1,4 @@
import SocialPostBase from './SocialPostBase.js';
import { captureGearScreenshot } from '../../screenshots/index.js';
import { getTopOfCurrentHour } from '../../../common/time.js';
export default class GearPost extends SocialPostBase {
@@ -29,7 +28,7 @@ export default class GearPost extends SocialPostBase {
getImage(data) {
let now = getTopOfCurrentHour();
return captureGearScreenshot(now);
return this.screenshots.captureGearScreenshot(now);
}
getPublicImageFilename() {

View File

@@ -1,5 +1,4 @@
import SocialPostBase from './SocialPostBase.js';
import { captureNewWeaponScreenshot } from '../../screenshots/index.js';
export default class NewWeaponPost extends SocialPostBase {
getKey() { return 'weapon'; }
@@ -31,7 +30,7 @@ export default class NewWeaponPost extends SocialPostBase {
}
getImage(data) {
return captureNewWeaponScreenshot(data[0].release_time, data.length);
return this.screenshots.captureNewWeaponScreenshot(data[0].release_time, data.length);
}
getText(data) {

View File

@@ -1,5 +1,4 @@
import SocialPostBase from './SocialPostBase.js';
import { captureSalmonRunGearScreenshot } from '../../screenshots/index.js';
import moment from 'moment-timezone';
export default class SalmonRunGearPost extends SocialPostBase {
@@ -22,7 +21,7 @@ export default class SalmonRunGearPost extends SocialPostBase {
}
getImage(data) {
return captureSalmonRunGearScreenshot(data.available_time);
return this.screenshots.captureSalmonRunGearScreenshot(data.available_time);
}
getText(data) {

View File

@@ -1,5 +1,4 @@
import SocialPostBase from './SocialPostBase.js';
import { captureSalmonRunScreenshot } from '../../screenshots/index.js';
const PREVIOUS_SCHEDULE_KEY = 'salmonrun-previousSchedule.json';
@@ -83,7 +82,7 @@ export default class SalmonRunPost extends SocialPostBase {
async getImage(data) {
let mode = (data.current) ? 'current' : 'upcoming';
return captureSalmonRunScreenshot(await this.getDataTime(), mode);
return this.screenshots.captureSalmonRunScreenshot(await this.getDataTime(), mode);
}
async getText(data) {

View File

@@ -1,5 +1,4 @@
import SocialPostBase from './SocialPostBase.js';
import { captureScheduleScreenshot } from '../../screenshots/index.js';
import finalFest from '../../../common/data/finalFest.json' with { type: 'json' };
import shiftyStations from '../../../common/data/shiftyStations.json' with { type: 'json' };
@@ -37,7 +36,7 @@ export default class SchedulePost extends SocialPostBase {
}
getImage(data) {
return captureScheduleScreenshot(data.regular.start_time);
return this.screenshots.captureScheduleScreenshot(data.regular.start_time);
}
getPublicImageFilename() {

View File

@@ -9,11 +9,13 @@ export default class SocialPostBase {
* publicStorage holds the site data this post reads and the public copy of its image;
* privateStorage holds the last-posted times and other state.
* @param {object[]} clients the social clients to post through (see ../clients/)
* @param {import('../../screenshots/ScreenshotGenerator.js').default} screenshots
*/
constructor(storage = {}, clients = []) {
constructor(storage = {}, clients = [], screenshots) {
this.publicStorage = storage.publicStorage;
this.privateStorage = storage.privateStorage;
this.clients = clients;
this.screenshots = screenshots;
}
async maybePost() {

View File

@@ -1,10 +1,9 @@
import SocialPostBase from './SocialPostBase.js';
import { captureSplatfestScreenshot } from '../../screenshots/index.js';
import { splatoonRegions } from '../../../common/regions.js';
export default class SplatfestPost extends SocialPostBase {
constructor(region, storage, clients) {
super(storage, clients);
constructor(region, storage, clients, screenshots) {
super(storage, clients, screenshots);
this.region = region;
this.regionInfo = this.getRegionInfo();
@@ -109,7 +108,7 @@ export default class SplatfestPost extends SocialPostBase {
}
async getImage(data) {
return captureSplatfestScreenshot(this.region, await this.getDataTime(), await this.regions(data));
return this.screenshots.captureSplatfestScreenshot(this.region, await this.getDataTime(), await this.regions(data));
}
async getText(data) {

View File

@@ -9,17 +9,18 @@ import SplatfestPost from './SplatfestPost.js';
/**
* @param {{ publicStorage: object, privateStorage: object }} storage
* @param {object[]} clients
* @param {import('../../screenshots/ScreenshotGenerator.js').default} screenshots
*/
export function createPosts(storage, clients) {
export function createPosts(storage, clients, screenshots) {
return [
new SchedulePost(storage, clients),
new GearPost(storage, clients),
new SalmonRunPost(storage, clients),
// new SalmonRunGearPost(storage, clients),
new NewWeaponPost(storage, clients),
new NewStagePost(storage, clients),
new SplatfestPost('na', storage, clients),
new SplatfestPost('eu', storage, clients),
new SplatfestPost('jp', storage, clients),
new SchedulePost(storage, clients, screenshots),
new GearPost(storage, clients, screenshots),
new SalmonRunPost(storage, clients, screenshots),
// new SalmonRunGearPost(storage, clients, screenshots),
new NewWeaponPost(storage, clients, screenshots),
new NewStagePost(storage, clients, screenshots),
new SplatfestPost('na', storage, clients, screenshots),
new SplatfestPost('eu', storage, clients, screenshots),
new SplatfestPost('jp', storage, clients, screenshots),
];
}

View File

@@ -9,17 +9,18 @@ export function createClients() {
* Post whatever is due for the current hour.
* @param {{ publicStorage: object, privateStorage: object }} storage
* @param {object[]} [clients]
* @param {import('../screenshots/ScreenshotGenerator.js').default} screenshots
*/
export async function sendStatuses(storage, clients = createClients()) {
export async function sendStatuses(storage, clients = createClients(), screenshots) {
let posts = [];
for (let post of createPosts(storage, clients)) {
for (let post of createPosts(storage, clients, screenshots)) {
let result = await post.maybePost();
posts.push({ key: post.getKey(), ...(result || { ok: true, skipped: true }) });
}
return { ok: posts.every(post => post.ok), posts };
}
export async function testScreenshots(storage, clients = createClients()) {
for (let post of createPosts(storage, clients))
export async function testScreenshots(storage, clients = createClients(), screenshots) {
for (let post of createPosts(storage, clients, screenshots))
await post.saveTestScreenshot();
}

View File

@@ -0,0 +1,96 @@
import { test, mock, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import PuppeteerRenderer from '../../src/app/screenshots/PuppeteerRenderer.js';
import { withScreenshots } from '../../src/app/screenshots/node.js';
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
afterEach(() => mock.restoreAll());
test('local browser uses page readiness and closes on success or failure', async () => {
let closed = 0;
let fail = false;
let ready;
let page = {
setDefaultTimeout(timeout) { assert.equal(timeout, 10_000); },
async setViewport() {},
async setCacheEnabled() {},
async goto(url, options) {
assert.equal(url, 'http://localhost:8080/screenshots.html');
assert.equal(options.waitUntil, 'domcontentloaded');
return { ok: () => true };
},
async waitForSelector(selector) {
ready = selector;
if (fail)
throw new Error('Page not ready');
},
async screenshot(options) {
assert.equal(options.type, 'png');
return PNG;
},
};
mock.method(puppeteer, 'launch', async () => ({
newPage: async () => page,
close: async () => { closed++; },
}));
let renderer = new PuppeteerRenderer;
let options = { url: 'http://localhost:8080/screenshots.html', viewport: {}, readySelector: '[data-screenshot-ready="true"]' };
assert.deepEqual(await renderer.capture(options), PNG);
assert.equal(ready, options.readySelector);
fail = true;
await assert.rejects(renderer.capture(options), /Page not ready/);
assert.equal(closed, 2);
});
test('Node can select Cloudflare for a direct URL without SITE_URL or social data', async () => {
let requests = [];
mock.method(globalThis, 'fetch', async (url, init) => {
requests.push(JSON.parse(init.body));
return new Response(PNG);
});
let previous = { ...process.env };
try {
process.env.CLOUDFLARE_ACCOUNT_ID = 'account';
process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'token';
let url = 'https://dev.example.test/screenshots.html#/schedules/3600';
let result = await withScreenshots(screenshots => screenshots.capture({ url }), {
provider: 'cloudflare', siteUrl: '', url,
});
assert.deepEqual(result.image, PNG);
assert.equal(requests[0].url, url);
} finally {
process.env = previous;
}
});
test('provider selection is explicit and Cloudflare requires a reachable target', async () => {
await assert.rejects(withScreenshots(() => {}, { provider: 'unknown' }), /SCREENSHOT_PROVIDER/);
await assert.rejects(withScreenshots(() => {}, { provider: 'cloudflare', siteUrl: '' }), /SITE_URL or --url/);
});
test('temporary dist server is loopback-only and closes when capture fails', async () => {
let { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
let { tmpdir } = await import('node:os');
let { join } = await import('node:path');
let directory = await mkdtemp(join(tmpdir(), 'screenshot-server-'));
let originalDirectory = process.cwd();
let siteUrl;
try {
await mkdir(join(directory, 'dist'));
await writeFile(join(directory, 'dist/screenshots.html'), '<html>local fixture</html>');
process.chdir(directory);
mock.method(PuppeteerRenderer.prototype, 'capture', async () => { throw new Error('Capture failed'); });
await assert.rejects(withScreenshots(async screenshots => {
siteUrl = screenshots.siteUrl;
assert.equal(new URL(siteUrl).hostname, '127.0.0.1');
let response = await fetch(new URL('/screenshots.html', siteUrl));
assert.match(await response.text(), /local fixture/);
return screenshots.capture({ hash: '/schedules/3600' });
}, { provider: 'puppeteer', siteUrl: '' }), /Capture failed/);
await assert.rejects(fetch(siteUrl));
} finally {
process.chdir(originalDirectory);
await rm(directory, { recursive: true, force: true });
}
});

View File

@@ -1,6 +1,14 @@
import { test, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { captureScreenshot, captureNewWeaponScreenshot, captureSplatfestScreenshot } from '../../src/app/screenshots/screenshots.js';
import BrowserRunClient from '../../src/app/screenshots/BrowserRunClient.js';
import ScreenshotGenerator from '../../src/app/screenshots/ScreenshotGenerator.js';
function screenshots() {
return new ScreenshotGenerator(new BrowserRunClient({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN,
}), process.env.SITE_URL);
}
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
@@ -22,7 +30,7 @@ afterEach(() => mock.restoreAll());
test('asks Browser Rendering for the deployed screenshot page at the default viewport', async () => {
const requests = fakeBrowserRendering();
const result = await captureScreenshot({ hash: '/schedules/3600' });
const result = await screenshots().capture({ hash: '/schedules/3600' });
assert.equal(requests.length, 1);
const [{ url, headers, body }] = requests;
@@ -40,25 +48,25 @@ test('asks Browser Rendering for the deployed screenshot page at the default vie
test('the new-weapon screenshot grows with the number of weapons, and splatfest passes its regions', async () => {
const requests = fakeBrowserRendering();
const tall = await captureNewWeaponScreenshot(3600, 9);
const tall = await screenshots().captureNewWeaponScreenshot(3600, 9);
assert.equal(requests[0].body.viewport.height, 3 * 320 + 60);
assert.equal(tall.height, (3 * 320 + 60) * 2);
const short = await captureNewWeaponScreenshot(3600, 1);
const short = await screenshots().captureNewWeaponScreenshot(3600, 1);
assert.equal(requests[1].body.viewport.height, 700);
assert.equal(short.width, 2432);
await captureSplatfestScreenshot('na', 3600, ['na', 'eu']);
await screenshots().captureSplatfestScreenshot('na', 3600, ['na', 'eu']);
assert.equal(requests[2].body.url, 'https://example.test/screenshots.html#/splatfest/na/3600?regions=na,eu');
});
test('reports API errors with Cloudflare\'s message', async () => {
fakeBrowserRendering(() => Response.json({ errors: [{ message: 'Invalid token' }] }, { status: 401 }));
await assert.rejects(captureScreenshot({ hash: '/x' }), /Browser Rendering screenshot failed \(401\): Invalid token/);
await assert.rejects(screenshots().capture({ hash: '/x' }), /Browser Rendering screenshot failed \(401\): Invalid token/);
});
test('fails clearly when configuration is missing', async () => {
delete process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN;
await assert.rejects(captureScreenshot({ hash: '/x' }), /Missing screenshot configuration: CLOUDFLARE_BROWSER_RUN_API_TOKEN/);
await assert.rejects(screenshots().capture({ hash: '/x' }), /Missing screenshot configuration: CLOUDFLARE_BROWSER_RUN_API_TOKEN/);
});
function immediateBackoff() {
@@ -76,7 +84,7 @@ test('retries timeout responses and succeeds with the same screenshot request',
const requests = fakeBrowserRendering(() => ++attempt < 3
? Response.json({ errors: [{ message: 'Navigation timeout of 10000 ms exceeded' }] }, { status: 422 })
: new Response(PNG));
const result = await captureScreenshot({ hash: '/schedules/3600' });
const result = await screenshots().capture({ hash: '/schedules/3600' });
assert.deepEqual(result.image, PNG);
assert.equal(requests.length, 3);
assert.deepEqual(requests[0].body, requests[2].body);
@@ -86,7 +94,7 @@ test('retries timeout responses and succeeds with the same screenshot request',
test('stops after three retries and preserves the final error', async () => {
const delays = immediateBackoff();
const requests = fakeBrowserRendering(() => new Response('upstream unavailable', { status: 503 }));
await assert.rejects(captureScreenshot({ hash: '/x' }), /503.*upstream unavailable/);
await assert.rejects(screenshots().capture({ hash: '/x' }), /503.*upstream unavailable/);
assert.equal(requests.length, 4);
assert.deepEqual(delays, [500, 1000, 2000]);
});
@@ -96,7 +104,7 @@ test('does not retry authentication, rate limits or non-timeout validation error
for (const status of [401, 403, 429, 422]) {
let count = 0;
const requests = fakeBrowserRendering(() => { count++; return new Response('invalid request', { status }); });
await assert.rejects(captureScreenshot({ hash: '/x' }), new RegExp(String(status)));
await assert.rejects(screenshots().capture({ hash: '/x' }), new RegExp(String(status)));
assert.equal(count, 1);
assert.equal(requests.length, 1);
}
@@ -113,6 +121,6 @@ test('retries network and client deadline failures, including while reading the
if (attempt === 3) return new Response(new ReadableStream({ start(controller) { controller.error(new TypeError('connection reset')); } }));
return new Response(PNG);
});
assert.deepEqual((await captureScreenshot({ hash: '/x' })).image, PNG);
assert.deepEqual((await screenshots().capture({ hash: '/x' })).image, PNG);
assert.equal(requests.length, 4);
});

View File

@@ -25,9 +25,10 @@ function pinTime(post, time) {
}
test('createPosts builds every post against the same storage and clients', () => {
const posts = createPosts(s, [client]);
const screenshots = {};
const posts = createPosts(s, [client], screenshots);
assert.deepEqual(posts.map(t => t.getKey()), ['schedule', 'gear', 'salmonrun', 'weapon', 'newstage', 'splatfest-na', 'splatfest-eu', 'splatfest-jp']);
assert.ok(posts.every(t => t.publicStorage === s.publicStorage && t.clients[0] === client));
assert.ok(posts.every(t => t.publicStorage === s.publicStorage && t.clients[0] === client && t.screenshots === screenshots));
});
test('schedule: posts the rotation for the current hour, with new-stage wording when the stage is new', async () => {

View File

@@ -1,3 +1,5 @@
import BrowserRunClient from '../../../src/app/screenshots/BrowserRunClient.js';
import ScreenshotGenerator from '../../../src/app/screenshots/ScreenshotGenerator.js';
import stringify from 'json-stable-stringify';
import { sendStatuses, createClients } from '../../../src/app/social/index.js';
import { fetchWithTimeout } from '../../../src/common/fetch.js';
@@ -32,7 +34,11 @@ export async function runPosters(env) {
for (let client of clients)
if (await client.canSend())
enabled.push(client.key);
let result = await sendStatuses(storage, clients);
let screenshots = new ScreenshotGenerator(new BrowserRunClient({
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN,
}), process.env.SITE_URL);
let result = await sendStatuses(storage, clients, screenshots);
let summary = { ...result, ms: Date.now() - started, clients: enabled };
createLogger('social')[summary.ok ? 'info' : 'error']('Social run finished', summary);
return summary;