Upload files to "/"
This commit is contained in:
+449
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env perl
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use utf8;
|
||||
use feature qw(say);
|
||||
|
||||
use Getopt::Long qw(GetOptions);
|
||||
use HTTP::Cookies;
|
||||
use JSON::PP qw(decode_json);
|
||||
use LWP::UserAgent;
|
||||
use Scalar::Util qw(looks_like_number);
|
||||
use Time::HiRes qw(sleep);
|
||||
|
||||
binmode STDOUT, ':encoding(UTF-8)';
|
||||
binmode STDERR, ':encoding(UTF-8)';
|
||||
|
||||
my %stores = (
|
||||
'Australia' => 'https://www.kobo.com/au/en/ebook/',
|
||||
'Austria' => 'https://www.kobo.com/at/de/ebook/',
|
||||
'Belgium (Dutch)' => 'https://www.kobo.com/be/nl/ebook/',
|
||||
'Belgium (French)' => 'https://www.kobo.com/be/fr/ebook/',
|
||||
'Brazil' => 'https://www.kobo.com/br/pt/ebook/',
|
||||
'Canada (English)' => 'https://www.kobo.com/ca/en/ebook/',
|
||||
'Canada (French)' => 'https://www.kobo.com/ca/fr/ebook/',
|
||||
'Cyprus' => 'https://www.kobo.com/cy/en/ebook/',
|
||||
'Czech Republic' => 'https://www.kobo.com/cz/cs/ebook/',
|
||||
'Denmark' => 'https://www.kobo.com/dk/da/ebook/',
|
||||
'Eire' => 'https://www.kobo.com/ie/en/ebook/',
|
||||
'Estonia' => 'https://www.kobo.com/ee/en/ebook/',
|
||||
'Finland' => 'https://www.kobo.com/fi/fi/ebook/',
|
||||
'France' => 'https://www.kobo.com/fr/fr/ebook/',
|
||||
'Germany' => 'https://www.kobo.com/de/de/ebook/',
|
||||
'Greece' => 'https://www.kobo.com/gr/en/ebook/',
|
||||
'Hong Kong (Chinese)' => 'https://www.kobo.com/hk/zh/ebook/',
|
||||
'Hong Kong (English)' => 'https://www.kobo.com/hk/en/ebook/',
|
||||
'India' => 'https://www.kobo.com/in/en/ebook/',
|
||||
'Italy' => 'https://www.kobo.com/it/it/ebook/',
|
||||
'Japan' => 'https://www.kobo.com/jp/ja/ebook/',
|
||||
'Lithuania' => 'https://www.kobo.com/lt/en/ebook/',
|
||||
'Luxembourg' => 'https://www.kobo.com/lu/fr/ebook/',
|
||||
'Malaysia' => 'https://www.kobo.com/my/en/ebook/',
|
||||
'Malta' => 'https://www.kobo.com/mt/en/ebook/',
|
||||
'Mexico' => 'https://www.kobo.com/mx/es/ebook/',
|
||||
'Netherlands' => 'https://www.kobo.com/nl/nl/ebook/',
|
||||
'New Zealand' => 'https://www.kobo.com/nz/en/ebook/',
|
||||
'Norway' => 'https://www.kobo.com/no/nb/ebook/',
|
||||
'Philippines' => 'https://www.kobo.com/ph/en/ebook/',
|
||||
'Poland' => 'https://www.kobo.com/pl/pl/ebook/',
|
||||
'Portugal' => 'https://www.kobo.com/pt/pt/ebook/',
|
||||
'Romania' => 'https://www.kobo.com/ro/ro/ebook/',
|
||||
'Singapore' => 'https://www.kobo.com/sg/en/ebook/',
|
||||
'Slovak Republic' => 'https://www.kobo.com/sk/en/ebook/',
|
||||
'Slovenia' => 'https://www.kobo.com/si/en/ebook/',
|
||||
'South Africa' => 'https://www.kobo.com/za/en/ebook/',
|
||||
'Spain' => 'https://www.kobo.com/es/es/ebook/',
|
||||
'Sweden' => 'https://www.kobo.com/se/sv/ebook/',
|
||||
'Switzerland (French)' => 'https://www.kobo.com/ch/fr/ebook/',
|
||||
'Taiwan' => 'https://www.kobo.com/tw/zh/ebook/',
|
||||
'Thailand' => 'https://www.kobo.com/th/en/ebook/',
|
||||
'Turkey' => 'https://www.kobo.com/tr/tr/ebook/',
|
||||
'UK' => 'https://www.kobo.com/gb/en/ebook/',
|
||||
'USA' => 'https://www.kobo.com/us/en/ebook/',
|
||||
'World-wide' => 'https://www.kobo.com/ww/en/ebook/',
|
||||
);
|
||||
|
||||
my $delay = 1.5;
|
||||
my $retries = 3;
|
||||
my $timeout = 30;
|
||||
my $show_failed = 0;
|
||||
my $help = 0;
|
||||
|
||||
GetOptions(
|
||||
'delay=f' => \$delay,
|
||||
'retries=i' => \$retries,
|
||||
'timeout=i' => \$timeout,
|
||||
'show-failed!' => \$show_failed,
|
||||
'help|h' => \$help,
|
||||
) or usage(2);
|
||||
|
||||
usage(0) if $help;
|
||||
|
||||
my $input = shift @ARGV;
|
||||
usage(2) unless defined $input && length $input;
|
||||
|
||||
$delay = 0 if $delay < 0;
|
||||
$retries = 0 if $retries < 0;
|
||||
$timeout = 5 if $timeout < 5;
|
||||
|
||||
my $book = clean_book_slug($input);
|
||||
die "Could not determine the Kobo book slug from: $input\n" unless length $book;
|
||||
die "Invalid Kobo book slug: $book\n" unless $book =~ /\A[[:alnum:]_%+.,'()!-]+\z/;
|
||||
|
||||
my $jar = HTTP::Cookies->new;
|
||||
my $ua = LWP::UserAgent->new(
|
||||
timeout => $timeout,
|
||||
max_redirect => 8,
|
||||
cookie_jar => $jar,
|
||||
protocols_allowed => [qw(http https)],
|
||||
ssl_opts => { verify_hostname => 1 },
|
||||
);
|
||||
|
||||
# A normal desktop browser identity. The old script used libwww-perl's default
|
||||
# identity and launched every request at once, which commonly triggers 403s.
|
||||
$ua->agent(
|
||||
'Mozilla/5.0 (X11; Linux x86_64) '
|
||||
. 'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
. 'Chrome/124.0 Safari/537.36'
|
||||
);
|
||||
$ua->default_header('Accept' =>
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8');
|
||||
$ua->default_header('Accept-Language' => 'en-GB,en;q=0.9');
|
||||
$ua->default_header('DNT' => '1');
|
||||
$ua->default_header('Upgrade-Insecure-Requests' => '1');
|
||||
|
||||
my @results;
|
||||
my @failures;
|
||||
my $index = 0;
|
||||
my $total = scalar keys %stores;
|
||||
|
||||
for my $country (sort keys %stores) {
|
||||
++$index;
|
||||
my $url = $stores{$country} . $book;
|
||||
print STDERR sprintf("[%02d/%02d] %-22s ", $index, $total, $country);
|
||||
|
||||
my ($response, $attempts) = fetch_with_retries($ua, $url, $retries);
|
||||
|
||||
if (!$response->is_success) {
|
||||
my $status = $response->code . ' ' . $response->message;
|
||||
say STDERR "failed ($status)";
|
||||
push @failures, [$country, $status, $url];
|
||||
polite_pause($delay);
|
||||
next;
|
||||
}
|
||||
|
||||
my $html = $response->decoded_content(charset => 'none');
|
||||
my ($price, $currency) = extract_price($html);
|
||||
|
||||
if (!defined $price || !defined $currency) {
|
||||
say STDERR 'no price found';
|
||||
push @failures, [$country, 'page loaded but price was not found', $url];
|
||||
polite_pause($delay);
|
||||
next;
|
||||
}
|
||||
|
||||
say STDERR sprintf('ok (%s %.2f)', $currency, $price);
|
||||
push @results, {
|
||||
country => $country,
|
||||
currency => $currency,
|
||||
price => $price + 0,
|
||||
url => $url,
|
||||
};
|
||||
|
||||
polite_pause($delay);
|
||||
}
|
||||
|
||||
die "\nNo prices were retrieved. Try a larger delay, for example --delay 3.\n"
|
||||
unless @results;
|
||||
|
||||
my %rates_to_eur = fetch_rates_to_eur($ua, [map { $_->{currency} } @results]);
|
||||
|
||||
for my $row (@results) {
|
||||
my $currency = $row->{currency};
|
||||
$row->{eur} = $row->{price} * $rates_to_eur{$currency}
|
||||
if exists $rates_to_eur{$currency};
|
||||
}
|
||||
|
||||
my $country_width = 0;
|
||||
for my $row (@results) {
|
||||
my $length = length $row->{country};
|
||||
$country_width = $length if $length > $country_width;
|
||||
}
|
||||
|
||||
say '';
|
||||
for my $row (sort_results(@results)) {
|
||||
if (defined $row->{eur}) {
|
||||
printf "%-*s €%7.2f %3s %10.2f\n",
|
||||
$country_width, $row->{country}, $row->{eur},
|
||||
$row->{currency}, $row->{price};
|
||||
} else {
|
||||
printf "%-*s %-9s %3s %10.2f\n",
|
||||
$country_width, $row->{country}, '(no FX)',
|
||||
$row->{currency}, $row->{price};
|
||||
}
|
||||
}
|
||||
|
||||
my @converted = grep { defined $_->{eur} } @results;
|
||||
if (@converted) {
|
||||
my $minimum = (sort { $a->{eur} <=> $b->{eur} } @converted)[0]{eur};
|
||||
my @cheapest = grep { abs($_->{eur} - $minimum) < 0.005 } @converted;
|
||||
|
||||
printf "\nCheapest price: €%.2f\n", $minimum;
|
||||
if (@cheapest == 1) {
|
||||
printf "Country: %s\n", $cheapest[0]{country};
|
||||
printf "Original price: %s %.2f\n",
|
||||
$cheapest[0]{currency}, $cheapest[0]{price};
|
||||
printf "URL: %s\n", $cheapest[0]{url};
|
||||
} else {
|
||||
say "Countries tied for cheapest price:";
|
||||
for my $row (@cheapest) {
|
||||
printf " %s — %s %.2f — %s\n",
|
||||
$row->{country}, $row->{currency}, $row->{price}, $row->{url};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
say "\nNo exchange rates were available, so no converted cheapest price was calculated.";
|
||||
}
|
||||
|
||||
printf STDERR "\nRetrieved %d of %d stores; %d failed.\n",
|
||||
scalar(@results), $total, scalar(@failures);
|
||||
|
||||
if ($show_failed && @failures) {
|
||||
say STDERR "\nFailed stores:";
|
||||
for my $failure (@failures) {
|
||||
printf STDERR " %-22s %s\n", $failure->[0], $failure->[1];
|
||||
}
|
||||
}
|
||||
|
||||
exit 0;
|
||||
|
||||
sub usage {
|
||||
my ($exit_code) = @_;
|
||||
print <<'USAGE';
|
||||
Usage:
|
||||
kobocompare-updated [options] KOBO_URL_OR_SLUG
|
||||
|
||||
Options:
|
||||
--delay SECONDS Pause between countries (default: 1.5)
|
||||
--retries NUMBER Retries for 403, 429 and server errors (default: 3)
|
||||
--timeout SECONDS Per-request timeout (default: 30)
|
||||
--show-failed Print a final list of failed stores
|
||||
--help Show this help
|
||||
|
||||
Examples:
|
||||
./kobocompare-updated wings-the-story-of-a-band-on-the-run
|
||||
./kobocompare-updated --delay 3 'https://www.kobo.com/es/es/ebook/wings-the-story-of-a-band-on-the-run'
|
||||
USAGE
|
||||
exit $exit_code;
|
||||
}
|
||||
|
||||
sub clean_book_slug {
|
||||
my ($value) = @_;
|
||||
$value =~ s/^\s+|\s+$//g;
|
||||
$value =~ s/[?#].*\z//; # Drop Kobo search/tracking parameters.
|
||||
$value =~ s{/+\z}{};
|
||||
$value =~ s{.*/}{};
|
||||
return $value;
|
||||
}
|
||||
|
||||
sub polite_pause {
|
||||
my ($seconds) = @_;
|
||||
return if $seconds <= 0;
|
||||
sleep($seconds + rand(0.45));
|
||||
}
|
||||
|
||||
sub fetch_with_retries {
|
||||
my ($agent, $url, $max_retries) = @_;
|
||||
my $response;
|
||||
|
||||
for my $attempt (0 .. $max_retries) {
|
||||
$response = $agent->get(
|
||||
$url,
|
||||
'Referer' => 'https://www.kobo.com/',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'Pragma' => 'no-cache',
|
||||
'Sec-Fetch-Dest' => 'document',
|
||||
'Sec-Fetch-Mode' => 'navigate',
|
||||
'Sec-Fetch-Site' => 'same-origin',
|
||||
'Sec-Fetch-User' => '?1',
|
||||
);
|
||||
|
||||
return ($response, $attempt + 1) if $response->is_success;
|
||||
|
||||
my $code = $response->code || 0;
|
||||
last unless $code == 403 || $code == 429 || $code >= 500 || $code == 0;
|
||||
last if $attempt == $max_retries;
|
||||
|
||||
my $retry_after = $response->header('Retry-After');
|
||||
my $backoff = (defined $retry_after && $retry_after =~ /^\d+(?:\.\d+)?$/)
|
||||
? $retry_after
|
||||
: (2 ** $attempt) + 1 + rand(1.0);
|
||||
sleep($backoff);
|
||||
}
|
||||
|
||||
return ($response, $max_retries + 1);
|
||||
}
|
||||
|
||||
sub extract_price {
|
||||
my ($html) = @_;
|
||||
|
||||
my $meta_price = extract_meta($html, 'og:price');
|
||||
my $meta_currency = extract_meta($html, 'og:currency_code');
|
||||
if (defined $meta_price && defined $meta_currency) {
|
||||
my $price = normalise_price($meta_price);
|
||||
return ($price, uc $meta_currency) if defined $price;
|
||||
}
|
||||
|
||||
while ($html =~ m{<script\b[^>]*type\s*=\s*(["'])application/ld\+json\1[^>]*>(.*?)</script>}gis) {
|
||||
my $json_text = $2;
|
||||
$json_text =~ s/^\s+|\s+$//g;
|
||||
next unless length $json_text;
|
||||
|
||||
my $data = eval { decode_json($json_text) };
|
||||
next if $@ || !defined $data;
|
||||
|
||||
my ($price, $currency) = find_price_in_json($data);
|
||||
return ($price, $currency) if defined $price && defined $currency;
|
||||
}
|
||||
|
||||
# Last-resort fallback for embedded JSON that is not valid JSON-LD.
|
||||
if ($html =~ /["']priceCurrency["']\s*:\s*["']([A-Z]{3})["']/i) {
|
||||
my $currency = uc $1;
|
||||
if ($html =~ /["'](?:salePrice|price)["']\s*:\s*["']?([0-9][0-9.,]*)/i) {
|
||||
my $price = normalise_price($1);
|
||||
return ($price, $currency) if defined $price;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
sub extract_meta {
|
||||
my ($html, $wanted) = @_;
|
||||
|
||||
while ($html =~ m{<meta\b[^>]*>}gi) {
|
||||
my $tag = $&;
|
||||
my $property = attribute_value($tag, 'property');
|
||||
$property = attribute_value($tag, 'name') unless defined $property;
|
||||
next unless defined $property && lc($property) eq lc($wanted);
|
||||
|
||||
my $content = attribute_value($tag, 'content');
|
||||
return $content if defined $content;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
sub attribute_value {
|
||||
my ($tag, $name) = @_;
|
||||
return $2 if $tag =~ /\b\Q$name\E\s*=\s*(["'])(.*?)\1/is;
|
||||
return $1 if $tag =~ /\b\Q$name\E\s*=\s*([^\s>]+)/is;
|
||||
return;
|
||||
}
|
||||
|
||||
sub find_price_in_json {
|
||||
my ($node) = @_;
|
||||
|
||||
if (ref $node eq 'ARRAY') {
|
||||
for my $item (@$node) {
|
||||
my ($price, $currency) = find_price_in_json($item);
|
||||
return ($price, $currency) if defined $price && defined $currency;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return unless ref $node eq 'HASH';
|
||||
|
||||
my $raw_price = exists $node->{price} ? $node->{price}
|
||||
: exists $node->{lowPrice} ? $node->{lowPrice}
|
||||
: undef;
|
||||
my $currency = $node->{priceCurrency};
|
||||
|
||||
if (defined $raw_price && defined $currency) {
|
||||
my $price = normalise_price($raw_price);
|
||||
return ($price, uc $currency) if defined $price;
|
||||
}
|
||||
|
||||
for my $key (keys %$node) {
|
||||
my ($price, $found_currency) = find_price_in_json($node->{$key});
|
||||
return ($price, $found_currency)
|
||||
if defined $price && defined $found_currency;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
sub normalise_price {
|
||||
my ($raw) = @_;
|
||||
return unless defined $raw;
|
||||
|
||||
my $value = "$raw";
|
||||
$value =~ s/[\s\x{00A0}]//g;
|
||||
$value =~ s/[^0-9,.-]//g;
|
||||
return unless $value =~ /\d/;
|
||||
|
||||
if ($value =~ /,/ && $value =~ /\./) {
|
||||
my $last_comma = rindex($value, ',');
|
||||
my $last_dot = rindex($value, '.');
|
||||
if ($last_comma > $last_dot) {
|
||||
$value =~ s/\.//g;
|
||||
$value =~ tr/,/./;
|
||||
} else {
|
||||
$value =~ s/,//g;
|
||||
}
|
||||
} elsif ($value =~ /,/) {
|
||||
$value =~ tr/,/./;
|
||||
}
|
||||
|
||||
return unless looks_like_number($value);
|
||||
return $value + 0;
|
||||
}
|
||||
|
||||
sub fetch_rates_to_eur {
|
||||
my ($agent, $currencies) = @_;
|
||||
|
||||
my %wanted = map { uc($_) => 1 } grep { defined && /^[A-Za-z]{3}$/ } @$currencies;
|
||||
my %to_eur = (EUR => 1);
|
||||
delete $wanted{EUR};
|
||||
return %to_eur unless keys %wanted;
|
||||
|
||||
my $quotes = join ',', sort keys %wanted;
|
||||
my $url = "https://api.frankfurter.dev/v2/rates?base=EUR"es=$quotes";
|
||||
my $response = $agent->get($url, 'Accept' => 'application/json');
|
||||
|
||||
if (!$response->is_success) {
|
||||
warn "Could not retrieve current exchange rates: "
|
||||
. $response->status_line . "\n";
|
||||
return %to_eur;
|
||||
}
|
||||
|
||||
my $data = eval { decode_json($response->decoded_content) };
|
||||
if ($@ || ref $data ne 'ARRAY') {
|
||||
warn "Could not parse the exchange-rate response.\n";
|
||||
return %to_eur;
|
||||
}
|
||||
|
||||
# API rows are expressed as: 1 EUR = RATE quote-currency units.
|
||||
# Therefore 1 quote-currency unit = 1 / RATE EUR.
|
||||
for my $row (@$data) {
|
||||
next unless ref $row eq 'HASH';
|
||||
my $quote = uc($row->{quote} // '');
|
||||
my $rate = $row->{rate};
|
||||
next unless $quote =~ /^[A-Z]{3}$/;
|
||||
next unless defined $rate && looks_like_number($rate) && $rate > 0;
|
||||
$to_eur{$quote} = 1 / $rate;
|
||||
}
|
||||
|
||||
return %to_eur;
|
||||
}
|
||||
|
||||
sub sort_results {
|
||||
my @rows = @_;
|
||||
return sort {
|
||||
defined($a->{eur}) && defined($b->{eur}) ? $a->{eur} <=> $b->{eur}
|
||||
: defined($a->{eur}) ? -1
|
||||
: defined($b->{eur}) ? 1
|
||||
: $a->{country} cmp $b->{country}
|
||||
} @rows;
|
||||
}
|
||||
Reference in New Issue
Block a user