$url, 'formats' => ['rawHtml'], // Firecrawl's own boilerplate stripping is deliberately off; see above. 'onlyMainContent' => false, 'timeout' => $this->timeout * 1000, ]; if ($headers) $payload['headers'] = $headers; $request_headers = ['Content-Type: application/json']; if ($this->api_key) $request_headers[] = 'Authorization: Bearer ' . $this->api_key; $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => rtrim($this->endpoint, '/') . '/v1/scrape', CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_SLASHES), CURLOPT_HTTPHEADER => $request_headers, CURLOPT_RETURNTRANSFER => true, // The browser render dominates; allow slack over the scrape timeout. CURLOPT_TIMEOUT => $this->timeout + 15, CURLOPT_CONNECTTIMEOUT => 10, ]); $body = curl_exec($ch); $err = curl_error($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($err !== '' || !is_string($body)) return new FetchResult(error: 'firecrawl: ' . ($err !== '' ? $err : 'empty response'), status: $status); $json = json_decode($body, true); if (!is_array($json)) return new FetchResult(error: 'firecrawl: unparseable response', status: $status); if (empty($json['success'])) { $msg = is_string($json['error'] ?? null) ? $json['error'] : "HTTP $status"; return new FetchResult(error: "firecrawl: $msg", status: $status); } $data = $json['data'] ?? []; $html = $data['rawHtml'] ?? $data['html'] ?? ''; if (!is_string($html) || $html === '') return new FetchResult(error: 'firecrawl: no html in response', status: $status); $effective = $data['metadata']['sourceURL'] ?? $data['metadata']['url'] ?? $url; return new FetchResult( html: $html, effective_url: is_string($effective) ? $effective : $url, status: $status, ); } }