WWW-AzimuthAero/lib/WWW/AzimuthAero.pm
package WWW::AzimuthAero;
$WWW::AzimuthAero::VERSION = '0.4';
# ABSTRACT: Parser for https://azimuth.aero/
use strict;
use warnings;
use utf8;
use feature 'say';
use Carp;
use List::Util qw/min/;
use Mojo::DOM;
use Mojo::UserAgent;
use WWW::AzimuthAero::Utils qw(:all);
use WWW::AzimuthAero::RouteMap;
use WWW::AzimuthAero::Flight;
use Data::Dumper;
use Data::Dumper::AutoEncode;
sub new {
my ( $self, %params ) = @_;
$params{ua_obj} = Mojo::UserAgent->new() unless defined $params{ua_obj};
$params{ua_str} = 'TAIS' unless defined $params{ua_str};
$params{ua_obj}->transactor->name( $params{ua_str} );
# get cookies, otheerwise will be 403 error
my $req = $params{ua_obj}->get('https://booking.azimuth.aero/')
; # Mojo::Transaction::HTTP
# get route map
my $route_map = $req->res->dom->find('script')
->grep( sub { $_->text =~ /\/\/ Route map/ } )->first->text;
$route_map = extract_js_glob_var( $route_map, 'data.routes' );
bless {
ua => $params{ua_obj},
route_map => WWW::AzimuthAero::RouteMap->new($route_map),
last_transaction => $req
}, $self;
}
sub last_transaction {
return shift->{last_transaction};
}
sub get {
my ( $self, %params ) = @_;
confess "from is not defined" unless defined $params{from};
confess "to is not defined" unless defined $params{to};
confess "date is not defined" unless defined $params{date};
$params{adults} = 1 unless defined $params{adults};
confess "adults > 9"
if ( defined $params{adults} && ( $params{adults} > 9 ) );
my $url =
'https://booking.azimuth.aero/!/'
. $params{from} . '/'
. $params{to} . '/'
. $params{date} . '/'
. $params{adults} . '-0-0/';
my $req = $self->{ua}->get($url); # Mojo::Transaction::HTTP
$self->{last_transaction} = $req;
my $target_dom = $req->res->dom->at('div.sf-day__content');
if ( ref($target_dom) eq 'Mojo::DOM' ) {
my @res;
for my $flight_dom ( $target_dom->find('div.sf-flight-block')->each ) {
my $flight = WWW::AzimuthAero::Flight->new(
from_city => $params{from},
to_city => $params{to},
flight_date => $params{date}
);
my $stops_css =
$flight_dom->at('div.ts-flight__duration div.ts-flight__stops');
if ($stops_css) {
if ( $stops_css->text =~ /пересадк/ ) {
$flight->has_stops(1);
warn "Dur: "
. fix_html_string $flight_dom->at(
'div.ts-flight__duration div.ts-flight__dur')->text;
$flight->trip_duration(
fix_html_string $flight_dom->at(
'div.ts-flight__duration div.ts-flight__dur')->text
);
}
}
$flight->arrival_time(
fix_html_string $flight_dom->at(
'div.ts-flight__arrival div.ts-flight__time')->text );
$flight->departure_time(
fix_html_string $flight_dom->at(
'div.ts-flight__deparure div.ts-flight__time')->text
);
$flight->flight_num(
fix_html_string $flight_dom->at('div.ts-flight__num')->text );
my $fares = {};
for my $class ( $self->possible_fares() ) {
my $tdom = $flight_dom->at(
'div.td_fare.' . $class . ' span.sf-price__value.rub' );
if ($tdom) {
my $f = $tdom->text;
$f =~ s/\D+//g; # remove all non-digits
$fares->{$class} = $f;
}
}
$fares->{lowest} = min values %$fares;
$flight->fares($fares);
push @res, $flight;
}
return \@res;
}
# return die
elsif ( $req->res->is_success ) {
return { error => 'TAIS fare search error' }
if ( $req->res->body =~ /travelshop-support\@tais.ru/ );
return { error => 'No flights found' };
}
else {
return { error => $req->res->code };
}
}
sub route_map {
return shift->{route_map};
}
# ENDPOINT LIKE: https://azimuth.aero/ru/flights?from=ROV&to=ASF
sub get_schedule_dates {
my ( $self, %params ) = @_;
confess "from city is not defined" unless defined $params{from};
confess "to city is not defined" unless defined $params{to};
my $url =
'https://azimuth.aero/ru/flights?from='
. $params{from} . '&to='
. $params{to};
my $get_req = $self->{ua}->get($url);
$self->{last_transaction} = $get_req;
my $res = $get_req->res->json;
if ( ref( $res->{available_to} ) eq 'ARRAY' ) {
my @dates;
for my $interval ( @{ $res->{available_to} } ) {
# warn "Interval : ".Dumper $interval;
# $interval->{min}
# $interval->{max}
# $interval->{days}
push @dates,
get_dates_from_dows(%$interval)
; # to-do : check min_date and max_date
}
# sort dates again for keeping sequence of interval
return sort_dates(@dates);
}
else {
carp "No schedule available between "
. $params{from} . " and "
. $params{to}
. ", url : $url, most likely is transit route"
if $params{v};
return () if $params{only_direct_flights};
return get_dates_from_range( min => $params{min}, max => $params{max} )
; # to-do : check max_date
}
}
sub possible_fares {
return qw/legkiy vygodnyy optimalnyy svobodnyy komfort/;
}
sub find_no_schedule {
my ( $self, %params ) = @_;
my $iata_map = $self->route_map->route_map_iata;
my %res = ();
my $i;
while ( my ( $from, $cities ) = each(%$iata_map) ) {
for my $to (@$cities) {
my $url =
'https://azimuth.aero/ru/flights?from=' . $from . '&to=' . $to;
my $res = $self->{ua}->get($url)->res->json;
say $i;
$res{$from} = $to if ( ref( $res->{available_to} ) ne 'ARRAY' );
$i++;
}
}
return %res;
}
sub get_fares_schedule {
my ( $self, %params ) = @_;
my @available_dates = $self->get_schedule_dates(%params);
# filter schedule dates from now to max
@available_dates = filter_dates(
\@available_dates,
max => $params{max},
min => $params{min}
);
# warn "Dates : ".Dumper @available_dates;
my @fares;
# say scalar @available_dates . ' days will be checked' if $params{progress_bar};
say $params{from} . '->'
. $params{to} . ' : '
. $params{min} . '->'
. $params{max}
if $params{progress_bar};
for my $i ( 0 .. $#available_dates ) {
my $date_str = $available_dates[$i];
say '' . ( $i + 1 ) . '/' . scalar @available_dates
if $params{progress_bar};
my $flights = $self->get(
from => $params{from},
to => $params{to},
date => $date_str
);
if ( ref($flights) eq 'ARRAY' ) { # not error
if ( $params{print_immediately} ) {
say $_->as_string( order => [qw/flight_date/] ) for (@$flights);
}
push @fares, @$flights;
}
}
if ( $params{print_table} ) {
say $_->as_string( order => [qw/flight_date/], separator => "\t" )
for (@fares);
}
# say scalar @fares . ' days of direct flights with available tickets found' if $params{progress_bar};
return sort { $a->flight_date cmp $b->flight_date } @fares;
}
sub get_lowest_fares {
my ( $self, %params ) = @_;
my @fares = $self->get_fares_schedule(%params);
if ( $params{check_neighbors} ) {
my @from_neighbors =
$self->route_map->get_neighbor_airports_iata( $params{from} );
my @to_neighbors =
$self->route_map->get_neighbor_airports_iata( $params{to} );
# say 'Will check neighbor airports also : '.join("\t",@from_neighbors,@to_neighbors) if ($params{progress_bar});
# say '== neighbours : ' . join( " ", @from_neighbors, @to_neighbors );
for my $from2 (@from_neighbors) {
# warn "from 2 : ".$from2;
push @fares,
$self->get_fares_schedule(
from => $from2,
to => $params{to},
min => $params{min},
max => $params{max},
progress_bar => $params{progress_bar},
);
}
for my $to2 (@to_neighbors) {
push @fares,
$self->get_fares_schedule(
from => $params{from},
to => $to2,
min => $params{min},
max => $params{max},
progress_bar => $params{progress_bar},
);
}
}
if ( $params{find_transits} ) {
my $routes = $self->route_map->transfer_routes(
from => $params{from},
to => $params{to}
);
# [ [ 'ROV', 'MOW', 'LED' ], [ 'ROV', 'KRR', 'LED' ] ]
# to
# [ { from => 'ROV', via => 'MOW', to => 'LED' }, ... ]
my @flights = iata_pairwise($routes);
# process transit flights
# for my $x (@flights) {
# push @fares, $self->get_fares_schedule_transit(
# from => $x->{from},
# via => $x->{via},
# via => $x->{to},
# min => $params{min},
# max => $params{max},
# progress_bar => $params{progress_bar},
# max_delay_days => $params{max_delay_days}
# );
# }
}
return sort { $a->fares->{lowest} <=> $b->fares->{lowest} } @fares;
}
1;
__END__
=pod
=encoding UTF-8
=head1 NAME
WWW::AzimuthAero - Parser for https://azimuth.aero/
=head1 VERSION
version 0.4
=head1 SYNOPSIS
use WWW::AzimuthAero;
my $az = WWW::AzimuthAero->new();
$az->get_schedule_dates( from => 'ROV', to => 'KLF' );
$az->get( from => 'ROV', to => 'LED', date => '14.06.2019' );
# to debug
# $az->last_transaction->req->url->to_abs
# $az->last_transaction->res->code
$az->get_lowest_fares( from => 'ROV', to => 'LED', max => '14.08.2019' );
$az->print_flights(
$az->get_lowest_fares(
from => 'ROV',
to => 'LED',
max => '14.08.2019',
progress_bar => 1
)
)
Outside:
perl -Ilib -MData::Dumper -MWWW::AzimuthAero -e 'my $x = WWW::AzimuthAero->new->route_map->transfer_routes; warn Dumper $x;'
=head1 DESCRIPTION
This module provides a parser for L<https://azimuth.aero/>
Module can be useful for creating price monitoring services and flexible travel planners
Module uses L<Mojo::UserAgent> as user agent and L<Mojo::DOM> + L<JavaScript::V8> as DOM parser
=head1 INSTALLATION NOTES
Since this module depends on L<JavaScript::V8> you need to install libv8-3.14.5 libv8-3.14-dev on your system
=head1 FOR DEVELOPERS
How to generate DOM samples for unit tests after git clone:
$ perl -Ilib -e "use WWW::AzimuthAero::Mock; WWW::AzimuthAero::Mock->generate()"
See L<WWW::AzimuthAero::Mock> and L<Mojo::UserAgent::Mockable> for more details
=head2 API endpoints
urls that modules uses:
L<https://booking.azimuth.aero/> (for fetching route map and initialize session)
L<https://azimuth.aero/ru/flights?from=ROV&to=LED> (for fetching schedule)
L<https://booking.azimuth.aero/!/ROV/LED/19.06.2019/1-0-0/> (for fetching prices)
=head1 TO-DO
+ implement find_transits at L<WWW::AzimuthAero/get_lowest_fares>
+ implement check_tickets at L<WWW::AzimuthAero/get>
=head1 MAIN METHODS
=head2 new
use WWW::AzimuthAero;
my $az = Azimuth->new();
# or my $az = Azimuth->new(ua_str => 'yandex-travel');
=head2 last_transaction
Return last L<Mojo::Transaction::HTTP> object. Very useful for debug if any problems
$az->last_transaction->req->url->to_abs
$az->last_transaction->res->code
$az->last_transaction->res->body
=head2 get
Checks for flight between two cities on selected date.
Cities are specified as IATA codes.
$az->get( from => 'ROV', to => 'LED', date => '04.06.2019' );
$az->get( from => 'ROV', to => 'LED', date => '04.06.2019', check_tickets => 1 );
You can also set adults params, from 1 to 9, and check_tickets (auto check with adults from 9 to 1)
Those params may be convenient for monitoring tickets availability.
WARN: check_tickets is not implemented yet
Return ARRAYref with L<WWW::AzimuthAero::Flight> objects or hash with error like
{ 'error' => 'No flights found' }
There could be two flights between same cities in same day
so for unification this method always returns ARRAYref even if array contains one item
=head2 route_map
Return L<WWW::AzimuthAero::RouteMap> object
perl -Ilib -MWWW::AzimuthAero -MData::Dumper::AutoEncode -e 'my $x = WWW::AzimuthAero->new->route_map; warn eDumper $x;'
=head2 get_schedule_dates
Get schedule by requested direction
$az->get_schedule_dates( from => 'ROV', to => 'KLF' );
$az->get_schedule_dates( from => 'ROV', to => 'PKV', max => '20.06.2019' ); # will start search from today
$az->get_schedule_dates( from => 'ROV', to => 'PKV', min => '16.06.2019', max => '20.06.2019' );
Return list of available dates in C<'%d.%m.%Y'> format
ALWAYS return dates from today
Method is useful for minimize amount of API requests
=head3 params
B<from> - IATA departure city code
B<to> -IATA arrival city code
B<min> - min date to check in C<'%d.%m.%Y'> format. Today if not specified
B<max> - max date to check in C<'%d.%m.%Y'> format. If not specified explicitly with count from C<available_to> property in API response, otherwise two month ahead
If no C<available_to> property in API response
(like at L<https://azimuth.aero/ru/flights?from=ROV&to=PKV> and seems like all indirect flights)
and no I<only_direct_flights> param set method will return dates 2 months ahead
B<only_direct_flights> - consider only direct flights (with C<available_to> property in API response, like at L<https://azimuth.aero/ru/flights?from=ROV&to=LED> )
=head1 HELPER METHODS
Useful for making CLI utilites
=head2 possible_fares
Return names of div.td_fare CSS classes which contain prices
Current:
qw/legkiy vygodnyy optimalnyy svobodnyy komfort/;
=head2 find_no_schedule
Return hash with routes with no available schedule, presumably all transit routes.
=head2 get_fares_schedule
Get fares schedule between selected cities. Cities are specified as IATA codes.
my @flights = $az->get_fares_schedule(
from => 'ROV',
to => 'LED',
min => '25.06.2019',
max => '30.06.2019',
progress_bar => 1,
print_immediately => 1,
print_table => 1
);
Returned list of L<WWW::AzimuthAero::Flight> objects sorted by date, ascending
Params:
from
to
min
max
progress_bar - will print on STDOUT progress bar like
ROV->LED : 25.06.2019->30.06.2019
1/3
2/3
3/3
print_immediately - will print on STDOUT flight data immediately with progress bar like
ROV->LED : 25.06.2019->30.06.2019
1/3
27.06.2019 10:00 07:20 A4 203 ROV LED
2/3
28.06.2019 10:00 07:20 A4 203 ROV LED
3/3
29.06.2019 09:50 07:10 A4 203 ROV LED
print_table - will print on STDOUT result table like
=head2 get_lowest_fares
Wrapper for L<WWW::AzimuthAero/get_fares_schedule>
Main difference that it sort flights by price and can also checks neighbor airports
Get lowest fares between selected cities. Cities are specified as IATA codes.
$az->get_lowest_fares(
from => 'ROV',
to => 'LED',
min => '7.06.2019',
max => '15.06.2019',
progress_bar => 1,
check_neighbors => 1, # will check PKV instead LED and KLG instead of MOW
find_transits => 1, # will find transit cities that are not mentioned by azimuth
max_delay_days => 1,
# max_edges => 2 # TO-DO, hardcoded for now
);
=head1 AUTHOR
Pavel Serikov <pavelsr@cpan.org>
=head1 COPYRIGHT AND LICENSE
This software is copyright (c) 2019 by Pavel Serikov.
This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.
=cut