summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJohannes 'josch' Schauer <josch@debian.org>2019-09-24 22:51:24 +0200
committerJohannes 'josch' Schauer <josch@debian.org>2019-09-24 22:51:24 +0200
commit8702184834089fd80a0caedd34297f834e716f52 (patch)
tree462095b3c147cb2a56ac5f1f42ce3d4e8b6839e2
Import Upstream version 2019-01-13
-rw-r--r--README.md236
-rw-r--r--UNLICENSE25
-rw-r--r--bridges/ABCTabsBridge.php42
-rw-r--r--bridges/AcrimedBridge.php24
-rw-r--r--bridges/AllocineFRBridge.php86
-rw-r--r--bridges/AmazonBridge.php97
-rw-r--r--bridges/AmazonPriceTrackerBridge.php187
-rw-r--r--bridges/AnidexBridge.php207
-rw-r--r--bridges/AnimeUltimeBridge.php140
-rw-r--r--bridges/Arte7Bridge.php122
-rw-r--r--bridges/AskfmBridge.php74
-rw-r--r--bridges/AutoJMBridge.php65
-rw-r--r--bridges/BAEBridge.php265
-rw-r--r--bridges/BandcampBridge.php67
-rw-r--r--bridges/BastaBridge.php34
-rw-r--r--bridges/BlaguesDeMerdeBridge.php46
-rw-r--r--bridges/BloombergBridge.php69
-rw-r--r--bridges/BooruprojectBridge.php45
-rw-r--r--bridges/BundesbankBridge.php86
-rw-r--r--bridges/CNETBridge.php109
-rw-r--r--bridges/CastorusBridge.php118
-rw-r--r--bridges/ChristianDailyReporterBridge.php28
-rw-r--r--bridges/CollegeDeFranceBridge.php84
-rw-r--r--bridges/CommonDreamsBridge.php26
-rw-r--r--bridges/ContainerLinuxReleasesBridge.php97
-rw-r--r--bridges/CopieDoubleBridge.php35
-rw-r--r--bridges/CourrierInternationalBridge.php55
-rw-r--r--bridges/CrewbayBridge.php227
-rw-r--r--bridges/CryptomeBridge.php45
-rw-r--r--bridges/DailymotionBridge.php127
-rw-r--r--bridges/DanbooruBridge.php136
-rw-r--r--bridges/DansTonChatBridge.php28
-rw-r--r--bridges/DauphineLibereBridge.php57
-rw-r--r--bridges/DealabsBridge.php1470
-rw-r--r--bridges/DemoBridge.php46
-rw-r--r--bridges/DemonoidBridge.php169
-rw-r--r--bridges/DerpibooruBridge.php113
-rw-r--r--bridges/DesoutterBridge.php239
-rw-r--r--bridges/DevToBridge.php103
-rw-r--r--bridges/DeveloppezDotComBridge.php47
-rw-r--r--bridges/DiceBridge.php124
-rw-r--r--bridges/DilbertBridge.php36
-rw-r--r--bridges/DiscogsBridge.php116
-rw-r--r--bridges/DollbooruBridge.php9
-rw-r--r--bridges/DribbbleBridge.php96
-rw-r--r--bridges/DuckDuckGoBridge.php42
-rw-r--r--bridges/ETTVBridge.php161
-rw-r--r--bridges/EZTVBridge.php67
-rw-r--r--bridges/EliteDangerousGalnetBridge.php51
-rw-r--r--bridges/ElloBridge.php146
-rw-r--r--bridges/ElsevierBridge.php79
-rw-r--r--bridges/EstCeQuonMetEnProdBridge.php27
-rw-r--r--bridges/EtsyBridge.php84
-rw-r--r--bridges/ExtremeDownloadBridge.php103
-rw-r--r--bridges/FB2Bridge.php290
-rw-r--r--bridges/FDroidBridge.php58
-rw-r--r--bridges/FacebookBridge.php691
-rw-r--r--bridges/FeedExpanderExampleBridge.php62
-rw-r--r--bridges/FierPandaBridge.php33
-rw-r--r--bridges/FilterBridge.php101
-rw-r--r--bridges/FindACrewBridge.php82
-rw-r--r--bridges/FlickrBridge.php185
-rw-r--r--bridges/FootitoBridge.php75
-rw-r--r--bridges/ForGifsBridge.php40
-rw-r--r--bridges/FourchanBridge.php78
-rw-r--r--bridges/FuturaSciencesBridge.php144
-rw-r--r--bridges/GBAtempBridge.php151
-rw-r--r--bridges/GOGBridge.php65
-rw-r--r--bridges/GQMagazineBridge.php123
-rw-r--r--bridges/GelbooruBridge.php35
-rw-r--r--bridges/GiphyBridge.php76
-rw-r--r--bridges/GitHubGistBridge.php163
-rw-r--r--bridges/GithubIssueBridge.php219
-rw-r--r--bridges/GithubSearchBridge.php66
-rw-r--r--bridges/GizmodoBridge.php36
-rw-r--r--bridges/GlassdoorBridge.php221
-rw-r--r--bridges/GoComicsBridge.php61
-rw-r--r--bridges/GooglePlusPostBridge.php208
-rw-r--r--bridges/GoogleSearchBridge.php64
-rw-r--r--bridges/GrandComicsDatabaseBridge.php62
-rw-r--r--bridges/HDWallpapersBridge.php83
-rw-r--r--bridges/HentaiHavenBridge.php37
-rw-r--r--bridges/HotUKDealsBridge.php1395
-rw-r--r--bridges/IPBBridge.php310
-rw-r--r--bridges/IdenticaBridge.php52
-rw-r--r--bridges/InstagramBridge.php175
-rw-r--r--bridges/InstructablesBridge.php370
-rw-r--r--bridges/JapanExpoBridge.php106
-rw-r--r--bridges/JustETFBridge.php352
-rw-r--r--bridges/KATBridge.php129
-rw-r--r--bridges/KernelBugTrackerBridge.php153
-rw-r--r--bridges/KonachanBridge.php11
-rw-r--r--bridges/KoreusBridge.php22
-rw-r--r--bridges/KununuBridge.php200
-rw-r--r--bridges/LWNprevBridge.php265
-rw-r--r--bridges/LeBonCoinBridge.php536
-rw-r--r--bridges/LeMondeInformatiqueBridge.php39
-rw-r--r--bridges/LegifranceJOBridge.php72
-rw-r--r--bridges/LesJoiesDuCodeBridge.php37
-rw-r--r--bridges/LichessBridge.php31
-rw-r--r--bridges/LinkedInCompanyBridge.php37
-rw-r--r--bridges/LolibooruBridge.php11
-rw-r--r--bridges/MangareaderBridge.php249
-rw-r--r--bridges/MilbooruBridge.php11
-rw-r--r--bridges/MixCloudBridge.php53
-rw-r--r--bridges/ModelKarteiBridge.php102
-rw-r--r--bridges/MoebooruBridge.php56
-rw-r--r--bridges/MoinMoinBridge.php327
-rw-r--r--bridges/MondeDiploBridge.php26
-rw-r--r--bridges/MozillaSecurityBridge.php28
-rw-r--r--bridges/MsnMondeBridge.php35
-rw-r--r--bridges/MspabooruBridge.php12
-rw-r--r--bridges/MydealsBridge.php142
-rw-r--r--bridges/N26Bridge.php37
-rw-r--r--bridges/NasaApodBridge.php44
-rw-r--r--bridges/NeuviemeArtBridge.php47
-rw-r--r--bridges/NextInpactBridge.php110
-rw-r--r--bridges/NextgovBridge.php70
-rw-r--r--bridges/NiceMatinBridge.php32
-rw-r--r--bridges/NineGagBridge.php331
-rw-r--r--bridges/NotAlwaysBridge.php61
-rw-r--r--bridges/NovelUpdatesBridge.php69
-rw-r--r--bridges/NyaaTorrentsBridge.php131
-rw-r--r--bridges/OnVaSortirBridge.php131
-rw-r--r--bridges/OneFortuneADayBridge.php954
-rw-r--r--bridges/OpenClassroomsBridge.php49
-rw-r--r--bridges/OsmAndBlogBridge.php64
-rw-r--r--bridges/ParuVenduImmoBridge.php102
-rw-r--r--bridges/PcGamerBridge.php23
-rw-r--r--bridges/PickyWallpapersBridge.php101
-rw-r--r--bridges/PikabuBridge.php100
-rw-r--r--bridges/PinterestBridge.php125
-rw-r--r--bridges/PixivBridge.php72
-rw-r--r--bridges/RTBFBridge.php66
-rw-r--r--bridges/RadioMelodieBridge.php34
-rw-r--r--bridges/RainbowSixSiegeBridge.php40
-rw-r--r--bridges/ReadComicsBridge.php44
-rw-r--r--bridges/Releases3DSBridge.php127
-rw-r--r--bridges/ReporterreBridge.php47
-rw-r--r--bridges/Rue89Bridge.php48
-rw-r--r--bridges/Rule34Bridge.php12
-rw-r--r--bridges/Rule34pahealBridge.php10
-rw-r--r--bridges/SafebooruBridge.php12
-rw-r--r--bridges/SakugabooruBridge.php11
-rw-r--r--bridges/ScmbBridge.php39
-rw-r--r--bridges/ScoopItBridge.php42
-rw-r--r--bridges/SensCritiqueBridge.php97
-rw-r--r--bridges/ShanaprojectBridge.php123
-rw-r--r--bridges/Shimmie2Bridge.php38
-rw-r--r--bridges/SkimfeedBridge.php823
-rw-r--r--bridges/SoundcloudBridge.php66
-rw-r--r--bridges/SteamBridge.php157
-rw-r--r--bridges/StripeAPIChangeLogBridge.php23
-rw-r--r--bridges/SupInfoBridge.php60
-rw-r--r--bridges/SuperSmashBlogBridge.php45
-rw-r--r--bridges/SuperbWallpapersBridge.php70
-rw-r--r--bridges/TagBoardBridge.php53
-rw-r--r--bridges/TbibBridge.php12
-rw-r--r--bridges/TebeoBridge.php42
-rw-r--r--bridges/TheCodingLoveBridge.php46
-rw-r--r--bridges/TheHackerNewsBridge.php79
-rw-r--r--bridges/ThePirateBayBridge.php174
-rw-r--r--bridges/TheTVDBBridge.php209
-rw-r--r--bridges/TheYeteeBridge.php41
-rw-r--r--bridges/ThingiverseBridge.php165
-rw-r--r--bridges/TrelloBridge.php687
-rw-r--r--bridges/TwitterBridge.php377
-rw-r--r--bridges/UnsplashBridge.php77
-rw-r--r--bridges/UsbekEtRicaBridge.php109
-rw-r--r--bridges/ViadeoCompanyBridge.php37
-rw-r--r--bridges/VkBridge.php413
-rw-r--r--bridges/WallpaperStopBridge.php107
-rw-r--r--bridges/WeLiveSecurityBridge.php32
-rw-r--r--bridges/WebfailBridge.php149
-rw-r--r--bridges/WhydBridge.php62
-rw-r--r--bridges/WikiLeaksBridge.php129
-rw-r--r--bridges/WikipediaBridge.php304
-rw-r--r--bridges/WordPressBridge.php101
-rw-r--r--bridges/WordPressPluginUpdateBridge.php86
-rw-r--r--bridges/WorldOfTanksBridge.php52
-rw-r--r--bridges/XbooruBridge.php12
-rw-r--r--bridges/XenForoBridge.php460
-rw-r--r--bridges/YGGTorrentBridge.php144
-rw-r--r--bridges/YandereBridge.php11
-rw-r--r--bridges/YoutubeBridge.php282
-rw-r--r--bridges/ZDNetBridge.php201
-rw-r--r--bridges/ZenodoBridge.php55
-rw-r--r--bridges/ZoneTelechargementBridge.php95
-rw-r--r--caches/FileCache.php122
-rw-r--r--config.default.ini.php50
-rw-r--r--formats/AtomFormat.php106
-rw-r--r--formats/HtmlFormat.php116
-rw-r--r--formats/JsonFormat.php126
-rw-r--r--formats/MrssFormat.php116
-rw-r--r--formats/PlaintextFormat.php30
-rw-r--r--index.php341
-rw-r--r--lib/Authentication.php85
-rw-r--r--lib/Bridge.php296
-rw-r--r--lib/BridgeAbstract.php293
-rw-r--r--lib/BridgeCard.php357
-rw-r--r--lib/BridgeInterface.php124
-rw-r--r--lib/BridgeList.php209
-rw-r--r--lib/Cache.php140
-rw-r--r--lib/CacheInterface.php50
-rw-r--r--lib/Configuration.php246
-rw-r--r--lib/Debug.php121
-rw-r--r--lib/Exceptions.php203
-rw-r--r--lib/FeedExpander.php418
-rw-r--r--lib/FeedItem.php485
-rw-r--r--lib/Format.php166
-rw-r--r--lib/FormatAbstract.php195
-rw-r--r--lib/FormatInterface.php83
-rw-r--r--lib/ParameterValidator.php227
-rw-r--r--lib/contents.php396
-rw-r--r--lib/error.php43
-rw-r--r--lib/html.php265
-rw-r--r--lib/rssbridge.php80
-rw-r--r--static/HtmlFormat.css87
-rw-r--r--static/search.js60
-rw-r--r--static/select.js10
-rw-r--r--static/style.css306
-rw-r--r--vendor/php-urljoin/src/urljoin.php140
-rw-r--r--vendor/simplehtmldom/simple_html_dom.php2172
223 files changed, 33024 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4e2c7b1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,236 @@
+rss-bridge
+===
+[![LICENSE](https://img.shields.io/badge/license-UNLICENSE-blue.svg)](UNLICENSE) [![GitHub release](https://img.shields.io/github/release/rss-bridge/rss-bridge.svg)](https://github.com/rss-bridge/rss-bridge/releases/latest) [![Debian Release](https://img.shields.io/badge/dynamic/json.svg?label=debian%20release&url=https%3A%2F%2Fsources.debian.org%2Fapi%2Fsrc%2Frss-bridge%2F&query=%24.versions%5B0%5D.version&colorB=blue)](https://tracker.debian.org/pkg/rss-bridge) [![Guix Release](https://img.shields.io/badge/guix%20release-unknown-light--gray.svg)](https://www.gnu.org/software/guix/packages/R/) [![Build Status](https://travis-ci.org/RSS-Bridge/rss-bridge.svg?branch=master)](https://travis-ci.org/RSS-Bridge/rss-bridge) [![Docker Build Status](https://img.shields.io/docker/build/rssbridge/rss-bridge.svg)](https://hub.docker.com/r/rssbridge/rss-bridge/)
+
+RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites which don't have one. It can be used on webservers or as stand alone application in CLI mode.
+
+**Important**: RSS-Bridge is __not__ a feed reader or feed aggregator, but a tool to generate feeds that are consumed by feed readers and feed aggregators. Find a list of feed aggregators on [Wikipedia](https://en.wikipedia.org/wiki/Comparison_of_feed_aggregators).
+
+Supported sites/pages (examples)
+===
+
+* `Bandcamp` : Returns last release from [bandcamp](https://bandcamp.com/) for a tag
+* `Cryptome` : Returns the most recent documents from [Cryptome.org](http://cryptome.org/)
+* `DansTonChat`: Most recent quotes from [danstonchat.com](http://danstonchat.com/)
+* `DuckDuckGo`: Most recent results from [DuckDuckGo.com](https://duckduckgo.com/)
+* `Facebook` : Returns the latest posts on a page or profile on [Facebook](https://facebook.com/)
+* `FlickrExplore` : [Latest interesting images](http://www.flickr.com/explore) from Flickr
+* `GooglePlus` : Most recent posts of user timeline
+* `GoogleSearch` : Most recent results from Google Search
+* `Identi.ca` : Identica user timeline (Should be compatible with other Pump.io instances)
+* `Instagram`: Most recent photos from an Instagram user
+* `OpenClassrooms`: Lastest tutorials from [fr.openclassrooms.com](http://fr.openclassrooms.com/)
+* `Pinterest`: Most recent photos from user or search
+* `ScmbBridge`: Newest stories from [secouchermoinsbete.fr](http://secouchermoinsbete.fr/)
+* `ThePirateBay` : Returns the newest indexed torrents from [The Pirate Bay](https://thepiratebay.se/) with keywords
+* `Twitter` : Return keyword/hashtag search or user timeline
+* `Wikipedia`: highlighted articles from [Wikipedia](https://wikipedia.org/) in English, German, French or Esperanto
+* `YouTube` : YouTube user channel, playlist or search
+
+And [many more](bridges/), thanks to the community!
+
+Output format
+===
+
+RSS-Bridge is capable of producing several output formats:
+
+* `Atom` : Atom feed, for use in feed readers
+* `Html` : Simple HTML page
+* `Json` : JSON, for consumption by other applications
+* `Mrss` : MRSS feed, for use in feed readers
+* `Plaintext` : Raw text, for consumption by other applications
+
+You can extend RSS-Bridge with your own format, using the [Format API](https://github.com/RSS-Bridge/rss-bridge/wiki/Format-API)!
+
+Screenshot
+===
+
+Welcome screen:
+
+![Screenshot](https://github.com/RSS-Bridge/rss-bridge/wiki/images/screenshot_rss-bridge_welcome.png)
+
+***
+
+RSS-Bridge hashtag (#rss-bridge) search on Twitter, in Atom format (as displayed by Firefox):
+
+![Screenshot](https://github.com/RSS-Bridge/rss-bridge/wiki/images/screenshot_twitterbridge_atom.png)
+
+Requirements
+===
+
+RSS-Bridge requires PHP 5.6 or higher with following extensions enabled:
+
+ - [`openssl`](https://secure.php.net/manual/en/book.openssl.php)
+ - [`libxml`](https://secure.php.net/manual/en/book.libxml.php)
+ - [`mbstring`](https://secure.php.net/manual/en/book.mbstring.php)
+ - [`simplexml`](https://secure.php.net/manual/en/book.simplexml.php)
+ - [`curl`](https://secure.php.net/manual/en/book.curl.php)
+ - [`json`](https://secure.php.net/manual/en/book.json.php)
+
+Find more information on our [Wiki](https://github.com/rss-bridge/rss-bridge/wiki)
+
+Enable / Disable bridges
+===
+
+RSS-Bridge allows you to take full control over which bridges are displayed to the user. That way you can host your own RSS-Bridge service with your favorite collection of bridges!
+
+Find more information on the [Wiki](https://github.com/RSS-Bridge/rss-bridge/wiki/Whitelisting)
+
+**Notice**: By default RSS-Bridge will only show a small subset of bridges. Make sure to read up on [whitelisting](https://github.com/RSS-Bridge/rss-bridge/wiki/Whitelisting) to unlock the full potential of RSS-Bridge!
+
+Deploy
+===
+
+Thanks to the community, hosting your own instance of RSS-Bridge is as easy as clicking a button!
+
+[![Deploy on Scalingo](https://cdn.scalingo.com/deploy/button.svg)](https://my.scalingo.com/deploy?source=https://github.com/sebsauvage/rss-bridge)
+[![Deploy to Docker Cloud](https://files.cloud.docker.com/images/deploy-to-dockercloud.svg)](https://cloud.docker.com/stack/deploy/?repo=https://github.com/rss-bridge/rss-bridge)
+
+Getting involved
+===
+
+There are many ways for you to getting involved with RSS-Bridge. Here are a few things:
+
+- Share RSS-Bridge with your friends (Twitter, Facebook, ..._you name it_...)
+- Report broken bridges or bugs by opening [Issues](https://github.com/RSS-Bridge/rss-bridge/issues) on GitHub
+- Request new features or suggest ideas (via [Issues](https://github.com/RSS-Bridge/rss-bridge/issues))
+- Discuss bugs, features, ideas or [issues](https://github.com/RSS-Bridge/rss-bridge/issues)
+- Add new bridges or improve the API
+- Improve the [Wiki](https://github.com/RSS-Bridge/rss-bridge/wiki)
+- Host an instance of RSS-Bridge for your personal use or make it available to the community :sparkling_heart:
+
+Authors
+===
+
+We are RSS-Bridge community, a group of developers continuing the project initiated by sebsauvage, webmaster of [sebsauvage.net](http://sebsauvage.net), author of [Shaarli](http://sebsauvage.net/wiki/doku.php?id=php:shaarli) and [ZeroBin](http://sebsauvage.net/wiki/doku.php?id=php:zerobin).
+
+**Contributors** (sorted alphabetically):
+<!--
+Use this script to generate the list automatically (using the GitHub API):
+https://gist.github.com/LogMANOriginal/da00cd1e5f0ca31cef8e193509b17fd8
+-->
+
+ * [16mhz](https://github.com/16mhz)
+ * [Ahiles3005](https://github.com/Ahiles3005)
+ * [Albirew](https://github.com/Albirew)
+ * [AmauryCarrade](https://github.com/AmauryCarrade)
+ * [AntoineTurmel](https://github.com/AntoineTurmel)
+ * [ArthurHoaro](https://github.com/ArthurHoaro)
+ * [Astalaseven](https://github.com/Astalaseven)
+ * [Astyan-42](https://github.com/Astyan-42)
+ * [Daiyousei](https://github.com/Daiyousei)
+ * [Djuuu](https://github.com/Djuuu)
+ * [Draeli](https://github.com/Draeli)
+ * [EtienneM](https://github.com/EtienneM)
+ * [Frenzie](https://github.com/Frenzie)
+ * [Ginko-Aloe](https://github.com/Ginko-Aloe)
+ * [Glandos](https://github.com/Glandos)
+ * [GregThib](https://github.com/GregThib)
+ * [Grummfy](https://github.com/Grummfy)
+ * [JackNUMBER](https://github.com/JackNUMBER)
+ * [JeremyRand](https://github.com/JeremyRand)
+ * [Jocker666z](https://github.com/Jocker666z)
+ * [LogMANOriginal](https://github.com/LogMANOriginal)
+ * [MonsieurPoutounours](https://github.com/MonsieurPoutounours)
+ * [Nono-m0le](https://github.com/Nono-m0le)
+ * [ORelio](https://github.com/ORelio)
+ * [PaulVayssiere](https://github.com/PaulVayssiere)
+ * [Piranhaplant](https://github.com/Piranhaplant)
+ * [Riduidel](https://github.com/Riduidel)
+ * [Roliga](https://github.com/Roliga)
+ * [Strubbl](https://github.com/Strubbl)
+ * [TheRadialActive](https://github.com/TheRadialActive)
+ * [TwizzyDizzy](https://github.com/TwizzyDizzy)
+ * [WalterBarrett](https://github.com/WalterBarrett)
+ * [ZeNairolf](https://github.com/ZeNairolf)
+ * [adamchainz](https://github.com/adamchainz)
+ * [aledeg](https://github.com/aledeg)
+ * [alexAubin](https://github.com/alexAubin)
+ * [az5he6ch](https://github.com/az5he6ch)
+ * [b1nj](https://github.com/b1nj)
+ * [benasse](https://github.com/benasse)
+ * [captn3m0](https://github.com/captn3m0)
+ * [chemel](https://github.com/chemel)
+ * [ckiw](https://github.com/ckiw)
+ * [cnlpete](https://github.com/cnlpete)
+ * [corenting](https://github.com/corenting)
+ * [couraudt](https://github.com/couraudt)
+ * [da2x](https://github.com/da2x)
+ * [disk0x](https://github.com/disk0x)
+ * [eMerzh](https://github.com/eMerzh)
+ * [em92](https://github.com/em92)
+ * [fluffy-critter](https://github.com/fluffy-critter)
+ * [fulmeek](https://github.com/fulmeek)
+ * [griffaurel](https://github.com/griffaurel)
+ * [hunhejj](https://github.com/hunhejj)
+ * [j0k3r](https://github.com/j0k3r)
+ * [jdigilio](https://github.com/jdigilio)
+ * [kranack](https://github.com/kranack)
+ * [kraoc](https://github.com/kraoc)
+ * [laBecasse](https://github.com/laBecasse)
+ * [lagaisse](https://github.com/lagaisse)
+ * [lalannev](https://github.com/lalannev)
+ * [ldidry](https://github.com/ldidry)
+ * [lorenzos](https://github.com/lorenzos)
+ * [m0zes](https://github.com/m0zes)
+ * [matthewseal](https://github.com/matthewseal)
+ * [mcbyte-it](https://github.com/mcbyte-it)
+ * [mdemoss](https://github.com/mdemoss)
+ * [melangue](https://github.com/melangue)
+ * [metaMMA](https://github.com/metaMMA)
+ * [mickael-bertrand](https://github.com/mickael-bertrand)
+ * [mitsukarenai](https://github.com/mitsukarenai)
+ * [mr-flibble](https://github.com/mr-flibble)
+ * [mro](https://github.com/mro)
+ * [mxmehl](https://github.com/mxmehl)
+ * [nel50n](https://github.com/nel50n)
+ * [niawag](https://github.com/niawag)
+ * [pellaeon](https://github.com/pellaeon)
+ * [pit-fgfjiudghdf](https://github.com/pit-fgfjiudghdf)
+ * [pitchoule](https://github.com/pitchoule)
+ * [pmaziere](https://github.com/pmaziere)
+ * [prysme01](https://github.com/prysme01)
+ * [quentinus95](https://github.com/quentinus95)
+ * [qwertygc](https://github.com/qwertygc)
+ * [regisenguehard](https://github.com/regisenguehard)
+ * [rogerdc](https://github.com/rogerdc)
+ * [sebsauvage](https://github.com/sebsauvage)
+ * [sublimz](https://github.com/sublimz)
+ * [sysadminstory](https://github.com/sysadminstory)
+ * [tameroski](https://github.com/tameroski)
+ * [teromene](https://github.com/teromene)
+ * [triatic](https://github.com/triatic)
+ * [wtuuju](https://github.com/wtuuju)
+ * [yardenac](https://github.com/yardenac)
+
+Licenses
+===
+
+The source code for RSS-Bridge is [Public Domain](UNLICENSE).
+
+RSS-Bridge uses third party libraries with their own license:
+
+ * [`PHP Simple HTML DOM Parser`](http://simplehtmldom.sourceforge.net/) licensed under the [MIT License](http://opensource.org/licenses/MIT)
+ * [`php-urljoin`](https://github.com/fluffy-critter/php-urljoin) licensed under the [MIT License](http://opensource.org/licenses/MIT)
+
+Technical notes
+===
+
+ * RSS-Bridge uses caching to prevent services from banning your server for repeatedly updating feeds. The specific cache duration can be different between bridges. Cached files are deleted automatically after 24 hours.
+ * You can implement your own bridge, [following these instructions](https://github.com/RSS-Bridge/rss-bridge/wiki/Bridge-API).
+ * You can enable debug mode to disable caching. Find more information on the [Wiki](https://github.com/RSS-Bridge/rss-bridge/wiki/Debug-mode)
+
+Rant
+===
+
+*Dear so-called "social" websites.*
+
+Your catchword is "share", but you don't want us to share. You want to keep us within your walled gardens. That's why you've been removing RSS links from webpages, hiding them deep on your website, or removed feeds entirely, replacing it with crippled or demented proprietary API. **FUCK YOU.**
+
+You're not social when you hamper sharing by removing feeds. You're happy to have customers creating content for your ecosystem, but you don't want this content out - a content you do not even own. Google Takeout is just a gimmick. We want our data to flow, we want RSS or Atom feeds.
+
+We want to share with friends, using open protocols: RSS, Atom, XMPP, whatever. Because no one wants to have *your* service with *your* applications using *your* API force-feeding them. Friends must be free to choose whatever software and service they want.
+
+We are rebuilding bridges you have wilfully destroyed.
+
+Get your shit together: Put RSS/Atom back in.
diff --git a/UNLICENSE b/UNLICENSE
new file mode 100644
index 0000000..a84c395
--- /dev/null
+++ b/UNLICENSE
@@ -0,0 +1,25 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to <http://unlicense.org>
+
diff --git a/bridges/ABCTabsBridge.php b/bridges/ABCTabsBridge.php
new file mode 100644
index 0000000..ef2c75b
--- /dev/null
+++ b/bridges/ABCTabsBridge.php
@@ -0,0 +1,42 @@
+<?php
+class ABCTabsBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'kranack';
+ const NAME = 'ABC Tabs Bridge';
+ const URI = 'https://www.abc-tabs.com/';
+ const DESCRIPTION = 'Returns 22 newest tabs';
+
+ public function collectData(){
+ $html = '';
+ $html = getSimpleHTMLDOM(static::URI . 'tablatures/nouveautes.html')
+ or returnClientError('No results for this query.');
+
+ $table = $html->find('table#myTable', 0)->children(1);
+
+ foreach ($table->find('tr') as $tab) {
+ $item = array();
+ $item['author'] = $tab->find('td', 1)->plaintext
+ . ' - '
+ . $tab->find('td', 2)->plaintext;
+
+ $item['title'] = $tab->find('td', 1)->plaintext
+ . ' - '
+ . $tab->find('td', 2)->plaintext;
+
+ $item['content'] = 'Le '
+ . $tab->find('td', 0)->plaintext
+ . '<br> Par: '
+ . $tab->find('td', 5)->plaintext
+ . '<br> Type: '
+ . $tab->find('td', 3)->plaintext;
+
+ $item['id'] = static::URI
+ . $tab->find('td', 2)->find('a', 0)->getAttribute('href');
+
+ $item['uri'] = static::URI
+ . $tab->find('td', 2)->find('a', 0)->getAttribute('href');
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/AcrimedBridge.php b/bridges/AcrimedBridge.php
new file mode 100644
index 0000000..7e0fb6b
--- /dev/null
+++ b/bridges/AcrimedBridge.php
@@ -0,0 +1,24 @@
+<?php
+class AcrimedBridge extends FeedExpander {
+
+ const MAINTAINER = 'qwertygc';
+ const NAME = 'Acrimed Bridge';
+ const URI = 'http://www.acrimed.org/';
+ const CACHE_TIMEOUT = 4800; //2hours
+ const DESCRIPTION = 'Returns the newest articles';
+
+ public function collectData(){
+ $this->collectExpandableDatas(static::URI . 'spip.php?page=backend');
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+
+ $articlePage = getSimpleHTMLDOM($newsItem->link);
+ $article = sanitize($articlePage->find('article.article1', 0)->innertext);
+ $article = defaultLinkTo($article, static::URI);
+ $item['content'] = $article;
+
+ return $item;
+ }
+}
diff --git a/bridges/AllocineFRBridge.php b/bridges/AllocineFRBridge.php
new file mode 100644
index 0000000..50a41ec
--- /dev/null
+++ b/bridges/AllocineFRBridge.php
@@ -0,0 +1,86 @@
+<?php
+class AllocineFRBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'superbaillot.net';
+ const NAME = 'Allo Cine Bridge';
+ const CACHE_TIMEOUT = 25200; // 7h
+ const URI = 'http://www.allocine.fr/';
+ const DESCRIPTION = 'Bridge for allocine.fr';
+ const PARAMETERS = array( array(
+ 'category' => array(
+ 'name' => 'category',
+ 'type' => 'list',
+ 'required' => true,
+ 'exampleValue' => 'Faux Raccord',
+ 'title' => 'Select your category',
+ 'values' => array(
+ 'Faux Raccord' => 'faux-raccord',
+ 'Top 5' => 'top-5',
+ 'Tueurs en Séries' => 'tueurs-en-serie'
+ )
+ )
+ ));
+
+ public function getURI(){
+ if(!is_null($this->getInput('category'))) {
+
+ switch($this->getInput('category')) {
+ case 'faux-raccord':
+ $uri = static::URI . 'video/programme-12284/saison-32180/';
+ break;
+ case 'top-5':
+ $uri = static::URI . 'video/programme-12299/saison-29561/';
+ break;
+ case 'tueurs-en-serie':
+ $uri = static::URI . 'video/programme-12286/saison-22938/';
+ break;
+ }
+
+ return $uri;
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('category'))) {
+ return self::NAME . ' : '
+ . array_search(
+ $this->getInput('category'),
+ self::PARAMETERS[$this->queriedContext]['category']['values']
+ );
+ }
+
+ return parent::getName();
+ }
+
+ public function collectData(){
+
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request ' . $this->getURI() . ' !');
+
+ $category = array_search(
+ $this->getInput('category'),
+ self::PARAMETERS[$this->queriedContext]['category']['values']
+ );
+
+ foreach($html->find('.media-meta-list figure.media-meta-fig') as $element) {
+ $item = array();
+
+ $title = $element->find('div.titlebar h3.title a', 0);
+ $content = trim($element->innertext);
+ $figCaption = strpos($content, $category);
+
+ if($figCaption !== false) {
+ $content = str_replace('src="/', 'src="' . static::URI, $content);
+ $content = str_replace('href="/', 'href="' . static::URI, $content);
+ $content = str_replace('src=\'/', 'src=\'' . static::URI, $content);
+ $content = str_replace('href=\'/', 'href=\'' . static::URI, $content);
+ $item['content'] = $content;
+ $item['title'] = trim($title->innertext);
+ $item['uri'] = static::URI . $title->href;
+ $this->items[] = $item;
+ }
+ }
+ }
+}
diff --git a/bridges/AmazonBridge.php b/bridges/AmazonBridge.php
new file mode 100644
index 0000000..c9d4dc9
--- /dev/null
+++ b/bridges/AmazonBridge.php
@@ -0,0 +1,97 @@
+<?php
+
+class AmazonBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Alexis CHEMEL';
+ const NAME = 'Amazon';
+ const URI = 'https://www.amazon.com/';
+ const CACHE_TIMEOUT = 3600; // 1h
+ const DESCRIPTION = 'Returns products from Amazon search';
+
+ const PARAMETERS = array(array(
+ 'q' => array(
+ 'name' => 'Keyword',
+ 'required' => true,
+ ),
+ 'sort' => array(
+ 'name' => 'Sort by',
+ 'type' => 'list',
+ 'required' => false,
+ 'values' => array(
+ 'Relevance' => 'relevanceblender',
+ 'Price: Low to High' => 'price-asc-rank',
+ 'Price: High to Low' => 'price-desc-rank',
+ 'Average Customer Review' => 'review-rank',
+ 'Newest Arrivals' => 'date-desc-rank',
+ ),
+ 'defaultValue' => 'relevanceblender',
+ ),
+ 'tld' => array(
+ 'name' => 'Country',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'Australia' => 'com.au',
+ 'Brazil' => 'com.br',
+ 'Canada' => 'ca',
+ 'China' => 'cn',
+ 'France' => 'fr',
+ 'Germany' => 'de',
+ 'India' => 'in',
+ 'Italy' => 'it',
+ 'Japan' => 'co.jp',
+ 'Mexico' => 'com.mx',
+ 'Netherlands' => 'nl',
+ 'Spain' => 'es',
+ 'United Kingdom' => 'co.uk',
+ 'United States' => 'com',
+ ),
+ 'defaultValue' => 'com',
+ ),
+ ));
+
+ public function getName(){
+ if(!is_null($this->getInput('tld')) && !is_null($this->getInput('q'))) {
+ return 'Amazon.' . $this->getInput('tld') . ': ' . $this->getInput('q');
+ }
+
+ return parent::getName();
+ }
+
+ public function collectData() {
+
+ $uri = 'https://www.amazon.' . $this->getInput('tld') . '/';
+ $uri .= 's/?field-keywords=' . urlencode($this->getInput('q')) . '&sort=' . $this->getInput('sort');
+
+ $html = getSimpleHTMLDOM($uri)
+ or returnServerError('Could not request Amazon.');
+
+ foreach($html->find('li.s-result-item') as $element) {
+
+ $item = array();
+
+ // Title
+ $title = $element->find('h2', 0);
+ if (is_null($title)) {
+ continue;
+ }
+
+ $item['title'] = html_entity_decode($title->innertext, ENT_QUOTES);
+
+ // Url
+ $uri = $title->parent()->getAttribute('href');
+ $uri = substr($uri, 0, strrpos($uri, '/'));
+
+ $item['uri'] = substr($uri, 0, strrpos($uri, '/'));
+
+ // Content
+ $image = $element->find('img', 0);
+ $price = $element->find('span.s-price', 0);
+ $price = ($price) ? $price->innertext : '';
+
+ $item['content'] = '<img src="' . $image->getAttribute('src') . '" /><br />' . $price;
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/AmazonPriceTrackerBridge.php b/bridges/AmazonPriceTrackerBridge.php
new file mode 100644
index 0000000..e31a03b
--- /dev/null
+++ b/bridges/AmazonPriceTrackerBridge.php
@@ -0,0 +1,187 @@
+<?php
+
+class AmazonPriceTrackerBridge extends BridgeAbstract {
+ const MAINTAINER = 'captn3m0';
+ const NAME = 'Amazon Price Tracker';
+ const URI = 'https://www.amazon.com/';
+ const CACHE_TIMEOUT = 3600; // 1h
+ const DESCRIPTION = 'Tracks price for a single product on Amazon';
+
+ const PARAMETERS = array(
+ array(
+ 'asin' => array(
+ 'name' => 'ASIN',
+ 'required' => true,
+ 'exampleValue' => 'B071GB1VMQ',
+ // https://stackoverflow.com/a/12827734
+ 'pattern' => 'B[\dA-Z]{9}|\d{9}(X|\d)',
+ ),
+ 'tld' => array(
+ 'name' => 'Country',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'Australia' => 'com.au',
+ 'Brazil' => 'com.br',
+ 'Canada' => 'ca',
+ 'China' => 'cn',
+ 'France' => 'fr',
+ 'Germany' => 'de',
+ 'India' => 'in',
+ 'Italy' => 'it',
+ 'Japan' => 'co.jp',
+ 'Mexico' => 'com.mx',
+ 'Netherlands' => 'nl',
+ 'Spain' => 'es',
+ 'United Kingdom' => 'co.uk',
+ 'United States' => 'com',
+ ),
+ 'defaultValue' => 'com',
+ ),
+ ));
+
+ protected $title;
+
+ /**
+ * Generates domain name given a amazon TLD
+ */
+ private function getDomainName() {
+ return 'https://www.amazon.' . $this->getInput('tld');
+ }
+
+ /**
+ * Generates URI for a Amazon product page
+ */
+ public function getURI() {
+ if (!is_null($this->getInput('asin'))) {
+ return $this->getDomainName() . '/dp/' . $this->getInput('asin') . '/';
+ }
+ return parent::getURI();
+ }
+
+ /**
+ * Scrapes the product title from the html page
+ * returns the default title if scraping fails
+ */
+ private function getTitle($html) {
+ $titleTag = $html->find('#productTitle', 0);
+
+ if (!$titleTag) {
+ return $this->getDefaultTitle();
+ } else {
+ return trim(html_entity_decode($titleTag->innertext, ENT_QUOTES));
+ }
+ }
+
+ /**
+ * Title used by the feed if none could be found
+ */
+ private function getDefaultTitle() {
+ return 'Amazon.' . $this->getInput('tld') . ': ' . $this->getInput('asin');
+ }
+
+ /**
+ * Returns name for the feed
+ * Uses title (already scraped) if it has one
+ */
+ public function getName() {
+ if (isset($this->title)) {
+ return $this->title;
+ } else {
+ return parent::getName();
+ }
+ }
+
+ private function parseDynamicImage($attribute) {
+ $json = json_decode(html_entity_decode($attribute), true);
+
+ if ($json and count($json) > 0) {
+ return array_keys($json)[0];
+ }
+ }
+
+ /**
+ * Returns a generated image tag for the product
+ */
+ private function getImage($html) {
+ $imageSrc = $html->find('#main-image-container img', 0);
+
+ if ($imageSrc) {
+ $hiresImage = $imageSrc->getAttribute('data-old-hires');
+ $dynamicImageAttribute = $imageSrc->getAttribute('data-a-dynamic-image');
+ $image = $hiresImage ?: $this->parseDynamicImage($dynamicImageAttribute);
+ }
+ $image = $image ?: 'https://placekitten.com/200/300';
+
+ return <<<EOT
+<img width="300" style="max-width:300;max-height:300" src="$image" alt="{$this->title}" />
+EOT;
+ }
+
+ /**
+ * Return \simple_html_dom object
+ * for the entire html of the product page
+ */
+ private function getHtml() {
+ $uri = $this->getURI();
+
+ return getSimpleHTMLDOM($uri) ?: returnServerError('Could not request Amazon.');
+ }
+
+ private function scrapePriceFromMetrics($html) {
+ $asinData = $html->find('#cerberus-data-metrics', 0);
+
+ // <div id="cerberus-data-metrics" style="display: none;"
+ // data-asin="B00WTHJ5SU" data-asin-price="14.99" data-asin-shipping="0"
+ // data-asin-currency-code="USD" data-substitute-count="-1" ... />
+ if ($asinData) {
+ return [
+ 'price' => $asinData->getAttribute('data-asin-price'),
+ 'currency' => $asinData->getAttribute('data-asin-currency-code'),
+ 'shipping' => $asinData->getAttribute('data-asin-shipping')
+ ];
+ }
+
+ return false;
+ }
+
+ private function scrapePriceGeneric($html) {
+ $priceDiv = $html->find('span.offer-price', 0) ?: $html->find('.a-color-price', 0);
+
+ preg_match('/^\s*([A-Z]{3}|£|\$)\s?([\d.,]+)\s*$/', $priceDiv->plaintext, $matches);
+
+ if (count($matches) === 3) {
+ return [
+ 'price' => $matches[2],
+ 'currency' => $matches[1],
+ 'shipping' => '0'
+ ];
+ }
+
+ return false;
+ }
+
+ /**
+ * Scrape method for Amazon product page
+ * @return [type] [description]
+ */
+ public function collectData() {
+ $html = $this->getHtml();
+ $this->title = $this->getTitle($html);
+ $imageTag = $this->getImage($html);
+
+ $data = $this->scrapePriceFromMetrics($html) ?: $this->scrapePriceGeneric($html);
+
+ $item = array(
+ 'title' => $this->title,
+ 'uri' => $this->getURI(),
+ 'content' => "$imageTag<br/>Price: {$data['price']} {$data['currency']}",
+ );
+
+ if ($data['shipping'] !== '0') {
+ $item['content'] .= "<br>Shipping: {$data['shipping']} {$data['currency']}</br>";
+ }
+
+ $this->items[] = $item;
+ }
+}
diff --git a/bridges/AnidexBridge.php b/bridges/AnidexBridge.php
new file mode 100644
index 0000000..ae387c9
--- /dev/null
+++ b/bridges/AnidexBridge.php
@@ -0,0 +1,207 @@
+<?php
+class AnidexBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'Anidex';
+ const URI = 'https://anidex.info/';
+ const DESCRIPTION = 'Returns the newest torrents, with optional search criteria.';
+ const PARAMETERS = array(
+ array(
+ 'id' => array(
+ 'name' => 'Category',
+ 'type' => 'list',
+ 'values' => array(
+ 'All categories' => '0',
+ 'Anime' => '1,2,3',
+ 'Anime - Sub' => '1',
+ 'Anime - Raw' => '2',
+ 'Anime - Dub' => '3',
+ 'Live Action' => '4,5',
+ 'Live Action - Sub' => '4',
+ 'Live Action - Raw' => '5',
+ 'Light Novel' => '6',
+ 'Manga' => '7,8',
+ 'Manga - Translated' => '7',
+ 'Manga - Raw' => '8',
+ 'Music' => '9,10,11',
+ 'Music - Lossy' => '9',
+ 'Music - Lossless' => '10',
+ 'Music - Video' => '11',
+ 'Games' => '12',
+ 'Applications' => '13',
+ 'Pictures' => '14',
+ 'Adult Video' => '15',
+ 'Other' => '16'
+ )
+ ),
+ 'lang_id' => array(
+ 'name' => 'Language',
+ 'type' => 'list',
+ 'values' => array(
+ 'All languages' => '0',
+ 'English' => '1',
+ 'Japanese' => '2',
+ 'Polish' => '3',
+ 'Serbo-Croatian' => '4',
+ 'Dutch' => '5',
+ 'Italian' => '6',
+ 'Russian' => '7',
+ 'German' => '8',
+ 'Hungarian' => '9',
+ 'French' => '10',
+ 'Finnish' => '11',
+ 'Vietnamese' => '12',
+ 'Greek' => '13',
+ 'Bulgarian' => '14',
+ 'Spanish (Spain)' => '15',
+ 'Portuguese (Brazil)' => '16',
+ 'Portuguese (Portugal)' => '17',
+ 'Swedish' => '18',
+ 'Arabic' => '19',
+ 'Danish' => '20',
+ 'Chinese (Simplified)' => '21',
+ 'Bengali' => '22',
+ 'Romanian' => '23',
+ 'Czech' => '24',
+ 'Mongolian' => '25',
+ 'Turkish' => '26',
+ 'Indonesian' => '27',
+ 'Korean' => '28',
+ 'Spanish (LATAM)' => '29',
+ 'Persian' => '30',
+ 'Malaysian' => '31'
+ )
+ ),
+ 'group_id' => array(
+ 'name' => 'Group ID',
+ 'type' => 'number'
+ ),
+ 'r' => array(
+ 'name' => 'Hide Remakes',
+ 'type' => 'checkbox'
+ ),
+ 'b' => array(
+ 'name' => 'Only Batches',
+ 'type' => 'checkbox'
+ ),
+ 'a' => array(
+ 'name' => 'Only Authorized',
+ 'type' => 'checkbox'
+ ),
+ 'q' => array(
+ 'name' => 'Keyword',
+ 'description' => 'Keyword(s)',
+ 'type' => 'text'
+ ),
+ 'h' => array(
+ 'name' => 'Adult content',
+ 'type' => 'list',
+ 'values' => array(
+ 'No filter' => '0',
+ 'Hide +18' => '1',
+ 'Only +18' => '2'
+ )
+ )
+ )
+ );
+
+ public function collectData() {
+
+ // Build Search URL from user-provided parameters
+ $search_url = self::URI . '?s=upload_timestamp&o=desc';
+ foreach (array('id', 'lang_id', 'group_id') as $param_name) {
+ $param = $this->getInput($param_name);
+ if (!empty($param) && intval($param) != 0 && ctype_digit(str_replace(',', '', $param))) {
+ $search_url .= '&' . $param_name . '=' . $param;
+ }
+ }
+ foreach (array('r', 'b', 'a') as $param_name) {
+ $param = $this->getInput($param_name);
+ if (!empty($param) && boolval($param)) {
+ $search_url .= '&' . $param_name . '=1';
+ }
+ }
+ $query = $this->getInput('q');
+ if (!empty($query)) {
+ $search_url .= '&q=' . urlencode($query);
+ }
+ $opt = array();
+ $h = $this->getInput('h');
+ if (!empty($h) && intval($h) != 0 && ctype_digit($h)) {
+ $opt[CURLOPT_COOKIE] = 'anidex_h_toggle=' . $h;
+ }
+
+ // Retrieve torrent listing from search results, which does not contain torrent description
+ $html = getSimpleHTMLDOM($search_url, array(), $opt)
+ or returnServerError('Could not request Anidex: ' . $search_url);
+ $links = $html->find('a');
+ $results = array();
+ foreach ($links as $link)
+ if (strpos($link->href, '/torrent/') === 0 && !in_array($link->href, $results))
+ $results[] = $link->href;
+ if (empty($results) && empty($this->getInput('q')))
+ returnServerError('No results from Anidex: ' . $search_url);
+
+ //Process each item individually
+ foreach ($results as $element) {
+
+ //Limit total amount of requests
+ if(count($this->items) >= 20) {
+ break;
+ }
+
+ $torrent_id = str_replace('/torrent/', '', $element);
+
+ //Ignore entries without valid torrent ID
+ if ($torrent_id != 0 && ctype_digit($torrent_id)) {
+
+ //Retrieve data for this torrent ID
+ $item_uri = self::URI . 'torrent/' . $torrent_id;
+
+ //Retrieve full description from torrent page
+ if ($item_html = getSimpleHTMLDOMCached($item_uri)) {
+
+ //Retrieve data from page contents
+ $item_title = str_replace(' (Torrent) - AniDex ', '', $item_html->find('title', 0)->plaintext);
+ $item_desc = $item_html->find('div.panel-body', 0);
+ $item_author = trim($item_html->find('span.fa-user', 0)->parent()->plaintext);
+ $item_date = strtotime(trim($item_html->find('span.fa-clock', 0)->parent()->plaintext));
+ $item_image = $this->getURI() . 'images/user_logos/default.png';
+
+ //Check for description-less torrent andn optionally extract image
+ $desc_title_found = false;
+ foreach ($item_html->find('h3.panel-title') as $h3) {
+ if (strpos($h3, 'Description') !== false) {
+ $desc_title_found = true;
+ break;
+ }
+ }
+ if ($desc_title_found) {
+ //Retrieve image for thumbnail or generic logo fallback
+ foreach ($item_desc->find('img') as $img) {
+ if (strpos($img->src, 'prez') === false) {
+ $item_image = $img->src;
+ break;
+ }
+ }
+ $item_desc = trim($item_desc->innertext);
+ } else {
+ $item_desc = '<em>No description.</em>';
+ }
+
+ //Build and add final item
+ $item = array();
+ $item['uri'] = $item_uri;
+ $item['title'] = $item_title;
+ $item['author'] = $item_author;
+ $item['timestamp'] = $item_date;
+ $item['enclosures'] = array($item_image);
+ $item['content'] = $item_desc;
+ $this->items[] = $item;
+ }
+ }
+ $element = null;
+ }
+ $results = null;
+ }
+}
diff --git a/bridges/AnimeUltimeBridge.php b/bridges/AnimeUltimeBridge.php
new file mode 100644
index 0000000..bc1dd7b
--- /dev/null
+++ b/bridges/AnimeUltimeBridge.php
@@ -0,0 +1,140 @@
+<?php
+class AnimeUltimeBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'Anime-Ultime';
+ const URI = 'http://www.anime-ultime.net/';
+ const CACHE_TIMEOUT = 10800; // 3h
+ const DESCRIPTION = 'Returns the newest releases posted on Anime-Ultime.';
+ const PARAMETERS = array( array(
+ 'type' => array(
+ 'name' => 'Type',
+ 'type' => 'list',
+ 'values' => array(
+ 'Everything' => '',
+ 'Anime' => 'A',
+ 'Drama' => 'D',
+ 'Tokusatsu' => 'T'
+ )
+ )
+ ));
+
+ private $filter = 'Releases';
+
+ public function collectData(){
+
+ //Add type filter if provided
+ $typeFilter = array_search(
+ $this->getInput('type'),
+ self::PARAMETERS[$this->queriedContext]['type']['values']
+ );
+
+ //Build date and filters for making requests
+ $thismonth = date('mY') . $typeFilter;
+ $lastmonth = date('mY', mktime(0, 0, 0, date('n') - 1, 1, date('Y'))) . $typeFilter;
+
+ //Process each HTML page until having 10 releases
+ $processedOK = 0;
+ foreach (array($thismonth, $lastmonth) as $requestFilter) {
+
+ //Retrive page contents
+ $url = self::URI . 'history-0-1/' . $requestFilter;
+ $html = getSimpleHTMLDOM($url)
+ or returnServerError('Could not request Anime-Ultime: ' . $url);
+
+ //Relases are sorted by day : process each day individually
+ foreach($html->find('div.history', 0)->find('h3') as $daySection) {
+
+ //Retrieve day and build date information
+ $dateString = $daySection->plaintext;
+ $day = intval(substr($dateString, strpos($dateString, ' ') + 1, 2));
+ $item_date = strtotime(str_pad($day, 2, '0', STR_PAD_LEFT)
+ . '-'
+ . substr($requestFilter, 0, 2)
+ . '-'
+ . substr($requestFilter, 2, 4));
+
+ //<h3>day</h3><br /><table><tr> <-- useful data in table rows
+ $release = $daySection->next_sibling()->next_sibling()->first_child();
+
+ //Process each release of that day, ignoring first table row: contains table headers
+ while(!is_null($release = $release->next_sibling())) {
+ if(count($release->find('td')) > 0) {
+
+ //Retrieve metadata from table columns
+ $item_link_element = $release->find('td', 0)->find('a', 0);
+ $item_uri = self::URI . $item_link_element->href;
+ $item_name = html_entity_decode($item_link_element->plaintext);
+
+ $item_image = self::URI . substr(
+ $item_link_element->onmouseover,
+ 37,
+ strpos($item_link_element->onmouseover, ' ', 37) - 37
+ );
+
+ $item_episode = html_entity_decode(
+ str_pad(
+ $release->find('td', 1)->plaintext,
+ 2,
+ '0',
+ STR_PAD_LEFT
+ )
+ );
+
+ $item_fansub = $release->find('td', 2)->plaintext;
+ $item_type = $release->find('td', 4)->plaintext;
+
+ if(!empty($item_uri)) {
+
+ // Retrieve description from description page
+ $html_item = getContents($item_uri)
+ or returnServerError('Could not request Anime-Ultime: ' . $item_uri);
+ $item_description = substr(
+ $html_item,
+ strpos($html_item, 'class="principal_contain" align="center">') + 41
+ );
+ $item_description = substr($item_description,
+ 0,
+ strpos($item_description, '<div id="table">')
+ );
+
+ // Convert relative image src into absolute image src, remove line breaks
+ $item_description = defaultLinkTo($item_description, self::URI);
+ $item_description = str_replace("\r", '', $item_description);
+ $item_description = str_replace("\n", '', $item_description);
+ $item_description = utf8_encode($item_description);
+
+ //Build and add final item
+ $item = array();
+ $item['uri'] = $item_uri;
+ $item['title'] = $item_name . ' ' . $item_type . ' ' . $item_episode;
+ $item['author'] = $item_fansub;
+ $item['timestamp'] = $item_date;
+ $item['enclosures'] = array($item_image);
+ $item['content'] = $item_description;
+ $this->items[] = $item;
+ $processedOK++;
+
+ //Stop processing once limit is reached
+ if ($processedOK >= 10)
+ return;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public function getName() {
+ if(!is_null($this->getInput('type'))) {
+ $typeFilter = array_search(
+ $this->getInput('type'),
+ self::PARAMETERS[$this->queriedContext]['type']['values']
+ );
+
+ return 'Latest ' . $typeFilter . ' - Anime-Ultime Bridge';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/Arte7Bridge.php b/bridges/Arte7Bridge.php
new file mode 100644
index 0000000..ff72211
--- /dev/null
+++ b/bridges/Arte7Bridge.php
@@ -0,0 +1,122 @@
+<?php
+class Arte7Bridge extends BridgeAbstract {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Arte +7';
+ const URI = 'https://www.arte.tv/';
+ const CACHE_TIMEOUT = 1800; // 30min
+ const DESCRIPTION = 'Returns newest videos from ARTE +7';
+
+ const API_TOKEN = 'Nzc1Yjc1ZjJkYjk1NWFhN2I2MWEwMmRlMzAzNjI5NmU3NWU3ODg4ODJjOWMxNTMxYzEzZGRjYjg2ZGE4MmIwOA';
+
+ const PARAMETERS = array(
+ 'Catégorie (Français)' => array(
+ 'catfr' => array(
+ 'type' => 'list',
+ 'name' => 'Catégorie',
+ 'values' => array(
+ 'Toutes les vidéos (français)' => null,
+ 'Actu & société' => 'ACT',
+ 'Séries & fiction' => 'SER',
+ 'Cinéma' => 'CIN',
+ 'Arts & spectacles classiques' => 'ARS',
+ 'Culture pop' => 'CPO',
+ 'Découverte' => 'DEC',
+ 'Histoire' => 'HIST',
+ 'Science' => 'SCI',
+ 'Autre' => 'AUT'
+ )
+ )
+ ),
+ 'Collection (Français)' => array(
+ 'colfr' => array(
+ 'name' => 'Collection id',
+ 'required' => true,
+ 'title' => 'ex. RC-014095 pour https://www.arte.tv/fr/videos/RC-014095/blow-up/'
+ )
+ ),
+ 'Catégorie (Allemand)' => array(
+ 'catde' => array(
+ 'type' => 'list',
+ 'name' => 'Catégorie',
+ 'values' => array(
+ 'Alle Videos (deutsch)' => null,
+ 'Aktuelles & Gesellschaft' => 'ACT',
+ 'Fernsehfilme & Serien' => 'SER',
+ 'Kino' => 'CIN',
+ 'Kunst & Kultur' => 'ARS',
+ 'Popkultur & Alternativ' => 'CPO',
+ 'Entdeckung' => 'DEC',
+ 'Geschichte' => 'HIST',
+ 'Wissenschaft' => 'SCI',
+ 'Sonstiges' => 'AUT'
+ )
+ )
+ ),
+ 'Collection (Allemand)' => array(
+ 'colde' => array(
+ 'name' => 'Collection id',
+ 'required' => true,
+ 'title' => 'ex. RC-014095 pour https://www.arte.tv/de/videos/RC-014095/blow-up/'
+ )
+ )
+ );
+
+ public function collectData(){
+ switch($this->queriedContext) {
+ case 'Catégorie (Français)':
+ $category = $this->getInput('catfr');
+ $lang = 'fr';
+ break;
+ case 'Collection (Français)':
+ $lang = 'fr';
+ $collectionId = $this->getInput('colfr');
+ break;
+ case 'Catégorie (Allemand)':
+ $category = $this->getInput('catde');
+ $lang = 'de';
+ break;
+ case 'Collection (Allemand)':
+ $lang = 'de';
+ $collectionId = $this->getInput('colde');
+ break;
+ }
+
+ $url = 'https://api.arte.tv/api/opa/v3/videos?sort=-lastModified&limit=10&language='
+ . $lang
+ . ($category != null ? '&category.code=' . $category : '')
+ . ($collectionId != null ? '&collections.collectionId=' . $collectionId : '');
+
+ $header = array(
+ 'Authorization: Bearer ' . self::API_TOKEN
+ );
+
+ $input = getContents($url, $header) or die('Could not request ARTE.');
+ $input_json = json_decode($input, true);
+
+ foreach($input_json['videos'] as $element) {
+
+ $item = array();
+ $item['uri'] = $element['url'];
+ $item['id'] = $element['id'];
+
+ $item['timestamp'] = strtotime($element['videoRightsBegin']);
+ $item['title'] = $element['title'];
+
+ if(!empty($element['subtitle']))
+ $item['title'] = $element['title'] . ' | ' . $element['subtitle'];
+
+ $item['duration'] = round((int)$element['durationSeconds'] / 60);
+ $item['content'] = $element['teaserText']
+ . '<br><br>'
+ . $item['duration']
+ . 'min<br><a href="'
+ . $item['uri']
+ . '"><img src="'
+ . $element['mainImage']['url']
+ . '" /></a>';
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/AskfmBridge.php b/bridges/AskfmBridge.php
new file mode 100644
index 0000000..b76d51b
--- /dev/null
+++ b/bridges/AskfmBridge.php
@@ -0,0 +1,74 @@
+<?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 = array(
+ 'Ask.fm username' => array(
+ 'u' => array(
+ 'name' => 'Username',
+ 'required' => true
+ )
+ )
+ );
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Requested username can\'t be found.');
+
+ $html = defaultLinkTo($html, self::URI);
+
+ foreach($html->find('article.streamItem-answer') as $element) {
+ $item = array();
+ $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);
+
+ $answer = trim($element->find('div.streamItem_content', 0)->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();
+ }
+}
diff --git a/bridges/AutoJMBridge.php b/bridges/AutoJMBridge.php
new file mode 100644
index 0000000..598f043
--- /dev/null
+++ b/bridges/AutoJMBridge.php
@@ -0,0 +1,65 @@
+<?php
+
+class AutoJMBridge extends BridgeAbstract {
+
+ const NAME = 'AutoJM';
+ const URI = 'http://www.autojm.fr/';
+ const DESCRIPTION = 'Suivre les offres de véhicules proposés par AutoJM en fonction des critères de filtrages';
+ const MAINTAINER = 'sysadminstory';
+ const PARAMETERS = array(
+ 'Afficher les offres de véhicules disponible en fonction des critères du site AutoJM' => array(
+ 'url' => array(
+ 'name' => 'URL de la recherche',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'URL d\'une recherche avec filtre de véhicules sans le http://www.autojm.fr/',
+ 'exampleValue' => 'gammes/index/398?order_by=finition_asc&energie[]=3&transmission[]=2&dispo=all'
+ )
+ )
+ );
+ const CACHE_TIMEOUT = 3600;
+
+ public function getIcon() {
+ return self::URI . 'assets/images/favicon.ico';
+ }
+
+ public function collectData() {
+ $html = getSimpleHTMLDOM(self::URI . $this->getInput('url'))
+ or returnServerError('Could not request AutoJM.');
+ $list = $html->find('div[class*=ligne_modele]');
+ foreach($list as $element) {
+ $image = $element->find('img[class=width-100]', 0)->src;
+ $serie = $element->find('div[class=serie]', 0)->find('span', 0)->plaintext;
+ $url = $element->find('div[class=serie]', 0)->find('a[class=btn_ligne color-black]', 0)->href;
+ if($element->find('div[class*=hasStock-info]', 0) != null) {
+ $dispo = 'Disponible';
+ } else {
+ $dispo = 'Sur commande';
+ }
+ $carburant = str_replace('dispo |', '', $element->find('div[class=carburant]', 0)->plaintext);
+ $transmission = $element->find('div[class*=bv]', 0)->plaintext;
+ $places = $element->find('div[class*=places]', 0)->plaintext;
+ $portes = $element->find('div[class*=nb_portes]', 0)->plaintext;
+ $carosserie = $element->find('div[class*=coloris]', 0)->plaintext;
+ $remise = $element->find('div[class*=remise]', 0)->plaintext;
+ $prix = $element->find('div[class*=prixjm]', 0)->plaintext;
+
+ $item = array();
+ $item['uri'] = $url;
+ $item['title'] = $serie;
+ $item['content'] = '<p><img style="vertical-align:middle ; padding: 10px" src="' . $image . '" />' . $serie . '</p>';
+ $item['content'] .= '<ul><li>Disponibilité : ' . $dispo . '</li>';
+ $item['content'] .= '<li>Carburant : ' . $carburant . '</li>';
+ $item['content'] .= '<li>Transmission : ' . $transmission . '</li>';
+ $item['content'] .= '<li>Nombre de places : ' . $places . '</li>';
+ $item['content'] .= '<li>Nombre de portes : ' . $portes . '</li>';
+ $item['content'] .= '<li>Série : ' . $serie . '</li>';
+ $item['content'] .= '<li>Carosserie : ' . $carosserie . '</li>';
+ $item['content'] .= '<li>Remise : ' . $remise . '</li>';
+ $item['content'] .= '<li>Prix : ' . $prix . '</li></ul>';
+
+ $this->items[] = $item;
+ }
+
+ }
+}
diff --git a/bridges/BAEBridge.php b/bridges/BAEBridge.php
new file mode 100644
index 0000000..caa2cf7
--- /dev/null
+++ b/bridges/BAEBridge.php
@@ -0,0 +1,265 @@
+<?php
+class BAEBridge extends BridgeAbstract {
+ const MAINTAINER = 'couraudt';
+ const NAME = 'Bourse Aux Equipiers Bridge';
+ const URI = 'https://www.bourse-aux-equipiers.com';
+ const DESCRIPTION = 'Returns the newest sailing offers.';
+ const PARAMETERS = array(
+ array(
+ 'keyword' => array(
+ 'name' => 'Filtrer par mots clés',
+ 'title' => 'Entrez le mot clé à filtrer ici'
+ ),
+ 'type' => array(
+ 'name' => 'Type de recherche',
+ 'title' => 'Afficher seuleument un certain type d\'annonce',
+ 'type' => 'list',
+ 'values' => array(
+ 'Toutes les annonces' => false,
+ 'Les embarquements' => 'boat',
+ 'Les skippers' => 'skipper',
+ 'Les équipiers' => 'crew'
+ )
+ )
+ )
+ );
+
+ public function collectData() {
+ $url = $this->getURI();
+ $html = getSimpleHTMLDOM($url) or returnClientError('No results for this query.');
+
+ $annonces = $html->find('main article');
+ foreach ($annonces as $annonce) {
+ $detail = $annonce->find('footer a', 0);
+
+ $htmlDetail = getSimpleHTMLDOMCached(parent::getURI() . $detail->href);
+ if (!$htmlDetail)
+ continue;
+
+ $item = array();
+
+ $item['title'] = $annonce->find('header h2', 0)->plaintext;
+ $item['uri'] = parent::getURI() . $detail->href;
+
+ $content = $htmlDetail->find('article p', 0)->innertext;
+ if (!empty($this->getInput('keyword'))) {
+ $keyword = $this->remove_accents(strtolower($this->getInput('keyword')));
+ $cleanTitle = $this->remove_accents(strtolower($item['title']));
+ if (strpos($cleanTitle, $keyword) === false) {
+ $cleanContent = $this->remove_accents(strtolower($content));
+ if (strpos($cleanContent, $keyword) === false) {
+ continue;
+ }
+ }
+ }
+
+ $content .= '<hr>';
+ $content .= $htmlDetail->find('section', 0)->innertext;
+ $content = str_replace('src="/', 'src="' . parent::getURI() . '/', $content);
+ $content = str_replace('href="/', 'href="' . parent::getURI() . '/', $content);
+ $item['content'] = $content;
+ $image = $htmlDetail->find('#zoom', 0);
+ if ($image) {
+ $item['enclosures'] = array(parent::getURI() . $image->getAttribute('src'));
+ }
+ $this->items[] = $item;
+ }
+ }
+
+ public function getURI() {
+ $uri = parent::getURI();
+ if (!empty($this->getInput('type'))) {
+ if ($this->getInput('type') == 'boat') {
+ $uri .= '/embarquements.html';
+ } elseif ($this->getInput('type') == 'skipper') {
+ $uri .= '/skippers.html';
+ } else {
+ $uri .= '/equipiers.html';
+ }
+ }
+
+ return $uri;
+ }
+
+ private function remove_accents($string) {
+ $chars = array(
+ // Decompositions for Latin-1 Supplement
+ 'ª' => 'a', 'º' => 'o',
+ 'À' => 'A', 'Á' => 'A',
+ 'Â' => 'A', 'Ã' => 'A',
+ 'Ä' => 'A', 'Å' => 'A',
+ 'Æ' => 'AE', 'Ç' => 'C',
+ 'È' => 'E', 'É' => 'E',
+ 'Ê' => 'E', 'Ë' => 'E',
+ 'Ì' => 'I', 'Í' => 'I',
+ 'Î' => 'I', 'Ï' => 'I',
+ 'Ð' => 'D', 'Ñ' => 'N',
+ 'Ò' => 'O', 'Ó' => 'O',
+ 'Ô' => 'O', 'Õ' => 'O',
+ 'Ö' => 'O', 'Ù' => 'U',
+ 'Ú' => 'U', 'Û' => 'U',
+ 'Ü' => 'U', 'Ý' => 'Y',
+ 'Þ' => 'TH', 'ß' => 's',
+ 'à' => 'a', 'á' => 'a',
+ 'â' => 'a', 'ã' => 'a',
+ 'ä' => 'a', 'å' => 'a',
+ 'æ' => 'ae', 'ç' => 'c',
+ 'è' => 'e', 'é' => 'e',
+ 'ê' => 'e', 'ë' => 'e',
+ 'ì' => 'i', 'í' => 'i',
+ 'î' => 'i', 'ï' => 'i',
+ 'ð' => 'd', 'ñ' => 'n',
+ 'ò' => 'o', 'ó' => 'o',
+ 'ô' => 'o', 'õ' => 'o',
+ 'ö' => 'o', 'ø' => 'o',
+ 'ù' => 'u', 'ú' => 'u',
+ 'û' => 'u', 'ü' => 'u',
+ 'ý' => 'y', 'þ' => 'th',
+ 'ÿ' => 'y', 'Ø' => 'O',
+ // Decompositions for Latin Extended-A
+ 'Ā' => 'A', 'ā' => 'a',
+ 'Ă' => 'A', 'ă' => 'a',
+ 'Ą' => 'A', 'ą' => 'a',
+ 'Ć' => 'C', 'ć' => 'c',
+ 'Ĉ' => 'C', 'ĉ' => 'c',
+ 'Ċ' => 'C', 'ċ' => 'c',
+ 'Č' => 'C', 'č' => 'c',
+ 'Ď' => 'D', 'ď' => 'd',
+ 'Đ' => 'D', 'đ' => 'd',
+ 'Ē' => 'E', 'ē' => 'e',
+ 'Ĕ' => 'E', 'ĕ' => 'e',
+ 'Ė' => 'E', 'ė' => 'e',
+ 'Ę' => 'E', 'ę' => 'e',
+ 'Ě' => 'E', 'ě' => 'e',
+ 'Ĝ' => 'G', 'ĝ' => 'g',
+ 'Ğ' => 'G', 'ğ' => 'g',
+ 'Ġ' => 'G', 'ġ' => 'g',
+ 'Ģ' => 'G', 'ģ' => 'g',
+ 'Ĥ' => 'H', 'ĥ' => 'h',
+ 'Ħ' => 'H', 'ħ' => 'h',
+ 'Ĩ' => 'I', 'ĩ' => 'i',
+ 'Ī' => 'I', 'ī' => 'i',
+ 'Ĭ' => 'I', 'ĭ' => 'i',
+ 'Į' => 'I', 'į' => 'i',
+ 'İ' => 'I', 'ı' => 'i',
+ 'IJ' => 'IJ', 'ij' => 'ij',
+ 'Ĵ' => 'J', 'ĵ' => 'j',
+ 'Ķ' => 'K', 'ķ' => 'k',
+ 'ĸ' => 'k', 'Ĺ' => 'L',
+ 'ĺ' => 'l', 'Ļ' => 'L',
+ 'ļ' => 'l', 'Ľ' => 'L',
+ 'ľ' => 'l', 'Ŀ' => 'L',
+ 'ŀ' => 'l', 'Ł' => 'L',
+ 'ł' => 'l', 'Ń' => 'N',
+ 'ń' => 'n', 'Ņ' => 'N',
+ 'ņ' => 'n', 'Ň' => 'N',
+ 'ň' => 'n', 'ʼn' => 'n',
+ 'Ŋ' => 'N', 'ŋ' => 'n',
+ 'Ō' => 'O', 'ō' => 'o',
+ 'Ŏ' => 'O', 'ŏ' => 'o',
+ 'Ő' => 'O', 'ő' => 'o',
+ 'Œ' => 'OE', 'œ' => 'oe',
+ 'Ŕ' => 'R', 'ŕ' => 'r',
+ 'Ŗ' => 'R', 'ŗ' => 'r',
+ 'Ř' => 'R', 'ř' => 'r',
+ 'Ś' => 'S', 'ś' => 's',
+ 'Ŝ' => 'S', 'ŝ' => 's',
+ 'Ş' => 'S', 'ş' => 's',
+ 'Š' => 'S', 'š' => 's',
+ 'Ţ' => 'T', 'ţ' => 't',
+ 'Ť' => 'T', 'ť' => 't',
+ 'Ŧ' => 'T', 'ŧ' => 't',
+ 'Ũ' => 'U', 'ũ' => 'u',
+ 'Ū' => 'U', 'ū' => 'u',
+ 'Ŭ' => 'U', 'ŭ' => 'u',
+ 'Ů' => 'U', 'ů' => 'u',
+ 'Ű' => 'U', 'ű' => 'u',
+ 'Ų' => 'U', 'ų' => 'u',
+ 'Ŵ' => 'W', 'ŵ' => 'w',
+ 'Ŷ' => 'Y', 'ŷ' => 'y',
+ 'Ÿ' => 'Y', 'Ź' => 'Z',
+ 'ź' => 'z', 'Ż' => 'Z',
+ 'ż' => 'z', 'Ž' => 'Z',
+ 'ž' => 'z', 'ſ' => 's',
+ // Decompositions for Latin Extended-B
+ 'Ș' => 'S', 'ș' => 's',
+ 'Ț' => 'T', 'ț' => 't',
+ // Euro Sign
+ '€' => 'E',
+ // GBP (Pound) Sign
+ '£' => '',
+ // Vowels with diacritic (Vietnamese)
+ // unmarked
+ 'Ơ' => 'O', 'ơ' => 'o',
+ 'Ư' => 'U', 'ư' => 'u',
+ // grave accent
+ 'Ầ' => 'A', 'ầ' => 'a',
+ 'Ằ' => 'A', 'ằ' => 'a',
+ 'Ề' => 'E', 'ề' => 'e',
+ 'Ồ' => 'O', 'ồ' => 'o',
+ 'Ờ' => 'O', 'ờ' => 'o',
+ 'Ừ' => 'U', 'ừ' => 'u',
+ 'Ỳ' => 'Y', 'ỳ' => 'y',
+ // hook
+ 'Ả' => 'A', 'ả' => 'a',
+ 'Ẩ' => 'A', 'ẩ' => 'a',
+ 'Ẳ' => 'A', 'ẳ' => 'a',
+ 'Ẻ' => 'E', 'ẻ' => 'e',
+ 'Ể' => 'E', 'ể' => 'e',
+ 'Ỉ' => 'I', 'ỉ' => 'i',
+ 'Ỏ' => 'O', 'ỏ' => 'o',
+ 'Ổ' => 'O', 'ổ' => 'o',
+ 'Ở' => 'O', 'ở' => 'o',
+ 'Ủ' => 'U', 'ủ' => 'u',
+ 'Ử' => 'U', 'ử' => 'u',
+ 'Ỷ' => 'Y', 'ỷ' => 'y',
+ // tilde
+ 'Ẫ' => 'A', 'ẫ' => 'a',
+ 'Ẵ' => 'A', 'ẵ' => 'a',
+ 'Ẽ' => 'E', 'ẽ' => 'e',
+ 'Ễ' => 'E', 'ễ' => 'e',
+ 'Ỗ' => 'O', 'ỗ' => 'o',
+ 'Ỡ' => 'O', 'ỡ' => 'o',
+ 'Ữ' => 'U', 'ữ' => 'u',
+ 'Ỹ' => 'Y', 'ỹ' => 'y',
+ // acute accent
+ 'Ấ' => 'A', 'ấ' => 'a',
+ 'Ắ' => 'A', 'ắ' => 'a',
+ 'Ế' => 'E', 'ế' => 'e',
+ 'Ố' => 'O', 'ố' => 'o',
+ 'Ớ' => 'O', 'ớ' => 'o',
+ 'Ứ' => 'U', 'ứ' => 'u',
+ // dot below
+ 'Ạ' => 'A', 'ạ' => 'a',
+ 'Ậ' => 'A', 'ậ' => 'a',
+ 'Ặ' => 'A', 'ặ' => 'a',
+ 'Ẹ' => 'E', 'ẹ' => 'e',
+ 'Ệ' => 'E', 'ệ' => 'e',
+ 'Ị' => 'I', 'ị' => 'i',
+ 'Ọ' => 'O', 'ọ' => 'o',
+ 'Ộ' => 'O', 'ộ' => 'o',
+ 'Ợ' => 'O', 'ợ' => 'o',
+ 'Ụ' => 'U', 'ụ' => 'u',
+ 'Ự' => 'U', 'ự' => 'u',
+ 'Ỵ' => 'Y', 'ỵ' => 'y',
+ // Vowels with diacritic (Chinese, Hanyu Pinyin)
+ 'ɑ' => 'a',
+ // macron
+ 'Ǖ' => 'U', 'ǖ' => 'u',
+ // acute accent
+ 'Ǘ' => 'U', 'ǘ' => 'u',
+ // caron
+ 'Ǎ' => 'A', 'ǎ' => 'a',
+ 'Ǐ' => 'I', 'ǐ' => 'i',
+ 'Ǒ' => 'O', 'ǒ' => 'o',
+ 'Ǔ' => 'U', 'ǔ' => 'u',
+ 'Ǚ' => 'U', 'ǚ' => 'u',
+ // grave accent
+ 'Ǜ' => 'U', 'ǜ' => 'u',
+ );
+
+ $string = strtr($string, $chars);
+
+ return $string;
+ }
+}
diff --git a/bridges/BandcampBridge.php b/bridges/BandcampBridge.php
new file mode 100644
index 0000000..9c8d436
--- /dev/null
+++ b/bridges/BandcampBridge.php
@@ -0,0 +1,67 @@
+<?php
+class BandcampBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'sebsauvage';
+ const NAME = 'Bandcamp Tag';
+ const URI = 'https://bandcamp.com/';
+ const CACHE_TIMEOUT = 600; // 10min
+ const DESCRIPTION = 'New bandcamp release by tag';
+ const PARAMETERS = array( array(
+ 'tag' => array(
+ 'name' => 'tag',
+ 'type' => 'text',
+ 'required' => true
+ )
+ ));
+
+ public function getIcon() {
+ return 'https://s4.bcbits.com/img/bc_favicon.ico';
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('No results for this query.');
+
+ foreach($html->find('li.item') as $release) {
+ $script = $release->find('div.art', 0)->getAttribute('onclick');
+ $uri = ltrim($script, "return 'url(");
+ $uri = rtrim($uri, "')");
+
+ $item = array();
+ $item['author'] = $release->find('div.itemsubtext', 0)->plaintext
+ . ' - '
+ . $release->find('div.itemtext', 0)->plaintext;
+
+ $item['title'] = $release->find('div.itemsubtext', 0)->plaintext
+ . ' - '
+ . $release->find('div.itemtext', 0)->plaintext;
+
+ $item['content'] = '<img src="'
+ . $uri
+ . '"/><br/>'
+ . $release->find('div.itemsubtext', 0)->plaintext
+ . ' - '
+ . $release->find('div.itemtext', 0)->plaintext;
+
+ $item['id'] = $release->find('a', 0)->getAttribute('href');
+ $item['uri'] = $release->find('a', 0)->getAttribute('href');
+ $this->items[] = $item;
+ }
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('tag'))) {
+ return self::URI . 'tag/' . urlencode($this->getInput('tag')) . '?sort_field=date';
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('tag'))) {
+ return $this->getInput('tag') . ' - Bandcamp Tag';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/BastaBridge.php b/bridges/BastaBridge.php
new file mode 100644
index 0000000..17d3da7
--- /dev/null
+++ b/bridges/BastaBridge.php
@@ -0,0 +1,34 @@
+<?php
+class BastaBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'qwertygc';
+ const NAME = 'Bastamag Bridge';
+ const URI = 'http://www.bastamag.net/';
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'Returns the newest articles.';
+
+ public function collectData(){
+ // Replaces all relative image URLs by absolute URLs.
+ // Relative URLs always start with 'local/'!
+ function replaceImageUrl($content){
+ return preg_replace('/src=["\']{1}([^"\']+)/ims', 'src=\'' . self::URI . '$1\'', $content);
+ }
+
+ $html = getSimpleHTMLDOM(self::URI . 'spip.php?page=backend')
+ or returnServerError('Could not request Bastamag.');
+
+ $limit = 0;
+
+ foreach($html->find('item') as $element) {
+ if($limit < 10) {
+ $item = array();
+ $item['title'] = $element->find('title', 0)->innertext;
+ $item['uri'] = $element->find('guid', 0)->plaintext;
+ $item['timestamp'] = strtotime($element->find('dc:date', 0)->plaintext);
+ $item['content'] = replaceImageUrl(getSimpleHTMLDOM($item['uri'])->find('div.texte', 0)->innertext);
+ $this->items[] = $item;
+ $limit++;
+ }
+ }
+ }
+}
diff --git a/bridges/BlaguesDeMerdeBridge.php b/bridges/BlaguesDeMerdeBridge.php
new file mode 100644
index 0000000..cae8f4f
--- /dev/null
+++ b/bridges/BlaguesDeMerdeBridge.php
@@ -0,0 +1,46 @@
+<?php
+class BlaguesDeMerdeBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'superbaillot.net, logmanoriginal';
+ const NAME = 'Blagues De Merde';
+ const URI = 'http://www.blaguesdemerde.fr/';
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'Blagues De Merde';
+
+ public function getIcon() {
+ return self::URI . 'assets/img/favicon.ico';
+ }
+
+ public function collectData(){
+
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request BDM.');
+
+ foreach($html->find('div.blague') as $element) {
+
+ $item = array();
+
+ $item['uri'] = static::URI . '#' . $element->id;
+ $item['author'] = $element->find('div[class="blague-footer"] p strong', 0)->plaintext;
+
+ // Let the title be everything up to the first <br>
+ $item['title'] = trim(explode("\n", $element->find('div.text', 0)->plaintext)[0]);
+
+ $item['content'] = strip_tags($element->find('div.text', 0));
+
+ // timestamp is part of:
+ // <p>Par <strong>{author}</strong> le {date} dans <strong>{category}</strong></p>
+ preg_match(
+ '/.+le(.+)dans.*/',
+ $element->find('div[class="blague-footer"]', 0)->plaintext,
+ $matches
+ );
+
+ $item['timestamp'] = strtotime($matches[1]);
+
+ $this->items[] = $item;
+
+ }
+
+ }
+}
diff --git a/bridges/BloombergBridge.php b/bridges/BloombergBridge.php
new file mode 100644
index 0000000..9eb1219
--- /dev/null
+++ b/bridges/BloombergBridge.php
@@ -0,0 +1,69 @@
+<?php
+class BloombergBridge extends BridgeAbstract
+{
+ const NAME = 'Bloomberg';
+ const URI = 'https://www.bloomberg.com/';
+ const DESCRIPTION = 'Trending stories from Bloomberg';
+ const MAINTAINER = 'mdemoss';
+
+ const PARAMETERS = array(
+ 'Trending Stories' => array(),
+ 'From Search' => array(
+ 'q' => array(
+ 'name' => 'Keyword',
+ 'required' => true
+ )
+ )
+ );
+
+ public function getName()
+ {
+ switch($this->queriedContext) {
+ case 'Trending Stories':
+ return self::NAME . ' Trending Stories';
+ case 'From Search':
+ if (!is_null($this->getInput('q'))) {
+ return self::NAME . ' Search : ' . $this->getInput('q');
+ }
+ break;
+ }
+
+ return parent::getName();
+ }
+
+ public function getIcon() {
+ return 'https://assets.bwbx.io/s3/javelin/public/hub/images/favicon-black-63fe5249d3.png';
+ }
+
+ public function collectData()
+ {
+ switch($this->queriedContext) {
+ case 'Trending Stories': // Get list of top new <article>s from the front page.
+ $html = getSimpleHTMLDOMCached($this->getURI(), 300);
+ $stories = $html->find('ul.top-news-v3__stories article.top-news-v3-story');
+ break;
+ case 'From Search': // Get list of <article> elements from search.
+ $html = getSimpleHTMLDOMCached(
+ $this->getURI() .
+ 'search?sort=time:desc&page=1&query=' .
+ urlencode($this->getInput('q')), 300
+ );
+ $stories = $html->find('div.search-result-items article.search-result-story');
+ break;
+ }
+ foreach ($stories as $element) {
+ $item['uri'] = $element->find('h1 a', 0)->href;
+ if (preg_match('#^https://#i', $item['uri']) !== 1) {
+ $item['uri'] = $this->getURI() . $item['uri'];
+ }
+ $articleHtml = getSimpleHTMLDOMCached($item['uri']);
+ if (!$articleHtml) {
+ continue;
+ }
+ $item['title'] = $element->find('h1 a', 0)->plaintext;
+ $item['timestamp'] = strtotime($articleHtml->find('meta[name=iso-8601-publish-date],meta[name=date]', 0)->content);
+ $item['content'] = $articleHtml->find('meta[name=description]', 0)->content;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/BooruprojectBridge.php b/bridges/BooruprojectBridge.php
new file mode 100644
index 0000000..6815d37
--- /dev/null
+++ b/bridges/BooruprojectBridge.php
@@ -0,0 +1,45 @@
+<?php
+require_once('GelbooruBridge.php');
+
+class BooruprojectBridge extends GelbooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Booruproject';
+ const URI = 'http://booru.org/';
+ const DESCRIPTION = 'Returns images from given page of booruproject';
+ const PARAMETERS = array(
+ 'global' => array(
+ 'p' => array(
+ 'name' => 'page',
+ 'type' => 'number'
+ ),
+ 't' => array(
+ 'name' => 'tags'
+ )
+ ),
+ 'Booru subdomain (subdomain.booru.org)' => array(
+ 'i' => array(
+ 'name' => 'Subdomain',
+ 'required' => true
+ )
+ )
+ );
+
+ const PIDBYPAGE = 20;
+
+ public function getURI(){
+ if(!is_null($this->getInput('i'))) {
+ return 'http://' . $this->getInput('i') . '.booru.org/';
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('i'))) {
+ return static::NAME . ' ' . $this->getInput('i');
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/BundesbankBridge.php b/bridges/BundesbankBridge.php
new file mode 100644
index 0000000..d21f22b
--- /dev/null
+++ b/bridges/BundesbankBridge.php
@@ -0,0 +1,86 @@
+<?php
+class BundesbankBridge extends BridgeAbstract {
+
+ const PARAM_LANG = 'lang';
+
+ const LANG_EN = 'en';
+ const LANG_DE = 'de';
+
+ const NAME = 'Bundesbank Bridge';
+ const URI = 'https://www.bundesbank.de/';
+ const DESCRIPTION = 'Returns the latest studies of the Bundesbank (Germany)';
+ const MAINTAINER = 'logmanoriginal';
+ const CACHE_TIMEOUT = 86400; // 24 hours
+
+ const PARAMETERS = array(
+ array(
+ self::PARAM_LANG => array(
+ 'name' => 'Language',
+ 'type' => 'list',
+ 'required' => true,
+ 'defaultValue' => self::LANG_DE,
+ 'values' => array(
+ 'English' => self::LANG_EN,
+ 'Deutsch' => self::LANG_DE
+ )
+ )
+ )
+ );
+
+ public function getIcon() {
+ return self::URI . 'resource/crblob/1890/a7f48ee0ae35348748121770ba3ca009/mL/favicon-ico-data.ico';
+ }
+
+ public function getURI() {
+ switch($this->getInput(self::PARAM_LANG)) {
+ case self::LANG_EN: return self::URI . 'en/publications/reports/studies';
+ case self::LANG_DE: return self::URI . 'de/publikationen/berichte/studien';
+ }
+
+ return parent::getURI();
+ }
+
+ public function collectData() {
+
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('No response for ' . $this->getURI());
+
+ $html = defaultLinkTo($html, $this->getURI());
+
+ foreach($html->find('ul.resultlist li') as $study) {
+ $item = array();
+
+ $item['uri'] = $study->find('.teasable__link', 0)->href;
+
+ // Get title without child elements (i.e. subtitle)
+ $title = $study->find('.teasable__title div.h2', 0);
+
+ foreach($title->children as &$child) {
+ $child->outertext = '';
+ }
+
+ $item['title'] = $title->innertext;
+
+ // Add subtitle to the content if it exists
+ $item['content'] = '';
+
+ if($subtitle = $study->find('.teasable__subtitle', 0)) {
+ $item['content'] .= '<strong>' . $study->find('.teasable__subtitle', 0)->plaintext . '</strong>';
+ }
+
+ $item['content'] .= '<p>' . $study->find('.teasable__text', 0)->plaintext . '</p>';
+
+ $item['timestamp'] = strtotime($study->find('.teasable__date', 0)->plaintext);
+
+ // Downloads and older studies don't have images
+ if($study->find('.teasable__image', 0)) {
+ $item['enclosures'] = array(
+ $study->find('.teasable__image img', 0)->src
+ );
+ }
+
+ $this->items[] = $item;
+ }
+
+ }
+}
diff --git a/bridges/CNETBridge.php b/bridges/CNETBridge.php
new file mode 100644
index 0000000..564b817
--- /dev/null
+++ b/bridges/CNETBridge.php
@@ -0,0 +1,109 @@
+<?php
+class CNETBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'CNET News';
+ const URI = 'https://www.cnet.com/';
+ const CACHE_TIMEOUT = 3600; // 1h
+ const DESCRIPTION = 'Returns the newest articles.';
+ const PARAMETERS = array(
+ array(
+ 'topic' => array(
+ 'name' => 'Topic',
+ 'type' => 'list',
+ 'values' => array(
+ 'All articles' => '',
+ 'Apple' => 'apple',
+ 'Google' => 'google',
+ 'Microsoft' => 'tags-microsoft',
+ 'Computers' => 'topics-computers',
+ 'Mobile' => 'topics-mobile',
+ 'Sci-Tech' => 'topics-sci-tech',
+ 'Security' => 'topics-security',
+ 'Internet' => 'topics-internet',
+ 'Tech Industry' => 'topics-tech-industry'
+ )
+ )
+ )
+ );
+
+ private function cleanArticle($article_html) {
+ $offset_p = strpos($article_html, '<p>');
+ $offset_figure = strpos($article_html, '<figure');
+ $offset = ($offset_figure < $offset_p ? $offset_figure : $offset_p);
+ $article_html = substr($article_html, $offset);
+ $article_html = str_replace('href="/', 'href="' . self::URI, $article_html);
+ $article_html = str_replace(' height="0"', '', $article_html);
+ $article_html = str_replace('<noscript>', '', $article_html);
+ $article_html = str_replace('</noscript>', '', $article_html);
+ $article_html = StripWithDelimiters($article_html, '<a class="clickToEnlarge', '</a>');
+ $article_html = stripWithDelimiters($article_html, '<span class="nowPlaying', '</span>');
+ $article_html = stripWithDelimiters($article_html, '<span class="duration', '</span>');
+ $article_html = stripWithDelimiters($article_html, '<script', '</script>');
+ $article_html = stripWithDelimiters($article_html, '<svg', '</svg>');
+ return $article_html;
+ }
+
+ public function collectData() {
+
+ // Retrieve and check user input
+ $topic = str_replace('-', '/', $this->getInput('topic'));
+ if (!empty($topic) && (substr_count($topic, '/') > 1 || !ctype_alpha(str_replace('/', '', $topic))))
+ returnClientError('Invalid topic: ' . $topic);
+
+ // Retrieve webpage
+ $pageUrl = self::URI . (empty($topic) ? 'news/' : $topic . '/');
+ $html = getSimpleHTMLDOM($pageUrl)
+ or returnServerError('Could not request CNET: ' . $pageUrl);
+
+ // Process articles
+ foreach($html->find('div.assetBody, div.riverPost') as $element) {
+
+ if(count($this->items) >= 10) {
+ break;
+ }
+
+ $article_title = trim($element->find('h2, h3', 0)->plaintext);
+ $article_uri = self::URI . substr($element->find('a', 0)->href, 1);
+ $article_thumbnail = $element->parent()->find('img[src]', 0)->src;
+ $article_timestamp = strtotime($element->find('time.assetTime, div.timeAgo', 0)->plaintext);
+ $article_author = trim($element->find('a[rel=author], a.name', 0)->plaintext);
+ $article_content = '<p><b>' . trim($element->find('p.dek', 0)->plaintext) . '</b></p>';
+
+ if (is_null($article_thumbnail))
+ $article_thumbnail = extractFromDelimiters($element->innertext, '<img src="', '"');
+
+ if (!empty($article_title) && !empty($article_uri) && strpos($article_uri, self::URI . 'news/') !== false) {
+
+ $article_html = getSimpleHTMLDOMCached($article_uri) or $article_html = null;
+
+ if (!is_null($article_html)) {
+
+ if (empty($article_thumbnail))
+ $article_thumbnail = $article_html->find('div.originalImage', 0);
+ if (empty($article_thumbnail))
+ $article_thumbnail = $article_html->find('span.imageContainer', 0);
+ if (is_object($article_thumbnail))
+ $article_thumbnail = $article_thumbnail->find('img', 0)->src;
+
+ $article_content .= trim(
+ $this->cleanArticle(
+ extractFromDelimiters(
+ $article_html, '<article', '<footer'
+ )
+ )
+ );
+ }
+
+ $item = array();
+ $item['uri'] = $article_uri;
+ $item['title'] = $article_title;
+ $item['author'] = $article_author;
+ $item['timestamp'] = $article_timestamp;
+ $item['enclosures'] = array($article_thumbnail);
+ $item['content'] = $article_content;
+ $this->items[] = $item;
+ }
+ }
+ }
+}
diff --git a/bridges/CastorusBridge.php b/bridges/CastorusBridge.php
new file mode 100644
index 0000000..3ed1331
--- /dev/null
+++ b/bridges/CastorusBridge.php
@@ -0,0 +1,118 @@
+<?php
+class CastorusBridge extends BridgeAbstract {
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'Castorus Bridge';
+ const URI = 'http://www.castorus.com';
+ const CACHE_TIMEOUT = 600; // 10min
+ const DESCRIPTION = 'Returns the latest changes';
+
+ const PARAMETERS = array(
+ 'Get latest changes' => array(),
+ 'Get latest changes via ZIP code' => array(
+ 'zip' => array(
+ 'name' => 'ZIP code',
+ 'type' => 'text',
+ 'required' => true,
+ 'exampleValue' => '74910, 74',
+ 'title' => 'Insert ZIP code (complete or partial)'
+ )
+ ),
+ 'Get latest changes via city name' => array(
+ 'city' => array(
+ 'name' => 'City name',
+ 'type' => 'text',
+ 'required' => true,
+ 'exampleValue' => 'Seyssel, Seys',
+ 'title' => 'Insert city name (complete or partial)'
+ )
+ )
+ );
+
+ // Extracts the title from an actitiy
+ private function extractActivityTitle($activity){
+ $title = $activity->find('a', 0);
+
+ if(!$title)
+ returnServerError('Cannot find title!');
+
+ return htmlspecialchars(trim($title->plaintext));
+ }
+
+ // Extracts the url from an actitiy
+ private function extractActivityUrl($activity){
+ $url = $activity->find('a', 0);
+
+ if(!$url)
+ returnServerError('Cannot find url!');
+
+ return self::URI . $url->href;
+ }
+
+ // Extracts the time from an activity
+ private function extractActivityTime($activity){
+ // Unfortunately the time is part of the parent node,
+ // so we have to clear all child nodes first
+ $nodes = $activity->find('*');
+
+ if(!$nodes)
+ returnServerError('Cannot find nodes!');
+
+ foreach($nodes as $node) {
+ $node->outertext = '';
+ }
+
+ return strtotime($activity->innertext);
+ }
+
+ // Extracts the price change
+ private function extractActivityPrice($activity){
+ $price = $activity->find('span', 1);
+
+ if(!$price)
+ returnServerError('Cannot find price!');
+
+ return $price->innertext;
+ }
+
+ public function collectData(){
+ $zip_filter = trim($this->getInput('zip'));
+ $city_filter = trim($this->getInput('city'));
+
+ $html = getSimpleHTMLDOM(self::URI);
+
+ if(!$html)
+ returnServerError('Could not load data from ' . self::URI . '!');
+
+ $activities = $html->find('div#activite/li');
+
+ if(!$activities)
+ returnServerError('Failed to find activities!');
+
+ foreach($activities as $activity) {
+ $item = array();
+
+ $item['title'] = $this->extractActivityTitle($activity);
+ $item['uri'] = $this->extractActivityUrl($activity);
+ $item['timestamp'] = $this->extractActivityTime($activity);
+ $item['content'] = '<a href="'
+ . $item['uri']
+ . '">'
+ . $item['title']
+ . '</a><br><p>'
+ . $this->extractActivityPrice($activity)
+ . '</p>';
+
+ if(isset($zip_filter)
+ && !(substr($item['title'], 0, strlen($zip_filter)) === $zip_filter)) {
+ continue; // Skip this item
+ }
+
+ if(isset($city_filter)
+ && !(substr($item['title'], strpos($item['title'], ' ') + 1, strlen($city_filter)) === $city_filter)) {
+ continue; // Skip this item
+ }
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/ChristianDailyReporterBridge.php b/bridges/ChristianDailyReporterBridge.php
new file mode 100644
index 0000000..85f664d
--- /dev/null
+++ b/bridges/ChristianDailyReporterBridge.php
@@ -0,0 +1,28 @@
+<?php
+class ChristianDailyReporterBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'rogerdc';
+ const NAME = 'Christian Daily Reporter Unofficial RSS';
+ const URI = 'https://www.christiandailyreporter.com/';
+ const DESCRIPTION = 'The Unofficial Christian Daily Reporter RSS';
+ // const CACHE_TIMEOUT = 86400; // 1 day
+
+ public function getIcon() {
+ return self::URI . 'images/cdrfavicon.png';
+ }
+
+ public function collectData() {
+ $uri = 'https://www.christiandailyreporter.com/';
+
+ $html = getSimpleHTMLDOM($uri)
+ or returnServerError('Could not request Christian Daily Reporter.');
+ foreach($html->find('div.top p a,div.column p a') as $element) {
+ $item = array();
+ // Title
+ $item['title'] = $element->innertext;
+ // URL
+ $item['uri'] = $element->href;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/CollegeDeFranceBridge.php b/bridges/CollegeDeFranceBridge.php
new file mode 100644
index 0000000..1f81683
--- /dev/null
+++ b/bridges/CollegeDeFranceBridge.php
@@ -0,0 +1,84 @@
+<?php
+class CollegeDeFranceBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'pit-fgfjiudghdf';
+ const NAME = 'CollegeDeFrance';
+ const URI = 'http://www.college-de-france.fr/';
+ const CACHE_TIMEOUT = 10800; // 3h
+ const DESCRIPTION = 'Returns the latest audio and video from CollegeDeFrance';
+
+ public function collectData(){
+ $months = array(
+ '01' => 'janv.',
+ '02' => 'févr.',
+ '03' => 'mars',
+ '04' => 'avr.',
+ '05' => 'mai',
+ '06' => 'juin',
+ '07' => 'juil.',
+ '08' => 'août',
+ '09' => 'sept.',
+ '10' => 'oct.',
+ '11' => 'nov.',
+ '12' => 'déc.'
+ );
+
+ // The "API" used by the site returns a list of partial HTML in this form
+ /* <li>
+ * <a href="/site/thomas-romer/guestlecturer-2016-04-15-14h30.htm" data-target="after">
+ * <span class="date"><span class="list-icon list-icon-video"></span>
+ * <span class="list-icon list-icon-audio"></span>15 avr. 2016</span>
+ * <span class="lecturer">Christopher Hays</span>
+ * <span class='title'>Imagery of Divine Suckling in the Hebrew Bible and the Ancient Near East</span>
+ * </a>
+ * </li>
+ */
+ $html = getSimpleHTMLDOM(self::URI
+ . 'components/search-audiovideo.jsp?fulltext=&siteid=1156951719600&lang=FR&type=all')
+ or returnServerError('Could not request CollegeDeFrance.');
+
+ foreach($html->find('a[data-target]') as $element) {
+ $item = array();
+ $item['title'] = $element->find('.title', 0)->plaintext;
+
+ // Most relative URLs contains an hour in addition to the date, so let's use it
+ // <a href="/site/yann-lecun/course-2016-04-08-11h00.htm" data-target="after">
+ //
+ // Sometimes there's an __1, perhaps it signifies an update
+ // "/site/patrick-boucheron/seminar-2016-05-03-18h00__1.htm"
+ //
+ // But unfortunately some don't have any hours info
+ // <a href="/site/institut-physique/
+ // The-Mysteries-of-Decoherence-Sebastien-Gleyzes-[Video-3-35].htm" data-target="after">
+ $timezone = new DateTimeZone('Europe/Paris');
+
+ // strpos($element->href, '201') will break in 2020 but it'll
+ // probably break prior to then due to site changes anyway
+ $d = DateTime::createFromFormat(
+ '!Y-m-d-H\hi',
+ substr($element->href, strpos($element->href, '201'), 16),
+ $timezone
+ );
+
+ if(!$d) {
+ $d = DateTime::createFromFormat(
+ '!d m Y',
+ trim(str_replace(
+ array_values($months),
+ array_keys($months),
+ $element->find('.date', 0)->plaintext
+ )),
+ $timezone
+ );
+ }
+
+ $item['timestamp'] = $d->format('U');
+ $item['content'] = $element->find('.lecturer', 0)->innertext
+ . ' - '
+ . $element->find('.title', 0)->innertext;
+
+ $item['uri'] = self::URI . $element->href;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/CommonDreamsBridge.php b/bridges/CommonDreamsBridge.php
new file mode 100644
index 0000000..22b9238
--- /dev/null
+++ b/bridges/CommonDreamsBridge.php
@@ -0,0 +1,26 @@
+<?php
+class CommonDreamsBridge extends FeedExpander {
+
+ const MAINTAINER = 'nyutag';
+ const NAME = 'CommonDreams Bridge';
+ const URI = 'https://www.commondreams.org/';
+ const DESCRIPTION = 'Returns the newest articles.';
+
+ public function collectData(){
+ $this->collectExpandableDatas('http://www.commondreams.org/rss.xml', 10);
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+ $item['content'] = $this->extractContent($item['uri']);
+ return $item;
+ }
+
+ private function extractContent($url){
+ $html3 = getSimpleHTMLDOMCached($url);
+ $text = $html3->find('div[class=field--type-text-with-summary]', 0)->innertext;
+ $html3->clear();
+ unset ($html3);
+ return $text;
+ }
+}
diff --git a/bridges/ContainerLinuxReleasesBridge.php b/bridges/ContainerLinuxReleasesBridge.php
new file mode 100644
index 0000000..ae43888
--- /dev/null
+++ b/bridges/ContainerLinuxReleasesBridge.php
@@ -0,0 +1,97 @@
+<?php
+class ContainerLinuxReleasesBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'captn3m0';
+ const NAME = 'Core OS Container Linux Releases Bridge';
+ const URI = 'https://coreos.com/releases/';
+ const DESCRIPTION = 'Returns the releases notes for Container Linux';
+
+ const STABLE = 'stable';
+ const BETA = 'beta';
+ const ALPHA = 'alpha';
+
+ const PARAMETERS = [
+ [
+ 'channel' => [
+ 'name' => 'Release Channel',
+ 'type' => 'list',
+ 'required' => true,
+ 'defaultValue' => self::STABLE,
+ 'values' => [
+ 'Stable' => self::STABLE,
+ 'Beta' => self::BETA,
+ 'Alpha' => self::ALPHA,
+ ],
+ ]
+ ]
+ ];
+
+ private function getReleaseFeed($jsonUrl) {
+ $json = getContents($jsonUrl)
+ or returnServerError('Could not request Core OS Website.');
+ return json_decode($json, true);
+ }
+
+ public function getIcon() {
+ return 'https://coreos.com/assets/ico/favicon.png';
+ }
+
+ public function collectData() {
+ $data = $this->getReleaseFeed($this->getJsonUri());
+
+ foreach ($data as $releaseVersion => $release) {
+ $item = [];
+
+ $item['uri'] = "https://coreos.com/releases/#$releaseVersion";
+ $item['title'] = $releaseVersion;
+
+ $content = $release['release_notes'];
+ $content .= <<<EOT
+
+Major Software:
+* Kernel: {$release['major_software']['kernel'][0]}
+* Docker: {$release['major_software']['docker'][0]}
+* etcd: {$release['major_software']['etcd'][0]}
+EOT;
+ $item['timestamp'] = strtotime($release['release_date']);
+
+ // Based on https://gist.github.com/jbroadway/2836900
+ // Links
+ $regex = '/\[([^\[]+)\]\(([^\)]+)\)/';
+ $replacement = '<a href=\'\2\'>\1</a>';
+ $item['content'] = preg_replace($regex, $replacement, $content);
+
+ // Headings
+ $regex = '/^(.*)\:\s?$/m';
+ $replacement = '<h3>\1</h3>';
+ $item['content'] = preg_replace($regex, $replacement, $item['content']);
+
+ // Lists
+ $regex = '/\n\s*[\*|\-](.*)/';
+ $item['content'] = preg_replace_callback ($regex, function($regs) {
+ $item = $regs[1];
+ return sprintf ('<ul><li>%s</li></ul>', trim ($item));
+ }, $item['content']);
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function getJsonUri() {
+ $channel = $this->getInput('channel');
+
+ return "https://coreos.com/releases/releases-$channel.json";
+ }
+
+ public function getURI() {
+ return self::URI;
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('channel'))) {
+ return 'Container Linux Releases: ' . $this->getInput('channel') . ' Channel';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/CopieDoubleBridge.php b/bridges/CopieDoubleBridge.php
new file mode 100644
index 0000000..3545c6f
--- /dev/null
+++ b/bridges/CopieDoubleBridge.php
@@ -0,0 +1,35 @@
+<?php
+class CopieDoubleBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'superbaillot.net';
+ const NAME = 'CopieDouble';
+ const URI = 'http://www.copie-double.com/';
+ const CACHE_TIMEOUT = 14400; // 4h
+ const DESCRIPTION = 'CopieDouble';
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request CopieDouble.');
+
+ $table = $html->find('table table', 2);
+
+ foreach($table->find('tr') as $element) {
+ $td = $element->find('td', 0);
+
+ if($td->class === 'couleur_1') {
+ $item = array();
+ $title = $td->innertext;
+ $pos = strpos($title, '<a');
+ $title = substr($title, 0, $pos);
+ $item['title'] = $title;
+ } elseif(strpos($element->innertext, '/images/suivant.gif') === false) {
+ $a = $element->find('a', 0);
+ $item['uri'] = self::URI . $a->href;
+ $content = str_replace('src="/', 'src="/' . self::URI, $element->find('td', 0)->innertext);
+ $content = str_replace('href="/', 'href="' . self::URI, $content);
+ $item['content'] = $content;
+ $this->items[] = $item;
+ }
+ }
+ }
+}
diff --git a/bridges/CourrierInternationalBridge.php b/bridges/CourrierInternationalBridge.php
new file mode 100644
index 0000000..1e7c93e
--- /dev/null
+++ b/bridges/CourrierInternationalBridge.php
@@ -0,0 +1,55 @@
+<?php
+class CourrierInternationalBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene';
+ const NAME = 'Courrier International Bridge';
+ const URI = 'http://CourrierInternational.com/';
+ const CACHE_TIMEOUT = 300; // 5 min
+ const DESCRIPTION = 'Courrier International bridge';
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Error.');
+
+ $element = $html->find('article');
+ $article_count = 1;
+
+ foreach($element as $article) {
+ $item = array();
+
+ $item['uri'] = $article->parent->getAttribute('href');
+
+ if(strpos($item['uri'], 'http') === false) {
+ $item['uri'] = self::URI . $item['uri'];
+ }
+
+ $page = getSimpleHTMLDOMCached($item['uri']);
+
+ $content = $page->find('.article-text', 0);
+
+ if(!$content) {
+ $content = $page->find('.depeche-text', 0);
+ }
+
+ $item['content'] = sanitize($content);
+ $item['title'] = strip_tags($article->find('.title', 0));
+
+ $dateTime = date_parse($page->find('time', 0));
+
+ $item['timestamp'] = mktime(
+ $dateTime['hour'],
+ $dateTime['minute'],
+ $dateTime['second'],
+ $dateTime['month'],
+ $dateTime['day'],
+ $dateTime['year']
+ );
+
+ $this->items[] = $item;
+ $article_count ++;
+
+ if($article_count > 5)
+ break;
+ }
+ }
+}
diff --git a/bridges/CrewbayBridge.php b/bridges/CrewbayBridge.php
new file mode 100644
index 0000000..a3c52b9
--- /dev/null
+++ b/bridges/CrewbayBridge.php
@@ -0,0 +1,227 @@
+<?php
+class CrewbayBridge extends BridgeAbstract {
+ const MAINTAINER = 'couraudt';
+ const NAME = 'Crewbay Bridge';
+ const URI = 'https://www.crewbay.com';
+ const DESCRIPTION = 'Returns the newest sailing offers.';
+ const PARAMETERS = array(
+ array(
+ 'keyword' => array(
+ 'name' => 'Filter by keyword',
+ 'title' => 'Enter the keyword to filter here'
+ ),
+ 'type' => array(
+ 'name' => 'Type of search',
+ 'title' => 'Choose between finding a boat or a crew',
+ 'type' => 'list',
+ 'values' => array(
+ 'Find a boat' => 'boats',
+ 'Find a crew' => 'crew'
+ )
+ ),
+ 'status' => array(
+ 'name' => 'Status on the boat',
+ 'title' => 'Choose between recreational or professional classified ads',
+ 'type' => 'list',
+ 'values' => array(
+ 'Recreational' => 'recreational',
+ 'Professional' => 'professional'
+ )
+ ),
+ 'recreational_position' => array(
+ 'name' => 'Recreational position wanted',
+ 'title' => 'Filter by recreational position you wanted aboard',
+ 'required' => false,
+ 'type' => 'list',
+ 'values' => array(
+ '' => '',
+ 'Amateur Crew' => 'Amateur Crew',
+ 'Friendship' => 'Friendship',
+ 'Competent Crew' => 'Competent Crew',
+ 'Racing' => 'Racing',
+ 'Voluntary work' => 'Voluntary work',
+ 'Mile building' => 'Mile building'
+ )
+ ),
+ 'professional_position' => array(
+ 'name' => 'Professional position wanted',
+ 'title' => 'Filter by professional position you wanted aboard',
+ 'required' => false,
+ 'type' => 'list',
+ 'values' => array(
+ '' => '',
+ '1st Engineer' => '1st Engineer',
+ '1st Mate' => '1st Mate',
+ 'Beautician' => 'Beautician',
+ 'Bosun' => 'Bosun',
+ 'Captain' => 'Captain',
+ 'Chef' => 'Chef',
+ 'Steward(ess)' => 'Steward(ess)',
+ 'Deckhand' => 'Deckhand',
+ 'Delivery Crew' => 'Delivery Crew',
+ 'Dive Instructor' => 'Dive Instructor',
+ 'Masseur' => 'Masseur',
+ 'Medical Staff' => 'Medical Staff',
+ 'Nanny' => 'Nanny',
+ 'Navigator' => 'Navigator',
+ 'Racing Crew' => 'Racing Crew',
+ 'Teacher' => 'Teacher',
+ 'Electrical Engineer' => 'Electrical Engineer',
+ 'Fitter' => 'Fitter',
+ '2nd Engineer' => '2nd Engineer',
+ '3rd Engineer' => '3rd Engineer',
+ 'Lead Deckhand' => 'Lead Deckhand',
+ 'Security Officer' => 'Security Officer',
+ 'O.O.W' => 'O.O.W',
+ '1st Officer' => '1st Officer',
+ '2nd Officer' => '2nd Officer',
+ '3rd Officer' => '3rd Officer',
+ 'Captain/Engineer' => 'Captain/Engineer',
+ 'Hairdresser' => 'Hairdresser',
+ 'Fitness Trainer' => 'Fitness Trainer',
+ 'Laundry' => 'Laundry',
+ 'Solo Steward/ess' => 'Solo Steward/ess',
+ 'Stew/Deck' => 'Stew/Deck',
+ '2nd Steward/ess' => '2nd Steward/ess',
+ '3rd Steward/ess' => '3rd Steward/ess',
+ 'Chief Steward/ess' => 'Chief Steward/ess',
+ 'Head Housekeeper' => 'Head Housekeeper',
+ 'Purser' => 'Purser',
+ 'Cook' => 'Cook',
+ 'Cook/Stew' => 'Cook/Stew',
+ '2nd Chef' => '2nd Chef',
+ 'Head Chef' => 'Head Chef',
+ 'Administrator' => 'Administrator',
+ 'P.A' => 'P.A',
+ 'Villa staff' => 'Villa staff',
+ 'Housekeeping/Stew' => 'Housekeeping/Stew',
+ 'Stew/Beautician' => 'Stew/Beautician',
+ 'Stew/Masseuse' => 'Stew/Masseuse',
+ 'Manager' => 'Manager',
+ 'Sailing instructor' => 'Sailing instructor'
+ )
+ )
+ )
+ );
+
+ public function collectData() {
+ $url = $this->getURI();
+ $html = getSimpleHTMLDOM($url) or returnClientError('No results for this query.');
+
+ $annonces = $html->find('#SearchResults div.result');
+ $limit = 0;
+
+ foreach ($annonces as $annonce) {
+ $detail = $annonce->find('.btn--profile', 0);
+ $htmlDetail = getSimpleHTMLDOMCached($detail->href);
+
+ if (!empty($this->getInput('recreational_position')) || !empty($this->getInput('professional_position'))) {
+ if ($this->getInput('type') == 'boats') {
+ if ($this->getInput('status') == 'professional') {
+ $positions = array($annonce->find('.title .position', 0)->plaintext);
+ } else {
+ $positions = array(str_replace('Wanted:', '', $annonce->find('.content li', 0)->plaintext));
+ }
+ } else {
+ $list = $htmlDetail->find('.viewer-details .viewer-list');
+ $positions = explode("\r\n", end($list)->find('span.value', 0)->plaintext);
+ }
+
+ $found = false;
+ $keyword = $this->getInput('status') == 'professional' ? 'professional_position' : 'recreational_position';
+ foreach ($positions as $position) {
+ if (strpos(trim($position), $this->getInput($keyword)) !== false) {
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found) {
+ continue;
+ }
+ }
+
+ $item = array();
+
+ if ($this->getInput('type') == 'boats') {
+ $titleSelector = '.title h2';
+ } else {
+ $titleSelector = '.layout__item h2';
+ }
+ $userName = $annonce->find('.result--description a', 0)->plaintext;
+ $annonceTitle = trim($annonce->find($titleSelector, 0)->plaintext);
+ if (empty($annonceTitle)) {
+ $item['title'] = $userName;
+ } else {
+ $item['title'] = $userName . ' - ' . $annonceTitle;
+ }
+
+ $item['uri'] = $detail->href;
+ $images = $annonce->find('.avatar img');
+ $item['enclosures'] = array(end($images)->getAttribute('src'));
+
+ $content = $htmlDetail->find('.viewer-intro--info', 0)->innertext;
+
+ $sections = $htmlDetail->find('.viewer-container .viewer-section');
+ foreach ($sections as $section) {
+ if ($section->find('.viewer-section-title', 0)) {
+ $class = str_replace('viewer-', '', explode(' ', $section->getAttribute('class'))[0]);
+ if (!in_array($class, array('apply', 'photos', 'reviews', 'contact', 'experience', 'qa'))) {
+ // Basic sections
+ $content .= $section->find('.viewer-section-title h3', 0)->outertext;
+ $content .= $section->find('.viewer-section-content', 0)->innertext;
+ }
+ } else {
+ // Info section
+ $content .= $section->find('.viewer-section-content h3', 0)->outertext;
+ $content .= $section->find('.viewer-section-content p', 0)->outertext;
+ }
+ }
+
+ if (!empty($this->getInput('keyword'))) {
+ $keyword = strtolower($this->getInput('keyword'));
+ if (strpos(strtolower($item['title']), $keyword) === false) {
+ if (strpos(strtolower($content), $keyword) === false) {
+ continue;
+ }
+ }
+ }
+
+ $item['content'] = $content;
+
+ $tags = $htmlDetail->find('li.viewer-tags--tag');
+ foreach ($tags as $tag) {
+ if (!isset($item['categories'])) {
+ $item['categories'] = array();
+ }
+ $text = trim($tag->plaintext);
+ if (!in_array($text, $item['categories'])) {
+ $item['categories'][] = $text;
+ }
+ }
+
+ $this->items[] = $item;
+ $limit += 1;
+
+ if ($limit == 10) break;
+ }
+ }
+
+ public function getURI() {
+ $uri = parent::getURI();
+
+ if ($this->getInput('type') == 'boats') {
+ $uri .= '/boats';
+ } else {
+ $uri .= '/crew';
+ }
+
+ if ($this->getInput('status') == 'professional') {
+ $uri .= '/professional';
+ } else {
+ $uri .= '/recreational';
+ }
+
+ return $uri;
+ }
+}
diff --git a/bridges/CryptomeBridge.php b/bridges/CryptomeBridge.php
new file mode 100644
index 0000000..8a3936f
--- /dev/null
+++ b/bridges/CryptomeBridge.php
@@ -0,0 +1,45 @@
+<?php
+class CryptomeBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'BoboTiG';
+ const NAME = 'Cryptome';
+ const URI = 'https://cryptome.org/';
+ const CACHE_TIMEOUT = 21600; //6h
+ const DESCRIPTION = 'Returns the N most recent documents.';
+
+ const PARAMETERS = array( array(
+ 'n' => array(
+ 'name' => 'number of elements',
+ 'type' => 'number',
+ 'defaultValue' => 20,
+ 'exampleValue' => 10
+ )
+ ));
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request Cryptome.');
+
+ $number = $this->getInput('n');
+
+ /* number of documents */
+ if(!empty($number)) {
+ $num = min($number, 20);
+ }
+
+ foreach($html->find('pre') as $element) {
+ for($i = 0; $i < $num; ++$i) {
+ $item = array();
+ $item['uri'] = self::URI . substr($element->find('a', $i)->href, 20);
+ $item['title'] = substr($element->find('b', $i)->plaintext, 22);
+ $item['content'] = preg_replace(
+ '#http://cryptome.org/#',
+ self::URI,
+ $element->find('b', $i)->innertext
+ );
+ $this->items[] = $item;
+ }
+ break;
+ }
+ }
+}
diff --git a/bridges/DailymotionBridge.php b/bridges/DailymotionBridge.php
new file mode 100644
index 0000000..ff8d482
--- /dev/null
+++ b/bridges/DailymotionBridge.php
@@ -0,0 +1,127 @@
+<?php
+class DailymotionBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Dailymotion Bridge';
+ const URI = 'https://www.dailymotion.com/';
+ const CACHE_TIMEOUT = 10800; // 3h
+ const DESCRIPTION = 'Returns the 5 newest videos by username/playlist or search';
+
+ const PARAMETERS = array (
+ 'By username' => array(
+ 'u' => array(
+ 'name' => 'username',
+ 'required' => true
+ )
+ ),
+ 'By playlist id' => array(
+ 'p' => array(
+ 'name' => 'playlist id',
+ 'required' => true
+ )
+ ),
+ 'From search results' => array(
+ 's' => array(
+ 'name' => 'Search keyword',
+ 'required' => true
+ ),
+ 'pa' => array(
+ 'name' => 'Page',
+ 'type' => 'number'
+ )
+ )
+ );
+
+ protected function getMetadata($id){
+ $metadata = array();
+ $html2 = getSimpleHTMLDOM(self::URI . 'video/' . $id);
+ if(!$html2) {
+ return $metadata;
+ }
+
+ $metadata['title'] = $html2->find('meta[property=og:title]', 0)->getAttribute('content');
+ $metadata['timestamp'] = strtotime(
+ $html2->find('meta[property=video:release_date]', 0)->getAttribute('content')
+ );
+ $metadata['thumbnailUri'] = $html2->find('meta[property=og:image]', 0)->getAttribute('content');
+ $metadata['uri'] = $html2->find('meta[property=og:url]', 0)->getAttribute('content');
+ return $metadata;
+ }
+
+ public function getIcon() {
+ return 'https://static1-ssl.dmcdn.net/images/neon/favicons/android-icon-36x36.png.vf806ca4ed0deed812';
+ }
+
+ public function collectData(){
+ $html = '';
+ $limit = 5;
+ $count = 0;
+
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request Dailymotion.');
+
+ foreach($html->find('div.media a.preview_link') as $element) {
+ if($count < $limit) {
+ $item = array();
+ $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;
+ $count++;
+ }
+ }
+ }
+
+ public function getName(){
+ switch($this->queriedContext) {
+ case 'By username':
+ $specific = $this->getInput('u');
+ break;
+ case 'By playlist id':
+ $specific = strtok($this->getInput('p'), '_');
+ break;
+ case 'From search results':
+ $specific = $this->getInput('s');
+ break;
+ default: return parent::getName();
+ }
+
+ return $specific . ' : Dailymotion Bridge';
+ }
+
+ public function getURI(){
+ $uri = self::URI;
+ switch($this->queriedContext) {
+ case 'By username':
+ $uri .= 'user/' . urlencode($this->getInput('u')) . '/1';
+ break;
+ case 'By playlist id':
+ $uri .= 'playlist/' . urlencode(strtok($this->getInput('p'), '_'));
+ break;
+ case 'From search results':
+ $uri .= 'search/' . urlencode($this->getInput('s'));
+ if($this->getInput('pa')) {
+ $uri .= '/' . $this->getInput('pa');
+ }
+ break;
+ default: return parent::getURI();
+ }
+ return $uri;
+ }
+}
diff --git a/bridges/DanbooruBridge.php b/bridges/DanbooruBridge.php
new file mode 100644
index 0000000..755399f
--- /dev/null
+++ b/bridges/DanbooruBridge.php
@@ -0,0 +1,136 @@
+<?php
+class DanbooruBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'mitsukarenai, logmanoriginal';
+ const NAME = 'Danbooru';
+ const URI = 'http://donmai.us/';
+ const CACHE_TIMEOUT = 1800; // 30min
+ const DESCRIPTION = 'Returns images from given page';
+
+ const PARAMETERS = array(
+ 'global' => array(
+ 'p' => array(
+ 'name' => 'page',
+ 'defaultValue' => 1,
+ 'type' => 'number'
+ ),
+ 't' => array(
+ 'name' => 'tags'
+ )
+ ),
+ 0 => array()
+ );
+
+ const PATHTODATA = 'article';
+ const IDATTRIBUTE = 'data-id';
+ const TAGATTRIBUTE = 'alt';
+
+ protected function getFullURI(){
+ return $this->getURI()
+ . 'posts?&page=' . $this->getInput('p')
+ . '&tags=' . urlencode($this->getInput('t'));
+ }
+
+ protected function getTags($element){
+ return $element->find('img', 0)->getAttribute(static::TAGATTRIBUTE);
+ }
+
+ protected function getItemFromElement($element){
+ // Fix links
+ defaultLinkTo($element, $this->getURI());
+
+ $item = array();
+ $item['uri'] = $element->find('a', 0)->href;
+ $item['postid'] = (int)preg_replace('/[^0-9]/', '', $element->getAttribute(static::IDATTRIBUTE));
+ $item['timestamp'] = time();
+ $thumbnailUri = $element->find('img', 0)->src;
+ $item['tags'] = $this->getTags($element);
+ $item['title'] = $this->getName() . ' | ' . $item['postid'];
+ $item['content'] = '<a href="'
+ . $item['uri']
+ . '"><img src="'
+ . $thumbnailUri
+ . '" /></a><br>Tags: '
+ . $item['tags'];
+
+ return $item;
+ }
+
+ public function collectData(){
+ $content = getContents($this->getFullURI())
+ or returnServerError('Could not request ' . $this->getName());
+
+ $html = Fix_Simple_Html_Dom::str_get_html($content);
+
+ foreach($html->find(static::PATHTODATA) as $element) {
+ $this->items[] = $this->getItemFromElement($element);
+ }
+ }
+}
+
+/**
+ * This class is a monkey patch to 'extend' simplehtmldom to recognize <source>
+ * tags (HTML5) as self closing tag. This patch should be removed once
+ * simplehtmldom was fixed. This seems to be a issue with more tags:
+ * https://sourceforge.net/p/simplehtmldom/bugs/83/
+ *
+ * The tag itself is valid according to Mozilla:
+ *
+ * The HTML <picture> element serves as a container for zero or more <source>
+ * elements and one <img> element to provide versions of an image for different
+ * display device scenarios. The browser will consider each of the child <source>
+ * elements and select one corresponding to the best match found; if no matches
+ * are found among the <source> elements, the file specified by the <img>
+ * element's src attribute is selected. The selected image is then presented in
+ * the space occupied by the <img> element.
+ *
+ * -- https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture
+ *
+ * Notice: This class uses parts of the original simplehtmldom, adjusted to pass
+ * the guidelines of RSS-Bridge (formatting)
+ */
+final class Fix_Simple_Html_Dom extends simple_html_dom {
+
+ /* copy from simple_html_dom, added 'source' at the end */
+ protected $self_closing_tags = array(
+ 'img' => 1,
+ 'br' => 1,
+ 'input' => 1,
+ 'meta' => 1,
+ 'link' => 1,
+ 'hr' => 1,
+ 'base' => 1,
+ 'embed' => 1,
+ 'spacer' => 1,
+ 'source' => 1
+ );
+
+ /* copy from simplehtmldom, changed 'simple_html_dom' to 'Fix_Simple_Html_Dom' */
+ public static function str_get_html($str,
+ $lowercase = true,
+ $forceTagsClosed = true,
+ $target_charset = DEFAULT_TARGET_CHARSET,
+ $stripRN = true,
+ $defaultBRText = DEFAULT_BR_TEXT,
+ $defaultSpanText = DEFAULT_SPAN_TEXT)
+ {
+ $dom = new Fix_Simple_Html_Dom(null,
+ $lowercase,
+ $forceTagsClosed,
+ $target_charset,
+ $stripRN,
+ $defaultBRText,
+ $defaultSpanText);
+
+ if (empty($str) || strlen($str) > MAX_FILE_SIZE) {
+
+ $dom->clear();
+ return false;
+
+ }
+
+ $dom->load($str, $lowercase, $stripRN);
+
+ return $dom;
+ }
+}
diff --git a/bridges/DansTonChatBridge.php b/bridges/DansTonChatBridge.php
new file mode 100644
index 0000000..0983bff
--- /dev/null
+++ b/bridges/DansTonChatBridge.php
@@ -0,0 +1,28 @@
+<?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(){
+
+ $html = getSimpleHTMLDOM(self::URI . 'latest.html')
+ or returnServerError('Could not request DansTonChat.');
+
+ foreach($html->find('div.item') as $element) {
+ $item = array();
+ $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;
+ }
+ }
+}
diff --git a/bridges/DauphineLibereBridge.php b/bridges/DauphineLibereBridge.php
new file mode 100644
index 0000000..20c8207
--- /dev/null
+++ b/bridges/DauphineLibereBridge.php
@@ -0,0 +1,57 @@
+<?php
+class DauphineLibereBridge extends FeedExpander {
+
+ const MAINTAINER = 'qwertygc';
+ const NAME = 'Dauphine Bridge';
+ const URI = 'https://www.ledauphine.com/';
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'Returns the newest articles.';
+
+ const PARAMETERS = array( array(
+ 'u' => array(
+ 'name' => 'Catégorie de l\'article',
+ 'type' => 'list',
+ 'values' => array(
+ 'À la une' => '',
+ 'France Monde' => 'france-monde',
+ 'Faits Divers' => 'faits-divers',
+ 'Économie et Finance' => 'economie-et-finance',
+ 'Politique' => 'politique',
+ 'Sport' => 'sport',
+ 'Ain' => 'ain',
+ 'Alpes-de-Haute-Provence' => 'haute-provence',
+ 'Hautes-Alpes' => 'hautes-alpes',
+ 'Ardèche' => 'ardeche',
+ 'Drôme' => 'drome',
+ 'Isère Sud' => 'isere-sud',
+ 'Savoie' => 'savoie',
+ 'Haute-Savoie' => 'haute-savoie',
+ 'Vaucluse' => 'vaucluse'
+ )
+ )
+ ));
+
+ public function collectData(){
+ $url = self::URI . 'rss';
+
+ if(empty($this->getInput('u'))) {
+ $url = self::URI . $this->getInput('u') . '/rss';
+ }
+
+ $this->collectExpandableDatas($url, 10);
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+ $item['content'] = $this->extractContent($item['uri']);
+ return $item;
+ }
+
+ private function extractContent($url){
+ $html2 = getSimpleHTMLDOMCached($url);
+ foreach ($html2->find('.noprint, link, script, iframe, .shareTool, .contentInfo') as $remove) {
+ $remove->outertext = '';
+ }
+ return $html2->find('div.content', 0)->innertext;
+ }
+}
diff --git a/bridges/DealabsBridge.php b/bridges/DealabsBridge.php
new file mode 100644
index 0000000..89183ed
--- /dev/null
+++ b/bridges/DealabsBridge.php
@@ -0,0 +1,1470 @@
+<?php
+class DealabsBridge extends PepperBridgeAbstract {
+
+ const NAME = 'Dealabs Bridge';
+ const URI = 'https://www.dealabs.com/';
+ const DESCRIPTION = 'Affiche les Deals de Dealabs';
+ const MAINTAINER = 'sysadminstory';
+ const PARAMETERS = array(
+ 'Recherche par Mot(s) clé(s)' => array (
+ 'q' => array(
+ 'name' => 'Mot(s) clé(s)',
+ 'type' => 'text',
+ 'required' => true
+ ),
+ 'hide_expired' => array(
+ 'name' => 'Masquer les éléments expirés',
+ 'type' => 'checkbox',
+ 'required' => true
+ ),
+ 'hide_local' => array(
+ 'name' => 'Masquer les deals locaux',
+ 'type' => 'checkbox',
+ 'title' => 'Masquer les deals en magasins physiques',
+ 'required' => true
+ ),
+ 'priceFrom' => array(
+ 'name' => 'Prix minimum',
+ 'type' => 'text',
+ 'title' => 'Prix mnimum en euros',
+ 'required' => false
+ ),
+ 'priceTo' => array(
+ 'name' => 'Prix maximum',
+ 'type' => 'text',
+ 'title' => 'Prix maximum en euros',
+ 'required' => false
+ ),
+ ),
+
+ 'Deals par groupe' => array(
+ 'group' => array(
+ 'name' => 'Groupe',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Groupe dont il faut afficher les deals',
+ 'values' => array(
+ 'Abonnements internet' => 'abonnements-internet',
+ 'Accessoires & gadgets' => 'accessoires-gadgets',
+ 'Accessoires photo' => 'accessoires-photo',
+ 'Accessoires vélo' => 'accessoires-velo',
+ 'Acer' => 'acer',
+ 'Adaptateurs' => 'adaptateurs',
+ 'Adhérents Fnac' => 'adherents-fnac',
+ 'adidas' => 'adidas',
+ 'adidas Stan Smith' => 'adidas-stan-smith',
+ 'adidas Superstar' => 'adidas-superstar',
+ 'adidas ZX Flux' => 'adidas-zx-flux',
+ 'Adoucissant' => 'adoucissant',
+ 'Agendas' => 'agendas',
+ 'Age of Empires' => 'age-of-empires',
+ 'Alarmes' => 'alarmes',
+ 'Alimentation & boissons' => 'alimentation-boissons',
+ 'Alimentation PC' => 'alimentation-pc',
+ 'Amazon Echo' => 'amazon-echo',
+ 'Amazon Fire TV' => 'amazon-fire-tv',
+ 'Amazon Kindle' => 'amazon-kindle',
+ 'Amazon Prime' => 'amazon-prime',
+ 'AMD Ryzen' => 'amd-ryzen',
+ 'AMD Vega' => 'amd-vega',
+ 'amiibo' => 'amiibo',
+ 'Amplis' => 'amplis',
+ 'Ampoules' => 'ampoules',
+ 'Animaux' => 'animaux',
+ 'Anker' => 'anker',
+ 'Antivirus' => 'antivirus',
+ 'Antivols' => 'antivols',
+ 'Appareils de musculation' => 'appareils-de-musculation',
+ 'Appareils photo' => 'appareils-photo',
+ 'Apple AirPods' => 'apple-airpods',
+ 'Apple' => 'apple',
+ 'Apple iPad' => 'apple-ipad',
+ 'Apple iPad Mini' => 'apple-ipad-mini',
+ 'Apple iPad Pro' => 'apple-ipad-pro',
+ 'Apple iPhone 6' => 'apple-iphone-6',
+ 'Apple iPhone 7' => 'apple-iphone-7',
+ 'Apple iPhone 8' => 'apple-iphone-8',
+ 'Apple iPhone 8 Plus' => 'apple-iphone-8-plus',
+ 'Apple iPhone' => 'apple-iphone',
+ 'Apple iPhone SE' => 'apple-iphone-se',
+ 'Apple iPhone X' => 'apple-iphone-x',
+ 'Apple MacBook Air' => 'apple-macbook-air',
+ 'Apple MacBook Pro' => 'apple-macbook-pro',
+ 'Apple TV' => 'apple-tv',
+ 'Apple Watch' => 'apple-watch',
+ 'Applications Android' => 'applications-android',
+ 'Applications' => 'applications',
+ 'Applications iOS' => 'applications-ios',
+ 'Applis & logiciels' => 'applis-logiciels',
+ 'Arbres à chat' => 'arbres-a-chat',
+ 'Asmodée' => 'asmodee',
+ 'Aspirateurs' => 'aspirateurs',
+ 'Aspirateurs Dyson' => 'aspirateurs-dyson',
+ 'Aspirateurs robot' => 'aspirateurs-robot',
+ 'Assassin&#039;s Creed' => 'assassin-s-creed',
+ 'Assassin&#039;s Creed Origins' => 'assassin-s-creed-origins',
+ 'Assurances' => 'assurances',
+ 'Asus' => 'asus',
+ 'ASUS Transformer' => 'asus-transformer',
+ 'Asus ZenFone 2' => 'asus-zenfone-2',
+ 'Asus ZenFone 3' => 'asus-zenfone-3',
+ 'Asus ZenFone 4' => 'asus-zenfone-4',
+ 'Asus ZenFone GO' => 'asus-zenfone-go',
+ 'Aukey' => 'aukey',
+ 'Auto' => 'auto',
+ 'Auto-Moto' => 'auto-moto',
+ 'Autoradios' => 'autoradios',
+ 'Baby foot' => 'baby-foot',
+ 'BabyLiss' => 'babyliss',
+ 'Babyphones' => 'babyphones',
+ 'Bagagerie' => 'bagagerie',
+ 'Balançoires' => 'balancoires',
+ 'Bandes dessinées' => 'bandes-dessinees',
+ 'Banques' => 'banques',
+ 'Barbecue' => 'barbecue',
+ 'Barbie' => 'barbie',
+ 'Barres de son' => 'barres-de-son',
+ 'Batteries externes' => 'batteries-externes',
+ 'Battlefield 1' => 'battlefield-1',
+ 'Battlefield' => 'battlefield',
+ 'Béaba' => 'beaba',
+ 'Beats by Dre' => 'beats-by-dre',
+ 'BenQ' => 'benq',
+ 'Be quiet!' => 'be-quiet',
+ 'Biberons' => 'biberons',
+ 'Bières' => 'bieres',
+ 'Bijoux' => 'bijoux',
+ 'Billets d&#039;avion' => 'billets-d-avion',
+ 'BioShock' => 'bioshock',
+ 'BioShock Infinite' => 'bioshock-infinite',
+ 'Bitdefender' => 'bitdefender',
+ 'Blackberry' => 'blackberry',
+ 'Black & Decker' => 'black-decker',
+ 'Blédina' => 'bledina',
+ 'Blu-Ray' => 'blu-ray',
+ 'Boissons' => 'boissons',
+ 'Boîtes à outils' => 'boites-a-outils',
+ 'Boîtiers PC' => 'boitiers-pc',
+ 'Bonbons' => 'bonbons',
+ 'Borderlands' => 'borderlands',
+ 'Bosch' => 'bosch',
+ 'Bose' => 'bose',
+ 'Bose SoundLink' => 'bose-soundlink',
+ 'Bottes' => 'bottes',
+ 'Box beauté' => 'box-beaute',
+ 'Bracelet fitness' => 'bracelet-fitness',
+ 'Brandt' => 'brandt',
+ 'Braun Silk Épil' => 'braun-silk-epil',
+ 'Bricolage' => 'bricolage',
+ 'Brosses à dents' => 'brosses-a-dents',
+ 'Cable management' => 'cable-management',
+ 'Câbles' => 'cables',
+ 'Câbles HDMI' => 'cables-hdmi',
+ 'Câbles USB' => 'cables-usb',
+ 'Cadres' => 'cadres',
+ 'Café' => 'cafe',
+ 'Café en grain' => 'cafe-en-grain',
+ 'Cafetières' => 'cafetieres',
+ 'Cahiers' => 'cahiers',
+ 'Call of Duty' => 'call-of-duty',
+ 'Call of Duty: Infinite Warfare' => 'call-of-duty-infinite-warfare',
+ 'Calor' => 'calor',
+ 'Caméras' => 'cameras',
+ 'Caméras IP' => 'cameras-ip',
+ 'Camping' => 'camping',
+ 'Carburant' => 'carburant',
+ 'Cartables' => 'cartables',
+ 'Cartes graphiques' => 'cartes-graphiques',
+ 'Cartes mères' => 'cartes-meres',
+ 'Cartes postales' => 'cartes-postales',
+ 'Casques audio' => 'casques-audio',
+ 'Casques sans fil' => 'casques-sans-fil',
+ 'Casquettes' => 'casquettes',
+ 'Casseroles' => 'casseroles',
+ 'CDAV' => 'cdav',
+ 'Ceintures' => 'ceintures',
+ 'Chaises' => 'chaises',
+ 'Chaises hautes' => 'chaises-hautes',
+ 'Chargeurs' => 'chargeurs',
+ 'Chasse' => 'chasse',
+ 'Chats' => 'chats',
+ 'Chaussons' => 'chaussons',
+ 'Chaussures adidas' => 'chaussures-adidas',
+ 'Chaussures' => 'chaussures',
+ 'Chaussures de football' => 'chaussures-de-football',
+ 'Chaussures de randonnée' => 'chaussures-de-randonnee',
+ 'Chaussures de running' => 'chaussures-de-running',
+ 'Chaussures de ski' => 'chaussures-de-ski',
+ 'Chaussures de ville' => 'chaussures-de-ville',
+ 'Chaussures Nike' => 'chaussures-nike',
+ 'Chelsea boots' => 'chelsea-boots',
+ 'Chemises' => 'chemises',
+ 'Chiens' => 'chiens',
+ 'Chocolat' => 'chocolat',
+ 'Chuck Taylor' => 'chuck-taylor',
+ 'Cinéma' => 'cinema',
+ 'Civilization' => 'civilization',
+ 'Civilization VI' => 'civilization-vi',
+ 'Clarks' => 'clarks',
+ 'Claviers' => 'claviers',
+ 'Claviers gamer' => 'claviers-gamer',
+ 'Claviers mécaniques' => 'claviers-mecaniques',
+ 'Clés USB' => 'cles-usb',
+ 'Composteurs' => 'composteurs',
+ 'Concerts' => 'concerts',
+ 'Congélateurs' => 'congelateurs',
+ 'Consoles' => 'consoles',
+ 'Consoles & jeux vidéo' => 'consoles-jeux-video',
+ 'Converse' => 'converse',
+ 'Costumes' => 'costumes',
+ 'Couches' => 'couches',
+ 'Couettes' => 'couettes',
+ 'Couteaux de cuisine' => 'couteaux-de-cuisine',
+ 'Couverts' => 'couverts',
+ 'Covoiturage' => 'covoiturage',
+ 'Crédits' => 'credits',
+ 'Croquettes pour chien' => 'croquettes-pour-chien',
+ 'Cuisinières' => 'cuisinieres',
+ 'Culture & divertissement' => 'culture-divertissement',
+ 'Cyclisme' => 'cyclisme',
+ 'DDR3' => 'ddr3',
+ 'DDR4' => 'ddr4',
+ 'Décoration' => 'decoration',
+ 'Deezer' => 'deezer',
+ 'Dell' => 'dell',
+ 'Delsey' => 'delsey',
+ 'Denon' => 'denon',
+ 'Dentifrices' => 'dentifrices',
+ 'Destiny 2' => 'destiny-2',
+ 'Destiny' => 'destiny',
+ 'Dishonored' => 'dishonored',
+ 'Disneyland Paris' => 'disneyland-paris',
+ 'Disques durs externes' => 'disques-durs-externes',
+ 'Disques durs internes' => 'disques-durs',
+ 'DJI' => 'dji',
+ 'Dosettes Nespresso' => 'dosettes-nespresso',
+ 'Dosettes Senseo' => 'dosettes-senseo',
+ 'Dosettes Tassimo' => 'dosettes-tassimo',
+ 'Draisiennes' => 'draisiennes',
+ 'Drones' => 'drones',
+ 'Durex' => 'durex',
+ 'DVD' => 'dvd',
+ 'Dyson' => 'dyson',
+ 'Eastpak' => 'eastpak',
+ 'ebooks' => 'ebooks',
+ 'Écharpes & foulards' => 'echarpes-et-foulards',
+ 'Écouteurs' => 'ecouteurs',
+ 'Écouteurs intra-auriculaires' => 'ecouteurs-intra-auriculaires',
+ 'Écouteurs sans fil' => 'ecouteurs-sans-fil',
+ 'Écouteurs sport' => 'ecouteurs-sport',
+ 'Écrans 21" et moins' => 'ecrans-21-pouces-et-moins',
+ 'Écrans 24"' => 'ecrans-24-pouces',
+ 'Écrans 27"' => 'ecrans-27-pouces',
+ 'Écrans 29" et plus' => 'ecrans-29-pouces-et-plus',
+ 'Écrans 4K / UHD' => 'ecrans-4k-uhd',
+ 'Écrans Acer' => 'ecrans-acer',
+ 'Écrans Asus' => 'ecrans-asus',
+ 'Écrans BenQ' => 'ecrans-benq',
+ 'Écrans Dell' => 'ecrans-dell',
+ 'Écrans de projection' => 'ecrans-de-projection',
+ 'Écrans' => 'ecrans',
+ 'Écrans FreeSync' => 'ecrans-freesync',
+ 'Écrans gamer' => 'ecrans-gamer',
+ 'Écrans incurvés' => 'ecrans-incurves',
+ 'Écrans Philips' => 'ecrans-philips',
+ 'Écrans Samsung' => 'ecrans-samsung',
+ 'Électricité (matériel)' => 'electricite',
+ 'Electrolux' => 'electrolux',
+ 'Électroménager' => 'electromenager',
+ 'Embauchoirs' => 'embauchoirs',
+ 'Enceintes Bluetooth' => 'enceintes-bluetooth',
+ 'Enceintes' => 'enceintes',
+ 'Engrais' => 'engrais',
+ 'Entretien du jardin' => 'entretien-du-jardin',
+ 'Épicerie' => 'epicerie',
+ 'Épilateurs à lumière pulsée' => 'epilateurs-a-lumiere-pulsee',
+ 'Épilateurs électriques' => 'epilateurs-electriques',
+ 'Épilation' => 'epilation',
+ 'Équipement auto' => 'equipement-auto',
+ 'Équipement motard' => 'equipement-motard',
+ 'Équipement sportif' => 'equipement-sportif',
+ 'Érotisme' => 'erotisme',
+ 'Escarpins' => 'escarpins',
+ 'Événements sportifs' => 'evenements-sportifs',
+ 'Expositions' => 'expositions',
+ 'F1 2017' => 'f1-2017',
+ 'Facom' => 'facom',
+ 'Fallout 4' => 'fallout-4',
+ 'Fallout' => 'fallout',
+ 'Fards à paupières' => 'fards-a-paupieres',
+ 'Fast-foods' => 'fast-foods',
+ 'Fauteuils' => 'fauteuils',
+ 'Fers à lisser / à friser' => 'fers-a-lisser-a-friser',
+ 'Fers à souder' => 'fers-a-souder',
+ 'Festivals' => 'festivals',
+ 'Feutres' => 'feutres',
+ 'FIFA 17' => 'fifa-17',
+ 'FIFA 18' => 'fifa-18',
+ 'FIFA 19' => 'fifa-19',
+ 'FIFA' => 'fifa',
+ 'Figurines' => 'figurines',
+ 'Films' => 'films',
+ 'Final Fantasy' => 'final-fantasy',
+ 'Final Fantasy XII' => 'final-fantasy-xii',
+ 'fitbit' => 'fitbit',
+ 'Flash' => 'flash',
+ 'Fluval' => 'fluval',
+ 'Foires & salons' => 'foires-et-salons',
+ 'Fonds de teint' => 'fonds-de-teint',
+ 'Football' => 'football',
+ 'Forfaits mobiles' => 'forfaits-mobiles',
+ 'For Honor' => 'for-honor',
+ 'Formule 1' => 'formule-1',
+ 'Fortnite' => 'fortnite',
+ 'Forza Horizon 3' => 'forza-horizon-3',
+ 'Forza Motorsport 7' => 'forza-motorsport-7',
+ 'Fossil' => 'fossil',
+ 'Fournitures de bureau' => 'fournitures-de-bureau',
+ 'Fournitures scolaires' => 'fournitures-scolaires',
+ 'Fours à poser' => 'fours-a-poser',
+ 'Fours encastrables' => 'fours-encastrables',
+ 'Fours' => 'fours',
+ 'Friandises pour chat' => 'friandises-pour-chat',
+ 'Friandises pour chien' => 'friandises-pour-chien',
+ 'Friskies' => 'friskies',
+ 'Fruits & légumes' => 'fruits-et-legumes',
+ 'FURminator' => 'furminator',
+ 'Futuroscope' => 'futuroscope',
+ 'Gamelles' => 'gamelles',
+ 'Game of Thrones' => 'game-of-thrones',
+ 'Gants' => 'gants',
+ 'Gants moto' => 'gants-moto',
+ 'Garmin' => 'garmin',
+ 'Gâteaux & biscuits' => 'gateaux-et-biscuits',
+ 'Gels douche' => 'gels-douche',
+ 'Geox' => 'geox',
+ 'Gigoteuses' => 'gigoteuses',
+ 'Gillette' => 'gillette',
+ 'Glaces' => 'glaces',
+ 'God of War' => 'god-of-war',
+ 'Google Chromecast' => 'google-chromecast',
+ 'Google Home' => 'google-home',
+ 'Google Pixel 2' => 'google-pixel-2',
+ 'Google Pixel 2 XL' => 'google-pixel-2-xl',
+ 'Google Pixel' => 'google-pixel',
+ 'Google Pixel XL' => 'google-pixel-xl',
+ 'GoPro Hero' => 'gopro-hero',
+ 'Gran Turismo' => 'gran-turismo',
+ 'Gratuit' => 'gratuit',
+ 'Grille-pain' => 'grille-pain',
+ 'GTA' => 'gta',
+ 'GTA V' => 'gta-v',
+ 'Guitares' => 'guitares',
+ 'Gyropodes' => 'gyropodes',
+ 'Haltères & poids' => 'halteres-et-poids',
+ 'Hamacs' => 'hamacs',
+ 'Hama' => 'hama',
+ 'Hand spinners' => 'hand-spinners',
+ 'Harnais pour chien' => 'harnais-pour-chien',
+ 'Harry Potter' => 'harry-potter',
+ 'Havaianas' => 'havaianas',
+ 'HDD' => 'hdd',
+ 'Hisense' => 'hisense',
+ 'Home Cinéma' => 'home-cinema',
+ 'Honor 6X' => 'honor-6x',
+ 'Honor 8' => 'honor-8',
+ 'Honor 8 Pro' => 'honor-8-pro',
+ 'Honor 9' => 'honor-9',
+ 'Horizon Zero Dawn' => 'horizon-zero-dawn',
+ 'Hôtels' => 'hotels',
+ 'Hoverboards' => 'hoverboards',
+ 'HTC 10' => 'htc-10',
+ 'HTC Desire' => 'htc-desire',
+ 'HTC One M9' => 'htc-one-m9',
+ 'HTC U11' => 'htc-u11',
+ 'HTC U Play' => 'htc-u-play',
+ 'HTC U Ultra' => 'htc-u-ultra',
+ 'HTC Vive' => 'htc-vive',
+ 'Huawei Mate 10' => 'huawei-mate-10',
+ 'Huawei Mate 9' => 'huawei-mate-9',
+ 'Huawei P10' => 'huawei-p10',
+ 'Huawei P10 Lite' => 'huawei-p10-lite',
+ 'Huawei P10 Plus' => 'huawei-p10-plus',
+ 'Huawei P20' => 'huawei-p20',
+ 'Huawei P20 Pro' => 'huawei-p20-pro',
+ 'Huawei P8 Lite' => 'huawei-p8-lite',
+ 'Huawei P9 Lite' => 'huawei-p9-lite',
+ 'Hubs' => 'hubs',
+ 'Huile moteur' => 'huile-moteur',
+ 'Hygiène corporelle' => 'hygiene-corporelle',
+ 'Hygiène de la maison' => 'hygiene-de-la-maison',
+ 'Hygiène des bébés' => 'hygiene-des-bebes',
+ 'Image, son & vidéo' => 'image-son-video',
+ 'Impressions photo' => 'impressions-photo',
+ 'Imprimantes 3D' => 'imprimantes-3d',
+ 'Imprimantes Brother' => 'imprimantes-brother',
+ 'Imprimantes Canon' => 'imprimantes-canon',
+ 'Imprimantes Epson' => 'imprimantes-epson',
+ 'Imprimantes HP' => 'imprimantes-hp',
+ 'Imprimantes' => 'imprimantes',
+ 'Imprimantes laser' => 'imprimantes-laser',
+ 'Imprimantes multifonctions' => 'imprimantes-multifonctions',
+ 'Informatique' => 'informatique',
+ 'Instruments de musique' => 'instruments-de-musique',
+ 'Intel i5' => 'intel-i5',
+ 'Intel i7' => 'intel-i7',
+ 'JBL Flip' => 'jbl-flip',
+ 'JBL' => 'jbl',
+ 'Jeans' => 'jeans',
+ 'Jeux d&#039;apprentissage' => 'jeux-d-apprentissage',
+ 'Jeux d&#039;extérieur' => 'jeux-d-exterieur',
+ 'Jeux d&#039;imitation' => 'jeux-d-imitation',
+ 'Jeux de construction' => 'jeux-de-construction',
+ 'Jeux de société' => 'jeux-de-societe',
+ 'Jeux & jouets' => 'jeux-jouets',
+ 'Jeux Nintendo Switch' => 'jeux-nintendo-switch',
+ 'Jeux & paris' => 'jeux-et-paris',
+ 'Jeux PC dématérialisés' => 'jeux-pc-dematerialises',
+ 'Jeux PlayStation 4' => 'jeux-playstation-4',
+ 'Jeux pour bébés' => 'jeux-pour-bebes',
+ 'Jeux PS4 dématérialisés' => 'jeux-ps4-dematerialises',
+ 'Jeux PS Plus' => 'jeux-ps-plus',
+ 'Jeux vidéo' => 'jeux-video',
+ 'Jeux Wii U' => 'jeux-wii-u',
+ 'Jeux Xbox dématérialisés' => 'jeux-xbox-dematerialises',
+ 'Jeux Xbox One' => 'jeux-xbox-one',
+ 'Jeux Xbox with Gold' => 'jeux-xbox-with-gold',
+ 'Journaux numériques' => 'journaux-numeriques',
+ 'Journaux papier' => 'journaux-papier',
+ 'Joy-Con' => 'manettes-nintendo-switch-joy-con',
+ 'Jungle Speed' => 'jungle-speed',
+ 'Kaspersky' => 'kaspersky',
+ 'Kinder' => 'kinder',
+ 'Kindle Paperwhite' => 'kindle-paperwhite',
+ 'Kindle Voyage' => 'kindle-voyage',
+ 'Kobo Aura 2' => 'kobo-aura-2',
+ 'Kobo Aura H2o' => 'kobo-aura-h2o',
+ 'Kobo' => 'kobo',
+ 'L&#039;annale du destin' => 'l-annale-du-destin',
+ 'L&#039;ombre de la guerre' => 'l-ombre-de-la-guerre',
+ 'L&#039;ombre du Mordor' => 'l-ombre-du-mordor',
+ 'Lacoste' => 'lacoste',
+ 'Lapeyre' => 'lapeyre',
+ 'La Terre du Milieu' => 'la-terre-du-milieu',
+ 'Lavage auto' => 'lavage-auto',
+ 'Lave-linge frontal' => 'lave-linge-frontal',
+ 'Lave-linge' => 'lave-linge',
+ 'Lave-linge séchant' => 'lave-linge-sechant',
+ 'Lave-linge top' => 'lave-linge-top',
+ 'Lave-vaisselle' => 'lave-vaisselle',
+ 'Le bâton de la vérité' => 'le-baton-de-la-verite',
+ 'Lecteurs Blu-Ray' => 'lecteurs-blu-ray',
+ 'Lecteurs CD' => 'lecteurs-cd',
+ 'Lecteurs DVD' => 'lecteurs-dvd',
+ 'Lego' => 'lego',
+ 'Lego Star Wars' => 'lego-star-wars',
+ 'Lenovo K6 Note' => 'lenovo-k6-note',
+ 'Lenovo' => 'lenovo',
+ 'Lenovo P8' => 'lenovo-p8',
+ 'Lenovo Tab 3' => 'lenovo-tab-3',
+ 'Lenovo Tab 4' => 'lenovo-tab-4',
+ 'Lenovo Yoga' => 'lenovo-yoga',
+ 'Lenovo Yoga Tab 3' => 'lenovo-yoga-tab-3',
+ 'Lentilles de contact' => 'lentilles-de-contact',
+ 'Le Seigneur des anneaux' => 'le-seigneur-des-anneaux',
+ 'Les Sims' => 'les-sims',
+ 'Lessive' => 'lessive',
+ 'Levi&#039;s' => 'levi-s',
+ 'LG G4' => 'lg-g4',
+ 'LG G5' => 'lg-g5',
+ 'LG G6' => 'lg-g6',
+ 'LG' => 'lg',
+ 'LG OLED TV' => 'lg-oled-tv',
+ 'LG Q6' => 'lg-q6',
+ 'LG Q8' => 'lg-q8',
+ 'Life is Strange' => 'life-is-strange',
+ 'Linge de maison' => 'linge-de-maison',
+ 'Lingerie' => 'lingerie',
+ 'Lingettes pour bébés' => 'lingettes-pour-bebes',
+ 'Liseuses' => 'liseuses',
+ 'Litière pour chat' => 'litiere-pour-chat',
+ 'Lits' => 'lits',
+ 'Lits pour bébé' => 'lits-pour-bebe',
+ 'Livres audio' => 'livres-audio',
+ 'Livres' => 'livres',
+ 'Livres photo' => 'livres-photo',
+ 'Location de voiture' => 'location-de-voiture',
+ 'Logiciels de sécurité' => 'logiciels-de-securite',
+ 'Logiciels Microsoft' => 'logiciels-microsoft',
+ 'Logitech Harmony' => 'logitech-harmony',
+ 'Logitech' => 'logitech',
+ 'Loup-Garou' => 'loup-garou',
+ 'Lubrifiants' => 'lubrifiants',
+ 'Luminaires' => 'luminaires',
+ 'Lunettes de natation' => 'lunettes-de-natation',
+ 'Lunettes de soleil' => 'lunettes-de-soleil',
+ 'MacBook' => 'macbook',
+ 'Mac de bureau' => 'mac-de-bureau',
+ 'Machines à café à dosettes' => 'machines-a-cafe-a-dosettes',
+ 'Machines à café en grain' => 'machines-a-cafe-en-grain',
+ 'Machines à pain' => 'machines-a-pain',
+ 'Machines Dolce Gusto' => 'machines-dolce-gusto',
+ 'Machines Nespresso' => 'machines-nespresso',
+ 'Machines Senseo' => 'machines-senseo',
+ 'Magasins d&#039;usine' => 'magasins-usine',
+ 'Magazines' => 'magazines',
+ 'Maillots de bain' => 'maillots-de-bain',
+ 'Maillots de football' => 'maillots-de-football',
+ 'Maison & Jardin' => 'maison-et-jardin',
+ 'Makita' => 'makita',
+ 'Manettes Nintendo Switch Pro' => 'manettes-nintendo-switch-pro',
+ 'Manettes PlayStation 4' => 'manettes-playstation-4',
+ 'Manettes Xbox One Elite' => 'manettes-xbox-one-elite',
+ 'Manettes Xbox One' => 'manettes-xbox-one',
+ 'Manix' => 'manix',
+ 'Manteaux' => 'manteaux',
+ 'Maquillage' => 'maquillage',
+ 'Mario Kart' => 'mario-kart',
+ 'Marteaux & maillets' => 'marteaux-et-maillets',
+ 'Mascara' => 'mascara',
+ 'Masques de ski' => 'masques-de-ski',
+ 'Mass Effect: Andromeda' => 'mass-effect-andromeda',
+ 'Matchs de football' => 'matchs-de-football',
+ 'Matelas gonflables' => 'matelas-gonflables',
+ 'Matelas' => 'matelas',
+ 'Matériaux de construction' => 'materiaux-de-construction',
+ 'Matériel de ski' => 'materiel-de-ski',
+ 'Medion' => 'medion',
+ 'Meubles pour chat' => 'meubles-pour-chat',
+ 'Micro-casques gaming' => 'micro-casques-gaming',
+ 'Micro-ondes' => 'micro-ondes',
+ 'Microphones' => 'microphones',
+ 'Micro-SD' => 'micro-sd',
+ 'Microsoft Office' => 'microsoft-office',
+ 'Microsoft Surface' => 'microsoft-surface',
+ 'Miele' => 'miele',
+ 'Minecraft' => 'minecraft',
+ 'Mixeurs' => 'mixeurs',
+ 'M&M&#039;s' => 'metm-s',
+ 'Mobilier' => 'mobilier',
+ 'Mode & accessoires' => 'mode-accessoires',
+ 'Mode enfants' => 'mode-enfants',
+ 'Mode femme' => 'mode-femme',
+ 'Mode homme' => 'mode-homme',
+ 'Modélisme' => 'modelisme',
+ 'Monopoly' => 'monopoly',
+ 'Montage PC' => 'montage-pc',
+ 'Montres' => 'montres',
+ 'Moto C Plus' => 'moto-c-plus',
+ 'Moto E4' => 'moto-e4',
+ 'Moto G5' => 'moto-g5',
+ 'Moto G5 Plus' => 'moto-g5-plus',
+ 'Moto G5S' => 'moto-g5s',
+ 'Moto G5S Plus' => 'moto-g5s-plus',
+ 'Moto M' => 'moto-m',
+ 'Moto' => 'moto',
+ 'Moto Z2' => 'moto-z2',
+ 'Moto Z2 Play' => 'moto-z2-play',
+ 'Moulinex' => 'moulinex',
+ 'Mousses à raser' => 'mousses-a-raser',
+ 'MSI' => 'msi',
+ 'Musées' => 'musees',
+ 'Musique' => 'musique',
+ 'NAS' => 'nas',
+ 'Natation' => 'natation',
+ 'Navigation' => 'navigation',
+ 'NERF' => 'nerf',
+ 'New Balance' => 'new-balance',
+ 'Nike Air Force' => 'nike-air-force',
+ 'Nike Air Max' => 'nike-air-max',
+ 'Nike Free' => 'nike-free',
+ 'Nike Huarache' => 'nike-huarache',
+ 'Nike' => 'nike',
+ 'Nintendo Classic Mini' => 'nintendo-classic-mini',
+ 'Nintendo' => 'nintendo',
+ 'Nintendo Switch' => 'nintendo-switch',
+ 'Nivea' => 'nivea',
+ 'Nokia 5' => 'nokia-5',
+ 'Nokia 6' => 'nokia-6',
+ 'Nokia 8' => 'nokia-8',
+ 'Nourriture pour chat' => 'nourriture-pour-chat',
+ 'Nourriture pour chien' => 'nourriture-pour-chien',
+ 'Nutella' => 'nutella',
+ 'Nvidia GeForce GTX 1060' => 'nvidia-geforce-gtx-1060',
+ 'Nvidia GeForce GTX 1070' => 'nvidia-geforce-gtx-1070',
+ 'Nvidia GeForce GTX 1080' => 'nvidia-geforce-gtx-1080',
+ 'Nvidia GeForce GTX 1080 Ti' => 'nvidia-geforce-gtx-1080-ti',
+ 'Nvidia' => 'nvidia',
+ 'Nvidia Shield' => 'nvidia-shield',
+ 'Objectifs' => 'objectifs',
+ 'Oculus Rift' => 'oculus-rift',
+ 'Oiseaux' => 'oiseaux',
+ 'OnePlus 5' => 'oneplus-5',
+ 'OnePlus 5T' => 'oneplus-5t',
+ 'OnePlus 6' => 'oneplus-6',
+ 'Onkyo' => 'onkyo',
+ 'Ordinateurs de bureau' => 'ordinateurs-de-bureau',
+ 'Oreillers' => 'oreillers',
+ 'Outillage' => 'outillage',
+ 'Outils de jardinage' => 'outils-de-jardinage',
+ 'Overwatch' => 'overwatch',
+ 'Packs clavier-souris' => 'packs-clavier-souris',
+ 'Paiement en ligne' => 'paiement-en-ligne',
+ 'Pampers' => 'pampers',
+ 'Panasonic' => 'panasonic',
+ 'Panier Plus' => 'panier-plus',
+ 'Pantalons' => 'pantalons',
+ 'Papeterie' => 'papeterie',
+ 'Papier peint' => 'papier-peint',
+ 'Papier toilette' => 'papier-toilette',
+ 'Parapharmacie' => 'parapharmacie',
+ 'Parc Astérix' => 'parc-asterix',
+ 'Parfums femme' => 'parfums-femme',
+ 'Parfums homme' => 'parfums-homme',
+ 'Parfums' => 'parfums',
+ 'Parkas' => 'parkas',
+ 'Parrot' => 'parrot',
+ 'Partitions' => 'partitions',
+ 'PC de bureau complets' => 'pc-de-bureau-complets',
+ 'PC gamer complets' => 'pc-gamer-complets',
+ 'PC hybrides' => 'hybrides',
+ 'PC portables' => 'pc-portables',
+ 'Pêche' => 'peche',
+ 'Peintures' => 'peintures',
+ 'Peluches' => 'peluches',
+ 'Perceuses' => 'perceuses',
+ 'Périphériques PC' => 'peripheriques-pc',
+ 'Pèse-personnes' => 'pese-personnes',
+ 'PES' => 'pro-evolution-soccer',
+ 'Petites voitures' => 'petites-voitures',
+ 'Philips Hue' => 'philips-hue',
+ 'Philips Lumea' => 'philips-lumea',
+ 'Philips One Blade' => 'philips-one-blade',
+ 'Philips' => 'philips',
+ 'Philips Sonicare' => 'philips-sonicare',
+ 'Photo' => 'photo',
+ 'Pièces auto' => 'pieces-auto',
+ 'Pièces moto' => 'pieces-moto',
+ 'Pièces vélo' => 'pieces-velo',
+ 'Piles' => 'piles',
+ 'Piles rechargeables' => 'piles-rechargeables',
+ 'Pinces' => 'pinces',
+ 'Pizza' => 'pizza',
+ 'Places de cinéma' => 'places-de-cinema',
+ 'Plage' => 'plage',
+ 'Plantes' => 'plantes',
+ 'Plaques de cuisson' => 'plaques-de-cuisson',
+ 'Platines vinyle' => 'platines-vinyle',
+ 'Playmobil' => 'playmobil',
+ 'PlayStation 4' => 'playstation-4',
+ 'PlayStation 4 Pro' => 'playstation-4-pro',
+ 'PlayStation 4 Slim' => 'playstation-4-slim',
+ 'PlayStation' => 'playstation',
+ 'PlayStation Plus' => 'playstation-plus',
+ 'Playstation Store' => 'playstation-store',
+ 'Plomberie' => 'plomberie',
+ 'Pneus' => 'pneus',
+ 'PocketBook' => 'pocketbook',
+ 'Poêles' => 'poeles',
+ 'Pokémon' => 'pokemon',
+ 'Portables gamer' => 'portables-gamer',
+ 'Porte-bébé' => 'porte-bebe',
+ 'Portefeuilles' => 'portefeuilles',
+ 'Posters' => 'posters',
+ 'Potager' => 'potager',
+ 'Poulaillers' => 'poulaillers',
+ 'Poupées' => 'poupees',
+ 'Poussettes' => 'poussettes',
+ 'Premiers secours' => 'premiers-secours',
+ 'Préservatifs' => 'preservatifs',
+ 'Princesse Tam-Tam' => 'princesse-tam-tam',
+ 'Processeurs' => 'processeurs',
+ 'Protection de la maison' => 'protection-de-la-maison',
+ 'Protections intimes' => 'protections-intimes',
+ 'Puériculture' => 'puericulture',
+ 'Pulls' => 'pulls',
+ 'Puma' => 'puma',
+ 'Purificateurs d&#039;air' => 'purificateurs-d-air',
+ 'Purina' => 'purina',
+ 'Puzzles' => 'puzzles',
+ 'Pyjamas pour bébés' => 'pyjamas-pour-bebes',
+ 'Pyjamas' => 'pyjamas',
+ 'Qobuz' => 'qobuz',
+ 'RAM' => 'ram',
+ 'Randonnée' => 'randonnee',
+ 'Rasage' => 'rasage',
+ 'Rasoirs électriques' => 'rasoirs-electriques',
+ 'Rasoirs manuels' => 'rasoirs-manuels',
+ 'Raspberry Pi' => 'raspberry-pi',
+ 'Ray-Ban' => 'ray-ban',
+ 'Razer' => 'razer',
+ 'Réductions étudiants & jeunes' => 'reductions-etudiants-et-jeunes',
+ 'Reebok' => 'reebok',
+ 'Réfrigérateurs' => 'refrigerateurs',
+ 'Réhausseurs' => 'rehausseurs',
+ 'Remington' => 'remington',
+ 'Répéteurs' => 'repeteurs',
+ 'Réseau' => 'reseau',
+ 'Resident Evil 7' => 'resident-evil-7',
+ 'Resident Evil' => 'resident-evil',
+ 'Restaurants' => 'restaurants',
+ 'Richelieus' => 'richelieus',
+ 'Risk' => 'risk',
+ 'Rongeurs' => 'rongeurs',
+ 'Rouges à lèvres' => 'rouges-a-levres',
+ 'Routeurs' => 'routeurs',
+ 'Royal Canin' => 'royal-canin',
+ 'Running' => 'running',
+ 'Sacs à dos' => 'sacs-a-dos',
+ 'Sacs à langer' => 'sacs-a-langer',
+ 'Sacs à main' => 'sacs-a-main',
+ 'Samsonite' => 'samsonite',
+ 'Samsung Galaxy A5' => 'samsung-galaxy-a5',
+ 'Samsung Galaxy Note 8' => 'samsung-galaxy-note-8',
+ 'Samsung Galaxy S7 Edge' => 'samsung-galaxy-s7-edge',
+ 'Samsung Galaxy S7' => 'samsung-galaxy-s7',
+ 'Samsung Galaxy S8' => 'samsung-galaxy-s8',
+ 'Samsung Galaxy S8+' => 'samsung-galaxy-s8plus',
+ 'Samsung Galaxy S9' => 'samsung-galaxy-s9',
+ 'Samsung Galaxy Tab A' => 'samsung-galaxy-tab-a',
+ 'Samsung Galaxy Tab S2' => 'samsung-galaxy-tab-s2',
+ 'Samsung Galaxy Tab S3' => 'samsung-galaxy-tab-s3',
+ 'Samsung Gear' => 'samsung-gear',
+ 'Samsung Gear VR' => 'samsung-gear-vr',
+ 'Samsung' => 'samsung',
+ 'Sandales' => 'sandales',
+ 'SanDisk' => 'sandisk',
+ 'Santé & Cosmétiques' => 'sante-et-cosmetiques',
+ 'Savons' => 'savons',
+ 'Scanners' => 'scanners',
+ 'Scies' => 'scies',
+ 'Scooters' => 'scooters',
+ 'Seagate' => 'seagate',
+ 'Sécateurs' => 'secateurs',
+ 'Sèche-cheveux' => 'seche-cheveux',
+ 'Sèche-linge' => 'seche-linge',
+ 'Séjours' => 'sejours',
+ 'Sennheiser' => 'sennheiser',
+ 'Séries TV' => 'series-tv',
+ 'Services divers' => 'services-divers',
+ 'Serviettes hygiéniques' => 'serviettes-hygieniques',
+ 'Serviettes' => 'serviettes',
+ 'Sextoys' => 'sextoys',
+ 'Shorts de bain' => 'shorts-de-bain',
+ 'Shorts' => 'shorts',
+ 'Sièges auto' => 'sieges-auto',
+ 'Siemens' => 'siemens',
+ 'Skechers' => 'sketchers',
+ 'Ski' => 'ski',
+ 'Skyrim' => 'skyrim',
+ 'Smartbox' => 'smartbox',
+ 'Smart Home' => 'smart-home',
+ 'Smartphones à moins de 100€' => 'smartphones-moins-de-100',
+ 'Smartphones à moins de 200€' => 'smartphones-moins-de-200',
+ 'Smartphones Android' => 'smartphones-android',
+ 'Smartphones Huawei' => 'smartphones-huawei',
+ 'Smartphones Nokia' => 'smartphones-nokia',
+ 'Smartphones Samsung' => 'smartphones-samsung',
+ 'Smartphones' => 'smartphones',
+ 'Smartphones Xiaomi' => 'smartphones-xiaomi',
+ 'Smart TV' => 'smart-tv',
+ 'Smartwatch' => 'smartwatch',
+ 'Sneakers' => 'sneakers',
+ 'Soin des cheveux' => 'soin-des-cheveux',
+ 'Sonos PLAYBAR' => 'sonos-playbar',
+ 'Sonos' => 'sonos',
+ 'Sony PlayStation VR' => 'sony-playstation-vr',
+ 'Sony' => 'sony',
+ 'Sony Xperia XA1' => 'sony-xperia-xa1',
+ 'Sony Xperia X Compact' => 'sony-xperia-x-compact',
+ 'Sony Xperia XZ1 Compact' => 'sony-xperia-xz1-compact',
+ 'Sony Xperia XZ1' => 'sony-xperia-xz1',
+ 'Sony Xperia XZ Premium' => 'sony-xperia-xz-premium',
+ 'Sony Xperia Z3' => 'sony-xperia-z3',
+ 'Sorties' => 'sorties',
+ 'Souris gamer' => 'souris-gamer',
+ 'Souris Logitech' => 'souris-logitech',
+ 'Souris sans fil' => 'souris-sans-fil',
+ 'Souris' => 'souris',
+ 'South Park' => 'south-park',
+ 'Spectacles comiques' => 'spectacles-comiques',
+ 'Spectacles' => 'spectacles',
+ 'Sports & plein air' => 'sports-plein-air',
+ 'Spotify' => 'spotify',
+ 'SSD' => 'ssd',
+ 'Star Wars Battlefront' => 'star-wars-battlefront',
+ 'Stickers muraux' => 'stickers-muraux',
+ 'Stihl' => 'stihl',
+ 'Stockage externe' => 'stockage',
+ 'Streaming musical' => 'streaming-musical',
+ 'Stylos' => 'stylos',
+ 'Sucettes' => 'sucettes',
+ 'Super Mario' => 'super-mario',
+ 'Support GPS & smartphone' => 'support-gps-et-smartphone',
+ 'Surface Pro 4' => 'surface-pro-4',
+ 'Surgelés' => 'surgeles',
+ 'Surveillance' => 'surveillance',
+ 'Swatch' => 'swatch',
+ 'Switch réseau' => 'switch-reseau',
+ 'Systèmes d&#039;exploitation' => 'systemes-d-exploitation',
+ 'Systèmes multiroom' => 'systemes-multiroom',
+ 'Tables à langer' => 'tables-a-langer',
+ 'Tables de camping' => 'tables-de-camping',
+ 'Tables de mixage' => 'tables-de-mixage',
+ 'Tables' => 'tables',
+ 'Tablettes graphiques Huion' => 'huion',
+ 'Tablettes graphiques' => 'tablettes-graphiques',
+ 'Tablettes graphiques Wacom' => 'wacom',
+ 'Tablettes Lenovo' => 'tablettes-lenovo',
+ 'Tablettes Samsung' => 'tablettes-samsung',
+ 'Tablettes' => 'tablettes',
+ 'Tablettes Xiaomi' => 'tablettes-xiaomi',
+ 'Tampons' => 'tampons',
+ 'Tapis' => 'tapis',
+ 'Taxis' => 'taxis',
+ 'Tefal' => 'tefal',
+ 'Télécommandes' => 'telecommandes',
+ 'Téléphones fixes' => 'telephones-fixes',
+ 'Téléphonie' => 'telephonie',
+ 'Téléviseurs' => 'televiseurs',
+ 'Tentes' => 'tentes',
+ 'Têtes de brosse à dents de rechange' => 'tetes-de-brosse-a-dents-de-rechange',
+ 'Théâtre' => 'theatre',
+ 'The Legend of Zelda' => 'the-legend-of-zelda',
+ 'Thermomètres' => 'thermometres',
+ 'Thermomix' => 'thermomix',
+ 'Thés glacés' => 'thes-glaces',
+ 'Thés' => 'thes',
+ 'The Walking dead' => 'the-walking-dead',
+ 'The Witcher 3' => 'the-witcher-3',
+ 'The Witcher' => 'the-witcher',
+ 'Time&#039;s Up!' => 'time-s-up',
+ 'Tom Clancy&#039;s Ghost Recon: Wildlands' => 'tom-clancy-s-ghost-recon-wildlands',
+ 'Tom Clancy&#039;s The Division' => 'tom-clancy-s-the-division',
+ 'Tom Clancy&#039;s' => 'tom-clancy-s',
+ 'TomTom' => 'tomtom',
+ 'Tondeuses à gazon' => 'tondeuses-a-gazon',
+ 'Tondeuses' => 'tondeuses',
+ 'Toner' => 'toner',
+ 'Torchons' => 'torchons',
+ 'Toshiba' => 'toshiba',
+ 'Total War' => 'total-war',
+ 'Total War: Warhammer II' => 'total-war-warhammer-ii',
+ 'Total War: Warhammer' => 'total-war-warhammer',
+ 'Tournevis & visseuses' => 'tournevis-et-visseuses',
+ 'TP-Link' => 'tp-link',
+ 'Transats & cosys' => 'transats-et-cosys',
+ 'Transports en commun' => 'transports-en-commun',
+ 'Trixie' => 'trixie',
+ 'Tronçonneuses' => 'tronconneuses',
+ 'Trottinettes électriques' => 'trottinettes-electriques',
+ 'Trottinettes' => 'trottinettes',
+ 'T-shirts' => 't-shirts',
+ 'TV 39&#039;&#039; et moins' => 'tv-39-pouces-et-moins',
+ 'TV 40&#039;&#039; à 64&#039;&#039;' => 'tv-40-pouces-a-64-pouces',
+ 'TV 4K' => 'tv-4k',
+ 'TV 65&#039;&#039; et plus' => 'tv-65-pouces-et-plus',
+ 'TV Full HD' => 'tv-full-hd',
+ 'TV incurvées' => 'tv-incurvees',
+ 'TV LG' => 'tv-lg',
+ 'TV OLED' => 'tv-oled',
+ 'TV Panasonic' => 'tv-panasonic',
+ 'TV Philips' => 'tv-philips',
+ 'TV Samsung' => 'tv-samsung',
+ 'TV Sony' => 'tv-sony',
+ 'Ultraportables' => 'ultraportables',
+ 'Uncharted 4' => 'uncharted-4',
+ 'Uncharted: The Lost Legacy' => 'uncharted-the-lost-legacy',
+ 'Uncharted' => 'uncharted',
+ 'Ustensiles de cuisine' => 'ustensiles-de-cuisine',
+ 'Ustensiles de cuisson' => 'ustensiles-de-cuisson',
+ 'Vaisselle' => 'vaisselle',
+ 'Valises cabine' => 'valises-cabine',
+ 'Valises rigides' => 'valises-rigides',
+ 'Valises' => 'valises',
+ 'Variétés & revues' => 'varietes-et-revues',
+ 'Vases' => 'vases',
+ 'Veet' => 'veet',
+ 'Vélos d&#039;appartement' => 'velos-d-appartement',
+ 'Vélos' => 'velos',
+ 'Ventilateurs' => 'ventilateurs',
+ 'Ventirad' => 'ventirad',
+ 'Vernis à ongles' => 'vernis-a-ongles',
+ 'Vestes' => 'vestes',
+ 'Vêtements d&#039;été' => 'vetements-d-ete',
+ 'Vêtements d&#039;hiver' => 'vetements-d-hiver',
+ 'Vêtements de grossesse' => 'vetements-de-grossesse',
+ 'Vêtements de ski' => 'vetements-de-ski',
+ 'Vêtements de sport' => 'vetements-de-sport',
+ 'Vêtements pour bébé' => 'vetements-pour-bebe',
+ 'Vêtements techniques' => 'vetements-techniques',
+ 'Vidéoprojecteurs 3D' => 'videoprojecteurs-3d',
+ 'Vidéoprojecteurs Acer' => 'videoprojecteurs-acer',
+ 'Vidéoprojecteurs BenQ' => 'videoprojecteurs-benq',
+ 'Vidéoprojecteurs Epson' => 'videoprojecteurs-epson',
+ 'Vidéoprojecteurs HD' => 'videoprojecteurs-hd',
+ 'Vidéoprojecteurs LG' => 'videoprojecteurs-lg',
+ 'Vidéoprojecteurs Optoma' => 'videoprojecteurs-optoma',
+ 'Vidéoprojecteurs' => 'projecteurs',
+ 'Vidéo' => 'video',
+ 'Vins' => 'vins',
+ 'Visites & patrimoine' => 'visites-et-patrimoine',
+ 'VOD' => 'vod',
+ 'Voitures télécommandées' => 'voitures-telecommandees',
+ 'Voyages & sorties' => 'voyages-et-sorties',
+ 'Voyages' => 'voyages',
+ 'VPN' => 'vpn',
+ 'VR' => 'vr',
+ 'VTC' => 'vtc',
+ 'VTT' => 'vtt',
+ 'Wacom Cintiq' => 'cintiq',
+ 'Watercooling' => 'watercooling',
+ 'WD (Western Digital)' => 'western-digital',
+ 'Wearables' => 'wearables',
+ 'Whey' => 'whey',
+ 'Whirlpool' => 'whirlpool',
+ 'Whiskas' => 'whiskas',
+ 'Wii U' => 'wii-u',
+ 'Wiko' => 'wiko',
+ 'Windows' => 'windows',
+ 'WindScribe' => 'windscribe',
+ 'Wolfenstein II: The New Colossus' => 'wolfenstein-ii-the-new-colossus',
+ 'Wolfenstein' => 'wolfenstein',
+ 'Wonderbox' => 'wonderbox',
+ 'Xbox Live' => 'xbox-live',
+ 'Xbox One S' => 'xbox-one-s',
+ 'Xbox One' => 'xbox-one',
+ 'Xbox One X' => 'xbox-one-x',
+ 'Xbox' => 'xbox',
+ 'Xiaomi Mi6' => 'xiaomi-mi6',
+ 'Xiaomi Mi A1' => 'xiaomi-mi-a1',
+ 'Xiaomi Mi Band' => 'xiaomi-mi-band',
+ 'Xiaomi Mi Box' => 'xiaomi-mi-box',
+ 'Xiaomi Mi Max' => 'xiaomi-mi-max',
+ 'Xiaomi Mi Mix 2' => 'xiaomi-mi-mix-2',
+ 'Xiaomi Mi Mix' => 'xiaomi-mi-mix',
+ 'Xiaomi Mi Pad 3' => 'xiaomi-mi-pad-3',
+ 'Xiaomi Redmi 4A' => 'xiaomi-redmi-4a',
+ 'Xiaomi Redmi 4X' => 'xiaomi-redmi-4x',
+ 'Xiaomi Redmi Note 4' => 'xiaomi-redmi-note-4',
+ 'Xiaomi Smart Home' => 'xiaomi-smart-home',
+ 'Xiaomi' => 'xiaomi',
+ 'Yamaha' => 'yamaha',
+ 'Zelda: Breath of the Wild' => 'zelda-breath-of-the-wild',
+ 'Zoos' => 'zoos',
+ )
+ ),
+ 'order' => array(
+ 'name' => 'Trier par',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Ordre de tri des deals',
+ 'values' => array(
+ 'Du deal le plus Hot au moins Hot' => '',
+ 'Du deal le plus récent au plus ancien' => '-nouveaux',
+ 'Du deal le plus commentés au moins commentés' => '-commentes'
+ )
+ )
+ )
+ );
+
+ public $lang = array(
+ 'bridge-uri' => SELF::URI,
+ 'bridge-name' => SELF::NAME,
+ 'context-keyword' => 'Recherche par Mot(s) clé(s)',
+ 'context-group' => 'Deals par groupe',
+ 'uri-group' => '/groupe/',
+ 'request-error' => 'Could not request Dealabs',
+ 'no-results' => 'Il n&#039;y a rien à afficher pour le moment :(',
+ 'relative-date-indicator' => array(
+ 'il y a',
+ ),
+ 'price' => 'Prix',
+ 'shipping' => 'Livraison',
+ 'origin' => 'Origine',
+ 'discount' => 'Réduction',
+ 'title-keyword' => 'Recherche',
+ 'title-group' => 'Groupe',
+ 'local-months' => array(
+ 'janvier',
+ 'février',
+ 'mars',
+ 'avril',
+ 'mai',
+ 'juin',
+ 'juillet',
+ 'août',
+ 'septembre',
+ 'octobre',
+ 'novembre',
+ 'décembre'
+ ),
+ 'local-time-relative' => array(
+ 'il y a ',
+ 'min',
+ 'h',
+ 'jour',
+ 'jours',
+ 'mois',
+ 'ans',
+ 'et '
+ ),
+ 'date-prefixes' => array(
+ 'Actualisé ',
+ ),
+ 'relative-date-alt-prefixes' => array(
+ 'Actualisé ',
+ ),
+ 'relative-date-ignore-suffix' => array(
+ ),
+
+ 'localdeal' => array(
+ 'Local',
+ 'Pays d\'expédition'
+ ),
+ );
+
+
+
+}
+
+class PepperBridgeAbstract extends BridgeAbstract {
+
+ const CACHE_TIMEOUT = 3600;
+
+ public function collectData(){
+ switch($this->queriedContext) {
+ case $this->i8n('context-keyword'):
+ return $this->collectDataKeywords();
+ break;
+ case $this->i8n('context-group'):
+ return $this->collectDataGroup();
+ break;
+ }
+ }
+
+ /**
+ * Get the Deal data from the choosen group in the choosed order
+ */
+ protected function collectDataGroup()
+ {
+
+ $group = $this->getInput('group');
+ $order = $this->getInput('order');
+
+ $url = $this->i8n('bridge-uri')
+ . $this->i8n('uri-group') . $group . $order;
+ $this->collectDeals($url);
+ }
+
+ /**
+ * Get the Deal data from the choosen keywords and parameters
+ */
+ protected function collectDataKeywords()
+ {
+ $q = $this->getInput('q');
+ $hide_expired = $this->getInput('hide_expired');
+ $hide_local = $this->getInput('hide_local');
+ $priceFrom = $this->getInput('priceFrom');
+ $priceTo = $this->getInput('priceFrom');
+
+ /* Even if the original website uses POST with the search page, GET works too */
+ $url = $this->i8n('bridge-uri')
+ . '/search/advanced?q='
+ . urlencode($q)
+ . '&hide_expired=' . $hide_expired
+ . '&hide_local=' . $hide_local
+ . '&priceFrom=' . $priceFrom
+ . '&priceTo=' . $priceTo
+ /* Some default parameters
+ * search_fields : Search in Titres & Descriptions & Codes
+ * sort_by : Sort the search by new deals
+ * time_frame : Search will not be on a limited timeframe
+ */
+ . '&search_fields[]=1&search_fields[]=2&search_fields[]=3&sort_by=new&time_frame=0';
+ $this->collectDeals($url);
+ }
+
+ /**
+ * Get the Deal data using the given URL
+ */
+ protected function collectDeals($url){
+ $html = getSimpleHTMLDOM($url)
+ or returnServerError($this->i8n('request-error'));
+ $list = $html->find('article[id]');
+
+ // Deal Image Link CSS Selector
+ $selectorImageLink = implode(
+ ' ', /* Notice this is a space! */
+ array(
+ 'cept-thread-image-link',
+ 'imgFrame',
+ 'imgFrame--noBorder',
+ 'thread-listImgCell',
+ )
+ );
+
+ // Deal Link CSS Selector
+ $selectorLink = implode(
+ ' ', /* Notice this is a space! */
+ array(
+ 'cept-tt',
+ 'thread-link',
+ 'linkPlain',
+ )
+ );
+
+ // Deal Hotness CSS Selector
+ $selectorHot = implode(
+ ' ', /* Notice this is a space! */
+ array(
+ 'cept-vote-box',
+ 'vote-box'
+ )
+ );
+
+ // Deal Description CSS Selector
+ $selectorDescription = implode(
+ ' ', /* Notice this is a space! */
+ array(
+ 'cept-description-container',
+ 'overflow--wrap-break'
+ )
+ );
+
+ // Deal Date CSS Selector
+ $selectorDate = implode(
+ ' ', /* Notice this is a space! */
+ array(
+ 'size--all-s',
+ 'flex',
+ 'boxAlign-jc--all-fe'
+ )
+ );
+
+ // If there is no results, we don't parse the content because it display some random deals
+ $noresult = $html->find('h3[class=size--all-l size--fromW2-xl size--fromW3-xxl]', 0);
+ if ($noresult != null && strpos($noresult->plaintext, $this->i8n('no-results')) !== false) {
+ $this->items = array();
+ } else {
+ foreach ($list as $deal) {
+ $item = array();
+ $item['uri'] = $deal->find('div[class=threadGrid-title]', 0)->find('a', 0)->href;
+ $item['title'] = $deal->find('a[class*=' . $selectorLink . ']', 0
+ )->plaintext;
+ $item['author'] = $deal->find('span.thread-username', 0)->plaintext;
+ $item['content'] = '<table><tr><td><a href="'
+ . $deal->find(
+ 'a[class*=' . $selectorImageLink . ']', 0)->href
+ . '"><img src="'
+ . $this->getImage($deal)
+ . '"/></td><td><h2><a href="'
+ . $deal->find('a[class*=' . $selectorLink . ']', 0)->href
+ . '">'
+ . $deal->find('a[class*=' . $selectorLink . ']', 0)->innertext
+ . '</a></h2>'
+ . $this->getPrice($deal)
+ . $this->getDiscount($deal)
+ . $this->getShipsFrom($deal)
+ . $this->getShippingCost($deal)
+ . $this->GetSource($deal)
+ . $deal->find('div[class*=' . $selectorDescription . ']', 0)->innertext
+ . '</td><td>'
+ . $deal->find('div[class*=' . $selectorHot . ']', 0)
+ ->find('span', 1)->outertext
+ . '</td></table>';
+ $dealDateDiv = $deal->find('div[class*=' . $selectorDate . ']', 0)
+ ->find('span[class=hide--toW3]');
+ $itemDate = end($dealDateDiv)->plaintext;
+ // In case of a Local deal, there is no date, but we can use
+ // this case for other reason (like date not in the last field)
+ if ($this->contains($itemDate, $this->i8n('localdeal'))) {
+ $item['timestamp'] = time();
+ } else if ($this->contains($itemDate, $this->i8n('relative-date-indicator'))) {
+ $item['timestamp'] = $this->relativeDateToTimestamp($itemDate);
+ } else {
+ $item['timestamp'] = $this->parseDate($itemDate);
+ }
+ $this->items[] = $item;
+ }
+ }
+ }
+
+ /**
+ * Check if the string $str contains any of the string of the array $arr
+ * @return boolean true if the string matched anything otherwise false
+ */
+ private function contains($str, array $arr)
+ {
+ foreach ($arr as $a) {
+ if (stripos($str, $a) !== false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get the Price from a Deal if it exists
+ * @return string String of the deal price
+ */
+ private function getPrice($deal)
+ {
+ if ($deal->find(
+ 'span[class*=thread-price]', 0) != null) {
+ return '<div>' . $this->i8n('price') . ' : '
+ . $deal->find(
+ 'span[class*=thread-price]', 0
+ )->plaintext
+ . '</div>';
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Get the Shipping costs from a Deal if it exists
+ * @return string String of the deal shipping Cost
+ */
+ private function getShippingCost($deal)
+ {
+ if ($deal->find('span[class*=cept-shipping-price]', 0) != null) {
+ if ($deal->find('span[class*=cept-shipping-price]', 0)->children(0) != null) {
+ return '<div>' . $this->i8n('shipping') . ' : '
+ . $deal->find('span[class*=cept-shipping-price]', 0)->children(0)->innertext
+ . '</div>';
+ } else {
+ return '<div>' . $this->i8n('shipping') . ' : '
+ . $deal->find('span[class*=cept-shipping-price]', 0)->innertext
+ . '</div>';
+ }
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Get the source of a Deal if it exists
+ * @return string String of the deal source
+ */
+ private function GetSource($deal)
+ {
+ if ($deal->find('a[class=text--color-greyShade]', 0) != null) {
+ return '<div>' . $this->i8n('origin') . ' : '
+ . $deal->find('a[class=text--color-greyShade]', 0)->outertext
+ . '</div>';
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Get the original Price and discout from a Deal if it exists
+ * @return string String of the deal original price and discount
+ */
+ private function getDiscount($deal)
+ {
+ if ($deal->find('span[class*=mute--text text--lineThrough]', 0) != null) {
+ $discountHtml = $deal->find('span[class=space--ml-1 size--all-l size--fromW3-xl]', 0);
+ if ($discountHtml != null) {
+ $discount = $discountHtml->plaintext;
+ } else {
+ $discount = '';
+ }
+ return '<div>' . $this->i8n('discount') . ' : <span style="text-decoration: line-through;">'
+ . $deal->find(
+ 'span[class*=mute--text text--lineThrough]', 0
+ )->plaintext
+ . '</span>&nbsp;'
+ . $discount
+ . '</div>';
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Get the Picture URL from a Deal if it exists
+ * @return string String of the deal Picture URL
+ */
+ private function getImage($deal)
+ {
+ $selectorLazy = implode(
+ ' ', /* Notice this is a space! */
+ array(
+ 'thread-image',
+ 'width--all-auto',
+ 'height--all-auto',
+ 'imgFrame-img',
+ 'cept-thread-img',
+ 'img--dummy',
+ 'js-lazy-img'
+ )
+ );
+
+ $selectorPlain = implode(
+ ' ', /* Notice this is a space! */
+ array(
+ 'thread-image',
+ 'width--all-auto',
+ 'height--all-auto',
+ 'imgFrame-img',
+ 'cept-thread-img'
+ )
+ );
+ if ($deal->find('img[class=' . $selectorLazy . ']', 0) != null) {
+ return json_decode(
+ html_entity_decode(
+ $deal->find('img[class=' . $selectorLazy . ']', 0)
+ ->getAttribute('data-lazy-img')))->{'src'};
+ } else {
+ return $deal->find('img[class*=' . $selectorPlain . ']', 0 )->src;
+ }
+ }
+
+ /**
+ * Get the originating country from a Deal if it exists
+ * @return string String of the deal originating country
+ */
+ private function getShipsFrom($deal)
+ {
+ $selector = implode(
+ ' ', /* Notice this is a space! */
+ array(
+ 'meta-ribbon',
+ 'overflow--wrap-off',
+ 'space--l-3',
+ 'text--color-greyShade'
+ )
+ );
+ if ($deal->find('span[class=' . $selector . ']', 0) != null) {
+ return '<div>'
+ . $deal->find('span[class=' . $selector . ']', 0)->children(2)->plaintext
+ . '</div>';
+ } else {
+ return '';
+ }
+ }
+
+ /**
+ * Transforms a local date into a timestamp
+ * @return int timestamp of the input date
+ */
+ private function parseDate($string)
+ {
+ $month_local = $this->i8n('local-months');
+ $month_en = array(
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December'
+ );
+
+ // A date can be prfixed with some words, we remove theme
+ $string = $this->removeDatePrefixes($string);
+ // We translate the local months name in the english one
+ $date_str = trim(str_replace($month_local, $month_en, $string));
+
+ // If the date does not contain any year, we add the current year
+ if (!preg_match('/[0-9]{4}/', $string)) {
+ $date_str .= ' ' . date('Y');
+ }
+
+ // Add the Hour and minutes
+ $date_str .= ' 00:00';
+
+ $date = DateTime::createFromFormat('j F Y H:i', $date_str);
+ return $date->getTimestamp();
+ }
+
+ /**
+ * Remove the prefix of a date if it has one
+ * @return the date without prefiux
+ */
+ private function removeDatePrefixes($string)
+ {
+ $string = str_replace($this->i8n('date-prefixes'), array(), $string);
+ return $string;
+ }
+
+ /**
+ * Remove the suffix of a relative date if it has one
+ * @return the relative date without suffixes
+ */
+ private function removeRelativeDateSuffixes($string)
+ {
+ if (count($this->i8n('relative-date-ignore-suffix')) > 0) {
+ $string = preg_replace($this->i8n('relative-date-ignore-suffix'), '', $string);
+ }
+ return $string;
+ }
+
+ /**
+ * Transforms a relative local date into a timestamp
+ * @return int timestamp of the input date
+ */
+ private function relativeDateToTimestamp($str) {
+ $date = new DateTime();
+
+ // In case of update date, replace it by the regular relative date first word
+ $str = str_replace($this->i8n('relative-date-alt-prefixes'), $this->i8n('local-time-relative')[0], $str);
+
+ $str = $this->removeRelativeDateSuffixes($str);
+
+ $search = $this->i8n('local-time-relative');
+
+ $replace = array(
+ '-',
+ 'minute',
+ 'hour',
+ 'day',
+ 'month',
+ 'year',
+ ''
+ );
+
+ $date->modify(str_replace($search, $replace, $str));
+ return $date->getTimestamp();
+ }
+
+ /**
+ * Returns the RSS Feed title according to the parameters
+ * @return string the RSS feed Tiyle
+ */
+ public function getName(){
+ switch($this->queriedContext) {
+ case $this->i8n('context-keyword'):
+ return $this->i8n('bridge-name') . ' - ' . $this->i8n('title-keyword') . ' : ' . $this->getInput('q');
+ break;
+ case $this->i8n('context-group'):
+ $values = $this->getParameters()[$this->i8n('context-group')]['group']['values'];
+ $group = array_search($this->getInput('group'), $values);
+ return $this->i8n('bridge-name') . ' - ' . $this->i8n('title-group') . ' : ' . $group;
+ break;
+ default: // Return default value
+ return static::NAME;
+ }
+ }
+
+ /**
+ * This is some "localisation" function that returns the needed content using
+ * the "$lang" class variable in the local class
+ * @return various the local content needed
+ */
+ protected function i8n($key)
+ {
+ if (array_key_exists($key, $this->lang)) {
+ return $this->lang[$key];
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/bridges/DemoBridge.php b/bridges/DemoBridge.php
new file mode 100644
index 0000000..f48b451
--- /dev/null
+++ b/bridges/DemoBridge.php
@@ -0,0 +1,46 @@
+<?php
+class DemoBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene';
+ const NAME = 'DemoBridge';
+ const URI = 'http://github.com/rss-bridge/rss-bridge';
+ const DESCRIPTION = 'Bridge used for demos';
+
+ const PARAMETERS = array(
+ 'testCheckbox' => array(
+ 'testCheckbox' => array(
+ 'type' => 'checkbox',
+ 'name' => 'test des checkbox'
+ )
+ ),
+ 'testList' => array(
+ 'testList' => array(
+ 'type' => 'list',
+ 'name' => 'test des listes',
+ 'values' => array(
+ 'Test' => 'test',
+ 'Test 2' => 'test2'
+ )
+ )
+ ),
+ 'testNumber' => array(
+ 'testNumber' => array(
+ 'type' => 'number',
+ 'name' => 'test des numéros',
+ 'exampleValue' => '1515632'
+ )
+ )
+ );
+
+ public function collectData(){
+
+ $item = array();
+ $item['author'] = 'Me!';
+ $item['title'] = 'Test';
+ $item['content'] = 'Awesome content !';
+ $item['id'] = 'Lalala';
+ $item['uri'] = 'http://example.com/test';
+
+ $this->items[] = $item;
+ }
+}
diff --git a/bridges/DemonoidBridge.php b/bridges/DemonoidBridge.php
new file mode 100644
index 0000000..842b421
--- /dev/null
+++ b/bridges/DemonoidBridge.php
@@ -0,0 +1,169 @@
+<?php
+class DemonoidBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'metaMMA';
+ const NAME = 'Demonoid';
+ const URI = 'https://www.demonoid.pw/';
+ const DESCRIPTION = 'Returns results from search';
+
+ const PARAMETERS = array(
+ 'Keywords' => array(
+ 'q' => array(
+ 'name' => 'keywords',
+ 'exampleValue' => 'keyword1 keyword2…',
+ 'required' => true,
+ ),
+ 'category' => array(
+ 'name' => 'Category',
+ 'type' => 'list',
+ 'values' => array(
+ 'All' => 0,
+ 'Movies' => 1,
+ 'Music' => 2,
+ 'TV' => 3,
+ 'Games' => 4,
+ 'Applications' => 5,
+ 'Pictures' => 8,
+ 'Anime' => 9,
+ 'Comics' => 10,
+ 'Books' => 11,
+ 'Audiobooks' => 17
+ )
+ )
+ ),
+ 'Category Only' => array(
+ 'catOnly' => array(
+ 'name' => 'Category',
+ 'type' => 'list',
+ 'values' => array(
+ 'All' => 0,
+ 'Movies' => 1,
+ 'Music' => 2,
+ 'TV' => 3,
+ 'Games' => 4,
+ 'Applications' => 5,
+ 'Pictures' => 8,
+ 'Anime' => 9,
+ 'Comics' => 10,
+ 'Books' => 11,
+ 'Audiobooks' => 17
+ )
+ )
+ ),
+ 'User ID' => array(
+ 'userid' => array(
+ 'name' => 'user id',
+ 'exampleValue' => '00000',
+ 'required' => true,
+ 'type' => 'number'
+ ),
+ 'category' => array(
+ 'name' => 'Category',
+ 'type' => 'list',
+ 'values' => array(
+ 'All' => 0,
+ 'Movies' => 1,
+ 'Music' => 2,
+ 'TV' => 3,
+ 'Games' => 4,
+ 'Applications' => 5,
+ 'Pictures' => 8,
+ 'Anime' => 9,
+ 'Comics' => 10,
+ 'Books' => 11,
+ 'Audiobooks' => 17
+ )
+ )
+ )
+ );
+
+ public function collectData() {
+
+ if(!empty($this->getInput('q'))) {
+
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'files/?category=' .
+ rawurlencode($this->getInput('category')) .
+ '&subcategory=All&quality=All&seeded=2&external=2&query=' .
+ urlencode($this->getInput('q')) .
+ '&uid=0&sort='
+ ) or returnServerError('Could not request Demonoid.');
+
+ } elseif(!empty($this->getInput('catOnly'))) {
+
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'files/?uid=0&category=' .
+ rawurlencode($this->getInput('catOnly')) .
+ '&subcategory=0&language=0&seeded=2&quality=0&query=&sort='
+ ) or returnServerError('Could not request Demonoid.');
+
+ } elseif(!empty($this->getInput('userid'))) {
+
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'files/?uid=' .
+ rawurlencode($this->getInput('userid')) .
+ '&seeded=2'
+ ) or returnServerError('Could not request Demonoid.');
+
+ } else {
+ returnServerError('Invalid parameters !');
+ }
+
+ if(preg_match('~No torrents found~', $html)) {
+ return;
+ }
+
+ $table = $html->find('td[class=ctable_content_no_pad]', 0);
+ $cursorCount = 4;
+ $elementCount = 0;
+ while($elementCount != 40) {
+ $elementCount++;
+ $currentElement = $table->find('tr', $cursorCount);
+ if(preg_match('~items total~', $currentElement)) {
+ break;
+ }
+ $item = array();
+ //Do we have a date ?
+ if(preg_match('~Added.*?(.*)~', $currentElement->plaintext, $dateStr)) {
+ if(preg_match('~today~', $dateStr[0])) {
+ date_default_timezone_set('UTC');
+ $timestamp = mktime(0, 0, 0, gmdate('n'), gmdate('j'), gmdate('Y'));
+ } else {
+ preg_match('~(?<=ed on ).*\d+~', $currentElement->plaintext, $fullDateStr);
+ date_default_timezone_set('UTC');
+ $dateObj = strptime($fullDateStr[0], '%A, %b %d, %Y');
+ $timestamp = mktime(0, 0, 0, $dateObj['tm_mon'] + 1, $dateObj['tm_mday'], 1900 + $dateObj['tm_year']);
+ }
+ $cursorCount++;
+ }
+
+ $content = $table->find('tr', $cursorCount)->find('a', 1);
+ $cursorCount++;
+ $torrentInfo = $table->find('tr', $cursorCount);
+ $item['timestamp'] = $timestamp;
+ $item['title'] = $content->plaintext;
+ $item['id'] = self::URI . $content->href;
+ $item['uri'] = self::URI . $content->href;
+ $item['author'] = $torrentInfo->find('a[class=user]', 0)->plaintext;
+ $item['seeders'] = $torrentInfo->find('font[class=green]', 0)->plaintext;
+ $item['leechers'] = $torrentInfo->find('font[class=red]', 0)->plaintext;
+ $item['size'] = $torrentInfo->find('td', 3)->plaintext;
+ $item['content'] = 'Uploaded by ' . $item['author']
+ . ' , Size ' . $item['size']
+ . '<br>seeders: '
+ . $item['seeders']
+ . ' | leechers: '
+ . $item['leechers']
+ . '<br><a href="'
+ . $item['id']
+ . '">info page</a>';
+
+ $this->items[] = $item;
+
+ $cursorCount++;
+ }
+ }
+}
diff --git a/bridges/DerpibooruBridge.php b/bridges/DerpibooruBridge.php
new file mode 100644
index 0000000..995e0cc
--- /dev/null
+++ b/bridges/DerpibooruBridge.php
@@ -0,0 +1,113 @@
+<?php
+class DerpibooruBridge extends BridgeAbstract {
+ const NAME = 'Derpibooru Bridge';
+ const URI = 'https://derpibooru.org/';
+ const DESCRIPTION = 'Returns newest posts from a Derpibooru search';
+ const CACHE_TIMEOUT = 300; // 5min
+ const MAINTAINER = 'Roliga';
+
+ const PARAMETERS = array(
+ array(
+ 'f' => array(
+ 'name' => 'Filter',
+ 'type' => 'list',
+ 'values' => array(
+ 'Everything' => 56027,
+ '18+ R34' => 37432,
+ 'Legacy Default' => 37431,
+ '18+ Dark' => 37429,
+ 'Maximum Spoilers' => 37430,
+ 'Default' => 100073
+ ),
+ 'defaultValue' => 56027
+
+ ),
+ 'q' => array(
+ 'name' => 'Query',
+ 'required' => true
+ )
+ )
+ );
+
+ public function detectParameters($url){
+ $params = array();
+
+ // Search page e.g. https://derpibooru.org/search?q=cute
+ $regex = '/^(https?:\/\/)?(www\.)?derpibooru.org\/search.+q=([^\/&?\n]+)/';
+ if(preg_match($regex, $url, $matches) > 0) {
+ $params['q'] = urldecode($matches[3]);
+ return $params;
+ }
+
+ // Tag page, e.g. https://derpibooru.org/tags/artist-colon-devinian
+ $regex = '/^(https?:\/\/)?(www\.)?derpibooru.org\/tags\/([^\/&?\n]+)/';
+ if(preg_match($regex, $url, $matches) > 0) {
+ $params['q'] = str_replace('-colon-', ':', urldecode($matches[3]));
+ return $params;
+ }
+
+ return null;
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('q'))) {
+ return 'Derpibooru search for: '
+ . $this->getInput('q');
+ } else {
+ return parent::getName();
+ }
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('f')) && !is_null($this->getInput('q'))) {
+ return self::URI
+ . 'search?filter_id='
+ . urlencode($this->getInput('f'))
+ . '&q='
+ . urlencode($this->getInput('q'));
+ } else {
+ return parent::getURI();
+ }
+ }
+
+ public function collectData(){
+ $queryJson = json_decode(getContents(
+ self::URI
+ . 'search.json?filter_id='
+ . urlencode($this->getInput('f'))
+ . '&q='
+ . urlencode($this->getInput('q'))
+ )) or returnServerError('Failed to query Derpibooru');
+
+ foreach($queryJson->search as $post) {
+ $item = array();
+
+ $postUri = self::URI . $post->id;
+
+ $item['uri'] = $postUri;
+ $item['title'] = $post->id;
+ $item['timestamp'] = strtotime($post->created_at);
+ $item['author'] = $post->uploader;
+ $item['enclosures'] = array('https:' . $post->image);
+ $item['categories'] = explode(', ', $post->tags);
+
+ $item['content'] = '<p><a href="' // image preview
+ . $postUri
+ . '"><img src="https:'
+ . $post->representations->medium
+ . '"></a></p><p>' // description
+ . $post->description
+ . '</p><p><b>Size:</b> ' // image size
+ . $post->width
+ . 'x'
+ . $post->height
+ . '<br><b>Source:</b> <a href="' // source link
+ . $post->source_url
+ . '">'
+ . $post->source_url
+ . '</a></p>';
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/DesoutterBridge.php b/bridges/DesoutterBridge.php
new file mode 100644
index 0000000..14e26c2
--- /dev/null
+++ b/bridges/DesoutterBridge.php
@@ -0,0 +1,239 @@
+<?php
+class DesoutterBridge extends BridgeAbstract {
+
+ const CATEGORY_NEWS = 'News & Events';
+ const CATEGORY_INDUSTRY = 'Industry 4.0 News';
+
+ const NAME = 'Desoutter Bridge';
+ const URI = 'https://www.desouttertools.com';
+ const DESCRIPTION = 'Returns feeds for news from Desoutter';
+ const MAINTAINER = 'logmanoriginal';
+ const CACHE_TIMEOUT = 86400; // 24 hours
+
+ const PARAMETERS = array(
+ self::CATEGORY_NEWS => array(
+ 'news_lang' => array(
+ 'name' => 'Language',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Select your language',
+ 'defaultValue' => 'Corporate',
+ 'values' => array(
+ 'Corporate'
+ => 'https://www.desouttertools.com/about-desoutter/news-events',
+ 'Česko'
+ => 'https://www.desouttertools.cz/o-desoutter/aktuality-udalsoti',
+ 'Deutschland'
+ => 'https://www.desoutter.de/ueber-desoutter/news-events',
+ 'España'
+ => 'https://www.desouttertools.es/sobre-desoutter/noticias-eventos',
+ 'México'
+ => 'https://www.desouttertools.mx/acerca-desoutter/noticias-eventos',
+ 'France'
+ => 'https://www.desouttertools.fr/a-propos-de-desoutter/actualites-evenements',
+ 'Magyarország'
+ => 'https://www.desouttertools.hu/a-desoutter-vallalatrol/hirek-esemenyek',
+ 'Italia'
+ => 'https://www.desouttertools.it/su-desoutter/news-eventi',
+ '日本'
+ => 'https://www.desouttertools.jp/desotanituite/niyusu-ibento',
+ '대한민국'
+ => 'https://www.desouttertools.co.kr/desoteoe-daehaeseo/nyuseu-mic-ibenteu',
+ 'Polska'
+ => 'https://www.desouttertools.pl/o-desoutter/aktualnosci-wydarzenia',
+ 'Brasil'
+ => 'https://www.desouttertools.com.br/sobre-desoutter/noti%C2%ADcias-eventos',
+ 'Portugal'
+ => 'https://www.desouttertools.pt/sobre-desoutter/notIcias-eventos',
+ 'România'
+ => 'https://www.desouttertools.ro/despre-desoutter/noutati-evenimente',
+ 'Российская Федерация'
+ => 'https://www.desouttertools.com.ru/o-desoutter/novosti-mieropriiatiia',
+ 'Slovensko'
+ => 'https://www.desouttertools.sk/o-spolocnosti-desoutter/novinky-udalosti',
+ 'Slovenija'
+ => 'https://www.desouttertools.si/o-druzbi-desoutter/novice-dogodki',
+ 'Sverige'
+ => 'https://www.desouttertools.se/om-desoutter/nyheter-evenemang',
+ 'Türkiye'
+ => 'https://www.desoutter.com.tr/desoutter-hakkinda/haberler-etkinlikler',
+ '中国'
+ => 'https://www.desouttertools.com.cn/guan-yu-ma-tou/xin-wen-he-huo-dong',
+ )
+ ),
+ ),
+ self::CATEGORY_INDUSTRY => array(
+ 'industry_lang' => array(
+ 'name' => 'Language',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Select your language',
+ 'defaultValue' => 'Corporate',
+ 'values' => array(
+ 'Corporate'
+ => 'https://www.desouttertools.com/industry-4-0/news',
+ 'Česko'
+ => 'https://www.desouttertools.cz/prumysl-4-0/novinky',
+ 'Deutschland'
+ => 'https://www.desoutter.de/industrie-4-0/news',
+ 'España'
+ => 'https://www.desouttertools.es/industria-4-0/noticias',
+ 'México'
+ => 'https://www.desouttertools.mx/industria-4-0/noticias',
+ 'France'
+ => 'https://www.desouttertools.fr/industrie-4-0/actualites',
+ 'Magyarország'
+ => 'https://www.desouttertools.hu/industry-4-0/hirek',
+ 'Italia'
+ => 'https://www.desouttertools.it/industry-4-0/news',
+ '日本'
+ => 'https://www.desouttertools.jp/industry-4-0/news',
+ '대한민국'
+ => 'https://www.desouttertools.co.kr/industry-4-0/news',
+ 'Polska'
+ => 'https://www.desouttertools.pl/przemysl-4-0/wiadomosci',
+ 'Brasil'
+ => 'https://www.desouttertools.com.br/industria-4-0/noticias',
+ 'Portugal'
+ => 'https://www.desouttertools.pt/industria-4-0/noticias',
+ 'România'
+ => 'https://www.desouttertools.ro/industry-4-0/noutati',
+ 'Российская Федерация'
+ => 'https://www.desouttertools.com.ru/industry-4-0/news',
+ 'Slovensko'
+ => 'https://www.desouttertools.sk/priemysel-4-0/novinky',
+ 'Slovenija'
+ => 'https://www.desouttertools.si/industrija-4-0/novice',
+ 'Sverige'
+ => 'https://www.desouttertools.se/industri-4-0/nyheter',
+ 'Türkiye'
+ => 'https://www.desoutter.com.tr/endustri-4-0/haberler',
+ '中国'
+ => 'https://www.desouttertools.com.cn/industry-4-0/news',
+ )
+ ),
+ ),
+ 'global' => array(
+ 'full' => array(
+ 'name' => 'Load full articles',
+ 'type' => 'checkbox',
+ 'required' => false,
+ 'title' => 'Enable to load the full article for each item'
+ )
+ )
+ );
+
+ private $title;
+
+ public function getURI() {
+ switch($this->queriedContext) {
+ case self::CATEGORY_NEWS:
+ return $this->getInput('news_lang') ?: parent::getURI();
+ case self::CATEGORY_INDUSTRY:
+ return $this->getInput('industry_lang') ?: parent::getURI();
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName() {
+ return isset($this->title) ? $this->title . ' - ' . parent::getName() : parent::getName();
+ }
+
+ public function collectData() {
+
+ // Uncomment to generate list of languages automtically (dev mode)
+ /*
+ switch($this->queriedContext) {
+ case self::CATEGORY_NEWS:
+ $this->extractNewsLanguages(); die;
+ case self::CATEGORY_INDUSTRY:
+ $this->extractIndustryLanguages(); die;
+ }
+ */
+
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request ' . $this->getURI());
+
+ $html = defaultLinkTo($html, $this->getURI());
+
+ $this->title = html_entity_decode($html->find('title', 0)->plaintext, ENT_QUOTES);
+
+ foreach($html->find('article') as $article) {
+ $item = array();
+
+ $item['uri'] = $article->find('[itemprop="name"]', 0)->href;
+ $item['title'] = $article->find('[itemprop="name"]', 0)->title;
+
+ if($this->getInput('full')) {
+ $item['content'] = $this->getFullNewsArticle($item['uri']);
+ } else {
+ $item['content'] = $article->find('[itemprop="description"]', 0)->plaintext;
+ }
+
+ $this->items[] = $item;
+ }
+
+ }
+
+ private function getFullNewsArticle($uri) {
+ $html = getSimpleHTMLDOMCached($uri)
+ or returnServerError('Unable to load full article!');
+
+ $html = defaultLinkTo($html, $this->getURI());
+
+ return $html->find('section.article', 0);
+ }
+
+ /**
+ * Generates a HTML page with a PHP formatted array of languages,
+ * pointing to the corresponding news pages. Implementation is based
+ * on the 'Corporate' site.
+ * @return void
+ */
+ private function extractNewsLanguages() {
+ $html = getSimpleHTMLDOMCached('https://www.desouttertools.com/about-desoutter/news-events')
+ or returnServerError('Error loading news!');
+
+ $html = defaultLinkTo($html, static::URI);
+
+ $items = $html->find('ul[class="dropdown-menu"] li');
+
+ $list = "\t'Corporate'\n\t=> 'https://www.desouttertools.com/about-desoutter/news-events',\n";
+
+ foreach($items as $item) {
+ $lang = trim($item->plaintext);
+ $uri = $item->find('a', 0)->href;
+
+ $list .= "\t'{$lang}'\n\t=> '{$uri}',\n";
+ }
+
+ echo $list;
+ }
+
+ /**
+ * Generates a HTML page with a PHP formatted array of languages,
+ * pointing to the corresponding news pages. Implementation is based
+ * on the 'Corporate' site.
+ * @return void
+ */
+ private function extractIndustryLanguages() {
+ $html = getSimpleHTMLDOMCached('https://www.desouttertools.com/industry-4-0/news')
+ or returnServerError('Error loading news!');
+
+ $html = defaultLinkTo($html, static::URI);
+
+ $items = $html->find('ul[class="dropdown-menu"] li');
+
+ $list = "\t'Corporate'\n\t=> 'https://www.desouttertools.com/industry-4-0/news',\n";
+
+ foreach($items as $item) {
+ $lang = trim($item->plaintext);
+ $uri = $item->find('a', 0)->href;
+
+ $list .= "\t'{$lang}'\n\t=> '{$uri}',\n";
+ }
+
+ echo $list;
+ }
+}
diff --git a/bridges/DevToBridge.php b/bridges/DevToBridge.php
new file mode 100644
index 0000000..868ac97
--- /dev/null
+++ b/bridges/DevToBridge.php
@@ -0,0 +1,103 @@
+<?php
+class DevToBridge extends BridgeAbstract {
+
+ const CONTEXT_BY_TAG = 'By tag';
+
+ const NAME = 'dev.to Bridge';
+ const URI = 'https://dev.to';
+ const DESCRIPTION = 'Returns feeds for tags';
+ const MAINTAINER = 'logmanoriginal';
+ const CACHE_TIMEOUT = 10800; // 15 min.
+
+ const PARAMETERS = array(
+ self::CONTEXT_BY_TAG => array(
+ 'tag' => array(
+ 'name' => 'Tag',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Insert your tag',
+ 'exampleValue' => 'python'
+ ),
+ 'full' => array(
+ 'name' => 'Full article',
+ 'type' => 'checkbox',
+ 'required' => false,
+ 'title' => 'Enable to receive the full article for each item'
+ )
+ )
+ );
+
+ public function getURI() {
+ switch($this->queriedContext) {
+ case self::CONTEXT_BY_TAG:
+ if($tag = $this->getInput('tag')) {
+ return static::URI . '/t/' . urlencode($tag);
+ }
+ break;
+ }
+
+ return parent::getURI();
+ }
+
+ public function getIcon() {
+ return 'https://practicaldev-herokuapp-com.freetls.fastly.net/assets/
+apple-icon-5c6fa9f2bce280428589c6195b7f1924206a53b782b371cfe2d02da932c8c173.png';
+ }
+
+ public function collectData() {
+
+ $html = getSimpleHTMLDOMCached($this->getURI())
+ or returnServerError('Could not request ' . $this->getURI());
+
+ $html = defaultLinkTo($html, static::URI);
+
+ $articles = $html->find('div[class="single-article"]')
+ or returnServerError('Could not find articles!');
+
+ foreach($articles as $article) {
+
+ if($article->find('[class*="cta"]', 0)) { // Skip ads
+ continue;
+ }
+
+ $item = array();
+
+ $item['uri'] = $article->find('a[id*=article-link]', 0)->href;
+ $item['title'] = $article->find('h3', 0)->plaintext;
+
+ // i.e. "Charlie Harrington・Sep 21"
+ $item['timestamp'] = strtotime(explode('・', $article->find('h4 a', 0)->plaintext, 2)[1]);
+ $item['author'] = explode('・', $article->find('h4 a', 0)->plaintext, 2)[0];
+
+ // Profile image
+ $item['enclosures'] = array($article->find('img', 0)->src);
+
+ if($this->getInput('full')) {
+ $fullArticle = $this->getFullArticle($item['uri']);
+ $item['content'] = <<<EOD
+<img src="{$item['enclosures'][0]}" alt="{$item['author']}">
+<p>{$fullArticle}</p>
+EOD;
+ } else {
+ $item['content'] = <<<EOD
+<img src="{$item['enclosures'][0]}" alt="{$item['author']}">
+<p>{$item['title']}</p>
+EOD;
+ }
+
+ $item['categories'] = array_map(function($e){ return $e->plaintext; }, $article->find('div.tags span.tag'));
+
+ $this->items[] = $item;
+ }
+
+ }
+
+ private function getFullArticle($url) {
+ $html = getSimpleHTMLDOMCached($url)
+ or returnServerError('Unable to load article from "' . $url . '"!');
+
+ $html = defaultLinkTo($html, static::URI);
+
+ return $html->find('[id="article-body"]', 0);
+ }
+}
diff --git a/bridges/DeveloppezDotComBridge.php b/bridges/DeveloppezDotComBridge.php
new file mode 100644
index 0000000..5719cf3
--- /dev/null
+++ b/bridges/DeveloppezDotComBridge.php
@@ -0,0 +1,47 @@
+<?php
+class DeveloppezDotComBridge extends FeedExpander {
+
+ const MAINTAINER = 'polopollo';
+ const NAME = 'Developpez.com Actus (FR)';
+ const URI = 'https://www.developpez.com/';
+ const CACHE_TIMEOUT = 1800; // 30min
+ const DESCRIPTION = 'Returns the 15 newest posts from DeveloppezDotCom (full text).';
+
+ public function collectData(){
+ $this->collectExpandableDatas(self::URI . 'index/rss', 15);
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+ $item['content'] = $this->extractContent($item['uri']);
+ return $item;
+ }
+
+ // F***ing quotes from Microsoft Word badly encoded, here was the trick:
+ // http://stackoverflow.com/questions/1262038/how-to-replace-microsoft-encoded-quotes-in-php
+ private function convertSmartQuotes($string)
+ {
+ $search = array(chr(145),
+ chr(146),
+ chr(147),
+ chr(148),
+ chr(151));
+
+ $replace = array(
+ "'",
+ "'",
+ '"',
+ '"',
+ '-'
+ );
+
+ return str_replace($search, $replace, $string);
+ }
+
+ private function extractContent($url){
+ $articleHTMLContent = getSimpleHTMLDOMCached($url);
+ $text = $this->convertSmartQuotes($articleHTMLContent->find('div.content', 0)->innertext);
+ $text = utf8_encode($text);
+ return trim($text);
+ }
+}
diff --git a/bridges/DiceBridge.php b/bridges/DiceBridge.php
new file mode 100644
index 0000000..11218df
--- /dev/null
+++ b/bridges/DiceBridge.php
@@ -0,0 +1,124 @@
+<?php
+class DiceBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'rogerdc';
+ const NAME = 'Dice Unofficial RSS';
+ const URI = 'https://www.dice.com/';
+ const DESCRIPTION = 'The Unofficial Dice RSS';
+ // const CACHE_TIMEOUT = 86400; // 1 day
+
+ const PARAMETERS = array(array(
+ 'for_one' => array(
+ 'name' => 'With at least one of the words',
+ 'required' => false,
+ ),
+ 'for_all' => array(
+ 'name' => 'With all of the words',
+ 'required' => false,
+ ),
+ 'for_exact' => array(
+ 'name' => 'With the exact phrase',
+ 'required' => false,
+ ),
+ 'for_none' => array(
+ 'name' => 'With none of these words',
+ 'required' => false,
+ ),
+ 'for_jt' => array(
+ 'name' => 'Within job title',
+ 'required' => false,
+ ),
+ 'for_com' => array(
+ 'name' => 'Within company name',
+ 'required' => false,
+ ),
+ 'for_loc' => array(
+ 'name' => 'City, State, or ZIP code',
+ 'required' => false,
+ ),
+ 'radius' => array(
+ 'name' => 'Radius in miles',
+ 'type' => 'list',
+ 'required' => false,
+ 'values' => array(
+ 'Exact Location' => 'El',
+ 'Within 5 miles' => '5',
+ 'Within 10 miles' => '10',
+ 'Within 20 miles' => '20',
+ 'Within 30 miles' => '0',
+ 'Within 40 miles' => '40',
+ 'Within 50 miles' => '50',
+ 'Within 75 miles' => '75',
+ 'Within 100 miles' => '100',
+ ),
+ 'defaultValue' => '0',
+ ),
+ 'jtype' => array(
+ 'name' => 'Job type',
+ 'type' => 'list',
+ 'required' => false,
+ 'values' => array(
+ 'Full-Time' => 'Full Time',
+ 'Part-Time' => 'Part Time',
+ 'Contract - Independent' => 'Contract Independent',
+ 'Contract - W2' => 'Contract W2',
+ 'Contract to Hire - Independent' => 'C2H Independent',
+ 'Contract to Hire - W2' => 'C2H W2',
+ 'Third Party - Contract - Corp-to-Corp' => 'Contract Corp-To-Corp',
+ 'Third Party - Contract to Hire - Corp-to-Corp' => 'C2H Corp-To-Corp',
+ ),
+ 'defaultValue' => 'Full Time',
+ ),
+ 'telecommute' => array(
+ 'name' => 'Telecommute',
+ 'type' => 'checkbox',
+ ),
+ ));
+
+ public function getIcon() {
+ return 'https://assets.dice.com/techpro/img/favicons/favicon.ico';
+ }
+
+ public function collectData() {
+ $uri = 'https://www.dice.com/jobs/advancedResult.html';
+ $uri .= '?for_one=' . urlencode($this->getInput('for_one'));
+ $uri .= '&for_all=' . urlencode($this->getInput('for_all'));
+ $uri .= '&for_exact=' . urlencode($this->getInput('for_exact'));
+ $uri .= '&for_none=' . urlencode($this->getInput('for_none'));
+ $uri .= '&for_jt=' . urlencode($this->getInput('for_jt'));
+ $uri .= '&for_com=' . urlencode($this->getInput('for_com'));
+ $uri .= '&for_loc=' . urlencode($this->getInput('for_loc'));
+ if ($this->getInput('jtype')) {
+ $uri .= '&jtype=' . urlencode($this->getInput('jtype'));
+ }
+ $uri .= '&sort=date&limit=100';
+ $uri .= '&radius=' . urlencode($this->getInput('radius'));
+ if ($this->getInput('telecommute')) {
+ $uri .= '&telecommute=true';
+ }
+
+ $html = getSimpleHTMLDOM($uri)
+ or returnServerError('Could not request Dice.');
+ foreach($html->find('div.complete-serp-result-div') as $element) {
+ $item = array();
+ // Title
+ $masterLink = $element->find('a[id^=position]', 0);
+ $item['title'] = $masterLink->title;
+ // URL
+ $uri = $masterLink->href;
+ // $uri = substr($uri, 0, strrpos($uri, '?'));
+ $item['uri'] = substr($uri, 0, strrpos($uri, '?'));
+ // ID
+ $item['id'] = $masterLink->value;
+ // Image
+ $image = $element->find('img', 0);
+ if ($image)
+ $item['image'] = $image->getAttribute('src');
+ // Content
+ $shortdesc = $element->find('.shortdesc', '0');
+ $shortdesc = ($shortdesc) ? $shortdesc->innertext : '';
+ $item['content'] = $shortdesc;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/DilbertBridge.php b/bridges/DilbertBridge.php
new file mode 100644
index 0000000..7069ee4
--- /dev/null
+++ b/bridges/DilbertBridge.php
@@ -0,0 +1,36 @@
+<?php
+class DilbertBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'kranack';
+ const NAME = 'Dilbert Daily Strip';
+ const URI = 'https://dilbert.com';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'The Unofficial Dilbert Daily Comic Strip';
+
+ public function collectData(){
+
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request Dilbert: ' . self::URI);
+
+ foreach($html->find('section.comic-item') as $element) {
+
+ $img = $element->find('img', 0);
+ $link = $element->find('a', 0);
+ $comic = $img->src;
+ $title = $img->alt;
+ $url = $link->href;
+ $date = substr(strrchr($url, '/'), 1);
+ if (empty($title))
+ $title = 'Dilbert Comic Strip on ' . $date;
+ $date = strtotime($date);
+
+ $item = array();
+ $item['uri'] = $url;
+ $item['title'] = $title;
+ $item['author'] = 'Scott Adams';
+ $item['timestamp'] = $date;
+ $item['content'] = '<img src="' . $comic . '" alt="' . $img->alt . '" />';
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/DiscogsBridge.php b/bridges/DiscogsBridge.php
new file mode 100644
index 0000000..ac128b1
--- /dev/null
+++ b/bridges/DiscogsBridge.php
@@ -0,0 +1,116 @@
+<?php
+
+class DiscogsBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene';
+ const NAME = 'DiscogsBridge';
+ const URI = 'https://www.discogs.com/';
+ const DESCRIPTION = 'Returns releases from discogs';
+ const PARAMETERS = array(
+ 'Artist Releases' => array(
+ 'artistid' => array(
+ 'name' => 'Artist ID',
+ 'type' => 'number',
+ )
+ ),
+ 'Label Releases' => array(
+ 'labelid' => array(
+ 'name' => 'Label ID',
+ 'type' => 'number',
+ )
+ ),
+ 'User Wantlist' => array(
+ 'username_wantlist' => array(
+ 'name' => 'Username',
+ 'type' => 'text',
+ )
+ ),
+ 'User Folder' => array(
+ 'username_folder' => array(
+ 'name' => 'Username',
+ 'type' => 'text',
+ ),
+ 'folderid' => array(
+ 'name' => 'Folder ID',
+ 'type' => 'number',
+ )
+ )
+ );
+
+ public function collectData() {
+
+ if(!empty($this->getInput('artistid')) || !empty($this->getInput('labelid'))) {
+
+ if(!empty($this->getInput('artistid'))) {
+ $data = getContents('https://api.discogs.com/artists/'
+ . $this->getInput('artistid')
+ . '/releases?sort=year&sort_order=desc')
+ or returnServerError('Unable to query discogs !');
+ } elseif(!empty($this->getInput('labelid'))) {
+ $data = getContents('https://api.discogs.com/labels/'
+ . $this->getInput('labelid')
+ . '/releases?sort=year&sort_order=desc')
+ or returnServerError('Unable to query discogs !');
+ }
+
+ $jsonData = json_decode($data, true);
+ foreach($jsonData['releases'] as $release) {
+
+ $item = array();
+ $item['author'] = $release['artist'];
+ $item['title'] = $release['title'];
+ $item['id'] = $release['id'];
+ $resId = array_key_exists('main_release', $release) ? $release['main_release'] : $release['id'];
+ $item['uri'] = self::URI . $this->getInput('artistid') . '/release/' . $resId;
+
+ if(isset($release['year'])) {
+ $item['timestamp'] = DateTime::createFromFormat('Y', $release['year'])->getTimestamp();
+ }
+
+ $item['content'] = $item['author'] . ' - ' . $item['title'];
+ $this->items[] = $item;
+ }
+
+ } elseif(!empty($this->getInput('username_wantlist')) || !empty($this->getInput('username_folder'))) {
+
+ if(!empty($this->getInput('username_wantlist'))) {
+ $data = getContents('https://api.discogs.com/users/'
+ . $this->getInput('username_wantlist')
+ . '/wants?sort=added&sort_order=desc')
+ or returnServerError('Unable to query discogs !');
+ $jsonData = json_decode($data, true)['wants'];
+
+ } elseif(!empty($this->getInput('username_folder'))) {
+ $data = getContents('https://api.discogs.com/users/'
+ . $this->getInput('username_folder')
+ . '/collection/folders/'
+ . $this->getInput('folderid')
+ . '/releases?sort=added&sort_order=desc')
+ or returnServerError('Unable to query discogs !');
+ $jsonData = json_decode($data, true)['releases'];
+ }
+ foreach($jsonData as $element) {
+
+ $infos = $element['basic_information'];
+ $item = array();
+ $item['title'] = $infos['title'];
+ $item['author'] = $infos['artists'][0]['name'];
+ $item['id'] = $infos['artists'][0]['id'];
+ $item['uri'] = self::URI . $infos['artists'][0]['id'] . '/release/' . $infos['id'];
+ $item['timestamp'] = strtotime($element['date_added']);
+ $item['content'] = $item['author'] . ' - ' . $item['title'];
+ $this->items[] = $item;
+
+ }
+ }
+
+ }
+
+ public function getURI() {
+ return self::URI;
+ }
+
+ public function getName() {
+ return static::NAME;
+ }
+}
diff --git a/bridges/DollbooruBridge.php b/bridges/DollbooruBridge.php
new file mode 100644
index 0000000..5ed4119
--- /dev/null
+++ b/bridges/DollbooruBridge.php
@@ -0,0 +1,9 @@
+<?php
+require_once('Shimmie2Bridge.php');
+
+class DollbooruBridge extends Shimmie2Bridge {
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Dollbooru';
+ const URI = 'http://dollbooru.org/';
+ const DESCRIPTION = 'Returns images from given page';
+}
diff --git a/bridges/DribbbleBridge.php b/bridges/DribbbleBridge.php
new file mode 100644
index 0000000..5058da6
--- /dev/null
+++ b/bridges/DribbbleBridge.php
@@ -0,0 +1,96 @@
+<?php
+class DribbbleBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'quentinus95';
+ const NAME = 'Dribbble popular shots';
+ const URI = 'https://dribbble.com';
+ const CACHE_TIMEOUT = 1800;
+ const DESCRIPTION = 'Returns the newest popular shots from Dribbble.';
+
+ public function getIcon() {
+ return 'https://cdn.dribbble.com/assets/
+favicon-63b2904a073c89b52b19aa08cebc16a154bcf83fee8ecc6439968b1e6db569c7.ico';
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI . '/shots')
+ or returnServerError('Error while downloading the website content');
+
+ $json = $this->loadEmbeddedJsonData($html);
+
+ foreach($html->find('li[id^="screenshot-"]') as $shot) {
+ $item = [];
+
+ $additional_data = $this->findJsonForShot($shot, $json);
+ if ($additional_data === null) {
+ $item['uri'] = self::URI . $shot->find('a', 0)->href;
+ $item['title'] = $shot->find('.dribbble-over strong', 0)->plaintext;
+ } else {
+ $item['timestamp'] = strtotime($additional_data['published_at']);
+ $item['uri'] = self::URI . $additional_data['path'];
+ $item['title'] = $additional_data['title'];
+ }
+
+ $item['author'] = trim($shot->find('.attribution-user a', 0)->plaintext);
+
+ $description = $shot->find('.comment', 0);
+ $item['content'] = $description === null ? '' : $description->plaintext;
+
+ $preview_path = $shot->find('picture source', 0)->attr['srcset'];
+ $item['content'] .= $this->getImageTag($preview_path, $item['title']);
+ $item['enclosures'] = [$this->getFullSizeImagePath($preview_path)];
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function loadEmbeddedJsonData($html){
+ $json = [];
+ $scripts = $html->find('script');
+
+ foreach($scripts as $script) {
+ if(strpos($script->innertext, 'newestShots') !== false) {
+ // fix single quotes
+ $script->innertext = str_replace('\'', '"', $script->innertext);
+
+ // fix JavaScript JSON (why do they not adhere to the standard?)
+ $script->innertext = preg_replace('/(\w+):/i', '"\1":', $script->innertext);
+
+ // find beginning of JSON array
+ $start = strpos($script->innertext, '[');
+
+ // find end of JSON array, compensate for missing character!
+ $end = strpos($script->innertext, '];') + 1;
+
+ // convert JSON to PHP array
+ $json = json_decode(substr($script->innertext, $start, $end - $start), true);
+ break;
+ }
+ }
+
+ return $json;
+ }
+
+ private function findJsonForShot($shot, $json){
+ foreach($json as $element) {
+ if(strpos($shot->getAttribute('id'), (string)$element['id']) !== false) {
+ return $element;
+ }
+ }
+
+ return null;
+ }
+
+ private function getImageTag($preview_path, $title){
+ return sprintf(
+ '<br /> <a href="%s"><img src="%s" alt="%s" /></a>',
+ $this->getFullSizeImagePath($preview_path),
+ $preview_path,
+ $title
+ );
+ }
+
+ private function getFullSizeImagePath($preview_path){
+ return str_replace('_1x', '', $preview_path);
+ }
+}
diff --git a/bridges/DuckDuckGoBridge.php b/bridges/DuckDuckGoBridge.php
new file mode 100644
index 0000000..8533be5
--- /dev/null
+++ b/bridges/DuckDuckGoBridge.php
@@ -0,0 +1,42 @@
+<?php
+class DuckDuckGoBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Astalaseven';
+ const NAME = 'DuckDuckGo';
+ const URI = 'https://duckduckgo.com/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Returns results from DuckDuckGo.';
+
+ const SORT_DATE = '+sort:date';
+ const SORT_RELEVANCE = '';
+
+ const PARAMETERS = array( array(
+ 'u' => array(
+ 'name' => 'keyword',
+ 'required' => true
+ ),
+ 'sort' => array(
+ 'name' => 'sort by',
+ 'type' => 'list',
+ 'required' => false,
+ 'values' => array(
+ 'date' => self::SORT_DATE,
+ 'relevance' => self::SORT_RELEVANCE
+ ),
+ 'defaultValue' => self::SORT_DATE
+ )
+ ));
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI . 'html/?kd=-1&q=' . $this->getInput('u') . $this->getInput('sort'))
+ or returnServerError('Could not request DuckDuckGo.');
+
+ foreach($html->find('div.results_links') as $element) {
+ $item = array();
+ $item['uri'] = $element->find('a', 0)->href;
+ $item['title'] = $element->find('a', 1)->innertext;
+ $item['content'] = $element->find('div.snippet', 0)->plaintext;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/ETTVBridge.php b/bridges/ETTVBridge.php
new file mode 100644
index 0000000..c348ca0
--- /dev/null
+++ b/bridges/ETTVBridge.php
@@ -0,0 +1,161 @@
+<?php
+class ETTVBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'GregThib';
+ const NAME = 'ETTV';
+ const URI = 'https://www.ettv.tv/';
+ const DESCRIPTION = 'Returns list of 20 latest torrents for a specific search.';
+ const CACHE_TIMEOUT = 14400; // 4 hours
+
+ const PARAMETERS = array( array(
+ 'query' => array(
+ 'name' => 'Keywords',
+ 'required' => true
+ ),
+ 'cat' => array(
+ 'type' => 'list',
+ 'name' => 'Category',
+ 'values' => array(
+ '(ALL TYPES)' => '0',
+ 'Anime: Movies' => '73',
+ 'Anime: Dubbed/Subbed' => '74',
+ 'Anime: Others' => '75',
+ 'Books: Ebooks' => '53',
+ 'Books: Magazines' => '54',
+ 'Books: Comics' => '55',
+ 'Books: Audio' => '56',
+ 'Books: Others' => '68',
+ 'Games: Windows' => '57',
+ 'Games: Android' => '58',
+ 'Games: Others' => '71',
+ 'Movies: HD 1080p' => '1',
+ 'Movies: HD 720p' => '2',
+ 'Movies: UltraHD/4K' => '3',
+ 'Movies: XviD' => '42',
+ 'Movies: X264/H264' => '47',
+ 'Movies: 3D' => '49',
+ 'Movies: Dubs/Dual Audio' => '51',
+ 'Movies: CAM/TS' => '65',
+ 'Movies: BluRay Disc/Remux' => '66',
+ 'Movies: DVDR' => '67',
+ 'Movies: HEVC/x265' => '76',
+ 'Music: MP3' => '59',
+ 'Music: FLAC' => '60',
+ 'Music: Music Videos' => '61',
+ 'Music: Others' => '69',
+ 'Software: Windows' => '62',
+ 'Software: Android' => '63',
+ 'Software: Mac' => '64',
+ 'Software: Others' => '70',
+ 'TV: HD/X264/H264' => '41',
+ 'TV: SD/X264/H264' => '5',
+ 'TV: TV Packs' => '7',
+ 'TV: SD/XVID' => '50',
+ 'TV: Sport' => '72',
+ 'TV: HEVC/x265' => '77',
+ 'Unsorted: Unsorted' => '78'
+ ),
+ 'defaultValue' => '(ALL TYPES)'
+ ),
+ 'status' => array(
+ 'type' => 'list',
+ 'name' => 'Status',
+ 'values' => array(
+ 'Active Transfers' => '0',
+ 'Included Dead' => '1',
+ 'Only Dead' => '2'
+ ),
+ 'defaultValue' => 'Included Dead'
+ ),
+ 'lang' => array(
+ 'type' => 'list',
+ 'name' => 'Lang',
+ 'values' => array(
+ '(ALL)' => '0',
+ 'Arabic' => '17',
+ 'Chinese ' => '10',
+ 'Danish' => '13',
+ 'Dutch' => '11',
+ 'English' => '1',
+ 'Finnish' => '18',
+ 'French' => '2',
+ 'German' => '3',
+ 'Greek' => '15',
+ 'Hindi' => '8',
+ 'Italian' => '4',
+ 'Japanese' => '5',
+ 'Korean' => '9',
+ 'Polish' => '14',
+ 'Russian' => '7',
+ 'Spanish' => '6',
+ 'Turkish' => '16'
+ ),
+ 'defaultValue' => '(ALL)'
+ )
+ ));
+
+ protected $results_link;
+
+ public function collectData(){
+ // No control on inputs, because all defaultValue are set
+ $query_str = 'torrents-search.php';
+ $query_str .= '?search=' . urlencode('+' . str_replace(' ', ' +', $this->getInput('query')));
+ $query_str .= '&cat=' . $this->getInput('cat');
+ $query_str .= '&incldead=' . $this->getInput('status');
+ $query_str .= '&lang=' . $this->getInput('lang');
+ $query_str .= '&sort=id&order=desc';
+
+ // Get results page
+ $this->results_link = self::URI . $query_str;
+ $html = getSimpleHTMLDOM($this->results_link)
+ or returnServerError('Could not request ' . $this->getName());
+
+ // Loop on each entry
+ foreach($html->find('table.table tr') as $element) {
+ if($element->parent->tag == 'thead') continue;
+ $entry = $element->find('td', 1)->find('a', 0);
+
+ // retrieve result page to get more details
+ $link = rtrim(self::URI, '/') . $entry->href;
+ $page = getSimpleHTMLDOM($link)
+ or returnServerError('Could not request page ' . $link);
+
+ // get details & download links
+ $details = $page->find('fieldset.download table', 0); // WHAT?? It should be the second one…
+ $dllinks = $page->find('div#downloadbox table', 0);
+
+ // fill item
+ $item = array();
+ $item['author'] = $details->children(6)->children(1)->plaintext;
+ $item['title'] = $entry->title;
+ $item['uri'] = $link;
+ $item['timestamp'] = strtotime($details->children(7)->children(1)->plaintext);
+ $item['content'] = '';
+ $item['content'] .= '<br/><b>Name: </b>' . $details->children(0)->children(1)->innertext;
+ $item['content'] .= '<br/><b>Lang: </b>' . $details->children(3)->children(1)->innertext;
+ $item['content'] .= '<br/><b>Size: </b>' . $details->children(4)->children(1)->innertext;
+ $item['content'] .= '<br/><b>Hash: </b>' . $details->children(5)->children(1)->innertext;
+ foreach($dllinks->children(0)->children(1)->find('a') as $dl) {
+ $item['content'] .= '<br/>' . $dl->outertext;
+ }
+ $item['content'] .= '<br/><br/>' . $details->children(1)->children(0)->innertext;
+ $this->items[] = $item;
+ }
+ }
+
+ public function getName(){
+ if($this->getInput('query')) {
+ return '[' . self::NAME . '] ' . $this->getInput('query');
+ }
+
+ return self::NAME;
+ }
+
+ public function getURI(){
+ if(isset($this->results_link) && !empty($this->results_link)) {
+ return $this->results_link;
+ }
+
+ return self::URI;
+ }
+}
diff --git a/bridges/EZTVBridge.php b/bridges/EZTVBridge.php
new file mode 100644
index 0000000..c016ff3
--- /dev/null
+++ b/bridges/EZTVBridge.php
@@ -0,0 +1,67 @@
+<?php
+class EZTVBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'alexAubin';
+ const NAME = 'EZTV';
+ const URI = 'https://eztv.ch/';
+ const DESCRIPTION = 'Returns list of *recent* torrents for a specific show
+on EZTV. Get showID from URLs in https://eztv.ch/shows/showID/show-full-name.';
+
+ const PARAMETERS = array( array(
+ 'i' => array(
+ 'name' => 'Show ids',
+ 'exampleValue' => 'showID1,showID2,…',
+ 'required' => true
+ )
+ ));
+
+ public function collectData(){
+
+ // Make timestamp from relative released time in table
+ function makeTimestamp($relativeReleaseTime){
+
+ $relativeDays = 0;
+ $relativeHours = 0;
+
+ foreach(explode(' ', $relativeReleaseTime) as $relativeTimeElement) {
+ if(substr($relativeTimeElement, -1) == 'd') $relativeDays = substr($relativeTimeElement, 0, -1);
+ if(substr($relativeTimeElement, -1) == 'h') $relativeHours = substr($relativeTimeElement, 0, -1);
+ }
+ return mktime(date('h') - $relativeHours, 0, 0, date('m'), date('d') - $relativeDays, date('Y'));
+ }
+
+ // Loop on show ids
+ $showList = explode(',', $this->getInput('i'));
+ foreach($showList as $showID) {
+
+ // Get show page
+ $html = getSimpleHTMLDOM(self::URI . 'shows/' . rawurlencode($showID) . '/')
+ or returnServerError('Could not request EZTV for id "' . $showID . '"');
+
+ // Loop on each element that look like an episode entry...
+ foreach($html->find('.forum_header_border') as $element) {
+
+ // Filter entries that are not episode entries
+ $ep = $element->find('td', 1);
+ if(empty($ep)) continue;
+ $epinfo = $ep->find('.epinfo', 0);
+ $released = $element->find('td', 3);
+ if(empty($epinfo)) continue;
+ if(empty($released->plaintext)) continue;
+
+ // Filter entries that are older than 1 week
+ if($released->plaintext == '&gt;1 week') continue;
+
+ // Fill item
+ $item = array();
+ $item['uri'] = self::URI . $epinfo->href;
+ $item['id'] = $item['uri'];
+ $item['timestamp'] = makeTimestamp($released->plaintext);
+ $item['title'] = $epinfo->plaintext;
+ $item['content'] = $epinfo->alt;
+ if(isset($item['title']))
+ $this->items[] = $item;
+ }
+ }
+ }
+}
diff --git a/bridges/EliteDangerousGalnetBridge.php b/bridges/EliteDangerousGalnetBridge.php
new file mode 100644
index 0000000..dc6077b
--- /dev/null
+++ b/bridges/EliteDangerousGalnetBridge.php
@@ -0,0 +1,51 @@
+<?php
+class EliteDangerousGalnetBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'corenting';
+ const NAME = 'Elite: Dangerous Galnet';
+ const URI = 'https://community.elitedangerous.com/galnet/';
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'Returns the latest page of news from Galnet';
+ const PARAMETERS = array(
+ array(
+ 'language' => array(
+ 'name' => 'Language',
+ 'type' => 'list',
+ 'values' => array(
+ 'English' => 'en',
+ 'French' => 'fr',
+ 'German' => 'de'
+ ),
+ 'defaultValue' => 'en'
+ )
+ )
+ );
+
+ public function collectData(){
+ $language = $this->getInput('language');
+ $url = 'https://community.elitedangerous.com/';
+ $url = $url . $language . '/galnet';
+ $html = getSimpleHTMLDOM($url)
+ or returnServerError('Error while downloading the website content');
+
+ foreach($html->find('div.article') as $element) {
+ $item = array();
+
+ $uri = $element->find('h3 a', 0)->href;
+ $uri = 'https://community.elitedangerous.com/' . $language . $uri;
+ $item['uri'] = $uri;
+
+ $item['title'] = $element->find('h3 a', 0)->plaintext;
+
+ $content = $element->find('p', -1)->innertext;
+ $item['content'] = $content;
+
+ $date = $element->find('p.small', 0)->innertext;
+ $article_year = substr($date, -4) - 1286; //Convert E:D date to actual date
+ $date = substr($date, 0, -4) . $article_year;
+ $item['timestamp'] = strtotime($date);
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/ElloBridge.php b/bridges/ElloBridge.php
new file mode 100644
index 0000000..22f5d30
--- /dev/null
+++ b/bridges/ElloBridge.php
@@ -0,0 +1,146 @@
+<?php
+class ElloBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene';
+ const NAME = 'Ello Bridge';
+ const URI = 'https://ello.co/';
+ const CACHE_TIMEOUT = 4800; //2hours
+ const DESCRIPTION = 'Returns the newest posts for Ello';
+
+ const PARAMETERS = array(
+ 'By User' => array(
+ 'u' => array(
+ 'name' => 'Username',
+ 'required' => true,
+ 'title' => 'Username'
+ )
+ ),
+ 'Search' => array(
+ 's' => array(
+ 'name' => 'Search',
+ 'required' => true,
+ 'title' => 'Search'
+ )
+ )
+ );
+
+ public function collectData() {
+
+ $header = array(
+ 'Authorization: Bearer ' . $this->getAPIKey()
+ );
+
+ if(!empty($this->getInput('u'))) {
+ $postData = getContents(self::URI . 'api/v2/users/~' . urlencode($this->getInput('u')) . '/posts', $header) or
+ returnServerError('Unable to query Ello API.');
+ } else {
+ $postData = getContents(self::URI . 'api/v2/posts?terms=' . urlencode($this->getInput('s')), $header) or
+ returnServerError('Unable to query Ello API.');
+ }
+
+ $postData = json_decode($postData);
+ $count = 0;
+ foreach($postData->posts as $post) {
+
+ $item = array();
+ $item['author'] = $this->getUsername($post, $postData);
+ $item['timestamp'] = strtotime($post->created_at);
+ $item['title'] = strip_tags($this->findText($post->summary));
+ $item['content'] = $this->getPostContent($post->body);
+ $item['enclosures'] = $this->getEnclosures($post, $postData);
+ $item['uri'] = self::URI . $item['author'] . '/post/' . $post->token;
+ $content = $post->body;
+
+ $this->items[] = $item;
+ $count += 1;
+
+ }
+
+ }
+
+ private function findText($path) {
+
+ foreach($path as $summaryElement) {
+
+ if($summaryElement->kind == 'text') {
+ return $summaryElement->data;
+ }
+
+ }
+
+ return '';
+
+ }
+
+ private function getPostContent($path) {
+
+ $content = '';
+ foreach($path as $summaryElement) {
+
+ if($summaryElement->kind == 'text') {
+ $content .= $summaryElement->data;
+ } elseif ($summaryElement->kind == 'image') {
+ $alt = '';
+ if(property_exists($summaryElement->data, 'alt')) {
+ $alt = $summaryElement->data->alt;
+ }
+ $content .= '<img src="' . $summaryElement->data->url . '" alt="' . $alt . '" />';
+ }
+
+ }
+
+ return $content;
+
+ }
+
+ private function getEnclosures($post, $postData) {
+
+ $assets = [];
+ foreach($post->links->assets as $asset) {
+ foreach($postData->linked->assets as $assetLink) {
+ if($asset == $assetLink->id) {
+ $assets[] = $assetLink->attachment->original->url;
+ break;
+ }
+ }
+ }
+
+ return $assets;
+
+ }
+
+ private function getUsername($post, $postData) {
+
+ foreach($postData->linked->users as $user) {
+ if($user->id == $post->links->author->id) {
+ return $user->username;
+ }
+ }
+
+ }
+
+ private function getAPIKey() {
+ $cache = Cache::create('FileCache');
+ $cache->setPath(PATH_CACHE);
+ $cache->setParameters(['key']);
+ $key = $cache->loadData();
+
+ if($key == null) {
+ $keyInfo = getContents(self::URI . 'api/webapp-token') or
+ returnServerError('Unable to get token.');
+ $key = json_decode($keyInfo)->token->access_token;
+ $cache->saveData($key);
+ }
+
+ return $key;
+
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('u'))) {
+ return $this->getInput('u') . ' - Ello Bridge';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/ElsevierBridge.php b/bridges/ElsevierBridge.php
new file mode 100644
index 0000000..080fe00
--- /dev/null
+++ b/bridges/ElsevierBridge.php
@@ -0,0 +1,79 @@
+<?php
+class ElsevierBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Pierre Mazière';
+ const NAME = 'Elsevier journals recent articles';
+ const URI = 'http://www.journals.elsevier.com/';
+ const CACHE_TIMEOUT = 43200; //12h
+ const DESCRIPTION = 'Returns the recent articles published in Elsevier journals';
+
+ const PARAMETERS = array( array(
+ 'j' => array(
+ 'name' => 'Journal name',
+ 'required' => true,
+ 'exampleValue' => 'academic-pediactrics',
+ 'title' => 'Insert html-part of your journal'
+ )
+ ));
+
+ // Extracts the list of names from an article as string
+ private function extractArticleName($article){
+ $names = $article->find('small', 0);
+ if($names)
+ return trim($names->plaintext);
+ return '';
+ }
+
+ // Extracts the timestamp from an article
+ private function extractArticleTimestamp($article){
+ $time = $article->find('.article-info', 0);
+ if($time) {
+ $timestring = trim($time->plaintext);
+ /*
+ The format depends on the age of an article:
+ - Available online 29 July 2016
+ - July 2016
+ - May–June 2016
+ */
+ if(preg_match('/\S*(\d+\s\S+\s\d{4})/ims', $timestring, $matches)) {
+ return strtotime($matches[0]);
+ } elseif (preg_match('/[A-Za-z]+\-([A-Za-z]+\s\d{4})/ims', $timestring, $matches)) {
+ return strtotime($matches[0]);
+ } elseif (preg_match('/([A-Za-z]+\s\d{4})/ims', $timestring, $matches)) {
+ return strtotime($matches[0]);
+ } else {
+ return 0;
+ }
+ }
+ return 0;
+ }
+
+ // Extracts the content from an article
+ private function extractArticleContent($article){
+ $content = $article->find('.article-content', 0);
+ if($content) {
+ return trim($content->plaintext);
+ }
+ return '';
+ }
+
+ public function getIcon() {
+ return 'https://cdn.elsevier.io/verona/includes/favicons/favicon-32x32.png';
+ }
+
+ public function collectData(){
+ $uri = self::URI . $this->getInput('j') . '/recent-articles/';
+ $html = getSimpleHTMLDOM($uri)
+ or returnServerError('No results for Elsevier journal ' . $this->getInput('j'));
+
+ foreach($html->find('.pod-listing') as $article) {
+ $item = array();
+ $item['uri'] = $article->find('.pod-listing-header>a', 0)->getAttribute('href') . '?np=y';
+ $item['title'] = $article->find('.pod-listing-header>a', 0)->plaintext;
+ $item['author'] = $this->extractArticleName($article);
+ $item['timestamp'] = $this->extractArticleTimestamp($article);
+ $item['content'] = $this->extractArticleContent($article);
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/EstCeQuonMetEnProdBridge.php b/bridges/EstCeQuonMetEnProdBridge.php
new file mode 100644
index 0000000..4439d69
--- /dev/null
+++ b/bridges/EstCeQuonMetEnProdBridge.php
@@ -0,0 +1,27 @@
+<?php
+class EstCeQuonMetEnProdBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'Est-ce qu\'on met en prod aujourd\'hui ?';
+ const URI = 'https://www.estcequonmetenprodaujourdhui.info/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Should we put a website in production today? (French)';
+
+ public function collectData() {
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request EstCeQuonMetEnProd: ' . self::URI);
+
+ $item = array();
+ $item['uri'] = $this->getURI() . '#' . date('Y-m-d');
+ $item['title'] = $this->getName();
+ $item['author'] = 'Nicolas Hoffmann';
+ $item['timestamp'] = strtotime('today midnight');
+ $item['content'] = str_replace(
+ 'src="/',
+ 'src="' . self::URI,
+ trim(extractFromDelimiters($html->outertext, '<body role="document">', '<div id="share'))
+ );
+
+ $this->items[] = $item;
+ }
+}
diff --git a/bridges/EtsyBridge.php b/bridges/EtsyBridge.php
new file mode 100644
index 0000000..2671797
--- /dev/null
+++ b/bridges/EtsyBridge.php
@@ -0,0 +1,84 @@
+<?php
+class EtsyBridge extends BridgeAbstract {
+
+ const NAME = 'Etsy search';
+ const URI = 'https://www.etsy.com';
+ const DESCRIPTION = 'Returns feeds for search results';
+ const MAINTAINER = 'logmanoriginal';
+ const PARAMETERS = array(
+ array(
+ 'query' => array(
+ 'name' => 'Search query',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Insert your search term here',
+ 'exampleValue' => 'Enter your search term'
+ ),
+ 'queryextension' => array(
+ 'name' => 'Query extension',
+ 'type' => 'text',
+ 'required' => false,
+ 'title' => 'Insert additional query parts here
+(anything after ?search=<your search query>)',
+ 'exampleValue' => '&explicit=1&locationQuery=2921044'
+ ),
+ 'showimage' => array(
+ 'name' => 'Show image in content',
+ 'type' => 'checkbox',
+ 'required' => false,
+ 'title' => 'Activate to show the image in the content',
+ 'defaultValue' => 'checked'
+ )
+ )
+ );
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Failed to receive ' . $this->getURI());
+
+ $results = $html->find('li.block-grid-item');
+
+ foreach($results as $result) {
+ // Skip banner cards (ads for categories)
+ if($result->find('span.ad-indicator'))
+ continue;
+
+ $item = array();
+
+ $item['title'] = $result->find('a', 0)->title;
+ $item['uri'] = $result->find('a', 0)->href;
+ $item['author'] = $result->find('p.text-gray-lighter', 0)->plaintext;
+
+ $item['content'] = '<p>'
+ . $result->find('span.currency-value', 0)->plaintext . ' '
+ . $result->find('span.currency-symbol', 0)->plaintext
+ . '</p><p>'
+ . $result->find('a', 0)->title
+ . '</p>';
+
+ $image = $result->find('img.display-block', 0)->src;
+
+ if($this->getInput('showimage')) {
+ $item['content'] .= '<img src="' . $image . '">';
+ }
+
+ $item['enclosures'] = array($image);
+
+ $this->items[] = $item;
+ }
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('query'))) {
+ $uri = self::URI . '/search?q=' . urlencode($this->getInput('query'));
+
+ if(!is_null($this->getInput('queryextension'))) {
+ $uri .= $this->getInput('queryextension');
+ }
+
+ return $uri;
+ }
+
+ return parent::getURI();
+ }
+}
diff --git a/bridges/ExtremeDownloadBridge.php b/bridges/ExtremeDownloadBridge.php
new file mode 100644
index 0000000..5272997
--- /dev/null
+++ b/bridges/ExtremeDownloadBridge.php
@@ -0,0 +1,103 @@
+<?php
+class ExtremeDownloadBridge extends BridgeAbstract {
+ const NAME = 'Extreme Download';
+ const URI = 'https://ww1.extreme-d0wn.com/';
+ const DESCRIPTION = 'Suivi de série sur Extreme Download';
+ const MAINTAINER = 'sysadminstory';
+ const PARAMETERS = array(
+ 'Suivre la publication des épisodes d\'une série en cours de diffusion' => array(
+ 'url' => array(
+ 'name' => 'URL de la série',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'URL d\'une série sans le https://ww1.extreme-d0wn.com/',
+ 'exampleValue' => 'series-hd/hd-series-vostfr/46631-halt-and-catch-fire-saison-04-vostfr-hdtv-720p.html'),
+ 'filter' => array(
+ 'name' => 'Type de contenu',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Type de contenu à suivre : Téléchargement, Streaming ou les deux',
+ 'values' => array(
+ 'Streaming et Téléchargement' => 'both',
+ 'Téléchargement' => 'download',
+ 'Streaming' => 'streaming'
+ )
+ )
+ )
+ );
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI . $this->getInput('url'))
+ or returnServerError('Could not request Extreme Download.');
+
+ $filter = $this->getInput('filter');
+
+ $typesText = array(
+ 'download' => 'Téléchargement',
+ 'streaming' => 'Streaming'
+ );
+
+ // Get the TV show title
+ $this->showTitle = trim($html->find('span[id=news-title]', 0)->plaintext);
+
+ $list = $html->find('div[class=prez_7]');
+ foreach($list as $element) {
+ $add = false;
+ // Link type is needed is needed to generate an unique link
+ $type = $this->findLinkType($element);
+ if($filter == 'both') {
+ $add = true;
+ } else {
+ if($type == $filter) {
+ $add = true;
+ }
+ }
+ if($add == true) {
+ $item = array();
+
+ // Get the element name
+ $title = $element->plaintext;
+
+ // Get thee element links
+ $links = $element->next_sibling()->innertext;
+
+ $item['content'] = $links;
+ $item['title'] = $this->showTitle . ' ' . $title . ' - ' . $typesText[$type];
+ // As RSS Bridge use the URI as GUID they need to be unique : adding a md5 hash of the title element
+ // should geneerate unique URI to prevent confusion for RSS readers
+ $item['uri'] = self::URI . $this->getInput('url') . '#' . hash('md5', $item['title']);
+
+ $this->items[] = $item;
+ }
+ }
+ }
+
+ public function getName(){
+ switch($this->queriedContext) {
+ case 'Suivre la publication des épisodes d\'une série en cours de diffusion':
+ return $this->showTitle . ' - ' . self::NAME;
+ break;
+ default:
+ return self::NAME;
+ }
+ }
+
+ private function findLinkType($element)
+ {
+ $return = '';
+ // Walk through all elements in the reverse order until finding one with class 'presz_2'
+ while($element->class != 'prez_2') {
+ $element = $element->prev_sibling();
+ }
+ $text = html_entity_decode($element->plaintext);
+
+ // Regarding the text of the element, return the according link type
+ if(stristr($text, 'téléchargement') != false) {
+ $return = 'download';
+ } else if(stristr($text, 'streaming') != false) {
+ $return = 'streaming';
+ }
+
+ return $return;
+ }
+}
diff --git a/bridges/FB2Bridge.php b/bridges/FB2Bridge.php
new file mode 100644
index 0000000..29df755
--- /dev/null
+++ b/bridges/FB2Bridge.php
@@ -0,0 +1,290 @@
+<?php
+class FB2Bridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene';
+ const NAME = 'Facebook Alternate';
+ const URI = 'https://www.facebook.com/';
+ const CACHE_TIMEOUT = 1000;
+ const DESCRIPTION = 'Input a page title or a profile log. For a profile log,
+ please insert the parameter as follow : myExamplePage/132621766841117';
+
+ const PARAMETERS = array( array(
+ 'u' => array(
+ 'name' => 'Username',
+ 'required' => true
+ )
+ ));
+
+ public function getIcon() {
+ return 'https://static.xx.fbcdn.net/rsrc.php/yo/r/iRmz9lCMBD2.ico';
+ }
+
+ public function collectData(){
+
+ //Utility function for cleaning a Facebook link
+ $unescape_fb_link = function($matches){
+ if(is_array($matches) && count($matches) > 1) {
+ $link = $matches[1];
+ if(strpos($link, '/') === 0)
+ $link = self::URI . substr($link, 1);
+ if(strpos($link, 'facebook.com/l.php?u=') !== false)
+ $link = urldecode(extractFromDelimiters($link, 'facebook.com/l.php?u=', '&'));
+ return ' href="' . $link . '"';
+ }
+ };
+
+ //Utility function for converting facebook emoticons
+ $unescape_fb_emote = function($matches){
+ static $facebook_emoticons = array(
+ 'smile' => ':)',
+ 'frown' => ':(',
+ 'tongue' => ':P',
+ 'grin' => ':D',
+ 'gasp' => ':O',
+ 'wink' => ';)',
+ 'pacman' => ':<',
+ 'grumpy' => '>_<',
+ 'unsure' => ':/',
+ 'cry' => ':\'(',
+ 'kiki' => '^_^',
+ 'glasses' => '8-)',
+ 'sunglasses' => 'B-)',
+ 'heart' => '<3',
+ 'devil' => ']:D',
+ 'angel' => '0:)',
+ 'squint' => '-_-',
+ 'confused' => 'o_O',
+ 'upset' => 'xD',
+ 'colonthree' => ':3',
+ 'like' => '&#x1F44D;');
+ $len = count($matches);
+ if ($len > 1)
+ for ($i = 1; $i < $len; $i++)
+ foreach ($facebook_emoticons as $name => $emote)
+ if ($matches[$i] === $name)
+ return $emote;
+ return $matches[0];
+ };
+
+ if($this->getInput('u') !== null) {
+ $page = 'https://touch.facebook.com/' . $this->getInput('u');
+ $cookies = $this->getCookies($page);
+ $pageInfo = $this->getPageInfos($page, $cookies);
+
+ if($pageInfo['userId'] === null) {
+ echo <<<EOD
+Unable to get the page id. You should consider getting the ID by hand, then importing it into FB2Bridge
+EOD;
+ die();
+ } elseif($pageInfo['userId'] == -1) {
+ echo <<<EOD
+This page is not accessible without being logged in.
+EOD;
+ die();
+ }
+ }
+
+ //Build the string for the first request
+ $requestString = 'https://touch.facebook.com/page_content_list_view/more/?page_id='
+ . $pageInfo['userId']
+ . '&start_cursor=1&num_to_fetch=105&surface_type=timeline';
+ $fileContent = getContents($requestString);
+ $html = $this->buildContent($fileContent);
+ $author = $pageInfo['username'];
+
+ foreach($html->find('article') as $content) {
+
+ $item = array();
+ //echo $content; die();
+ preg_match('/publish_time\\\":([0-9]+),/', $content->getAttribute('data-store', 0), $match);
+ if(isset($match[1]))
+ $timestamp = $match[1];
+ else
+ $timestamp = 0;
+
+ $item['uri'] = html_entity_decode('http://touch.facebook.com'
+ . $content->find("div[class='_52jc _5qc4 _78cz _24u0 _36xo']", 0)->find('a', 0)->getAttribute('href'), ENT_QUOTES);
+
+ //Decode images
+ $imagecleaned = preg_replace_callback('/<i [^>]* style="[^"]*url\(\'(.*?)\'\).*?><\/i>/m', function ($matches) {
+ return "<img src='" . str_replace(['\\3a ', '\\3d ', '\\26 '], [':', '=', '&'], $matches[1]) . "' />";
+ }, $content);
+ $content = str_get_html($imagecleaned);
+
+ if($content->find('header', 0) !== null) {
+ $content->find('header', 0)->innertext = '';
+ }
+
+ if($content->find('footer', 0) !== null) {
+ $content->find('footer', 0)->innertext = '';
+ }
+
+ // Replace emoticon images by their textual representation (part of the span)
+ foreach($content->find('span[title*="emoticon"]') as $emoticon) {
+ $emoticon->innertext = $emoticon->find('span[aria-hidden="true"]', 0)->innertext;
+ }
+
+ //Remove html nodes, keep only img, links, basic formatting
+ $content = strip_tags($content, '<a><img><i><u><br><p><h3><h4><section>');
+
+ //Adapt link hrefs: convert relative links into absolute links and bypass external link redirection
+ $content = preg_replace_callback('/ href=\"([^"]+)\"/i', $unescape_fb_link, $content);
+
+ //Clean useless html tag properties and fix link closing tags
+ foreach (array(
+ 'onmouseover',
+ 'onclick',
+ 'target',
+ 'ajaxify',
+ 'tabindex',
+ 'class',
+ 'data-[^=]*',
+ 'aria-[^=]*',
+ 'role',
+ 'rel',
+ 'id') as $property_name)
+ $content = preg_replace('/ ' . $property_name . '=\"[^"]*\"/i', '', $content);
+ $content = preg_replace('/<\/a [^>]+>/i', '</a>', $content);
+
+ //Convert textual representation of emoticons eg
+ // "<i><u>smile emoticon</u></i>" back to ASCII emoticons eg ":)"
+ $content = preg_replace_callback('/<i><u>([^ <>]+) ([^<>]+)<\/u><\/i>/i', $unescape_fb_emote, $content);
+
+ //Remove the "...Plus" tag
+ $content = preg_replace(
+ '/… (<span>|)<a href="https:\/\/www\.facebook\.com\/story\.php\?story_fbid=.*?<\/a>/m',
+ '', $content, 1);
+
+ //Remove tracking images
+ $content = preg_replace('/<img src=\'.*?safe_image\.php.*?\' \/>/m', '', $content);
+
+ //Remove the double section tags
+ $content = str_replace(['<section><section>', '</section></section>'], ['<section>', '</section>'], $content);
+
+ //Move the section tag link upper, if it is down
+ $content = str_get_html($content);
+ $sectionContent = $content->find('section', 0);
+ if($sectionContent != null) {
+ $sectionLink = $sectionContent->nextSibling();
+ if($sectionLink != null) {
+ $fullLink = '<a href="' . $sectionLink->getAttribute('href') . '">' . $sectionContent->innertext . '</a>';
+ $sectionContent->innertext = $fullLink;
+ }
+ }
+
+ //Move the href tag upper if it is inside the section
+ foreach($content->find('section > a') as $sectionToFix) {
+ $sectionLink = $sectionToFix->getAttribute('href');
+ $section = $sectionToFix->parent();
+ $section->outertext = '<a href="' . $sectionLink . '">' . $section . '</a>';
+ }
+
+ $item['content'] = html_entity_decode($content, ENT_QUOTES);
+
+ $title = $author;
+ if (strlen($title) > 24)
+ $title = substr($title, 0, strpos(wordwrap($title, 24), "\n")) . '...';
+ $title = $title . ' | ' . strip_tags($content);
+ if (strlen($title) > 64)
+ $title = substr($title, 0, strpos(wordwrap($title, 64), "\n")) . '...';
+
+ $item['title'] = html_entity_decode($title, ENT_QUOTES);
+ $item['author'] = html_entity_decode($author, ENT_QUOTES);
+ $item['timestamp'] = html_entity_decode($timestamp, ENT_QUOTES);
+
+ if($item['timestamp'] != 0)
+ array_push($this->items, $item);
+ }
+
+ }
+
+ //Builds the HTML from the encoded JS that Facebook provides.
+ private function buildContent($pageContent){
+ // The html ends with:
+ // /div>","replaceifexists
+ $regex = '/\\"html\\":(\".+\/div>"),"replace/';
+ preg_match($regex, $pageContent, $result);
+
+ $htmlContent = json_decode($result[1]);
+ $htmlContent = preg_replace('/(?<!style)="(.*?)"/', '=\'$1\'', $htmlContent);
+ $htmlContent = html_entity_decode($htmlContent, ENT_QUOTES, 'UTF-8');
+
+ return str_get_html($htmlContent);
+ }
+
+ //Builds the cookie from the page, as Facebook sometimes refuses to give
+ //the page if no cookie is provided.
+ private function getCookies($pageURL){
+
+ $ctx = stream_context_create(array(
+ 'http' => array(
+ 'user_agent' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:46.0) Gecko/20100101 Firefox/46.0',
+ 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
+ )
+ )
+ );
+ $a = file_get_contents($pageURL, 0, $ctx);
+
+ //First request to get the cookie
+ $cookies = '';
+ foreach($http_response_header as $hdr) {
+ if(strpos($hdr, 'Set-Cookie') !== false) {
+ $cLine = explode(':', $hdr)[1];
+ $cLine = explode(';', $cLine)[0];
+ $cookies .= ';' . $cLine;
+ }
+ }
+
+ return substr($cookies, 1);
+ }
+
+ //Get the page ID and username from the Facebook page.
+ private function getPageInfos($page, $cookies){
+
+ $context = stream_context_create(array(
+ 'http' => array(
+ 'user_agent' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:46.0) Gecko/20100101 Firefox/46.0',
+ 'header' => 'Cookie: ' . $cookies
+ )
+ )
+ );
+
+ $pageContent = file_get_contents($page, 0, $context);
+
+ if(strpos($pageContent, 'signup-button') != false) {
+ return -1;
+ }
+
+ //Get the username
+ $usernameRegex = '/data-nt=\"FB:TEXT4\">(.*?)<\/div>/m';
+ preg_match($usernameRegex, $pageContent, $usernameMatches);
+ if(count($usernameMatches) > 0) {
+ $username = strip_tags($usernameMatches[1]);
+ } else {
+ $username = $this->getInput('u');
+ }
+
+ //Get the page ID if we don't have a captcha
+ $regex = '/page_id=([0-9]*)&/';
+ preg_match($regex, $pageContent, $matches);
+
+ if(count($matches) > 0) {
+ return array('userId' => $matches[1], 'username' => $username);
+ }
+
+ //Get the page ID if we do have a captcha
+ $regex = '/"pageID":"([0-9]*)"/';
+ preg_match($regex, $pageContent, $matches);
+
+ return array('userId' => $matches[1], 'username' => $username);
+
+ }
+
+ public function getName(){
+ return (isset($this->name) ? $this->name . ' - ' : '') . 'Facebook Bridge';
+ }
+
+ public function getURI(){
+ return 'http://facebook.com';
+ }
+}
diff --git a/bridges/FDroidBridge.php b/bridges/FDroidBridge.php
new file mode 100644
index 0000000..b606cec
--- /dev/null
+++ b/bridges/FDroidBridge.php
@@ -0,0 +1,58 @@
+<?php
+class FDroidBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Mitsukarenai';
+ const NAME = 'F-Droid Bridge';
+ const URI = 'https://f-droid.org/';
+ const CACHE_TIMEOUT = 60 * 60 * 2; // 2 hours
+ const DESCRIPTION = 'Returns latest added/updated apps on the open-source Android apps repository F-Droid';
+
+ const PARAMETERS = array( array(
+ 'u' => array(
+ 'name' => 'Widget selection',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'Latest added apps' => 'added',
+ 'Latest updated apps' => 'updated'
+ )
+ )
+ ));
+
+ public function getIcon() {
+ return self::URI . 'assets/favicon.ico?v=8j6PKzW9Mk';
+ }
+
+ public function collectData(){
+ $url = self::URI;
+ $html = getSimpleHTMLDOM($url)
+ or returnServerError('Could not request F-Droid.');
+
+ // targetting the corresponding widget based on user selection
+ // "updated" is the 4th widget on the page, "added" is the 5th
+
+ switch($this->getInput('u')) {
+ case 'updated':
+ $html_widget = $html->find('div.sidebar-widget', 4);
+ break;
+ default:
+ $html_widget = $html->find('div.sidebar-widget', 5);
+ break;
+ }
+
+ // and now extracting app info from the selected widget (and yeah turns out icons are of heterogeneous sizes)
+
+ foreach($html_widget->find('a') as $element) {
+ $item = array();
+ $item['uri'] = self::URI . $element->href;
+ $item['title'] = $element->find('h4', 0)->plaintext;
+ $item['icon'] = $element->find('img', 0)->src;
+ $item['summary'] = $element->find('span.package-summary', 0)->plaintext;
+ $item['content'] = '
+ <a href="' . $item['uri'] . '">
+ <img alt="" style="max-height:128px" src="' . $item['icon'] . '">
+ </a><br>' . $item['summary'];
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/FacebookBridge.php b/bridges/FacebookBridge.php
new file mode 100644
index 0000000..7b61705
--- /dev/null
+++ b/bridges/FacebookBridge.php
@@ -0,0 +1,691 @@
+<?php
+class FacebookBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene, logmanoriginal';
+ const NAME = 'Facebook Bridge';
+ const URI = 'https://www.facebook.com/';
+ const CACHE_TIMEOUT = 300; // 5min
+ const DESCRIPTION = 'Input a page title or a profile log. For a profile log,
+ please insert the parameter as follow : myExamplePage/132621766841117';
+
+ const PARAMETERS = array(
+ 'User' => array(
+ 'u' => array(
+ 'name' => 'Username',
+ 'required' => true
+ ),
+ 'media_type' => array(
+ 'name' => 'Media type',
+ 'type' => 'list',
+ 'required' => false,
+ 'values' => array(
+ 'All' => 'all',
+ 'Video' => 'video',
+ 'No Video' => 'novideo'
+ ),
+ 'defaultValue' => 'all'
+ ),
+ 'skip_reviews' => array(
+ 'name' => 'Skip reviews',
+ 'type' => 'checkbox',
+ 'required' => false,
+ 'defaultValue' => false,
+ 'title' => 'Feed includes reviews when checked'
+ )
+ ),
+ 'Group' => array(
+ 'g' => array(
+ 'name' => 'Group',
+ 'type' => 'text',
+ 'required' => true,
+ 'exampleValue' => 'https://www.facebook.com/groups/743149642484225',
+ 'title' => 'Insert group name or facebook group URL'
+ )
+ ),
+ 'global' => array(
+ 'limit' => array(
+ 'name' => 'Limit',
+ 'type' => 'number',
+ 'required' => false,
+ 'title' => 'Specify the number of items to return (default: -1)',
+ 'defaultValue' => -1
+ )
+ )
+ );
+
+ private $authorName = '';
+ private $groupName = '';
+
+ public function getIcon() {
+ return 'https://static.xx.fbcdn.net/rsrc.php/yo/r/iRmz9lCMBD2.ico';
+ }
+
+ public function getName(){
+
+ switch($this->queriedContext) {
+
+ case 'User':
+ if(!empty($this->authorName)) {
+ return isset($this->extraInfos['name']) ? $this->extraInfos['name'] : $this->authorName
+ . ' - ' . static::NAME;
+ }
+ break;
+
+ case 'Group':
+ if(!empty($this->groupName)) {
+ return $this->groupName . ' - ' . static::NAME;
+ }
+ break;
+
+ }
+
+ return parent::getName();
+ }
+
+ public function getURI() {
+ $uri = self::URI;
+
+ switch($this->queriedContext) {
+
+ case 'Group':
+ // Discover groups via https://www.facebook.com/groups/
+ // Example group: https://www.facebook.com/groups/sailors.worldwide
+ $uri .= 'groups/' . $this->sanitizeGroup(filter_var($this->getInput('g'), FILTER_SANITIZE_URL));
+ break;
+
+ case 'User':
+ // Example user 1: https://www.facebook.com/artetv/
+ // Example user 2: artetv
+ $user = $this->sanitizeUser($this->getInput('u'));
+
+ if(!strpos($user, '/')) {
+ $uri .= urlencode($user) . '/posts';
+ } else {
+ $uri .= 'pages/' . $user;
+ }
+
+ break;
+
+ }
+
+ // Request the mobile version to reduce page size (no javascript)
+ // More information: https://stackoverflow.com/a/11103592
+ return $uri .= '?_fb_noscript=1';
+ }
+
+ public function collectData() {
+
+ switch($this->queriedContext) {
+
+ case 'Group':
+ $this->collectGroupData();
+ break;
+
+ case 'User':
+ $this->collectUserData();
+ break;
+
+ default:
+ returnClientError('Unknown context: "' . $this->queriedContext . '"!');
+
+ }
+
+ $limit = $this->getInput('limit') ?: -1;
+
+ if($limit > 0 && count($this->items) > $limit) {
+ $this->items = array_slice($this->items, 0, $limit);
+ }
+
+ }
+
+ #region Group
+
+ private function collectGroupData() {
+
+ $header = array('Accept-Language: ' . getEnv('HTTP_ACCEPT_LANGUAGE') . "\r\n");
+
+ $html = getSimpleHTMLDOM($this->getURI(), $header)
+ or returnServerError('Failed loading facebook page: ' . $this->getURI());
+
+ if(!$this->isPublicGroup($html)) {
+ returnClientError('This group is not public! RSS-Bridge only supports public groups!');
+ }
+
+ defaultLinkTo($html, substr(self::URI, 0, strlen(self::URI) - 1));
+
+ $this->groupName = $this->extractGroupName($html);
+
+ $posts = $html->find('div.userContentWrapper')
+ or returnServerError('Failed finding posts!');
+
+ foreach($posts as $post) {
+
+ $item = array();
+
+ $item['uri'] = $this->extractGroupURI($post);
+ $item['title'] = $this->extractGroupTitle($post);
+ $item['author'] = $this->extractGroupAuthor($post);
+ $item['content'] = $this->extractGroupContent($post);
+ $item['timestamp'] = $this->extractGroupTimestamp($post);
+ $item['enclosures'] = $this->extractGroupEnclosures($post);
+
+ $this->items[] = $item;
+
+ }
+
+ }
+
+ private function sanitizeGroup($group) {
+
+ if(filter_var(
+ $group,
+ FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED)) {
+ // User provided a URL
+
+ $urlparts = parse_url($group);
+
+ if($urlparts['host'] !== parse_url(self::URI)['host']
+ && 'www.' . $urlparts['host'] !== parse_url(self::URI)['host']) {
+
+ returnClientError('The host you provided is invalid! Received "'
+ . $urlparts['host']
+ . '", expected "'
+ . parse_url(self::URI)['host']
+ . '"!');
+
+ }
+
+ return explode('/', $urlparts['path'])[2];
+
+ } elseif(strpos($group, '/') !== false) {
+ returnClientError('The group you provided is invalid: ' . $group);
+ } else {
+ return $group;
+ }
+
+ }
+
+ private function isPublicGroup($html) {
+
+ // Facebook redirects to the groups about page for non-public groups
+ $about = $html->find('#pagelet_group_about', 0);
+
+ return !($about);
+
+ }
+
+ private function extractGroupName($html) {
+
+ $ogtitle = $html->find('meta[property="og:title"]', 0)
+ or returnServerError('Unable to find group title!');
+
+ return htmlspecialchars_decode($ogtitle->content, ENT_QUOTES);
+
+ }
+
+ private function extractGroupURI($post) {
+
+ $elements = $post->find('a')
+ or returnServerError('Unable to find URI!');
+
+ foreach($elements as $anchor) {
+
+ // Find the one that is a permalink
+ if(strpos($anchor->href, 'permalink') !== false) {
+ return $anchor->href;
+ }
+
+ }
+
+ return null;
+
+ }
+
+ private function extractGroupContent($post) {
+
+ $content = $post->find('div.userContent', 0)
+ or returnServerError('Unable to find user content!');
+
+ return $content->innertext . $content->next_sibling()->innertext;
+
+ }
+
+ private function extractGroupTimestamp($post) {
+
+ $element = $post->find('abbr[data-utime]', 0)
+ or returnServerError('Unable to find timestamp!');
+
+ return $element->getAttribute('data-utime');
+
+ }
+
+ private function extractGroupAuthor($post) {
+
+ $element = $post->find('img', 0)
+ or returnServerError('Unable to find author information!');
+
+ return $element->{'aria-label'};
+
+ }
+
+ private function extractGroupEnclosures($post) {
+
+ $elements = $post->find('div.userContent', 0)->next_sibling()->find('img');
+
+ $enclosures = array();
+
+ foreach($elements as $enclosure) {
+ $enclosures[] = $enclosure->src;
+ }
+
+ return empty($enclosures) ? null : $enclosures;
+
+ }
+
+ private function extractGroupTitle($post) {
+
+ $element = $post->find('h5', 0)
+ or returnServerError('Unable to find title!');
+
+ if(strpos($element->plaintext, 'shared') === false) {
+
+ $content = strip_tags($this->extractGroupContent($post));
+
+ return $this->extractGroupAuthor($post)
+ . ' posted: '
+ . substr(
+ $content,
+ 0,
+ strpos(wordwrap($content, 64), "\n")
+ )
+ . '...';
+
+ }
+
+ return $element->plaintext;
+
+ }
+
+ #endregion (Group)
+
+ #region User
+
+ /**
+ * Checks if $user is a valid username or URI and returns the username
+ */
+ private function sanitizeUser($user) {
+ if (filter_var($user, FILTER_VALIDATE_URL)) {
+
+ $urlparts = parse_url($user);
+
+ if($urlparts['host'] !== parse_url(self::URI)['host']) {
+ returnClientError('The host you provided is invalid! Received "'
+ . $urlparts['host']
+ . '", expected "'
+ . parse_url(self::URI)['host']
+ . '"!');
+ }
+
+ if(!array_key_exists('path', $urlparts)
+ || $urlparts['path'] === '/') {
+ returnClientError('The URL you provided doesn\'t contain the user name!');
+ }
+
+ return explode('/', $urlparts['path'])[1];
+
+ } else {
+
+ // First character cannot be a forward slash
+ if(strpos($user, '/') === 0) {
+ returnClientError('Remove leading slash "/" from the username!');
+ }
+
+ return $user;
+
+ }
+ }
+
+ /**
+ * Bypass external link redirection
+ */
+ private function unescape_fb_link($content){
+ return preg_replace_callback('/ href=\"([^"]+)\"/i', function($matches){
+ if(is_array($matches) && count($matches) > 1) {
+
+ $link = $matches[1];
+
+ if(strpos($link, 'facebook.com/l.php?u=') !== false)
+ $link = urldecode(extractFromDelimiters($link, 'facebook.com/l.php?u=', '&'));
+
+ return ' href="' . $link . '"';
+
+ }
+ }, $content);
+ }
+
+ /**
+ * Remove Facebook's tracking code
+ */
+ private function remove_tracking_codes($content){
+ return preg_replace_callback('/ href=\"([^"]+)\"/i', function($matches){
+ if(is_array($matches) && count($matches) > 1) {
+
+ $link = $matches[1];
+
+ if(strpos($link, 'facebook.com') !== false) {
+ if(strpos($link, '?') !== false) {
+ $link = substr($link, 0, strpos($link, '?'));
+ }
+ }
+ return ' href="' . $link . '"';
+
+ }
+ }, $content);
+ }
+
+ /**
+ * Convert textual representation of emoticons back to ASCII emoticons.
+ * i.e. "<i><u>smile emoticon</u></i>" => ":)"
+ */
+ private function unescape_fb_emote($content){
+ return preg_replace_callback('/<i><u>([^ <>]+) ([^<>]+)<\/u><\/i>/i', function($matches){
+ static $facebook_emoticons = array(
+ 'smile' => ':)',
+ 'frown' => ':(',
+ 'tongue' => ':P',
+ 'grin' => ':D',
+ 'gasp' => ':O',
+ 'wink' => ';)',
+ 'pacman' => ':<',
+ 'grumpy' => '>_<',
+ 'unsure' => ':/',
+ 'cry' => ':\'(',
+ 'kiki' => '^_^',
+ 'glasses' => '8-)',
+ 'sunglasses' => 'B-)',
+ 'heart' => '<3',
+ 'devil' => ']:D',
+ 'angel' => '0:)',
+ 'squint' => '-_-',
+ 'confused' => 'o_O',
+ 'upset' => 'xD',
+ 'colonthree' => ':3',
+ 'like' => '&#x1F44D;');
+
+ $len = count($matches);
+
+ if ($len > 1)
+ for ($i = 1; $i < $len; $i++)
+ foreach ($facebook_emoticons as $name => $emote)
+ if ($matches[$i] === $name)
+ return $emote;
+
+ return $matches[0];
+ }, $content);
+ }
+
+ /**
+ * Returns the captcha message for the given captcha
+ */
+ private function returnCaptchaMessage($captcha) {
+ // Save form for submitting after getting captcha response
+ if (session_status() == PHP_SESSION_NONE) {
+ session_start();
+ }
+
+ $captcha_fields = array();
+
+ foreach ($captcha->find('input, button') as $input) {
+ $captcha_fields[$input->name] = $input->value;
+ }
+
+ $_SESSION['captcha_fields'] = $captcha_fields;
+ $_SESSION['captcha_action'] = $captcha->find('form', 0)->action;
+
+ // Show captcha filling form to the viewer, proxying the captcha image
+ $img = base64_encode(getContents($captcha->find('img', 0)->src));
+
+ header('Content-Type: text/html', true, 500);
+
+ $message = <<<EOD
+<form method="post" action="?{$_SERVER['QUERY_STRING']}">
+<h2>Facebook captcha challenge</h2>
+<p>Unfortunately, rss-bridge cannot fetch the requested page.<br />
+Facebook wants rss-bridge to resolve the following captcha:</p>
+<p><img src="data:image/png;base64,{$img}" /></p>
+<p><b>Response:</b> <input name="captcha_response" placeholder="please fill in" />
+<input type="submit" value="Submit!" /></p>
+</form>
+EOD;
+
+ die($message);
+ }
+
+ /**
+ * Checks if a capture response was received and tries to load the contents
+ * @return mixed null if no capture response was received, simplhtmldom document otherwise
+ */
+ private function handleCaptchaResponse() {
+ if (isset($_POST['captcha_response'])) {
+ if (session_status() == PHP_SESSION_NONE)
+ session_start();
+
+ if (isset($_SESSION['captcha_fields'], $_SESSION['captcha_action'])) {
+ $captcha_action = $_SESSION['captcha_action'];
+ $captcha_fields = $_SESSION['captcha_fields'];
+ $captcha_fields['captcha_response'] = preg_replace('/[^a-zA-Z0-9]+/', '', $_POST['captcha_response']);
+
+ $header = array(
+ 'Content-type: application/x-www-form-urlencoded',
+ 'Referer: ' . $captcha_action,
+ 'Cookie: noscript=1'
+ );
+
+ $opts = array(
+ CURLOPT_POST => 1,
+ CURLOPT_POSTFIELDS => http_build_query($captcha_fields)
+ );
+
+ $html = getSimpleHTMLDOM($captcha_action, $header, $opts)
+ or returnServerError('Failed to submit captcha response back to Facebook');
+
+ return $html;
+ }
+
+ unset($_SESSION['captcha_fields']);
+ unset($_SESSION['captcha_action']);
+ }
+
+ return null;
+ }
+
+ private function collectUserData(){
+
+ $html = $this->handleCaptchaResponse();
+
+ // Retrieve page contents
+ if(is_null($html)) {
+
+ $header = array('Accept-Language: ' . getEnv('HTTP_ACCEPT_LANGUAGE'));
+
+ $html = getSimpleHTMLDOM($this->getURI(), $header)
+ or returnServerError('No results for this query.');
+
+ }
+
+ // Handle captcha form?
+ $captcha = $html->find('div.captcha_interstitial', 0);
+
+ if (!is_null($captcha)) {
+ $this->returnCaptchaMessage($captcha);
+ }
+
+ // No captcha? We can carry on retrieving page contents :)
+ // First, we check wether the page is public or not
+ $loginForm = $html->find('._585r', 0);
+
+ if($loginForm != null) {
+ returnServerError('You must be logged in to view this page. This is not supported by RSS-Bridge.');
+ }
+
+ $element = $html
+ ->find('#pagelet_timeline_main_column')[0]
+ ->children(0)
+ ->children(0)
+ ->next_sibling()
+ ->children(0);
+
+ if(isset($element)) {
+
+ $author = str_replace(' - Posts | Facebook', '', $html->find('title#pageTitle', 0)->innertext);
+
+ $profilePic = $html->find('meta[property="og:image"]', 0)->content;
+
+ $this->authorName = $author;
+
+ foreach($element->children() as $cell) {
+ // Manage summary posts
+ if(strpos($cell->class, '_3xaf') !== false) {
+ $posts = $cell->children();
+ } else {
+ $posts = array($cell);
+ }
+
+ // Optionally skip reviews
+ if($this->getInput('skip_reviews')
+ && !is_null($cell->find('#review_composer_container', 0))) {
+ continue;
+ }
+
+ foreach($posts as $post) {
+ // Check media type
+ switch($this->getInput('media_type')) {
+ case 'all': break;
+ case 'video':
+ if(empty($post->find('[aria-label=Video]'))) continue 2;
+ break;
+ case 'novideo':
+ if(!empty($post->find('[aria-label=Video]'))) continue 2;
+ break;
+ default: break;
+ }
+
+ $item = array();
+
+ if(count($post->find('abbr')) > 0) {
+
+ $content = $post->find('.userContentWrapper', 0);
+
+ // This array specifies filters applied to all posts in order of appearance
+ $content_filters = array(
+ '._5mly', // Remove embedded videos (the preview image remains)
+ '._2ezg', // Remove "Views ..."
+ '.hidden_elem', // Remove hidden elements (they are hidden anyway)
+ );
+
+ foreach($content_filters as $filter) {
+ foreach($content->find($filter) as $subject) {
+ $subject->outertext = '';
+ }
+ }
+
+ // Change origin tag for embedded media from div to paragraph
+ foreach($content->find('._59tj') as $subject) {
+ $subject->outertext = '<p>' . $subject->innertext . '</p>';
+ }
+
+ // Change title tag for embedded media from anchor to paragraph
+ foreach($content->find('._3n1k a') as $anchor) {
+ $anchor->outertext = '<p>' . $anchor->innertext . '</p>';
+ }
+
+ $content = preg_replace(
+ '/(?i)><div class=\"_3dp([^>]+)>(.+?)div\ class=\"[^u]+userContent\"/i',
+ '',
+ $content);
+
+ $content = preg_replace(
+ '/(?i)><div class=\"_4l5([^>]+)>(.+?)<\/div>/i',
+ '',
+ $content);
+
+ // Remove "SpSonsSoriSsés"
+ $content = preg_replace(
+ '/(?iU)<a [^>]+ href="#" role="link" [^>}]+>.+<\/a>/iU',
+ '',
+ $content);
+
+ // Remove html nodes, keep only img, links, basic formatting
+ $content = strip_tags($content, '<a><img><i><u><br><p>');
+
+ $content = $this->unescape_fb_link($content);
+
+ // Clean useless html tag properties and fix link closing tags
+ foreach (array(
+ 'onmouseover',
+ 'onclick',
+ 'target',
+ 'ajaxify',
+ 'tabindex',
+ 'class',
+ 'style',
+ 'data-[^=]*',
+ 'aria-[^=]*',
+ 'role',
+ 'rel',
+ 'id') as $property_name) {
+ $content = preg_replace('/ ' . $property_name . '=\"[^"]*\"/i', '', $content);
+ }
+
+ $content = preg_replace('/<\/a [^>]+>/i', '</a>', $content);
+
+ $this->unescape_fb_emote($content);
+
+ // Restore links in the post before further parsing
+ $post = defaultLinkTo($post, self::URI);
+
+ // Restore links in the content before adding to the item
+ $content = defaultLinkTo($content, self::URI);
+
+ $content = $this->remove_tracking_codes($content);
+
+ // Retrieve date of the post
+ $date = $post->find('abbr')[0];
+
+ if(isset($date) && $date->hasAttribute('data-utime')) {
+ $date = $date->getAttribute('data-utime');
+ } else {
+ $date = 0;
+ }
+
+ // Build title from content
+ $title = strip_tags($post->find('.userContent', 0)->innertext);
+ if(strlen($title) > 64)
+ $title = substr($title, 0, strpos(wordwrap($title, 64), "\n")) . '...';
+
+ $uri = $post->find('abbr')[0]->parent()->getAttribute('href');
+
+ if (false !== strpos($uri, '?')) {
+ $uri = substr($uri, 0, strpos($uri, '?'));
+ }
+
+ //Build and add final item
+ $item['uri'] = htmlspecialchars_decode($uri, ENT_QUOTES);
+ $item['content'] = htmlspecialchars_decode($content, ENT_QUOTES);
+ $item['title'] = htmlspecialchars_decode($title, ENT_QUOTES);
+ $item['author'] = htmlspecialchars_decode($author, ENT_QUOTES);
+ $item['timestamp'] = $date;
+
+ if(strpos($item['content'], '<img') === false) {
+ $item['enclosures'] = array($profilePic);
+ }
+
+ $this->items[] = $item;
+ }
+ }
+ }
+ }
+ }
+ #endregion (User)
+
+}
diff --git a/bridges/FeedExpanderExampleBridge.php b/bridges/FeedExpanderExampleBridge.php
new file mode 100644
index 0000000..537a635
--- /dev/null
+++ b/bridges/FeedExpanderExampleBridge.php
@@ -0,0 +1,62 @@
+<?php
+class FeedExpanderExampleBridge extends FeedExpander {
+
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'FeedExpander Example';
+ const URI = 'http://github.com/RSS-Bridge/rss-bridge/';
+ const DESCRIPTION = 'Example bridge to test FeedExpander';
+
+ const PARAMETERS = array(
+ 'Feed' => array(
+ 'version' => array(
+ 'name' => 'Version',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Select your feed format/version',
+ 'defaultValue' => 'RSS 2.0',
+ 'values' => array(
+ 'RSS 0.91' => 'rss_0_9_1',
+ 'RSS 1.0' => 'rss_1_0',
+ 'RSS 2.0' => 'rss_2_0',
+ 'ATOM 1.0' => 'atom_1_0'
+ )
+ )
+ )
+ );
+
+ public function collectData(){
+ switch($this->getInput('version')) {
+ case 'rss_0_9_1':
+ parent::collectExpandableDatas('http://static.userland.com/gems/backend/sampleRss.xml');
+ break;
+ case 'rss_1_0':
+ parent::collectExpandableDatas('http://feeds.nature.com/nature/rss/current?format=xml');
+ break;
+ case 'rss_2_0':
+ parent::collectExpandableDatas('http://feeds.rssboard.org/rssboard?format=xml');
+ break;
+ case 'atom_1_0':
+ parent::collectExpandableDatas('http://segfault.linuxmint.com/feed/atom/');
+ break;
+ default: returnClientError('Unknown version ' . $this->getInput('version') . '!');
+ }
+ }
+
+ protected function parseItem($newsItem) {
+ switch($this->getInput('version')) {
+ case 'rss_0_9_1':
+ return $this->parseRSS_0_9_1_Item($newsItem);
+ break;
+ case 'rss_1_0':
+ return $this->parseRSS_1_0_Item($newsItem);
+ break;
+ case 'rss_2_0':
+ return $this->parseRSS_2_0_Item($newsItem);
+ break;
+ case 'atom_1_0':
+ return $this->parseATOMItem($newsItem);
+ break;
+ default: returnClientError('Unknown version ' . $this->getInput('version') . '!');
+ }
+ }
+}
diff --git a/bridges/FierPandaBridge.php b/bridges/FierPandaBridge.php
new file mode 100644
index 0000000..75a02cf
--- /dev/null
+++ b/bridges/FierPandaBridge.php
@@ -0,0 +1,33 @@
+<?php
+class FierPandaBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'snroki';
+ const NAME = 'Fier Panda Bridge';
+ const URI = 'http://www.fier-panda.fr/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Returns latest articles from Fier Panda.';
+
+ public function getIcon() {
+ return self::URI . 'wp-content/themes/fier-panda/img/favicon.png';
+ }
+
+ public function collectData(){
+
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request Fier Panda.');
+
+ defaultLinkTo($html, static::URI);
+
+ foreach($html->find('article') as $article) {
+
+ $item = array();
+
+ $item['uri'] = $article->find('a', 0)->href;
+ $item['title'] = $article->find('a', 0)->title;
+
+ $this->items[] = $item;
+
+ }
+
+ }
+}
diff --git a/bridges/FilterBridge.php b/bridges/FilterBridge.php
new file mode 100644
index 0000000..696b100
--- /dev/null
+++ b/bridges/FilterBridge.php
@@ -0,0 +1,101 @@
+<?php
+
+class FilterBridge extends FeedExpander {
+
+ const MAINTAINER = 'Frenzie';
+ const NAME = 'Filter';
+ const CACHE_TIMEOUT = 3600; // 1h
+ const DESCRIPTION = 'Filters a feed of your choice';
+ const URI = 'https://github.com/rss-bridge/rss-bridge';
+
+ const PARAMETERS = array(array(
+ 'url' => array(
+ 'name' => 'Feed URL',
+ 'required' => true,
+ ),
+ 'filter' => array(
+ 'name' => 'Filter item title (regular expression)',
+ 'required' => false,
+ ),
+ 'filter_type' => array(
+ 'name' => 'Filter type',
+ 'type' => 'list',
+ 'required' => false,
+ 'values' => array(
+ 'Permit' => 'permit',
+ 'Block' => 'block',
+ ),
+ 'defaultValue' => 'permit',
+ ),
+ 'title_from_content' => array(
+ 'name' => 'Generate title from content',
+ 'type' => 'checkbox',
+ 'required' => false,
+ )
+ ));
+
+ protected function parseItem($newItem){
+ $item = parent::parseItem($newItem);
+
+ if($this->getInput('title_from_content') && array_key_exists('content', $item)) {
+
+ $content = str_get_html($item['content']);
+
+ $pos = strpos($item['content'], ' ', 50);
+
+ $item['title'] = substr(
+ $content->plaintext,
+ 0,
+ $pos
+ );
+
+ if(strlen($content->plaintext) >= $pos) {
+ $item['title'] .= '...';
+ }
+
+ }
+
+ switch(true) {
+ case $this->getFilterType() === 'permit':
+ if (preg_match($this->getFilter(), $item['title'])) {
+ return $item;
+ }
+ break;
+ case $this->getFilterType() === 'block':
+ if (!preg_match($this->getFilter(), $item['title'])) {
+ return $item;
+ }
+ break;
+ }
+ return null;
+ }
+
+ protected function getFilter(){
+ return '/' . $this->getInput('filter') . '/';
+ }
+
+ protected function getFilterType(){
+ return $this->getInput('filter_type');
+ }
+
+ public function getURI(){
+ $url = $this->getInput('url');
+
+ if(empty($url)) {
+ $url = parent::getURI();
+ }
+ return $url;
+ }
+
+ public function collectData(){
+ if($this->getInput('url') && substr($this->getInput('url'), 0, strlen('http')) !== 'http') {
+ // just in case someone find a way to access local files by playing with the url
+ returnClientError('The url parameter must either refer to http or https protocol.');
+ }
+ try{
+ $this->collectExpandableDatas($this->getURI());
+ } catch (Exception $e) {
+ $this->collectExpandableDatas($this->getURI());
+ }
+ }
+}
diff --git a/bridges/FindACrewBridge.php b/bridges/FindACrewBridge.php
new file mode 100644
index 0000000..c245c84
--- /dev/null
+++ b/bridges/FindACrewBridge.php
@@ -0,0 +1,82 @@
+<?php
+class FindACrewBridge extends BridgeAbstract {
+ const MAINTAINER = 'couraudt';
+ const NAME = 'Find A Crew Bridge';
+ const URI = 'https://www.findacrew.net';
+ const DESCRIPTION = 'Returns the newest sailing offers.';
+ const PARAMETERS = array(
+ array(
+ 'type' => array(
+ 'name' => 'Type of search',
+ 'title' => 'Choose between finding a boat or a crew',
+ 'type' => 'list',
+ 'values' => array(
+ 'Find a boat' => 'boat',
+ 'Find a crew' => 'crew'
+ )
+ ),
+ 'long' => array(
+ 'name' => 'Longitude of the searched location',
+ 'title' => 'Center the search at that longitude (e.g: -42.02)'
+ ),
+ 'lat' => array(
+ 'name' => 'Latitude of the searched location',
+ 'title' => 'Center the search at that latitude (e.g: 12.42)'
+ ),
+ 'distance' => array(
+ 'name' => 'Limit boundary of search in KM',
+ 'title' => 'Boundary of the search in kilometers when using longitude and latitude'
+ )
+ )
+ );
+
+ public function collectData() {
+ $url = $this->getURI();
+
+ if ($this->getInput('type') == 'boat') {
+ $data = array('SrhLstBtAction' => 'Create');
+ } else {
+ $data = array('SrhLstCwAction' => 'Create');
+ }
+
+ if ($this->getInput('long') && $this->getInput('lat')) {
+ $data['real_LocSrh_Lng'] = $this->getInput('long');
+ $data['real_LocSrh_Lat'] = $this->getInput('lat');
+ if ($this->getInput('distance')) {
+ $data['LocDis'] = (int)$this->getInput('distance') * 1000;
+ }
+ }
+
+ $header = array(
+ 'Content-Type: application/x-www-form-urlencoded'
+ );
+
+ $opts = array(
+ CURLOPT_CUSTOMREQUEST => 'POST',
+ CURLOPT_POSTFIELDS => http_build_query($data) . "\n"
+ );
+
+ $html = getSimpleHTMLDOM($url, $header, $opts) or returnClientError('No results for this query.');
+
+ $annonces = $html->find('.css_SrhRst');
+ foreach ($annonces as $annonce) {
+ $item = array();
+
+ $img = parent::getURI() . $annonce->find('.css_LstPic img', 0)->getAttribute('src');
+ $item['title'] = $annonce->find('.css_LstCtrls span', 0)->plaintext;
+ $item['uri'] = parent::getURI() . $annonce->find('.css_PnlCtrls a', 0)->href;
+ $content = $annonce->find('.css_LstDtl div', 2)->innertext;
+ $item['content'] = "<img src='$img' /><br>$content";
+ $item['enclosures'] = array($img);
+ $item['categories'] = array($annonce->find('.css_AccLocCur', 0)->plaintext);
+ $this->items[] = $item;
+ }
+ }
+
+ public function getURI() {
+ $uri = parent::getURI();
+ // Those params must be in the URL
+ $uri .= '/en/' . $this->getInput('type') . '/search?srhtyp=srhrst&mdl=2';
+ return $uri;
+ }
+}
diff --git a/bridges/FlickrBridge.php b/bridges/FlickrBridge.php
new file mode 100644
index 0000000..cb9db72
--- /dev/null
+++ b/bridges/FlickrBridge.php
@@ -0,0 +1,185 @@
+<?php
+
+/* This is a mashup of FlickrExploreBridge by sebsauvage and FlickrTagBridge
+ * by erwang, providing the functionality of both in one.
+ */
+class FlickrBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'Flickr Bridge';
+ const URI = 'https://www.flickr.com/';
+ const CACHE_TIMEOUT = 21600; // 6 hours
+ const DESCRIPTION = 'Returns images from Flickr';
+
+ const PARAMETERS = array(
+ 'Explore' => array(),
+ 'By keyword' => array(
+ 'q' => array(
+ 'name' => 'Keyword',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Insert keyword',
+ 'exampleValue' => 'bird'
+ )
+ ),
+ 'By username' => array(
+ 'u' => array(
+ 'name' => 'Username',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Insert username (as shown in the address bar)',
+ 'exampleValue' => 'flickr'
+ )
+ )
+ );
+
+ public function collectData(){
+
+ switch($this->queriedContext) {
+
+ case 'Explore':
+ $filter = 'photo-lite-models';
+ $html = getSimpleHTMLDOM(self::URI . 'explore')
+ or returnServerError('Could not request Flickr.');
+ break;
+
+ case 'By keyword':
+ $filter = 'photo-lite-models';
+ $html = getSimpleHTMLDOM(self::URI . 'search/?q=' . urlencode($this->getInput('q')) . '&s=rec')
+ or returnServerError('No results for this query.');
+ break;
+
+ case 'By username':
+ $filter = 'photo-models';
+ $html = getSimpleHTMLDOM(self::URI . 'photos/' . urlencode($this->getInput('u')))
+ or returnServerError('Requested username can\'t be found.');
+ break;
+
+ default:
+ returnClientError('Invalid context: ' . $this->queriedContext);
+
+ }
+
+ $model_json = $this->extractJsonModel($html);
+ $photo_models = $this->getPhotoModels($model_json, $filter);
+
+ foreach($photo_models as $model) {
+
+ $item = array();
+
+ /* Author name depends on scope. On a keyword search the
+ * author is part of the picture data. On a username search
+ * the author is part of the owner data.
+ */
+ if(array_key_exists('username', $model)) {
+ $item['author'] = $model['username'];
+ } elseif (array_key_exists('owner', reset($model_json)[0])) {
+ $item['author'] = reset($model_json)[0]['owner']['username'];
+ }
+
+ $item['title'] = (array_key_exists('title', $model) ? $model['title'] : 'Untitled');
+ $item['uri'] = self::URI . 'photo.gne?id=' . $model['id'];
+
+ $description = (array_key_exists('description', $model) ? $model['description'] : '');
+
+ $item['content'] = '<a href="'
+ . $item['uri']
+ . '"><img src="'
+ . $this->extractContentImage($model)
+ . '" style="max-width: 640px; max-height: 480px;"/></a><br><p>'
+ . $description
+ . '</p>';
+
+ $item['enclosures'] = $this->extractEnclosures($model);
+
+ $this->items[] = $item;
+
+ }
+
+ }
+
+ private function extractJsonModel($html) {
+
+ // Find SCRIPT containing JSON data
+ $model = $html->find('.modelExport', 0);
+ $model_text = $model->innertext;
+
+ // Find start and end of JSON data
+ $start = strpos($model_text, 'modelExport:') + strlen('modelExport:');
+ $end = strpos($model_text, 'auth:') - strlen('auth:');
+
+ // Extract JSON data, remove trailing comma
+ $model_text = trim(substr($model_text, $start, $end - $start));
+ $model_text = substr($model_text, 0, strlen($model_text) - 1);
+
+ return json_decode($model_text, true);
+
+ }
+
+ private function getPhotoModels($json, $filter) {
+
+ // The JSON model contains a "legend" array, where each element contains
+ // the path to an element in the "main" object
+ $photo_models = array();
+
+ foreach($json['legend'] as $legend) {
+
+ $photo_model = $json['main'];
+
+ foreach($legend as $element) { // Traverse tree
+ $photo_model = $photo_model[$element];
+ }
+
+ // We are only interested in content
+ if($photo_model['_flickrModelRegistry'] === $filter) {
+ $photo_models[] = $photo_model;
+ }
+
+ }
+
+ return $photo_models;
+
+ }
+
+ private function extractEnclosures($model) {
+
+ $areas = array();
+
+ foreach($model['sizes'] as $size) {
+ $areas[$size['width'] * $size['height']] = $size['url'];
+ }
+
+ return array($this->fixURL(max($areas)));
+
+ }
+
+ private function extractContentImage($model) {
+
+ $areas = array();
+ $limit = 320 * 240;
+
+ foreach($model['sizes'] as $size) {
+
+ $image_area = $size['width'] * $size['height'];
+
+ if($image_area >= $limit) {
+ $areas[$image_area] = $size['url'];
+ }
+
+ }
+
+ return $this->fixURL(min($areas));
+
+ }
+
+ private function fixURL($url) {
+
+ // For some reason the image URLs don't include the protocol (https)
+ if(strpos($url, '//') === 0) {
+ $url = 'https:' . $url;
+ }
+
+ return $url;
+
+ }
+}
diff --git a/bridges/FootitoBridge.php b/bridges/FootitoBridge.php
new file mode 100644
index 0000000..22aead4
--- /dev/null
+++ b/bridges/FootitoBridge.php
@@ -0,0 +1,75 @@
+<?php
+class FootitoBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'superbaillot.net';
+ const NAME = 'Footito';
+ const URI = 'http://www.footito.fr/';
+ const DESCRIPTION = 'Footito';
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request Footito.');
+
+ foreach($html->find('div.post') as $element) {
+ $item = array();
+
+ $content = trim($element->innertext);
+ $content = str_replace(
+ '<img',
+ "<img style='float : left;'",
+ $content );
+
+ $content = str_replace(
+ 'class="logo"',
+ "style='float : left;'",
+ $content );
+
+ $content = str_replace(
+ 'class="contenu"',
+ "style='margin-left : 60px;'",
+ $content );
+
+ $content = str_replace(
+ 'class="responsive-comment"',
+ "style='border-top : 1px #DDD solid; background-color : white; padding : 10px;'",
+ $content );
+
+ $content = str_replace(
+ 'class="jaime"',
+ "style='display : none;'",
+ $content );
+
+ $content = str_replace(
+ 'class="auteur-event responsive"',
+ "style='display : none;'",
+ $content );
+
+ $content = str_replace(
+ 'class="report-abuse-button"',
+ "style='display : none;'",
+ $content );
+
+ $content = str_replace(
+ 'class="reaction clearfix"',
+ "style='margin : 10px 0px; padding : 5px; border-bottom : 1px #DDD solid;'",
+ $content );
+
+ $content = str_replace(
+ 'class="infos"',
+ "style='font-size : 0.7em;'",
+ $content );
+
+ $item['content'] = $content;
+
+ $title = $element->find('.contenu .texte ', 0)->plaintext;
+ $item['title'] = $title;
+
+ $info = $element->find('div.infos', 0);
+
+ $item['timestamp'] = strtotime($info->find('time', 0)->datetime);
+ $item['author'] = $info->find('a.auteur', 0)->plaintext;
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/ForGifsBridge.php b/bridges/ForGifsBridge.php
new file mode 100644
index 0000000..ea599b9
--- /dev/null
+++ b/bridges/ForGifsBridge.php
@@ -0,0 +1,40 @@
+<?php
+class ForGifsBridge extends FeedExpander {
+
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'forgifs Bridge';
+ const URI = 'https://forgifs.com';
+ const DESCRIPTION = 'Returns the forgifs feed with actual gifs instead of images';
+
+ public function collectData() {
+ $this->collectExpandableDatas('https://forgifs.com/gallery/srss/7');
+ }
+
+ protected function parseItem($feedItem) {
+
+ $item = parent::parseItem($feedItem);
+
+ $content = str_get_html($item['content']);
+ $img = $content->find('img', 0);
+ $poster = $img->src;
+
+ // The actual gif is the same path but its id must be decremented by one.
+ // Example:
+ // http://forgifs.com/gallery/d/279419-2/Reporter-videobombed-shoulder-checks.gif
+ // http://forgifs.com/gallery/d/279418-2/Reporter-videobombed-shoulder-checks.gif
+ // Notice how this changes ----------^
+ // Now let's extract that number and do some math
+ // Notice: Technically we could also load the content page but that would
+ // require unnecessary traffic. As long as it works...
+ $num = substr($img->src, 29, 6);
+ $num -= 1;
+ $img->src = substr_replace($img->src, $num, 29, strlen($num));
+ $img->width = 'auto';
+ $img->height = 'auto';
+
+ $item['content'] = $content;
+
+ return $item;
+
+ }
+}
diff --git a/bridges/FourchanBridge.php b/bridges/FourchanBridge.php
new file mode 100644
index 0000000..b20b9c1
--- /dev/null
+++ b/bridges/FourchanBridge.php
@@ -0,0 +1,78 @@
+<?php
+class FourchanBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = '4chan';
+ const URI = 'https://boards.4chan.org/';
+ const CACHE_TIMEOUT = 300; // 5min
+ const DESCRIPTION = 'Returns posts from the specified thread';
+
+ const PARAMETERS = array( array(
+ 'c' => array(
+ 'name' => 'Thread category',
+ 'required' => true
+ ),
+ 't' => array(
+ 'name' => 'Thread number',
+ 'type' => 'number',
+ 'required' => true
+ )
+ ));
+
+ public function getURI(){
+ if(!is_null($this->getInput('c')) && !is_null($this->getInput('t'))) {
+ return static::URI . $this->getInput('c') . '/thread/' . $this->getInput('t');
+ }
+
+ return parent::getURI();
+ }
+
+ public function collectData(){
+
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request 4chan, thread not found');
+
+ foreach($html->find('div.postContainer') as $element) {
+ $item = array();
+ $item['id'] = $element->find('.post', 0)->getAttribute('id');
+ $item['uri'] = $this->getURI() . '#' . $item['id'];
+ $item['timestamp'] = $element->find('span.dateTime', 0)->getAttribute('data-utc');
+ $item['author'] = $element->find('span.name', 0)->plaintext;
+
+ $file = $element->find('.file', 0);
+
+ if(!empty($file)) {
+ $item['image'] = $element->find('.file a', 0)->href;
+ $item['imageThumb'] = $element->find('.file img', 0)->src;
+ if(!isset($item['imageThumb']) and strpos($item['image'], '.swf') !== false)
+ $item['imageThumb'] = 'http://i.imgur.com/eO0cxf9.jpg';
+ }
+
+ if(!empty($element->find('span.subject', 0)->innertext)) {
+ $item['subject'] = $element->find('span.subject', 0)->innertext;
+ }
+
+ $item['title'] = 'reply ' . $item['id'] . ' | ' . $item['author'];
+ if(isset($item['subject'])) {
+ $item['title'] = $item['subject'] . ' - ' . $item['title'];
+ }
+
+ $content = $element->find('.postMessage', 0)->innertext;
+ $content = str_replace('href="#p', 'href="' . $this->getURI() . '#p', $content);
+ $item['content'] = '<span id="' . $item['id'] . '">' . $content . '</span>';
+
+ if(isset($item['image'])) {
+ $item['content'] = '<a href="'
+ . $item['image']
+ . '"><img alt="'
+ . $item['id']
+ . '" src="'
+ . $item['imageThumb']
+ . '" /></a><br>'
+ . $item['content'];
+ }
+ $this->items[] = $item;
+ }
+ $this->items = array_reverse($this->items);
+ }
+}
diff --git a/bridges/FuturaSciencesBridge.php b/bridges/FuturaSciencesBridge.php
new file mode 100644
index 0000000..772f443
--- /dev/null
+++ b/bridges/FuturaSciencesBridge.php
@@ -0,0 +1,144 @@
+<?php
+class FuturaSciencesBridge extends FeedExpander {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'Futura-Sciences Bridge';
+ const URI = 'https://www.futura-sciences.com/';
+ const DESCRIPTION = 'Returns the newest articles.';
+
+ const PARAMETERS = array( array(
+ 'feed' => array(
+ 'name' => 'Feed',
+ 'type' => 'list',
+ 'values' => array(
+ 'Les flux multi-magazines' => array(
+ 'Les dernières actualités de Futura-Sciences' => 'actualites',
+ 'Les dernières définitions de Futura-Sciences' => 'definitions',
+ 'Les dernières photos de Futura-Sciences' => 'photos',
+ 'Les dernières questions - réponses de Futura-Sciences' => 'questions-reponses',
+ 'Les derniers dossiers de Futura-Sciences' => 'dossiers'
+ ),
+ 'Les flux Services' => array(
+ 'Les cartes virtuelles de Futura-Sciences' => 'services/cartes-virtuelles',
+ 'Les fonds d\'écran de Futura-Sciences' => 'services/fonds-ecran'
+ ),
+ 'Les flux Santé' => array(
+ 'Les dernières actualités de Futura-Santé' => 'sante/actualites',
+ 'Les dernières définitions de Futura-Santé' => 'sante/definitions',
+ 'Les dernières questions-réponses de Futura-Santé' => 'sante/question-reponses',
+ 'Les derniers dossiers de Futura-Santé' => 'sante/dossiers'
+ ),
+ 'Les flux High-Tech' => array(
+ 'Les dernières actualités de Futura-High-Tech' => 'high-tech/actualites',
+ 'Les dernières astuces de Futura-High-Tech' => 'high-tech/question-reponses',
+ 'Les dernières définitions de Futura-High-Tech' => 'high-tech/definitions',
+ 'Les derniers dossiers de Futura-High-Tech' => 'high-tech/dossiers'
+ ),
+ 'Les flux Espace' => array(
+ 'Les dernières actualités de Futura-Espace' => 'espace/actualites',
+ 'Les dernières définitions de Futura-Espace' => 'espace/definitions',
+ 'Les dernières questions-réponses de Futura-Espace' => 'espace/question-reponses',
+ 'Les derniers dossiers de Futura-Espace' => 'espace/dossiers'
+ ),
+ 'Les flux Environnement' => array(
+ 'Les dernières actualités de Futura-Environnement' => 'environnement/actualites',
+ 'Les dernières définitions de Futura-Environnement' => 'environnement/definitions',
+ 'Les dernières questions-réponses de Futura-Environnement' => 'environnement/question-reponses',
+ 'Les derniers dossiers de Futura-Environnement' => 'environnement/dossiers'
+ ),
+ 'Les flux Maison' => array(
+ 'Les dernières actualités de Futura-Maison' => 'maison/actualites',
+ 'Les dernières astuces de Futura-Maison' => 'maison/question-reponses',
+ 'Les dernières définitions de Futura-Maison' => 'maison/definitions',
+ 'Les derniers dossiers de Futura-Maison' => 'maison/dossiers'
+ ),
+ 'Les flux Nature' => array(
+ 'Les dernières actualités de Futura-Nature' => 'nature/actualites',
+ 'Les dernières définitions de Futura-Nature' => 'nature/definitions',
+ 'Les dernières questions-réponses de Futura-Nature' => 'nature/question-reponses',
+ 'Les derniers dossiers de Futura-Nature' => 'nature/dossiers'
+ ),
+ 'Les flux Terre' => array(
+ 'Les dernières actualités de Futura-Terre' => 'terre/actualites',
+ 'Les dernières définitions de Futura-Terre' => 'terre/definitions',
+ 'Les dernières questions-réponses de Futura-Terre' => 'terre/question-reponses',
+ 'Les derniers dossiers de Futura-Terre' => 'terre/dossiers'
+ ),
+ 'Les flux Matière' => array(
+ 'Les dernières actualités de Futura-Matière' => 'matiere/actualites',
+ 'Les dernières définitions de Futura-Matière' => 'matiere/definitions',
+ 'Les dernières questions-réponses de Futura-Matière' => 'matiere/question-reponses',
+ 'Les derniers dossiers de Futura-Matière' => 'matiere/dossiers'
+ ),
+ 'Les flux Mathématiques' => array(
+ 'Les dernières actualités de Futura-Mathématiques' => 'mathematiques/actualites',
+ 'Les derniers dossiers de Futura-Mathématiques' => 'mathematiques/dossiers'
+ )
+ )
+ )
+ ));
+
+ public function collectData(){
+ $url = self::URI . 'rss/' . $this->getInput('feed') . '.xml';
+ $this->collectExpandableDatas($url, 10);
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+ $item['uri'] = str_replace('#xtor=RSS-8', '', $item['uri']);
+ $article = getSimpleHTMLDOMCached($item['uri'])
+ or returnServerError('Could not request Futura-Sciences: ' . $item['uri']);
+ $item['content'] = $this->extractArticleContent($article);
+ $author = $this->extractAuthor($article);
+ if (!empty($author))
+ $item['author'] = $author;
+ return $item;
+ }
+
+ private function extractArticleContent($article){
+ $contents = $article->find('section.article-text-classic', 0)->innertext;
+ $headline = trim($article->find('p.description', 0)->plaintext);
+ if(!empty($headline))
+ $headline = '<p><b>' . $headline . '</b></p>';
+
+ foreach (array(
+ '<div class="clear',
+ '<div class="sharebar2',
+ '<div class="diaporamafullscreen"',
+ '<div class="module social-button',
+ '<div class="module social-share',
+ '<div style="margin-bottom:10px;" class="noprint"',
+ '<div class="ficheprevnext',
+ '<div class="bar noprint',
+ '<div class="toolbar noprint',
+ '<div class="addthis_toolbox',
+ '<div class="noprint',
+ '<div class="bg bglight border border-full noprint',
+ '<div class="httplogbar-wrapper noprint',
+ '<div id="forumcomments',
+ '<div ng-if="active"'
+ ) as $div_start) {
+ $contents = stripRecursiveHTMLSection($contents, 'div', $div_start);
+ }
+
+ $contents = stripWithDelimiters($contents, '<hr ', '/>');
+ $contents = stripWithDelimiters($contents, '<p class="content-date', '</p>');
+ $contents = stripWithDelimiters($contents, '<h1 class="content-title', '</h1>');
+ $contents = stripWithDelimiters($contents, 'fs:definition="', '"');
+ $contents = stripWithDelimiters($contents, 'fs:xt:clicktype="', '"');
+ $contents = stripWithDelimiters($contents, 'fs:xt:clickname="', '"');
+ $contents = StripWithDelimiters($contents, '<section class="module-toretain module-propal-nl', '</section>');
+ $contents = stripWithDelimiters($contents, '<script ', '</script>');
+
+ return $headline . trim($contents);
+ }
+
+ // Extracts the author from an article or element
+ private function extractAuthor($article){
+ $article_author = $article->find('h3.epsilon', 0);
+ if($article_author) {
+ return trim(str_replace(', Futura-Sciences', '', $article_author->plaintext));
+ }
+ return '';
+ }
+}
diff --git a/bridges/GBAtempBridge.php b/bridges/GBAtempBridge.php
new file mode 100644
index 0000000..9383be7
--- /dev/null
+++ b/bridges/GBAtempBridge.php
@@ -0,0 +1,151 @@
+<?php
+class GBAtempBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'GBAtemp';
+ const URI = 'https://gbatemp.net/';
+ const DESCRIPTION = 'GBAtemp is a user friendly underground video game community.';
+
+ const PARAMETERS = array( array(
+ 'type' => array(
+ 'name' => 'Type',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'News' => 'N',
+ 'Reviews' => 'R',
+ 'Tutorials' => 'T',
+ 'Forum' => 'F'
+ )
+ )
+ ));
+
+ private function buildItem($uri, $title, $author, $timestamp, $thumbnail, $content){
+ $item = array();
+ $item['uri'] = $uri;
+ $item['title'] = $title;
+ $item['author'] = $author;
+ $item['timestamp'] = $timestamp;
+ $item['content'] = $content;
+ if (!empty($thumbnail)) {
+ $item['enclosures'] = array($thumbnail);
+ }
+ return $item;
+ }
+
+ private function cleanupPostContent($content, $site_url){
+ $content = str_replace(':arrow:', '&#x27a4;', $content);
+ $content = str_replace('href="attachments/', 'href="' . $site_url . 'attachments/', $content);
+ $content = stripWithDelimiters($content, '<script', '</script>');
+ return $content;
+ }
+
+ private function findItemDate($item){
+ $time = 0;
+ $dateField = $item->find('abbr.DateTime', 0);
+ if (is_object($dateField)) {
+ $time = intval(
+ extractFromDelimiters(
+ $dateField->outertext,
+ 'data-time="',
+ '"'
+ )
+ );
+ } else {
+ $dateField = $item->find('span.DateTime', 0);
+ $time = DateTime::createFromFormat(
+ 'M j, Y \a\t g:i A',
+ extractFromDelimiters(
+ $dateField->outertext,
+ 'title="',
+ '"'
+ )
+ )->getTimestamp();
+ }
+ return $time;
+ }
+
+ private function fetchPostContent($uri, $site_url){
+ $html = getSimpleHTMLDOMCached($uri);
+ if(!$html) {
+ return 'Could not request GBAtemp: ' . $uri;
+ }
+
+ $content = $html->find('div.messageContent, blockquote.baseHtml', 0)->innertext;
+ return $this->cleanupPostContent($content, $site_url);
+ }
+
+ public function collectData(){
+
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request GBAtemp.');
+
+ switch($this->getInput('type')) {
+ case 'N':
+ foreach($html->find('li[class=news_item full]') as $newsItem) {
+ $url = self::URI . $newsItem->find('a', 0)->href;
+ $img = $this->getURI() . $newsItem->find('img', 0)->src . '#.image';
+ $time = $this->findItemDate($newsItem);
+ $author = $newsItem->find('a.username', 0)->plaintext;
+ $title = $newsItem->find('a', 1)->plaintext;
+ $content = $this->fetchPostContent($url, self::URI);
+ $this->items[] = $this->buildItem($url, $title, $author, $time, $img, $content);
+ unset($newsItem); // Some items are heavy, freeing the item proactively helps saving memory
+ }
+ break;
+ case 'R':
+ foreach($html->find('li.portal_review') as $reviewItem) {
+ $url = self::URI . $reviewItem->find('a', 0)->href;
+ $img = $this->getURI() . extractFromDelimiters($reviewItem->find('a', 0)->style, 'image:url(', ')');
+ $title = $reviewItem->find('span.review_title', 0)->plaintext;
+ $content = getSimpleHTMLDOM($url)
+ or returnServerError('Could not request GBAtemp: ' . $uri);
+ $author = $content->find('a.username', 0)->plaintext;
+ $time = $this->findItemDate($content);
+ $intro = '<p><b>' . ($content->find('div#review_intro', 0)->plaintext) . '</b></p>';
+ $review = $content->find('div#review_main', 0)->innertext;
+ $subheader = '<p><b>' . $content->find('div.review_subheader', 0)->plaintext . '</b></p>';
+ $procons = $content->find('table.review_procons', 0)->outertext;
+ $scores = $content->find('table.reviewscores', 0)->outertext;
+ $content = $this->cleanupPostContent($intro . $review . $subheader . $procons . $scores, self::URI);
+ $this->items[] = $this->buildItem($url, $title, $author, $time, $img, $content);
+ unset($reviewItem); // Free up memory
+ }
+ break;
+ case 'T':
+ foreach($html->find('li.portal-tutorial') as $tutorialItem) {
+ $url = self::URI . $tutorialItem->find('a', 0)->href;
+ $title = $tutorialItem->find('a', 0)->plaintext;
+ $time = $this->findItemDate($tutorialItem);
+ $author = $tutorialItem->find('a.username', 0)->plaintext;
+ $content = $this->fetchPostContent($url, self::URI);
+ $this->items[] = $this->buildItem($url, $title, $author, $time, null, $content);
+ unset($tutorialItem); // Free up memory
+ }
+ break;
+ case 'F':
+ foreach($html->find('li.rc_item') as $postItem) {
+ $url = self::URI . $postItem->find('a', 1)->href;
+ $title = $postItem->find('a', 1)->plaintext;
+ $time = $this->findItemDate($postItem);
+ $author = $postItem->find('a.username', 0)->plaintext;
+ $content = $this->fetchPostContent($url, self::URI);
+ $this->items[] = $this->buildItem($url, $title, $author, $time, null, $content);
+ unset($postItem); // Free up memory
+ }
+ break;
+ }
+ }
+
+ public function getName() {
+ if(!is_null($this->getInput('type'))) {
+ $type = array_search(
+ $this->getInput('type'),
+ self::PARAMETERS[$this->queriedContext]['type']['values']
+ );
+ return 'GBAtemp ' . $type . ' Bridge';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/GOGBridge.php b/bridges/GOGBridge.php
new file mode 100644
index 0000000..669332f
--- /dev/null
+++ b/bridges/GOGBridge.php
@@ -0,0 +1,65 @@
+<?php
+class GOGBridge extends BridgeAbstract {
+
+ const NAME = 'GOGBridge';
+ const MAINTAINER = 'teromene';
+ const URI = 'https://gog.com';
+ const DESCRIPTION = 'Returns the latest releases from GOG.com';
+
+ public function collectData() {
+
+ $values = getContents('https://www.gog.com/games/ajax/filtered?limit=25&sort=new') or
+ die('Unable to get the news pages from GOG !');
+ $decodedValues = json_decode($values);
+
+ $limit = 0;
+ foreach($decodedValues->products as $game) {
+
+ $item = array();
+ $item['author'] = $game->developer . ' / ' . $game->publisher;
+ $item['title'] = $game->title;
+ $item['id'] = $game->id;
+ $item['uri'] = self::URI . $game->url;
+ $item['content'] = $this->buildGameContentPage($game);
+ $item['timestamp'] = $game->globalReleaseDate;
+
+ foreach($game->gallery as $image) {
+ $item['enclosures'][] = $image . '.jpg';
+ }
+
+ $this->items[] = $item;
+ $limit += 1;
+
+ if($limit == 10) break;
+
+ }
+
+ }
+
+ private function buildGameContentPage($game) {
+
+ $gameDescriptionText = getContents('https://api.gog.com/products/' . $game->id . '?expand=description') or
+ die('Unable to get game description from GOG !');
+
+ $gameDescriptionValue = json_decode($gameDescriptionText);
+
+ $content = 'Genres: ';
+ $content .= implode(', ', $game->genres);
+
+ $content .= '<br />Supported Platforms: ';
+ if($game->worksOn->Windows) {
+ $content .= 'Windows ';
+ }
+ if($game->worksOn->Mac) {
+ $content .= 'Mac ';
+ }
+ if($game->worksOn->Linux) {
+ $content .= 'Linux ';
+ }
+
+ $content .= '<br />' . $gameDescriptionValue->description->full;
+
+ return $content;
+
+ }
+}
diff --git a/bridges/GQMagazineBridge.php b/bridges/GQMagazineBridge.php
new file mode 100644
index 0000000..961b3a0
--- /dev/null
+++ b/bridges/GQMagazineBridge.php
@@ -0,0 +1,123 @@
+<?php
+
+/**
+ * An extension of the previous SexactuBridge to cover the whole GQMagazine.
+ * This one taks a page (as an example sexe/news or journaliste/maia-mazaurette) which is to be configured,
+ * reads all the articles visible on that page, and make a stream out of it.
+ * @author nicolas-delsaux
+ *
+ */
+class GQMagazineBridge extends BridgeAbstract
+{
+ const MAINTAINER = 'Riduidel';
+
+ const NAME = 'GQMagazine';
+
+ // URI is no more valid, since we can address the whole gq galaxy
+ const URI = 'https://www.gqmagazine.fr';
+
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'GQMagazine section extractor bridge. This bridge allows you get only a specific section.';
+
+ const DEFAULT_DOMAIN = 'www.gqmagazine.fr';
+
+ const PARAMETERS = array( array(
+ 'domain' => array(
+ 'name' => 'Domain to use',
+ 'required' => true,
+ 'defaultValue' => self::DEFAULT_DOMAIN
+ ),
+ 'page' => array(
+ 'name' => 'Initial page to load',
+ 'required' => true,
+ 'exampleValue' => 'sexe/news'
+ ),
+ ));
+
+ const REPLACED_ATTRIBUTES = array(
+ 'href' => 'href',
+ 'src' => 'src',
+ 'data-original' => 'src'
+ );
+
+ private function getDomain() {
+ $domain = $this->getInput('domain');
+ if (empty($domain))
+ $domain = self::DEFAULT_DOMAIN;
+ if (strpos($domain, '://') === false)
+ $domain = 'https://' . $domain;
+ return $domain;
+ }
+
+ public function getURI()
+ {
+ return $this->getDomain() . '/' . $this->getInput('page');
+ }
+
+ public function collectData()
+ {
+ $html = getSimpleHTMLDOM($this->getURI()) or returnServerError('Could not request ' . $this->getURI());
+
+ // Since GQ don't want simple class scrapping, let's do it the hard way and ... discover content !
+ $main = $html->find('main', 0);
+ foreach ($main->find('a') as $link) {
+ $uri = $link->href;
+ $title = $link->find('h2', 0);
+ $date = $link->find('time', 0);
+
+ $item = array();
+ $author = $link->find('span[itemprop=name]', 0);
+ $item['author'] = $author->plaintext;
+ $item['title'] = $title->plaintext;
+ if(substr($uri, 0, 1) === 'h') { // absolute uri
+ $item['uri'] = $uri;
+ } else if(substr($uri, 0, 1) === '/') { // domain relative url
+ $item['uri'] = $this->getDomain() . $uri;
+ } else {
+ $item['uri'] = $this->getDomain() . '/' . $uri;
+ }
+
+ $article = $this->loadFullArticle($item['uri']);
+ if($article) {
+ $item['content'] = $this->replaceUriInHtmlElement($article);
+ } else {
+ $item['content'] = "<strong>Article body couldn't be loaded</strong>. It must be a bug!";
+ }
+ $short_date = $date->datetime;
+ $item['timestamp'] = strtotime($short_date);
+ $this->items[] = $item;
+ }
+ }
+
+ /**
+ * Loads the full article and returns the contents
+ * @param $uri The article URI
+ * @return The article content
+ */
+ private function loadFullArticle($uri){
+ $html = getSimpleHTMLDOMCached($uri);
+ // Once again, that generated css classes madness is an obstacle ... which i can go over easily
+ foreach($html->find('div') as $div) {
+ // List the CSS classes of that div
+ $classes = $div->class;
+ // I can't directly lookup that class since GQ since to generate random names like "ArticleBodySection-fkggUW"
+ if(strpos($classes, 'ArticleBodySection') !== false) {
+ return $div;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Replaces all relative URIs with absolute ones
+ * @param $element A simplehtmldom element
+ * @return The $element->innertext with all URIs replaced
+ */
+ private function replaceUriInHtmlElement($element){
+ $returned = $element->innertext;
+ foreach (self::REPLACED_ATTRIBUTES as $initial => $final) {
+ $returned = str_replace($initial . '="/', $final . '="' . self::URI . '/', $returned);
+ }
+ return $returned;
+ }
+}
diff --git a/bridges/GelbooruBridge.php b/bridges/GelbooruBridge.php
new file mode 100644
index 0000000..4fe30e2
--- /dev/null
+++ b/bridges/GelbooruBridge.php
@@ -0,0 +1,35 @@
+<?php
+require_once('DanbooruBridge.php');
+
+class GelbooruBridge extends DanbooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Gelbooru';
+ const URI = 'http://gelbooru.com/';
+ const DESCRIPTION = 'Returns images from given page';
+
+ const PATHTODATA = '.thumb';
+ const IDATTRIBUTE = 'id';
+ const TAGATTRIBUTE = 'title';
+
+ const PIDBYPAGE = 63;
+
+ protected function getFullURI(){
+ return $this->getURI()
+ . 'index.php?page=post&s=list&pid='
+ . ($this->getInput('p') ? ($this->getInput('p') - 1) * static::PIDBYPAGE : '')
+ . '&tags=' . urlencode($this->getInput('t'));
+ }
+
+ protected function getTags($element){
+ $tags = parent::getTags($element);
+ $tags = explode(' ', $tags);
+
+ // Remove statistics from the tags list (identified by colon)
+ foreach($tags as $key => $tag) {
+ if(strpos($tag, ':') !== false) unset($tags[$key]);
+ }
+
+ return implode(' ', $tags);
+ }
+}
diff --git a/bridges/GiphyBridge.php b/bridges/GiphyBridge.php
new file mode 100644
index 0000000..26d1eba
--- /dev/null
+++ b/bridges/GiphyBridge.php
@@ -0,0 +1,76 @@
+<?php
+define('GIPHY_LIMIT', 10);
+
+class GiphyBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'kraoc';
+ const NAME = 'Giphy Bridge';
+ const URI = 'http://giphy.com/';
+ const CACHE_TIMEOUT = 300; //5min
+ const DESCRIPTION = 'Bridge for giphy.com';
+
+ const PARAMETERS = array( array(
+ 's' => array(
+ 'name' => 'search tag',
+ 'required' => true
+ ),
+ 'n' => array(
+ 'name' => 'max number of returned items',
+ 'type' => 'number'
+ )
+ ));
+
+ public function collectData(){
+ $html = '';
+ $base_url = 'http://giphy.com';
+ $html = getSimpleHTMLDOM(self::URI . '/search/' . urlencode($this->getInput('s') . '/'))
+ or returnServerError('No results for this query.');
+
+ $max = GIPHY_LIMIT;
+ if($this->getInput('n')) {
+ $max = $this->getInput('n');
+ }
+
+ $limit = 0;
+ $kw = urlencode($this->getInput('s'));
+ foreach($html->find('div.hoverable-gif') as $entry) {
+ if($limit < $max) {
+ $node = $entry->first_child();
+ $href = $node->getAttribute('href');
+
+ $html2 = getSimpleHTMLDOM(self::URI . $href)
+ or returnServerError('No results for this query.');
+ $figure = $html2->getElementByTagName('figure');
+ $img = $figure->firstChild();
+ $caption = $figure->lastChild();
+
+ $item = array();
+ $item['id'] = $img->getAttribute('data-gif_id');
+ $item['uri'] = $img->getAttribute('data-bitly_gif_url');
+ $item['username'] = 'Giphy - ' . ucfirst($kw);
+ $title = $caption->innertext();
+ $title = preg_replace('/\s+/', ' ', $title);
+ $title = str_replace('animated GIF', '', $title);
+ $title = str_replace($kw, '', $title);
+ $title = preg_replace('/\s+/', ' ', $title);
+ $title = trim($title);
+ if(strlen($title) <= 0) {
+ $title = $item['id'];
+ }
+ $item['title'] = trim($title);
+ $item['content'] = '<a href="'
+ . $item['uri']
+ . '"><img src="'
+ . $img->getAttribute('src')
+ . '" width="'
+ . $img->getAttribute('data-original-width')
+ . '" height="'
+ . $img->getAttribute('data-original-height')
+ . '" /></a>';
+
+ $this->items[] = $item;
+ $limit++;
+ }
+ }
+ }
+}
diff --git a/bridges/GitHubGistBridge.php b/bridges/GitHubGistBridge.php
new file mode 100644
index 0000000..f20acb5
--- /dev/null
+++ b/bridges/GitHubGistBridge.php
@@ -0,0 +1,163 @@
+<?php
+
+class GitHubGistBridge extends BridgeAbstract {
+
+ const NAME = 'GitHubGist comment bridge';
+ const URI = 'https://gist.github.com';
+ const DESCRIPTION = 'Generates feeds for Gist comments';
+ const MAINTAINER = 'logmanoriginal';
+ const CACHE_TIMEOUT = 3600;
+
+ const PARAMETERS = array(array(
+ 'id' => array(
+ 'name' => 'Gist',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Insert Gist ID or URI',
+ 'exampleValue' => '2646763, https://gist.github.com/2646763'
+ )
+ ));
+
+ private $filename;
+
+ public function getURI() {
+
+ $id = $this->getInput('id') ?: '';
+
+ $urlpath = parse_url($id, PHP_URL_PATH);
+
+ if($urlpath) {
+
+ $components = explode('/', $urlpath);
+ $id = end($components);
+
+ }
+
+ return static::URI . '/' . $id;
+
+ }
+
+ public function getName() {
+ return $this->filename ? $this->filename . ' - ' . static::NAME : static::NAME;
+ }
+
+ public function collectData() {
+
+ $html = getSimpleHTMLDOM($this->getURI(),
+ null,
+ null,
+ true,
+ true,
+ DEFAULT_TARGET_CHARSET,
+ false, // Do NOT remove line breaks
+ DEFAULT_BR_TEXT,
+ DEFAULT_SPAN_TEXT)
+ or returnServerError('Could not request ' . $this->getURI());
+
+ $html = defaultLinkTo($html, $this->getURI());
+
+ $fileinfo = $html->find('[class="file-info"]', 0)
+ or returnServerError('Could not find file info!');
+
+ $this->filename = $fileinfo->plaintext;
+
+ $comments = $html->find('div[class="timeline-comment-wrapper"]');
+
+ if(is_null($comments)) { // no comments yet
+ return;
+ }
+
+ foreach($comments as $comment) {
+
+ $uri = $comment->find('a[href*=#gistcomment]', 0)
+ or returnServerError('Could not find comment anchor!');
+
+ $title = $comment->find('div[class="unminimized-comment"] h3[class="timeline-comment-header-text"]', 0)
+ or returnServerError('Could not find comment header text!');
+
+ $datetime = $comment->find('[datetime]', 0)
+ or returnServerError('Could not find comment datetime!');
+
+ $author = $comment->find('a.author', 0)
+ or returnServerError('Could not find author name!');
+
+ $message = $comment->find('[class="comment-body"]', 0)
+ or returnServerError('Could not find comment body!');
+
+ $item = array();
+
+ $item['uri'] = $uri->href;
+ $item['title'] = str_replace('commented', 'commented on', $title->plaintext);
+ $item['timestamp'] = strtotime($datetime->datetime);
+ $item['author'] = '<a href="' . $author->href . '">' . $author->plaintext . '</a>';
+ $item['content'] = $this->fixContent($message);
+ // $item['enclosures'] = array();
+ // $item['categories'] = array();
+
+ $this->items[] = $item;
+
+ }
+
+ }
+
+ /** Removes all unnecessary tags and adds formatting */
+ private function fixContent($content){
+
+ // Restore code (inside <pre />) highlighting
+ foreach($content->find('pre') as $pre) {
+
+ $pre->style = <<<EOD
+padding: 16px;
+overflow: auto;
+font-size: 85%;
+line-height: 1.45;
+background-color: #f6f8fa;
+border-radius: 3px;
+word-wrap: normal;
+box-sizing: border-box;
+margin-bottom: 16px;
+EOD;
+
+ $code = $pre->find('code', 0);
+
+ if($code) {
+
+ $code->style = <<<EOD
+white-space: pre;
+word-break: normal;
+EOD;
+
+ }
+
+ }
+
+ // find <code /> not inside <pre /> (`inline-code`)
+ foreach($content->find('code') as $code) {
+
+ if($code->parent()->tag === 'pre') {
+ continue;
+ }
+
+ $code->style = <<<EOD
+background-color: rgba(27,31,35,0.05);
+padding: 0.2em 0.4em;
+border-radius: 3px;
+EOD;
+
+ }
+
+ // restore text spacing
+ foreach($content->find('p') as $p) {
+ $p->style = 'margin-bottom: 16px;';
+ }
+
+ // Remove unnecessary tags
+ $content = strip_tags(
+ $content->innertext,
+ '<p><a><img><ol><ul><li><table><tr><th><td><string><pre><code><br><hr><h>'
+ );
+
+ return $content;
+
+ }
+}
diff --git a/bridges/GithubIssueBridge.php b/bridges/GithubIssueBridge.php
new file mode 100644
index 0000000..91dd45e
--- /dev/null
+++ b/bridges/GithubIssueBridge.php
@@ -0,0 +1,219 @@
+<?php
+class GithubIssueBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Pierre Mazière';
+ const NAME = 'Github Issue';
+ const URI = 'https://github.com/';
+ const CACHE_TIMEOUT = 600; // 10min
+ const DESCRIPTION = 'Returns the issues or comments of an issue of a github project';
+
+ const PARAMETERS = array(
+ 'global' => array(
+ 'u' => array(
+ 'name' => 'User name',
+ 'required' => true
+ ),
+ 'p' => array(
+ 'name' => 'Project name',
+ 'required' => true
+ )
+ ),
+ 'Project Issues' => array(
+ 'c' => array(
+ 'name' => 'Show Issues Comments',
+ 'type' => 'checkbox'
+ )
+ ),
+ 'Issue comments' => array(
+ 'i' => array(
+ 'name' => 'Issue number',
+ 'type' => 'number',
+ 'required' => true
+ )
+ )
+ );
+
+ public function getName(){
+ $name = $this->getInput('u') . '/' . $this->getInput('p');
+ switch($this->queriedContext) {
+ case 'Project Issues':
+ $prefix = static::NAME . 's for ';
+ if($this->getInput('c')) {
+ $prefix = static::NAME . 's comments for ';
+ }
+ $name = $prefix . $name;
+ break;
+ case 'Issue comments':
+ $name = static::NAME . ' ' . $name . ' #' . $this->getInput('i');
+ break;
+ default: return parent::getName();
+ }
+ return $name;
+ }
+
+ public function getURI(){
+ if(null !== $this->getInput('u') && null !== $this->getInput('p')) {
+ $uri = static::URI . $this->getInput('u') . '/'
+ . $this->getInput('p') . '/issues';
+ if($this->queriedContext === 'Issue comments') {
+ $uri .= '/' . $this->getInput('i');
+ } elseif($this->getInput('c')) {
+ $uri .= '?q=is%3Aissue+sort%3Aupdated-desc';
+ }
+ return $uri;
+ }
+
+ return parent::getURI();
+ }
+
+ protected function extractIssueEvent($issueNbr, $title, $comment){
+ $comment = $comment->firstChild();
+ $uri = static::URI . $this->getInput('u') . '/' . $this->getInput('p')
+ . '/issues/' . $issueNbr . '#' . $comment->getAttribute('id');
+
+ $author = $comment->find('.author', 0)->plaintext;
+
+ $title .= ' / ' . trim($comment->plaintext);
+
+ $content = $title;
+ if (null !== $comment->nextSibling()) {
+ $content = $comment->nextSibling()->innertext;
+ if ($comment->nextSibling()->nodeName() === 'span') {
+ $content = $comment->nextSibling()->nextSibling()->innertext;
+ }
+ }
+
+ $item = array();
+ $item['author'] = $author;
+ $item['uri'] = $uri;
+ $item['title'] = html_entity_decode($title, ENT_QUOTES, 'UTF-8');
+ $item['timestamp'] = strtotime(
+ $comment->find('relative-time', 0)->getAttribute('datetime')
+ );
+ $item['content'] = $content;
+ return $item;
+ }
+
+ protected function extractIssueComment($issueNbr, $title, $comment){
+ $uri = static::URI . $this->getInput('u') . '/'
+ . $this->getInput('p') . '/issues/' . $issueNbr;
+
+ $author = $comment->find('.author', 0)->plaintext;
+
+ $title .= ' / ' . trim(
+ $comment->find('.comment .timeline-comment-header-text', 0)->plaintext
+ );
+
+ $content = $comment->find('.comment-body', 0)->innertext;
+
+ $item = array();
+ $item['author'] = $author;
+ $item['uri'] = $uri
+ . '#' . $comment->firstChild()->nextSibling()->getAttribute('id');
+ $item['title'] = html_entity_decode($title, ENT_QUOTES, 'UTF-8');
+ $item['timestamp'] = strtotime(
+ $comment->find('relative-time', 0)->getAttribute('datetime')
+ );
+ $item['content'] = $content;
+ return $item;
+ }
+
+ protected function extractIssueComments($issue){
+ $items = array();
+ $title = $issue->find('.gh-header-title', 0)->plaintext;
+ $issueNbr = trim(
+ substr($issue->find('.gh-header-number', 0)->plaintext, 1)
+ );
+ $comments = $issue->find('.js-discussion', 0);
+ foreach($comments->children() as $comment) {
+ if (!$comment->hasChildNodes()) {
+ continue;
+ }
+ $comment = $comment->firstChild();
+ $classes = explode(' ', $comment->getAttribute('class'));
+ if (in_array('timeline-comment-wrapper', $classes)) {
+ $item = $this->extractIssueComment($issueNbr, $title, $comment);
+ $items[] = $item;
+ continue;
+ }
+ while (in_array('discussion-item', $classes)) {
+ $item = $this->extractIssueEvent($issueNbr, $title, $comment);
+ $items[] = $item;
+ $comment = $comment->nextSibling();
+ if (null == $comment) {
+ break;
+ }
+ $classes = explode(' ', $comment->getAttribute('class'));
+ }
+ }
+ return $items;
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError(
+ 'No results for Github Issue ' . $this->getURI()
+ );
+
+ switch($this->queriedContext) {
+ case 'Issue comments':
+ $this->items = $this->extractIssueComments($html);
+ break;
+ case 'Project Issues':
+ foreach($html->find('.js-active-navigation-container .js-navigation-item') as $issue) {
+ $info = $issue->find('.opened-by', 0);
+ $issueNbr = substr(
+ trim($info->plaintext), 1, strpos(trim($info->plaintext), ' ')
+ );
+
+ $item = array();
+ $item['content'] = '';
+
+ if($this->getInput('c')) {
+ $uri = static::URI . $this->getInput('u')
+ . '/' . $this->getInput('p') . '/issues/' . $issueNbr;
+ $issue = getSimpleHTMLDOMCached($uri, static::CACHE_TIMEOUT);
+ if($issue) {
+ $this->items = array_merge(
+ $this->items,
+ $this->extractIssueComments($issue)
+ );
+ continue;
+ }
+ $item['content'] = 'Can not extract comments from ' . $uri;
+ }
+
+ $item['author'] = $info->find('a', 0)->plaintext;
+ $item['timestamp'] = strtotime(
+ $info->find('relative-time', 0)->getAttribute('datetime')
+ );
+ $item['title'] = html_entity_decode(
+ $issue->find('.js-navigation-open', 0)->plaintext,
+ ENT_QUOTES,
+ 'UTF-8'
+ );
+ $comments = trim($issue->find('.col-5', 0)->plaintext);
+ $item['content'] .= "\n" . 'Comments: ' . ($comments ? $comments : '0');
+ $item['uri'] = self::URI
+ . $issue->find('.js-navigation-open', 0)->getAttribute('href');
+ $this->items[] = $item;
+ }
+ break;
+ }
+
+ array_walk($this->items, function(&$item){
+ $item['content'] = preg_replace('/\s+/', ' ', $item['content']);
+ $item['content'] = str_replace(
+ 'href="/',
+ 'href="' . static::URI,
+ $item['content']
+ );
+ $item['content'] = str_replace(
+ 'href="#',
+ 'href="' . substr($item['uri'], 0, strpos($item['uri'], '#') + 1),
+ $item['content']
+ );
+ $item['title'] = preg_replace('/\s+/', ' ', $item['title']);
+ });
+ }
+}
diff --git a/bridges/GithubSearchBridge.php b/bridges/GithubSearchBridge.php
new file mode 100644
index 0000000..fe8a721
--- /dev/null
+++ b/bridges/GithubSearchBridge.php
@@ -0,0 +1,66 @@
+<?php
+class GithubSearchBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'corenting';
+ const NAME = 'Github Repositories Search';
+ const URI = 'https://github.com/';
+ const CACHE_TIMEOUT = 600; // 10min
+ const DESCRIPTION = 'Returns a specified repositories search (sorted by recently updated)';
+ const PARAMETERS = array( array(
+ 's' => array(
+ 'type' => 'text',
+ 'name' => 'Search query'
+ )
+ ));
+
+ public function collectData(){
+ $params = array('utf8' => '✓',
+ 'q' => urlencode($this->getInput('s')),
+ 's' => 'updated',
+ 'o' => 'desc',
+ 'type' => 'Repositories');
+ $url = self::URI . 'search?' . http_build_query($params);
+
+ $html = getSimpleHTMLDOM($url)
+ or returnServerError('Error while downloading the website content');
+
+ foreach($html->find('div.repo-list-item') as $element) {
+ $item = array();
+
+ $uri = $element->find('h3 a', 0)->href;
+ $uri = substr(self::URI, 0, -1) . $uri;
+ $item['uri'] = $uri;
+
+ $title = $element->find('h3', 0)->plaintext;
+ $item['title'] = $title;
+
+ // Description
+ if (count($element->find('p.d-inline-block')) != 0) {
+ $content = $element->find('p.d-inline-block', 0)->innertext;
+ } else{
+ $content = 'No description';
+ }
+
+ // Tags
+ $content = $content . '<br />';
+ $tags = $element->find('a.topic-tag');
+ $tags_array = array();
+ if (count($tags) != 0) {
+ $content = $content . 'Tags : ';
+ foreach($tags as $tag_element) {
+ $tag_link = 'https://github.com' . $tag_element->href;
+ $tag_name = trim($tag_element->innertext);
+ $content = $content . '<a href="' . $tag_link . '">' . $tag_name . '</a> ';
+ array_push($tags_array, $tag_element->innertext);
+ }
+ }
+
+ $item['categories'] = $tags_array;
+ $item['content'] = $content;
+ $date = $element->find('relative-time', 0)->datetime;
+ $item['timestamp'] = strtotime($date);
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/GizmodoBridge.php b/bridges/GizmodoBridge.php
new file mode 100644
index 0000000..35f162b
--- /dev/null
+++ b/bridges/GizmodoBridge.php
@@ -0,0 +1,36 @@
+<?php
+class GizmodoBridge extends FeedExpander {
+
+ const MAINTAINER = 'polopollo';
+ const NAME = 'Gizmodo';
+ const URI = 'http://gizmodo.com/';
+ const CACHE_TIMEOUT = 1800; // 30min
+ const DESCRIPTION = 'Returns the newest posts from Gizmodo (full text).';
+
+ protected function parseItem($item){
+ $item = parent::parseItem($item);
+
+ $articleHTMLContent = getSimpleHTMLDOMCached($item['uri']);
+ if(!$articleHTMLContent) {
+ $text = 'Could not load ' . $item['uri'];
+ } else {
+ $text = $articleHTMLContent->find('div.entry-content', 0)->innertext;
+ foreach($articleHTMLContent->find('pagespeed_iframe') as $element) {
+ $text .= '<p>link to a iframe (could be a video): <a href="'
+ . $element->src
+ . '">'
+ . $element->src
+ . '</a></p><br>';
+ }
+
+ $text = strip_tags($text, '<p><b><a><blockquote><img><em>');
+ }
+
+ $item['content'] = $text;
+ return $item;
+ }
+
+ public function collectData(){
+ $this->collectExpandableDatas('http://feeds.gawker.com/gizmodo/full');
+ }
+}
diff --git a/bridges/GlassdoorBridge.php b/bridges/GlassdoorBridge.php
new file mode 100644
index 0000000..1e20077
--- /dev/null
+++ b/bridges/GlassdoorBridge.php
@@ -0,0 +1,221 @@
+<?php
+class GlassdoorBridge extends BridgeAbstract {
+
+ // Contexts
+ const CONTEXT_BLOG = 'Blogs';
+ const CONTEXT_REVIEW = 'Company Reviews';
+ const CONTEXT_GLOBAL = 'global';
+
+ // Global context parameters
+ const PARAM_LIMIT = 'limit';
+
+ // Blog context parameters
+ const PARAM_BLOG_TYPE = 'blog_type';
+ const PARAM_BLOG_FULL = 'full_article';
+
+ const BLOG_TYPE_HOME = 'Home';
+ const BLOG_TYPE_COMPANIES_HIRING = 'Companies Hiring';
+ const BLOG_TYPE_CAREER_ADVICE = 'Career Advice';
+ const BLOG_TYPE_INTERVIEWS = 'Interviews';
+ const BLOG_TYPE_GUIDE = 'Guides';
+
+ // Review context parameters
+ const PARAM_REVIEW_COMPANY = 'company';
+
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'Glassdoor Bridge';
+ const URI = 'https://www.glassdoor.com/';
+ const DESCRIPTION = 'Returns feeds for blog posts and company reviews';
+ const CACHE_TIMEOUT = 86400; // 24 hours
+
+ const PARAMETERS = array(
+ self::CONTEXT_BLOG => array(
+ self::PARAM_BLOG_TYPE => array(
+ 'name' => 'Blog type',
+ 'type' => 'list',
+ 'title' => 'Select the blog you want to follow',
+ 'values' => array(
+ self::BLOG_TYPE_HOME => 'blog/',
+ self::BLOG_TYPE_COMPANIES_HIRING => 'blog/companies-hiring/',
+ self::BLOG_TYPE_CAREER_ADVICE => 'blog/career-advice/',
+ self::BLOG_TYPE_INTERVIEWS => 'blog/interviews/',
+ self::BLOG_TYPE_GUIDE => 'blog/guide/'
+ )
+ ),
+ self::PARAM_BLOG_FULL => array(
+ 'name' => 'Full article',
+ 'type' => 'checkbox',
+ 'title' => 'Enable to return the full article for each post'
+ ),
+ ),
+ self::CONTEXT_REVIEW => array(
+ self::PARAM_REVIEW_COMPANY => array(
+ 'name' => 'Company URL',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Paste the company review page URL here!',
+ 'exampleValue' => 'https://www.glassdoor.com/Reviews/GitHub-Reviews-E671945.htm'
+ )
+ ),
+ self::CONTEXT_GLOBAL => array(
+ self::PARAM_LIMIT => array(
+ 'name' => 'Limit',
+ 'type' => 'number',
+ 'defaultValue' => -1,
+ 'title' => 'Specifies the maximum number of items to return (default: All)'
+ )
+ )
+ );
+
+ private $host = self::URI; // They redirect without notice :/
+ private $title = '';
+
+ public function getURI() {
+ switch($this->queriedContext) {
+ case self::CONTEXT_BLOG:
+ return self::URI . $this->getInput(self::PARAM_BLOG_TYPE);
+ case self::CONTEXT_REVIEW:
+ return $this->filterCompanyURI($this->getInput(self::PARAM_REVIEW_COMPANY));
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName() {
+ return $this->title ? $this->title . ' - ' . self::NAME : parent::getName();
+ }
+
+ public function collectData() {
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Failed loading contents!');
+
+ $this->host = $html->find('link[rel="canonical"]', 0)->href;
+
+ $html = defaultLinkTo($html, $this->host);
+
+ $this->title = $html->find('meta[property="og:title"]', 0)->content;
+ $limit = $this->getInput(self::PARAM_LIMIT);
+
+ switch($this->queriedContext) {
+ case self::CONTEXT_BLOG:
+ $this->collectBlogData($html, $limit);
+ break;
+ case self::CONTEXT_REVIEW:
+ $this->collectReviewData($html, $limit);
+ break;
+ }
+ }
+
+ private function collectBlogData($html, $limit) {
+ $posts = $html->find('section')
+ or returnServerError('Unable to find blog posts!');
+
+ foreach($posts as $post) {
+ $item = array();
+
+ $item['uri'] = $post->find('header a', 0)->href;
+ $item['title'] = $post->find('header', 0)->plaintext;
+ $item['content'] = $post->find('div[class="excerpt-content"]', 0)->plaintext;
+ $item['enclosures'] = array(
+ $this->getFullSizeImageURI($post->find('div[class="post-thumb"]', 0)->{'data-original'})
+ );
+
+ // optionally load full articles
+ if($this->getInput(self::PARAM_BLOG_FULL)) {
+ $full_html = getSimpleHTMLDOMCached($item['uri'])
+ or returnServerError('Unable to load full article!');
+
+ $full_html = defaultLinkTo($full_html, $this->host);
+
+ $item['author'] = $full_html->find('a[rel="author"]', 0);
+ $item['content'] = $full_html->find('article', 0);
+ $item['timestamp'] = strtotime($full_html->find('time.updated', 0)->datetime);
+ $item['categories'] = $full_html->find('span[class="post_tag"]');
+ }
+
+ $this->items[] = $item;
+
+ if($limit > 0 && count($this->items) >= $limit)
+ return;
+ }
+ }
+
+ private function collectReviewData($html, $limit) {
+ $reviews = $html->find('#EmployerReviews li[id^="empReview]')
+ or returnServerError('Unable to find reviews!');
+
+ foreach($reviews as $review) {
+ $item = array();
+
+ $item['uri'] = $review->find('a.reviewLink', 0)->href;
+ $item['title'] = $review->find('[class="summary"]', 0)->plaintext;
+ $item['author'] = $review->find('div.author span', 0)->plaintext;
+ $item['timestamp'] = strtotime($review->find('time', 0)->datetime);
+
+ $mainText = $review->find('p.mainText', 0)->plaintext;
+ $description = $review->find('div.prosConsAdvice', 0)->innertext;
+ $item['content'] = "<p>{$mainText}</p><p>{$description}</p>";
+
+ $this->items[] = $item;
+
+ if($limit > 0 && count($this->items) >= $limit)
+ return;
+ }
+ }
+
+ private function getFullSizeImageURI($uri) {
+ /* Images are scaled for display on the website. The scaling takes place
+ * on the host, who provides images in different sizes.
+ *
+ * For example:
+ * https://www.glassdoor.com/blog/app/uploads/sites/2/GettyImages-982402074-e1538092065712-390x193.jpg
+ *
+ * By removing the size information we receive the full sized image.
+ *
+ * For example:
+ * https://www.glassdoor.com/blog/app/uploads/sites/2/GettyImages-982402074-e1538092065712.jpg
+ */
+
+ $uri = filter_var($uri, FILTER_SANITIZE_URL);
+ return preg_replace('/(.*)(\-\d+x\d+)(\.jpg)/', '$1$3', $uri);
+ }
+
+ private function filterCompanyURI($uri) {
+ /* Make sure the URI is a valid review page. Unfortunately there is no
+ * simple way to determine if the URI is valid, because of automagic
+ * redirection and strange naming conventions.
+ */
+ if(!filter_var($uri,
+ FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED)) {
+ returnClientError('The specified URL is invalid!');
+ }
+
+ $uri = filter_var($uri, FILTER_SANITIZE_URL);
+ $path = parse_url($uri, PHP_URL_PATH);
+ $parts = explode('/', $path);
+
+ $allowed_strings = array(
+ 'de-DE' => 'Bewertungen',
+ 'en-AU' => 'Reviews',
+ 'nl-BE' => 'Reviews',
+ 'fr-BE' => 'Avis',
+ 'en-CA' => 'Reviews',
+ 'fr-CA' => 'Avis',
+ 'fr-FR' => 'Avis',
+ 'en-IN' => 'Reviews',
+ 'en-IE' => 'Reviews',
+ 'nl-NL' => 'Reviews',
+ 'de-AT' => 'Bewertungen',
+ 'de-CH' => 'Bewertungen',
+ 'fr-CH' => 'Avis',
+ 'en-GB' => 'Reviews',
+ 'en' => 'Reviews'
+ );
+
+ if(!in_array($parts[1], $allowed_strings)) {
+ returnClientError('Please specify a URL pointing to the companies review page!');
+ }
+
+ return $uri;
+ }
+}
diff --git a/bridges/GoComicsBridge.php b/bridges/GoComicsBridge.php
new file mode 100644
index 0000000..3223d19
--- /dev/null
+++ b/bridges/GoComicsBridge.php
@@ -0,0 +1,61 @@
+<?php
+class GoComicsBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'sky';
+ const NAME = 'GoComics Unofficial RSS';
+ const URI = 'https://www.gocomics.com/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'The Unofficial GoComics RSS';
+ const PARAMETERS = array( array(
+ 'comicname' => array(
+ 'name' => 'comicname',
+ 'type' => 'text',
+ 'required' => true
+ )
+ ));
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request GoComics: ' . $this->getURI());
+
+ //Get info from first page
+ $author = preg_replace('/By /', '', $html->find('.media-subheading', 0)->plaintext);
+
+ $link = self::URI . $html->find('.gc-deck--cta-0', 0)->find('a', 0)->href;
+ for($i = 0; $i < 5; $i++) {
+
+ $item = array();
+
+ $page = getSimpleHTMLDOM($link)
+ or returnServerError('Could not request GoComics: ' . $link);
+ $imagelink = $page->find('.img-fluid', 1)->src;
+ $date = explode('/', $link);
+
+ $item['id'] = $imagelink;
+ $item['uri'] = $link;
+ $item['author'] = $author;
+ $item['title'] = 'GoComics ' . $this->getInput('comicname');
+ $item['timestamp'] = DateTime::createFromFormat('Ymd', $date[5] . $date[6] . $date[7])->getTimestamp();
+ $item['content'] = '<img src="' . $imagelink . '" />';
+
+ $link = self::URI . $page->find('.js-previous-comic', 0)->href;
+ $this->items[] = $item;
+ }
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('comicname'))) {
+ return self::URI . urlencode($this->getInput('comicname'));
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('comicname'))) {
+ return $this->getInput('comicname') . ' - GoComics';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/GooglePlusPostBridge.php b/bridges/GooglePlusPostBridge.php
new file mode 100644
index 0000000..7911eaf
--- /dev/null
+++ b/bridges/GooglePlusPostBridge.php
@@ -0,0 +1,208 @@
+<?php
+class GooglePlusPostBridge extends BridgeAbstract{
+
+ private $title;
+ private $url;
+
+ const MAINTAINER = 'Grummfy, logmanoriginal';
+ const NAME = 'Google Plus Post Bridge';
+ const URI = 'https://plus.google.com';
+ const CACHE_TIMEOUT = 600; //10min
+ const DESCRIPTION = 'Returns user public post (without API).';
+
+ const PARAMETERS = array( array(
+ 'username' => array(
+ 'name' => 'username or Id',
+ 'required' => true
+ ),
+ 'include_media' => array(
+ 'name' => 'Include media',
+ 'type' => 'checkbox',
+ 'title' => 'Enable to include media in the feed content'
+ )
+ ));
+
+ public function getIcon() {
+ return 'https://ssl.gstatic.com/images/branding/product/ico/google_plus_alldp.ico';
+ }
+
+ public function collectData(){
+
+ $username = $this->getInput('username');
+
+ // Usernames start with a + if it's not an ID
+ if(!is_numeric($username) && substr($username, 0, 1) !== '+') {
+ $username = '+' . $username;
+ }
+
+ $html = getSimpleHTMLDOM(static::URI . '/' . urlencode($username) . '/posts')
+ or returnServerError('No results for this query.');
+
+ $html = defaultLinkTo($html, static::URI);
+
+ $this->title = $html->find('meta[property=og:title]', 0)->getAttribute('content');
+ $this->url = $html->find('meta[property=og:url]', 0)->getAttribute('content');
+
+ foreach($html->find('div[jsname=WsjYwc]') as $post) {
+
+ $item = array();
+
+ $item['author'] = $post->find('div div div div a', 0)->innertext;
+ $item['uri'] = $post->find('div div div a', 1)->href;
+
+ $timestamp = $post->find('a.qXj2He span', 0);
+
+ if($timestamp) {
+ $item['timestamp'] = strtotime('+' . preg_replace(
+ '/[^0-9A-Za-z]/',
+ '',
+ $timestamp->getAttribute('aria-label')));
+ }
+
+ $message = $post->find('div[jsname=EjRJtf]', 0);
+
+ // Empty messages are not supported right now
+ if(!$message) {
+ continue;
+ }
+
+ $item['content'] = '<div style="float: left; padding: 0 10px 10px 0;"><a href="'
+ . $this->url
+ . '"><img align="top" alt="'
+ . $item['author']
+ . '" src="'
+ . $post->find('div img', 0)->src
+ . '" /></a></div><div>'
+ . trim(strip_tags($message, '<a><p><div><img>'))
+ . '</div>';
+
+ // Make title at least 50 characters long, but don't add '...' if it is shorter!
+ if(strlen($message->plaintext) > 50) {
+ $end = strpos($message->plaintext, ' ', 50) ?: strlen($message->plaintext);
+ } else {
+ $end = strlen($message->plaintext);
+ }
+
+ if(strlen(substr($message->plaintext, 0, $end)) === strlen($message->plaintext)) {
+ $item['title'] = $message->plaintext;
+ } else {
+ $item['title'] = substr($message->plaintext, 0, $end) . '...';
+ }
+
+ $media = $post->find('[jsname="MTOxpb"]', 0);
+
+ if($media) {
+
+ $item['enclosures'] = array();
+
+ foreach($media->find('img') as $img) {
+ $item['enclosures'][] = $this->fixImage($img)->src;
+ }
+
+ if($this->getInput('include_media') === true && count($item['enclosures'] > 0)) {
+ $item['content'] .= '<div style="clear: both;"><a href="'
+ . $item['enclosures'][0]
+ . '"><img src="'
+ . $item['enclosures'][0]
+ . '" /></a></div>';
+ }
+
+ }
+
+ // Add custom parameters (only useful for JSON or Plaintext)
+ $item['fullname'] = $item['author'];
+ $item['avatar'] = $post->find('div img', 0)->src;
+ $item['id'] = $post->find('div div div', 0)->getAttribute('id');
+ $item['content_simple'] = $message->plaintext;
+
+ $this->items[] = $item;
+
+ }
+
+ }
+
+ public function getName(){
+ return $this->title ?: 'Google Plus Post Bridge';
+ }
+
+ public function getURI(){
+ return $this->url ?: parent::getURI();
+ }
+
+ private function fixImage($img) {
+
+ // There are certain images like .gif which link to a static picture and
+ // get replaced dynamically via JS in the browser. If we want the "real"
+ // image we need to account for that.
+
+ $urlparts = parse_url($img->src);
+
+ if(array_key_exists('host', $urlparts)) {
+
+ // For some reason some URIs don't contain the scheme, assume https
+ if(!array_key_exists('scheme', $urlparts)) {
+ $urlparts['scheme'] = 'https';
+ }
+
+ $pathelements = explode('/', $urlparts['path']);
+
+ switch($urlparts['host']) {
+
+ case 'lh3.googleusercontent.com':
+
+ if(pathinfo(end($pathelements), PATHINFO_EXTENSION)) {
+
+ // The second to last element of the path specifies the
+ // image format. The URL is still valid if we remove it.
+ unset($pathelements[count($pathelements) - 2]);
+
+ } elseif(strrpos(end($pathelements), '=') !== false) {
+
+ // Some images go throug a proxy. For those images they
+ // add size information after an equal sign.
+ // Example: '=w530-h298-n'. Again this can safely be
+ // removed to get the original image.
+ $pathelements[count($pathelements) - 1] = substr(
+ end($pathelements),
+ 0,
+ strrpos(end($pathelements), '=')
+ );
+
+ }
+
+ break;
+
+ }
+
+ $urlparts['path'] = implode('/', $pathelements);
+
+ }
+
+ $img->src = $this->build_url($urlparts);
+ return $img;
+
+ }
+
+ /**
+ * From: https://gist.github.com/Ellrion/f51ba0d40ae1d62eeae44fd1adf7b704
+ * slightly adjusted to work with PHP < 7.0
+ * @param array $parts
+ * @return string
+ */
+ private function build_url(array $parts)
+ {
+
+ $scheme = isset($parts['scheme']) ? ($parts['scheme'] . '://') : '';
+ $host = isset($parts['host']) ? $parts['host'] : '';
+ $port = isset($parts['port']) ? (':' . $parts['port']) : '';
+ $user = isset($parts['user']) ? $parts['user'] : '';
+ $pass = isset($parts['pass']) ? (':' . $parts['pass']) : '';
+ $pass = ($user || $pass) ? ($pass . '@') : '';
+ $path = isset($parts['path']) ? $parts['path'] : '';
+ $query = isset($parts['query']) ? ('?' . $parts['query']) : '';
+ $fragment = isset($parts['fragment']) ? ('#' . $parts['fragment']) : '';
+
+ return implode('', [$scheme, $user, $pass, $host, $port, $path, $query, $fragment]);
+
+ }
+}
diff --git a/bridges/GoogleSearchBridge.php b/bridges/GoogleSearchBridge.php
new file mode 100644
index 0000000..c3d9561
--- /dev/null
+++ b/bridges/GoogleSearchBridge.php
@@ -0,0 +1,64 @@
+<?php
+/**
+* Returns the 100 most recent links in results in past year, sorting by date (most recent first).
+* Example:
+* http://www.google.com/search?q=sebsauvage&num=100&complete=0&tbs=qdr:y,sbd:1
+* complete=0&num=100 : get 100 results
+* qdr:y : in past year
+* sbd:1 : sort by date (will only work if qdr: is specified)
+*/
+class GoogleSearchBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'sebsauvage';
+ const NAME = 'Google search';
+ const URI = 'https://www.google.com/';
+ const CACHE_TIMEOUT = 1800; // 30min
+ const DESCRIPTION = 'Returns most recent results from Google search.';
+
+ const PARAMETERS = array(array(
+ 'q' => array(
+ 'name' => 'keyword',
+ 'required' => true
+ )
+ ));
+
+ public function collectData(){
+ $html = '';
+
+ $html = getSimpleHTMLDOM(self::URI
+ . 'search?q='
+ . urlencode($this->getInput('q'))
+ . '&num=100&complete=0&tbs=qdr:y,sbd:1')
+ or returnServerError('No results for this query.');
+
+ $emIsRes = $html->find('div[id=ires]', 0);
+
+ if(!is_null($emIsRes)) {
+ foreach($emIsRes->find('div[class=g]') as $element) {
+
+ $item = array();
+
+ // Extract direct URL from google href (eg. /url?q=...)
+ $t = $element->find('a[href]', 0)->href;
+ $item['uri'] = '' . $t;
+ parse_str(parse_url($t, PHP_URL_QUERY), $parameters);
+ if(isset($parameters['q'])) {
+ $item['uri'] = $parameters['q'];
+ }
+
+ $item['title'] = $element->find('h3', 0)->plaintext;
+ $item['content'] = $element->find('span[class=st]', 0)->plaintext;
+
+ $this->items[] = $item;
+ }
+ }
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('q'))) {
+ return $this->getInput('q') . ' - Google search';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/GrandComicsDatabaseBridge.php b/bridges/GrandComicsDatabaseBridge.php
new file mode 100644
index 0000000..537a5b2
--- /dev/null
+++ b/bridges/GrandComicsDatabaseBridge.php
@@ -0,0 +1,62 @@
+<?php
+class GrandComicsDatabaseBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'corenting';
+ const NAME = 'Grand Comics Database Bridge';
+ const URI = 'https://www.comics.org/';
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'Returns the latest comics added to a series timeline';
+ const PARAMETERS = array( array(
+ 'series' => array(
+ 'name' => 'Series id (from the timeline URL)',
+ 'required' => true,
+ 'exampleValue' => '63051',
+ ),
+ ));
+
+ public function collectData(){
+
+ $url = self::URI . 'series/' . $this->getInput('series') . '/details/timeline/';
+ $html = getSimpleHTMLDOM($url)
+ or returnServerError('Error while downloading the website content');
+
+ $table = $html->find('table', 0);
+ $list = array_reverse($table->find('[class^=row_even]'));
+ $seriesName = $html->find('span[id=series_name]', 0)->innertext;
+
+ // Get row headers
+ $rowHeaders = $table->find('th');
+ foreach($list as $article) {
+
+ // Skip empty rows
+ $emptyRow = $article->find('td.empty_month');
+ if (count($emptyRow) != 0) {
+ continue;
+ }
+
+ $rows = $article->find('td');
+ $key_date = $rows[0]->innertext;
+
+ // Get URL too
+ $uri = 'https://www.comics.org' . $article->find('a')[0]->href;
+
+ // Build content
+ $content = '';
+ for($i = 0; $i < count($rowHeaders); $i++) {
+ $headerItem = $rowHeaders[$i]->innertext;
+ $rowItem = $rows[$i]->innertext;
+ $content = $content . $headerItem . ': ' . $rowItem . '<br/>';
+ }
+
+ // Build final item
+ $content = str_replace('href="/', 'href="' . static::URI, $content);
+ $item = array();
+ $item['title'] = $seriesName . ' - ' . $key_date;
+ $item['timestamp'] = strtotime($key_date);
+ $item['content'] = str_get_html($content);
+ $item['uri'] = $uri;
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/HDWallpapersBridge.php b/bridges/HDWallpapersBridge.php
new file mode 100644
index 0000000..cea6e34
--- /dev/null
+++ b/bridges/HDWallpapersBridge.php
@@ -0,0 +1,83 @@
+<?php
+class HDWallpapersBridge extends BridgeAbstract {
+ const MAINTAINER = 'nel50n';
+ const NAME = 'HD Wallpapers Bridge';
+ const URI = 'http://www.hdwallpapers.in/';
+ const CACHE_TIMEOUT = 43200; //12h
+ const DESCRIPTION = 'Returns the latests wallpapers from HDWallpapers';
+
+ const PARAMETERS = array( array(
+ 'c' => array(
+ 'name' => 'category',
+ 'defaultValue' => 'latest_wallpapers'
+ ),
+ 'm' => array(
+ 'name' => 'max number of wallpapers'
+ ),
+ 'r' => array(
+ 'name' => 'resolution',
+ 'defaultValue' => '1920x1200',
+ 'exampleValue' => '1920x1200, 1680x1050,…'
+ )
+ ));
+
+ public function collectData(){
+ $category = $this->category;
+ if(strrpos($category, 'wallpapers') !== strlen($category) - strlen('wallpapers')) {
+ $category .= '-desktop-wallpapers';
+ }
+
+ $num = 0;
+ $max = $this->getInput('m') ?: 14;
+ $lastpage = 1;
+
+ for($page = 1; $page <= $lastpage; $page++) {
+ $link = self::URI . '/' . $category . '/page/' . $page;
+ $html = getSimpleHTMLDOM($link)
+ or returnServerError('No results for this query.');
+
+ if($page === 1) {
+ preg_match('/page\/(\d+)$/', $html->find('.pagination a', -2)->href, $matches);
+ $lastpage = min($matches[1], ceil($max / 14));
+ }
+
+ foreach($html->find('.wallpapers .wall a') as $element) {
+ $thumbnail = $element->find('img', 0);
+
+ $item = array();
+ // http://www.hdwallpapers.in/download/yosemite_reflections-1680x1050.jpg
+ $item['uri'] = self::URI
+ . '/download'
+ . str_replace('wallpapers.html', $this->getInput('r') . '.jpg', $element->href);
+
+ $item['timestamp'] = time();
+ $item['title'] = $element->find('p', 0)->text();
+ $item['content'] = $item['title']
+ . '<br><a href="'
+ . $item['uri']
+ . '"><img src="'
+ . self::URI
+ . $thumbnail->src
+ . '" /></a>';
+
+ $this->items[] = $item;
+
+ $num++;
+ if ($num >= $max)
+ break 2;
+ }
+ }
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('c')) && !is_null($this->getInput('r'))) {
+ return 'HDWallpapers - '
+ . str_replace(['__', '_'], [' & ', ' '], $this->getInput('c'))
+ . ' ['
+ . $this->getInput('r')
+ . ']';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/HentaiHavenBridge.php b/bridges/HentaiHavenBridge.php
new file mode 100644
index 0000000..21a0ff5
--- /dev/null
+++ b/bridges/HentaiHavenBridge.php
@@ -0,0 +1,37 @@
+<?php
+class HentaiHavenBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'albirew';
+ const NAME = 'Hentai Haven';
+ const URI = 'http://hentaihaven.org/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Returns releases from Hentai Haven';
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request Hentai Haven.');
+
+ foreach($html->find('div.zoe-grid') as $element) {
+ $item = array();
+ $item['uri'] = $element->find('div.brick-content h3 a', 0)->href;
+ $thumbnailUri = $element->find('a.thumbnail-image img', 0)->getAttribute('data-src');
+ $item['title'] = mb_convert_encoding(
+ trim($element->find('div.brick-content h3 a', 0)->innertext),
+ 'UTF-8',
+ 'HTML-ENTITIES'
+ );
+
+ $item['tags'] = $element->find('div.oFlyout_bg div.oFlyout div.flyoutContent span.tags', 0)->plaintext;
+ $item['content'] = 'Tags: '
+ . $item['tags']
+ . '<br><br><a href="'
+ . $item['uri']
+ . '"><img width="300" height="169" src="'
+ . $thumbnailUri
+ . '" /></a><br>'
+ . $element->find('div.oFlyout_bg div.oFlyout div.flyoutContent p.description', 0)->innertext;
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/HotUKDealsBridge.php b/bridges/HotUKDealsBridge.php
new file mode 100644
index 0000000..8c1b3bd
--- /dev/null
+++ b/bridges/HotUKDealsBridge.php
@@ -0,0 +1,1395 @@
+<?php
+
+require_once(__DIR__ . '/DealabsBridge.php');
+class HotUKDealsBridge extends PepperBridgeAbstract {
+
+ const NAME = 'HotUKDeals bridge';
+ const URI = 'https://www.hotukdeals.com/';
+ const DESCRIPTION = 'Return the HotUKDeals search result using keywords';
+ const MAINTAINER = 'sysadminstory';
+ const PARAMETERS = array(
+ 'Search by keyword(s))' => array (
+ 'q' => array(
+ 'name' => 'Keyword(s)',
+ 'type' => 'text',
+ 'required' => true
+ ),
+ 'hide_expired' => array(
+ 'name' => 'Hide expired deals',
+ 'type' => 'checkbox',
+ 'required' => true
+ ),
+ 'hide_local' => array(
+ 'name' => 'Hide local deals',
+ 'type' => 'checkbox',
+ 'title' => 'Hide deals in physical store',
+ 'required' => true
+ ),
+ 'priceFrom' => array(
+ 'name' => 'Minimal Price',
+ 'type' => 'text',
+ 'title' => 'Minmal Price in Pounds',
+ 'required' => false
+ ),
+ 'priceTo' => array(
+ 'name' => 'Maximum Price',
+ 'type' => 'text',
+ 'title' => 'Maximum Price in Pounds',
+ 'required' => false
+ ),
+ ),
+
+ 'Deals per group' => array(
+ 'group' => array(
+ 'name' => 'Group',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Group whose deals must be displayed',
+ 'values' => array(
+ '2DS' => '2ds',
+ '3D Bluray' => '3d-bluray',
+ '3D Glasses' => '3d-glasses',
+ '3D Printer' => '3d-printer',
+ '3DS' => '3ds',
+ '3DS Games' => '3ds-games',
+ '3D TV' => '3d-tv',
+ '4G' => '4g',
+ '4k Bluray' => '4k-bluray',
+ '4k Monitor' => '4k-monitor',
+ '4K TV' => '4k-tv',
+ '7up' => '7up',
+ '144Hz Monitor' => '144hz',
+ 'AA Batteries' => 'aa',
+ 'Acer' => 'acer',
+ 'Actifry' => 'actifry',
+ 'Action Camera' => 'action-camera',
+ 'Add On Item' => 'add-on-item',
+ 'Adidas' => 'adidas',
+ 'Adobe' => 'adobe',
+ 'Aftershave' => 'aftershave',
+ 'Air Bed' => 'air-bed',
+ 'Air Conditioner' => 'air-con',
+ 'Air Fryer' => 'air-fryer',
+ 'Airport Parking' => 'airport-parking',
+ 'AKG' => 'akg',
+ 'Alarm' => 'alarm',
+ 'Alcatel' => 'alcatel',
+ 'Alcohol' => 'alcohol',
+ 'Alienware' => 'alienware',
+ 'Alton Towers' => 'alton-towers',
+ 'Amazon Echo' => 'amazon-echo',
+ 'Amazon Fire Stick' => 'amazon-fire-stick',
+ 'Amazon Fire Tv' => 'fire-tv',
+ 'Amazon Pantry' => 'amazon-pantry',
+ 'Amazon Prime' => 'amazon-prime',
+ 'Amazon Warehouse' => 'amazon-warehouse',
+ 'AMD' => 'amd',
+ 'Amex' => 'amex',
+ 'Amiibo' => 'amiibo',
+ 'Amsterdam' => 'amsterdam',
+ 'Android' => 'android',
+ 'Android Tablet' => 'android-tablet',
+ 'Android TV' => 'android-tv',
+ 'Anker' => 'anker',
+ 'Apple' => 'apple',
+ 'Apple TV' => 'apple-tv',
+ 'Apple Watch' => 'apple-watch',
+ 'Armani' => 'armani',
+ 'Asics' => 'asics',
+ 'ASUS' => 'asus',
+ 'Audi' => 'audi',
+ 'Baby' => 'baby',
+ 'Baby & Kids' => 'kids',
+ 'Baby Monitor' => 'baby-monitor',
+ 'Baby Swing' => 'baby-swing',
+ 'Backpack' => 'backpack',
+ 'Bag' => 'bag',
+ 'Bank' => 'bank',
+ 'Barbour' => 'barbour',
+ 'Barcelona' => 'barcelona',
+ 'Bathroom' => 'bathroom',
+ 'Batman' => 'batman',
+ 'Battery' => 'battery',
+ 'Battlefield' => 'battlefield',
+ 'Battlefield 1' => 'battlefield-1',
+ 'BBQ' => 'bbq',
+ 'Bean To Cup' => 'bean-to-cup',
+ 'Beard Trimmer' => 'beard-trimmer',
+ 'Bed' => 'bed',
+ 'Bedding' => 'bedding',
+ 'Bed Frame' => 'bed-frame',
+ 'Beer' => 'beer',
+ 'Beko' => 'beko',
+ 'Belfast' => 'belfast',
+ 'Bench' => 'bench',
+ 'Berghaus' => 'berghaus',
+ 'Bike' => 'bike',
+ 'Bin' => 'bin',
+ 'Bioshock' => 'bioshock',
+ 'Black Ops 3' => 'black-ops-3',
+ 'Blackpool' => 'blackpool',
+ 'Blender' => 'blender',
+ 'Blinds' => 'blinds',
+ 'Bloodborne' => 'bloodborne',
+ 'Blu-Ray' => 'blu-ray',
+ 'Bluetooth Headphones' => 'bluetooth-headphones',
+ 'Bluetooth Speaker' => 'bluetooth-speaker',
+ 'BMW' => 'bmw',
+ 'Board Game' => 'board-game',
+ 'Boiler' => 'boiler',
+ 'Bosch' => 'bosch',
+ 'Bose' => 'bose',
+ 'Bourbon' => 'bourbon',
+ 'Boxers' => 'boxers',
+ 'Bra' => 'bra',
+ 'Braun' => 'braun',
+ 'Breakdown' => 'breakdown',
+ 'Brewdog' => 'brewdog',
+ 'Brita' => 'brita',
+ 'Broadband' => 'broadband',
+ 'BT' => 'bt',
+ 'Bt Sport' => 'bt-sport',
+ 'Buggy' => 'buggy',
+ 'Call Of Duty' => 'call-of-duty',
+ 'Camera' => 'camera',
+ 'Camera Lens' => 'lens',
+ 'Camping' => 'camping',
+ 'Candle' => 'candle',
+ 'Canon' => 'canon',
+ 'Canvas' => 'canvas',
+ 'Car' => 'car',
+ 'Caravan' => 'caravan',
+ 'Car Battery' => 'car-battery',
+ 'Car Hire' => 'car-hire',
+ 'Car Insurance' => 'car-insurance',
+ 'Car Lease' => 'car-lease',
+ 'Car Mats' => 'car-mats',
+ 'Carpet' => 'carpet',
+ 'Carpet Cleaner' => 'carpet-cleaner',
+ 'Car Seat' => 'car-seat',
+ 'Car Stereo' => 'car-stereo',
+ 'Casio' => 'casio',
+ 'Caterpillar Boots' => 'caterpillar',
+ 'Cat Food' => 'cat-food',
+ 'CCTV' => 'cctv',
+ 'Chainsaw' => 'chainsaw',
+ 'Chair' => 'chair',
+ 'Champagne' => 'champagne',
+ 'Charger' => 'charger',
+ 'Chessington' => 'chessington',
+ 'Chest Freezer' => 'chest-freezer',
+ 'Chocolate' => 'chocolate',
+ 'Chromebook' => 'chromebook',
+ 'Chromecast' => 'chromecast',
+ 'Cider' => 'cider',
+ 'Cinema' => 'cinema',
+ 'Cineworld' => 'cineworld',
+ 'Circular Saw' => 'circular-saw',
+ 'Circulon' => 'circulon',
+ 'Citizen' => 'citizen',
+ 'Clarins' => 'clarins',
+ 'Clarks' => 'clarks',
+ 'Clinique' => 'clinique',
+ 'Clothes' => 'clothes',
+ 'Coat' => 'coat',
+ 'Coffee' => 'coffee',
+ 'Coffee Machine' => 'coffee-machine',
+ 'Coke' => 'coke',
+ 'Compost' => 'compost',
+ 'Computers' => 'computers',
+ 'Converse' => 'converse',
+ 'Cooker' => 'cooker',
+ 'Cordless Phone' => 'cordless-phone',
+ 'Corsair' => 'corsair',
+ 'Cot' => 'cot',
+ 'CPU' => 'cpu',
+ 'Crash Bandicoot' => 'crash-bandicoot',
+ 'Credit Card' => 'credit-card',
+ 'Cricket' => 'cricket',
+ 'Crisps' => 'crisps',
+ 'Crocs' => 'crocs',
+ 'Cruise' => 'cruise',
+ 'Cuprinol' => 'cuprinol',
+ 'Cutlery' => 'cutlery',
+ 'Dab Radio' => 'dab-radio',
+ 'Dark Souls' => 'dark-souls',
+ 'Dark Souls 3' => 'dark-souls-3',
+ 'Dash Cam' => 'dash-cam',
+ 'Days Out' => 'days-out',
+ 'DDR3' => 'ddr3',
+ 'DDR4' => 'ddr4',
+ 'Deezer' => 'deezer',
+ 'Dehumidifier' => 'dehumidifier',
+ 'Dell' => 'dell',
+ 'Delonghi' => 'delonghi',
+ 'Denon' => 'denon',
+ 'Desk' => 'desk',
+ 'Desktop' => 'desktop',
+ 'Destiny' => 'destiny',
+ 'Destiny 2' => 'destiny-2',
+ 'Deus Ex' => 'deus-ex',
+ 'Dewalt' => 'dewalt',
+ 'Digital Camera' => 'digital-camera',
+ 'Dining Table' => 'dining-table',
+ 'Dinner Set' => 'dinner-set',
+ 'Dirt 4' => 'dirt-4',
+ 'Dishonored 2' => 'dishonored-2',
+ 'Dishwasher' => 'dishwasher',
+ 'Disney' => 'disney',
+ 'Disney Infinity' => 'disney-infinity',
+ 'Disneyland' => 'disneyland',
+ 'DIY' => 'diy',
+ 'Doctor Who' => 'doctor-who',
+ 'Dog' => 'dog',
+ 'Dog Bed' => 'dog-bed',
+ 'Dolce Gusto' => 'dolce-gusto',
+ 'DOOM' => 'doom',
+ 'Dremel' => 'dremel',
+ 'Dress' => 'dress',
+ 'Drill' => 'drill',
+ 'Drone' => 'drone',
+ 'Dryer' => 'dryer',
+ 'DSLR Camera' => 'dslr',
+ 'Dubai' => 'dubai',
+ 'Dulux' => 'dulux',
+ 'Durex' => 'durex',
+ 'Duvet' => 'duvet',
+ 'DVD' => 'dvd',
+ 'DVD Player' => 'dvd-player',
+ 'Dying Light' => 'dying-light',
+ 'Dyson' => 'dyson',
+ 'Dyson V6' => 'dyson-v6',
+ 'Dyson V8' => 'dyson-v8',
+ 'E-Cig' => 'e-cig',
+ 'EA' => 'ea',
+ 'EA Access' => 'ea-access',
+ 'Earphones' => 'earphones',
+ 'Earrings' => 'earrings',
+ 'Eastpak' => 'eastpak',
+ 'eBook' => 'ebook',
+ 'Eco-Drive' => 'eco-drive',
+ 'Ecobubble' => 'ecobubble',
+ 'Edifice' => 'edifice',
+ 'Edinburgh' => 'edinburgh',
+ 'EE' => 'ee',
+ 'Egg' => 'egg',
+ 'Egypt' => 'egypt',
+ 'Elder Scrolls' => 'elder-scrolls',
+ 'Electric Bike' => 'electric-bike',
+ 'Electric Cooker' => 'electric-cooker',
+ 'Electric Fires' => 'electric-fire',
+ 'Electric Shower' => 'electric-shower',
+ 'Electric Toothbrush' => 'electric-toothbrush',
+ 'Electronics' => 'electronics',
+ 'Elemis' => 'elemis',
+ 'Elephone' => 'elephone',
+ 'Elgato' => 'elgato',
+ 'Elite Dangerous' => 'elite-dangerous',
+ 'Emirates' => 'emirates',
+ 'Eneloop' => 'eneloop',
+ 'Energy' => 'energy',
+ 'Engine Oil' => 'engine-oil',
+ 'Entertainment' => 'entertainment',
+ 'Epilator' => 'epilator',
+ 'Epson' => 'epson',
+ 'eReader' => 'ereader',
+ 'Espresso' => 'espresso',
+ 'Estee Lauder' => 'estee-lauder',
+ 'Ethernet' => 'ethernet',
+ 'Eurostar' => 'eurostar',
+ 'Eurotunnel' => 'eurotunnel',
+ 'EVGA' => 'evga',
+ 'Extension Lead' => 'extension-lead',
+ 'External Hard Drive' => 'external-hard-drive',
+ 'Fairy' => 'fairy',
+ 'Fallout' => 'fallout',
+ 'Fallout 4' => 'fallout-4',
+ 'Fan' => 'fan',
+ 'Fancy Dress' => 'fancy-dress',
+ 'Far Cry' => 'far-cry',
+ 'Far Cry 4' => 'far-cry-4',
+ 'Far Cry Primal' => 'far-cry-primal',
+ 'Fashion' => 'fashion',
+ 'Fathers Day' => 'fathers-day',
+ 'Felix' => 'felix',
+ 'Fence' => 'fence',
+ 'Fender Guitars' => 'fender',
+ 'Ferrero Rocher' => 'ferrero-rocher',
+ 'Ferry' => 'ferry',
+ 'Festival' => 'festival',
+ 'Fiat' => 'fiat',
+ 'FIFA' => 'fifa',
+ 'FIFA 17' => 'fifa-17',
+ 'FIFA 18' => 'fifa-18',
+ 'Figures' => 'figures',
+ 'Final Fantasy' => 'final-fantasy',
+ 'Finance & Utilities' => 'personal-finance',
+ 'Finish' => 'finish',
+ 'Finlux' => 'finlux',
+ 'Fire Emblem' => 'fire-emblem',
+ 'Fire Pit' => 'fire-pit',
+ 'Fireplace' => 'fireplace',
+ 'Fish' => 'fish',
+ 'Fisher Price' => 'fisher-price',
+ 'Fishing' => 'fishing',
+ 'Fiskars' => 'fiskars',
+ 'Fitbit' => 'fitbit',
+ 'Fitbit Alta' => 'fitbit-alta',
+ 'Fitbit Blaze' => 'fitbit-blaze',
+ 'Fitbit Charge 2' => 'fitbit-charge-2',
+ 'Fitness Tracker' => 'fitness-tracker',
+ 'Flamingo Land' => 'flamingo-land',
+ 'Flask' => 'flask',
+ 'Fleece' => 'fleece',
+ 'Flight' => 'flight',
+ 'Flip Flops' => 'flip-flops',
+ 'Floodlight' => 'floodlight',
+ 'Flooring' => 'flooring',
+ 'Florida' => 'florida',
+ 'Flowers' => 'flowers',
+ 'Flybe' => 'flybe',
+ 'Flymo' => 'flymo',
+ 'Food' => 'food',
+ 'Food Mixer' => 'food-mixer',
+ 'Food Processor' => 'food-processor',
+ 'Football' => 'football',
+ 'Football Boots' => 'football-boots',
+ 'Football Manager' => 'football-manager',
+ 'Football Shirt' => 'football-shirt',
+ 'Ford' => 'ford',
+ 'For Honor' => 'for-honor',
+ 'Formula 1' => 'f1',
+ 'Forza' => 'forza',
+ 'Forza Horizon' => 'forza-horizon',
+ 'Forza Horizon 3' => 'forza-horizon-3',
+ 'Fossil' => 'fossil',
+ 'Fosters' => 'fosters',
+ 'Foundation' => 'foundation',
+ 'France' => 'france',
+ 'Fred Perry' => 'fred-perry',
+ 'Freebies' => 'freebies',
+ 'Freesat' => 'freesat',
+ 'Freeview' => 'freeview',
+ 'Freezer' => 'freezer',
+ 'Fridge' => 'fridge',
+ 'Fridge Freezer' => 'fridge-freezer',
+ 'Frozen' => 'frozen',
+ 'Fruit' => 'fruit',
+ 'Fryer' => 'fryer',
+ 'Frying Pan' => 'frying-pan',
+ 'Fujifilm' => 'fuji',
+ 'Funko Pop' => 'funko-pop',
+ 'Furby' => 'furby',
+ 'Furniture' => 'furniture',
+ 'Fusion' => 'fusion',
+ 'G-Shock' => 'g-shock',
+ 'G-Sync Monitor' => 'g-sync',
+ 'Game Of Thrones' => 'game-of-thrones',
+ 'Gaming' => 'gaming',
+ 'Gaming Chair' => 'gaming-chair',
+ 'Gaming Controller' => 'controller',
+ 'Gaming Headset' => 'gaming-headset',
+ 'Gaming Keyboard' => 'gaming-keyboard',
+ 'Gaming Laptop' => 'gaming-laptop',
+ 'Gaming Monitor' => 'gaming-monitor',
+ 'Gaming PC' => 'gaming-pc',
+ 'Garage' => 'garage',
+ 'Garden' => 'garden',
+ 'Garden Furniture' => 'garden-furniture',
+ 'Garmin' => 'garmin',
+ 'Gas' => 'gas',
+ 'Gas Cooker' => 'gas-cooker',
+ 'Gatwick' => 'gatwick',
+ 'Gazebo' => 'gazebo',
+ 'Gazelle' => 'gazelle',
+ 'GBK' => 'gbk',
+ 'Gears Of War' => 'gears-of-war',
+ 'Gears Of War 4' => 'gears-of-war-4',
+ 'GeForce' => 'geforce',
+ 'George Foreman' => 'george-foreman',
+ 'Geox' => 'geox',
+ 'GHD' => 'ghd',
+ 'Ghostbusters' => 'ghostbusters',
+ 'Ghost Recon' => 'ghost-recon',
+ 'Gibson Guitars' => 'gibson',
+ 'Giffgaff' => 'giffgaff',
+ 'Gift Card' => 'gift-card',
+ 'Gifts' => 'gifts',
+ 'Gift Set' => 'gift-set',
+ 'Gilet' => 'gilet',
+ 'Gillette' => 'gillette',
+ 'Gimbal' => 'gimbal',
+ 'Gin' => 'gin',
+ 'Glasgow' => 'glasgow',
+ 'Glasses' => 'glasses',
+ 'Gloves' => 'gloves',
+ 'Glue Gun' => 'glue-gun',
+ 'Gluten Free' => 'gluten-free',
+ 'Goggles' => 'goggles',
+ 'Go Kart' => 'go-kart',
+ 'Golf' => 'golf',
+ 'Golf Balls' => 'golf-balls',
+ 'Golf Clubs' => 'golf-clubs',
+ 'Goodfellas' => 'goodfellas',
+ 'Google' => 'google',
+ 'Google Home' => 'google-home',
+ 'Google Pixel' => 'google-pixel',
+ 'Go Outdoors' => 'go-outdoors',
+ 'GoPro' => 'gopro',
+ 'Graco' => 'graco',
+ 'Grand National' => 'grand-national',
+ 'Graphics Card' => 'graphics-card',
+ 'Gravity Rush' => 'gravity-rush',
+ 'Graze' => 'graze',
+ 'Greece' => 'greece',
+ 'Greenhouse' => 'greenhouse',
+ 'Greggs' => 'greggs',
+ 'Grey Goose' => 'grey-goose',
+ 'Grill' => 'grill',
+ 'Grinder' => 'grinder',
+ 'Grobag' => 'grobag',
+ 'Groceries' => 'groceries',
+ 'GTA' => 'gta',
+ 'GTA V' => 'gta-v',
+ 'Gtx 970' => 'gtx-970',
+ 'GTX 1060' => 'gtx-1060',
+ 'GTX 1070' => 'gtx-1070',
+ 'GTX 1080' => 'gtx-1080',
+ 'Guardians Of The Galaxy' => 'guardians-of-the-galaxy',
+ 'Gucci' => 'gucci',
+ 'Guinness' => 'guinness',
+ 'Guitar' => 'guitar',
+ 'Guitar Hero' => 'guitar-hero',
+ 'Gullivers' => 'gullivers',
+ 'Gym' => 'gym',
+ 'Gym Membership' => 'gym-membership',
+ 'H1z1' => 'h1z1',
+ 'Habitat' => 'habitat',
+ 'Hair' => 'hair',
+ 'Hair Clipper' => 'hair-clipper',
+ 'Hair Dryer' => 'hair-dryer',
+ 'Hair Dye' => 'hair-dye',
+ 'Halifax' => 'halifax',
+ 'Halo' => 'halo',
+ 'Halo 5' => 'halo-5',
+ 'Hammer' => 'hammer',
+ 'Hammock' => 'hammock',
+ 'Hamper' => 'hamper',
+ 'Handbag' => 'handbag',
+ 'Hand Mixer' => 'hand-mixer',
+ 'Happyland' => 'happyland',
+ 'Hard Drive' => 'hard-drive',
+ 'Haribo' => 'haribo',
+ 'Harman Kardon' => 'harman-kardon',
+ 'Harmony' => 'harmony',
+ 'Harry Potter' => 'harry-potter',
+ 'Hat' => 'hat',
+ 'Hatchimals' => 'hatchimals',
+ 'Hayfever' => 'hayfever',
+ 'Hdr Tv' => 'hdr-tv',
+ 'HD TV' => 'hd-tv',
+ 'Headboard' => 'headboard',
+ 'Headphones' => 'headphones',
+ 'Headset' => 'headset',
+ 'Health & Beauty' => 'beauty',
+ 'Heater' => 'heater',
+ 'Hedge Trimmer' => 'hedge-trimmer',
+ 'Heineken' => 'heineken',
+ 'Heinz' => 'heinz',
+ 'Helmet' => 'helmet',
+ 'Hermes' => 'hermes',
+ 'Highchair' => 'highchair',
+ 'Hiking' => 'hiking',
+ 'Hilton' => 'hilton',
+ 'Hisense' => 'hisense',
+ 'Hitachi' => 'hitachi',
+ 'Hitman' => 'hitman',
+ 'Hive' => 'hive',
+ 'Hob' => 'hob',
+ 'Holiday Inn' => 'holiday-inn',
+ 'Holidays & Leisure' => 'holiday',
+ 'Home & Garden' => 'home',
+ 'Home Cinema' => 'home-cinema',
+ 'Homedics' => 'homedics',
+ 'Homefront' => 'homefront',
+ 'Homeplug' => 'homeplug',
+ 'Home Security' => 'home-security',
+ 'Honey' => 'honey',
+ 'Honeywell' => 'honeywell',
+ 'Hong Kong' => 'hong-kong',
+ 'Honor' => 'honor',
+ 'Honor 6x' => 'honor-6x',
+ 'Hoodie' => 'hoodie',
+ 'Hoover' => 'hoover',
+ 'Horizon Zero Dawn' => 'horizon-zero-dawn',
+ 'Hornby' => 'hornby',
+ 'Hose' => 'hose',
+ 'Hotel' => 'hotel',
+ 'Hotpoint' => 'hotpoint',
+ 'Hot Tub' => 'hot-tub',
+ 'Hot Wheels' => 'hot-wheels',
+ 'Hozelock' => 'hozelock',
+ 'HP' => 'hp',
+ 'HP Envy' => 'hp-envy',
+ 'HP Laptop' => 'hp-laptop',
+ 'H Samuel' => 'h-samuel',
+ 'HTC' => 'htc',
+ 'HTC 10' => 'htc-10',
+ 'HTC Vive' => 'htc-vive',
+ 'Huawei' => 'huawei',
+ 'Huawei P9' => 'huawei-p9',
+ 'Huggies' => 'huggies',
+ 'Hugo Boss' => 'hugo-boss',
+ 'Humax' => 'humax',
+ 'Humidifier' => 'humidifier',
+ 'Hunter' => 'hunter',
+ 'Hydro 5' => 'hydro-5',
+ 'Hyperx' => 'hyperx',
+ 'Hyundai' => 'hyundai',
+ 'Iams Pet Food' => 'iams',
+ 'Ibiza' => 'ibiza',
+ 'Icandy' => 'icandy',
+ 'Ice Cream' => 'ice-cream',
+ 'Ice Cream Maker' => 'ice-cream-maker',
+ 'Imaginext' => 'imaginext',
+ 'Impact Driver' => 'impact-driver',
+ 'Indesit' => 'indesit',
+ 'India' => 'india',
+ 'Inflatable' => 'inflatable',
+ 'Injustice' => 'injustice',
+ 'Ink' => 'ink',
+ 'Inner Tube' => 'inner-tube',
+ 'Instant Ink' => 'instant-ink',
+ 'Insulation' => 'insulation',
+ 'Insurance' => 'insurance',
+ 'Intel' => 'intel',
+ 'Intel Atom' => 'atom',
+ 'Intel i3' => 'i3',
+ 'Intel i5' => 'i5',
+ 'Intel i7' => 'i7',
+ 'Internal Hard Drive' => 'internal-hard-drive',
+ 'Internet' => 'internet',
+ 'In The Night Garden' => 'in-the-night-garden',
+ 'iOS' => 'ios',
+ 'iPad' => 'ipad',
+ 'iPad Air' => 'ipad-air',
+ 'iPad Case' => 'ipad-case',
+ 'iPad Mini' => 'ipad-mini',
+ 'iPad Pro' => 'ipad-pro',
+ 'Ip Camera' => 'ip-camera',
+ 'iPhone' => 'iphone',
+ 'iPhone 5S' => 'iphone-5s',
+ 'iPhone 6' => 'iphone-6',
+ 'iPhone 6 Plus' => 'iphone-6-plus',
+ 'iPhone 6s' => 'iphone-6s',
+ 'iPhone 6s Plus' => 'iphone-6s-plus',
+ 'iPhone 7' => 'iphone-7',
+ 'iPhone 7 Plus' => 'iphone-7-plus',
+ 'iPhone Case' => 'iphone-case',
+ 'Iphone SE' => 'iphone-se',
+ 'iPod' => 'ipod',
+ 'iPod Nano' => 'ipod-nano',
+ 'iPod Touch' => 'ipod-touch',
+ 'Ireland' => 'ireland',
+ 'Irn Bru' => 'irn-bru',
+ 'Iron' => 'iron',
+ 'Ironing Board' => 'ironing-board',
+ 'Isle Of Wight' => 'isle-of-wight',
+ 'Isofix' => 'isofix',
+ 'Issey Miyake' => 'issey-miyake',
+ 'Italy' => 'italy',
+ 'iTunes' => 'itunes',
+ 'ITV' => 'itv',
+ 'Jabra' => 'jabra',
+ 'Jack Daniels' => 'jack-daniels',
+ 'Jacket' => 'jacket',
+ 'Jack Wills' => 'jack-wills',
+ 'Jack Wolfskin' => 'jack-wolfskin',
+ 'Jaguar' => 'jaguar',
+ 'Jamaica' => 'jamaica',
+ 'Jameson' => 'jameson',
+ 'Japan' => 'japan',
+ 'Jawbone' => 'jawbone',
+ 'Jaybird' => 'jaybird',
+ 'JBL' => 'jbl',
+ 'Jeans' => 'jeans',
+ 'Jewellery' => 'jewellery',
+ 'Jigsaw' => 'jigsaw',
+ 'Jim Beam' => 'jim-beam',
+ 'Jimmy Choo' => 'jimmy-choo',
+ 'Joop' => 'joop',
+ 'Jordan' => 'jordan',
+ 'Joseph Joseph' => 'joseph-joseph',
+ 'Joules' => 'joules',
+ 'Juice' => 'juice',
+ 'Juicer' => 'juicer',
+ 'Jumper' => 'jumper',
+ 'Jumperoo' => 'jumperoo',
+ 'Jura' => 'jura',
+ 'Just Cause 3' => 'just-cause-3',
+ 'Just Dance' => 'just-dance',
+ 'JVC' => 'jvc',
+ 'Karcher' => 'karcher',
+ 'Kaspersky' => 'kaspersky',
+ 'Kayak' => 'kayak',
+ 'Keg' => 'keg',
+ 'Kenwood' => 'kenwood',
+ 'Kenwood kMix' => 'kmix',
+ 'Keter' => 'keter',
+ 'Kettle' => 'kettle',
+ 'Kettlebell' => 'kettlebell',
+ 'Keyboard' => 'keyboard',
+ 'Kia' => 'kia',
+ 'Kickers' => 'kickers',
+ 'Kids Bike' => 'kids-bike',
+ 'Kinder' => 'kinder',
+ 'Kindle' => 'kindle',
+ 'Kindle Fire' => 'kindle-fire',
+ 'Kindle Paperwhite' => 'kindle-paperwhite',
+ 'Kinect' => 'kinect',
+ 'Kingdom Hearts' => 'kingdom-hearts',
+ 'King Size' => 'king-size',
+ 'Kirby' => 'kirby',
+ 'Kitchen' => 'kitchen',
+ 'KitchenAid' => 'kitchenaid',
+ 'Kitchen Roll' => 'kitchen-roll',
+ 'Kitsound' => 'kitsound',
+ 'Knickers' => 'knickers',
+ 'Knife' => 'knife',
+ 'Kobo' => 'kobo',
+ 'Kodi' => 'kodi',
+ 'Kopparberg' => 'kopparberg',
+ 'Kraken' => 'kraken',
+ 'Krakow' => 'krakow',
+ 'Krispy Kreme' => 'krispy-kreme',
+ 'Kurt Geiger' => 'kurt-geiger',
+ 'Lacoste' => 'lacoste',
+ 'Ladder' => 'ladder',
+ 'Lamb' => 'lamb',
+ 'Laminate' => 'laminate',
+ 'Lamp' => 'lamp',
+ 'Lancome' => 'lancome',
+ 'Laptop' => 'laptop',
+ 'Laser Printer' => 'laser-printer',
+ 'Laura Ashley' => 'laura-ashley',
+ 'Lavazza' => 'lavazza',
+ 'Lavender' => 'lavender',
+ 'Lawnmower' => 'lawnmower',
+ 'Lay-Z-Spa' => 'lay-z-spa',
+ 'Le Creuset' => 'le-creuset',
+ 'LED Bulbs' => 'led-bulbs',
+ 'LED TV' => 'led-tv',
+ 'Leeds' => 'leeds',
+ 'Lego' => 'lego',
+ 'Lego City' => 'lego-city',
+ 'Lego Dimensions' => 'lego-dimensions',
+ 'Lego Friends' => 'lego-friends',
+ 'Legoland' => 'legoland',
+ 'Lego Star Wars' => 'lego-star-wars',
+ 'Lego Technic' => 'lego-technic',
+ 'Leicester' => 'leicester',
+ 'Lenovo' => 'lenovo',
+ 'Lenovo Tablet' => 'lenovo-tablet',
+ 'Lenovo Yoga' => 'lenovo-yoga',
+ 'Levi' => 'levi',
+ 'LG' => 'lg',
+ 'LG G5' => 'lg-g5',
+ 'LG G6' => 'lg-g6',
+ 'LG TV' => 'lg-tv',
+ 'Light' => 'light',
+ 'Lighting' => 'lighting',
+ 'Lightning Cable' => 'lightning-cable',
+ 'Lindor' => 'lindor',
+ 'Lindt' => 'lindt',
+ 'Lingerie' => 'lingerie',
+ 'Linx' => 'linx',
+ 'Little Tikes' => 'little-tikes',
+ 'Liverpool' => 'liverpool',
+ 'Logitech' => 'logitech',
+ 'London' => 'london',
+ 'London Eye' => 'london-eye',
+ 'London Zoo' => 'london-zoo',
+ 'Lottery' => 'lottery',
+ 'Lounger' => 'lounger',
+ 'Lurpak' => 'lurpak',
+ 'Lynx' => 'lynx',
+ 'MacBook' => 'macbook',
+ 'MacBook Air' => 'macbook-air',
+ 'MacBook Pro' => 'macbook-pro',
+ 'Mac Mini' => 'mac-mini',
+ 'Mad Max' => 'mad-max',
+ 'Mafia 3' => 'mafia-3',
+ 'Magazine' => 'magazine',
+ 'Magimix' => 'magimix',
+ 'Majorca' => 'majorca',
+ 'Make Up' => 'make-up',
+ 'Makita' => 'makita',
+ 'Maldives' => 'maldives',
+ 'Manchester' => 'manchester',
+ 'Manfrotto' => 'manfrotto',
+ 'Marc Jacobs' => 'marc-jacobs',
+ 'Mario' => 'mario',
+ 'Mario Kart' => 'mario-kart',
+ 'Mario Kart 8' => 'mario-kart-8',
+ 'Mario Kart 8 Deluxe' => 'mario-kart-8-deluxe',
+ 'Marvel' => 'marvel',
+ 'Mascara' => 'mascara',
+ 'Massage' => 'massage',
+ 'Mass Effect' => 'mass-effect',
+ 'Mass Effect Andromeda' => 'mass-effect-andromeda',
+ 'Maternity' => 'maternity',
+ 'Mattress' => 'mattress',
+ 'Mattress Topper' => 'mattress-topper',
+ 'Mavic' => 'mavic',
+ 'Maxi Cosi' => 'maxi-cosi',
+ 'Meat' => 'meat',
+ 'Mechanical Keyboard' => 'mechanical-keyboard',
+ 'Medion' => 'medion',
+ 'Memory Foam' => 'memory-foam',
+ 'Mens Boots' => 'mens-boots',
+ 'Mens Fashion' => 'mens-clothing',
+ 'Mens Shoes' => 'mens-shoes',
+ 'Mercedes' => 'mercedes',
+ 'Merlin' => 'merlin',
+ 'Merrell' => 'merrell',
+ 'Mexico' => 'mexico',
+ 'Michael Kors' => 'michael-kors',
+ 'Microphone' => 'microphone',
+ 'Micro SD' => 'micro-sd',
+ 'Microserver' => 'microserver',
+ 'Microsoft' => 'microsoft',
+ 'Microsoft Office' => 'microsoft-office',
+ 'Microwave' => 'microwave',
+ 'Miele' => 'miele',
+ 'Milwaukee' => 'milwaukee',
+ 'Minecraft' => 'minecraft',
+ 'Mini' => 'mini',
+ 'Mini Fridge' => 'mini-fridge',
+ 'Mini PC' => 'mini-pc',
+ 'Mirror' => 'mirror',
+ 'Mitre Saw' => 'mitre-saw',
+ 'Mobile Broadband' => 'mobile-broadband',
+ 'Mobile Contract' => 'mobile-contract',
+ 'Mobile Phone' => 'mobile-phone',
+ 'Mobiles' => 'mobiles',
+ 'Molton Brown' => 'molton-brown',
+ 'Monitor' => 'monitor',
+ 'Monopoly' => 'monopoly',
+ 'Monsoon' => 'monsoon',
+ 'Mop' => 'mop',
+ 'Morocco' => 'morocco',
+ 'Mortgage' => 'mortgage',
+ 'Moses Basket' => 'moses-basket',
+ 'Mot' => 'mot',
+ 'Motherboard' => 'motherboard',
+ 'Moto 360' => 'moto-360',
+ 'Moto G' => 'moto-g',
+ 'Moto G4' => 'moto-g4',
+ 'Moto G5' => 'moto-g5',
+ 'Motorcycle' => 'motorcycle',
+ 'Motorola' => 'motorola',
+ 'Moto Z' => 'moto-z',
+ 'Mountain Bike' => 'mountain-bike',
+ 'Mouse' => 'mouse',
+ 'Mouse Mat' => 'mouse-mat',
+ 'Movie' => 'movie',
+ 'MP3 Player' => 'mp3-player',
+ 'MSI' => 'msi',
+ 'Mug' => 'mug',
+ 'Multitool' => 'multitool',
+ 'Music' => 'music',
+ 'My Little Pony' => 'my-little-pony',
+ 'Nandos' => 'nandos',
+ 'NAS' => 'nas',
+ 'National Express' => 'national-express',
+ 'National Trust' => 'national-trust',
+ 'Necklace' => 'necklace',
+ 'Nectar' => 'nectar',
+ 'Neff' => 'neff',
+ 'Nerf' => 'nerf',
+ 'Nescafe' => 'nescafe',
+ 'Nespresso' => 'nespresso',
+ 'Nest' => 'nest',
+ 'Netflix' => 'netflix',
+ 'Netgear' => 'netgear',
+ 'Netgear Arlo' => 'arlo',
+ 'New Balance' => 'new-balance',
+ 'Newcastle' => 'newcastle',
+ 'New Look' => 'new-look',
+ 'New York' => 'new-york',
+ 'Nextbase' => 'nextbase',
+ 'Nexus' => 'nexus',
+ 'NFL' => 'nfl',
+ 'Nier' => 'nier',
+ 'Nike' => 'nike',
+ 'Nike Air Max' => 'nike-air-max',
+ 'Nikon' => 'nikon',
+ 'Nilfisk' => 'nilfisk',
+ 'Ninja' => 'ninja',
+ 'Nintendo' => 'nintendo',
+ 'Nintendo DS' => 'nintendo-ds',
+ 'Nintendo eShop' => 'eshop',
+ 'Nintendo Switch' => 'nintendo-switch',
+ 'Nioh' => 'nioh',
+ 'Nissan' => 'nissan',
+ 'Nivea' => 'nivea',
+ 'Nokia' => 'nokia',
+ 'North Face' => 'north-face',
+ 'Norton' => 'norton',
+ 'Note 4' => 'note-4',
+ 'Now TV' => 'now-tv',
+ 'Nursery' => 'nursery',
+ 'Nus' => 'nus',
+ 'Nutella' => 'nutella',
+ 'Nutribullet' => 'nutribullet',
+ 'Nutri Ninja' => 'nutri-ninja',
+ 'NVIDIA' => 'nvidia',
+ 'O2' => 'o2',
+ 'O2 Refresh' => 'o2-refresh',
+ 'Oak' => 'oak',
+ 'Oakley' => 'oakley',
+ 'Oculus' => 'oculus',
+ 'Odeon' => 'odeon',
+ 'Office' => 'office',
+ 'Office Chair' => 'office-chair',
+ 'OLED TV' => 'oled',
+ 'Olympus' => 'olympus',
+ 'Oneplus' => 'oneplus',
+ 'Onkyo' => 'onkyo',
+ 'Oral-B' => 'oral-b',
+ 'Origin' => 'origin',
+ 'Orlando' => 'orlando',
+ 'Osprey' => 'osprey',
+ 'Ottoman' => 'ottoman',
+ 'Outdoor' => 'outdoor',
+ 'Oven' => 'oven',
+ 'Overwatch' => 'overwatch',
+ 'Paddling Pool' => 'paddling-pool',
+ 'Paint' => 'paint',
+ 'Pampers' => 'pampers',
+ 'Pan' => 'pan',
+ 'Panasonic' => 'panasonic',
+ 'Panasonic Lumix' => 'lumix',
+ 'Pandora' => 'pandora',
+ 'Papa Johns' => 'papa-johns',
+ 'Parasol' => 'parasol',
+ 'Parcel' => 'parcel',
+ 'Paris' => 'paris',
+ 'Parking' => 'parking',
+ 'Paw Patrol' => 'paw-patrol',
+ 'PAYG' => 'payg',
+ 'Paypal' => 'paypal',
+ 'PC' => 'pc',
+ 'PC Case' => 'pc-case',
+ 'PC Game' => 'pc-game',
+ 'Pebble' => 'pebble',
+ 'Peppa Pig' => 'peppa-pig',
+ 'Pepsi' => 'pepsi',
+ 'Perfume' => 'perfume',
+ 'Persona' => 'persona',
+ 'Persona 5' => 'persona-5',
+ 'Petrol' => 'petrol',
+ 'Philips' => 'philips',
+ 'Philips Hue' => 'philips-hue',
+ 'Phones' => 'phone',
+ 'Photo' => 'photo',
+ 'Piano' => 'piano',
+ 'Pillow' => 'pillow',
+ 'Pizza' => 'pizza',
+ 'Plant' => 'plant',
+ 'Playhouse' => 'playhouse',
+ 'Playmobil' => 'playmobil',
+ 'Playstation' => 'playstation',
+ 'Playstation Plus' => 'playstation-plus',
+ 'Playstation VR' => 'playstation-vr',
+ 'Pokemon' => 'pokemon',
+ 'Pool' => 'pool',
+ 'Power Bank' => 'power-bank',
+ 'Powerline' => 'powerline',
+ 'Power Rangers' => 'power-rangers',
+ 'Pram' => 'pram',
+ 'Pressure Cooker' => 'pressure-cooker',
+ 'Pressure Washer' => 'pressure-washer',
+ 'Printer' => 'printer',
+ 'Projector' => 'projector',
+ 'Protein' => 'protein',
+ 'PS3' => 'ps3',
+ 'PS4' => 'ps4',
+ 'PS4 Controller' => 'ps4-controller',
+ 'PS4 Games' => 'ps4-games',
+ 'PS4 Headset' => 'ps4-headset',
+ 'PS4 Pro' => 'ps4-pro',
+ 'PS4 Slim' => 'ps4-slim',
+ 'PSN' => 'psn',
+ 'PSU' => 'psu',
+ 'PS Vita' => 'ps-vita',
+ 'Puma' => 'puma',
+ 'Pushchair' => 'pushchair',
+ 'Qnap' => 'qnap',
+ 'Quorn' => 'quorn',
+ 'Rab' => 'rab',
+ 'Radiator' => 'radiator',
+ 'Radio' => 'radio',
+ 'Radley' => 'radley',
+ 'Railcard' => 'railcard',
+ 'Ralph Lauren' => 'ralph-lauren',
+ 'RAM' => 'ram',
+ 'Raspberry Pi' => 'raspberry-pi',
+ 'Rattan Garden Furniture' => 'rattan',
+ 'Ray Ban' => 'ray-ban',
+ 'Razer' => 'razer',
+ 'Razor' => 'razor',
+ 'Reebok' => 'reebok',
+ 'Resident Evil' => 'resident-evil',
+ 'Resident Evil 7' => 'resident-evil-7',
+ 'Rice' => 'rice',
+ 'Ring' => 'ring',
+ 'Road Bike' => 'road-bike',
+ 'Rocket League' => 'rocket-league',
+ 'Rogue One' => 'rogue-one',
+ 'Roku' => 'roku',
+ 'Rolex' => 'rolex',
+ 'Roof Box' => 'roof-box',
+ 'Roses' => 'roses',
+ 'Rotary' => 'rotary',
+ 'Router' => 'router',
+ 'Rug' => 'rug',
+ 'Rum' => 'rum',
+ 'Running' => 'running',
+ 'RX 480' => 'rx-480',
+ 'Ryanair' => 'ryanair',
+ 'Ryobi' => 'ryobi',
+ 'Sale' => 'sale',
+ 'Salmon' => 'salmon',
+ 'Salomon' => 'salomon',
+ 'Samsonite' => 'samsonite',
+ 'Samsung' => 'samsung',
+ 'Samsung Galaxy' => 'samsung-galaxy',
+ 'Samsung Galaxy S7' => 'samsung-galaxy-s7',
+ 'Samsung Galaxy S7 Edge' => 'samsung-galaxy-s7-edge',
+ 'Samsung Galaxy S8' => 'samsung-galaxy-s8',
+ 'Samsung Galaxy S8 Plus' => 'samsung-s8-plus',
+ 'Samsung Gear' => 'samsung-gear',
+ 'Samsung TV' => 'samsung-tv',
+ 'Sandals' => 'sandals',
+ 'Sander' => 'sander',
+ 'SanDisk' => 'sandisk',
+ 'Sat Nav' => 'sat-nav',
+ 'Saw' => 'saw',
+ 'Scalextric' => 'scalextric',
+ 'Scooter' => 'scooter',
+ 'Screenwash' => 'screenwash',
+ 'Screwdriver' => 'screwdriver',
+ 'SD Card' => 'sd-card',
+ 'SDXC' => 'sdxc',
+ 'Seagate' => 'seagate',
+ 'Seat' => 'seat',
+ 'Security Camera' => 'security-camera',
+ 'Seeds' => 'seeds',
+ 'Seiko' => 'seiko',
+ 'Sennheiser' => 'sennheiser',
+ 'Server' => 'server',
+ 'Sewing Machine' => 'sewing-machine',
+ 'Shadow Of Mordor' => 'shadow-of-mordor',
+ 'Shark' => 'shark',
+ 'Sharpie' => 'sharpie',
+ 'Shaver' => 'shaver',
+ 'Shed' => 'shed',
+ 'Shelves' => 'shelves',
+ 'Shirt' => 'shirt',
+ 'Shoes' => 'shoe',
+ 'Shopkins' => 'shopkins',
+ 'Shorts' => 'shorts',
+ 'Shower' => 'shower',
+ 'Shredder' => 'shredder',
+ 'Sideboard' => 'sideboard',
+ 'Sim' => 'sim',
+ 'Sim Free' => 'sim-free',
+ 'Sim Only' => 'sim-only',
+ 'Sink' => 'sink',
+ 'Skechers' => 'skechers',
+ 'Ski' => 'ski',
+ 'Skoda' => 'skoda',
+ 'Sky' => 'sky',
+ 'Skylanders' => 'skylanders',
+ 'Skyrim' => 'skyrim',
+ 'Sleeping Bag' => 'sleeping-bag',
+ 'Slide' => 'slide',
+ 'Slimming World' => 'slimming-world',
+ 'Slippers' => 'slippers',
+ 'Slow Cooker' => 'slow-cooker',
+ 'SLR Camera' => 'slr',
+ 'Smart' => 'smart',
+ 'Smartphone' => 'smartphone',
+ 'Smartthings' => 'smartthings',
+ 'Smart TV' => 'smart-tv',
+ 'Smartwatch' => 'smartwatch',
+ 'Snapfish' => 'snapfish',
+ 'Socket Set' => 'socket-set',
+ 'Socks' => 'socks',
+ 'Sofa' => 'sofa',
+ 'Software & Apps' => 'software-apps',
+ 'Sonicare' => 'sonicare',
+ 'Sonos' => 'sonos',
+ 'Sony' => 'sony',
+ 'Sony TV' => 'sony-tv',
+ 'Soundbar' => 'soundbar',
+ 'Soup Maker' => 'soup-maker',
+ 'Spa' => 'spa',
+ 'Spain' => 'spain',
+ 'Speakers' => 'speakers',
+ 'Spinner' => 'spinner',
+ 'Spirits' => 'spirits',
+ 'Splatoon' => 'splatoon',
+ 'Sports & Fitness' => 'sports-fitness',
+ 'SSD' => 'ssd',
+ 'Starbucks' => 'starbucks',
+ 'Star Trek' => 'star-trek',
+ 'Star Wars' => 'star-wars',
+ 'Steak' => 'steak',
+ 'Steam' => 'steam',
+ 'Steamer' => 'steamer',
+ 'Steam Iron' => 'steam-iron',
+ 'Steam Link' => 'steam-link',
+ 'Steam Mop' => 'steam-mop',
+ 'Storage' => 'storage',
+ 'Storage Box' => 'storage-box',
+ 'Strimmer' => 'strimmer',
+ 'Student' => 'student',
+ 'Suit' => 'suit',
+ 'Suitcase' => 'suitcase',
+ 'Sun Cream' => 'sun-cream',
+ 'Sunglasses' => 'sunglasses',
+ 'Superdry' => 'superdry',
+ 'Surface' => 'surface',
+ 'Surface Book' => 'surface-book',
+ 'Sweets' => 'sweets',
+ 'Swing' => 'swing',
+ 'Synology' => 'synology',
+ 'T-Shirt' => 't-shirt',
+ 'Table' => 'table',
+ 'Tablet' => 'tablet',
+ 'Table Tennis' => 'table-tennis',
+ 'Tado' => 'tado',
+ 'Tag Heuer' => 'tag-heuer',
+ 'Takeaway' => 'takeaway',
+ 'Talkmobile' => 'talkmobile',
+ 'Tap' => 'tap',
+ 'Tassimo' => 'tassimo',
+ 'Tastecard' => 'tastecard',
+ 'Tea' => 'tea',
+ 'Ted Baker' => 'ted-baker',
+ 'Tefal' => 'tefal',
+ 'Tekken' => 'tekken',
+ 'Tekken 7' => 'tekken-7',
+ 'Telegraph' => 'telegraph',
+ 'Telescope' => 'telescope',
+ 'Tenerife' => 'tenerife',
+ 'Tennis' => 'tennis',
+ 'Tent' => 'tent',
+ 'Tesco Clothing' => 'tesco-clothing',
+ 'Tesla' => 'tesla',
+ 'Thailand' => 'thailand',
+ 'Theatre' => 'theatre',
+ 'The Body Shop' => 'body-shop',
+ 'The Last Guardian' => 'the-last-guardian',
+ 'The Last Of Us' => 'the-last-of-us',
+ 'Theme Park' => 'theme-park',
+ 'Thermometer' => 'thermometer',
+ 'Thermos' => 'thermos',
+ 'Thermostat' => 'thermostat',
+ 'The Sun' => 'the-sun',
+ 'The Witcher 3' => 'the-witcher-3',
+ 'Thinkpad' => 'thinkpad',
+ 'Thomas Sabo' => 'thomas-sabo',
+ 'Thorntons' => 'thorntons',
+ 'Thorpe Park' => 'thorpe-park',
+ 'Throw' => 'throw',
+ 'Thrustmaster' => 'thrustmaster',
+ 'Thule' => 'thule',
+ 'Tights' => 'tights',
+ 'Tile' => 'tile',
+ 'Timberland' => 'timberland',
+ 'Tissot' => 'tissot',
+ 'Titanfall' => 'titanfall',
+ 'Titanfall 2' => 'titanfall-2',
+ 'Toaster' => 'toaster',
+ 'Toddler Bed' => 'toddler-bed',
+ 'Toilet' => 'toilet',
+ 'Toilet Roll' => 'toilet-roll',
+ 'Toilet Seat' => 'toilet-seat',
+ 'Tomb Raider' => 'tomb-raider',
+ 'Tom Clancy' => 'tom-clancy',
+ 'Tom Ford' => 'tom-ford',
+ 'Tommee Tippee' => 'tommee-tippee',
+ 'Toms' => 'toms',
+ 'TomTom' => 'tomtom',
+ 'Tool' => 'tool',
+ 'Toothbrush' => 'toothbrush',
+ 'Toothpaste' => 'toothpaste',
+ 'Toot Toot' => 'toot-toot',
+ 'Torch' => 'torch',
+ 'Torque Wrench' => 'torque-wrench',
+ 'Toshiba' => 'toshiba',
+ 'Towel' => 'towel',
+ 'Toyota' => 'toyota',
+ 'Toys' => 'toy',
+ 'Toy Story' => 'toy-story',
+ 'Tp Link' => 'tp-link',
+ 'Tracksuit' => 'tracksuit',
+ 'Train' => 'train',
+ 'Trainers' => 'trainers',
+ 'Trampoline' => 'trampoline',
+ 'Transcend' => 'transcend',
+ 'Transformers' => 'transformers',
+ 'Travel' => 'travel',
+ 'Travel Insurance' => 'travel-insurance',
+ 'Travelodge' => 'travelodge',
+ 'Travel System' => 'travel-system',
+ 'Treadmill' => 'treadmill',
+ 'Trespass' => 'trespass',
+ 'Trike' => 'trike',
+ 'Tripod' => 'tripod',
+ 'Tripp' => 'tripp',
+ 'Trolley' => 'trolley',
+ 'Trousers' => 'trousers',
+ 'Trunki' => 'trunki',
+ 'Tumble Dryer' => 'tumble-dryer',
+ 'Tuna' => 'tuna',
+ 'Turbo Trainer' => 'turbo-trainer',
+ 'Turkey' => 'turkey',
+ 'Turntable' => 'turntable',
+ 'Turtle Beach' => 'turtle-beach',
+ 'TV' => 'tv',
+ 'TV Stand' => 'tv-stand',
+ 'Tyres' => 'tyres',
+ 'Ubisoft' => 'ubisoft',
+ 'Ue Boom' => 'ue-boom',
+ 'UFC' => 'ufc',
+ 'UGG' => 'ugg',
+ 'Ulefone' => 'ulefone',
+ 'Ultimate Ears UE Boom 2' => 'ue-boom-2',
+ 'Ultimate Outdoors' => 'ultimate-outdoors',
+ 'Ultrabook' => 'ultrabook',
+ 'Ultrawide Monitor' => 'ultrawide',
+ 'Umbrella' => 'umbrella',
+ 'Umi' => 'umi',
+ 'Uncharted' => 'uncharted',
+ 'Uncharted 4' => 'uncharted-4',
+ 'Under Armour' => 'under-armour',
+ 'Underwear' => 'underwear',
+ 'Unicorn' => 'unicorn',
+ 'Unidays' => 'unidays',
+ 'Urban Decay' => 'urban-decay',
+ 'Usa' => 'usa',
+ 'USB Hub' => 'usb-hub',
+ 'USB Memory Stick' => 'flash-drive',
+ 'Usn' => 'usn',
+ 'Vacuum Cleaners' => 'vacuum-cleaners',
+ 'Vango' => 'vango',
+ 'Vanish' => 'vanish',
+ 'Vans' => 'vans',
+ 'Vape' => 'vape',
+ 'Vauxhall' => 'vauxhall',
+ 'Vax' => 'vax',
+ 'Velvet' => 'velvet',
+ 'Venice' => 'venice',
+ 'Versace' => 'versace',
+ 'Vibrator' => 'vibrator',
+ 'Victorinox' => 'victorinox',
+ 'Vileda' => 'vileda',
+ 'Vinyl' => 'vinyl',
+ 'Virgin' => 'virgin',
+ 'Vitamix' => 'vitamix',
+ 'Vodafone' => 'vodafone',
+ 'Vodka' => 'vodka',
+ 'Volvo' => 'volvo',
+ 'VPN' => 'vpn',
+ 'VR' => 'vr',
+ 'VTech' => 'vtech',
+ 'Vue' => 'vue',
+ 'VW' => 'vw',
+ 'Wacom' => 'wacom',
+ 'Waffle Maker' => 'waffle-maker',
+ 'Wahl' => 'wahl',
+ 'Walkers' => 'walkers',
+ 'Walking Boots' => 'walking-boots',
+ 'Walking Dead' => 'walking-dead',
+ 'Wallet' => 'wallet',
+ 'Wallpaper' => 'wallpaper',
+ 'Walsall' => 'walsall',
+ 'Wardrobe' => 'wardrobe',
+ 'Warhammer' => 'warhammer',
+ 'Washer Dryer' => 'washer-dryer',
+ 'Washing Machine' => 'washing-machine',
+ 'Watch' => 'watch',
+ 'Watch Dogs' => 'watch-dogs',
+ 'Watch Dogs 2' => 'watch-dogs-2',
+ 'Water Bottle' => 'water-bottle',
+ 'Water Butt' => 'water-butt',
+ 'Water Filter' => 'water-filter',
+ 'Wayfair' => 'wayfair',
+ 'Webcam' => 'webcam',
+ 'Weber' => 'weber',
+ 'Wedding' => 'wedding',
+ 'Weed' => 'weed',
+ 'Weekend Break' => 'weekend-break',
+ 'Weetabix' => 'weetabix',
+ 'Weight Watchers' => 'weight-watchers',
+ 'Wellies' => 'wellies',
+ 'Wenger' => 'wenger',
+ 'Western Digital' => 'western-digital',
+ 'Wetsuit' => 'wetsuit',
+ 'Wheelbarrow' => 'wheelbarrow',
+ 'Whey' => 'whey',
+ 'Whiskas' => 'whiskas',
+ 'Whisky' => 'whisky',
+ 'Wifi Camera' => 'wifi-camera',
+ 'Wifi Extender' => 'wifi-extender',
+ 'Wii' => 'wii',
+ 'Wii U' => 'wii-u',
+ 'Wii U Pro Controller' => 'wii-u-pro-controller',
+ 'Wileyfox' => 'wileyfox',
+ 'Wilkinson Sword' => 'wilkinson-sword',
+ 'Wimbledon' => 'wimbledon',
+ 'Windows' => 'windows',
+ 'Windows 10' => 'windows-10',
+ 'Wine' => 'wine',
+ 'Wipes' => 'wipes',
+ 'Wireless Headphones' => 'wireless-headphones',
+ 'Wireless Keyboard' => 'wireless-keyboard',
+ 'Witcher' => 'witcher',
+ 'Wok' => 'wok',
+ 'Wolfenstein' => 'wolfenstein',
+ 'Women Fashion' => 'womens-clothes',
+ 'Workbench' => 'workbench',
+ 'World Of Warcraft' => 'world-of-warcraft',
+ 'Worx' => 'worx',
+ 'Wuaki' => 'wuaki',
+ 'WWE' => 'wwe',
+ 'Xbox' => 'xbox',
+ 'Xbox 360' => 'xbox-360',
+ 'Xbox 360 Game' => 'xbox-360-game',
+ 'Xbox Controller' => 'xbox-controller',
+ 'Xbox Gift Card' => 'xbox-gift-card',
+ 'Xbox Headset' => 'xbox-headset',
+ 'Xbox Live' => 'xbox-live',
+ 'Xbox One' => 'xbox-one',
+ 'Xbox One Controller' => 'xbox-one-controller',
+ 'Xbox One Elite Controller' => 'xbox-one-elite-controller',
+ 'Xbox One Games' => 'xbox-one-games',
+ 'Xbox One S' => 'xbox-one-s',
+ 'Xbox One X' => 'xbox-one-x',
+ 'Xbox Wireless Adapter' => 'xbox-wireless-adapter',
+ 'Xcom' => 'xcom',
+ 'XCOM 2' => 'xcom-2',
+ 'XFX' => 'xfx',
+ 'Xiaomi' => 'xiaomi',
+ 'Xiaomi Redmi' => 'redmi',
+ 'Xperia' => 'xperia',
+ 'Xperia Z3' => 'xperia-z3',
+ 'Xperia Z5' => 'xperia-z5',
+ 'XPS' => 'xps',
+ 'Yakuza' => 'yakuza',
+ 'Yale' => 'yale',
+ 'Yamaha' => 'yamaha',
+ 'Yankee Candle' => 'yankee-candle',
+ 'Yoga' => 'yoga',
+ 'York' => 'york',
+ 'Yorkshire' => 'yorkshire',
+ 'Yoshi' => 'yoshi',
+ 'Youview' => 'youview',
+ 'Yves Saint Laurent' => 'yves-saint-laurent',
+ 'Zante' => 'zante',
+ 'Zanussi' => 'zanussi',
+ 'Zelda' => 'zelda',
+ 'Zelda Breath Of The Wild' => 'zelda-breath-of-the-wild',
+ 'Zenbook' => 'zenbook',
+ 'Zippo' => 'zippo',
+ 'Zizzi' => 'zizzi',
+ 'Zoo' => 'zoo',
+ 'Zoostorm' => 'zoostorm',
+ 'ZOTAC' => 'zotac',
+ 'ZTE' => 'zte',
+ 'ZyXEL' => 'zyxel',
+ )
+ ),
+ 'order' => array(
+ 'name' => 'Order by',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Sort order of deals',
+ 'values' => array(
+ 'From the most to the least hot deal' => '-hot',
+ 'From the most recent deal to the oldest' => '',
+ 'From the most commented deal to the least commented deal' => '-discussed'
+ )
+ )
+ )
+ );
+
+ public $lang = array(
+ 'bridge-uri' => SELF::URI,
+ 'bridge-name' => SELF::NAME,
+ 'context-keyword' => 'Search by keyword(s))',
+ 'context-group' => 'Deals per group',
+ 'uri-group' => '/tag/',
+ 'request-error' => 'Could not request HotUKDeals',
+ 'no-results' => 'Ooops, looks like we could',
+ 'relative-date-indicator' => array(
+ 'ago',
+ ),
+ 'price' => 'Price',
+ 'shipping' => 'Shipping',
+ 'origin' => 'Origin',
+ 'discount' => 'Discount',
+ 'title-keyword' => 'Search',
+ 'title-group' => 'Group',
+ 'local-months' => array(
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Occ',
+ 'Nov',
+ 'Dec',
+ 'st',
+ 'nd',
+ 'rd',
+ 'th'
+ ),
+ 'local-time-relative' => array(
+ 'Found ',
+ 'm',
+ 'h,',
+ 'day',
+ 'days',
+ 'month',
+ 'year',
+ 'and '
+ ),
+ 'date-prefixes' => array(
+ 'Found ',
+ 'Refreshed ',
+ 'Made hot '
+ ),
+ 'relative-date-alt-prefixes' => array(
+ 'Made hot ',
+ 'Refreshed ',
+ 'Last updated '
+ ),
+ 'relative-date-ignore-suffix' => array(
+ '/by.*$/'
+ ),
+ 'localdeal' => array(
+ 'Local',
+ 'Expires'
+ )
+ );
+
+}
diff --git a/bridges/IPBBridge.php b/bridges/IPBBridge.php
new file mode 100644
index 0000000..5b9d0e0
--- /dev/null
+++ b/bridges/IPBBridge.php
@@ -0,0 +1,310 @@
+<?php
+class IPBBridge extends FeedExpander {
+
+ const NAME = 'IPB Bridge';
+ const URI = 'https://www.invisionpower.com';
+ const DESCRIPTION = 'Returns feeds for forums powered by IPB';
+ const MAINTAINER = 'logmanoriginal';
+ const PARAMETERS = array(
+ array(
+ 'uri' => array(
+ 'name' => 'URI',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Insert forum, subforum or topic URI',
+ 'exampleValue' => 'https://invisioncommunity.com/forums/forum/499-feedback-and-ideas/'
+ ),
+ 'limit' => array(
+ 'name' => 'Limit',
+ 'type' => 'number',
+ 'required' => false,
+ 'title' => 'Specifies the number of items to return on each request (-1: all)',
+ 'defaultValue' => 10
+ )
+ )
+ );
+ const CACHE_TIMEOUT = 3600;
+
+ // Constants for internal use
+ const FORUM_TYPE_LIST_FILTER = '.cForumTopicTable';
+ const FORUM_TYPE_TABLE_FILTER = '#forum_table';
+
+ const TOPIC_TYPE_ARTICLE = 'article';
+ const TOPIC_TYPE_DIV = 'div.post_block';
+
+ public function getURI(){
+ return $this->getInput('uri') ?: parent::getURI();
+ }
+
+ public function collectData(){
+ // The URI cannot be the mainpage (or anything related)
+ switch(parse_url($this->getInput('uri'), PHP_URL_PATH)) {
+ case null:
+ case '/index.php':
+ returnClientError('Provided URI is invalid!');
+ break;
+ default:
+ break;
+ }
+
+ // Sanitize the URI (because else it won't work)
+ $uri = rtrim($this->getInput('uri'), '/'); // No trailing slashes!
+
+ // Forums might provide feeds, though that's optional *facepalm*
+ // Let's check if there is a valid feed available
+ $headers = get_headers($uri . '.xml');
+
+ if($headers[0] === 'HTTP/1.1 200 OK') { // Heureka! It's a valid feed!
+ return $this->collectExpandableDatas($uri);
+ }
+
+ // No valid feed, so do it the hard way
+ $html = getSimpleHTMLDOM($uri)
+ or returnServerError('Could not request ' . $this->getInput('uri') . '!');
+
+ $limit = $this->getInput('limit');
+
+ // Determine if this is a topic or a forum
+ switch(true) {
+ case $this->isTopic($html):
+ $this->collectTopic($html, $limit);
+ break;
+ case $this->isForum($html);
+ $this->collectForum($html);
+ break;
+ default:
+ returnClientError('Unknown type!');
+ break;
+ }
+ }
+
+ private function isForum($html){
+ return !is_null($html->find('div[data-controller*=forums.front.forum.forumPage]', 0))
+ || !is_null($html->find(static::FORUM_TYPE_TABLE_FILTER, 0));
+ }
+
+ private function isTopic($html){
+ return !is_null($html->find('div[data-controller*=core.front.core.commentFeed]', 0))
+ || !is_null($html->find(static::TOPIC_TYPE_DIV, 0));
+ }
+
+ private function collectForum($html){
+ // There are multiple forum designs in use (depends on version?)
+ // 1 - Uses an ordered list (based on https://invisioncommunity.com/forums)
+ // 2 - Uses a table (based on https://onehallyu.com)
+
+ switch(true) {
+ case !is_null($html->find(static::FORUM_TYPE_LIST_FILTER, 0)):
+ $this->collectForumList($html);
+ break;
+ case !is_null($html->find(static::FORUM_TYPE_TABLE_FILTER, 0)):
+ $this->collectForumTable($html);
+ break;
+ default:
+ returnClientError('Unknown forum format!');
+ break;
+ }
+ }
+
+ private function collectForumList($html){
+ foreach($html->find(static::FORUM_TYPE_LIST_FILTER, 0)->children() as $row) {
+ // Columns: Title, Statistics, Last modified
+ $item = array();
+
+ $item['uri'] = $row->find('a', 0)->href;
+ $item['title'] = $row->find('a', 0)->title;
+ $item['author'] = $row->find('a', 1)->innertext;
+ $item['timestamp'] = strtotime($row->find('time', 0)->getAttribute('datetime'));
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function collectForumTable($html){
+ foreach($html->find(static::FORUM_TYPE_TABLE_FILTER, 0)->children() as $row) {
+ // Columns: Icon, Content, Preview, Statistics, Last modified
+ $item = array();
+
+ // Skip header row
+ if(!is_null($row->find('th', 0))) continue;
+
+ $item['uri'] = $row->find('a', 0)->href;
+ $item['title'] = $row->find('.title', 0)->plaintext;
+ $item['timestamp'] = strtotime($row->find('[itemprop=dateCreated]', 0)->plaintext);
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function collectTopic($html, $limit){
+ // There are multiple topic designs in use (depends on version?)
+ // 1 - Uses articles (based on https://invisioncommunity.com/forums)
+ // 2 - Uses divs (based on https://onehallyu.com)
+
+ switch(true) {
+ case !is_null($html->find(static::TOPIC_TYPE_ARTICLE, 0)):
+ $this->collectTopicHistory($html, $limit, 'collectTopicArticle');
+ break;
+ case !is_null($html->find(static::TOPIC_TYPE_DIV, 0)):
+ $this->collectTopicHistory($html, $limit, 'collectTopicDiv');
+ break;
+ default:
+ returnClientError('Unknown topic format!');
+ break;
+ }
+ }
+
+ private function collectTopicHistory($html, $limit, $callback){
+ // Make sure the callback is valid!
+ if(!method_exists($this, $callback))
+ returnServerError('Unknown function (\'' . $callback . '\')!');
+
+ $next = null; // Holds the URI of the next page
+
+ while(true) {
+ $next = $this->$callback($html, is_null($next));
+
+ if(is_null($next) || ($limit > 0 && count($this->items) >= $limit)) {
+ break;
+ }
+
+ $html = getSimpleHTMLDOMCached($next);
+ }
+
+ // We might have more items than specified, remove excess
+ $this->items = array_slice($this->items, 0, $limit);
+ }
+
+ private function collectTopicArticle($html, $firstrun = true){
+ $title = $html->find('h1.ipsType_pageTitle', 0)->plaintext;
+
+ // Are we on last page?
+ if($firstrun && !is_null($html->find('.ipsPagination', 0))) {
+ $last = $html->find('.ipsPagination_last a', 0)->{'data-page'};
+ $active = $html->find('.ipsPagination_active a', 0)->{'data-page'};
+
+ if($active !== $last) {
+ // Load last page into memory (cached)
+ $html = getSimpleHTMLDOMCached($html->find('.ipsPagination_last a', 0)->href);
+ }
+ }
+
+ foreach(array_reverse($html->find(static::TOPIC_TYPE_ARTICLE)) as $article) {
+ $item = array();
+
+ $item['uri'] = $article->find('time', 0)->parent()->href;
+ $item['author'] = $article->find('aside a', 0)->plaintext;
+ $item['title'] = $item['author'] . ' - ' . $title;
+ $item['timestamp'] = strtotime($article->find('time', 0)->getAttribute('datetime'));
+
+ $content = $article->find('[data-role=commentContent]', 0);
+ $content = $this->scaleImages($content);
+ $item['content'] = $this->fixContent($content);
+ $item['enclosures'] = $this->findImages($article->find('[data-role=commentContent]', 0)) ?: null;
+
+ $this->items[] = $item;
+ }
+
+ // Return whatever page comes next (previous, as we add in inverse order)
+ // Do we have a previous page? (inactive means no)
+ if(!is_null($html->find('li[class=ipsPagination_prev ipsPagination_inactive]', 0))) {
+ return null; // No, or no more
+ } elseif(!is_null($html->find('li[class=ipsPagination_prev]', 0))) {
+ return $html->find('.ipsPagination_prev a', 0)->href;
+ }
+
+ return null;
+ }
+
+ private function collectTopicDiv($html, $firstrun = true){
+ $title = $html->find('h1.ipsType_pagetitle', 0)->plaintext;
+
+ // Are we on last page?
+ if($firstrun && !is_null($html->find('.pagination', 0))) {
+
+ $active = $html->find('li[class=page active]', 0)->plaintext;
+
+ // There are two ways the 'last' page is displayed:
+ // - With a distict 'last' button (only if there are enough pages)
+ // - With a button for each page (use last button)
+ if(!is_null($html->find('li.last', 0))) {
+ $last = $html->find('li.last a', 0);
+ } else {
+ $last = $html->find('li[class=page] a', -1);
+ }
+
+ if($active !== $last->plaintext) {
+ // Load last page into memory (cached)
+ $html = getSimpleHTMLDOMCached($last->href);
+ }
+ }
+
+ foreach(array_reverse($html->find(static::TOPIC_TYPE_DIV)) as $article) {
+ $item = array();
+
+ $item['uri'] = $article->find('a[rel=bookmark]', 0)->href;
+ $item['author'] = $article->find('.author', 0)->plaintext;
+ $item['title'] = $item['author'] . ' - ' . $title;
+ $item['timestamp'] = strtotime($article->find('.published', 0)->getAttribute('title'));
+
+ $content = $article->find('[itemprop=commentText]', 0);
+ $content = $this->scaleImages($content);
+ $item['content'] = $this->fixContent($content);
+
+ $item['enclosures'] = $this->findImages($article->find('.post_body', 0)) ?: null;
+
+ $this->items[] = $item;
+ }
+
+ // Return whatever page comes next (previous, as we add in inverse order)
+ // Do we have a previous page?
+ if(!is_null($html->find('li.prev', 0))) {
+ return $html->find('li.prev a', 0)->href;
+ }
+
+ return null;
+ }
+
+ /** Returns all images from the provide HTML DOM */
+ private function findImages($html){
+ $images = array();
+
+ foreach($html->find('img') as $img) {
+ $images[] = $img->src;
+ }
+
+ return $images;
+ }
+
+ /** Sets the maximum width and height for all images */
+ private function scaleImages($html, $width = 400, $height = 400){
+ foreach($html->find('img') as $img) {
+ $img->style = "max-width: {$width}px; max-height: {$height}px;";
+ }
+
+ return $html;
+ }
+
+ /** Removes all unnecessary tags and adds formatting */
+ private function fixContent($html){
+
+ // Restore quote highlighting
+ foreach($html->find('blockquote') as $quote) {
+ $quote->style = <<<EOD
+padding: 0px 15px;
+border-width: 1px 1px 1px 2px;
+border-style: solid;
+border-color: #ededed #e8e8e8 #dbdbdb #666666;
+background: #fbfbfb;
+EOD;
+ }
+
+ // Remove unnecessary tags
+ $content = strip_tags(
+ $html->innertext,
+ '<p><a><img><ol><ul><li><table><tr><th><td><strong><blockquote><br><hr><h>'
+ );
+
+ return $content;
+ }
+}
diff --git a/bridges/IdenticaBridge.php b/bridges/IdenticaBridge.php
new file mode 100644
index 0000000..ef52998
--- /dev/null
+++ b/bridges/IdenticaBridge.php
@@ -0,0 +1,52 @@
+<?php
+class IdenticaBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Identica Bridge';
+ const URI = 'https://identi.ca/';
+ const CACHE_TIMEOUT = 300; // 5min
+ const DESCRIPTION = 'Returns user timelines';
+
+ const PARAMETERS = array( array(
+ 'u' => array(
+ 'name' => 'username',
+ 'required' => true
+ )
+ ));
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Requested username can\'t be found.');
+
+ foreach($html->find('li.major') as $dent) {
+ $item = array();
+
+ // get dent link
+ $item['uri'] = html_entity_decode($dent->find('a', 0)->href);
+
+ // extract dent timestamp
+ $item['timestamp'] = strtotime($dent->find('abbr.easydate', 0)->plaintext);
+
+ // extract dent text
+ $item['content'] = trim($dent->find('div.activity-content', 0)->innertext);
+ $item['title'] = $this->getInput('u') . ' | ' . $item['content'];
+ $this->items[] = $item;
+ }
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('u'))) {
+ return $this->getInput('u') . ' - Identica Bridge';
+ }
+
+ return parent::getName();
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('u'))) {
+ return self::URI . urlencode($this->getInput('u'));
+ }
+
+ return parent::getURI();
+ }
+}
diff --git a/bridges/InstagramBridge.php b/bridges/InstagramBridge.php
new file mode 100644
index 0000000..317fb12
--- /dev/null
+++ b/bridges/InstagramBridge.php
@@ -0,0 +1,175 @@
+<?php
+class InstagramBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'pauder';
+ const NAME = 'Instagram Bridge';
+ const URI = 'https://www.instagram.com/';
+ const DESCRIPTION = 'Returns the newest images';
+
+ const PARAMETERS = array(
+ 'Username' => array(
+ 'u' => array(
+ 'name' => 'username',
+ 'required' => true
+ )
+ ),
+ 'Hashtag' => array(
+ 'h' => array(
+ 'name' => 'hashtag',
+ 'required' => true
+ )
+ ),
+ 'Location' => array(
+ 'l' => array(
+ 'name' => 'location',
+ 'required' => true
+ )
+ ),
+ 'global' => array(
+ 'media_type' => array(
+ 'name' => 'Media type',
+ 'type' => 'list',
+ 'required' => false,
+ 'values' => array(
+ 'All' => 'all',
+ 'Story' => 'story',
+ 'Video' => 'video',
+ 'Picture' => 'picture',
+ ),
+ 'defaultValue' => 'all'
+ )
+ )
+
+ );
+
+ public function collectData(){
+
+ if(is_null($this->getInput('u')) && $this->getInput('media_type') == 'story') {
+ returnClientError('Stories are not supported for hashtags nor locations!');
+ }
+
+ $data = $this->getInstagramJSON($this->getURI());
+
+ if(!is_null($this->getInput('u'))) {
+ $userMedia = $data->entry_data->ProfilePage[0]->graphql->user->edge_owner_to_timeline_media->edges;
+ } elseif(!is_null($this->getInput('h'))) {
+ $userMedia = $data->entry_data->TagPage[0]->graphql->hashtag->edge_hashtag_to_media->edges;
+ } elseif(!is_null($this->getInput('l'))) {
+ $userMedia = $data->entry_data->LocationsPage[0]->graphql->location->edge_location_to_media->edges;
+ }
+
+ foreach($userMedia as $media) {
+ $media = $media->node;
+
+ if(!is_null($this->getInput('u'))) {
+ switch($this->getInput('media_type')) {
+ case 'all': break;
+ case 'video':
+ if($media->__typename != 'GraphVideo') continue 2;
+ break;
+ case 'picture':
+ if($media->__typename != 'GraphImage') continue 2;
+ break;
+ case 'story':
+ if($media->__typename != 'GraphSidecar') continue 2;
+ break;
+ default: break;
+ }
+ } else {
+ if($this->getInput('media_type') == 'video' && !$media->is_video) continue;
+ }
+
+ $item = array();
+ $item['uri'] = self::URI . 'p/' . $media->shortcode . '/';
+
+ if (isset($media->owner->username)) {
+ $item['author'] = $media->owner->username;
+ }
+
+ if (isset($media->edge_media_to_caption->edges[0]->node->text)) {
+ $textContent = $media->edge_media_to_caption->edges[0]->node->text;
+ } else {
+ $textContent = basename($media->display_url);
+ }
+
+ $item['title'] = ($media->is_video ? '▶ ' : '') . trim($textContent);
+ $titleLinePos = strpos(wordwrap($item['title'], 120), "\n");
+ if ($titleLinePos != false) {
+ $item['title'] = substr($item['title'], 0, $titleLinePos) . '...';
+ }
+
+ if(!is_null($this->getInput('u')) && $media->__typename == 'GraphSidecar') {
+ $data = $this->getInstagramStory($item['uri']);
+ $item['content'] = $data[0];
+ $item['enclosures'] = $data[1];
+ } else {
+ $item['content'] = '<a href="' . htmlentities($item['uri']) . '" target="_blank">';
+ $item['content'] .= '<img src="' . htmlentities($media->display_url) . '" alt="' . $item['title'] . '" />';
+ $item['content'] .= '</a><br><br>' . nl2br(htmlentities($textContent));
+ $item['enclosures'] = array($media->display_url);
+ }
+
+ $item['timestamp'] = $media->taken_at_timestamp;
+
+ $this->items[] = $item;
+ }
+ }
+
+ protected function getInstagramStory($uri) {
+
+ $data = $this->getInstagramJSON($uri);
+ $mediaInfo = $data->entry_data->PostPage[0]->graphql->shortcode_media;
+
+ //Process the first element, that isn't in the node graph
+ if (count($mediaInfo->edge_media_to_caption->edges) > 0) {
+ $caption = $mediaInfo->edge_media_to_caption->edges[0]->node->text;
+ } else {
+ $caption = '';
+ }
+
+ $enclosures = [$mediaInfo->display_url];
+ $content = '<img src="' . htmlentities($mediaInfo->display_url) . '" alt="' . $caption . '" />';
+
+ foreach($mediaInfo->edge_sidecar_to_children->edges as $media) {
+ $display_url = $media->node->display_url;
+ if(!in_array($display_url, $enclosures)) { // add only if not added yet
+ $content .= '<img src="' . htmlentities($display_url) . '" alt="' . $caption . '" />';
+ $enclosures[] = $display_url;
+ }
+ }
+
+ return [$content, $enclosures];
+
+ }
+
+ protected function getInstagramJSON($uri) {
+
+ $html = getContents($uri)
+ or returnServerError('Could not request Instagram.');
+ $scriptRegex = '/window\._sharedData = (.*);<\/script>/';
+
+ preg_match($scriptRegex, $html, $matches, PREG_OFFSET_CAPTURE, 0);
+
+ return json_decode($matches[1][0]);
+
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('u'))) {
+ return $this->getInput('u') . ' - Instagram Bridge';
+ }
+
+ return parent::getName();
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('u'))) {
+ return self::URI . urlencode($this->getInput('u')) . '/';
+ } elseif(!is_null($this->getInput('h'))) {
+ return self::URI . 'explore/tags/' . urlencode($this->getInput('h'));
+ } elseif(!is_null($this->getInput('l'))) {
+ return self::URI . 'explore/locations/' . urlencode($this->getInput('l'));
+ }
+ return parent::getURI();
+ }
+}
diff --git a/bridges/InstructablesBridge.php b/bridges/InstructablesBridge.php
new file mode 100644
index 0000000..05f1a91
--- /dev/null
+++ b/bridges/InstructablesBridge.php
@@ -0,0 +1,370 @@
+<?php
+/**
+* This class implements a bridge for http://www.instructables.com, supporting
+* general feeds and feeds by category. Instructables doesn't support HTTPS as
+* of now (23.06.2018), so all connections are insecure!
+*
+* Remarks:
+* - For some reason it is very important to have the category URI end with a
+* slash, otherwise the site defaults to the main category (i.e. Technology)!
+* If you need to update the categories list, enable the 'listCategories'
+* function (see comments below) and run the bridge with format=Html (see page
+* source)
+*/
+class InstructablesBridge extends BridgeAbstract {
+ const NAME = 'Instructables Bridge';
+ const URI = 'http://www.instructables.com';
+ const DESCRIPTION = 'Returns general feeds and feeds by category';
+ const MAINTAINER = 'logmanoriginal';
+ const PARAMETERS = array(
+ 'Category' => array(
+ 'category' => array(
+ 'name' => 'Category',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'Play' => array(
+ 'All' => '/play/',
+ 'KNEX' => '/play/knex/',
+ 'Offbeat' => '/play/offbeat/',
+ 'Lego' => '/play/lego/',
+ 'Airsoft' => '/play/airsoft/',
+ 'Card Games' => '/play/card-games/',
+ 'Guitars' => '/play/guitars/',
+ 'Instruments' => '/play/instruments/',
+ 'Magic Tricks' => '/play/magic-tricks/',
+ 'Minecraft' => '/play/minecraft/',
+ 'Music' => '/play/music/',
+ 'Nerf' => '/play/nerf/',
+ 'Nintendo' => '/play/nintendo/',
+ 'Office Supplies' => '/play/office-supplies/',
+ 'Paintball' => '/play/paintball/',
+ 'Paper Airplanes' => '/play/paper-airplanes/',
+ 'Party Tricks' => '/play/party-tricks/',
+ 'PlayStation' => '/play/playstation/',
+ 'Pranks and Humor' => '/play/pranks-and-humor/',
+ 'Puzzles' => '/play/puzzles/',
+ 'Siege Engines' => '/play/siege-engines/',
+ 'Sports' => '/play/sports/',
+ 'Table Top' => '/play/table-top/',
+ 'Toys' => '/play/toys/',
+ 'Video Games' => '/play/video-games/',
+ 'Wii' => '/play/wii/',
+ 'Xbox' => '/play/xbox/',
+ 'Yo-Yo' => '/play/yo-yo/',
+ ),
+ 'Craft' => array(
+ 'All' => '/craft/',
+ 'Art' => '/craft/art/',
+ 'Sewing' => '/craft/sewing/',
+ 'Paper' => '/craft/paper/',
+ 'Jewelry' => '/craft/jewelry/',
+ 'Fashion' => '/craft/fashion/',
+ 'Books & Journals' => '/craft/books-and-journals/',
+ 'Cards' => '/craft/cards/',
+ 'Clay' => '/craft/clay/',
+ 'Duct Tape' => '/craft/duct-tape/',
+ 'Embroidery' => '/craft/embroidery/',
+ 'Felt' => '/craft/felt/',
+ 'Fiber Arts' => '/craft/fiber-arts/',
+ 'Gifts & Wrapping' => '/craft/gifts-and-wrapping/',
+ 'Knitting & Crocheting' => '/craft/knitting-and-crocheting/',
+ 'Leather' => '/craft/leather/',
+ 'Mason Jars' => '/craft/mason-jars/',
+ 'No-Sew' => '/craft/no-sew/',
+ 'Parties & Weddings' => '/craft/parties-and-weddings/',
+ 'Print Making' => '/craft/print-making/',
+ 'Soap' => '/craft/soap/',
+ 'Wallets' => '/craft/wallets/',
+ ),
+ 'Technology' => array(
+ 'All' => '/technology/',
+ 'Electronics' => '/technology/electronics/',
+ 'Arduino' => '/technology/arduino/',
+ 'Photography' => '/technology/photography/',
+ 'Leds' => '/technology/leds/',
+ 'Science' => '/technology/science/',
+ 'Reuse' => '/technology/reuse/',
+ 'Apple' => '/technology/apple/',
+ 'Computers' => '/technology/computers/',
+ '3D Printing' => '/technology/3D-Printing/',
+ 'Robots' => '/technology/robots/',
+ 'Art' => '/technology/art/',
+ 'Assistive Tech' => '/technology/assistive-technology/',
+ 'Audio' => '/technology/audio/',
+ 'Clocks' => '/technology/clocks/',
+ 'CNC' => '/technology/cnc/',
+ 'Digital Graphics' => '/technology/digital-graphics/',
+ 'Gadgets' => '/technology/gadgets/',
+ 'Kits' => '/technology/kits/',
+ 'Laptops' => '/technology/laptops/',
+ 'Lasers' => '/technology/lasers/',
+ 'Linux' => '/technology/linux/',
+ 'Microcontrollers' => '/technology/microcontrollers/',
+ 'Microsoft' => '/technology/microsoft/',
+ 'Mobile' => '/technology/mobile/',
+ 'Raspberry Pi' => '/technology/raspberry-pi/',
+ 'Remote Control' => '/technology/remote-control/',
+ 'Sensors' => '/technology/sensors/',
+ 'Software' => '/technology/software/',
+ 'Soldering' => '/technology/soldering/',
+ 'Speakers' => '/technology/speakers/',
+ 'Steampunk' => '/technology/steampunk/',
+ 'Tools' => '/technology/tools/',
+ 'USB' => '/technology/usb/',
+ 'Wearables' => '/technology/wearables/',
+ 'Websites' => '/technology/websites/',
+ 'Wireless' => '/technology/wireless/',
+ ),
+ 'Workshop' => array(
+ 'All' => '/workshop/',
+ 'Woodworking' => '/workshop/woodworking/',
+ 'Tools' => '/workshop/tools/',
+ 'Gardening' => '/workshop/gardening/',
+ 'Cars' => '/workshop/cars/',
+ 'Metalworking' => '/workshop/metalworking/',
+ 'Cardboard' => '/workshop/cardboard/',
+ 'Electric Vehicles' => '/workshop/electric-vehicles/',
+ 'Energy' => '/workshop/energy/',
+ 'Furniture' => '/workshop/furniture/',
+ 'Home Improvement' => '/workshop/home-improvement/',
+ 'Home Theater' => '/workshop/home-theater/',
+ 'Hydroponics' => '/workshop/hydroponics/',
+ 'Laser Cutting' => '/workshop/laser-cutting/',
+ 'Lighting' => '/workshop/lighting/',
+ 'Molds & Casting' => '/workshop/molds-and-casting/',
+ 'Motorcycles' => '/workshop/motorcycles/',
+ 'Organizing' => '/workshop/organizing/',
+ 'Pallets' => '/workshop/pallets/',
+ 'Repair' => '/workshop/repair/',
+ 'Shelves' => '/workshop/shelves/',
+ 'Solar' => '/workshop/solar/',
+ 'Workbenches' => '/workshop/workbenches/',
+ ),
+ 'Home' => array(
+ 'All' => '/home/',
+ 'Halloween' => '/home/halloween/',
+ 'Decorating' => '/home/decorating/',
+ 'Organizing' => '/home/organizing/',
+ 'Pets' => '/home/pets/',
+ 'Life Hacks' => '/home/life-hacks/',
+ 'Beauty' => '/home/beauty/',
+ 'Christmas' => '/home/christmas/',
+ 'Cleaning' => '/home/cleaning/',
+ 'Education' => '/home/education/',
+ 'Finances' => '/home/finances/',
+ 'Gardening' => '/home/gardening/',
+ 'Green' => '/home/green/',
+ 'Health' => '/home/health/',
+ 'Hiding Places' => '/home/hiding-places/',
+ 'Holidays' => '/home/holidays/',
+ 'Homesteading' => '/home/homesteading/',
+ 'Kids' => '/home/kids/',
+ 'Kitchen' => '/home/kitchen/',
+ 'Life Skills' => '/home/life-skills/',
+ 'Parenting' => '/home/parenting/',
+ 'Pest Control' => '/home/pest-control/',
+ 'Relationships' => '/home/relationships/',
+ 'Reuse' => '/home/reuse/',
+ 'Travel' => '/home/travel/',
+ ),
+ 'Outside' => array(
+ 'All' => '/outside/',
+ 'Bikes' => '/outside/bikes/',
+ 'Survival' => '/outside/survival/',
+ 'Backyard' => '/outside/backyard/',
+ 'Beach' => '/outside/beach/',
+ 'Birding' => '/outside/birding/',
+ 'Boats' => '/outside/boats/',
+ 'Camping' => '/outside/camping/',
+ 'Climbing' => '/outside/climbing/',
+ 'Fire' => '/outside/fire/',
+ 'Fishing' => '/outside/fishing/',
+ 'Hunting' => '/outside/hunting/',
+ 'Kites' => '/outside/kites/',
+ 'Knives' => '/outside/knives/',
+ 'Knots' => '/outside/knots/',
+ 'Paracord' => '/outside/paracord/',
+ 'Rockets' => '/outside/rockets/',
+ 'Skateboarding' => '/outside/skateboarding/',
+ 'Snow' => '/outside/snow/',
+ 'Water' => '/outside/water/',
+ ),
+ 'Food' => array(
+ 'All' => '/food/',
+ 'Dessert' => '/food/dessert/',
+ 'Snacks & Appetizers' => '/food/snacks-and-appetizers/',
+ 'Bacon' => '/food/bacon/',
+ 'BBQ & Grilling' => '/food/bbq-and-grilling/',
+ 'Beverages' => '/food/beverages/',
+ 'Bread' => '/food/bread/',
+ 'Breakfast' => '/food/breakfast/',
+ 'Cake' => '/food/cake/',
+ 'Candy' => '/food/candy/',
+ 'Canning & Preserves' => '/food/canning-and-preserves/',
+ 'Cocktails & Mocktails' => '/food/cocktails-and-mocktails/',
+ 'Coffee' => '/food/coffee/',
+ 'Cookies' => '/food/cookies/',
+ 'Cupcakes' => '/food/cupcakes/',
+ 'Homebrew' => '/food/homebrew/',
+ 'Main Course' => '/food/main-course/',
+ 'Pasta' => '/food/pasta/',
+ 'Pie' => '/food/pie/',
+ 'Pizza' => '/food/pizza/',
+ 'Salad' => '/food/salad/',
+ 'Sandwiches' => '/food/sandwiches/',
+ 'Soups & Stews' => '/food/soups-and-stews/',
+ 'Vegetarian & Vegan' => '/food/vegetarian-and-vegan/',
+ ),
+ 'Costumes' => array(
+ 'All' => '/costumes/',
+ 'Props' => '/costumes/props-and-accessories/',
+ 'Animals' => '/costumes/animals/',
+ 'Comics' => '/costumes/comics/',
+ 'Fantasy' => '/costumes/fantasy/',
+ 'For Kids' => '/costumes/for-kids/',
+ 'For Pets' => '/costumes/for-pets/',
+ 'Funny' => '/costumes/funny/',
+ 'Games' => '/costumes/games/',
+ 'Historic & Futuristic' => '/costumes/historic-and-futuristic/',
+ 'Makeup' => '/costumes/makeup/',
+ 'Masks' => '/costumes/masks/',
+ 'Scary' => '/costumes/scary/',
+ 'TV & Movies' => '/costumes/tv-and-movies/',
+ 'Weapons & Armor' => '/costumes/weapons-and-armor/',
+ )
+ ),
+ 'title' => 'Select your category (required)',
+ 'defaultValue' => 'Technology'
+ ),
+ 'filter' => array(
+ 'name' => 'Filter',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'Featured' => ' ',
+ 'Recent' => 'recent/',
+ 'Popular' => 'popular/',
+ 'Views' => 'views/',
+ 'Contest Winners' => 'winners/'
+ ),
+ 'title' => 'Select a filter',
+ 'defaultValue' => 'Featured'
+ )
+ )
+ );
+
+ private $uri;
+
+ public function collectData() {
+ // Enable the following line to get the category list (dev mode)
+ // $this->listCategories();
+
+ $this->uri = static::URI;
+
+ switch($this->queriedContext) {
+ case 'Category': $this->uri .= $this->getInput('category') . $this->getInput('filter');
+ }
+
+ $html = getSimpleHTMLDOM($this->uri)
+ or returnServerError('Error loading category ' . $this->uri);
+
+ foreach($html->find('ul.explore-covers-list li') as $cover) {
+ $item = array();
+
+ $item['uri'] = static::URI . $cover->find('a.cover-image', 0)->href;
+ $item['title'] = $cover->find('.title', 0)->innertext;
+ $item['author'] = $this->getCategoryAuthor($cover);
+ $item['content'] = '<a href='
+ . $item['uri']
+ . '><img src='
+ . $cover->find('a.cover-image img', 0)->src
+ . '></a>';
+
+ $image = str_replace('.RECTANGLE1', '.LARGE', $cover->find('a.cover-image img', 0)->src);
+ $item['enclosures'] = [$image];
+
+ $this->items[] = $item;
+ }
+ }
+
+ public function getName() {
+ if(!is_null($this->getInput('category'))
+ && !is_null($this->getInput('filter'))) {
+ foreach(self::PARAMETERS[$this->queriedContext]['category']['values'] as $key => $value) {
+ $subcategory = array_search($this->getInput('category'), $value);
+
+ if($subcategory !== false)
+ break;
+ }
+
+ $filter = array_search(
+ $this->getInput('filter'),
+ self::PARAMETERS[$this->queriedContext]['filter']['values']
+ );
+
+ return $subcategory . ' (' . $filter . ') - ' . static::NAME;
+ }
+
+ return parent::getName();
+ }
+
+ public function getURI() {
+ if(!is_null($this->getInput('category'))
+ && !is_null($this->getInput('filter'))) {
+ return $this->uri;
+ }
+
+ return parent::getURI();
+ }
+
+ /**
+ * Returns a list of categories for development purposes (used to build the
+ * parameters list)
+ */
+ private function listCategories(){
+ // Use arbitrary category to receive full list
+ $html = getSimpleHTMLDOM(self::URI . '/technology/');
+
+ foreach($html->find('.channel a') as $channel) {
+ $name = html_entity_decode(trim($channel->innertext));
+
+ // Remove unwanted entities
+ $name = str_replace("'", '', $name);
+ $name = str_replace('&#39;', '', $name);
+
+ $uri = $channel->href;
+
+ $category = explode('/', $uri)[1];
+
+ if(!isset($categories)
+ || !array_key_exists($category, $categories)
+ || !in_array($uri, $categories[$category]))
+ $categories[$category][$name] = $uri;
+ }
+
+ // Build PHP array manually
+ foreach($categories as $key => $value) {
+ $name = ucfirst($key);
+ echo "'{$name}' => array(\n";
+ echo "\t'All' => '/{$key}/',\n";
+ foreach($value as $name => $uri) {
+ echo "\t'{$name}' => '{$uri}',\n";
+ }
+ echo "),\n";
+ }
+
+ die;
+ }
+
+ /**
+ * Returns the author as anchor for a given cover.
+ */
+ private function getCategoryAuthor($cover) {
+ return '<a href='
+ . static::URI . $cover->find('span.author a', 0)->href
+ . '>'
+ . $cover->find('span.author a', 0)->innertext
+ . '</a>';
+ }
+}
diff --git a/bridges/JapanExpoBridge.php b/bridges/JapanExpoBridge.php
new file mode 100644
index 0000000..1790171
--- /dev/null
+++ b/bridges/JapanExpoBridge.php
@@ -0,0 +1,106 @@
+<?php
+class JapanExpoBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Ginko';
+ const NAME = 'Japan Expo Actualités';
+ const URI = 'https://www.japan-expo-paris.com/fr/actualites';
+ const CACHE_TIMEOUT = 14400; // 4h
+ const DESCRIPTION = 'Returns most recent entries from Japan Expo actualités.';
+ const PARAMETERS = array( array(
+ 'mode' => array(
+ 'name' => 'Show full contents',
+ 'type' => 'checkbox',
+ )
+ ));
+
+ public function getIcon() {
+ return 'https://s.japan-expo.com/katana/images/JES073/favicons/paris.png';
+ }
+
+ public function collectData(){
+
+ function frenchPubDateToTimestamp($date_to_parse) {
+ return strtotime(
+ strtr(
+ strtolower(str_replace('Publié le ', '', $date_to_parse)),
+ array(
+ 'janvier' => 'jan',
+ 'février' => 'feb',
+ 'mars' => 'march',
+ 'avril' => 'apr',
+ 'mai' => 'may',
+ 'juin' => 'jun',
+ 'juillet' => 'jul',
+ 'août' => 'aug',
+ 'septembre' => 'sep',
+ 'octobre' => 'oct',
+ 'novembre' => 'nov',
+ 'décembre' => 'dec'
+ )
+ )
+ );
+ }
+
+ $convert_article_images = function($matches){
+ if(is_array($matches) && count($matches) > 1) {
+ return '<img src="' . $matches[1] . '" />';
+ }
+ };
+
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request JapanExpo: ' . self::URI);
+ $fullcontent = $this->getInput('mode');
+ $count = 0;
+
+ foreach($html->find('a._tile2') as $element) {
+
+ $url = $element->href;
+ $thumbnail = 'https://s.japan-expo.com/katana/images/JES049/paris.png';
+ preg_match('/url\(([^)]+)\)/', $element->find('img.rspvimgset', 0)->style, $img_search_result);
+
+ if(count($img_search_result) >= 2)
+ $thumbnail = trim($img_search_result[1], "'");
+
+ if($fullcontent) {
+ if($count >= 5) {
+ break;
+ }
+
+ $article_html = getSimpleHTMLDOMCached($url)
+ or returnServerError('Could not request JapanExpo: ' . $url);
+ $header = $article_html->find('header.pageHeadBox', 0);
+ $timestamp = strtotime($header->find('time', 0)->datetime);
+ $title_html = $header->find('div.section', 0)->next_sibling();
+ $title = $title_html->plaintext;
+ $headings = $title_html->next_sibling()->outertext;
+ $article = $article_html->find('div.content', 0)->innertext;
+ $article = preg_replace_callback(
+ '/<img [^>]+ style="[^\(]+\(\'([^\']+)\'[^>]+>/i',
+ $convert_article_images,
+ $article);
+
+ $content = $headings . $article;
+ } else {
+ $date_text = $element->find('span.date', 0)->plaintext;
+ $timestamp = frenchPubDateToTimestamp($date_text);
+ $title = trim($element->find('span._title', 0)->plaintext);
+ $content = '<img src="'
+ . $thumbnail
+ . '"></img><br />'
+ . $date_text
+ . '<br /><a href="'
+ . $url
+ . '">Lire l\'article</a>';
+ }
+
+ $item = array();
+ $item['uri'] = $url;
+ $item['title'] = $title;
+ $item['timestamp'] = $timestamp;
+ $item['enclosures'] = array($thumbnail);
+ $item['content'] = $content;
+ $this->items[] = $item;
+ $count++;
+ }
+ }
+}
diff --git a/bridges/JustETFBridge.php b/bridges/JustETFBridge.php
new file mode 100644
index 0000000..85318b8
--- /dev/null
+++ b/bridges/JustETFBridge.php
@@ -0,0 +1,352 @@
+<?php
+class JustETFBridge extends BridgeAbstract {
+ const NAME = 'justETF Bridge';
+ const URI = 'https://www.justetf.com';
+ const DESCRIPTION = 'Currently only supports the news feed';
+ const MAINTAINER = 'logmanoriginal';
+ const PARAMETERS = array(
+ 'News' => array(
+ 'full' => array(
+ 'name' => 'Full Article',
+ 'type' => 'checkbox',
+ 'title' => 'Enable to load full articles'
+ )
+ ),
+ 'Profile' => array(
+ 'isin' => array(
+ 'name' => 'ISIN',
+ 'type' => 'text',
+ 'required' => true,
+ 'pattern' => '[a-zA-Z]{2}[a-zA-Z0-9]{10}',
+ 'title' => 'ISIN, consisting of 2-letter country code, 9-character identifier, check character'
+ ),
+ 'strategy' => array(
+ 'name' => 'Include Strategy',
+ 'type' => 'checkbox',
+ 'defaultValue' => 'checked'
+ ),
+ 'description' => array(
+ 'name' => 'Include Description',
+ 'type' => 'checkbox',
+ 'defaultValue' => 'checked'
+ )
+ ),
+ 'global' => array(
+ 'lang' => array(
+ 'name' => 'Language',
+ 'required' => true,
+ 'type' => 'list',
+ 'values' => array(
+ 'Englisch' => 'en',
+ 'Deutsch' => 'de',
+ 'Italiano' => 'it'
+ ),
+ 'defaultValue' => 'Englisch'
+ )
+ )
+ );
+
+ public function collectData() {
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Failed loading contents from ' . $this->getURI());
+
+ defaultLinkTo($html, static::URI);
+
+ switch($this->queriedContext) {
+ case 'News':
+ $this->collectNews($html);
+ break;
+ case 'Profile':
+ $this->collectProfile($html);
+ break;
+ }
+ }
+
+ public function getURI() {
+ $uri = static::URI;
+
+ if($this->getInput('lang')) {
+ $uri .= '/' . $this->getInput('lang');
+ }
+
+ switch($this->queriedContext) {
+ case 'News':
+ $uri .= '/news';
+ break;
+ case 'Profile':
+ $uri .= '/etf-profile.html?' . http_build_query(array(
+ 'isin' => strtoupper($this->getInput('isin'))
+ ));
+ break;
+ }
+
+ return $uri;
+ }
+
+ public function getName() {
+ $name = static::NAME;
+
+ $name .= ($this->queriedContext) ? ' - ' . $this->queriedContext : '';
+
+ switch($this->queriedContext) {
+ case 'News': break;
+ case 'Profile':
+ if($this->getInput('isin')) {
+ $name .= ' ISIN ' . strtoupper($this->getInput('isin'));
+ }
+ }
+
+ if($this->getInput('lang')) {
+ $name .= ' (' . strtoupper($this->getInput('lang')) . ')';
+ }
+
+ return $name;
+ }
+
+ #region Common
+
+ /**
+ * Fixes dates depending on the choosen language:
+ *
+ * de : dd.mm.yy
+ * en : dd.mm.yy
+ * it : dd/mm/yy
+ *
+ * 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) {
+ switch($this->getInput('lang')) {
+ case 'en':
+ case 'de':
+ $df = date_create_from_format('d.m.y', $date);
+ break;
+ case 'it':
+ $df = date_create_from_format('d/m/y', $date);
+ break;
+ }
+
+ date_time_set($df, 0, 0);
+
+ // Debug::log(date_format($df, 'U'));
+
+ return date_format($df, 'U');
+ }
+
+ private function extractImages($article) {
+ // Notice: We can have zero or more images (though it should mostly be 1)
+ $elements = $article->find('img');
+
+ $images = array();
+
+ foreach($elements as $img) {
+ // Skip the logo (mostly provided part of a hidden div)
+ if(substr($img->src, strrpos($img->src, '/') + 1) === 'logo.png')
+ continue;
+
+ $images[] = $img->src;
+ }
+
+ return $images;
+ }
+
+ #endregion
+
+ #region News
+
+ private function collectNews($html) {
+ $articles = $html->find('div.newsTopArticle')
+ or returnServerError('No articles found! Layout might have changed!');
+
+ foreach($articles as $article) {
+
+ $item = array();
+
+ // Common data
+
+ $item['uri'] = $this->extractNewsUri($article);
+ $item['timestamp'] = $this->extractNewsDate($article);
+ $item['title'] = $this->extractNewsTitle($article);
+
+ if($this->getInput('full')) {
+
+ $uri = $this->extractNewsUri($article);
+
+ $html = getSimpleHTMLDOMCached($uri)
+ or returnServerError('Failed loading full article from ' . $uri);
+
+ $fullArticle = $html->find('div.article', 0)
+ or returnServerError('No content found! Layout might have changed!');
+
+ defaultLinkTo($fullArticle, static::URI);
+
+ $item['author'] = $this->extractFullArticleAuthor($fullArticle);
+ $item['content'] = $this->extractFullArticleContent($fullArticle);
+ $item['enclosures'] = $this->extractImages($fullArticle);
+
+ } else {
+
+ $item['content'] = $this->extractNewsDescription($article);
+ $item['enclosures'] = $this->extractImages($article);
+
+ }
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function extractNewsUri($article) {
+ $element = $article->find('a', 0)
+ or returnServerError('Anchor not found!');
+
+ return $element->href;
+ }
+
+ private function extractNewsDate($article) {
+ $element = $article->find('div.subheadline', 0)
+ or returnServerError('Date not found!');
+
+ // Debug::log($element->plaintext);
+
+ $date = trim(explode('|', $element->plaintext)[0]);
+
+ return $this->fixDate($date);
+ }
+
+ private function extractNewsDescription($article) {
+ $element = $article->find('span.newsText', 0)
+ or returnServerError('Description not found!');
+
+ $element->find('a', 0)->onclick = '';
+
+ // Debug::log($element->innertext);
+
+ return $element->innertext;
+ }
+
+ private function extractNewsTitle($article) {
+ $element = $article->find('h3', 0)
+ or returnServerError('Title not found!');
+
+ return $element->plaintext;
+ }
+
+ private function extractFullArticleContent($article) {
+ $element = $article->find('div.article_body', 0)
+ or returnServerError('Article body not found!');
+
+ // Remove teaser image
+ $element->find('img.teaser-img', 0)->outertext = '';
+
+ // Remove self advertisements
+ foreach($element->find('.call-action') as $adv) {
+ $adv->outertext = '';
+ }
+
+ // Remove tips
+ foreach($element->find('.panel-edu') as $tip) {
+ $tip->outertext = '';
+ }
+
+ // Remove inline scripts (used for i.e. interactive graphs) as they are
+ // rendered as a long series of strings
+ foreach($element->find('script') as $script) {
+ $script->outertext = '[Content removed! Visit site to see full contents!]';
+ }
+
+ return $element->innertext;
+ }
+
+ private function extractFullArticleAuthor($article) {
+ $element = $article->find('span[itemprop=name]', 0)
+ or returnServerError('Author not found!');
+
+ return $element->plaintext;
+ }
+
+ #endregion
+
+ #region Profile
+
+ private function collectProfile($html) {
+ $item = array();
+
+ $item['uri'] = $this->getURI();
+ $item['timestamp'] = $this->extractProfileDate($html);
+ $item['title'] = $this->extractProfiletitle($html);
+ $item['author'] = $this->extractProfileAuthor($html);
+ $item['content'] = $this->extractProfileContent($html);
+
+ $this->items[] = $item;
+ }
+
+ private function extractProfileDate($html) {
+ $element = $html->find('div.infobox div.vallabel', 0)
+ or returnServerError('Date not found!');
+
+ // Debug::log($element->plaintext);
+
+ $date = trim(explode("\r\n", $element->plaintext)[1]);
+
+ return $this->fixDate($date);
+ }
+
+ private function extractProfileTitle($html) {
+ $element = $html->find('span.h1', 0)
+ or returnServerError('Title not found!');
+
+ return $element->plaintext;
+ }
+
+ private function extractProfileContent($html) {
+ // There are a few thins we are interested:
+ // - Investment Strategy
+ // - Description
+ // - Quote
+
+ $strategy = $html->find('div.tab-container div.col-sm-6 p', 0)
+ or returnServerError('Investment Strategy not found!');
+
+ // Description requires a bit of cleanup due to lack of propper identification
+
+ $description = $html->find('div.headline', 5)
+ or returnServerError('Description container not found!');
+
+ $description = $description->parent();
+
+ foreach($description->find('div') as $div) {
+ $div->outertext = '';
+ }
+
+ $quote = $html->find('div.infobox div.val', 0)
+ or returnServerError('Quote not found!');
+
+ $quote_html = '<strong>Quote</strong><br><p>' . $quote . '</p>';
+ $strategy_html = '';
+ $description_html = '';
+
+ if($this->getInput('strategy') === true) {
+ $strategy_html = '<strong>Strategy</strong><br><p>' . $strategy . '</p><br>';
+ }
+
+ if($this->getInput('description') === true) {
+ $description_html = '<strong>Description</strong><br><p>' . $description . '</p><br>';
+ }
+
+ return $strategy_html . $description_html . $quote_html;
+ }
+
+ private function extractProfileAuthor($html) {
+ // Use ISIN + WKN as author
+ // Notice: "identfier" is not a typo [sic]!
+ $element = $html->find('span.identfier', 0)
+ or returnServerError('Author not found!');
+
+ return $element->plaintext;
+ }
+ #endregion
+}
diff --git a/bridges/KATBridge.php b/bridges/KATBridge.php
new file mode 100644
index 0000000..7e312a1
--- /dev/null
+++ b/bridges/KATBridge.php
@@ -0,0 +1,129 @@
+<?php
+class KATBridge extends BridgeAbstract {
+ const MAINTAINER = 'niawag';
+ const NAME = 'KickassTorrents';
+ const URI = 'https://katcr.co/new/';
+ const DESCRIPTION = 'Returns results for the keywords. You can put several
+ list of keywords by separating them with a semicolon (e.g. "one show;another
+ show"). Category based search needs the category number as input. User based
+ search takes the Uploader ID: see KAT URL for user feed. Search can be done in a specified category';
+
+ const PARAMETERS = array( array(
+ 'q' => array(
+ 'name' => 'keywords, separated by semicolons',
+ 'exampleValue' => 'first list;second list;…',
+ 'required' => true
+ ),
+ 'crit' => array(
+ 'type' => 'list',
+ 'name' => 'Search type',
+ 'values' => array(
+ 'search' => 'search',
+ 'category' => 'cat',
+ 'user' => 'usr'
+ )
+ ),
+ 'cat_check' => array(
+ 'type' => 'checkbox',
+ 'name' => 'Specify category for normal search ?',
+ ),
+ 'cat' => array(
+ 'name' => 'Category number',
+ 'exampleValue' => '100, 200… See KAT for category number'
+ ),
+ 'trusted' => array(
+ 'type' => 'checkbox',
+ 'name' => 'Only get results from Elite or Verified uploaders ?',
+ ),
+ ));
+
+ public function getIcon() {
+ return 'https://statuskatcrco-631f.kxcdn.com/assets/images/favicon.ico';
+ }
+
+ public function collectData(){
+ function parseDateTimestamp($element){
+ $guessedDate = strptime($element, '%d-%m-%Y %H:%M:%S');
+ $timestamp = mktime(
+ $guessedDate['tm_hour'],
+ $guessedDate['tm_min'],
+ $guessedDate['tm_sec'],
+ $guessedDate['tm_mon'] + 1,
+ $guessedDate['tm_mday'],
+ $guessedDate['tm_year'] + 1900);
+ return $timestamp;
+ }
+
+ $catBool = $this->getInput('cat_check');
+ if($catBool) {
+ $catNum = $this->getInput('cat');
+ }
+ $critList = $this->getInput('crit');
+ $trustedBool = $this->getInput('trusted');
+ $keywordsList = explode(';', $this->getInput('q'));
+ foreach($keywordsList as $keywords) {
+ switch($critList) {
+ case 'search':
+ if($catBool == false) {
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'torrents-search.php?search=' .
+ rawurlencode($keywords)
+ ) or returnServerError('Could not request KAT.');
+ } else {
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'torrents-search.php?search=' .
+ rawurlencode($keywords) .
+ '&cat=' .
+ rawurlencode($catNum)
+ ) or returnServerError('Could not request KAT.');
+ }
+ break;
+ case 'cat':
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'torrents.php?cat=' .
+ rawurlencode($keywords)
+ ) or returnServerError('Could not request KAT.');
+ break;
+ case 'usr':
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'account-details.php?id=' .
+ rawurlencode($keywords)
+ ) or returnServerError('Could not request KAT.');
+ break;
+ }
+ if ($html->find('table.ttable_headinner', 0) == false)
+ returnServerError('No result for query ' . $keywords);
+ foreach($html->find('tr.t-row') as $element) {
+ if(!$trustedBool
+ || !is_null($element->find('i[title="Elite Uploader"]', 0))
+ || !is_null($element->find('i[title="Verified Uploader"]', 0))) {
+ $item = array();
+ $item['uri'] = self::URI . $element->find('a', 2)->href;
+ $item['id'] = self::URI . $element->find('a.cellMainLink', 0)->href;
+ $item['timestamp'] = parseDateTimestamp($element->find('td', 2)->plaintext);
+ $item['author'] = $element->find('a.plain', 0)->plaintext;
+ $item['title'] = $element->find('a.cellMainLink', 0)->plaintext;
+ $item['seeders'] = (int)$element->find('td', 3)->plaintext;
+ $item['leechers'] = (int)$element->find('td', 4)->plaintext;
+ $item['size'] = $element->find('td', 1)->plaintext;
+ $item['content'] = $item['title']
+ . '<br>size: '
+ . $item['size']
+ . '<br>seeders: '
+ . $item['seeders']
+ . ' | leechers: '
+ . $item['leechers']
+ . '<br><a href="'
+ . $item['id']
+ . '">info page</a>';
+ if(isset($item['title']))
+ $this->items[] = $item;
+ }
+ }
+ }
+ }
+}
diff --git a/bridges/KernelBugTrackerBridge.php b/bridges/KernelBugTrackerBridge.php
new file mode 100644
index 0000000..d617b80
--- /dev/null
+++ b/bridges/KernelBugTrackerBridge.php
@@ -0,0 +1,153 @@
+<?php
+class KernelBugTrackerBridge extends BridgeAbstract {
+
+ const NAME = 'Kernel Bug Tracker';
+ const URI = 'https://bugzilla.kernel.org';
+ const DESCRIPTION = 'Returns feeds for bug comments';
+ const MAINTAINER = 'logmanoriginal';
+ const PARAMETERS = array(
+ 'Bug comments' => array(
+ 'id' => array(
+ 'name' => 'Bug tracking ID',
+ 'type' => 'number',
+ 'required' => true,
+ 'title' => 'Insert bug tracking ID',
+ 'exampleValue' => 121241
+ ),
+ 'limit' => array(
+ 'name' => 'Number of comments to return',
+ 'type' => 'number',
+ 'required' => false,
+ 'title' => 'Specify number of comments to return',
+ 'defaultValue' => -1
+ ),
+ 'sorting' => array(
+ 'name' => 'Sorting',
+ 'type' => 'list',
+ 'required' => false,
+ 'title' => 'Defines the sorting order of the comments returned',
+ 'defaultValue' => 'of',
+ 'values' => array(
+ 'Oldest first' => 'of',
+ 'Latest first' => 'lf'
+ )
+ )
+ )
+ );
+
+ private $bugid = '';
+ private $bugdesc = '';
+
+ public function getIcon() {
+ return self::URI . '/images/favicon.ico';
+ }
+
+ public function collectData(){
+ $limit = $this->getInput('limit');
+ $sorting = $this->getInput('sorting');
+
+ // We use the print preview page for simplicity
+ $html = getSimpleHTMLDOMCached($this->getURI() . '&format=multiple',
+ 86400,
+ null,
+ null,
+ true,
+ true,
+ DEFAULT_TARGET_CHARSET,
+ false, // Do NOT remove line breaks
+ DEFAULT_BR_TEXT,
+ DEFAULT_SPAN_TEXT);
+
+ if($html === false)
+ returnServerError('Failed to load page!');
+
+ // Store header information into private members
+ $this->bugid = $html->find('#bugzilla-body', 0)->find('a', 0)->innertext;
+ $this->bugdesc = $html->find('table.bugfields', 0)->find('tr', 0)->find('td', 0)->innertext;
+
+ // Get and limit comments
+ $comments = $html->find('div.bz_comment');
+
+ if($limit > 0 && count($comments) > $limit) {
+ $comments = array_slice($comments, count($comments) - $limit, $limit);
+ }
+
+ // Order comments
+ switch($sorting) {
+ case 'lf': $comments = array_reverse($comments, true);
+ case 'of':
+ default: // Nothing to do, keep original order
+ }
+
+ foreach($comments as $comment) {
+ $comment = $this->inlineStyles($comment);
+
+ $item = array();
+ $item['uri'] = $this->getURI() . '#' . $comment->id;
+ $item['author'] = $comment->find('span.bz_comment_user', 0)->innertext;
+ $item['title'] = $comment->find('span.bz_comment_number', 0)->find('a', 0)->innertext;
+ $item['timestamp'] = strtotime($comment->find('span.bz_comment_time', 0)->innertext);
+ $item['content'] = $comment->find('pre.bz_comment_text', 0)->innertext;
+
+ // Fix line breaks (they use LF)
+ $item['content'] = str_replace("\n", '<br>', $item['content']);
+
+ // Fix relative URIs
+ $item['content'] = $this->replaceRelativeURI($item['content']);
+
+ $this->items[] = $item;
+ }
+
+ }
+
+ public function getURI(){
+ switch($this->queriedContext) {
+ case 'Bug comments':
+ return parent::getURI()
+ . '/show_bug.cgi?id='
+ . $this->getInput('id');
+ break;
+ default: return parent::getURI();
+ }
+ }
+
+ public function getName(){
+ switch($this->queriedContext) {
+ case 'Bug comments':
+ return 'Bug '
+ . $this->bugid
+ . ' tracker for '
+ . $this->bugdesc
+ . ' - '
+ . parent::getName();
+ break;
+ default: return parent::getName();
+ }
+ }
+
+ /**
+ * Replaces all relative URIs with absolute ones
+ *
+ * @param string $content The source string
+ * @return string Returns the source string with all relative URIs replaced
+ * by absolute ones.
+ */
+ private function replaceRelativeURI($content){
+ return preg_replace('/href="(?!http)/', 'href="' . self::URI . '/', $content);
+ }
+
+ /**
+ * Adds styles as attributes to tags with known classes
+ *
+ * @param object $html A simplehtmldom object
+ * @return object Returns the original object with styles added as
+ * attributes.
+ */
+ private function inlineStyles($html){
+ foreach($html->find('.bz_obsolete') as $element) {
+ $element->style = 'text-decoration:line-through;';
+ }
+
+ return $html;
+ }
+}
diff --git a/bridges/KonachanBridge.php b/bridges/KonachanBridge.php
new file mode 100644
index 0000000..4250e8b
--- /dev/null
+++ b/bridges/KonachanBridge.php
@@ -0,0 +1,11 @@
+<?php
+require_once('MoebooruBridge.php');
+
+class KonachanBridge extends MoebooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Konachan';
+ const URI = 'http://konachan.com/';
+ const DESCRIPTION = 'Returns images from given page';
+
+}
diff --git a/bridges/KoreusBridge.php b/bridges/KoreusBridge.php
new file mode 100644
index 0000000..a5e09cb
--- /dev/null
+++ b/bridges/KoreusBridge.php
@@ -0,0 +1,22 @@
+<?php
+class KoreusBridge extends FeedExpander {
+
+ const MAINTAINER = 'pit-fgfjiudghdf';
+ const NAME = 'Koreus';
+ const URI = 'http://www.koreus.com/';
+ const DESCRIPTION = 'Returns the newest posts from Koreus (full text)';
+
+ protected function parseItem($item){
+ $item = parent::parseItem($item);
+
+ $html = getSimpleHTMLDOMCached($item['uri']);
+ $text = $html->find('p.itemText', 0)->innertext;
+ $item['content'] = utf8_encode($text);
+
+ return $item;
+ }
+
+ public function collectData(){
+ $this->collectExpandableDatas('http://feeds.feedburner.com/Koreus-articles');
+ }
+}
diff --git a/bridges/KununuBridge.php b/bridges/KununuBridge.php
new file mode 100644
index 0000000..2f4bf0b
--- /dev/null
+++ b/bridges/KununuBridge.php
@@ -0,0 +1,200 @@
+<?php
+class KununuBridge extends BridgeAbstract {
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'Kununu Bridge';
+ const URI = 'https://www.kununu.com/';
+ const CACHE_TIMEOUT = 86400; // 24h
+ const DESCRIPTION = 'Returns the latest reviews for a company and site of your choice.';
+
+ const PARAMETERS = array(
+ 'global' => array(
+ 'site' => array(
+ 'name' => 'Site',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Select your site',
+ 'values' => array(
+ 'Austria' => 'at',
+ 'Germany' => 'de',
+ 'Switzerland' => 'ch',
+ 'United States' => 'us'
+ )
+ ),
+ 'full' => array(
+ 'name' => 'Load full article',
+ 'type' => 'checkbox',
+ 'required' => false,
+ 'exampleValue' => 'checked',
+ 'title' => 'Activate to load full article'
+ )
+ ),
+ array(
+ 'company' => array(
+ 'name' => 'Company',
+ 'required' => true,
+ 'exampleValue' => 'kununu-us',
+ 'title' => 'Insert company name (i.e. Kununu US) or URI path (i.e. kununu-us)'
+ )
+ )
+ );
+
+ private $companyName = '';
+
+ public function getURI(){
+ if(!is_null($this->getInput('company')) && !is_null($this->getInput('site'))) {
+
+ $company = $this->fixCompanyName($this->getInput('company'));
+ $site = $this->getInput('site');
+ $section = '';
+
+ switch($site) {
+ case 'at':
+ case 'de':
+ case 'ch':
+ $section = 'kommentare';
+ break;
+ case 'us':
+ $section = 'reviews';
+ break;
+ }
+
+ return self::URI . $site . '/' . $company . '/' . $section . '?sort=update_time_desc';
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('company'))) {
+ $company = $this->fixCompanyName($this->getInput('company'));
+ return ($this->companyName ?: $company) . ' - ' . self::NAME;
+ }
+
+ return parent::getName();
+ }
+
+ public function getIcon() {
+ return 'https://www.kununu.com/favicon-196x196.png';
+ }
+
+ public function collectData(){
+ $full = $this->getInput('full');
+
+ // Load page
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Unable to receive data from ' . $this->getURI() . '!');
+
+ $html = defaultLinkTo($html, static::URI);
+
+ // Update name for this request
+ $company = $html->find('span[class="company-name"]', 0)
+ or returnServerError('Cannot find company name!');
+
+ $this->companyName = $company->innertext;
+
+ // Find the section with all the panels (reviews)
+ $section = $html->find('section.kununu-scroll-element', 0)
+ or returnServerError('Unable to find panel section!');
+
+ // Find all articles (within the panels)
+ $articles = $section->find('article')
+ or returnServerError('Unable to find articles!');
+
+ // Go through all articles
+ foreach($articles as $article) {
+
+ $anchor = $article->find('h1.review-title a', 0)
+ or returnServerError('Cannot find article URI!');
+
+ $date = $article->find('meta[itemprop=dateCreated]', 0)
+ or returnServerError('Cannot find article date!');
+
+ $rating = $article->find('span.rating', 0)
+ or returnServerError('Cannot find article rating!');
+
+ $summary = $article->find('[itemprop=name]', 0)
+ or returnServerError('Cannot find article summary!');
+
+ $item = array();
+
+ $item['author'] = $this->extractArticleAuthorPosition($article);
+ $item['timestamp'] = strtotime($date);
+ $item['title'] = $rating->getAttribute('aria-label')
+ . ' : '
+ . strip_tags($summary->innertext);
+
+ $item['uri'] = $anchor->href;
+
+ if($full) {
+ $item['content'] = $this->extractFullDescription($item['uri']);
+ } else {
+ $item['content'] = $this->extractArticleDescription($article);
+ }
+
+ $this->items[] = $item;
+
+ }
+ }
+
+ /*
+ * Returns a fixed version of the provided company name
+ */
+ private function fixCompanyName($company){
+ $company = trim($company);
+ $company = str_replace(' ', '-', $company);
+ $company = strtolower($company);
+
+ $umlauts = Array('/ä/','/ö/','/ü/','/Ä/','/Ö/','/Ü/','/ß/');
+ $replace = Array('ae','oe','ue','Ae','Oe','Ue','ss');
+
+ return preg_replace($umlauts, $replace, $company);
+ }
+
+ /**
+ * Returns the position of the author from a given article
+ */
+ private function extractArticleAuthorPosition($article){
+ // We need to parse the user-content manually
+ $user_content = $article->find('div.user-content', 0)
+ or returnServerError('Cannot find user content!');
+
+ // Go through all h2 elements to find index of required span (I know... it's stupid)
+ $author_position = 'Unknown';
+ foreach($user_content->find('div') as $content) {
+ if(stristr(strtolower($content->plaintext), 'position')) { /* This works for at, ch, de, us */
+ $author_position = $content->next_sibling()->plaintext;
+ break;
+ }
+ }
+
+ return $author_position;
+ }
+
+ /**
+ * Returns the description from a given article
+ */
+ private function extractArticleDescription($article){
+ $description = $article->find('[itemprop=reviewBody]', 0)
+ or returnServerError('Cannot find article description!');
+
+ return $description->innertext;
+ }
+
+ /**
+ * Returns the full description from a given uri
+ */
+ private function extractFullDescription($uri){
+ // Load full article
+ $html = getSimpleHTMLDOMCached($uri)
+ or returnServerError('Could not load full description!');
+
+ $html = defaultLinkTo($html, static::URI);
+
+ // Find the article
+ $article = $html->find('article', 0)
+ or returnServerError('Cannot find article!');
+
+ // Luckily they use the same layout for the review overview and full article pages :)
+ return $this->extractArticleDescription($article);
+ }
+}
diff --git a/bridges/LWNprevBridge.php b/bridges/LWNprevBridge.php
new file mode 100644
index 0000000..baa30c9
--- /dev/null
+++ b/bridges/LWNprevBridge.php
@@ -0,0 +1,265 @@
+<?php
+class LWNprevBridge extends BridgeAbstract{
+ const MAINTAINER = 'Pierre Mazière';
+ const NAME = 'LWN Free Weekly Edition';
+ const URI = 'https://lwn.net/';
+ const CACHE_TIMEOUT = 604800; // 1 week
+ const DESCRIPTION = 'LWN Free Weekly Edition available one week late';
+
+ private $editionTimeStamp;
+
+ function getURI(){
+ return self::URI . 'free/bigpage';
+ }
+
+ private function jumpToNextTag(&$node){
+ while($node && $node->nodeType === XML_TEXT_NODE) {
+ $nextNode = $node->nextSibling;
+ if(!$nextNode) {
+ break;
+ }
+ $node = $nextNode;
+ }
+ }
+
+ private function jumpToPreviousTag(&$node){
+ while($node && $node->nodeType === XML_TEXT_NODE) {
+ $previousNode = $node->previousSibling;
+ if(!$previousNode) {
+ break;
+ }
+ $node = $previousNode;
+ }
+ }
+
+ public function collectData(){
+ // Because the LWN page is written in loose HTML and not XHTML,
+ // Simple HTML Dom is not accurate enough for the job
+ $content = getContents($this->getURI())
+ or returnServerError('No results for LWNprev');
+
+ $contents = explode('<b>Page editor</b>', $content);
+
+ foreach($contents as $content) {
+ if(strpos($content, '<html>') === false) {
+ $content = <<<EOD
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
+<html><head><title>LWN</title></head><body>{$content}</body></html>
+EOD;
+ } else {
+ $content = $content . '</body></html>';
+ }
+
+ libxml_use_internal_errors(true);
+ $html = new DOMDocument();
+ $html->loadHTML($content);
+ libxml_clear_errors();
+
+ $edition = $html->getElementsByTagName('h1');
+ if($edition->length !== 0) {
+ $text = $edition->item(0)->textContent;
+ $this->editionTimeStamp = strtotime(
+ substr($text, strpos($text, 'for ') + strlen('for '))
+ );
+ }
+
+ if(strpos($content, 'Cat1HL') === false) {
+ $items = $this->getFeatureContents($html);
+ } elseif(strpos($content, 'Cat3HL') === false) {
+ $items = $this->getBriefItems($html);
+ } else {
+ $items = $this->getAnnouncements($html);
+ }
+
+ $this->items = array_merge($this->items, $items);
+ }
+ }
+
+ private function getArticleContent(&$title){
+ $link = $title->firstChild;
+ $this->jumpToNextTag($link);
+ $item['uri'] = self::URI;
+ if($link->nodeName === 'a') {
+ $item['uri'] .= $link->getAttribute('href');
+ }
+
+ $item['timestamp'] = $this->editionTimeStamp;
+
+ $node = $title;
+ $content = '';
+ $contentEnd = false;
+ while(!$contentEnd) {
+ $node = $node->nextSibling;
+ if(!$node || (
+ $node->nodeType !== XML_TEXT_NODE &&
+ $node->nodeName === 'h2' || (
+ !is_null($node->attributes) &&
+ !is_null($class = $node->attributes->getNamedItem('class')) &&
+ in_array($class->nodeValue, array('Cat1HL','Cat2HL'))
+ )
+ )
+ ) {
+ $contentEnd = true;
+ } else {
+ $content .= $node->C14N();
+ }
+ }
+ $item['content'] = $content;
+ return $item;
+ }
+
+ private function getFeatureContents(&$html){
+ $items = array();
+ foreach($html->getElementsByTagName('h2') as $title) {
+ if($title->getAttribute('class') !== 'SummaryHL') {
+ continue;
+ }
+
+ $item = array();
+
+ $author = $title->nextSibling;
+ $this->jumpToNextTag($author);
+ if($author->getAttribute('class') === 'FeatureByline') {
+ $item['author'] = $author->getElementsByTagName('b')->item(0)->textContent;
+ } else {
+ continue;
+ }
+
+ $item['title'] = $title->textContent;
+
+ $items[] = array_merge($item, $this->getArticleContent($title));
+ }
+ return $items;
+ }
+
+ private function getItemPrefix(&$cat, &$cats){
+ $cat1 = '';
+ $cat2 = '';
+ $cat3 = '';
+ switch($cat->getAttribute('class')) {
+ case 'Cat3HL':
+ $cat3 = $cat->textContent;
+ $cat = $cat->previousSibling;
+ $this->jumpToPreviousTag($cat);
+ $cats[2] = $cat3;
+ if($cat->getAttribute('class') !== 'Cat2HL') {
+ break;
+ }
+ case 'Cat2HL':
+ $cat2 = $cat->textContent;
+ $cat = $cat->previousSibling;
+ $this->jumpToPreviousTag($cat);
+ $cats[1] = $cat2;
+ if(empty($cat3)) {
+ $cats[2] = '';
+ }
+ if($cat->getAttribute('class') !== 'Cat1HL') {
+ break;
+ }
+ case 'Cat1HL':
+ $cat1 = $cat->textContent;
+ $cats[0] = $cat1;
+ if(empty($cat3)) {
+ $cats[2] = '';
+ }
+ if(empty($cat2)) {
+ $cats[1] = '';
+ }
+ break;
+ default:
+ break;
+ }
+
+ $prefix = '';
+ if(!empty($cats[0])) {
+ $prefix .= '[' . $cats[0] . ($cats[1] ? '/' . $cats[1] : '') . '] ';
+ }
+ return $prefix;
+ }
+
+ private function getAnnouncements(&$html){
+ $items = array();
+ $cats = array('','','');
+
+ foreach($html->getElementsByTagName('p') as $newsletters) {
+ if($newsletters->getAttribute('class') !== 'Cat3HL') {
+ continue;
+ }
+
+ $item = array();
+
+ $item['uri'] = self::URI . '#' . count($items);
+
+ $item['timestamp'] = $this->editionTimeStamp;
+
+ $item['author'] = 'LWN';
+
+ $cat = $newsletters->previousSibling;
+ $this->jumpToPreviousTag($cat);
+ $prefix = $this->getItemPrefix($cat, $cats);
+ $item['title'] = $prefix . ' ' . $newsletters->textContent;
+
+ $node = $newsletters;
+ $content = '';
+ $contentEnd = false;
+ while(!$contentEnd) {
+ $node = $node->nextSibling;
+ if(!$node || (
+ $node->nodeType !== XML_TEXT_NODE && (
+ !is_null($node->attributes) &&
+ !is_null($class = $node->attributes->getNamedItem('class')) &&
+ in_array($class->nodeValue, array('Cat1HL','Cat2HL','Cat3HL'))
+ )
+ )
+ ) {
+ $contentEnd = true;
+ } else {
+ $content .= $node->C14N();
+ }
+ }
+ $item['content'] = $content;
+ $items[] = $item;
+ }
+
+ foreach($html->getElementsByTagName('h2') as $title) {
+ if($title->getAttribute('class') !== 'SummaryHL') {
+ continue;
+ }
+
+ $item = array();
+
+ $cat = $title->previousSibling;
+ $this->jumpToPreviousTag($cat);
+ $cat = $cat->previousSibling;
+ $this->jumpToPreviousTag($cat);
+ $prefix = $this->getItemPrefix($cat, $cats);
+ $item['title'] = $prefix . ' ' . $title->textContent;
+ $items[] = array_merge($item, $this->getArticleContent($title));
+ }
+
+ return $items;
+ }
+
+ private function getBriefItems(&$html){
+ $items = array();
+ $cats = array('','','');
+ foreach($html->getElementsByTagName('h2') as $title) {
+ if($title->getAttribute('class') !== 'SummaryHL') {
+ continue;
+ }
+
+ $item = array();
+
+ $cat = $title->previousSibling;
+ $this->jumpToPreviousTag($cat);
+ $cat = $cat->previousSibling;
+ $this->jumpToPreviousTag($cat);
+ $prefix = $this->getItemPrefix($cat, $cats);
+ $item['title'] = $prefix . ' ' . $title->textContent;
+ $items[] = array_merge($item, $this->getArticleContent($title));
+ }
+
+ return $items;
+ }
+}
+?>
diff --git a/bridges/LeBonCoinBridge.php b/bridges/LeBonCoinBridge.php
new file mode 100644
index 0000000..36196cb
--- /dev/null
+++ b/bridges/LeBonCoinBridge.php
@@ -0,0 +1,536 @@
+<?php
+class LeBonCoinBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'jacknumber';
+ const NAME = 'LeBonCoin';
+ const URI = 'https://www.leboncoin.fr/';
+ const DESCRIPTION = 'Returns most recent results from LeBonCoin';
+
+ const PARAMETERS = array(
+ array(
+ 'keywords' => array('name' => 'Mots-Clés'),
+ 'region' => array(
+ 'name' => 'Région',
+ 'type' => 'list',
+ 'values' => array(
+ 'Toute la France' => '',
+ 'Alsace' => '1',
+ 'Aquitaine' => '2',
+ 'Auvergne' => '3',
+ 'Basse Normandie' => '4',
+ 'Bourgogne' => '5',
+ 'Bretagne' => '6',
+ 'Centre' => '7',
+ 'Champagne Ardenne' => '8',
+ 'Corse' => '9',
+ 'Franche Comté' => '10',
+ 'Haute Normandie' => '11',
+ 'Ile de France' => '12',
+ 'Languedoc Roussillon' => '13',
+ 'Limousin' => '14',
+ 'Lorraine' => '15',
+ 'Midi Pyrénées' => '16',
+ 'Nord Pas De Calais' => '17',
+ 'Pays de la Loire' => '18',
+ 'Picardie' => '19',
+ 'Poitou Charentes' => '20',
+ 'Provence Alpes Côte d\'Azur' => '21',
+ 'Rhône-Alpes' => '22',
+ 'Guadeloupe' => '23',
+ 'Martinique' => '24',
+ 'Guyane' => '25',
+ 'Réunion' => '26'
+ )
+ ),
+ 'department' => array(
+ 'name' => 'Département',
+ 'type' => 'list',
+ 'values' => array(
+ '' => '',
+ 'Ain' => '1',
+ 'Aisne' => '2',
+ 'Allier' => '3',
+ 'Alpes-de-Haute-Provence' => '4',
+ 'Hautes-Alpes' => '5',
+ 'Alpes-Maritimes' => '6',
+ 'Ardèche' => '7',
+ 'Ardennes' => '8',
+ 'Ariège' => '9',
+ 'Aube' => '10',
+ 'Aude' => '11',
+ 'Aveyron' => '12',
+ 'Bouches-du-Rhône' => '13',
+ 'Calvados' => '14',
+ 'Cantal' => '15',
+ 'Charente' => '16',
+ 'Charente-Maritime' => '17',
+ 'Cher' => '18',
+ 'Corrèze' => '19',
+ 'Corse-du-Sud' => '2A',
+ 'Haute-Corse' => '2B',
+ 'Côte-d\'Or' => '21',
+ 'Côtes-d\'Armor' => '22',
+ 'Creuse' => '23',
+ 'Dordogne' => '24',
+ 'Doubs' => '25',
+ 'Drôme' => '26',
+ 'Eure' => '27',
+ 'Eure-et-Loir' => '28',
+ 'Finistère' => '29',
+ 'Gard' => '30',
+ 'Haute-Garonne' => '31',
+ 'Gers' => '32',
+ 'Gironde' => '33',
+ 'Hérault' => '34',
+ 'Ille-et-Vilaine' => '35',
+ 'Indre' => '36',
+ 'Indre-et-Loire' => '37',
+ 'Isère' => '38',
+ 'Jura' => '39',
+ 'Landes' => '40',
+ 'Loir-et-Cher' => '41',
+ 'Loire' => '42',
+ 'Haute-Loire' => '43',
+ 'Loire-Atlantique' => '44',
+ 'Loiret' => '45',
+ 'Lot' => '46',
+ 'Lot-et-Garonne' => '47',
+ 'Lozère' => '48',
+ 'Maine-et-Loire' => '49',
+ 'Manche' => '50',
+ 'Marne' => '51',
+ 'Haute-Marne' => '52',
+ 'Mayenne' => '53',
+ 'Meurthe-et-Moselle' => '54',
+ 'Meuse' => '55',
+ 'Morbihan' => '56',
+ 'Moselle' => '57',
+ 'Nièvre' => '58',
+ 'Nord' => '59',
+ 'Oise' => '60',
+ 'Orne' => '61',
+ 'Pas-de-Calais' => '62',
+ 'Puy-de-Dôme' => '63',
+ 'Pyrénées-Atlantiques' => '64',
+ 'Hautes-Pyrénées' => '65',
+ 'Pyrénées-Orientales' => '66',
+ 'Bas-Rhin' => '67',
+ 'Haut-Rhin' => '68',
+ 'Rhône' => '69',
+ 'Haute-Saône' => '70',
+ 'Saône-et-Loire' => '71',
+ 'Sarthe' => '72',
+ 'Savoie' => '73',
+ 'Haute-Savoie' => '74',
+ 'Paris' => '75',
+ 'Seine-Maritime' => '76',
+ 'Seine-et-Marne' => '77',
+ 'Yvelines' => '78',
+ 'Deux-Sèvres' => '79',
+ 'Somme' => '80',
+ 'Tarn' => '81',
+ 'Tarn-et-Garonne' => '82',
+ 'Var' => '83',
+ 'Vaucluse' => '84',
+ 'Vendée' => '85',
+ 'Vienne' => '86',
+ 'Haute-Vienne' => '87',
+ 'Vosges' => '88',
+ 'Yonne' => '89',
+ 'Territoire de Belfort' => '90',
+ 'Essonne' => '91',
+ 'Hauts-de-Seine' => '92',
+ 'Seine-Saint-Denis' => '93',
+ 'Val-de-Marne' => '94',
+ 'Val-d\'Oise' => '95'
+ )
+ ),
+ 'cities' => array(
+ 'name' => 'Villes',
+ 'title' => 'Codes postaux séparés par des virgules'
+ ),
+ 'category' => array(
+ 'name' => 'Catégorie',
+ 'type' => 'list',
+ 'values' => array(
+ 'Toutes catégories' => '',
+ 'EMPLOI' => array(
+ 'Emploi et recrutement' => '71',
+ 'Offres d\'emploi et jobs' => '33'
+ ),
+ 'VÉHICULES' => array(
+ 'Tous' => '1',
+ 'Voitures' => '2',
+ 'Motos' => '3',
+ 'Caravaning' => '4',
+ 'Utilitaires' => '5',
+ 'Equipement Auto' => '6',
+ 'Equipement Moto' => '44',
+ 'Equipement Caravaning' => '50',
+ 'Nautisme' => '7',
+ 'Equipement Nautisme' => '51'
+ ),
+ 'IMMOBILIER' => array(
+ 'Tous' => '8',
+ 'Ventes immobilières' => '9',
+ 'Locations' => '10',
+ 'Colocations' => '11',
+ 'Bureaux & Commerces' => '13'
+ ),
+ 'VACANCES' => array(
+ 'Tous' => '66',
+ 'Locations & Gîtes' => '12',
+ 'Chambres d\'hôtes' => '67',
+ 'Campings' => '68',
+ 'Hôtels' => '69',
+ 'Hébergements insolites' => '70'
+ ),
+ 'MULTIMÉDIA' => array(
+ 'Tous' => '14',
+ 'Informatique' => '15',
+ 'Consoles & Jeux vidéo' => '43',
+ 'Image & Son' => '16',
+ 'Téléphonie' => '17'
+ ),
+ 'LOISIRS' => array(
+ 'Tous' => '24',
+ 'DVD / Films' => '25',
+ 'CD / Musique' => '26',
+ 'Livres' => '27',
+ 'Animaux' => '28',
+ 'Vélos' => '55',
+ 'Sports & Hobbies' => '29',
+ 'Instruments de musique' => '30',
+ 'Collection' => '40',
+ 'Jeux & Jouets' => '41',
+ 'Vins & Gastronomie' => '48'
+ ),
+ 'MATÉRIEL PROFESSIONNEL' => array(
+ 'Tous' => '56',
+ 'Matériel Agricole' => '57',
+ 'Transport - Manutention' => '58',
+ 'BTP - Chantier Gros-oeuvre' => '59',
+ 'Outillage - Matériaux 2nd-oeuvre' => '60',
+ 'Équipements Industriels' => '32',
+ 'Restauration - Hôtellerie' => '61',
+ 'Fournitures de Bureau' => '62',
+ 'Commerces & Marchés' => '63',
+ 'Matériel Médical' => '64'
+ ),
+ 'SERVICES' => array(
+ 'Tous' => '31',
+ 'Prestations de services' => '34',
+ 'Billetterie' => '35',
+ 'Événements' => '49',
+ 'Cours particuliers' => '36',
+ 'Covoiturage' => '65'
+ ),
+ 'MAISON' => array(
+ 'Tous' => '18',
+ 'Ameublement' => '19',
+ 'Électroménager' => '20',
+ 'Arts de la table' => '45',
+ 'Décoration' => '39',
+ 'Linge de maison' => '46',
+ 'Bricolage' => '21',
+ 'Jardinage' => '52',
+ 'Vêtements' => '22',
+ 'Chaussures' => '53',
+ 'Accessoires & Bagagerie' => '47',
+ 'Montres & Bijoux' => '42',
+ 'Équipement bébé' => '23',
+ 'Vêtements bébé' => '54',
+ ),
+ 'AUTRES' => '37'
+ )
+ ),
+ 'pricemin' => array(
+ 'name' => 'Prix min',
+ 'type' => 'number'
+ ),
+ 'pricemax' => array(
+ 'name' => 'Prix max',
+ 'type' => 'number'
+ ),
+ 'estate' => array(
+ 'name' => 'Type de bien',
+ 'type' => 'list',
+ 'values' => array(
+ '' => '',
+ 'Maison' => '1',
+ 'Appartement' => '2',
+ 'Terrain' => '3',
+ 'Parking' => '4',
+ 'Autre' => '5'
+ )
+ ),
+ 'roomsmin' => array(
+ 'name' => 'Pièces min',
+ 'type' => 'number'
+ ),
+ 'roomsmax' => array(
+ 'name' => 'Pièces max',
+ 'type' => 'number'
+ ),
+ 'squaremin' => array(
+ 'name' => 'Surface min',
+ 'type' => 'number'
+ ),
+ 'squaremax' => array(
+ 'name' => 'Surface max',
+ 'type' => 'number'
+ ),
+ 'mileagemin' => array(
+ 'name' => 'Kilométrage min',
+ 'type' => 'number'
+ ),
+ 'mileagemax' => array(
+ 'name' => 'Kilométrage max',
+ 'type' => 'number'
+ ),
+ 'yearmin' => array(
+ 'name' => 'Année min',
+ 'type' => 'number'
+ ),
+ 'yearmax' => array(
+ 'name' => 'Année max',
+ 'type' => 'number'
+ ),
+ 'cubiccapacitymin' => array(
+ 'name' => 'Cylindrée min',
+ 'type' => 'number'
+ ),
+ 'cubiccapacitymax' => array(
+ 'name' => 'Cylindrée max',
+ 'type' => 'number'
+ ),
+ 'fuel' => array(
+ 'name' => 'Énergie',
+ 'type' => 'list',
+ 'values' => array(
+ '' => '',
+ 'Essence' => '1',
+ 'Diesel' => '2',
+ 'GPL' => '3',
+ 'Électrique' => '4',
+ 'Hybride' => '6',
+ 'Autre' => '5'
+ )
+ ),
+ 'owner' => array(
+ 'name' => 'Vendeur',
+ 'type' => 'list',
+ 'values' => array(
+ 'Tous' => '',
+ 'Particuliers' => 'private',
+ 'Professionnels' => 'pro'
+ )
+ )
+ )
+ );
+
+ public static $LBC_API_KEY = 'ba0c2dad52b3ec';
+
+ private function getRange($field, $range_min, $range_max){
+
+ if(!is_null($range_min)
+ && !is_null($range_max)
+ && $range_min > $range_max) {
+ returnClientError('Min-' . $field . ' must be lower than max-' . $field . '.');
+ }
+
+ if(!is_null($range_min)
+ && is_null($range_max)) {
+ returnClientError('Max-' . $field . ' is needed when min-' . $field . ' is setted (range).');
+ }
+
+ return array(
+ 'min' => $range_min,
+ 'max' => $range_max
+ );
+ }
+
+ public function collectData(){
+
+ $url = 'https://api.leboncoin.fr/finder/search/';
+ $data = $this->buildRequestJson();
+
+ $header = array(
+ 'Content-Type: application/json',
+ 'Content-Length: ' . strlen($data),
+ 'api_key: ' . self::$LBC_API_KEY
+ );
+
+ $opts = array(
+ CURLOPT_CUSTOMREQUEST => 'POST',
+ CURLOPT_POSTFIELDS => $data
+
+ );
+
+ $content = getContents($url, $header, $opts)
+ or returnServerError('Could not request LeBonCoin. Tried: ' . $url);
+
+ $json = json_decode($content);
+
+ if($json->total === 0) {
+ return;
+ }
+
+ foreach($json->ads as $element) {
+
+ $item['title'] = $element->subject;
+ $item['content'] = $element->body;
+ $item['date'] = $element->index_date;
+ $item['timestamp'] = strtotime($element->index_date);
+ $item['uri'] = $element->url;
+ $item['ad_type'] = $element->ad_type;
+ $item['author'] = $element->owner->name;
+
+ if(isset($element->location->city)) {
+
+ $item['city'] = $element->location->city;
+ $item['content'] .= ' -- ' . $element->location->city;
+
+ }
+
+ if(isset($element->location->zipcode)) {
+ $item['zipcode'] = $element->location->zipcode;
+ }
+
+ if(isset($element->price)) {
+
+ $item['price'] = $element->price[0];
+ $item['content'] .= ' -- ' . current($element->price) . '€';
+
+ }
+
+ if(isset($element->images->urls)) {
+
+ $item['thumbnail'] = $element->images->thumb_url;
+ $item['enclosures'] = array();
+
+ foreach($element->images->urls as $image) {
+ $item['enclosures'][] = $image;
+ }
+
+ }
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function buildRequestJson() {
+
+ $requestJson = new StdClass();
+ $requestJson->owner_type = $this->getInput('owner');
+ $requestJson->filters = new StdClass();
+
+ $requestJson->filters->keywords = array(
+ 'text' => $this->getInput('keywords')
+ );
+
+ if($this->getInput('region') != '') {
+ $requestJson->filters->location['regions'] = [$this->getInput('region')];
+ }
+
+ if($this->getInput('department') != '') {
+ $requestJson->filters->location['departments'] = [$this->getInput('department')];
+ }
+
+ if($this->getInput('cities') != '') {
+
+ $requestJson->filters->location['city_zipcodes'] = array();
+
+ foreach (explode(',', $this->getInput('cities')) as $zipcode) {
+
+ $requestJson->filters->location['city_zipcodes'][] = array(
+ 'zipcode' => trim($zipcode)
+ );
+ }
+
+ }
+
+ $requestJson->filters->category = array(
+ 'id' => $this->getInput('category')
+ );
+
+ if($this->getInput('pricemin') != ''
+ || $this->getInput('pricemax') != '') {
+
+ $requestJson->filters->ranges->price = $this->getRange(
+ 'price',
+ $this->getInput('pricemin'),
+ $this->getInput('pricemax')
+ );
+
+ }
+
+ if($this->getInput('estate') != '') {
+ $requestJson->filters->enums['real_estate_type'] = [$this->getInput('estate')];
+ }
+
+ if($this->getInput('roomsmin') != ''
+ || $this->getInput('roomsmax') != '') {
+
+ $requestJson->filters->ranges->rooms = $this->getRange(
+ 'rooms',
+ $this->getInput('roomsmin'),
+ $this->getInput('roomsmax')
+ );
+
+ }
+
+ if($this->getInput('squaremin') != ''
+ || $this->getInput('squaremax') != '') {
+
+ $requestJson->filters->ranges->square = $this->getRange(
+ 'square',
+ $this->getInput('squaremin'),
+ $this->getInput('squaremax')
+ );
+
+ }
+
+ if($this->getInput('mileagemin') != ''
+ || $this->getInput('mileagemax') != '') {
+
+ $requestJson->filters->ranges->mileage = $this->getRange(
+ 'mileage',
+ $this->getInput('mileagemin'),
+ $this->getInput('mileagemax')
+ );
+
+ }
+
+ if($this->getInput('yearmin') != ''
+ || $this->getInput('yearmax') != '') {
+
+ $requestJson->filters->ranges->regdate = $this->getRange(
+ 'year',
+ $this->getInput('yearmin'),
+ $this->getInput('yearmax')
+ );
+
+ }
+
+ if($this->getInput('cubiccapacitymin') != ''
+ || $this->getInput('cubiccapacitymax') != '') {
+
+ $requestJson->filters->ranges->cubic_capacity = $this->getRange(
+ 'cubic_capacity',
+ $this->getInput('cubiccapacitymin'),
+ $this->getInput('cubiccapacitymax')
+ );
+
+ }
+
+ if($this->getInput('fuel') != '') {
+ $requestJson->filters->enums['fuel'] = [$this->getInput('fuel')];
+ }
+
+ $requestJson->limit = 30;
+
+ return json_encode($requestJson);
+
+ }
+}
diff --git a/bridges/LeMondeInformatiqueBridge.php b/bridges/LeMondeInformatiqueBridge.php
new file mode 100644
index 0000000..09bcf6a
--- /dev/null
+++ b/bridges/LeMondeInformatiqueBridge.php
@@ -0,0 +1,39 @@
+<?php
+class LeMondeInformatiqueBridge extends FeedExpander {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'Le Monde Informatique';
+ const URI = 'https://www.lemondeinformatique.fr/';
+ const DESCRIPTION = 'Returns the newest articles.';
+
+ public function collectData(){
+ $this->collectExpandableDatas(self::URI . 'rss/rss.xml', 10);
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+ $article_html = getSimpleHTMLDOMCached($item['uri'])
+ or returnServerError('Could not request LeMondeInformatique: ' . $item['uri']);
+
+ //Deduce thumbnail URL from article image URL
+ $item['enclosures'] = array(
+ str_replace(
+ '/grande/',
+ '/petite/',
+ $article_html->find('.article-image', 0)->find('img', 0)->src
+ )
+ );
+
+ //No response header sets the encoding, explicit conversion is needed or subsequent xml_encode() will fail
+ $item['content'] = utf8_encode($this->cleanArticle($article_html->find('div.col-primary', 0)->innertext));
+ $item['author'] = utf8_encode($article_html->find('div.author-infos', 0)->find('b', 0)->plaintext);
+
+ return $item;
+ }
+
+ private function cleanArticle($article_html){
+ $article_html = stripWithDelimiters($article_html, '<script', '</script>');
+ $article_html = explode('<p class="contact-error', $article_html)[0] . '</div>';
+ return $article_html;
+ }
+}
diff --git a/bridges/LegifranceJOBridge.php b/bridges/LegifranceJOBridge.php
new file mode 100644
index 0000000..41a9b06
--- /dev/null
+++ b/bridges/LegifranceJOBridge.php
@@ -0,0 +1,72 @@
+<?php
+class LegifranceJOBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Pierre Mazière';
+ const NAME = 'Journal Officiel de la République Française';
+ const URI = 'https://www.legifrance.gouv.fr/affichJO.do';
+ const DESCRIPTION = 'Returns the laws and decrees officially registered daily in France';
+
+ const PARAMETERS = array();
+
+ private $author;
+ private $timestamp;
+ private $uri;
+
+ private function extractItem($section, $subsection = null, $origin = null){
+ $item = array();
+ $item['author'] = $this->author;
+ $item['timestamp'] = $this->timestamp;
+ $item['uri'] = $this->uri . '#' . count($this->items);
+ $item['title'] = $section->plaintext;
+
+ if(!is_null($origin)) {
+ $item['title'] = '[ ' . $item['title'] . ' / ' . $subsection->plaintext . ' ] ' . $origin->plaintext;
+ $data = $origin;
+ } elseif(!is_null($subsection)) {
+ $item['title'] = '[ ' . $item['title'] . ' ] ' . $subsection->plaintext;
+ $data = $subsection;
+ } else {
+ $data = $section;
+ }
+
+ $item['content'] = '';
+ foreach($data->nextSibling()->find('a') as $content) {
+ $text = $content->plaintext;
+ $href = $content->nextSibling()->getAttribute('resource');
+ $item['content'] .= '<p><a href="' . $href . '">' . $text . '</a></p>';
+ }
+ return $item;
+ }
+
+ public function getIcon() {
+ return 'https://www.legifrance.gouv.fr/img/favicon.ico';
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or $this->returnServer('Unable to download ' . self::URI);
+
+ $this->author = trim($html->find('h2.titleJO', 0)->plaintext);
+ $uri = $html->find('h2.titleELI', 0)->plaintext;
+ $this->uri = trim(substr($uri, strpos($uri, 'https')));
+ $this->timestamp = strtotime(substr($this->uri, strpos($this->uri, 'eli/jo/') + strlen('eli/jo/'), -5));
+
+ foreach($html->find('h3') as $section) {
+ $subsections = $section->nextSibling()->find('h4');
+ foreach($subsections as $subsection) {
+ $origins = $subsection->nextSibling()->find('h5');
+ foreach($origins as $origin) {
+ $this->items[] = $this->extractItem($section, $subsection, $origin);
+ }
+ if(!empty($origins)) {
+ continue;
+ }
+ $this->items[] = $this->extractItem($section, $subsection);
+ }
+ if(!empty($subsections)) {
+ continue;
+ }
+ $this->items[] = $this->extractItem($section);
+ }
+ }
+}
diff --git a/bridges/LesJoiesDuCodeBridge.php b/bridges/LesJoiesDuCodeBridge.php
new file mode 100644
index 0000000..0957d92
--- /dev/null
+++ b/bridges/LesJoiesDuCodeBridge.php
@@ -0,0 +1,37 @@
+<?php
+class LesJoiesDuCodeBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'superbaillot.net';
+ const NAME = 'Les Joies Du Code';
+ const URI = 'https://lesjoiesducode.fr/';
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'LesJoiesDuCode';
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request LesJoiesDuCode.');
+
+ foreach($html->find('div.blog-post') as $element) {
+ $item = array();
+ $temp = $element->find('h1 a', 0);
+ $titre = html_entity_decode($temp->innertext);
+ $url = $temp->href;
+
+ $temp = $element->find('div.blog-post-content', 0);
+
+ // retrieve .gif instead of static .jpg
+ $images = $temp->find('p img');
+ foreach($images as $image) {
+ $img_src = str_replace('.jpg', '.gif', $image->src);
+ $image->src = $img_src;
+ }
+ $content = $temp->innertext;
+
+ $item['content'] = trim($content);
+ $item['uri'] = $url;
+ $item['title'] = trim($titre);
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/LichessBridge.php b/bridges/LichessBridge.php
new file mode 100644
index 0000000..bf7369f
--- /dev/null
+++ b/bridges/LichessBridge.php
@@ -0,0 +1,31 @@
+<?php
+class LichessBridge extends FeedExpander {
+
+ const MAINTAINER = 'AmauryCarrade';
+ const NAME = 'Lichess Blog';
+ const URI = 'http://fr.lichess.org/blog';
+ const DESCRIPTION = 'Returns the 5 newest posts from the Lichess blog (full text)';
+
+ public function collectData(){
+ $this->collectExpandableDatas(self::URI . '.atom', 5);
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+ $item['content'] = $this->retrieveLichessPost($item['uri']);
+ return $item;
+ }
+
+ private function retrieveLichessPost($blog_post_uri){
+ $blog_post_html = getSimpleHTMLDOMCached($blog_post_uri);
+ $blog_post_div = $blog_post_html->find('#lichess_blog', 0);
+
+ $post_chapo = $blog_post_div->find('.shortlede', 0)->innertext;
+ $post_content = $blog_post_div->find('.body', 0)->innertext;
+
+ $content = '<p><em>' . $post_chapo . '</em></p>';
+ $content .= '<div>' . $post_content . '</div>';
+
+ return $content;
+ }
+}
diff --git a/bridges/LinkedInCompanyBridge.php b/bridges/LinkedInCompanyBridge.php
new file mode 100644
index 0000000..e629211
--- /dev/null
+++ b/bridges/LinkedInCompanyBridge.php
@@ -0,0 +1,37 @@
+<?php
+class LinkedInCompanyBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'regisenguehard';
+ const NAME = 'LinkedIn Company';
+ const URI = 'https://www.linkedin.com/';
+ const CACHE_TIMEOUT = 21600; //6
+ const DESCRIPTION = 'Returns most recent actus from Company on LinkedIn.
+ (https://www.linkedin.com/company/<strong style=\"font-weight:bold;\">apple</strong>)';
+
+ const PARAMETERS = array( array(
+ 'c' => array(
+ 'name' => 'Company name',
+ 'required' => true
+ )
+ ));
+
+ public function collectData(){
+ $html = '';
+ $link = self::URI . 'company/' . $this->getInput('c');
+
+ $html = getSimpleHTMLDOM($link)
+ or returnServerError('Could not request LinkedIn.');
+
+ foreach($html->find('//*[@id="my-feed-post"]/li') as $element) {
+ $title = $element->find('span.share-body', 0)->innertext;
+ if($title) {
+ $item = array();
+ $item['uri'] = $link;
+ $item['title'] = mb_substr(strip_tags($element->find('span.share-body', 0)->innertext), 0, 100);
+ $item['content'] = strip_tags($element->find('span.share-body', 0)->innertext);
+ $this->items[] = $item;
+ $i++;
+ }
+ }
+ }
+}
diff --git a/bridges/LolibooruBridge.php b/bridges/LolibooruBridge.php
new file mode 100644
index 0000000..b5bbd75
--- /dev/null
+++ b/bridges/LolibooruBridge.php
@@ -0,0 +1,11 @@
+<?php
+require_once('MoebooruBridge.php');
+
+class LolibooruBridge extends MoebooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Lolibooru';
+ const URI = 'https://lolibooru.moe/';
+ const DESCRIPTION = 'Returns images from given page and tags';
+
+}
diff --git a/bridges/MangareaderBridge.php b/bridges/MangareaderBridge.php
new file mode 100644
index 0000000..9153706
--- /dev/null
+++ b/bridges/MangareaderBridge.php
@@ -0,0 +1,249 @@
+<?php
+class MangareaderBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'Mangareader Bridge';
+ const URI = 'http://www.mangareader.net';
+ const CACHE_TIMEOUT = 10800; // 3h
+ const DESCRIPTION = 'Returns the latest updates, popular mangas or manga updates (new chapters)';
+
+ const PARAMETERS = array(
+ 'Get latest updates' => array(),
+ 'Get popular mangas' => array(
+ 'category' => array(
+ 'name' => 'Category',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'All' => 'all',
+ 'Action' => 'action',
+ 'Adventure' => 'adventure',
+ 'Comedy' => 'comedy',
+ 'Demons' => 'demons',
+ 'Drama' => 'drama',
+ 'Ecchi' => 'ecchi',
+ 'Fantasy' => 'fantasy',
+ 'Gender Bender' => 'gender-bender',
+ 'Harem' => 'harem',
+ 'Historical' => 'historical',
+ 'Horror' => 'horror',
+ 'Josei' => 'josei',
+ 'Magic' => 'magic',
+ 'Martial Arts' => 'martial-arts',
+ 'Mature' => 'mature',
+ 'Mecha' => 'mecha',
+ 'Military' => 'military',
+ 'Mystery' => 'mystery',
+ 'One Shot' => 'one-shot',
+ 'Psychological' => 'psychological',
+ 'Romance' => 'romance',
+ 'School Life' => 'school-life',
+ 'Sci-Fi' => 'sci-fi',
+ 'Seinen' => 'seinen',
+ 'Shoujo' => 'shoujo',
+ 'Shoujoai' => 'shoujoai',
+ 'Shounen' => 'shounen',
+ 'Shounenai' => 'shounenai',
+ 'Slice of Life' => 'slice-of-life',
+ 'Smut' => 'smut',
+ 'Sports' => 'sports',
+ 'Super Power' => 'super-power',
+ 'Supernatural' => 'supernatural',
+ 'Tragedy' => 'tragedy',
+ 'Vampire' => 'vampire',
+ 'Yaoi' => 'yaoi',
+ 'Yuri' => 'yuri'
+ ),
+ 'exampleValue' => 'All',
+ 'title' => 'Select your category'
+ )
+ ),
+ 'Get manga updates' => array(
+ 'path' => array(
+ 'name' => 'Path',
+ 'required' => true,
+ 'pattern' => '[a-zA-Z0-9-_]*',
+ 'exampleValue' => 'bleach, umi-no-kishidan',
+ 'title' => 'URL part of desired manga'
+ ),
+ 'limit' => array(
+ 'name' => 'Limit',
+ 'type' => 'number',
+ 'defaultValue' => 10,
+ 'title' => 'Number of items to return [-1 returns all]'
+ )
+ )
+ );
+
+ private $request = '';
+
+ public function collectData(){
+ // We'll use the DOM parser for this as it makes navigation easier
+ $html = getContents($this->getURI());
+ if(!$html) {
+ returnClientError('Could not receive data for ' . $path . '!');
+ }
+ libxml_use_internal_errors(true);
+ $doc = new DomDocument;
+ @$doc->loadHTML($html);
+ libxml_clear_errors();
+
+ // Navigate via XPath
+ $xpath = new DomXPath($doc);
+
+ $this->request = '';
+ switch($this->queriedContext) {
+ case 'Get latest updates':
+ $this->request = 'Latest updates';
+ $this->getLatestUpdates($xpath);
+ break;
+ case 'Get popular mangas':
+ // Find manga name within "Popular mangas for ..."
+ $pagetitle = $xpath->query(".//*[@id='bodyalt']/h1")->item(0)->nodeValue;
+ $this->request = substr($pagetitle, 0, strrpos($pagetitle, ' -'));
+ $this->getPopularMangas($xpath);
+ break;
+ case 'Get manga updates':
+ $limit = $this->getInput('limit');
+ if(empty($limit)) {
+ $limit = self::PARAMETERS[$this->queriedContext]['limit']['defaultValue'];
+ }
+
+ $this->request = $xpath->query(".//*[@id='mangaproperties']//*[@class='aname']")
+ ->item(0)
+ ->nodeValue;
+
+ $this->getMangaUpdates($xpath, $limit);
+ break;
+ }
+
+ // Return some dummy-data if no content available
+ if(empty($this->items)) {
+ $item = array();
+ $item['content'] = '<p>No updates available</p>';
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function getLatestUpdates($xpath){
+ // Query each item (consists of Manga + chapters)
+ $nodes = $xpath->query("//*[@id='latestchapters']/table//td");
+
+ foreach ($nodes as $node) {
+ // Query the manga
+ $manga = $xpath->query("a[@class='chapter']", $node)->item(0);
+
+ // Collect the chapters for each Manga
+ $chapters = $xpath->query("a[@class='chaptersrec']", $node);
+
+ if (isset($manga) && $chapters->length >= 1) {
+ $item = array();
+ $item['uri'] = self::URI . htmlspecialchars($manga->getAttribute('href'));
+ $item['title'] = htmlspecialchars($manga->nodeValue);
+
+ // Add each chapter to the feed
+ $item['content'] = '';
+
+ foreach ($chapters as $chapter) {
+ if($item['content'] <> '') {
+ $item['content'] .= '<br>';
+ }
+ $item['content'] .= "<a href='"
+ . self::URI
+ . htmlspecialchars($chapter->getAttribute('href'))
+ . "'>"
+ . htmlspecialchars($chapter->nodeValue)
+ . '</a>';
+ }
+
+ $this->items[] = $item;
+ }
+ }
+ }
+
+ private function getPopularMangas($xpath){
+ // Query all mangas
+ $mangas = $xpath->query("//*[@id='mangaresults']/*[@class='mangaresultitem']");
+
+ foreach ($mangas as $manga) {
+
+ // The thumbnail is encrypted in a css-style...
+ // format: "background-image:url('<the part which is actually interesting>')"
+ $mangaimgelement = $xpath->query(".//*[@class='imgsearchresults']", $manga)
+ ->item(0)
+ ->getAttribute('style');
+ $thumbnail = substr($mangaimgelement, 22, strlen($mangaimgelement) - 24);
+
+ $item = array();
+ $item['title'] = htmlspecialchars($xpath->query(".//*[@class='manga_name']//a", $manga)
+ ->item(0)
+ ->nodeValue);
+ $item['uri'] = self::URI . $xpath->query(".//*[@class='manga_name']//a", $manga)
+ ->item(0)
+ ->getAttribute('href');
+ $item['author'] = htmlspecialchars($xpath->query("//*[@class='author_name']", $manga)
+ ->item(0)
+ ->nodeValue);
+ $item['chaptercount'] = $xpath->query(".//*[@class='chapter_count']", $manga)
+ ->item(0)
+ ->nodeValue;
+ $item['genre'] = htmlspecialchars($xpath->query(".//*[@class='manga_genre']", $manga)
+ ->item(0)
+ ->nodeValue);
+ $item['content'] = <<<EOD
+<a href="{$item['uri']}"><img src="{$thumbnail}" alt="{$item['title']}" /></a>
+<p>{$item['genre']}</p>
+<p>{$item['chaptercount']}</p>
+EOD;
+ $this->items[] = $item;
+ }
+ }
+
+ private function getMangaUpdates($xpath, $limit){
+ $query = "(.//*[@id='listing']//tr)[position() > 1]";
+
+ if($limit !== -1) {
+ $query = "(.//*[@id='listing']//tr)[position() > 1][position() > last() - {$limit}]";
+ }
+
+ $chapters = $xpath->query($query);
+
+ foreach ($chapters as $chapter) {
+ $item = array();
+ $item['title'] = htmlspecialchars($xpath->query('td[1]', $chapter)
+ ->item(0)
+ ->nodeValue);
+ $item['uri'] = self::URI . $xpath->query('td[1]/a', $chapter)
+ ->item(0)
+ ->getAttribute('href');
+ $item['timestamp'] = strtotime($xpath->query('td[2]', $chapter)
+ ->item(0)
+ ->nodeValue);
+ array_unshift($this->items, $item);
+ }
+ }
+
+ public function getURI(){
+ switch($this->queriedContext) {
+ case 'Get latest updates':
+ $path = 'latest';
+ break;
+ case 'Get popular mangas':
+ $path = 'popular';
+ if($this->getInput('category') !== 'all') {
+ $path .= '/' . $this->getInput('category');
+ }
+ break;
+ case 'Get manga updates':
+ $path = $this->getInput('path');
+ break;
+ default: return parent::getURI();
+ }
+ return self::URI . '/' . $path;
+ }
+
+ public function getName(){
+ return (!empty($this->request) ? $this->request . ' - ' : '') . 'Mangareader Bridge';
+ }
+}
diff --git a/bridges/MilbooruBridge.php b/bridges/MilbooruBridge.php
new file mode 100644
index 0000000..c3b633e
--- /dev/null
+++ b/bridges/MilbooruBridge.php
@@ -0,0 +1,11 @@
+<?php
+require_once('Shimmie2Bridge.php');
+
+class MilbooruBridge extends Shimmie2Bridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Milbooru';
+ const URI = 'http://sheslostcontrol.net/moe/shimmie/';
+ const DESCRIPTION = 'Returns images from given page';
+
+}
diff --git a/bridges/MixCloudBridge.php b/bridges/MixCloudBridge.php
new file mode 100644
index 0000000..723f634
--- /dev/null
+++ b/bridges/MixCloudBridge.php
@@ -0,0 +1,53 @@
+<?php
+
+class MixCloudBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Alexis CHEMEL';
+ const NAME = 'MixCloud';
+ const URI = 'https://www.mixcloud.com';
+ const CACHE_TIMEOUT = 3600; // 1h
+ const DESCRIPTION = 'Returns latest musics on user stream';
+
+ const PARAMETERS = array(array(
+ 'u' => array(
+ 'name' => 'username',
+ 'required' => true,
+ )
+ ));
+
+ public function getName(){
+ if(!is_null($this->getInput('u'))) {
+ return 'MixCloud - ' . $this->getInput('u');
+ }
+
+ return parent::getName();
+ }
+
+ public function collectData(){
+ ini_set('user_agent', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:53.0) Gecko/20100101 Firefox/53.0');
+
+ $html = getSimpleHTMLDOM(self::URI . '/' . $this->getInput('u'))
+ or returnServerError('Could not request MixCloud.');
+
+ foreach($html->find('section.card') as $element) {
+
+ $item = array();
+
+ $item['uri'] = self::URI . $element->find('hgroup.card-title h1 a', 0)->getAttribute('href');
+ $item['title'] = html_entity_decode(
+ $element->find('hgroup.card-title h1 a span', 0)->getAttribute('title'),
+ ENT_QUOTES
+ );
+
+ $image = $element->find('a.album-art img', 0);
+
+ if($image) {
+ $item['content'] = '<img src="' . $image->getAttribute('src') . '" />';
+ }
+
+ $item['author'] = trim($element->find('hgroup.card-title h2 a', 0)->innertext);
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/ModelKarteiBridge.php b/bridges/ModelKarteiBridge.php
new file mode 100644
index 0000000..6def683
--- /dev/null
+++ b/bridges/ModelKarteiBridge.php
@@ -0,0 +1,102 @@
+<?php
+class ModelKarteiBridge extends BridgeAbstract {
+ const NAME = 'model-kartei.de';
+ const URI = 'https://www.model-kartei.de/';
+ const DESCRIPTION = 'Get the public comp card gallery';
+ const MAINTAINER = 'fulmeek';
+ const PARAMETERS = array(array(
+ 'model_id' => array(
+ 'name' => 'Model ID',
+ 'exampleValue' => '123456'
+ )
+ ));
+
+ const LIMIT_ITEMS = 10;
+
+ private $feedName = '';
+
+ public function collectData() {
+ $model_id = preg_replace('/[^0-9]/', '', $this->getInput('model_id'));
+ if (empty($model_id))
+ returnServerError('Invalid model ID');
+
+ $html = getSimpleHTMLDOM(self::URI . 'sedcards/model/' . $model_id . '/')
+ or returnServerError('Model not found');
+
+ $objTitle = $html->find('.sTitle', 0);
+ if ($objTitle)
+ $this->feedName = $objTitle->plaintext;
+
+ $itemlist = $html->find('#photoList .photoPreview');
+ if (!$itemlist)
+ returnServerError('No gallery');
+
+ foreach($itemlist as $idx => $element) {
+ if ($idx >= self::LIMIT_ITEMS)
+ break;
+
+ $item = array();
+
+ $title = $element->title;
+ $date = $element->{'data-date'};
+ $author = $this->feedName;
+ $text = '';
+
+ $objImage = $element->find('a.photoLink img', 0);
+ $objLink = $element->find('a.photoLink', 0);
+
+ if ($objLink) {
+ $page = getSimpleHTMLDOMCached($objLink->href);
+
+ if (empty($title)) {
+ $objTitle = $page->find('.p-title', 0);
+ if ($objTitle)
+ $title = $objTitle->plaintext;
+ }
+ if (empty($date)) {
+ $objDate = $page->find('.cameraDetails .date', 0);
+ if ($objDate)
+ $date = strtotime($objDate->parent()->plaintext);
+ }
+ if (empty($author)) {
+ $objAuthor = $page->find('.p-publisher a', 0);
+ if ($objAuthor)
+ $author = $objAuthor->plaintext;
+ }
+
+ $objFullImage = $page->find('img#gofullscreen', 0);
+ if ($objFullImage)
+ $objImage = $objFullImage;
+
+ $objText = $page->find('.p-desc', 0);
+ if ($objText)
+ $text = $objText->plaintext;
+ }
+
+ $item['title'] = $title;
+ $item['timestamp'] = $date;
+ $item['author'] = $author;
+
+ if ($objImage)
+ $item['content'] = '<img src="' . $objImage->src . '"/>';
+ if ($objLink) {
+ $item['uri'] = $objLink->href;
+ if (!empty($item['content']))
+ $item['content'] = '<a href="' . $objLink->href . '" target="_blank">' . $item['content'] . '</a>';
+ } else {
+ $item['uri'] = 'urn:sha1:' . hash('sha1', $item['content']);
+ }
+ if (!empty($text))
+ $item['content'] = '<p>' . $text . '</p>' . $item['content'];
+
+ $this->items[] = $item;
+ }
+ }
+
+ public function getName(){
+ if(!empty($this->feedName)) {
+ return $this->feedName . ' - ' . self::NAME;
+ }
+ return parent::getName();
+ }
+}
diff --git a/bridges/MoebooruBridge.php b/bridges/MoebooruBridge.php
new file mode 100644
index 0000000..9d9a625
--- /dev/null
+++ b/bridges/MoebooruBridge.php
@@ -0,0 +1,56 @@
+<?php
+class MoebooruBridge extends BridgeAbstract {
+
+ const NAME = 'Moebooru';
+ const URI = 'https://moe.dev.myconan.net/';
+ const CACHE_TIMEOUT = 1800; // 30min
+ const DESCRIPTION = 'Returns images from given page';
+ const MAINTAINER = 'pmaziere';
+
+ const PARAMETERS = array( array(
+ 'p' => array(
+ 'name' => 'page',
+ 'defaultValue' => 1,
+ 'type' => 'number'
+ ),
+ 't' => array(
+ 'name' => 'tags'
+ )
+ ));
+
+ protected function getFullURI(){
+ return $this->getURI()
+ . 'post?page='
+ . $this->getInput('p')
+ . '&tags='
+ . urlencode($this->getInput('t'));
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getFullURI())
+ or returnServerError('Could not request ' . $this->getName());
+
+ $input_json = explode('Post.register(', $html);
+ foreach($input_json as $element)
+ $data[] = preg_replace('/}\)(.*)/', '}', $element);
+ unset($data[0]);
+
+ foreach($data as $datai) {
+ $json = json_decode($datai, true);
+ $item = array();
+ $item['uri'] = $this->getURI() . '/post/show/' . $json['id'];
+ $item['postid'] = $json['id'];
+ $item['timestamp'] = $json['created_at'];
+ $item['imageUri'] = $json['file_url'];
+ $item['title'] = $this->getName() . ' | ' . $json['id'];
+ $item['content'] = '<a href="'
+ . $item['imageUri']
+ . '"><img src="'
+ . $json['preview_url']
+ . '" /></a><br>Tags: '
+ . $json['tags'];
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/MoinMoinBridge.php b/bridges/MoinMoinBridge.php
new file mode 100644
index 0000000..5b41924
--- /dev/null
+++ b/bridges/MoinMoinBridge.php
@@ -0,0 +1,327 @@
+<?php
+class MoinMoinBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'MoinMoin Bridge';
+ const URI = 'https://moinmo.in';
+ const DESCRIPTION = 'Generates feeds for pages of a MoinMoin (compatible) wiki';
+ const PARAMETERS = array(
+ array(
+ 'source' => array(
+ 'name' => 'Source',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Insert wiki page URI (e.g.: https://moinmo.in/MoinMoin)',
+ 'exampleValue' => 'https://moinmo.in/MoinMoin'
+ ),
+ 'separator' => array(
+ 'name' => 'Separator',
+ 'type' => 'list',
+ 'requied' => true,
+ 'title' => 'Defines the separtor for splitting content into feeds',
+ 'defaultValue' => 'h2',
+ 'values' => array(
+ 'Header (h1)' => 'h1',
+ 'Header (h2)' => 'h2',
+ 'Header (h3)' => 'h3',
+ 'List element (li)' => 'li',
+ 'Anchor (a)' => 'a'
+ )
+ ),
+ 'limit' => array(
+ 'name' => 'Limit',
+ 'type' => 'number',
+ 'required' => false,
+ 'title' => 'Number of items to return (from top)',
+ 'defaultValue' => -1
+ ),
+ 'content' => array(
+ 'name' => 'Content',
+ 'type' => 'list',
+ 'required' => false,
+ 'title' => 'Defines how feed contents are build',
+ 'defaultValue' => 'separator',
+ 'values' => array(
+ 'By separator' => 'separator',
+ 'Follow link (only for anchor)' => 'follow',
+ 'None' => 'none'
+ )
+ )
+ )
+ );
+
+ private $title = '';
+
+ public function collectData(){
+ /* MoinMoin uses a rather unpleasent representation of HTML. Instead of
+ * using tags like <article/>, <navigation/>, <header/>, etc... it uses
+ * <div/>, <span/> and <p/>. Also each line is literaly identified via
+ * IDs. The only way to distinguish content is via headers, though not
+ * in all cases.
+ *
+ * Example (indented for the sake of readability):
+ * ...
+ * <span class="anchor" id="line-1"></span>
+ * <span class="anchor" id="line-2"></span>
+ * <span class="anchor" id="line-3"></span>
+ * <span class="anchor" id="line-4"></span>
+ * <span class="anchor" id="line-5"></span>
+ * <span class="anchor" id="line-6"></span>
+ * <span class="anchor" id="line-7"></span>
+ * <span class="anchor" id="line-8"></span>
+ * <span class="anchor" id="line-9"></span>
+ * <p class="line867">MoinMoin is a Wiki software implemented in
+ * <a class="interwiki" href="/Python" title="MoinMoin">Python</a>
+ * and distributed as Free Software under
+ * <a class="interwiki" href="/GPL" title="MoinMoin">GNU GPL license</a>.
+ * ...
+ */
+ $html = getSimpleHTMLDOM($this->getInput('source'))
+ or returnServerError('Could not load ' . $this->getInput('source'));
+
+ // Some anchors link to local sites or local IDs (both don't work well
+ // in feeds)
+ $html = $this->fixAnchors($html);
+
+ $this->title = $html->find('title', 0)->innertext . ' | ' . self::NAME;
+
+ // Here we focus on simple author and timestamp information from the given
+ // page. Later we update this information in case the anchor is followed.
+ $author = $this->findAuthor($html);
+ $timestamp = $this->findTimestamp($html);
+
+ $sections = $this->splitSections($html);
+
+ foreach($sections as $section) {
+ $item = array();
+
+ $item['uri'] = $this->findSectionAnchor($section[0]);
+
+ switch($this->getInput('content')) {
+ case 'none': // Do not return any content
+ break;
+ case 'follow': // Follow the anchor
+ // We can only follow anchors (use default otherwise)
+ if($this->getInput('separator') === 'a') {
+ $content = $this->followAnchor($item['uri']);
+
+ // Return only actual content
+ $item['content'] = $content->find('div#page', 0)->innertext;
+
+ // Each page could have its own author and timestamp
+ $author = $this->findAuthor($content);
+ $timestamp = $this->findTimestamp($content);
+
+ break;
+ }
+ case 'separator':
+ default: // Use contents from the current page
+ $item['content'] = $this->cleanArticle($section[2]);
+ }
+
+ if(!is_null($author)) $item['author'] = $author;
+ if(!is_null($timestamp)) $item['timestamp'] = $timestamp;
+ $item['title'] = strip_tags($section[1]);
+
+ // Skip items with empty title
+ if(empty(trim($item['title']))) {
+ continue;
+ }
+
+ $this->items[] = $item;
+
+ if($this->getInput('limit') > 0
+ && count($this->items) >= $this->getInput('limit')) {
+ break;
+ }
+ }
+ }
+
+ public function getName(){
+ return $this->title ?: parent::getName();
+ }
+
+ public function getURI(){
+ return $this->getInput('source') ?: parent::getURI();
+ }
+
+ /**
+ * Splits the html into sections.
+ *
+ * Returns an array with one element per section. Each element consists of:
+ * [0] The entire section
+ * [1] The section title
+ * [2] The section content
+ */
+ private function splitSections($html){
+ $content = $html->find('div#page', 0)->innertext
+ or returnServerError('Unable to find <div id="page"/>!');
+
+ $sections = array();
+
+ $regex = implode(
+ '',
+ array(
+ "\<{$this->getInput('separator')}.+?(?=\>)\>",
+ "(.+?)(?=\<\/{$this->getInput('separator')}\>)",
+ "\<\/{$this->getInput('separator')}\>",
+ "(.+?)((?=\<{$this->getInput('separator')})|(?=\<div\sid=\"pagebottom\")){1}"
+ )
+ );
+
+ preg_match_all(
+ '/' . $regex . '/m',
+ $content,
+ $sections,
+ PREG_SET_ORDER
+ );
+
+ // Some pages don't use headers, return page as one feed
+ if(count($sections) === 0) {
+ return array(
+ array(
+ $content,
+ $html->find('title', 0)->innertext,
+ $content
+ )
+ );
+ }
+
+ return $sections;
+ }
+
+ /**
+ * Returns the anchor for a given section
+ */
+ private function findSectionAnchor($section){
+ $html = str_get_html($section);
+
+ // For IDs
+ $anchor = $html->find($this->getInput('separator') . '[id=]', 0);
+ if(!is_null($anchor)) {
+ return $this->getInput('source') . '#' . $anchor->id;
+ }
+
+ // For actual anchors
+ $anchor = $html->find($this->getInput('separator') . '[href=]', 0);
+ if(!is_null($anchor)) {
+ return $anchor->href;
+ }
+
+ // Nothing found
+ return $this->getInput('source');
+ }
+
+ /**
+ * Returns the author
+ *
+ * Notice: Some pages don't provide author information
+ */
+ private function findAuthor($html){
+ /* Example:
+ * <p id="pageinfo" class="info" dir="ltr" lang="en">MoinMoin: LocalSpellingWords
+ * (last edited 2017-02-16 15:36:31 by <span title="??? @ hosted-by.leaseweb.com
+ * [178.162.199.143]">hosted-by</span>)</p>
+ */
+ $pageinfo = $html->find('[id="pageinfo"]', 0);
+
+ if(is_null($pageinfo)) {
+ return null;
+ } else {
+ $author = $pageinfo->find('[title=]', 0);
+ if(is_null($author)) {
+ return null;
+ } else {
+ return trim(explode('@', $author->title)[0]);
+ }
+ }
+ }
+
+ /**
+ * Returns the time of last edit
+ *
+ * Notice: Some pages don't provide this information
+ */
+ private function findTimestamp($html){
+ // See example of findAuthor()
+ $pageinfo = $html->find('[id="pageinfo"]', 0);
+
+ if(is_null($pageinfo)) {
+ return null;
+ } else {
+ $timestamp = $pageinfo->innertext;
+ $matches = array();
+ preg_match('/.+?(?=\().+?(?=\d)([0-9\-\s\:]+)/m', $pageinfo, $matches);
+ return strtotime($matches[1]);
+ }
+ }
+
+ /**
+ * Returns the original HTML with all anchors fixed (makes relative anchors
+ * absolute)
+ */
+ private function fixAnchors($html, $source = null){
+
+ $source = $source ?: $this->getURI();
+
+ foreach($html->find('a') as $anchor) {
+ switch(substr($anchor->href, 0, 1)) {
+ case 'h': // http or https, no actions required
+ break;
+ case '/': // some relative path
+ $anchor->href = $this->findDomain($source) . $anchor->href;
+ break;
+ case '#': // it's an ID
+ default: // probably something like ? or &, skip empty ones
+ if(!isset($anchor->href))
+ break;
+ $anchor->href = $source . $anchor->href;
+ }
+ }
+
+ return $html;
+ }
+
+ /**
+ * Loads the full article of a given anchor (if the anchor is from the same
+ * wiki domain)
+ */
+ private function followAnchor($anchor){
+ if(strrpos($anchor, $this->findDomain($this->getInput('source')) === false)) {
+ return null;
+ }
+
+ $html = getSimpleHTMLDOMCached($anchor);
+ if(!$html) { // Cannot load article
+ return null;
+ }
+
+ return $this->fixAnchors($html, $anchor);
+ }
+
+ /**
+ * Finds the domain for a given URI
+ */
+ private function findDomain($uri){
+ $matches = array();
+ preg_match('/(http[s]{0,1}:\/\/.+?(?=\/))/', $uri, $matches);
+ return $matches[1];
+ }
+
+ /* This function is a copy from CNETBridge */
+ private function stripWithDelimiters($string, $start, $end){
+ while(strpos($string, $start) !== false) {
+ $section_to_remove = substr($string, strpos($string, $start));
+ $section_to_remove = substr($section_to_remove, 0, strpos($section_to_remove, $end) + strlen($end));
+ $string = str_replace($section_to_remove, '', $string);
+ }
+
+ return $string;
+ }
+
+ /* This function is based on CNETBridge */
+ private function cleanArticle($article_html){
+ $article_html = $this->stripWithDelimiters($article_html, '<script', '</script>');
+ return $article_html;
+ }
+}
diff --git a/bridges/MondeDiploBridge.php b/bridges/MondeDiploBridge.php
new file mode 100644
index 0000000..85f771e
--- /dev/null
+++ b/bridges/MondeDiploBridge.php
@@ -0,0 +1,26 @@
+<?php
+class MondeDiploBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Pitchoule';
+ const NAME = 'Monde Diplomatique';
+ const URI = 'http://www.monde-diplomatique.fr/';
+ const CACHE_TIMEOUT = 21600; //6h
+ const DESCRIPTION = 'Returns most recent results from MondeDiplo.';
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request MondeDiplo. for : ' . self::URI);
+
+ foreach($html->find('div.unarticle') as $article) {
+ $element = $article->parent();
+ $item = array();
+ $item['uri'] = self::URI . $element->href;
+ $item['title'] = $element->find('h3', 0)->plaintext;
+ $item['content'] = $element->find('div.dates_auteurs', 0)->plaintext
+ . '<br>'
+ . strstr($element->find('div', 0)->plaintext, $element->find('div.dates_auteurs', 0)->plaintext, true);
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/MozillaSecurityBridge.php b/bridges/MozillaSecurityBridge.php
new file mode 100644
index 0000000..0b951a1
--- /dev/null
+++ b/bridges/MozillaSecurityBridge.php
@@ -0,0 +1,28 @@
+<?php
+class MozillaSecurityBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'm0le.net';
+ const NAME = 'Mozilla Security Advisories';
+ const URI = 'https://www.mozilla.org/en-US/security/advisories/';
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'Mozilla Security Advisories';
+ const WEBROOT = 'https://www.mozilla.org';
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request MSA.');
+
+ $html = defaultLinkTo($html, self::WEBROOT);
+
+ $item = array();
+ $articles = $html->find('div[itemprop="articleBody"] h2');
+
+ foreach ($articles as $element) {
+ $item['title'] = $element->innertext;
+ $item['timestamp'] = strtotime($element->innertext);
+ $item['content'] = $element->next_sibling()->innertext;
+ $item['uri'] = self::URI;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/MsnMondeBridge.php b/bridges/MsnMondeBridge.php
new file mode 100644
index 0000000..12d3d2f
--- /dev/null
+++ b/bridges/MsnMondeBridge.php
@@ -0,0 +1,35 @@
+<?php
+class MsnMondeBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'kranack';
+ const NAME = 'MSN Actu Monde';
+ const URI = 'http://www.msn.com/';
+ const DESCRIPTION = 'Returns the 10 newest posts from MSN Actualités (full text)';
+
+ public function getURI(){
+ return self::URI . 'fr-fr/actualite/monde';
+ }
+
+ private function msnMondeExtractContent($url, &$item){
+ $html2 = getSimpleHTMLDOM($url);
+ $item['content'] = $html2->find('#content', 0)->find('article', 0)->find('section', 0)->plaintext;
+ $item['timestamp'] = strtotime($html2->find('.authorinfo-txt', 0)->find('time', 0)->datetime);
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request MsnMonde.');
+
+ $limit = 0;
+ foreach($html->find('.smalla') as $article) {
+ if($limit < 10) {
+ $item = array();
+ $item['title'] = utf8_decode($article->find('h4', 0)->innertext);
+ $item['uri'] = self::URI . utf8_decode($article->find('a', 0)->href);
+ $this->msnMondeExtractContent($item['uri'], $item);
+ $this->items[] = $item;
+ $limit++;
+ }
+ }
+ }
+}
diff --git a/bridges/MspabooruBridge.php b/bridges/MspabooruBridge.php
new file mode 100644
index 0000000..00a7bd7
--- /dev/null
+++ b/bridges/MspabooruBridge.php
@@ -0,0 +1,12 @@
+<?php
+require_once('GelbooruBridge.php');
+
+class MspabooruBridge extends GelbooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Mspabooru';
+ const URI = 'http://mspabooru.com/';
+ const DESCRIPTION = 'Returns images from given page';
+ const PIDBYPAGE = 50;
+
+}
diff --git a/bridges/MydealsBridge.php b/bridges/MydealsBridge.php
new file mode 100644
index 0000000..aa5bb48
--- /dev/null
+++ b/bridges/MydealsBridge.php
@@ -0,0 +1,142 @@
+<?php
+
+require_once(__DIR__ . '/DealabsBridge.php');
+class MydealsBridge extends PepperBridgeAbstract {
+
+ const NAME = 'Mydeals bridge';
+ const URI = 'https://www.mydealz.de/';
+ const DESCRIPTION = 'Zeigt die Deals von mydeals.de';
+ const MAINTAINER = 'sysadminstory';
+ const PARAMETERS = array(
+ 'Suche nach Stichworten' => array (
+ 'q' => array(
+ 'name' => 'Stichworten',
+ 'type' => 'text',
+ 'required' => true
+ ),
+ 'hide_expired' => array(
+ 'name' => 'Abgelaufenes ausblenden',
+ 'type' => 'checkbox',
+ 'required' => true
+ ),
+ 'hide_local' => array(
+ 'name' => 'Lokales ausblenden',
+ 'type' => 'checkbox',
+ 'title' => 'Deals im physischen Geschäft ausblenden',
+ 'required' => true
+ ),
+ 'priceFrom' => array(
+ 'name' => 'Minimaler Preis',
+ 'type' => 'text',
+ 'title' => 'Minmaler Preis in Euros',
+ 'required' => false
+ ),
+ 'priceTo' => array(
+ 'name' => 'Maximaler Preis',
+ 'type' => 'text',
+ 'title' => 'maximaler Preis in Euro',
+ 'required' => false
+ ),
+ ),
+
+ 'Deals pro Gruppen' => array(
+ 'group' => array(
+ 'name' => 'Gruppen',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Gruppe, deren Deals angezeigt werden müssen',
+ 'values' => array(
+ 'Elektronik' => 'elektronik',
+ 'Handy & Smartphone' => 'smartphone',
+ 'Gaming' => 'gaming',
+ 'Software' => 'apps-software',
+ 'Fashion Frauen' => 'fashion-frauen',
+ 'Fashion Männer' => 'fashion-accessoires',
+ 'Beauty & Gesundheit' => 'beauty',
+ 'Family & Kids' => 'family-kids',
+ 'Essen & Trinken' => 'food',
+ 'Freizeit & Reisen' => 'reisen',
+ 'Haushalt & Garten' => 'home-living',
+ 'Entertainment' => 'entertainment',
+ 'Verträge & Finanzen' => 'vertraege-finanzen',
+ 'Coupons' => 'coupons',
+
+ )
+ ),
+ 'order' => array(
+ 'name' => 'sortieren nach',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Sortierung der deals',
+ 'values' => array(
+ 'Vom heißesten zum kältesten Deal' => '',
+ 'Vom jüngsten Deal zum ältesten' => '-new',
+ 'Vom am meisten kommentierten Deal zum am wenigsten kommentierten Deal' => '-discussed'
+ )
+ )
+ )
+ );
+
+ public $lang = array(
+ 'bridge-uri' => SELF::URI,
+ 'bridge-name' => SELF::NAME,
+ 'context-keyword' => 'Suche nach Stichworten',
+ 'context-group' => 'Deals pro Gruppen',
+ 'uri-group' => '/gruppe/',
+ 'request-error' => 'Could not request mydeals',
+ 'no-results' => 'Ups, wir konnten keine Deals zu',
+ 'relative-date-indicator' => array(
+ 'vor',
+ 'seit'
+ ),
+ 'price' => 'Preis',
+ 'shipping' => 'Versand',
+ 'origin' => 'Ursprung',
+ 'discount' => 'Rabatte',
+ 'title-keyword' => 'Suche',
+ 'title-group' => 'Gruppe',
+ 'local-months' => array(
+ 'Jan',
+ 'Feb',
+ 'Mär',
+ 'Apr',
+ 'Mai',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Okt',
+ 'Nov',
+ 'Dez',
+ '.'
+ ),
+ 'local-time-relative' => array(
+ 'eingestellt vor ',
+ 'm',
+ 'h,',
+ 'day',
+ 'days',
+ 'month',
+ 'year',
+ 'and '
+ ),
+ 'date-prefixes' => array(
+ 'eingestellt am ',
+ 'lokal ',
+ 'aktualisiert ',
+ ),
+ 'relative-date-alt-prefixes' => array(
+ 'aktualisiert vor ',
+ 'kommentiert vor ',
+ 'heiß seit '
+ ),
+ 'relative-date-ignore-suffix' => array(
+ '/von.*$/'
+ ),
+ 'localdeal' => array(
+ 'Lokal ',
+ 'Läuft bis '
+ )
+ );
+
+}
diff --git a/bridges/N26Bridge.php b/bridges/N26Bridge.php
new file mode 100644
index 0000000..dd1c423
--- /dev/null
+++ b/bridges/N26Bridge.php
@@ -0,0 +1,37 @@
+<?php
+
+class N26Bridge extends BridgeAbstract
+{
+ const MAINTAINER = 'quentinus95';
+ const NAME = 'N26 Blog';
+ const URI = 'https://n26.com';
+ const CACHE_TIMEOUT = 1800;
+ const DESCRIPTION = 'Returns recent blog posts from N26.';
+
+ public function getIcon()
+ {
+ return 'https://n26.com/favicon.ico';
+ }
+
+ public function collectData()
+ {
+ $html = getSimpleHTMLDOM(self::URI . '/en-fr/blog-archive')
+ or returnServerError('Error while downloading the website content');
+
+ foreach($html->find('div.ga') as $article) {
+ $item = [];
+
+ $item['uri'] = self::URI . $article->find('h2 a', 0)->href;
+ $item['title'] = $article->find('h2 a', 0)->plaintext;
+
+ $fullArticle = getSimpleHTMLDOM($item['uri'])
+ or returnServerError('Error while downloading the full article');
+
+ $dateElement = $fullArticle->find('span[class="fk fl de ch fm by"]', 0);
+ $item['timestamp'] = strtotime($dateElement->plaintext);
+ $item['content'] = $fullArticle->find('main article', 0)->innertext;
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/NasaApodBridge.php b/bridges/NasaApodBridge.php
new file mode 100644
index 0000000..8e293e0
--- /dev/null
+++ b/bridges/NasaApodBridge.php
@@ -0,0 +1,44 @@
+<?php
+class NasaApodBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'corenting';
+ const NAME = 'NASA APOD Bridge';
+ const URI = 'https://apod.nasa.gov/apod/';
+ const CACHE_TIMEOUT = 43200; // 12h
+ const DESCRIPTION = 'Returns the 3 latest NASA APOD pictures and explanations';
+
+ public function collectData(){
+
+ $html = getSimpleHTMLDOM(self::URI . 'archivepix.html')
+ or returnServerError('Error while downloading the website content');
+
+ $list = explode('<br>', $html->find('b', 0)->innertext);
+
+ for($i = 0; $i < 3; $i++) {
+ $line = $list[$i];
+ $item = array();
+
+ $uri_page = $html->find('a', $i + 3)->href;
+ $uri = self::URI . $uri_page;
+ $item['uri'] = $uri;
+
+ $picture_html = getSimpleHTMLDOM($uri);
+ $picture_html_string = $picture_html->innertext;
+
+ //Extract image and explanation
+ $media = $picture_html->find('p', 1)->innertext;
+ $media = strstr($media, '<br>');
+ $media = preg_replace('/<br>/', '', $media, 1);
+ $explanation = $picture_html->find('p', 2)->innertext;
+
+ //Extract date from the picture page
+ $date = explode(' ', $picture_html->find('p', 1)->innertext);
+ $item['timestamp'] = strtotime($date[4] . $date[3] . $date[2]);
+
+ //Other informations
+ $item['content'] = $media . '<br />' . $explanation;
+ $item['title'] = $picture_html->find('b', 0)->innertext;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/NeuviemeArtBridge.php b/bridges/NeuviemeArtBridge.php
new file mode 100644
index 0000000..8c5bb70
--- /dev/null
+++ b/bridges/NeuviemeArtBridge.php
@@ -0,0 +1,47 @@
+<?php
+class NeuviemeArtBridge extends FeedExpander {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = '9ème Art Bridge';
+ const URI = 'http://www.9emeart.fr/';
+ const DESCRIPTION = 'Returns the newest articles.';
+
+ protected function parseItem($item){
+ $item = parent::parseItem($item);
+
+ $article_html = getSimpleHTMLDOMCached($item['uri']);
+ if(!$article_html) {
+ $item['content'] = 'Could not request 9eme Art: ' . $item['uri'];
+ return $item;
+ }
+
+ $article_image = '';
+ foreach ($article_html->find('img.img_full') as $img) {
+ if ($img->alt == $item['title']) {
+ $article_image = self::URI . $img->src;
+ break;
+ }
+ }
+
+ $article_content = '';
+ if ($article_image) {
+ $article_content = '<p><img src="' . $article_image . '" /></p>';
+ }
+ $article_content .= str_replace(
+ 'src="/', 'src="' . self::URI,
+ $article_html->find('div.newsGenerique_con', 0)->innertext
+ );
+ $article_content = stripWithDelimiters($article_content, '<script', '</script>');
+ $article_content = stripWithDelimiters($article_content, '<style', '</style>');
+ $article_content = stripWithDelimiters($article_content, '<link', '>');
+
+ $item['content'] = $article_content;
+
+ return $item;
+ }
+
+ public function collectData(){
+ $feedUrl = self::URI . '9emeart.rss';
+ $this->collectExpandableDatas($feedUrl);
+ }
+}
diff --git a/bridges/NextInpactBridge.php b/bridges/NextInpactBridge.php
new file mode 100644
index 0000000..c6bf2f5
--- /dev/null
+++ b/bridges/NextInpactBridge.php
@@ -0,0 +1,110 @@
+<?php
+class NextInpactBridge extends FeedExpander {
+
+ const MAINTAINER = 'qwertygc';
+ const NAME = 'NextInpact Bridge';
+ const URI = 'https://www.nextinpact.com/';
+ const DESCRIPTION = 'Returns the newest articles.';
+
+ const PARAMETERS = array( array(
+ 'feed' => array(
+ 'name' => 'Feed',
+ 'type' => 'list',
+ 'values' => array(
+ 'Tous nos articles' => 'news',
+ 'Nos contenus en accès libre' => 'acces-libre',
+ 'Blog' => 'blog',
+ 'Bons plans' => 'bonsplans'
+ )
+ ),
+ 'filter_premium' => array(
+ 'name' => 'Premium',
+ 'type' => 'list',
+ 'values' => array(
+ 'No filter' => '0',
+ 'Hide Premium' => '1',
+ 'Only Premium' => '2'
+ )
+ ),
+ 'filter_brief' => array(
+ 'name' => 'Brief',
+ 'type' => 'list',
+ 'values' => array(
+ 'No filter' => '0',
+ 'Hide Brief' => '1',
+ 'Only Brief' => '2'
+ )
+ )
+ ));
+
+ public function collectData(){
+ $feed = $this->getInput('feed');
+ if (empty($feed))
+ $feed = 'news';
+ $this->collectExpandableDatas(self::URI . 'rss/' . $feed . '.xml');
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+ $item['content'] = $this->extractContent($item, $item['uri']);
+ if (is_null($item['content']))
+ return null; //Filtered article
+ return $item;
+ }
+
+ private function extractContent($item, $url){
+ $html = getSimpleHTMLDOMCached($url);
+ if (!is_object($html))
+ return 'Failed to request NextInpact: ' . $url;
+
+ foreach(array(
+ 'filter_premium' => 'h2.title_reserve_article',
+ 'filter_brief' => 'div.brief-inner-content'
+ ) as $param_name => $selector) {
+ $param_val = intval($this->getInput($param_name));
+ if ($param_val != 0) {
+ $element_present = is_object($html->find($selector, 0));
+ $element_wanted = ($param_val == 2);
+ if ($element_present != $element_wanted) {
+ return null; //Filter article
+ }
+ }
+ }
+
+ if (is_object($html->find('div[itemprop=articleBody], div.brief-inner-content', 0))) {
+
+ $subtitle = trim($html->find('span.sub_title, div.brief-head', 0));
+ if(is_object($subtitle) && $subtitle->plaintext !== $item['title']) {
+ $subtitle = '<p><em>' . $subtitle->plaintext . '</em></p>';
+ } else {
+ $subtitle = '';
+ }
+
+ $postimg = $html->find(
+ 'div.container_main_image_article, div.image-brief-container, div.image-brief-side-container', 0
+ );
+ if(is_object($postimg)) {
+ $postimg = '<p><img src="'
+ . $postimg->find('img.dedicated', 0)->src
+ . '" alt="-" /></p>';
+ } else {
+ $postimg = '';
+ }
+
+ $text = $subtitle
+ . $postimg
+ . $html->find('div[itemprop=articleBody], div.brief-inner-content', 0)->outertext;
+
+ } else {
+ $text = $item['content']
+ . '<p><em>Failed retrieve full article content</em></p>';
+ }
+
+ $premium_article = $html->find('h2.title_reserve_article', 0);
+ if (is_object($premium_article)) {
+ $text .= '<p><em>' . $premium_article->innertext . '</em></p>';
+ }
+
+ return $text;
+ }
+}
diff --git a/bridges/NextgovBridge.php b/bridges/NextgovBridge.php
new file mode 100644
index 0000000..74bfc54
--- /dev/null
+++ b/bridges/NextgovBridge.php
@@ -0,0 +1,70 @@
+<?php
+class NextgovBridge extends FeedExpander {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'Nextgov Bridge';
+ const URI = 'https://www.nextgov.com/';
+ const DESCRIPTION = 'USA Federal technology news, best practices, and web 2.0 tools.';
+
+ const PARAMETERS = array( array(
+ 'category' => array(
+ 'name' => 'Category',
+ 'type' => 'list',
+ 'values' => array(
+ 'All' => 'all',
+ 'Technology News' => 'technology-news',
+ 'CIO Briefing' => 'cio-briefing',
+ 'Emerging Tech' => 'emerging-tech',
+ 'Cloud' => 'cloud-computing',
+ 'Cybersecurity' => 'cybersecurity',
+ 'Mobile' => 'mobile',
+ 'Health' => 'health',
+ 'Defense' => 'defense',
+ 'Big Data' => 'big-data'
+ )
+ )
+ ));
+
+ public function collectData(){
+ $this->collectExpandableDatas(self::URI . 'rss/' . $this->getInput('category') . '/', 10);
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+
+ $article_thumbnail = 'https://cdn.nextgov.com/nextgov/images/logo.png';
+ $item['content'] = '<p><b>' . $item['content'] . '</b></p>';
+
+ $namespaces = $newsItem->getNamespaces(true);
+ if(isset($namespaces['media'])) {
+ $media = $newsItem->children($namespaces['media']);
+ if(isset($media->content)) {
+ $attributes = $media->content->attributes();
+ $item['content'] = '<p><img src="' . $attributes['url'] . '"></p>' . $item['content'];
+ $article_thumbnail = str_replace(
+ 'large.jpg',
+ 'small.jpg',
+ strval($attributes['url'])
+ );
+ }
+ }
+
+ $item['enclosures'] = array($article_thumbnail);
+ $item['content'] .= $this->extractContent($item['uri']);
+ return $item;
+ }
+
+ private function extractContent($url){
+ $article = getSimpleHTMLDOMCached($url);
+
+ if (!is_object($article))
+ return 'Could not request Nextgov: ' . $url;
+
+ $contents = $article->find('div.wysiwyg', 0);
+ $contents->find('svg.content-tombstone', 0)->outertext = '';
+ $contents = $contents->innertext;
+ $contents = stripWithDelimiters($contents, '<div class="ad-container">', '</div>');
+ $contents = stripWithDelimiters($contents, '<div', '</div>'); //ad outer div
+ return trim(stripWithDelimiters($contents, '<script', '</script>'));
+ }
+}
diff --git a/bridges/NiceMatinBridge.php b/bridges/NiceMatinBridge.php
new file mode 100644
index 0000000..117c779
--- /dev/null
+++ b/bridges/NiceMatinBridge.php
@@ -0,0 +1,32 @@
+<?php
+class NiceMatinBridge extends FeedExpander {
+
+ const MAINTAINER = 'pit-fgfjiudghdf';
+ const NAME = 'NiceMatin';
+ const URI = 'http://www.nicematin.com/';
+ const DESCRIPTION = 'Returns the 10 newest posts from NiceMatin (full text)';
+
+ public function collectData(){
+ $this->collectExpandableDatas(self::URI . 'derniere-minute/rss', 10);
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+ $item['content'] = $this->extractContent($item['uri']);
+ return $item;
+ }
+
+ private function extractContent($url){
+ $html = getSimpleHTMLDOMCached($url);
+ if(!$html)
+ return 'Could not acquire content from url: ' . $url . '!';
+
+ $content = $html->find('article', 0);
+ if(!$content)
+ return 'Could not find \'section\'!';
+
+ $text = preg_replace('#<script(.*?)>(.*?)</script>#is', '', $content->innertext);
+ $text = strip_tags($text, '<p><a><img>');
+ return $text;
+ }
+}
diff --git a/bridges/NineGagBridge.php b/bridges/NineGagBridge.php
new file mode 100644
index 0000000..f526135
--- /dev/null
+++ b/bridges/NineGagBridge.php
@@ -0,0 +1,331 @@
+<?php
+
+class NineGagBridge extends BridgeAbstract {
+ const NAME = '9gag Bridge';
+ const URI = 'https://9gag.com/';
+ const DESCRIPTION = 'Returns latest quotes from 9gag.';
+ const MAINTAINER = 'ZeNairolf';
+ const CACHE_TIMEOUT = 3600;
+ const PARAMETERS = array(
+ 'Popular' => array(
+ 'd' => array(
+ 'name' => 'Section',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'Hot' => 'hot',
+ 'Trending' => 'trending',
+ 'Fresh' => 'fresh',
+ ),
+ ),
+ 'p' => array(
+ 'name' => 'Pages',
+ 'type' => 'number',
+ 'defaultValue' => 3,
+ ),
+ ),
+ 'Sections' => array(
+ 'g' => array(
+ 'name' => 'Section',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'Animals' => 'cute',
+ 'Anime & Manga' => 'anime-manga',
+ 'Ask 9GAG' => 'ask9gag',
+ 'Awesome' => 'awesome',
+ 'Basketball' => 'basketball',
+ 'Car' => 'car',
+ 'Classical Art Memes' => 'classicalartmemes',
+ 'Comic' => 'comic',
+ 'Cosplay' => 'cosplay',
+ 'Countryballs' => 'country',
+ 'DIY & Crafts' => 'imadedis',
+ 'Drawing & Illustration' => 'drawing',
+ 'Fan Art' => 'animefanart',
+ 'Food & Drinks' => 'food',
+ 'Football' => 'football',
+ 'Fortnite' => 'fortnite',
+ 'Funny' => 'funny',
+ 'GIF' => 'gif',
+ 'Gaming' => 'gaming',
+ 'Girl' => 'girl',
+ 'Girly Things' => 'girly',
+ 'Guy' => 'guy',
+ 'History' => 'history',
+ 'Home Design' => 'home',
+ 'Horror' => 'horror',
+ 'K-Pop' => 'kpop',
+ 'LEGO' => 'lego',
+ 'League of Legends' => 'leagueoflegends',
+ 'Movie & TV' => 'movie-tv',
+ 'Music' => 'music',
+ 'NFK - Not For Kids' => 'nsfw',
+ 'Overwatch' => 'overwatch',
+ 'PC Master Race' => 'pcmr',
+ 'PUBG' => 'pubg',
+ 'Pic Of The Day' => 'photography',
+ 'Pokémon' => 'pokemon',
+ 'Politics' => 'politics',
+ 'Relationship' => 'relationship',
+ 'Roast Me' => 'roastme',
+ 'Satisfying' => 'satisfying',
+ 'Savage' => 'savage',
+ 'School' => 'school',
+ 'Sci-Tech' => 'science',
+ 'Sport' => 'sport',
+ 'Star Wars' => 'starwars',
+ 'Superhero' => 'superhero',
+ 'Surreal Memes' => 'surrealmemes',
+ 'Timely' => 'timely',
+ 'Travel' => 'travel',
+ 'Video' => 'video',
+ 'WTF' => 'wtf',
+ 'Wallpaper' => 'wallpaper',
+ 'Warhammer' => 'warhammer',
+ ),
+ ),
+ 't' => array(
+ 'name' => 'Type',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'Hot' => 'hot',
+ 'Fresh' => 'fresh',
+ ),
+ ),
+ 'p' => array(
+ 'name' => 'Pages',
+ 'type' => 'number',
+ 'defaultValue' => 3,
+ ),
+ ),
+ );
+
+ const MIN_NBR_PAGE = 1;
+ const MAX_NBR_PAGE = 6;
+
+ protected $p = null;
+
+ public function collectData() {
+ $url = sprintf(
+ '%sv1/group-posts/group/%s/type/%s?',
+ self::URI,
+ $this->getGroup(),
+ $this->getType()
+ );
+ $cursor = 'c=10';
+ $posts = array();
+ for ($i = 0; $i < $this->getPages(); ++$i) {
+ $content = getContents($url . $cursor);
+ $json = json_decode($content, true);
+ $posts = array_merge($posts, $json['data']['posts']);
+ $cursor = $json['data']['nextCursor'];
+ }
+
+ foreach ($posts as $post) {
+ $item['uri'] = $post['url'];
+ $item['title'] = $post['title'];
+ $item['content'] = self::getContent($post);
+ $item['categories'] = self::getCategories($post);
+ $item['timestamp'] = self::getTimestamp($post);
+
+ $this->items[] = $item;
+ }
+ }
+
+ public function getName() {
+ if ($this->getInput('d')) {
+ $name = sprintf('%s - %s', '9GAG', $this->getParameterKey('d'));
+ } elseif ($this->getInput('g')) {
+ $name = sprintf('%s - %s', '9GAG', $this->getParameterKey('g'));
+ if ($this->getInput('t')) {
+ $name = sprintf('%s [%s]', $name, $this->getParameterKey('t'));
+ }
+ }
+ if (!empty($name)) {
+ return $name;
+ }
+
+ return self::NAME;
+ }
+
+ public function getURI() {
+ $uri = $this->getInput('g');
+ if ($uri === 'default') {
+ $uri = $this->getInput('t');
+ }
+
+ return self::URI . $uri;
+ }
+
+ protected function getGroup() {
+ if ($this->getInput('d')) {
+ return 'default';
+ }
+
+ return $this->getInput('g');
+ }
+
+ protected function getType() {
+ if ($this->getInput('d')) {
+ return $this->getInput('d');
+ }
+
+ return $this->getInput('t');
+ }
+
+ protected function getPages() {
+ if ($this->p === null) {
+ $value = (int) $this->getInput('p');
+ $value = ($value < self::MIN_NBR_PAGE) ? self::MIN_NBR_PAGE : $value;
+ $value = ($value > self::MAX_NBR_PAGE) ? self::MAX_NBR_PAGE : $value;
+
+ $this->p = $value;
+ }
+
+ return $this->p;
+ }
+
+ protected function getParameterKey($input = '') {
+ $params = $this->getParameters();
+ $tab = 'Sections';
+ if ($input === 'd') {
+ $tab = 'Popular';
+ }
+ if (!isset($params[$tab][$input])) {
+ return '';
+ }
+
+ return array_search(
+ $this->getInput($input),
+ $params[$tab][$input]['values']
+ );
+ }
+
+ protected static function getContent($post) {
+ if ($post['type'] === 'Animated') {
+ $content = self::getAnimated($post);
+ } elseif ($post['type'] === 'Article') {
+ $content = self::getArticle($post);
+ } else {
+ $content = self::getPhoto($post);
+ }
+
+ return $content;
+ }
+
+ protected static function getPhoto($post) {
+ $image = $post['images']['image460'];
+ $photo = '<picture>';
+ $photo .= sprintf(
+ '<source srcset="%s" type="image/webp">',
+ $image['webpUrl']
+ );
+ $photo .= sprintf(
+ '<img src="%s" alt="%s" %s>',
+ $image['url'],
+ $post['title'],
+ 'width="500"'
+ );
+ $photo .= '</picture>';
+
+ return $photo;
+ }
+
+ protected static function getAnimated($post) {
+ $poster = $post['images']['image460']['url'];
+ $sources = $post['images'];
+ $video = sprintf(
+ '<video poster="%s" %s>',
+ $poster,
+ 'preload="auto" loop controls style="min-height: 300px" width="500"'
+ );
+ $video .= sprintf(
+ '<source src="%s" type="video/webm">',
+ $sources['image460sv']['vp9Url']
+ );
+ $video .= sprintf(
+ '<source src="%s" type="video/mp4">',
+ $sources['image460sv']['h265Url']
+ );
+ $video .= sprintf(
+ '<source src="%s" type="video/mp4">',
+ $sources['image460svwm']['url']
+ );
+ $video .= '</video>';
+
+ return $video;
+ }
+
+ protected static function getArticle($post) {
+ $blocks = $post['article']['blocks'];
+ $medias = $post['article']['medias'];
+ $contents = array();
+ foreach ($blocks as $block) {
+ if ('Media' === $block['type']) {
+ $mediaId = $block['mediaId'];
+ $contents[] = self::getContent($medias[$mediaId]);
+ } elseif ('RichText' === $block['type']) {
+ $contents[] = self::getRichText($block['content']);
+ }
+ }
+
+ $content = join('</div><div>', $contents);
+ $content = sprintf(
+ '<%1$s>%2$s</%1$s>',
+ 'div',
+ $content
+ );
+
+ return $content;
+ }
+
+ protected static function getRichText($text = '') {
+ $text = trim($text);
+
+ if (preg_match('/^>\s(?<text>.*)/', $text, $matches)) {
+ $text = sprintf(
+ '<%1$s>%2$s</%1$s>',
+ 'blockquote',
+ $matches['text']
+ );
+ } else {
+ $text = sprintf(
+ '<%1$s>%2$s</%1$s>',
+ 'p',
+ $text
+ );
+ }
+
+ return $text;
+ }
+
+ protected static function getCategories($post) {
+ $params = self::PARAMETERS;
+ $sections = $params['Sections']['g']['values'];
+
+ if(isset($post['sections'])) {
+ $postSections = $post['sections'];
+ } elseif (isset($post['postSection'])) {
+ $postSections = array($post['postSection']);
+ } else {
+ $postSections = array();
+ }
+
+ foreach ($postSections as $key => $section) {
+ $postSections[$key] = array_search($section, $sections);
+ }
+
+ return $postSections;
+ }
+
+ protected static function getTimestamp($post) {
+ $url = $post['images']['image460']['url'];
+ $headers = get_headers($url, true);
+ $date = $headers['Date'];
+ $time = strtotime($date);
+
+ return $time;
+ }
+}
diff --git a/bridges/NotAlwaysBridge.php b/bridges/NotAlwaysBridge.php
new file mode 100644
index 0000000..b2f4c35
--- /dev/null
+++ b/bridges/NotAlwaysBridge.php
@@ -0,0 +1,61 @@
+<?php
+class NotAlwaysBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'mozes';
+ const NAME = 'Not Always family Bridge';
+ const URI = 'https://notalwaysright.com/';
+ const DESCRIPTION = 'Returns the latest stories';
+ const CACHE_TIMEOUT = 1800; // 30 minutes
+
+ const PARAMETERS = array( array(
+ 'filter' => array(
+ 'type' => 'list',
+ 'name' => 'Filter',
+ 'values' => array(
+ 'All' => 'all',
+ 'Right' => 'right',
+ 'Working' => 'working',
+ 'Romantic' => 'romantic',
+ 'Related' => 'related',
+ 'Learning' => 'learning',
+ 'Friendly' => 'friendly',
+ 'Hopeless' => 'hopeless',
+ 'Unfiltered' => 'unfiltered'
+ ),
+ 'required' => true
+ )
+ ));
+
+ public function getIcon() {
+ return self::URI . 'favicon_nar.png';
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request NotAlways.');
+ foreach($html->find('.post') as $post) {
+ #print_r($post);
+ $item = array();
+ $item['uri'] = $post->find('h1', 0)->find('a', 0)->href;
+ $item['content'] = $post;
+ $item['title'] = $post->find('h1', 0)->find('a', 0)->innertext;
+ $this->items[] = $item;
+ }
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('filter'))) {
+ return $this->getInput('filter') . ' - NotAlways Bridge';
+ }
+
+ return parent::getName();
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('filter'))) {
+ return self::URI . $this->getInput('filter') . '/';
+ }
+
+ return parent::getURI();
+ }
+}
diff --git a/bridges/NovelUpdatesBridge.php b/bridges/NovelUpdatesBridge.php
new file mode 100644
index 0000000..729eb48
--- /dev/null
+++ b/bridges/NovelUpdatesBridge.php
@@ -0,0 +1,69 @@
+<?php
+class NovelUpdatesBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'albirew';
+ const NAME = 'Novel Updates';
+ const URI = 'http://www.novelupdates.com/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Returns releases from Novel Updates';
+ const PARAMETERS = array( array(
+ 'n' => array(
+ 'name' => 'Novel name as found in the url',
+ 'exampleValue' => 'spirit-realm',
+ 'required' => true
+ )
+ ));
+
+ private $seriesTitle = '';
+
+ public function getURI(){
+ if(!is_null($this->getInput('n'))) {
+ return static::URI . '/series/' . $this->getInput('n') . '/';
+ }
+
+ return parent::getURI();
+ }
+
+ public function collectData(){
+ $fullhtml = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request NovelUpdates, novel "' . $this->getInput('n') . '" not found');
+
+ $this->seriesTitle = $fullhtml->find('h4.seriestitle', 0)->plaintext;
+ // dirty fix for nasty simpledom bug: https://github.com/sebsauvage/rss-bridge/issues/259
+ // forcefully removes tbody
+ $html = $fullhtml->find('table#myTable', 0)->innertext;
+ $html = stristr($html, '<tbody>'); //strip thead
+ $html = stristr($html, '<tr>'); //remove tbody
+ $html = str_get_html(stristr($html, '</tbody>', true)); //remove last tbody and get back as an array
+ foreach($html->find('tr') as $element) {
+ $item = array();
+ $item['uri'] = $element->find('td', 2)->find('a', 0)->href;
+ $item['title'] = $element->find('td', 2)->find('a', 0)->plaintext;
+ $item['team'] = $element->find('td', 1)->innertext;
+ $item['timestamp'] = strtotime($element->find('td', 0)->plaintext);
+ $item['content'] = '<a href="'
+ . $item['uri']
+ . '">'
+ . $this->seriesTitle
+ . ' - '
+ . $item['title']
+ . '</a> by '
+ . $item['team']
+ . '<br><a href="'
+ . $item['uri']
+ . '">'
+ . $fullhtml->find('div.seriesimg', 0)->innertext
+ . '</a>';
+
+ $this->items[] = $item;
+ }
+ }
+
+ public function getName(){
+ if(!empty($this->seriesTitle)) {
+ return $this->seriesTitle . ' - ' . static::NAME;
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/NyaaTorrentsBridge.php b/bridges/NyaaTorrentsBridge.php
new file mode 100644
index 0000000..b40b0f9
--- /dev/null
+++ b/bridges/NyaaTorrentsBridge.php
@@ -0,0 +1,131 @@
+<?php
+class NyaaTorrentsBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'NyaaTorrents';
+ const URI = 'https://nyaa.si/';
+ const DESCRIPTION = 'Returns the newest torrents, with optional search criteria.';
+ const PARAMETERS = array(
+ array(
+ 'f' => array(
+ 'name' => 'Filter',
+ 'type' => 'list',
+ 'values' => array(
+ 'No filter' => '0',
+ 'No remakes' => '1',
+ 'Trusted only' => '2'
+ )
+ ),
+ 'c' => array(
+ 'name' => 'Category',
+ 'type' => 'list',
+ 'values' => array(
+ 'All categories' => '0_0',
+ 'Anime' => '1_0',
+ 'Anime - AMV' => '1_1',
+ 'Anime - English' => '1_2',
+ 'Anime - Non-English' => '1_3',
+ 'Anime - Raw' => '1_4',
+ 'Audio' => '2_0',
+ 'Audio - Lossless' => '2_1',
+ 'Audio - Lossy' => '2_2',
+ 'Literature' => '3_0',
+ 'Literature - English' => '3_1',
+ 'Literature - Non-English' => '3_2',
+ 'Literature - Raw' => '3_3',
+ 'Live Action' => '4_0',
+ 'Live Action - English' => '4_1',
+ 'Live Action - Idol/PV' => '4_2',
+ 'Live Action - Non-English' => '4_3',
+ 'Live Action - Raw' => '4_4',
+ 'Pictures' => '5_0',
+ 'Pictures - Graphics' => '5_1',
+ 'Pictures - Photos' => '5_2',
+ 'Software' => '6_0',
+ 'Software - Apps' => '6_1',
+ 'Software - Games' => '6_2',
+ )
+ ),
+ 'q' => array(
+ 'name' => 'Keyword',
+ 'description' => 'Keyword(s)',
+ 'type' => 'text'
+ )
+ )
+ );
+
+ public function getIcon() {
+ return self::URI . 'static/favicon.png';
+ }
+
+ public function collectData() {
+
+ // Build Search URL from user-provided parameters
+ $search_url = self::URI . '?s=id&o=desc&'
+ . http_build_query(array(
+ 'f' => $this->getInput('f'),
+ 'c' => $this->getInput('c'),
+ 'q' => $this->getInput('q')
+ ));
+
+ // Retrieve torrent listing from search results, which does not contain torrent description
+ $html = getSimpleHTMLDOM($search_url)
+ or returnServerError('Could not request Nyaa: ' . $search_url);
+ $links = $html->find('a');
+ $results = array();
+ foreach ($links as $link)
+ if (strpos($link->href, '/view/') === 0 && !in_array($link->href, $results))
+ $results[] = $link->href;
+ if (empty($results) && empty($this->getInput('q')))
+ returnServerError('No results from Nyaa: ' . $url, 500);
+
+ //Process each item individually
+ foreach ($results as $element) {
+
+ //Limit total amount of requests
+ if(count($this->items) >= 20) {
+ break;
+ }
+
+ $torrent_id = str_replace('/view/', '', $element);
+
+ //Ignore entries without valid torrent ID
+ if ($torrent_id != 0 && ctype_digit($torrent_id)) {
+
+ //Retrieve data for this torrent ID
+ $item_uri = self::URI . 'view/' . $torrent_id;
+
+ //Retrieve full description from torrent page
+ if ($item_html = getSimpleHTMLDOMCached($item_uri)) {
+
+ //Retrieve data from page contents
+ $item_title = str_replace(' :: Nyaa', '', $item_html->find('title', 0)->plaintext);
+ $item_desc = str_get_html(markdownToHtml($item_html->find('#torrent-description', 0)->innertext));
+ $item_author = extractFromDelimiters($item_html->outertext, 'href="/user/', '"');
+ $item_date = intval(extractFromDelimiters($item_html->outertext, 'data-timestamp="', '"'));
+
+ //Retrieve image for thumbnail or generic logo fallback
+ $item_image = $this->getURI() . 'static/img/avatar/default.png';
+ foreach ($item_desc->find('img') as $img) {
+ if (strpos($img->src, 'prez') === false) {
+ $item_image = $img->src;
+ break;
+ }
+ }
+
+ //Build and add final item
+ $item = array();
+ $item['uri'] = $item_uri;
+ $item['title'] = $item_title;
+ $item['author'] = $item_author;
+ $item['timestamp'] = $item_date;
+ $item['enclosures'] = array($item_image);
+ $item['content'] = $item_desc;
+ $this->items[] = $item;
+ }
+ }
+ $element = null;
+ }
+ $results = null;
+ }
+}
diff --git a/bridges/OnVaSortirBridge.php b/bridges/OnVaSortirBridge.php
new file mode 100644
index 0000000..ee6baf1
--- /dev/null
+++ b/bridges/OnVaSortirBridge.php
@@ -0,0 +1,131 @@
+<?php
+class OnVaSortirBridge extends FeedExpander {
+ const MAINTAINER = 'AntoineTurmel';
+ const NAME = 'OnVaSortir';
+ const URI = 'https://www.onvasortir.com';
+ const DESCRIPTION = 'Returns the newest events from OnVaSortir (full text)';
+ const PARAMETERS = array(
+ array(
+ 'city' => array(
+ 'name' => 'City',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'Agen' => 'Agen',
+ 'Ajaccio' => 'Ajaccio',
+ 'Albi' => 'Albi',
+ 'Amiens' => 'Amiens',
+ 'Angers' => 'Angers',
+ 'Angoulême' => 'Angouleme',
+ 'Annecy' => 'annecy',
+ 'Aurillac' => 'aurillac',
+ 'Auxerre' => 'auxerre',
+ 'Avignon' => 'avignon',
+ 'Béziers' => 'Beziers',
+ 'Bastia' => 'Bastia',
+ 'Beauvais' => 'Beauvais',
+ 'Belfort' => 'Belfort',
+ 'Bergerac' => 'bergerac',
+ 'Besançon' => 'Besancon',
+ 'Biarritz' => 'Biarritz',
+ 'Blois' => 'Blois',
+ 'Bordeaux' => 'bordeaux',
+ 'Bourg-en-Bresse' => 'bourg-en-bresse',
+ 'Bourges' => 'Bourges',
+ 'Brest' => 'Brest',
+ 'Brive' => 'brive-la-gaillarde',
+ 'Bruxelles' => 'bruxelles',
+ 'Caen' => 'Caen',
+ 'Calais' => 'Calais',
+ 'Carcassonne' => 'Carcassonne',
+ 'Châteauroux' => 'Chateauroux',
+ 'Chalon-sur-saone' => 'chalon-sur-saone',
+ 'Chambéry' => 'chambery',
+ 'Chantilly' => 'chantilly',
+ 'Charleroi' => 'charleroi',
+ 'Charleville-Mézières' => 'Charleville-Mezieres',
+ 'Chartres' => 'Chartres',
+ 'Cherbourg' => 'Cherbourg',
+ 'Cholet' => 'cholet',
+ 'Clermont-Ferrand' => 'Clermont-Ferrand',
+ 'Compiègne' => 'compiegne',
+ 'Dieppe' => 'dieppe',
+ 'Dijon' => 'Dijon',
+ 'Dunkerque' => 'Dunkerque',
+ 'Evreux' => 'evreux',
+ 'Fréjus' => 'frejus',
+ 'Gap' => 'gap',
+ 'Genève' => 'geneve',
+ 'Grenoble' => 'Grenoble',
+ 'La Roche sur Yon' => 'La-Roche-sur-Yon',
+ 'La Rochelle' => 'La-Rochelle',
+ 'Lausanne' => 'lausanne',
+ 'Laval' => 'Laval',
+ 'Le Havre' => 'le-havre',
+ 'Le Mans' => 'le-mans',
+ 'Liège' => 'liege',
+ 'Lille' => 'lille',
+ 'Limoges' => 'Limoges',
+ 'Lorient' => 'Lorient',
+ 'Luxembourg' => 'Luxembourg',
+ 'Lyon' => 'lyon',
+ 'Marseille' => 'marseille',
+ 'Metz' => 'Metz',
+ 'Mons' => 'Mons',
+ 'Mont de Marsan' => 'mont-de-marsan',
+ 'Montauban' => 'Montauban',
+ 'Montluçon' => 'montlucon',
+ 'Montpellier' => 'montpellier',
+ 'Mulhouse' => 'Mulhouse',
+ 'Nîmes' => 'nimes',
+ 'Namur' => 'Namur',
+ 'Nancy' => 'Nancy',
+ 'Nantes' => 'nantes',
+ 'Nevers' => 'nevers',
+ 'Nice' => 'nice',
+ 'Niort' => 'niort',
+ 'Orléans' => 'orleans',
+ 'Périgueux' => 'perigueux',
+ 'Paris' => 'paris',
+ 'Pau' => 'Pau',
+ 'Perpignan' => 'Perpignan',
+ 'Poitiers' => 'Poitiers',
+ 'Quimper' => 'Quimper',
+ 'Reims' => 'Reims',
+ 'Rennes' => 'Rennes',
+ 'Roanne' => 'roanne',
+ 'Rodez' => 'rodez',
+ 'Rouen' => 'Rouen',
+ 'Saint-Brieuc' => 'Saint-Brieuc',
+ 'Saint-Etienne' => 'saint-etienne',
+ 'Saint-Malo' => 'saint-malo',
+ 'Saint-Nazaire' => 'saint-nazaire',
+ 'Saint-Quentin' => 'saint-quentin',
+ 'Saintes' => 'saintes',
+ 'Strasbourg' => 'Strasbourg',
+ 'Tarbes' => 'Tarbes',
+ 'Toulon' => 'Toulon',
+ 'Toulouse' => 'Toulouse',
+ 'Tours' => 'Tours',
+ 'Troyes' => 'troyes',
+ 'Valence' => 'valence',
+ 'Vannes' => 'vannes',
+ 'Zurich' => 'zurich',
+ )
+ )
+ )
+ );
+
+ protected function parseItem($item){
+ $item = parent::parseItem($item);
+ $html = getSimpleHTMLDOMCached($item['uri']);
+ $text = $html->find('div.corpsMax', 0)->innertext;
+ $item['content'] = utf8_encode($text);
+ return $item;
+ }
+
+ public function collectData(){
+ $this->collectExpandableDatas('https://' .
+ $this->getInput('city') . '.onvasortir.com/rss.php');
+ }
+}
diff --git a/bridges/OneFortuneADayBridge.php b/bridges/OneFortuneADayBridge.php
new file mode 100644
index 0000000..ed0b5ec
--- /dev/null
+++ b/bridges/OneFortuneADayBridge.php
@@ -0,0 +1,954 @@
+<?php
+class OneFortuneADayBridge extends BridgeAbstract {
+ const NAME = 'One Fortune a Day';
+ const URI = 'https://github.com/fulmeek';
+ const DESCRIPTION = 'Get a fortune quote every single day.';
+ const MAINTAINER = 'fulmeek';
+ const PARAMETERS = array(array(
+ 'time' => array(
+ 'name' => 'Time in UTC',
+ 'type' => 'list',
+ 'values' => array(
+ '0:00' => 0,
+ '1:00' => 1,
+ '2:00' => 2,
+ '3:00' => 3,
+ '4:00' => 4,
+ '5:00' => 5,
+ '6:00' => 6,
+ '7:00' => 7,
+ '8:00' => 8,
+ '9:00' => 9,
+ '10:00' => 10,
+ '11:00' => 11,
+ '12:00' => 12,
+ '13:00' => 13,
+ '14:00' => 14,
+ '15:00' => 15,
+ '16:00' => 16,
+ '17:00' => 17,
+ '18:00' => 18,
+ '19:00' => 19,
+ '20:00' => 20,
+ '21:00' => 21,
+ '22:00' => 22,
+ '23:00' => 23,
+ ),
+ 'defaultValue' => 5
+ )
+ ));
+
+ const LIMIT_ITEMS = 7;
+ const DAY_SECS = 86400;
+
+ public function collectData() {
+ $time = gmmktime((int)$this->getInput('time'), 0, 0);
+ if ($time > time())
+ $time -= self::DAY_SECS;
+
+ for ($i = self::LIMIT_ITEMS; $i > 0; --$i) {
+ $seed = date('Ymd', $time);
+ $quote = $this->getQuote($seed);
+
+ $item['title'] = strftime('%A, %x', $time);
+ $item['content'] = htmlentities($quote, ENT_QUOTES, 'UTF-8');
+ $item['timestamp'] = $time;
+ $item['uri'] = 'urn:sha1:' . hash('sha1', $seed);
+
+ $this->items[] = $item;
+
+ $time -= self::DAY_SECS;
+ }
+ }
+
+ private function getQuote($seed) {
+ $quotes = explode('//', <<<QUOTES
+People are naturally attracted to you.
+//You learn from your mistakes... You will learn a lot today.
+//If you have something good in your life, don't let it go!
+//What ever you're goal is in life, embrace it visualize it, and for it will be
+yours.
+//Your shoes will make you happy today.
+//You cannot love life until you live the life you love.
+//Be on the lookout for coming events; They cast their shadows beforehand.
+//Land is always on the mind of a flying bird.
+//The man or woman you desire feels the same about you.
+//Meeting adversity well is the source of your strength.
+//A dream you have will come true.
+//Our deeds determine us, as much as we determine our deeds.
+//Never give up. You're not a failure if you don't give up.
+//You will become great if you believe in yourself.
+//There is no greater pleasure than seeing your loved ones prosper.
+//You will marry your lover.
+//A very attractive person has a message for you.
+//You already know the answer to the questions lingering inside your head.
+//It is now, and in this world, that we must live.
+//You must try, or hate yourself for not trying.
+//You can make your own happiness.
+//The greatest risk is not taking one.
+//The love of your life is stepping into your planet this summer.
+//Love can last a lifetime, if you want it to.
+//Adversity is the parent of virtue.
+//Serious trouble will bypass you.
+//A short stranger will soon enter your life with blessings to share.
+//Now is the time to try something new.
+//Wealth awaits you very soon.
+//If you feel you are right, stand firmly by your convictions.
+//If winter comes, can spring be far behind?
+//Keep your eye out for someone special.
+//You are very talented in many ways.
+//A stranger, is a friend you have not spoken to yet.
+//A new voyage will fill your life with untold memories.
+//You will travel to many exotic places in your lifetime.
+//Your ability for accomplishment will follow with success.
+//Nothing astonishes men so much as common sense and plain dealing.
+//Its amazing how much good you can do if you dont care who gets the credit.
+//Everyone agrees. You are the best.
+//LIFE CONSIST NOT IN HOLDING GOOD CARDS, BUT IN PLAYING THOSE YOU HOLD WELL.
+//Jealousy doesn't open doors, it closes them!
+//It's better to be alone sometimes.
+//When fear hurts you, conquer it and defeat it!
+//Let the deeds speak.
+//You will be called in to fulfill a position of high honor and responsibility.
+//The man on the top of the mountain did not fall there.
+//You will conquer obstacles to achieve success.
+//Joys are often the shadows, cast by sorrows.
+//Fortune favors the brave.
+//An upward movement initiated in time can counteract fate.
+//A journey of a thousand miles begins with a single step.
+//Sometimes you just need to lay on the floor.
+//Never give up. Always find a reason to keep trying.
+//If you have something worth fighting for, then fight for it.
+//Stop wishing. Start doing.
+//Accept your past without regrets. Handle your present with confidence. Face
+your future without fear.
+//Stay true to those who would do the same for you.
+//Ask yourself if what you are doing today is getting you closer to where you
+want to be tomorrow.
+//Happiness is an activity.
+//Help is always needed but not always appreciated. Stay true to your heart and
+help those in need weather they appreciate it or not.
+//Hone your competitive instincts.
+//Finish your work on hand don't be greedy.
+//For success today, look first to yourself.
+//Your fortune is as sweet as a cookie.
+//Integrity is the essence of everything successful.
+//If you're happy, you're successful.
+//You will always be surrounded by true friends
+//Believing that you are beautiful will make you appear beautiful to others
+around you.
+//Happinees comes from a good life.
+//Before trying to please others think of what makes you happy.
+//When hungry, order more Chinese food.
+//Your golden opportunity is coming shortly.
+//For hate is never conquered by hate. Hate is conquered by love .
+//You will make many changes before settling down happily.
+//A man is born to live and not prepare to live.
+//You cannot become rich except by enriching others.
+//Don't pursue happiness - create it.
+//You will be successful in love.
+//All your fingers can't be of the same length.
+//Wise sayings often fall on barren ground, but a kind word is never thrown away.
+//A lifetime of happiness is in store for you.
+//It is very possible that you will achieve greatness in your lifetime.
+//Be tactful; overlook your own opportunity.
+//You are the controller of your destiny.
+//Everything happens for a reson.
+//How can you have a beutiful ending without making beautiful mistakes.
+//You can open doors with your charm and patience.
+//Welcome the change coming into your life.
+//There will be a happy romance for you shortly.
+//Your fondest dream will come true within this year.
+//You have a deep interest in all that is artistic.
+//Your emotional nature is strong and sensitive.
+//A letter of great importance may reach you any day now.
+//Good health will be yours for a long time.
+//You will become better acquainted with a coworker.
+//To be old and wise, you must first be young and stupid.
+//Failure is only the opportunity to begin again more intelligently.
+//Integrity is doing the right thing, even if nobody is watching.
+//Conquer your fears or they will conquer you.
+//You are a lover of words; One day you will write a book.
+//In this life it is not what we take up, but what we give up, that makes us
+rich.
+//Fear can keep us up all night long, but faith makes one fine pillow.
+//Seek out the significance of your problem at this time. Try to understand.
+//Never upset the driver of the car you're in; they're the master of your
+destiny until you get home.
+//He who slithers among the ground is not always a foe.
+//You learn from your mistakes, you will learn a lot today.
+//You only need look to your own reflection for inspiration. Because you are
+Beautiful!
+//You are not judged by your efforts you put in; you are judged on your
+performance.
+//Rivers need springs.
+//Good news from afar may bring you a welcome visitor.
+//When all else seems to fail, smile for today and just love someone.
+//Patience is a virtue, unless its against a brick wall.
+//When you look down, all you see is dirt, so keep looking up.
+//If you are afraid to shake the dice, you will never throw a six.
+//Even if the person who appears most wrong, is also quite often right.
+//A single conversation with a wise man is better than ten years of study.
+//Happiness is often a rebound from hard work.
+//The world may be your oyster, but that doesn't mean you'll get it's pearl.
+//Your life will be filled with magical moments.
+//You're true love will show himself to you under the moonlight.
+//Do not follow where the path may lead. Go where there is no path...and leave a
+trail
+//Do not fear what you don't know
+//The object of your desire comes closer.
+//You have a flair for adding a fanciful dimension to any story.
+//If you wish to know the mind of a man, listen to his words
+//The most useless energy is trying to change what and who God so carefully
+created.
+//Do not be covered in sadness or be fooled in happiness they both must exist
+//You will have unexpected great good luck.
+//You will have a pleasant surprise
+//All progress occurs because people dare to be different.
+//Your ability for accomplishment will be followed by success.
+//The world is always ready to receive talent with open arms.
+//Things may come to those who wait, but only the things left by those who
+hustle.
+//We can't help everyone. But everyone can help someone.
+//Every day is a new day. But tomorrow is never promised.
+//Express yourself: Don't hold back!
+//It is not necessary to show others you have change; the change will be obvious.
+//You have a deep appreciation of the arts and music.
+//If your desires are not extravagant, they will be rewarded.
+//You try hard, never to fail. You don't, never to win.
+//Never give up on someone that you don't go a day without thinking about.
+//It never pays to kick a skunk.
+//In case of fire, keep calm, pay bill and run.
+//Next full moon brings an enchanting evening.
+//Not all closed eye is sleeping nor open eye is seeing.
+//Impossible is a word only to be found in the dictionary of fools.
+//You will soon witness a miracle.
+//The time is alway right to do what is right.
+//Love is as necessary to human beings as food and shelter.
+//You will make heads turn.
+//You are extremely loved. Don't worry :)
+//If you are never patient, you will never get anything done. If you believe you
+can do it, you will be rewarded with success.
+//You will soon embark on a business venture.
+//You believe in the goodness of man kind.
+//You will have a long and wealthy life.
+//You will take a pleasant journey to a place far away.
+//You are a person of culture.
+//Keep it simple. The more you say, the less people remember.
+//Life is like a dogsled team. If you ain't the lead dog, the scenery never
+changes.
+//Prosperity makes friends and adversity tries them.
+//Nothing seems impossible to you.
+//Patience is bitter, but its fruit is sweet.
+//The only certainty is that nothing is certain.
+//Success is the sum of my unique visions realized by the sweat of perseverance.
+//When you expect your opponent to yield, you also should avoid hurting him.
+//Human evolution: “wider freeway but narrower viewpoints.
+//Intelligence is the door to freedom and alert attention is the mother of
+intelligence.
+//Back away from individuals who are impulsive.
+//Enjoyed the meal? Buy one to go too.
+//You believe in the goodness of mankind.
+//A big fortune will descend upon you this year.
+//Now these three remain, faith, hope, and love. The greatest of these is love.
+//For success today look first to yourself.
+//Determination is the wake-up call to the human will.
+//There are no limitations to the mind except those we aknowledge.
+//A merry heart does good like a medicine.
+//Whenever possible, keep it simple.
+//Your dearest wish will come true.
+//Poverty is no disgrace.
+//If you don’t do it excellently, don’t do it at all.
+//You have an unusual equipment for success, use it properly.
+//Emotion is energy in motion.
+//You will soon be honored by someone you respect.
+//Punctuality is the politeness of kings and the duty of gentle people
+everywhere.
+//Your happiness is intertwined with your outlook on life.
+//Elegant surroundings will soon be yours.
+//If you feel you are right, stand firmly by your convictions.
+//Your smile brings happiness to everyone you meet.
+//Instead of worrying and agonizing, move ahead constructively.
+//Do you believe? Endurance and persistence will be rewarded.
+//A new business venture is on the horizon.
+//Never underestimate the power of the human touch.
+//Hold on to the past but eventually, let the times go and keep the memories
+into the present.
+//Truth is an unpopular subject. Because it is unquestionably correct.
+//The most important thing in communication is to hear what isn’t being said.
+//You are broad minded and socially active.
+//Your dearest dream is coming true. God looks after you especially.
+//You will recieve some high prize or award.
+//Your present question marks are going to succeed.
+//You have a fine capacity for the enjoyment of life.
+//You will live long and enjoy life.
+//An admirer is concealing his/her affection for you.
+//A wish is what makes life happen when you dream of rose petals.
+//Love can turn cottage into a golden palace.
+//Lend your money and lose your freind.
+//You will kiss your crush ohhh lalahh
+//You will be rewarded for being a good listener in the next week.
+//If you never give up on love, It will never give up on you.
+//Unleash your life force.
+//Your wish will come true.
+//There is a prospect of a thrilling time ahead for you.
+//No distance is too far, if two hearts are tied together.
+//Land is always in the mind of the flying birds.
+//Try? No! Do or do not, there is no try.
+//Do not worry, you will have great peace.
+//It's about time you asked that special someone on a date.
+//You create your own stage ... the audience is waiting.
+//It is never too late. Just as it is never too early.
+//Discover the power within yourself.
+//Good things take time.
+//Stop thinking about the road not taken and pave over the one you did.
+//Put your unhappiness aside. Life is beautiful, be happy.
+//You can still love what you can not have in life.
+//Make a wise choice everyday.
+//Circumstance does not make the man; it reveals him to himself.
+//The man who waits till tomorrow, misses the opportunities of today.
+//Life does not get better by chance. It gets better by change.
+//If you never expect anything you can never be disappointed.
+//People in your surroundings will be more cooperative than usual.
+//True wisdom is found in happiness.
+//Ones always regrets what could have done. Remember for next time.
+//Follow your bliss and the Universe will open doors where there were once only
+walls.
+//Find a peaceful place where you can make plans for the future.
+//All the water in the world can't sink a ship unless it gets inside.
+//The earth is a school learn in it.
+//In music, one must think with his heart and feel with his brain.
+//If you speak honestly, everyone will listen.
+//Ganerosity will repay itself sooner than you imagine.
+//good things take time
+//Do what is right, not what you should.
+//To effect the quality of the day is no small achievement.
+//Simplicity and clearity should be the theme in your dress.
+//Virtuous find joy while Wrongdoers find grieve in their actions.
+//Not all closed eye is sleeping, nor open eye is seeing.
+//Bread today is better than cake tomorrow.
+//In evrything there is a piece of truth.But a piece.
+//A feeling is an idea with roots.
+//Man is born to live and not prepare to live
+//It's all right to have butterflies in your stomach. Just get them to fly in
+formation.
+//If you don t give something, you will not get anything
+//The harder you try to not be like your parents, the more likely you will
+become them
+//Someday everything will all make perfect sense
+//you will think for yourself when you stop letting others think for you
+//Everything will be ok. Don't obsess. Time will prove you right, you must stay
+where you are.
+//Let's finish this up now, someone is waiting for you on that
+//The finest men like the finest steels have been tempered in the hottest
+furnace.
+//A dream you have will come true
+//The worst of friends may become the best of enemies, but you will always find
+yourself hanging on.
+//I think, you ate your fortune while you were eating your cookie
+//If u love someone keep fighting for them
+//Do what you want, when you want, and you will be rewarded
+//Let your fantasies unwind...
+//The cooler you think you are the dumber you look
+//Expect great things and great things will come
+//The Wheel of Good Fortune is finally turning in your direction!
+//Don't lead if you won't lead.
+//You will always be successful in your professional career
+//Share your hapiness with others today.
+//It's up to you to clearify.
+//Your future will be happy and productive.
+//Seize every second of your life and savor it.
+//Those who walk in other's tracks leave no footprints.
+//Failure is the mother of all success.
+//Difficulty at the beginning useually means ease at the end.
+//Do not seek so much to find the answer as much as to understand the question
+better.
+//Your way of doing what other people do their way is what makes you special.
+//A beautiful, smart, and loving person will be coming into your life.
+//Friendship is an ocean that you cannot see bottom.
+//Your life does not get better by chance, it gets better by change.
+//Our duty,as men and women,is to proceed as if limits to our ability did not
+exist.
+//A pleasant expeience is ahead:don't pass it by.
+//Our perception and attitude toward any situation will determine the outcome
+//They say you are stubborn; you call it persistence.
+//Two small jumps are sometimes better than one big leap.
+//A new wardrobe brings great joy and change to your life.
+//The cure for grief is motion.
+//It's a good thing that life is not as serious as it seems to the waiter
+//I hear and I forget. I see and I remember. I do and I understand.
+//I have a dream....Time to go to bed.
+//Ideas you believe are absurd ultimately lead to success!
+//A human being is a deciding being.
+//Today is an ideal time to water your parsonal garden.
+//Some men dream of fortunes, others dream of cookies.
+//Things are never quite the way they seem.
+//the project on your mind will soon gain momentum
+//YOUR FAILURES WILL LEAD YOU TO YOUR SUCCESS.
+//IN ORDER TO GET THE RAINBOW, YOU MUST ENDURE THE RAIN.
+//Beauty is simply beauty. originality is magical.
+//Your dream will come true when you least expect it.
+//Let not your hand be stretched out to receive and shut when you should repay.
+//Don't worry, half the people you know are below average.
+//Vision is the art of seeing what is invisible to others.
+//You don't need talent to gain experience.
+//A focused mind is one of the most powerful forces in the universe.
+//Today you shed your last tear. Tomorrow fortune knocks at your door.
+//Be patient! The Great Wall didn't got build in one day.
+//Think you can. Think you can't. Either way, you'll be right.
+//Wisdom is on her way to you.
+//Digital circuits are made from analog parts.
+//If you eat a box of fortune cookies, anything is possible.
+//The best is yet to come.
+//I'm with you.
+//Be direct,usually one can accomplish more that way.
+//A single kind work will keep one warm for years.
+//Ask a friend to join you on your next voyage.
+//In God we trust.
+//Love is free. Lust will cost you everything you have.
+//Stop searching forever, happiness is just next to you.
+//You don't need the answers to all of life's questions. Just ask your father
+what to do.
+//Jealousy is a useless emotion.
+//You are not a ghost.
+//There is someone rather annoying in your life that you need to listen to.
+//You will plant the smallest seed and it will become the greatest and most
+mighty tree in the world.
+//The dream you've been dreaming all your life isn't worth it. Find a new dream,
+and once you're sure you've found it, fight for it.
+//See if you can learn anything from the children.
+//It's Never Too Late For Good Things To Happen!
+//A clear conscience is usually the sign of a bad memory.
+//Aim high, time flies.
+//One is not sleeping, does not mean they are awake.
+//A great pleasure in life is doing what others say you can't.
+//Isn't there something else you should be working on right now?
+//Your father still loves and is in always with you. Remember that.
+//Before you can be reborn you must die.
+//It better to be the hammer than the nail.
+//You are admired by everyone for your talent and ability.
+//Save the whales. Collect the whole set.
+//You will soon discover a major truth about the one you love most.
+//Your life will prosper only if you acknowledge your faults and work to reduce
+them.
+//Pray to God, but row towards shore.
+//You will soon witness a miracle.
+//The early bird gets the worm, but the second mouse gets the cheese
+//Help, I'm being held prisoner in a Chinese cookie factory.
+//Alas! The onion you are eating is someone else’s water lily.
+//You are a persoon with a good sense of justice, now it's time to act like it.
+//You create enthusiasm around you.
+//There are big changes ahead for you. They will be good ones!
+//You will have many happy days soon.
+//Out of confusion comes new patterns.
+//If you love someone enough and they break your heart, you can't stop yourself
+from still loving them again even after all that pain.
+//Look right...Now look left...Now look forward (do this really fast) do you
+feel any different? good you should feel dizzy.
+//Live like you are on the bottom, even if you are on the top.
+//You will soon emerge victorious from the maze you've been traveling in.
+//Do not judge a book by it's color.
+//Everything will come your way.
+//There is a time to be practical now.
+//Bend the rod while it is still hot.
+//Darkness is only succesful when there is no light. Don't forget about light!
+//Acting is not lying. It is findind someone hiding inside you and letting that
+person run free.
+//You will be forced to face fear, but if you do not run, fear will be afraid of
+you.
+//You are thinking about doing something. Don't do it, it won't help anything.
+//Your worst enemy has a crush on you!
+//Love Conquers all.
+//The phrase is follow your dreams. Not dream period.
+//stop nagging to your partner and take it day by day.
+//Do not think that me or my brothers have supreme control over what will happen
+to you.
+//Bad luck and misfortune will follow you all your days.
+//Remember the fate of the early Worm.
+//Begin your life anew with strength, grace and wonder.
+//Be a good friend and a fair enemy.
+//What goes around comes around.
+//Bad luck and misfortune will infest your pathetic soul for all eternity.
+//The best prophet of the future is the past
+//Movies have pause buttons, friends do not
+//Use the force.
+//Trust your intuition.
+//Encourage your peers.
+//Let your imagination wander.
+//Your pain is the breaking of the shell that encloses your understanding.
+//Patience is key, a wait short or long will have its reward.
+//Tell them before it's too late...
+//A bird in the hand is worth three in the bush!!
+//Be assertive when decisive action is needed.
+//To determine whether someone is beautiful is not by looking at his/her
+appearance, but his/her heart.
+//Hope brings about a better future
+//While you have this day, fill it with life. While you're in this moment, give
+it your own special meaning and purpose and joy.
+//Even though it will often be difficult and complicated, you know you have what
+it takes to get it done.
+//You can choose, right now and in every moment, to put your powerful and
+effective abilities to purposeful use. There is always something you can do, no
+matter what the situation may be, that will move your life forward.
+//IT IS NOT GOOD TO BE A USER BLESSINGS COME FROM BEING A GIVER NOT A TAKER.
+//Cookie says, You crack me up
+//You will prosper in the field of wacky inventions.
+//Your tongue is your ambassador.
+//The cure for grief is movement.
+//Love Is At Your Hands Be Glad And Hold On To It.
+//You are often asked if it is in yet.
+//Life to you is a bold and dashing responsibility.
+//Patience is a key to joy.
+//A bargain is something you don't need at a price you can't resist.
+//Today is going to be a disasterous day, be prepared!
+//Stay to your inner-self, you will benefit in many ways.
+//Rarely do great beauty and great virtue dwell together as they do in you.
+//You are talented in many ways.
+//You are the master of every situation.
+//Your problem just got bigger. Think, what have you done.
+//If your cookie still in one piece, buy lotto.
+//Go with the flow will make your transition ever so much easier.
+//Tomorrow Morning,Take a Left Turn As Soon As You Leave Home
+//A metaphor could save your life.
+//Don't wait for your ship to come in, swim out to it
+//There are lessons to be learned by listening to others.
+//If you want the rainbow, you have to tolerate the rain.
+//Volition, Strength, Languages, Freedom and Power rests in you.
+//TOO MANY PEOPLE VOLUNTEER TO CARRY THE STOOL WHEN ITS TIME TO MOVE THE PIANO
+//It takes more than a good memory to have good memories.
+//You are what you are; understand yourself before you react
+//Word to the wise: Don't play leapfrog with a unicorn.........
+//Forgive your enemies, but never forget them.
+//Everything will now come your way
+//Don't worry about the stock market. Invest in family.
+//Your fortune is as sweet as a cookie.
+//It is much easier to look for the bad, than it is to find the good
+//If a person who has caused you pain and suffering has brought you, reconsider
+that person's value in your life
+//You are worth loving, you are also worth the effort it takes to love you
+//Never trouble trouble till trouble troubles you.
+//Get off to a new start - come out of your shell.
+//Life is a dancefloor,you are the DJ!
+//Cooperate with those who have both know how and integrith.
+//Minor aches today are likely to pay off handsomely tomorrow.
+//You are about to become $8.95 poorer. ($6.95 if you had the buffet)
+//Your mouth may be moving, but nobody is listening.
+//Focus in on the color yellow tomorrow for good luck!
+//The problem with resisting temptation is that it may never come again.
+//All your sorrows will vanish.
+//About time I got out of that cookie.
+//Love will lead the way.
+//The ads revenge is massive success
+//It is best to act with confidence, no matter how little right you have to it.
+//Soon, a visitor shall delight you.
+//What breaks in a moment may take years to mend.
+//Someone stole your fortune and replaced it with this one. Your luck sucks.
+Have a good day!
+//Take control of your life rather than letting things happen just like that!
+//You will be rewarded for your patience and understanding.
+//You will achieve all your desires and pleasures.
+//Never miss a chance to keep your mouth shut.
+//Nothing Shows A Man's Character More Than What He Laughs At.
+//Never regret anything that made you smile.
+//Love Takes Pratice.
+//Don't take yourself so seriously, no one else does.
+//You've got what it takes, but it will take everything you've got!
+//At this very moment you can change the rest of your life.
+//Become who you are.
+//All comes at the proper time to him who knows how to wait.
+//The energy is within you. Money is Coming!
+//The quotes that you do not understand, are not meant for you.
+//You have an important new business development shaping up.
+//if love someone a lot tell it before it's too late
+//Birds are entangled by their feet and men by their tongues.
+//Benefit by doing things that others give up on.
+//Rest has a peaceful effect on your physical and emotional health.
+//One of the best ways to persuade others is with your ears--by listening to
+them.
+//Plan your work and work your plan.
+//Over self-confidence is equal to being blind.
+//Those who bring sunshine to the lives of others cannot keep it from themselves.
+//Love or money, or neither?
+//Before the beginning of great brilliance, there must be chaos.
+//Old friends make best friends.
+//Stop searching forever. Happiness is just next to you.
+//Accept something that you cannot change, and you will feel better.
+//Kiss is not a kiss without the heart.
+//Enhance your karma by engaging in various charitable activities.
+//You will have good luck and overcome many hardships.
+//You never hesitate to tackle the most difficult problems.
+//Hope is like food. You will starve without it.
+//WHEN FIRE AND WATER GO TO WAR WATER ALWAYS WINS.
+//An angry man opens his mouth and shuts up his eyes.
+//Make the system work for you, not the other way around.
+//You will be hungry soon, order takeout now.
+//Be prepared for extra energy.
+//An unexpected relationship will become permanent.
+//The love of your life is sitting across from you.
+//Better be the head of a chicken than the tail of an ox.
+//To forgive others one more time is to create one more blessing for yourself.
+//Enjoy yourself while you can.
+//The ultimate test of a relationship is to disagree but to hold hands.
+//Excellence is the difference between what I do and what I am capable of.
+//Do not let what you do not have prevent you from using what you do have.
+//What ends on hope does not end at all.
+//People enjoy having you around. Appreciate this.
+//You are admired for your adventuous ways.
+//It's never crowded along the extra mile
+//You are blessed, today is the day to bless others.
+//The Greatest War Sometimes Isn't On The Battlefield But Against Oneself.
+//People in your background will be more co-operative than usual.
+//A good way to stay healthy is to eat more Chinese food.
+//Anyone who dares to be, can never be weak.
+//Affirm it, visualize it, believe it, and it`will actualize itself.
+//The measure of time to your next goal is the measure of your discipline.
+//Help, I'm prisoner in a Chinese bakery!!!
+//Take a minute and let it ride, then take a minute to let it breeze.
+//We are here to love each other, serve each other and uplift each other.
+//If everybody is a worm you should be a glow worm
+//To affirm is to make firm.
+//Remember this: duct tape can fix anything, so don't worry about messing things
+up.
+//You broke my cookie!
+//Failure is not defeat until you stop trying.
+//The days that make us happy make us wise.
+//Men do not fail... they give up trying.
+//Time may fly by. But Memories don't.
+//You will win success in whatever you adopt.
+//You will outdistance all your competitors.
+//You have a great capability to break cookies - use it wisely!
+//AT TIMES IT IS BETTER TO KNOW WHEN EXIT THAN ENTER
+//Money will come to you when you are doing the right thing.
+//When you get something for nothing, you just haven't been billed for it yet.
+//You will discover your hidden talents.
+//You'll advance for with your abilities.
+//When you can't naturally feel upbeat it can sometimes help you to act as if
+you did.
+//You will overcome difficult times.
+//Your problem just became your stepping stone. Catch the moment.
+//I am a fortune. You just broke my little house. Where will i live now?
+//The majority of the word can't is can.
+//The secret of getting ahead is getting started.
+//Be most affectionate today.
+//Change your thoughts and you change the world.
+//Sing and rejoice, fortune is smiling on you.
+//All the preparation you've done will finally be paying off!
+//A truly great person never puts away the simplicity of a child.
+//Customer service is like taking a bath you have to keep doing it.
+//The expanse of your intelligence is a void no universe could ever fill.
+//Those grapes you cannot taste are always sour.
+//An unexpected aquaintance will resurface.
+//If you want the rainbow, then you have to tolerate the rain.
+//You don't get harmony when everyone sings the same note.
+//The race is not always to the swift, but to those who keep on running.
+//The early bird gets the worm, but the second mouse gets the cheese.
+//The best things in life aren't things.
+//Don't bother looking for fault. The reward for finding it is low.
+//Everything has beauty but not everyone sees it.
+//Nothing is as good or bad as it appears.
+//Never cut what you can untie.
+//Meet your opponent half way. You need the exercise.
+//Laughter is the shortest distance between two people.
+//We cannot change the direction of the wind, but we can adjust our sails.
+//We could learn a lot from crayons: Some of are sharp, some are pretty, some
+have weird names, and all are different colors. But they all have to learn to
+live in the same box.
+//Use your instincts now.
+//If you take a single step to your journey, you'll succeed; it's not best to
+fail.
+//In the eyes of lovers, everything is beautiful.
+//Warning, do not eat your fortune.
+//Demonstrate refinement in everything you do.
+//Impossible standards just make life difficult.
+//A different world cannot be build by indifferent people.
+//Q. What is H2O? A. Caring, 2 parts Hug and 1 part Open-mind.
+//All troubles you have can pass away very quickly.
+//Integrity is the essense of everything successful.
+//For true love? Send real roses preserved in 24kt gold!
+//Sometimes the object of the journey is not the end, but the journey itself.
+//Fear is just excitement in need of an attitude adjustment.
+//The food here taste so good, even a cave man likes it.
+//Perhaps you've been focusing too much on spending.
+//Happiness isn't something you remember, it's something you experience.
+//Oops... Wrong cookie.
+//The dream is within you.
+//Love is on its way.
+//Be direct, usually one can accomplish more that way.
+//Use your talents. That's what they are intended for.
+//The troubles you have now will pass away quickly.
+//See the light at the end of the tunnel.
+//Your dream will come true when you least expect it.
+//Don't 'face' reality, let it be the place from which you leap.
+//Fortune smiles upon you today.
+//Believing is doing.
+//Your dynamic eyes have attracted a secret admirer.
+//You know where you are going and how to get there.
+//Go confidently in the direction of your dreams.
+//Your ability to pick a winner will bring you success.
+//Humor usually works at the moment of awkwardness.
+//A good time to finish up old tasks.
+//Stop procrastinating - starting tomorrow
+//Enthusiastic leadership gets you a promotion when you least expect it.
+//You love Chinese food.
+//You are far more influential than you think.
+//Adjust finances, make budgets, to improve your standing.
+//Happiness is not the absence of conflict, but the ability to cope with it.
+//An understanding heart warms all that are graced with it's presense.
+//Your co-workers take pleasure in your great sense of creativity.
+//You are one of the people who goes places in life.
+//Others enjoy your company.
+//When in doubt, let your instincts guide you.
+//A cheerful message is on its way to you.
+//A pleasant surprise is in store for you tonight.
+//you cant go down the right path with out first discovering the path to go down
+//To courageously shoulder the responsibility of one's mistake is character.
+//The joyful energy of the day will have a positive affect on you.
+//You have a strong desire for a home and your family interests come first.
+//Dogs have owners, cats have staff.
+//Be patient: in time, even an egg will walk.
+//You are not a person who can be ignored.
+//You always know the right times to be assertive or to simply wait.
+//Reading to the mind is what exercise is to the body.
+//Eat something you never tried before.
+//Your life becomes more and more of an adventure!
+//You need to live authentically, and you can't ignore that.
+//Make all you can, save all you can, give all you can.
+//A well-aimed spear is worth three.
+//To build a better world, start in your community.
+//When you can't naturally feel upbeat, it can sometimes help to act a if you
+did.
+//May you have great luck.
+//A kind word will keep someone warm for years.
+//Nothing in the world is accomplished without passion.
+//Human invented language to satisfy the need to complain.
+//Accept what comes to you each day.
+//A small lucky package is on its way to you soon.
+//In human endeavor, chance favors the prepared mind.
+//Do not upset the penguin today.
+//Don't cry.
+//The best way to give credit is to give it away.
+//Anything you do, do it well. The last thing you want is to be sorry for what
+you didn't do.
+//It takes more then good memory to have good memories.
+//Grant yourself a wish this year only you can do it.
+//love thy neighbour, just don't get caught
+//You will be selected for a promotion because of your accomplishments.
+//There are many new opportunities that are being presented to you.
+//You will inherit a large sum of money.
+//You will recieve a gift from someone that cares about you.
+//You are not illiterate.
+//Love because it is the only true adventure.
+//You are contemplating some action which will bring credit upon you
+//Keep true to the dreams of your youth.
+//Treasure what you have.
+//The greatest precept is continual awareness.
+//A new friend helps you break out of an old routine.
+//I have a dream.... Time to go to bed.
+//Your skill will accomplish what the force of many cannot.
+//You will soon be surrounded by good friends and laughter.
+//The best is yet to come.
+//It is better to be the hammer then the anvil.
+//He who climbs a ladder must begin at the first step.
+//Action speaks nothing, without the Motive.
+//Give yourself some peace and quiet for at least a few hours.
+//Live each day well and wisely
+//Old dreams never die they just get filed away.
+//You can fix it with a little extra energy and a positive attitude.
+//Life is a verb
+//A man without aim is like a clock without hands, as useless if it turns as if
+it stands.
+//Many folks are about as happy as they make up their minds to be.
+//It's kind of fun to do the impossible
+//Wow! A secret message from you teeth!
+//You should be able to make money and hold on to it.
+//The human spirit is stronger than anything that can happen to it.
+//Your succeess will astonish everyone.
+//It is better to have a hen tomorrow than an egg today.
+//Judge each day not by the harvest you reap but by the seeds you plant.
+//You like Chinese food.
+//Your hard work will get payoff today.
+//Today is the tomorrow we worried about yesterday
+//There are no shortcuts to any place worth going
+//No matter what your past has been, you have a spotless future.
+//Your secret desire to completely change your life will manifest.
+//Soon you will be sitting on top of the world.
+//You are never selfish with your advice or your help.
+//A thrilling time is in store for you.
+//It's tough to be fascinating.
+//Soon life will become more interesting
+//Luck sometimes visits a fool, but it never sits down with him.
+//Keep your plans secret for now.
+//Aren't you glad you just had a great meal?
+//Traveling this year will bring your life into greater perspective.
+//Only talent people get help from others.
+//Constant grinding can turn an iron nod into a needle.
+//You will be successful in your work
+//you will spend old age in confort and material wealth
+//When you're about to turn your heart into a stone remember: you do not walk
+alone.
+//I am a bad luck person since I was born
+//You are vigorous in words and action.
+//The one who snores will always fall asleep first.
+//An alien of some sort will be appearing to you shortly!
+//Rest is a good thing, but boredom is its brother.
+//Do not be overly judgemental of your loved one's intentions or actions.
+//Think of how you can assist on a problem, not who to blame.
+//The life of every woman or man - the heart of it - is pure and holy joy.
+//Take it easy
+//Trust your intuition. The universe is guiding your life.
+//Use your head, but live in your heart.
+//Don't find fault, find a remedy
+//It may be those who do most, dream most
+//Your passions sweep you away.
+//Listen to yourself more often
+//Think of mother's exhortations more.
+//The gambler is like the fisherman both have beginners luck.
+//You are given the chance to take part in an exciting adventure.
+//The simplest answer is to act.
+//You will always be surrounded by true friends.
+//Keep your feet on the ground even though friends flatter you.
+//You are the man of righteousness and integrity.
+//He who seeks will find.
+//The smart thing to do is to begin trusting your intuitions.
+//Your many hidden talents will become obvious to those around you.
+//Pick a path with heart.
+//The human spirit is stronger then anything that can happen to it.
+//It takes more than good memory to have good memories.
+//Face facts with dignity.
+//Be calm when confronting an emergency crisis.
+//Do you believe? Endurance and persistence will be rewarded.
+//A new wardrobe brings great joy and change in your life.
+//Everyone agrees you are the best.
+//A new outlook brightens your image and brings new friends.
+//Everything will now come your way.
+//You will be called to fill a position of high honor and responsibility.
+//The eyes believe themselves; the ears believe other people.
+//Good beginning is half done.
+//Some pursue happiness; you create it.
+//It's the worst of times, you need to summon your optimism.
+//You are cautious in showing your true self to others.
+//Your ability to accomplish tasks will follow with success.
+//We all have extraordinary coded within us, waiting to be released.
+//You will have a bright future.
+//Compassion is a way of being.
+//You will always have good luck in your personal affairs.
+//The pleasure of what we enjoy is lost by wanting more
+//Did you remember to order your take out also?
+//Perhaps you've been focusing too much on that one thing..
+//Right now there's an energy pushing you in a new direction.
+//Everybody feels lucky for having you as a friend.
+//When the moment comes, take the top one.
+//Sometimes travel to new places leads to great transformation.
+//There is always a way - if you are committed.
+//Life is too short to waste time hating anyone.
+//All the world may not love a lover but they will be watching him.
+//Don't just spend time, invest it.
+//Life always gets harder near the summit.
+//Take the chance while you still have the choice.
+//It is much easier to be cirtical than to be correct.
+//Enjoy life! It is better to be happy than wise.
+//To make the cart go, you must grease the wheels.
+//You are contemplating some action which will bring credit upon you.
+//Before you wonder Am I doing things right, ask Am I doing the right things?
+//You may be disappointed if you fail, but you are doomed if you don't try.
+//You will always get what you want through your charm and personality.
+//The big issues are work, career, or status right now.
+//Your emotional currents are flowing powerfully now.
+//Any decision you have to make tomorrow is a good decsion.
+//Consume less. Share more. Enjoy life.
+//The secret of staying young is good health and lying about your age.
+//Spring has sprung. Life is blooming.
+//Go ask your mom.
+//The possibility of a career change is near.
+//The important thing is to never stop questioning.
+//Compassion will cure more then condemnation.
+//Excuses are easy to manufacture, and hard to sell.
+//Put your mind into planning today. Look into the future.
+//Listen to life, and you will hear the voice of life crying, Be!
+//Broke is only temporaryl poor is a state of mind.
+//Here we go. Moo Shu Cereal for breakfast with duck sauce.
+//Teamwork: the fuel that allows common people attain uncommon results.
+//Hard words break no bones, fine words butter no parsnips.
+//We cannot direct the wind but we can adjust the sails.
+//You are offered the dream of a lifetime. Say yes!
+//Working out the kinks today will make for a better tomorrow.
+//You have a curious smile and a mysterious nature.
+//Questions provide the key to unlocking our unlimited potential.
+//You will enjoy razon-sharp spiritual vision today.
+//The wise are aware of their treasure, while fools follow their vanity
+//Well-arranged time is the surest sign of a well-arranged mind.
+//Never bring unhappy feelings into your home.
+//This is really a lovely day. Congratulations!
+//Bad luck and ill misfortune will infest your pathetic soul for all eternity.
+//A golden egg of opportunity falls into your lap this month.
+//You are very grateful for the small pleasures of life.
+//today you should be a passenger. Stay close to a driver for a day.
+//For hate is never conquered by hate. Hate is conquered by love.
+//Service is the rent we pay for the privilege of living on this planet.
+//Good clothes open many doors. Go shopping.
+//The leader seeks to communicate his vision to his followers.
+//Great works are performed not by strength, but by perseverance.
+//People who are late are often happier than those who have to wait for them
+//Present your best ideas today to an eager and welcoming audience.
+//Friends long absent are coming back to you.
+//The time is right to make new friends.
+//Life to you is a dashing and bold adventure
+//You may be hungry soon: order a takeout now.
+//Do not hesitate to look for help, an extra hand should always be welcomed.
+//How can you have a beautiful ending without making beautiful mistakes?
+//Humor is an affirmation of dignity
+//He who climbs a ladder must begin at the first step
+//What's vice today may be virtue tomorow.
+//You have an unusually magnetic personality.
+//You will travel to many places.
+//Accept yourself
+//Be a generous friend and a fair enemy
+//Never quit!
+//Old friends, old wines and old gold are best
+//If your desires are not extravagant, they will be granted
+//Every Friend Joys in your Success
+//You should be able to undertake and complete anything
+//You will enjoy good health, you will be surrounded by luxury
+//You are a person of strong sense of duty
+//Dream lofty dreams, and as you dream, so shall you become.
+//You have a quiet and unobtrusive nature.
+//Great thoughts come from the heart.
+//You love peace
+//Judge not according to the appearance.
+//One who admires you greatly is hidden before your eyes.
+//Traveling more often is important for your health and happiness.
+//You will be sharing great news with all people you love
+//You have a reputation for being straightforward and honest.
+//You are always welcome in any gathering.
+//You will be traveling and coming into a fortune.
+//Open up your heart - it can always be closed again.
+//Being happy is not always being perfect.
+//Next time you have the opportunity, go on a rollercoaster.
+//Try everything once, even the things you don't think you will like.
+//Life is too short to hold grudges.
+//Dream your dream and your dream will dream of you.
+//Being alone and being lonely are two different things.
+//Don't worry about things in the past, there is nothing you can do about them
+now. Don't worry about things that are happening now, make the best of a bad
+situation. Don't worry about things in the future, they may never happen.
+//Tomorrow, take a moment to do something just for yourself.
+//Someone close to you is waiting for you to call.
+//A virtual fortune cookie will not satisfy your hunger like that of a home made
+one.
+//Smile. Tomorrow is another day.
+//You can never been certain of success, but you can be certain of failure if
+you never try.
+//It takes ten times as many muscles to frown as it does to smile.
+//Shoot for the moon! If you miss you will still be amongst the stars.
+//Keep your eyes open. You never know what you might see.
+//Tell them what you really think. Otherwise, nothing will change.
+//Let your heart make your decisions - it does not get as confused as your head.
+//Working hard will make you live a happy life.
+//A pleasant surprise is waiting for you.
+QUOTES
+ );
+
+ $i = round(fmod(hexdec(hash('crc32', $seed)), count($quotes)), 0);
+ return trim(str_replace(array("\r\n", "\n", "\r"), ' ', $quotes[$i]));
+ }
+}
diff --git a/bridges/OpenClassroomsBridge.php b/bridges/OpenClassroomsBridge.php
new file mode 100644
index 0000000..5f0daca
--- /dev/null
+++ b/bridges/OpenClassroomsBridge.php
@@ -0,0 +1,49 @@
+<?php
+class OpenClassroomsBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'sebsauvage';
+ const NAME = 'OpenClassrooms Bridge';
+ const URI = 'https://openclassrooms.com/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Returns latest tutorials from OpenClassrooms.';
+
+ const PARAMETERS = array( array(
+ 'u' => array(
+ 'name' => 'Catégorie',
+ 'type' => 'list',
+ 'required' => true,
+ 'values' => array(
+ 'Arts & Culture' => 'arts',
+ 'Code' => 'code',
+ 'Design' => 'design',
+ 'Entreprise' => 'business',
+ 'Numérique' => 'digital',
+ 'Sciences' => 'sciences',
+ 'Sciences Humaines' => 'humainities',
+ 'Systèmes d\'information' => 'it',
+ 'Autres' => 'others'
+ )
+ )
+ ));
+
+ public function getURI(){
+ if(!is_null($this->getInput('u'))) {
+ return self::URI . '/courses?categories=' . $this->getInput('u') . '&title=&sort=updatedAt+desc';
+ }
+
+ return parent::getURI();
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request OpenClassrooms.');
+
+ foreach($html->find('.courseListItem') as $element) {
+ $item = array();
+ $item['uri'] = self::URI . $element->find('a', 0)->href;
+ $item['title'] = $element->find('h3', 0)->plaintext;
+ $item['content'] = $element->find('slidingItem__descriptionContent', 0)->plaintext;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/OsmAndBlogBridge.php b/bridges/OsmAndBlogBridge.php
new file mode 100644
index 0000000..402c030
--- /dev/null
+++ b/bridges/OsmAndBlogBridge.php
@@ -0,0 +1,64 @@
+<?php
+class OsmAndBlogBridge extends BridgeAbstract {
+ const NAME = 'OsmAnd Blog';
+ const URI = 'https://osmand.net/';
+ const DESCRIPTION = 'Get the latest news from OsmAnd.net';
+ const MAINTAINER = 'fulmeek';
+
+ public function collectData() {
+ $html = getSimpleHTMLDOM(self::URI . 'blog')
+ or returnServerError('Could not load content');
+
+ foreach($html->find('div.article') as $element) {
+ $item = array();
+
+ $objTitle = $element->find('h1', 0);
+ if (!$objTitle)
+ $objTitle = $element->find('h2', 0);
+ if (!$objTitle)
+ $objTitle = $element->find('h3', 0);
+ if ($objTitle)
+ $item['title'] = $objTitle->plaintext;
+
+ $objDate = $element->find('meta[pubdate]', 0);
+ if ($objDate) {
+ $item['timestamp'] = strtotime($objDate->pubdate);
+ } else {
+ $objDate = $element->find('.date', 0);
+ if ($objDate)
+ $item['timestamp'] = strtotime($objDate->plaintext);
+ }
+
+ $this->cleanupContent($element, $objTitle, $objDate, $element->find('.date', 0));
+ $item['content'] = $element->innertext;
+
+ $objLink = $html->find('.articlelinklist a', 0);
+ if ($objLink) {
+ $item['uri'] = $this->filterURL($objLink->href);
+ } else {
+ $item['uri'] = 'urn:sha1:' . hash('sha1', $item['content']);
+ }
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function filterURL($url) {
+ if (strpos($url, '://') === false)
+ return self::URI . ltrim($url, '/');
+ return $url;
+ }
+
+ private function cleanupContent($content, ...$removeItems) {
+ foreach ($removeItems as $obj) {
+ if ($obj) $obj->outertext = '';
+ }
+ foreach ($content->find('img') as $obj) {
+ $obj->src = $this->filterURL($obj->src);
+ }
+ foreach ($content->find('a') as $obj) {
+ $obj->href = $this->filterURL($obj->href);
+ $obj->target = '_blank';
+ }
+ }
+}
diff --git a/bridges/ParuVenduImmoBridge.php b/bridges/ParuVenduImmoBridge.php
new file mode 100644
index 0000000..a2e2b33
--- /dev/null
+++ b/bridges/ParuVenduImmoBridge.php
@@ -0,0 +1,102 @@
+<?php
+class ParuVenduImmoBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'polo2ro';
+ const NAME = 'Paru Vendu Immobilier';
+ const URI = 'http://www.paruvendu.fr';
+ const CACHE_TIMEOUT = 10800; // 3h
+ const DESCRIPTION = 'Returns the ads from the first page of search result.';
+
+ const PARAMETERS = array( array(
+ 'minarea' => array(
+ 'name' => 'Minimal surface m²',
+ 'type' => 'number'
+ ),
+ 'maxprice' => array(
+ 'name' => 'Max price',
+ 'type' => 'number'
+ ),
+ 'pa' => array(
+ 'name' => 'Country code',
+ 'exampleValue' => 'FR'
+ ),
+ 'lo' => array(
+ 'name' => 'department numbers or postal codes, comma-separated'
+ )
+ ));
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request paruvendu.');
+
+ foreach($html->find('div.annonce a') as $element) {
+
+ if(!$element->title) {
+ continue;
+ }
+
+ $img = '';
+ foreach($element->find('span.img img') as $img) {
+ if($img->original) {
+ $img = '<img src="' . $img->original . '" />';
+ }
+ }
+
+ $desc = $element->find('span.desc')[0]->innertext;
+ $desc = str_replace("voir l'annonce", '', $desc);
+
+ $price = $element->find('span.price')[0]->innertext;
+
+ list($href) = explode('#', $element->href);
+
+ $item = array();
+ $item['uri'] = self::URI . $href;
+ $item['title'] = $element->title;
+ $item['content'] = $img . $desc . $price;
+ $this->items[] = $item;
+ }
+ }
+
+ public function getURI(){
+ $appartment = '&tbApp=1&tbDup=1&tbChb=1&tbLof=1&tbAtl=1&tbPla=1';
+ $maison = '&tbMai=1&tbVil=1&tbCha=1&tbPro=1&tbHot=1&tbMou=1&tbFer=1';
+ $link = self::URI
+ . '/immobilier/annonceimmofo/liste/listeAnnonces?tt=1'
+ . $appartment
+ . $maison;
+
+ if($this->getInput('minarea')) {
+ $link .= '&sur0=' . urlencode($this->getInput('minarea'));
+ }
+
+ if($this->getInput('maxprice')) {
+ $link .= '&px1=' . urlencode($this->getInput('maxprice'));
+ }
+
+ if($this->getInput('pa')) {
+ $link .= '&pa=' . urlencode($this->getInput('pa'));
+ }
+
+ if($this->getInput('lo')) {
+ $link .= '&lo=' . urlencode($this->getInput('lo'));
+ }
+ return $link;
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('minarea'))) {
+ $request = '';
+ $minarea = $this->getInput('minarea');
+ if(!empty($minarea)) {
+ $request .= ' ' . $minarea . ' m2';
+ }
+ $location = $this->getInput('lo');
+ if(!empty($location)) {
+ $request .= ' In: ' . $location;
+ }
+ return 'Paru Vendu Immobilier' . $request;
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/PcGamerBridge.php b/bridges/PcGamerBridge.php
new file mode 100644
index 0000000..e0e55ce
--- /dev/null
+++ b/bridges/PcGamerBridge.php
@@ -0,0 +1,23 @@
+<?php
+class PcGamerBridge extends BridgeAbstract
+{
+ const NAME = 'PC Gamer';
+ const URI = 'https://www.pcgamer.com/';
+ const DESCRIPTION = 'PC Gamer Most Read Stories';
+ const MAINTAINER = 'mdemoss';
+
+ public function collectData()
+ {
+ $html = getSimpleHTMLDOMCached($this->getURI(), 300);
+ $stories = $html->find('div#popularcontent li.most-popular-item');
+ foreach ($stories as $element) {
+ $item['uri'] = $element->find('a', 0)->href;
+ $articleHtml = getSimpleHTMLDOMCached($item['uri']);
+ $item['title'] = $element->find('h4 a', 0)->plaintext;
+ $item['timestamp'] = strtotime($articleHtml->find('meta[name=pub_date]', 0)->content);
+ $item['content'] = $articleHtml->find('meta[name=description]', 0)->content;
+ $item['author'] = $articleHtml->find('a[itemprop=author]', 0)->plaintext;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/PickyWallpapersBridge.php b/bridges/PickyWallpapersBridge.php
new file mode 100644
index 0000000..6c26df7
--- /dev/null
+++ b/bridges/PickyWallpapersBridge.php
@@ -0,0 +1,101 @@
+<?php
+class PickyWallpapersBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'nel50n';
+ const NAME = 'PickyWallpapers Bridge';
+ const URI = 'http://www.pickywallpapers.com/';
+ const CACHE_TIMEOUT = 43200; // 12h
+ const DESCRIPTION = 'Returns the latests wallpapers from PickyWallpapers';
+
+ const PARAMETERS = array( array(
+ 'c' => array(
+ 'name' => 'category',
+ 'required' => true
+ ),
+ 's' => array(
+ 'name' => 'subcategory'
+ ),
+ 'm' => array(
+ 'name' => 'Max number of wallpapers',
+ 'defaultValue' => 12,
+ 'type' => 'number'
+ ),
+ 'r' => array(
+ 'name' => 'resolution',
+ 'exampleValue' => '1920x1200, 1680x1050,…',
+ 'defaultValue' => '1920x1200',
+ 'pattern' => '[0-9]{3,4}x[0-9]{3,4}'
+ )
+ ));
+
+ public function collectData(){
+ $lastpage = 1;
+ $num = 0;
+ $max = $this->getInput('m');
+ $resolution = $this->getInput('r'); // Wide wallpaper default
+
+ for($page = 1; $page <= $lastpage; $page++) {
+ $html = getSimpleHTMLDOM($this->getURI() . '/page-' . $page . '/')
+ or returnServerError('No results for this query.');
+
+ if($page === 1) {
+ preg_match('/page-(\d+)\/$/', $html->find('.pages li a', -2)->href, $matches);
+ $lastpage = min($matches[1], ceil($max / 12));
+ }
+
+ foreach($html->find('.items li img') as $element) {
+ $item = array();
+ $item['uri'] = str_replace('www', 'wallpaper', self::URI)
+ . '/'
+ . $resolution
+ . '/'
+ . basename($element->src);
+
+ $item['timestamp'] = time();
+ $item['title'] = $element->alt;
+ $item['content'] = $item['title']
+ . '<br><a href="'
+ . $item['uri']
+ . '">'
+ . $element
+ . '</a>';
+
+ $this->items[] = $item;
+
+ $num++;
+ if ($num >= $max)
+ break 2;
+ }
+ }
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('s')) && !is_null($this->getInput('r')) && !is_null($this->getInput('c'))) {
+ $subcategory = $this->getInput('s');
+ $link = self::URI
+ . $this->getInput('r')
+ . '/'
+ . $this->getInput('c')
+ . '/'
+ . $subcategory;
+
+ return $link;
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('s'))) {
+ $subcategory = $this->getInput('s');
+ return 'PickyWallpapers - '
+ . $this->getInput('c')
+ . ($subcategory ? ' > ' . $subcategory : '')
+ . ' ['
+ . $this->getInput('r')
+ . ']';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/PikabuBridge.php b/bridges/PikabuBridge.php
new file mode 100644
index 0000000..af603ac
--- /dev/null
+++ b/bridges/PikabuBridge.php
@@ -0,0 +1,100 @@
+<?php
+class PikabuBridge extends BridgeAbstract {
+
+ const NAME = 'Пикабу';
+ const URI = 'https://pikabu.ru';
+ const DESCRIPTION = 'Выводит посты по тегу';
+ const MAINTAINER = 'em92';
+
+ const PARAMETERS = array(
+ 'По тегу' => array(
+ 'tag' => array(
+ 'name' => 'Тег',
+ 'exampleValue' => 'it',
+ 'required' => true
+ ),
+ 'filter' => array(
+ 'name' => 'Фильтр',
+ 'type' => 'list',
+ 'values' => array(
+ 'Горячее' => 'hot',
+ 'Свежее' => 'new',
+ ),
+ 'defaultValue' => 'hot'
+ )
+ )
+ );
+
+ public function getURI() {
+ if ($this->getInput('tag')) {
+ return self::URI . '/tag/' . rawurlencode($this->getInput('tag')) . '/' . rawurlencode($this->getInput('filter'));
+ } else {
+ return parent::getURI();
+ }
+ }
+
+ public function getIcon() {
+ return 'https://cs.pikabu.ru/assets/favicon.ico';
+ }
+
+ public function getName() {
+ if (is_string($this->getInput('tag'))) {
+ return $this->getInput('tag') . ' - ' . parent::getName();
+ } else {
+ return parent::getName();
+ }
+ }
+
+ public function collectData(){
+ $link = $this->getURI();
+
+ $text_html = getContents($link) or returnServerError('Could not fetch ' . $link);
+ $text_html = iconv('windows-1251', 'utf-8', $text_html);
+ $html = str_get_html($text_html);
+
+ foreach($html->find('article.story') as $post) {
+ $time = $post->find('time.story__datetime', 0);
+ if (is_null($time)) continue;
+
+ $el_to_remove_selectors = array(
+ '.story__read-more',
+ 'svg.story-image__stretch',
+ );
+
+ foreach($el_to_remove_selectors as $el_to_remove_selector) {
+ foreach($post->find($el_to_remove_selector) as $el) {
+ $el->outertext = '';
+ }
+ }
+
+ foreach($post->find('img') as $img) {
+ $src = $img->getAttribute('src');
+ if (!$src) {
+ $src = $img->getAttribute('data-src');
+ if (!$src) {
+ continue;
+ }
+ }
+ $img->outertext = '<img src="' . $src . '">';
+ }
+
+ $categories = array();
+ foreach($post->find('.tags__tag') as $tag) {
+ if ($tag->getAttribute('data-tag')) {
+ $categories[] = $tag->innertext;
+ }
+ }
+
+ $title = $post->find('.story__title-link', 0);
+
+ $item = array();
+ $item['categories'] = $categories;
+ $item['author'] = $post->find('.user__nick', 0)->innertext;
+ $item['title'] = $title->plaintext;
+ $item['content'] = strip_tags(backgroundToImg($post->find('.story__content-inner', 0)->innertext), '<br><p><img>');
+ $item['uri'] = $title->href;
+ $item['timestamp'] = strtotime($time->getAttribute('datetime'));
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/PinterestBridge.php b/bridges/PinterestBridge.php
new file mode 100644
index 0000000..2917b26
--- /dev/null
+++ b/bridges/PinterestBridge.php
@@ -0,0 +1,125 @@
+<?php
+class PinterestBridge extends FeedExpander {
+
+ const MAINTAINER = 'pauder';
+ const NAME = 'Pinterest Bridge';
+ const URI = 'https://www.pinterest.com';
+ const DESCRIPTION = 'Returns the newest images on a board';
+
+ const PARAMETERS = array(
+ 'By username and board' => array(
+ 'u' => array(
+ 'name' => 'username',
+ 'required' => true
+ ),
+ 'b' => array(
+ 'name' => 'board',
+ 'required' => true
+ )
+ ),
+ 'From search' => array(
+ 'q' => array(
+ 'name' => 'Keyword',
+ 'required' => true
+ )
+ )
+ );
+
+ public function getIcon() {
+ return 'https://s.pinimg.com/webapp/style/images/favicon-9f8f9adf.png';
+ }
+
+ public function collectData(){
+ switch($this->queriedContext) {
+ case 'By username and board':
+ $this->collectExpandableDatas($this->getURI() . '.rss');
+ $this->fixLowRes();
+ break;
+ case 'From search':
+ default:
+ $html = getSimpleHTMLDOMCached($this->getURI());
+ $this->getSearchResults($html);
+ }
+ }
+
+ private function fixLowRes() {
+
+ $newitems = [];
+ $pattern = '/https\:\/\/i\.pinimg\.com\/[a-zA-Z0-9]*x\//';
+ foreach($this->items as $item) {
+
+ $item['content'] = preg_replace($pattern, 'https://i.pinimg.com/originals/', $item['content']);
+ $newitems[] = $item;
+ }
+ $this->items = $newitems;
+
+ }
+
+ private function getSearchResults($html){
+ $json = json_decode($html->find('#jsInit1', 0)->innertext, true);
+ $results = $json['resourceDataCache'][0]['data']['results'];
+
+ foreach($results as $result) {
+ $item = array();
+
+ $item['uri'] = self::URI . $result['board']['url'];
+
+ // Some use regular titles, others provide 'advanced' infos, a few
+ // provide even less info. Thus we attempt multiple options.
+ $item['title'] = trim($result['title']);
+
+ if($item['title'] === '')
+ $item['title'] = trim($result['rich_summary']['display_name']);
+
+ if($item['title'] === '')
+ $item['title'] = trim($result['grid_description']);
+
+ $item['timestamp'] = strtotime($result['created_at']);
+ $item['username'] = $result['pinner']['username'];
+ $item['fullname'] = $result['pinner']['full_name'];
+ $item['avatar'] = $result['pinner']['image_small_url'];
+ $item['author'] = $item['username'] . ' (' . $item['fullname'] . ')';
+ $item['content'] = '<img align="left" style="margin: 2px 4px;" src="'
+ . htmlentities($item['avatar'])
+ . '" /><p><strong>'
+ . $item['username']
+ . '</strong><br>'
+ . $item['fullname']
+ . '</p><br><img src="'
+ . $result['images']['736x']['url']
+ . '" alt="" /><br><p>'
+ . $result['description']
+ . '</p>';
+
+ $item['enclosures'] = array($result['images']['orig']['url']);
+
+ $this->items[] = $item;
+ }
+ }
+
+ public function getURI(){
+ switch($this->queriedContext) {
+ case 'By username and board':
+ $uri = self::URI . '/' . urlencode($this->getInput('u')) . '/' . urlencode($this->getInput('b'));// . '.rss';
+ break;
+ case 'From search':
+ $uri = self::URI . '/search/?q=' . urlencode($this->getInput('q'));
+ break;
+ default: return parent::getURI();
+ }
+ return $uri;
+ }
+
+ public function getName(){
+ switch($this->queriedContext) {
+ case 'By username and board':
+ $specific = $this->getInput('u') . ' - ' . $this->getInput('b');
+ break;
+ case 'From search':
+ $specific = $this->getInput('q');
+ break;
+ default: return parent::getName();
+ }
+ return $specific . ' - ' . self::NAME;
+ }
+}
diff --git a/bridges/PixivBridge.php b/bridges/PixivBridge.php
new file mode 100644
index 0000000..4af2da5
--- /dev/null
+++ b/bridges/PixivBridge.php
@@ -0,0 +1,72 @@
+<?php
+class PixivBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene';
+ const NAME = 'Pixiv Bridge';
+ const URI = 'https://www.pixiv.net/';
+ const DESCRIPTION = 'Returns the tag search from pixiv.net';
+
+
+ const PARAMETERS = array( array(
+ 'tag' => array(
+ 'name' => 'Tag to search',
+ 'exampleValue' => 'example',
+ 'required' => true
+ ),
+ ));
+
+ public function collectData(){
+
+ $html = getContents(static::URI . 'search.php?word=' . urlencode($this->getInput('tag')))
+ or returnClientError('Unable to query pixiv.net');
+ $regex = '/<input type="hidden"id="js-mount-point-search-result-list"data-items="([^"]*)/';
+ $timeRegex = '/img\/([0-9]{4})\/([0-9]{2})\/([0-9]{2})\/([0-9]{2})\/([0-9]{2})\/([0-9]{2})\//';
+
+ preg_match_all($regex, $html, $matches, PREG_SET_ORDER, 0);
+ if(!$matches) return;
+
+ $content = json_decode(html_entity_decode($matches[0][1]), true);
+ $count = 0;
+ foreach($content as $result) {
+ if($count == 10) break;
+ $count++;
+
+ $item = array();
+ $item['id'] = $result['illustId'];
+ $item['uri'] = 'https://www.pixiv.net/member_illust.php?mode=medium&illust_id=' . $result['illustId'];
+ $item['title'] = $result['illustTitle'];
+ $item['author'] = $result['userName'];
+
+ preg_match_all($timeRegex, $result['url'], $dt, PREG_SET_ORDER, 0);
+ $elementDate = DateTime::createFromFormat('YmdHis',
+ $dt[0][1] . $dt[0][2] . $dt[0][3] . $dt[0][4] . $dt[0][5] . $dt[0][6],
+ new DateTimeZone('Asia/Tokyo'));
+ $item['timestamp'] = $elementDate->getTimestamp();
+
+ $item['content'] = "<img src='" . $this->cacheImage($result['url'], $item['id']) . "' />";
+ $this->items[] = $item;
+ }
+ }
+
+ private function cacheImage($url, $illustId) {
+
+ $url = str_replace('_master1200', '', $url);
+ $url = str_replace('c/240x240/img-master/', 'img-original/', $url);
+ $path = PATH_CACHE . 'pixiv_img/';
+
+ if(!is_dir($path))
+ mkdir($path, 0755, true);
+
+ if(!is_file($path . '/' . $illustId . '.jpeg')) {
+ $headers = array('Referer: https://www.pixiv.net/member_illust.php?mode=medium&illust_id=' . $illustId);
+ $illust = getContents($url, $headers);
+ if(strpos($illust, '404 Not Found') !== false) {
+ $illust = getContents(str_replace('jpg', 'png', $url), $headers);
+ }
+ file_put_contents($path . '/' . $illustId . '.jpeg', $illust);
+ }
+
+ return 'cache/pixiv_img/' . $illustId . '.jpeg';
+
+ }
+}
diff --git a/bridges/RTBFBridge.php b/bridges/RTBFBridge.php
new file mode 100644
index 0000000..0f0acdc
--- /dev/null
+++ b/bridges/RTBFBridge.php
@@ -0,0 +1,66 @@
+<?php
+class RTBFBridge extends BridgeAbstract {
+ const NAME = 'RTBF Bridge';
+ const URI = 'http://www.rtbf.be/auvio/';
+ const CACHE_TIMEOUT = 21600; //6h
+ const DESCRIPTION = 'Returns the newest RTBF videos by series ID';
+ const MAINTAINER = 'Frenzie';
+
+ const PARAMETERS = array( array(
+ 'c' => array(
+ 'name' => 'series id',
+ 'exampleValue' => 9500,
+ 'required' => true
+ )
+ ));
+
+ public function collectData(){
+ $html = '';
+ $limit = 10;
+ $count = 0;
+
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request RTBF.');
+
+ foreach($html->find('section[id!=widget-ml-avoiraussi-] .rtbf-media-grid article') as $element) {
+ if($count >= $limit) {
+ break;
+ }
+
+ $item = array();
+ $item['id'] = $element->getAttribute('data-id');
+ $item['uri'] = self::URI . 'detail?id=' . $item['id'];
+ $thumbnailUriSrcSet = explode(
+ ',',
+ $element->find('figure .www-img-16by9 img', 0)->getAttribute('data-srcset')
+ );
+
+ $thumbnailUriLastSrc = end($thumbnailUriSrcSet);
+ $thumbnailUri = explode(' ', $thumbnailUriLastSrc)[0];
+ $item['title'] = trim($element->find('h3', 0)->plaintext)
+ . ' - '
+ . trim($element->find('h4', 0)->plaintext);
+
+ $item['timestamp'] = strtotime($element->find('time', 0)->getAttribute('datetime'));
+ $item['content'] = '<a href="' . $item['uri'] . '"><img src="' . $thumbnailUri . '" /></a>';
+ $this->items[] = $item;
+ $count++;
+ }
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('c'))) {
+ return self::URI . 'emissions/detail?id=' . $this->getInput('c');
+ }
+
+ return parent::getURI() . 'emissions/';
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('c'))) {
+ return $this->getInput('c') . ' - RTBF Bridge';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/RadioMelodieBridge.php b/bridges/RadioMelodieBridge.php
new file mode 100644
index 0000000..ca033fd
--- /dev/null
+++ b/bridges/RadioMelodieBridge.php
@@ -0,0 +1,34 @@
+<?php
+class RadioMelodieBridge extends BridgeAbstract {
+ const NAME = 'Radio Melodie Actu';
+ const URI = 'https://www.radiomelodie.com/';
+ const DESCRIPTION = 'Retourne les actualités publiées par Radio Melodie';
+ const MAINTAINER = 'sysadminstory';
+
+ public function getIcon() {
+ return self::URI . 'img/favicon.png';
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI . 'actu')
+ or returnServerError('Could not request Radio Melodie.');
+ $list = $html->find('div[class=actuitem]');
+ foreach($list as $element) {
+ $item = array();
+
+ // Get picture URL
+ $pictureHTML = $element->find('div[class=picture]');
+ preg_match(
+ '/background-image:url\((.*)\);/',
+ $pictureHTML[0]->getAttribute('style'),
+ $pictures);
+ $pictureURL = $pictures[1];
+
+ $item['enclosures'] = array($pictureURL);
+ $item['uri'] = self::URI . $element->parent()->href;
+ $item['title'] = $element->find('h3', 0)->plaintext;
+ $item['content'] = $element->find('p', 0)->plaintext . '<br/><img src="' . $pictureURL . '"/>';
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/RainbowSixSiegeBridge.php b/bridges/RainbowSixSiegeBridge.php
new file mode 100644
index 0000000..724edc8
--- /dev/null
+++ b/bridges/RainbowSixSiegeBridge.php
@@ -0,0 +1,40 @@
+<?php
+class RainbowSixSiegeBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'corenting';
+ const NAME = 'Rainbow Six Siege Blog';
+ const URI = 'https://rainbow6.ubisoft.com/siege/en-us/news/';
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'Latest articles from the Rainbow Six Siege blog';
+
+ public function getIcon() {
+ return 'https://ubistatic19-a.akamaihd.net/resource/en-us/game/rainbow6/siege-v3/r6s-favicon_316592.ico';
+ }
+
+ public function collectData(){
+ $dlUrl = 'https://prod-tridionservice.ubisoft.com/live/v1/News/Latest?templateId=tcm%3A152-7677';
+ $dlUrl .= '8-32&pageIndex=0&pageSize=10&language=en-US&detailPageId=tcm%3A150-194572-64';
+ $dlUrl .= '&keywordList=233416%2C316144%2C233418%2C233417&siteId=undefined&useSeoFriendlyUrl=true';
+ $jsonString = getContents($dlUrl) or returnServerError('Error while downloading the website content');
+
+ $json = json_decode($jsonString, true);
+ $json = $json['items'];
+
+ // Start at index 2 to remove highlighted articles
+ for($i = 0; $i < count($json); $i++) {
+ $jsonItem = $json[$i]['Content'];
+ $article = str_get_html($jsonItem);
+
+ $item = array();
+
+ $uri = $article->find('h3 a', 0)->href;
+ $uri = 'https://rainbow6.ubisoft.com' . $uri;
+ $item['uri'] = $uri;
+ $item['title'] = $article->find('h3', 0)->plaintext;
+ $item['content'] = $article->find('img', 0)->outertext . '<br />' . $article->find('strong', 0)->plaintext;
+ $item['timestamp'] = strtotime($article->find('p.news_date', 0)->plaintext);
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/ReadComicsBridge.php b/bridges/ReadComicsBridge.php
new file mode 100644
index 0000000..739e6cc
--- /dev/null
+++ b/bridges/ReadComicsBridge.php
@@ -0,0 +1,44 @@
+<?php
+class ReadComicsBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'niawag';
+ const NAME = 'Read Comics';
+ const URI = 'http://www.readcomics.tv/';
+ const DESCRIPTION = 'Enter the comics as they appear in the website uri,
+ separated by semicolons, ex: good-comic-1;good-comic-2; ...';
+
+ const PARAMETERS = array( array(
+ 'q' => array(
+ 'name' => 'keywords, separated by semicolons',
+ 'exampleValue' => 'first list;second list;...',
+ 'required' => true
+ ),
+ ));
+
+ public function collectData(){
+
+ function parseDateTimestamp($element){
+ $guessedDate = $element->find('span', 0)->plaintext;
+ $guessedDate = strptime($guessedDate, '%m/%d/%Y');
+ $timestamp = mktime(0, 0, 0, $guessedDate['tm_mon'] + 1, $guessedDate['tm_mday'], date('Y'));
+
+ return $timestamp;
+ }
+
+ $keywordsList = explode(';', $this->getInput('q'));
+ foreach($keywordsList as $keywords) {
+ $html = $this->getSimpleHTMLDOM(self::URI . 'comic/' . rawurlencode($keywords))
+ or $this->returnServerError('Could not request readcomics.tv.');
+
+ foreach($html->find('li') as $element) {
+ $item = array();
+ $item['uri'] = $element->find('a.ch-name', 0)->href;
+ $item['id'] = $item['uri'];
+ $item['timestamp'] = parseDateTimestamp($element);
+ $item['title'] = $element->find('a.ch-name', 0)->plaintext;
+ if(isset($item['title']))
+ $this->items[] = $item;
+ }
+ }
+ }
+}
diff --git a/bridges/Releases3DSBridge.php b/bridges/Releases3DSBridge.php
new file mode 100644
index 0000000..6c159d1
--- /dev/null
+++ b/bridges/Releases3DSBridge.php
@@ -0,0 +1,127 @@
+<?php
+class Releases3DSBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = '3DS Scene Releases';
+ const URI = 'http://www.3dsdb.com/';
+ const CACHE_TIMEOUT = 10800; // 3h
+ const DESCRIPTION = 'Returns the newest scene releases.';
+
+ public function collectData(){
+
+ function typeToString($type){
+ switch($type) {
+ case 1: return '3DS Game';
+ case 4: return 'eShop';
+ default: return '??? (' . $type . ')';
+ }
+ }
+
+ function cardToString($card){
+ switch($card) {
+ case 1: return 'Regular (CARD1)';
+ case 2: return 'NAND (CARD2)';
+ default: return '??? (' . $card . ')';
+ }
+ }
+
+ $dataUrl = self::URI . 'xml.php';
+ $xml = getContents($dataUrl)
+ or returnServerError('Could not request 3dsdb: ' . $dataUrl);
+ $limit = 0;
+
+ foreach(array_reverse(explode('<release>', $xml)) as $element) {
+ if($limit >= 5) {
+ break;
+ }
+
+ if(strpos($element, '</release>') === false) {
+ continue;
+ }
+
+ $releasename = extractFromDelimiters($element, '<releasename>', '</releasename>');
+ if(empty($releasename)) {
+ continue;
+ }
+
+ $id = extractFromDelimiters($element, '<id>', '</id>');
+ $name = extractFromDelimiters($element, '<name>', '</name>');
+ $publisher = extractFromDelimiters($element, '<publisher>', '</publisher>');
+ $region = extractFromDelimiters($element, '<region>', '</region>');
+ $group = extractFromDelimiters($element, '<group>', '</group>');
+ $imagesize = extractFromDelimiters($element, '<imagesize>', '</imagesize>');
+ $serial = extractFromDelimiters($element, '<serial>', '</serial>');
+ $titleid = extractFromDelimiters($element, '<titleid>', '</titleid>');
+ $imgcrc = extractFromDelimiters($element, '<imgcrc>', '</imgcrc>');
+ $filename = extractFromDelimiters($element, '<filename>', '</filename>');
+ $trimmedsize = extractFromDelimiters($element, '<trimmedsize>', '</trimmedsize>');
+ $firmware = extractFromDelimiters($element, '<firmware>', '</firmware>');
+ $type = extractFromDelimiters($element, '<type>', '</type>');
+ $card = extractFromDelimiters($element, '<card>', '</card>');
+
+ //Retrieve cover art and short desc from IGN?
+ $ignResult = false;
+ $ignDescription = '';
+ $ignLink = '';
+ $ignDate = time();
+ $ignCoverArt = '';
+
+ $ignSearchUrl = 'https://www.ign.com/search?q=' . urlencode($name);
+ if($ignResult = getSimpleHTMLDOMCached($ignSearchUrl)) {
+ $ignCoverArt = $ignResult->find('div.search-item-media', 0)->find('img', 0)->src;
+ $ignDesc = $ignResult->find('div.search-item-description', 0)->plaintext;
+ $ignLink = $ignResult->find('div.search-item-sub-title', 0)->find('a', 1)->href;
+ $ignDate = strtotime(trim($ignResult->find('span.publish-date', 0)->plaintext));
+ $ignDescription = '<div><img src="'
+ . $ignCoverArt
+ . '" /></div><div>'
+ . $ignDesc
+ . ' <a href="'
+ . $ignLink
+ . '">More at IGN</a></div>';
+ }
+
+ //Main section : Release description from 3DS database
+ $releaseDescription = '<h3>Release Details</h3><b>Release ID: </b>' . $id
+ . '<br /><b>Game Name: </b>' . $name
+ . '<br /><b>Publisher: </b>' . $publisher
+ . '<br /><b>Region: </b>' . $region
+ . '<br /><b>Group: </b>' . $group
+ . '<br /><b>Image size: </b>' . (intval($imagesize) / 8)
+ . 'MB<br /><b>Serial: </b>' . $serial
+ . '<br /><b>Title ID: </b>' . $titleid
+ . '<br /><b>Image CRC: </b>' . $imgcrc
+ . '<br /><b>File Name: </b>' . $filename
+ . '<br /><b>Release Name: </b>' . $releasename
+ . '<br /><b>Trimmed size: </b>' . intval(intval($trimmedsize) / 1048576)
+ . 'MB<br /><b>Firmware: </b>' . $firmware
+ . '<br /><b>Type: </b>' . typeToString($type)
+ . '<br /><b>Card: </b>' . cardToString($card)
+ . '<br />';
+
+ //Build search links section to facilitate release search using search engines
+ $releaseNameEncoded = urlencode(str_replace(' ', '+', $releasename));
+ $searchLinkGoogle = 'https://google.com/?q=' . $releaseNameEncoded;
+ $searchLinkDuckDuckGo = 'https://duckduckgo.com/?q=' . $releaseNameEncoded;
+ $searchLinkQwant = 'https://lite.qwant.com/?q=' . $releaseNameEncoded . '&t=web';
+ $releaseSearchLinks = '<h3>Search this release</h3><ul><li><a href="'
+ . $searchLinkGoogle
+ . '">Search using Google</a></li><li><a href="'
+ . $searchLinkDuckDuckGo
+ . '">Search using DuckDuckGo</a></li><li><a href="'
+ . $searchLinkQwant
+ . '">Search using Qwant</a></li></ul>';
+
+ //Build and add final item with the above three sections
+ $item = array();
+ $item['title'] = $name;
+ $item['author'] = $publisher;
+ $item['timestamp'] = $ignDate;
+ $item['enclosures'] = array($ignCoverArt);
+ $item['uri'] = empty($ignLink) ? $searchLinkDuckDuckGo : $ignLink;
+ $item['content'] = $ignDescription . $releaseDescription . $releaseSearchLinks;
+ $this->items[] = $item;
+ $limit++;
+ }
+ }
+}
diff --git a/bridges/ReporterreBridge.php b/bridges/ReporterreBridge.php
new file mode 100644
index 0000000..438c55b
--- /dev/null
+++ b/bridges/ReporterreBridge.php
@@ -0,0 +1,47 @@
+<?php
+class ReporterreBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'nyutag';
+ const NAME = 'Reporterre Bridge';
+ const URI = 'http://www.reporterre.net/';
+ const DESCRIPTION = 'Returns the newest articles.';
+
+ private function extractContent($url){
+ $html2 = getSimpleHTMLDOM($url);
+
+ foreach($html2->find('div[style=text-align:justify]') as $e) {
+ $text = $e->outertext;
+ }
+
+ $html2->clear();
+ unset($html2);
+
+ // Replace all relative urls with absolute ones
+ $text = preg_replace(
+ '/(href|src)(\=[\"\'])(?!http)([^"\']+)/ims',
+ '$1$2' . self::URI . '$3',
+ $text
+ );
+
+ $text = strip_tags($text, '<p><br><a><img>');
+ return $text;
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI . 'spip.php?page=backend')
+ or returnServerError('Could not request Reporterre.');
+ $limit = 0;
+
+ foreach($html->find('item') as $element) {
+ if($limit < 5) {
+ $item = array();
+ $item['title'] = html_entity_decode($element->find('title', 0)->plaintext);
+ $item['timestamp'] = strtotime($element->find('dc:date', 0)->plaintext);
+ $item['uri'] = $element->find('guid', 0)->innertext;
+ $item['content'] = html_entity_decode($this->extractContent($item['uri']));
+ $this->items[] = $item;
+ $limit++;
+ }
+ }
+ }
+}
diff --git a/bridges/Rue89Bridge.php b/bridges/Rue89Bridge.php
new file mode 100644
index 0000000..934ef99
--- /dev/null
+++ b/bridges/Rue89Bridge.php
@@ -0,0 +1,48 @@
+<?php
+class Rue89Bridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene';
+ const NAME = 'Rue89';
+ const URI = 'https://www.nouvelobs.com/rue89/';
+ const DESCRIPTION = 'Returns the newest posts from Rue89';
+
+ public function collectData() {
+
+ $jsonArticles = getContents('https://appdata.nouvelobs.com/rue89/feed.json')
+ or die('Unable to query Rue89 !');
+ $articles = json_decode($jsonArticles)->items;
+ foreach($articles as $article) {
+ $this->items[] = $this->getArticle($article);
+ }
+
+ }
+
+ private function getArticle($articleInfo) {
+
+ $articleJson = getContents($articleInfo->json_url) or die('Unable to get article !');
+ $article = json_decode($articleJson);
+ $item = array();
+ $item['title'] = $article->title;
+ $item['uri'] = $article->url;
+ if($article->content_premium !== null) {
+ $item['content'] = $article->content_premium;
+ } else {
+ $item['content'] = $article->content;
+ }
+ $item['timestamp'] = $article->date_publi;
+ $item['author'] = $article->author->show_name;
+
+ $item['enclosures'] = array();
+ foreach($article->images as $image) {
+ $item['enclosures'][] = $image->url;
+ }
+
+ $item['categories'] = array();
+ foreach($article->categories as $category) {
+ $item['categories'][] = $category->title;
+ }
+
+ return $item;
+
+ }
+}
diff --git a/bridges/Rule34Bridge.php b/bridges/Rule34Bridge.php
new file mode 100644
index 0000000..b46ec00
--- /dev/null
+++ b/bridges/Rule34Bridge.php
@@ -0,0 +1,12 @@
+<?php
+require_once('GelbooruBridge.php');
+
+class Rule34Bridge extends GelbooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Rule34';
+ const URI = 'http://rule34.xxx/';
+ const DESCRIPTION = 'Returns images from given page';
+
+ const PIDBYPAGE = 50;
+}
diff --git a/bridges/Rule34pahealBridge.php b/bridges/Rule34pahealBridge.php
new file mode 100644
index 0000000..1a74616
--- /dev/null
+++ b/bridges/Rule34pahealBridge.php
@@ -0,0 +1,10 @@
+<?php
+require_once('Shimmie2Bridge.php');
+
+class Rule34pahealBridge extends Shimmie2Bridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Rule34paheal';
+ const URI = 'http://rule34.paheal.net/';
+ const DESCRIPTION = 'Returns images from given page';
+}
diff --git a/bridges/SafebooruBridge.php b/bridges/SafebooruBridge.php
new file mode 100644
index 0000000..d95e557
--- /dev/null
+++ b/bridges/SafebooruBridge.php
@@ -0,0 +1,12 @@
+<?php
+require_once('GelbooruBridge.php');
+
+class SafebooruBridge extends GelbooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Safebooru';
+ const URI = 'http://safebooru.org/';
+ const DESCRIPTION = 'Returns images from given page';
+
+ const PIDBYPAGE = 40;
+}
diff --git a/bridges/SakugabooruBridge.php b/bridges/SakugabooruBridge.php
new file mode 100644
index 0000000..1d6cee0
--- /dev/null
+++ b/bridges/SakugabooruBridge.php
@@ -0,0 +1,11 @@
+<?php
+require_once('MoebooruBridge.php');
+
+class SakugabooruBridge extends MoebooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Sakugabooru';
+ const URI = 'http://sakuga.yshi.org/';
+ const DESCRIPTION = 'Returns images from given page';
+
+}
diff --git a/bridges/ScmbBridge.php b/bridges/ScmbBridge.php
new file mode 100644
index 0000000..2107aa3
--- /dev/null
+++ b/bridges/ScmbBridge.php
@@ -0,0 +1,39 @@
+<?php
+class ScmbBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Astalaseven';
+ const NAME = 'Se Coucher Moins Bête Bridge';
+ const URI = 'http://secouchermoinsbete.fr';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Returns the newest anecdotes.';
+
+ public function collectData(){
+ $html = '';
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request Se Coucher Moins Bete.');
+
+ foreach($html->find('article') as $article) {
+ $item = array();
+ $item['uri'] = self::URI . $article->find('p.summary a', 0)->href;
+ $item['title'] = $article->find('header h1 a', 0)->innertext;
+
+ // remove text "En savoir plus" from anecdote content
+ $article->find('span.read-more', 0)->outertext = '';
+ $content = $article->find('p.summary a', 0)->innertext;
+
+ // remove superfluous spaces at the end
+ $content = substr($content, 0, strlen($content) - 17);
+
+ // get publication date
+ $str_date = $article->find('time', 0)->datetime;
+ list($date, $time) = explode(' ', $str_date);
+ list($y, $m, $d) = explode('-', $date);
+ list($h, $i) = explode(':', $time);
+ $timestamp = mktime($h, $i, 0, $m, $d, $y);
+ $item['timestamp'] = $timestamp;
+
+ $item['content'] = $content;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/ScoopItBridge.php b/bridges/ScoopItBridge.php
new file mode 100644
index 0000000..997837d
--- /dev/null
+++ b/bridges/ScoopItBridge.php
@@ -0,0 +1,42 @@
+<?php
+class ScoopItBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Pitchoule';
+ const NAME = 'ScoopIt';
+ const URI = 'http://www.scoop.it/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Returns most recent results from ScoopIt.';
+
+ const PARAMETERS = array( array(
+ 'u' => array(
+ 'name' => 'keyword',
+ 'required' => true
+ )
+ ));
+
+ public function collectData(){
+ $this->request = $this->getInput('u');
+ $link = self::URI . 'search?q=' . urlencode($this->getInput('u'));
+
+ $html = getSimpleHTMLDOM($link)
+ or returnServerError('Could not request ScoopIt. for : ' . $link);
+
+ foreach($html->find('div.post-view') as $element) {
+ $item = array();
+ $item['uri'] = $element->find('a', 0)->href;
+ $item['title'] = preg_replace(
+ '~[[:cntrl:]]~',
+ '',
+ $element->find('div.tCustomization_post_title', 0)->plaintext
+ );
+
+ $item['content'] = preg_replace(
+ '~[[:cntrl:]]~',
+ '',
+ $element->find('div.tCustomization_post_description', 0)->plaintext
+ );
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/SensCritiqueBridge.php b/bridges/SensCritiqueBridge.php
new file mode 100644
index 0000000..7ac35f2
--- /dev/null
+++ b/bridges/SensCritiqueBridge.php
@@ -0,0 +1,97 @@
+<?php
+class SensCritiqueBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'kranack';
+ const NAME = 'Sens Critique';
+ const URI = 'http://www.senscritique.com/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Sens Critique news';
+
+ const PARAMETERS = array( array(
+ 'm' => array(
+ 'name' => 'Movies',
+ 'type' => 'checkbox'
+ ),
+ 's' => array(
+ 'name' => 'Series',
+ 'type' => 'checkbox'
+ ),
+ 'g' => array(
+ 'name' => 'Video Games',
+ 'type' => 'checkbox'
+ ),
+ 'b' => array(
+ 'name' => 'Books',
+ 'type' => 'checkbox'
+ ),
+ 'bd' => array(
+ 'name' => 'BD',
+ 'type' => 'checkbox'
+ ),
+ 'mu' => array(
+ 'name' => 'Music',
+ 'type' => 'checkbox'
+ )
+ ));
+
+ public function collectData(){
+ $categories = array();
+ foreach(self::PARAMETERS[$this->queriedContext] as $category => $properties) {
+ if($this->getInput($category)) {
+ $uri = self::URI;
+ switch($category) {
+ case 'm': $uri .= 'films/cette-semaine';
+ break;
+ case 's': $uri .= 'series/actualite';
+ break;
+ case 'g': $uri .= 'jeuxvideo/actualite';
+ break;
+ case 'b': $uri .= 'livres/actualite';
+ break;
+ case 'bd': $uri .= 'bd/actualite';
+ break;
+ case 'mu': $uri .= 'musique/actualite';
+ break;
+ }
+ $html = getSimpleHTMLDOM($uri)
+ or returnServerError('No results for this query.');
+ $list = $html->find('ul.elpr-list', 0);
+
+ $this->extractDataFromList($list);
+ }
+ }
+ }
+
+ private function extractDataFromList($list){
+ if($list === null) {
+ returnClientError('Cannot extract data from list');
+ }
+
+ foreach($list->find('li') as $movie) {
+ $item = array();
+ $item['author'] = htmlspecialchars_decode($movie->find('.elco-title a', 0)->plaintext, ENT_QUOTES)
+ . ' '
+ . $movie->find('.elco-date', 0)->plaintext;
+
+ $item['title'] = $movie->find('.elco-title a', 0)->plaintext
+ . ' '
+ . $movie->find('.elco-date', 0)->plaintext;
+
+ $item['content'] = '<em>'
+ . $movie->find('.elco-original-title', 0)->plaintext
+ . '</em><br><br>'
+ . $movie->find('.elco-baseline', 0)->plaintext
+ . '<br>'
+ . $movie->find('.elco-baseline', 1)->plaintext
+ . '<br><br>'
+ . $movie->find('.elco-description', 0)->plaintext
+ . '<br><br>'
+ . trim($movie->find('.erra-ratings .erra-global', 0)->plaintext)
+ . ' / 10';
+
+ $item['id'] = $this->getURI() . $movie->find('.elco-title a', 0)->href;
+ $item['uri'] = $this->getURI() . $movie->find('.elco-title a', 0)->href;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/ShanaprojectBridge.php b/bridges/ShanaprojectBridge.php
new file mode 100644
index 0000000..6eadcb1
--- /dev/null
+++ b/bridges/ShanaprojectBridge.php
@@ -0,0 +1,123 @@
+<?php
+class ShanaprojectBridge extends BridgeAbstract {
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'Shanaproject Bridge';
+ const URI = 'http://www.shanaproject.com';
+ const DESCRIPTION = 'Returns a list of anime from the current Season Anime List';
+
+ // Returns an html object for the Season Anime List (latest season)
+ private function loadSeasonAnimeList(){
+ // First we need to find the URI to the latest season from the
+ // 'seasons' page searching for 'Season Anime List'
+ $html = getSimpleHTMLDOM($this->getURI() . '/seasons');
+ if(!$html)
+ returnServerError('Could not load \'seasons\' page!');
+
+ $season = $html->find('div.follows_menu/a', 1);
+ if(!$season)
+ returnServerError('Could not find \'Season Anime List\'!');
+
+ $html = getSimpleHTMLDOM($this->getURI() . $season->href);
+ if(!$html)
+ returnServerError(
+ 'Could not load \'Season Anime List\' from \''
+ . $season->innertext
+ . '\'!'
+ );
+
+ return $html;
+ }
+
+ // Extracts the anime title
+ private function extractAnimeTitle($anime){
+ $title = $anime->find('a', 0);
+ if(!$title)
+ returnServerError('Could not find anime title!');
+ return trim($title->innertext);
+ }
+
+ // Extracts the anime URI
+ private function extractAnimeUri($anime){
+ $uri = $anime->find('a', 0);
+ if(!$uri)
+ returnServerError('Could not find anime URI!');
+ return $this->getURI() . $uri->href;
+ }
+
+ // Extracts the anime release date (timestamp)
+ private function extractAnimeTimestamp($anime){
+ $timestamp = $anime->find('span.header_info_block', 1);
+ if(!$timestamp)
+ return null;
+ return strtotime($timestamp->innertext);
+ }
+
+ // Extracts the anime studio name (author)
+ private function extractAnimeAuthor($anime){
+ $author = $anime->find('span.header_info_block', 2);
+ if(!$author)
+ return; // Sometimes the studio is unknown, so leave empty
+ return trim($author->innertext);
+ }
+
+ // Extracts the episode information (x of y released)
+ private function extractAnimeEpisodeInformation($anime){
+ $episode = $anime->find('div.header_info_episode', 0);
+ if(!$episode)
+ returnServerError('Could not find anime episode information!');
+ return preg_replace('/\r|\n/', ' ', $episode->plaintext);
+ }
+
+ // Extracts the background image
+ private function extractAnimeBackgroundImage($anime){
+ // Getting the picture is a little bit tricky as it is part of the style.
+ // Luckily the style is part of the parent div :)
+
+ if(preg_match('/url\(\/\/([^\)]+)\)/i', $anime->parent->style, $matches))
+ return $matches[1];
+
+ returnServerError('Could not extract background image!');
+ }
+
+ // Builds an URI to search for a specific anime (subber is left empty)
+ private function buildAnimeSearchUri($anime){
+ return $this->getURI()
+ . '/search/?title='
+ . urlencode($this->extractAnimeTitle($anime))
+ . '&subber=';
+ }
+
+ // Builds the content string for a given anime
+ private function buildAnimeContent($anime){
+ // We'll use a template string to place our contents
+ return '<a href="'
+ . $this->extractAnimeUri($anime)
+ . '"><img src="http://'
+ . $this->extractAnimeBackgroundImage($anime)
+ . '" alt="'
+ . htmlspecialchars($this->extractAnimeTitle($anime))
+ . '" style="border: 1px solid black"></a><br><p>'
+ . $this->extractAnimeEpisodeInformation($anime)
+ . '</p><br><p><a href="'
+ . $this->buildAnimeSearchUri($anime)
+ . '">Search episodes</a></p>';
+ }
+
+ public function collectData(){
+ $html = $this->loadSeasonAnimeList();
+
+ $animes = $html->find('div.header_display_box_info');
+ if(!$animes)
+ returnServerError('Could not find anime headers!');
+
+ foreach($animes as $anime) {
+ $item = array();
+ $item['title'] = $this->extractAnimeTitle($anime);
+ $item['author'] = $this->extractAnimeAuthor($anime);
+ $item['uri'] = $this->extractAnimeUri($anime);
+ $item['timestamp'] = $this->extractAnimeTimestamp($anime);
+ $item['content'] = $this->buildAnimeContent($anime);
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/Shimmie2Bridge.php b/bridges/Shimmie2Bridge.php
new file mode 100644
index 0000000..9923514
--- /dev/null
+++ b/bridges/Shimmie2Bridge.php
@@ -0,0 +1,38 @@
+<?php
+require_once('DanbooruBridge.php');
+
+class Shimmie2Bridge extends DanbooruBridge {
+
+ const NAME = 'Shimmie v2';
+ const URI = 'http://shimmie.shishnet.org/v2/';
+ const DESCRIPTION = 'Returns images from given page';
+
+ const PATHTODATA = '.shm-thumb-link';
+ const IDATTRIBUTE = 'data-post-id';
+
+ protected function getFullURI(){
+ return $this->getURI()
+ . 'post/list/'
+ . $this->getInput('t')
+ . '/'
+ . $this->getInput('p');
+ }
+
+ protected function getItemFromElement($element){
+ $item = array();
+ $item['uri'] = $this->getURI() . $element->href;
+ $item['id'] = (int)preg_replace('/[^0-9]/', '', $element->getAttribute(static::IDATTRIBUTE));
+ $item['timestamp'] = time();
+ $thumbnailUri = $this->getURI() . $element->find('img', 0)->src;
+ $item['tags'] = $element->getAttribute('data-tags');
+ $item['title'] = $this->getName() . ' | ' . $item['id'];
+ $item['content'] = '<a href="'
+ . $item['uri']
+ . '"><img src="'
+ . $thumbnailUri
+ . '" /></a><br>Tags: '
+ . $item['tags'];
+
+ return $item;
+ }
+}
diff --git a/bridges/SkimfeedBridge.php b/bridges/SkimfeedBridge.php
new file mode 100644
index 0000000..9fdd454
--- /dev/null
+++ b/bridges/SkimfeedBridge.php
@@ -0,0 +1,823 @@
+<?php
+
+class SkimfeedBridge extends BridgeAbstract {
+
+ const CONTEXT_NEWS_BOX = 'News box';
+ const CONTEXT_HOT_TOPICS = 'Hot topics';
+ const CONTEXT_TECH_NEWS = 'Tech news';
+ const CONTEXT_CUSTOM = 'Custom feed';
+
+ const NAME = 'Skimfeed Bridge';
+ const URI = 'https://skimfeed.com';
+ const DESCRIPTION = 'Returns feeds from Skimfeed, also supports custom feeds!';
+ const MAINTAINER = 'logmanoriginal';
+ const CACHE_TIMEOUT = 3600;
+
+ const PARAMETERS = array(
+ self::CONTEXT_NEWS_BOX => array( // auto-generated (see below)
+ 'box_channel' => array(
+ 'name' => 'Channel',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Select your channel',
+ 'values' => array(
+ 'Hacker News' => '/news/hacker-news.html',
+ 'QZ' => '/news/qz.html',
+ 'The Verge' => '/news/the-verge.html',
+ 'Slashdot' => '/news/slashdot.html',
+ 'Lifehacker' => '/news/lifehacker.html',
+ 'Gizmag' => '/news/gizmag.html',
+ 'Fast Company' => '/news/fast-company.html',
+ 'Engadget' => '/news/engadget.html',
+ 'Wired' => '/news/wired.html',
+ 'MakeUseOf' => '/news/makeuseof.html',
+ 'Techcrunch' => '/news/techcrunch.html',
+ 'Apple Insider' => '/news/apple-insider.html',
+ 'ArsTechnica' => '/news/arstechnica.html',
+ 'Tech in Asia' => '/news/tech-in-asia.html',
+ 'FastCoExist' => '/news/fastcoexist.html',
+ 'Digital Trends' => '/news/digital-trends.html',
+ 'AnandTech' => '/news/anandtech.html',
+ 'How to Geek' => '/news/how-to-geek.html',
+ 'Geek' => '/news/geek.html',
+ 'BBC Technology' => '/news/bbc-technology.html',
+ 'Extreme Tech' => '/news/extreme-tech.html',
+ 'Packet Storm Sec' => '/news/packet-storm-sec.html',
+ 'MedGadget' => '/news/medgadget.html',
+ 'Design' => '/news/design.html',
+ 'The Next Web' => '/news/the-next-web.html',
+ 'Bit-Tech' => '/news/bit-tech.html',
+ 'Next Big Future' => '/news/next-big-future.html',
+ 'A VC' => '/news/a-vc.html',
+ 'Copyblogger' => '/news/copyblogger.html',
+ 'Smashing Mag' => '/news/smashing-mag.html',
+ 'Continuations' => '/news/continuations.html',
+ 'Cult of Mac' => '/news/cult-of-mac.html',
+ 'SecuriTeam' => '/news/securiteam.html',
+ 'The Tech Block' => '/news/the-tech-block.html',
+ 'BetaBeat' => '/news/betabeat.html',
+ 'PC Mag' => '/news/pc-mag.html',
+ 'Venture Beat' => '/news/venture-beat.html',
+ 'ReadWriteWeb' => '/news/readwriteweb.html',
+ 'High Scalability' => '/news/high-scalability.html',
+ )
+ )
+ ),
+ self::CONTEXT_HOT_TOPICS => array(),
+ self::CONTEXT_TECH_NEWS => array( // auto-generated (see below)
+ 'tech_channel' => array(
+ 'name' => 'Tech channel',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Select your tech channel',
+ 'values' => array(
+ 'Agg' => array(
+ 'Reddit' => '/news/reddit.html',
+ 'Tech Insider' => '/news/tech-insider.html',
+ 'Digg' => '/news/digg.html',
+ 'Meta Filter' => '/news/meta-filter.html',
+ 'Fark' => '/news/fark.html',
+ 'Mashable' => '/news/mashable.html',
+ 'Ad Week' => '/news/ad-week.html',
+ 'The Chive' => '/news/the-chive.html',
+ 'BoingBoing' => '/news/boingboing.html',
+ 'Vice' => '/news/vice.html',
+ 'ClientsFromHell' => '/news/clientsfromhell.html',
+ 'How Stuff Works' => '/news/how-stuff-works.html',
+ 'Buzzfeed' => '/news/buzzfeed.html',
+ 'BoingBoing' => '/news/boingboing.html',
+ 'Cracked' => '/news/cracked.html',
+ 'Weird News' => '/news/weird-news.html',
+ 'ITOTD' => '/news/itotd.html',
+ 'Metafilter' => '/news/metafilter.html',
+ 'TheOnion' => '/news/theonion.html',
+ ),
+ 'Cars' => array(
+ 'Reddit Cars' => '/news/reddit-cars.html',
+ 'NYT Auto' => '/news/nyt-auto.html',
+ 'Truth About Cars' => '/news/truth-about-cars.html',
+ 'AutoBlog' => '/news/autoblog.html',
+ 'AutoSpies' => '/news/autospies.html',
+ 'Autoweek' => '/news/autoweek.html',
+ 'The Garage' => '/news/the-garage.html',
+ 'Car and Driver' => '/news/car-and-driver.html',
+ 'EGM Car Tech' => '/news/egm-car-tech.html',
+ 'Top Gear' => '/news/top-gear.html',
+ 'eGarage' => '/news/egarage.html',
+ ),
+ 'Comics' => array(
+ 'Penny Arcade' => '/news/penny-arcade.html',
+ 'XKCD' => '/news/xkcd.html',
+ 'Channelate' => '/news/channelate.html',
+ 'Savage Chicken' => '/news/savage-chicken.html',
+ 'Dinosaur Comics' => '/news/dinosaur-comics.html',
+ 'Explosm' => '/news/explosm.html',
+ 'PoorlyDLines' => '/news/poorlydlines.html',
+ 'Moonbeard' => '/news/moonbeard.html',
+ 'Nedroid' => '/news/nedroid.html',
+ ),
+ 'Design' => array(
+ 'FastCoCreate' => '/news/fastcocreate.html',
+ 'Dezeen' => '/news/dezeen.html',
+ 'Design Boom' => '/news/design-boom.html',
+ 'Mmminimal' => '/news/mmminimal.html',
+ 'We Heart' => '/news/we-heart.html',
+ 'CreativeBloq' => '/news/creativebloq.html',
+ 'TheDSGNblog' => '/news/thedsgnblog.html',
+ 'Grainedit' => '/news/grainedit.html',
+ ),
+ 'Football' => array(
+ 'Mail Football' => '/news/mail-football.html',
+ 'Yahoo Football' => '/news/yahoo-football.html',
+ 'FourFourTwo' => '/news/fourfourtwo.html',
+ 'Goal' => '/news/goal.html',
+ 'BBC Football' => '/news/bbc-football.html',
+ 'TalkSport' => '/news/talksport.html',
+ '101 Great Goals' => '/news/101-great-goals.html',
+ 'Who Scored' => '/news/who-scored.html',
+ 'Football365 Champ' => '/news/football365-champ.html',
+ 'Football365 Premier' => '/news/football365-premier.html',
+ 'BleacherReport' => '/news/bleacherreport.html',
+ ),
+ 'Gaming' => array(
+ 'Polygon' => '/news/polygon.html',
+ 'Gamespot' => '/news/gamespot.html',
+ 'RockPaperShotgun' => '/news/rockpapershotgun.html',
+ 'VG247' => '/news/vg247.html',
+ 'IGN' => '/news/ign.html',
+ 'Reddit Games' => '/news/reddit-games.html',
+ 'TouchArcade' => '/news/toucharcade.html',
+ 'GamesRadar' => '/news/gamesradar.html',
+ 'Siliconera' => '/news/siliconera.html',
+ 'Reddit GameDeals' => '/news/reddit-gamedeals.html',
+ 'Joystiq' => '/news/joystiq.html',
+ 'GameInformer' => '/news/gameinformer.html',
+ 'PSN Blog' => '/news/psn-blog.html',
+ 'Reddit GamerNews' => '/news/reddit-gamernews.html',
+ 'Steam' => '/news/steam.html',
+ 'DualShockers' => '/news/dualshockers.html',
+ 'ShackNews' => '/news/shacknews.html',
+ 'CheapAssGamer' => '/news/cheapassgamer.html',
+ 'Eurogamer' => '/news/eurogamer.html',
+ 'Major Nelson' => '/news/major-nelson.html',
+ 'Reddit Truegaming' => '/news/reddit-truegaming.html',
+ 'GameTrailers' => '/news/gametrailers.html',
+ 'GamaSutra' => '/news/gamasutra.html',
+ 'USGamer' => '/news/usgamer.html',
+ 'Shoryuken' => '/news/shoryuken.html',
+ 'Destructoid' => '/news/destructoid.html',
+ 'ArsGaming' => '/news/arsgaming.html',
+ 'XBOX Blog' => '/news/xbox-blog.html',
+ 'GiantBomb' => '/news/giantbomb.html',
+ 'VideoGamer' => '/news/videogamer.html',
+ 'Pocket Tactics' => '/news/pocket-tactics.html',
+ 'WiredGaming' => '/news/wiredgaming.html',
+ 'AllGamesBeta' => '/news/allgamesbeta.html',
+ 'OnGamers' => '/news/ongamers.html',
+ 'Reddit GameBundles' => '/news/reddit-gamebundles.html',
+ 'Kotaku' => '/news/kotaku.html',
+ 'PCGamer' => '/news/pcgamer.html',
+ ),
+ 'Investing' => array(
+ 'Seeking Alpha' => '/news/seeking-alpha.html',
+ 'BBC Business' => '/news/bbc-business.html',
+ 'Harvard Biz' => '/news/harvard-biz.html',
+ 'Market Watch' => '/news/market-watch.html',
+ 'Investor Place' => '/news/investor-place.html',
+ 'Money Week' => '/news/money-week.html',
+ 'Moneybeat' => '/news/moneybeat.html',
+ 'Dealbook' => '/news/dealbook.html',
+ 'Economist Business' => '/news/economist-business.html',
+ 'Economist' => '/news/economist.html',
+ 'Economist CN' => '/news/economist-cn.html',
+ ),
+ 'Long' => array(
+ 'The Atlantic' => '/news/the-atlantic.html',
+ 'Reddit Long' => '/news/reddit-long.html',
+ 'Paris Review' => '/news/paris-review.html',
+ 'New Yorker' => '/news/new-yorker.html',
+ 'LongForm' => '/news/longform.html',
+ 'LongReads' => '/news/longreads.html',
+ 'The Browser' => '/news/the-browser.html',
+ 'The Feature' => '/news/the-feature.html',
+ ),
+ 'MMA' => array(
+ 'MMA Weekly' => '/news/mma-weekly.html',
+ 'MMAFighting' => '/news/mmafighting.html',
+ 'Reddit MMA' => '/news/reddit-mma.html',
+ 'Sherdog Articles' => '/news/sherdog-articles.html',
+ 'FightLand Vice' => '/news/fightland-vice.html',
+ 'Sherdog Forum' => '/news/sherdog-forum.html',
+ 'MMA Junkie' => '/news/mma-junkie.html',
+ 'Sherdog MMA Video' => '/news/sherdog-mma-video.html',
+ 'BloodyElbow' => '/news/bloodyelbow.html',
+ 'CageWriter' => '/news/cagewriter.html',
+ 'Sherdog News' => '/news/sherdog-news.html',
+ 'MMAForum' => '/news/mmaforum.html',
+ 'MMA Junkie Radio' => '/news/mma-junkie-radio.html',
+ 'UFC News' => '/news/ufc-news.html',
+ 'FightLinker' => '/news/fightlinker.html',
+ 'Bodybuilding MMA' => '/news/bodybuilding-mma.html',
+ 'BleacherReport MMA' => '/news/bleacherreport-mma.html',
+ 'FiveOuncesofPain' => '/news/fiveouncesofpain.html',
+ 'Sherdog Pictures' => '/news/sherdog-pictures.html',
+ 'CagePotato' => '/news/cagepotato.html',
+ 'Sherdog Radio' => '/news/sherdog-radio.html',
+ 'ProMMARadio' => '/news/prommaradio.html',
+ ),
+ 'Mobile' => array(
+ 'Macrumors' => '/news/macrumors.html',
+ 'Android Police' => '/news/android-police.html',
+ 'GSM Arena' => '/news/gsm-arena.html',
+ 'DigiTrend Mobile' => '/news/digitrend-mobile.html',
+ 'Mobile Nation' => '/news/mobile-nation.html',
+ 'TechRadar' => '/news/techradar.html',
+ 'ZDNET Mobile' => '/news/zdnet-mobile.html',
+ 'MacWorld' => '/news/macworld.html',
+ 'Android Dev Blog' => '/news/android-dev-blog.html',
+ ),
+ 'News' => array(
+ 'Daily Mail' => '/news/daily-mail.html',
+ 'Business Insider' => '/news/business-insider.html',
+ 'The Guardian' => '/news/the-guardian.html',
+ 'Fox' => '/news/fox.html',
+ 'BBC World' => '/news/bbc-world.html',
+ 'MSNBC' => '/news/msnbc.html',
+ 'ABC News' => '/news/abc-news.html',
+ 'Al Jazeera' => '/news/al-jazeera.html',
+ 'Business Insider India' => '/news/business-insider-india.html',
+ 'Observer' => '/news/observer.html',
+ 'NYT Tech' => '/news/nyt-tech.html',
+ 'NYT World' => '/news/nyt-world.html',
+ 'CNN' => '/news/cnn.html',
+ 'Japan Times' => '/news/japan-times.html',
+ 'WorldCrunch' => '/news/worldcrunch.html',
+ 'Pro publica' => '/news/pro-publica.html',
+ 'OZY' => '/news/ozy.html',
+ 'Times of India' => '/news/times-of-india.html',
+ 'The Australian' => '/news/the-australian.html',
+ 'Harpers' => '/news/harpers.html',
+ 'Moscow Times' => '/news/moscow-times.html',
+ 'The Times' => '/news/the-times.html',
+ 'Reuters Tech' => '/news/reuters-tech.html',
+ ),
+ 'Politics' => array(
+ 'FreeRepublic' => '/news/freerepublic.html',
+ 'Salon' => '/news/salon.html',
+ 'DrudgeReport' => '/news/drudgereport.html',
+ 'TheHill' => '/news/thehill.html',
+ 'TheBlaze' => '/news/theblaze.html',
+ 'InfoWars' => '/news/infowars.html',
+ 'New Republic' => '/news/new-republic.html',
+ 'WashTimes' => '/news/washtimes.html',
+ 'RealCleanPol' => '/news/realcleanpol.html',
+ 'Fact Check' => '/news/fact-check.html',
+ 'DailyKos' => '/news/dailykos.html',
+ 'NewsMax' => '/news/newsmax.html',
+ 'Politico' => '/news/politico.html',
+ 'Michelle Malkin' => '/news/michelle-malkin.html',
+ ),
+ 'Reddit' => array(
+ 'R Movies' => '/news/r-movies.html',
+ 'R News' => '/news/r-news.html',
+ 'Futurology' => '/news/futurology.html',
+ 'R All' => '/news/r-all.html',
+ 'R Music' => '/news/r-music.html',
+ 'R Askscience' => '/news/r-askscience.html',
+ 'R Technology' => '/news/r-technology.html',
+ 'R Bestof' => '/news/r-bestof.html',
+ 'R Askreddit' => '/news/r-askreddit.html',
+ 'R Worldnews' => '/news/r-worldnews.html',
+ 'R Explainlikeimfive' => '/news/r-explainlikeimfive.html',
+ 'R Iama' => '/news/r-iama.html',
+ ),
+ 'Science' => array(
+ 'PhysOrg' => '/news/physorg.html',
+ 'Hack-a-day' => '/news/hack-a-day.html',
+ 'Reddit Science' => '/news/reddit-science.html',
+ 'Stats Blog' => '/news/stats-blog.html',
+ 'Flowing Data' => '/news/flowing-data.html',
+ 'Eureka Alert' => '/news/eureka-alert.html',
+ 'Robotics BizRev' => '/news/robotics-bizrev.html',
+ 'Planet big Data' => '/news/planet-big-data.html',
+ 'Makezine' => '/news/makezine.html',
+ 'MIT Tech' => '/news/mit-tech.html',
+ 'R Bloggers' => '/news/r-bloggers.html',
+ 'DataIsBeautiful' => '/news/dataisbeautiful.html',
+ 'Ted Videos' => '/news/ted-videos.html',
+ 'Advanced Science' => '/news/advanced-science.html',
+ 'Robotiq' => '/news/robotiq.html',
+ 'Science Daily' => '/news/science-daily.html',
+ 'IEEE Robotics' => '/news/ieee-robotics.html',
+ 'PSFK' => '/news/psfk.html',
+ 'Discover Magazine' => '/news/discover-magazine.html',
+ 'DataTau' => '/news/datatau.html',
+ 'RoboHub' => '/news/robohub.html',
+ 'Discovery' => '/news/discovery.html',
+ 'Smart Data' => '/news/smart-data.html',
+ 'Whats Big Data' => '/news/whats-big-data.html',
+ ),
+ 'Tech' => array(
+ 'Hacker News' => '/news/hacker-news.html',
+ 'The Verge' => '/news/the-verge.html',
+ 'Lifehacker' => '/news/lifehacker.html',
+ 'Fast Company' => '/news/fast-company.html',
+ 'ArsTechnica' => '/news/arstechnica.html',
+ 'MakeUseOf' => '/news/makeuseof.html',
+ 'FastCoExist' => '/news/fastcoexist.html',
+ 'How to Geek' => '/news/how-to-geek.html',
+ 'The Next Web' => '/news/the-next-web.html',
+ 'Engadget' => '/news/engadget.html',
+ 'Gizmag' => '/news/gizmag.html',
+ 'QZ' => '/news/qz.html',
+ 'Wired' => '/news/wired.html',
+ 'Techcrunch' => '/news/techcrunch.html',
+ 'Slashdot' => '/news/slashdot.html',
+ 'Extreme Tech' => '/news/extreme-tech.html',
+ 'AnandTech' => '/news/anandtech.html',
+ 'Digital Trends' => '/news/digital-trends.html',
+ 'Next Big Future' => '/news/next-big-future.html',
+ 'Apple Insider' => '/news/apple-insider.html',
+ 'Geek' => '/news/geek.html',
+ 'BBC Technology' => '/news/bbc-technology.html',
+ 'Bit-Tech' => '/news/bit-tech.html',
+ 'Packet Storm Sec' => '/news/packet-storm-sec.html',
+ 'Design' => '/news/design.html',
+ 'High Scalability' => '/news/high-scalability.html',
+ 'Smashing Mag' => '/news/smashing-mag.html',
+ 'The Tech Block' => '/news/the-tech-block.html',
+ 'A VC' => '/news/a-vc.html',
+ 'Tech in Asia' => '/news/tech-in-asia.html',
+ 'ReadWriteWeb' => '/news/readwriteweb.html',
+ 'PC Mag' => '/news/pc-mag.html',
+ 'Continuations' => '/news/continuations.html',
+ 'Copyblogger' => '/news/copyblogger.html',
+ 'Cult of Mac' => '/news/cult-of-mac.html',
+ 'BetaBeat' => '/news/betabeat.html',
+ 'MedGadget' => '/news/medgadget.html',
+ 'SecuriTeam' => '/news/securiteam.html',
+ 'Venture Beat' => '/news/venture-beat.html',
+ ),
+ 'Trend' => array(
+ 'Trend Hunter' => '/news/trend-hunter.html',
+ 'ApartmentT' => '/news/apartmentt.html',
+ 'GQ' => '/news/gq.html',
+ 'Digital Trends' => '/news/digital-trends.html',
+ 'Cool Hunting' => '/news/cool-hunting.html',
+ 'FastCoDesign' => '/news/fastcodesign.html',
+ 'TC Startups' => '/news/tc-startups.html',
+ 'Killer Startups' => '/news/killer-startups.html',
+ 'DigiInfo' => '/news/digiinfo.html',
+ 'New Startups' => '/news/new-startups.html',
+ 'DigiTrends' => '/news/digitrends.html',
+ ),
+ 'Watches' => array(
+ 'Hodinkee' => '/news/hodinkee.html',
+ 'Quill and Pad' => '/news/quill-and-pad.html',
+ 'Monochrome' => '/news/monochrome.html',
+ 'Deployant' => '/news/deployant.html',
+ 'Watches by SJX' => '/news/watches-by-sjx.html',
+ 'Fratello Watches' => '/news/fratello-watches.html',
+ 'A Blog to Watch' => '/news/a-blog-to-watch.html',
+ 'Wound for Life' => '/news/wound-for-life.html',
+ 'Watch Paper' => '/news/watch-paper.html',
+ 'Watch Report' => '/news/watch-report.html',
+ 'Perpetuelle' => '/news/perpetuelle.html',
+ ),
+ 'Youtube' => array(
+ 'LinusTechTips' => '/news/linustechtips.html',
+ 'MetalJesusRocks' => '/news/metaljesusrocks.html',
+ 'TotalBiscuit' => '/news/totalbiscuit.html',
+ 'DexBonus' => '/news/dexbonus.html',
+ 'Lon Siedman' => '/news/lon-siedman.html',
+ 'MKBHD' => '/news/mkbhd.html',
+ 'Terry A Davis' => '/news/terry-a-davis.html',
+ 'HappyConsole' => '/news/happyconsole.html',
+ 'Austin Evans' => '/news/austin-evans.html',
+ 'NCIX' => '/news/ncix.html',
+ ),
+ )
+ ),
+ ),
+ self::CONTEXT_CUSTOM => array(
+ 'config' => array(
+ 'name' => 'Configuration',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Enter feed numbers from Skimfeed!',
+ 'exampleValue' => '5,8,2,l,p,9,23'
+ )
+ ),
+ 'global' => array(
+ 'limit' => array(
+ 'name' => 'Limit',
+ 'type' => 'number',
+ 'title' => 'Limits the number of returned items in the feed',
+ 'exampleValue' => 10
+ )
+ )
+ );
+
+ public function getURI() {
+
+ switch($this->queriedContext) {
+
+ case self::CONTEXT_NEWS_BOX:
+
+ $channel = $this->getInput('box_channel');
+
+ if($channel) {
+ return static::URI . $channel;
+ }
+
+ break;
+
+ case self::CONTEXT_HOT_TOPICS:
+ return static::URI;
+
+ case self::CONTEXT_TECH_NEWS:
+
+ $channel = $this->getInput('tech_channel');
+
+ if($channel) {
+ return static::URI . $channel;
+ }
+
+ break;
+
+ case self::CONTEXT_CUSTOM:
+
+ $config = $this->getInput('config');
+
+ return static::URI . '/custom.php?f=' . urlencode($config);
+
+ }
+
+ return parent::getURI();
+
+ }
+
+ public function getName() {
+
+ switch($this->queriedContext) {
+
+ case self::CONTEXT_NEWS_BOX:
+
+ $channel = $this->getInput('box_channel');
+
+ $title = array_search(
+ $channel,
+ static::PARAMETERS[self::CONTEXT_NEWS_BOX]['box_channel']['values']
+ );
+
+ return $title . ' - ' . static::NAME;
+
+ case self::CONTEXT_HOT_TOPICS:
+ return 'Hot topics - ' . static::NAME;
+
+ case self::CONTEXT_TECH_NEWS:
+
+ $channel = $this->getInput('tech_channel');
+
+ $titles = array();
+
+ foreach(static::PARAMETERS[self::CONTEXT_TECH_NEWS]['tech_channel']['values'] as $ch) {
+ $titles = array_merge($titles, $ch);
+ }
+
+ $title = array_search($channel, $titles);
+
+ return $title . ' - ' . static::NAME;
+
+ case self::CONTEXT_CUSTOM:
+ return 'Custom - ' . static::NAME;
+
+ }
+
+ return parent::getName();
+
+ }
+
+ public function collectData() {
+
+ // enable to export parameter lists
+ // $this->exportBoxChannels(); die;
+ // $this->exportTechChannels(); die;
+
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Request to ' . $this->getURI() . ' failed!');
+
+ defaultLinkTo($html, static::URI);
+
+ switch($this->queriedContext) {
+
+ case self::CONTEXT_NEWS_BOX:
+
+ $author = array_search(
+ $this->getInput('box_channel'),
+ static::PARAMETERS[self::CONTEXT_NEWS_BOX]['box_channel']['values']
+ );
+
+ $author = '<a href="'
+ . $this->getURI()
+ . '">'
+ . $author
+ . '</a>';
+
+ $this->extractFeed($html, $author);
+ break;
+
+ case self::CONTEXT_HOT_TOPICS:
+ $this->extractHotTopics($html);
+ break;
+
+ case self::CONTEXT_TECH_NEWS:
+ $authors = array();
+
+ foreach(static::PARAMETERS[self::CONTEXT_TECH_NEWS]['tech_channel']['values'] as $ch) {
+ $authors = array_merge($authors, $ch);
+ }
+
+ $author = '<a href="'
+ . $this->getURI()
+ . '">'
+ . array_search($this->getInput('tech_channel'), $authors)
+ . '</a>';
+
+ $this->extractFeed($html, $author);
+ break;
+
+ case self::CONTEXT_CUSTOM:
+ $this->extractCustomFeed($html);
+ break;
+
+ }
+
+ }
+
+ private function extractFeed($html, $author) {
+
+ $articles = $html->find('li')
+ or returnServerError('Could not find articles!');
+
+ if(count($articles) === 1
+ && stristr($articles[0]->plaintext, 'Nothing new in the last 48 hours')) {
+ return; // Nothing to show
+ }
+
+ $limit = $this->getInput('limit') ?: -1;
+
+ foreach($articles as $article) {
+
+ $anchor = $article->find('a', 0)
+ or returnServerError('Could not find anchor!');
+
+ $item = array();
+
+ $item['uri'] = $this->getTarget($anchor);
+ $item['title'] = trim($anchor->plaintext);
+
+ // The timestamp is encoded as relative time (max. the last 48 hours)
+ // like this: "- 7 hours". It should always be at the end of the article:
+ $age = substr($article->plaintext, strrpos($article->plaintext, '-'));
+
+ $item['timestamp'] = strtotime($age);
+ $item['author'] = $author;
+
+ $this->items[] = $item;
+
+ if($limit > 0 && count($this->items) >= $limit) {
+ return;
+ }
+
+ }
+
+ }
+
+ private function extractHotTopics($html) {
+
+ $topics = $html->find('#popbox ul li')
+ or returnServerError('Could not find topics!');
+
+ $limit = $this->getInput('limit') ?: -1;
+
+ foreach($topics as $topic) {
+
+ $anchor = $topic->find('a', 0)
+ or returnServerError('Could not find anchor!');
+
+ $item = array();
+
+ $item['uri'] = $this->getTarget($anchor);
+ $item['title'] = $anchor->title;
+
+ $this->items[] = $item;
+
+ if($limit > 0 && count($this->items) >= $limit) {
+ return;
+ }
+
+ }
+
+ }
+
+ private function extractCustomFeed($html) {
+
+ $boxes = $html->find('#boxx .boxes')
+ or returnServerError('Could not find boxes!');
+
+ foreach($boxes as $box) {
+
+ $anchor = $box->find('span.boxtitles a', 0)
+ or returnServerError('Could not find box anchor!');
+
+ $author = '<a href="' . $anchor->href . '">' . trim($anchor->plaintext) . '</a>';
+ $uri = $anchor->href;
+
+ $box_html = getSimpleHTMLDOM($uri)
+ or returnServerError('Could not load custom feed!');
+
+ $this->extractFeed($box_html, $author);
+
+ }
+
+ }
+
+ private function getTarget($anchor) {
+
+ // Anchors are linked to Skimfeed, luckily the target URI is encoded
+ // in that URI via '&u=<URI>':
+ $query = parse_url($anchor->href, PHP_URL_QUERY);
+
+ foreach(explode('&', $query) as $parameter) {
+
+ list($key, $value) = explode('=', $parameter);
+
+ if($key !== 'u') {
+ continue;
+ }
+
+ return urldecode($value);
+
+ }
+
+ }
+
+ /**
+ * dev-mode!
+ * Requires '&format=Html'
+ *
+ * Returns the 'box' array from the source site
+ */
+ private function exportBoxChannels() {
+ $html = getSimpleHTMLDOMCached(static::URI)
+ or returnServerError('No contents received from Skimfeed!');
+
+ if(!$this->isCompatible($html)) {
+ returnServerError('Skimfeed version is not compatible!');
+ }
+
+ $boxes = $html->find('#boxx .boxes')
+ or returnServerError('Could not find boxes!');
+
+ // begin of 'channel' list
+ $message = <<<EOD
+'box_channel' => array(
+ 'name' => 'Channel',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Select your channel',
+ 'values' => array(
+
+EOD;
+
+ foreach($boxes as $box) {
+
+ $anchor = $box->find('span.boxtitles a', 0)
+ or returnServerError('Could not find box anchor!');
+
+ $title = trim($anchor->plaintext);
+ $uri = $anchor->href;
+
+ // add value
+ $message .= "\t\t'{$title}' => '{$uri}', \n";
+
+ }
+
+ // end of 'box' list
+ $message .= <<<EOD
+ )
+),
+EOD;
+
+ echo <<<EOD
+<!DOCTYPE html>
+
+<html>
+ <body>
+ <code style="white-space: pre-wrap;">{$message}</code>
+ </body>
+</html>
+EOD;
+
+ }
+
+ /**
+ * dev-mode!
+ * Requires '&format=Html'
+ *
+ * Returns the 'techs' array from the source site
+ */
+ private function exportTechChannels() {
+ $html = getSimpleHTMLDOMCached(static::URI)
+ or returnServerError('No contents received from Skimfeed!');
+
+ if(!$this->isCompatible($html)) {
+ returnServerError('Skimfeed version is not compatible!');
+ }
+
+ $channels = $html->find('#menubar a')
+ or returnServerError('Could not find channels!');
+
+ // begin of 'tech_channel' list
+ $message = <<<EOD
+'tech_channel' => array(
+ 'name' => 'Tech channel',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Select your tech channel',
+ 'values' => array(
+
+EOD;
+
+ foreach($channels as $channel) {
+
+ if($channel->href === '#'
+ || $channel->class === 'homelink'
+ || $channel->plaintext === 'Twitter'
+ || $channel->plaintext === 'Weather'
+ || $channel->plaintext === '+Custom') {
+ continue;
+ }
+
+ $title = trim($channel->plaintext);
+ $uri = '/' . $channel->href;
+
+ $message .= "\t\t'{$title}' => array(\n";
+
+ $channel_html = getSimpleHTMLDOMCached(static::URI . $uri)
+ or returnServerError('Could not load tech channel ' . $channel->plaintext . '!');
+
+ $boxes = $channel_html->find('#boxx .boxes')
+ or returnServerError('Could not find boxes!');
+
+ foreach($boxes as $box) {
+
+ $anchor = $box->find('span.boxtitles a', 0)
+ or returnServerError('Could not find box anchor!');
+
+ $boxtitle = trim($anchor->plaintext);
+ $boxuri = $anchor->href;
+
+ $message .= "\t\t\t'{$boxtitle}' => '{$boxuri}', \n";
+
+ }
+
+ $message .= "\t\t),\n";
+
+ }
+
+ // end of 'box' list
+ $message .= <<<EOD
+ )
+),
+EOD;
+
+ echo <<<EOD
+<!DOCTYPE html>
+
+<html>
+ <body>
+ <code style="white-space: pre-wrap;">{$message}</code>
+ </body>
+</html>
+EOD;
+ }
+
+ /**
+ * Checks if the reported skimfeed version is compatible
+ */
+ private function isCompatible($html) {
+ $title = $html->find('title', 0);
+
+ if(!$title) {
+ return false;
+ }
+
+ if($title->plaintext === 'Skimfeed V5.5 - Tech News') {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/bridges/SoundcloudBridge.php b/bridges/SoundcloudBridge.php
new file mode 100644
index 0000000..91ac2b5
--- /dev/null
+++ b/bridges/SoundcloudBridge.php
@@ -0,0 +1,66 @@
+<?php
+class SoundCloudBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'kranack';
+ const NAME = 'Soundcloud Bridge';
+ const URI = 'https://soundcloud.com/';
+ const CACHE_TIMEOUT = 600; // 10min
+ const DESCRIPTION = 'Returns 10 newest music from user profile';
+
+ const PARAMETERS = array( array(
+ 'u' => array(
+ 'name' => 'username',
+ 'required' => true
+ )
+ ));
+
+ const CLIENT_ID = '4jkoEFmZEDaqjwJ9Eih4ATNhcH3vMVfp';
+
+ public function collectData(){
+
+ $res = json_decode(getContents(
+ 'https://api.soundcloud.com/resolve?url=http://www.soundcloud.com/'
+ . urlencode($this->getInput('u'))
+ . '&client_id='
+ . self::CLIENT_ID
+ )) or returnServerError('No results for this query');
+
+ $tracks = json_decode(getContents(
+ 'https://api.soundcloud.com/users/'
+ . urlencode($res->id)
+ . '/tracks?client_id='
+ . self::CLIENT_ID
+ )) or returnServerError('No results for this user');
+
+ $numTracks = min(count($tracks), 10);
+ for($i = 0; $i < $numTracks; $i++) {
+ $item = array();
+ $item['author'] = $tracks[$i]->user->username;
+ $item['title'] = $tracks[$i]->user->username . ' - ' . $tracks[$i]->title;
+ $item['timestamp'] = strtotime($tracks[$i]->created_at);
+ $item['content'] = $tracks[$i]->description;
+ $item['enclosures'] = array($tracks[$i]->uri
+ . '/stream?client_id='
+ . self::CLIENT_ID);
+
+ $item['id'] = self::URI
+ . urlencode($this->getInput('u'))
+ . '/'
+ . urlencode($tracks[$i]->permalink);
+ $item['uri'] = self::URI
+ . urlencode($this->getInput('u'))
+ . '/'
+ . urlencode($tracks[$i]->permalink);
+ $this->items[] = $item;
+ }
+
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('u'))) {
+ return self::NAME . ' - ' . $this->getInput('u');
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/SteamBridge.php b/bridges/SteamBridge.php
new file mode 100644
index 0000000..8ff456d
--- /dev/null
+++ b/bridges/SteamBridge.php
@@ -0,0 +1,157 @@
+<?php
+class SteamBridge extends BridgeAbstract {
+
+ const NAME = 'Steam Bridge';
+ const URI = 'https://store.steampowered.com/';
+ const CACHE_TIMEOUT = 3600; // 1h
+ const DESCRIPTION = 'Returns apps list';
+ const MAINTAINER = 'jacknumber';
+ const PARAMETERS = array(
+ 'Wishlist' => array(
+ 'username' => array(
+ 'name' => 'Username',
+ 'required' => true,
+ ),
+ 'currency' => array(
+ 'name' => 'Currency',
+ 'type' => 'list',
+ 'values' => array(
+ // source: http://steam.steamlytics.xyz/currencies
+ 'USD' => 'us',
+ 'GBP' => 'gb',
+ 'EUR' => 'fr',
+ 'CHF' => 'ch',
+ 'RUB' => 'ru',
+ 'BRL' => 'br',
+ 'JPY' => 'jp',
+ 'SEK' => 'se',
+ 'IDR' => 'id',
+ 'MYR' => 'my',
+ 'PHP' => 'ph',
+ 'SGD' => 'sg',
+ 'THB' => 'th',
+ 'KRW' => 'kr',
+ 'TRY' => 'tr',
+ 'MXN' => 'mx',
+ 'CAD' => 'ca',
+ 'NZD' => 'nz',
+ 'CNY' => 'cn',
+ 'INR' => 'in',
+ 'CLP' => 'cl',
+ 'PEN' => 'pe',
+ 'COP' => 'co',
+ 'ZAR' => 'za',
+ 'HKD' => 'hk',
+ 'TWD' => 'tw',
+ 'SRD' => 'sr',
+ 'AED' => 'ae',
+ ),
+ ),
+ 'only_discount' => array(
+ 'name' => 'Only discount',
+ 'type' => 'checkbox',
+ )
+ )
+ );
+
+ public function collectData(){
+
+ $username = $this->getInput('username');
+ $params = array(
+ 'cc' => $this->getInput('currency')
+ );
+
+ $url = self::URI . 'wishlist/id/' . $username . '?' . http_build_query($params);
+
+ $targetVariable = 'g_rgAppInfo';
+ $sort = array();
+
+ $html = '';
+ $html = getSimpleHTMLDOM($url)
+ or returnServerError("Could not request Steam Wishlist. Tried:\n - $url");
+
+ $jsContent = $html->find('.responsive_page_template_content script', 0)->innertext;
+
+ if(preg_match('/var ' . $targetVariable . ' = (.*?);/s', $jsContent, $matches)) {
+ $appsData = json_decode($matches[1]);
+ } else {
+ returnServerError("Could not parse JS variable ($targetVariable) in page content.");
+ }
+
+ foreach($appsData as $id => $element) {
+
+ $appType = $element->type;
+ $appIsBuyable = 0;
+ $appHasDiscount = 0;
+ $appIsFree = 0;
+
+ if($element->subs) {
+ $appIsBuyable = 1;
+
+ if($element->subs[0]->discount_pct) {
+
+ $appHasDiscount = 1;
+ $discountBlock = str_get_html($element->subs[0]->discount_block);
+ $appDiscountValue = $discountBlock->find('.discount_pct', 0)->plaintext;
+ $appOldPrice = $discountBlock->find('.discount_original_price', 0)->plaintext;
+ $appNewPrice = $discountBlock->find('.discount_final_price', 0)->plaintext;
+ $appPrice = $appNewPrice;
+
+ } else {
+
+ if($this->getInput('only_discount')) {
+ continue;
+ }
+
+ $appPrice = $element->subs[0]->price / 100;
+ }
+
+ } else {
+
+ if($this->getInput('only_discount')) {
+ continue;
+ }
+
+ if(isset($element->free) && $element->free = 1) {
+ $appIsFree = 1;
+ }
+ }
+
+ $item = array();
+ $item['uri'] = "http://store.steampowered.com/app/$id/";
+ $item['title'] = $element->name;
+ $item['type'] = $appType;
+ $item['cover'] = str_replace('_292x136', '', $element->capsule);
+ $item['timestamp'] = $element->added;
+ $item['isBuyable'] = $appIsBuyable;
+ $item['hasDiscount'] = $appHasDiscount;
+ $item['isFree'] = $appIsFree;
+ $item['priority'] = $element->priority;
+
+ if($appIsBuyable) {
+ $item['price'] = floatval(str_replace(',', '.', $appPrice));
+ }
+
+ if($appHasDiscount) {
+
+ $item['discount']['value'] = $appDiscountValue;
+ $item['discount']['oldPrice'] = floatval(str_replace(',', '.', $appOldPrice));
+ $item['discount']['newPrice'] = floatval(str_replace(',', '.', $appNewPrice));
+
+ }
+
+ $item['enclosures'] = array();
+ $item['enclosures'][] = str_replace('_292x136', '', $element->capsule);
+
+ foreach($element->screenshots as $screenshot) {
+ $item['enclosures'][] = substr($element->capsule, 0, -31) . $screenshot;
+ }
+
+ $sort[$id] = $element->priority;
+
+ $this->items[] = $item;
+ }
+
+ array_multisort($sort, SORT_ASC, $this->items);
+ }
+}
diff --git a/bridges/StripeAPIChangeLogBridge.php b/bridges/StripeAPIChangeLogBridge.php
new file mode 100644
index 0000000..22ef381
--- /dev/null
+++ b/bridges/StripeAPIChangeLogBridge.php
@@ -0,0 +1,23 @@
+<?php
+class StripeAPIChangeLogBridge extends BridgeAbstract {
+ const MAINTAINER = 'Pierre Mazière';
+ const NAME = 'Stripe API Changelog';
+ const URI = 'https://stripe.com/docs/upgrades';
+ const CACHE_TIMEOUT = 86400; // 24h
+ const DESCRIPTION = 'Returns the changes made to the stripe.com API';
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('No results for Stripe API Changelog');
+
+ foreach($html->find('h3') as $change) {
+ $item = array();
+ $item['title'] = trim($change->plaintext);
+ $item['uri'] = self::URI . '#' . $item['title'];
+ $item['author'] = 'stripe';
+ $item['content'] = $change->nextSibling()->outertext;
+ $item['timestamp'] = strtotime($item['title']);
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/SupInfoBridge.php b/bridges/SupInfoBridge.php
new file mode 100644
index 0000000..f713b00
--- /dev/null
+++ b/bridges/SupInfoBridge.php
@@ -0,0 +1,60 @@
+<?php
+class SupInfoBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene';
+ const NAME = 'SupInfoBridge';
+ const URI = 'https://www.supinfo.com';
+ const DESCRIPTION = 'Returns the newest articles.';
+
+ const PARAMETERS = array(array(
+ 'tag' => array(
+ 'name' => 'Category (not mandatory)',
+ 'type' => 'text',
+ )
+ ));
+
+ public function getIcon() {
+ return self::URI . '/favicon.png';
+ }
+
+ public function collectData() {
+
+ if(empty($this->getInput('tag'))) {
+ $html = getSimpleHTMLDOM(self::URI . '/articles/')
+ or returnServerError('Unable to fetch articles !');
+ } else {
+ $html = getSimpleHTMLDOM(self::URI . '/articles/tag/' . $this->getInput('tag'))
+ or returnServerError('Unable to fetch articles !');
+ }
+ $content = $html->find('#latest', 0)->find('ul[class=courseContent]', 0);
+
+ for($i = 0; $i < 5; $i++) {
+
+ $this->items[] = $this->fetchArticle($content->find('h4', $i)->find('a', 0)->href);
+
+ }
+ }
+
+ private function fetchArticle($link) {
+
+ $articleHTML = getSimpleHTMLDOM(self::URI . $link)
+ or returnServerError('Unable to fetch article !');
+
+ $article = $articleHTML->find('div[id=courseDocZero]', 0);
+ $item = array();
+ $item['author'] = $article->find('#courseMetas', 0)->find('a', 0)->plaintext;
+ $item['id'] = $link;
+ $item['uri'] = self::URI . $link;
+ $item['title'] = $article->find('h1', 0)->plaintext;
+ $date = explode(' ', $article->find('#courseMetas', 0)->find('span', 1)->plaintext);
+ $item['timestamp'] = DateTime::createFromFormat('d/m/Y H:i:s', $date[2] . ' ' . $date[4])->getTimestamp();
+
+ $article->find('div[id=courseHeader]', 0)->innertext = '';
+ $article->find('div[id=author-infos]', 0)->innertext = '';
+ $article->find('div[id=cartouche-tete]', 0)->innertext = '';
+ $item['content'] = $article;
+
+ return $item;
+
+ }
+}
diff --git a/bridges/SuperSmashBlogBridge.php b/bridges/SuperSmashBlogBridge.php
new file mode 100644
index 0000000..a2ce47d
--- /dev/null
+++ b/bridges/SuperSmashBlogBridge.php
@@ -0,0 +1,45 @@
+<?php
+class SuperSmashBlogBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'corenting';
+ const NAME = 'Super Smash Blog';
+ const URI = 'https://www.smashbros.com/en_US/blog/index.html';
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'Latest articles from the Super Smash Blog blog';
+
+ public function collectData(){
+ $dlUrl = 'https://www.smashbros.com/data/bs/en_US/json/en_US.json';
+
+ $jsonString = getContents($dlUrl) or returnServerError('Error while downloading the website content');
+ $json = json_decode($jsonString, true);
+
+ foreach($json as $article) {
+
+ // Build content
+ $picture = $article['acf']['image1']['url'];
+ if (strlen($picture) != 0) {
+ $picture = str_get_html('<img src="https://www.smashbros.com/' . substr($picture, 8) . '"/>');
+ } else {
+ $picture = '';
+ }
+
+ $video = $article['acf']['link_url'];
+ if (strlen($video) != 0) {
+ $video = str_get_html('<a href="' . $video . '">Youtube video</a>');
+ } else {
+ $video = '';
+ }
+ $text = str_get_html($article['acf']['editor']);
+ $content = $picture . $video . $text;
+
+ // Build final item
+ $item = array();
+ $item['title'] = $article['title']['rendered'];
+ $item['timestamp'] = strtotime($article['date']);
+ $item['content'] = $content;
+ $item['uri'] = self::URI . '?post=' . $article['id'];
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/SuperbWallpapersBridge.php b/bridges/SuperbWallpapersBridge.php
new file mode 100644
index 0000000..610dd32
--- /dev/null
+++ b/bridges/SuperbWallpapersBridge.php
@@ -0,0 +1,70 @@
+<?php
+class SuperbWallpapersBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'nel50n';
+ const NAME = 'Superb Wallpapers Bridge';
+ const URI = 'http://www.superbwallpapers.com/';
+ const CACHE_TIMEOUT = 43200; // 12h
+ const DESCRIPTION = 'Returns the latests wallpapers from SuperbWallpapers';
+
+ const PARAMETERS = array( array(
+ 'c' => array(
+ 'name' => 'category',
+ 'required' => true
+ ),
+ 'm' => array(
+ 'name' => 'Max number of wallpapers',
+ 'type' => 'number'
+ ),
+ 'r' => array(
+ 'name' => 'resolution',
+ 'exampleValue' => '1920x1200, 1680x1050,…',
+ 'defaultValue' => '1920x1200'
+ )
+ ));
+
+ public function collectData(){
+ $category = $this->getInput('c');
+ $resolution = $this->getInput('r'); // Wide wallpaper default
+
+ $num = 0;
+ $max = $this->getInput('m') ?: 36;
+ $lastpage = 1;
+
+ // Get last page number
+ $link = self::URI . '/' . $category . '/9999.html';
+ $html = getSimpleHTMLDOM($link)
+ or returnServerError('Could not load ' . $link);
+
+ $lastpage = min($html->find('.paging .cpage', 0)->innertext(), ceil($max / 36));
+
+ for($page = 1; $page <= $lastpage; $page++) {
+ $link = self::URI . '/' . $category . '/' . $page . '.html';
+ $html = getSimpleHTMLDOM($link)
+ or returnServerError('No results for this query.');
+
+ foreach($html->find('.wpl .i a') as $element) {
+ $thumbnail = $element->find('img', 0);
+
+ $item = array();
+ $item['uri'] = str_replace('200x125', $this->resolution, $thumbnail->src);
+ $item['timestamp'] = time();
+ $item['title'] = $element->title;
+ $item['content'] = $item['title'] . '<br><a href="' . $item['uri'] . '">' . $thumbnail . '</a>';
+ $this->items[] = $item;
+
+ $num++;
+ if ($num >= $max)
+ break 2;
+ }
+ }
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('c')) && !is_null($this->getInput('r'))) {
+ return self::NAME . '- ' . $this->getInput('c') . ' [' . $this->getInput('r') . ']';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/TagBoardBridge.php b/bridges/TagBoardBridge.php
new file mode 100644
index 0000000..2a2f51c
--- /dev/null
+++ b/bridges/TagBoardBridge.php
@@ -0,0 +1,53 @@
+<?php
+class TagBoardBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Pitchoule';
+ const NAME = 'TagBoard';
+ const URI = 'http://www.TagBoard.com/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Returns most recent results from TagBoard.';
+
+ const PARAMETERS = array( array(
+ 'u' => array(
+ 'name' => 'keyword',
+ 'required' => true
+ )
+ ));
+
+ public function getIcon() {
+ return 'https://static.tagboard.com/public/favicon-32x32.png';
+ }
+
+ public function collectData(){
+ $link = 'https://post-cache.tagboard.com/search/' . $this->getInput('u');
+
+ $html = getSimpleHTMLDOM($link)
+ or returnServerError('Could not request TagBoard for : ' . $link);
+ $parsed_json = json_decode($html);
+
+ foreach($parsed_json->{'posts'} as $element) {
+ $item = array();
+ $item['uri'] = $element->{'permalink'};
+ $item['title'] = $element->{'text'};
+ $thumbnailUri = $element->{'photos'}[0]->{'m'};
+ if(isset($thumbnailUri)) {
+ $item['content'] = '<a href="'
+ . $item['uri']
+ . '"><img src="'
+ . $thumbnailUri
+ . '" /></a>';
+ } else {
+ $item['content'] = $element->{'html'};
+ }
+ $this->items[] = $item;
+ }
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('u'))) {
+ return 'tagboard - ' . $this->getInput('u');
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/TbibBridge.php b/bridges/TbibBridge.php
new file mode 100644
index 0000000..edb761e
--- /dev/null
+++ b/bridges/TbibBridge.php
@@ -0,0 +1,12 @@
+<?php
+require_once('GelbooruBridge.php');
+
+class TbibBridge extends GelbooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Tbib';
+ const URI = 'http://tbib.org/';
+ const DESCRIPTION = 'Returns images from given page';
+
+ const PIDBYPAGE = 50;
+}
diff --git a/bridges/TebeoBridge.php b/bridges/TebeoBridge.php
new file mode 100644
index 0000000..083ea94
--- /dev/null
+++ b/bridges/TebeoBridge.php
@@ -0,0 +1,42 @@
+<?php
+class TebeoBridge extends FeedExpander {
+ const NAME = 'Tébéo Bridge';
+ const URI = 'http://www.tebeo.bzh/';
+ const CACHE_TIMEOUT = 21600; //6h
+ const DESCRIPTION = 'Returns the newest Tébéo videos by category';
+ const MAINTAINER = 'Mitsukarenai';
+
+ const PARAMETERS = array( array(
+ 'cat' => array(
+ 'name' => 'Catégorie',
+ 'type' => 'list',
+ 'values' => array(
+ 'Toutes les vidéos' => '/',
+ 'Actualité' => '/14-actualite',
+ 'Sport' => '/3-sport',
+ 'Culture-Loisirs' => '/5-culture-loisirs',
+ 'Société' => '/15-societe',
+ 'Langue Bretonne' => '/9-langue-bretonne'
+ )
+ )
+ ));
+
+ public function getIcon() {
+ return self::URI . 'images/header_logo.png';
+ }
+
+ public function collectData(){
+ $url = self::URI . '/le-replay/' . $this->getInput('cat');
+ $html = getSimpleHTMLDOM($url)
+ or returnServerError('Could not request Tébéo.');
+
+ foreach($html->find('div[id=items_replay] div.replay') as $element) {
+ $item = array();
+ $item['uri'] = $element->find('a', 0)->href;
+ $item['title'] = $element->find('h3', 0)->plaintext;
+ $item['timestamp'] = strtotime($element->find('p.moment-format-day', 0)->plaintext);
+ $item['content'] = '<a href="' . $item['uri'] . '"><img alt="" src="' . $element->find('img', 0)->src . '"></a>';
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/TheCodingLoveBridge.php b/bridges/TheCodingLoveBridge.php
new file mode 100644
index 0000000..2a639e3
--- /dev/null
+++ b/bridges/TheCodingLoveBridge.php
@@ -0,0 +1,46 @@
+<?php
+class TheCodingLoveBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'superbaillot.net';
+ const NAME = 'The Coding Love';
+ const URI = 'http://thecodinglove.com/';
+ const CACHE_TIMEOUT = 7200; // 2h
+ const DESCRIPTION = 'The Coding Love';
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request The Coding Love.');
+
+ foreach($html->find('div.post') as $element) {
+ $item = array();
+ $temp = $element->find('h3 a', 0);
+
+ $titre = $temp->innertext;
+ $url = $temp->href;
+
+ $temp = $element->find('div.bodytype', 0);
+
+ // retrieve .gif instead of static .jpg
+ $images = $temp->find('p.e img');
+ foreach($images as $image) {
+ $img_src = str_replace('.jpg', '.gif', $image->src);
+ $image->src = $img_src;
+ }
+ $content = $temp->innertext;
+
+ $auteur = $temp->find('i', 0);
+ $pos = strpos($auteur->innertext, 'by');
+
+ if($pos > 0) {
+ $auteur = trim(str_replace('*/', '', substr($auteur->innertext, ($pos + 2))));
+ $item['author'] = $auteur;
+ }
+
+ $item['content'] .= trim($content);
+ $item['uri'] = $url;
+ $item['title'] = trim($titre);
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/TheHackerNewsBridge.php b/bridges/TheHackerNewsBridge.php
new file mode 100644
index 0000000..687b620
--- /dev/null
+++ b/bridges/TheHackerNewsBridge.php
@@ -0,0 +1,79 @@
+<?php
+class TheHackerNewsBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'The Hacker News Bridge';
+ const URI = 'https://thehackernews.com/';
+ const DESCRIPTION = 'Cyber Security, Hacking, Technology News.';
+
+ public function collectData(){
+
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Could not request TheHackerNews: ' . $this->getURI());
+ $limit = 0;
+
+ foreach($html->find('div.body-post') as $element) {
+ if($limit < 5) {
+
+ $article_url = $element->find('a.story-link', 0)->href;
+ $article_author = trim($element->find('i.icon-user', 0)->parent()->plaintext);
+ $article_title = $element->find('h2.home-title', 0)->plaintext;
+
+ //Date without time
+ $article_timestamp = strtotime(
+ extractFromDelimiters(
+ $element->find('i.icon-calendar', 0)->parent()->outertext,
+ '</i>',
+ '<span>'
+ )
+ );
+
+ //Article thumbnail in lazy-loading image
+ if (is_object($element->find('img[data-echo]', 0))) {
+ $article_thumbnail = array(
+ extractFromDelimiters(
+ $element->find('img[data-echo]', 0)->outertext,
+ "data-echo='",
+ "'"
+ )
+ );
+ } else {
+ $article_thumbnail = array();
+ }
+
+ if ($article = getSimpleHTMLDOMCached($article_url)) {
+
+ //Article body
+ $contents = $article->find('div.articlebody', 0)->innertext;
+ $contents = stripRecursiveHtmlSection($contents, 'div', '<div class="ad_');
+ $contents = stripWithDelimiters($contents, 'id="google_ads', '</iframe>');
+ $contents = stripWithDelimiters($contents, '<script', '</script>');
+
+ //Date with time
+ if (is_object($article->find('meta[itemprop=dateModified]', 0))) {
+ $article_timestamp = strtotime(
+ extractFromDelimiters(
+ $article->find('meta[itemprop=dateModified]', 0)->outertext,
+ "content='",
+ "'"
+ )
+ );
+ }
+ } else {
+ $contents = 'Could not request TheHackerNews: ' . $article_url;
+ }
+
+ $item = array();
+ $item['uri'] = $article_url;
+ $item['title'] = $article_title;
+ $item['author'] = $article_author;
+ $item['enclosures'] = $article_thumbnail;
+ $item['timestamp'] = $article_timestamp;
+ $item['content'] = trim($contents);
+ $this->items[] = $item;
+ $limit++;
+ }
+ }
+
+ }
+}
diff --git a/bridges/ThePirateBayBridge.php b/bridges/ThePirateBayBridge.php
new file mode 100644
index 0000000..9aefcbb
--- /dev/null
+++ b/bridges/ThePirateBayBridge.php
@@ -0,0 +1,174 @@
+<?php
+class ThePirateBayBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'The Pirate Bay';
+ const URI = 'https://thepiratebay.wf/';
+ const DESCRIPTION = 'Returns results for the keywords. You can put several
+ list of keywords by separating them with a semicolon (e.g. "one show;another
+ show"). Category based search needs the category number as input. User based
+ search takes the Uploader name. Search can be done in a specified category';
+
+ const PARAMETERS = array( array(
+ 'q' => array(
+ 'name' => 'keywords/username/category, separated by semicolons',
+ 'exampleValue' => 'first list;second list;…',
+ 'required' => true
+ ),
+ 'crit' => array(
+ 'type' => 'list',
+ 'name' => 'Search type',
+ 'values' => array(
+ 'search' => 'search',
+ 'category' => 'cat',
+ 'user' => 'usr'
+ )
+ ),
+ 'catCheck' => array(
+ 'type' => 'checkbox',
+ 'name' => 'Specify category for keyword search ?',
+ ),
+ 'cat' => array(
+ 'name' => 'Category number',
+ 'exampleValue' => '100, 200… See TPB for category number'
+ ),
+ 'trusted' => array(
+ 'type' => 'checkbox',
+ 'name' => 'Only get results from Trusted or VIP users ?',
+ ),
+ ));
+
+ public function collectData(){
+
+ function parseDateTimestamp($element){
+ $guessedDate = $element->find('font', 0)->plaintext;
+ $guessedDate = explode('Uploaded ', $guessedDate)[1];
+ $guessedDate = explode(',', $guessedDate)[0];
+
+ if(count(explode(':', $guessedDate)) == 1) {
+ $guessedDate = strptime($guessedDate, '%m-%d&nbsp;%Y');
+ $timestamp = mktime(
+ 0,
+ 0,
+ 0,
+ $guessedDate['tm_mon'] + 1,
+ $guessedDate['tm_mday'],
+ 1900 + $guessedDate['tm_year']
+ );
+ } elseif(explode('&nbsp;', $guessedDate)[0] == 'Today') {
+ $guessedDate = strptime(
+ explode('&nbsp;', $guessedDate)[1], '%H:%M'
+ );
+
+ $timestamp = mktime(
+ $guessedDate['tm_hour'],
+ $guessedDate['tm_min'],
+ 0,
+ date('m'),
+ date('d'),
+ date('Y')
+ );
+ } elseif(explode('&nbsp;', $guessedDate)[0] == 'Y-day') {
+ $guessedDate = strptime(
+ explode('&nbsp;', $guessedDate)[1], '%H:%M'
+ );
+
+ $timestamp = mktime(
+ $guessedDate['tm_hour'],
+ $guessedDate['tm_min'],
+ 0,
+ date('m', time() - 24 * 60 * 60),
+ date('d', time() - 24 * 60 * 60),
+ date('Y', time() - 24 * 60 * 60)
+ );
+ } else {
+ $guessedDate = strptime($guessedDate, '%m-%d&nbsp;%H:%M');
+ $timestamp = mktime(
+ $guessedDate['tm_hour'],
+ $guessedDate['tm_min'],
+ 0,
+ $guessedDate['tm_mon'] + 1,
+ $guessedDate['tm_mday'],
+ date('Y'));
+ }
+ return $timestamp;
+ }
+
+ $catBool = $this->getInput('catCheck');
+ if($catBool) {
+ $catNum = $this->getInput('cat');
+ }
+ $critList = $this->getInput('crit');
+
+ $trustedBool = $this->getInput('trusted');
+ $keywordsList = explode(';', $this->getInput('q'));
+ foreach($keywordsList as $keywords) {
+ switch($critList) {
+ case 'search':
+ if($catBool == false) {
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'search/' .
+ rawurlencode($keywords) .
+ '/0/3/0'
+ ) or returnServerError('Could not request TPB.');
+ } else {
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'search/' .
+ rawurlencode($keywords) .
+ '/0/3/' .
+ rawurlencode($catNum)
+ ) or returnServerError('Could not request TPB.');
+ }
+ break;
+ case 'cat':
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'browse/' .
+ rawurlencode($keywords) .
+ '/0/3/0'
+ ) or returnServerError('Could not request TPB.');
+ break;
+ case 'usr':
+ $html = getSimpleHTMLDOM(
+ self::URI .
+ 'user/' .
+ rawurlencode($keywords) .
+ '/0/3/0'
+ ) or returnServerError('Could not request TPB.');
+ break;
+ }
+
+ if ($html->find('table#searchResult', 0) == false)
+ returnServerError('No result for query ' . $keywords);
+
+ foreach($html->find('tr') as $element) {
+
+ if(!$trustedBool
+ || !is_null($element->find('img[alt=VIP]', 0))
+ || !is_null($element->find('img[alt=Trusted]', 0))) {
+ $item = array();
+ $item['uri'] = $element->find('a', 3)->href;
+ $item['id'] = self::URI . $element->find('a.detLink', 0)->href;
+ $item['timestamp'] = parseDateTimestamp($element);
+ $item['author'] = $element->find('a.detDesc', 0)->plaintext;
+ $item['title'] = $element->find('a.detLink', 0)->plaintext;
+ $item['seeders'] = (int)$element->find('td', 2)->plaintext;
+ $item['leechers'] = (int)$element->find('td', 3)->plaintext;
+ $item['content'] = $element->find('font', 0)->plaintext
+ . '<br>seeders: '
+ . $item['seeders']
+ . ' | leechers: '
+ . $item['leechers']
+ . '<br><a href="'
+ . $item['id']
+ . '">info page</a>';
+
+ if(isset($item['title']))
+ $this->items[] = $item;
+ }
+ }
+ }
+ }
+}
diff --git a/bridges/TheTVDBBridge.php b/bridges/TheTVDBBridge.php
new file mode 100644
index 0000000..38b45a8
--- /dev/null
+++ b/bridges/TheTVDBBridge.php
@@ -0,0 +1,209 @@
+<?php
+
+class TheTVDBBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Astyan';
+ const NAME = 'TheTVDB';
+ const URI = 'http://thetvdb.com/';
+ const APIURI = 'https://api.thetvdb.com/';
+ const CACHE_TIMEOUT = 43200; // 12h
+ const DESCRIPTION = 'Returns latest episodes of a serie with theTVDB api. You can contribute to theTVDB.';
+ const PARAMETERS = array(
+ array(
+ 'serie_id' => array(
+ 'type' => 'number',
+ 'name' => 'ID',
+ 'required' => true,
+ ),
+ 'nb_episode' => array(
+ 'type' => 'number',
+ 'name' => 'Number of episodes',
+ 'defaultValue' => 10,
+ 'required' => true,
+ ),
+ )
+ );
+ const APIACCOUNT = 'RSSBridge';
+ const APIKEY = '76DE1887EA401C9A';
+ const APIUSERKEY = 'B52869AC6005330F';
+
+ private function getApiUri(){
+ return self::APIURI;
+ }
+
+ private function getToken(){
+ //login and get token, don't use curlJob to do less adaptations
+ $login_array = array(
+ 'apikey' => self::APIKEY,
+ 'username' => self::APIACCOUNT,
+ 'userkey' => self::APIUSERKEY
+ );
+
+ $login_json = json_encode($login_array);
+ $ch = curl_init($this->getApiUri() . 'login');
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $login_json);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, array(
+ 'Content-Type: application/json',
+ 'Accept: application/json'
+ )
+ );
+
+ curl_setopt($ch, CURLOPT_TIMEOUT, 5);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
+ $result = curl_exec($ch);
+ curl_close($ch);
+ $token_json = (array)json_decode($result);
+ if(isset($token_json['Error'])) {
+ throw new Exception($token_json['Error']);
+ die;
+ }
+ $token = $token_json['token'];
+ return $token;
+ }
+
+ private function curlJob($token, $url){
+ $token_header = 'Authorization: Bearer ' . $token;
+ $ch = curl_init($url);
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, array(
+ 'Accept: application/json',
+ $token_header
+ )
+ );
+ curl_setopt($ch, CURLOPT_TIMEOUT, 5);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
+ $result = curl_exec($ch);
+ curl_close($ch);
+ $result_array = (array)json_decode($result);
+ if(isset($result_array['Error'])) {
+ throw new Exception($result_array['Error']);
+ die;
+ }
+ return $result_array;
+ }
+
+ private function getLatestSeasonNumber($token, $serie_id){
+ // get the last season
+ $url = $this->getApiUri() . 'series/' . $serie_id . '/episodes/summary';
+ $summary = $this->curlJob($token, $url);
+ return max($summary['data']->airedSeasons);
+ }
+
+ private function getSerieName($token, $serie_id){
+ $url = $this->getApiUri() . 'series/' . $serie_id;
+ $serie = $this->curlJob($token, $url);
+ return $serie['data']->seriesName;
+ }
+
+ private function getSeasonEpisodes($token,
+ $serie_id,
+ $season,
+ $seriename,
+ &$episodelist,
+ $nbepisodemin,
+ $page = 1){
+ $url = $this->getApiUri()
+ . 'series/'
+ . $serie_id
+ . '/episodes/query?airedSeason='
+ . $season
+ . '?page='
+ . $page;
+
+ $episodes = $this->curlJob($token, $url);
+ // we don't check the number of page because we assume there is less
+ //than 100 episodes in every season
+ $episodes = (array)$episodes['data'];
+ $episodes = array_slice($episodes, -$nbepisodemin, $nbepisodemin);
+ foreach($episodes as $episode) {
+ $episodedata = array();
+ $episodedata['uri'] = $this->getURI()
+ . '?tab=episode&seriesid='
+ . $serie_id
+ . '&seasonid='
+ . $episode->airedSeasonID
+ . '&id='
+ . $episode->id;
+
+ // check if the absoluteNumber exist
+ if(isset($episode->absoluteNumber)) {
+ $episodedata['title'] = 'S'
+ . $episode->airedSeason
+ . 'E'
+ . $episode->airedEpisodeNumber
+ . '('
+ . $episode->absoluteNumber
+ . ') : '
+ . $episode->episodeName;
+ } else {
+ $episodedata['title'] = 'S'
+ . $episode->airedSeason
+ . 'E'
+ . $episode->airedEpisodeNumber
+ . ' : '
+ . $episode->episodeName;
+ }
+ $episodedata['author'] = $seriename;
+ $date = DateTime::createFromFormat(
+ 'Y-m-d H:i:s',
+ $episode->firstAired . ' 00:00:00'
+ );
+
+ $episodedata['timestamp'] = $date->getTimestamp();
+ $episodedata['content'] = $episode->overview;
+ $episodelist[] = $episodedata;
+ }
+ }
+
+ public function getIcon() {
+ return self::URI . 'application/themes/thetvdb/images/logo.png';
+ }
+
+ public function collectData(){
+ $serie_id = $this->getInput('serie_id');
+ $nbepisode = $this->getInput('nb_episode');
+ $episodelist = array();
+ $token = $this->getToken();
+ $maxseason = $this->getLatestSeasonNumber($token, $serie_id);
+ $seriename = $this->getSerieName($token, $serie_id);
+ $season = $maxseason;
+ while(sizeof($episodelist) < $nbepisode && $season >= 1) {
+ $nbepisodetmp = $nbepisode - sizeof($episodelist);
+ $this->getSeasonEpisodes(
+ $token,
+ $serie_id,
+ $season,
+ $seriename,
+ $episodelist,
+ $nbepisodetmp
+ );
+
+ $season = $season - 1;
+ }
+ // add the 10 last specials episodes
+ try { // catch to avoid error if empty
+ $this->getSeasonEpisodes(
+ $token,
+ $serie_id,
+ 0,
+ $seriename,
+ $episodelist,
+ $nbepisode
+ );
+ } catch(Exception $e) {
+ unset($e);
+ }
+ // sort and keep the 10 last episodes, works bad with the netflix serie
+ // (all episode lauch at once)
+ usort(
+ $episodelist,
+ function ($a, $b){
+ return $a['timestamp'] < $b['timestamp'];
+ }
+ );
+ $this->items = array_slice($episodelist, 0, $nbepisode);
+ }
+}
diff --git a/bridges/TheYeteeBridge.php b/bridges/TheYeteeBridge.php
new file mode 100644
index 0000000..fa5a645
--- /dev/null
+++ b/bridges/TheYeteeBridge.php
@@ -0,0 +1,41 @@
+<?php
+class TheYeteeBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'Monsieur Poutounours';
+ const NAME = 'TheYetee';
+ const URI = 'https://theyetee.com';
+ const CACHE_TIMEOUT = 14400; // 4 h
+ const DESCRIPTION = 'Fetch daily shirts from The Yetee';
+
+ public function collectData(){
+
+ $html = getSimpleHTMLDOM(self::URI)
+ or returnServerError('Could not request The Yetee.');
+
+ $div = $html->find('.hero-col');
+ foreach($div as $element) {
+
+ $item = array();
+ $item['enclosures'] = array();
+
+ $title = $element->find('h2', 0)->plaintext;
+ $item['title'] = $title;
+
+ $author = trim($element->find('div[class=credit]', 0)->plaintext);
+ $item['author'] = $author;
+
+ $uri = $element->find('div[class=controls] a', 0)->href;
+ $item['uri'] = static::URI . $uri;
+
+ $content = '<p>' . $element->find('section[class=product-listing-info] p', -1)->plaintext . '</p>';
+ $photos = $element->find('a[class=js-modaal-gallery] img');
+ foreach($photos as $photo) {
+ $content = $content . "<br /><img src='$photo->src' />";
+ $item['enclosures'][] = $photo->src;
+ }
+ $item['content'] = $content;
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/ThingiverseBridge.php b/bridges/ThingiverseBridge.php
new file mode 100644
index 0000000..2412f79
--- /dev/null
+++ b/bridges/ThingiverseBridge.php
@@ -0,0 +1,165 @@
+<?php
+class ThingiverseBridge extends BridgeAbstract {
+
+ const NAME = 'Thingiverse Search';
+ const URI = 'https://thingiverse.com';
+ const DESCRIPTION = 'Returns feeds for search results';
+ const MAINTAINER = 'AntoineTurmel';
+ const PARAMETERS = array(
+ array(
+ 'query' => array(
+ 'name' => 'Search query',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Insert your search term here',
+ 'exampleValue' => 'Enter your search term'
+ ),
+ 'sortby' => array(
+ 'name' => 'Sort by',
+ 'type' => 'list',
+ 'required' => false,
+ 'values' => array(
+ 'Relevant' => 'relevant',
+ 'Text' => 'text',
+ 'Popular' => 'popular',
+ '# of Makes' => 'makes',
+ 'Newest' => 'newest',
+ ),
+ 'defaultValue' => 'newest'
+ ),
+ 'category' => array(
+ 'name' => 'Category',
+ 'type' => 'list',
+ 'required' => false,
+ 'values' => array(
+ 'Any' => '',
+ '3D Printing' => '73',
+ 'Art' => '63',
+ 'Fashion' => '64',
+ 'Gadgets' => '65',
+ 'Hobby' => '66',
+ 'Household' => '67',
+ 'Learning' => '69',
+ 'Models' => '70',
+ 'Tools' => '71',
+ 'Toys &amp; Games' => '72',
+ '2D Art' => '144',
+ 'Art Tools' => '75',
+ 'Coins &amp; Badges' => '143',
+ 'Interactive Art' => '78',
+ 'Math Art' => '79',
+ 'Scans &amp; Replicas' => '145',
+ 'Sculptures' => '80',
+ 'Signs &amp; Logos' => '76',
+ 'Accessories' => '81',
+ 'Bracelets' => '82',
+ 'Costume' => '142',
+ 'Earrings' => '139',
+ 'Glasses' => '83',
+ 'Jewelry' => '84',
+ 'Keychains' => '130',
+ 'Rings' => '85',
+ 'Audio' => '141',
+ 'Camera' => '86',
+ 'Computer' => '87',
+ 'Mobile Phone' => '88',
+ 'Tablet' => '90',
+ 'Video Games' => '91',
+ 'Automotive' => '155',
+ 'DIY' => '93',
+ 'Electronics' => '92',
+ 'Music' => '94',
+ 'R/C Vehicles' => '95',
+ 'Robotics' => '96',
+ 'Sport &amp; Outdoors' => '140',
+ 'Bathroom' => '147',
+ 'Containers' => '146',
+ 'Decor' => '97',
+ 'Household Supplies' => '99',
+ 'Kitchen &amp; Dining' => '100',
+ 'Office' => '101',
+ 'Organization' => '102',
+ 'Outdoor &amp; Garden' => '98',
+ 'Pets' => '103',
+ 'Replacement Parts' => '153',
+ 'Biology' => '106',
+ 'Engineering' => '104',
+ 'Math' => '105',
+ 'Physics &amp; Astronomy' => '148',
+ 'Animals' => '107',
+ 'Buildings &amp; Structures' => '108',
+ 'Creatures' => '109',
+ 'Food &amp; Drink' => '110',
+ 'Model Furniture' => '111',
+ 'Model Robots' => '115',
+ 'People' => '112',
+ 'Props' => '114',
+ 'Vehicles' => '116',
+ 'Hand Tools' => '118',
+ 'Machine Tools' => '117',
+ 'Parts' => '119',
+ 'Tool Holders &amp; Boxes' => '120',
+ 'Chess' => '151',
+ 'Construction Toys' => '121',
+ 'Dice' => '122',
+ 'Games' => '123',
+ 'Mechanical Toys' => '124',
+ 'Playsets' => '113',
+ 'Puzzles' => '125',
+ 'Toy &amp; Game Accessories' => '149',
+ '3D Printer Accessories' => '127',
+ '3D Printer Extruders' => '152',
+ '3D Printer Parts' => '128',
+ '3D Printers' => '126',
+ '3D Printing Tests' => '129',
+ )
+ ),
+ 'showimage' => array(
+ 'name' => 'Show image in content',
+ 'type' => 'checkbox',
+ 'required' => false,
+ 'title' => 'Activate to show the image in the content',
+ 'defaultValue' => 'checked'
+ )
+ )
+ );
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('Failed to receive ' . $this->getURI());
+
+ $results = $html->find('div.thing-card');
+
+ foreach($results as $result) {
+
+ $item = array();
+
+ $item['title'] = $result->find('span.ellipsis', 0);
+ $item['uri'] = self::URI . $result->find('a', 1)->href;
+ $item['author'] = $result->find('span.item-creator', 0);
+ $item['content'] = '';
+
+ $image = $result->find('img.card-img', 0)->src;
+
+ if($this->getInput('showimage')) {
+ $item['content'] .= '<img src="' . $image . '">';
+ }
+
+ $item['enclosures'] = array($image);
+
+ $this->items[] = $item;
+ }
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('query'))) {
+ $uri = self::URI . '/search?q=' . urlencode($this->getInput('query'));
+ $uri .= '&sort=' . $this->getInput('sortby');
+ $uri .= '&category_id=' . $this->getInput('category');
+
+ return $uri;
+ }
+
+ return parent::getURI();
+ }
+}
diff --git a/bridges/TrelloBridge.php b/bridges/TrelloBridge.php
new file mode 100644
index 0000000..23db961
--- /dev/null
+++ b/bridges/TrelloBridge.php
@@ -0,0 +1,687 @@
+<?php
+class TrelloBridge extends BridgeAbstract {
+ const NAME = 'Trello Bridge';
+ const URI = 'https://trello.com/';
+ const CACHE_TIMEOUT = 300; // 5min
+ const DESCRIPTION = 'Returns activity on Trello boards or cards';
+ const MAINTAINER = 'Roliga';
+ const PARAMETERS = array(
+ 'Board' => array(
+ 'b' => array(
+ 'name' => 'Board ID',
+ 'required' => true,
+ 'exampleValue' => 'g9mdhdzg',
+ 'title' => 'Taken from Trello URL, e.g. trello.com/b/[Board ID]'
+ )
+ ),
+ 'Card' => array(
+ 'c' => array(
+ 'name' => 'Card ID',
+ 'required' => true,
+ 'exampleValue' => '8vddc9pE',
+ 'title' => 'Taken from Trello URL, e.g. trello.com/c/[Card ID]'
+ )
+ )
+ );
+
+ /*
+ * This was extracted from webpack on a Trello page, e.g. trello.com/b/g9mdhdzg
+ * In the browser's inspector/debugger go to the Debugger (Firefox) or
+ * Sources (Chromium) tab, these values can be found at:
+ * webpack:///resources/strings/actions/en.json
+ */
+ const ACTION_TEXTS = array(
+ 'action_accept_enterprise_join_request'
+ => '{memberCreator} added team {organization} to the enterprise {enterprise}',
+ 'action_add_attachment_to_card'
+ => '{memberCreator} attached {attachment} to {card} {attachmentPreview}',
+ 'action_add_attachment_to_card@card'
+ => '{memberCreator} attached {attachment} to this card {attachmentPreview}',
+ 'action_add_checklist_to_card'
+ => '{memberCreator} added {checklist} to {card}',
+ 'action_add_checklist_to_card@card'
+ => '{memberCreator} added {checklist} to this card',
+ 'action_add_label_to_card'
+ => '{memberCreator} added the {label} label to {card}',
+ 'action_add_label_to_card@card'
+ => '{memberCreator} added the {label} label to this card',
+ 'action_add_organization_to_enterprise'
+ => '{memberCreator} added team {organization} to the enterprise {enterprise}',
+ 'action_add_to_organization_board'
+ => '{memberCreator} added {board} to {organization}',
+ 'action_add_to_organization_board@board'
+ => '{memberCreator} added this board to {organization}',
+ 'action_added_a_due_date'
+ => '{memberCreator} set {card} to be due {date}',
+ 'action_added_a_due_date@card'
+ => '{memberCreator} set this card to be due {date}',
+ 'action_added_list_to_board'
+ => '{memberCreator} added list {list} to {board}',
+ 'action_added_list_to_board@board'
+ => '{memberCreator} added {list} to this board',
+ 'action_added_member_to_board'
+ => '{memberCreator} added {member} to {board}',
+ 'action_added_member_to_board@board'
+ => '{memberCreator} added {member} to this board',
+ 'action_added_member_to_board_as_admin'
+ => '{memberCreator} added {member} to {board} as an admin',
+ 'action_added_member_to_board_as_admin@board'
+ => '{memberCreator} added {member} to this board as an admin',
+ 'action_added_member_to_board_as_observer'
+ => '{memberCreator} added {member} to {board} as an observer',
+ 'action_added_member_to_board_as_observer@board'
+ => '{memberCreator} added {member} to this board as an observer',
+ 'action_added_member_to_card'
+ => '{memberCreator} added {member} to {card}',
+ 'action_added_member_to_card@card'
+ => '{memberCreator} added {member} to this card',
+ 'action_added_member_to_organization'
+ => '{memberCreator} added {member} to {organization}',
+ 'action_added_member_to_organization_as_admin'
+ => '{memberCreator} added {member} to {organization} as an admin',
+ 'action_admins_visibility'
+ => 'its admins',
+ 'action_another_board'
+ => 'another board',
+ 'action_archived_card'
+ => '{memberCreator} archived {card}',
+ 'action_archived_card@card'
+ => '{memberCreator} archived this card',
+ 'action_archived_list'
+ => '{memberCreator} archived list {list}',
+ 'action_became_a_normal_user_in_organization'
+ => '{memberCreator} became a normal user in {organization}',
+ 'action_became_a_normal_user_on'
+ => '{memberCreator} became a normal user on {board}',
+ 'action_became_a_normal_user_on@board'
+ => '{memberCreator} became a normal user on this board',
+ 'action_became_an_admin_of_organization'
+ => '{memberCreator} became an admin of {organization}',
+ 'action_board_perm_level'
+ => '{memberCreator} made {board} visible to {level}',
+ 'action_board_perm_level@board'
+ => '{memberCreator} made this board visible to {level}',
+ 'action_calendar'
+ => 'calendar',
+ 'action_cardAging'
+ => 'card aging',
+ 'action_changed_a_due_date'
+ => '{memberCreator} changed the due date of {card} to {date}',
+ 'action_changed_a_due_date@card'
+ => '{memberCreator} changed the due date of this card to {date}',
+ 'action_changed_board_background'
+ => '{memberCreator} changed the background of {board}',
+ 'action_changed_board_background@board'
+ => '{memberCreator} changed the background of this board',
+ 'action_changed_description_of_card'
+ => '{memberCreator} changed description of {card}',
+ 'action_changed_description_of_card@card'
+ => '{memberCreator} changed description of this card',
+ 'action_changed_description_of_organization'
+ => '{memberCreator} changed description of {organization}',
+ 'action_changed_display_name_of_organization'
+ => '{memberCreator} changed display name of {organization}',
+ 'action_changed_name_of_organization'
+ => '{memberCreator} changed name of {organization}',
+ 'action_changed_website_of_organization'
+ => '{memberCreator} changed website of {organization}',
+ 'action_closed_board'
+ => '{memberCreator} closed {board}',
+ 'action_closed_board@board'
+ => '{memberCreator} closed this board',
+ 'action_comment_on_card'
+ => '{memberCreator} {contextOn} {card} {comment}',
+ 'action_comment_on_card@card'
+ => '{memberCreator} {comment}',
+ 'action_completed_checkitem'
+ => '{memberCreator} completed {checkitem} on {card}',
+ 'action_completed_checkitem@card'
+ => '{memberCreator} completed {checkitem} on this card',
+ 'action_convert_to_card_from_checkitem'
+ => '{memberCreator} converted {card} from a checklist item on {cardSource}',
+ 'action_convert_to_card_from_checkitem@card'
+ => '{memberCreator} converted this card from a checklist item on {cardSource}',
+ 'action_convert_to_card_from_checkitem@cardSource'
+ => '{memberCreator} converted {card} from a checklist item on this card',
+ 'action_copy_board'
+ => '{memberCreator} copied this board from {board}',
+ 'action_copy_card'
+ => '{memberCreator} copied {card} from {cardSource} in list {list}',
+ 'action_copy_card@card'
+ => '{memberCreator} copied this card from {cardSource} in list {list}',
+ 'action_copy_comment_from_card'
+ => '{memberCreator} copied comment by {member} from card {card} {comment}',
+ 'action_create_board'
+ => '{memberCreator} created {board}',
+ 'action_create_board@board'
+ => '{memberCreator} created this board',
+ 'action_create_card'
+ => '{memberCreator} added {card} to {list}',
+ 'action_create_card@card'
+ => '{memberCreator} added this card to {list}',
+ 'action_create_custom_field'
+ => '{memberCreator} created the {customField} custom field on {board}',
+ 'action_create_custom_field@board'
+ => '{memberCreator} created the {customField} custom field on this board',
+ 'action_create_enterprise_join_request'
+ => '{memberCreator} requested to add team {organization} to the enterprise {enterprise}',
+ 'action_created_an_invitation_to_board'
+ => '{memberCreator} created an invitation to {board}',
+ 'action_created_an_invitation_to_board@board'
+ => '{memberCreator} created an invitation to this board',
+ 'action_created_an_invitation_to_organization'
+ => '{memberCreator} created an invitation to {organization}',
+ 'action_created_checklist_on_board'
+ => '{memberCreator} created {checklist} on {board}',
+ 'action_created_checklist_on_board@board'
+ => '{memberCreator} created {checklist} on this board',
+ 'action_created_organization'
+ => '{memberCreator} created {organization}',
+ 'action_decline_enterprise_join_request'
+ => '{memberCreator} declined the request to add team {organization} to the enterprise {enterprise}',
+ 'action_delete_attachment_from_card'
+ => '{memberCreator} deleted the {attachment} attachment from {card}',
+ 'action_delete_attachment_from_card@card'
+ => '{memberCreator} deleted the {attachment} attachment from this card',
+ 'action_delete_card'
+ => '{memberCreator} deleted card #{idCard} from {list}',
+ 'action_delete_custom_field'
+ => '{memberCreator} deleted the {customField} custom field from {board}',
+ 'action_delete_custom_field@board'
+ => '{memberCreator} deleted the {customField} custom field from this board',
+ 'action_deleted_account'
+ => '[deleted account]',
+ 'action_deleted_an_invitation_to_board'
+ => '{memberCreator} deleted an invitation to {board}',
+ 'action_deleted_an_invitation_to_board@board'
+ => '{memberCreator} deleted an invitation to this board',
+ 'action_deleted_an_invitation_to_organization'
+ => '{memberCreator} deleted an invitation to {organization}',
+ 'action_deleted_checkitem'
+ => '{memberCreator} deleted task {checkitem} on {checklist}',
+ 'action_disabled_calendar_feed'
+ => '{memberCreator} disabled the iCalendar feed on {board}',
+ 'action_disabled_calendar_feed@board'
+ => '{memberCreator} disabled the iCalendar feed on this board',
+ 'action_disabled_card_covers'
+ => '{memberCreator} disabled card cover images on {board}',
+ 'action_disabled_card_covers@board'
+ => '{memberCreator} disabled card cover images on this board',
+ 'action_disabled_commenting'
+ => '{memberCreator} disabled commenting on {board}',
+ 'action_disabled_commenting@board'
+ => '{memberCreator} disabled commenting on this board',
+ 'action_disabled_inviting'
+ => '{memberCreator} disabled inviting on {board}',
+ 'action_disabled_inviting@board'
+ => '{memberCreator} disabled inviting on this board',
+ 'action_disabled_plugin'
+ => '{memberCreator} disabled the {plugin} Power-Up',
+ 'action_disabled_powerup'
+ => '{memberCreator} disabled the {powerup} Power-Up',
+ 'action_disabled_self_join'
+ => '{memberCreator} disabled self join on {board}',
+ 'action_disabled_self_join@board'
+ => '{memberCreator} disabled self join on this board',
+ 'action_disabled_voting'
+ => '{memberCreator} disabled voting on {board}',
+ 'action_disabled_voting@board'
+ => '{memberCreator} disabled voting on this board',
+ 'action_due_date_change'
+ => '{memberCreator}',
+ 'action_email_card'
+ => '{memberCreator} emailed {card} to {list}',
+ 'action_email_card@card'
+ => '{memberCreator} emailed this card to {list}',
+ 'action_email_card_from'
+ => '{memberCreator} emailed {card} to {list} from {from}',
+ 'action_email_card_from@card'
+ => '{memberCreator} emailed this card to {list} from {from}',
+ 'action_enabled_calendar_feed'
+ => '{memberCreator} enabled the iCalendar feed on {board}',
+ 'action_enabled_calendar_feed@board'
+ => '{memberCreator} enabled the iCalendar feed on this board',
+ 'action_enabled_card_covers'
+ => '{memberCreator} enabled card cover images on {board}',
+ 'action_enabled_card_covers@board'
+ => '{memberCreator} enabled card cover images on this board',
+ 'action_enabled_plugin'
+ => '{memberCreator} enabled the {plugin} Power-Up',
+ 'action_enabled_powerup'
+ => '{memberCreator} enabled the {powerup} Power-Up',
+ 'action_enabled_self_join'
+ => '{memberCreator} enabled self join on {board}',
+ 'action_enabled_self_join@board'
+ => '{memberCreator} enabled self join on this board',
+ 'action_hid_board'
+ => '{memberCreator} hid {board}',
+ 'action_hid_board@board'
+ => '{memberCreator} hid this board',
+ 'action_invited_an_unconfirmed_member_to_board'
+ => '{memberCreator} invited an unconfirmed member to {board}',
+ 'action_invited_an_unconfirmed_member_to_board@board'
+ => '{memberCreator} invited an unconfirmed member to this board',
+ 'action_invited_an_unconfirmed_member_to_organization'
+ => '{memberCreator} invited an unconfirmed member to {organization}',
+ 'action_joined_board'
+ => '{memberCreator} joined {board}',
+ 'action_joined_board@board'
+ => '{memberCreator} joined this board',
+ 'action_joined_board_by_invitation_link'
+ => '{memberCreator} joined {board} with an invitation link from {memberInviter}',
+ 'action_joined_board_by_invitation_link@board'
+ => '{memberCreator} joined this board with an invitation link from {memberInviter}',
+ 'action_joined_organization'
+ => '{memberCreator} joined {organization}',
+ 'action_joined_organization_by_invitation_link'
+ => '{memberCreator} joined {organization} with an invitation link from {memberInviter}',
+ 'action_left_board'
+ => '{memberCreator} left {board}',
+ 'action_left_board@board'
+ => '{memberCreator} left this board',
+ 'action_left_organization'
+ => '{memberCreator} left {organization}',
+ 'action_made_a_normal_user_in_organization'
+ => '{memberCreator} made {member} a normal user in {organization}',
+ 'action_made_a_normal_user_on'
+ => '{memberCreator} made {member} a normal user on {board}',
+ 'action_made_a_normal_user_on@board'
+ => '{memberCreator} made {member} a normal user on this board',
+ 'action_made_admin_of_board'
+ => '{memberCreator} made {member} an admin of {board}',
+ 'action_made_admin_of_board@board'
+ => '{memberCreator} made {member} an admin of this board',
+ 'action_made_an_admin_of_organization'
+ => '{memberCreator} made {member} an admin of {organization}',
+ 'action_made_commenting_on'
+ => '{memberCreator} made commenting on {board} available to {level}',
+ 'action_made_commenting_on@board'
+ => '{memberCreator} made commenting on this board available to {level}',
+ 'action_made_inviting_on'
+ => '{memberCreator} made inviting on {board} available to {level}',
+ 'action_made_inviting_on@board'
+ => '{memberCreator} made inviting on this board available to {level}',
+ 'action_made_observer_of_board'
+ => '{memberCreator} made {member} an observer of {board}',
+ 'action_made_observer_of_board@board'
+ => '{memberCreator} made {member} an observer of this board',
+ 'action_made_self_admin_of_board'
+ => '{memberCreator} made themselves an admin of {board}',
+ 'action_made_self_admin_of_board@board'
+ => '{memberCreator} made themselves an admin of this board',
+ 'action_made_self_observer_of_board'
+ => '{memberCreator} became an observer of {board}',
+ 'action_made_self_observer_of_board@board'
+ => '{memberCreator} became an observer of this board',
+ 'action_made_voting_on'
+ => '{memberCreator} made voting on {board} available to {level}',
+ 'action_made_voting_on@board'
+ => '{memberCreator} made voting on this board available to {level}',
+ 'action_marked_checkitem_incomplete'
+ => '{memberCreator} marked {checkitem} incomplete on {card}',
+ 'action_marked_checkitem_incomplete@card'
+ => '{memberCreator} marked {checkitem} incomplete on this card',
+ 'action_marked_the_due_date_complete'
+ => '{memberCreator} marked the due date on {card} complete',
+ 'action_marked_the_due_date_complete@card'
+ => '{memberCreator} marked the due date complete',
+ 'action_marked_the_due_date_incomplete'
+ => '{memberCreator} marked the due date on {card} incomplete',
+ 'action_marked_the_due_date_incomplete@card'
+ => '{memberCreator} marked the due date incomplete',
+ 'action_member_joined_card'
+ => '{memberCreator} joined {card}',
+ 'action_member_joined_card@card'
+ => '{memberCreator} joined this card',
+ 'action_member_left_card'
+ => '{memberCreator} left {card}',
+ 'action_member_left_card@card'
+ => '{memberCreator} left this card',
+ 'action_members_visibility'
+ => 'its members',
+ 'action_move_card_from_board'
+ => '{memberCreator} transferred {card} to {board}',
+ 'action_move_card_from_board@card'
+ => '{memberCreator} transferred this card to {board}',
+ 'action_move_card_from_list_to_list'
+ => '{memberCreator} moved {card} from {listBefore} to {listAfter}',
+ 'action_move_card_from_list_to_list@card'
+ => '{memberCreator} moved this card from {listBefore} to {listAfter}',
+ 'action_move_card_to_board'
+ => '{memberCreator} transferred {card} from {board}',
+ 'action_move_card_to_board@card'
+ => '{memberCreator} transferred this card from {board}',
+ 'action_move_list_from_board'
+ => '{memberCreator} transferred {list} to {board}',
+ 'action_move_list_to_board'
+ => '{memberCreator} transferred {list} from {board}',
+ 'action_moved_card_higher'
+ => '{memberCreator} moved {card} higher',
+ 'action_moved_card_higher@card'
+ => '{memberCreator} moved this card higher',
+ 'action_moved_card_lower'
+ => '{memberCreator} moved {card} lower',
+ 'action_moved_card_lower@card'
+ => '{memberCreator} moved this card lower',
+ 'action_moved_checkitem_higher'
+ => '{memberCreator} moved {checkitem} higher in the checklist {checklist}',
+ 'action_moved_checkitem_lower'
+ => '{memberCreator} moved {checkitem} higher in the checklist {checklist}',
+ 'action_moved_list_left'
+ => '{memberCreator} moved list {list} left on {board}',
+ 'action_moved_list_left@board'
+ => '{memberCreator} moved {list} left on this board',
+ 'action_moved_list_right'
+ => '{memberCreator} moved list {list} right on {board}',
+ 'action_moved_list_right@board'
+ => '{memberCreator} moved {list} right on this board',
+ 'action_observers_visibility'
+ => 'members and observers',
+ 'action_on'
+ => 'on',
+ 'action_org_visibility'
+ => 'members of its team',
+ 'action_public_visibility'
+ => 'the public',
+ 'action_remove_checklist_from_card'
+ => '{memberCreator} removed {checklist} from {card}',
+ 'action_remove_checklist_from_card@card'
+ => '{memberCreator} removed {checklist} from this card',
+ 'action_remove_from_organization_board'
+ => '{memberCreator} removed {board} from {organization}',
+ 'action_remove_from_organization_board@board'
+ => '{memberCreator} removed this board from {organization}',
+ 'action_remove_label_from_card'
+ => '{memberCreator} removed the {label} label from {card}',
+ 'action_remove_label_from_card@card'
+ => '{memberCreator} removed the {label} label from this card',
+ 'action_remove_organization_from_enterprise'
+ => '{memberCreator} removed team {organization} from the enterprise {enterprise}',
+ 'action_removed_a_due_date'
+ => '{memberCreator} removed the due date from {card}',
+ 'action_removed_a_due_date@card'
+ => '{memberCreator} removed the due date from this card',
+ 'action_removed_from_board'
+ => '{memberCreator} removed {member} from {board}',
+ 'action_removed_from_board@board'
+ => '{memberCreator} removed {member} from this board',
+ 'action_removed_member_from_card'
+ => '{memberCreator} removed {member} from {card}',
+ 'action_removed_member_from_card@card'
+ => '{memberCreator} removed {member} from this card',
+ 'action_removed_member_from_organization'
+ => '{memberCreator} removed {member} from {organization}',
+ 'action_removed_vote_for_card'
+ => '{memberCreator} removed vote for {card}',
+ 'action_removed_vote_for_card@card'
+ => '{memberCreator} removed vote for this card',
+ 'action_rename_custom_field'
+ => '{memberCreator} renamed the {customField} custom field on {board} (from {name})',
+ 'action_rename_custom_field@board'
+ => '{memberCreator} renamed the {customField} custom field on this board (from {name})',
+ 'action_renamed_card'
+ => '{memberCreator} renamed {card} (from {name})',
+ 'action_renamed_card@card'
+ => '{memberCreator} renamed this card (from {name})',
+ 'action_renamed_checkitem'
+ => '{memberCreator} renamed {checkitem} (from {name})',
+ 'action_renamed_checklist'
+ => '{memberCreator} renamed {checklist} (from {name})',
+ 'action_renamed_list'
+ => '{memberCreator} renamed list {list} (from {name})',
+ 'action_reopened_board'
+ => '{memberCreator} re-opened {board}',
+ 'action_reopened_board@board'
+ => '{memberCreator} re-opened this board',
+ 'action_sent_card_to_board'
+ => '{memberCreator} sent {card} to the board',
+ 'action_sent_card_to_board@card'
+ => '{memberCreator} sent this card to the board',
+ 'action_sent_list_to_board'
+ => '{memberCreator} sent list {list} to the board',
+ 'action_set_card_aging_mode_pirate'
+ => '{memberCreator} changed card aging to pirate mode',
+ 'action_set_card_aging_mode_regular'
+ => '{memberCreator} changed card aging to regular mode',
+ 'action_update_board_desc'
+ => '{memberCreator} changed description of {board}',
+ 'action_update_board_desc@board'
+ => '{memberCreator} changed description of this board',
+ 'action_update_board_name'
+ => '{memberCreator} renamed {board} (from {name})',
+ 'action_update_board_name@board'
+ => '{memberCreator} renamed this board (from {name})',
+ 'action_update_custom_field'
+ => '{memberCreator} updated the {customField} custom field on {board}',
+ 'action_update_custom_field@board'
+ => '{memberCreator} updated the {customField} custom field on this board',
+ 'action_update_custom_field_item'
+ => '{memberCreator} updated the value for the {customFieldItem} custom field on {card}',
+ 'action_update_custom_field_item@card'
+ => '{memberCreator} updated the value for the {customFieldItem} custom field on this card',
+ 'action_updated_their_bio'
+ => '{memberCreator} updated their bio',
+ 'action_updated_their_display_name'
+ => '{memberCreator} updated their display name',
+ 'action_updated_their_initials'
+ => '{memberCreator} updated their initials',
+ 'action_updated_their_username'
+ => '{memberCreator} updated their username',
+ 'action_vote_on_card'
+ => '{memberCreator} voted for {card}',
+ 'action_vote_on_card@card'
+ => '{memberCreator} voted for this card',
+ 'action_voting'
+ => 'voting',
+ 'action_withdraw_enterprise_join_request'
+ => '{memberCreator} withdrew a request to add team {organization} to the enterprise {enterprise}'
+ );
+
+ const REQUEST_ACTIONS_BOARDS = array(
+ 'addAttachmentToCard',
+ 'addChecklistToCard',
+ 'addMemberToCard',
+ 'commentCard',
+ 'copyCommentCard',
+ 'convertToCardFromCheckItem',
+ 'createCard',
+ 'copyCard',
+ 'deleteAttachmentFromCard',
+ 'emailCard',
+ 'moveCardFromBoard',
+ 'moveCardToBoard',
+ 'removeChecklistFromCard',
+ 'removeMemberFromCard',
+ 'updateCard:idList',
+ 'updateCard:closed',
+ 'updateCard:due',
+ 'updateCard:dueComplete',
+ 'updateCheckItemStateOnCard',
+ 'updateCustomFieldItem',
+ 'addMemberToBoard',
+ 'addToOrganizationBoard',
+ 'copyBoard',
+ 'createBoard',
+ 'createCustomField',
+ 'createList',
+ 'deleteCard',
+ 'deleteCustomField',
+ 'disablePlugin',
+ 'disablePowerUp',
+ 'enablePlugin',
+ 'enablePowerUp',
+ 'makeAdminOfBoard',
+ 'makeNormalMemberOfBoard',
+ 'makeObserverOfBoard',
+ 'moveListFromBoard',
+ 'moveListToBoard',
+ 'removeFromOrganizationBoard',
+ 'unconfirmedBoardInvitation',
+ 'unconfirmedOrganizationInvitation',
+ 'updateBoard',
+ 'updateCustomField',
+ 'updateList:closed'
+ );
+
+ const REQUEST_ACTIONS_CARDS = array(
+ 'addAttachmentToCard',
+ 'addChecklistToCard',
+ 'addMemberToCard',
+ 'commentCard',
+ 'copyCommentCard',
+ 'convertToCardFromCheckItem',
+ 'createCard',
+ 'copyCard',
+ 'deleteAttachmentFromCard',
+ 'emailCard',
+ 'moveCardFromBoard',
+ 'moveCardToBoard',
+ 'removeChecklistFromCard',
+ 'removeMemberFromCard',
+ 'updateCard:idList',
+ 'updateCard:closed',
+ 'updateCard:due',
+ 'updateCard:dueComplete',
+ 'updateCheckItemStateOnCard',
+ 'updateCustomFieldItem'
+ );
+
+ private $feedName = '';
+ private $feedURI = '';
+
+ private function queryAPI($path, $params = array()) {
+ $data = json_decode(getContents('https://trello.com/1/'
+ . $path
+ . '?'
+ . http_build_query($params)))
+ or returnServerError('Failed to query trello API');
+ return $data;
+ }
+
+ private function renderAction($action, $textOnly = false) {
+ if(!array_key_exists($action->display->translationKey, self::ACTION_TEXTS)) {
+ return '';
+ }
+
+ $strings = array();
+ $entities = (array)$action->display->entities;
+
+ foreach($entities as $entity_name => $entity) {
+ $type = $entity->type;
+ if($type === 'attachmentPreview'
+ && !$textOnly
+ && isset($entity->originalUrl)) {
+ $string = '<p><a href="'
+ . $entity->originalUrl
+ . '"><img src="'
+ . $entity->previewUrl
+ . '"></a></p>';
+ } elseif($type === 'card' && !$textOnly) {
+ $string = '<a href="https://trello.com/c/'
+ . $entity->shortLink
+ . '">'
+ . $entity->text
+ . '</a>';
+ } elseif($type === 'member' && !$textOnly) {
+ $string = '<a href="https://trello.com/'
+ . $entity->username
+ . '">'
+ . $entity->text
+ . '</a>';
+ } elseif($type === 'date') {
+ $string = gmdate('M j, Y \a\t g:i A T', strtotime($entity->date));
+ } elseif($type === 'translatable') {
+ $string = self::ACTION_TEXTS[$entity->translationKey];
+ } else {
+ if(isset($entity->text)) {
+ $string = $entity->text;
+ } else {
+ $string = '';
+ }
+ }
+ $strings['{' . $entity_name . '}'] = $string;
+ }
+
+ return str_replace(array_keys($strings),
+ array_values($strings),
+ self::ACTION_TEXTS[$action->display->translationKey]);
+ }
+
+ public function collectData() {
+ $apiParams = array(
+ 'actions_display' => 'true',
+ 'fields' => 'name,url'
+ );
+ switch($this->queriedContext) {
+ case 'Board':
+ $apiParams['actions'] = implode(',', self::REQUEST_ACTIONS_BOARDS);
+ $data = $this->queryAPI('boards/' . $this->getInput('b'), $apiParams);
+ break;
+ case 'Card':
+ $apiParams['actions'] = implode(',', self::REQUEST_ACTIONS_CARDS);
+ $data = $this->queryAPI('cards/' . $this->getInput('c'), $apiParams);
+ break;
+ default:
+ returnClientError('Invalid context');
+ }
+
+ $this->feedName = $data->name;
+ $this->feedURI = $data->url;
+
+ foreach($data->actions as $action) {
+ $item = array();
+
+ $item['title'] = $this->renderAction($action, true);
+ $item['timestamp'] = strtotime($action->date);
+ $item['author'] = $action->memberCreator->fullName;
+ $item['categories'] = array(
+ 'trello',
+ $action->data->board->name,
+ $action->type
+ );
+ if(isset($action->data->card)) {
+ $item['categories'][] = $action->data->card->name;
+ $item['uri'] = 'https://trello.com/c/'
+ . $action->data->card->shortLink
+ . '#action-'
+ . $action->id;
+ } else {
+ $item['uri'] = 'https://trello.com/b/'
+ . $action->data->board->shortLink;
+ }
+ $item['content'] = $this->renderAction($action, false);
+ if(isset($action->data->attachment->url)) {
+ $item['enclosures'] = array($action->data->attachment->url);
+ }
+
+ $this->items[] = $item;
+ }
+ }
+
+ public function detectParameters($url) {
+ $regex = '/^(https?:\/\/)?trello\.com\/([bc])\/([^\/?\n]+)/';
+ if(preg_match($regex, $url, $matches) > 0) {
+ return array($matches[2] => $matches[3]);
+ } else {
+ return null;
+ }
+ }
+
+ public function getURI() {
+ switch($this->queriedContext) {
+ case 'Board':
+ case 'Card':
+ return $this->feedURI;
+ default: return parent::getURI();
+ }
+ }
+
+ public function getName() {
+ switch($this->queriedContext) {
+ case 'Board':
+ case 'Card':
+ return $this->feedName;
+ default: return parent::getName();
+ }
+ }
+}
diff --git a/bridges/TwitterBridge.php b/bridges/TwitterBridge.php
new file mode 100644
index 0000000..32ed942
--- /dev/null
+++ b/bridges/TwitterBridge.php
@@ -0,0 +1,377 @@
+<?php
+class TwitterBridge extends BridgeAbstract {
+ const NAME = 'Twitter Bridge';
+ const URI = 'https://twitter.com/';
+ const CACHE_TIMEOUT = 300; // 5min
+ const DESCRIPTION = 'returns tweets';
+ const MAINTAINER = 'pmaziere';
+ const PARAMETERS = array(
+ 'global' => array(
+ 'nopic' => array(
+ 'name' => 'Hide profile pictures',
+ 'type' => 'checkbox',
+ 'title' => 'Activate to hide profile pictures in content'
+ ),
+ 'noimg' => array(
+ 'name' => 'Hide images in tweets',
+ 'type' => 'checkbox',
+ 'title' => 'Activate to hide images in tweets'
+ ),
+ 'noimgscaling' => array(
+ 'name' => 'Disable image scaling',
+ 'type' => 'checkbox',
+ 'title' => 'Activate to disable image scaling in tweets (keeps original image)'
+ )
+ ),
+ 'By keyword or hashtag' => array(
+ 'q' => array(
+ 'name' => 'Keyword or #hashtag',
+ 'required' => true,
+ 'exampleValue' => 'rss-bridge, #rss-bridge',
+ 'title' => 'Insert a keyword or hashtag'
+ )
+ ),
+ 'By username' => array(
+ 'u' => array(
+ 'name' => 'username',
+ 'required' => true,
+ 'exampleValue' => 'sebsauvage',
+ 'title' => 'Insert a user name'
+ ),
+ 'norep' => array(
+ 'name' => 'Without replies',
+ 'type' => 'checkbox',
+ 'title' => 'Only return initial tweets'
+ ),
+ 'noretweet' => array(
+ 'name' => 'Without retweets',
+ 'required' => false,
+ 'type' => 'checkbox',
+ 'title' => 'Hide retweets'
+ )
+ ),
+ 'By list' => array(
+ 'user' => array(
+ 'name' => 'User',
+ 'required' => true,
+ 'exampleValue' => 'sebsauvage',
+ 'title' => 'Insert a user name'
+ ),
+ 'list' => array(
+ 'name' => 'List',
+ 'required' => true,
+ 'title' => 'Insert the list name'
+ ),
+ 'filter' => array(
+ 'name' => 'Filter',
+ 'exampleValue' => '#rss-bridge',
+ 'required' => false,
+ 'title' => 'Specify term to search for'
+ )
+ )
+ );
+
+ public function detectParameters($url){
+ $params = array();
+
+ // By keyword or hashtag (search)
+ $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/search.*(\?|&)q=([^\/&?\n]+)/';
+ if(preg_match($regex, $url, $matches) > 0) {
+ $params['q'] = urldecode($matches[4]);
+ return $params;
+ }
+
+ // By hashtag
+ $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/hashtag\/([^\/?\n]+)/';
+ if(preg_match($regex, $url, $matches) > 0) {
+ $params['q'] = urldecode($matches[3]);
+ return $params;
+ }
+
+ // By list
+ $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/([^\/?\n]+)\/lists\/([^\/?\n]+)/';
+ if(preg_match($regex, $url, $matches) > 0) {
+ $params['user'] = urldecode($matches[3]);
+ $params['list'] = urldecode($matches[4]);
+ return $params;
+ }
+
+ // By username
+ $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/([^\/?\n]+)/';
+ if(preg_match($regex, $url, $matches) > 0) {
+ $params['u'] = urldecode($matches[3]);
+ return $params;
+ }
+
+ return null;
+ }
+
+ public function getName(){
+ switch($this->queriedContext) {
+ case 'By keyword or hashtag':
+ $specific = 'search ';
+ $param = 'q';
+ break;
+ case 'By username':
+ $specific = '@';
+ $param = 'u';
+ break;
+ case 'By list':
+ return $this->getInput('list') . ' - Twitter list by ' . $this->getInput('user');
+ default: return parent::getName();
+ }
+ return 'Twitter ' . $specific . $this->getInput($param);
+ }
+
+ public function getURI(){
+ switch($this->queriedContext) {
+ case 'By keyword or hashtag':
+ return self::URI
+ . 'search?q='
+ . urlencode($this->getInput('q'))
+ . '&f=tweets';
+ case 'By username':
+ return self::URI
+ . urlencode($this->getInput('u'));
+ // Always return without replies!
+ // . ($this->getInput('norep') ? '' : '/with_replies');
+ case 'By list':
+ return self::URI
+ . urlencode($this->getInput('user'))
+ . '/lists/'
+ . str_replace(' ', '-', strtolower($this->getInput('list')));
+ default: return parent::getURI();
+ }
+ }
+
+ public function collectData(){
+ $html = '';
+
+ $html = getSimpleHTMLDOM($this->getURI());
+ if(!$html) {
+ switch($this->queriedContext) {
+ case 'By keyword or hashtag':
+ returnServerError('No results for this query.');
+ case 'By username':
+ returnServerError('Requested username can\'t be found.');
+ case 'By list':
+ returnServerError('Requested username or list can\'t be found');
+ }
+ }
+
+ $hidePictures = $this->getInput('nopic');
+
+ foreach($html->find('div.js-stream-tweet') as $tweet) {
+
+ // Skip retweets?
+ if($this->getInput('noretweet')
+ && $tweet->getAttribute('data-screen-name') !== $this->getInput('u')) {
+ continue;
+ }
+
+ // remove 'invisible' content
+ foreach($tweet->find('.invisible') as $invisible) {
+ $invisible->outertext = '';
+ }
+
+ // Skip protmoted tweets
+ $heading = $tweet->previousSibling();
+ if(!is_null($heading) &&
+ $heading->getAttribute('class') === 'promoted-tweet-heading'
+ ) {
+ continue;
+ }
+
+ $item = array();
+ // extract username and sanitize
+ $item['username'] = htmlspecialchars_decode($tweet->getAttribute('data-screen-name'), ENT_QUOTES);
+ // extract fullname (pseudonym)
+ $item['fullname'] = htmlspecialchars_decode($tweet->getAttribute('data-name'), ENT_QUOTES);
+ // get author
+ $item['author'] = $item['fullname'] . ' (@' . $item['username'] . ')';
+ // get avatar link
+ $item['avatar'] = $tweet->find('img', 0)->src;
+ // get TweetID
+ $item['id'] = $tweet->getAttribute('data-tweet-id');
+ // get tweet link
+ $item['uri'] = self::URI . substr($tweet->find('a.js-permalink', 0)->getAttribute('href'), 1);
+ // extract tweet timestamp
+ $item['timestamp'] = $tweet->find('span.js-short-timestamp', 0)->getAttribute('data-time');
+ // generate the title
+ $item['title'] = strip_tags($this->fixAnchorSpacing(htmlspecialchars_decode(
+ $tweet->find('p.js-tweet-text', 0), ENT_QUOTES), '<a>'));
+
+ switch($this->queriedContext) {
+ case 'By list':
+ // Check if filter applies to list (using raw content)
+ if($this->getInput('filter')) {
+ if(stripos($tweet->find('p.js-tweet-text', 0)->plaintext, $this->getInput('filter')) === false) {
+ continue 2; // switch + for-loop!
+ }
+ }
+ break;
+ default:
+ }
+
+ $this->processContentLinks($tweet);
+ $this->processEmojis($tweet);
+
+ // get tweet text
+ $cleanedTweet = str_replace(
+ 'href="/',
+ 'href="' . self::URI,
+ $tweet->find('p.js-tweet-text', 0)->innertext
+ );
+
+ // fix anchors missing spaces in-between
+ $cleanedTweet = $this->fixAnchorSpacing($cleanedTweet);
+
+ // Add picture to content
+ $picture_html = '';
+ if(!$hidePictures) {
+ $picture_html = <<<EOD
+<a href="https://twitter.com/{$item['username']}">
+<img
+ style="align:top; width:75px; border:1px solid black;"
+ alt="{$item['username']}"
+ src="{$item['avatar']}"
+ title="{$item['fullname']}" />
+</a>
+EOD;
+ }
+
+ // Add embeded image to content
+ $image_html = '';
+ $image = $this->getImageURI($tweet);
+ if(!$this->getInput('noimg') && !is_null($image)) {
+ // Set image scaling
+ $image_orig = $this->getInput('noimgscaling') ? $image : $image . ':orig';
+ $image_thumb = $this->getInput('noimgscaling') ? $image : $image . ':thumb';
+
+ // add enclosures
+ $item['enclosures'] = array($image_orig);
+
+ $image_html = <<<EOD
+<a href="{$image_orig}">
+<img
+ style="align:top; max-width:558px; border:1px solid black;"
+ src="{$image_thumb}" />
+</a>
+EOD;
+ }
+
+ // add content
+ $item['content'] = <<<EOD
+<div style="display: inline-block; vertical-align: top;">
+ {$picture_html}
+</div>
+<div style="display: inline-block; vertical-align: top;">
+ <blockquote>{$cleanedTweet}</blockquote>
+</div>
+<div style="display: block; vertical-align: top;">
+ <blockquote>{$image_html}</blockquote>
+</div>
+EOD;
+
+ // add quoted tweet
+ $quotedTweet = $tweet->find('div.QuoteTweet', 0);
+ if($quotedTweet) {
+ // get tweet text
+ $cleanedQuotedTweet = str_replace(
+ 'href="/',
+ 'href="' . self::URI,
+ $quotedTweet->find('div.tweet-text', 0)->innertext
+ );
+
+ $this->processContentLinks($quotedTweet);
+ $this->processEmojis($quotedTweet);
+
+ // Add embeded image to content
+ $quotedImage_html = '';
+ $quotedImage = $this->getQuotedImageURI($tweet);
+ if(!$this->getInput('noimg') && !is_null($quotedImage)) {
+ // Set image scaling
+ $quotedImage_orig = $this->getInput('noimgscaling') ? $quotedImage : $quotedImage . ':orig';
+ $quotedImage_thumb = $this->getInput('noimgscaling') ? $quotedImage : $quotedImage . ':thumb';
+
+ // add enclosures
+ $item['enclosures'] = array($quotedImage_orig);
+
+ $quotedImage_html = <<<EOD
+<a href="{$quotedImage_orig}">
+<img
+ style="align:top; max-width:558px; border:1px solid black;"
+ src="{$quotedImage_thumb}" />
+</a>
+EOD;
+ }
+
+ $item['content'] = <<<EOD
+{$item['content']}
+<hr>
+<div style="display: inline-block; vertical-align: top;">
+ <blockquote>{$cleanedQuotedTweet}</blockquote>
+</div>
+<div style="display: block; vertical-align: top;">
+ <blockquote>{$quotedImage_html}</blockquote>
+</div>
+EOD;
+ }
+ $item['content'] = htmlspecialchars_decode($item['content'], ENT_QUOTES);
+
+ // put out
+ $this->items[] = $item;
+ }
+ }
+
+ private function processEmojis($tweet){
+ // process emojis (reduce size)
+ foreach($tweet->find('img.Emoji') as $img) {
+ $img->style .= ' height: 1em;';
+ }
+ }
+
+ private function processContentLinks($tweet){
+ // processing content links
+ foreach($tweet->find('a') as $link) {
+ if($link->hasAttribute('data-expanded-url')) {
+ $link->href = $link->getAttribute('data-expanded-url');
+ }
+ $link->removeAttribute('data-expanded-url');
+ $link->removeAttribute('data-query-source');
+ $link->removeAttribute('rel');
+ $link->removeAttribute('class');
+ $link->removeAttribute('target');
+ $link->removeAttribute('title');
+ }
+ }
+
+ private function fixAnchorSpacing($content){
+ // fix anchors missing spaces in-between
+ return str_replace(
+ '<a',
+ ' <a',
+ $content
+ );
+ }
+
+ private function getImageURI($tweet){
+ // Find media in tweet
+ $container = $tweet->find('div.AdaptiveMedia-container', 0);
+ if($container && $container->find('img', 0)) {
+ return $container->find('img', 0)->src;
+ }
+
+ return null;
+ }
+
+ private function getQuotedImageURI($tweet){
+ // Find media in tweet
+ $container = $tweet->find('div.QuoteMedia-container', 0);
+ if($container && $container->find('img', 0)) {
+ return $container->find('img', 0)->src;
+ }
+
+ return null;
+ }
+}
diff --git a/bridges/UnsplashBridge.php b/bridges/UnsplashBridge.php
new file mode 100644
index 0000000..ae76734
--- /dev/null
+++ b/bridges/UnsplashBridge.php
@@ -0,0 +1,77 @@
+<?php
+class UnsplashBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'nel50n';
+ const NAME = 'Unsplash Bridge';
+ const URI = 'http://unsplash.com/';
+ const CACHE_TIMEOUT = 43200; // 12h
+ const DESCRIPTION = 'Returns the latests photos from Unsplash';
+
+ const PARAMETERS = array( array(
+ 'm' => array(
+ 'name' => 'Max number of photos',
+ 'type' => 'number',
+ 'defaultValue' => 20
+ ),
+ 'w' => array(
+ 'name' => 'Width',
+ 'exampleValue' => '1920, 1680, …',
+ 'defaultValue' => '1920'
+ ),
+ 'q' => array(
+ 'name' => 'JPEG quality',
+ 'type' => 'number',
+ 'defaultValue' => 75
+ )
+ ));
+
+ public function collectData(){
+ $width = $this->getInput('w');
+ $num = 0;
+ $max = $this->getInput('m');
+ $quality = $this->getInput('q');
+ $lastpage = 1;
+
+ for($page = 1; $page <= $lastpage; $page++) {
+ $link = self::URI . '/grid?page=' . $page;
+ $html = getSimpleHTMLDOM($link)
+ or returnServerError('No results for this query.');
+
+ if($page === 1) {
+ preg_match(
+ '/=(\d+)$/',
+ $html->find('.pagination > a[!class]', -1)->href,
+ $matches
+ );
+
+ $lastpage = min($matches[1], ceil($max / 40));
+ }
+
+ foreach($html->find('.photo') as $element) {
+ $thumbnail = $element->find('img', 0);
+ $thumbnail->src = str_replace('https://', 'http://', $thumbnail->src);
+
+ $item = array();
+ $item['uri'] = str_replace(
+ array('q=75', 'w=400'),
+ array("q=$quality", "w=$width"),
+ $thumbnail->src) . '.jpg'; // '.jpg' only for format hint
+
+ $item['timestamp'] = time();
+ $item['title'] = $thumbnail->alt;
+ $item['content'] = $item['title']
+ . '<br><a href="'
+ . $item['uri']
+ . '"><img src="'
+ . $thumbnail->src
+ . '" /></a>';
+
+ $this->items[] = $item;
+
+ $num++;
+ if ($num >= $max)
+ break 2;
+ }
+ }
+ }
+}
diff --git a/bridges/UsbekEtRicaBridge.php b/bridges/UsbekEtRicaBridge.php
new file mode 100644
index 0000000..3cecd5d
--- /dev/null
+++ b/bridges/UsbekEtRicaBridge.php
@@ -0,0 +1,109 @@
+<?php
+class UsbekEtRicaBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'Usbek & Rica Bridge';
+ const URI = 'https://usbeketrica.com';
+ const DESCRIPTION = 'Returns latest articles from the front page';
+
+ const PARAMETERS = array(
+ array(
+ 'limit' => array(
+ 'name' => 'Number of articles to return',
+ 'type' => 'number',
+ 'required' => false,
+ 'title' => 'Specifies the maximum number of articles to return',
+ 'defaultValue' => -1
+ ),
+ 'fullarticle' => array(
+ 'name' => 'Load full article',
+ 'type' => 'checkbox',
+ 'required' => false,
+ 'title' => 'Activate to load full articles',
+ )
+ )
+ );
+
+ public function collectData(){
+ $limit = $this->getInput('limit');
+ $fullarticle = $this->getInput('fullarticle');
+ $html = getSimpleHTMLDOM($this->getURI());
+
+ $articles = $html->find('div.details');
+
+ foreach($articles as $article) {
+ $item = array();
+
+ $title = $article->find('div.card-title', 0);
+ if($title) {
+ $item['title'] = $title->plaintext;
+ } else {
+ // Sometimes we get rubbish, ignore.
+ continue;
+ }
+
+ $author = $article->find('div.author span', 0);
+ if($author) {
+ $item['author'] = $author->plaintext;
+ }
+
+ $uri = $article->find('a.read', 0)->href;
+ if(substr($uri, 0, 1) === 'h') { // absolute uri
+ $item['uri'] = $uri;
+ } else { // relative uri
+ $item['uri'] = $this->getURI() . $uri;
+ }
+
+ if($fullarticle) {
+ $content = $this->loadFullArticle($item['uri']);
+ }
+
+ if($fullarticle && !is_null($content)) {
+ $item['content'] = $content;
+ } else {
+ $excerpt = $article->find('div.card-excerpt', 0);
+ if($excerpt) {
+ $item['content'] = $excerpt->plaintext;
+ }
+ }
+
+ $image = $article->find('div.card-img img', 0);
+ if($image) {
+ $item['enclosures'] = array(
+ $image->src
+ );
+ }
+
+ $this->items[] = $item;
+
+ if($limit > 0 && count($this->items) >= $limit) {
+ break;
+ }
+ }
+ }
+
+ /**
+ * Loads the full article and returns the contents
+ * @param $uri The article URI
+ * @return The article content
+ */
+ private function loadFullArticle($uri){
+ $html = getSimpleHTMLDOMCached($uri);
+
+ $content = $html->find('section.main', 0);
+ if($content) {
+ return $this->replaceUriInHtmlElement($content);
+ }
+
+ return null;
+ }
+
+ /**
+ * Replaces all relative URIs with absolute ones
+ * @param $element A simplehtmldom element
+ * @return The $element->innertext with all URIs replaced
+ */
+ private function replaceUriInHtmlElement($element){
+ return str_replace('href="/', 'href="' . $this->getURI() . '/', $element->innertext);
+ }
+}
diff --git a/bridges/ViadeoCompanyBridge.php b/bridges/ViadeoCompanyBridge.php
new file mode 100644
index 0000000..3f76188
--- /dev/null
+++ b/bridges/ViadeoCompanyBridge.php
@@ -0,0 +1,37 @@
+<?php
+class ViadeoCompanyBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'regisenguehard';
+ const NAME = 'Viadeo Company';
+ const URI = 'https://www.viadeo.com/';
+ const CACHE_TIMEOUT = 21600; // 6h
+ const DESCRIPTION = 'Returns most recent actus from Company on Viadeo.
+ (http://www.viadeo.com/fr/company/<strong style="font-weight:bold;">apple</strong>)';
+
+ const PARAMETERS = array( array(
+ 'c' => array(
+ 'name' => 'Company name',
+ 'required' => true
+ )
+ ));
+
+ public function collectData(){
+ $html = '';
+ $link = self::URI . 'fr/company/' . $this->getInput('c');
+
+ $html = getSimpleHTMLDOM($link)
+ or returnServerError('Could not request Viadeo.');
+
+ foreach($html->find('//*[@id="company-newsfeed"]/ul/li') as $element) {
+ $title = $element->find('p', 0)->innertext;
+ if($title) {
+ $item = array();
+ $item['uri'] = $link;
+ $item['title'] = mb_substr($element->find('p', 0)->innertext, 0, 100);
+ $item['content'] = $element->find('p', 0)->innertext;;
+ $this->items[] = $item;
+ $i++;
+ }
+ }
+ }
+}
diff --git a/bridges/VkBridge.php b/bridges/VkBridge.php
new file mode 100644
index 0000000..d4e84d9
--- /dev/null
+++ b/bridges/VkBridge.php
@@ -0,0 +1,413 @@
+<?php
+
+class VkBridge extends BridgeAbstract
+{
+
+ const MAINTAINER = 'ahiles3005';
+ const NAME = 'VK.com';
+ const URI = 'https://vk.com/';
+ const CACHE_TIMEOUT = 300; // 5min
+ const DESCRIPTION = 'Working with open pages';
+ const PARAMETERS = array(
+ array(
+ 'u' => array(
+ 'name' => 'Group or user name',
+ 'required' => true
+ )
+ )
+ );
+
+ protected $videos = array();
+ protected $pageName;
+
+ protected function getAccessToken()
+ {
+ return 'c8071613517c155c6cfbd2a059b2718e9c37b89094c4766834969dda75f657a2c1cbb49bab4c5e649f1db';
+ }
+
+ public function getURI()
+ {
+ if (!is_null($this->getInput('u'))) {
+ return static::URI . urlencode($this->getInput('u'));
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName()
+ {
+ if ($this->pageName) {
+ return $this->pageName;
+ }
+
+ return parent::getName();
+ }
+
+ public function collectData()
+ {
+ $text_html = $this->getContents()
+ or returnServerError('No results for group or user name "' . $this->getInput('u') . '".');
+
+ $text_html = iconv('windows-1251', 'utf-8', $text_html);
+ // makes album link generating work correctly
+ $text_html = str_replace('"class="page_album_link">', '" class="page_album_link">', $text_html);
+ $html = str_get_html($text_html);
+ $pageName = $html->find('.page_name', 0);
+ if (is_object($pageName)) {
+ $pageName = $pageName->plaintext;
+ $this->pageName = htmlspecialchars_decode($pageName);
+ }
+ foreach ($html->find('div.replies') as $comment_block) {
+ $comment_block->outertext = '';
+ }
+ $html->load($html->save());
+
+ $pinned_post_item = null;
+ $last_post_id = 0;
+
+ foreach ($html->find('.post') as $post) {
+
+ defaultLinkTo($post, self::URI);
+
+ $post_videos = array();
+
+ $is_pinned_post = false;
+ if (strpos($post->getAttribute('class'), 'post_fixed') !== false) {
+ $is_pinned_post = true;
+ }
+
+ if (is_object($post->find('a.wall_post_more', 0))) {
+ //delete link "show full" in content
+ $post->find('a.wall_post_more', 0)->outertext = '';
+ }
+
+ $content_suffix = '';
+
+ // looking for external links
+ $external_link_selectors = array(
+ 'a.page_media_link_title',
+ 'div.page_media_link_title > a',
+ 'div.media_desc > a.lnk',
+ );
+
+ foreach($external_link_selectors as $sel) {
+ if (is_object($post->find($sel, 0))) {
+ $a = $post->find($sel, 0);
+ $innertext = $a->innertext;
+ $parsed_url = parse_url($a->getAttribute('href'));
+ if (strpos($parsed_url['path'], '/away.php') !== 0) continue;
+ parse_str($parsed_url['query'], $parsed_query);
+ $content_suffix .= "<br>External link: <a href='" . $parsed_query['to'] . "'>$innertext</a>";
+ }
+ }
+
+ // remove external link from content
+ $external_link_selectors_to_remove = array(
+ 'div.page_media_thumbed_link',
+ 'div.page_media_link_desc_wrap',
+ 'div.media_desc > a.lnk',
+ );
+
+ foreach($external_link_selectors_to_remove as $sel) {
+ if (is_object($post->find($sel, 0))) {
+ $post->find($sel, 0)->outertext = '';
+ }
+ }
+
+ // looking for article
+ $article = $post->find('a.article_snippet', 0);
+ if (is_object($article)) {
+ if (strpos($article->getAttribute('class'), 'article_snippet_mini') !== false) {
+ $article_title_selector = 'div.article_snippet_mini_title';
+ $article_author_selector = 'div.article_snippet_mini_info > .mem_link,
+ div.article_snippet_mini_info > .group_link';
+ $article_thumb_selector = 'div.article_snippet_mini_thumb';
+ } else {
+ $article_title_selector = 'div.article_snippet__title';
+ $article_author_selector = 'div.article_snippet__author';
+ $article_thumb_selector = 'div.article_snippet__image';
+ }
+ $article_title = $article->find($article_title_selector, 0)->innertext;
+ $article_author = $article->find($article_author_selector, 0)->innertext;
+ $article_link = $article->getAttribute('href');
+ $article_img_element_style = $article->find($article_thumb_selector, 0)->getAttribute('style');
+ preg_match('/background-image: url\((.*)\)/', $article_img_element_style, $matches);
+ if (count($matches) > 0) {
+ $content_suffix .= "<br><img src='" . $matches[1] . "'>";
+ }
+ $content_suffix .= "<br>Article: <a href='$article_link'>$article_title ($article_author)</a>";
+ $article->outertext = '';
+ }
+
+ // get video on post
+ $video = $post->find('div.post_video_desc', 0);
+ $main_video_link = '';
+ if (is_object($video)) {
+ $video_title = $video->find('div.post_video_title', 0)->plaintext;
+ $video_link = $video->find('a.lnk', 0)->getAttribute('href');
+ $this->appendVideo($video_title, $video_link, $content_suffix, $post_videos);
+ $video->outertext = '';
+ $main_video_link = $video_link;
+ }
+
+ // get all other videos
+ foreach($post->find('a.page_post_thumb_video') as $a) {
+ $video_title = htmlspecialchars_decode($a->getAttribute('aria-label'));
+ $temp = explode(' ', $video_title, 2);
+ if (count($temp) > 1) $video_title = $temp[1];
+ $video_link = $a->getAttribute('href');
+ if ($video_link != $main_video_link) $this->appendVideo($video_title, $video_link, $content_suffix, $post_videos);
+ $a->outertext = '';
+ }
+
+ // get all photos
+ foreach($post->find('div.wall_text > a.page_post_thumb_wrap') as $a) {
+ $result = $this->getPhoto($a);
+ if ($result == null) continue;
+ $a->outertext = '';
+ $content_suffix .= "<br>$result";
+ }
+
+ // get albums
+ foreach($post->find('.page_album_wrap') as $el) {
+ $a = $el->find('.page_album_link', 0);
+ $album_title = $a->find('.page_album_title_text', 0)->getAttribute('title');
+ $album_link = $a->getAttribute('href');
+ $el->outertext = '';
+ $content_suffix .= "<br>Album: <a href='$album_link'>$album_title</a>";
+ }
+
+ // get photo documents
+ foreach($post->find('a.page_doc_photo_href') as $a) {
+ $doc_link = $a->getAttribute('href');
+ $doc_gif_label_element = $a->find('.page_gif_label', 0);
+ $doc_title_element = $a->find('.doc_label', 0);
+
+ if (is_object($doc_gif_label_element)) {
+ $gif_preview_img = backgroundToImg($a->find('.page_doc_photo', 0));
+ $content_suffix .= "<br>Gif: <a href='$doc_link'>$gif_preview_img</a>";
+
+ } else if (is_object($doc_title_element)) {
+ $doc_title = $doc_title_element->innertext;
+ $content_suffix .= "<br>Doc: <a href='$doc_link'>$doc_title</a>";
+
+ } else {
+ continue;
+
+ }
+
+ $a->outertext = '';
+ }
+
+ // get other documents
+ foreach($post->find('div.page_doc_row') as $div) {
+ $doc_title_element = $div->find('a.page_doc_title', 0);
+
+ if (is_object($doc_title_element)) {
+ $doc_title = $doc_title_element->innertext;
+ $doc_link = $doc_title_element->getAttribute('href');
+ $content_suffix .= "<br>Doc: <a href='$doc_link'>$doc_title</a>";
+
+ } else {
+ continue;
+
+ }
+
+ $div->outertext = '';
+ }
+
+ // get polls
+ foreach($post->find('div.page_media_poll_wrap') as $div) {
+ $poll_title = $div->find('.page_media_poll_title', 0)->innertext;
+ $content_suffix .= "<br>Poll: $poll_title";
+ foreach($div->find('div.page_poll_text') as $poll_stat_title) {
+ $content_suffix .= '<br>- ' . $poll_stat_title->innertext;
+ }
+ $div->outertext = '';
+ }
+
+ // get sign
+ $post_author = $pageName;
+ foreach($post->find('a.wall_signed_by') as $a) {
+ $post_author = $a->innertext;
+ $a->outertext = '';
+ }
+
+ if (is_object($post->find('div.copy_quote', 0))) {
+ $copy_quote = $post->find('div.copy_quote', 0);
+ if ($copy_post_header = $copy_quote->find('div.copy_post_header', 0)) {
+ $copy_post_header->outertext = '';
+ }
+ $copy_quote_content = $copy_quote->innertext;
+ $copy_quote->outertext = "<br>Reposted: <br>$copy_quote_content";
+ }
+
+ $item = array();
+ $item['content'] = strip_tags(backgroundToImg($post->find('div.wall_text', 0)->innertext), '<br><img>');
+ $item['content'] .= $content_suffix;
+ $item['categories'] = array();
+
+ // get post hashtags
+ foreach($post->find('a') as $a) {
+ $href = $a->getAttribute('href');
+ $prefix = '/feed?section=search&q=%23';
+ $innertext = $a->innertext;
+ if ($href && substr($href, 0, strlen($prefix)) === $prefix) {
+ $item['categories'][] = urldecode(substr($href, strlen($prefix)));
+ } else if (substr($innertext, 0, 1) == '#') {
+ $item['categories'][] = $innertext;
+ }
+ }
+
+ // get post link
+ $post_link = $post->find('a.post_link', 0)->getAttribute('href');
+ preg_match('/wall-?\d+_(\d+)/', $post_link, $preg_match_result);
+ $item['post_id'] = intval($preg_match_result[1]);
+ $item['uri'] = $post_link;
+ $item['timestamp'] = $this->getTime($post);
+ $item['title'] = $this->getTitle($item['content']);
+ $item['author'] = $post_author;
+ $item['videos'] = $post_videos;
+ if ($is_pinned_post) {
+ // do not append it now
+ $pinned_post_item = $item;
+ } else {
+ $last_post_id = $item['post_id'];
+ $this->items[] = $item;
+ }
+
+ }
+
+ if (!is_null($pinned_post_item)) {
+ if (count($this->items) == 0) {
+ $this->items[] = $pinned_post_item;
+ } else if ($last_post_id < $pinned_post_item['post_id']) {
+ $this->items[] = $pinned_post_item;
+ usort($this->items, function ($item1, $item2) {
+ return $item2['post_id'] - $item1['post_id'];
+ });
+ }
+ }
+
+ $this->getCleanVideoLinks();
+ }
+
+ private function getPhoto($a) {
+ $onclick = $a->getAttribute('onclick');
+ preg_match('/return showPhoto\(.+?({.*})/', $onclick, $preg_match_result);
+ if (count($preg_match_result) == 0) return;
+
+ $arg = htmlspecialchars_decode( str_replace('queue:1', '"queue":1', $preg_match_result[1]) );
+ $data = json_decode($arg, true);
+ if ($data == null) return;
+
+ $thumb = $data['temp']['base'] . $data['temp']['x_'][0] . '.jpg';
+ $original = '';
+ foreach(array('y_', 'z_', 'w_') as $key) {
+ if (!isset($data['temp'][$key])) continue;
+ if (!isset($data['temp'][$key][0])) continue;
+ if (substr($data['temp'][$key][0], 0, 4) == 'http') {
+ $base = '';
+ } else {
+ $base = $data['temp']['base'];
+ }
+ $original = $base . $data['temp'][$key][0] . '.jpg';
+ }
+
+ if ($original) {
+ return "<a href='$original'><img src='$thumb'></a>";
+ } else {
+ return "<img src='$thumb'>";
+ }
+ }
+
+ private function getTitle($content)
+ {
+ preg_match('/^["\w\ \p{Cyrillic}\(\)\?#«»-]+/mu', htmlspecialchars_decode($content), $result);
+ if (count($result) == 0) return 'untitled';
+ return $result[0];
+ }
+
+ private function getTime($post)
+ {
+ if ($time = $post->find('span.rel_date', 0)->getAttribute('time')) {
+ return $time;
+ } else {
+ $strdate = $post->find('span.rel_date', 0)->plaintext;
+
+ $date = date_parse($strdate);
+ if (!$date['year']) {
+ if (strstr($strdate, 'today') !== false) {
+ $strdate = date('d-m-Y') . ' ' . $strdate;
+ } elseif (strstr($strdate, 'yesterday ') !== false) {
+ $time = time() - 60 * 60 * 24;
+ $strdate = date('d-m-Y', $time) . ' ' . $strdate;
+ } else {
+ $strdate = $strdate . ' ' . date('Y');
+ }
+
+ $date = date_parse($strdate);
+ }
+ return strtotime($date['day'] . '-' . $date['month'] . '-' . $date['year'] . ' ' .
+ $date['hour'] . ':' . $date['minute']);
+ }
+
+ }
+
+ private function getContents()
+ {
+ ini_set('user-agent', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:53.0) Gecko/20100101 Firefox/53.0');
+
+ $header = array('Accept-language: en', 'Cookie: remixlang=3');
+
+ return getContents($this->getURI(), $header);
+ }
+
+ protected function appendVideo($video_title, $video_link, &$content_suffix, array &$post_videos)
+ {
+ if (!$video_title) $video_title = '(empty)';
+
+ preg_match('/video([0-9-]+_[0-9]+)/', $video_link, $preg_match_result);
+
+ if (count($preg_match_result) > 1) {
+ $video_id = $preg_match_result[1];
+ $this->videos[ $video_id ] = array(
+ 'url' => $video_link,
+ 'title' => $video_title,
+ );
+ $post_videos[] = $video_id;
+ } else {
+ $content_suffix .= '<br>Video: <a href="' . htmlspecialchars($video_link) . '">' . $video_title . '</a>';
+ }
+ }
+
+ protected function getCleanVideoLinks() {
+ $result = $this->api('video.get', array(
+ 'videos' => implode(',', array_keys($this->videos)),
+ 'count' => 200
+ ));
+
+ if (isset($result['error'])) return;
+
+ foreach($result['response']['items'] as $item) {
+ $video_id = strval($item['owner_id']) . '_' . strval($item['id']);
+ $this->videos[$video_id]['url'] = $item['player'];
+ }
+
+ foreach($this->items as &$item) {
+ foreach($item['videos'] as $video_id) {
+ $video_link = $this->videos[$video_id]['url'];
+ $video_title = $this->videos[$video_id]['title'];
+ $item['content'] .= '<br>Video: <a href="' . htmlspecialchars($video_link) . '">' . $video_title . '</a>';
+ }
+ unset($item['videos']);
+ }
+ }
+
+ protected function api($method, array $params)
+ {
+ $params['v'] = '5.80';
+ $params['access_token'] = $this->getAccessToken();
+ return json_decode( getContents('https://api.vk.com/method/' . $method . '?' . http_build_query($params)), true );
+ }
+}
diff --git a/bridges/WallpaperStopBridge.php b/bridges/WallpaperStopBridge.php
new file mode 100644
index 0000000..3578e71
--- /dev/null
+++ b/bridges/WallpaperStopBridge.php
@@ -0,0 +1,107 @@
+<?php
+class WallpaperStopBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'nel50n';
+ const NAME = 'WallpaperStop Bridge';
+ const URI = 'http://www.wallpaperstop.com';
+ const CACHE_TIMEOUT = 43200; // 12h
+ const DESCRIPTION = 'Returns the latests wallpapers from WallpaperStop';
+
+ const PARAMETERS = array( array(
+ 'c' => array(
+ 'name' => 'Category'
+ ),
+ 's' => array(
+ 'name' => 'subcategory'
+ ),
+ 'm' => array(
+ 'name' => 'Max number of wallpapers',
+ 'type' => 'number',
+ 'defaultValue' => 20
+ ),
+ 'r' => array(
+ 'name' => 'resolution',
+ 'exampleValue' => '1920x1200, 1680x1050,…',
+ 'defaultValue' => '1920x1200'
+ )
+ ));
+
+ public function collectData(){
+ $category = $this->getInput('c');
+ $subcategory = $this->getInput('s');
+ $resolution = $this->getInput('r');
+
+ $num = 0;
+ $max = $this->getInput('m');
+ $lastpage = 1;
+
+ for($page = 1; $page <= $lastpage; $page++) {
+ $link = self::URI
+ . '/'
+ . $category
+ . '-wallpaper/'
+ . (!empty($subcategory) ? $subcategory . '-wallpaper/' : '')
+ . 'desktop-wallpaper-'
+ . $page
+ . '.html';
+
+ $html = getSimpleHTMLDOM($link)
+ or returnServerError('No results for this query.');
+
+ if($page === 1) {
+ preg_match('/-(\d+)\.html$/', $html->find('.pagination > .last', 0)->href, $matches);
+ $lastpage = min($matches[1], ceil($max / 20));
+ }
+
+ foreach($html->find('article.item') as $element) {
+ $wplink = $element->getAttribute('data-permalink');
+ if(preg_match('%^' . self::URI . '/(.+)/([^/]+)-(\d+)\.html$%', $wplink, $matches)) {
+ $thumbnail = $element->find('img', 0);
+
+ $item = array();
+ $item['uri'] = self::URI
+ . '/wallpapers/'
+ . str_replace('wallpaper', 'wallpapers', $matches[1])
+ . '/'
+ . $matches[2]
+ . '-'
+ . $resolution
+ . '-'
+ . $matches[3]
+ . '.jpg';
+
+ $item['id'] = $matches[3];
+ $item['timestamp'] = time();
+ $item['title'] = $thumbnail->title;
+ $item['content'] = $item['title']
+ . '<br><a href="'
+ . $wplink
+ . '"><img src="'
+ . self::URI
+ . $thumbnail->src
+ . '" /></a>';
+
+ $this->items[] = $item;
+
+ $num++;
+ if ($num >= $max)
+ break 2;
+ }
+ }
+ }
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('s')) && !is_null($this->getInput('c')) && !is_null($this->getInput('r'))) {
+ $subcategory = $this->getInput('s');
+ return 'WallpaperStop - '
+ . $this->getInput('c')
+ . (!empty($subcategory) ? ' > ' . $subcategory : '')
+ . ' ['
+ . $this->getInput('r')
+ . ']';
+ }
+
+ return parent::getName();
+ }
+}
diff --git a/bridges/WeLiveSecurityBridge.php b/bridges/WeLiveSecurityBridge.php
new file mode 100644
index 0000000..59a094a
--- /dev/null
+++ b/bridges/WeLiveSecurityBridge.php
@@ -0,0 +1,32 @@
+<?php
+class WeLiveSecurityBridge extends FeedExpander {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'We Live Security';
+ const URI = 'https://www.welivesecurity.com/';
+ const DESCRIPTION = 'Returns the newest articles.';
+
+ protected function parseItem($item){
+ $item = parent::parseItem($item);
+
+ $article_html = getSimpleHTMLDOMCached($item['uri']);
+ if(!$article_html) {
+ $item['content'] .= '<p><em>Could not request ' . $this->getName() . ': ' . $item['uri'] . '</em></p>';
+ return $item;
+ }
+
+ $article_content = $article_html->find('div.formatted', 0)->innertext;
+ $article_content = stripWithDelimiters($article_content, '<script', '</script>');
+ $article_content = stripRecursiveHTMLSection($article_content, 'div', '<div class="comments');
+ $article_content = stripRecursiveHTMLSection($article_content, 'div', '<div class="similar-articles');
+ $article_content = stripRecursiveHTMLSection($article_content, 'span', '<span class="meta');
+ $item['content'] = trim($article_content);
+
+ return $item;
+ }
+
+ public function collectData(){
+ $feed = static::URI . 'feed/';
+ $this->collectExpandableDatas($feed);
+ }
+}
diff --git a/bridges/WebfailBridge.php b/bridges/WebfailBridge.php
new file mode 100644
index 0000000..2a63740
--- /dev/null
+++ b/bridges/WebfailBridge.php
@@ -0,0 +1,149 @@
+<?php
+class WebfailBridge extends BridgeAbstract {
+ const MAINTAINER = 'logmanoriginal';
+ const URI = 'https://webfail.com';
+ const NAME = 'Webfail';
+ const DESCRIPTION = 'Returns the latest fails';
+ const PARAMETERS = array(
+ 'By content type' => array(
+ 'language' => array(
+ 'name' => 'Language',
+ 'type' => 'list',
+ 'title' => 'Select your language',
+ 'values' => array(
+ 'English' => 'en',
+ 'German' => 'de'
+ ),
+ 'defaultValue' => 'English'
+ ),
+ 'type' => array(
+ 'name' => 'Type',
+ 'type' => 'list',
+ 'title' => 'Select your content type',
+ 'values' => array(
+ 'None' => '/',
+ 'Facebook' => '/ffdts',
+ 'Images' => '/images',
+ 'Videos' => '/videos',
+ 'Gifs' => '/gifs'
+ ),
+ 'defaultValue' => 'None'
+ )
+ )
+ );
+
+ public function getURI(){
+ if(is_null($this->getInput('language')))
+ return parent::getURI();
+
+ // e.g.: https://en.webfail.com
+ return 'https://' . $this->getInput('language') . '.webfail.com';
+ }
+
+ public function collectData(){
+
+ ini_set('user_agent', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:53.0) Gecko/20100101 Firefox/53.0');
+
+ $html = getSimpleHTMLDOM($this->getURI() . $this->getInput('type'));
+
+ $type = array_search($this->getInput('type'),
+ self::PARAMETERS[$this->queriedContext]['type']['values']);
+
+ switch(strtolower($type)) {
+ case 'facebook':
+ case 'videos':
+ $this->extractNews($html, $type);
+ break;
+ case 'none':
+ case 'images':
+ case 'gifs':
+ $this->extractArticle($html);
+ break;
+ default: returnClientError('Unknown type: ' . $type);
+ }
+ }
+
+ private function extractNews($html, $type){
+ $news = $html->find('#main', 0)->find('a.wf-list-news');
+ foreach($news as $element) {
+ $item = array();
+ $item['title'] = $this->fixTitle($element->find('div.wf-news-title', 0)->innertext);
+ $item['uri'] = $this->getURI() . $element->href;
+
+ $img = $element->find('img.wf-image', 0)->src;
+ // Load high resolution image for 'facebook'
+ switch(strtolower($type)) {
+ case 'facebook':
+ $img = $this->getImageHiResUri($item['uri']);
+ break;
+ default:
+ }
+
+ $description = '';
+ if(!is_null($element->find('div.wf-news-description', 0))) {
+ $description = $element->find('div.wf-news-description', 0)->innertext;
+ }
+
+ $item['content'] = '<p>'
+ . $description
+ . '</p><br><a href="'
+ . $item['uri']
+ . '"><img src="'
+ . $img
+ . '"></a>';
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function extractArticle($html){
+ $articles = $html->find('article');
+ foreach($articles as $article) {
+ $item = array();
+ $item['title'] = $this->fixTitle($article->find('a', 1)->innertext);
+
+ // Images, videos and gifs are provided in their own unique way
+ if(!is_null($article->find('img.wf-image', 0))) { // Image type
+ $item['uri'] = $this->getURI() . $article->find('a', 2)->href;
+ $item['content'] = '<a href="'
+ . $item['uri']
+ . '"><img src="'
+ . $article->find('img.wf-image', 0)->src
+ . '"></a>';
+ } elseif(!is_null($article->find('div.wf-video', 0))) { // Video type
+ $videoId = $this->getVideoId($article->find('div.wf-play', 0)->onclick);
+ $item['uri'] = 'https://youtube.com/watch?v=' . $videoId;
+ $item['content'] = '<a href="'
+ . $item['uri']
+ . '"><img src="http://img.youtube.com/vi/'
+ . $videoId
+ . '/0.jpg"></a>';
+ } elseif(!is_null($article->find('video[id*=gif-]', 0))) { // Gif type
+ $item['uri'] = $this->getURI() . $article->find('a', 2)->href;
+ $item['content'] = '<video controls src="'
+ . $article->find('video[id*=gif-]', 0)->src
+ . '" poster="'
+ . $article->find('video[id*=gif-]', 0)->poster
+ . '"></video>';
+ }
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function fixTitle($title){
+ // This fixes titles that include umlauts (in German language)
+ return html_entity_decode($title, ENT_QUOTES | ENT_HTML401, 'UTF-8');
+ }
+
+ private function getVideoId($onclick){
+ return substr($onclick, 21, 11);
+ }
+
+ private function getImageHiResUri($url){
+ // https://de.webfail.com/ef524fae509?tag=ffdt
+ // http://cdn.webfail.com/upl/img/ef524fae509/post2.jpg
+ $id = substr($url, strrpos($url, '/') + 1, strlen($url) - strrpos($url, '?') + 2);
+ return 'http://cdn.webfail.com/upl/img/' . $id . '/post2.jpg';
+ }
+}
diff --git a/bridges/WhydBridge.php b/bridges/WhydBridge.php
new file mode 100644
index 0000000..04d0b30
--- /dev/null
+++ b/bridges/WhydBridge.php
@@ -0,0 +1,62 @@
+<?php
+class WhydBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'kranack';
+ const NAME = 'Whyd Bridge';
+ const URI = 'http://www.whyd.com/';
+ const CACHE_TIMEOUT = 600; // 10min
+ const DESCRIPTION = 'Returns 10 newest music from user profile';
+
+ const PARAMETERS = array( array(
+ 'u' => array(
+ 'name' => 'username/id',
+ 'required' => true
+ )
+ ));
+
+ private $userName = '';
+
+ public function getIcon() {
+ return self::URI . 'assets/favicons/
+32-6b62a9f14d5e1a9213090d8f00f286bba3a6022381a76390d1d0926493b12593.png?v=6';
+ }
+
+ public function collectData(){
+ $html = '';
+ if(strlen(preg_replace('/[^0-9a-f]/', '', $this->getInput('u'))) == 24) {
+ // is input the userid ?
+ $html = getSimpleHTMLDOM(
+ self::URI . 'u/' . preg_replace('/[^0-9a-f]/', '', $this->getInput('u'))
+ ) or returnServerError('No results for this query.');
+ } else { // input may be the username
+ $html = getSimpleHTMLDOM(
+ self::URI . 'search?q=' . urlencode($this->getInput('u'))
+ ) or returnServerError('No results for this query.');
+
+ for($j = 0; $j < 5; $j++) {
+ if(strtolower($html->find('div.user', $j)->find('a', 0)->plaintext) == strtolower($this->getInput('u'))) {
+ $html = getSimpleHTMLDOM(
+ self::URI . $html->find('div.user', $j)->find('a', 0)->getAttribute('href')
+ ) or returnServerError('No results for this query');
+ break;
+ }
+ }
+ }
+ $this->userName = $html->find('div#profileTop', 0)->find('h1', 0)->plaintext;
+
+ for($i = 0; $i < 10; $i++) {
+ $track = $html->find('div.post', $i);
+ $item = array();
+ $item['author'] = $track->find('h2', 0)->plaintext;
+ $item['title'] = $track->find('h2', 0)->plaintext;
+ $item['content'] = $track->find('a.thumb', 0) . '<br/>' . $track->find('h2', 0)->plaintext;
+ $item['id'] = self::URI . $track->find('a.no-ajaxy', 0)->getAttribute('href');
+ $item['uri'] = self::URI . $track->find('a.no-ajaxy', 0)->getAttribute('href');
+ $this->items[] = $item;
+ }
+ }
+
+ public function getName(){
+ return (!empty($this->userName) ? $this->userName . ' - ' : '') . 'Whyd Bridge';
+ }
+}
diff --git a/bridges/WikiLeaksBridge.php b/bridges/WikiLeaksBridge.php
new file mode 100644
index 0000000..c5b9bb6
--- /dev/null
+++ b/bridges/WikiLeaksBridge.php
@@ -0,0 +1,129 @@
+<?php
+class WikiLeaksBridge extends BridgeAbstract {
+ const NAME = 'WikiLeaks';
+ const URI = 'https://wikileaks.org';
+ const DESCRIPTION = 'Returns the latest news or articles from WikiLeaks';
+ const MAINTAINER = 'logmanoriginal';
+ const PARAMETERS = array(
+ array(
+ 'category' => array(
+ 'name' => 'Category',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Select your category',
+ 'values' => array(
+ 'News' => '-News-',
+ 'Leaks' => array(
+ 'All' => '-Leaks-',
+ 'Intelligence' => '+-Intelligence-+',
+ 'Global Economy' => '+-Global-Economy-+',
+ 'International Politics' => '+-International-Politics-+',
+ 'Corporations' => '+-Corporations-+',
+ 'Government' => '+-Government-+',
+ 'War & Military' => '+-War-Military-+'
+ )
+ ),
+ 'defaultValue' => 'news'
+ ),
+ 'teaser' => array(
+ 'name' => 'Show teaser',
+ 'type' => 'checkbox',
+ 'required' => false,
+ 'title' => 'If checked feeds will display the teaser',
+ 'defaultValue' => true
+ )
+ )
+ );
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI());
+
+ // News are presented differently
+ switch($this->getInput('category')) {
+ case '-News-':
+ $this->loadNewsItems($html);
+ break;
+ default:
+ $this->loadLeakItems($html);
+ }
+ }
+
+ public function getURI(){
+ if(!is_null($this->getInput('category'))) {
+ return static::URI . '/' . $this->getInput('category') . '.html';
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('category'))) {
+ $category = array_search(
+ $this->getInput('category'),
+ static::PARAMETERS[0]['category']['values']
+ );
+
+ if($category === false) {
+ $category = array_search(
+ $this->getInput('category'),
+ static::PARAMETERS[0]['category']['values']['Leaks']
+ );
+ }
+
+ return $category . ' - ' . static::NAME;
+ }
+
+ return parent::getName();
+ }
+
+ private function loadNewsItems($html){
+ $articles = $html->find('div.news-articles ul li');
+
+ if(is_null($articles) || count($articles) === 0) {
+ return;
+ }
+
+ foreach($articles as $article) {
+ $item = array();
+
+ $item['title'] = $article->find('h3', 0)->plaintext;
+ $item['uri'] = static::URI . $article->find('h3 a', 0)->href;
+ $item['content'] = $article->find('div.introduction', 0)->plaintext;
+ $item['timestamp'] = strtotime($article->find('div.timestamp', 0)->plaintext);
+
+ $this->items[] = $item;
+ }
+ }
+
+ private function loadLeakItems($html){
+ $articles = $html->find('li.tile');
+
+ if(is_null($articles) || count($articles) === 0) {
+ return;
+ }
+
+ foreach($articles as $article) {
+ $item = array();
+
+ $item['title'] = $article->find('h2', 0)->plaintext;
+ $item['uri'] = static::URI . $article->find('a', 0)->href;
+
+ $teaser = static::URI . '/' . $article->find('div.teaser img', 0)->src;
+
+ if($this->getInput('teaser')) {
+ $item['content'] = '<img src="'
+ . $teaser
+ . '" /><p>'
+ . $article->find('div.intro', 0)->plaintext
+ . '</p>';
+ } else {
+ $item['content'] = $article->find('div.intro', 0)->plaintext;
+ }
+
+ $item['timestamp'] = strtotime($article->find('div.timestamp', 0)->plaintext);
+ $item['enclosures'] = array($teaser);
+
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/WikipediaBridge.php b/bridges/WikipediaBridge.php
new file mode 100644
index 0000000..6b53440
--- /dev/null
+++ b/bridges/WikipediaBridge.php
@@ -0,0 +1,304 @@
+<?php
+
+define('WIKIPEDIA_SUBJECT_TFA', 0); // Today's featured article
+define('WIKIPEDIA_SUBJECT_DYK', 1); // Did you know...
+
+class WikipediaBridge extends BridgeAbstract {
+ const MAINTAINER = 'logmanoriginal';
+ const NAME = 'Wikipedia bridge for many languages';
+ const URI = 'https://www.wikipedia.org/';
+ const DESCRIPTION = 'Returns articles for a language of your choice';
+
+ const PARAMETERS = array( array(
+ 'language' => array(
+ 'name' => 'Language',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'Select your language',
+ 'exampleValue' => 'English',
+ 'values' => array(
+ 'English' => 'en',
+ 'Dutch' => 'nl',
+ 'Esperanto' => 'eo',
+ 'French' => 'fr',
+ 'German' => 'de',
+ )
+ ),
+ 'subject' => array(
+ 'name' => 'Subject',
+ 'type' => 'list',
+ 'required' => true,
+ 'title' => 'What subject are you interested in?',
+ 'exampleValue' => 'Today\'s featured article',
+ 'values' => array(
+ 'Today\'s featured article' => 'tfa',
+ 'Did you know…' => 'dyk'
+ )
+ ),
+ 'fullarticle' => array(
+ 'name' => 'Load full article',
+ 'type' => 'checkbox',
+ 'title' => 'Activate to always load the full article'
+ )
+ ));
+
+ public function getURI(){
+ if(!is_null($this->getInput('language'))) {
+ return 'https://'
+ . strtolower($this->getInput('language'))
+ . '.wikipedia.org';
+ }
+
+ return parent::getURI();
+ }
+
+ public function getName(){
+ switch($this->getInput('subject')) {
+ case 'tfa':
+ $subject = WIKIPEDIA_SUBJECT_TFA;
+ break;
+ case 'dyk':
+ $subject = WIKIPEDIA_SUBJECT_DYK;
+ break;
+ default: return parent::getName();
+ }
+
+ switch($subject) {
+ case WIKIPEDIA_SUBJECT_TFA:
+ $name = 'Today\'s featured article from '
+ . strtolower($this->getInput('language'))
+ . '.wikipedia.org';
+ break;
+ case WIKIPEDIA_SUBJECT_DYK:
+ $name = 'Did you know? - articles from '
+ . strtolower($this->getInput('language'))
+ . '.wikipedia.org';
+ break;
+ default:
+ $name = 'Articles from '
+ . strtolower($this->getInput('language'))
+ . '.wikipedia.org';
+ break;
+ }
+ return $name;
+ }
+
+ public function collectData(){
+
+ switch($this->getInput('subject')) {
+ case 'tfa':
+ $subject = WIKIPEDIA_SUBJECT_TFA;
+ break;
+ case 'dyk':
+ $subject = WIKIPEDIA_SUBJECT_DYK;
+ break;
+ default:
+ $subject = WIKIPEDIA_SUBJECT_TFA;
+ break;
+ }
+
+ $fullArticle = $this->getInput('fullarticle');
+
+ // This will automatically send us to the correct main page in any language (try it!)
+ $html = getSimpleHTMLDOM($this->getURI() . '/wiki');
+
+ if(!$html)
+ returnServerError('Could not load site: ' . $this->getURI() . '!');
+
+ /*
+ * Now read content depending on the language (make sure to create one function per language!)
+ * We build the function name automatically, just make sure you create a private function ending
+ * with your desired language code, where the language code is upper case! (en -> getContentsEN).
+ */
+ $function = 'getContents' . ucfirst(strtolower($this->getInput('language')));
+
+ if(!method_exists($this, $function))
+ returnServerError('A function to get the contents for your language is missing (\'' . $function . '\')!');
+
+ /*
+ * The method takes care of creating all items.
+ */
+ $this->$function($html, $subject, $fullArticle);
+ }
+
+ /**
+ * Replaces all relative URIs with absolute ones
+ * @param $element A simplehtmldom element
+ * @return The $element->innertext with all URIs replaced
+ */
+ private function replaceUriInHtmlElement($element){
+ return str_replace('href="/', 'href="' . $this->getURI() . '/', $element->innertext);
+ }
+
+ /*
+ * Adds a new item to $items using a generic operation (should work for most
+ * (all?) wikis) $anchorText can be specified if the wiki in question doesn't
+ * use '...' (like Dutch, French and Italian) $anchorFallbackIndex can be
+ * used to specify a different fallback link than the first
+ * (e.g., -1 for the last)
+ */
+ private function addTodaysFeaturedArticleGeneric($element,
+ $fullArticle,
+ $anchorText = '...',
+ $anchorFallbackIndex = 0){
+ // Clean the bottom of the featured article
+ if ($element->find('div', -1))
+ $element->find('div', -1)->outertext = '';
+
+ // The title and URI of the article can be found in an anchor containing
+ // the string '...' in most wikis ('full article ...')
+ $target = $element->find('p/a', $anchorFallbackIndex);
+ foreach($element->find('//a') as $anchor) {
+ if(strpos($anchor->innertext, $anchorText) !== false) {
+ $target = $anchor;
+ break;
+ }
+ }
+
+ $item = array();
+ $item['uri'] = $this->getURI() . $target->href;
+ $item['title'] = $target->title;
+
+ if(!$fullArticle)
+ $item['content'] = strip_tags($this->replaceUriInHtmlElement($element), '<a><p><br><img>');
+ else
+ $item['content'] = $this->loadFullArticle($item['uri']);
+
+ $this->items[] = $item;
+ }
+
+ /*
+ * Adds a new item to $items using a generic operation (should work for most (all?) wikis)
+ */
+ private function addDidYouKnowGeneric($element, $fullArticle){
+ foreach($element->find('ul', 0)->find('li') as $entry) {
+ $item = array();
+
+ // We can only use the first anchor, there is no way of finding the 'correct' one if there are multiple
+ $item['uri'] = $this->getURI() . $entry->find('a', 0)->href;
+ $item['title'] = strip_tags($entry->innertext);
+
+ if(!$fullArticle)
+ $item['content'] = $this->replaceUriInHtmlElement($entry);
+ else
+ $item['content'] = $this->loadFullArticle($item['uri']);
+
+ $this->items[] = $item;
+ }
+ }
+
+ /**
+ * Loads the full article from a given URI
+ */
+ private function loadFullArticle($uri){
+ $content_html = getSimpleHTMLDOMCached($uri);
+
+ if(!$content_html)
+ returnServerError('Could not load site: ' . $uri . '!');
+
+ $content = $content_html->find('#mw-content-text', 0);
+
+ if(!$content)
+ returnServerError('Could not find content in page: ' . $uri . '!');
+
+ // Let's remove a couple of things from the article
+ $table = $content->find('#toc', 0); // Table of contents
+ if(!$table === false)
+ $table->outertext = '';
+
+ foreach($content->find('ol.references') as $reference) // References
+ $reference->outertext = '';
+
+ return str_replace('href="/', 'href="' . $this->getURI() . '/', $content->innertext);
+ }
+
+ /**
+ * Implementation for de.wikipedia.org
+ */
+ private function getContentsDe($html, $subject, $fullArticle){
+ switch($subject) {
+ case WIKIPEDIA_SUBJECT_TFA:
+ $element = $html->find('div[id=mf-tfa]', 0);
+ $this->addTodaysFeaturedArticleGeneric($element, $fullArticle);
+ break;
+ case WIKIPEDIA_SUBJECT_DYK:
+ $element = $html->find('div[id=mf-dyk]', 0);
+ $this->addDidYouKnowGeneric($element, $fullArticle);
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Implementation for fr.wikipedia.org
+ */
+ private function getContentsFr($html, $subject, $fullArticle){
+ switch($subject) {
+ case WIKIPEDIA_SUBJECT_TFA:
+ $element = $html->find('div[class=accueil_2017_cadre]', 0);
+ $this->addTodaysFeaturedArticleGeneric($element, $fullArticle, 'Lire la suite');
+ break;
+ case WIKIPEDIA_SUBJECT_DYK:
+ $element = $html->find('div[class=accueil_2017_cadre]', 2);
+ $this->addDidYouKnowGeneric($element, $fullArticle);
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Implementation for en.wikipedia.org
+ */
+ private function getContentsEn($html, $subject, $fullArticle){
+ switch($subject) {
+ case WIKIPEDIA_SUBJECT_TFA:
+ $element = $html->find('div[id=mp-tfa]', 0);
+ $this->addTodaysFeaturedArticleGeneric($element, $fullArticle);
+ break;
+ case WIKIPEDIA_SUBJECT_DYK:
+ $element = $html->find('div[id=mp-dyk]', 0);
+ $this->addDidYouKnowGeneric($element, $fullArticle);
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Implementation for eo.wikipedia.org
+ */
+ private function getContentsEo($html, $subject, $fullArticle){
+ switch($subject) {
+ case WIKIPEDIA_SUBJECT_TFA:
+ $element = $html->find('div[id=mf-artikolo-de-la-semajno]', 0);
+ $this->addTodaysFeaturedArticleGeneric($element, $fullArticle);
+ break;
+ case WIKIPEDIA_SUBJECT_DYK:
+ $element = $html->find('div[id=mw-content-text]', 0)->find('table', 4)->find('td', 4);
+ $this->addDidYouKnowGeneric($element, $fullArticle);
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Implementation for nl.wikipedia.org
+ */
+ private function getContentsNl($html, $subject, $fullArticle){
+ switch($subject) {
+ case WIKIPEDIA_SUBJECT_TFA:
+ $element = $html->find('div[id=mf-uitgelicht]', 0);
+ $this->addTodaysFeaturedArticleGeneric($element, $fullArticle, 'Lees meer');
+ break;
+ case WIKIPEDIA_SUBJECT_DYK:
+ $element = $html->find('div[id=mw-content-text]', 0)->find('table', 4)->find('td', 2);
+ $this->addDidYouKnowGeneric($element, $fullArticle);
+ break;
+ default:
+ break;
+ }
+ }
+}
diff --git a/bridges/WordPressBridge.php b/bridges/WordPressBridge.php
new file mode 100644
index 0000000..1589c72
--- /dev/null
+++ b/bridges/WordPressBridge.php
@@ -0,0 +1,101 @@
+<?php
+class WordPressBridge extends FeedExpander {
+ const MAINTAINER = 'aledeg';
+ const NAME = 'Wordpress Bridge';
+ const URI = 'https://wordpress.org/';
+ const DESCRIPTION = 'Returns the newest full posts of a WordPress powered website';
+
+ const PARAMETERS = array( array(
+ 'url' => array(
+ 'name' => 'Blog URL',
+ 'required' => true
+ )
+ ));
+
+ private function cleanContent($content){
+ $content = stripWithDelimiters($content, '<script', '</script>');
+ $content = preg_replace('/<div class="wpa".*/', '', $content);
+ $content = preg_replace('/<form.*\/form>/', '', $content);
+ return $content;
+ }
+
+ protected function parseItem($newItem){
+ $item = parent::parseItem($newItem);
+
+ $article_html = getSimpleHTMLDOMCached($item['uri']);
+
+ $article = null;
+ switch(true) {
+ case !is_null($article_html->find('[itemprop=articleBody]', 0)):
+ // highest priority content div
+ $article = $article_html->find('[itemprop=articleBody]', 0);
+ break;
+ case !is_null($article_html->find('article', 0)):
+ // most common content div
+ $article = $article_html->find('article', 0);
+ break;
+ case !is_null($article_html->find('.single-content', 0)):
+ // another common content div
+ $article = $article_html->find('.single-content', 0);
+ break;
+ case !is_null($article_html->find('.post-content', 0)):
+ // another common content div
+ $article = $article_html->find('.post-content', 0);
+ break;
+ case !is_null($article_html->find('.post', 0)):
+ // for old WordPress themes without HTML5
+ $article = $article_html->find('.post', 0);
+ break;
+ }
+
+ foreach ($article->find('h1.entry-title') as $title)
+ if ($title->plaintext == $item['title'])
+ $title->outertext = '';
+
+ $article_image = $article_html->find('img.wp-post-image', 0);
+ if(!empty($item['content']) && (!is_object($article_image) || empty($article_image->src))) {
+ $article_image = str_get_html($item['content'])->find('img.wp-post-image', 0);
+ }
+ if(is_object($article_image) && !empty($article_image->src)) {
+ if(empty($article_image->getAttribute('data-lazy-src'))) {
+ $article_image = $article_image->src;
+ } else {
+ $article_image = $article_image->getAttribute('data-lazy-src');
+ }
+ $mime_type = getMimeType($article_image);
+ if (strpos($mime_type, 'image') === false)
+ $article_image .= '#.image'; // force image
+ if (empty($item['enclosures']))
+ $item['enclosures'] = array($article_image);
+ else
+ $item['enclosures'] = array_merge($item['enclosures'], $article_image);
+ }
+
+ if(!is_null($article)) {
+ $item['content'] = $this->cleanContent($article->innertext);
+ }
+
+ return $item;
+ }
+
+ public function getURI(){
+ $url = $this->getInput('url');
+ if(empty($url)) {
+ $url = parent::getURI();
+ }
+ return $url;
+ }
+
+ public function collectData(){
+ if($this->getInput('url') && substr($this->getInput('url'), 0, strlen('http')) !== 'http') {
+ // just in case someone find a way to access local files by playing with the url
+ returnClientError('The url parameter must either refer to http or https protocol.');
+ }
+ try{
+ $this->collectExpandableDatas($this->getURI() . '/feed/atom/');
+ } catch (Exception $e) {
+ $this->collectExpandableDatas($this->getURI() . '/?feed=atom');
+ }
+
+ }
+}
diff --git a/bridges/WordPressPluginUpdateBridge.php b/bridges/WordPressPluginUpdateBridge.php
new file mode 100644
index 0000000..80b53ea
--- /dev/null
+++ b/bridges/WordPressPluginUpdateBridge.php
@@ -0,0 +1,86 @@
+<?php
+class WordPressPluginUpdateBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene';
+ const NAME = 'WordPress Plugins Update Bridge';
+ const URI = 'https://wordpress.org/plugins/';
+ const CACHE_TIMEOUT = 86400; // 24h = 86400s
+ const DESCRIPTION = 'Returns latest updates of WordPress.com plugins.';
+
+ const PARAMETERS = array(
+ array(
+ 'pluginUrl' => array(
+ 'name' => 'URL to the plugin',
+ 'required' => true
+ )
+ )
+ );
+
+ public function collectData(){
+
+ $request = str_replace('/', '', $this->getInput('pluginUrl'));
+ $page = self::URI . $request . '/changelog/';
+
+ $html = getSimpleHTMLDOM($page)
+ or returnServerError('No results for this query.');
+
+ $content = $html->find('.block-content', 0);
+
+ $item = array();
+ $item['content'] = '';
+ $version = null;
+
+ foreach($content->children() as $element) {
+
+ if($element->tag != 'h4') {
+
+ $item['content'] .= $element;
+
+ } else {
+
+ if($version == null) {
+
+ $version = $element;
+
+ } else {
+
+ $item['title'] = $version;
+ $item['uri'] = 'https://downloads.wordpress.org/plugin/' . $request . '.' . strip_tags($version) . '.zip';
+ $this->items[] = $item;
+
+ $version = $element;
+ $item = array();
+ $item['content'] = '';
+
+ }
+
+ }
+
+ }
+
+ $item['uri'] = 'https://downloads.wordpress.org/plugin/' . $request . '.' . strip_tags($version) . '.zip';
+ $item['title'] = $version;
+ $this->items[] = $item;
+
+ }
+
+ public function getName(){
+ if(!is_null($this->getInput('q'))) {
+ return $this->getInput('q') . ' : ' . self::NAME;
+ }
+
+ return parent::getName();
+ }
+
+ private function getCachedDate($url){
+ Debug::log('getting pubdate from url ' . $url . '');
+ // Initialize cache
+ $cache = Cache::create('FileCache');
+ $cache->setPath(PATH_CACHE . 'pages/');
+ $params = [$url];
+ $cache->setParameters($params);
+ // Get cachefile timestamp
+ $time = $cache->getTime();
+ return ($time !== false ? $time : time());
+ }
+}
diff --git a/bridges/WorldOfTanksBridge.php b/bridges/WorldOfTanksBridge.php
new file mode 100644
index 0000000..46dd588
--- /dev/null
+++ b/bridges/WorldOfTanksBridge.php
@@ -0,0 +1,52 @@
+<?php
+class WorldOfTanksBridge extends FeedExpander {
+
+ const MAINTAINER = 'Riduidel';
+ const NAME = 'World of Tanks';
+ const URI = 'http://worldoftanks.eu/';
+ const DESCRIPTION = 'News about the tank slaughter game.';
+
+ const PARAMETERS = array( array(
+ 'lang' => array(
+ 'name' => 'Langue',
+ 'type' => 'list',
+ 'values' => array(
+ 'Français' => 'fr',
+ 'English' => 'en',
+ 'Español' => 'es',
+ 'Deutsch' => 'de',
+ 'Čeština' => 'cs',
+ 'Polski' => 'pl',
+ 'Türkçe' => 'tr'
+ )
+ )
+ ));
+
+ public function collectData() {
+ $this->collectExpandableDatas(sprintf('https://worldoftanks.eu/%s/rss/news/', $this->getInput('lang')));
+ }
+
+ protected function parseItem($newsItem){
+ $item = parent::parseItem($newsItem);
+ $item['content'] = $this->loadFullArticle($item['uri']);
+ return $item;
+ }
+
+ /**
+ * Loads the full article and returns the contents
+ * @param $uri The article URI
+ * @return The article content
+ */
+ private function loadFullArticle($uri){
+ $html = getSimpleHTMLDOMCached($uri);
+
+ $content = $html->find('article', 0);
+
+ // Remove the scripts, please
+ foreach($content->find('script') as $script) {
+ $script->outertext = '';
+ }
+
+ return $content->innertext;
+ }
+}
diff --git a/bridges/XbooruBridge.php b/bridges/XbooruBridge.php
new file mode 100644
index 0000000..d3605be
--- /dev/null
+++ b/bridges/XbooruBridge.php
@@ -0,0 +1,12 @@
+<?php
+require_once('GelbooruBridge.php');
+
+class XbooruBridge extends GelbooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Xbooru';
+ const URI = 'http://xbooru.com/';
+ const DESCRIPTION = 'Returns images from given page';
+
+ const PIDBYPAGE = 50;
+}
diff --git a/bridges/XenForoBridge.php b/bridges/XenForoBridge.php
new file mode 100644
index 0000000..7bf1f15
--- /dev/null
+++ b/bridges/XenForoBridge.php
@@ -0,0 +1,460 @@
+<?php
+/**
+ * This bridge generates feeds for threads from forums running XenForo version 2
+ *
+ * Examples:
+ * - https://xenforo.com/community/
+ * - http://www.ign.com/boards/
+ *
+ * Notice: XenForo does provide RSS feeds for forums. For example:
+ * - https://xenforo.com/community/forums/-/index.rss
+ *
+ * For more information on XenForo, visit
+ * - https://xenforo.com/
+ * - https://en.wikipedia.org/wiki/XenForo
+ */
+class XenForoBridge extends BridgeAbstract {
+
+ // Bridge specific constants
+ const CONTEXT_THREAD = 'Thread';
+ const XENFORO_VERSION_1 = '1.0';
+ const XENFORO_VERSION_2 = '2.0';
+
+ // RSS-Bridge constants
+ const NAME = 'XenForo Bridge';
+ const URI = 'https://xenforo.com/';
+ const DESCRIPTION = 'Generates feeds for threads in forums powered by XenForo';
+ const MAINTAINER = 'logmanoriginal';
+ const PARAMETERS = array(
+ self::CONTEXT_THREAD => array(
+ 'url' => array(
+ 'name' => 'Thread URL',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'Insert URL to the thread for which the feed should be generated',
+ 'exampleValue' => 'https://xenforo.com/community/threads/guide-to-suggestions.2285/'
+ )
+ ),
+ 'global' => array(
+ 'limit' => array(
+ 'name' => 'Limit',
+ 'type' => 'number',
+ 'required' => false,
+ 'title' => 'Specify maximum number of elements to return in the feed',
+ 'defaultValue' => 10
+ )
+ )
+ );
+ const CACHE_TIMEOUT = 7200; // 10 minutes
+
+ private $title = '';
+ private $threadurl = '';
+ private $version; // Holds the XenForo version
+
+ public function getName() {
+
+ switch($this->queriedContext) {
+ case self::CONTEXT_THREAD: return $this->title . ' - ' . static::NAME;
+ }
+
+ return parent::getName();
+
+ }
+
+ public function getURI() {
+
+ switch($this->queriedContext) {
+ case self::CONTEXT_THREAD: return $this->threadurl;
+ }
+
+ return parent::getURI();
+
+ }
+
+ public function collectData() {
+
+ $this->threadurl = filter_var(
+ $this->getInput('url'),
+ FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED);
+
+ if($this->threadurl === false) {
+ returnClientError('The URL you provided is invalid!');
+ }
+
+ $urlparts = parse_url($this->threadurl, PHP_URL_SCHEME);
+
+ // Scheme must be "http" or "https"
+ if(preg_match('/http[s]{0,1}/', parse_url($this->threadurl, PHP_URL_SCHEME)) == false) {
+ returnClientError('The URL you provided doesn\'t specify a valid scheme (http or https)!');
+ }
+
+ // Path cannot be root (../)
+ if(parse_url($this->threadurl, PHP_URL_PATH) === '/') {
+ returnClientError('The URL you provided doesn\'t link to a valid thread (root path)!');
+ }
+
+ // XenForo adds a thread ID to the URL, like "...-thread.454934283". It must be present
+ if(preg_match('/.+\.\d+[\/]{0,1}/', parse_URL($this->threadurl, PHP_URL_PATH)) == false) {
+ returnClientError('The URL you provided doesn\'t link to a valid thread (ID missing)!');
+ }
+
+ // We want to start at the first page in the thread. XenForo uses "../page-n" syntax
+ // to identify pages (except for the first page).
+ // Notice: XenForo uses the concept of "sentinels" to find and replace parts in the
+ // URL. Technically forum hosts can change the syntax!
+ if(preg_match('/.+\/(page-\d+.*)$/', $this->threadurl, $matches) != false) {
+
+ // before: https://xenforo.com/community/threads/guide-to-suggestions.2285/page-5
+ // after : https://xenforo.com/community/threads/guide-to-suggestions.2285/
+ $this->threadurl = str_replace($matches[1], '', $this->threadurl);
+
+ }
+
+ $html = getSimpleHTMLDOMCached($this->threadurl)
+ or returnServerError('Failed loading data from "' . $this->threadurl . '"!');
+
+ $html = defaultLinkTo($html, $this->threadurl);
+
+ // Notice: The DOM structure changes depending on the XenForo version used
+ if($mainContent = $html->find('div.mainContent', 0)) {
+ $this->version = self::XENFORO_VERSION_1;
+ } elseif ($mainContent = $html->find('div[class="p-body"]', 0)) {
+ $this->version = self::XENFORO_VERSION_2;
+ } else {
+ returnServerError('This forum is currently not supported!');
+ }
+
+ switch($this->version) {
+ case self::XENFORO_VERSION_1:
+
+ $titleBar = $mainContent->find('div.titleBar h1', 0)
+ or returnServerError('Error finding title bar!');
+
+ $this->title = $titleBar->plaintext;
+
+ // Store items from current page (we'll use $this->items as LIFO buffer)
+ $this->extractThreadPostsV1($html, $this->threadurl);
+ $this->extractPagesV1($html);
+
+ break;
+
+ case self::XENFORO_VERSION_2:
+
+ $titleBar = $mainContent->find('div[class="p-title"] h1', 0)
+ or returnServerError('Error finding title bar!');
+
+ $this->title = $titleBar->plaintext;
+ $this->extractThreadPostsV2($html, $this->threadurl);
+ $this->extractPagesV2($html);
+
+ break;
+ }
+
+ while(count($this->items) > $this->getInput('limit')) {
+ array_shift($this->items);
+ }
+
+ }
+
+ /**
+ * Extracts thread posts
+ * @param $html A simplehtmldom object
+ * @param $url The url from which $html was loaded
+ */
+ private function extractThreadPostsV1($html, $url) {
+
+ $lang = $html->find('html', 0)->lang;
+
+ // Posts are contained in an "ol"
+ $messageList = $html->find('#messageList li')
+ or returnServerError('Error finding message list!');
+
+ foreach($messageList as $post) {
+
+ if(!isset($post->attr['id'])) { // Skip ads
+ continue;
+ }
+
+ $item = array();
+
+ $item['uri'] = $url . '#' . $post->getAttribute('id');
+
+ $content = $post->find('.messageContent article', 0);
+
+ // Add some style to quotes
+ foreach($content->find('.bbCodeQuote') as $quote) {
+ $quote->style = '
+ color: #495566;
+ background-color: rgb(248,251,253);
+ border: 1px solid rgb(111, 140, 180);
+ border-color: rgb(111, 140, 180);
+ font-style: italic;';
+ }
+
+ // Remove script tags
+ foreach($content->find('script') as $script) {
+ $script->outertext = '';
+ }
+
+ $item['content'] = $content->innertext;
+
+ // Remove quotes (for the title)
+ foreach($content->find('.bbCodeQuote') as $quote) {
+ $quote->innertext = '';
+ }
+
+ $title = trim($content->plaintext);
+
+ if(strlen($title) > 70) {
+ $item['title'] = substr($title, 0, strpos($title, ' ', 70)) . '...';
+ } else {
+ $item['title'] = $title;
+ }
+
+ /**
+ * Timestamps are presented in two forms:
+ *
+ * 1) short version (for older posts?)
+ * <span
+ * class="DateTime"
+ * title="22 Oct. 2018 at 23:47"
+ * >22 Oct. 2018</span>
+ *
+ * This form has to be interpreted depending on the current language.
+ *
+ * 2) long version (for newer posts?)
+ * <abbr
+ * class="DateTime"
+ * data-time="1541008785"
+ * data-diff="310694"
+ * data-datestring="31 Oct. 2018"
+ * data-timestring="18:59"
+ * title="31 Oct. 2018 at 18:59"
+ * >Wednesday at 18:59</abbr>
+ *
+ * This form has the timestamp embedded (data-time)
+ */
+ if($timestamp = $post->find('abbr.DateTime', 0)) { // long version (preffered)
+ $item['timestamp'] = $timestamp->{'data-time'};
+ } elseif($timestamp = $post->find('span.DateTime', 0)) { // short version
+ $item['timestamp'] = $this->fixDate($timestamp->title, $lang);
+ }
+
+ $item['author'] = $post->getAttribute('data-author');
+
+ // Bridge specific properties
+ $item['id'] = $post->getAttribute('id');
+
+ $this->items[] = $item;
+
+ }
+
+ }
+
+ private function extractThreadPostsV2($html, $url) {
+
+ $lang = $html->find('html', 0)->lang;
+
+ $messageList = $html->find('div[class="block-body"] article')
+ or returnServerError('Error finding message list!');
+
+ foreach($messageList as $post) {
+
+ if(!isset($post->attr['id'])) { // Skip ads
+ continue;
+ }
+
+ $item = array();
+
+ $item['uri'] = $url . '#' . $post->getAttribute('id');
+
+ $title = $post->find('div[class="message-content"] article', 0)->plaintext;
+ $end = strpos($title, ' ', 70);
+ $item['title'] = substr($title, 0, $end);
+
+ $item['timestamp'] = $this->fixDate($post->find('time', 0)->title, $lang);
+ $item['author'] = $post->getAttribute('data-author');
+ $item['content'] = $post->find('div[class="message-content"] article', 0);
+
+ // Bridge specific properties
+ $item['id'] = $post->getAttribute('id');
+
+ $this->items[] = $item;
+
+ }
+
+ }
+
+ private function extractPagesV1($html) {
+
+ // A navigation bar becomes available if the number of posts grows too
+ // high. When this happens we need to load further pages (from last backwards)
+ if(($pageNav = $html->find('div.PageNav', 0))) {
+
+ $lastpage = $pageNav->{'data-last'};
+ $baseurl = $pageNav->{'data-baseurl'};
+ $sentinel = $pageNav->{'data-sentinel'};
+
+ $hosturl = parse_url($this->threadurl, PHP_URL_SCHEME)
+ . '://'
+ . parse_url($this->threadurl, PHP_URL_HOST)
+ . '/';
+
+ $page = $lastpage;
+
+ // Load at least the last page
+ do {
+
+ $pageurl = $hosturl . str_replace($sentinel, $lastpage, $baseurl);
+
+ // We can optimize performance by caching all but the last page
+ if($page != $lastpage) {
+ $html = getSimpleHTMLDOMCached($pageurl)
+ or returnServerError('Error loading contents from ' . $pageurl . '!');
+ } else {
+ $html = getSimpleHTMLDOM($pageurl)
+ or returnServerError('Error loading contents from ' . $pageurl . '!');
+ }
+
+ $html = defaultLinkTo($html, $hosturl);
+
+ $this->extractThreadPostsV1($html, $pageurl);
+
+ $page--;
+
+ } while (count($this->items) < $this->getInput('limit') && $page != 1);
+
+ }
+
+ }
+
+ private function extractPagesV2($html) {
+
+ // A navigation bar becomes available if the number of posts grows too
+ // high. When this happens we need to load further pages (from last backwards)
+ if(($pageNav = $html->find('div.pageNav', 0))) {
+
+ foreach($pageNav->find('li') as $nav) {
+ $lastpage = $nav->plaintext;
+ }
+
+ // Manually extract baseurl and inject sentinel
+ $baseurl = $pageNav->find('li a', -1)->href;
+ $baseurl = str_replace('page-' . $lastpage, 'page-{{sentinel}}', $baseurl);
+
+ $sentinel = '{{sentinel}}';
+
+ $hosturl = parse_url($this->threadurl, PHP_URL_SCHEME)
+ . '://'
+ . parse_url($this->threadurl, PHP_URL_HOST);
+
+ $page = $lastpage;
+
+ // Load at least the last page
+ do {
+
+ $pageurl = $hosturl . str_replace($sentinel, $lastpage, $baseurl);
+
+ // We can optimize performance by caching all but the last page
+ if($page != $lastpage) {
+ $html = getSimpleHTMLDOMCached($pageurl)
+ or returnServerError('Error loading contents from ' . $pageurl . '!');
+ } else {
+ $html = getSimpleHTMLDOM($pageurl)
+ or returnServerError('Error loading contents from ' . $pageurl . '!');
+ }
+
+ $html = defaultLinkTo($html, $this->hosturl);
+
+ $this->extractThreadPostsV2($html, $this->pageurl);
+
+ $page--;
+
+ } while (count($this->items) < $this->getInput('limit') && $page != 1);
+
+ }
+
+ }
+
+ /**
+ * Fixes dates depending on the choosen language:
+ *
+ * de : dd.mm.yy
+ * en : dd.mm.yy
+ * it : dd/mm/yy
+ *
+ * Basically strtotime doesn't convert dates correctly due to formats
+ * being hard to interpret. So we use the DateTime object.
+ *
+ * We don't know the timezone, so just assume +00:00 (or whatever
+ * DateTime chooses)
+ */
+ private function fixDate($date, $lang = 'en-US') {
+
+ $mnamesen = [
+ 'January',
+ 'Feburary',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December'
+ ];
+
+ switch($lang) {
+ case 'en-US': // example: Jun 9, 2018 at 11:46 PM
+
+ $df = date_create_from_format('M d, Y \a\t H:i A', $date);
+ break;
+
+ case 'de-DE': // example: 19 Juli 2018 um 19:27 Uhr
+
+ $mnamesde = [
+ 'Januar',
+ 'Februar',
+ 'März',
+ 'April',
+ 'Mai',
+ 'Juni',
+ 'Juli',
+ 'August',
+ 'September',
+ 'Oktober',
+ 'November',
+ 'Dezember'
+ ];
+
+ $mnamesdeshort = [
+ 'Jan.',
+ 'Feb.',
+ 'Mär.',
+ 'Apr.',
+ 'Mai',
+ 'Juni',
+ 'Juli',
+ 'Aug.',
+ 'Sep.',
+ 'Okt.',
+ 'Nov.',
+ 'Dez.'
+ ];
+
+ $date = str_ireplace($mnamesde, $mnamesen, $date);
+ $date = str_ireplace($mnamesdeshort, $mnamesen, $date);
+
+ $df = date_create_from_format('d M Y \u\m H:i \U\h\r', $date);
+ break;
+
+ }
+
+ // Debug::log(date_format($df, 'U'));
+
+ return date_format($df, 'U');
+
+ }
+}
diff --git a/bridges/YGGTorrentBridge.php b/bridges/YGGTorrentBridge.php
new file mode 100644
index 0000000..3e37c93
--- /dev/null
+++ b/bridges/YGGTorrentBridge.php
@@ -0,0 +1,144 @@
+<?php
+
+/* This is a mashup of FlickrExploreBridge by sebsauvage and FlickrTagBridge
+ * by erwang.providing the functionality of both in one.
+ */
+class YGGTorrentBridge extends BridgeAbstract {
+
+ const MAINTAINER = 'teromene';
+ const NAME = 'Yggtorrent Bridge';
+ const URI = 'https://yggtorrent.is';
+ const DESCRIPTION = 'Returns torrent search from Yggtorrent';
+
+ const PARAMETERS = array(
+ array(
+ 'cat' => array(
+ 'name' => 'category',
+ 'type' => 'list',
+ 'values' => array(
+ 'Toute les catégories' => 'all.all',
+ 'Film/Vidéo - Toutes les sous-catégories' => '2145.all',
+ 'Film/Vidéo - Animation' => '2145.2178',
+ 'Film/Vidéo - Animation Série' => '2145.2179',
+ 'Film/Vidéo - Concert' => '2145.2180',
+ 'Film/Vidéo - Documentaire' => '2145.2181',
+ 'Film/Vidéo - Émission TV' => '2145.2182',
+ 'Film/Vidéo - Film' => '2145.2183',
+ 'Film/Vidéo - Série TV' => '2145.2184',
+ 'Film/Vidéo - Spectacle' => '2145.2185',
+ 'Film/Vidéo - Sport' => '2145.2186',
+ 'Film/Vidéo - Vidéo-clips' => '2145.2186',
+ 'Audio - Toutes les sous-catégories' => '2139.all',
+ 'Audio - Karaoké' => '2139.2147',
+ 'Audio - Musique' => '2139.2148',
+ 'Audio - Podcast Radio' => '2139.2150',
+ 'Audio - Samples' => '2139.2149',
+ 'Jeu vidéo - Toutes les sous-catégories' => '2142.all',
+ 'Jeu vidéo - Autre' => '2142.2167',
+ 'Jeu vidéo - Linux' => '2142.2159',
+ 'Jeu vidéo - MacOS' => '2142.2160',
+ 'Jeu vidéo - Microsoft' => '2142.2162',
+ 'Jeu vidéo - Nintendo' => '2142.2163',
+ 'Jeu vidéo - Smartphone' => '2142.2165',
+ 'Jeu vidéo - Sony' => '2142.2164',
+ 'Jeu vidéo - Tablette' => '2142.2166',
+ 'Jeu vidéo - Windows' => '2142.2161',
+ 'eBook - Toutes les sous-catégories' => '2140.all',
+ 'eBook - Audio' => '2140.2151',
+ 'eBook - Bds' => '2140.2152',
+ 'eBook - Comics' => '2140.2153',
+ 'eBook - Livres' => '2140.2154',
+ 'eBook - Mangas' => '2140.2155',
+ 'eBook - Presse' => '2140.2156',
+ 'Emulation - Toutes les sous-catégories' => '2141.all',
+ 'Emulation - Emulateurs' => '2141.2157',
+ 'Emulation - Roms' => '2141.2158',
+ 'GPS - Toutes les sous-catégories' => '2141.all',
+ 'GPS - Applications' => '2141.2168',
+ 'GPS - Cartes' => '2141.2169',
+ 'GPS - Divers' => '2141.2170'
+ )
+ ),
+ 'nom' => array(
+ 'name' => 'Nom',
+ 'description' => 'Nom du torrent',
+ 'type' => 'text'
+ ),
+ 'description' => array(
+ 'name' => 'Description',
+ 'description' => 'Description du torrent',
+ 'type' => 'text'
+ ),
+ 'fichier' => array(
+ 'name' => 'Fichier',
+ 'description' => 'Fichier du torrent',
+ 'type' => 'text'
+ ),
+ 'uploader' => array(
+ 'name' => 'Uploader',
+ 'description' => 'Uploader du torrent',
+ 'type' => 'text'
+ ),
+
+ )
+ );
+
+ public function collectData() {
+
+ $catInfo = explode('.', $this->getInput('cat'));
+ $category = $catInfo[0];
+ $subcategory = $catInfo[1];
+
+ $html = getSimpleHTMLDOM(self::URI . '/engine/search?name='
+ . $this->getInput('nom')
+ . '&description='
+ . $this->getInput('description')
+ . '&fichier='
+ . $this->getInput('fichier')
+ . '&file='
+ . $this->getInput('uploader')
+ . '&category='
+ . $category
+ . '&sub_category='
+ . $subcategory
+ . '&do=search&order=desc&sort=publish_date')
+ or returnServerError('Unable to query Yggtorrent !');
+
+ $count = 0;
+ $results = $html->find('.results', 0);
+ if(!$results) return;
+
+ foreach($results->find('tr') as $row) {
+ $count++;
+ if($count == 1) continue; // Skip table header
+ if($count == 22) break; // Stop processing after 21 items (20 + 1 table header)
+ $item = array();
+ $item['timestamp'] = $row->find('.hidden', 1)->plaintext;
+ $item['title'] = $row->find('a', 1)->plaintext;
+ $item['uri'] = $row->find('a', 1)->href;
+ $torrentData = $this->collectTorrentData($row->find('a', 1)->href);
+ $item['author'] = $torrentData['author'];
+ $item['content'] = $torrentData['content'];
+ $item['seeders'] = $row->find('td', 7)->plaintext;
+ $item['leechers'] = $row->find('td', 8)->plaintext;
+ $item['size'] = $row->find('td', 5)->plaintext;
+
+ $this->items[] = $item;
+ }
+
+ }
+
+ private function collectTorrentData($url) {
+
+ //For weird reason, the link we get can be invalid, we fix it.
+ $url_full = explode('/', $url);
+ $url_full[4] = urlencode($url_full[4]);
+ $url_full[5] = urlencode($url_full[5]);
+ $url_full[6] = urlencode($url_full[6]);
+ $url = implode('/', $url_full);
+ $page = getSimpleHTMLDOMCached($url) or returnServerError('Unable to query Yggtorrent page !');
+ $author = $page->find('.informations', 0)->find('a', 4)->plaintext;
+ $content = $page->find('.default', 1);
+ return array('author' => $author, 'content' => $content);
+ }
+}
diff --git a/bridges/YandereBridge.php b/bridges/YandereBridge.php
new file mode 100644
index 0000000..df8b30e
--- /dev/null
+++ b/bridges/YandereBridge.php
@@ -0,0 +1,11 @@
+<?php
+require_once('MoebooruBridge.php');
+
+class YandereBridge extends MoebooruBridge {
+
+ const MAINTAINER = 'mitsukarenai';
+ const NAME = 'Yande.re';
+ const URI = 'https://yande.re/';
+ const DESCRIPTION = 'Returns images from given page and tags';
+
+}
diff --git a/bridges/YoutubeBridge.php b/bridges/YoutubeBridge.php
new file mode 100644
index 0000000..67e9566
--- /dev/null
+++ b/bridges/YoutubeBridge.php
@@ -0,0 +1,282 @@
+<?php
+/**
+* RssBridgeYoutube
+* Returns the newest videos
+* WARNING: to parse big playlists (over ~90 videos), you need to edit simple_html_dom.php:
+* change: define('MAX_FILE_SIZE', 600000);
+* into: define('MAX_FILE_SIZE', 900000); (or more)
+*/
+class YoutubeBridge extends BridgeAbstract {
+
+ const NAME = 'YouTube Bridge';
+ const URI = 'https://www.youtube.com/';
+ const CACHE_TIMEOUT = 10800; // 3h
+ const DESCRIPTION = 'Returns the 10 newest videos by username/channel/playlist or search';
+ const MAINTAINER = 'mitsukarenai';
+
+ const PARAMETERS = array(
+ 'By username' => array(
+ 'u' => array(
+ 'name' => 'username',
+ 'exampleValue' => 'test',
+ 'required' => true
+ )
+ ),
+ 'By channel id' => array(
+ 'c' => array(
+ 'name' => 'channel id',
+ 'exampleValue' => '15',
+ 'required' => true
+ )
+ ),
+ 'By playlist Id' => array(
+ 'p' => array(
+ 'name' => 'playlist id',
+ 'exampleValue' => '15'
+ )
+ ),
+ 'Search result' => array(
+ 's' => array(
+ 'name' => 'search keyword',
+ 'exampleValue' => 'test'
+ ),
+ 'pa' => array(
+ 'name' => 'page',
+ 'type' => 'number',
+ 'exampleValue' => 1
+ )
+ ),
+ 'global' => array(
+ 'duration_min' => array(
+ 'name' => 'min. duration (minutes)',
+ 'type' => 'number',
+ 'title' => 'Minimum duration for the video in minutes',
+ 'exampleValue' => 5
+ ),
+ 'duration_max' => array(
+ 'name' => 'max. duration (minutes)',
+ 'type' => 'number',
+ 'title' => 'Maximum duration for the video in minutes',
+ 'exampleValue' => 10
+ )
+ )
+ );
+
+ private $feedName = '';
+
+ private function ytBridgeQueryVideoInfo($vid, &$author, &$desc, &$time){
+ $html = $this->ytGetSimpleHTMLDOM(self::URI . "watch?v=$vid");
+
+ // Skip unavailable videos
+ if(!strpos($html->innertext, 'IS_UNAVAILABLE_PAGE')) {
+ return;
+ }
+
+ foreach($html->find('script') as $script) {
+ $data = trim($script->innertext);
+
+ if(strpos($data, '{') !== 0)
+ continue; // Wrong script
+
+ $json = json_decode($data);
+
+ if(!isset($json->itemListElement))
+ continue; // Wrong script
+
+ $author = $json->itemListElement[0]->item->name;
+ }
+
+ if(!is_null($html->find('#watch-description-text', 0)))
+ $desc = $html->find('#watch-description-text', 0)->innertext;
+
+ if(!is_null($html->find('meta[itemprop=datePublished]', 0)))
+ $time = strtotime($html->find('meta[itemprop=datePublished]', 0)->getAttribute('content'));
+ }
+
+ private function ytBridgeAddItem($vid, $title, $author, $desc, $time){
+ $item = array();
+ $item['id'] = $vid;
+ $item['title'] = $title;
+ $item['author'] = $author;
+ $item['timestamp'] = $time;
+ $item['uri'] = self::URI . 'watch?v=' . $vid;
+ $thumbnailUri = str_replace('/www.', '/img.', self::URI) . 'vi/' . $vid . '/0.jpg';
+ $item['content'] = '<a href="' . $item['uri'] . '"><img src="' . $thumbnailUri . '" /></a><br />' . $desc;
+ $this->items[] = $item;
+ }
+
+ private function ytBridgeParseXmlFeed($xml) {
+ foreach($xml->find('entry') as $element) {
+ $title = $this->ytBridgeFixTitle($element->find('title', 0)->plaintext);
+ $author = $element->find('name', 0)->plaintext;
+ $desc = $element->find('media:description', 0)->innertext;
+
+ // Make sure the description is easy on the eye :)
+ $desc = htmlspecialchars($desc);
+ $desc = nl2br($desc);
+ $desc = preg_replace('/(http[s]{0,1}\:\/\/[a-zA-Z0-9.\/\?\&=\-_]{4,})/ims',
+ '<a href="$1" target="_blank">$1</a> ',
+ $desc);
+
+ $vid = str_replace('yt:video:', '', $element->find('id', 0)->plaintext);
+ $time = strtotime($element->find('published', 0)->plaintext);
+ if(strpos($vid, 'googleads') === false)
+ $this->ytBridgeAddItem($vid, $title, $author, $desc, $time);
+ }
+ $this->feedName = $this->ytBridgeFixTitle($xml->find('feed > title', 0)->plaintext); // feedName will be used by getName()
+ }
+
+ private function ytBridgeParseHtmlListing($html, $element_selector, $title_selector, $add_parsed_items = true) {
+ $limit = $add_parsed_items ? 10 : INF;
+ $count = 0;
+
+ $duration_min = $this->getInput('duration_min') ?: -1;
+ $duration_min = $duration_min * 60;
+
+ $duration_max = $this->getInput('duration_max') ?: INF;
+ $duration_max = $duration_max * 60;
+
+ if($duration_max < $duration_min) {
+ returnClientError('Max duration must be greater than min duration!');
+ }
+
+ foreach($html->find($element_selector) as $element) {
+ if($count < $limit) {
+ $author = '';
+ $desc = '';
+ $time = 0;
+ $vid = str_replace('/watch?v=', '', $element->find('a', 0)->href);
+ $vid = substr($vid, 0, strpos($vid, '&') ?: strlen($vid));
+ $title = trim($this->ytBridgeFixTitle($element->find($title_selector, 0)->plaintext));
+
+ if (strpos($vid, 'googleads') !== false
+ || $title == '[Private video]'
+ || $title == '[Deleted video]'
+ ) {
+ continue;
+ }
+
+ // The duration comes in one of the formats:
+ // hh:mm:ss / mm:ss / m:ss
+ // 01:03:30 / 15:06 / 1:24
+ $durationText = trim($element->find('div.timestamp span', 0)->plaintext);
+ $durationText = preg_replace('/([\d]{1,2})\:([\d]{2})/', '00:$1:$2', $durationText);
+
+ sscanf($durationText, '%d:%d:%d', $hours, $minutes, $seconds);
+ $duration = $hours * 3600 + $minutes * 60 + $seconds;
+
+ if($duration < $duration_min || $duration > $duration_max) {
+ continue;
+ }
+
+ if ($add_parsed_items) {
+ $this->ytBridgeQueryVideoInfo($vid, $author, $desc, $time);
+ $this->ytBridgeAddItem($vid, $title, $author, $desc, $time);
+ }
+ $count++;
+ }
+ }
+ return $count;
+ }
+
+ private function ytBridgeFixTitle($title) {
+ // convert both &#1234; and &quot; to UTF-8
+ return html_entity_decode($title, ENT_QUOTES, 'UTF-8');
+ }
+
+ private function ytGetSimpleHTMLDOM($url){
+ return getSimpleHTMLDOM($url,
+ $header = array(
+ 'Accept-Language: en-US'
+ ),
+ $opts = array(),
+ $lowercase = true,
+ $forceTagsClosed = true,
+ $target_charset = DEFAULT_TARGET_CHARSET,
+ $stripRN = false,
+ $defaultBRText = DEFAULT_BR_TEXT,
+ $defaultSpanText = DEFAULT_SPAN_TEXT);
+ }
+
+ public function collectData(){
+
+ $xml = '';
+ $html = '';
+ $url_feed = '';
+ $url_listing = '';
+
+ if($this->getInput('u')) { /* User and Channel modes */
+ $this->request = $this->getInput('u');
+ $url_feed = self::URI . 'feeds/videos.xml?user=' . urlencode($this->request);
+ $url_listing = self::URI . 'user/' . urlencode($this->request) . '/videos';
+ } elseif($this->getInput('c')) {
+ $this->request = $this->getInput('c');
+ $url_feed = self::URI . 'feeds/videos.xml?channel_id=' . urlencode($this->request);
+ $url_listing = self::URI . 'channel/' . urlencode($this->request) . '/videos';
+ }
+
+ if(!empty($url_feed) && !empty($url_listing)) {
+ if(!$this->skipFeeds() && $xml = $this->ytGetSimpleHTMLDOM($url_feed)) {
+ $this->ytBridgeParseXmlFeed($xml);
+ } elseif($html = $this->ytGetSimpleHTMLDOM($url_listing)) {
+ $this->ytBridgeParseHtmlListing($html, 'li.channels-content-item', 'h3');
+ } else {
+ returnServerError("Could not request YouTube. Tried:\n - $url_feed\n - $url_listing");
+ }
+ } elseif($this->getInput('p')) { /* playlist mode */
+ $this->request = $this->getInput('p');
+ $url_feed = self::URI . 'feeds/videos.xml?playlist_id=' . urlencode($this->request);
+ $url_listing = self::URI . 'playlist?list=' . urlencode($this->request);
+ $html = $this->ytGetSimpleHTMLDOM($url_listing)
+ or returnServerError("Could not request YouTube. Tried:\n - $url_listing");
+ $item_count = $this->ytBridgeParseHtmlListing($html, 'tr.pl-video', '.pl-video-title a', false);
+ if ($item_count <= 15 && !$this->skipFeeds() && ($xml = $this->ytGetSimpleHTMLDOM($url_feed))) {
+ $this->ytBridgeParseXmlFeed($xml);
+ } else {
+ $this->ytBridgeParseHtmlListing($html, 'tr.pl-video', '.pl-video-title a');
+ }
+ $this->feedName = 'Playlist: ' . str_replace(' - YouTube', '', $html->find('title', 0)->plaintext); // feedName will be used by getName()
+ usort($this->items, function ($item1, $item2) {
+ return $item2['timestamp'] - $item1['timestamp'];
+ });
+ } elseif($this->getInput('s')) { /* search mode */
+ $this->request = $this->getInput('s');
+ $page = 1;
+ if($this->getInput('pa'))
+ $page = (int)preg_replace('/[^0-9]/', '', $this->getInput('pa'));
+
+ $url_listing = self::URI
+ . 'results?search_query='
+ . urlencode($this->request)
+ . '&page='
+ . $page
+ . '&filters=video&search_sort=video_date_uploaded';
+
+ $html = $this->ytGetSimpleHTMLDOM($url_listing)
+ or returnServerError("Could not request YouTube. Tried:\n - $url_listing");
+
+ $this->ytBridgeParseHtmlListing($html, 'div.yt-lockup', 'h3 > a');
+ $this->feedName = 'Search: ' . str_replace(' - YouTube', '', $html->find('title', 0)->plaintext); // feedName will be used by getName()
+ } else { /* no valid mode */
+ returnClientError("You must either specify either:\n - YouTube
+ username (?u=...)\n - Channel id (?c=...)\n - Playlist id (?p=...)\n - Search (?s=...)");
+ }
+ }
+
+ private function skipFeeds() {
+ return ($this->getInput('duration_min') || $this->getInput('duration_max'));
+ }
+
+ public function getName(){
+ // Name depends on queriedContext:
+ switch($this->queriedContext) {
+ case 'By username':
+ case 'By channel id':
+ case 'By playlist Id':
+ case 'Search result':
+ return $this->feedName . ' - YouTube'; // We already know it's a bridge, right?
+ default:
+ return parent::getName();
+ }
+ }
+}
diff --git a/bridges/ZDNetBridge.php b/bridges/ZDNetBridge.php
new file mode 100644
index 0000000..75df3b1
--- /dev/null
+++ b/bridges/ZDNetBridge.php
@@ -0,0 +1,201 @@
+<?php
+class ZDNetBridge extends FeedExpander {
+
+ const MAINTAINER = 'ORelio';
+ const NAME = 'ZDNet Bridge';
+ const URI = 'https://www.zdnet.com/';
+ const DESCRIPTION = 'Technology News, Analysis, Comments and Product Reviews for IT Professionals.';
+
+ //http://www.zdnet.com/zdnet.opml
+ const PARAMETERS = array( array(
+ 'feed' => array(
+ 'name' => 'Feed',
+ 'type' => 'list',
+ 'values' => array(
+ 'Subscribe to ZDNet RSS Feeds' => array(
+ 'All Blogs' => 'blog',
+ 'Just News' => 'news',
+ 'All Reviews' => 'topic/reviews',
+ 'Latest Downloads' => 'downloads!recent',
+ 'Latest Articles' => '/',
+ 'Latest Australia Articles' => 'au',
+ 'Latest UK Articles' => 'uk',
+ 'Latest US Articles' => 'us',
+ 'Latest Asia Articles' => 'as'
+ ),
+ 'Keep up with ZDNet Blogs RSS:' => array(
+ 'Transforming the Datacenter' => 'blog/transforming-datacenter',
+ 'SMB India' => 'blog/smb-india',
+ 'Indonesia BizTech' => 'blog/indonesia-biztech',
+ 'Hong Kong Techie' => 'blog/hong-kong-techie',
+ 'Tech Taiwan' => 'blog/tech-taiwan',
+ 'Startup India' => 'blog/startup-india',
+ 'Starting Up Asia' => 'blog/starting-up-asia',
+ 'Next-Gen Partner' => 'blog/partner',
+ 'Post-PC Developments' => 'blog/post-pc',
+ 'Benelux' => 'blog/benelux',
+ 'Heat Sink' => 'blog/heat-sink',
+ 'Italy\'s got tech' => 'blog/italy',
+ 'African Enterprise' => 'blog/african-enterprise',
+ 'New Tech for Old India' => 'blog/new-india',
+ 'Estonia Uncovered' => 'blog/estonia',
+ 'IT Iberia' => 'blog/iberia',
+ 'Brazil Tech' => 'blog/brazil',
+ '500 words into the future' => 'blog/500-words-into-the-future',
+ 'ÜberTech' => 'blog/ubertech',
+ 'All About Microsoft' => 'blog/microsoft',
+ 'Back office' => 'blog/back-office',
+ 'Barker Bites Back' => 'blog/barker-bites-back',
+ 'Between the Lines' => 'blog/btl',
+ 'Big on Data' => 'blog/big-data',
+ 'bootstrappr' => 'blog/bootstrappr',
+ 'By The Way' => 'blog/by-the-way',
+ 'Central European Processing' => 'blog/central-europe',
+ 'Cloud Builders' => 'blog/cloud-builders',
+ 'Communication Breakdown' => 'blog/communication-breakdown',
+ 'Collaboration 2.0' => 'blog/collaboration',
+ 'Constellation Research' => 'blog/constellation',
+ 'Consumerization: BYOD' => 'blog/consumerization',
+ 'DIY-IT' => 'blog/diy-it',
+ 'Enterprise Web 2.0' => 'blog/hinchcliffe',
+ 'Five Nines: The Next Gen Datacenter' => 'blog/datacenter',
+ 'Forrester Research' => 'blog/forrester',
+ 'Full Duplex' => 'blog/full-duplex',
+ 'Gen Why?' => 'blog/gen-why',
+ 'Hardware 2.0' => 'blog/hardware',
+ 'Identity Matters' => 'blog/identity',
+ 'iGeneration' => 'blog/igeneration',
+ 'Internet of Everything' => 'blog/cisco',
+ 'Beyond IT Failure' => 'blog/projectfailures',
+ 'Jamie\'s Mostly Linux Stuff' => 'blog/jamies-mostly-linux-stuff',
+ 'Jack\'s Blog' => 'blog/jacks-blog',
+ 'Laptops & Desktops' => 'blog/computers',
+ 'Linux and Open Source' => 'blog/open-source',
+ 'London Calling' => 'blog/london',
+ 'Mapping Babel' => 'blog/mapping-babel',
+ 'Mixed Signals' => 'blog/mixed-signals',
+ 'Mobile India' => 'blog/mobile-india',
+ 'Mobile News' => 'blog/mobile-news',
+ 'Networking' => 'blog/networking',
+ 'Norse Code' => 'blog/norse-code',
+ 'Null Pointer' => 'blog/null-pointer',
+ 'The Full Tilt' => 'blog/the-full-tilt',
+ 'Pinoy Post' => 'blog/pinoy-post',
+ 'Practically Tech' => 'blog/practically-tech',
+ 'Product Central' => 'blog/product-central',
+ 'Pulp Tech' => 'blog/violetblue',
+ 'Qubits and Pieces' => 'blog/qubits-and-pieces',
+ 'Securify This!' => 'blog/securify-this',
+ 'Service Oriented' => 'blog/service-oriented',
+ 'Small Talk' => 'blog/small-talk',
+ 'Small Business Matters' => 'blog/small-business-matters',
+ 'Smartphones and Cell Phones' => 'blog/cell-phones',
+ 'Social Business' => 'blog/feeds',
+ 'Social CRM: The Conversation' => 'blog/crm',
+ 'Software & Services Safari' => 'blog/sommer',
+ 'Storage Bits' => 'blog/storage',
+ 'Stacking up Open Clouds' => 'blog/apac-redhat',
+ 'Techie Isles' => 'blog/techie-isles',
+ 'Technolatte' => 'blog/technolatte',
+ 'Tech Podium' => 'blog/tech-podium',
+ 'Tel Aviv Tech' => 'blog/tel-aviv',
+ 'Tech Broiler' => 'blog/perlow',
+ 'The SANMAN' => 'blog/the-sanman',
+ 'The open source revolution' => 'blog/the-open-source-revolution',
+ 'The German View' => 'blog/german',
+ 'The Ed Bott Report' => 'blog/bott',
+ 'The Mobile Gadgeteer' => 'blog/mobile-gadgeteer',
+ 'The Apple Core' => 'blog/apple',
+ 'Tom Foremski: IMHO' => 'blog/foremski',
+ 'Twisted Wire' => 'blog/twisted-wire',
+ 'Vive la tech' => 'blog/france',
+ 'Virtually Speaking' => 'blog/virtualization',
+ 'View from China' => 'blog/china',
+ 'Web design & Free Software' => 'blog/web-design-and-free-software',
+ 'ZDNet Government' => 'blog/government',
+ 'ZDNet UK Book Reviews' => 'blog/zdnet-uk-book-reviews',
+ 'ZDNet UK First Take' => 'blog/zdnet-uk-first-take',
+ 'Zero Day' => 'blog/security'
+ ),
+ 'ZDNet Hot Topics RSS:' => array(
+ 'Apple' => 'topic/apple',
+ 'Collaboration' => 'topic/collaboration',
+ 'Enterprise Software' => 'topic/enterprise-software',
+ 'Google' => 'topic/google',
+ 'Great debate' => 'topic/great-debate',
+ 'Hardware' => 'topic/hardware',
+ 'IBM' => 'topic/ibm',
+ 'iOS' => 'topic/ios',
+ 'iPhone' => 'topic/iphone',
+ 'iPad' => 'topic/ipad',
+ 'IT Priorities' => 'topic/it-priorities',
+ 'Laptops' => 'topic/laptops',
+ 'Legal' => 'topic/legal',
+ 'Linux' => 'topic/linux',
+ 'Microsoft' => 'topic/microsoft',
+ 'Mobile OS' => 'topic/mobile-os',
+ 'Mobility' => 'topic/mobility',
+ 'Networking' => 'topic/networking',
+ 'Oracle' => 'topic/oracle',
+ 'Processors' => 'topic/processors',
+ 'Samsung' => 'topic/samsung',
+ 'Security' => 'topic/security',
+ 'Small business: going big on mobility' => 'topic/small-business-going-big-on-mobility'
+ ),
+ 'Product Blogs:' => array(
+ 'Digital Cameras & Camcorders' => 'blog/digitalcameras',
+ 'Home Theater' => 'blog/home-theater',
+ 'Laptops and Desktops' => 'blog/computers',
+ 'The Mobile Gadgeteer' => 'blog/mobile-gadgeteer',
+ 'Smartphones and Cell Phones' => 'blog/cell-phones',
+ 'The ToyBox' => 'blog/gadgetreviews'
+ ),
+ 'Vertical Blogs:' => array(
+ 'ZDNet Education' => 'blog/education',
+ 'ZDNet Healthcare' => 'blog/healthcare',
+ 'ZDNet Government' => 'blog/government'
+ )
+ )
+ )
+ ));
+
+ public function collectData(){
+ $baseUri = static::URI;
+ $feed = $this->getInput('feed');
+ if(strpos($feed, 'downloads!') !== false) {
+ $feed = str_replace('downloads!', '', $feed);
+ $baseUri = str_replace('www.', 'downloads.', $baseUri);
+ }
+ $url = $baseUri . trim($feed, '/') . '/rss.xml';
+ $this->collectExpandableDatas($url);
+ }
+
+ protected function parseItem($item){
+ $item = parent::parseItem($item);
+
+ $article = getSimpleHTMLDOMCached($item['uri']);
+ if(!$article)
+ returnServerError('Could not request ZDNet: ' . $url);
+
+ $contents = $article->find('article', 0)->innertext;
+ foreach(array(
+ '<div class="shareBar"',
+ '<div class="shortcodeGalleryWrapper"',
+ '<div class="relatedContent',
+ '<div class="downloadNow',
+ '<div data-shortcode',
+ '<div id="sharethrough',
+ '<div id="inpage-video'
+ ) as $div_start) {
+ $contents = stripRecursiveHtmlSection($contents, 'div', $div_start);
+ }
+ $contents = stripWithDelimiters($contents, '<script', '</script>');
+ $contents = stripWithDelimiters($contents, '<meta itemprop="image"', '>');
+ $contents = stripWithDelimiters($contents, '<svg class="svg-symbol', '</svg>');
+ $contents = trim(stripWithDelimiters($contents, '<section class="sharethrough-top', '</section>'));
+ $item['content'] = $contents;
+
+ return $item;
+
+ }
+}
diff --git a/bridges/ZenodoBridge.php b/bridges/ZenodoBridge.php
new file mode 100644
index 0000000..18ef91c
--- /dev/null
+++ b/bridges/ZenodoBridge.php
@@ -0,0 +1,55 @@
+<?php
+class ZenodoBridge extends BridgeAbstract {
+ const MAINTAINER = 'theradialactive';
+ const NAME = 'Zenodo';
+ const URI = 'https://zenodo.org';
+ const CACHE_TIMEOUT = 10;
+ const DESCRIPTION = 'Returns the newest content of Zenodo';
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM($this->getURI())
+ or returnServerError('zenodo.org not reachable.');
+
+ foreach($html->find('div.record-elem') as $element) {
+ $item = array();
+ $item['uri'] = self::URI . $element->find('h4', 0)->find('a', 0)->href;
+ $item['title'] = trim(
+ htmlspecialchars_decode($element->find('h4', 0)->find('a', 0)->innertext,
+ ENT_QUOTES
+ )
+ );
+ foreach($element->find('p', 0)->find('span') as $authors) {
+ $item['author'] = $item['author'] . $authors . '; ';
+ }
+ $content = $element->find('p.hidden-xs', 0)->find('a', 0)->innertext . '<br>';
+ $type = '<br>Type: ' . $element->find('span.label-default', 0)->innertext;
+
+ $raw_date = $element->find('small.text-muted', 0)->innertext;
+ $clean_date = date_parse(str_replace('Uploaded on ', '', $raw_date));
+
+ $content = $content . date_parse($clean_date);
+
+ $item['timestamp'] = mktime(
+ $clean_date['hour'],
+ $clean_date['minute'],
+ $clean_date['second'],
+ $clean_date['month'],
+ $clean_date['day'],
+ $clean_date['year']
+ );
+
+ $access = '';
+ if ($element->find('span.label-success', 0)->innertext) {
+ $access = 'Open Access';
+ } elseif ($element->find('span.label-warning', 0)->innertext) {
+ $access = 'Embargoed Access';
+ } else {
+ $access = $element->find('span.label-error', 0)->innertext;
+ }
+ $access = '<br>Access: ' . $access;
+ $publication = '<br>Publication Date: ' . $element->find('span.label-info', 0)->innertext;
+ $item['content'] = $content . $type . $access . $publication;
+ $this->items[] = $item;
+ }
+ }
+}
diff --git a/bridges/ZoneTelechargementBridge.php b/bridges/ZoneTelechargementBridge.php
new file mode 100644
index 0000000..44cdfce
--- /dev/null
+++ b/bridges/ZoneTelechargementBridge.php
@@ -0,0 +1,95 @@
+<?php
+class ZoneTelechargementBridge extends BridgeAbstract {
+
+ /* This bridge was initally done for the Website Zone Telechargement,
+ * but the website changed it's name and URL.
+ * Therefore, the class name and filename does not correspond to the
+ * name of the bridge. This permits to keep the same RSS Feed URL.
+ */
+
+ const NAME = 'Annuaire Telechargement';
+ const URI = 'https://www.annuaire-telechargement.com/';
+ const DESCRIPTION = 'Suivi de série sur Annuaire Telechargement';
+ const MAINTAINER = 'sysadminstory';
+ const PARAMETERS = array(
+ 'Suivre la publication des épisodes d\'une série en cours de diffusion' => array(
+ 'url' => array(
+ 'name' => 'URL de la série',
+ 'type' => 'text',
+ 'required' => true,
+ 'title' => 'URL d\'une série sans le https://www.annuaire-telechargement.com/',
+ 'exampleValue' => 'telecharger-series/31079-halt-and-catch-fire-saison-4-french-hd720p.html'
+ )
+ )
+ );
+
+ public function getIcon() {
+ return 'https://www.annuaire-telechargement.com/templates/Default/images/favicon.ico';
+ }
+
+ public function collectData(){
+ $html = getSimpleHTMLDOM(self::URI . $this->getInput('url'))
+ or returnServerError('Could not request Zone Telechargement.');
+
+ // Get the TV show title
+ $qualityselector = 'div[style=font-size: 18px;margin: 10px auto;color:red;font-weight:bold;text-align:center;]';
+ $show = trim($html->find('div[class=smallsep]', 0)->next_sibling()->plaintext);
+ $quality = trim(explode("\n", $html->find($qualityselector, 0)->plaintext)[0]);
+ $this->showTitle = $show . ' ' . $quality;
+
+ // Get the post content
+ $linkshtml = $html->find('div[class=postinfo]', 0);
+
+ $episodes = array();
+
+ $list = $linkshtml->find('a');
+ // Construct the tabble of episodes using the links
+ foreach($list as $element) {
+ // Retrieve episode number from link text
+ $epnumber = explode(' ', $element->plaintext)[1];
+ $hoster = $this->findLinkHoster($element);
+
+ // Format the link and add the link to the corresponding episode table
+ $episodes[$epnumber][] = '<a href="' . $element->href . '">' . $hoster . ' - '
+ . $this->showTitle . ' Episode ' . $epnumber . '</a>';
+
+ }
+
+ // Finally construct the items array
+ foreach($episodes as $epnum => $episode) {
+ $item = array();
+ // Add every link available in the episode table separated by a <br/> tag
+ $item['content'] = implode('<br/>', $episode);
+ $item['title'] = $this->showTitle . ' Episode ' . $epnum;
+ // As RSS Bridge use the URI as GUID they need to be unique : adding a md5 hash of the title element
+ // should geneerate unique URI to prevent confusion for RSS readers
+ $item['uri'] = self::URI . $this->getInput('url') . '#' . hash('md5', $item['title']);
+ // Insert the episode at the beginning of the item list, to show the newest episode first
+ array_unshift($this->items, $item);
+ }
+ }
+
+ public function getName() {
+ switch($this->queriedContext) {
+ case 'Suivre la publication des épisodes d\'une série en cours de diffusion':
+ return $this->showTitle . ' - ' . self::NAME;
+ break;
+ default:
+ return self::NAME;
+ }
+ }
+
+ private function findLinkHoster($element) {
+ // The hoster name is one level higher than the link tag : get the parent element
+ $element = $element->parent();
+ //echo "PARENT : $element \n";
+ $continue = true;
+ // Walk through all elements in the reverse order until finding the one with a div and that is not a <br/>
+ while(!($element->find('div', 0) != null && $element->tag != 'br')) {
+ $element = $element->prev_sibling();
+ }
+ // Return the text of the div : it's the file hoster name !
+ return $element->find('div', 0)->plaintext;
+
+ }
+}
diff --git a/caches/FileCache.php b/caches/FileCache.php
new file mode 100644
index 0000000..04d08a2
--- /dev/null
+++ b/caches/FileCache.php
@@ -0,0 +1,122 @@
+<?php
+/**
+* Cache with file system
+*/
+class FileCache implements CacheInterface {
+
+ protected $path;
+ protected $param;
+
+ public function loadData(){
+ if(file_exists($this->getCacheFile())) {
+ return unserialize(file_get_contents($this->getCacheFile()));
+ }
+ }
+
+ public function saveData($datas){
+ // Notice: We use plain serialize() here to reduce memory footprint on
+ // large input data.
+ $writeStream = file_put_contents($this->getCacheFile(), serialize($datas));
+
+ if($writeStream === false) {
+ throw new \Exception('Cannot write the cache... Do you have the right permissions ?');
+ }
+
+ return $this;
+ }
+
+ public function getTime(){
+ $cacheFile = $this->getCacheFile();
+ clearstatcache(false, $cacheFile);
+ if(file_exists($cacheFile)) {
+ return filemtime($cacheFile);
+ }
+
+ return false;
+ }
+
+ public function purgeCache($duration){
+ $cachePath = $this->getPath();
+ if(file_exists($cachePath)) {
+ $cacheIterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($cachePath),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach($cacheIterator as $cacheFile) {
+ if(in_array($cacheFile->getBasename(), array('.', '..', '.gitkeep')))
+ continue;
+ elseif($cacheFile->isFile()) {
+ if(filemtime($cacheFile->getPathname()) < time() - $duration)
+ unlink($cacheFile->getPathname());
+ }
+ }
+ }
+ }
+
+ /**
+ * Set cache path
+ * @return self
+ */
+ public function setPath($path){
+ if(is_null($path) || !is_string($path)) {
+ throw new \Exception('The given path is invalid!');
+ }
+
+ $this->path = $path;
+
+ // Make sure path ends with '/' or '\'
+ $lastchar = substr($this->path, -1, 1);
+ if($lastchar !== '/' && $lastchar !== '\\')
+ $this->path .= '/';
+
+ if(!is_dir($this->path))
+ mkdir($this->path, 0755, true);
+
+ return $this;
+ }
+
+ /**
+ * Set HTTP GET parameters
+ * @return self
+ */
+ public function setParameters(array $param){
+ $this->param = array_map('strtolower', $param);
+
+ return $this;
+ }
+
+ /**
+ * Return cache path (and create if not exist)
+ * @return string Cache path
+ */
+ protected function getPath(){
+ if(is_null($this->path)) {
+ throw new \Exception('Call "setPath" first!');
+ }
+
+ return $this->path;
+ }
+
+ /**
+ * Get the file name use for cache store
+ * @return string Path to the file cache
+ */
+ protected function getCacheFile(){
+ return $this->getPath() . $this->getCacheName();
+ }
+
+ /**
+ * Determines file name for store the cache
+ * return string
+ */
+ protected function getCacheName(){
+ if(is_null($this->param)) {
+ throw new \Exception('Call "setParameters" first!');
+ }
+
+ // Change character when making incompatible changes to prevent loading
+ // errors due to incompatible file contents \|/
+ return hash('md5', http_build_query($this->param) . 'A') . '.cache';
+ }
+}
diff --git a/config.default.ini.php b/config.default.ini.php
new file mode 100644
index 0000000..2d6fca1
--- /dev/null
+++ b/config.default.ini.php
@@ -0,0 +1,50 @@
+; <?php exit; ?> DO NOT REMOVE THIS LINE
+
+; This file contains the default settings for RSS-Bridge. Do not change this
+; file, it will be replaced on the next update of RSS-Bridge! You can specify
+; your own configuration in 'config.ini.php' (copy this file).
+
+[cache]
+
+; Allow users to specify custom timeout for specific requests.
+; true = enabled
+; false = disabled (default)
+custom_timeout = false
+
+[admin]
+; Advertise an email address where people can reach the administrator.
+; This address is displayed on the main page, visible to everyone!
+; "" = Disabled (default)
+email = ""
+
+[proxy]
+
+; Sets the proxy url (i.e. "tcp://192.168.0.0:32")
+; "" = Proxy disabled (default)
+url = ""
+
+; Sets the proxy name that is shown on the bridge instead of the proxy url.
+; "" = Show proxy url
+name = "Hidden proxy name"
+
+; Allow users to disable proxy usage for specific requests.
+; true = enabled
+; false = disabled (default)
+by_bridge = false
+
+[authentication]
+
+; Enables authentication for all requests to this RSS-Bridge instance.
+;
+; Warning: You'll have to upgrade existing feeds after enabling this option!
+;
+; true = enabled
+; false = disabled (default)
+enable = false
+
+; The username for authentication. Insert this name when prompted for login.
+username = ""
+
+; The password for authentication. Insert this password when prompted for login.
+; Use a strong password to prevent others from guessing your login!
+password = ""
diff --git a/formats/AtomFormat.php b/formats/AtomFormat.php
new file mode 100644
index 0000000..bb5e30e
--- /dev/null
+++ b/formats/AtomFormat.php
@@ -0,0 +1,106 @@
+<?php
+/**
+* Atom
+* Documentation Source http://en.wikipedia.org/wiki/Atom_%28standard%29 and
+* http://tools.ietf.org/html/rfc4287
+*/
+class AtomFormat extends FormatAbstract{
+ public function stringify(){
+ $https = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on' ? 's' : '';
+ $httpHost = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
+ $httpInfo = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : '';
+
+ $serverRequestUri = isset($_SERVER['REQUEST_URI']) ? $this->xml_encode($_SERVER['REQUEST_URI']) : '';
+
+ $extraInfos = $this->getExtraInfos();
+ $title = $this->xml_encode($extraInfos['name']);
+ $uri = !empty($extraInfos['uri']) ? $extraInfos['uri'] : REPOSITORY;
+
+ $uriparts = parse_url($uri);
+ if(!empty($extraInfos['icon'])) {
+ $icon = $extraInfos['icon'];
+ } else {
+ $icon = $this->xml_encode($uriparts['scheme'] . '://' . $uriparts['host'] . '/favicon.ico');
+ }
+
+ $uri = $this->xml_encode($uri);
+
+ $entries = '';
+ foreach($this->getItems() as $item) {
+ $entryAuthor = $this->xml_encode($item->getAuthor());
+ $entryTitle = $this->xml_encode($item->getTitle());
+ $entryUri = $this->xml_encode($item->getURI());
+ $entryTimestamp = $this->xml_encode(date(DATE_ATOM, $item->getTimestamp()));
+ $entryContent = $this->xml_encode($this->sanitizeHtml($item->getContent()));
+
+ $entryEnclosures = '';
+ foreach($item->getEnclosures() as $enclosure) {
+ $entryEnclosures .= '<link rel="enclosure" href="'
+ . $this->xml_encode($enclosure)
+ . '" type="' . getMimeType($enclosure) . '" />'
+ . PHP_EOL;
+ }
+
+ $entryCategories = '';
+ foreach($item->getCategories() as $category) {
+ $entryCategories .= '<category term="'
+ . $this->xml_encode($category)
+ . '"/>'
+ . PHP_EOL;
+ }
+
+ $entries .= <<<EOD
+
+ <entry>
+ <author>
+ <name>{$entryAuthor}</name>
+ </author>
+ <title type="html">{$entryTitle}</title>
+ <link rel="alternate" type="text/html" href="{$entryUri}" />
+ <id>{$entryUri}</id>
+ <updated>{$entryTimestamp}</updated>
+ <content type="html">{$entryContent}</content>
+ {$entryEnclosures}
+ {$entryCategories}
+ </entry>
+
+EOD;
+ }
+
+ $feedTimestamp = date(DATE_ATOM, time());
+ $charset = $this->getCharset();
+
+ /* Data are prepared, now let's begin the "MAGIE !!!" */
+ $toReturn = <<<EOD
+<?xml version="1.0" encoding="{$charset}"?>
+<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0">
+
+ <title type="text">{$title}</title>
+ <id>http{$https}://{$httpHost}{$httpInfo}/</id>
+ <icon>{$icon}</icon>
+ <logo>{$icon}</logo>
+ <updated>{$feedTimestamp}</updated>
+ <link rel="alternate" type="text/html" href="{$uri}" />
+ <link rel="self" href="http{$https}://{$httpHost}{$serverRequestUri}" />
+{$entries}
+</feed>
+EOD;
+
+ // Remove invalid characters
+ ini_set('mbstring.substitute_character', 'none');
+ $toReturn = mb_convert_encoding($toReturn, $this->getCharset(), 'UTF-8');
+ return $toReturn;
+ }
+
+ public function display(){
+ $this
+ ->setContentType('application/atom+xml; charset=' . $this->getCharset())
+ ->callContentType();
+
+ return parent::display();
+ }
+
+ private function xml_encode($text){
+ return htmlspecialchars($text, ENT_XML1);
+ }
+}
diff --git a/formats/HtmlFormat.php b/formats/HtmlFormat.php
new file mode 100644
index 0000000..052bedc
--- /dev/null
+++ b/formats/HtmlFormat.php
@@ -0,0 +1,116 @@
+<?php
+class HtmlFormat extends FormatAbstract {
+ public function stringify(){
+ $extraInfos = $this->getExtraInfos();
+ $title = htmlspecialchars($extraInfos['name']);
+ $uri = htmlspecialchars($extraInfos['uri']);
+ $atomquery = str_replace('format=Html', 'format=Atom', htmlentities($_SERVER['QUERY_STRING']));
+ $mrssquery = str_replace('format=Html', 'format=Mrss', htmlentities($_SERVER['QUERY_STRING']));
+
+ $entries = '';
+ foreach($this->getItems() as $item) {
+ $entryAuthor = $item->getAuthor() ? '<br /><p class="author">by: ' . $item->getAuthor() . '</p>' : '';
+ $entryTitle = $this->sanitizeHtml(strip_tags($item->getTitle()));
+ $entryUri = $item->getURI() ?: $uri;
+
+ $entryTimestamp = '';
+ if($item->getTimestamp()) {
+ $entryTimestamp = '<time datetime="'
+ . date(DATE_ATOM, $item->getTimestamp())
+ . '">'
+ . date(DATE_ATOM, $item->getTimestamp())
+ . '</time>';
+ }
+
+ $entryContent = '';
+ if($item->getContent()) {
+ $entryContent = '<div class="content">'
+ . $this->sanitizeHtml($item->getContent())
+ . '</div>';
+ }
+
+ $entryEnclosures = '';
+ if(!empty($item->getEnclosures())) {
+ $entryEnclosures = '<div class="attachments"><p>Attachments:</p>';
+
+ foreach($item->getEnclosures() as $enclosure) {
+ $url = $this->sanitizeHtml($enclosure);
+
+ $entryEnclosures .= '<li class="enclosure"><a href="'
+ . $url
+ . '">'
+ . substr($url, strrpos($url, '/') + 1)
+ . '</a></li>';
+ }
+
+ $entryEnclosures .= '</div>';
+ }
+
+ $entryCategories = '';
+ if(!empty($item->getCategories())) {
+ $entryCategories = '<div class="categories"><p>Categories:</p>';
+
+ foreach($item->getCategories() as $category) {
+
+ $entryCategories .= '<li class="category">'
+ . $this->sanitizeHtml($category)
+ . '</li>';
+ }
+
+ $entryCategories .= '</div>';
+ }
+
+ $entries .= <<<EOD
+
+<section class="feeditem">
+ <h2><a class="itemtitle" href="{$entryUri}">{$entryTitle}</a></h2>
+ {$entryTimestamp}
+ {$entryAuthor}
+ {$entryContent}
+ {$entryEnclosures}
+ {$entryCategories}
+</section>
+
+EOD;
+ }
+
+ $charset = $this->getCharset();
+
+ /* Data are prepared, now let's begin the "MAGIE !!!" */
+ $toReturn = <<<EOD
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="{$charset}">
+ <title>{$title}</title>
+ <link href="static/HtmlFormat.css" rel="stylesheet">
+ <link rel="alternate" type="application/atom+xml" title="Atom" href="./?{$atomquery}" />
+ <link rel="alternate" type="application/rss+xml" title="RSS" href="/?{$mrssquery}" />
+ <meta name="robots" content="noindex, follow">
+</head>
+<body>
+ <h1 class="pagetitle"><a href="{$uri}" target="_blank">{$title}</a></h1>
+ <div class="buttons">
+ <a href="./#bridge-{$_GET['bridge']}"><button class="backbutton">← back to rss-bridge</button></a>
+ <a href="./?{$atomquery}"><button class="rss-feed">RSS feed (ATOM)</button></a>
+ <a href="./?{$mrssquery}"><button class="rss-feed">RSS feed (MRSS)</button></a>
+ </div>
+{$entries}
+</body>
+</html>
+EOD;
+
+ // Remove invalid characters
+ ini_set('mbstring.substitute_character', 'none');
+ $toReturn = mb_convert_encoding($toReturn, $this->getCharset(), 'UTF-8');
+ return $toReturn;
+ }
+
+ public function display() {
+ $this
+ ->setContentType('text/html; charset=' . $this->getCharset())
+ ->callContentType();
+
+ return parent::display();
+ }
+}
diff --git a/formats/JsonFormat.php b/formats/JsonFormat.php
new file mode 100644
index 0000000..fafe7a5
--- /dev/null
+++ b/formats/JsonFormat.php
@@ -0,0 +1,126 @@
+<?php
+/**
+ * JsonFormat - JSON Feed Version 1
+ * https://jsonfeed.org/version/1
+ *
+ * Validators:
+ * https://validator.jsonfeed.org
+ * https://github.com/vigetlabs/json-feed-validator
+ */
+class JsonFormat extends FormatAbstract {
+ const VENDOR_EXCLUDES = array(
+ 'author',
+ 'title',
+ 'uri',
+ 'timestamp',
+ 'content',
+ 'enclosures',
+ 'categories',
+ );
+
+ public function stringify(){
+ $urlScheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') ? 'https://' : 'http://';
+ $urlHost = (isset($_SERVER['HTTP_HOST'])) ? $_SERVER['HTTP_HOST'] : '';
+ $urlPath = (isset($_SERVER['PATH_INFO'])) ? $_SERVER['PATH_INFO'] : '';
+ $urlRequest = (isset($_SERVER['REQUEST_URI'])) ? $_SERVER['REQUEST_URI'] : '';
+
+ $extraInfos = $this->getExtraInfos();
+
+ $data = array(
+ 'version' => 'https://jsonfeed.org/version/1',
+ 'title' => (!empty($extraInfos['name'])) ? $extraInfos['name'] : $urlHost,
+ 'home_page_url' => (!empty($extraInfos['uri'])) ? $extraInfos['uri'] : REPOSITORY,
+ 'feed_url' => $urlScheme . $urlHost . $urlRequest
+ );
+
+ if (!empty($extraInfos['icon'])) {
+ $data['icon'] = $extraInfos['icon'];
+ $data['favicon'] = $extraInfos['icon'];
+ }
+
+ $items = array();
+ foreach ($this->getItems() as $item) {
+ $entry = array();
+
+ $entryAuthor = $item->getAuthor();
+ $entryTitle = $item->getTitle();
+ $entryUri = $item->getURI();
+ $entryTimestamp = $item->getTimestamp();
+ $entryContent = $this->sanitizeHtml($item->getContent());
+ $entryEnclosures = $item->getEnclosures();
+ $entryCategories = $item->getCategories();
+
+ $vendorFields = $item->toArray();
+ foreach (self::VENDOR_EXCLUDES as $key) {
+ unset($vendorFields[$key]);
+ }
+
+ $entry['id'] = $entryUri;
+
+ if (!empty($entryTitle)) {
+ $entry['title'] = $entryTitle;
+ }
+ if (!empty($entryAuthor)) {
+ $entry['author'] = array(
+ 'name' => $entryAuthor
+ );
+ }
+ if (!empty($entryTimestamp)) {
+ $entry['date_modified'] = gmdate(DATE_ATOM, $entryTimestamp);
+ }
+ if (!empty($entryUri)) {
+ $entry['url'] = $entryUri;
+ }
+ if (!empty($entryContent)) {
+ if ($this->isHTML($entryContent)) {
+ $entry['content_html'] = $entryContent;
+ } else {
+ $entry['content_text'] = $entryContent;
+ }
+ }
+ if (!empty($entryEnclosures)) {
+ $entry['attachments'] = array();
+ foreach ($entryEnclosures as $enclosure) {
+ $entry['attachments'][] = array(
+ 'url' => $enclosure,
+ 'mime_type' => getMimeType($enclosure)
+ );
+ }
+ }
+ if (!empty($entryCategories)) {
+ $entry['tags'] = array();
+ foreach ($entryCategories as $category) {
+ $entry['tags'][] = $category;
+ }
+ }
+ if (!empty($vendorFields)) {
+ $entry['_rssbridge'] = $vendorFields;
+ }
+
+ if (empty($entry['id']))
+ $entry['id'] = hash('sha1', $entryTitle . $entryContent);
+
+ $items[] = $entry;
+ }
+ $data['items'] = $items;
+
+ $toReturn = json_encode($data, JSON_PRETTY_PRINT);
+
+ // Remove invalid non-UTF8 characters
+ ini_set('mbstring.substitute_character', 'none');
+ $toReturn = mb_convert_encoding($toReturn, $this->getCharset(), 'UTF-8');
+ return $toReturn;
+ }
+
+ public function display(){
+ $this
+ ->setContentType('application/json; charset=' . $this->getCharset())
+ ->callContentType();
+
+ return parent::display();
+ }
+
+ private function isHTML($text) {
+ return (strlen(strip_tags($text)) != strlen($text));
+ }
+}
diff --git a/formats/MrssFormat.php b/formats/MrssFormat.php
new file mode 100644
index 0000000..34b9a92
--- /dev/null
+++ b/formats/MrssFormat.php
@@ -0,0 +1,116 @@
+<?php
+/**
+* Mrss
+* Documentation Source http://www.rssboard.org/media-rss
+*/
+class MrssFormat extends FormatAbstract {
+ public function stringify(){
+ $https = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on' ? 's' : '';
+ $httpHost = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
+ $httpInfo = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : '';
+
+ $serverRequestUri = isset($_SERVER['REQUEST_URI']) ? $this->xml_encode($_SERVER['REQUEST_URI']) : '';
+
+ $extraInfos = $this->getExtraInfos();
+ $title = $this->xml_encode($extraInfos['name']);
+
+ if(!empty($extraInfos['uri'])) {
+ $uri = $this->xml_encode($extraInfos['uri']);
+ } else {
+ $uri = REPOSITORY;
+ }
+
+ $uriparts = parse_url($uri);
+ $icon = $this->xml_encode($uriparts['scheme'] . '://' . $uriparts['host'] . '/favicon.ico');
+
+ $items = '';
+ foreach($this->getItems() as $item) {
+ $itemAuthor = $this->xml_encode($item->getAuthor());
+ $itemTitle = $this->xml_encode($item->getTitle());
+ $itemUri = $this->xml_encode($item->getURI());
+ $itemTimestamp = $this->xml_encode(date(DATE_RFC2822, $item->getTimestamp()));
+ $itemContent = $this->xml_encode($this->sanitizeHtml($item->getContent()));
+
+ $entryEnclosuresWarning = '';
+ $entryEnclosures = '';
+ if(!empty($item->getEnclosures())) {
+ $entryEnclosures .= '<enclosure url="'
+ . $this->xml_encode($item->getEnclosures()[0])
+ . '" type="' . getMimeType($item->getEnclosures()[0]) . '" />';
+
+ if(count($item->getEnclosures()) > 1) {
+ $entryEnclosures .= PHP_EOL;
+ $entryEnclosuresWarning = '&lt;br&gt;Warning:
+Some media files might not be shown to you. Consider using the ATOM format instead!';
+ foreach($item->getEnclosures() as $enclosure) {
+ $entryEnclosures .= '<atom:link rel="enclosure" href="'
+ . $enclosure . '" type="' . getMimeType($enclosure) . '" />'
+ . PHP_EOL;
+ }
+ }
+ }
+
+ $entryCategories = '';
+ foreach($item->getCategories() as $category) {
+ $entryCategories .= '<category>'
+ . $category . '</category>'
+ . PHP_EOL;
+ }
+
+ $items .= <<<EOD
+
+ <item>
+ <title>{$itemTitle}</title>
+ <link>{$itemUri}</link>
+ <guid isPermaLink="true">{$itemUri}</guid>
+ <pubDate>{$itemTimestamp}</pubDate>
+ <description>{$itemContent}{$entryEnclosuresWarning}</description>
+ <author>{$itemAuthor}</author>
+ {$entryEnclosures}
+ {$entryCategories}
+ </item>
+
+EOD;
+ }
+
+ $charset = $this->getCharset();
+
+ /* xml attributes need to have certain characters escaped to be w3c compliant */
+ $imageTitle = htmlspecialchars($title, ENT_COMPAT);
+ /* Data are prepared, now let's begin the "MAGIE !!!" */
+ $toReturn = <<<EOD
+<?xml version="1.0" encoding="{$charset}"?>
+<rss version="2.0"
+xmlns:dc="http://purl.org/dc/elements/1.1/"
+xmlns:media="http://search.yahoo.com/mrss/"
+xmlns:atom="http://www.w3.org/2005/Atom">
+ <channel>
+ <title>{$title}</title>
+ <link>http{$https}://{$httpHost}{$httpInfo}/</link>
+ <description>{$title}</description>
+ <image url="{$icon}" title="{$imageTitle}" link="{$uri}"/>
+ <atom:link rel="alternate" type="text/html" href="{$uri}" />
+ <atom:link rel="self" href="http{$https}://{$httpHost}{$serverRequestUri}" />
+ {$items}
+ </channel>
+</rss>
+EOD;
+
+ // Remove invalid non-UTF8 characters
+ ini_set('mbstring.substitute_character', 'none');
+ $toReturn = mb_convert_encoding($toReturn, $this->getCharset(), 'UTF-8');
+ return $toReturn;
+ }
+
+ public function display(){
+ $this
+ ->setContentType('application/rss+xml; charset=' . $this->getCharset())
+ ->callContentType();
+
+ return parent::display();
+ }
+
+ private function xml_encode($text){
+ return htmlspecialchars($text, ENT_XML1);
+ }
+}
diff --git a/formats/PlaintextFormat.php b/formats/PlaintextFormat.php
new file mode 100644
index 0000000..591a4b3
--- /dev/null
+++ b/formats/PlaintextFormat.php
@@ -0,0 +1,30 @@
+<?php
+/**
+* Plaintext
+* Returns $this->items as raw php data.
+*/
+class PlaintextFormat extends FormatAbstract {
+ public function stringify(){
+ $items = $this->getItems();
+ $data = array();
+
+ foreach($items as $item) {
+ $data[] = $item->toArray();
+ }
+
+ $toReturn = print_r($data, true);
+
+ // Remove invalid non-UTF8 characters
+ ini_set('mbstring.substitute_character', 'none');
+ $toReturn = mb_convert_encoding($toReturn, $this->getCharset(), 'UTF-8');
+ return $toReturn;
+ }
+
+ public function display(){
+ $this
+ ->setContentType('text/plain; charset=' . $this->getCharset())
+ ->callContentType();
+
+ return parent::display();
+ }
+}
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..a95302a
--- /dev/null
+++ b/index.php
@@ -0,0 +1,341 @@
+<?php
+require_once __DIR__ . '/lib/rssbridge.php';
+
+Configuration::verifyInstallation();
+Configuration::loadConfiguration();
+
+Authentication::showPromptIfNeeded();
+
+date_default_timezone_set('UTC');
+
+/*
+Move the CLI arguments to the $_GET array, in order to be able to use
+rss-bridge from the command line
+*/
+if (isset($argv)) {
+ parse_str(implode('&', array_slice($argv, 1)), $cliArgs);
+ $params = array_merge($_GET, $cliArgs);
+} else {
+ $params = $_GET;
+}
+
+define('USER_AGENT',
+ 'Mozilla/5.0 (X11; Linux x86_64; rv:30.0) Gecko/20121202 Firefox/30.0(rss-bridge/'
+ . Configuration::$VERSION
+ . ';+'
+ . REPOSITORY
+ . ')'
+);
+
+ini_set('user_agent', USER_AGENT);
+
+// default whitelist
+$whitelist_default = array(
+ 'BandcampBridge',
+ 'CryptomeBridge',
+ 'DansTonChatBridge',
+ 'DuckDuckGoBridge',
+ 'FacebookBridge',
+ 'FlickrBridge',
+ 'GooglePlusPostBridge',
+ 'GoogleSearchBridge',
+ 'IdenticaBridge',
+ 'InstagramBridge',
+ 'OpenClassroomsBridge',
+ 'PinterestBridge',
+ 'ScmbBridge',
+ 'TwitterBridge',
+ 'WikipediaBridge',
+ 'YoutubeBridge');
+
+try {
+
+ Bridge::setWhitelist($whitelist_default);
+
+ $showInactive = filter_input(INPUT_GET, 'show_inactive', FILTER_VALIDATE_BOOLEAN);
+ $action = array_key_exists('action', $params) ? $params['action'] : null;
+ $bridge = array_key_exists('bridge', $params) ? $params['bridge'] : null;
+
+ // Return list of bridges as JSON formatted text
+ if($action === 'list') {
+
+ $list = new StdClass();
+ $list->bridges = array();
+ $list->total = 0;
+
+ foreach(Bridge::getBridgeNames() as $bridgeName) {
+
+ $bridge = Bridge::create($bridgeName);
+
+ if($bridge === false) { // Broken bridge, show as inactive
+
+ $list->bridges[$bridgeName] = array(
+ 'status' => 'inactive'
+ );
+
+ continue;
+
+ }
+
+ $status = Bridge::isWhitelisted($bridgeName) ? 'active' : 'inactive';
+
+ $list->bridges[$bridgeName] = array(
+ 'status' => $status,
+ 'uri' => $bridge->getURI(),
+ 'name' => $bridge->getName(),
+ 'icon' => $bridge->getIcon(),
+ 'parameters' => $bridge->getParameters(),
+ 'maintainer' => $bridge->getMaintainer(),
+ 'description' => $bridge->getDescription()
+ );
+
+ }
+
+ $list->total = count($list->bridges);
+
+ header('Content-Type: application/json');
+ echo json_encode($list, JSON_PRETTY_PRINT);
+
+ } elseif($action === 'detect') {
+
+ $targetURL = $params['url']
+ or returnClientError('You must specify a url!');
+
+ $format = $params['format']
+ or returnClientError('You must specify a format!');
+
+ foreach(Bridge::getBridgeNames() as $bridgeName) {
+
+ if(!Bridge::isWhitelisted($bridgeName)) {
+ continue;
+ }
+
+ $bridge = Bridge::create($bridgeName);
+
+ if($bridge === false) {
+ continue;
+ }
+
+ $bridgeParams = $bridge->detectParameters($targetURL);
+
+ if(is_null($bridgeParams)) {
+ continue;
+ }
+
+ $bridgeParams['bridge'] = $bridgeName;
+ $bridgeParams['format'] = $format;
+
+ header('Location: ?action=display&' . http_build_query($bridgeParams), true, 301);
+ die();
+
+ }
+
+ returnClientError('No bridge found for given URL: ' . $targetURL);
+
+ } elseif($action === 'display' && !empty($bridge)) {
+
+ $format = $params['format']
+ or returnClientError('You must specify a format!');
+
+ // DEPRECATED: 'nameFormat' scheme is replaced by 'name' in format parameter values
+ // this is to keep compatibility until futher complete removal
+ if(($pos = strpos($format, 'Format')) === (strlen($format) - strlen('Format'))) {
+ $format = substr($format, 0, $pos);
+ }
+
+ // whitelist control
+ if(!Bridge::isWhitelisted($bridge)) {
+ throw new \Exception('This bridge is not whitelisted', 401);
+ die;
+ }
+
+ // Data retrieval
+ $bridge = Bridge::create($bridge);
+
+ $noproxy = array_key_exists('_noproxy', $params) && filter_var($params['_noproxy'], FILTER_VALIDATE_BOOLEAN);
+ if(defined('PROXY_URL') && PROXY_BYBRIDGE && $noproxy) {
+ define('NOPROXY', true);
+ }
+
+ // Cache timeout
+ $cache_timeout = -1;
+ if(array_key_exists('_cache_timeout', $params)) {
+
+ if(!CUSTOM_CACHE_TIMEOUT) {
+ unset($params['_cache_timeout']);
+ $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) . '?' . http_build_query($params);
+ header('Location: ' . $uri, true, 301);
+ die();
+ }
+
+ $cache_timeout = filter_var($params['_cache_timeout'], FILTER_VALIDATE_INT);
+
+ } else {
+ $cache_timeout = $bridge->getCacheTimeout();
+ }
+
+ // Remove parameters that don't concern bridges
+ $bridge_params = array_diff_key(
+ $params,
+ array_fill_keys(
+ array(
+ 'action',
+ 'bridge',
+ 'format',
+ '_noproxy',
+ '_cache_timeout',
+ '_error_time'
+ ), '')
+ );
+
+ // Remove parameters that don't concern caches
+ $cache_params = array_diff_key(
+ $params,
+ array_fill_keys(
+ array(
+ 'action',
+ 'format',
+ '_noproxy',
+ '_cache_timeout',
+ '_error_time'
+ ), '')
+ );
+
+ // Initialize cache
+ $cache = Cache::create('FileCache');
+ $cache->setPath(PATH_CACHE);
+ $cache->purgeCache(86400); // 24 hours
+ $cache->setParameters($cache_params);
+
+ $items = array();
+ $infos = array();
+ $mtime = $cache->getTime();
+
+ if($mtime !== false
+ && (time() - $cache_timeout < $mtime)
+ && !Debug::isEnabled()) { // Load cached data
+
+ // Send "Not Modified" response if client supports it
+ // Implementation based on https://stackoverflow.com/a/10847262
+ if(isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
+ $stime = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
+
+ if($mtime <= $stime) { // Cached data is older or same
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s ', $mtime) . 'GMT', true, 304);
+ die();
+ }
+ }
+
+ $cached = $cache->loadData();
+
+ if(isset($cached['items']) && isset($cached['extraInfos'])) {
+ foreach($cached['items'] as $item) {
+ $items[] = new \FeedItem($item);
+ }
+
+ $infos = $cached['extraInfos'];
+ }
+
+ } else { // Collect new data
+
+ try {
+ $bridge->setDatas($bridge_params);
+ $bridge->collectData();
+
+ $items = $bridge->getItems();
+
+ // Transform "legacy" items to FeedItems if necessary.
+ // Remove this code when support for "legacy" items ends!
+ if(isset($items[0]) && is_array($items[0])) {
+ $feedItems = array();
+
+ foreach($items as $item) {
+ $feedItems[] = new \FeedItem($item);
+ }
+
+ $items = $feedItems;
+ }
+
+ $infos = array(
+ 'name' => $bridge->getName(),
+ 'uri' => $bridge->getURI(),
+ 'icon' => $bridge->getIcon()
+ );
+ } catch(Error $e) {
+ error_log($e);
+
+ $item = new \FeedItem();
+
+ // Create "new" error message every 24 hours
+ $params['_error_time'] = urlencode((int)(time() / 86400));
+
+ // Error 0 is a special case (i.e. "trying to get property of non-object")
+ if($e->getCode() === 0) {
+ $item->setTitle('Bridge encountered an unexpected situation! (' . $params['_error_time'] . ')');
+ } else {
+ $item->setTitle('Bridge returned error ' . $e->getCode() . '! (' . $params['_error_time'] . ')');
+ }
+
+ $item->setURI(
+ (isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '')
+ . '?'
+ . http_build_query($params)
+ );
+
+ $item->setTimestamp(time());
+ $item->setContent(buildBridgeException($e, $bridge));
+
+ $items[] = $item;
+ } catch(Exception $e) {
+ error_log($e);
+
+ $item = new \FeedItem();
+
+ // Create "new" error message every 24 hours
+ $params['_error_time'] = urlencode((int)(time() / 86400));
+
+ $item->setURI(
+ (isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '')
+ . '?'
+ . http_build_query($params)
+ );
+
+ $item->setTitle('Bridge returned error ' . $e->getCode() . '! (' . $params['_error_time'] . ')');
+ $item->setTimestamp(time());
+ $item->setContent(buildBridgeException($e, $bridge));
+
+ $items[] = $item;
+ }
+
+ // Store data in cache
+ $cache->saveData(array(
+ 'items' => array_map(function($i){ return $i->toArray(); }, $items),
+ 'extraInfos' => $infos
+ ));
+
+ }
+
+ // Data transformation
+ try {
+ $format = Format::create($format);
+ $format->setItems($items);
+ $format->setExtraInfos($infos);
+ $format->setLastModified($cache->getTime());
+ $format->display();
+ } catch(Error $e) {
+ error_log($e);
+ header('Content-Type: text/html', true, $e->getCode());
+ die(buildTransformException($e, $bridge));
+ } catch(Exception $e) {
+ error_log($e);
+ header('Content-Type: text/html', true, $e->getCode());
+ die(buildTransformException($e, $bridge));
+ }
+ } else {
+ echo BridgeList::create($showInactive);
+ }
+} catch(\Exception $e) {
+ error_log($e);
+ header('Content-Type: text/plain', true, $e->getCode());
+ die($e->getMessage());
+}
diff --git a/lib/Authentication.php b/lib/Authentication.php
new file mode 100644
index 0000000..ac8ea96
--- /dev/null
+++ b/lib/Authentication.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Authentication module for RSS-Bridge.
+ *
+ * This class implements an authentication module for RSS-Bridge, utilizing the
+ * HTTP authentication capabilities of PHP.
+ *
+ * _Notice_: Authentication via HTTP does not prevent users from accessing files
+ * on your server. If your server supports `.htaccess`, you should globally restrict
+ * access to files instead.
+ *
+ * @link https://php.net/manual/en/features.http-auth.php HTTP authentication with PHP
+ * @link https://httpd.apache.org/docs/2.4/howto/htaccess.html Apache HTTP Server
+ * Tutorial: .htaccess files
+ *
+ * @todo Configuration parameters should be stored internally instead of accessing
+ * the configuration class directly.
+ * @todo Add functions to detect if a user is authenticated or not. This can be
+ * utilized for limiting access to authorized users only.
+ */
+class Authentication {
+ /**
+ * Throw an exception when trying to create a new instance of this class.
+ * Use {@see Authentication::showPromptIfNeeded()} instead!
+ *
+ * @throws \LogicException if called.
+ */
+ public function __construct(){
+ throw new \LogicException('Use ' . __CLASS__ . '::showPromptIfNeeded()!');
+ }
+
+ /**
+ * Requests the user for login credentials if necessary.
+ *
+ * Responds to an authentication request or returns the `WWW-Authenticate`
+ * header if authentication is enabled in the configuration of RSS-Bridge
+ * (`[authentication] enable = true`).
+ *
+ * @return void
+ */
+ public static function showPromptIfNeeded() {
+
+ if(Configuration::getConfig('authentication', 'enable') === true) {
+ if(!Authentication::verifyPrompt()) {
+ header('WWW-Authenticate: Basic realm="RSS-Bridge"', true, 401);
+ die('Please authenticate in order to access this instance !');
+ }
+
+ }
+
+ }
+
+ /**
+ * Verifies if an authentication request was received and compares the
+ * provided username and password to the configuration of RSS-Bridge
+ * (`[authentication] username` and `[authentication] password`).
+ *
+ * @return bool True if authentication succeeded.
+ */
+ public static function verifyPrompt() {
+
+ if(isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) {
+ if(Configuration::getConfig('authentication', 'username') === $_SERVER['PHP_AUTH_USER']
+ && Configuration::getConfig('authentication', 'password') === $_SERVER['PHP_AUTH_PW']) {
+ return true;
+ } else {
+ error_log('[RSS-Bridge] Failed authentication attempt from ' . $_SERVER['REMOTE_ADDR']);
+ }
+ }
+ return false;
+
+ }
+}
diff --git a/lib/Bridge.php b/lib/Bridge.php
new file mode 100644
index 0000000..c9561c8
--- /dev/null
+++ b/lib/Bridge.php
@@ -0,0 +1,296 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Factory class responsible for creating bridge objects from a given working
+ * directory, limited by a whitelist.
+ *
+ * This class is capable of:
+ * - Locating bridge classes in the specified working directory (see {@see Bridge::$workingDir})
+ * - Filtering bridges based on a whitelist (see {@see Bridge::$whitelist})
+ * - Creating new bridge instances based on the bridge's name (see {@see Bridge::create()})
+ *
+ * The following example illustrates the intended use for this class.
+ *
+ * ```PHP
+ * require_once __DIR__ . '/rssbridge.php';
+ *
+ * // Step 1: Set the working directory
+ * Bridge::setWorkingDir(__DIR__ . '/../bridges/');
+ *
+ * // Step 2: Add bridges to the whitelist
+ * Bridge::setWhitelist(array('GitHubIssue', 'GoogleSearch', 'Facebook', 'Twitter'));
+ *
+ * // Step 3: Create a new instance of a bridge (based on the name)
+ * $bridge = Bridge::create('GitHubIssue');
+ * ```
+ */
+class Bridge {
+
+ /**
+ * Holds a path to the working directory.
+ *
+ * Do not access this property directly!
+ * Use {@see Bridge::setWorkingDir()} and {@see Bridge::getWorkingDir()} instead.
+ *
+ * @var string|null
+ */
+ protected static $workingDir = null;
+
+ /**
+ * Holds a list of whitelisted bridges.
+ *
+ * Do not access this property directly!
+ * Use {@see Bridge::getWhitelist()} instead.
+ *
+ * @var array
+ */
+ protected static $whitelist = array();
+
+ /**
+ * Throws an exception when trying to create a new instance of this class.
+ * Use {@see Bridge::create()} to instanciate a new bridge from the working
+ * directory.
+ *
+ * @throws \LogicException if called.
+ */
+ public function __construct(){
+ throw new \LogicException('Use ' . __CLASS__ . '::create($name) to create bridge objects!');
+ }
+
+ /**
+ * Creates a new bridge object from the working directory.
+ *
+ * @throws \InvalidArgumentException if the requested bridge name is invalid.
+ * @throws \Exception if the requested bridge doesn't exist in the working
+ * directory.
+ * @param string $name Name of the bridge object.
+ * @return object|bool The bridge object or false if the class is not instantiable.
+ */
+ public static function create($name){
+ if(!self::isBridgeName($name)) {
+ throw new \InvalidArgumentException('Bridge name invalid!');
+ }
+
+ $name = self::sanitizeBridgeName($name) . 'Bridge';
+ $filePath = self::getWorkingDir() . $name . '.php';
+
+ if(!file_exists($filePath)) {
+ throw new \Exception('Bridge file ' . $filePath . ' does not exist!');
+ }
+
+ require_once $filePath;
+
+ if((new \ReflectionClass($name))->isInstantiable()) {
+ return new $name();
+ }
+
+ return false;
+ }
+
+ /**
+ * Sets the working directory.
+ *
+ * @param string $dir Path to the directory containing bridges.
+ * @throws \LogicException if the provided path is not a valid string.
+ * @throws \Exception if the provided path does not exist.
+ * @throws \InvalidArgumentException if $dir is not a directory.
+ * @return void
+ */
+ public static function setWorkingDir($dir){
+ self::$workingDir = null;
+
+ if(!is_string($dir)) {
+ throw new \InvalidArgumentException('Working directory is not a valid string!');
+ }
+
+ if(!file_exists($dir)) {
+ throw new \Exception('Working directory does not exist!');
+ }
+
+ if(!is_dir($dir)) {
+ throw new \InvalidArgumentException('Working directory is not a directory!');
+ }
+
+ self::$workingDir = realpath($dir) . '/';
+ }
+
+ /**
+ * Returns the working directory.
+ * The working directory must be specified with {@see Bridge::setWorkingDir()}!
+ *
+ * @throws \LogicException if the working directory is not set.
+ * @return string The current working directory.
+ */
+ public static function getWorkingDir(){
+ if(is_null(self::$workingDir)) {
+ throw new \LogicException('Working directory is not set!');
+ }
+
+ return self::$workingDir;
+ }
+
+ /**
+ * Returns true if the provided name is a valid bridge name.
+ *
+ * A valid bridge name starts with a capital letter ([A-Z]), followed by
+ * zero or more alphanumeric characters or hyphen ([A-Za-z0-9-]).
+ *
+ * @param string $name The bridge name.
+ * @return bool true if the name is a valid bridge name, false otherwise.
+ */
+ public static function isBridgeName($name){
+ return is_string($name) && preg_match('/^[A-Z][a-zA-Z0-9-]*$/', $name) === 1;
+ }
+
+ /**
+ * Returns the list of bridge names from the working directory.
+ *
+ * The list is cached internally to allow for successive calls.
+ *
+ * @return array List of bridge names
+ */
+ public static function getBridgeNames(){
+
+ static $bridgeNames = array(); // Initialized on first call
+
+ if(empty($bridgeNames)) {
+ $files = scandir(self::getWorkingDir());
+
+ if($files !== false) {
+ foreach($files as $file) {
+ if(preg_match('/^([^.]+)Bridge\.php$/U', $file, $out)) {
+ $bridgeNames[] = $out[1];
+ }
+ }
+ }
+ }
+
+ return $bridgeNames;
+ }
+
+ /**
+ * Checks if a bridge is whitelisted.
+ *
+ * @param string $name Name of the bridge.
+ * @return bool True if the bridge is whitelisted.
+ */
+ public static function isWhitelisted($name){
+ return in_array(self::sanitizeBridgeName($name), self::getWhitelist());
+ }
+
+ /**
+ * Returns the whitelist.
+ *
+ * On first call this function reads the whitelist from {@see WHITELIST}.
+ * * Each line in the file specifies one bridge on the whitelist.
+ * * An empty file disables all bridges.
+ * * If the file only only contains `*`, all bridges are whitelisted.
+ *
+ * Use {@see Bridge::setWhitelist()} to specify a default whitelist **before**
+ * calling this function! The list is cached internally to allow for
+ * successive calls. If {@see Bridge::setWhitelist()} gets called after this
+ * function, the whitelist is **not** updated again!
+ *
+ * @return array Array of whitelisted bridges
+ */
+ public static function getWhitelist() {
+
+ static $firstCall = true; // Initialized on first call
+
+ if($firstCall) {
+
+ // Create initial whitelist or load from disk
+ if (!file_exists(WHITELIST) && !empty(self::$whitelist)) {
+ file_put_contents(WHITELIST, implode("\n", self::$whitelist));
+ } else {
+
+ $contents = trim(file_get_contents(WHITELIST));
+
+ if($contents === '*') { // Whitelist all bridges
+ self::$whitelist = self::getBridgeNames();
+ } else {
+ self::$whitelist = array_map('self::sanitizeBridgeName', explode("\n", $contents));
+ }
+
+ }
+
+ }
+
+ return self::$whitelist;
+
+ }
+
+ /**
+ * Sets the (default) whitelist.
+ *
+ * If this function is called **before** {@see Bridge::getWhitelist()}, the
+ * provided whitelist will be replaced by a custom whitelist specified in
+ * {@see WHITELIST} (if it exists).
+ *
+ * If this function is called **after** {@see Bridge::getWhitelist()}, the
+ * provided whitelist is taken as is (not updated by the custom whitelist
+ * again).
+ *
+ * @param array $default The whitelist as array of bridge names.
+ * @return void
+ */
+ public static function setWhitelist($default = array()) {
+ self::$whitelist = array_map('self::sanitizeBridgeName', $default);
+ }
+
+ /**
+ * Returns the sanitized bridge name.
+ *
+ * The bridge name can be specified in various ways:
+ * * The PHP file name (i.e. `GitHubIssueBridge.php`)
+ * * The PHP file name without file extension (i.e. `GitHubIssueBridge`)
+ * * The bridge name (i.e. `GitHubIssue`)
+ *
+ * Casing is ignored (i.e. `GITHUBISSUE` and `githubissue` are the same).
+ *
+ * A bridge file matching the given bridge name must exist in the working
+ * directory!
+ *
+ * @param string $name The bridge name
+ * @return string|null The sanitized bridge name if the provided name is
+ * valid, null otherwise.
+ */
+ protected static function sanitizeBridgeName($name) {
+
+ if(is_string($name)) {
+
+ // Trim trailing '.php' if exists
+ if(preg_match('/(.+)(?:\.php)/', $name, $matches)) {
+ $name = $matches[1];
+ }
+
+ // Trim trailing 'Bridge' if exists
+ if(preg_match('/(.+)(?:Bridge)/i', $name, $matches)) {
+ $name = $matches[1];
+ }
+
+ // The name is valid if a corresponding bridge file is found on disk
+ if(in_array(strtolower($name), array_map('strtolower', self::getBridgeNames()))) {
+ $index = array_search(strtolower($name), array_map('strtolower', self::getBridgeNames()));
+ return self::getBridgeNames()[$index];
+ }
+
+ Debug::log('Invalid bridge name specified: "' . $name . '"!');
+
+ }
+
+ return null; // Bad parameter
+
+ }
+}
diff --git a/lib/BridgeAbstract.php b/lib/BridgeAbstract.php
new file mode 100644
index 0000000..13215a4
--- /dev/null
+++ b/lib/BridgeAbstract.php
@@ -0,0 +1,293 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * An abstract class for bridges
+ *
+ * This class implements {@see BridgeInterface} with most common functions in
+ * order to reduce code duplication. Bridges should inherit from this class
+ * instead of implementing the interface manually.
+ *
+ * @todo Move constants to the interface (this is supported by PHP)
+ * @todo Change visibility of constants to protected
+ * @todo Return `self` on more functions to allow chaining
+ * @todo Add specification for PARAMETERS ()
+ * @todo Add specification for $items
+ */
+abstract class BridgeAbstract implements BridgeInterface {
+
+ /**
+ * Name of the bridge
+ *
+ * Use {@see BridgeAbstract::getName()} to read this parameter
+ */
+ const NAME = 'Unnamed bridge';
+
+ /**
+ * URI to the site the bridge is intended to be used for.
+ *
+ * Use {@see BridgeAbstract::getURI()} to read this parameter
+ */
+ const URI = '';
+
+ /**
+ * A brief description of what the bridge can do
+ *
+ * Use {@see BridgeAbstract::getDescription()} to read this parameter
+ */
+ const DESCRIPTION = 'No description provided';
+
+ /**
+ * The name of the maintainer. Multiple maintainers can be separated by comma
+ *
+ * Use {@see BridgeAbstract::getMaintainer()} to read this parameter
+ */
+ const MAINTAINER = 'No maintainer';
+
+ /**
+ * The default cache timeout for the bridge
+ *
+ * Use {@see BridgeAbstract::getCacheTimeout()} to read this parameter
+ */
+ const CACHE_TIMEOUT = 3600;
+
+ /**
+ * Parameters for the bridge
+ *
+ * Use {@see BridgeAbstract::getParameters()} to read this parameter
+ */
+ const PARAMETERS = array();
+
+ /**
+ * Holds the list of items collected by the bridge
+ *
+ * Items must be collected by {@see BridgeInterface::collectData()}
+ *
+ * Use {@see BridgeAbstract::getItems()} to access items.
+ *
+ * @var array
+ */
+ protected $items = array();
+
+ /**
+ * Holds the list of input parameters used by the bridge
+ *
+ * Do not access this parameter directly!
+ * Use {@see BridgeAbstract::setInputs()} and {@see BridgeAbstract::getInput()} instead!
+ *
+ * @var array
+ */
+ protected $inputs = array();
+
+ /**
+ * Holds the name of the queried context
+ *
+ * @var string
+ */
+ protected $queriedContext = '';
+
+ /** {@inheritdoc} */
+ public function getItems(){
+ return $this->items;
+ }
+
+ /**
+ * Sets the input values for a given context.
+ *
+ * @param array $inputs Associative array of inputs
+ * @param string $queriedContext The context name
+ * @return void
+ */
+ protected function setInputs(array $inputs, $queriedContext){
+ // Import and assign all inputs to their context
+ foreach($inputs as $name => $value) {
+ foreach(static::PARAMETERS as $context => $set) {
+ if(array_key_exists($name, static::PARAMETERS[$context])) {
+ $this->inputs[$context][$name]['value'] = $value;
+ }
+ }
+ }
+
+ // Apply default values to missing data
+ $contexts = array($queriedContext);
+ if(array_key_exists('global', static::PARAMETERS)) {
+ $contexts[] = 'global';
+ }
+
+ foreach($contexts as $context) {
+ foreach(static::PARAMETERS[$context] as $name => $properties) {
+ if(isset($this->inputs[$context][$name]['value'])) {
+ continue;
+ }
+
+ $type = isset($properties['type']) ? $properties['type'] : 'text';
+
+ switch($type) {
+ case 'checkbox':
+ if(!isset($properties['defaultValue'])) {
+ $this->inputs[$context][$name]['value'] = false;
+ } else {
+ $this->inputs[$context][$name]['value'] = $properties['defaultValue'];
+ }
+ break;
+ case 'list':
+ if(!isset($properties['defaultValue'])) {
+ $firstItem = reset($properties['values']);
+ if(is_array($firstItem)) {
+ $firstItem = reset($firstItem);
+ }
+ $this->inputs[$context][$name]['value'] = $firstItem;
+ } else {
+ $this->inputs[$context][$name]['value'] = $properties['defaultValue'];
+ }
+ break;
+ default:
+ if(isset($properties['defaultValue'])) {
+ $this->inputs[$context][$name]['value'] = $properties['defaultValue'];
+ }
+ break;
+ }
+ }
+ }
+
+ // Copy global parameter values to the guessed context
+ if(array_key_exists('global', static::PARAMETERS)) {
+ foreach(static::PARAMETERS['global'] as $name => $properties) {
+ if(isset($inputs[$name])) {
+ $value = $inputs[$name];
+ } elseif(isset($properties['value'])) {
+ $value = $properties['value'];
+ } else {
+ continue;
+ }
+ $this->inputs[$queriedContext][$name]['value'] = $value;
+ }
+ }
+
+ // Only keep guessed context parameters values
+ if(isset($this->inputs[$queriedContext])) {
+ $this->inputs = array($queriedContext => $this->inputs[$queriedContext]);
+ } else {
+ $this->inputs = array();
+ }
+ }
+
+ /**
+ * Set inputs for the bridge
+ *
+ * Returns errors and aborts execution if the provided input parameters are
+ * invalid.
+ *
+ * @param array List of input parameters. Each element in this list must
+ * relate to an item in {@see BridgeAbstract::PARAMETERS}
+ * @return void
+ */
+ public function setDatas(array $inputs){
+
+ if(empty(static::PARAMETERS)) {
+
+ if(!empty($inputs)) {
+ returnClientError('Invalid parameters value(s)');
+ }
+
+ return;
+
+ }
+
+ $validator = new ParameterValidator();
+
+ if(!$validator->validateData($inputs, static::PARAMETERS)) {
+ $parameters = array_map(
+ function($i){ return $i['name']; }, // Just display parameter names
+ $validator->getInvalidParameters()
+ );
+
+ returnClientError(
+ 'Invalid parameters value(s): '
+ . implode(', ', $parameters)
+ );
+ }
+
+ // Guess the paramter context from input data
+ $this->queriedContext = $validator->getQueriedContext($inputs, static::PARAMETERS);
+ if(is_null($this->queriedContext)) {
+ returnClientError('Required parameter(s) missing');
+ } elseif($this->queriedContext === false) {
+ returnClientError('Mixed context parameters');
+ }
+
+ $this->setInputs($inputs, $this->queriedContext);
+
+ }
+
+ /**
+ * Returns the value for the provided input
+ *
+ * @param string $input The input name
+ * @return mixed|null The input value or null if the input is not defined
+ */
+ protected function getInput($input){
+ if(!isset($this->inputs[$this->queriedContext][$input]['value'])) {
+ return null;
+ }
+ return $this->inputs[$this->queriedContext][$input]['value'];
+ }
+
+ /** {@inheritdoc} */
+ public function getDescription(){
+ return static::DESCRIPTION;
+ }
+
+ /** {@inheritdoc} */
+ public function getMaintainer(){
+ return static::MAINTAINER;
+ }
+
+ /** {@inheritdoc} */
+ public function getName(){
+ return static::NAME;
+ }
+
+ /** {@inheritdoc} */
+ public function getIcon(){
+ return '';
+ }
+
+ /** {@inheritdoc} */
+ public function getParameters(){
+ return static::PARAMETERS;
+ }
+
+ /** {@inheritdoc} */
+ public function getURI(){
+ return static::URI;
+ }
+
+ /** {@inheritdoc} */
+ public function getCacheTimeout(){
+ return static::CACHE_TIMEOUT;
+ }
+
+ /** {@inheritdoc} */
+ public function detectParameters($url){
+ $regex = '/^(https?:\/\/)?(www\.)?(.+?)(\/)?$/';
+ if(empty(static::PARAMETERS)
+ && preg_match($regex, $url, $urlMatches) > 0
+ && preg_match($regex, static::URI, $bridgeUriMatches) > 0
+ && $urlMatches[3] === $bridgeUriMatches[3]) {
+ return array();
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/lib/BridgeCard.php b/lib/BridgeCard.php
new file mode 100644
index 0000000..a3493b7
--- /dev/null
+++ b/lib/BridgeCard.php
@@ -0,0 +1,357 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * A generator class for a single bridge card on the home page of RSS-Bridge.
+ *
+ * This class generates the HTML content for a single bridge card for the home
+ * page of RSS-Bridge.
+ *
+ * @todo Return error if a caller creates an object of this class.
+ */
+final class BridgeCard {
+ /**
+ * Build a HTML document string of buttons for each of the provided formats
+ *
+ * @param array $formats A list of format names
+ * @return string The document string
+ */
+ private static function buildFormatButtons($formats) {
+ $buttons = '';
+
+ foreach($formats as $name) {
+ $buttons .= '<button type="submit" name="format" value="'
+ . $name
+ . '">'
+ . $name
+ . '</button>'
+ . PHP_EOL;
+ }
+
+ return $buttons;
+ }
+
+ /**
+ * Get the form header for a bridge card
+ *
+ * @param string $bridgeName The bridge name
+ * @param bool $isHttps If disabled, adds a warning to the form
+ * @return string The form header
+ */
+ private static function getFormHeader($bridgeName, $isHttps = false) {
+ $form = <<<EOD
+ <form method="GET" action="?">
+ <input type="hidden" name="action" value="display" />
+ <input type="hidden" name="bridge" value="{$bridgeName}" />
+EOD;
+
+ if(!$isHttps) {
+ $form .= '<div class="secure-warning">Warning :
+This bridge is not fetching its content through a secure connection</div>';
+ }
+
+ return $form;
+ }
+
+ /**
+ * Get the form body for a bridge
+ *
+ * @param string $bridgeName The bridge name
+ * @param array $formats A list of supported formats
+ * @param bool $isActive Indicates if a bridge is enabled or not
+ * @param bool $isHttps Indicates if a bridge uses HTTPS or not
+ * @param string $parameterName Sets the bridge context for the current form
+ * @param array $parameters The bridge parameters
+ * @return string The form body
+ */
+ private static function getForm($bridgeName,
+ $formats,
+ $isActive = false,
+ $isHttps = false,
+ $parameterName = '',
+ $parameters = array()) {
+ $form = self::getFormHeader($bridgeName, $isHttps);
+
+ if(count($parameters) > 0) {
+
+ $form .= '<div class="parameters">';
+
+ foreach($parameters as $id => $inputEntry) {
+ if(!isset($inputEntry['exampleValue']))
+ $inputEntry['exampleValue'] = '';
+
+ if(!isset($inputEntry['defaultValue']))
+ $inputEntry['defaultValue'] = '';
+
+ $idArg = 'arg-'
+ . urlencode($bridgeName)
+ . '-'
+ . urlencode($parameterName)
+ . '-'
+ . urlencode($id);
+
+ $form .= '<label for="'
+ . $idArg
+ . '">'
+ . filter_var($inputEntry['name'], FILTER_SANITIZE_STRING)
+ . '</label>'
+ . PHP_EOL;
+
+ if(!isset($inputEntry['type']) || $inputEntry['type'] === 'text') {
+ $form .= self::getTextInput($inputEntry, $idArg, $id);
+ } elseif($inputEntry['type'] === 'number') {
+ $form .= self::getNumberInput($inputEntry, $idArg, $id);
+ } else if($inputEntry['type'] === 'list') {
+ $form .= self::getListInput($inputEntry, $idArg, $id);
+ } elseif($inputEntry['type'] === 'checkbox') {
+ $form .= self::getCheckboxInput($inputEntry, $idArg, $id);
+ }
+ }
+
+ $form .= '</div>';
+
+ }
+
+ if($isActive) {
+ $form .= self::buildFormatButtons($formats);
+ } else {
+ $form .= '<span style="font-weight: bold;">Inactive</span>';
+ }
+
+ return $form . '</form>' . PHP_EOL;
+ }
+
+ /**
+ * Get input field attributes
+ *
+ * @param array $entry The current entry
+ * @return string The input field attributes
+ */
+ private static function getInputAttributes($entry) {
+ $retVal = '';
+
+ if(isset($entry['required']) && $entry['required'] === true)
+ $retVal .= ' required';
+
+ if(isset($entry['pattern']))
+ $retVal .= ' pattern="' . $entry['pattern'] . '"';
+
+ if(isset($entry['title']))
+ $retVal .= ' title="' . filter_var($entry['title'], FILTER_SANITIZE_STRING) . '"';
+
+ return $retVal;
+ }
+
+ /**
+ * Get text input
+ *
+ * @param array $entry The current entry
+ * @param string $id The field ID
+ * @param string $name The field name
+ * @return string The text input field
+ */
+ private static function getTextInput($entry, $id, $name) {
+ return '<input '
+ . self::getInputAttributes($entry)
+ . ' id="'
+ . $id
+ . '" type="text" value="'
+ . filter_var($entry['defaultValue'], FILTER_SANITIZE_STRING)
+ . '" placeholder="'
+ . filter_var($entry['exampleValue'], FILTER_SANITIZE_STRING)
+ . '" name="'
+ . $name
+ . '" />'
+ . PHP_EOL;
+ }
+
+ /**
+ * Get number input
+ *
+ * @param array $entry The current entry
+ * @param string $id The field ID
+ * @param string $name The field name
+ * @return string The number input field
+ */
+ private static function getNumberInput($entry, $id, $name) {
+ return '<input '
+ . self::getInputAttributes($entry)
+ . ' id="'
+ . $id
+ . '" type="number" value="'
+ . filter_var($entry['defaultValue'], FILTER_SANITIZE_NUMBER_INT)
+ . '" placeholder="'
+ . filter_var($entry['exampleValue'], FILTER_SANITIZE_NUMBER_INT)
+ . '" name="'
+ . $name
+ . '" />'
+ . PHP_EOL;
+ }
+
+ /**
+ * Get list input
+ *
+ * @param array $entry The current entry
+ * @param string $id The field ID
+ * @param string $name The field name
+ * @return string The list input field
+ */
+ private static function getListInput($entry, $id, $name) {
+ $list = '<select '
+ . self::getInputAttributes($entry)
+ . ' id="'
+ . $id
+ . '" name="'
+ . $name
+ . '" >';
+
+ foreach($entry['values'] as $name => $value) {
+ if(is_array($value)) {
+ $list .= '<optgroup label="' . htmlentities($name) . '">';
+ foreach($value as $subname => $subvalue) {
+ if($entry['defaultValue'] === $subname
+ || $entry['defaultValue'] === $subvalue) {
+ $list .= '<option value="'
+ . $subvalue
+ . '" selected>'
+ . $subname
+ . '</option>';
+ } else {
+ $list .= '<option value="'
+ . $subvalue
+ . '">'
+ . $subname
+ . '</option>';
+ }
+ }
+ $list .= '</optgroup>';
+ } else {
+ if($entry['defaultValue'] === $name
+ || $entry['defaultValue'] === $value) {
+ $list .= '<option value="'
+ . $value
+ . '" selected>'
+ . $name
+ . '</option>';
+ } else {
+ $list .= '<option value="'
+ . $value
+ . '">'
+ . $name
+ . '</option>';
+ }
+ }
+ }
+
+ $list .= '</select>';
+
+ return $list;
+ }
+
+ /**
+ * Get checkbox input
+ *
+ * @param array $entry The current entry
+ * @param string $id The field ID
+ * @param string $name The field name
+ * @return string The checkbox input field
+ */
+ private static function getCheckboxInput($entry, $id, $name) {
+ return '<input '
+ . self::getInputAttributes($entry)
+ . ' id="'
+ . $id
+ . '" type="checkbox" name="'
+ . $name
+ . '" '
+ . ($entry['defaultValue'] === 'checked' ? 'checked' : '')
+ . ' />'
+ . PHP_EOL;
+ }
+
+ /**
+ * Gets a single bridge card
+ *
+ * @param string $bridgeName The bridge name
+ * @param array $formats A list of formats
+ * @param bool $isActive Indicates if the bridge is active or not
+ * @return string The bridge card
+ */
+ static function displayBridgeCard($bridgeName, $formats, $isActive = true){
+
+ $bridge = Bridge::create($bridgeName);
+
+ if($bridge == false)
+ return '';
+
+ $isHttps = strpos($bridge->getURI(), 'https') === 0;
+
+ $uri = $bridge->getURI();
+ $name = $bridge->getName();
+ $icon = $bridge->getIcon();
+ $description = $bridge->getDescription();
+ $parameters = $bridge->getParameters();
+
+ if(defined('PROXY_URL') && PROXY_BYBRIDGE) {
+ $parameters['global']['_noproxy'] = array(
+ 'name' => 'Disable proxy (' . ((defined('PROXY_NAME') && PROXY_NAME) ? PROXY_NAME : PROXY_URL) . ')',
+ 'type' => 'checkbox'
+ );
+ }
+
+ if(CUSTOM_CACHE_TIMEOUT) {
+ $parameters['global']['_cache_timeout'] = array(
+ 'name' => 'Cache timeout in seconds',
+ 'type' => 'number',
+ 'defaultValue' => $bridge->getCacheTimeout()
+ );
+ }
+
+ $card = <<<CARD
+ <section id="bridge-{$bridgeName}" data-ref="{$bridgeName}">
+ <h2><a href="{$uri}">{$name}</a></h2>
+ <p class="description">{$description}</p>
+ <input type="checkbox" class="showmore-box" id="showmore-{$bridgeName}" />
+ <label class="showmore" for="showmore-{$bridgeName}">Show more</label>
+CARD;
+
+ // If we don't have any parameter for the bridge, we print a generic form to load it.
+ if(count($parameters) === 0
+ || count($parameters) === 1 && array_key_exists('global', $parameters)) {
+
+ $card .= self::getForm($bridgeName, $formats, $isActive, $isHttps);
+
+ } else {
+
+ foreach($parameters as $parameterName => $parameter) {
+ if(!is_numeric($parameterName) && $parameterName === 'global')
+ continue;
+
+ if(array_key_exists('global', $parameters))
+ $parameter = array_merge($parameter, $parameters['global']);
+
+ if(!is_numeric($parameterName))
+ $card .= '<h5>' . $parameterName . '</h5>' . PHP_EOL;
+
+ $card .= self::getForm($bridgeName, $formats, $isActive, $isHttps, $parameterName, $parameter);
+ }
+
+ }
+
+ $card .= '<label class="showless" for="showmore-' . $bridgeName . '">Show less</label>';
+ $card .= '<p class="maintainer">' . $bridge->getMaintainer() . '</p>';
+ $card .= '</section>';
+
+ return $card;
+ }
+}
diff --git a/lib/BridgeInterface.php b/lib/BridgeInterface.php
new file mode 100644
index 0000000..d006918
--- /dev/null
+++ b/lib/BridgeInterface.php
@@ -0,0 +1,124 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * The bridge interface
+ *
+ * A bridge is a class that is responsible for collecting and transforming data
+ * from one hosting provider into an internal representation of feed data, that
+ * can later be transformed into different feed formats (see {@see FormatInterface}).
+ *
+ * For this purpose, all bridges need to perform three common operations:
+ *
+ * 1. Collect data from a remote site.
+ * 2. Extract the required contents.
+ * 3. Add the contents to the internal data structure.
+ *
+ * Bridges can optionally specify parameters to customize bridge behavior based
+ * on user input. For example, a user could specify how many items to return in
+ * the feed and where to get them.
+ *
+ * In order to present a bridge on the home page, and for the purpose of bridge
+ * specific behaviour, additional information must be provided by the bridge:
+ *
+ * * **Name**
+ * The name of the bridge that can be displayed to users.
+ *
+ * * **Description**
+ * A brief description for the bridge that can be displayed to users.
+ *
+ * * **URI**
+ * A link to the hosting provider.
+ *
+ * * **Maintainer**
+ * The GitHub username of the bridge maintainer
+ *
+ * * **Parameters**
+ * A list of parameters for customization
+ *
+ * * **Icon**
+ * A link to the favicon of the hosting provider
+ *
+ * * **Cache timeout**
+ * The default cache timeout for the bridge.
+ */
+interface BridgeInterface {
+ /**
+ * Collects data from the site
+ */
+ public function collectData();
+
+ /**
+ * Returns the description
+ *
+ * @return string Description
+ */
+ public function getDescription();
+
+ /**
+ * Returns an array of collected items
+ *
+ * @return array Associative array of items
+ */
+ public function getItems();
+
+ /**
+ * Returns the bridge maintainer
+ *
+ * @return string Bridge maintainer
+ */
+ public function getMaintainer();
+
+ /**
+ * Returns the bridge name
+ *
+ * @return string Bridge name
+ */
+ public function getName();
+
+ /**
+ * Returns the bridge icon
+ *
+ * @return string Bridge icon
+ */
+ public function getIcon();
+
+ /**
+ * Returns the bridge parameters
+ *
+ * @return array Bridge parameters
+ */
+ public function getParameters();
+
+ /**
+ * Returns the bridge URI
+ *
+ * @return string Bridge URI
+ */
+ public function getURI();
+
+ /**
+ * Returns the cache timeout
+ *
+ * @return int Cache timeout
+ */
+ public function getCacheTimeout();
+
+ /**
+ * Returns parameters from given URL or null if URL is not applicable
+ *
+ * @param string $url URL to extract parameters from
+ * @return array|null List of bridge parameters or null if detection failed.
+ */
+ public function detectParameters($url);
+}
diff --git a/lib/BridgeList.php b/lib/BridgeList.php
new file mode 100644
index 0000000..d79d72f
--- /dev/null
+++ b/lib/BridgeList.php
@@ -0,0 +1,209 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * A generator class for the home page of RSS-Bridge.
+ *
+ * This class generates the HTML content for displaying all bridges on the home
+ * page of RSS-Bridge.
+ *
+ * @todo Return error if a caller creates an object of this class.
+ */
+final class BridgeList {
+ /**
+ * Get the document head
+ *
+ * @return string The document head
+ */
+ private static function getHead() {
+ return <<<EOD
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <meta name="description" content="RSS-Bridge" />
+ <title>RSS-Bridge</title>
+ <link href="static/style.css" rel="stylesheet">
+ <script src="static/search.js"></script>
+ <script src="static/select.js"></script>
+ <noscript>
+ <style>
+ .searchbar {
+ display: none;
+ }
+ </style>
+ </noscript>
+</head>
+EOD;
+ }
+
+ /**
+ * Get the document body for all bridge cards
+ *
+ * @param bool $showInactive Inactive bridges are visible on the home page if
+ * enabled.
+ * @param int $totalBridges (ref) Returns the total number of bridges.
+ * @param int $totalActiveBridges (ref) Returns the number of active bridges.
+ * @return string The document body for all bridge cards.
+ */
+ private static function getBridges($showInactive, &$totalBridges, &$totalActiveBridges) {
+
+ $body = '';
+ $totalActiveBridges = 0;
+ $inactiveBridges = '';
+
+ $bridgeList = Bridge::getBridgeNames();
+ $formats = Format::getFormatNames();
+
+ $totalBridges = count($bridgeList);
+
+ foreach($bridgeList as $bridgeName) {
+
+ if(Bridge::isWhitelisted($bridgeName)) {
+
+ $body .= BridgeCard::displayBridgeCard($bridgeName, $formats);
+ $totalActiveBridges++;
+
+ } elseif($showInactive) {
+
+ // inactive bridges
+ $inactiveBridges .= BridgeCard::displayBridgeCard($bridgeName, $formats, false) . PHP_EOL;
+
+ }
+
+ }
+
+ $body .= $inactiveBridges;
+
+ return $body;
+ }
+
+ /**
+ * Get the document header
+ *
+ * @return string The document header
+ */
+ private static function getHeader() {
+ $warning = '';
+
+ if(Debug::isEnabled()) {
+ if(!Debug::isSecure()) {
+ $warning .= <<<EOD
+<section class="critical-warning">Warning : Debug mode is active from any location,
+ make sure only you can access RSS-Bridge.</section>
+EOD;
+ } else {
+ $warning .= <<<EOD
+<section class="warning">Warning : Debug mode is active from your IP address,
+ your requests will bypass the cache.</section>
+EOD;
+ }
+ }
+
+ return <<<EOD
+<header>
+ <h1>RSS-Bridge</h1>
+ <h2>Reconnecting the Web</h2>
+ {$warning}
+</header>
+EOD;
+ }
+
+ /**
+ * Get the searchbar
+ *
+ * @return string The searchbar
+ */
+ private static function getSearchbar() {
+ $query = filter_input(INPUT_GET, 'q');
+
+ return <<<EOD
+<section class="searchbar">
+ <h3>Search</h3>
+ <input type="text" name="searchfield"
+ id="searchfield" placeholder="Enter the bridge you want to search for"
+ onchange="search()" onkeyup="search()" value="{$query}">
+</section>
+EOD;
+ }
+
+ /**
+ * Get the document footer
+ *
+ * @param int $totalBridges The total number of bridges, shown in the footer
+ * @param int $totalActiveBridges The total number of active bridges, shown
+ * in the footer.
+ * @param bool $showInactive Sets the 'Show active'/'Show inactive' text in
+ * the footer.
+ * @return string The document footer
+ */
+ private static function getFooter($totalBridges, $totalActiveBridges, $showInactive) {
+ $version = Configuration::getVersion();
+
+ $email = Configuration::getConfig('admin', 'email');
+ $admininfo = '';
+ if (!empty($email)) {
+ $admininfo = <<<EOD
+<br />
+<span>
+ You may email the administrator of this RSS-Bridge instance
+ at <a href="mailto:{$email}">{$email}</a>
+</span>
+EOD;
+ }
+
+ $inactive = '';
+
+ if($totalActiveBridges !== $totalBridges) {
+
+ if(!$showInactive) {
+ $inactive = '<a href="?show_inactive=1"><button class="small">Show inactive bridges</button></a><br>';
+ } else {
+ $inactive = '<a href="?show_inactive=0"><button class="small">Hide inactive bridges</button></a><br>';
+ }
+
+ }
+
+ return <<<EOD
+<section class="footer">
+ <a href="https://github.com/rss-bridge/rss-bridge">RSS-Bridge ~ Public Domain</a><br>
+ <p class="version">{$version}</p>
+ {$totalActiveBridges}/{$totalBridges} active bridges.<br>
+ {$inactive}
+ {$admininfo}
+</section>
+EOD;
+ }
+
+ /**
+ * Create the entire home page
+ *
+ * @param bool $showInactive Inactive bridges are displayed on the home page,
+ * if enabled.
+ * @return string The home page
+ */
+ static function create($showInactive = true) {
+
+ $totalBridges = 0;
+ $totalActiveBridges = 0;
+
+ return '<!DOCTYPE html><html lang="en">'
+ . BridgeList::getHead()
+ . '<body onload="search()">'
+ . BridgeList::getHeader()
+ . BridgeList::getSearchbar()
+ . BridgeList::getBridges($showInactive, $totalBridges, $totalActiveBridges)
+ . BridgeList::getFooter($totalBridges, $totalActiveBridges, $showInactive)
+ . '</body></html>';
+
+ }
+}
diff --git a/lib/Cache.php b/lib/Cache.php
new file mode 100644
index 0000000..a0d2ac7
--- /dev/null
+++ b/lib/Cache.php
@@ -0,0 +1,140 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Factory class responsible for creating cache objects from a given working
+ * directory.
+ *
+ * This class is capable of:
+ * - Locating cache classes in the specified working directory (see {@see Cache::$workingDir})
+ * - Creating new cache instances based on the cache's name (see {@see Cache::create()})
+ *
+ * The following example illustrates the intended use for this class.
+ *
+ * ```PHP
+ * require_once __DIR__ . '/rssbridge.php';
+ *
+ * // Step 1: Set the working directory
+ * Cache::setWorkingDir(__DIR__ . '/../caches/');
+ *
+ * // Step 2: Create a new instance of a cache object (based on the name)
+ * $cache = Cache::create('FileCache');
+ * ```
+ */
+class Cache {
+
+ /**
+ * Holds a path to the working directory.
+ *
+ * Do not access this property directly!
+ * Use {@see Cache::setWorkingDir()} and {@see Cache::getWorkingDir()} instead.
+ *
+ * @var string|null
+ */
+ protected static $workingDir = null;
+
+ /**
+ * Throws an exception when trying to create a new instance of this class.
+ * Use {@see Cache::create()} to create a new cache object from the working
+ * directory.
+ *
+ * @throws \LogicException if called.
+ */
+ public function __construct(){
+ throw new \LogicException('Use ' . __CLASS__ . '::create($name) to create cache objects!');
+ }
+
+ /**
+ * Creates a new cache object from the working directory.
+ *
+ * @throws \InvalidArgumentException if the requested cache name is invalid.
+ * @throws \Exception if the requested cache file doesn't exist in the
+ * working directory.
+ * @param string $name Name of the cache object.
+ * @return object|bool The cache object or false if the class is not instantiable.
+ */
+ public static function create($name){
+ if(!self::isCacheName($name)) {
+ throw new \InvalidArgumentException('Cache name invalid!');
+ }
+
+ $filePath = self::getWorkingDir() . $name . '.php';
+
+ if(!file_exists($filePath)) {
+ throw new \Exception('Cache file ' . $filePath . ' does not exist!');
+ }
+
+ require_once $filePath;
+
+ if((new \ReflectionClass($name))->isInstantiable()) {
+ return new $name();
+ }
+
+ return false;
+ }
+
+ /**
+ * Sets the working directory.
+ *
+ * @param string $dir Path to a directory containing cache classes
+ * @throws \InvalidArgumentException if $dir is not a string.
+ * @throws \Exception if the working directory doesn't exist.
+ * @throws \InvalidArgumentException if $dir is not a directory.
+ * @return void
+ */
+ public static function setWorkingDir($dir){
+ self::$workingDir = null;
+
+ if(!is_string($dir)) {
+ throw new \InvalidArgumentException('Working directory is not a valid string!');
+ }
+
+ if(!file_exists($dir)) {
+ throw new \Exception('Working directory does not exist!');
+ }
+
+ if(!is_dir($dir)) {
+ throw new \InvalidArgumentException('Working directory is not a directory!');
+ }
+
+ self::$workingDir = realpath($dir) . '/';
+ }
+
+ /**
+ * Returns the working directory.
+ * The working directory must be set with {@see Cache::setWorkingDir()}!
+ *
+ * @throws \LogicException if the working directory is not set.
+ * @return string The current working directory.
+ */
+ public static function getWorkingDir(){
+ if(is_null(self::$workingDir)) {
+ throw new \LogicException('Working directory is not set!');
+ }
+
+ return self::$workingDir;
+ }
+
+ /**
+ * Returns true if the provided name is a valid cache name.
+ *
+ * A valid cache name starts with a capital letter ([A-Z]), followed by
+ * zero or more alphanumeric characters or hyphen ([A-Za-z0-9-]).
+ *
+ * @param string $name The cache name.
+ * @return bool true if the name is a valid cache name, false otherwise.
+ */
+ public static function isCacheName($name){
+ return is_string($name) && preg_match('/^[A-Z][a-zA-Z0-9-]*$/', $name) === 1;
+ }
+}
diff --git a/lib/CacheInterface.php b/lib/CacheInterface.php
new file mode 100644
index 0000000..a74fc0d
--- /dev/null
+++ b/lib/CacheInterface.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * The cache interface
+ *
+ * @todo Add missing function to the interface
+ * @todo Explain parameters and return values in more detail
+ * @todo Return self more often (to allow call chaining)
+ */
+interface CacheInterface {
+ /**
+ * Loads data from cache
+ *
+ * @return mixed The cache data
+ */
+ public function loadData();
+
+ /**
+ * Stores data to the cache
+ *
+ * @param mixed $datas The data to store
+ * @return self The cache object
+ */
+ public function saveData($datas);
+
+ /**
+ * Returns the timestamp for the curent cache file
+ *
+ * @return int Timestamp
+ */
+ public function getTime();
+
+ /**
+ * Removes any data that is older than the specified duration from cache
+ *
+ * @param int $duration The cache duration in seconds
+ */
+ public function purgeCache($duration);
+}
diff --git a/lib/Configuration.php b/lib/Configuration.php
new file mode 100644
index 0000000..64d9dde
--- /dev/null
+++ b/lib/Configuration.php
@@ -0,0 +1,246 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Configuration module for RSS-Bridge.
+ *
+ * This class implements a configuration module for RSS-Bridge.
+ */
+final class Configuration {
+
+ /**
+ * Holds the current release version of RSS-Bridge.
+ *
+ * Do not access this property directly!
+ * Use {@see Configuration::getVersion()} instead.
+ *
+ * @var string
+ *
+ * @todo Replace this property by a constant.
+ */
+ public static $VERSION = '2019-01-13';
+
+ /**
+ * Holds the configuration data.
+ *
+ * Do not access this property directly!
+ * Use {@see Configuration::getConfig()} instead.
+ *
+ * @var array|null
+ */
+ private static $config = null;
+
+ /**
+ * Throw an exception when trying to create a new instance of this class.
+ *
+ * @throws \LogicException if called.
+ */
+ public function __construct(){
+ throw new \LogicException('Can\'t create object of this class!');
+ }
+
+ /**
+ * Verifies the current installation of RSS-Bridge and PHP.
+ *
+ * Returns an error message and aborts execution if the installation does
+ * not satisfy the requirements of RSS-Bridge.
+ *
+ * **Requirements**
+ * - PHP 5.6.0 or higher
+ * - `openssl` extension
+ * - `libxml` extension
+ * - `mbstring` extension
+ * - `simplexml` extension
+ * - `curl` extension
+ * - `json` extension
+ * - The cache folder specified by {@see PATH_CACHE} requires write permission
+ * - The whitelist file specified by {@see WHITELIST} requires write permission
+ *
+ * @link http://php.net/supported-versions.php PHP Supported Versions
+ * @link http://php.net/manual/en/book.openssl.php OpenSSL
+ * @link http://php.net/manual/en/book.libxml.php libxml
+ * @link http://php.net/manual/en/book.mbstring.php Multibyte String (mbstring)
+ * @link http://php.net/manual/en/book.simplexml.php SimpleXML
+ * @link http://php.net/manual/en/book.curl.php Client URL Library (curl)
+ * @link http://php.net/manual/en/book.json.php JavaScript Object Notation (json)
+ *
+ * @return void
+ */
+ public static function verifyInstallation() {
+
+ // Check PHP version
+ if(version_compare(PHP_VERSION, '5.6.0') === -1)
+ die('RSS-Bridge requires at least PHP version 5.6.0!');
+
+ // extensions check
+ if(!extension_loaded('openssl'))
+ die('"openssl" extension not loaded. Please check "php.ini"');
+
+ if(!extension_loaded('libxml'))
+ die('"libxml" extension not loaded. Please check "php.ini"');
+
+ if(!extension_loaded('mbstring'))
+ die('"mbstring" extension not loaded. Please check "php.ini"');
+
+ if(!extension_loaded('simplexml'))
+ die('"simplexml" extension not loaded. Please check "php.ini"');
+
+ // Allow RSS-Bridge to run without curl module in CLI mode without root certificates
+ if(!extension_loaded('curl') && !(php_sapi_name() === 'cli' && empty(ini_get('curl.cainfo'))))
+ die('"curl" extension not loaded. Please check "php.ini"');
+
+ if(!extension_loaded('json'))
+ die('"json" extension not loaded. Please check "php.ini"');
+
+ // Check cache folder permissions (write permissions required)
+ if(!is_writable(PATH_CACHE))
+ die('RSS-Bridge does not have write permissions for ' . PATH_CACHE . '!');
+
+ // Check whitelist file permissions
+ if(!file_exists(WHITELIST) && !is_writable(dirname(WHITELIST)))
+ die('RSS-Bridge does not have write permissions for ' . WHITELIST . '!');
+
+ }
+
+ /**
+ * Loads the configuration from disk and checks if the parameters are valid.
+ *
+ * Returns an error message and aborts execution if the configuration is invalid.
+ *
+ * The RSS-Bridge configuration is split into two files:
+ * - `config.default.ini.php`: The default configuration file that ships with
+ * every release of RSS-Bridge (do not modify this file!).
+ * - `config.ini.php`: The local configuration file that can be modified by
+ * server administrators.
+ *
+ * The files must be located at {@see PATH_ROOT}
+ *
+ * RSS-Bridge will first load `config.default.ini.php` into memory and then
+ * replace parameters with the contents of `config.ini.php`. That way new
+ * parameters are automatically initialized with default values and custom
+ * configurations can be reduced to the minimum set of parametes necessary
+ * (only the ones that changed).
+ *
+ * The configuration files must be placed in the root folder of RSS-Bridge
+ * (next to `index.php`).
+ *
+ * _Notice_: The configuration is stored in {@see Configuration::$config}.
+ *
+ * @return void
+ */
+ public static function loadConfiguration() {
+
+ if(!file_exists(PATH_ROOT . 'config.default.ini.php'))
+ die('The default configuration file "config.default.ini.php" is missing!');
+
+ Configuration::$config = parse_ini_file(PATH_ROOT . 'config.default.ini.php', true, INI_SCANNER_TYPED);
+ if(!Configuration::$config)
+ die('Error parsing config.default.ini.php');
+
+ if(file_exists(PATH_ROOT . 'config.ini.php')) {
+ // Replace default configuration with custom settings
+ foreach(parse_ini_file(PATH_ROOT . 'config.ini.php', true, INI_SCANNER_TYPED) as $header => $section) {
+ foreach($section as $key => $value) {
+ // Skip unknown sections and keys
+ if(array_key_exists($header, Configuration::$config) && array_key_exists($key, Configuration::$config[$header])) {
+ Configuration::$config[$header][$key] = $value;
+ }
+ }
+ }
+ }
+
+ if(!is_string(self::getConfig('proxy', 'url')))
+ die('Parameter [proxy] => "url" is not a valid string! Please check "config.ini.php"!');
+
+ if(!empty(self::getConfig('proxy', 'url'))) {
+ /** URL of the proxy server */
+ define('PROXY_URL', self::getConfig('proxy', 'url'));
+ }
+
+ if(!is_bool(self::getConfig('proxy', 'by_bridge')))
+ die('Parameter [proxy] => "by_bridge" is not a valid Boolean! Please check "config.ini.php"!');
+
+ /** True if proxy usage can be enabled selectively for each bridge */
+ define('PROXY_BYBRIDGE', self::getConfig('proxy', 'by_bridge'));
+
+ if(!is_string(self::getConfig('proxy', 'name')))
+ die('Parameter [proxy] => "name" is not a valid string! Please check "config.ini.php"!');
+
+ /** Name of the proxy server */
+ define('PROXY_NAME', self::getConfig('proxy', 'name'));
+
+ if(!is_bool(self::getConfig('cache', 'custom_timeout')))
+ die('Parameter [cache] => "custom_timeout" is not a valid Boolean! Please check "config.ini.php"!');
+
+ /** True if the cache timeout can be specified by the user */
+ define('CUSTOM_CACHE_TIMEOUT', self::getConfig('cache', 'custom_timeout'));
+
+ if(!is_bool(self::getConfig('authentication', 'enable')))
+ die('Parameter [authentication] => "enable" is not a valid Boolean! Please check "config.ini.php"!');
+
+ if(!is_string(self::getConfig('authentication', 'username')))
+ die('Parameter [authentication] => "username" is not a valid string! Please check "config.ini.php"!');
+
+ if(!is_string(self::getConfig('authentication', 'password')))
+ die('Parameter [authentication] => "password" is not a valid string! Please check "config.ini.php"!');
+
+ if(!empty(self::getConfig('admin', 'email'))
+ && !filter_var(self::getConfig('admin', 'email'), FILTER_VALIDATE_EMAIL))
+ die('Parameter [admin] => "email" is not a valid email address! Please check "config.ini.php"!');
+
+ }
+
+ /**
+ * Returns the value of a parameter identified by section and key.
+ *
+ * @param string $section The section name.
+ * @param string $key The property name (key).
+ * @return mixed|null The parameter value.
+ */
+ public static function getConfig($section, $key) {
+
+ if(array_key_exists($section, self::$config) && array_key_exists($key, self::$config[$section])) {
+ return self::$config[$section][$key];
+ }
+
+ return null;
+
+ }
+
+ /**
+ * Returns the current version string of RSS-Bridge.
+ *
+ * This function returns the contents of {@see Configuration::$VERSION} for
+ * regular installations and the git branch name and commit id for instances
+ * running in a git environment.
+ *
+ * @return string The version string.
+ */
+ public static function getVersion() {
+
+ $headFile = PATH_ROOT . '.git/HEAD';
+
+ // '@' is used to mute open_basedir warning
+ if(@is_readable($headFile)) {
+
+ $revisionHashFile = '.git/' . substr(file_get_contents($headFile), 5, -1);
+ $branchName = explode('/', $revisionHashFile)[3];
+ if(file_exists($revisionHashFile)) {
+ return 'git.' . $branchName . '.' . substr(file_get_contents($revisionHashFile), 0, 7);
+ }
+ }
+
+ return Configuration::$VERSION;
+
+ }
+}
diff --git a/lib/Debug.php b/lib/Debug.php
new file mode 100644
index 0000000..f912fb3
--- /dev/null
+++ b/lib/Debug.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Implements functions for debugging purposes. Debugging can be enabled by
+ * placing a file named DEBUG in {@see PATH_ROOT}.
+ *
+ * The file specifies a whitelist of IP addresses on which debug mode will be
+ * enabled. An empty file enables debug mode for everyone (highly discouraged
+ * for public servers!). Each line in the file specifies one client in the
+ * whitelist. For example:
+ *
+ * * `192.168.1.72`
+ * * `127.0.0.1`
+ * * `::1`
+ *
+ * Notice: If you are running RSS-Bridge on your local machine, you need to add
+ * localhost (either `127.0.0.1` for IPv4 or `::1` for IPv6) to your whitelist!
+ *
+ * Warning: In debug mode your server may display sensitive information! For
+ * security reasons it is recommended to whitelist only specific IP addresses.
+ */
+class Debug {
+
+ /**
+ * Indicates if debug mode is enabled.
+ *
+ * Do not access this property directly!
+ * Use {@see Debug::isEnabled()} instead.
+ *
+ * @var bool
+ */
+ private static $enabled = false;
+
+ /**
+ * Indicates if debug mode is secure.
+ *
+ * Do not access this property directly!
+ * Use {@see Debug::isSecure()} instead.
+ *
+ * @var bool
+ */
+ private static $secure = false;
+
+ /**
+ * Returns true if debug mode is enabled
+ *
+ * If debug mode is enabled, sets `display_errors = 1` and `error_reporting = E_ALL`
+ *
+ * @return bool True if enabled.
+ */
+ public static function isEnabled() {
+ static $firstCall = true; // Initialized on first call
+
+ if($firstCall && file_exists(PATH_ROOT . 'DEBUG')) {
+
+ $debug_whitelist = trim(file_get_contents(PATH_ROOT . 'DEBUG'));
+
+ self::$enabled = empty($debug_whitelist) || in_array($_SERVER['REMOTE_ADDR'],
+ explode("\n", str_replace("\r", '', $debug_whitelist)
+ )
+ );
+
+ if(self::$enabled) {
+ ini_set('display_errors', '1');
+ error_reporting(E_ALL);
+
+ self::$secure = !empty($debug_whitelist);
+ }
+
+ $firstCall = false; // Skip check on next call
+
+ }
+
+ return self::$enabled;
+ }
+
+ /**
+ * Returns true if debug mode is enabled only for specific IP addresses.
+ *
+ * Notice: The security flag is set by {@see Debug::isEnabled()}. If this
+ * function is called before {@see Debug::isEnabled()}, the default value is
+ * false!
+ *
+ * @return bool True if debug mode is secure
+ */
+ public static function isSecure() {
+ return self::$secure;
+ }
+
+ /**
+ * Adds a debug message to error_log if debug mode is enabled
+ *
+ * @param string $text The message to add to error_log
+ */
+ public static function log($text) {
+ if(!self::isEnabled()) {
+ return;
+ }
+
+ $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
+ $calling = end($backtrace);
+ $message = $calling['file'] . ':'
+ . $calling['line'] . ' class '
+ . (isset($calling['class']) ? $calling['class'] : '<no-class>') . '->'
+ . $calling['function'] . ' - '
+ . $text;
+
+ error_log($message);
+ }
+}
diff --git a/lib/Exceptions.php b/lib/Exceptions.php
new file mode 100644
index 0000000..ac452d0
--- /dev/null
+++ b/lib/Exceptions.php
@@ -0,0 +1,203 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Returns an URL that automatically populates a new issue on GitHub based
+ * on the information provided
+ *
+ * @param string $title string Sets the title of the issue
+ * @param string $body string Sets the body of the issue (GitHub markdown applies)
+ * @param string $labels mixed (optional) Specifies labels to add to the issue
+ * @param string $maintainer string (optional) Specifies the maintainer for the issue.
+ * The maintainer only applies if part of the development team!
+ * @return string|null A qualified URL to a new issue with populated conent or null.
+ *
+ * @todo This function belongs inside a class
+ */
+function buildGitHubIssueQuery($title, $body, $labels = null, $maintainer = null){
+ if(!isset($title) || !isset($body) || empty($title) || empty($body)) {
+ return null;
+ }
+
+ // Add title and body
+ $uri = REPOSITORY
+ . 'issues/new?title='
+ . urlencode($title)
+ . '&body='
+ . urlencode($body);
+
+ // Add labels
+ if(!is_null($labels) && is_array($labels) && count($labels) > 0) {
+ if(count($lables) === 1) {
+ $uri .= '&labels=' . urlencode($labels[0]);
+ } else {
+ foreach($labels as $label) {
+ $uri .= '&labels[]=' . urlencode($label);
+ }
+ }
+ } elseif(!is_null($labels) && is_string($labels)) {
+ $uri .= '&labels=' . urlencode($labels);
+ }
+
+ // Add maintainer
+ if(!empty($maintainer)) {
+ $uri .= '&assignee=' . urlencode($maintainer);
+ }
+
+ return $uri;
+}
+
+/**
+ * Returns the exception message as HTML string
+ *
+ * @param object $e Exception The exception to show
+ * @param object $bridge object The bridge object
+ * @return string|null Returns the exception as HTML string or null.
+ *
+ * @todo This function belongs inside a class
+ */
+function buildBridgeException($e, $bridge){
+ if(( !($e instanceof \Exception) && !($e instanceof \Error)) || !($bridge instanceof \BridgeInterface)) {
+ return null;
+ }
+
+ $title = $bridge->getName() . ' failed with error ' . $e->getCode();
+
+ // Build a GitHub compatible message
+ $body = 'Error message: `'
+ . $e->getMessage()
+ . "`\nQuery string: `"
+ . (isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '')
+ . "`\nVersion: `"
+ . Configuration::getVersion()
+ . '`';
+
+ $body_html = nl2br($body);
+ $link = buildGitHubIssueQuery($title, $body, 'bug report', $bridge->getMaintainer());
+
+ $header = buildHeader($e, $bridge);
+ $message = <<<EOD
+<strong>{$bridge->getName()}</strong> was unable to receive or process the
+remote website's content!<br>
+{$body_html}
+EOD;
+ $section = buildSection($e, $bridge, $message, $link);
+
+ return $section;
+}
+
+/**
+ * Returns the exception message as HTML string
+ *
+ * @param object $e Exception The exception to show
+ * @param object $bridge object The bridge object
+ * @return string|null Returns the exception as HTML string or null.
+ *
+ * @todo This function belongs inside a class
+ */
+function buildTransformException($e, $bridge){
+ if(( !($e instanceof \Exception) && !($e instanceof \Error)) || !($bridge instanceof \BridgeInterface)) {
+ return null;
+ }
+
+ $title = $bridge->getName() . ' failed with error ' . $e->getCode();
+
+ // Build a GitHub compatible message
+ $body = 'Error message: `'
+ . $e->getMessage()
+ . "`\nQuery string: `"
+ . (isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '')
+ . '`';
+
+ $link = buildGitHubIssueQuery($title, $body, 'bug report', $bridge->getMaintainer());
+ $header = buildHeader($e, $bridge);
+ $message = "RSS-Bridge was unable to transform the contents returned by
+<strong>{$bridge->getName()}</strong>!";
+ $section = buildSection($e, $bridge, $message, $link);
+
+ return buildPage($title, $header, $section);
+}
+
+/**
+ * Builds a new HTML header with data from a exception an a bridge
+ *
+ * @param object $e The exception object
+ * @param object $bridge The bridge object
+ * @return string The HTML header
+ *
+ * @todo This function belongs inside a class
+ */
+function buildHeader($e, $bridge){
+ return <<<EOD
+<header>
+ <h1>Error {$e->getCode()}</h1>
+ <h2>{$e->getMessage()}</h2>
+ <p class="status">{$bridge->getName()}</p>
+</header>
+EOD;
+}
+
+/**
+ * Builds a new HTML section
+ *
+ * @param object $e The exception object
+ * @param object $bridge The bridge object
+ * @param string $message The message to display
+ * @param string $link The link to include in the anchor
+ * @return string The HTML section
+ *
+ * @todo This function belongs inside a class
+ */
+function buildSection($e, $bridge, $message, $link){
+ return <<<EOD
+<section>
+ <p class="exception-message">{$message}</p>
+ <div class="advice">
+ <ul class="advice">
+ <li>Press Return to check your input parameters</li>
+ <li>Press F5 to retry</li>
+ <li>Open a <a href="{$link}">GitHub Issue</a> if this error persists</li>
+ </ul>
+ </div>
+ <a href="{$link}" title="After clicking this button you can review
+ the issue before submitting it"><button>Open GitHub Issue</button></a>
+ <p class="maintainer">{$bridge->getMaintainer()}</p>
+</section>
+EOD;
+}
+
+/**
+ * Builds a new HTML page
+ *
+ * @param string $title The HTML title
+ * @param string $header The HTML header
+ * @param string $section The HTML section
+ * @return string The HTML page
+ *
+ * @todo This function belongs inside a class
+ */
+function buildPage($title, $header, $section){
+ return <<<EOD
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <title>{$title}</title>
+ <link href="static/style.css" rel="stylesheet">
+</head>
+<body>
+ {$header}
+ {$section}
+</body>
+</html>
+EOD;
+}
diff --git a/lib/FeedExpander.php b/lib/FeedExpander.php
new file mode 100644
index 0000000..b669351
--- /dev/null
+++ b/lib/FeedExpander.php
@@ -0,0 +1,418 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * An abstract class for bridges that need to transform existing RSS or Atom
+ * feeds.
+ *
+ * This class extends {@see BridgeAbstract} with functions to extract contents
+ * from existing RSS or Atom feeds. Bridges that need to transform existing feeds
+ * should inherit from this class instead of {@see BridgeAbstract}.
+ *
+ * Bridges that extend this class don't need to concern themselves with getting
+ * contents from existing feeds, but can focus on adding additional contents
+ * (i.e. by downloading additional data), filtering or just transforming a feed
+ * into another format.
+ *
+ * @link http://www.rssboard.org/rss-0-9-1 RSS 0.91 Specification
+ * @link http://web.resource.org/rss/1.0/spec RDF Site Summary (RSS) 1.0
+ * @link http://www.rssboard.org/rss-specification RSS 2.0 Specification
+ * @link https://tools.ietf.org/html/rfc4287 The Atom Syndication Format
+ *
+ * @todo The parsing functions should all be private. This class is complicated
+ * enough without having to consider children overriding functions.
+ */
+abstract class FeedExpander extends BridgeAbstract {
+
+ /** Indicates an RSS 1.0 feed */
+ const FEED_TYPE_RSS_1_0 = 'RSS_1_0';
+
+ /** Indicates an RSS 2.0 feed */
+ const FEED_TYPE_RSS_2_0 = 'RSS_2_0';
+
+ /** Indicates an Atom 1.0 feed */
+ const FEED_TYPE_ATOM_1_0 = 'ATOM_1_0';
+
+ /**
+ * Holds the title of the current feed
+ *
+ * @var string
+ */
+ private $title;
+
+ /**
+ * Holds the URI of the feed
+ *
+ * @var string
+ */
+ private $uri;
+
+ /**
+ * Holds the feed type during internal operations.
+ *
+ * @var string
+ */
+ private $feedType;
+
+ /**
+ * Collects data from an existing feed.
+ *
+ * Children should call this function in {@see BridgeInterface::collectData()}
+ * to extract a feed.
+ *
+ * @param string $url URL to the feed.
+ * @param int $maxItems Maximum number of items to collect from the feed
+ * (`-1`: no limit).
+ * @return self
+ */
+ public function collectExpandableDatas($url, $maxItems = -1){
+ if(empty($url)) {
+ returnServerError('There is no $url for this RSS expander');
+ }
+
+ Debug::log('Loading from ' . $url);
+
+ /* Notice we do not use cache here on purpose:
+ * we want a fresh view of the RSS stream each time
+ */
+ $content = getContents($url)
+ or returnServerError('Could not request ' . $url);
+ $rssContent = simplexml_load_string(trim($content));
+
+ Debug::log('Detecting feed format/version');
+ switch(true) {
+ case isset($rssContent->item[0]):
+ Debug::log('Detected RSS 1.0 format');
+ $this->feedType = self::FEED_TYPE_RSS_1_0;
+ break;
+ case isset($rssContent->channel[0]):
+ Debug::log('Detected RSS 0.9x or 2.0 format');
+ $this->feedType = self::FEED_TYPE_RSS_2_0;
+ break;
+ case isset($rssContent->entry[0]):
+ Debug::log('Detected ATOM format');
+ $this->feedType = self::FEED_TYPE_ATOM_1_0;
+ break;
+ default:
+ Debug::log('Unknown feed format/version');
+ returnServerError('The feed format is unknown!');
+ break;
+ }
+
+ Debug::log('Calling function "collect_' . $this->feedType . '_data"');
+ $this->{'collect_' . $this->feedType . '_data'}($rssContent, $maxItems);
+
+ return $this;
+ }
+
+ /**
+ * Collect data from a RSS 1.0 compatible feed
+ *
+ * @link http://web.resource.org/rss/1.0/spec RDF Site Summary (RSS) 1.0
+ *
+ * @param string $rssContent The RSS content
+ * @param int $maxItems Maximum number of items to collect from the feed
+ * (`-1`: no limit).
+ * @return void
+ *
+ * @todo Instead of passing $maxItems to all functions, just add all items
+ * and remove excessive items later.
+ */
+ protected function collect_RSS_1_0_data($rssContent, $maxItems){
+ $this->load_RSS_2_0_feed_data($rssContent->channel[0]);
+ foreach($rssContent->item as $item) {
+ Debug::log('parsing item ' . var_export($item, true));
+ $tmp_item = $this->parseItem($item);
+ if (!empty($tmp_item)) {
+ $this->items[] = $tmp_item;
+ }
+ if($maxItems !== -1 && count($this->items) >= $maxItems) break;
+ }
+ }
+
+ /**
+ * Collect data from a RSS 2.0 compatible feed
+ *
+ * @link http://www.rssboard.org/rss-specification RSS 2.0 Specification
+ *
+ * @param object $rssContent The RSS content
+ * @param int $maxItems Maximum number of items to collect from the feed
+ * (`-1`: no limit).
+ * @return void
+ *
+ * @todo Instead of passing $maxItems to all functions, just add all items
+ * and remove excessive items later.
+ */
+ protected function collect_RSS_2_0_data($rssContent, $maxItems){
+ $rssContent = $rssContent->channel[0];
+ Debug::log('RSS content is ===========\n'
+ . var_export($rssContent, true)
+ . '===========');
+
+ $this->load_RSS_2_0_feed_data($rssContent);
+ foreach($rssContent->item as $item) {
+ Debug::log('parsing item ' . var_export($item, true));
+ $tmp_item = $this->parseItem($item);
+ if (!empty($tmp_item)) {
+ $this->items[] = $tmp_item;
+ }
+ if($maxItems !== -1 && count($this->items) >= $maxItems) break;
+ }
+ }
+
+ /**
+ * Collect data from a Atom 1.0 compatible feed
+ *
+ * @link https://tools.ietf.org/html/rfc4287 The Atom Syndication Format
+ *
+ * @param object $content The Atom content
+ * @param int $maxItems Maximum number of items to collect from the feed
+ * (`-1`: no limit).
+ * @return void
+ *
+ * @todo Instead of passing $maxItems to all functions, just add all items
+ * and remove excessive items later.
+ */
+ protected function collect_ATOM_1_0_data($content, $maxItems){
+ $this->load_ATOM_feed_data($content);
+ foreach($content->entry as $item) {
+ Debug::log('parsing item ' . var_export($item, true));
+ $tmp_item = $this->parseItem($item);
+ if (!empty($tmp_item)) {
+ $this->items[] = $tmp_item;
+ }
+ if($maxItems !== -1 && count($this->items) >= $maxItems) break;
+ }
+ }
+
+ /**
+ * Convert RSS 2.0 time to timestamp
+ *
+ * @param object $item A feed item
+ * @return int The timestamp
+ */
+ protected function RSS_2_0_time_to_timestamp($item){
+ return DateTime::createFromFormat('D, d M Y H:i:s e', $item->pubDate)->getTimestamp();
+ }
+
+ /**
+ * Load RSS 2.0 feed data into RSS-Bridge
+ *
+ * @param object $rssContent The RSS content
+ * @return void
+ *
+ * @todo set title, link, description, language, and so on
+ */
+ protected function load_RSS_2_0_feed_data($rssContent){
+ $this->title = trim((string)$rssContent->title);
+ $this->uri = trim((string)$rssContent->link);
+ }
+
+ /**
+ * Load Atom feed data into RSS-Bridge
+ *
+ * @param object $content The Atom content
+ * @return void
+ */
+ protected function load_ATOM_feed_data($content){
+ $this->title = (string)$content->title;
+
+ // Find best link (only one, or first of 'alternate')
+ if(!isset($content->link)) {
+ $this->uri = '';
+ } elseif (count($content->link) === 1) {
+ $this->uri = (string)$content->link[0]['href'];
+ } else {
+ $this->uri = '';
+ foreach($content->link as $link) {
+ if(strtolower($link['rel']) === 'alternate') {
+ $this->uri = (string)$link['href'];
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Parse the contents of a single Atom feed item into a RSS-Bridge item for
+ * further transformation.
+ *
+ * @param object $feedItem A single feed item
+ * @return object The RSS-Bridge item
+ *
+ * @todo To reduce confusion, the RSS-Bridge item should maybe have a class
+ * of its own?
+ */
+ protected function parseATOMItem($feedItem){
+ // Some ATOM entries also contain RSS 2.0 fields
+ $item = $this->parseRSS_2_0_Item($feedItem);
+
+ if(isset($feedItem->id)) $item['uri'] = (string)$feedItem->id;
+ if(isset($feedItem->title)) $item['title'] = (string)$feedItem->title;
+ if(isset($feedItem->updated)) $item['timestamp'] = strtotime((string)$feedItem->updated);
+ if(isset($feedItem->author)) $item['author'] = (string)$feedItem->author->name;
+ if(isset($feedItem->content)) $item['content'] = (string)$feedItem->content;
+
+ //When "link" field is present, URL is more reliable than "id" field
+ if (count($feedItem->link) === 1) {
+ $this->uri = (string)$feedItem->link[0]['href'];
+ } else {
+ foreach($feedItem->link as $link) {
+ if(strtolower($link['rel']) === 'alternate') {
+ $item['uri'] = (string)$link['href'];
+ break;
+ }
+ }
+ }
+
+ return $item;
+ }
+
+ /**
+ * Parse the contents of a single RSS 0.91 feed item into a RSS-Bridge item
+ * for further transformation.
+ *
+ * @param object $feedItem A single feed item
+ * @return object The RSS-Bridge item
+ *
+ * @todo To reduce confusion, the RSS-Bridge item should maybe have a class
+ * of its own?
+ */
+ protected function parseRSS_0_9_1_Item($feedItem){
+ $item = array();
+ if(isset($feedItem->link)) $item['uri'] = (string)$feedItem->link;
+ if(isset($feedItem->title)) $item['title'] = (string)$feedItem->title;
+ // rss 0.91 doesn't support timestamps
+ // rss 0.91 doesn't support authors
+ // rss 0.91 doesn't support enclosures
+ if(isset($feedItem->description)) $item['content'] = (string)$feedItem->description;
+ return $item;
+ }
+
+ /**
+ * Parse the contents of a single RSS 1.0 feed item into a RSS-Bridge item
+ * for further transformation.
+ *
+ * @param object $feedItem A single feed item
+ * @return object The RSS-Bridge item
+ *
+ * @todo To reduce confusion, the RSS-Bridge item should maybe have a class
+ * of its own?
+ */
+ protected function parseRSS_1_0_Item($feedItem){
+ // 1.0 adds optional elements around the 0.91 standard
+ $item = $this->parseRSS_0_9_1_Item($feedItem);
+
+ $namespaces = $feedItem->getNamespaces(true);
+ if(isset($namespaces['dc'])) {
+ $dc = $feedItem->children($namespaces['dc']);
+ if(isset($dc->date)) $item['timestamp'] = strtotime((string)$dc->date);
+ if(isset($dc->creator)) $item['author'] = (string)$dc->creator;
+ }
+
+ return $item;
+ }
+
+ /**
+ * Parse the contents of a single RSS 2.0 feed item into a RSS-Bridge item
+ * for further transformation.
+ *
+ * @param object $feedItem A single feed item
+ * @return object The RSS-Bridge item
+ *
+ * @todo To reduce confusion, the RSS-Bridge item should maybe have a class
+ * of its own?
+ */
+ protected function parseRSS_2_0_Item($feedItem){
+ // Primary data is compatible to 0.91 with some additional data
+ $item = $this->parseRSS_0_9_1_Item($feedItem);
+
+ $namespaces = $feedItem->getNamespaces(true);
+ if(isset($namespaces['dc'])) $dc = $feedItem->children($namespaces['dc']);
+ if(isset($namespaces['media'])) $media = $feedItem->children($namespaces['media']);
+
+ if(isset($feedItem->guid)) {
+ foreach($feedItem->guid->attributes() as $attribute => $value) {
+ if($attribute === 'isPermaLink'
+ && ($value === 'true' || (
+ filter_var($feedItem->guid, FILTER_VALIDATE_URL)
+ && !filter_var($item['uri'], FILTER_VALIDATE_URL)
+ )
+ )
+ ) {
+ $item['uri'] = (string)$feedItem->guid;
+ break;
+ }
+ }
+ }
+
+ if(isset($feedItem->pubDate)) {
+ $item['timestamp'] = strtotime((string)$feedItem->pubDate);
+ } elseif(isset($dc->date)) {
+ $item['timestamp'] = strtotime((string)$dc->date);
+ }
+
+ if(isset($feedItem->author)) {
+ $item['author'] = (string)$feedItem->author;
+ } elseif (isset($feedItem->creator)) {
+ $item['author'] = (string)$feedItem->creator;
+ } elseif(isset($dc->creator)) {
+ $item['author'] = (string)$dc->creator;
+ } elseif(isset($media->credit)) {
+ $item['author'] = (string)$media->credit;
+ }
+
+ if(isset($feedItem->enclosure) && !empty($feedItem->enclosure['url'])) {
+ $item['enclosures'] = array((string)$feedItem->enclosure['url']);
+ }
+
+ return $item;
+ }
+
+ /**
+ * Parse the contents of a single feed item, depending on the current feed
+ * type, into a RSS-Bridge item.
+ *
+ * @param object $item The current feed item
+ * @return object A RSS-Bridge item, with (hopefully) the whole content
+ */
+ protected function parseItem($item){
+ switch($this->feedType) {
+ case self::FEED_TYPE_RSS_1_0:
+ return $this->parseRSS_1_0_Item($item);
+ break;
+ case self::FEED_TYPE_RSS_2_0:
+ return $this->parseRSS_2_0_Item($item);
+ break;
+ case self::FEED_TYPE_ATOM_1_0:
+ return $this->parseATOMItem($item);
+ break;
+ default: returnClientError('Unknown version ' . $this->getInput('version') . '!');
+ }
+ }
+
+ /** {@inheritdoc} */
+ public function getURI(){
+ return !empty($this->uri) ? $this->uri : parent::getURI();
+ }
+
+ /** {@inheritdoc} */
+ public function getName(){
+ return !empty($this->title) ? $this->title : parent::getName();
+ }
+
+ /** {@inheritdoc} */
+ public function getIcon(){
+ return !empty($this->icon) ? $this->icon : parent::getIcon();
+ }
+}
diff --git a/lib/FeedItem.php b/lib/FeedItem.php
new file mode 100644
index 0000000..2812da6
--- /dev/null
+++ b/lib/FeedItem.php
@@ -0,0 +1,485 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Represents a simple feed item for transformation into various feed formats.
+ *
+ * This class represents a feed item. A feed item is an entity that can be
+ * transformed into various feed formats. It holds a set of pre-defined
+ * properties:
+ *
+ * - **URI**: URI to the full article (i.e. "https://...")
+ * - **Title**: The title
+ * - **Timestamp**: A timestamp of when the item was first released
+ * - **Author**: Name of the author
+ * - **Content**: Body of the feed, as text or HTML
+ * - **Enclosures**: A list of links to media objects (images, videos, etc...)
+ * - **Categories**: A list of category names or tags to categorize the item
+ *
+ * _Note_: A feed item can have any number of additional parameters, all of which
+ * may or may not be transformed to the selected output format.
+ *
+ * _Remarks_: This class supports legacy items via {@see FeedItem::__construct()}
+ * (i.e. `$feedItem = \FeedItem($item);`). Support for legacy items may be removed
+ * in future versions of RSS-Bridge.
+ */
+class FeedItem {
+ /** @var string|null URI to the full article */
+ protected $uri = null;
+
+ /** @var string|null Title of the item */
+ protected $title = null;
+
+ /** @var int|null Timestamp of when the item was first released */
+ protected $timestamp = null;
+
+ /** @var string|null Name of the author */
+ protected $author = null;
+
+ /** @var string|null Body of the feed */
+ protected $content = null;
+
+ /** @var array List of links to media objects */
+ protected $enclosures = array();
+
+ /** @var array List of category names or tags */
+ protected $categories = array();
+
+ /** @var array Associative list of additional parameters */
+ protected $misc = array(); // Custom parameters
+
+ /**
+ * Create object from legacy item.
+ *
+ * The provided array must be an associative array of key-value-pairs, where
+ * keys may correspond to any of the properties of this class.
+ *
+ * Example use:
+ *
+ * ```PHP
+ * <?php
+ * $item = array();
+ *
+ * $item['uri'] = 'https://www.github.com/rss-bridge/rss-bridge/';
+ * $item['title'] = 'Title';
+ * $item['timestamp'] = strtotime('now');
+ * $item['autor'] = 'Unknown author';
+ * $item['content'] = 'Hello World!';
+ * $item['enclosures'] = array('https://github.com/favicon.ico');
+ * $item['categories'] = array('php', 'rss-bridge', 'awesome');
+ *
+ * $feedItem = new \FeedItem($item);
+ *
+ * ```
+ *
+ * The result of the code above is the same as the code below:
+ *
+ * ```PHP
+ * <?php
+ * $feedItem = \FeedItem();
+ *
+ * $feedItem->uri = 'https://www.github.com/rss-bridge/rss-bridge/';
+ * $feedItem->title = 'Title';
+ * $feedItem->timestamp = strtotime('now');
+ * $feedItem->autor = 'Unknown author';
+ * $feedItem->content = 'Hello World!';
+ * $feedItem->enclosures = array('https://github.com/favicon.ico');
+ * $feedItem->categories = array('php', 'rss-bridge', 'awesome');
+ * ```
+ *
+ * @param array $item (optional) A legacy item (empty: no legacy support).
+ * @return object A new object of this class
+ */
+ public function __construct($item = array()) {
+ if(!is_array($item))
+ Debug::log('Item must be an array!');
+
+ foreach($item as $key => $value) {
+ $this->__set($key, $value);
+ }
+ }
+
+ /**
+ * Get current URI.
+ *
+ * Use {@see FeedItem::setURI()} to set the URI.
+ *
+ * @return string|null The URI or null if it hasn't been set.
+ */
+ public function getURI() {
+ return $this->uri;
+ }
+
+ /**
+ * Set URI to the full article.
+ *
+ * Use {@see FeedItem::getURI()} to get the URI.
+ *
+ * _Note_: Removes whitespace from the beginning and end of the URI.
+ *
+ * _Remarks_: Uses the attribute "href" or "src" if the provided URI is an
+ * object of simple_html_dom_node.
+ *
+ * @param object|string $uri URI to the full article.
+ * @return self
+ */
+ public function setURI($uri) {
+ $this->uri = null; // Clear previous data
+
+ if($uri instanceof simple_html_dom_node) {
+ if($uri->hasAttribute('href')) { // Anchor
+ $uri = $uri->href;
+ } elseif($uri->hasAttribute('src')) { // Image
+ $uri = $uri->src;
+ } else {
+ Debug::log('The item provided as URI is unknown!');
+ }
+ }
+
+ if(!is_string($uri)) {
+ Debug::log('URI must be a string!');
+ } elseif(!filter_var(
+ $uri,
+ FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED)) {
+ Debug::log('URI must include a scheme, host and path!');
+ } else {
+ $scheme = parse_url($uri, PHP_URL_SCHEME);
+
+ if($scheme !== 'http' && $scheme !== 'https') {
+ Debug::log('URI scheme must be "http" or "https"!');
+ } else {
+ $this->uri = trim($uri);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get current title.
+ *
+ * Use {@see FeedItem::setTitle()} to set the title.
+ *
+ * @return string|null The current title or null if it hasn't been set.
+ */
+ public function getTitle() {
+ return $this->title;
+ }
+
+ /**
+ * Set title.
+ *
+ * Use {@see FeedItem::getTitle()} to get the title.
+ *
+ * _Note_: Removes whitespace from beginning and end of the title.
+ *
+ * @param string $title The title
+ * @return self
+ */
+ public function setTitle($title) {
+ $this->title = null; // Clear previous data
+
+ if(!is_string($title)) {
+ Debug::log('Title must be a string!');
+ } else {
+ $this->title = trim($title);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get current timestamp.
+ *
+ * Use {@see FeedItem::setTimestamp()} to set the timestamp.
+ *
+ * @return int|null The current timestamp or null if it hasn't been set.
+ */
+ public function getTimestamp() {
+ return $this->timestamp;
+ }
+
+ /**
+ * Set timestamp of first release.
+ *
+ * _Note_: The timestamp should represent the number of seconds since
+ * January 1 1970 00:00:00 GMT (Unix time).
+ *
+ * _Remarks_: If the provided timestamp is a string (not numeric), this
+ * function automatically attempts to parse the string using
+ * [strtotime](http://php.net/manual/en/function.strtotime.php)
+ *
+ * @link http://php.net/manual/en/function.strtotime.php strtotime (PHP)
+ * @link https://en.wikipedia.org/wiki/Unix_time Unix time (Wikipedia)
+ *
+ * @param string|int $timestamp A timestamp of when the item was first released
+ * @return self
+ */
+ public function setTimestamp($timestamp) {
+ $this->timestamp = null; // Clear previous data
+
+ if(!is_numeric($timestamp)
+ && !$timestamp = strtotime($timestamp)) {
+ Debug::log('Unable to parse timestamp!');
+ }
+
+ if($timestamp <= 0) {
+ Debug::log('Timestamp must be greater than zero!');
+ } else {
+ $this->timestamp = $timestamp;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the current author name.
+ *
+ * Use {@see FeedItem::setAuthor()} to set the author.
+ *
+ * @return string|null The author or null if it hasn't been set.
+ */
+ public function getAuthor() {
+ return $this->author;
+ }
+
+ /**
+ * Set the author name.
+ *
+ * Use {@see FeedItem::getAuthor()} to get the author.
+ *
+ * @param string $author The author name.
+ * @return self
+ */
+ public function setAuthor($author) {
+ $this->author = null; // Clear previous data
+
+ if(!is_string($author)) {
+ Debug::log('Author must be a string!');
+ } else {
+ $this->author = $author;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get item content.
+ *
+ * Use {@see FeedItem::setContent()} to set the item content.
+ *
+ * @return string|null The item content or null if it hasn't been set.
+ */
+ public function getContent() {
+ return $this->content;
+ }
+
+ /**
+ * Set item content.
+ *
+ * Note: This function casts objects of type simple_html_dom and
+ * simple_html_dom_node to string.
+ *
+ * Use {@see FeedItem::getContent()} to get the current item content.
+ *
+ * @param string|object $content The item content as text or simple_html_dom
+ * object.
+ * @return self
+ */
+ public function setContent($content) {
+ $this->content = null; // Clear previous data
+
+ if($content instanceof simple_html_dom
+ || $content instanceof simple_html_dom_node) {
+ $content = (string)$content;
+ }
+
+ if(!is_string($content)) {
+ Debug::log('Content must be a string!');
+ } else {
+ $this->content = $content;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get item enclosures.
+ *
+ * Use {@see FeedItem::setEnclosures()} to set feed enclosures.
+ *
+ * @return array Enclosures as array of enclosure URIs.
+ */
+ public function getEnclosures() {
+ return $this->enclosures;
+ }
+
+ /**
+ * Set item enclosures.
+ *
+ * Use {@see FeedItem::getEnclosures()} to get the current item enclosures.
+ *
+ * @param array $enclosures Array of enclosures, where each element links to
+ * one enclosure.
+ * @return self
+ */
+ public function setEnclosures($enclosures) {
+ $this->enclosures = array(); // Clear previous data
+
+ if(!is_array($enclosures)) {
+ Debug::log('Enclosures must be an array!');
+ } else {
+ foreach($enclosures as $enclosure) {
+ if(!filter_var(
+ $enclosure,
+ FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED)) {
+ Debug::log('Each enclosure must contain a scheme, host and path!');
+ } else {
+ $this->enclosures[] = $enclosure;
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get item categories.
+ *
+ * Use {@see FeedItem::setCategories()} to set item categories.
+ *
+ * @param array The item categories.
+ */
+ public function getCategories() {
+ return $this->categories;
+ }
+
+ /**
+ * Set item categories.
+ *
+ * Use {@see FeedItem::getCategories()} to get the current item categories.
+ *
+ * @param array $categories Array of categories, where each element defines
+ * a single category name.
+ * @return self
+ */
+ public function setCategories($categories) {
+ $this->categories = array(); // Clear previous data
+
+ if(!is_array($categories)) {
+ Debug::log('Categories must be an array!');
+ } else {
+ foreach($categories as $category) {
+ if(!is_string($category)) {
+ Debug::log('Category must be a string!');
+ } else {
+ $this->categories[] = $category;
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add miscellaneous elements to the item.
+ *
+ * @param string $key Name of the element.
+ * @param mixed $value Value of the element.
+ * @return self
+ */
+ public function addMisc($key, $value) {
+
+ if(!is_string($key)) {
+ Debug::log('Key must be a string!');
+ } elseif(in_array($key, get_object_vars($this))) {
+ Debug::log('Key must be unique!');
+ } else {
+ $this->misc[$key] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Transform current object to array
+ *
+ * @return array
+ */
+ public function toArray() {
+ return array_merge(
+ array(
+ 'uri' => $this->uri,
+ 'title' => $this->title,
+ 'timestamp' => $this->timestamp,
+ 'author' => $this->author,
+ 'content' => $this->content,
+ 'enclosures' => $this->enclosures,
+ 'categories' => $this->categories,
+ ), $this->misc
+ );
+ }
+
+ /**
+ * Set item property
+ *
+ * Allows simple assignment to parameters. This method is slower, but easier
+ * to implement in some cases:
+ *
+ * ```PHP
+ * $item = new \FeedItem();
+ * $item->content = 'Hello World!';
+ * $item->my_id = 42;
+ * ```
+ *
+ * @param string $name Property name
+ * @param mixed $value Property value
+ */
+ function __set($name, $value) {
+ switch($name) {
+ case 'uri': $this->setURI($value); break;
+ case 'title': $this->setTitle($value); break;
+ case 'timestamp': $this->setTimestamp($value); break;
+ case 'author': $this->setAuthor($value); break;
+ case 'content': $this->setContent($value); break;
+ case 'enclosures': $this->setEnclosures($value); break;
+ case 'categories': $this->setCategories($value); break;
+ default: $this->addMisc($name, $value);
+ }
+ }
+
+ /**
+ * Get item property
+ *
+ * Allows simple assignment to parameters. This method is slower, but easier
+ * to implement in some cases.
+ *
+ * @param string $name Property name
+ * @return mixed Property value
+ */
+ function __get($name) {
+ switch($name) {
+ case 'uri': return $this->getURI();
+ case 'title': return $this->getTitle();
+ case 'timestamp': return $this->getTimestamp();
+ case 'author': return $this->getAuthor();
+ case 'content': return $this->getContent();
+ case 'enclosures': return $this->getEnclosures();
+ case 'categories': return $this->getCategories();
+ default:
+ if(array_key_exists($name, $this->misc))
+ return $this->misc[$name];
+ return null;
+ }
+ }
+}
diff --git a/lib/Format.php b/lib/Format.php
new file mode 100644
index 0000000..061b1f2
--- /dev/null
+++ b/lib/Format.php
@@ -0,0 +1,166 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Factory class responsible for creating format objects from a given working
+ * directory.
+ *
+ * This class is capable of:
+ * - Locating format classes in the specified working directory (see {@see Format::$workingDir})
+ * - Creating new format instances based on the format's name (see {@see Format::create()})
+ *
+ * The following example illustrates the intended use for this class.
+ *
+ * ```PHP
+ * require_once __DIR__ . '/rssbridge.php';
+ *
+ * // Step 1: Set the working directory
+ * Format::setWorkingDir(__DIR__ . '/../formats/');
+ *
+ * // Step 2: Create a new instance of a format object (based on the name)
+ * $format = Format::create('Atom');
+ * ```
+ */
+class Format {
+
+ /**
+ * Holds a path to the working directory.
+ *
+ * Do not access this property directly!
+ * Use {@see Format::setWorkingDir()} and {@see Format::getWorkingDir()} instead.
+ *
+ * @var string|null
+ */
+ protected static $workingDir = null;
+
+ /**
+ * Throws an exception when trying to create a new instance of this class.
+ * Use {@see Format::create()} to create a new format object from the working
+ * directory.
+ *
+ * @throws \LogicException if called.
+ */
+ public function __construct(){
+ throw new \LogicException('Use ' . __CLASS__ . '::create($name) to create cache objects!');
+ }
+
+ /**
+ * Creates a new format object from the working directory.
+ *
+ * @throws \InvalidArgumentException if the requested format name is invalid.
+ * @throws \Exception if the requested format file doesn't exist in the
+ * working directory.
+ * @param string $name Name of the format object.
+ * @return object|bool The format object or false if the class is not instantiable.
+ */
+ public static function create($name){
+ if(!self::isFormatName($name)) {
+ throw new \InvalidArgumentException('Format name invalid!');
+ }
+
+ $name = $name . 'Format';
+ $pathFormat = self::getWorkingDir() . $name . '.php';
+
+ if(!file_exists($pathFormat)) {
+ throw new \Exception('Format file ' . $filePath . ' does not exist!');
+ }
+
+ require_once $pathFormat;
+
+ if((new \ReflectionClass($name))->isInstantiable()) {
+ return new $name();
+ }
+
+ return false;
+ }
+
+ /**
+ * Sets the working directory.
+ *
+ * @param string $dir Path to a directory containing cache classes
+ * @throws \InvalidArgumentException if $dir is not a string.
+ * @throws \Exception if the working directory doesn't exist.
+ * @throws \InvalidArgumentException if $dir is not a directory.
+ * @return void
+ */
+ public static function setWorkingDir($dir){
+ self::$workingDir = null;
+
+ if(!is_string($dir)) {
+ throw new \InvalidArgumentException('Dir format must be a string.');
+ }
+
+ if(!file_exists($dir)) {
+ throw new \Exception('Working directory does not exist!');
+ }
+
+ if(!is_dir($dir)) {
+ throw new \InvalidArgumentException('Working directory is not a directory!');
+ }
+
+ self::$workingDir = realpath($dir) . '/';
+ }
+
+ /**
+ * Returns the working directory.
+ * The working directory must be set with {@see Format::setWorkingDir()}!
+ *
+ * @throws \LogicException if the working directory is not set.
+ * @return string The current working directory.
+ */
+ public static function getWorkingDir(){
+ if(is_null(self::$workingDir)) {
+ throw new \LogicException('Working directory is not set!');
+ }
+
+ return self::$workingDir;
+ }
+
+ /**
+ * Returns true if the provided name is a valid format name.
+ *
+ * A valid format name starts with a capital letter ([A-Z]), followed by
+ * zero or more alphanumeric characters or hyphen ([A-Za-z0-9-]).
+ *
+ * @param string $name The format name.
+ * @return bool true if the name is a valid format name, false otherwise.
+ */
+ public static function isFormatName($name){
+ return is_string($name) && preg_match('/^[A-Z][a-zA-Z0-9-]*$/', $name) === 1;
+ }
+
+ /**
+ * Returns the list of format names from the working directory.
+ *
+ * The list is cached internally to allow for successive calls.
+ *
+ * @return array List of format names
+ */
+ public static function getFormatNames(){
+ static $formatNames = array(); // Initialized on first call
+
+ if(empty($formatNames)) {
+ $files = scandir(self::getWorkingDir());
+
+ if($files !== false) {
+ foreach($files as $file) {
+ if(preg_match('/^([^.]+)Format\.php$/U', $file, $out)) {
+ $formatNames[] = $out[1];
+ }
+ }
+ }
+ }
+
+ return $formatNames;
+ }
+}
diff --git a/lib/FormatAbstract.php b/lib/FormatAbstract.php
new file mode 100644
index 0000000..5395d56
--- /dev/null
+++ b/lib/FormatAbstract.php
@@ -0,0 +1,195 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license https://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * An abstract class for format implementations
+ *
+ * This class implements {@see FormatInterface}
+ */
+abstract class FormatAbstract implements FormatInterface {
+
+ /** The default charset (UTF-8) */
+ const DEFAULT_CHARSET = 'UTF-8';
+
+ /** @var string|null $contentType The content type */
+ protected $contentType = null;
+
+ /** @var string $charset The charset */
+ protected $charset;
+
+ /** @var array $items The items */
+ protected $items;
+
+ /**
+ * @var int $lastModified A timestamp to indicate the last modified time of
+ * the output data.
+ */
+ protected $lastModified;
+
+ /** @var array $extraInfos The extra infos */
+ protected $extraInfos;
+
+ /**
+ * {@inheritdoc}
+ *
+ * @param string $charset {@inheritdoc}
+ */
+ public function setCharset($charset){
+ $this->charset = $charset;
+
+ return $this;
+ }
+
+ /** {@inheritdoc} */
+ public function getCharset(){
+ $charset = $this->charset;
+
+ return is_null($charset) ? static::DEFAULT_CHARSET : $charset;
+ }
+
+ /**
+ * Set the content type
+ *
+ * @param string $contentType The content type
+ * @return self The format object
+ */
+ protected function setContentType($contentType){
+ $this->contentType = $contentType;
+
+ return $this;
+ }
+
+ /**
+ * Set the last modified time
+ *
+ * @param int $lastModified The last modified time
+ * @return void
+ */
+ public function setLastModified($lastModified){
+ $this->lastModified = $lastModified;
+ }
+
+ /**
+ * Send header with the currently specified content type
+ *
+ * @throws \LogicException if the content type is not set
+ * @throws \LogicException if the content type is not a string
+ *
+ * @return void
+ */
+ protected function callContentType(){
+ if(empty($this->contentType))
+ throw new \LogicException('Content-Type is not set!');
+
+ if(!is_string($this->contentType))
+ throw new \LogicException('Content-Type must be a string!');
+
+ header('Content-Type: ' . $this->contentType);
+ }
+
+ /** {@inheritdoc} */
+ public function display(){
+ if ($this->lastModified) {
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s ', $this->lastModified) . 'GMT');
+ }
+ echo $this->stringify();
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @param array $items {@inheritdoc}
+ */
+ public function setItems(array $items){
+ $this->items = $items;
+
+ return $this;
+ }
+
+ /** {@inheritdoc} */
+ public function getItems(){
+ if(!is_array($this->items))
+ throw new \LogicException('Feed the ' . get_class($this) . ' with "setItems" method before !');
+
+ return $this->items;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @param array $extraInfos {@inheritdoc}
+ */
+ public function setExtraInfos(array $extraInfos = array()){
+ foreach(array('name', 'uri', 'icon') as $infoName) {
+ if(!isset($extraInfos[$infoName])) {
+ $extraInfos[$infoName] = '';
+ }
+ }
+
+ $this->extraInfos = $extraInfos;
+
+ return $this;
+ }
+
+ /** {@inheritdoc} */
+ public function getExtraInfos(){
+ if(is_null($this->extraInfos)) { // No extra info ?
+ $this->setExtraInfos(); // Define with default value
+ }
+
+ return $this->extraInfos;
+ }
+
+ /**
+ * Sanitize HTML while leaving it functional.
+ *
+ * Keeps HTML as-is (with clickable hyperlinks) while reducing annoying and
+ * potentially dangerous things.
+ *
+ * @param string $html The HTML content
+ * @return string The sanitized HTML content
+ *
+ * @todo This belongs into `html.php`
+ * @todo Maybe switch to http://htmlpurifier.org/
+ * @todo Maybe switch to http://www.bioinformatics.org/phplabware/internal_utilities/htmLawed/index.php
+ */
+ protected function sanitizeHtml($html)
+ {
+ $html = str_replace('<script', '<&zwnj;script', $html); // Disable scripts, but leave them visible.
+ $html = str_replace('<iframe', '<&zwnj;iframe', $html);
+ $html = str_replace('<link', '<&zwnj;link', $html);
+ // We leave alone object and embed so that videos can play in RSS readers.
+ return $html;
+ }
+
+ /**
+ * Trim each element of an array
+ *
+ * This function applies `trim()` to all elements in the array, if the element
+ * is a valid string.
+ *
+ * @param array $elements The array to trim
+ * @return array The trimmed array
+ *
+ * @todo This is a utility function that doesn't belong here, find a new home.
+ */
+ protected function array_trim($elements){
+ foreach($elements as $key => $value) {
+ if(is_string($value))
+ $elements[$key] = trim($value);
+ }
+ return $elements;
+ }
+}
diff --git a/lib/FormatInterface.php b/lib/FormatInterface.php
new file mode 100644
index 0000000..68b0bd5
--- /dev/null
+++ b/lib/FormatInterface.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * The format interface
+ *
+ * @todo Add missing function to the interface
+ * @todo Explain parameters and return values in more detail
+ * @todo Return self more often (to allow call chaining)
+ */
+interface FormatInterface {
+ /**
+ * Generate a string representation of the current data
+ *
+ * @return string The string representation
+ */
+ public function stringify();
+
+ /**
+ * Display the current data to the user
+ *
+ * @return self The format object
+ */
+ public function display();
+
+ /**
+ * Set items
+ *
+ * @param array $bridges The items
+ * @return self The format object
+ *
+ * @todo Rename parameter `$bridges` to `$items`
+ */
+ public function setItems(array $bridges);
+
+ /**
+ * Return items
+ *
+ * @throws \LogicException if the items are not set
+ * @return array The items
+ */
+ public function getItems();
+
+ /**
+ * Set extra information
+ *
+ * @param array $infos Extra information
+ * @return self The format object
+ */
+ public function setExtraInfos(array $infos);
+
+ /**
+ * Return extra information
+ *
+ * @return array Extra information
+ */
+ public function getExtraInfos();
+
+ /**
+ * Set charset
+ *
+ * @param string $charset The charset
+ * @return self The format object
+ */
+ public function setCharset($charset);
+
+ /**
+ * Return current charset
+ *
+ * @return string The charset
+ */
+ public function getCharset();
+}
diff --git a/lib/ParameterValidator.php b/lib/ParameterValidator.php
new file mode 100644
index 0000000..91fe7c9
--- /dev/null
+++ b/lib/ParameterValidator.php
@@ -0,0 +1,227 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Validator for bridge parameters
+ */
+class ParameterValidator {
+
+ /**
+ * Holds the list of invalid parameters
+ *
+ * @var array
+ */
+ private $invalid = array();
+
+ /**
+ * Add item to list of invalid parameters
+ *
+ * @param string $name The name of the parameter
+ * @param string $reason The reason for that parameter being invalid
+ * @return void
+ */
+ private function addInvalidParameter($name, $reason){
+ $this->invalid[] = array(
+ 'name' => $name,
+ 'reason' => $reason
+ );
+ }
+
+ /**
+ * Return list of invalid parameters.
+ *
+ * Each element is an array of 'name' and 'reason'.
+ *
+ * @return array List of invalid parameters
+ */
+ public function getInvalidParameters() {
+ return $this->invalid;
+ }
+
+ /**
+ * Validate value for a text input
+ *
+ * @param string $value The value of a text input
+ * @param string|null $pattern (optional) A regex pattern
+ * @return string|null The filtered value or null if the value is invalid
+ */
+ private function validateTextValue($value, $pattern = null){
+ if(!is_null($pattern)) {
+ $filteredValue = filter_var($value,
+ FILTER_VALIDATE_REGEXP,
+ array('options' => array(
+ 'regexp' => '/^' . $pattern . '$/'
+ )
+ ));
+ } else {
+ $filteredValue = filter_var($value);
+ }
+
+ if($filteredValue === false)
+ return null;
+
+ return $filteredValue;
+ }
+
+ /**
+ * Validate value for a number input
+ *
+ * @param int $value The value of a number input
+ * @return int|null The filtered value or null if the value is invalid
+ */
+ private function validateNumberValue($value){
+ $filteredValue = filter_var($value, FILTER_VALIDATE_INT);
+
+ if($filteredValue === false)
+ return null;
+
+ return $filteredValue;
+ }
+
+ /**
+ * Validate value for a checkbox
+ *
+ * @param bool $value The value of a checkbox
+ * @return bool The filtered value
+ */
+ private function validateCheckboxValue($value){
+ return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+ }
+
+ /**
+ * Validate value for a list
+ *
+ * @param string $value The value of a list
+ * @param array $expectedValues A list of expected values
+ * @return string|null The filtered value or null if the value is invalid
+ */
+ private function validateListValue($value, $expectedValues){
+ $filteredValue = filter_var($value);
+
+ if($filteredValue === false)
+ return null;
+
+ if(!in_array($filteredValue, $expectedValues)) { // Check sub-values?
+ foreach($expectedValues as $subName => $subValue) {
+ if(is_array($subValue) && in_array($filteredValue, $subValue))
+ return $filteredValue;
+ }
+ return null;
+ }
+
+ return $filteredValue;
+ }
+
+ /**
+ * Check if all required parameters are satisfied
+ *
+ * @param array $data (ref) A list of input values
+ * @param array $parameters The bridge parameters
+ * @return bool True if all parameters are satisfied
+ */
+ public function validateData(&$data, $parameters){
+
+ if(!is_array($data))
+ return false;
+
+ foreach($data as $name => $value) {
+ $registered = false;
+ foreach($parameters as $context => $set) {
+ if(array_key_exists($name, $set)) {
+ $registered = true;
+ if(!isset($set[$name]['type'])) {
+ $set[$name]['type'] = 'text';
+ }
+
+ switch($set[$name]['type']) {
+ case 'number':
+ $data[$name] = $this->validateNumberValue($value);
+ break;
+ case 'checkbox':
+ $data[$name] = $this->validateCheckboxValue($value);
+ break;
+ case 'list':
+ $data[$name] = $this->validateListValue($value, $set[$name]['values']);
+ break;
+ default:
+ case 'text':
+ if(isset($set[$name]['pattern'])) {
+ $data[$name] = $this->validateTextValue($value, $set[$name]['pattern']);
+ } else {
+ $data[$name] = $this->validateTextValue($value);
+ }
+ break;
+ }
+
+ if(is_null($data[$name]) && isset($set[$name]['required']) && $set[$name]['required']) {
+ $this->addInvalidParameter($name, 'Parameter is invalid!');
+ }
+ }
+ }
+
+ if(!$registered) {
+ $this->addInvalidParameter($name, 'Parameter is not registered!');
+ }
+ }
+
+ return empty($this->invalid);
+ }
+
+ /**
+ * Get the name of the context matching the provided inputs
+ *
+ * @param array $data Associative array of user data
+ * @param array $parameters Array of bridge parameters
+ * @return string|null Returns the context name or null if no match was found
+ */
+ public function getQueriedContext($data, $parameters){
+ $queriedContexts = array();
+
+ // Detect matching context
+ foreach($parameters as $context => $set) {
+ $queriedContexts[$context] = null;
+
+ // Check if all parameters of the context are satisfied
+ foreach($set as $id => $properties) {
+ if(isset($data[$id]) && !empty($data[$id])) {
+ $queriedContexts[$context] = true;
+ } elseif(isset($properties['required'])
+ && $properties['required'] === true) {
+ $queriedContexts[$context] = false;
+ break;
+ }
+ }
+
+ }
+
+ // Abort if one of the globally required parameters is not satisfied
+ if(array_key_exists('global', $parameters)
+ && $queriedContexts['global'] === false) {
+ return null;
+ }
+ unset($queriedContexts['global']);
+
+ switch(array_sum($queriedContexts)) {
+ case 0: // Found no match, is there a context without parameters?
+ foreach($queriedContexts as $context => $queried) {
+ if(is_null($queried)) {
+ return $context;
+ }
+ }
+ return null;
+ case 1: // Found unique match
+ return array_search(true, $queriedContexts);
+ default: return false;
+ }
+ }
+}
diff --git a/lib/contents.php b/lib/contents.php
new file mode 100644
index 0000000..dc0ca51
--- /dev/null
+++ b/lib/contents.php
@@ -0,0 +1,396 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Gets contents from the Internet.
+ *
+ * **Content caching** (disabled in debug mode)
+ *
+ * A copy of the received content is stored in a local cache folder `server/` at
+ * {@see PATH_CACHE}. The `If-Modified-Since` header is added to the request, if
+ * the provided URL has been cached before.
+ *
+ * When the server responds with `304 Not Modified`, the cached data is returned.
+ * This will improve response times and reduce bandwidth for servers that support
+ * the `If-Modified-Since` header.
+ *
+ * Cached files are forcefully removed after 24 hours.
+ *
+ * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
+ * If-Modified-Since
+ *
+ * @param string $url The URL.
+ * @param array $header (optional) A list of cURL header.
+ * For more information follow the links below.
+ * * https://php.net/manual/en/function.curl-setopt.php
+ * * https://curl.haxx.se/libcurl/c/CURLOPT_HTTPHEADER.html
+ * @param array $opts (optional) A list of cURL options as associative array in
+ * the format `$opts[$option] = $value;`, where `$option` is any `CURLOPT_XXX`
+ * option and `$value` the corresponding value.
+ *
+ * For more information see http://php.net/manual/en/function.curl-setopt.php
+ * @return string The contents.
+ */
+function getContents($url, $header = array(), $opts = array()){
+ Debug::log('Reading contents from "' . $url . '"');
+
+ // Initialize cache
+ $cache = Cache::create('FileCache');
+ $cache->setPath(PATH_CACHE . 'server/');
+ $cache->purgeCache(86400); // 24 hours (forced)
+
+ $params = [$url];
+ $cache->setParameters($params);
+
+ // Use file_get_contents if in CLI mode with no root certificates defined
+ if(php_sapi_name() === 'cli' && empty(ini_get('curl.cainfo'))) {
+ $data = @file_get_contents($url);
+
+ if($data === false) {
+ $errorCode = 500;
+ } else {
+ $errorCode = 200;
+ }
+
+ $curlError = '';
+ $curlErrno = '';
+ $headerSize = 0;
+ $finalHeader = array();
+ } else {
+ $ch = curl_init($url);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+
+ if(is_array($header) && count($header) !== 0) {
+
+ Debug::log('Setting headers: ' . json_encode($header));
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $header);
+
+ }
+
+ curl_setopt($ch, CURLOPT_USERAGENT, ini_get('user_agent'));
+ curl_setopt($ch, CURLOPT_ENCODING, '');
+ curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
+
+ if(is_array($opts) && count($opts) !== 0) {
+
+ Debug::log('Setting options: ' . json_encode($opts));
+
+ foreach($opts as $key => $value) {
+ curl_setopt($ch, $key, $value);
+ }
+
+ }
+
+ if(defined('PROXY_URL') && !defined('NOPROXY')) {
+
+ Debug::log('Setting proxy url: ' . PROXY_URL);
+ curl_setopt($ch, CURLOPT_PROXY, PROXY_URL);
+
+ }
+
+ // We always want the response header as part of the data!
+ curl_setopt($ch, CURLOPT_HEADER, true);
+
+ // Build "If-Modified-Since" header
+ if(!Debug::isEnabled() && $time = $cache->getTime()) { // Skip if cache file doesn't exist
+ Debug::log('Adding If-Modified-Since');
+ curl_setopt($ch, CURLOPT_TIMEVALUE, $time);
+ curl_setopt($ch, CURLOPT_TIMECONDITION, CURL_TIMECOND_IFMODSINCE);
+ }
+
+ // Enables logging for the outgoing header
+ curl_setopt($ch, CURLINFO_HEADER_OUT, true);
+
+ $data = curl_exec($ch);
+ $errorCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+
+ $curlError = curl_error($ch);
+ $curlErrno = curl_errno($ch);
+ $curlInfo = curl_getinfo($ch);
+
+ Debug::log('Outgoing header: ' . json_encode($curlInfo));
+
+ if($data === false)
+ Debug::log('Cant\'t download ' . $url . ' cUrl error: ' . $curlError . ' (' . $curlErrno . ')');
+
+ $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+ $header = substr($data, 0, $headerSize);
+
+ Debug::log('Response header: ' . $header);
+
+ $headers = parseResponseHeader($header);
+ $finalHeader = end($headers);
+
+ curl_close($ch);
+ }
+
+ switch($errorCode) {
+ case 200: // Contents received
+ Debug::log('New contents received');
+ $data = substr($data, $headerSize);
+ // Disable caching if the server responds with "Cache-Control: no-cache"
+ // or "Cache-Control: no-store"
+ $finalHeader = array_change_key_case($finalHeader, CASE_LOWER);
+ if(array_key_exists('cache-control', $finalHeader)) {
+ Debug::log('Server responded with "Cache-Control" header');
+ $directives = explode(',', $finalHeader['cache-control']);
+ $directives = array_map('trim', $directives);
+ if(in_array('no-cache', $directives)
+ || in_array('no-store', $directives)) { // Skip caching
+ Debug::log('Skip server side caching');
+ return $data;
+ }
+ }
+ Debug::log('Store response to cache');
+ $cache->saveData($data);
+ return $data;
+ case 304: // Not modified, use cached data
+ Debug::log('Contents not modified on host, returning cached data');
+ return $cache->loadData();
+ default:
+ if(array_key_exists('Server', $finalHeader) && strpos($finalHeader['Server'], 'cloudflare') !== false) {
+ returnServerError(<<< EOD
+The server responded with a Cloudflare challenge, which is not supported by RSS-Bridge!
+If this error persists longer than a week, please consider opening an issue on GitHub!
+EOD
+ );
+ }
+
+ $lastError = error_get_last();
+ if($lastError !== null)
+ $lastError = $lastError['message'];
+ returnError(<<<EOD
+The requested resource cannot be found!
+Please make sure your input parameters are correct!
+cUrl error: $curlError ($curlErrno)
+PHP error: $lastError
+EOD
+ , $errorCode);
+ }
+}
+
+/**
+ * Gets contents from the Internet as simplhtmldom object.
+ *
+ * @param string $url The URL.
+ * @param array $header (optional) A list of cURL header.
+ * For more information follow the links below.
+ * * https://php.net/manual/en/function.curl-setopt.php
+ * * https://curl.haxx.se/libcurl/c/CURLOPT_HTTPHEADER.html
+ * @param array $opts (optional) A list of cURL options as associative array in
+ * the format `$opts[$option] = $value;`, where `$option` is any `CURLOPT_XXX`
+ * option and `$value` the corresponding value.
+ *
+ * For more information see http://php.net/manual/en/function.curl-setopt.php
+ * @param bool $lowercase Force all selectors to lowercase.
+ * @param bool $forceTagsClosed Forcefully close tags in malformed HTML.
+ *
+ * _Remarks_: Forcefully closing tags is great for malformed HTML, but it can
+ * lead to parsing errors.
+ * @param string $target_charset Defines the target charset.
+ * @param bool $stripRN Replace all occurrences of `"\r"` and `"\n"` by `" "`.
+ * @param string $defaultBRText Specifies the replacement text for `<br>` tags
+ * when returning plaintext.
+ * @param string $defaultSpanText Specifies the replacement text for `<span />`
+ * tags when returning plaintext.
+ * @return string Contents as simplehtmldom object.
+ */
+function getSimpleHTMLDOM($url,
+$header = array(),
+$opts = array(),
+$lowercase = true,
+$forceTagsClosed = true,
+$target_charset = DEFAULT_TARGET_CHARSET,
+$stripRN = true,
+$defaultBRText = DEFAULT_BR_TEXT,
+$defaultSpanText = DEFAULT_SPAN_TEXT){
+ $content = getContents($url, $header, $opts);
+ return str_get_html($content,
+ $lowercase,
+ $forceTagsClosed,
+ $target_charset,
+ $stripRN,
+ $defaultBRText,
+ $defaultSpanText);
+}
+
+/**
+ * Gets contents from the Internet as simplhtmldom object. Contents are cached
+ * and re-used for subsequent calls until the cache duration elapsed.
+ *
+ * _Notice_: Cached contents are forcefully removed after 24 hours (86400 seconds).
+ *
+ * @param string $url The URL.
+ * @param int $duration Cache duration in seconds.
+ * @param array $header (optional) A list of cURL header.
+ * For more information follow the links below.
+ * * https://php.net/manual/en/function.curl-setopt.php
+ * * https://curl.haxx.se/libcurl/c/CURLOPT_HTTPHEADER.html
+ * @param array $opts (optional) A list of cURL options as associative array in
+ * the format `$opts[$option] = $value;`, where `$option` is any `CURLOPT_XXX`
+ * option and `$value` the corresponding value.
+ *
+ * For more information see http://php.net/manual/en/function.curl-setopt.php
+ * @param bool $lowercase Force all selectors to lowercase.
+ * @param bool $forceTagsClosed Forcefully close tags in malformed HTML.
+ *
+ * _Remarks_: Forcefully closing tags is great for malformed HTML, but it can
+ * lead to parsing errors.
+ * @param string $target_charset Defines the target charset.
+ * @param bool $stripRN Replace all occurrences of `"\r"` and `"\n"` by `" "`.
+ * @param string $defaultBRText Specifies the replacement text for `<br>` tags
+ * when returning plaintext.
+ * @param string $defaultSpanText Specifies the replacement text for `<span />`
+ * tags when returning plaintext.
+ * @return string Contents as simplehtmldom object.
+ */
+function getSimpleHTMLDOMCached($url,
+$duration = 86400,
+$header = array(),
+$opts = array(),
+$lowercase = true,
+$forceTagsClosed = true,
+$target_charset = DEFAULT_TARGET_CHARSET,
+$stripRN = true,
+$defaultBRText = DEFAULT_BR_TEXT,
+$defaultSpanText = DEFAULT_SPAN_TEXT){
+ Debug::log('Caching url ' . $url . ', duration ' . $duration);
+
+ // Initialize cache
+ $cache = Cache::create('FileCache');
+ $cache->setPath(PATH_CACHE . 'pages/');
+ $cache->purgeCache(86400); // 24 hours (forced)
+
+ $params = [$url];
+ $cache->setParameters($params);
+
+ // Determine if cached file is within duration
+ $time = $cache->getTime();
+ if($time !== false
+ && (time() - $duration < $time)
+ && Debug::isEnabled()) { // Contents within duration
+ $content = $cache->loadData();
+ } else { // Content not within duration
+ $content = getContents($url, $header, $opts);
+ if($content !== false) {
+ $cache->saveData($content);
+ }
+ }
+
+ return str_get_html($content,
+ $lowercase,
+ $forceTagsClosed,
+ $target_charset,
+ $stripRN,
+ $defaultBRText,
+ $defaultSpanText);
+}
+
+/**
+ * Parses the cURL response header into an associative array
+ *
+ * Based on https://stackoverflow.com/a/18682872
+ *
+ * @param string $header The cURL response header.
+ * @return array An associative array of response headers.
+ */
+function parseResponseHeader($header) {
+
+ $headers = array();
+ $requests = explode("\r\n\r\n", trim($header));
+
+ foreach ($requests as $request) {
+
+ $header = array();
+
+ foreach (explode("\r\n", $request) as $i => $line) {
+
+ if($i === 0) {
+ $header['http_code'] = $line;
+ } else {
+
+ list ($key, $value) = explode(': ', $line);
+ $header[$key] = $value;
+
+ }
+
+ }
+
+ $headers[] = $header;
+
+ }
+
+ return $headers;
+
+}
+
+/**
+ * Determines the MIME type from a URL/Path file extension.
+ *
+ * _Remarks_:
+ *
+ * * The built-in functions `mime_content_type` and `fileinfo` require fetching
+ * remote contents.
+ * * A caller can hint for a MIME type by appending `#.ext` to the URL (i.e. `#.image`).
+ *
+ * Based on https://stackoverflow.com/a/1147952
+ *
+ * @param string $url The URL or path to the file.
+ * @return string The MIME type of the file.
+ */
+function getMimeType($url) {
+ static $mime = null;
+
+ if (is_null($mime)) {
+ // Default values, overriden by /etc/mime.types when present
+ $mime = array(
+ 'jpg' => 'image/jpeg',
+ 'gif' => 'image/gif',
+ 'png' => 'image/png',
+ 'image' => 'image/*'
+ );
+ // '@' is used to mute open_basedir warning, see issue #818
+ if (@is_readable('/etc/mime.types')) {
+ $file = fopen('/etc/mime.types', 'r');
+ while(($line = fgets($file)) !== false) {
+ $line = trim(preg_replace('/#.*/', '', $line));
+ if(!$line)
+ continue;
+ $parts = preg_split('/\s+/', $line);
+ if(count($parts) == 1)
+ continue;
+ $type = array_shift($parts);
+ foreach($parts as $part)
+ $mime[$part] = $type;
+ }
+ fclose($file);
+ }
+ }
+
+ if (strpos($url, '?') !== false) {
+ $url_temp = substr($url, 0, strpos($url, '?'));
+ if (strpos($url, '#') !== false) {
+ $anchor = substr($url, strpos($url, '#'));
+ $url_temp .= $anchor;
+ }
+ $url = $url_temp;
+ }
+
+ $ext = strtolower(pathinfo($url, PATHINFO_EXTENSION));
+ if (!empty($mime[$ext])) {
+ return $mime[$ext];
+ }
+
+ return 'application/octet-stream';
+}
diff --git a/lib/error.php b/lib/error.php
new file mode 100644
index 0000000..9a0756f
--- /dev/null
+++ b/lib/error.php
@@ -0,0 +1,43 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Throws an exception when called.
+ *
+ * @throws \Exception when called
+ * @param string $message The error message
+ * @param int $code The HTTP error code
+ * @link https://en.wikipedia.org/wiki/List_of_HTTP_status_codes List of HTTP
+ * status codes
+ */
+function returnError($message, $code){
+ throw new \Exception($message, $code);
+}
+
+/**
+ * Returns HTTP Error 400 (Bad Request) when called.
+ *
+ * @param string $message The error message
+ */
+function returnClientError($message){
+ returnError($message, 400);
+}
+
+/**
+ * Returns HTTP Error 500 (Internal Server Error) when called.
+ *
+ * @param string $message The error message
+ */
+function returnServerError($message){
+ returnError($message, 500);
+}
diff --git a/lib/html.php b/lib/html.php
new file mode 100644
index 0000000..e49ca7a
--- /dev/null
+++ b/lib/html.php
@@ -0,0 +1,265 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/**
+ * Removes unwanted tags from a given HTML text.
+ *
+ * @param string $html The HTML text to sanitize.
+ * @param array $tags_to_remove A list of tags to remove from the DOM.
+ * @param array $attributes_to_keep A list of attributes to keep on tags (other
+ * attributes are removed).
+ * @param array $text_to_keep A list of tags where the innertext replaces the tag
+ * (i.e. `<p>Hello World!</p>` becomes `Hello World!`).
+ * @return object A simplehtmldom object of the remaining contents.
+ *
+ * @todo Check if this implementation is still necessary, because simplehtmldom
+ * already removes some of the tags (search for `remove_noise` in simple_html_dom.php).
+ */
+function sanitize($html,
+$tags_to_remove = array('script', 'iframe', 'input', 'form'),
+$attributes_to_keep = array('title', 'href', 'src'),
+$text_to_keep = array()){
+ $htmlContent = str_get_html($html);
+
+ /*
+ * Notice: simple_html_dom currently doesn't support "->find(*)", which is a
+ * known issue: https://sourceforge.net/p/simplehtmldom/bugs/157/
+ *
+ * A solution to this is to find all nodes WITHOUT a specific attribute. If
+ * the attribute is very unlikely to appear in the DOM, this is essentially
+ * returning all nodes.
+ *
+ * "*[!b38fd2b1fe7f4747d6b1c1254ccd055e]" is doing exactly that. The attrib
+ * "b38fd2b1fe7f4747d6b1c1254ccd055e" is very unlikely to appear in any DOM.
+ */
+ foreach($htmlContent->find('*[!b38fd2b1fe7f4747d6b1c1254ccd055e]') as $element) {
+ if(in_array($element->tag, $text_to_keep)) {
+ $element->outertext = $element->plaintext;
+ } elseif(in_array($element->tag, $tags_to_remove)) {
+ $element->outertext = '';
+ } else {
+ foreach($element->getAllAttributes() as $attributeName => $attribute) {
+ if(!in_array($attributeName, $attributes_to_keep))
+ $element->removeAttribute($attributeName);
+ }
+ }
+ }
+
+ return $htmlContent;
+}
+
+/**
+ * Replace background by image
+ *
+ * Replaces tags with styles of `backgroud-image` by `<img />` tags.
+ *
+ * For example:
+ *
+ * ```HTML
+ * <html>
+ * <body style="background-image: url('bgimage.jpg');">
+ * <h1>Hello world!</h1>
+ * </body>
+ * </html>
+ * ```
+ *
+ * results in this output:
+ *
+ * ```HTML
+ * <html>
+ * <img style="display:block;" src="bgimage.jpg" />
+ * </html>
+ * ```
+ *
+ * @param string $htmlContent The HTML content
+ * @return string The HTML content with all ocurrences replaced
+ */
+function backgroundToImg($htmlContent) {
+
+ $regex = '/background-image[ ]{0,}:[ ]{0,}url\([\'"]{0,}(.*?)[\'"]{0,}\)/';
+ $htmlContent = str_get_html($htmlContent);
+
+ /*
+ * Notice: simple_html_dom currently doesn't support "->find(*)", which is a
+ * known issue: https://sourceforge.net/p/simplehtmldom/bugs/157/
+ *
+ * A solution to this is to find all nodes WITHOUT a specific attribute. If
+ * the attribute is very unlikely to appear in the DOM, this is essentially
+ * returning all nodes.
+ *
+ * "*[!b38fd2b1fe7f4747d6b1c1254ccd055e]" is doing exactly that. The attrib
+ * "b38fd2b1fe7f4747d6b1c1254ccd055e" is very unlikely to appear in any DOM.
+ */
+ foreach($htmlContent->find('*[!b38fd2b1fe7f4747d6b1c1254ccd055e]') as $element) {
+
+ if(preg_match($regex, $element->style, $matches) > 0) {
+
+ $element->outertext = '<img style="display:block;" src="' . $matches[1] . '" />';
+
+ }
+
+ }
+
+ return $htmlContent;
+
+}
+
+/**
+ * Convert relative links in HTML into absolute links
+ *
+ * This function is based on `php-urljoin`.
+ *
+ * @link https://github.com/plaidfluff/php-urljoin php-urljoin
+ *
+ * @param string|object $content The HTML content. Supports HTML objects or string objects
+ * @param string $server Fully qualified URL to the page containing relative links
+ * @return object Content with fixed URLs.
+ */
+function defaultLinkTo($content, $server){
+ $string_convert = false;
+ if (is_string($content)) {
+ $string_convert = true;
+ $content = str_get_html($content);
+ }
+
+ foreach($content->find('img') as $image) {
+ $image->src = urljoin($server, $image->src);
+ }
+
+ foreach($content->find('a') as $anchor) {
+ $anchor->href = urljoin($server, $anchor->href);
+ }
+
+ if ($string_convert) {
+ $content = $content->outertext;
+ }
+
+ return $content;
+}
+
+/**
+ * Extract the first part of a string matching the specified start and end delimiters
+ *
+ * @param string $string Input string, e.g. `<div>Post author: John Doe</div>`
+ * @param string $start Start delimiter, e.g. `author: `
+ * @param string $end End delimiter, e.g. `<`
+ * @return string|bool Extracted string, e.g. `John Doe`, or false if the
+ * delimiters were not found.
+ */
+function extractFromDelimiters($string, $start, $end) {
+ if (strpos($string, $start) !== false) {
+ $section_retrieved = substr($string, strpos($string, $start) + strlen($start));
+ $section_retrieved = substr($section_retrieved, 0, strpos($section_retrieved, $end));
+ return $section_retrieved;
+ } return false;
+}
+
+/**
+ * Remove one or more part(s) of a string using a start and end delmiters
+ *
+ * @param string $string Input string, e.g. `foo<script>superscript()</script>bar`
+ * @param string $start Start delimiter, e.g. `<script`
+ * @param string $end End delimiter, e.g. `</script>`
+ * @return string Cleaned string, e.g. `foobar`
+ */
+function stripWithDelimiters($string, $start, $end) {
+ while(strpos($string, $start) !== false) {
+ $section_to_remove = substr($string, strpos($string, $start));
+ $section_to_remove = substr($section_to_remove, 0, strpos($section_to_remove, $end) + strlen($end));
+ $string = str_replace($section_to_remove, '', $string);
+ }
+ return $string;
+}
+
+/**
+ * Remove HTML sections containing one or more sections using the same HTML tag
+ *
+ * @param string $string Input string, e.g. `foo<div class="ads"><div>ads</div>ads</div>bar`
+ * @param string $tag_name Name of the HTML tag, e.g. `div`
+ * @param string $tag_start Start of the HTML tag to remove, e.g. `<div class="ads">`
+ * @return string Cleaned String, e.g. `foobar`
+ *
+ * @todo This function needs more documentation to make it maintainable.
+ */
+function stripRecursiveHTMLSection($string, $tag_name, $tag_start){
+ $open_tag = '<' . $tag_name;
+ $close_tag = '</' . $tag_name . '>';
+ $close_tag_length = strlen($close_tag);
+ if(strpos($tag_start, $open_tag) === 0) {
+ while(strpos($string, $tag_start) !== false) {
+ $max_recursion = 100;
+ $section_to_remove = null;
+ $section_start = strpos($string, $tag_start);
+ $search_offset = $section_start;
+ do {
+ $max_recursion--;
+ $section_end = strpos($string, $close_tag, $search_offset);
+ $search_offset = $section_end + $close_tag_length;
+ $section_to_remove = substr($string, $section_start, $section_end - $section_start + $close_tag_length);
+ $open_tag_count = substr_count($section_to_remove, $open_tag);
+ $close_tag_count = substr_count($section_to_remove, $close_tag);
+ } while ($open_tag_count > $close_tag_count && $max_recursion > 0);
+ $string = str_replace($section_to_remove, '', $string);
+ }
+ }
+ return $string;
+}
+
+/**
+ * Convert Markdown into HTML. Only a subset of the Markdown syntax is implemented.
+ *
+ * @link https://daringfireball.net/projects/markdown/ Markdown
+ * @link https://github.github.com/gfm/ GitHub Flavored Markdown Spec
+ *
+ * @param string $string Input string in Markdown format
+ * @return string output string in HTML format
+ */
+function markdownToHtml($string) {
+
+ //For more details about how these regex work:
+ // https://github.com/RSS-Bridge/rss-bridge/pull/802#discussion_r216138702
+ // Images: https://regex101.com/r/JW9Evr/1
+ // Links: https://regex101.com/r/eRGVe7/1
+ // Bold: https://regex101.com/r/2p40Y0/1
+ // Italic: https://regex101.com/r/xJkET9/1
+ // Separator: https://regex101.com/r/ZBEqFP/1
+ // Plain URL: https://regex101.com/r/2JHYwb/1
+ // Site name: https://regex101.com/r/qIuKYE/1
+
+ $string = preg_replace('/\!\[([^\]]+)\]\(([^\) ]+)(?: [^\)]+)?\)/', '<img src="$2" alt="$1" />', $string);
+ $string = preg_replace('/\[([^\]]+)\]\(([^\)]+)\)/', '<a href="$2">$1</a>', $string);
+ $string = preg_replace('/\*\*(.*)\*\*/U', '<b>$1</b>', $string);
+ $string = preg_replace('/\*(.*)\*/U', '<i>$1</i>', $string);
+ $string = preg_replace('/__(.*)__/U', '<b>$1</b>', $string);
+ $string = preg_replace('/_(.*)_/U', '<i>$1</i>', $string);
+ $string = preg_replace('/[-]{6,99}/', '<hr />', $string);
+ $string = str_replace('&#10;', '<br />', $string);
+ $string = preg_replace('/([^"])(https?:\/\/[^ "<]+)([^"])/', '$1<a href="$2">$2</a>$3', $string . ' ');
+ $string = preg_replace('/([^"\/])(www\.[^ "<]+)([^"])/', '$1<a href="http://$2">$2</a>$3', $string . ' ');
+
+ //As the regex are not perfect, we need to fix <i> and </i> that are introduced in URLs
+ // Fixup regex <i>: https://regex101.com/r/NTRPf6/1
+ // Fixup regex </i>: https://regex101.com/r/aNklRp/1
+
+ $count = 1;
+ while($count > 0) {
+ $string = preg_replace('/ (src|href)="([^"]+)<i>([^"]+)"/U', ' $1="$2_$3"', $string, -1, $count);
+ }
+
+ $count = 1;
+ while($count > 0) {
+ $string = preg_replace('/ (src|href)="([^"]+)<\/i>([^"]+)"/U', ' $1="$2_$3"', $string, -1, $count);
+ }
+
+ return '<div>' . trim($string) . '</div>';
+}
diff --git a/lib/rssbridge.php b/lib/rssbridge.php
new file mode 100644
index 0000000..dbeab26
--- /dev/null
+++ b/lib/rssbridge.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
+ * Atom feeds for websites that don't have one.
+ *
+ * For the full license information, please view the UNLICENSE file distributed
+ * with this source code.
+ *
+ * @package Core
+ * @license http://unlicense.org/ UNLICENSE
+ * @link https://github.com/rss-bridge/rss-bridge
+ */
+
+/** Path to the root folder of RSS-Bridge (where index.php is located) */
+define('PATH_ROOT', __DIR__ . '/../');
+
+/** Path to the core library */
+define('PATH_LIB', __DIR__ . '/../lib/'); // Path to core library
+
+/** Path to the vendor library */
+define('PATH_LIB_VENDOR', __DIR__ . '/../vendor/');
+
+/** Path to the bridges library */
+define('PATH_LIB_BRIDGES', __DIR__ . '/../bridges/');
+
+/** Path to the formats library */
+define('PATH_LIB_FORMATS', __DIR__ . '/../formats/');
+
+/** Path to the caches library */
+define('PATH_LIB_CACHES', __DIR__ . '/../caches/');
+
+/** Path to the cache folder */
+define('PATH_CACHE', __DIR__ . '/../cache/');
+
+/** Path to the whitelist file */
+define('WHITELIST', __DIR__ . '/../whitelist.txt');
+
+/** URL to the RSS-Bridge repository */
+define('REPOSITORY', 'https://github.com/RSS-Bridge/rss-bridge/');
+
+// Interfaces
+require_once PATH_LIB . 'BridgeInterface.php';
+require_once PATH_LIB . 'CacheInterface.php';
+require_once PATH_LIB . 'FormatInterface.php';
+
+// Classes
+require_once PATH_LIB . 'FeedItem.php';
+require_once PATH_LIB . 'Debug.php';
+require_once PATH_LIB . 'Exceptions.php';
+require_once PATH_LIB . 'Format.php';
+require_once PATH_LIB . 'FormatAbstract.php';
+require_once PATH_LIB . 'Bridge.php';
+require_once PATH_LIB . 'BridgeAbstract.php';
+require_once PATH_LIB . 'FeedExpander.php';
+require_once PATH_LIB . 'Cache.php';
+require_once PATH_LIB . 'Authentication.php';
+require_once PATH_LIB . 'Configuration.php';
+require_once PATH_LIB . 'BridgeCard.php';
+require_once PATH_LIB . 'BridgeList.php';
+require_once PATH_LIB . 'ParameterValidator.php';
+
+// Functions
+require_once PATH_LIB . 'html.php';
+require_once PATH_LIB . 'error.php';
+require_once PATH_LIB . 'contents.php';
+
+// Vendor
+require_once PATH_LIB_VENDOR . 'simplehtmldom/simple_html_dom.php';
+require_once PATH_LIB_VENDOR . 'php-urljoin/src/urljoin.php';
+
+// Initialize static members
+try {
+ Bridge::setWorkingDir(PATH_LIB_BRIDGES);
+ Format::setWorkingDir(PATH_LIB_FORMATS);
+ Cache::setWorkingDir(PATH_LIB_CACHES);
+} catch(Exception $e) {
+ error_log($e);
+ header('Content-type: text/plain', true, 500);
+ die($e->getMessage());
+}
diff --git a/static/HtmlFormat.css b/static/HtmlFormat.css
new file mode 100644
index 0000000..e17e325
--- /dev/null
+++ b/static/HtmlFormat.css
@@ -0,0 +1,87 @@
+html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, figcaption, figure, footer, header, hgroup, menu, nav, section, summary, time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ outline: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+/* HTML5 display-role reset for older browsers */
+ article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section {
+ display: block;
+}
+/* Let's go for the actual style */
+ body {
+ background-color: #f0f0f0;
+ font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
+}
+ a, a:link, a:visited {
+ color: #2196F3;
+ text-decoration: none;
+}
+ a:hover {
+ text-decoration: underline;
+}
+ img {
+ max-width: 100%;
+}
+/* Section */
+ section {
+ background-color: #FFFFFF;
+ width: 60%;
+ margin: 30px auto;
+ padding: 15px 15px;
+ text-align: center;
+ box-shadow: 0 6px 15px rgba(0, 0, 0, 0.09);
+ border-radius: 4px;
+}
+ section > h2 {
+ font-size: 200%;
+ font-weight: bold;
+ text-align: center;
+}
+ h1.pagetitle {
+ margin: 40px 0 20px;
+ font-size: 300%;
+ font-weight: bold;
+ text-align: center;
+ color: #2196F3;
+}
+ h1.pagetitle > a {
+ color: #2196F3;
+}
+ a.backlink, a.backlink:link, a.backlink:visited, a.itemtitle, a.itemtitle:link, a.itemtitle:visited {
+ color: #2196F3;
+}
+ .buttons {
+ text-align: center;
+}
+ section > div.content, section > div.attachments {
+ padding: 10px;
+}
+ section > div.attachments > li.enclosure {
+ list-style-type: circle;
+ list-style-position: inside;
+}
+ section > time, section > p.author {
+ color: #888;
+ font-size: 80%;
+ padding: 10px;
+}
+ button {
+ line-height: 1.9em;
+ color: #FFF;
+ font-weight: bold;
+ vertical-align: middle;
+ padding: 6px 12px;
+ margin: 12px auto 0px;
+ border-radius: 4px;
+ border: 1px solid transparent;
+ background: #2196F3 none repeat scroll 0% 0%;
+ cursor: pointer;
+ width: 200px;
+}
+ button:hover {
+ background: #49afff;
+} \ No newline at end of file
diff --git a/static/search.js b/static/search.js
new file mode 100644
index 0000000..daf3287
--- /dev/null
+++ b/static/search.js
@@ -0,0 +1,60 @@
+function search() {
+
+ var searchTerm = document.getElementById('searchfield').value;
+ var searchableElements = document.getElementsByTagName('section');
+
+ var regexMatch = new RegExp(searchTerm, 'i');
+
+ // Attempt to create anchor from search term (will default to 'localhost' on failure)
+ var searchTermUri = document.createElement('a');
+ searchTermUri.href = searchTerm;
+
+ if(searchTermUri.hostname == 'localhost') {
+ searchTermUri = null;
+ } else {
+
+ // Ignore "www."
+ if(searchTermUri.hostname.indexOf('www.') === 0) {
+ searchTermUri.hostname = searchTermUri.hostname.substr(4);
+ }
+
+ }
+
+ for(var i = 0; i < searchableElements.length; i++) {
+
+ var textValue = searchableElements[i].getAttribute('data-ref');
+ var anchors = searchableElements[i].getElementsByTagName('a');
+
+ if(anchors != null && anchors.length > 0) {
+
+ var uriValue = anchors[0]; // First anchor is bridge URI
+
+ // Ignore "www."
+ if(uriValue.hostname.indexOf('www.') === 0) {
+ uriValue.hostname = uriValue.hostname.substr(4);
+ }
+
+ }
+
+ if(textValue != null || uriValue != null) {
+
+ if(textValue.match(regexMatch) != null ||
+ uriValue.hostname.match(regexMatch) ||
+ searchTermUri != null &&
+ uriValue.hostname != 'localhost' && (
+ uriValue.href.match(regexMatch) != null ||
+ uriValue.hostname == searchTermUri.hostname)) {
+
+ searchableElements[i].style.display = 'block';
+
+ } else {
+
+ searchableElements[i].style.display = 'none';
+
+ }
+
+ }
+
+ }
+
+}
diff --git a/static/select.js b/static/select.js
new file mode 100644
index 0000000..792b92d
--- /dev/null
+++ b/static/select.js
@@ -0,0 +1,10 @@
+function select(){
+ var fragment = window.location.hash.substr(1);
+ var bridge = document.getElementById(fragment);
+
+ if(bridge !== null) {
+ bridge.getElementsByClassName('showmore-box')[0].checked = true;
+ }
+}
+
+document.addEventListener('DOMContentLoaded', select); \ No newline at end of file
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..fa70f2d
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,306 @@
+html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, figcaption, figure, footer, header, hgroup, menu, nav, section, summary, time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ outline: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+}
+
+/* HTML5 display-role reset for older browsers */
+article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section {
+ display: block;
+}
+
+/* Adjust parameters for browsers that don't support the grid layout */
+
+.parameters label:before {
+ content: " ";
+ display: block;
+}
+
+/* Let's go for the actual style */
+body {
+ background-color: #f0f0f0;
+ font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
+}
+
+a, a:link, a:visited {
+ color: #2196F3;
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+/* Header */
+
+header {
+ margin-top: 40px;
+ text-align: center;
+ color: #1182DB;
+}
+
+header > h1 {
+ font-size: 500%;
+ font-weight: bold;
+}
+
+header > h2 {
+ margin-left: 1em;
+ font-size: 200%;
+}
+
+header > section.warning {
+ width: 40%;
+ background-color: #ffc600;
+ color: #5f5f5f;
+}
+
+header > section.critical-warning {
+ width: 40%;
+ background-color: #cf3e3e;
+ font-weight: bold;
+ color: white;
+}
+
+select,
+input[type="text"],
+input[type="number"] {
+ background-color: white;
+ color: #404552;
+ border: 1px solid #dedede;
+ margin-left: 8px;
+ margin-bottom: 10px;
+ padding: 5px 10px;
+}
+
+select:focus,
+input[type="text"]:focus,
+input[type="number"]:focus {
+ outline: none;
+ border-color: #888;
+}
+
+.searchbar {
+ width: 40%;
+ margin: 40px auto 100px;
+}
+
+.searchbar input[type="text"] {
+ width: 90%;
+ margin: auto;
+ font-size: 1.1em;
+ text-align: center;
+ margin-bottom: 10px;
+}
+
+.searchbar input[type="text"]::placeholder {
+ text-align: center;
+}
+
+.searchbar input[type="text"]:focus::-webkit-input-placeholder,
+.searchbar input[type="text"]:focus::-moz-placeholder,
+.searchbar input[type="text"]:focus:-moz-placeholder,
+.searchbar input[type="text"]:focus:-ms-input-placeholder {
+ opacity: 0;
+}
+
+.searchbar > h3 {
+ font-size: 200%;
+ font-weight: bold;
+ color: #1182DB;
+ margin-bottom: 10px;
+}
+
+/* Section */
+section {
+ background-color: #FFFFFF;
+ width: 60%;
+ margin: 30px auto;
+ padding: 15px 15px;
+ text-align: center;
+ box-shadow: 0 6px 15px rgba(0, 0, 0, 0.09);
+ border-radius: 4px;
+}
+
+section.footer {
+ opacity: 0.5;
+}
+
+section.footer:hover {
+ opacity: 1;
+}
+
+section.footer .version {
+ font-size: 80%;
+}
+
+section > h2 {
+ font-size: 200%;
+ font-weight: bold;
+}
+
+/* Buttons */
+button {
+ line-height: 1.9em;
+ color: #FFF;
+ font-weight: bold;
+ vertical-align: middle;
+ padding: 6px 12px;
+ margin: 12px auto 0px;
+ border-radius: 4px;
+ border: 1px solid transparent;
+ background: #2196F3 none repeat scroll 0% 0%;
+ cursor: pointer;
+ width: calc(20% - 4px);
+}
+
+button.small {
+ width: auto;
+ line-height: 1.2em;
+}
+
+button:hover {
+ background: #49afff;
+}
+
+.description {
+ margin: 10px;
+}
+
+h5 {
+ margin: 20px;
+ font-weight: bold;
+}
+
+form {
+ margin-bottom: 6px;
+}
+
+.parameters label::first-letter {
+ text-transform: capitalize;
+}
+
+.parameters label::after {
+ content: ' :';
+}
+
+@supports (display: grid) {
+
+ .parameters {
+ display: grid;
+ padding: 12px 0;
+ grid-template-columns: 40% max-content;
+ grid-column-gap: 10px;
+ grid-row-gap: 5px;
+ }
+
+ .parameters label {
+ text-align: right;
+ }
+
+ .parameters label::before {
+ content: none;
+ }
+
+ .parameters input[type="text"],
+ .parameters input[type="number"],
+ .parameters input[type="checkbox"],
+ .parameters select {
+ margin-left: 0;
+ }
+
+ .parameters input[type="text"],
+ .parameters input[type="number"] {
+ width: auto;
+ color: #404552;
+ }
+
+ .parameters input[type="checkbox"] {
+ width: 20px;
+ height: 20px;
+ }
+
+} /* @supports (display: grid) */
+
+.maintainer {
+ color: #888888;
+ font-size: 70%;
+ text-align: right;
+}
+
+.secure-warning {
+ background-color: #ffc600;
+ color: #5f5f5f;
+ box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.3);
+ border-radius: 2px;
+ border: 1px solid transparent;
+ width: 80%;
+ margin: auto;
+ margin-bottom: 6px;
+}
+
+form {
+ display: none;
+}
+
+select {
+ padding: 5px 10px;
+ margin-left: 8px;
+}
+
+h5 {
+ display: none;
+}
+
+/* Show more / less */
+.showmore-box {
+ display: none;
+}
+
+.showmore, .showless {
+ color: #888888;
+ cursor: pointer;
+}
+.showmore:hover, .showless:hover {
+ color: #000;
+ cursor: pointer;
+}
+
+.showmore-box:checked ~ .showmore {
+ display: none;
+}
+
+.showmore-box:not(:checked) ~ .showless {
+ display: none;
+}
+
+.showmore-box:checked ~ form, .showmore-box:checked ~ h5 {
+ display: block;
+}
+
+/* Additional styles for error pages */
+.exception-message {
+ background-color: #c00000;
+ color: #FFFFFF;
+ font-weight: bold;
+ box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.3);
+ border-radius: 2px;
+ border: 1px solid transparent;
+ width: 80%;
+ margin: auto;
+ margin-bottom: 6px;
+}
+
+.advice {
+ margin-left: auto;
+ margin-right: auto;
+ display: table;
+}
+
+.advice > li {
+ text-align: left;
+}
diff --git a/vendor/php-urljoin/src/urljoin.php b/vendor/php-urljoin/src/urljoin.php
new file mode 100644
index 0000000..ef84fbc
--- /dev/null
+++ b/vendor/php-urljoin/src/urljoin.php
@@ -0,0 +1,140 @@
+<?php
+
+/*
+
+A spiritual port of Python's urlparse.urljoin() function to PHP. Why this isn't in the standard library is anyone's guess.
+
+Author: fluffy, http://beesbuzz.biz/
+Latest version at: https://github.com/plaidfluff/php-urljoin
+
+ */
+
+function urljoin($base, $rel) {
+ if (!$base) {
+ return $rel;
+ }
+
+ if (!$rel) {
+ return $base;
+ }
+
+ $uses_relative = array('', 'ftp', 'http', 'gopher', 'nntp', 'imap',
+ 'wais', 'file', 'https', 'shttp', 'mms',
+ 'prospero', 'rtsp', 'rtspu', 'sftp',
+ 'svn', 'svn+ssh', 'ws', 'wss');
+
+ $pbase = parse_url($base);
+ $prel = parse_url($rel);
+
+ if ($prel === false || preg_match('/^[a-z0-9\-.]*[^a-z0-9\-.:][a-z0-9\-.]*:/i', $rel)) {
+ /*
+ Either parse_url couldn't parse this, or the original URL
+ fragment had an invalid scheme character before the first :,
+ which can confuse parse_url
+ */
+ $prel = array('path' => $rel);
+ }
+
+ if (array_key_exists('path', $pbase) && $pbase['path'] === '/') {
+ unset($pbase['path']);
+ }
+
+ if (isset($prel['scheme'])) {
+ if ($prel['scheme'] != $pbase['scheme'] || in_array($prel['scheme'], $uses_relative) == false) {
+ return $rel;
+ }
+ }
+
+ $merged = array_merge($pbase, $prel);
+
+ // Handle relative paths:
+ // 'path/to/file.ext'
+ // './path/to/file.ext'
+ if (array_key_exists('path', $prel) && substr($prel['path'], 0, 1) != '/') {
+
+ // Normalize: './path/to/file.ext' => 'path/to/file.ext'
+ if (substr($prel['path'], 0, 2) === './') {
+ $prel['path'] = substr($prel['path'], 2);
+ }
+
+ if (array_key_exists('path', $pbase)) {
+ $dir = preg_replace('@/[^/]*$@', '', $pbase['path']);
+ $merged['path'] = $dir . '/' . $prel['path'];
+ } else {
+ $merged['path'] = '/' . $prel['path'];
+ }
+
+ }
+
+ if(array_key_exists('path', $merged)) {
+ // Get the path components, and remove the initial empty one
+ $pathParts = explode('/', $merged['path']);
+ array_shift($pathParts);
+
+ $path = [];
+ $prevPart = '';
+ foreach ($pathParts as $part) {
+ if ($part == '..' && count($path) > 0) {
+ // Cancel out the parent directory (if there's a parent to cancel)
+ $parent = array_pop($path);
+ // But if it was also a parent directory, leave it in
+ if ($parent == '..') {
+ array_push($path, $parent);
+ array_push($path, $part);
+ }
+ } else if ($prevPart != '' || ($part != '.' && $part != '')) {
+ // Don't include empty or current-directory components
+ if ($part == '.') {
+ $part = '';
+ }
+ array_push($path, $part);
+ }
+ $prevPart = $part;
+ }
+ $merged['path'] = '/' . implode('/', $path);
+ }
+
+ $ret = '';
+ if (isset($merged['scheme'])) {
+ $ret .= $merged['scheme'] . ':';
+ }
+
+ if (isset($merged['scheme']) || isset($merged['host'])) {
+ $ret .= '//';
+ }
+
+ if (isset($prel['host'])) {
+ $hostSource = $prel;
+ } else {
+ $hostSource = $pbase;
+ }
+
+ // username, password, and port are associated with the hostname, not merged
+ if (isset($hostSource['host'])) {
+ if (isset($hostSource['user'])) {
+ $ret .= $hostSource['user'];
+ if (isset($hostSource['pass'])) {
+ $ret .= ':' . $hostSource['pass'];
+ }
+ $ret .= '@';
+ }
+ $ret .= $hostSource['host'];
+ if (isset($hostSource['port'])) {
+ $ret .= ':' . $hostSource['port'];
+ }
+ }
+
+ if (isset($merged['path'])) {
+ $ret .= $merged['path'];
+ }
+
+ if (isset($prel['query'])) {
+ $ret .= '?' . $prel['query'];
+ }
+
+ if (isset($prel['fragment'])) {
+ $ret .= '#' . $prel['fragment'];
+ }
+
+ return $ret;
+}
diff --git a/vendor/simplehtmldom/simple_html_dom.php b/vendor/simplehtmldom/simple_html_dom.php
new file mode 100644
index 0000000..43a1172
--- /dev/null
+++ b/vendor/simplehtmldom/simple_html_dom.php
@@ -0,0 +1,2172 @@
+<?php
+/**
+ * Website: http://sourceforge.net/projects/simplehtmldom/
+ * Additional projects that may be used: http://sourceforge.net/projects/debugobject/
+ * Acknowledge: Jose Solorzano (https://sourceforge.net/projects/php-html/)
+ * Contributions by:
+ * Yousuke Kumakura (Attribute filters)
+ * Vadim Voituk (Negative indexes supports of "find" method)
+ * Antcs (Constructor with automatically load contents either text or file/url)
+ *
+ * all affected sections have comments starting with "PaperG"
+ *
+ * Paperg - Added case insensitive testing of the value of the selector.
+ * Paperg - Added tag_start for the starting index of tags - NOTE: This works but not accurately.
+ * This tag_start gets counted AFTER \r\n have been crushed out, and after the remove_noice calls so it will not reflect the REAL position of the tag in the source,
+ * it will almost always be smaller by some amount.
+ * We use this to determine how far into the file the tag in question is. This "percentage will never be accurate as the $dom->size is the "real" number of bytes the dom was created from.
+ * but for most purposes, it's a really good estimation.
+ * Paperg - Added the forceTagsClosed to the dom constructor. Forcing tags closed is great for malformed html, but it CAN lead to parsing errors.
+ * Allow the user to tell us how much they trust the html.
+ * Paperg add the text and plaintext to the selectors for the find syntax. plaintext implies text in the innertext of a node. text implies that the tag is a text node.
+ * This allows for us to find tags based on the text they contain.
+ * Create find_ancestor_tag to see if a tag is - at any level - inside of another specific tag.
+ * Paperg: added parse_charset so that we know about the character set of the source document.
+ * NOTE: If the user's system has a routine called get_last_retrieve_url_contents_content_type availalbe, we will assume it's returning the content-type header from the
+ * last transfer or curl_exec, and we will parse that and use it in preference to any other method of charset detection.
+ *
+ * Found infinite loop in the case of broken html in restore_noise. Rewrote to protect from that.
+ * PaperG (John Schlick) Added get_display_size for "IMG" tags.
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @author S.C. Chen <me578022@gmail.com>
+ * @author John Schlick
+ * @author Rus Carroll
+ * @version Rev. 1.7 (214)
+ * @package PlaceLocalInclude
+ * @subpackage simple_html_dom
+ */
+
+/**
+ * All of the Defines for the classes below.
+ * @author S.C. Chen <me578022@gmail.com>
+ */
+define('HDOM_TYPE_ELEMENT', 1);
+define('HDOM_TYPE_COMMENT', 2);
+define('HDOM_TYPE_TEXT', 3);
+define('HDOM_TYPE_ENDTAG', 4);
+define('HDOM_TYPE_ROOT', 5);
+define('HDOM_TYPE_UNKNOWN', 6);
+define('HDOM_QUOTE_DOUBLE', 0);
+define('HDOM_QUOTE_SINGLE', 1);
+define('HDOM_QUOTE_NO', 3);
+define('HDOM_INFO_BEGIN', 0);
+define('HDOM_INFO_END', 1);
+define('HDOM_INFO_QUOTE', 2);
+define('HDOM_INFO_SPACE', 3);
+define('HDOM_INFO_TEXT', 4);
+define('HDOM_INFO_INNER', 5);
+define('HDOM_INFO_OUTER', 6);
+define('HDOM_INFO_ENDSPACE',7);
+define('DEFAULT_TARGET_CHARSET', 'UTF-8');
+define('DEFAULT_BR_TEXT', "\r\n");
+define('DEFAULT_SPAN_TEXT', " ");
+define('MAX_FILE_SIZE', 10000000);
+
+/** Contents between curly braces "{" and "}" are interpreted as text */
+define('HDOM_SMARTY_AS_TEXT', 1);
+
+// helper functions
+// -----------------------------------------------------------------------------
+// get html dom from file
+// $maxlen is defined in the code as PHP_STREAM_COPY_ALL which is defined as -1.
+function file_get_html($url, $use_include_path = false, $context=null, $offset = 0, $maxLen=-1, $lowercase = true, $forceTagsClosed=true, $target_charset = DEFAULT_TARGET_CHARSET, $stripRN=true, $defaultBRText=DEFAULT_BR_TEXT, $defaultSpanText=DEFAULT_SPAN_TEXT)
+{
+ // Ensure maximum length is greater than zero
+ if($maxLen <= 0) { $maxLen = MAX_FILE_SIZE; }
+
+ // We DO force the tags to be terminated.
+ $dom = new simple_html_dom(null, $lowercase, $forceTagsClosed, $target_charset, $stripRN, $defaultBRText, $defaultSpanText);
+ // For sourceforge users: uncomment the next line and comment the retrieve_url_contents line 2 lines down if it is not already done.
+ $contents = file_get_contents($url, $use_include_path, $context, $offset, $maxLen);
+ // Paperg - use our own mechanism for getting the contents as we want to control the timeout.
+ //$contents = retrieve_url_contents($url);
+ if (empty($contents) || strlen($contents) > $maxLen)
+ {
+ return false;
+ }
+ // The second parameter can force the selectors to all be lowercase.
+ $dom->load($contents, $lowercase, $stripRN);
+ return $dom;
+}
+
+// get html dom from string
+function str_get_html($str, $lowercase=true, $forceTagsClosed=true, $target_charset = DEFAULT_TARGET_CHARSET, $stripRN=true, $defaultBRText=DEFAULT_BR_TEXT, $defaultSpanText=DEFAULT_SPAN_TEXT)
+{
+ $dom = new simple_html_dom(null, $lowercase, $forceTagsClosed, $target_charset, $stripRN, $defaultBRText, $defaultSpanText);
+ if (empty($str) || strlen($str) > MAX_FILE_SIZE)
+ {
+ $dom->clear();
+ return false;
+ }
+ $dom->load($str, $lowercase, $stripRN);
+ return $dom;
+}
+
+// dump html dom tree
+function dump_html_tree($node, $show_attr=true, $deep=0)
+{
+ $node->dump($node);
+}
+
+
+/**
+ * simple html dom node
+ * PaperG - added ability for "find" routine to lowercase the value of the selector.
+ * PaperG - added $tag_start to track the start position of the tag in the total byte index
+ *
+ * @package PlaceLocalInclude
+ */
+class simple_html_dom_node
+{
+ /**
+ * Node type
+ *
+ * Default is {@see HDOM_TYPE_TEXT}
+ *
+ * @var int
+ */
+ public $nodetype = HDOM_TYPE_TEXT;
+
+ /**
+ * Tag name
+ *
+ * Default is 'text'
+ *
+ * @var string
+ */
+ public $tag = 'text';
+
+ /**
+ * List of attributes
+ *
+ * @var array
+ */
+ public $attr = array();
+
+ /**
+ * List of child node objects
+ *
+ * @var array
+ */
+ public $children = array();
+ public $nodes = array();
+
+ /**
+ * The parent node object
+ *
+ * @var object|null
+ */
+ public $parent = null;
+
+ // The "info" array - see HDOM_INFO_... for what each element contains.
+ public $_ = array();
+
+ /**
+ * Start position of the tag in the document
+ *
+ * @var int
+ */
+ public $tag_start = 0;
+
+ /**
+ * The DOM object
+ *
+ * @var object|null
+ */
+ private $dom = null;
+
+ /**
+ * Construct new node object
+ *
+ * Adds itself to the list of DOM Nodes {@see simple_html_dom::$nodes}
+ */
+ function __construct($dom)
+ {
+ $this->dom = $dom;
+ $dom->nodes[] = $this;
+ }
+
+ function __destruct()
+ {
+ $this->clear();
+ }
+
+ function __toString()
+ {
+ return $this->outertext();
+ }
+
+ // clean up memory due to php5 circular references memory leak...
+ function clear()
+ {
+ $this->dom = null;
+ $this->nodes = null;
+ $this->parent = null;
+ $this->children = null;
+ }
+
+ // dump node's tree
+ function dump($show_attr=true, $deep=0)
+ {
+ $lead = str_repeat(' ', $deep);
+
+ echo $lead.$this->tag;
+ if ($show_attr && count($this->attr)>0)
+ {
+ echo '(';
+ foreach ($this->attr as $k=>$v)
+ echo "[$k]=>\"".$this->$k.'", ';
+ echo ')';
+ }
+ echo "\n";
+
+ if ($this->nodes)
+ {
+ foreach ($this->nodes as $c)
+ {
+ $c->dump($show_attr, $deep+1);
+ }
+ }
+ }
+
+
+ // Debugging function to dump a single dom node with a bunch of information about it.
+ function dump_node($echo=true)
+ {
+
+ $string = $this->tag;
+ if (count($this->attr)>0)
+ {
+ $string .= '(';
+ foreach ($this->attr as $k=>$v)
+ {
+ $string .= "[$k]=>\"".$this->$k.'", ';
+ }
+ $string .= ')';
+ }
+ if (count($this->_)>0)
+ {
+ $string .= ' $_ (';
+ foreach ($this->_ as $k=>$v)
+ {
+ if (is_array($v))
+ {
+ $string .= "[$k]=>(";
+ foreach ($v as $k2=>$v2)
+ {
+ $string .= "[$k2]=>\"".$v2.'", ';
+ }
+ $string .= ")";
+ } else {
+ $string .= "[$k]=>\"".$v.'", ';
+ }
+ }
+ $string .= ")";
+ }
+
+ if (isset($this->text))
+ {
+ $string .= " text: (" . $this->text . ")";
+ }
+
+ $string .= " HDOM_INNER_INFO: '";
+ if (isset($node->_[HDOM_INFO_INNER]))
+ {
+ $string .= $node->_[HDOM_INFO_INNER] . "'";
+ }
+ else
+ {
+ $string .= ' NULL ';
+ }
+
+ $string .= " children: " . count($this->children);
+ $string .= " nodes: " . count($this->nodes);
+ $string .= " tag_start: " . $this->tag_start;
+ $string .= "\n";
+
+ if ($echo)
+ {
+ echo $string;
+ return;
+ }
+ else
+ {
+ return $string;
+ }
+ }
+
+ /**
+ * Return or set parent node
+ *
+ * @param object|null $parent (optional) The parent node, `null` to return
+ * the current parent node.
+ * @return object|null The parent node
+ */
+ function parent($parent=null)
+ {
+ // I am SURE that this doesn't work properly.
+ // It fails to unset the current node from it's current parents nodes or children list first.
+ if ($parent !== null)
+ {
+ $this->parent = $parent;
+ $this->parent->nodes[] = $this;
+ $this->parent->children[] = $this;
+ }
+
+ return $this->parent;
+ }
+
+ /**
+ * @return bool True if the node has at least one child node
+ */
+ function has_child()
+ {
+ return !empty($this->children);
+ }
+
+ /**
+ * Get child node at specified index
+ *
+ * @param int $idx The index of the child node to return, `-1` to return all
+ * child nodes.
+ * @return object|array|null The child node at the specified index, all child
+ * nodes or null if the index is invalid.
+ */
+ function children($idx=-1)
+ {
+ if ($idx===-1)
+ {
+ return $this->children;
+ }
+ if (isset($this->children[$idx]))
+ {
+ return $this->children[$idx];
+ }
+ return null;
+ }
+
+ /**
+ * Get first child node
+ *
+ * @return object|null The first child node or null if the current node has
+ * no child nodes.
+ *
+ * @todo Use `empty()` instead of `count()` to improve performance on large
+ * arrays.
+ */
+ function first_child()
+ {
+ if (count($this->children)>0)
+ {
+ return $this->children[0];
+ }
+ return null;
+ }
+
+ /**
+ * Get last child node
+ *
+ * @return object|null The last child node or null if the current node has
+ * no child nodes.
+ *
+ * @todo Use `end()` to slightly improve performance on large arrays.
+ */
+ function last_child()
+ {
+ if (($count=count($this->children))>0)
+ {
+ return $this->children[$count-1];
+ }
+ return null;
+ }
+
+ /**
+ * Get next sibling node
+ *
+ * @return object|null The sibling node or null if the current node has no
+ * sibling nodes.
+ */
+ function next_sibling()
+ {
+ if ($this->parent===null)
+ {
+ return null;
+ }
+
+ $idx = 0;
+ $count = count($this->parent->children);
+ while ($idx<$count && $this!==$this->parent->children[$idx])
+ {
+ ++$idx;
+ }
+ if (++$idx>=$count)
+ {
+ return null;
+ }
+ return $this->parent->children[$idx];
+ }
+
+ /**
+ * Get previous sibling node
+ *
+ * @return object|null The sibling node or null if the current node has no
+ * sibling nodes.
+ */
+ function prev_sibling()
+ {
+ if ($this->parent===null) return null;
+ $idx = 0;
+ $count = count($this->parent->children);
+ while ($idx<$count && $this!==$this->parent->children[$idx])
+ ++$idx;
+ if (--$idx<0) return null;
+ return $this->parent->children[$idx];
+ }
+
+ /**
+ * Traverse ancestors to the first matching tag.
+ *
+ * @param string $tag Tag to find
+ * @return object|null First matching node in the DOM tree or null if no
+ * match was found.
+ *
+ * @todo Null is returned implicitly by calling ->parent on the root node.
+ * This behaviour could change at any time, rendering this function invalid.
+ */
+ function find_ancestor_tag($tag)
+ {
+ global $debug_object;
+ if (is_object($debug_object)) { $debug_object->debug_log_entry(1); }
+
+ // Start by including ourselves in the comparison.
+ $returnDom = $this;
+
+ while (!is_null($returnDom))
+ {
+ if (is_object($debug_object)) { $debug_object->debug_log(2, "Current tag is: " . $returnDom->tag); }
+
+ if ($returnDom->tag == $tag)
+ {
+ break;
+ }
+ $returnDom = $returnDom->parent;
+ }
+ return $returnDom;
+ }
+
+ /**
+ * Get node's inner text (everything inside the opening and closing tags)
+ *
+ * @return string
+ */
+ function innertext()
+ {
+ if (isset($this->_[HDOM_INFO_INNER])) return $this->_[HDOM_INFO_INNER];
+ if (isset($this->_[HDOM_INFO_TEXT])) return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]);
+
+ $ret = '';
+ foreach ($this->nodes as $n)
+ $ret .= $n->outertext();
+ return $ret;
+ }
+
+ /**
+ * Get node's outer text (everything including the opening and closing tags)
+ *
+ * @return string
+ */
+ function outertext()
+ {
+ global $debug_object;
+ if (is_object($debug_object))
+ {
+ $text = '';
+ if ($this->tag == 'text')
+ {
+ if (!empty($this->text))
+ {
+ $text = " with text: " . $this->text;
+ }
+ }
+ $debug_object->debug_log(1, 'Innertext of tag: ' . $this->tag . $text);
+ }
+
+ if ($this->tag==='root') return $this->innertext();
+
+ // trigger callback
+ if ($this->dom && $this->dom->callback!==null)
+ {
+ call_user_func_array($this->dom->callback, array($this));
+ }
+
+ if (isset($this->_[HDOM_INFO_OUTER])) return $this->_[HDOM_INFO_OUTER];
+ if (isset($this->_[HDOM_INFO_TEXT])) return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]);
+
+ // render begin tag
+ if ($this->dom && $this->dom->nodes[$this->_[HDOM_INFO_BEGIN]])
+ {
+ $ret = $this->dom->nodes[$this->_[HDOM_INFO_BEGIN]]->makeup();
+ } else {
+ $ret = "";
+ }
+
+ // render inner text
+ if (isset($this->_[HDOM_INFO_INNER]))
+ {
+ // If it's a br tag... don't return the HDOM_INNER_INFO that we may or may not have added.
+ if ($this->tag != "br")
+ {
+ $ret .= $this->_[HDOM_INFO_INNER];
+ }
+ } else {
+ if ($this->nodes)
+ {
+ foreach ($this->nodes as $n)
+ {
+ $ret .= $this->convert_text($n->outertext());
+ }
+ }
+ }
+
+ // render end tag
+ if (isset($this->_[HDOM_INFO_END]) && $this->_[HDOM_INFO_END]!=0)
+ $ret .= '</'.$this->tag.'>';
+ return $ret;
+ }
+
+ /**
+ * Get node's plain text (everything excluding all tags)
+ *
+ * @return string
+ */
+ function text()
+ {
+ if (isset($this->_[HDOM_INFO_INNER])) return $this->_[HDOM_INFO_INNER];
+ switch ($this->nodetype)
+ {
+ case HDOM_TYPE_TEXT: return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]);
+ case HDOM_TYPE_COMMENT: return '';
+ case HDOM_TYPE_UNKNOWN: return '';
+ }
+ if (strcasecmp($this->tag, 'script')===0) return '';
+ if (strcasecmp($this->tag, 'style')===0) return '';
+
+ $ret = '';
+ // In rare cases, (always node type 1 or HDOM_TYPE_ELEMENT - observed for some span tags, and some p tags) $this->nodes is set to NULL.
+ // NOTE: This indicates that there is a problem where it's set to NULL without a clear happening.
+ // WHY is this happening?
+ if (!is_null($this->nodes))
+ {
+ foreach ($this->nodes as $n)
+ {
+ // Start paragraph after a blank line
+ if ($n->tag == 'p')
+ {
+ $ret .= "\n\n";
+ }
+
+ $ret .= $this->convert_text($n->text());
+
+ // If this node is a span... add a space at the end of it so multiple spans don't run into each other. This is plaintext after all.
+ if ($n->tag == "span")
+ {
+ $ret .= $this->dom->default_span_text;
+ }
+ }
+ }
+ return trim($ret);
+ }
+
+ /**
+ * Get node's xml text (inner text as a CDATA section)
+ *
+ * @return string
+ */
+ function xmltext()
+ {
+ $ret = $this->innertext();
+ $ret = str_ireplace('<![CDATA[', '', $ret);
+ $ret = str_replace(']]>', '', $ret);
+ return $ret;
+ }
+
+ // build node's text with tag
+ function makeup()
+ {
+ // text, comment, unknown
+ if (isset($this->_[HDOM_INFO_TEXT])) return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]);
+
+ $ret = '<'.$this->tag;
+ $i = -1;
+
+ foreach ($this->attr as $key=>$val)
+ {
+ ++$i;
+
+ // skip removed attribute
+ if ($val===null || $val===false)
+ continue;
+
+ $ret .= $this->_[HDOM_INFO_SPACE][$i][0];
+ //no value attr: nowrap, checked selected...
+ if ($val===true)
+ $ret .= $key;
+ else {
+ switch ($this->_[HDOM_INFO_QUOTE][$i])
+ {
+ case HDOM_QUOTE_DOUBLE: $quote = '"'; break;
+ case HDOM_QUOTE_SINGLE: $quote = '\''; break;
+ default: $quote = '';
+ }
+ $ret .= $key.$this->_[HDOM_INFO_SPACE][$i][1].'='.$this->_[HDOM_INFO_SPACE][$i][2].$quote.$val.$quote;
+ }
+ }
+ $ret = $this->dom->restore_noise($ret);
+ return $ret . $this->_[HDOM_INFO_ENDSPACE] . '>';
+ }
+
+ // find elements by css selector
+ //PaperG - added ability for find to lowercase the value of the selector.
+ function find($selector, $idx=null, $lowercase=false)
+ {
+ $selectors = $this->parse_selector($selector);
+ if (($count=count($selectors))===0) return array();
+ $found_keys = array();
+
+ // find each selector
+ for ($c=0; $c<$count; ++$c)
+ {
+ // The change on the below line was documented on the sourceforge code tracker id 2788009
+ // used to be: if (($levle=count($selectors[0]))===0) return array();
+ if (($levle=count($selectors[$c]))===0) return array();
+ if (!isset($this->_[HDOM_INFO_BEGIN])) return array();
+
+ $head = array($this->_[HDOM_INFO_BEGIN]=>1);
+
+ // handle descendant selectors, no recursive!
+ for ($l=0; $l<$levle; ++$l)
+ {
+ $ret = array();
+ foreach ($head as $k=>$v)
+ {
+ $n = ($k===-1) ? $this->dom->root : $this->dom->nodes[$k];
+ //PaperG - Pass this optional parameter on to the seek function.
+ $n->seek($selectors[$c][$l], $ret, $lowercase);
+ }
+ $head = $ret;
+ }
+
+ foreach ($head as $k=>$v)
+ {
+ if (!isset($found_keys[$k]))
+ {
+ $found_keys[$k] = 1;
+ }
+ }
+ }
+
+ // sort keys
+ ksort($found_keys);
+
+ $found = array();
+ foreach ($found_keys as $k=>$v)
+ $found[] = $this->dom->nodes[$k];
+
+ // return nth-element or array
+ if (is_null($idx)) return $found;
+ else if ($idx<0) $idx = count($found) + $idx;
+ return (isset($found[$idx])) ? $found[$idx] : null;
+ }
+
+ // seek for given conditions
+ // PaperG - added parameter to allow for case insensitive testing of the value of a selector.
+ protected function seek($selector, &$ret, $lowercase=false)
+ {
+ global $debug_object;
+ if (is_object($debug_object)) { $debug_object->debug_log_entry(1); }
+
+ list($tag, $key, $val, $exp, $no_key) = $selector;
+
+ // xpath index
+ if ($tag && $key && is_numeric($key))
+ {
+ $count = 0;
+ foreach ($this->children as $c)
+ {
+ if ($tag==='*' || $tag===$c->tag) {
+ if (++$count==$key) {
+ $ret[$c->_[HDOM_INFO_BEGIN]] = 1;
+ return;
+ }
+ }
+ }
+ return;
+ }
+
+ $end = (!empty($this->_[HDOM_INFO_END])) ? $this->_[HDOM_INFO_END] : 0;
+ if ($end==0) {
+ $parent = $this->parent;
+ while (!isset($parent->_[HDOM_INFO_END]) && $parent!==null) {
+ $end -= 1;
+ $parent = $parent->parent;
+ }
+ $end += $parent->_[HDOM_INFO_END];
+ }
+
+ for ($i=$this->_[HDOM_INFO_BEGIN]+1; $i<$end; ++$i) {
+ $node = $this->dom->nodes[$i];
+
+ $pass = true;
+
+ if ($tag==='*' && !$key) {
+ if (in_array($node, $this->children, true))
+ $ret[$i] = 1;
+ continue;
+ }
+
+ // compare tag
+ if ($tag && $tag!=$node->tag && $tag!=='*') {$pass=false;}
+ // compare key
+ if ($pass && $key) {
+ if ($no_key) {
+ if (isset($node->attr[$key])) $pass=false;
+ } else {
+ if (($key != "plaintext") && !isset($node->attr[$key])) $pass=false;
+ }
+ }
+ // compare value
+ if ($pass && $key && $val && $val!=='*') {
+ // If they have told us that this is a "plaintext" search then we want the plaintext of the node - right?
+ if ($key == "plaintext") {
+ // $node->plaintext actually returns $node->text();
+ $nodeKeyValue = $node->text();
+ } else {
+ // this is a normal search, we want the value of that attribute of the tag.
+ $nodeKeyValue = $node->attr[$key];
+ }
+ if (is_object($debug_object)) {$debug_object->debug_log(2, "testing node: " . $node->tag . " for attribute: " . $key . $exp . $val . " where nodes value is: " . $nodeKeyValue);}
+
+ //PaperG - If lowercase is set, do a case insensitive test of the value of the selector.
+ if ($lowercase) {
+ $check = $this->match($exp, strtolower($val), strtolower($nodeKeyValue));
+ } else {
+ $check = $this->match($exp, $val, $nodeKeyValue);
+ }
+ if (is_object($debug_object)) {$debug_object->debug_log(2, "after match: " . ($check ? "true" : "false"));}
+
+ // handle multiple class
+ if (!$check && strcasecmp($key, 'class')===0) {
+ foreach (explode(' ',$node->attr[$key]) as $k) {
+ // Without this, there were cases where leading, trailing, or double spaces lead to our comparing blanks - bad form.
+ if (!empty($k)) {
+ if ($lowercase) {
+ $check = $this->match($exp, strtolower($val), strtolower($k));
+ } else {
+ $check = $this->match($exp, $val, $k);
+ }
+ if ($check) break;
+ }
+ }
+ }
+ if (!$check) $pass = false;
+ }
+ if ($pass) $ret[$i] = 1;
+ unset($node);
+ }
+ // It's passed by reference so this is actually what this function returns.
+ if (is_object($debug_object)) {$debug_object->debug_log(1, "EXIT - ret: ", $ret);}
+ }
+
+ protected function match($exp, $pattern, $value) {
+ global $debug_object;
+ if (is_object($debug_object)) {$debug_object->debug_log_entry(1);}
+
+ switch ($exp) {
+ case '=':
+ return ($value===$pattern);
+ case '!=':
+ return ($value!==$pattern);
+ case '^=':
+ return preg_match("/^".preg_quote($pattern,'/')."/", $value);
+ case '$=':
+ return preg_match("/".preg_quote($pattern,'/')."$/", $value);
+ case '*=':
+ if ($pattern[0]=='/') {
+ return preg_match($pattern, $value);
+ }
+ return preg_match("/".$pattern."/i", $value);
+ }
+ return false;
+ }
+
+ protected function parse_selector($selector_string) {
+ global $debug_object;
+ if (is_object($debug_object)) {$debug_object->debug_log_entry(1);}
+
+ // pattern of CSS selectors, modified from mootools
+ // Paperg: Add the colon to the attrbute, so that it properly finds <tag attr:ibute="something" > like google does.
+ // Note: if you try to look at this attribute, yo MUST use getAttribute since $dom->x:y will fail the php syntax check.
+// Notice the \[ starting the attbute? and the @? following? This implies that an attribute can begin with an @ sign that is not captured.
+// This implies that an html attribute specifier may start with an @ sign that is NOT captured by the expression.
+// farther study is required to determine of this should be documented or removed.
+// $pattern = "/([\w-:\*]*)(?:\#([\w-]+)|\.([\w-]+))?(?:\[@?(!?[\w-]+)(?:([!*^$]?=)[\"']?(.*?)[\"']?)?\])?([\/, ]+)/is";
+ $pattern = "/([\w:\*-]*)(?:\#([\w-]+)|\.([\w-]+))?(?:\[@?(!?[\w:-]+)(?:([!*^$]?=)[\"']?(.*?)[\"']?)?\])?([\/, ]+)/is";
+ preg_match_all($pattern, trim($selector_string).' ', $matches, PREG_SET_ORDER);
+ if (is_object($debug_object)) {$debug_object->debug_log(2, "Matches Array: ", $matches);}
+
+ $selectors = array();
+ $result = array();
+ //print_r($matches);
+
+ foreach ($matches as $m) {
+ $m[0] = trim($m[0]);
+ if ($m[0]==='' || $m[0]==='/' || $m[0]==='//') continue;
+ // for browser generated xpath
+ if ($m[1]==='tbody') continue;
+
+ list($tag, $key, $val, $exp, $no_key) = array($m[1], null, null, '=', false);
+ if (!empty($m[2])) {$key='id'; $val=$m[2];}
+ if (!empty($m[3])) {$key='class'; $val=$m[3];}
+ if (!empty($m[4])) {$key=$m[4];}
+ if (!empty($m[5])) {$exp=$m[5];}
+ if (!empty($m[6])) {$val=$m[6];}
+
+ // convert to lowercase
+ if ($this->dom->lowercase) {$tag=strtolower($tag); $key=strtolower($key);}
+ //elements that do NOT have the specified attribute
+ if (isset($key[0]) && $key[0]==='!') {$key=substr($key, 1); $no_key=true;}
+
+ $result[] = array($tag, $key, $val, $exp, $no_key);
+ if (trim($m[7])===',') {
+ $selectors[] = $result;
+ $result = array();
+ }
+ }
+ if (count($result)>0)
+ $selectors[] = $result;
+ return $selectors;
+ }
+
+ function __get($name)
+ {
+ if (isset($this->attr[$name]))
+ {
+ return $this->convert_text($this->attr[$name]);
+ }
+ switch ($name)
+ {
+ case 'outertext': return $this->outertext();
+ case 'innertext': return $this->innertext();
+ case 'plaintext': return $this->text();
+ case 'xmltext': return $this->xmltext();
+ default: return array_key_exists($name, $this->attr);
+ }
+ }
+
+ function __set($name, $value)
+ {
+ global $debug_object;
+ if (is_object($debug_object)) {$debug_object->debug_log_entry(1);}
+
+ switch ($name)
+ {
+ case 'outertext': return $this->_[HDOM_INFO_OUTER] = $value;
+ case 'innertext':
+ if (isset($this->_[HDOM_INFO_TEXT])) return $this->_[HDOM_INFO_TEXT] = $value;
+ return $this->_[HDOM_INFO_INNER] = $value;
+ }
+ if (!isset($this->attr[$name]))
+ {
+ $this->_[HDOM_INFO_SPACE][] = array(' ', '', '');
+ $this->_[HDOM_INFO_QUOTE][] = HDOM_QUOTE_DOUBLE;
+ }
+ $this->attr[$name] = $value;
+ }
+
+ function __isset($name)
+ {
+ switch ($name)
+ {
+ case 'outertext': return true;
+ case 'innertext': return true;
+ case 'plaintext': return true;
+ }
+ //no value attr: nowrap, checked selected...
+ return (array_key_exists($name, $this->attr)) ? true : isset($this->attr[$name]);
+ }
+
+ function __unset($name) {
+ if (isset($this->attr[$name]))
+ unset($this->attr[$name]);
+ }
+
+ // PaperG - Function to convert the text from one character set to another if the two sets are not the same.
+ function convert_text($text)
+ {
+ global $debug_object;
+ if (is_object($debug_object)) {$debug_object->debug_log_entry(1);}
+
+ $converted_text = $text;
+
+ $sourceCharset = "";
+ $targetCharset = "";
+
+ if ($this->dom)
+ {
+ $sourceCharset = strtoupper($this->dom->_charset);
+ $targetCharset = strtoupper($this->dom->_target_charset);
+ }
+ if (is_object($debug_object)) {$debug_object->debug_log(3, "source charset: " . $sourceCharset . " target charaset: " . $targetCharset);}
+
+ if (!empty($sourceCharset) && !empty($targetCharset) && (strcasecmp($sourceCharset, $targetCharset) != 0))
+ {
+ // Check if the reported encoding could have been incorrect and the text is actually already UTF-8
+ if ((strcasecmp($targetCharset, 'UTF-8') == 0) && ($this->is_utf8($text)))
+ {
+ $converted_text = $text;
+ }
+ else
+ {
+ $converted_text = iconv($sourceCharset, $targetCharset, $text);
+ }
+ }
+
+ // Lets make sure that we don't have that silly BOM issue with any of the utf-8 text we output.
+ if ($targetCharset == 'UTF-8')
+ {
+ if (substr($converted_text, 0, 3) == "\xef\xbb\xbf")
+ {
+ $converted_text = substr($converted_text, 3);
+ }
+ if (substr($converted_text, -3) == "\xef\xbb\xbf")
+ {
+ $converted_text = substr($converted_text, 0, -3);
+ }
+ }
+
+ return $converted_text;
+ }
+
+ /**
+ * Returns true if $string is valid UTF-8 and false otherwise.
+ *
+ * @param mixed $str String to be tested
+ * @return boolean
+ */
+ static function is_utf8($str)
+ {
+ $c=0; $b=0;
+ $bits=0;
+ $len=strlen($str);
+ for($i=0; $i<$len; $i++)
+ {
+ $c=ord($str[$i]);
+ if($c > 128)
+ {
+ if(($c >= 254)) return false;
+ elseif($c >= 252) $bits=6;
+ elseif($c >= 248) $bits=5;
+ elseif($c >= 240) $bits=4;
+ elseif($c >= 224) $bits=3;
+ elseif($c >= 192) $bits=2;
+ else return false;
+ if(($i+$bits) > $len) return false;
+ while($bits > 1)
+ {
+ $i++;
+ $b=ord($str[$i]);
+ if($b < 128 || $b > 191) return false;
+ $bits--;
+ }
+ }
+ }
+ return true;
+ }
+ /*
+ function is_utf8($string)
+ {
+ //this is buggy
+ return (utf8_encode(utf8_decode($string)) == $string);
+ }
+ */
+
+ /**
+ * Function to try a few tricks to determine the displayed size of an img on the page.
+ * NOTE: This will ONLY work on an IMG tag. Returns FALSE on all other tag types.
+ *
+ * @author John Schlick
+ * @version April 19 2012
+ * @return array an array containing the 'height' and 'width' of the image on the page or -1 if we can't figure it out.
+ */
+ function get_display_size()
+ {
+ global $debug_object;
+
+ $width = -1;
+ $height = -1;
+
+ if ($this->tag !== 'img')
+ {
+ return false;
+ }
+
+ // See if there is aheight or width attribute in the tag itself.
+ if (isset($this->attr['width']))
+ {
+ $width = $this->attr['width'];
+ }
+
+ if (isset($this->attr['height']))
+ {
+ $height = $this->attr['height'];
+ }
+
+ // Now look for an inline style.
+ if (isset($this->attr['style']))
+ {
+ // Thanks to user gnarf from stackoverflow for this regular expression.
+ $attributes = array();
+ preg_match_all("/([\w-]+)\s*:\s*([^;]+)\s*;?/", $this->attr['style'], $matches, PREG_SET_ORDER);
+ foreach ($matches as $match) {
+ $attributes[$match[1]] = $match[2];
+ }
+
+ // If there is a width in the style attributes:
+ if (isset($attributes['width']) && $width == -1)
+ {
+ // check that the last two characters are px (pixels)
+ if (strtolower(substr($attributes['width'], -2)) == 'px')
+ {
+ $proposed_width = substr($attributes['width'], 0, -2);
+ // Now make sure that it's an integer and not something stupid.
+ if (filter_var($proposed_width, FILTER_VALIDATE_INT))
+ {
+ $width = $proposed_width;
+ }
+ }
+ }
+
+ // If there is a width in the style attributes:
+ if (isset($attributes['height']) && $height == -1)
+ {
+ // check that the last two characters are px (pixels)
+ if (strtolower(substr($attributes['height'], -2)) == 'px')
+ {
+ $proposed_height = substr($attributes['height'], 0, -2);
+ // Now make sure that it's an integer and not something stupid.
+ if (filter_var($proposed_height, FILTER_VALIDATE_INT))
+ {
+ $height = $proposed_height;
+ }
+ }
+ }
+
+ }
+
+ // Future enhancement:
+ // Look in the tag to see if there is a class or id specified that has a height or width attribute to it.
+
+ // Far future enhancement
+ // Look at all the parent tags of this image to see if they specify a class or id that has an img selector that specifies a height or width
+ // Note that in this case, the class or id will have the img subselector for it to apply to the image.
+
+ // ridiculously far future development
+ // If the class or id is specified in a SEPARATE css file thats not on the page, go get it and do what we were just doing for the ones on the page.
+
+ $result = array('height' => $height,
+ 'width' => $width);
+ return $result;
+ }
+
+ // camel naming conventions
+ function getAllAttributes() {return $this->attr;}
+ function getAttribute($name) {return $this->__get($name);}
+ function setAttribute($name, $value) {$this->__set($name, $value);}
+ function hasAttribute($name) {return $this->__isset($name);}
+ function removeAttribute($name) {$this->__set($name, null);}
+ function getElementById($id) {return $this->find("#$id", 0);}
+ function getElementsById($id, $idx=null) {return $this->find("#$id", $idx);}
+ function getElementByTagName($name) {return $this->find($name, 0);}
+ function getElementsByTagName($name, $idx=null) {return $this->find($name, $idx);}
+ function parentNode() {return $this->parent();}
+ function childNodes($idx=-1) {return $this->children($idx);}
+ function firstChild() {return $this->first_child();}
+ function lastChild() {return $this->last_child();}
+ function nextSibling() {return $this->next_sibling();}
+ function previousSibling() {return $this->prev_sibling();}
+ function hasChildNodes() {return $this->has_child();}
+ function nodeName() {return $this->tag;}
+ function appendChild($node) {$node->parent($this); return $node;}
+
+}
+
+/**
+ * simple html dom parser
+ * Paperg - in the find routine: allow us to specify that we want case insensitive testing of the value of the selector.
+ * Paperg - change $size from protected to public so we can easily access it
+ * Paperg - added ForceTagsClosed in the constructor which tells us whether we trust the html or not. Default is to NOT trust it.
+ *
+ * @package PlaceLocalInclude
+ */
+class simple_html_dom
+{
+ /**
+ * The root node of the document
+ *
+ * @var object
+ */
+ public $root = null;
+
+ /**
+ * List of nodes in the current DOM
+ *
+ * @var array
+ */
+ public $nodes = array();
+
+ /**
+ * Callback function to run for each element in the DOM.
+ *
+ * @var callable|null
+ */
+ public $callback = null;
+
+ /**
+ * Indicates how tags and attributes are matched
+ *
+ * @var bool When set to **true** tags and attributes will be converted to
+ * lowercase before matching.
+ */
+ public $lowercase = false;
+
+ /**
+ * Original document size
+ *
+ * Holds the original document size.
+ *
+ * @var int
+ */
+ public $original_size;
+
+ /**
+ * Current document size
+ *
+ * Holds the current document size. The document size is determined by the
+ * string length of ({@see simple_html_dom::$doc}).
+ *
+ * _Note_: Using this variable is more efficient than calling `strlen($doc)`
+ *
+ * @var int
+ * */
+ public $size;
+
+ /**
+ * Current position in the document
+ *
+ * @var int
+ */
+ protected $pos;
+
+ /**
+ * The document
+ *
+ * @var string
+ */
+ protected $doc;
+
+ /**
+ * Current character
+ *
+ * Holds the current character at position {@see simple_html_dom::$pos} in
+ * the document {@see simple_html_dom::$doc}
+ *
+ * _Note_: Using this variable is more efficient than calling `substr($doc, $pos, 1)`
+ *
+ * @var string
+ */
+ protected $char;
+
+ protected $cursor;
+
+ /**
+ * Parent node of the next node detected by the parser
+ *
+ * @var object
+ */
+ protected $parent;
+ protected $noise = array();
+
+ /**
+ * Tokens considered blank in HTML
+ *
+ * @var string
+ */
+ protected $token_blank = " \t\r\n";
+
+ /**
+ * Tokens to identify the equal sign for attributes, stopping either at the
+ * closing tag ("/" i.e. "<html />") or the end of an opening tag (">" i.e.
+ * "<html>")
+ *
+ * @var string
+ */
+ protected $token_equal = ' =/>';
+
+ /**
+ * Tokens to identify the end of a tag name. A tag name either ends on the
+ * ending slash ("/" i.e. "<html/>") or whitespace ("\s\r\n\t")
+ *
+ * @var string
+ */
+ protected $token_slash = " />\r\n\t";
+
+ /**
+ * Tokens to identify the end of an attribute
+ *
+ * @var string
+ */
+ protected $token_attr = ' >';
+
+ // Note that this is referenced by a child node, and so it needs to be public for that node to see this information.
+ public $_charset = '';
+ public $_target_charset = '';
+
+ /**
+ * Innertext for <br> elements
+ *
+ * @var string
+ */
+ protected $default_br_text = "";
+
+ /**
+ * Suffix for <span> elements
+ *
+ * @var string
+ */
+ public $default_span_text = "";
+
+ /**
+ * Defines a list of self-closing tags (Void elements) according to the HTML
+ * Specification
+ *
+ * _Remarks_:
+ * - Use `isset()` instead of `in_array()` on array elements to boost
+ * performance about 30%
+ * - Sort elements by name for better readability!
+ *
+ * @link https://www.w3.org/TR/html HTML Specification
+ * @link https://www.w3.org/TR/html/syntax.html#void-elements Void elements
+ */
+ protected $self_closing_tags = array(
+ 'area'=>1,
+ 'base'=>1,
+ 'br'=>1,
+ 'col'=>1,
+ 'embed'=>1,
+ 'hr'=>1,
+ 'img'=>1,
+ 'input'=>1,
+ 'link'=>1,
+ 'meta'=>1,
+ 'param'=>1,
+ 'source'=>1,
+ 'track'=>1,
+ 'wbr'=>1
+ );
+
+ /**
+ * Defines a list of tags which - if closed - close all optional closing
+ * elements within if they haven't been closed yet. (So, an element where
+ * neither opening nor closing tag is omissible consistently closes every
+ * optional closing element within)
+ *
+ * _Remarks_:
+ * - Use `isset()` instead of `in_array()` on array elements to boost
+ * performance about 30%
+ * - Sort elements by name for better readability!
+ */
+ protected $block_tags = array(
+ 'body'=>1,
+ 'div'=>1,
+ 'form'=>1,
+ 'root'=>1,
+ 'span'=>1,
+ 'table'=>1
+ );
+
+ /**
+ * Defines elements whose end tag is omissible.
+ *
+ * * key = Name of an element whose end tag is omissible.
+ * * value = Names of elements whose end tag is omissible, that are closed
+ * by the current element.
+ *
+ * _Remarks_:
+ * - Use `isset()` instead of `in_array()` on array elements to boost
+ * performance about 30%
+ * - Sort elements by name for better readability!
+ *
+ * **Example**
+ *
+ * An `li` element’s end tag may be omitted if the `li` element is immediately
+ * followed by another `li` element. To do that, add following element to the
+ * array:
+ *
+ * ```php
+ * 'li' => array('li'),
+ * ```
+ *
+ * With this, the following two examples are considered equal. Note that the
+ * second example is missing the closing tags on `li` elements.
+ *
+ * ```html
+ * <ul><li>First Item</li><li>Second Item</li></ul>
+ * ```
+ *
+ * <ul><li>First Item</li><li>Second Item</li></ul>
+ *
+ * ```html
+ * <ul><li>First Item<li>Second Item</ul>
+ * ```
+ *
+ * <ul><li>First Item<li>Second Item</ul>
+ *
+ * @var array A two-dimensional array where the key is the name of an
+ * element whose end tag is omissible and the value is an array of elements
+ * whose end tag is omissible, that are closed by the current element.
+ *
+ * @link https://www.w3.org/TR/html/syntax.html#optional-tags Optional tags
+ *
+ * @todo The implementation of optional closing tags doesn't work in all cases
+ * because it only consideres elements who close other optional closing
+ * tags, not taking into account that some (non-blocking) tags should close
+ * these optional closing tags. For example, the end tag for "p" is omissible
+ * and can be closed by an "address" element, whose end tag is NOT omissible.
+ * Currently a "p" element without closing tag stops at the next "p" element
+ * or blocking tag, even if it contains other elements.
+ *
+ * @todo Known sourceforge issue #2977341
+ * B tags that are not closed cause us to return everything to the end of
+ * the document.
+ */
+ protected $optional_closing_tags = array(
+ 'b'=>array('b'=>1), // Not optional, see https://www.w3.org/TR/html/textlevel-semantics.html#the-b-element
+ 'dd'=>array('dd'=>1, 'dt'=>1),
+ 'dl'=>array('dd'=>1, 'dt'=>1), // Not optional, see https://www.w3.org/TR/html/grouping-content.html#the-dl-element
+ 'dt'=>array('dd'=>1, 'dt'=>1),
+ 'li'=>array('li'=>1),
+ 'optgroup'=>array('optgroup'=>1, 'option'=>1),
+ 'option'=>array('optgroup'=>1, 'option'=>1),
+ 'p'=>array('p'=>1),
+ 'rp'=>array('rp'=>1, 'rt'=>1),
+ 'rt'=>array('rp'=>1, 'rt'=>1),
+ 'td'=>array('td'=>1, 'th'=>1),
+ 'th'=>array('td'=>1, 'th'=>1),
+ 'tr'=>array('td'=>1, 'th'=>1, 'tr'=>1),
+ );
+
+ function __construct($str=null, $lowercase=true, $forceTagsClosed=true, $target_charset=DEFAULT_TARGET_CHARSET, $stripRN=true, $defaultBRText=DEFAULT_BR_TEXT, $defaultSpanText=DEFAULT_SPAN_TEXT, $options=0)
+ {
+ if ($str)
+ {
+ if (preg_match("/^http:\/\//i",$str) || is_file($str))
+ {
+ $this->load_file($str);
+ }
+ else
+ {
+ $this->load($str, $lowercase, $stripRN, $defaultBRText, $defaultSpanText, $options);
+ }
+ }
+ // Forcing tags to be closed implies that we don't trust the html, but it can lead to parsing errors if we SHOULD trust the html.
+ if (!$forceTagsClosed) {
+ $this->optional_closing_array=array();
+ }
+ $this->_target_charset = $target_charset;
+ }
+
+ function __destruct()
+ {
+ $this->clear();
+ }
+
+ // load html from string
+ function load($str, $lowercase=true, $stripRN=true, $defaultBRText=DEFAULT_BR_TEXT, $defaultSpanText=DEFAULT_SPAN_TEXT, $options=0)
+ {
+ global $debug_object;
+
+ // prepare
+ $this->prepare($str, $lowercase, $defaultBRText, $defaultSpanText);
+
+ // Per sourceforge http://sourceforge.net/tracker/?func=detail&aid=2949097&group_id=218559&atid=1044037
+ // Script tags removal now preceeds style tag removal.
+ // strip out <script> tags
+ $this->remove_noise("'<\s*script[^>]*[^/]>(.*?)<\s*/\s*script\s*>'is");
+ $this->remove_noise("'<\s*script\s*>(.*?)<\s*/\s*script\s*>'is");
+
+ // strip out the \r \n's if we are told to.
+ if ($stripRN) {
+ $this->doc = str_replace("\r", " ", $this->doc);
+ $this->doc = str_replace("\n", " ", $this->doc);
+
+ // set the length of content since we have changed it.
+ $this->size = strlen($this->doc);
+ }
+
+ // strip out cdata
+ $this->remove_noise("'<!\[CDATA\[(.*?)\]\]>'is", true);
+ // strip out comments
+ $this->remove_noise("'<!--(.*?)-->'is");
+ // strip out <style> tags
+ $this->remove_noise("'<\s*style[^>]*[^/]>(.*?)<\s*/\s*style\s*>'is");
+ $this->remove_noise("'<\s*style\s*>(.*?)<\s*/\s*style\s*>'is");
+ // strip out preformatted tags
+ $this->remove_noise("'<\s*(?:code)[^>]*>(.*?)<\s*/\s*(?:code)\s*>'is");
+ // strip out server side scripts
+ $this->remove_noise("'(<\?)(.*?)(\?>)'s", true);
+
+ if($options & HDOM_SMARTY_AS_TEXT) { // Strip Smarty scripts
+ $this->remove_noise("'(\{\w)(.*?)(\})'s", true);
+ }
+
+ // parsing
+ $this->parse();
+ // end
+ $this->root->_[HDOM_INFO_END] = $this->cursor;
+ $this->parse_charset();
+
+ // make load function chainable
+ return $this;
+
+ }
+
+ // load html from file
+ function load_file()
+ {
+ $args = func_get_args();
+
+ if($doc = call_user_func_array('file_get_contents', $args) !== false) {
+ $this->load($doc, true);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Set the callback function
+ *
+ * @param callable $function_name Callback function to run for each element
+ * in the DOM.
+ * @return void
+ */
+ function set_callback($function_name)
+ {
+ $this->callback = $function_name;
+ }
+
+ /**
+ * Remove callback function
+ *
+ * @return void
+ */
+ function remove_callback()
+ {
+ $this->callback = null;
+ }
+
+ // save dom as string
+ function save($filepath='')
+ {
+ $ret = $this->root->innertext();
+ if ($filepath!=='') file_put_contents($filepath, $ret, LOCK_EX);
+ return $ret;
+ }
+
+ // find dom node by css selector
+ // Paperg - allow us to specify that we want case insensitive testing of the value of the selector.
+ function find($selector, $idx=null, $lowercase=false)
+ {
+ return $this->root->find($selector, $idx, $lowercase);
+ }
+
+ // clean up memory due to php5 circular references memory leak...
+ function clear()
+ {
+ foreach ($this->nodes as $n) {$n->clear(); $n = null;}
+ // This add next line is documented in the sourceforge repository. 2977248 as a fix for ongoing memory leaks that occur even with the use of clear.
+ if (isset($this->children)) foreach ($this->children as $n) {$n->clear(); $n = null;}
+ if (isset($this->parent)) {$this->parent->clear(); unset($this->parent);}
+ if (isset($this->root)) {$this->root->clear(); unset($this->root);}
+ unset($this->doc);
+ unset($this->noise);
+ }
+
+ function dump($show_attr=true)
+ {
+ $this->root->dump($show_attr);
+ }
+
+ // prepare HTML data and init everything
+ protected function prepare($str, $lowercase=true, $defaultBRText=DEFAULT_BR_TEXT, $defaultSpanText=DEFAULT_SPAN_TEXT)
+ {
+ $this->clear();
+
+ $this->doc = trim($str);
+ $this->size = strlen($this->doc);
+ $this->original_size = $this->size; // Save the original size of the html that we got in. It might be useful to someone.
+ $this->pos = 0;
+ $this->cursor = 1;
+ $this->noise = array();
+ $this->nodes = array();
+ $this->lowercase = $lowercase;
+ $this->default_br_text = $defaultBRText;
+ $this->default_span_text = $defaultSpanText;
+ $this->root = new simple_html_dom_node($this);
+ $this->root->tag = 'root';
+ $this->root->_[HDOM_INFO_BEGIN] = -1;
+ $this->root->nodetype = HDOM_TYPE_ROOT;
+ $this->parent = $this->root;
+ if ($this->size>0) $this->char = $this->doc[0];
+ }
+
+ /**
+ * Parse HTML content
+ *
+ * @return bool True on success
+ */
+ protected function parse()
+ {
+ while (true) {
+ // Read next tag if there is no text between current position and the
+ // next opening tag.
+ if (($s = $this->copy_until_char('<'))==='')
+ {
+ if($this->read_tag()) {
+ continue;
+ } else {
+ return true;
+ }
+ }
+
+ // Add a text node for text between tags
+ $node = new simple_html_dom_node($this);
+ ++$this->cursor;
+ $node->_[HDOM_INFO_TEXT] = $s;
+ $this->link_nodes($node, false);
+ }
+ }
+
+ // PAPERG - dkchou - added this to try to identify the character set of the page we have just parsed so we know better how to spit it out later.
+ // NOTE: IF you provide a routine called get_last_retrieve_url_contents_content_type which returns the CURLINFO_CONTENT_TYPE from the last curl_exec
+ // (or the content_type header from the last transfer), we will parse THAT, and if a charset is specified, we will use it over any other mechanism.
+ protected function parse_charset()
+ {
+ global $debug_object;
+
+ $charset = null;
+
+ if (function_exists('get_last_retrieve_url_contents_content_type'))
+ {
+ $contentTypeHeader = get_last_retrieve_url_contents_content_type();
+ $success = preg_match('/charset=(.+)/', $contentTypeHeader, $matches);
+ if ($success)
+ {
+ $charset = $matches[1];
+ if (is_object($debug_object)) {$debug_object->debug_log(2, 'header content-type found charset of: ' . $charset);}
+ }
+
+ }
+
+ if (empty($charset))
+ {
+ $el = $this->root->find('meta[http-equiv=Content-Type]',0, true);
+ if (!empty($el))
+ {
+ $fullvalue = $el->content;
+ if (is_object($debug_object)) {$debug_object->debug_log(2, 'meta content-type tag found' . $fullvalue);}
+
+ if (!empty($fullvalue))
+ {
+ $success = preg_match('/charset=(.+)/i', $fullvalue, $matches);
+ if ($success)
+ {
+ $charset = $matches[1];
+ }
+ else
+ {
+ // If there is a meta tag, and they don't specify the character set, research says that it's typically ISO-8859-1
+ if (is_object($debug_object)) {$debug_object->debug_log(2, 'meta content-type tag couldn\'t be parsed. using iso-8859 default.');}
+ $charset = 'ISO-8859-1';
+ }
+ }
+ }
+ }
+
+ // If we couldn't find a charset above, then lets try to detect one based on the text we got...
+ if (empty($charset))
+ {
+ // Use this in case mb_detect_charset isn't installed/loaded on this machine.
+ $charset = false;
+ if (function_exists('mb_detect_encoding'))
+ {
+ // Have php try to detect the encoding from the text given to us.
+ $charset = mb_detect_encoding($this->doc . "ascii", $encoding_list = array( "UTF-8", "CP1252" ) );
+ if (is_object($debug_object)) {$debug_object->debug_log(2, 'mb_detect found: ' . $charset);}
+ }
+
+ // and if this doesn't work... then we need to just wrongheadedly assume it's UTF-8 so that we can move on - cause this will usually give us most of what we need...
+ if ($charset === false)
+ {
+ if (is_object($debug_object)) {$debug_object->debug_log(2, 'since mb_detect failed - using default of utf-8');}
+ $charset = 'UTF-8';
+ }
+ }
+
+ // Since CP1252 is a superset, if we get one of it's subsets, we want it instead.
+ if ((strtolower($charset) == strtolower('ISO-8859-1')) || (strtolower($charset) == strtolower('Latin1')) || (strtolower($charset) == strtolower('Latin-1')))
+ {
+ if (is_object($debug_object)) {$debug_object->debug_log(2, 'replacing ' . $charset . ' with CP1252 as its a superset');}
+ $charset = 'CP1252';
+ }
+
+ if (is_object($debug_object)) {$debug_object->debug_log(1, 'EXIT - ' . $charset);}
+
+ return $this->_charset = $charset;
+ }
+
+ /**
+ * Parse tag from current document position.
+ *
+ * @return bool True if a tag was found, false otherwise
+ */
+ protected function read_tag()
+ {
+ // Set end position if no further tags found
+ if ($this->char!=='<')
+ {
+ $this->root->_[HDOM_INFO_END] = $this->cursor;
+ return false;
+ }
+ $begin_tag_pos = $this->pos;
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+
+ // end tag
+ if ($this->char==='/')
+ {
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+
+ // Skip whitespace in end tags (i.e. in "</ html>")
+ $this->skip($this->token_blank);
+ $tag = $this->copy_until_char('>');
+
+ // Skip attributes in end tags
+ if (($pos = strpos($tag, ' '))!==false)
+ $tag = substr($tag, 0, $pos);
+
+ $parent_lower = strtolower($this->parent->tag);
+ $tag_lower = strtolower($tag);
+
+ // The end tag is supposed to close the parent tag. Handle situations
+ // when it doesn't
+ if ($parent_lower!==$tag_lower)
+ {
+ // Parent tag does not have to be closed necessarily (optional closing tag)
+ // Current tag is a block tag, so it may close an ancestor
+ if (isset($this->optional_closing_tags[$parent_lower]) && isset($this->block_tags[$tag_lower]))
+ {
+ $this->parent->_[HDOM_INFO_END] = 0;
+ $org_parent = $this->parent;
+
+ // Traverse ancestors to find a matching opening tag
+ // Stop at root node
+ while (($this->parent->parent) && strtolower($this->parent->tag)!==$tag_lower)
+ $this->parent = $this->parent->parent;
+
+ // If we don't have a match add current tag as text node
+ if (strtolower($this->parent->tag)!==$tag_lower) {
+ $this->parent = $org_parent; // restore origonal parent
+ if ($this->parent->parent) $this->parent = $this->parent->parent;
+ $this->parent->_[HDOM_INFO_END] = $this->cursor;
+ return $this->as_text_node($tag);
+ }
+ }
+ // Grandparent exists and current tag is a block tag, so our parent doesn't have an end tag
+ else if (($this->parent->parent) && isset($this->block_tags[$tag_lower]))
+ {
+ $this->parent->_[HDOM_INFO_END] = 0; // No end tag
+ $org_parent = $this->parent;
+
+ // Traverse ancestors to find a matching opening tag
+ // Stop at root node
+ while (($this->parent->parent) && strtolower($this->parent->tag)!==$tag_lower)
+ $this->parent = $this->parent->parent;
+
+ // If we don't have a match add current tag as text node
+ if (strtolower($this->parent->tag)!==$tag_lower)
+ {
+ $this->parent = $org_parent; // restore origonal parent
+ $this->parent->_[HDOM_INFO_END] = $this->cursor;
+ return $this->as_text_node($tag);
+ }
+ }
+ // Grandparent exists and current tag closes it
+ else if (($this->parent->parent) && strtolower($this->parent->parent->tag)===$tag_lower)
+ {
+ $this->parent->_[HDOM_INFO_END] = 0;
+ $this->parent = $this->parent->parent;
+ }
+ else // Random tag, add as text node
+ return $this->as_text_node($tag);
+ }
+
+ // Set end position of parent tag to current cursor position
+ $this->parent->_[HDOM_INFO_END] = $this->cursor;
+ if ($this->parent->parent) $this->parent = $this->parent->parent;
+
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ return true;
+ }
+
+ // start tag
+ $node = new simple_html_dom_node($this);
+ $node->_[HDOM_INFO_BEGIN] = $this->cursor;
+ ++$this->cursor;
+ $tag = $this->copy_until($this->token_slash); // Get tag name
+ $node->tag_start = $begin_tag_pos;
+
+ // doctype, cdata & comments...
+ // <!DOCTYPE html>
+ // <![CDATA[ ... ]]>
+ // <!-- Comment -->
+ if (isset($tag[0]) && $tag[0]==='!') {
+ $node->_[HDOM_INFO_TEXT] = '<' . $tag . $this->copy_until_char('>');
+
+ if (isset($tag[2]) && $tag[1]==='-' && $tag[2]==='-') { // Comment ("<!--")
+ $node->nodetype = HDOM_TYPE_COMMENT;
+ $node->tag = 'comment';
+ } else { // Could be doctype or CDATA but we don't care
+ $node->nodetype = HDOM_TYPE_UNKNOWN;
+ $node->tag = 'unknown';
+ }
+ if ($this->char==='>') $node->_[HDOM_INFO_TEXT].='>';
+ $this->link_nodes($node, true);
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ return true;
+ }
+
+ // The start tag cannot contain another start tag, if so add as text
+ // i.e. "<<html>"
+ if ($pos=strpos($tag, '<')!==false) {
+ $tag = '<' . substr($tag, 0, -1);
+ $node->_[HDOM_INFO_TEXT] = $tag;
+ $this->link_nodes($node, false);
+ $this->char = $this->doc[--$this->pos]; // prev
+ return true;
+ }
+
+ // Handle invalid tag names (i.e. "<html#doc>")
+ if (!preg_match("/^\w[\w:-]*$/", $tag)) {
+ $node->_[HDOM_INFO_TEXT] = '<' . $tag . $this->copy_until('<>');
+
+ // Next char is the beginning of a new tag, don't touch it.
+ if ($this->char==='<') {
+ $this->link_nodes($node, false);
+ return true;
+ }
+
+ // Next char closes current tag, add and be done with it.
+ if ($this->char==='>') $node->_[HDOM_INFO_TEXT].='>';
+ $this->link_nodes($node, false);
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ return true;
+ }
+
+ // begin tag, add new node
+ $node->nodetype = HDOM_TYPE_ELEMENT;
+ $tag_lower = strtolower($tag);
+ $node->tag = ($this->lowercase) ? $tag_lower : $tag;
+
+ // handle optional closing tags
+ if (isset($this->optional_closing_tags[$tag_lower]) )
+ {
+ // Traverse ancestors to close all optional closing tags
+ while (isset($this->optional_closing_tags[$tag_lower][strtolower($this->parent->tag)]))
+ {
+ $this->parent->_[HDOM_INFO_END] = 0;
+ $this->parent = $this->parent->parent;
+ }
+ $node->parent = $this->parent;
+ }
+
+ $guard = 0; // prevent infinity loop
+ $space = array($this->copy_skip($this->token_blank), '', ''); // [0] Space between tag and first attribute
+
+ // attributes
+ do
+ {
+ // Everything until the first equal sign should be the attribute name
+ $name = $this->copy_until($this->token_equal);
+
+ if ($name==='' && $this->char!==null && $space[0]==='')
+ {
+ break;
+ }
+
+ if ($guard===$this->pos) // Escape infinite loop
+ {
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ continue;
+ }
+ $guard = $this->pos;
+
+ // handle endless '<'
+ if ($this->pos>=$this->size-1 && $this->char!=='>') { // Out of bounds before the tag ended
+ $node->nodetype = HDOM_TYPE_TEXT;
+ $node->_[HDOM_INFO_END] = 0;
+ $node->_[HDOM_INFO_TEXT] = '<'.$tag . $space[0] . $name;
+ $node->tag = 'text';
+ $this->link_nodes($node, false);
+ return true;
+ }
+
+ // handle mismatch '<'
+ if ($this->doc[$this->pos-1]=='<') { // Attributes cannot start after opening tag
+ $node->nodetype = HDOM_TYPE_TEXT;
+ $node->tag = 'text';
+ $node->attr = array();
+ $node->_[HDOM_INFO_END] = 0;
+ $node->_[HDOM_INFO_TEXT] = substr($this->doc, $begin_tag_pos, $this->pos-$begin_tag_pos-1);
+ $this->pos -= 2;
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ $this->link_nodes($node, false);
+ return true;
+ }
+
+ if ($name!=='/' && $name!=='') { // this is a attribute name
+ $space[1] = $this->copy_skip($this->token_blank); // [1] Whitespace after attribute name
+ $name = $this->restore_noise($name); // might be a noisy name
+ if ($this->lowercase) $name = strtolower($name);
+ if ($this->char==='=') { // attribute with value
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ $this->parse_attr($node, $name, $space); // get attribute value
+ }
+ else {
+ //no value attr: nowrap, checked selected...
+ $node->_[HDOM_INFO_QUOTE][] = HDOM_QUOTE_NO;
+ $node->attr[$name] = true;
+ if ($this->char!='>') $this->char = $this->doc[--$this->pos]; // prev
+ }
+ $node->_[HDOM_INFO_SPACE][] = $space;
+ $space = array($this->copy_skip($this->token_blank), '', ''); // prepare for next attribute
+ }
+ else // no more attributes
+ break;
+ } while ($this->char!=='>' && $this->char!=='/'); // go until the tag ended
+
+ $this->link_nodes($node, true);
+ $node->_[HDOM_INFO_ENDSPACE] = $space[0];
+
+ // handle empty tags (i.e. "<div/>")
+ if ($this->copy_until_char('>')==='/')
+ {
+ $node->_[HDOM_INFO_ENDSPACE] .= '/';
+ $node->_[HDOM_INFO_END] = 0;
+ }
+ else
+ {
+ // reset parent
+ if (!isset($this->self_closing_tags[strtolower($node->tag)])) $this->parent = $node;
+ }
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+
+ // If it's a BR tag, we need to set it's text to the default text.
+ // This way when we see it in plaintext, we can generate formatting that the user wants.
+ // since a br tag never has sub nodes, this works well.
+ if ($node->tag == "br")
+ {
+ $node->_[HDOM_INFO_INNER] = $this->default_br_text;
+ }
+
+ return true;
+ }
+
+ /**
+ * Parse attribute from current document position
+ *
+ * @param object $node Node for the attributes
+ * @param string $name Name of the current attribute
+ * @param array $space Array for spacing information
+ * @return void
+ */
+ protected function parse_attr($node, $name, &$space)
+ {
+ // Per sourceforge: http://sourceforge.net/tracker/?func=detail&aid=3061408&group_id=218559&atid=1044037
+ // If the attribute is already defined inside a tag, only pay attention to the first one as opposed to the last one.
+ // https://stackoverflow.com/a/26341866
+ if (isset($node->attr[$name]))
+ {
+ return;
+ }
+
+ $space[2] = $this->copy_skip($this->token_blank); // [2] Whitespace between "=" and the value
+ switch ($this->char) {
+ case '"': // value is anything between double quotes
+ $node->_[HDOM_INFO_QUOTE][] = HDOM_QUOTE_DOUBLE;
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ $node->attr[$name] = $this->restore_noise($this->copy_until_char('"'));
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ break;
+ case '\'': // value is anything between single quotes
+ $node->_[HDOM_INFO_QUOTE][] = HDOM_QUOTE_SINGLE;
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ $node->attr[$name] = $this->restore_noise($this->copy_until_char('\''));
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ break;
+ default: // value is anything until the first space or end tag
+ $node->_[HDOM_INFO_QUOTE][] = HDOM_QUOTE_NO;
+ $node->attr[$name] = $this->restore_noise($this->copy_until($this->token_attr));
+ }
+ // PaperG: Attributes should not have \r or \n in them, that counts as html whitespace.
+ $node->attr[$name] = str_replace("\r", "", $node->attr[$name]);
+ $node->attr[$name] = str_replace("\n", "", $node->attr[$name]);
+ // PaperG: If this is a "class" selector, lets get rid of the preceeding and trailing space since some people leave it in the multi class case.
+ if ($name == "class") {
+ $node->attr[$name] = trim($node->attr[$name]);
+ }
+ }
+
+ /**
+ * Link node to parent node
+ *
+ * @param object $node Node to link to parent
+ * @param bool $is_child True if the node is a child of parent
+ * @return void
+ */
+ // link node's parent
+ protected function link_nodes(&$node, $is_child)
+ {
+ $node->parent = $this->parent;
+ $this->parent->nodes[] = $node;
+ if ($is_child)
+ {
+ $this->parent->children[] = $node;
+ }
+ }
+
+ /**
+ * Add tag as text node to current node
+ *
+ * @param string $tag Tag name
+ * @return bool True on success
+ */
+ protected function as_text_node($tag)
+ {
+ $node = new simple_html_dom_node($this);
+ ++$this->cursor;
+ $node->_[HDOM_INFO_TEXT] = '</' . $tag . '>';
+ $this->link_nodes($node, false);
+ $this->char = (++$this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ return true;
+ }
+
+ /**
+ * Seek from the current document position to the first occurrence of a
+ * character not defined by the provided string. Update the current document
+ * position to the new position.
+ *
+ * @param string $chars A string containing every allowed character.
+ * @return void
+ */
+ protected function skip($chars)
+ {
+ $this->pos += strspn($this->doc, $chars, $this->pos);
+ $this->char = ($this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ }
+
+ /**
+ * Copy substring from the current document position to the first occurrence
+ * of a character not defined by the provided string.
+ *
+ * @param string $chars A string containing every allowed character.
+ * @return string Substring from the current document position to the first
+ * occurrence of a character not defined by the provided string.
+ */
+ protected function copy_skip($chars)
+ {
+ $pos = $this->pos;
+ $len = strspn($this->doc, $chars, $pos);
+ $this->pos += $len;
+ $this->char = ($this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ if ($len===0) return '';
+ return substr($this->doc, $pos, $len);
+ }
+
+ /**
+ * Copy substring from the current document position to the first occurrence
+ * of any of the provided characters.
+ *
+ * @param string $chars A string containing every character to stop at.
+ * @return string Substring from the current document position to the first
+ * occurrence of any of the provided characters.
+ */
+ protected function copy_until($chars)
+ {
+ $pos = $this->pos;
+ $len = strcspn($this->doc, $chars, $pos);
+ $this->pos += $len;
+ $this->char = ($this->pos<$this->size) ? $this->doc[$this->pos] : null; // next
+ return substr($this->doc, $pos, $len);
+ }
+
+ /**
+ * Copy substring from the current document position to the first occurrence
+ * of the provided string.
+ *
+ * @param string $char The string to stop at.
+ * @return string Substring from the current document position to the first
+ * occurrence of the provided string.
+ */
+ protected function copy_until_char($char)
+ {
+ if ($this->char===null) return '';
+
+ if (($pos = strpos($this->doc, $char, $this->pos))===false) {
+ $ret = substr($this->doc, $this->pos, $this->size-$this->pos);
+ $this->char = null;
+ $this->pos = $this->size;
+ return $ret;
+ }
+
+ if ($pos===$this->pos) return '';
+ $pos_old = $this->pos;
+ $this->char = $this->doc[$pos];
+ $this->pos = $pos;
+ return substr($this->doc, $pos_old, $pos-$pos_old);
+ }
+
+ /**
+ * Remove noise from HTML content
+ *
+ * Noise is stored to {@see simple_html_dom::$noise}
+ *
+ * @param string $pattern The regex pattern used for finding noise
+ * @param bool $remove_tag True to remove the entire match. Default is false
+ * to only remove the captured data.
+ */
+ protected function remove_noise($pattern, $remove_tag=false)
+ {
+ global $debug_object;
+ if (is_object($debug_object)) { $debug_object->debug_log_entry(1); }
+
+ $count = preg_match_all($pattern, $this->doc, $matches, PREG_SET_ORDER|PREG_OFFSET_CAPTURE);
+
+ for ($i=$count-1; $i>-1; --$i)
+ {
+ $key = '___noise___'.sprintf('% 5d', count($this->noise)+1000);
+ if (is_object($debug_object)) { $debug_object->debug_log(2, 'key is: ' . $key); }
+ $idx = ($remove_tag) ? 0 : 1; // 0 = entire match, 1 = submatch
+ $this->noise[$key] = $matches[$i][$idx][0];
+ $this->doc = substr_replace($this->doc, $key, $matches[$i][$idx][1], strlen($matches[$i][$idx][0]));
+ }
+
+ // reset the length of content
+ $this->size = strlen($this->doc);
+ if ($this->size>0)
+ {
+ $this->char = $this->doc[0];
+ }
+ }
+
+ /**
+ * Restore noise to HTML content
+ *
+ * Noise is restored from {@see simple_html_dom::$noise}
+ *
+ * @param string $text A subset of HTML containing noise
+ * @return string The same content with noise restored
+ */
+ function restore_noise($text)
+ {
+ global $debug_object;
+ if (is_object($debug_object)) { $debug_object->debug_log_entry(1); }
+
+ while (($pos=strpos($text, '___noise___'))!==false)
+ {
+ // Sometimes there is a broken piece of markup, and we don't GET the pos+11 etc... token which indicates a problem outside of us...
+ if (strlen($text) > $pos+15)
+ { // todo: "___noise___1000" (or any number with four or more digits) in the DOM causes an infinite loop which could be utilized by malicious software
+ $key = '___noise___'.$text[$pos+11].$text[$pos+12].$text[$pos+13].$text[$pos+14].$text[$pos+15];
+ if (is_object($debug_object)) { $debug_object->debug_log(2, 'located key of: ' . $key); }
+
+ if (isset($this->noise[$key]))
+ {
+ $text = substr($text, 0, $pos).$this->noise[$key].substr($text, $pos+16);
+ }
+ else
+ {
+ // do this to prevent an infinite loop.
+ $text = substr($text, 0, $pos).'UNDEFINED NOISE FOR KEY: '.$key . substr($text, $pos+16);
+ }
+ }
+ else
+ {
+ // There is no valid key being given back to us... We must get rid of the ___noise___ or we will have a problem.
+ $text = substr($text, 0, $pos).'NO NUMERIC NOISE KEY' . substr($text, $pos+11);
+ }
+ }
+ return $text;
+ }
+
+ // Sometimes we NEED one of the noise elements.
+ function search_noise($text)
+ {
+ global $debug_object;
+ if (is_object($debug_object)) { $debug_object->debug_log_entry(1); }
+
+ foreach($this->noise as $noiseElement)
+ {
+ if (strpos($noiseElement, $text)!==false)
+ {
+ return $noiseElement;
+ }
+ }
+ }
+ function __toString()
+ {
+ return $this->root->innertext();
+ }
+
+ function __get($name)
+ {
+ switch ($name)
+ {
+ case 'outertext':
+ return $this->root->innertext();
+ case 'innertext':
+ return $this->root->innertext();
+ case 'plaintext':
+ return $this->root->text();
+ case 'charset':
+ return $this->_charset;
+ case 'target_charset':
+ return $this->_target_charset;
+ }
+ }
+
+ // camel naming conventions
+ function childNodes($idx=-1) {return $this->root->childNodes($idx);}
+ function firstChild() {return $this->root->first_child();}
+ function lastChild() {return $this->root->last_child();}
+ function createElement($name, $value=null) {return @str_get_html("<$name>$value</$name>")->first_child();}
+ function createTextNode($value) {return @end(str_get_html($value)->nodes);}
+ function getElementById($id) {return $this->find("#$id", 0);}
+ function getElementsById($id, $idx=null) {return $this->find("#$id", $idx);}
+ function getElementByTagName($name) {return $this->find($name, 0);}
+ function getElementsByTagName($name, $idx=-1) {return $this->find($name, $idx);}
+ function loadFile() {$args = func_get_args();$this->load_file($args);}
+}
+
+?> \ No newline at end of file