├── src ├── Enums │ └── Polling.php ├── Exceptions │ ├── FileDoesNotExistException.php │ ├── ElementNotFound.php │ ├── RemoteConnectionException.php │ ├── UnsuccessfulResponse.php │ ├── HtmlIsNotAllowedToContainFile.php │ ├── FileUrlNotAllowed.php │ └── CouldNotTakeBrowsershot.php ├── ImageManipulations.php ├── ChromiumResult.php └── Browsershot.php ├── LICENSE.md ├── composer.json ├── README.md └── bin └── browser.cjs /src/Enums/Polling.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/browsershot", 3 | "description": "Convert a webpage to an image or pdf using headless Chrome", 4 | "homepage": "https://github.com/spatie/browsershot", 5 | "keywords": [ 6 | "convert", 7 | "webpage", 8 | "image", 9 | "pdf", 10 | "screenshot", 11 | "chrome", 12 | "headless", 13 | "puppeteer" 14 | ], 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Freek Van der Herten", 19 | "email": "freek@spatie.be", 20 | "homepage": "https://github.com/freekmurze", 21 | "role": "Developer" 22 | } 23 | ], 24 | "require": { 25 | "php": "^8.2", 26 | "spatie/temporary-directory": "^2.0", 27 | "symfony/process": "^6.0|^7.0|^8.0", 28 | "ext-json": "*", 29 | "ext-fileinfo": "*" 30 | }, 31 | "require-dev": { 32 | "pestphp/pest": "^3.0|^4.0", 33 | "spatie/image": "^3.6", 34 | "spatie/pdf-to-text": "^1.52", 35 | "spatie/phpunit-snapshot-assertions": "^5.0" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "Spatie\\Browsershot\\": "src" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Spatie\\Browsershot\\Test\\": "tests" 45 | } 46 | }, 47 | "scripts": { 48 | "test": "vendor/bin/pest" 49 | }, 50 | "config": { 51 | "sort-packages": true, 52 | "allow-plugins": { 53 | "pestphp/pest-plugin": true 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ImageManipulations.php: -------------------------------------------------------------------------------- 1 | addManipulation($method, $parameters); 16 | 17 | return $this; 18 | } 19 | 20 | public function addManipulation(string $name, array $parameters = []): self 21 | { 22 | $this->manipulations[$name] = $parameters; 23 | 24 | return $this; 25 | } 26 | 27 | public function apply(string $path): void 28 | { 29 | $this->ensureImageDependencyIsInstalled(); 30 | 31 | $image = Image::load($path); 32 | 33 | foreach ($this->manipulations as $manipulationName => $parameters) { 34 | $image->$manipulationName(...$parameters); 35 | } 36 | 37 | $image->save($path); 38 | } 39 | 40 | public function isEmpty(): bool 41 | { 42 | return count($this->manipulations) === 0; 43 | } 44 | 45 | public function ensureImageDependencyIsInstalled(): void 46 | { 47 | if (! InstalledVersions::isInstalled('spatie/image')) { 48 | throw new Exception('The spatie/image package is required to perform image manipulations. Please install it by running `composer require spatie/image`'); 49 | } 50 | 51 | $installedVersion = InstalledVersions::getVersion('spatie/image'); 52 | 53 | if (version_compare($installedVersion, '3.0.0', '<')) { 54 | throw new Exception("The spatie/image package must be at least version 3.0.0 to perform image manipulations. Your current version is `{$installedVersion}`"); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ChromiumResult.php: -------------------------------------------------------------------------------- 1 | result = $output['result'] ?? ''; 64 | $this->exception = $output['exception'] ?? null; 65 | $this->consoleMessages = $output['consoleMessages'] ?? null; 66 | $this->requestsList = $output['requestsList'] ?? null; 67 | $this->failedRequests = $output['failedRequests'] ?? null; 68 | $this->pageErrors = $output['pageErrors'] ?? null; 69 | $this->redirectHistory = $output['redirectHistory'] ?? null; 70 | } 71 | 72 | public function getResult(): string 73 | { 74 | return $this->result; 75 | } 76 | 77 | public function getException(): ?string 78 | { 79 | return $this->exception; 80 | } 81 | 82 | /** @return null|array{ 83 | * type: string, 84 | * message: string, 85 | * location: array, 86 | * stackTrace: string 87 | * } 88 | */ 89 | public function getConsoleMessages(): ?array 90 | { 91 | return $this->consoleMessages; 92 | } 93 | 94 | /** 95 | * @return null|array{url: string} 96 | */ 97 | public function getRequestsList(): ?array 98 | { 99 | return $this->requestsList; 100 | } 101 | 102 | /** 103 | * @return null|array{status: int, url: string} 104 | */ 105 | public function getFailedRequests(): ?array 106 | { 107 | return $this->failedRequests; 108 | } 109 | 110 | /** @return null|array{ 111 | * name: string, 112 | * message: string 113 | * } 114 | */ 115 | public function getPageErrors(): ?array 116 | { 117 | return $this->pageErrors; 118 | } 119 | 120 | /** @return null|array{ 121 | * url: string, 122 | * status: int, 123 | * statusText: string, 124 | * headers: array 125 | * } 126 | */ 127 | public function getRedirectHistory(): ?array 128 | { 129 | return $this->redirectHistory; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for Browsershot 6 | 7 | 8 | 9 |

Render web pages to an image or PDF with Puppeteer

