File Coverage

blib/lib/API/Octopart.pm
Criterion Covered Total %
statement 20 154 12.9
branch 0 52 0.0
condition 0 43 0.0
subroutine 7 17 41.1
pod 7 7 100.0
total 34 273 12.4


line stmt bran cond sub pod time code
1             #!/usr/bin/perl
2              
3              
4             # This module is free software: you can redistribute it and/or modify it under
5             # the terms of the GNU General Public License as published by the Free Software
6             # Foundation, either version 3 of the License, or (at your option) any later
7             # version.
8             #
9             # This module is distributed in the hope that it will be useful, but WITHOUT ANY
10             # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11             # PARTICULAR PURPOSE. See the GNU General Public License for more details.
12             #
13             # You should have received a copy of the GNU General Public License along with
14             # this module. If not, see .
15             #
16             # Copyright (C) 2022- eWheeler, Inc. L
17             # Originally written by Eric Wheeler, KJ7LNW
18             # All rights reserved.
19             #
20             # All tradmarks, product names, logos, and brands are property of their
21             # respective owners and no grant or license is provided thereof.
22             #
23              
24             package API::Octopart;
25             $VERSION = 1.003;
26              
27 1     1   67821 use 5.010;
  1         4  
28 1     1   6 use strict;
  1         2  
  1         35  
29 1     1   6 use warnings;
  1         2  
  1         33  
30              
31 1     1   649 use JSON;
  1         12466  
  1         5  
32 1     1   841 use LWP::UserAgent;
  1         50474  
  1         41  
33 1     1   19 use Digest::MD5 qw(md5_hex);
  1         3  
  1         73  
34              
35 1     1   707 use Data::Dumper;
  1         6894  
  1         1983  
