├── 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 |
6 |
7 |
8 |
9 |
Render web pages to an image or PDF with Puppeteer
10 |
11 | [](https://github.com/spatie/browsershot/releases)
12 | [](LICENSE.md)
13 | [](https://github.com/spatie/browsershot/actions)
14 | [](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 | [](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 |
--------------------------------------------------------------------------------