These changes are mostly in underlying libraries; however, this now uses the [inspired by F#](https://git.bitbadger.solutions/bit-badger/inspired-by-fsharp) library to handle the feed parsing pipeline and optional return values Reviewed-on: #26
277 lines
11 KiB
PHP
277 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
|
* @license MIT
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace FeedReaderCentral;
|
|
|
|
use BitBadger\InspiredByFSharp\Result;
|
|
use DOMDocument;
|
|
use DOMElement;
|
|
use DOMException;
|
|
use DOMNode;
|
|
|
|
/**
|
|
* A feed, as parsed from the Atom or RSS XML
|
|
*/
|
|
readonly class ParsedFeed
|
|
{
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param string $url The URL for the feed
|
|
* @param string $title The title of the feed
|
|
* @param string|null $updatedOn When the feed was last updated
|
|
* @param ParsedItem[] $items The items contained in the feed
|
|
*/
|
|
public function __construct(public string $url = '', public string $title = '', public ?string $updatedOn = null,
|
|
public array $items = []) {}
|
|
|
|
/** @var string The XML namespace for Atom feeds */
|
|
public const ATOM_NS = 'http://www.w3.org/2005/Atom';
|
|
|
|
/** @var string The XML namespace for the `<content:encoded>` tag that allows HTML content in a feed */
|
|
public const CONTENT_NS = 'http://purl.org/rss/1.0/modules/content/';
|
|
|
|
/** @var string The XML namespace for XHTML */
|
|
public const XHTML_NS = 'http://www.w3.org/1999/xhtml';
|
|
|
|
/** @var string The user agent for Feed Reader Central's refresh requests */
|
|
private const USER_AGENT =
|
|
'FeedReaderCentral/' . FRC_VERSION . ' +https://bitbadger.solutions/open-source/feed-reader-central';
|
|
|
|
/**
|
|
* When parsing XML into a DOMDocument, errors are presented as warnings; this creates an exception for them
|
|
*
|
|
* @param int $errno The error level encountered
|
|
* @param string $errstr The text of the error encountered
|
|
* @return bool False, to delegate to the next error handler in the chain
|
|
* @throws DOMException If the error is a warning
|
|
*/
|
|
private static function xmlParseError(int $errno, string $errstr): bool
|
|
{
|
|
if ($errno === E_WARNING && substr_count($errstr, 'DOMDocument::loadXML()') > 0) {
|
|
throw new DOMException($errstr, $errno);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Parse a feed into an XML tree
|
|
*
|
|
* @param string $content The feed's RSS content
|
|
* @return Result<DOMDocument, string> The feed if successful, an error message if not
|
|
*/
|
|
public static function parseFeed(string $content): Result
|
|
{
|
|
set_error_handler(self::xmlParseError(...));
|
|
try {
|
|
$feed = new DOMDocument();
|
|
$feed->loadXML($content);
|
|
return Result::OK($feed);
|
|
} catch (DOMException $ex) {
|
|
return Result::Error($ex->getMessage());
|
|
} finally {
|
|
restore_error_handler();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the value of a child element by its tag name for an RSS feed
|
|
*
|
|
* @param DOMNode $element The parent element
|
|
* @param string $tagName The name of the tag whose value should be obtained
|
|
* @return string The value of the element (or "[element] not found" if that element does not exist)
|
|
*/
|
|
public static function rssValue(DOMNode $element, string $tagName): string
|
|
{
|
|
$tags = $element->getElementsByTagName($tagName);
|
|
return $tags->length === 0 ? "$tagName not found" : $tags->item(0)->textContent;
|
|
}
|
|
|
|
/**
|
|
* Extract items from an RSS feed
|
|
*
|
|
* @param DOMDocument $xml The XML received from the feed
|
|
* @param string $url The actual URL for the feed
|
|
* @return Result<ParsedFeed, string> The feed if successful, an error message if not
|
|
*/
|
|
private static function fromRSS(DOMDocument $xml, string $url): Result
|
|
{
|
|
$channel = $xml->getElementsByTagName('channel')->item(0);
|
|
if (!($channel instanceof DOMElement)) {
|
|
$type = $channel?->nodeType ?? -1;
|
|
return Result::Error("Channel element not found ($type)");
|
|
}
|
|
|
|
// The Atom namespace provides a lastBuildDate, which contains the last time an item in the feed was updated; if
|
|
// that is not present, use the pubDate element instead
|
|
if (($updatedOn = self::rssValue($channel, 'lastBuildDate')) == 'lastBuildDate not found') {
|
|
if (($updatedOn = self::rssValue($channel, 'pubDate')) == 'pubDate not found') {
|
|
$updatedOn = null;
|
|
}
|
|
}
|
|
|
|
return Result::OK(new self(
|
|
url: $url,
|
|
title: self::rssValue($channel, 'title'),
|
|
updatedOn: Data::formatDate($updatedOn),
|
|
items: array_map(ParsedItem::fromRSS(...), iterator_to_array($channel->getElementsByTagName('item')))));
|
|
}
|
|
|
|
/**
|
|
* Get an attribute value from a DOM node
|
|
*
|
|
* @param DOMNode $node The node with an attribute value to obtain
|
|
* @param string $name The name of the attribute whose value should be obtained
|
|
* @return string The attribute value if it exists, an empty string if not
|
|
*/
|
|
private static function attrValue(DOMNode $node, string $name): string
|
|
{
|
|
return ($node->hasAttributes() ? $node->attributes->getNamedItem($name)?->value : null) ?? '';
|
|
}
|
|
|
|
/**
|
|
* Get the value of a child element by its tag name for an Atom feed
|
|
*
|
|
* (Atom feeds can have type attributes on nearly any value. For our purposes, types "text" and "html" will work as
|
|
* regular string values; for "xhtml", though, we will need to get the `<div>` and extract its contents instead.)
|
|
*
|
|
* @param DOMNode $element The parent element
|
|
* @param string $tagName The name of the tag whose value should be obtained
|
|
* @return string The value of the element (or "[element] not found" if that element does not exist)
|
|
*/
|
|
public static function atomValue(DOMNode $element, string $tagName): string
|
|
{
|
|
$tags = $element->getElementsByTagName($tagName);
|
|
if ($tags->length === 0) return "$tagName not found";
|
|
$tag = $tags->item(0);
|
|
if (!($tag instanceof DOMElement)) return $tag->textContent;
|
|
if (self::attrValue($tag, 'type') == 'xhtml') {
|
|
$div = $tag->getElementsByTagNameNS(self::XHTML_NS, 'div');
|
|
if ($div->length === 0) return "-- invalid XHTML content --";
|
|
return $div->item(0)->textContent;
|
|
}
|
|
return $tag->textContent;
|
|
}
|
|
|
|
/**
|
|
* Extract items from an Atom feed
|
|
*
|
|
* @param DOMDocument $xml The XML received from the feed
|
|
* @param string $url The actual URL for the feed
|
|
* @return Result<ParsedFeed, string> The feed (does not have any error handling)
|
|
*/
|
|
private static function fromAtom(DOMDocument $xml, string $url): Result
|
|
{
|
|
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
|
|
if (($updatedOn = self::atomValue($root, 'updated')) == 'pubDate not found') $updatedOn = null;
|
|
|
|
return Result::OK(new self(
|
|
url: $url,
|
|
title: self::atomValue($root, 'title'),
|
|
updatedOn: Data::formatDate($updatedOn),
|
|
items: array_map(ParsedItem::fromAtom(...), iterator_to_array($root->getElementsByTagName('entry')))));
|
|
}
|
|
|
|
/**
|
|
* Retrieve a document (http/https)
|
|
*
|
|
* @param string $url The URL of the document to retrieve
|
|
* @return Result<array, string> ['content' => doc content, 'code' => HTTP response code, 'url' => effective URL] if
|
|
* successful, an error message if not
|
|
*/
|
|
private static function retrieveDocument(string $url): Result
|
|
{
|
|
$docReq = curl_init($url);
|
|
try {
|
|
curl_setopt($docReq, CURLOPT_FOLLOWLOCATION, true);
|
|
curl_setopt($docReq, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($docReq, CURLOPT_CONNECTTIMEOUT, 5);
|
|
curl_setopt($docReq, CURLOPT_TIMEOUT, 15);
|
|
curl_setopt($docReq, CURLOPT_USERAGENT, self::USER_AGENT);
|
|
|
|
$error = curl_error($docReq);
|
|
if ($error !== '') return Result::Error($error);
|
|
|
|
return Result::OK([
|
|
'content' => curl_exec($docReq),
|
|
'code' => curl_getinfo($docReq, CURLINFO_RESPONSE_CODE),
|
|
'url' => curl_getinfo($docReq, CURLINFO_EFFECTIVE_URL)
|
|
]);
|
|
} finally {
|
|
curl_close($docReq);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Derive a feed URL from an HTML document
|
|
*
|
|
* @param string $content The HTML document content from which to derive a feed URL
|
|
* @return Result<string, string> The feed URL if successful, an error message if not
|
|
*/
|
|
private static function deriveFeedFromHTML(string $content): Result
|
|
{
|
|
$html = new DOMDocument();
|
|
$html->loadHTML(substr($content, 0, strpos($content, '</head>') + 7));
|
|
$headTags = $html->getElementsByTagName('head');
|
|
if ($headTags->length < 1) return Result::Error('Cannot find feed at this URL');
|
|
$head = $headTags->item(0);
|
|
foreach ($head->getElementsByTagName('link') as $link) {
|
|
if (self::attrValue($link, 'rel') === 'alternate') {
|
|
$type = self::attrValue($link, 'type');
|
|
if ($type === 'application/rss+xml' || $type === 'application/atom+xml') {
|
|
return Result::OK(self::attrValue($link, 'href'));
|
|
}
|
|
}
|
|
}
|
|
return Result::Error('Cannot find feed at this URL');
|
|
}
|
|
|
|
/**
|
|
* Retrieve the feed
|
|
*
|
|
* @param string $url The URL of the feed to retrieve
|
|
* @return Result<ParsedFeed, string> The feed if successful, an error message if not
|
|
*/
|
|
public static function retrieve(string $url): Result
|
|
{
|
|
$doc = self::retrieveDocument($url)
|
|
->bind(fn(array $doc) => match ($doc['code']) {
|
|
200 => Result::OK($doc),
|
|
default => Result::Error(
|
|
"Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"),
|
|
})
|
|
->bind(function (array $doc) use ($url) {
|
|
$start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']);
|
|
return $start === '<!doctype' || str_starts_with($start, '<html')
|
|
? self::deriveFeedFromHTML($doc['content'])
|
|
->bind(function (string $feedURL) use ($url) {
|
|
if (!str_starts_with($feedURL, 'http')) {
|
|
// Relative URL; feed should be retrieved in the context of the original URL
|
|
$original = parse_url($url);
|
|
$port = key_exists('port', $original) ? ":{$original['port']}" : '';
|
|
$feedURL = $original['scheme'] . '://' . $original['host'] . $port . $feedURL;
|
|
}
|
|
return self::retrieveDocument($feedURL);
|
|
})
|
|
->bind(fn($doc) => match ($doc['code']) {
|
|
200 => Result::OK($doc),
|
|
default => Result::Error(
|
|
"Derived feed URL {$doc['url']} returned HTTP Code {$doc['code']}: {$doc['content']}"),
|
|
})
|
|
: Result::OK($doc);
|
|
});
|
|
return $doc
|
|
->bind(fn($doc) => self::parseFeed($doc['content']))
|
|
->bind(function (DOMDocument $parsed) use ($doc) {
|
|
$extract = $parsed->getElementsByTagNameNS(self::ATOM_NS, 'feed')->length > 0
|
|
? self::fromAtom(...) : self::fromRSS(...);
|
|
return $extract($parsed, $doc->getOK()['url']);
|
|
});
|
|
}
|
|
}
|