Change from SimpleXML to DOM (#4)

This API is more reliable, and should help when implementing the "load a site's HTML and look for feed links" functionality coming before the final release
This commit is contained in:
Daniel J. Summers 2024-04-10 20:50:45 -04:00
parent 0530ed0dc9
commit 8ca4bf2109
2 changed files with 115 additions and 50 deletions

View File

@ -99,8 +99,23 @@ class Data {
$query->execute(); $query->execute();
} }
/**
* Parse/format a date/time from a string
*
* @param ?string $value The date/time to be parsed and formatted
* @return string|null The date/time in `DateTimeInterface::ATOM` format, or `null` if the input cannot be parsed
*/
private static function formatDate(?string $value): ?string {
try {
return $value ? (new DateTimeImmutable($value))->format(DateTimeInterface::ATOM) : null;
} catch (Exception) {
return null;
}
}
/** /**
* Add an RSS feed * Add an RSS feed
*
* @param string $url The URL for the RSS feed * @param string $url The URL for the RSS feed
* @param string $title The title of the RSS feed * @param string $title The title of the RSS feed
* @param string $updatedOn The date/time the RSS feed was last updated (from the XML, not when we checked) * @param string $updatedOn The date/time the RSS feed was last updated (from the XML, not when we checked)
@ -108,28 +123,25 @@ class Data {
*/ */
public static function addFeed(string $url, string $title, string $updatedOn): int { public static function addFeed(string $url, string $title, string $updatedOn): int {
$db = self::getConnection(); $db = self::getConnection();
if ($updatedOn) { $query = $db->prepare(<<<'SQL'
try { INSERT INTO feed (
$updated = (new DateTimeImmutable($updatedOn))->format(DateTimeInterface::ATOM); user_id, url, title, updated_on, checked_on
} catch (Exception) { ) VALUES (
$updated = null; :user, :url, :title, :updated, :checked
} )
} else { SQL);
$updated = null; $query->bindValue(':user', $_REQUEST['FRC_USER_ID']);
} $query->bindValue(':url', $url);
$query = $db->prepare('INSERT INTO feed (user_id, url, title, updated_on, checked_on)' $query->bindValue(':title', $title);
. ' VALUES (:user, :url, :title, :updated, :checked)'); $query->bindValue(':updated', self::formatDate($updatedOn));
$query->bindValue(':user', $_REQUEST['FRC_USER_ID']); $query->bindValue(':checked', self::formatDate('now'));
$query->bindValue(':url', $url);
$query->bindValue(':title', $title);
$query->bindValue(':updated', $updated);
$query->bindValue(':checked', (new DateTimeImmutable())->format(DateTimeInterface::ATOM));
$result = $query->execute(); $result = $query->execute();
return $result ? $db->lastInsertRowID() : -1; return $result ? $db->lastInsertRowID() : -1;
} }
/** /**
* Does a feed item already exist? * Does a feed item already exist?
*
* @param int $feedId The ID of the feed to which the item belongs * @param int $feedId The ID of the feed to which the item belongs
* @param string $guid The GUID from the RSS feed, uniquely identifying the item * @param string $guid The GUID from the RSS feed, uniquely identifying the item
* @return bool True if the item exists, false if not * @return bool True if the item exists, false if not
@ -145,28 +157,34 @@ class Data {
/** /**
* Add a feed item * Add a feed item
*
* @param int $feedId The ID of the feed to which the item should be added * @param int $feedId The ID of the feed to which the item should be added
* @param string $guid The GUID from the RSS feed (uses link if `<guid>` not specified) * @param string $guid The GUID from the RSS feed (uses link if `<guid>` not specified)
* @param string $link The link to this item * @param string $link The link to this item
* @param string $title The title of the item * @param string $title The title of the item
* @param string $published The date/time the item was published * @param string $publishedOn The date/time the item was published
* @param ?string $updatedOn The date/time the item was last updated
* @param string $content The content of the item * @param string $content The content of the item
* @param bool $isEncoded Whether the content has HTML (true) or is plaintext (false) * @param bool $isEncoded Whether the content has HTML (true) or is plaintext (false)
* @throws Exception If the published date is not valid
*/ */
public static function addItem(int $feedId, string $guid, string $link, string $title, string $published, public static function addItem(int $feedId, string $guid, string $link, string $title, string $publishedOn,
string $content, bool $isEncoded): void { ?string $updatedOn, string $content, bool $isEncoded): void {
$db = self::getConnection(); $db = self::getConnection();
$query = $db->prepare( $query = $db->prepare(<<<'SQL'
'INSERT INTO item (feed_id, item_guid, item_link, title, published_on, content, is_encoded)' INSERT INTO item (
. ' VALUES (:feed, :guid, :link, :title, :published, :content, :encoded)'); feed_id, item_guid, item_link, title, published_on, updated_on, content, is_encoded
$query->bindValue(':feed', $feedId); ) VALUES (
$query->bindValue(':guid', $guid); :feed, :guid, :link, :title, :published, :updated, :content, :encoded
$query->bindValue(':link', $link); )
$query->bindValue(':title', $title); SQL);
$query->bindValue(':published', (new DateTimeImmutable($published))->format(DateTimeInterface::ATOM)); $query->bindValue(':feed', $feedId);
$query->bindValue(':content', $content); $query->bindValue(':guid', $guid);
$query->bindValue(':encoded', $isEncoded); $query->bindValue(':link', $link);
$query->bindValue(':title', $title);
$query->bindValue(':published', self::formatDate($publishedOn));
$query->bindValue(':updated', self::formatDate($updatedOn));
$query->bindValue(':content', $content);
$query->bindValue(':encoded', $isEncoded);
$query->execute(); $query->execute();
} }
} }

View File

