1
0
mirror of https://github.com/RSS-Bridge/rss-bridge.git synced 2025-08-17 14:00:43 +02:00

Compare commits

...

46 Commits

Author SHA1 Message Date
Dag
b39964cee3 chore: prepare for aug 2025 release (#4654) 2025-08-05 19:50:27 +02:00
Joseph
9c43921a33 [FirstLookMediaTechBridge] Remove bridge (#4653)
Website no longer exists
2025-08-04 22:57:35 +02:00
Joseph
9e2975048f [AskfmBridge] Remove bridge (#4652)
Website closed in December 2024 https://web.archive.org/web/20241129120541/https://about.ask.fm/closure-notice-the-platform-to-be-deactivated-december-1-2024/
2025-08-04 22:56:27 +02:00
Joseph
fb153f9a92 [DansTonChatBridge] Remove bridge (#4650)
bridge is broken and website has native feeds.

https://danstonchat.com/category/quote/feed
2025-08-04 17:19:24 +02:00
Joseph
20fec74c63 [DailymotionBridge] Fetch playlist title from API (#4649) 2025-08-04 15:41:04 +02:00
Simone Dotto
b5f90f8d47 [AmazonPriceTracker] Fix price not shown, new default source (#4631)
Fixes issue #4586

Co-authored-by: Simone Dotto <simonedotto@proton.me>
2025-08-04 14:31:43 +02:00
shaun
aba38845d2 [YoutubeCommunityTabsBridge] Rename Community→Posts to fix broken bridge (#4606)
* youtube community posts are just called "Posts" now

* finish renaming Community -> Posts

* add feedName fallbacks (thanks @Mar-Koeh)

* rename YouTubePostsTabBridge back to YouTubeCommunityTabBridge

* fix linter error by breaking up long expression

* fix optional-chaining regression by using ‘?? null’
2025-08-04 14:30:48 +02:00
Joseph
1211ac63d9 Update DailymotionBridge.php (#4648) 2025-08-04 14:28:16 +02:00
Joseph
640503168e [FirefoxAddonsBridge] Minor change to item content html (#4647) 2025-08-04 14:27:40 +02:00
Arnav Jain
93de253d01 [GoComicsBridge] cache individual comic page for 24h (#4646) 2025-08-04 14:27:19 +02:00
User123698745
6ec4da854f [FallGuysBridge] fix: handle new data structure (#4640)
* [FallGuysBridge] fix: handle new data structure

* [FallGuysBridge] review feedback: removed mixed
2025-08-04 01:36:44 +02:00
Dag
e5f9fe6251 lint (#4645) 2025-08-04 01:36:15 +02:00
Dag
47c9983e16 fix: dont cache basic auth response (#4644) 2025-08-04 01:32:36 +02:00
Sandro
69eda522c8 Mention php extension filter (#4608)
While trying around to minimize my installation, I noticed that this
extension is nowhere mentioned.
2025-08-04 01:09:38 +02:00
User123698745
172e7eb280 [prtester] fix wrong pr check fail when refactoring code (the bridge html output has not changed) (#4642)
ignore "nothing to commit, working tree clean"
2025-08-04 01:08:25 +02:00
User123698745
acb9373c10 [DRKBlutspendeBridge] add offers to content & add caption to images & use cached request (#4641) 2025-08-04 01:07:41 +02:00
Joseph
85497238c5 Update HaveIBeenPwnedBridge.php (#4638) 2025-08-04 00:58:09 +02:00
Marcin Morawski
a2334838a6 Fix deprecations (#4636)
* Fix PHP 8.4 deprecation

Implicitly marking parameter as nullable is deprecated, the explicit nullable type must be used instead

* [github workflow] Add additional php versions
2025-08-04 00:55:50 +02:00
mruac
c65fbd5543 [BlueskyBridge] Fix cases for missing reply post context and QoL fix for video loading (#4635)
* added fix for missing reply post context

* qol fix - no preload on videos
2025-08-04 00:50:12 +02:00
sysadminstory
e241f3dcde [PepperBridgeAbstract, DealabsBridge, HotUKDealsBridge, MydealsBridge] Adapt RSS bridge to website content update; remove country of origin due to missing data (#4634)
Website use now "vue3" and some class and attributes have changed their
names : bridge was updated to use the new class and attribute names

Country of origin has been removed from the deal list : it's for now
disabled, but code is still present in the bridge, in case the website
enable it again.
2025-08-04 00:48:27 +02:00
Pavel Korytov
16bb6156a5 [UniverseTodayBridge] Add bridge (#4627) 2025-08-04 00:22:50 +02:00
Pavel Korytov
9f8dc411a4 [InstituteForTheStudyOfWarBridge] Increase caching time (#4626) 2025-08-04 00:21:57 +02:00
July
5b97899734 [FanaticalBridge] Create a new bridge (#4624)
Provides a fairly barebones bridge for Fanatical bundles:
- Tags detail bundle tiers and prices
- Contents name and link to each bundle item
- Images for each item are in enclosures
2025-08-04 00:21:04 +02:00
July
8ae2c2e3c3 [HumbleBundleBridge] Overhaul to include more information (#4621)
* [HumbleBundleBridge] Overhaul to include more information

* [HumbleBundleBridge] Remove use of named args in calls

PHP 7.4 lacks named arg support and fails unit tests
2025-08-04 00:20:00 +02:00
July
9ec6ae39a2 [ComickBridge] Add new bridge (#4625)
Makes new brige for manga from comick.io. Like the CubariProxyBridge,
can provide manga page images in feed entry content or enclosures.
2025-08-04 00:19:08 +02:00
July
3517cda4a5 [YouTubeFeedExpanderBridge] More reliable channel icons (#4622) 2025-08-04 00:17:30 +02:00
July
52be29d3ec [AnnasArchiveBridge] Fix book list CSS selector (#4619) 2025-08-04 00:17:01 +02:00
July
696aed22cc [CubariProxyBridge] Replace MangaSee with WeebCentral (#4618) 2025-08-04 00:16:30 +02:00
July
e394be7ca5 [KemonoBridge] Add search query support (#4620) 2025-08-04 00:16:14 +02:00
jaydeethree
3835f290c1 Update GOGBridge to use GOG's REST API. I have tested this locally and it seems to work correctly. (#4616) 2025-08-04 00:14:51 +02:00
Nomis
c7de5c95be Update 06_Public_Hosts.md (#4614)
Remove bridge.easter.fr
2025-08-04 00:12:38 +02:00
Tobias Alexander Franke
71808aaa81 [WarhammerComBridge] Bridge for Warhammer Community blog (#4610)
* [WarhammerComBridge] Bridge for Warhammer Community blog

* Fix Linter issues
2025-08-04 00:10:58 +02:00
Anton Smirnov
2ca696c1cf [EpicGamesFreeBridge] productSlug can be null; also add a universal future-proof-ish fallback (#4595)
* productSlug can be null, do more discovery, add fallback

* productSlug can be garbage too, remove it completely
2025-08-03 23:59:42 +02:00
Sebastian K
c90b98b965 Error handling in ExplosmBridge (#4600)
Skip further processing if element was not found to avoid errors
2025-08-03 23:58:24 +02:00
Quentin B.
8e880de3d2 [CentreFranceBridge] Fix parser following website update (#4596)
* [CentreFranceBridge] Fix parser following website update

* [CentreFranceBridge] Fix empty content

* [CentreFranceBridge] Fix title parsing
2025-08-03 23:52:06 +02:00
Tone
bfa6c4c080 [HeiseBridge] removes language-info-text, add archive.is link for people without subscription (#4594)
* [HeiseBridge] removes language-info-text, add archive.is link for people without subscription

* fix annoying phpcs
2025-08-03 23:50:54 +02:00
User.
5ab938ada7 [WaggaCouncilBridge] Add bridge (#4593)
Co-authored-by: Scrub000 <scrub@example.com>
2025-08-03 23:49:10 +02:00
Petr Prenghy
4d2fe2f12d [NasestrechaBridge] Add bridge (#4591)
* Add files via upload

Bridge for NaseStrecha.cz - NaseStrecha.cz is a specialized Czech news and advice portal focusing on roofs, construction, and home improvement, offering reliable expert guidance on roofing materials, insulation, and energy-saving techniques nasestrecha.cz . It is run by the team behind the Strechy-Solar-Remeslo trade fair and includes up-to-date news, practical tips, and industry events

* phpcs fix

* Bridge for i4wifi.cz for product news.
The website i4wifi.cz is a wholesale distributor specializing in wireless, networking, and photovoltaic equipment, offering products from brands like MikroTik, Ubiquiti, and Hikvision. It provides a wide range of network solutions, technical support, and training services for businesses and professional installers in the Czech Republic and beyond.
2025-08-03 23:46:35 +02:00
Mynacol
4c0b97d605 [ZeitBridge] Add advertorial marker to article
So users are aware that it's a paid article.

Some might still find them interesting, so we cannot just filter them
away.
2025-07-20 01:35:28 +02:00
Mynacol
1d5bcba41f [ZeitBridge] Hide magazine ads in articles
Test article: https://www.zeit.de/campus/2025/03/kyoto-university-abschlussfeier-kostueme-japan
2025-07-20 01:35:28 +02:00
Mynacol
d19ce75d4b Merge pull request #4613 from Mynacol/golem-add-table
[GolemBridge] Add tables to content
2025-07-16 13:53:53 +02:00
Mynacol
bfbe2abdce [GolemBridge] Add tables to content
For example the following article has such tables that should be
included:
https://www.golem.de/news/immobilien-mieten-oder-kaufen-warum-es-dabei-nicht-nur-ums-geld-geht-2507-197406.html
2025-07-16 11:50:00 +00:00
Jonathan Kay
354cea09a7 [GoComicsBridge] Add fallback when link to current comic is missing (#4589) 2025-06-08 21:57:41 +02:00
sysadminstory
8dada08e69 [IdealoBridge] Bypass bot protection (#4588)
Add some headers (User-Agent, Accept, Accept-Language) and activate
compression to bypass the bot protection
2025-06-07 23:31:02 +02:00
Jonathan Kay
514b3edf0b [GoComicsBridge] Fix for JSON being removed (#4585)
- Now redirects to first comic from landing page
- Switched to meta tags
2025-06-05 23:41:20 +02:00
Tobias Alexander Franke
7aa54602cf [FabBridge] Pull 100% discounted items via Fab API (#4584)
* [FabBridge] Pull 100% discounted items via Fab API

* [FabBridge] Linter fixes
2025-06-04 22:15:28 +02:00
51 changed files with 1784 additions and 581 deletions

View File

@@ -122,5 +122,5 @@ jobs:
run: |
export COMMIT_MESSAGE="Added tests for PR ${{github.event.number}}"
git add .
git commit -m "$COMMIT_MESSAGE"
git commit -m "$COMMIT_MESSAGE" || exit 0
git push

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-versions: ['7.4', '8.0', '8.1']
php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2

View File

@@ -46,7 +46,7 @@ Requires minimum PHP 7.4.
* `TwitchBridge`: [Fetches videos from channel](https://rss-bridge.org/bridge01/#bridge-TwitchBridge)
* `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)
* `YouTubeCommunityTabBridge`: [Fetches posts from a channel's Posts tab](https://rss-bridge.org/bridge01/#bridge-YouTubeCommunityTabBridge)
## Tutorial

View File

@@ -2,7 +2,7 @@
class AmazonPriceTrackerBridge extends BridgeAbstract
{
const MAINTAINER = 'captn3m0, sal0max';
const MAINTAINER = 'captn3m0, sal0max, bagnacauda';
const NAME = 'Amazon Price Tracker';
const URI = 'https://www.amazon.com/';
const CACHE_TIMEOUT = 3600; // 1h
@@ -13,7 +13,7 @@ class AmazonPriceTrackerBridge extends BridgeAbstract
'asin' => [
'name' => 'ASIN',
'required' => true,
'exampleValue' => 'B071GB1VMQ',
'exampleValue' => 'B0923XT6K7',
// https://stackoverflow.com/a/12827734
'pattern' => 'B[\dA-Z]{9}|\d{9}(X|\d)',
],
@@ -169,19 +169,23 @@ EOT;
private function scrapePriceTwister($html)
{
$str = $html->find('.twister-plus-buying-options-price-data', 0);
$json = $html->find('.twister-plus-buying-options-price-data', 0);
if ($json == null) {
return null;
}
$data = json_decode($str->innertext, true);
if (count($data) === 1) {
$data = $data[0];
$data = json_decode($json->innertext, true);
foreach ($data as $key => $value) {
$value = $value[0];
return [
'displayPrice' => $data['displayPrice'],
'currency' => $data['currency'],
'shipping' => '0',
'displayPrice' => $value['displayPrice'],
'price' => $value['priceAmount'],
'currency' => $value['currencySymbol'],
'shipping' => null,
];
}
return false;
return null;
}
private function scrapePriceGeneric($html)
@@ -206,9 +210,21 @@ EOT;
}
$priceString = str_replace(str_split(self::WHITESPACE), '', $priceDiv->plaintext);
preg_match('/(\d+\.\d{0,2})/', $priceString, $matches);
$price = null;
$priceFound = false;
// find longest repeated string
for ($offset = 0; $offset < strlen($priceString); $offset++) {
for ($length = 1; substr_count($priceString, substr($priceString, $offset, $length + 1)) >= 2; $length++) {
$priceFound = true;
}
if ($priceFound) {
$price = substr($priceString, $offset, $length);
break;
}
}
$price = $matches[0] ?? null;
$currency = str_replace($price, '', $priceString);
if ($price != null && $currency != null) {
@@ -216,7 +232,7 @@ EOT;
'price' => $price,
'displayPrice' => null,
'currency' => $currency,
'shipping' => '0'
'shipping' => null
];
}
return $default;
@@ -227,7 +243,7 @@ EOT;
$html = $this->getHtml();
$this->title = $this->getTitle($html);
$image = $this->getImage($html);
$data = $this->scrapePriceGeneric($html);
$data = $this->scrapePriceTwister($html) ?? $this->scrapePriceGeneric($html);
// render
$content = '';
@@ -236,7 +252,7 @@ EOT;
$price = sprintf('%s %s', $data['price'], $data['currency']);
}
$content .= sprintf('%s<br>Price: %s', $image, $price);
if ($data['shipping'] !== '0') {
if ($data['shipping'] !== null) {
$content .= sprintf('<br>Shipping: %s %s</br>', $data['shipping'], $data['currency']);
}

View File

@@ -126,7 +126,7 @@ class AnnasArchiveBridge extends BridgeAbstract
return;
}
$elements = $list->find('.w-full > .mb-4 > div');
$elements = $list->find('#aarecord-list > div');
foreach ($elements as $element) {
// stop added entries once partial match list starts
if (str_contains($element->innertext, 'partial match')) {

View File

@@ -1,80 +0,0 @@
<?php
class AskfmBridge extends BridgeAbstract
{
const MAINTAINER = 'az5he6ch, logmanoriginal';
const NAME = 'Ask.fm Answers';
const URI = 'https://ask.fm/';
const CACHE_TIMEOUT = 300; //5 min
const DESCRIPTION = 'Returns answers from an Ask.fm user';
const PARAMETERS = [
'Ask.fm username' => [
'u' => [
'name' => 'Username',
'required' => true,
'exampleValue' => 'ApprovedAndReal'
]
]
];
public function collectData()
{
$html = getSimpleHTMLDOM($this->getURI());
$html = defaultLinkTo($html, self::URI);
foreach ($html->find('article.streamItem-answer') as $element) {
$item = [];
$item['uri'] = $element->find('a.streamItem_meta', 0)->href;
$question = trim($element->find('header.streamItem_header', 0)->innertext);
$item['title'] = trim(
htmlspecialchars_decode(
$element->find('header.streamItem_header', 0)->plaintext,
ENT_QUOTES
)
);
$item['timestamp'] = strtotime($element->find('time', 0)->datetime);
$var = $element->find('div.streamItem_content', 0);
$answer = trim($var->innertext ?? '');
// This probably should be cleaned up, especially for YouTube embeds
if ($visual = $element->find('div.streamItem_visual', 0)) {
$visual = $visual->innertext;
}
// Fix tracking links, also doesn't work
foreach ($element->find('a') as $link) {
if (strpos($link->href, 'l.ask.fm') !== false) {
$link->href = $link->plaintext;
}
}
$item['content'] = '<p>' . $question
. '</p><p>' . $answer
. '</p><p>' . $visual . '</p>';
$this->items[] = $item;
}
}
public function getName()
{
if (!is_null($this->getInput('u'))) {
return self::NAME . ' : ' . $this->getInput('u');
}
return parent::getName();
}
public function getURI()
{
if (!is_null($this->getInput('u'))) {
return self::URI . urlencode($this->getInput('u'));
}
return parent::getURI();
}
}

View File

@@ -330,157 +330,163 @@ class BlueskyBridge extends BridgeAbstract
}
//reply
if ($replyContext && isset($post['reply']) && !isset($post['reply']['parent']['notFound'])) {
if ($replyContext && isset($post['reply']) && isset($post['reply']['parent'])) {
$replyPost = $post['reply']['parent'];
$replyPostRecord = $replyPost['record'];
$description .= '<hr/>';
$description .= '<p>';
$replyPostAuthorDID = $replyPost['author']['did'];
$replyPostAuthorHandle = $replyPost['author']['handle'] !== 'handle.invalid' ? '<i>@' . $replyPost['author']['handle'] . '</i> ' : '';
$replyPostDisplayName = $replyPost['author']['displayName'] ?? '';
$replyPostDisplayName = e($replyPostDisplayName);
$replyPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyPost['author'], 'url') . '/post/' . explode('app.bsky.feed.post/', $replyPost['uri'])[1];
if (isset($replyPost['notFound']) && $replyPost['notFound']) { //deleted post
$description .= 'Replied to post was deleted.';
} elseif (isset($replyPost['blocked']) && $replyPost['blocked']) { //blocked by quote author
$description .= 'Author of replied to post has blocked OP.';
} else {
$replyPostRecord = $replyPost['record'];
$replyPostAuthorDID = $replyPost['author']['did'];
$replyPostAuthorHandle = $replyPost['author']['handle'] !== 'handle.invalid' ? '<i>@' . $replyPost['author']['handle'] . '</i> ' : '';
$replyPostDisplayName = $replyPost['author']['displayName'] ?? '';
$replyPostDisplayName = e($replyPostDisplayName);
$replyPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyPost['author'], 'url') . '/post/' . explode('app.bsky.feed.post/', $replyPost['uri'])[1];
// reply post
$description .= $this->getPostDescription(
$replyPostDisplayName,
$replyPostAuthorHandle,
$replyPostUri,
$replyPostRecord,
'reply'
);
// reply post
$description .= $this->getPostDescription(
$replyPostDisplayName,
$replyPostAuthorHandle,
$replyPostUri,
$replyPostRecord,
'reply'
);
if (isset($replyPostRecord['embed']['$type'])) {
//post link embed
if ($replyPostRecord['embed']['$type'] === 'app.bsky.embed.external') {
$description .= $this->parseExternal($replyPostRecord['embed']['external'], $replyPostAuthorDID);
} elseif (
$replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&
$replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.external'
) {
$description .= $this->parseExternal($replyPostRecord['embed']['media']['external'], $replyPostAuthorDID);
}
//post images
if (
$replyPostRecord['embed']['$type'] === 'app.bsky.embed.images' ||
(
if (isset($replyPostRecord['embed']['$type'])) {
//post link embed
if ($replyPostRecord['embed']['$type'] === 'app.bsky.embed.external') {
$description .= $this->parseExternal($replyPostRecord['embed']['external'], $replyPostAuthorDID);
} elseif (
$replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&
$replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.images'
)
) {
$images = $replyPost['embed']['images'] ?? $replyPost['embed']['media']['images'];
foreach ($images as $image) {
$description .= $this->getPostImageDescription($image);
$replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.external'
) {
$description .= $this->parseExternal($replyPostRecord['embed']['media']['external'], $replyPostAuthorDID);
}
//post images
if (
$replyPostRecord['embed']['$type'] === 'app.bsky.embed.images' ||
(
$replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&
$replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.images'
)
) {
$images = $replyPost['embed']['images'] ?? $replyPost['embed']['media']['images'];
foreach ($images as $image) {
$description .= $this->getPostImageDescription($image);
}
}
//post video
if (
$replyPostRecord['embed']['$type'] === 'app.bsky.embed.video' ||
(
$replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&
$replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.video'
)
) {
$description .= $this->getPostVideoDescription(
$replyPostRecord['embed']['video'] ?? $replyPostRecord['embed']['media']['video'],
$replyPostAuthorDID
);
}
}
$description .= '</p>';
//post video
//quote post
if (
$replyPostRecord['embed']['$type'] === 'app.bsky.embed.video' ||
(
$replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&
$replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.video'
)
isset($replyPostRecord['embed']) &&
($replyPostRecord['embed']['$type'] === 'app.bsky.embed.record' || $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia') &&
isset($replyPost['embed']['record'])
) {
$description .= $this->getPostVideoDescription(
$replyPostRecord['embed']['video'] ?? $replyPostRecord['embed']['media']['video'],
$replyPostAuthorDID
);
}
}
$description .= '</p>';
$description .= '<p>';
$replyQuotedRecord = $replyPost['embed']['record']['record'] ?? $replyPost['embed']['record'];
//quote post
if (
isset($replyPostRecord['embed']) &&
($replyPostRecord['embed']['$type'] === 'app.bsky.embed.record' || $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia') &&
isset($replyPost['embed']['record'])
) {
$description .= '<p>';
$replyQuotedRecord = $replyPost['embed']['record']['record'] ?? $replyPost['embed']['record'];
if (isset($replyQuotedRecord['notFound']) && $replyQuotedRecord['notFound']) { //deleted post
$description .= 'Quoted post deleted.';
} elseif (isset($replyQuotedRecord['detached']) && $replyQuotedRecord['detached']) { //detached quote
$uri_explode = explode('/', $replyQuotedRecord['uri']);
$uri_reconstructed = self::URI . '/profile/' . $uri_explode[2] . '/post/' . $uri_explode[4];
$description .= '<a href="' . $uri_reconstructed . '">Quoted post detached.</a>';
} elseif (isset($replyQuotedRecord['blocked']) && $replyQuotedRecord['blocked']) { //blocked by quote author
$description .= 'Author of quoted post has blocked OP.';
} elseif (
($replyQuotedRecord['$type'] ?? '') === 'app.bsky.feed.defs#generatorView' ||
($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#listView'
) {
$description .= $this->getListFeedDescription($replyQuotedRecord);
} elseif (
($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.starterpack' ||
($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#starterPackViewBasic'
) {
$description .= $this->getStarterPackDescription($replyPost['embed']['record']);
} else {
$quotedAuthorDid = $replyQuotedRecord['author']['did'];
$quotedDisplayName = $replyQuotedRecord['author']['displayName'] ?? '';
$quotedDisplayName = e($quotedDisplayName);
$quotedAuthorHandle = $replyQuotedRecord['author']['handle'] !== 'handle.invalid' ? '<i>@' . $replyQuotedRecord['author']['handle'] . '</i>' : '';
if (isset($replyQuotedRecord['notFound']) && $replyQuotedRecord['notFound']) { //deleted post
$description .= 'Quoted post deleted.';
} elseif (isset($replyQuotedRecord['detached']) && $replyQuotedRecord['detached']) { //detached quote
$uri_explode = explode('/', $replyQuotedRecord['uri']);
$uri_reconstructed = self::URI . '/profile/' . $uri_explode[2] . '/post/' . $uri_explode[4];
$description .= '<a href="' . $uri_reconstructed . '">Quoted post detached.</a>';
} elseif (isset($replyQuotedRecord['blocked']) && $replyQuotedRecord['blocked']) { //blocked by quote author
$description .= 'Author of quoted post has blocked OP.';
} elseif (
($replyQuotedRecord['$type'] ?? '') === 'app.bsky.feed.defs#generatorView' ||
($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#listView'
) {
$description .= $this->getListFeedDescription($replyQuotedRecord);
} elseif (
($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.starterpack' ||
($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#starterPackViewBasic'
) {
$description .= $this->getStarterPackDescription($replyPost['embed']['record']);
} else {
$quotedAuthorDid = $replyQuotedRecord['author']['did'];
$quotedDisplayName = $replyQuotedRecord['author']['displayName'] ?? '';
$quotedDisplayName = e($quotedDisplayName);
$quotedAuthorHandle = $replyQuotedRecord['author']['handle'] !== 'handle.invalid' ? '<i>@' . $replyQuotedRecord['author']['handle'] . '</i>' : '';
$parts = explode('/', $replyQuotedRecord['uri']);
$quotedPostId = end($parts);
$quotedPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyQuotedRecord['author'], 'url') . '/post/' . $quotedPostId;
$parts = explode('/', $replyQuotedRecord['uri']);
$quotedPostId = end($parts);
$quotedPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyQuotedRecord['author'], 'url') . '/post/' . $quotedPostId;
//quoted post - post
$description .= $this->getPostDescription(
$quotedDisplayName,
$quotedAuthorHandle,
$quotedPostUri,
$replyQuotedRecord,
'quote'
);
//quoted post - post
$description .= $this->getPostDescription(
$quotedDisplayName,
$quotedAuthorHandle,
$quotedPostUri,
$replyQuotedRecord,
'quote'
);
if (isset($replyQuotedRecord['value']['embed']['$type'])) {
//quoted post - post link embed
if ($replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.external') {
$description .= $this->parseExternal($replyQuotedRecord['value']['embed']['external'], $quotedAuthorDid);
}
if (isset($replyQuotedRecord['value']['embed']['$type'])) {
//quoted post - post link embed
if ($replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.external') {
$description .= $this->parseExternal($replyQuotedRecord['value']['embed']['external'], $quotedAuthorDid);
}
//quoted post - post video
if (
$replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.video' ||
(
$replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&
$replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.video'
)
) {
$description .= $this->getPostVideoDescription(
$replyQuotedRecord['value']['embed']['video'] ?? $replyQuotedRecord['value']['embed']['media']['video'],
$quotedAuthorDid
);
}
//quoted post - post video
if (
$replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.video' ||
(
$replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&
$replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.video'
)
) {
$description .= $this->getPostVideoDescription(
$replyQuotedRecord['value']['embed']['video'] ?? $replyQuotedRecord['value']['embed']['media']['video'],
$quotedAuthorDid
);
}
//quoted post - post images
if (
$replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.images' ||
(
$replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&
$replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.images'
)
) {
foreach ($replyQuotedRecord['embeds'] as $embed) {
if (
$embed['$type'] === 'app.bsky.embed.images#view' ||
($embed['$type'] === 'app.bsky.embed.recordWithMedia#view' && $embed['media']['$type'] === 'app.bsky.embed.images#view')
) {
$images = $embed['images'] ?? $embed['media']['images'];
foreach ($images as $image) {
$description .= $this->getPostImageDescription($image);
//quoted post - post images
if (
$replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.images' ||
(
$replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&
$replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.images'
)
) {
foreach ($replyQuotedRecord['embeds'] as $embed) {
if (
$embed['$type'] === 'app.bsky.embed.images#view' ||
($embed['$type'] === 'app.bsky.embed.recordWithMedia#view' && $embed['media']['$type'] === 'app.bsky.embed.images#view')
) {
$images = $embed['images'] ?? $embed['media']['images'];
foreach ($images as $image) {
$description .= $this->getPostImageDescription($image);
}
}
}
}
}
}
$description .= '</p>';
}
$description .= '</p>';
}
}
@@ -496,7 +502,7 @@ class BlueskyBridge extends BridgeAbstract
$videoMime = $video['mimeType'];
$thumbnail = "poster=\"https://video.bsky.app/watch/$authorDID/$videoCID/thumbnail.jpg\"" ?? '';
$videoURL = "https://bsky.social/xrpc/com.atproto.sync.getBlob?did=$authorDID&cid=$videoCID";
return "<figure><video loop $thumbnail controls src=\"$videoURL\" type=\"$videoMime\"/></figure>";
return "<figure><video loop $thumbnail preload=\"none\" controls src=\"$videoURL\" type=\"$videoMime\"/></figure>";
}
private function getPostImageDescription(array $image)

View File

@@ -72,15 +72,9 @@ class CentreFranceBridge extends BridgeAbstract
$newspaperUrl = 'https://www.' . $this->getInput('newspaper') . '/' . $localitySlug . '/';
$html = getSimpleHTMLDOM($newspaperUrl);
// Articles are detected through their titles
foreach ($html->find('.c-titre') as $articleTitleDOMElement) {
$articleLinkDOMElement = $articleTitleDOMElement->find('a', 0);
// Ignore articles in the « Les + partagés » block
if (strpos($articleLinkDOMElement->id, 'les_plus_partages') !== false) {
continue;
}
// Articles are detected through a standard tag
foreach ($html->find('article') as $articleDOMElement) {
$articleLinkDOMElement = $articleDOMElement->find('a', 0);
$articleURI = $articleLinkDOMElement->href;
// If the URI has already been processed, ignore it
@@ -96,7 +90,7 @@ class CentreFranceBridge extends BridgeAbstract
$articleTitle = '';
// If article is reserved for subscribers
if ($articleLinkDOMElement->find('span.premium-picto', 0)) {
if ($articleLinkDOMElement->find('span.premium-icon', 0)) {
if ($this->getInput('remove-reserved-for-subscribers-articles') === true) {
continue;
}
@@ -104,18 +98,22 @@ class CentreFranceBridge extends BridgeAbstract
$articleTitle .= '🔒 ';
}
$articleTitleDOMElement = $articleLinkDOMElement->find('span[data-tb-title]', 0);
if ($articleTitleDOMElement === null) {
continue;
}
if ($limit > 0 && count($this->items) === $limit) {
break;
}
$articleTitle .= $articleLinkDOMElement->find('span[data-tb-title]', 0)->innertext;
$articleFullURI = urljoin('https://www.' . $this->getInput('newspaper') . '/', $articleURI);
// Loop through each possible title class name
for ($i = 1; $i <= 3; $i++) {
$articleTitleDOMElement = $articleLinkDOMElement->find('.typo-card-title-' . $i, 0);
if (!$articleTitleDOMElement instanceof \simple_html_dom_node) {
continue;
}
$articleTitle .= $articleTitleDOMElement->text();
break;
}
$articleFullURI = urljoin('https://www.' . $this->getInput('newspaper') . '/', $articleURI);
$item = [
'title' => $articleTitle,
'uri' => $articleFullURI,
@@ -184,7 +182,7 @@ class CentreFranceBridge extends BridgeAbstract
$articleTags = $html->find('#content>div.flex+div.grid section>.bg-gray-light>a.border-gray-dark');
if (is_array($articleTags)) {
$item['categories'] = array_map(static fn ($articleTag) => $articleTag->innertext, $articleTags);
$item['categories'] = array_map(static fn ($articleTag) => html_entity_decode($articleTag->innertext), $articleTags);
}
$explode = explode('_', $uri);
@@ -195,6 +193,10 @@ class CentreFranceBridge extends BridgeAbstract
$item['uid'] = $uid;
}
if (!isset($item['content'])) {
$item['content'] = '';
}
// If the article is a "grand format", we use another parsing strategy
if ($item['content'] === '' && $html->find('article') !== []) {
$articleContent = $html->find('article > section');

186
bridges/ComickBridge.php Normal file
View File

@@ -0,0 +1,186 @@
<?php
class ComickBridge extends BridgeAbstract
{
const MAINTAINER = 'phantop';
const NAME = 'Comick';
const URI = 'https://comick.io/';
const DESCRIPTION = 'Returns the latest chapters for a manga on comick.io.';
const PARAMETERS = [[
'slug' => [
'name' => 'Manga Slug',
'type' => 'text',
'required' => true,
'title' => 'The part of the URL after /comic/',
'exampleValue' => '00-kusuriya-no-hitorigoto-maomao-no-koukyuu-nazotoki-techou'
],
'lang' => [
'name' => 'Language',
'type' => 'list',
'title' => 'Language for comic (list is # of comics, descending)',
'values' => [
'English' => 'en',
'Brazilian Portuguese' => 'pt-br',
'Spanish Latin American' => 'es-la',
'Russian' => 'ru',
'Vietnamese' => 'vi',
'French' => 'fr',
'Polish' => 'pl',
'Indonesian' => 'id',
'Turkish' => 'tr',
'Italian' => 'it',
'Spanish; Castilian' => 'es',
'Ukrainian' => 'uk',
'Arabic' => 'ar',
'Hong Kong (Traditional Chinese)' => 'zh-hk',
'Hungarian' => 'hu',
'Chinese' => 'zh',
'German' => 'de',
'Korean' => 'ko',
'Thai' => 'th',
'Catalan; Valencian' => 'ca',
'Bulgarian' => 'bg',
'Persian' => 'fa',
'Romanian, Moldavian, Moldovan' => 'ro',
'Czech' => 'cs',
'Mongolian' => 'mn',
'Portuguese' => 'pt',
'Hebrew (modern)' => 'he',
'Hindi' => 'hi',
'Filipino/Tagalog' => 'tl',
'Finnish' => 'fi',
'Malay' => 'ms',
'Basque' => 'eu',
'Kazakh' => 'kk',
'Serbian' => 'sr',
'Burmese' => 'my',
'Japanese' => 'ja',
'Greek, Modern' => 'el',
'Dutch' => 'nl',
'Bengali' => 'bn',
'Uzbek' => 'uz',
'Esperanto' => 'eo',
'Lithuanian' => 'lt',
'Georgian' => 'ka',
'Danish' => 'da',
'Tamil' => 'ta',
'Swedish' => 'sv',
'Belarusian' => 'be',
'Chuvash' => 'cv',
'Croatian' => 'hr',
'Latin' => 'la',
'Nepali' => 'ne',
'Urdu' => 'ur',
'Galician' => 'gl',
'Norwegian' => 'no',
'Albanian' => 'sq',
'Irish' => 'ga',
'Javanese' => 'jv',
'Telugu' => 'te',
'Slovene' => 'sl',
'Estonian' => 'et',
'Azerbaijani' => 'az',
'Slovak' => 'sk',
'Afrikaans' => 'af',
'Latvian' => 'lv',
],
'defaultValue' => 'en'
],
'fetch' => [
'name' => 'Fetch chapter page images',
'type' => 'list',
'title' => 'Places chapter images in feed contents. Entries will consume more bandwidth.',
'defaultValue' => 'c',
'values' => [
'None' => 'n',
'Content' => 'c',
'Enclosure' => 'e'
]
],
'limit' => [
'name' => 'Limit',
'type' => 'number',
'title' => 'Maximum number of chapters to return',
'defaultValue' => 10
]
]];
private $title;
private function getComick($url)
{
$API = 'https://api.comick.fun';
// Need a non-cURL UA, otherwise we get Cloudflare 403'd
$opts = [
CURLOPT_USERAGENT => 'rss-bridge (https://github.com/RSS-Bridge/rss-bridge)'
];
$content = getContents("$API/$url", [], $opts);
return json_decode($content, true);
}
public function collectData()
{
$slug = $this->getInput('slug');
$lang = $this->getInput('lang');
$limit = $this->getInput('limit');
$manga = $this->getComick("comic/$slug");
$hid = $manga['comic']['hid'];
$this->title = $manga['comic']['title'];
$manga = $this->getComick("comic/$hid/chapters?lang=$lang&limit=$limit");
foreach ($manga['chapters'] as $chapter) {
$hid = $chapter['hid'];
$item['author'] = implode(', ', $chapter['group_name']);
$item['timestamp'] = strtotime($chapter['created_at']);
$item['uri'] = $this->getURI() . '/' . $hid;
$item['title'] = '';
if ($chapter['vol']) {
$item['title'] .= ' Vol. ' . $chapter['vol'];
}
if ($chapter['chap']) {
$item['title'] .= ' Ch. ' . $chapter['chap'];
}
if ($chapter['title']) {
$item['title'] .= ' - ' . $chapter['title'];
}
if ($this->getInput('fetch') != 'n') {
$chapter = $this->getComick("chapter/$hid");
if (isset($chapter['chapter']['md_images'])) {
$item['content'] = '';
foreach ($chapter['chapter']['md_images'] as $image) {
$img = 'https://meo.comick.pictures/' . $image['b2key'];
if ($this->getInput('fetch') == 'c') {
$item['content'] .= '<img src="' . $img . '" />';
}
if ($this->getInput('fetch') == 'e') {
$item['enclosures'][] = $img;
}
}
}
}
$this->items[] = $item;
}
}
public function getName()
{
if ($this->title) {
return parent::getName() . ' - ' . $this->title;
}
return parent::getName();
}
public function getURI()
{
if ($this->getInput('slug')) {
return self::URI . 'comic/' . $this->getInput('slug');
}
return parent::getURI();
}
}

View File

@@ -15,7 +15,7 @@ class CubariProxyBridge extends BridgeAbstract
'MangAventure' => 'mangadventure',
'MangaDex' => 'mangadex',
'MangaKatana' => 'mangakatana',
'MangaSee' => 'mangasee',
'WeebCentral' => 'weebcentral',
]
],
'series' => [

View File

@@ -37,6 +37,26 @@ class DRKBlutspendeBridge extends FeedExpander
]
];
const OFFER_LOW_PRIORITIES = [
'Imbiss nach der Blutspende',
'Registrierung als Stammzellspender',
'Typisierung möglich!',
'Allgemeine Informationen',
'Krankenkassen belohnen Blutspender',
'Wer benötigt eigentlich eine Blutspende?',
'Win-Win-Situation für die Gesundheit!',
'Terminreservierung',
'Du möchtest das erste Mal Blut spenden?',
'Spende-Check',
'Sie haben Fragen vor Ihrer Blutspende?'
];
const IMAGE_PRIORITIES = [
'DRK',
'Imbiss',
'Obst',
];
public function collectData()
{
$limitItems = intval($this->getInput('limit_items'));
@@ -45,37 +65,116 @@ class DRKBlutspendeBridge extends FeedExpander
protected function parseItem(array $item)
{
$html = getSimpleHTMLDOM($item['uri']);
$html = getSimpleHTMLDOMCached($item['uri']);
$detailsElement = $html->find('.details', 0);
$dateElement = $detailsElement->find('.datum', 0);
$dateLines = self::explodeLines($dateElement->plaintext);
$addressElement = $detailsElement->find('.adresse', 0);
$addressLines = self::explodeLines($addressElement->plaintext);
$dateLines = self::explodeLines($detailsElement->find('.datum', 0)->plaintext);
$addressLines = self::explodeLines($detailsElement->find('.adresse', 0)->plaintext);
$infoElement = $detailsElement->find('.angebote > h4 + p', 0);
$info = $infoElement ? $infoElement->innertext : '';
$info = $infoElement ? trim($infoElement->plaintext) : '';
$imageElements = $detailsElement->find('.fotos img');
$offers = self::parseOffers($detailsElement->find('.angebote .item'));
$item['title'] = $dateLines[0] . ' ' . $dateLines[1] . ' ' . $addressLines[0] . ' - ' . $addressLines[1];
$images = self::parseImages($detailsElement->find('.fotos', 0));
usort($images, function ($imageA, $imageB): int {
list($titleA) = $imageA;
list($titleB) = $imageB;
$prioA = 0;
$prioB = 0;
foreach (self::IMAGE_PRIORITIES as $prioIndex => $prioTitleNeedle) {
if (stripos($titleA, $prioTitleNeedle) !== false) {
$prioA = $prioIndex + 1;
}
if (stripos($titleB, $prioTitleNeedle) !== false) {
$prioB = $prioIndex + 1;
}
}
return $prioA - $prioB;
});
$item['content'] = <<<HTML
<p><b>{$dateLines[0]} {$dateLines[1]}</b></p>
<p>{$addressElement->innertext}</p>
<p>{$info}</p>
$itemContent = <<<HTML
<div>
<p>
<b>{$dateLines[0]} {$dateLines[1]}</b><br>
{$addressLines[3]}
</p>
<p>
<b>{$addressLines[0]}</b><br>
{$addressLines[1]}<br>
{$addressLines[2]}
</p>
</div>
HTML;
foreach ($imageElements as $imageElement) {
$src = $imageElement->getAttribute('src');
$item['content'] .= <<<HTML
<p><img src="{$src}"></p>
if ($info) {
$itemContent .= <<<HTML
<div>
<h3>Infos</h3>
<p>{$info}</p>
</div>
HTML;
}
$majorOffers = array_filter($offers, fn($title): bool => !in_array($title, self::OFFER_LOW_PRIORITIES), ARRAY_FILTER_USE_KEY);
foreach ($majorOffers as $offerTitle => list($offerText, $offerImages)) {
$itemContent .= <<<HTML
<div>
<h3>{$offerTitle}</h3>
<p>{$offerText}</p>
HTML;
foreach ($offerImages as list($imageTitle, $imageUrl)) {
$itemContent .= <<<HTML
<figure>
<img src="{$imageUrl}">
<figcaption>{$imageTitle}</figcaption>
</figure>
HTML;
}
$itemContent .= <<<HTML
</div>
HTML;
}
if (count($images) > 0) {
$itemContent .= <<<HTML
<div>
<h3>Fotos</h3>
HTML;
foreach ($images as list($imageTitle, $imageUrl)) {
$itemContent .= <<<HTML
<figure>
<img src="{$imageUrl}">
<figcaption>{$imageTitle}</figcaption>
</figure>
HTML;
}
$itemContent .= <<<HTML
</div>
HTML;
}
$minorOffers = array_filter($offers, fn($title): bool => in_array($title, self::OFFER_LOW_PRIORITIES), ARRAY_FILTER_USE_KEY);
foreach ($minorOffers as $offerTitle => list($offerText)) {
$itemContent .= <<<HTML
<div>
<h3>{$offerTitle}</h3>
<p>{$offerText}</p>
</div>
HTML;
}
$item['title'] = $dateLines[0] . ' ' . $dateLines[1] . ' ' . $addressLines[0] . ' - ' . $addressLines[1];
$item['content'] = $itemContent;
$item['description'] = null;
$item['enclosures'] = array_map(
function ($image): string {
list($title, $url) = $image;
return $url . '#' . urlencode(str_replace(' ', '_', $title));
},
$images
);
return $item;
}
@@ -97,6 +196,67 @@ class DRKBlutspendeBridge extends FeedExpander
return self::BASE_URI . '/blutspendetermine/termine.rss?date_to=' . $dateTo . '&radius=' . $radius . '&term=' . $term;
}
private function parseImages($parentElement): array
{
$images = [];
if ($parentElement) {
$elements = $parentElement->find('a[data-lightbox]');
foreach ($elements as $i => $element) {
$url = trim($element->getAttribute('href'));
if (!$url) {
continue;
}
$title = trim($element->getAttribute('title'));
if (!$title) {
$number = $i + 1;
$title = "Foto {$number}";
}
$images[] = [$title, $url];
}
}
return $images;
}
private function parseOffers($offerElements): array
{
$offers = [];
foreach ($offerElements as $element) {
$title = self::getCleanPlainText($element->find(':is(h1,h2,h3,h4,h5,h6)', 0));
$text = trim(substr(self::getCleanPlainText($element), strlen($title)));
if (!$title || !$text) {
continue;
}
$linkElements = $element->find('a');
foreach ($linkElements as $linkElement) {
$linkText = trim($linkElement->plaintext);
$linkUrl = trim($linkElement->getAttribute('href'));
if (!$linkText || !$linkUrl) {
continue;
}
$linkHtml = <<<HTML
<a href="{$linkUrl}" target="_blank">{$linkText}</a>
HTML;
$text = str_replace($linkText, $linkHtml, $text);
}
$offers[$title] = [$text, self::parseImages($element)];
}
return $offers;
}
private function getCleanPlainText($htmlElement): string
{
return $htmlElement ? trim(preg_replace('/\s+/', ' ', html_entity_decode($htmlElement->plaintext))) : '';
}
/**
* Returns an array of strings, each of which is a substring of string formed by splitting it on boundaries formed by line breaks.
*/

View File

@@ -44,68 +44,32 @@ class DailymotionBridge extends BridgeAbstract
public function getIcon()
{
return 'https://static1-ssl.dmcdn.net/images/neon/favicons/android-icon-36x36.png.vf806ca4ed0deed812';
return 'https://static1.dmcdn.net/neon-user-ssr/prod/favicons/apple-icon-60x60.831b96ed0a8eca7f6539.png';
}
public function collectData()
{
if ($this->queriedContext === 'By username' || $this->queriedContext === 'By playlist id') {
$apiJson = getContents($this->getApiUrl());
$apiData = json_decode($apiJson, true);
$apiJson = getContents($this->getApiUrl());
$apiData = json_decode($apiJson, true);
if ($this->queriedContext === 'By playlist id') {
$this->feedName = $this->getPlaylistTitle($this->getInput('p'));
foreach ($apiData['list'] as $apiItem) {
$item = [];
$item['uri'] = $apiItem['url'];
$item['uid'] = $apiItem['id'];
$item['title'] = $apiItem['title'];
$item['timestamp'] = $apiItem['created_time'];
$item['author'] = $apiItem['owner.screenname'];
$item['content'] = '<p><a href="' . $apiItem['url'] . '">
<img src="' . $apiItem['thumbnail_url'] . '"></a></p><p>' . $apiItem['description'] . '</p>';
$item['categories'] = $apiItem['tags'];
$item['enclosures'][] = $apiItem['thumbnail_url'];
$this->items[] = $item;
}
}
if ($this->queriedContext === 'From search results') {
$html = getSimpleHTMLDOM($this->getURI());
foreach ($apiData['list'] as $apiItem) {
$item = [];
foreach ($html->find('div.media a.preview_link') as $element) {
$item = [];
$item['uri'] = $apiItem['url'];
$item['uid'] = $apiItem['id'];
$item['title'] = $apiItem['title'];
$item['timestamp'] = $apiItem['created_time'];
$item['author'] = $apiItem['owner.screenname'];
$item['content'] = '<p><a href="' . $apiItem['url'] . '">
<img src="' . $apiItem['thumbnail_url'] . '"></a></p><p>' . $apiItem['description'] . '</p>';
$item['categories'] = $apiItem['tags'];
$item['enclosures'][] = $apiItem['thumbnail_url'];
$item['id'] = str_replace('/video/', '', strtok($element->href, '_'));
$metadata = $this->getMetadata($item['id']);
if (empty($metadata)) {
continue;
}
$item['uri'] = $metadata['uri'];
$item['title'] = $metadata['title'];
$item['timestamp'] = $metadata['timestamp'];
$item['content'] = '<a href="'
. $item['uri']
. '"><img src="'
. $metadata['thumbnailUri']
. '" /></a><br><a href="'
. $item['uri']
. '">'
. $item['title']
. '</a>';
$this->items[] = $item;
if (count($this->items) >= 5) {
break;
}
}
$this->items[] = $item;
}
}
@@ -136,6 +100,7 @@ class DailymotionBridge extends BridgeAbstract
public function getURI()
{
$uri = self::URI;
switch ($this->queriedContext) {
case 'By username':
$uri .= 'user/' . urlencode($this->getInput('u'));
@@ -162,35 +127,11 @@ class DailymotionBridge extends BridgeAbstract
return $uri;
}
private function getMetadata($id)
{
$metadata = [];
$html = getSimpleHTMLDOM(self::URI . 'video/' . $id);
if (!$html) {
return $metadata;
}
$metadata['title'] = $html->find('meta[property=og:title]', 0)->getAttribute('content');
$metadata['timestamp'] = strtotime(
$html->find('meta[property=video:release_date]', 0)->getAttribute('content')
);
$metadata['thumbnailUri'] = $html->find('meta[property=og:image]', 0)->getAttribute('content');
$metadata['uri'] = $html->find('meta[property=og:url]', 0)->getAttribute('content');
return $metadata;
}
private function getPlaylistTitle($id)
{
$title = '';
$url = self::URI . 'playlist/' . $id;
$html = getSimpleHTMLDOM($url);
$title = $html->find('meta[property=og:title]', 0)->getAttribute('content');
return $title;
$apiJson = getContents($this->apiUrl . '/playlist/' . $this->getInput('p'));
$apiData = json_decode($apiJson, true);
return $apiData['name'];
}
private function getApiUrl()
@@ -204,6 +145,9 @@ class DailymotionBridge extends BridgeAbstract
return $this->apiUrl . '/playlist/' . $this->getInput('p')
. '/videos?fields=' . urlencode($this->apiFields) . '&limit=5';
break;
case 'From search results':
return $this->apiUrl . '/videos?search=' . $this->getInput('s') . '&fields=' . urlencode($this->apiFields) . '&limit=5';
break;
}
}
}

View File

@@ -1,30 +0,0 @@
<?php
class DansTonChatBridge extends BridgeAbstract
{
const MAINTAINER = 'Astalaseven';
const NAME = 'DansTonChat Bridge';
const URI = 'https://danstonchat.com/';
const CACHE_TIMEOUT = 21600; //6h
const DESCRIPTION = 'Returns latest quotes from DansTonChat.';
public function collectData()
{
$url = self::URI . 'latest.html';
$dom = getSimpleHTMLDOM($url);
$items = $dom->find('div.item');
foreach ($items as $element) {
$item = [];
$item['uri'] = $element->find('a', 0)->href;
$titleContent = $element->find('h3 a', 0);
if ($titleContent) {
$item['title'] = 'DansTonChat ' . html_entity_decode($titleContent->plaintext, ENT_QUOTES);
} else {
$item['title'] = 'DansTonChat';
}
$item['content'] = $element->find('div.item-content a', 0)->innertext;
$this->items[] = $item;
}
}
}

View File

@@ -59,13 +59,20 @@ class EpicGamesFreeBridge extends BridgeAbstract
) {
continue;
}
$slug = $element['catalogNs']['mappings'][0]['pageSlug'] ?? null;
if ($slug !== null) {
$uri = parent::getURI() . $this->getInput('locale') . '/p/' . $slug;
} else {
// slug not found, show the root promos page
$uri = parent::getURI() . $this->getInput('locale') . '/free-games';
}
$item = [
'author' => $element['seller']['name'],
'content' => $element['description'],
'enclosures' => array_map(fn($item) => $item['url'], $element['keyImages']),
'timestamp' => strtotime($promo['startDate']),
'title' => $element['title'],
'uri' => parent::getURI() . $this->getInput('locale') . '/p/' . $element['productSlug'],
'uri' => $uri,
];
$this->items[] = $item;
}

View File

@@ -36,6 +36,9 @@ class ExplosmBridge extends BridgeAbstract
$html = getSimpleHTMLDOM($url);
$element = $html->find('[class*=ComicImage]', 0);
if (!$element) {
break; // skip, if element was not found
}
$date = $element->find('[class^=Author__Right] p', 0)->plaintext;
$author = str_replace('by ', '', $element->find('[class^=Author__Right] p', 1)->plaintext);
$image = $element->find('img', 0)->src;

43
bridges/FabBridge.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
class FabBridge extends BridgeAbstract
{
const NAME = 'Epic Games Fab.com';
const URI = 'https://www.fab.com';
const DESCRIPTION = 'Limited-Time Free Game Engine Assets';
const MAINTAINER = 'thefranke';
const CACHE_TIMEOUT = 86400;
public function collectData()
{
$url = static::URI . '/i/listings/search?is_discounted=1&is_free=1';
$header = [
'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0',
'Accept: application/json, text/plain, */*',
'Accept-Language: en',
'Accept-Encoding: gzip, deflate, br, zstd',
'Referer: ' . static::URI
];
$json = getContents($url, $header);
$json = json_decode($json);
foreach ($json->results as $item) {
$thumbnail = $item->thumbnails[0]->mediaUrl;
$itemurl = static::URI . '/listings/' . $item->uid;
$itemapiurl = static::URI . '/i/listings/' . $item->uid;
$itemjson = getContents($itemapiurl, $header);
$itemjson = json_decode($itemjson);
$this->items[] = [
'title' => $item->title,
'author' => $item->user->sellerName,
'uri' => $itemurl,
'timestamp' => strtotime($item->lastUpdatedAt),
'content' => '<a href="' . $itemurl . '"><img src="' . $thumbnail . '"></a>' . $itemjson->description,
];
}
}
}

View File

@@ -37,13 +37,14 @@ class FallGuysBridge extends BridgeAbstract
public function collectData()
{
$html = getSimpleHTMLDOM(self::getURI());
$newsData = self::requestJsonData(self::getURI(), false);
$data = json_decode($html->find('#__NEXT_DATA__', 0)->innertext);
foreach ($newsData->props->pageProps->newsList as $newsItem) {
$newsItemUrl = self::getURI() . '/' . $newsItem->slug;
$newsItemTitle = $newsItem->header->title;
foreach ($data->props->pageProps->newsList as $newsItem) {
$headerDescription = property_exists($newsItem->header, 'description') ? $newsItem->header->description : '';
$headerImage = $newsItem->header->image->src;
$headerImage = $newsItem->newsLandingConfig->options[0]->image->src->url;
$contentImages = [$headerImage];
@@ -52,67 +53,79 @@ class FallGuysBridge extends BridgeAbstract
<p><img src="{$headerImage}"></p>
HTML;
foreach ($newsItem->content->items as $contentItem) {
if (property_exists($contentItem, 'articleCopy')) {
if (property_exists($contentItem->articleCopy, 'title')) {
$title = $contentItem->articleCopy->title;
try {
$newsItemData = self::requestJsonData($newsItemUrl, true);
} catch (\Exception $e) {
$this->logger->error(sprintf('Failed to request data for news item "%s" (%s)', $newsItemTitle, $newsItemUrl), ['e' => $e]);
$newsItemData = null;
}
if (!$newsItemData) {
$this->logger->error(sprintf('Failed to parse json data for news item "%s" (%s)', $newsItemTitle, $newsItemUrl));
} else {
foreach ($newsItemData->props->pageProps->pageData->content->items as $contentItem) {
if (property_exists($contentItem, 'articleCopy')) {
if (property_exists($contentItem->articleCopy, 'title')) {
$title = $contentItem->articleCopy->title;
$content .= <<<HTML
<h2>{$title}</h2>
HTML;
}
$text = $contentItem->articleCopy->copy;
$content .= <<<HTML
<h2>{$title}</h2>
<p>{$text}</p>
HTML;
}
} elseif (property_exists($contentItem, 'articleImage')) {
$image = $contentItem->articleImage->imageSrc;
$text = $contentItem->articleCopy->copy;
if ($image != $headerImage) {
$contentImages[] = $image;
$content .= <<<HTML
<p>{$text}</p>
HTML;
} elseif (property_exists($contentItem, 'articleImage')) {
$image = $contentItem->articleImage->imageSrc;
$content .= <<<HTML
<p><img src="{$image}"></p>
HTML;
}
} elseif (property_exists($contentItem, 'embeddedVideo')) {
$mediaOptions = $contentItem->embeddedVideo->mediaOptions;
$mainContentOptions = $contentItem->embeddedVideo->mainContentOptions;
if ($image != $headerImage) {
$contentImages[] = $image;
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 .= <<<HTML
<p><img src="{$image}"></p>
HTML;
}
} elseif (property_exists($contentItem, 'embeddedVideo')) {
$mediaOptions = $contentItem->embeddedVideo->mediaOptions;
$mainContentOptions = $contentItem->embeddedVideo->mainContentOptions;
$content .= '<p>';
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 ?? '';
if ($image != $headerImage) {
$contentImages[] = $image;
$content .= '<p>';
if ($image != $headerImage) {
$contentImages[] = $image;
$content .= <<<HTML
<a href="{$videoUrl}"><img src="{$image}"></a><br>
HTML;
}
$content .= <<<HTML
<a href="{$videoUrl}"><img src="{$image}"></a><br>
<i>(Video: <a href="{$videoUrl}">{$videoUrl}</a>)</i>
HTML;
$content .= '</p>';
}
$content .= <<<HTML
<i>(Video: <a href="{$videoUrl}">{$videoUrl}</a>)</i>
HTML;
$content .= '</p>';
}
}
} else {
$this->logger->warning(sprintf('Unsupported content item in news item "%s" (%s)', $newsItemTitle, $newsItemUrl));
}
}
}
$item = [
'uid' => $newsItem->_id,
'uri' => self::getURI() . '/' . $newsItem->_slug,
'title' => $newsItem->_title,
'timestamp' => $newsItem->lastModified,
'uid' => $newsItem->id,
'uri' => $newsItemUrl,
'title' => $newsItemTitle,
'timestamp' => $newsItem->activeDate,
'content' => $content,
'enclosures' => $contentImages,
];
@@ -131,4 +144,12 @@ class FallGuysBridge extends BridgeAbstract
{
return self::BASE_URI . '/favicon.ico';
}
private function requestJsonData(string $url, bool $useCache)
{
$html = $useCache ? getSimpleHTMLDOMCached($url) : getSimpleHTMLDOM($url);
$jsonElement = $html->find('#__NEXT_DATA__', 0);
$json = $jsonElement ? $jsonElement->innertext : null;
return json_decode($json);
}
}

View File

@@ -0,0 +1,95 @@
<?php
class FanaticalBridge extends BridgeAbstract
{
const NAME = 'Fanatical';
const MAINTAINER = 'phantop';
const URI = 'https://www.fanatical.com/en/';
const DESCRIPTION = 'Returns bundles from Fanatical.';
const PARAMETERS = [[
'type' => [
'name' => 'Bundle type',
'type' => 'list',
'defaultValue' => 'all',
'values' => [
'All' => 'all',
'Books' => 'book-',
'ELearning' => 'elearning-',
'Games' => '',
'Software' => 'software-',
]
]
]];
const IMGURL = 'https://fanatical.imgix.net/product/original/';
public function collectData()
{
$api = 'https://www.fanatical.com/api/all/en';
$json = json_decode(getContents($api), true)['pickandmix'];
$type = $this->getInput('type');
foreach ($json as $element) {
if ($type != 'all') {
if ($element['type'] != $type . 'bundle') {
continue;
}
}
$item = [
'categories' => [$element['type']],
'content' => '<ul>',
'enclosures' => [self::IMGURL . $element['cover_image']],
'timestamp' => $element['valid_from'],
'title' => $element['name'],
'uri' => parent::getURI() . 'pick-and-mix/' . $element['slug'],
];
$slugs = [];
foreach ($element['products'] as $product) {
$slug = $product['slug'];
if (in_array($slug, $slugs)) {
continue;
}
$slugs[] = $slug;
$uri = parent::getURI() . 'game/' . $slug;
$item['content'] .= '<li><a href="' . $uri . '">' . $product['name'] . '</a></li>';
$item['enclosures'][] = self::IMGURL . $product['cover'];
}
foreach ($element['tiers'] as $tier) {
$count = $tier['quantity'];
$price = round($tier['price']['USD'] / 100, 2);
$per = round($price / $count, 2);
$item['categories'][] = "$count at $per for $price total";
}
$item['content'] .= '</ul>';
$this->items[] = $item;
}
}
public function getName()
{
$name = parent::getName();
$name .= $this->getKey('type') ? ' - ' . $this->getKey('type') : '';
return $name;
}
public function getURI()
{
$uri = parent::getURI();
$type = $this->getKey('type');
if ($type) {
$uri .= 'bundle/';
if ($type != 'All') {
$uri .= strtolower($type);
}
}
return $uri;
}
public function getIcon()
{
return 'https://cdn.fanatical.com/production/icons/fanatical-icon-android-chrome-192x192.png';
}
}

View File

@@ -58,13 +58,13 @@ class FirefoxAddonsBridge extends BridgeAbstract
}
$item['content'] = <<<EOD
<strong>Release Notes</strong>
<p><strong>Release Notes</strong></p>
<p>{$releaseNotes}</p>
<strong>Compatibility</strong>
<p><strong>Compatibility</strong></p>
<p>{$compatibility}</p>
<strong>License</strong>
<p><strong>License</strong></p>
<p>{$license}</p>
<strong>Download</strong>
<p><strong>Download</strong></p>
<p><a href="{$downloadlink}">{$xpiFilename}</a> ($size)</p>
EOD;

View File

@@ -1,52 +0,0 @@
<?php
class FirstLookMediaTechBridge extends BridgeAbstract
{
const NAME = 'First Look Media - Technology';
const URI = 'https://tech.firstlook.media';
const DESCRIPTION = 'First Look Media Technology page';
const MAINTAINER = 'somini';
const PARAMETERS = [
[
'projects' => [
'type' => 'checkbox',
'name' => 'Include Projects?',
]
]
];
public function collectData()
{
$html = getSimpleHTMLDOM(self::URI);
if ($this->getInput('projects')) {
$top_projects = $html->find('.PromoList-ul', 0);
foreach ($top_projects->find('li.PromoList-item') as $element) {
$item = [];
$item_uri = $element->find('a', 0);
$item['uri'] = $item_uri->href;
$item['title'] = strip_tags($item_uri->innertext);
$item['content'] = $element->find('div > div', 0);
$this->items[] = $item;
}
}
$top_articles = $html->find('.PromoList-ul', 1);
foreach ($top_articles->find('li.PromoList-item') as $element) {
$item = [];
$item_left = $element->find('div > div', 0);
$item_date = $element->find('.PromoList-date', 0);
$item['timestamp'] = strtotime($item_date->innertext);
$item_date->outertext = ''; /* Remove */
$item['author'] = $item_left->innertext;
$item_uri = $element->find('a', 0);
$item['uri'] = self::URI . $item_uri->href;
$item['title'] = strip_tags($item_uri);
$this->items[] = $item;
}
}
}

View File

@@ -9,20 +9,19 @@ class GOGBridge extends BridgeAbstract
public function collectData()
{
$values = getContents('https://www.gog.com/games/ajax/filtered?limit=25&sort=new');
$values = getContents('https://catalog.gog.com/v1/catalog?limit=48&order=desc%3AstoreReleaseDate');
$decodedValues = json_decode($values);
$limit = 0;
foreach ($decodedValues->products as $game) {
$item = [];
$item['author'] = $game->developer . ' / ' . $game->publisher;
$item['author'] = implode(', ', $game->developers) . ' / ' . implode(', ', $game->publishers);
$item['title'] = $game->title;
$item['id'] = $game->id;
$item['uri'] = self::URI . $game->url;
$item['uri'] = $game->storeLink;
$item['content'] = $this->buildGameContentPage($game);
$item['timestamp'] = $game->globalReleaseDate;
foreach ($game->gallery as $image) {
foreach ($game->screenshots as $image) {
$item['enclosures'][] = $image . '.jpg';
}
@@ -42,18 +41,10 @@ class GOGBridge extends BridgeAbstract
$gameDescriptionValue = json_decode($gameDescriptionText);
$content = 'Genres: ';
$content .= implode(', ', $game->genres);
$content .= implode(', ', array_column($game->genres, 'name'));
$content .= '<br />Supported Platforms: ';
if ($game->worksOn->Windows) {
$content .= 'Windows ';
}
if ($game->worksOn->Mac) {
$content .= 'Mac ';
}
if ($game->worksOn->Linux) {
$content .= 'Linux ';
}
$content .= implode(', ', $game->operatingSystems);
$content .= '<br />' . $gameDescriptionValue->description->full;

View File

@@ -31,25 +31,33 @@ class GoComicsBridge extends BridgeAbstract
public function collectData()
{
$link = $this->getURI();
$landingpage = getSimpleHTMLDOM($link);
$element = $landingpage->find('div[data-post-url]', 0);
if ($element) {
$link = $element->getAttribute('data-post-url');
} else { // fallback for comics without data-post-url (assumes daily comic)
$nextcomiclink = $landingpage->find('a[class*="ComicNavigation_controls__button_previous__"]', 0)->href;
preg_match('/(\d{4}\/\d{2}\/\d{2})/', $nextcomiclink, $nclmatches);
if (!empty($nclmatches[1])) {
$nextdate = new DateTime($nclmatches[1]);
$nextdate = $nextdate->modify('+1 day')->format('Y/m/d');
$link = $link . '/' . $nextdate;
} else {
throw new \Exception('Could not find the first comic URL. Please create a new GitHub issue.');
}
}
for ($i = 0; $i < $this->getInput('limit'); $i++) {
$html = getSimpleHTMLDOM($link);
// get json data from the first page
$json = $html->find('div[class^="ShowComicViewer_showComicViewer__comic__"] script[type="application/ld+json"]', 0)->innertext;
$data = json_decode($json, false);
$html = getSimpleHTMLDOMCached($link, 86400);
$imagelink = $html->find('meta[property="og:image"]', 0)->content;
$parts = explode('/', $link);
$date = DateTime::createFromFormat('Y/m/d', implode('/', array_slice($parts, -3)));
$title = $html->find('meta[property="og:title"]', 0)->content;
preg_match('/by (.*?) for/', $title, $authormatches);
$author = $authormatches[1] ?? 'GoComics';
$item = [];
$author = $data->author->name;
$imagelink = $data->contentUrl;
$date = $data->datePublished;
$title = $data->name . ' - GoComics';
// get a permlink for this day's comic if there isn't one specified
if ($link === $this->getURI()) {
$link = $this->getURI() . '/' . DateTime::createFromFormat('F j, Y', $date)->format('Y/m/d');
}
$item['id'] = $imagelink;
$item['uri'] = $link;
$item['author'] = $author;
@@ -57,7 +65,7 @@ class GoComicsBridge extends BridgeAbstract
if ($this->getInput('date-in-title') === true) {
$item['title'] = $title;
}
$item['timestamp'] = DateTime::createFromFormat('F j, Y', $date)->setTime(0, 0, 0)->getTimestamp();
$item['timestamp'] = $date->setTime(0, 0, 0)->getTimestamp();
$item['content'] = '<img src="' . $imagelink . '" />';
$link = rtrim(self::URI, '/') . $html->find('a[class*="ComicNavigation_controls__button_previous__"]', 0)->href;

View File

@@ -152,7 +152,7 @@ class GolemBridge extends FeedExpander
$img->src = $img->getAttribute('data-src-full');
}
foreach ($content->find('p, h1, h2, h3, pre, img[src*="."], iframe, video') as $element) {
foreach ($content->find('p, h1, h2, h3, pre, img[src*="."], div[class*="golem_tablediv"], iframe, video') as $element) {
$item .= $element;
}

View File

@@ -48,7 +48,7 @@ class HaveIBeenPwnedBridge extends BridgeAbstract
. $pwnCount . ' breached accounts';
$item['dateAdded'] = $breach['AddedDate'];
$item['breachDate'] = $breach['BreachDate'];
$item['uri'] = self::URI . '/PwnedWebsites#' . $breach['Name'];
$item['uri'] = self::URI . '/breach/' . $breach['Name'];
$item['content'] = '<p>' . $breach['Description'] . '</p>';
$item['content'] .= '<p>' . $this->breachType($breach) . '</p>';

View File

@@ -138,6 +138,7 @@ class HeiseBridge extends FeedExpander
}
// abort on heise+ articles
if ($sessioncookie == '' && str_starts_with($item['title'], 'heise+ |')) {
$item['uri'] = 'https://archive.is/' . $item['uri'];
return $item;
}
@@ -162,7 +163,7 @@ class HeiseBridge extends FeedExpander
// remove unwanted stuff
foreach (
$article->find('figure.branding, figure.a-inline-image, a-ad, div.ho-text, a-img,
.a-toc__list, a-collapse, .opt-in__description, .opt-in__footnote') as $element
.a-toc__list, a-collapse, .opt-in__description, .opt-in__footnote, .notice-banner__text, .notice-banner__link') as $element
) {
$element->remove();
}

View File

@@ -34,25 +34,90 @@ class HumbleBundleBridge extends BridgeAbstract
}
foreach ($products as $element) {
$item = [];
$item['author'] = $element['author'];
$item['timestamp'] = $element['start_date|datetime'];
$item['title'] = $element['tile_short_name'];
$item['uid'] = $element['machine_name'];
$item['uri'] = parent::getURI() . $element['product_url'];
$dom = new simple_html_dom();
$body = $dom->createElement('div');
$item = [
'author' => $element['author'],
'categories' => $element['hover_highlights'],
'content' => $body,
'timestamp' => $element['start_date|datetime'],
'title' => $element['tile_short_name'],
'uid' => $element['machine_name'],
'uri' => parent::getURI() . $element['product_url'],
];
$item['content'] = $element['marketing_blurb'];
$item['content'] .= '<br>' . $element['detailed_marketing_blurb'];
$item['categories'] = $element['hover_highlights'];
array_unshift($item['categories'], explode(':', $element['tile_name'])[0]);
array_unshift($item['categories'], $element['tile_stamp']);
$item['enclosures'] = [$element['tile_logo'], $element['high_res_tile_image']];
$this->items[] = $item;
$this->createChild($dom, $body, 'img', null, ['src' => $element['tile_logo']]);
$this->createChild($dom, $body, 'img', null, ['src' => $element['high_res_tile_image']]);
$this->createChild($dom, $body, 'h2', $element['short_marketing_blurb']);
$this->createChild($dom, $body, 'p', $element['detailed_marketing_blurb']);
$this->items[] = $this->processBundle($item, $dom, $body);
}
}
private function createChild($dom, $body, $name = null, $val = null, $args = [])
{
if ($name == null) {
$elem = $dom->createTextNode($val);
} else {
$elem = $dom->createElement($name, $val);
}
foreach ($args as $arg => $val) {
$elem->setAttribute($arg, $val);
}
$body->appendChild($elem);
return $elem;
}
private function processBundle($item, $dom, $body)
{
$page = getSimpleHTMLDOMCached($item['uri']);
$json_text = $page->find('#webpack-bundle-page-data', 0)->innertext;
$json = json_decode(html_entity_decode($json_text), true)['bundleData'];
$tiers = $json['tier_display_data'];
ksort($tiers, SORT_NATURAL);
# `initial` element gets sorted to the end as bt# (bundle tiers) precede it alphabetically
array_unshift($tiers, array_pop($tiers));
$seen = [];
$toc = $this->createChild($dom, $body, 'ul');
foreach ($tiers as $tiername => $tier) {
$this->createChild($dom, $body, 'h2', $tier['header'], ['id' => $tiername]);
$li = $this->createChild($dom, $toc, 'li');
$this->createChild($dom, $li, 'a', $tier['header'], ['href' => "#$tiername"]);
$toc_tier = $this->createChild($dom, $toc, 'ul');
foreach ($tier['tier_item_machine_names'] as $name) {
if (in_array($name, $seen)) {
continue;
}
array_push($seen, $name);
$element = $json['tier_item_data'][$name];
$head = $this->createChild($dom, $body, 'h3', null, ['id' => $name]);
$head_link = $this->createChild($dom, $head, 'a', $element['human_name'], ['id' => $name]);
$li = $this->createChild($dom, $toc_tier, 'li');
$this->createChild($dom, $li, 'a', $element['human_name'], ['href' => "#$name"]);
$this->createChild($dom, $body, 'img', null, ['src' => $element['resolved_paths']['featured_image']]);
$this->createChild($dom, $body, 'img', null, ['src' => $element['resolved_paths']['preview_image']]);
$this->createChild($dom, $body, 'br');
if ($element['description_text']) {
$body->appendChild(str_get_html($element['description_text'])->root);
}
if ($element['youtube_link']) {
$head_link->href = 'https://youtu.be/' . $element['youtube_link'];
}
if ($element['book_preview']) {
$head_link->href = $element['book_preview']['preview_file_link'];
}
}
}
return $item;
}
public function getName()
{
$name = parent::getName();

293
bridges/I4wifiBridge.php Normal file
View File

@@ -0,0 +1,293 @@
<?php
/**
*
* The website i4wifi.cz is a wholesale distributor specializing in wireless, networking, and photovoltaic equipment, offering products from brands like MikroTik, Ubiquiti, and Hikvision. It provides a wide range of network solutions, technical support, and training services for businesses and professional installers in the Czech Republic and beyond.
*/
class I4wifiBridge extends BridgeAbstract
{
const NAME = 'i4wifi Bridge';
const URI = 'https://www.i4wifi.cz';
const DESCRIPTION = 'Product news not only from the wireless, network and security technology sector from i4wifi.cz - Czech Republic';
const MAINTAINER = 'pprenghyorg';
// Only Articles are supported
const PARAMETERS = [
'Product news' => [
],
];
/**
* Fetches and processes data based on the selected context.
*
* This function retrieves the HTML content for the specified context's URI,
* resolves relative links within the content, and then delegates the data
* extraction to the appropriate method (currently only `collectNews`).
*/
public function collectData()
{
$html = getSimpleHTMLDOMCached($this->getURI(), 86400);
defaultLinkTo($html, static::URI);
// Router
switch ($this->queriedContext) {
case 'Product news':
$this->collectNews($html);
break;
}
}
/**
* Returns the icon for the bridge.
*
* @return string The icon URL.
*/
public function getURI()
{
$uri = static::URI;
// URI Router
switch ($this->queriedContext) {
case 'Product news':
$uri .= '/';
break;
}
return $uri;
}
/**
* Returns the name for the bridge.
*
* @return string The Name.
*/
public function getName()
{
$name = static::NAME;
$name .= ($this->queriedContext) ? ' - ' . $this->queriedContext : '';
switch ($this->queriedContext) {
case 'Product news':
break;
}
return $name;
}
/**
* Parse most used date formats
*
* Basically strtotime doesn't convert dates correctly due to formats
* being hard to interpret. So we use the DateTime object, manually
* fixing dates and times (set to 00:00:00.000).
*
* We don't know the timezone, so just assume +00:00 (or whatever
* DateTime chooses)
*/
private function fixDate($date)
{
$df = $this->parseDateTimeFromString($date);
return date_format($df, 'U');
}
/**
* Extracts the images from the article.
*
* @param object $article The article object.
* @return array An array of image URLs.
*/
private function extractImages($article)
{
// Notice: We can have zero or more images (though it should mostly be 1)
$elements = $article->find('img');
$images = [];
foreach ($elements as $img) {
$images[] = $img->src;
}
return $images;
}
#region Articles
/**
* Collects uri, timestamp, title, content and images in the news articles from the HTML and transforms to rss.
*
* @param object $html The HTML object.
* @return void
*/
private function collectNews($html)
{
$articles = $html->find('.timeline-item.timeline-item-right')
or returnServerError('No articles found! Layout might have changed!');
foreach ($articles as $article) {
$item = [];
// get uri of product
$item['uri'] = $this->extractNewsUri($article);
// Add content
$item['content'] = $this->extractNewsDescription($article);
// Add images
$item['title'] = $this->extractNewsTitle($article);
// Add images
$item['enclosures'] = $this->extractImages($article);
// Add timestamp
$item['timestamp'] = $this->extractNewsDate($article);
// collect sources into rss article
$this->items[] = $item;
}
}
/**
* Extracts the URI of the news article.
*
* @param object $article The article object.
* @return string The URI of the news article.
*/
private function extractNewsUri($article)
{
// Return URI of the article
$element = $article->find('a', 0)
or returnServerError('Anchor not found!');
return $element->href;
}
/**
* Extracts the date of the news article.
*
* @param object $article The article object.
* @return string The date of the news article.
*/
private function extractNewsDate($article)
{
// Check if date is set
$element = $article->find('.timeline-item-info', 0)
or returnServerError('Date not found!');
// Format date
return $this->fixDate($element->plaintext);
}
/**
* Extracts the description of the news article.
*
* @param object $article The article object.
* @return string The description of the news article.
*/
private function extractNewsDescription($article)
{
// Extract description
$element = $article->find('p', 0)
or returnServerError('Description not found!');
return $element->innertext;
}
/**
* Extracts the title of the news article.
*
* @param object $article The article object.
* @return string The title of the news article.
*/
private function extractNewsTitle($article)
{
// Extract title
$element = $article->find('img', 0)
or returnServerError('Title not found!');
return $element->alt;
}
/**
* It attempts to recognize the date/time format in a string and create a DateTime object.
*
* It goes through the list of defined formats and tries to apply them to the input string.
* Returns the first successfully parsed DateTime object that matches the entire string.
*
* @param string $dateString A string potentially containing a date and/or time.
* @return DateTime|null A DateTime object if successfully recognized and parsed, otherwise null.
*/
private function parseDateTimeFromString(string $dateString): ?DateTime
{
// List of common formats - YOU CAN AND SHOULD EXPAND IT according to expected inputs!
// Order may matter if the formats are ambiguous.
// It is recommended to give more specific formats (with time, full year) before more general ones.
$possibleFormats = [
// Czech formats (day.month.year)
'd.m.Y H:i:s', // 10.04.2025 10:57:47
'j.n.Y H:i:s', // 10.4.2025 10:57:47
'd. m. Y H:i:s', // 10. 04. 2025 10:57:47
'j. n. Y H:i:s', // 10. 4. 2025 10:57:47
'd.m.Y H:i', // 10.04.2025 10:57
'j.n.Y H:i', // 10.4.2025 10:57
'd. m. Y H:i', // 10. 04. 2025 10:57
'j. n. Y H:i', // 10. 4. 2025 10:57
'd.m.Y', // 10.04.2025
'j.n.Y', // 10.4.2025
'd. m. Y', // 10. 04. 2025
'j. n. Y', // 10. 4. 2025
// ISO 8601 and international formats (year-month-day)
'Y-m-d H:i:s', // 2025-04-10 10:57:47
'Y-m-d H:i', // 2025-04-10 10:57
'Y-m-d', // 2025-04-10
'YmdHis', // 20250410105747
'Ymd', // 20250410
// American formats (month/day/year) - beware of ambiguity!
'm/d/Y H:i:s', // 04/10/2025 10:57:47
'n/j/Y H:i:s', // 4/10/2025 10:57:47
'm/d/Y H:i', // 04/10/2025 10:57
'n/j/Y H:i', // 4/10/2025 10:57
'm/d/Y', // 04/10/2025
'n/j/Y', // 4/10/2025
// Standard formats (including time zone)
DateTime::ATOM, // example. 2025-04-10T10:57:47+02:00
DateTime::RFC3339, // example. 2025-04-10T10:57:47+02:00
DateTime::RFC3339_EXTENDED, // example. 2025-04-10T10:57:47.123+02:00
DateTime::RFC2822, // example. Thu, 10 Apr 2025 10:57:47 +0200
DateTime::ISO8601, // example. 2025-04-10T105747+0200
'Y-m-d\TH:i:sP', // ISO 8601 s 'T' oddělovačem
'Y-m-d\TH:i:s.uP', // ISO 8601 s mikrosekundami
// You can add more formats as needed...
// e.g. 'd-M-Y' (10-Apr-2025) - requires English locale
// e.g. 'j. F Y' (10. abren 2025) - requires Czech locale
];
// Set locale for parsing month/day names (if using F, M, l, D)
// E.g. setlocale(LC_TIME, 'cs_CZ.UTF-8'); or 'en_US.UTF-8');
foreach ($possibleFormats as $format) {
// We will try to create a DateTime object from the given format
$dateTime = DateTime::createFromFormat($format, $dateString);
// We check that the parsing was successful AND ALSO
// that there were no errors or warnings during the parsing.
// This is important to ensure that the format matches the ENTIRE string.
if ($dateTime !== false) {
$errors = DateTime::getLastErrors();
if (!($errors)) {
// Success! We found a valid format for the entire string.
return $dateTime;
}
}
}
// If no format matches or parsing failed
return null;
}
#endregion
}

View File

@@ -35,6 +35,16 @@ class IdealoBridge extends BridgeAbstract
]
];
private $headers = [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0',
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language: fr-FR,fr;q=0.8,en-US;q=0.5,en;q=0.3'
];
private $options = [
CURLOPT_TRANSFER_ENCODING => 1,
CURLOPT_ACCEPT_ENCODING => 'gzip, deflate, br'
];
public function getIcon()
{
return 'https://cdn.idealo.com/storage/ids-assets/ico/favicon.ico';
@@ -53,10 +63,7 @@ class IdealoBridge extends BridgeAbstract
// The cache does not contain the title of the bridge, we must get it and save it in the cache
if ($product === null) {
$header = [
'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15'
];
$html = getSimpleHTMLDOM($link, $header);
$html = getSimpleHTMLDOM($link, $this->headers, $this->options);
$product = $html->find('.oopStage-title', 0)->find('span', 0)->plaintext;
$this->saveCacheValue($keyTITLE, $product);
}
@@ -123,13 +130,8 @@ class IdealoBridge extends BridgeAbstract
}
public function collectData()
{
// Needs header with user-agent to function properly.
$header = [
'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15'
];
$link = $this->getInput('Link');
$html = getSimpleHTMLDOM($link, $header);
$html = getSimpleHTMLDOM($link, $this->headers, $this->options);
// Get Productname
$titleobj = $html->find('.oopStage-title', 0);

View File

@@ -39,7 +39,7 @@ class InstituteForTheStudyOfWarBridge extends BridgeAbstract
list($date_string, $user) = explode('-', $date_span->innertext);
$date = DateTime::createFromFormat('F d, Y', trim($date_string));
$html = getSimpleHTMLDOMCached(self::URI . $uri);
$html = getSimpleHTMLDOMCached(self::URI . $uri, 60 * 60 * 24 * 7);
$content = $html->find('[property=content:encoded]', 0)->innertext;
$enclosures = [];

View File

@@ -443,7 +443,7 @@ class ItakuBridge extends BridgeAbstract
return $data['owner'];
}
private function getPost($id, array $metadata = null)
private function getPost($id, ?array $metadata = null)
{
if (isset($metadata) && count($metadata['gallery_images']) < $metadata['num_images']) {
$metadata = null; //force re-fetch of metadata
@@ -515,7 +515,7 @@ class ItakuBridge extends BridgeAbstract
];
}
private function getCommission($id, array $metadata = null)
private function getCommission($id, ?array $metadata = null)
{
$url = self::URI . '/api/commissions/' . $id . '/?format=json';
$uri = self::URI . '/commissions/' . $id;

View File

@@ -24,6 +24,11 @@ class KemonoBridge extends BridgeAbstract
'name' => 'User ID/Name',
'exampleValue' => '9069743', # Thomas Joy
'required' => true,
],
'q' => [
'name' => 'Search query',
'exampleValue' => 'classic',
'required' => false,
]
]];
@@ -33,13 +38,17 @@ class KemonoBridge extends BridgeAbstract
{
$api = parent::getURI() . 'api/v1/';
$url = $api . $this->getInput('service') . '/user/' . $this->getInput('user');
$api_response = getContents($url . '/profile');
$profile = Json::decode($api_response);
$this->title = ucfirst($profile['name']);
if ($this->getInput('q')) {
$url .= '?q=' . urlencode($this->getInput('q'));
}
$api_response = getContents($url);
$json = Json::decode($api_response);
$url .= '/profile';
$api_response = getContents($url);
$profile = Json::decode($api_response);
$this->title = ucfirst($profile['name']);
foreach ($json as $element) {
$item = [];

View File

@@ -0,0 +1,291 @@
<?php
/**
*
* NaseStrecha.cz is a specialized Czech news and advice portal focusing on roofs, construction, and home improvement, offering reliable expert guidance on roofing materials, insulation, and energy-saving techniques nasestrecha.cz . It is run by the team behind the Strechy-Solar-Remeslo trade fair and includes up-to-date news, practical tips, and industry events..
*
*/
class NasestrechaBridge extends BridgeAbstract
{
const NAME = 'Nasestrecha Bridge';
const URI = 'https://www.nasestrecha.cz/';
const DESCRIPTION = 'Articles from Nasestrecha.cz news site - Czech Republic / Spolehlivé informace pro Vaší střechu i stavbu';
const MAINTAINER = 'pprenghyorg';
// Only Articles are supported
const PARAMETERS = [
'Articles, news and reviews from from construction and housing' => [
],
];
/**
* Fetches and processes data based on the selected context.
*
* This function retrieves the HTML content for the specified context's URI,
* resolves relative links within the content, and then delegates the data
* extraction to the appropriate method (currently only `collectNews`).
*/
public function collectData()
{
$html = getSimpleHTMLDOM($this->getURI());
defaultLinkTo($html, static::URI);
// Router
switch ($this->queriedContext) {
case 'Articles, news and reviews from from construction and housing':
$this->collectNews($html);
break;
}
}
/**
* Returns the icon for the bridge.
*
* @return string The icon URL.
*/
public function getURI()
{
$uri = static::URI;
// URI Router
switch ($this->queriedContext) {
case 'Articles, news and reviews from from construction and housing':
$uri .= 'clanky/';
break;
}
return $uri;
}
/**
* Returns the name for the bridge.
*
* @return string The Name.
*/
public function getName()
{
$name = static::NAME;
$name .= ($this->queriedContext) ? ' - ' . $this->queriedContext : '';
switch ($this->queriedContext) {
case 'Articles, news and reviews from from construction and housing':
break;
}
return $name;
}
/**
* Parse most used date formats
*
* Basically strtotime doesn't convert dates correctly due to formats
* being hard to interpret. So we use the DateTime object, manually
* fixing dates and times (set to 00:00:00.000).
*
* We don't know the timezone, so just assume +00:00 (or whatever
* DateTime chooses)
*/
private function fixDate($date)
{
$df = $this->parseDateTimeFromString($date);
return date_format($df, 'U');
}
/**
* Extracts the images from the article.
*
* @param object $article The article object.
* @return array An array of image URLs.
*/
private function extractImages($article)
{
// Notice: We can have zero or more images (though it should mostly be 1)
$elements = $article->find('img');
$images = [];
foreach ($elements as $img) {
$images[] = $img->src;
}
return $images;
}
#region Articles
/**
* Collects uri, timestamp, title, content and images in the news articles from the HTML and transforms to rss.
*
* @param object $html The HTML object.
* @return void
*/
private function collectNews($html)
{
// Check if page contains articles
$articles = $html->find('.post')
or returnServerError('No articles found! Layout might have changed!');
foreach ($articles as $article) {
$item = [];
$item['uri'] = $this->extractNewsUri($article);
$item['timestamp'] = $this->extractNewsDate($article);
$item['title'] = $this->extractNewsTitle($article);
$item['content'] = $this->extractNewsDescription($article);
$item['enclosures'] = $this->extractImages($article);
// collect sources into rss article
$this->items[] = $item;
}
}
/**
* Extracts the URI of the news article.
*
* @param object $article The article object.
* @return string The URI of the news article.
*/
private function extractNewsUri($article)
{
// Return URI of the article
$element = $article->find('.thumbnail', 0)
or returnServerError('Anchor not found!');
return $element->href;
}
/**
* Extracts the date of the news article.
*
* @param object $article The article object.
* @return string The date of the news article.
*/
private function extractNewsDate($article)
{
// Check if date is set
$element = $article->find('div.post__info', 0)->find('span', 0)
or returnServerError('Date not found!');
$date = trim(explode('|', $element->plaintext)[0]);
// Format date
return $this->fixDate($date);
}
/**
* Extracts the description of the news article.
*
* @param object $article The article object.
* @return string The description of the news article.
*/
private function extractNewsDescription($article)
{
// Extract description
$element = $article->find('p.post__text', 0)
or returnServerError('Description not found!');
return $element->innertext;
}
/**
* Extracts the title of the news article.
*
* @param object $article The article object.
* @return string The title of the news article.
*/
private function extractNewsTitle($article)
{
// Extract title
$element = $article->find('a.post__title', 0)
or returnServerError('Title not found!');
return $element->plaintext;
}
/**
* It attempts to recognize the date/time format in a string and create a DateTime object.
*
* It goes through the list of defined formats and tries to apply them to the input string.
* Returns the first successfully parsed DateTime object that matches the entire string.
*
* @param string $dateString A string potentially containing a date and/or time.
* @return DateTime|null A DateTime object if successfully recognized and parsed, otherwise null.
*/
private function parseDateTimeFromString(string $dateString): ?DateTime
{
// List of common formats - YOU CAN AND SHOULD EXPAND IT according to expected inputs!
// Order may matter if the formats are ambiguous.
// It is recommended to give more specific formats (with time, full year) before more general ones.
$possibleFormats = [
// Czech formats (day.month.year)
'd.m.Y H:i:s', // 10.04.2025 10:57:47
'j.n.Y H:i:s', // 10.4.2025 10:57:47
'd. m. Y H:i:s', // 10. 04. 2025 10:57:47
'j. n. Y H:i:s', // 10. 4. 2025 10:57:47
'd.m.Y H:i', // 10.04.2025 10:57
'j.n.Y H:i', // 10.4.2025 10:57
'd. m. Y H:i', // 10. 04. 2025 10:57
'j. n. Y H:i', // 10. 4. 2025 10:57
'd.m.Y', // 10.04.2025
'j.n.Y', // 10.4.2025
'd. m. Y', // 10. 04. 2025
'j. n. Y', // 10. 4. 2025
// ISO 8601 and international formats (year-month-day)
'Y-m-d H:i:s', // 2025-04-10 10:57:47
'Y-m-d H:i', // 2025-04-10 10:57
'Y-m-d', // 2025-04-10
'YmdHis', // 20250410105747
'Ymd', // 20250410
// American formats (month/day/year) - beware of ambiguity!
'm/d/Y H:i:s', // 04/10/2025 10:57:47
'n/j/Y H:i:s', // 4/10/2025 10:57:47
'm/d/Y H:i', // 04/10/2025 10:57
'n/j/Y H:i', // 4/10/2025 10:57
'm/d/Y', // 04/10/2025
'n/j/Y', // 4/10/2025
// Standard formats (including time zone)
DateTime::ATOM, // example. 2025-04-10T10:57:47+02:00
DateTime::RFC3339, // example. 2025-04-10T10:57:47+02:00
DateTime::RFC3339_EXTENDED, // example. 2025-04-10T10:57:47.123+02:00
DateTime::RFC2822, // example. Thu, 10 Apr 2025 10:57:47 +0200
DateTime::ISO8601, // example. 2025-04-10T105747+0200
'Y-m-d\TH:i:sP', // ISO 8601 s 'T' oddělovačem
'Y-m-d\TH:i:s.uP', // ISO 8601 s mikrosekundami
// You can add more formats as needed...
// e.g. 'd-M-Y' (10-Apr-2025) - requires English locale
// e.g. 'j. F Y' (10. abren 2025) - requires Czech locale
];
// Set locale for parsing month/day names (if using F, M, l, D)
// E.g. setlocale(LC_TIME, 'cs_CZ.UTF-8'); or 'en_US.UTF-8');
foreach ($possibleFormats as $format) {
// We will try to create a DateTime object from the given format
$dateTime = DateTime::createFromFormat($format, $dateString);
// We check that the parsing was successful AND ALSO
// that there were no errors or warnings during the parsing.
// This is important to ensure that the format matches the ENTIRE string.
if ($dateTime !== false) {
$errors = DateTime::getLastErrors();
if (!($errors)) {
// Success! We found a valid format for the entire string.
return $dateTime;
}
}
}
// If no format matches or parsing failed
return null;
}
#endregion
}

View File

@@ -62,7 +62,8 @@ class PepperBridgeAbstract extends BridgeAbstract
foreach ($list as $deal) {
// Get the JSON Data stored as vue
$jsonDealData = $this->getDealJsonData($deal);
$dealMeta = Json::decode($deal->find('div[class=js-vue2]', 1)->getAttribute('data-vue2'));
// DEPRECATED : website does not show this info in the deal list anymore
// $dealMeta = Json::decode($deal->find('div[class=js-vue3]', 1)->getAttribute('data-vue3'));
$item = [];
$item['uri'] = $this->getDealURI($jsonDealData);
@@ -77,7 +78,10 @@ class PepperBridgeAbstract extends BridgeAbstract
. $this->getHTMLTitle($jsonDealData)
. $this->getPrice($jsonDealData)
. $this->getDiscount($jsonDealData)
. $this->getShipsFrom($dealMeta)
/*
* DEPRECATED : the list does not show this info anymore
* . $this->getShipsFrom($dealMeta)
*/
. $this->getShippingCost($jsonDealData)
. $this->getSource($jsonDealData)
. $this->getDealLocation($jsonDealData)
@@ -354,7 +358,7 @@ HEREDOC;
*/
private function getDealJsonData($deal)
{
$data = Json::decode($deal->find('div[class=js-vue2]', 0)->getAttribute('data-vue2'));
$data = Json::decode($deal->find('div[class=js-vue3]', 0)->getAttribute('data-vue3'));
return $data;
}
@@ -419,7 +423,7 @@ HEREDOC;
private function getImage($deal)
{
// Get thread Image JSON content
$content = Json::decode($deal->find('div[class=js-vue2]', 0)->getAttribute('data-vue2'));
$content = Json::decode($deal->find('div[class=js-vue3]', 0)->getAttribute('data-vue3'));
//return '<img src="' . $content['props']['threadImageUrl'] . '"/>';
return '<img src="' . $this->i8n('image-host') . $content['props']['thread']['mainImage']['path'] . '/'
. $content['props']['thread']['mainImage']['name'] . '/re/202x202/qt/70/'
@@ -429,6 +433,7 @@ HEREDOC;
/**
* Get the originating country from a Deal if it exists
* @return string String of the deal originating country
* DEPRECATED : the deal on the result list does not contain this info anymore
*/
private function getShipsFrom($dealMeta)
{

View File

@@ -0,0 +1,52 @@
<?php
class UniverseTodayBridge extends FeedExpander
{
const MAINTAINER = 'sqrtminusone';
const NAME = 'Universe Today Bridge';
const URI = 'https://www.universetoday.com/';
const DESCRIPTION = 'Returns the latest articles from Universe Today.';
const PARAMETERS = [
'' => [
'limit' => [
'name' => 'Feed Item Limit',
'required' => true,
'type' => 'number',
'defaultValue' => 10,
'title' => 'Maximum number of returned feed items. Default 10'
],
],
];
public function collectData()
{
$this->collectExpandableDatas(self::URI . 'feed', (int)$this->getInput('limit'));
}
protected function parseItem(array $item)
{
$dom = getSimpleHTMLDOMCached($item['uri'], 7 * 24 * 60 * 60);
$article_main = $dom->find('main > article', 0);
// Mostly YouTube videos
$iframes = $article_main->find('iframe');
foreach ($iframes as $iframe) {
$iframe->outertext = '<a href="' . $iframe->src . '">' . $iframe->src . '</a>';
}
$article_main = defaultLinkTo($article_main, self::URI);
$author_bio = $article_main->find('div.author-bio', 0);
if ($author_bio) {
$author_bio->parent->removeChild($author_bio);
}
$article_nav = $article_main->find('nav.article-navigation', 0);
if ($article_nav) {
$article_nav->parent->removeChild($article_nav);
}
$item['content'] = $article_main->innertext;
return $item;
}
}

View File

@@ -0,0 +1,109 @@
<?php
class WaggaCouncilBridge extends BridgeAbstract
{
const NAME = 'Wagga Wagga Council';
const URI = 'https://news.wagga.nsw.gov.au/';
const DESCRIPTION = 'Wagga Wagga Council updates';
const MAINTAINER = 'Scrub000';
const CACHE_TIMEOUT = 3600;
const PARAMETERS = [
[
'section' => [
'name' => 'Section',
'type' => 'list',
'values' => [
'Council' => 'council',
'Community' => 'community',
'Projects & Works' => 'projects-and-works',
'Arts & Culture' => 'arts-and-culture',
'Environment' => 'environment',
'Events & Tourism' => 'events-and-tourism',
'Parks & Recreation' => 'parks-and-recreation',
],
'defaultValue' => 'council',
],
]
];
public function getURI(): string
{
$section = $this->getInput('section') ?: 'council';
return urljoin(self::URI, $section);
}
public function collectData(): void
{
$html = getSimpleHTMLDOM($this->getURI());
foreach ($html->find('div.container') as $container) {
$titleElement = $container->find('h5', 0);
$linkElement = $container->find('a', 0);
$timeElement = $container->find('small.text-muted', 0);
if (!$titleElement || !$linkElement || !$timeElement) {
continue;
}
$title = trim($titleElement->plaintext);
$uri = urljoin(self::URI, $linkElement->href);
$timestamp = strtotime(str_replace('Published: ', '', $timeElement->plaintext));
// Load full article
$articleHtml = getSimpleHTMLDOM($uri);
$articleContent = '';
if ($articleHtml) {
$article = $articleHtml->find('article.article', 0);
if ($article) {
// Remove uneeded content
$selectorsToRemove = [
'button',
'nav',
'.visually-hidden',
'.carousel-control-prev',
'.carousel-control-next',
'.article__heading',
'.article__badge',
'p.text-muted',
];
foreach ($selectorsToRemove as $sel) {
foreach ($article->find($sel) as $el) {
$el->outertext = '';
}
}
foreach ($article->find('iframe') as $iframe) {
$src = $iframe->getAttribute('src');
$iframe->outertext = '<p><a href="' . htmlspecialchars($src) . '">Embedded content: ' . htmlspecialchars($src) . '</a></p>';
}
// Enhance list rendering
foreach ($article->find('ul') as $ul) {
$ul->style = 'margin-left: 1em; padding-left: 1em;';
}
foreach ($article->find('li') as $li) {
$li->innertext = '• ' . $li->innertext;
}
foreach ($article->children() as $node) {
// Skip <p> that contains <figure> to avoid duplication
if ($node->tag === 'p' && $node->find('figure', 0)) {
continue;
}
$articleContent .= $node->outertext;
}
}
}
$this->items[] = [
'title' => $title,
'uri' => $uri,
'author' => 'Wagga Wagga City Council',
'timestamp' => $timestamp,
'content' => $articleContent,
];
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
class WarhammerComBridge extends BridgeAbstract
{
const NAME = 'Warhammer Community Blog';
const URI = 'https://www.warhammer-community.com';
const DESCRIPTION = 'Warhammer Community Blog';
const MAINTAINER = 'thefranke';
const CACHE_TIMEOUT = 86400;
public function collectData()
{
$url = static::URI . '/api/search/news/';
$header = [
'Content-Type: application/json',
];
$data = '{"sortBy":"date_desc","category":"","collections":["articles"],"game_systems":[],"index":"news","locale":"en-gb","page":0,"perPage":16,"topics":[]}';
$opts = [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $data,
CURLOPT_RETURNTRANSFER => true
];
$json = getContents($url, $header, $opts);
$json = json_decode($json);
foreach ($json->news as $article) {
$articleurl = static::URI . $article->uri;
$fullarticle = getSimpleHTMLDOMCached($articleurl);
$content = $fullarticle->find('.article-content', 0);
$categories = [];
foreach ($article->topics as $topic) {
$categories[] = $topic->title;
}
$this->items[] = [
'title' => $article->title,
'uri' => static::URI . $article->uri,
'timestamp' => strtotime($article->date),
'content' => $content,
'uid' => $article->uuid,
'categories' => $categories
];
}
}
}

View File

@@ -2,9 +2,9 @@
class YouTubeCommunityTabBridge extends BridgeAbstract
{
const NAME = 'YouTube Community Tab Bridge';
const NAME = 'YouTube Posts Tab Bridge';
const URI = 'https://www.youtube.com';
const DESCRIPTION = 'Returns posts from a channel\'s community tab';
const DESCRIPTION = 'Returns posts from a channel\'s posts tab';
const MAINTAINER = 'VerifiedJoseph';
const PARAMETERS = [
'By channel ID' => [
@@ -31,7 +31,7 @@ class YouTubeCommunityTabBridge extends BridgeAbstract
private $feedName = '';
private $itemTitle = '';
private $urlRegex = '/youtube\.com\/(channel|user|c)\/([\w]+)\/community/';
private $urlRegex = '/youtube\.com\/(channel|user|c)\/([\w]+)\/posts/';
private $jsonRegex = '/var ytInitialData = ([^<]*);<\/script>/';
public function detectParameters($url)
@@ -59,26 +59,30 @@ class YouTubeCommunityTabBridge extends BridgeAbstract
{
if (is_null($this->getInput('username')) === false) {
try {
$this->feedUrl = $this->buildCommunityUri($this->getInput('username'), 'c');
$this->feedUrl = $this->buildPostsUri($this->getInput('username'), 'c');
$html = getSimpleHTMLDOM($this->feedUrl);
} catch (Exception $e) {
$this->feedUrl = $this->buildCommunityUri($this->getInput('username'), 'user');
$this->feedUrl = $this->buildPostsUri($this->getInput('username'), 'user');
$html = getSimpleHTMLDOM($this->feedUrl);
}
} else {
$this->feedUrl = $this->buildCommunityUri($this->getInput('channel'), 'channel');
$this->feedUrl = $this->buildPostsUri($this->getInput('channel'), 'channel');
$html = getSimpleHTMLDOM($this->feedUrl);
}
$json = $this->extractJson($html->find('html', 0)->innertext);
$this->feedName = $json->header->c4TabbedHeaderRenderer->title;
$this->feedName = $json->header->c4TabbedHeaderRenderer->title ?? null;
$this->feedName ??= $json->header->pageHeaderRenderer->pageTitle ?? null;
$this->feedName ??= $json->metadata->channelMetadataRenderer->title ?? null;
$this->feedName ??= $json->microformat->microformatDataRenderer->title ?? null;
$this->feedName ??= '';
if ($this->hasCommunityTab($json) === false) {
returnServerError('Channel does not have a community tab');
if ($this->hasPostsTab($json) === false) {
returnServerError('Channel does not have a posts tab');
}
$posts = $this->getCommunityPosts($json);
$posts = $this->getPosts($json);
foreach ($posts as $key => $post) {
$this->itemTitle = '';
@@ -132,18 +136,18 @@ class YouTubeCommunityTabBridge extends BridgeAbstract
public function getName()
{
if (!empty($this->feedName)) {
return $this->feedName . ' - YouTube Community Tab';
return $this->feedName . ' - YouTube Posts Tab';
}
return parent::getName();
}
/**
* Build Community URI
* Build Posts URI
*/
private function buildCommunityUri($value, $type)
private function buildPostsUri($value, $type)
{
return self::URI . '/' . $type . '/' . $value . '/community';
return self::URI . '/' . $type . '/' . $value . '/posts';
}
/**
@@ -165,14 +169,14 @@ class YouTubeCommunityTabBridge extends BridgeAbstract
}
/**
* Check if channel has a community tab
* Check if channel has a posts tab
*/
private function hasCommunityTab($json)
private function hasPostsTab($json)
{
foreach ($json->contents->twoColumnBrowseResultsRenderer->tabs as $tab) {
if (
isset($tab->tabRenderer)
&& str_ends_with($tab->tabRenderer->endpoint->commandMetadata->webCommandMetadata->url, 'community')
&& str_ends_with($tab->tabRenderer->endpoint->commandMetadata->webCommandMetadata->url, 'posts')
) {
return true;
}
@@ -182,14 +186,14 @@ class YouTubeCommunityTabBridge extends BridgeAbstract
}
/**
* Get community tab posts
* Get posts from posts tab
*/
private function getCommunityPosts($json)
private function getPosts($json)
{
foreach ($json->contents->twoColumnBrowseResultsRenderer->tabs as $tab) {
if (
isset($tab->tabRenderer)
&& str_ends_with($tab->tabRenderer->endpoint->commandMetadata->webCommandMetadata->url, 'community')
&& str_ends_with($tab->tabRenderer->endpoint->commandMetadata->webCommandMetadata->url, 'posts')
) {
return $tab->tabRenderer->content->sectionListRenderer->contents[0]->itemSectionRenderer->contents;
}

View File

@@ -38,12 +38,7 @@ class YouTubeFeedExpanderBridge extends FeedExpander
{
if ($this->getInput('channel') != null) {
$html = getSimpleHTMLDOMCached($this->getURI());
$scriptRegex = '/var ytInitialData = (.*?);<\/script>/';
$result = preg_match($scriptRegex, $html, $matches);
if (isset($matches[1])) {
$json = json_decode($matches[1]);
return $json->metadata->channelMetadataRenderer->avatar->thumbnails[0]->url;
}
return $html->find('[itemprop="thumbnailUrl"]', 0)->href;
}
return parent::getIcon();
}

View File

@@ -89,7 +89,8 @@ class ZeitBridge extends FeedExpander
$article->find(
'aside, .visually-hidden, .carousel-container, #tickaroo-liveblog, .zplus-badge,
.article-heading__container--podcast, .podcast-player__image, div[data-paywall],
.js-embed-consent, script, nav, .article-flexible-toc__subheading-link, .faq-link'
.js-embed-consent, script, nav, .article-flexible-toc__subheading-link, .faq-link,
.zoner-article-magazinbox'
) as $bad
) {
$bad->remove();
@@ -118,6 +119,11 @@ class ZeitBridge extends FeedExpander
}
$item['content'] = '';
// advertorial marker
$advert = $article->find('.advertorial-marker', 0);
if ($advert) {
$item['content'] .= $advert;
}
// summary
$summary = $article->find('.summary');

View File

@@ -23,7 +23,7 @@ class ArrayCache implements CacheInterface
return $default;
}
public function set(string $key, $value, int $ttl = null): void
public function set(string $key, $value, ?int $ttl = null): void
{
$this->data[$key] = [
'key' => $key,

View File

@@ -45,7 +45,7 @@ class FileCache implements CacheInterface
return $default;
}
public function set($key, $value, int $ttl = null): void
public function set($key, $value, ?int $ttl = null): void
{
$item = [
'key' => $key,

View File

@@ -9,7 +9,7 @@ class NullCache implements CacheInterface
return $default;
}
public function set(string $key, $value, int $ttl = null): void
public function set(string $key, $value, ?int $ttl = null): void
{
}

View File

@@ -77,7 +77,7 @@ class SQLiteCache implements CacheInterface
return $default;
}
public function set(string $key, $value, int $ttl = null): void
public function set(string $key, $value, ?int $ttl = null): void
{
$cacheKey = $this->createCacheKey($key);
$blob = serialize($value);

View File

@@ -29,7 +29,8 @@
"ext-libxml": "*",
"ext-simplexml": "*",
"ext-dom": "*",
"ext-json": "*"
"ext-json": "*",
"ext-filter": "*"
},
"require-dev": {
"phpunit/phpunit": "^9",

View File

@@ -7,7 +7,6 @@
| ![](https://iplookup.flagfox.net/images/h16/FR.png) | https://rss-bridge.cheredeprince.net | ![](https://img.shields.io/website/https/rss-bridge.cheredeprince.net) | [@La_Bécasse](https://cheredeprince.net/contact) | Self-Hosted at home in France |
| ![](https://iplookup.flagfox.net/images/h16/FR.png) | https://rss-bridge.sans-nuage.fr | ![](https://img.shields.io/website/https/rss-bridge.sans-nuage.fr) | [@Alsace Réseau Neutre](https://arn-fai.net/contact) | Hosted in Alsace, France |
| ![](https://iplookup.flagfox.net/images/h16/GB.png) | https://rss-bridge.lewd.tech | ![](https://img.shields.io/website/https/rss-bridge.lewd.tech.svg) | [@Erisa](https://github.com/Erisa) | Hosted in London, protected by Cloudflare Rate Limiting |
| ![](https://iplookup.flagfox.net/images/h16/FR.png) | https://bridge.easter.fr | ![](https://img.shields.io/website/https/bridge.easter.fr.svg) | [@chatainsim](https://github.com/chatainsim) | Hosted in Isère, France |
| ![](https://iplookup.flagfox.net/images/h16/FR.png) | https://wtf.roflcopter.fr/rss-bridge | ![](https://img.shields.io/website/https/wtf.roflcopter.fr/rss-bridge.svg) | [roflcopter.fr](https://wtf.roflcopter.fr/) | Hosted in France |
| ![](https://iplookup.flagfox.net/images/h16/DE.png) | https://rss.nixnet.services | ![](https://img.shields.io/website/https/rss.nixnet.services.svg) | [@amolith](https://nixnet.services/contact) | Hosted in Wunstorf, Germany |
| ![](https://iplookup.flagfox.net/images/h16/AT.png) | https://rss-bridge.ggc-project.de | ![](https://img.shields.io/website/https/rss-bridge.ggc-project.de) | [@ggc-project.de](https://social.dev-wiki.de/@ggc_project) | Hosted in Steyr, Austria |

View File

@@ -12,7 +12,7 @@ class CacheFactory
$this->logger = $logger;
}
public function create(string $name = null): CacheInterface
public function create(?string $name = null): CacheInterface
{
$cacheNames = [];
foreach (scandir(PATH_LIB_CACHES) as $file) {

View File

@@ -4,7 +4,7 @@ interface CacheInterface
{
public function get(string $key, $default = null);
public function set(string $key, $value, int $ttl = null): void;
public function set(string $key, $value, ?int $ttl = null): void;
public function delete(string $key): void;

View File

@@ -7,7 +7,7 @@
*/
final class Configuration
{
private const VERSION = '2025-06-03';
private const VERSION = '2025-08-05';
private static $config = [];

View File

@@ -23,11 +23,11 @@ final class RssBridge
$handler = $this->container[$actionName];
$middlewares = [
new BasicAuthMiddleware(),
new CacheMiddleware($this->container['cache']),
new ExceptionMiddleware($this->container['logger']),
new SecurityMiddleware(),
new MaintenanceMiddleware(),
new BasicAuthMiddleware(),
new TokenAuthenticationMiddleware(),
];
$action = function ($req) use ($handler) {

View File

@@ -227,7 +227,7 @@ final class Request
return $this->get[$key] ?? $default;
}
public function server(string $key, string $default = null): ?string
public function server(string $key, ?string $default = null): ?string
{
return $this->server[$key] ?? $default;
}

View File

@@ -712,7 +712,7 @@ class Parsedown
#
# Setext
protected function blockSetextHeader($Line, array $Block = null)
protected function blockSetextHeader($Line, ?array $Block = null)
{
if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
{
@@ -850,7 +850,7 @@ class Parsedown
#
# Table
protected function blockTable($Line, array $Block = null)
protected function blockTable($Line, ?array $Block = null)
{
if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
{