10 | 11 | [![Latest Version](https://img.shields.io/github/release/spatie/browsershot.svg?style=flat-square)](https://github.com/spatie/browsershot/releases) 12 | [![MIT Licensed](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 13 | [![run-tests](https://img.shields.io/github/actions/workflow/status/spatie/browsershot/run-tests.yml?label=tests&style=flat-square)](https://github.com/spatie/browsershot/actions) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/browsershot.svg?style=flat-square)](https://packagist.org/packages/spatie/browsershot) 15 | 16 |
17 | 18 | The package can convert a web page to an image or PDF. The conversion is done behind the scenes by [Puppeteer](https://github.com/GoogleChrome/puppeteer) which runs a headless version of Google Chrome. 19 | 20 | Here's a quick example: 21 | 22 | ```php 23 | use Spatie\Browsershot\Browsershot; 24 | 25 | // an image will be saved 26 | Browsershot::url('https://example.com')->save($pathToImage); 27 | ``` 28 | 29 | It will save a PDF if the path passed to the `save` method has a `pdf` extension. 30 | 31 | ```php 32 | // a pdf will be saved 33 | Browsershot::url('https://example.com')->save('example.pdf'); 34 | ``` 35 | 36 | You can also use an arbitrary html input, simply replace the `url` method with `html`: 37 | 38 | ```php 39 | Browsershot::html('

Hello world!!

')->save('example.pdf'); 40 | ``` 41 | 42 | If your HTML input is already in a file locally use the : 43 | 44 | ```php 45 | Browsershot::htmlFromFilePath('/local/path/to/file.html')->save('example.pdf'); 46 | ``` 47 | 48 | Browsershot also can get the body of an html page after JavaScript has been executed: 49 | 50 | ```php 51 | Browsershot::url('https://example.com')->bodyHtml(); // returns the html of the body 52 | ``` 53 | 54 | If you wish to retrieve an array list with all of the requests that the page triggered you can do so: 55 | 56 | ```php 57 | $requests = Browsershot::url('https://example.com') 58 | ->triggeredRequests(); 59 | 60 | foreach ($requests as $request) { 61 | $url = $request['url']; //https://example.com/ 62 | } 63 | ``` 64 | 65 | To use Chrome's new [headless mode](https://developers.google.com/web/updates/2017/04/headless-chrome) pass the `newHeadless` method: 66 | 67 | ```php 68 | Browsershot::url('https://example.com')->newHeadless()->save($pathToImage); 69 | ``` 70 | 71 | ## Support us 72 | 73 | Learn how to create a package like this one, by watching our premium video course: 74 | 75 | [![Laravel Package training](https://spatie.be/github/package-training.jpg)](https://laravelpackage.training) 76 | 77 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 78 | 79 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 80 | 81 | ## Documentation 82 | 83 | All documentation is available [on our documentation site](https://spatie.be/docs/browsershot). 84 | 85 | ## Testing 86 | 87 | For running the testsuite, you'll need to have Puppeteer installed. Pleaser refer to the Browsershot requirements [here](https://spatie.be/docs/browsershot/v4/requirements). Usually `npm -g i puppeteer` will do the trick. 88 | 89 | Additionally, you'll need the `pdftotext` CLI which is part of the poppler-utils package. More info can be found in in the [spatie/pdf-to-text readme](https://github.com/spatie/pdf-to-text?tab=readme-ov-file#requirements). Usually `brew install poppler-utils` will suffice. 90 | 91 | Finally run the tests with: 92 | 93 | ```bash 94 | composer test 95 | ``` 96 | 97 | ## Contributing 98 | 99 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 100 | 101 | ## Security 102 | 103 | If you've found a bug regarding security please mail [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. 104 | 105 | ## Alternatives 106 | 107 | If you're not able to install Node and Puppeteer, take a look at [v2 of browsershot](https://github.com/spatie/browsershot/tree/2.4.1), which uses Chrome headless CLI to take a screenshot. `v2` is not maintained anymore, but should work pretty well. 108 | 109 | If using headless Chrome does not work for you take a look at at `v1` of this package which uses the abandoned `PhantomJS` binary. 110 | 111 | ## Credits 112 | 113 | - [Freek Van der Herten](https://github.com/freekmurze) 114 | - [All Contributors](../../contributors) 115 | 116 | ## License 117 | 118 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 119 | -------------------------------------------------------------------------------- /bin/browser.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const URL = require('url').URL; 3 | const URLParse = require('url').parse; 4 | 5 | if (typeof global.ReadableStream === 'undefined') { 6 | const {ReadableStream} = require("stream/web"); 7 | global.ReadableStream = ReadableStream; 8 | } 9 | 10 | const [, , ...args] = process.argv; 11 | 12 | /** 13 | * There are two ways for Browsershot to communicate with puppeteer: 14 | * - By giving a options JSON dump as an argument 15 | * - Or by providing a temporary file with the options JSON dump, 16 | * the path to this file is then given as an argument with the flag -f 17 | */ 18 | const request = args[0].startsWith('-f ') 19 | ? JSON.parse(fs.readFileSync(new URL(args[0].substring(3)))) 20 | : JSON.parse(args[0]); 21 | 22 | const requestsList = []; 23 | 24 | const redirectHistory = []; 25 | 26 | const consoleMessages = []; 27 | 28 | const failedRequests = []; 29 | 30 | const pageErrors = []; 31 | 32 | const getOutput = async (request, page = null) => { 33 | let output = { 34 | requestsList, 35 | consoleMessages, 36 | failedRequests, 37 | redirectHistory, 38 | pageErrors, 39 | }; 40 | 41 | if ( 42 | ![ 43 | 'requestsList', 44 | 'consoleMessages', 45 | 'failedRequests', 46 | 'redirectHistory', 47 | 'pageErrors', 48 | ].includes(request.action) && 49 | page 50 | ) { 51 | if (request.action == 'evaluate') { 52 | output.result = await page.evaluate(request.options.pageFunction); 53 | } else { 54 | const result = await page[request.action](request.options); 55 | 56 | // Ignore output result when saving to a file 57 | output.result = request.options.path 58 | ? '' 59 | : (result instanceof Uint8Array ? Buffer.from(result) : result).toString('base64'); 60 | } 61 | } 62 | 63 | if (page) { 64 | return JSON.stringify(output); 65 | } 66 | 67 | // this will allow adding additional error info (only reach this point when there's an exception) 68 | return output; 69 | }; 70 | 71 | const callChrome = async pup => { 72 | let browser; 73 | let page; 74 | let remoteInstance; 75 | const puppet = (pup || require('puppeteer')); 76 | 77 | try { 78 | if (request.options.remoteInstanceUrl || request.options.browserWSEndpoint ) { 79 | // default options 80 | let options = { 81 | acceptInsecureCerts: request.options.acceptInsecureCerts 82 | }; 83 | 84 | // choose only one method to connect to the browser instance 85 | if ( request.options.remoteInstanceUrl ) { 86 | options.browserURL = request.options.remoteInstanceUrl; 87 | } else if ( request.options.browserWSEndpoint ) { 88 | options.browserWSEndpoint = request.options.browserWSEndpoint; 89 | } 90 | 91 | try { 92 | browser = await puppet.connect( options ); 93 | 94 | remoteInstance = true; 95 | } catch (exception) { 96 | 97 | if (request.options.throwOnRemoteConnectionError) { 98 | console.error(exception.toString()); 99 | process.exit(4); 100 | } 101 | 102 | /** fallback to launching a chromium instance */ 103 | } 104 | } 105 | 106 | if (!browser) { 107 | browser = await puppet.launch({ 108 | headless: request.options.newHeadless ? true : 'shell', 109 | acceptInsecureCerts: request.options.acceptInsecureCerts, 110 | executablePath: request.options.executablePath, 111 | args: request.options.args || [], 112 | pipe: request.options.pipe || false, 113 | env: { 114 | ...(request.options.env || {}), 115 | ...process.env 116 | }, 117 | protocolTimeout: request.options.protocolTimeout ?? 30000, 118 | }); 119 | } 120 | 121 | page = await browser.newPage(); 122 | 123 | if (request.options && request.options.disableJavascript) { 124 | await page.setJavaScriptEnabled(false); 125 | } 126 | 127 | await page.setRequestInterception(true); 128 | 129 | const contentUrl = request.options.contentUrl; 130 | const parsedContentUrl = contentUrl ? contentUrl.replace(/\/$/, "") : undefined; 131 | let pageContent; 132 | 133 | if (contentUrl) { 134 | pageContent = fs.readFileSync(request.url.replace('file://', '')); 135 | request.url = contentUrl; 136 | } 137 | 138 | page.on('console', (message) => 139 | consoleMessages.push({ 140 | type: message.type(), 141 | message: message.text(), 142 | location: message.location(), 143 | stackTrace: message.stackTrace(), 144 | }) 145 | ); 146 | 147 | page.on('pageerror', (msg) => { 148 | pageErrors.push({ 149 | name: msg?.name || 'unknown error', 150 | message: msg?.message || msg?.toString() || 'null' 151 | }); 152 | }); 153 | 154 | page.on('response', function (response) { 155 | const frame = response.request().frame(); 156 | if (response.request().isNavigationRequest() && frame && frame.parentFrame() === null) { 157 | redirectHistory.push({ 158 | url: response.request().url(), 159 | status: response.status(), 160 | reason: response.statusText(), 161 | headers: response.headers() 162 | }) 163 | } 164 | 165 | if (response.status() >= 200 && response.status() <= 399) { 166 | return; 167 | } 168 | 169 | failedRequests.push({ 170 | status: response.status(), 171 | url: response.url(), 172 | }); 173 | }) 174 | 175 | page.on('request', interceptedRequest => { 176 | var headers = interceptedRequest.headers(); 177 | 178 | if (!request.options || !request.options.disableCaptureURLS) { 179 | requestsList.push({ 180 | url: interceptedRequest.url(), 181 | }); 182 | } 183 | 184 | if (request.options && request.options.disableImages) { 185 | if (interceptedRequest.resourceType() === 'image') { 186 | interceptedRequest.abort(); 187 | return; 188 | } 189 | } 190 | 191 | if (request.options && request.options.blockDomains) { 192 | const hostname = URLParse(interceptedRequest.url()).hostname; 193 | if (request.options.blockDomains.includes(hostname)) { 194 | interceptedRequest.abort(); 195 | return; 196 | } 197 | } 198 | 199 | if (request.options && request.options.blockUrls) { 200 | for (const element of request.options.blockUrls) { 201 | if (interceptedRequest.url().indexOf(element) >= 0) { 202 | interceptedRequest.abort(); 203 | return; 204 | } 205 | } 206 | } 207 | 208 | if (request.options && request.options.disableRedirects) { 209 | if (interceptedRequest.isNavigationRequest() && interceptedRequest.redirectChain().length) { 210 | interceptedRequest.abort(); 211 | return 212 | } 213 | } 214 | 215 | if (request.options && request.options.extraNavigationHTTPHeaders) { 216 | // Do nothing in case of non-navigation requests. 217 | if (interceptedRequest.isNavigationRequest()) { 218 | headers = Object.assign({}, headers, request.options.extraNavigationHTTPHeaders); 219 | } 220 | } 221 | 222 | if (pageContent) { 223 | const interceptedUrl = interceptedRequest.url().replace(/\/$/, ""); 224 | 225 | // if content url matches the intercepted request url, will return the content fetched from the local file system 226 | if (interceptedUrl === parsedContentUrl) { 227 | interceptedRequest.respond({ 228 | headers, 229 | body: pageContent, 230 | }); 231 | return; 232 | } 233 | } 234 | 235 | if (request.postParams) { 236 | const postParamsArray = request.postParams; 237 | const queryString = Object.keys(postParamsArray) 238 | .map(key => `${key}=${postParamsArray[key]}`) 239 | .join('&'); 240 | interceptedRequest.continue({ 241 | method: "POST", 242 | postData: queryString, 243 | headers: { 244 | ...interceptedRequest.headers(), 245 | "Content-Type": "application/x-www-form-urlencoded" 246 | } 247 | }); 248 | return; 249 | } 250 | 251 | interceptedRequest.continue({ headers }); 252 | }); 253 | 254 | if (request.options && request.options.dismissDialogs) { 255 | page.on('dialog', async dialog => { 256 | await dialog.dismiss(); 257 | }); 258 | } 259 | 260 | if (request.options && request.options.userAgent) { 261 | await page.setUserAgent(request.options.userAgent); 262 | } 263 | 264 | if (request.options && request.options.device) { 265 | const devices = puppet.KnownDevices; 266 | const device = devices[request.options.device]; 267 | await page.emulate(device); 268 | } 269 | 270 | if (request.options && request.options.emulateMedia) { 271 | await page.emulateMediaType(request.options.emulateMedia); 272 | } 273 | 274 | if (request.options && request.options.emulateMediaFeatures) { 275 | await page.emulateMediaFeatures(JSON.parse(request.options.emulateMediaFeatures)); 276 | } 277 | 278 | if (request.options && request.options.viewport) { 279 | await page.setViewport(request.options.viewport); 280 | } 281 | 282 | if (request.options && request.options.extraHTTPHeaders) { 283 | await page.setExtraHTTPHeaders(request.options.extraHTTPHeaders); 284 | } 285 | 286 | if (request.options && request.options.authentication) { 287 | await page.authenticate(request.options.authentication); 288 | } 289 | 290 | if (request.options && request.options.cookies) { 291 | await page.setCookie(...request.options.cookies); 292 | } 293 | 294 | if (request.options && request.options.timeout) { 295 | await page.setDefaultNavigationTimeout(request.options.timeout); 296 | } 297 | 298 | const requestOptions = {}; 299 | 300 | if (request.options && request.options.networkIdleTimeout) { 301 | requestOptions.waitUntil = 'networkidle'; 302 | requestOptions.networkIdleTimeout = request.options.networkIdleTimeout; 303 | } else if (request.options && request.options.waitUntil) { 304 | requestOptions.waitUntil = request.options.waitUntil; 305 | } 306 | 307 | const response = await page.goto(request.url, requestOptions); 308 | 309 | if (request.options.preventUnsuccessfulResponse) { 310 | const status = response.status() 311 | 312 | if (status >= 400 && status < 600) { 313 | throw {type: "UnsuccessfulResponse", status}; 314 | } 315 | } 316 | 317 | if (request.options && request.options.disableImages) { 318 | await page.evaluate(() => { 319 | let images = document.getElementsByTagName('img'); 320 | while (images.length > 0) { 321 | images[0].parentNode.removeChild(images[0]); 322 | } 323 | }); 324 | } 325 | 326 | if (request.options && request.options.types) { 327 | for (let i = 0, len = request.options.types.length; i < len; i++) { 328 | let typeOptions = request.options.types[i]; 329 | await page.type(typeOptions.selector, typeOptions.text, { 330 | 'delay': typeOptions.delay, 331 | }); 332 | } 333 | } 334 | 335 | if (request.options && request.options.selects) { 336 | for (let i = 0, len = request.options.selects.length; i < len; i++) { 337 | let selectOptions = request.options.selects[i]; 338 | await page.select(selectOptions.selector, selectOptions.value); 339 | } 340 | } 341 | 342 | if (request.options && request.options.clicks) { 343 | for (let i = 0, len = request.options.clicks.length; i < len; i++) { 344 | let clickOptions = request.options.clicks[i]; 345 | await page.click(clickOptions.selector, { 346 | 'button': clickOptions.button, 347 | 'clickCount': clickOptions.clickCount, 348 | 'delay': clickOptions.delay, 349 | }); 350 | } 351 | } 352 | 353 | if (request.options && request.options.locatorClicks) { 354 | for (let i = 0, len = request.options.locatorClicks.length; i < len; i++) { 355 | let clickOptions = request.options.locatorClicks[i]; 356 | try { 357 | await page.locator(clickOptions.selector).click({ 358 | 'button': clickOptions.button, 359 | 'clickCount': clickOptions.clickCount, 360 | 'delay': clickOptions.delay, 361 | }); 362 | } catch (error) { 363 | console.error('Timeout error:', error); 364 | } 365 | } 366 | } 367 | 368 | if (request.options && request.options.addStyleTag) { 369 | await page.addStyleTag(JSON.parse(request.options.addStyleTag)); 370 | } 371 | 372 | if (request.options && request.options.addScriptTag) { 373 | await page.addScriptTag(JSON.parse(request.options.addScriptTag)); 374 | } 375 | 376 | if (request.options.delay) { 377 | await new Promise(r => setTimeout(r, request.options.delay)); 378 | } 379 | 380 | if (request.options.initialPageNumber) { 381 | await page.evaluate((initialPageNumber) => { 382 | window.pageStart = initialPageNumber; 383 | 384 | const style = document.createElement('style'); 385 | style.type = 'text/css'; 386 | style.innerHTML = '.empty-page { page-break-after: always; visibility: hidden; }'; 387 | document.getElementsByTagName('head')[0].appendChild(style); 388 | 389 | const emptyPages = Array.from({length: window.pageStart}).map(() => { 390 | const emptyPage = document.createElement('div'); 391 | emptyPage.className = "empty-page"; 392 | emptyPage.textContent = "empty"; 393 | return emptyPage; 394 | }); 395 | document.body.prepend(...emptyPages); 396 | }, request.options.initialPageNumber); 397 | } 398 | 399 | if (request.options.function) { 400 | let functionOptions = { 401 | polling: request.options.functionPolling, 402 | timeout: request.options.functionTimeout || request.options.timeout 403 | }; 404 | await page.waitForFunction(request.options.function, functionOptions); 405 | } 406 | 407 | if (request.options.waitForSelector) { 408 | await page.waitForSelector(request.options.waitForSelector, (request.options.waitForSelectorOptions ? request.options.waitForSelectorOptions : undefined)); 409 | } 410 | 411 | if (request.options.selector) { 412 | var element; 413 | const index = request.options.selectorIndex || 0; 414 | if(index){ 415 | element = await page.$$(request.options.selector); 416 | if(!element.length || typeof element[index] === 'undefined'){ 417 | element = null; 418 | }else{ 419 | element = element[index]; 420 | } 421 | }else{ 422 | element = await page.$(request.options.selector); 423 | } 424 | if (element === null) { 425 | throw {type: 'ElementNotFound'}; 426 | } 427 | 428 | request.options.clip = await element.boundingBox(); 429 | } 430 | 431 | console.log(await getOutput(request, page)); 432 | 433 | if (remoteInstance && page) { 434 | await page.close(); 435 | } 436 | 437 | await (remoteInstance ? browser.disconnect() : browser.close()); 438 | } catch (exception) { 439 | if (browser) { 440 | if (remoteInstance && page) { 441 | await page.close(); 442 | } 443 | 444 | await (remoteInstance ? browser.disconnect() : browser.close()); 445 | } 446 | 447 | const output = await getOutput(request); 448 | 449 | if (exception.type === 'UnsuccessfulResponse') { 450 | output.exception = exception.toString(); 451 | console.error(exception.status); 452 | console.log(JSON.stringify(output)); 453 | process.exit(3); 454 | } 455 | 456 | output.exception = exception.toString(); 457 | 458 | console.error(exception); 459 | console.log(JSON.stringify(output)); 460 | 461 | if (exception.type === 'ElementNotFound') { 462 | process.exit(2); 463 | } 464 | 465 | process.exit(1); 466 | } 467 | }; 468 | 469 | if (require.main === module) { 470 | callChrome(); 471 | } 472 | 473 | exports.callChrome = callChrome; 474 | -------------------------------------------------------------------------------- /src/Browsershot.php: -------------------------------------------------------------------------------- 1 | */ 82 | protected array $nodeEnvVars = []; 83 | 84 | public static function url(string $url): static 85 | { 86 | return (new static)->setUrl($url); 87 | } 88 | 89 | public static function html(string $html): static 90 | { 91 | return (new static)->setHtml($html); 92 | } 93 | 94 | public static function htmlFromFilePath(string $filePath): static 95 | { 96 | return (new static)->setHtmlFromFilePath($filePath); 97 | } 98 | 99 | public function __construct(string $url = '', bool $deviceEmulate = false) 100 | { 101 | $this->url = $url; 102 | 103 | if (! $deviceEmulate) { 104 | $this->windowSize(800, 600); 105 | } 106 | 107 | $this->imageManipulations = new ImageManipulations; 108 | } 109 | 110 | public function setNodeBinary(string $nodeBinary): static 111 | { 112 | $this->nodeBinary = $nodeBinary; 113 | 114 | return $this; 115 | } 116 | 117 | public function setNpmBinary(string $npmBinary): static 118 | { 119 | $this->npmBinary = $npmBinary; 120 | 121 | return $this; 122 | } 123 | 124 | public function setIncludePath(string $includePath): static 125 | { 126 | $this->includePath = $includePath; 127 | 128 | return $this; 129 | } 130 | 131 | public function setBinPath(string $binPath): static 132 | { 133 | $this->binPath = $binPath; 134 | 135 | return $this; 136 | } 137 | 138 | public function setNodeModulePath(string $nodeModulePath): static 139 | { 140 | $this->nodeModulePath = $nodeModulePath; 141 | 142 | return $this; 143 | } 144 | 145 | public function setChromePath(string $executablePath): static 146 | { 147 | $this->setOption('executablePath', $executablePath); 148 | 149 | return $this; 150 | } 151 | 152 | public function setCustomTempPath(string $tempPath): static 153 | { 154 | $this->tempPath = $tempPath; 155 | 156 | return $this; 157 | } 158 | 159 | public function post(array $postParams = []): static 160 | { 161 | $this->postParams = $postParams; 162 | 163 | return $this; 164 | } 165 | 166 | public function useCookies(array $cookies, ?string $domain = null): static 167 | { 168 | if (! count($cookies)) { 169 | return $this; 170 | } 171 | 172 | if (is_null($domain)) { 173 | $domain = parse_url($this->url)['host']; 174 | } 175 | 176 | $cookies = array_map(function ($value, $name) use ($domain) { 177 | return compact('name', 'value', 'domain'); 178 | }, $cookies, array_keys($cookies)); 179 | 180 | if (isset($this->additionalOptions['cookies'])) { 181 | $cookies = array_merge($this->additionalOptions['cookies'], $cookies); 182 | } 183 | 184 | $this->setOption('cookies', $cookies); 185 | 186 | return $this; 187 | } 188 | 189 | public function setExtraHttpHeaders(array $extraHTTPHeaders): static 190 | { 191 | $this->setOption('extraHTTPHeaders', $extraHTTPHeaders); 192 | 193 | return $this; 194 | } 195 | 196 | public function setExtraNavigationHttpHeaders(array $extraNavigationHTTPHeaders): static 197 | { 198 | $this->setOption('extraNavigationHTTPHeaders', $extraNavigationHTTPHeaders); 199 | 200 | return $this; 201 | } 202 | 203 | public function setNodeEnv(array $envVars): static 204 | { 205 | $this->nodeEnvVars = $envVars; 206 | 207 | return $this; 208 | } 209 | 210 | public function authenticate(string $username, string $password): static 211 | { 212 | $this->setOption('authentication', compact('username', 'password')); 213 | 214 | return $this; 215 | } 216 | 217 | public function click(string $selector, string $button = 'left', int $clickCount = 1, int $delay = 0): static 218 | { 219 | $clicks = $this->additionalOptions['clicks'] ?? []; 220 | 221 | $clicks[] = compact('selector', 'button', 'clickCount', 'delay'); 222 | 223 | $this->setOption('clicks', $clicks); 224 | 225 | return $this; 226 | } 227 | 228 | public function locatorClick(string $selector, string $button = 'left', int $clickCount = 1, int $delay = 0): static 229 | { 230 | $locatorClicks = $this->additionalOptions['locatorClicks'] ?? []; 231 | 232 | $locatorClicks[] = compact('selector', 'button', 'clickCount', 'delay'); 233 | 234 | $this->setOption('locatorClicks', $locatorClicks); 235 | 236 | return $this; 237 | } 238 | 239 | public function selectOption(string $selector, string $value = ''): static 240 | { 241 | $dropdownSelects = $this->additionalOptions['selects'] ?? []; 242 | 243 | $dropdownSelects[] = compact('selector', 'value'); 244 | 245 | $this->setOption('selects', $dropdownSelects); 246 | 247 | return $this; 248 | } 249 | 250 | public function type(string $selector, string $text = '', int $delay = 0): static 251 | { 252 | $types = $this->additionalOptions['types'] ?? []; 253 | 254 | $types[] = compact('selector', 'text', 'delay'); 255 | 256 | $this->setOption('types', $types); 257 | 258 | return $this; 259 | } 260 | 261 | public function waitUntilNetworkIdle(bool $strict = true): static 262 | { 263 | $this->setOption('waitUntil', $strict ? 'networkidle0' : 'networkidle2'); 264 | 265 | return $this; 266 | } 267 | 268 | public function waitForFunction(string $function, ?Polling $polling = null, int $timeout = 0): static 269 | { 270 | $polling ??= Polling::RequestAnimationFrame; 271 | 272 | $this->setOption('functionPolling', $polling->value); 273 | $this->setOption('functionTimeout', $timeout); 274 | 275 | return $this->setOption('function', $function); 276 | } 277 | 278 | public function waitForSelector(string $selector, array $options = []): static 279 | { 280 | $this->setOption('waitForSelector', $selector); 281 | 282 | if (! empty($options)) { 283 | $this->setOption('waitForSelectorOptions', $options); 284 | } 285 | 286 | return $this; 287 | } 288 | 289 | public function setUrl(string $url): static 290 | { 291 | $url = trim($url); 292 | 293 | if (filter_var($url, FILTER_VALIDATE_URL) === false) { 294 | throw FileUrlNotAllowed::urlCannotBeParsed($url); 295 | } 296 | 297 | foreach ($this->unsafeProtocols as $unsupportedProtocol) { 298 | if (str_starts_with(strtolower($url), $unsupportedProtocol)) { 299 | throw FileUrlNotAllowed::make(); 300 | } 301 | } 302 | 303 | $this->url = $url; 304 | $this->html = ''; 305 | 306 | return $this; 307 | } 308 | 309 | public function setHtmlFromFilePath(string $filePath): static 310 | { 311 | 312 | if (! file_exists($filePath)) { 313 | throw FileDoesNotExistException::make($filePath); 314 | } 315 | 316 | $this->url = 'file://'.$filePath; 317 | $this->html = ''; 318 | 319 | return $this; 320 | } 321 | 322 | public function setProxyServer(string $proxyServer): static 323 | { 324 | $this->proxyServer = $proxyServer; 325 | 326 | return $this; 327 | } 328 | 329 | public function setHtml(string $html): static 330 | { 331 | $decodedHtml = html_entity_decode($html, ENT_QUOTES | ENT_HTML5); 332 | 333 | $protocols = array_filter($this->unsafeProtocols, function (string $protocol) { 334 | return $protocol !== 'file:'; 335 | }); 336 | 337 | foreach ([$html, $decodedHtml] as $content) { 338 | foreach ($protocols as $protocol) { 339 | if (str_contains(strtolower($content), $protocol)) { 340 | throw HtmlIsNotAllowedToContainFile::make(); 341 | } 342 | } 343 | } 344 | 345 | $this->html = $html; 346 | $this->url = ''; 347 | 348 | $this->hideBrowserHeaderAndFooter(); 349 | 350 | return $this; 351 | } 352 | 353 | public function clip(int $x, int $y, int $width, int $height): static 354 | { 355 | return $this->setOption('clip', compact('x', 'y', 'width', 'height')); 356 | } 357 | 358 | public function preventUnsuccessfulResponse(bool $preventUnsuccessfulResponse = true): static 359 | { 360 | return $this->setOption('preventUnsuccessfulResponse', $preventUnsuccessfulResponse); 361 | } 362 | 363 | public function select($selector, $index = 0): static 364 | { 365 | $this->selectorIndex($index); 366 | 367 | return $this->setOption('selector', $selector); 368 | } 369 | 370 | public function selectorIndex(int $index): static 371 | { 372 | return $this->setOption('selectorIndex', $index); 373 | } 374 | 375 | public function showBrowserHeaderAndFooter(): static 376 | { 377 | return $this->setOption('displayHeaderFooter', true); 378 | } 379 | 380 | public function hideBrowserHeaderAndFooter(): static 381 | { 382 | return $this->setOption('displayHeaderFooter', false); 383 | } 384 | 385 | public function hideHeader(): static 386 | { 387 | return $this->headerHtml('

'); 388 | } 389 | 390 | public function hideFooter(): static 391 | { 392 | return $this->footerHtml('

'); 393 | } 394 | 395 | public function headerHtml(string $html): static 396 | { 397 | return $this->setOption('headerTemplate', $html); 398 | } 399 | 400 | public function footerHtml(string $html): static 401 | { 402 | return $this->setOption('footerTemplate', $html); 403 | } 404 | 405 | public function deviceScaleFactor(int $deviceScaleFactor): static 406 | { 407 | // Google Chrome currently supports values of 1, 2, and 3. 408 | return $this->setOption('viewport.deviceScaleFactor', max(1, min(3, $deviceScaleFactor))); 409 | } 410 | 411 | public function fullPage(): static 412 | { 413 | return $this->setOption('fullPage', true); 414 | } 415 | 416 | public function showBackground(): static 417 | { 418 | $this->showBackground = true; 419 | $this->showScreenshotBackground = true; 420 | 421 | return $this; 422 | } 423 | 424 | public function hideBackground(): static 425 | { 426 | $this->showBackground = false; 427 | $this->showScreenshotBackground = false; 428 | 429 | return $this; 430 | } 431 | 432 | public function transparentBackground(): static 433 | { 434 | $this->transparentBackground = true; 435 | 436 | return $this; 437 | } 438 | 439 | public function taggedPdf(): static 440 | { 441 | $this->taggedPdf = true; 442 | 443 | return $this; 444 | } 445 | 446 | public function setScreenshotType(string $type, ?int $quality = null): static 447 | { 448 | $this->screenshotType = $type; 449 | 450 | if (! is_null($quality)) { 451 | $this->screenshotQuality = $quality; 452 | } 453 | 454 | return $this; 455 | } 456 | 457 | public function ignoreHttpsErrors(): static 458 | { 459 | return $this->setOption('acceptInsecureCerts', true); 460 | } 461 | 462 | public function mobile(bool $mobile = true): static 463 | { 464 | return $this->setOption('viewport.isMobile', $mobile); 465 | } 466 | 467 | public function touch(bool $touch = true): static 468 | { 469 | return $this->setOption('viewport.hasTouch', $touch); 470 | } 471 | 472 | public function landscape(bool $landscape = true): static 473 | { 474 | return $this->setOption('landscape', $landscape); 475 | } 476 | 477 | public function margins(float $top, float $right, float $bottom, float $left, string $unit = 'mm'): static 478 | { 479 | return $this->setOption('margin', [ 480 | 'top' => $top.$unit, 481 | 'right' => $right.$unit, 482 | 'bottom' => $bottom.$unit, 483 | 'left' => $left.$unit, 484 | ]); 485 | } 486 | 487 | public function noSandbox(): static 488 | { 489 | $this->noSandbox = true; 490 | 491 | return $this; 492 | } 493 | 494 | public function dismissDialogs(): static 495 | { 496 | return $this->setOption('dismissDialogs', true); 497 | } 498 | 499 | public function disableJavascript(): static 500 | { 501 | return $this->setOption('disableJavascript', true); 502 | } 503 | 504 | public function disableImages(): static 505 | { 506 | return $this->setOption('disableImages', true); 507 | } 508 | 509 | public function disableCaptureURLS(): static 510 | { 511 | return $this->setOption('disableCaptureURLS', true); 512 | } 513 | 514 | public function blockUrls($array): static 515 | { 516 | return $this->setOption('blockUrls', $array); 517 | } 518 | 519 | public function blockDomains($array): static 520 | { 521 | return $this->setOption('blockDomains', $array); 522 | } 523 | 524 | public function disableRedirects(): static 525 | { 526 | return $this->setOption('disableRedirects', true); 527 | } 528 | 529 | public function pages(string $pages): static 530 | { 531 | return $this->setOption('pageRanges', $pages); 532 | } 533 | 534 | public function paperSize(float $width, float $height, string $unit = 'mm'): static 535 | { 536 | return $this 537 | ->setOption('width', $width.$unit) 538 | ->setOption('height', $height.$unit); 539 | } 540 | 541 | // paper format 542 | public function format(string $format): static 543 | { 544 | return $this->setOption('format', $format); 545 | } 546 | 547 | public function scale(float $scale): static 548 | { 549 | $this->scale = $scale; 550 | 551 | return $this; 552 | } 553 | 554 | public function timeout(int $timeout): static 555 | { 556 | $this->timeout = $timeout; 557 | 558 | return $this->setOption('timeout', $timeout * 1000); 559 | } 560 | 561 | public function protocolTimeout(int $protocolTimeout): static 562 | { 563 | return $this->setOption('protocolTimeout', $protocolTimeout * 1000); 564 | } 565 | 566 | public function userAgent(string $userAgent): static 567 | { 568 | return $this->setOption('userAgent', $userAgent); 569 | } 570 | 571 | public function device(string $device): static 572 | { 573 | return $this->setOption('device', $device); 574 | } 575 | 576 | public function emulateMedia(?string $media): static 577 | { 578 | return $this->setOption('emulateMedia', $media); 579 | } 580 | 581 | public function emulateMediaFeatures(array $features): static 582 | { 583 | return $this->setOption('emulateMediaFeatures', json_encode($features)); 584 | } 585 | 586 | public function newHeadless(): self 587 | { 588 | return $this->setOption('newHeadless', true); 589 | } 590 | 591 | public function windowSize(int $width, int $height): static 592 | { 593 | return $this 594 | ->setOption('viewport.width', $width) 595 | ->setOption('viewport.height', $height); 596 | } 597 | 598 | public function setDelay(int $delayInMilliseconds): static 599 | { 600 | return $this->setOption('delay', $delayInMilliseconds); 601 | } 602 | 603 | public function delay(int $delayInMilliseconds): static 604 | { 605 | return $this->setDelay($delayInMilliseconds); 606 | } 607 | 608 | public function setUserDataDir(string $absolutePath): static 609 | { 610 | return $this->addChromiumArguments(['user-data-dir' => $absolutePath]); 611 | } 612 | 613 | public function userDataDir(string $absolutePath): static 614 | { 615 | return $this->setUserDataDir($absolutePath); 616 | } 617 | 618 | public function writeOptionsToFile(): static 619 | { 620 | $this->writeOptionsToFile = true; 621 | 622 | return $this; 623 | } 624 | 625 | public function setOption($key, $value): static 626 | { 627 | $this->arraySet($this->additionalOptions, $key, $value); 628 | 629 | return $this; 630 | } 631 | 632 | public function addChromiumArguments(array $arguments): static 633 | { 634 | foreach ($arguments as $argument => $value) { 635 | if (is_numeric($argument)) { 636 | $this->chromiumArguments[] = "--$value"; 637 | } else { 638 | $this->chromiumArguments[] = "--$argument=$value"; 639 | } 640 | } 641 | 642 | return $this; 643 | } 644 | 645 | public function __call($name, $arguments) 646 | { 647 | $this->imageManipulations->$name(...$arguments); 648 | 649 | return $this; 650 | } 651 | 652 | public function save(string $targetPath): void 653 | { 654 | $extension = strtolower(pathinfo($targetPath, PATHINFO_EXTENSION)); 655 | 656 | if ($extension === '') { 657 | throw CouldNotTakeBrowsershot::outputFileDidNotHaveAnExtension($targetPath); 658 | } 659 | 660 | if ($extension === 'pdf') { 661 | $this->savePdf($targetPath); 662 | 663 | return; 664 | } 665 | 666 | $command = $this->createScreenshotCommand($targetPath); 667 | 668 | $output = $this->callBrowser($command); 669 | 670 | $this->cleanupTemporaryHtmlFile(); 671 | 672 | if (! file_exists($targetPath)) { 673 | throw CouldNotTakeBrowsershot::chromeOutputEmpty($targetPath, $output, $command); 674 | } 675 | 676 | if (! $this->imageManipulations->isEmpty()) { 677 | $this->imageManipulations->apply($targetPath); 678 | } 679 | } 680 | 681 | public function bodyHtml(): string 682 | { 683 | $command = $this->createBodyHtmlCommand(); 684 | 685 | $html = $this->callBrowser($command); 686 | 687 | $this->cleanupTemporaryHtmlFile(); 688 | 689 | return $html; 690 | } 691 | 692 | public function base64Screenshot(): string 693 | { 694 | $command = $this->createScreenshotCommand(); 695 | 696 | $encodedImage = $this->callBrowser($command); 697 | 698 | $this->cleanupTemporaryHtmlFile(); 699 | 700 | return $encodedImage; 701 | } 702 | 703 | public function screenshot(): string 704 | { 705 | if ($this->imageManipulations->isEmpty()) { 706 | 707 | $command = $this->createScreenshotCommand(); 708 | 709 | $encodedImage = $this->callBrowser($command); 710 | 711 | $this->cleanupTemporaryHtmlFile(); 712 | 713 | return base64_decode($encodedImage); 714 | } 715 | 716 | $temporaryDirectory = (new TemporaryDirectory($this->tempPath))->create(); 717 | 718 | $this->save($temporaryDirectory->path('screenshot.png')); 719 | 720 | $screenshot = file_get_contents($temporaryDirectory->path('screenshot.png')); 721 | 722 | $temporaryDirectory->delete(); 723 | 724 | return $screenshot; 725 | 726 | } 727 | 728 | public function pdf(): string 729 | { 730 | $command = $this->createPdfCommand(); 731 | 732 | $encodedPdf = $this->callBrowser($command); 733 | 734 | $this->cleanupTemporaryHtmlFile(); 735 | 736 | return base64_decode($encodedPdf); 737 | } 738 | 739 | public function savePdf(string $targetPath) 740 | { 741 | $command = $this->createPdfCommand($targetPath); 742 | 743 | $output = $this->callBrowser($command); 744 | 745 | $this->cleanupTemporaryHtmlFile(); 746 | 747 | if (! file_exists($targetPath)) { 748 | throw CouldNotTakeBrowsershot::chromeOutputEmpty($targetPath, $output); 749 | } 750 | } 751 | 752 | public function base64pdf(): string 753 | { 754 | $command = $this->createPdfCommand(); 755 | 756 | $encodedPdf = $this->callBrowser($command); 757 | 758 | $this->cleanupTemporaryHtmlFile(); 759 | 760 | return $encodedPdf; 761 | } 762 | 763 | public function evaluate(string $pageFunction): string 764 | { 765 | $command = $this->createEvaluateCommand($pageFunction); 766 | 767 | $evaluation = $this->callBrowser($command); 768 | 769 | $this->cleanupTemporaryHtmlFile(); 770 | 771 | return $evaluation; 772 | } 773 | 774 | /** 775 | * @return null|array{url: string} 776 | */ 777 | public function triggeredRequests(): ?array 778 | { 779 | $requests = $this->chromiumResult?->getRequestsList(); 780 | 781 | if (! is_null($requests)) { 782 | return $requests; 783 | } 784 | 785 | $command = $this->createTriggeredRequestsListCommand(); 786 | 787 | $this->callBrowser($command); 788 | 789 | $this->cleanupTemporaryHtmlFile(); 790 | 791 | return $this->chromiumResult?->getRequestsList(); 792 | } 793 | 794 | /** 795 | * @return null|array{ 796 | * url: string, 797 | * status: int, 798 | * statusText: string, 799 | * headers: array 800 | * } 801 | */ 802 | public function redirectHistory(): ?array 803 | { 804 | $redirectHistory = $this->chromiumResult?->getRedirectHistory(); 805 | 806 | if (! is_null($redirectHistory)) { 807 | return $redirectHistory; 808 | } 809 | 810 | $command = $this->createRedirectHistoryCommand(); 811 | 812 | $this->callBrowser($command); 813 | 814 | return $this->chromiumResult?->getRedirectHistory(); 815 | } 816 | 817 | /** 818 | * @return null|array{ 819 | * type: string, 820 | * message: string, 821 | * location:array 822 | * } 823 | */ 824 | public function consoleMessages(): ?array 825 | { 826 | $messages = $this->chromiumResult?->getConsoleMessages(); 827 | 828 | if (! is_null($messages)) { 829 | return $messages; 830 | } 831 | 832 | $command = $this->createConsoleMessagesCommand(); 833 | 834 | $this->callBrowser($command); 835 | 836 | $this->cleanupTemporaryHtmlFile(); 837 | 838 | return $this->chromiumResult?->getConsoleMessages(); 839 | } 840 | 841 | /** 842 | * @return null|array{status: int, url: string} 843 | */ 844 | public function failedRequests(): ?array 845 | { 846 | $requests = $this->chromiumResult?->getFailedRequests(); 847 | 848 | if (! is_null($requests)) { 849 | return $requests; 850 | } 851 | 852 | $command = $this->createFailedRequestsCommand(); 853 | 854 | $this->callBrowser($command); 855 | 856 | $this->cleanupTemporaryHtmlFile(); 857 | 858 | return $this->chromiumResult?->getFailedRequests(); 859 | } 860 | 861 | /** 862 | * @return null|array{name: string, message: string} 863 | */ 864 | public function pageErrors(): ?array 865 | { 866 | $pageErrors = $this->chromiumResult?->getPageErrors(); 867 | 868 | if (! is_null($pageErrors)) { 869 | return $pageErrors; 870 | } 871 | 872 | $command = $this->createPageErrorsCommand(); 873 | 874 | $this->callBrowser($command); 875 | 876 | $this->cleanupTemporaryHtmlFile(); 877 | 878 | return $this->chromiumResult?->getPageErrors(); 879 | } 880 | 881 | public function createBodyHtmlCommand(): array 882 | { 883 | $url = $this->getFinalContentsUrl(); 884 | 885 | return $this->createCommand($url, 'content'); 886 | } 887 | 888 | public function createScreenshotCommand($targetPath = null): array 889 | { 890 | $url = $this->getFinalContentsUrl(); 891 | 892 | $options = [ 893 | 'type' => $this->screenshotType, 894 | ]; 895 | if ($targetPath) { 896 | $options['path'] = $targetPath; 897 | } 898 | 899 | if ($this->screenshotQuality) { 900 | $options['quality'] = $this->screenshotQuality; 901 | } 902 | 903 | $command = $this->createCommand($url, 'screenshot', $options); 904 | 905 | if (! $this->showScreenshotBackground) { 906 | $command['options']['omitBackground'] = true; 907 | } 908 | 909 | return $command; 910 | } 911 | 912 | public function createPdfCommand($targetPath = null): array 913 | { 914 | $url = $this->getFinalContentsUrl(); 915 | 916 | $options = []; 917 | 918 | if ($targetPath) { 919 | $options['path'] = $targetPath; 920 | } 921 | 922 | $command = $this->createCommand($url, 'pdf', $options); 923 | 924 | if ($this->showBackground) { 925 | $command['options']['printBackground'] = true; 926 | } 927 | 928 | if ($this->transparentBackground) { 929 | $command['options']['omitBackground'] = true; 930 | } 931 | 932 | if ($this->taggedPdf) { 933 | $command['options']['tagged'] = true; 934 | } 935 | 936 | if ($this->scale) { 937 | $command['options']['scale'] = $this->scale; 938 | } 939 | 940 | return $command; 941 | } 942 | 943 | public function createEvaluateCommand(string $pageFunction): array 944 | { 945 | $url = $this->getFinalContentsUrl(); 946 | 947 | $options = [ 948 | 'pageFunction' => $pageFunction, 949 | ]; 950 | 951 | return $this->createCommand($url, 'evaluate', $options); 952 | } 953 | 954 | public function createTriggeredRequestsListCommand(): array 955 | { 956 | $url = $this->html 957 | ? $this->createTemporaryHtmlFile() 958 | : $this->url; 959 | 960 | return $this->createCommand($url, 'requestsList'); 961 | } 962 | 963 | public function createRedirectHistoryCommand(): array 964 | { 965 | $url = $this->html 966 | ? $this->createTemporaryHtmlFile() 967 | : $this->url; 968 | 969 | return $this->createCommand($url, 'redirectHistory'); 970 | } 971 | 972 | public function createConsoleMessagesCommand(): array 973 | { 974 | $url = $this->html 975 | ? $this->createTemporaryHtmlFile() 976 | : $this->url; 977 | 978 | return $this->createCommand($url, 'consoleMessages'); 979 | } 980 | 981 | public function createFailedRequestsCommand(): array 982 | { 983 | $url = $this->html 984 | ? $this->createTemporaryHtmlFile() 985 | : $this->url; 986 | 987 | return $this->createCommand($url, 'failedRequests'); 988 | } 989 | 990 | public function createPageErrorsCommand(): array 991 | { 992 | $url = $this->html 993 | ? $this->createTemporaryHtmlFile() 994 | : $this->url; 995 | 996 | return $this->createCommand($url, 'pageErrors'); 997 | } 998 | 999 | public function setRemoteInstance(string $ip = '127.0.0.1', int $port = 9222): self 1000 | { 1001 | // assuring that ip and port does actually contains a value 1002 | if ($ip && $port) { 1003 | $this->setOption('remoteInstanceUrl', 'http://'.$ip.':'.$port); 1004 | } 1005 | 1006 | return $this; 1007 | } 1008 | 1009 | public function setWSEndpoint(string $endpoint): self 1010 | { 1011 | if (! is_null($endpoint)) { 1012 | $this->setOption('browserWSEndpoint', $endpoint); 1013 | } 1014 | 1015 | return $this; 1016 | } 1017 | 1018 | public function throwOnRemoteConnectionError(bool $throw = true): self 1019 | { 1020 | $this->setOption('throwOnRemoteConnectionError', $throw); 1021 | 1022 | return $this; 1023 | } 1024 | 1025 | public function usePipe(): self 1026 | { 1027 | $this->setOption('pipe', true); 1028 | 1029 | return $this; 1030 | } 1031 | 1032 | public function setEnvironmentOptions(array $options = []): self 1033 | { 1034 | return $this->setOption('env', $options); 1035 | } 1036 | 1037 | public function setContentUrl(string $contentUrl): self 1038 | { 1039 | return $this->html ? $this->setOption('contentUrl', $contentUrl) : $this; 1040 | } 1041 | 1042 | protected function getOptionArgs(): array 1043 | { 1044 | $args = $this->chromiumArguments; 1045 | 1046 | if ($this->noSandbox) { 1047 | $args[] = '--no-sandbox'; 1048 | } 1049 | 1050 | if ($this->proxyServer) { 1051 | $args[] = '--proxy-server='.$this->proxyServer; 1052 | } 1053 | 1054 | return $args; 1055 | } 1056 | 1057 | protected function createCommand(string $url, string $action, array $options = []): array 1058 | { 1059 | $command = compact('url', 'action', 'options'); 1060 | 1061 | $command['options']['args'] = $this->getOptionArgs(); 1062 | 1063 | if (! empty($this->postParams)) { 1064 | $command['postParams'] = $this->postParams; 1065 | } 1066 | 1067 | if (! empty($this->additionalOptions)) { 1068 | $command['options'] = array_merge_recursive($command['options'], $this->additionalOptions); 1069 | } 1070 | 1071 | return $command; 1072 | } 1073 | 1074 | protected function createTemporaryHtmlFile(): string 1075 | { 1076 | $this->temporaryHtmlDirectory = (new TemporaryDirectory($this->tempPath))->create(); 1077 | 1078 | file_put_contents($temporaryHtmlFile = $this->temporaryHtmlDirectory->path('index.html'), $this->html); 1079 | 1080 | return "file://{$temporaryHtmlFile}"; 1081 | } 1082 | 1083 | protected function cleanupTemporaryHtmlFile(): void 1084 | { 1085 | if ($this->temporaryHtmlDirectory) { 1086 | $this->temporaryHtmlDirectory->delete(); 1087 | } 1088 | } 1089 | 1090 | protected function createTemporaryOptionsFile(string $command): string 1091 | { 1092 | $this->temporaryOptionsDirectory = (new TemporaryDirectory($this->tempPath))->create(); 1093 | 1094 | file_put_contents($temporaryOptionsFile = $this->temporaryOptionsDirectory->path('command.js'), $command); 1095 | 1096 | return "file://{$temporaryOptionsFile}"; 1097 | } 1098 | 1099 | protected function cleanupTemporaryOptionsFile(): void 1100 | { 1101 | if ($this->temporaryOptionsDirectory) { 1102 | $this->temporaryOptionsDirectory->delete(); 1103 | } 1104 | } 1105 | 1106 | protected function callBrowser(array $command): string 1107 | { 1108 | $fullCommand = $this->getFullCommand($command); 1109 | 1110 | $process = $this->isWindows() ? new Process($fullCommand, null, $this->getWindowsEnv()) : Process::fromShellCommandline($fullCommand); 1111 | 1112 | $process->setTimeout($this->timeout); 1113 | 1114 | // clear additional output data fetched on last browser request 1115 | $this->chromiumResult = null; 1116 | 1117 | $process->run(); 1118 | 1119 | $rawOutput = rtrim($process->getOutput()); 1120 | 1121 | $this->chromiumResult = new ChromiumResult(json_decode($rawOutput, true)); 1122 | 1123 | if ($process->isSuccessful()) { 1124 | $result = $this->chromiumResult?->getResult(); 1125 | 1126 | $this->cleanupTemporaryOptionsFile(); 1127 | 1128 | return $result; 1129 | } 1130 | 1131 | $this->cleanupTemporaryOptionsFile(); 1132 | $process->clearOutput(); 1133 | $exitCode = $process->getExitCode(); 1134 | $errorOutput = $process->getErrorOutput(); 1135 | 1136 | if ($exitCode === 4) { 1137 | throw RemoteConnectionException::make(rtrim($errorOutput)); 1138 | } 1139 | 1140 | if ($exitCode === 3) { 1141 | throw UnsuccessfulResponse::make($this->url, $errorOutput ?? ''); 1142 | } 1143 | 1144 | if ($exitCode === 2) { 1145 | throw ElementNotFound::make($this->additionalOptions['selector']); 1146 | } 1147 | 1148 | throw new ProcessFailedException($process); 1149 | } 1150 | 1151 | protected function getWindowsEnv(): array 1152 | { 1153 | return [ 1154 | 'LOCALAPPDATA' => getenv('LOCALAPPDATA'), 1155 | 'Path' => getenv('Path'), 1156 | 'SystemRoot' => getenv('SystemRoot'), 1157 | 'USERPROFILE' => getenv('USERPROFILE'), 1158 | ]; 1159 | } 1160 | 1161 | protected function getFullCommand(array $command): array|string 1162 | { 1163 | $nodeBinary = $this->nodeBinary ?: 'node'; 1164 | 1165 | $binPath = $this->binPath ?: __DIR__.'/../bin/browser.cjs'; 1166 | 1167 | $optionsCommand = $this->getOptionsCommand(json_encode($command)); 1168 | 1169 | if ($this->isWindows()) { 1170 | // on Windows we will let Symfony/process handle the command escaping 1171 | // by passing an array to the process instance 1172 | return [ 1173 | $nodeBinary, 1174 | $binPath, 1175 | $optionsCommand, 1176 | ]; 1177 | } 1178 | 1179 | $setIncludePathCommand = "PATH={$this->includePath}"; 1180 | 1181 | $setNodePathCommand = $this->getNodePathCommand($nodeBinary); 1182 | 1183 | $envVarsCommand = $this->buildEnvVarsCommand(); 1184 | 1185 | return 1186 | $setIncludePathCommand.' ' 1187 | .$setNodePathCommand.' ' 1188 | .$envVarsCommand.' ' 1189 | .'"'.$nodeBinary.'" ' 1190 | .escapeshellarg($binPath).' ' 1191 | .$optionsCommand; 1192 | } 1193 | 1194 | protected function getNodePathCommand(string $nodeBinary): string 1195 | { 1196 | if ($this->nodeModulePath) { 1197 | return "NODE_PATH=\"{$this->nodeModulePath}\""; 1198 | } 1199 | if ($this->npmBinary) { 1200 | return "NODE_PATH=$(\"{$nodeBinary}\" \"{$this->npmBinary}\" root -g)"; 1201 | } 1202 | 1203 | return 'NODE_PATH=`npm root -g`'; 1204 | } 1205 | 1206 | protected function getOptionsCommand(string $command): string 1207 | { 1208 | if ($this->writeOptionsToFile) { 1209 | $temporaryOptionsFile = $this->createTemporaryOptionsFile($command); 1210 | $command = "-f {$temporaryOptionsFile}"; 1211 | } 1212 | 1213 | if ($this->isWindows()) { 1214 | return $command; 1215 | } 1216 | 1217 | return escapeshellarg($command); 1218 | } 1219 | 1220 | protected function arraySet(array &$array, string $key, mixed $value): array 1221 | { 1222 | if (is_null($key)) { 1223 | return $array = $value; 1224 | } 1225 | 1226 | $keys = explode('.', $key); 1227 | 1228 | while (count($keys) > 1) { 1229 | $key = array_shift($keys); 1230 | 1231 | // If the key doesn't exist at this depth, we will just create an empty array 1232 | // to hold the next value, allowing us to create the arrays to hold final 1233 | // values at the correct depth. Then we'll keep digging into the array. 1234 | if (! isset($array[$key]) || ! is_array($array[$key])) { 1235 | $array[$key] = []; 1236 | } 1237 | 1238 | $array = &$array[$key]; 1239 | } 1240 | 1241 | $array[array_shift($keys)] = $value; 1242 | 1243 | return $array; 1244 | } 1245 | 1246 | protected function buildEnvVarsCommand(): string 1247 | { 1248 | if (empty($this->nodeEnvVars)) { 1249 | return ''; 1250 | } 1251 | 1252 | $parts = []; 1253 | foreach ($this->nodeEnvVars as $key => $value) { 1254 | $parts[] = $key.'='.escapeshellarg($value); 1255 | } 1256 | 1257 | return implode(' ', $parts); 1258 | } 1259 | 1260 | public function initialPageNumber(int $initialPage = 1): static 1261 | { 1262 | return $this 1263 | ->setOption('initialPageNumber', ($initialPage - 1)) 1264 | ->pages("{$initialPage}-"); 1265 | } 1266 | 1267 | public function getOutput(): ?ChromiumResult 1268 | { 1269 | return $this->chromiumResult; 1270 | } 1271 | 1272 | protected function isWindows(): bool 1273 | { 1274 | return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; 1275 | } 1276 | 1277 | protected function getFinalContentsUrl(): string 1278 | { 1279 | return $this->html 1280 | ? $this->createTemporaryHtmlFile() 1281 | : $this->url; 1282 | } 1283 | } 1284 | --------------------------------------------------------------------------------