@ -4,23 +4,52 @@
*/ */
class Feed { class Feed {
/** @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>` tag that allows HTML content in a feed */
public const CONTENT_NS = 'http://purl.org/rss/1.0/modules/content/';
/**
* 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 * Parse a feed into an XML tree
*
* @param string $content The feed's RSS content * @param string $content The feed's RSS content
* @return array|SimpleXMLElement[]|string[] [ 'ok' => feed ] if successful, [ 'error' => message] if not * @return array|DOMDocument[]|string[] [ 'ok' => feed ] if successful, [ 'error' => message] if not
*/ */
public static function parseFeed(string $content): array { public static function parseFeed(string $content): array {
set_error_handler(self::xmlParseError(...));
try { try {
return [ 'ok' => new SimpleXMLElement($content) ]; $feed = new DOMDocument();
} catch (Exception $ex) { $feed->loadXML($content);
return [ 'ok' => $feed ];
} catch (DOMException $ex) {
return [ 'error' => $ex->getMessage() ]; return [ 'error' => $ex->getMessage() ];
} finally {
restore_error_handler();
} }
} }
/** /**
* Retrieve the feed * Retrieve the feed
*
* @param string $url * @param string $url
* @return array|SimpleXMLElement[]|string[] [ 'ok' => feedXml, 'url' => actualUrl ] if successful, [ 'error' => message ] if not * @return array|DOMDocument[]|string[] [ 'ok' => feedXml, 'url' => actualUrl ] if successful,
* [ 'error' => message ] if not
*/ */
public static function retrieveFeed(string $url): array { public static function retrieveFeed(string $url): array {
$feedReq = curl_init($url); $feedReq = curl_init($url);
@ -52,32 +81,46 @@ class Feed {
return $result; return $result;
} }
/**
* Get the value of a child element by its tag name
*
* @param DOMElement $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)
*/
private static function eltValue(DOMElement $element, string $tagName): string {
$tags = $element->getElementsByTagName($tagName);
return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent;
}
/** /**
* Update a feed's items * Update a feed's items
*
* @param int $feedId The ID of the feed to which these items belong * @param int $feedId The ID of the feed to which these items belong
* @param SimpleXMLElement $channel The RSS feed items * @param DOMElement $channel The RSS feed items
* @return array [ 'ok' => true ] if successful, [ 'error' => message ] if not * @return array [ 'ok' => true ] if successful, [ 'error' => message ] if not
*/ */
public static function updateItems(int $feedId, SimpleXMLElement $channel): array { public static function updateItems(int $feedId, DOMElement $channel): array {
try { try {
for ($i = 0; $i < sizeof($channel->item); $i++) { foreach ($channel->getElementsByTagName('item') as $item) {
$item = $channel->item[$i]; $itemGuid = self::eltValue($item, 'guid');
$itemGuid = (string)$item->guid ? $item->guid : $item->link; if ($itemGuid == 'guid not found') $itemGuid = self::eltValue($item, 'link');
$isNew = !Data::itemExists($feedId, $itemGuid); $isNew = !Data::itemExists($feedId, $itemGuid);
if ($isNew) { if ($isNew) {
$title = (string)$item->title; $title = self::eltValue($item, 'title');
$link = (string)$item->link; $link = self::eltValue($item, 'link');
$published = (string)$item->pubDate; $published = self::eltValue($item, 'pubDate');
// TODO: why is this getting all encoded content, and not just the one for the current item? $updNodes = $item->getElementsByTagNameNS(self::ATOM_NS, 'updated');
$encodedContent = $item->xpath('//content:encoded'); $updated = $updNodes->length > 0 ? $updNodes->item(0)->textContent : null;
if ($encodedContent) { $encNodes = $item->getElementsByTagNameNS(self::CONTENT_NS, 'encoded');
$content = (string) $encodedContent[$i]; if ($encNodes->length > 0) {
$content = $encNodes->item(0)->textContent;
$isEncoded = true; $isEncoded = true;
} else { } else {
$content = $item->description; $content = self::eltValue($item, 'description');
$isEncoded = false; $isEncoded = false;
} }
Data::addItem($feedId, $itemGuid, $link, $title, $published, $content, $isEncoded); Data::addItem($feedId, $itemGuid, $link, $title, $published, $updated, $content, $isEncoded);
} // TODO: else check updated date; may want to return that from the isNew check instead } // TODO: else check updated date; may want to return that from the isNew check instead
} }
} catch (Exception $ex) { } catch (Exception $ex) {
@ -88,6 +131,7 @@ class Feed {
/** /**
* Add an RSS feed * Add an RSS feed
*
* @param string $url The URL of the RSS feed to add * @param string $url The URL of the RSS feed to add
* @return array [ 'ok' => true ] if successful, [ 'error' => message ] if not * @return array [ 'ok' => true ] if successful, [ 'error' => message ] if not
*/ */
@ -95,8 +139,11 @@ class Feed {
$feed = self::retrieveFeed($url); $feed = self::retrieveFeed($url);
if (array_key_exists('error', $feed)) return $feed; if (array_key_exists('error', $feed)) return $feed;
$channel = $feed['ok']->channel; $channel = $feed['ok']->getElementsByTagName('channel')->item(0);
$feedId = Data::addFeed($feed['url'], (string) $channel->title, (string) $channel->lastBuildDate); if (!$channel instanceof DOMElement) return [ 'error' => "Channel element not found ($channel->nodeType)" ];
$feedId = Data::addFeed($feed['url'], self::eltValue($channel, 'title'),
self::eltValue($channel, 'lastBuildDate'));
$result = self::updateItems($feedId, $channel); $result = self::updateItems($feedId, $channel);
if (array_key_exists('error', $result)) return $result; if (array_key_exists('error', $result)) return $result;