beta4 changes (#26)
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
This commit was merged in pull request #26.
This commit is contained in:
+121
-97
@@ -1,25 +1,34 @@
|
||||
<?php declare(strict_types=1);
|
||||
<?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;
|
||||
|
||||
class ParsedFeed
|
||||
/**
|
||||
* A feed, as parsed from the Atom or RSS XML
|
||||
*/
|
||||
readonly class ParsedFeed
|
||||
{
|
||||
/** @var string The URL for the feed */
|
||||
public string $url = '';
|
||||
|
||||
/** @var string The title of the feed */
|
||||
public string $title = '';
|
||||
|
||||
/** @var ?string When the feed was last updated */
|
||||
public ?string $updatedOn = null;
|
||||
|
||||
/** @var ParsedItem[] The items contained in the feed */
|
||||
public array $items = [];
|
||||
/**
|
||||
* 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';
|
||||
@@ -42,8 +51,9 @@ class ParsedFeed
|
||||
* @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) {
|
||||
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;
|
||||
@@ -53,16 +63,17 @@ class ParsedFeed
|
||||
* Parse a feed into an XML tree
|
||||
*
|
||||
* @param string $content The feed's RSS content
|
||||
* @return array|DOMDocument[]|string[] ['ok' => feed] if successful, ['error' => message] if not
|
||||
* @return Result<DOMDocument, string> The feed if successful, an error message if not
|
||||
*/
|
||||
public static function parseFeed(string $content): array {
|
||||
public static function parseFeed(string $content): Result
|
||||
{
|
||||
set_error_handler(self::xmlParseError(...));
|
||||
try {
|
||||
$feed = new DOMDocument();
|
||||
$feed->loadXML($content);
|
||||
return ['ok' => $feed];
|
||||
return Result::OK($feed);
|
||||
} catch (DOMException $ex) {
|
||||
return ['error' => $ex->getMessage()];
|
||||
return Result::Error($ex->getMessage());
|
||||
} finally {
|
||||
restore_error_handler();
|
||||
}
|
||||
@@ -75,9 +86,10 @@ class ParsedFeed
|
||||
* @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 {
|
||||
public static function rssValue(DOMNode $element, string $tagName): string
|
||||
{
|
||||
$tags = $element->getElementsByTagName($tagName);
|
||||
return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent;
|
||||
return $tags->length === 0 ? "$tagName not found" : $tags->item(0)->textContent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,13 +97,14 @@ class ParsedFeed
|
||||
*
|
||||
* @param DOMDocument $xml The XML received from the feed
|
||||
* @param string $url The actual URL for the feed
|
||||
* @return array|Feed[]|string[] ['ok' => feed] if successful, ['error' => message] if not
|
||||
* @return Result<ParsedFeed, string> The feed if successful, an error message if not
|
||||
*/
|
||||
private static function fromRSS(DOMDocument $xml, string $url): array {
|
||||
private static function fromRSS(DOMDocument $xml, string $url): Result
|
||||
{
|
||||
$channel = $xml->getElementsByTagName('channel')->item(0);
|
||||
if (!($channel instanceof DOMElement)) {
|
||||
$type = $channel?->nodeType ?? -1;
|
||||
return ['error' => "Channel element not found ($type)"];
|
||||
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
|
||||
@@ -102,13 +115,11 @@ class ParsedFeed
|
||||
}
|
||||
}
|
||||
|
||||
$feed = new static();
|
||||
$feed->title = self::rssValue($channel, 'title');
|
||||
$feed->url = $url;
|
||||
$feed->updatedOn = Data::formatDate($updatedOn);
|
||||
foreach ($channel->getElementsByTagName('item') as $item) $feed->items[] = ParsedItem::fromRSS($item);
|
||||
|
||||
return ['ok' => $feed];
|
||||
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')))));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,10 +129,11 @@ class ParsedFeed
|
||||
* @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 {
|
||||
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
|
||||
*
|
||||
@@ -132,14 +144,15 @@ class ParsedFeed
|
||||
* @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 {
|
||||
public static function atomValue(DOMNode $element, string $tagName): string
|
||||
{
|
||||
$tags = $element->getElementsByTagName($tagName);
|
||||
if ($tags->length == 0) return "$tagName not found";
|
||||
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 --";
|
||||
if ($div->length === 0) return "-- invalid XHTML content --";
|
||||
return $div->item(0)->textContent;
|
||||
}
|
||||
return $tag->textContent;
|
||||
@@ -150,103 +163,114 @@ class ParsedFeed
|
||||
*
|
||||
* @param DOMDocument $xml The XML received from the feed
|
||||
* @param string $url The actual URL for the feed
|
||||
* @return array|Feed[] ['ok' => feed]
|
||||
* @return Result<ParsedFeed, string> The feed (does not have any error handling)
|
||||
*/
|
||||
private static function fromAtom(DOMDocument $xml, string $url): array {
|
||||
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;
|
||||
|
||||
$feed = new static();
|
||||
$feed->title = self::atomValue($root, 'title');
|
||||
$feed->url = $url;
|
||||
$feed->updatedOn = Data::formatDate($updatedOn);
|
||||
foreach ($root->getElementsByTagName('entry') as $entry) $feed->items[] = ParsedItem::fromAtom($entry);
|
||||
|
||||
return ['ok' => $feed];
|
||||
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 array ['content' => document content, 'error' => error message, 'code' => HTTP response code,
|
||||
* 'url' => effective URL]
|
||||
* @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): array {
|
||||
private static function retrieveDocument(string $url): Result
|
||||
{
|
||||
$docReq = curl_init($url);
|
||||
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);
|
||||
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);
|
||||
|
||||
$result = [
|
||||
'content' => curl_exec($docReq),
|
||||
'error' => curl_error($docReq),
|
||||
'code' => curl_getinfo($docReq, CURLINFO_RESPONSE_CODE),
|
||||
'url' => curl_getinfo($docReq, CURLINFO_EFFECTIVE_URL)
|
||||
];
|
||||
$error = curl_error($docReq);
|
||||
if ($error !== '') return Result::Error($error);
|
||||
|
||||
curl_close($docReq);
|
||||
return $result;
|
||||
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 array|string[] ['ok' => feed URL] if successful, ['error' => message] if not
|
||||
* @return Result<string, string> The feed URL if successful, an error message if not
|
||||
*/
|
||||
private static function deriveFeedFromHTML(string $content): array {
|
||||
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 ['error' => 'Cannot find feed at this URL'];
|
||||
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') {
|
||||
if (self::attrValue($link, 'rel') === 'alternate') {
|
||||
$type = self::attrValue($link, 'type');
|
||||
if ($type == 'application/rss+xml' || $type == 'application/atom+xml') {
|
||||
return ['ok' => self::attrValue($link, 'href')];
|
||||
if ($type === 'application/rss+xml' || $type === 'application/atom+xml') {
|
||||
return Result::OK(self::attrValue($link, 'href'));
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['error' => 'Cannot find feed at this URL'];
|
||||
return Result::Error('Cannot find feed at this URL');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the feed
|
||||
*
|
||||
* @param string $url The URL of the feed to retrieve
|
||||
* @return array|ParsedFeed[]|string[] ['ok' => feed] if successful, ['error' => message] if not
|
||||
* @return Result<ParsedFeed, string> The feed if successful, an error message if not
|
||||
*/
|
||||
public static function retrieve(string $url): array {
|
||||
$doc = self::retrieveDocument($url);
|
||||
|
||||
if ($doc['error'] != '') return ['error' => $doc['error']];
|
||||
if ($doc['code'] != 200) {
|
||||
return ['error' => "Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"];
|
||||
}
|
||||
|
||||
$start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']);
|
||||
if ($start == '<!doctype' || str_starts_with($start, '<html')) {
|
||||
$derivedURL = self::deriveFeedFromHTML($doc['content']);
|
||||
if (key_exists('error', $derivedURL)) return ['error' => $derivedURL['error']];
|
||||
$feedURL = $derivedURL['ok'];
|
||||
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;
|
||||
}
|
||||
$doc = self::retrieveDocument($feedURL);
|
||||
}
|
||||
|
||||
$parsed = self::parseFeed($doc['content']);
|
||||
if (key_exists('error', $parsed)) return ['error' => $parsed['error']];
|
||||
|
||||
$extract = $parsed['ok']->getElementsByTagNameNS(self::ATOM_NS, 'feed')->length > 0
|
||||
? self::fromAtom(...) : self::fromRSS(...);
|
||||
return $extract($parsed['ok'], $doc['url']);
|
||||
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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user