src/Tweeper.php: add a retry mechanism for cURL sessions
[tweeper.git] / src / Tweeper.php
1 <?php
2
3 namespace Tweeper;
4
5 /**
6  * @file
7  * Tweeper - a Twitter to RSS web scraper.
8  *
9  * Copyright (C) 2013-2018  Antonio Ospite <ao2@ao2.it>
10  *
11  * This program is free software: you can redistribute it and/or modify
12  * it under the terms of the GNU General Public License as published by
13  * the Free Software Foundation, either version 3 of the License, or
14  * (at your option) any later version.
15  *
16  * This program is distributed in the hope that it will be useful,
17  * but WITHOUT ANY WARRANTY; without even the implied warranty of
18  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19  * GNU General Public License for more details.
20  *
21  * You should have received a copy of the GNU General Public License
22  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
23  */
24
25 use DOMDocument;
26 use XSLTProcessor;
27
28 use Symfony\Component\Serializer\Serializer;
29 use Symfony\Component\Serializer\Encoder\XmlEncoder;
30 use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
31
32 date_default_timezone_set('UTC');
33
34 /**
35  * Scrape supported websites and perform conversion to RSS.
36  */
37 class Tweeper {
38
39   private static $userAgent = "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:60.0) Gecko/20100101 Firefox/60.0";
40   private static $maxConnectionTimeout = 5;
41   private static $maxConnectionRetries = 5;
42
43   /**
44    * Create a new Tweeper object controlling optional settings.
45    *
46    * @param bool $generate_enclosure
47    *   Enables the creation of <enclosure/> elements (disabled by default).
48    * @param bool $show_usernames
49    *   Enables showing the username in front of the content for multi-user
50    *   sites (enabled by default). Only some stylesheets supports this
51    *   functionality (twitter, instagram, pump.io).
52    */
53   public function __construct($generate_enclosure = FALSE, $show_usernames = TRUE) {
54     $this->generate_enclosure = $generate_enclosure;
55     $this->show_usernames = $show_usernames;
56   }
57
58   /**
59    * Convert numeric Epoch to the date format expected in a RSS document.
60    */
61   public static function epochToRssDate($timestamp) {
62     if (!is_numeric($timestamp) || is_nan($timestamp)) {
63       $timestamp = 0;
64     }
65
66     return gmdate(DATE_RSS, $timestamp);
67   }
68
69   /**
70    * Convert generic date string to the date format expected in a RSS document.
71    */
72   public static function strToRssDate($date) {
73     $timestamp = strtotime($date);
74     if (FALSE === $timestamp) {
75       $timestamp = 0;
76     }
77
78     return Tweeper::epochToRssDate($timestamp);
79   }
80
81   /**
82    * Convert string to UpperCamelCase.
83    */
84   public static function toUpperCamelCase($str, $delim = ' ') {
85     $str_upper = ucwords($str, $delim);
86     $str_camel_case = str_replace($delim, '', $str_upper);
87     return $str_camel_case;
88   }
89
90   /**
91    * Perform a cURL session multiple times when it fails with a timeout.
92    *
93    * @param resource $ch
94    *   a cURL session handle.
95    */
96   private static function curlExec($ch) {
97     $ret = FALSE;
98     $attempt = 0;
99     do {
100       $ret = curl_exec($ch);
101       if (FALSE === $ret) {
102         trigger_error(curl_error($ch), E_USER_WARNING);
103       }
104     } while (curl_errno($ch) == CURLE_OPERATION_TIMEDOUT && ++$attempt < Tweeper::$maxConnectionRetries);
105
106     return $ret;
107   }
108
109   /**
110    * Get the contents from a URL.
111    */
112   private static function getUrlContents($url) {
113     $ch = curl_init($url);
114     curl_setopt_array($ch, array(
115       CURLOPT_HEADER => FALSE,
116       CURLOPT_CONNECTTIMEOUT => Tweeper::$maxConnectionTimeout,
117       // Follow http redirects to get the real URL.
118       CURLOPT_FOLLOWLOCATION => TRUE,
119       CURLOPT_RETURNTRANSFER => TRUE,
120       CURLOPT_SSL_VERIFYHOST => FALSE,
121       CURLOPT_SSL_VERIFYPEER => FALSE,
122       CURLOPT_HTTPHEADER => array('Accept-language: en'),
123       CURLOPT_USERAGENT => Tweeper::$userAgent,
124     ));
125     $contents = Tweeper::curlExec($ch);
126     curl_close($ch);
127
128     return $contents;
129   }
130
131   /**
132    * Get the headers from a URL.
133    */
134   private static function getUrlInfo($url) {
135     $ch = curl_init($url);
136     curl_setopt_array($ch, array(
137       CURLOPT_HEADER => TRUE,
138       CURLOPT_NOBODY => TRUE,
139       CURLOPT_CONNECTTIMEOUT => Tweeper::$maxConnectionTimeout,
140       // Follow http redirects to get the real URL.
141       CURLOPT_FOLLOWLOCATION => TRUE,
142       CURLOPT_RETURNTRANSFER => TRUE,
143       CURLOPT_SSL_VERIFYHOST => FALSE,
144       CURLOPT_SSL_VERIFYPEER => FALSE,
145       CURLOPT_USERAGENT => Tweeper::$userAgent,
146     ));
147
148     $ret = Tweeper::curlExec($ch);
149     if (FALSE === $ret) {
150       curl_close($ch);
151       return FALSE;
152     }
153
154     $url_info = curl_getinfo($ch);
155     if (FALSE === $url_info) {
156       trigger_error(curl_error($ch), E_USER_WARNING);
157     }
158     curl_close($ch);
159
160     return $url_info;
161   }
162
163   /**
164    * Generate an RSS <enclosure/> element.
165    */
166   public static function generateEnclosure($url) {
167     $supported_content_types = array(
168       "application/octet-stream",
169       "application/ogg",
170       "application/pdf",
171       "audio/aac",
172       "audio/mp4",
173       "audio/mpeg",
174       "audio/ogg",
175       "audio/vorbis",
176       "audio/wav",
177       "audio/webm",
178       "audio/x-midi",
179       "image/gif",
180       "image/jpeg",
181       "image/png",
182       "video/avi",
183       "video/mp4",
184       "video/mpeg",
185       "video/ogg",
186     );
187
188     $url_info = Tweeper::getUrlInfo($url);
189     if (FALSE === $url_info) {
190       trigger_error("Failed to retrieve info for URL: " . $url, E_USER_WARNING);
191       return '';
192     }
193
194     $supported = in_array($url_info['content_type'], $supported_content_types);
195     if (!$supported) {
196       trigger_error("Unsupported enclosure content type \"" . $url_info['content_type'] . "\" for URL: " . $url_info['url'], E_USER_WARNING);
197       return '';
198     }
199
200     // The RSS specification says that the enclosure element URL must be http.
201     // See http://sourceforge.net/p/feedvalidator/bugs/72/
202     $http_url = preg_replace("/^https/", "http", $url_info['url']);
203
204     $dom = new DOMDocument();
205     $enc = $dom->createElement('enclosure');
206     $enc->setAttribute('url', $http_url);
207     $enc->setAttribute('length', $url_info['download_content_length']);
208     $enc->setAttribute('type', $url_info['content_type']);
209
210     return $enc;
211   }
212
213   /**
214    * Mimic the message from libxml.c::php_libxml_ctx_error_level()
215    */
216   private static function logXmlError($error) {
217     $output = "";
218
219     switch ($error->level) {
220       case LIBXML_ERR_WARNING:
221         $output .= "Warning $error->code: ";
222         break;
223
224       case LIBXML_ERR_ERROR:
225         $output .= "Error $error->code: ";
226         break;
227
228       case LIBXML_ERR_FATAL:
229         $output .= "Fatal Error $error->code: ";
230         break;
231     }
232
233     $output .= trim($error->message);
234
235     if ($error->file) {
236       $output .= " in $error->file";
237     }
238     else {
239       $output .= " in Entity,";
240     }
241
242     $output .= " line $error->line";
243
244     trigger_error($output, E_USER_WARNING);
245   }
246
247   /**
248    * Convert json to XML.
249    */
250   private static function jsonToXml($json, $root_node_name) {
251     // Apparently the ObjectNormalizer used afterwards is not able to handle
252     // the stdClass object created by json_decode() with the default setting
253     // $assoc = false; so use $assoc = true.
254     $data = json_decode($json, $assoc = TRUE);
255     if (!$data) {
256       return NULL;
257     }
258
259     $encoder = new XmlEncoder();
260     $normalizer = new ObjectNormalizer();
261     $serializer = new Serializer(array($normalizer), array($encoder));
262
263     $serializer_options = array(
264       'xml_encoding' => "UTF-8",
265       'xml_format_output' => TRUE,
266       'xml_root_node_name' => $root_node_name,
267     );
268
269     $xml_data = $serializer->serialize($data, 'xml', $serializer_options);
270     if (!$xml_data) {
271       trigger_error("Cannot serialize data", E_USER_WARNING);
272       return NULL;
273     }
274
275     return $xml_data;
276   }
277
278   /**
279    * Convert the Instagram content to XML.
280    */
281   private function getXmlInstagramCom($html) {
282     // Extract the json data from the html code.
283     $json_match_expr = '/window._sharedData = (.*);/';
284     $ret = preg_match($json_match_expr, $html, $matches);
285     if ($ret !== 1) {
286       trigger_error("Cannot match expression: $json_match_expr\n", E_USER_WARNING);
287       return NULL;
288     }
289
290     $data = json_decode($matches[1], $assoc = TRUE);
291
292     // The "qe" object contains elements which will result in invalid XML
293     // element names, so remove it.
294     unset($data["qe"]);
295
296     // The "knobs" object contains elements with undefined namespaces, so
297     // remove it to silence an error message.
298     unset($data["knobs"]);
299
300     $json = json_encode($data);
301
302     return Tweeper::jsonToXml($json, 'instagram');
303   }
304
305   /**
306    * Make the Facebook HTML processable.
307    */
308   private function preprocessHtmlFacebookCom($html) {
309     $html = str_replace('<!--', '', $html);
310     $html = str_replace('-->', '', $html);
311     return $html;
312   }
313
314   /**
315    * Convert the HTML retrieved from the site to XML.
316    */
317   private function htmlToXml($html, $host) {
318     $xmlDoc = new DOMDocument();
319
320     // Handle warnings and errors when loading invalid HTML.
321     $xml_errors_value = libxml_use_internal_errors(TRUE);
322
323     // If there is a host-specific method to get the XML data, use it!
324     $get_xml_host_method = 'getXml' . Tweeper::toUpperCamelCase($host, '.');
325     if (method_exists($this, $get_xml_host_method)) {
326       $xml_data = call_user_func_array(array($this, $get_xml_host_method), array($html));
327       $xmlDoc->loadXML($xml_data);
328     }
329     else {
330       $xmlDoc->loadHTML($html);
331     }
332
333     foreach (libxml_get_errors() as $xml_error) {
334       Tweeper::logXmlError($xml_error);
335     }
336     libxml_clear_errors();
337     libxml_use_internal_errors($xml_errors_value);
338
339     return $xmlDoc;
340   }
341
342   /**
343    * Load a stylesheet if the web site is supported.
344    */
345   private function loadStylesheet($host) {
346     $stylesheet = "file://" . __DIR__ . "/rss_converter_" . $host . ".xsl";
347     if (FALSE === file_exists($stylesheet)) {
348       trigger_error("Conversion to RSS not supported for $host ($stylesheet not found)", E_USER_WARNING);
349       return NULL;
350     }
351
352     $stylesheet_contents = Tweeper::getUrlContents($stylesheet);
353     if (FALSE === $stylesheet_contents) {
354       trigger_error("Cannot open $stylesheet", E_USER_WARNING);
355       return NULL;
356     }
357
358     $xslDoc = new DOMDocument();
359     $xslDoc->loadXML($stylesheet_contents);
360
361     $xsltProcessor = new XSLTProcessor();
362     $xsltProcessor->registerPHPFunctions();
363     $xsltProcessor->setParameter('', 'generate-enclosure', $this->generate_enclosure);
364     $xsltProcessor->setParameter('', 'show-usernames', $this->show_usernames);
365     $xsltProcessor->importStylesheet($xslDoc);
366
367     return $xsltProcessor;
368   }
369
370   /**
371    * Convert the site content to RSS.
372    */
373   public function tweep($src_url, $host=NULL, $validate_scheme=TRUE) {
374     $url = parse_url($src_url);
375     if (FALSE === $url) {
376       trigger_error("Invalid URL: $src_url", E_USER_WARNING);
377       return NULL;
378     }
379
380     if (TRUE === $validate_scheme) {
381       $scheme = $url["scheme"];
382       if (!in_array($scheme, array("http", "https"))) {
383         trigger_error("unsupported scheme: $scheme", E_USER_WARNING);
384         return NULL;
385       }
386     }
387
388     // if the host is not given derive it from the URL
389     if (NULL === $host) {
390       if (empty($url["host"])) {
391         trigger_error("Invalid host in URL: $src_url", E_USER_WARNING);
392         return NULL;
393       }
394       // Strip the leading www. to be more forgiving on input URLs.
395       $host = preg_replace('/^www\./', '', $url["host"]);
396     }
397
398     $xsltProcessor = $this->loadStylesheet($host);
399     if (NULL === $xsltProcessor) {
400       return NULL;
401     }
402
403     $html = Tweeper::getUrlContents($src_url);
404     if (FALSE === $html) {
405       trigger_error("Failed to retrieve $src_url", E_USER_WARNING);
406       return NULL;
407     }
408
409     $preprocess_html_host_method = 'preprocessHtml' . Tweeper::toUpperCamelCase($host, '.');
410     if (method_exists($this, $preprocess_html_host_method)) {
411       $html = call_user_func_array(array($this, $preprocess_html_host_method), array($html));
412     }
413
414     $xmlDoc = $this->htmlToXml($html, $host);
415     if (NULL === $xmlDoc) {
416       return NULL;
417     }
418
419     $output = $xsltProcessor->transformToXML($xmlDoc);
420     if (FALSE === $output) {
421       trigger_error('XSL transformation failed.', E_USER_WARNING);
422       return NULL;
423     }
424
425     return $output;
426   }
427
428 }