├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── src ├── Enum │ ├── BlockedResourceTypes.php │ └── ResponseStatuses.php ├── Server.php └── Structs │ ├── Cookie.php │ ├── DNSOverTlsProvider.php │ ├── GeoLocation.php │ ├── Options.php │ ├── Profile.php │ ├── Response.php │ ├── Storage.php │ ├── Task.php │ ├── Timings.php │ └── Viewport.php └── test ├── example.js └── local.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 LuKa 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Helper lib for [luka-dev/headless-task-server](https://github.com/luka-dev/headless-task-server) 2 | 3 | This lib help to prepare request with your scraper script and parse response. 4 | 5 | # Install 6 | ```bash 7 | composer require luka-dev/headless-task-server-php 8 | ``` 9 | 10 | # Usage 11 | - Connect to server 12 | ```php 13 | use LuKa\HeadlessTaskServerPhp\Server; 14 | 15 | //Let's created connection to specific server 16 | $server = new Server( 17 | 'http://127.0.0.1:8080/', //Addres to your task-server 18 | 'MySecretAuthKeyIfNeeded' //AUTH_KEY from server 19 | ); 20 | 21 | //This test will return true, if server work correct 22 | $server->isAlive() 23 | ``` 24 | - Create Task 25 | ```php 26 | //From var 27 | $task = new Task('here you can past your js'); 28 | 29 | //OR 30 | 31 | //From file 32 | $task = Task::fromFile('./path/to/file.js'); 33 | ``` 34 | - Set additional Options 35 | ```php 36 | $options = new Options(); 37 | 38 | //Set locale for our browser 39 | $options->setLocale('en-US'); 40 | 41 | //Set proxy for our browser (http or socks5) 42 | $options->setUpstreamProxyUrl('http://username:password@proxy.com:80'); 43 | ``` 44 | - Run Task and get Response 45 | ```php 46 | $response = $server->runTask($task, $options); 47 | 48 | //Get session 49 | $session = $response->getSession(); 50 | 51 | //Check if Task DONE in correct way 52 | $isDONE = $response->getStatus() === \LuKa\HeadlessTaskServerPhp\Enum\ResponseStatuses::RESOLVE; 53 | 54 | //Get Timings (How much time take to process this Task) 55 | $timings = $response->getTimings() 56 | //You can use this: 57 | //$timings->getCreatedAt() 58 | //$timings->getBeginAt() 59 | //$timings->getEndAt() 60 | 61 | //Here will be provided all output from `resolve` 62 | $output = $response->getOutput(); 63 | ``` 64 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luka-dev/headless-task-server-php", 3 | "description": "Helper for sending requests to luka-dev/headless-task-server", 4 | "keywords": [ 5 | "headless", 6 | "browser", 7 | "playwright", 8 | "puppeteer", 9 | "task", 10 | "automation", 11 | "cluster", 12 | "scrape", 13 | "crawl", 14 | "chrome", 15 | "chromium", 16 | "parse", 17 | "hero", 18 | "secret-agent", 19 | "render", 20 | "js", 21 | "php" 22 | ], 23 | "license": "MIT", 24 | "autoload": { 25 | "psr-4": { 26 | "LuKa\\HeadlessTaskServerPhp\\": "src/" 27 | } 28 | }, 29 | "authors": [ 30 | { 31 | "name": "luka-dev", 32 | "homepage": "https://github.com/luka-dev/" 33 | } 34 | ], 35 | "require": { 36 | "php": ">=7.3", 37 | "ext-json": "*", 38 | "ext-curl": "*" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Enum/BlockedResourceTypes.php: -------------------------------------------------------------------------------- 1 | address = rtrim($address, '/'); 21 | $this->authKey = $authKey; 22 | } 23 | 24 | /** @return bool */ 25 | public function isAlive(): bool 26 | { 27 | $curl = curl_init($this->address); 28 | curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); 29 | 30 | $response = curl_exec($curl); 31 | if (curl_error($curl)) { 32 | curl_close($curl); 33 | return false; 34 | } 35 | curl_close($curl); 36 | 37 | return $response === '{"health":"ok"}'; 38 | } 39 | 40 | /** 41 | * @param Task $task 42 | * @param Options|null $options 43 | * @param Profile|null $profile 44 | * @return Response 45 | * @throws \Exception 46 | */ 47 | public function runTask(Task $task, ?Options $options = null, ?Profile $profile = null): Response 48 | { 49 | $headers = [ 50 | 'Content-Type:application/json' 51 | ]; 52 | 53 | if ($this->authKey !== null) { 54 | $headers[] = 'Authorization:'.$this->authKey; 55 | } 56 | 57 | $curl = curl_init($this->address . '/task'); 58 | curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); 59 | curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); 60 | curl_setopt( $curl, CURLOPT_POSTFIELDS, json_encode([ 61 | 'options' => $options, 62 | 'profile' => $profile, 63 | 'script' => $task 64 | ])); 65 | 66 | $response = curl_exec($curl); 67 | if (curl_error($curl)) { 68 | curl_close($curl); 69 | throw new \Exception('No response from the task-server'); 70 | } 71 | curl_close($curl); 72 | 73 | return new Response($response); 74 | } 75 | } -------------------------------------------------------------------------------- /src/Structs/Cookie.php: -------------------------------------------------------------------------------- 1 | name = $data['name'] ?? null; 39 | $this->value = $data['value'] ?? null; 40 | $this->setSecure($data['secure'] ?? null); 41 | $this->setSameSite($data['sameSite'] ?? null); 42 | $this->setSameParty($data['sameParty'] ?? null); 43 | $this->setExpires($data['expires'] ?? null); 44 | $this->setHttpOnly($data['httpOnly'] ?? null); 45 | $this->setPath($data['path'] ?? null); 46 | $this->setDomain($data['domain'] ?? null); 47 | } 48 | 49 | public function getName(): string 50 | { 51 | return $this->name; 52 | } 53 | 54 | public function getValue(): string 55 | { 56 | return $this->value; 57 | } 58 | 59 | public function getDomain(): ?string 60 | { 61 | return $this->domain; 62 | } 63 | 64 | public function setDomain(?string $domain): void 65 | { 66 | $this->domain = $domain; 67 | } 68 | 69 | public function getPath(): ?string 70 | { 71 | return $this->path; 72 | } 73 | 74 | public function setPath(?string $path): void 75 | { 76 | $this->path = $path; 77 | } 78 | 79 | public function getExpires(): ?string 80 | { 81 | return $this->expires; 82 | } 83 | 84 | public function setExpires(?string $expires): void 85 | { 86 | $this->expires = $expires; 87 | } 88 | 89 | public function getHttpOnly(): ?bool 90 | { 91 | return $this->httpOnly; 92 | } 93 | 94 | public function setHttpOnly(?bool $httpOnly): void 95 | { 96 | $this->httpOnly = $httpOnly; 97 | } 98 | 99 | public function getSecure(): ?bool 100 | { 101 | return $this->secure; 102 | } 103 | 104 | public function setSecure(?bool $secure): void 105 | { 106 | $this->secure = $secure; 107 | } 108 | 109 | public function getSameSite(): ?string 110 | { 111 | return $this->sameSite; 112 | } 113 | 114 | /** 115 | * @param string|null $sameSite 116 | * @return void 117 | * @throws \Exception 118 | */ 119 | public function setSameSite(?string $sameSite): void 120 | { 121 | if (is_null($sameSite) || in_array($sameSite, ['Strict', 'Lax', 'None'])) { 122 | $this->sameSite = $sameSite; 123 | } else { 124 | throw new \Exception('Incorrect sameSite value'); 125 | } 126 | } 127 | 128 | /** 129 | * @return bool|null 130 | */ 131 | public function getSameParty(): ?bool 132 | { 133 | return $this->sameParty; 134 | } 135 | 136 | /** 137 | * @param bool|null $sameParty 138 | */ 139 | public function setSameParty(?bool $sameParty): void 140 | { 141 | $this->sameParty = $sameParty; 142 | } 143 | 144 | public function jsonSerialize(): array 145 | { 146 | return array_filter(get_object_vars($this), function ($v) { 147 | return !is_null($v); 148 | }); 149 | } 150 | } -------------------------------------------------------------------------------- /src/Structs/DNSOverTlsProvider.php: -------------------------------------------------------------------------------- 1 | host = $host; 18 | $this->servername = $servername; 19 | } 20 | 21 | public function jsonSerialize(): array 22 | { 23 | return [ 24 | 'host' => $this->host, 25 | 'servername' => $this->servername, 26 | ]; 27 | } 28 | } -------------------------------------------------------------------------------- /src/Structs/GeoLocation.php: -------------------------------------------------------------------------------- 1 | 90) { 22 | throw new Exception('$latitude has not acceptable value. Min -90, Max 90'); 23 | } 24 | if ($longitude < -90 || $longitude > 90) { 25 | throw new Exception('$longitude has not acceptable value. Min -180, Max 180'); 26 | } 27 | if (!is_null($accuracy) && $accuracy < 1) { 28 | throw new Exception('$accuracy has not acceptable value. Min 1. null - random'); 29 | } 30 | 31 | $this->longitude = $longitude; 32 | $this->latitude = $latitude; 33 | $this->accuracy = $accuracy; 34 | } 35 | 36 | public function jsonSerialize(): array 37 | { 38 | return [ 39 | 'latitude' => $this->latitude, 40 | 'longitude' => $this->longitude, 41 | 'accuracy' => $this->accuracy, 42 | ]; 43 | } 44 | } -------------------------------------------------------------------------------- /src/Structs/Options.php: -------------------------------------------------------------------------------- 1 | $value) { 37 | switch ($key) { 38 | case 'dnsOverTlsProvider': 39 | $this->dnsOverTlsProvider = new DNSOverTlsProvider($value['host'] ?? null, $value['servername'] ?? null); 40 | break; 41 | default: 42 | if (property_exists(self::class, $key)) { 43 | $this->{$key} = $value; 44 | } 45 | } 46 | } 47 | } 48 | 49 | public function getUserAgent(): ?string 50 | { 51 | return $this->userAgent; 52 | } 53 | 54 | public function setUserAgent(?string $userAgent): void 55 | { 56 | $this->userAgent = $userAgent; 57 | } 58 | 59 | public function getGeoLocation(): ?GeoLocation 60 | { 61 | return $this->geoLocation; 62 | } 63 | 64 | public function setGeoLocation(?GeoLocation $geoLocation): void 65 | { 66 | $this->geoLocation = $geoLocation; 67 | } 68 | 69 | public function getTimezoneId(): ?string 70 | { 71 | return $this->timezoneId; 72 | } 73 | 74 | public function setTimezoneId(?string $timezoneId): void 75 | { 76 | $this->timezoneId = $timezoneId; 77 | } 78 | 79 | public function getLocale(): ?string 80 | { 81 | return $this->locale; 82 | } 83 | 84 | public function setLocale(?string $locale): void 85 | { 86 | $this->locale = $locale; 87 | } 88 | 89 | public function setBlockedResourceTypes(?array $blockedResourceTypes): void 90 | { 91 | $this->blockedResourceTypes = $blockedResourceTypes; 92 | } 93 | 94 | public function setUpstreamProxyUrl(?string $upstreamProxyUrl): void 95 | { 96 | $this->upstreamProxyUrl = $upstreamProxyUrl; 97 | } 98 | 99 | /** 100 | * string from const of BlockedResourceTypes:: 101 | * @return \string[]|null 102 | */ 103 | public function getBlockedResourceTypes(): ?array 104 | { 105 | return $this->blockedResourceTypes; 106 | } 107 | public function getBlockedResourceUrls(): ?array 108 | { 109 | return $this->blockedResourceUrls; 110 | } 111 | 112 | public function getUpstreamProxyUrl(): ?string 113 | { 114 | return $this->upstreamProxyUrl; 115 | } 116 | 117 | public function getDnsOverTlsProvider(): ?DNSOverTlsProvider 118 | { 119 | return $this->dnsOverTlsProvider; 120 | } 121 | 122 | public function setDnsOverTlsProvider(?DNSOverTlsProvider $dnsOverTlsProvider): void 123 | { 124 | $this->dnsOverTlsProvider = $dnsOverTlsProvider; 125 | } 126 | 127 | public function jsonSerialize(): array 128 | { 129 | return array_filter(get_object_vars($this), function ($v) { 130 | return !is_null($v); 131 | }); 132 | } 133 | } -------------------------------------------------------------------------------- /src/Structs/Profile.php: -------------------------------------------------------------------------------- 1 | |null 15 | */ 16 | private $storage = []; 17 | 18 | /** @var string|null */ 19 | private $userAgentString = null; 20 | 21 | /** @var mixed|null */ 22 | private $deviceProfile = null; 23 | 24 | /** 25 | * @throws \Exception 26 | */ 27 | public function __construct(array $data = []) 28 | { 29 | foreach ($data as $key => $value) { 30 | switch ($key) { 31 | case 'cookies': 32 | foreach ($value as $cookie) { 33 | $this->addCookie(new Cookie($cookie)); 34 | } 35 | break; 36 | case 'storage': 37 | foreach ($value as $url => $storage) { 38 | $this->storage[$url] = new Storage($storage['indexedDB'], $storage['localStorage'], $storage['sessionStorage']); 39 | } 40 | default: 41 | if (property_exists(self::class, $key)) { 42 | $this->{$key} = $value; 43 | } 44 | } 45 | 46 | } 47 | } 48 | 49 | /** 50 | * @return Cookie[]|null 51 | */ 52 | public function getAllCookies(): ?array 53 | { 54 | return $this->cookies; 55 | } 56 | 57 | public function addCookie(Cookie $cookie): void 58 | { 59 | $this->cookies[] = $cookie; 60 | } 61 | 62 | /** @param Cookie[]|null $cookie */ 63 | public function setCookies(?array $cookie): void 64 | { 65 | $this->cookies[] = $cookie; 66 | } 67 | 68 | public function getUserAgentString(): ?string 69 | { 70 | return $this->userAgentString; 71 | } 72 | 73 | public function setUserAgentString(?string $userAgentString): void 74 | { 75 | $this->userAgentString = $userAgentString; 76 | } 77 | 78 | public function jsonSerialize(): array 79 | { 80 | return array_filter(get_object_vars($this), function ($v) { 81 | return !is_null($v); 82 | }); 83 | } 84 | } -------------------------------------------------------------------------------- /src/Structs/Response.php: -------------------------------------------------------------------------------- 1 | status = $decoded['status']; 34 | $this->timings = new Timings($decoded['timings'] ?? []); 35 | $this->options = new Options($decoded['options']); 36 | $this->profile = new Profile($decoded['profile']); 37 | $this->output = $decoded['output'] ?? null; 38 | $this->error = $decoded['error'] ?? null; 39 | } 40 | catch (\Throwable $e) { 41 | var_dump($e); 42 | throw new \Exception('Bad response. Cant be parsed'); 43 | } 44 | } 45 | 46 | /** 47 | * @return string 48 | */ 49 | public function getStatus(): string 50 | { 51 | return $this->status; 52 | } 53 | 54 | /** 55 | * @return Timings 56 | */ 57 | public function getTimings(): Timings 58 | { 59 | return $this->timings; 60 | } 61 | 62 | /** 63 | * @return Options 64 | */ 65 | public function getOptions(): Options 66 | { 67 | return $this->options; 68 | } 69 | 70 | /** 71 | * @return Profile 72 | */ 73 | public function getProfile(): Profile 74 | { 75 | return $this->profile; 76 | } 77 | 78 | /** 79 | * @return mixed 80 | */ 81 | public function getOutput() 82 | { 83 | return $this->output; 84 | } 85 | 86 | /** 87 | * @return string|null 88 | */ 89 | public function getError(): ?string 90 | { 91 | return $this->error; 92 | } 93 | 94 | } -------------------------------------------------------------------------------- /src/Structs/Storage.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private $localStorage; 15 | 16 | /** 17 | * @description each element is 2 element array where first element - key, second - value 18 | * @var Array 19 | */ 20 | private $sessionStorage; 21 | 22 | /** 23 | * @param mixed[] $indexedDB 24 | * @param Array $localStorage see property description 25 | * @param Array $sessionStorage see property description 26 | */ 27 | public function __construct(array $indexedDB = [], array $localStorage = [], array $sessionStorage = []) 28 | { 29 | $this->indexedDB = $indexedDB; 30 | $this->localStorage = $localStorage; 31 | $this->sessionStorage = $sessionStorage; 32 | } 33 | 34 | public function jsonSerialize(): array 35 | { 36 | return [ 37 | 'indexedDB' => $this->indexedDB, 38 | 'localStorage' => $this->localStorage, 39 | 'sessionStorage' => $this->sessionStorage, 40 | ]; 41 | } 42 | } -------------------------------------------------------------------------------- /src/Structs/Task.php: -------------------------------------------------------------------------------- 1 | value, where key is var name. 27 | * @var array 28 | */ 29 | private $vars = []; 30 | 31 | /** 32 | * @description php assoc array of key => value, where key is var name. 33 | * @var array 34 | */ 35 | private $consts = []; 36 | 37 | /** Send js from var or use static fromFile */ 38 | public function __construct(string $script) 39 | { 40 | $this->script = $script; 41 | } 42 | 43 | /** 44 | * @param string $pathToFile 45 | * @param array $vars 46 | * @return Task 47 | * @throws \Exception 48 | */ 49 | public static function fromFile(string $pathToFile): Task 50 | { 51 | $script = file_get_contents($pathToFile); 52 | if ($script === false) { 53 | throw new \Exception('Not path to file'); 54 | } 55 | 56 | return new Task($script); 57 | } 58 | 59 | /** 60 | * @param string $name 61 | * @param $value 62 | * @return void 63 | * @throws \Exception 64 | */ 65 | public function setVar(string $name, $value): void 66 | { 67 | if (in_array($name, self::$reservedVars)) { 68 | throw new \Exception('this let name is reserved'); 69 | } 70 | $this->vars[$name] = $value; 71 | } 72 | 73 | /** 74 | * @param string $name 75 | * @param $value 76 | * @return void 77 | * @throws \Exception 78 | */ 79 | public function setConst(string $name, $value): void 80 | { 81 | if (in_array($name, self::$reservedVars)) { 82 | throw new \Exception('this const name is reserved'); 83 | } 84 | $this->consts[$name] = $value; 85 | } 86 | 87 | public function jsonSerialize(): string { 88 | $script = ''; 89 | foreach ($this->consts as $name => $value) { 90 | $script .= 'const ' . $name . ' = ' . json_encode($value) . PHP_EOL; 91 | } 92 | foreach ($this->vars as $name => $value) { 93 | $script .= 'let ' . $name . ' = ' . json_encode($value) . PHP_EOL; 94 | } 95 | $script .= $this->script; 96 | return $script; 97 | } 98 | } -------------------------------------------------------------------------------- /src/Structs/Timings.php: -------------------------------------------------------------------------------- 1 | beginAt = new DateTime($timings['begin_at'], new DateTimeZone('UTC')); 24 | } 25 | } catch (\Throwable $e) { 26 | $this->beginAt = null; 27 | } 28 | 29 | try { 30 | if ($timings['end_at'] !== null) { 31 | $this->endAt = new DateTime($timings['end_at'], new DateTimeZone('UTC')); 32 | } 33 | } catch (\Throwable $e) { 34 | $this->endAt = null; 35 | } 36 | 37 | try { 38 | if ($timings['end_at'] !== null) { 39 | $this->createdAt = new DateTime($timings['created_at'], new DateTimeZone('UTC')); 40 | } 41 | } catch (\Throwable $e) { 42 | $this->createdAt = null; 43 | } 44 | } 45 | 46 | public function getBeginAt(): ?DateTime 47 | { 48 | return $this->beginAt; 49 | } 50 | 51 | public function getEndAt(): ?DateTime 52 | { 53 | return $this->endAt; 54 | } 55 | 56 | public function getCreatedAt(): ?DateTime 57 | { 58 | return $this->createdAt; 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /src/Structs/Viewport.php: -------------------------------------------------------------------------------- 1 | width = $width; 37 | $this->height = $height; 38 | $this->deviceScaleFactor = $deviceScaleFactor; 39 | } 40 | 41 | /** 42 | * @return int|null 43 | */ 44 | public function getScreenWidth(): ?int 45 | { 46 | return $this->screenWidth; 47 | } 48 | 49 | public function getScreenHeight(): ?int 50 | { 51 | return $this->screenHeight; 52 | } 53 | 54 | public function setScreen(int $screenWidth, int $screenHeight): void 55 | { 56 | $this->screenWidth = $screenWidth; 57 | $this->screenHeight = $screenHeight; 58 | } 59 | 60 | public function removeScreen(): void 61 | { 62 | $this->screenWidth = null; 63 | $this->screenHeight = null; 64 | } 65 | 66 | 67 | public function getPositionX(): ?int 68 | { 69 | return $this->positionX; 70 | } 71 | 72 | public function getPositionY(): ?int 73 | { 74 | return $this->positionY; 75 | } 76 | 77 | public function setPosition(?int $positionX, ?int $positionY): void 78 | { 79 | $this->positionX = $positionX; 80 | $this->positionY = $positionY; 81 | } 82 | 83 | public function removePosition(): void 84 | { 85 | $this->positionX = null; 86 | $this->positionY = null; 87 | } 88 | 89 | public function jsonSerialize(): array 90 | { 91 | return array_filter(get_object_vars($this), function ($v) { 92 | return !is_null($v); 93 | }); 94 | } 95 | } -------------------------------------------------------------------------------- /test/example.js: -------------------------------------------------------------------------------- 1 | await agent.goto('https://example.com/'); 2 | resolve(await agent.document.title); -------------------------------------------------------------------------------- /test/local.php: -------------------------------------------------------------------------------- 1 | isAlive()) { 12 | echo 'Server is OK' . PHP_EOL; 13 | } else { 14 | echo 'Hmm, server ded...'; 15 | die(); 16 | } 17 | 18 | try { 19 | $task = Task::fromFile(__DIR__ . '/example.js'); 20 | $task->setVar('myVar', 'Some TeSt VALUE'); 21 | } catch (Exception $e) { 22 | echo $e->getMessage() . PHP_EOL; 23 | echo 'Check path to file'; 24 | die(); 25 | } 26 | 27 | $options = new Options(); 28 | $options->setLocale('en-US'); 29 | 30 | try { 31 | $response = $server->runTask($task, $options); 32 | } 33 | catch (Exception $e) { 34 | echo $e->getMessage() . PHP_EOL; 35 | echo 'We cant understand response, check server'; 36 | die(); 37 | } 38 | 39 | echo 'Response received' . PHP_EOL; 40 | echo 'Status is: ' . $response->getStatus() . PHP_EOL; 41 | echo 'Title is: '. json_encode($response->getOutput()) . PHP_EOL; 42 | echo 'Test passed. Bye'; 43 | --------------------------------------------------------------------------------