36              
37             =head1 NAME
38              
39             API::Octopart - Simple inteface for querying part status across vendors at octopart.com.
40              
41             =head1 SYNOPSIS
42              
43             my $o = API::Octopart->new(
44             token => 'abcdefg-your-octopart-token-here',
45             cache => "$ENV{HOME}/.octopart/cache",
46             include_specs => 1,
47             ua_debug => 1,
48             query_limit => 10
49             );
50              
51             # Query part stock:
52             my %opts = (
53             currency => 'USD',
54             max_moq => 100,
55             min_qty => 10,
56             max_price => 4,
57             #mfg => 'Murata',
58             );
59             print Dumper $o->get_part_stock_detail('RC0805FR-0710KL', %opts);
60             print Dumper $o->get_part_stock_detail('GQM1555C2DR90BB01D', %opts);
61              
62             =head1 METHODS
63              
64             =over 4
65              
66             =item * $o = API::Octopart->new(%opts) - Returns new Octopart object.
67              
68             Object Options (%opt):
69              
70             =over 4
71              
72             =item * token => 'abcdefg-your-octopart-token-here',
73              
74             This is your Octopart API token. You could do something like this to read the token from a file:
75              
76             token => (sub { my $t = `cat ~/.octopart/token`; chomp $t; return $t})->(),
77              
78             =item * include_specs => 1
79              
80             If you have a PRO account then you can include product specs:
81              
82             =item * cache => "$ENV{HOME}/.octopart/cache"
83              
84             An optional (but recommended) cache directory to minimize requests to Octopart:
85              
86             =item * cache_age => 3
87              
88             The cache age (in days) before re-querying octopart. Defaults to 30 days.
89              
90             =item * query_limit: die if too many API requests are made.
91              
92             Defaults to no limit. I exhasted 20,000 queries very quickly due to a bug!
93             This might help with that, set to a reasonable limit while testing.
94              
95             =item * ua_debug => 1
96              
97             User Agent debugging. This is very verbose and provides API communication details.
98              
99             =item * json_debug => 1
100              
101             JSON response debugging. This is very verbose and dumps the Octopart response
102             in JSON.
103              
104             =back
105            
106             =cut
107              
108              
109             our %valid_opts = map { $_ => 1 } qw/token include_specs cache cache_age ua_debug query_limit json_debug/;
110             sub new
111             {
112 0     0 1   my ($class, %args) = @_;
113              
114 0           foreach my $arg (keys %args)
115             {
116 0 0         die "invalid option: $arg => $args{$arg}" if !$valid_opts{$arg};
117             }
118              
119 0           $args{api_queries} = 0;
120 0   0       $args{cache_age} //= 30;
121              
122 0 0         die "An Octopart API token is required." if (!$args{token});
123              
124 0           return bless(\%args, $class);
125             }
126              
127             =item * $o->has_stock($part, %opts) - Returns the number of items in stock
128              
129             $part: The model number of the part
130              
131             %opts: Optional filters. No defaults are specified, it will return all unless limited.
132              
133             =over 4
134              
135             =item * min_qty => - Minimum stock quantity, per seller.
136              
137             If a sellerhas fewer than min_qty parts in stock then the seller will be excluded.
138              
139             =item * max_moq => - Maximum "minimum order quantity"
140              
141             This is the max MOQ you will accept as being in
142             stock. For example, a 5000-part reel might be more
143             than you want for prototyping so set this to 10 or
144             100.
145              
146             =item * seller => - Seller's name (regular expression)
147              
148             This is a regular expression so something like
149             'Mouser|Digi-key' is valid.
150              
151             =item * mfg => - Manufacturer name (regular expression)
152              
153             Specifying the mfg name is useful if your part model
154             number is similar to those of other manufacturers.
155              
156             =item * currency => - eg, 'USD' for US dollars
157              
158             Defaults to include all currencies
159              
160             =back
161              
162             =cut
163              
164             sub has_stock
165             {
166 0     0 1   my ($self, $part, %opts) = @_;
167              
168 0           my $parts = $self->get_part_stock_detail($part, %opts);
169              
170 0           my $stock = 0;
171 0           foreach my $p (@$parts)
172             {
173 0           foreach my $s (values(%{ $p->{sellers} }))
  0            
174             {
175             $stock += $s->{stock}
176 0           }
177             }
178              
179 0           return $stock;
180             }
181              
182              
183             =item * $o->get_part_stock($part, %opts) - Returns a simple stock structure
184              
185             $part, %opts: same as has_stock().
186              
187             Returns the following structure:
188              
189             {
190             'Mouser' => {
191             'moq_price' => '0.2',
192             'moq' => 1,
193             'stock' => 24071
194             },
195             'Digi-Key' => {
196             'moq_price' => '0.2',
197             'moq' => 1,
198             'stock' => 10000
199             }
200             };
201              
202             =cut
203              
204             sub get_part_stock
205             {
206 0     0 1   my ($self, $part, %opts) = @_;
207              
208 0           my $results = $self->get_part_stock_detail($part, %opts);
209              
210 0           my %ret;
211 0           foreach my $result (@$results)
212             {
213 0           my $sellers = $result->{sellers};
214 0           foreach my $s (keys %$sellers)
215             {
216 0           $ret{$s} = $sellers->{$s};
217 0           delete $ret{$s}->{price_tier};
218             }
219             }
220              
221 0           return \%ret;
222             }
223              
224             =item * $o->get_part_stock_detail($part, %opts) - Returns a stock detail structure
225              
226             $part, %opts: same as has_stock().
227              
228             Returns a structure like this:
229              
230             [
231             {
232             'mfg' => 'Yageo',
233             'sellers' => {
234             'Digi-Key' => {
235             'moq' => 1,
236             'moq_price' => '0.1',
237             'price_tier' => {
238             '1' => '0.1',
239             '10' => '0.042',
240             '100' => '0.017',
241             '1000' => '0.00762',
242             '2500' => '0.00661',
243             '5000' => '0.00546'
244             },
245             'stock' => 4041192
246             },
247             ...
248             },
249             'specs' => {
250             'case_package' => '0805',
251             'composition' => 'Thick Film',
252             'contactplating' => 'Tin',
253             'leadfree' => 'Lead Free',
254             'length' => '2mm',
255             'numberofpins' => '2',
256             'radiationhardening' => 'No',
257             'reachsvhc' => 'No SVHC',
258             'resistance' =>
259             "10k\x{ce}\x{a9}", # <- That is an Ohm symbol
260             'rohs' => 'Compliant',
261             'tolerance' => '1%',
262             'voltagerating_dc_' => '150V',
263             'width' => '1.25mm',
264             ...
265             }
266             },
267             ...
268             ]
269              
270             =cut
271              
272             sub get_part_stock_detail
273             {
274 0     0 1   my ($self, $part, %opts) = @_;
275              
276 0           my $p = $self->query_part_detail($part);
277              
278 0           return $self->_parse_part_stock($p, %opts);
279             }
280              
281              
282             =item * $o->octo_query($q) - Queries the Octopart API
283              
284             Return the JSON response structure as a perl ARRAY/HASH given a query meeting Octopart's
285             API specification.
286              
287             =cut
288              
289             sub octo_query
290             {
291 0     0 1   my ($self, $q) = @_;
292 0           my $part = shift;
293              
294              
295 0           my ($content, $hashfile);
296              
297 0 0         if ($self->{cache})
298             {
299 0 0         system('mkdir', '-p', $self->{cache}) if (! -d $self->{cache});
300              
301 0           my $h = md5_hex($q);
302              
303 0           $hashfile = "$self->{cache}/$h.query";
304              
305             # Load the cached version if older than cache_age days.
306 0           my $age_days = (-M $hashfile);
307 0 0 0       if (-e $hashfile && $age_days < $self->{cache_age})
308             {
309 0 0         if ($self->{ua_debug})
310             {
311 0           print STDERR "Reading from cache file (age=$age_days days): $hashfile\n";
312             }
313              
314 0 0         if (open(my $in, $hashfile))
315             {
316 0           local $/;
317 0           $content = <$in>;
318 0           close($in);
319             }
320             else
321             {
322 0           die "$hashfile: $!";
323             }
324             }
325             }
326              
327 0 0         if (!$content)
328             {
329 0           my $ua = LWP::UserAgent->new( agent => 'mdf-perl/1.0', keep_alive => 3);
330              
331 0   0       $self->{api_queries} //= 0;
332              
333 0 0 0       if ($self->{query_limit} && $self->{api_queries} >= $self->{query_limit})
334             {
335 0           die "query limit exceeded: $self->{api_queries} >= $self->{query_limit}";
336             }
337              
338 0           $self->{api_queries}++;
339              
340              
341 0 0         if ($self->{ua_debug})
342             {
343             $ua->add_handler(
344             "request_send",
345             sub {
346 0     0     my $msg = shift; # HTTP::Request
347 0           print STDERR "SEND >> \n"
348             . $msg->headers->as_string . "\n"
349             . "\n";
350 0           return;
351             }
352 0           );
353              
354             $ua->add_handler(
355             "response_done",
356             sub {
357 0     0     my $msg = shift; # HTTP::Response
358 0           print STDERR "RECV << \n"
359             . $msg->headers->as_string . "\n"
360             . $msg->status_line . "\n"
361             . "\n";
362 0           return;
363             }
364 0           );
365             }
366              
367 0           my $req;
368             my $response;
369              
370 0           my $tries = 0;
371 0           while ($tries < 3)
372             {
373             $req = HTTP::Request->new('POST' => 'https://octopart.com/api/v4/endpoint',
374             HTTP::Headers->new(
375             'Host' => 'octopart.com',
376             'Content-Type' => 'application/json',
377             'Accept' => 'application/json',
378             'Accept-Encoding' => 'gzip, deflate',
379             'token' => $self->{token},
380 0           'DNT' => 1,
381             'Origin' => 'https://octopart.com',
382             ),
383             encode_json( { query => $q }));
384              
385 0           $response = $ua->request($req);
386 0 0         if (!$response->is_success)
387             {
388 0           $tries++;
389 0           print STDERR "query error, retry $tries. "
390             . $response->code . ": "
391             . $response->message . "\n";
392 0           sleep 2**$tries;
393             }
394             else
395             {
396 0           last;
397             }
398             }
399              
400 0           $content = $response->decoded_content;
401              
402 0 0         if (!$response->is_success) {
403 0           die "request: " . $req->as_string . "\n" .
404             "resp: " . $response->as_string;
405             }
406              
407             }
408              
409 0           my $j = from_json($content);
410              
411 0 0         if (!$j->{errors})
412             {
413 0 0         if ($hashfile)
414             {
415 0 0         open(my $out, ">", $hashfile) or die "$hashfile: $!";
416 0           print $out $content;
417 0           close($out);
418             }
419             }
420             else
421             {
422 0           my %errors;
423 0           foreach my $e (@{ $j->{errors} })
  0            
424             {
425 0           $errors{$e->{message}}++;
426             }
427 0           die "Octopart: " . join("\n", keys(%errors)) . "\n";
428             }
429              
430 0 0         if ($self->{json_debug})
431             {
432 0 0         if ($hashfile)
433             {
434 0           my $age_days = (-M $hashfile);
435 0           print STDERR "======= cache: $hashfile (age=$age_days days) =====\n"
436             }
437 0           print STDERR Dumper $j;
438             }
439              
440 0           return $j;
441             }
442              
443              
444             =item * $o->octo_query_count() - Return the number of API calls so far.
445             =cut
446              
447             sub octo_query_count
448             {
449 0     0 1   my $self = shift;
450 0           return $self->{api_queries};
451             }
452              
453             =item * $o->query_part_detail($part)
454              
455             Return the JSON response structure as a perl ARRAY/HASH given a part search term
456             shown as "$part". This function calls $o->octo_query() with a query from Octopart's
457             "Basic Example" so you can easily lookup a specific part number. The has_stock()
458             and get_part_stock_detail() methods use this query internally.
459              
460             =cut
461              
462             sub query_part_detail
463             {
464 0     0 1   my ($self, $part) = @_;
465              
466             # Specs require a pro account:
467 0           my $specs = '';
468 0 0         if ($self->{include_specs})
469             {
470 0           $specs = q(
471             specs {
472             units
473             value
474             display_value
475             attribute {
476             id
477             name
478             shortname
479             group
480             }
481             }
482             );
483             }
484              
485 0           return $self->octo_query( qq(
486             query {
487             search(q: "$part", limit: 3) {
488             results {
489             part {
490             manufacturer {
491             name
492             }
493             mpn
494             $specs
495             # Brokers are non-authorized dealers. See: https://octopart.com/authorized
496             sellers(include_brokers: false) {
497             company {
498             name
499             }
500             offers {
501             click_url
502             inventory_level
503             prices {
504             price
505             currency
506             quantity
507             }
508             }
509             }
510             }
511             }
512             }
513             }
514             ));
515             }
516              
517             our %_valid_filter_opts = ( map { $_ => 1 } (qw/currency max_moq min_qty max_price mfg seller/) );
518             sub _parse_part_stock
519             {
520 0     0     my ($self, $resp, %opts) = @_;
521              
522 0           foreach my $o (keys %opts)
523             {
524 0 0         die "invalid filter option: '$o'" if (!$_valid_filter_opts{$o});
525             }
526              
527 0           my @results;
528 0           foreach my $r (@{ $resp->{data}{search}{results} })
  0            
529             {
530 0           $r = $r->{part};
531 0           my %part;
532              
533 0           $part{mfg} = $r->{manufacturer}{name};
534              
535 0 0         if (defined $r->{specs})
536             {
537             $part{specs} = {
538             # Try to map first by shortname, then by unit, then by value if
539             # the former are undefined:
540             map {
541             defined($_->{attribute}{shortname})
542             ? ($_->{attribute}{shortname} => $_->{value} . "$_->{units}")
543             : (
544             $_->{units}
545             ? ($_->{units} => $_->{value})
546 0 0         : ($_->{value} => 'true')
    0          
547             )
548 0           } @{ $r->{specs} }
  0            
549             },
550             }
551              
552             # Seller stock and MOQ pricing:
553 0           my %ss;
554 0           foreach my $s (@{ $r->{sellers} })
  0            
555             {
556 0           foreach my $o (@{ $s->{offers} })
  0            
557             {
558 0           $ss{$s->{company}{name}}{stock} = $o->{inventory_level};
559 0           foreach my $p (@{ $o->{prices} })
  0            
560             {
561 0 0 0       next if (defined($opts{currency}) && $p->{currency} ne $opts{currency});
562              
563 0           my $moq = $p->{quantity};
564 0           my $price = $p->{price};
565              
566 0           $ss{$s->{company}{name}}{price_tier}{$p->{quantity}} = $price;
567              
568             # Find the minimum order quantity and the MOQ price:
569 0 0 0       if (!defined($ss{$s->{company}{name}}{moq}) ||
570             $ss{$s->{company}{name}}{moq} > $moq)
571             {
572 0           $ss{$s->{company}{name}}{moq} = $moq;
573 0           $ss{$s->{company}{name}}{moq_price} = $price;
574             }
575             }
576             }
577             }
578              
579 0           $part{sellers} = \%ss;
580              
581 0           push @results, \%part;
582             }
583              
584             # Delete sellers that do not meet the constraints and
585             # add matching results to @ret:
586 0           my @ret;
587 0           foreach my $r (@results)
588             {
589 0 0 0       next if (defined($opts{mfg}) && $r->{mfg} !~ /$opts{mfg}/i);
590              
591 0           foreach my $s (keys %{ $r->{sellers} })
  0            
592             {
593 0 0 0       if (!defined($r->{sellers}{$s}{price_tier})
      0        
      0        
      0        
      0        
      0        
      0        
      0        
594             || (defined($opts{min_qty}) && $r->{sellers}{$s}{stock} < $opts{min_qty})
595             || (defined($opts{max_price}) && $r->{sellers}{$s}{moq_price} > $opts{max_price})
596             || (defined($opts{max_moq}) && $r->{sellers}{$s}{moq} > $opts{max_moq}
597             || defined($opts{seller}) && $s !~ /$opts{seller}/i)
598             )
599             {
600 0           delete $r->{sellers}{$s};
601             }
602             }
603              
604 0           push @ret, $r;
605             }
606              
607 0           return \@ret;
608             }
609              
610             =back
611              
612             =head1 SEE ALSO
613              
614             L, L
615              
616             =head1 ATTRIBUTION
617              
618             Octopart is a registered trademark and brand of Octopart, Inc. All tradmarks,
619             product names, logos, and brands are property of their respective owners and no
620             grant or license is provided thereof.
621              
622             The copyright below applies to this software module; the copyright holder is
623             unaffiliated with Octopart, Inc.
624              
625             =head1 AUTHOR
626              
627             Originally written at eWheeler, Inc. dba Linux Global by Eric Wheeler
628             to facilitate optimization of RF matching components, but only for
629             components that are available for purchase at electronic component
630             vendors (of course!) L
631              
632             =head1 COPYRIGHT
633              
634             Copyright (C) 2022 eWheeler, Inc. dba Linux Global
635             L
636              
637             This module is free software: you can redistribute it and/or modify it under
638             the terms of the GNU General Public License as published by the Free Software
639             Foundation, either version 3 of the License, or (at your option) any later
640             version.
641              
642             This module is distributed in the hope that it will be useful, but WITHOUT ANY
643             WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
644             PARTICULAR PURPOSE. See the GNU General Public License for more details.
645              
646             You should have received a copy of the GNU General Public License along with
647             this module. If not, see .
648              
649             =cut
650              
651             1;