diff --git a/.github/prtester.py b/.github/prtester.py index 93b8328987e..df6cc1ffb73 100644 --- a/.github/prtester.py +++ b/.github/prtester.py @@ -52,10 +52,14 @@ def testBridges(bridges,status): for listing in lists: selectionvalue = '' listname = listing.get('name') - if 'optgroup' in listing.contents[0].name: - listing = list(itertools.chain.from_iterable(listing)) + cleanlist = [] + for option in listing.contents: + if 'optgroup' in option.name: + cleanlist.extend(option) + else: + cleanlist.append(option) firstselectionentry = 1 - for selectionentry in listing: + for selectionentry in cleanlist: if firstselectionentry: selectionvalue = selectionentry.get('value') firstselectionentry = 0 diff --git a/.github/workflows/prhtmlgenerator.yml b/.github/workflows/prhtmlgenerator.yml index cacb6642472..ce82aef1d7b 100644 --- a/.github/workflows/prhtmlgenerator.yml +++ b/.github/workflows/prhtmlgenerator.yml @@ -18,11 +18,11 @@ jobs: - name: Check out rss-bridge run: | PR=${{github.event.number}}; - wget -O requirements.txt https://raw.githubusercontent.com/RSS-Bridge/rss-bridge/master/.github/prtester-requirements.txt; - wget https://raw.githubusercontent.com/RSS-Bridge/rss-bridge/master/.github/prtester.py; + wget -O requirements.txt https://raw.githubusercontent.com/$GITHUB_REPOSITORY/${{ github.event.pull_request.base.ref }}/.github/prtester-requirements.txt; + wget https://raw.githubusercontent.com/$GITHUB_REPOSITORY/${{ github.event.pull_request.base.ref }}/.github/prtester.py; wget https://patch-diff.githubusercontent.com/raw/$GITHUB_REPOSITORY/pull/$PR.patch; touch DEBUG; - cat $PR.patch | grep " bridges/.*\.php" | sed "s= bridges/\(.*\)Bridge.php.*=\1=g" | sort | uniq > whitelist.txt + cat $PR.patch | grep "\bbridges/.*Bridge\.php\b" | sed "s=.*\bbridges/\(.*\)Bridge\.php\b.*=\1=g" | sort | uniq > whitelist.txt - name: Start Docker - Current run: | docker run -d -v $GITHUB_WORKSPACE/whitelist.txt:/app/whitelist.txt -v $GITHUB_WORKSPACE/DEBUG:/app/DEBUG -p 3000:80 ghcr.io/rss-bridge/rss-bridge:latest diff --git a/README.md b/README.md index b54194a7221..e0487e6b07f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ ![RSS-Bridge](static/logo_600px.png) -RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites that don't have one. +RSS-Bridge is a web application. + +It generates web feeds for websites that don't have one. + +Officially hosted instance: https://rss-bridge.org/bridge01/ [![LICENSE](https://img.shields.io/badge/license-UNLICENSE-blue.svg)](UNLICENSE) [![GitHub release](https://img.shields.io/github/release/rss-bridge/rss-bridge.svg?logo=github)](https://github.com/rss-bridge/rss-bridge/releases/latest) @@ -17,43 +21,58 @@ RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for website |![Screenshot #5](/static/screenshot-5.png?raw=true)|![Screenshot #6](/static/screenshot-6.png?raw=true)| |![Screenshot #7](/static/twitter-form.png?raw=true)|![Screenshot #8](/static/twitter-rasmus.png?raw=true)| -## A subset of bridges - -* `YouTube` : YouTube user channel, playlist or search -* `Twitter` : Return keyword/hashtag search or user timeline -* `Telegram` : Return the latest posts from a public group -* `Reddit` : Return the latest posts from a subreddit or user -* `Filter` : Filter an existing feed url -* `Vk` : Latest posts from a user or group -* `FeedMerge` : Merge two or more existing feeds into one -* `Twitch` : Fetch the latest videos from a channel -* `ThePirateBay` : Returns the newest indexed torrents from [The Pirate Bay](https://thepiratebay.se/) with keywords - -And [many more](bridges/), thanks to the community! +## A subset of bridges (17/412) + +* `CssSelectorBridge`: [Scrape out a feed using CSS selectors](https://rss-bridge.org/bridge01/#bridge-CssSelectorBridge) +* `FeedMergeBridge`: [Combine multiple feeds into one](https://rss-bridge.org/bridge01/#bridge-FeedMergeBridge) +* `FeedReducerBridge`: [Reduce a noisy feed by some percentage](https://rss-bridge.org/bridge01/#bridge-FeedReducerBridge) +* `FilterBridge`: [Filter a feed by excluding/including items by keyword](https://rss-bridge.org/bridge01/#bridge-FilterBridge) +* `GettrBridge`: [Fetches the latest posts from a GETTR user](https://rss-bridge.org/bridge01/#bridge-GettrBridge) +* `MastodonBridge`: [Fetches statuses from a Mastodon (ActivityPub) instance](https://rss-bridge.org/bridge01/#bridge-MastodonBridge) +* `RedditBridge`: [Fetches posts from a user/subredit (with filtering options)](https://rss-bridge.org/bridge01/#bridge-RedditBridge) +* `RumbleBridge`: [Fetches channel/user videos](https://rss-bridge.org/bridge01/#bridge-RumbleBridge) +* `SoundcloudBridge`: [Fetches music by username](https://rss-bridge.org/bridge01/#bridge-SoundcloudBridge) +* `TelegramBridge`: [Fetches posts from a public channel](https://rss-bridge.org/bridge01/#bridge-TelegramBridge) +* `ThePirateBayBridge:` [Fetches torrents by search/user/category](https://rss-bridge.org/bridge01/#bridge-ThePirateBayBridge) +* `TikTokBridge`: [Fetches posts by username](https://rss-bridge.org/bridge01/#bridge-TikTokBridge) +* `TwitchBridge`: [Fetches videos from channel](https://rss-bridge.org/bridge01/#bridge-TwitchBridge) +* `TwitterBridge`: [Fetches tweets](https://rss-bridge.org/bridge01/#bridge-TwitterBridge) +* `VkBridge`: [Fetches posts from user/group](https://rss-bridge.org/bridge01/#bridge-VkBridge) +* `XPathBridge`: [Scrape out a feed using XPath expressions](https://rss-bridge.org/bridge01/#bridge-XPathBridge) +* `YoutubeBridge`: [Fetches videos by username/channel/playlist/search](https://rss-bridge.org/bridge01/#bridge-YoutubeBridge) +* `YouTubeCommunityTabBridge`: [Fetches posts from a channel's community tab](https://rss-bridge.org/bridge01/#bridge-YouTubeCommunityTabBridge) [Full documentation](https://rss-bridge.github.io/rss-bridge/index.html) -Check out RSS-Bridge right now on https://rss-bridge.org/bridge01 or find another +Check out RSS-Bridge right now on https://rss-bridge.org/bridge01/ + +Alternatively find another [public instance](https://rss-bridge.github.io/rss-bridge/General/Public_Hosts.html). ## Tutorial -RSS-Bridge requires php 7.4 (or higher). +### Install with composer or git -### Install with git: +Requires minimum PHP 7.4. -```bash +```shell +cd /var/www +composer create-project -v --no-dev rss-bridge/rss-bridge +``` + +```shell cd /var/www git clone https://github.com/RSS-Bridge/rss-bridge.git +``` + +Config: +```shell # Give the http user write permission to the cache folder chown www-data:www-data /var/www/rss-bridge/cache # Optionally copy over the default config file cp config.default.ini.php config.ini.php - -# Optionally copy over the default whitelist file -cp whitelist.default.txt whitelist.txt ``` Example config for nginx: @@ -74,9 +93,9 @@ server { } ``` -### Install with Docker: +### Install from Docker Hub: -Install by using docker image from Docker Hub: +Install by downloading the docker image from Docker Hub: ```bash # Create container @@ -88,7 +107,7 @@ docker start rss-bridge Browse http://localhost:3000/ -Install by locally building the image: +### Install by locally building from Dockerfile ```bash # Build image from Dockerfile @@ -97,13 +116,13 @@ docker build -t rss-bridge . # Create container docker create --name rss-bridge --publish 3000:80 rss-bridge -# Start the container +# Start container docker start rss-bridge ``` Browse http://localhost:3000/ -#### Install with docker-compose +### Install with docker-compose Create a `docker-compose.yml` file locally with with the following content: ```yml @@ -126,7 +145,7 @@ docker-compose up Browse http://localhost:3000/ -### Alternative installation methods +### Other installation methods [![Deploy on Scalingo](https://cdn.scalingo.com/deploy/button.svg)](https://my.scalingo.com/deploy?source=https://github.com/sebsauvage/rss-bridge) [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) @@ -169,30 +188,95 @@ Learn more in [bridge api](https://rss-bridge.github.io/rss-bridge/Bridge_API/in ### How to enable all bridges -Write an asterisks to `whitelist.txt`: - - echo '*' > whitelist.txt - -Learn more in [enabling briges](https://rss-bridge.github.io/rss-bridge/For_Hosts/Whitelisting.html) +Modify `config.ini.php`: -### How to enable a bridge + enabled_bridges[] = * -Add the bridge name to `whitelist.txt`: +### How to enable some bridges - echo 'FirefoxAddonsBridge' >> whitelist.txt +``` +enabled_bridges[] = TwitchBridge +enabled_bridges[] = GettrBridge +``` ### How to enable debug mode -Set in `config.ini.php`: +The +[debug mode](https://rss-bridge.github.io/rss-bridge/For_Developers/Debug_mode.html) +disables the majority of caching operations. enable_debug_mode = true -Learn more in [debug mode](https://rss-bridge.github.io/rss-bridge/For_Developers/Debug_mode.html). +### How to switch to memcached as cache backend + +``` +[cache] + +; Cache backend: file (default), sqlite, memcached, null +type = "memcached" +``` + +### How to switch to sqlite3 as cache backend + + type = "sqlite" + +### How to disable bridge errors (as feed items) + +When a bridge fails, RSS-Bridge will produce a feed with a single item describing the error. + +This way, feed readers pick it up and you are notified. + +If you don't want this behaviour, switch the error output to `http`: + + [error] + + ; Defines how error messages are returned by RSS-Bridge + ; + ; "feed" = As part of the feed (default) + ; "http" = As HTTP error message + ; "none" = No errors are reported + output = "http" + +### How to accumulate errors before finally reporting it + +Modify `report_limit` so that an error must occur 3 times before it is reported. + + ; Defines how often an error must occur before it is reported to the user + report_limit = 3 + +### How to password-protect the instance + +HTTP basic access authentication: + + [authentication] + + enable = true + username = "alice" + password = "cat" + +Will typically require feed readers to be configured with the credentials. + +It may also be possible to manually include the credentials in the URL: + +https://alice:cat@rss-bridge.org/bridge01/?action=display&bridge=FabriceBellardBridge&format=Html ### How to create a new output format [Create a new format](https://rss-bridge.github.io/rss-bridge/Format_API/index.html). +### How to run unit tests and linter + +These commands require that you have installed the dev dependencies in `composer.json`. + + ./vendor/bin/phpunit + ./vendor/bin/phpcs --standard=phpcs.xml --warning-severity=0 --extensions=php -p ./ + +### How to spawn a minimal development environment + + php -S 127.0.0.1:9001 + +http://127.0.0.1:9001/ + ## Explanation We are RSS-Bridge community, a group of developers continuing the project initiated by sebsauvage, @@ -204,15 +288,19 @@ webmaster of See [CONTRIBUTORS.md](CONTRIBUTORS.md) RSS-Bridge uses caching to prevent services from banning your server for repeatedly updating feeds. -The specific cache duration can be different between bridges. Cached files are deleted automatically after 24 hours. +The specific cache duration can be different between bridges. +Cached files are deleted automatically after 24 hours. RSS-Bridge allows you to take full control over which bridges are displayed to the user. That way you can host your own RSS-Bridge service with your favorite collection of bridges! +Current maintainers (as of 2023): @dvikan and @Mynacol #2519 ## Reference -### FeedItem properties +### Feed item structure + +This is the feed item structure that bridges are expected to produce. ```php $item = [ @@ -235,13 +323,21 @@ That way you can host your own RSS-Bridge service with your favorite collection ] ``` -### Output formats: +### Output formats + +* `Atom`: Atom feed, for use in feed readers +* `Html`: Simple HTML page +* `Json`: JSON, for consumption by other applications +* `Mrss`: MRSS feed, for use in feed readers +* `Plaintext`: Raw text, for consumption by other applications +* `Sfeed`: Text, TAB separated + +### Cache backends -* `Atom` : Atom feed, for use in feed readers -* `Html` : Simple HTML page -* `Json` : JSON, for consumption by other applications -* `Mrss` : MRSS feed, for use in feed readers -* `Plaintext` : Raw text, for consumption by other applications +* `file` +* `sqlite` +* `memcached` +* `null` ### Licenses diff --git a/actions/ConnectivityAction.php b/actions/ConnectivityAction.php index 19e6b9a6794..c11e6595fa1 100644 --- a/actions/ConnectivityAction.php +++ b/actions/ConnectivityAction.php @@ -41,18 +41,14 @@ public function execute(array $request) return render_template('connectivity.html.php'); } - $bridgeClassName = $this->bridgeFactory->sanitizeBridgeName($request['bridge']); - - if ($bridgeClassName === null) { - throw new \InvalidArgumentException('Bridge name invalid!'); - } + $bridgeClassName = $this->bridgeFactory->createBridgeClassName($request['bridge']); return $this->reportBridgeConnectivity($bridgeClassName); } private function reportBridgeConnectivity($bridgeClassName) { - if (!$this->bridgeFactory->isWhitelisted($bridgeClassName)) { + if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { throw new \Exception('Bridge is not whitelisted!'); } diff --git a/actions/DetectAction.php b/actions/DetectAction.php index 6524bdfed72..6c9fa22dfd7 100644 --- a/actions/DetectAction.php +++ b/actions/DetectAction.php @@ -29,7 +29,7 @@ public function execute(array $request) $bridgeFactory = new BridgeFactory(); foreach ($bridgeFactory->getBridgeClassNames() as $bridgeClassName) { - if (!$bridgeFactory->isWhitelisted($bridgeClassName)) { + if (!$bridgeFactory->isEnabled($bridgeClassName)) { continue; } diff --git a/actions/DisplayAction.php b/actions/DisplayAction.php index 0a3d6dcda04..129d45871be 100644 --- a/actions/DisplayAction.php +++ b/actions/DisplayAction.php @@ -1,37 +1,45 @@ sanitizeBridgeName($request['bridge']); + if (Configuration::getConfig('system', 'enable_maintenance_mode')) { + return new Response('503 Service Unavailable', 503); } - - if ($bridgeClassName === null) { - throw new \InvalidArgumentException('Bridge name invalid!'); + $this->cache = RssBridge::getCache(); + $this->cache->setScope('http'); + $this->cache->setKey($request); + // avg timeout of 20m + $timeout = 60 * 15 + rand(1, 60 * 10); + /** @var Response $cachedResponse */ + $cachedResponse = $this->cache->loadData($timeout); + if ($cachedResponse && !Debug::isEnabled()) { + //Logger::info(sprintf('Returning cached (http) response: %s', $cachedResponse->getBody())); + return $cachedResponse; + } + $response = $this->createResponse($request); + if (in_array($response->getCode(), [429, 503])) { + //Logger::info(sprintf('Storing cached (http) response: %s', $response->getBody())); + $this->cache->setScope('http'); + $this->cache->setKey($request); + $this->cache->saveData($response); } + return $response; + } + + private function createResponse(array $request) + { + $bridgeFactory = new BridgeFactory(); + $bridgeClassName = $bridgeFactory->createBridgeClassName($request['bridge'] ?? ''); $format = $request['format'] ?? null; if (!$format) { throw new \Exception('You must specify a format!'); } - if (!$bridgeFactory->isWhitelisted($bridgeClassName)) { + if (!$bridgeFactory->isEnabled($bridgeClassName)) { throw new \Exception('This bridge is not whitelisted'); } @@ -41,22 +49,22 @@ public function execute(array $request) $bridge = $bridgeFactory->create($bridgeClassName); $bridge->loadConfiguration(); - $noproxy = array_key_exists('_noproxy', $request) && filter_var($request['_noproxy'], FILTER_VALIDATE_BOOLEAN); - - if (Configuration::getConfig('proxy', 'url') && Configuration::getConfig('proxy', 'by_bridge') && $noproxy) { + $noproxy = $request['_noproxy'] ?? null; + if ( + Configuration::getConfig('proxy', 'url') + && Configuration::getConfig('proxy', 'by_bridge') + && $noproxy + ) { + // This const is only used once in getContents() define('NOPROXY', true); } - if (array_key_exists('_cache_timeout', $request)) { - if (! Configuration::getConfig('cache', 'custom_timeout')) { - unset($request['_cache_timeout']); - $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) . '?' . http_build_query($request); - return new Response('', 301, ['Location' => $uri]); - } - - $cache_timeout = filter_var($request['_cache_timeout'], FILTER_VALIDATE_INT); + $cacheTimeout = $request['_cache_timeout'] ?? null; + if (Configuration::getConfig('cache', 'custom_timeout') && $cacheTimeout) { + $cacheTimeout = (int) $cacheTimeout; } else { - $cache_timeout = $bridge->getCacheTimeout(); + // At this point the query argument might still be in the url but it won't be used + $cacheTimeout = $bridge->getCacheTimeout(); } // Remove parameters that don't concern bridges @@ -90,49 +98,36 @@ public function execute(array $request) ) ); - $cacheFactory = new CacheFactory(); - - $cache = $cacheFactory->create(); - $cache->setScope(''); - $cache->purgeCache(86400); // 24 hours - $cache->setKey($cache_params); + $this->cache->setScope(''); + $this->cache->setKey($cache_params); $items = []; $infos = []; - $mtime = $cache->getTime(); - if ( - $mtime !== false - && (time() - $cache_timeout < $mtime) - && !Debug::isEnabled() - ) { - // At this point we found the feed in the cache and debug mode is disabled + $feed = $this->cache->loadData($cacheTimeout); + if ($feed && !Debug::isEnabled()) { if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { + $modificationTime = $this->cache->getTime(); // The client wants to know if the feed has changed since its last check - $stime = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); - if ($mtime <= $stime) { - $lastModified2 = gmdate('D, d M Y H:i:s ', $mtime) . 'GMT'; - return new Response('', 304, ['Last-Modified' => $lastModified2]); + $modifiedSince = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); + if ($modificationTime <= $modifiedSince) { + $modificationTimeGMT = gmdate('D, d M Y H:i:s ', $modificationTime); + return new Response('', 304, ['Last-Modified' => $modificationTimeGMT . 'GMT']); } } - // Load the feed from cache and prepare it - $cached = $cache->loadData(); - if (isset($cached['items']) && isset($cached['extraInfos'])) { - foreach ($cached['items'] as $item) { + if (isset($feed['items']) && isset($feed['extraInfos'])) { + foreach ($feed['items'] as $item) { $items[] = new FeedItem($item); } - $infos = $cached['extraInfos']; + $infos = $feed['extraInfos']; } } else { - // At this point we did NOT find the feed in the cache or debug mode is enabled. try { $bridge->setDatas($bridge_params); $bridge->collectData(); - $items = $bridge->getItems(); - if (isset($items[0]) && is_array($items[0])) { $feedItems = []; foreach ($items as $item) { @@ -146,43 +141,63 @@ public function execute(array $request) 'donationUri' => $bridge->getDonationURI(), 'icon' => $bridge->getIcon() ]; - } catch (\Throwable $e) { + } catch (\Exception $e) { + $errorOutput = Configuration::getConfig('error', 'output'); + $reportLimit = Configuration::getConfig('error', 'report_limit'); if ($e instanceof HttpException) { - // Produce a smaller log record for http exceptions - Logger::warning(sprintf('Exception in %s: %s', $bridgeClassName, create_sane_exception_message($e))); - } else { - // Log the exception - Logger::error(sprintf('Exception in %s', $bridgeClassName), ['e' => $e]); + // Reproduce (and log) these responses regardless of error output and report limit + if ($e->getCode() === 429) { + Logger::info(sprintf('Exception in DisplayAction(%s): %s', $bridgeClassName, create_sane_exception_message($e))); + return new Response('429 Too Many Requests', 429); + } + if ($e->getCode() === 503) { + Logger::info(sprintf('Exception in DisplayAction(%s): %s', $bridgeClassName, create_sane_exception_message($e))); + return new Response('503 Service Unavailable', 503); + } + // Might want to cache other codes such as 504 Gateway Timeout } - - // Emit error only if we are passed the error report limit - $errorCount = self::logBridgeError($bridge->getName(), $e->getCode()); - if ($errorCount >= Configuration::getConfig('error', 'report_limit')) { - if (Configuration::getConfig('error', 'output') === 'feed') { - // Emit the error as a feed item in a feed so that feed readers can pick it up + if (in_array($errorOutput, ['feed', 'none'])) { + Logger::error(sprintf('Exception in DisplayAction(%s): %s', $bridgeClassName, create_sane_exception_message($e)), ['e' => $e]); + } + $errorCount = 1; + if ($reportLimit > 1) { + $errorCount = $this->logBridgeError($bridge->getName(), $e->getCode()); + } + // Let clients know about the error if we are passed the report limit + if ($errorCount >= $reportLimit) { + if ($errorOutput === 'feed') { + // Render the exception as a feed item $items[] = $this->createFeedItemFromException($e, $bridge); - } elseif (Configuration::getConfig('error', 'output') === 'http') { - // Emit as a regular web response + } elseif ($errorOutput === 'http') { + // Rethrow so that the main exception handler in RssBridge.php produces an HTTP 500 throw $e; + } elseif ($errorOutput === 'none') { + // Do nothing (produces an empty feed) + } else { + // Do nothing, unknown error output? Maybe throw exception or validate in Configuration.php } } } - $cache->saveData([ + // Unfortunately need to set scope and key again because they might be modified + $this->cache->setScope(''); + $this->cache->setKey($cache_params); + $this->cache->saveData([ 'items' => array_map(function (FeedItem $item) { return $item->toArray(); }, $items), 'extraInfos' => $infos ]); + $this->cache->purgeCache(); } $format->setItems($items); $format->setExtraInfos($infos); - $lastModified = $cache->getTime(); - $format->setLastModified($lastModified); + $newModificationTime = $this->cache->getTime(); + $format->setLastModified($newModificationTime); $headers = []; - if ($lastModified) { - $headers['Last-Modified'] = gmdate('D, d M Y H:i:s ', $lastModified) . 'GMT'; + if ($newModificationTime) { + $headers['Last-Modified'] = gmdate('D, d M Y H:i:s ', $newModificationTime) . 'GMT'; } $headers['Content-Type'] = $format->getMimeType() . '; charset=' . $format->getCharset(); return new Response($format->stringify(), 200, $headers); @@ -212,14 +227,12 @@ private function createFeedItemFromException($e, BridgeInterface $bridge): FeedI return $item; } - private static function logBridgeError($bridgeName, $code) + private function logBridgeError($bridgeName, $code) { - $cacheFactory = new CacheFactory(); - $cache = $cacheFactory->create(); - $cache->setScope('error_reporting'); - $cache->setkey([$bridgeName . '_' . $code]); - $cache->purgeCache(86400); // 24 hours - if ($report = $cache->loadData()) { + $this->cache->setScope('error_reporting'); + $this->cache->setkey([$bridgeName . '_' . $code]); + $report = $this->cache->loadData(); + if ($report) { $report = Json::decode($report); $report['time'] = time(); $report['count']++; @@ -230,7 +243,7 @@ private static function logBridgeError($bridgeName, $code) 'count' => 1, ]; } - $cache->saveData(Json::encode($report)); + $this->cache->saveData(Json::encode($report)); return $report['count']; } diff --git a/actions/FrontpageAction.php b/actions/FrontpageAction.php index f7ba56e65b0..40d25ea4805 100644 --- a/actions/FrontpageAction.php +++ b/actions/FrontpageAction.php @@ -15,7 +15,7 @@ public function execute(array $request) $body = ''; foreach ($bridgeClassNames as $bridgeClassName) { - if ($bridgeFactory->isWhitelisted($bridgeClassName)) { + if ($bridgeFactory->isEnabled($bridgeClassName)) { $body .= BridgeCard::displayBridgeCard($bridgeClassName, $formats); $activeBridges++; } elseif ($showInactive) { diff --git a/actions/HealthAction.php b/actions/HealthAction.php new file mode 100644 index 00000000000..8ae5df1b4ae --- /dev/null +++ b/actions/HealthAction.php @@ -0,0 +1,15 @@ + 200, + 'message' => 'all is good', + ]; + return new Response(Json::encode($response), 200, ['content-type' => 'application/json']); + } +} diff --git a/actions/ListAction.php b/actions/ListAction.php index 3e15169077d..6ce7e33ee58 100644 --- a/actions/ListAction.php +++ b/actions/ListAction.php @@ -26,7 +26,7 @@ public function execute(array $request) $bridge = $bridgeFactory->create($bridgeClassName); $list->bridges[$bridgeClassName] = [ - 'status' => $bridgeFactory->isWhitelisted($bridgeClassName) ? 'active' : 'inactive', + 'status' => $bridgeFactory->isEnabled($bridgeClassName) ? 'active' : 'inactive', 'uri' => $bridge->getURI(), 'donationUri' => $bridge->getDonationURI(), 'name' => $bridge->getName(), diff --git a/actions/SetBridgeCacheAction.php b/actions/SetBridgeCacheAction.php index ac56f7eabfd..a9a598bd426 100644 --- a/actions/SetBridgeCacheAction.php +++ b/actions/SetBridgeCacheAction.php @@ -23,17 +23,10 @@ public function execute(array $request) $bridgeFactory = new BridgeFactory(); - $bridgeClassName = null; - if (isset($request['bridge'])) { - $bridgeClassName = $bridgeFactory->sanitizeBridgeName($request['bridge']); - } - - if ($bridgeClassName === null) { - throw new \InvalidArgumentException('Bridge name invalid!'); - } + $bridgeClassName = $bridgeFactory->createBridgeClassName($request['bridge'] ?? ''); // whitelist control - if (!$bridgeFactory->isWhitelisted($bridgeClassName)) { + if (!$bridgeFactory->isEnabled($bridgeClassName)) { throw new \Exception('This bridge is not whitelisted', 401); die; } @@ -42,10 +35,12 @@ public function execute(array $request) $bridge->loadConfiguration(); $value = $request['value']; - $cacheFactory = new CacheFactory(); - - $cache = $cacheFactory->create(); + $cache = RssBridge::getCache(); $cache->setScope(get_class($bridge)); + if (!is_array($key)) { + // not sure if $key is an array when it comes in from request + $key = [$key]; + } $cache->setKey($key); $cache->saveData($value); diff --git a/bridges/ABolaBridge.php b/bridges/ABolaBridge.php new file mode 100644 index 00000000000..1f1c5da1954 --- /dev/null +++ b/bridges/ABolaBridge.php @@ -0,0 +1,116 @@ + [ + 'name' => 'News Feed', + 'type' => 'list', + 'title' => 'Feeds from the Portuguese sports newspaper A BOLA.PT', + 'values' => [ + 'Últimas' => 'Nnh/Noticias', + 'Seleção Nacional' => 'Selecao/Noticias', + 'Futebol Nacional' => [ + 'Notícias' => 'Nacional/Noticias', + 'Primeira Liga' => 'Nacional/Liga/Noticias', + 'Liga 2' => 'Nacional/Liga2/Noticias', + 'Liga 3' => 'Nacional/Liga3/Noticias', + 'Liga Revelação' => 'Nacional/Liga-Revelacao/Noticias', + 'Campeonato de Portugal' => 'Nacional/Campeonato-Portugal/Noticias', + 'Distritais' => 'Nacional/Distritais/Noticias', + 'Taça de Portugal' => 'Nacional/TPortugal/Noticias', + 'Futebol Feminino' => 'Nacional/FFeminino/Noticias', + 'Futsal' => 'Nacional/Futsal/Noticias', + ], + 'Futebol Internacional' => [ + 'Notícias' => 'Internacional/Noticias/Noticias', + 'Liga dos Campeões' => 'Internacional/Liga-dos-campeoes/Noticias', + 'Liga Europa' => 'Internacional/Liga-europa/Noticias', + 'Liga Conferência' => 'Internacional/Liga-conferencia/Noticias', + 'Liga das Nações' => 'Internacional/Liga-das-nacoes/Noticias', + 'UEFA Youth League' => 'Internacional/Uefa-Youth-League/Noticias', + ], + 'Mercado' => 'Mercado', + 'Modalidades' => 'Modalidades/Noticias', + 'Motores' => 'Motores/Noticias', + ] + ] + ] + ]; + + public function getIcon() + { + return 'https://abola.pt/img/icons/favicon-96x96.png'; + } + + public function getName() + { + return !is_null($this->getKey('feed')) ? self::NAME . ' | ' . $this->getKey('feed') : self::NAME; + } + + public function getURI() + { + return self::URI . $this->getInput('feed'); + } + + public function collectData() + { + $url = sprintf('https://abola.pt/%s', $this->getInput('feed')); + $dom = getSimpleHTMLDOM($url); + if ($this->getInput('feed') !== 'Mercado') { + $dom = $dom->find('div#body_Todas1_upNoticiasTodas', 0); + } else { + $dom = $dom->find('div#body_NoticiasMercado_upNoticiasTodas', 0); + } + if (!$dom) { + throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); + } + $dom = defaultLinkTo($dom, $this->getURI()); + foreach ($dom->find('div.media') as $key => $article) { + //Get thumbnail + $image = $article->find('.media-img', 0)->style; + $image = preg_replace('/background-image: url\(/i', '', $image); + $image = substr_replace($image, '', -4); + $image = preg_replace('/https:\/\//i', '', $image); + $image = preg_replace('/www\./i', '', $image); + $image = preg_replace('/\/\//', '/', $image); + $image = preg_replace('/\/\/\//', '//', $image); + $image = substr($image, 7); + $image = 'https://' . $image; + $image = preg_replace('/ptimg/', 'pt/img', $image); + $image = preg_replace('/\/\/bola/', 'www.abola', $image); + //Timestamp + $date = date('Y/m/d'); + if (!is_null($article->find("span#body_Todas1_rptNoticiasTodas_lblData_$key", 0))) { + $date = $article->find("span#body_Todas1_rptNoticiasTodas_lblData_$key", 0)->plaintext; + $date = preg_replace('/\./', '/', $date); + } + $time = $article->find("span#body_Todas1_rptNoticiasTodas_lblHora_$key", 0)->plaintext; + $date = explode('/', $date); + $time = explode(':', $time); + $year = $date[0]; + $month = $date[1]; + $day = $date[2]; + $hour = $time[0]; + $minute = $time[1]; + $timestamp = mktime($hour, $minute, 0, $month, $day, $year); + //Content + $image = ''; + $description = '
' . $article->find('.media-texto > span', 0)->plaintext . '
'; + $content = $image . '' . $description; + $a = $article->find('.media-body > a', 0); + $this->items[] = [ + 'title' => $a->find('h4 span', 0)->plaintext, + 'uri' => $a->href, + 'content' => $content, + 'timestamp' => $timestamp, + ]; + } + } +} diff --git a/bridges/AO3Bridge.php b/bridges/AO3Bridge.php index 6ca59cc5b24..57e12fbde49 100644 --- a/bridges/AO3Bridge.php +++ b/bridges/AO3Bridge.php @@ -92,7 +92,12 @@ private function collectList($url) private function collectWork($id) { $url = self::URI . "/works/$id/navigate"; - $response = _http_request($url, ['useragent' => 'rss-bridge bot (https://github.com/RSS-Bridge/rss-bridge)']); + $httpClient = RssBridge::getHttpClient(); + + $response = $httpClient->request($url, [ + 'useragent' => 'rss-bridge bot (https://github.com/RSS-Bridge/rss-bridge)', + ]); + $html = \str_get_html($response['body']); $html = defaultLinkTo($html, self::URI); diff --git a/bridges/ASRockNewsBridge.php b/bridges/ASRockNewsBridge.php index 1b516377057..1a3279784a0 100644 --- a/bridges/ASRockNewsBridge.php +++ b/bridges/ASRockNewsBridge.php @@ -34,7 +34,12 @@ public function collectData() $item['content'] = $contents->innertext; $item['timestamp'] = $this->extractDate($a->plaintext); - $item['enclosures'][] = $a->find('img', 0)->src; + + $img = $a->find('img', 0); + if ($img) { + $item['enclosures'][] = $img->src; + } + $this->items[] = $item; if (count($this->items) >= 10) { diff --git a/bridges/AllegroBridge.php b/bridges/AllegroBridge.php index 5545741645b..be240857de8 100644 --- a/bridges/AllegroBridge.php +++ b/bridges/AllegroBridge.php @@ -16,14 +16,20 @@ class AllegroBridge extends BridgeAbstract 'sessioncookie' => [ 'name' => 'The \'wdctx\' session cookie', 'title' => 'Paste the value of the \'wdctx\' cookie from your browser if you want to prevent Allegro imposing rate limits', - 'pattern' => '^.{250,};?$', + 'pattern' => '^.{70,};?$', // phpcs:ignore 'exampleValue' => 'v4.1-oCrmXTMqv2ppC21GTUCKLmUwRPP1ssQVALKuqwsZ1VXjcKgL2vO5TTRM5xMxS9GiyqxF1gAeyc-63dl0coUoBKXCXi_nAmr95yyqGpq2RAFoneZ4L399E8n6iYyemcuGARjAoSfjvLHJCEwvvHHynSgaxlFBu7hUnKfuy39zo9sSQdyTUjotJg3CAZ53q9v2raAnPCyGOAR4ytRILd9p24EJnxp7_oR0XbVPIo1hDa4WmjXFOxph8rHaO5tWd', 'required' => false, ], 'includeSponsoredOffers' => [ 'type' => 'checkbox', - 'name' => 'Include Sponsored Offers' + 'name' => 'Include Sponsored Offers', + 'defaultValue' => 'checked' + ], + 'includePromotedOffers' => [ + 'type' => 'checkbox', + 'name' => 'Include Promoted Offers', + 'defaultValue' => 'checked' ] ]]; @@ -63,58 +69,57 @@ public function collectData() return; } - $results = $html->find('._6a66d_V7Lel article'); + $results = $html->find('article[data-analytics-view-custom-context="REGULAR"]'); if (!$this->getInput('includeSponsoredOffers')) { - $results = array_filter($results, function ($node) { - return $node->{'data-analytics-view-label'} != 'showSponsoredItems'; - }); + $results = array_merge($results, $html->find('article[data-analytics-view-custom-context="SPONSORED"]')); + } + + if (!$this->getInput('includePromotedOffers')) { + $results = array_merge($results, $html->find('article[data-analytics-view-custom-context="PROMOTED"]')); } foreach ($results as $post) { $item = []; - $item['uri'] = $post->find('._6a66d_LX75-', 0)->href; - -//TODO: port this over, whatever it does, from https://github.com/MK-PL/AllegroRSS -// if (arrayLinks.includes('events/clicks?')) { -// let sponsoredLink = new URL(arrayLinks).searchParams.get('redirect') -// arrayLinks = sponsoredLink.slice(0, sponsoredLink.indexOf('?')) -// } - - $item['title'] = $post->find('._6a66d_LX75-', 0)->innertext; - $item['uid'] = $post->{'data-analytics-view-value'}; - $descriptionPatterns = ['/<\s*dt[^>]*>\b/', '/<\/dt>/', '/<\s*dd[^>]*>\b/', '/<\/dd>/']; - $descriptionReplacements = ['', ': ', '', ' ']; - $description = $post->find('.m7er_k4.mpof_5r.mpof_z0_s', 0)->innertext; - $descriptionPretty = preg_replace($descriptionPatterns, $descriptionReplacements, $description); + $item_link = $post->find('a[href*="' . $item['uid'] . '"], a[href*="allegrolokalnie"]', 0); - $buyNowAuction = $post->find('.mqu1_g3.mvrt_0.mgn2_12', 0)->innertext ?? ''; - $buyNowAuction = str_replace(' href; - $auctionTimeLeft = $post->find('._6a66d_ImOzU', 0)->innertext ?? ''; + $item['title'] = $item_link->find('img', 0)->alt; - $price = $post->find('._6a66d_6R3iN', 0)->plaintext; - $price = empty($auctionTimeLeft) ? $price : $price . '- kwota licytacji'; + $image = $item_link->find('img', 0)->{'data-src'} ?: $item_link->find('img', 0)->src ?? false; - $image = $post->find('._6a66d_44ioA img', 0)->{'data-src'} ?: $post->find('._6a66d_44ioA img', 0)->src ?? false; if ($image) { $item['enclosures'] = [$image . '#.image']; } - $offerExtraInfo = array_filter($post->find('.mqu1_g3.mgn2_12'), function ($node) { + $price = $post->{'data-analytics-view-json-custom-price'}; + if ($price) { + $priceDecoded = json_decode(html_entity_decode($price)); + $price = $priceDecoded->amount . ' ' . $priceDecoded->currency; + } + + $descriptionPatterns = ['/<\s*dt[^>]*>\b/', '/<\/dt>/', '/<\s*dd[^>]*>\b/', '/<\/dd>/']; + $descriptionReplacements = ['', ': ', '', ' ']; + $description = $post->find('.m7er_k4.mpof_5r.mpof_z0_s', 0)->innertext; + $descriptionPretty = preg_replace($descriptionPatterns, $descriptionReplacements, $description); + + $pricingExtraInfo = array_filter($post->find('.mqu1_g3.mgn2_12'), function ($node) { return empty($node->find('.mvrt_0')); }); - $offerExtraInfo = $offerExtraInfo[0]->plaintext ?? ''; + $pricingExtraInfo = $pricingExtraInfo[0]->plaintext ?? ''; + + $offerExtraInfo = array_map(function ($node) { + return str_contains($node->plaintext, 'zapłać później') ? '' : $node->outertext; + }, $post->find('div.mpof_ki.mwdn_1.mj7a_4.mgn2_12')); - $isSmart = $post->find('._6a66d_TC2Zk', 0)->innertext ?? ''; - if (str_contains($isSmart, 'z kurierem')) { - $offerExtraInfo .= ', Smart z kurierem'; - } else { - $offerExtraInfo .= ', Smart'; + $isSmart = $post->find('img[alt="Smart!"]', 0) ?? false; + if ($isSmart) { + $pricingExtraInfo .= $isSmart->outertext; } $item['categories'] = []; @@ -131,11 +136,9 @@ public function collectData() . '' - . $result->find('span.currency-symbol', 0)->plaintext - . $result->find('span.currency-value', 0)->plaintext + . ($result->find('span.currency-symbol', 0)->plaintext ?? '') + . ($result->find('span.currency-value', 0)->plaintext ?? '') . '
' . $result->find('a', 0)->title . '
'; diff --git a/bridges/FB2Bridge.php b/bridges/FB2Bridge.php index 19030dd2e29..141ea59b962 100644 --- a/bridges/FB2Bridge.php +++ b/bridges/FB2Bridge.php @@ -304,7 +304,11 @@ private function getPageInfos($page, $cookies) $regex = '/"pageID":"([0-9]*)"/'; preg_match($regex, $pageContent, $matches); - return ['userId' => $matches[1], 'username' => $username]; + $arr = [ + 'userId' => $matches[1] ?? null, + 'username' => $username, + ]; + return $arr; } public function getName() diff --git a/bridges/FallGuysBridge.php b/bridges/FallGuysBridge.php new file mode 100644 index 00000000000..dbb34792602 --- /dev/null +++ b/bridges/FallGuysBridge.php @@ -0,0 +1,134 @@ + [ + 'name' => 'Language', + 'type' => 'list', + 'values' => [ + 'English' => 'en-US', + 'لعربية' => 'ar', + 'Deutsch' => 'de', + 'Español (Spain)' => 'es-ES', + 'Español (LA)' => 'es-MX', + 'Français' => 'fr', + 'Italiano' => 'it', + '日本語' => 'ja', + '한국어' => 'ko', + 'Polski' => 'pl', + 'Português (Brasil)' => 'pt-BR', + 'Русский' => 'ru', + 'Türkçe' => 'tr', + '简体中文' => 'zh-CN', + ], + 'defaultValue' => self::DEFAULT_LOCALE, + ] + ] + ]; + + public function collectData() + { + $html = getSimpleHTMLDOM(self::getURI()); + + $data = json_decode($html->find('#__NEXT_DATA__', 0)->innertext); + + foreach ($data->props->pageProps->newsList as $newsItem) { + $headerDescription = property_exists($newsItem->header, 'description') ? $newsItem->header->description : ''; + $headerImage = $newsItem->header->image->src; + + $contentImages = [$headerImage]; + + $content = <<{$headerDescription} + + HTML; + + foreach ($newsItem->content->items as $contentItem) { + if (property_exists($contentItem, 'articleCopy')) { + if (property_exists($contentItem->articleCopy, 'title')) { + $title = $contentItem->articleCopy->title; + + $content .= <<{$title} + HTML; + } + + $text = $contentItem->articleCopy->copy; + + $content .= <<{$text} + HTML; + } elseif (property_exists($contentItem, 'articleImage')) { + $image = $contentItem->articleImage->imageSrc; + + if ($image != $headerImage) { + $contentImages[] = $image; + + $content .= << + HTML; + } + } elseif (property_exists($contentItem, 'embeddedVideo')) { + $mediaOptions = $contentItem->embeddedVideo->mediaOptions; + $mainContentOptions = $contentItem->embeddedVideo->mainContentOptions; + + if (count($mediaOptions) == count($mainContentOptions)) { + for ($i = 0; $i < count($mediaOptions); $i++) { + if (property_exists($mediaOptions[$i], 'youtubeVideo')) { + $videoUrl = 'https://youtu.be/' . $mediaOptions[$i]->youtubeVideo->contentId; + $image = $mainContentOptions[$i]->image->src; + + $content .= '';
+
+ if ($image != $headerImage) {
+ $contentImages[] = $image;
+
+ $content .= <<
+ HTML;
+ }
+
+ $content .= <<(Video: {$videoUrl})
+ HTML;
+
+ $content .= '
' . $article_html->find('div.article_top_description', 0)->innertext . '
'; - $hero_image = 'getAttribute('data-src') . '>'; - + $heroImage = $article_html->find('img.article_top_DMT_Image', 0); + if ($heroImage) { + $hero_image = ''; + } $article_body = $article_html->find('div.TGN_Article_ReadTimeSection', 0); // Remove the menu bar on some articles (PDF download etc.) diff --git a/bridges/GithubIssueBridge.php b/bridges/GithubIssueBridge.php index 9ca84010183..e4e995e30c4 100644 --- a/bridges/GithubIssueBridge.php +++ b/bridges/GithubIssueBridge.php @@ -5,7 +5,7 @@ class GithubIssueBridge extends BridgeAbstract const MAINTAINER = 'Pierre Mazière'; const NAME = 'Github Issue'; const URI = 'https://github.com/'; - const CACHE_TIMEOUT = 0; // 10min + const CACHE_TIMEOUT = 600; // 10m const DESCRIPTION = 'Returns the issues or comments of an issue of a github project'; const PARAMETERS = [ @@ -137,7 +137,8 @@ private function extractIssueComment($issueNbr, $title, $comment) { $uri = $this->buildGitHubIssueCommentUri($issueNbr, $comment->id); - $author = $comment->find('.author', 0)->plaintext; + $authorDom = $comment->find('.author', 0); + $author = $authorDom->plaintext ?? null; $header = $comment->find('.timeline-comment-header > h3', 0); $title .= ' / ' . ($header ? $header->plaintext : 'Activity'); diff --git a/bridges/GizmodoBridge.php b/bridges/GizmodoBridge.php index 64e2fc8ae36..8ed30704152 100644 --- a/bridges/GizmodoBridge.php +++ b/bridges/GizmodoBridge.php @@ -22,7 +22,7 @@ protected function parseItem($item) // Get header image $image = $html->find('meta[property="og:image"]', 0)->content; - $item['content'] = $html->find('div.js_post-content', 0)->innertext; + $item['content'] = $html->find('div.js_post-content', 0)->innertext ?? ''; // Get categories $categories = explode(',', $html->find('meta[name="keywords"]', 0)->content); diff --git a/bridges/GolemBridge.php b/bridges/GolemBridge.php index 58c07984f73..96fa450631d 100644 --- a/bridges/GolemBridge.php +++ b/bridges/GolemBridge.php @@ -115,7 +115,7 @@ private function extractContent($page) // delete known bad elements foreach ( $article->find('div[id*="adtile"], #job-market, #seminars, iframe, - div.gbox_affiliate, div.toc, .embedcontent') as $bad + div.gbox_affiliate, div.toc, .embedcontent, script') as $bad ) { $bad->remove(); } diff --git a/bridges/GoogleScholarBridge.php b/bridges/GoogleScholarBridge.php index 932efb5b3d8..981355dd32a 100644 --- a/bridges/GoogleScholarBridge.php +++ b/bridges/GoogleScholarBridge.php @@ -2,19 +2,101 @@ class GoogleScholarBridge extends BridgeAbstract { - const NAME = 'Goolge Scholar'; + const NAME = 'Google Scholar v2'; const URI = 'https://scholar.google.com/'; - const DESCRIPTION = 'Follow authors of scientific publications.'; - const MAINTAINER = 'thefranke'; + const DESCRIPTION = 'Search for publications or follow authors on Google Scholar.'; + const MAINTAINER = 'nicholasmccarthy'; const CACHE_TIMEOUT = 86400; // 24h - const PARAMETERS = [[ - 'userId' => [ - 'name' => 'User ID', - 'exampleValue' => 'qc6CJjYAAAAJ', - 'required' => true - ] - ]]; + const PARAMETERS = [ + 'user' => [ + 'userId' => [ + 'name' => 'User ID', + 'exampleValue' => 'qc6CJjYAAAAJ', + 'required' => true + ] + ], + 'query' => [ + 'q' => [ + 'name' => 'Search Query', + 'title' => 'Search Query', + 'required' => true, + 'exampleValue' => 'machine learning' + ], + 'cites' => [ + 'name' => 'Cites', + 'required' => false, + 'default' => '', + 'exampleValue' => '1275980731835430123', + 'title' => 'Parameter defines unique ID for an article to trigger Cited By searches. Usage of cites + will bring up a list of citing documents in Google Scholar. Example value: cites=1275980731835430123. + Usage of cites and q parameters triggers search within citing articles.' + ], + 'language' => [ + 'name' => 'Language', + 'required' => false, + 'default' => '', + 'exampleValue' => 'en', + 'title' => 'Parameter defines the language to use for the Google Scholar search. ' + ], + 'minCitations' => [ + 'name' => 'Minimum Citations', + 'required' => false, + 'type' => 'number', + 'default' => '0', + 'title' => 'Parameter defines the minimum number of citations in order for the results to be included.' + ], + 'sinceYear' => [ + 'name' => 'Since Year', + 'required' => false, + 'type' => 'number', + 'default' => '0', + 'title' => 'Parameter defines the year from which you want the results to be included.' + ], + 'untilYear' => [ + 'name' => 'Until Year', + 'required' => false, + 'type' => 'number', + 'default' => '0', + 'title' => 'Parameter defines the year until which you want the results to be included.' + ], + 'sortBy' => [ + 'name' => 'Sort By Date', + 'type' => 'checkbox', + 'default' => false, + 'title' => 'Parameter defines articles added in the last year, sorted by date. Alternatively sorts + by relevance. This overrides Since-Until Year values.', + ], + 'includePatents' => [ + 'name' => 'Include Patents', + 'type' => 'checkbox', + 'default' => false, + 'title' => 'Include Patents', + ], + 'includeCitations' => [ + 'name' => 'Include Citations', + 'type' => 'checkbox', + 'default' => true, + 'title' => 'Parameter defines whether you would like to include citations or not.', + ], + 'reviewArticles' => [ + 'name' => 'Only Review Articles', + 'type' => 'checkbox', + 'default' => false, + 'title' => 'Parameter defines whether you would like to show only review articles or not (these + articles consist of topic reviews, or discuss the works or authors you have searched for).', + ], + 'numResults' => [ + 'name' => 'Number of Results (max 20)', + 'required' => false, + 'type' => 'number', + 'default' => 10, + 'exampleValue' => 10, + 'title' => 'Number of results to return' + ] + ], + ]; + public function getIcon() { @@ -23,58 +105,138 @@ public function getIcon() public function collectData() { - $uri = self::URI . '/citations?hl=en&view_op=list_works&sortby=pubdate&user=' . $this->getInput('userId'); - - $html = getSimpleHTMLDOM($uri) - or returnServerError('Could not fetch Google Scholar data.'); - - $publications = $html->find('tr[class="gsc_a_tr"]'); - - foreach ($publications as $publication) { - $articleUrl = self::URI . htmlspecialchars_decode($publication->find('a[class="gsc_a_at"]', 0)->href); - $articleTitle = $publication->find('a[class="gsc_a_at"]', 0)->plaintext; - - # fetch the article itself to extract rest of content - $contentArticle = getSimpleHTMLDOMCached($articleUrl); - $articleEntries = $contentArticle->find('div[class="gs_scl"]'); - - $articleDate = ''; - $articleAbstract = ''; - $articleAuthor = ''; - $content = ''; - - foreach ($articleEntries as $entry) { - $field = $entry->find('div[class="gsc_oci_field"]', 0)->plaintext; - $value = $entry->find('div[class="gsc_oci_value"]', 0)->plaintext; - - if ($field == 'Publication date') { - $articleDate = $value; - } else if ($field == 'Description') { - $articleAbstract = $value; - } else if ($field == 'Authors') { - $articleAuthor = $value; - } else if ($field == 'Scholar articles' || $field == 'Total citations') { - continue; - } else { - $content = $content . $field . ': ' . $value . '(video)
' : ''; + + $isMoreContent = (bool) $post->find('svg', 0); + $moreContentNote = $isMoreContent ? '(multiple images and/or videos)
' : ''; + + + + + $this->items[] = [ + 'uri' => $url, + 'author' => $author, + 'timestamp' => $date, + 'title' => $title, + 'thumbnail' => $imageUrl, + 'enclosures' => [$imageUrl, $download], + 'content' => << + + +{$videoNote} +{$moreContentNote} +{$description}
+
+ +HTML, + 'uid' => $uid + ]; + } + } + + private function collectStories() + { + try { + $username = $this->getInput('u'); + $html = getSimpleHTMLDOMCached(self::URI . 'api/media/?name=' . $username); + $json = Json::decode($html); + + foreach ($json as $post) { + $url = $post['src']; + $imageUrl = $post['thumb']; + $download = $url; + $author = $username; + $uid = $url; + $title = 'Story - ' . $username; + + $this->items[] = [ + 'uri' => $url, + 'author' => $author, + 'title' => $title, + 'thumbnail' => $imageUrl, + 'enclosures' => [$imageUrl, $download], + 'content' => << + + + + HTML, + 'uid' => $uid + ]; + } + } catch (Exception $e) { + // If it fails, it's because there are no stories, so don't do anything + } + } + + private function collectTaggeds() + { + $username = $this->getInput('u'); + try { + $html = getSimpleHTMLDOMCached(self::URI . 'tagged/' . $username . '/'); + $html = defaultLinkTo($html, self::URI); + + foreach ($html->find('div[class=item]') as $post) { + $url = $post->find('a', 1)->href; + $instagramURL = $this->convertURLToInstagram($url); + $fromURL = $post->find('div[class=username]', 0)->find('a', 0)->href; + $fromUsername = $post->find('div[class=username]', 0)->plaintext; + $date = $this->parseDate($post->find('div[class=time]', 0)->plaintext); + $description = $post->find('img', 0)->alt; + $imageUrl = $post->find('img', 0)->src; + $download = $post->find('a[class=download]', 0)->href; + $author = $fromUsername; + $uid = $post->find('a', 0)->href; + $title = 'Tagged - ' . $fromUsername . ' - ' . $this->descriptionToTitle($description); + + // Checking post type + $isVideo = (bool) $post->find('i[class=video]', 0); + $videoNote = $isVideo ? '(video)
' : ''; + + $isMoreContent = (bool) $post->find('svg', 0); + $moreContentNote = $isMoreContent ? '(multiple images and/or videos)
' : ''; + + + $this->items[] = [ + 'uri' => $url, + 'author' => $author, + 'timestamp' => $date, + 'title' => $title, + 'thumbnail' => $imageUrl, + 'enclosures' => [$imageUrl, $download], + 'content' => << + + +{$videoNote} +{$moreContentNote} +From {$fromUsername}
+{$description}
+
+ +HTML, + 'uid' => $uid + ]; + } + } catch (Exception $e) { + // If it fails, it's because the account was not tagged + } + } + + // Parse date, and transform the date into a timetamp, even in a case of a relative date + private function parseDate($content) + { + $date = date_create(); + $dateString = str_replace(' ago', '', $content); + $relativeDate = date_interval_create_from_date_string($dateString); + if ($relativeDate) { + date_sub($date, $relativeDate); + } else { + Logger::info(sprintf('Unable to parse date string: %s', $dateString)); + } + return date_format($date, 'r'); + } + + private function convertURLToInstagram($url) + { + return str_replace(self::URI, self::INSTAGRAMURI, $url); + } + private function descriptionToTitle($description) + { + return strlen($description) > 60 ? mb_substr($description, 0, 57) . '...' : $description; + } + + public function getName() + { + if (!is_null($this->getInput('u'))) { + $types = []; + if ($this->getInput('post')) { + $types[] = 'Posts'; + } + if ($this->getInput('story')) { + $types[] = 'Stories'; + } + if ($this->getInput('tagged')) { + $types[] = 'Tags'; + } + $typesText = $types[0]; + if (count($types) > 1) { + for ($i = 1; $i < count($types) - 1; $i++) { + $typesText .= ', ' . $types[$i]; + } + $typesText .= ' & ' . $types[$i]; + } + + return 'Username ' . $this->getInput('u') . ' - ' . $typesText . ' - Imgsed Bridge'; + } + return parent::getName(); + } +} diff --git a/bridges/InstagramBridge.php b/bridges/InstagramBridge.php index 1bfa2472718..714319067f4 100644 --- a/bridges/InstagramBridge.php +++ b/bridges/InstagramBridge.php @@ -98,9 +98,7 @@ protected function getInstagramUserId($username) return $username; } - $cacheFactory = new CacheFactory(); - - $cache = $cacheFactory->create(); + $cache = RssBridge::getCache(); $cache->setScope('InstagramBridge'); $cache->setKey([$username]); $key = $cache->loadData(); diff --git a/bridges/JohannesBlickBridge.php b/bridges/JohannesBlickBridge.php new file mode 100644 index 00000000000..6c00fecaba1 --- /dev/null +++ b/bridges/JohannesBlickBridge.php @@ -0,0 +1,29 @@ +find('td > a') as $index => $a) { + $item = []; // Create an empty item + $articlePath = $a->href; + $item['title'] = $a->innertext; + $item['uri'] = $articlePath; + $item['content'] = ''; + + $this->items[] = $item; // Add item to the list + if (count($this->items) >= 10) { + break; + } + } + } +} diff --git a/bridges/JornalDeNoticiasBridge.php b/bridges/JornalDeNoticiasBridge.php deleted file mode 100644 index 1549d04f121..00000000000 --- a/bridges/JornalDeNoticiasBridge.php +++ /dev/null @@ -1,59 +0,0 @@ - [ - 'url' => [ - 'name' => 'URL (relative)', - 'exampleValue' => 'opiniao/catia-domingues.html', - ] - ] - ]; - - public function getIcon() - { - return 'https://static.globalnoticias.pt/jn/common/images/favicons/favicon-128.png'; - } - - public function getURI() - { - switch ($this->queriedContext) { - case 'URL': - $url = self::URI . '/' . $this->getInput('url'); - break; - default: - $url = self::URI; - } - return $url; - } - - public function collectData() - { - $archives = $this->getURI(); - $html = getSimpleHTMLDOMCached($archives); - - foreach ($html->find('article') as $element) { - $item = []; - - $title = $element->find('h2 a', 0); - $link = $element->find('h2 a', 0); - $auth = $element->find('h3 a', 0); - - $item['title'] = $title->plaintext; - $item['uri'] = self::URI . $link->href; - $item['author'] = $auth->plaintext; - - $snippet = $element->find('h4 a', 0); - if ($snippet) { - $item['content'] = $snippet->plaintext; - } - - $this->items[] = $item; - } - } -} diff --git a/bridges/JornalNBridge.php b/bridges/JornalNBridge.php new file mode 100644 index 00000000000..2a9d6661455 --- /dev/null +++ b/bridges/JornalNBridge.php @@ -0,0 +1,104 @@ + [ + 'name' => 'News Feed', + 'type' => 'list', + 'title' => 'Feeds from the Portuguese sports newspaper A BOLA.PT', + 'values' => [ + 'Concelhos' => [ + 'Espinho' => 'espinho', + 'Ovar' => 'ovar', + 'Santa Maria da Feira' => 'santa-maria-da-feira', + ], + 'Cultura' => 'ovar/cultura', + 'Desporto' => 'desporto', + 'Economia' => 'santa-maria-da-feira/economia', + 'Política' => 'santa-maria-da-feira/politica', + 'Opinião' => 'santa-maria-da-feira/opiniao', + 'Sociedade' => 'santa-maria-da-feira/sociedade', + ] + ] + ] + ]; + + const PT_MONTH_NAMES = [ + 'janeiro' => '01', + 'fevereiro' => '02', + 'março' => '03', + 'abril' => '04', + 'maio' => '05', + 'junho' => '06', + 'julho' => '07', + 'agosto' => '08', + 'setembro' => '09', + 'outubro' => '10', + 'novembro' => '11', + 'dezembro' => '12', + ]; + + public function getIcon() + { + return 'https://www.jornaln.pt/wp-content/uploads/2023/01/cropped-NovoLogoJornal_Instagram-192x192.png'; + } + + public function getName() + { + if ($this->getKey('feed')) { + return self::NAME . ' | ' . $this->getKey('feed'); + } + return self::NAME; + } + + public function getURI() + { + return self::URI . $this->getInput('feed'); + } + + public function collectData() + { + $url = sprintf(self::URI . '/%s', $this->getInput('feed')); + $dom = getSimpleHTMLDOMCached($url); + $domSelector = '.elementor-widget-container > .elementor-posts-container'; + $dom = $dom->find($domSelector, 0); + if (!$dom) { + throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); + } + $dom = defaultLinkTo($dom, $this->getURI()); + foreach ($dom->find('article') as $article) { + //Get thumbnail + $image = $article->find('.elementor-post__thumbnail img', 0)->src; + //Timestamp + $date = $article->find('.elementor-post-date', 0)->plaintext; + $date = trim($date, "\t "); + $date = preg_replace('/ de /i', '/', $date); + $date = preg_replace('/, /', '/', $date); + $date = explode('/', $date); + $year = (int) $date[2]; + $month = (int) $date[1]; + $day = (int) $date[0]; + foreach (self::PT_MONTH_NAMES as $key => $item) { + if ($key === strtolower($month)) { + $month = (int) $item; + } + } + $timestamp = mktime(0, 0, 0, $month, $day, $year); + //Content + $content = ''; + $this->items[] = [ + 'title' => $article->find('.elementor-post__title > a', 0)->plaintext, + 'uri' => $article->find('a', 0)->href, + 'content' => $content, + 'timestamp' => $timestamp + ]; + } + } +} diff --git a/bridges/JustWatchBridge.php b/bridges/JustWatchBridge.php index 59e60a16d47..66b61aff4b3 100644 --- a/bridges/JustWatchBridge.php +++ b/bridges/JustWatchBridge.php @@ -169,10 +169,17 @@ public function collectData() foreach ($titles as $title) { $item = []; $item['uri'] = $title->find('a', 0)->href; - $item['title'] = $provider->find('picture > img', 0)->alt . ' - ' . $title->find('.title-poster__image > img', 0)->alt; - $image = $title->find('.title-poster__image > img', 0)->attr['src']; - if (str_starts_with($image, 'data')) { - $image = $title->find('.title-poster__image > img', 0)->attr['data-src']; + + $itemTitle = sprintf( + '%s - %s', + $provider->find('picture > img', 0)->alt ?? '', + $title->find('.title-poster__image > img', 0)->alt ?? '' + ); + $item['title'] = $itemTitle; + + $imageUrl = $title->find('.title-poster__image > img', 0)->attr['src'] ?? ''; + if (str_starts_with($imageUrl, 'data')) { + $imageUrl = $title->find('.title-poster__image > img', 0)->attr['data-src']; } $content = 'Provider: ' @@ -190,7 +197,7 @@ public function collectData() $content .= 'Poster:(video)
' : ''; - $isVideo = (bool) $element->find('.icon_video', 0); - $videoNote = $isVideo ? '(video)
' : ''; + $isTV = (bool) $element->find('.icon_tv', 0); + $tvNote = $isTV ? '(TV)
' : ''; - $isTV = (bool) $element->find('.icon_tv', 0); - $tvNote = $isTV ? '(TV)
' : ''; + $isMoreContent = (bool) $element->find('.icon_multi', 0); + $moreContentNote = $isMoreContent ? '(multiple images and/or videos)
' : ''; - $isMoreContent = (bool) $element->find('.icon_multi', 0); - $moreContentNote = $isMoreContent ? '(multiple images and/or videos)
' : ''; + $imageUrl = $element->find('.img', 0)->getAttribute('data-src'); - $imageUrl = $element->find('.img', 0)->getAttribute('data-src'); - parse_str(parse_url($imageUrl, PHP_URL_QUERY), $imageVars); - $imageUrl = $imageVars['u']; + $uid = explode('/', parse_url($url, PHP_URL_PATH))[2]; - $uid = explode('/', parse_url($url, PHP_URL_PATH))[2]; - - $this->items[] = [ - 'uri' => $url, - 'timestamp' => date_format($date, 'r'), - 'title' => strlen($description) > 60 ? mb_substr($description, 0, 57) . '...' : $description, - 'thumbnail' => $imageUrl, - 'enclosures' => [$imageUrl], - 'content' => <<items[] = [ + 'uri' => $url, + 'timestamp' => date_format($date, 'r'), + 'title' => strlen($description) > 60 ? mb_substr($description, 0, 57) . '...' : $description, + 'thumbnail' => $imageUrl, + 'enclosures' => [$imageUrl], + 'content' => << @@ -86,8 +85,8 @@ public function collectData() {$moreContentNote}{$description}
HTML, - 'uid' => $uid - ]; + 'uid' => $uid + ]; } } } diff --git a/bridges/PicukiBridge.php b/bridges/PicukiBridge.php index e90177ed5ab..f1d45e2acd8 100644 --- a/bridges/PicukiBridge.php +++ b/bridges/PicukiBridge.php @@ -6,9 +6,17 @@ class PicukiBridge extends BridgeAbstract const NAME = 'Picuki Bridge'; const URI = 'https://www.picuki.com/'; const CACHE_TIMEOUT = 3600; // 1h - const DESCRIPTION = 'Returns Picuki posts by user and by hashtag'; + const DESCRIPTION = 'Returns Picuki (Instagram viewer) posts by user and by hashtag'; const PARAMETERS = [ + 'global' => [ + 'count' => [ + 'name' => 'Count', + 'type' => 'number', + 'title' => 'How many posts to fetch', + 'defaultValue' => 12 + ] + ], 'Username' => [ 'u' => [ 'name' => 'username', @@ -43,6 +51,13 @@ public function collectData() $re = '#let short_code = "(.*?)";\s*$#m'; $html = getSimpleHTMLDOM($this->getURI()); + $requestedCount = $this->getInput('count'); + if ($requestedCount > 12) { + // Picuki shows 12 posts per page at initial load. + throw new \Exception('Maximum count is 12'); + } + + $count = 0; foreach ($html->find('.box-photos .box-photo') as $element) { // skip ad items if (in_array('adv', explode(' ', $element->class))) { @@ -86,14 +101,19 @@ public function collectData() 'source' => $sourceUrl, 'enclosures' => [$imageUrl], 'content' => << - - -{$sourceUrl} -{$videoNote} -
{$description}
-HTML + + + + {$sourceUrl} + {$videoNote} +
{$description}
+ HTML
];
+
+ $count++;
+ if ($count >= $requestedCount) {
+ break;
+ }
}
}
diff --git a/bridges/PokemonNewsBridge.php b/bridges/PokemonNewsBridge.php
index 954e584c5f2..3dacb163108 100644
--- a/bridges/PokemonNewsBridge.php
+++ b/bridges/PokemonNewsBridge.php
@@ -14,7 +14,10 @@ public function collectData()
// todo: parse json api instead: https://www.pokemon.com/api/1/us/news/get-news.json
$url = 'https://www.pokemon.com/us/pokemon-news';
$dom = getSimpleHTMLDOM($url);
-
+ $haystack = (string)$dom;
+ if (str_contains($haystack, 'Request unsuccessful. Incapsula incident')) {
+ throw new \Exception('Blocked by anti-bot');
+ }
foreach ($dom->find('.news-list ul li') as $item) {
$title = $item->find('h3', 0)->plaintext;
$description = $item->find('p.hidden-mobile', 0);
diff --git a/bridges/PornhubBridge.php b/bridges/PornhubBridge.php
index 75ac4c0f5b1..104463a82df 100644
--- a/bridges/PornhubBridge.php
+++ b/bridges/PornhubBridge.php
@@ -67,7 +67,9 @@ public function collectData()
$show_images = $this->getInput('show_images');
- $html = getSimpleHTMLDOM($uri);
+ $html = getSimpleHTMLDOM($uri, [
+ 'cookie: accessAgeDisclaimerPH=1'
+ ]);
foreach ($html->find('div.videoUList ul.videos li.videoblock') as $element) {
$item = [];
diff --git a/bridges/PresidenciaPTBridge.php b/bridges/PresidenciaPTBridge.php
index 5afcc91fe19..8b02a481a27 100644
--- a/bridges/PresidenciaPTBridge.php
+++ b/bridges/PresidenciaPTBridge.php
@@ -61,9 +61,9 @@ public function collectData()
$item = [];
$link = $element->find('a', 0);
- $etitle = $element->find('.content-box h2', 0);
- $edts = $element->find('p', 1);
- $edt = html_entity_decode($edts->innertext, ENT_HTML5);
+ $etitle = $element->find('.article-title', 0);
+ $edts = $element->find('.date', 0);
+ $edt = $edts->innertext;
$item['title'] = strip_tags($etitle->innertext);
$item['uri'] = self::URI . $link->href;
diff --git a/bridges/QwantzBridge.php b/bridges/QwantzBridge.php
new file mode 100644
index 00000000000..e48e948adf0
--- /dev/null
+++ b/bridges/QwantzBridge.php
@@ -0,0 +1,37 @@
+find('img')[0]->{'src'};
+ $subject = $content->find('a')[1]->{'href'};
+ $subject = urldecode(substr($subject, strpos($subject, 'subject') + 8));
+ $p = (string)$content->find('P')[0];
+
+ $item['content'] = "{$subject}{$p}";
+
+ return $item;
+ }
+
+ public function collectData()
+ {
+ $this->collectExpandableDatas(self::URI . 'rssfeed.php');
+ }
+
+ public function getIcon()
+ {
+ return self::URI . 'favicon.ico';
+ }
+}
diff --git a/bridges/RedditBridge.php b/bridges/RedditBridge.php
index de80f09d434..86d7884b2a2 100644
--- a/bridges/RedditBridge.php
+++ b/bridges/RedditBridge.php
@@ -73,47 +73,6 @@ class RedditBridge extends BridgeAbstract
]
];
- public function detectParameters($url)
- {
- $parsed_url = parse_url($url);
-
- $host = $parsed_url['host'] ?? null;
-
- if ($host != 'www.reddit.com' && $host != 'old.reddit.com') {
- return null;
- }
-
- $path = explode('/', $parsed_url['path']);
-
- if ($path[1] == 'r') {
- return [
- 'r' => $path[2]
- ];
- } elseif ($path[1] == 'user') {
- return [
- 'u' => $path[2]
- ];
- } else {
- return null;
- }
- }
-
- public function getIcon()
- {
- return 'https://www.redditstatic.com/desktop2x/img/favicon/favicon-96x96.png';
- }
-
- public function getName()
- {
- if ($this->queriedContext == 'single') {
- return 'Reddit r/' . $this->getInput('r');
- } elseif ($this->queriedContext == 'user') {
- return 'Reddit u/' . $this->getInput('u');
- } else {
- return self::NAME;
- }
- }
-
public function collectData()
{
$user = false;
@@ -152,18 +111,22 @@ public function collectData()
foreach ($subreddits as $subreddit) {
$name = trim($subreddit);
- $values = getContents(self::URI
- . '/search.json?q='
- . $keywords
- . $flair
- . ($user ? 'author%3A' : 'subreddit%3A')
- . $name
- . '&sort='
- . $this->getInput('d')
- . '&include_over_18=on');
- $decodedValues = json_decode($values);
-
- foreach ($decodedValues->data->children as $post) {
+ $url = self::URI
+ . '/search.json?q='
+ . $keywords
+ . $flair
+ . ($user ? 'author%3A' : 'subreddit%3A')
+ . $name
+ . '&sort='
+ . $this->getInput('d')
+ . '&include_over_18=on';
+
+ $version = 'v0.0.1';
+ $useragent = "rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)";
+ $json = getContents($url, ['User-Agent: ' . $useragent]);
+ $parsedJson = Json::decode($json, false);
+
+ foreach ($parsedJson->data->children as $post) {
if ($post->kind == 't1' && !$comments) {
continue;
}
@@ -288,6 +251,22 @@ public function collectData()
});
}
+ public function getIcon()
+ {
+ return 'https://www.redditstatic.com/desktop2x/img/favicon/favicon-96x96.png';
+ }
+
+ public function getName()
+ {
+ if ($this->queriedContext == 'single') {
+ return 'Reddit r/' . $this->getInput('r');
+ } elseif ($this->queriedContext == 'user') {
+ return 'Reddit u/' . $this->getInput('u');
+ } else {
+ return self::NAME;
+ }
+ }
+
private function encodePermalink($link)
{
return self::URI . implode(
@@ -307,4 +286,29 @@ private function link($href, $text)
{
return '' . $text . '';
}
+
+ public function detectParameters($url)
+ {
+ $parsed_url = parse_url($url);
+
+ $host = $parsed_url['host'] ?? null;
+
+ if ($host != 'www.reddit.com' && $host != 'old.reddit.com') {
+ return null;
+ }
+
+ $path = explode('/', $parsed_url['path']);
+
+ if ($path[1] == 'r') {
+ return [
+ 'r' => $path[2]
+ ];
+ } elseif ($path[1] == 'user') {
+ return [
+ 'u' => $path[2]
+ ];
+ } else {
+ return null;
+ }
+ }
}
diff --git a/bridges/Releases3DSBridge.php b/bridges/Releases3DSBridge.php
index 56946a4776c..4fd25b008fd 100644
--- a/bridges/Releases3DSBridge.php
+++ b/bridges/Releases3DSBridge.php
@@ -82,10 +82,10 @@ protected function collectDataUrl($dataUrl)
$item = [];
$item['title'] = $name;
$item['author'] = $publisher;
- $item['timestamp'] = $ignDate;
- $item['enclosures'] = [$ignCoverArt];
+ //$item['timestamp'] = $ignDate;
+ //$item['enclosures'] = [$ignCoverArt];
$item['uri'] = empty($ignLink) ? $searchLinkDuckDuckGo : $ignLink;
- $item['content'] = $ignDescription . $releaseDescription . $releaseSearchLinks;
+ $item['content'] = $releaseDescription . $releaseSearchLinks;
$this->items[] = $item;
$limit++;
}
diff --git a/bridges/ReutersBridge.php b/bridges/ReutersBridge.php
index ab2e812d3bd..2acadfc3edd 100644
--- a/bridges/ReutersBridge.php
+++ b/bridges/ReutersBridge.php
@@ -143,18 +143,6 @@ class ReutersBridge extends BridgeAbstract
'wire'
];
- /**
- * Performs an HTTP request to the Reuters API and returns decoded JSON
- * in the form of an associative array
- * @param string $feed_uri Full API URL to fetch data
- * @return array
- */
- private function getJson($uri)
- {
- $returned_data = getContents($uri);
- return json_decode($returned_data, true);
- }
-
/**
* Takes in data from Reuters Wire API and
* creates structured data in the form of a list
@@ -295,8 +283,19 @@ private function getArticle($feed_uri, $is_article_uid = false)
{
// This will make another request to API to get full detail of article and author's name.
$url = $this->getAPIURL($feed_uri, 'article', $is_article_uid);
- $rawData = $this->getJson($url);
+ try {
+ $json = getContents($url);
+ $rawData = Json::decode($json);
+ } catch (\JsonException $e) {
+ return [
+ 'content' => '',
+ 'author' => '',
+ 'category' => '',
+ 'images' => '',
+ 'published_at' => ''
+ ];
+ }
$article_content = '';
$authorlist = '';
$category = [];
@@ -342,15 +341,12 @@ private function handleImage($images)
{
$img_placeholder = '';
- foreach ($images as $image) { // Add more image to article.
+ foreach ($images as $image) {
+ // Add more image to article.
$image_url = $image['url'];
- $image_caption = $image['caption'];
+ $image_caption = $image['caption'] ?? $image['alt_text'] ?? $image['subtitle'] ?? '';
$image_alt_text = '';
- if (isset($image['alt_text'])) {
- $image_alt_text = $image['alt_text'];
- } else {
- $image_alt_text = $image_caption;
- }
+ $image_alt_text = $image['alt_text'] ?? $image_caption;
$img = "";
$img_caption = "