Repository: RSS-Bridge/rss-bridge Branch: master Commit: d44856db173e Files: 726 Total size: 3.3 MB Directory structure: gitextract_888ghlns/ ├── .devcontainer/ │ ├── Dockerfile │ ├── devcontainer.json │ ├── launch.json │ ├── nginx.conf │ ├── post-create-command.sh │ └── xdebug.ini ├── .dockerignore ├── .git-blame-ignore-revs ├── .gitattributes ├── .github/ │ ├── .gitignore │ ├── CONTRIBUTING.md │ ├── ISSUE_TEMPLATE/ │ │ ├── bridge-request.md │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── prtester-requirements.txt │ ├── prtester.py │ └── workflows/ │ ├── dockerbuild.yml │ ├── documentation.yml │ ├── lint.yml │ ├── prhtmlgenerator.yml │ └── tests.yml ├── .gitignore ├── CONTRIBUTORS.md ├── Dockerfile ├── README.md ├── UNLICENSE ├── actions/ │ ├── ConnectivityAction.php │ ├── DetectAction.php │ ├── DisplayAction.php │ ├── FindfeedAction.php │ ├── FrontpageAction.php │ ├── HealthAction.php │ └── ListAction.php ├── app.json ├── bridges/ │ ├── ABCNewsBridge.php │ ├── ABolaBridge.php │ ├── AO3Bridge.php │ ├── ARDAudiothekBridge.php │ ├── ARDMediathekBridge.php │ ├── ARMCommunityBridge.php │ ├── ASRockNewsBridge.php │ ├── AcademiaBridge.php │ ├── AcrimedBridge.php │ ├── ActivisionResearchBridge.php │ ├── AirBreizhBridge.php │ ├── AkamaiBridge.php │ ├── AlbionOnlineBridge.php │ ├── AlfaBankByBridge.php │ ├── AllSidesBridge.php │ ├── AllegroBridge.php │ ├── AllocineFRBridge.php │ ├── AllocineFRSortiesBridge.php │ ├── AlpinePackagesBridge.php │ ├── AmazonBridge.php │ ├── AmazonPriceTrackerBridge.php │ ├── AnfrBridge.php │ ├── AnidexBridge.php │ ├── AnimeUltimeBridge.php │ ├── AnisearchBridge.php │ ├── AnnasArchiveBridge.php │ ├── AppleAppStoreBridge.php │ ├── AppleMusicBridge.php │ ├── ArsTechnicaBridge.php │ ├── ArtStationBridge.php │ ├── Arte7Bridge.php │ ├── AsahiShimbunAJWBridge.php │ ├── AssociatedPressNewsBridge.php │ ├── AstrophysicsDataSystemBridge.php │ ├── AtmoNouvelleAquitaineBridge.php │ ├── AtmoOccitanieBridge.php │ ├── AuctionetBridge.php │ ├── AutoJMBridge.php │ ├── AwwwardsBridge.php │ ├── BAEBridge.php │ ├── BMDSystemhausBlogBridge.php │ ├── BadDragonBridge.php │ ├── BakaUpdatesMangaReleasesBridge.php │ ├── BandcampBridge.php │ ├── BandcampDailyBridge.php │ ├── BarraqueiroBridgeAbstract.php │ ├── BarraqueiroOesteBridge.php │ ├── BastaBridge.php │ ├── BazarakiBridge.php │ ├── BinanceBridge.php │ ├── BlaguesDeMerdeBridge.php │ ├── BleepingComputerBridge.php │ ├── BlizzardNewsBridge.php │ ├── BlueskyBridge.php │ ├── BoaViagemBridge.php │ ├── BodaccBridge.php │ ├── BookMyShowBridge.php │ ├── BooruprojectBridge.php │ ├── BrotFuerDieWeltBridge.php │ ├── BruegelBridge.php │ ├── BrutBridge.php │ ├── BugzillaBridge.php │ ├── BukowskisBridge.php │ ├── BundesbankBridge.php │ ├── BundestagParteispendenBridge.php │ ├── BundesverbandFuerFreieKammernBridge.php │ ├── CBCEditorsBlogBridge.php │ ├── CMetropolitanaBridge.php │ ├── CNETBridge.php │ ├── CNETFranceBridge.php │ ├── CVEDetailsBridge.php │ ├── CachetBridge.php │ ├── CarThrottleBridge.php │ ├── CaschyBridge.php │ ├── CastorusBridge.php │ ├── CdactionBridge.php │ ├── CentreFranceBridge.php │ ├── CeskaTelevizeBridge.php │ ├── CodebergBridge.php │ ├── CollegeDeFranceBridge.php │ ├── ComboiosDePortugalBridge.php │ ├── ComickBridge.php │ ├── ComicsKingdomBridge.php │ ├── CommonDreamsBridge.php │ ├── CopieDoubleBridge.php │ ├── CorreioDaFeiraBridge.php │ ├── CourrierInternationalBridge.php │ ├── CraigslistBridge.php │ ├── CrewbayBridge.php │ ├── CryptomeBridge.php │ ├── CssSelectorBridge.php │ ├── CssSelectorComplexBridge.php │ ├── CssSelectorFeedExpanderBridge.php │ ├── CubariBridge.php │ ├── CubariProxyBridge.php │ ├── CybernewsBridge.php │ ├── DRKBlutspendeBridge.php │ ├── DacksnackBridge.php │ ├── DagensNyheterDirektBridge.php │ ├── DailymotionBridge.php │ ├── DailythanthiBridge.php │ ├── DanbooruBridge.php │ ├── DarkReadingBridge.php │ ├── DauphineLibereBridge.php │ ├── DealabsBridge.php │ ├── DemoBridge.php │ ├── DemosBerlinBridge.php │ ├── DerpibooruBridge.php │ ├── DesoutterBridge.php │ ├── DeutscheWelleBridge.php │ ├── DeutscherAeroClubBridge.php │ ├── DevToBridge.php │ ├── DeveloppezDotComBridge.php │ ├── DiarioDeNoticiasBridge.php │ ├── DiarioDoAlentejoBridge.php │ ├── DiceBridge.php │ ├── DiscogsBridge.php │ ├── DjMagDotComBridge.php │ ├── DockerHubBridge.php │ ├── DonnonsBridge.php │ ├── DoujinStyleBridge.php │ ├── DribbbleBridge.php │ ├── Drive2ruBridge.php │ ├── DuckDuckGoBridge.php │ ├── DuvarOrgBridge.php │ ├── EASeedBridge.php │ ├── EBayBridge.php │ ├── EDDHPiRepsBridge.php │ ├── EDDHPresseschauBridge.php │ ├── EZTVBridge.php │ ├── EconomistBridge.php │ ├── EconomistWorldInBriefBridge.php │ ├── EdfPricesBridge.php │ ├── ElektroARGOSBridge.php │ ├── EliteDangerousGalnetBridge.php │ ├── ElloBridge.php │ ├── ElsevierBridge.php │ ├── EngadgetBridge.php │ ├── EpicGamesFreeBridge.php │ ├── EpicgamesBridge.php │ ├── ErowallBridge.php │ ├── EsquerdaNetBridge.php │ ├── EstCeQuonMetEnProdBridge.php │ ├── EtsyBridge.php │ ├── EuronewsBridge.php │ ├── ExecuteProgramBridge.php │ ├── ExplosmBridge.php │ ├── FB2Bridge.php │ ├── FDroidRepoBridge.php │ ├── FFXIVLodestoneNewsBridge.php │ ├── FM4Bridge.php │ ├── FSecureBlogBridge.php │ ├── FabBridge.php │ ├── FabriceBellardBridge.php │ ├── FacebookBridge.php │ ├── FallGuysBridge.php │ ├── FanaticalBridge.php │ ├── FarsideNitterBridge.php │ ├── FeedExpanderExampleBridge.php │ ├── FeedExpanderTestBridge.php │ ├── FeedMergeBridge.php │ ├── FeedReducerBridge.php │ ├── FiaBridge.php │ ├── FicbookBridge.php │ ├── FiderBridge.php │ ├── FilterBridge.php │ ├── FinanzflussBridge.php │ ├── FindACrewBridge.php │ ├── FirefoxAddonsBridge.php │ ├── FirefoxReleaseNotesBridge.php │ ├── FlaschenpostBridge.php │ ├── FlashbackBridge.php │ ├── FlickrBridge.php │ ├── FliegermagazinBridge.php │ ├── FolhaDeSaoPauloBridge.php │ ├── ForGifsBridge.php │ ├── ForensicArchitectureBridge.php │ ├── Formula1Bridge.php │ ├── FourchanBridge.php │ ├── FreeCodeCampBridge.php │ ├── FreeTelechargerBridge.php │ ├── FunkBridge.php │ ├── FurAffinityBridge.php │ ├── FurAffinityUserBridge.php │ ├── FuturaSciencesBridge.php │ ├── GBAtempBridge.php │ ├── GGDealsBridge.php │ ├── GOGBridge.php │ ├── GQMagazineBridge.php │ ├── GULPProjekteBridge.php │ ├── GabBridge.php │ ├── GameBananaBridge.php │ ├── GatesNotesBridge.php │ ├── GelbooruBridge.php │ ├── GenshinImpactBridge.php │ ├── GettrBridge.php │ ├── GiphyBridge.php │ ├── GitHubGistBridge.php │ ├── GiteaBridge.php │ ├── GithubIssueBridge.php │ ├── GithubPackagesBridge.php │ ├── GithubPullRequestBridge.php │ ├── GithubReleaseBridge.php │ ├── GithubSearchBridge.php │ ├── GithubTrendingBridge.php │ ├── GitlabIssueBridge.php │ ├── GizmodoBridge.php │ ├── GlassdoorBridge.php │ ├── GlowficBridge.php │ ├── GoAccessBridge.php │ ├── GoComicsBridge.php │ ├── GogsBridge.php │ ├── GolemBridge.php │ ├── GoodreadsBridge.php │ ├── GoogleGroupsBridge.php │ ├── GooglePlayStoreBridge.php │ ├── GoogleScholarBridge.php │ ├── GoogleSearchBridge.php │ ├── GovTrackBridge.php │ ├── GrandComicsDatabaseBridge.php │ ├── GroupBundNaturschutzBridge.php │ ├── HDWallpapersBridge.php │ ├── HackerNewsUserThreadsBridge.php │ ├── HanimeBridge.php │ ├── HarvardBusinessReviewBridge.php │ ├── HarvardHealthBlogBridge.php │ ├── HashnodeBridge.php │ ├── HaveIBeenPwnedBridge.php │ ├── HeiseBridge.php │ ├── HinduTamilBridge.php │ ├── HonkaiImpactSeaBridge.php │ ├── HotUKDealsBridge.php │ ├── HumbleBundleBridge.php │ ├── HuntShowdownNewsBridge.php │ ├── HytaleBridge.php │ ├── I4wifiBridge.php │ ├── IGNBridge.php │ ├── IKWYDBridge.php │ ├── IPBBridge.php │ ├── IdealoBridge.php │ ├── IdenticaBridge.php │ ├── ImgsedBridge.php │ ├── IndeedBridge.php │ ├── IndiegogoBridge.php │ ├── InstagramBridge.php │ ├── InstituteForTheStudyOfWarBridge.php │ ├── InstructablesBridge.php │ ├── InternationalInstituteForStrategicStudiesBridge.php │ ├── InternetArchiveBridge.php │ ├── InvestorsObserverBridge.php │ ├── ItakuBridge.php │ ├── ItchioBridge.php │ ├── IvooxBridge.php │ ├── JapanExpoBridge.php │ ├── JohannesBlickBridge.php │ ├── JornalNBridge.php │ ├── JustETFBridge.php │ ├── JustWatchBridge.php │ ├── Kanali6Bridge.php │ ├── KemonoBridge.php │ ├── KernelBugTrackerBridge.php │ ├── KhinsiderBridge.php │ ├── KilledbyGoogleBridge.php │ ├── KilledbyMicrosoftBridge.php │ ├── KitsuBridge.php │ ├── KleinanzeigenBridge.php │ ├── KoFiBridge.php │ ├── KonachanBridge.php │ ├── KoreusBridge.php │ ├── KununuBridge.php │ ├── LWNprevBridge.php │ ├── LaCentraleBridge.php │ ├── LaTeX3ProjectNewslettersBridge.php │ ├── LeBonCoinBridge.php │ ├── LeMondeInformatiqueBridge.php │ ├── LeagueOfLegendsNewsBridge.php │ ├── LegifranceJOBridge.php │ ├── LegoIdeasBridge.php │ ├── LesJoiesDuCodeBridge.php │ ├── LfcPlBridge.php │ ├── LinuxBlogBridge.php │ ├── ListverseBridge.php │ ├── LogicMastersBridge.php │ ├── LolibooruBridge.php │ ├── LuftfahrtBundesAmtBridge.php │ ├── LuftsportSHBridge.php │ ├── MaalaimalarBridge.php │ ├── MagellantvBridge.php │ ├── MagicTheGatheringBridge.php │ ├── Mailman2Bridge.php │ ├── MallTvBridge.php │ ├── MangaDexBridge.php │ ├── MangaReaderBridge.php │ ├── ManyVidsBridge.php │ ├── MarktplaatsBridge.php │ ├── MastodonBridge.php │ ├── MediapartBlogsBridge.php │ ├── MediapartBridge.php │ ├── MicrosoftOfficeUpdatesBridge.php │ ├── MilbooruBridge.php │ ├── MinecraftBridge.php │ ├── MistralAIBridge.php │ ├── MixCloudBridge.php │ ├── MixologyBridge.php │ ├── ModelKarteiBridge.php │ ├── ModifyBridge.php │ ├── ModrinthBridge.php │ ├── MoebooruBridge.php │ ├── MoinMoinBridge.php │ ├── MondeDiploBridge.php │ ├── MotatosBridge.php │ ├── MozillaBugTrackerBridge.php │ ├── MozillaSecurityBridge.php │ ├── MsnMondeBridge.php │ ├── MspabooruBridge.php │ ├── MydealsBridge.php │ ├── N26Bridge.php │ ├── NACSouthGermanyMediaLibraryBridge.php │ ├── NFLRUSBridge.php │ ├── NHKWorldJapanShowBridge.php │ ├── NOSBridge.php │ ├── NPRBridge.php │ ├── NYTBridge.php │ ├── NasaApodBridge.php │ ├── NasestrechaBridge.php │ ├── NationalGeographicBridge.php │ ├── NautiljonBridge.php │ ├── NewOnNetflixBridge.php │ ├── NewgroundsBridge.php │ ├── NextInkBridge.php │ ├── NextgovBridge.php │ ├── NiceMatinBridge.php │ ├── NikonDownloadCenterBridge.php │ ├── NineGagBridge.php │ ├── NintendoBridge.php │ ├── NordbayernBridge.php │ ├── NotAlwaysBridge.php │ ├── NovayaGazetaEuropeBridge.php │ ├── NovelUpdatesBridge.php │ ├── NpciBridge.php │ ├── NurembergerNachrichtenBridge.php │ ├── NvidiaDriverBridge.php │ ├── NyaaTorrentsBridge.php │ ├── OLXBridge.php │ ├── OMonlineBridge.php │ ├── OglafBridge.php │ ├── OllamaBridge.php │ ├── OnVaSortirBridge.php │ ├── OneFortuneADayBridge.php │ ├── OpenCVEBridge.php │ ├── OpenwhydBridge.php │ ├── OpenwrtSecurityBridge.php │ ├── OtrkeyFinderBridge.php │ ├── OvertakeBridge.php │ ├── PanneauPocketBridge.php │ ├── ParksOnTheAirBridge.php │ ├── ParlerBridge.php │ ├── ParuVenduImmoBridge.php │ ├── PatreonBridge.php │ ├── PaulGrahamBridge.php │ ├── PcGamerBridge.php │ ├── PepperBridgeAbstract.php │ ├── PhoronixBridge.php │ ├── PicalaBridge.php │ ├── PicartoBridge.php │ ├── PickyWallpapersBridge.php │ ├── PicnobBridge.php │ ├── PicukiBridge.php │ ├── PikabuBridge.php │ ├── PillowfortBridge.php │ ├── PinterestBridge.php │ ├── PirateCommunityBridge.php │ ├── PixivBridge.php │ ├── PlantUMLReleasesBridge.php │ ├── PokemonNewsBridge.php │ ├── PornhubBridge.php │ ├── PresidenciaPTBridge.php │ ├── PriviblurBridge.php │ ├── QnapBridge.php │ ├── QwantzBridge.php │ ├── QwenBlogBridge.php │ ├── QwerteeBridge.php │ ├── RadioFranceBridge.php │ ├── RadioMelodieBridge.php │ ├── RainLoopBridge.php │ ├── RainbowSixSiegeBridge.php │ ├── RedditBridge.php │ ├── Releases3DSBridge.php │ ├── ReleasesSwitchBridge.php │ ├── RemixAudioBridge.php │ ├── ReporterreBridge.php │ ├── ReutersBridge.php │ ├── RibatejanaBridge.php │ ├── RiptApparelBridge.php │ ├── RoadAndTrackBridge.php │ ├── RobinhoodSnacksBridge.php │ ├── RoosterTeethBridge.php │ ├── RtsBridge.php │ ├── Rue89Bridge.php │ ├── Rule34Bridge.php │ ├── Rule34pahealBridge.php │ ├── RumbleBridge.php │ ├── RutubeBridge.php │ ├── SIMARBridge.php │ ├── SafebooruBridge.php │ ├── SamMobileUpdateBridge.php │ ├── SamsungMobileChangelogBridge.php │ ├── ScalableCapitalBlogBridge.php │ ├── SchweinfurtBuergerinformationenBridge.php │ ├── ScientificAmericanBridge.php │ ├── ScmbBridge.php │ ├── ScoopItBridge.php │ ├── ScribbleHubBridge.php │ ├── ScribdBridge.php │ ├── SensCritiqueBridge.php │ ├── SeznamZpravyBridge.php │ ├── ShadertoyBridge.php │ ├── ShanaprojectBridge.php │ ├── Shimmie2Bridge.php │ ├── SitemapBridge.php │ ├── SkimfeedBridge.php │ ├── SkyArteBridge.php │ ├── SleeperFantasyFootballBridge.php │ ├── SlusheBridge.php │ ├── SongkickBridge.php │ ├── SoundcloudBridge.php │ ├── SplCenterBridge.php │ ├── SpotifyBridge.php │ ├── SpottschauBridge.php │ ├── StanfordSIRbookreviewBridge.php │ ├── SteamAppNewsBridge.php │ ├── SteamBridge.php │ ├── SteamCommunityBridge.php │ ├── SteamGroupAnnouncementsBridge.php │ ├── StockFilingsBridge.php │ ├── StorytelBridge.php │ ├── StravaBridge.php │ ├── StreamCzBridge.php │ ├── StripeAPIChangeLogBridge.php │ ├── SubitoBridge.php │ ├── SubstackBridge.php │ ├── SubstackProfileBridge.php │ ├── SummitsOnTheAirBridge.php │ ├── SuperSmashBlogBridge.php │ ├── SymfonyCastsBridge.php │ ├── TCBScansBridge.php │ ├── TagesspiegelBridge.php │ ├── TapasBridge.php │ ├── TarnkappeBridge.php │ ├── TbibBridge.php │ ├── TebeoBridge.php │ ├── TeefuryBridge.php │ ├── TelegramBridge.php │ ├── TestFaktaBridge.php │ ├── TheDriveBridge.php │ ├── TheFarSideBridge.php │ ├── TheGuardianBridge.php │ ├── TheHackerNewsBridge.php │ ├── TheOatmealBridge.php │ ├── ThePirateBayBridge.php │ ├── TheRedHandFilesBridge.php │ ├── TheWhiteboardBridge.php │ ├── TheYeteeBridge.php │ ├── ThreadsBridge.php │ ├── TicketioBridge.php │ ├── TikTokBridge.php │ ├── TinyLetterBridge.php │ ├── TldrTechBridge.php │ ├── TomsToucheBridge.php │ ├── TorrentGalaxyBridge.php │ ├── TraktBridge.php │ ├── TrelloBridge.php │ ├── TriabolosNewsBridge.php │ ├── TwitScoopBridge.php │ ├── TwitchBridge.php │ ├── TwitterBridge.php │ ├── TwitterEngineeringBridge.php │ ├── TwitterV2Bridge.php │ ├── UberNewsroomBridge.php │ ├── UniverseTodayBridge.php │ ├── UnogsBridge.php │ ├── UnraidCommunityApplicationsBridge.php │ ├── UnsplashBridge.php │ ├── UrlebirdBridge.php │ ├── UsbekEtRicaBridge.php │ ├── UsenixBridge.php │ ├── UsesTechBridge.php │ ├── VarietyBridge.php │ ├── ViadeoCompanyBridge.php │ ├── ViceBridge.php │ ├── VideoCardzBridge.php │ ├── VieDeMerdeBridge.php │ ├── VimeoBridge.php │ ├── VixenBridge.php │ ├── Vk2Bridge.php │ ├── VkBridge.php │ ├── VproTegenlichtBridge.php │ ├── WKYTNewsBridge.php │ ├── WYMTNewsBridge.php │ ├── WaggaCouncilBridge.php │ ├── WallmineNewsBridge.php │ ├── WallpaperflareBridge.php │ ├── WarhammerComBridge.php │ ├── WeLiveSecurityBridge.php │ ├── WebfailBridge.php │ ├── WhatsAppBlogBridge.php │ ├── WikiLeaksBridge.php │ ├── WikipediaBridge.php │ ├── WirecutterDealsBridge.php │ ├── WiredBridge.php │ ├── WordPressBridge.php │ ├── WordPressMadaraBridge.php │ ├── WordPressPluginUpdateBridge.php │ ├── WorldOfTanksBridge.php │ ├── WorldbankBridge.php │ ├── XPathBridge.php │ ├── XbooruBridge.php │ ├── XenForoBridge.php │ ├── YGGTorrentBridge.php │ ├── YandereBridge.php │ ├── YandexZenBridge.php │ ├── YeggiBridge.php │ ├── YorushikaBridge.php │ ├── YouTubeCommunityTabBridge.php │ ├── YouTubeFeedExpanderBridge.php │ ├── YoutubeBridge.php │ ├── ZDFMediathekBridge.php │ ├── ZDNetBridge.php │ ├── ZeitBridge.php │ ├── ZenodoBridge.php │ └── ZonebourseBridge.php ├── caches/ │ ├── ArrayCache.php │ ├── FileCache.php │ ├── MemcachedCache.php │ ├── NullCache.php │ └── SQLiteCache.php ├── composer.json ├── config/ │ ├── nginx.conf │ ├── php-fpm.conf │ └── php.ini ├── config.default.ini.php ├── contrib/ │ └── .gitkeep ├── docker-bake.hcl ├── docker-compose.yml ├── docker-entrypoint.sh ├── docs/ │ ├── 01_General/ │ │ ├── 01_Project-goals.md │ │ ├── 02_Contribute.md │ │ ├── 03_Requirements.md │ │ ├── 04_Screenshots.md │ │ ├── 05_FAQ.md │ │ └── 06_Public_Hosts.md │ ├── 02_CLI/ │ │ └── index.md │ ├── 03_For_Hosts/ │ │ ├── 01_Installation.md │ │ ├── 02_Updating.md │ │ ├── 04_Heroku_Installation.md │ │ ├── 05_Whitelisting.md │ │ ├── 06_Authentication.md │ │ ├── 07_Customizations.md │ │ ├── 08_Custom_Configuration.md │ │ └── index.md │ ├── 04_For_Developers/ │ │ ├── 01_Coding_style_policy.md │ │ ├── 02_Pull_Request_policy.md │ │ ├── 03_Folder_structure.md │ │ ├── 04_Actions.md │ │ ├── 05_Debug_mode.md │ │ ├── 06_Github_Codespaces_Tutorial.md │ │ ├── 07_Development_Environment_Setup.md │ │ └── index.md │ ├── 05_Bridge_API/ │ │ ├── 01_How_to_create_a_new_bridge.md │ │ ├── 02_BridgeAbstract.md │ │ ├── 03_FeedExpander.md │ │ ├── 04_WebDriverAbstract.md │ │ ├── 05_XPathAbstract.md │ │ └── index.md │ ├── 06_Helper_functions/ │ │ └── index.md │ ├── 07_Cache_API/ │ │ ├── 01_How_to_create_a_new_cache.md │ │ ├── 02_CacheInterface.md │ │ └── index.md │ ├── 09_Technical_recommendations/ │ │ └── index.md │ ├── 10_Bridge_Specific/ │ │ ├── ActivityPub_(Mastodon).md │ │ ├── Economist.md │ │ ├── FacebookBridge.md │ │ ├── FurAffinityBridge.md │ │ ├── Furaffinityuser.md │ │ ├── Instagram.md │ │ ├── PixivBridge.md │ │ ├── Substack.md │ │ ├── Telegram.md │ │ ├── TwitterV2.md │ │ └── Vk2.md │ ├── 99_Theme/ │ │ └── rssbridge/ │ │ └── config.json │ ├── config.json │ ├── index.md │ └── readme.md ├── formats/ │ ├── AtomFormat.php │ ├── HtmlFormat.php │ ├── JsonFormat.php │ ├── MrssFormat.php │ ├── PlaintextFormat.php │ └── SfeedFormat.php ├── index.php ├── lib/ │ ├── ActionInterface.php │ ├── BridgeAbstract.php │ ├── BridgeFactory.php │ ├── CacheFactory.php │ ├── CacheInterface.php │ ├── Configuration.php │ ├── Container.php │ ├── FeedExpander.php │ ├── FeedItem.php │ ├── FeedParser.php │ ├── FormatAbstract.php │ ├── FormatFactory.php │ ├── ParameterValidator.php │ ├── RssBridge.php │ ├── TwitterClient.php │ ├── WebDriverAbstract.php │ ├── XPathAbstract.php │ ├── bootstrap.php │ ├── config.php │ ├── contents.php │ ├── dependencies.php │ ├── html.php │ ├── http.php │ ├── logger.php │ ├── parsedown/ │ │ ├── LICENSE.txt │ │ └── Parsedown.php │ ├── php-urljoin/ │ │ ├── LICENSE │ │ └── src/ │ │ └── urljoin.php │ ├── php8backports.php │ ├── seotags.php │ ├── simplehtmldom/ │ │ ├── LICENSE │ │ └── simple_html_dom.php │ ├── url.php │ └── utils.php ├── middlewares/ │ ├── BasicAuthMiddleware.php │ ├── CacheMiddleware.php │ ├── ExceptionMiddleware.php │ ├── MaintenanceMiddleware.php │ ├── Middleware.php │ ├── SecurityMiddleware.php │ └── TokenAuthenticationMiddleware.php ├── phpcompatibility.xml ├── phpcs.xml ├── phpunit.xml ├── scalingo.json ├── static/ │ ├── connectivity.css │ ├── connectivity.js │ ├── rss-bridge.js │ └── style.css ├── templates/ │ ├── base.html.php │ ├── bridge-error.html.php │ ├── connectivity.html.php │ ├── error.html.php │ ├── exception.html.php │ ├── frontpage.html.php │ ├── html-format.html.php │ └── token.html.php └── tests/ ├── BridgeCardTest.php ├── BridgeFactoryTest.php ├── BridgeImplementationTest.php ├── CacheImplementationTest.php ├── CacheTest.php ├── ConfigurationTest.php ├── FeedItemTest.php ├── FeedParserTest.php ├── FormatTest.php ├── Formats/ │ ├── AtomFormatTest.php │ ├── BaseFormatTest.php │ ├── FormatImplementationTest.php │ ├── JsonFormatTest.php │ ├── MrssFormatTest.php │ └── samples/ │ ├── expectedAtomFormat/ │ │ ├── feed.common.xml │ │ ├── feed.empty.xml │ │ ├── feed.emptyItems.xml │ │ └── feed.microblog.xml │ ├── expectedJsonFormat/ │ │ ├── feed.common.json │ │ ├── feed.empty.json │ │ ├── feed.emptyItems.json │ │ └── feed.microblog.json │ ├── expectedMrssFormat/ │ │ ├── feed.common.xml │ │ ├── feed.empty.xml │ │ ├── feed.emptyItems.xml │ │ └── feed.microblog.xml │ ├── feed.common.json │ ├── feed.empty.json │ ├── feed.emptyItems.json │ └── feed.microblog.json ├── ParameterValidatorTest.php ├── RedditBridgeTest.php ├── UrlTest.php └── UtilsTest.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ FROM rssbridge/rss-bridge:latest COPY --chmod=755 post-create-command.sh /usr/local/bin/post-create-command ADD https://raw.githubusercontent.com/docker-library/php/master/docker-php-ext-enable /usr/local/bin/docker-php-ext-enable RUN chmod u+x /usr/local/bin/docker-php-ext-enable ADD https://getcomposer.org/installer /usr/local/bin/composer-installer.php RUN chmod u+x /usr/local/bin/composer-installer.php RUN php /usr/local/bin/composer-installer.php --check && \ php /usr/local/bin/composer-installer.php --filename=composer --install-dir=/usr/local/bin RUN apt-get update && \ apt-get install -y \ git \ php-dev \ make \ unzip RUN pecl install xdebug && \ PHP_INI_DIR=/etc/php/8.2/fpm docker-php-ext-enable xdebug ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "rss-bridge dev", "build": { "dockerfile": "Dockerfile" }, "customizations": { // Configure properties specific to VS Code. "vscode": { // Set *default* container specific settings.json values on container create. "settings": { "php.validate.executablePath": "/usr/bin/php", "phpSniffer.executablesFolder": "/root/.config/composer/vendor/bin", "phpcs.executablePath": "/root/.config/composer/vendor/bin/phpcs", "phpcs.lintOnType": false }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ "xdebug.php-debug", "bmewburn.vscode-intelephense-client", "philfontaine.autolaunch", "eamodio.gitlens", "shevaua.phpcs" ] } }, "remoteEnv": { "PATH": "${containerEnv:PATH}:/root/.config/composer/vendor/bin", }, "forwardPorts": [3100, 9000, 9003], "postCreateCommand": "/usr/local/bin/post-create-command" } ================================================ FILE: .devcontainer/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Listen for Xdebug", "type": "php", "request": "launch", "port": 9003, "auto": true, "log": true }, { "name": "Launch currently open script", "type": "php", "request": "launch", "program": "${file}", "cwd": "${fileDirname}", "port": 0, "runtimeArgs": [ "-dxdebug.start_with_request=yes" ], "env": { "XDEBUG_MODE": "debug,develop", "XDEBUG_CONFIG": "client_port=${port}" } }, { "name": "Launch Built-in web server", "type": "php", "request": "launch", "runtimeArgs": [ "-dxdebug.mode=debug", "-dxdebug.start_with_request=yes", "-S", "localhost:0" ], "program": "", "cwd": "${workspaceRoot}", "port": 9003, "serverReadyAction": { "pattern": "Development Server \\(http://localhost:([0-9]+)\\) started", "uriFormat": "http://localhost:%s", "action": "openExternally" } } ] } ================================================ FILE: .devcontainer/nginx.conf ================================================ server { listen 3100 default_server; root /workspaces/rss-bridge; access_log /var/log/nginx/rssbridge.access.log; error_log /var/log/nginx/rssbridge.error.log; index index.php; location ~ /(\.|vendor|tests) { deny all; return 403; # Forbidden } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; } } ================================================ FILE: .devcontainer/post-create-command.sh ================================================ #/bin/sh cp .devcontainer/nginx.conf /etc/nginx/conf.d/default.conf cp .devcontainer/xdebug.ini /etc/php/8.2/fpm/conf.d/xdebug.ini # This should download some dev-dependencies, like phpunit and the PHP code sniffers composer global require "phpunit/phpunit:^9" composer global require "squizlabs/php_codesniffer:^3.6" composer global require "phpcompatibility/php-compatibility:^9.3" # We need to this manually for running the PHPCompatibility ruleset phpcs --config-set installed_paths /root/.config/composer/vendor/phpcompatibility/php-compatibility mkdir -p .vscode cp .devcontainer/launch.json .vscode echo '*' > whitelist.txt chmod a+x $(pwd) rm -rf /var/www/html ln -s $(pwd) /var/www/html # Solves possible issue of cache directory not being accessible chown www-data:www-data -R $(pwd)/cache nginx php-fpm8.2 -D ================================================ FILE: .devcontainer/xdebug.ini ================================================ [xdebug] xdebug.mode=develop,debug xdebug.client_host=localhost xdebug.client_port=9003 xdebug.start_with_request=yes xdebug.discover_client_host=false xdebug.log='/var/www/html/xdebug.log' ================================================ FILE: .dockerignore ================================================ .git !.git/HEAD !.git/refs/heads/* .gitattributes .github/* .travis.yml cache/* CONTRIBUTING.md DEBUG Dockerfile phpcompatibility.xml phpcs.xml phpcs.xml scalingo.json tests/* whitelist.txt ================================================ FILE: .git-blame-ignore-revs ================================================ # Reformat code base to PSR12 4f75591060d95208a301bc6bf460d875631b29cc # Fix coding style missed by phpbcf 951092eef374db048b77bac85e75e3547bfac702 ================================================ FILE: .gitattributes ================================================ # Auto detect text files and perform LF normalization * text=auto *.sh text eol=lf # Custom for Visual Studio *.cs diff=csharp *.sln merge=union *.csproj merge=union *.vbproj merge=union *.fsproj merge=union *.dbproj merge=union # Standard to msysgit *.doc diff=astextplain *.DOC diff=astextplain *.docx diff=astextplain *.DOCX diff=astextplain *.dot diff=astextplain *.DOT diff=astextplain *.pdf diff=astextplain *.PDF diff=astextplain *.rtf diff=astextplain *.RTF diff=astextplain # Ignore files in git archive (i.e. GitHub release builds) ## Docker Dockerfile export-ignore .dockerignore export-ignore ## Travis .travis.yml export-ignore ## GitHub .github/ export-ignore ## Git .gitattributes export-ignore .gitignore export-ignore ## Scalingo scalingo.json export-ignore ## RSS-Bridge phpunit.xml export-ignore phpcs.xml export-ignore phpcompatibility.xml export-ignore tests/ export-ignore cache/.gitkeep export-ignore ## Composer # # Keep the following lines commented out. Heroku does # not function if the composer files are ignored during # export. For more information see # https://github.com/rss-bridge/rss-bridge/issues/1165 # # composer.json export-ignore # composer.lock export-ignore ## Heroku # # Keep the following line commented out. Heroku does # not function if app.json is ignored during export. # For more information see # https://github.com/rss-bridge/rss-bridge/issues/1165 # # app.json export-ignore ================================================ FILE: .github/.gitignore ================================================ # Visual Studio Code .vscode/* # Generated files comment*.md comment*.txt *.html ================================================ FILE: .github/CONTRIBUTING.md ================================================ ### Pull request policy See the [Pull request policy page on the documentation](https://rss-bridge.github.io/rss-bridge/For_Developers/Pull_Request_policy.html) for more information on the pull request policy. ### Coding style See the [Coding style policy page on the documentation](https://rss-bridge.github.io/rss-bridge/For_Developers/Coding_style_policy.html) for more information on the coding style of the project. ================================================ FILE: .github/ISSUE_TEMPLATE/bridge-request.md ================================================ --- name: Bridge request about: Use this template for requesting a new bridge title: Bridge request for ... labels: Bridge-Request assignees: '' --- # Bridge request ## General information - _Host URI for the bridge_ (i.e. `https://github.com`): - Which information would you like to see? - How should the information be displayed/formatted? - Which of the following parameters do you expect? - [X] Title - [X] URI (link to the original article) - [ ] Author - [ ] Timestamp - [X] Content (the content of the article) - [ ] Enclosures (pictures, videos, etc...) - [ ] Categories (categories, tags, etc...) ## Options - [ ] Limit number of returned items - _Default limit_: 5 - [ ] Load full articles - _Cache articles_ (articles are stored in a local cache on first request): yes - _Cache timeout_ : 24 hours - [X] Balance requests (RSS-Bridge uses cached versions to reduce bandwith usage) - _Timeout_ (default = 5 minutes): 5 minutes ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: Bug-Report assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: Feature-Request assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/prtester-requirements.txt ================================================ beautifulsoup4>=4.10.0 requests>=2.26.0 ================================================ FILE: .github/prtester.py ================================================ import argparse import requests import re from bs4 import BeautifulSoup from datetime import datetime from typing import Iterable import os import glob import urllib # This script is specifically written to be used in automation for https://github.com/RSS-Bridge/rss-bridge # # This will scrape the whitelisted bridges in the current state (port 3000) and the PR state (port 3001) of # RSS-Bridge, generate a feed for each of the bridges and save the output as html files. # It also add a tag with the url of em's public instance, so viewing # the HTML file locally will actually work as designed. ARTIFACT_FILE_EXTENSION = '.html' class Instance: name = '' url = '' def main(instances: Iterable[Instance], with_artifacts: bool, with_reduced_artifacts: bool, artifacts_directory: str, artifacts_base_url: str, title: str, output_file: str): start_date = datetime.now() for file in glob.glob(f'*{ARTIFACT_FILE_EXTENSION}', root_dir=artifacts_directory): os.remove(file) table_rows = [] for instance in instances: page = requests.get(instance.url) # Use python requests to grab the rss-bridge main page soup = BeautifulSoup(page.content, "html.parser") # use bs4 to turn the page into soup bridge_cards = soup.select('.bridge-card') # get a soup-formatted list of all bridges on the rss-bridge page table_rows += testBridges( instance=instance, bridge_cards=bridge_cards, with_artifacts=with_artifacts, with_reduced_artifacts=with_reduced_artifacts, artifacts_directory=artifacts_directory, artifacts_base_url=artifacts_base_url) # run the main scraping code with the list of bridges with open(file=output_file, mode='w+', encoding='utf-8') as file: table_rows_value = '\n'.join(sorted(table_rows)) file.write(f''' ## {title} | Bridge | Context | Status | | - | - | - | {table_rows_value} *last change: {start_date.strftime("%A %Y-%m-%d %H:%M:%S")}* '''.strip()) def testBridges(instance: Instance, bridge_cards: Iterable, with_artifacts: bool, with_reduced_artifacts: bool, artifacts_directory: str, artifacts_base_url: str) -> Iterable: instance_suffix = '' if instance.name: instance_suffix = f' ({instance.name})' table_rows = [] for bridge_card in bridge_cards: bridgeid = bridge_card.get('id') bridgeid = bridgeid.split('-')[1] # this extracts a readable bridge name from the bridge metadata print(f'{bridgeid}{instance_suffix}') bridge_name = bridgeid.replace('Bridge', '') context_forms = bridge_card.find_all("form") form_number = 1 for context_form in context_forms: # a bridge can have multiple contexts, named 'forms' in html # this code will produce a fully working url that should create a working feed when called # this will create an example feed for every single context, to test them all context_parameters = {} error_messages = [] context_name = '*untitled*' context_name_element = context_form.find_previous_sibling('h5') if context_name_element and context_name_element.text.strip() != '': context_name = context_name_element.text parameters = context_form.find_all("input") lists = context_form.find_all("select") # this for/if mess cycles through all available input parameters, checks if it required, then pulls # the default or examplevalue and then combines it all together into the url parameters # if an example or default value is missing for a required attribute, it will throw an error # any non-required fields are not tested!!! for parameter in parameters: parameter_type = parameter.get('type') parameter_name = parameter.get('name') if parameter_type == 'hidden': context_parameters[parameter_name] = parameter.get('value') if parameter_type == 'number' or parameter_type == 'text': if parameter.has_attr('required'): if parameter.get('placeholder') == '': if parameter.get('value') == '': error_messages.append(f'Missing example or default value for parameter "{parameter_name}"') else: context_parameters[parameter_name] = parameter.get('value') else: context_parameters[parameter_name] = parameter.get('placeholder') # same thing, just for checkboxes. If a checkbox is checked per default, it gets added to the url parameters if parameter_type == 'checkbox': if parameter.has_attr('checked'): context_parameters[parameter_name] = 'on' for listing in lists: selectionvalue = '' listname = listing.get('name') cleanlist = [] options = listing.find_all('option') for option in options: if 'optgroup' in option.name: cleanlist.extend(option) else: cleanlist.append(option) firstselectionentry = 1 for selectionentry in cleanlist: if firstselectionentry: selectionvalue = selectionentry.get('value') firstselectionentry = 0 else: if 'selected' in selectionentry.attrs: selectionvalue = selectionentry.get('value') break context_parameters[listname] = selectionvalue artifact_url = 'about:blank' if error_messages: status = '
'.join(map(lambda m: f'❌ `{m}`', error_messages)) else: # if all example/default values are present, form the full request url, run the request, add a tag with # the url of em's public instance to the response text (so that relative paths work, e.g. to the static css file) and # then save it to a html file. context_parameters.update({ 'action': 'display', 'bridge': bridgeid, 'format': 'Html', }) request_url = f'{instance.url}/?{urllib.parse.urlencode(context_parameters)}' response = requests.get(request_url) page_text = response.text.replace('','') page_text = page_text.encode("utf_8") soup = BeautifulSoup(page_text, "html.parser") status_messages = [] if response.status_code != 200: status_messages += [f'❌ `HTTP status {response.status_code} {response.reason}`'] else: feed_items = soup.select('.feeditem') feed_items_length = len(feed_items) if feed_items_length <= 0: status_messages += [f'⚠️ `The feed has no items`'] elif feed_items_length == 1 and len(soup.select('.error')) > 0: status_messages += [f'❌ `{getFirstLine(feed_items[0].text)}`'] status_messages += map(lambda e: f'❌ `{getFirstLine(e.text)}`', soup.select('.error .error-type') + soup.select('.error .error-message')) for item_element in soup.select('.feeditem'): # remove all feed items to not accidentally selected
 tags from item content
                    item_element.decompose()
                status_messages += map(lambda e: f'⚠️ `{getFirstLine(e.text)}`', soup.find_all('pre'))
                status_messages = list(dict.fromkeys(status_messages)) # remove duplicates
                status = '
'.join(status_messages) status_is_ok = status == ''; if status_is_ok: status = '✔️' if with_artifacts and (not with_reduced_artifacts or not status_is_ok): filename = f'{bridge_name} {form_number}{instance_suffix}{ARTIFACT_FILE_EXTENSION}' filename = re.sub(r'[^a-z0-9 \_\-\.]', '', filename, flags=re.I).replace(' ', '_') with open(file=f'{artifacts_directory}/{filename}', mode='wb') as file: file.write(page_text) artifact_url = f'{artifacts_base_url}/{filename}' table_rows.append(f'| {bridge_name} | [{form_number} {context_name}{instance_suffix}]({artifact_url}) | {status} |') form_number += 1 return table_rows def getFirstLine(value: str) -> str: # trim whitespace and remove text that can break the table or is simply unnecessary clean_value = re.sub(r'^\[[^\]]+\]\s*rssbridge\.|[\|`]', '', value.strip()) first_line = next(iter(clean_value.splitlines()), '') max_length = 250 if (len(first_line) > max_length): first_line = first_line[:max_length] + '...' return first_line if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--instances', nargs='+') parser.add_argument('--no-artifacts', action='store_true') parser.add_argument('--reduced-artifacts', action='store_true') parser.add_argument('--artifacts-directory', default=os.getcwd()) parser.add_argument('--artifacts-base-url', default='') parser.add_argument('--title', default='Pull request artifacts') parser.add_argument('--output-file', default=os.getcwd() + '/comment.md') args = parser.parse_args() instances = [] if args.instances: for instance_arg in args.instances: instance_arg_parts = instance_arg.split('::') instance = Instance() instance.name = instance_arg_parts[1].strip() if len(instance_arg_parts) >= 2 else '' instance.url = instance_arg_parts[0].strip().rstrip("/") instances.append(instance) else: instance = Instance() instance.name = 'current' instance.url = 'http://localhost:3000' instances.append(instance) instance = Instance() instance.name = 'pr' instance.url = 'http://localhost:3001' instances.append(instance) main( instances=instances, with_artifacts=not args.no_artifacts, with_reduced_artifacts=args.reduced_artifacts and not args.no_artifacts, artifacts_directory=args.artifacts_directory, artifacts_base_url=args.artifacts_base_url, title=args.title, output_file=args.output_file ); ================================================ FILE: .github/workflows/dockerbuild.yml ================================================ name: Build Container Image on: push: branches: - 'master' tags: - '20*' pull_request: branches: - 'master' paths: - .github/workflows/** - Dockerfile permissions: contents: read packages: write env: DOCKERHUB_SLUG: rssbridge/rss-bridge GHCR_SLUG: ghcr.io/rss-bridge/rss-bridge jobs: bake: runs-on: ubuntu-24.04-arm steps: - name: Docker meta id: docker_meta uses: docker/metadata-action@v5 with: images: | ${{ env.DOCKERHUB_SLUG }} ${{ env.GHCR_SLUG }} tags: | type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }} type=sha type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/20') }} type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/20') }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to DockerHub uses: docker/login-action@v3 if: ${{ github.event_name != 'pull_request' }} with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GitHub Container Registry uses: docker/login-action@v3 if: ${{ github.event_name != 'pull_request' }} with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/bake-action@v6 with: files: | ./docker-bake.hcl cwd://${{ steps.docker_meta.outputs.bake-file }} targets: image-all push: ${{ github.event_name != 'pull_request' }} ================================================ FILE: .github/workflows/documentation.yml ================================================ name: Documentation on: push: paths: - 'docs/**' pull_request: branches: - 'master' paths: - .github/workflows/** - 'docs/**' permissions: contents: write jobs: documentation: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v5 with: persist-credentials: false - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.0 - name: Install dependencies run: composer global require daux/daux.io - name: Generate documentation run: daux generate - name: Deploy same repository 🚀 if: ${{ github.event_name != 'pull_request' }} uses: JamesIves/github-pages-deploy-action@v4 with: folder: "static" branch: gh-pages ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: push: branches: [ master ] pull_request: branches: [ master ] permissions: contents: read jobs: phpcs: runs-on: ubuntu-24.04-arm strategy: matrix: php-versions: ['7.4'] steps: - uses: actions/checkout@v5 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} tools: phpcs - run: phpcs . --standard=phpcs.xml --warning-severity=0 --extensions=php -p phpcompatibility: runs-on: ubuntu-24.04-arm strategy: matrix: php-versions: ['7.4'] steps: - uses: actions/checkout@v5 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - run: composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true - run: composer global require dealerdirect/phpcodesniffer-composer-installer phpcompatibility/php-compatibility - run: ~/.composer/vendor/bin/phpcs . --standard=phpcompatibility.xml --warning-severity=0 --extensions=php -p executable_php_files_check: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v5 - run: | if find -name "*.php" -executable -type f -print -exec false {} + then echo 'Good, no executable php scripts found' else echo 'Please unmark php scripts above as non-executable' exit 1 fi ================================================ FILE: .github/workflows/prhtmlgenerator.yml ================================================ name: 'PR Testing' on: pull_request_target: branches: [ master ] permissions: contents: read jobs: checks: name: Check if bridges were changed runs-on: ubuntu-24.04-arm outputs: BRIDGES: ${{ steps.check_bridges.outputs.BRIDGES }} WITH_UPLOAD: ${{ steps.check_upload.outputs.WITH_UPLOAD }} steps: - name: Check number of bridges id: check_bridges run: | PR=${{ github.event.number || 'none' }}; wget https://patch-diff.githubusercontent.com/raw/$GITHUB_REPOSITORY/pull/$PR.patch; bridgeamount=$(cat $PR.patch | grep "\bbridges/[A-Za-z0-9]*Bridge\.php\b" | sed "s=.*\bbridges/\([A-Za-z0-9]*\)Bridge\.php\b.*=\1=g" | sort | uniq | wc -l); echo "BRIDGES=$bridgeamount" >> "$GITHUB_OUTPUT" - name: "Check upload token secret RSSTESTER_SSH_KEY is set" id: check_upload run: | echo "WITH_UPLOAD=$([ -n "${{ secrets.RSSTESTER_SSH_KEY }}" ] && echo "true" || echo "false")" >> "$GITHUB_OUTPUT" test-pr: name: Generate HTML files runs-on: ubuntu-24.04-arm needs: checks if: needs.checks.outputs.BRIDGES > 0 outputs: COMMENT_LENGTH: ${{ steps.run-tests.outputs.bodylength }} steps: - name: Checkout - PR uses: actions/checkout@v5 with: ref: ${{github.event.pull_request.head.ref}} repository: ${{github.event.pull_request.head.repo.full_name}} - name: Build docker image - PR run: docker build -t rss-bridge-pr . - name: Clear workspace run: rm -r * .* - name: Checkout - Current uses: actions/checkout@v5 - name: Build docker image - Current run: docker build -t rss-bridge-current . - name: Create configuration files run: | touch DEBUG; PR=${{ github.event.number || 'none' }}; wget https://patch-diff.githubusercontent.com/raw/$GITHUB_REPOSITORY/pull/$PR.patch; cat $PR.patch | grep "\bbridges/[A-Za-z0-9]*Bridge\.php\b" | sed "s=.*\bbridges/\([A-Za-z0-9]*\)Bridge\.php\b.*=\1=g" | sort | uniq > whitelist.txt; echo "whitelist.txt:"; cat whitelist.txt - name: Run docker container - Current run: docker run -v $GITHUB_WORKSPACE/whitelist.txt:/app/whitelist.txt -v $GITHUB_WORKSPACE/DEBUG:/app/DEBUG --detach --pull never -p 3000:80 rss-bridge-current - name: Run docker container - PR run: docker run -v $GITHUB_WORKSPACE/whitelist.txt:/app/whitelist.txt -v $GITHUB_WORKSPACE/DEBUG:/app/DEBUG --detach --pull never -p 3001:80 rss-bridge-pr - name: Setup python uses: actions/setup-python@v6 with: python-version: '3.13' cache: 'pip' cache-dependency-path: '**/*requirements*.txt' - name: Install requirements run: pip install -r ./.github/prtester-requirements.txt - name: Run bridge tests id: run-tests env: PYTHONUNBUFFERED: 1 run: | python ./.github/prtester.py --artifacts-base-url "https://${{ github.repository_owner }}.github.io/${{ vars.ARTIFACTS_REPO || 'rss-bridge-tests' }}/prs/${{ github.event.number || 'none' }}"; body="$(cat comment.md)"; body="${body//'%'/'%25'}"; body="${body//$'\n'/'%0A'}"; body="${body//$'\r'/'%0D'}"; echo "bodylength=${#body}" >> $GITHUB_OUTPUT - name: Upload test results if: steps.run-tests.outputs.bodylength > 130 uses: actions/upload-artifact@v5 with: name: test-results path: | comment.md *.html if-no-files-found: error comment-pr: name: Create or update PR comment runs-on: ubuntu-24.04-arm needs: test-pr if: needs.test-pr.outputs.COMMENT_LENGTH > 130 permissions: pull-requests: write steps: - name: Download test results uses: actions/download-artifact@v5 with: name: test-results - name: Add comment to job summary run: cat comment.md >> $GITHUB_STEP_SUMMARY - name: Find Comment uses: peter-evans/find-comment@v4 id: find-comment with: issue-number: ${{ github.event.pull_request.number }} comment-author: 'github-actions[bot]' body-includes: Pull request artifacts - name: Create or update comment uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-comment.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} body-file: comment.md edit-mode: replace upload-test-results: name: Upload to GitHub Pages repository runs-on: ubuntu-24.04-arm needs: test-pr if: needs.test-pr.outputs.COMMENT_LENGTH > 130 && needs.checks.outputs.WITH_UPLOAD == 'true' steps: - uses: actions/checkout@v5 with: repository: "${{ github.repository_owner }}/${{ vars.ARTIFACTS_REPO || 'rss-bridge-tests' }}" ref: 'main' ssh-key: ${{ secrets.RSSTESTER_SSH_KEY }} - name: Setup git config run: | git config --global user.name "GitHub Actions" git config --global user.email "<>" - name: Download test results uses: actions/download-artifact@v5 with: name: test-results - name: Move test results run: | DIRECTORY="$GITHUB_WORKSPACE/prs/${{ github.event.number || 'none' }}" rm -rf $DIRECTORY mkdir -p $DIRECTORY cd $DIRECTORY mv -f $GITHUB_WORKSPACE/comment.md ./README.md mv -f $GITHUB_WORKSPACE/*.html . - name: Commit and push test results run: | export COMMIT_MESSAGE="Test results for PR ${{ github.event.number || 'none' }}" git add . git commit -m "$COMMIT_MESSAGE" || exit 0 git push ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: push: branches: [ master ] pull_request: branches: [ master ] permissions: contents: read jobs: phpunit8: runs-on: ubuntu-24.04 strategy: matrix: php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v5 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} env: update: true - run: composer install - run: composer test ================================================ FILE: .gitignore ================================================ ################# ## Eclipse ################# vendor/* data/ *.pydevproject .project .metadata tmp/ *.tmp *.bak *.swp *~.nib local.properties .classpath .settings/ .loadpath # External tool builders .externalToolBuilders/ # Locally stored "Eclipse launch configurations" *.launch # CDT-specific .cproject # PDT-specific .buildpath ################# ## Visual Studio ################# ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. # User-specific files *.suo *.user *.sln.docstates # Build results [Dd]ebug/ [Rr]elease/ x64/ build/ [Bb]in/ [Oo]bj/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* *_i.c *_p.c *.ilk *.meta *.obj *.pch *.pdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *.log *.vspscc *.vssscc .builds *.pidb *.log *.scc # Visual C++ cache files ipch/ *.aps *.ncb *.opensdf *.sdf *.cachefile # Visual Studio profiler *.psess *.vsp *.vspx # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # NCrunch *.ncrunch* .*crunch*.local.xml # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.Publish.xml *.pubxml # NuGet Packages Directory ## TODO: If you have NuGet Package Restore enabled, uncomment the next line #packages/ # Windows Azure Build Output csx *.build.csdef # Windows Store app package directory AppPackages/ # Others sql/ *.Cache ClientBin/ [Ss]tyle[Cc]op.* ~$* *~ *.dbmdl *.[Pp]ublish.xml *.pfx *.publishsettings # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file to a newer # Visual Studio version. Backup files are not needed, because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm # SQL Server files App_Data/*.mdf App_Data/*.ldf ################# ## Other ide stuff ################# .idea/* [#]*[#] ############# ## Windows detritus ############# # Windows image file caches Thumbs.db ehthumbs.db # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Mac crap .DS_Store ############# ## Python ############# *.py[co] # Packages *.egg *.egg-info dist/ build/ eggs/ parts/ var/ sdist/ develop-eggs/ .installed.cfg # Installer logs pip-log.txt # Unit test / coverage reports .coverage .phpunit.result.cache .tox #Translations *.mo #Mr Developer .mr.developer.cfg ############## ## RSS-Bridge ############## /cache /whitelist.txt DEBUG config.ini.php config/* !config/nginx.conf !config/php-fpm.conf !config/php.ini favicon.gif favicon.ico ###################### ## VisualStudioCode ## ###################### .vscode/* #Builder .buildconfig #Auth .htaccess .htpasswd #Crawler robots.txt ================================================ FILE: CONTRIBUTORS.md ================================================ # Contributors * [16mhz](https://github.com/16mhz) * [adamchainz](https://github.com/adamchainz) * [Ahiles3005](https://github.com/Ahiles3005) * [akirk](https://github.com/akirk) * [Albirew](https://github.com/Albirew) * [aledeg](https://github.com/aledeg) * [alex73](https://github.com/alex73) * [alexAubin](https://github.com/alexAubin) * [Alkarex](https://github.com/Alkarex) * [AmauryCarrade](https://github.com/AmauryCarrade) * [arnd-s](https://github.com/arnd-s) * [ArthurHoaro](https://github.com/ArthurHoaro) * [Astalaseven](https://github.com/Astalaseven) * [Astyan-42](https://github.com/Astyan-42) * [austinhuang0131](https://github.com/austinhuang0131) * [axor-mst](https://github.com/axor-mst) * [ayacoo](https://github.com/ayacoo) * [az5he6ch](https://github.com/az5he6ch) * [b1nj](https://github.com/b1nj) * [benasse](https://github.com/benasse) * [Binnette](https://github.com/Binnette) * [BoboTiG](https://github.com/BoboTiG) * [Bockiii](https://github.com/Bockiii) * [brtsos](https://github.com/brtsos) * [captn3m0](https://github.com/captn3m0) * [chemel](https://github.com/chemel) * [Chouchen](https://github.com/Chouchen) * [ckiw](https://github.com/ckiw) * [cn-tools](https://github.com/cn-tools) * [cnlpete](https://github.com/cnlpete) * [corenting](https://github.com/corenting) * [couraudt](https://github.com/couraudt) * [csisoap](https://github.com/csisoap) * [da2x](https://github.com/da2x) * [dabenzel](https://github.com/dabenzel) * [Daiyousei](https://github.com/Daiyousei) * [dawidsowa](https://github.com/dawidsowa) * [DevonHess](https://github.com/DevonHess) * [dhuschde](https://github.com/dhuschde) * [disk0x](https://github.com/disk0x) * [DJCrashdummy](https://github.com/DJCrashdummy) * [Djuuu](https://github.com/Djuuu) * [DnAp](https://github.com/DnAp) * [dominik-th](https://github.com/dominik-th) * [Draeli](https://github.com/Draeli) * [Dreckiger-Dan](https://github.com/Dreckiger-Dan) * [drego85](https://github.com/drego85) * [drklee3](https://github.com/drklee3) * [DRogueRonin](https://github.com/DRogueRonin) * [dvikan](https://github.com/dvikan) * [eggwhalefrog](https://github.com/eggwhalefrog) * [em92](https://github.com/em92) * [eMerzh](https://github.com/eMerzh) * [EtienneM](https://github.com/EtienneM) * [f0086](https://github.com/f0086) * [fanch317](https://github.com/fanch317) * [fatuuse](https://github.com/fatuuse) * [fivefilters](https://github.com/fivefilters) * [floviolleau](https://github.com/floviolleau) * [fluffy-critter](https://github.com/fluffy-critter) * [fmachen](https://github.com/fmachen) * [Frenzie](https://github.com/Frenzie) * [fulmeek](https://github.com/fulmeek) * [ggiessen](https://github.com/ggiessen) * [gileri](https://github.com/gileri) * [Ginko-Aloe](https://github.com/Ginko-Aloe) * [girlpunk](https://github.com/girlpunk) * [Glandos](https://github.com/Glandos) * [gloony](https://github.com/gloony) * [GregThib](https://github.com/GregThib) * [griffaurel](https://github.com/griffaurel) * [Grummfy](https://github.com/Grummfy) * [gsantner](https://github.com/gsantner) * [guigot](https://github.com/guigot) * [hollowleviathan](https://github.com/hollowleviathan) * [hpacleb](https://github.com/hpacleb) * [hunhejj](https://github.com/hunhejj) * [husim0](https://github.com/husim0) * [IceWreck](https://github.com/IceWreck) * [imagoiq](https://github.com/imagoiq) * [j0k3r](https://github.com/j0k3r) * [JackNUMBER](https://github.com/JackNUMBER) * [jacquesh](https://github.com/jacquesh) * [jakubvalenta](https://github.com/jakubvalenta) * [JasonGhent](https://github.com/JasonGhent) * [jcgoette](https://github.com/jcgoette) * [jdesgats](https://github.com/jdesgats) * [jdigilio](https://github.com/jdigilio) * [JeremyRand](https://github.com/JeremyRand) * [JimDog546](https://github.com/JimDog546) * [jNullj](https://github.com/jNullj) * [Jocker666z](https://github.com/Jocker666z) * [johnnygroovy](https://github.com/johnnygroovy) * [johnpc](https://github.com/johnpc) * [joni1993](https://github.com/joni1993) * [jtojnar](https://github.com/jtojnar) * [KamaleiZestri](https://github.com/KamaleiZestri) * [kkoyung](https://github.com/kkoyung) * [klimplant](https://github.com/klimplant) * [KN4CK3R](https://github.com/KN4CK3R) * [kolarcz](https://github.com/kolarcz) * [kranack](https://github.com/kranack) * [kraoc](https://github.com/kraoc) * [krisu5](https://github.com/krisu5) * [l1n](https://github.com/l1n) * [laBecasse](https://github.com/laBecasse) * [lagaisse](https://github.com/lagaisse) * [lalannev](https://github.com/lalannev) * [langfingaz](https://github.com/langfingaz) * [lassana](https://github.com/lassana) * [ldidry](https://github.com/ldidry) * [Leomaradan](https://github.com/Leomaradan) * [leyrer](https://github.com/leyrer) * [liamka](https://github.com/liamka) * [Limero](https://github.com/Limero) * [LogMANOriginal](https://github.com/LogMANOriginal) * [lorenzos](https://github.com/lorenzos) * [lukasklinger](https://github.com/lukasklinger) * [m0zes](https://github.com/m0zes) * [Mar-Koeh](https://github.com/Mar-Koeh) * [marcus-at-localhost](https://github.com/marcus-at-localhost) * [marius8510000-bot](https://github.com/marius8510000-bot) * [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) * [mibe](https://github.com/mibe) * [mickaelBert](https://github.com/mickaelBert) * [mightymt](https://github.com/mightymt) * [mitsukarenai](https://github.com/mitsukarenai) * [Monocularity](https://github.com/Monocularity) * [MonsieurPoutounours](https://github.com/MonsieurPoutounours) * [mr-flibble](https://github.com/mr-flibble) * [mro](https://github.com/mro) * [mschwld](https://github.com/mschwld) * [muekoeff](https://github.com/muekoeff) * [mw80](https://github.com/mw80) * [mxmehl](https://github.com/mxmehl) * [Mynacol](https://github.com/Mynacol) * [nel50n](https://github.com/nel50n) * [niawag](https://github.com/niawag) * [Niehztog](https://github.com/Niehztog) * [NikNikYkt](https://github.com/NikNikYkt) * [Nono-m0le](https://github.com/Nono-m0le) * [NotsoanoNimus](https://github.com/NotsoanoNimus) * [obsiwitch](https://github.com/obsiwitch) * [Ololbu](https://github.com/Ololbu) * [ORelio](https://github.com/ORelio) * [otakuf](https://github.com/otakuf) * [Park0](https://github.com/Park0) * [Paroleen](https://github.com/Paroleen) * [Patricol](https://github.com/Patricol) * [paulchen](https://github.com/paulchen) * [PaulVayssiere](https://github.com/PaulVayssiere) * [pellaeon](https://github.com/pellaeon) * [PeterDaveHello](https://github.com/PeterDaveHello) * [Peterr-K](https://github.com/Peterr-K) * [Piranhaplant](https://github.com/Piranhaplant) * [pirnz](https://github.com/pirnz) * [pit-fgfjiudghdf](https://github.com/pit-fgfjiudghdf) * [pitchoule](https://github.com/pitchoule) * [pmaziere](https://github.com/pmaziere) * [Pofilo](https://github.com/Pofilo) * [prysme01](https://github.com/prysme01) * [pubak42](https://github.com/pubak42) * [Qluxzz](https://github.com/Qluxzz) * [quentinus95](https://github.com/quentinus95) * [quickwick](https://github.com/quickwick) * [rakoo](https://github.com/rakoo) * [RawkBob](https://github.com/RawkBob) * [regisenguehard](https://github.com/regisenguehard) * [Riduidel](https://github.com/Riduidel) * [rogerdc](https://github.com/rogerdc) * [Roliga](https://github.com/Roliga) * [ronansalmon](https://github.com/ronansalmon) * [rremizov](https://github.com/rremizov) * [s0lesurviv0r](https://github.com/s0lesurviv0r) * [sal0max](https://github.com/sal0max) * [sebsauvage](https://github.com/sebsauvage) * [shutosg](https://github.com/shutosg) * [simon816](https://github.com/simon816) * [Simounet](https://github.com/Simounet) * [somini](https://github.com/somini) * [SpangleLabs](https://github.com/SpangleLabs) * [SqrtMinusOne](https://github.com/SqrtMinusOne) * [squeek502](https://github.com/squeek502) * [StelFux](https://github.com/StelFux) * [stjohnjohnson](https://github.com/stjohnjohnson) * [Stopka](https://github.com/Stopka) * [Strubbl](https://github.com/Strubbl) * [sublimz](https://github.com/sublimz) * [sunchaserinfo](https://github.com/sunchaserinfo) * [SuperSandro2000](https://github.com/SuperSandro2000) * [sysadminstory](https://github.com/sysadminstory) * [t0stiman](https://github.com/t0stiman) * [tameroski](https://github.com/tameroski) * [teromene](https://github.com/teromene) * [tgkenney](https://github.com/tgkenney) * [thefranke](https://github.com/thefranke) * [TheRadialActive](https://github.com/TheRadialActive) * [theScrabi](https://github.com/theScrabi) * [thezeroalpha](https://github.com/thezeroalpha) * [thibaultcouraud](https://github.com/thibaultcouraud) * [timendum](https://github.com/timendum) * [TitiTestScalingo](https://github.com/TitiTestScalingo) * [tomaszkane](https://github.com/tomaszkane) * [tomershvueli](https://github.com/tomershvueli) * [TotalCaesar659](https://github.com/TotalCaesar659) * [tpikonen](https://github.com/tpikonen) * [TReKiE](https://github.com/TReKiE) * [triatic](https://github.com/triatic) * [User123698745](https://github.com/User123698745) * [VerifiedJoseph](https://github.com/VerifiedJoseph) * [vitkabele](https://github.com/vitkabele) * [WalterBarrett](https://github.com/WalterBarrett) * [wtuuju](https://github.com/wtuuju) * [xurxof](https://github.com/xurxof) * [yamanq](https://github.com/yamanq) * [yardenac](https://github.com/yardenac) * [ymeister](https://github.com/ymeister) * [yue-dongchen](https://github.com/yue-dongchen) * [ZeNairolf](https://github.com/ZeNairolf) ================================================ FILE: Dockerfile ================================================ FROM debian:12-slim AS rssbridge LABEL description="RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites that don't have one." LABEL repository="https://github.com/RSS-Bridge/rss-bridge" LABEL website="https://github.com/RSS-Bridge/rss-bridge" ARG DEBIAN_FRONTEND=noninteractive RUN set -xe && \ apt-get update && \ apt-get install --yes --no-install-recommends \ ca-certificates \ nginx \ nss-plugin-pem \ php-curl \ php-fpm \ php-intl \ # php-json is enabled by default with PHP 8.2 in Debian 12 php-mbstring \ php-memcached \ # php-opcache is enabled by default with PHP 8.2 in Debian 12 # php-openssl is enabled by default with PHP 8.2 in Debian 12 php-sqlite3 \ php-xml \ php-zip \ # php-zlib is enabled by default with PHP 8.2 in Debian 12 # for downloading libcurl-impersonate curl \ # for patching libcurl-impersonate patchelf \ && \ # install curl-impersonate library curlimpersonate_version=1.2.5 && \ { \ { \ [ $(arch) = 'aarch64' ] && \ archive="libcurl-impersonate-v${curlimpersonate_version}.aarch64-linux-gnu.tar.gz" && \ sha512sum="cd340819d27c03e6833e746c1de181aa828f5986f4152fe0d55d5ea1b0a7c5328db129f9146d6369d2c2e20facd7c0a67e32cc916dddc74d1557106f89636dd0" \ ; } \ || { \ [ $(arch) = 'armv7l' ] && \ archive="libcurl-impersonate-v${curlimpersonate_version}.arm-linux-gnueabihf.tar.gz" && \ sha512sum="143e57779c4872557e8becfd91bf9c92d9085b1c964d103a39b6e85253e3f3257796d205de4b49f6bc25c8ad0a39e5d4ec4f51391037e27d32d6355e52c5d346" \ ; } \ || { \ [ $(arch) = 'x86_64' ] && \ archive="libcurl-impersonate-v${curlimpersonate_version}.x86_64-linux-gnu.tar.gz" && \ sha512sum="816e7d08110f2f5a6e7e2364e7c1d9ec0cc371e9b5024e0239db937379f057bb40ec80e56d0c49cdaf80b7f560888511c1bda5516b850a6d54c46a2eccc94dc6" \ ; } \ } && \ curl -LO "https://github.com/lexiforest/curl-impersonate/releases/download/v${curlimpersonate_version}/${archive}" && \ echo "$sha512sum $archive" | sha512sum -c - && \ mkdir -p /usr/local/lib/curl-impersonate && \ tar xaf "$archive" -C /usr/local/lib/curl-impersonate && \ patchelf --set-soname libcurl.so.4 /usr/local/lib/curl-impersonate/libcurl-impersonate.so && \ rm "$archive" && \ apt-get purge --assume-yes curl patchelf && \ rm -rf /var/lib/apt/lists/* ENV LD_PRELOAD=/usr/local/lib/curl-impersonate/libcurl-impersonate.so ENV CURL_IMPERSONATE=chrome142 # logs should go to stdout / stderr RUN ln -sfT /dev/stderr /var/log/nginx/error.log; \ ln -sfT /dev/stdout /var/log/nginx/access.log; \ chown -R --no-dereference www-data:adm /var/log/nginx/ COPY ./config/nginx.conf /etc/nginx/sites-available/default COPY ./config/php-fpm.conf /etc/php/8.2/fpm/pool.d/rss-bridge.conf COPY ./config/php.ini /etc/php/8.2/fpm/conf.d/90-rss-bridge.ini COPY --chown=www-data:www-data ./ /app/ EXPOSE 80 ENTRYPOINT ["/app/docker-entrypoint.sh"] ================================================ FILE: README.md ================================================ # RSS-Bridge ![RSS-Bridge](static/logo_600px.png) RSS-Bridge is a PHP web application. It generates web feeds for websites that don't have one. Officially hosted instance: https://rss-bridge.org/bridge01/ IRC channel #rssbridge at https://libera.chat/ [Full documentation](https://rss-bridge.github.io/rss-bridge/index.html) Alternatively find another [public instance](https://rss-bridge.github.io/rss-bridge/General/Public_Hosts.html). Requires minimum PHP 7.4. [![LICENSE](https://img.shields.io/badge/license-UNLICENSE-blue.svg)](UNLICENSE) [![GitHub release](https://img.shields.io/github/release/rss-bridge/rss-bridge.svg?logo=github)](https://github.com/rss-bridge/rss-bridge/releases/latest) [![irc.libera.chat](https://img.shields.io/badge/irc.libera.chat-%23rssbridge-blue.svg)](https://web.libera.chat/#rssbridge) [![Actions Status](https://img.shields.io/github/actions/workflow/status/RSS-Bridge/rss-bridge/tests.yml?branch=master&label=GitHub%20Actions&logo=github)](https://github.com/RSS-Bridge/rss-bridge/actions) ||| |:-:|:-:| |![Screenshot #1](/static/screenshot-1.png?raw=true)|![Screenshot #2](/static/screenshot-2.png?raw=true)| |![Screenshot #3](/static/screenshot-3.png?raw=true)|![Screenshot #4](/static/screenshot-4.png?raw=true)| |![Screenshot #5](/static/screenshot-5.png?raw=true)|![Screenshot #6](/static/screenshot-6.png?raw=true)| ## A subset of bridges (15/447) * `CssSelectorBridge`: [Scrape out a feed using CSS selectors](https://rss-bridge.org/bridge01/#bridge-CssSelectorBridge) * `FeedMergeBridge`: [Combine multiple feeds into one](https://rss-bridge.org/bridge01/#bridge-FeedMergeBridge) * `FeedReducerBridge`: [Reduce a noisy feed by some percentage](https://rss-bridge.org/bridge01/#bridge-FeedReducerBridge) * `FilterBridge`: [Filter a feed by excluding/including items by keyword](https://rss-bridge.org/bridge01/#bridge-FilterBridge) * `GettrBridge`: [Fetches the latest posts from a GETTR user](https://rss-bridge.org/bridge01/#bridge-GettrBridge) * `MastodonBridge`: [Fetches statuses from a Mastodon (ActivityPub) instance](https://rss-bridge.org/bridge01/#bridge-MastodonBridge) * `RedditBridge`: [Fetches posts from a user/subredit (with filtering options)](https://rss-bridge.org/bridge01/#bridge-RedditBridge) * `RumbleBridge`: [Fetches channel/user videos](https://rss-bridge.org/bridge01/#bridge-RumbleBridge) * `SoundcloudBridge`: [Fetches music by username](https://rss-bridge.org/bridge01/#bridge-SoundcloudBridge) * `TelegramBridge`: [Fetches posts from a public channel](https://rss-bridge.org/bridge01/#bridge-TelegramBridge) * `ThePirateBayBridge:` [Fetches torrents by search/user/category](https://rss-bridge.org/bridge01/#bridge-ThePirateBayBridge) * `TikTokBridge`: [Fetches posts by username](https://rss-bridge.org/bridge01/#bridge-TikTokBridge) * `TwitchBridge`: [Fetches videos from channel](https://rss-bridge.org/bridge01/#bridge-TwitchBridge) * `XPathBridge`: [Scrape out a feed using XPath expressions](https://rss-bridge.org/bridge01/#bridge-XPathBridge) * `YoutubeBridge`: [Fetches videos by username/channel/playlist/search](https://rss-bridge.org/bridge01/#bridge-YoutubeBridge) * `YouTubeCommunityTabBridge`: [Fetches posts from a channel's Posts tab](https://rss-bridge.org/bridge01/#bridge-YouTubeCommunityTabBridge) ## Tutorial ### How to install on traditional shared web hosting RSS-Bridge can basically be unzipped into a web folder. Should be working instantly. Latest zip: https://github.com/RSS-Bridge/rss-bridge/archive/refs/heads/master.zip (2MB) ### How to install on Debian 12 (nginx + php-fpm) These instructions have been tested on a fresh Debian 12 VM from Digital Ocean (1vcpu-512mb-10gb, 5 USD/month). ```shell timedatectl set-timezone Europe/Oslo apt install git nginx php8.2-fpm php-mbstring php-simplexml php-curl php-intl # Create a user account useradd --shell /bin/bash --create-home rss-bridge cd /var/www # Create folder and change its ownership to rss-bridge mkdir rss-bridge && chown rss-bridge:rss-bridge rss-bridge/ # Become rss-bridge su rss-bridge # Clone master branch into existing folder git clone https://github.com/RSS-Bridge/rss-bridge.git rss-bridge/ cd rss-bridge # Copy over the default config (OPTIONAL) cp -v config.default.ini.php config.ini.php # Recursively give full permissions to user/owner chmod 700 --recursive ./ # Give read and execute to others on folder ./static chmod o+rx ./ ./static # Recursively give give read to others on folder ./static chmod o+r --recursive ./static ``` Nginx config: ```nginx # /etc/nginx/sites-enabled/rss-bridge.conf server { listen 80; # TODO: change to your own server name server_name example.com; access_log /var/log/nginx/rss-bridge.access.log; error_log /var/log/nginx/rss-bridge.error.log; log_not_found off; # Intentionally not setting a root folder # Static content only served here location /static/ { alias /var/www/rss-bridge/static/; } # Pass off to php-fpm only when location is EXACTLY == / location = / { root /var/www/rss-bridge/; include snippets/fastcgi-php.conf; fastcgi_read_timeout 45s; fastcgi_pass unix:/run/php/rss-bridge.sock; } # Reduce log noise location = /favicon.ico { access_log off; } # Reduce log noise location = /robots.txt { access_log off; } } ``` PHP FPM pool config: ```ini ; /etc/php/8.2/fpm/pool.d/rss-bridge.conf [rss-bridge] user = rss-bridge group = rss-bridge listen = /run/php/rss-bridge.sock listen.owner = www-data listen.group = www-data ; Create 10 workers standing by to serve requests pm = static pm.max_children = 10 ; Respawn worker after 500 requests (workaround for memory leaks etc.) pm.max_requests = 500 ``` PHP ini config: ```ini ; /etc/php/8.2/fpm/conf.d/30-rss-bridge.ini max_execution_time = 15 memory_limit = 64M ``` Restart fpm and nginx: ```shell # Lint and restart php-fpm php-fpm8.2 -t && systemctl restart php8.2-fpm # Lint and restart nginx nginx -t && systemctl restart nginx ``` ### How to install from Composer Install the latest release. ```shell cd /var/www composer create-project -v --no-dev --no-scripts rss-bridge/rss-bridge ``` ### How to install with Caddy TODO. See https://github.com/RSS-Bridge/rss-bridge/issues/3785 ### Install from Docker Hub: Install by downloading the docker image from Docker Hub: ```bash # Create container docker create --name=rss-bridge --publish 3000:80 --volume $(pwd)/config:/config rssbridge/rss-bridge ``` You can put custom `config.ini.php` and bridges into `./config`. **You must restart container for custom changes to take effect.** See `docker-entrypoint.sh` for details. ```bash # Start container docker start rss-bridge ``` Browse http://localhost:3000/ ### Install by locally building from Dockerfile ```bash # Build image from Dockerfile docker build -t rss-bridge . # Create container docker create --name rss-bridge --publish 3000:80 --volume $(pwd)/config:/config rss-bridge ``` You can put custom `config.ini.php` and bridges into `./config`. **You must restart container for custom changes to take effect.** See `docker-entrypoint.sh` for details. ```bash # Start container docker start rss-bridge ``` Browse http://localhost:3000/ ### Install with docker-compose (using Docker Hub) You can put custom `config.ini.php` and bridges into `./config`. **You must restart container for custom changes to take effect.** See `docker-entrypoint.sh` for details. ```bash docker-compose up ``` Browse http://localhost:3000/ ### Other installation methods [![Deploy on Scalingo](https://cdn.scalingo.com/deploy/button.svg)](https://my.scalingo.com/deploy?source=https://github.com/sebsauvage/rss-bridge) [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) [![Deploy to Cloudron](https://cloudron.io/img/button.svg)](https://www.cloudron.io/store/com.rssbridgeapp.cloudronapp.html) [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=rssbridge) The Heroku quick deploy currently does not work. It might work if you fork this repo and modify the `repository` in `scalingo.json`. See https://github.com/RSS-Bridge/rss-bridge/issues/2688 Learn more in [Installation](https://rss-bridge.github.io/rss-bridge/For_Hosts/Installation.html). ## How-to ### How to fix "Access denied." Output is from php-fpm. It is unable to read index.php. chown rss-bridge:rss-bridge /var/www/rss-bridge/index.php ### How to password-protect the instance (token) Modify `config.ini.php`: [authentication] token = "hunter2" ### How to remove all cache items As current user: bin/cache-clear As user rss-bridge: sudo -u rss-bridge bin/cache-clear As root: sudo bin/cache-clear ### How to remove all expired cache items bin/cache-prune ### How to fix "PHP Fatal error: Uncaught Exception: The FileCache path is not writable" ```shell # Give rss-bridge ownership chown rss-bridge:rss-bridge -R /var/www/rss-bridge/cache # Or, give www-data ownership chown www-data:www-data -R /var/www/rss-bridge/cache # Or, give everyone write permission chmod 777 -R /var/www/rss-bridge/cache # Or last ditch effort (CAREFUL) rm -rf /var/www/rss-bridge/cache/ && mkdir /var/www/rss-bridge/cache/ ``` ### How to fix "attempt to write a readonly database" The sqlite files (db, wal and shm) are not writeable. chown -v rss-bridge:rss-bridge cache/* ### How to fix "Unable to prepare statement: 1, no such table: storage" rm cache/* ### How to create a completely new bridge New code files MUST have `declare(strict_types=1);` at the top of file: ```php find('.blog-posts li') as $li) { $a = $li->find('a', 0); $this->items[] = [ 'title' => $a->plaintext, 'uri' => 'https://herman.bearblog.dev' . $a->href, ]; } } } ``` Learn more in [bridge api](https://rss-bridge.github.io/rss-bridge/Bridge_API/index.html). ### How to enable all bridges enabled_bridges[] = * ### How to enable some bridges ``` enabled_bridges[] = TwitchBridge enabled_bridges[] = GettrBridge ``` ### How to switch to memcached as cache backend ``` [cache] ; Cache backend: file (default), sqlite, memcached, null type = "memcached" ``` ### How to switch to sqlite3 as cache backend type = "sqlite" ### How to disable bridge errors (as feed items) When a bridge fails, RSS-Bridge will produce a feed with a single item describing the error. This way, feed readers pick it up and you are notified. If you don't want this behaviour, switch the error output to `http`: [error] ; Defines how error messages are returned by RSS-Bridge ; ; "feed" = As part of the feed (default) ; "http" = As HTTP error message ; "none" = No errors are reported output = "http" ### How to accumulate errors before finally reporting it Modify `report_limit` so that an error must occur 3 times before it is reported. ; Defines how often an error must occur before it is reported to the user report_limit = 3 The report count is reset to 0 each day. ### How to password-protect the instance (HTTP Basic Auth) [authentication] enable = true username = "alice" password = "cat" Will typically require feed readers to be configured with the credentials. It may also be possible to manually include the credentials in the URL: https://alice:cat@rss-bridge.org/bridge01/?action=display&bridge=FabriceBellardBridge&format=Html ### How to create a new output format See `formats/PlaintextFormat.php` for an example. ### How to run unit tests and linter These commands require that you have installed the dev dependencies in `composer.json`. Run all tests: ./vendor/bin/phpunit Run a single test class: ./vendor/bin/phpunit --filter UrlTest Run linter: ./vendor/bin/phpcs --standard=phpcs.xml --warning-severity=0 --extensions=php -p ./ https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki ### How to spawn a minimal development environment php -S 127.0.0.1:9001 http://127.0.0.1:9001/ ## Explanation We are RSS-Bridge community, a group of developers continuing the project initiated by sebsauvage, webmaster of [sebsauvage.net](https://sebsauvage.net), author of [Shaarli](https://sebsauvage.net/wiki/doku.php?id=php:shaarli) and [ZeroBin](https://sebsauvage.net/wiki/doku.php?id=php:zerobin). See [CONTRIBUTORS.md](CONTRIBUTORS.md) RSS-Bridge uses caching to prevent services from banning your server for repeatedly updating feeds. The specific cache duration can be different between bridges. RSS-Bridge allows you to take full control over which bridges are displayed to the user. That way you can host your own RSS-Bridge service with your favorite collection of bridges! Current maintainers (as of 2024): @dvikan and @Mynacol #2519 ## Reference ### Feed item structure This is the feed item structure that bridges are expected to produce. ```php $item = [ 'uri' => 'https://example.com/blog/hello', 'title' => 'Hello world', // Publication date in unix timestamp 'timestamp' => 1668706254, 'author' => 'Alice', 'content' => 'Here be item content', 'enclosures' => [ 'https://example.com/foo.png', 'https://example.com/bar.png' ], 'categories' => [ 'news', 'tech', ], // Globally unique id 'uid' => 'e7147580c8747aad', ] ``` ### Output formats * `Atom`: Atom feed, for use in feed readers * `Html`: Simple HTML page * `Json`: JSON, for consumption by other applications * `Mrss`: MRSS feed, for use in feed readers * `Plaintext`: Raw text, for consumption by other applications * `Sfeed`: Text, TAB separated ### Cache backends * `File` * `SQLite` * `Memcached` * `Array` * `Null` ### Licenses The source code for RSS-Bridge is [Public Domain](UNLICENSE). RSS-Bridge uses third party libraries with their own license: * [`Parsedown`](https://github.com/erusev/parsedown) licensed under the [MIT License](https://opensource.org/licenses/MIT) * [`PHP Simple HTML DOM Parser`](https://simplehtmldom.sourceforge.io/docs/1.9/index.html) licensed under the [MIT License](https://opensource.org/licenses/MIT) * [`php-urljoin`](https://github.com/fluffy-critter/php-urljoin) licensed under the [MIT License](https://opensource.org/licenses/MIT) * [`Laravel framework`](https://github.com/laravel/framework/) licensed under the [MIT License](https://opensource.org/licenses/MIT) ## 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 willfully destroyed. Get your shit together: Put RSS/Atom back in. ================================================ FILE: UNLICENSE ================================================ 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 ================================================ FILE: actions/ConnectivityAction.php ================================================ bridgeFactory = $bridgeFactory; } public function __invoke(Request $request): Response { if (Configuration::getConfig('system', 'env') !== 'dev') { return new Response('This action is only available in dev environment!', 403); } $bridgeName = $request->get('bridge'); if (!$bridgeName) { return new Response(render_template('connectivity.html.php')); } $bridgeClassName = $this->bridgeFactory->createBridgeClassName($bridgeName); if (!$bridgeClassName) { return new Response('Bridge not found', 404); } return $this->reportBridgeConnectivity($bridgeClassName); } private function reportBridgeConnectivity($bridgeClassName) { if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { throw new \Exception('Bridge is not whitelisted!'); } $bridge = $this->bridgeFactory->create($bridgeClassName); $curl_opts = [ CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_FOLLOWLOCATION => true, ]; $result = [ 'bridge' => $bridgeClassName, 'successful' => false, 'http_code' => null, ]; try { $response = getContents($bridge::URI, [], $curl_opts, true); $result['http_code'] = $response->getCode(); if (in_array($result['http_code'], [200])) { $result['successful'] = true; } } catch (\Exception $e) { } return new Response(Json::encode($result), 200, ['content-type' => 'text/json']); } } ================================================ FILE: actions/DetectAction.php ================================================ bridgeFactory = $bridgeFactory; } public function __invoke(Request $request): Response { $url = $request->get('url'); $format = $request->get('format'); if (!$url) { return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a url'])); } if (!$format) { return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a format'])); } foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) { if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { continue; } $bridge = $this->bridgeFactory->create($bridgeClassName); $bridgeParams = $bridge->detectParameters($url); if (!$bridgeParams) { continue; } $query = [ 'action' => 'display', 'bridge' => $bridgeClassName, 'format' => $format, ]; $query = array_merge($query, $bridgeParams); return new Response('', 301, ['location' => '?' . http_build_query($query)]); } return new Response(render(__DIR__ . '/../templates/error.html.php', [ 'message' => 'No bridge found for given URL: ' . $url, ])); } } ================================================ FILE: actions/DisplayAction.php ================================================ cache = $cache; $this->logger = $logger; $this->bridgeFactory = $bridgeFactory; } public function __invoke(Request $request): Response { $bridgeName = $request->get('bridge'); $format = $request->get('format'); $noproxy = $request->get('_noproxy'); if (!$bridgeName) { return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'Missing bridge name parameter']), 400); } $bridgeClassName = $this->bridgeFactory->createBridgeClassName($bridgeName); if (!$bridgeClassName) { return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'Bridge not found']), 404); } if (!$format) { return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a format']), 400); } if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'This bridge is not whitelisted']), 400); } // Disable proxy (if enabled and per user's request) if ( Configuration::getConfig('proxy', 'url') && Configuration::getConfig('proxy', 'by_bridge') && $noproxy ) { // This const is only used once in getContents() define('NOPROXY', true); } $cacheKey = 'http_' . json_encode($request->toArray()); $bridge = $this->bridgeFactory->create($bridgeClassName); $response = $this->createResponse($request, $bridge, $format); if ($response->getCode() === 200) { $ttl = $request->get('_cache_timeout'); if (Configuration::getConfig('cache', 'custom_timeout') && isset($ttl)) { $ttl = (int) $ttl; } else { $ttl = $bridge->getCacheTimeout(); } $this->cache->set($cacheKey, $response, $ttl); } return $response; } private function createResponse(Request $request, BridgeAbstract $bridge, string $format) { $items = []; try { $bridge->loadConfiguration(); // Remove parameters that don't concern bridges $remove = [ 'token', 'action', 'bridge', 'format', '_noproxy', '_cache_timeout', '_error_time', '_', // Some RSS readers add a cache-busting parameter (_=) to feed URLs, detect and ignore them. ]; $requestArray = $request->toArray(); $input = array_diff_key($requestArray, array_fill_keys($remove, '')); $bridge->setInput($input); $bridge->collectData(); $items = $bridge->getItems(); } catch (\Throwable $e) { if ($e instanceof ClientException) { $this->logger->debug(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e))); } elseif ($e instanceof RateLimitException) { $this->logger->debug(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e))); return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 429); } elseif ($e instanceof HttpException) { if (in_array($e->getCode(), [429, 503])) { // Log with debug, immediately reproduce and return $this->logger->debug(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e))); return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), $e->getCode()); } // Some other status code which we let fail normally (but don't log it) } else { $this->logger->error(sprintf('Exception in DisplayAction(%s)', $bridge->getShortName()), ['e' => $e]); } $errorOutput = Configuration::getConfig('error', 'output'); $reportLimit = Configuration::getConfig('error', 'report_limit'); $errorCount = 1; if ($reportLimit > 1) { $errorCount = $this->logBridgeError($bridge->getName(), $e->getCode()); } // Let clients know about the error if we are passed the report limit if ($errorCount >= $reportLimit) { if ($errorOutput === 'feed') { // Render the exception as a feed item $items = [$this->createFeedItemFromException($e, $bridge)]; } elseif ($errorOutput === 'http') { return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 500); } elseif ($errorOutput === 'none') { // Do nothing (produces an empty feed) } } } $formatFactory = new FormatFactory(); $format = $formatFactory->create($format); $format->setItems($items); $format->setFeed($bridge->getFeed()); $now = time(); $format->setLastModified($now); $headers = [ 'last-modified' => gmdate('D, d M Y H:i:s ', $now) . 'GMT', 'content-type' => $format->getMimeType() . '; charset=UTF-8', ]; $body = $format->render(); // This is supposed to remove non-utf8 byte sequences, but I'm unsure if it works ini_set('mbstring.substitute_character', 'none'); $body = mb_convert_encoding($body, 'UTF-8', 'UTF-8'); return new Response($body, 200, $headers); } private function createFeedItemFromException($e, BridgeAbstract $bridge): array { $item = []; // Create a unique identifier every 24 hours $uniqueIdentifier = urlencode((int)(time() / 86400)); $title = sprintf('Bridge returned error %s! (%s)', $e->getCode(), $uniqueIdentifier); $item['title'] = $title; $item['uri'] = get_current_url(); $item['timestamp'] = time(); // Create an item identifier for feed readers e.g. "staysafetv twitch videos_19389" $item['uid'] = $bridge->getName() . '_' . $uniqueIdentifier; $content = render_template(__DIR__ . '/../templates/bridge-error.html.php', [ 'error' => render_template(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 'searchUrl' => self::createGithubSearchUrl($bridge), 'issueUrl' => self::createGithubIssueUrl($bridge, $e), 'maintainer' => $bridge->getMaintainer(), ]); $item['content'] = $content; return $item; } private function logBridgeError($bridgeName, $code) { // todo: it's not really necessary to json encode $report $cacheKey = 'error_reporting_' . $bridgeName . '_' . $code; $report = $this->cache->get($cacheKey); if ($report) { $report = Json::decode($report); $report['time'] = time(); $report['count']++; } else { $report = [ 'error' => $code, 'time' => time(), 'count' => 1, ]; } $ttl = 86400 * 5; $this->cache->set($cacheKey, Json::encode($report), $ttl); return $report['count']; } private static function createGithubIssueUrl(BridgeAbstract $bridge, \Throwable $e): string { $maintainer = $bridge->getMaintainer(); if (str_contains($maintainer, ',')) { $maintainers = explode(',', $maintainer); } else { $maintainers = [$maintainer]; } $maintainers = array_map('trim', $maintainers); $queryString = $_SERVER['QUERY_STRING'] ?? ''; $query = [ 'title' => $bridge->getName() . ' failed with: ' . $e->getMessage(), 'body' => sprintf( "```\n%s\n\n%s\n\nQuery string: %s\nVersion: %s\nOs: %s\nPHP version: %s\n```\nMaintainer: @%s", create_sane_exception_message($e), implode("\n", trace_to_call_points(trace_from_exception($e))), $queryString, Configuration::getVersion(), PHP_OS_FAMILY, phpversion() ?: 'Unknown', implode(', @', $maintainers), ), 'labels' => 'Bridge-Broken', 'assignee' => $maintainer[0], ]; return 'https://github.com/RSS-Bridge/rss-bridge/issues/new?' . http_build_query($query); } private static function createGithubSearchUrl($bridge): string { return sprintf( 'https://github.com/RSS-Bridge/rss-bridge/issues?q=%s', urlencode('is:issue is:open ' . $bridge->getName()) ); } } ================================================ FILE: actions/FindfeedAction.php ================================================ bridgeFactory = $bridgeFactory; } public function __invoke(Request $request): Response { $url = $request->get('url'); $format = $request->get('format'); if (!$url) { return new Response('You must specify a url', 400); } if (!$format) { return new Response('You must specify a format', 400); } $results = []; foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) { if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { continue; } $bridge = $this->bridgeFactory->create($bridgeClassName); $bridgeParams = $bridge->detectParameters($url); if ($bridgeParams === null) { continue; } // It's allowed to have no 'context' in a bridge (only a default context without any name) // In this case, the reference to the parameters are found in the first element of the PARAMETERS array $context = $bridgeParams['context'] ?? 0; $bridgeData = []; // Construct the array of parameters foreach ($bridgeParams as $key => $value) { // 'context' is a special case : it's a bridge parameters, there is no "name" for this parameter if ($key == 'context') { $bridgeData[$key]['name'] = 'Context'; $bridgeData[$key]['value'] = $value; } else { $bridgeData[$key]['name'] = $this->getParameterName($bridge, $context, $key); $bridgeData[$key]['value'] = $value; } } $bridgeParams['bridge'] = $bridgeClassName; $bridgeParams['format'] = $format; $content = [ 'url' => './?action=display&' . http_build_query($bridgeParams), 'bridgeParams' => $bridgeParams, 'bridgeData' => $bridgeData, 'bridgeMeta' => [ 'name' => $bridge::NAME, 'description' => $bridge::DESCRIPTION, 'parameters' => $bridge::PARAMETERS, 'icon' => $bridge->getIcon(), ], ]; $results[] = $content; } if ($results === []) { return new Response(Json::encode(['message' => 'No bridge found for given url']), 404, ['content-type' => 'application/json']); } return new Response(Json::encode($results), 200, ['content-type' => 'application/json']); } // Get parameter name in the actual context, or in the global parameter private function getParameterName($bridge, $context, $key) { if (isset($bridge::PARAMETERS[$context][$key]['name'])) { $name = $bridge::PARAMETERS[$context][$key]['name']; } else if (isset($bridge::PARAMETERS['global'][$key]['name'])) { $name = $bridge::PARAMETERS['global'][$key]['name']; } else { $name = 'Variable "' . $key . '" (No name provided)'; } return $name; } } ================================================ FILE: actions/FrontpageAction.php ================================================ bridgeFactory = $bridgeFactory; } public function __invoke(Request $request): Response { $token = $request->getAttribute('token'); $messages = []; $activeBridges = 0; $bridgeClassNames = $this->bridgeFactory->getBridgeClassNames(); foreach ($this->bridgeFactory->getMissingEnabledBridges() as $missingEnabledBridge) { $messages[] = [ 'body' => sprintf('Warning : Bridge "%s" not found', $missingEnabledBridge), 'level' => 'warning' ]; } $body = ''; foreach ($bridgeClassNames as $bridgeClassName) { if ($this->bridgeFactory->isEnabled($bridgeClassName)) { $bridge = $this->bridgeFactory->create($bridgeClassName); $body .= self::render($bridge, $bridgeClassName, $token); $activeBridges++; } } $response = new Response(render(__DIR__ . '/../templates/frontpage.html.php', [ 'messages' => $messages, 'admin_email' => Configuration::getConfig('admin', 'email'), 'admin_telegram' => Configuration::getConfig('admin', 'telegram'), 'bridges' => $body, 'active_bridges' => $activeBridges, 'total_bridges' => count($bridgeClassNames), ])); // TODO: The rendered template could be cached, but beware config changes that changes the html return $response; } public static function render( BridgeAbstract $bridge, string $bridgeClassName, ?string $token ): string { $uri = $bridge->getURI(); $name = $bridge->getName(); $icon = $bridge->getIcon(); $description = $bridge->getDescription(); $parameters = $bridge->getParameters(); // Checkbox for disabling of proxy (if enabled) if ( Configuration::getConfig('proxy', 'url') && Configuration::getConfig('proxy', 'by_bridge') ) { $proxyName = Configuration::getConfig('proxy', 'name') ?: Configuration::getConfig('proxy', 'url'); $parameters['global']['_noproxy'] = [ 'name' => sprintf('Disable proxy (%s)', $proxyName), 'type' => 'checkbox', ]; } if (Configuration::getConfig('cache', 'custom_timeout')) { $parameters['global']['_cache_timeout'] = [ 'name' => 'Cache timeout in seconds', 'type' => 'number', 'defaultValue' => $bridge->getCacheTimeout() ]; } $shortName = $bridge->getShortName(); $card = <<

#

{$name}

{$description}

CARD; if (count($parameters) === 0) { // The bridge has zero parameters $card .= self::renderForm($bridgeClassName, '', [], $token); } elseif (count($parameters) === 1 && array_key_exists('global', $parameters)) { // The bridge has a single context with key 'global' $card .= self::renderForm($bridgeClassName, '', $parameters['global'], $token); } else { // The bridge has one or more contexts (named or unnamed) foreach ($parameters as $contextName => $contextParameters) { if ($contextName === 'global') { continue; } if (array_key_exists('global', $parameters)) { // Merge the global parameters into current context $contextParameters = array_merge($contextParameters, $parameters['global']); } if (!is_numeric($contextName)) { // This is a named context $card .= '
' . $contextName . "
\n"; } $card .= self::renderForm($bridgeClassName, $contextName, $contextParameters, $token); } } $card .= html_tag('label', 'Show less', [ 'class' => 'showless', 'for' => "showmore-$bridgeClassName", ]) . "\n"; if (Configuration::getConfig('admin', 'donations') && $bridge->getDonationURI()) { $card .= sprintf( '

%s ~ Donate

', $bridge->getMaintainer(), $bridge->getDonationURI() ); } else { $card .= html_tag('p', $bridge->getMaintainer(), ['class' => 'maintainer']) . "\n"; } $card .= "\n\n"; return $card; } private static function renderForm( string $bridgeClassName, string $contextName, array $parameters, ?string $token ): string { $form = << EOD; if (Configuration::getConfig('authentication', 'token') && $token) { $form .= html_input([ 'type' => 'hidden', 'name' => 'token', 'value' => $token, ]) . "\n"; } if (!empty($contextName)) { $form .= html_input([ 'type' => 'hidden', 'name' => 'context', 'value' => $contextName, ]) . "\n"; } $form .= '
' . "\n"; foreach ($parameters as $id => $parameter) { if (!isset($parameter['exampleValue'])) { $parameter['exampleValue'] = ''; } if (!isset($parameter['defaultValue'])) { $parameter['defaultValue'] = ''; } $idArg = 'arg-' . urlencode($bridgeClassName) . '-' . urlencode($contextName) . '-' . urlencode($id); $form .= html_tag('label', $parameter['name'], ['for' => $idArg]) . "\n"; if ( !isset($parameter['type']) || $parameter['type'] === 'text' ) { $form .= self::getTextInput($parameter, $idArg, $id) . "\n"; } elseif ($parameter['type'] === 'number') { $form .= self::getNumberInput($parameter, $idArg, $id) . "\n"; } elseif ($parameter['type'] === 'list') { $form .= self::getListInput($parameter, $idArg, $id) . "\n"; } elseif ($parameter['type'] === 'checkbox') { $form .= self::getCheckboxInput($parameter, $idArg, $id) . "\n"; } else { $foo = 2; // oops? } $params = []; if (isset($parameter['title'])) { $params = [ 'title' => $parameter['title'], 'class' => 'info', ]; } if ($parameter['exampleValue'] !== '') { $params = [ 'title' => sprintf("Example (right click to use):\n%s", $parameter['exampleValue']), 'class' => 'info', 'oncontextmenu' => 'rssbridge_use_placeholder_value(this);return false', 'data-for' => $idArg, ]; } if ($params) { $form .= html_tag('i', 'i', $params) . "\n"; } else { $form .= html_tag('i', ' ', ['class' => 'no-info']) . "\n"; } } $form .= "
\n\n"; $form .= html_tag('button', 'Generate feed', [ 'type' => 'submit', 'name' => 'format', 'value' => 'Html', 'formtarget' => '_blank', ]) . "\n"; return $form . "\n\n"; } public static function getTextInput(array $parameter, string $id, string $name): string { $pattern = $parameter['pattern'] ?? null; $checked = $parameter['defaultValue'] === 'checked'; $required = $parameter['required'] ?? false; return html_input([ 'id' => $id, 'type' => 'text', 'value' => $parameter['defaultValue'], 'placeholder' => $parameter['exampleValue'], 'name' => $name, 'pattern' => $pattern, 'checked' => $checked, 'required' => $required, ]); } public static function getNumberInput(array $parameter, string $id, string $name): string { $pattern = $parameter['pattern'] ?? null; $checked = $parameter['defaultValue'] === 'checked'; $required = $parameter['required'] ?? false; return html_input([ 'id' => $id, 'type' => 'number', 'value' => $parameter['defaultValue'], 'placeholder' => $parameter['exampleValue'], 'name' => $name, 'pattern' => $pattern, 'checked' => $checked, 'required' => $required, ]); } public static function getCheckboxInput(array $parameter, string $id, string $name): string { return html_input([ 'id' => $id, 'type' => 'checkbox', 'name' => $name, 'checked' => $parameter['defaultValue'] === 'checked', ]); } public static function getListInput(array $parameter, string $id, string $name): string { $list = sprintf('\n"; return $list; } } ================================================ FILE: actions/HealthAction.php ================================================ 200, 'message' => 'all is good', ]; return new Response(Json::encode($response), 200, ['content-type' => 'application/json']); } } ================================================ FILE: actions/ListAction.php ================================================ bridgeFactory = $bridgeFactory; } public function __invoke(Request $request): Response { $list = new \stdClass(); $list->bridges = []; $list->total = 0; foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) { $bridge = $this->bridgeFactory->create($bridgeClassName); $list->bridges[$bridgeClassName] = [ 'status' => $this->bridgeFactory->isEnabled($bridgeClassName) ? 'active' : 'inactive', 'uri' => $bridge->getURI(), 'donationUri' => $bridge->getDonationURI(), 'name' => $bridge->getName(), 'icon' => $bridge->getIcon(), 'parameters' => $bridge->getParameters(), 'maintainer' => $bridge->getMaintainer(), 'description' => $bridge->getDescription() ]; } $list->total = count($list->bridges); return new Response(Json::encode($list), 200, ['content-type' => 'application/json']); } } ================================================ FILE: app.json ================================================ { "service": "Heroku", "name": "rss-bridge-heroku", "description": "RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites which don't have one.", "repository": "https://github.com/RSS-Bridge/rss-bridge?1651005770", "keywords": ["php", "rss-bridge", "rss"] } ================================================ FILE: bridges/ABCNewsBridge.php ================================================ [ 'type' => 'list', 'name' => 'Region', 'title' => 'Choose state', 'values' => [ 'ACT' => 'act', 'NSW' => 'nsw', 'NT' => 'nt', 'QLD' => 'qld', 'SA' => 'sa', 'TAS' => 'tas', 'VIC' => 'vic', 'WA' => 'wa' ], ] ] ]; public function collectData() { $url = sprintf('https://www.abc.net.au/news/%s', $this->getInput('topic')); $dom = getSimpleHTMLDOM($url); $dom = $dom->find('div[data-component="PaginationList"]', 0); if (!$dom) { throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); } $dom = defaultLinkTo($dom, $this->getURI()); foreach ($dom->find('article[data-component="DetailCard"]') as $article) { $a = $article->find('a', 0); $this->items[] = [ 'title' => $a->plaintext, 'uri' => $a->href, 'content' => $article->find('p', 0)->plaintext, 'timestamp' => strtotime($article->find('time', 0)->datetime), ]; } } } ================================================ FILE: bridges/ABolaBridge.php ================================================ [ 'name' => 'News Feed', 'type' => 'list', 'title' => 'Feeds from the Portuguese sports newspaper A BOLA.PT', 'values' => [ 'Últimas' => 'Nnh/Noticias', 'Seleção Nacional' => 'Selecao/Noticias', 'Futebol Nacional' => [ 'Notícias' => 'Nacional/Noticias', 'Primeira Liga' => 'Nacional/Liga/Noticias', 'Liga 2' => 'Nacional/Liga2/Noticias', 'Liga 3' => 'Nacional/Liga3/Noticias', 'Liga Revelação' => 'Nacional/Liga-Revelacao/Noticias', 'Campeonato de Portugal' => 'Nacional/Campeonato-Portugal/Noticias', 'Distritais' => 'Nacional/Distritais/Noticias', 'Taça de Portugal' => 'Nacional/TPortugal/Noticias', 'Futebol Feminino' => 'Nacional/FFeminino/Noticias', 'Futsal' => 'Nacional/Futsal/Noticias', ], 'Futebol Internacional' => [ 'Notícias' => 'Internacional/Noticias/Noticias', 'Liga dos Campeões' => 'Internacional/Liga-dos-campeoes/Noticias', 'Liga Europa' => 'Internacional/Liga-europa/Noticias', 'Liga Conferência' => 'Internacional/Liga-conferencia/Noticias', 'Liga das Nações' => 'Internacional/Liga-das-nacoes/Noticias', 'UEFA Youth League' => 'Internacional/Uefa-Youth-League/Noticias', ], 'Mercado' => 'Mercado', 'Modalidades' => 'Modalidades/Noticias', 'Motores' => 'Motores/Noticias', ] ] ] ]; public function getIcon() { return 'https://abola.pt/img/icons/favicon-96x96.png'; } public function getName() { return !is_null($this->getKey('feed')) ? self::NAME . ' | ' . $this->getKey('feed') : self::NAME; } public function getURI() { return self::URI . $this->getInput('feed'); } public function collectData() { $url = sprintf('https://abola.pt/%s', $this->getInput('feed')); $dom = getSimpleHTMLDOM($url); if ($this->getInput('feed') !== 'Mercado') { $dom = $dom->find('div#body_Todas1_upNoticiasTodas', 0); } else { $dom = $dom->find('div#body_NoticiasMercado_upNoticiasTodas', 0); } if (!$dom) { throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); } $dom = defaultLinkTo($dom, $this->getURI()); foreach ($dom->find('div.media') as $key => $article) { //Get thumbnail $image = $article->find('.media-img', 0)->style; $image = preg_replace('/background-image: url\(/i', '', $image); $image = substr_replace($image, '', -4); $image = preg_replace('/https:\/\//i', '', $image); $image = preg_replace('/www\./i', '', $image); $image = preg_replace('/\/\//', '/', $image); $image = preg_replace('/\/\/\//', '//', $image); $image = substr($image, 7); $image = 'https://' . $image; $image = preg_replace('/ptimg/', 'pt/img', $image); $image = preg_replace('/\/\/bola/', 'www.abola', $image); //Timestamp $date = date('Y/m/d'); if (!is_null($article->find("span#body_Todas1_rptNoticiasTodas_lblData_$key", 0))) { $date = $article->find("span#body_Todas1_rptNoticiasTodas_lblData_$key", 0)->plaintext; $date = preg_replace('/\./', '/', $date); } $time = $article->find("span#body_Todas1_rptNoticiasTodas_lblHora_$key", 0)->plaintext; $date = explode('/', $date); $time = explode(':', $time); $year = $date[0]; $month = $date[1]; $day = $date[2]; $hour = $time[0]; $minute = $time[1]; $timestamp = mktime($hour, $minute, 0, $month, $day, $year); //Content $image = '' . $article->find('h4 span', 0)->plaintext . ''; $description = '

' . $article->find('.media-texto > span', 0)->plaintext . '

'; $content = $image . '
' . $description; $a = $article->find('.media-body > a', 0); $this->items[] = [ 'title' => $a->find('h4 span', 0)->plaintext, 'uri' => $a->href, 'content' => $content, 'timestamp' => $timestamp, ]; } } } ================================================ FILE: bridges/AO3Bridge.php ================================================ [ 'url' => [ 'name' => 'url', 'required' => true, // Example: F/F tag 'exampleValue' => 'https://archiveofourown.org/tags/F*s*F/works', ], 'range' => [ 'name' => 'Chapter Content', 'title' => 'Chapter(s) to include in each work\'s feed entry', 'defaultValue' => null, 'type' => 'list', 'values' => [ 'None' => null, 'First' => 'first', 'Latest' => 'last', 'Entire work' => 'all', ], ], 'unique' => [ 'name' => 'Make separate entries for new fic chapters', 'type' => 'checkbox', 'required' => false, 'title' => 'Make separate entries for new fic chapters', 'defaultValue' => 'checked', ], 'limit' => self::LIMIT, ], 'Bookmarks' => [ 'user' => [ 'name' => 'user', 'required' => true, // Example: Nyaaru's bookmarks 'exampleValue' => 'Nyaaru', ], ], 'Work' => [ 'id' => [ 'name' => 'id', 'required' => true, // Example: latest chapters from A Better Past by LysSerris 'exampleValue' => '18181853', ], ] ]; private $title; public function collectData() { switch ($this->queriedContext) { case 'Bookmarks': $this->collectList($this->getURI()); break; case 'List': $this->collectList($this->getURI()); break; case 'Work': $this->collectWork($this->getURI()); break; } } /** * Feed for lists of works (e.g. recent works, search results, filtered tags, * bookmarks, series, collections). */ private function collectList($url) { $version = 'v0.0.1'; $headers = [ "useragent: rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)" ]; $response = getContents($url, $headers); $html = \str_get_html($response); $html = defaultLinkTo($html, self::URI); // Get list title. Will include page range + count in some cases $heading = ($html->find('#main h2', 0)); if ($heading->find('a.tag')) { $heading = $heading->find('a.tag', 0); } $this->title = $heading->plaintext; $limit = $this->getInput('limit') ?? 3; $count = 0; foreach ($html->find('.index.group > li') as $element) { $item = []; $title = $element->find('div h4 a', 0); if (!isset($title)) { continue; // discard deleted works } $item['title'] = $title->plaintext; $item['uri'] = $title->href; $strdate = $element->find('div p.datetime', 0)->plaintext; $item['timestamp'] = strtotime($strdate); // detach from rest of page because remove() is buggy $element = str_get_html($element->outertext()); $tags = $element->find('ul.required-tags', 0); foreach ($tags->childNodes() as $tag) { $item['categories'][] = html_entity_decode($tag->plaintext); } $tags->remove(); $tags = $element->find('ul.tags', 0); foreach ($tags->childNodes() as $tag) { $item['categories'][] = html_entity_decode($tag->plaintext); } $tags->remove(); $item['content'] = implode('', $element->childNodes()); $chapters = $element->find('dl dd.chapters', 0); // bookmarked series and external works do not have a chapters count $chapters = (isset($chapters) ? $chapters->plaintext : 0); if ($this->getInput('unique')) { $item['uid'] = $item['uri'] . "/$strdate/$chapters"; } else { $item['uid'] = $item['uri']; } // Fetch workskin of desired chapter(s) in list if ($this->getInput('range') && ($limit == 0 || $count++ < $limit)) { $url = $item['uri']; switch ($this->getInput('range')) { case ('all'): $url .= '?view_full_work=true'; break; case ('first'): break; case ('last'): // only way to get this is using the navigate page unfortunately $url .= '/navigate'; $response = getContents($url, $headers); $html = \str_get_html($response); $html = defaultLinkTo($html, self::URI); $url = $html->find('ol.index.group > li > a', -1)->href; break; } $response = getContents($url, $headers); $html = \str_get_html($response); $html = defaultLinkTo($html, self::URI); // remove duplicate fic summary if ($ficsum = $html->find('#workskin > .preface > .summary', 0)) { $ficsum->remove(); } $item['content'] .= $html->find('#workskin', 0); } // Use predictability of download links to generate enclosures $wid = explode('/', $item['uri'])[4]; foreach (['azw3', 'epub', 'mobi', 'pdf', 'html'] as $ext) { $item['enclosures'][] = 'https://archiveofourown.org/downloads/' . $wid . '/work.' . $ext; } $this->items[] = $item; } } /** * Feed for recent chapters of a specific work. */ private function collectWork($url) { $version = 'v0.0.1'; $headers = [ "useragent: rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)" ]; $response = getContents($url . '/navigate', $headers); $html = \str_get_html($response); $html = defaultLinkTo($html, self::URI); $response = getContents($url . '?view_full_work=true', $headers); $workhtml = \str_get_html($response); $workhtml = defaultLinkTo($workhtml, self::URI); $this->title = $html->find('h2 a', 0)->plaintext; $nav = $html->find('ol.index.group > li'); for ($i = 0; $i < count($nav); $i++) { $item = []; $element = $nav[$i]; $item['title'] = $element->find('a', 0)->plaintext; $item['content'] = $workhtml->find('#chapter-' . ($i + 1), 0); $item['uri'] = $element->find('a', 0)->href; $strdate = $element->find('span.datetime', 0)->plaintext; $strdate = str_replace('(', '', $strdate); $strdate = str_replace(')', '', $strdate); $item['timestamp'] = strtotime($strdate); $item['uid'] = $item['uri'] . "/$strdate"; $this->items[] = $item; } $this->items = array_reverse($this->items); } public function getName() { $name = parent::getName() . " $this->queriedContext"; if (isset($this->title)) { $name .= " - $this->title"; } return $name; } public function getIcon() { return self::URI . '/favicon.ico'; } public function getURI() { $url = parent::getURI(); switch ($this->queriedContext) { case 'Bookmarks': $user = $this->getInput('user'); $url = self::URI . '/users/' . $user . '/bookmarks?bookmark_search[sort_column]=bookmarkable_date'; break; case 'List': $url = $this->getInput('url'); break; case 'Work': $url = self::URI . '/works/' . $this->getInput('id'); break; } return $url; } } ================================================ FILE: bridges/ARDAudiothekBridge.php ================================================ icon * @const IMAGEEXTENSION */ const IMAGEEXTENSION = '.jpg'; const PARAMETERS = [ [ 'path' => [ 'name' => 'Show Link or ID', 'required' => true, 'title' => 'Link to the show page or just its numeric suffix', 'defaultValue' => 'https://www.ardaudiothek.de/sendung/kalk-welk/10777871/' ], 'limit' => self::LIMIT, ] ]; /** * Holds the title of the current show * * @var string */ private $title; /** * Holds the URI of the show * * @var string */ private $uri; /** * Holds the icon of the feed * */ private $icon; public function collectData() { $path = $this->getInput('path'); $limit = $this->getInput('limit'); $oldTz = date_default_timezone_get(); date_default_timezone_set('Europe/Berlin'); $pathComponents = explode('/', $path); if (empty($pathComponents)) { throwClientException('Path may not be empty'); } if (count($pathComponents) < 2) { $showID = $pathComponents[0]; } else { $lastKey = count($pathComponents) - 1; $showID = $pathComponents[$lastKey]; if (strlen($showID) === 0) { $showID = $pathComponents[$lastKey - 1]; } } $url = self::APIENDPOINT . 'programsets/' . $showID . '/'; $json1 = getContents($url); $data1 = Json::decode($json1, false); $processedJSON = $data1->data->programSet; if (!$processedJSON) { throw new \Exception('Unable to find show id: ' . $showID); } $answerLength = 1; $offset = 0; $numberOfElements = 1; while ($answerLength != 0 && $offset < $numberOfElements && (is_null($limit) || $offset < $limit)) { $json2 = getContents($url . '?offset=' . $offset); $data2 = Json::decode($json2, false); $processedJSON = $data2->data->programSet; $answerLength = count($processedJSON->items->nodes); $offset = $offset + $answerLength; $numberOfElements = $processedJSON->numberOfElements; foreach ($processedJSON->items->nodes as $audio) { $item = []; $item['uri'] = $audio->sharingUrl; $item['title'] = $audio->title; $imageSquare = str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $audio->image->url1X1); $image = str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $audio->image->url); $item['enclosures'] = [ $audio->audios[0]->url, $imageSquare ]; // synopsis in list is shortened, full synopsis is available using one request per item $item['content'] = '

' . $audio->synopsis . '

'; $item['timestamp'] = $audio->publicationStartDateAndTime; $item['uid'] = $audio->id; $item['author'] = $audio->programSet->publicationService->title; $category = $audio->programSet->editorialCategories->title ?? null; if ($category) { $item['categories'] = [$category]; } $item['itunes'] = [ 'duration' => $audio->duration, ]; $this->items[] = $item; } } $this->title = $processedJSON->title; $this->uri = $processedJSON->sharingUrl; $this->icon = str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $processedJSON->image->url1X1); // add image file extension to URL so icon is shown in generated RSS feeds, see // https://github.com/RSS-Bridge/rss-bridge/blob/4aed05c7b678b5673386d61374bba13637d15487/formats/MrssFormat.php#L76 $this->icon = $this->icon . self::IMAGEEXTENSION; $this->items = array_slice($this->items, 0, $limit); date_default_timezone_set($oldTz); } /** {@inheritdoc} */ public function getURI() { if (!empty($this->uri)) { return $this->uri; } return parent::getURI(); } /** {@inheritdoc} */ public function getName() { if (!empty($this->title)) { return $this->title; } return parent::getName(); } /** {@inheritdoc} */ public function getIcon() { if (!empty($this->icon)) { return $this->icon; } return parent::getIcon(); } } ================================================ FILE: bridges/ARDMediathekBridge.php ================================================ [ 'name' => 'Show Link or ID', 'required' => true, 'title' => 'Link to the show page or just its alphanumeric suffix', 'defaultValue' => 'https://www.ardmediathek.de/sendung/45-min/Y3JpZDovL25kci5kZS8xMzkx/' ] ] ]; public function collectData() { $oldTz = date_default_timezone_get(); date_default_timezone_set('Europe/Berlin'); $pathComponents = explode('/', $this->getInput('path')); if (empty($pathComponents)) { throwClientException('Path may not be empty'); } if (count($pathComponents) < 2) { $showID = $pathComponents[0]; } else { $lastKey = count($pathComponents) - 1; $showID = $pathComponents[$lastKey]; if (strlen($showID) === 0) { $showID = $pathComponents[$lastKey - 1]; } } $url = self::APIENDPOINT . $showID . '?pageSize=' . self::PAGESIZE; $rawJSON = getContents($url); $processedJSON = json_decode($rawJSON); foreach ($processedJSON->teasers as $video) { $item = []; // there is also ->links->self->id, ->links->self->urlId, ->links->target->id, ->links->target->urlId $item['uri'] = self::VIDEOLINKPREFIX . $video->id . '/'; // there is also ->mediumTitle and ->shortTitle $item['title'] = $video->longTitle; // in the test, aspect16x9 was the only child of images, not sure whether that is always true $item['enclosures'] = [ str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $video->images->aspect16x9->src) ]; $item['content'] = '

'; $item['timestamp'] = $video->broadcastedOn; $item['uid'] = $video->id; $item['author'] = $video->publicationService->name; $this->items[] = $item; } $this->title = $processedJSON->title; date_default_timezone_set($oldTz); } /** {@inheritdoc} */ public function getName() { if (!empty($this->title)) { return $this->title; } return parent::getName(); } } ================================================ FILE: bridges/ARMCommunityBridge.php ================================================ [ 'community' => [ 'name' => 'Community', 'type' => 'list', 'values' => [ 'AI' => 'ai-blog', 'Announcements' => 'announcements', 'Architectures and Processors' => 'architectures-and-processors-blog', 'Automotive' => 'automotive-blog', 'Embedded and Microcontrollers' => 'embedded-and-microcontrollers-blog', 'Internet of Things (IoT)' => 'internet-of-things-blog', 'Laptops and Desktops' => 'laptops-and-desktops-blog', 'Mobile, Graphics, and Gaming' => 'mobile-graphics-and-gaming-blog', 'Operating Systems' => 'operating-systems-blog', 'Server and Cloud Computing' => 'servers-and-cloud-computing-blog', 'SoC Design and Simulation' => 'soc-design-and-simulation-blog', 'Tools, Software and IDEs' => 'tools-software-ides-blog', ], ] ] ]; public function collectData() { $category = '/community/arm-community-blogs/b/' . $this->getInput('community'); $header = [ 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0', ]; $html = getSimpleHTMLDOM(static::URI . $category, $header); $html = defaultLinkTo($html, static::URI); foreach ($html->find('ads-card') as $c) { $articleurl = static::URI . $c->link; $articlehtml = getSimpleHTMLDOMCached($articleurl, static::CACHE_TIMEOUT, $header); $date = strtotime($articlehtml->find('#blog-date', 0)->innertext); $title = $articlehtml->find('#blog-title', 0)->innertext; $author = $articlehtml->find('#blog-title', 0)->parent->find('p', 1)->find('a', 0)->innertext; $content = $articlehtml->find('#blog-body', 0)->innertext; $this->items[] = [ 'title' => $title, 'timestamp' => $date, 'author' => $author, 'uri' => $articleurl, 'content' => $content, ]; } } public function getName() { $categoryname = $this->getKey('community'); if (empty($categoryname)) { return static::NAME; } return static::NAME . ' - ' . $categoryname; } } ================================================ FILE: bridges/ASRockNewsBridge.php ================================================ find('div.inner > a') as $index => $a) { $item = []; $articlePath = $a->href; $articlePageHtml = getSimpleHTMLDOMCached($articlePath, self::CACHE_TIMEOUT); $articlePageHtml = defaultLinkTo($articlePageHtml, self::URI); $contents = $articlePageHtml->find('div.Contents', 0); $item['uri'] = $articlePath; $item['title'] = $contents->find('h3', 0)->innertext; $contents->find('h3', 0)->outertext = ''; $item['content'] = $contents->innertext; $item['timestamp'] = $this->extractDate($a->plaintext); $img = $a->find('img', 0); if ($img) { $item['enclosures'][] = $img->src; } $this->items[] = $item; if (count($this->items) >= 10) { break; } } } private function extractDate($text) { $dateRegex = '/^([0-9]{4}\/[0-9]{1,2}\/[0-9]{1,2})/'; $text = trim($text); if (preg_match($dateRegex, $text, $matches)) { return $matches[1]; } return ''; } } ================================================ FILE: bridges/AcademiaBridge.php ================================================ [ 'name' => 'Topic name', 'required' => true, 'exampleValue' => 'Deadlock_Avoidance', ], 'sort' => [ 'name' => 'Sort by', 'type' => 'list', 'values' => [ 'Newest' => 'Newest', 'Top papers' => 'TopPapers', 'Most cited' => 'MostCited', 'Most downloaded' => 'MostDownloaded', ], ], ], ]; public function getName() { $topic = $this->getInput('topic'); if ($topic) { return self::NAME . ' - ' . str_replace('_', ' ', $topic); } return self::NAME; } public function collectData() { $topic = $this->getInput('topic'); $sort = $this->getInput('sort') ?? 'Newest'; $url = self::URI . '/Documents/in/' . $topic; if (!filter_var($url, FILTER_VALIDATE_URL)) { throwServerException('Invalid topic name: ' . $topic); } if ($sort !== 'Newest') { $url .= '/' . $sort; } $dom = getSimpleHTMLDOM($url); $json = $dom->find('script[type="application/ld+json"]', 0); if (!$json) { throwServerException('Unable to parse content'); } $data = Json::decode($json->innertext); $articles = $data['subjectOf'] ?? null; if (!is_array($articles) || empty($articles)) { throwServerException('Invalid or empty content'); } $summaryByUrl = $this->extractSummaries($dom); foreach ($articles as $article) { if (($article['@type'] ?? '') !== 'ScholarlyArticle') { continue; } $articleUrl = $article['url'] ?? ''; if (!filter_var($articleUrl, FILTER_VALIDATE_URL)) { continue; } $this->items[] = [ 'uri' => $articleUrl, 'uid' => $articleUrl, 'title' => $article['name'] ?? '', 'author' => $article['author']['name'] ?? '', 'timestamp' => $article['datePublished'] ?? '', 'content' => $summaryByUrl[$articleUrl] ?? '', ]; } } private function extractSummaries($dom): array { $summaryByUrl = []; foreach ($dom->find('.work-card-container') as $card) { $a = $card->find('.title a', 0); if (!$a) { continue; } $url = $a->href; $complete = $card->find('.complete.hidden', 0); $summary = $complete ? trim($complete->plaintext) : ''; $summaryByUrl[$url] = $summary; } return $summaryByUrl; } } ================================================ FILE: bridges/AcrimedBridge.php ================================================ [ 'name' => 'limit', 'type' => 'number', 'defaultValue' => -1, ] ] ]; public function collectData() { $url = 'https://www.acrimed.org/spip.php?page=backend'; $limit = $this->getInput('limit'); $this->collectExpandableDatas($url, $limit); } protected function parseItem(array $item) { $articlePage = getSimpleHTMLDOM($item['uri']); $article = sanitize($articlePage->find('article.article1', 0)->innertext); $article = defaultLinkTo($article, static::URI); $item['content'] = $article; return $item; } } ================================================ FILE: bridges/ActivisionResearchBridge.php ================================================ find('div[id="home-blog-feed"]', 0); if (!$dom) { throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); } $dom = defaultLinkTo($dom, $this->getURI()); foreach ($dom->find('div[class="blog-entry"]') as $article) { $a = $article->find('a', 0); $blogimg = extractFromDelimiters($article->find('div[class="blog-img"]', 0)->style, 'url(', ')'); $title = htmlspecialchars_decode($article->find('div[class="title"]', 0)->plaintext); $author = htmlspecialchars_decode($article->find('div[class="author]', 0)->plaintext); $date = $article->find('div[class="pubdate"]', 0)->plaintext; $entry = getSimpleHTMLDOMCached($a->href, static::CACHE_TIMEOUT * 7 * 4); $entry = defaultLinkTo($entry, $this->getURI()); $content = $entry->find('div[class="blog-body"]', 0); $tagsremove = ['script', 'iframe', 'input', 'form']; $content = sanitize($content, $tagsremove); $content = '' . $content; $this->items[] = [ 'title' => $title, 'author' => $author, 'uri' => $a->href, 'content' => $content, 'timestamp' => strtotime($date), ]; } } } ================================================ FILE: bridges/AirBreizhBridge.php ================================================ [ 'theme' => [ 'name' => 'Thematique', 'type' => 'list', 'values' => [ 'Tout' => '', 'Rapport d\'activite' => 'rapport-dactivite', 'Etude' => 'etudes', 'Information' => 'information', 'Autres documents' => 'autres-documents', 'Plan Régional de Surveillance de la qualité de l’air' => 'prsqa', 'Transport' => 'transport' ] ] ] ]; public function getIcon() { return 'https://www.airbreizh.asso.fr/voy_content/uploads/2017/11/favicon.png'; } public function collectData() { $html = ''; $html = getSimpleHTMLDOM(static::URI . 'publications/?fwp_publications_thematiques=' . $this->getInput('theme')); foreach ($html->find('article') as $article) { $item = []; // Title $item['title'] = $article->find('h2', 0)->plaintext; // Author $item['author'] = 'Air Breizh'; // Image $imagelink = $article->find('.card__image', 0)->find('img', 0)->getAttribute('src'); // Content preview $item['content'] = '
' . $article->find('.card__text', 0)->plaintext; // URL $item['uri'] = $article->find('.publi__buttons', 0)->find('a', 0)->getAttribute('href'); // ID $item['id'] = $article->find('.publi__buttons', 0)->find('a', 0)->getAttribute('href'); $this->items[] = $item; } } } ================================================ FILE: bridges/AkamaiBridge.php ================================================ [ 'name' => 'Limit', 'type' => 'number', 'required' => false, 'title' => 'Specify number of full articles to return', 'defaultValue' => 5 ] ]]; const FEED_URI = 'https://feeds.feedburner.com/akamai/blog'; const HEADERS = [ 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0', 'Accept-Language: en', ]; public function collectData() { $this->collectExpandableDatas( self::FEED_URI, $this->getInput('limit') ?? static::LIMIT ); } protected function parseItem(array $item) { $page = getSimpleHTMLDOMCached($item['uri'], self::CACHE_TIMEOUT, self::HEADERS); $page = defaultLinkTo($page, $item['uri']); if (!$page) { return $item; } $article = $page->find('section.main-content', 0); if (!$article) { return $item; } // Extract categories/tags foreach ($article->find('.taglist .cmp-tag-list__list-item') as $tag) { $item['categories'][] = $tag->plaintext; } // Remove annoying elements foreach ($article->find('.socialshare, .blogauthor, .taglist, .cmp-prismjs__copy') as $elem) { $elem->remove(); } foreach ($article->find('p') as $elem) { if ($elem->plaintext === 'Tags') { $elem->remove(); } } // Replace content with full text $item['content'] = $article->innertext; return $item; } public function getIcon() { return 'https://www.akamai.com/site/favicon/android-chrome-192x192.png'; } } ================================================ FILE: bridges/AlbionOnlineBridge.php ================================================ [ 'name' => 'Limit', 'type' => 'number', 'required' => true, 'title' => 'Maximum number of items to return', 'defaultValue' => 5, ], 'language' => [ 'name' => 'Language', 'type' => 'list', 'values' => [ 'English' => 'en', 'Deutsch' => 'de', 'Polski' => 'pl', 'Français' => 'fr', 'Русский' => 'ru', 'Português' => 'pt', 'Español' => 'es', ], 'title' => 'Language of changelog posts', 'defaultValue' => 'en', ], 'full' => [ 'name' => 'Full changelog', 'type' => 'checkbox', 'required' => false, 'title' => 'Enable to receive the full changelog post for each item' ], ]]; public function collectData() { $api = 'https://albiononline.com/'; // Example: https://albiononline.com/en/changelog/1/5 $url = $api . $this->getInput('language') . '/changelog/1/' . $this->getInput('postcount'); $html = getSimpleHTMLDOM($url); foreach ($html->find('li') as $data) { $item = []; $item['uri'] = self::URI . $data->find('a', 0)->getAttribute('href'); $item['title'] = trim(explode('|', $data->find('span', 0)->plaintext)[0]); // Time below work only with en lang. Need to think about solution. May be separate request like getFullChangelog, but to english list for all language //print_r( date_parse_from_format( 'M j, Y' , 'Sep 9, 2020') ); //$item['timestamp'] = $this->extractDate($a->plaintext); $item['author'] = 'albiononline.com'; if ($this->getInput('full')) { $item['content'] = $this->getFullChangelog($item['uri']); } else { //$item['content'] = trim(preg_replace('/\s+/', ' ', $data->find('span', 0)->plaintext)); // Just use title, no info at all or use title and date, see above $item['content'] = $item['title']; } $item['uid'] = hash('sha256', $item['title']); $this->items[] = $item; } } private function getFullChangelog($url) { $html = getSimpleHTMLDOMCached($url); $html = defaultLinkTo($html, self::URI); return $html->find('div.small-12.columns', 1)->innertext; } } ================================================ FILE: bridges/AlfaBankByBridge.php ================================================ [ 'business' => [ 'name' => 'Альфа Бизнес', 'type' => 'list', 'title' => 'В зависимости от выбора, возращает уведомления для" . " клиентов физ. лиц либо для клиентов-юридических лиц и ИП', 'values' => [ 'Новости' => 'news', 'Новости бизнеса' => 'newsBusiness' ], 'defaultValue' => 'news' ], 'fullContent' => [ 'name' => 'Включать содержимое', 'type' => 'checkbox', 'title' => 'Если выбрано, содержимое уведомлений вставляется в поток (работает медленно)' ] ] ]; public function collectData() { $business = $this->getInput('business') == 'newsBusiness'; $fullContent = $this->getInput('fullContent') == 'on'; $mainPageUrl = self::URI . '/about/articles/uvedomleniya/'; if ($business) { $mainPageUrl .= '?business=true'; } $html = getSimpleHTMLDOM($mainPageUrl); $limit = 0; foreach ($html->find('a.notifications__item') as $element) { if ($limit < 10) { $item = []; $item['uid'] = 'urn:sha1:' . hash('sha1', $element->getAttribute('data-notification-id')); $item['title'] = $element->find('div.item-title', 0)->innertext; $item['timestamp'] = DateTime::createFromFormat( 'd M Y', $this->ruMonthsToEn($element->find('div.item-date', 0)->innertext) )->getTimestamp(); $itemUrl = self::URI . $element->href; if ($business) { $itemUrl = str_replace('?business=true', '', $itemUrl); } $item['uri'] = $itemUrl; if ($fullContent) { $itemHtml = getSimpleHTMLDOM($itemUrl); if ($itemHtml) { $item['content'] = $itemHtml->find('div.now-p__content-text', 0)->innertext; } } $this->items[] = $item; $limit++; } } } public function getIcon() { return static::URI . '/local/images/favicon.ico'; } private function ruMonthsToEn($date) { $ruMonths = [ 'Января', 'Февраля', 'Марта', 'Апреля', 'Мая', 'Июня', 'Июля', 'Августа', 'Сентября', 'Октября', 'Ноября', 'Декабря' ]; $enMonths = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; return str_replace($ruMonths, $enMonths, $date); } } ================================================ FILE: bridges/AllSidesBridge.php ================================================ [ 'limit' => [ 'name' => 'Number of posts to return', 'type' => 'number', 'defaultValue' => 10, 'required' => false, 'title' => 'Zero or negative values return all posts (ignored if not fetching full article)', ], 'fetch' => [ 'name' => 'Fetch full article content', 'type' => 'checkbox', 'defaultValue' => 'checked', ], ], 'Headline Roundups' => [], ]; private const ROUNDUPS_URI = self::URI . '/headline-roundups'; public function collectData() { switch ($this->queriedContext) { case 'Headline Roundups': $index = getSimpleHTMLDOM(self::ROUNDUPS_URI); defaultLinkTo($index, self::ROUNDUPS_URI); $entries = $index->find('table.views-table > tbody > tr'); $limit = (int) $this->getInput('limit'); $fetch = (bool) $this->getInput('fetch'); if ($limit > 0 && $fetch) { $entries = array_slice($entries, 0, $limit); } foreach ($entries as $entry) { $item = [ 'title' => $entry->find('.views-field-name', 0)->text(), 'uri' => $entry->find('a', 0)->href, 'timestamp' => $entry->find('.date-display-single', 0)->content, 'author' => 'AllSides Staff', ]; if ($fetch) { $article = getSimpleHTMLDOMCached($item['uri']); defaultLinkTo($article, $item['uri']); $item['content'] = $article->find('.story-id-page-description', 0); foreach ($article->find('.page-tags a') as $tag) { $item['categories'][] = $tag->text(); } } $this->items[] = $item; } break; } } public function getName() { if ($this->queriedContext) { return self::NAME . " - {$this->queriedContext}"; } return self::NAME; } public function getURI() { switch ($this->queriedContext) { case 'Headline Roundups': return self::ROUNDUPS_URI; } return self::URI; } } ================================================ FILE: bridges/AllegroBridge.php ================================================ [ 'name' => 'Search URL', 'title' => 'Copy the URL from your browser\'s address bar after searching for your items and paste it here', 'exampleValue' => 'https://allegro.pl/kategoria/swieze-warzywa-cebula-318660', 'required' => true, ], 'cookie' => [ 'name' => 'The complete cookie value', 'title' => 'Paste the cookie value from your browser, otherwise 403 gets returned', 'required' => true, ], 'includeSponsoredOffers' => [ 'type' => 'checkbox', 'name' => 'Include Sponsored Offers', 'defaultValue' => 'checked' ], 'includePromotedOffers' => [ 'type' => 'checkbox', 'name' => 'Include Promoted Offers', 'defaultValue' => 'checked' ] ]]; public function getName() { $url = $this->getInput('url'); if (!$url) { return parent::getName(); } $parsedUrl = parse_url($url, PHP_URL_QUERY); if (!$parsedUrl) { return parent::getName(); } parse_str($parsedUrl, $fields); if (array_key_exists('string', $fields)) { $f = urldecode($fields['string']); } else { $f = false; } if ($f) { return $f; } return parent::getName(); } public function getURI() { $url = $this->getInput('url'); if (!$url) { return parent::getURI(); } # make sure we order by the most recently listed offers $url = preg_replace('/([?&])order=[^&]+(&|$)/', '$1', $this->getInput('url')); $url .= (parse_url($url, PHP_URL_QUERY) ? '&' : '?') . 'order=n'; # do not return related listings if no exact matches are found $url .= '&strategy=NO_FALLBACK'; return $url; } public function collectData() { $html = getContents($this->getURI(), [], [CURLOPT_COOKIE => $this->getInput('cookie')]); $storeData = null; if (preg_match('/]*>\s*(\{\s*?"__listing_StoreState".*\})\s*<\/script>/i', $html, $match)) { $data = json_decode($match[1], true); $storeData = $data['__listing_StoreState'] ?? null; } foreach ($storeData['items']['elements'] as $elements) { if (!array_key_exists('offerId', $elements)) { continue; } if (!$this->getInput('includeSponsoredOffers') && $elements['isSponsored']) { continue; } if (!$this->getInput('includePromotedOffers') && $elements['promoted']) { continue; } $item = []; $item['uid'] = $elements['offerId']; $item['uri'] = $elements['url']; $item['title'] = $elements['alt']; $image = $elements['photos'][0]['medium']; if ($image) { $item['enclosures'] = [$image . '#.image']; } $price = $elements['price']['mainPrice']['amount']; $currency = $elements['price']['mainPrice']['currency']; $sellerType = $elements['seller']['title']; $item['categories'] = [$sellerType]; $description = ''; foreach ($elements['parameters'] as $parameter) { $item['categories'] = array_merge($item['categories'], $parameter['values']); $description .= '

' . $parameter['name'] . ': ' . implode(',', $parameter['values']) . '
'; } $item['content'] = '
' . $price . ' ' . $currency . '
' . $sellerType . '
' . $description . '

'; $this->items[] = $item; } } } ================================================ FILE: bridges/AllocineFRBridge.php ================================================ [ 'name' => 'Emission', 'type' => 'list', 'title' => 'Sélectionner l\'emission', 'values' => [ 'Faux Raccord' => 'faux-raccord', 'Fanzone' => 'fanzone', 'Game In Ciné' => 'game-in-cine', 'Pour la faire courte' => 'pour-la-faire-courte', 'Home Cinéma' => 'home-cinema', 'PILS - Par Ici Les Sorties' => 'pils-par-ici-les-sorties', 'AlloCiné : l\'émission, sur LeStream' => 'allocine-lemission-sur-lestream', 'Give Me Five' => 'give-me-five', 'Aviez-vous remarqué ?' => 'aviez-vous-remarque', 'Et paf, il est mort' => 'et-paf-il-est-mort', 'The Big Fan Theory' => 'the-big-fan-theory', 'Clichés' => 'cliches', 'Complètement...' => 'completement', '#Fun Facts' => 'fun-facts', 'Origin Story' => 'origin-story', ] ] ]]; public function getURI() { if (!is_null($this->getInput('category'))) { $categories = [ 'faux-raccord' => '/video/programme-12284/', 'fanzone' => '/video/programme-12298/', 'game-in-cine' => '/video/programme-12288/', 'pour-la-faire-courte' => '/video/programme-20960/', 'home-cinema' => '/video/programme-12287/', 'pils-par-ici-les-sorties' => '/video/programme-25789/', 'allocine-lemission-sur-lestream' => '/video/programme-25123/', 'give-me-five' => '/video/programme-21919/saison-34518/', 'aviez-vous-remarque' => '/video/programme-19518/', 'et-paf-il-est-mort' => '/video/programme-25113/', 'the-big-fan-theory' => '/video/programme-20403/', 'cliches' => '/video/programme-24834/', 'completement' => '/video/programme-23859/', 'fun-facts' => '/video/programme-23040/', 'origin-story' => '/video/programme-25667/' ]; $category = $this->getInput('category'); if (array_key_exists($category, $categories)) { return static::URI . $this->getLastSeasonURI($categories[$category]); } else { throwClientException('Emission inconnue'); } } return parent::getURI(); } private function getLastSeasonURI($category) { $html = getSimpleHTMLDOMCached(static::URI . $category, 86400); $seasonLink = $html->find('section[class=section-wrap section]', 0)->find('div[class=cf]', 0)->find('a', 0); $URI = $seasonLink->href; return $URI; } public function getName() { if (!is_null($this->getInput('category'))) { return self::NAME . ' : ' . $this->getKey('category'); } return parent::getName(); } public function collectData() { $html = getSimpleHTMLDOM($this->getURI()); foreach ($html->find('div[class=gd-col-left]', 0)->find('div[class*=video-card]') as $element) { $item = []; $title = $element->find('a[class*=meta-title-link]', 0); $content = trim(defaultLinkTo($element->outertext, static::URI)); // Replace image 'src' with the one in 'data-src' $content = preg_replace('@src="data:image/gif;base64,[A-Za-z0-9+\/]*"@', '', $content); $content = preg_replace('@data-src=@', 'src=', $content); // Remove date in the content to prevent content update while the video is getting older $content = preg_replace('@
.*[^<]*[^<]*
@', '', $content); $item['content'] = $content; $item['title'] = trim($title->innertext); $item['uri'] = static::URI . '/' . substr($title->href, 1); $this->items[] = $item; } } } ================================================ FILE: bridges/AllocineFRSortiesBridge.php ================================================ getURI()); foreach ($html->find('section.section.section-wrap', 0)->find('li.mdl') as $element) { $item = []; $thumb = $element->find('figure.thumbnail', 0); $meta = $element->find('div.meta-body', 0); $synopsis = $element->find('div.synopsis', 0); $date = $element->find('span.date', 0); $title = $element->find('a[class*=meta-title-link]', 0); $content = trim(defaultLinkTo($thumb->outertext . $meta->outertext . $synopsis->outertext, static::URI)); // Replace image 'src' with the one in 'data-src' $content = preg_replace('@src="data:image/gif;base64,[A-Za-z0-9=+\/]*"@', '', $content); $content = preg_replace('@data-src=@', 'src=', $content); $item['content'] = $content; $item['title'] = trim($title->innertext); $item['timestamp'] = $this->frenchPubDateToTimestamp($date->plaintext); $item['uri'] = static::BASE_URI . '/' . substr($title->href, 1); $this->items[] = $item; } } private function frenchPubDateToTimestamp($date) { return strtotime( strtr( strtolower($date), [ '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' ] ) ); } } ================================================ FILE: bridges/AlpinePackagesBridge.php ================================================ [ 'type' => 'text', 'name' => 'Package Name', 'required' => true, 'exampleValue' => 'curl', 'title' => 'Name of the package. Use * and ? as wildcards. For example: curl-dev, curl-* or curl-???.' ], 'branch' => [ 'type' => 'text', 'name' => 'Package branch', 'required' => true, 'exampleValue' => 'v3.23', 'title' => 'Name of the branch. For example: edge, v3.23, v3.22, etc.' ], 'repository' => [ 'type' => 'list', 'name' => 'Repository name', 'values' => [ 'All' => 'all', 'Community' => 'community', 'Main' => 'main', 'Testing' => 'testing' ], 'defaultValue' => 'all' ], 'architecture' => [ 'type' => 'list', 'name' => 'Achitecture', 'values' => [ 'All' => 'all', 'aarch64' => 'aarch64', 'armhf' => 'armhf', 'armv7' => 'armv7', 'loongarch64' => 'loongarch64', 'ppc64le' => 'ppc64le', 'riscv64' => 'riscv64', 's390x' => 's390x', 'x86' => 'x86', 'x86_64' => 'x86_64' ], 'defaultValue' => 'all' ] ] ]; private function getADom($element) { return $element->find('a')[0]; } private function getElementData($element) { $classes = [ 'package', 'repo', 'arch', 'maintainer' ]; $noAhrefClasses = [ 'branch', 'bdate' ]; $data = []; // Get data from element which contains . foreach ($classes as $class) { $td = $this->getTdClassDom($element, $class); $a = $this->getADom($td); $data[$class] = trim($a->plaintext); $data[$class . '-href'] = $a->href; } // Get data from element which only contains text. foreach ($noAhrefClasses as $class) { $td = $this->getTdClassDom($element, $class); $data[$class] = trim($td->plaintext); } // Get version data in a element. $td = $this->getTdClassDom($element, 'version'); $strong = $td->find('strong[class=hint--right hint--rounded text-success]')[0]; $data['version'] = trim($strong->plaintext); return $data; } private function getTdClassDom($element, $class) { return $element->find('td[class=' . $class . ']')[0]; } public function collectData() { $dom = getSimpleHTMLDOM($this->getUri()); $dom = defaultLinkTo($dom, self::URI); $table = $dom->find('table[class=pure-table pure-table-striped]')[0]; $tbody = $table->find('tbody')[0]; $trs = $tbody->find('tr'); foreach ($trs as $tr) { $itemData = $this->getElementData($tr); $this->items[] = [ 'title' => $itemData['package'] . '-' . $itemData['version'], 'uri' => $itemData['package-href'], 'timestamp' => strtotime($itemData['bdate']), 'uid' => trim($itemData['package']) . $itemData['version'] . $itemData['arch'] . $itemData['branch'] . $itemData['repo'], 'author' => $itemData['maintainer'], 'categories' => [ 'arch: ' . $itemData['arch'], 'branch: ' . $itemData['branch'], 'repo: ' . $itemData['repo'] ] ]; } } public function getName() { $packageName = $this->getInput('package'); $branchName = $this->getInput('branch'); $repositoryName = $this->getInput('repository'); $architecture = $this->getInput('architecture'); $name = ''; if ($packageName) { $packageName = strtolower($packageName); $name = $packageName . ' ('; if ($branchName) { $branchName = strtolower($branchName); $name .= 'branch ' . $branchName; } if ($repositoryName) { $repositoryName = strtolower($repositoryName); if ($repositoryName !== 'all') { $name .= ', repo ' . $repositoryName; } } if ($architecture) { $architecture = strtolower($architecture); if ($architecture !== 'all') { $name .= ', arch ' . $architecture; } } $name .= ') - Alpine packages'; return $name; } return parent::getName(); } public function getUri() { $package = $this->getInput('package'); $branch = $this->getInput('branch'); $repository = $this->getInput('repository'); $architecture = $this->getInput('architecture'); if ($package) { $package = urlencode(strtolower(trim($package))); } if ($branch) { $branch = strtolower(trim($branch)); } if ($repository) { $repository = strtolower($repository); if ($repository === 'all') { $repository = ''; } } if ($architecture) { $architecture = strtolower(trim($architecture)); if ($architecture === 'all') { $architecture = ''; } } if ($package && $branch) { return self::URI . '/packages?name=' . $package . '&branch=' . $branch . '&repo=' . $repository . '&arch=' . $architecture . '&origin=&flagged=&maintainer='; } return self::URI; } } ================================================ FILE: bridges/AmazonBridge.php ================================================ [ 'name' => 'Keyword', 'required' => true, 'exampleValue' => 'watch', ], 'sort' => [ 'name' => 'Sort by', 'type' => 'list', 'values' => [ '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' => [ 'name' => 'Country', 'type' => 'list', 'values' => [ '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', 'Poland' => 'pl', 'Spain' => 'es', 'Sweden' => 'se', 'Turkey' => 'com.tr', 'United Kingdom' => 'co.uk', 'United States' => 'com', ], 'defaultValue' => 'com', ], ]]; public function collectData() { $baseUrl = sprintf('https://www.amazon.%s', $this->getInput('tld')); $url = sprintf( '%s/s/?field-keywords=%s&sort=%s', $baseUrl, urlencode($this->getInput('q')), $this->getInput('sort') ); $dom = getSimpleHTMLDOM($url); $elements = $dom->find('div.s-result-item'); foreach ($elements as $element) { $item = []; $title = $element->find('h2', 0); if (!$title) { continue; } $item['title'] = $title->innertext; $itemUrl = $element->find('a', 0)->href; $item['uri'] = urljoin($baseUrl, $itemUrl); $image = $element->find('img', 0); if ($image) { $item['content'] = '
'; } $price = $element->find('span.a-price > .a-offscreen', 0); if ($price) { $item['content'] .= $price->innertext; } $this->items[] = $item; } } 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(); } } ================================================ FILE: bridges/AmazonPriceTrackerBridge.php ================================================ [ 'name' => 'ASIN', 'required' => true, 'exampleValue' => 'B0923XT6K7', // https://stackoverflow.com/a/12827734 'pattern' => 'B[\dA-Z]{9}|\d{9}(X|\d)', ], 'tld' => [ 'name' => 'Country', 'type' => 'list', 'values' => [ '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', 'Poland' => 'pl', 'Spain' => 'es', 'Sweden' => 'se', 'Turkey' => 'com.tr', 'United Kingdom' => 'co.uk', 'United States' => 'com', ], 'defaultValue' => 'com', ], ]]; const PRICE_SELECTORS = [ '#priceblock_ourprice', '.priceBlockBuyingPriceString', '#newBuyBoxPrice', '#tp_price_block_total_price_ww', 'span.offer-price', '.a-color-price', ]; const WHITESPACE = " \t\n\r\0\x0B\xC2\xA0"; 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) { $image = 'https://placekitten.com/200/300'; $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); } return << EOT; } /** * Return \simple_html_dom object * for the entire html of the product page */ private function getHtml() { $uri = $this->getURI(); return getSimpleHTMLDOM($uri); } private function scrapePriceFromMetrics($html) { $asinData = $html->find('#cerberus-data-metrics', 0); //
HTML; if ($info) { $itemContent .= <<

Infos

{$info}

HTML; } $majorOffers = array_filter($offers, fn($title): bool => !in_array($title, self::OFFER_LOW_PRIORITIES), ARRAY_FILTER_USE_KEY); foreach ($majorOffers as $offerTitle => list($offerText, $offerImages)) { $itemContent .= <<

{$offerTitle}

{$offerText}

HTML; foreach ($offerImages as list($imageTitle, $imageUrl)) { $itemContent .= <<
{$imageTitle}
HTML; } $itemContent .= << HTML; } if (count($images) > 0) { $itemContent .= <<

Fotos

HTML; foreach ($images as list($imageTitle, $imageUrl)) { $itemContent .= <<
{$imageTitle}
HTML; } $itemContent .= << HTML; } $minorOffers = array_filter($offers, fn($title): bool => in_array($title, self::OFFER_LOW_PRIORITIES), ARRAY_FILTER_USE_KEY); foreach ($minorOffers as $offerTitle => list($offerText)) { $itemContent .= <<

{$offerTitle}

{$offerText}

HTML; } $item['title'] = $dateLines[0] . ' ' . $dateLines[1] . ' ' . $addressLines[0] . ' - ' . $addressLines[1]; $item['content'] = $itemContent; $item['description'] = null; $item['enclosures'] = array_map( function ($image): string { list($title, $url) = $image; return $url . '#' . urlencode(str_replace(' ', '_', $title)); }, $images ); return $item; } public function getURI() { if ($this->queriedContext === self::CONTEXT_APPOINTMENTS) { return str_replace('.rss?', '?', self::buildAppointmentsURI()); } return parent::getURI(); } private function buildAppointmentsURI() { $term = $this->getInput('term') ?? ''; $radius = $this->getInput('radius') ?? ''; $limitDays = intval($this->getInput('limit_days')); $dateTo = $limitDays > 0 ? date('Y-m-d', time() + (60 * 60 * 24 * $limitDays)) : ''; return self::BASE_URI . '/blutspendetermine/termine.rss?date_to=' . $dateTo . '&radius=' . $radius . '&term=' . $term; } private function parseImages($parentElement): array { $images = []; if ($parentElement) { $elements = $parentElement->find('a[data-lightbox]'); foreach ($elements as $i => $element) { $url = trim($element->getAttribute('href')); if (!$url) { continue; } $title = trim($element->getAttribute('title')); if (!$title) { $number = $i + 1; $title = "Foto {$number}"; } $images[] = [$title, $url]; } } return $images; } private function parseOffers($offerElements): array { $offers = []; foreach ($offerElements as $element) { $title = self::getCleanPlainText($element->find(':is(h1,h2,h3,h4,h5,h6)', 0)); $text = trim(substr(self::getCleanPlainText($element), strlen($title))); if (!$title || !$text) { continue; } $linkElements = $element->find('a'); foreach ($linkElements as $linkElement) { $linkText = trim($linkElement->plaintext); $linkUrl = trim($linkElement->getAttribute('href')); if (!$linkText || !$linkUrl) { continue; } $linkHtml = <<{$linkText} HTML; $text = str_replace($linkText, $linkHtml, $text); } $offers[$title] = [$text, self::parseImages($element)]; } return $offers; } private function getCleanPlainText($htmlElement): string { return $htmlElement ? trim(preg_replace('/\s+/', ' ', html_entity_decode($htmlElement->plaintext))) : ''; } /** * Returns an array of strings, each of which is a substring of string formed by splitting it on boundaries formed by line breaks. */ private function explodeLines(string $text): array { return array_map('trim', preg_split('/(\s*(\r\n|\n|\r)\s*)+/', $text)); } } ================================================ FILE: bridges/DacksnackBridge.php ================================================ '01', 'februari' => '02', 'mars' => '03', 'april' => '04', 'maj' => '05', 'juni' => '06', 'juli' => '07', 'augusti' => '08', 'september' => '09', 'oktober' => '10', 'november' => '11', 'december' => '12' ]; // Split the date string into parts list($day, $monthName, $year) = explode(' ', $dateString); // Convert month name to month number $month = $monthNames[$monthName]; // Format to a string recognizable by DateTime $formattedDate = sprintf('%04d-%02d-%02d', $year, $month, $day); // Create a DateTime object $dateValue = new DateTime($formattedDate); if ($dateValue) { $dateValue->setTime(0, 0); // Set time to 00:00 return $dateValue->getTimestamp(); } return $dateValue ? $dateValue->getTimestamp() : false; } public function collectData() { $NEWSURL = self::URI; $html = getSimpleHTMLDOMCached($NEWSURL, 18000); foreach ($html->find('a.main-news-item') as $element) { // Debug::log($element); $title = trim($element->find('h2', 0)->plaintext); $category = trim($element->find('.category-tag', 0)->plaintext); $url = self::URI . $element->getAttribute('href'); $published = $this->parseSwedishDates(trim($element->find('.published', 0)->plaintext)); $article_html = getSimpleHTMLDOMCached($url, 18000); $article_content = $article_html->find('#ctl00_ContentPlaceHolder1_NewsArticleVeiw_pnlArticle', 0); $figure = self::URI . $article_content->find('img.news-image', 0)->getAttribute('src'); $figure_caption = $article_content->find('.image-description', 0)->plaintext; $author = $article_content->find('span.main-article-author', 0)->plaintext; $preamble = $article_content->find('h4.main-article-ingress', 0)->plaintext; $article_text = ''; foreach ($article_content->find('div') as $div) { if (!$div->hasAttribute('class')) { $article_text = $div; } } // Use a regular expression to extract the name if (preg_match('/Text:\s*(.*?)\s*Foto:/', $author, $matches)) { $author = $matches[1]; // This will contain 'Jonna Jansson' } $content = ' [' . $category . '] ' . $preamble . '

'; $content .= '
'; $content .= ''; $content .= '
' . $figure_caption . '
'; $content .= '
'; $content .= $article_text; $this->items[] = [ 'uri' => $url, 'title' => $title, 'author' => $author, 'timestamp' => $published, 'content' => trim($content), ]; } } } ================================================ FILE: bridges/DagensNyheterDirektBridge.php ================================================ find('article') as $element) { $link = $element->find('button', 0)->getAttribute('data-link'); $datetime = $element->getAttribute('data-publication-time'); $url = self::BASEURL . $link; $title = $element->find('h2', 0)->plaintext; $author = $element->find('div.ds-byline__titles', 0)->plaintext; $article_content = $element->find('div.direkt-post__content', 0); $article_html = ''; $figure = $element->find('figure', 0); if ($figure) { $article_html = $figure->find('img', 0) . '

' . $figure->find('figcaption', 0) . '

'; } foreach ($article_content->find('p') as $p) { $article_html = $article_html . $p; } $this->items[] = [ 'uri' => $url, 'title' => $title, 'author' => trim($author), 'timestamp' => $datetime, 'content' => trim($article_html), ]; if (count($this->items) > self::LIMIT) { break; } } } } ================================================ FILE: bridges/DailymotionBridge.php ================================================ [ 'u' => [ 'name' => 'username', 'required' => true, 'exampleValue' => 'moviepilot', ] ], 'By playlist id' => [ 'p' => [ 'name' => 'playlist id', 'required' => true, 'exampleValue' => 'x6xyc6', ] ], 'From search results' => [ 's' => [ 'name' => 'Search keyword', 'required' => true, 'exampleValue' => 'matrix', ], 'pa' => [ 'name' => 'Page', 'type' => 'number', 'defaultValue' => 1, ] ] ]; private $feedName = ''; private $apiUrl = 'https://api.dailymotion.com'; private $apiFields = 'created_time,description,id,owner.screenname,tags,thumbnail_url,title,url'; public function getIcon() { return 'https://static1.dmcdn.net/neon-user-ssr/prod/favicons/apple-icon-60x60.831b96ed0a8eca7f6539.png'; } public function collectData() { $apiJson = getContents($this->getApiUrl()); $apiData = json_decode($apiJson, true); if ($this->queriedContext === 'By playlist id') { $this->feedName = $this->getPlaylistTitle($this->getInput('p')); } foreach ($apiData['list'] as $apiItem) { $item = []; $item['uri'] = $apiItem['url']; $item['uid'] = $apiItem['id']; $item['title'] = $apiItem['title']; $item['timestamp'] = $apiItem['created_time']; $item['author'] = $apiItem['owner.screenname']; $item['content'] = '

' . $apiItem['description'] . '

'; $item['categories'] = $apiItem['tags']; $item['enclosures'][] = $apiItem['thumbnail_url']; $this->items[] = $item; } } public function getName() { switch ($this->queriedContext) { case 'By username': $specific = $this->getInput('u'); break; case 'By playlist id': $specific = strtok($this->getInput('p'), '_'); if ($this->feedName) { $specific = $this->feedName; } break; case 'From search results': $specific = $this->getInput('s'); break; default: return parent::getName(); } return $specific . ' : Dailymotion'; } public function getURI() { $uri = self::URI; switch ($this->queriedContext) { case 'By username': $uri .= 'user/' . urlencode($this->getInput('u')); break; case 'By playlist id': $uri .= 'playlist/' . urlencode(strtok($this->getInput('p'), '_')); break; case 'From search results': $uri .= 'search/' . urlencode($this->getInput('s')); if (!is_null($this->getInput('pa'))) { $pa = $this->getInput('pa'); if ($this->getInput('pa') < 1) { $pa = 1; } $uri .= '/' . $pa; } break; default: return parent::getURI(); } return $uri; } private function getPlaylistTitle($id) { $apiJson = getContents($this->apiUrl . '/playlist/' . $this->getInput('p')); $apiData = json_decode($apiJson, true); return $apiData['name']; } private function getApiUrl() { switch ($this->queriedContext) { case 'By username': return $this->apiUrl . '/user/' . $this->getInput('u') . '/videos?fields=' . urlencode($this->apiFields) . '&availability=1&sort=recent&limit=5'; break; case 'By playlist id': return $this->apiUrl . '/playlist/' . $this->getInput('p') . '/videos?fields=' . urlencode($this->apiFields) . '&limit=5'; break; case 'From search results': return $this->apiUrl . '/videos?search=' . $this->getInput('s') . '&fields=' . urlencode($this->apiFields) . '&limit=5'; break; } } } ================================================ FILE: bridges/DailythanthiBridge.php ================================================ [ 'name' => 'topic', 'type' => 'list', 'values' => [ 'news' => [ 'tamilnadu' => 'news/state', 'india' => 'news/india', 'world' => 'news/world', 'sirappu-katturaigal' => 'news/sirappukatturaigal', ], 'cinema' => [ 'news' => 'cinema/cinemanews', ], 'sports' => [ 'sports' => 'sports', 'cricket' => 'sports/cricket', 'football' => 'sports/football', 'tennis' => 'sports/tennis', 'hockey' => 'sports/hockey', 'other-sports' => 'sports/othersports', ], 'devotional' => [ 'devotional' => 'others/devotional', 'aalaya-varalaru' => 'aalaya-varalaru', ], ], ], ], ]; public function getName() { $topic = $this->getKey('topic'); return self::NAME . ($topic ? ' - ' . ucfirst($topic) : ''); } public function collectData() { $dom = getSimpleHTMLDOM(self::URI . '/' . $this->getInput('topic')); foreach ($dom->find('div.ListingNewsWithMEDImage') as $element) { $slug = $element->find('a', 1); $title = $element->find('h3', 0); if (!$slug || !$title) { continue; } $url = self::URI . $slug->href; $date = $element->find('span', 1); $date = $date ? $date->{'data-datestring'} : ''; $this->items[] = [ 'content' => $this->constructContent($url), 'timestamp' => $date ? $date . 'UTC' : '', 'title' => $title->plaintext, 'uid' => $slug->href, 'uri' => $url, ]; } } private function constructContent($url) { $dom = getSimpleHTMLDOMCached($url); $article = $dom->find('div.details-content-story', 0); if (!$article) { return 'Content Not Found'; } // Remove ads foreach ($article->find('div[id*="_ad"]') as $remove) { $remove->outertext = ''; } // Correct image tag in $article foreach ($article->find('h-img') as $img) { $img->parent->outertext = sprintf('

', $img->src); } $image = $dom->find('div.main-image-caption-container img', 0); $image = $image ? '

' . $image->outertext . '

' : ''; return $image . $article; } } ================================================ FILE: bridges/DanbooruBridge.php ================================================ [ 'p' => [ 'name' => 'page', 'defaultValue' => 1, 'type' => 'number' ], 't' => [ 'type' => 'text', 'name' => 'tags', 'exampleValue' => 'cosplay', ] ], 0 => [] ]; 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 = []; $item['uri'] = html_entity_decode($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['categories'] = array_filter(explode(' ', $this->getTags($element))); $item['title'] = $this->getName() . ' | ' . $item['postid']; $item['content'] = '
Tags: ' . $this->getTags($element); return $item; } public function collectData() { $html = getSimpleHTMLDOMCached($this->getFullURI()); foreach ($html->find(static::PATHTODATA) as $element) { $this->items[] = $this->getItemFromElement($element); } } } ================================================ FILE: bridges/DarkReadingBridge.php ================================================ [ 'name' => 'Feed (NOT IN USE)', 'type' => 'list', 'values' => [ 'All Dark Reading Stories' => '000_AllArticles', 'Attacks/Breaches' => '644_Attacks/Breaches', 'Application Security' => '645_Application%20Security', 'Database Security' => '646_Database%20Security', 'Cloud' => '647_Cloud', 'Endpoint' => '648_Endpoint', 'Authentication' => '649_Authentication', 'Privacy' => '650_Privacy', 'Mobile' => '651_Mobile', 'Perimeter' => '652_Perimeter', 'Risk' => '653_Risk', 'Compliance' => '654_Compliance', 'Operations' => '655_Operations', 'Careers and People' => '656_Careers%20and%20People', 'Identity and Access Management' => '657_Identity%20and%20Access%20Management', 'Analytics' => '658_Analytics', 'Threat Intelligence' => '659_Threat%20Intelligence', 'Security Monitoring' => '660_Security%20Monitoring', 'Vulnerabilities / Threats' => '661_Vulnerabilities%20/%20Threats', 'Advanced Threats' => '662_Advanced%20Threats', 'Insider Threats' => '663_Insider%20Threats', 'Vulnerability Management' => '664_Vulnerability%20Management', ] ], 'limit' => self::LIMIT, ]]; public function collectData() { $feed_url = 'https://www.darkreading.com/rss.xml'; $limit = $this->getInput('limit') ?? 10; $this->collectExpandableDatas($feed_url, $limit); } protected function parseItem(array $item) { $article = getSimpleHTMLDOMCached($item['uri']); $item['content'] = $this->extractArticleContent($article); $item['enclosures'] = []; //remove author profile picture $image = $article->find('meta[property="og:image"]', 0); if (is_object($image)) { $image = $image->content; $item['enclosures'] = [$image]; } return $item; } private function extractArticleContent($article) { $content = $article->find('div.ContentModule-Wrapper', 0)->innertext; foreach ( [ '
[ 'name' => 'Catégorie de l\'article', 'type' => 'list', 'values' => [ 'À 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(array $item) { $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; } } ================================================ FILE: bridges/DealabsBridge.php ================================================ [ 'q' => [ 'name' => 'Mot(s) clé(s)', 'type' => 'text', 'exampleValue' => 'lampe', 'required' => true ], 'hide_expired' => [ 'name' => 'Masquer les éléments expirés', 'type' => 'checkbox', ], 'hide_local' => [ 'name' => 'Masquer les deals locaux', 'type' => 'checkbox', 'title' => 'Masquer les deals en magasins physiques', ], 'priceFrom' => [ 'name' => 'Prix minimum', 'type' => 'text', 'title' => 'Prix mnimum en euros', 'required' => false ], 'priceTo' => [ 'name' => 'Prix maximum', 'type' => 'text', 'title' => 'Prix maximum en euros', 'required' => false ], ], 'Deals par groupe' => [ 'group' => [ 'name' => 'Groupe', 'type' => 'text', 'exampleValue' => 'abonnements-internet', 'title' => 'Nom du groupe dans l\'URL : Il faut entrer le nom du groupe qui est présent après "https://www.dealabs.com/groupe/" et avant tout éventuel "?" Exemple : Si l\'URL du groupe affichées dans le navigateur est : https://www.dealabs.com/groupe/abonnements-internet?sortBy=lowest_price Il faut alors saisir : abonnements-internet', ], 'subgroups' => [ 'name' => 'Catégorie', 'type' => 'text', 'exampleValue' => '1071', 'title' => 'Numéro du ou des catégories dans l\'URL : Il faut entrer le ou les numéros de catégories qui sont présent après "groups=" et avant tout éventuel "&" Exemple : Si l\'URL du groupe affichées dans le navigateur est : https://www.dealabs.com/groupe/telecommunications?groups=1071%2C1070&sortBy=new Il faut alors saisir : 1071%2C1070', ], 'order' => [ 'name' => 'Trier par', 'type' => 'list', 'title' => 'Ordre de tri des deals', 'values' => [ 'Du deal le plus Hot au moins Hot' => '-hot', 'Du deal le plus récent au plus ancien' => '-nouveaux', ] ] ], 'Surveillance Discussion' => [ 'url' => [ 'name' => 'URL de la discussion', 'type' => 'text', 'required' => true, 'title' => 'URL discussion à surveiller: https://www.dealabs.com/discussions/titre-1234', 'exampleValue' => 'https://www.dealabs.com/discussions/jeux-steam-gratuits-gleam-woobox-etc-1071415', ], 'only_with_url' => [ 'name' => 'Exclure les commentaires sans URL', 'type' => 'checkbox', 'title' => 'Exclure les commentaires ne contenant pas d\'URL dans le flux', 'defaultValue' => false, ] ] ]; public $lang = [ 'bridge-uri' => self::URI, 'bridge-name' => self::NAME, 'context-keyword' => 'Recherche par Mot(s) clé(s)', 'context-group' => 'Deals par groupe', 'context-talk' => 'Surveillance Discussion', 'uri-group' => 'groupe/', 'uri-deal' => 'bons-plans/', 'uri-merchant' => 'search/bons-plans?merchant-id=', 'image-host' => 'https://static-pepper.dealabs.com/', 'request-error' => 'Impossible de joindre Dealabs', 'thread-error' => 'Impossible de déterminer l\'ID de la discussion. Vérifiez l\'URL que vous avez entré', 'currency' => '€', 'price' => 'Prix', 'shipping' => 'Livraison', 'origin' => 'Origine', 'discount' => 'Réduction', 'title-keyword' => 'Recherche', 'title-group' => 'Groupe', 'title-talk' => 'Surveillance Discussion', 'deal-type' => 'Type de deal', 'localdeal' => 'Deal Local', 'context-hot' => '-hot', 'context-new' => '-nouveaux', ]; } ================================================ FILE: bridges/DemoBridge.php ================================================ [ 'testCheckbox' => [ 'type' => 'checkbox', 'name' => 'test des checkbox' ] ], 'testList' => [ 'testList' => [ 'type' => 'list', 'name' => 'test des listes', 'values' => [ 'Test' => 'test', 'Test 2' => 'test2' ] ] ], 'testNumber' => [ 'testNumber' => [ 'type' => 'number', 'name' => 'test des numéros', 'exampleValue' => '1515632' ] ] ]; public function collectData() { $item = []; $item['author'] = 'Me!'; $item['title'] = 'Test'; $item['content'] = 'Awesome content !'; $item['id'] = 'Lalala'; $item['uri'] = 'http://example.com/test'; $this->items[] = $item; } } ================================================ FILE: bridges/DemosBerlinBridge.php ================================================ [ 'name' => 'Tage', 'type' => 'number', 'title' => 'Einträge für die nächsten Tage zurückgeben', 'required' => true, 'defaultValue' => 7, ] ]]; public function getIcon() { return 'https://www.berlin.de/i9f/r1/images/favicon/favicon.ico'; } public function collectData() { $url = 'https://www.berlin.de/polizei/service/versammlungsbehoerde/versammlungen-aufzuege/index.php/index/all.json'; $json = getContents($url); $jsonFile = json_decode($json, true); $daysInterval = DateInterval::createFromDateString($this->getInput('days') . ' day'); $maxTargetDate = date_add(new DateTime('now'), $daysInterval); foreach ($jsonFile['index'] as $entry) { $entryDay = implode('-', array_reverse(explode('.', $entry['datum']))); // dd.mm.yyyy to yyyy-mm-dd $ts = (new DateTime())->setTimestamp(strtotime($entryDay)); if ($ts <= $maxTargetDate) { $item = []; $item['uri'] = 'https://www.berlin.de/polizei/service/versammlungsbehoerde/versammlungen-aufzuege/index.php/detail/' . $entry['id']; $item['timestamp'] = $entryDay . ' ' . $entry['von']; $item['title'] = $entry['thema']; $location = $entry['strasse_nr'] . ' ' . $entry['plz']; $locationQuery = http_build_query(['query' => $location]); $item['content'] = <<{$entry['thema']}

📅

📍 {$location}

{$entry['aufzugsstrecke']}

HTML; $item['uid'] = $this->getSanitizedHash($entry['datum'] . '-' . $entry['von'] . '-' . $entry['bis'] . '-' . $entry['thema']); $this->items[] = $item; } } } private function getSanitizedHash($string) { return hash('sha1', preg_replace('/[^a-zA-Z0-9]/', '', strtolower($string))); } } ================================================ FILE: bridges/DerpibooruBridge.php ================================================ [ 'name' => 'Filter', 'type' => 'list', 'values' => [ 'Everything' => 56027, '18+ R34' => 37432, 'Legacy Default' => 37431, '18+ Dark' => 37429, 'Maximum Spoilers' => 37430, 'Default' => 100073 ], 'defaultValue' => 56027 ], 'q' => [ 'name' => 'Query', 'required' => true, 'exampleValue' => 'dog', ] ] ]; public function detectParameters($url) { $params = []; // 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() { $url = self::URI . 'api/v1/json/search/images?filter_id=' . urlencode($this->getInput('f')) . '&q=' . urlencode($this->getInput('q')); $queryJson = json_decode(getContents($url)); foreach ($queryJson->images as $post) { $item = []; $postUri = self::URI . $post->id; $item['uri'] = $postUri; $item['title'] = $post->name; $item['timestamp'] = strtotime($post->created_at); $item['author'] = $post->uploader; $item['enclosures'] = [$post->view_url]; $item['categories'] = $post->tags; $item['content'] = '

' // description . $post->description . '

Size: ' // image size . $post->width . 'x' . $post->height; // source link if ($post->source_url != null) { $item['content'] .= '
Source: ' . $post->source_url . '

'; }; $this->items[] = $item; } } } ================================================ FILE: bridges/DesoutterBridge.php ================================================ [ 'news_lang' => [ 'name' => 'Language', 'type' => 'list', 'title' => 'Select your language', 'defaultValue' => 'https://www.desouttertools.com/about-desoutter/news-events', 'values' => [ '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 => [ 'industry_lang' => [ 'name' => 'Language', 'type' => 'list', 'title' => 'Select your language', 'defaultValue' => 'Corporate', 'values' => [ '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' => [ 'full' => [ 'name' => 'Load full articles', 'type' => 'checkbox', 'title' => 'Enable to load the full article for each item' ], 'limit' => [ 'name' => 'Limit', 'type' => 'number', 'required' => true, 'defaultValue' => 3, 'title' => "Maximum number of items to return in the feed.\n0 = unlimited" ] ] ]; 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()); $html = defaultLinkTo($html, $this->getURI()); $this->title = html_entity_decode($html->find('title', 0)->plaintext, ENT_QUOTES); $limit = $this->getInput('limit') ?: 0; foreach ($html->find('article') as $article) { $item = []; $item['uri'] = $article->find('a', 0)->href; $item['title'] = $article->find('a[title]', 0)->title; if ($this->getInput('full')) { $item['content'] = $this->getFullNewsArticle($item['uri']); } else { $item['content'] = $article->find('div.tile-body p', 0)->plaintext; } $this->items[] = $item; if ($limit > 0 && count($this->items) >= $limit) { break; } } } private function getFullNewsArticle($uri) { $html = getSimpleHTMLDOMCached($uri); $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'); $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'); $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; } } ================================================ FILE: bridges/DeutscheWelleBridge.php ================================================ [ 'name' => 'feed', 'type' => 'list', 'values' => [ 'All Top Stories and News Updates' => 'http://rss.dw.com/atom/rss-en-all', 'Top Stories' => 'http://rss.dw.com/atom/rss-en-top', 'Germany' => 'http://rss.dw.com/atom/rss-en-ger', 'World' => 'http://rss.dw.com/atom/rss-en-world', 'Europe' => 'http://rss.dw.com/atom/rss-en-eu', 'Business' => 'http://rss.dw.com/atom/rss-en-bus', 'Science' => 'http://rss.dw.com/atom/rss_en_science', 'Environment' => 'http://rss.dw.com/atom/rss_en_environment', 'Culture & Lifestyle' => 'http://rss.dw.com/atom/rss-en-cul', 'Sports' => 'http://rss.dw.de/atom/rss-en-sports', 'Visit Germany' => 'http://rss.dw.com/atom/rss-en-visitgermany', 'Asia' => 'http://rss.dw.com/atom/rss-en-asia', 'Deutsche Welle Gesamt' => 'http://rss.dw.com/atom/rss-de-all', 'Themen des Tages' => 'http://rss.dw.com/atom/rss-de-top', 'Nachrichten' => 'http://rss.dw.com/atom/rss-de-news', 'Wissenschaft' => 'http://rss.dw.com/atom/rss-de-wissenschaft', 'Sport' => 'http://rss.dw.com/atom/rss-de-sport', 'Deutschland entdecken' => 'http://rss.dw.com/atom/rss-de-deutschlandentdecken', 'Presse' => 'http://rss.dw.com/atom/presse', 'Politik' => 'http://rss.dw.com/atom/rss_de_politik', 'Wirtschaft' => 'http://rss.dw.com/atom/rss-de-eco', 'Kultur & Leben' => 'http://rss.dw.com/atom/rss-de-cul', 'Kultur & Leben: Buch' => 'http://rss.dw.com/atom/rss-de-cul-buch', 'Kultur & Leben: Film' => 'http://rss.dw.com/atom/rss-de-cul-film', 'Kultur & Leben: Musik' => 'http://rss.dw.com/atom/rss-de-cul-musik', ] ] ]]; public function collectData() { $this->collectExpandableDatas($this->getInput('feed')); } protected function parseItem(array $item) { $parsedUri = parse_url($item['uri']); unset($parsedUri['query']); $item['uri'] = $this->unparseUrl($parsedUri); $page = getSimpleHTMLDOM($item['uri']); $page = defaultLinkTo($page, $item['uri']); $article = $page->find('article', 0); // author $author = $article->find('.author-link > span', 0); if ($author) { $item['author'] = $author->text(); } $teaser = $article->find('.teaser-text', 0); if (!is_null($teaser)) { $item['content'] = $teaser->outertext(); } else { $item['content'] = ''; } // remove unneeded elements foreach ( $article->find( 'header, .advertisement, [data-tracking-name="sharing-icons-inline"], a.external-link > svg, picture > source, .vjs-wrapper, .dw-widget, footer' ) as $bad ) { $bad->remove(); } // reload html as remove() is buggy $article = str_get_html($article->outertext()); // remove width and height values from img tags foreach ($article->find('img') as $img) { $img->width = null; $img->height = null; } // Remove inline SVG icons that are not part of the article content foreach ($article->find('svg') as $svg) { $svg->outertext = ''; } // replace lazy-loaded images foreach ($article->find('figure.placeholder-image') as $figure) { $img = $figure->find('img', 0); $img->src = str_replace('${formatId}', '906', $img->getAttribute('data-url')); $img->style = null; } $item['content'] .= $article->save(); return $item; } // https://www.php.net/manual/en/function.parse-url.php#106731 private function unparseUrl($parsed_url) { $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; $user = isset($parsed_url['user']) ? $parsed_url['user'] : ''; $pass = isset($parsed_url['pass']) ? $parsed_url['pass'] : ''; $pass = ($user || $pass) ? "$pass@" : ''; $path = isset($parsed_url['path']) ? $parsed_url['path'] : ''; $query = isset($parsed_url['query']) ? '?' . $parsed_url['query'] : ''; $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : ''; return "$scheme$user$pass$host$port$path$query$fragment"; } } ================================================ FILE: bridges/DeutscherAeroClubBridge.php ================================================ setTime(0, 0, 0); return $dti->getTimestamp(); } } ================================================ FILE: bridges/DevToBridge.php ================================================ [ 'tag' => [ 'name' => 'Tag', 'type' => 'text', 'required' => true, 'title' => 'Insert your tag', 'exampleValue' => 'python' ], 'full' => [ 'name' => 'Full article', 'type' => 'checkbox', 'required' => false, 'title' => 'Enable to receive the full article for each item' ] ], self::CONTEXT_BY_USER => [ 'user' => [ 'name' => 'User', 'type' => 'text', 'required' => true, 'title' => 'Insert your username', 'exampleValue' => 'n3wt0n' ], 'full' => [ '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; case self::CONTEXT_BY_USER: if ($user = $this->getInput('user')) { return static::URI . '/' . urlencode($user); } 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()); $html = defaultLinkTo($html, static::URI); $articles = $html->find('div.crayons-story') or throwServerException('Could not find articles!'); foreach ($articles as $article) { $item = []; $item['uri'] = $article->find('a[id*=article-link]', 0)->href; $item['title'] = $article->find('h2 > a', 0)->plaintext; $item['timestamp'] = $article->find('time', 0)->datetime; $item['author'] = $article->find('a.crayons-story__secondary.fw-medium', 0)->plaintext; // Profile image $item['enclosures'] = [$article->find('img', 0)->src]; if ($this->getInput('full')) { $fullArticle = $this->getFullArticle($item['uri']); $item['content'] = <<{$fullArticle}

EOD; } else { $item['content'] = <<

{$item['title']}

EOD; } // categories foreach ($article->find('a.crayons-tag') as $tag) { $item['categories'][] = str_replace('#', '', $tag->plaintext); } $this->items[] = $item; } } public function getName() { if (!is_null($this->getInput('tag'))) { return ucfirst($this->getInput('tag')) . ' - dev.to'; } return parent::getName(); } private function getFullArticle($url) { $html = getSimpleHTMLDOMCached($url); $html = defaultLinkTo($html, static::URI); if ($html->find('div.crayons-article__cover', 0)) { return $html->find('div.crayons-article__cover', 0) . $html->find('[id="article-body"]', 0); } return $html->find('[id="article-body"]', 0); } } ================================================ FILE: bridges/DeveloppezDotComBridge.php ================================================ [ 'name' => 'Max items', 'type' => 'number', 'defaultValue' => 5, ], // list of the differents RSS availables 'domain' => [ 'type' => 'list', 'name' => 'Domaine', 'title' => 'Chosissez un sous-domaine', 'values' => [ '= Domaine principal =' => 'www', '4d' => '4d', 'abbyy' => 'abbyy', 'access' => 'access', 'agile' => 'agile', 'ajax' => 'ajax', 'algo' => 'algo', 'alm' => 'alm', 'android' => 'android', 'apache' => 'apache', 'applications' => 'applications', 'arduino' => 'arduino', 'asm' => 'asm', 'asp' => 'asp', 'aspose' => 'aspose', 'bacasable' => 'bacasable', 'big-data' => 'big-data', 'bpm' => 'bpm', 'bsd' => 'bsd', 'business-intelligence' => 'business-intelligence', 'c' => 'c', 'cloud-computing' => 'cloud-computing', 'club' => 'club', 'cms' => 'cms', 'cpp' => 'cpp', 'crm' => 'crm', 'css' => 'css', 'd' => 'd', 'dart' => 'dart', 'data-science' => 'data-science', 'db2' => 'db2', 'delphi' => 'delphi', 'dotnet' => 'dotnet', 'droit' => 'droit', 'eclipse' => 'eclipse', 'edi' => 'edi', 'embarque' => 'embarque', 'emploi' => 'emploi', 'etudes' => 'etudes', 'excel' => 'excel', 'firebird' => 'firebird', 'flash' => 'flash', 'go' => 'go', 'green-it' => 'green-it', 'gtk' => 'gtk', 'hardware' => 'hardware', 'hpc' => 'hpc', 'humour' => 'humour', 'ibmcloud' => 'ibmcloud', 'intelligence-artificielle' => 'intelligence-artificielle', 'interbase' => 'interbase', 'ios' => 'ios', 'java' => 'java', 'javascript' => 'javascript', 'javaweb' => 'javaweb', 'jetbrains' => 'jetbrains', 'jeux' => 'jeux', 'kotlin' => 'kotlin', 'labview' => 'labview', 'laravel' => 'laravel', 'latex' => 'latex', 'lazarus' => 'lazarus', 'linux' => 'linux', 'mac' => 'mac', 'matlab' => 'matlab', 'megaoffice' => 'megaoffice', 'merise' => 'merise', 'microsoft' => 'microsoft', 'mobiles' => 'mobiles', 'mongodb' => 'mongodb', 'mysql' => 'mysql', 'netbeans' => 'netbeans', 'nodejs' => 'nodejs', 'nosql' => 'nosql', 'objective-c' => 'objective-c', 'office' => 'office', 'open-source' => 'open-source', 'openoffice-libreoffice' => 'openoffice-libreoffice', 'oracle' => 'oracle', 'outlook' => 'outlook', 'pascal' => 'pascal', 'perl' => 'perl', 'php' => 'php', 'portail-emploi' => 'portail-emploi', 'portail-projets' => 'portail-projets', 'postgresql' => 'postgresql', 'powerpoint' => 'powerpoint', 'preprod-emploi' => 'preprod-emploi', 'programmation' => 'programmation', 'project' => 'project', 'purebasic' => 'purebasic', 'pyqt' => 'pyqt', 'python' => 'python', 'qt-creator' => 'qt-creator', 'qt' => 'qt', 'r' => 'r', 'raspberry-pi' => 'raspberry-pi', 'reseau' => 'reseau', 'ruby' => 'ruby', 'rust' => 'rust', 'sap' => 'sap', 'sas' => 'sas', 'scilab' => 'scilab', 'securite' => 'securite', 'sgbd' => 'sgbd', 'sharepoint' => 'sharepoint', 'solutions-entreprise' => 'solutions-entreprise', 'spring' => 'spring', 'sqlserver' => 'sqlserver', 'stages' => 'stages', 'supervision' => 'supervision', 'swift' => 'swift', 'sybase' => 'sybase', 'symfony' => 'symfony', 'systeme' => 'systeme', 'talend' => 'talend', 'typescript' => 'typescript', 'uml' => 'uml', 'unix' => 'unix', 'vb' => 'vb', 'vba' => 'vba', 'virtualisation' => 'virtualisation', 'visualstudio' => 'visualstudio', 'web-semantique' => 'web-semantique', 'web' => 'web', 'webmarketing' => 'webmarketing', 'wind' => 'wind', 'windows-azure' => 'windows-azure', 'windows' => 'windows', 'windowsphone' => 'windowsphone', 'word' => 'word', 'xhtml' => 'xhtml', 'xml' => 'xml', 'zend-framework' => 'zend-framework' ], ] ] ]; /** * Grabs the RSS item from Developpez.com */ public function collectData() { $url = $this->getRssUrl(); $this->collectExpandableDatas($url, 20); } /** * Parse the content of every RSS item. And will try to get the full article * pointed by the item URL intead of the default abstract. */ protected function parseItem(array $item) { if (count($this->items) >= $this->getInput('limit')) { return null; } // There is a bug in Developpez RSS, coma are writtent as '~?' in the // title, so I have to fix it manually $item['title'] = $this->fixComaInTitle($item['title']); // We get the content of the full article behind the RSS item URL $articleHTMLContent = getSimpleHTMLDOMCached($item['uri']); // Here we call our custom parser $fullText = $this->extractFullText($articleHTMLContent); if (!is_null($fullText)) { // if we manage to parse the page behind the url of the RSS item // then we set it as the new content. Otherwise we keep the default // content to avoid RSS Bridge to return an empty item $item['content'] = $fullText; } // Now we will attach video url in item $videosUrl = $this->getAllVideoUrl($articleHTMLContent); if (!empty($videosUrl)) { $item['enclosures'] = array_merge($item['enclosures'], $videosUrl); } // Now we can look for the blog writer/creator $author = $articleHTMLContent->find('[itemprop="creator"]', 0); if (!empty($author)) { $item['author'] = $author->outertext; } return $item; } /** * Return the RSS url for selected domain */ private function getRssUrl() { $domain = $this->getInput('domain'); if (!empty($domain)) { return 'https://' . $domain . self::DOMAIN . self::RSS_URL; } return self::URI . self::RSS_URL; } /** * Replace '~?' by a proper coma ',' */ private function fixComaInTitle($txt) { return str_replace('~?', ',', $txt); } /** * Return the full article pointed by the url in the RSS item * Since Developpez.com only provides a short abstract of the article, we * use the url to retrieve the complete article and return it as the content */ private function extractFullText($articleHTMLContent) { // All blog entry contains a div with the class 'content'. This div // contains the complete blog article. But the RSS can also return // announcement and not a blog article. So the next if, should take // care of the "non blog" entry $divArticleEntry = $articleHTMLContent->find('div.content', 0); if (is_null($divArticleEntry)) { // Didn't find the div with class content. It is probably not a blog // entry. It is probably just an announcement for an ebook, a PDF, // etc. So we can use the default RSS item content. return null; } // The following code is a bit hacky, but I really manage to get the // full content of articles without any encoding issues. What is very // weird and ugly in Developpez.com is the fact the some paragraphs of // the article will be encoded as UTF-8 and some other paragraphs will // be encoded as Windows-1252. So we can NOT decode the full article // with only one encoding. We have to check every paragraph and // determine its encoding // This contains all the 'paragraphs' of the article. It includes the // pictures, the text and the links at the bottom of the article $paragraphs = $divArticleEntry->nodes; // This will store the complete decoded content $fullText = ''; // For each paragraph, we will identify the encoding, then decode it // and finally store the decoded content in $text foreach ($paragraphs as $paragraph) { // We have to recreate a new DOM document from the current node // otherwise the find function will look in the complet article and // not only in the current paragraph. This is an ugly behavior of // the library Simple HTML DOM Parser... $html = str_get_html($paragraph->outertext); $fullText .= $this->decodeParagraph($html); } // Finally we return the full 'well' enconded content of the article return $fullText; } /** * */ private function decodeParagraph($p) { // First we check if this paragraph is a video $videoUrl = $this->getVideoUrl($p); if (!empty($videoUrl)) { // If this is a video, we just return a link to the video // 📺 => 🎞️ return '

📺 Voir la vidéo

'; } // We take outertext to get the complete paragraph not only the text // inside it. That way we still graph block and so on. $pTxt = $p->outertext; // This will store the decoded text if we manage to decode it $decodedTxt = ''; // This is the only way to properly decode each paragraph. I tried // many stuffs but this is the only working way I found. foreach (self::ENCONDINGS as $enc) { // We check the encoding of the current paragraph if (mb_check_encoding($pTxt, $enc)) { // If the encoding is well recognized, we can convert from // this encoding to UTF-8 $decodedTxt = iconv($enc, 'UTF-8', $pTxt); } } // We should not trim the strings to avoid the to be glued to the // text like: the softwarestartedto... if (!empty($decodedTxt)) { // We manage to decode the text, so we take the decoded version return $this->formatParagraph($decodedTxt); } else { // Otherwise we take the non decoded version and hope it will // be displayed not too ugly in the fulltext content return $this->formatParagraph($pTxt); } } /** * Return true in $txt is a HTML tag and not plain text */ private function isHtmlTagNotTxt($txt) { if ($txt === '') { return false; } $html = str_get_html($txt); return $html && $html->root && count($html->root->children) > 0; } /** * Will add a space before paragraph when needed */ private function formatParagraph($txt) { // If the paragraph is an html tag, we add a space before if ($this->isHtmlTagNotTxt($txt)) { // the first element is an html tag and not a text, so we can add a // space before it return ' ' . $txt; } // If the text start with word (not punctation), we had a space $pattern = '/^\w/'; if (preg_match($pattern, $txt)) { return ' ' . $txt; } return $txt; } /** * Retrieve all video url in the article */ private function getAllVideoUrl($item) { // Array of video url $url = []; // Developpez use a div with the class video-container $divsVideo = $item->find('div.video-container'); if (empty($divsVideo)) { return $url; } // get the url of the video foreach ($divsVideo as $div) { $html = str_get_html($div->outertext); $url[] = $this->getVideoUrl($html); } return $url; } /** * Retrieve URL video. We have to check for the src of an iframe * Work for Youtube. Will have to test for other video platform */ private function getVideoUrl($p) { $divVideo = $p->find('div.video-container', 0); if (empty($divVideo)) { return null; } $iframe = $divVideo->find('iframe', 0); if (empty($iframe)) { return null; } $src = trim($iframe->getAttribute('src')); if (empty($src)) { return null; } if (str_starts_with($src, '//')) { $src = 'https:' . $src; } return $src; } } ================================================ FILE: bridges/DiarioDeNoticiasBridge.php ================================================ [ 'n' => [ 'name' => 'Tag Name', 'required' => true, 'exampleValue' => 'rogerio-casanova', ] ] ]; const MONPT = [ 'jan', 'fev', 'mar', 'abr', 'mai', 'jun', 'jul', 'ago', 'set', 'out', 'nov', 'dez', ]; public function getIcon() { return 'https://static.globalnoticias.pt/dn/common/images/favicons/favicon-128.png'; } public function getName() { switch ($this->queriedContext) { case 'Tag': $name = self::NAME . ' | Tag | ' . $this->getInput('n'); break; default: $name = self::NAME; } return $name; } public function getURI() { switch ($this->queriedContext) { case 'Tag': $url = self::URI . '/tag/' . $this->getInput('n') . '.html'; break; default: $url = self::URI; } return $url; } public function collectData() { $archives = $this->getURI(); $html = getSimpleHTMLDOMCached($archives); foreach ($html->find('article') as $element) { $item = []; $title = $element->find('.t-am-title', 0); $link = $element->find('a.t-am-text', 0); $item['title'] = $title->plaintext; $item['uri'] = self::URI . $link->href; $snippet = $element->find('.t-am-lead', 0); if ($snippet) { $item['content'] = $snippet->plaintext; } preg_match('|edicao-do-dia\\/(?P\d\d)-(?P\w\w\w)-(?P\d\d\d\d)|', $link->href, $d); if ($d) { $item['timestamp'] = sprintf('%s-%s-%s', $d['year'], array_search($d['monpt'], self::MONPT) + 1, $d['day']); } $this->items[] = $item; } } } ================================================ FILE: bridges/DiarioDoAlentejoBridge.php ================================================ 30s!), keep the cache timeout high to avoid killing the host */ $html = getSimpleHTMLDOMCached($this->getURI() . '/pt/noticias-listagem.aspx'); foreach ($html->find('.list_news .item') as $element) { $item = []; $item_link = $element->find('.body h2.title a', 0); /* Another broken URL, see also `bridges/ComboiosDePortugalBridge.php` */ $item['uri'] = self::URI . implode('/', array_map('urlencode', explode('/', $item_link->href))); $item['title'] = $item_link->innertext; $item['timestamp'] = str_ireplace( array_map(function ($name) { return ' ' . $name . ' '; }, self::PT_MONTH_NAMES), array_map(function ($num) { return sprintf('-%02d-', $num); }, range(1, count(self::PT_MONTH_NAMES))), $element->find('span.date', 0)->innertext ); /* Fix the Image URL */ $item_image = $element->find('img.thumb', 0); $item_image->src = preg_replace('/.*&img=([^&]+).*/', '\1', $item_image->getAttribute('data-src')); /* Content: */ /* - Image */ /* - Category */ $content = $item_image . '
' . $element->find('a.category', 0) . '
'; $item['content'] = defaultLinkTo($content, self::URI); $this->items[] = $item; } } } ================================================ FILE: bridges/DiceBridge.php ================================================ [ 'name' => 'With at least one of the words', 'required' => false, ], 'for_all' => [ 'name' => 'With all of the words', 'required' => false, ], 'for_exact' => [ 'name' => 'With the exact phrase', 'required' => false, ], 'for_none' => [ 'name' => 'With none of these words', 'required' => false, ], 'for_jt' => [ 'name' => 'Within job title', 'required' => false, ], 'for_com' => [ 'name' => 'Within company name', 'required' => false, ], 'for_loc' => [ 'name' => 'City, State, or ZIP code', 'required' => false, ], 'radius' => [ 'name' => 'Radius in miles', 'type' => 'list', 'required' => false, 'values' => [ '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' => [ 'name' => 'Job type', 'type' => 'list', 'required' => false, 'values' => [ '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' => [ '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); foreach ($html->find('div.complete-serp-result-div') as $element) { $item = []; // 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; } } } ================================================ FILE: bridges/DiscogsBridge.php ================================================ [ 'artistid' => [ 'name' => 'Artist ID', 'type' => 'number', 'required' => true, 'exampleValue' => '28104', 'title' => 'Only the ID from an artist page. EG /artist/28104-Aesop-Rock is 28104' ], 'image' => [ 'name' => 'Include Image', 'type' => 'checkbox', 'defaultValue' => 'checked', 'title' => 'Whether to include image (if bridge is configured with a personal access token)', ] ], 'Label Releases' => [ 'labelid' => [ 'name' => 'Label ID', 'type' => 'number', 'required' => true, 'exampleValue' => '8201', 'title' => 'Only the ID from a label page. EG /label/8201-Rhymesayers-Entertainment is 8201' ], 'image' => [ 'name' => 'Include Image', 'type' => 'checkbox', 'defaultValue' => 'checked', 'title' => 'Whether to include image (if bridge is configured with a personal access token)', ] ], 'User Wantlist' => [ 'username_wantlist' => [ 'name' => 'Username', 'type' => 'text', 'required' => true, 'exampleValue' => 'TheBlindMaster', ], 'image' => [ 'name' => 'Include Image', 'type' => 'checkbox', 'defaultValue' => 'checked', 'title' => 'Whether to include image (if bridge is configured with a personal access token)', ] ], 'User Folder' => [ 'username_folder' => [ 'name' => 'Username', 'type' => 'text', ], 'folderid' => [ 'name' => 'Folder ID', 'type' => 'number', ], 'image' => [ 'name' => 'Include Image', 'type' => 'checkbox', 'defaultValue' => 'checked', 'title' => 'Whether to include image (if bridge is configured with a personal access token)', ] ], ]; const CONFIGURATION = [ /** * When a personal access token is provided, Discogs' API will * return images as part of artist and label information. * * @see https://www.discogs.com/settings/developers */ 'personal_access_token' => [ 'required' => false, ], ]; public function collectData() { $headers = []; if ($this->getOption('personal_access_token')) { $headers = ['Authorization: Discogs token=' . $this->getOption('personal_access_token')]; } if (!empty($this->getInput('artistid')) || !empty($this->getInput('labelid'))) { if (!empty($this->getInput('artistid'))) { $url = 'https://api.discogs.com/artists/' . $this->getInput('artistid') . '/releases?sort=year&sort_order=desc'; $data = getContents($url, $headers); } elseif (!empty($this->getInput('labelid'))) { $url = 'https://api.discogs.com/labels/' . $this->getInput('labelid') . '/releases?sort=year&sort_order=desc'; $data = getContents($url, $headers); } $jsonData = json_decode($data, true); foreach ($jsonData['releases'] as $release) { $item = []; $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']; if (isset($release['thumb']) && $this->getInput('image') === true) { $item['content'] = sprintf( '

%s', $release['thumb'], $item['content'], ); } $this->items[] = $item; } } elseif (!empty($this->getInput('username_wantlist')) || !empty($this->getInput('username_folder'))) { if (!empty($this->getInput('username_wantlist'))) { $url = 'https://api.discogs.com/users/' . $this->getInput('username_wantlist') . '/wants?sort=added&sort_order=desc'; $data = getContents($url, $headers); $jsonData = json_decode($data, true)['wants']; } elseif (!empty($this->getInput('username_folder'))) { $url = 'https://api.discogs.com/users/' . $this->getInput('username_folder') . '/collection/folders/' . $this->getInput('folderid') . '/releases?sort=added&sort_order=desc'; $data = getContents($url, $headers); $jsonData = json_decode($data, true)['releases']; } foreach ($jsonData as $element) { $infos = $element['basic_information']; $item = []; $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']; if (isset($infos['thumb']) && $this->getInput('image') === true) { $item['content'] = sprintf( '

%s', $infos['thumb'], $item['content'], ); } $this->items[] = $item; } } } public function getURI() { return self::URI; } public function getName() { return static::NAME; } } ================================================ FILE: bridges/DjMagDotComBridge.php ================================================ [ 'name' => 'Limit', 'type' => 'number', 'title' => 'The number of news to get (max: 20)', 'defaultValue' => 10 ] ] ]; public function getIcon() { return 'https://djmag.com/sites/default/files/favicons/favicon-32x32.png?v=2024'; } public function getURI() { return self::URI . 'news'; } private function parseDateString($dateString) { // Expect formats like "30 December 2025, 12:10" $dateString = trim($dateString); // Try a strict parse first: day (no leading zero) monthname year, 24h:minute $dt = DateTime::createFromFormat('j F Y, H:i', $dateString); if ($dt instanceof DateTime) { return $dt->getTimestamp(); } // Try with leading zero day $dt = DateTime::createFromFormat('d F Y, H:i', $dateString); if ($dt instanceof DateTime) { return $dt->getTimestamp(); } // Fallback to strtotime which handles many human-readable formats $ts = strtotime($dateString); if ($ts !== false) { return $ts; } return null; } private function fetchArticleDetails($uri, $image, $title) { $itemHtml = getSimpleHTMLDOM($uri); $content = '

' . $itemHtml->find('article div.article--standfirst p', 0)->plaintext . '


'; $content .= '' . htmlentities($title) . '

'; $content .= '

' . trim(nl2br(htmlentities($itemHtml->find('article div.content-column-wrap-oh > div > div.field--name-field-content > div', 0)->plaintext))) . '

'; $metaFields = $itemHtml->find('article div.pane-author-info', 1); // contains a timestamp in a format like 30 December 2025, 12:10 $rawTimestamp = $metaFields->find('div', 1)->plaintext; $timestamp = $this->parseDateString($rawTimestamp); $author = trim($metaFields->find('div', 0)->plaintext); return [$timestamp, $content, $author]; } public function collectData() { $limit = max(0, min($this->getInput('limit'), 20)); $url = $this->getUri(); $mainHtml = getSimpleHTMLDOM($url); // fetch first/latest news item separately as it is structured differently due to being featured $firstNewsItemHtml = $mainHtml->find('div.attachment-before div.view-content', 0); $title = trim($firstNewsItemHtml->find('h1 > a', 0)->plaintext); $uri = self::URI . $firstNewsItemHtml->find('h1 > a', 0)->href; $image = rtrim(self::URI, '/') . $firstNewsItemHtml->find('.teaser-media source', 0)->srcset; list($timestamp, $content, $author) = $this->fetchArticleDetails($uri, $image, $title); $this->items[] = [ 'title' => $title, 'uri' => $uri, 'uid' => sha1($uri), 'thumbnail' => $image, 'content' => $content, 'timestamp' => $timestamp, 'author' => $author, 'categories' => ['NEWS'], 'enclosures' => [$image], ]; // continue with the rest of the news items foreach ($mainHtml->find('div#views-bootstrap-listing-news-page > div.row article') as $newsItem) { if ($limit-- <= 0) { break; } $title = trim($newsItem->find('h1 > a', 0)->plaintext); $uri = self::URI . $newsItem->find('a', 0)->href; $image = rtrim(self::URI, '/') . $newsItem->find('source', 0)->srcset; list($timestamp, $content, $author) = $this->fetchArticleDetails($uri, $image, $title); $this->items[] = [ 'title' => $title, 'uri' => $uri, 'uid' => sha1($uri), 'thumbnail' => $image, 'content' => $content, 'timestamp' => $timestamp, 'author' => $author, 'categories' => ['NEWS'], 'enclosures' => [$image], ]; } } } ================================================ FILE: bridges/DockerHubBridge.php ================================================ [ 'user' => [ 'name' => 'User', 'type' => 'text', 'required' => true, 'exampleValue' => 'rssbridge', ], 'repo' => [ 'name' => 'Repository', 'type' => 'text', 'required' => true, 'exampleValue' => 'rss-bridge', ], 'filter' => [ 'name' => 'Filter tag', 'type' => 'text', 'required' => false, 'exampleValue' => 'latest', ] ], 'Official Image' => [ 'repo' => [ 'name' => 'Repository', 'type' => 'text', 'required' => true, 'exampleValue' => 'postgres', ], 'filter' => [ 'name' => 'Filter tag', 'type' => 'text', 'required' => false, 'exampleValue' => 'alpine3.17', ] ] ]; const CACHE_TIMEOUT = 3600; // 1 hour private $apiURL = 'https://hub.docker.com/v2/repositories/'; private $imageUrlRegex = '/hub\.docker\.com\/r\/([\w]+)\/([\w-]+)\/?/'; private $officialImageUrlRegex = '/hub\.docker\.com\/_\/([\w-]+)\/?/'; public function detectParameters($url) { $params = []; // user submitted image if (preg_match($this->imageUrlRegex, $url, $matches)) { $params['context'] = 'User Submitted Image'; $params['user'] = $matches[1]; $params['repo'] = $matches[2]; return $params; } // official image if (preg_match($this->officialImageUrlRegex, $url, $matches)) { $params['context'] = 'Official Image'; $params['repo'] = $matches[1]; return $params; } return null; } public function collectData() { $json = getContents($this->getApiUrl()); $data = json_decode($json, false); foreach ($data->results as $result) { $item = []; $lastPushed = date('Y-m-d H:i:s', strtotime($result->tag_last_pushed)); $item['title'] = $result->name; $item['uid'] = $result->id; $item['uri'] = $this->getTagUrl($result->name); $item['author'] = $result->last_updater_username; $item['timestamp'] = $result->tag_last_pushed; $item['content'] = <<Tag

{$result->name}

Last pushed

{$lastPushed}

Images
{$this->getImagesTable($result)} EOD; $this->items[] = $item; } } public function getURI() { $uri = parent::getURI(); if ($this->queriedContext === 'Official Image') { $uri = self::URI . '/_/' . $this->getRepo(); } if ($this->queriedContext === 'User Submitted Image') { $uri = '/r/' . $this->getRepo(); } if ($this->getInput('filter')) { $uri .= '/tags/?&page=1&name=' . $this->getInput('filter'); } return $uri; } public function getName() { if ($this->getInput('repo')) { $name = $this->getRepo(); if ($this->getInput('filter')) { $name .= ':' . $this->getInput('filter'); } return $name . ' - Docker Hub'; } return parent::getName(); } private function getRepo() { if ($this->queriedContext === 'Official Image') { return $this->getInput('repo'); } return $this->getInput('user') . '/' . $this->getInput('repo'); } private function getApiUrl() { $url = ''; if ($this->queriedContext === 'Official Image') { $url = $this->apiURL . 'library/' . $this->getRepo() . '/tags/?page_size=25&page=1'; } if ($this->queriedContext === 'User Submitted Image') { $url = $this->apiURL . $this->getRepo() . '/tags/?page_size=25&page=1'; } if ($this->getInput('filter')) { $url .= '&name=' . $this->getInput('filter'); } return $url; } private function getLayerUrl($name, $digest) { if ($this->queriedContext === 'Official Image') { return self::URI . '/layers/' . $this->getRepo() . '/library/' . $this->getRepo() . '/' . $name . '/images/' . $digest; } return self::URI . '/layers/' . $this->getRepo() . '/' . $name . '/images/' . $digest; } private function getTagUrl($name) { $url = ''; if ($this->queriedContext === 'Official Image') { $url = self::URI . '/_/' . $this->getRepo(); } if ($this->queriedContext === 'User Submitted Image') { $url = self::URI . '/r/' . $this->getRepo(); } return $url . '/tags/?&name=' . $name; } private function getImagesTable($result) { $data = ''; foreach ($result->images as $image) { $layersUrl = $this->getLayerUrl($result->name, $image->digest); $id = $this->getShortDigestId($image->digest); $size = format_bytes($image->size); $data .= << {$id} {$image->os}/{$image->architecture} {$size} EOD; } return << Digest OS/architecture Compressed Size {$data} EOD; } private function getShortDigestId($digest) { $parts = explode(':', $digest); return substr($parts[1], 0, 12); } } ================================================ FILE: bridges/DonnonsBridge.php ================================================ [ 'name' => 'Url de recherche', 'required' => true, 'exampleValue' => '/Sport/Ile-de-France', 'pattern' => '\/.*', 'title' => 'Faites une recherche sur le site. Puis copiez ici la fin de l’url. Doit commencer par /', ], 'p' => [ 'name' => 'Nombre de pages à scanner', 'type' => 'number', 'required' => true, 'defaultValue' => 5, 'title' => 'Indique le nombre de pages de donnons.org qui seront scannées' ] ] ]; public function collectData() { $pages = $this->getInput('p'); for ($i = 1; $i <= $pages; $i++) { $this->collectDataByPage($i); } } private function collectDataByPage($page) { $uri = $this->getPageURI($page); $dom = getSimpleHTMLDOM($uri); $searchDiv = $dom->find('div[id=search]', 0); if (! $searchDiv) { return; } $elements = $searchDiv->find('a.lst-annonce'); foreach ($elements as $element) { $item = []; // Lien vers le don $item['uri'] = self::URI . $element->href; // Id de l'objet $item['uid'] = $element->getAttribute('data-id'); // Grab info from json $jsonString = $element->find('script', 0)->innertext; $json = json_decode($jsonString, true); $name = $json['name']; $category = $json['category']; $date = $json['availabilityStarts']; $description = $json['description']; $city = $json['availableAtOrFrom']['address']['addressLocality']; $region = $json['availableAtOrFrom']['address']['addressRegion']; // Grab info from HTML $imageSrc = $element->find('img.ima-center', 0)->getAttribute('src'); // Use large image instead of small one $imageSrc = str_replace('/xs/', '/lg/', $imageSrc); $image = self::URI . $imageSrc; $author = $element->find('div.avatar-holder', 0)->plaintext; $content = '

' . $name . '

' . $description . '

Lieu : ' . $city . ' - ' . $region . '

Par : ' . $author . '

Date : ' . $date . '

'; // Titre du don $item['title'] = '[' . $category . '] ' . $name; $item['timestamp'] = $date; $item['author'] = $author; $item['content'] = $content; $item['enclosures'] = [$image]; $this->items[] = $item; } } private function getPageURI($page) { $uri = $this->getURI(); $haveQueryParams = strpos($uri, '?') !== false; if ($haveQueryParams) { return $uri . '&page=' . $page; } else { return $uri . '?page=' . $page; } } public function getURI() { if (!is_null($this->getInput('q'))) { return self::URI . $this->getInput('q'); } return parent::getURI(); } public function getName() { if (!is_null($this->getInput('q'))) { return 'Donnons.org - ' . $this->getInput('q'); } return parent::getName(); } } ================================================ FILE: bridges/DoujinStyleBridge.php ================================================ [], 'Randomly selected items' => [], 'From search results' => [ 'query' => [ 'name' => 'Search query', 'required' => true, 'exampleValue' => 'FELT', ], 'flac' => [ 'name' => 'Include FLAC', 'type' => 'checkbox', 'defaultValue' => false, ], 'mp3' => [ 'name' => 'Include MP3', 'type' => 'checkbox', 'defaultValue' => false, ], 'tta' => [ 'name' => 'Include TTA', 'type' => 'checkbox', 'defaultValue' => false, ], 'opus' => [ 'name' => 'Include Opus', 'type' => 'checkbox', 'defaultValue' => false, ], 'ogg' => [ 'name' => 'Include OGG', 'type' => 'checkbox', 'defaultValue' => false, ] ] ]; public function collectData() { $html = getSimpleHTMLDOM($this->getURI()); $html = defaultLinkTo($html, $this->getURI()); $submissions = $html->find('.gridBox .gridDetails'); foreach ($submissions as $submission) { $item = []; $item['uri'] = $submission->find('a', 0)->href; $content = getSimpleHTMLDOM($item['uri']); $content = defaultLinkTo($content, $this->getURI()); $title = $content->find('h2', 0)->plaintext; $cover = $content->find('#imgClick a', 0); if (is_null($cover)) { $cover = $content->find('.coverWrap', 0)->src; } else { $cover = $cover->href; } $item['content'] = ""; $keys = []; foreach ($content->find('.pageWrap .pageSpan1') as $key) { $keys[] = $key->plaintext; } $values = $content->find('.pageWrap .pageSpan2'); $metadata = array_combine($keys, $values); $format = 'Unknown'; foreach ($metadata as $key => $value) { switch ($key) { case 'Artist': $artist = $value->find('a', 0)->plaintext; $item['title'] = "$artist - $title"; $item['content'] .= "
Artist: $artist"; break; case 'Tags:': $item['categories'] = []; foreach ($value->find('a') as $tag) { $tag = str_replace('-', '-', $tag->plaintext); $item['categories'][] = $tag; } $item['content'] .= '
Tags: ' . join(', ', $item['categories']); break; case 'Format:': $item['content'] .= "
Format: $value->plaintext"; break; case 'Date Added:': $item['timestamp'] = $value->plaintext; break; case 'Provided By:': $item['author'] = $value->find('a', 0)->plaintext; break; } } $this->items[] = $item; } } public function getURI() { $url = self::URI; switch ($this->queriedContext) { case 'From search results': $url .= '?p=search&type=blanket'; $url .= '&result=' . $this->getInput('query'); if ($this->getInput('flac') == 1) { $url .= '&format0=on'; } if ($this->getInput('mp3') == 1) { $url .= '&format1=on'; } if ($this->getInput('tta') == 1) { $url .= '&format2=on'; } if ($this->getInput('opus') == 1) { $url .= '&format3=on'; } if ($this->getInput('ogg') == 1) { $url .= '&format4=on'; } break; case 'Randomly selected items': $url .= '?p=random'; break; } return $url; } } ================================================ FILE: bridges/DribbbleBridge.php ================================================ fetchData($html); foreach ($html->find('li[id^="screenshot-"]') as $shot) { $item = []; $additional_data = $this->findJsonForShot($shot, $data); if ($additional_data === null) { $item['uri'] = self::URI . $shot->find('a', 0)->href; $item['title'] = $shot->find('.shot-title', 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('.user-information .display-name', 0)->plaintext); $description = $shot->find('.comment', 0); $item['content'] = $description === null ? '' : $description->plaintext; $preview_path = $shot->find('figure img', 1)->attr['data-srcset']; $item['content'] .= $this->getImageTag($preview_path, $item['title']); $item['enclosures'] = [$this->getFullSizeImagePath($preview_path)]; $this->items[] = $item; } } private function fetchData($html) { $scripts = $html->find('script'); foreach ($scripts as $script) { if (strpos($script->innertext, 'newestShots') !== false) { // fix single quotes $script->innertext = preg_replace('/\'(.*)\'(,?)$/im', '"\1"\2', $script->innertext); // fix JavaScript JSON (why do they not adhere to the standard?) $script->innertext = preg_replace('/^(\s*)(\w+):/im', '\1"\2":', $script->innertext); // fix relative dates, so they are recognized by strtotime $script->innertext = preg_replace('/"about ([0-9]+ hours? ago)"(,?)$/im', '"\1"\2', $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 = substr($script->innertext, $start, $end - $start); try { // TODO: fix broken json return Json::decode($json); } catch (\JsonException $e) { return []; } } } return []; } 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( '
%s', $this->getFullSizeImagePath($preview_path), $preview_path, $title ); } private function getFullSizeImagePath($preview_path) { // Get last image from srcset $src_set_urls = explode(',', $preview_path); $url = end($src_set_urls); $url = explode(' ', $url)[1]; return htmlspecialchars_decode($url); } } ================================================ FILE: bridges/Drive2ruBridge.php ================================================ [], 'Бортжурналы (По модели или марке)' => [ 'url' => [ 'name' => 'Ссылка на страницу с бортжурналом', 'type' => 'text', 'required' => true, 'title' => 'Например: https://www.drive2.ru/experience/suzuki/g4895/', 'exampleValue' => 'https://www.drive2.ru/experience/suzuki/g4895/' ], ], 'Личные блоги' => [ 'username' => [ 'name' => 'Никнейм пользователя на сайте', 'type' => 'text', 'required' => true, 'title' => 'Например: Mickey', 'exampleValue' => 'Mickey' ] ], 'Публикации по темам (Стоит почитать)' => [ 'topic' => [ 'name' => 'Темы', 'type' => 'list', 'values' => [ 'Автозвук' => '16', 'Автомобильный дизайн' => '10', 'Автоспорт' => '11', 'Автошоу, музеи, выставки' => '12', 'Безопасность' => '18', 'Беспилотные автомобили' => '15', 'Видеосюжеты' => '20', 'Вне дорог' => '21', 'Встречи' => '22', 'Выбор и покупка машины' => '23', 'Гаджеты' => '30', 'Гибридные машины' => '32', 'Грузовики, автобусы, спецтехника' => '31', 'Доработка интерьера' => '35', 'Законодательство' => '40', 'История автомобилестроения' => '50', 'Мототехника' => '60', 'Новые модели и концепты' => '85', 'Обучение вождению' => '70', 'Путешествия' => '80', 'Ремонт и обслуживание' => '90', 'Реставрация ретро-авто' => '91', 'Сделай сам' => '104', 'Смешное' => '103', 'Спорткары' => '102', 'Стайлинг' => '101', 'Тест-драйвы' => '110', 'Тюнинг' => '111', 'Фотосессии' => '120', 'Шины и диски' => '140', 'Электрика' => '130', 'Электромобили' => '131' ], 'defaultValue' => '16', ] ], 'global' => [ 'full_articles' => [ 'name' => 'Загружать в ленту полный текст', 'type' => 'checkbox' ] ] ]; private $title; private function getUserContent($url) { $html = getSimpleHTMLDOM($url); $this->title = $html->find('title', 0)->innertext; $articles = $html->find('div.js-entity'); foreach ($articles as $article) { $item = []; $item['title'] = $article->find('a.c-link--text', 0)->plaintext; $item['uri'] = urljoin(self::URI, $article->find('a.c-link--text', 0)->href); if ($this->getInput('full_articles')) { $item['content'] = $this->addCommentsLink( $this->adjustContent(getSimpleHTMLDomCached($item['uri'])->find('div.c-post__body', 0))->innertext, $item['uri'] ); } else { $item['content'] = $this->addReadMoreLink($article->find('div.c-post-preview__lead', 0), $item['uri']); } $item['author'] = $article->find('a.c-username--wrap', 0)->plaintext; if (!is_null($article->find('img', 1))) { $item['enclosures'][] = $article->find('img', 1)->src; } $this->items[] = $item; } } private function getLogbooksContent($url) { $html = getSimpleHTMLDOM($url); $this->title = $html->find('title', 0)->innertext; $articles = $html->find('div.js-entity'); foreach ($articles as $article) { $item = []; $item['title'] = $article->find('a.c-link--text', 1)->plaintext; $item['uri'] = urljoin(self::URI, $article->find('a.c-link--text', 1)->href); if ($this->getInput('full_articles')) { $item['content'] = $this->addCommentsLink( $this->adjustContent(getSimpleHTMLDomCached($item['uri'])->find('div.c-post__body', 0))->innertext, $item['uri'] ); } else { $item['content'] = $this->addReadMoreLink($article->find('div.c-post-preview__lead', 0), $item['uri']); } $item['author'] = $article->find('a.c-username--wrap', 0)->plaintext; if (!is_null($article->find('img', 1))) { $item['enclosures'][] = $article->find('img', 1)->src; } $this->items[] = $item; } } private function getNews() { $html = getSimpleHTMLDOM('https://www.drive2.ru/editorial/'); $this->title = $html->find('title', 0)->innertext; $articles = $html->find('div.c-article-card'); foreach ($articles as $article) { $item = []; $item['title'] = $article->find('a.c-link--text', 0)->plaintext; $item['uri'] = urljoin(self::URI, $article->find('a.c-link--text', 0)->href); if ($this->getInput('full_articles')) { $item['content'] = $this->addCommentsLink( $this->adjustContent(getSimpleHTMLDomCached($item['uri'])->find('div.article', 0))->innertext, $item['uri'] ); } else { $item['content'] = $this->addReadMoreLink($article->find('div.c-article-card__lead', 0), $item['uri']); } $item['author'] = 'Новости и тест-драйвы на Drive2.ru'; if (!is_null($article->find('img', 0))) { $item['enclosures'][] = $article->find('img', 0)->src; } $this->items[] = $item; } } private function adjustContent($content) { foreach ($content->find('div.o-group') as $node) { $node->outertext = ''; } foreach ($content->find('div, span') as $attrs) { foreach ($attrs->getAllAttributes() as $attr => $val) { $attrs->removeAttribute($attr); } } foreach ($content->getElementsByTagName('figcaption') as $attrs) { $attrs->setAttribute( 'style', 'font-style: italic; font-size: small; margin: 0 100px 75px;' ); } foreach ($content->find('script') as $node) { $node->outertext = ''; } foreach ($content->find('iframe') as $node) { $node->outertext = handleYoutube($node->src); } return $content; } private function addCommentsLink($content, $url) { return $content . '
Перейти к комментариям'; } private function addReadMoreLink($content, $url) { if (!is_null($content)) { return preg_replace('!\s+!', ' ', str_replace('Читать дальше', '', $content->plaintext)) . '
Читать далее'; } else { return ''; } } public function collectData() { switch ($this->queriedContext) { default: case 'Новости и тест-драйвы': $this->getNews(); break; case 'Бортжурналы (По модели или марке)': if (!preg_match('/^https:\/\/www.drive2.ru\/experience/', $this->getInput('url'))) { throwServerException('Invalid url'); } $this->getLogbooksContent($this->getInput('url')); break; case 'Личные блоги': if (!preg_match('/^[a-zA-Z0-9-]{3,16}$/', $this->getInput('username'))) { throwServerException('Invalid username'); } $this->getUserContent('https://www.drive2.ru/users/' . $this->getInput('username')); break; case 'Публикации по темам (Стоит почитать)': $this->getUserContent('https://www.drive2.ru/topics/' . $this->getInput('topic')); break; } } public function getName() { return $this->title ?: parent::getName(); } public function getIcon() { return 'https://www.drive2.ru/favicon.ico'; } } ================================================ FILE: bridges/DuckDuckGoBridge.php ================================================ [ 'name' => 'keyword', 'exampleValue' => 'duck', 'required' => true ], 'sort' => [ 'name' => 'sort by', 'type' => 'list', 'required' => false, 'values' => [ 'date' => self::SORT_DATE, 'relevance' => self::SORT_RELEVANCE ], 'defaultValue' => self::SORT_DATE ] ]]; public function collectData() { $query = [ 'kd' => '-1', 'q' => $this->getInput('u') . $this->getInput('sort'), ]; $url = 'https://duckduckgo.com/html/?' . http_build_query($query); $html = getSimpleHTMLDOM($url); foreach ($html->find('div.result') as $element) { $item = []; $item['uri'] = $element->find('a.result__a', 0)->href; $item['title'] = $element->find('h2.result__title', 0)->plaintext; $snippet = $element->find('a.result__snippet', 0); if ($snippet) { $item['content'] = $snippet->plaintext; } $this->items[] = $item; } } } ================================================ FILE: bridges/DuvarOrgBridge.php ================================================ [ 'name' => 'Limit', 'type' => 'number', 'required' => true, 'title' => 'Maximum number of items to return', 'defaultValue' => 20, ], 'urlsuffix' => [ 'name' => 'URL Suffix', 'type' => 'list', 'title' => 'Suffix for the URL to scrape a specific section', 'defaultValue' => 'Main', 'values' => [ 'Main' => '', 'Balanced' => '/uyumlu', 'Protest' => '/muhalif', 'Center' => '/merkez', 'Alternative' => '/alternatif', 'Global' => '/global', ], ], ]]; public function collectData() { $postCount = $this->getInput('postcount'); $urlSuffix = $this->getInput('urlsuffix'); $url = self::URI . $urlSuffix; $html = getSimpleHTMLDOM($url); foreach ($html->find('article.news-item') as $data) { if ($data === null) { continue; } try { $item = []; $linkElement = $data->find('h2.news-title a', 0); $titleElement = $data->find('h2.news-title a', 0); $timestampElement = $data->find('time.meta-tag.date-tag', 0); $contentElement = $data->find('div.news-description', 0); if ($linkElement) { $item['uri'] = $linkElement->getAttribute('href'); } else { continue; } if ($titleElement) { $item['title'] = trim($titleElement->plaintext); } else { continue; } if ($timestampElement) { $item['timestamp'] = strtotime($timestampElement->plaintext); } else { $item['timestamp'] = time(); } if ($contentElement) { $item['content'] = trim($contentElement->plaintext); } else { $item['content'] = ''; } $item['uid'] = hash('sha256', $item['title']); $this->items[] = $item; if (count($this->items) >= $postCount) { break; } } catch (Exception $e) { continue; } } } } ================================================ FILE: bridges/EASeedBridge.php ================================================ find('ea-grid', 0); if (!$dom) { throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); } $dom = defaultLinkTo($dom, $this->getURI()); foreach ($dom->find('ea-tile') as $article) { $a = $article->find('a', 0); $date = $article->find('div', 1)->plaintext; $title = $article->find('h3', 0)->plaintext; $author = $article->find('div', 0)->plaintext; $entry = getSimpleHTMLDOMCached($a->href, static::CACHE_TIMEOUT * 7 * 4); $content = $entry->find('main', 0); // remove header and links to other posts $content->find('ea-header', 0)->outertext = ''; $content->find('ea-section', -1)->outertext = ''; $this->items[] = [ 'title' => $title, 'author' => $author, 'uri' => $a->href, 'content' => $content, 'timestamp' => strtotime($date), ]; } } } ================================================ FILE: bridges/EBayBridge.php ================================================ [ 'name' => 'Search URL', 'title' => 'Copy the URL from your browser\'s address bar after searching for your items and paste it here', 'pattern' => '^(https:\/\/)?(www\.)?(befr\.|benl\.)?ebay\.(com|com\.au|at|be|ca|ch|cn|es|fr|de|com\.hk|ie|it|com\.my|nl|ph|pl|com\.sg|co\.uk)\/.*$', 'exampleValue' => 'https://www.ebay.com/sch/i.html?_nkw=atom+rss', 'required' => true, ], 'includesSearchLink' => [ 'name' => 'Include Original Search Link', 'title' => 'Whether or not each feed item should include the original search query link to eBay which was used to find the given listing.', 'type' => 'checkbox', 'defaultValue' => false, ], ]]; public function getURI() { if ($this->getInput('url')) { # make sure we order by the most recently listed offers $uri = trim(preg_replace('/([?&])_sop=[^&]+(&|$)/', '$1', $this->getInput('url')), '?&/'); $uri .= (parse_url($uri, PHP_URL_QUERY) ? '&' : '?') . '_sop=10'; // Ensure the List View is used instead of the Gallery View. $uri = trim(preg_replace('/[?&]_dmd=[^&]+(&|$)/i', '$1', $uri), '?&/'); $uri .= '&_dmd=1'; return $uri; } else { return parent::getURI(); } } public function getName() { $url = $this->getInput('url'); if (!$url) { return parent::getName(); } $urlQueries = explode('&', parse_url($url, PHP_URL_QUERY)); $searchQuery = array_reduce($urlQueries, function ($q, $p) { if (preg_match('/^_nkw=(.+)$/i', $p, $matches)) { $q[] = str_replace('+', ' ', urldecode($matches[1])); } return $q; }); if ($searchQuery) { return 'eBay - ' . $searchQuery[0]; } return parent::getName(); } public function collectData() { $html = getSimpleHTMLDOM($this->getURI()); // Remove any unsolicited results, e.g. "Results matching fewer words" foreach ($html->find('ul.srp-results > li.srp-river-answer--REWRITE_START ~ li') as $inexactMatches) { $inexactMatches->remove(); } // Remove "NEW LISTING" labels: we sort by the newest, so this is redundant. foreach ($html->find('.LIGHT_HIGHLIGHT') as $new_listing_label) { $new_listing_label->remove(); } $results = $html->find('ul.srp-results > li.s-card'); foreach ($results as $listing) { $item = []; // Define a closure to shorten the ugliness of querying the current listing. $find = function ($query, $altText = '') use ($listing) { return $listing->find($query, 0)->plaintext ?? $altText; }; $item['title'] = $find('.s-card__title'); if (!$item['title']) { // Skip entries where the title cannot be found (for w/e reason). continue; } // It appears there may be more than a single 'subtitle' subclass in the listing. Collate them. $subtitles = $listing->find('.s-card__subtitle'); if (is_array($subtitles)) { $subtitle = trim(implode(' ', array_column($subtitles, 'plaintext'))); } else { $subtitle = trim($subtitles->plaintext ?? ''); } // Get the listing's link and uid. $itemUri = $listing->find('.s-card__link', 0); if ($itemUri) { $item['uri'] = $itemUri->href; } if (preg_match('/.*\/itm\/(\d+).*/i', $item['uri'], $matches)) { $item['uid'] = $matches[1]; } // Price should be fetched on its own so we can provide the alt text without complication. $price = $find('.s-card__price', '[NO PRICE]'); // Map a list of dynamic variable names to their subclasses within the listing. // This is just a bit of sugar to make this cleaner and more maintainable. $propertyMappings = [ 'additionalPrice' => '.s-card__additional-price', 'discount' => '.s-card__discount', 'shippingFree' => '.s-card__freeXDays', 'localDelivery' => '.s-card__localDelivery', 'logisticsCost' => '.s-card__logisticsCost', 'location' => '.s-card__location', 'obo' => '.s-card__formatBestOfferEnabled', 'sellerInfo' => '.s-card__seller-info-text', 'bids' => '.s-card__bidCount', 'timeLeft' => '.s-card__time-left', 'timeEnd' => '.s-card__time-end', ]; foreach ($propertyMappings as $k => $v) { $$k = $find($v); } // When an additional price detail or discount is defined, create the 'discountLine'. if ($additionalPrice || $discount) { $discountLine = '
(' . trim($additionalPrice ?? '') . '; ' . trim($discount ?? '') . ')'; } else { $discountLine = ''; } // Prepend the time-left info with a comma if the right details were found. $timeInfo = trim($timeLeft . ' ' . $timeEnd); if ($timeInfo) { $timeInfo = ', ' . $timeInfo; } // Set the listing type. if ($bids) { $listingTypeDetails = "Auction: {$bids}{$timeInfo}"; } else { $listingTypeDetails = 'Buy It Now'; } // Acquire the listing's primary image and atach it. $image = $listing->find('.s-card__media-wrapper img', 0); if ($image) { // Not quite sure why append fragment here $imageUrl = $image->src . '#.image'; $item['enclosures'] = [$imageUrl]; } // Include the original search link, if specified. if ($this->getInput('includesSearchLink')) { $searchLink = '

View Search

'; } else { $searchLink = ''; } // Build the final item's content to display and add the item onto the list. $item['content'] = <<$sellerInfo $location

$price $obo ($listingTypeDetails) $discountLine
$shippingFree $localDelivery $logisticsCost

{$subtitle}

$searchLink CONTENT; $this->items[] = $item; } } } ================================================ FILE: bridges/EDDHPiRepsBridge.php ================================================ find('table table table td') as $itemnode) { $texts = $this->extractTexts($itemnode->find('text, br')); $timestamp = $itemnode->find('.su_dat', 0)->innertext(); $uri = $itemnode->find('.pir_hd a', 0)->href; $this->items[] = [ 'timestamp' => $this->formatItemTimestamp($timestamp), 'title' => $this->formatItemTitle($texts), 'uri' => $this->formatItemUri($uri), 'author' => $this->formatItemAuthor($texts), 'content' => $this->formatItemContent($texts) ]; } } public function getIcon() { return 'https://eddh.de/favicon.ico'; } private function extractTexts($nodes) { $texts = []; $i = 0; foreach ($nodes as $node) { $text = trim($node->outertext()); if ($node->tag == 'br') { $texts[$i++] = "\n"; } elseif (($node->tag == 'text') && ($text != '')) { $text = iconv('Windows-1252', 'UTF-8', $text); $text = str_replace(' ', '', $text); $texts[$i++] = $text; } } return $texts; } protected function formatItemAuthor($texts) { $pos = array_search('Name:', $texts); return $texts[$pos + 1]; } protected function formatItemContent($texts) { $pos1 = array_search('Bemerkungen:', $texts); $pos2 = array_search('Bewertung:', $texts); $content = ''; for ($i = $pos1 + 1; $i < $pos2; $i++) { $content .= $texts[$i]; } return trim($content); } protected function formatItemTitle($texts) { $texts[5] = ltrim($texts[5], '('); return implode(' ', [$texts[1], $texts[2], $texts[3], $texts[5]]); } protected function formatItemTimestamp($value) { $value = str_replace('Eintrag vom', '', $value); $value = trim($value); return strtotime($value); } protected function formatItemUri($value) { return 'https://eddh.de/info/' . $value; } } ================================================ FILE: bridges/EDDHPresseschauBridge.php ================================================ setTime(0, 0, 0); return $dti->getTimestamp(); } } ================================================ FILE: bridges/EZTVBridge.php ================================================ [ 'name' => 'IMDB ids', 'exampleValue' => '8740790,1733785', 'required' => true, 'title' => 'One or more IMDB ids' ], 'no480' => [ 'name' => 'No 480p', 'type' => 'checkbox', 'title' => 'Activate to exclude 480p torrents' ], 'no720' => [ 'name' => 'No 720p', 'type' => 'checkbox', 'title' => 'Activate to exclude 720p torrents' ], 'no1080' => [ 'name' => 'No 1080p', 'type' => 'checkbox', 'title' => 'Activate to exclude 1080p torrents' ], 'no2160' => [ 'name' => 'No 2160p', 'type' => 'checkbox', 'title' => 'Activate to exclude 2160p torrents' ], 'noUnknownRes' => [ 'name' => 'No Unknown resolution', 'type' => 'checkbox', 'title' => 'Activate to exclude unknown resolution torrents' ], ] ]; public function collectData() { $eztv_uri = $this->getEztvUri(); $ids = explode(',', trim($this->getInput('ids'))); foreach ($ids as $id) { $url = sprintf('%s/api/get-torrents?imdb_id=%s', $eztv_uri, $id); $json = getContents($url); $data = json_decode($json); if (!isset($data->torrents)) { // No results continue; } foreach ($data->torrents as $torrent) { $title = $torrent->title; $regex480 = '/480p/'; $regex720 = '/720p/'; $regex1080 = '/1080p/'; $regex2160 = '/2160p/'; $regexUnknown = '/(480p|720p|1080p|2160p)/'; // Skip unwanted resolution torrents if ( (preg_match($regex480, $title) === 1 && $this->getInput('no480')) || (preg_match($regex720, $title) === 1 && $this->getInput('no720')) || (preg_match($regex1080, $title) === 1 && $this->getInput('no1080')) || (preg_match($regex2160, $title) === 1 && $this->getInput('no2160')) || (preg_match($regexUnknown, $title) !== 1 && $this->getInput('noUnknownRes')) ) { continue; } $this->items[] = $this->getItemFromTorrent($torrent); } } usort($this->items, function ($torrent1, $torrent2) { return $torrent2['timestamp'] <=> $torrent1['timestamp']; }); } protected function getEztvUri() { $html = getSimpleHTMLDom(self::URI); $urls = $html->find('a.domainLink'); foreach ($urls as $url) { $headers = get_headers($url->href); if (substr($headers[0], 9, 3) === '200') { return $url->href; } } throw new Exception('No valid EZTV URI available'); } protected function getItemFromTorrent($torrent) { $item = []; $item['uri'] = $torrent->episode_url ?? $torrent->torrent_url; $item['author'] = $torrent->imdb_id; $item['timestamp'] = $torrent->date_released_unix; $item['title'] = $torrent->title; $item['enclosures'][] = $torrent->torrent_url; $thumbnailUri = 'https:' . $torrent->small_screenshot; $torrentSize = format_bytes((int) $torrent->size_bytes); $item['content'] = $torrent->filename . '
File size: ' . $torrentSize . '
magnet link
torrent link
'; return $item; } } ================================================ FILE: bridges/EconomistBridge.php ================================================ [ 'required' => false, ] ]; const PARAMETERS = [ 'global' => [ 'limit' => [ 'name' => 'Feed Item Limit', 'required' => true, 'type' => 'number', 'defaultValue' => 10, 'title' => 'Maximum number of returned feed items. Maximum 30, default 10' ] ], 'Topics' => [ 'topic' => [ 'name' => 'Topics', 'type' => 'list', 'title' => 'Select a Topic', 'defaultValue' => 'latest', 'values' => [ 'Latest' => 'latest', 'The world this week' => 'the-world-this-week', 'Letters' => 'letters', 'Leaders' => 'leaders', 'Briefings' => 'briefing', 'Special reports' => 'special-report', 'Britain' => 'britain', 'Europe' => 'europe', 'United States' => 'united-states', 'The Americas' => 'the-americas', 'Middle East and Africa' => 'middle-east-and-africa', 'Asia' => 'asia', 'China' => 'china', 'International' => 'international', 'Business' => 'business', 'Finance and economics' => 'finance-and-economics', 'Science and technology' => 'science-and-technology', 'Books and arts' => 'books-and-arts', 'Obituaries' => 'obituary', 'Graphic detail' => 'graphic-detail', 'Indicators' => 'economic-and-financial-indicators', 'The Economist Reads' => 'the-economist-reads', ] ] ], 'Blogs' => [ 'blog' => [ 'name' => 'Blogs', 'type' => 'list', 'title' => 'Select a Blog', 'values' => [ 'Bagehots notebook' => 'bagehots-notebook', 'Bartleby' => 'bartleby', 'Buttonwoods notebook' => 'buttonwoods-notebook', 'Charlemagnes notebook' => 'charlemagnes-notebook', 'Democracy in America' => 'democracy-in-america', 'Erasmus' => 'erasmus', 'Free exchange' => 'free-exchange', 'Game theory' => 'game-theory', 'Gulliver' => 'gulliver', 'Kaffeeklatsch' => 'kaffeeklatsch', 'Prospero' => 'prospero', 'The Economist Explains' => 'the-economist-explains', ] ] ] ]; public function collectData() { // get if topics or blogs were selected and store the selected category switch ($this->queriedContext) { case 'Topics': $category = $this->getInput('topic'); break; case 'Blogs': $category = $this->getInput('blog'); break; default: $category = 'latest'; } // limit the returned articles to 30 at max if ((int)$this->getInput('limit') <= 30) { $limit = (int)$this->getInput('limit'); } else { $limit = 30; } $url = 'https://www.economist.com/' . $category . '/rss.xml'; $this->collectExpandableDatas($url, $limit); } protected function parseItem(array $item) { $headers = []; if ($this->getOption('cookie')) { $headers = [ 'Authority: www.economist.com', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 'Accept-language: en-US,en;q=0.9', 'Cache-control: max-age=0', 'Cookie: ' . $this->getOption('cookie'), 'Upgrade-insecure-requests: 1', 'User-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36' ]; } try { $dom = getSimpleHTMLDOM($item['uri'], $headers); } catch (Exception $e) { $item['content'] = $e->getMessage(); return $item; } $article = $dom->find('#new-article-template', 0); if ($article == null) { $article = $dom->find('main', 0); } if ($article) { $elem = $article->find('div', 0); list($content, $audio_url) = $this->processContent($dom, $elem); $item['content'] = $content; if ($audio_url != null) { $item['enclosures'] = [$audio_url]; } } return $item; } private function processContent($html, $elem) { // Remove extra styles $styles = $elem->find('style'); foreach ($styles as $style) { $style->parent->removeChild($style); } // Remove the section with remaining articles $more_elem = $elem->find('h2.ds-section-headline.ds-section-headline--rule-emphasised', 0); if ($more_elem != null) { if ($more_elem->parent && $more_elem->parent->parent) { $more_elem->parent->parent->removeChild($more_elem->parent); } } // Remove 'capitalization' with tags foreach ($elem->find('small') as $small) { $small->outertext = strtoupper($small->innertext); } // Extract audio $audio_url = null; $audio_elem = $elem->find('#audio-player', 0); if ($audio_elem != null) { $audio_url = $audio_elem->src; $audio_elem->parent->parent->removeChild($audio_elem->parent); } // No idea how this works on the original site foreach ($elem->find('img') as $img) { $img->removeAttribute('width'); $img->removeAttribute('height'); } // Some hacks for 'interactive' sections to make them a bit // more readable. Here's one example: // https://www.economist.com/interactive/briefing/2022/09/24/war-in-ukraine-has-reshaped-worlds-fuel-markets $svelte = $elem->find('svelte-scroller-outer', 0); if ($svelte != null) { $svelte->parent->removeChild($svelte); } foreach ($elem->find('img') as $strange_img) { if (!str_contains($strange_img->src, 'economist.com')) { $strange_img->src = 'https://economist.com' . $strange_img->src; } } // Trying to fix interactive infographics. This doesn't look // quite as well, but fortunately, such elements are rare // (~95% of infographics are plain images) foreach ($elem->find('div.ds-image') as $ds_img) { $ds_img->style = 'max-width: min(100%, 700px); overflow: hidden; margin: 2rem auto;'; $g_artboard = null; foreach ($ds_img->find('div.g-artboard') as $g_artboard_cand) { if (!str_contains($g_artboard_cand->style, 'display: none')) { $g_artboard = $g_artboard_cand; } } if ($g_artboard != null) { $g_artboard->style = $g_artboard->style . 'position: relative;'; $img = $g_artboard->find('img', 0); if ($img != null) { $img->style = 'top: 0; display: block; width: 100% !important;'; foreach ($g_artboard->find('div') as $div) { if ($div->style == null) { $div->style = 'position: absolute;'; } else { $div->style = $div->style . 'position: absolute'; } } } } } $vertical = $elem->find('div[data-test-id=vertical]', 0); if ($vertical != null) { $vertical->parent->removeChild($vertical); } // Section with 'Save', 'Share' and 'Give buttons' foreach ($elem->find('div[data-test-id=sharing-modal]') as $sharing) { $sharing->parent->removeChild($sharing); } // These links become HUGE without '; public function collectData() { $creds = $this->getInput('login') . ':' . $this->getInput('password'); $authHeader = 'Authorization: Basic ' . base64_encode($creds); $instance = $this->getInput('instance'); $queries = []; $filter = $this->getInput('filter'); $filterValues = []; if ($filter && mb_strlen($filter) > 0) { $filterValues = explode(';', $filter); } else { $queries[''] = []; } foreach ($filterValues as $filterValue) { $params = explode(',', $filterValue); $queryName = $filterValue; $query = []; foreach ($params as $param) { [$key, $value] = explode(':', $param); if ($key == 'title') { $queryName = $value; } else { $query[$key] = $value; } } $queries[$queryName] = $query; } $fetchedIds = []; foreach ($queries as $queryName => $query) { for ($i = 1; $i <= $this->getInput('pages'); $i++) { $queryPaginated = array_merge($query, ['page' => $i]); $url = $instance . '/api/cve?' . http_build_query($queryPaginated); $response = getContents($url, [$authHeader]); $titlePrefix = ''; if (count($queries) > 1) { $titlePrefix = '[' . $queryName . '] '; } foreach (json_decode($response)->results as $cveItem) { if (array_key_exists($cveItem->cve_id, $fetchedIds)) { continue; } $fetchedIds[$cveItem->cve_id] = true; $item = [ 'uri' => $instance . '/cve/' . $cveItem->cve_id, 'uid' => $cveItem->cve_id, ]; if ($this->getInput('upd_timestamp') == 1) { $item['timestamp'] = strtotime($cveItem->updated_at); } else { $item['timestamp'] = strtotime($cveItem->created_at); } if ($this->getInput('fetch_contents')) { [$content, $title] = $this->fetchContents( $cveItem, $titlePrefix, $instance, $authHeader ); $item['content'] = $content; $item['title'] = $title; } else { $item['content'] = $cveItem->description . $this->getLinks($cveItem->cve_id); $item['title'] = $this->getTitle($titlePrefix, $cveItem); } $this->items[] = $item; } } } usort($this->items, function ($a, $b) { return $b['timestamp'] - $a['timestamp']; }); } private function getTitle($titlePrefix, $cveItem) { $summary = $cveItem->description; $limit = $this->getInput('limit'); if ($limit && mb_strlen($summary) > 100) { $summary = mb_substr($summary, 0, $limit) + '...'; } return $titlePrefix . $cveItem->cve_id . '. ' . $summary; } private function fetchContents($cveItem, $titlePrefix, $instance, $authHeader) { $url = $instance . '/api/cve/' . $cveItem->cve_id; $response = getContents($url, [$authHeader]); $datum = json_decode($response); $title = $this->getTitleFromDatum($datum, $titlePrefix); $result = self::CSS; $result .= '

' . $cveItem->cve_id . '

'; $result .= $this->getCVSSLabels($datum); $result .= '

' . $datum->description . '

'; $result .= <<Information:

  • Created At: {$datum->created_at}
  • Updated At: {$datum->updated_at}

EOD; if (isset($datum->metrics->cvssV4_0->data->vector)) { $result .= $this->cvssV4VectorToTable($datum->metrics->cvssV4_0->data->vector); } if (isset($datum->metrics->cvssV3_1->data->vector)) { $result .= $this->cvssV3VectorToTable($datum->metrics->cvssV3_1->data->vector); } if (isset($datum->metrics->cvssV3_0->data->vector)) { $result .= $this->cvssV3VectorToTable($datum->metrics->cvssV3_0->data->vector); } if (isset($datum->metrics->cvssV2_0->data->vector)) { $result .= $this->cvssV2VectorToTable($datum->metrics->cvssV2_0->data->vector); } $result .= $this->getLinks($datum->cve_id); $result .= $this->getVendors($datum); return [$result, $title]; } private function getTitleFromDatum($datum, $titlePrefix) { $title = $titlePrefix; if (isset($datum->metrics->cvssV4_0->data->score)) { $title .= "[v4: {$datum->metrics->cvssV4_0->data->score}] "; } if (isset($datum->metrics->cvssV3_1->data->score)) { $title .= "[v3.1: {$datum->metrics->cvssV3_1->data->score}] "; } if (isset($datum->metrics->cvssV3_0->data->score)) { $title .= "[v3: {$datum->metrics->cvssV3_0->data->score}] "; } if (isset($datum->metrics->cvssV2_0->data->score)) { $title .= "[v2: {$datum->metrics->cvssV2_0->data->score}] "; } $title .= $datum->cve_id . '. '; $titlePostfix = $datum->description; $limit = $this->getInput('limit'); if ($limit && mb_strlen($titlePostfix) > 100) { $titlePostfix = mb_substr($titlePostfix, 0, $limit) + '...'; } $title .= $titlePostfix; return $title; } private function getCVSSLabels($datum) { $cvss4 = ''; $cvss31 = ''; $cvss3 = ''; $cvss2 = ''; if (isset($datum->metrics->cvssV4_0->data->score)) { $cvss4 = $this->formatCVSSLabel($datum->metrics->cvssV4_0->data->score, '4.0', 9, 7, 4); } if (isset($datum->metrics->cvssV3_1->data->score)) { $cvss31 = $this->formatCVSSLabel($datum->metrics->cvssV3_1->data->score, '3.1', 9, 7, 4); } if (isset($datum->metrics->cvssV3_0->data->score)) { $cvss3 = $this->formatCVSSLabel($datum->metrics->cvssV3_0->data->score, '3.0', 9, 7, 4); } if (isset($datum->metrics->cvssV2_0->data->score)) { $cvss2 = $this->formatCVSSLabel($datum->metrics->cvssV2_0->data->score, '2.0', 99, 7, 4); } return '
' . $cvss4 . $cvss31 . $cvss3 . $cvss2 . '
'; } private function formatCVSSLabel($score, $version, $critical_thr, $high_thr, $medium_thr) { $text = 'n/a'; $class = 'cvss-na-color'; if ($score) { $importance = ''; if ($score >= $critical_thr) { $importance = 'CRITICAL'; $class = 'cvss-crit-color'; } else if ($score >= $high_thr) { $importance = 'HIGH'; $class = 'cvss-high-color'; } else if ($score >= $medium_thr) { $importance = 'MEDIUM'; $class = 'cvss-medium-color'; } else { $importance = 'LOW'; $class = 'cvss-low-color'; } $text = sprintf('[%s] %.1f', $importance, $score); } $item = "
CVSS {$version}:
{$text}
"; return $item; } private function getLinks($id) { return <<Links

EOD; } private function cvssV3VectorToTable($cvssVector) { $vectorComponents = []; $parts = explode('/', $cvssVector); if (!preg_match('/^CVSS:3\.[01]/', $parts[0])) { return 'Error: Not a valid CVSS v3.0 or v3.1 vector'; } for ($i = 1; $i < count($parts); $i++) { $component = explode(':', $parts[$i]); if (count($component) == 2) { $vectorComponents[$component[0]] = $component[1]; } } $readableNames = [ 'AV' => ['N' => 'Network', 'A' => 'Adjacent', 'L' => 'Local', 'P' => 'Physical'], 'AC' => ['L' => 'Low', 'H' => 'High'], 'PR' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'], 'UI' => ['N' => 'None', 'R' => 'Required'], 'S' => ['U' => 'Unchanged', 'C' => 'Changed'], 'C' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'], 'I' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'], 'A' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'] ]; $data = new stdClass(); $data->attackVector = isset($readableNames['AV'][$vectorComponents['AV']]) ? $readableNames['AV'][$vectorComponents['AV']] : 'Unknown'; $data->attackComplexity = isset($readableNames['AC'][$vectorComponents['AC']]) ? $readableNames['AC'][$vectorComponents['AC']] : 'Unknown'; $data->privilegesRequired = isset($readableNames['PR'][$vectorComponents['PR']]) ? $readableNames['PR'][$vectorComponents['PR']] : 'Unknown'; $data->userInteraction = isset($readableNames['UI'][$vectorComponents['UI']]) ? $readableNames['UI'][$vectorComponents['UI']] : 'Unknown'; $data->scope = isset($readableNames['S'][$vectorComponents['S']]) ? $readableNames['S'][$vectorComponents['S']] : 'Unknown'; $data->confidentialityImpact = isset($readableNames['C'][$vectorComponents['C']]) ? $readableNames['C'][$vectorComponents['C']] : 'Unknown'; $data->integrityImpact = isset($readableNames['I'][$vectorComponents['I']]) ? $readableNames['I'][$vectorComponents['I']] : 'Unknown'; $data->availabilityImpact = isset($readableNames['A'][$vectorComponents['A']]) ? $readableNames['A'][$vectorComponents['A']] : 'Unknown'; $html = '

CVSS v3 details

Attack vector' . $data->attackVector . ' Confidentiality Impact' . $data->confidentialityImpact . '
Attack complexity' . $data->attackComplexity . ' Integrity Impact' . $data->integrityImpact . '
Privileges Required' . $data->privilegesRequired . ' Availability Impact' . $data->availabilityImpact . '
User Interaction' . $data->userInteraction . ' Scope' . $data->scope . '
'; return $html; } private function cvssV2VectorToTable($cvssVector) { $vectorComponents = []; $parts = explode('/', $cvssVector); foreach ($parts as $part) { $component = explode(':', $part); if (count($component) == 2) { $vectorComponents[$component[0]] = $component[1]; } } $readableNames = [ 'AV' => ['L' => 'Local', 'A' => 'Adjacent Network', 'N' => 'Network'], 'AC' => ['H' => 'High', 'M' => 'Medium', 'L' => 'Low'], 'Au' => ['M' => 'Multiple', 'S' => 'Single', 'N' => 'None'], 'C' => ['N' => 'None', 'P' => 'Partial', 'C' => 'Complete'], 'I' => ['N' => 'None', 'P' => 'Partial', 'C' => 'Complete'], 'A' => ['N' => 'None', 'P' => 'Partial', 'C' => 'Complete'] ]; $metricValues = [ 'AV' => ['L' => 0.395, 'A' => 0.646, 'N' => 1.0], 'AC' => ['H' => 0.35, 'M' => 0.61, 'L' => 0.71], 'Au' => ['M' => 0.45, 'S' => 0.56, 'N' => 0.704], 'C' => ['N' => 0, 'P' => 0.275, 'C' => 0.660], 'I' => ['N' => 0, 'P' => 0.275, 'C' => 0.660], 'A' => ['N' => 0, 'P' => 0.275, 'C' => 0.660] ]; $confImpact = isset($metricValues['C'][$vectorComponents['C']]) ? $metricValues['C'][$vectorComponents['C']] : 0; $integImpact = isset($metricValues['I'][$vectorComponents['I']]) ? $metricValues['I'][$vectorComponents['I']] : 0; $availImpact = isset($metricValues['A'][$vectorComponents['A']]) ? $metricValues['A'][$vectorComponents['A']] : 0; $impact = 10.41 * (1 - (1 - $confImpact) * (1 - $integImpact) * (1 - $availImpact)); $av = isset($metricValues['AV'][$vectorComponents['AV']]) ? $metricValues['AV'][$vectorComponents['AV']] : 0; $ac = isset($metricValues['AC'][$vectorComponents['AC']]) ? $metricValues['AC'][$vectorComponents['AC']] : 0; $au = isset($metricValues['Au'][$vectorComponents['Au']]) ? $metricValues['Au'][$vectorComponents['Au']] : 0; $exploitability = 20 * $av * $ac * $au; $impact = round($impact, 1); $exploitability = round($exploitability, 1); $data = new stdClass(); $data->accessVector = isset($readableNames['AV'][$vectorComponents['AV']]) ? $readableNames['AV'][$vectorComponents['AV']] : 'Unknown'; $data->accessComplexity = isset($readableNames['AC'][$vectorComponents['AC']]) ? $readableNames['AC'][$vectorComponents['AC']] : 'Unknown'; $data->authentication = isset($readableNames['Au'][$vectorComponents['Au']]) ? $readableNames['Au'][$vectorComponents['Au']] : 'Unknown'; $data->confidentialityImpact = isset($readableNames['C'][$vectorComponents['C']]) ? $readableNames['C'][$vectorComponents['C']] : 'Unknown'; $data->integrityImpact = isset($readableNames['I'][$vectorComponents['I']]) ? $readableNames['I'][$vectorComponents['I']] : 'Unknown'; $data->availabilityImpact = isset($readableNames['A'][$vectorComponents['A']]) ? $readableNames['A'][$vectorComponents['A']] : 'Unknown'; $v2 = new stdClass(); $v2->impactScore = $impact; $v2->exploitabilityScore = $exploitability; $html = '

CVSS v2 details

Impact score' . $v2->impactScore . ' Exploitability score' . $v2->exploitabilityScore . '
Access Vector' . $data->accessVector . ' Confidentiality Impact' . $data->confidentialityImpact . '
Access Complexity' . $data->accessComplexity . ' Integrity Impact' . $data->integrityImpact . '
Authentication' . $data->authentication . ' Availability Impact' . $data->availabilityImpact . '
'; return $html; } private function cvssV4VectorToTable($cvssVector) { $vectorComponents = []; $parts = explode('/', $cvssVector); if (!preg_match('/^CVSS:4\.0/', $parts[0])) { return 'Error: Not a valid CVSS v4.0 vector'; } for ($i = 1; $i < count($parts); $i++) { $component = explode(':', $parts[$i]); if (count($component) == 2) { $vectorComponents[$component[0]] = $component[1]; } } $readableNames = [ 'AV' => ['N' => 'Network', 'A' => 'Adjacent', 'L' => 'Local', 'P' => 'Physical'], 'AC' => ['L' => 'Low', 'H' => 'High'], 'AT' => ['N' => 'None', 'P' => 'Present'], 'PR' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'], 'UI' => ['N' => 'None', 'P' => 'Passive', 'A' => 'Active'], 'VC' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'], 'VI' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'], 'VA' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'], 'SC' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'], 'SI' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'], 'SA' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'] ]; $data = new stdClass(); $data->attackVector = isset($readableNames['AV'][$vectorComponents['AV']]) ? $readableNames['AV'][$vectorComponents['AV']] : 'Unknown'; $data->attackComplexity = isset($readableNames['AC'][$vectorComponents['AC']]) ? $readableNames['AC'][$vectorComponents['AC']] : 'Unknown'; $data->privilegesRequired = isset($readableNames['PR'][$vectorComponents['PR']]) ? $readableNames['PR'][$vectorComponents['PR']] : 'Unknown'; $data->attackRequirements = isset($readableNames['AT'][$vectorComponents['AT']]) ? $readableNames['AT'][$vectorComponents['AT']] : 'Unknown'; $data->userInteraction = isset($readableNames['UI'][$vectorComponents['UI']]) ? $readableNames['UI'][$vectorComponents['UI']] : 'Unknown'; $data->confidentialityImpact = isset($readableNames['VC'][$vectorComponents['VC']]) ? $readableNames['VC'][$vectorComponents['VC']] : 'Unknown'; $data->integrityImpact = isset($readableNames['VI'][$vectorComponents['VI']]) ? $readableNames['VI'][$vectorComponents['VI']] : 'Unknown'; $data->availabilityImpact = isset($readableNames['VA'][$vectorComponents['VA']]) ? $readableNames['VA'][$vectorComponents['VA']] : 'Unknown'; $data->confidentialityImpactS = isset($readableNames['SC'][$vectorComponents['SC']]) ? $readableNames['SC'][$vectorComponents['SC']] : 'Unknown'; $data->integrityImpactS = isset($readableNames['SI'][$vectorComponents['SI']]) ? $readableNames['SI'][$vectorComponents['SI']] : 'Unknown'; $data->availabilityImpactS = isset($readableNames['SA'][$vectorComponents['SA']]) ? $readableNames['SA'][$vectorComponents['SA']] : 'Unknown'; $html = '

CVSS v4.0 details

Attack vector' . $data->attackVector . ' Vulnerable System Confidentiality Impact' . $data->confidentialityImpact . '
Attack complexity' . $data->attackComplexity . ' Vulnerable System Integrity Impact' . $data->integrityImpact . '
Privileges Required' . $data->privilegesRequired . ' Vulnerable System Availability Impact' . $data->availabilityImpact . '
Attack Requirements' . $data->attackRequirements . ' Subsequent System Confidentiality Impact' . $data->confidentialityImpactS . '
User Interaction' . $data->userInteraction . ' Subsequent System Integrity Impact' . $data->integrityImpactS . '
Subsequent System Avaliablity Impact' . $data->availabilityImpactS . '
'; return $html; } private function getVendors($datum) { if (count((array)$datum->vendors) == 0) { return ''; } $vendor_data = []; foreach ($datum->vendors as $vendor_str) { $pieces = explode('$PRODUCT$', $vendor_str); if (count($pieces) == 1) { $vendor = $pieces[0]; if (!array_key_exists($vendor, $vendor_data)) { $vendor_data[$vendor] = []; } } else { $vendor = $pieces[0]; $product = $pieces[1]; if (!array_key_exists($vendor, $vendor_data)) { $vendor_data[$vendor] = []; } array_push($vendor_data[$vendor], $product); } } $res = '

Affected products

    '; foreach ($vendor_data as $vendor => $products) { $res .= "
  • {$vendor}"; if (count($products) > 0) { $res .= '
      '; foreach ($products as $product) { $res .= '
    • ' . $product . '
    • '; } $res .= '
    '; } $res .= '
  • '; } $res .= '

'; return $res; } } ================================================ FILE: bridges/OpenwhydBridge.php ================================================ [ 'name' => 'username/id', 'exampleValue' => '5247f0267e91c862b2b052d0', 'required' => true ] ]]; private $userName = ''; public function getIcon() { return self::URI . '/images/favicon.ico'; } 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')) ); } else { // input may be the username $html = getSimpleHTMLDOM( self::URI . '/search?q=' . urlencode($this->getInput('u')) ); 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') ); 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 = []; $item['author'] = $track->find('h2', 0)->plaintext; $item['title'] = $track->find('h2', 0)->plaintext; $item['content'] = $track->find('a.thumb', 0) . '
' . $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 . ' - ' : '') . 'Openwhyd'; } } ================================================ FILE: bridges/OpenwrtSecurityBridge.php ================================================ find('div[class=plugin_nspages]', 0); foreach ($advisories->find('a[class=wikilink1]') as $element) { $item = []; $row = $element->innertext; $item['title'] = substr($row, 0, strpos($row, ' - ')); $item['timestamp'] = $this->getDate($element->href); $item['uri'] = self::WEBROOT . $element->href; $item['uid'] = self::WEBROOT . $element->href; $item['content'] = substr($row, strpos($row, ' - ') + 3); $item['author'] = 'OpenWrt Project'; $this->items[] = $item; } } private function getDate($href) { $date = substr($href, -12); return $date; } } ================================================ FILE: bridges/OtrkeyFinderBridge.php ================================================ [ 'name' => 'Search term', 'exampleValue' => 'Tatort', 'title' => 'The search term is case-insensitive', ], 'station' => [ 'name' => 'Station name', 'exampleValue' => 'ARD', ], 'type' => [ 'name' => 'Media type', 'type' => 'list', 'values' => [ 'any' => '', 'Detail' => [ 'HD' => 'HD.avi', 'AC3' => 'HD.ac3', 'HD & AC3' => 'HD.', 'HQ' => 'HQ.avi', 'AVI' => 'g.avi', // 'g.' to exclude HD.avi and HQ.avi (filename always contains 'mpg.') 'MP4' => '.mp4', ], ], ], 'minTime' => [ 'name' => 'Min. running time', 'type' => 'number', 'title' => 'The minimum running time in minutes. The resolution is 5 minutes.', 'exampleValue' => '90', 'defaultValue' => '0', ], 'maxTime' => [ 'name' => 'Max. running time', 'type' => 'number', 'title' => 'The maximum running time in minutes. The resolution is 5 minutes.', 'exampleValue' => '120', 'defaultValue' => '0', ], 'pages' => [ 'name' => 'Number of pages', 'type' => 'number', 'title' => 'Specifies the number of pages to fetch. Increase this value if you get an empty feed.', 'exampleValue' => '5', 'defaultValue' => '5', ], ] ]; // Example: Terminator_20.04.13_02-25_sf2_100_TVOON_DE.mpg.avi.otrkey // The first group is the running time in minutes const FILENAME_REGEX = '/_(\d+)_TVOON_DE\.mpg\..+\.otrkey/'; // year.month.day_hour-minute with leading zeros const TIME_REGEX = '/\d{2}\.\d{2}\.\d{2}_\d{2}-\d{2}/'; const CONTENT_TEMPLATE = '
    %s
'; const MIRROR_TEMPLATE = '
  • %s
  • '; public function collectData() { $pages = $this->getInput('pages'); for ($page = 1; $page <= $pages; $page++) { $uri = $this->buildUri($page); $html = getSimpleHTMLDOMCached($uri, self::CACHE_TIMEOUT); $keys = $html->find('div.otrkey'); foreach ($keys as $key) { $temp = $this->buildItem($key); if ($temp != null) { $this->items[] = $temp; } } // Sleep for 0.5 seconds to don't hammer the server. usleep(500000); } } private function buildUri($page) { $searchterm = $this->getInput('searchterm'); $station = $this->getInput('station'); $type = $this->getInput('type'); // Combine all three parts to a search query by separating them with white space $search = implode(' ', [$searchterm, $station, $type]); $search = trim($search); $search = urlencode($search); return sprintf(self::URI_TEMPLATE, $search, $page); } private function buildItem(simple_html_dom_node $node) { $file = $this->getFilename($node); if ($file == null) { return null; } $minTime = $this->getInput('minTime'); $maxTime = $this->getInput('maxTime'); // Do we need to check the running time? if ($minTime != 0 || $maxTime != 0) { if ($maxTime > 0 && $maxTime < $minTime) { throwClientException('The minimum running time must be less than the maximum running time.'); } preg_match(self::FILENAME_REGEX, $file, $matches); if (!isset($matches[1])) { return null; } $time = (int)$matches[1]; // Check for minimum running time if ($minTime > 0 && $minTime > $time) { return null; } // Check for maximum running time if ($maxTime > 0 && $maxTime < $time) { return null; } } $item = []; $item['title'] = $file; // The URI_TEMPLATE for querying the site can be reused here $item['uri'] = sprintf(self::URI_TEMPLATE, $file, 1); $content = $this->buildContent($node); if ($content != null) { $item['content'] = $content; } if (preg_match(self::TIME_REGEX, $file, $matches) === 1) { $item['timestamp'] = DateTime::createFromFormat( 'y.m.d_H-i', $matches[0], new DateTimeZone('Europe/Berlin') )->getTimestamp(); } return $item; } private function getFilename(simple_html_dom_node $node) { $file = $node->find('.file', 0); if ($file == null) { return null; } // Sometimes there is HTML in the filename - we don't want that. // To filter that out, enumerate to the node which contains the text only. foreach ($file->nodes as $node) { if ($node->nodetype == HDOM_TYPE_TEXT) { return trim($node->innertext); } } return null; } private function buildContent(simple_html_dom_node $node) { $mirrors = $node->find('div.mirror'); $list = ''; // Build list of available mirrors foreach ($mirrors as $mirror) { $anchor = $mirror->find('a', 0); $list .= sprintf(self::MIRROR_TEMPLATE, $anchor->href, $anchor->innertext); } return sprintf(self::CONTENT_TEMPLATE, $list); } } ================================================ FILE: bridges/OvertakeBridge.php ================================================ collectExpandableDatas('https://www.overtake.gg/ams/index.rss', 10); } protected function parseItem(array $item) { $articlePage = getSimpleHTMLDOMCached($item['uri']); $coverImage = $articlePage->find('img.js-articleCoverImage', 0); #relative url -> absolute url $coverImage = str_replace('src="/', 'src="' . $this->getURI() . '/', $coverImage); $article = $articlePage->find('article.articleBody-main > div.bbWrapper', 0); $item['content'] = str_get_html($coverImage . $article); //convert iframes to links. meant for embedded videos. foreach ($item['content']->find('iframe') as $found) { $iframeUrl = $found->getAttribute('src'); if ($iframeUrl) { $found->outertext = '' . $iframeUrl . ''; } } $item['categories'] = []; foreach ($articlePage->find('a.tagItem') as $tag) { array_push($item['categories'], $tag->innertext); } return $item; } } ================================================ FILE: bridges/PanneauPocketBridge.php ================================================ [ 'name' => 'City slug', 'exampleValue' => '508884409-hadol-88220', 'required' => true, ], ], ]; const CACHE_TIMEOUT = 7200; // 2h private $cityName = ''; public function getName() { return $this->cityName !== '' ? $this->cityName : self::NAME; } public function collectData() { $citySlug = $this->getInput('city'); $cityUrl = self::URI . '/ville/' . $citySlug; if (!filter_var($cityUrl, FILTER_VALIDATE_URL)) { throwServerException('Invalid city slug: ' . $citySlug); } $dom = getSimpleHTMLDOM($cityUrl); $this->cityName = $this->extractCityName($dom); $notices = $dom->find('div.sign-carousel--item'); if (!is_array($notices)) { throwServerException('Invalid or empty content'); } foreach ($notices as $notice) { $a = $notice->find('button.dropdown-item', 0); $url = $a->href ?? ''; if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) { continue; } $title = $notice->find('.sign-preview__content .title', 0); $content = $notice->find('.sign-preview__content .content', 0); $date = $notice->find('span.date', 0); $this->items[] = [ 'uid' => $url, 'uri' => $url, 'title' => $title ? trim($title->plaintext) : '', 'timestamp' => $date ? $this->extractDate($date->plaintext) : '', 'content' => $content ? sanitize($content->innertext) : '', ]; } } private function extractCityName($dom) { $city = $dom->find('.sign-preview__title .infos .city', 0); if (!$city) { return ''; } $cityName = trim($city->plaintext); if ($cityName === '') { return ''; } $postcode = $dom->find('.sign-preview__title .infos .postcode', 0); if ($postcode) { $postcodeValue = trim($postcode->plaintext); if ($postcodeValue !== '') { return $cityName . ' - ' . $postcodeValue; } } return $cityName; } private function extractDate($text) { $text = trim($text); if (!preg_match('~(\d{2})/(\d{2})/(\d{4})$~', $text, $match)) { return ''; } [, $day, $month, $year] = $match; if (!checkdate((int)$month, (int)$day, (int)$year)) { return ''; } return mktime(0, 0, 0, (int)$month, (int)$day, (int)$year); } } ================================================ FILE: bridges/ParksOnTheAirBridge.php ================================================ 1]; $json = getContents(self::API_URI, $header, $opts); $spots = json_decode($json, true); foreach ($spots as $spot) { $title = $spot['activator'] . ' @ ' . $spot['reference'] . ' ' . $spot['frequency'] . ' kHz'; $park_link = self::URI . '/park/' . $spot['reference']; $content = << {$spot['reference']}, {$spot['name']}
    Location: {$spot['locationDesc']}
    Frequency: {$spot['frequency']} kHz
    Spotter: {$spot['spotter']}
    Comments: {$spot['comments']} EOL; $this->items[] = [ 'uri' => $park_link, 'title' => $title, 'content' => $content, 'timestamp' => $spot['spotTime'] ]; } } } ================================================ FILE: bridges/ParlerBridge.php ================================================ [ 'name' => 'User', 'type' => 'text', 'required' => true, 'exampleValue' => 'NigelFarage', ], 'limit' => self::LIMIT, ] ]; public function collectData() { $user = trim($this->getInput('user')); if (preg_match('#^https?://parler\.com/(\w+)#i', $user, $m)) { $user = $m[1]; } $json = getContents(sprintf('https://api.parler.com/v0/public/user/%s/feed/?page=1&limit=20&media_only=0', $user)); $response = Json::decode($json, false); $data = $response->data ?? null; if (!$data) { throw new \Exception('The returned data is empty'); } foreach ($data as $post) { $item = [ 'title' => $post->body, 'uri' => sprintf('https://parler.com/feed/%s', $post->postuuid), 'author' => $post->user->username, 'uid' => $post->postuuid, 'content' => $post->body, ]; $date = $post->date_created; $createdAt = date_create($date); if ($createdAt) { $item['timestamp'] = $createdAt->getTimestamp(); } if (isset($post->image)) { $item['content'] .= sprintf('', $post->image); } $this->items[] = $item; } } } ================================================ FILE: bridges/ParuVenduImmoBridge.php ================================================ [ 'name' => 'Minimal surface m²', 'type' => 'number' ], 'maxprice' => [ 'name' => 'Max price', 'type' => 'number' ], 'pa' => [ 'name' => 'Country code', 'exampleValue' => 'FR' ], 'lo' => [ 'name' => 'department numbers or postal codes, comma-separated' ] ]]; public function collectData() { $html = getSimpleHTMLDOM($this->getURI()); $elements = $html->find('#bloc_liste > div.ergov3-annonce a'); foreach ($elements as $element) { if (!$element->title) { continue; } $img = ''; foreach ($element->find('span.img img') as $img) { if ($img->original) { $img = ''; } } $description = $element->find('p', 0); if ($description) { $desc = str_replace("voir l'annonce", '', $description->innertext); } else { $desc = ''; } $priceElement = $element->find('div.ergov3-priceannonce', 0); if ($priceElement) { $price = $priceElement->innertext; } else { $price = ''; } [$href] = explode('#', $element->href); $item = []; $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(); } } ================================================ FILE: bridges/PatreonBridge.php ================================================ [ 'name' => 'Creator', 'type' => 'text', 'required' => true, 'exampleValue' => 'user?u=13425451', 'title' => 'Creator name as seen in their page URL' ] ]]; public function collectData() { $url = $this->getURI(); $html = getSimpleHTMLDOMCached($url); $regex = '#/api/campaigns/([0-9]+)#'; if (preg_match($regex, $html->save(), $matches) > 0) { $campaign_id = $matches[1]; } else { throwServerException('Could not find campaign ID'); } $query = [ 'include' => implode(',', [ 'user', 'attachments', 'user_defined_tags', //'campaign', 'poll.choices', //'poll.current_user_responses.user', //'poll.current_user_responses.choice', //'poll.current_user_responses.poll', //'access_rules.tier.null', 'images.null', 'audio.null', // 'user.null', 'attachments.null', 'audio_preview.null', 'poll.choices.null' // 'poll.current_user_responses.null' ]), 'fields' => [ 'post' => implode(',', [ //'change_visibility_at', //'comment_count', 'content', //'current_user_can_delete', //'current_user_can_view', //'current_user_has_liked', 'embed', 'image', //'is_paid', //'like_count', //'min_cents_pledged_to_view', //'patreon_url', //'patron_count', //'pledge_url', // 'post_file', // 'post_metadata', 'post_type', 'published_at', 'teaser_text', //'thumbnail_url', 'title', //'upgrade_url', 'url', //'was_posted_by_campaign_owner' // 'content_teaser_text', // 'current_user_can_report', 'thumbnail', // 'video_preview' ]), 'user' => implode(',', [ //'image_url', 'full_name', //'url' ]), 'media' => implode(',', [ 'id', 'image_urls', 'download_url', 'metadata', 'file_name', 'mimetype', 'size_bytes' ]) ], 'filter' => [ 'contains_exclusive_posts' => true, 'is_draft' => false, 'campaign_id' => $campaign_id ], 'sort' => '-published_at' ]; $posts = $this->apiGet('posts', $query); foreach ($posts->data as $post) { $item = [ 'uri' => $post->attributes->url, 'title' => $post->attributes->title, 'timestamp' => $post->attributes->published_at, 'content' => '', 'uid' => 'patreon.com/' . $post->id ]; $user = $this->findInclude( $posts, 'user', $post->relationships->user->data->id )->attributes; $item['author'] = $user->full_name; //image, video, audio, link (featured post content) switch ($post->attributes->post_type) { case 'audio_file': //check if download_url is null before assigning $audio $id = $post->relationships->audio->data->id ?? null; if (isset($id)) { $audio = $this->findInclude($posts, 'media', $id)->attributes ?? null; } if (!isset($audio->download_url)) { //if not unlocked $id = $post->relationships->audio_preview->data->id ?? null; if (isset($id)) { $audio = $this->findInclude($posts, 'media', $id)->attributes ?? null; } } $thumbnail = $post->attributes->thumbnail->large ?? null; $thumbnail = $thumbnail ?? $post->attributes->thumbnail->url ?? null; $thumbnail = $thumbnail ?? $post->attributes->image->thumb_url ?? null; $thumbnail = $thumbnail ?? $post->attributes->image->url ?? null; $audio_filename = $audio->file_name ?? $item['title']; $download_url = $audio->download_url ?? $item['uri']; $item['content'] .= "


    🎧 {$audio_filename}

    "; if ($download_url !== $item['uri']) { $item['enclosures'][] = $download_url; $item['content'] .= ""; } $item['content'] .= '

    '; break; case 'video_embed': $thumbnail = $post->attributes->thumbnail->large ?? null; $thumbnail = $thumbnail ?? $post->attributes->thumbnail->url ?? null; $thumbnail = $thumbnail ?? $post->attributes->image->thumb_url ?? null; $thumbnail = $thumbnail ?? $post->attributes->image->url ?? null; $item['content'] .= "

    🎬 {$item['title']}

    "; break; case 'video_external_file': $thumbnail = $post->attributes->thumbnail->large ?? null; $thumbnail = $thumbnail ?? $post->attributes->thumbnail->url ?? null; $thumbnail = $thumbnail ?? $post->attributes->image->thumb_url ?? null; $thumbnail = $thumbnail ?? $post->attributes->image->url ?? null; $item['content'] .= "

    🎬 {$item['title']}

    "; break; case 'image_file': $item['content'] .= '

    '; foreach ($post->relationships->images->data as $key => $image) { $image = $this->findInclude($posts, 'media', $image->id)->attributes; $image_fullres = $image->download_url ?? $image->image_urls->url ?? $image->image_urls->original ?? null; $filename = $image->file_name ?? ''; $image_url = $image->image_urls->url ?? $image->image_urls->original ?? null; $item['enclosures'][] = $image_fullres; $item['content'] .= "{$filename}


    "; } $item['content'] .= '

    '; break; case 'link': //make it locked safe if (isset($post->attributes->embed)) { $embed = $post->attributes->embed; $thumbnail = $post->attributes->image->large_url ?? $post->attributes->image->thumb_url ?? $post->attributes->image->url; $item['content'] .= '

    '; $item['content'] .= ""; $item['content'] .= ""; $item['content'] .= ""; $item['content'] .= '
    url}\">
    {$embed->subject}
    {$embed->description}


    '; } break; } //content of the post if (isset($post->attributes->content)) { $item['content'] .= $post->attributes->content; } elseif (isset($post->attributes->teaser_text)) { $item['content'] .= '

    ' . $post->attributes->teaser_text; if (strlen($post->attributes->teaser_text) === 140) { $item['content'] .= '…'; } $item['content'] .= '

    '; } //post tags if (isset($post->relationships->user_defined_tags)) { $item['categories'] = []; foreach ($post->relationships->user_defined_tags->data as $tag) { $attrs = $this->findInclude($posts, 'post_tag', $tag->id)->attributes; $item['categories'][] = $attrs->value; } } //poll if (isset($post->relationships->poll->data)) { $poll = $this->findInclude($posts, 'poll', $post->relationships->poll->data->id); $item['content'] .= "

    "; foreach ($poll->relationships->choices->data as $key => $poll_option) { $poll_option = $this->findInclude($posts, 'poll_choice', $poll_option->id); $poll_option_text = $poll_option->attributes->text_content ?? null; if (isset($poll_option_text)) { $item['content'] .= ""; } } $item['content'] .= '
    Poll: {$poll->attributes->question_text}
    {$poll_option_text}

    '; } //post attachments if ( isset($post->relationships->attachments->data) && count($post->relationships->attachments->data) > 0 ) { $item['enclosures'] = []; $item['content'] .= '

    Attachments:

      '; foreach ($post->relationships->attachments->data as $attachment) { $attrs = $this->findInclude($posts, 'attachment', $attachment->id)->attributes; $filename = $attrs->name; $n = strrpos($filename, '.'); $ext = ($n === false) ? '' : substr($filename, $n); $item['enclosures'][] = $attrs->url . '#' . $ext; $item['content'] .= '
    • ' . $filename . '
    • '; } $item['content'] .= '

    '; } $this->items[] = $item; } } /* * Searches the "included" array in an API response and returns the result for the first match. * A result will include attributes containing further details of the included object * (e.g. an audio object), and an optional relationships object that links to more "included" * objects. (e.g. a poll object with related poll_choice(s)) */ private function findInclude($data, $type, $id) { foreach ($data->included as $include) { if ($include->type === $type && $include->id === $id) { return $include; } } } private function apiGet($endpoint, $query_data = []) { $query_data['json-api-version'] = 1.0; $query_data['json-api-use-default-includes'] = 0; $url = 'https://www.patreon.com/api/' . $endpoint . '?' . http_build_query($query_data); /* * Accept-Language header and the CURL cipher list are for bypassing the * Cloudflare anti-bot protection on the Patreon API. If this ever breaks, * here are some other project that also deal with this: * https://github.com/mikf/gallery-dl/issues/342 * https://github.com/daemionfox/patreon-feed/issues/7 * https://www.patreondevelopers.com/t/api-returning-cloudflare-challenge/2025 * https://github.com/splitbrain/patreon-rss/issues/4 */ $header = [ 'Accept-Language: en-US', 'Content-Type: application/json' ]; $opts = [ CURLOPT_SSL_CIPHER_LIST => implode(':', [ 'DEFAULT', '!DHE-RSA-CHACHA20-POLY1305' ]) ]; $data = json_decode(getContents($url, $header, $opts)); return $data; } public function getName() { if (!is_null($this->getInput('creator'))) { $html = getSimpleHTMLDOMCached($this->getURI()); if ($html) { preg_match('#"name": "(.*)"#', $html->save(), $matches); return 'Patreon posts from ' . stripcslashes($matches[1]); } else { return $this->getInput('creator') . 'posts from Patreon'; } } return parent::getName(); } public function getURI() { if (!is_null($this->getInput('creator'))) { return self::URI . $this->getInput('creator'); } return parent::getURI(); } public function detectParameters($url) { $params = []; // Matches e.g. https://www.patreon.com/SomeCreator $regex = '/^(https?:\/\/)?(www\.)?patreon\.com\/([^\/&?\n]+)/'; if (preg_match($regex, $url, $matches) > 0) { $params['creator'] = urldecode($matches[3]); return $params; } return null; } } ================================================ FILE: bridges/PaulGrahamBridge.php ================================================ find('body table'); if (!isset($tables[0])) { return; } $tds = $tables[0]->find('td'); if (!isset($tds[2])) { return; } $contentTd = $tds[2]; // Find all inner tables (each one holds a single essay link) $essayTables = $contentTd->find('table'); if (!isset($essayTables[1])) { return; } $essayTable = $essayTables[1]; // /html/body/table/tbody/tr/td[3]/table[2]/tbody/tr[2]/td/font/a $links = $essayTable->find('font'); $essayLinks = []; foreach ($links as $t) { $link = $t->find('a', 0); if (!$link) { continue; } $href = trim($link->href); $title = trim($link->plaintext); if (empty($href) || strpos($href, 'http') === 0 || !preg_match('/\.html$/', $href)) { continue; } $essayLinks[] = [ 'title' => $title, 'url' => 'https://www.paulgraham.com/' . $href, ]; } // Only fetch the first 10 (in display order) $essayLinks = array_slice($essayLinks, 0, 10); foreach ($essayLinks as $essay) { $item = [ 'uri' => $essay['url'], 'title' => $essay['title'], 'uid' => $essay['url'], 'content' => '', ]; $essayHtml = getSimpleHTMLDOMCached($essay['url']); if ($essayHtml) { $essayTables = $essayHtml->find('body table'); if (isset($essayTables[0])) { $essayTds = $essayTables[0]->find('td'); if (isset($essayTds[2])) { $mainContent = $essayTds[2]->innertext; $mainDom = str_get_html($mainContent); // Strip unwanted layout elements foreach ($mainDom->find('map, img, script') as $el) { $el->outertext = ''; } $item['content'] = $mainDom->save(); } } } $this->items[] = $item; } } } ================================================ FILE: bridges/PcGamerBridge.php ================================================ self::LIMIT, ] ]; public function collectData() { $html = getSimpleHTMLDOMCached($this->getURI(), 300); $stories = $html->find('a.article-link'); $limit = $this->getInput('limit') ?? 10; foreach (array_slice($stories, 0, $limit) as $element) { $item = []; $item['uri'] = $element->href; $articleHtml = getSimpleHTMLDOMCached($item['uri']); // Relying on meta tags ought to be more reliable. $item['title'] = $articleHtml->find('meta[property=og:title]', 0)->content; $item['content'] = html_entity_decode($articleHtml->find('meta[name=description]', 0)->content); // TODO: parsely-author is no longer available, but it is in the application/ld+json $item['author'] = $articleHtml->find('a[rel=author]', 0)->innertext; $imageUrl = $articleHtml->find('meta[property=og:image]', 0); if ($imageUrl) { $item['enclosures'][] = $imageUrl->content; } /* Tags in mrf:tags are semicolon-delimited and each begins with a label and a ':' Example: "region:US;articleType:News;channel:Gaming software;" Find the tag, replace ; with \n, remove the label prefixes, then explode by newline. */ $item['categories'] = array_unique( explode( PHP_EOL, preg_replace( '/^[^:]+:/m', '', preg_replace( '/;/', PHP_EOL, $articleHtml->find('meta[property=mrf:tags]', 0)->content ) ) ) ); $item['timestamp'] = strtotime($articleHtml->find('meta[name=pub_date]', 0)->content); $this->items[] = $item; } } } ================================================ FILE: bridges/PepperBridgeAbstract.php ================================================ queriedContext) { case $this->i8n('context-keyword'): return $this->collectDataKeywords(); break; case $this->i8n('context-group'): return $this->collectDataGroup(); break; case $this->i8n('context-talk'): return $this->collectDataTalk(); break; } } /** * Get the Deal data from the choosen group in the choosed order */ protected function collectDataGroup() { $url = $this->getGroupURI(); $this->collectDeals($url); } /** * Get the Deal data from the choosen keywords and parameters */ protected function collectDataKeywords() { /* Even if the original website uses POST with the search page, GET works too */ $url = $this->getSearchURI(); $this->collectDeals($url); } /** * Get the Deal data using the given URL */ protected function collectDeals($url) { $html = getSimpleHTMLDOM($url); $list = $html->find('article[id][class*=thread--deal]]'); // Deal Description CSS Selector $selectorDescription = implode( ' ', /* Notice this is a space! */ [ 'overflow--wrap-break' ] ); // If there is no results, we don't parse the content because it display some random deals $noresult = $html->find('div[id=content-list]', 0)->find('h2', 0); if ($noresult !== null) { $this->items = []; } else { foreach ($list as $deal) { // Get the JSON Data stored as vue $jsonDealData = $this->getDealJsonData($deal); // DEPRECATED : website does not show this info in the deal list anymore // $dealMeta = Json::decode($deal->find('div[class=js-vue3]', 1)->getAttribute('data-vue3')); $item = []; $item['uri'] = $this->getDealURI($jsonDealData); $item['title'] = $this->getTitle($jsonDealData); $item['author'] = $this->getDealAuthor($jsonDealData); $item['content'] = '
    ' . $this->getImage($deal) . '' . $this->getHTMLTitle($jsonDealData) . $this->getPrice($jsonDealData) . $this->getDiscount($jsonDealData) /* * DEPRECATED : the list does not show this info anymore * . $this->getShipsFrom($dealMeta) */ . $this->getShippingCost($jsonDealData) . $this->getSource($jsonDealData) . $this->getDealLocation($jsonDealData) . $deal->find('div[class*=' . $selectorDescription . ']', 0)->innertext . '' . $this->getTemperature($jsonDealData) . '
    '; $item['timestamp'] = $this->getPublishedDate($jsonDealData); $this->items[] = $item; } } } /** * Get the Talk lastest comments */ protected function collectDataTalk() { $threadURL = $this->getInput('url'); $onlyWithUrl = $this->getInput('only_with_url'); // Get Thread ID from url passed in parameter $threadSearch = preg_match('/-([0-9]{1,20})$/', $threadURL, $matches); // Show an error message if we can't find the thread ID in the URL sent by the user if ($threadSearch !== 1) { throwClientException($this->i8n('thread-error')); } $threadID = $matches[1]; $url = $this->i8n('bridge-uri') . 'graphql'; // Get Cookies header to do the query $cookiesHeaderValue = $this->getCookiesHeaderValue($url); // GraphQL String // This was extracted from https://www.dealabs.com/assets/js/modern/common_211b99.js // This string was extracted during a Website visit, and minified using this neat tool : // https://codepen.io/dangodev/pen/Baoqmoy $graphqlString = <<<'HEREDOC' query comments($filter:CommentFilter!,$limit:Int,$page:Int){comments(filter:$filter,limit:$limit,page:$page){ items{...commentFields}pagination{...paginationFields}}}fragment commentFields on Comment{commentId threadId url preparedHtmlContent user{...userMediumAvatarFields...userNameFields...userPersonaFields bestBadge{...badgeFields}} reactionCounts{type count}deletable currentUserReaction{type}reported reportable source status createdAt updatedAt ignored popular deletedBy{username}notes{content createdAt user{username}}lastEdit{reason timeAgo userId}}fragment userMediumAvatarFields on User{userId isDeletedOrPendingDeletion imageUrls(slot:"default",variations: ["user_small_avatar"])}fragment userNameFields on User{userId username isUserProfileHidden isDeletedOrPendingDeletion} fragment userPersonaFields on User{persona{type text}}fragment badgeFields on Badge{badgeId level{...badgeLevelFields}} fragment badgeLevelFields on BadgeLevel{key name description}fragment paginationFields on Pagination{count current last next previous size order} HEREDOC; // Construct the JSON object to send to the Website $queryArray = [ 'query' => $graphqlString, 'variables' => [ 'filter' => [ 'threadId' => [ 'eq' => $threadID, ], 'order' => [ 'direction' => 'Descending', ], ], 'page' => 1, ], ]; $queryJSON = json_encode($queryArray); // HTTP headers $header = [ 'Content-Type: application/json', 'Accept: application/json, text/plain, */*', 'X-Pepper-Txn: threads.show', 'X-Request-Type: application/vnd.pepper.v1+json', 'X-Requested-With: XMLHttpRequest', "Cookie: $cookiesHeaderValue", ]; // CURL Options $opts = [ CURLOPT_POST => 1, CURLOPT_POSTFIELDS => $queryJSON ]; $json = getContents($url, $header, $opts); $objects = json_decode($json); foreach ($objects->data->comments->items as $comment) { $item = []; $item['uri'] = $comment->url; $item['title'] = $comment->user->username . ' - ' . $comment->createdAt; $item['author'] = $comment->user->username; $item['content'] = $comment->preparedHtmlContent; $item['uid'] = $comment->commentId; // Timestamp handling needs a new parsing function if ($onlyWithUrl == true) { // Only parse the comment if it is not empry if ($item['content'] != '') { // Count Links and Quote Links $content = str_get_html($item['content']); $countLinks = count($content->find('a[href]')); $countQuoteLinks = count($content->find('a[href][class=userHtml-quote-source]')); // Only add element if there are Links and more links tant Quote links if ($countLinks > 0 && $countLinks > $countQuoteLinks) { $this->items[] = $item; } } } else { $this->items[] = $item; } } } private function getCookiesHeaderValue($url) { $response = getContents($url, [], [], true); $setCookieHeaders = $response->getHeader('set-cookie', true); $cookies = array_map(fn($c): string => explode(';', $c)[0], $setCookieHeaders); return implode('; ', $cookies); } /** * 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($jsonDealData) { if ($jsonDealData['props']['thread']['discountType'] == null) { $price = $jsonDealData['props']['thread']['price']; return '
    ' . $this->i8n('price') . ' : ' . $price . ' ' . $this->i8n('currency') . '
    '; } else { return ''; } } /** * Get the Publish Date from a Deal if it exists * @return integer Timestamp of the published date of the deal */ private function getPublishedDate($jsonDealData) { return $jsonDealData['props']['thread']['publishedAt']; } /** * Get the Deal Author from a Deal if it exists * @return String Author of the deal */ private function getDealAuthor($jsonDealData) { return $jsonDealData['props']['thread']['user']['username']; } /** * Get the Title from a Deal if it exists * @return string String of the deal title */ private function getTitle($jsonDealData) { $title = $jsonDealData['props']['thread']['title']; return $title; } /** * Get the Title from a Talk if it exists * @return string String of the Talk title */ private function getTalkTitle() { $cacheKey = $this->getInput('url') . 'TITLE'; $title = $this->loadCacheValue($cacheKey); // The cache does not contain the title of the bridge, we must get it and save it in the cache if ($title === null) { $html = getSimpleHTMLDOMCached($this->getInput('url')); $title = $html->find('title', 0)->plaintext; // Save the value in the cache for the next 15 days $this->saveCacheValue($cacheKey, $title, 86400 * 15); } return $title; } /** * Get the Title from a Group if it exists * @return string String of the Talk title */ private function getGroupTitle() { $cacheKey = $this->getInput('group') . 'TITLE'; $title = $this->loadCacheValue($cacheKey); // The cache does not contain the title of the bridge, we must get it and save it in the cache if ($title == null) { $html = getSimpleHTMLDOMCached($this->getGroupURI()); // Search the title in the javascript mess preg_match('/threadGroupName":"([^"]*)","threadGroupUrlName":"' . $this->getInput('group') . '"/m', $html, $matches); $title = $matches[1]; // Save the value in the cache for the next 15 days $this->saveCacheValue($cacheKey, $title, 86400 * 15); } $order = $this->getKey('order'); return $title . ' - ' . $order; } /** * Get the HTML Title code from an item * @return string String of the deal title */ private function getHTMLTitle($jsonDealData) { $html = '

    ' . $this->getTitle($jsonDealData) . '

    '; return $html; } /** * Get the URI from a Deal if it exists * @return string String of the deal URI */ private function getDealURI($jsonDealData) { $dealSlug = $jsonDealData['props']['thread']['titleSlug']; $dealId = $jsonDealData['props']['thread']['threadId']; $uri = $this->i8n('bridge-uri') . $this->i8n('uri-deal') . $dealSlug . '-' . $dealId; return $uri; } /** * Get the Shipping costs from a Deal if it exists * @return string String of the deal shipping Cost */ private function getShippingCost($jsonDealData) { $isFree = $jsonDealData['props']['thread']['shipping']['isFree']; $price = $jsonDealData['props']['thread']['shipping']['price']; if ($isFree !== null) { return '
    ' . $this->i8n('shipping') . ' : ' . $price . ' ' . $this->i8n('currency') . '
    '; } else { return ''; } } /** * Get the temperature from a Deal if it exists * @return string String of the deal temperature */ private function getTemperature($data) { return $data['props']['thread']['temperature'] . '°'; } /** * Get the Deal data from the "data-vue2" JSON attribute * @return array Array containg the deal properties contained in the "data-vue2" attribute */ private function getDealJsonData($deal) { $data = Json::decode($deal->find('div[class=js-vue3]', 0)->getAttribute('data-vue3')); return $data; } /** * Get the source of a Deal if it exists * @return string String of the deal source */ private function getSource($jsonData) { if ($jsonData['props']['thread']['merchant'] != null) { $path = $this->i8n('uri-merchant') . $jsonData['props']['thread']['merchant']['merchantId']; $text = $jsonData['props']['thread']['merchant']['merchantName']; return '
    ' . $this->i8n('origin') . ' : ' . $text . '
    '; } 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($jsonDealData) { $oldPrice = $jsonDealData['props']['thread']['nextBestPrice']; $newPrice = $jsonDealData['props']['thread']['price']; $percentage = $jsonDealData['props']['thread']['percentage']; if ($oldPrice != 0) { // If there is no percentage calculated, then calculate it manually if ($percentage == 0) { $percentage = round(100 - ($newPrice * 100 / $oldPrice), 2); } return '
    ' . $this->i8n('discount') . ' : ' . $oldPrice . ' ' . $this->i8n('currency') . '  -' . $percentage . ' %
    '; } else { return ''; } } /** * Get the Deal location if it exists * @return string String of the deal location */ private function getDealLocation($jsonDealData) { if ($jsonDealData['props']['thread']['isLocal']) { $content = '
    ' . $this->i8n('deal-type') . ' : ' . $this->i8n('localdeal') . '
    '; } else { $content = ''; } return $content; } /** * Get the Picture URL from a Deal if it exists * @return string String of the deal Picture URL */ private function getImage($deal) { // Get thread Image JSON content $content = Json::decode($deal->find('div[class=js-vue3]', 0)->getAttribute('data-vue3')); //return ''; return ''; } /** * Get the originating country from a Deal if it exists * @return string String of the deal originating country * DEPRECATED : the deal on the result list does not contain this info anymore */ private function getShipsFrom($dealMeta) { $metas = $dealMeta['props']['metaRibbons'] ?? []; $shipsFrom = null; foreach ($metas as $meta) { if ($meta['type'] == 'dispatched-from') { $shipsFrom = $meta['text']; } } if ($shipsFrom != null) { return '
    ' . $shipsFrom . '
    '; } return ''; } /** * 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'): return $this->i8n('bridge-name') . ' - ' . $this->i8n('title-group') . ' : ' . $this->getGroupTitle(); break; case $this->i8n('context-talk'): return $this->i8n('bridge-name') . ' - ' . $this->i8n('title-talk') . ' : ' . $this->getTalkTitle(); break; default: // Return default value return static::NAME; } } /** * Returns the RSS Feed URI according to the parameters * @return string the RSS feed Title */ public function getURI() { switch ($this->queriedContext) { case $this->i8n('context-keyword'): return $this->getSearchURI(); break; case $this->i8n('context-group'): return $this->getGroupURI(); break; case $this->i8n('context-talk'): return $this->getTalkURI(); break; default: // Return default value return static::URI; } } /** * Returns the RSS Feed URI for a keyword Feed * @return string the RSS feed URI */ private function getSearchURI() { $q = $this->getInput('q'); $hide_expired = $this->getInput('hide_expired'); $hide_local = $this->getInput('hide_local'); $priceFrom = $this->getInput('priceFrom'); $priceTo = $this->getInput('priceTo'); $url = $this->i8n('bridge-uri') . 'search?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'; return $url; } /** * Returns the RSS Feed URI for a group Feed * @return string the RSS feed URI */ private function getGroupURI() { $group = $this->getInput('group'); $order = $this->getInput('order'); $subgroups = $this->getInput('subgroups'); // This permit to keep the existing Feed to work if ($order == $this->i8n('context-hot')) { $sortBy = 'temp'; } else if ($order == $this->i8n('context-new')) { $sortBy = 'new'; } $url = $this->i8n('bridge-uri') . $this->i8n('uri-group') . $group . '?sortBy=' . $sortBy . '&groups=' . $subgroups; return $url; } /** * Returns the RSS Feed URI for a Talk Feed * @return string the RSS feed URI */ private function getTalkURI() { $url = $this->getInput('url'); return $url; } /** * 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; } } } ================================================ FILE: bridges/PhoronixBridge.php ================================================ [ 'name' => 'Limit', 'type' => 'number', 'required' => false, 'title' => 'Maximum number of items to return', 'defaultValue' => 10 ], 'svgAsImg' => [ 'name' => 'SVG in "image" tag', 'type' => 'checkbox', 'title' => 'Some benchmarks are exported as SVG with "object" tag, but some RSS readers don\'t support this. "img" tag are supported by most browsers', 'defaultValue' => false ], ]]; public function collectData() { $this->collectExpandableDatas('https://www.phoronix.com/rss.php', $this->getInput('n')); } protected function parseItem(array $item) { $itemUrl = $item['uri']; $articlePage = getSimpleHTMLDOM($itemUrl); $articlePage = defaultLinkTo($articlePage, $this->getURI()); // Extract final link. From Facebook's like plugin. $parsedUrlQuery = parse_url($articlePage->find('iframe[src^=//www.facebook.com/plugins]', 0), PHP_URL_QUERY); parse_str($parsedUrlQuery, $facebookQuery); if (array_key_exists('href', $facebookQuery)) { $itemUrl = $facebookQuery['href']; } $item['content'] = $this->extractContent($articlePage); $pages = $articlePage->find('.pagination a[!title]'); foreach ($pages as $page) { $pageURI = urljoin($itemUrl, html_entity_decode($page->href)); $page = getSimpleHTMLDOM($pageURI); $item['content'] .= $this->extractContent($page); } return $item; } private function extractContent($page) { $content = $page->find('.content', 0); $objects = $content->find('script[src^=//openbenchmarking.org]'); foreach ($objects as $object) { $objectSrc = preg_replace('/p=0/', 'p=2', $object->src); if ($this->getInput('svgAsImg')) { $object->outertext = ''; } else { $object->outertext = ''; } } $content = stripWithDelimiters($content, ''); return $content; } } ================================================ FILE: bridges/PicalaBridge.php ================================================ 'actualites', 'Économie' => 'economie', 'Tests' => 'tests', 'Pratique' => 'pratique', ]; const NAME = 'Picala'; const URI = 'https://www.picala.fr'; const DESCRIPTION = 'Dernière nouvelles du média indépendant sur le vélo électrique'; const MAINTAINER = 'Chouchen'; const PARAMETERS = [ [ 'type' => [ 'name' => 'Type', 'type' => 'list', 'values' => self::TYPES, ], ], ]; public function getURI() { if (!is_null($this->getInput('type'))) { return sprintf('%s/%s', static::URI, $this->getInput('type')); } return parent::getURI(); } public function getIcon() { return 'https://picala-static.s3.amazonaws.com/static/img/favicon/favicon-32x32.png'; } public function getDescription() { if (!is_null($this->getInput('type'))) { return sprintf('%s - %s', static::DESCRIPTION, array_search($this->getInput('type'), self::TYPES)); } return parent::getDescription(); } public function getName() { if (!is_null($this->getInput('type'))) { return sprintf('%s - %s', static::NAME, array_search($this->getInput('type'), self::TYPES)); } return parent::getName(); } public function collectData() { $fullhtml = getSimpleHTMLDOM($this->getURI()); foreach ($fullhtml->find('.list-container-category a') as $article) { $firstImage = $article->find('img', 0); $image = null; if ($firstImage !== null) { $srcsets = explode(',', $firstImage->getAttribute('srcset')); $image = explode(' ', trim(array_shift($srcsets)))[0]; } $item = []; $item['uri'] = self::URI . $article->href; $item['title'] = $article->find('h2', 0)->plaintext; if ($image === null) { $item['content'] = $article->find('.teaser__text', 0)->plaintext; } else { $item['content'] = sprintf( '
    %s', $image, $article->find('.teaser__text', 0)->plaintext ); } $this->items[] = $item; } } } ================================================ FILE: bridges/PicartoBridge.php ================================================ [ 'name' => 'Channel name', 'type' => 'text', 'required' => true, 'title' => 'Channel name', 'exampleValue' => 'Wysdrem', ], ] ]; public function collectData() { $channel = $this->getInput('channel'); $data = json_decode(getContents('https://api.picarto.tv/api/v1/channel/name/' . $channel), true); if (!$data['online']) { return; } $lastLive = new \DateTime($data['last_live']); $this->items[] = [ 'uri' => 'https://picarto.tv/' . $channel, 'title' => $data['name'] . ' is now online', 'content' => sprintf('', $data['thumbnails']['tablet']), 'timestamp' => $lastLive->getTimestamp(), 'uid' => 'https://picarto.tv/' . $channel . $lastLive->getTimestamp(), ]; } public function getName() { return 'Picarto - ' . $this->getInput('channel'); } } ================================================ FILE: bridges/PickyWallpapersBridge.php ================================================ [ 'name' => 'category', 'exampleValue' => 'funny', 'required' => true ], 's' => [ 'name' => 'subcategory' ], 'm' => [ 'name' => 'Max number of wallpapers', 'defaultValue' => 12, 'type' => 'number' ], 'r' => [ '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 . '/'); 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 = []; $item['uri'] = str_replace('www', 'wallpaper', self::URI) . '/' . $resolution . '/' . basename($element->src); $item['timestamp'] = time(); $item['title'] = $element->alt; $item['content'] = $item['title'] . '
    ' . $element . ''; $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(); } } ================================================ FILE: bridges/PicnobBridge.php ================================================ [ 'u' => [ 'name' => 'username', 'type' => 'text', 'title' => 'Instagram username you want to follow', 'exampleValue' => 'aesoprockwins', 'required' => true, ], ], 'Hashtag' => [ 'h' => [ 'name' => 'hashtag', 'type' => 'text', 'title' => 'Instagram hastag you want to follow, without the \'#\'', 'exampleValue' => 'beautifulday', 'required' => true, ], ] ]; public function getURI() { if (!is_null($this->getInput('u'))) { return urljoin(self::URI, '/profile/' . $this->getInput('u') . '/'); } if (!is_null($this->getInput('h'))) { return urljoin(self::URI, '/tag/' . trim($this->getInput('h') . '/')); } return parent::getURI(); } public function collectData() { $html = getSimpleHTMLDOM($this->getURI()); foreach ($html->find('.items') as $part) { foreach ($part->find('.item') as $element) { $url = urljoin(self::URI, $element->find('a', 0)->href); $date = date_create(); $relativeDate = date_interval_create_from_date_string(str_replace(' ago', '', $element->find('.time', 0)->plaintext)); if ($relativeDate) { date_sub($date, $relativeDate); } $description = defaultLinkTo(trim($element->find('.sum', 0)->innertext), self::URI); $isVideo = (bool) $element->find('.icon_video', 0); $videoNote = $isVideo ? '

    (video)

    ' : ''; $isTV = (bool) $element->find('.icon_tv', 0); $tvNote = $isTV ? '

    (TV)

    ' : ''; $isMoreContent = (bool) $element->find('.icon_multi', 0); $moreContentNote = $isMoreContent ? '

    (multiple images and/or videos)

    ' : ''; $imageUrl = $element->find('.img', 0)->getAttribute('data-src'); $uid = explode('/', parse_url($url, PHP_URL_PATH))[2]; $this->items[] = [ 'uri' => $url, 'timestamp' => date_format($date, 'r'), 'title' => strlen($description) > 60 ? mb_substr($description, 0, 57) . '...' : $description, 'thumbnail' => $imageUrl, 'enclosures' => [$imageUrl], 'content' => << {$videoNote} {$tvNote} {$moreContentNote}

    {$description}

    HTML, 'uid' => $uid ]; } } } public function getName() { if (!is_null($this->getInput('u'))) { return 'Username ' . $this->getInput('u') . ' - Picnob'; } if (!is_null($this->getInput('h'))) { return 'Hashtag ' . $this->getInput('h') . ' - Picnob'; } return parent::getName(); } } ================================================ FILE: bridges/PicukiBridge.php ================================================ [ 'count' => [ 'name' => 'Count', 'type' => 'number', 'title' => 'How many posts to fetch', 'defaultValue' => 12 ] ], 'Username' => [ 'u' => [ 'name' => 'username', 'exampleValue' => 'aesoprockwins', 'required' => true, ], ], 'Hashtag' => [ 'h' => [ 'name' => 'hashtag', 'exampleValue' => 'beautifulday', 'required' => true, ], ] ]; public function getURI() { if (!is_null($this->getInput('u'))) { return urljoin(self::URI, '/profile/' . $this->getInput('u')); } if (!is_null($this->getInput('h'))) { return urljoin(self::URI, '/tag/' . trim($this->getInput('h'), '#')); } return parent::getURI(); } public function collectData() { $re = '#let short_code = "(.*?)";\s*$#m'; $html = getSimpleHTMLDOM($this->getURI()); $requestedCount = $this->getInput('count'); if ($requestedCount > 12) { // Picuki shows 12 posts per page at initial load. throw new \Exception('Maximum count is 12'); } $count = 0; foreach ($html->find('div[class=.box-photo][data-s=media]') as $element) { // skip ad items if (in_array('adv', explode(' ', $element->class))) { continue; } $url = $element->find('a', 0)->href; $html_single = getSimpleHTMLDOMCached($url); $sourceUrl = null; if (preg_match($re, $html_single, $matches) > 0) { $sourceUrl = 'https://instagram.com/p/' . $matches[1]; } //$author = trim($element->find('.single-photo-nickname', 0)->plaintext); $date = date_create(); $relativeDate = str_replace(' ago', '', $element->find('.time', 0)->plaintext); date_sub($date, date_interval_create_from_date_string($relativeDate)); $description = trim($element->find('.photo-action-description', 0)->plaintext); $isVideo = (bool) $element->find('.video-icon', 0); $videoNote = $isVideo ? '

    (video)

    ' : ''; $imageUrl = $element->find('.post-image', 0)->src; // the last path segment needs to be encoded, because it contains special characters like + or | $imageUrlParts = explode('/', $imageUrl); $imageUrlParts[count($imageUrlParts) - 1] = urlencode($imageUrlParts[count($imageUrlParts) - 1]); $imageUrl = implode('/', $imageUrlParts); $this->items[] = [ 'uri' => $url, /*'author' => $author,*/ 'timestamp' => date_format($date, 'r'), 'title' => strlen($description) > 60 ? mb_substr($description, 0, 57) . '...' : $description, 'thumbnail' => $imageUrl, 'source' => $sourceUrl, 'enclosures' => [$imageUrl], 'content' => << {$sourceUrl} {$videoNote}

    {$description}

    HTML ]; $count++; if ($count >= $requestedCount) { break; } } } public function getName() { if (!is_null($this->getInput('u'))) { return $this->getInput('u') . ' - Picuki'; } if (!is_null($this->getInput('h'))) { return $this->getInput('h') . ' - Picuki'; } return parent::getName(); } } ================================================ FILE: bridges/PikabuBridge.php ================================================ 'Фильтр', 'type' => 'list', 'values' => [ 'Горячее' => 'hot', 'Свежее' => 'new', ], 'defaultValue' => 'hot', ]; const PARAMETERS = [ 'По тегу' => [ 'tag' => [ 'name' => 'Тег', 'exampleValue' => 'it', 'required' => true ], 'filter' => self::PARAMETERS_FILTER ], 'По сообществу' => [ 'community' => [ 'name' => 'Сообщество', 'exampleValue' => 'linux', 'required' => true ], 'filter' => self::PARAMETERS_FILTER ], 'По пользователю' => [ 'user' => [ 'name' => 'Пользователь', 'exampleValue' => 'admin', 'required' => true ] ] ]; protected $title = null; public function getURI() { if ($this->getInput('tag')) { return self::URI . '/tag/' . rawurlencode($this->getInput('tag')) . '/' . rawurlencode($this->getInput('filter')); } elseif ($this->getInput('user')) { return self::URI . '/@' . rawurlencode($this->getInput('user')); } elseif ($this->getInput('community')) { $uri = self::URI . '/community/' . rawurlencode($this->getInput('community')); if ($this->getInput('filter') != 'hot') { $uri .= '/' . rawurlencode($this->getInput('filter')); } return $uri; } else { return parent::getURI(); } } public function getIcon() { return 'https://cs.pikabu.ru/assets/favicon.ico'; } public function getName() { if (is_null($this->title)) { return parent::getName(); } else { return $this->title . ' - ' . parent::getName(); } } public function collectData() { $link = $this->getURI(); $text_html = getContents($link); $text_html = iconv('windows-1251', 'utf-8', $text_html); $html = str_get_html($text_html); $this->title = $html->find('title', 0)->innertext; foreach ($html->find('article.story') as $post) { $time = $post->find('time.story__datetime', 0); if (is_null($time)) { continue; } $el_to_remove_selectors = [ '.story__read-more', 'script', '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('[data-type=gifx]') as $el) { $src = $el->getAttribute('data-source'); $el->outertext = ''; } foreach ($post->find('img') as $img) { $src = $img->getAttribute('src'); if (!$src) { $src = $img->getAttribute('data-src'); if (!$src) { continue; } } $img->outertext = ''; // it is assumed, that img's parents are links to post itself // we don't need them $img->parent()->outertext = $img->outertext; } $categories = []; foreach ($post->find('.tags__tag') as $tag) { if ($tag->getAttribute('data-tag')) { $categories[] = $tag->innertext; } } $title_element = $post->find('.story__title-link', 0); if (str_contains($title_element->href, 'from=cpm')) { // skip sponsored posts continue; } $title = $title_element->plaintext; $community_link = $post->find('.story__community-link', 0); // adding special marker for "Maybe News" section // these posts are fake if (!is_null($community_link) && $community_link->getAttribute('href') == '/community/maybenews') { $title = '[' . trim($community_link->plaintext) . '] ' . $title; } $item = []; $item['categories'] = $categories; $item['author'] = trim($post->find('.user__nick', 0)->plaintext); $item['title'] = $title; $item['content'] = strip_tags( backgroundToImg($post->find('.story__content-inner', 0)->innertext), '

    ' ); $item['uri'] = $title_element->href; $item['timestamp'] = strtotime($time->getAttribute('datetime')); $this->items[] = $item; } } } ================================================ FILE: bridges/PillowfortBridge.php ================================================ [ 'name' => 'Username', 'type' => 'text', 'required' => true, 'exampleValue' => 'Staff' ], 'noava' => [ 'name' => 'Hide avatar', 'type' => 'checkbox', 'title' => 'Check to hide user avatars.' ], 'noreblog' => [ 'name' => 'Hide reblogs', 'type' => 'checkbox', 'title' => 'Check to only show original posts.' ], 'noretags' => [ 'name' => 'Prefer original tags', 'type' => 'checkbox', 'title' => 'Check to use tags from original post(if available) instead of reblog\'s tags' ], 'image' => [ 'name' => 'Select image type', 'type' => 'list', 'title' => 'Decides how the image is displayed, if at all.', 'values' => [ 'None' => 'None', 'Small' => 'Small', 'Full' => 'Full' ], 'defaultValue' => 'Full' ] ]]; /** * The Pillowfort bridge. * * Pillowfort pages are dynamically generated from a json file * which holds the last 20 or so posts from the given user. * This bridge uses that json file and HTML/CSS similar * to the Twitter bridge for formatting. */ public function collectData() { $jsonSite = getContents($this->getJSONURI()); $jsonFile = json_decode($jsonSite, true); $posts = $jsonFile['posts']; foreach ($posts as $post) { $item = $this->getItemFromPost($post); //empty when 'noreblogs' is checked and current post is a reblog. if (!empty($item)) { $this->items[] = $item; } } } public function getName() { $name = $this -> getUsername(); if ($name != '') { return $name . ' - ' . self::NAME; } else { return parent::getName(); } } public function getURI() { $name = $this -> getUsername(); if ($name != '') { return self::URI . '/' . $name; } else { return parent::getURI(); } } protected function getJSONURI() { return $this -> getURI() . '/json/?p=1'; } protected function getUsername() { return $this -> getInput('username'); } protected function genAvatarText($author, $avatar_url, $title) { $noava = $this -> getInput('noava'); if ($noava) { return ''; } else { return << {$author} EOD; } } protected function genImagesText($media) { $dimensions = $this -> getInput('image'); $text = ''; //preg_replace used for images with spaces in the url switch ($dimensions) { case 'None': foreach ($media as $image) { $imageURL = preg_replace('[ ]', '%20', $image['url']); $text .= << {$imageURL} EOD; } break; case 'Small': foreach ($media as $image) { $imageURL = preg_replace('[ ]', '%20', $image['small_image_url']); $text .= << EOD; } break; case 'Full': foreach ($media as $image) { $imageURL = preg_replace('[ ]', '%20', $image['url']); $text .= << EOD; } break; default: break; } return $text; } protected function getItemFromPost($post) { //check if its a reblog. if ($post['original_post_id'] == null) { $embPost = false; } else { $embPost = true; } if ($this -> getInput('noreblog') && $embPost) { return []; } $item = []; $item['uid'] = $post['id']; $item['timestamp'] = strtotime($post['created_at']); if ($embPost) { $item['uri'] = self::URI . '/posts/' . $post['original_post']['id']; $item['author'] = $post['original_username']; if ($post['original_post']['title'] != '') { $item['title'] = $post['original_post']['title']; } else { $item['title'] = '[NO TITLE]'; } } else { $item['uri'] = self::URI . '/posts/' . $post['id']; $item['author'] = $post['username']; if ($post['title'] != '') { $item['title'] = $post['title']; } else { $item['title'] = '[NO TITLE]'; } } /** * 4 cases if it is a reblog. * 1: reblog has tags, original has tags. defer to option. * 2: reblog has tags, original has no tags. use reblog tags. * 3: reblog has no tags, original has tags. use original tags. * 4: reblog has no tags, original has no tags. use reblog tags not that it matters. */ $item['categories'] = $post['tags']; if ($embPost) { if ($this -> getInput('noretags') || ($post['tags'] == null)) { $item['categories'] = $post['original_post']['tag_list']; } } $avatarText = $this -> genAvatarText( $item['author'], $post['avatar_url'], $item['title'] ); $imagesText = $this -> genImagesText($post['media']); $item['content'] = << {$avatarText}

    {$post['content']}
    {$imagesText}
    EOD; return $item; } } ================================================ FILE: bridges/PinterestBridge.php ================================================ [ 'u' => [ 'name' => 'username', 'exampleValue' => 'VIGOIndustries', 'required' => true ], 'b' => [ 'name' => 'board', 'exampleValue' => 'bathroom-remodels', 'required' => true ] ] ]; public function getIcon() { return 'https://s.pinimg.com/webapp/style/images/favicon-9f8f9adf.png'; } public function collectData() { $this->collectExpandableDatas($this->getURI() . '.rss'); $this->fixLowRes(); } 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']); $item['enclosures'] = [ $item['uri'], ]; $newitems[] = $item; } $this->items = $newitems; } public function getURI() { if ($this->queriedContext === 'By username and board') { return self::URI . '/' . urlencode($this->getInput('u')) . '/' . urlencode($this->getInput('b')); } return parent::getURI(); } public function getName() { if ($this->queriedContext === 'By username and board') { return $this->getInput('u') . ' - ' . $this->getInput('b') . ' - ' . self::NAME; } return parent::getName(); } } ================================================ FILE: bridges/PirateCommunityBridge.php ================================================ [ 'name' => 'Topic ID', 'type' => 'number', 'exampleValue' => '12651', 'title' => 'Topic ID from topic URL. If the URL contains t=12 the ID is 12.', 'required' => true ]]]; private $feedName = ''; public function detectParameters($url) { $parsed_url = parse_url($url); $host = $parsed_url['host'] ?? null; if ($host !== 'raymanpc.com') { return null; } parse_str($parsed_url['query'], $parsed_query); if ( $parsed_url['path'] === '/forum/viewtopic.php' && array_key_exists('t', $parsed_query) ) { return ['t' => $parsed_query['t']]; } return null; } public function getName() { if (!empty($this->feedName)) { return $this->feedName; } return parent::getName(); } public function getURI() { if (!is_null($this->getInput('t'))) { return self::URI . 'forum/viewtopic.php?t=' . $this->getInput('t') . '&sd=d'; // sort posts decending by ate so first page has latest posts } return parent::getURI(); } public function collectData() { $html = getSimpleHTMLDOM($this->getURI()); $this->feedName = $html->find('head title', 0)->plaintext; foreach ($html->find('.post') as $reply) { $item = []; $item['uri'] = $this->getURI() . $reply->find('h3 a', 0)->getAttribute('href'); $item['title'] = $reply->find('h3 a', 0)->plaintext; $author_html = $reply->find('.author', 0); // author_html contains the timestamp as text directly inside it, // so delete all other child elements foreach ($author_html->children as $child) { $child->outertext = ''; } // Timestamps are always in UTC+1 $item['timestamp'] = trim($author_html->innertext) . ' +01:00'; $item['author'] = $reply ->find('.username, .username-coloured', 0) ->plaintext; $item['content'] = defaultLinkTo( $reply->find('.content', 0)->innertext, $this->getURI() ); $item['enclosures'] = []; foreach ($reply->find('.attachbox img.postimage') as $img) { $item['enclosures'][] = urljoin($this->getURI(), $img->src); } $this->items[] = $item; } } } ================================================ FILE: bridges/PixivBridge.php ================================================ [ 'required' => false, 'defaultValue' => null ], 'proxy_url' => [ 'required' => false, 'defaultValue' => null ] ]; const PARAMETERS = [ 'global' => [ 'posts' => [ 'name' => 'Post Limit', 'type' => 'number', 'defaultValue' => '10' ], 'fullsize' => [ 'name' => 'Full-size Image', 'type' => 'checkbox' ], 'mode' => [ 'name' => 'Post Type', 'type' => 'list', 'values' => [ 'All Works' => 'all', 'Illustrations' => 'illustrations/', 'Manga' => 'manga/', 'Novels' => 'novels/' ] ], 'mature' => [ 'name' => 'Include R-18 works', 'type' => 'checkbox' ], 'ai' => [ 'name' => 'Include AI-Generated works', 'type' => 'checkbox' ] ], 'Tag' => [ 'tag' => [ 'name' => 'Query to search', 'exampleValue' => 'オリジナル', 'required' => true ] ], 'User' => [ 'userid' => [ 'name' => 'User ID from profile URL', 'exampleValue' => '11', 'required' => true ] ] ]; // maps from URLs to json keys by context const JSON_KEY_MAP = [ 'Tag' => [ 'illustrations/' => 'illust', 'manga/' => 'manga', 'novels/' => 'novel' ], 'User' => [ 'illustrations/' => 'illusts', 'manga/' => 'manga', 'novels/' => 'novels' ] ]; // Hold the username for getName() private $username = null; public function getName() { switch ($this->queriedContext) { case 'Tag': $context = 'Tag'; $query = $this->getInput('tag'); break; case 'User': $context = 'User'; $query = $this->username ?? $this->getInput('userid'); break; default: return parent::getName(); } return 'Pixiv ' . $this->getKey('mode') . " from {$context} {$query}"; } public function getURI() { switch ($this->queriedContext) { case 'Tag': $uri = static::URI . 'tags/' . urlencode($this->getInput('tag') ?? ''); break; case 'User': $uri = static::URI . 'users/' . $this->getInput('userid'); break; default: return parent::getURI(); } if ($this->getInput('mode') != 'all') { $uri = $uri . '/' . $this->getInput('mode'); } return $uri; } private function getSearchURI($mode) { switch ($this->queriedContext) { case 'Tag': $query = urlencode($this->getInput('tag')); $uri = static::URI . 'ajax/search/top/' . $query; break; case 'User': $uri = static::URI . 'ajax/user/' . $this->getInput('userid') . '/profile/top'; break; default: throwClientException('Invalid Context'); } return $uri; } private function getDataFromJSON($json, $json_key) { $key = $json_key; if ( $this->queriedContext === 'Tag' && $this->getOption('cookie') !== null ) { switch ($json_key) { case 'illust': case 'manga': $key = 'illustManga'; break; } } $json = $json['body'][$key]; // Tags context contains subkey if ($this->queriedContext === 'Tag') { $json = $json['data']; if ($this->getOption('cookie') !== null) { switch ($json_key) { case 'illust': $json = array_reduce($json, function ($acc, $i) { if ($i['illustType'] === 0) { $acc[] = $i; } return $acc; }, []); break; case 'manga': $json = array_reduce($json, function ($acc, $i) { if ($i['illustType'] === 1) { $acc[] = $i; }return $acc; }, []); break; } } } return $json; } private function collectWorksArray() { $content = $this->getData($this->getSearchURI($this->getInput('mode')), true, true); if ($this->getInput('mode') == 'all') { $total = []; foreach (self::JSON_KEY_MAP[$this->queriedContext] as $mode => $json_key) { $current = $this->getDataFromJSON($content, $json_key); $total = array_merge($total, $current); } $content = $total; } else { $json_key = self::JSON_KEY_MAP[$this->queriedContext][$this->getInput('mode')]; $content = $this->getDataFromJSON($content, $json_key); } return $content; } public function collectData() { $this->checkOptions(); $proxy_url = $this->getOption('proxy_url'); $proxy_url = $proxy_url ? rtrim($proxy_url, '/') : null; $content = $this->collectWorksArray(); $content = array_filter($content, function ($v, $k) { return !array_key_exists('isAdContainer', $v); }, ARRAY_FILTER_USE_BOTH); // Sort by updateDate to get newest works usort($content, function ($a, $b) { return $b['updateDate'] <=> $a['updateDate']; }); //exclude AI generated works if unchecked. if ($this->getInput('ai') !== true) { $content = array_filter($content, function ($v) { $isAI = $v['aiType'] === 2; return !$isAI; }); } //exclude R-18 works if unchecked. if ($this->getInput('mature') !== true) { $content = array_filter($content, function ($v) { $isMature = $v['xRestrict'] > 0; return !$isMature; }); } $content = array_slice($content, 0, $this->getInput('posts')); foreach ($content as $result) { // Store username for getName() if (!$this->username) { $this->username = $result['userName']; } $item = []; $item['uid'] = $result['id']; $subpath = array_key_exists('illustType', $result) ? 'artworks/' : 'novel/show.php?id='; $item['uri'] = static::URI . $subpath . $result['id']; $item['title'] = $result['title']; $item['author'] = $result['userName']; $item['timestamp'] = $result['updateDate']; $item['categories'] = $result['tags']; if ($proxy_url) { //use proxy image host if set. if ($this->getInput('fullsize')) { $ajax_uri = static::URI . 'ajax/illust/' . $result['id']; $imagejson = $this->getData($ajax_uri, true, true); $img_url = preg_replace('/https:\/\/i\.pximg\.net/', $proxy_url, $imagejson['body']['urls']['original']); } else { $img_url = preg_replace('/https:\/\/i\.pximg\.net/', $proxy_url, $result['url']); } } else { $img_url = $result['url']; } // Currently, this might result in broken image due to their strict referrer check $item['content'] = sprintf('', $img_url, $img_url); // Additional content items if (array_key_exists('pageCount', $result)) { $item['content'] .= '
    Page Count: ' . $result['pageCount']; } else { $item['content'] .= '
    Word Count: ' . $result['wordCount']; } $this->items[] = $item; } } private function checkOptions() { $proxy = $this->getOption('proxy_url'); if ($proxy) { if ( !(strlen($proxy) > 0 && preg_match('/https?:\/\/.*/', $proxy)) ) { throwServerException('Invalid proxy_url value set. The proxy must include the HTTP/S at the beginning of the url.'); } } $cookie = $this->getCookie(); if ($cookie) { $isAuth = $this->loadCacheValue('is_authenticated'); if (!$isAuth) { $res = $this->getData('https://www.pixiv.net/ajax/webpush', true, true); if ($res['error'] === false) { $this->saveCacheValue('is_authenticated', true); } } } } private function checkCookie(array $headers) { if (array_key_exists('set-cookie', $headers)) { foreach ($headers['set-cookie'] as $value) { if (str_starts_with($value, 'PHPSESSID=')) { parse_str(strtr($value, ['&' => '%26', '+' => '%2B', ';' => '&']), $cookie); if ($cookie['PHPSESSID'] != $this->getCookie()) { $this->saveCacheValue('cookie', $cookie['PHPSESSID']); } break; } } } } private function getCookie() { // checks if cookie is set, if not initialise it with the cookie from the config $value = $this->loadCacheValue('cookie'); if (!$value) { $value = $this->getOption('cookie'); // 30 days + 1 day to let cookie chance to renew $this->saveCacheValue('cookie', $this->getOption('cookie'), 2678400); } return $value; } //Cache getContents by default private function getData(string $url, bool $cache = true, bool $getJSON = false, array $httpHeaders = [], array $curlOptions = []) { $cookie_str = $this->getCookie(); if ($cookie_str) { $curlOptions[CURLOPT_COOKIE] = 'PHPSESSID=' . $cookie_str; } if ($cache) { $response = $this->loadCacheValue($url); if (!$response || is_array($response)) { $response = getContents($url, $httpHeaders, $curlOptions, true); $this->saveCacheValue($url, $response); } } else { $response = getContents($url, $httpHeaders, $curlOptions, true); } $this->checkCookie($response->getHeaders()); if ($getJSON) { return json_decode($response->getBody(), true); } return $response->getBody(); } } ================================================ FILE: bridges/PlantUMLReleasesBridge.php ================================================ getURI()), self::URI); $num_items = 0; $main = $html->find('div[id=root]', 0); foreach ($main->find('h2') as $release) { // Limit to $ITEM_LIMIT number of results if ($num_items++ >= self::ITEM_LIMIT) { break; } $item = []; $item['author'] = self::AUTHOR; $release_text = $release->innertext; if (preg_match('/(.+) \((.*)\)/', $release_text, $matches)) { $item['title'] = $matches[1]; $item['timestamp'] = preg_replace('/(\d+) (\w{3})\w*, (\d+)/', '${1} ${2} ${3}', $matches[2]); } else { $item['title'] = $release_text; } $item['uri'] = $this->getURI(); $item['content'] = $release->next_sibling(); $this->items[] = $item; } } } ================================================ FILE: bridges/PokemonNewsBridge.php ================================================ find('.news-list ul li') as $item) { $title = $item->find('h3', 0)->plaintext; $description = $item->find('p.hidden-mobile', 0); $dateString = $item->find('p.date', 0)->plaintext; // e.g. September 15, 2022 $createdAt = date_create_from_format('F d, Y', $dateString); // todo: $tagsString = $item->find('p.tags', 0)->plaintext; $path = $item->find('a', 0)->href; $imagePath = $item->find('img', 0)->src; $tags = explode('&', $tagsString); $tags = array_map('trim', $tags); $this->items[] = [ 'title' => $title, 'uri' => sprintf('https://www.pokemon.com%s', $path), 'timestamp' => $createdAt ? $createdAt->getTimestamp() : time(), 'categories' => $tags, 'content' => sprintf( '

    %s', $imagePath, $description ? $description->plaintext : '' ), ]; } } } ================================================ FILE: bridges/PornhubBridge.php ================================================ [ 'name' => 'User name', 'exampleValue' => 'asa-akira', 'required' => true, ], 'type' => [ 'name' => 'User type', 'type' => 'list', 'values' => [ 'user' => 'users', 'model' => 'model', 'pornstar' => 'pornstar', ], 'defaultValue' => 'pornstar', ], 'sort' => [ 'name' => 'Sort by', 'type' => 'list', 'values' => [ 'Most recent' => '?', 'Most views' => '?o=mv', 'Top rated' => '?o=tr', 'Longest' => '?o=lg', ], 'defaultValue' => '?', ], 'show_images' => [ 'name' => 'Show thumbnails', 'type' => 'checkbox', ], ]]; public function getName() { if (!is_null($this->getInput('type')) && !is_null($this->getInput('q'))) { return 'PornHub ' . $this->getInput('type') . ':' . $this->getInput('q'); } return parent::getName(); } public function collectData() { $uri = 'https://www.pornhub.com/' . $this->getInput('type') . '/'; switch ($this->getInput('type')) { // select proper permalink format per user type... case 'model': $uri .= urlencode($this->getInput('q')) . '/videos' . $this->getInput('sort'); break; case 'users': $uri .= urlencode($this->getInput('q')) . '/videos/public' . $this->getInput('sort'); break; case 'pornstar': $uri .= urlencode($this->getInput('q')) . '/videos/upload' . $this->getInput('sort'); break; } $show_images = $this->getInput('show_images'); $html = getSimpleHTMLDOM($uri, [ 'cookie: accessAgeDisclaimerPH=1' ]); foreach ($html->find('div.videoUList ul.videos li.videoblock') as $element) { $item = []; $item['author'] = $this->getInput('q'); // Title $title = $element->find('a', 0)->getAttribute('title'); if (is_null($title)) { continue; } $item['title'] = $title; // Url $url = $element->find('a', 0)->href; $item['uri'] = 'https://www.pornhub.com' . $url; // Duration $marker = $element->find('div.marker-overlays var', 0); $duration = $marker->innertext ?? ''; // Content $videoImage = $element->find('img', 0); $image = $videoImage->getAttribute('data-src') ?: $videoImage->getAttribute('src'); if ($show_images === true) { $item['content'] = sprintf('
    %s', $item['uri'], $image, $duration); } $uploaded = explode('/', $image); if (isset($uploaded[4])) { // date hack, guess upload YYYYMMDD from thumbnail URL (format: https://ci.phncdn.com/videos/201907/25/--- ) $uploadTimestamp = strtotime($uploaded[4] . $uploaded[5]); $item['timestamp'] = $uploadTimestamp; } else { // The thumbnail url did not have a date in it for some unknown reason } $this->items[] = $item; } } } ================================================ FILE: bridges/PresidenciaPTBridge.php ================================================ [ '/atualidade/noticias' => [ 'name' => 'Notícias', 'type' => 'checkbox', 'defaultValue' => 'checked', ], '/atualidade/mensagens' => [ 'name' => 'Mensagens', 'type' => 'checkbox', 'defaultValue' => 'checked', ], '/atualidade/atividade-legislativa' => [ 'name' => 'Atividade Legislativa', 'type' => 'checkbox', 'defaultValue' => 'checked', ], '/atualidade/notas-informativas' => [ 'name' => 'Notas Informativas', 'type' => 'checkbox', 'defaultValue' => 'checked', ] ] ]; const PT_MONTH_NAMES = [ 'janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro']; public function getIcon() { return 'https://www.presidencia.pt/Theme/favicon/apple-touch-icon.png'; } public function collectData() { $contexts = $this->getParameters(); foreach (array_keys($contexts['Section']) as $k) { if ($this->getInput($k)) { $html = getSimpleHTMLDOMCached($this->getURI() . $k); foreach ($html->find('#atualidade-list article.card-block') as $element) { $item = []; $link = $element->find('a', 0); $etitle = $element->find('.article-title', 0); $edts = $element->find('.date', 0); $edt = $edts->innertext; $item['title'] = strip_tags($etitle->innertext); $item['uri'] = self::URI . $link->href; $item['description'] = $element; $item['timestamp'] = str_ireplace( array_map(function ($name) { return ' de ' . $name . ' de '; }, self::PT_MONTH_NAMES), array_map(function ($num) { return sprintf('-%02d-', $num); }, range(1, count(self::PT_MONTH_NAMES))), $edt ); $this->items[] = $item; } } } } } ================================================ FILE: bridges/PriviblurBridge.php ================================================ [ 'name' => 'URL', 'exampleValue' => 'https://priviblur.fly.dev', 'required' => true, ] ] ]; private $title; private $favicon = 'https://www.tumblr.com/favicon.ico'; public function collectData() { $url = $this->getURI(); $html = getSimpleHTMLDOM($url); $html = defaultLinkTo($html, $url); $this->title = $html->find('head title', 0)->innertext; if ($html->find('#blog-header img.avatar', 0)) { $icon = $html->find('#blog-header img.avatar', 0)->src; $this->favicon = str_replace('pnj', 'png', $icon); } $elements = $html->find('.post'); foreach ($elements as $element) { $item = []; $item['author'] = $element->find('.primary-post-author .blog-name', 0)->innertext; $item['comments'] = $element->find('.interaction-buttons > a', 1)->href; $item['content'] = $element->find('.post-body', 0); $item['timestamp'] = $element->find('.primary-post-author time', 0)->innertext; $item['title'] = $item['author'] . ': ' . $item['timestamp']; $item['uid'] = $item['comments']; // tumblr url is canonical $item['uri'] = $element->find('.interaction-buttons > a', 0)->href; if ($element->find('.post-tags', 0)) { $tags = html_entity_decode($element->find('.post-tags', 0)->plaintext); $tags = explode('#', $tags); $tags = array_map('trim', $tags); array_shift($tags); $item['categories'] = $tags; } $heading = $element->find('h1', 0); if ($heading) { $item['title'] = $heading->innertext; } $this->items[] = $item; } } public function getName() { $name = parent::getName(); if (isset($this->title)) { $name = $this->title; } return $name; } public function getURI() { return $this->getInput('url') ?? parent::getURI(); } public function getIcon() { return $this->favicon; } } ================================================ FILE: bridges/QnapBridge.php ================================================ Use offical feed instead: https://www.qnap.com/fr-fr/security-news/feed

    Unofficial feed for security news. DESCRIPTION; const MAINTAINER = 'dvikan'; public function collectData() { $thisYear = date('Y'); $url = sprintf('https://www.qnap.com/api/v1/articles/security-news?locale=fr-fr&year=%s&page=1', $thisYear); $response = json_decode(getContents($url)); foreach ($response->data as $post) { $item = []; $item['uri'] = sprintf('https://www.qnap.com%s', $post->url); $item['title'] = $post->title; $item['timestamp'] = \DateTime::createFromFormat('Y-m-d', $post->date)->format('U'); $image = sprintf('', $post->image_url); $item['content'] = $image . '

    ' . $post->desc; $this->items[] = $item; } usort($this->items, function ($a, $b) { return $a['timestamp'] < $b['timestamp']; }); } } ================================================ FILE: bridges/QwantzBridge.php ================================================ collectExpandableDatas(self::URI . 'rssfeed.php'); } protected function parseItem(array $item) { $item['author'] = 'Ryan North'; preg_match('/title="(.*?)"/', $item['content'], $matches); $title = $matches[1] ?? ''; $content = str_get_html(html_entity_decode($item['content'])); $comicURL = $content->find('img')[0]->{'src'}; $subject = $content->find('a')[1]->{'href'}; $subject = urldecode(substr($subject, strpos($subject, 'subject') + 8)); $p = (string)$content->find('P')[0]; $item['content'] = "{$subject}

    {$title}

    {$p}"; return $item; } public function getIcon() { return self::URI . 'favicon.ico'; } } ================================================ FILE: bridges/QwenBlogBridge.php ================================================ [ 'limit' => [ 'name' => 'Limit', 'type' => 'number', 'required' => true, 'defaultValue' => 10 ], ] ]; public function collectData() { $this->collectExpandableDatas(self::URI . 'index.xml', $this->getInput('limit')); } protected function parseItem(array $item) { $dom = getSimpleHTMLDOM($item['uri']); $content = $dom->find('div.post-content', 0); if ($content == null) { return $item; } // Fix code blocks foreach ($dom->find('pre.chroma') as $code_block) { // Somehow there are tags in
    ??
                $code_block_html = str_get_html($code_block->plaintext);
                $code = '';
                foreach ($code_block_html->find('span.line') as $line) {
                    $code .= $line->plaintext . "\n";
                }
                $code_block->outertext = '
    ' . $code . '
    '; } $item['content'] = $content; return $item; } } ================================================ FILE: bridges/QwerteeBridge.php ================================================ find('div.big-slides', 0)->find('div.big-slide') as $element) { $title = $element->find('div.index-tee', 0)->getAttribute('data-name', 0); $today = date('m/d/Y'); $item = []; $item['uri'] = self::URI; $item['title'] = $title; $item['uid'] = $title; $item['timestamp'] = $today; $item['content'] = ''; $this->items[] = $item; } } } ================================================ FILE: bridges/RadioFranceBridge.php ================================================ [ 'name' => 'Domain to use', 'required' => true, 'defaultValue' => self::DEFAULT_DOMAIN ], 'page' => [ 'name' => 'Initial page to load', 'required' => true, 'exampleValue' => 'franceinter/podcasts/burne-out' ] ]]; 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()); // An array of dom nodes $documentsList = $html->find('.DocumentsList', 0); $documentsListWrapper = $documentsList->find('.DocumentsList-wrapper', 0); $cardList = $documentsListWrapper->find('.CardMedia'); foreach ($cardList as $card) { $item = []; $title_link = $card->find('.ConceptTitle a', 0); $item['title'] = $title_link->plaintext; $uri = $title_link->getAttribute('href', 0); switch (substr($uri, 0, 1)) { case 'h': // absolute uri $item['uri'] = $uri; break; case '/': // domain relative uri $item['uri'] = $this->getDomain() . $uri; break; default: $item['uri'] = $this->getDomain() . '/' . $uri; } // Finally, obtain the mp3 from some weird Radio France API (url obtained by reading network calls, no less) $media_url = self::APIENDPOINT . '?value=' . $uri; $rawJSON = getSimpleHTMLDOMCached($media_url); $processedJSON = json_decode($rawJSON); $model_content = $processedJSON->content; if (empty($model_content->manifestations)) { error_log("Seems like $uri has no manifestation"); } else { $item['enclosures'] = [ $model_content->manifestations[0]->url ]; $item['content'] = ''; if (isset($model_content->visual)) { $item['content'] .= "visual->src}\" alt=\"{$model_content->visual->legend}\" style=\"float:left; width:400px; margin: 1em;\"/>"; } if (isset($model_content->standFirst)) { $item['content'] .= $model_content->standFirst; } if (isset($model_content->bodyJson)) { if (!empty($item['content'])) { $item['content'] .= '
    '; } $pseudo_html_array = array_map([$this, 'convertJsonElementToHTML'], $model_content->bodyJson); $pseudo_html_text = array_reduce( $pseudo_html_array, function ($text, $element) { return $text . "\n" . $element; }, '' ); $item['content'] .= $pseudo_html_text; } if (isset($model_content->producers)) { $item['author'] = $this->readAuthorsNamesFrom($model_content->producers); } elseif (isset($model_content->staff)) { $item['author'] = $this->readAuthorsNamesFrom($model_content->staff); } $time = $card->find('time', 0); $timevalue = $time->getAttribute('datetime'); $item['timestamp'] = strtotime($timevalue); $this->items[] = $item; } } } private function readAuthorsNamesFrom($persons_array) { $persons_names = array_map(function ($person_element) { return $person_element->name; }, $persons_array); return array_reduce($persons_names, function ($a, $b) { if (!empty($a)) { $a .= ', '; } return $a . $b; }, ''); } private function convertJsonElementToHTML($jsonElement) { $childText = isset($jsonElement->children) ? $this->convertJsonChildrenToHTML($jsonElement->children) : ''; $valueText = isset($jsonElement->value) ? $jsonElement->value : ''; switch ($jsonElement->type) { case 'text': return "{$childText}{$valueText}"; case 'heading': $level = $jsonElement->level; return "{$childText}{$valueText}"; case 'list': $tag = 'ul'; if (isset($jsonElement->ordered)) { if ($jsonElement->ordered) { $tag = 'ol'; } } return "<$tag>\n" . $childText . "\n"; case 'list_item': return "
  • {$childText}{$valueText}
  • \n"; case 'bounce': return ''; case 'paragraph': return "

    {$childText}{$valueText}

    \n"; case 'quote': return "
    {$childText}{$valueText}
    \n"; case 'link': return "data->href}\">{$childText}{$valueText}\n"; case 'audio': return ''; case 'embed': return $jsonElement->data->html; default: return $jsonElement->value; } } private function convertJsonChildrenToHTML($children) { $converted = array_map([$this, 'convertJsonElementToHTML'], $children); return array_reduce($converted, function ($a, $b) { return $a . $b; }, ''); } private function removeAds($element) { $ads = $element->find('AdSlot'); foreach ($ads as $ad) { $ad->remove(); } return $element; } /** * 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; } } ================================================ FILE: bridges/RadioMelodieBridge.php ================================================ find('div[class=listArticles]', 0)->children(); foreach ($list as $element) { if ($element->tag == 'a') { $articleURL = self::URI . $element->href; $article = getSimpleHTMLDOM($articleURL); $this->rewriteAudioPlayers($article); // Reload the modified content $article = str_get_html($article->save()); $textDOM = $article->find('article', 0); // Remove HTML code for the article title $textDOM->find('h1', 0)->outertext = ''; // Fix the CSS for the author $textDOM->find('div[class=author]', 0)->find('img', 0) ->setAttribute('style', 'width: 60px; margin: 0 15px; display: inline-block; vertical-align: top;'); // Initialise arrays $item = []; $audio = []; $picture = []; // Get the Main picture URL $picture[] = $article->find('figure[class*=photoviewer]', 0)->find('img', 0)->src; $audioHTML = $article->find('audio'); // Add the audio element to the enclosure foreach ($audioHTML as $audioElement) { $audioURL = $audioElement->src; $audio[] = $audioURL; } // Rewrite pictures URL $imgs = $textDOM->find('img[src^="http://www.radiomelodie.com/image.php]'); foreach ($imgs as $img) { $img->src = $this->rewriteImage($img->src); $article->save(); } // Remove Google Ads $ads = $article->find('div[class=adInline]'); foreach ($ads as $ad) { $ad->outertext = ''; $article->save(); } // Extract the author $author = $article->find('div[class=author]', 0)->children(1)->children(0)->plaintext; // Handle date to timestamp $dateHTML = $article->find('div[class=author]', 0)->children(1)->plaintext; preg_match('/([a-z]{4,10}[ ]{1,2}[0-9]{1,2} [\p{L}]{3,10} [0-9]{4} à [0-9]{2}:[0-9]{2})/mus', $dateHTML, $matches); $dateText = $matches[1]; $timestamp = $this->parseDate($dateText); $item['enclosures'] = array_merge($picture, $audio); $item['author'] = $author; $item['uri'] = $articleURL; $item['title'] = $article->find('meta[property=og:title]', 0)->content; if ($timestamp !== false) { $item['timestamp'] = $timestamp; } // Remove the share article part $textDOM->find('div[class=share]', 0)->outertext = ''; $textDOM->find('div[class=share]', 1)->outertext = ''; // Rewrite relative Links $textDOM = defaultLinkTo($textDOM, self::URI . '/'); $article->save(); $text = $textDOM->innertext; $item['content'] = '

    ' . $item['title'] . '

    ' . $dateText . '
    ' . $text; $this->items[] = $item; } } } /* * Function to rewrite image URL to use the real Image URL and not the resized one (which is very slow) */ private function rewriteImage($url) { $parts = explode('?', $url); parse_str(html_entity_decode($parts[1]), $params); return self::URI . '/' . $params['image']; } /* * Function to rewrite Audio Players to use the