]> Raphaƫl G. Git Repositories - airbundle/blob - Command/WeatherCommand.php
New event layout
[airbundle] / Command / WeatherCommand.php
1 <?php
2
3 namespace Rapsys\AirBundle\Command;
4
5 use Doctrine\Bundle\DoctrineBundle\Command\DoctrineCommand;
6 use Doctrine\Persistence\ManagerRegistry;
7 use Symfony\Component\Console\Input\InputInterface;
8 use Symfony\Component\Console\Output\OutputInterface;
9 use Symfony\Component\Filesystem\Exception\IOException;
10 use Symfony\Component\Filesystem\Filesystem;
11
12 use Rapsys\AirBundle\Entity\Session;
13
14 class WeatherCommand extends DoctrineCommand {
15 //Set failure constant
16 const FAILURE = 1;
17
18 ///Set success constant
19 const SUCCESS = 0;
20
21 ///Set Tidy config
22 private $config = [
23 //Mostly useless in fact
24 'indent' => true,
25 //Required to simplify simplexml transition
26 'output-xml' => true,
27 //Required to avoid xml errors
28 'quote-nbsp' => false,
29 //Required to fix code
30 'clean' => true
31 ];
32
33 ///Set accuweather uris
34 private $accuweather = [
35 //Hourly uri
36 'hourly' => [
37 75001 => 'https://www.accuweather.com/en/fr/paris-01-louvre/75001/hourly-weather-forecast/179142_pc?day=',
38 75004 => 'https://www.accuweather.com/en/fr/paris-04-hotel-de-ville/75004/hourly-weather-forecast/179145_pc?day=',
39 75005 => 'https://www.accuweather.com/en/fr/paris-05-pantheon/75005/hourly-weather-forecast/179146_pc?day=',
40 75006 => 'https://www.accuweather.com/fr/fr/paris-06-luxembourg/75006/hourly-weather-forecast/179147_pc?day=',
41 75007 => 'https://www.accuweather.com/en/fr/paris-07-palais-bourbon/75007/hourly-weather-forecast/179148_pc?day=',
42 75009 => 'https://www.accuweather.com/en/fr/paris-09-opera/75009/hourly-weather-forecast/179150_pc?day=',
43 75013 => 'https://www.accuweather.com/en/fr/paris-13-gobelins/75013/hourly-weather-forecast/179154_pc?day=',
44 75015 => 'https://www.accuweather.com/en/fr/paris-15-vaugirard/75015/hourly-weather-forecast/179156_pc?day=',
45 75019 => 'https://www.accuweather.com/en/fr/paris-19-buttes-chaumont/75019/hourly-weather-forecast/179160_pc?day=',
46 75116 => 'https://www.accuweather.com/en/fr/paris-16-passy/75116/hourly-weather-forecast/179246_pc?day='
47 ],
48 //Daily uri
49 'daily' => [
50 75001 => 'https://www.accuweather.com/en/fr/paris-01-louvre/75001/daily-weather-forecast/179142_pc',
51 75004 => 'https://www.accuweather.com/en/fr/paris-04-hotel-de-ville/75004/daily-weather-forecast/179145_pc',
52 75005 => 'https://www.accuweather.com/en/fr/paris-05-pantheon/75005/daily-weather-forecast/179146_pc',
53 75006 => 'https://www.accuweather.com/fr/fr/paris-06-luxembourg/75006/daily-weather-forecast/179147_pc',
54 75007 => 'https://www.accuweather.com/en/fr/paris-07-palais-bourbon/75007/daily-weather-forecast/179148_pc',
55 75009 => 'https://www.accuweather.com/en/fr/paris-09-opera/75009/daily-weather-forecast/179150_pc',
56 75013 => 'https://www.accuweather.com/en/fr/paris-13-gobelins/75013/daily-weather-forecast/179154_pc',
57 75015 => 'https://www.accuweather.com/en/fr/paris-15-vaugirard/75015/daily-weather-forecast/179156_pc',
58 75019 => 'https://www.accuweather.com/en/fr/paris-19-buttes-chaumont/75019/daily-weather-forecast/179160_pc',
59 75116 => 'https://www.accuweather.com/en/fr/paris-16-passy/75116/daily-weather-forecast/179246_pc'
60 ]
61 ];
62
63 ///Set curl handler
64 private $ch = null;
65
66 ///Set manager registry
67 private $doctrine;
68
69 ///Set filesystem
70 private $filesystem;
71
72 ///Weather command constructor
73 public function __construct(ManagerRegistry $doctrine, Filesystem $filesystem) {
74 parent::__construct($doctrine);
75
76 //Set entity manager
77 $this->doctrine = $doctrine;
78
79 //Set filesystem
80 $this->filesystem = $filesystem;
81 }
82
83 ///Configure attribute command
84 protected function configure() {
85 //Configure the class
86 $this
87 //Set name
88 ->setName('rapsysair:weather')
89 //Set description shown with bin/console list
90 ->setDescription('Updates session rain and temperature fields')
91 //Set description shown with bin/console --help airlibre:attribute
92 ->setHelp('This command updates session rain and temperature fields in next three days')
93 //Add daily and hourly aliases
94 ->setAliases(['rapsysair:weather:daily', 'rapsysair:weather:hourly']);
95 }
96
97 ///Process the attribution
98 protected function execute(InputInterface $input, OutputInterface $output) {
99 //Kernel object
100 $kernel = $this->getApplication()->getKernel();
101
102 //Tmp directory
103 $tmpdir = $kernel->getContainer()->getParameter('kernel.project_dir').'/var/cache/weather';
104
105 //Set tmpdir
106 //XXX: worst case scenario we have 3 files per zipcode plus daily
107 if (!is_dir($tmpdir)) {
108 try {
109 //Create dir
110 $this->filesystem->mkdir($tmpdir, 0775);
111 } catch (IOException $exception) {
112 //Display error
113 echo 'Create dir '.$exception->getPath().' failed'."\n";
114
115 //Exit with failure
116 exit(self::FAILURE);
117 }
118 }
119
120 //Cleanup kernel
121 unset($kernel);
122
123 //Tidy object
124 $tidy = new \tidy();
125
126 //Init zipcodes array
127 $zipcodes = [];
128
129 //Init types
130 $types = [];
131
132 //Process hourly accuweather
133 if (($command = $input->getFirstArgument()) == 'rapsysair:weather:hourly' || $command == 'rapsysair:weather') {
134 //Fetch hourly sessions to attribute
135 $types['hourly'] = $this->doctrine->getRepository(Session::class)->findAllPendingHourlyWeather();
136
137 //Iterate on each session
138 foreach($types['hourly'] as $sessionId => $session) {
139 //Get zipcode
140 $zipcode = $session->getLocation()->getZipcode();
141
142 //Get start
143 $start = $session->getStart();
144
145 //Set start day
146 $day = $start->diff((new \DateTime('now'))->setTime(0, 0, 0))->d + 1;
147
148 //Check if zipcode is set
149 if (!isset($zipcodes[$zipcode])) {
150 $zipcodes[$zipcode] = [];
151 }
152
153 //Check if zipcode date is set
154 if (!isset($zipcodes[$zipcode][$day])) {
155 $zipcodes[$zipcode][$day] = [ $sessionId => $sessionId ];
156 } else {
157 $zipcodes[$zipcode][$day][$sessionId] = $sessionId;
158 }
159
160 //Get stop
161 $stop = $session->getStop();
162
163 //Set stop day
164 $day = $stop->diff((new \DateTime('now'))->setTime(0, 0, 0))->d + 1;
165
166 //Check if zipcode date is set
167 if (!isset($zipcodes[$zipcode][$day])) {
168 $zipcodes[$zipcode][$day] = [ $sessionId => $sessionId ];
169 } else {
170 $zipcodes[$zipcode][$day][$sessionId] = $sessionId;
171 }
172 }
173 }
174
175 //Process daily accuweather
176 if ($command == 'rapsysair:weather:daily' || $command == 'rapsysair:weather') {
177 //Fetch daily sessions to attribute
178 $types['daily'] = $this->doctrine->getRepository(Session::class)->findAllPendingDailyWeather();
179
180 //Iterate on each session
181 foreach($types['daily'] as $sessionId => $session) {
182 //Get zipcode
183 $zipcode = $session->getLocation()->getZipcode();
184
185 //Get start
186 $start = $session->getStart();
187
188 //Set start day
189 $day = 'daily';
190
191 //Check if zipcode is set
192 if (!isset($zipcodes[$zipcode])) {
193 $zipcodes[$zipcode] = [];
194 }
195
196 //Check if zipcode date is set
197 if (!isset($zipcodes[$zipcode][$day])) {
198 $zipcodes[$zipcode][$day] = [ $sessionId => $sessionId ];
199 } else {
200 $zipcodes[$zipcode][$day][$sessionId] = $sessionId;
201 }
202 }
203 }
204
205 //Init curl
206 $this->curl_init();
207
208 //Init data array
209 $data = [];
210
211 //Iterate on zipcodes
212 foreach($zipcodes as $zipcode => $days) {
213 //Iterate on days
214 foreach($days as $day => $null) {
215 //Try to load content from cache
216 if (!is_file($file = $tmpdir.'/'.$zipcode.'.'.$day.'.html') || stat($file)['ctime'] <= (time() - ($day == 'daily' ? 4 : 2)*3600) || ($content = file_get_contents($file)) === false) {
217 //Prevent timing detection
218 //XXX: from 0.1 to 5 seconds
219 usleep(rand(1,50) * 100000);
220
221 //Get content
222 //TODO: for daily we may load data for requested quarter of the day
223 $content = $this->curl_get($day == 'daily' ? $this->accuweather['daily'][$zipcode] : $this->accuweather['hourly'][$zipcode].$day);
224
225 //Store it
226 if (file_put_contents($tmpdir.'/'.$zipcode.'.'.$day.'.html', $content) === false) {
227 //Display error
228 echo 'Write to '.$tmpdir.'/'.$zipcode.'.'.$day.'.html failed'."\n";
229
230 //Exit with failure
231 exit(self::FAILURE);
232 }
233 }
234
235 //Parse string
236 $tidy->parseString($content, $this->config, 'utf8');
237
238 //Fix error buffer
239 //XXX: don't care about theses errors, tidy is here to fix...
240 #if (!empty($tidy->errorBuffer)) {
241 # var_dump($tidy->errorBuffer);
242 # die('Tidy errors');
243 #}
244
245 //Load simplexml
246 //XXX: trash all xmlns= broken tags
247 $sx = new \SimpleXMLElement(str_replace(['xmlns=', 'xlink:href='], ['xns=', 'href='], $tidy));
248
249 //Process daily
250 if ($day == 'daily') {
251 //Iterate on each link containing data
252 foreach($sx->xpath('//a[contains(@class,"daily-forecast-card")]') as $node) {
253 //Get date
254 $dsm = trim($node->div[0]->h2[0]->span[1]);
255
256 //Get temperature
257 $temperature = str_replace('Ā°', '', $node->div[0]->div[0]->span[0]);
258
259 //Get rainrisk
260 $rainrisk = str_replace('%', '', trim($node->div[2]))/100;
261
262 //Store data
263 $data[$zipcode][$dsm]['daily'] = [
264 'temperature' => $temperature,
265 'rainrisk' => $rainrisk
266 ];
267 }
268 //Process hourly
269 } else {
270 //Iterate on each div containing data
271 #(string)$sx->xpath('//div[@class="hourly-card-nfl"]')[0]->attributes()->value
272 #/html/body/div[1]/div[5]/div[1]/div[1]/div[1]/div[1]/div[1]/div/h2/span[1]
273 foreach($sx->xpath('//div[@data-shared="false"]') as $node) {
274 //Get hour
275 $hour = trim(str_replace(' h', '', $node->div[0]->div[0]->div[0]->h2[0]->span[0]));
276
277 //Compute dsm from day (1=d,2=d+1,3=d+2)
278 $dsm = (new \DateTime('+'.($day - 1).' day'))->format('d/m');
279
280 //Get temperature
281 $temperature = str_replace('Ā°', '', $node->div[0]->div[0]->div[0]->div[0]);
282
283 //Get realfeel
284 $realfeel = trim(str_replace(['RealFeelĀ®', 'Ā°'], '', $node->div[0]->div[0]->div[1]->div[0]->div[0]->div[0]));
285
286 //Get rainrisk
287 $rainrisk = floatval(str_replace('%', '', trim($node->div[0]->div[0]->div[2]->div[0]))/100);
288
289 //Set rainfall to 0 (mm)
290 $rainfall = 0;
291
292 //Iterate on each entry
293 //TODO: wind and other infos are present in $node->div[1]->div[0]->div[1]->div[0]->p
294 foreach($node->div[1]->div[0]->div[1]->div[1]->p as $p) {
295 //Lookup for rain entry if present
296 if (trim($p) == 'Rain') {
297 //Get rainfall
298 $rainfall = floatval(str_replace(' mm', '', $p->span[0]));
299 }
300 }
301
302 //Store data
303 $data[$zipcode][$dsm][$hour] = [
304 'temperature' => $temperature,
305 'realfeel' => $realfeel,
306 'rainrisk' => $rainrisk,
307 'rainfall' => $rainfall
308 ];
309 }
310 }
311
312 //Cleanup
313 unset($sx);
314 }
315 }
316
317 //Iterate on types
318 foreach($types as $type => $sessions) {
319 //Iterate on each type
320 foreach($sessions as $sessionId => $session) {
321 //Get zipcode
322 $zipcode = $session->getLocation()->getZipcode();
323
324 //Get start
325 $start = $session->getStart();
326
327 //Daily type
328 if ($type == 'daily') {
329 //Set period
330 $period = [ $start ];
331 //Hourly type
332 } else {
333 //Get stop
334 $stop = $session->getStop();
335
336 //Compute period
337 $period = new \DatePeriod(
338 //Start from begin
339 $start,
340 //Iterate on each hour
341 new \DateInterval('PT1H'),
342 //End with begin + length
343 $stop
344 );
345 }
346
347 //Set meteo
348 $meteo = [
349 'rainfall' => null,
350 'rainrisk' => null,
351 'realfeel' => [],
352 'realfeelmin' => null,
353 'realfeelmax' => null,
354 'temperature' => [],
355 'temperaturemin' => null,
356 'temperaturemax' => null
357 ];
358
359 //Iterate on the period
360 foreach($period as $time) {
361 //Set dsm
362 $dsm = $time->format('d/m');
363
364 //Set hour
365 $hour = $type=='daily'?$type:$time->format('H');
366
367 //Check data availability
368 //XXX: sometimes startup delay causes weather data to be unavailable for session first hour
369 if (!isset($data[$zipcode][$dsm][$hour])) {
370 //Skip unavailable data
371 continue;
372 }
373
374 //Set info alias
375 $info = $data[$zipcode][$dsm][$hour];
376
377 //Check if rainrisk is higher
378 if ($meteo['rainrisk'] === null || $info['rainrisk'] > $meteo['rainrisk']) {
379 //Set highest rain risk
380 $meteo['rainrisk'] = floatval($info['rainrisk']);
381 }
382
383 //Check if rainfall is set
384 if (isset($info['rainfall'])) {
385 //Set rainfall sum
386 $meteo['rainfall'] += floatval($info['rainfall']);
387 }
388
389 //Add temperature
390 $meteo['temperature'][$hour] = $info['temperature'];
391
392 //Hourly type
393 if ($type != 'daily') {
394 //Check min temperature
395 if ($meteo['temperaturemin'] === null || $info['temperature'] < $meteo['temperaturemin']) {
396 $meteo['temperaturemin'] = floatval($info['temperature']);
397 }
398
399 //Check max temperature
400 if ($meteo['temperaturemax'] === null || $info['temperature'] > $meteo['temperaturemax']) {
401 $meteo['temperaturemax'] = floatval($info['temperature']);
402 }
403 }
404
405 //Check if realfeel is set
406 if (isset($info['realfeel'])) {
407 //Add realfeel
408 $meteo['realfeel'][$hour] = $info['realfeel'];
409
410 //Check min realfeel
411 if ($meteo['realfeelmin'] === null || $info['realfeel'] < $meteo['realfeelmin']) {
412 $meteo['realfeelmin'] = floatval($info['realfeel']);
413 }
414
415 //Check max realfeel
416 if ($meteo['realfeelmax'] === null || $info['realfeel'] > $meteo['realfeelmax']) {
417 $meteo['realfeelmax'] = floatval($info['realfeel']);
418 }
419 }
420 }
421
422 //Check if rainfall is set and differ
423 if ($session->getRainfall() !== $meteo['rainfall']) {
424 //Set rainfall
425 $session->setRainfall($meteo['rainfall']);
426 }
427
428 //Check if rainrisk differ
429 if ($session->getRainrisk() !== $meteo['rainrisk']) {
430 //Set rainrisk
431 $session->setRainrisk($meteo['rainrisk']);
432 }
433
434 //Check realfeel array
435 if ($meteo['realfeel'] !== []) {
436 //Compute realfeel
437 $realfeel = floatval(round(array_sum($meteo['realfeel'])/count($meteo['realfeel']),1));
438
439 //Check if realfeel differ
440 if ($session->getRealfeel() !== $realfeel) {
441 //Set average realfeel
442 #$meteo['realfeel'] = array_sum($meteo['realfeel'])/count($meteo['realfeel']);
443 $session->setRealfeel($realfeel);
444 }
445
446 //Check if realfeelmin differ
447 if ($session->getRealfeelmin() !== $meteo['realfeelmin']) {
448 //Set realfeelmin
449 $session->setRealfeelmin($meteo['realfeelmin']);
450 }
451
452 //Check if realfeelmax differ
453 if ($session->getRealfeelmax() !== $meteo['realfeelmax']) {
454 //Set realfeelmax
455 $session->setRealfeelmax($meteo['realfeelmax']);
456 }
457 }
458
459 //Check temperature array
460 if ($meteo['temperature'] !== []) {
461 //Compute temperature
462 $temperature = floatval(round(array_sum($meteo['temperature'])/count($meteo['temperature']),1));
463
464 //Check if temperature differ
465 if ($session->getTemperature() !== $temperature) {
466 //Set average temperature
467 #$meteo['temperature'] = array_sum($meteo['temperature'])/count($meteo['temperature']);
468 $session->setTemperature($temperature);
469 }
470
471 //Check if temperaturemin differ
472 if ($session->getTemperaturemin() !== $meteo['temperaturemin']) {
473 //Set temperaturemin
474 $session->setTemperaturemin($meteo['temperaturemin']);
475 }
476
477 //Check if temperaturemax differ
478 if ($session->getTemperaturemax() !== $meteo['temperaturemax']) {
479 //Set temperaturemax
480 $session->setTemperaturemax($meteo['temperaturemax']);
481 }
482 }
483 }
484 }
485
486 //Flush to get the ids
487 $this->doctrine->getManager()->flush();
488
489 //Close curl handler
490 $this->curl_close();
491
492 //Return success
493 return self::SUCCESS;
494 }
495
496 /**
497 * Init curl handler
498 *
499 * @return bool|void Return success or exit
500 */
501 function curl_init() {
502 //Init curl
503 if (($this->ch = curl_init()) === false) {
504 //Display error
505 echo 'Curl init failed: '.curl_error($this->ch)."\n";
506 //Exit with failure
507 exit(self::FAILURE);
508 }
509
510 //Set curl options
511 if (
512 curl_setopt_array(
513 $this->ch,
514 [
515 //Force http2
516 CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0,
517 //Set http headers
518 CURLOPT_HTTPHEADER => [
519 //XXX: it seems that you can disable akamai fucking protection with Pragma: akamai-x-cache-off
520 //XXX: see https://support.globaldots.com/hc/en-us/articles/115003996705-Akamai-Pragma-Headers-overview
521 #'Pragma: akamai-x-cache-off',
522 //XXX: working curl command
523 #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/'
524 //Set accept
525 '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',
526 //Set accept language
527 'Accept-Language: en-GB,en;q=0.9',
528 //Disable cache
529 'Cache-Control: no-cache',
530 //Keep connection alive
531 'Connection: keep-alive',
532 //Disable cache
533 'Pragma: no-cache',
534 //Force secure requests
535 'Upgrade-Insecure-Requests: 1',
536 //Set user agent
537 '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',
538 //Force akamai cookie
539 //XXX: seems to come from http request
540 'Cookie: AKA_A2=A',
541 ],
542 //Enable cookie
543 CURLOPT_COOKIEFILE => '',
544 //Disable location following
545 CURLOPT_FOLLOWLOCATION => false,
546 //Set url
547 #CURLOPT_URL => $url = 'https://www.accuweather.com/',
548 //Return headers too
549 CURLOPT_HEADER => true,
550 //Return content
551 CURLOPT_RETURNTRANSFER => true,
552
553 //XXX: debug
554 CURLINFO_HEADER_OUT => true
555 ]
556 ) === false
557 ) {
558 //Display error
559 echo 'Curl setopt array failed: '.curl_error($this->ch)."\n";
560 //Exit with failure
561 exit(self::FAILURE);
562 }
563
564 //Return success
565 return true;
566 }
567
568 /**
569 * Get url
570 *
571 * @return string|void Return url content or exit
572 */
573 function curl_get($url) {
574 //Set url to fetch
575 if (curl_setopt($this->ch, CURLOPT_URL, $url) === false) {
576 //Display error
577 echo 'Setopt for '.$url.' failed: '.curl_error($this->ch)."\n";
578
579 //Close curl handler
580 curl_close($this->ch);
581
582 //Exit with failure
583 exit(self::FAILURE);
584 }
585
586 //Check return status
587 if (($response = curl_exec($this->ch)) === false) {
588 //Display error
589 echo 'Get for '.$url.' failed: '.curl_error($this->ch)."\n";
590
591 //Display sent headers
592 var_dump(curl_getinfo($this->ch, CURLINFO_HEADER_OUT));
593
594 //Display response
595 var_dump($response);
596
597 //Close curl handler
598 curl_close($this->ch);
599
600 //Exit with failure
601 exit(self::FAILURE);
602 }
603
604 //Get header size
605 if (empty($hs = curl_getinfo($this->ch, CURLINFO_HEADER_SIZE))) {
606 //Display error
607 echo 'Getinfo for '.$url.' failed: '.curl_error($this->ch)."\n";
608
609 //Display sent headers
610 var_dump(curl_getinfo($this->ch, CURLINFO_HEADER_OUT));
611
612 //Display response
613 var_dump($response);
614
615 //Close curl handler
616 curl_close($this->ch);
617
618 //Exit with failure
619 exit(self::FAILURE);
620 }
621
622 //Get header
623 if (empty($header = substr($response, 0, $hs))) {
624 //Display error
625 echo 'Header for '.$url.' empty: '.curl_error($this->ch)."\n";
626
627 //Display sent headers
628 var_dump(curl_getinfo($this->ch, CURLINFO_HEADER_OUT));
629
630 //Display response
631 var_dump($response);
632
633 //Close curl handler
634 curl_close($this->ch);
635
636 //Exit with failure
637 exit(self::FAILURE);
638 }
639
640 //Check request success
641 if (strlen($header) <= 10 || substr($header, 0, 10) !== 'HTTP/2 200') {
642 //Display error
643 echo 'Status for '.$url.' failed: '.curl_error($this->ch)."\n";
644
645 //Display sent headers
646 var_dump(curl_getinfo($this->ch, CURLINFO_HEADER_OUT));
647
648 //Display response
649 var_dump($header);
650
651 //Close curl handler
652 curl_close($this->ch);
653
654 //Exit with failure
655 exit(self::FAILURE);
656 }
657
658 //Return content
659 return substr($response, $hs);
660 }
661
662 /**
663 * Close curl handler
664 *
665 * @return bool Return success or failure
666 */
667 function curl_close() {
668 return curl_close($this->ch);
669 }
670 }