1 <?php
declare(strict_types
=1);
4 * This file is part of the Rapsys AirBundle package.
6 * (c) Raphaël Gertz <symfony@rapsys.eu>
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
12 namespace Rapsys\AirBundle\Command
;
14 use Doctrine\Bundle\DoctrineBundle\Command\DoctrineCommand
;
15 use Doctrine\Persistence\ManagerRegistry
;
17 use Symfony\Component\Console\Input\InputInterface
;
18 use Symfony\Component\Console\Output\OutputInterface
;
19 use Symfony\Component\Filesystem\Exception\IOException
;
20 use Symfony\Component\Filesystem\Filesystem
;
22 use Rapsys\AirBundle\Entity\Session
;
24 class WeatherCommand
extends DoctrineCommand
{
25 //Set failure constant
28 ///Set success constant
33 //Mostly useless in fact
35 //Required to simplify simplexml transition
37 //Required to avoid xml errors
38 'quote-nbsp' => false,
39 //Required to fix code
43 ///Set accuweather uris
44 private $accuweather = [
47 75001 => 'https://www.accuweather.com/en/fr/paris-01-louvre/75001/hourly-weather-forecast/179142_pc?day=',
48 75004 => 'https://www.accuweather.com/en/fr/paris-04-hotel-de-ville/75004/hourly-weather-forecast/179145_pc?day=',
49 75005 => 'https://www.accuweather.com/en/fr/paris-05-pantheon/75005/hourly-weather-forecast/179146_pc?day=',
50 75006 => 'https://www.accuweather.com/fr/fr/paris-06-luxembourg/75006/hourly-weather-forecast/179147_pc?day=',
51 75007 => 'https://www.accuweather.com/en/fr/paris-07-palais-bourbon/75007/hourly-weather-forecast/179148_pc?day=',
52 75009 => 'https://www.accuweather.com/en/fr/paris-09-opera/75009/hourly-weather-forecast/179150_pc?day=',
53 75010 => 'https://www.accuweather.com/en/fr/paris-10-entrepot/75010/hourly-weather-forecast/179151_pc?day=',
54 75012 => 'https://www.accuweather.com/en/fr/paris-12-reuilly/75012/hourly-weather-forecast/179153_pc?day=',
55 75013 => 'https://www.accuweather.com/en/fr/paris-13-gobelins/75013/hourly-weather-forecast/179154_pc?day=',
56 75015 => 'https://www.accuweather.com/en/fr/paris-15-vaugirard/75015/hourly-weather-forecast/179156_pc?day=',
57 75019 => 'https://www.accuweather.com/en/fr/paris-19-buttes-chaumont/75019/hourly-weather-forecast/179160_pc?day=',
58 75116 => 'https://www.accuweather.com/en/fr/paris-16-passy/75116/hourly-weather-forecast/179246_pc?day='
62 75001 => 'https://www.accuweather.com/en/fr/paris-01-louvre/75001/daily-weather-forecast/179142_pc',
63 75004 => 'https://www.accuweather.com/en/fr/paris-04-hotel-de-ville/75004/daily-weather-forecast/179145_pc',
64 75005 => 'https://www.accuweather.com/en/fr/paris-05-pantheon/75005/daily-weather-forecast/179146_pc',
65 75006 => 'https://www.accuweather.com/fr/fr/paris-06-luxembourg/75006/daily-weather-forecast/179147_pc',
66 75007 => 'https://www.accuweather.com/en/fr/paris-07-palais-bourbon/75007/daily-weather-forecast/179148_pc',
67 75009 => 'https://www.accuweather.com/en/fr/paris-09-opera/75009/daily-weather-forecast/179150_pc',
68 75010 => 'https://www.accuweather.com/en/fr/paris-10-entrepot/75010/daily-weather-forecast/179151_pc',
69 75012 => 'https://www.accuweather.com/en/fr/paris-12-reuilly/75012/daily-weather-forecast/179153_pc',
70 75013 => 'https://www.accuweather.com/en/fr/paris-13-gobelins/75013/daily-weather-forecast/179154_pc',
71 75015 => 'https://www.accuweather.com/en/fr/paris-15-vaugirard/75015/daily-weather-forecast/179156_pc',
72 75019 => 'https://www.accuweather.com/en/fr/paris-19-buttes-chaumont/75019/daily-weather-forecast/179160_pc',
73 75116 => 'https://www.accuweather.com/en/fr/paris-16-passy/75116/daily-weather-forecast/179246_pc'
80 ///Set manager registry
86 ///Weather command constructor
87 public function __construct(ManagerRegistry
$doctrine, Filesystem
$filesystem) {
88 parent
::__construct($doctrine);
91 $this->doctrine
= $doctrine;
94 $this->filesystem
= $filesystem;
97 ///Configure attribute command
98 protected function configure() {
102 ->setName('rapsysair:weather')
103 //Set description shown with bin/console list
104 ->setDescription('Updates session rain and temperature fields')
105 //Set description shown with bin/console --help airlibre:attribute
106 ->setHelp('This command updates session rain and temperature fields in next three days')
107 //Add daily and hourly aliases
108 ->setAliases(['rapsysair:weather:daily', 'rapsysair:weather:hourly']);
111 ///Process the attribution
112 protected function execute(InputInterface
$input, OutputInterface
$output): int {
114 $kernel = $this->getApplication()->getKernel();
117 $tmpdir = $kernel->getContainer()->getParameter('kernel.project_dir').'/var/cache/weather';
120 //XXX: worst case scenario we have 3 files per zipcode plus daily
121 if (!is_dir($tmpdir)) {
124 $this->filesystem
->mkdir($tmpdir, 0775);
125 } catch (IOException
$exception) {
127 echo 'Create dir '.$exception->getPath().' failed'."\n";
140 //Init zipcodes array
146 //Process hourly accuweather
147 if (($command = $input->getFirstArgument()) == 'rapsysair:weather:hourly' || $command == 'rapsysair:weather') {
148 //Fetch hourly sessions to attribute
149 $types['hourly'] = $this->doctrine
->getRepository(Session
::class)->findAllPendingHourlyWeather();
151 //Iterate on each session
152 foreach($types['hourly'] as $sessionId => $session) {
154 $zipcode = $session->getLocation()->getZipcode();
157 $start = $session->getStart();
160 $day = $start->diff((new \
DateTime('now'))->setTime(0, 0, 0))->d +
1;
162 //Check if zipcode is set
163 if (!isset($zipcodes[$zipcode])) {
164 $zipcodes[$zipcode] = [];
167 //Check if zipcode date is set
168 if (!isset($zipcodes[$zipcode][$day])) {
169 $zipcodes[$zipcode][$day] = [ $sessionId => $sessionId ];
171 $zipcodes[$zipcode][$day][$sessionId] = $sessionId;
175 $stop = $session->getStop();
178 $day = $stop->diff((new \
DateTime('now'))->setTime(0, 0, 0))->d +
1;
180 //Check if zipcode date is set
181 if (!isset($zipcodes[$zipcode][$day])) {
182 $zipcodes[$zipcode][$day] = [ $sessionId => $sessionId ];
184 $zipcodes[$zipcode][$day][$sessionId] = $sessionId;
189 //Process daily accuweather
190 if ($command == 'rapsysair:weather:daily' || $command == 'rapsysair:weather') {
191 //Fetch daily sessions to attribute
192 $types['daily'] = $this->doctrine
->getRepository(Session
::class)->findAllPendingDailyWeather();
194 //Iterate on each session
195 foreach($types['daily'] as $sessionId => $session) {
197 $zipcode = $session->getLocation()->getZipcode();
200 $start = $session->getStart();
205 //Check if zipcode is set
206 if (!isset($zipcodes[$zipcode])) {
207 $zipcodes[$zipcode] = [];
210 //Check if zipcode date is set
211 if (!isset($zipcodes[$zipcode][$day])) {
212 $zipcodes[$zipcode][$day] = [ $sessionId => $sessionId ];
214 $zipcodes[$zipcode][$day][$sessionId] = $sessionId;
225 //Iterate on zipcodes
226 foreach($zipcodes as $zipcode => $days) {
228 foreach($days as $day => $null) {
229 //Try to load content from cache
230 if (!is_file($file = $tmpdir.'/'.$zipcode.'.'.$day.'.html') || stat($file)['ctime'] <= (time() - ($day == 'daily' ? 4 : 2)*3600) || ($content = file_get_contents($file)) === false) {
231 //Prevent timing detection
232 //XXX: from 0.1 to 5 seconds
233 usleep(rand(1,50) * 100000);
236 //TODO: for daily we may load data for requested quarter of the day
237 $content = $this->curl_get($day == 'daily' ? $this->accuweather
['daily'][$zipcode] : $this->accuweather
['hourly'][$zipcode].$day);
240 if (file_put_contents($tmpdir.'/'.$zipcode.'.'.$day.'.html', $content) === false) {
242 echo 'Write to '.$tmpdir.'/'.$zipcode.'.'.$day.'.html failed'."\n";
250 $tidy->parseString($content, $this->config
, 'utf8');
253 //XXX: don't care about theses errors, tidy is here to fix...
254 #if (!empty($tidy->errorBuffer)) {
255 # var_dump($tidy->errorBuffer);
256 # die('Tidy errors');
260 //XXX: trash all xmlns= broken tags
261 $sx = new \
SimpleXMLElement(str_replace(['xmlns=', 'xlink:href='], ['xns=', 'href='], (string)$tidy));
264 if ($day == 'daily') {
265 //Iterate on each link containing data
266 foreach($sx->xpath('//a[contains(@class,"daily-forecast-card")]') as $node) {
268 $dsm = trim($node->div
[0]->h2
[0]->span
[1]);
271 $temperature = str_replace('°', '', (string)$node->div
[0]->div
[0]->span
[0]);
274 $rainrisk = trim(str_replace('%', '', (string)$node->div
[1]))/100;
277 $data[$zipcode][$dsm]['daily'] = [
278 'temperature' => $temperature,
279 'rainrisk' => $rainrisk
284 //Iterate on each div containing data
285 #(string)$sx->xpath('//div[@class="hourly-card-nfl"]')[0]->attributes()->value
286 #/html/body/div[1]/div[5]/div[1]/div[1]/div[1]/div[1]/div[1]/div/h2/span[1]
287 foreach($sx->xpath('//div[@data-shared="false"]') as $node) {
289 $hour = trim(str_replace(' h', '', (string)$node->div
[0]->div
[0]->div
[0]->div
[0]->div
[0]->h2
[0]));
291 //Compute dsm from day (1=d,2=d+1,3=d+2)
292 $dsm = (new \
DateTime('+'.($day - 1).' day'))->format('d/m');
295 $temperature = str_replace('°', '', (string)$node->div
[0]->div
[0]->div
[0]->div
[0]->div
[1]);
298 $realfeel = trim(str_replace(['RealFeel®', '°'], '', (string)$node->div
[0]->div
[0]->div
[0]->div
[1]->div
[0]->div
[0]->div
[0]));
301 $rainrisk = floatval(str_replace('%', '', trim((string)$node->div
[0]->div
[0]->div
[0]->div
[2]->div
[0]))/100);
303 //Set rainfall to 0 (mm)
306 //Iterate on each entry
307 //TODO: wind and other infos are present in $node->div[1]->div[0]->div[1]->div[0]->p
308 foreach($node->div
[1]->div
[0]->div
[1]->div
[0]->p
as $p) {
309 //Lookup for rain entry if present
310 if (in_array(trim((string)$p), ['Rain', 'Pluie'])) {
312 $rainfall = floatval(str_replace(' mm', '', (string)$p->span
[0]));
317 $data[$zipcode][$dsm][$hour] = [
318 'temperature' => $temperature,
319 'realfeel' => $realfeel,
320 'rainrisk' => $rainrisk,
321 'rainfall' => $rainfall
332 foreach($types as $type => $sessions) {
333 //Iterate on each type
334 foreach($sessions as $sessionId => $session) {
336 $zipcode = $session->getLocation()->getZipcode();
339 $start = $session->getStart();
342 if ($type == 'daily') {
344 $period = [ $start ];
348 $stop = $session->getStop();
351 $period = new \
DatePeriod(
354 //Iterate on each hour
355 new \
DateInterval('PT1H'),
356 //End with begin + length
366 'realfeelmin' => null,
367 'realfeelmax' => null,
369 'temperaturemin' => null,
370 'temperaturemax' => null
373 //Iterate on the period
374 foreach($period as $time) {
376 $dsm = $time->format('d/m');
379 $hour = $type=='daily'?$type:$time->format('H');
381 //Check data availability
382 //XXX: sometimes startup delay causes weather data to be unavailable for session first hour
383 if (!isset($data[$zipcode][$dsm][$hour])) {
384 //Skip unavailable data
389 $info = $data[$zipcode][$dsm][$hour];
391 //Check if rainrisk is higher
392 if ($meteo['rainrisk'] === null || $info['rainrisk'] > $meteo['rainrisk']) {
393 //Set highest rain risk
394 $meteo['rainrisk'] = floatval($info['rainrisk']);
397 //Check if rainfall is set
398 if (isset($info['rainfall'])) {
400 $meteo['rainfall'] +
= floatval($info['rainfall']);
404 $meteo['temperature'][$hour] = $info['temperature'];
407 if ($type != 'daily') {
408 //Check min temperature
409 if ($meteo['temperaturemin'] === null || $info['temperature'] < $meteo['temperaturemin']) {
410 $meteo['temperaturemin'] = floatval($info['temperature']);
413 //Check max temperature
414 if ($meteo['temperaturemax'] === null || $info['temperature'] > $meteo['temperaturemax']) {
415 $meteo['temperaturemax'] = floatval($info['temperature']);
419 //Check if realfeel is set
420 if (isset($info['realfeel'])) {
422 $meteo['realfeel'][$hour] = $info['realfeel'];
425 if ($meteo['realfeelmin'] === null || $info['realfeel'] < $meteo['realfeelmin']) {
426 $meteo['realfeelmin'] = floatval($info['realfeel']);
430 if ($meteo['realfeelmax'] === null || $info['realfeel'] > $meteo['realfeelmax']) {
431 $meteo['realfeelmax'] = floatval($info['realfeel']);
436 //Check if rainfall is set and differ
437 if ($session->getRainfall() !== $meteo['rainfall']) {
439 $session->setRainfall($meteo['rainfall']);
442 //Check if rainrisk differ
443 if ($session->getRainrisk() !== $meteo['rainrisk']) {
445 $session->setRainrisk($meteo['rainrisk']);
448 //Check realfeel array
449 if ($meteo['realfeel'] !== []) {
451 $realfeel = floatval(round(array_sum($meteo['realfeel'])/count($meteo['realfeel']),1));
453 //Check if realfeel differ
454 if ($session->getRealfeel() !== $realfeel) {
455 //Set average realfeel
456 $session->setRealfeel($realfeel);
459 //Check if realfeelmin differ
460 if ($session->getRealfeelmin() !== $meteo['realfeelmin']) {
462 $session->setRealfeelmin($meteo['realfeelmin']);
465 //Check if realfeelmax differ
466 if ($session->getRealfeelmax() !== $meteo['realfeelmax']) {
468 $session->setRealfeelmax($meteo['realfeelmax']);
472 //Check temperature array
473 if ($meteo['temperature'] !== []) {
474 //Compute temperature
475 $temperature = floatval(round(array_sum($meteo['temperature'])/count($meteo['temperature']),1));
477 //Check if temperature differ
478 if ($session->getTemperature() !== $temperature) {
479 //Set average temperature
480 $session->setTemperature($temperature);
483 //Check if temperaturemin differ
484 if ($session->getTemperaturemin() !== $meteo['temperaturemin']) {
486 $session->setTemperaturemin($meteo['temperaturemin']);
489 //Check if temperaturemax differ
490 if ($session->getTemperaturemax() !== $meteo['temperaturemax']) {
492 $session->setTemperaturemax($meteo['temperaturemax']);
498 //Flush to get the ids
499 $this->doctrine
->getManager()->flush();
505 return self
::SUCCESS
;
511 * @return bool|void Return success or exit
513 function curl_init() {
515 if (($this->ch
= curl_init()) === false) {
517 echo 'Curl init failed: '.curl_error($this->ch
)."\n";
528 CURLOPT_HTTP_VERSION
=> CURL_HTTP_VERSION_2_0
,
530 CURLOPT_HTTPHEADER
=> [
531 //XXX: it seems that you can disable akamai fucking protection with Pragma: akamai-x-cache-off
532 //XXX: see https://support.globaldots.com/hc/en-us/articles/115003996705-Akamai-Pragma-Headers-overview
533 #'Pragma: akamai-x-cache-off',
534 //XXX: working curl command
535 #curl --http2 --cookie file.jar --cookie-jar file.jar -v -i -k -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' -H 'Accept-Language: en-GB,en;q=0.9' -H 'Cache-Control: no-cache' -H 'Connection: keep-alive' -H 'Host: www.accuweather.com' -H 'Pragma: no-cache' -H 'Upgrade-Insecure-Requests: 1' -H 'User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36' 'https://www.accuweather.com/'
537 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
538 //Set accept language
539 'Accept-Language: en-GB,en;q=0.9',
541 'Cache-Control: no-cache',
542 //Keep connection alive
543 'Connection: keep-alive',
546 //Force secure requests
547 'Upgrade-Insecure-Requests: 1',
549 'User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
550 //Force akamai cookie
551 //XXX: seems to come from http request
555 CURLOPT_COOKIEFILE
=> '',
556 //Disable location following
557 CURLOPT_FOLLOWLOCATION
=> false,
559 #CURLOPT_URL => $url = 'https://www.accuweather.com/',
561 CURLOPT_HEADER
=> true,
563 CURLOPT_RETURNTRANSFER
=> true,
566 CURLINFO_HEADER_OUT
=> true
571 echo 'Curl setopt array failed: '.curl_error($this->ch
)."\n";
583 * @return string|void Return url content or exit
585 function curl_get($url) {
587 if (curl_setopt($this->ch
, CURLOPT_URL
, $url) === false) {
589 echo 'Setopt for '.$url.' failed: '.curl_error($this->ch
)."\n";
592 curl_close($this->ch
);
598 //Check return status
599 if (($response = curl_exec($this->ch
)) === false) {
601 echo 'Get for '.$url.' failed: '.curl_error($this->ch
)."\n";
603 //Display sent headers
604 var_dump(curl_getinfo($this->ch
, CURLINFO_HEADER_OUT
));
610 curl_close($this->ch
);
617 if (empty($hs = curl_getinfo($this->ch
, CURLINFO_HEADER_SIZE
))) {
619 echo 'Getinfo for '.$url.' failed: '.curl_error($this->ch
)."\n";
621 //Display sent headers
622 var_dump(curl_getinfo($this->ch
, CURLINFO_HEADER_OUT
));
628 curl_close($this->ch
);
635 if (empty($header = substr($response, 0, $hs))) {
637 echo 'Header for '.$url.' empty: '.curl_error($this->ch
)."\n";
639 //Display sent headers
640 var_dump(curl_getinfo($this->ch
, CURLINFO_HEADER_OUT
));
646 curl_close($this->ch
);
652 //Check request success
653 if (strlen($header) <= 10 || substr($header, 0, 10) !== 'HTTP/2 200') {
655 echo 'Status for '.$url.' failed: '.curl_error($this->ch
)."\n";
657 //Display sent headers
658 var_dump(curl_getinfo($this->ch
, CURLINFO_HEADER_OUT
));
664 curl_close($this->ch
);
671 return substr($response, $hs);
677 * @return bool Return success or failure
679 function curl_close() {
680 return curl_close($this->ch
);