* @file
* Tweeper - a Twitter to RSS web scraper.
*
- * Copyright (C) 2013-2015 Antonio Ospite <ao2@ao2.it>
+ * Copyright (C) 2013-2020 Antonio Ospite <ao2@ao2.it>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
use DOMDocument;
use XSLTProcessor;
-require_once 'Symfony/Component/Serializer/autoload.php';
-
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
*/
class Tweeper {
- private static $userAgent = "Mozilla/5.0 (Windows NT 6.1; rv:22.0) Gecko/20130405 Firefox/22.0";
+ private static $userAgent = "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J)";
+ private static $maxConnectionTimeout = 5;
+ private static $maxConnectionRetries = 5;
/**
- * Constructor sets up {@link $generate_enclosure}.
+ * Create a new Tweeper object controlling optional settings.
+ *
+ * @param bool $generate_enclosure
+ * Enables the creation of <enclosure/> elements (disabled by default).
+ * @param bool $show_usernames
+ * Enables showing the username in front of the content for multi-user
+ * sites (enabled by default). Only some stylesheets supports this
+ * functionality (twitter, instagram, pump.io).
+ * @param bool $show_multimedia
+ * Enables showing multimedia content (images, videos) directly in the
+ * item description (enabled by default). Only some stylesheets supports
+ * this functionality (twitter, instagram, dilbert).
+ * @param bool $verbose_output
+ * Enables showing non-fatal errors like XML parsing errors.
*/
- public function __construct($generate_enclosure = FALSE) {
+ public function __construct($generate_enclosure = FALSE, $show_usernames = TRUE, $show_multimedia = TRUE, $verbose_output = TRUE) {
$this->generate_enclosure = $generate_enclosure;
+ $this->show_usernames = $show_usernames;
+ $this->show_multimedia = $show_multimedia;
+ $this->verbose_output = $verbose_output;
}
/**
}
/**
+ * Convert Twitter mobile date to the date format expected in a RSS document.
+ */
+ public static function twitterToRssDate($date) {
+ // Twitter uses relative timestamps in minutes for recent tweets.
+ if (preg_match('/^(\d+)m$/', $date, $matches)) {
+ $timestamp = strtotime("+" . $matches[1] . " min", time());
+ if (FALSE === $timestamp) {
+ $timestamp = 0;
+ }
+ }
+ else {
+ /*
+ * In case the time is specified put it after the date,
+ * to make it recognized by strptime().
+ */
+ if (preg_match('/(.*) - (.*)/', $date, $matches)) {
+ $date = $matches[2] . " " . $matches[1];
+ }
+
+ $timestamp = strtotime($date);
+ if (FALSE === $timestamp) {
+ $timestamp = 0;
+ }
+
+ /*
+ * The twitter mobile UI usually only specifies the month and the day, so
+ * strtotime($date) may interpret the date as being in the future.
+ *
+ * If the date is in the future it is probably in the same day but in the
+ * previous year.
+ */
+ if ($timestamp > time()) {
+ $timestamp = strtotime('-1 years', $timestamp);
+ }
+ }
+
+ return Tweeper::epochToRssDate($timestamp);
+ }
+
+ /**
* Convert string to UpperCamelCase.
*/
public static function toUpperCamelCase($str, $delim = ' ') {
}
/**
+ * Perform a cURL session multiple times when it fails with a timeout.
+ *
+ * @param resource $ch
+ * a cURL session handle.
+ */
+ private static function curlExec($ch) {
+ $ret = FALSE;
+ $attempt = 0;
+ do {
+ $ret = curl_exec($ch);
+ if (FALSE === $ret) {
+ trigger_error(curl_error($ch), E_USER_WARNING);
+ }
+ } while (curl_errno($ch) == CURLE_OPERATION_TIMEDOUT && ++$attempt < Tweeper::$maxConnectionRetries);
+
+ return $ret;
+ }
+
+ /**
* Get the contents from a URL.
*/
private static function getUrlContents($url) {
$ch = curl_init($url);
curl_setopt_array($ch, array(
CURLOPT_HEADER => FALSE,
+ CURLOPT_CONNECTTIMEOUT => Tweeper::$maxConnectionTimeout,
// Follow http redirects to get the real URL.
CURLOPT_FOLLOWLOCATION => TRUE,
+ CURLOPT_COOKIEFILE => "",
CURLOPT_RETURNTRANSFER => TRUE,
- CURLOPT_SSL_VERIFYHOST => FALSE,
- CURLOPT_SSL_VERIFYPEER => FALSE,
CURLOPT_HTTPHEADER => array('Accept-language: en'),
CURLOPT_USERAGENT => Tweeper::$userAgent,
));
- $contents = curl_exec($ch);
- if (FALSE === $contents) {
- trigger_error(curl_error($ch));
- }
+ $contents = Tweeper::curlExec($ch);
curl_close($ch);
return $contents;
curl_setopt_array($ch, array(
CURLOPT_HEADER => TRUE,
CURLOPT_NOBODY => TRUE,
+ CURLOPT_CONNECTTIMEOUT => Tweeper::$maxConnectionTimeout,
// Follow http redirects to get the real URL.
CURLOPT_FOLLOWLOCATION => TRUE,
CURLOPT_RETURNTRANSFER => TRUE,
- CURLOPT_SSL_VERIFYHOST => FALSE,
- CURLOPT_SSL_VERIFYPEER => FALSE,
CURLOPT_USERAGENT => Tweeper::$userAgent,
));
- curl_exec($ch);
+
+ $ret = Tweeper::curlExec($ch);
+ if (FALSE === $ret) {
+ curl_close($ch);
+ return FALSE;
+ }
+
$url_info = curl_getinfo($ch);
if (FALSE === $url_info) {
- trigger_error(curl_error($ch));
+ trigger_error(curl_error($ch), E_USER_WARNING);
}
curl_close($ch);
);
$url_info = Tweeper::getUrlInfo($url);
+ if (FALSE === $url_info) {
+ trigger_error("Failed to retrieve info for URL: " . $url, E_USER_WARNING);
+ return '';
+ }
$supported = in_array($url_info['content_type'], $supported_content_types);
if (!$supported) {
- error_log("Unsupported enclosure content type \"" . $url_info['content_type'] . "\" for URL: " . $url_info['url']);
+ trigger_error("Unsupported enclosure content type \"" . $url_info['content_type'] . "\" for URL: " . $url_info['url'], E_USER_WARNING);
return '';
}
// See http://sourceforge.net/p/feedvalidator/bugs/72/
$http_url = preg_replace("/^https/", "http", $url_info['url']);
+ // When the server does not provide a Content-Length header,
+ // curl_getinfo() would return a negative value for
+ // "download_content_length", however RSS recommends to use 0 when the
+ // enclosure's size cannot be determined.
+ // See: https://www.feedvalidator.org/docs/error/UseZeroForUnknown.html
+ $length = max($url_info['download_content_length'], 0);
+
$dom = new DOMDocument();
$enc = $dom->createElement('enclosure');
$enc->setAttribute('url', $http_url);
- $enc->setAttribute('length', $url_info['download_content_length']);
+ $enc->setAttribute('length', $length);
$enc->setAttribute('type', $url_info['content_type']);
return $enc;
$output .= " line $error->line";
- error_log($output);
+ trigger_error($output, E_USER_WARNING);
}
/**
$xml_data = $serializer->serialize($data, 'xml', $serializer_options);
if (!$xml_data) {
- trigger_error("Cannot serialize data", E_USER_ERROR);
+ trigger_error("Cannot serialize data", E_USER_WARNING);
return NULL;
}
$json_match_expr = '/window._sharedData = (.*);/';
$ret = preg_match($json_match_expr, $html, $matches);
if ($ret !== 1) {
- trigger_error("Cannot match expression: $json_match_expr\n", E_USER_ERROR);
+ trigger_error("Cannot match expression: $json_match_expr\n", E_USER_WARNING);
return NULL;
}
- return Tweeper::jsonToXml($matches[1], 'instagram');
+ $data = json_decode($matches[1], $assoc = TRUE);
+
+ // The "qe" object contains elements which will result in invalid XML
+ // element names, so remove it.
+ unset($data["qe"]);
+
+ // The "knobs" object contains elements with undefined namespaces, so
+ // remove it to silence an error message.
+ unset($data["knobs"]);
+
+ $json = json_encode($data);
+
+ return Tweeper::jsonToXml($json, 'instagram');
}
/**
$xmlDoc->loadHTML($html);
}
- foreach (libxml_get_errors() as $xml_error) {
- Tweeper::logXmlError($xml_error);
+ if ($this->verbose_output) {
+ foreach (libxml_get_errors() as $xml_error) {
+ Tweeper::logXmlError($xml_error);
+ }
}
libxml_clear_errors();
libxml_use_internal_errors($xml_errors_value);
private function loadStylesheet($host) {
$stylesheet = "file://" . __DIR__ . "/rss_converter_" . $host . ".xsl";
if (FALSE === file_exists($stylesheet)) {
- trigger_error("Conversion to RSS not supported for $host ($stylesheet not found)", E_USER_ERROR);
+ trigger_error("Conversion to RSS not supported for $host ($stylesheet not found)", E_USER_WARNING);
return NULL;
}
$stylesheet_contents = Tweeper::getUrlContents($stylesheet);
+ if (FALSE === $stylesheet_contents) {
+ trigger_error("Cannot open $stylesheet", E_USER_WARNING);
+ return NULL;
+ }
$xslDoc = new DOMDocument();
$xslDoc->loadXML($stylesheet_contents);
$xsltProcessor = new XSLTProcessor();
$xsltProcessor->registerPHPFunctions();
$xsltProcessor->setParameter('', 'generate-enclosure', $this->generate_enclosure);
+ $xsltProcessor->setParameter('', 'show-usernames', $this->show_usernames);
+ $xsltProcessor->setParameter('', 'show-multimedia', $this->show_multimedia);
$xsltProcessor->importStylesheet($xslDoc);
return $xsltProcessor;
/**
* Convert the site content to RSS.
*/
- public function tweep($src_url) {
+ public function tweep($src_url, $host = NULL, $validate_scheme = TRUE) {
$url = parse_url($src_url);
- if (FALSE === $url || empty($url["host"])) {
- trigger_error("Invalid URL: $src_url", E_USER_ERROR);
+ if (FALSE === $url) {
+ trigger_error("Invalid URL: $src_url", E_USER_WARNING);
return NULL;
}
- $scheme = $url["scheme"];
- if (!in_array($scheme, array("http", "https"))) {
- trigger_error("unsupported scheme: $scheme", E_USER_ERROR);
- return NULL;
+ if (TRUE === $validate_scheme) {
+ $scheme = $url["scheme"];
+ if (!in_array($scheme, array("http", "https"))) {
+ trigger_error("unsupported scheme: $scheme", E_USER_WARNING);
+ return NULL;
+ }
}
- // Strip the leading www. to be more forgiving on input URLs.
- $host = preg_replace('/^www\./', '', $url["host"]);
+ // If the host is not given derive it from the URL.
+ if (NULL === $host) {
+ if (empty($url["host"])) {
+ trigger_error("Invalid host in URL: $src_url", E_USER_WARNING);
+ return NULL;
+ }
+ // Strip the leading www. to be more forgiving on input URLs.
+ $host = preg_replace('/^www\./', '', $url["host"]);
+ }
$xsltProcessor = $this->loadStylesheet($host);
if (NULL === $xsltProcessor) {
$html = Tweeper::getUrlContents($src_url);
if (FALSE === $html) {
+ trigger_error("Failed to retrieve $src_url", E_USER_WARNING);
return NULL;
}
}
$output = $xsltProcessor->transformToXML($xmlDoc);
-
if (FALSE === $output) {
- trigger_error('XSL transformation failed.', E_USER_ERROR);
+ trigger_error('XSL transformation failed.', E_USER_WARNING);
return NULL;
}
+
return $output;
}