File Coverage

blib/lib/WWW/Firecrawl.pm
Criterion Covered Total %
statement 218 351 62.1
branch 81 120 67.5
condition 59 128 46.0
subroutine 51 124 41.1
pod 61 104 58.6
total 470 827 56.8


line stmt bran cond sub pod time code
1             package WWW::Firecrawl;
2             # ABSTRACT: Firecrawl v2 API bindings (self-host first, cloud compatible)
3 8     8   917323 use Moo;
  8         48348  
  8         31  
4 8     8   9724 use Carp qw( croak );
  8         14  
  8         308  
5 8     8   4729 use HTTP::Request ();
  8         61819  
  8         170  
6 8     8   768 use JSON::MaybeXS ();
  8         17720  
  8         131  
7 8     8   32 use URI ();
  8         10  
  8         125  
8 8     8   2896 use Safe::Isa;
  8         3600  
  8         907  
9 8     8   2961 use WWW::Firecrawl::Error ();
  8         29  
  8         38839  
10              
11             our $VERSION = '0.001';
12              
13             has base_url => (
14             is => 'ro',
15             default => sub { $ENV{FIRECRAWL_BASE_URL} || 'https://api.firecrawl.dev' },
16             );
17              
18             has api_key => (
19             is => 'ro',
20             default => sub { $ENV{FIRECRAWL_API_KEY} },
21             );
22              
23             has api_version => (
24             is => 'ro',
25             default => sub { 'v2' },
26             );
27              
28             has json => (
29             is => 'lazy',
30             default => sub { JSON::MaybeXS->new( utf8 => 1, convert_blessed => 1 ) },
31             );
32              
33             has user_agent_string => (
34             is => 'ro',
35             default => sub { "WWW-Firecrawl/$VERSION" },
36             );
37              
38             has ua => (
39             is => 'lazy',
40             );
41              
42             has is_failure => (
43             is => 'ro',
44             default => sub { \&_default_is_failure },
45             );
46              
47             has strict => ( is => 'ro', default => sub { 0 } );
48              
49             has max_attempts => ( is => 'ro', default => sub { 3 } );
50             has retry_backoff => ( is => 'ro', default => sub { [ 1, 2, 4 ] } );
51             has retry_statuses => ( is => 'ro', default => sub { [ 429, 502, 503, 504 ] } );
52             has on_retry => ( is => 'ro' );
53              
54             has sleep_sub => (
55             is => 'ro',
56             default => sub {
57             require Time::HiRes;
58             return sub { Time::HiRes::sleep($_[0]) };
59             },
60             );
61              
62             has _retry_status_set => ( is => 'lazy' );
63             sub _build__retry_status_set {
64 6     6   44 return { map { $_ => 1 } @{ $_[0]->retry_statuses } };
  24         70  
  6         17  
65             }
66              
67             sub _default_is_failure {
68 16     16   22 my ( $page ) = @_;
69 16 50       38 return 0 unless ref $page eq 'HASH';
70 16   50     65 my $meta = $page->{metadata} || {};
71 16 100 66     61 return 1 if defined $meta->{error} && length $meta->{error};
72 11   100     69 my $sc = $meta->{statusCode} // 0;
73 11         59 return $sc >= 500;
74             }
75              
76             sub BUILDARGS {
77 25     25 0 1714506 my ( $class, @args ) = @_;
78 25 50 33     157 my %args = @args == 1 && ref $args[0] eq 'HASH' ? %{ $args[0] } : @args;
  0         0  
79 25 100       76 if ( exists $args{failure_codes} ) {
80             die "WWW::Firecrawl: pass either 'is_failure' or 'failure_codes', not both\n"
81 4 100       20 if exists $args{is_failure};
82 3         6 my $spec = delete $args{failure_codes};
83 3         8 $args{is_failure} = $class->_compile_failure_codes($spec);
84             }
85 24         365 return \%args;
86             }
87              
88             sub _compile_failure_codes {
89 3     3   7 my ( $class, $spec ) = @_;
90 3 50 66     14 if ( !ref $spec && defined $spec && $spec eq 'any-non-2xx' ) {
      66        
91             return sub {
92 5     5   7 my ($p) = @_;
93 5 50       13 return 0 unless ref $p eq 'HASH';
94 5   50     8 my $meta = $p->{metadata} || {};
95 5 50 33     12 return 1 if defined $meta->{error} && length $meta->{error};
96 5   50     11 my $sc = $meta->{statusCode} // 200;
97 5   100     27 return $sc < 200 || $sc >= 300;
98 1         5 };
99             }
100 2 50       6 if ( ref $spec eq 'ARRAY' ) {
101 2         32 my %codes = map { $_ => 1 } @$spec;
  202         277  
102             return sub {
103 5     5   32 my ($p) = @_;
104 5 50       14 return 0 unless ref $p eq 'HASH';
105 5   50     11 my $meta = $p->{metadata} || {};
106 5 100 66     19 return 1 if defined $meta->{error} && length $meta->{error};
107 4   50     9 my $sc = $meta->{statusCode} // 0;
108 4         17 return !!$codes{$sc};
109 2         21 };
110             }
111 0         0 die "WWW::Firecrawl: failure_codes must be arrayref or the string 'any-non-2xx'\n";
112             }
113              
114             sub _build_ua {
115 0     0   0 my ( $self ) = @_;
116 0         0 require LWP::UserAgent;
117 0         0 my $ua = LWP::UserAgent->new(
118             agent => $self->user_agent_string,
119             timeout => 300,
120             );
121 0         0 return $ua;
122             }
123              
124             #----------------------------------------------------------------------
125             # URL + header helpers
126             #----------------------------------------------------------------------
127              
128             sub endpoint_url {
129 33     33 1 76 my ( $self, @path ) = @_;
130 33         74 my $base = $self->base_url;
131 33         134 $base =~ s{/+\z}{};
132 33         159 return join('/', $base, $self->api_version, @path);
133             }
134              
135             sub _default_headers {
136 31     31   52 my ( $self ) = @_;
137 31         65 my @h = ( 'Content-Type' => 'application/json', 'Accept' => 'application/json' );
138 31 100       106 push @h, 'Authorization' => 'Bearer '.$self->api_key if defined $self->api_key;
139 31         162 return @h;
140             }
141              
142             sub _json_post {
143 18     18   31 my ( $self, $url, $body ) = @_;
144 18 50       343 my $content = defined $body ? $self->json->encode($body) : '{}';
145 18         361 return HTTP::Request->new( POST => $url, [ $self->_default_headers ], $content );
146             }
147              
148             sub _get {
149 11     11   16 my ( $self, $url ) = @_;
150 11         18 return HTTP::Request->new( GET => $url, [ $self->_default_headers ] );
151             }
152              
153             sub _delete {
154 1     1   3 my ( $self, $url ) = @_;
155 1         3 return HTTP::Request->new( DELETE => $url, [ $self->_default_headers ] );
156             }
157              
158             #----------------------------------------------------------------------
159             # Retry wrapper
160             #----------------------------------------------------------------------
161              
162             sub _classify_response {
163 15     15   26 my ( $self, $response, $attempt ) = @_;
164 15         90 my $cw = $response->header('Client-Warning');
165 15 50 33     867 if ( defined $cw && $cw eq 'Internal response' ) {
166             return (
167 0   0     0 WWW::Firecrawl::Error->new(
      0        
168             type => 'transport',
169             message => 'Firecrawl transport error: ' . ($response->decoded_content // $response->message // 'unknown'),
170             response => $response,
171             status_code => 0,
172             attempt => $attempt,
173             ),
174             1,
175             );
176             }
177 15 100       49 return (undef, 0) if $response->is_success;
178 7         62 my $code = $response->code;
179 7 100       179 my $retryable = $self->_retry_status_set->{$code} ? 1 : 0;
180 7         32 my $err = WWW::Firecrawl::Error->new(
181             type => 'api',
182             message => "Firecrawl: HTTP $code " . $response->message,
183             response => $response,
184             status_code => $code,
185             attempt => $attempt,
186             );
187 7         2280 return ($err, $retryable);
188             }
189              
190             sub _retry_delay {
191 4     4   10 my ( $self, $response, $attempt ) = @_;
192 4         15 my $ra = $response->header('Retry-After');
193 4 100 66     192 if ( defined $ra && $ra =~ /\A\d+\z/ ) {
194 1         3 return 0 + $ra;
195             }
196 3         7 my $backoff = $self->retry_backoff;
197 3         9 my $idx = $attempt - 1;
198 3 50       8 $idx = $#$backoff if $idx > $#$backoff;
199 3   50     11 return $backoff->[ $idx ] // 1;
200             }
201              
202             sub _do_with_retry {
203 11     11   26134 my ( $self, $request ) = @_;
204 11         32 my $max = $self->max_attempts;
205 11         26 my $last_error;
206 11         30 for my $attempt ( 1 .. $max ) {
207 15         278 my $response = $self->ua->request($request);
208 15         398 my ( $err, $retryable ) = $self->_classify_response($response, $attempt);
209 15 100       140 return $response unless $err;
210 7         24 $last_error = $err;
211 7 100 100     32 if ( $retryable && $attempt < $max ) {
212 4         11 my $delay = $self->_retry_delay($response, $attempt);
213 4 100       14 if ( my $cb = $self->on_retry ) {
214 1         4 $cb->( $attempt, $delay, $err );
215             }
216 4         20 $self->sleep_sub->($delay);
217 4         18 next;
218             }
219 3         25 die $err;
220             }
221 0         0 die $last_error;
222             }
223              
224             #----------------------------------------------------------------------
225             # Response parser: unified JSON decode + error handling
226             #----------------------------------------------------------------------
227              
228 21     21 1 72 sub is_response { $_[1]->$_isa('HTTP::Response') }
229 0     0 1 0 sub is_request { $_[1]->$_isa('HTTP::Request') }
230              
231             sub parse_response {
232 21     21 1 9324 my ( $self, $response ) = @_;
233 21 50       67 croak "parse_response requires an HTTP::Response" unless $self->is_response($response);
234 21         276 my $body = $response->decoded_content;
235 21         4593 my $data;
236 21 50 33     107 if ( defined $body && length $body ) {
237 21         47 local $@;
238 21         27 $data = eval { $self->json->decode($body) };
  21         433  
239 21 100       423 if ($@) {
240 1 50       3 if ( !$response->is_success ) {
241 1         8 die WWW::Firecrawl::Error->new(
242             type => 'api',
243             message => "Firecrawl: HTTP ".$response->code.": ".$response->message,
244             response => $response,
245             status_code => $response->code,
246             );
247             }
248 0         0 die WWW::Firecrawl::Error->new(
249             type => 'api',
250             message => "Firecrawl: invalid JSON response (HTTP ".$response->code."): $@",
251             response => $response,
252             status_code => $response->code,
253             );
254             }
255             }
256 20 100       51 if ( !$response->is_success ) {
257 1   0     11 my $msg = (ref $data eq 'HASH' && ($data->{error} || $data->{message}))
258             || $response->message || 'unknown error';
259 1         3 die WWW::Firecrawl::Error->new(
260             type => 'api',
261             message => "Firecrawl: HTTP ".$response->code.": $msg",
262             response => $response,
263             data => $data,
264             status_code => $response->code,
265             );
266             }
267 19 100 66     335 if ( ref $data eq 'HASH' && exists $data->{success} && !$data->{success} ) {
      100        
268 1   0     7 my $msg = $data->{error} || $data->{message} || 'request failed';
269 1         4 die WWW::Firecrawl::Error->new(
270             type => 'api',
271             message => "Firecrawl: $msg",
272             response => $response,
273             data => $data,
274             status_code => $response->code,
275             );
276             }
277 18         129 return $data;
278             }
279              
280             #----------------------------------------------------------------------
281             # Inspection helpers (classification)
282             #----------------------------------------------------------------------
283              
284             sub is_scrape_ok {
285 24     24 1 2619 my ( $self, $page ) = @_;
286 24         58 return !$self->is_failure->($page);
287             }
288              
289             sub scrape_status {
290 7     7 1 23 my ( $self, $page ) = @_;
291 7 50       21 return 0 unless ref $page eq 'HASH';
292 7   100     77 return $page->{metadata}{statusCode} // 0;
293             }
294              
295             sub scrape_error {
296 9     9 1 30 my ( $self, $page ) = @_;
297 9 50       27 return undef unless ref $page eq 'HASH';
298 9         18 my $err = $page->{metadata}{error};
299 9         34 my $sc = $page->{metadata}{statusCode};
300 9   66     48 my $bad_sc = defined $sc && ( $sc < 200 || $sc >= 300 );
301 9 100 66     41 if ( defined $err && length $err && $bad_sc ) {
      100        
302 5         31 return "$err (HTTP $sc)";
303             }
304 4 100 66     19 return $err if defined $err && length $err;
305 3 100       19 return "HTTP $sc" if $bad_sc;
306 1         5 return undef;
307             }
308              
309             #----------------------------------------------------------------------
310             # Endpoint: /scrape
311             #----------------------------------------------------------------------
312              
313             sub scrape_request {
314 14     14 1 1805 my ( $self, %args ) = @_;
315 14         18 delete $args{strict}; # client-side option, not an API param
316 14 100       165 croak "scrape_request requires 'url'" unless defined $args{url};
317 13         30 return $self->_json_post($self->endpoint_url('scrape'), \%args);
318             }
319              
320             sub parse_scrape_response {
321 16     16 1 2288 my ( $self, $response, %opts ) = @_;
322 16         41 my $full = $self->parse_response($response);
323 16         26 my $data = $full->{data};
324 16 100       92 my $strict = exists $opts{strict} ? $opts{strict} : $self->strict;
325 16 100 100     63 if ( $strict && $self->is_failure->($data) ) {
326             die WWW::Firecrawl::Error->new(
327             type => 'scrape',
328             message => 'Firecrawl scrape failed: ' . ($self->scrape_error($data) // 'unknown'),
329             data => $data,
330             status_code => $self->scrape_status($data),
331             url => $data->{metadata}{sourceURL} // $data->{metadata}{url},
332 3   50     7 response => $response,
      33        
333             );
334             }
335 13         88 return $data;
336             }
337              
338             sub scrape {
339 11     11 1 188 my ( $self, %args ) = @_;
340 11         16 my %parse_opts;
341 11 50       25 $parse_opts{strict} = delete $args{strict} if exists $args{strict};
342 11         36 return $self->parse_scrape_response(
343             $self->_do_with_retry($self->scrape_request(%args)),
344             %parse_opts,
345             );
346             }
347              
348             #----------------------------------------------------------------------
349             # Endpoint: /crawl
350             #----------------------------------------------------------------------
351              
352             sub crawl_request {
353 2     2 1 1367 my ( $self, %args ) = @_;
354 2 100       89 croak "crawl_request requires 'url'" unless defined $args{url};
355 1         3 return $self->_json_post($self->endpoint_url('crawl'), \%args);
356             }
357              
358             sub parse_crawl_response {
359 0     0 1 0 my ( $self, $response ) = @_;
360 0         0 return $self->parse_response($response);
361             }
362              
363             sub crawl {
364 0     0 1 0 my ( $self, %args ) = @_;
365 0         0 return $self->parse_crawl_response(
366             $self->_do_with_retry($self->crawl_request(%args))
367             );
368             }
369              
370             sub crawl_status_request {
371 2     2 1 1983 my ( $self, $id ) = @_;
372 2 100 66     88 croak "crawl_status_request requires id" unless defined $id && length $id;
373 1         3 return $self->_get($self->endpoint_url('crawl', $id));
374             }
375              
376             sub parse_crawl_status_response {
377 1     1 1 3226 my ( $self, $response ) = @_;
378 1         3 return $self->parse_response($response);
379             }
380              
381             sub crawl_status {
382 0     0 1 0 my ( $self, $id ) = @_;
383 0         0 return $self->parse_crawl_status_response(
384             $self->_do_with_retry($self->crawl_status_request($id))
385             );
386             }
387              
388             sub crawl_cancel_request {
389 1     1 1 1369 my ( $self, $id ) = @_;
390 1 50 33     7 croak "crawl_cancel_request requires id" unless defined $id && length $id;
391 1         3 return $self->_delete($self->endpoint_url('crawl', $id));
392             }
393              
394 0     0 0 0 sub parse_crawl_cancel_response { $_[0]->parse_response($_[1]) }
395              
396             sub crawl_cancel {
397 0     0 1 0 my ( $self, $id ) = @_;
398 0         0 return $self->parse_crawl_cancel_response(
399             $self->_do_with_retry($self->crawl_cancel_request($id))
400             );
401             }
402              
403             sub crawl_errors_request {
404 1     1 1 774 my ( $self, $id ) = @_;
405 1 50 33     8 croak "crawl_errors_request requires id" unless defined $id && length $id;
406 1         32 return $self->_get($self->endpoint_url('crawl', $id, 'errors'));
407             }
408              
409 0     0 0 0 sub parse_crawl_errors_response { $_[0]->parse_response($_[1]) }
410              
411             sub crawl_errors {
412 0     0 1 0 my ( $self, $id ) = @_;
413 0         0 return $self->parse_crawl_errors_response(
414             $self->_do_with_retry($self->crawl_errors_request($id))
415             );
416             }
417              
418             sub crawl_active_request {
419 1     1 1 774 my ( $self ) = @_;
420 1         3 return $self->_get($self->endpoint_url('crawl', 'active'));
421             }
422              
423 0     0 0 0 sub parse_crawl_active_response { $_[0]->parse_response($_[1]) }
424              
425             sub crawl_active {
426 0     0 1 0 my ( $self ) = @_;
427 0         0 return $self->parse_crawl_active_response(
428             $self->_do_with_retry($self->crawl_active_request)
429             );
430             }
431              
432             sub crawl_params_preview_request {
433 0     0 1 0 my ( $self, %args ) = @_;
434 0         0 return $self->_json_post($self->endpoint_url('crawl', 'params', 'preview'), \%args);
435             }
436              
437 0     0 0 0 sub parse_crawl_params_preview_response { $_[0]->parse_response($_[1]) }
438              
439             sub crawl_params_preview {
440 0     0 1 0 my ( $self, %args ) = @_;
441 0         0 return $self->parse_crawl_params_preview_response(
442             $self->_do_with_retry($self->crawl_params_preview_request(%args))
443             );
444             }
445              
446             # Follow the "next" pagination URL verbatim (crawl_status chunks > 10MB)
447             sub crawl_status_next_request {
448 1     1 0 2492 my ( $self, $next_url ) = @_;
449 1 50 33     6 croak "crawl_status_next_request requires next URL" unless defined $next_url && length $next_url;
450 1         3 return HTTP::Request->new( GET => $next_url, [ $self->_default_headers ] );
451             }
452              
453             sub crawl_status_next {
454 0     0 1 0 my ( $self, $next_url ) = @_;
455 0         0 return $self->parse_crawl_status_response(
456             $self->_do_with_retry($self->crawl_status_next_request($next_url))
457             );
458             }
459              
460             #----------------------------------------------------------------------
461             # Endpoint: /map
462             #----------------------------------------------------------------------
463              
464             sub map_request {
465 1     1 1 714 my ( $self, %args ) = @_;
466 1 50       4 croak "map_request requires 'url'" unless defined $args{url};
467 1         3 return $self->_json_post($self->endpoint_url('map'), \%args);
468             }
469              
470             sub parse_map_response {
471 1     1 1 2954 my ( $self, $response ) = @_;
472 1         3 my $data = $self->parse_response($response);
473 1 50       5 return $data->{links} if exists $data->{links};
474 0         0 return $data;
475             }
476              
477             sub map {
478 0     0 1 0 my ( $self, %args ) = @_;
479 0         0 return $self->parse_map_response(
480             $self->_do_with_retry($self->map_request(%args))
481             );
482             }
483              
484             #----------------------------------------------------------------------
485             # Endpoint: /search
486             #----------------------------------------------------------------------
487              
488             sub search_request {
489 2     2 1 1399 my ( $self, %args ) = @_;
490 2 100       86 croak "search_request requires 'query'" unless defined $args{query};
491 1         3 return $self->_json_post($self->endpoint_url('search'), \%args);
492             }
493              
494             sub parse_search_response {
495 0     0 1 0 my ( $self, $response ) = @_;
496 0         0 return $self->parse_response($response);
497             }
498              
499             sub search {
500 0     0 1 0 my ( $self, %args ) = @_;
501 0         0 return $self->parse_search_response(
502             $self->_do_with_retry($self->search_request(%args))
503             );
504             }
505              
506             #----------------------------------------------------------------------
507             # Endpoint: /batch/scrape
508             #----------------------------------------------------------------------
509              
510             sub batch_scrape_request {
511 3     3 1 1895 my ( $self, %args ) = @_;
512             croak "batch_scrape_request requires 'urls' arrayref"
513 3 100       171 unless ref $args{urls} eq 'ARRAY';
514 1         3 return $self->_json_post($self->endpoint_url('batch', 'scrape'), \%args);
515             }
516              
517 0     0 0 0 sub parse_batch_scrape_response { $_[0]->parse_response($_[1]) }
518              
519             sub batch_scrape {
520 0     0 1 0 my ( $self, %args ) = @_;
521 0         0 return $self->parse_batch_scrape_response(
522             $self->_do_with_retry($self->batch_scrape_request(%args))
523             );
524             }
525              
526             sub batch_scrape_status_request {
527 1     1 1 1295 my ( $self, $id ) = @_;
528 1 50 33     6 croak "batch_scrape_status_request requires id" unless defined $id && length $id;
529 1         3 return $self->_get($self->endpoint_url('batch', 'scrape', $id));
530             }
531              
532 0     0 0 0 sub parse_batch_scrape_status_response { $_[0]->parse_response($_[1]) }
533              
534             sub batch_scrape_status {
535 0     0 1 0 my ( $self, $id ) = @_;
536 0         0 return $self->parse_batch_scrape_status_response(
537             $self->_do_with_retry($self->batch_scrape_status_request($id))
538             );
539             }
540              
541             sub batch_scrape_cancel_request {
542 0     0 1 0 my ( $self, $id ) = @_;
543 0 0 0     0 croak "batch_scrape_cancel_request requires id" unless defined $id && length $id;
544 0         0 return $self->_delete($self->endpoint_url('batch', 'scrape', $id));
545             }
546              
547 0     0 0 0 sub parse_batch_scrape_cancel_response { $_[0]->parse_response($_[1]) }
548              
549             sub batch_scrape_cancel {
550 0     0 1 0 my ( $self, $id ) = @_;
551 0         0 return $self->parse_batch_scrape_cancel_response(
552             $self->_do_with_retry($self->batch_scrape_cancel_request($id))
553             );
554             }
555              
556             sub batch_scrape_errors_request {
557 1     1 1 786 my ( $self, $id ) = @_;
558 1 50 33     6 croak "batch_scrape_errors_request requires id" unless defined $id && length $id;
559 1         4 return $self->_get($self->endpoint_url('batch', 'scrape', $id, 'errors'));
560             }
561              
562 0     0 0 0 sub parse_batch_scrape_errors_response { $_[0]->parse_response($_[1]) }
563              
564             sub batch_scrape_errors {
565 0     0 1 0 my ( $self, $id ) = @_;
566 0         0 return $self->parse_batch_scrape_errors_response(
567             $self->_do_with_retry($self->batch_scrape_errors_request($id))
568             );
569             }
570              
571             sub batch_scrape_status_next_request {
572 0     0 0 0 my ( $self, $next_url ) = @_;
573 0 0 0     0 croak "batch_scrape_status_next_request requires next URL"
574             unless defined $next_url && length $next_url;
575 0         0 return HTTP::Request->new( GET => $next_url, [ $self->_default_headers ] );
576             }
577              
578             sub batch_scrape_status_next {
579 0     0 1 0 my ( $self, $next_url ) = @_;
580 0         0 return $self->parse_batch_scrape_status_response(
581             $self->_do_with_retry($self->batch_scrape_status_next_request($next_url))
582             );
583             }
584              
585             #----------------------------------------------------------------------
586             # Endpoint: /extract
587             #----------------------------------------------------------------------
588              
589             sub extract_request {
590 2     2 1 1317 my ( $self, %args ) = @_;
591             croak "extract_request requires 'urls' arrayref"
592 2 100       85 unless ref $args{urls} eq 'ARRAY';
593 1         2 return $self->_json_post($self->endpoint_url('extract'), \%args);
594             }
595              
596 0     0 0 0 sub parse_extract_response { $_[0]->parse_response($_[1]) }
597              
598             sub extract {
599 0     0 1 0 my ( $self, %args ) = @_;
600 0         0 return $self->parse_extract_response(
601             $self->_do_with_retry($self->extract_request(%args))
602             );
603             }
604              
605             sub extract_status_request {
606 1     1 1 824 my ( $self, $id ) = @_;
607 1 50 33     5 croak "extract_status_request requires id" unless defined $id && length $id;
608 1         4 return $self->_get($self->endpoint_url('extract', $id));
609             }
610              
611 0     0 0 0 sub parse_extract_status_response { $_[0]->parse_response($_[1]) }
612              
613             sub extract_status {
614 0     0 1 0 my ( $self, $id ) = @_;
615 0         0 return $self->parse_extract_status_response(
616             $self->_do_with_retry($self->extract_status_request($id))
617             );
618             }
619              
620             #----------------------------------------------------------------------
621             # Endpoint: /agent + /agent/{id}
622             #----------------------------------------------------------------------
623              
624             sub agent_request {
625 0     0 0 0 my ( $self, %args ) = @_;
626 0         0 return $self->_json_post($self->endpoint_url('agent'), \%args);
627             }
628              
629 0     0 0 0 sub parse_agent_response { $_[0]->parse_response($_[1]) }
630              
631             sub agent {
632 0     0 1 0 my ( $self, %args ) = @_;
633 0         0 return $self->parse_agent_response(
634             $self->_do_with_retry($self->agent_request(%args))
635             );
636             }
637              
638             sub agent_status_request {
639 0     0 0 0 my ( $self, $id ) = @_;
640 0 0 0     0 croak "agent_status_request requires id" unless defined $id && length $id;
641 0         0 return $self->_get($self->endpoint_url('agent', $id));
642             }
643              
644 0     0 0 0 sub parse_agent_status_response { $_[0]->parse_response($_[1]) }
645              
646             sub agent_status {
647 0     0 1 0 my ( $self, $id ) = @_;
648 0         0 return $self->parse_agent_status_response(
649             $self->_do_with_retry($self->agent_status_request($id))
650             );
651             }
652              
653             sub agent_cancel_request {
654 0     0 0 0 my ( $self, $id ) = @_;
655 0 0 0     0 croak "agent_cancel_request requires id" unless defined $id && length $id;
656 0         0 return $self->_delete($self->endpoint_url('agent', $id));
657             }
658              
659 0     0 0 0 sub parse_agent_cancel_response { $_[0]->parse_response($_[1]) }
660              
661             sub agent_cancel {
662 0     0 1 0 my ( $self, $id ) = @_;
663 0         0 return $self->parse_agent_cancel_response(
664             $self->_do_with_retry($self->agent_cancel_request($id))
665             );
666             }
667              
668             #----------------------------------------------------------------------
669             # Endpoint: /browser
670             #----------------------------------------------------------------------
671              
672             sub browser_create_request {
673 0     0 0 0 my ( $self, %args ) = @_;
674 0         0 return $self->_json_post($self->endpoint_url('browser', 'create'), \%args);
675             }
676              
677 0     0 0 0 sub parse_browser_create_response { $_[0]->parse_response($_[1]) }
678              
679             sub browser_create {
680 0     0 1 0 my ( $self, %args ) = @_;
681 0         0 return $self->parse_browser_create_response(
682             $self->_do_with_retry($self->browser_create_request(%args))
683             );
684             }
685              
686             sub browser_list_request {
687 0     0 0 0 my ( $self ) = @_;
688 0         0 return $self->_get($self->endpoint_url('browser', 'list'));
689             }
690              
691 0     0 0 0 sub parse_browser_list_response { $_[0]->parse_response($_[1]) }
692              
693             sub browser_list {
694 0     0 1 0 my ( $self ) = @_;
695 0         0 return $self->parse_browser_list_response(
696             $self->_do_with_retry($self->browser_list_request)
697             );
698             }
699              
700             sub browser_delete_request {
701 0     0 0 0 my ( $self, $id ) = @_;
702 0 0 0     0 croak "browser_delete_request requires id" unless defined $id && length $id;
703 0         0 return $self->_delete($self->endpoint_url('browser', $id));
704             }
705              
706 0     0 0 0 sub parse_browser_delete_response { $_[0]->parse_response($_[1]) }
707              
708             sub browser_delete {
709 0     0 1 0 my ( $self, $id ) = @_;
710 0         0 return $self->parse_browser_delete_response(
711             $self->_do_with_retry($self->browser_delete_request($id))
712             );
713             }
714              
715             sub browser_execute_request {
716 0     0 0 0 my ( $self, %args ) = @_;
717 0         0 return $self->_json_post($self->endpoint_url('browser', 'execute'), \%args);
718             }
719              
720 0     0 0 0 sub parse_browser_execute_response { $_[0]->parse_response($_[1]) }
721              
722             sub browser_execute {
723 0     0 1 0 my ( $self, %args ) = @_;
724 0         0 return $self->parse_browser_execute_response(
725             $self->_do_with_retry($self->browser_execute_request(%args))
726             );
727             }
728              
729             #----------------------------------------------------------------------
730             # Endpoint: /scrape/execute + /scrape/{id}/browser
731             #----------------------------------------------------------------------
732              
733             sub scrape_execute_request {
734 0     0 0 0 my ( $self, %args ) = @_;
735 0         0 return $self->_json_post($self->endpoint_url('scrape', 'execute'), \%args);
736             }
737              
738 0     0 0 0 sub parse_scrape_execute_response { $_[0]->parse_response($_[1]) }
739              
740             sub scrape_execute {
741 0     0 1 0 my ( $self, %args ) = @_;
742 0         0 return $self->parse_scrape_execute_response(
743             $self->_do_with_retry($self->scrape_execute_request(%args))
744             );
745             }
746              
747             sub scrape_browser_stop_request {
748 0     0 0 0 my ( $self, $id ) = @_;
749 0 0 0     0 croak "scrape_browser_stop_request requires id" unless defined $id && length $id;
750 0         0 return $self->_delete($self->endpoint_url('scrape', $id, 'browser'));
751             }
752              
753 0     0 0 0 sub parse_scrape_browser_stop_response { $_[0]->parse_response($_[1]) }
754              
755             sub scrape_browser_stop {
756 0     0 1 0 my ( $self, $id ) = @_;
757 0         0 return $self->parse_scrape_browser_stop_response(
758             $self->_do_with_retry($self->scrape_browser_stop_request($id))
759             );
760             }
761              
762             #----------------------------------------------------------------------
763             # Usage + monitoring endpoints
764             #----------------------------------------------------------------------
765              
766 1     1 0 734 sub credit_usage_request { $_[0]->_get($_[0]->endpoint_url('credit-usage')) }
767 0     0 0 0 sub parse_credit_usage_response { $_[0]->parse_response($_[1]) }
768             sub credit_usage {
769 0     0 1 0 my ( $self ) = @_;
770 0         0 return $self->parse_credit_usage_response($self->_do_with_retry($self->credit_usage_request));
771             }
772              
773 1     1 0 850 sub credit_usage_historical_request { $_[0]->_get($_[0]->endpoint_url('credit-usage', 'historical')) }
774 0     0 0 0 sub parse_credit_usage_historical_response { $_[0]->parse_response($_[1]) }
775             sub credit_usage_historical {
776 0     0 1 0 my ( $self ) = @_;
777 0         0 return $self->parse_credit_usage_historical_response($self->_do_with_retry($self->credit_usage_historical_request));
778             }
779              
780 1     1 0 783 sub token_usage_request { $_[0]->_get($_[0]->endpoint_url('token-usage')) }
781 0     0 0 0 sub parse_token_usage_response { $_[0]->parse_response($_[1]) }
782             sub token_usage {
783 0     0 1 0 my ( $self ) = @_;
784 0         0 return $self->parse_token_usage_response($self->_do_with_retry($self->token_usage_request));
785             }
786              
787 0     0 0 0 sub token_usage_historical_request { $_[0]->_get($_[0]->endpoint_url('token-usage', 'historical')) }
788 0     0 0 0 sub parse_token_usage_historical_response { $_[0]->parse_response($_[1]) }
789             sub token_usage_historical {
790 0     0 1 0 my ( $self ) = @_;
791 0         0 return $self->parse_token_usage_historical_response($self->_do_with_retry($self->token_usage_historical_request));
792             }
793              
794 1     1 0 826 sub queue_status_request { $_[0]->_get($_[0]->endpoint_url('queue-status')) }
795 0     0 0 0 sub parse_queue_status_response { $_[0]->parse_response($_[1]) }
796             sub queue_status {
797 0     0 1 0 my ( $self ) = @_;
798 0         0 return $self->parse_queue_status_response($self->_do_with_retry($self->queue_status_request));
799             }
800              
801 1     1 0 777 sub activity_request { $_[0]->_get($_[0]->endpoint_url('activity')) }
802 0     0 0 0 sub parse_activity_response { $_[0]->parse_response($_[1]) }
803             sub activity {
804 0     0 1 0 my ( $self ) = @_;
805 0         0 return $self->parse_activity_response($self->_do_with_retry($self->activity_request));
806             }
807              
808             #----------------------------------------------------------------------
809             # Bulk scrape + retry helpers
810             #----------------------------------------------------------------------
811              
812             sub scrape_many {
813 2     2 1 8 my ( $self, $urls, %common ) = @_;
814 2 50       9 croak "scrape_many: first arg must be arrayref of URLs"
815             unless ref $urls eq 'ARRAY';
816 2         5 my @ok;
817             my @failed;
818 2         4 for my $url ( @$urls ) {
819 6         1287 my $res = eval { $self->scrape( url => $url, %common ) };
  6         21  
820 6 100       13 if ( my $e = $@ ) {
821 1 50 33     11 my $err = ref $e && $e->isa('WWW::Firecrawl::Error')
822             ? $e
823             : WWW::Firecrawl::Error->new( type => 'api', message => "$e", url => $url );
824 1         3 push @failed, { url => $url, error => $err };
825 1         2 next;
826             }
827 5 100       11 if ( $self->is_scrape_ok($res) ) {
828 4         14 push @ok, { url => $url, data => $res };
829             }
830             else {
831 1   50     4 push @failed, {
832             url => $url,
833             error => WWW::Firecrawl::Error->new(
834             type => 'page',
835             message => 'Firecrawl scrape failed: ' . ($self->scrape_error($res) // 'unknown'),
836             data => $res,
837             status_code => $self->scrape_status($res),
838             url => $url,
839             ),
840             };
841             }
842             }
843             return {
844 2         17 ok => \@ok,
845             failed => \@failed,
846             stats => { ok => scalar @ok, failed => scalar @failed, total => scalar @$urls },
847             };
848             }
849              
850             sub retry_failed_pages {
851 1     1 1 17 my ( $self, $result, %scrape_opts ) = @_;
852 1 50       2 my @urls = map { $_->{url} } @{ $result->{failed} || [] };
  2         6  
  1         3  
853 1         4 return $self->scrape_many( \@urls, %scrape_opts );
854             }
855              
856             1;
857              
858             __END__